tilemap-parser 5.0.3__tar.gz → 5.0.4__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 (72) hide show
  1. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/PKG-INFO +1 -1
  2. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/pyproject.toml +1 -1
  3. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/collision.py +53 -3
  4. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/collision_loader.py +4 -1
  5. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/animation_player.py +49 -8
  6. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision_cache.py +12 -6
  7. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/particles.py +18 -0
  8. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  9. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_collision.py +121 -1
  10. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_integration_animation.py +125 -0
  11. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/LICENSE +0 -0
  12. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/README.md +0 -0
  13. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/pypi-readme.md +0 -0
  14. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/setup.cfg +0 -0
  15. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/__init__.py +0 -0
  16. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/__init__.py +0 -0
  17. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/animation.py +0 -0
  18. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/map_parse.py +0 -0
  19. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/node_parse.py +0 -0
  20. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/particle.py +0 -0
  21. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/parser/tmx_converter.py +0 -0
  22. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/__init__.py +0 -0
  23. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/area_node.py +0 -0
  24. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/body.py +0 -0
  25. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/camera.py +0 -0
  26. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision/__init__.py +0 -0
  27. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision/hit.py +0 -0
  28. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision/manager.py +0 -0
  29. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/collision/shapes.py +0 -0
  30. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/map_loader.py +0 -0
  31. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/map_object.py +0 -0
  32. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/__init__.py +0 -0
  33. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/grounded.py +0 -0
  34. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/platformer.py +0 -0
  35. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/queries.py +0 -0
  36. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/rpg.py +0 -0
  37. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/runner.py +0 -0
  38. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/slide.py +0 -0
  39. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/movement/types.py +0 -0
  40. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/navigation/__init__.py +0 -0
  41. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/navigation/nav_grid.py +0 -0
  42. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/navigation/path_follower.py +0 -0
  43. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/navigation/pathfinder.py +0 -0
  44. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/object_collision.py +0 -0
  45. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/polygon_query.py +0 -0
  46. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/protocols.py +0 -0
  47. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/renderer.py +0 -0
  48. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/tile_collision.py +0 -0
  49. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/runtime/world.py +0 -0
  50. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/utils/__init__.py +0 -0
  51. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser/utils/geometry.py +0 -0
  52. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/SOURCES.txt +0 -0
  53. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  54. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/requires.txt +0 -0
  55. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  56. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_body.py +0 -0
  57. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_camera.py +0 -0
  58. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_geometry.py +0 -0
  59. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_gid_collision.py +0 -0
  60. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_integration_collision.py +0 -0
  61. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_integration_map_loader.py +0 -0
  62. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_map_loader.py +0 -0
  63. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_map_object.py +0 -0
  64. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_move_grounded.py +0 -0
  65. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_navigation.py +0 -0
  66. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_object_collision.py +0 -0
  67. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_object_surfaces.py +0 -0
  68. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_particle_emitter.py +0 -0
  69. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_render_scale.py +0 -0
  70. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_tile_collision.py +0 -0
  71. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_tile_layer_renderer_y_sort.py +0 -0
  72. {tilemap_parser-5.0.3 → tilemap_parser-5.0.4}/tests/test_tmx_converter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 5.0.3
3
+ Version: 5.0.4
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 = "5.0.3"
7
+ version = "5.0.4"
8
8
  description = "Standalone parser/loader for tilemap-editor JSON maps, sprite animations, and collision detection runtime."
9
9
  readme = "pypi-readme.md"
10
10
  requires-python = ">=3.10"
@@ -9,6 +9,7 @@ Provides parsing of collision data from JSON into data classes:
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import math
12
13
  from dataclasses import dataclass, field
13
14
  from typing import Any, Dict, List, Optional, Tuple, Union
14
15
 
@@ -34,6 +35,13 @@ class CollisionPolygon:
34
35
  world_vertices = [(tile_x + vx * scale, tile_y + vy * scale) for vx, vy in self.vertices]
35
36
  return CollisionPolygon(vertices=world_vertices, one_way=self.one_way)
36
37
 
38
+ def scaled(self, scale: float) -> "CollisionPolygon":
39
+ """Return a copy with all vertices multiplied by *scale*."""
40
+ return CollisionPolygon(
41
+ vertices=[(vx * scale, vy * scale) for vx, vy in self.vertices],
42
+ one_way=self.one_way,
43
+ )
44
+
37
45
  def is_valid(self) -> bool:
