tilemap-parser 4.2.2__tar.gz → 4.2.4__tar.gz

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 (50) hide show
  1. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/PKG-INFO +1 -1
  2. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/pyproject.toml +1 -1
  3. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/__init__.py +1 -0
  4. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/map_parse.py +5 -0
  5. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/tmx_converter.py +1 -0
  6. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/__init__.py +2 -0
  7. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/map_object.py +3 -0
  8. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/object_collision.py +3 -10
  9. tilemap_parser-4.2.4/src/tilemap_parser/runtime/protocols.py +37 -0
  10. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/renderer.py +41 -28
  11. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/tile_collision.py +12 -11
  12. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  13. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/SOURCES.txt +2 -0
  14. tilemap_parser-4.2.4/tests/test_tile_layer_renderer_y_sort.py +348 -0
  15. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/LICENSE +0 -0
  16. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/README.md +0 -0
  17. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/setup.cfg +0 -0
  18. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/__init__.py +0 -0
  19. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/animation.py +0 -0
  20. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/collision.py +0 -0
  21. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/collision_loader.py +0 -0
  22. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/node_parse.py +0 -0
  23. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/particle.py +0 -0
  24. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/animation_player.py +0 -0
  25. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/area_node.py +0 -0
  26. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/camera.py +0 -0
  27. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  28. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/map_loader.py +0 -0
  29. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/particles.py +0 -0
  30. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/utils/__init__.py +0 -0
  31. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser/utils/geometry.py +0 -0
  32. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  33. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/requires.txt +0 -0
  34. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  35. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_camera.py +0 -0
  36. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_collision.py +0 -0
  37. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_geometry.py +0 -0
  38. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_gid_collision.py +0 -0
  39. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_integration_animation.py +0 -0
  40. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_integration_collision.py +0 -0
  41. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_integration_map_loader.py +0 -0
  42. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_map_loader.py +0 -0
  43. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_map_object.py +0 -0
  44. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_move_grounded.py +0 -0
  45. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_object_collision.py +0 -0
  46. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_object_surfaces.py +0 -0
  47. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_particle_emitter.py +0 -0
  48. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_render_scale.py +0 -0
  49. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_tile_collision.py +0 -0
  50. {tilemap_parser-4.2.2 → tilemap_parser-4.2.4}/tests/test_tmx_converter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.2.2
3
+ Version: 4.2.4
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime.
5
5
  Author: tilemap parser contributors
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "4.2.2"
7
+ version = "4.2.4"
8
8
  description = "Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -20,6 +20,7 @@ __all__ = [
20
20
  "CollisionPolygon",
21
21
  "CollisionResult",
22
22
  "CollisionRunner",
23
+ "ICollidable",
23
24
  "ICollidableObject",
24
25
  "ICollidableSprite",
25
26
  "LayerRenderStats",
@@ -159,6 +159,8 @@ class ParsedLayer:
159
159
  locked: bool
160
160
  opacity: float
161
161
  z_index: int
162
+ y_sort: bool = False
163
+ y_sort_origin: int = 0
162
164
  properties: JsonDict = field(default_factory=dict)
163
165
  tiles: Dict[Point, ParsedTile] = field(default_factory=dict)
164
166
  objects: Dict[int, ParsedObject] = field(default_factory=dict)
@@ -285,6 +287,8 @@ def _parse_layer(layer_obj: JsonDict, layer_id: int, ctx: str) -> ParsedLayer:
285
287
  locked=_coerce_bool(layer_obj.get("locked", False), f"{ctx}.locked"),
286
288
  opacity=_coerce_float(layer_obj.get("opacity", 1.0), f"{ctx}.opacity"),
287
289
  z_index=_coerce_int(layer_obj.get("z_index", layer_id), f"{ctx}.z_index"),
290
+ y_sort=_coerce_bool(layer_obj.get("y_sort", False), f"{ctx}.y_sort"),
291
+ y_sort_origin=layer_obj.get("y_sort_origin", 0),
288
292
  properties=_optional_dict(layer_obj.get("properties"), f"{ctx}.properties")
289
293
  or {},
290
294
  )
@@ -409,6 +413,7 @@ def _expand_ongrid_to_layer(data_obj: JsonDict, ctx: str) -> List[ParsedLayer]:
409
413
  locked=False,
410
414
  opacity=1.0,
411
415
  z_index=0,
