tilemap-parser 3.1.19__tar.gz → 3.1.20__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.20}/PKG-INFO +2 -2
  2. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/README.md +1 -1
  3. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/pyproject.toml +1 -1
  4. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/object_collision.py +100 -14
  5. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser.egg-info/PKG-INFO +2 -2
  6. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/tests/test_object_collision.py +122 -0
  7. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/LICENSE +0 -0
  8. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/setup.cfg +0 -0
  9. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/__init__.py +0 -0
  10. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/parser/__init__.py +0 -0
  11. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/parser/animation.py +0 -0
  12. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/parser/collision.py +0 -0
  13. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/parser/collision_loader.py +0 -0
  14. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/parser/map_parse.py +0 -0
  15. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/parser/node_parse.py +0 -0
  16. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/parser/particle.py +0 -0
  17. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/__init__.py +0 -0
  18. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/animation_player.py +0 -0
  19. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/area_node.py +0 -0
  20. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  21. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/map_loader.py +0 -0
  22. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/particles.py +0 -0
  23. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/renderer.py +0 -0
  24. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  25. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/utils/__init__.py +0 -0
  26. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser/utils/geometry.py +0 -0
  27. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser.egg-info/SOURCES.txt +0 -0
  28. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  29. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser.egg-info/requires.txt +0 -0
  30. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  31. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/tests/test_collision.py +0 -0
  32. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/tests/test_geometry.py +0 -0
  33. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/tests/test_map_loader.py +0 -0
  34. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/tests/test_object_surfaces.py +0 -0
  35. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/tests/test_render_scale.py +0 -0
  36. {tilemap_parser-3.1.19 → tilemap_parser-3.1.20}/tests/test_tile_collision.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.20
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.20"
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,10 +442,13 @@ 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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 3.1.19
3
+ Version: 3.1.20
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
 
@@ -21,6 +21,7 @@ from tilemap_parser.parser.collision import (
21
21
  CollisionPolygon,
22
22
  RectangleShape,
23
23
  )
