tilemap-parser 2.0.1__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.
Files changed (20) hide show
  1. {tilemap_parser-2.0.1/src/tilemap_parser.egg-info → tilemap_parser-2.0.3}/PKG-INFO +1 -1
  2. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/pyproject.toml +1 -1
  3. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser/collision_runner.py +339 -161
  4. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3/src/tilemap_parser.egg-info}/PKG-INFO +1 -1
  5. tilemap_parser-2.0.3/tests/test_collision_runner.py +307 -0
  6. tilemap_parser-2.0.1/tests/test_collision_runner.py +0 -110
  7. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/LICENSE +0 -0
  8. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/README.md +0 -0
  9. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/setup.cfg +0 -0
  10. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser/__init__.py +0 -0
  11. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser/animation.py +0 -0
  12. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser/collision.py +0 -0
  13. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser/map_loader.py +0 -0
  14. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser/map_parse.py +0 -0
  15. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser/renderer.py +0 -0
  16. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/SOURCES.txt +0 -0
  17. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  18. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/requires.txt +0 -0
  19. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  20. {tilemap_parser-2.0.1 → tilemap_parser-2.0.3}/tests/test_collision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps and sprite animation JSON.
5
5
  Author: tilemap parser contributors
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tilemap-parser"
7
- version = "2.0.1"
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]
@@ -70,75 +69,153 @@ def point_in_polygon(point: Point, vertices: List[Point]) -> bool:
70
69
  if p1x == p2x or x <= xinters:
71
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
- corners = [
83
- (rect_x, rect_y),
84
- (rect_x + rect_w, rect_y),
85
- (rect_x, rect_y + rect_h),
86
- (rect_x + rect_w, rect_y + rect_h),
87
- ]
88
-
89
- for corner in corners:
90
- if point_in_polygon(corner, vertices):
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
- if rect_x <= vx <= rect_x + rect_w and rect_y <= vy <= rect_y + rect_h:
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 = sprite.y + shape.offset[1]
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
- left, top, right - left, bottom - top, polygon.vertices
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
- """Get all collision shapes near sprite"""
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 // self.tile_size[0]) - margin
283
- max_tile_x = int(right // self.tile_size[0]) + margin
284
- min_tile_y = int(top // self.tile_size[1]) - margin
285
- max_tile_y = int(bottom // self.tile_size[1]) + margin
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 or not tileset_collision.has_collision(tile_id):
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
293
401
 
294
- tile_world_x = tile_x * self.tile_size[0]
295
- tile_world_y = tile_y * self.tile_size[1]
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.
296
411
 
297
- tile_shapes = tileset_collision.get_world_shapes(
298
- tile_id, tile_world_x, tile_world_y
299
- )
300
- shapes.extend(tile_shapes)
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
301
417
 
302
- return shapes
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
422
+
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 = CollisionResult(final_x=sprite.x, final_y=sprite.y)
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 i in range(max_slides):
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
- shapes = self.get_nearby_tile_shapes(
348
- tileset_collision, tile_map, sprite
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, colliding_shape, motion_x, motion_y
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 = motion_x - normal[0] * dot
377
- motion_y = motion_y - normal[1] * dot
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
- sprite.x += delta_x
390
- sprite.y += delta_y
391
-
392
- shapes = self.get_nearby_tile_shapes(tileset_collision, tile_map, sprite)
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
- sprite.x = old_x
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 from a polygon based on motion direction.
451
- Returns the normal of the edge that the sprite is moving into.
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
- if len(vertices) < 2:
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(len(vertices)):
466
- v1 = vertices[i]
467
- v2 = vertices[(i + 1) % len(vertices)]
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 = v2[0] - v1[0]
470
- edge_y = v2[1] - v1[1]
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 = edge_x / edge_len
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
- to_outside_x = edge_mid_x - poly_center_x
485
- to_outside_y = edge_mid_y - poly_center_y
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 = CollisionResult(final_x=sprite.x, final_y=sprite.y)
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 = min(sprite.vy, self.max_fall_speed)
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
- sprite.x += delta_x
541
- shapes = self.get_nearby_tile_shapes(tileset_collision, tile_map, sprite)
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
- sprite.y += delta_y
549
- shapes = self.get_nearby_tile_shapes(tileset_collision, tile_map, sprite)
550
-
701
+ # Y axis — check one-way platforms
702
+ sprite.y = old_y + delta_y
551
703
  collided_y = False
552
704
 
553
- for shape in shapes:
554
- if check_sprite_polygon_collision(sprite, shape):
555
- if shape.one_way and sprite.vy > 0:
556
- if old_y + get_shape_bounds(sprite)[3] - old_y <= min(
557
- v[1] for v in shape.vertices
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 = CollisionResult(final_x=sprite.x, final_y=sprite.y)
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 += delta_x
611
- sprite.y += delta_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 = True
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Standalone parser/loader for tilemap-editor JSON maps and sprite animation JSON.
5
5
  Author: tilemap parser contributors
6
6
  Classifier: Programming Language :: Python :: 3
@@ -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
@@ -1,110 +0,0 @@
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