tilemap-parser 5.1.0__tar.gz → 5.2.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.
Files changed (75) hide show
  1. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/PKG-INFO +1 -1
  2. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/pyproject.toml +1 -1
  3. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/__init__.py +2 -0
  4. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/parser/__init__.py +2 -0
  5. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/parser/map_parse.py +109 -4
  6. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/__init__.py +2 -1
  7. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/map_loader.py +154 -2
  8. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  9. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser.egg-info/SOURCES.txt +2 -0
  10. tilemap_parser-5.2.0/tests/test_background_layer.py +223 -0
  11. tilemap_parser-5.2.0/tests/test_object_animation.py +364 -0
  12. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/LICENSE +0 -0
  13. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/README.md +0 -0
  14. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/pypi-readme.md +0 -0
  15. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/setup.cfg +0 -0
  16. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/parser/animation.py +0 -0
  17. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/parser/collision.py +0 -0
  18. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/parser/collision_loader.py +0 -0
  19. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/parser/node_parse.py +0 -0
  20. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/parser/particle.py +0 -0
  21. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  22. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/animation_player.py +0 -0
  23. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/area_node.py +0 -0
  24. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/body.py +0 -0
  25. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/camera.py +0 -0
  26. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/collision/__init__.py +0 -0
  27. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/collision/hit.py +0 -0
  28. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/collision/manager.py +0 -0
  29. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/collision/shapes.py +0 -0
  30. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  31. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/map_object.py +0 -0
  32. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/movement/__init__.py +0 -0
  33. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/movement/grounded.py +0 -0
  34. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/movement/platformer.py +0 -0
  35. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/movement/queries.py +0 -0
  36. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/movement/rpg.py +0 -0
  37. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/movement/runner.py +0 -0
  38. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/movement/slide.py +0 -0
  39. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/movement/types.py +0 -0
  40. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/navigation/__init__.py +0 -0
  41. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/navigation/nav_grid.py +0 -0
  42. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/navigation/path_follower.py +0 -0
  43. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/navigation/pathfinder.py +0 -0
  44. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/object_collision.py +0 -0
  45. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/particles.py +0 -0
  46. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/polygon_query.py +0 -0
  47. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/protocols.py +0 -0
  48. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/renderer.py +0 -0
  49. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  50. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/runtime/world.py +0 -0
  51. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/utils/__init__.py +0 -0
  52. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser/utils/geometry.py +0 -0
  53. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  54. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser.egg-info/requires.txt +0 -0
  55. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  56. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_body.py +0 -0
  57. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_camera.py +0 -0
  58. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_collision.py +0 -0
  59. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_geometry.py +0 -0
  60. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_gid_collision.py +0 -0
  61. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_gid_routing.py +0 -0
  62. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_integration_animation.py +0 -0
  63. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_integration_collision.py +0 -0
  64. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_integration_map_loader.py +0 -0
  65. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_map_loader.py +0 -0
  66. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_map_object.py +0 -0
  67. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_move_grounded.py +0 -0
  68. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_navigation.py +0 -0
  69. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_object_collision.py +0 -0
  70. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_object_surfaces.py +0 -0
  71. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_particle_emitter.py +0 -0
  72. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_render_scale.py +0 -0
  73. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_tile_collision.py +0 -0
  74. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_tile_layer_renderer_y_sort.py +0 -0
  75. {tilemap_parser-5.1.0 → tilemap_parser-5.2.0}/tests/test_tmx_converter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 5.1.0
3
+ Version: 5.2.0
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime.
5
5
  Author: tilemap parser contributors
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "5.1.0"
7
+ version = "5.2.0"
8
8
  description = "Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime."
9
9
  readme = "pypi-readme.md"
10
10
  requires-python = ">=3.10"
