tilemap-parser 4.1.0__tar.gz → 4.2.1__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 (48) hide show
  1. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/PKG-INFO +1 -1
  2. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/pyproject.toml +1 -1
  3. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/__init__.py +3 -0
  4. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/map_loader.py +16 -4
  5. tilemap_parser-4.2.1/src/tilemap_parser/runtime/map_object.py +181 -0
  6. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/object_collision.py +124 -80
  7. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  8. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser.egg-info/SOURCES.txt +2 -0
  9. tilemap_parser-4.2.1/tests/test_map_object.py +707 -0
  10. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/LICENSE +0 -0
  11. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/README.md +0 -0
  12. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/setup.cfg +0 -0
  13. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/__init__.py +0 -0
  14. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/parser/__init__.py +0 -0
  15. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/parser/animation.py +0 -0
  16. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/parser/collision.py +0 -0
  17. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/parser/collision_loader.py +0 -0
  18. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/parser/map_parse.py +0 -0
  19. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/parser/node_parse.py +0 -0
  20. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/parser/particle.py +0 -0
  21. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  22. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/animation_player.py +0 -0
  23. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/area_node.py +0 -0
  24. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/camera.py +0 -0
  25. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  26. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/particles.py +0 -0
  27. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/renderer.py +0 -0
  28. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  29. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/utils/__init__.py +0 -0
  30. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser/utils/geometry.py +0 -0
  31. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  32. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser.egg-info/requires.txt +0 -0
  33. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  34. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_camera.py +0 -0
  35. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_collision.py +0 -0
  36. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_geometry.py +0 -0
  37. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_gid_collision.py +0 -0
  38. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_integration_animation.py +0 -0
  39. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_integration_collision.py +0 -0
  40. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_integration_map_loader.py +0 -0
  41. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_map_loader.py +0 -0
  42. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_move_grounded.py +0 -0
  43. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_object_collision.py +0 -0
  44. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_object_surfaces.py +0 -0
  45. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_particle_emitter.py +0 -0
  46. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_render_scale.py +0 -0
  47. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/tests/test_tile_collision.py +0 -0
  48. {tilemap_parser-4.1.0 → tilemap_parser-4.2.1}/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.1.0
3
+ Version: 4.2.1
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.1.0"
7
+ version = "4.2.1"
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"
@@ -10,6 +10,7 @@ from .collision_cache import (
10
10
  load_object_collision,
11
11
  load_tileset_collision,
12
12
  )
