tilemap-parser 4.2.0__py3-none-any.whl → 4.2.2__py3-none-any.whl

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.
@@ -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,212 @@
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
+ import pygame
22
+ from pygame import Surface
23
+
24
+ from ..parser.collision import CollisionPolygon, ObjectCollisionData
25
+ from ..parser.collision_loader import load_object_collision
26
+ from .collision_cache import CollisionCache
27
+ from .map_loader import TilemapData
28
+
29
+
30
+ class MapObject:
31
+ """
32
+ A game object loaded from a tilemap, carrying a surface for
33
+ rendering and collision data for physics.
34
+
35
+ All spatial data (``x``, ``y``, ``surface`` size, and collision
36
+ polygon vertices) is pre-scaled by the map's ``render_scale``.
37
+ Units are in *effective* pixels (``render_scale × tile_size``).
38
+ The object is ready for direct use in a game loop that runs in
39
+ effective-pixel space — no additional scaling needed.
40
+
41
+ Satisfies the :class:`ICollidableObject` protocol so it can be
42
+ added directly to an :class:`ObjectCollisionManager`.
43
+
44
+ When a region contains multiple disjoint polygons, *all* shapes
45
+ are stored in :attr:`collision_shapes` and the first shape is
46
+ used as :attr:`collision_shape` for protocol compatibility.
47
+ The collision system (:func:`check_collision`) iterates all
48
+ shape pairs when either object has multiple shapes.
49
+ """
50
+
51
+ __slots__ = (
52
+ "x",
53
+ "y",
54
+ "surface",
55
+ "collision_shape",
56
+ "collision_shapes",
57
+ "collision_layer",
58
+ "collision_mask",
59
+ )
60
+
61
+ def __init__(
62
+ self,
63
+ x: float,
64
+ y: float,
65
+ surface: Surface,
66
+ collision_shape: CollisionPolygon,
67
+ *,
68
+ collision_shapes: Optional[List[CollisionPolygon]] = None,
69
+ collision_layer: int = 1,
70
+ collision_mask: int = 0xFFFFFFFF,
71
+ ) -> None:
72
+ self.x = x
73
+ self.y = y
74
+ self.surface = surface
75
+ self.collision_shapes = collision_shapes or [collision_shape]
76
+ self.collision_shape = self.collision_shapes[0]
77
+ self.collision_layer = collision_layer
78
+ self.collision_mask = collision_mask
79
+
80
+
81
+ def _resolve_object_collision_filename(tileset_path: Union[str, Path]) -> str:
82
+ """Extract the object collision filename for a given tileset path.
83
+
84
+ Example: ``"building7.png"`` → ``"building7.object_collision.json"``
85
+ """
86
+ return f"{Path(tileset_path).stem}.object_collision.json"
87
+
88
+
89
+ def load_map_objects(
90
+ tilemap_data: TilemapData,
91
+ collision_dir: Union[str, Path],
92
+ *,
93
+ cache: Optional[CollisionCache] = None,
94
+ ) -> List[MapObject]:
95
+ """Load all collidable objects from a tilemap.
96
+
97
+ Iterates every object layer in *tilemap_data*, resolves the
98
+ corresponding ``.object_collision.json`` from *collision_dir*, and
99
+ builds :class:`MapObject` instances with pre-scaled surfaces,
100
+ positions, and collision shapes.
101
+
102
+ **render_scale transparency**
103
+
104
+ All spatial data is automatically scaled by the map's
105
+ ``render_scale`` (from ``tilemap_data.render_scale``):
106
+
107
+ * ``MapObject.x`` / ``MapObject.y`` — raw map coords × ``rs``
108
+ (stored as ``float``; fractional pixel positions are preserved).
109
+ * ``MapObject.surface`` — surface is scaled by ``rs`` via
110
+ :func:`pygame.transform.scale` (no-op when ``rs == 1.0``).
111
+ Raster dimensions are truncated to integers via ``int()``
112
+ to satisfy :func:`pygame.transform.scale` requirements.
113
+ * Collision polygon vertices and ``region_rect`` offsets are
114
+ multiplied by ``rs`` via :meth:`CollisionPolygon.transform`.
115
+
116
+ The returned objects are ready for a game loop that runs in
117
+ effective-pixel space (``render_scale × tile_size``). No
118
+ additional scaling is required by the caller.
119
+
120
+ Collision data is cached per tileset index so the same file is
121
+ never loaded twice.
122
+
123
+ Args:
124
+ tilemap_data: Loaded tilemap data (surfaces, tilesets, layers).
125
+ collision_dir: Directory containing ``*.object_collision.json``
126
+ files (typically ``<map_path>/collision/``).
127
+ cache: Optional :class:`CollisionCache` for caching parsed
128
+ collision data across calls.
129
+
130
+ Returns:
131
+ List of :class:`MapObject` instances, one per map object that
132
+ has both a valid surface and a matching collision region.
133
+ When a region contains multiple collision polygons, all shapes
134
+ are preserved in :attr:`MapObject.collision_shapes` and the
135
+ collision system checks each shape pair during narrowphase.
136
+ """
137
+ collision_dir = Path(collision_dir)
138
+ objects: List[MapObject] = []
139
+ object_layers = tilemap_data.get_layers(layer_type="object")
140
+
141
+ loaded_collision: Dict[int, Optional[ObjectCollisionData]] = {}
142
+
143
+ for layer in object_layers:
144
+ for obj_id, obj in layer.objects.items():
145
+ surf_x_y = tilemap_data.get_object_surface_by_id(layer.id, obj_id)
146
+ if surf_x_y is None:
147
+ continue
148
+
149
+ surf, x, y = surf_x_y
150
+ rs = tilemap_data.render_scale
151
+ x = x * rs
152
+ y = y * rs
153
+
154
+ if rs != 1.0 and surf is not None:
155
+ w, h = surf.get_size()
156
+ surf = pygame.transform.scale(surf, (int(w * rs), int(h * rs)))
157
+
158
+ ttype = obj.ttype
159
+ if ttype not in loaded_collision:
160
+ collision_data = _load_collision_for_tileset(
161
+ tilemap_data, ttype, collision_dir, cache
162
+ )
163
+ loaded_collision[ttype] = collision_data
164
+
165
+ collision_data = loaded_collision[ttype]
166
+ if collision_data is None:
167
+ continue
168
+
169
+ world_shapes = []
170
+ region_layer = 1
171
+ region_mask = 0xFFFFFFFF
172
+ for region in collision_data.regions.values():
173
+ if not world_shapes:
174
+ region_layer = region.collision_layer
175
+ region_mask = region.collision_mask
176
+ ox = region.region_rect[0] * rs
177
+ oy = region.region_rect[1] * rs
178
+ world_shapes.extend(shape.transform(ox, oy, rs) for shape in region.shapes)
179
+ if not world_shapes:
180
+ continue
181
+
182
+ game_obj = MapObject(
183
+ x=x,
184
+ y=y,
185
+ surface=surf,
186
+ collision_shape=world_shapes[0],
187
+ collision_shapes=world_shapes,
188
+ collision_layer=region_layer,
189
+ collision_mask=region_mask,
190
+ )
191
+ objects.append(game_obj)
192
+
193
+ return objects
194
+
195
+
196
+ def _load_collision_for_tileset(
197
+ tilemap_data: TilemapData,
198
+ ttype: int,
199
+ collision_dir: Path,
200
+ cache: Optional[CollisionCache],
201
+ ) -> Optional[ObjectCollisionData]:
202
+ """Load (or retrieve from cache) collision data for a tileset index."""
203
+ if ttype < 0 or ttype >= len(tilemap_data.parsed.tilesets):
204
+ return None
205
+
206
+ tileset_path = tilemap_data.parsed.tilesets[ttype].path
207
+ coll_filename = _resolve_object_collision_filename(tileset_path)
208
+ coll_path = collision_dir / coll_filename
209
+
210
+ if cache is not None:
211
+ return cache.get_object_collision(coll_path)
212
+ 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.2.0
3
+ Version: 4.2.2
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
@@ -7,20 +7,21 @@ tilemap_parser/parser/map_parse.py,sha256=0P8ubprY3eyV34Kpo8eQWabCwlywRFd48y9Kwa
7
7
  tilemap_parser/parser/node_parse.py,sha256=KJopNR4fOhAezJly8ZaKB0gpXJ9rQAVwYE23Yc2OfsE,2301
