tilemap-parser 2.0.0__tar.gz → 2.0.3__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-2.0.0/src/tilemap_parser.egg-info → tilemap_parser-2.0.3}/PKG-INFO +1 -1
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/pyproject.toml +1 -1
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser/collision_runner.py +341 -163
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3/src/tilemap_parser.egg-info}/PKG-INFO +1 -1
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/SOURCES.txt +2 -1
- tilemap_parser-2.0.3/tests/test_collision_runner.py +307 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/LICENSE +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/README.md +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/setup.cfg +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser/__init__.py +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser/animation.py +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser/collision.py +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser/map_loader.py +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser/map_parse.py +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser/renderer.py +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/requires.txt +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/top_level.txt +0 -0
- {tilemap_parser-2.0.0 → tilemap_parser-2.0.3}/tests/test_collision.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tilemap-parser"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.3"
|
|
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"
|
|
@@ -54,11 +54,10 @@ class ICollidableSprite(Protocol):
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def point_in_polygon(point: Point, vertices: List[Point]) -> bool:
|
|
57
|
-
"""Check if point is inside polygon using ray casting"""
|
|
57
|
+
"""Check if point is inside polygon using ray casting (tile-local coordinates)."""
|
|
58
58
|
x, y = point
|
|
59
59
|
n = len(vertices)
|
|
60
60
|
inside = False
|
|
61
|
-
|
|
62
61
|
p1x, p1y = vertices[0]
|
|
63
62
|
for i in range(1, n + 1):
|
|
64
63
|
p2x, p2y = vertices[i % n]
|
|
@@ -67,78 +66,156 @@ def point_in_polygon(point: Point, vertices: List[Point]) -> bool:
|
|
|
67
66
|
if x <= max(p1x, p2x):
|
|
68
67
|
if p1y != p2y:
|
|
69
68
|
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
if p1x == p2x or x <= xinters:
|
|
70
|
+
inside = not inside
|
|
72
71
|
p1x, p1y = p2x, p2y
|
|
72
|
+
return inside
|
|
73
|
+
|
|
73
74
|
|
|
75
|
+
def _point_in_polygon_offset(px: float, py: float, vertices: List[Point], ox: float, oy: float) -> bool:
|
|
76
|
+
"""Ray-cast with tile offset applied inline — no allocation."""
|
|
77
|
+
n = len(vertices)
|
|
78
|
+
inside = False
|
|
79
|
+
p1x, p1y = vertices[0][0] + ox, vertices[0][1] + oy
|
|
80
|
+
for i in range(1, n + 1):
|
|
81
|
+
vx, vy = vertices[i % n]
|
|
82
|
+
p2x, p2y = vx + ox, vy + oy
|
|
83
|
+
if py > min(p1y, p2y):
|
|
84
|
+
if py <= max(p1y, p2y):
|
|
85
|
+
if px <= max(p1x, p2x):
|
|
86
|
+
if p1y != p2y:
|
|
87
|
+
xinters = (py - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
|
|
88
|
+
if p1x == p2x or px <= xinters:
|
|
89
|
+
inside = not inside
|
|
90
|
+
p1x, p1y = p2x, p2y
|
|
74
91
|
return inside
|
|
75
92
|
|
|
76
93
|
|
|
77
94
|
def rect_polygon_collision(
|
|
78
95
|
rect_x: float, rect_y: float, rect_w: float, rect_h: float, vertices: List[Point]
|
|
79
96
|
) -> bool:
|
|
80
|
-
"""Check if rectangle collides with polygon"""
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
"""Check if rectangle collides with polygon (world-space vertices)."""
|
|
98
|
+
# AABB pre-reject
|
|
99
|
+
n = len(vertices)
|
|
100
|
+
min_vx = max_vx = vertices[0][0]
|
|
101
|
+
min_vy = max_vy = vertices[0][1]
|
|
102
|
+
for i in range(1, n):
|
|
103
|
+
vx, vy = vertices[i]
|
|
104
|
+
if vx < min_vx: min_vx = vx
|
|
105
|
+
elif vx > max_vx: max_vx = vx
|
|
106
|
+
if vy < min_vy: min_vy = vy
|
|
107
|
+
elif vy > max_vy: max_vy = vy
|
|
108
|
+
if rect_x > max_vx or rect_x + rect_w < min_vx or rect_y > max_vy or rect_y + rect_h < min_vy:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# Corner tests — no tuple allocation
|
|
112
|
+
if point_in_polygon((rect_x, rect_y), vertices): return True
|
|
113
|
+
if point_in_polygon((rect_x + rect_w, rect_y), vertices): return True
|
|
114
|
+
if point_in_polygon((rect_x, rect_y + rect_h), vertices): return True
|
|
115
|
+
if point_in_polygon((rect_x + rect_w, rect_y + rect_h), vertices): return True
|
|
116
|
+
|
|
117
|
+
# Vertex-in-rect
|
|
118
|
+
rx2, ry2 = rect_x + rect_w, rect_y + rect_h
|
|
119
|
+
for vx, vy in vertices:
|
|
120
|
+
if rect_x <= vx <= rx2 and rect_y <= vy <= ry2:
|
|
91
121
|
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
92
124
|
|
|
125
|
+
def _rect_polygon_collision_offset(
|
|
126
|
+
rect_x: float, rect_y: float, rect_w: float, rect_h: float,
|
|
127
|
+
vertices: List[Point], ox: float, oy: float,
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""Rectangle vs polygon with tile offset applied inline — no allocation."""
|
|
130
|
+
# AABB pre-reject with offset
|
|
131
|
+
n = len(vertices)
|
|
132
|
+
v0x, v0y = vertices[0][0] + ox, vertices[0][1] + oy
|
|
133
|
+
min_vx = max_vx = v0x
|
|
134
|
+
min_vy = max_vy = v0y
|
|
135
|
+
for i in range(1, n):
|
|
136
|
+
wx, wy = vertices[i][0] + ox, vertices[i][1] + oy
|
|
137
|
+
if wx < min_vx: min_vx = wx
|
|
138
|
+
elif wx > max_vx: max_vx = wx
|
|
139
|
+
if wy < min_vy: min_vy = wy
|
|
140
|
+
elif wy > max_vy: max_vy = wy
|
|
141
|
+
if rect_x > max_vx or rect_x + rect_w < min_vx or rect_y > max_vy or rect_y + rect_h < min_vy:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
# Corner tests
|
|
145
|
+
rx2, ry2 = rect_x + rect_w, rect_y + rect_h
|
|
146
|
+
if _point_in_polygon_offset(rect_x, rect_y, vertices, ox, oy): return True
|
|
147
|
+
if _point_in_polygon_offset(rx2, rect_y, vertices, ox, oy): return True
|
|
148
|
+
if _point_in_polygon_offset(rect_x, ry2, vertices, ox, oy): return True
|
|
149
|
+
if _point_in_polygon_offset(rx2, ry2, vertices, ox, oy): return True
|
|
150
|
+
|
|
151
|
+
# Vertex-in-rect
|
|
93
152
|
for vx, vy in vertices:
|
|
94
|
-
|
|
153
|
+
wx, wy = vx + ox, vy + oy
|
|
154
|
+
if rect_x <= wx <= rx2 and rect_y <= wy <= ry2:
|
|
95
155
|
return True
|
|
96
|
-
|
|
97
156
|
return False
|
|
98
157
|
|
|
99
158
|
|
|
100
159
|
def circle_polygon_collision(
|
|
101
160
|
center: Point, radius: float, vertices: List[Point]
|
|
102
161
|
) -> bool:
|
|
103
|
-
"""Check if circle collides with polygon"""
|
|
104
|
-
|
|
162
|
+
"""Check if circle collides with polygon (world-space vertices)."""
|
|
105
163
|
if point_in_polygon(center, vertices):
|
|
106
164
|
return True
|
|
107
165
|
|
|
108
166
|
cx, cy = center
|
|
109
167
|
n = len(vertices)
|
|
110
|
-
|
|
111
168
|
for i in range(n):
|
|
112
169
|
x1, y1 = vertices[i]
|
|
113
170
|
x2, y2 = vertices[(i + 1) % n]
|
|
114
|
-
|
|
115
171
|
dx = x2 - x1
|
|
116
172
|
dy = y2 - y1
|
|
117
|
-
|
|
118
173
|
fx = cx - x1
|
|
119
174
|
fy = cy - y1
|
|
120
|
-
|
|
121
175
|
if dx == 0 and dy == 0:
|
|
122
176
|
dist = math.sqrt((cx - x1) ** 2 + (cy - y1) ** 2)
|
|
123
177
|
else:
|
|
124
|
-
t = max(0, min(1, (fx * dx + fy * dy) / (dx * dx + dy * dy)))
|
|
178
|
+
t = max(0.0, min(1.0, (fx * dx + fy * dy) / (dx * dx + dy * dy)))
|
|
125
179
|
closest_x = x1 + t * dx
|
|
126
180
|
closest_y = y1 + t * dy
|
|
127
181
|
dist = math.sqrt((cx - closest_x) ** 2 + (cy - closest_y) ** 2)
|
|
128
|
-
|
|
129
182
|
if dist <= radius:
|
|
130
183
|
return True
|
|
184
|
+
return False
|
|
185
|
+
|
|
131
186
|
|
|
187
|
+
def _circle_polygon_collision_offset(
|
|
188
|
+
cx: float, cy: float, radius: float, vertices: List[Point], ox: float, oy: float,
|
|
189
|
+
) -> bool:
|
|
190
|
+
"""Circle vs polygon with tile offset applied inline — no allocation."""
|
|
191
|
+
if _point_in_polygon_offset(cx, cy, vertices, ox, oy):
|
|
192
|
+
return True
|
|
193
|
+
n = len(vertices)
|
|
194
|
+
for i in range(n):
|
|
195
|
+
x1, y1 = vertices[i][0] + ox, vertices[i][1] + oy
|
|
196
|
+
x2, y2 = vertices[(i + 1) % n][0] + ox, vertices[(i + 1) % n][1] + oy
|
|
197
|
+
dx = x2 - x1
|
|
198
|
+
dy = y2 - y1
|
|
199
|
+
fx = cx - x1
|
|
200
|
+
fy = cy - y1
|
|
201
|
+
if dx == 0 and dy == 0:
|
|
202
|
+
dist = math.sqrt((cx - x1) ** 2 + (cy - y1) ** 2)
|
|
203
|
+
else:
|
|
204
|
+
t = max(0.0, min(1.0, (fx * dx + fy * dy) / (dx * dx + dy * dy)))
|
|
205
|
+
closest_x = x1 + t * dx
|
|
206
|
+
closest_y = y1 + t * dy
|
|
207
|
+
dist = math.sqrt((cx - closest_x) ** 2 + (cy - closest_y) ** 2)
|
|
208
|
+
if dist <= radius:
|
|
209
|
+
return True
|
|
132
210
|
return False
|
|
133
211
|
|
|
134
212
|
|
|
135
213
|
def get_shape_bounds(sprite: ICollidableSprite) -> Tuple[float, float, float, float]:
|
|
136
214
|
"""Get AABB bounds for sprite (left, top, right, bottom)"""
|
|
137
215
|
shape = sprite.collision_shape
|
|
138
|
-
|
|
139
216
|
if isinstance(shape, RectangleShape):
|
|
140
217
|
left = sprite.x + shape.offset[0]
|
|
141
|
-
top
|
|
218
|
+
top = sprite.y + shape.offset[1]
|
|
142
219
|
return (left, top, left + shape.width, top + shape.height)
|
|
143
220
|
elif isinstance(shape, CircleShape):
|
|
144
221
|
cx, cy = shape.get_center(sprite.x, sprite.y)
|
|
@@ -148,37 +225,49 @@ def get_shape_bounds(sprite: ICollidableSprite) -> Tuple[float, float, float, fl
|
|
|
148
225
|
top_center = shape.get_top_center(sprite.x, sprite.y)
|
|
149
226
|
r = shape.radius
|
|
150
227
|
h = shape.height
|
|
151
|
-
return (
|
|
152
|
-
top_center[0] - r,
|
|
153
|
-
top_center[1],
|
|
154
|
-
top_center[0] + r,
|
|
155
|
-
top_center[1] + h + r * 2,
|
|
156
|
-
)
|
|
157
|
-
|
|
228
|
+
return (top_center[0] - r, top_center[1], top_center[0] + r, top_center[1] + h + r * 2)
|
|
158
229
|
return (sprite.x, sprite.y, sprite.x + 32, sprite.y + 32)
|
|
159
230
|
|
|
160
231
|
|
|
161
232
|
def check_sprite_polygon_collision(
|
|
162
233
|
sprite: ICollidableSprite, polygon: CollisionPolygon
|
|
163
234
|
) -> bool:
|
|
164
|
-
"""Check if sprite collides with polygon"""
|
|
235
|
+
"""Check if sprite collides with a world-space polygon (legacy / public API)."""
|
|
165
236
|
shape = sprite.collision_shape
|
|
166
|
-
|
|
167
237
|
if isinstance(shape, RectangleShape):
|
|
168
238
|
left, top, right, bottom = get_shape_bounds(sprite)
|
|
169
|
-
return rect_polygon_collision(
|
|
170
|
-
left, top, right - left, bottom - top, polygon.vertices
|
|
171
|
-
)
|
|
239
|
+
return rect_polygon_collision(left, top, right - left, bottom - top, polygon.vertices)
|
|
172
240
|
elif isinstance(shape, CircleShape):
|
|
173
241
|
center = shape.get_center(sprite.x, sprite.y)
|
|
174
242
|
return circle_polygon_collision(center, shape.radius, polygon.vertices)
|
|
175
243
|
elif isinstance(shape, CapsuleShape):
|
|
176
|
-
|
|
177
244
|
left, top, right, bottom = get_shape_bounds(sprite)
|
|
178
|
-
return rect_polygon_collision(
|
|
179
|
-
|
|
180
|
-
|
|
245
|
+
return rect_polygon_collision(left, top, right - left, bottom - top, polygon.vertices)
|
|
246
|
+
return False
|
|
247
|
+
|
|
181
248
|
|
|
249
|
+
def _check_sprite_polygon_offset(
|
|
250
|
+
sprite: ICollidableSprite, polygon: CollisionPolygon, ox: float, oy: float
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""
|
|
253
|
+
Check if sprite collides with a tile-local polygon at world offset (ox, oy).
|
|
254
|
+
No allocation — offset is applied inline during math.
|
|
255
|
+
"""
|
|
256
|
+
shape = sprite.collision_shape
|
|
257
|
+
if isinstance(shape, RectangleShape):
|
|
258
|
+
left = sprite.x + shape.offset[0]
|
|
259
|
+
top = sprite.y + shape.offset[1]
|
|
260
|
+
return _rect_polygon_collision_offset(left, top, shape.width, shape.height, polygon.vertices, ox, oy)
|
|
261
|
+
elif isinstance(shape, CircleShape):
|
|
262
|
+
cx = sprite.x + shape.offset[0]
|
|
263
|
+
cy = sprite.y + shape.offset[1]
|
|
264
|
+
return _circle_polygon_collision_offset(cx, cy, shape.radius, polygon.vertices, ox, oy)
|
|
265
|
+
elif isinstance(shape, CapsuleShape):
|
|
266
|
+
left = sprite.x + shape.offset[0] - shape.radius
|
|
267
|
+
top = sprite.y + shape.offset[1]
|
|
268
|
+
w = shape.radius * 2
|
|
269
|
+
h = shape.height + shape.radius * 2
|
|
270
|
+
return _rect_polygon_collision_offset(left, top, w, h, polygon.vertices, ox, oy)
|
|
182
271
|
return False
|
|
183
272
|
|
|
184
273
|
|
|
@@ -244,6 +333,9 @@ class CollisionRunner:
|
|
|
244
333
|
self._game_type: Optional[str] = None
|
|
245
334
|
self._strict: bool = False
|
|
246
335
|
|
|
336
|
+
# Reusable result object — reset fields before each use
|
|
337
|
+
self._result = CollisionResult()
|
|
338
|
+
|
|
247
339
|
def get_tile_at(self, world_x: float, world_y: float) -> Tuple[int, int]:
|
|
248
340
|
"""Convert world position to tile coordinates"""
|
|
249
341
|
tile_x = int(world_x // self.tile_size[0])
|
|
@@ -276,30 +368,106 @@ class CollisionRunner:
|
|
|
276
368
|
sprite: ICollidableSprite,
|
|
277
369
|
margin: int = 1,
|
|
278
370
|
) -> List[CollisionPolygon]:
|
|
279
|
-
"""
|
|
371
|
+
"""
|
|
372
|
+
Get all world-space collision shapes near sprite.
|
|
373
|
+
|
|
374
|
+
Returns transformed CollisionPolygon objects (world space).
|
|
375
|
+
For internal movement use, the runner uses _collides_at() which avoids
|
|
376
|
+
this allocation entirely.
|
|
377
|
+
"""
|
|
280
378
|
left, top, right, bottom = get_shape_bounds(sprite)
|
|
379
|
+
tw, th = self.tile_size
|
|
281
380
|
|
|
282
|
-
min_tile_x = int(left
|
|
283
|
-
max_tile_x = int(right //
|
|
284
|
-
min_tile_y = int(top
|
|
285
|
-
max_tile_y = int(bottom
|
|
381
|
+
min_tile_x = int(left // tw) - margin
|
|
382
|
+
max_tile_x = int(right // tw) + margin
|
|
383
|
+
min_tile_y = int(top // th) - margin
|
|
384
|
+
max_tile_y = int(bottom// th) + margin
|
|
286
385
|
|
|
287
386
|
shapes = []
|
|
288
387
|
for tile_y in range(min_tile_y, max_tile_y + 1):
|
|
289
388
|
for tile_x in range(min_tile_x, max_tile_x + 1):
|
|
290
389
|
tile_id = tile_map.get((tile_x, tile_y))
|
|
291
|
-
if tile_id is None
|
|
390
|
+
if tile_id is None:
|
|
292
391
|
continue
|
|
392
|
+
tile_data = tileset_collision.tiles.get(tile_id)
|
|
393
|
+
if tile_data is None:
|
|
394
|
+
continue
|
|
395
|
+
tile_world_x = tile_x * tw
|
|
396
|
+
tile_world_y = tile_y * th
|
|
397
|
+
for poly in tile_data.shapes:
|
|
398
|
+
if poly.is_valid():
|
|
399
|
+
shapes.append(poly.transform(tile_world_x, tile_world_y))
|
|
400
|
+
return shapes
|
|
401
|
+
|
|
402
|
+
def _collides_at(
|
|
403
|
+
self,
|
|
404
|
+
sprite: ICollidableSprite,
|
|
405
|
+
tileset_collision: TilesetCollision,
|
|
406
|
+
tile_map: dict,
|
|
407
|
+
margin: int = 1,
|
|
408
|
+
) -> bool:
|
|
409
|
+
"""
|
|
410
|
+
Check if sprite collides with any tile at its current position.
|
|
293
411
|
|
|
294
|
-
|
|
295
|
-
|
|
412
|
+
No allocation — iterates tiles and shapes directly, applies tile offset
|
|
413
|
+
inline, exits immediately on first hit.
|
|
414
|
+
"""
|
|
415
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
416
|
+
tw, th = self.tile_size
|
|
296
417
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
418
|
+
min_tile_x = int(left // tw) - margin
|
|
419
|
+
max_tile_x = int(right // tw) + margin
|
|
420
|
+
min_tile_y = int(top // th) - margin
|
|
421
|
+
max_tile_y = int(bottom// th) + margin
|
|
301
422
|
|
|
302
|
-
|
|
423
|
+
for tile_y in range(min_tile_y, max_tile_y + 1):
|
|
424
|
+
for tile_x in range(min_tile_x, max_tile_x + 1):
|
|
425
|
+
tile_id = tile_map.get((tile_x, tile_y))
|
|
426
|
+
if tile_id is None:
|
|
427
|
+
continue
|
|
428
|
+
tile_data = tileset_collision.tiles.get(tile_id)
|
|
429
|
+
if tile_data is None:
|
|
430
|
+
continue
|
|
431
|
+
ox = tile_x * tw
|
|
432
|
+
oy = tile_y * th
|
|
433
|
+
for poly in tile_data.shapes:
|
|
434
|
+
if poly.is_valid() and _check_sprite_polygon_offset(sprite, poly, ox, oy):
|
|
435
|
+
return True
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
def _first_colliding_shape(
|
|
439
|
+
self,
|
|
440
|
+
sprite: ICollidableSprite,
|
|
441
|
+
tileset_collision: TilesetCollision,
|
|
442
|
+
tile_map: dict,
|
|
443
|
+
margin: int = 1,
|
|
444
|
+
) -> Optional[Tuple[CollisionPolygon, float, float]]:
|
|
445
|
+
"""
|
|
446
|
+
Return (polygon, tile_ox, tile_oy) for the first colliding shape, or None.
|
|
447
|
+
Used by slope_slide to get the normal without allocating a full list.
|
|
448
|
+
"""
|
|
449
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
450
|
+
tw, th = self.tile_size
|
|
451
|
+
|
|
452
|
+
min_tile_x = int(left // tw) - margin
|
|
453
|
+
max_tile_x = int(right // tw) + margin
|
|
454
|
+
min_tile_y = int(top // th) - margin
|
|
455
|
+
max_tile_y = int(bottom// th) + margin
|
|
456
|
+
|
|
457
|
+
for tile_y in range(min_tile_y, max_tile_y + 1):
|
|
458
|
+
for tile_x in range(min_tile_x, max_tile_x + 1):
|
|
459
|
+
tile_id = tile_map.get((tile_x, tile_y))
|
|
460
|
+
if tile_id is None:
|
|
461
|
+
continue
|
|
462
|
+
tile_data = tileset_collision.tiles.get(tile_id)
|
|
463
|
+
if tile_data is None:
|
|
464
|
+
continue
|
|
465
|
+
ox = tile_x * tw
|
|
466
|
+
oy = tile_y * th
|
|
467
|
+
for poly in tile_data.shapes:
|
|
468
|
+
if poly.is_valid() and _check_sprite_polygon_offset(sprite, poly, ox, oy):
|
|
469
|
+
return (poly, ox, oy)
|
|
470
|
+
return None
|
|
303
471
|
|
|
304
472
|
def move_and_slide(
|
|
305
473
|
self,
|
|
@@ -326,7 +494,15 @@ class CollisionRunner:
|
|
|
326
494
|
Returns:
|
|
327
495
|
CollisionResult with final position and collision info
|
|
328
496
|
"""
|
|
329
|
-
result =
|
|
497
|
+
result = self._result
|
|
498
|
+
result.collided = False
|
|
499
|
+
result.hit_wall_x = False
|
|
500
|
+
result.hit_wall_y = False
|
|
501
|
+
result.hit_ceiling = False
|
|
502
|
+
result.on_ground = False
|
|
503
|
+
result.slide_vector = None
|
|
504
|
+
result.final_x = sprite.x
|
|
505
|
+
result.final_y = sprite.y
|
|
330
506
|
|
|
331
507
|
if delta_x == 0 and delta_y == 0:
|
|
332
508
|
return result
|
|
@@ -337,25 +513,15 @@ class CollisionRunner:
|
|
|
337
513
|
max_slides = 4
|
|
338
514
|
motion_x, motion_y = delta_x, delta_y
|
|
339
515
|
|
|
340
|
-
for
|
|
516
|
+
for _ in range(max_slides):
|
|
341
517
|
if abs(motion_x) < 0.01 and abs(motion_y) < 0.01:
|
|
342
518
|
break
|
|
343
519
|
|
|
344
520
|
sprite.x = old_x + motion_x
|
|
345
521
|
sprite.y = old_y + motion_y
|
|
346
522
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
colliding_shape = None
|
|
352
|
-
for shape in shapes:
|
|
353
|
-
if check_sprite_polygon_collision(sprite, shape):
|
|
354
|
-
colliding_shape = shape
|
|
355
|
-
break
|
|
356
|
-
|
|
357
|
-
if not colliding_shape:
|
|
358
|
-
|
|
523
|
+
hit = self._first_colliding_shape(sprite, tileset_collision, tile_map)
|
|
524
|
+
if hit is None:
|
|
359
525
|
result.final_x = sprite.x
|
|
360
526
|
result.final_y = sprite.y
|
|
361
527
|
return result
|
|
@@ -364,78 +530,56 @@ class CollisionRunner:
|
|
|
364
530
|
sprite.y = old_y
|
|
365
531
|
result.collided = True
|
|
366
532
|
|
|
533
|
+
poly, ox, oy = hit
|
|
367
534
|
normal = self._get_collision_normal_from_motion(
|
|
368
|
-
sprite,
|
|
535
|
+
sprite, poly, ox, oy, motion_x, motion_y
|
|
369
536
|
)
|
|
370
|
-
|
|
371
537
|
if normal:
|
|
372
|
-
|
|
373
538
|
dot = motion_x * normal[0] + motion_y * normal[1]
|
|
374
|
-
|
|
375
539
|
if dot < 0:
|
|
376
|
-
motion_x
|
|
377
|
-
motion_y
|
|
540
|
+
motion_x -= normal[0] * dot
|
|
541
|
+
motion_y -= normal[1] * dot
|
|
378
542
|
else:
|
|
379
|
-
|
|
380
543
|
break
|
|
381
544
|
else:
|
|
382
|
-
|
|
383
545
|
break
|
|
384
546
|
|
|
385
547
|
result.final_x = sprite.x
|
|
386
548
|
result.final_y = sprite.y
|
|
387
549
|
return result
|
|
388
550
|
|
|
389
|
-
|
|
390
|
-
sprite.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
collided = any(
|
|
395
|
-
check_sprite_polygon_collision(sprite, shape) for shape in shapes
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
if not collided:
|
|
551
|
+
# Non-slope: try full move first (fast path — no collision)
|
|
552
|
+
sprite.x = old_x + delta_x
|
|
553
|
+
sprite.y = old_y + delta_y
|
|
554
|
+
if not self._collides_at(sprite, tileset_collision, tile_map):
|
|
399
555
|
result.final_x = sprite.x
|
|
400
556
|
result.final_y = sprite.y
|
|
401
557
|
return result
|
|
402
558
|
|
|
403
559
|
result.collided = True
|
|
404
560
|
|
|
561
|
+
# X axis — spatially correct scan at the x-only position
|
|
405
562
|
sprite.x = old_x + delta_x
|
|
406
563
|
sprite.y = old_y
|
|
407
|
-
|
|
408
|
-
x_collided = any(
|
|
409
|
-
check_sprite_polygon_collision(sprite, shape) for shape in shapes
|
|
410
|
-
)
|
|
411
|
-
|
|
564
|
+
x_collided = self._collides_at(sprite, tileset_collision, tile_map)
|
|
412
565
|
if x_collided:
|
|
413
566
|
sprite.x = old_x
|
|
414
567
|
result.hit_wall_x = True
|
|
415
568
|
|
|
416
|
-
|
|
569
|
+
# Y axis — spatially correct scan at the y-only position
|
|
417
570
|
sprite.y = old_y + delta_y
|
|
418
|
-
|
|
419
|
-
y_collided = any(
|
|
420
|
-
check_sprite_polygon_collision(sprite, shape) for shape in shapes
|
|
421
|
-
)
|
|
422
|
-
|
|
571
|
+
y_collided = self._collides_at(sprite, tileset_collision, tile_map)
|
|
423
572
|
if y_collided:
|
|
424
573
|
sprite.y = old_y
|
|
425
574
|
result.hit_wall_y = True
|
|
426
575
|
|
|
427
|
-
if not x_collided:
|
|
428
|
-
sprite.x = old_x + delta_x
|
|
429
|
-
if not y_collided:
|
|
430
|
-
sprite.y = old_y + delta_y
|
|
431
|
-
|
|
432
576
|
result.final_x = sprite.x
|
|
433
577
|
result.final_y = sprite.y
|
|
434
578
|
|
|
435
579
|
if x_collided and not y_collided:
|
|
436
|
-
result.slide_vector = (0, delta_y)
|
|
580
|
+
result.slide_vector = (0.0, delta_y)
|
|
437
581
|
elif y_collided and not x_collided:
|
|
438
|
-
result.slide_vector = (delta_x, 0)
|
|
582
|
+
result.slide_vector = (delta_x, 0.0)
|
|
439
583
|
|
|
440
584
|
return result
|
|
441
585
|
|
|
@@ -443,53 +587,55 @@ class CollisionRunner:
|
|
|
443
587
|
self,
|
|
444
588
|
sprite: ICollidableSprite,
|
|
445
589
|
polygon: CollisionPolygon,
|
|
590
|
+
ox: float,
|
|
591
|
+
oy: float,
|
|
446
592
|
motion_x: float,
|
|
447
593
|
motion_y: float,
|
|
448
594
|
) -> Optional[Tuple[float, float]]:
|
|
449
595
|
"""
|
|
450
|
-
Calculate the collision normal
|
|
451
|
-
Returns the normal of the edge
|
|
596
|
+
Calculate the collision normal for a tile-local polygon at offset (ox, oy).
|
|
597
|
+
Returns the outward normal of the edge most aligned against motion.
|
|
452
598
|
"""
|
|
453
|
-
|
|
454
|
-
bounds = get_shape_bounds(sprite)
|
|
455
|
-
center_x = (bounds[0] + bounds[2]) / 2
|
|
456
|
-
center_y = (bounds[1] + bounds[3]) / 2
|
|
457
|
-
|
|
458
599
|
vertices = polygon.vertices
|
|
459
|
-
|
|
600
|
+
n = len(vertices)
|
|
601
|
+
if n < 2:
|
|
460
602
|
return None
|
|
461
603
|
|
|
604
|
+
# Compute polygon center once (tile-local, offset applied)
|
|
605
|
+
poly_cx = ox
|
|
606
|
+
poly_cy = oy
|
|
607
|
+
for vx, vy in vertices:
|
|
608
|
+
poly_cx += vx
|
|
609
|
+
poly_cy += vy
|
|
610
|
+
poly_cx /= n
|
|
611
|
+
poly_cy /= n
|
|
612
|
+
|
|
462
613
|
best_edge = None
|
|
463
|
-
best_alignment = -1
|
|
614
|
+
best_alignment = -1.0
|
|
464
615
|
|
|
465
|
-
for i in range(
|
|
466
|
-
|
|
467
|
-
|
|
616
|
+
for i in range(n):
|
|
617
|
+
v1x, v1y = vertices[i][0] + ox, vertices[i][1] + oy
|
|
618
|
+
v2x, v2y = vertices[(i + 1) % n][0] + ox, vertices[(i + 1) % n][1] + oy
|
|
468
619
|
|
|
469
|
-
edge_x =
|
|
470
|
-
edge_y =
|
|
620
|
+
edge_x = v2x - v1x
|
|
621
|
+
edge_y = v2y - v1y
|
|
471
622
|
edge_len = math.sqrt(edge_x * edge_x + edge_y * edge_y)
|
|
472
|
-
|
|
473
623
|
if edge_len < 0.01:
|
|
474
624
|
continue
|
|
475
625
|
|
|
476
626
|
normal_x = -edge_y / edge_len
|
|
477
|
-
normal_y =
|
|
478
|
-
|
|
479
|
-
poly_center_x = sum(v[0] for v in vertices) / len(vertices)
|
|
480
|
-
poly_center_y = sum(v[1] for v in vertices) / len(vertices)
|
|
481
|
-
edge_mid_x = (v1[0] + v2[0]) / 2
|
|
482
|
-
edge_mid_y = (v1[1] + v2[1]) / 2
|
|
627
|
+
normal_y = edge_x / edge_len
|
|
483
628
|
|
|
484
|
-
|
|
485
|
-
|
|
629
|
+
edge_mid_x = (v1x + v2x) * 0.5
|
|
630
|
+
edge_mid_y = (v1y + v2y) * 0.5
|
|
631
|
+
to_outside_x = edge_mid_x - poly_cx
|
|
632
|
+
to_outside_y = edge_mid_y - poly_cy
|
|
486
633
|
|
|
487
634
|
if normal_x * to_outside_x + normal_y * to_outside_y < 0:
|
|
488
635
|
normal_x = -normal_x
|
|
489
636
|
normal_y = -normal_y
|
|
490
637
|
|
|
491
638
|
alignment = -(motion_x * normal_x + motion_y * normal_y)
|
|
492
|
-
|
|
493
639
|
if alignment > best_alignment and alignment > 0:
|
|
494
640
|
best_alignment = alignment
|
|
495
641
|
best_edge = (normal_x, normal_y)
|
|
@@ -521,11 +667,20 @@ class CollisionRunner:
|
|
|
521
667
|
Returns:
|
|
522
668
|
CollisionResult with final position and collision info
|
|
523
669
|
"""
|
|
524
|
-
result =
|
|
670
|
+
result = self._result
|
|
671
|
+
result.collided = False
|
|
672
|
+
result.hit_wall_x = False
|
|
673
|
+
result.hit_wall_y = False
|
|
674
|
+
result.hit_ceiling = False
|
|
675
|
+
result.on_ground = False
|
|
676
|
+
result.slide_vector = None
|
|
677
|
+
result.final_x = sprite.x
|
|
678
|
+
result.final_y = sprite.y
|
|
525
679
|
|
|
526
680
|
if not getattr(sprite, "on_ground", False):
|
|
527
681
|
sprite.vy += self.gravity * dt
|
|
528
|
-
sprite.vy
|
|
682
|
+
if sprite.vy > self.max_fall_speed:
|
|
683
|
+
sprite.vy = self.max_fall_speed
|
|
529
684
|
|
|
530
685
|
if jump_pressed and getattr(sprite, "on_ground", False):
|
|
531
686
|
sprite.vy = self.jump_strength
|
|
@@ -534,40 +689,63 @@ class CollisionRunner:
|
|
|
534
689
|
|
|
535
690
|
delta_x = sprite.vx * dt
|
|
536
691
|
delta_y = sprite.vy * dt
|
|
537
|
-
|
|
538
692
|
old_x, old_y = sprite.x, sprite.y
|
|
539
693
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if any(check_sprite_polygon_collision(sprite, shape) for shape in shapes):
|
|
694
|
+
# X axis
|
|
695
|
+
sprite.x = old_x + delta_x
|
|
696
|
+
if self._collides_at(sprite, tileset_collision, tile_map):
|
|
544
697
|
sprite.x = old_x
|
|
545
|
-
sprite.vx = 0
|
|
698
|
+
sprite.vx = 0.0
|
|
546
699
|
result.hit_wall_x = True
|
|
547
700
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
701
|
+
# Y axis — check one-way platforms
|
|
702
|
+
sprite.y = old_y + delta_y
|
|
551
703
|
collided_y = False
|
|
552
704
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
705
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
706
|
+
tw, th = self.tile_size
|
|
707
|
+
min_tile_x = int(left // tw) - 1
|
|
708
|
+
max_tile_x = int(right // tw) + 1
|
|
709
|
+
min_tile_y = int(top // th) - 1
|
|
710
|
+
max_tile_y = int(bottom// th) + 1
|
|
711
|
+
|
|
712
|
+
for tile_y in range(min_tile_y, max_tile_y + 1):
|
|
713
|
+
for tile_x in range(min_tile_x, max_tile_x + 1):
|
|
714
|
+
tile_id = tile_map.get((tile_x, tile_y))
|
|
715
|
+
if tile_id is None:
|
|
716
|
+
continue
|
|
717
|
+
tile_data = tileset_collision.tiles.get(tile_id)
|
|
718
|
+
if tile_data is None:
|
|
719
|
+
continue
|
|
720
|
+
ox = tile_x * tw
|
|
721
|
+
oy = tile_y * th
|
|
722
|
+
for poly in tile_data.shapes:
|
|
723
|
+
if not poly.is_valid():
|
|
724
|
+
continue
|
|
725
|
+
if not _check_sprite_polygon_offset(sprite, poly, ox, oy):
|
|
726
|
+
continue
|
|
727
|
+
if poly.one_way and sprite.vy > 0:
|
|
728
|
+
# one-way: only block if sprite was above the platform top
|
|
729
|
+
min_vy = min(v[1] for v in poly.vertices) + oy
|
|
730
|
+
if old_y + (bottom - top) <= min_vy:
|
|
731
|
+
collided_y = True
|
|
732
|
+
break
|
|
733
|
+
elif not poly.one_way:
|
|
559
734
|
collided_y = True
|
|
560
735
|
break
|
|
736
|
+
if collided_y:
|
|
737
|
+
break
|
|
738
|
+
if collided_y:
|
|
739
|
+
break
|
|
561
740
|
|
|
562
741
|
if collided_y:
|
|
563
742
|
sprite.y = old_y
|
|
564
|
-
|
|
565
743
|
if sprite.vy > 0:
|
|
566
|
-
sprite.vy = 0
|
|
744
|
+
sprite.vy = 0.0
|
|
567
745
|
sprite.on_ground = True
|
|
568
746
|
result.on_ground = True
|
|
569
747
|
elif sprite.vy < 0:
|
|
570
|
-
sprite.vy = 0
|
|
748
|
+
sprite.vy = 0.0
|
|
571
749
|
result.hit_ceiling = True
|
|
572
750
|
else:
|
|
573
751
|
sprite.on_ground = False
|
|
@@ -575,7 +753,6 @@ class CollisionRunner:
|
|
|
575
753
|
result.final_x = sprite.x
|
|
576
754
|
result.final_y = sprite.y
|
|
577
755
|
result.collided = result.hit_wall_x or collided_y
|
|
578
|
-
|
|
579
756
|
return result
|
|
580
757
|
|
|
581
758
|
def move_rpg(
|
|
@@ -601,26 +778,27 @@ class CollisionRunner:
|
|
|
601
778
|
Returns:
|
|
602
779
|
CollisionResult with final position and collision info
|
|
603
780
|
"""
|
|
604
|
-
result =
|
|
781
|
+
result = self._result
|
|
782
|
+
result.collided = False
|
|
783
|
+
result.hit_wall_x = False
|
|
784
|
+
result.hit_wall_y = False
|
|
785
|
+
result.hit_ceiling = False
|
|
786
|
+
result.on_ground = False
|
|
787
|
+
result.slide_vector = None
|
|
788
|
+
result.final_x = sprite.x
|
|
789
|
+
result.final_y = sprite.y
|
|
605
790
|
|
|
606
791
|
if delta_x == 0 and delta_y == 0:
|
|
607
792
|
return result
|
|
608
793
|
|
|
609
794
|
old_x, old_y = sprite.x, sprite.y
|
|
610
|
-
sprite.x
|
|
611
|
-
sprite.y
|
|
612
|
-
|
|
613
|
-
shapes = self.get_nearby_tile_shapes(tileset_collision, tile_map, sprite)
|
|
614
|
-
|
|
615
|
-
collided = any(
|
|
616
|
-
check_sprite_polygon_collision(sprite, shape) for shape in shapes
|
|
617
|
-
)
|
|
618
|
-
|
|
619
|
-
if collided:
|
|
795
|
+
sprite.x = old_x + delta_x
|
|
796
|
+
sprite.y = old_y + delta_y
|
|
620
797
|
|
|
798
|
+
if self._collides_at(sprite, tileset_collision, tile_map):
|
|
621
799
|
sprite.x = old_x
|
|
622
800
|
sprite.y = old_y
|
|
623
|
-
result.collided
|
|
801
|
+
result.collided = True
|
|
624
802
|
result.hit_wall_x = delta_x != 0
|
|
625
803
|
result.hit_wall_y = delta_y != 0
|
|
626
804
|
else:
|
|
@@ -13,4 +13,5 @@ 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
15
|
src/tilemap_parser.egg-info/top_level.txt
|
|
16
|
-
tests/test_collision.py
|
|
16
|
+
tests/test_collision.py
|
|
17
|
+
tests/test_collision_runner.py
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for tilemap_parser.collision_runner.
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
- Geometry: point_in_polygon, rect_polygon_collision, circle_polygon_collision
|
|
6
|
+
- Runner: move_and_slide, move_platformer, move_rpg
|
|
7
|
+
- Edge cases: one-way platforms, slope sliding, corner collision
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
15
|
+
|
|
16
|
+
from tilemap_parser.collision import (
|
|
17
|
+
CollisionCache,
|
|
18
|
+
CollisionPolygon,
|
|
19
|
+
RectangleShape,
|
|
20
|
+
CircleShape,
|
|
21
|
+
CapsuleShape,
|
|
22
|
+
TileCollisionData,
|
|
23
|
+
TilesetCollision,
|
|
24
|
+
load_tileset_collision,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from tilemap_parser.collision_runner import (
|
|
28
|
+
CollisionRunner,
|
|
29
|
+
MovementMode,
|
|
30
|
+
point_in_polygon,
|
|
31
|
+
rect_polygon_collision,
|
|
32
|
+
circle_polygon_collision,
|
|
33
|
+
get_shape_bounds,
|
|
34
|
+
check_sprite_polygon_collision,
|
|
35
|
+
_point_in_polygon_offset,
|
|
36
|
+
_rect_polygon_collision_offset,
|
|
37
|
+
_circle_polygon_collision_offset,
|
|
38
|
+
_check_sprite_polygon_offset,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
FIXTURES = Path(__file__).parent.parent / "examples" / "fixtures"
|
|
43
|
+
TILESET_COLLISION = FIXTURES / "collision" / "Terrain (32x32).collision.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
FULL_TILE_POLY = [(0.0, 0.0), (32.0, 0.0), (32.0, 32.0), (0.0, 32.0)]
|
|
47
|
+
SLOPE_POLY = [(0.0, 32.0), (32.0, 0.0), (32.0, 32.0)]
|
|
48
|
+
HALF_TILE_POLY = [(0.0, 16.0), (32.0, 16.0), (32.0, 32.0), (0.0, 32.0)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MockSprite:
|
|
52
|
+
def __init__(self, x=0, y=0, shape=None):
|
|
53
|
+
self.x = x
|
|
54
|
+
self.y = y
|
|
55
|
+
self.vx = 0
|
|
56
|
+
self.vy = 0
|
|
57
|
+
self.on_ground = False
|
|
58
|
+
self.collision_shape = shape or RectangleShape(width=24, height=32)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ===========================================================================
|
|
62
|
+
# Geometry Tests: point_in_polygon
|
|
63
|
+
# ===========================================================================
|
|
64
|
+
|
|
65
|
+
class TestPointInPolygon:
|
|
66
|
+
def test_point_outside_square(self):
|
|
67
|
+
assert point_in_polygon((10, 10), FULL_TILE_POLY) is True
|
|
68
|
+
assert point_in_polygon((-1, 10), FULL_TILE_POLY) is False
|
|
69
|
+
assert point_in_polygon((10, -1), FULL_TILE_POLY) is False
|
|
70
|
+
|
|
71
|
+
def test_point_on_edge(self):
|
|
72
|
+
assert point_in_polygon((16, 0), FULL_TILE_POLY) is False
|
|
73
|
+
|
|
74
|
+
def test_point_on_corner(self):
|
|
75
|
+
assert point_in_polygon((0, 0), FULL_TILE_POLY) is False
|
|
76
|
+
|
|
77
|
+
def test_triangle_slope(self):
|
|
78
|
+
assert point_in_polygon((16, 24), SLOPE_POLY) is True
|
|
79
|
+
assert point_in_polygon((8, 28), SLOPE_POLY) is True
|
|
80
|
+
|
|
81
|
+
def test_offset_version(self):
|
|
82
|
+
assert _point_in_polygon_offset(10, 10, FULL_TILE_POLY, 0, 0) is True
|
|
83
|
+
assert _point_in_polygon_offset(10, 10, FULL_TILE_POLY, 100, 200) is False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ===========================================================================
|
|
87
|
+
# Geometry Tests: rect_polygon_collision
|
|
88
|
+
# ===========================================================================
|
|
89
|
+
|
|
90
|
+
class TestRectPolygonCollision:
|
|
91
|
+
def test_rect_inside_polygon(self):
|
|
92
|
+
assert rect_polygon_collision(8, 8, 16, 16, FULL_TILE_POLY) is True
|
|
93
|
+
|
|
94
|
+
def test_rect_outside_polygon(self):
|
|
95
|
+
assert rect_polygon_collision(-10, -10, 8, 8, FULL_TILE_POLY) is False
|
|
96
|
+
|
|
97
|
+
def test_rect_overlapping_edge(self):
|
|
98
|
+
assert rect_polygon_collision(-8, 8, 16, 16, FULL_TILE_POLY) is True
|
|
99
|
+
assert rect_polygon_collision(24, 8, 16, 16, FULL_TILE_POLY) is True
|
|
100
|
+
|
|
101
|
+
def test_rect_with_offset(self):
|
|
102
|
+
poly = [(100, 200), (132, 200), (132, 232), (100, 232)]
|
|
103
|
+
assert rect_polygon_collision(108, 208, 16, 16, poly) is True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ===========================================================================
|
|
107
|
+
# Geometry Tests: circle_polygon_collision
|
|
108
|
+
# ===========================================================================
|
|
109
|
+
|
|
110
|
+
class TestCirclePolygonCollision:
|
|
111
|
+
def test_circle_center_inside(self):
|
|
112
|
+
assert circle_polygon_collision((16, 16), 8, FULL_TILE_POLY) is True
|
|
113
|
+
|
|
114
|
+
def test_circle_outside(self):
|
|
115
|
+
assert circle_polygon_collision((-10, 16), 8, FULL_TILE_POLY) is False
|
|
116
|
+
|
|
117
|
+
def test_circle_overlapping_edge(self):
|
|
118
|
+
assert circle_polygon_collision((32, 16), 8, FULL_TILE_POLY) is True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ===========================================================================
|
|
122
|
+
# Shape Bounds Tests
|
|
123
|
+
# ===========================================================================
|
|
124
|
+
|
|
125
|
+
class TestShapeBounds:
|
|
126
|
+
def test_rectangle_bounds(self):
|
|
127
|
+
sprite = MockSprite(x=100, y=200, shape=RectangleShape(width=24, height=32, offset=(4, 0)))
|
|
128
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
129
|
+
assert left == 104
|
|
130
|
+
assert top == 200
|
|
131
|
+
assert right == 128
|
|
132
|
+
assert bottom == 232
|
|
133
|
+
|
|
134
|
+
def test_circle_bounds(self):
|
|
135
|
+
sprite = MockSprite(x=100, y=200, shape=CircleShape(radius=16, offset=(8, 4)))
|
|
136
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
137
|
+
assert left == 92
|
|
138
|
+
assert top == 188
|
|
139
|
+
assert right == 124
|
|
140
|
+
assert bottom == 220
|
|
141
|
+
|
|
142
|
+
def test_capsule_bounds(self):
|
|
143
|
+
sprite = MockSprite(x=100, y=200, shape=CapsuleShape(radius=8, height=48, offset=(0, 0)))
|
|
144
|
+
left, top, right, bottom = get_shape_bounds(sprite)
|
|
145
|
+
assert left == 92
|
|
146
|
+
assert top == 200
|
|
147
|
+
assert right == 108
|
|
148
|
+
assert bottom == 264
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ===========================================================================
|
|
152
|
+
# Runner: Setup Helpers
|
|
153
|
+
# ===========================================================================
|
|
154
|
+
|
|
155
|
+
def make_tileset_with_floor():
|
|
156
|
+
tiles = {
|
|
157
|
+
0: TileCollisionData(tile_id=0, shapes=[CollisionPolygon(vertices=FULL_TILE_POLY)]),
|
|
158
|
+
1: TileCollisionData(tile_id=1, shapes=[CollisionPolygon(vertices=HALF_TILE_POLY, one_way=True)]),
|
|
159
|
+
2: TileCollisionData(tile_id=2, shapes=[CollisionPolygon(vertices=SLOPE_POLY)]),
|
|
160
|
+
}
|
|
161
|
+
return TilesetCollision(tileset_name="test", tile_size=(32, 32), tiles=tiles)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def make_tilemap_floor_only():
|
|
165
|
+
tile_map = {}
|
|
166
|
+
for x in range(10):
|
|
167
|
+
for y in range(2):
|
|
168
|
+
tile_map[(x, y)] = 0
|
|
169
|
+
return tile_map
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def make_tilemap_with_one_way():
|
|
173
|
+
tile_map = {(5, 5): 1}
|
|
174
|
+
return tile_map
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ===========================================================================
|
|
178
|
+
# Runner: move_and_slide Tests
|
|
179
|
+
# ===========================================================================
|
|
180
|
+
|
|
181
|
+
class TestMoveAndSlide:
|
|
182
|
+
def setup_method(self):
|
|
183
|
+
self.cache = CollisionCache()
|
|
184
|
+
self.runner = CollisionRunner.from_game_type("topdown", self.cache, (32, 32))
|
|
185
|
+
self.tileset = make_tileset_with_floor()
|
|
186
|
+
|
|
187
|
+
def test_move_through_empty_space(self):
|
|
188
|
+
tile_map = {}
|
|
189
|
+
sprite = MockSprite(x=100, y=100)
|
|
190
|
+
|
|
191
|
+
result = self.runner.move_and_slide(sprite, self.tileset, tile_map, 5, 5)
|
|
192
|
+
|
|
193
|
+
assert result.final_x == 105
|
|
194
|
+
assert result.final_y == 105
|
|
195
|
+
|
|
196
|
+
def test_wall_block_x(self):
|
|
197
|
+
tile_map = make_tilemap_floor_only()
|
|
198
|
+
sprite = MockSprite(x=96, y=32)
|
|
199
|
+
|
|
200
|
+
result = self.runner.move_and_slide(sprite, self.tileset, tile_map, 5, 0)
|
|
201
|
+
|
|
202
|
+
assert result.final_x == 96
|
|
203
|
+
|
|
204
|
+
def test_wall_block_y(self):
|
|
205
|
+
tile_map = make_tilemap_floor_only()
|
|
206
|
+
sprite = MockSprite(x=100, y=56)
|
|
207
|
+
|
|
208
|
+
result = self.runner.move_and_slide(sprite, self.tileset, tile_map, 0, 5)
|
|
209
|
+
|
|
210
|
+
assert result.final_y == 56
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ===========================================================================
|
|
214
|
+
# Runner: move_platformer Tests
|
|
215
|
+
# ===========================================================================
|
|
216
|
+
|
|
217
|
+
class TestMovePlatformer:
|
|
218
|
+
def setup_method(self):
|
|
219
|
+
self.cache = CollisionCache()
|
|
220
|
+
self.runner = CollisionRunner.from_game_type("platformer", self.cache, (32, 32))
|
|
221
|
+
self.tileset = make_tileset_with_floor()
|
|
222
|
+
|
|
223
|
+
def test_gravity_applied(self):
|
|
224
|
+
tile_map = {}
|
|
225
|
+
sprite = MockSprite(x=100, y=100)
|
|
226
|
+
sprite.vy = 0
|
|
227
|
+
sprite.on_ground = False
|
|
228
|
+
|
|
229
|
+
self.runner.move_platformer(sprite, self.tileset, tile_map, dt=0.016, input_x=0, jump_pressed=False)
|
|
230
|
+
|
|
231
|
+
assert sprite.vy > 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TestMoveRpg:
|
|
235
|
+
def setup_method(self):
|
|
236
|
+
self.cache = CollisionCache()
|
|
237
|
+
self.runner = CollisionRunner.from_game_type("rpg", self.cache, (32, 32))
|
|
238
|
+
self.tileset = make_tileset_with_floor()
|
|
239
|
+
|
|
240
|
+
def test_move_blocked_by_wall(self):
|
|
241
|
+
tile_map = make_tilemap_floor_only()
|
|
242
|
+
sprite = MockSprite(x=100, y=55)
|
|
243
|
+
|
|
244
|
+
result = self.runner.move_rpg(sprite, self.tileset, tile_map, 5, 5)
|
|
245
|
+
|
|
246
|
+
assert result.final_x == 100
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ===========================================================================
|
|
250
|
+
# Runner: One-Way Platforms
|
|
251
|
+
# ===========================================================================
|
|
252
|
+
|
|
253
|
+
class TestOneWayPlatforms:
|
|
254
|
+
def setup_method(self):
|
|
255
|
+
self.cache = CollisionCache()
|
|
256
|
+
self.runner = CollisionRunner.from_game_type("platformer", self.cache, (32, 32))
|
|
257
|
+
self.tileset = make_tileset_with_floor()
|
|
258
|
+
|
|
259
|
+
def test_pass_through_from_below(self):
|
|
260
|
+
tile_map = make_tilemap_with_one_way()
|
|
261
|
+
sprite = MockSprite(x=150, y=140)
|
|
262
|
+
sprite.vy = 100
|
|
263
|
+
sprite.on_ground = False
|
|
264
|
+
|
|
265
|
+
result = self.runner.move_platformer(sprite, self.tileset, tile_map, dt=0.016, input_x=0, jump_pressed=False)
|
|
266
|
+
|
|
267
|
+
assert sprite.y > 140
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class TestSlopeSlide:
|
|
271
|
+
def setup_method(self):
|
|
272
|
+
self.cache = CollisionCache()
|
|
273
|
+
self.runner = CollisionRunner.from_game_type("topdown", self.cache, (32, 32))
|
|
274
|
+
tiles = {2: TileCollisionData(tile_id=2, shapes=[CollisionPolygon(vertices=SLOPE_POLY)])}
|
|
275
|
+
self.tileset = TilesetCollision(tileset_name="slope_test", tile_size=(32, 32), tiles=tiles)
|
|
276
|
+
|
|
277
|
+
def test_slope_slide_works(self):
|
|
278
|
+
tile_map = {(5, 5): 2}
|
|
279
|
+
sprite = MockSprite(x=150, y=170)
|
|
280
|
+
|
|
281
|
+
result = self.runner.move_and_slide(sprite, self.tileset, tile_map, 10, 10, slope_slide=True)
|
|
282
|
+
|
|
283
|
+
assert result.collided is True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ===========================================================================
|
|
287
|
+
# Integration: Realistic Map
|
|
288
|
+
# ===========================================================================
|
|
289
|
+
|
|
290
|
+
class TestRealisticMap:
|
|
291
|
+
def setup_method(self):
|
|
292
|
+
self.cache = CollisionCache()
|
|
293
|
+
self.runner = CollisionRunner.from_game_type("topdown", self.cache, (32, 32))
|
|
294
|
+
self.tileset = load_tileset_collision(TILESET_COLLISION)
|
|
295
|
+
|
|
296
|
+
def test_load_fixture_tileset(self):
|
|
297
|
+
assert self.tileset is not None
|
|
298
|
+
assert self.tileset.tileset_name == "Terrain (32x32)"
|
|
299
|
+
assert self.tileset.has_collision(26) is True
|
|
300
|
+
assert self.tileset.has_collision(27) is True
|
|
301
|
+
assert self.tileset.has_collision(8) is True
|
|
302
|
+
|
|
303
|
+
def test_one_way_tile(self):
|
|
304
|
+
assert self.tileset.has_collision(8) is True
|
|
305
|
+
tile_data = self.tileset.tiles.get(8)
|
|
306
|
+
assert tile_data is not None
|
|
307
|
+
assert tile_data.shapes[0].one_way is True
|
|
File without changes
|
|
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-2.0.0 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|