416
+ y_sort=False, y_sort_origin=0,
412
417
  properties={},
413
418
  )
414
419
  for loc_str, tile_data in raw_ongrid.items():
@@ -268,6 +268,7 @@ def _parse_tile_layer(
268
268
  locked=False,
269
269
  opacity=opacity,
270
270
  z_index=0,
271
+ y_sort=False, y_sort_origin=0,
271
272
  )
272
273
 
273
274
  layer.properties = _parse_properties(layer_elem)
@@ -18,6 +18,7 @@ from .tile_collision import (
18
18
  MovementMode,
19
19
  rect_vs_tilemap,
20
20
  )
21
+ from .protocols import ICollidable
21
22
  from .map_loader import TilemapData, load_map
22
23
  from .object_collision import (
23
24
  CollisionHit,
@@ -45,6 +46,7 @@ __all__ = [
45
46
  "CollisionHit",
46
47
  "CollisionResult",
47
48
  "CollisionRunner",
49
+ "ICollidable",
48
50
  "ICollidableObject",
49
51
  "ICollidableSprite",
50
52
  "LayerRenderStats",
@@ -56,6 +56,7 @@ class MapObject:
56
56
  "collision_shapes",
57
57
  "collision_layer",
58
58
  "collision_mask",
59
+ "y_sort_origin",
59
60
  )
60
61
 
61
62
  def __init__(
@@ -68,6 +69,7 @@ class MapObject:
68
69
  collision_shapes: Optional[List[CollisionPolygon]] = None,
69
70
  collision_layer: int = 1,
70
71
  collision_mask: int = 0xFFFFFFFF,
72
+ y_sort_origin: Optional[int] = None,
71
73
  ) -> None:
72
74
  self.x = x
73
75
  self.y = y
@@ -76,6 +78,7 @@ class MapObject:
76
78
  self.collision_shape = self.collision_shapes[0]
77
79
  self.collision_layer = collision_layer
78
80
  self.collision_mask = collision_mask
81
+ self.y_sort_origin = y_sort_origin
79
82
 
80
83
 
81
84
  def _resolve_object_collision_filename(tileset_path: Union[str, Path]) -> str:
@@ -33,27 +33,20 @@ from ..utils.geometry import (
33
33
  rect_vs_circle,
34
34
  rect_vs_rect,
35
35
  )
36
+ from .protocols import ICollidable
36
37
 
37
38
 
38
- class ICollidableObject(Protocol):
39
+ class ICollidableObject(ICollidable, Protocol):
39
40
  """
40
41
  Protocol for objects that can collide.
41
42
 
42
- Required attributes:
43
- x: World X position
44
- y: World Y position
45
- collision_shape: Shape for collision
46
- (RectangleShape, CircleShape, CapsuleShape, or CollisionPolygon)
43
+ All required attributes (x, y, collision_shape) are inherited from ICollidable.
47
44
 
48
45
  Optional attributes (with defaults):
49
46
  collision_layer: Layer this object is on (default: 1)
50
47
  collision_mask: Layers to collide with (default: 0xFFFFFFFF)
51
48
  """
52
49
 
53
- x: float
54
- y: float
55
- collision_shape: Union[RectangleShape, CircleShape, CapsuleShape, CollisionPolygon]
56
-
57
50
 
58
51
  @dataclass(slots=True)
59
52
  class CollisionHit:
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ import pygame
6
+ from pygame import Surface
7
+
8
+ from ..parser.collision import (
9
+ CapsuleShape,
10
+ CircleShape,
11
+ CollisionPolygon,
12
+ RectangleShape,
13
+ )
14
+
15
+
16
+ class ExtraObject(Protocol):
17
+ surface: Surface | None
18
+ x: float
19
+ y: float
20
+
21
+
22
+ class ICollidable(Protocol):
23
+ """Base protocol for anything with a world position and collision shape.
24
+
25
+ This is the minimal interface for participating in collision detection.
26
+ All collidable protocols (ICollidableSprite, ICollidableObject) extend this.
27
+
28
+ Required attributes:
29
+ x (float): World X position
30
+ y (float): World Y position
31
+ collision_shape: Shape used for collision detection
32
+ (RectangleShape, CircleShape, CapsuleShape, or CollisionPolygon)
33
+ """
34
+
35
+ x: float
36
+ y: float
37
+ collision_shape: RectangleShape | CircleShape | CapsuleShape | CollisionPolygon
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Sequence
3
4
  from dataclasses import dataclass
4
- from typing import Dict, List, Optional, Tuple, Union
5
5
 
6
6
  import pygame
7
7
  from pygame import Rect, Surface, transform
8
8
 
9
9
  from .map_loader import TilemapData
10
+ from .protocols import ExtraObject
10
11
 
11
12
  CHUNK_SIZE = 32
12
13
 
@@ -19,19 +20,15 @@ class LayerRenderStats:
19
20
 
20
21
 
21
22
  class TileLayerRenderer:
22
- def __init__(
23
- self, data: TilemapData, *, include_hidden_layers: bool = False
24
- ) -> None:
23
+ def __init__(self, data: TilemapData, *, include_hidden_layers: bool = False) -> None:
25
24
  self.data = data
26
- self.tile_layers = data.get_tile_layers_dict(
27
- include_hidden=include_hidden_layers
28
- )
25
+ self.tile_layers = data.get_tile_layers_dict(include_hidden=include_hidden_layers)
29
26
  self._sorted_layer_ids = sorted(
30
27
  self.tile_layers.keys(),
31
28
  key=lambda lid: (self.tile_layers[lid].z_index, lid),
32
29
  )
33
30
 
34
- self._variant_cache: Dict[Tuple[int, int], Optional[Surface]] = {}
31
+ self._variant_cache: dict[tuple[int, int], Surface | None] = {}
35
32
 
36
33
  self._tile_w, self._tile_h = data.tile_size
37
34
  self._rs = data.render_scale
@@ -45,7 +42,7 @@ class TileLayerRenderer:
45
42
  f"got tile_size=({self._tile_w}, {self._tile_h}) render_scale={self._rs}"
46
43
  )
