tilemap-parser 5.0.0__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 (73) hide show
  1. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/PKG-INFO +35 -1
  2. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/README.md +34 -0
  3. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/pyproject.toml +1 -1
  4. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/__init__.py +1 -0
  5. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/collision.py +39 -1
  6. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/map_parse.py +16 -0
  7. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/tmx_converter.py +1 -0
  8. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/__init__.py +13 -9
  9. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/area_node.py +9 -2
  10. tilemap_parser-5.0.1/src/tilemap_parser/runtime/body.py +198 -0
  11. tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/__init__.py +10 -0
  12. tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/hit.py +150 -0
  13. tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/manager.py +184 -0
  14. tilemap_parser-5.0.1/src/tilemap_parser/runtime/collision/shapes.py +170 -0
  15. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/map_loader.py +62 -14
  16. tilemap_parser-5.0.1/src/tilemap_parser/runtime/map_object.py +255 -0
  17. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/__init__.py +11 -0
  18. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/grounded.py +181 -0
  19. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/platformer.py +533 -0
  20. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/queries.py +264 -0
  21. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/rpg.py +87 -0
  22. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/runner.py +488 -0
  23. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/slide.py +209 -0
  24. tilemap_parser-5.0.1/src/tilemap_parser/runtime/movement/types.py +30 -0
  25. tilemap_parser-5.0.1/src/tilemap_parser/runtime/navigation/__init__.py +3 -0
  26. tilemap_parser-5.0.1/src/tilemap_parser/runtime/navigation/nav_grid.py +184 -0
  27. tilemap_parser-5.0.1/src/tilemap_parser/runtime/navigation/path_follower.py +76 -0
  28. tilemap_parser-5.0.1/src/tilemap_parser/runtime/navigation/pathfinder.py +75 -0
  29. tilemap_parser-5.0.1/src/tilemap_parser/runtime/object_collision.py +38 -0
  30. tilemap_parser-5.0.1/src/tilemap_parser/runtime/polygon_query.py +490 -0
  31. tilemap_parser-5.0.1/src/tilemap_parser/runtime/protocols.py +71 -0
  32. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/renderer.py +43 -28
  33. tilemap_parser-5.0.1/src/tilemap_parser/runtime/tile_collision.py +62 -0
  34. tilemap_parser-5.0.1/src/tilemap_parser/runtime/world.py +146 -0
  35. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/PKG-INFO +35 -1
  36. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/SOURCES.txt +26 -0
  37. tilemap_parser-5.0.1/tests/test_body.py +802 -0
  38. tilemap_parser-5.0.1/tests/test_gid_collision.py +260 -0
  39. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_map_loader.py +99 -0
  40. tilemap_parser-5.0.1/tests/test_map_object.py +1379 -0
  41. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_move_grounded.py +1 -1
  42. tilemap_parser-5.0.1/tests/test_navigation.py +317 -0
  43. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_object_collision.py +3 -3
  44. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_object_surfaces.py +93 -6
  45. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_render_scale.py +2 -3
  46. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_tile_collision.py +2 -3
  47. tilemap_parser-5.0.1/tests/test_tile_layer_renderer_y_sort.py +348 -0
  48. tilemap_parser-5.0.0/src/tilemap_parser/runtime/object_collision.py +0 -475
  49. tilemap_parser-5.0.0/src/tilemap_parser/runtime/tile_collision.py +0 -2156
  50. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/LICENSE +0 -0
  51. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/setup.cfg +0 -0
  52. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/__init__.py +0 -0
  53. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/animation.py +0 -0
  54. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/collision_loader.py +0 -0
  55. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/node_parse.py +0 -0
  56. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/parser/particle.py +0 -0
  57. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/animation_player.py +0 -0
  58. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/camera.py +0 -0
  59. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  60. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/runtime/particles.py +0 -0
  61. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/utils/__init__.py +0 -0
  62. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser/utils/geometry.py +0 -0
  63. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  64. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/requires.txt +0 -0
  65. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  66. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_camera.py +0 -0
  67. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_collision.py +0 -0
  68. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_geometry.py +0 -0
  69. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_integration_animation.py +0 -0
  70. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_integration_collision.py +0 -0
  71. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_integration_map_loader.py +0 -0
  72. {tilemap_parser-5.0.0 → tilemap_parser-5.0.1}/tests/test_particle_emitter.py +0 -0
  73. {tilemap_parser-5.0.0 → 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: 5.0.0
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 = "5.0.0"
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"
@@ -20,6 +20,7 @@ __all__ = [
20
20
  "CollisionPolygon",
21
21
  "CollisionResult",
22
22
  "CollisionRunner",
23
+ "ICollidable",
23
24
  "ICollidableObject",
24
25
  "ICollidableSprite",
25
26
  "LayerRenderStats",
@@ -77,6 +77,44 @@ class TilesetCollision:
77
77
  return []
78
78
  return [shape.transform(tile_x, tile_y, scale) for shape in tile_data.shapes]
79
79
 
80
+ @classmethod
81
+ def merge(
82
+ cls,
83
+ collisions: List["TilesetCollision"],
84
+ firstgids: List[int],
85
+ ) -> "TilesetCollision":
86
+ """Merge multiple tileset collisions into one, offsetting keys by firstgid.
87
+
88
+ Each collision's tiles are re-keyed as ``firstgid + local_id`` so that
89
+ the resulting dict uses global tile IDs for lookup.
90
+
91
+ Args:
92
+ collisions: List of TilesetCollision objects to merge.
93
+ firstgids: List of firstgid offsets, one per collision, in the
94
+ same order as *collisions*. The offset is added to
95
+ each tile's local ID to produce the global key.
96
+
97
+ Usage::
98
+
99
+ merged = TilesetCollision.merge([ts0, ts1], [0, 200])
100
+ """
101
+ if not collisions:
102
+ return cls(tileset_name="merged", tile_size=(0, 0))
103
+ tile_size = collisions[0].tile_size
104
+ merged_tiles: Dict[int, TileCollisionData] = {}
105
+ for coll, offset in zip(collisions, firstgids, strict=True):
106
+ for local_id, data in coll.tiles.items():
107
+ gid = offset + local_id
108
+ merged_tiles[gid] = TileCollisionData(
109
+ tile_id=gid,
110
+ shapes=data.shapes[:],
111
+ )
112
+ return cls(
113
+ tileset_name="merged",
114
+ tile_size=tile_size,
115
+ tiles=merged_tiles,
116
+ )
117
+
80
118
 
81
119
  @dataclass
82
120
  class RectangleShape:
@@ -211,7 +249,7 @@ def parse_tileset_collision(data: JsonDict) -> TilesetCollision:
211
249
  tiles[tile_id] = TileCollisionData(tile_id=tile_id, shapes=shapes)
212
250
 
213
251
  return TilesetCollision(
214
- tileset_name=tileset_name, tile_size=tile_size, tiles=tiles
252
+ tileset_name=tileset_name, tile_size=tile_size, tiles=tiles,
215
253
  )
216
254
  except (KeyError, ValueError, TypeError) as e:
217
255
  raise CollisionParseError(f"Invalid tileset collision data: {e}") from e
@@ -105,6 +105,7 @@ class ParsedTile:
105
105
  pos: Point
106
106
  ttype: TilesetRef
107
107
  variant: int
108
+ gid: Optional[int] = None
108
109
  properties: Optional[JsonDict] = None
109
110
  flip_h: bool = False
110
111
  flip_v: bool = False
@@ -142,6 +143,8 @@ class TilesetAnimation:
142
143
  class ParsedTileset:
143
144
  path: str
144
145
  type: str
146
+ tile_count: int = 0
147
+ firstgid: int = 0
145
148
  properties: JsonDict = field(default_factory=dict)
146
149
  tile_properties: Dict[str, JsonDict] = field(default_factory=dict)
147
150
  animation: Optional[TilesetAnimation] = None
@@ -156,10 +159,13 @@ class ParsedLayer:
156
159
  locked: bool
157
160
  opacity: float
158
161
  z_index: int
162
+ y_sort: bool = False
163
+ y_sort_origin: int = 0
159
164
  properties: JsonDict = field(default_factory=dict)
160
165
  tiles: Dict[Point, ParsedTile] = field(default_factory=dict)
161
166
  objects: Dict[int, ParsedObject] = field(default_factory=dict)
162
167
  next_object_id: Optional[int] = None
168
+ ttypes: set[int] = field(default_factory=set)
163
169
 
164
170
 
165
171
  @dataclass
@@ -217,10 +223,13 @@ def _parse_tile(tile_data: JsonDict, ctx: str) -> ParsedTile:
217
223
  else:
218
224
  ttype = _coerce_int(ttype_raw, f"{ctx}.ttype")
219
225
  props = _optional_dict(tile_data.get("properties"), f"{ctx}.properties")
226
+ gid_raw = tile_data.get("gid")
227
+ gid = _coerce_int(gid_raw, f"{ctx}.gid") if gid_raw is not None else None
220
228
  return ParsedTile(
221
229
  pos=pos,
222
230
  ttype=ttype,
223
231
  variant=variant,
232
+ gid=gid,
224
233
  properties=props,
225
234
  flip_h=tile_data.get("flip_h", False),
226
235
  flip_v=tile_data.get("flip_v", False),
@@ -279,6 +288,8 @@ def _parse_layer(layer_obj: JsonDict, layer_id: int, ctx: str) -> ParsedLayer:
279
288
  locked=_coerce_bool(layer_obj.get("locked", False), f"{ctx}.locked"),
280
289
  opacity=_coerce_float(layer_obj.get("opacity", 1.0), f"{ctx}.opacity"),
281
290
  z_index=_coerce_int(layer_obj.get("z_index", layer_id), f"{ctx}.z_index"),
291
+ y_sort=_coerce_bool(layer_obj.get("y_sort", False), f"{ctx}.y_sort"),
292
+ y_sort_origin=layer_obj.get("y_sort_origin", 0),
282
293
  properties=_optional_dict(layer_obj.get("properties"), f"{ctx}.properties")
283
294
  or {},
284
295
  )
@@ -291,6 +302,8 @@ def _parse_layer(layer_obj: JsonDict, layer_id: int, ctx: str) -> ParsedLayer:
291
302
  _require_dict(layer_obj.get("objects", {}), f"{ctx}.objects"),
292
303
  f"{ctx}.objects",
293
304
  )
305
+ for obj in layer.objects.values():
306
+ layer.ttypes.add(obj.ttype)
294
307
  if "next_object_id" in layer_obj and layer_obj["next_object_id"] is not None:
295
308
  layer.next_object_id = _coerce_int(
296
309
  layer_obj["next_object_id"], f"{ctx}.next_object_id"
@@ -378,6 +391,8 @@ def _parse_tilesets_list(tilesets_raw: List[Any], ctx: str) -> List[ParsedTilese
378
391
  ParsedTileset(
379
392
  path=path, type=ts_type, properties=props, tile_properties=tile_props,
380
393
  animation=animation,
394
+ tile_count=_coerce_int(ts_obj.get("tile_count", 0), f"{ctx}[{i}].tile_count"),
395
+ firstgid=_coerce_int(ts_obj.get("firstgid", 0), f"{ctx}[{i}].firstgid"),
381
396
  )
382
397
  )
383
398
  return out
@@ -401,6 +416,7 @@ def _expand_ongrid_to_layer(data_obj: JsonDict, ctx: str) -> List[ParsedLayer]:
401
416
  locked=False,
402
417
  opacity=1.0,
403
418
  z_index=0,
419
+ y_sort=False, y_sort_origin=0,
404
420
  properties={},
405
421
  )
406
422
  for loc_str, tile_data in raw_ongrid.items():
@@ -268,6 +268,7 @@ def _parse_tile_layer(
268
268
  locked=False,
269
269
  opacity=opacity,
270
270
  z_index=0,
271
+ y_sort=False, y_sort_origin=0,
271
272
  )
272
273
 
273
274
  layer.properties = _parse_properties(layer_elem)
@@ -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,
@@ -10,22 +12,19 @@ from .collision_cache import (
10
12
  load_object_collision,
11
13
  load_tileset_collision,
12
14
  )
13
- from .tile_collision import (
14
- CollisionResult,
15
- CollisionRunner,
16
- ICollidableSprite,
17
- MovementMode,
18
- rect_vs_tilemap,
19
- )
15
+ from .map_object import MapObject, load_map_objects
16
+ from .movement import CollisionResult, CollisionRunner, MovementMode
17
+ from .polygon_query import rect_vs_tilemap
18
+ from .protocols import ICollidable, ICollidableObject, ICollidableSprite
20
19
  from .map_loader import TilemapData, load_map
21
- from .object_collision import (
20
+ from .collision import (
22
21
  CollisionHit,
23
- ICollidableObject,
24
22
  ObjectCollisionManager,
25
23
  check_collision,
26
24
  )
27
25
  from .renderer import LayerRenderStats, TileLayerRenderer
28
26
  from .area_node import AreaNode
27
+ from . import navigation
29
28
  from .particles import (
30
29
  Particle,
31
30
  ParticleEmitter,
@@ -39,14 +38,17 @@ from .particles import (
39
38
  __all__ = [
40
39
  "AnimationPlayer",
41
40
  "AreaNode",
41
+ "Body",
42
42
  "Camera",
43
43
  "CollisionCache",
44
44
  "CollisionHit",
45
45
  "CollisionResult",
46
46
  "CollisionRunner",
47
+ "ICollidable",
47
48
  "ICollidableObject",
48
49
  "ICollidableSprite",
49
50
  "LayerRenderStats",
51
+ "MapObject",
50
52
  "MovementMode",
51
53
  "ObjectCollisionManager",
52
54
  "SpriteAnimationSet",
@@ -59,6 +61,7 @@ __all__ = [
59
61
  "get_cached_tileset_collision",
60
62
  "load_character_collision",
61
63
  "load_map",
64
+ "load_map_objects",
62
65
  "load_object_collision",
63
66
  "load_tileset_collision",
64
67
  "Particle",
@@ -66,6 +69,7 @@ __all__ = [
66
69
  "ParticleEmitterNode",
67
70
  "ParticleRenderer",
68
71
  "ParticleSystem",
72
+ "PhysicsWorld",
69
73
  "rect_vs_tilemap",
70
74
  "SpriteBatchRenderer",
71
75
  "clear_texture_caches",
@@ -8,11 +8,18 @@ from ..parser.node_parse import ParsedNode
8
8
 
9
9
 
10
10
  class AreaNode:
11
- def __init__(self, parsed: ParsedNode) -> None:
11
+ def __init__(self, parsed: ParsedNode, render_scale: float = 1.0) -> None:
12
12
  self.node_id = parsed.node_id
13
13
  self.name = parsed.name
14
14
  self.node_type = parsed.node_type
15
- self._rect = Rect(parsed.area.x, parsed.area.y, parsed.area.w, parsed.area.h)
15
+ self.render_scale = render_scale
16
+ rs = render_scale
17
+ self._rect = Rect(
18
+ int(parsed.area.x * rs),
19
+ int(parsed.area.y * rs),
20
+ int(parsed.area.w * rs),
21
+ int(parsed.area.h * rs),
22
+ )
16
23
  self.layer_name = parsed.layer_name
17
24
  self.properties = dict(parsed.properties)
18
25
  self.group = parsed.group
@@ -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"]