tilemap-parser 4.2.7__tar.gz → 4.2.8__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 (55) hide show
  1. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/PKG-INFO +1 -1
  2. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/pyproject.toml +1 -1
  3. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/parser/map_parse.py +3 -0
  4. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/__init__.py +1 -0
  5. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/map_object.py +1 -1
  6. tilemap_parser-4.2.8/src/tilemap_parser/runtime/navigation/__init__.py +3 -0
  7. tilemap_parser-4.2.8/src/tilemap_parser/runtime/navigation/nav_grid.py +184 -0
  8. tilemap_parser-4.2.8/src/tilemap_parser/runtime/navigation/path_follower.py +76 -0
  9. tilemap_parser-4.2.8/src/tilemap_parser/runtime/navigation/pathfinder.py +75 -0
  10. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  11. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser.egg-info/SOURCES.txt +5 -0
  12. tilemap_parser-4.2.8/tests/test_navigation.py +317 -0
  13. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_object_surfaces.py +93 -6
  14. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/LICENSE +0 -0
  15. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/README.md +0 -0
  16. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/setup.cfg +0 -0
  17. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/__init__.py +0 -0
  18. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/parser/__init__.py +0 -0
  19. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/parser/animation.py +0 -0
  20. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/parser/collision.py +0 -0
  21. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/parser/collision_loader.py +0 -0
  22. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/parser/node_parse.py +0 -0
  23. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/parser/particle.py +0 -0
  24. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  25. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/animation_player.py +0 -0
  26. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/area_node.py +0 -0
  27. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/camera.py +0 -0
  28. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  29. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/map_loader.py +0 -0
  30. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/object_collision.py +0 -0
  31. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/particles.py +0 -0
  32. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/protocols.py +0 -0
  33. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/renderer.py +0 -0
  34. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  35. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/utils/__init__.py +0 -0
  36. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser/utils/geometry.py +0 -0
  37. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  38. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser.egg-info/requires.txt +0 -0
  39. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  40. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_camera.py +0 -0
  41. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_collision.py +0 -0
  42. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_geometry.py +0 -0
  43. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_gid_collision.py +0 -0
  44. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_integration_animation.py +0 -0
  45. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_integration_collision.py +0 -0
  46. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_integration_map_loader.py +0 -0
  47. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_map_loader.py +0 -0
  48. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_map_object.py +0 -0
  49. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_move_grounded.py +0 -0
  50. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_object_collision.py +0 -0
  51. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_particle_emitter.py +0 -0
  52. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_render_scale.py +0 -0
  53. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_tile_collision.py +0 -0
  54. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_tile_layer_renderer_y_sort.py +0 -0
  55. {tilemap_parser-4.2.7 → tilemap_parser-4.2.8}/tests/test_tmx_converter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.2.7
3
+ Version: 4.2.8
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 = "4.2.7"
7
+ version = "4.2.8"
8
8
  description = "Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -165,6 +165,7 @@ class ParsedLayer:
165
165
  tiles: Dict[Point, ParsedTile] = field(default_factory=dict)
166
166
  objects: Dict[int, ParsedObject] = field(default_factory=dict)
167
167
  next_object_id: Optional[int] = None
168
+ ttypes: set[int] = field(default_factory=set)
168
169
 
169
170
 
170
171
  @dataclass
@@ -301,6 +302,8 @@ def _parse_layer(layer_obj: JsonDict, layer_id: int, ctx: str) -> ParsedLayer:
301
302
  _require_dict(layer_obj.get("objects", {}), f"{ctx}.objects"),
302
303
  f"{ctx}.objects",
303
304
  )
305
+ for obj in layer.objects.values():
306
+ layer.ttypes.add(obj.ttype)
304
307
  if "next_object_id" in layer_obj and layer_obj["next_object_id"] is not None:
