tilemap-parser 3.1.19__tar.gz → 3.1.21__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 (36) hide show
  1. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/PKG-INFO +2 -2
  2. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/README.md +1 -1
  3. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/pyproject.toml +1 -1
  4. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/object_collision.py +120 -14
  5. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser.egg-info/PKG-INFO +2 -2
  6. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/tests/test_object_collision.py +193 -17
  7. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/tests/test_tile_collision.py +2 -2
  8. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/LICENSE +0 -0
  9. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/setup.cfg +0 -0
  10. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/__init__.py +0 -0
  11. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/parser/__init__.py +0 -0
  12. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/parser/animation.py +0 -0
  13. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/parser/collision.py +0 -0
  14. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/parser/collision_loader.py +0 -0
  15. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/parser/map_parse.py +0 -0
  16. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/parser/node_parse.py +0 -0
  17. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/parser/particle.py +0 -0
  18. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/__init__.py +0 -0
  19. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/animation_player.py +0 -0
  20. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/area_node.py +0 -0
  21. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  22. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/map_loader.py +0 -0
  23. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/particles.py +0 -0
  24. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/renderer.py +0 -0
  25. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  26. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/utils/__init__.py +0 -0
  27. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser/utils/geometry.py +0 -0
  28. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser.egg-info/SOURCES.txt +0 -0
  29. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  30. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser.egg-info/requires.txt +0 -0
  31. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  32. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/tests/test_collision.py +0 -0
  33. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/tests/test_geometry.py +0 -0
  34. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/tests/test_map_loader.py +0 -0
  35. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/tests/test_object_surfaces.py +0 -0
  36. {tilemap_parser-3.1.19 → tilemap_parser-3.1.21}/tests/test_render_scale.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 3.1.19
3
+ Version: 3.1.21
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
@@ -706,7 +706,7 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
706
706
  - **Map parsing**: Load and query tilemaps, layers, objects, and autotile data from JSON
707
707
  - **Animation**: Frame-based sprite animation with `AnimationPlayer`
708
708
  - **Collision (tile-based)**: Polygon collision detection for tilemaps with slide, platformer, and RPG movement modes via `CollisionRunner`
709
- - **Collision (object-to-object)**: Mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
709
+ - **Collision (object-to-object)**: Spatial-grid mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
710
710
  - **Capsule support**: Full capsule collision against all shape types
711
711
  - **Hit helpers**: `CollisionHit.resolve()`, `involves()`, `other()` for ergonomic separation
712
712
 
@@ -7,7 +7,7 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
7
7
  - **Map parsing**: Load and query tilemaps, layers, objects, and autotile data from JSON
8
8
  - **Animation**: Frame-based sprite animation with `AnimationPlayer`
9
9
  - **Collision (tile-based)**: Polygon collision detection for tilemaps with slide, platformer, and RPG movement modes via `CollisionRunner`
10
- - **Collision (object-to-object)**: Mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
10
+ - **Collision (object-to-object)**: Spatial-grid mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
11
11
  - **Capsule support**: Full capsule collision against all shape types
12
12
  - **Hit helpers**: `CollisionHit.resolve()`, `involves()`, `other()` for ergonomic separation
13
13
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "3.1.19"
7
+ version = "3.1.21"
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"
@@ -9,7 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  import warnings
11
11
  from dataclasses import dataclass
12
- from typing import List, Optional, Protocol, Union
12
+ from math import floor, isfinite
13
+ from typing import Dict, Iterable, Iterator, List, Optional, Protocol, Set, Tuple, Union
13
14
 
