tilemap-parser 4.2.3__tar.gz → 4.2.5__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.3 → tilemap_parser-4.2.5}/PKG-INFO +1 -1
  2. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/pyproject.toml +1 -1
  3. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/parser/map_parse.py +5 -0
  4. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/parser/tmx_converter.py +1 -0
  5. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/area_node.py +9 -2
  6. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/map_loader.py +24 -8
  7. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/map_object.py +44 -10
  8. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/protocols.py +11 -2
  9. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/renderer.py +41 -28
  10. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  11. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser.egg-info/SOURCES.txt +1 -0
  12. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_map_loader.py +99 -0
  13. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_map_object.py +22 -0
  14. tilemap_parser-4.2.5/tests/test_tile_layer_renderer_y_sort.py +348 -0
  15. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/LICENSE +0 -0
  16. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/README.md +0 -0
  17. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/setup.cfg +0 -0
  18. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/__init__.py +0 -0
  19. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/parser/__init__.py +0 -0
  20. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/parser/animation.py +0 -0
  21. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/parser/collision.py +0 -0
  22. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/parser/collision_loader.py +0 -0
  23. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/parser/node_parse.py +0 -0
  24. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/parser/particle.py +0 -0
  25. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/__init__.py +0 -0
  26. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/animation_player.py +0 -0
  27. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/camera.py +0 -0
  28. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  29. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/object_collision.py +0 -0
  30. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/particles.py +0 -0
  31. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  32. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/utils/__init__.py +0 -0
  33. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser/utils/geometry.py +0 -0
  34. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  35. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser.egg-info/requires.txt +0 -0
  36. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  37. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_camera.py +0 -0
  38. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_collision.py +0 -0
  39. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_geometry.py +0 -0
  40. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_gid_collision.py +0 -0
  41. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_integration_animation.py +0 -0
  42. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_integration_collision.py +0 -0
  43. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_integration_map_loader.py +0 -0
  44. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_move_grounded.py +0 -0
  45. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_object_collision.py +0 -0
  46. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_object_surfaces.py +0 -0
  47. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_particle_emitter.py +0 -0
  48. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_render_scale.py +0 -0
  49. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/tests/test_tile_collision.py +0 -0
  50. {tilemap_parser-4.2.3 → tilemap_parser-4.2.5}/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.3
3
+ Version: 4.2.5
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.3"
7
+ version = "4.2.5"
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"
@@ -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)
@@ -8,11 +8,18 @@ from ..parser.node_parse import ParsedNode
8
8
 
9
9
 
10
10
  class AreaNode:
11
- def __init__(self, parsed: ParsedNode) -> None:
11
+ def __init__(self, parsed: ParsedNode, render_scale: float = 1.0) -> None:
12
12
  self.node_id = parsed.node_id
13
13
  self.name = parsed.name
14
14
  self.node_type = parsed.node_type
15
- self._rect = Rect(parsed.area.x, parsed.area.y, parsed.area.w, parsed.area.h)
15
+ self.render_scale = render_scale
16
+ rs = render_scale
17
+ self._rect = Rect(
18
+ int(parsed.area.x * rs),
19
+ int(parsed.area.y * rs),
20
+ int(parsed.area.w * rs),
21
+ int(parsed.area.h * rs),
22
+ )
16
23
  self.layer_name = parsed.layer_name
17
24
  self.properties = dict(parsed.properties)
18
25
  self.group = parsed.group
@@ -109,7 +109,11 @@ class TilemapData:
109
109
  origin_offset = _normalize_origin(parsed)
110
110
  result = cls(parsed, surfaces, resolved_paths, warnings, map_path=p)
111
111
  result.origin_offset = origin_offset
112
- result.area_nodes = [AreaNode(n) for n in parsed.nodes if n.node_type == "area"]
112
+ result.area_nodes = [
113
+ AreaNode(n, render_scale=result.render_scale)
114
+ for n in parsed.nodes
115
+ if n.node_type == "area"
116
+ ]
113
117
  result.particle_emitters = [
114
118
  ParticleEmitterNode(n) for n in parsed.nodes if n.node_type == "particle_emitter"
115
119
  ]
@@ -275,8 +279,8 @@ class TilemapData:
275
279
  return self.get_tile_surface(tile.ttype, tile.variant)