47
44
 
48
- self._tileset_animations: Dict[int, dict] = {}
45
+ self._tileset_animations: dict[int, dict] = {}
49
46
  for ts_idx, ts in enumerate(data.parsed.tilesets):
50
47
  if ts.animation is not None:
51
48
  self._tileset_animations[ts_idx] = {
@@ -56,9 +53,9 @@ class TileLayerRenderer:
56
53
  "animation_mode": ts.animation.animation_mode,
57
54
  }
58
55
 
59
- self._layer_chunks: Dict[int, Dict[Tuple[int, int], List[Tuple[int, int]]]] = {}
56
+ self._layer_chunks: dict[int, dict[tuple[int, int], list[tuple[int, int]]]] = {}
60
57
  for layer_id, layer in self.tile_layers.items():
61
- chunks: Dict[Tuple[int, int], List[Tuple[int, int]]] = {}
58
+ chunks: dict[tuple[int, int], list[tuple[int, int]]] = {}
62
59
  for (x, y), tile in layer.tiles.items():
63
60
  if not isinstance(tile.ttype, int):
64
61
  continue
@@ -69,10 +66,10 @@ class TileLayerRenderer:
69
66
  chunks[chunk_key].append((x, y))
70
67
  self._layer_chunks[layer_id] = chunks
71
68
 
72
- def get_layer_dict(self) -> Dict[int, object]:
69
+ def get_layer_dict(self) -> dict[int, object]:
73
70
  return dict(self.tile_layers)
74
71
 
75
- def _get_cached_variant(self, ttype: int, variant: int) -> Optional[Surface]:
72
+ def _get_cached_variant(self, ttype: int, variant: int) -> Surface | None:
76
73
  key = (ttype, variant)
77
74
  if key not in self._variant_cache:
78
75
  cell = self.data.get_tile_surface(ttype, variant, copy_surface=True)
@@ -121,17 +118,21 @@ class TileLayerRenderer:
121
118
  def render(
122
119
  self,
123
120
  target: Surface,
124
- camera_xy: Union[Tuple[float, float], Tuple[int, int]] = (0, 0),
125
- viewport_size: Optional[Tuple[int, int]] = None,
121
+ camera_xy: tuple[float, float] | tuple[int, int] = (0, 0),
122
+ viewport_size: tuple[int, int] | None = None,
126
123
  *,
127
- current_time_ms: Optional[float] = None,
124
+ extra_objects: Sequence[ExtraObject] | None = None,
125
+ current_time_ms: float | None = None,
128
126
  ) -> LayerRenderStats:
