tilemap-parser 1.1.1__tar.gz → 2.0.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-1.1.1/src/tilemap_parser.egg-info → tilemap_parser-2.0.1}/PKG-INFO +1 -1
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/pyproject.toml +2 -1
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser/collision.py +70 -36
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser/collision_runner.py +2 -2
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1/src/tilemap_parser.egg-info}/PKG-INFO +1 -1
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser.egg-info/SOURCES.txt +3 -1
- tilemap_parser-2.0.1/tests/test_collision.py +387 -0
- tilemap_parser-2.0.1/tests/test_collision_runner.py +110 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/LICENSE +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/README.md +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/setup.cfg +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser/__init__.py +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser/animation.py +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser/map_loader.py +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser/map_parse.py +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser/renderer.py +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser.egg-info/requires.txt +0 -0
- {tilemap_parser-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tilemap-parser"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.0.1"
|
|
8
8
|
description = "Standalone parser/loader for tilemap-editor JSON maps and sprite animation JSON."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -26,4 +26,5 @@ include-package-data = false
|
|
|
26
26
|
[tool.setuptools.packages.find]
|
|
27
27
|
where = ["src"]
|
|
28
28
|
include = ["tilemap_parser*"]
|
|
29
|
+
exclude = ["webdocs*", "tests*"]
|
|
29
30
|
namespaces = false
|
|
@@ -217,22 +217,24 @@ def parse_character_collision(data: JsonDict) -> CharacterCollision:
|
|
|
217
217
|
|
|
218
218
|
|
|
219
219
|
def load_tileset_collision(
|
|
220
|
-
|
|
220
|
+
collision_path: Union[str, Path],
|
|
221
221
|
) -> Optional[TilesetCollision]:
|
|
222
222
|
"""
|
|
223
|
-
Load tileset collision data from file.
|
|
223
|
+
Load tileset collision data from a collision JSON file.
|
|
224
|
+
|
|
225
|
+
The editor stores tileset collision files at:
|
|
226
|
+
<data_root>/collision/<tileset_stem>.collision.json
|
|
224
227
|
|
|
225
228
|
Args:
|
|
226
|
-
|
|
229
|
+
collision_path: Direct path to the .collision.json file.
|
|
227
230
|
|
|
228
231
|
Returns:
|
|
229
|
-
TilesetCollision object or None if
|
|
232
|
+
TilesetCollision object, or None if the file does not exist.
|
|
230
233
|
|
|
231
234
|
Raises:
|
|
232
|
-
CollisionParseError: If
|
|
235
|
+
CollisionParseError: If the file exists but cannot be parsed.
|
|
233
236
|
"""
|
|
234
|
-
|
|
235
|
-
collision_path = tileset_path.with_suffix(".collision.json")
|
|
237
|
+
collision_path = Path(collision_path)
|
|
236
238
|
|
|
237
239
|
if not collision_path.exists():
|
|
238
240
|
return None
|
|
@@ -241,27 +243,29 @@ def load_tileset_collision(
|
|
|
241
243
|
with open(collision_path, "r", encoding="utf-8") as f:
|
|
242
244
|
data = json.load(f)
|
|
243
245
|
return parse_tileset_collision(data)
|
|
244
|
-
except (OSError, json.JSONDecodeError) as e:
|
|
246
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
245
247
|
raise CollisionParseError(f"Cannot load {collision_path}: {e}") from e
|
|
246
248
|
|
|
247
249
|
|
|
248
250
|
def load_character_collision(
|
|
249
|
-
|
|
251
|
+
collision_path: Union[str, Path],
|
|
250
252
|
) -> Optional[CharacterCollision]:
|
|
251
253
|
"""
|
|
252
|
-
Load character collision data from file.
|
|
254
|
+
Load character collision data from a collision JSON file.
|
|
255
|
+
|
|
256
|
+
The editor stores character collision files at:
|
|
257
|
+
<data_root>/character_collision/<character_name>.collision.json
|
|
253
258
|
|
|
254
259
|
Args:
|
|
255
|
-
|
|
260
|
+
collision_path: Direct path to the .collision.json file.
|
|
256
261
|
|
|
257
262
|
Returns:
|
|
258
|
-
CharacterCollision object or None if
|
|
263
|
+
CharacterCollision object, or None if the file does not exist.
|
|
259
264
|
|
|
260
265
|
Raises:
|
|
261
|
-
CollisionParseError: If
|
|
266
|
+
CollisionParseError: If the file exists but cannot be parsed.
|
|
262
267
|
"""
|
|
263
|
-
|
|
264
|
-
collision_path = sprite_path.with_suffix(".collision.json")
|
|
268
|
+
collision_path = Path(collision_path)
|
|
265
269
|
|
|
266
270
|
if not collision_path.exists():
|
|
267
271
|
return None
|
|
@@ -270,7 +274,7 @@ def load_character_collision(
|
|
|
270
274
|
with open(collision_path, "r", encoding="utf-8") as f:
|
|
271
275
|
data = json.load(f)
|
|
272
276
|
return parse_character_collision(data)
|
|
273
|
-
except (OSError, json.JSONDecodeError) as e:
|
|
277
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
274
278
|
raise CollisionParseError(f"Cannot load {collision_path}: {e}") from e
|
|
275
279
|
|
|
276
280
|
|
|
@@ -280,6 +284,12 @@ class CollisionCache:
|
|
|
280
284
|
|
|
281
285
|
Caches parsed collision data to avoid repeated file I/O and parsing.
|
|
282
286
|
Useful for runtime performance in game engines.
|
|
287
|
+
|
|
288
|
+
All methods accept the direct path to the .collision.json file.
|
|
289
|
+
|
|
290
|
+
Typical paths produced by the editor:
|
|
291
|
+
Tileset: <data_root>/collision/<stem>.collision.json
|
|
292
|
+
Character: <data_root>/character_collision/<name>.collision.json
|
|
283
293
|
"""
|
|
284
294
|
|
|
285
295
|
def __init__(self):
|
|
@@ -287,24 +297,32 @@ class CollisionCache:
|
|
|
287
297
|
self._character_cache: Dict[str, Optional[CharacterCollision]] = {}
|
|
288
298
|
|
|
289
299
|
def get_tileset_collision(
|
|
290
|
-
self,
|
|
300
|
+
self, collision_path: Union[str, Path]
|
|
291
301
|
) -> Optional[TilesetCollision]:
|
|
292
|
-
"""Get tileset collision data (cached)
|
|
293
|
-
|
|
302
|
+
"""Get tileset collision data (cached).
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
collision_path: Direct path to the .collision.json file.
|
|
306
|
+
"""
|
|
307
|
+
key = str(Path(collision_path).resolve())
|
|
294
308
|
|
|
295
309
|
if key not in self._tileset_cache:
|
|
296
|
-
self._tileset_cache[key] = load_tileset_collision(
|
|
310
|
+
self._tileset_cache[key] = load_tileset_collision(collision_path)
|
|
297
311
|
|
|
298
312
|
return self._tileset_cache[key]
|
|
299
313
|
|
|
300
314
|
def get_character_collision(
|
|
301
|
-
self,
|
|
315
|
+
self, collision_path: Union[str, Path]
|
|
302
316
|
) -> Optional[CharacterCollision]:
|
|
303
|
-
"""Get character collision data (cached)
|
|
304
|
-
|
|
317
|
+
"""Get character collision data (cached).
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
collision_path: Direct path to the .collision.json file.
|
|
321
|
+
"""
|
|
322
|
+
key = str(Path(collision_path).resolve())
|
|
305
323
|
|
|
306
324
|
if key not in self._character_cache:
|
|
307
|
-
self._character_cache[key] = load_character_collision(
|
|
325
|
+
self._character_cache[key] = load_character_collision(collision_path)
|
|
308
326
|
|
|
309
327
|
return self._character_cache[key]
|
|
310
328
|
|
|
@@ -313,30 +331,46 @@ class CollisionCache:
|
|
|
313
331
|
self._tileset_cache.clear()
|
|
314
332
|
self._character_cache.clear()
|
|
315
333
|
|
|
316
|
-
def preload_tileset(self,
|
|
317
|
-
"""Preload tileset collision data into cache
|
|
318
|
-
|
|
334
|
+
def preload_tileset(self, collision_path: Union[str, Path]):
|
|
335
|
+
"""Preload tileset collision data into cache.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
collision_path: Direct path to the .collision.json file.
|
|
339
|
+
"""
|
|
340
|
+
self.get_tileset_collision(collision_path)
|
|
319
341
|
|
|
320
|
-
def preload_character(self,
|
|
321
|
-
"""Preload character collision data into cache
|
|
322
|
-
|
|
342
|
+
def preload_character(self, collision_path: Union[str, Path]):
|
|
343
|
+
"""Preload character collision data into cache.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
collision_path: Direct path to the .collision.json file.
|
|
347
|
+
"""
|
|
348
|
+
self.get_character_collision(collision_path)
|
|
323
349
|
|
|
324
350
|
|
|
325
351
|
_global_cache = CollisionCache()
|
|
326
352
|
|
|
327
353
|
|
|
328
354
|
def get_cached_tileset_collision(
|
|
329
|
-
|
|
355
|
+
collision_path: Union[str, Path],
|
|
330
356
|
) -> Optional[TilesetCollision]:
|
|
331
|
-
"""Get tileset collision using global cache
|
|
332
|
-
|
|
357
|
+
"""Get tileset collision using global cache.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
collision_path: Direct path to the .collision.json file.
|
|
361
|
+
"""
|
|
362
|
+
return _global_cache.get_tileset_collision(collision_path)
|
|
333
363
|
|
|
334
364
|
|
|
335
365
|
def get_cached_character_collision(
|
|
336
|
-
|
|
366
|
+
collision_path: Union[str, Path],
|
|
337
367
|
) -> Optional[CharacterCollision]:
|
|
338
|
-
"""Get character collision using global cache
|
|
339
|
-
|
|
368
|
+
"""Get character collision using global cache.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
collision_path: Direct path to the .collision.json file.
|
|
372
|
+
"""
|
|
373
|
+
return _global_cache.get_character_collision(collision_path)
|
|
340
374
|
|
|
341
375
|
|
|
342
376
|
def clear_collision_cache():
|
|
@@ -67,8 +67,8 @@ def point_in_polygon(point: Point, vertices: List[Point]) -> bool:
|
|
|
67
67
|
if x <= max(p1x, p2x):
|
|
68
68
|
if p1y != p2y:
|
|
69
69
|
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
if p1x == p2x or x <= xinters:
|
|
71
|
+
inside = not inside
|
|
72
72
|
p1x, p1y = p2x, p2y
|
|
73
73
|
|
|
74
74
|
return inside
|
|
@@ -12,4 +12,6 @@ src/tilemap_parser.egg-info/PKG-INFO
|
|
|
12
12
|
src/tilemap_parser.egg-info/SOURCES.txt
|
|
13
13
|
src/tilemap_parser.egg-info/dependency_links.txt
|
|
14
14
|
src/tilemap_parser.egg-info/requires.txt
|
|
15
|
-
src/tilemap_parser.egg-info/top_level.txt
|
|
15
|
+
src/tilemap_parser.egg-info/top_level.txt
|
|
16
|
+
tests/test_collision.py
|
|
17
|
+
tests/test_collision_runner.py
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for tilemap_parser.collision — covering the refactored API where
|
|
3
|
+
load_tileset_collision() and load_character_collision() accept a direct
|
|
4
|
+
path to the .collision.json file rather than deriving it from an image path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import pytest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
13
|
+
|
|
14
|
+
from tilemap_parser.collision import (
|
|
15
|
+
# parsers
|
|
16
|
+
parse_tileset_collision,
|
|
17
|
+
parse_character_collision,
|
|
18
|
+
# loaders
|
|
19
|
+
load_tileset_collision,
|
|
20
|
+
load_character_collision,
|
|
21
|
+
# cache
|
|
22
|
+
CollisionCache,
|
|
23
|
+
get_cached_tileset_collision,
|
|
24
|
+
get_cached_character_collision,
|
|
25
|
+
clear_collision_cache,
|
|
26
|
+
# data classes
|
|
27
|
+
TilesetCollision,
|
|
28
|
+
CharacterCollision,
|
|
29
|
+
RectangleShape,
|
|
30
|
+
CircleShape,
|
|
31
|
+
CapsuleShape,
|
|
32
|
+
CollisionPolygon,
|
|
33
|
+
CollisionParseError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Fixtures
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
FIXTURES = Path(__file__).parent.parent / "examples" / "fixtures"
|
|
41
|
+
TILESET_COLLISION = FIXTURES / "collision" / "Terrain (32x32).collision.json"
|
|
42
|
+
CHARACTER_COLLISION = FIXTURES / "character_collision" / "hero.collision.json"
|
|
43
|
+
|
|
44
|
+
TILESET_DATA = {
|
|
45
|
+
"tileset_name": "test_tileset",
|
|
46
|
+
"tile_size": [32, 32],
|
|
47
|
+
"tiles": {
|
|
48
|
+
"0": {
|
|
49
|
+
"tile_id": 0,
|
|
50
|
+
"shapes": [
|
|
51
|
+
{
|
|
52
|
+
"vertices": [[0.0, 0.0], [32.0, 0.0], [32.0, 32.0], [0.0, 32.0]],
|
|
53
|
+
"one_way": False,
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
"1": {
|
|
58
|
+
"tile_id": 1,
|
|
59
|
+
"shapes": [
|
|
60
|
+
{
|
|
61
|
+
"vertices": [[0.0, 16.0], [32.0, 16.0], [32.0, 32.0], [0.0, 32.0]],
|
|
62
|
+
"one_way": True,
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
CHARACTER_RECT_DATA = {
|
|
70
|
+
"name": "player",
|
|
71
|
+
"shape": {"type": "rectangle", "width": 24.0, "height": 32.0, "offset": [4.0, 0.0]},
|
|
72
|
+
"properties": {"speed": 150},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
CHARACTER_CIRCLE_DATA = {
|
|
76
|
+
"name": "ball",
|
|
77
|
+
"shape": {"type": "circle", "radius": 16.0, "offset": [0.0, 0.0]},
|
|
78
|
+
"properties": {},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
CHARACTER_CAPSULE_DATA = {
|
|
82
|
+
"name": "snake",
|
|
83
|
+
"shape": {"type": "capsule", "radius": 8.0, "height": 48.0, "offset": [0.0, 0.0]},
|
|
84
|
+
"properties": {},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ===========================================================================
|
|
89
|
+
# parse_tileset_collision
|
|
90
|
+
# ===========================================================================
|
|
91
|
+
|
|
92
|
+
class TestParseTilesetCollision:
|
|
93
|
+
def test_basic_parse(self):
|
|
94
|
+
result = parse_tileset_collision(TILESET_DATA)
|
|
95
|
+
assert isinstance(result, TilesetCollision)
|
|
96
|
+
assert result.tileset_name == "test_tileset"
|
|
97
|
+
assert result.tile_size == (32, 32)
|
|
98
|
+
assert len(result.tiles) == 2
|
|
99
|
+
|
|
100
|
+
def test_tile_has_collision(self):
|
|
101
|
+
result = parse_tileset_collision(TILESET_DATA)
|
|
102
|
+
assert result.has_collision(0)
|
|
103
|
+
assert result.has_collision(1)
|
|
104
|
+
assert not result.has_collision(99)
|
|
105
|
+
|
|
106
|
+
def test_one_way_flag(self):
|
|
107
|
+
result = parse_tileset_collision(TILESET_DATA)
|
|
108
|
+
shapes = result.get_world_shapes(1, 0, 0)
|
|
109
|
+
assert shapes[0].one_way is True
|
|
110
|
+
|
|
111
|
+
def test_world_shapes_transform(self):
|
|
112
|
+
result = parse_tileset_collision(TILESET_DATA)
|
|
113
|
+
shapes = result.get_world_shapes(0, 100.0, 200.0)
|
|
114
|
+
assert len(shapes) == 1
|
|
115
|
+
# All vertices should be offset by (100, 200)
|
|
116
|
+
for vx, vy in shapes[0].vertices:
|
|
117
|
+
assert vx >= 100.0
|
|
118
|
+
assert vy >= 200.0
|
|
119
|
+
|
|
120
|
+
def test_world_shapes_missing_tile(self):
|
|
121
|
+
result = parse_tileset_collision(TILESET_DATA)
|
|
122
|
+
assert result.get_world_shapes(99, 0, 0) == []
|
|
123
|
+
|
|
124
|
+
def test_missing_tileset_name_raises(self):
|
|
125
|
+
bad = {k: v for k, v in TILESET_DATA.items() if k != "tileset_name"}
|
|
126
|
+
with pytest.raises(CollisionParseError):
|
|
127
|
+
parse_tileset_collision(bad)
|
|
128
|
+
|
|
129
|
+
def test_missing_tile_size_raises(self):
|
|
130
|
+
bad = {k: v for k, v in TILESET_DATA.items() if k != "tile_size"}
|
|
131
|
+
with pytest.raises(CollisionParseError):
|
|
132
|
+
parse_tileset_collision(bad)
|
|
133
|
+
|
|
134
|
+
def test_empty_tiles(self):
|
|
135
|
+
data = {"tileset_name": "empty", "tile_size": [16, 16], "tiles": {}}
|
|
136
|
+
result = parse_tileset_collision(data)
|
|
137
|
+
assert len(result.tiles) == 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ===========================================================================
|
|
141
|
+
# parse_character_collision
|
|
142
|
+
# ===========================================================================
|
|
143
|
+
|
|
144
|
+
class TestParseCharacterCollision:
|
|
145
|
+
def test_rectangle(self):
|
|
146
|
+
result = parse_character_collision(CHARACTER_RECT_DATA)
|
|
147
|
+
assert isinstance(result, CharacterCollision)
|
|
148
|
+
assert result.name == "player"
|
|
149
|
+
assert isinstance(result.shape, RectangleShape)
|
|
150
|
+
assert result.shape.width == 24.0
|
|
151
|
+
assert result.shape.height == 32.0
|
|
152
|
+
assert result.shape.offset == (4.0, 0.0)
|
|
153
|
+
assert result.properties["speed"] == 150
|
|
154
|
+
|
|
155
|
+
def test_circle(self):
|
|
156
|
+
result = parse_character_collision(CHARACTER_CIRCLE_DATA)
|
|
157
|
+
assert isinstance(result.shape, CircleShape)
|
|
158
|
+
assert result.shape.radius == 16.0
|
|
159
|
+
|
|
160
|
+
def test_capsule(self):
|
|
161
|
+
result = parse_character_collision(CHARACTER_CAPSULE_DATA)
|
|
162
|
+
assert isinstance(result.shape, CapsuleShape)
|
|
163
|
+
assert result.shape.radius == 8.0
|
|
164
|
+
assert result.shape.height == 48.0
|
|
165
|
+
|
|
166
|
+
def test_unknown_shape_raises(self):
|
|
167
|
+
bad = {
|
|
168
|
+
"name": "x",
|
|
169
|
+
"shape": {"type": "hexagon", "radius": 10.0},
|
|
170
|
+
"properties": {},
|
|
171
|
+
}
|
|
172
|
+
with pytest.raises(CollisionParseError):
|
|
173
|
+
parse_character_collision(bad)
|
|
174
|
+
|
|
175
|
+
def test_missing_name_raises(self):
|
|
176
|
+
bad = {k: v for k, v in CHARACTER_RECT_DATA.items() if k != "name"}
|
|
177
|
+
with pytest.raises(CollisionParseError):
|
|
178
|
+
parse_character_collision(bad)
|
|
179
|
+
|
|
180
|
+
def test_properties_default_empty(self):
|
|
181
|
+
data = {
|
|
182
|
+
"name": "ghost",
|
|
183
|
+
"shape": {"type": "circle", "radius": 10.0},
|
|
184
|
+
}
|
|
185
|
+
result = parse_character_collision(data)
|
|
186
|
+
assert result.properties == {}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ===========================================================================
|
|
190
|
+
# Shape helpers
|
|
191
|
+
# ===========================================================================
|
|
192
|
+
|
|
193
|
+
class TestShapeHelpers:
|
|
194
|
+
def test_rectangle_get_bounds(self):
|
|
195
|
+
shape = RectangleShape(width=24.0, height=32.0, offset=(4.0, 0.0))
|
|
196
|
+
left, top, right, bottom = shape.get_bounds(100.0, 200.0)
|
|
197
|
+
assert left == 104.0
|
|
198
|
+
assert top == 200.0
|
|
199
|
+
assert right == 128.0
|
|
200
|
+
assert bottom == 232.0
|
|
201
|
+
|
|
202
|
+
def test_circle_get_center(self):
|
|
203
|
+
shape = CircleShape(radius=16.0, offset=(2.0, 3.0))
|
|
204
|
+
cx, cy = shape.get_center(100.0, 200.0)
|
|
205
|
+
assert cx == 102.0
|
|
206
|
+
assert cy == 203.0
|
|
207
|
+
|
|
208
|
+
def test_capsule_top_bottom(self):
|
|
209
|
+
shape = CapsuleShape(radius=8.0, height=48.0, offset=(0.0, 0.0))
|
|
210
|
+
top = shape.get_top_center(100.0, 200.0)
|
|
211
|
+
bottom = shape.get_bottom_center(100.0, 200.0)
|
|
212
|
+
assert top == (100.0, 200.0)
|
|
213
|
+
assert bottom == (100.0, 248.0)
|
|
214
|
+
|
|
215
|
+
def test_collision_polygon_is_valid(self):
|
|
216
|
+
valid = CollisionPolygon(vertices=[(0, 0), (1, 0), (1, 1)])
|
|
217
|
+
invalid = CollisionPolygon(vertices=[(0, 0), (1, 0)])
|
|
218
|
+
assert valid.is_valid()
|
|
219
|
+
assert not invalid.is_valid()
|
|
220
|
+
|
|
221
|
+
def test_collision_polygon_transform(self):
|
|
222
|
+
poly = CollisionPolygon(vertices=[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)])
|
|
223
|
+
transformed = poly.transform(5.0, 7.0)
|
|
224
|
+
assert transformed.vertices[0] == (5.0, 7.0)
|
|
225
|
+
assert transformed.vertices[1] == (15.0, 7.0)
|
|
226
|
+
assert transformed.vertices[2] == (15.0, 17.0)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ===========================================================================
|
|
230
|
+
# load_tileset_collision — direct path API
|
|
231
|
+
# ===========================================================================
|
|
232
|
+
|
|
233
|
+
class TestLoadTilesetCollision:
|
|
234
|
+
def test_loads_fixture(self):
|
|
235
|
+
result = load_tileset_collision(TILESET_COLLISION)
|
|
236
|
+
assert result is not None
|
|
237
|
+
assert isinstance(result, TilesetCollision)
|
|
238
|
+
assert result.tileset_name == "Terrain (32x32)"
|
|
239
|
+
|
|
240
|
+
def test_returns_none_for_missing_file(self, tmp_path):
|
|
241
|
+
result = load_tileset_collision(tmp_path / "nonexistent.collision.json")
|
|
242
|
+
assert result is None
|
|
243
|
+
|
|
244
|
+
def test_raises_on_invalid_json(self, tmp_path):
|
|
245
|
+
bad = tmp_path / "bad.collision.json"
|
|
246
|
+
bad.write_text("not json", encoding="utf-8")
|
|
247
|
+
with pytest.raises(CollisionParseError):
|
|
248
|
+
load_tileset_collision(bad)
|
|
249
|
+
|
|
250
|
+
def test_raises_on_wrong_schema(self, tmp_path):
|
|
251
|
+
bad = tmp_path / "bad.collision.json"
|
|
252
|
+
bad.write_text(json.dumps({"wrong": "schema"}), encoding="utf-8")
|
|
253
|
+
with pytest.raises(CollisionParseError):
|
|
254
|
+
load_tileset_collision(bad)
|
|
255
|
+
|
|
256
|
+
def test_accepts_string_path(self):
|
|
257
|
+
result = load_tileset_collision(str(TILESET_COLLISION))
|
|
258
|
+
assert result is not None
|
|
259
|
+
|
|
260
|
+
def test_fixture_tile_ids(self):
|
|
261
|
+
result = load_tileset_collision(TILESET_COLLISION)
|
|
262
|
+
assert result.has_collision(26)
|
|
263
|
+
assert result.has_collision(8)
|
|
264
|
+
assert not result.has_collision(0)
|
|
265
|
+
|
|
266
|
+
def test_non_json_file_raises(self, tmp_path):
|
|
267
|
+
# Passing a non-JSON file (e.g. an image) raises CollisionParseError,
|
|
268
|
+
# not silently returns None — the file exists so we try to parse it.
|
|
269
|
+
fake_image = tmp_path / "terrain.png"
|
|
270
|
+
fake_image.write_bytes(b"\x89PNG\r\n")
|
|
271
|
+
with pytest.raises(CollisionParseError):
|
|
272
|
+
load_tileset_collision(fake_image)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ===========================================================================
|
|
276
|
+
# load_character_collision — direct path API
|
|
277
|
+
# ===========================================================================
|
|
278
|
+
|
|
279
|
+
class TestLoadCharacterCollision:
|
|
280
|
+
def test_loads_fixture(self):
|
|
281
|
+
result = load_character_collision(CHARACTER_COLLISION)
|
|
282
|
+
assert result is not None
|
|
283
|
+
assert isinstance(result, CharacterCollision)
|
|
284
|
+
assert result.name == "hero"
|
|
285
|
+
|
|
286
|
+
def test_returns_none_for_missing_file(self, tmp_path):
|
|
287
|
+
result = load_character_collision(tmp_path / "ghost.collision.json")
|
|
288
|
+
assert result is None
|
|
289
|
+
|
|
290
|
+
def test_raises_on_invalid_json(self, tmp_path):
|
|
291
|
+
bad = tmp_path / "bad.collision.json"
|
|
292
|
+
bad.write_text("{broken", encoding="utf-8")
|
|
293
|
+
with pytest.raises(CollisionParseError):
|
|
294
|
+
load_character_collision(bad)
|
|
295
|
+
|
|
296
|
+
def test_accepts_string_path(self):
|
|
297
|
+
result = load_character_collision(str(CHARACTER_COLLISION))
|
|
298
|
+
assert result is not None
|
|
299
|
+
|
|
300
|
+
def test_fixture_shape_type(self):
|
|
301
|
+
result = load_character_collision(CHARACTER_COLLISION)
|
|
302
|
+
assert isinstance(result.shape, RectangleShape)
|
|
303
|
+
|
|
304
|
+
def test_non_json_file_raises(self, tmp_path):
|
|
305
|
+
# Passing a non-JSON file (e.g. an image) raises CollisionParseError,
|
|
306
|
+
# not silently returns None — the file exists so we try to parse it.
|
|
307
|
+
fake_image = tmp_path / "hero.png"
|
|
308
|
+
fake_image.write_bytes(b"\x89PNG\r\n")
|
|
309
|
+
with pytest.raises(CollisionParseError):
|
|
310
|
+
load_character_collision(fake_image)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ===========================================================================
|
|
314
|
+
# CollisionCache
|
|
315
|
+
# ===========================================================================
|
|
316
|
+
|
|
317
|
+
class TestCollisionCache:
|
|
318
|
+
def setup_method(self):
|
|
319
|
+
self.cache = CollisionCache()
|
|
320
|
+
|
|
321
|
+
def test_get_tileset_collision(self):
|
|
322
|
+
result = self.cache.get_tileset_collision(TILESET_COLLISION)
|
|
323
|
+
assert result is not None
|
|
324
|
+
assert result.tileset_name == "Terrain (32x32)"
|
|
325
|
+
|
|
326
|
+
def test_get_character_collision(self):
|
|
327
|
+
result = self.cache.get_character_collision(CHARACTER_COLLISION)
|
|
328
|
+
assert result is not None
|
|
329
|
+
assert result.name == "hero"
|
|
330
|
+
|
|
331
|
+
def test_missing_returns_none(self, tmp_path):
|
|
332
|
+
result = self.cache.get_tileset_collision(tmp_path / "nope.collision.json")
|
|
333
|
+
assert result is None
|
|
334
|
+
|
|
335
|
+
def test_caches_result(self):
|
|
336
|
+
r1 = self.cache.get_tileset_collision(TILESET_COLLISION)
|
|
337
|
+
r2 = self.cache.get_tileset_collision(TILESET_COLLISION)
|
|
338
|
+
assert r1 is r2 # same object — not re-parsed
|
|
339
|
+
|
|
340
|
+
def test_clear_removes_cache(self):
|
|
341
|
+
r1 = self.cache.get_tileset_collision(TILESET_COLLISION)
|
|
342
|
+
self.cache.clear()
|
|
343
|
+
r2 = self.cache.get_tileset_collision(TILESET_COLLISION)
|
|
344
|
+
assert r1 is not r2 # re-parsed after clear
|
|
345
|
+
|
|
346
|
+
def test_preload_tileset(self):
|
|
347
|
+
self.cache.preload_tileset(TILESET_COLLISION)
|
|
348
|
+
key = str(TILESET_COLLISION.resolve())
|
|
349
|
+
assert key in self.cache._tileset_cache
|
|
350
|
+
|
|
351
|
+
def test_preload_character(self):
|
|
352
|
+
self.cache.preload_character(CHARACTER_COLLISION)
|
|
353
|
+
key = str(CHARACTER_COLLISION.resolve())
|
|
354
|
+
assert key in self.cache._character_cache
|
|
355
|
+
|
|
356
|
+
def test_cache_key_is_resolved_path(self, tmp_path):
|
|
357
|
+
# Two different Path objects pointing to the same file should share a cache entry
|
|
358
|
+
link = tmp_path / "link.collision.json"
|
|
359
|
+
import shutil
|
|
360
|
+
shutil.copy(TILESET_COLLISION, link)
|
|
361
|
+
r1 = self.cache.get_tileset_collision(link)
|
|
362
|
+
r2 = self.cache.get_tileset_collision(link)
|
|
363
|
+
assert r1 is r2
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ===========================================================================
|
|
367
|
+
# Global cache helpers
|
|
368
|
+
# ===========================================================================
|
|
369
|
+
|
|
370
|
+
class TestGlobalCacheHelpers:
|
|
371
|
+
def setup_method(self):
|
|
372
|
+
clear_collision_cache()
|
|
373
|
+
|
|
374
|
+
def test_get_cached_tileset(self):
|
|
375
|
+
result = get_cached_tileset_collision(TILESET_COLLISION)
|
|
376
|
+
assert result is not None
|
|
377
|
+
|
|
378
|
+
def test_get_cached_character(self):
|
|
379
|
+
result = get_cached_character_collision(CHARACTER_COLLISION)
|
|
380
|
+
assert result is not None
|
|
381
|
+
|
|
382
|
+
def test_clear_collision_cache(self):
|
|
383
|
+
get_cached_tileset_collision(TILESET_COLLISION)
|
|
384
|
+
clear_collision_cache()
|
|
385
|
+
# After clear, a fresh load still works
|
|
386
|
+
result = get_cached_tileset_collision(TILESET_COLLISION)
|
|
387
|
+
assert result is not None
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for tilemap_parser.collision_runner — focusing on correctness of
|
|
3
|
+
geometric utilities, especially the xinters bug fix in point_in_polygon.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
12
|
+
|
|
13
|
+
from tilemap_parser.collision_runner import point_in_polygon, CollisionRunner, MovementMode
|
|
14
|
+
from tilemap_parser.collision import CollisionCache, RectangleShape
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ===========================================================================
|
|
18
|
+
# point_in_polygon
|
|
19
|
+
# ===========================================================================
|
|
20
|
+
|
|
21
|
+
# Simple axis-aligned square: (0,0) -> (10,0) -> (10,10) -> (0,10)
|
|
22
|
+
SQUARE = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
|
|
23
|
+
|
|
24
|
+
# Triangle with a horizontal bottom edge — this is what triggered the xinters bug
|
|
25
|
+
# vertices: (0,0), (10,0), (5,10)
|
|
26
|
+
TRIANGLE_HORIZ_BASE = [(0.0, 0.0), (10.0, 0.0), (5.0, 10.0)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestPointInPolygon:
|
|
30
|
+
def test_inside_square(self):
|
|
31
|
+
assert point_in_polygon((5.0, 5.0), SQUARE) is True
|
|
32
|
+
|
|
33
|
+
def test_outside_square(self):
|
|
34
|
+
assert point_in_polygon((15.0, 5.0), SQUARE) is False
|
|
35
|
+
assert point_in_polygon((-1.0, 5.0), SQUARE) is False
|
|
36
|
+
assert point_in_polygon((5.0, -1.0), SQUARE) is False
|
|
37
|
+
assert point_in_polygon((5.0, 15.0), SQUARE) is False
|
|
38
|
+
|
|
39
|
+
def test_inside_triangle(self):
|
|
40
|
+
assert point_in_polygon((5.0, 5.0), TRIANGLE_HORIZ_BASE) is True
|
|
41
|
+
|
|
42
|
+
def test_outside_triangle(self):
|
|
43
|
+
assert point_in_polygon((0.5, 9.0), TRIANGLE_HORIZ_BASE) is False
|
|
44
|
+
assert point_in_polygon((9.5, 9.0), TRIANGLE_HORIZ_BASE) is False
|
|
45
|
+
|
|
46
|
+
# --- xinters bug regression tests ---
|
|
47
|
+
# A polygon with a horizontal edge at y=0. When the ray is cast from a point
|
|
48
|
+
# whose y equals the horizontal edge's y, the old code would read an
|
|
49
|
+
# uninitialised / stale xinters and flip `inside` incorrectly.
|
|
50
|
+
|
|
51
|
+
def test_horizontal_edge_does_not_use_stale_xinters(self):
|
|
52
|
+
# Point well inside — must not be flipped by the horizontal base edge
|
|
53
|
+
assert point_in_polygon((5.0, 5.0), TRIANGLE_HORIZ_BASE) is True
|
|
54
|
+
|
|
55
|
+
def test_point_below_horizontal_edge(self):
|
|
56
|
+
# y=-1 is outside regardless
|
|
57
|
+
assert point_in_polygon((5.0, -1.0), TRIANGLE_HORIZ_BASE) is False
|
|
58
|
+
|
|
59
|
+
def test_polygon_with_multiple_horizontal_edges(self):
|
|
60
|
+
# Rectangle has horizontal top and bottom edges
|
|
61
|
+
rect = [(0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)]
|
|
62
|
+
assert point_in_polygon((10.0, 5.0), rect) is True
|
|
63
|
+
assert point_in_polygon((10.0, 15.0), rect) is False
|
|
64
|
+
assert point_in_polygon((-1.0, 5.0), rect) is False
|
|
65
|
+
|
|
66
|
+
def test_first_iteration_no_unbound_xinters(self):
|
|
67
|
+
# Polygon whose very first edge is horizontal — this is the case that
|
|
68
|
+
# caused an UnboundLocalError before the fix (xinters never assigned
|
|
69
|
+
# yet when the stale-read branch was reached on i=1).
|
|
70
|
+
horiz_first = [(0.0, 5.0), (10.0, 5.0), (10.0, 0.0), (0.0, 0.0)]
|
|
71
|
+
# Point inside
|
|
72
|
+
assert point_in_polygon((5.0, 2.0), horiz_first) is True
|
|
73
|
+
# Point outside
|
|
74
|
+
assert point_in_polygon((5.0, 8.0), horiz_first) is False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ===========================================================================
|
|
78
|
+
# CollisionRunner construction
|
|
79
|
+
# ===========================================================================
|
|
80
|
+
|
|
81
|
+
class TestCollisionRunnerConstruction:
|
|
82
|
+
def test_from_game_type_platformer(self):
|
|
83
|
+
cache = CollisionCache()
|
|
84
|
+
runner = CollisionRunner.from_game_type("platformer", cache, (32, 32))
|
|
85
|
+
assert runner.mode == MovementMode.PLATFORMER
|
|
86
|
+
assert runner.gravity > 0
|
|
87
|
+
|
|
88
|
+
def test_from_game_type_topdown(self):
|
|
89
|
+
cache = CollisionCache()
|
|
90
|
+
runner = CollisionRunner.from_game_type("topdown", cache, (32, 32))
|
|
91
|
+
assert runner.mode == MovementMode.SLIDE
|
|
92
|
+
assert runner.gravity == 0.0
|
|
93
|
+
|
|
94
|
+
def test_from_game_type_rpg(self):
|
|
95
|
+
cache = CollisionCache()
|
|
96
|
+
runner = CollisionRunner.from_game_type("rpg", cache, (32, 32))
|
|
97
|
+
assert runner.mode == MovementMode.RPG
|
|
98
|
+
assert runner.gravity == 0.0
|
|
99
|
+
|
|
100
|
+
def test_unknown_game_type_raises(self):
|
|
101
|
+
cache = CollisionCache()
|
|
102
|
+
with pytest.raises(ValueError):
|
|
103
|
+
CollisionRunner.from_game_type("unknown", cache, (32, 32))
|
|
104
|
+
|
|
105
|
+
def test_platformer_zero_gravity_raises(self):
|
|
106
|
+
cache = CollisionCache()
|
|
107
|
+
runner = CollisionRunner.from_game_type("platformer", cache, (32, 32))
|
|
108
|
+
runner.gravity = 0.0
|
|
109
|
+
with pytest.raises(ValueError):
|
|
110
|
+
runner.validate_config()
|
|
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-1.1.1 → tilemap_parser-2.0.1}/src/tilemap_parser.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|