276
280
 
277
281
  def get_object_surface_by_id(
278
- self, layer_id_or_name: Union[int, str], object_id: int, *, copy_surface: bool = True
279
- ) -> Optional[Tuple[Surface, int, int]]:
282
+ self, layer_id_or_name: Union[int, str], object_id: int, *, copy_surface: bool = True, scaled: bool = False
283
+ ) -> Optional[Tuple[Surface, float, float]]:
280
284
  layer = self.get_layer(layer_id_or_name)
281
285
  if layer is None or layer.layer_type != "object":
282
286
  return None
@@ -286,19 +290,31 @@ class TilemapData:
286
290
  surf = self.get_object_surface(obj, copy_surface=copy_surface)
287
291
  if surf is None:
288
292
  return None
289
- return surf, obj.area.x, obj.area.y
293
+ rs = self.render_scale if scaled else 1.0
294
+ x = obj.area.x * rs
295
+ y = obj.area.y * rs
296
+ if scaled and rs != 1.0:
297
+ w, h = surf.get_size()
298
+ surf = pygame.transform.scale(surf, (int(w * rs), int(h * rs)))
299
+ return surf, x, y
290
300
 
291
301
  def get_object_surfaces(
292
- self, layer_id_or_name: Union[int, str], *, copy_surface: bool = True
293
- ) -> List[Tuple[Surface, int, int, int]]:
302
+ self, layer_id_or_name: Union[int, str], *, copy_surface: bool = True, scaled: bool = False
303
+ ) -> List[Tuple[Surface, float, float, int]]:
294
304
  layer = self.get_layer(layer_id_or_name)
295
305
  if layer is None or layer.layer_type != "object":
296
306
  return []
297
- result: List[Tuple[Surface, int, int, int]] = []
307
+ result: List[Tuple[Surface, float, float, int]] = []
308
+ rs = self.render_scale if scaled else 1.0
298
309
  for oid, obj in layer.objects.items():
299
310
  surf = self.get_object_surface(obj, copy_surface=copy_surface)
300
311
  if surf is not None:
301
- result.append((surf, obj.area.x, obj.area.y, oid))
312
+ x = obj.area.x * rs
313
+ y = obj.area.y * rs
314
+ if scaled and rs != 1.0:
315
+ w, h = surf.get_size()
316
+ surf = pygame.transform.scale(surf, (int(w * rs), int(h * rs)))
317
+ result.append((surf, x, y, oid))
302
318
  return result
303
319
 
304
320
  def get_tileset_animation(self, ttype: int) -> Optional[dict]:
@@ -56,26 +56,40 @@ 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__(
62
63
  self,
63
64
  x: float,
64
65
  y: float,
65
- surface: Surface,
66
- collision_shape: CollisionPolygon,
66
+ surface: Optional[Surface] = None,
67
+ collision_shape: Optional[CollisionPolygon] = None,
67
68
  *,
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
74
76
  self.surface = surface
75
- self.collision_shapes = collision_shapes or [collision_shape]
76
- self.collision_shape = self.collision_shapes[0]
77
+ if collision_shapes is not None:
78
+ self.collision_shapes = collision_shapes
79
+ self.collision_shape = collision_shapes[0] if collision_shapes else None
80
+ elif collision_shape is not None:
81
+ self.collision_shapes = [collision_shape]
82
+ self.collision_shape = collision_shape
83
+ else:
84
+ self.collision_shapes = []
85
+ self.collision_shape = None
77
86
  self.collision_layer = collision_layer
78
87
  self.collision_mask = collision_mask
88
+ self.y_sort_origin = y_sort_origin
89
+
90
+ @property
91
+ def has_collision(self) -> bool:
92
+ return len(self.collision_shapes) > 0
79
93
 
80
94
 
81
95
  def _resolve_object_collision_filename(tileset_path: Union[str, Path]) -> str:
@@ -91,8 +105,9 @@ def load_map_objects(
91
105
  collision_dir: Union[str, Path],
92
106
  *,
93
107
  cache: Optional[CollisionCache] = None,
108
+ require_collision: bool = True,
94
109
  ) -> List[MapObject]:
95
- """Load all collidable objects from a tilemap.
110
+ """Load objects from a tilemap.
96
111
 
97
112
  Iterates every object layer in *tilemap_data*, resolves the
98
113
  corresponding ``.object_collision.json`` from *collision_dir*, and
@@ -126,13 +141,12 @@ def load_map_objects(
126
141
  files (typically ``<map_path>/collision/``).
127
142
  cache: Optional :class:`CollisionCache` for caching parsed
128
143
  collision data across calls.
144
+ require_collision: If True (default), only objects with matching
145
+ collision regions are returned. If False, visual-only objects
146
+ without collision are also included.
129
147
 
130
148
  Returns:
131
- List of :class:`MapObject` instances, one per map object that
132
- has both a valid surface and a matching collision region.
133
- When a region contains multiple collision polygons, all shapes
134
- are preserved in :attr:`MapObject.collision_shapes` and the
135
- collision system checks each shape pair during narrowphase.
149
+ List of :class:`MapObject` instances.
136
150
  """
137
151
  collision_dir = Path(collision_dir)
138
152
  objects: List[MapObject] = []
@@ -164,6 +178,16 @@ def load_map_objects(
164
178
 
165
179
  collision_data = loaded_collision[ttype]
166
180
  if collision_data is None:
181
+ if require_collision:
182
+ continue
183
+ game_obj = MapObject(
184
+ x=x,
185
+ y=y,
186
+ surface=surf,
187
+ collision_shape=None,
188
+ collision_shapes=[],
189
+ )
190
+ objects.append(game_obj)
167
191
  continue
168
192
 
169
193
  world_shapes = []
@@ -177,6 +201,16 @@ def load_map_objects(
177
201
  oy = region.region_rect[1] * rs
178
202
  world_shapes.extend(shape.transform(ox, oy, rs) for shape in region.shapes)
179
203
  if not world_shapes:
204
+ if require_collision:
205
+ continue
206
+ game_obj = MapObject(
207
+ x=x,
208
+ y=y,
209
+ surface=surf,
210
+ collision_shape=None,
211
+ collision_shapes=[],
212
+ )
213
+ objects.append(game_obj)
180
214
  continue
181
215
 
182
216
  game_obj = MapObject(
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Protocol, Union
3
+ from typing import Protocol
4
+
5
+ import pygame
6
+ from pygame import Surface
4
7
 
5
8
  from ..parser.collision import (
6
9
  CapsuleShape,
@@ -10,6 +13,12 @@ from ..parser.collision import (
10
13
  )
11
14
 
12
15
 
16
+ class ExtraObject(Protocol):
17
+ surface: Surface | None
18
+ x: float
19
+ y: float
20
+
21
+
13
22
  class ICollidable(Protocol):
14
23
  """Base protocol for anything with a world position and collision shape.
15
24
 
@@ -25,4 +34,4 @@ class ICollidable(Protocol):
25
34
 
26
35
  x: float
27
36
  y: float
28
- collision_shape: Union[RectangleShape, CircleShape, CapsuleShape, CollisionPolygon]
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.2.3
3
+ Version: 4.2.5
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
@@ -44,4 +44,5 @@ tests/test_object_surfaces.py
44
44
  tests/test_particle_emitter.py
45
45
  tests/test_render_scale.py
46
46
  tests/test_tile_collision.py
47
+ tests/test_tile_layer_renderer_y_sort.py
47
48
  tests/test_tmx_converter.py
@@ -346,3 +346,102 @@ class TestNodeLoading:
346
346
  assert pe.config.lifetime_min == 0.5
347
347
  assert pe.config.lifetime_max == 2.0
348
348
  assert pe.config.gravity_y == 30.0
349
+
350
+ def test_get_object_surfaces_scaled(self, map_data):
351
+ _, data_dir, _, _ = map_data
352
+ # Create map with render_scale = 2.0 and object layer
353
+ map_path = data_dir / "test_map_obj.json"
354
+ payload = {
355
+ "meta": {
356
+ "tile_size": "16;16",
357
+ "map_size": "10;10",
358
+ "version": "1.1",
359
+ "render_scale": 2.0,
360
+ },
361
+ "resources": {"tilesets": [{"path": "../assets/tileset.png", "type": "object"}]},
362
+ "project_state": {"rules": [], "groups": []},
363
+ "data": {
364
+ "ongrid": {},
365
+ "layers": [
366
+ {
367
+ "name": "Obj",
368
+ "type": "object",
369
+ "visible": True,
370
+ "locked": False,
371
+ "opacity": 1.0,
372
+ "z_index": 0,
373
+ "properties": {},
374
+ "tiles": {},
375
+ "objects": {
376
+ "1": {
377
+ "area": {"x": 32, "y": 64, "w": 16, "h": 16},
378
+ "ttype": 0,
379
+ "tileset_type": "object",
380
+ "variant": 0,
381
+ "properties": {},
382
+ }
383
+ },
384
+ }
385
+ ],
386
+ },
387
+ }
388
+ with open(map_path, "w") as f:
389
+ json.dump(payload, f, indent=2)
390
+
391
+ td = TilemapData.load(map_path)
392
+ # Unscaled
393
+ unscaled = td.get_object_surfaces("Obj", scaled=False)
394
+ assert len(unscaled) == 1
395
+ surf_u, x_u, y_u, _ = unscaled[0]
396
+ assert (x_u, y_u) == (32, 64)
397
+ assert surf_u.get_size() == (16, 16)
398
+
399
+ # Scaled
400
+ scaled = td.get_object_surfaces("Obj", scaled=True)
401
+ assert len(scaled) == 1
402
+ surf_s, x_s, y_s, _ = scaled[0]
403
+ assert (x_s, y_s) == (64, 128)
404
+ assert surf_s.get_size() == (32, 32)
405
+
406
+ def test_area_node_scaled(self, map_data):
407
+ _, data_dir, _, _ = map_data
408
+ map_path = data_dir / "test_map_scaled_nodes.json"
409
+ payload = {
410
+ "meta": {
411
+ "tile_size": "16;16",
412
+ "map_size": "10;10",
413
+ "version": "1.1",
414
+ "render_scale": 3.0,
415
+ },
416
+ "resources": {"tilesets": [{"path": "../assets/tileset.png", "type": "tile"}]},
417
+ "project_state": {"rules": [], "groups": []},
418
+ "data": {"ongrid": {}, "layers": []},
419
+ }
420
+ with open(map_path, "w") as f:
421
+ json.dump(payload, f, indent=2)
422
+
423
+ nodes_path = data_dir / "test_map_scaled_nodes.nodes.json"
424
+ nodes_payload = {
425
+ "nodes": [
426
+ {
427
+ "node_id": "area_1",
428
+ "name": "Zone",
429
+ "node_type": "area",
430
+ "area": {"x": 10, "y": 20, "w": 30, "h": 40},
431
+ "layer_name": "main",
432
+ "properties": {},
433
+ "group": "",
434
+ }
435
+ ],
436
+ "groups": [],
437
+ }
438
+ with open(nodes_path, "w") as f:
439
+ json.dump(nodes_payload, f, indent=2)
440
+
441
+ td = TilemapData.load(map_path)
442
+ assert len(td.area_nodes) == 1
443
+ an = td.area_nodes[0]
444
+ assert an.rect == pygame.Rect(30, 60, 90, 120)
445
+
446
+
447
+
@@ -1299,4 +1299,26 @@ class TestLoadMapObjects:
1299
1299
  objects = load_map_objects(td, collision_dir)
1300
1300
  assert objects == []
1301
1301
 
1302
+ def test_load_visual_only_objects(self, map_no_collision_file):
1303
+ td, collision_dir = map_no_collision_file
1304
+ # With require_collision=False, objects without collision files are returned
1305
+ objects = load_map_objects(td, collision_dir, require_collision=False)
1306
+ assert len(objects) == 1
1307
+ obj = objects[0]
1308
+ assert obj.has_collision is False
1309
+ assert obj.collision_shape is None
1310
+ assert obj.collision_shapes == []
1311
+ assert isinstance(obj.surface, pygame.Surface)
1312
+
1313
+ def test_map_object_optional_collision(self):
1314
+ surf = pygame.Surface((16, 16))
1315
+ obj = MapObject(x=10, y=20, surface=surf)
1316
+ assert obj.x == 10
1317
+ assert obj.y == 20
1318
+ assert obj.surface is surf
1319
+ assert obj.collision_shape is None
1320
+ assert obj.collision_shapes == []
1321
+ assert obj.has_collision is False
1322
+
1323
+
1302
1324
 
@@ -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