38
46
  """Check if polygon has at least 3 vertices"""
39
47
  return len(self.vertices) >= 3
@@ -130,6 +138,14 @@ class RectangleShape:
130
138
  top = y + self.offset[1]
131
139
  return (left, top, left + self.width, top + self.height)
132
140
 
141
+ def scaled(self, scale: float) -> "RectangleShape":
142
+ """Return a copy with dimensions and offset multiplied by *scale*."""
143
+ return RectangleShape(
144
+ width=self.width * scale,
145
+ height=self.height * scale,
146
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
147
+ )
148
+
133
149
 
134
150
  @dataclass
135
151
  class CircleShape:
@@ -142,6 +158,13 @@ class CircleShape:
142
158
  """Get center position in world space"""
143
159
  return (x + self.offset[0], y + self.offset[1])
144
160
 
161
+ def scaled(self, scale: float) -> "CircleShape":
162
+ """Return a copy with radius and offset multiplied by *scale*."""
163
+ return CircleShape(
164
+ radius=self.radius * scale,
165
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
166
+ )
167
+
145
168
 
146
169
  @dataclass
147
170
  class CapsuleShape:
@@ -159,6 +182,14 @@ class CapsuleShape:
159
182
  """Get bottom circle center in world space"""
160
183
  return (x + self.offset[0], y + self.offset[1] + self.height)
161
184
 
185
+ def scaled(self, scale: float) -> "CapsuleShape":
186
+ """Return a copy with radius, height, and offset multiplied by *scale*."""
187
+ return CapsuleShape(
188
+ radius=self.radius * scale,
189
+ height=self.height * scale,
190
+ offset=(self.offset[0] * scale, self.offset[1] * scale),
191
+ )
192
+
162
193
 
163
194
  CharacterShapeType = Union[RectangleShape, CircleShape, CapsuleShape, CollisionPolygon]
164
195
 
@@ -173,6 +204,16 @@ class CharacterCollision:
173
204
  collision_layer: int = 1
174
205
  collision_mask: int = 0xFFFFFFFF
175
206
 
207
+ def scaled(self, scale: float) -> "CharacterCollision":
208
+ """Return a copy whose shape is scaled by *scale* (typically ``render_scale``)."""
209
+ return CharacterCollision(
210
+ name=self.name,
211
+ shape=self.shape.scaled(scale),
212
+ properties=dict(self.properties),
213
+ collision_layer=self.collision_layer,
214
+ collision_mask=self.collision_mask,
215
+ )
216
+
176
217
 
177
218
  @dataclass
178
219
  class ObjectCollisionRegionData:
@@ -255,19 +296,28 @@ def parse_tileset_collision(data: JsonDict) -> TilesetCollision:
255
296
  raise CollisionParseError(f"Invalid tileset collision data: {e}") from e
256
297
 
257
298
 
258
- def parse_character_collision(data: JsonDict) -> CharacterCollision:
299
+ def parse_character_collision(data: JsonDict, render_scale: float = 1.0) -> CharacterCollision:
259
300
  """
260
301
  Parse character collision data from dictionary.
261
302
 
262
303
  Args:
263
304
  data: Dictionary loaded from .collision.json file
305
+ render_scale: Multiplier applied to shape dimensions and offsets
306
+ (no-op when 1.0).
264
307
 
265
308
  Returns:
266
309
  CharacterCollision object
267
310
 
268
311
  Raises:
269
- CollisionParseError: If data format is invalid
312
+ CollisionParseError: If data format is invalid or render_scale is not
313
+ finite and greater than zero.
270
314
  """
315
+ if (
316
+ not isinstance(render_scale, (int, float))
317
+ or not math.isfinite(render_scale)
318
+ or render_scale <= 0
319
+ ):
320
+ raise CollisionParseError(f"render_scale must be finite and > 0, got {render_scale!r}")
271
321
  try:
272
322
  name = data["name"]
273
323
  shape_data = data["shape"]
@@ -312,7 +362,7 @@ def parse_character_collision(data: JsonDict) -> CharacterCollision:
312
362
  properties=properties,
313
363
  collision_layer=collision_layer,
314
364
  collision_mask=collision_mask,