8
8
  tilemap_parser/parser/particle.py,sha256=DZxRJcM8EwGAL7OmHwcVcINjDS1-apVJHG1h4Xd5xqk,6722
9
9
  tilemap_parser/parser/tmx_converter.py,sha256=-gslroiPocoCZ882duuc1Xxw8RkgcVzs8Fdy0uJqVDY,14006
10
- tilemap_parser/runtime/__init__.py,sha256=IntqAi1vc_0sWqLnH9ySN8IN7E4govNhxXwOhlNBhy0,1745
10
+ tilemap_parser/runtime/__init__.py,sha256=nX8yCAPMFCAkRoBrs39bCvSnugxnWd-BuQwv0CyV-A4,1838
11
11
  tilemap_parser/runtime/animation_player.py,sha256=VwYQjp12oe07uNyWiSWU2YPJPwSrbg9A_dL2rWzGThE,5648
12
12
  tilemap_parser/runtime/area_node.py,sha256=gLxpKnO5PumtZ0HzWHl9Osqgp2fzbF2L4ZbNxTY-HH4,1078
13
13
  tilemap_parser/runtime/camera.py,sha256=UXHN0cRvhbiA0soCinu357S5FpynT0cAYENURax4Ti8,5016
14
14
  tilemap_parser/runtime/collision_cache.py,sha256=fMDGPuyRjddL8KjUnn95iGE-RlK-PaGa3Pzh80z0lAg,4751
