mima-engine 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. mima/__init__.py +4 -0
  2. mima/backend/__init__.py +1 -0
  3. mima/backend/pygame_assets.py +401 -0
  4. mima/backend/pygame_audio.py +78 -0
  5. mima/backend/pygame_backend.py +603 -0
  6. mima/backend/pygame_camera.py +63 -0
  7. mima/backend/pygame_events.py +695 -0
  8. mima/backend/touch_control_scheme_a.py +126 -0
  9. mima/backend/touch_control_scheme_b.py +132 -0
  10. mima/core/__init__.py +0 -0
  11. mima/core/collision.py +325 -0
  12. mima/core/database.py +58 -0
  13. mima/core/engine.py +367 -0
  14. mima/core/mode_engine.py +81 -0
  15. mima/core/scene_engine.py +81 -0
  16. mima/integrated/__init__.py +0 -0
  17. mima/integrated/entity.py +183 -0
  18. mima/integrated/layered_map.py +351 -0
  19. mima/integrated/sprite.py +156 -0
  20. mima/layered/__init__.py +0 -0
  21. mima/layered/assets.py +56 -0
  22. mima/layered/scene.py +415 -0
  23. mima/layered/shape.py +99 -0
  24. mima/layered/shaped_sprite.py +78 -0
  25. mima/layered/virtual_input.py +302 -0
  26. mima/maps/__init__.py +0 -0
  27. mima/maps/template.py +71 -0
  28. mima/maps/tile.py +20 -0
  29. mima/maps/tile_animation.py +7 -0
  30. mima/maps/tile_info.py +10 -0
  31. mima/maps/tile_layer.py +52 -0
  32. mima/maps/tiled/__init__.py +0 -0
  33. mima/maps/tiled/tiled_layer.py +48 -0
  34. mima/maps/tiled/tiled_map.py +95 -0
  35. mima/maps/tiled/tiled_object.py +79 -0
  36. mima/maps/tiled/tiled_objectgroup.py +25 -0
  37. mima/maps/tiled/tiled_template.py +49 -0
  38. mima/maps/tiled/tiled_tile.py +90 -0
  39. mima/maps/tiled/tiled_tileset.py +51 -0
  40. mima/maps/tilemap.py +216 -0
  41. mima/maps/tileset.py +39 -0
  42. mima/maps/tileset_info.py +9 -0
  43. mima/maps/transition_map.py +146 -0
  44. mima/objects/__init__.py +0 -0
  45. mima/objects/animated_sprite.py +217 -0
  46. mima/objects/attribute_effect.py +26 -0
  47. mima/objects/attributes.py +126 -0
  48. mima/objects/creature.py +384 -0
  49. mima/objects/dynamic.py +206 -0
  50. mima/objects/effects/__init__.py +0 -0
  51. mima/objects/effects/colorize_screen.py +60 -0
  52. mima/objects/effects/debug_box.py +133 -0
  53. mima/objects/effects/light.py +103 -0
  54. mima/objects/effects/show_sprite.py +50 -0
  55. mima/objects/effects/walking_on_grass.py +70 -0
  56. mima/objects/effects/walking_on_water.py +57 -0
  57. mima/objects/loader.py +111 -0
  58. mima/objects/projectile.py +111 -0
  59. mima/objects/sprite.py +116 -0
  60. mima/objects/world/__init__.py +0 -0
  61. mima/objects/world/color_gate.py +67 -0
  62. mima/objects/world/color_switch.py +101 -0
  63. mima/objects/world/container.py +175 -0
  64. mima/objects/world/floor_switch.py +109 -0
  65. mima/objects/world/gate.py +178 -0
  66. mima/objects/world/light_source.py +121 -0
  67. mima/objects/world/logic_gate.py +157 -0
  68. mima/objects/world/movable.py +399 -0
  69. mima/objects/world/oneway.py +195 -0
  70. mima/objects/world/pickup.py +157 -0
  71. mima/objects/world/switch.py +179 -0
  72. mima/objects/world/teleport.py +308 -0
  73. mima/py.typed +0 -0
  74. mima/scripts/__init__.py +2 -0
  75. mima/scripts/command.py +38 -0
  76. mima/scripts/commands/__init__.py +0 -0
  77. mima/scripts/commands/add_quest.py +19 -0
  78. mima/scripts/commands/change_map.py +34 -0
  79. mima/scripts/commands/close_dialog.py +9 -0
  80. mima/scripts/commands/equip_weapon.py +23 -0
  81. mima/scripts/commands/give_item.py +26 -0
  82. mima/scripts/commands/give_resource.py +51 -0
  83. mima/scripts/commands/move_map.py +152 -0
  84. mima/scripts/commands/move_to.py +49 -0
  85. mima/scripts/commands/oneway_move.py +58 -0
  86. mima/scripts/commands/parallel.py +66 -0
  87. mima/scripts/commands/play_sound.py +13 -0
  88. mima/scripts/commands/present_item.py +53 -0
  89. mima/scripts/commands/progress_quest.py +12 -0
  90. mima/scripts/commands/quit_game.py +8 -0
  91. mima/scripts/commands/save_game.py +14 -0
  92. mima/scripts/commands/screen_fade.py +83 -0
  93. mima/scripts/commands/serial.py +69 -0
  94. mima/scripts/commands/set_facing_direction.py +21 -0
  95. mima/scripts/commands/set_spawn_map.py +17 -0
  96. mima/scripts/commands/show_choices.py +52 -0
  97. mima/scripts/commands/show_dialog.py +118 -0
  98. mima/scripts/commands/take_coins.py +23 -0
  99. mima/scripts/script_processor.py +61 -0
  100. mima/standalone/__init__.py +0 -0
  101. mima/standalone/camera.py +153 -0
  102. mima/standalone/geometry.py +1318 -0
  103. mima/standalone/multicolumn_list.py +54 -0
  104. mima/standalone/pixel_font.py +84 -0
  105. mima/standalone/scripting.py +145 -0
  106. mima/standalone/spatial.py +186 -0
  107. mima/standalone/sprite.py +158 -0
  108. mima/standalone/tiled_map.py +1247 -0
  109. mima/standalone/transformed_view.py +433 -0
  110. mima/standalone/user_input.py +563 -0
  111. mima/states/__init__.py +0 -0
  112. mima/states/game_state.py +189 -0
  113. mima/states/memory.py +28 -0
  114. mima/states/quest.py +71 -0
  115. mima/types/__init__.py +0 -0
  116. mima/types/alignment.py +7 -0
  117. mima/types/blend.py +8 -0
  118. mima/types/damage.py +42 -0
  119. mima/types/direction.py +44 -0
  120. mima/types/gate_color.py +7 -0
  121. mima/types/graphic_state.py +23 -0
  122. mima/types/keys.py +64 -0
  123. mima/types/mode.py +9 -0
  124. mima/types/nature.py +12 -0
  125. mima/types/object.py +22 -0
  126. mima/types/player.py +9 -0
  127. mima/types/position.py +13 -0
  128. mima/types/start.py +7 -0
  129. mima/types/terrain.py +9 -0
  130. mima/types/tile_collision.py +11 -0
  131. mima/types/weapon_slot.py +6 -0
  132. mima/types/window.py +44 -0
  133. mima/usables/__init__.py +0 -0
  134. mima/usables/item.py +51 -0
  135. mima/usables/weapon.py +68 -0
  136. mima/util/__init__.py +1 -0
  137. mima/util/colors.py +50 -0
  138. mima/util/constants.py +55 -0
  139. mima/util/functions.py +38 -0
  140. mima/util/input_defaults.py +170 -0
  141. mima/util/logging.py +51 -0
  142. mima/util/property.py +8 -0
  143. mima/util/runtime_config.py +327 -0
  144. mima/util/trading_item.py +23 -0
  145. mima/view/__init__.py +0 -0
  146. mima/view/camera.py +192 -0
  147. mima/view/mima_mode.py +618 -0
  148. mima/view/mima_scene.py +231 -0
  149. mima/view/mima_view.py +12 -0
  150. mima/view/mima_window.py +244 -0
  151. mima_engine-0.4.0.dist-info/METADATA +47 -0
  152. mima_engine-0.4.0.dist-info/RECORD +153 -0
  153. mima_engine-0.4.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,1247 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import logging
