import io
import os
from lxml import etree
from PIL import Image
import numpy
import typing
import copy
import logging
LOADED_ImageTk = True
if LOADED_ImageTk:
try:
from PIL import ImageTk
except:
LOADED_ImageTk = False
from ..utils.filesystem import *
from .object import Object
from ..gameobject import GameObject
from .objectpack.pack import ObjectPack
[docs]class Level(GameObject):
"""
The Level object.
Attributes:
HD (bool): Using HD graphics
TabHD (bool): Using TabHD graphics.
object_pack (ObjectPack): The ObjectPack that is being used for all the objects.
objects (list[Object]): List of Objects currently in this level.
properties (dict[str,str]:) All the Level properties.
challenges (list[Level.Challenge]): List of WMW2 challenges in this level.
room (tuple[float,float]): The room element in WMW levels. I have no idea what this does, but it still should be kept, even though it doesn't actually do anything.
"""
XML_TEMPLATE = b"""<?xml version="1.0"?>
<Objects>
</Objects>
"""
IMAGE_TEMPLATE = Image.new('P', (90,127), 'white').quantize(colors=256)
IMAGE_FORMAT = 'PNG'
def __init__(
this,
xml : str | bytes | File = None,
image : str | bytes | File = None,
filesystem : Filesystem | Folder = None,
gamepath : str = None,
assets : str = '/assets',
baseassets : str = '/',
load_callback : typing.Callable[[int, str, int], typing.Any] = None,
ignore_errors : bool = False,
HD : bool = False,
TabHD : bool = False,
object_pack : ObjectPack = None
) -> None:
"""Load level
Args:
xml (str | bytes | File): XML file for level.
image (str | bytes | File): Image file for level.
filesystem (Filesystem | Folder, optional): Filesystem to use. Defaults to None.
gamepath (str, optional): Game path. Only used if filesystem not specified. Defaults to None.
assets (str, optional): Assets path relative to game path. Only used if filesystem not specified. Defaults to '/assets'.
baseassets (str, optional): Base assets path within the assets folder, e.g. `/perry/` in wmp. Defaults to `/`
load_callback (Callable[[int, str, int], Any], optional): A callback function to be ran while loading the level. Defaults to None.
ignore_errors (bool, optional): Ignore errors while loading. Defaults to False.
HD (bool, optional): Use HD images. Defaults to False.
TabHD (bool, optional): Use TabHD images. Defaults to False.
"""
this.gamepath = gamepath
this.assets = assets
this.filename = ''
if this.assets == None:
this.assets = '/assets'
super().__init__(filesystem, gamepath, assets, baseassets)
logging.debug(f'Level: xml before: {xml}')
if isinstance(xml, File):
logging.debug(f'Level: xml path: {xml.path}')
this.xml_file = super().get_file(xml, template = this.XML_TEMPLATE)
logging.debug(f'Level: xml after: {this.xml_file}')
# try:
# logging.debug(f'Level: raw xml:\n{this.xml_file.getvalue().decode()}')
# except:
# logging.debug('Level: file not io.BytesIO')
if isinstance(this.xml_file, io.BytesIO):
this.xml_file.seek(0)
this.xml = etree.parse(this.xml_file).getroot()
this.image_file = super().get_file(image)
if this.image_file == None:
this.image = this.IMAGE_TEMPLATE.copy()
else:
this.image = Image.open(this.image_file).quantize(colors=256)
this.HD = HD
this.TabHD = TabHD
this.object_pack = object_pack
this.objects : list[Object] = []
this.properties : dict[str,str] = {}
this.challenges : list[Level.Challenge] = []
this.room = (0,0)
this.read(load_callback = load_callback, ignore_errors = ignore_errors)
this.scale = 5
@property
def size(this) -> tuple[int,int]:
"""Level image size
Returns:
tuple[int,int]: (width,height)
"""
return this._image.size
@property
def image(this) -> Image.Image:
"""Scaled up Level image
Returns:
PIL.Image.Image: PIL Image
"""
image = this._image.copy()
size = numpy.array(image.size)
size = size * this.scale
image = image.resize(size, resample = Image.NEAREST)
return image
@image.setter
def image(this, value : Image.Image):
this._image = value
@property
def PhotoImage(this) -> 'ImageTk.PhotoImage':
"""Tkinter PhotoImage of the Level image
Returns:
ImageTk.PhotoImage: Tkinter PhotoImage
"""
if LOADED_ImageTk:
this._PhotoImage = ImageTk.PhotoImage(this.image)
else:
this._PhotoImage = this.image.copy()
return this._PhotoImage
@property
def scale(this) -> int:
"""Level size scale
"""
return this._scale
@scale.setter
def scale(this, value : int):
this._scale = value
for obj in this.objects:
obj.scale = this._scale
[docs] def read(
this,
load_callback : typing.Callable[[int, str, int], typing.Any] = None,
ignore_errors : bool = False,
):
"""Read level XML
"""
this.objects : list[Object] = []
this.properties = {}
def run_callback(index, name, max):
try:
if callable(load_callback):
load_callback(index, name, max)
except:
logging.exception('run_callback error:')
id = 0
max = len(this.xml)
index = 0
for element in this.xml:
element : etree.ElementBase
try:
# comment safe-guard
if element is etree.Comment:
run_callback(index, 'Comment', max)
continue
if element.tag == 'Object':
properties = {}
pos = (0,0)
name = element.get('name')
run_callback(index, f'Object: {name}', max)
for el in element:
if not el is etree.Comment:
if el.tag == 'AbsoluteLocation':
pos = el.get('value')
elif el.tag == 'Properties':
for property in el:
if not property is etree.Comment and property.tag == 'Property':
properties[property.get('name')] = property.get('value')
obj = Object(
this.filesystem.get(properties['Filename']), # get file because `Object` does not take filepath
filesystem = this.filesystem,
properties = properties,
pos = pos,
name = name,
HD = this.HD,
TabHD = this.TabHD,
object_pack = this.object_pack,
)
obj.id = id
this.objects.append(obj)
id += 1
elif element.tag == 'Properties':
run_callback(index, 'Level Properties', max)
for el in element:
if el is etree.Comment:
continue
if el.tag == 'Property':
this.properties[el.get('name')] = el.get('value')
elif element.tag == 'Room':
run_callback(index, 'Room', max)
for el in element:
if el is etree.Comment:
continue
if el.tag == 'AbsoluteLocation':
this.room = tuple([float(_) for _ in el.get('value').split()])
elif element.tag == 'Challenges':
for challenge in element:
challenge : etree.ElementBase
if challenge is etree.Comment:
continue
if challenge.tag == 'Challenge':
this.challenges.append(this.Challenge(challenge))
elif callable(load_callback):
run_callback(index, element.tag, max)
index += 1
run_callback(index, 'Done!', max)
except:
logging.exception('level load error:')
if not ignore_errors:
raise
[docs] def export(
this,
filename : str = None,
exportObjects : bool = False,
saveImage : bool = True,
) -> bytes:
"""Export level
Args:
filename (str, optional): Path to level. Defaults to Level.filename.
exportObjects (bool, optional): Whether to export objects. Defaults to False.
Raises:
TypeError: Path is not a file.
Returns:
bytes: XML file.
"""
if filename == None:
if this.filename:
filename = this.filename
else:
this.filename = filename
xml : etree.ElementBase = etree.Element('Objects')
for object in this.objects:
if exportObjects:
object.export()
xml.append(object.getLevelXML())
room = etree.Element('Room')
etree.SubElement(room, 'AbsoluteLocation', value = ' '.join([str(_) for _ in this.room]))
properties = etree.Element('Properties')
for name in this.properties:
value = this.properties[name]
etree.SubElement(properties, 'Property', name = name, value = value)
if len(properties):
xml.append(properties)
challenges : etree._Element = etree.Element('Challenges')
for challenge in this.challenges:
challenges.append(challenge.getXML())
if len(challenges):
xml.append(challenges)
this.xml = xml
output = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding='utf-8')
if (file := this.filesystem.get(filename)) != None:
if isinstance(file, File):
file.write(output)
else:
raise TypeError(f'Path {filename} is not a file.')
else:
this.filesystem.add(filename, output)
if saveImage:
imgFile = io.BytesIO()
this._image.save(
imgFile,
format = this.IMAGE_FORMAT,
)
filename = os.path.splitext(filename)[0] + f'.{this.IMAGE_FORMAT.lower()}'
if (file := this.filesystem.get(filename)) != None:
if isinstance(file, File):
file.write(imgFile.getvalue())
else:
raise TypeError(f'Path {filename} is not a file.')
else:
this.filesystem.add(filename, imgFile.getvalue())
return output
[docs] def addObject(
this,
filename : str | Object,
properties : dict = {},
pos : tuple[float,float] = (0,0),
name : str = 'Obj'
):
"""Add object to level.
Args:
filename (str | Object): Filename for object. If it's a wmwpy.classes.Object class, then it will use that instead.
properties (dict, optional): Object properties. Defaults to {}.
pos (tuple[x,y], optional): Position of object in level. Defaults to (0,0).
name (str, optional): Name of object. May get renamed if object with name already exists. Defaults to 'Obj'.
Returns:
Object: wmwpy Object.
"""
if not isinstance(filename, Object):
filename = Object(
filename,
filesystem = this.filesystem,
properties = properties,
pos = pos,
name = name,
object_pack = this.object_pack,
)
else:
filename.name = name
filename.pos = pos
filename.setProperty(properties)
obj = filename
id = 0
while this.getObjectById(id) != None:
id += 1
if this.getObject(obj.name) != None:
objnum = 0
name = obj.name
while this.getObject(obj.name) != None:
objnum += 1
obj.name = f'{name}{str(objnum)}'
obj.id = id
this.objects.append(obj)
obj.scale = this.scale
return obj
[docs] def getObjectById(this, id : int) -> Object:
"""Get an Object by it's id
Args:
id (int): Object id to find
Returns:
Object: wmwpy Object
"""
for obj in this.objects:
if obj.id == id:
return obj
return None
[docs] def getObject(this, name : str):
"""
Get object by name
Args:
name (str): Object name.
"""
for obj in this.objects:
if obj.name == name:
return obj
[docs] class Challenge():
def __init__(
this,
xml : etree.ElementBase = None,
id : str = '',
requirements : dict[str, dict[str, str]] = {},
) -> None:
"""A level challenge used in wmw2
Args:
xml (etree.Element, optional): The xml of the challenge. If it is `None`, it will just use the other values. Defaults to None.
id (str, optional): The id of the challenge. Defaults to ''.
requirements (dict[str, dict[str, str]], optional): The requirements as a `dict`. Defaults to {}.
Requirements format:
```python
dict[str, dict[str, str]]
```
Example:
```python
{
'WindWait' : {
'seconds' : '1'
},
'Duck' : {
'count' : '2'
}
}
```
"""
this.xml = xml
this.id = id
this.requirements : dict[str, dict[str, str]] = copy.deepcopy(requirements)
if isinstance(this.xml, etree._Element):
this.readXML()
[docs] def readXML(this):
"""Read the XML of the challenge. If the XML wasn't set, it'll just return `None`
"""
if not isinstance(this.xml, etree._Element):
return
this.id = this.xml.get('id', '')
for element in this.xml:
# so I can access the attributes in vscode
element : etree.ElementBase
if element is etree.Comment:
continue
requirement = copy.deepcopy(element.attrib)
this.requirements[element.tag] = requirement
[docs] def getXML(this):
"""Get the XML for the challenge.
Returns:
lxml.etree.Element: lxml etree Element.
"""
root : etree._Element = etree.Element('Challenge', id = this.id)
for name in this.requirements:
requirement = this.requirements[name]
etree.SubElement(root, name, **requirement)
this.xml = root
return root