tilemap-parser 5.0.4__tar.gz → 5.1.0__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 (73) hide show
  1. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/PKG-INFO +1 -1
  2. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/pyproject.toml +1 -1
  3. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/platformer.py +2 -3
  4. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/queries.py +24 -13
  5. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/runner.py +10 -5
  6. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/navigation/nav_grid.py +45 -19
  7. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/world.py +70 -5
  8. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  9. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/SOURCES.txt +1 -0
  10. tilemap_parser-5.1.0/tests/test_gid_routing.py +293 -0
  11. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/LICENSE +0 -0
  12. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/README.md +0 -0
  13. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/pypi-readme.md +0 -0
  14. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/setup.cfg +0 -0
  15. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/__init__.py +0 -0
  16. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/__init__.py +0 -0
  17. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/animation.py +0 -0
  18. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/collision.py +0 -0
  19. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/collision_loader.py +0 -0
  20. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/map_parse.py +0 -0
  21. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/node_parse.py +0 -0
  22. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/particle.py +0 -0
  23. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  24. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/__init__.py +0 -0
  25. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/animation_player.py +0 -0
  26. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/area_node.py +0 -0
  27. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/body.py +0 -0
  28. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/camera.py +0 -0
  29. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision/__init__.py +0 -0
  30. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision/hit.py +0 -0
  31. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision/manager.py +0 -0
  32. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision/shapes.py +0 -0
  33. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  34. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/map_loader.py +0 -0
  35. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/map_object.py +0 -0
  36. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/__init__.py +0 -0
  37. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/grounded.py +0 -0
  38. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/rpg.py +0 -0
  39. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/slide.py +0 -0
  40. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/types.py +0 -0
  41. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/navigation/__init__.py +0 -0
  42. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/navigation/path_follower.py +0 -0
  43. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/navigation/pathfinder.py +0 -0
  44. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/object_collision.py +0 -0
  45. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/particles.py +0 -0
  46. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/polygon_query.py +0 -0
  47. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/protocols.py +0 -0
  48. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/renderer.py +0 -0
  49. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  50. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/utils/__init__.py +0 -0
  51. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser/utils/geometry.py +0 -0
  52. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  53. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/requires.txt +0 -0
  54. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  55. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_body.py +0 -0
  56. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_camera.py +0 -0
  57. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_collision.py +0 -0
  58. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_geometry.py +0 -0
  59. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_gid_collision.py +0 -0
  60. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_integration_animation.py +0 -0
  61. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_integration_collision.py +0 -0
  62. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_integration_map_loader.py +0 -0
  63. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_map_loader.py +0 -0
  64. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_map_object.py +0 -0
  65. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_move_grounded.py +0 -0
  66. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_navigation.py +0 -0
  67. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_object_collision.py +0 -0
  68. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_object_surfaces.py +0 -0
  69. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_particle_emitter.py +0 -0
  70. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_render_scale.py +0 -0
  71. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_tile_collision.py +0 -0
  72. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_tile_layer_renderer_y_sort.py +0 -0
  73. {tilemap_parser-5.0.4 → tilemap_parser-5.1.0}/tests/test_tmx_converter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 5.0.4
3
+ Version: 5.1.0
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 = "5.0.4"
7
+ version = "5.1.0"
8
8
  description = "Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime."
9
9
  readme = "pypi-readme.md"
10
10
  requires-python = ">=3.10"
@@ -8,6 +8,7 @@ from ...parser.collision import TilesetCollision
8
8
  from ..polygon_query import _check_sprite_polygon_offset, get_shape_bounds
9
9
  from ..protocols import ICollidableSprite
10
10
  from ..world import PhysicsWorld
11
+ from .queries import _resolve_tile_data
11
12
  from .types import CollisionResult, Vector2
12
13
 
13
14
 