129
- """Render visible tile layers.
127
+ """Render visible tile layers, optionally merged with extra objects.
128
+
129
+ When *extra_objects* is provided, each item must have ``surface``,
130
+ ``x``, and ``y`` attributes (duck-typed). Extras are blitted after
131
+ all tile layers, preserving caller order.
130
132
 
131
- Note: ``skipped_tiles`` is a debugging indicator, not a whole-map
132
- accumulated count. It tracks null surfaces and invalid tiles within
133
- the active chunk range enough to spot rendering issues without
134
- the cost of a full scan.
133
+ Tiles are blitted per layer in natural chunk order. When
134
+ ``layer.y_sort`` is enabled, tiles within each chunk are sorted by
135
+ their tile-space Y coordinate before blitting.
135
136
  """
136
137
  cam_x, cam_y = float(camera_xy[0]), float(camera_xy[1])
137
138
  if viewport_size is None:
@@ -171,11 +172,14 @@ class TileLayerRenderer:
171
172
  if not chunk:
172
173
  continue
173
174
 
174
- for x, y in chunk:
175
+ tile_iter = chunk
176
+ if layer.y_sort:
177
+ origin = layer.y_sort_origin
178
+ tile_iter = sorted(chunk, key=lambda p: p[1] * self._eff_h + origin)
179
+
180
+ for x, y in tile_iter:
175
181
  tile = layer.tiles[(x, y)]
176
- display_variant = self._compute_display_variant(
177
- tile.variant, tile.ttype, x, y, time_ms
178
- )
182
+ display_variant = self._compute_display_variant(tile.variant, tile.ttype, x, y, time_ms)
179
183
  cell = self._get_cached_variant(tile.ttype, display_variant)
180
184
  if cell is None:
181
185
  skipped += 1
@@ -187,6 +191,15 @@ class TileLayerRenderer:
187
191
  )
188
192
  drawn += 1
189
193
 
190
- return LayerRenderStats(
191
- drawn_tiles=drawn, skipped_tiles=skipped, visible_layers=visible_layers
192
- )
194
+ if extra_objects:
195
+ for obj in extra_objects:
196
+ surf = obj.surface
197
+ if surf is None:
198
+ continue
199
+ target.blit(surf, (obj.x - cam_x, obj.y - cam_y))
200
+
201
+ return LayerRenderStats(drawn_tiles=drawn, skipped_tiles=skipped, visible_layers=visible_layers)
202
+
203
+ @property
204
+ def sorted_layer_ids(self) -> list[int]:
205
+ return list(self._sorted_layer_ids)
@@ -23,12 +23,13 @@ from ..parser.collision import (
23
23
  RectangleShape,
24
24
  TilesetCollision,
25
25
  )
26
+ from .protocols import ICollidable
26
27
 
27
28
  Point = Tuple[float, float]
28
29
  Vector2 = Tuple[float, float]
29
30
 
30
31
 
31
- class ICollidableSprite(Protocol):
32
+ class ICollidableSprite(ICollidable, Protocol):
32
33
  """
33
34
  Interface that any sprite/character class must implement to use collision runners.
34
35
 
