tilemap-parser 1.0.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 1.0.1
3
+ Version: 1.1.0
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.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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 1.0.1
3
+ Version: 1.1.0
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
@@ -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