@@ -10,6 +10,7 @@ __all__ = [
10
10
  "AnimationParseError",
11
11
  "AnimationPlayer",
12
12
  "AreaNode",
13
+ "BackgroundLayer",
13
14
  "CapsuleShape",
14
15
  "CharacterCollision",
15
16
  "CircleShape",
@@ -26,6 +27,7 @@ __all__ = [
26
27
  "LayerRenderStats",
27
28
  "MapParseError",
28
29
  "MovementMode",
30
+ "ObjectAnimation",
29
31
  "ObjectCollisionData",
30
32
  "ObjectCollisionManager",
31
33
  "ObjectCollisionRegionData",
@@ -30,6 +30,7 @@ from .collision_loader import (
30
30
  )
31
31
  from .map_parse import (
32
32
  MapParseError,
33
+ ObjectAnimation,
33
34
  ParsedAutotileGroup,
34
35
  ParsedAutotileRule,
35
36
  ParsedLayer,
@@ -72,6 +73,7 @@ __all__ = [
72
73
  "CollisionParseError",
73
74
  "CollisionPolygon",
74
75
  "MapParseError",
76
+ "ObjectAnimation",
75
77
  "ObjectCollisionData",
76
78
  "ObjectCollisionRegionData",
77
79
  "ParsedAutotileGroup",
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import math
4
5
  import re
5
6
  import string
6
7
  from dataclasses import dataclass, field
@@ -121,6 +122,20 @@ class ParsedObjectArea:
121
122
  h: int
122
123
 
123
124
 
125
+ @dataclass
126
+ class ObjectAnimation:
127
+ frame_count: int
128
+ frame_duration_ms: float
129
+ speed: float = 1.0
130
+ loop: bool = True
131
+ animation_mode: str = "default"
132
+ random_phase: bool = False
133
+ frames: List[int] = field(default_factory=list)
134
+ frame_stride: int = 1
135
+ frame_w: Optional[int] = None
136
+ frame_h: Optional[int] = None
137
+
138
+
124
139
  @dataclass
125
140
  class ParsedObject:
126
141
  area: ParsedObjectArea
@@ -128,15 +143,18 @@ class ParsedObject:
128
143
  tileset_type: str
129
144
  variant: int
130
145
  properties: Optional[JsonDict] = None
146
+ animation: Optional[ObjectAnimation] = None
131
147
 
132
148
 
133
149
  @dataclass
134
150
  class TilesetAnimation:
135
151
  frame_count: int
136
152
  frame_duration_ms: float
137
- frame_stride: int
153
+ frame_stride: int = 1
138
154
  loop: bool = True
139
155
  animation_mode: str = "default"
156
+ frame_w: Optional[int] = None
157
+ frame_h: Optional[int] = None
140
158
 
141
159
 
142
160
  @dataclass
@@ -166,6 +184,8 @@ class ParsedLayer:
166
184
  objects: Dict[int, ParsedObject] = field(default_factory=dict)
167
185
  next_object_id: Optional[int] = None
168
186
  ttypes: set[int] = field(default_factory=set)
187
+ image_path: Optional[str] = None
188
+ image_rect: Optional[Tuple[int, int, int, int]] = None
169
189
 
170
190
 
171
191
  @dataclass
@@ -256,6 +276,47 @@ def _parse_object_area(area_obj: JsonDict, ctx: str) -> ParsedObjectArea:
256
276
  )
257
277
 
258
278
 
279
+ def _parse_object_animation(anim_obj: JsonDict, ctx: str) -> ObjectAnimation:
280
+ frame_count = _coerce_int(anim_obj.get("frame_count"), f"{ctx}.frame_count")
281
+ if frame_count < 1:
282
+ raise MapParseError(_ctx(f"{ctx}.frame_count", "must be >= 1"))
283
+ frame_duration_ms = _coerce_float(
284
+ anim_obj.get("frame_duration_ms"), f"{ctx}.frame_duration_ms"
285
+ )
286
+ if not math.isfinite(frame_duration_ms) or frame_duration_ms <= 0:
287
+ raise MapParseError(_ctx(f"{ctx}.frame_duration_ms", "must be > 0 and finite"))
288
+ frames_raw = anim_obj.get("frames")
289
+ frames: List[int] = []
290
+ if frames_raw is not None:
291
+ frames_list = _require_list(frames_raw, f"{ctx}.frames")
292
+ if len(frames_list) != frame_count:
293
+ raise MapParseError(
294
+ _ctx(
295
+ f"{ctx}.frames",
296
+ f"must contain exactly {frame_count} entries (got {len(frames_list)})",
297
+ )
298
+ )
299
+ frames = [_coerce_int(f, f"{ctx}.frames[{i}]") for i, f in enumerate(frames_list)]
300
+ for i, frame_idx in enumerate(frames):
301
+ if frame_idx < 0:
302
+ raise MapParseError(
303
+ _ctx(f"{ctx}.frames[{i}]", f"must be non-negative (got {frame_idx})")
304
+ )
305
+ return ObjectAnimation(
306
+ frame_count=frame_count,
307
+ frame_duration_ms=frame_duration_ms,
308
+ speed=_coerce_float(anim_obj.get("speed", 1.0), f"{ctx}.speed"),
309
+ loop=_coerce_bool(anim_obj.get("loop", True), f"{ctx}.loop"),
310
+ animation_mode=_require_str(
311
+ anim_obj.get("animation_mode", "default"), f"{ctx}.animation_mode"
312
+ ),
313
+ random_phase=_coerce_bool(
314
+ anim_obj.get("random_phase", False), f"{ctx}.random_phase"
315
+ ),
316
+ frames=frames,
317
+ )
318
+
319
+
259
320
  def _parse_objects(objs_obj: JsonDict, ctx: str) -> Dict[int, ParsedObject]:
260
321
  result: Dict[int, ParsedObject] = {}
261
322
  for key, value in objs_obj.items():
@@ -269,12 +330,22 @@ def _parse_objects(objs_obj: JsonDict, ctx: str) -> Dict[int, ParsedObject]:
269
330
  )
270
331
  variant = _coerce_int(obj_dict.get("variant"), f"{ctx}.{key}.variant")
271
332
  props = _optional_dict(obj_dict.get("properties"), f"{ctx}.{key}.properties")
333
+ animation_raw = obj_dict.get("animation")
334
+ animation = (
335
+ _parse_object_animation(
336
+ _require_dict(animation_raw, f"{ctx}.{key}.animation"),
337
+ f"{ctx}.{key}.animation",
338
+ )
339
+ if animation_raw is not None
340
+ else None
341
+ )
272
342
  result[oid] = ParsedObject(
273
343
  area=area,
274
344
  ttype=ttype,
275
345
  tileset_type=tileset_type,
276
346
  variant=variant,
277
347
  properties=props,
348
+ animation=animation,
278
349
  )
279
350
  return result
280
351
 
@@ -308,6 +379,24 @@ def _parse_layer(layer_obj: JsonDict, layer_id: int, ctx: str) -> ParsedLayer:
308
379
  layer.next_object_id = _coerce_int(
309
380
  layer_obj["next_object_id"], f"{ctx}.next_object_id"
310
381
  )
382
+
383
+ image_path_raw = layer_obj.get("image_path")
384
+ if image_path_raw is not None:
385
+ layer.image_path = _require_str(image_path_raw, f"{ctx}.image_path")
386
+ # Normalize background/background_layer types with images to "image" type
387
+ if layer.layer_type in ("background", "background_layer"):
388
+ layer.layer_type = "image"
389
+
390
+ image_rect_raw = layer_obj.get("image_rect")
391
+ if image_rect_raw is not None:
392
+ rect_obj = _require_dict(image_rect_raw, f"{ctx}.image_rect")
393
+ layer.image_rect = (
394
+ _coerce_int(rect_obj.get("x"), f"{ctx}.image_rect.x"),
395
+ _coerce_int(rect_obj.get("y"), f"{ctx}.image_rect.y"),
396
+ _coerce_int(rect_obj.get("w"), f"{ctx}.image_rect.w"),
397
+ _coerce_int(rect_obj.get("h"), f"{ctx}.image_rect.h"),
398
+ )
399
+
311
400
  return layer
312
401
 
313
402
 
@@ -380,12 +469,28 @@ def _parse_tilesets_list(tilesets_raw: List[Any], ctx: str) -> List[ParsedTilese
380
469
  animation_raw = ts_obj.get("animation")
381
470
  if animation_raw is not None:
382
471
  anim_obj = _require_dict(animation_raw, f"{ctx}[{i}].animation")
472
+ frame_stride_raw = anim_obj.get("frame_stride")
473
+ frame_stride = (
474
+ _coerce_int(frame_stride_raw, f"{ctx}[{i}].animation.frame_stride")
475
+ if frame_stride_raw is not None
476
+ else 1
477
+ )
478
+ frame_w_raw = anim_obj.get("frame_w")
479
+ frame_h_raw = anim_obj.get("frame_h")
480
+ frame_count = _coerce_int(anim_obj.get("frame_count"), f"{ctx}[{i}].animation.frame_count")
481
+ frame_duration_ms = _coerce_float(anim_obj.get("frame_duration_ms"), f"{ctx}[{i}].animation.frame_duration_ms")
482
+ if frame_count < 1:
483
+ raise MapParseError(_ctx(f"{ctx}[{i}].animation.frame_count", "must be >= 1"))
484
+ if not math.isfinite(frame_duration_ms) or frame_duration_ms <= 0:
485
+ raise MapParseError(_ctx(f"{ctx}[{i}].animation.frame_duration_ms", "must be > 0 and finite"))
383
486
  animation = TilesetAnimation(
384
- frame_count=_coerce_int(anim_obj.get("frame_count"), f"{ctx}[{i}].animation.frame_count"),
385
- frame_duration_ms=_coerce_float(anim_obj.get("frame_duration_ms"), f"{ctx}[{i}].animation.frame_duration_ms"),
386
- frame_stride=_coerce_int(anim_obj.get("frame_stride"), f"{ctx}[{i}].animation.frame_stride"),
487
+ frame_count=frame_count,
488
+ frame_duration_ms=frame_duration_ms,
489
+ frame_stride=frame_stride,
387
490
  loop=_coerce_bool(anim_obj.get("loop", True), f"{ctx}[{i}].animation.loop"),
388
491
  animation_mode=_require_str(anim_obj.get("animation_mode", "default"), f"{ctx}[{i}].animation.animation_mode"),
492
+ frame_w=_coerce_int(frame_w_raw, f"{ctx}[{i}].animation.frame_w") if frame_w_raw is not None else None,
493
+ frame_h=_coerce_int(frame_h_raw, f"{ctx}[{i}].animation.frame_h") if frame_h_raw is not None else None,
389
494
  )
390
495
  out.append(
391
496
  ParsedTileset(
@@ -16,7 +16,7 @@ from .map_object import MapObject, load_map_objects
16
16
  from .movement import CollisionResult, CollisionRunner, MovementMode
17
17
  from .polygon_query import rect_vs_tilemap
18
18
  from .protocols import ICollidable, ICollidableObject, ICollidableSprite
19
- from .map_loader import TilemapData, load_map
19
+ from .map_loader import BackgroundLayer, TilemapData, load_map
20
20
  from .collision import (
21
21
  CollisionHit,
22
22
  ObjectCollisionManager,
@@ -43,6 +43,7 @@ from .particles import (
43
43
  __all__ = [
44
44
  "AnimationPlayer",
45
45
  "AreaNode",
46
+ "BackgroundLayer",
46
47
  "Body",
47
48
  "Camera",
48
49
  "CollisionCache",
@@ -4,12 +4,20 @@ import json
4
4
  import math
5
5
  from copy import deepcopy
6
6
  from pathlib import Path
7
- from typing import Dict, List, Optional, Tuple, Union
7
+ from typing import Dict, List, Literal, Optional, Tuple, TypedDict, Union
8
8
 
9
9
  import pygame
10
10
  from pygame import Rect, Surface
11
11
 
12
- from ..parser.map_parse import MapParseError, ParsedLayer, ParsedMap, ParsedObject, ParsedTile, parse_map_file
12
+ from ..parser.map_parse import (
13
+ MapParseError,
14
+ ObjectAnimation,
15
+ ParsedLayer,
16
+ ParsedMap,
17
+ ParsedObject,
18
+ ParsedTile,
19
+ parse_map_file,
20
+ )
13
21
  from ..parser.node_parse import parse_nodes_dict
14
22
  from .area_node import AreaNode
15
23
  from .particles import ParticleEmitterNode
@@ -17,6 +25,31 @@ from .particles import ParticleEmitterNode
17
25
  PathLike = Union[str, Path]
18
26
 
19
27
 
28
+ class BackgroundLayer:
29
+ __slots__ = ("image_path", "image_rect", "surface")
30
+
31
+ def __init__(
32
+ self,
33
+ image_path: str,
34
+ image_rect: tuple[int, int, int, int] | None,
35
+ surface: Surface | None,
36
+ ) -> None:
37
+ self.image_path = image_path
38
+ self.image_rect = image_rect
39
+ self.surface = surface
40
+
41
+
42
+ # Public return for get_object_animation (exposed, not internal ObjectAnimation)
43
+ class AnimData(TypedDict, total=True):
44
+ frames: List[Surface]
45
+ properties: Dict[str, object]
46
+ frame_duration_ms: float
47
+ loop: bool
48
+ animation_mode: Literal["default", "random_start_times"]
49
+ frame_w: int
50
+ frame_h: int
51
+
52
+
20
53
  class TilemapData:
21
54
  def __init__(
22
55
  self,
@@ -36,6 +69,7 @@ class TilemapData:
36
69
  self.origin_offset = (0, 0)
37
70
  self.area_nodes: List[AreaNode] = []
38
71
  self.particle_emitters: List[ParticleEmitterNode] = []
72
+ self.background_layer: Optional[BackgroundLayer] = None
39
73
  self._tw, self._th = parsed.meta.tile_size
40
74
  self._build_path_index()
41
75
  self._normalize_tile_ttypes()
@@ -117,6 +151,35 @@ class TilemapData:
117
151
  result.particle_emitters = [
118
152
  ParticleEmitterNode(n) for n in parsed.nodes if n.node_type == "particle_emitter"
119
153
  ]
154
+
155
+ for layer in parsed.layers:
156
+ if layer.image_path is not None and layer.layer_type == "image":
157
+ bg_path = _resolve_resource_path(layer.image_path, map_dir, extra_search_base)
158
+ bg_surface: Optional[Surface] = None
159
+ if bg_path.is_file():
160
+ try:
161
+ bg_surface = pygame.image.load(str(bg_path))
162
+ try:
163
+ bg_surface = bg_surface.convert_alpha()
164
+ except pygame.error:
165
+ pass
166
+ except pygame.error as e:
167
+ msg = f"Background layer image load failed: {e}"
168
+ warnings.append(msg)
169
+ if not skip_missing_images:
170
+ raise MapParseError(msg) from e
171
+ else:
172
+ msg = f"Background layer image not found: {layer.image_path!r} -> {bg_path}"
173
+ warnings.append(msg)
174
+ if not skip_missing_images:
175
+ raise MapParseError(msg)
176
+ result.background_layer = BackgroundLayer(
177
+ image_path=layer.image_path,
178
+ image_rect=layer.image_rect,
179
+ surface=bg_surface,
180
+ )
181
+ break
182
+
120
183
  return result
121
184
 
122
185
  def _build_path_index(self) -> None:
@@ -327,9 +390,98 @@ class TilemapData:
327
390
  "frame_stride": anim.frame_stride,
328
391
  "loop": anim.loop,
329
392
  "animation_mode": anim.animation_mode,
393
+ "frame_w": anim.frame_w,
394
+ "frame_h": anim.frame_h,
330
395
  }
331
396
  return None
332
397
 
398
+ def get_object_animation(self, obj: ParsedObject, render_scale: float = 1.0) -> Optional[AnimData]:
399
+ """Return effective object animation as ``AnimData`` or ``None``.
400
+
401
+ When per-object ``animation`` is ``None``, falls back to the object's
402
+ tileset ``ParsedTileset.animation`` (shared strip). ``render_scale``
403
+ scales both ``frames`` and ``frame_w/h`` in the returned dict
404
+ (default ``1.0`` leaves at source resolution). No ``Surface`` overload -
405
+ single dict with ``frames`` + ``properties`` + ``loop``/``duration``/``mode``/``w/h``.
406
+ """
407
+ if not isinstance(render_scale, (int, float)) or not math.isfinite(render_scale) or render_scale <= 0:
408
+ raise ValueError(f"render_scale must be finite and > 0, got {render_scale!r}")
409
+ # effective animation (per-object or tileset fallback) - internal ObjectAnimationData
410
+ anim_data: Optional[ObjectAnimation] = obj.animation # internal, keep frame_count
411
+ if anim_data is None:
412
+ t_anim = self.get_tileset_animation(obj.ttype)
413
+ if t_anim is None:
414
+ return None
415
+ anim_data = ObjectAnimation(
416
+ frame_count=t_anim["frame_count"],
417
+ frame_duration_ms=t_anim["frame_duration_ms"],
418
+ loop=t_anim.get("loop", True),
419
+ animation_mode=t_anim.get("animation_mode", "default"),
420
+ frames=[],
421
+ frame_stride=t_anim.get("frame_stride", 1),
422
+ frame_w=t_anim.get("frame_w"),
423
+ frame_h=t_anim.get("frame_h"),
424
+ )
425
+ if obj.ttype < 0 or obj.ttype >= len(self.surfaces):
426
+ return None
427
+ source = self.surfaces[obj.ttype]
428
+ if source is None:
429
+ return None
430
+ # frame dimensions - prefer animation's w/h, else area, else tile_size, scaled by render_scale
431
+ fw = getattr(anim_data, "frame_w", None)
432
+ if fw is None:
433
+ fw = obj.area.w if obj.area.w > 0 else self._tw
434
+ fh = getattr(anim_data, "frame_h", None)
435
+ if fh is None:
436
+ fh = obj.area.h if obj.area.h > 0 else self._th
437
+ if fw <= 0 or fh <= 0:
438
+ return None
439
+ # apply render_scale to dimensions
440
+ if render_scale != 1.0:
441
+ fw = int(fw * render_scale)
442
+ fh = int(fh * render_scale)
443
+ # cols based on original fw before scale for correct slicing
444
+ orig_fw = getattr(anim_data, "frame_w", None)
445
+ if orig_fw is None:
446
+ orig_fw = obj.area.w if obj.area.w > 0 else self._tw
447
+ if orig_fw is None or orig_fw <= 0:
448
+ orig_fw = fw
449
+ cols = max(1, source.get_width() // orig_fw) if render_scale != 1.0 else max(1, source.get_width() // fw)
450
+ if anim_data.frames:
451
+ frame_indices = anim_data.frames
452
+ else:
453
+ stride = getattr(anim_data, "frame_stride", 1)
454
+ if stride is None or stride <= 0:
455
+ stride = 1
456
+ if stride == 1:
457
+ frame_indices = list(range(anim_data.frame_count))
458
+ else:
459
+ frame_indices = [i * stride for i in range(anim_data.frame_count)]
460
+ frames: List[Surface] = []
461
+ for fi in frame_indices:
462
+ col = fi % cols
463
+ row = fi // cols
464
+ src_fw = getattr(anim_data, "frame_w", None) or (obj.area.w if obj.area.w > 0 else self._tw)
465
+ src_fh = getattr(anim_data, "frame_h", None) or (obj.area.h if obj.area.h > 0 else self._th)
466
+ src = Rect(col * src_fw, row * src_fh, src_fw, src_fh)
467
+ if not source.get_rect().contains(src):
468
+ surf = Surface((src_fw, src_fh), pygame.SRCALPHA)
469
+ else:
470
+ cell = source.subsurface(src)
471
+ surf = cell.copy()
472
+ if render_scale != 1.0:
473
+ surf = pygame.transform.scale(surf, (fw, fh))
474
+ frames.append(surf)
475
+ return {
476
+ "frames": frames,
477
+ "properties": dict(obj.properties) if obj.properties else {},
478
+ "frame_duration_ms": float(anim_data.frame_duration_ms),
479
+ "loop": bool(anim_data.loop),
480
+ "animation_mode": str(anim_data.animation_mode),
481
+ "frame_w": fw,
482
+ "frame_h": fh,
483
+ }
484
+
333
485
 
334
486
  def _variant_surface(
335
487
  surf: Surface,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 5.1.0
3
+ Version: 5.2.0
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime.
5
5
  Author: tilemap parser contributors
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -49,6 +49,7 @@ src/tilemap_parser/runtime/navigation/path_follower.py
49
49
  src/tilemap_parser/runtime/navigation/pathfinder.py
50
50
  src/tilemap_parser/utils/__init__.py
51
51
  src/tilemap_parser/utils/geometry.py
52
+ tests/test_background_layer.py
52
53
  tests/test_body.py
53
54
  tests/test_camera.py
54
55
  tests/test_collision.py
@@ -62,6 +63,7 @@ tests/test_map_loader.py
62
63
  tests/test_map_object.py
63
64
  tests/test_move_grounded.py
64
65
  tests/test_navigation.py
66
+ tests/test_object_animation.py
65
67
  tests/test_object_collision.py
66
68
  tests/test_object_surfaces.py
67
69
  tests/test_particle_emitter.py