24
+ import tilemap_parser.runtime.object_collision as object_collision_runtime
24
25
  from tilemap_parser.runtime.object_collision import (
25
26
  CollisionHit,
26
27
  ObjectCollisionManager,
@@ -257,6 +258,22 @@ class TestManagerAddRemove:
257
258
  assert "already" in str(w[0].message).lower()
258
259
  assert len(mgr.objects) == 1
259
260
 
261
+ def test_add_value_equal_distinct_objects(self):
262
+ class EqualObject(_DummyObject):
263
+ def __eq__(self, other):
264
+ return isinstance(other, EqualObject)
265
+
266
+ mgr = ObjectCollisionManager()
267
+ a = EqualObject(0, 0)
268
+ b = EqualObject(20, 0)
269
+
270
+ mgr.add_object(a)
271
+ mgr.add_object(b)
272
+
273
+ assert len(mgr.objects) == 2
274
+ assert mgr.objects[0] is a
275
+ assert mgr.objects[1] is b
276
+
260
277
  def test_remove_nonexistent_warns(self):
261
278
  mgr = ObjectCollisionManager()
262
279
  obj = _make_rect(0, 0, 10, 10)
@@ -267,8 +284,49 @@ class TestManagerAddRemove:
267
284
  assert issubclass(w[0].category, UserWarning)
268
285
  assert "not" in str(w[0].message).lower()
269
286
 
287
+ def test_remove_uses_identity_not_equality(self):
288
+ class EqualObject(_DummyObject):
289
+ def __eq__(self, other):
290
+ return isinstance(other, EqualObject)
291
+
292
+ mgr = ObjectCollisionManager()
293
+ a = EqualObject(0, 0)
294
+ b = EqualObject(20, 0)
295
+ mgr.add_object(a)
296
+ mgr.add_object(b)
297
+
298
+ mgr.remove_object(b)
299
+
300
+ assert len(mgr.objects) == 1
301
+ assert mgr.objects[0] is a
302
+
303
+ def test_construct_with_objects(self):
304
+ a = _make_rect(0, 0, 10, 10)
305
+ b = _make_rect(20, 0, 10, 10)
306
+
307
+ mgr = ObjectCollisionManager([a, b])
308
+
309
+ assert len(mgr) == 2
310
+ objects = list(mgr)
311
+ assert objects[0] is a
312
+ assert objects[1] is b
313
+ assert a in mgr
314
+ assert b in mgr
315
+
316
+ def test_clear(self):
317
+ mgr = ObjectCollisionManager([_make_rect(0, 0, 10, 10)])
318
+
319
+ mgr.clear()
320
+
321
+ assert len(mgr) == 0
322
+ assert mgr.objects == []
323
+
270
324
 
271
325
  class TestManagerCheckAll:
326
+ def test_invalid_cell_size_raises(self):
327
+ with pytest.raises(ValueError, match="cell_size"):
328
+ ObjectCollisionManager(cell_size=0)
329
+
272
330
  def test_no_duplicates(self):
273
331
  """Three overlapping circles — should produce exactly 3 pairs."""
274
332
  mgr = ObjectCollisionManager()
@@ -286,6 +344,56 @@ class TestManagerCheckAll:
286
344
  mgr.add_object(_make_rect(0, 0, 10, 10))
287
345
  assert mgr.check_all_collisions() == []
288
346
 
347
+ def test_spatial_broadphase_skips_distant_pairs(self, monkeypatch):
348
+ mgr = ObjectCollisionManager(cell_size=16)
349
+ a = _make_rect(0, 0, 10, 10)
350
+ b = _make_rect(100, 0, 10, 10)
351
+ mgr.add_object(a)
352
+ mgr.add_object(b)
353
+ calls = []
354
+
355
+ def fake_check_collision(obj_a, obj_b):
356
+ calls.append((obj_a, obj_b))
357
+ return None
358
+
359
+ monkeypatch.setattr(
360
+ object_collision_runtime,
361
+ "check_collision",
362
+ fake_check_collision,
363
+ )
364
+
365
+ assert mgr.check_all_collisions() == []
366
+ assert calls == []
367
+
368
+ def test_large_object_spanning_cells_collides(self):
369
+ mgr = ObjectCollisionManager(cell_size=16)
370
+ large = _make_rect(0, 0, 64, 16)
371
+ small = _make_rect(48, 0, 8, 8)
372
+ mgr.add_object(large)
373
+ mgr.add_object(small)
374
+
375
+ hits = mgr.check_all_collisions()
376
+
377
+ assert len(hits) == 1
378
+ assert hits[0].object_a is large
379
+ assert hits[0].object_b is small
380
+
381
+ def test_moved_objects_are_reindexed_per_query(self):
382
+ mgr = ObjectCollisionManager(cell_size=16)
383
+ a = _make_rect(0, 0, 10, 10)
384
+ b = _make_rect(100, 0, 10, 10)
385
+ mgr.add_object(a)
386
+ mgr.add_object(b)
387
+
388
+ assert mgr.check_all_collisions() == []
389
+
390
+ b.x = 8
391
+ hits = mgr.check_all_collisions()
392
+
393
+ assert len(hits) == 1
394
+ assert hits[0].involves(a)
395
+ assert hits[0].involves(b)
396
+
289
397
 
290
398
  class TestManagerCheckObject:
291
399
  def test_returns_hits(self):
@@ -316,6 +424,20 @@ class TestManagerCheckObject:
316
424
  hits = mgr.check_object(a)
317
425
  assert hits == []
318
426
 
427
+ def test_unmanaged_query_object(self):
428
+ mgr = ObjectCollisionManager(cell_size=16)
429
+ query = _make_rect(0, 0, 10, 10)
430
+ managed = _make_rect(8, 0, 10, 10)
431
+ far = _make_rect(100, 0, 10, 10)
432
+ mgr.add_object(managed)
433
+ mgr.add_object(far)
434
+
435
+ hits = mgr.check_object(query)
436
+
437
+ assert len(hits) == 1
438
+ assert hits[0].object_a is query
439
+ assert hits[0].object_b is managed
440
+
319
441
 
320
442
  # ---------------------------------------------------------------------------
321
443
  # CollisionHit helpers
File without changes