315
- )
365
+ ).scaled(render_scale)
316
366
  except (KeyError, ValueError, TypeError) as e:
317
367
  raise CollisionParseError(f"Invalid character collision data: {e}") from e
318
368
 
@@ -55,6 +55,7 @@ def load_tileset_collision(
55
55
 
56
56
  def load_character_collision(
57
57
  collision_path: Union[str, Path],
58
+ render_scale: float = 1.0,
58
59
  ) -> CharacterCollision | None:
59
60
  """
60
61
  Load character collision data from a collision JSON file.
@@ -64,6 +65,8 @@ def load_character_collision(
64
65
 
65
66
  Args:
66
67
  collision_path: Direct path to the .collision.json file.
68
+ render_scale: Multiplier applied to shape dimensions and offsets
69
+ (no-op when 1.0).
67
70
 
68
71
  Returns:
69
72
  CharacterCollision object, or None if the file does not exist.
@@ -79,7 +82,7 @@ def load_character_collision(
79
82
  try:
80
83
  with open(collision_path, "r", encoding="utf-8") as f:
81
84
  data = json.load(f)
82
- return parse_character_collision(data)
85
+ return parse_character_collision(data, render_scale=render_scale)
83
86
  except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
84
87
  raise CollisionParseError(f"Cannot load {collision_path}: {e}") from e
85
88
 
@@ -20,6 +20,9 @@ class SpriteAnimationSet:
20
20
  json_path: Optional[Path] = None
21
21
  grid_offset_x: int = 0
22
22
  grid_offset_y: int = 0
23
+ render_scale: float = 1.0
24
+ grid_columns: int = 0
25
+ grid_rows: int = 0
23
26
 
24
27
  @classmethod
25
28
  def load(
@@ -28,6 +31,7 @@ class SpriteAnimationSet:
28
31
  *,
29
32
  spritesheet_path: Optional[PathLike] = None,
30
33
  extra_search_base: Optional[Path] = None,
34
+ render_scale: float = 1.0,
31
35
  ) -> "SpriteAnimationSet":
32
36
  path = Path(json_path)
33
37
  library = parse_animation_file(path)
@@ -59,6 +63,29 @@ class SpriteAnimationSet:
59
63
  except pygame.error as e:
60
64
  raise AnimationParseError(f"Failed to load image {image_path}: {e}") from e
61
65
 
66
+ tw, th = library.tile_size
67
+ ox, oy = library.grid_offset
68
+ grid_columns = max(1, (surface.get_width() - ox) // tw)
69
+ grid_rows = max(1, (surface.get_height() - oy) // th)
70
+
71
+ if render_scale != 1.0:
72
+ if render_scale <= 0:
73
+ raise ValueError(f"render_scale must be > 0, got {render_scale}")
74
+ w, h = surface.get_size()
75
+ sw, sh = int(w * render_scale), int(h * render_scale)
76
+ stw, sth = int(tw * render_scale), int(th * render_scale)
77
+ if sw < 1 or sh < 1 or stw < 1 or sth < 1:
78
+ raise ValueError(f"render_scale {render_scale} produces zero-sized atlas cells")
79
+ if (
80
+ int(ox * render_scale) + grid_columns * stw > sw
81
+ or int(oy * render_scale) + grid_rows * sth > sh
82
+ ):
83
+ raise ValueError(
84
+ f"render_scale {render_scale} cannot maintain atlas grid cell bounds"
85
+ )
86
+ surface = pygame.transform.scale(surface, (sw, sh))
87
+ library = _scaled_library(library, render_scale)
88
+
62
89
  return cls(
63
90
  library=library,
64
91
  surface=surface,
@@ -66,6 +93,9 @@ class SpriteAnimationSet:
66
93
  json_path=path,
67
94
  grid_offset_x=library.grid_offset[0],
68
95
  grid_offset_y=library.grid_offset[1],
96
+ render_scale=render_scale,
97
+ grid_columns=grid_columns,
98
+ grid_rows=grid_rows,
69
99
  )
70
100
 
71
101
  def get_content_bounds(self, clip_name: str) -> Optional[Rect]:
@@ -88,15 +118,10 @@ class SpriteAnimationSet:
88
118
 
89
119
  def get_image(self, variant_id: int, *, copy_surface: bool = True) -> Optional[Surface]:
90
120
  tw, th = self.library.tile_size
91
- if tw <= 0 or th <= 0:
92
- return None
93
- available_w = self.surface.get_width() - self.grid_offset_x
94
- available_h = self.surface.get_height() - self.grid_offset_y
95
- if available_w < tw or available_h < th:
121
+ if tw <= 0 or th <= 0 or self.grid_columns <= 0 or self.grid_rows <= 0:
96
122
  return None
97
- cols = max(1, available_w // tw)
98
- col = variant_id % cols
99
- row = variant_id // cols
123
+ col = variant_id % self.grid_columns
124
+ row = variant_id // self.grid_columns
100
125
  src = Rect(self.grid_offset_x + col * tw, self.grid_offset_y + row * th, tw, th)
101
126
  if not self.surface.get_rect().contains(src):
102
127
  return None
@@ -161,3 +186,19 @@ class AnimationPlayer:
161
186
  if variant not in self._frame_cache:
162
187
  self._frame_cache[variant] = self.animation_set.get_image(variant, copy_surface=True)
163
188
  return self._frame_cache[variant]
189
+
190
+
191
+ def _scaled_library(library: AnimationLibrary, scale: float) -> AnimationLibrary:
192
+ """Return a copy of *library* whose grid geometry is scaled by *scale*.
193
+
194
+ Kept in lockstep with the scaled spritesheet surface so that
195
+ :meth:`SpriteAnimationSet.get_image` continues to address cells
196
+ correctly.
197
+ """
198
+ return AnimationLibrary(
199
+ animations=library.animations,
200
+ spritesheet_path=library.spritesheet_path,
201
+ tile_size=(int(library.tile_size[0] * scale), int(library.tile_size[1] * scale)),
202
+ grid_offset=(int(library.grid_offset[0] * scale), int(library.grid_offset[1] * scale)),
203
+ trim_transparent=library.trim_transparent,
204
+ )
@@ -8,7 +8,7 @@ repeated file I/O during runtime.
8
8
  from __future__ import annotations
9
9
 
10
10
  from pathlib import Path
11
- from typing import Dict, Optional, Union
11
+ from typing import Dict, Optional, Tuple, Union
12
12
 
13
13
  from ..parser.collision import (
14
14
  CharacterCollision,
@@ -39,7 +39,7 @@ class CollisionCache:
39
39
 
40
40
  def __init__(self):
41
41
  self._tileset_cache: Dict[str, Optional[TilesetCollision]] = {}
42
- self._character_cache: Dict[str, Optional[CharacterCollision]] = {}
42
+ self._character_cache: Dict[Tuple[str, float], Optional[CharacterCollision]] = {}
43
43
  self._object_cache: Dict[str, Optional[ObjectCollisionData]] = {}
44
44
 
45
45
  def get_tileset_collision(
@@ -58,17 +58,21 @@ class CollisionCache:
58
58
  return self._tileset_cache[key]
59
59
 
60
60
  def get_character_collision(
61
- self, collision_path: Union[str, Path]
61
+ self, collision_path: Union[str, Path], render_scale: float = 1.0
62
62
  ) -> Optional[CharacterCollision]:
63
63
  """Get character collision data (cached).
64
64
 
65
65
  Args:
66
66
  collision_path: Direct path to the .collision.json file.
67
+ render_scale: Multiplier applied to shape dimensions and offsets.
68
+ Cached per (path, render_scale) pair.
67
69
  """
68
- key = str(Path(collision_path).resolve())
70
+ key = (str(Path(collision_path).resolve()), render_scale)
69
71
 
70
72
  if key not in self._character_cache:
71
- self._character_cache[key] = load_character_collision(collision_path)
73
+ self._character_cache[key] = load_character_collision(
74
+ collision_path, render_scale=render_scale
75
+ )
72
76
 
73
77
  return self._character_cache[key]
74
78
 
@@ -134,13 +138,15 @@ def get_cached_tileset_collision(
134
138
 
135
139
  def get_cached_character_collision(
136
140
  collision_path: Union[str, Path],
141
+ render_scale: float = 1.0,
137
142
  ) -> Optional[CharacterCollision]:
138
143
  """Get character collision using global cache.
139
144
 
140
145
  Args:
141
146
  collision_path: Direct path to the .collision.json file.
147
+ render_scale: Multiplier applied to shape dimensions and offsets.
142
148
  """
143
- return _global_cache.get_character_collision(collision_path)
149
+ return _global_cache.get_character_collision(collision_path, render_scale=render_scale)
144
150
 
145
151
 
146
152
  def get_cached_object_collision(
@@ -177,6 +177,23 @@ def _make_heart_texture() -> Surface:
177
177
  return s
178
178
 
179
179
 
180
+ def _make_line_texture() -> Surface:
181
+ # will be applied on next release not yet
182
+ """Vertical streak spanning the full canvas with a thin core.
183
+
184
+ Kept axis-aligned (not rotated): the renderer only rotates particles
185
+ when ``rotation_speed`` is non-zero, so a vertical line reads as a
186
+ falling streak for rain and stream effects.
187
+ """
188
+ s = Surface((PARTICLE_TEXTURE_SIZE, PARTICLE_TEXTURE_SIZE), pygame.SRCALPHA)
189
+ w = max(1, PARTICLE_TEXTURE_SIZE // 6)
190
+ x = (PARTICLE_TEXTURE_SIZE - w) // 2
191
+ pygame.draw.rect(s, (255, 255, 255, 255), Rect(x, 2, w, PARTICLE_TEXTURE_SIZE - 4))
192
+ pygame.draw.rect(s, (255, 255, 255, 140), Rect(x, 0, w, 2))
193
+ pygame.draw.rect(s, (255, 255, 255, 140), Rect(x, PARTICLE_TEXTURE_SIZE - 2, w, 2))
194
+ return s
195
+
196
+
180
197
  def _get_base_texture(shape: str) -> Surface:
181
198
  if shape not in _TEXTURE_CACHE:
182
199
  makers = {
@@ -188,6 +205,7 @@ def _get_base_texture(shape: str) -> Surface:
188
205
  "smoke": _make_smoke_texture,
189
206
  "fog": _make_fog_texture,
190
207
  "heart": _make_heart_texture,
208
+ "line": _make_line_texture,
191
209
  }
192
210
  maker = makers.get(shape, _make_circle_texture)
193
211
  _TEXTURE_CACHE[shape] = maker()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 5.0.3
3
+ Version: 5.0.4
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
@@ -192,6 +192,60 @@ class TestParseCharacterCollision:
192
192
  result = parse_character_collision(data)
193
193
  assert result.properties == {}
194
194
 
195
+ def test_render_scale_rectangle(self):
196
+ result = parse_character_collision(CHARACTER_RECT_DATA, render_scale=2.0)
197
+ assert isinstance(result.shape, RectangleShape)
198
+ assert result.shape.width == 48.0
199
+ assert result.shape.height == 64.0
200
+ assert result.shape.offset == (8.0, 0.0)
201
+
202
+ def test_render_scale_circle(self):
203
+ result = parse_character_collision(CHARACTER_CIRCLE_DATA, render_scale=3.0)
204
+ assert isinstance(result.shape, CircleShape)
205
+ assert result.shape.radius == 48.0
206
+
207
+ def test_render_scale_capsule(self):
208
+ result = parse_character_collision(CHARACTER_CAPSULE_DATA, render_scale=2.0)
209
+ assert isinstance(result.shape, CapsuleShape)
210
+ assert result.shape.radius == 16.0
211
+ assert result.shape.height == 96.0
212
+
213
+ def test_render_scale_polygon(self):
214
+ data = {
215
+ "name": "poly",
216
+ "shape": {
217
+ "type": "polygon",
218
+ "vertices": [[0, 0], [10, 0], [10, 10], [0, 10]],
219
+ },
220
+ "properties": {},
221
+ }
222
+ result = parse_character_collision(data, render_scale=2.0)
223
+ assert isinstance(result.shape, CollisionPolygon)
224
+ assert result.shape.vertices == [(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)]
225
+
226
+ def test_render_scale_default_is_one(self):
227
+ result = parse_character_collision(CHARACTER_RECT_DATA)
228
+ assert isinstance(result.shape, RectangleShape)
229
+ assert result.shape.width == 24.0
230
+
231
+ def test_render_scale_zero_rejected(self):
232
+ with pytest.raises(CollisionParseError, match="render_scale"):
233
+ parse_character_collision(CHARACTER_RECT_DATA, render_scale=0)
234
+
235
+ def test_render_scale_negative_rejected(self):
236
+ with pytest.raises(CollisionParseError, match="render_scale"):
237
+ parse_character_collision(CHARACTER_RECT_DATA, render_scale=-1)
238
+
239
+ def test_render_scale_nonfinite_rejected(self):
240
+ for bad in (float("nan"), float("inf"), float("-inf")):
241
+ with pytest.raises(CollisionParseError, match="render_scale"):
242
+ parse_character_collision(CHARACTER_RECT_DATA, render_scale=bad)
243
+
244
+ def test_render_scale_non_numeric_rejected(self):
245
+ for bad in (None, "2", "abc"):
246
+ with pytest.raises(CollisionParseError, match="render_scale"):
247
+ parse_character_collision(CHARACTER_RECT_DATA, render_scale=bad)
248
+
195
249
 
196
250
  # ===========================================================================
197
251
  # Shape helpers
@@ -232,6 +286,54 @@ class TestShapeHelpers:
232
286
  assert transformed.vertices[1] == (15.0, 7.0)
233
287
  assert transformed.vertices[2] == (15.0, 17.0)
234
288
 
289
+ def test_rectangle_scaled(self):
290
+ shape = RectangleShape(width=24.0, height=32.0, offset=(4.0, 0.0))
291
+ scaled = shape.scaled(2.0)
292
+ assert isinstance(scaled, RectangleShape)
293
+ assert scaled.width == 48.0
294
+ assert scaled.height == 64.0
295
+ assert scaled.offset == (8.0, 0.0)
296
+ assert shape.width == 24.0 # original untouched
297
+
298
+ def test_circle_scaled(self):
299
+ shape = CircleShape(radius=16.0, offset=(2.0, 3.0))
300
+ scaled = shape.scaled(3.0)
301
+ assert isinstance(scaled, CircleShape)
302
+ assert scaled.radius == 48.0
303
+ assert scaled.offset == (6.0, 9.0)
304
+
305
+ def test_capsule_scaled(self):
306
+ shape = CapsuleShape(radius=8.0, height=48.0, offset=(0.0, 2.0))
307
+ scaled = shape.scaled(1.5)
308
+ assert isinstance(scaled, CapsuleShape)
309
+ assert scaled.radius == 12.0
310
+ assert scaled.height == 72.0
311
+ assert scaled.offset == (0.0, 3.0)
312
+
313
+ def test_collision_polygon_scaled(self):
314
+ poly = CollisionPolygon(vertices=[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], one_way=True)
315
+ scaled = poly.scaled(2.0)
316
+ assert isinstance(scaled, CollisionPolygon)
317
+ assert scaled.vertices == [(0.0, 0.0), (20.0, 0.0), (20.0, 20.0)]
318
+ assert scaled.one_way is True
319
+
320
+ def test_scaled_one_is_identity(self):
321
+ shape = RectangleShape(width=24.0, height=32.0, offset=(4.0, 0.0))
322
+ assert shape.scaled(1.0) == shape
323
+
324
+ def test_character_collision_scaled(self):
325
+ coll = parse_character_collision(CHARACTER_RECT_DATA)
326
+ scaled = coll.scaled(2.0)
327
+ assert scaled.name == coll.name
328
+ assert isinstance(scaled.shape, RectangleShape)
329
+ assert scaled.shape.width == 48.0
330
+ assert scaled.shape.height == 64.0
331
+ assert scaled.shape.offset == (8.0, 0.0)
332
+ assert scaled.collision_layer == coll.collision_layer
333
+ assert scaled.collision_mask == coll.collision_mask
334
+ assert scaled.properties == coll.properties
335
+ assert coll.shape.width == 24.0 # original untouched
336
+
235
337
 
236
338
  # ===========================================================================
237
339
  # load_tileset_collision — direct path API
@@ -308,6 +410,16 @@ class TestLoadCharacterCollision:
308
410
  result = load_character_collision(CHARACTER_COLLISION)
309
411
  assert isinstance(result.shape, RectangleShape)
310
412
 
413
+ def test_render_scale_scales_shape(self):
414
+ base = load_character_collision(CHARACTER_COLLISION)
415
+ scaled = load_character_collision(CHARACTER_COLLISION, render_scale=2.0)
416
+ assert base is not None and scaled is not None
417
+ assert isinstance(base.shape, RectangleShape)
418
+ assert isinstance(scaled.shape, RectangleShape)
419
+ assert scaled.shape.width == base.shape.width * 2.0
420
+ assert scaled.shape.height == base.shape.height * 2.0
421
+ assert scaled.shape.offset == (base.shape.offset[0] * 2.0, base.shape.offset[1] * 2.0)
422
+
311
423
  def test_non_json_file_raises(self, tmp_path):
312
424
  # Passing a non-JSON file (e.g. an image) raises CollisionParseError,
313
425
  # not silently returns None — the file exists so we try to parse it.
@@ -357,7 +469,7 @@ class TestCollisionCache:
357
469
 
358
470
  def test_preload_character(self):
359
471
  self.cache.preload_character(CHARACTER_COLLISION)
360
- key = str(CHARACTER_COLLISION.resolve())
472
+ key = (str(CHARACTER_COLLISION.resolve()), 1.0)
361
473
  assert key in self.cache._character_cache
362
474
 
363
475
  def test_cache_key_is_resolved_path(self, tmp_path):
@@ -369,6 +481,14 @@ class TestCollisionCache:
369
481
  r2 = self.cache.get_tileset_collision(link)
370
482
  assert r1 is r2
371
483
 
484
+ def test_character_cache_separates_render_scales(self):
485
+ r1 = self.cache.get_character_collision(CHARACTER_COLLISION)
486
+ r2 = self.cache.get_character_collision(CHARACTER_COLLISION, render_scale=1.0)
487
+ r3 = self.cache.get_character_collision(CHARACTER_COLLISION, render_scale=2.0)
488
+ assert r1 is r2 # same scale -> same cached object
489
+ assert r1 is not r3 # different scale -> separate cache entry
490
+ assert r3.shape.width == r1.shape.width * 2.0
491
+
372
492
 
373
493
  # ===========================================================================
374
494
  # Global cache helpers
@@ -1,5 +1,8 @@
1
1
  from pathlib import Path
2
2
 
3
+ import json
4
+
5
+ import pygame
3
6
  import pytest
4
7
 
5
8
  from tilemap_parser import (
@@ -115,6 +118,128 @@ class TestSpriteAnimationSetLoad:
115
118
  bounds = anim_set.get_content_bounds("nonexistent")
116
119
  assert bounds is None
117
120
 
121
+ def test_load_with_render_scale(self):
122
+ anim_set = SpriteAnimationSet.load(PLAYER_ANIM_PATH, render_scale=2.0)
123
+ assert anim_set.render_scale == 2.0
124
+ assert anim_set.surface.get_width() == 2400
125
+ assert anim_set.surface.get_height() == 840
126
+ assert anim_set.library.tile_size == (300, 120)
127
+ assert anim_set.library.grid_offset == (0, 0)
128
+ assert anim_set.grid_offset_x == 0
129
+
130
+ def test_render_scale_one_matches_default(self):
131
+ default = SpriteAnimationSet.load(PLAYER_ANIM_PATH)
132
+ explicit = SpriteAnimationSet.load(PLAYER_ANIM_PATH, render_scale=1.0)
133
+ assert explicit.render_scale == 1.0
134
+ assert explicit.surface.get_size() == default.surface.get_size()
135
+ assert explicit.library.tile_size == default.library.tile_size
136
+
137
+ def test_get_image_scaled_by_render_scale(self):
138
+ anim_set = SpriteAnimationSet.load(PLAYER_ANIM_PATH, render_scale=2.0)
139
+ img = anim_set.get_image(0)
140
+ assert img is not None
141
+ assert img.get_width() == 300
142
+ assert img.get_height() == 120
143
+
144
+ def test_get_image_cell_lookup_still_valid_at_scale(self):
145
+ anim_set = SpriteAnimationSet.load(PLAYER_ANIM_PATH, render_scale=2.0)
146
+ img = anim_set.get_image(8)
147
+ assert img is not None
148
+ assert img.get_size() == (300, 120)
149
+
150
+ def test_get_content_bounds_scaled(self):
151
+ base = SpriteAnimationSet.load(PLAYER_ANIM_PATH)
152
+ scaled = SpriteAnimationSet.load(PLAYER_ANIM_PATH, render_scale=3.0)
153
+ base_bounds = base.get_content_bounds("idle")
154
+ scaled_bounds = scaled.get_content_bounds("idle")
155
+ assert base_bounds is not None and scaled_bounds is not None
156
+ assert scaled_bounds.width == base_bounds.width * 3
157
+ assert scaled_bounds.height == base_bounds.height * 3
158
+ assert scaled_bounds.x == base_bounds.x * 3
159
+ assert scaled_bounds.y == base_bounds.y * 3
160
+
161
+ def test_invalid_render_scale_raises(self):
162
+ with pytest.raises(ValueError):
163
+ SpriteAnimationSet.load(PLAYER_ANIM_PATH, render_scale=0)
164
+
165
+ def test_zero_sized_scaled_cells_rejected(self):
166
+ for bad in (0.01, 0.0005):
167
+ with pytest.raises(ValueError, match="zero-sized"):
168
+ SpriteAnimationSet.load(PLAYER_ANIM_PATH, render_scale=bad)
169
+
170
+ def test_animation_player_frames_scaled(self):
171
+ anim_set = SpriteAnimationSet.load(PLAYER_ANIM_PATH, render_scale=2.0)
172
+ player = AnimationPlayer(anim_set, "idle")
173
+ img = player.get_current_image()
174
+ assert img is not None
175
+ assert img.get_size() == (300, 120)
176
+
177
+
178
+ def _write_offset_atlas(tmp_path: Path, cols: int, rows: int):
179
+ """Write a synthetic atlas with a nonzero grid offset and solid per-cell colors."""
180
+ tile = 8
181
+ ox, oy = 1, 1
182
+ w, h = ox + cols * tile, oy + rows * tile
183
+ sheet = pygame.Surface((w, h))
184
+ colors = {}
185
+ for row in range(rows):
186
+ for col in range(cols):
187
+ idx = row * cols + col
188
+ color = (
189
+ (20 + idx * 7) % 256,
190
+ (40 + idx * 13) % 256,
191
+ (60 + idx * 29) % 256,
192
+ )
193
+ colors[idx] = color
194
+ sheet.fill(color, (ox + col * tile, oy + row * tile, tile, tile))
195
+ sheet.fill((250, 250, 250), (0, 0, w, oy))
196
+ sheet.fill((250, 250, 250), (0, 0, ox, h))
197
+ png_path = tmp_path / "atlas.png"
198
+ pygame.image.save(sheet, str(png_path))
199
+ json_path = tmp_path / "atlas.animation.json"
200
+ json_path.write_text(
201
+ json.dumps(
202
+ {
203
+ "spritesheet_path": str(png_path),
204
+ "tile_size": [tile, tile],
205
+ "grid_offset": [ox, oy],
206
+ "trim_transparent": False,
207
+ "animations": {
208
+ "test": {
209
+ "name": "test",
210
+ "frames": [{"variant_id": 0, "duration_ms": 100.0}],
211
+ "loop": True,
212
+ }
213
+ },
214
+ }
215
+ )
216
+ )
217
+ return colors
218
+
219
+
220
+ class TestFractionalScaleWithOffset:
221
+ def test_downscale_0_6_returns_correct_cell(self, tmp_path):
222
+ colors = _write_offset_atlas(tmp_path, cols=5, rows=3)
223
+ anim_set = SpriteAnimationSet.load(
224
+ tmp_path / "atlas.animation.json", render_scale=0.6
225
+ )
226
+ for variant in (0, 5, 6):
227
+ img = anim_set.get_image(variant)
228
+ assert img is not None
229
+ center = img.get_at((img.get_width() // 2, img.get_height() // 2))[:3]
230
+ assert center == colors[variant]
231
+
232
+ def test_upscale_1_1_returns_correct_cell(self, tmp_path):
233
+ colors = _write_offset_atlas(tmp_path, cols=12, rows=3)
234
+ anim_set = SpriteAnimationSet.load(
235
+ tmp_path / "atlas.animation.json", render_scale=1.1
236
+ )
237
+ for variant in (0, 12, 13):
238
+ img = anim_set.get_image(variant)
239
+ assert img is not None
240
+ center = img.get_at((img.get_width() // 2, img.get_height() // 2))[:3]
241
+ assert center == colors[variant]
242
+
118
243
 
119
244
  class TestAnimationPlayerPlayback:
120
245
  def test_initial_state(self):
File without changes
File without changes
File without changes