tilemap-parser 4.2.8__tar.gz → 5.0.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 (74) hide show
  1. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/PKG-INFO +35 -1
  2. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/README.md +34 -0
  3. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/pyproject.toml +1 -1
  4. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/__init__.py +8 -10
  5. tilemap_parser-5.0.1/src/tilemap_parser/runtime/body.py +198 -0
  6. tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/__init__.py +10 -0
  7. tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/hit.py +150 -0
  8. tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/manager.py +184 -0
  9. tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/shapes.py +170 -0
  10. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/map_loader.py +1 -1
  11. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/__init__.py +11 -0
  12. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/grounded.py +181 -0
  13. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/platformer.py +533 -0
  14. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/queries.py +264 -0
  15. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/rpg.py +87 -0
  16. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/runner.py +488 -0
  17. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/slide.py +209 -0
  18. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/types.py +30 -0
  19. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/navigation/path_follower.py +1 -1
  20. tilemap_parser-5.0.1/src/tilemap_parser/runtime/object_collision.py +38 -0
  21. tilemap_parser-5.0.1/src/tilemap_parser/runtime/polygon_query.py +490 -0
  22. tilemap_parser-5.0.1/src/tilemap_parser/runtime/protocols.py +71 -0
  23. tilemap_parser-5.0.1/src/tilemap_parser/runtime/tile_collision.py +62 -0
  24. tilemap_parser-5.0.1/src/tilemap_parser/runtime/world.py +146 -0
  25. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/PKG-INFO +35 -1
  26. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/SOURCES.txt +16 -0
  27. tilemap_parser-5.0.1/tests/test_body.py +802 -0
  28. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_map_object.py +6 -6
  29. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_move_grounded.py +1 -1
  30. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_navigation.py +1 -1
  31. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_object_collision.py +3 -3
  32. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_render_scale.py +2 -3
  33. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_tile_collision.py +2 -3
  34. tilemap_parser-4.2.8/src/tilemap_parser/runtime/object_collision.py +0 -512
  35. tilemap_parser-4.2.8/src/tilemap_parser/runtime/protocols.py +0 -37
  36. tilemap_parser-4.2.8/src/tilemap_parser/runtime/tile_collision.py +0 -2157
  37. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/LICENSE +0 -0
  38. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/setup.cfg +0 -0
  39. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/__init__.py +0 -0
  40. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/__init__.py +0 -0
  41. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/animation.py +0 -0
  42. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/collision.py +0 -0
  43. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/collision_loader.py +0 -0
  44. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/map_parse.py +0 -0
  45. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/node_parse.py +0 -0
  46. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/particle.py +0 -0
  47. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  48. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/animation_player.py +0 -0
  49. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/area_node.py +0 -0
  50. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/camera.py +0 -0
  51. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  52. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/map_object.py +0 -0
  53. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/navigation/__init__.py +0 -0
  54. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/navigation/nav_grid.py +0 -0
  55. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/navigation/pathfinder.py +0 -0
  56. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/particles.py +0 -0
  57. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/renderer.py +0 -0
  58. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/utils/__init__.py +0 -0
  59. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/utils/geometry.py +0 -0
  60. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  61. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/requires.txt +0 -0
  62. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  63. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_camera.py +0 -0
  64. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_collision.py +0 -0
  65. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_geometry.py +0 -0
  66. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_gid_collision.py +0 -0
  67. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_integration_animation.py +0 -0
  68. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_integration_collision.py +0 -0
  69. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_integration_map_loader.py +0 -0
  70. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_map_loader.py +0 -0
  71. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_object_surfaces.py +0 -0
  72. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_particle_emitter.py +0 -0
  73. {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_tile_layer_renderer_y_sort.py +0 -0
  74. {tilemap_parser-4.2.8 → tilemap_parser-5.0.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.2.8
3
+ Version: 5.0.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
@@ -765,6 +765,40 @@ particle_config = ParticleSystemConfig(
765
765
  particles = ParticleSystem(particle_config)
766
766
  ```
767
767
 
768
+ ## Physics bodies
769
+
770
+ A `PhysicsWorld` is a single space holding the tile layer plus solid `Body` objects (static walls, kinematic crates). Attach a `CollisionRunner` to the world once and every move method resolves tiles and bodies through it — no per-call tile arguments.
771
+
772
+ > Full contract — object↔world↔`move_*`↔tiles↔rendering: see [docs/physics-world.md](docs/physics-world.md) and `examples/physics-crate/main.py`.
773
+
774
+ ```python
775
+ from tilemap_parser import (
776
+ Body, PhysicsWorld, CollisionRunner, RectangleShape,
777
+ TilemapData, TilesetCollision, CollisionCache,
778
+ )
779
+
780
+ cache = CollisionCache()
781
+ tileset = cache.get_tileset_collision("data/collision/tileset.collision.json")
782
+ world = PhysicsWorld.from_map(game_data, tileset) # adopts tile_size / render_scale
783
+
784
+ crate = Body(RectangleShape(width=16, height=16), x=320, y=480, mode="kinematic")
785
+ world.add_body(crate)
786
+
787
+ runner = CollisionRunner.from_world(world) # or runner.attach(world)
788
+ ```
789
+
790
+ Pushable crates are just kinematic bodies moved with an explicit velocity (gravity is skipped when `velocity=` is given):
791
+
792
+ ```python
793
+ # player pressed against the crate -> hand the player's vx over
794
+ crate.vx = player.vx
795
+ result = runner.move_grounded(crate, None, None, dt, velocity=(crate.vx, 0))
796
+ if result.hit_wall_x:
797
+ crate.vx = 0 # crate stopped against a tile wall or another body
798
+ ```
799
+
800
+ `Body` accepts primitive shapes only (`RectangleShape`, `CircleShape`, `CapsuleShape`); use `MapObject` for polygon solids. Collision pairs honor both sides' `collision_layer` / `collision_mask`, and bodies are landing surfaces for platformer step-up (small crates are climbed, tall ones block like walls).
801
+
768
802
  ## Links
769
803
 
770
804
  - **Docs**: [https://deepwiki.com/FluffyBrudy/tilemap-parser](https://deepwiki.com/FluffyBrudy/tilemap-parser)
@@ -66,6 +66,40 @@ particle_config = ParticleSystemConfig(
66
66
  particles = ParticleSystem(particle_config)
67
67
  ```
68
68
 
69
+ ## Physics bodies
70
+
71
+ A `PhysicsWorld` is a single space holding the tile layer plus solid `Body` objects (static walls, kinematic crates). Attach a `CollisionRunner` to the world once and every move method resolves tiles and bodies through it — no per-call tile arguments.
72
+
73
+ > Full contract — object↔world↔`move_*`↔tiles↔rendering: see [docs/physics-world.md](docs/physics-world.md) and `examples/physics-crate/main.py`.
74
+
75
+ ```python
76
+ from tilemap_parser import (
77
+ Body, PhysicsWorld, CollisionRunner, RectangleShape,
78
+ TilemapData, TilesetCollision, CollisionCache,
79
+ )
80
+
81
+ cache = CollisionCache()
82
+ tileset = cache.get_tileset_collision("data/collision/tileset.collision.json")
83
+ world = PhysicsWorld.from_map(game_data, tileset) # adopts tile_size / render_scale
84
+
85
+ crate = Body(RectangleShape(width=16, height=16), x=320, y=480, mode="kinematic")
86
+ world.add_body(crate)
87
+
88
+ runner = CollisionRunner.from_world(world) # or runner.attach(world)
89
+ ```
90
+
91
+ Pushable crates are just kinematic bodies moved with an explicit velocity (gravity is skipped when `velocity=` is given):
92
+
93
+ ```python
94
+ # player pressed against the crate -> hand the player's vx over
95
+ crate.vx = player.vx
96
+ result = runner.move_grounded(crate, None, None, dt, velocity=(crate.vx, 0))
97
+ if result.hit_wall_x:
98
+ crate.vx = 0 # crate stopped against a tile wall or another body
99
+ ```
100
+
101
+ `Body` accepts primitive shapes only (`RectangleShape`, `CircleShape`, `CapsuleShape`); use `MapObject` for polygon solids. Collision pairs honor both sides' `collision_layer` / `collision_mask`, and bodies are landing surfaces for platformer step-up (small crates are climbed, tall ones block like walls).
102
+
69
103
  ## Links
70
104
 
71
105
  - **Docs**: [https://deepwiki.com/FluffyBrudy/tilemap-parser](https://deepwiki.com/FluffyBrudy/tilemap-parser)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "4.2.8"
7
+ version = "5.0.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"
@@ -1,5 +1,7 @@
1
1
  from .camera import Camera
2
2
  from .animation_player import AnimationPlayer, SpriteAnimationSet
3
+ from .body import Body
4
+ from .world import PhysicsWorld
3
5
  from .collision_cache import (
4
6
  CollisionCache,
5
7
  clear_collision_cache,
@@ -11,18 +13,12 @@ from .collision_cache import (
11
13
  load_tileset_collision,
12
14
  )
13
15
  from .map_object import MapObject, load_map_objects
14
- from .tile_collision import (
15
- CollisionResult,
16
- CollisionRunner,
17
- ICollidableSprite,
18
- MovementMode,
19
- rect_vs_tilemap,
20
- )
21
- from .protocols import ICollidable
16
+ from .movement import CollisionResult, CollisionRunner, MovementMode
17
+ from .polygon_query import rect_vs_tilemap
18
+ from .protocols import ICollidable, ICollidableObject, ICollidableSprite
22
19
  from .map_loader import TilemapData, load_map
23
- from .object_collision import (
20
+ from .collision import (
24
21
  CollisionHit,
25
- ICollidableObject,
26
22
  ObjectCollisionManager,
27
23
  check_collision,
28
24
  )
@@ -42,6 +38,7 @@ from .particles import (
42
38
  __all__ = [
43
39
  "AnimationPlayer",
44
40
  "AreaNode",
41
+ "Body",
45
42
  "Camera",
46
43
  "CollisionCache",
47
44
  "CollisionHit",
@@ -72,6 +69,7 @@ __all__ = [
72
69
  "ParticleEmitterNode",
73
70
  "ParticleRenderer",
74
71
  "ParticleSystem",
72
+ "PhysicsWorld",
75
73
  "rect_vs_tilemap",
76
74
  "SpriteBatchRenderer",
77
75
  "clear_texture_caches",
@@ -0,0 +1,198 @@
1
+ """Physics bodies authored into a :class:`~.world.PhysicsWorld`.
2
+
3
+ A :class:`Body` is the authoring surface for a solid in the world. It owns
4
+ a single primitive collision shape (rectangle, circle, or capsule — polygon
5
+ shapes stay in the ``MapObject`` lane) plus its position and velocity, and
6
+ participates in collision detection through the same
7
+ ``ICollidableObject`` contract as :class:`~.map_object.MapObject`
8
+ (owner-local shape, ``obj.x + vertex`` applied once by the narrowphase).
9
+
10
+ Bodies are NOT self-moving. ``mode == "kinematic"`` marks a body the game
11
+ moves explicitly each frame (e.g. a crate pushed with ``move_grounded``);
12
+ ``mode == "static"`` marks a body that never moves (e.g. scenery, furniture).
13
+ Neither mode implies physics-engine dynamics — velocity is scripted, Godot
14
+ ``StaticBody2D`` / ``CharacterBody2D`` style.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from typing import Optional, Tuple
21
+
22
+ from ..parser.collision import (
23
+ CapsuleShape,
24
+ CircleShape,
25
+ CollisionPolygon,
26
+ RectangleShape,
27
+ )
28
+
29
+ BodyMode = str # "static" | "kinematic"
30
+
31
+ BODY_MODES = ("static", "kinematic")
32
+
33
+ BodyShape = RectangleShape | CircleShape | CapsuleShape
34
+
35
+
36
+ class Body:
37
+ """A solid body with a single primitive collision shape."""
38
+
39
+ __slots__ = (
40
+ "collision_layer",
41
+ "collision_mask",
42
+ "collision_shape",
43
+ "game_id",
44
+ "mode",
45
+ "on_ground",
46
+ "vx",
47
+ "vy",
48
+ "x",
49
+ "y",
50
+ )
51
+
52
+ def __init__(
53
+ self,
54
+ collision_shape: BodyShape,
55
+ x: float = 0.0,
56
+ y: float = 0.0,
57
+ *,
58
+ vx: float = 0.0,
59
+ vy: float = 0.0,
60
+ mode: BodyMode = "static",
61
+ collision_layer: int = 1,
62
+ collision_mask: int = 0xFFFFFFFF,
63
+ game_id: str = "",
64
+ ):
65
+ """
66
+ Create a body.
67
+
68
+ Args:
69
+ collision_shape: Primitive shape (RectangleShape, CircleShape,
70
+ or CapsuleShape). Polygon shapes are not supported on
71
+ bodies — use :class:`MapObject` for polygon solids.
72
+ x: World X position of the shape origin (top-left / center
73
+ per shape offset semantics).
74
+ y: World Y position.
75
+ vx: X velocity (for kinematic bodies).
76
+ vy: Y velocity (for kinematic bodies).
77
+ mode: ``"static"`` (never moves) or ``"kinematic"`` (moved
78
+ explicitly by the game).
79
+ collision_layer: Layer this body is on (default 1).
80
+ collision_mask: Layers this body collides with (default all).
81
+ game_id: Optional label for debugging.
82
+ """
83
+ if not isinstance(collision_shape, (RectangleShape, CircleShape, CapsuleShape)):
84
+ raise TypeError(
85
+ "Body requires a primitive shape (RectangleShape, CircleShape, "
86
+ f"or CapsuleShape), got {type(collision_shape).__name__}"
87
+ )
88
+ if mode not in BODY_MODES:
89
+ raise ValueError(
90
+ f"mode must be one of {BODY_MODES}, got {mode!r}"
91
+ )
92
+ self.collision_shape = collision_shape
93
+ self.x = x
94
+ self.y = y
95
+ self.vx = vx
96
+ self.vy = vy
97
+ self.mode = mode
98
+ self.collision_layer = collision_layer
99
+ self.collision_mask = collision_mask
100
+ self.game_id = game_id
101
+ self.on_ground = False
102
+
103
+ def __repr__(self) -> str:
104
+ return (
105
+ f"Body(shape={type(self.collision_shape).__name__}, x={self.x}, "
106
+ f"y={self.y}, mode={self.mode!r}, game_id={self.game_id!r})"
107
+ )
108
+
109
+ # ------------------------------------------------------------------
110
+ # Geometry helpers used by the movement resolver
111
+ # ------------------------------------------------------------------
112
+
113
+ def top_y_at(self, world_x: float) -> Optional[float]:
114
+ """Return the top-surface world Y of this body at *world_x*, or None.
115
+
116
+ Only the top surface is sampled — bodies are never one-way, but the
117
+ resolver only needs the topmost surface for ground landing.
118
+ """
119
+ shape = self.collision_shape
120
+ if isinstance(shape, RectangleShape):
121
+ left = self.x + shape.offset[0]
122
+ if left <= world_x <= left + shape.width:
123
+ return self.y + shape.offset[1]
124
+ return None
125
+
126
+ if isinstance(shape, CircleShape):
127
+ cx = self.x + shape.offset[0]
128
+ cy = self.y + shape.offset[1]
129
+ return _circle_top_y(cx, cy, shape.radius, world_x)
130
+
131
+ # CapsuleShape — vertical segment (top cap center, radius, height)
132
+ px = self.x + shape.offset[0]
133
+ py = self.y + shape.offset[1]
134
+ return _circle_top_y(px, py, shape.radius, world_x)
135
+
136
+ def as_polygon(self) -> CollisionPolygon:
137
+ """World-space polygon approximation of this body's shape.
138
+
139
+ Used only by slide-mode normal computation (the tile resolver works
140
+ on polygon edges). Circles/capsules are approximated with enough
141
+ edges that the closest-edge normal is visually exact.
142
+ """
143
+ shape = self.collision_shape
144
+ if isinstance(shape, RectangleShape):
145
+ left = self.x + shape.offset[0]
146
+ top = self.y + shape.offset[1]
147
+ return CollisionPolygon(
148
+ vertices=[
149
+ (left, top),
150
+ (left + shape.width, top),
151
+ (left + shape.width, top + shape.height),
152
+ (left, top + shape.height),
153
+ ]
154
+ )
155
+
156
+ if isinstance(shape, CircleShape):
157
+ cx = self.x + shape.offset[0]
158
+ cy = self.y + shape.offset[1]
159
+ return CollisionPolygon(
160
+ vertices=_ngon(cx, cy, shape.radius, 16)
161
+ )
162
+
163
+ # Capsule — top cap semicircle, implicit side edges, bottom cap
164
+ px = self.x + shape.offset[0]
165
+ py = self.y + shape.offset[1]
166
+ bx = px
167
+ by = py + shape.height
168
+ r = shape.radius
169
+ steps = 4
170
+ verts: list[Tuple[float, float]] = []
171
+ # Top cap — left (pi) to right (0) through the top (3pi/2 = up)
172
+ for k in range(steps + 1):
173
+ a = math.pi + (math.pi * k / steps)
174
+ verts.append((px + r * math.cos(a), py + r * math.sin(a)))
175
+ # Bottom cap — right (0) to left (pi) through the bottom (pi/2 = down)
176
+ for k in range(steps + 1):
177
+ a = math.pi * k / steps
178
+ verts.append((bx + r * math.cos(a), by + r * math.sin(a)))
179
+ return CollisionPolygon(vertices=verts)
180
+
181
+
182
+ def _circle_top_y(cx: float, cy: float, radius: float, world_x: float) -> Optional[float]:
183
+ """Top-surface Y of a circle at *world_x* (upper semicircle), or None."""
184
+ dx = world_x - cx
185
+ if abs(dx) > radius:
186
+ return None
187
+ return cy - math.sqrt(radius * radius - dx * dx)
188
+
189
+
190
+ def _ngon(cx: float, cy: float, radius: float, edges: int) -> list[Tuple[float, float]]:
191
+ """Vertices of a regular polygon approximating a circle."""
192
+ return [
193
+ (
194
+ cx + radius * math.cos(2 * math.pi * i / edges),
195
+ cy + radius * math.sin(2 * math.pi * i / edges),
196
+ )
197
+ for i in range(edges)
198
+ ]
@@ -0,0 +1,10 @@
1
+ """Object-to-object collision package.
2
+
3
+ Shape-level narrowphase lives in :mod:`shapes`, hit results and pair
4
+ queries in :mod:`hit`, and the spatial-grid manager in :mod:`manager`.
5
+ """
6
+
7
+ from .hit import CollisionHit, check_collision, should_collide
8
+ from .manager import ObjectCollisionManager
9
+
10
+ __all__ = ["CollisionHit", "ObjectCollisionManager", "check_collision", "should_collide"]
@@ -0,0 +1,150 @@
1
+ """Collision hit results and pair queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from ..protocols import ICollidableObject
9
+ from ...utils.geometry import CollisionInfo, aabb_overlap, get_shape_aabb
10
+ from .shapes import _check_pair, _combined_aabb, _get_shapes
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class CollisionHit:
15
+ """Result of a collision detection between two objects."""
16
+
17
+ object_a: ICollidableObject
18
+ object_b: ICollidableObject
19
+ normal: tuple[float, float] # Direction to separate (from A to B)
20
+ depth: float # Penetration depth
21
+
22
+ def resolve(self) -> None:
23
+ """Separate both objects by half the depth along the collision normal."""
24
+ sep_x = self.normal[0] * self.depth * 0.5
25
+ sep_y = self.normal[1] * self.depth * 0.5
26
+ self.object_a.x -= sep_x
27
+ self.object_a.y -= sep_y
28
+ self.object_b.x += sep_x
29
+ self.object_b.y += sep_y
30
+
31
+ def slide_velocity(self, vx: float, vy: float) -> tuple[float, float]:
32
+ """Project velocity along the collision surface (slide response).
33
+
34
+ Removes the component of (vx, vy) that is along *self.normal*,
35
+ leaving only the tangential component. Intended for the moving
36
+ object passed as *object_a* — when that object moves into
37
+ *object_b* the approach component is stripped so the object slides
38
+ along the surface instead of penetrating.
39
+
40
+ If the velocity is already parallel to the surface or points away
41
+ from *object_b* the original velocity is returned unchanged.
42
+
43
+ Args:
44
+ vx: X component of velocity (object_a's velocity)
45
+ vy: Y component of velocity
46
+
47
+ Returns:
48
+ (slide_x, slide_y) — velocity projected onto the surface
49
+ """
50
+ dot = vx * self.normal[0] + vy * self.normal[1]
51
+ if dot > 0:
52
+ return (vx - self.normal[0] * dot, vy - self.normal[1] * dot)
53
+ return (vx, vy)
54
+
55
+ def involves(self, obj: ICollidableObject) -> bool:
56
+ """Check if this hit involves the given object."""
57
+ return self.object_a is obj or self.object_b is obj
58
+
59
+ def other(self, obj: ICollidableObject) -> ICollidableObject:
60
+ """Get the other object in this hit pair. Raises ValueError if obj is not part of the hit."""
61
+ if self.object_a is obj:
62
+ return self.object_b
63
+ if self.object_b is obj:
64
+ return self.object_a
65
+ raise ValueError("Object is not part of this collision hit")
66
+
67
+ def should_collide(
68
+ obj_a: ICollidableObject,
69
+ obj_b: ICollidableObject,
70
+ ) -> bool:
71
+ """
72
+ Check if two objects should collide based on layers.
73
+
74
+ Uses mutual agreement: BOTH objects must want to collide.
75
+ This prevents asymmetric filtering issues.
76
+ """
77
+ a_layer = getattr(obj_a, "collision_layer", 1)
78
+ a_mask = getattr(obj_a, "collision_mask", 0xFFFFFFFF)
79
+ b_layer = getattr(obj_b, "collision_layer", 1)
80
+ b_mask = getattr(obj_b, "collision_mask", 0xFFFFFFFF)
81
+
82
+ # CRITICAL: AND for mutual agreement (not OR)
83
+ return (a_mask & b_layer) != 0 and (b_mask & a_layer) != 0
84
+
85
+
86
+ # Backward compat alias
87
+ _should_collide = should_collide
88
+
89
+ def check_collision(
90
+ obj_a: ICollidableObject,
91
+ obj_b: ICollidableObject,
92
+ ) -> Optional[CollisionHit]:
93
+ """
94
+ Check if two objects collide.
95
+
96
+ Pipeline:
97
+ 1. Layer filtering
98
+ 2. Broadphase AABB rejection
99
+ 3. Narrowphase geometry dispatch
100
+ 4. Return CollisionHit or None
101
+
102
+ Supports multi-shape objects (those with a ``collision_shapes``
103
+ attribute). When both objects have a single shape the behaviour
104
+ is identical to previous versions.
105
+ """
106
+ # 1. Layer filter
107
+ if not should_collide(obj_a, obj_b):
108
+ return None
109
+
110
+ # 2. Broadphase — use combined AABB when an object has multiple shapes
111
+ shapes_a = _get_shapes(obj_a)
112
+ shapes_b = _get_shapes(obj_b)
113
+
114
+ if len(shapes_a) == 1:
115
+ aabb_a = get_shape_aabb(obj_a.x, obj_a.y, shapes_a[0])
116
+ else:
117
+ aabb_a = _combined_aabb(obj_a.x, obj_a.y, shapes_a)
118
+
119
+ if len(shapes_b) == 1:
120
+ aabb_b = get_shape_aabb(obj_b.x, obj_b.y, shapes_b[0])
121
+ else:
122
+ aabb_b = _combined_aabb(obj_b.x, obj_b.y, shapes_b)
123
+
124
+ if not aabb_overlap(aabb_a, aabb_b):
125
+ return None
126
+
127
+ # 3. Narrowphase — iterate all shape pairs, keep the deepest
128
+ deepest: Optional[CollisionInfo] = None
129
+
130
+ for shape_a in shapes_a:
131
+ for shape_b in shapes_b:
132
+ pair_aabb_a = get_shape_aabb(obj_a.x, obj_a.y, shape_a)
133
+ pair_aabb_b = get_shape_aabb(obj_b.x, obj_b.y, shape_b)
134
+ if not aabb_overlap(pair_aabb_a, pair_aabb_b):
135
+ continue
136
+
137
+ info = _check_pair(obj_a, obj_b, shape_a, shape_b, pair_aabb_a, pair_aabb_b)
138
+ if info is not None and (deepest is None or info.depth > deepest.depth):
139
+ deepest = info
140
+
141
+ if deepest is None:
142
+ return None
143
+
144
+ return CollisionHit(
145
+ object_a=obj_a,
146
+ object_b=obj_b,
147
+ normal=deepest.normal,
148
+ depth=deepest.depth,
149
+ )
150
+
@@ -0,0 +1,184 @@
1
+ """Spatial-grid object collision manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from math import floor, isfinite
7
+ from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple
8
+
9
+ from ..protocols import ICollidableObject
10
+ from ...utils.geometry import get_shape_aabb
11
+ from .hit import CollisionHit, check_collision
12
+ from .shapes import _combined_aabb, _get_shapes
13
+
14
+
15
+ class ObjectCollisionManager:
16
+ """
17
+ Manages collision detection for multiple objects.
18
+
19
+ Features:
20
+ - Add / remove objects
21
+ - All-vs-all and one-vs-all queries
22
+ - Layer filtering
23
+
24
+ Uses a uniform-grid spatial broadphase for all-vs-all queries
25
+ (rebuilt per query) and a linear scan for single-object queries.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ objects: Optional[Iterable[ICollidableObject]] = None,
31
+ *,
32
+ cell_size: float = 128.0,
33
+ ) -> None:
34
+ if not isfinite(cell_size) or cell_size <= 0:
35
+ raise ValueError("cell_size must be a finite positive number")
36
+
37
+ self.objects: List[ICollidableObject] = []
38
+ self.cell_size = float(cell_size)
39
+ if objects is not None:
40
+ for obj in objects:
41
+ self.add_object(obj)
42
+
43
+ def __len__(self) -> int:
44
+ """Return the number of objects currently managed."""
45
+ return len(self.objects)
46
+
47
+ def __iter__(self) -> Iterator[ICollidableObject]:
48
+ """Iterate over managed objects in insertion order."""
49
+ return iter(self.objects)
50
+
51
+ def __contains__(self, obj: object) -> bool:
52
+ """Return True if the exact object instance is managed."""
53
+ return any(existing is obj for existing in self.objects)
54
+
55
+ def _find_object_index(self, obj: ICollidableObject) -> int:
56
+ for index, existing in enumerate(self.objects):
57
+ if existing is obj:
58
+ return index
59
+ return -1
60
+
61
+ def add_object(self, obj: ICollidableObject) -> None:
62
+ """Add an object to the collision system."""
63
+ if self._find_object_index(obj) != -1:
64
+ warnings.warn(
65
+ f"Object {obj} is already in the collision manager, skipping.",
66
+ UserWarning,
67
+ stacklevel=2,
68
+ )
69
+ return
70
+ self.objects.append(obj)
71
+
72
+ def remove_object(self, obj: ICollidableObject) -> None:
73
+ """Remove an object from the collision system."""
74
+ index = self._find_object_index(obj)
75
+ if index == -1:
76
+ warnings.warn(
77
+ f"Object {obj} is not in the collision manager, skipping.",
78
+ UserWarning,
79
+ stacklevel=2,
80
+ )
81
+ return
82
+ del self.objects[index]
83
+
84
+ def clear(self) -> None:
85
+ """Remove all objects from the collision system."""
86
+ self.objects.clear()
87
+
88
+ def _cells_for_aabb(
89
+ self,
90
+ aabb: tuple[float, float, float, float],
91
+ ) -> Iterator[Tuple[int, int]]:
92
+ left, top, right, bottom = aabb
93
+ min_cell_x = floor(left / self.cell_size)
94
+ max_cell_x = floor(right / self.cell_size)
95
+ min_cell_y = floor(top / self.cell_size)
96
+ max_cell_y = floor(bottom / self.cell_size)
97
+
98
+ for cell_y in range(min_cell_y, max_cell_y + 1):
99
+ for cell_x in range(min_cell_x, max_cell_x + 1):
100
+ yield (cell_x, cell_y)
101
+
102
+ def _object_aabb(
103
+ self,
104
+ obj: ICollidableObject,
105
+ ) -> tuple[float, float, float, float]:
106
+ shapes = _get_shapes(obj)
107
+ if len(shapes) == 1:
108
+ return get_shape_aabb(obj.x, obj.y, shapes[0])
109
+ return _combined_aabb(obj.x, obj.y, shapes)
110
+
111
+ def _build_spatial_index(
112
+ self,
113
+ ) -> tuple[Tuple[ICollidableObject, ...], Dict[Tuple[int, int], List[int]]]:
114
+ objects = tuple(self.objects)
115
+ grid: Dict[Tuple[int, int], List[int]] = {}
116
+
117
+ for index, obj in enumerate(objects):
118
+ for cell in self._cells_for_aabb(self._object_aabb(obj)):
119
+ grid.setdefault(cell, []).append(index)
120
+
121
+ return objects, grid
122
+
123
+ def _candidate_indices(
124
+ self,
125
+ obj: ICollidableObject,
126
+ grid: Dict[Tuple[int, int], List[int]],
127
+ ) -> Set[int]:
128
+ candidates: Set[int] = set()
129
+ for cell in self._cells_for_aabb(self._object_aabb(obj)):
130
+ candidates.update(grid.get(cell, ()))
131
+ return candidates
132
+
133
+ def check_all_collisions(self) -> List[CollisionHit]:
134
+ """
135
+ Check every potentially colliding pair.
136
+
137
+ Returns a list of CollisionHit for all colliding pairs.
138
+ Each pair appears at most once (i, j) with j > i.
139
+ """
140
+ objects, grid = self._build_spatial_index()
141
+ hits: List[CollisionHit] = []
142
+
143
+ for i, obj in enumerate(objects):
144
+ candidate_indices = self._candidate_indices(obj, grid)
145
+ for j in sorted(candidate_indices):
146
+ if j <= i:
147
+ continue
148
+ hit = check_collision(objects[i], objects[j])
149
+ if hit is not None:
150
+ hits.append(hit)
151
+ return hits
152
+
153
+ def check_object(self, obj: ICollidableObject) -> List[CollisionHit]:
154
+ """
155
+ Check one object against all others using a linear scan.
156
+
157
+ The queried object does not need to be managed. If it is managed,
158
+ comparison with itself is skipped by identity.
159
+ """
160
+ hits: List[CollisionHit] = []
161
+ for other in self.objects:
162
+ if other is obj:
163
+ continue
164
+ hit = check_collision(obj, other)
165
+ if hit is not None:
166
+ hits.append(hit)
167
+ return hits
168
+
169
+ def check_object_first(self, obj: ICollidableObject) -> Optional[CollisionHit]:
170
+ """
171
+ Check one object against all others and return the first collision hit.
172
+
173
+ Iterates managed objects in insertion order. The queried object
174
+ does not need to be managed; if it is managed, comparison with
175
+ itself is skipped by identity.
176
+ """
177
+ for other in self.objects:
178
+ if other is obj:
179
+ continue
180
+ hit = check_collision(obj, other)
181
+ if hit is not None:
182
+ return hit
183
+ return None
184
+