@@ -303,7 +304,7 @@ def _circle_polygon_collision_offset(
303
304
  return False
304
305
 
305
306
 
306
- def get_shape_bounds(sprite: ICollidableSprite) -> Tuple[float, float, float, float]:
307
+ def get_shape_bounds(sprite: ICollidable) -> Tuple[float, float, float, float]:
307
308
  """Get AABB bounds for sprite (left, top, right, bottom)"""
308
309
  shape = sprite.collision_shape
309
310
  if isinstance(shape, RectangleShape):
@@ -328,7 +329,7 @@ def get_shape_bounds(sprite: ICollidableSprite) -> Tuple[float, float, float, fl
328
329
 
329
330
 
330
331
  def check_sprite_polygon_collision(
331
- sprite: ICollidableSprite, polygon: CollisionPolygon
332
+ sprite: ICollidable, polygon: CollisionPolygon
332
333
  ) -> bool:
333
334
  """Check if sprite collides with a world-space polygon (legacy / public API)."""
334
335
  shape = sprite.collision_shape
@@ -349,7 +350,7 @@ def check_sprite_polygon_collision(
349
350
 
350
351
 
351
352
  def _check_sprite_polygon_offset(
352
- sprite: ICollidableSprite,
353
+ sprite: ICollidable,
353
354
  polygon: CollisionPolygon,
354
355
  ox: float,
355
356
  oy: float,
@@ -489,7 +490,7 @@ class CollisionRunner:
489
490
  self,
490
491
  tileset_collision: TilesetCollision,
491
492
  tile_map: dict,
492
- sprite: ICollidableSprite,
493
+ sprite: ICollidable,
493
494
  margin: int = 1,
494
495
  ) -> List[CollisionPolygon]:
495
496
  """
@@ -529,7 +530,7 @@ class CollisionRunner:
529
530
 
530
531
  def _collides_at(
531
532
  self,
532
- sprite: ICollidableSprite,
533
+ sprite: ICollidable,
533
534
  tileset_collision: TilesetCollision,
534
535
  tile_map: dict,
535
536
  margin: int = 1,
@@ -567,7 +568,7 @@ class CollisionRunner:
567
568
 
568
569
  def _first_colliding_shape(
569
570
  self,
570
- sprite: ICollidableSprite,
571
+ sprite: ICollidable,
571
572
  tileset_collision: TilesetCollision,
572
573
  tile_map: dict,
573
574
  margin: int = 1,
@@ -603,7 +604,7 @@ class CollisionRunner:
603
604
 
604
605
  def move_and_slide(
605
606
  self,
606
- sprite: ICollidableSprite,
607
+ sprite: ICollidable,
607
608
  tileset_collision: TilesetCollision,
608
609
  tile_map: dict,
609
610
  delta_x: float,
@@ -729,7 +730,7 @@ class CollisionRunner:
729
730
 
730
731
  def _get_collision_normal_from_motion(
731
732
  self,
732
- sprite: ICollidableSprite,
733
+ sprite: ICollidable,
733
734
  polygon: CollisionPolygon,
734
735
  ox: float,
735
736
  oy: float,
@@ -1745,7 +1746,7 @@ class CollisionRunner:
1745
1746
 
1746
1747
  def move_rpg(
1747
1748
  self,
1748
- sprite: ICollidableSprite,
1749
+ sprite: ICollidable,
1749
1750
  tileset_collision: TilesetCollision,
1750
1751
  tile_map: dict,
1751
1752
  delta_x: float,
@@ -1814,7 +1815,7 @@ class CollisionRunner:
1814
1815
 
1815
1816
  def move(
1816
1817
  self,
1817
- sprite: ICollidableSprite,
1818
+ sprite: ICollidable,
1818
1819
  tileset_collision: TilesetCollision,
1819
1820
  tile_map: dict,
1820
1821
  delta_x: float = 0.0,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.2.2
3
+ Version: 4.2.4
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime.
5
5
  Author: tilemap parser contributors
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -24,6 +24,7 @@ src/tilemap_parser/runtime/map_loader.py
24
24
  src/tilemap_parser/runtime/map_object.py
25
25
  src/tilemap_parser/runtime/object_collision.py
26
26
  src/tilemap_parser/runtime/particles.py
27
+ src/tilemap_parser/runtime/protocols.py
27
28
  src/tilemap_parser/runtime/renderer.py
28
29
  src/tilemap_parser/runtime/tile_collision.py
29
30
  src/tilemap_parser/utils/__init__.py
@@ -43,4 +44,5 @@ tests/test_object_surfaces.py
43
44
  tests/test_particle_emitter.py
44
45
  tests/test_render_scale.py
45
46
  tests/test_tile_collision.py
47
+ tests/test_tile_layer_renderer_y_sort.py
46
48
  tests/test_tmx_converter.py
@@ -0,0 +1,348 @@
1
+ """
2
+ Tests for TileLayerRenderer y-sort and extra_objects.
3
+
4
+ Covers:
5
+ - y_sort read from parsed layer data
6
+ - y-major iteration when layer.y_sort=True
7
+ - extra_objects merged and sorted by (z_index, y)
8
+ - Duck-typed extra objects (surface, x, y only)
9
+ - Smoke test: render with extra_objects does not crash, stats correct
10
+ """
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
16
+
17
+ import pygame
18
+ import pytest
19
+ from pygame import Surface
20
+
21
+ from tilemap_parser.parser.map_parse import (
22
+ ParsedLayer,
23
+ ParsedMeta,
24
+ ParsedMap,
25
+ _parse_layer,
26
+ )
27
+ from tilemap_parser.runtime.map_loader import TilemapData
28
+ from tilemap_parser.runtime.renderer import TileLayerRenderer
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # ParsedLayer y_sort field
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ class TestParsedLayerYSort:
37
+ def test_default_is_false(self):
38
+ """y_sort defaults to False when not present in data."""
39
+ d = {
40
+ "name": "test",
41
+ "type": "tile",
42
+ "visible": True,
43
+ "locked": False,
44
+ "opacity": 1.0,
45
+ "z_index": 0,
46
+ }
47
+ layer = _parse_layer(d, 0, "test")
48
+ assert layer.y_sort is False
49
+
50
+ def test_reads_true(self):
51
+ d = {
52
+ "name": "test",
53
+ "type": "tile",
54
+ "visible": True,
55
+ "locked": False,
56
+ "opacity": 1.0,
57
+ "z_index": 0,
58
+ "y_sort": True,
59
+ }
60
+ layer = _parse_layer(d, 0, "test")
61
+ assert layer.y_sort is True
62
+
63
+ def test_reads_false_explicit(self):
64
+ d = {
65
+ "name": "test",
66
+ "type": "tile",
67
+ "visible": True,
68
+ "locked": False,
69
+ "opacity": 1.0,
70
+ "z_index": 0,
71
+ "y_sort": False,
72
+ }
73
+ layer = _parse_layer(d, 0, "test")
74
+ assert layer.y_sort is False
75
+
76
+ def test_y_sort_origin_defaults_to_zero(self):
77
+ d = {"name": "test", "type": "tile", "visible": True, "locked": False, "opacity": 1.0, "z_index": 0}
78
+ layer = _parse_layer(d, 0, "test")
79
+ assert layer.y_sort_origin == 0
80
+
81
+ def test_y_sort_origin_reads_from_json(self):
82
+ d = {"name": "test", "type": "tile", "visible": True, "locked": False, "opacity": 1.0, "z_index": 0, "y_sort_origin": 16}
83
+ layer = _parse_layer(d, 0, "test")
84
+ assert layer.y_sort_origin == 16
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Helpers — minimal map for renderer tests
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ def _dummy_tileset_surface(w=16, h=16) -> Surface:
93
+ """Create a solid-colour surface to serve as a tileset cell."""
94
+ surf = Surface((w, h))
95
+ surf.fill((200, 100, 50))
96
+ return surf
97
+
98
+
99
+ class DummyVisual:
100
+ """Minimal duck-typed visual — surface, x, y, optional z_index."""
101
+
102
+ __slots__ = ("surface", "x", "y", "z_index")
103
+
104
+ def __init__(self, surface, x, y, z_index=0):
105
+ self.surface = surface
106
+ self.x = x
107
+ self.y = y
108
+ self.z_index = z_index
109
+
110
+ def __repr__(self):
111
+ return f"DummyVisual(x={self.x}, y={self.y}, z={self.z_index})"
112
+
113
+
114
+ def _make_tile(pos, ttype=0, variant=0):
115
+ from tilemap_parser.parser.map_parse import ParsedTile
116
+
117
+ return ParsedTile(pos=pos, ttype=ttype, variant=variant, gid=None, properties=None)
118
+
119
+
120
+ def _make_map_data(layers, tile_size=(16, 16)) -> TilemapData:
121
+ """Build a TilemapData from a list of ParsedLayer objects."""
122
+ from pathlib import Path
123
+ from tilemap_parser.parser.map_parse import ParsedTileset
124
+
125
+ meta = ParsedMeta(
126
+ tile_size=tile_size,
127
+ map_size=(10, 10),
128
+ initial_map_size=(10, 10),
129
+ zoom_level=1.0,
130
+ scroll=(0, 0),
131
+ version="1.1",
132
+ render_scale=1.0,
133
+ )
134
+ mock_map = object.__new__(ParsedMap)
135
+ mock_map.meta = meta
136
+ mock_map.layers = layers
137
+ mock_map.tilesets = [ParsedTileset(path="dummy.png", type="tile")]
138
+ tw, th = tile_size
139
+ resolved_paths = [Path("dummy.png")]
140
+ return TilemapData(mock_map, [_dummy_tileset_surface(tw, th)], resolved_paths, [])
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # TileLayerRenderer — extra_objects
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ class TestExtraObjects:
149
+ def make_map_data(self) -> TilemapData:
150
+ """Minimal 1-layer map with a single tile at (0,0)."""
151
+ layer = ParsedLayer(
152
+ id=0,
153
+ name="Ground",
154
+ layer_type="tile",
155
+ visible=True,
156
+ locked=False,
157
+ opacity=1.0,
158
+ z_index=0,
159
+ )
160
+ layer.tiles[(0, 0)] = _make_tile((0, 0))
161
+ return _make_map_data([layer])
162
+
163
+ def test_extra_objects_smoke(self):
164
+ """Render with extra_objects does not crash."""
165
+ data = self.make_map_data()
166
+ renderer = TileLayerRenderer(data)
167
+ target = Surface((100, 100))
168
+ objs = [DummyVisual(_dummy_tileset_surface(), 10, 20)]
169
+ stats = renderer.render(target, (0, 0), extra_objects=objs)
170
+ assert stats.drawn_tiles == 1
171
+ assert stats.visible_layers == 1
172
+
173
+ def test_extra_objects_merged_into_drawn_count(self):
174
+ """drawn_tiles only counts tiles, not extras (unchanged stat)."""
175
+ data = self.make_map_data()
176
+ renderer = TileLayerRenderer(data)
177
+ target = Surface((100, 100))
178
+ objs = [DummyVisual(_dummy_tileset_surface(), 10, 20)]
179
+ stats = renderer.render(target, (0, 0), extra_objects=objs)
180
+ assert stats.drawn_tiles == 1 # extras not counted here
181
+
182
+ def test_multiple_extra_objects(self):
183
+ """Multiple extra objects all blit without error."""
184
+ data = self.make_map_data()
185
+ renderer = TileLayerRenderer(data)
186
+ target = Surface((200, 200))
187
+ objs = [
188
+ DummyVisual(_dummy_tileset_surface(), 10, 20, z_index=0),
189
+ DummyVisual(_dummy_tileset_surface(), 30, 40, z_index=1),
190
+ DummyVisual(_dummy_tileset_surface(), 50, 60, z_index=0),
191
+ ]
192
+ renderer.render(target, (0, 0), extra_objects=objs)
193
+ # no crash is the assertion
194
+
195
+ def test_duck_typed_minimal(self):
196
+ """Object with only surface/x/y works (no z_index)."""
197
+ data = self.make_map_data()
198
+ renderer = TileLayerRenderer(data)
199
+ target = Surface((100, 100))
200
+
201
+ class MinimalVisual:
202
+ def __init__(self):
203
+ self.surface = _dummy_tileset_surface()
204
+ self.x = 5
205
+ self.y = 15
206
+
207
+ renderer.render(target, (0, 0), extra_objects=[MinimalVisual()])
208
+
209
+ def test_extra_object_with_none_surface_skipped(self):
210
+ """Objects with surface=None are silently skipped."""
211
+ data = self.make_map_data()
212
+ renderer = TileLayerRenderer(data)
213
+ target = Surface((100, 100))
214
+
215
+ class NoneSurfaceVisual:
216
+ def __init__(self):
217
+ self.surface = None
218
+ self.x = 0
219
+ self.y = 0
220
+
221
+ renderer.render(target, (0, 0), extra_objects=[NoneSurfaceVisual()])
222
+
223
+ def test_render_without_extra_objects_still_works(self):
224
+ """Calling render() without extra_objects is unchanged."""
225
+ data = self.make_map_data()
226
+ renderer = TileLayerRenderer(data)
227
+ target = Surface((100, 100))
228
+ stats = renderer.render(target, (0, 0))
229
+ assert stats.drawn_tiles == 1
230
+ assert stats.visible_layers == 1
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # TileLayerRenderer — y_sort iteration
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ class _SpySurface(pygame.Surface):
239
+ """Records (dest_x, dest_y) for each blit call."""
240
+
241
+ def __init__(self, size):
242
+ super().__init__(size)
243
+ self.blit_calls: list[tuple[float, float]] = []
244
+
245
+ def blit(self, source, dest, *args, **kwargs):
246
+ if isinstance(dest, tuple):
247
+ self.blit_calls.append((float(dest[0]), float(dest[1])))
248
+ else:
249
+ self.blit_calls.append((float(dest.x), float(dest.y)))
250
+ return super().blit(source, dest, *args, **kwargs)
251
+
252
+
253
+ class TestLayerYSort:
254
+ """Verify that y_sort changes iteration order within a chunk."""
255
+
256
+ def make_map_data_two_tiles(self, y_sort: bool = True) -> TilemapData:
257
+ """One layer with two tiles at different Y positions."""
258
+ layer = ParsedLayer(
259
+ id=0,
260
+ name="Bushes",
261
+ layer_type="tile",
262
+ visible=True,
263
+ locked=False,
264
+ opacity=1.0,
265
+ z_index=0,
266
+ y_sort=y_sort,
267
+ )
268
+ layer.tiles[(0, 0)] = _make_tile((0, 0))
269
+ layer.tiles[(0, 5)] = _make_tile((0, 5))
270
+ return _make_map_data([layer])
271
+
272
+ def test_y_sort_layer_does_not_crash(self):
273
+ """Layer with y_sort=True renders without error."""
274
+ data = self.make_map_data_two_tiles()
275
+ renderer = TileLayerRenderer(data)
276
+ target = Surface((200, 200))
277
+ stats = renderer.render(target, (0, 0))
278
+ assert stats.drawn_tiles == 2
279
+
280
+ def test_y_major_order(self):
281
+ """y-sort within a chunk produces y-major iteration."""
282
+ data = self.make_map_data_two_tiles(y_sort=True)
283
+ renderer = TileLayerRenderer(data)
284
+ target = _SpySurface((200, 200))
285
+ renderer.render(target, (0, 0))
286
+ ys = [y for _, y in target.blit_calls]
287
+ assert ys == sorted(ys)
288
+
289
+ def test_no_ysort_preserves_input_order(self):
290
+ """Without y-sort the chunk iteration is insertion order."""
291
+ data = self.make_map_data_two_tiles(y_sort=False)
292
+ renderer = TileLayerRenderer(data)
293
+ target = _SpySurface((200, 200))
294
+ renderer.render(target, (0, 0))
295
+ ef = 16
296
+ assert target.blit_calls == [(0.0, 0.0), (0.0, 5.0 * ef)]
297
+
298
+ def test_y_sort_origin_shifts_sort_order(self):
299
+ """y_sort_origin offsets the Y used for sorting."""
300
+ layer = ParsedLayer(
301
+ id=0, name="test", layer_type="tile", visible=True,
302
+ locked=False, opacity=1.0, z_index=0,
303
+ y_sort=True, y_sort_origin=32,
304
+ )
305
+ layer.tiles[(0, 0)] = _make_tile((0, 0))
306
+ layer.tiles[(0, 1)] = _make_tile((0, 1))
307
+ data = _make_map_data([layer])
308
+ renderer = TileLayerRenderer(data)
309
+ target = _SpySurface((200, 200))
310
+ renderer.render(target, (0, 0))
311
+ ef = 16
312
+ assert target.blit_calls == [(0.0, 0.0), (0.0, 1.0 * ef)]
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Integration — combined y_sort + extra_objects
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ class TestIntegration:
321
+ def test_y_sort_with_extra_objects(self):
322
+ """y_sort layer renders tiles first, then extras in caller order."""
323
+ layer = ParsedLayer(
324
+ id=0,
325
+ name="Bushes",
326
+ layer_type="tile",
327
+ visible=True,
328
+ locked=False,
329
+ opacity=1.0,
330
+ z_index=0,
331
+ y_sort=True,
332
+ )
333
+ layer.tiles[(0, 0)] = _make_tile((0, 0))
334
+ layer.tiles[(0, 5)] = _make_tile((0, 5))
335
+ data = _make_map_data([layer])
336
+ renderer = TileLayerRenderer(data)
337
+ target = _SpySurface((200, 200))
338
+ ef = 16
339
+ objs = [
340
+ DummyVisual(_dummy_tileset_surface(), 5, 25),
341
+ DummyVisual(_dummy_tileset_surface(), 5, 3),
342
+ ]
343
+ renderer.render(target, (0, 0), extra_objects=objs)
344
+ assert len(target.blit_calls) == 4
345
+ assert target.blit_calls[0] == (0.0, 0.0) # tile y=0
346
+ assert target.blit_calls[1] == (0.0, 5.0 * ef) # tile y=5
347
+ assert target.blit_calls[2] == (5.0, 25.0) # extra in caller order
348
+ assert target.blit_calls[3] == (5.0, 3.0) # extra in caller order
File without changes
File without changes
File without changes