tilemap-parser 1.0.0__tar.gz → 1.1.0__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-1.0.0/src/tilemap_parser.egg-info → tilemap_parser-1.1.0}/PKG-INFO +1 -1
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/pyproject.toml +1 -1
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser/__init__.py +44 -0
- tilemap_parser-1.1.0/src/tilemap_parser/collision.py +344 -0
- tilemap_parser-1.1.0/src/tilemap_parser/collision_runner.py +580 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser/map_parse.py +84 -22
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser/renderer.py +17 -5
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0/src/tilemap_parser.egg-info}/PKG-INFO +1 -1
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser.egg-info/SOURCES.txt +2 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/LICENSE +0 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/README.md +0 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/setup.cfg +0 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser/animation.py +0 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser/map_loader.py +0 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser.egg-info/requires.txt +0 -0
- {tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tilemap-parser"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "Standalone parser/loader for tilemap-editor JSON maps and sprite animation JSON."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -10,6 +10,30 @@ from .animation import (
|
|
|
10
10
|
parse_animation_file,
|
|
11
11
|
parse_animation_json,
|
|
12
12
|
)
|
|
13
|
+
from .collision import (
|
|
14
|
+
CapsuleShape,
|
|
15
|
+
CharacterCollision,
|
|
16
|
+
CircleShape,
|
|
17
|
+
CollisionCache,
|
|
18
|
+
CollisionParseError,
|
|
19
|
+
CollisionPolygon,
|
|
20
|
+
RectangleShape,
|
|
21
|
+
TileCollisionData,
|
|
22
|
+
TilesetCollision,
|
|
23
|
+
clear_collision_cache,
|
|
24
|
+
get_cached_character_collision,
|
|
25
|
+
get_cached_tileset_collision,
|
|
26
|
+
load_character_collision,
|
|
27
|
+
load_tileset_collision,
|
|
28
|
+
parse_character_collision,
|
|
29
|
+
parse_tileset_collision,
|
|
30
|
+
)
|
|
31
|
+
from .collision_runner import (
|
|
32
|
+
CollisionResult,
|
|
33
|
+
CollisionRunner,
|
|
34
|
+
ICollidableSprite,
|
|
35
|
+
MovementMode,
|
|
36
|
+
)
|
|
13
37
|
from .map_loader import TilemapData, load_map
|
|
14
38
|
from .map_parse import (
|
|
15
39
|
MapParseError,
|
|
@@ -36,8 +60,18 @@ __all__ = [
|
|
|
36
60
|
"AnimationMarker",
|
|
37
61
|
"AnimationParseError",
|
|
38
62
|
"AnimationPlayer",
|
|
63
|
+
"CapsuleShape",
|
|
64
|
+
"CharacterCollision",
|
|
65
|
+
"CircleShape",
|
|
66
|
+
"CollisionCache",
|
|
67
|
+
"CollisionParseError",
|
|
68
|
+
"CollisionPolygon",
|
|
69
|
+
"CollisionResult",
|
|
70
|
+
"CollisionRunner",
|
|
71
|
+
"ICollidableSprite",
|
|
39
72
|
"LayerRenderStats",
|
|
40
73
|
"MapParseError",
|
|
74
|
+
"MovementMode",
|
|
41
75
|
"ParsedAutotileGroup",
|
|
42
76
|
"ParsedAutotileRule",
|
|
43
77
|
"ParsedLayer",
|
|
@@ -48,14 +82,24 @@ __all__ = [
|
|
|
48
82
|
"ParsedProjectState",
|
|
49
83
|
"ParsedTile",
|
|
50
84
|
"ParsedTileset",
|
|
85
|
+
"RectangleShape",
|
|
51
86
|
"SpriteAnimationSet",
|
|
87
|
+
"TileCollisionData",
|
|
52
88
|
"TileLayerRenderer",
|
|
89
|
+
"TilesetCollision",
|
|
53
90
|
"TilemapData",
|
|
91
|
+
"clear_collision_cache",
|
|
92
|
+
"get_cached_character_collision",
|
|
93
|
+
"get_cached_tileset_collision",
|
|
94
|
+
"load_character_collision",
|
|
54
95
|
"load_map",
|
|
96
|
+
"load_tileset_collision",
|
|
55
97
|
"parse_animation_dict",
|
|
56
98
|
"parse_animation_file",
|
|
57
99
|
"parse_animation_json",
|
|
100
|
+
"parse_character_collision",
|
|
58
101
|
"parse_map_dict",
|
|
59
102
|
"parse_map_file",
|
|
60
103
|
"parse_map_json",
|
|
104
|
+
"parse_tileset_collision",
|
|
61
105
|
]
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collision data parser and runtime utilities for tilemap collision systems.
|
|
3
|
+
|
|
4
|
+
This module provides parsers for both tileset collision (polygon-based) and
|
|
5
|
+
character collision (geometric shapes) data generated by the tilemap editor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
14
|
+
|
|
15
|
+
JsonDict = Dict[str, Any]
|
|
16
|
+
Point = Tuple[float, float]
|
|
17
|
+
IntPoint = Tuple[int, int]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CollisionParseError(ValueError):
|
|
21
|
+
"""Raised when collision data cannot be parsed"""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CollisionPolygon:
|
|
27
|
+
"""Polygon collision shape for a tile"""
|
|
28
|
+
|
|
29
|
+
vertices: List[Point]
|
|
30
|
+
one_way: bool = False
|
|
31
|
+
|
|
32
|
+
def transform(self, tile_x: float, tile_y: float) -> "CollisionPolygon":
|
|
33
|
+
"""Transform polygon to world space coordinates"""
|
|
34
|
+
world_vertices = [(tile_x + vx, tile_y + vy) for vx, vy in self.vertices]
|
|
35
|
+
return CollisionPolygon(vertices=world_vertices, one_way=self.one_way)
|
|
36
|
+
|
|
37
|
+
def is_valid(self) -> bool:
|
|
38
|
+
"""Check if polygon has at least 3 vertices"""
|
|
39
|
+
return len(self.vertices) >= 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class TileCollisionData:
|
|
44
|
+
"""Collision data for a single tile"""
|
|
45
|
+
|
|
46
|
+
tile_id: int
|
|
47
|
+
shapes: List[CollisionPolygon] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
def has_collision(self) -> bool:
|
|
50
|
+
"""Check if tile has any valid collision shapes"""
|
|
51
|
+
return any(shape.is_valid() for shape in self.shapes)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TilesetCollision:
|
|
56
|
+
"""Complete collision data for a tileset"""
|
|
57
|
+
|
|
58
|
+
tileset_name: str
|
|
59
|
+
tile_size: IntPoint
|
|
60
|
+
tiles: Dict[int, TileCollisionData] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
def get_tile_collision(self, tile_id: int) -> Optional[TileCollisionData]:
|
|
63
|
+
"""Get collision data for a specific tile"""
|
|
64
|
+
return self.tiles.get(tile_id)
|
|
65
|
+
|
|
66
|
+
def has_collision(self, tile_id: int) -> bool:
|
|
67
|
+
"""Check if tile has collision data"""
|
|
68
|
+
tile_data = self.get_tile_collision(tile_id)
|
|
69
|
+
return tile_data is not None and tile_data.has_collision()
|
|
70
|
+
|
|
71
|
+
def get_world_shapes(
|
|
72
|
+
self, tile_id: int, tile_x: float, tile_y: float
|
|
73
|
+
) -> List[CollisionPolygon]:
|
|
74
|
+
"""Get collision shapes transformed to world space"""
|
|
75
|
+
tile_data = self.get_tile_collision(tile_id)
|
|
76
|
+
if not tile_data:
|
|
77
|
+
return []
|
|
78
|
+
return [shape.transform(tile_x, tile_y) for shape in tile_data.shapes]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class RectangleShape:
|
|
83
|
+
"""Rectangle collision shape"""
|
|
84
|
+
|
|
85
|
+
width: float
|
|
86
|
+
height: float
|
|
87
|
+
offset: Point = (0.0, 0.0)
|
|
88
|
+
|
|
89
|
+
def get_bounds(self, x: float, y: float) -> Tuple[float, float, float, float]:
|
|
90
|
+
"""Get AABB bounds in world space (left, top, right, bottom)"""
|
|
91
|
+
left = x + self.offset[0]
|
|
92
|
+
top = y + self.offset[1]
|
|
93
|
+
return (left, top, left + self.width, top + self.height)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class CircleShape:
|
|
98
|
+
"""Circle collision shape"""
|
|
99
|
+
|
|
100
|
+
radius: float
|
|
101
|
+
offset: Point = (0.0, 0.0)
|
|
102
|
+
|
|
103
|
+
def get_center(self, x: float, y: float) -> Point:
|
|
104
|
+
"""Get center position in world space"""
|
|
105
|
+
return (x + self.offset[0], y + self.offset[1])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class CapsuleShape:
|
|
110
|
+
"""Capsule collision shape (vertical orientation)"""
|
|
111
|
+
|
|
112
|
+
radius: float
|
|
113
|
+
height: float
|
|
114
|
+
offset: Point = (0.0, 0.0)
|
|
115
|
+
|
|
116
|
+
def get_top_center(self, x: float, y: float) -> Point:
|
|
117
|
+
"""Get top circle center in world space"""
|
|
118
|
+
return (x + self.offset[0], y + self.offset[1])
|
|
119
|
+
|
|
120
|
+
def get_bottom_center(self, x: float, y: float) -> Point:
|
|
121
|
+
"""Get bottom circle center in world space"""
|
|
122
|
+
return (x + self.offset[0], y + self.offset[1] + self.height)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
CharacterShapeType = Union[RectangleShape, CircleShape, CapsuleShape]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class CharacterCollision:
|
|
130
|
+
"""Complete collision data for a character sprite"""
|
|
131
|
+
|
|
132
|
+
name: str
|
|
133
|
+
shape: CharacterShapeType
|
|
134
|
+
properties: Dict[str, Any] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_tileset_collision(data: JsonDict) -> TilesetCollision:
|
|
138
|
+
"""
|
|
139
|
+
Parse tileset collision data from dictionary.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
data: Dictionary loaded from .collision.json file
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
TilesetCollision object
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
CollisionParseError: If data format is invalid
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
tileset_name = data["tileset_name"]
|
|
152
|
+
tile_size_raw = data["tile_size"]
|
|
153
|
+
tile_size = (int(tile_size_raw[0]), int(tile_size_raw[1]))
|
|
154
|
+
|
|
155
|
+
tiles: Dict[int, TileCollisionData] = {}
|
|
156
|
+
tiles_data = data.get("tiles", {})
|
|
157
|
+
|
|
158
|
+
for tile_id_str, tile_data in tiles_data.items():
|
|
159
|
+
tile_id = int(tile_id_str)
|
|
160
|
+
shapes: List[CollisionPolygon] = []
|
|
161
|
+
|
|
162
|
+
for shape_data in tile_data.get("shapes", []):
|
|
163
|
+
vertices = [tuple(v) for v in shape_data["vertices"]]
|
|
164
|
+
one_way = shape_data.get("one_way", False)
|
|
165
|
+
shapes.append(CollisionPolygon(vertices=vertices, one_way=one_way))
|
|
166
|
+
|
|
167
|
+
tiles[tile_id] = TileCollisionData(tile_id=tile_id, shapes=shapes)
|
|
168
|
+
|
|
169
|
+
return TilesetCollision(
|
|
170
|
+
tileset_name=tileset_name, tile_size=tile_size, tiles=tiles
|
|
171
|
+
)
|
|
172
|
+
except (KeyError, ValueError, TypeError) as e:
|
|
173
|
+
raise CollisionParseError(f"Invalid tileset collision data: {e}") from e
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_character_collision(data: JsonDict) -> CharacterCollision:
|
|
177
|
+
"""
|
|
178
|
+
Parse character collision data from dictionary.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
data: Dictionary loaded from .collision.json file
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
CharacterCollision object
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
CollisionParseError: If data format is invalid
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
name = data["name"]
|
|
191
|
+
shape_data = data["shape"]
|
|
192
|
+
shape_type = shape_data["type"]
|
|
193
|
+
offset = tuple(shape_data.get("offset", (0.0, 0.0)))
|
|
194
|
+
|
|
195
|
+
if shape_type == "rectangle":
|
|
196
|
+
shape = RectangleShape(
|
|
197
|
+
width=float(shape_data["width"]),
|
|
198
|
+
height=float(shape_data["height"]),
|
|
199
|
+
offset=offset,
|
|
200
|
+
)
|
|
201
|
+
elif shape_type == "circle":
|
|
202
|
+
shape = CircleShape(radius=float(shape_data["radius"]), offset=offset)
|
|
203
|
+
elif shape_type == "capsule":
|
|
204
|
+
shape = CapsuleShape(
|
|
205
|
+
radius=float(shape_data["radius"]),
|
|
206
|
+
height=float(shape_data["height"]),
|
|
207
|
+
offset=offset,
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
raise CollisionParseError(f"Unknown shape type: {shape_type}")
|
|
211
|
+
|
|
212
|
+
properties = data.get("properties", {})
|
|
213
|
+
|
|
214
|
+
return CharacterCollision(name=name, shape=shape, properties=properties)
|
|
215
|
+
except (KeyError, ValueError, TypeError) as e:
|
|
216
|
+
raise CollisionParseError(f"Invalid character collision data: {e}") from e
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def load_tileset_collision(
|
|
220
|
+
tileset_path: Union[str, Path],
|
|
221
|
+
) -> Optional[TilesetCollision]:
|
|
222
|
+
"""
|
|
223
|
+
Load tileset collision data from file.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
tileset_path: Path to tileset image file
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
TilesetCollision object or None if collision file doesn't exist
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
CollisionParseError: If collision file exists but is invalid
|
|
233
|
+
"""
|
|
234
|
+
tileset_path = Path(tileset_path)
|
|
235
|
+
collision_path = tileset_path.with_suffix(".collision.json")
|
|
236
|
+
|
|
237
|
+
if not collision_path.exists():
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
with open(collision_path, "r", encoding="utf-8") as f:
|
|
242
|
+
data = json.load(f)
|
|
243
|
+
return parse_tileset_collision(data)
|
|
244
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
245
|
+
raise CollisionParseError(f"Cannot load {collision_path}: {e}") from e
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def load_character_collision(
|
|
249
|
+
sprite_path: Union[str, Path],
|
|
250
|
+
) -> Optional[CharacterCollision]:
|
|
251
|
+
"""
|
|
252
|
+
Load character collision data from file.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
sprite_path: Path to character sprite image file
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
CharacterCollision object or None if collision file doesn't exist
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
CollisionParseError: If collision file exists but is invalid
|
|
262
|
+
"""
|
|
263
|
+
sprite_path = Path(sprite_path)
|
|
264
|
+
collision_path = sprite_path.with_suffix(".collision.json")
|
|
265
|
+
|
|
266
|
+
if not collision_path.exists():
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
with open(collision_path, "r", encoding="utf-8") as f:
|
|
271
|
+
data = json.load(f)
|
|
272
|
+
return parse_character_collision(data)
|
|
273
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
274
|
+
raise CollisionParseError(f"Cannot load {collision_path}: {e}") from e
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class CollisionCache:
|
|
278
|
+
"""
|
|
279
|
+
Optimized collision data cache with fast lookups.
|
|
280
|
+
|
|
281
|
+
Caches parsed collision data to avoid repeated file I/O and parsing.
|
|
282
|
+
Useful for runtime performance in game engines.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self):
|
|
286
|
+
self._tileset_cache: Dict[str, Optional[TilesetCollision]] = {}
|
|
287
|
+
self._character_cache: Dict[str, Optional[CharacterCollision]] = {}
|
|
288
|
+
|
|
289
|
+
def get_tileset_collision(
|
|
290
|
+
self, tileset_path: Union[str, Path]
|
|
291
|
+
) -> Optional[TilesetCollision]:
|
|
292
|
+
"""Get tileset collision data (cached)"""
|
|
293
|
+
key = str(Path(tileset_path).resolve())
|
|
294
|
+
|
|
295
|
+
if key not in self._tileset_cache:
|
|
296
|
+
self._tileset_cache[key] = load_tileset_collision(tileset_path)
|
|
297
|
+
|
|
298
|
+
return self._tileset_cache[key]
|
|
299
|
+
|
|
300
|
+
def get_character_collision(
|
|
301
|
+
self, sprite_path: Union[str, Path]
|
|
302
|
+
) -> Optional[CharacterCollision]:
|
|
303
|
+
"""Get character collision data (cached)"""
|
|
304
|
+
key = str(Path(sprite_path).resolve())
|
|
305
|
+
|
|
306
|
+
if key not in self._character_cache:
|
|
307
|
+
self._character_cache[key] = load_character_collision(sprite_path)
|
|
308
|
+
|
|
309
|
+
return self._character_cache[key]
|
|
310
|
+
|
|
311
|
+
def clear(self):
|
|
312
|
+
"""Clear all cached collision data"""
|
|
313
|
+
self._tileset_cache.clear()
|
|
314
|
+
self._character_cache.clear()
|
|
315
|
+
|
|
316
|
+
def preload_tileset(self, tileset_path: Union[str, Path]):
|
|
317
|
+
"""Preload tileset collision data into cache"""
|
|
318
|
+
self.get_tileset_collision(tileset_path)
|
|
319
|
+
|
|
320
|
+
def preload_character(self, sprite_path: Union[str, Path]):
|
|
321
|
+
"""Preload character collision data into cache"""
|
|
322
|
+
self.get_character_collision(sprite_path)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
_global_cache = CollisionCache()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_cached_tileset_collision(
|
|
329
|
+
tileset_path: Union[str, Path],
|
|
330
|
+
) -> Optional[TilesetCollision]:
|
|
331
|
+
"""Get tileset collision using global cache"""
|
|
332
|
+
return _global_cache.get_tileset_collision(tileset_path)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def get_cached_character_collision(
|
|
336
|
+
sprite_path: Union[str, Path],
|
|
337
|
+
) -> Optional[CharacterCollision]:
|
|
338
|
+
"""Get character collision using global cache"""
|
|
339
|
+
return _global_cache.get_character_collision(sprite_path)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def clear_collision_cache():
|
|
343
|
+
"""Clear global collision cache"""
|
|
344
|
+
_global_cache.clear()
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collision runner with ready-to-use movement modes for games.
|
|
3
|
+
|
|
4
|
+
Provides optimized collision detection and response for common game types:
|
|
5
|
+
- Slide: Smooth sliding along walls (top-down games)
|
|
6
|
+
- Platformer: Gravity-based movement with jump mechanics
|
|
7
|
+
- RPG: Grid-based or free movement with tile blocking
|
|
8
|
+
|
|
9
|
+
All runners work through a defined interface that any sprite class can implement.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import List, Optional, Protocol, Tuple, Union
|
|
18
|
+
|
|
19
|
+
from .collision import (
|
|
20
|
+
CapsuleShape,
|
|
21
|
+
CircleShape,
|
|
22
|
+
CollisionCache,
|
|
23
|
+
CollisionPolygon,
|
|
24
|
+
RectangleShape,
|
|
25
|
+
TilesetCollision,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
Point = Tuple[float, float]
|
|
29
|
+
Vector2 = Tuple[float, float]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ICollidableSprite(Protocol):
|
|
38
|
+
"""
|
|
39
|
+
Interface that any sprite/character class must implement to use collision runners.
|
|
40
|
+
|
|
41
|
+
Required attributes:
|
|
42
|
+
x (float): World X position
|
|
43
|
+
y (float): World Y position
|
|
44
|
+
collision_shape (RectangleShape | CircleShape | CapsuleShape): Collision shape
|
|
45
|
+
|
|
46
|
+
Optional attributes:
|
|
47
|
+
vx (float): X velocity (for physics-based runners)
|
|
48
|
+
vy (float): Y velocity (for physics-based runners)
|
|
49
|
+
on_ground (bool): Whether sprite is on ground (for platformer)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
x: float
|
|
53
|
+
y: float
|
|
54
|
+
collision_shape: Union[RectangleShape, CircleShape, CapsuleShape]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
vx: float
|
|
58
|
+
vy: float
|
|
59
|
+
on_ground: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def point_in_polygon(point: Point, vertices: List[Point]) -> bool:
|
|
68
|
+
"""Check if point is inside polygon using ray casting"""
|
|
69
|
+
x, y = point
|
|
70
|
+
n = len(vertices)
|
|
71
|
+
inside = False
|
|
72
|
+
|
|
73
|
+
p1x, p1y = vertices[0]
|
|
74
|
+
for i in range(1, n + 1):
|
|
75
|
+
p2x, p2y = vertices[i % n]
|
|
76
|
+
if y > min(p1y, p2y):
|
|
77
|
+
if y <= max(p1y, p2y):
|
|
78
|
+
if x <= max(p1x, p2x):
|
|
79
|
+
if p1y != p2y:
|
|
80
|
+
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
|
|
81
|
+
if p1x == p2x or x <= xinters:
|
|
82
|
+
inside = not inside
|
|
83
|
+
p1x, p1y = p2x, p2y
|
|
84
|
+
|
|
85
|
+
return inside
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def rect_polygon_collision(
|
|
89
|
+
rect_x: float, rect_y: float, rect_w: float, rect_h: float,
|
|
90
|
+
vertices: List[Point]
|
|
91
|
+
) -> bool:
|
|
92
|
+
"""Check if rectangle collides with polygon"""
|
|
93
|
+
|
|
94
|
+
corners = [
|
|
95
|
+
(rect_x, rect_y),
|
|
96
|
+
(rect_x + rect_w, rect_y),
|
|
97
|
+
(rect_x, rect_y + rect_h),
|
|
98
|
+
(rect_x + rect_w, rect_y + rect_h),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
for corner in corners:
|
|
102
|
+
if point_in_polygon(corner, vertices):
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
for vx, vy in vertices:
|
|
107
|
+
if rect_x <= vx <= rect_x + rect_w and rect_y <= vy <= rect_y + rect_h:
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def circle_polygon_collision(
|
|
114
|
+
center: Point, radius: float, vertices: List[Point]
|
|
115
|
+
) -> bool:
|
|
116
|
+
"""Check if circle collides with polygon"""
|
|
117
|
+
|
|
118
|
+
if point_in_polygon(center, vertices):
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
cx, cy = center
|
|
123
|
+
n = len(vertices)
|
|
124
|
+
|
|
125
|
+
for i in range(n):
|
|
126
|
+
x1, y1 = vertices[i]
|
|
127
|
+
x2, y2 = vertices[(i + 1) % n]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
dx = x2 - x1
|
|
131
|
+
dy = y2 - y1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
fx = cx - x1
|
|
135
|
+
fy = cy - y1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if dx == 0 and dy == 0:
|
|
139
|
+
dist = math.sqrt((cx - x1) ** 2 + (cy - y1) ** 2)
|
|
140
|
+
else:
|
|
141
|
+
t = max(0, min(1, (fx * dx + fy * dy) / (dx * dx + dy * dy)))
|
|
142
|
+
closest_x = x1 + t * dx
|
|
143
|
+
closest_y = y1 + t * dy
|
|
144
|
+
dist = math.sqrt((cx - closest_x) ** 2 + (cy - closest_y) ** 2)
|
|
145
|
+
|
|
146
|
+
if dist <= radius:
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_shape_bounds(
|
|
153
|
+
sprite: ICollidableSprite
|
|
154
|
+
) -> Tuple[float, float, float, float]:
|
|
155
|
+
"""Get AABB bounds for sprite (left, top, right, bottom)"""
|
|
156
|
+
shape = sprite.collision_shape
|
|
157
|
+
|
|
158
|
+
if isinstance(shape, RectangleShape):
|
|
159
|
+
left = sprite.x + shape.offset[0]
|
|
160
|
+
top = sprite.y + shape.offset[1]
|
|
161
|
+
return (left, top, left + shape.width, top + shape.height)
|
|
162
|
+
elif isinstance(shape, CircleShape):
|
|
163
|
+
cx, cy = shape.get_center(sprite.x, sprite.y)
|
|
164
|
+
r = shape.radius
|
|
165
|
+
return (cx - r, cy - r, cx + r, cy + r)
|
|
166
|
+
elif isinstance(shape, CapsuleShape):
|
|
167
|
+
top_center = shape.get_top_center(sprite.x, sprite.y)
|
|
168
|
+
r = shape.radius
|
|
169
|
+
h = shape.height
|
|
170
|
+
return (top_center[0] - r, top_center[1], top_center[0] + r, top_center[1] + h + r * 2)
|
|
171
|
+
|
|
172
|
+
return (sprite.x, sprite.y, sprite.x + 32, sprite.y + 32)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def check_sprite_polygon_collision(
|
|
176
|
+
sprite: ICollidableSprite, polygon: CollisionPolygon
|
|
177
|
+
) -> bool:
|
|
178
|
+
"""Check if sprite collides with polygon"""
|
|
179
|
+
shape = sprite.collision_shape
|
|
180
|
+
|
|
181
|
+
if isinstance(shape, RectangleShape):
|
|
182
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
183
|
+
return rect_polygon_collision(left, top, right - left, bottom - top, polygon.vertices)
|
|
184
|
+
elif isinstance(shape, CircleShape):
|
|
185
|
+
center = shape.get_center(sprite.x, sprite.y)
|
|
186
|
+
return circle_polygon_collision(center, shape.radius, polygon.vertices)
|
|
187
|
+
elif isinstance(shape, CapsuleShape):
|
|
188
|
+
|
|
189
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
190
|
+
return rect_polygon_collision(left, top, right - left, bottom - top, polygon.vertices)
|
|
191
|
+
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class MovementMode(Enum):
|
|
201
|
+
"""Movement modes for collision runner"""
|
|
202
|
+
SLIDE = "slide"
|
|
203
|
+
PLATFORMER = "platformer"
|
|
204
|
+
RPG = "rpg"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class CollisionResult:
|
|
214
|
+
"""Result of collision detection and resolution"""
|
|
215
|
+
collided: bool = False
|
|
216
|
+
final_x: float = 0.0
|
|
217
|
+
final_y: float = 0.0
|
|
218
|
+
hit_wall_x: bool = False
|
|
219
|
+
hit_wall_y: bool = False
|
|
220
|
+
hit_ceiling: bool = False
|
|
221
|
+
on_ground: bool = False
|
|
222
|
+
slide_vector: Optional[Vector2] = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class CollisionRunner:
|
|
226
|
+
"""
|
|
227
|
+
Ready-to-use collision runner with multiple movement modes.
|
|
228
|
+
|
|
229
|
+
Handles collision detection and response for common game types.
|
|
230
|
+
Works with any sprite class that implements ICollidableSprite interface.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
collision_cache: CollisionCache,
|
|
236
|
+
tile_size: Tuple[int, int] = (32, 32),
|
|
237
|
+
mode: MovementMode = MovementMode.SLIDE
|
|
238
|
+
):
|
|
239
|
+
"""
|
|
240
|
+
Initialize collision runner.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
collision_cache: Cache with preloaded collision data
|
|
244
|
+
tile_size: Size of tiles in pixels (width, height)
|
|
245
|
+
mode: Movement mode (slide, platformer, rpg)
|
|
246
|
+
"""
|
|
247
|
+
self.cache = collision_cache
|
|
248
|
+
self.tile_size = tile_size
|
|
249
|
+
self.mode = mode
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
self.gravity = 800.0
|
|
253
|
+
self.max_fall_speed = 600.0
|
|
254
|
+
self.jump_strength = -400.0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
self.slide_friction = 0.1
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
self.rpg_snap_to_grid = False
|
|
261
|
+
|
|
262
|
+
def get_tile_at(
|
|
263
|
+
self, world_x: float, world_y: float
|
|
264
|
+
) -> Tuple[int, int]:
|
|
265
|
+
"""Convert world position to tile coordinates"""
|
|
266
|
+
tile_x = int(world_x // self.tile_size[0])
|
|
267
|
+
tile_y = int(world_y // self.tile_size[1])
|
|
268
|
+
return (tile_x, tile_y)
|
|
269
|
+
|
|
270
|
+
def get_tile_shapes(
|
|
271
|
+
self,
|
|
272
|
+
tileset_collision: TilesetCollision,
|
|
273
|
+
tile_map: dict,
|
|
274
|
+
world_x: float,
|
|
275
|
+
world_y: float
|
|
276
|
+
) -> List[CollisionPolygon]:
|
|
277
|
+
"""Get collision shapes at world position"""
|
|
278
|
+
tile_x, tile_y = self.get_tile_at(world_x, world_y)
|
|
279
|
+
tile_id = tile_map.get((tile_x, tile_y))
|
|
280
|
+
|
|
281
|
+
if tile_id is None or not tileset_collision.has_collision(tile_id):
|
|
282
|
+
return []
|
|
283
|
+
|
|
284
|
+
tile_world_x = tile_x * self.tile_size[0]
|
|
285
|
+
tile_world_y = tile_y * self.tile_size[1]
|
|
286
|
+
|
|
287
|
+
return tileset_collision.get_world_shapes(tile_id, tile_world_x, tile_world_y)
|
|
288
|
+
|
|
289
|
+
def get_nearby_tile_shapes(
|
|
290
|
+
self,
|
|
291
|
+
tileset_collision: TilesetCollision,
|
|
292
|
+
tile_map: dict,
|
|
293
|
+
sprite: ICollidableSprite,
|
|
294
|
+
margin: int = 1
|
|
295
|
+
) -> List[CollisionPolygon]:
|
|
296
|
+
"""Get all collision shapes near sprite"""
|
|
297
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
298
|
+
|
|
299
|
+
min_tile_x = int(left // self.tile_size[0]) - margin
|
|
300
|
+
max_tile_x = int(right // self.tile_size[0]) + margin
|
|
301
|
+
min_tile_y = int(top // self.tile_size[1]) - margin
|
|
302
|
+
max_tile_y = int(bottom // self.tile_size[1]) + margin
|
|
303
|
+
|
|
304
|
+
shapes = []
|
|
305
|
+
for tile_y in range(min_tile_y, max_tile_y + 1):
|
|
306
|
+
for tile_x in range(min_tile_x, max_tile_x + 1):
|
|
307
|
+
tile_id = tile_map.get((tile_x, tile_y))
|
|
308
|
+
if tile_id is None or not tileset_collision.has_collision(tile_id):
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
tile_world_x = tile_x * self.tile_size[0]
|
|
312
|
+
tile_world_y = tile_y * self.tile_size[1]
|
|
313
|
+
|
|
314
|
+
tile_shapes = tileset_collision.get_world_shapes(
|
|
315
|
+
tile_id, tile_world_x, tile_world_y
|
|
316
|
+
)
|
|
317
|
+
shapes.extend(tile_shapes)
|
|
318
|
+
|
|
319
|
+
return shapes
|
|
320
|
+
|
|
321
|
+
def move_and_slide(
|
|
322
|
+
self,
|
|
323
|
+
sprite: ICollidableSprite,
|
|
324
|
+
tileset_collision: TilesetCollision,
|
|
325
|
+
tile_map: dict,
|
|
326
|
+
delta_x: float,
|
|
327
|
+
delta_y: float
|
|
328
|
+
) -> CollisionResult:
|
|
329
|
+
"""
|
|
330
|
+
Move sprite with sliding collision response.
|
|
331
|
+
|
|
332
|
+
Best for top-down games where sprite should slide along walls.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
sprite: Sprite to move (must implement ICollidableSprite)
|
|
336
|
+
tileset_collision: Tileset collision data
|
|
337
|
+
tile_map: Dictionary mapping (tile_x, tile_y) to tile_id
|
|
338
|
+
delta_x: X movement amount
|
|
339
|
+
delta_y: Y movement amount
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
CollisionResult with final position and collision info
|
|
343
|
+
"""
|
|
344
|
+
result = CollisionResult(final_x=sprite.x, final_y=sprite.y)
|
|
345
|
+
|
|
346
|
+
if delta_x == 0 and delta_y == 0:
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
old_x, old_y = sprite.x, sprite.y
|
|
351
|
+
sprite.x += delta_x
|
|
352
|
+
sprite.y += delta_y
|
|
353
|
+
|
|
354
|
+
shapes = self.get_nearby_tile_shapes(tileset_collision, tile_map, sprite)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
collided = any(check_sprite_polygon_collision(sprite, shape) for shape in shapes)
|
|
358
|
+
|
|
359
|
+
if not collided:
|
|
360
|
+
result.final_x = sprite.x
|
|
361
|
+
result.final_y = sprite.y
|
|
362
|
+
return result
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
result.collided = True
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
sprite.x = old_x + delta_x
|
|
369
|
+
sprite.y = old_y
|
|
370
|
+
|
|
371
|
+
x_collided = any(check_sprite_polygon_collision(sprite, shape) for shape in shapes)
|
|
372
|
+
|
|
373
|
+
if x_collided:
|
|
374
|
+
sprite.x = old_x
|
|
375
|
+
result.hit_wall_x = True
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
sprite.x = old_x
|
|
379
|
+
sprite.y = old_y + delta_y
|
|
380
|
+
|
|
381
|
+
y_collided = any(check_sprite_polygon_collision(sprite, shape) for shape in shapes)
|
|
382
|
+
|
|
383
|
+
if y_collided:
|
|
384
|
+
sprite.y = old_y
|
|
385
|
+
result.hit_wall_y = True
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if not x_collided:
|
|
389
|
+
sprite.x = old_x + delta_x
|
|
390
|
+
if not y_collided:
|
|
391
|
+
sprite.y = old_y + delta_y
|
|
392
|
+
|
|
393
|
+
result.final_x = sprite.x
|
|
394
|
+
result.final_y = sprite.y
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if x_collided and not y_collided:
|
|
398
|
+
result.slide_vector = (0, delta_y)
|
|
399
|
+
elif y_collided and not x_collided:
|
|
400
|
+
result.slide_vector = (delta_x, 0)
|
|
401
|
+
|
|
402
|
+
return result
|
|
403
|
+
|
|
404
|
+
def move_platformer(
|
|
405
|
+
self,
|
|
406
|
+
sprite: ICollidableSprite,
|
|
407
|
+
tileset_collision: TilesetCollision,
|
|
408
|
+
tile_map: dict,
|
|
409
|
+
dt: float,
|
|
410
|
+
input_x: float = 0.0,
|
|
411
|
+
jump_pressed: bool = False
|
|
412
|
+
) -> CollisionResult:
|
|
413
|
+
"""
|
|
414
|
+
Move sprite with platformer physics (gravity, jumping).
|
|
415
|
+
|
|
416
|
+
Best for side-scrolling platformer games.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
sprite: Sprite to move (must have vx, vy, on_ground attributes)
|
|
420
|
+
tileset_collision: Tileset collision data
|
|
421
|
+
tile_map: Dictionary mapping (tile_x, tile_y) to tile_id
|
|
422
|
+
dt: Delta time in seconds
|
|
423
|
+
input_x: Horizontal input (-1 to 1)
|
|
424
|
+
jump_pressed: Whether jump button is pressed
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
CollisionResult with final position and collision info
|
|
428
|
+
"""
|
|
429
|
+
result = CollisionResult(final_x=sprite.x, final_y=sprite.y)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if not getattr(sprite, 'on_ground', False):
|
|
433
|
+
sprite.vy += self.gravity * dt
|
|
434
|
+
sprite.vy = min(sprite.vy, self.max_fall_speed)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
if jump_pressed and getattr(sprite, 'on_ground', False):
|
|
438
|
+
sprite.vy = self.jump_strength
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
sprite.vx = input_x * 200.0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
delta_x = sprite.vx * dt
|
|
445
|
+
delta_y = sprite.vy * dt
|
|
446
|
+
|
|
447
|
+
old_x, old_y = sprite.x, sprite.y
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
sprite.x += delta_x
|
|
451
|
+
shapes = self.get_nearby_tile_shapes(tileset_collision, tile_map, sprite)
|
|
452
|
+
|
|
453
|
+
if any(check_sprite_polygon_collision(sprite, shape) for shape in shapes):
|
|
454
|
+
sprite.x = old_x
|
|
455
|
+
sprite.vx = 0
|
|
456
|
+
result.hit_wall_x = True
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
sprite.y += delta_y
|
|
460
|
+
shapes = self.get_nearby_tile_shapes(tileset_collision, tile_map, sprite)
|
|
461
|
+
|
|
462
|
+
collided_y = False
|
|
463
|
+
|
|
464
|
+
for shape in shapes:
|
|
465
|
+
if check_sprite_polygon_collision(sprite, shape):
|
|
466
|
+
if shape.one_way and sprite.vy > 0:
|
|
467
|
+
if old_y + get_shape_bounds(sprite)[3] - old_y <= min(v[1] for v in shape.vertices):
|
|
468
|
+
collided_y = True
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
if collided_y:
|
|
472
|
+
sprite.y = old_y
|
|
473
|
+
|
|
474
|
+
if sprite.vy > 0:
|
|
475
|
+
sprite.vy = 0
|
|
476
|
+
sprite.on_ground = True
|
|
477
|
+
result.on_ground = True
|
|
478
|
+
elif sprite.vy < 0:
|
|
479
|
+
sprite.vy = 0
|
|
480
|
+
result.hit_ceiling = True
|
|
481
|
+
else:
|
|
482
|
+
sprite.on_ground = False
|
|
483
|
+
|
|
484
|
+
result.final_x = sprite.x
|
|
485
|
+
result.final_y = sprite.y
|
|
486
|
+
result.collided = result.hit_wall_x or collided_y
|
|
487
|
+
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
def move_rpg(
|
|
491
|
+
self,
|
|
492
|
+
sprite: ICollidableSprite,
|
|
493
|
+
tileset_collision: TilesetCollision,
|
|
494
|
+
tile_map: dict,
|
|
495
|
+
delta_x: float,
|
|
496
|
+
delta_y: float
|
|
497
|
+
) -> CollisionResult:
|
|
498
|
+
"""
|
|
499
|
+
Move sprite with RPG-style blocking (no sliding).
|
|
500
|
+
|
|
501
|
+
Best for grid-based RPG games where movement is blocked by walls.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
sprite: Sprite to move
|
|
505
|
+
tileset_collision: Tileset collision data
|
|
506
|
+
tile_map: Dictionary mapping (tile_x, tile_y) to tile_id
|
|
507
|
+
delta_x: X movement amount
|
|
508
|
+
delta_y: Y movement amount
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
CollisionResult with final position and collision info
|
|
512
|
+
"""
|
|
513
|
+
result = CollisionResult(final_x=sprite.x, final_y=sprite.y)
|
|
514
|
+
|
|
515
|
+
if delta_x == 0 and delta_y == 0:
|
|
516
|
+
return result
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
old_x, old_y = sprite.x, sprite.y
|
|
520
|
+
sprite.x += delta_x
|
|
521
|
+
sprite.y += delta_y
|
|
522
|
+
|
|
523
|
+
shapes = self.get_nearby_tile_shapes(tileset_collision, tile_map, sprite)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
collided = any(check_sprite_polygon_collision(sprite, shape) for shape in shapes)
|
|
527
|
+
|
|
528
|
+
if collided:
|
|
529
|
+
|
|
530
|
+
sprite.x = old_x
|
|
531
|
+
sprite.y = old_y
|
|
532
|
+
result.collided = True
|
|
533
|
+
result.hit_wall_x = delta_x != 0
|
|
534
|
+
result.hit_wall_y = delta_y != 0
|
|
535
|
+
else:
|
|
536
|
+
result.final_x = sprite.x
|
|
537
|
+
result.final_y = sprite.y
|
|
538
|
+
|
|
539
|
+
return result
|
|
540
|
+
|
|
541
|
+
def move(
|
|
542
|
+
self,
|
|
543
|
+
sprite: ICollidableSprite,
|
|
544
|
+
tileset_collision: TilesetCollision,
|
|
545
|
+
tile_map: dict,
|
|
546
|
+
delta_x: float = 0.0,
|
|
547
|
+
delta_y: float = 0.0,
|
|
548
|
+
dt: float = 0.016,
|
|
549
|
+
**kwargs
|
|
550
|
+
) -> CollisionResult:
|
|
551
|
+
"""
|
|
552
|
+
Move sprite using configured movement mode.
|
|
553
|
+
|
|
554
|
+
This is a convenience method that calls the appropriate movement function
|
|
555
|
+
based on the runner's mode.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
sprite: Sprite to move
|
|
559
|
+
tileset_collision: Tileset collision data
|
|
560
|
+
tile_map: Dictionary mapping (tile_x, tile_y) to tile_id
|
|
561
|
+
delta_x: X movement amount (for slide/rpg modes)
|
|
562
|
+
delta_y: Y movement amount (for slide/rpg modes)
|
|
563
|
+
dt: Delta time in seconds (for platformer mode)
|
|
564
|
+
**kwargs: Additional mode-specific arguments
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
CollisionResult with final position and collision info
|
|
568
|
+
"""
|
|
569
|
+
if self.mode == MovementMode.SLIDE:
|
|
570
|
+
return self.move_and_slide(sprite, tileset_collision, tile_map, delta_x, delta_y)
|
|
571
|
+
elif self.mode == MovementMode.PLATFORMER:
|
|
572
|
+
return self.move_platformer(
|
|
573
|
+
sprite, tileset_collision, tile_map, dt,
|
|
574
|
+
input_x=kwargs.get('input_x', 0.0),
|
|
575
|
+
jump_pressed=kwargs.get('jump_pressed', False)
|
|
576
|
+
)
|
|
577
|
+
elif self.mode == MovementMode.RPG:
|
|
578
|
+
return self.move_rpg(sprite, tileset_collision, tile_map, delta_x, delta_y)
|
|
579
|
+
|
|
580
|
+
return CollisionResult(final_x=sprite.x, final_y=sprite.y)
|
|
@@ -81,7 +81,9 @@ def _optional_dict(value: Any, path: str) -> Optional[JsonDict]:
|
|
|
81
81
|
def _parse_point(text: str, path: str) -> Point:
|
|
82
82
|
if not isinstance(text, str):
|
|
83
83
|
raise MapParseError(_ctx(path, "expected point string"))
|
|
84
|
-
matched = re.search(
|
|
84
|
+
matched = re.search(
|
|
85
|
+
rf"(-?\d+)[{re.escape(string.punctuation)}](-?\d+)$", text.strip()
|
|
86
|
+
)
|
|
85
87
|
if matched is None:
|
|
86
88
|
raise MapParseError(_ctx(path, f"invalid point {text!r}"))
|
|
87
89
|
return int(matched.group(1)), int(matched.group(2))
|
|
@@ -224,7 +226,9 @@ def _parse_objects(objs_obj: JsonDict, ctx: str) -> Dict[int, ParsedObject]:
|
|
|
224
226
|
area_dict = _require_dict(obj_dict.get("area"), f"{ctx}.{key}.area")
|
|
225
227
|
area = _parse_object_area(area_dict, f"{ctx}.{key}.area")
|
|
226
228
|
ttype = _coerce_int(obj_dict.get("ttype"), f"{ctx}.{key}.ttype")
|
|
227
|
-
tileset_type = _require_str(
|
|
229
|
+
tileset_type = _require_str(
|
|
230
|
+
obj_dict.get("tileset_type", "object"), f"{ctx}.{key}.tileset_type"
|
|
231
|
+
)
|
|
228
232
|
variant = _coerce_int(obj_dict.get("variant"), f"{ctx}.{key}.variant")
|
|
229
233
|
props = _optional_dict(obj_dict.get("properties"), f"{ctx}.{key}.properties")
|
|
230
234
|
result[oid] = ParsedObject(
|
|
@@ -246,14 +250,22 @@ def _parse_layer(layer_obj: JsonDict, layer_id: int, ctx: str) -> ParsedLayer:
|
|
|
246
250
|
locked=_coerce_bool(layer_obj.get("locked", False), f"{ctx}.locked"),
|
|
247
251
|
opacity=_coerce_float(layer_obj.get("opacity", 1.0), f"{ctx}.opacity"),
|
|
248
252
|
z_index=_coerce_int(layer_obj.get("z_index", layer_id), f"{ctx}.z_index"),
|
|
249
|
-
properties=_optional_dict(layer_obj.get("properties"), f"{ctx}.properties")
|
|
253
|
+
properties=_optional_dict(layer_obj.get("properties"), f"{ctx}.properties")
|
|
254
|
+
or {},
|
|
255
|
+
)
|
|
256
|
+
layer.tiles = _parse_tiles(
|
|
257
|
+
_require_dict(layer_obj.get("tiles", {}), f"{ctx}.tiles"), f"{ctx}.tiles"
|
|
250
258
|
)
|
|
251
|
-
layer.tiles = _parse_tiles(_require_dict(layer_obj.get("tiles", {}), f"{ctx}.tiles"), f"{ctx}.tiles")
|
|
252
259
|
|
|
253
260
|
if layer.layer_type == "object":
|
|
254
|
-
layer.objects = _parse_objects(
|
|
261
|
+
layer.objects = _parse_objects(
|
|
262
|
+
_require_dict(layer_obj.get("objects", {}), f"{ctx}.objects"),
|
|
263
|
+
f"{ctx}.objects",
|
|
264
|
+
)
|
|
255
265
|
if "next_object_id" in layer_obj and layer_obj["next_object_id"] is not None:
|
|
256
|
-
layer.next_object_id = _coerce_int(
|
|
266
|
+
layer.next_object_id = _coerce_int(
|
|
267
|
+
layer_obj["next_object_id"], f"{ctx}.next_object_id"
|
|
268
|
+
)
|
|
257
269
|
return layer
|
|
258
270
|
|
|
259
271
|
|
|
@@ -264,13 +276,24 @@ def _parse_rule(rule_obj: JsonDict, ctx: str) -> ParsedAutotileRule:
|
|
|
264
276
|
pair_list = _require_list(pair, f"{ctx}.neighbors[{idx}]")
|
|
265
277
|
if len(pair_list) != 2:
|
|
266
278
|
raise MapParseError(_ctx(f"{ctx}.neighbors[{idx}]", "expected [x, y]"))
|
|
267
|
-
neighbors.append(
|
|
279
|
+
neighbors.append(
|
|
280
|
+
(
|
|
281
|
+
_coerce_int(pair_list[0], f"{ctx}.neighbors[{idx}][0]"),
|
|
282
|
+
_coerce_int(pair_list[1], f"{ctx}.neighbors[{idx}][1]"),
|
|
283
|
+
)
|
|
284
|
+
)
|
|
268
285
|
|
|
269
286
|
variants_raw = _require_list(rule_obj.get("variant_ids", []), f"{ctx}.variant_ids")
|
|
270
|
-
variant_ids = [
|
|
287
|
+
variant_ids = [
|
|
288
|
+
_coerce_int(v, f"{ctx}.variant_ids[{i}]") for i, v in enumerate(variants_raw)
|
|
289
|
+
]
|
|
271
290
|
tileset_path = _require_str(rule_obj.get("tileset_path", ""), f"{ctx}.tileset_path")
|
|
272
291
|
tileset_index_raw = rule_obj.get("tileset_index")
|
|
273
|
-
tileset_index =
|
|
292
|
+
tileset_index = (
|
|
293
|
+
_coerce_int(tileset_index_raw, f"{ctx}.tileset_index")
|
|
294
|
+
if tileset_index_raw is not None
|
|
295
|
+
else None
|
|
296
|
+
)
|
|
274
297
|
return ParsedAutotileRule(
|
|
275
298
|
name=_require_str(rule_obj.get("name"), f"{ctx}.name"),
|
|
276
299
|
neighbors=neighbors,
|
|
@@ -284,8 +307,13 @@ def _parse_rule(rule_obj: JsonDict, ctx: str) -> ParsedAutotileRule:
|
|
|
284
307
|
|
|
285
308
|
def _parse_group(group_obj: JsonDict, ctx: str) -> ParsedAutotileGroup:
|
|
286
309
|
rules_raw = _require_list(group_obj.get("rules", []), f"{ctx}.rules")
|
|
287
|
-
rules = [
|
|
288
|
-
|
|
310
|
+
rules = [
|
|
311
|
+
_parse_rule(_require_dict(r, f"{ctx}.rules[{i}]"), f"{ctx}.rules[{i}]")
|
|
312
|
+
for i, r in enumerate(rules_raw)
|
|
313
|
+
]
|
|
314
|
+
return ParsedAutotileGroup(
|
|
315
|
+
name=_require_str(group_obj.get("name"), f"{ctx}.name"), rules=rules
|
|
316
|
+
)
|
|
289
317
|
|
|
290
318
|
|
|
291
319
|
def _parse_tilesets_list(tilesets_raw: List[Any], ctx: str) -> List[ParsedTileset]:
|
|
@@ -303,8 +331,14 @@ def _parse_tilesets_list(tilesets_raw: List[Any], ctx: str) -> List[ParsedTilese
|
|
|
303
331
|
if raw_tile_props is not None:
|
|
304
332
|
tp_obj = _require_dict(raw_tile_props, f"{ctx}[{i}].tile_properties")
|
|
305
333
|
for k, v in tp_obj.items():
|
|
306
|
-
tile_props[str(k)] = _require_dict(
|
|
307
|
-
|
|
334
|
+
tile_props[str(k)] = _require_dict(
|
|
335
|
+
v, f"{ctx}[{i}].tile_properties[{k!r}]"
|
|
336
|
+
)
|
|
337
|
+
out.append(
|
|
338
|
+
ParsedTileset(
|
|
339
|
+
path=path, type=ts_type, properties=props, tile_properties=tile_props
|
|
340
|
+
)
|
|
341
|
+
)
|
|
308
342
|
return out
|
|
309
343
|
|
|
310
344
|
|
|
@@ -341,9 +375,17 @@ def parse_map_dict(root: JsonDict) -> ParsedMap:
|
|
|
341
375
|
root = _require_dict(root, "root")
|
|
342
376
|
meta_obj = _require_dict(root.get("meta"), "meta")
|
|
343
377
|
tile_size = _parse_point_field(meta_obj.get("tile_size"), "meta.tile_size")
|
|
344
|
-
map_size = _parse_point_field(
|
|
378
|
+
map_size = _parse_point_field(
|
|
379
|
+
meta_obj.get("map_size"),
|
|
380
|
+
"meta.map_size",
|
|
381
|
+
default=f"{tile_size[0]};{tile_size[1]}",
|
|
382
|
+
)
|
|
345
383
|
init_raw = meta_obj.get("initial_map_size")
|
|
346
|
-
initial_map_size =
|
|
384
|
+
initial_map_size = (
|
|
385
|
+
map_size
|
|
386
|
+
if init_raw is None
|
|
387
|
+
else _parse_point_field(init_raw, "meta.initial_map_size")
|
|
388
|
+
)
|
|
347
389
|
|
|
348
390
|
meta = ParsedMeta(
|
|
349
391
|
tile_size=tile_size,
|
|
@@ -360,7 +402,9 @@ def parse_map_dict(root: JsonDict) -> ParsedMap:
|
|
|
360
402
|
layers: List[ParsedLayer] = []
|
|
361
403
|
else:
|
|
362
404
|
layers = [
|
|
363
|
-
_parse_layer(
|
|
405
|
+
_parse_layer(
|
|
406
|
+
_require_dict(layer, f"data.layers[{i}]"), i, f"data.layers[{i}]"
|
|
407
|
+
)
|
|
364
408
|
for i, layer in enumerate(_require_list(layers_raw, "data.layers"))
|
|
365
409
|
]
|
|
366
410
|
if not layers:
|
|
@@ -368,17 +412,35 @@ def parse_map_dict(root: JsonDict) -> ParsedMap:
|
|
|
368
412
|
|
|
369
413
|
project_obj = _require_dict(root.get("project_state", {}), "project_state")
|
|
370
414
|
rules = [
|
|
371
|
-
_parse_rule(
|
|
372
|
-
|
|
415
|
+
_parse_rule(
|
|
416
|
+
_require_dict(rule, f"project_state.rules[{i}]"),
|
|
417
|
+
f"project_state.rules[{i}]",
|
|
418
|
+
)
|
|
419
|
+
for i, rule in enumerate(
|
|
420
|
+
_require_list(project_obj.get("rules", []), "project_state.rules")
|
|
421
|
+
)
|
|
373
422
|
]
|
|
374
423
|
groups = [
|
|
375
|
-
_parse_group(
|
|
376
|
-
|
|
424
|
+
_parse_group(
|
|
425
|
+
_require_dict(group, f"project_state.groups[{i}]"),
|
|
426
|
+
f"project_state.groups[{i}]",
|
|
427
|
+
)
|
|
428
|
+
for i, group in enumerate(
|
|
429
|
+
_require_list(project_obj.get("groups", []), "project_state.groups")
|
|
430
|
+
)
|
|
377
431
|
]
|
|
378
|
-
project_state = ParsedProjectState(
|
|
432
|
+
project_state = ParsedProjectState(
|
|
433
|
+
rules=rules, groups=groups, automap_rules=project_obj.get("automap_rules")
|
|
434
|
+
)
|
|
379
435
|
|
|
380
436
|
tilesets = _parse_resources(root.get("resources", {}), "resources")
|
|
381
|
-
return ParsedMap(
|
|
437
|
+
return ParsedMap(
|
|
438
|
+
meta=meta,
|
|
439
|
+
layers=layers,
|
|
440
|
+
tilesets=tilesets,
|
|
441
|
+
project_state=project_state,
|
|
442
|
+
raw=root,
|
|
443
|
+
)
|
|
382
444
|
|
|
383
445
|
|
|
384
446
|
def parse_map_json(text: str) -> ParsedMap:
|
|
@@ -16,10 +16,17 @@ class LayerRenderStats:
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class TileLayerRenderer:
|
|
19
|
-
def __init__(
|
|
19
|
+
def __init__(
|
|
20
|
+
self, data: TilemapData, *, include_hidden_layers: bool = False
|
|
21
|
+
) -> None:
|
|
20
22
|
self.data = data
|
|
21
|
-
self.tile_layers = data.get_tile_layers_dict(
|
|
22
|
-
|
|
23
|
+
self.tile_layers = data.get_tile_layers_dict(
|
|
24
|
+
include_hidden=include_hidden_layers
|
|
25
|
+
)
|
|
26
|
+
self._sorted_layer_ids = sorted(
|
|
27
|
+
self.tile_layers.keys(),
|
|
28
|
+
key=lambda lid: (self.tile_layers[lid].z_index, lid),
|
|
29
|
+
)
|
|
23
30
|
self._variant_cache: Dict[Tuple[int, int], Optional[Surface]] = {}
|
|
24
31
|
self._tile_w, self._tile_h = data.tile_size
|
|
25
32
|
|
|
@@ -29,7 +36,9 @@ class TileLayerRenderer:
|
|
|
29
36
|
def _get_cached_variant(self, ttype: int, variant: int) -> Optional[Surface]:
|
|
30
37
|
key = (ttype, variant)
|
|
31
38
|
if key not in self._variant_cache:
|
|
32
|
-
self._variant_cache[key] = self.data.get_tile_surface(
|
|
39
|
+
self._variant_cache[key] = self.data.get_tile_surface(
|
|
40
|
+
ttype, variant, copy_surface=True
|
|
41
|
+
)
|
|
33
42
|
return self._variant_cache[key]
|
|
34
43
|
|
|
35
44
|
def warm_cache(self) -> None:
|
|
@@ -38,6 +47,7 @@ class TileLayerRenderer:
|
|
|
38
47
|
for tile in layer.tiles.values():
|
|
39
48
|
if isinstance(tile.ttype, int):
|
|
40
49
|
self._get_cached_variant(tile.ttype, tile.variant)
|
|
50
|
+
self.data = None
|
|
41
51
|
|
|
42
52
|
def render(
|
|
43
53
|
self,
|
|
@@ -79,4 +89,6 @@ class TileLayerRenderer:
|
|
|
79
89
|
target.blit(cell, (x * self._tile_w - cam_x, y * self._tile_h - cam_y))
|
|
80
90
|
drawn += 1
|
|
81
91
|
|
|
82
|
-
return LayerRenderStats(
|
|
92
|
+
return LayerRenderStats(
|
|
93
|
+
drawn_tiles=drawn, skipped_tiles=skipped, visible_layers=visible_layers
|
|
94
|
+
)
|
|
@@ -3,6 +3,8 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/tilemap_parser/__init__.py
|
|
5
5
|
src/tilemap_parser/animation.py
|
|
6
|
+
src/tilemap_parser/collision.py
|
|
7
|
+
src/tilemap_parser/collision_runner.py
|
|
6
8
|
src/tilemap_parser/map_loader.py
|
|
7
9
|
src/tilemap_parser/map_parse.py
|
|
8
10
|
src/tilemap_parser/renderer.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tilemap_parser-1.0.0 → tilemap_parser-1.1.0}/src/tilemap_parser.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|