@@ -118,9 +119,7 @@ def move_platformer(
118
119
  for tile_y in range(min_tile_y, max_tile_y + 1):
119
120
  for tile_x in range(min_tile_x, max_tile_x + 1):
120
121
  tile_id = tile_map.get((tile_x, tile_y))
121
- if tile_id is None:
122
- continue
123
- tile_data = tileset_collision.tiles.get(tile_id)
122
+ tile_data = _resolve_tile_data(world, tileset_collision, tile_id)
124
123
  if tile_data is None:
125
124
  continue
126
125
  ox = tile_x * tw
@@ -4,12 +4,31 @@ from __future__ import annotations
4
4
 
5
5
  import math
6
6
 
7
- from ...parser.collision import CollisionPolygon, TilesetCollision
7
+ from ...parser.collision import CollisionPolygon, TileCollisionData, TilesetCollision
8
8
  from ..collision.hit import should_collide
9
9
  from ..polygon_query import _check_sprite_polygon_offset, get_shape_bounds
10
10
  from ..protocols import ICollidable, ICollidableSprite
11
11
 
12
12
 
13
+ def _resolve_tile_data(
14
+ world,
15
+ tileset_collision: TilesetCollision,
16
+ tile_id: int | None,
17
+ ) -> TileCollisionData | None:
18
+ """Fetch collision data for *tile_id*, GID-routed when a world is attached.
19
+
20
+ With an attached world built via ``from_map(..., use_gids=True)`` the id
21
+ is range-routed against the map's grid resources (decoration tilesets can
22
+ never alias into the collision owner's local keys). Without a world the
23
+ historic literal lookup applies.
24
+ """
25
+ if tile_id is None:
26
+ return None
27
+ if world is not None:
28
+ return world.resolve_collision(tile_id)
29
+ return tileset_collision.tiles.get(tile_id)
30
+
31
+
13
32
  def _collides_at(
14
33
  self,
15
34
  sprite: ICollidable,
@@ -35,9 +54,7 @@ def _collides_at(
35
54
  for tile_y in range(min_tile_y, max_tile_y + 1):
36
55
  for tile_x in range(min_tile_x, max_tile_x + 1):
37
56
  tile_id = tile_map.get((tile_x, tile_y))
38
- if tile_id is None:
39
- continue
40
- tile_data = tileset_collision.tiles.get(tile_id)
57
+ tile_data = _resolve_tile_data(world, tileset_collision, tile_id)
41
58
  if tile_data is None:
42
59
  continue
43
60
  ox = tile_x * tw
@@ -72,9 +89,7 @@ def _first_colliding_shape(
72
89
  for tile_y in range(min_tile_y, max_tile_y + 1):
73
90
  for tile_x in range(min_tile_x, max_tile_x + 1):
74
91
  tile_id = tile_map.get((tile_x, tile_y))
75
- if tile_id is None:
76
- continue
77
- tile_data = tileset_collision.tiles.get(tile_id)
92
+ tile_data = _resolve_tile_data(world, tileset_collision, tile_id)
78
93
  if tile_data is None:
79
94
  continue
80
95
  ox = tile_x * tw
@@ -113,9 +128,7 @@ def _collides_at_platformer(
113
128
  for tile_y in range(min_tile_y, max_tile_y + 1):
114
129
  for tile_x in range(min_tile_x, max_tile_x + 1):
115
130
  tile_id = tile_map.get((tile_x, tile_y))
116
- if tile_id is None:
117
- continue
118
- tile_data = tileset_collision.tiles.get(tile_id)
131
+ tile_data = _resolve_tile_data(world, tileset_collision, tile_id)
119
132
  if tile_data is None:
120
133
  continue
121
134
  ox = tile_x * tw
@@ -218,9 +231,7 @@ def _find_walkable_ground_y(
218
231
  for tile_y in range(min_tile_y, max_tile_y + 1):
219
232
  for tile_x in range(min_tile_x, max_tile_x + 1):
220
233
  tile_id = tile_map.get((tile_x, tile_y))
221
- if tile_id is None:
222
- continue
223
- tile_data = tileset_collision.tiles.get(tile_id)
234
+ tile_data = _resolve_tile_data(world, tileset_collision, tile_id)
224
235
  if tile_data is None:
225
236
  continue
226
237
  ox = tile_x * tw
@@ -151,13 +151,19 @@ class CollisionRunner:
151
151
  tile_x, tile_y = self.get_tile_at(world_x, world_y)
152
152
  tile_id = tile_map.get((tile_x, tile_y))
153
153
 
154
- if tile_id is None or not tileset_collision.has_collision(tile_id):
154
+ world = self._resolve_world(None)
155
+ tile_data = queries._resolve_tile_data(world, tileset_collision, tile_id)
156
+ if tile_data is None:
155
157
  return []
156
158
 
157
159
  tile_world_x = tile_x * self._eff_tw
158
160
  tile_world_y = tile_y * self._eff_th
159
161
 
160
- return tileset_collision.get_world_shapes(tile_id, tile_world_x, tile_world_y, self.render_scale)
162
+ return [
163
+ shape.transform(tile_world_x, tile_world_y, self.render_scale)
164
+ for shape in tile_data.shapes
165
+ if shape.is_valid()
166
+ ]
161
167
 
162
168
  def get_nearby_tile_shapes(
163
169
  self,
@@ -182,12 +188,11 @@ class CollisionRunner:
182
188
  max_tile_y = int(bottom // th) + margin
183
189
 
184
190
  shapes = []
191
+ world = self._resolve_world(None)
185
192
  for tile_y in range(min_tile_y, max_tile_y + 1):
186
193
  for tile_x in range(min_tile_x, max_tile_x + 1):
187
194
  tile_id = tile_map.get((tile_x, tile_y))
188
- if tile_id is None:
189
- continue
190
- tile_data = tileset_collision.tiles.get(tile_id)
195
+ tile_data = queries._resolve_tile_data(world, tileset_collision, tile_id)
191
196
  if tile_data is None:
192
197
  continue
193
198
  tile_world_x = tile_x * tw
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import math
4
4
  from typing import Dict, List, Optional, Tuple
5
5
 
6
- from ...parser.collision import TilesetCollision
6
+ from ...parser.collision import TileCollisionData, TilesetCollision
7
7
 
8
8
 
9
9
  class NavGrid:
@@ -15,14 +15,15 @@ class NavGrid:
15
15
  """
16
16
 
17
17
  __slots__ = (
18
- "tile_map",
19
- "tileset_collision",
20
- "tile_size",
21
- "_eff_tw",
22
18
  "_eff_th",
23
- "_width",
19
+ "_eff_tw",
20
+ "_gid_resolver",
24
21
  "_height",
25
22
  "_walkable",
23
+ "_width",
24
+ "tile_map",
25
+ "tile_size",
26
+ "tileset_collision",
26
27
  )
27
28
 
28
29
  def __init__(
@@ -32,9 +33,14 @@ class NavGrid:
32
33
  tile_size: Tuple[int, int],
33
34
  render_scale: float = 1.0,
34
35
  map_size: Optional[Tuple[int, int]] = None,
36
+ gid_resolver=None,
35
37
  ) -> None:
36
38
  self.tile_map = tile_map
37
39
  self.tileset_collision = tileset_collision
40
+ # Optional callable (int) -> TileCollisionData | None. Pass
41
+ # ``physics_world.resolve_collision`` for GID-routed maps so ids from
42
+ # non-collidable grid tilesets resolve to None instead of aliasing.
43
+ self._gid_resolver = gid_resolver
38
44
  self.tile_size = tile_size
39
45
  self._eff_tw = tile_size[0] * render_scale
40
46
  self._eff_th = tile_size[1] * render_scale
@@ -52,11 +58,15 @@ class NavGrid:
52
58
 
53
59
  self._walkable = [[self._is_tile_walkable(x, y) for x in range(self._width)] for y in range(self._height)]
54
60
 
55
- def _is_tile_walkable(self, tx: int, ty: int) -> bool:
56
- tile_id = self.tile_map.get((tx, ty))
61
+ def _resolve(self, tile_id):
57
62
  if tile_id is None:
58
- return True
59
- tile_data = self.tileset_collision.tiles.get(tile_id)
63
+ return None
64
+ if self._gid_resolver is not None:
65
+ return self._gid_resolver(tile_id)
66
+ return self.tileset_collision.tiles.get(tile_id)
67
+
68
+ def _is_tile_walkable(self, tx: int, ty: int) -> bool:
69
+ tile_data = self._resolve(self.tile_map.get((tx, ty)))
60
70
  if tile_data is None:
61
71
  return True
62
72
  for poly in tile_data.shapes:
@@ -78,10 +88,7 @@ class NavGrid:
78
88
  return self._walkable[ty][tx]
79
89
 
80
90
  def is_one_way(self, tx: int, ty: int) -> bool:
81
- tile_id = self.tile_map.get((tx, ty))
82
- if tile_id is None:
83
- return False
84
- tile_data = self.tileset_collision.tiles.get(tile_id)
91
+ tile_data = self._resolve(self.tile_map.get((tx, ty)))
85
92
  if tile_data is None:
86
93
  return False
87
94
  has_one_way = False
@@ -104,6 +111,10 @@ class NavGrid:
104
111
  new._width = self._width
105
112
  new._height = self._height
106
113
  new._walkable = [row[:] for row in self._walkable]
114
+ # _gid_resolver lives in __slots__: dropping it here made every
115
+ # derived grid raise AttributeError once _resolve() ran (e.g. via
116
+ # is_one_way), and silently lost GID routing before that.
117
+ new._gid_resolver = self._gid_resolver
107
118
  return new
108
119
 
109
120
  def erode(self, margin: float) -> NavGrid:
@@ -155,16 +166,31 @@ class NavGrid:
155
166
  sprite_height: Optional[float] = None,
156
167
  render_scale: float = 1.0,
157
168
  map_size: Optional[Tuple[int, int]] = None,
158
- cache: Optional[Dict[float, NavGrid]] = None,
169
+ cache: Optional[dict[tuple[float, bool], NavGrid]] = None,
170
+ gid_resolver=None,
159
171
  ) -> NavGrid:
172
+ """Build (or fetch from *cache*) an eroded grid for one entity size.
173
+
174
+ The cache is scoped to a single map/routing context and is keyed by
175
+ ``(margin, resolver_is_literal)`` so literal and GID-routed grids can
176
+ never cross-contaminate through a shared cache dict.
177
+ """
160
178
  tw = tile_size[0] * render_scale
161
179
  size = max(sprite_width, sprite_height if sprite_height is not None else sprite_width)
162
180
  margin = (size / 2.0) / tw
163
- if cache is not None and margin in cache:
164
- return cache[margin]
165
- nav = cls(tile_map, tileset_collision, tile_size, render_scale, map_size).erode(margin)
181
+ key = (margin, gid_resolver is None)
182
+ if cache is not None and key in cache:
183
+ return cache[key]
184
+ nav = cls(
185
+ tile_map,
186
+ tileset_collision,
187
+ tile_size,
188
+ render_scale,
189
+ map_size,
190
+ gid_resolver=gid_resolver,
191
+ ).erode(margin)
166
192
  if cache is not None:
167
- cache[margin] = nav
193
+ cache[key] = nav
168
194
  return nav
169
195
 
170
196
  def get_neighbors(self, tx: int, ty: int, *, diagonals: bool = False) -> List[Tuple[int, int]]:
@@ -9,14 +9,18 @@ resolves movement against the world's tiles AND bodies uniformly.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from typing import Dict, List, Optional, Tuple
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
13
14
 
14
- from ..parser.collision import TilesetCollision
15
+ from ..parser.collision import TileCollisionData, TilesetCollision
15
16
  from .body import Body
16
17
  from .collision.hit import check_collision
17
18
  from .map_loader import TilemapData
18
19
  from .protocols import ICollidable
19
20
 
21
+ if TYPE_CHECKING: # pragma: no cover
22
+ from ..parser.map_parse import ParsedTileset
23
+
20
24
 
21
25
  class PhysicsWorld:
22
26
  """A space containing a tile layer and solid bodies."""
@@ -45,6 +49,11 @@ class PhysicsWorld:
45
49
  self.tile_size = tuple(tile_size)
46
50
  self.render_scale = render_scale
47
51
  self.bodies: List[Body] = []
52
+ # GID routing (see `resolve_collision`): (firstgid, tile_count, stem)
53
+ # for every *grid* resource of the source map, plus the stem that the
54
+ # single collision file belongs to. Empty => literal local lookups.
55
+ self._grid_ranges: List[Tuple[int, int, str]] = []
56
+ self._collision_owner_stem: Optional[str] = None
48
57
  if self.tile_map and self.tileset_collision is None:
49
58
  raise ValueError(
50
59
  "tile_map requires tileset_collision: a world with solid "
@@ -91,12 +100,68 @@ class PhysicsWorld:
91
100
  "tiles cannot resolve movement without collision data"
92
101
  )
93
102
  world.tileset_collision = tileset_collision
103
+ if use_gids:
104
+ world._capture_grid_ownership(tilemap_data, tileset_collision)
94
105
  return world
95
106
 
96
- # ------------------------------------------------------------------
97
- # Body management
98
- # ------------------------------------------------------------------
107
+
108
+ def _capture_grid_ownership(
109
+ self, map_data: TilemapData, tileset_collision: TilesetCollision
110
+ ) -> None:
111
+ """Record every grid resource's GID range and the collision owner.
99
112
 
113
+ Only ``type=="tile"`` resources participate; object tilesets are
114
+ never part of the physics grid. Routing activates only when the
115
+ collision file's ``tileset_name`` stem-matches one of them —
116
+ otherwise lookups stay literal (legacy / pre-merged files).
117
+ """
118
+ owner = tileset_collision.tileset_name
119
+ ranges: List[Tuple[int, int, str]] = []
120
+ matched: Optional[str] = None
121
+ for ts in map_data.parsed.tilesets:
122
+ if getattr(ts, "type", "tile") != "tile":
123
+ continue
124
+ stem = Path(ts.path).stem
125
+ ranges.append((ts.firstgid, ts.tile_count, stem))
126
+ if stem == owner and matched is None:
127
+ matched = stem
128
+ if matched is not None:
129
+ self._grid_ranges = ranges
130
+ self._collision_owner_stem = matched
131
+
132
+ def resolve_collision(self, tile_id: int) -> Optional[TileCollisionData]:
133
+ """Resolve a possibly-global tile id to its collision data.
134
+
135
+ With GID routing active (``from_map(..., use_gids=True)`` plus a
136
+ stem-matched collision file):
137
+
138
+ 1. find the grid resource whose ``[firstgid, firstgid+count)``
139
+ window contains *tile_id*;
140
+ 2. if that resource is **not** the collision owner → ``None``
141
+ (decoration grids are never solid, no cross-set aliasing);
142
+ 3. else translate ``tile_id - firstgid`` to the local key.
143
+
144
+ Without routing (literal mode, or a pre-merged GID-keyed
145
+ collision), falls back to a plain dictionary lookup — identical
146
+ to historic behaviour.
147
+ """
148
+ if self._grid_ranges and self.tileset_collision is not None:
149
+ for firstgid, count, stem in self._grid_ranges:
150
+ if firstgid <= tile_id < firstgid + count:
151
+ if stem != self._collision_owner_stem:
152
+ return None
153
+ return self.tileset_collision.tiles.get(tile_id - firstgid)
154
+ return None
155
+ if self.tileset_collision is None:
156
+ return None
157
+ return self.tileset_collision.tiles.get(tile_id)
158
+
159
+ def has_collision_gid(self, tile_id: int) -> bool:
160
+ """Convenience boolean form of :meth:`resolve_collision`."""
161
+ data = self.resolve_collision(tile_id)
162
+ return data is not None and data.has_collision()
163
+
164
+ # body
100
165
  def add_body(self, body: Body) -> None:
101
166
  """Add a body to the world. Adding the same body twice is a no-op."""
102
167
  if body not in self.bodies:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 5.0.4
3
+ Version: 5.1.0
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
@@ -54,6 +54,7 @@ tests/test_camera.py
54
54
  tests/test_collision.py
55
55
  tests/test_geometry.py
56
56
  tests/test_gid_collision.py
57
+ tests/test_gid_routing.py
57
58
  tests/test_integration_animation.py
58
59
  tests/test_integration_collision.py
59
60
  tests/test_integration_map_loader.py
@@ -0,0 +1,293 @@
1
+ """GID range-routing tests: single grid collision file, multi-tileset maps.
2
+
3
+ Regression ground: with ``use_gids=True`` and a collision file whose
4
+ ``firstgid != 0``, literal lookups silently missed (e.g. only 1 of many
5
+ collidable tiles resolved). Naive per-lookup offset subtraction also aliases:
6
+ a decoration grid whose GID window lands inside the owner's *local*
7
+ key space (e.g. gid ``1813 - 90 = 1723``) would become falsely solid
8
+ the moment the owner gains that local key.
9
+
10
+ Routing must therefore resolve ownership against the map's grid
11
+ resource table BEFORE translating.
12
+ """
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from tilemap_parser.parser.collision import (
20
+ CollisionPolygon,
21
+ TileCollisionData,
22
+ TilesetCollision,
23
+ )
24
+ from tilemap_parser.runtime.navigation.nav_grid import NavGrid
25
+ from tilemap_parser.runtime.world import PhysicsWorld
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # builders
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def solid(x=8.0, y=8.0):
32
+ return CollisionPolygon(vertices=[(0, 0), (x, 0), (x, y), (0, y)])
33
+
34
+
35
+ def make_col(name: str, keys: dict[int, list]) -> TilesetCollision:
36
+ return TilesetCollision(
37
+ tileset_name=name,
38
+ tile_size=(8, 8),
39
+ tiles={k: TileCollisionData(tile_id=k, shapes=v) for k, v in keys.items()},
40
+ )
41
+
42
+
43
+ def stamp(world: PhysicsWorld, ranges, owner_stem: str | None) -> None:
44
+ """Simulate what from_map captures from map_data.parsed.tilesets."""
45
+ world._grid_ranges = list(ranges)
46
+ world._collision_owner_stem = owner_stem
47
+
48
+
49
+
50
+ RANGES = [
51
+ (0, 90, "cave"), # decoration grid
52
+ (90, 1750, "tileset"), # collision owner
53
+ (1840, 56, "cave_hole"), # decoration grid
54
+ (1896, 1296, "bg"), # decoration grid
55
+ ]
56
+ COL_KEYS = {1: [solid()], 2: [solid()], 575: [solid()], 981: [solid()]}
57
+
58
+
59
+ @pytest.fixture()
60
+ def routed_world() -> PhysicsWorld:
61
+ w = PhysicsWorld(
62
+ tile_map={(0, 6): 92, (3, 6): 1813},
63
+ tileset_collision=make_col("tileset", COL_KEYS),
64
+ tile_size=(8, 8),
65
+ render_scale=3.0,
66
+ )
67
+ stamp(w, RANGES, "tileset")
68
+ return w
69
+
70
+
71
+ class TestRoutingMatrix:
72
+ def test_owner_gid_translates_to_local(self, routed_world):
73
+ assert routed_world.resolve_collision(92) is not None # local 2
74
+ assert routed_world.has_collision_gid(92) is True
75
+
76
+ def test_decoration_grid_never_solids__alias_regression(self, routed_world):
77
+ # gid 1813 belongs to cave_hole; naive `gid-90` would hit local 1723.
78
+ assert routed_world.resolve_collision(1813) is None
79
+ assert routed_world.has_collision_gid(1813) is False
80
+
81
+ def test_other_owner_windows_rejected(self, routed_world):
82
+ assert routed_world.resolve_collision(5) is None # cave window
83
+ assert routed_world.resolve_collision(1900) is None # bg window
84
+ assert routed_world.resolve_collision(1840 + 4) is None # hole window
85
+
86
+ def test_unmapped_gap_is_none(self, routed_world):
87
+ # 90+1750+56+1296 == 3192: first id past every grid window
88
+ assert routed_world.resolve_collision(3192) is None
89
+ assert routed_world.resolve_collision(5000) is None
90
+
91
+ def test_owner_local_keys_without_collision_shapes(self, routed_world):
92
+ # owner-local 50 exists as a gid but has no entry -> None, not crash
93
+ assert routed_world.resolve_collision(90 + 50) is None
94
+
95
+ def test_boundary_edges(self, routed_world):
96
+ assert routed_world.resolve_collision(89) is None # last cave gid
97
+ # inclusive owner lower edge: local 0 exists in the window but has no
98
+ # collision entry -> None (must not fall through to another range)
99
+ assert routed_world.resolve_collision(90) is None
100
+ assert routed_world.resolve_collision(90 + 1749) is None # last owner gid, keyless
101
+
102
+
103
+ class TestLiteralFallback:
104
+ def test_no_routing_keeps_literal_lookup(self):
105
+ w = PhysicsWorld(
106
+ tile_map={(0, 0): 2},
107
+ tileset_collision=make_col("tileset", COL_KEYS),
108
+ tile_size=(8, 8),
109
+ )
110
+ assert w._grid_ranges == []
111
+ assert w.resolve_collision(2) is not None
112
+ assert w.has_collision_gid(2) is True
113
+
114
+ def test_merged_gid_keyed_file_stays_literal(self):
115
+ # merge() output: keys ARE gids, no stem match possible
116
+ merged = make_col("merged", {92: [solid()]})
117
+ w = PhysicsWorld(
118
+ tile_map={(0, 6): 92}, tileset_collision=merged, tile_size=(8, 8)
119
+ )
120
+ stamp(w, [], None)
121
+ assert w.resolve_collision(92) is not None
122
+
123
+
124
+ class TestFromMapCapture:
125
+ def _payload(self, tmp: Path):
126
+ (tmp / "a.png").write_bytes(b"x") # existence not required (skip_missing_images)
127
+ (tmp / "b.png").write_bytes(b"x")
128
+ return {
129
+ "meta": {
130
+ "tile_size": "8;8",
131
+ "map_size": "4;4",
132
+ "offset": "0;0",
133
+ "render_scale": 1.0,
134
+ "version": "1.1",
135
+ },
136
+ "resources": {
137
+ "tilesets": [
138
+ {"path": str(tmp / "a.png"), "type": "tile", "tile_count": 4, "firstgid": 0},
139
+ {"path": str(tmp / "b.png"), "type": "tile", "tile_count": 4, "firstgid": 4},
140
+ {"path": str(tmp / "c.png"), "type": "object", "tile_count": 2, "firstgid": 8},
141
+ ]
142
+ },
143
+ "project_state": {"rules": []},
144
+ "data": {
145
+ "layers": [
146
+ {
147
+ "name": "L", "type": "tile", "visible": True,
148
+ "tiles": {
149
+ "0;0": {"pos": "0;0", "ttype": 1, "variant": 0, "gid": 4},
150
+ "1;0": {"pos": "1;0", "ttype": 0, "variant": 1, "gid": 1},
151
+ },
152
+ }
153
+ ]
154
+ },
155
+ }
156
+
157
+ def test_from_map_auto_captures_owner_and_routes(self, tmp_path):
158
+ from tilemap_parser.runtime.map_loader import load_map
159
+
160
+ payload_path = tmp_path / "m.json"
161
+ payload_path.write_text(json.dumps(self._payload(tmp_path)))
162
+
163
+ map_data = load_map(payload_path)
164
+ col = make_col("b", {0: [solid()]}) # local-keyed file for resource b
165
+
166
+ world = PhysicsWorld.from_map(map_data, col, use_gids=True)
167
+
168
+ # gid 4 -> owner b -> local 0 -> solid; gid 1 -> owner a -> decor
169
+ assert world.has_collision_gid(4) is True
170
+ assert world.has_collision_gid(1) is False
171
+ # object resource c never participates even though its window follows
172
+ assert world.resolve_collision(8) is None
173
+
174
+ def test_from_map_literal_mode_untouched(self, tmp_path):
175
+ from tilemap_parser.runtime.map_loader import load_map
176
+
177
+ payload_path = tmp_path / "m.json"
178
+ payload_path.write_text(json.dumps(self._payload(tmp_path)))
179
+ map_data = load_map(payload_path)
180
+ col = make_col("b", {0: [solid(), solid()]})
181
+
182
+ world = PhysicsWorld.from_map(map_data, col, use_gids=False)
183
+ assert world._grid_ranges == []
184
+ assert world.resolve_collision(0) is not None # literal local id
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # NavGrid resolver propagation (copy / erode / for_entity)
189
+ # ---------------------------------------------------------------------------
190
+
191
+ class TestNavGridResolverPropagation:
192
+ def _world(self):
193
+ world = PhysicsWorld(
194
+ tile_map={(0, 6): 92, (3, 6): 1813},
195
+ tileset_collision=make_col("tileset", COL_KEYS),
196
+ tile_size=(8, 8),
197
+ )
198
+ stamp(world, RANGES, "tileset")
199
+ return world
200
+
201
+ def test_copy_preserves_resolver_and_does_not_crash(self):
202
+ w = self._world()
203
+ base = NavGrid(w.tile_map, w.tileset_collision, w.tile_size, gid_resolver=w.resolve_collision)
204
+
205
+ derived = base.copy() # used to lack the slot entirely
206
+ assert derived._gid_resolver is not None
207
+
208
+ # is_one_way hits _resolve(): raised AttributeError on copies before
209
+ derived.is_one_way(0, 6)
210
+ # routed semantics survive the copy: owner solid, decoration not
211
+ assert derived.is_solid(0, 6) is True # gid 92 -> owner local 2
212
+ assert derived.is_solid(3, 6) is False # gid 1813 -> other grid resource
213
+
214
+ eroded = base.erode(1.0)
215
+ eroded.is_one_way(0, 6)
216
+ assert eroded._gid_resolver is not None
217
+
218
+ def test_literal_grid_unchanged_without_resolver(self):
219
+ grid = NavGrid({(0, 0): 2}, make_col("t", COL_KEYS), (8, 8))
220
+ assert grid.copy()._gid_resolver is None
221
+ assert grid.is_solid(0, 0) is True # literal local key 2
222
+ grid.is_one_way(0, 0) # must not raise
223
+
224
+ def test_cache_keys_separate_resolver_modes(self):
225
+ w = self._world()
226
+ cache: dict = {}
227
+
228
+ literal = NavGrid.for_entity(
229
+ w.tile_map, w.tileset_collision, w.tile_size,
230
+ sprite_width=10.0, sprite_height=10.0, cache=cache,
231
+ )
232
+ routed = NavGrid.for_entity(
233
+ w.tile_map, w.tileset_collision, w.tile_size,
234
+ sprite_width=10.0, sprite_height=10.0, cache=cache,
235
+ gid_resolver=w.resolve_collision,
236
+ )
237
+
238
+ assert len(cache) == 2, "modes must occupy distinct cache slots"
239
+ assert literal is not routed
240
+ assert literal._gid_resolver is None
241
+ assert routed._gid_resolver is not None
242
+ # same inputs, different semantics: owner tile solid only when routed
243
+ assert literal.is_solid(0, 6) is False # literal lookup of gid 92 misses
244
+ assert routed.is_solid(0, 6) is True # routed resolves to local 2
245
+
246
+ def test_cache_reuses_entry_within_same_mode(self):
247
+ w = self._world()
248
+ cache: dict = {}
249
+ a = NavGrid.for_entity(
250
+ w.tile_map, w.tileset_collision, w.tile_size,
251
+ sprite_width=16.0, cache=cache, gid_resolver=w.resolve_collision,
252
+ )
253
+ b = NavGrid.for_entity(
254
+ w.tile_map, w.tileset_collision, w.tile_size,
255
+ sprite_width=16.0, cache=cache, gid_resolver=w.resolve_collision,
256
+ )
257
+ assert a is b, "same margin + same mode must hit the cached grid"
258
+
259
+ lit_a = NavGrid.for_entity(
260
+ w.tile_map, w.tileset_collision, w.tile_size,
261
+ sprite_width=16.0, cache=cache,
262
+ )
263
+ lit_b = NavGrid.for_entity(
264
+ w.tile_map, w.tileset_collision, w.tile_size,
265
+ sprite_width=16.0, cache=cache,
266
+ )
267
+ assert lit_a is lit_b and lit_a is not a
268
+ assert len(cache) == 2
269
+
270
+ def test_for_entity_forwards_resolver(self):
271
+ w = self._world()
272
+ grid = NavGrid.for_entity(
273
+ w.tile_map,
274
+ w.tileset_collision,
275
+ w.tile_size,
276
+ sprite_width=10.0,
277
+ sprite_height=10.0,
278
+ gid_resolver=w.resolve_collision,
279
+ )
280
+ assert grid._gid_resolver is not None
281
+ # owner tile stays solid under entity clearance; decoration never was
282
+ assert grid.is_solid(0, 6) is True
283
+ assert grid.is_solid(3, 6) is False
284
+
285
+ # parity: identical construction without a resolver would alias the
286
+ # decoration gid into local 1723 (keyless today, so also False) --
287
+ # assert via direct literal probe that routing, not luck, decides.
288
+ literal = NavGrid.for_entity(
289
+ w.tile_map, w.tileset_collision, w.tile_size, sprite_width=10.0
290
+ )
291
+ assert literal._gid_resolver is None
292
+ assert literal.is_solid(0, 6) is False # literal lookup of gid 92 misses
293
+ assert grid.is_solid(0, 6) is True # routed lookup resolves it
File without changes
File without changes
File without changes