305
308
  layer.next_object_id = _coerce_int(
306
309
  layer_obj["next_object_id"], f"{ctx}.next_object_id"
@@ -28,6 +28,7 @@ from .object_collision import (
28
28
  )
29
29
  from .renderer import LayerRenderStats, TileLayerRenderer
30
30
  from .area_node import AreaNode
31
+ from . import navigation
31
32
  from .particles import (
32
33
  Particle,
33
34
  ParticleEmitter,
@@ -27,7 +27,7 @@ from .collision_cache import CollisionCache
27
27
  from .map_loader import TilemapData
28
28
 
29
29
 
30
- @final
30
+
31
31
  class MapObject:
32
32
  """
33
33
  A game object loaded from a tilemap, carrying a surface for
@@ -0,0 +1,3 @@
1
+ from .nav_grid import NavGrid
2
+ from .pathfinder import Pathfinder
3
+ from .path_follower import PathFollower
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Dict, List, Optional, Tuple
5
+
6
+ from ...parser.collision import TilesetCollision
7
+
8
+
9
+ class NavGrid:
10
+ """Walkability grid derived from tile collision data.
11
+
12
+ The base grid is a pure representation of the world — no entity size
13
+ or clearance baked in. Entity-specific clearance is layered on via
14
+ ``erode(margin)`` which returns a derived grid with walls inflated.
15
+ """
16
+
17
+ __slots__ = (
18
+ "tile_map",
19
+ "tileset_collision",
20
+ "tile_size",
21
+ "_eff_tw",
22
+ "_eff_th",
23
+ "_width",
24
+ "_height",
25
+ "_walkable",
26
+ )
27
+
28
+ def __init__(
29
+ self,
30
+ tile_map: Dict[Tuple[int, int], int],
31
+ tileset_collision: TilesetCollision,
32
+ tile_size: Tuple[int, int],
33
+ render_scale: float = 1.0,
34
+ map_size: Optional[Tuple[int, int]] = None,
35
+ ) -> None:
36
+ self.tile_map = tile_map
37
+ self.tileset_collision = tileset_collision
38
+ self.tile_size = tile_size
39
+ self._eff_tw = tile_size[0] * render_scale
40
+ self._eff_th = tile_size[1] * render_scale
41
+
42
+ if map_size is not None:
43
+ self._width, self._height = map_size
44
+ else:
45
+ self._width = 0
46
+ self._height = 0
47
+ for tx, ty in tile_map:
48
+ if tx >= self._width:
49
+ self._width = tx + 1
50
+ if ty >= self._height:
51
+ self._height = ty + 1
52
+
53
+ self._walkable = [[self._is_tile_walkable(x, y) for x in range(self._width)] for y in range(self._height)]
54
+
55
+ def _is_tile_walkable(self, tx: int, ty: int) -> bool:
56
+ tile_id = self.tile_map.get((tx, ty))
57
+ if tile_id is None:
58
+ return True
59
+ tile_data = self.tileset_collision.tiles.get(tile_id)
60
+ if tile_data is None:
61
+ return True
62
+ for poly in tile_data.shapes:
63
+ if poly.is_valid() and not poly.one_way:
64
+ return False
65
+ return True
66
+
67
+ def _in_bounds(self, tx: int, ty: int) -> bool:
68
+ return 0 <= tx < self._width and 0 <= ty < self._height
69
+
70
+ def is_solid(self, tx: int, ty: int) -> bool:
71
+ if not self._in_bounds(tx, ty):
72
+ return True
73
+ return not self._walkable[ty][tx]
74
+
75
+ def is_walkable(self, tx: int, ty: int) -> bool:
76
+ if not self._in_bounds(tx, ty):
77
+ return False
78
+ return self._walkable[ty][tx]
79
+
80
+ def is_one_way(self, tx: int, ty: int) -> bool:
81
+ tile_id = self.tile_map.get((tx, ty))
82
+ if tile_id is None:
83
+ return False
84
+ tile_data = self.tileset_collision.tiles.get(tile_id)
85
+ if tile_data is None:
86
+ return False
87
+ has_one_way = False
88
+ for poly in tile_data.shapes:
89
+ if not poly.is_valid():
90
+ continue
91
+ if poly.one_way:
92
+ has_one_way = True
93
+ else:
94
+ return False
95
+ return has_one_way
96
+
97
+ def copy(self) -> NavGrid:
98
+ new = NavGrid.__new__(NavGrid)
99
+ new.tile_map = self.tile_map
100
+ new.tileset_collision = self.tileset_collision
101
+ new.tile_size = self.tile_size
102
+ new._eff_tw = self._eff_tw
103
+ new._eff_th = self._eff_th
104
+ new._width = self._width
105
+ new._height = self._height
106
+ new._walkable = [row[:] for row in self._walkable]
107
+ return new
108
+
109
+ def erode(self, margin: float) -> NavGrid:
110
+ new = self.copy()
111
+ new._erode_in_place(margin)
112
+ return new
113
+
114
+ def _erode_in_place(self, margin: float) -> None:
115
+ original = [row[:] for row in self._walkable]
116
+ r = int(math.ceil(margin + 0.5))
117
+
118
+ for sy in range(self._height):
119
+ for sx in range(self._width):
120
+ if not original[sy][sx]:
121
+ continue
122
+ min_tx = max(0, sx - r)
123
+ max_tx = min(self._width - 1, sx + r)
124
+ min_ty = max(0, sy - r)
125
+ max_ty = min(self._height - 1, sy + r)
126
+ for ty in range(min_ty, max_ty + 1):
127
+ for tx in range(min_tx, max_tx + 1):
128
+ if original[ty][tx]:
129
+ continue
130
+ dx = abs(tx - sx)
131
+ dy = abs(ty - sy)
132
+ dist_x = max(0.0, dx - 0.5)
133
+ dist_y = max(0.0, dy - 0.5)
134
+ if dist_x == 0 and dist_y == 0:
135
+ dist = 0.0
136
+ elif dist_x == 0:
137
+ dist = dist_y
138
+ elif dist_y == 0:
139
+ dist = dist_x
140
+ else:
141
+ dist = math.hypot(dist_x, dist_y)
142
+ if dist <= margin:
143
+ self._walkable[sy][sx] = False
144
+ break
145
+ if not self._walkable[sy][sx]:
146
+ break
147
+
148
+ @classmethod
149
+ def for_entity(
150
+ cls,
151
+ tile_map: Dict[Tuple[int, int], int],
152
+ tileset_collision: TilesetCollision,
153
+ tile_size: Tuple[int, int],
154
+ sprite_width: float,
155
+ sprite_height: Optional[float] = None,
156
+ render_scale: float = 1.0,
157
+ map_size: Optional[Tuple[int, int]] = None,
158
+ cache: Optional[Dict[float, NavGrid]] = None,
159
+ ) -> NavGrid:
160
+ tw = tile_size[0] * render_scale
161
+ size = max(sprite_width, sprite_height if sprite_height is not None else sprite_width)
162
+ margin = (size / 2.0) / tw
163
+ if cache is not None and margin in cache:
164
+ return cache[margin]
165
+ nav = cls(tile_map, tileset_collision, tile_size, render_scale, map_size).erode(margin)
166
+ if cache is not None:
167
+ cache[margin] = nav
168
+ return nav
169
+
170
+ def get_neighbors(self, tx: int, ty: int, *, diagonals: bool = False) -> List[Tuple[int, int]]:
171
+ neighbors: List[Tuple[int, int]] = []
172
+ for dx, dy in [(0, -1), (0, 1), (-1, 0), (1, 0)]:
173
+ nx, ny = tx + dx, ty + dy
174
+ if self.is_walkable(nx, ny):
175
+ neighbors.append((nx, ny))
176
+ if diagonals:
177
+ for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
178
+ nx, ny = tx + dx, ty + dy
179
+ if not self.is_walkable(nx, ny):
180
+ continue
181
+ if not self.is_walkable(tx + dx, ty) or not self.is_walkable(tx, ty + dy):
182
+ continue
183
+ neighbors.append((nx, ny))
184
+ return neighbors
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import List, Optional, Tuple
5
+
6
+ from ...parser.collision import TilesetCollision
7
+ from ..protocols import ICollidable
8
+ from ..tile_collision import CollisionRunner
9
+
10
+
11
+ class PathFollower:
12
+ __slots__ = ("_eff_tw", "_eff_th", "arrival_distance")
13
+
14
+ def __init__(
15
+ self,
16
+ effective_tile_size: Tuple[float, float],
17
+ *,
18
+ arrival_distance: Optional[float] = None,
19
+ ) -> None:
20
+ self._eff_tw, self._eff_th = effective_tile_size
21
+ self.arrival_distance = (
22
+ arrival_distance
23
+ if arrival_distance is not None
24
+ else math.hypot(self._eff_tw, self._eff_th) * 0.2
25
+ )
26
+
27
+ def update_rpg(
28
+ self,
29
+ sprite: ICollidable,
30
+ path: List[Tuple[int, int]],
31
+ waypoint_index: int,
32
+ collision_runner: CollisionRunner,
33
+ tileset_collision: TilesetCollision,
34
+ tile_map: dict,
35
+ speed: float = 200.0,
36
+ dt: float = 0.016,
37
+ ) -> Tuple[int, bool, bool, bool]:
38
+ if not path or waypoint_index >= len(path):
39
+ return waypoint_index, True, False, False
40
+
41
+ while waypoint_index < len(path):
42
+ wx, wy = path[waypoint_index]
43
+ tx = wx * self._eff_tw + self._eff_tw * 0.5
44
+ ty = wy * self._eff_th + self._eff_th * 0.5
45
+ if math.hypot(tx - sprite.x, ty - sprite.y) < self.arrival_distance:
46
+ waypoint_index += 1
47
+ else:
48
+ break
49
+
50
+ if waypoint_index >= len(path):
51
+ return waypoint_index, True, False, False
52
+
53
+ wx, wy = path[waypoint_index]
54
+ tx = wx * self._eff_tw + self._eff_tw * 0.5
55
+ ty = wy * self._eff_th + self._eff_th * 0.5
56
+
57
+ dx = tx - sprite.x
58
+ dy = ty - sprite.y
59
+ dist = math.hypot(dx, dy)
60
+
61
+ if dist < 0.01:
62
+ delta_x = 0.0
63
+ delta_y = 0.0
64
+ else:
65
+ step = min(speed * dt, dist)
66
+ delta_x = (dx / dist) * step
67
+ delta_y = (dy / dist) * step
68
+
69
+ result = collision_runner.move_rpg(
70
+ sprite, tileset_collision, tile_map, delta_x, delta_y
71
+ )
72
+
73
+ if math.hypot(tx - sprite.x, ty - sprite.y) < self.arrival_distance:
74
+ waypoint_index += 1
75
+
76
+ return waypoint_index, waypoint_index >= len(path), result.hit_wall_x, result.hit_wall_y
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import heapq
4
+ from typing import Dict, List, Optional, Set, Tuple
5
+
6
+ from .nav_grid import NavGrid
7
+
8
+
9
+ class Pathfinder:
10
+ __slots__ = ("nav_grid",)
11
+
12
+ def __init__(self, nav_grid: NavGrid) -> None:
13
+ self.nav_grid = nav_grid
14
+
15
+ def find_path(
16
+ self,
17
+ start: Tuple[int, int],
18
+ end: Tuple[int, int],
19
+ max_steps: int = 2000,
20
+ ) -> Optional[List[Tuple[int, int]]]:
21
+ sx, sy = start
22
+ ex, ey = end
23
+
24
+ if not self.nav_grid.is_walkable(ex, ey):
25
+ return None
26
+
27
+ open_set: List[Tuple[float, int, int]] = [(0.0, sx, sy)]
28
+ came_from: Dict[Tuple[int, int], Tuple[int, int]] = {}
29
+ g_score: Dict[Tuple[int, int], float] = {start: 0.0}
30
+ f_score: Dict[Tuple[int, int], float] = {
31
+ start: _manhattan(sx, sy, ex, ey)
32
+ }
33
+
34
+ closed: Set[Tuple[int, int]] = set()
35
+ steps = 0
36
+ while open_set and steps < max_steps:
37
+ _, cx, cy = heapq.heappop(open_set)
38
+ current = (cx, cy)
39
+
40
+ if current in closed:
41
+ continue
42
+ closed.add(current)
43
+ steps += 1
44
+
45
+ if current == end:
46
+ return _reconstruct_path(came_from, current)
47
+
48
+ for neighbor in self.nav_grid.get_neighbors(cx, cy):
49
+ tentative = g_score[current] + 1.0
50
+ if tentative < g_score.get(neighbor, float("inf")):
51
+ came_from[neighbor] = current
52
+ g_score[neighbor] = tentative
53
+ f = tentative + _manhattan(
54
+ neighbor[0], neighbor[1], ex, ey
55
+ )
56
+ f_score[neighbor] = f
57
+ heapq.heappush(open_set, (f, neighbor[0], neighbor[1]))
58
+
59
+ return None
60
+
61
+
62
+ def _manhattan(ax: int, ay: int, bx: int, by: int) -> float:
63
+ return float(abs(ax - bx) + abs(ay - by))
64
+
65
+
66
+ def _reconstruct_path(
67
+ came_from: Dict[Tuple[int, int], Tuple[int, int]],
68
+ current: Tuple[int, int],
69
+ ) -> List[Tuple[int, int]]:
70
+ path = [current]
71
+ while current in came_from:
72
+ current = came_from[current]
73
+ path.append(current)
74
+ path.reverse()
75
+ return path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 4.2.7
3
+ Version: 4.2.8
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
@@ -27,6 +27,10 @@ src/tilemap_parser/runtime/particles.py
27
27
  src/tilemap_parser/runtime/protocols.py
28
28
  src/tilemap_parser/runtime/renderer.py
29
29
  src/tilemap_parser/runtime/tile_collision.py
30
+ src/tilemap_parser/runtime/navigation/__init__.py
31
+ src/tilemap_parser/runtime/navigation/nav_grid.py
32
+ src/tilemap_parser/runtime/navigation/path_follower.py
33
+ src/tilemap_parser/runtime/navigation/pathfinder.py
30
34
  src/tilemap_parser/utils/__init__.py
31
35
  src/tilemap_parser/utils/geometry.py
32
36
  tests/test_camera.py
@@ -39,6 +43,7 @@ tests/test_integration_map_loader.py
39
43
  tests/test_map_loader.py
40
44
  tests/test_map_object.py
41
45
  tests/test_move_grounded.py
46
+ tests/test_navigation.py
42
47
  tests/test_object_collision.py
43
48
  tests/test_object_surfaces.py
44
49
  tests/test_particle_emitter.py
@@ -0,0 +1,317 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import sys
6
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
7
+
8
+ import pytest
9
+
10
+ from tilemap_parser.parser.collision import (
11
+ CollisionPolygon,
12
+ RectangleShape,
13
+ TileCollisionData,
14
+ TilesetCollision,
15
+ )
16
+ from tilemap_parser.runtime.navigation import NavGrid, Pathfinder, PathFollower
17
+ from tilemap_parser.runtime.tile_collision import CollisionRunner
18
+
19
+ TILE_SIZE = 32
20
+ FULL_TILE = [(0.0, 0.0), (32.0, 0.0), (32.0, 32.0), (0.0, 32.0)]
21
+
22
+ MAP = [
23
+ [1, 1, 1, 1, 1, 1],
24
+ [1, 0, 0, 0, 0, 1],
25
+ [1, 0, 1, 0, 0, 1],
26
+ [1, 0, 0, 0, 0, 1],
27
+ [1, 0, 0, 0, 0, 1],
28
+ [1, 1, 1, 1, 1, 1],
29
+ ]
30
+
31
+ ROWS = len(MAP)
32
+ COLS = len(MAP[0])
33
+
34
+ START_TILE = (1, 2)
35
+ GOAL_TILE = (4, 4)
36
+
37
+
38
+ class MockSprite:
39
+ def __init__(self, x: float, y: float) -> None:
40
+ self.x = x
41
+ self.y = y
42
+ self.collision_shape = RectangleShape(width=18, height=26, offset=(-9, -13))
43
+
44
+
45
+ def build_tile_map() -> dict:
46
+ tile_map = {}
47
+ for r in range(ROWS):
48
+ for c in range(COLS):
49
+ if MAP[r][c] == 1:
50
+ tile_map[(c, r)] = 0
51
+ return tile_map
52
+
53
+
54
+ def build_tileset() -> TilesetCollision:
55
+ return TilesetCollision(
56
+ tileset_name="test",
57
+ tile_size=(TILE_SIZE, TILE_SIZE),
58
+ tiles={
59
+ 0: TileCollisionData(
60
+ tile_id=0,
61
+ shapes=[CollisionPolygon(vertices=FULL_TILE)],
62
+ ),
63
+ },
64
+ )
65
+
66
+
67
+ class TestNavigation:
68
+ def setup_method(self):
69
+ self.tile_map = build_tile_map()
70
+ self.tileset = build_tileset()
71
+ self.runner = CollisionRunner.from_game_type("rpg", (TILE_SIZE, TILE_SIZE))
72
+ self.nav_grid = NavGrid(self.tile_map, self.tileset, (TILE_SIZE, TILE_SIZE))
73
+ self.pathfinder = Pathfinder(self.nav_grid)
74
+ self.follower = PathFollower((TILE_SIZE, TILE_SIZE))
75
+
76
+ def test_path_exists_around_wall(self):
77
+ path = self.pathfinder.find_path(START_TILE, GOAL_TILE)
78
+ assert path is not None, "A* should find a path"
79
+ assert path[0] == START_TILE
80
+ assert path[-1] == GOAL_TILE
81
+ assert len(path) >= 3
82
+
83
+ def test_no_path_into_solid_wall(self):
84
+ path = self.pathfinder.find_path(START_TILE, (2, 2))
85
+ assert path is None
86
+
87
+ def test_no_path_outside_map(self):
88
+ path = self.pathfinder.find_path(START_TILE, (-1, -1))
89
+ assert path is None
90
+
91
+ def test_path_avoids_wall_tiles(self):
92
+ path = self.pathfinder.find_path(START_TILE, GOAL_TILE)
93
+ for tx, ty in path[1:-1]:
94
+ assert MAP[ty][tx] == 0, f"path goes through wall at ({tx}, {ty})"
95
+
96
+ def test_path_same_start_and_goal(self):
97
+ path = self.pathfinder.find_path((1, 1), (1, 1))
98
+ assert path == [(1, 1)]
99
+
100
+ def test_follower_reaches_goal(self):
101
+ path = self.pathfinder.find_path(START_TILE, GOAL_TILE)
102
+ assert path is not None
103
+
104
+ cx = START_TILE[0] * TILE_SIZE + TILE_SIZE * 0.5
105
+ cy = START_TILE[1] * TILE_SIZE + TILE_SIZE * 0.5
106
+ sprite = MockSprite(x=cx, y=cy)
107
+
108
+ waypoint_idx = 0
109
+ max_steps = 300
110
+ arrived = False
111
+
112
+ for _ in range(max_steps):
113
+ waypoint_idx, arrived, _, _ = self.follower.update_rpg(
114
+ sprite, path, waypoint_idx, self.runner,
115
+ self.tileset, self.tile_map, speed=150.0, dt=0.016,
116
+ )
117
+ if arrived:
118
+ break
119
+
120
+ assert arrived, (
121
+ f"sprite stuck at ({sprite.x:.1f}, {sprite.y:.1f}) "
122
+ f"waypoint {waypoint_idx}/{len(path)}"
123
+ )
124
+
125
+ gx = GOAL_TILE[0] * TILE_SIZE + TILE_SIZE * 0.5
126
+ gy = GOAL_TILE[1] * TILE_SIZE + TILE_SIZE * 0.5
127
+ dx = gx - sprite.x
128
+ dy = gy - sprite.y
129
+ assert (dx * dx + dy * dy) ** 0.5 < TILE_SIZE * 0.5, "sprite should be near goal tile"
130
+
131
+ def test_follower_stops_at_end_of_path(self):
132
+ path = self.pathfinder.find_path(START_TILE, GOAL_TILE)
133
+ assert path is not None
134
+
135
+ cx = START_TILE[0] * TILE_SIZE + TILE_SIZE * 0.5
136
+ cy = START_TILE[1] * TILE_SIZE + TILE_SIZE * 0.5
137
+ sprite = MockSprite(x=cx, y=cy)
138
+
139
+ _, arrived, _, _ = self.follower.update_rpg(
140
+ sprite, [], 0, self.runner,
141
+ self.tileset, self.tile_map,
142
+ )
143
+ assert arrived is True
144
+
145
+ _, arrived, _, _ = self.follower.update_rpg(
146
+ sprite, path, len(path), self.runner,
147
+ self.tileset, self.tile_map,
148
+ )
149
+ assert arrived is True
150
+
151
+ def test_nav_grid_walkable_tiles(self):
152
+ assert self.nav_grid.is_walkable(1, 1) is True
153
+ assert self.nav_grid.is_walkable(0, 0) is False
154
+ assert self.nav_grid.is_walkable(2, 2) is False
155
+ assert self.nav_grid.is_walkable(4, 4) is True
156
+
157
+ def test_nav_grid_solid(self):
158
+ assert self.nav_grid.is_solid(0, 0) is True
159
+ assert self.nav_grid.is_solid(1, 1) is False
160
+
161
+ def test_out_of_bounds_not_walkable(self):
162
+ assert self.nav_grid.is_walkable(-1, 0) is False
163
+ assert self.nav_grid.is_walkable(100, 100) is False
164
+
165
+ def test_out_of_bounds_solid(self):
166
+ assert self.nav_grid.is_solid(-1, 0) is True
167
+ assert self.nav_grid.is_solid(100, 100) is True
168
+
169
+ def test_get_neighbors_no_diagonals(self):
170
+ neighbors = self.nav_grid.get_neighbors(3, 3)
171
+ expected = {(3, 2), (3, 4), (2, 3), (4, 3)}
172
+ assert set(neighbors) == expected
173
+
174
+ def test_get_neighbors_with_diagonals(self):
175
+ neighbors = self.nav_grid.get_neighbors(3, 3, diagonals=True)
176
+ for nx, ny in neighbors:
177
+ assert self.nav_grid.is_walkable(nx, ny), f"({nx}, {ny}) not walkable"
178
+ assert (2, 2) not in neighbors
179
+ assert (3, 2) in neighbors
180
+ assert (4, 3) in neighbors
181
+
182
+ def test_neighbor_near_wall_excludes_wall(self):
183
+ neighbors = self.nav_grid.get_neighbors(2, 1)
184
+ for nx, ny in neighbors:
185
+ assert self.nav_grid.is_walkable(nx, ny), f"neighbor ({nx}, {ny}) is wall"
186
+
187
+
188
+ class TestNavGridErosion:
189
+ def setup_method(self):
190
+ self.tile_map = build_tile_map()
191
+ self.tileset = build_tileset()
192
+ self.base = NavGrid(self.tile_map, self.tileset, (TILE_SIZE, TILE_SIZE))
193
+
194
+ def test_erosion_blocks_adjacent(self):
195
+ eroded = self.base.erode(1.0)
196
+ assert eroded.is_solid(0, 0) is True
197
+ assert eroded.is_solid(1, 1) is True
198
+ assert eroded.is_solid(1, 2) is True
199
+ assert eroded.is_solid(2, 1) is True
200
+
201
+ def test_erosion_does_not_affect_base(self):
202
+ self.base.erode(1.0)
203
+ assert self.base.is_walkable(1, 1) is True
204
+
205
+ def test_erosion_margin_half(self):
206
+ eroded = self.base.erode(0.5)
207
+ assert eroded.is_walkable(1, 1) is False
208
+ assert eroded.is_walkable(3, 3) is True
209
+
210
+ def test_copy_is_independent(self):
211
+ copy = self.base.copy()
212
+ assert copy.is_walkable(1, 1) is True
213
+ eroded = copy.erode(1.0)
214
+ assert copy.is_walkable(1, 1) is True
215
+ assert eroded.is_walkable(1, 1) is False
216
+
217
+ def test_for_entity_small_vs_large(self):
218
+ small = NavGrid.for_entity(
219
+ self.tile_map, self.tileset, (TILE_SIZE, TILE_SIZE), sprite_width=16
220
+ )
221
+ large = NavGrid.for_entity(
222
+ self.tile_map, self.tileset, (TILE_SIZE, TILE_SIZE), sprite_width=64
223
+ )
224
+ assert small.is_walkable(3, 3) is True
225
+ assert large.is_walkable(3, 3) is False
226
+
227
+ def test_for_entity_caching(self):
228
+ cache: dict = {}
229
+ a = NavGrid.for_entity(
230
+ self.tile_map, self.tileset, (TILE_SIZE, TILE_SIZE),
231
+ sprite_width=32, cache=cache,
232
+ )
233
+ b = NavGrid.for_entity(
234
+ self.tile_map, self.tileset, (TILE_SIZE, TILE_SIZE),
235
+ sprite_width=32, cache=cache,
236
+ )
237
+ assert a is b
238
+
239
+ def test_erosion_blocks_narrow_gap_no_alternate(self):
240
+ w, h = 6, 6
241
+ tight_map: dict[tuple[int, int], int] = {}
242
+ for x in range(w):
243
+ for y in range(h):
244
+ if x == 0 or x == w - 1 or y == 0 or y == h - 1:
245
+ continue
246
+ if x == 3 and y != 3:
247
+ tight_map[(x, y)] = 0
248
+ ts = TilesetCollision(
249
+ tileset_name="test",
250
+ tile_size=(TILE_SIZE, TILE_SIZE),
251
+ tiles={
252
+ 0: TileCollisionData(
253
+ tile_id=0,
254
+ shapes=[CollisionPolygon(vertices=FULL_TILE)],
255
+ ),
256
+ },
257
+ )
258
+ base = NavGrid(tight_map, ts, (TILE_SIZE, TILE_SIZE), map_size=(w, h))
259
+ pf = Pathfinder(base)
260
+ path = pf.find_path((1, 1), (4, 4))
261
+ assert path is not None
262
+ assert (3, 3) in path
263
+
264
+ eroded = base.erode(1.0)
265
+ pf_eroded = Pathfinder(eroded)
266
+ path_eroded = pf_eroded.find_path((1, 1), (4, 4))
267
+ assert path_eroded is None
268
+
269
+
270
+ class TestPathfinderClosedSet:
271
+ def setup_method(self):
272
+ self.tile_map = build_tile_map()
273
+ self.tileset = build_tileset()
274
+ self.nav_grid = NavGrid(self.tile_map, self.tileset, (TILE_SIZE, TILE_SIZE))
275
+ self.pathfinder = Pathfinder(self.nav_grid)
276
+
277
+ def test_path_found_with_closed_set(self):
278
+ path = self.pathfinder.find_path(START_TILE, GOAL_TILE)
279
+ assert path is not None
280
+ assert path[0] == START_TILE
281
+ assert path[-1] == GOAL_TILE
282
+
283
+ def test_no_path_with_closed_set(self):
284
+ path = self.pathfinder.find_path(START_TILE, (2, 2))
285
+ assert path is None
286
+
287
+
288
+ class TestPathFollowerStalling:
289
+ def setup_method(self):
290
+ self.tile_map = build_tile_map()
291
+ self.tileset = build_tileset()
292
+ self.runner = CollisionRunner.from_game_type("rpg", (TILE_SIZE, TILE_SIZE))
293
+ self.follower = PathFollower((TILE_SIZE, TILE_SIZE))
294
+
295
+ def test_update_rpg_returns_stall_info(self):
296
+ path = [(2, 2)]
297
+ sprite = MockSprite(x=5 * TILE_SIZE, y=5 * TILE_SIZE)
298
+ idx, done, hwx, hwy = self.follower.update_rpg(
299
+ sprite, path, 0, self.runner,
300
+ self.tileset, self.tile_map, speed=150.0, dt=0.016,
301
+ )
302
+ assert isinstance(hwx, bool)
303
+ assert isinstance(hwy, bool)
304
+
305
+ def test_update_rpg_arrived_has_no_stall(self):
306
+ path = [(2, 2)]
307
+ sprite = MockSprite(
308
+ x=2 * TILE_SIZE + TILE_SIZE * 0.5,
309
+ y=2 * TILE_SIZE + TILE_SIZE * 0.5,
310
+ )
311
+ _, done, hwx, hwy = self.follower.update_rpg(
312
+ sprite, path, 0, self.runner,
313
+ self.tileset, self.tile_map, speed=150.0, dt=0.016,
314
+ )
315
+ assert done is True
316
+ assert hwx is False
317
+ assert hwy is False
@@ -1,16 +1,14 @@
1
1
  import json
2
+ import sys
2
3
  import tempfile
3
4
  from pathlib import Path
4
5
 
5
6
  import pygame
6
7
  import pytest
7
8
 
8
- import sys
9
9
  sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
10
-
11
10
  from tilemap_parser.runtime.map_loader import TilemapData
12
- from tilemap_parser.parser.map_parse import ParsedObject, ParsedObjectArea
13
-
11
+ from tilemap_parser.parser.map_parse import ParsedObject, ParsedObjectArea, parse_map_dict
14
12
 
15
13
  MINIMAL_MAP_META = {
16
14
  "tile_size": "16;16",
@@ -188,5 +186,94 @@ class TestObjectSurfaces:
188
186
  tileset_type="object",
189
187
  variant=0,
190
188
  )
191
- surf = td.get_object_surface(obj)
192
- assert surf is None
189
+
190
+
191
+ class TestParsedLayerTtypes:
192
+ def test_object_layer_ttypes_after_parse(self):
193
+ payload = {
194
+ "meta": {"tile_size": "16;16", "map_size": "10;10", "version": "1.1"},
195
+ "resources": {"tilesets": [{"path": "a.png", "type": "tile"}, {"path": "b.png", "type": "tile"}]},
196
+ "project_state": {"rules": [], "groups": []},
197
+ "data": {
198
+ "layers": [
199
+ {
200
+ "name": "Objs",
201
+ "type": "object",
202
+ "visible": True,
203
+ "locked": False,
204
+ "opacity": 1.0,
205
+ "z_index": 0,
206
+ "properties": {},
207
+ "tiles": {},
208
+ "objects": {
209
+ "1": {
210
+ "area": {"x": 0, "y": 0, "w": 16, "h": 16},
211
+ "ttype": 0,
212
+ "tileset_type": "object",
213
+ "variant": 0,
214
+ "properties": {},
215
+ },
216
+ "2": {
217
+ "area": {"x": 0, "y": 0, "w": 16, "h": 16},
218
+ "ttype": 1,
219
+ "tileset_type": "object",
220
+ "variant": 0,
221
+ "properties": {},
222
+ },
223
+ "3": {
224
+ "area": {"x": 0, "y": 0, "w": 16, "h": 16},
225
+ "ttype": 0,
226
+ "tileset_type": "object",
227
+ "variant": 1,
228
+ "properties": {},
229
+ },
230
+ },
231
+ },
232
+ {
233
+ "name": "Tiles",
234
+ "type": "tile",
235
+ "visible": True,
236
+ "locked": False,
237
+ "opacity": 1.0,
238
+ "z_index": 1,
239
+ "tiles": {},
240
+ },
241
+ ],
242
+ },
243
+ }
244
+ parsed = parse_map_dict(payload)
245
+ obj_layer = parsed.layers[0]
246
+ tile_layer = parsed.layers[1]
247
+
248
+ assert obj_layer.ttypes == {0, 1}
249
+ assert tile_layer.ttypes == set()
250
+
251
+ def test_object_layer_empty_ttypes(self):
252
+ payload = {
253
+ "meta": {"tile_size": "16;16", "map_size": "10;10", "version": "1.1"},
254
+ "resources": {"tilesets": []},
255
+ "project_state": {"rules": [], "groups": []},
256
+ "data": {
257
+ "layers": [
258
+ {
259
+ "name": "Empty",
260
+ "type": "object",
261
+ "visible": True,
262
+ "locked": False,
263
+ "opacity": 1.0,
264
+ "z_index": 0,
265
+ "properties": {},
266
+ "tiles": {},
267
+ "objects": {},
268
+ }
269
+ ],
270
+ },
271
+ }
272
+ parsed = parse_map_dict(payload)
273
+ assert parsed.layers[0].ttypes == set()
274
+
275
+ def test_ttypes_preserved_through_tilemap_data(self, map_data):
276
+ td, *_ = map_data
277
+ layer = td.get_layer("Object Layer 1")
278
+ assert layer is not None
279
+ assert layer.ttypes == {0}
File without changes
File without changes
File without changes