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.
- tilemap_parser/__init__.py +61 -0
- tilemap_parser/animation.py +306 -0
- tilemap_parser/map_loader.py +209 -0
- tilemap_parser/map_parse.py +402 -0
- tilemap_parser/renderer.py +82 -0
- tilemap_parser-1.0.0.dist-info/METADATA +80 -0
- tilemap_parser-1.0.0.dist-info/RECORD +10 -0
- tilemap_parser-1.0.0.dist-info/WHEEL +5 -0
- tilemap_parser-1.0.0.dist-info/licenses/LICENSE +674 -0
- tilemap_parser-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|