14
15
  from ..parser.collision import (
15
16
  CapsuleShape,
@@ -310,16 +311,47 @@ class ObjectCollisionManager:
310
311
  - All-vs-all and one-vs-all queries
311
312
  - Layer filtering
312
313
 
313
- Note: This version uses brute-force O(n²) detection.
314
- Spatial partitioning is deferred to other version.
314
+ Uses a uniform-grid spatial broadphase and exact shape narrowphase.
315
+ The grid is rebuilt for each query so moved objects are always indexed
316
+ at their current world positions.
315
317
  """
316
318
 
317
- def __init__(self) -> None:
319
+ def __init__(
320
+ self,
321
+ objects: Optional[Iterable[ICollidableObject]] = None,
322
+ *,
323
+ cell_size: float = 128.0,
324
+ ) -> None:
325
+ if not isfinite(cell_size) or cell_size <= 0:
326
+ raise ValueError("cell_size must be a finite positive number")
327
+
318
328
  self.objects: List[ICollidableObject] = []
329
+ self.cell_size = float(cell_size)
330
+ if objects is not None:
331
+ for obj in objects:
332
+ self.add_object(obj)
333
+
334
+ def __len__(self) -> int:
335
+ """Return the number of objects currently managed."""
336
+ return len(self.objects)
337
+
338
+ def __iter__(self) -> Iterator[ICollidableObject]:
339
+ """Iterate over managed objects in insertion order."""
340
+ return iter(self.objects)
341
+
342
+ def __contains__(self, obj: object) -> bool:
343
+ """Return True if the exact object instance is managed."""
344
+ return any(existing is obj for existing in self.objects)
345
+
346
+ def _find_object_index(self, obj: ICollidableObject) -> int:
347
+ for index, existing in enumerate(self.objects):
348
+ if existing is obj:
349
+ return index
350
+ return -1
319
351
 
320
352
  def add_object(self, obj: ICollidableObject) -> None:
321
353
  """Add an object to the collision system."""
322
- if obj in self.objects:
354
+ if self._find_object_index(obj) != -1:
323
355
  warnings.warn(
324
356
  f"Object {obj} is already in the collision manager, skipping.",
325
357
  UserWarning,
@@ -330,27 +362,78 @@ class ObjectCollisionManager:
330
362
 
331
363
  def remove_object(self, obj: ICollidableObject) -> None:
332
364
  """Remove an object from the collision system."""
333
- if obj not in self.objects:
365
+ index = self._find_object_index(obj)
366
+ if index == -1:
334
367
  warnings.warn(
335
368
  f"Object {obj} is not in the collision manager, skipping.",
336
369
  UserWarning,
337
370
  stacklevel=2,
338
371
  )
339
372
  return
340
- self.objects.remove(obj)
373
+ del self.objects[index]
374
+
375
+ def clear(self) -> None:
376
+ """Remove all objects from the collision system."""
377
+ self.objects.clear()
378
+
379
+ def _cells_for_aabb(
380
+ self,
381
+ aabb: tuple[float, float, float, float],
382
+ ) -> Iterator[Tuple[int, int]]:
383
+ left, top, right, bottom = aabb
384
+ min_cell_x = floor(left / self.cell_size)
385
+ max_cell_x = floor(right / self.cell_size)
386
+ min_cell_y = floor(top / self.cell_size)
387
+ max_cell_y = floor(bottom / self.cell_size)
388
+
389
+ for cell_y in range(min_cell_y, max_cell_y + 1):
390
+ for cell_x in range(min_cell_x, max_cell_x + 1):
391
+ yield (cell_x, cell_y)
392
+
393
+ def _object_aabb(
394
+ self,
395
+ obj: ICollidableObject,
396
+ ) -> tuple[float, float, float, float]:
397
+ return get_shape_aabb(obj.x, obj.y, obj.collision_shape)
398
+
399
+ def _build_spatial_index(
400
+ self,
401
+ ) -> tuple[Tuple[ICollidableObject, ...], Dict[Tuple[int, int], List[int]]]:
402
+ objects = tuple(self.objects)
403
+ grid: Dict[Tuple[int, int], List[int]] = {}
404
+
405
+ for index, obj in enumerate(objects):
406
+ for cell in self._cells_for_aabb(self._object_aabb(obj)):
407
+ grid.setdefault(cell, []).append(index)
408
+
409
+ return objects, grid
410
+
411
+ def _candidate_indices(
412
+ self,
413
+ obj: ICollidableObject,
414
+ grid: Dict[Tuple[int, int], List[int]],
415
+ ) -> Set[int]:
416
+ candidates: Set[int] = set()
417
+ for cell in self._cells_for_aabb(self._object_aabb(obj)):
418
+ candidates.update(grid.get(cell, ()))
419
+ return candidates
341
420
 
342
421
  def check_all_collisions(self) -> List[CollisionHit]:
343
422
  """
344
- Check every unique pair (brute-force).
423
+ Check every potentially colliding pair.
345
424
 
346
425
  Returns a list of CollisionHit for all colliding pairs.
347
426
  Each pair appears at most once (i, j) with j > i.
348
427
  """
428
+ objects, grid = self._build_spatial_index()
349
429
  hits: List[CollisionHit] = []
350
- n = len(self.objects)
351
- for i in range(n):
352
- for j in range(i + 1, n):
353
- hit = check_collision(self.objects[i], self.objects[j])
430
+
431
+ for i, obj in enumerate(objects):
432
+ candidate_indices = self._candidate_indices(obj, grid)
433
+ for j in sorted(candidate_indices):
434
+ if j <= i:
435
+ continue
436
+ hit = check_collision(objects[i], objects[j])
354
437
  if hit is not None:
355
438
  hits.append(hit)
356
439
  return hits
@@ -359,13 +442,36 @@ class ObjectCollisionManager:
359
442
  """
360
443
  Check one object against all others.
361
444
 
362
- Skips comparison with itself.
445
+ The queried object does not need to be managed. If it is managed,
446
+ comparison with itself is skipped by identity.
363
447
  """
448
+ objects, grid = self._build_spatial_index()
364
449
  hits: List[CollisionHit] = []
365
- for other in self.objects:
450
+ for index in sorted(self._candidate_indices(obj, grid)):
451
+ other = objects[index]
366
452
  if other is obj:
367
453
  continue
368
454
  hit = check_collision(obj, other)
369
455
  if hit is not None:
370
456
  hits.append(hit)
371
457
  return hits
458
+
459
+ def check_object_first(self, obj: ICollidableObject) -> Optional[CollisionHit]:
460
+ """
461
+ Check one object against all others and return the first collision hit.
462
+
463
+ The queried object does not need to be managed. If it is managed,
464
+ comparison with itself is skipped by identity.
465
+
466
+ Candidate iteration follows insertion order among the spatially relevant
467
+ managed objects.
468
+ """
469
+ objects, grid = self._build_spatial_index()
470
+ for index in sorted(self._candidate_indices(obj, grid)):
471
+ other = objects[index]
472
+ if other is obj:
473
+ continue
474
+ hit = check_collision(obj, other)
475
+ if hit is not None:
476
+ return hit
477
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 3.1.19
3
+ Version: 3.1.21
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
@@ -706,7 +706,7 @@ Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and co
706
706
  - **Map parsing**: Load and query tilemaps, layers, objects, and autotile data from JSON
707
707
  - **Animation**: Frame-based sprite animation with `AnimationPlayer`
708
708
  - **Collision (tile-based)**: Polygon collision detection for tilemaps with slide, platformer, and RPG movement modes via `CollisionRunner`
709
- - **Collision (object-to-object)**: Mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
709
+ - **Collision (object-to-object)**: Spatial-grid mixed-shape collision detection (rect, circle, capsule, polygon) with layer filtering via `ObjectCollisionManager`
710
710
  - **Capsule support**: Full capsule collision against all shape types
711
711
  - **Hit helpers**: `CollisionHit.resolve()`, `involves()`, `other()` for ergonomic separation
712
712
 
@@ -8,13 +8,15 @@ Covers:
8
8
  - ObjectCollisionManager (add/remove, queries, duplicates, warnings)
9
9
  """
10
10
 
11
+ import sys
11
12
  import warnings
12
- import pytest
13
13
  from pathlib import Path
14
- import sys
14
+
15
+ import pytest
15
16
 
16
17
  sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
17
18
 
19
+ import tilemap_parser.runtime.object_collision as object_collision_runtime
18
20
  from tilemap_parser.parser.collision import (
19
21
  CapsuleShape,
20
22
  CircleShape,
@@ -24,15 +26,15 @@ from tilemap_parser.parser.collision import (
24
26
  from tilemap_parser.runtime.object_collision import (
25
27
  CollisionHit,
26
28
  ObjectCollisionManager,
27
- should_collide,
28
29
  check_collision,
30
+ should_collide,
29
31
  )
30
32
 
31
-
32
33
  # ---------------------------------------------------------------------------
33
34
  # Helpers — concrete objects implementing ICollidableObject
34
35
  # ---------------------------------------------------------------------------
35
36
 
37
+
36
38
  class _DummyObject:
37
39
  """Minimal collidable object for tests."""
38
40
 
@@ -67,6 +69,7 @@ def _make_poly(x, y, vertices, collision_layer=1, collision_mask=0xFFFFFFFF):
67
69
  # should_collide
68
70
  # ---------------------------------------------------------------------------
69
71
 
72
+
70
73
  class TestShouldCollide:
71
74
  def test_default_layers_collide(self):
72
75
  a = _make_rect(0, 0, 10, 10)
@@ -93,6 +96,7 @@ class TestShouldCollide:
93
96
  x = 0.0
94
97
  y = 0.0
95
98
  collision_shape = RectangleShape(width=10, height=10)
99
+
96
100
  assert should_collide(BareObject(), BareObject()) is True
97
101
 
98
102
 
@@ -100,6 +104,7 @@ class TestShouldCollide:
100
104
  # check_collision
101
105
  # ---------------------------------------------------------------------------
102
106
 
107
+
103
108
  class TestCheckCollisionCircleCircle:
104
109
  def test_colliding(self):
105
110
  a = _make_circle(0, 0, 10)
@@ -236,6 +241,7 @@ class TestCheckCollisionPolygon:
236
241
  # ObjectCollisionManager
237
242
  # ---------------------------------------------------------------------------
238
243
 
244
+
239
245
  class TestManagerAddRemove:
240
246
  def test_add_and_remove(self):
241
247
  mgr = ObjectCollisionManager()
@@ -257,6 +263,22 @@ class TestManagerAddRemove:
257
263
  assert "already" in str(w[0].message).lower()
258
264
  assert len(mgr.objects) == 1
259
265
 
266
+ def test_add_value_equal_distinct_objects(self):
267
+ class EqualObject(_DummyObject):
268
+ def __eq__(self, other):
269
+ return isinstance(other, EqualObject)
270
+
271
+ mgr = ObjectCollisionManager()
272
+ a = EqualObject(0, 0)
273
+ b = EqualObject(20, 0)
274
+
275
+ mgr.add_object(a)
276
+ mgr.add_object(b)
277
+
278
+ assert len(mgr.objects) == 2
279
+ assert mgr.objects[0] is a
280
+ assert mgr.objects[1] is b
281
+
260
282
  def test_remove_nonexistent_warns(self):
261
283
  mgr = ObjectCollisionManager()
262
284
  obj = _make_rect(0, 0, 10, 10)
@@ -267,8 +289,49 @@ class TestManagerAddRemove:
267
289
  assert issubclass(w[0].category, UserWarning)
268
290
  assert "not" in str(w[0].message).lower()
269
291
 
292
+ def test_remove_uses_identity_not_equality(self):
293
+ class EqualObject(_DummyObject):
294
+ def __eq__(self, other):
295
+ return isinstance(other, EqualObject)
296
+
297
+ mgr = ObjectCollisionManager()
298
+ a = EqualObject(0, 0)
299
+ b = EqualObject(20, 0)
300
+ mgr.add_object(a)
301
+ mgr.add_object(b)
302
+
303
+ mgr.remove_object(b)
304
+
305
+ assert len(mgr.objects) == 1
306
+ assert mgr.objects[0] is a
307
+
308
+ def test_construct_with_objects(self):
309
+ a = _make_rect(0, 0, 10, 10)
310
+ b = _make_rect(20, 0, 10, 10)
311
+
312
+ mgr = ObjectCollisionManager([a, b])
313
+
314
+ assert len(mgr) == 2
315
+ objects = list(mgr)
316
+ assert objects[0] is a
317
+ assert objects[1] is b
318
+ assert a in mgr
319
+ assert b in mgr
320
+
321
+ def test_clear(self):
322
+ mgr = ObjectCollisionManager([_make_rect(0, 0, 10, 10)])
323
+
324
+ mgr.clear()
325
+
326
+ assert len(mgr) == 0
327
+ assert mgr.objects == []
328
+
270
329
 
271
330
  class TestManagerCheckAll:
331
+ def test_invalid_cell_size_raises(self):
332
+ with pytest.raises(ValueError, match="cell_size"):
333
+ ObjectCollisionManager(cell_size=0)
334
+
272
335
  def test_no_duplicates(self):
273
336
  """Three overlapping circles — should produce exactly 3 pairs."""
274
337
  mgr = ObjectCollisionManager()
@@ -286,6 +349,56 @@ class TestManagerCheckAll:
286
349
  mgr.add_object(_make_rect(0, 0, 10, 10))
287
350
  assert mgr.check_all_collisions() == []
288
351
 
352
+ def test_spatial_broadphase_skips_distant_pairs(self, monkeypatch):
353
+ mgr = ObjectCollisionManager(cell_size=16)
354
+ a = _make_rect(0, 0, 10, 10)
355
+ b = _make_rect(100, 0, 10, 10)
356
+ mgr.add_object(a)
357
+ mgr.add_object(b)
358
+ calls = []
359
+
360
+ def fake_check_collision(obj_a, obj_b):
361
+ calls.append((obj_a, obj_b))
362
+ return None
363
+
364
+ monkeypatch.setattr(
365
+ object_collision_runtime,
366
+ "check_collision",
367
+ fake_check_collision,
368
+ )
369
+
370
+ assert mgr.check_all_collisions() == []
371
+ assert calls == []
372
+
373
+ def test_large_object_spanning_cells_collides(self):
374
+ mgr = ObjectCollisionManager(cell_size=16)
375
+ large = _make_rect(0, 0, 64, 16)
376
+ small = _make_rect(48, 0, 8, 8)
377
+ mgr.add_object(large)
378
+ mgr.add_object(small)
379
+
380
+ hits = mgr.check_all_collisions()
381
+
382
+ assert len(hits) == 1
383
+ assert hits[0].object_a is large
384
+ assert hits[0].object_b is small
385
+
386
+ def test_moved_objects_are_reindexed_per_query(self):
387
+ mgr = ObjectCollisionManager(cell_size=16)
388
+ a = _make_rect(0, 0, 10, 10)
389
+ b = _make_rect(100, 0, 10, 10)
390
+ mgr.add_object(a)
391
+ mgr.add_object(b)
392
+
393
+ assert mgr.check_all_collisions() == []
394
+
395
+ b.x = 8
396
+ hits = mgr.check_all_collisions()
397
+
398
+ assert len(hits) == 1
399
+ assert hits[0].involves(a)
400
+ assert hits[0].involves(b)
401
+
289
402
 
290
403
  class TestManagerCheckObject:
291
404
  def test_returns_hits(self):
@@ -316,11 +429,59 @@ class TestManagerCheckObject:
316
429
  hits = mgr.check_object(a)
317
430
  assert hits == []
318
431
 
432
+ def test_unmanaged_query_object(self):
433
+ mgr = ObjectCollisionManager(cell_size=16)
434
+ query = _make_rect(0, 0, 10, 10)
435
+ managed = _make_rect(8, 0, 10, 10)
436
+ far = _make_rect(100, 0, 10, 10)
437
+ mgr.add_object(managed)
438
+ mgr.add_object(far)
439
+
440
+ hits = mgr.check_object(query)
441
+
442
+ assert len(hits) == 1
443
+ assert hits[0].object_a is query
444
+ assert hits[0].object_b is managed
445
+
446
+
447
+ class TestManagerCheckObjectFirst:
448
+ def test_returns_first_hit(self):
449
+ mgr = ObjectCollisionManager()
450
+ query = _make_circle(0, 0, 10)
451
+ first = _make_circle(15, 0, 10)
452
+ second = _make_circle(18, 0, 10)
453
+ far = _make_circle(100, 0, 10)
454
+ mgr.add_object(first)
455
+ mgr.add_object(second)
456
+ mgr.add_object(far)
457
+
458
+ hit = mgr.check_object_first(query)
459
+
460
+ assert hit is not None
461
+ assert hit.object_a is query
462
+ assert hit.object_b is first
463
+
464
+ def test_returns_none_when_no_hit(self):
465
+ mgr = ObjectCollisionManager()
466
+ query = _make_rect(0, 0, 10, 10)
467
+ far = _make_rect(100, 0, 10, 10)
468
+ mgr.add_object(far)
469
+
470
+ assert mgr.check_object_first(query) is None
471
+
472
+ def test_skips_self(self):
473
+ mgr = ObjectCollisionManager()
474
+ obj = _make_rect(0, 0, 10, 10)
475
+ mgr.add_object(obj)
476
+
477
+ assert mgr.check_object_first(obj) is None
478
+
319
479
 
320
480
  # ---------------------------------------------------------------------------
321
481
  # CollisionHit helpers
322
482
  # ---------------------------------------------------------------------------
323
483
 
484
+
324
485
  class TestCollisionHitHelpers:
325
486
  def test_resolve_separates(self):
326
487
  a = _make_circle(0, 0, 10)
@@ -364,8 +525,10 @@ class TestCollisionHitHelpers:
364
525
  def test_slide_head_on(self):
365
526
  """Directly into surface → zero tangential motion."""
366
527
  hit = CollisionHit(
367
- object_a=None, object_b=None,
368
- normal=(1.0, 0.0), depth=5.0,
528
+ object_a=None,
529
+ object_b=None,
530
+ normal=(1.0, 0.0),
531
+ depth=5.0,
369
532
  )
370
533
  sx, sy = hit.slide_velocity(10.0, 0.0)
371
534
  assert sx == 0.0
@@ -374,8 +537,10 @@ class TestCollisionHitHelpers:
374
537
  def test_slide_angled(self):
375
538
  """Diagonal into surface → only perpendicular component removed."""
376
539
  hit = CollisionHit(
377
- object_a=None, object_b=None,
378
- normal=(1.0, 0.0), depth=5.0,
540
+ object_a=None,
541
+ object_b=None,
542
+ normal=(1.0, 0.0),
543
+ depth=5.0,
379
544
  )
380
545
  sx, sy = hit.slide_velocity(10.0, 5.0)
381
546
  assert sx == 0.0 # x component fully removed
@@ -384,8 +549,10 @@ class TestCollisionHitHelpers:
384
549
  def test_slide_parallel(self):
385
550
  """Velocity along the surface → unchanged."""
386
551
  hit = CollisionHit(
387
- object_a=None, object_b=None,
388
- normal=(0.0, 1.0), depth=5.0,
552
+ object_a=None,
553
+ object_b=None,
554
+ normal=(0.0, 1.0),
555
+ depth=5.0,
389
556
  )
390
557
  sx, sy = hit.slide_velocity(10.0, 0.0)
391
558
  assert sx == 10.0
@@ -394,8 +561,10 @@ class TestCollisionHitHelpers:
394
561
  def test_slide_away(self):
395
562
  """Velocity pointing away from surface → unchanged."""
396
563
  hit = CollisionHit(
397
- object_a=None, object_b=None,
398
- normal=(1.0, 0.0), depth=5.0,
564
+ object_a=None,
565
+ object_b=None,
566
+ normal=(1.0, 0.0),
567
+ depth=5.0,
399
568
  )
400
569
  sx, sy = hit.slide_velocity(-10.0, 0.0)
401
570
  assert sx == -10.0
@@ -404,8 +573,10 @@ class TestCollisionHitHelpers:
404
573
  def test_slide_zero(self):
405
574
  """Zero velocity → zero."""
406
575
  hit = CollisionHit(
407
- object_a=None, object_b=None,
408
- normal=(1.0, 0.0), depth=5.0,
576
+ object_a=None,
577
+ object_b=None,
578
+ normal=(1.0, 0.0),
579
+ depth=5.0,
409
580
  )
410
581
  sx, sy = hit.slide_velocity(0.0, 0.0)
411
582
  assert sx == 0.0
@@ -414,10 +585,13 @@ class TestCollisionHitHelpers:
414
585
  def test_slide_diagonal_normal(self):
415
586
  """Normal at 45°, velocity into surface → slide along surface."""
416
587
  import math
588
+
417
589
  n = (1.0 / math.sqrt(2), 1.0 / math.sqrt(2))
418
590
  hit = CollisionHit(
419
- object_a=None, object_b=None,
420
- normal=n, depth=5.0,
591
+ object_a=None,
592
+ object_b=None,
593
+ normal=n,
594
+ depth=5.0,
421
595
  )
422
596
  sx, sy = hit.slide_velocity(1.0, 0.0)
423
597
  # Dot = 1/sqrt(2) ≈ 0.707
@@ -430,6 +604,7 @@ class TestCollisionHitHelpers:
430
604
  # Capsule collision
431
605
  # ---------------------------------------------------------------------------
432
606
 
607
+
433
608
  def _make_capsule(
434
609
  x: float = 0.0,
435
610
  y: float = 0.0,
@@ -441,7 +616,8 @@ def _make_capsule(
441
616
  collision_mask: int = 0xFFFFFFFF,
442
617
  ) -> _DummyObject:
443
618
  return _DummyObject(
444
- x=x, y=y,
619
+ x=x,
620
+ y=y,
445
621
  shape=CapsuleShape(radius=radius, height=height, offset=(ox, oy)),
446
622
  collision_layer=collision_layer,
447
623
  collision_mask=collision_mask,
@@ -142,9 +142,9 @@ class TestShapeBounds:
142
142
  sprite = MockSprite(x=100, y=200, shape=CapsuleShape(radius=8, height=48, offset=(0, 0)))
143
143
  left, top, right, bottom = get_shape_bounds(sprite)
144
144
  assert left == 92
145
- assert top == 200
145
+ assert top == 192
146
146
  assert right == 108
147
- assert bottom == 264
147
+ assert bottom == 256
148
148
 
149
149
 
150
150
  # ===========================================================================
File without changes