tilemap-parser 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,61 @@
1
+ from .animation import (
2
+ AnimationClip,
3
+ AnimationFrame,
4
+ AnimationLibrary,
5
+ AnimationMarker,
6
+ AnimationParseError,
7
+ AnimationPlayer,
8
+ SpriteAnimationSet,
9
+ parse_animation_dict,
10
+ parse_animation_file,
11
+ parse_animation_json,
12
+ )
13
+ from .map_loader import TilemapData, load_map
14
+ from .map_parse import (
15
+ MapParseError,
16
+ ParsedAutotileGroup,
17
+ ParsedAutotileRule,
18
+ ParsedLayer,
19
+ ParsedMap,
20
+ ParsedMeta,
21
+ ParsedObject,
22
+ ParsedObjectArea,
23
+ ParsedProjectState,
24
+ ParsedTile,
25
+ ParsedTileset,
26
+ parse_map_dict,
27
+ parse_map_file,
28
+ parse_map_json,
29
+ )
30
+ from .renderer import LayerRenderStats, TileLayerRenderer
31
+
32
+ __all__ = [
33
+ "AnimationClip",
34
+ "AnimationFrame",
35
+ "AnimationLibrary",
36
+ "AnimationMarker",
37
+ "AnimationParseError",
38
+ "AnimationPlayer",
39
+ "LayerRenderStats",
40
+ "MapParseError",
41
+ "ParsedAutotileGroup",
42
+ "ParsedAutotileRule",
43
+ "ParsedLayer",
44
+ "ParsedMap",
45
+ "ParsedMeta",
46
+ "ParsedObject",
47
+ "ParsedObjectArea",
48
+ "ParsedProjectState",
49
+ "ParsedTile",
50
+ "ParsedTileset",
51
+ "SpriteAnimationSet",
52
+ "TileLayerRenderer",
53
+ "TilemapData",
54
+ "load_map",
55
+ "parse_animation_dict",
56
+ "parse_animation_file",
57
+ "parse_animation_json",
58
+ "parse_map_dict",
59
+ "parse_map_file",
60
+ "parse_map_json",
61
+ ]
@@ -0,0 +1,306 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Tuple, Union
7
+
8
+ import pygame
9
+ from pygame import Rect, Surface
10
+
11
+ PathLike = Union[str, Path]
12
+
13
+
14
+ class AnimationParseError(ValueError):
15
+ pass
16
+
17
+
18
+ def _req_dict(v: Any, ctx: str) -> Dict[str, Any]:
19
+ if not isinstance(v, dict):
20
+ raise AnimationParseError(f"{ctx}: expected object")
21
+ return v
22
+
23
+
24
+ def _req_list(v: Any, ctx: str) -> List[Any]:
25
+ if not isinstance(v, list):
26
+ raise AnimationParseError(f"{ctx}: expected array")
27
+ return v
28
+
29
+
30
+ def _req_str(v: Any, ctx: str) -> str:
31
+ if not isinstance(v, str):
32
+ raise AnimationParseError(f"{ctx}: expected string")
33
+ return v
34
+
35
+
36
+ def _coerce_int(v: Any, ctx: str) -> int:
37
+ if isinstance(v, bool):
38
+ raise AnimationParseError(f"{ctx}: expected int")
39
+ if isinstance(v, int):
40
+ return v
41
+ if isinstance(v, str):
42
+ try:
43
+ return int(v, 10)
44
+ except ValueError as e:
45
+ raise AnimationParseError(f"{ctx}: expected int") from e
46
+ raise AnimationParseError(f"{ctx}: expected int")
47
+
48
+
49
+ def _coerce_float(v: Any, ctx: str) -> float:
50
+ if isinstance(v, bool):
51
+ raise AnimationParseError(f"{ctx}: expected number")
52
+ if isinstance(v, (int, float)):
53
+ return float(v)
54
+ if isinstance(v, str):
55
+ try:
56
+ return float(v)
57
+ except ValueError as e:
58
+ raise AnimationParseError(f"{ctx}: expected number") from e
59
+ raise AnimationParseError(f"{ctx}: expected number")
60
+
61
+
62
+ @dataclass
63
+ class AnimationMarker:
64
+ name: str
65
+ frame_index: int
66
+
67
+
68
+ @dataclass
69
+ class AnimationFrame:
70
+ variant_id: int
71
+ duration_ms: float = 100.0
72
+
73
+
74
+ @dataclass
75
+ class AnimationClip:
76
+ name: str
77
+ frames: List[AnimationFrame] = field(default_factory=list)
78
+ loop: bool = True
79
+ fps: float = 60.0
80
+ metadata: Dict[str, Any] = field(default_factory=dict)
81
+ markers: List[AnimationMarker] = field(default_factory=list)
82
+
83
+ def frame_count(self) -> int:
84
+ return len(self.frames)
85
+
86
+ def total_duration_ms(self) -> float:
87
+ return sum(frame.duration_ms for frame in self.frames)
88
+
89
+ def clamp_markers(self) -> None:
90
+ count = len(self.frames)
91
+ if count == 0:
92
+ self.markers.clear()
93
+ return
94
+ for marker in self.markers:
95
+ marker.frame_index = max(0, min(marker.frame_index, count - 1))
96
+
97
+
98
+ @dataclass
99
+ class AnimationLibrary:
100
+ animations: Dict[str, AnimationClip] = field(default_factory=dict)
101
+ spritesheet_path: Optional[str] = None
102
+ tile_size: Tuple[int, int] = (32, 32)
103
+
104
+ def get(self, name: str) -> Optional[AnimationClip]:
105
+ return self.animations.get(name)
106
+
107
+
108
+ def _parse_marker(d: Dict[str, Any], ctx: str) -> AnimationMarker:
109
+ return AnimationMarker(name=_req_str(d.get("name"), f"{ctx}.name"), frame_index=_coerce_int(d.get("frame_index"), f"{ctx}.frame_index"))
110
+
111
+
112
+ def _parse_frame(d: Dict[str, Any], ctx: str) -> AnimationFrame:
113
+ return AnimationFrame(variant_id=_coerce_int(d.get("variant_id"), f"{ctx}.variant_id"), duration_ms=_coerce_float(d.get("duration_ms", 100.0), f"{ctx}.duration_ms"))
114
+
115
+
116
+ def _parse_animation(name: str, d: Dict[str, Any], ctx: str) -> AnimationClip:
117
+ frames_raw = _req_list(d.get("frames", []), f"{ctx}.frames")
118
+ frames = [_parse_frame(_req_dict(f, f"{ctx}.frames[{i}]"), f"{ctx}.frames[{i}]") for i, f in enumerate(frames_raw)]
119
+
120
+ metadata_raw = d.get("metadata")
121
+ if metadata_raw is None:
122
+ metadata: Dict[str, Any] = {}
123
+ elif isinstance(metadata_raw, dict):
124
+ metadata = dict(metadata_raw)
125
+ else:
126
+ raise AnimationParseError(f"{ctx}.metadata: expected object or null")
127
+
128
+ markers: List[AnimationMarker] = []
129
+ markers_raw = d.get("markers")
130
+ if markers_raw is not None:
131
+ for i, marker in enumerate(_req_list(markers_raw, f"{ctx}.markers")):
132
+ markers.append(_parse_marker(_req_dict(marker, f"{ctx}.markers[{i}]"), f"{ctx}.markers[{i}]"))
133
+
134
+ clip = AnimationClip(
135
+ name=_req_str(d.get("name", name), f"{ctx}.name"),
136
+ frames=frames,
137
+ loop=bool(d.get("loop", True)),
138
+ fps=_coerce_float(d.get("fps", 60.0), f"{ctx}.fps"),
139
+ metadata=metadata,
140
+ markers=markers,
141
+ )
142
+ clip.clamp_markers()
143
+ return clip
144
+
145
+
146
+ def parse_animation_dict(data: Dict[str, Any]) -> AnimationLibrary:
147
+ root = _req_dict(data, "root")
148
+ spritesheet = root.get("spritesheet_path")
149
+ spritesheet_path = _req_str(spritesheet, "spritesheet_path") if spritesheet is not None else None
150
+
151
+ tile_size_raw = _req_list(root.get("tile_size", [32, 32]), "tile_size")
152
+ if len(tile_size_raw) != 2:
153
+ raise AnimationParseError("tile_size: expected [w, h]")
154
+ tw = _coerce_int(tile_size_raw[0], "tile_size[0]")
155
+ th = _coerce_int(tile_size_raw[1], "tile_size[1]")
156
+ if tw < 1 or th < 1:
157
+ raise AnimationParseError("tile_size: width and height must be >= 1")
158
+
159
+ animations_raw = _req_dict(root.get("animations", {}), "animations")
160
+ animations: Dict[str, AnimationClip] = {}
161
+ for key, value in animations_raw.items():
162
+ k = str(key)
163
+ animations[k] = _parse_animation(k, _req_dict(value, f"animations[{k!r}]"), f"animations[{k!r}]")
164
+
165
+ return AnimationLibrary(animations=animations, spritesheet_path=spritesheet_path, tile_size=(tw, th))
166
+
167
+
168
+ def parse_animation_json(text: str) -> AnimationLibrary:
169
+ try:
170
+ payload = json.loads(text)
171
+ except json.JSONDecodeError as e:
172
+ raise AnimationParseError(f"Invalid JSON: {e}") from e
173
+ return parse_animation_dict(_req_dict(payload, "root"))
174
+
175
+
176
+ def parse_animation_file(path: PathLike) -> AnimationLibrary:
177
+ p = Path(path)
178
+ if not p.is_file():
179
+ raise AnimationParseError(f"Not a file: {p}")
180
+ try:
181
+ text = p.read_text(encoding="utf-8")
182
+ except OSError as e:
183
+ raise AnimationParseError(f"Cannot read {p}: {e}") from e
184
+ return parse_animation_json(text)
185
+
186
+
187
+ @dataclass
188
+ class SpriteAnimationSet:
189
+ library: AnimationLibrary
190
+ surface: Surface
191
+ warnings: List[str]
192
+ json_path: Optional[Path] = None
193
+ grid_offset_x: int = 0
194
+ grid_offset_y: int = 0
195
+
196
+ @classmethod
197
+ def load(
198
+ cls,
199
+ json_path: PathLike,
200
+ *,
201
+ spritesheet_path: Optional[PathLike] = None,
202
+ extra_search_base: Optional[Path] = None,
203
+ ) -> "SpriteAnimationSet":
204
+ path = Path(json_path)
205
+ library = parse_animation_file(path)
206
+ warnings: List[str] = []
207
+
208
+ if not pygame.get_init():
209
+ pygame.init()
210
+
211
+ sheet_ref = spritesheet_path if spritesheet_path is not None else library.spritesheet_path
212
+ if sheet_ref is None:
213
+ raise AnimationParseError("No spritesheet_path in JSON and none passed to load()")
214
+
215
+ image_path = Path(sheet_ref)
216
+ if not image_path.is_absolute():
217
+ candidate = (path.parent / image_path).resolve()
218
+ if candidate.is_file():
219
+ image_path = candidate
220
+ elif extra_search_base is not None:
221
+ extra = (Path(extra_search_base) / str(sheet_ref)).resolve()
222
+ image_path = extra if extra.is_file() else candidate
223
+ else:
224
+ image_path = candidate
225
+
226
+ if not image_path.is_file():
227
+ raise AnimationParseError(f"Spritesheet not found: {sheet_ref!r} (tried {image_path})")
228
+
229
+ try:
230
+ surface = pygame.image.load(str(image_path)).convert_alpha()
231
+ except pygame.error as e:
232
+ raise AnimationParseError(f"Failed to load image {image_path}: {e}") from e
233
+
234
+ return cls(library=library, surface=surface, warnings=warnings, json_path=path)
235
+
236
+ def get_image(self, variant_id: int, *, copy_surface: bool = True) -> Optional[Surface]:
237
+ tw, th = self.library.tile_size
238
+ if tw <= 0 or th <= 0:
239
+ return None
240
+ available_w = self.surface.get_width() - self.grid_offset_x
241
+ available_h = self.surface.get_height() - self.grid_offset_y
242
+ if available_w < tw or available_h < th:
243
+ return None
244
+ cols = max(1, available_w // tw)
245
+ col = variant_id % cols
246
+ row = variant_id // cols
247
+ src = Rect(self.grid_offset_x + col * tw, self.grid_offset_y + row * th, tw, th)
248
+ if not self.surface.get_rect().contains(src):
249
+ return None
250
+ cel = self.surface.subsurface(src)
251
+ return cel.copy() if copy_surface else cel
252
+
253
+
254
+ class AnimationPlayer:
255
+ def __init__(self, animation_set: SpriteAnimationSet, animation_name: str) -> None:
256
+ self.animation_set = animation_set
257
+ self.animation_name = animation_name
258
+ self._elapsed_in_frame = 0.0
259
+ self._frame_index = 0
260
+ self._finished = False
261
+ self._frame_cache: Dict[int, Optional[Surface]] = {}
262
+
263
+ @property
264
+ def clip(self) -> Optional[AnimationClip]:
265
+ return self.animation_set.library.get(self.animation_name)
266
+
267
+ @property
268
+ def finished(self) -> bool:
269
+ return self._finished
270
+
271
+ @property
272
+ def frame_index(self) -> int:
273
+ return self._frame_index
274
+
275
+ def reset(self) -> None:
276
+ self._elapsed_in_frame = 0.0
277
+ self._frame_index = 0
278
+ self._finished = False
279
+
280
+ def update(self, dt_ms: float) -> None:
281
+ clip = self.clip
282
+ if clip is None or not clip.frames:
283
+ self._finished = True
284
+ return
285
+ if self._finished:
286
+ return
287
+ self._elapsed_in_frame += dt_ms
288
+ while self._elapsed_in_frame >= clip.frames[self._frame_index].duration_ms:
289
+ self._elapsed_in_frame -= clip.frames[self._frame_index].duration_ms
290
+ self._frame_index += 1
291
+ if self._frame_index >= len(clip.frames):
292
+ if clip.loop:
293
+ self._frame_index = 0
294
+ else:
295
+ self._frame_index = len(clip.frames) - 1
296
+ self._finished = True
297
+ break
298
+
299
+ def get_current_image(self) -> Optional[Surface]:
300
+ clip = self.clip
301
+ if clip is None or not clip.frames:
302
+ return None
303
+ variant = clip.frames[self._frame_index].variant_id
304
+ if variant not in self._frame_cache:
305
+ self._frame_cache[variant] = self.animation_set.get_image(variant, copy_surface=True)
306
+ return self._frame_cache[variant]
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional, Tuple, Union
6
+
7
+ import pygame
8
+ from pygame import Rect, Surface
9
+
10
+ from .map_parse import MapParseError, ParsedLayer, ParsedMap, ParsedTile, parse_map_file
11
+
12
+ PathLike = Union[str, Path]
13
+
14
+
15
+ class TilemapData:
16
+ def __init__(
17
+ self,
18
+ parsed: ParsedMap,
19
+ surfaces: List[Optional[Surface]],
20
+ resolved_paths: List[Path],
21
+ warnings: List[str],
22
+ *,
23
+ map_path: Optional[Path] = None,
24
+ ) -> None:
25
+ self.parsed = parsed
26
+ self.surfaces = surfaces
27
+ self.resolved_paths = resolved_paths
28
+ self.warnings = warnings
29
+ self.map_path = map_path
30
+ self._tw, self._th = parsed.meta.tile_size
31
+ self._build_path_index()
32
+ self._normalize_tile_ttypes()
33
+
34
+ @classmethod
35
+ def load(
36
+ cls,
37
+ path: PathLike,
38
+ *,
39
+ extra_search_base: Optional[Path] = None,
40
+ skip_missing_images: bool = True,
41
+ ) -> "TilemapData":
42
+ p = Path(path)
43
+ parsed = parse_map_file(p)
44
+ map_dir = p.parent
45
+
46
+ surfaces: List[Optional[Surface]] = []
47
+ resolved_paths: List[Path] = []
48
+ warnings: List[str] = []
49
+
50
+ if not pygame.get_init():
51
+ pygame.init()
52
+
53
+ for i, ts in enumerate(parsed.tilesets):
54
+ resolved = _resolve_resource_path(ts.path, map_dir, extra_search_base)
55
+ resolved_paths.append(resolved)
56
+ if not resolved.is_file():
57
+ warnings.append(f"Tileset missing ({i}): {ts.path!r} -> {resolved}")
58
+ surfaces.append(None)
59
+ continue
60
+ try:
61
+ surfaces.append(pygame.image.load(str(resolved)).convert_alpha())
62
+ except pygame.error as e:
63
+ msg = f"Tileset load failed ({i}) {resolved}: {e}"
64
+ warnings.append(msg)
65
+ if not skip_missing_images:
66
+ raise MapParseError(msg) from e
67
+ surfaces.append(None)
68
+
69
+ return cls(parsed, surfaces, resolved_paths, warnings, map_path=p)
70
+
71
+ def _build_path_index(self) -> None:
72
+ self._path_to_index: Dict[str, int] = {}
73
+ for i, ts in enumerate(self.parsed.tilesets):
74
+ raw = ts.path.replace("\\", "/")
75
+ rp = self.resolved_paths[i]
76
+ self._path_to_index[raw] = i
77
+ self._path_to_index[str(rp)] = i
78
+ self._path_to_index[str(rp.resolve())] = i
79
+ self._path_to_index[Path(raw).name] = i
80
+
81
+ def _lookup_tileset_index(self, ref: str) -> int:
82
+ norm = ref.replace("\\", "/")
83
+ if norm in self._path_to_index:
84
+ return self._path_to_index[norm]
85
+ pref = Path(ref)
86
+ for i, rp in enumerate(self.resolved_paths):
87
+ try:
88
+ if rp.resolve() == pref.resolve():
89
+ return i
90
+ except (OSError, ValueError):
91
+ pass
92
+ if rp.name == pref.name:
93
+ return i
94
+ return -1
95
+
96
+ def _normalize_tile_ttypes(self) -> None:
97
+ for layer in self.parsed.layers:
98
+ if layer.layer_type == "object":
99
+ continue
100
+ for pos, tile in layer.tiles.items():
101
+ if isinstance(tile.ttype, str):
102
+ idx = self._lookup_tileset_index(tile.ttype)
103
+ if idx < 0:
104
+ self.warnings.append(
105
+ f"Unresolved tileset ref {tile.ttype!r} at layer {layer.name!r} cell {pos}"
106
+ )
107
+ tile.ttype = idx
108
+
109
+ @property
110
+ def tile_size(self) -> Tuple[int, int]:
111
+ return self.parsed.meta.tile_size
112
+
113
+ @property
114
+ def map_size(self) -> Tuple[int, int]:
115
+ return self.parsed.meta.map_size
116
+
117
+ def get_raw(self) -> dict:
118
+ return deepcopy(self.parsed.raw)
119
+
120
+ def get_layers(
121
+ self,
122
+ *,
123
+ include_hidden: bool = True,
124
+ layer_type: Optional[str] = None,
125
+ sort_by_zindex: bool = True,
126
+ ) -> List[ParsedLayer]:
127
+ layers = self.parsed.layers
128
+ if layer_type is not None:
129
+ layers = [layer for layer in layers if layer.layer_type == layer_type]
130
+ if not include_hidden:
131
+ layers = [layer for layer in layers if layer.visible]
132
+ if sort_by_zindex:
133
+ layers = sorted(layers, key=lambda layer: (layer.z_index, layer.id))
134
+ return list(layers)
135
+
136
+ def get_layer(self, layer_id_or_name: Union[int, str]) -> Optional[ParsedLayer]:
137
+ if isinstance(layer_id_or_name, int):
138
+ for layer in self.parsed.layers:
139
+ if layer.id == layer_id_or_name:
140
+ return layer
141
+ return None
142
+ for layer in self.parsed.layers:
143
+ if layer.name == layer_id_or_name:
144
+ return layer
145
+ return None
146
+
147
+ def get_tile_layers_dict(self, *, include_hidden: bool = True) -> Dict[int, ParsedLayer]:
148
+ return {layer.id: layer for layer in self.get_layers(include_hidden=include_hidden, layer_type="tile", sort_by_zindex=False)}
149
+
150
+ def get_image(self, variant: int, ttype: int = 0, *, copy_surface: bool = True) -> Optional[Surface]:
151
+ if ttype < 0 or ttype >= len(self.surfaces):
152
+ return None
153
+ source = self.surfaces[ttype]
154
+ if source is None:
155
+ return None
156
+ return _variant_surface(source, variant, self.tile_size, copy_surface=copy_surface)
157
+
158
+ def get_tile_surface(self, ttype: int, variant: int, *, copy_surface: bool = True) -> Optional[Surface]:
159
+ return self.get_image(variant=variant, ttype=ttype, copy_surface=copy_surface)
160
+
161
+ def get_tile_at(self, layer_id_or_name: Union[int, str], x: int, y: int) -> Optional[ParsedTile]:
162
+ layer = self.get_layer(layer_id_or_name)
163
+ if layer is None:
164
+ return None
165
+ return layer.tiles.get((x, y))
166
+
167
+ def get_tile_surface_at(self, layer_id_or_name: Union[int, str], x: int, y: int) -> Optional[Surface]:
168
+ tile = self.get_tile_at(layer_id_or_name, x, y)
169
+ if tile is None or not isinstance(tile.ttype, int):
170
+ return None
171
+ return self.get_tile_surface(tile.ttype, tile.variant)
172
+
173
+
174
+ def _variant_surface(
175
+ surf: Surface,
176
+ variant: int,
177
+ tile_size: Tuple[int, int],
178
+ *,
179
+ copy_surface: bool,
180
+ ) -> Optional[Surface]:
181
+ tw, th = tile_size
182
+ if tw <= 0 or th <= 0:
183
+ return None
184
+ cols = max(1, surf.get_width() // tw)
185
+ col = variant % cols
186
+ row = variant // cols
187
+ src = Rect(col * tw, row * th, tw, th)
188
+ if not surf.get_rect().contains(src):
189
+ return None
190
+ cell = surf.subsurface(src)
191
+ return cell.copy() if copy_surface else cell
192
+
193
+
194
+ def _resolve_resource_path(path_str: str, map_dir: Path, extra_search_base: Optional[Path]) -> Path:
195
+ path = Path(path_str)
196
+ if path.is_absolute():
197
+ return path
198
+ candidate = (map_dir / path).resolve()
199
+ if candidate.is_file():
200
+ return candidate
201
+ if extra_search_base is not None:
202
+ extra_candidate = (Path(extra_search_base) / path_str).resolve()
203
+ if extra_candidate.is_file():
204
+ return extra_candidate
205
+ return candidate
206
+
207
+
208
+ def load_map(path: PathLike, *, extra_search_base: Optional[Path] = None, skip_missing_images: bool = True) -> TilemapData:
209
+ return TilemapData.load(path, extra_search_base=extra_search_base, skip_missing_images=skip_missing_images)