tilemap-parser 4.2.3__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.3 → tilemap_parser-4.2.4}/PKG-INFO +1 -1
  2. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/pyproject.toml +1 -1
  3. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/map_parse.py +5 -0
  4. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/tmx_converter.py +1 -0
  5. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/map_object.py +3 -0
  6. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/protocols.py +11 -2
  7. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/renderer.py +41 -28
  8. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  9. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/SOURCES.txt +1 -0
  10. tilemap_parser-4.2.4/tests/test_tile_layer_renderer_y_sort.py +348 -0
  11. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/LICENSE +0 -0
  12. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/README.md +0 -0
  13. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/setup.cfg +0 -0
  14. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/__init__.py +0 -0
  15. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/__init__.py +0 -0
  16. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/animation.py +0 -0
  17. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/collision.py +0 -0
  18. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/collision_loader.py +0 -0
  19. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/node_parse.py +0 -0
  20. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/parser/particle.py +0 -0
  21. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/__init__.py +0 -0
  22. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/animation_player.py +0 -0
  23. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/area_node.py +0 -0
  24. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/camera.py +0 -0
  25. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  26. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/map_loader.py +0 -0
  27. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/object_collision.py +0 -0
  28. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/particles.py +0 -0
  29. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  30. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/utils/__init__.py +0 -0
  31. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser/utils/geometry.py +0 -0
  32. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  33. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/requires.txt +0 -0
  34. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  35. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_camera.py +0 -0
  36. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_collision.py +0 -0
  37. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_geometry.py +0 -0
  38. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_gid_collision.py +0 -0
  39. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_integration_animation.py +0 -0
  40. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_integration_collision.py +0 -0
  41. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_integration_map_loader.py +0 -0
  42. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_map_loader.py +0 -0
  43. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_map_object.py +0 -0
  44. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_move_grounded.py +0 -0
  45. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_object_collision.py +0 -0
  46. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_object_surfaces.py +0 -0
  47. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_particle_emitter.py +0 -0
  48. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_render_scale.py +0 -0
  49. {tilemap_parser-4.2.3 → tilemap_parser-4.2.4}/tests/test_tile_collision.py +0 -0
  50. {tilemap_parser-4.2.3 → 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.3
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.3"
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"
@@ -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)
@@ -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:
@@ -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.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
@@ -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
@@ -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