5
+ import math
6
+ from dataclasses import dataclass
7
+ from io import StringIO
8
+ from pathlib import Path
9
+ from xml.etree import ElementTree
10
+ from xml.etree.ElementTree import Element
11
+
12
+ import pygame
13
+ from pygame import Vector2
14
+ from typing_extensions import (
15
+ TYPE_CHECKING,
16
+ Any,
17
+ Protocol,
18
+ Self,
19
+ TypeAlias,
20
+ Union,
21
+ overload,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from pygame import Surface
26
+
27
+
28
+ LOG = logging.getLogger(__name__)
29
+
30
+ Property: TypeAlias = str | int | float | bool
31
+ TileCache: TypeAlias = dict[tuple[int, tuple[int, int]], tuple[int, int]]
32
+ CollisionBoxType: TypeAlias = dict[str, str | float | Vector2]
33
+ TileAnimationType: TypeAlias = dict[
34
+ str, Union[int, float, tuple[int, int], "Surface", list[CollisionBoxType]]
35
+ ]
36
+ VZERO = Vector2()
37
+
38
+
39
+ @dataclass
40
+ class _CollisionBox:
41
+ shape: str
42
+ pos: Vector2
43
+ radius: float
44
+ size: Vector2
45
+
46
+ def as_dict(self) -> CollisionBoxType:
47
+ return {
48
+ "shape": self.shape,
49
+ "pos": self.pos,
50
+ "size": self.size,
51
+ "radius": self.radius,
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class _TileAnimation:
57
+ frame_id: int
58
+ duration: float
59
+ offset: tuple[int, int]
60
+ size: tuple[int, int]
61
+ image: pygame.Surface
62
+ collision_boxes: list[_CollisionBox]
63
+
64
+ def as_dict(self) -> TileAnimationType:
65
+ return {
66
+ "frame_id": self.frame_id,
67
+ "duration": self.duration,
68
+ "offset": self.offset,
69
+ "size": self.size,
70
+ "image": self.image,
71
+ "collision": [cb.as_dict() for cb in self.collision_boxes],
72
+ }
73
+
74
+
75
+ @dataclass
76
+ class _TilesetInfo:
77
+ tileset: TiledTileset
78
+ first_gid: int
79
+
80
+
81
+ @dataclass
82
+ class _TileInfo:
83
+ tileset: TiledTileset
84
+ tile: TiledTile
85
+
86
+
87
+ class Renderer(Protocol):
88
+ def draw_surface(
89
+ self,
90
+ pos: Vector2,
91
+ surf: pygame.Surface,
92
+ *,
93
+ src_pos: Vector2 | None = None,
94
+ src_size: Vector2 | None = None,
95
+ scale: float = 1.0,
96
+ angle: float = 0,
97
+ cache: bool = False,
98
+ special_flags: int = 0,
99
+ ) -> None: ...
100
+
101
+ def get_tl_tile(self) -> Vector2: ...
102
+
103
+ def get_br_tile(self) -> Vector2: ...
104
+
105
+
106
+ class _TiledBase:
107
+ def __init__(self, mapman: MapManager, name: str = "Unnamed Object") -> None:
108
+ self.mapman = mapman
109
+ self.name = name
110
+ self.props: dict[str, Property] = {}
111
+
112
+ @overload
113
+ def get(self, key: str, default: bool) -> bool: ...
114
+ @overload
115
+ def get(self, key: str, default: str) -> str: ...
116
+ @overload
117
+ def get(self, key: str, default: int) -> int: ...
118
+ @overload
119
+ def get(self, key: str, default: float) -> float: ...
120
+ @overload
121
+ def get(self, key: str, default: None) -> Property: ...
122
+
123
+ def get(self, key: str, default: Property | None = None) -> Property | None:
124
+ return self.props.get(key, default)
125
+
126
+
127
+ class TiledTile(_TiledBase):
128
+ def __init__(
129
+ self,
130
+ mapman: MapManager,
131
+ image: pygame.Surface,
132
+ width: int = 0,
133
+ height: int = 0,
134
+ ts_cols: int = 0,
135
+ ) -> None:
136
+ super().__init__(mapman, "TiledTile")
137
+
138
+ self.image = image
139
+ self.size: tuple[int, int] = (width, height)
140
+ self.ts_cols: int = ts_cols
141
+ self.basic_tile_id: int = 0
142
+ self.current_tile_id: int = 0
143
+ self.animated: bool = False
144
+ self._tile_type: str = "tile"
145
+ self.frames: list[_TileAnimation]
146
+ self._frame: int = 0
147
+ self._n_frames: int = 0
148
+ self._frame_timer: float = 0.0
149
+ self._collision_objects: list[_CollisionBox] = []
150
+ self._groups: list[_TiledObjectGroup] = []
151
+ self.z_height: float = 0.01
152
+
153
+ def load_from_xml(self, t_xtree: Element, path: Path) -> Self:
154
+ self._tile_type = t_xtree.attrib.get(
155
+ "class", t_xtree.attrib.get("type", "tile")
156
+ )
157
+ self.basic_tile_id = int(t_xtree.attrib["id"])
158
+
159
+ self._groups = [
160
+ _TiledObjectGroup(self.mapman).load_from_xml(og, path)
161
+ for og in t_xtree.findall("objectgroup")
162
+ ]
163
+
164
+ tile_size = Vector2(self.size)
165
+ for group in self._groups:
166
+ for obj in group._objects:
167
+ pos = obj.pos.elementwise() / tile_size.elementwise()
168
+ size = obj.size.elementwise() / tile_size.elementwise()
169
+ if obj.type == "circle":
170
+ shape = "circle"
171
+ radius = obj.size.x / tile_size.x / 2.0
172
+ pos = pos.elementwise() + radius
173
+ else:
174
+ shape = "rect"
175
+ radius = 0
176
+ self._collision_objects.append(
177
+ _CollisionBox(shape=shape, pos=pos, radius=radius, size=size)
178
+ )
179
+
180
+ def pos_from_id(tile_id: int) -> tuple[int, int]:
181
+ sx = (tile_id % self.ts_cols) * self.size[0]
182
+ sy = (tile_id // self.ts_cols) * self.size[1]
183
+ return int(sx), int(sy)
184
+
185
+ animation = t_xtree.findall("animation")
186
+
187
+ if animation:
188
+ frames = animation[0].findall("frame")
189
+ self.frames = [
190
+ _TileAnimation(
191
+ frame_id=int(f.attrib["tileid"]),
192
+ duration=int(f.attrib["duration"]) / 1000.0,
193
+ offset=pos_from_id(int(f.attrib["tileid"])),
194
+ size=self.size,
195
+ image=self.image,
196
+ collision_boxes=(
197
+ self._collision_objects
198
+ if int(f.attrib["tileid"]) == self.basic_tile_id
199
+ else []
200
+ ),
201
+ )
202
+ for f in frames
203
+ ]
204
+ self.animated = True
205
+ else:
206
+ self.frames = [
207
+ _TileAnimation(
208
+ frame_id=self.basic_tile_id,
209
+ duration=0.0,
210
+ offset=pos_from_id(self.basic_tile_id),
211
+ size=self.size,
212
+ image=self.image,
213
+ collision_boxes=self._collision_objects,
214
+ )
215
+ ]
216
+
217
+ self._n_frames = len(self.frames)
218
+ self.current_tile_id = self.frames[0].frame_id
219
+ self._frame_timer = self.frames[0].duration
220
+
221
+ self.props = read_properties_to_dict(t_xtree)
222
+ self.z_height = self.get("z_height", 0.01)
223
+ return self
224
+
225
+ def update(self, elapsed_time: float) -> bool:
226
+ """Update tile and return True on new frame."""
227
+ if self._n_frames <= 1:
228
+ return False
229
+
230
+ self._frame_timer -= elapsed_time
231
+ if self._frame_timer <= 0:
232
+ self._frame = (self._frame + 1) % self._n_frames
233
+ self.current_tile_id = self.frames[self._frame].frame_id
234
+ self._frame_timer += self.frames[self._frame].duration
235
+ return True
236
+
237
+ return False
238
+
239
+ def current_offset(self):
240
+ return self.frames[self._frame].offset
241
+
242
+ def get_frame(self) -> _TileAnimation:
243
+ return self.frames[self._frame]
244
+
245
+ def get_collision_boxes(self) -> list[_CollisionBox]:
246
+ frame_cb = self.frames[self._frame].collision_boxes
247
+ if frame_cb:
248
+ return frame_cb
249
+ return self._collision_objects
250
+
251
+ def reset(self) -> None:
252
+ self._frame = 0
253
+ self._frame_timer = self.frames[self._frame].duration
254
+ self.current_tile_id = self.frames[self._frame].frame_id
255
+
256
+
257
+ class TiledTemplate(_TiledBase):
258
+ def __init__(self, mapman: MapManager, name: str) -> None:
259
+ super().__init__(mapman, "TiledTemplate")
260
+ self.name = name
261
+ self.tileset: TiledTileset
262
+ self.first_gid: int = 0
263
+ self.template_name: str = "Unnamed"
264
+ self.template_type: str = "Untyped"
265
+ self.gid: int = 0
266
+ self.size: Vector2 = Vector2()
267
+
268
+ def load_from_path(self, path: Path) -> Self:
269
+ path = Path(path)
270
+ t_xtree = ElementTree.parse(path).getroot()
271
+ tileset = t_xtree.findall("tileset")[0]
272
+ self.tileset = self.mapman.get_tileset(
273
+ (path.parent / tileset.attrib["source"]).resolve()
274
+ )
275
+ self.first_gid = int(tileset.attrib["firstgid"])
276
+
277
+ obj = t_xtree.findall("object")[0]
278
+ self.template_name = obj.attrib.get("name", "Unnamed")
279
+ self.template_type = obj.attrib.get("type", obj.attrib.get("class", "Untyped"))
280
+ self.gid = int(obj.attrib["gid"])
281
+ self.size = Vector2(int(obj.attrib["width"]), int(obj.attrib["height"]))
282
+
283
+ self.props = read_properties_to_dict(obj)
284
+
285
+ return self
286
+
287
+
288
+ class TiledObject(_TiledBase):
289
+ def __init__(self, mapman: MapManager) -> None:
290
+ super().__init__(mapman, "TiledObject")
291
+
292
+ self.object_id: int = 0
293
+ self.type: str = ""
294
+ self.pos: Vector2 = Vector2()
295
+ self.size: Vector2 = Vector2()
296
+ self.tileset: TiledTileset | None = None
297
+ self.gid: int = 0
298
+
299
+ def load_from_xml(self, o_xtree: Element, path: Path) -> Self:
300
+ self.object_id = int(o_xtree.attrib["id"])
301
+ self.pos = Vector2(float(o_xtree.attrib["x"]), float(o_xtree.attrib["y"]))
302
+
303
+ tsource = o_xtree.attrib.get("template", "")
304
+ if tsource:
305
+ tpl = self.mapman.get_template((path.parent / tsource).resolve())
306
+ self.name = tpl.template_name
307
+ self.type = tpl.template_type
308
+ self.size = tpl.size
309
+ self.tileset = tpl.tileset
310
+ self.gid = tpl.gid - tpl.first_gid
311
+ self.props = tpl.props.copy()
312
+ # Templates' y positions are bottom instead of top
313
+ self.pos.y -= self.size.y
314
+ else:
315
+ self.size = Vector2(
316
+ float(o_xtree.attrib.get("width", 0.0)),
317
+ float(o_xtree.attrib.get("height", 0.0)),
318
+ )
319
+
320
+ self.name = o_xtree.attrib.get("name", self.name)
321
+ self.type = o_xtree.attrib.get("type", o_xtree.attrib.get("class", self.type))
322
+ self.props.update(read_properties_to_dict(o_xtree))
323
+ return self
324
+
325
+
326
+ class _TiledObjectGroup(_TiledBase):
327
+ def __init__(self, mapman: MapManager):
328
+ super().__init__(mapman, "TiledObjectGroup")
329
+
330
+ self._layer_id: int = 0
331
+ self._objects: list[TiledObject] = []
332
+
333
+ def load_from_xml(self, o_xtree: Element, path: Path) -> Self:
334
+ self._layer_id = int(o_xtree.attrib["id"])
335
+ objects = o_xtree.findall("object")
336
+ for obj in objects:
337
+ self._objects.append(TiledObject(self.mapman).load_from_xml(obj, path))
338
+
339
+ return self
340
+
341
+
342
+ class TiledTileset:
343
+ """Represents a Tiled tileset normally stored in a .tsx file."""
344
+
345
+ def __init__(self, mapman: MapManager, name: str = "") -> None:
346
+ self._mapman = mapman
347
+ self.name: str = name
348
+ self.image: pygame.Surface = pygame.Surface((0, 0))
349
+ self._image_width: int = 0
350
+ self._image_height: int = 0
351
+ self._tile_width: int = 0
352
+ self._tile_height: int = 0
353
+ self._tile_count: int = 0
354
+ self.n_cols: int = 0
355
+
356
+ self.tiles: dict[int, TiledTile] = {}
357
+ self._anim_tiles: list[TiledTile] = []
358
+ self._is_new_frame: bool = False
359
+
360
+ def load_from_path(self, path: str | Path) -> Self:
361
+ """Load tileset information from tsx file.
362
+
363
+ Args:
364
+ path: File path to the tsx file to load.
365
+
366
+ Returns:
367
+ A reference to this object.
368
+
369
+ """
370
+ path = Path(path)
371
+ t_xtree = ElementTree.parse(path).getroot()
372
+
373
+ self._tile_width = int(t_xtree.attrib["tilewidth"])
374
+ self._tile_height = int(t_xtree.attrib["tileheight"])
375
+ self._tile_count = int(t_xtree.attrib["tilecount"])
376
+ self.n_cols = int(t_xtree.attrib["columns"])
377
+
378
+ image = t_xtree.findall("image")[0]
379
+ image_path = path.parent / image.attrib["source"]
380
+ self._image_width = int(image.attrib["width"])
381
+ self._image_height = int(image.attrib["height"])
382
+ self.image = self._mapman.get_image(image_path)
383
+
384
+ tiles = t_xtree.findall("tile")
385
+
386
+ for tile in tiles:
387
+ t_obj = TiledTile(
388
+ self._mapman,
389
+ self.image,
390
+ self._tile_width,
391
+ self._tile_height,
392
+ self.n_cols,
393
+ ).load_from_xml(tile, path)
394
+ self.tiles[t_obj.basic_tile_id] = t_obj
395
+ if t_obj.animated:
396
+ self._anim_tiles.append(t_obj)
397
+
398
+ return self
399
+
400
+ def update(self, elapsed_time: float) -> bool:
401
+ """Update all animated tiles in this tileset.
402
+
403
+ Args:
404
+ elapsed_time: How many seconds to progress the simulation.
405
+
406
+ Returns:
407
+ True if any of the tiles reached a new state.
408
+ """
409
+ if self._is_new_frame:
410
+ changed = False
411
+ for tile in self._anim_tiles:
412
+ changed = tile.update(elapsed_time) or changed
413
+ self._is_new_frame = False
414
+ return changed
415
+
416
+ return False
417
+
418
+ def get_tile(self, tile_id: int) -> TiledTile:
419
+ """Return tile at index ``tile_id``."""
420
+ return self.tiles[tile_id]
421
+
422
+ def start_new_frame(self):
423
+ """Notify tileset about a new frame starting.
424
+
425
+ Tilesets should not update more than once per frame, which can happen if
426
+ multiple maps with the same tileset are active at the same time.
427
+ Tilesets will only update if a new frame was triggered manually.
428
+ """
429
+ self._is_new_frame = True
430
+
431
+ def reset(self) -> None:
432
+ """Reset the animation state of all animated tiles."""
433
+ for tile in self._anim_tiles:
434
+ tile.reset()
435
+
436
+
437
+ class TiledLayerRenderable:
438
+ def __init__(
439
+ self,
440
+ image: pygame.Surface,
441
+ tile_size: Vector2,
442
+ layer: int = 0,
443
+ elevation: int = 0,
444
+ ) -> None:
445
+ self.image = image
446
+ self.layer = layer
447
+ self.tile_size = tile_size
448
+ self.elevation = elevation
449
+ self.duration: float = 1.0
450
+ self.pos: Vector2 = Vector2()
451
+ self.parallax: Vector2 = Vector2(1.0, 1.0)
452
+ self.wrap_x: bool = False
453
+ self.wrap_y: bool = False
454
+ self.wrap_margin: float = 0 # Does not yet work as intended
455
+
456
+ def draw(self, ttv: Renderer, cache: bool = False) -> None:
457
+ # No wrapping; render directly
458
+ view_pos = ttv.get_tl_tile()
459
+ base_pos = self.pos + view_pos.elementwise() * (
460
+ 1.0 - self.parallax.elementwise()
461
+ )
462
+ if not self.wrap_x and not self.wrap_y:
463
+ ttv.draw_surface(base_pos, self.image, cache=cache)
464
+ return
465
+
466
+ img_size = Vector2(self.image.get_size()).elementwise() / self.tile_size
467
+
468
+ view_size = ttv.get_br_tile() - view_pos
469
+
470
+ # Normalize start so we begin drawing off-screen
471
+ start_x = base_pos.x
472
+ start_y = base_pos.y
473
+ end_x = view_pos.x + view_size.x + self.wrap_margin
474
+ end_y = view_pos.y + view_size.y + self.wrap_margin
475
+
476
+ if self.wrap_x:
477
+ start_x -= (
478
+ (start_x - view_pos.x) // img_size.x + 1
479
+ ) * img_size.x - self.wrap_margin
480
+ end_x += self.wrap_margin
481
+ if self.wrap_y:
482
+ start_y -= (
483
+ (start_y - view_pos.y) // img_size.y + 1
484
+ ) * img_size.y - self.wrap_margin
485
+ end_y += self.wrap_margin
486
+
487
+ y = start_y
488
+ while y < end_y:
489
+ x = start_x
490
+ while x < end_x:
491
+ ttv.draw_surface(Vector2(x, y), self.image, cache=cache)
492
+ if not self.wrap_x:
493
+ break
494
+ x += img_size.x
495
+ if not self.wrap_y:
496
+ break
497
+ y += img_size.y
498
+
499
+ def get_pos(self) -> Vector2:
500
+ return self.pos
501
+
502
+
503
+ class _TiledLayer(_TiledBase):
504
+ def __init__(self, mapman: MapManager) -> None:
505
+ super().__init__(mapman, "Unnamed Layer")
506
+ self._layer_id: int = 0
507
+ self._width: int = 0
508
+ self._height: int = 0
509
+ self._layer_type: str = "layer"
510
+ self.offset: Vector2 = Vector2(0, 0)
511
+ self.speed: Vector2 = Vector2(0, 0)
512
+ self.elevation: int = 0
513
+ self.is_wall_layer: bool = False
514
+ self._is_prerendered: bool = False
515
+ self._prerendered_frames: list[TiledLayerRenderable] = []
516
+ self._durations: list[float] = []
517
+ self._timer: float = 0.25
518
+ self._frame: int = 0
519
+
520
+ self._indices: list[int] = []
521
+
522
+ def load_from_xml(self, l_xtree: Element) -> Self:
523
+ self._name = l_xtree.attrib["name"]
524
+ self._layer_id = int(l_xtree.attrib["id"])
525
+ self._layer_type = l_xtree.attrib.get(
526
+ "class", l_xtree.attrib.get("type", "Layer")
527
+ )
528
+ self._width = int(l_xtree.attrib["width"])
529
+ self._height = int(l_xtree.attrib["height"])
530
+
531
+ data = l_xtree.findall("data")[0]
532
+ reader = csv.reader(StringIO(data.text), delimiter=",")
533
+ for row in reader:
534
+ if len(row) <= 0:
535
+ continue
536
+ self._indices.extend([int(c) for c in row if c])
537
+
538
+ self.props = read_properties_to_dict(l_xtree)
539
+ self.elevation = self.get("elevation", 0)
540
+ self.is_wall_layer = self.get("is_wall_layer", False)
541
+ self.speed = Vector2(self.get("speed_x", 0.0), self.get("speed_y", 0.0))
542
+ return self
543
+
544
+ def update(self, elapsed_time: float) -> bool:
545
+ if self._is_prerendered:
546
+ self._timer -= elapsed_time
547
+ if self._timer <= 0.0:
548
+ self._frame = (self._frame + 1) % len(self._prerendered_frames)
549
+ self._timer += self._durations[self._frame]
550
+
551
+ if self.speed == 0.0:
552
+ return False
553
+
554
+ self.offset += self.speed * elapsed_time
555
+ if self.offset.x > self._width:
556
+ self.offset.x -= self._width
557
+ if self.offset.x < 0:
558
+ self.offset.x += self._width
559
+ if self.offset.y > self._height:
560
+ self.offset.y -= self._height
561
+ if self.offset.y < 0:
562
+ self.offset.y += self._height
563
+
564
+ return True
565
+
566
+ def get_index(self, px: int, py: int) -> int:
567
+ if self.offset.x != 0.0:
568
+ px = math.floor(px - self.offset.x)
569
+ if px > self._width:
570
+ px -= self._width
571
+ while px < 0:
572
+ px += self._width
573
+ if self.offset.y != 0.0:
574
+ py = math.floor(py - self.offset.y)
575
+ if py > self._height:
576
+ py -= self._height
577
+ while py < 0:
578
+ py += self._height
579
+
580
+ if 0 <= px < self._width and 0 <= py < self._height:
581
+ return self._indices[py * self._width + px]
582
+ else:
583
+ return 0
584
+
585
+ def get_all_indices(self) -> list[int]:
586
+ return self._indices
587
+
588
+ def add_prerendered_frame(self, renderable: TiledLayerRenderable) -> None:
589
+ self._prerendered_frames.append(renderable)
590
+ self._durations.append(renderable.duration)
591
+
592
+ def get_pre_rendered_frame(self) -> TiledLayerRenderable | None:
593
+ if not self._is_prerendered:
594
+ return None
595
+ frame = self._prerendered_frames[self._frame]
596
+ frame.pos = Vector2() + self.offset
597
+ return frame
598
+
599
+
600
+ class TiledTilemap:
601
+ """Represents an orthogonal Tiled tilemap."""
602
+
603
+ def __init__(self, mapman: MapManager, name: str = "") -> None:
604
+ self._mapman: MapManager = mapman
605
+ self.name = name
606
+
607
+ self._width: int = 0
608
+ self._height: int = 0
609
+ self._tile_width = 1
610
+ self._tile_height = 1
611
+ self.world_size: Vector2 = Vector2(0, 0)
612
+ self.tile_size: Vector2 = Vector2(0, 0)
613
+
614
+ self._layers: list[_TiledLayer] = []
615
+ self._floor_layer_map: dict[int, list[_TiledLayer]] = {}
616
+ self._wall_layers: list[_TiledLayer] = []
617
+ self._decor_layers: list[_TiledLayer] = []
618
+ self._tilesets: list[_TilesetInfo] = []
619
+ self._groups: list[_TiledObjectGroup] = []
620
+ self._cache: dict[int, _TileInfo] = {}
621
+ self._tile_source_cache: TileCache = {}
622
+ self.debug_draws: list[dict[str, Any]] = []
623
+ self.br = Vector2()
624
+ self.tl = Vector2()
625
+
626
+ # Prerendering
627
+ self._is_pre_rendered: bool = False
628
+ self._rendered_layers: dict[int, list[tuple[pygame.Surface, float]]] = {}
629
+
630
+ def load_from_path(self, path: str | Path) -> None:
631
+ path = Path(path).resolve()
632
+ m_xtree = ElementTree.parse(path).getroot()
633
+
634
+ self._width = int(m_xtree.attrib["width"])
635
+ self._height = int(m_xtree.attrib["height"])
636
+ self.world_size = Vector2(self._width, self._height)
637
+ self._tile_width = int(m_xtree.attrib["tilewidth"])
638
+ self._tile_height = int(m_xtree.attrib["tileheight"])
639
+ self.tile_size = Vector2(self._tile_width, self._tile_height)
640
+
641
+ self._props = read_properties_to_dict(m_xtree)
642
+
643
+ # TODO allow to load embedded tilesets
644
+ self._tilesets = [
645
+ _TilesetInfo(
646
+ tileset=self._mapman.get_tileset(
647
+ (path.parent / ts.attrib["source"]).resolve()
648
+ ),
649
+ first_gid=int(ts.attrib["firstgid"]),
650
+ )
651
+ for ts in m_xtree.findall("tileset")
652
+ ]
653
+
654
+ self._layers = [
655
+ _TiledLayer(self._mapman).load_from_xml(layer)
656
+ for layer in m_xtree.findall("layer")
657
+ ]
658
+
659
+ for layer in self._layers:
660
+ if layer.is_wall_layer:
661
+ self._wall_layers.append(layer)
662
+ else:
663
+ self._floor_layer_map.setdefault(layer.elevation, []).append(layer)
664
+
665
+ self._groups = [
666
+ _TiledObjectGroup(self._mapman).load_from_xml(og, path)
667
+ for og in m_xtree.findall("objectgroup")
668
+ ]
669
+
670
+ def update(self, elapsed_time: float) -> bool:
671
+ changed = True
672
+ for info in self._tilesets:
673
+ changed = info.tileset.update(elapsed_time) and changed
674
+
675
+ for layer in self._layers:
676
+ changed = layer.update(elapsed_time) and changed
677
+
678
+ return changed
679
+
680
+ def draw(self, ttv: Renderer, layers: list[int] | None = None) -> None:
681
+ tl = vmax(ttv.get_tl_tile(), VZERO)
682
+ br = vmin(ttv.get_br_tile(), self.world_size)
683
+ self.tl = tl
684
+ self.br = br
685
+ if layers is None or not layers:
686
+ layers = [x for x in range(len(self._layers))]
687
+
688
+ self.debug_draws = []
689
+
690
+ for lid in layers:
691
+ if lid >= len(self._layers):
692
+ continue
693
+ layer = self._layers[lid]
694
+
695
+ for y in range(int(tl.y), int(br.y) + 1):
696
+ for x in range(int(tl.x), int(br.x) + 1):
697
+ tid = layer.get_index(x, y)
698
+ if tid <= 0:
699
+ continue
700
+
701
+ info = self._cache.get(tid)
702
+ if info is None:
703
+ if not self._load_to_cache(tid):
704
+ continue
705
+ info = self._cache[tid]
706
+
707
+ tile_position = Vector2(x, y)
708
+ src_pos = Vector2(info.tile.current_offset())
709
+
710
+ ttv.draw_surface(
711
+ tile_position,
712
+ info.tileset.image,
713
+ src_pos=src_pos,
714
+ src_size=self.tile_size,
715
+ )
716
+
717
+ for obj in info.tile.get_collision_boxes():
718
+ self.debug_draws.append(
719
+ {
720
+ "is_circle": obj.shape == "circle",
721
+ "pos": obj.pos + tile_position,
722
+ "size": obj.size,
723
+ "radius": obj.radius,
724
+ }
725
+ )
726
+
727
+ return
728
+
729
+ def draw_to_surface(
730
+ self,
731
+ surf: pygame.Surface,
732
+ offset: Vector2,
733
+ visible_tiles_tl: Vector2 | None = None,
734
+ visible_tiles_br: Vector2 | None = None,
735
+ special_flags: int = 0,
736
+ ) -> None:
737
+ visible_tiles_tl = (
738
+ Vector2(0, 0) if visible_tiles_tl is None else visible_tiles_tl
739
+ )
740
+ visible_tiles_br = (
741
+ Vector2(surf.get_size()) if visible_tiles_br is None else visible_tiles_br
742
+ )
743
+
744
+ # Get offsets for smooth movement
745
+ tile_offset = offset - vfloor(offset)
746
+ tile_offset.x *= self._tile_width
747
+ tile_offset.y *= self._tile_height
748
+
749
+ for layer in self._layers:
750
+ layer_offset = layer.offset - vfloor(layer.offset)
751
+ layer_offset.x *= self._tile_width
752
+ layer_offset.y *= self._tile_height
753
+
754
+ layer_vtiles_sx = int(visible_tiles_tl.x)
755
+ layer_vtiles_sy = int(visible_tiles_tl.y)
756
+ if layer.speed.x != 0.0:
757
+ layer_vtiles_sx -= 1
758
+ if layer.speed.y != 0.0:
759
+ layer_vtiles_sy -= 1
760
+
761
+ # Draw visible tiles of the map
762
+ for y in range(layer_vtiles_sy, int(visible_tiles_br.y) + 2):
763
+ for x in range(layer_vtiles_sx, int(visible_tiles_br.x) + 2):
764
+ tid = layer.get_index(
765
+ int(x + math.floor(offset.x)), int(y + math.floor(offset.y))
766
+ )
767
+ if tid <= 0:
768
+ # Zero means the tile was not set in Tiled
769
+ continue
770
+ if tid not in self._cache:
771
+ if not self._load_to_cache(tid):
772
+ continue
773
+ info = self._cache[tid]
774
+ sx = int(
775
+ (info.tile.current_tile_id % info.tileset.n_cols)
776
+ * self._tile_width
777
+ )
778
+ sy = int(
779
+ (info.tile.current_tile_id // info.tileset.n_cols)
780
+ * self._tile_height
781
+ )
782
+
783
+ px = int(x * self._tile_width - tile_offset.x + layer_offset.x)
784
+ py = int(y * self._tile_height - tile_offset.y + layer_offset.y)
785
+
786
+ surf.blit(
787
+ info.tileset.image,
788
+ (px, py),
789
+ ((sx, sy), (self._tile_width, self._tile_height)),
790
+ special_flags,
791
+ )
792
+
793
+ def get_rendered_layers(self) -> list[TiledLayerRenderable]:
794
+ layers = [
795
+ layer_frame
796
+ for layer in self._layers
797
+ if (layer_frame := layer.get_pre_rendered_frame()) is not None
798
+ ]
799
+
800
+ return layers
801
+
802
+ def _load_to_cache(self, tid: int) -> bool:
803
+ tileset: TiledTileset | None = None
804
+ first_gid: int = 0
805
+ for tsinfo in self._tilesets:
806
+ if tid < tsinfo.first_gid:
807
+ break
808
+
809
+ first_gid = tsinfo.first_gid
810
+ tileset = tsinfo.tileset
811
+
812
+ if tileset is None:
813
+ return False
814
+
815
+ tidx = tid - first_gid
816
+ tile = tileset.get_tile(tidx)
817
+ self._cache[tid] = _TileInfo(tileset=tileset, tile=tile)
818
+ return True
819
+
820
+ def start_new_frame(self) -> None:
821
+ for tsinfo in self._tilesets:
822
+ tsinfo.tileset.start_new_frame()
823
+
824
+ def get_tiles(self, x: int, y: int, z: int = 0) -> list[TiledTile]:
825
+ tiles = []
826
+ for layer in self._floor_layer_map.get(z, []):
827
+ tile = self._collect_tile(layer, x, y)
828
+ if tile is None:
829
+ continue
830
+ tiles.append(tile)
831
+
832
+ for layer in self._wall_layers:
833
+ if layer.elevation <= z:
834
+ tile = self._collect_tile(layer, x, y)
835
+ if tile is None:
836
+ continue
837
+ if z < layer.elevation + tile.z_height:
838
+ tiles.append(tile)
839
+ return tiles
840
+
841
+ def get_tiles_in_area(
842
+ self, pos: Vector2, area: Vector2, z: int = 0
843
+ ) -> list[TiledTile]:
844
+ tiles = []
845
+ tl = vfloor(pos)
846
+ br = tl + vfloor(area.elementwise() + 1)
847
+
848
+ for y in range(int(tl.y), int(br.y)):
849
+ for x in range(int(tl.x), int(br.x)):
850
+ tiles.extend(self.get_tiles(x, y, z))
851
+
852
+ return tiles
853
+
854
+ def _collect_tile(self, layer: _TiledLayer, x: int, y: int) -> TiledTile | None:
855
+ tid = layer.get_index(x, y)
856
+ if tid <= 0:
857
+ return None
858
+
859
+ info = self._cache.get(tid)
860
+ if info is None:
861
+ if not self._load_to_cache(tid):
862
+ return None
863
+ info = self._cache[tid]
864
+
865
+ return info.tile
866
+
867
+ def get_objects(self) -> list[TiledObject]:
868
+ all_objects = []
869
+ for group in self._groups:
870
+ all_objects.extend(group._objects)
871
+
872
+ return all_objects
873
+
874
+ def prerender_layers(self) -> None:
875
+ """Compute possible animation states for all layers.
876
+
877
+ Prerendered means that the tile layers will be drawn to a surface once.
878
+ During the simulation, the full layer image will be drawn instead of
879
+ each tile individually. Normally, this results in a noticable speed-up.
880
+ However, when using animated tiles, those animations have to be pre-
881
+ computed as well.
882
+
883
+ The current implementation will find every possible combination of tile
884
+ animations and render an image for that combination. This works well and
885
+ accurate if the frame times of the animated tiles are equal or multiples
886
+ of each other. Otherwise, the number of frame to precompute will grow
887
+ exponentially.
888
+
889
+ """
890
+ for layer in self._layers:
891
+ layer._is_prerendered = True
892
+ indices = layer.get_all_indices()
893
+
894
+ data = build_frame_table(self, layer)
895
+ schedule = build_prerender_schedule(data)
896
+
897
+ for idx in range(len(schedule)):
898
+ renderable = TiledLayerRenderable(
899
+ pygame.Surface(self.world_size.elementwise() * self.tile_size),
900
+ self.tile_size,
901
+ layer.get("layer", 0),
902
+ layer.elevation,
903
+ )
904
+ if layer.speed.x != 0:
905
+ renderable.wrap_x = True
906
+ if layer.speed.y != 0:
907
+ renderable.wrap_y = True
908
+
909
+ renderable.image.fill((254, 253, 252))
910
+
911
+ # Draw current frame state to surface
912
+ for y in range(int(self.world_size.y)):
913
+ for x in range(int(self.world_size.x)):
914
+ tid = indices[int(y * self.world_size.x + x)]
915
+ if (info := self._cache.get(tid)) is None:
916
+ continue
917
+ tile_position = Vector2(x, y).elementwise() * self.tile_size
918
+ src_pos = Vector2(info.tile.current_offset())
919
+ renderable.image.blit(
920
+ info.tileset.image,
921
+ tile_position,
922
+ ((src_pos, self.tile_size)),
923
+ )
924
+ renderable.image.set_colorkey((254, 253, 252))
925
+
926
+ # Compute next animation state
927
+ frame_t = schedule[idx][0]
928
+ renderable.duration = frame_t
929
+ layer.add_prerendered_frame(renderable)
930
+ if len(schedule) > 1:
931
+ for tsinfo in self._tilesets:
932
+ tsinfo.tileset.start_new_frame()
933
+ tsinfo.tileset.update(frame_t)
934
+
935
+ for tsinfo in self._tilesets:
936
+ tsinfo.tileset.reset()
937
+
938
+
939
+ class MapManager:
940
+ def __init__(self) -> None:
941
+ self._tilemaps: dict[str, TiledTilemap] = {}
942
+ self._tilesets: dict[str, TiledTileset] = {}
943
+ self._templates: dict[str, TiledTemplate] = {}
944
+ self._images: dict[str, pygame.Surface] = {}
945
+ self._loaded_paths: dict[str, str] = {}
946
+
947
+ def load(self, files: list[str | Path]) -> None:
948
+ for f in files:
949
+ fp = Path(f).resolve()
950
+
951
+ if not fp.exists():
952
+ LOG.warning("File path '%s' does not exist! Skipping", fp)
953
+ continue
954
+
955
+ if fp.is_dir():
956
+ self._load_dir(fp)
957
+ else:
958
+ self._load_file(fp)
959
+
960
+ def _load_file(self, fp: Path, skip_invalid: bool = False) -> None:
961
+ fp = fp.resolve()
962
+ if str(fp) in self._loaded_paths:
963
+ return
964
+
965
+ _, suf = fp.parts[-1].rsplit(".")
966
+ if suf.lower() == "tmx":
967
+ self._load_tilemap(fp)
968
+ elif suf.lower() == "tsx":
969
+ self._load_tileset(fp)
970
+ elif suf.lower() == "tx":
971
+ pass # Template
972
+ elif not skip_invalid:
973
+ msg = (
974
+ f"File {fp} has an unsupported file ending {suf} . "
975
+ "Please provide .tmx, .tsx, or .tx"
976
+ )
977
+ raise ValueError(msg)
978
+
979
+ def _load_dir(self, fp: Path) -> None:
980
+ pass
981
+
982
+ def _load_tilemap(self, path: Path) -> str:
983
+ path = path.resolve()
984
+ name = path.stem
985
+ if name in self._tilemaps:
986
+ return name
987
+
988
+ tm = TiledTilemap(self, name)
989
+
990
+ LOG.info("Attempting to load tilemap '%s' from TMX file '%s' ...", name, path)
991
+ tm.load_from_path(path)
992
+ LOG.info("Map '%s' successfully loaded.", name)
993
+
994
+ self._tilemaps[name] = tm
995
+ self._loaded_paths[str(path)] = name
996
+ return name
997
+
998
+ def _load_tileset(self, path: Path) -> str:
999
+ path = path.resolve()
1000
+ name = path.stem
1001
+ if name in self._tilesets:
1002
+ return name
1003
+
1004
+ ts = TiledTileset(self, name)
1005
+
1006
+ LOG.info("Attempting to load tileset '%s' from TSX file '%s' ...", name, path)
1007
+ ts.load_from_path(path)
1008
+ LOG.info("Tileset '%s' successfully loaded.", name)
1009
+
1010
+ self._tilesets[name] = ts
1011
+ self._loaded_paths[str(path)] = name
1012
+ return name
1013
+
1014
+ def _load_template(self, path: Path) -> str:
1015
+ path = path.resolve()
1016
+ name = path.stem
1017
+ if name in self._templates:
1018
+ return name
1019
+
1020
+ tpl = TiledTemplate(self, name)
1021
+
1022
+ LOG.info("Attempting to load template '%s' from TX file '%s' ...", name, path)
1023
+ tpl.load_from_path(path)
1024
+
1025
+ self._templates[name] = tpl
1026
+ self._loaded_paths[str(path)] = name
1027
+ return name
1028
+
1029
+ def _load_image(self, path: Path) -> str:
1030
+ path = path.resolve()
1031
+ name = path.stem
1032
+ if name in self._images:
1033
+ return name
1034
+ self._images[name] = pygame.image.load(path).convert_alpha()
1035
+ self._loaded_paths[str(path)] = name
1036
+ return name
1037
+
1038
+ def get_map(self, path: str | Path) -> TiledTilemap:
1039
+ name = str(path)
1040
+ if name in self._tilemaps:
1041
+ return self._tilemaps[name]
1042
+
1043
+ if name in self._loaded_paths:
1044
+ return self._tilemaps[self._loaded_paths[name]]
1045
+
1046
+ LOG.info("Tilemap '%s' not found. Attempting to read from disk ...", name)
1047
+ try:
1048
+ self._load_tilemap(Path(name))
1049
+ except ValueError:
1050
+ LOG.exception(
1051
+ "Invalid tilemap name format '%s. File should end with .tmx", name
1052
+ )
1053
+ raise
1054
+
1055
+ return self._tilemaps[self._loaded_paths[name]]
1056
+
1057
+ def get_tileset(self, path: str | Path) -> TiledTileset:
1058
+ name = str(path)
1059
+ if name in self._tilesets:
1060
+ return self._tilesets[name]
1061
+
1062
+ if name in self._loaded_paths:
1063
+ return self._tilesets[self._loaded_paths[name]]
1064
+
1065
+ LOG.info("Tileset '%s' not found. Attempting to read from disk ...", name)
1066
+ try:
1067
+ self._load_tileset(Path(name))
1068
+ except ValueError:
1069
+ LOG.exception(
1070
+ "Invalid tileset name format '%s'. File should end with .tsx", name
1071
+ )
1072
+ raise
1073
+
1074
+ return self._tilesets[self._loaded_paths[name]]
1075
+
1076
+ def get_template(self, name: str | Path) -> TiledTemplate:
1077
+ name = str(name)
1078
+ if name in self._templates:
1079
+ return self._templates[name]
1080
+ if name in self._loaded_paths:
1081
+ return self._templates[self._loaded_paths[name]]
1082
+
1083
+ LOG.info("Template '%s' not found. Attempt to read from disk ...", name)
1084
+ try:
1085
+ self._load_template(Path(name))
1086
+ except ValueError:
1087
+ LOG.exception(
1088
+ "Invalid template name format '%s'. File should end with .tx", name
1089
+ )
1090
+ raise
1091
+
1092
+ return self._templates[self._loaded_paths[name]]
1093
+
1094
+ def get_image(self, name: str | Path) -> pygame.Surface:
1095
+ name = str(name)
1096
+ if name in self._images:
1097
+ return self._images[name]
1098
+ if name in self._loaded_paths:
1099
+ return self._images[self._loaded_paths[name]]
1100
+
1101
+ LOG.info("Image '%s' not found. Attempting to read from disk ...", name)
1102
+ self._load_image(Path(name))
1103
+
1104
+ return self._images[self._loaded_paths[name]]
1105
+
1106
+
1107
+ def read_properties_to_dict(xtree: Element):
1108
+ return {
1109
+ p.attrib["name"]: convert_from_str(
1110
+ p.attrib["value"], p.attrib.get("type", "str")
1111
+ )
1112
+ for pd in xtree.findall("properties")
1113
+ for p in pd.findall("property")
1114
+ }
1115
+
1116
+
1117
+ def build_frame_table(
1118
+ tm: TiledTilemap, layer: _TiledLayer
1119
+ ) -> dict[int, dict[int, float]]:
1120
+ """Build the frame table as preprocessing step for the prerender schedule.
1121
+
1122
+ Args:
1123
+ tm: The tilemap object.
1124
+ layer: The currently processed layer.
1125
+
1126
+ Returns:
1127
+ The frame table containing the frame durations for each tile ID.
1128
+ """
1129
+ data = {}
1130
+ indices = layer.get_all_indices()
1131
+
1132
+ for y in range(int(tm.world_size.y)):
1133
+ for x in range(int(tm.world_size.x)):
1134
+ tid = indices[int(y * tm.world_size.x + x)]
1135
+ info = tm._cache.get(tid)
1136
+ if info is None:
1137
+ if not tm._load_to_cache(tid):
1138
+ continue
1139
+ info = tm._cache[tid]
1140
+ if not info.tile.animated:
1141
+ continue
1142
+
1143
+ frame_ticks = {}
1144
+ for idx, frame in enumerate(info.tile.frames):
1145
+ frame_ticks[idx] = frame.duration
1146
+ data[tid] = frame_ticks
1147
+ return data
1148
+
1149
+
1150
+ def build_prerender_schedule(
1151
+ frame_table: dict[int, dict[int, float]],
1152
+ ) -> list[tuple[float, dict[int, int]]]:
1153
+ """Build the prerender schedule that combines frame durations of all tiles.
1154
+
1155
+ Args:
1156
+ frame_table: The frame table for the current layer, computed by
1157
+ :func:`build_frame_table`.
1158
+
1159
+ Returns:
1160
+ The prerender schedule that contains the durations that are required to
1161
+ capture all possible tile state combinations.
1162
+ """
1163
+ if not frame_table:
1164
+ return [(0.0, {})]
1165
+
1166
+ tiles: dict[int, dict[str, Any]] = {}
1167
+ for tile_id, frames in frame_table.items():
1168
+ tiles[tile_id] = {
1169
+ "frames": list(frames.items()),
1170
+ "frame_idx": 0,
1171
+ "remaining": frames[0],
1172
+ }
1173
+
1174
+ def snapshot():
1175
+ return tuple(
1176
+ (tile_id, state["frames"][state["frame_idx"]][0])
1177
+ for tile_id, state in sorted(tiles.items())
1178
+ )
1179
+
1180
+ seen_states = set()
1181
+ schedule = []
1182
+
1183
+ while True:
1184
+ state = snapshot()
1185
+ if state in seen_states:
1186
+ break
1187
+ seen_states.add(state)
1188
+
1189
+ # Find next event
1190
+ dt = min(state["remaining"] for state in tiles.values())
1191
+
1192
+ schedule.append((dt, state))
1193
+
1194
+ # Update tiles
1195
+ for state in tiles.values():
1196
+ state["remaining"] -= dt
1197
+ if state["remaining"] == 0:
1198
+ state["frame_idx"] = (state["frame_idx"] + 1) % len(state["frames"])
1199
+ state["remaining"] = state["frames"][state["frame_idx"]][1]
1200
+ return schedule
1201
+
1202
+
1203
+ def convert_from_str(val: str, vtype: str):
1204
+ if vtype == "bool":
1205
+ return strtobool(val)
1206
+ elif vtype == "float":
1207
+ return float(val)
1208
+ elif vtype == "int":
1209
+ return int(val)
1210
+ return val
1211
+
1212
+
1213
+ def strtobool(val: str) -> bool:
1214
+ """Convert a string representation of truth to true (1) or false (0).
1215
+
1216
+ True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
1217
+ are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
1218
+ 'val' is anything else.
1219
+ """
1220
+ val = val.lower()
1221
+ if val in ("y", "yes", "t", "true", "on", "1"):
1222
+ return True
1223
+ elif val in ("n", "no", "f", "false", "off", "0", ""):
1224
+ return False
1225
+ else:
1226
+ msg = f"Invalid truth value {val}"
1227
+ raise ValueError(msg)
1228
+
1229
+
1230
+ def vfloor(vec: Vector2, inplace: bool = False) -> Vector2:
1231
+ if inplace:
1232
+ vec.x = math.floor(vec.x)
1233
+ vec.y = math.floor(vec.y)
1234
+ return vec
1235
+ return Vector2(math.floor(vec.x), math.floor(vec.y))
1236
+
1237
+
1238
+ def vclamp(val: Vector2, low: Vector2, high: Vector2) -> Vector2:
1239
+ return Vector2(max(low.x, min(high.x, val.x)), max(low.y, min(high.y, val.y)))
1240
+
1241
+
1242
+ def vmax(val: Vector2, other: Vector2) -> Vector2:
1243
+ return Vector2(max(val.x, other.x), max(val.y, other.y))
1244
+
1245
+
1246
+ def vmin(val: Vector2, other: Vector2) -> Vector2:
1247
+ return Vector2(min(val.x, other.x), min(val.y, other.y))