15
- tilemap_parser/runtime/map_loader.py,sha256=59HTLet0otmCD_4eO6p6T8njYEwOrc7jNVc0qdQTH3k,15813
16
- tilemap_parser/runtime/object_collision.py,sha256=qX8eImFzVab-MBfq1Rq0rnLWj0bMsWju-itd2s90Q4U,17402
15
+ tilemap_parser/runtime/map_loader.py,sha256=jW-C9AdX8bknNEfB-rEY9g5oABvZDTosPJccvIgPf3Y,16278
16
+ tilemap_parser/runtime/map_object.py,sha256=Sy3tt3OSfot1E2MaxrgfX4I8GlUA5bWPUEHwVJfjBgE,7551
17
+ tilemap_parser/runtime/object_collision.py,sha256=FgxjTOtSHcjg1dlro9FPt4uAofvIiKXGTlYxT0Orh3U,18960
17
18
  tilemap_parser/runtime/particles.py,sha256=PO-1fwa-BW3FUPSECjvqH7s_4z5zocLzJZTze_1gUbY,18066
18
19
  tilemap_parser/runtime/renderer.py,sha256=vMO8Nfm6d7j6wlGGLxKrnQykdmnwRSExnIKpz9MZRUY,7132
19
20
  tilemap_parser/runtime/tile_collision.py,sha256=tPj9qKg9nbbOpKbAIYJh8QknjRg9oh-dyKdYvOD4QgA,77946
20
21
  tilemap_parser/utils/__init__.py,sha256=GjdWhC__OaDH-eZZkRxYnDzfd_5kTeHKcZ2_F1D3FEQ,621
21
22
  tilemap_parser/utils/geometry.py,sha256=UyZm1TMoUs40xnYmuxS8iD9626D6_Ad2SXa7l_ukg-c,22369
22
- tilemap_parser-4.2.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
23
- tilemap_parser-4.2.0.dist-info/METADATA,sha256=FtcYuOKTkvql__PtSaEvvDkKgW4W8P_TcEH8wyYIbN0,44346
24
- tilemap_parser-4.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
25
- tilemap_parser-4.2.0.dist-info/top_level.txt,sha256=VOScMmS9EE8Z6kFSJPMwiiFEqpnH2rjLF6cU8M_oFeU,15
26
- tilemap_parser-4.2.0.dist-info/RECORD,,
23
+ tilemap_parser-4.2.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
24
+ tilemap_parser-4.2.2.dist-info/METADATA,sha256=uB7ZmHICQiitm9SjOWcB2Ux60HFcrbGuz_IH7D_nNBc,44346
25
+ tilemap_parser-4.2.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
26
+ tilemap_parser-4.2.2.dist-info/top_level.txt,sha256=VOScMmS9EE8Z6kFSJPMwiiFEqpnH2rjLF6cU8M_oFeU,15
27
+ tilemap_parser-4.2.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5