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.
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/PKG-INFO +35 -1
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/README.md +34 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/pyproject.toml +1 -1
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/__init__.py +8 -10
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/body.py +198 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/__init__.py +10 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/hit.py +150 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/manager.py +184 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/shapes.py +170 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/map_loader.py +1 -1
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/__init__.py +11 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/grounded.py +181 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/platformer.py +533 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/queries.py +264 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/rpg.py +87 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/runner.py +488 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/slide.py +209 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/types.py +30 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/navigation/path_follower.py +1 -1
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/object_collision.py +38 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/polygon_query.py +490 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/protocols.py +71 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/tile_collision.py +62 -0
- tilemap_parser-5.0.1/src/tilemap_parser/runtime/world.py +146 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/PKG-INFO +35 -1
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/SOURCES.txt +16 -0
- tilemap_parser-5.0.1/tests/test_body.py +802 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_map_object.py +6 -6
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_move_grounded.py +1 -1
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_navigation.py +1 -1
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_object_collision.py +3 -3
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_render_scale.py +2 -3
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_tile_collision.py +2 -3
- tilemap_parser-4.2.8/src/tilemap_parser/runtime/object_collision.py +0 -512
- tilemap_parser-4.2.8/src/tilemap_parser/runtime/protocols.py +0 -37
- tilemap_parser-4.2.8/src/tilemap_parser/runtime/tile_collision.py +0 -2157
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/LICENSE +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/setup.cfg +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/__init__.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/__init__.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/animation.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/collision.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/collision_loader.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/map_parse.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/node_parse.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/particle.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/tmx_converter.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/animation_player.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/area_node.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/camera.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/collision_cache.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/map_object.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/navigation/__init__.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/navigation/nav_grid.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/navigation/pathfinder.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/particles.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/renderer.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/utils/__init__.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser/utils/geometry.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/requires.txt +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/top_level.txt +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_camera.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_collision.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_geometry.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_gid_collision.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_integration_animation.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_integration_collision.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_integration_map_loader.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_map_loader.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_object_surfaces.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_particle_emitter.py +0 -0
- {tilemap_parser-4.2.8 → tilemap_parser-5.0.1}/tests/test_tile_layer_renderer_y_sort.py +0 -0
- {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:
|
|
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 = "
|
|
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 .
|
|
15
|
-
|
|
16
|
-
|
|
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 .
|
|
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
|
+
|