13
+ from .map_object import MapObject, load_map_objects
13
14
  from .tile_collision import (
14
15
  CollisionResult,
15
16
  CollisionRunner,
@@ -47,6 +48,7 @@ __all__ = [
47
48
  "ICollidableObject",
48
49
  "ICollidableSprite",
49
50
  "LayerRenderStats",
51
+ "MapObject",
50
52
  "MovementMode",
51
53
  "ObjectCollisionManager",
52
54
  "SpriteAnimationSet",
@@ -59,6 +61,7 @@ __all__ = [
59
61
  "get_cached_tileset_collision",
60
62
  "load_character_collision",
61
63
  "load_map",
64
+ "load_map_objects",
62
65
  "load_object_collision",
63
66
  "load_tileset_collision",
64
67
  "Particle",
@@ -68,7 +68,12 @@ class TilemapData:
68
68
  surfaces.append(None)
69
69
  continue
70
70
  try:
71
- surfaces.append(pygame.image.load(str(resolved)).convert_alpha())
71
+ surf = pygame.image.load(str(resolved))
72
+ try:
73
+ surf = surf.convert_alpha()
74
+ except pygame.error:
75
+ pass
76
+ surfaces.append(surf)
72
77
  except pygame.error as e:
73
78
  msg = f"Tileset load failed ({i}) {resolved}: {e}"
74
79
  warnings.append(msg)
@@ -244,6 +249,16 @@ class TilemapData:
244
249
  return None
245
250
  return _variant_surface(source, variant, self.tile_size, copy_surface=copy_surface)
246
251
 
252
+ def get_object_surface(self, obj: ParsedObject, *, copy_surface: bool = True) -> Optional[Surface]:
253
+ if obj.ttype < 0 or obj.ttype >= len(self.surfaces):
254
+ return None
255
+ source = self.surfaces[obj.ttype]
256
+ if source is None:
257
+ return None
258
+ if (obj.area.w, obj.area.h) == (source.get_width(), source.get_height()):
259
+ return source.copy() if copy_surface else source
260
+ return _variant_surface(source, obj.variant, self.tile_size, copy_surface=copy_surface)
261
+
247
262
  def get_tile_surface(self, ttype: int, variant: int, *, copy_surface: bool = True) -> Optional[Surface]:
248
263
  return self.get_image(variant=variant, ttype=ttype, copy_surface=copy_surface)
249
264
 
@@ -259,9 +274,6 @@ class TilemapData:
259
274
  return None
260
275
  return self.get_tile_surface(tile.ttype, tile.variant)
261
276
 
262
- def get_object_surface(self, obj: ParsedObject, *, copy_surface: bool = True) -> Optional[Surface]:
263
- return self.get_image(variant=obj.variant, ttype=obj.ttype, copy_surface=copy_surface)
264
-
265
277
  def get_object_surface_by_id(
266
278
  self, layer_id_or_name: Union[int, str], object_id: int, *, copy_surface: bool = True
267
279
  ) -> Optional[Tuple[Surface, int, int]]:
@@ -0,0 +1,181 @@
1
+ """
2
+ Runtime map object loading and representation.
3
+
4
+ Bridges the gap between parsed map objects, collision data, and
5
+ the collision manager. Provides a ready-to-use :class:`MapObject`
6
+ that implements :class:`ICollidableObject` and carries a pygame
7
+ :class:`~pygame.Surface` for rendering.
8
+
9
+ Usage::
10
+
11
+ objects = load_map_objects(tilemap_data, MAPS_PATH / "collision")
12
+ for obj in objects:
13
+ collision_manager.add_object(obj)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Dict, List, Optional, Union
20
+
21
+ from pygame import Surface
22
+
23
+ from ..parser.collision import CollisionPolygon, ObjectCollisionData
24
+ from ..parser.collision_loader import load_object_collision
25
+ from .collision_cache import CollisionCache
26
+ from .map_loader import TilemapData
27
+
28
+
29
+ class MapObject:
30
+ """
31
+ A game object loaded from a tilemap, carrying a surface for
32
+ rendering and collision data for physics.
33
+
34
+ Satisfies the :class:`ICollidableObject` protocol so it can be
35
+ added directly to an :class:`ObjectCollisionManager`.
36
+
37
+ When a region contains multiple disjoint polygons, *all* shapes
38
+ are stored in :attr:`collision_shapes` and the first shape is
39
+ used as :attr:`collision_shape` for protocol compatibility.
40
+ The collision system (:func:`check_collision`) iterates all
41
+ shape pairs when either object has multiple shapes.
42
+ """
43
+
44
+ __slots__ = (
45
+ "x",
46
+ "y",
47
+ "surface",
48
+ "collision_shape",
49
+ "collision_shapes",
50
+ "collision_layer",
51
+ "collision_mask",
52
+ )
53
+
54
+ def __init__(
55
+ self,
56
+ x: float,
57
+ y: float,
58
+ surface: Surface,
59
+ collision_shape: CollisionPolygon,
60
+ *,
61
+ collision_shapes: Optional[List[CollisionPolygon]] = None,
62
+ collision_layer: int = 1,
63
+ collision_mask: int = 0xFFFFFFFF,
64
+ ) -> None:
65
+ self.x = x
66
+ self.y = y
67
+ self.surface = surface
68
+ self.collision_shapes = collision_shapes or [collision_shape]
69
+ self.collision_shape = self.collision_shapes[0]
70
+ self.collision_layer = collision_layer
71
+ self.collision_mask = collision_mask
72
+
73
+
74
+ def _resolve_object_collision_filename(tileset_path: Union[str, Path]) -> str:
75
+ """Extract the object collision filename for a given tileset path.
76
+
77
+ Example: ``"building7.png"`` → ``"building7.object_collision.json"``
78
+ """
79
+ return f"{Path(tileset_path).stem}.object_collision.json"
80
+
81
+
82
+ def load_map_objects(
83
+ tilemap_data: TilemapData,
84
+ collision_dir: Union[str, Path],
85
+ *,
86
+ cache: Optional[CollisionCache] = None,
87
+ ) -> List[MapObject]:
88
+ """Load all collidable objects from a tilemap.
89
+
90
+ Iterates every object layer in *tilemap_data*, resolves the
91
+ corresponding ``.object_collision.json`` from *collision_dir*, and
92
+ builds :class:`MapObject` instances with surfaces, owner-local
93
+ collision shapes (region offset only; consumers add x/y), and
94
+ layer/mask values from the collision regions.
95
+
96
+ Collision data is cached per tileset index so the same file is
97
+ never loaded twice.
98
+
99
+ Args:
100
+ tilemap_data: Loaded tilemap data (surfaces, tilesets, layers).
101
+ collision_dir: Directory containing ``*.object_collision.json``
102
+ files (typically ``<map_path>/collision/``).
103
+ cache: Optional :class:`CollisionCache` for caching parsed
104
+ collision data across calls.
105
+
106
+ Returns:
107
+ List of :class:`MapObject` instances, one per map object that
108
+ has both a valid surface and a matching collision region.
109
+ When a region contains multiple collision polygons, all shapes
110
+ are preserved in :attr:`MapObject.collision_shapes` and the
111
+ collision system checks each shape pair during narrowphase.
112
+ """
113
+ collision_dir = Path(collision_dir)
114
+ objects: List[MapObject] = []
115
+ object_layers = tilemap_data.get_layers(layer_type="object")
116
+
117
+ loaded_collision: Dict[int, Optional[ObjectCollisionData]] = {}
118
+
119
+ for layer in object_layers:
120
+ for obj_id, obj in layer.objects.items():
121
+ surf_x_y = tilemap_data.get_object_surface_by_id(layer.id, obj_id)
122
+ if surf_x_y is None:
123
+ continue
124
+
125
+ surf, x, y = surf_x_y
126
+
127
+ ttype = obj.ttype
128
+ if ttype not in loaded_collision:
129
+ collision_data = _load_collision_for_tileset(
130
+ tilemap_data, ttype, collision_dir, cache
131
+ )
132
+ loaded_collision[ttype] = collision_data
133
+
134
+ collision_data = loaded_collision[ttype]
135
+ if collision_data is None:
136
+ continue
137
+
138
+ world_shapes = []
139
+ region_layer = 1
140
+ region_mask = 0xFFFFFFFF
141
+ for region in collision_data.regions.values():
142
+ if not world_shapes:
143
+ region_layer = region.collision_layer
144
+ region_mask = region.collision_mask
145
+ ox = region.region_rect[0]
146
+ oy = region.region_rect[1]
147
+ world_shapes.extend(shape.transform(ox, oy) for shape in region.shapes)
148
+ if not world_shapes:
149
+ continue
150
+
151
+ game_obj = MapObject(
152
+ x=x,
153
+ y=y,
154
+ surface=surf,
155
+ collision_shape=world_shapes[0],
156
+ collision_shapes=world_shapes,
157
+ collision_layer=region_layer,
158
+ collision_mask=region_mask,
159
+ )
160
+ objects.append(game_obj)
161
+
162
+ return objects
163
+
164
+
165
+ def _load_collision_for_tileset(
166
+ tilemap_data: TilemapData,
167
+ ttype: int,
168
+ collision_dir: Path,
169
+ cache: Optional[CollisionCache],
170
+ ) -> Optional[ObjectCollisionData]:
171
+ """Load (or retrieve from cache) collision data for a tileset index."""
172
+ if ttype < 0 or ttype >= len(tilemap_data.parsed.tilesets):
173
+ return None
174
+
175
+ tileset_path = tilemap_data.parsed.tilesets[ttype].path
176
+ coll_filename = _resolve_object_collision_filename(tileset_path)
177
+ coll_path = collision_dir / coll_filename
178
+
179
+ if cache is not None:
180
+ return cache.get_object_collision(coll_path)
181
+ return load_object_collision(coll_path)
@@ -133,96 +133,89 @@ def should_collide(
133
133
  _should_collide = should_collide
134
134
 
135
135
 
136
- def check_collision(
137
- obj_a: ICollidableObject,
138
- obj_b: ICollidableObject,
139
- ) -> Optional[CollisionHit]:
140
- """
141
- Check if two objects collide.
136
+ def _get_shapes(obj: ICollidableObject) -> List:
137
+ """Return all collision shapes for an object.
142
138
 
143
- Pipeline:
144
- 1. Layer filtering
145
- 2. Broadphase AABB rejection
146
- 3. Narrowphase geometry dispatch
147
- 4. Return CollisionHit or None
148
-
149
- All shape combinations are supported, including polygon-vs-anything.
139
+ Objects with a :attr:`collision_shapes` attribute (e.g.
140
+ :class:`MapObject`) may carry multiple polygons per region;
141
+ single-shape objects return ``[obj.collision_shape]``.
150
142
  """
151
- # 1. Layer filter
152
- if not should_collide(obj_a, obj_b):
153
- return None
154
-
155
- # 2. Broadphase
156
- aabb_a = get_shape_aabb(obj_a.x, obj_a.y, obj_a.collision_shape)
157
- aabb_b = get_shape_aabb(obj_b.x, obj_b.y, obj_b.collision_shape)
158
- if not aabb_overlap(aabb_a, aabb_b):
159
- return None
160
-
161
- # 3. Narrowphase dispatch
162
- shape_a = obj_a.collision_shape
163
- shape_b = obj_b.collision_shape
164
-
165
- info: Optional[CollisionInfo] = None
166
-
143
+ shapes = getattr(obj, "collision_shapes", None)
144
+ if shapes is not None and len(shapes) > 0:
145
+ return list(shapes)
146
+ return [obj.collision_shape]
147
+
148
+
149
+ def _combined_aabb(x: float, y: float, shapes: List) -> tuple[float, float, float, float]:
150
+ """Union AABB across all shapes at position *(x, y)*."""
151
+ left = top = float("inf")
152
+ right = bottom = float("-inf")
153
+ for shape in shapes:
154
+ sx0, sy0, sx1, sy1 = get_shape_aabb(x, y, shape)
155
+ if sx0 < left:
156
+ left = sx0
157
+ if sy0 < top:
158
+ top = sy0
159
+ if sx1 > right:
160
+ right = sx1
161
+ if sy1 > bottom:
162
+ bottom = sy1
163
+ return (left, top, right, bottom)
164
+
165
+
166
+ def _check_pair(
167
+ obj_a: ICollidableObject,
168
+ obj_b: ICollidableObject,
169
+ shape_a,
170
+ shape_b,
171
+ aabb_a: tuple[float, float, float, float],
172
+ aabb_b: tuple[float, float, float, float],
173
+ ) -> Optional[CollisionInfo]:
174
+ """Run narrowphase for a single shape pair. (Extracted from
175
+ :func:`check_collision` for reuse in the multi-shape loop.)"""
167
176
  if isinstance(shape_a, CircleShape) and isinstance(shape_b, CircleShape):
168
177
  ca = (obj_a.x + shape_a.offset[0], obj_a.y + shape_a.offset[1])
169
178
  cb = (obj_b.x + shape_b.offset[0], obj_b.y + shape_b.offset[1])
170
- info = circle_vs_circle(ca, shape_a.radius, cb, shape_b.radius)
179
+ return circle_vs_circle(ca, shape_a.radius, cb, shape_b.radius)
171
180
 
172
181
  elif isinstance(shape_a, RectangleShape) and isinstance(shape_b, RectangleShape):
173
- info = rect_vs_rect(aabb_a, aabb_b)
182
+ return rect_vs_rect(aabb_a, aabb_b)
174
183
 
175
184
  elif isinstance(shape_a, RectangleShape) and isinstance(shape_b, CircleShape):
176
185
  cb = (obj_b.x + shape_b.offset[0], obj_b.y + shape_b.offset[1])
177
- info = rect_vs_circle(aabb_a, cb, shape_b.radius)
186
+ return rect_vs_circle(aabb_a, cb, shape_b.radius)
178
187
 
179
188
  elif isinstance(shape_a, CircleShape) and isinstance(shape_b, RectangleShape):
180
189
  ca = (obj_a.x + shape_a.offset[0], obj_a.y + shape_a.offset[1])
181
- result = rect_vs_circle(aabb_b, ca, shape_a.radius)
182
- if result is not None:
183
- # Flip normal: was rect→circle, need circle→rect
184
- info = CollisionInfo(
185
- normal=(-result.normal[0], -result.normal[1]),
186
- depth=result.depth,
187
- )
190
+ return _flip_result(rect_vs_circle(aabb_b, ca, shape_a.radius))
188
191
 
189
192
  # Polygon vs Polygon
190
193
  elif isinstance(shape_a, CollisionPolygon) and isinstance(shape_b, CollisionPolygon):
191
194
  verts_a = [(obj_a.x + v[0], obj_a.y + v[1]) for v in shape_a.vertices]
192
195
  verts_b = [(obj_b.x + v[0], obj_b.y + v[1]) for v in shape_b.vertices]
193
- info = polygon_vs_polygon(verts_a, verts_b)
196
+ return polygon_vs_polygon(verts_a, verts_b)
194
197
 
195
198
  # Polygon vs Circle
196
199
  elif isinstance(shape_a, CollisionPolygon) and isinstance(shape_b, CircleShape):
197
200
  verts_a = [(obj_a.x + v[0], obj_a.y + v[1]) for v in shape_a.vertices]
198
201
  center_b = (obj_b.x + shape_b.offset[0], obj_b.y + shape_b.offset[1])
199
- info = polygon_vs_circle(verts_a, center_b, shape_b.radius)
202
+ return polygon_vs_circle(verts_a, center_b, shape_b.radius)
200
203
 
201
204
  # Circle vs Polygon (flip normal)
202
205
  elif isinstance(shape_a, CircleShape) and isinstance(shape_b, CollisionPolygon):
203
206
  verts_b = [(obj_b.x + v[0], obj_b.y + v[1]) for v in shape_b.vertices]
204
207
  center_a = (obj_a.x + shape_a.offset[0], obj_a.y + shape_a.offset[1])
205
- result = polygon_vs_circle(verts_b, center_a, shape_a.radius)
206
- if result is not None:
207
- info = CollisionInfo(
208
- normal=(-result.normal[0], -result.normal[1]),
209
- depth=result.depth,
210
- )
208
+ return _flip_result(polygon_vs_circle(verts_b, center_a, shape_a.radius))
211
209
 
212
210
  # Polygon vs Rect
213
211
  elif isinstance(shape_a, CollisionPolygon) and isinstance(shape_b, RectangleShape):
214
212
  verts_a = [(obj_a.x + v[0], obj_a.y + v[1]) for v in shape_a.vertices]
215
- info = polygon_vs_rect(verts_a, aabb_b)
213
+ return polygon_vs_rect(verts_a, aabb_b)
216
214
 
217
215
  # Rect vs Polygon (flip normal)
218
216
  elif isinstance(shape_a, RectangleShape) and isinstance(shape_b, CollisionPolygon):
219
217
  verts_b = [(obj_b.x + v[0], obj_b.y + v[1]) for v in shape_b.vertices]
220
- result = polygon_vs_rect(verts_b, aabb_a)
221
- if result is not None:
222
- info = CollisionInfo(
223
- normal=(-result.normal[0], -result.normal[1]),
224
- depth=result.depth,
225
- )
218
+ return _flip_result(polygon_vs_rect(verts_b, aabb_a))
226
219
 
227
220
  # Capsule pairs
228
221
  elif isinstance(shape_a, CapsuleShape) and isinstance(shape_b, CapsuleShape):
@@ -230,73 +223,121 @@ def check_collision(
230
223
  p2 = (p1[0], p1[1] + shape_a.height)
231
224
  q1 = (obj_b.x + shape_b.offset[0], obj_b.y + shape_b.offset[1])
232
225
  q2 = (q1[0], q1[1] + shape_b.height)
233
- info = capsule_vs_capsule(p1, p2, shape_a.radius, q1, q2, shape_b.radius)
226
+ return capsule_vs_capsule(p1, p2, shape_a.radius, q1, q2, shape_b.radius)
234
227
 
235
228
  elif isinstance(shape_a, CapsuleShape) and isinstance(shape_b, CircleShape):
236
229
  p1 = (obj_a.x + shape_a.offset[0], obj_a.y + shape_a.offset[1])
237
230
  p2 = (p1[0], p1[1] + shape_a.height)
238
231
  cb = (obj_b.x + shape_b.offset[0], obj_b.y + shape_b.offset[1])
239
- info = capsule_vs_circle(p1, p2, shape_a.radius, cb, shape_b.radius)
232
+ return capsule_vs_circle(p1, p2, shape_a.radius, cb, shape_b.radius)
240
233
 
241
234
  elif isinstance(shape_a, CircleShape) and isinstance(shape_b, CapsuleShape):
242
235
  ca = (obj_a.x + shape_a.offset[0], obj_a.y + shape_a.offset[1])
243
236
  q1 = (obj_b.x + shape_b.offset[0], obj_b.y + shape_b.offset[1])
244
237
  q2 = (q1[0], q1[1] + shape_b.height)
245
- result = capsule_vs_circle(q1, q2, shape_b.radius, ca, shape_a.radius)
246
- if result is not None:
247
- info = CollisionInfo(
248
- normal=(-result.normal[0], -result.normal[1]),
249
- depth=result.depth,
250
- )
238
+ return _flip_result(capsule_vs_circle(q1, q2, shape_b.radius, ca, shape_a.radius))
251
239
 
252
240
  elif isinstance(shape_a, CapsuleShape) and isinstance(shape_b, RectangleShape):
253
241
  p1 = (obj_a.x + shape_a.offset[0], obj_a.y + shape_a.offset[1])
254
242
  p2 = (p1[0], p1[1] + shape_a.height)
255
- info = capsule_vs_rect(p1, p2, shape_a.radius, aabb_b)
243
+ return capsule_vs_rect(p1, p2, shape_a.radius, aabb_b)
256
244
 
257
245
  elif isinstance(shape_a, RectangleShape) and isinstance(shape_b, CapsuleShape):
258
246
  q1 = (obj_b.x + shape_b.offset[0], obj_b.y + shape_b.offset[1])
259
247
  q2 = (q1[0], q1[1] + shape_b.height)
260
- result = capsule_vs_rect(q1, q2, shape_b.radius, aabb_a)
261
- if result is not None:
262
- info = CollisionInfo(
263
- normal=(-result.normal[0], -result.normal[1]),
264
- depth=result.depth,
265
- )
248
+ return _flip_result(capsule_vs_rect(q1, q2, shape_b.radius, aabb_a))
266
249
 
267
250
  elif isinstance(shape_a, CapsuleShape) and isinstance(shape_b, CollisionPolygon):
268
251
  p1 = (obj_a.x + shape_a.offset[0], obj_a.y + shape_a.offset[1])
269
252
  p2 = (p1[0], p1[1] + shape_a.height)
270
253
  verts_b = [(obj_b.x + v[0], obj_b.y + v[1]) for v in shape_b.vertices]
271
- info = capsule_vs_polygon(p1, p2, shape_a.radius, verts_b)
254
+ return capsule_vs_polygon(p1, p2, shape_a.radius, verts_b)
272
255
 
273
256
  elif isinstance(shape_a, CollisionPolygon) and isinstance(shape_b, CapsuleShape):
274
257
  verts_a = [(obj_a.x + v[0], obj_a.y + v[1]) for v in shape_a.vertices]
275
258
  q1 = (obj_b.x + shape_b.offset[0], obj_b.y + shape_b.offset[1])
276
259
  q2 = (q1[0], q1[1] + shape_b.height)
277
- result = capsule_vs_polygon(q1, q2, shape_b.radius, verts_a)
278
- if result is not None:
279
- info = CollisionInfo(
280
- normal=(-result.normal[0], -result.normal[1]),
281
- depth=result.depth,
282
- )
260
+ return _flip_result(capsule_vs_polygon(q1, q2, shape_b.radius, verts_a))
283
261
 
284
262
  else:
285
263
  warnings.warn(
286
264
  f"Unhandled collision shape pair: {type(shape_a).__name__} vs {type(shape_b).__name__}",
287
265
  UserWarning,
288
- stacklevel=2,
266
+ stacklevel=3,
289
267
  )
290
268
  return None
291
269
 
270
+
271
+ def _flip_result(info: Optional[CollisionInfo]) -> Optional[CollisionInfo]:
272
+ """Flip the normal of a :class:`CollisionInfo` in place."""
292
273
  if info is None:
293
274
  return None
275
+ return CollisionInfo(
276
+ normal=(-info.normal[0], -info.normal[1]),
277
+ depth=info.depth,
278
+ )
279
+
280
+
281
+ def check_collision(
282
+ obj_a: ICollidableObject,
283
+ obj_b: ICollidableObject,
284
+ ) -> Optional[CollisionHit]:
285
+ """
286
+ Check if two objects collide.
287
+
288
+ Pipeline:
289
+ 1. Layer filtering
290
+ 2. Broadphase AABB rejection
291
+ 3. Narrowphase geometry dispatch
292
+ 4. Return CollisionHit or None
293
+
294
+ Supports multi-shape objects (those with a ``collision_shapes``
295
+ attribute). When both objects have a single shape the behaviour
296
+ is identical to previous versions.
297
+ """
298
+ # 1. Layer filter
299
+ if not should_collide(obj_a, obj_b):
300
+ return None
301
+
302
+ # 2. Broadphase — use combined AABB when an object has multiple shapes
303
+ shapes_a = _get_shapes(obj_a)
304
+ shapes_b = _get_shapes(obj_b)
305
+
306
+ if len(shapes_a) == 1:
307
+ aabb_a = get_shape_aabb(obj_a.x, obj_a.y, shapes_a[0])
308
+ else:
309
+ aabb_a = _combined_aabb(obj_a.x, obj_a.y, shapes_a)
310
+
311
+ if len(shapes_b) == 1:
312
+ aabb_b = get_shape_aabb(obj_b.x, obj_b.y, shapes_b[0])
313
+ else:
314
+ aabb_b = _combined_aabb(obj_b.x, obj_b.y, shapes_b)
315
+
316
+ if not aabb_overlap(aabb_a, aabb_b):
317
+ return None
318
+
319
+ # 3. Narrowphase — iterate all shape pairs, keep the deepest
320
+ deepest: Optional[CollisionInfo] = None
321
+
322
+ for shape_a in shapes_a:
323
+ for shape_b in shapes_b:
324
+ pair_aabb_a = get_shape_aabb(obj_a.x, obj_a.y, shape_a)
325
+ pair_aabb_b = get_shape_aabb(obj_b.x, obj_b.y, shape_b)
326
+ if not aabb_overlap(pair_aabb_a, pair_aabb_b):
327
+ continue
328
+
329
+ info = _check_pair(obj_a, obj_b, shape_a, shape_b, pair_aabb_a, pair_aabb_b)
330
+ if info is not None and (deepest is None or info.depth > deepest.depth):
331
+ deepest = info
332
+
333
+ if deepest is None:
334
+ return None
294
335
 
295
336
  return CollisionHit(
296
337
  object_a=obj_a,
297
338
  object_b=obj_b,
298
- normal=info.normal,
299
- depth=info.depth,
339
+ normal=deepest.normal,
340
+ depth=deepest.depth,
300
341
  )
301
342
 
302
343
 
@@ -392,7 +433,10 @@ class ObjectCollisionManager:
392
433
  self,
393
434
  obj: ICollidableObject,
394
435
  ) -> tuple[float, float, float, float]:
395
- return get_shape_aabb(obj.x, obj.y, obj.collision_shape)
436
+ shapes = _get_shapes(obj)
437
+ if len(shapes) == 1:
438
+ return get_shape_aabb(obj.x, obj.y, shapes[0])
439
+ return _combined_aabb(obj.x, obj.y, shapes)
396
440
 
397
441
  def _build_spatial_index(
398
442
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.1.0
3
+ Version: 4.2.1
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
@@ -21,6 +21,7 @@ src/tilemap_parser/runtime/area_node.py
21
21
  src/tilemap_parser/runtime/camera.py
22
22
  src/tilemap_parser/runtime/collision_cache.py
23
23
  src/tilemap_parser/runtime/map_loader.py
24
+ src/tilemap_parser/runtime/map_object.py
24
25
  src/tilemap_parser/runtime/object_collision.py
25
26
  src/tilemap_parser/runtime/particles.py
26
27
  src/tilemap_parser/runtime/renderer.py
@@ -35,6 +36,7 @@ tests/test_integration_animation.py
35
36
  tests/test_integration_collision.py
36
37
  tests/test_integration_map_loader.py
37
38
  tests/test_map_loader.py
39
+ tests/test_map_object.py
38
40
  tests/test_move_grounded.py
39
41
  tests/test_object_collision.py
40
42
  tests/test_object_surfaces.py