tilemap-parser 1.0.1__tar.gz → 1.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 1.0.1
3
+ Version: 1.1.1
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps and sprite animation JSON.
5
5
  Author: tilemap parser contributors
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "1.0.1"
7
+ version = "1.1.1"
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()