tilemap-parser 5.0.3__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.3 → tilemap_parser-5.1.0}/PKG-INFO +1 -1
  2. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/pyproject.toml +1 -1
  3. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/collision.py +53 -3
  4. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/collision_loader.py +4 -1
  5. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/animation_player.py +49 -8
  6. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision_cache.py +12 -6
  7. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/platformer.py +2 -3
  8. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/queries.py +24 -13
  9. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/runner.py +10 -5
  10. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/navigation/nav_grid.py +45 -19
  11. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/particles.py +18 -0
  12. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/world.py +70 -5
  13. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  14. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/SOURCES.txt +1 -0
  15. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_collision.py +121 -1
  16. tilemap_parser-5.1.0/tests/test_gid_routing.py +293 -0
  17. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_integration_animation.py +125 -0
  18. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/LICENSE +0 -0
  19. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/README.md +0 -0
  20. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/pypi-readme.md +0 -0
  21. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/setup.cfg +0 -0
  22. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/__init__.py +0 -0
  23. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/__init__.py +0 -0
  24. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/animation.py +0 -0
  25. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/map_parse.py +0 -0
  26. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/node_parse.py +0 -0
  27. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/particle.py +0 -0
  28. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  29. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/__init__.py +0 -0
  30. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/area_node.py +0 -0
  31. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/body.py +0 -0
  32. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/camera.py +0 -0
  33. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision/__init__.py +0 -0
  34. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision/hit.py +0 -0
  35. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision/manager.py +0 -0
  36. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/collision/shapes.py +0 -0
  37. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/map_loader.py +0 -0
  38. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/map_object.py +0 -0
  39. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/__init__.py +0 -0
  40. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/grounded.py +0 -0
  41. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/rpg.py +0 -0
  42. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/slide.py +0 -0
  43. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/movement/types.py +0 -0
  44. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/navigation/__init__.py +0 -0
  45. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/navigation/path_follower.py +0 -0
  46. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/navigation/pathfinder.py +0 -0
  47. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/object_collision.py +0 -0
  48. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/polygon_query.py +0 -0
  49. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/protocols.py +0 -0
  50. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/renderer.py +0 -0
  51. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  52. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/utils/__init__.py +0 -0
  53. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser/utils/geometry.py +0 -0
  54. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  55. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/requires.txt +0 -0
  56. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  57. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_body.py +0 -0
  58. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_camera.py +0 -0
  59. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_geometry.py +0 -0
  60. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_gid_collision.py +0 -0
  61. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_integration_collision.py +0 -0
  62. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_integration_map_loader.py +0 -0
  63. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_map_loader.py +0 -0
  64. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_map_object.py +0 -0
  65. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_move_grounded.py +0 -0
  66. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_navigation.py +0 -0
  67. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_object_collision.py +0 -0
  68. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_object_surfaces.py +0 -0
  69. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_particle_emitter.py +0 -0
  70. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_render_scale.py +0 -0
  71. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_tile_collision.py +0 -0
  72. {tilemap_parser-5.0.3 → tilemap_parser-5.1.0}/tests/test_tile_layer_renderer_y_sort.py +0 -0
  73. {tilemap_parser-5.0.3 → 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.3
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.3"
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"
@@ -9,6 +9,7 @@ Provides parsing of collision data from JSON into data classes:
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import math
12
13
  from dataclasses import dataclass, field
13
14
  from typing import Any, Dict, List, Optional, Tuple, Union
14
15
 
@@ -34,6 +35,13 @@ class CollisionPolygon:
34
35
  world_vertices = [(tile_x + vx * scale, tile_y + vy * scale) for vx, vy in self.vertices]
35
36
  return CollisionPolygon(vertices=world_vertices, one_way=self.one_way)
36
37
 
38
+ def scaled(self, scale: float) -> "CollisionPolygon":
39
+ """Return a copy with all vertices multiplied by *scale*."""
40
+ return CollisionPolygon(
41
+ vertices=[(vx * scale, vy * scale) for vx, vy in self.vertices],
42
+ one_way=self.one_way,
43
+ )
44
+
37
45
  def is_valid(self) -> bool:
38
46
  """Check if polygon has at least 3 vertices"""
39
47
  return len(self.vertices) >= 3
@@ -130,6 +138,14 @@ class RectangleShape:
130
138
  top = y + self.offset[1]
131
139
  return (left, top, left + self.width, top + self.height)
132
140
 
141
+ def scaled(self, scale: float) -> "RectangleShape":
142
+ """Return a copy with dimensions and offset multiplied by *scale*."""
143
+ return RectangleShape(
144
+ width=self.width * scale,
145
+ height=self.height * scale,
146
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
147
+ )
148
+
133
149
 
134
150
  @dataclass
135
151
  class CircleShape:
@@ -142,6 +158,13 @@ class CircleShape:
142
158
  """Get center position in world space"""
143
159
  return (x + self.offset[0], y + self.offset[1])
144
160
 
161
+ def scaled(self, scale: float) -> "CircleShape":
162
+ """Return a copy with radius and offset multiplied by *scale*."""
163
+ return CircleShape(
164
+ radius=self.radius * scale,
165
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
166
+ )
167
+
145
168
 
146
169
  @dataclass
147
170
  class CapsuleShape:
@@ -159,6 +182,14 @@ class CapsuleShape:
159
182
  """Get bottom circle center in world space"""
160
183
  return (x + self.offset[0], y + self.offset[1] + self.height)
161
184
 
185
+ def scaled(self, scale: float) -> "CapsuleShape":
186
+ """Return a copy with radius, height, and offset multiplied by *scale*."""
187
+ return CapsuleShape(
188
+ radius=self.radius * scale,
189
+ height=self.height * scale,
190
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
191
+ )
192
+
162
193
 
163
194
  CharacterShapeType = Union[RectangleShape, CircleShape, CapsuleShape, CollisionPolygon]
164
195
 
@@ -173,6 +204,16 @@ class CharacterCollision:
173
204
  collision_layer: int = 1
174
205
  collision_mask: int = 0xFFFFFFFF
175
206
 
207
+ def scaled(self, scale: float) -> "CharacterCollision":
208
+ """Return a copy whose shape is scaled by *scale* (typically ``render_scale``)."""
209
+ return CharacterCollision(
210
+ name=self.name,
211
+ shape=self.shape.scaled(scale),
212
+ properties=dict(self.properties),
213
+ collision_layer=self.collision_layer,
214
+ collision_mask=self.collision_mask,
215
+ )
216
+
176
217
 
177
218
  @dataclass
178
219
  class ObjectCollisionRegionData:
@@ -255,19 +296,28 @@ def parse_tileset_collision(data: JsonDict) -> TilesetCollision:
255
296
  raise CollisionParseError(f"Invalid tileset collision data: {e}") from e
256
297
 
257
298
 
258
- def parse_character_collision(data: JsonDict) -> CharacterCollision:
299
+ def parse_character_collision(data: JsonDict, render_scale: float = 1.0) -> CharacterCollision:
259
300
  """
260
301
  Parse character collision data from dictionary.
261
302
 
262
303
  Args:
263
304
  data: Dictionary loaded from .collision.json file
305
+ render_scale: Multiplier applied to shape dimensions and offsets
306
+ (no-op when 1.0).
264
307
 
265
308
  Returns:
266
309
  CharacterCollision object
267
310
 
268
311
  Raises:
269
- CollisionParseError: If data format is invalid
312
+ CollisionParseError: If data format is invalid or render_scale is not
313
+ finite and greater than zero.
270
314
  """
315
+ if (
316
+ not isinstance(render_scale, (int, float))
317
+ or not math.isfinite(render_scale)
318
+ or render_scale <= 0
319
+ ):
320
+ raise CollisionParseError(f"render_scale must be finite and > 0, got {render_scale!r}")
271
321
  try:
272
322
  name = data["name"]
273
323
  shape_data = data["shape"]
@@ -312,7 +362,7 @@ def parse_character_collision(data: JsonDict) -> CharacterCollision:
312
362
  properties=properties,
313
363
  collision_layer=collision_layer,
314
364
  collision_mask=collision_mask,
315
- )
365
+ ).scaled(render_scale)
316
366
  except (KeyError, ValueError, TypeError) as e:
