tilemap-parser 3.1.21__tar.gz → 3.1.23__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 (43) hide show
  1. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/PKG-INFO +1 -1
  2. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/pyproject.toml +1 -1
  3. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/parser/particle.py +17 -0
  4. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/__init__.py +2 -0
  5. tilemap_parser-3.1.23/src/tilemap_parser/runtime/camera.py +176 -0
  6. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/map_loader.py +83 -0
  7. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/object_collision.py +0 -2
  8. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/tile_collision.py +165 -0
  9. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  10. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser.egg-info/SOURCES.txt +7 -0
  11. tilemap_parser-3.1.23/tests/test_camera.py +334 -0
  12. tilemap_parser-3.1.23/tests/test_integration_animation.py +179 -0
  13. tilemap_parser-3.1.23/tests/test_integration_collision.py +132 -0
  14. tilemap_parser-3.1.23/tests/test_integration_map_loader.py +148 -0
  15. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/tests/test_map_loader.py +81 -8
  16. tilemap_parser-3.1.23/tests/test_move_grounded.py +308 -0
  17. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/tests/test_object_surfaces.py +0 -8
  18. tilemap_parser-3.1.23/tests/test_particle_emitter.py +74 -0
  19. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/LICENSE +0 -0
  20. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/README.md +0 -0
  21. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/setup.cfg +0 -0
  22. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/__init__.py +0 -0
  23. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/parser/__init__.py +0 -0
  24. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/parser/animation.py +0 -0
  25. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/parser/collision.py +0 -0
  26. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/parser/collision_loader.py +0 -0
  27. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/parser/map_parse.py +0 -0
  28. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/parser/node_parse.py +0 -0
  29. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/animation_player.py +0 -0
  30. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/area_node.py +0 -0
  31. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  32. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/particles.py +0 -0
  33. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/runtime/renderer.py +0 -0
  34. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/utils/__init__.py +0 -0
  35. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser/utils/geometry.py +0 -0
  36. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  37. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser.egg-info/requires.txt +0 -0
  38. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  39. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/tests/test_collision.py +0 -0
  40. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/tests/test_geometry.py +0 -0
  41. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/tests/test_object_collision.py +0 -0
  42. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/tests/test_render_scale.py +0 -0
  43. {tilemap_parser-3.1.21 → tilemap_parser-3.1.23}/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.1.21
3
+ Version: 3.1.23
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.1.21"
7
+ version = "3.1.23"
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"
@@ -53,6 +53,23 @@ class ParticleSystemConfig:
53
53
  rotation_speed: float = 0.0
54
54
  alpha_fade: str = "fade_out"
55
55
 
56
+ def apply_render_scale(self, scale: float) -> None:
57
+ """Scale dimensionful fields by *scale* (typically ``render_scale``).
58
+
59
+ Modifies ``particle_size_min``, ``particle_size_max``,
60
+ ``speed_min``, ``speed_max``, ``gravity_x``, and ``gravity_y``
61
+ in place. Call once after loading config.
62
+
63
+ ``start_scale``, ``end_scale``, lifetimes, colors, angles, and
64
+ counts are intentionally left untouched.
65
+ """
66
+ self.particle_size_min = max(1, int(self.particle_size_min * scale))
67
+ self.particle_size_max = max(1, int(self.particle_size_max * scale))
68
+ self.speed_min *= scale
69
+ self.speed_max *= scale
70
+ self.gravity_x *= scale
71
+ self.gravity_y *= scale
72
+
56
73
  def to_dict(self) -> JsonDict:
57
74
  return {
58
75
  "emission_shape": self.emission_shape,
@@ -1,3 +1,4 @@
1
+ from .camera import Camera
1
2
  from .animation_player import AnimationPlayer, SpriteAnimationSet
2
3
  from .collision_cache import (
3
4
  CollisionCache,
@@ -38,6 +39,7 @@ from .particles import (
38
39
  __all__ = [
39
40
  "AnimationPlayer",
40
41
  "AreaNode",
42
+ "Camera",
41
43
  "CollisionCache",
42
44
  "CollisionHit",
43
45
  "CollisionResult",
@@ -0,0 +1,176 @@
1
+ """
2
+ Camera with centered or deadzone follow, lerp smoothing, screen-shake, and bounds clamping.
3
+
4
+ Usage::
5
+
6
+ camera = Camera(800, 600, mode="centered")
7
+ camera.follow(player)
8
+
9
+ # each frame:
10
+ camera.update(dt)
11
+
12
+ # render with camera.offset:
13
+ tile_renderer.render(screen, camera.offset)
14
+ player.render(screen, camera.offset)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ import random
21
+ from typing import Optional, Tuple
22
+
23
+ import pygame
24
+
25
+ from ..utils.geometry import get_shape_aabb
26
+
27
+
28
+ class Camera:
29
+ __slots__ = (
30
+ "x",
31
+ "y",
32
+ "viewport_w",
33
+ "viewport_h",
34
+ "mode",
35
+ "target",
36
+ "lerp_speed",
37
+ "deadzone",
38
+ "bounds",
39
+ "_shake_timer",
40
+ "_shake_intensity",
41
+ "_shake_ox",
42
+ "_shake_oy",
43
+ )
44
+
45
+ def __init__(
46
+ self,
47
+ viewport_width: int,
48
+ viewport_height: int,
49
+ mode: str = "centered",
50
+ ):
51
+ if mode not in ("centered", "deadzone"):
52
+ raise ValueError(f"Camera mode must be 'centered' or 'deadzone', got {mode!r}")
53
+
54
+ self.x = 0.0
55
+ self.y = 0.0
56
+ self.viewport_w = viewport_width
57
+ self.viewport_h = viewport_height
58
+ self.mode = mode
59
+ self.target = None
60
+ self.lerp_speed = 0.0
61
+ self.bounds = None
62
+
63
+ if mode == "deadzone":
64
+ dw = viewport_width * 0.5
65
+ dh = viewport_height * 0.5
66
+ self.deadzone = pygame.Rect(
67
+ (viewport_width - dw) / 2,
68
+ (viewport_height - dh) / 2,
69
+ dw,
70
+ dh,
71
+ )
72
+ else:
73
+ self.deadzone = None
74
+
75
+ self._shake_timer = 0.0
76
+ self._shake_intensity = 0.0
77
+ self._shake_ox = 0.0
78
+ self._shake_oy = 0.0
79
+
80
+ def follow(self, target) -> None:
81
+ """Set the entity the camera should follow.
82
+
83
+ *target* must have ``x``, ``y``, and ``collision_shape`` attributes
84
+ (any sprite managed by the collision runner satisfies this).
85
+ """
86
+ self.target = target
87
+
88
+ def shake(self, duration: float, intensity: float) -> None:
89
+ """Trigger a screen-shake effect.
90
+
91
+ Args:
92
+ duration: Seconds the shake lasts.
93
+ intensity: Maximum pixel offset applied each frame.
94
+ """
95
+ self._shake_timer = duration
96
+ self._shake_intensity = intensity
97
+
98
+ def update(self, dt: float) -> None:
99
+ """Advance the camera by *dt* seconds.
100
+
101
+ Computes the new position based on the follow mode, applies lerp,
102
+ clamps to bounds, and updates the screen-shake.
103
+ """
104
+ if self.target is not None:
105
+ l, t, r, b = get_shape_aabb(
106
+ self.target.x,
107
+ self.target.y,
108
+ self.target.collision_shape,
109
+ )
110
+ cx = (l + r) * 0.5
111
+ cy = (t + b) * 0.5
112
+
113
+ if self.mode == "centered":
114
+ self._move_toward(
115
+ cx - self.viewport_w / 2,
116
+ cy - self.viewport_h / 2,
117
+ dt,
118
+ )
119
+ elif self.mode == "deadzone" and self.deadzone is not None:
120
+ self._follow_deadzone(cx, cy, dt)
121
+
122
+ # Bounds clamp
123
+ if self.bounds is not None:
124
+ min_x, min_y, max_x, max_y = self.bounds
125
+ self.x = max(min_x, min(self.x, max_x - self.viewport_w))
126
+ self.y = max(min_y, min(self.y, max_y - self.viewport_h))
127
+
128
+ # Shake
129
+ if self._shake_timer > 0:
130
+ self._shake_timer -= dt
131
+ i = self._shake_intensity
132
+ self._shake_ox = random.uniform(-i, i)
133
+ self._shake_oy = random.uniform(-i, i)
134
+ if self._shake_timer <= 0:
135
+ self._shake_timer = 0.0
136
+ self._shake_ox = 0.0
137
+ self._shake_oy = 0.0
138
+
139
+ @property
140
+ def offset(self) -> Tuple[float, float]:
141
+ """Camera offset including active screen-shake.
142
+
143
+ Pass this directly to render calls::
144
+
145
+ tile_renderer.render(screen, camera.offset)
146
+ player.render(screen, camera.offset)
147
+ """
148
+ return (self.x + self._shake_ox, self.y + self._shake_oy)
149
+
150
+ def _move_toward(self, target_x: float, target_y: float, dt: float) -> None:
151
+ if self.lerp_speed > 0:
152
+ t = 1.0 - math.exp(-self.lerp_speed * dt)
153
+ self.x += (target_x - self.x) * t
154
+ self.y += (target_y - self.y) * t
155
+ else:
156
+ self.x = target_x
157
+ self.y = target_y
158
+
159
+ def _follow_deadzone(self, cx: float, cy: float, dt: float) -> None:
160
+ # Target position in screen space
161
+ sx = cx - self.x
162
+ sy = cy - self.y
163
+ dz = self.deadzone
164
+ dx = dy = 0.0
165
+
166
+ if sx < dz.left:
167
+ dx = sx - dz.left
168
+ elif sx > dz.right:
169
+ dx = sx - dz.right
170
+
171
+ if sy < dz.top:
172
+ dy = sy - dz.top
173
+ elif sy > dz.bottom:
174
+ dy = sy - dz.bottom
175
+
176
+ self._move_toward(self.x + dx, self.y + dy, dt)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import math
4
5
  from copy import deepcopy
5
6
  from pathlib import Path
6
7
  from typing import Dict, List, Optional, Tuple, Union
@@ -31,6 +32,8 @@ class TilemapData:
31
32
  self.resolved_paths = resolved_paths
32
33
  self.warnings = warnings
33
34
  self.map_path = map_path
35
+ # Pixel/world offset applied while normalizing negative source coordinates.
36
+ self.origin_offset = (0, 0)
34
37
  self.area_nodes: List[AreaNode] = []
35
38
  self.particle_emitters: List[ParticleEmitterNode] = []
36
39
  self._tw, self._th = parsed.meta.tile_size
@@ -98,7 +101,9 @@ class TilemapData:
98
101
  warnings.append(f"Failed to load nodes: {e}")
99
102
  break
100
103
 
104
+ origin_offset = _normalize_origin(parsed)
101
105
  result = cls(parsed, surfaces, resolved_paths, warnings, map_path=p)
106
+ result.origin_offset = origin_offset
102
107
  result.area_nodes = [AreaNode(n) for n in parsed.nodes if n.node_type == "area"]
103
108
  result.particle_emitters = [
104
109
  ParticleEmitterNode(n) for n in parsed.nodes if n.node_type == "particle_emitter"
@@ -312,5 +317,83 @@ def _resolve_resource_path(path_str: str, map_dir: Path, extra_search_base: Opti
312
317
  return candidate
313
318
 
314
319
 
320
+ def _normalize_origin(parsed: ParsedMap) -> Tuple[int, int]:
321
+ tw, th = parsed.meta.tile_size
322
+ rs = parsed.meta.render_scale
323
+ eff_w = int(tw * rs)
324
+ eff_h = int(th * rs)
325
+ if eff_w <= 0 or eff_h <= 0:
326
+ return (0, 0)
327
+
328
+ min_x = 0
329
+ min_y = 0
330
+ max_x = parsed.meta.map_size[0]
331
+ max_y = parsed.meta.map_size[1]
332
+
333
+ for layer in parsed.layers:
334
+ for x, y in layer.tiles.keys():
335
+ min_x = min(min_x, x)
336
+ min_y = min(min_y, y)
337
+ max_x = max(max_x, x + 1)
338
+ max_y = max(max_y, y + 1)
339
+
340
+ for obj in layer.objects.values():
341
+ left = math.floor(obj.area.x / eff_w)
342
+ top = math.floor(obj.area.y / eff_h)
343
+ right = math.ceil((obj.area.x + obj.area.w) / eff_w)
344
+ bottom = math.ceil((obj.area.y + obj.area.h) / eff_h)
345
+ min_x = min(min_x, left)
346
+ min_y = min(min_y, top)
347
+ max_x = max(max_x, right)
348
+ max_y = max(max_y, bottom)
349
+
350
+ for node in parsed.nodes:
351
+ left = math.floor(node.area.x / eff_w)
352
+ top = math.floor(node.area.y / eff_h)
353
+ right = math.ceil((node.area.x + node.area.w) / eff_w)
354
+ bottom = math.ceil((node.area.y + node.area.h) / eff_h)
355
+ min_x = min(min_x, left)
356
+ min_y = min(min_y, top)
357
+ max_x = max(max_x, right)
358
+ max_y = max(max_y, bottom)
359
+
360
+ if min_x >= 0 and min_y >= 0:
361
+ parsed.meta.map_size = (max_x, max_y)
362
+ return (0, 0)
363
+
364
+ shift_x = -min_x
365
+ shift_y = -min_y
366
+ pixel_shift_x = shift_x * eff_w
367
+ pixel_shift_y = shift_y * eff_h
368
+
369
+ for layer in parsed.layers:
370
+ if layer.tiles:
371
+ shifted_tiles = {}
372
+ for (x, y), tile in layer.tiles.items():
373
+ new_pos = (x + shift_x, y + shift_y)
374
+ tile.pos = new_pos
375
+ shifted_tiles[new_pos] = tile
376
+ layer.tiles = shifted_tiles
377
+
378
+ for obj in layer.objects.values():
379
+ obj.area.x += pixel_shift_x
380
+ obj.area.y += pixel_shift_y
381
+
382
+ for node in parsed.nodes:
383
+ node.area.x += pixel_shift_x
384
+ node.area.y += pixel_shift_y
385
+
386
+ parsed.meta.map_size = (max_x + shift_x, max_y + shift_y)
387
+ parsed.meta.initial_map_size = (
388
+ parsed.meta.initial_map_size[0] + shift_x,
389
+ parsed.meta.initial_map_size[1] + shift_y,
390
+ )
391
+ parsed.meta.scroll = (
392
+ parsed.meta.scroll[0] + pixel_shift_x,
393
+ parsed.meta.scroll[1] + pixel_shift_y,
394
+ )
395
+ return (pixel_shift_x, pixel_shift_y)
396
+
397
+
315
398
  def load_map(path: PathLike, *, extra_search_base: Optional[Path] = None, skip_missing_images: bool = True, nodes_dir: Optional[PathLike] = None) -> TilemapData:
316
399
  return TilemapData.load(path, extra_search_base=extra_search_base, skip_missing_images=skip_missing_images, nodes_dir=nodes_dir)
@@ -224,9 +224,7 @@ def check_collision(
224
224
  depth=result.depth,
225
225
  )
226
226
 
227
- # ------------------------------------------------------------------
228
227
  # Capsule pairs
229
- # ------------------------------------------------------------------
230
228
  elif isinstance(shape_a, CapsuleShape) and isinstance(shape_b, CapsuleShape):
231
229
  p1 = (obj_a.x + shape_a.offset[0], obj_a.y + shape_a.offset[1])
232
230
  p2 = (p1[0], p1[1] + shape_a.height)
@@ -790,6 +790,171 @@ class CollisionRunner:
790
790
 
791
791
  return best_edge
792
792
 
793
+ def move_grounded(
794
+ self,
795
+ sprite: ICollidableSprite,
796
+ tileset_collision: TilesetCollision,
797
+ tile_map: dict,
798
+ dt: float,
799
+ velocity: Optional[Vector2] = None,
800
+ ) -> CollisionResult:
801
+ """
802
+ Move sprite with basic grounded physics for simple entities.
803
+
804
+ A lightweight alternative to ``move_platformer()`` and
805
+ ``move_platformer_with_slide()`` for enemies and other
806
+ entities that need tilemap collision without platformer
807
+ features like jumping, one-way platforms, or slope walking.
808
+
809
+ Key behaviours:
810
+
811
+ * Applies gravity every frame when the sprite is not on the
812
+ ground.
813
+ * Resolves X collision (walls) by backing out the X axis and
814
+ zeroing horizontal velocity.
815
+ * Resolves Y collision via binary search so the sprite lands
816
+ exactly on the polygon surface --- critical for **partial
817
+ tiles** that only have collision in part of the tile (e.g.
818
+ a 32x32 tile whose collision polygon only occupies the
819
+ bottom 16 px).
820
+ * Detects walk-off-ledge: when the sprite was on the ground
821
+ and moves horizontally over empty space it is immediately
822
+ set airborne.
823
+
824
+ All collision uses the tile polygon shapes through the same
825
+ zero-allocation ``_collides_at`` machinery as the other
826
+ movement methods, so one-way platform polygons are treated
827
+ as solid geometry (they block both X and Y movement).
828
+
829
+ Args:
830
+ sprite: Sprite to move. Must expose ``x``, ``y``, ``vx``,
831
+ ``vy``, ``on_ground``, and ``collision_shape``.
832
+ tileset_collision: Tileset collision data.
833
+ tile_map: Dictionary mapping ``(tile_x, tile_y)`` to tile id.
834
+ dt: Frame delta time in seconds.
835
+ velocity: Optional explicit ``(vx, vy)``. When provided
836
+ the method skips gravity and uses this velocity directly
837
+ instead of reading from ``sprite.vx``/``sprite.vy``.
838
+ Useful for custom controllers, knockback, or flying
839
+ entities that should not receive gravity.
840
+
841
+ Returns:
842
+ :class:`CollisionResult` with final position and flags.
843
+ """
844
+ result = self._result
845
+ result.collided = False
846
+ result.hit_wall_x = False
847
+ result.hit_wall_y = False
848
+ result.hit_ceiling = False
849
+ result.on_ground = False
850
+ result.slide_vector = None
851
+ result.final_x = sprite.x
852
+ result.final_y = sprite.y
853
+
854
+ old_x, old_y = sprite.x, sprite.y
855
+ was_on_ground = getattr(sprite, "on_ground", False)
856
+
857
+ # --- resolve velocity -----------------------------------------------
858
+ if velocity is not None:
859
+ sprite.vx = velocity[0]
860
+ sprite.vy = velocity[1]
861
+ else:
862
+ if not was_on_ground:
863
+ sprite.vy += self.gravity * dt
864
+ if sprite.vy > self.max_fall_speed:
865
+ sprite.vy = self.max_fall_speed
866
+
867
+ delta_x = sprite.vx * dt
868
+ delta_y = sprite.vy * dt
869
+
870
+ # --- X axis ---------------------------------------------------------
871
+ if delta_x != 0.0:
872
+ sprite.x = old_x + delta_x
873
+ sprite.y = old_y
874
+
875
+ if self._collides_at(sprite, tileset_collision, tile_map):
876
+ sprite.x = old_x
877
+ sprite.vx = 0.0
878
+ result.hit_wall_x = True
879
+ result.collided = True
880
+
881
+ # --- ledge detection ------------------------------------------------
882
+ # If the sprite was grounded and isn't actively falling, probe 1 px
883
+ # downward at the *new* X position. When nothing is underneath we
884
+ # immediately leave the ground so gravity kicks in next frame.
885
+ # Not applied when velocity is explicitly passed (caller controls Y).
886
+ if velocity is None and was_on_ground and delta_y == 0.0:
887
+ saved_y = sprite.y
888
+ sprite.y += 1.0
889
+ ground_below = self._collides_at(sprite, tileset_collision, tile_map)
890
+ sprite.y = saved_y
891
+
892
+ if not ground_below:
893
+ sprite.on_ground = False
894
+ was_on_ground = False
895
+
896
+ # Begin falling immediately.
897
+ sprite.vy += self.gravity * dt
898
+ if sprite.vy > self.max_fall_speed:
899
+ sprite.vy = self.max_fall_speed
900
+
901
+ delta_y = sprite.vy * dt
902
+
903
+ # --- Y axis ---------------------------------------------------------
904
+ sprite.y = sprite.y + delta_y
905
+
906
+ collided_y = self._collides_at(sprite, tileset_collision, tile_map)
907
+
908
+ if collided_y:
909
+ result.collided = True
910
+
911
+ if delta_y >= 0.0:
912
+ # Falling -> binary search for ground surface
913
+ sprite.y = old_y
914
+ lo, hi = old_y, old_y + delta_y
915
+
916
+ for _ in range(8):
917
+ mid = (lo + hi) * 0.5
918
+ sprite.y = mid
919
+
920
+ if self._collides_at(sprite, tileset_collision, tile_map):
921
+ hi = mid
922
+ else:
923
+ lo = mid
924
+
925
+ sprite.y = lo
926
+ sprite.vy = 0.0
927
+ sprite.on_ground = True
928
+ result.hit_wall_y = True
929
+ result.on_ground = True
930
+
931
+ else:
932
+ # Rising -> binary search for ceiling
933
+ sprite.y = old_y
934
+ lo, hi = old_y + delta_y, old_y
935
+
936
+ for _ in range(8):
937
+ mid = (lo + hi) * 0.5
938
+ sprite.y = mid
939
+
940
+ if self._collides_at(sprite, tileset_collision, tile_map):
941
+ lo = mid
942
+ else:
943
+ hi = mid
944
+
945
+ sprite.y = hi
946
+ sprite.vy = 0.0
947
+ result.hit_ceiling = True
948
+
949
+ elif delta_y > 0.0:
950
+ sprite.on_ground = False
951
+
952
+ result.final_x = sprite.x
953
+ result.final_y = sprite.y
954
+ result.on_ground = getattr(sprite, "on_ground", False)
955
+
956
+ return result
957
+
793
958
  def move_platformer(
794
959
  self,
795
960
  sprite: ICollidableSprite,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 3.1.21
3
+ Version: 3.1.23
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
@@ -17,6 +17,7 @@ src/tilemap_parser/parser/particle.py
17
17
  src/tilemap_parser/runtime/__init__.py
18
18
  src/tilemap_parser/runtime/animation_player.py
19
19
  src/tilemap_parser/runtime/area_node.py
20
+ src/tilemap_parser/runtime/camera.py
20
21
  src/tilemap_parser/runtime/collision_cache.py
21
22
  src/tilemap_parser/runtime/map_loader.py
22
23
  src/tilemap_parser/runtime/object_collision.py
@@ -25,10 +26,16 @@ src/tilemap_parser/runtime/renderer.py
25
26
  src/tilemap_parser/runtime/tile_collision.py
26
27
  src/tilemap_parser/utils/__init__.py
27
28
  src/tilemap_parser/utils/geometry.py
29
+ tests/test_camera.py
28
30
  tests/test_collision.py
29
31
  tests/test_geometry.py
32
+ tests/test_integration_animation.py
33
+ tests/test_integration_collision.py
34
+ tests/test_integration_map_loader.py
30
35
  tests/test_map_loader.py
36
+ tests/test_move_grounded.py
31
37
  tests/test_object_collision.py
32
38
  tests/test_object_surfaces.py
39
+ tests/test_particle_emitter.py
33
40
  tests/test_render_scale.py
34
41
  tests/test_tile_collision.py