tilemap-parser 3.0.0__tar.gz → 3.1.1__tar.gz

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