317
367
  raise CollisionParseError(f"Invalid character collision data: {e}") from e
318
368
 
@@ -55,6 +55,7 @@ def load_tileset_collision(
55
55
 
56
56
  def load_character_collision(
57
57
  collision_path: Union[str, Path],
58
+ render_scale: float = 1.0,
58
59
  ) -> CharacterCollision | None:
59
60
  """
60
61
  Load character collision data from a collision JSON file.
@@ -64,6 +65,8 @@ def load_character_collision(
64
65
 
65
66
  Args:
66
67
  collision_path: Direct path to the .collision.json file.
68
+ render_scale: Multiplier applied to shape dimensions and offsets
69
+ (no-op when 1.0).
67
70
 
68
71
  Returns:
69
72
  CharacterCollision object, or None if the file does not exist.
@@ -79,7 +82,7 @@ def load_character_collision(
79
82
  try:
80
83
  with open(collision_path, "r", encoding="utf-8") as f:
81
84
  data = json.load(f)
82
- return parse_character_collision(data)
85
+ return parse_character_collision(data, render_scale=render_scale)
83
86
  except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
84
87
  raise CollisionParseError(f"Cannot load {collision_path}: {e}") from e
85
88
 
@@ -20,6 +20,9 @@ class SpriteAnimationSet:
20
20
  json_path: Optional[Path] = None
21
21
  grid_offset_x: int = 0
22
22
  grid_offset_y: int = 0
23
+ render_scale: float = 1.0
24
+ grid_columns: int = 0
25
+ grid_rows: int = 0
23
26
 
24
27
  @classmethod
25
28
  def load(
@@ -28,6 +31,7 @@ class SpriteAnimationSet:
28
31
  *,
29
32
  spritesheet_path: Optional[PathLike] = None,
30
33
  extra_search_base: Optional[Path] = None,
34
+ render_scale: float = 1.0,
31
35
  ) -> "SpriteAnimationSet":
32
36
  path = Path(json_path)
33
37
  library = parse_animation_file(path)
@@ -59,6 +63,29 @@ class SpriteAnimationSet:
59
63
  except pygame.error as e:
60
64
  raise AnimationParseError(f"Failed to load image {image_path}: {e}") from e
61
65
 
66
+ tw, th = library.tile_size
67
+ ox, oy = library.grid_offset
68
+ grid_columns = max(1, (surface.get_width() - ox) // tw)
69
+ grid_rows = max(1, (surface.get_height() - oy) // th)
70
+
71
+ if render_scale != 1.0:
72
+ if render_scale <= 0:
73
+ raise ValueError(f"render_scale must be > 0, got {render_scale}")
74
+ w, h = surface.get_size()
75
+ sw, sh = int(w * render_scale), int(h * render_scale)
76
+ stw, sth = int(tw * render_scale), int(th * render_scale)
77
+ if sw < 1 or sh < 1 or stw < 1 or sth < 1:
78
+ raise ValueError(f"render_scale {render_scale} produces zero-sized atlas cells")
79
+ if (
80
+ int(ox * render_scale) + grid_columns * stw > sw
81
+ or int(oy * render_scale) + grid_rows * sth > sh
82
+ ):
83
+ raise ValueError(
84
+ f"render_scale {render_scale} cannot maintain atlas grid cell bounds"
85
+ )
86
+ surface = pygame.transform.scale(surface, (sw, sh))
87
+ library = _scaled_library(library, render_scale)
88
+
62
89
  return cls(
63
90
  library=library,
64
91
  surface=surface,
@@ -66,6 +93,9 @@ class SpriteAnimationSet:
66
93
  json_path=path,
67
94
  grid_offset_x=library.grid_offset[0],
68
95
  grid_offset_y=library.grid_offset[1],
96
+ render_scale=render_scale,
97
+ grid_columns=grid_columns,
98
+ grid_rows=grid_rows,
69
99
  )
70
100
 
71
101
  def get_content_bounds(self, clip_name: str) -> Optional[Rect]:
@@ -88,15 +118,10 @@ class SpriteAnimationSet:
88
118
 
89
119
  def get_image(self, variant_id: int, *, copy_surface: bool = True) -> Optional[Surface]:
90
120
  tw, th = self.library.tile_size
91
- if tw <= 0 or th <= 0:
92
- return None
93
- available_w = self.surface.get_width() - self.grid_offset_x
94
- available_h = self.surface.get_height() - self.grid_offset_y
95
- if available_w < tw or available_h < th:
121
+ if tw <= 0 or th <= 0 or self.grid_columns <= 0 or self.grid_rows <= 0:
96
122
  return None
97
- cols = max(1, available_w // tw)
98
- col = variant_id % cols
99
- row = variant_id // cols
123
+ col = variant_id % self.grid_columns
124
+ row = variant_id // self.grid_columns
100
125
  src = Rect(self.grid_offset_x + col * tw, self.grid_offset_y + row * th, tw, th)
101
126
  if not self.surface.get_rect().contains(src):
102
127
  return None
@@ -161,3 +186,19 @@ class AnimationPlayer:
161
186
  if variant not in self._frame_cache:
162
187
  self._frame_cache[variant] = self.animation_set.get_image(variant, copy_surface=True)
163
188
  return self._frame_cache[variant]
189
+
190
+
191
+ def _scaled_library(library: AnimationLibrary, scale: float) -> AnimationLibrary:
192
+ """Return a copy of *library* whose grid geometry is scaled by *scale*.
193
+
194
+ Kept in lockstep with the scaled spritesheet surface so that
195
+ :meth:`SpriteAnimationSet.get_image` continues to address cells
196
+ correctly.
197
+ """
198
+ return AnimationLibrary(
199
+ animations=library.animations,
200
+ spritesheet_path=library.spritesheet_path,
201
+ tile_size=(int(library.tile_size[0] * scale), int(library.tile_size[1] * scale)),
202
+ grid_offset=(int(library.grid_offset[0] * scale), int(library.grid_offset[1] * scale)),
203
+ trim_transparent=library.trim_transparent,
204
+ )
@@ -8,7 +8,7 @@ repeated file I/O during runtime.
8
8
  from __future__ import annotations
9
9
 
10
10
  from pathlib import Path
11
- from typing import Dict, Optional, Union
11
+ from typing import Dict, Optional, Tuple, Union
12
12
 
13
13
  from ..parser.collision import (
14
14
  CharacterCollision,
@@ -39,7 +39,7 @@ class CollisionCache:
39
39
 
40
40
  def __init__(self):
41
41
  self._tileset_cache: Dict[str, Optional[TilesetCollision]] = {}
42
- self._character_cache: Dict[str, Optional[CharacterCollision]] = {}
42
+ self._character_cache: Dict[Tuple[str, float], Optional[CharacterCollision]] = {}
43
43
  self._object_cache: Dict[str, Optional[ObjectCollisionData]] = {}
44
44
 
45
45
  def get_tileset_collision(
@@ -58,17 +58,21 @@ class CollisionCache:
58
58
  return self._tileset_cache[key]
59
59
 
60
60
  def get_character_collision(
61
- self, collision_path: Union[str, Path]
61
+ self, collision_path: Union[str, Path], render_scale: float = 1.0
62
62
  ) -> Optional[CharacterCollision]:
63
63
  """Get character collision data (cached).
64
64
 
65
65
  Args:
66
66
  collision_path: Direct path to the .collision.json file.
67
+ render_scale: Multiplier applied to shape dimensions and offsets.
68
+ Cached per (path, render_scale) pair.
67
69
  """
68
- key = str(Path(collision_path).resolve())
70
+ key = (str(Path(collision_path).resolve()), render_scale)
69
71
 
70
72
  if key not in self._character_cache:
71
- self._character_cache[key] = load_character_collision(collision_path)
73
+ self._character_cache[key] = load_character_collision(
74
+ collision_path, render_scale=render_scale
75
+ )
72
76
 
73
77
  return self._character_cache[key]
74
78
 
@@ -134,13 +138,15 @@ def get_cached_tileset_collision(
134
138
 
135
139
  def get_cached_character_collision(
136
140
  collision_path: Union[str, Path],
141
+ render_scale: float = 1.0,
137
142
  ) -> Optional[CharacterCollision]:
138
143
  """Get character collision using global cache.
139
144
 
140
145
  Args:
141
146
  collision_path: Direct path to the .collision.json file.
147
+ render_scale: Multiplier applied to shape dimensions and offsets.
142
148
  """
143
- return _global_cache.get_character_collision(collision_path)
149
+ return _global_cache.get_character_collision(collision_path, render_scale=render_scale)
144
150
 
145
151
 
146
152
  def get_cached_object_collision(
@@ -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]]:
@@ -177,6 +177,23 @@ def _make_heart_texture() -> Surface:
177
177
  return s
178
178
 
179
179
 
180
+ def _make_line_texture() -> Surface:
181
+ # will be applied on next release not yet
182
+ """Vertical streak spanning the full canvas with a thin core.
183
+
184
+ Kept axis-aligned (not rotated): the renderer only rotates particles
185
+ when ``rotation_speed`` is non-zero, so a vertical line reads as a
186
+ falling streak for rain and stream effects.
187
+ """
188
+ s = Surface((PARTICLE_TEXTURE_SIZE, PARTICLE_TEXTURE_SIZE), pygame.SRCALPHA)
189
+ w = max(1, PARTICLE_TEXTURE_SIZE // 6)
190
+ x = (PARTICLE_TEXTURE_SIZE - w) // 2
191
+ pygame.draw.rect(s, (255, 255, 255, 255), Rect(x, 2, w, PARTICLE_TEXTURE_SIZE - 4))
192
+ pygame.draw.rect(s, (255, 255, 255, 140), Rect(x, 0, w, 2))
193
+ pygame.draw.rect(s, (255, 255, 255, 140), Rect(x, PARTICLE_TEXTURE_SIZE - 2, w, 2))
194
+ return s
195
+
196
+
180
197
  def _get_base_texture(shape: str) -> Surface:
181
198
  if shape not in _TEXTURE_CACHE:
182
199
  makers = {
@@ -188,6 +205,7 @@ def _get_base_texture(shape: str) -> Surface:
188
205
  "smoke": _make_smoke_texture,
189
206
  "fog": _make_fog_texture,
190
207
  "heart": _make_heart_texture,
208
+ "line": _make_line_texture,
191
209
  }
192
210
  maker = makers.get(shape, _make_circle_texture)
193
211
  _TEXTURE_CACHE[shape] = maker()