tilemap-parser 3.0.0__tar.gz → 3.1.1__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.
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/PKG-INFO +1 -1
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/pyproject.toml +1 -1
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/parser/collision.py +4 -4
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/parser/map_parse.py +2 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/map_loader.py +4 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/object_collision.py +25 -1
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/renderer.py +15 -7
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/tile_collision.py +50 -41
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser.egg-info/SOURCES.txt +2 -0
- tilemap_parser-3.1.1/tests/test_map_loader.py +122 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/tests/test_object_collision.py +67 -0
- tilemap_parser-3.1.1/tests/test_render_scale.py +424 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/LICENSE +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/README.md +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/setup.cfg +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/__init__.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/parser/__init__.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/parser/animation.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/parser/collision_loader.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/__init__.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/animation_player.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/collision_cache.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/utils/__init__.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/utils/geometry.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser.egg-info/requires.txt +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser.egg-info/top_level.txt +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/tests/test_collision.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/tests/test_geometry.py +0 -0
- {tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/tests/test_tile_collision.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tilemap-parser
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.1
|
|
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 = "3.
|
|
7
|
+
version = "3.1.1"
|
|
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"
|
|
@@ -29,9 +29,9 @@ class CollisionPolygon:
|
|
|
29
29
|
vertices: List[Point]
|
|
30
30
|
one_way: bool = False
|
|
31
31
|
|
|
32
|
-
def transform(self, tile_x: float, tile_y: float) -> "CollisionPolygon":
|
|
32
|
+
def transform(self, tile_x: float, tile_y: float, scale: float = 1.0) -> "CollisionPolygon":
|
|
33
33
|
"""Transform polygon to world space coordinates"""
|
|
34
|
-
world_vertices = [(tile_x + vx, tile_y + vy) for vx, vy in self.vertices]
|
|
34
|
+
world_vertices = [(tile_x + vx * scale, tile_y + vy * scale) for vx, vy in self.vertices]
|
|
35
35
|
return CollisionPolygon(vertices=world_vertices, one_way=self.one_way)
|
|
36
36
|
|
|
37
37
|
def is_valid(self) -> bool:
|
|
@@ -69,13 +69,13 @@ class TilesetCollision:
|
|
|
69
69
|
return tile_data is not None and tile_data.has_collision()
|
|
70
70
|
|
|
71
71
|
def get_world_shapes(
|
|
72
|
-
self, tile_id: int, tile_x: float, tile_y: float
|
|
72
|
+
self, tile_id: int, tile_x: float, tile_y: float, scale: float = 1.0
|
|
73
73
|
) -> List[CollisionPolygon]:
|
|
74
74
|
"""Get collision shapes transformed to world space"""
|
|
75
75
|
tile_data = self.get_tile_collision(tile_id)
|
|
76
76
|
if not tile_data:
|
|
77
77
|
return []
|
|
78
|
-
return [shape.transform(tile_x, tile_y) for shape in tile_data.shapes]
|
|
78
|
+
return [shape.transform(tile_x, tile_y, scale) for shape in tile_data.shapes]
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
@dataclass
|
|
@@ -170,6 +170,7 @@ class ParsedMeta:
|
|
|
170
170
|
zoom_level: float
|
|
171
171
|
scroll: Point
|
|
172
172
|
version: str
|
|
173
|
+
render_scale: float = 1.0
|
|
173
174
|
|
|
174
175
|
|
|
175
176
|
@dataclass
|
|
@@ -394,6 +395,7 @@ def parse_map_dict(root: JsonDict) -> ParsedMap:
|
|
|
394
395
|
zoom_level=_coerce_float(meta_obj.get("zoom_level", 1.0), "meta.zoom_level"),
|
|
395
396
|
scroll=_parse_point_field(meta_obj.get("scroll"), "meta.scroll", default="0;0"),
|
|
396
397
|
version=_require_str(meta_obj.get("version", "1.1"), "meta.version"),
|
|
398
|
+
render_scale=_coerce_float(meta_obj.get("render_scale", 1.0), "meta.render_scale"),
|
|
397
399
|
)
|
|
398
400
|
|
|
399
401
|
data_obj = _require_dict(root.get("data"), "data")
|
|
@@ -114,6 +114,10 @@ class TilemapData:
|
|
|
114
114
|
def map_size(self) -> Tuple[int, int]:
|
|
115
115
|
return self.parsed.meta.map_size
|
|
116
116
|
|
|
117
|
+
@property
|
|
118
|
+
def render_scale(self) -> float:
|
|
119
|
+
return self.parsed.meta.render_scale
|
|
120
|
+
|
|
117
121
|
def get_raw(self) -> dict:
|
|
118
122
|
return deepcopy(self.parsed.raw)
|
|
119
123
|
|
{tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/object_collision.py
RENAMED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object-to-object collision detection runtime.
|
|
3
3
|
|
|
4
4
|
Provides a protocol-based interface, shape dispatch, layer filtering,
|
|
5
|
-
a multi-object manager, and collision hit helpers for separation/resolve.
|
|
5
|
+
a multi-object manager, and collision hit helpers for separation/resolve/slide.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -72,6 +72,30 @@ class CollisionHit:
|
|
|
72
72
|
self.object_b.x += sep_x
|
|
73
73
|
self.object_b.y += sep_y
|
|
74
74
|
|
|
75
|
+
def slide_velocity(self, vx: float, vy: float) -> tuple[float, float]:
|
|
76
|
+
"""Project velocity along the collision surface (slide response).
|
|
77
|
+
|
|
78
|
+
Removes the component of (vx, vy) that is along *self.normal*,
|
|
79
|
+
leaving only the tangential component. Intended for the moving
|
|
80
|
+
object passed as *object_a* — when that object moves into
|
|
81
|
+
*object_b* the approach component is stripped so the object slides
|
|
82
|
+
along the surface instead of penetrating.
|
|
83
|
+
|
|
84
|
+
If the velocity is already parallel to the surface or points away
|
|
85
|
+
from *object_b* the original velocity is returned unchanged.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
vx: X component of velocity (object_a's velocity)
|
|
89
|
+
vy: Y component of velocity
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
(slide_x, slide_y) — velocity projected onto the surface
|
|
93
|
+
"""
|
|
94
|
+
dot = vx * self.normal[0] + vy * self.normal[1]
|
|
95
|
+
if dot > 0:
|
|
96
|
+
return (vx - self.normal[0] * dot, vy - self.normal[1] * dot)
|
|
97
|
+
return (vx, vy)
|
|
98
|
+
|
|
75
99
|
def involves(self, obj: ICollidableObject) -> bool:
|
|
76
100
|
"""Check if this hit involves the given object."""
|
|
77
101
|
return self.object_a is obj or self.object_b is obj
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from typing import Dict, Optional, Tuple, Union
|
|
5
5
|
|
|
6
|
-
from pygame import Rect, Surface
|
|
6
|
+
from pygame import Rect, Surface, transform
|
|
7
7
|
|
|
8
8
|
from .map_loader import TilemapData
|
|
9
9
|
|
|
@@ -29,6 +29,11 @@ class TileLayerRenderer:
|
|
|
29
29
|
)
|
|
30
30
|
self._variant_cache: Dict[Tuple[int, int], Optional[Surface]] = {}
|
|
31
31
|
self._tile_w, self._tile_h = data.tile_size
|
|
32
|
+
self._rs = data.render_scale
|
|
33
|
+
if self._rs <= 0:
|
|
34
|
+
raise ValueError(f"render_scale must be positive, got {self._rs}")
|
|
35
|
+
self._eff_w = int(self._tile_w * self._rs)
|
|
36
|
+
self._eff_h = int(self._tile_h * self._rs)
|
|
32
37
|
|
|
33
38
|
def get_layer_dict(self) -> Dict[int, object]:
|
|
34
39
|
return dict(self.tile_layers)
|
|
@@ -36,9 +41,12 @@ class TileLayerRenderer:
|
|
|
36
41
|
def _get_cached_variant(self, ttype: int, variant: int) -> Optional[Surface]:
|
|
37
42
|
key = (ttype, variant)
|
|
38
43
|
if key not in self._variant_cache:
|
|
39
|
-
|
|
44
|
+
cell = self.data.get_tile_surface(
|
|
40
45
|
ttype, variant, copy_surface=True
|
|
41
46
|
)
|
|
47
|
+
if cell is not None and self._rs != 1.0:
|
|
48
|
+
cell = transform.scale(cell, (self._eff_w, self._eff_h))
|
|
49
|
+
self._variant_cache[key] = cell
|
|
42
50
|
return self._variant_cache[key]
|
|
43
51
|
|
|
44
52
|
def warm_cache(self) -> None:
|
|
@@ -61,10 +69,10 @@ class TileLayerRenderer:
|
|
|
61
69
|
else:
|
|
62
70
|
viewport = Rect(0, 0, viewport_size[0], viewport_size[1])
|
|
63
71
|
|
|
64
|
-
min_x = int(cam_x // self.
|
|
65
|
-
max_x = int((cam_x + viewport.width) // self.
|
|
66
|
-
min_y = int(cam_y // self.
|
|
67
|
-
max_y = int((cam_y + viewport.height) // self.
|
|
72
|
+
min_x = int(cam_x // self._eff_w) - 1
|
|
73
|
+
max_x = int((cam_x + viewport.width) // self._eff_w) + 1
|
|
74
|
+
min_y = int(cam_y // self._eff_h) - 1
|
|
75
|
+
max_y = int((cam_y + viewport.height) // self._eff_h) + 1
|
|
68
76
|
|
|
69
77
|
drawn = 0
|
|
70
78
|
skipped = 0
|
|
@@ -86,7 +94,7 @@ class TileLayerRenderer:
|
|
|
86
94
|
if cell is None:
|
|
87
95
|
skipped += 1
|
|
88
96
|
continue
|
|
89
|
-
target.blit(cell, (x * self.
|
|
97
|
+
target.blit(cell, (x * self._eff_w - cam_x, y * self._eff_h - cam_y))
|
|
90
98
|
drawn += 1
|
|
91
99
|
|
|
92
100
|
return LayerRenderStats(
|
|
@@ -71,14 +71,14 @@ def point_in_polygon(point: Point, vertices: List[Point]) -> bool:
|
|
|
71
71
|
return inside
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
def _point_in_polygon_offset(px: float, py: float, vertices: List[Point], ox: float, oy: float) -> bool:
|
|
74
|
+
def _point_in_polygon_offset(px: float, py: float, vertices: List[Point], ox: float, oy: float, scale: float = 1.0) -> bool:
|
|
75
75
|
"""Ray-cast with tile offset applied inline — no allocation."""
|
|
76
76
|
n = len(vertices)
|
|
77
77
|
inside = False
|
|
78
|
-
p1x, p1y = vertices[0][0] + ox, vertices[0][1] + oy
|
|
78
|
+
p1x, p1y = vertices[0][0] * scale + ox, vertices[0][1] * scale + oy
|
|
79
79
|
for i in range(1, n + 1):
|
|
80
80
|
vx, vy = vertices[i % n]
|
|
81
|
-
p2x, p2y = vx + ox, vy + oy
|
|
81
|
+
p2x, p2y = vx * scale + ox, vy * scale + oy
|
|
82
82
|
if py > min(p1y, p2y):
|
|
83
83
|
if py <= max(p1y, p2y):
|
|
84
84
|
if px <= max(p1x, p2x):
|
|
@@ -123,16 +123,16 @@ def rect_polygon_collision(
|
|
|
123
123
|
|
|
124
124
|
def _rect_polygon_collision_offset(
|
|
125
125
|
rect_x: float, rect_y: float, rect_w: float, rect_h: float,
|
|
126
|
-
vertices: List[Point], ox: float, oy: float,
|
|
126
|
+
vertices: List[Point], ox: float, oy: float, scale: float = 1.0,
|
|
127
127
|
) -> bool:
|
|
128
128
|
"""Rectangle vs polygon with tile offset applied inline — no allocation."""
|
|
129
129
|
# AABB pre-reject with offset
|
|
130
130
|
n = len(vertices)
|
|
131
|
-
v0x, v0y = vertices[0][0] + ox, vertices[0][1] + oy
|
|
131
|
+
v0x, v0y = vertices[0][0] * scale + ox, vertices[0][1] * scale + oy
|
|
132
132
|
min_vx = max_vx = v0x
|
|
133
133
|
min_vy = max_vy = v0y
|
|
134
134
|
for i in range(1, n):
|
|
135
|
-
wx, wy = vertices[i][0] + ox, vertices[i][1] + oy
|
|
135
|
+
wx, wy = vertices[i][0] * scale + ox, vertices[i][1] * scale + oy
|
|
136
136
|
if wx < min_vx: min_vx = wx
|
|
137
137
|
elif wx > max_vx: max_vx = wx
|
|
138
138
|
if wy < min_vy: min_vy = wy
|
|
@@ -142,14 +142,14 @@ def _rect_polygon_collision_offset(
|
|
|
142
142
|
|
|
143
143
|
# Corner tests
|
|
144
144
|
rx2, ry2 = rect_x + rect_w, rect_y + rect_h
|
|
145
|
-
if _point_in_polygon_offset(rect_x, rect_y, vertices, ox, oy): return True
|
|
146
|
-
if _point_in_polygon_offset(rx2, rect_y, vertices, ox, oy): return True
|
|
147
|
-
if _point_in_polygon_offset(rect_x, ry2, vertices, ox, oy): return True
|
|
148
|
-
if _point_in_polygon_offset(rx2, ry2, vertices, ox, oy): return True
|
|
145
|
+
if _point_in_polygon_offset(rect_x, rect_y, vertices, ox, oy, scale): return True
|
|
146
|
+
if _point_in_polygon_offset(rx2, rect_y, vertices, ox, oy, scale): return True
|
|
147
|
+
if _point_in_polygon_offset(rect_x, ry2, vertices, ox, oy, scale): return True
|
|
148
|
+
if _point_in_polygon_offset(rx2, ry2, vertices, ox, oy, scale): return True
|
|
149
149
|
|
|
150
150
|
# Vertex-in-rect
|
|
151
151
|
for vx, vy in vertices:
|
|
152
|
-
wx, wy = vx + ox, vy + oy
|
|
152
|
+
wx, wy = vx * scale + ox, vy * scale + oy
|
|
153
153
|
if rect_x <= wx <= rx2 and rect_y <= wy <= ry2:
|
|
154
154
|
return True
|
|
155
155
|
return False
|
|
@@ -184,15 +184,15 @@ def circle_polygon_collision(
|
|
|
184
184
|
|
|
185
185
|
|
|
186
186
|
def _circle_polygon_collision_offset(
|
|
187
|
-
cx: float, cy: float, radius: float, vertices: List[Point], ox: float, oy: float,
|
|
187
|
+
cx: float, cy: float, radius: float, vertices: List[Point], ox: float, oy: float, scale: float = 1.0,
|
|
188
188
|
) -> bool:
|
|
189
189
|
"""Circle vs polygon with tile offset applied inline — no allocation."""
|
|
190
|
-
if _point_in_polygon_offset(cx, cy, vertices, ox, oy):
|
|
190
|
+
if _point_in_polygon_offset(cx, cy, vertices, ox, oy, scale):
|
|
191
191
|
return True
|
|
192
192
|
n = len(vertices)
|
|
193
193
|
for i in range(n):
|
|
194
|
-
x1, y1 = vertices[i][0] + ox, vertices[i][1] + oy
|
|
195
|
-
x2, y2 = vertices[(i + 1) % n][0] + ox, vertices[(i + 1) % n][1] + oy
|
|
194
|
+
x1, y1 = vertices[i][0] * scale + ox, vertices[i][1] * scale + oy
|
|
195
|
+
x2, y2 = vertices[(i + 1) % n][0] * scale + ox, vertices[(i + 1) % n][1] * scale + oy
|
|
196
196
|
dx = x2 - x1
|
|
197
197
|
dy = y2 - y1
|
|
198
198
|
fx = cx - x1
|
|
@@ -246,7 +246,7 @@ def check_sprite_polygon_collision(
|
|
|
246
246
|
|
|
247
247
|
|
|
248
248
|
def _check_sprite_polygon_offset(
|
|
249
|
-
sprite: ICollidableSprite, polygon: CollisionPolygon, ox: float, oy: float
|
|
249
|
+
sprite: ICollidableSprite, polygon: CollisionPolygon, ox: float, oy: float, scale: float = 1.0
|
|
250
250
|
) -> bool:
|
|
251
251
|
"""
|
|
252
252
|
Check if sprite collides with a tile-local polygon at world offset (ox, oy).
|
|
@@ -256,17 +256,17 @@ def _check_sprite_polygon_offset(
|
|
|
256
256
|
if isinstance(shape, RectangleShape):
|
|
257
257
|
left = sprite.x + shape.offset[0]
|
|
258
258
|
top = sprite.y + shape.offset[1]
|
|
259
|
-
return _rect_polygon_collision_offset(left, top, shape.width, shape.height, polygon.vertices, ox, oy)
|
|
259
|
+
return _rect_polygon_collision_offset(left, top, shape.width, shape.height, polygon.vertices, ox, oy, scale)
|
|
260
260
|
elif isinstance(shape, CircleShape):
|
|
261
261
|
cx = sprite.x + shape.offset[0]
|
|
262
262
|
cy = sprite.y + shape.offset[1]
|
|
263
|
-
return _circle_polygon_collision_offset(cx, cy, shape.radius, polygon.vertices, ox, oy)
|
|
263
|
+
return _circle_polygon_collision_offset(cx, cy, shape.radius, polygon.vertices, ox, oy, scale)
|
|
264
264
|
elif isinstance(shape, CapsuleShape):
|
|
265
265
|
left = sprite.x + shape.offset[0] - shape.radius
|
|
266
266
|
top = sprite.y + shape.offset[1]
|
|
267
267
|
w = shape.radius * 2
|
|
268
268
|
h = shape.height + shape.radius * 2
|
|
269
|
-
return _rect_polygon_collision_offset(left, top, w, h, polygon.vertices, ox, oy)
|
|
269
|
+
return _rect_polygon_collision_offset(left, top, w, h, polygon.vertices, ox, oy, scale)
|
|
270
270
|
return False
|
|
271
271
|
|
|
272
272
|
|
|
@@ -304,6 +304,7 @@ class CollisionRunner:
|
|
|
304
304
|
self,
|
|
305
305
|
tile_size: Tuple[int, int] = (32, 32),
|
|
306
306
|
mode: MovementMode = MovementMode.SLIDE,
|
|
307
|
+
render_scale: float = 1.0,
|
|
307
308
|
):
|
|
308
309
|
"""
|
|
309
310
|
Initialize collision runner.
|
|
@@ -314,9 +315,15 @@ class CollisionRunner:
|
|
|
314
315
|
Args:
|
|
315
316
|
tile_size: Size of tiles in pixels (width, height)
|
|
316
317
|
mode: Movement mode (slide, platformer, rpg)
|
|
318
|
+
render_scale: Visual scale factor for tile rendering (default 1.0)
|
|
317
319
|
"""
|
|
318
320
|
self.tile_size = tile_size
|
|
319
321
|
self.mode = mode
|
|
322
|
+
if render_scale <= 0:
|
|
323
|
+
raise ValueError(f"render_scale must be positive, got {render_scale}")
|
|
324
|
+
self.render_scale = render_scale
|
|
325
|
+
self._eff_tw = int(tile_size[0] * render_scale)
|
|
326
|
+
self._eff_th = int(tile_size[1] * render_scale)
|
|
320
327
|
|
|
321
328
|
self.gravity = 800.0
|
|
322
329
|
self.max_fall_speed = 600.0
|
|
@@ -334,8 +341,8 @@ class CollisionRunner:
|
|
|
334
341
|
|
|
335
342
|
def get_tile_at(self, world_x: float, world_y: float) -> Tuple[int, int]:
|
|
336
343
|
"""Convert world position to tile coordinates"""
|
|
337
|
-
tile_x = int(world_x // self.
|
|
338
|
-
tile_y = int(world_y // self.
|
|
344
|
+
tile_x = int(world_x // self._eff_tw)
|
|
345
|
+
tile_y = int(world_y // self._eff_th)
|
|
339
346
|
return (tile_x, tile_y)
|
|
340
347
|
|
|
341
348
|
def get_tile_shapes(
|
|
@@ -352,10 +359,10 @@ class CollisionRunner:
|
|
|
352
359
|
if tile_id is None or not tileset_collision.has_collision(tile_id):
|
|
353
360
|
return []
|
|
354
361
|
|
|
355
|
-
tile_world_x = tile_x * self.
|
|
356
|
-
tile_world_y = tile_y * self.
|
|
362
|
+
tile_world_x = tile_x * self._eff_tw
|
|
363
|
+
tile_world_y = tile_y * self._eff_th
|
|
357
364
|
|
|
358
|
-
return tileset_collision.get_world_shapes(tile_id, tile_world_x, tile_world_y)
|
|
365
|
+
return tileset_collision.get_world_shapes(tile_id, tile_world_x, tile_world_y, self.render_scale)
|
|
359
366
|
|
|
360
367
|
def get_nearby_tile_shapes(
|
|
361
368
|
self,
|
|
@@ -372,7 +379,7 @@ class CollisionRunner:
|
|
|
372
379
|
this allocation entirely.
|
|
373
380
|
"""
|
|
374
381
|
left, top, right, bottom = get_shape_bounds(sprite)
|
|
375
|
-
tw, th = self.
|
|
382
|
+
tw, th = self._eff_tw, self._eff_th
|
|
376
383
|
|
|
377
384
|
min_tile_x = int(left // tw) - margin
|
|
378
385
|
max_tile_x = int(right // tw) + margin
|
|
@@ -392,7 +399,7 @@ class CollisionRunner:
|
|
|
392
399
|
tile_world_y = tile_y * th
|
|
393
400
|
for poly in tile_data.shapes:
|
|
394
401
|
if poly.is_valid():
|
|
395
|
-
shapes.append(poly.transform(tile_world_x, tile_world_y))
|
|
402
|
+
shapes.append(poly.transform(tile_world_x, tile_world_y, self.render_scale))
|
|
396
403
|
return shapes
|
|
397
404
|
|
|
398
405
|
def _collides_at(
|
|
@@ -409,7 +416,7 @@ class CollisionRunner:
|
|
|
409
416
|
inline, exits immediately on first hit.
|
|
410
417
|
"""
|
|
411
418
|
left, top, right, bottom = get_shape_bounds(sprite)
|
|
412
|
-
tw, th = self.
|
|
419
|
+
tw, th = self._eff_tw, self._eff_th
|
|
413
420
|
|
|
414
421
|
min_tile_x = int(left // tw) - margin
|
|
415
422
|
max_tile_x = int(right // tw) + margin
|
|
@@ -427,7 +434,7 @@ class CollisionRunner:
|
|
|
427
434
|
ox = tile_x * tw
|
|
428
435
|
oy = tile_y * th
|
|
429
436
|
for poly in tile_data.shapes:
|
|
430
|
-
if poly.is_valid() and _check_sprite_polygon_offset(sprite, poly, ox, oy):
|
|
437
|
+
if poly.is_valid() and _check_sprite_polygon_offset(sprite, poly, ox, oy, self.render_scale):
|
|
431
438
|
return True
|
|
432
439
|
return False
|
|
433
440
|
|
|
@@ -443,7 +450,7 @@ class CollisionRunner:
|
|
|
443
450
|
Used by slope_slide to get the normal without allocating a full list.
|
|
444
451
|
"""
|
|
445
452
|
left, top, right, bottom = get_shape_bounds(sprite)
|
|
446
|
-
tw, th = self.
|
|
453
|
+
tw, th = self._eff_tw, self._eff_th
|
|
447
454
|
|
|
448
455
|
min_tile_x = int(left // tw) - margin
|
|
449
456
|
max_tile_x = int(right // tw) + margin
|
|
@@ -461,7 +468,7 @@ class CollisionRunner:
|
|
|
461
468
|
ox = tile_x * tw
|
|
462
469
|
oy = tile_y * th
|
|
463
470
|
for poly in tile_data.shapes:
|
|
464
|
-
if poly.is_valid() and _check_sprite_polygon_offset(sprite, poly, ox, oy):
|
|
471
|
+
if poly.is_valid() and _check_sprite_polygon_offset(sprite, poly, ox, oy, self.render_scale):
|
|
465
472
|
return (poly, ox, oy)
|
|
466
473
|
return None
|
|
467
474
|
|
|
@@ -528,7 +535,7 @@ class CollisionRunner:
|
|
|
528
535
|
|
|
529
536
|
poly, ox, oy = hit
|
|
530
537
|
normal = self._get_collision_normal_from_motion(
|
|
531
|
-
sprite, poly, ox, oy, motion_x, motion_y
|
|
538
|
+
sprite, poly, ox, oy, motion_x, motion_y, self.render_scale
|
|
532
539
|
)
|
|
533
540
|
if normal:
|
|
534
541
|
dot = motion_x * normal[0] + motion_y * normal[1]
|
|
@@ -587,6 +594,7 @@ class CollisionRunner:
|
|
|
587
594
|
oy: float,
|
|
588
595
|
motion_x: float,
|
|
589
596
|
motion_y: float,
|
|
597
|
+
scale: float = 1.0,
|
|
590
598
|
) -> Optional[Tuple[float, float]]:
|
|
591
599
|
"""
|
|
592
600
|
Calculate the collision normal for a tile-local polygon at offset (ox, oy).
|
|
@@ -601,8 +609,8 @@ class CollisionRunner:
|
|
|
601
609
|
poly_cx = 0.0
|
|
602
610
|
poly_cy = 0.0
|
|
603
611
|
for vx, vy in vertices:
|
|
604
|
-
poly_cx += vx
|
|
605
|
-
poly_cy += vy
|
|
612
|
+
poly_cx += vx * scale
|
|
613
|
+
poly_cy += vy * scale
|
|
606
614
|
poly_cx = ox + poly_cx / n
|
|
607
615
|
poly_cy = oy + poly_cy / n
|
|
608
616
|
|
|
@@ -610,8 +618,8 @@ class CollisionRunner:
|
|
|
610
618
|
best_alignment = -1.0
|
|
611
619
|
|
|
612
620
|
for i in range(n):
|
|
613
|
-
v1x, v1y = vertices[i][0] + ox, vertices[i][1] + oy
|
|
614
|
-
v2x, v2y = vertices[(i + 1) % n][0] + ox, vertices[(i + 1) % n][1] + oy
|
|
621
|
+
v1x, v1y = vertices[i][0] * scale + ox, vertices[i][1] * scale + oy
|
|
622
|
+
v2x, v2y = vertices[(i + 1) % n][0] * scale + ox, vertices[(i + 1) % n][1] * scale + oy
|
|
615
623
|
|
|
616
624
|
edge_x = v2x - v1x
|
|
617
625
|
edge_y = v2y - v1y
|
|
@@ -699,7 +707,7 @@ class CollisionRunner:
|
|
|
699
707
|
collided_y = False
|
|
700
708
|
|
|
701
709
|
left, top, right, bottom = get_shape_bounds(sprite)
|
|
702
|
-
tw, th = self.
|
|
710
|
+
tw, th = self._eff_tw, self._eff_th
|
|
703
711
|
min_tile_x = int(left // tw) - 1
|
|
704
712
|
max_tile_x = int(right // tw) + 1
|
|
705
713
|
min_tile_y = int(top // th) - 1
|
|
@@ -718,11 +726,11 @@ class CollisionRunner:
|
|
|
718
726
|
for poly in tile_data.shapes:
|
|
719
727
|
if not poly.is_valid():
|
|
720
728
|
continue
|
|
721
|
-
if not _check_sprite_polygon_offset(sprite, poly, ox, oy):
|
|
729
|
+
if not _check_sprite_polygon_offset(sprite, poly, ox, oy, self.render_scale):
|
|
722
730
|
continue
|
|
723
731
|
if poly.one_way and sprite.vy > 0:
|
|
724
732
|
# one-way: only block if sprite was above the platform top
|
|
725
|
-
min_vy = min(v[1] for v in poly.vertices) + oy
|
|
733
|
+
min_vy = min(v[1] for v in poly.vertices) * self.render_scale + oy
|
|
726
734
|
if old_y + (bottom - top) <= min_vy:
|
|
727
735
|
collided_y = True
|
|
728
736
|
break
|
|
@@ -855,6 +863,7 @@ class CollisionRunner:
|
|
|
855
863
|
game_type: str,
|
|
856
864
|
tile_size: Tuple[int, int] = (32, 32),
|
|
857
865
|
strict: bool = False,
|
|
866
|
+
render_scale: float = 1.0,
|
|
858
867
|
) -> "CollisionRunner":
|
|
859
868
|
"""
|
|
860
869
|
Create a collision runner with preset configuration for a specific game type.
|
|
@@ -910,7 +919,7 @@ class CollisionRunner:
|
|
|
910
919
|
game_type = game_type.lower()
|
|
911
920
|
|
|
912
921
|
if game_type == "platformer":
|
|
913
|
-
runner = cls(tile_size, mode=MovementMode.PLATFORMER)
|
|
922
|
+
runner = cls(tile_size, mode=MovementMode.PLATFORMER, render_scale=render_scale)
|
|
914
923
|
runner.gravity = 800.0
|
|
915
924
|
runner.max_fall_speed = 600.0
|
|
916
925
|
runner.jump_strength = -400.0
|
|
@@ -919,7 +928,7 @@ class CollisionRunner:
|
|
|
919
928
|
runner._strict = strict
|
|
920
929
|
|
|
921
930
|
elif game_type == "topdown":
|
|
922
|
-
runner = cls(tile_size, mode=MovementMode.SLIDE)
|
|
931
|
+
runner = cls(tile_size, mode=MovementMode.SLIDE, render_scale=render_scale)
|
|
923
932
|
runner.gravity = 0.0
|
|
924
933
|
runner.max_fall_speed = 0.0
|
|
925
934
|
runner.jump_strength = 0.0
|
|
@@ -928,7 +937,7 @@ class CollisionRunner:
|
|
|
928
937
|
runner._strict = strict
|
|
929
938
|
|
|
930
939
|
elif game_type == "rpg":
|
|
931
|
-
runner = cls(tile_size, mode=MovementMode.RPG)
|
|
940
|
+
runner = cls(tile_size, mode=MovementMode.RPG, render_scale=render_scale)
|
|
932
941
|
runner.gravity = 0.0
|
|
933
942
|
runner.max_fall_speed = 0.0
|
|
934
943
|
runner.jump_strength = 0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tilemap-parser
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.1
|
|
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
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pygame
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
11
|
+
|
|
12
|
+
from tilemap_parser.runtime.map_loader import TilemapData, _resolve_resource_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
MINIMAL_MAP_META = {
|
|
16
|
+
"tile_size": "16;16",
|
|
17
|
+
"map_size": "10;10",
|
|
18
|
+
"version": "1.1",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_minimal_png(path: Path, size: tuple[int, int] = (16, 16)) -> None:
|
|
23
|
+
surf = pygame.Surface(size)
|
|
24
|
+
surf.fill((255, 0, 255))
|
|
25
|
+
pygame.image.save(surf, str(path))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_map_json(tileset_path: str, data_dir: Path) -> Path:
|
|
29
|
+
payload = {
|
|
30
|
+
"meta": {**MINIMAL_MAP_META},
|
|
31
|
+
"resources": {"tilesets": [{"path": tileset_path, "type": "tile"}]},
|
|
32
|
+
"project_state": {"rules": [], "groups": []},
|
|
33
|
+
"data": {"ongrid": {}},
|
|
34
|
+
}
|
|
35
|
+
map_path = data_dir / "test_map.json"
|
|
36
|
+
with open(map_path, "w") as f:
|
|
37
|
+
json.dump(payload, f, indent=2)
|
|
38
|
+
return map_path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture(autouse=True)
|
|
42
|
+
def pygame_init():
|
|
43
|
+
pygame.display.init()
|
|
44
|
+
pygame.display.set_mode((1, 1))
|
|
45
|
+
yield
|
|
46
|
+
pygame.quit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def tmp_project():
|
|
51
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
52
|
+
tmp = Path(tmp)
|
|
53
|
+
data_dir = tmp / "data"
|
|
54
|
+
assets_dir = tmp / "assets"
|
|
55
|
+
data_dir.mkdir()
|
|
56
|
+
assets_dir.mkdir()
|
|
57
|
+
yield tmp, data_dir, assets_dir
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture
|
|
61
|
+
def map_data(tmp_project):
|
|
62
|
+
tmp, data_dir, assets_dir = tmp_project
|
|
63
|
+
png = assets_dir / "tileset.png"
|
|
64
|
+
_make_minimal_png(png)
|
|
65
|
+
return tmp, data_dir, assets_dir, png
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestResolveResourcePath:
|
|
69
|
+
def test_relative_goes_up_to_assets(self, map_data):
|
|
70
|
+
tmp, data_dir, assets_dir, png = map_data
|
|
71
|
+
result = _resolve_resource_path("../assets/tileset.png", data_dir, None)
|
|
72
|
+
assert result == png.resolve()
|
|
73
|
+
|
|
74
|
+
def test_relative_within_data_fails(self, map_data):
|
|
75
|
+
tmp, data_dir, assets_dir, png = map_data
|
|
76
|
+
result = _resolve_resource_path("assets/tileset.png", data_dir, None)
|
|
77
|
+
assert not result.is_file()
|
|
78
|
+
|
|
79
|
+
def test_extra_search_base_fallback(self, map_data):
|
|
80
|
+
tmp, data_dir, assets_dir, png = map_data
|
|
81
|
+
result = _resolve_resource_path("assets/tileset.png", data_dir, extra_search_base=tmp)
|
|
82
|
+
assert result == png.resolve()
|
|
83
|
+
|
|
84
|
+
def test_absolute_path(self, tmp_project):
|
|
85
|
+
tmp, data_dir, assets_dir = tmp_project
|
|
86
|
+
png = tmp / "tileset.png"
|
|
87
|
+
_make_minimal_png(png)
|
|
88
|
+
result = _resolve_resource_path(str(png), tmp, None)
|
|
89
|
+
assert result == png
|
|
90
|
+
assert result.resolve() == png.resolve()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestTilemapDataLoad:
|
|
94
|
+
def test_load_with_valid_map_relative_path(self, map_data):
|
|
95
|
+
tmp, data_dir, assets_dir, png = map_data
|
|
96
|
+
map_path = _make_map_json("../assets/tileset.png", data_dir)
|
|
97
|
+
|
|
98
|
+
td = TilemapData.load(map_path)
|
|
99
|
+
|
|
100
|
+
assert len(td.resolved_paths) == 1
|
|
101
|
+
assert td.resolved_paths[0] == png.resolve()
|
|
102
|
+
assert td.surfaces[0] is not None
|
|
103
|
+
assert len(td.warnings) == 0
|
|
104
|
+
|
|
105
|
+
def test_load_with_invalid_path_generates_warning(self, map_data):
|
|
106
|
+
tmp, data_dir, assets_dir, png = map_data
|
|
107
|
+
map_path = _make_map_json("assets/tileset.png", data_dir)
|
|
108
|
+
|
|
109
|
+
td = TilemapData.load(map_path)
|
|
110
|
+
|
|
111
|
+
assert len(td.warnings) >= 1
|
|
112
|
+
assert "missing" in td.warnings[0].lower()
|
|
113
|
+
assert td.surfaces[0] is None
|
|
114
|
+
|
|
115
|
+
def test_load_with_extra_search_base(self, map_data):
|
|
116
|
+
tmp, data_dir, assets_dir, png = map_data
|
|
117
|
+
map_path = _make_map_json("assets/tileset.png", data_dir)
|
|
118
|
+
|
|
119
|
+
td = TilemapData.load(map_path, extra_search_base=tmp)
|
|
120
|
+
|
|
121
|
+
assert td.resolved_paths[0] == png.resolve()
|
|
122
|
+
assert td.surfaces[0] is not None
|
|
@@ -338,6 +338,73 @@ class TestCollisionHitHelpers:
|
|
|
338
338
|
with pytest.raises(ValueError, match="not part"):
|
|
339
339
|
hit.other(c)
|
|
340
340
|
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# slide_velocity
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
def test_slide_head_on(self):
|
|
345
|
+
"""Directly into surface → zero tangential motion."""
|
|
346
|
+
hit = CollisionHit(
|
|
347
|
+
object_a=None, object_b=None,
|
|
348
|
+
normal=(1.0, 0.0), depth=5.0,
|
|
349
|
+
)
|
|
350
|
+
sx, sy = hit.slide_velocity(10.0, 0.0)
|
|
351
|
+
assert sx == 0.0
|
|
352
|
+
assert sy == 0.0
|
|
353
|
+
|
|
354
|
+
def test_slide_angled(self):
|
|
355
|
+
"""Diagonal into surface → only perpendicular component removed."""
|
|
356
|
+
hit = CollisionHit(
|
|
357
|
+
object_a=None, object_b=None,
|
|
358
|
+
normal=(1.0, 0.0), depth=5.0,
|
|
359
|
+
)
|
|
360
|
+
sx, sy = hit.slide_velocity(10.0, 5.0)
|
|
361
|
+
assert sx == 0.0 # x component fully removed
|
|
362
|
+
assert sy == 5.0 # y component preserved (tangential)
|
|
363
|
+
|
|
364
|
+
def test_slide_parallel(self):
|
|
365
|
+
"""Velocity along the surface → unchanged."""
|
|
366
|
+
hit = CollisionHit(
|
|
367
|
+
object_a=None, object_b=None,
|
|
368
|
+
normal=(0.0, 1.0), depth=5.0,
|
|
369
|
+
)
|
|
370
|
+
sx, sy = hit.slide_velocity(10.0, 0.0)
|
|
371
|
+
assert sx == 10.0
|
|
372
|
+
assert sy == 0.0
|
|
373
|
+
|
|
374
|
+
def test_slide_away(self):
|
|
375
|
+
"""Velocity pointing away from surface → unchanged."""
|
|
376
|
+
hit = CollisionHit(
|
|
377
|
+
object_a=None, object_b=None,
|
|
378
|
+
normal=(1.0, 0.0), depth=5.0,
|
|
379
|
+
)
|
|
380
|
+
sx, sy = hit.slide_velocity(-10.0, 0.0)
|
|
381
|
+
assert sx == -10.0
|
|
382
|
+
assert sy == 0.0
|
|
383
|
+
|
|
384
|
+
def test_slide_zero(self):
|
|
385
|
+
"""Zero velocity → zero."""
|
|
386
|
+
hit = CollisionHit(
|
|
387
|
+
object_a=None, object_b=None,
|
|
388
|
+
normal=(1.0, 0.0), depth=5.0,
|
|
389
|
+
)
|
|
390
|
+
sx, sy = hit.slide_velocity(0.0, 0.0)
|
|
391
|
+
assert sx == 0.0
|
|
392
|
+
assert sy == 0.0
|
|
393
|
+
|
|
394
|
+
def test_slide_diagonal_normal(self):
|
|
395
|
+
"""Normal at 45°, velocity into surface → slide along surface."""
|
|
396
|
+
import math
|
|
397
|
+
n = (1.0 / math.sqrt(2), 1.0 / math.sqrt(2))
|
|
398
|
+
hit = CollisionHit(
|
|
399
|
+
object_a=None, object_b=None,
|
|
400
|
+
normal=n, depth=5.0,
|
|
401
|
+
)
|
|
402
|
+
sx, sy = hit.slide_velocity(1.0, 0.0)
|
|
403
|
+
# Dot = 1/sqrt(2) ≈ 0.707
|
|
404
|
+
# slide = (1, 0) - (0.707, 0.707) * 0.707 = (1 - 0.5, -0.5)
|
|
405
|
+
assert sx == pytest.approx(0.5)
|
|
406
|
+
assert sy == pytest.approx(-0.5)
|
|
407
|
+
|
|
341
408
|
|
|
342
409
|
# ---------------------------------------------------------------------------
|
|
343
410
|
# Capsule collision
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for render_scale support in the parser layer.
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
- CollisionPolygon.transform with scale
|
|
6
|
+
- TilesetCollision.get_world_shapes with scale
|
|
7
|
+
- ParsedMeta.render_scale parsing
|
|
8
|
+
- TilemapData.render_scale property
|
|
9
|
+
- Inline offset functions with scale parameter
|
|
10
|
+
- CollisionRunner with effective_tile_size
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import pytest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Tuple
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
21
|
+
|
|
22
|
+
from tilemap_parser.parser.collision import CollisionPolygon, TilesetCollision, TileCollisionData, RectangleShape, CircleShape, CapsuleShape
|
|
23
|
+
from tilemap_parser.parser.map_parse import ParsedMeta, parse_map_dict
|
|
24
|
+
from tilemap_parser.runtime.tile_collision import (
|
|
25
|
+
CollisionRunner,
|
|
26
|
+
MovementMode,
|
|
27
|
+
_point_in_polygon_offset,
|
|
28
|
+
_rect_polygon_collision_offset,
|
|
29
|
+
_circle_polygon_collision_offset,
|
|
30
|
+
_check_sprite_polygon_offset,
|
|
31
|
+
get_shape_bounds,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Helpers — minimal sprite for collision tests
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
class DummySprite:
|
|
40
|
+
def __init__(self, x=0, y=0, shape=None, vx=0, vy=0, on_ground=False):
|
|
41
|
+
self.x = x
|
|
42
|
+
self.y = y
|
|
43
|
+
self.collision_shape = shape or RectangleShape(width=16, height=16)
|
|
44
|
+
self.vx = vx
|
|
45
|
+
self.vy = vy
|
|
46
|
+
self.on_ground = on_ground
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# CollisionPolygon.transform
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
class TestCollisionPolygonTransformScale:
|
|
54
|
+
def test_no_scale_default(self):
|
|
55
|
+
poly = CollisionPolygon(vertices=[(0, 0), (16, 0), (16, 16), (0, 16)])
|
|
56
|
+
world = poly.transform(100, 200)
|
|
57
|
+
assert world.vertices == [
|
|
58
|
+
(100, 200), (116, 200), (116, 216), (100, 216)
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def test_scale_2x(self):
|
|
62
|
+
poly = CollisionPolygon(vertices=[(0, 0), (16, 0), (16, 16), (0, 16)])
|
|
63
|
+
world = poly.transform(100, 200, scale=2.0)
|
|
64
|
+
# tile_x/y already in world space; vertices get multiplied by scale
|
|
65
|
+
assert world.vertices == [
|
|
66
|
+
(100, 200), (132, 200), (132, 232), (100, 232)
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def test_scale_half(self):
|
|
70
|
+
poly = CollisionPolygon(vertices=[(10, 10), (20, 10), (20, 20)])
|
|
71
|
+
world = poly.transform(50, 50, scale=0.5)
|
|
72
|
+
# tile_x=50, tile_y=50; vertices: 10*0.5=5, 20*0.5=10
|
|
73
|
+
# (50+5, 50+5) = (55, 55)
|
|
74
|
+
# (50+10, 50+5) = (60, 55)
|
|
75
|
+
# (50+10, 50+10) = (60, 60)
|
|
76
|
+
assert world.vertices == [
|
|
77
|
+
(55.0, 55.0), (60.0, 55.0), (60.0, 60.0)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def test_scale_non_uniform_tile_offset(self):
|
|
81
|
+
poly = CollisionPolygon(vertices=[(4, 8)])
|
|
82
|
+
world = poly.transform(32, 64, scale=2.0)
|
|
83
|
+
# 32 + 4*2 = 40, 64 + 8*2 = 80
|
|
84
|
+
assert world.vertices == [(40, 80)]
|
|
85
|
+
|
|
86
|
+
def test_scale_one_way_preserved(self):
|
|
87
|
+
poly = CollisionPolygon(vertices=[(0, 0), (32, 0), (32, 32)], one_way=True)
|
|
88
|
+
world = poly.transform(0, 0, scale=3.0)
|
|
89
|
+
assert world.one_way is True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# TilesetCollision.get_world_shapes
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
class TestGetWorldShapesScale:
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def ts_coll(self):
|
|
99
|
+
return TilesetCollision(
|
|
100
|
+
tileset_name="test",
|
|
101
|
+
tile_size=(32, 32),
|
|
102
|
+
tiles={
|
|
103
|
+
0: TileCollisionData(
|
|
104
|
+
tile_id=0,
|
|
105
|
+
shapes=[
|
|
106
|
+
CollisionPolygon(vertices=[(0, 0), (32, 0), (32, 32), (0, 32)]),
|
|
107
|
+
],
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def test_default_scale(self, ts_coll):
|
|
113
|
+
shapes = ts_coll.get_world_shapes(0, 64, 128)
|
|
114
|
+
assert shapes[0].vertices == [(64, 128), (96, 128), (96, 160), (64, 160)]
|
|
115
|
+
|
|
116
|
+
def test_custom_scale(self, ts_coll):
|
|
117
|
+
shapes = ts_coll.get_world_shapes(0, 64, 128, scale=2.0)
|
|
118
|
+
assert shapes[0].vertices == [(64, 128), (128, 128), (128, 192), (64, 192)]
|
|
119
|
+
|
|
120
|
+
def test_missing_tile_returns_empty(self, ts_coll):
|
|
121
|
+
assert ts_coll.get_world_shapes(999, 0, 0, scale=2.0) == []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# ParsedMeta.render_scale
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
class TestParsedMetaRenderScale:
|
|
129
|
+
def test_default_render_scale(self):
|
|
130
|
+
meta = ParsedMeta(
|
|
131
|
+
tile_size=(16, 16),
|
|
132
|
+
map_size=(10, 10),
|
|
133
|
+
initial_map_size=(10, 10),
|
|
134
|
+
zoom_level=1.0,
|
|
135
|
+
scroll=(0, 0),
|
|
136
|
+
version="1.1",
|
|
137
|
+
)
|
|
138
|
+
assert meta.render_scale == 1.0
|
|
139
|
+
|
|
140
|
+
def test_custom_render_scale(self):
|
|
141
|
+
meta = ParsedMeta(
|
|
142
|
+
tile_size=(16, 16),
|
|
143
|
+
map_size=(10, 10),
|
|
144
|
+
initial_map_size=(10, 10),
|
|
145
|
+
zoom_level=1.0,
|
|
146
|
+
scroll=(0, 0),
|
|
147
|
+
version="1.1",
|
|
148
|
+
render_scale=3.0,
|
|
149
|
+
)
|
|
150
|
+
assert meta.render_scale == 3.0
|
|
151
|
+
|
|
152
|
+
def test_parsed_from_map_dict(self):
|
|
153
|
+
"""render_scale is read from meta dict by parse_map_dict"""
|
|
154
|
+
root = {
|
|
155
|
+
"meta": {
|
|
156
|
+
"tile_size": "16,16",
|
|
157
|
+
"map_size": "10,10",
|
|
158
|
+
"zoom_level": 1.0,
|
|
159
|
+
"scroll": "0,0",
|
|
160
|
+
"version": "1.1",
|
|
161
|
+
"render_scale": 2.5,
|
|
162
|
+
},
|
|
163
|
+
"data": {"layers": []},
|
|
164
|
+
"project_state": {"rules": [], "groups": []},
|
|
165
|
+
}
|
|
166
|
+
parsed = parse_map_dict(root)
|
|
167
|
+
assert parsed.meta.render_scale == 2.5
|
|
168
|
+
|
|
169
|
+
def test_parsed_defaults_to_1_0_when_missing(self):
|
|
170
|
+
root = {
|
|
171
|
+
"meta": {
|
|
172
|
+
"tile_size": "16,16",
|
|
173
|
+
"map_size": "10,10",
|
|
174
|
+
"zoom_level": 1.0,
|
|
175
|
+
"scroll": "0,0",
|
|
176
|
+
"version": "1.1",
|
|
177
|
+
},
|
|
178
|
+
"data": {"layers": []},
|
|
179
|
+
"project_state": {"rules": [], "groups": []},
|
|
180
|
+
}
|
|
181
|
+
parsed = parse_map_dict(root)
|
|
182
|
+
assert parsed.meta.render_scale == 1.0
|
|
183
|
+
|
|
184
|
+
def test_parsed_rejects_non_numeric(self):
|
|
185
|
+
root = {
|
|
186
|
+
"meta": {
|
|
187
|
+
"tile_size": "16,16",
|
|
188
|
+
"map_size": "10,10",
|
|
189
|
+
"zoom_level": 1.0,
|
|
190
|
+
"scroll": "0,0",
|
|
191
|
+
"version": "1.1",
|
|
192
|
+
"render_scale": "not-a-number",
|
|
193
|
+
},
|
|
194
|
+
"data": {"layers": []},
|
|
195
|
+
"project_state": {"rules": [], "groups": []},
|
|
196
|
+
}
|
|
197
|
+
with pytest.raises(Exception):
|
|
198
|
+
parse_map_dict(root)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Inline offset functions with scale parameter
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
class TestInlineOffsetScale:
|
|
206
|
+
"""_point_in_polygon_offset, _rect_polygon_collision_offset,
|
|
207
|
+
_circle_polygon_collision_offset, _check_sprite_polygon_offset with scale."""
|
|
208
|
+
|
|
209
|
+
def test_point_offset_scale(self):
|
|
210
|
+
"""_point_in_polygon_offset scales vertices before offset"""
|
|
211
|
+
verts = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
|
212
|
+
# ox=100, oy=200, scale=2 → world vertices: [(100,200), (120,200), (120,220), (100,220)]
|
|
213
|
+
# point (90, 190) is outside (below and left)
|
|
214
|
+
assert _point_in_polygon_offset(90, 190, verts, 100, 200, scale=2.0) is False
|
|
215
|
+
# center of scaled polygon: (10,10)*2 + (100,200) = (120, 220)
|
|
216
|
+
assert _point_in_polygon_offset(120, 220, verts, 100, 200, scale=2.0) is True
|
|
217
|
+
|
|
218
|
+
def test_rect_offset_scale(self):
|
|
219
|
+
"""_rect_polygon_collision_offset scales vertices"""
|
|
220
|
+
verts = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
|
221
|
+
# scale=2, ox=0, oy=0 → polygon covers (0,0) to (20,20)
|
|
222
|
+
# rect at (5, 5, 10, 10) should collide
|
|
223
|
+
assert _rect_polygon_collision_offset(5, 5, 10, 10, verts, 0, 0, scale=2.0) is True
|
|
224
|
+
# rect at (25, 25, 5, 5) should not
|
|
225
|
+
assert _rect_polygon_collision_offset(25, 25, 5, 5, verts, 0, 0, scale=2.0) is False
|
|
226
|
+
|
|
227
|
+
def test_circle_offset_scale(self):
|
|
228
|
+
"""_circle_polygon_collision_offset scales vertices"""
|
|
229
|
+
verts = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
|
230
|
+
# scale=2, ox=0, oy=0 → polygon covers (0,0) to (20,20)
|
|
231
|
+
# circle at center (10, 10) radius 5 should collide
|
|
232
|
+
assert _circle_polygon_collision_offset(10, 10, 5, verts, 0, 0, scale=2.0) is True
|
|
233
|
+
# far away should not
|
|
234
|
+
assert _circle_polygon_collision_offset(100, 100, 5, verts, 0, 0, scale=2.0) is False
|
|
235
|
+
|
|
236
|
+
def test_rect_collision_scale_defaults_to_1(self):
|
|
237
|
+
"""scale=1.0 should give same result as no scale"""
|
|
238
|
+
verts = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
|
239
|
+
assert _rect_polygon_collision_offset(2, 2, 5, 5, verts, 5, 5, scale=1.0) == \
|
|
240
|
+
_rect_polygon_collision_offset(2, 2, 5, 5, verts, 5, 5)
|
|
241
|
+
|
|
242
|
+
def test_check_sprite_polygon_offset_scale(self):
|
|
243
|
+
"""_check_sprite_polygon_offset passes scale through"""
|
|
244
|
+
verts = [(0, 0), (16, 0), (16, 16), (0, 16)]
|
|
245
|
+
poly = CollisionPolygon(vertices=verts)
|
|
246
|
+
sprite = DummySprite(x=100, y=100, shape=RectangleShape(width=16, height=16))
|
|
247
|
+
# ox=0, oy=0, scale=2 → polygon covers (0,0) to (32,32)
|
|
248
|
+
# sprite at (100,100) out of range
|
|
249
|
+
assert _check_sprite_polygon_offset(sprite, poly, 0, 0, scale=2.0) is False
|
|
250
|
+
# sprite at (0,0) should intersect
|
|
251
|
+
sprite.x = 0
|
|
252
|
+
sprite.y = 0
|
|
253
|
+
assert _check_sprite_polygon_offset(sprite, poly, 0, 0, scale=2.0) is True
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# CollisionRunner
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
class TestCollisionRunnerRenderScale:
|
|
261
|
+
def test_default_render_scale(self):
|
|
262
|
+
runner = CollisionRunner(tile_size=(32, 32))
|
|
263
|
+
assert runner.render_scale == 1.0
|
|
264
|
+
assert runner._eff_tw == 32
|
|
265
|
+
assert runner._eff_th == 32
|
|
266
|
+
|
|
267
|
+
def test_custom_scale_effective_size(self):
|
|
268
|
+
runner = CollisionRunner(tile_size=(32, 32), render_scale=2.0)
|
|
269
|
+
assert runner.render_scale == 2.0
|
|
270
|
+
assert runner._eff_tw == 64
|
|
271
|
+
assert runner._eff_th == 64
|
|
272
|
+
|
|
273
|
+
def test_non_integer_scale(self):
|
|
274
|
+
runner = CollisionRunner(tile_size=(16, 16), render_scale=1.5)
|
|
275
|
+
assert runner._eff_tw == 24 # int(16 * 1.5)
|
|
276
|
+
assert runner._eff_th == 24
|
|
277
|
+
|
|
278
|
+
def test_get_tile_at_with_scale(self):
|
|
279
|
+
runner = CollisionRunner(tile_size=(32, 32), render_scale=2.0)
|
|
280
|
+
# world pos at (63, 63) → tile 0 (since eff_tw=64)
|
|
281
|
+
assert runner.get_tile_at(63, 63) == (0, 0)
|
|
282
|
+
# world pos at (64, 64) → tile 1
|
|
283
|
+
assert runner.get_tile_at(64, 64) == (1, 1)
|
|
284
|
+
|
|
285
|
+
def test_get_tile_at_no_scale(self):
|
|
286
|
+
runner = CollisionRunner(tile_size=(32, 32))
|
|
287
|
+
# world pos at (31, 31) → tile 0
|
|
288
|
+
assert runner.get_tile_at(31, 31) == (0, 0)
|
|
289
|
+
# world pos at (32, 32) → tile 1
|
|
290
|
+
assert runner.get_tile_at(32, 32) == (1, 1)
|
|
291
|
+
|
|
292
|
+
def test_scale_affects_collision_query(self):
|
|
293
|
+
"""With render_scale=2, the effective grid is 64x64, affecting which
|
|
294
|
+
tiles are checked for collision at a given world position."""
|
|
295
|
+
runner = CollisionRunner(tile_size=(32, 32), render_scale=2.0)
|
|
296
|
+
eff_tw = runner._eff_tw # 64
|
|
297
|
+
# A tile_map with a single tile at (0,0)
|
|
298
|
+
tile_map = {(0, 0): 0}
|
|
299
|
+
ts_coll = TilesetCollision(
|
|
300
|
+
tileset_name="test",
|
|
301
|
+
tile_size=(32, 32),
|
|
302
|
+
tiles={
|
|
303
|
+
0: TileCollisionData(
|
|
304
|
+
tile_id=0,
|
|
305
|
+
shapes=[CollisionPolygon(vertices=[(0, 0), (32, 0), (32, 32), (0, 32)])],
|
|
306
|
+
),
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# sprite at (10, 10) with 16x16 rect shape should collide with tile (0, 0)
|
|
311
|
+
sprite = DummySprite(x=10, y=10, shape=RectangleShape(width=16, height=16))
|
|
312
|
+
# _collides_at internally checks tiles using eff_tw/eff_th and passes self.render_scale
|
|
313
|
+
assert runner._collides_at(sprite, ts_coll, tile_map) is True
|
|
314
|
+
|
|
315
|
+
# sprite far to the right at (128, 10) should NOT collide (tile (0,0) world is 0..64)
|
|
316
|
+
sprite2 = DummySprite(x=128, y=10, shape=RectangleShape(width=16, height=16))
|
|
317
|
+
# sprite at (128, 10) with 16 wide → bounds 128..144, tile_x = 128//64 = 2 → no tile at (2,0)
|
|
318
|
+
assert runner._collides_at(sprite2, ts_coll, tile_map) is False
|
|
319
|
+
|
|
320
|
+
def test_from_game_type_passes_scale(self):
|
|
321
|
+
runner = CollisionRunner.from_game_type("platformer", tile_size=(16, 16), render_scale=3.0)
|
|
322
|
+
assert runner.render_scale == 3.0
|
|
323
|
+
assert runner._eff_tw == 48
|
|
324
|
+
assert runner._eff_th == 48
|
|
325
|
+
|
|
326
|
+
def test_from_game_type_default_scale(self):
|
|
327
|
+
runner = CollisionRunner.from_game_type("topdown", tile_size=(16, 16))
|
|
328
|
+
assert runner.render_scale == 1.0
|
|
329
|
+
assert runner._eff_tw == 16
|
|
330
|
+
assert runner._eff_th == 16
|
|
331
|
+
|
|
332
|
+
def test_collides_at_inline_offset_pass_scale(self):
|
|
333
|
+
"""Verify _collides_at passes scale down to _check_sprite_polygon_offset
|
|
334
|
+
by checking edge-case with scaled vertices."""
|
|
335
|
+
runner = CollisionRunner(tile_size=(8, 8), render_scale=2.0)
|
|
336
|
+
# eff_tw = 16, eff_th = 16
|
|
337
|
+
# A triangle polygon that only fills bottom-right corner of tile
|
|
338
|
+
verts = [(0, 0), (8, 0), (4, 8)]
|
|
339
|
+
ts_coll = TilesetCollision(
|
|
340
|
+
tileset_name="test",
|
|
341
|
+
tile_size=(8, 8),
|
|
342
|
+
tiles={0: TileCollisionData(tile_id=0, shapes=[CollisionPolygon(vertices=verts)])},
|
|
343
|
+
)
|
|
344
|
+
tile_map = {(0, 0): 0}
|
|
345
|
+
# sprite at (8, 12) with 4x4 rect
|
|
346
|
+
# With scale=2, vertices become (0,0)*2=0, (8,0)*2=16, (4,8)*2=8+16=24 in world:
|
|
347
|
+
# (16,0), (0,0), (8,16). ox=0, oy=0.
|
|
348
|
+
# Wait, ox = tile_x * eff_tw = 0*16 = 0
|
|
349
|
+
# Vertices are tile-local: [(0,0), (8,0), (4,8)]
|
|
350
|
+
# Scaled: [(0,0), (16,0), (8,16)]
|
|
351
|
+
# + offset (0,0) → world: [(0,0), (16,0), (8,16)]
|
|
352
|
+
# Sprite bounds: x=8, y=12, w=4, h=4 → (8..12, 12..16)
|
|
353
|
+
# Scaled triangle: [(0,0), (16,0), (8,16)] — at y=12 the triangle spans x=6..10,
|
|
354
|
+
# which overlaps the sprite rect (8..12, 12..16). So collision IS expected.
|
|
355
|
+
sprite = DummySprite(x=8, y=12, shape=RectangleShape(width=4, height=4))
|
|
356
|
+
assert runner._collides_at(sprite, ts_coll, tile_map) is True
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# CollisionRunner.get_tile_shapes and get_nearby_tile_shapes
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
class TestRunnerTileShapeMethodsScale:
|
|
364
|
+
@pytest.fixture
|
|
365
|
+
def ts_coll(self):
|
|
366
|
+
return TilesetCollision(
|
|
367
|
+
tileset_name="test",
|
|
368
|
+
tile_size=(32, 32),
|
|
369
|
+
tiles={
|
|
370
|
+
0: TileCollisionData(
|
|
371
|
+
tile_id=0,
|
|
372
|
+
shapes=[CollisionPolygon(vertices=[(0, 0), (32, 0), (32, 32), (0, 32)])],
|
|
373
|
+
),
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def test_get_tile_shapes_with_scale(self, ts_coll):
|
|
378
|
+
runner = CollisionRunner(tile_size=(32, 32), render_scale=2.0)
|
|
379
|
+
tile_map = {(0, 0): 0, (1, 0): 0}
|
|
380
|
+
# world pos (10, 10) → tile (0, 0) → tile world = (0*64, 0*64) = (0,0)
|
|
381
|
+
# vertices scaled by 2.0: [(0,0), (64,0), (64,64), (0,64)]
|
|
382
|
+
# + offset (0,0) → same
|
|
383
|
+
shapes = runner.get_tile_shapes(ts_coll, tile_map, 10, 10)
|
|
384
|
+
assert len(shapes) == 1
|
|
385
|
+
assert shapes[0].vertices == [(0, 0), (64, 0), (64, 64), (0, 64)]
|
|
386
|
+
|
|
387
|
+
def test_get_nearby_tile_shapes_with_scale(self, ts_coll):
|
|
388
|
+
runner = CollisionRunner(tile_size=(32, 32), render_scale=2.0)
|
|
389
|
+
tile_map = {(0, 0): 0, (1, 0): 0}
|
|
390
|
+
sprite = DummySprite(x=10, y=10, shape=RectangleShape(width=16, height=16))
|
|
391
|
+
shapes = runner.get_nearby_tile_shapes(ts_coll, tile_map, sprite, margin=0)
|
|
392
|
+
assert len(shapes) == 1
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# TilemapData.render_scale (integration with map_loader)
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
class TestTilemapDataRenderScale:
|
|
400
|
+
def test_render_scale_property_exists(self):
|
|
401
|
+
"""Verify TilemapData has a render_scale property read from parsed meta"""
|
|
402
|
+
from tilemap_parser.runtime.map_loader import TilemapData
|
|
403
|
+
|
|
404
|
+
class MockParsedMap:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
meta = ParsedMeta(
|
|
408
|
+
tile_size=(16, 16),
|
|
409
|
+
map_size=(10, 10),
|
|
410
|
+
initial_map_size=(10, 10),
|
|
411
|
+
zoom_level=1.0,
|
|
412
|
+
scroll=(0, 0),
|
|
413
|
+
version="1.1",
|
|
414
|
+
render_scale=2.0,
|
|
415
|
+
)
|
|
416
|
+
mock = MockParsedMap()
|
|
417
|
+
mock.meta = meta
|
|
418
|
+
mock.layers = []
|
|
419
|
+
mock.tilesets = []
|
|
420
|
+
mock.project_state = None
|
|
421
|
+
mock.raw = {}
|
|
422
|
+
import pygame
|
|
423
|
+
data = TilemapData(mock, [], [], [])
|
|
424
|
+
assert data.render_scale == 2.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser/runtime/animation_player.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tilemap_parser-3.0.0 → tilemap_parser-3.1.1}/src/tilemap_parser.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|