tilemap-parser 3.1.13__tar.gz → 3.1.15__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 (35) hide show
  1. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/PKG-INFO +1 -1
  2. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/pyproject.toml +1 -1
  3. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/map_loader.py +22 -0
  4. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/tile_collision.py +638 -94
  5. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/utils/geometry.py +4 -4
  6. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser.egg-info/PKG-INFO +1 -1
  7. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/tests/test_geometry.py +4 -4
  8. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/tests/test_object_collision.py +20 -0
  9. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/tests/test_tile_collision.py +118 -1
  10. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/LICENSE +0 -0
  11. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/README.md +0 -0
  12. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/setup.cfg +0 -0
  13. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/__init__.py +0 -0
  14. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/parser/__init__.py +0 -0
  15. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/parser/animation.py +0 -0
  16. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/parser/collision.py +0 -0
  17. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/parser/collision_loader.py +0 -0
  18. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/parser/map_parse.py +0 -0
  19. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/parser/node_parse.py +0 -0
  20. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/parser/particle.py +0 -0
  21. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/__init__.py +0 -0
  22. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/animation_player.py +0 -0
  23. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/area_node.py +0 -0
  24. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/collision_cache.py +0 -0
  25. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/object_collision.py +0 -0
  26. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/particles.py +0 -0
  27. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/runtime/renderer.py +0 -0
  28. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser/utils/__init__.py +0 -0
  29. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser.egg-info/SOURCES.txt +0 -0
  30. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser.egg-info/dependency_links.txt +0 -0
  31. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser.egg-info/requires.txt +0 -0
  32. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/src/tilemap_parser.egg-info/top_level.txt +0 -0
  33. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/tests/test_collision.py +0 -0
  34. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/tests/test_map_loader.py +0 -0
  35. {tilemap_parser-3.1.13 → tilemap_parser-3.1.15}/tests/test_render_scale.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 3.1.13
3
+ Version: 3.1.15
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.13"
7
+ version = "3.1.15"
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"
@@ -189,6 +189,28 @@ class TilemapData:
189
189
  def get_tile_layers_dict(self, *, include_hidden: bool = True) -> Dict[int, ParsedLayer]:
190
190
  return {layer.id: layer for layer in self.get_layers(include_hidden=include_hidden, layer_type="tile", sort_by_zindex=False)}
191
191
 
192
+ def build_tile_map(
193
+ self,
194
+ exclude_layers: Optional[set[str]] = None,
195
+ ) -> Dict[Tuple[int, int], int]:
196
+ """Build a ``{(col, row): tile_id}`` dict for use with
197
+ :class:`tilemap_parser.runtime.tile_collision.CollisionRunner`.
198
+
199
+ Only tile layers are scanned; object layers are skipped
200
+ automatically. Pass *exclude_layers* to skip specific tile
201
+ layers by name (e.g. collisions, overlays).
202
+ """
203
+ tile_map: Dict[Tuple[int, int], int] = {}
204
+ for layer in self.parsed.layers:
205
+ if layer.layer_type != "tile":
206
+ continue
207
+ if exclude_layers and layer.name in exclude_layers:
208
+ continue
209
+ for (tx, ty), tile in layer.tiles.items():
210
+ if isinstance(tile.ttype, int):
211
+ tile_map[(tx, ty)] = tile.variant
212
+ return tile_map
213
+
192
214
  def get_image(self, variant: int, ttype: int = 0, *, copy_surface: bool = True) -> Optional[Surface]:
193
215
  if ttype < 0 or ttype >= len(self.surfaces):
194
216
  return None
@@ -97,6 +97,27 @@ def _point_in_polygon_offset(
97
97
  return inside
98
98
 
99
99
 
100
+ def _segments_intersect(
101
+ ax: float,
102
+ ay: float,
103
+ bx: float,
104
+ by: float,
105
+ cx: float,
106
+ cy: float,
107
+ dx: float,
108
+ dy: float,
109
+ ) -> bool:
110
+ """Check if segment AB intersects CD (open — ignores collinear/endpoint)."""
111
+ o1 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
112
+ o2 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax)
113
+ o3 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx)
114
+ o4 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx)
115
+ if (o1 > 0 and o2 < 0) or (o1 < 0 and o2 > 0):
116
+ if (o3 > 0 and o4 < 0) or (o3 < 0 and o4 > 0):
117
+ return True
118
+ return False
119
+
120
+
100
121
  def rect_polygon_collision(
101
122
  rect_x: float, rect_y: float, rect_w: float, rect_h: float, vertices: List[Point]
102
123
  ) -> bool:
@@ -115,29 +136,39 @@ def rect_polygon_collision(
115
136
  min_vy = vy
116
137
  elif vy > max_vy:
117
138
  max_vy = vy
118
- if (
119
- rect_x > max_vx
120
- or rect_x + rect_w < min_vx
121
- or rect_y > max_vy
122
- or rect_y + rect_h < min_vy
123
- ):
139
+ rx2 = rect_x + rect_w
140
+ ry2 = rect_y + rect_h
141
+ if rect_x > max_vx or rx2 < min_vx or rect_y > max_vy or ry2 < min_vy:
124
142
  return False
125
143
 
126
144
  # Corner tests — no tuple allocation
127
145
  if point_in_polygon((rect_x, rect_y), vertices):
128
146
  return True
129
- if point_in_polygon((rect_x + rect_w, rect_y), vertices):
147
+ if point_in_polygon((rx2, rect_y), vertices):
130
148
  return True
131
- if point_in_polygon((rect_x, rect_y + rect_h), vertices):
149
+ if point_in_polygon((rect_x, ry2), vertices):
132
150
  return True
133
- if point_in_polygon((rect_x + rect_w, rect_y + rect_h), vertices):
151
+ if point_in_polygon((rx2, ry2), vertices):
134
152
  return True
135
153
 
136
154
  # Vertex-in-rect
137
- rx2, ry2 = rect_x + rect_w, rect_y + rect_h
138
155
  for vx, vy in vertices:
139
156
  if rect_x <= vx <= rx2 and rect_y <= vy <= ry2:
140
157
  return True
158
+
159
+ # Edge-edge intersection (catches triangle-vs-rectangle cases)
160
+ rect_edges = (
161
+ (rect_x, rect_y, rx2, rect_y),
162
+ (rx2, rect_y, rx2, ry2),
163
+ (rx2, ry2, rect_x, ry2),
164
+ (rect_x, ry2, rect_x, rect_y),
165
+ )
166
+ for rax, ray, rbx, rby in rect_edges:
167
+ for i in range(n):
168
+ p1x, p1y = vertices[i]
169
+ p2x, p2y = vertices[(i + 1) % n]
170
+ if _segments_intersect(rax, ray, rbx, rby, p1x, p1y, p2x, p2y):
171
+ return True
141
172
  return False
142
173
 
143
174
 
@@ -191,6 +222,21 @@ def _rect_polygon_collision_offset(
191
222
  wx, wy = vx * scale + ox, vy * scale + oy
192
223
  if rect_x <= wx <= rx2 and rect_y <= wy <= ry2:
193
224
  return True
225
+
226
+ # Edge-edge intersection (catches triangle-vs-rectangle cases)
227
+ for i in range(n):
228
+ p1x = vertices[i][0] * scale + ox
229
+ p1y = vertices[i][1] * scale + oy
230
+ p2x = vertices[(i + 1) % n][0] * scale + ox
231
+ p2y = vertices[(i + 1) % n][1] * scale + oy
232
+ if _segments_intersect(rect_x, rect_y, rx2, rect_y, p1x, p1y, p2x, p2y):
233
+ return True
234
+ if _segments_intersect(rx2, rect_y, rx2, ry2, p1x, p1y, p2x, p2y):
235
+ return True
236
+ if _segments_intersect(rx2, ry2, rect_x, ry2, p1x, p1y, p2x, p2y):
237
+ return True
238
+ if _segments_intersect(rect_x, ry2, rect_x, rect_y, p1x, p1y, p2x, p2y):
239
+ return True
194
240
  return False
195
241
 
196
242
 
@@ -399,6 +445,8 @@ class CollisionRunner:
399
445
  self.ground_snap_tolerance = 2.0
400
446
  self.step_height = 4.0
401
447
 
448
+ self.max_walk_angle = 60.0 # degrees from horizontal; steeper = wall
449
+
402
450
  self.slide_friction = 0.1
403
451
 
404
452
  self.rpg_snap_to_grid = False
@@ -656,6 +704,18 @@ class CollisionRunner:
656
704
  sprite.y = old_y
657
705
  result.hit_wall_y = True
658
706
 
707
+ if not x_collided and not y_collided:
708
+ if abs(delta_x) >= abs(delta_y):
709
+ sprite.y = old_y
710
+ y_collided = True
711
+ result.hit_wall_y = True
712
+ result.slide_vector = (delta_x, 0.0)
713
+ else:
714
+ sprite.x = old_x
715
+ x_collided = True
716
+ result.hit_wall_x = True
717
+ result.slide_vector = (0.0, delta_y)
718
+
659
719
  result.final_x = sprite.x
660
720
  result.final_y = sprite.y
661
721
 
@@ -777,17 +837,22 @@ class CollisionRunner:
777
837
  delta_x = sprite.vx * dt
778
838
  delta_y = sprite.vy * dt
779
839
  old_x, old_y = sprite.x, sprite.y
840
+ _, _, _, old_bottom = get_shape_bounds(sprite)
780
841
 
781
842
  # X axis
782
843
  sprite.x = old_x + delta_x
783
844
  # Lift above ground snap overlap so ground doesn't block horizontal movement
784
845
  sprite.y = old_y - self.ground_snap_tolerance
785
846
  stepped_up = False
786
- if self._collides_at(sprite, tileset_collision, tile_map):
847
+ if self._collides_at_platformer(
848
+ sprite, tileset_collision, tile_map, include_one_way=False
849
+ ):
787
850
  if delta_x != 0:
788
851
  # Try stepping up onto slope/stairs
789
852
  sprite.y = old_y - self.ground_snap_tolerance - self.step_height
790
- if not self._collides_at(sprite, tileset_collision, tile_map):
853
+ if not self._collides_at_platformer(
854
+ sprite, tileset_collision, tile_map, include_one_way=False
855
+ ):
791
856
  sprite.y = old_y - self.step_height
792
857
  stepped_up = True
793
858
  else:
@@ -853,7 +918,9 @@ class CollisionRunner:
853
918
  for _ in range(8):
854
919
  mid = (lo + hi) * 0.5
855
920
  sprite.y = mid
856
- if self._collides_at(sprite, tileset_collision, tile_map):
921
+ if self._collides_at_platformer(
922
+ sprite, tileset_collision, tile_map, include_one_way=False
923
+ ):
857
924
  hi = mid
858
925
  else:
859
926
  lo = mid
@@ -871,7 +938,13 @@ class CollisionRunner:
871
938
  for _ in range(8):
872
939
  mid = (lo + hi) * 0.5
873
940
  sprite.y = mid
874
- if self._collides_at(sprite, tileset_collision, tile_map):
941
+ if self._collides_at_platformer(
942
+ sprite,
943
+ tileset_collision,
944
+ tile_map,
945
+ include_one_way=True,
946
+ previous_bottom=old_bottom,
947
+ ):
875
948
  hi = mid
876
949
  else:
877
950
  lo = mid
@@ -887,14 +960,67 @@ class CollisionRunner:
887
960
 
888
961
  downward_travel = max(0.0, sprite.vy) * dt
889
962
  if not sprite.on_ground and 0 <= downward_travel <= self.ground_snap_tolerance:
890
- if self._collides_at(sprite, tileset_collision, tile_map):
963
+ if self._collides_at_platformer(
964
+ sprite,
965
+ tileset_collision,
966
+ tile_map,
967
+ include_one_way=True,
968
+ previous_bottom=old_bottom,
969
+ ):
970
+ saved_y = sprite.y
971
+ sprite.y = saved_y - self.ground_snap_tolerance
972
+ if not self._collides_at_platformer(
973
+ sprite,
974
+ tileset_collision,
975
+ tile_map,
976
+ include_one_way=True,
977
+ previous_bottom=old_bottom,
978
+ ):
979
+ lo, hi = sprite.y, saved_y
980
+ for _ in range(8):
981
+ mid = (lo + hi) * 0.5
982
+ sprite.y = mid
983
+ if self._collides_at_platformer(
984
+ sprite,
985
+ tileset_collision,
986
+ tile_map,
987
+ include_one_way=True,
988
+ previous_bottom=old_bottom,
989
+ ):
990
+ hi = mid
991
+ else:
992
+ lo = mid
993
+ sprite.y = lo
994
+ else:
995
+ sprite.y = saved_y
891
996
  sprite.on_ground = True
892
997
  result.on_ground = True
893
998
  sprite.vy = 0.0
894
999
  else:
895
1000
  saved_y = sprite.y
896
1001
  sprite.y += self.ground_snap_tolerance
897
- if self._collides_at(sprite, tileset_collision, tile_map):
1002
+ if self._collides_at_platformer(
1003
+ sprite,
1004
+ tileset_collision,
1005
+ tile_map,
1006
+ include_one_way=True,
1007
+ previous_bottom=old_bottom,
1008
+ ):
1009
+ lo, hi = saved_y, sprite.y
1010
+ for _ in range(8):
1011
+ mid = (lo + hi) * 0.5
1012
+ sprite.y = mid
1013
+ if self._collides_at_platformer(
1014
+ sprite,
1015
+ tileset_collision,
1016
+ tile_map,
1017
+ include_one_way=True,
1018
+ previous_bottom=old_bottom,
1019
+ ):
1020
+ hi = mid
1021
+ else:
1022
+ lo = mid
1023
+ sprite.y = lo
898
1024
  sprite.on_ground = True
899
1025
  result.on_ground = True
900
1026
  sprite.vy = 0.0
@@ -906,6 +1032,291 @@ class CollisionRunner:
906
1032
  result.collided = result.hit_wall_x or collided_y
907
1033
  return result
908
1034
 
1035
+ def _walkable_slope_at(
1036
+ self,
1037
+ sprite: ICollidableSprite,
1038
+ tileset_collision: TilesetCollision,
1039
+ tile_map: dict,
1040
+ motion_x: float,
1041
+ motion_y: float,
1042
+ walk_upness: float,
1043
+ ) -> Optional[float]:
1044
+ """Check if there is a walkable slope at the sprite's current position.
1045
+ Iterates all overlapping tiles and picks the best walkable edge.
1046
+ Returns the Y offset to apply, or None if not a walkable slope.
1047
+ """
1048
+ left, top, right, bottom = get_shape_bounds(sprite)
1049
+ tw, th = self._eff_tw, self._eff_th
1050
+ min_tile_x = int(left // tw) - 1
1051
+ max_tile_x = int(right // tw) + 1
1052
+ min_tile_y = int(top // th) - 1
1053
+ max_tile_y = int(bottom // th) + 1
1054
+
1055
+ best_adj_y = None
1056
+
1057
+ for tile_y in range(min_tile_y, max_tile_y + 1):
1058
+ for tile_x in range(min_tile_x, max_tile_x + 1):
1059
+ tile_id = tile_map.get((tile_x, tile_y))
1060
+ if tile_id is None:
1061
+ continue
1062
+ tile_data = tileset_collision.tiles.get(tile_id)
1063
+ if tile_data is None:
1064
+ continue
1065
+ ox = tile_x * tw
1066
+ oy = tile_y * th
1067
+ for poly in tile_data.shapes:
1068
+ if not poly.is_valid():
1069
+ continue
1070
+ if self._is_full_rect(poly):
1071
+ continue
1072
+ if not _check_sprite_polygon_offset(
1073
+ sprite, poly, ox, oy, self.render_scale
1074
+ ):
1075
+ continue
1076
+
1077
+ # Non-full-rect polygon overlapping the sprite — find the best edge.
1078
+ verts = poly.vertices
1079
+ n = len(verts)
1080
+ cx = sum(verts[j][0] for j in range(n)) / n * self.render_scale + ox
1081
+ cy = sum(verts[j][1] for j in range(n)) / n * self.render_scale + oy
1082
+ for i in range(n):
1083
+ v1x = verts[i][0] * self.render_scale + ox
1084
+ v1y = verts[i][1] * self.render_scale + oy
1085
+ v2x = verts[(i + 1) % n][0] * self.render_scale + ox
1086
+ v2y = verts[(i + 1) % n][1] * self.render_scale + oy
1087
+
1088
+ ex = v2x - v1x
1089
+ ey = v2y - v1y
1090
+ e_len = math.sqrt(ex * ex + ey * ey)
1091
+ if e_len < 0.01:
1092
+ continue
1093
+
1094
+ # Two perpendicular normals
1095
+ nx_a = -ey / e_len
1096
+ ny_a = ex / e_len
1097
+ nx_b = -nx_a
1098
+ ny_b = -ny_a
1099
+
1100
+ # Pick the one pointing INTO the solid (toward polygon interior).
1101
+ # Use the polygon centroid to determine which side is interior.
1102
+ mx = (v1x + v2x) * 0.5
1103
+ my = (v1y + v2y) * 0.5
1104
+ to_centroid_x = cx - mx
1105
+ to_centroid_y = cy - my
1106
+ if nx_a * to_centroid_x + ny_a * to_centroid_y < 0:
1107
+ nx_a = -nx_a
1108
+ ny_a = -ny_a
1109
+ nx_b = -nx_b
1110
+ ny_b = -ny_b
1111
+
1112
+ # nx_a/ny_a now points away from centroid (outward).
1113
+ # nx_b/ny_b points toward centroid (inward) = into the solid.
1114
+
1115
+ # For a walkable surface, the inward normal should push the
1116
+ # player UP (negative screen y when inward is upward).
1117
+ # Inward normal = (nx_b, ny_b) = (-nx_a, -ny_a).
1118
+ # "Upward" in the inward sense means the y-component of
1119
+ # inward normal is negative (pointing up on screen).
1120
+ inward_up = -ny_b # inward normal y component, negated
1121
+ # Actually: for a floor, inward normal = (0, 1) pointing DOWN.
1122
+ # -inward_y = -1, which is wrong.
1123
+ # Better: just use the outward normal's upness.
1124
+ # Outward normal = (nx_a, ny_a).
1125
+ # For a floor, outward = (0, -1). upness = -(-1) = 1. ✓
1126
+ # For this diagonal, outward = ? Let ny_a determine outward.
1127
+
1128
+ outward_ny = ny_a
1129
+ upness = -outward_ny
1130
+ if upness < walk_upness:
1131
+ continue
1132
+
1133
+ # Check motion is INTO the surface (dot with outward normal < 0).
1134
+ out_nx = nx_a
1135
+ out_ny = ny_a
1136
+ dot = motion_x * out_nx + motion_y * out_ny
1137
+ if dot >= 0:
1138
+ continue
1139
+
1140
+ # Project motion along the slope surface.
1141
+ adj_x = motion_x - out_nx * dot
1142
+ adj_y = motion_y - out_ny * dot
1143
+ dy_off = adj_y - motion_y
1144
+ if best_adj_y is None or dy_off > best_adj_y:
1145
+ best_adj_y = dy_off
1146
+
1147
+ if best_adj_y is not None:
1148
+ return best_adj_y
1149
+ return None
1150
+
1151
+ def _is_full_rect(self, poly: CollisionPolygon) -> bool:
1152
+ """Check if a polygon is a full-tile rectangle."""
1153
+ if len(poly.vertices) != 4:
1154
+ return False
1155
+ verts = [
1156
+ (vx * self.render_scale, vy * self.render_scale) for vx, vy in poly.vertices
1157
+ ]
1158
+ min_x = min(v[0] for v in verts)
1159
+ max_x = max(v[0] for v in verts)
1160
+ min_y = min(v[1] for v in verts)
1161
+ max_y = max(v[1] for v in verts)
1162
+ return (
1163
+ abs(max_x - min_x - self._eff_tw) < 1.0
1164
+ and abs(max_y - min_y - self._eff_th) < 1.0
1165
+ )
1166
+
1167
+ def _collides_at_platformer(
1168
+ self,
1169
+ sprite: ICollidableSprite,
1170
+ tileset_collision: TilesetCollision,
1171
+ tile_map: dict,
1172
+ include_one_way: bool = False,
1173
+ previous_bottom: Optional[float] = None,
1174
+ ) -> bool:
1175
+ """Collision query for platformers, with one-way platforms gated by approach."""
1176
+ left, top, right, bottom = get_shape_bounds(sprite)
1177
+ tw, th = self._eff_tw, self._eff_th
1178
+
1179
+ min_tile_x = int(left // tw) - 1
1180
+ max_tile_x = int(right // tw) + 1
1181
+ min_tile_y = int(top // th) - 1
1182
+ max_tile_y = int(bottom // th) + 1
1183
+
1184
+ for tile_y in range(min_tile_y, max_tile_y + 1):
1185
+ for tile_x in range(min_tile_x, max_tile_x + 1):
1186
+ tile_id = tile_map.get((tile_x, tile_y))
1187
+ if tile_id is None:
1188
+ continue
1189
+ tile_data = tileset_collision.tiles.get(tile_id)
1190
+ if tile_data is None:
1191
+ continue
1192
+ ox = tile_x * tw
1193
+ oy = tile_y * th
1194
+ for poly in tile_data.shapes:
1195
+ if not poly.is_valid():
1196
+ continue
1197
+ if poly.one_way:
1198
+ if not include_one_way:
1199
+ continue
1200
+ platform_y = (
1201
+ min(v[1] for v in poly.vertices) * self.render_scale + oy
1202
+ )
1203
+ if (
1204
+ previous_bottom is not None
1205
+ and previous_bottom > platform_y + 0.5
1206
+ ):
1207
+ continue
1208
+ if _check_sprite_polygon_offset(
1209
+ sprite, poly, ox, oy, self.render_scale
1210
+ ):
1211
+ return True
1212
+ return False
1213
+
1214
+ def _walkable_edge_y_at_x(
1215
+ self,
1216
+ poly: CollisionPolygon,
1217
+ ox: float,
1218
+ oy: float,
1219
+ world_x: float,
1220
+ edge_index: int,
1221
+ min_upness: float,
1222
+ ) -> Optional[float]:
1223
+ """Return the world Y for a walkable polygon edge at world_x."""
1224
+ verts = poly.vertices
1225
+ n = len(verts)
1226
+ v1x = verts[edge_index][0] * self.render_scale + ox
1227
+ v1y = verts[edge_index][1] * self.render_scale + oy
1228
+ v2x = verts[(edge_index + 1) % n][0] * self.render_scale + ox
1229
+ v2y = verts[(edge_index + 1) % n][1] * self.render_scale + oy
1230
+
1231
+ min_x = min(v1x, v2x)
1232
+ max_x = max(v1x, v2x)
1233
+ if world_x < min_x - 0.01 or world_x > max_x + 0.01:
1234
+ return None
1235
+
1236
+ edge_x = v2x - v1x
1237
+ edge_y = v2y - v1y
1238
+ edge_len = math.sqrt(edge_x * edge_x + edge_y * edge_y)
1239
+ if edge_len < 0.01:
1240
+ return None
1241
+
1242
+ # Vertical faces are walls, never floors.
1243
+ if abs(edge_x) < 0.01:
1244
+ return None
1245
+
1246
+ normal_x = -edge_y / edge_len
1247
+ normal_y = edge_x / edge_len
1248
+
1249
+ cx = sum(v[0] for v in verts) / n * self.render_scale + ox
1250
+ cy = sum(v[1] for v in verts) / n * self.render_scale + oy
1251
+ mid_x = (v1x + v2x) * 0.5
1252
+ mid_y = (v1y + v2y) * 0.5
1253
+
1254
+ # Flip to outward normal when the candidate points toward the centroid.
1255
+ if normal_x * (cx - mid_x) + normal_y * (cy - mid_y) > 0:
1256
+ normal_x = -normal_x
1257
+ normal_y = -normal_y
1258
+
1259
+ upness = -normal_y
1260
+ if upness < min_upness:
1261
+ return None
1262
+
1263
+ t = (world_x - v1x) / edge_x
1264
+ return v1y + (v2y - v1y) * t
1265
+
1266
+ def _find_walkable_ground_y(
1267
+ self,
1268
+ sprite: ICollidableSprite,
1269
+ tileset_collision: TilesetCollision,
1270
+ tile_map: dict,
1271
+ max_up: float,
1272
+ max_down: float,
1273
+ include_one_way: bool = True,
1274
+ previous_bottom: Optional[float] = None,
1275
+ ) -> Optional[float]:
1276
+ """Find the nearest walkable floor surface under or just above the sprite."""
1277
+ left, top, right, bottom = get_shape_bounds(sprite)
1278
+ sample_xs = (left, (left + right) * 0.5, right)
1279
+
1280
+ tw, th = self._eff_tw, self._eff_th
1281
+ min_tile_x = int((left - 1.0) // tw) - 1
1282
+ max_tile_x = int((right + 1.0) // tw) + 1
1283
+ min_tile_y = int((bottom - max_up - th) // th) - 1
1284
+ max_tile_y = int((bottom + max_down + th) // th) + 1
1285
+ min_upness = math.cos(math.radians(self.max_walk_angle))
1286
+
1287
+ best_y: Optional[float] = None
1288
+ for tile_y in range(min_tile_y, max_tile_y + 1):
1289
+ for tile_x in range(min_tile_x, max_tile_x + 1):
1290
+ tile_id = tile_map.get((tile_x, tile_y))
1291
+ if tile_id is None:
1292
+ continue
1293
+ tile_data = tileset_collision.tiles.get(tile_id)
1294
+ if tile_data is None:
1295
+ continue
1296
+ ox = tile_x * tw
1297
+ oy = tile_y * th
1298
+ for poly in tile_data.shapes:
1299
+ if not poly.is_valid():
1300
+ continue
1301
+ if poly.one_way and not include_one_way:
1302
+ continue
1303
+ for sample_x in sample_xs:
1304
+ for i in range(len(poly.vertices)):
1305
+ ground_y = self._walkable_edge_y_at_x(
1306
+ poly, ox, oy, sample_x, i, min_upness
1307
+ )
1308
+ if ground_y is None:
1309
+ continue
1310
+ one_way_from_above = True
1311
+ if poly.one_way and previous_bottom is not None:
1312
+ one_way_from_above = previous_bottom <= ground_y + 0.5
1313
+ if not one_way_from_above:
1314
+ continue
1315
+ if bottom - max_up <= ground_y <= bottom + max_down:
1316
+ if best_y is None or ground_y < best_y:
1317
+ best_y = ground_y
1318
+ return best_y
1319
+
909
1320
  def move_platformer_with_slide(
910
1321
  self,
911
1322
  sprite: ICollidableSprite,
@@ -914,116 +1325,232 @@ class CollisionRunner:
914
1325
  dt: float,
915
1326
  input_x: float = 0.0,
916
1327
  jump_pressed: bool = False,
917
- max_iterations: int = 4,
918
1328
  ) -> CollisionResult:
919
1329
  """
920
- Move sprite with platformer physics and combined slope-sliding collision.
1330
+ Slope-aware platformer movement.
921
1331
 
922
- Uses iterative normal projection for movement resolution instead of
923
- separate X/Y sweeps. This allows smooth movement on slopes — walking
924
- up a slope ascends the player, walking down follows the surface.
1332
+ Supports:
1333
+ - gravity and jumping
1334
+ - one-way platforms
1335
+ - walkable slopes
1336
+ - stair stepping
1337
+ - smooth ground following
925
1338
 
926
- One-way platforms are treated as solid (same as move_and_slide).
927
- Use move_platformer() if you need one-way pass-through from below.
1339
+ Unlike move_platformer(), this mode follows polygon floor
1340
+ surfaces and prevents steep slopes from being treated as
1341
+ walkable terrain.
928
1342
 
929
1343
  Args:
930
- sprite: Sprite to move (must have vx, vy, on_ground attributes)
931
- tileset_collision: Tileset collision data
932
- tile_map: Dictionary mapping (tile_x, tile_y) to tile_id
933
- dt: Delta time in seconds
934
- input_x: Horizontal input (-1 to 1)
935
- jump_pressed: Whether jump button is pressed
936
- max_iterations: Max slope-slide iterations (default 4)
1344
+ sprite:
1345
+ Sprite being simulated. Expected to provide position,
1346
+ velocity, and ground state attributes.
1347
+
1348
+ tileset_collision:
1349
+ Collision definitions for tiles in the map.
1350
+
1351
+ tile_map:
1352
+ Mapping of (tile_x, tile_y) coordinates to tile identifiers.
1353
+
1354
+ dt:
1355
+ Frame delta time in seconds.
1356
+
1357
+ input_x:
1358
+ Horizontal movement input, typically in the range [-1, 1].
1359
+
1360
+ jump_pressed:
1361
+ True if jump was pressed during this frame.
937
1362
 
938
1363
  Returns:
939
- CollisionResult with final position and collision info
1364
+ CollisionResult describing the resolved movement and collision
1365
+ state after simulation.
940
1366
  """
1367
+
941
1368
  result = self._result
942
- result.collided = False
943
- result.hit_wall_x = False
944
- result.hit_wall_y = False
1369
+ result.collided = False
1370
+ result.hit_wall_x = False
1371
+ result.hit_wall_y = False
945
1372
  result.hit_ceiling = False
946
- result.on_ground = False
1373
+ result.on_ground = False
947
1374
  result.slide_vector = None
948
1375
  result.final_x = sprite.x
949
1376
  result.final_y = sprite.y
950
1377
 
951
- if not getattr(sprite, "on_ground", False):
1378
+ skin = 0.01
1379
+ old_x, old_y = sprite.x, sprite.y
1380
+ _, _, _, old_bottom = get_shape_bounds(sprite)
1381
+ was_on_ground = getattr(sprite, "on_ground", False)
1382
+ jumped = False
1383
+
1384
+ if jump_pressed and was_on_ground:
1385
+ sprite.vy = self.jump_strength
1386
+ sprite.on_ground = False
1387
+ jumped = True
1388
+ elif not was_on_ground:
952
1389
  sprite.vy += self.gravity * dt
953
1390
  if sprite.vy > self.max_fall_speed:
954
1391
  sprite.vy = self.max_fall_speed
955
-
956
- if jump_pressed and getattr(sprite, "on_ground", False):
957
- sprite.vy = self.jump_strength
1392
+ else:
1393
+ sprite.vy = min(sprite.vy, 0.0)
958
1394
 
959
1395
  sprite.vx = input_x * 200.0
960
-
961
1396
  delta_x = sprite.vx * dt
962
1397
  delta_y = sprite.vy * dt
963
- old_x, old_y = sprite.x, sprite.y
964
-
965
- if delta_x == 0 and delta_y == 0:
966
- return result
967
-
968
- motion_x, motion_y = delta_x, delta_y
969
- collided = False
1398
+ bottom_offset = old_bottom - old_y
970
1399
 
971
- for _ in range(max_iterations):
972
- if abs(motion_x) < 0.01 and abs(motion_y) < 0.01:
973
- break
1400
+ slope_follow = abs(delta_x) * math.tan(math.radians(self.max_walk_angle))
1401
+ max_ground_up = max(self.step_height, slope_follow + skin)
1402
+ max_ground_down = max(self.ground_snap_tolerance, slope_follow + skin)
974
1403
 
975
- sprite.x = old_x + motion_x
976
- sprite.y = old_y + motion_y
1404
+ # Horizontal movement first. Grounded sprites are allowed to follow
1405
+ # walkable floor contours, but only when a jump did not start this frame.
1406
+ if delta_x != 0.0:
1407
+ sprite.x = old_x + delta_x
1408
+ sprite.y = old_y
977
1409
 
978
- hit = self._first_colliding_shape(sprite, tileset_collision, tile_map)
979
- if hit is None:
980
- break
1410
+ followed_ground = False
1411
+ if was_on_ground and not jumped:
1412
+ ground_y = self._find_walkable_ground_y(
1413
+ sprite,
1414
+ tileset_collision,
1415
+ tile_map,
1416
+ max_up=max_ground_up,
1417
+ max_down=max_ground_down,
1418
+ include_one_way=True,
1419
+ previous_bottom=old_bottom,
1420
+ )
1421
+ if ground_y is not None:
1422
+ sprite.y = ground_y - bottom_offset - skin
1423
+ followed_ground = True
1424
+
1425
+ if self._collides_at_platformer(
1426
+ sprite, tileset_collision, tile_map, include_one_way=False
1427
+ ):
1428
+ sprite.x = old_x + delta_x
1429
+ sprite.y = old_y - self.step_height
1430
+ step_ground_y = self._find_walkable_ground_y(
1431
+ sprite,
1432
+ tileset_collision,
1433
+ tile_map,
1434
+ max_up=self.step_height + skin,
1435
+ max_down=self.step_height + skin,
1436
+ include_one_way=False,
1437
+ previous_bottom=old_bottom,
1438
+ )
1439
+ if step_ground_y is not None:
1440
+ sprite.y = step_ground_y - bottom_offset - skin
1441
+ if step_ground_y is None or self._collides_at_platformer(
1442
+ sprite, tileset_collision, tile_map, include_one_way=False
1443
+ ):
1444
+ sprite.x = old_x
1445
+ sprite.y = old_y
1446
+ sprite.vx = 0.0
1447
+ result.collided = True
1448
+ result.hit_wall_x = True
1449
+ else:
1450
+ followed_ground = True
981
1451
 
1452
+ if followed_ground:
1453
+ sprite.on_ground = True
1454
+ result.on_ground = True
1455
+ else:
982
1456
  sprite.x = old_x
983
1457
  sprite.y = old_y
984
- collided = True
985
1458
 
986
- poly, ox, oy = hit
987
- normal = self._get_collision_normal_from_motion(
988
- sprite, poly, ox, oy, motion_x, motion_y, self.render_scale
1459
+ y_before_vertical = sprite.y
1460
+ _, _, _, previous_bottom = get_shape_bounds(sprite)
1461
+
1462
+ if jumped or sprite.vy < 0.0:
1463
+ sprite.y = y_before_vertical + delta_y
1464
+ if self._collides_at_platformer(
1465
+ sprite, tileset_collision, tile_map, include_one_way=False
1466
+ ):
1467
+ lo = y_before_vertical + delta_y
1468
+ hi = y_before_vertical
1469
+ for _ in range(10):
1470
+ mid = (lo + hi) * 0.5
1471
+ sprite.y = mid
1472
+ if self._collides_at_platformer(
1473
+ sprite, tileset_collision, tile_map, include_one_way=False
1474
+ ):
1475
+ lo = mid
1476
+ else:
1477
+ hi = mid
1478
+ sprite.y = hi
1479
+ sprite.vy = 0.0
1480
+ sprite.on_ground = False
1481
+ result.collided = True
1482
+ result.hit_ceiling = True
1483
+ else:
1484
+ sprite.on_ground = False
1485
+ elif sprite.vy > 0.0:
1486
+ sprite.y = y_before_vertical + delta_y
1487
+ ground_y = self._find_walkable_ground_y(
1488
+ sprite,
1489
+ tileset_collision,
1490
+ tile_map,
1491
+ max_up=abs(delta_y) + max_ground_up,
1492
+ max_down=skin,
1493
+ include_one_way=True,
1494
+ previous_bottom=previous_bottom,
989
1495
  )
990
- if normal is None:
991
- break
992
-
993
- dot = motion_x * normal[0] + motion_y * normal[1]
994
- if dot < 0:
995
- motion_x -= normal[0] * dot
996
- motion_y -= normal[1] * dot
1496
+ if ground_y is not None:
1497
+ sprite.y = ground_y - bottom_offset - skin
1498
+ sprite.vy = 0.0
1499
+ sprite.on_ground = True
1500
+ result.on_ground = True
1501
+ result.collided = True
1502
+ result.hit_wall_y = True
1503
+ elif self._collides_at_platformer(
1504
+ sprite,
1505
+ tileset_collision,
1506
+ tile_map,
1507
+ include_one_way=True,
1508
+ previous_bottom=previous_bottom,
1509
+ ):
1510
+ lo = y_before_vertical
1511
+ hi = y_before_vertical + delta_y
1512
+ for _ in range(10):
1513
+ mid = (lo + hi) * 0.5
1514
+ sprite.y = mid
1515
+ if self._collides_at_platformer(
1516
+ sprite,
1517
+ tileset_collision,
1518
+ tile_map,
1519
+ include_one_way=True,
1520
+ previous_bottom=previous_bottom,
1521
+ ):
1522
+ hi = mid
1523
+ else:
1524
+ lo = mid
1525
+ sprite.y = lo
1526
+ sprite.vy = 0.0
1527
+ sprite.on_ground = True
1528
+ result.on_ground = True
1529
+ result.collided = True
1530
+ result.hit_wall_y = True
997
1531
  else:
998
- break
1532
+ sprite.on_ground = False
1533
+ elif not jumped:
1534
+ ground_y = self._find_walkable_ground_y(
1535
+ sprite,
1536
+ tileset_collision,
1537
+ tile_map,
1538
+ max_up=max_ground_up,
1539
+ max_down=max_ground_down,
1540
+ include_one_way=True,
1541
+ previous_bottom=previous_bottom,
1542
+ )
1543
+ if ground_y is not None:
1544
+ sprite.y = ground_y - bottom_offset - skin
1545
+ sprite.vy = 0.0
1546
+ sprite.on_ground = True
1547
+ result.on_ground = True
1548
+ else:
1549
+ sprite.on_ground = False
999
1550
 
1000
1551
  result.final_x = sprite.x
1001
1552
  result.final_y = sprite.y
1002
- result.collided = collided
1003
-
1004
- if collided:
1005
- if sprite.vy >= 0:
1006
- sprite.y += 1.0
1007
- if self._collides_at(sprite, tileset_collision, tile_map):
1008
- result.on_ground = True
1009
- sprite.on_ground = True
1010
- sprite.vy = 0.0
1011
- else:
1012
- sprite.on_ground = False
1013
- sprite.y -= 1.0
1014
-
1015
- if sprite.vy < 0:
1016
- sprite.y -= 1.0
1017
- if self._collides_at(sprite, tileset_collision, tile_map):
1018
- result.hit_ceiling = True
1019
- sprite.vy = 0.0
1020
- sprite.y += 1.0
1021
-
1022
- if abs(result.final_x - old_x) < 0.01 and abs(delta_x) > 0.01:
1023
- result.hit_wall_x = True
1024
- else:
1025
- sprite.on_ground = False
1026
-
1553
+ result.on_ground = getattr(sprite, "on_ground", False)
1027
1554
  return result
1028
1555
 
1029
1556
  def move_rpg(
@@ -1070,8 +1597,25 @@ class CollisionRunner:
1070
1597
  sprite.x = old_x
1071
1598
  sprite.y = old_y
1072
1599
  result.collided = True
1073
- result.hit_wall_x = delta_x != 0
1074
- result.hit_wall_y = delta_y != 0
1600
+
1601
+ x_blocked = False
1602
+ y_blocked = False
1603
+ if delta_x != 0:
1604
+ sprite.x = old_x + delta_x
1605
+ sprite.y = old_y
1606
+ x_blocked = self._collides_at(sprite, tileset_collision, tile_map)
1607
+ if delta_y != 0:
1608
+ sprite.x = old_x
1609
+ sprite.y = old_y + delta_y
1610
+ y_blocked = self._collides_at(sprite, tileset_collision, tile_map)
1611
+ sprite.x = old_x
1612
+ sprite.y = old_y
1613
+
1614
+ if not x_blocked and not y_blocked:
1615
+ x_blocked = delta_x != 0
1616
+ y_blocked = delta_y != 0
1617
+ result.hit_wall_x = x_blocked
1618
+ result.hit_wall_y = y_blocked
1075
1619
  else:
1076
1620
  result.final_x = sprite.x
1077
1621
  result.final_y = sprite.y
@@ -193,16 +193,16 @@ def rect_vs_circle(
193
193
 
194
194
  if min_dist == dist_left:
195
195
  normal = (-1.0, 0.0)
196
- depth = circle_radius - dist_left
196
+ depth = circle_radius + dist_left
197
197
  elif min_dist == dist_right:
198
198
  normal = (1.0, 0.0)
199
- depth = circle_radius - dist_right
199
+ depth = circle_radius + dist_right
200
200
  elif min_dist == dist_top:
201
201
  normal = (0.0, -1.0)
202
- depth = circle_radius - dist_top
202
+ depth = circle_radius + dist_top
203
203
  else:
204
204
  normal = (0.0, 1.0)
205
- depth = circle_radius - dist_bottom
205
+ depth = circle_radius + dist_bottom
206
206
  else:
207
207
  depth = circle_radius - dist
208
208
  normal = (dx / dist, dy / dist)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilemap-parser
3
- Version: 3.1.13
3
+ Version: 3.1.15
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
@@ -390,9 +390,9 @@ class TestRectVsCircle:
390
390
  result = rect_vs_circle(rect, center, 8.0)
391
391
  assert result is not None
392
392
  # Closest edge is left (dist_left = 5.0), so normal = (-1, 0)
393
- # depth = radius - dist_left = 8 - 5 = 3
393
+ # depth separates the full circle out of the rect: radius + dist_left.
394
394
  assert result.normal == (-1.0, 0.0)
395
- assert result.depth == pytest.approx(3.0)
395
+ assert result.depth == pytest.approx(13.0)
396
396
 
397
397
  def test_circle_inside_rect_closest_top(self):
398
398
  rect = (0.0, 0.0, 20.0, 20.0)
@@ -401,7 +401,7 @@ class TestRectVsCircle:
401
401
  assert result is not None
402
402
  # Closest edge is top (dist_top = 3.0)
403
403
  assert result.normal == (0.0, -1.0)
404
- assert result.depth == pytest.approx(5.0)
404
+ assert result.depth == pytest.approx(11.0)
405
405
 
406
406
  def test_circle_inside_rect_closest_bottom(self):
407
407
  rect = (0.0, 0.0, 20.0, 20.0)
@@ -410,7 +410,7 @@ class TestRectVsCircle:
410
410
  assert result is not None
411
411
  # Closest edge is bottom (dist_bottom = 3.0)
412
412
  assert result.normal == (0.0, 1.0)
413
- assert result.depth == pytest.approx(5.0)
413
+ assert result.depth == pytest.approx(11.0)
414
414
 
415
415
  def test_circle_center_on_edge(self):
416
416
  """Circle center exactly on rect edge."""
@@ -141,6 +141,16 @@ class TestCheckCollisionRectCircle:
141
141
  c = _make_circle(30, 5, 5)
142
142
  assert check_collision(r, c) is None
143
143
 
144
+ def test_circle_fully_inside_rect_has_positive_depth(self):
145
+ r = _make_rect(0, 0, 100, 100)
146
+ c = _make_circle(50, 50, 5)
147
+
148
+ hit = check_collision(r, c)
149
+
150
+ assert hit is not None
151
+ assert hit.depth == pytest.approx(55.0)
152
+ assert hit.normal == (-1.0, 0.0)
153
+
144
154
 
145
155
  class TestCheckCollisionCircleRect:
146
156
  def test_normal_flipped(self):
@@ -152,6 +162,16 @@ class TestCheckCollisionCircleRect:
152
162
  # Normal should point from circle toward rect (left, so negative X)
153
163
  assert hit.normal[0] == pytest.approx(-1.0)
154
164
 
165
+ def test_rect_fully_contains_circle_has_positive_depth(self):
166
+ c = _make_circle(50, 50, 5)
167
+ r = _make_rect(0, 0, 100, 100)
168
+
169
+ hit = check_collision(c, r)
170
+
171
+ assert hit is not None
172
+ assert hit.depth == pytest.approx(55.0)
173
+ assert hit.normal == (1.0, -0.0)
174
+
155
175
 
156
176
  class TestCheckCollisionLayerFiltering:
157
177
  def test_layer_blocks(self):
@@ -207,6 +207,18 @@ class TestMoveAndSlide:
207
207
 
208
208
  assert result.final_y == 56
209
209
 
210
+ def test_diagonal_corner_collision_keeps_safe_slide_axis(self):
211
+ tile_map = {(4, 4): 0}
212
+ sprite = MockSprite(x=100, y=92)
213
+
214
+ result = self.runner.move_and_slide(sprite, self.tileset, tile_map, 5, 5)
215
+
216
+ assert result.collided is True
217
+ assert result.final_x == 105
218
+ assert result.final_y == 92
219
+ assert result.slide_vector == (5, 0.0)
220
+ assert not self.runner._collides_at(sprite, self.tileset, tile_map)
221
+
210
222
 
211
223
  # ===========================================================================
212
224
  # Runner: move_platformer Tests
@@ -227,6 +239,101 @@ class TestMovePlatformer:
227
239
 
228
240
  assert sprite.vy > 0
229
241
 
242
+ def test_ground_snap_does_not_embed_sprite(self):
243
+ tile_map = {(0, 5): 0}
244
+ sprite = MockSprite(x=4, y=127)
245
+ sprite.vy = 0
246
+ sprite.on_ground = False
247
+
248
+ result = self.runner.move_platformer(
249
+ sprite, self.tileset, tile_map, dt=0.016, input_x=0, jump_pressed=False
250
+ )
251
+
252
+ assert result.on_ground is True
253
+ assert sprite.on_ground is True
254
+ assert sprite.y < 129
255
+ assert not self.runner._collides_at(sprite, self.tileset, tile_map)
256
+
257
+ def test_one_way_platform_does_not_block_horizontal_movement(self):
258
+ tile_map = make_tilemap_with_one_way()
259
+ sprite = MockSprite(x=137, y=160)
260
+ sprite.vy = 0
261
+ sprite.on_ground = True
262
+
263
+ result = self.runner.move_platformer(
264
+ sprite, self.tileset, tile_map, dt=0.016, input_x=1, jump_pressed=False
265
+ )
266
+
267
+ assert sprite.x > 137
268
+ assert result.hit_wall_x is False
269
+
270
+
271
+ class TestMovePlatformerWithSlide:
272
+ def setup_method(self):
273
+ self.runner = CollisionRunner.from_game_type("platformer", (32, 32))
274
+ self.tileset = make_tileset_with_floor()
275
+
276
+ def test_moves_and_resets_reused_result(self):
277
+ blocking_map = make_tilemap_floor_only()
278
+ blocked_sprite = MockSprite(x=96, y=32)
279
+ first = self.runner.move_rpg(blocked_sprite, self.tileset, blocking_map, 5, 0)
280
+ assert first.collided is True
281
+
282
+ sprite = MockSprite(x=100, y=100)
283
+ sprite.vy = 0
284
+ sprite.on_ground = False
285
+
286
+ result = self.runner.move_platformer_with_slide(
287
+ sprite, self.tileset, {}, dt=0.016, input_x=1, jump_pressed=False
288
+ )
289
+
290
+ assert sprite.x > 100
291
+ assert sprite.y > 100
292
+ assert result.collided is False
293
+ assert result.hit_wall_x is False
294
+
295
+ def test_jump_takes_priority_over_ground_following(self):
296
+ sprite = MockSprite(x=100, y=100)
297
+ sprite.vy = 0
298
+ sprite.on_ground = True
299
+
300
+ result = self.runner.move_platformer_with_slide(
301
+ sprite, self.tileset, {}, dt=0.016, input_x=0, jump_pressed=True
302
+ )
303
+
304
+ assert sprite.y < 100
305
+ assert sprite.vy < 0
306
+ assert sprite.on_ground is False
307
+ assert result.on_ground is False
308
+
309
+ def test_follows_walkable_slope_up(self):
310
+ tile_map = {(5, 5): 2}
311
+ sprite = MockSprite(x=160, y=169.99, shape=RectangleShape(width=8, height=16))
312
+ sprite.vy = 0
313
+ sprite.on_ground = True
314
+
315
+ result = self.runner.move_platformer_with_slide(
316
+ sprite, self.tileset, tile_map, dt=0.016, input_x=1, jump_pressed=False
317
+ )
318
+
319
+ assert sprite.x > 160
320
+ assert sprite.y < 169.99
321
+ assert sprite.on_ground is True
322
+ assert result.on_ground is True
323
+
324
+ def test_steep_wall_blocks_horizontal_motion(self):
325
+ tile_map = {(4, 2): 0}
326
+ sprite = MockSprite(x=103, y=80)
327
+ sprite.vy = 0
328
+ sprite.on_ground = True
329
+
330
+ result = self.runner.move_platformer_with_slide(
331
+ sprite, self.tileset, tile_map, dt=0.016, input_x=1, jump_pressed=False
332
+ )
333
+
334
+ assert sprite.x == 103
335
+ assert result.hit_wall_x is True
336
+
230
337
 
231
338
  class TestMoveRpg:
232
339
  def setup_method(self):
@@ -241,6 +348,16 @@ class TestMoveRpg:
241
348
 
242
349
  assert result.final_x == 100
243
350
 
351
+ def test_reports_blocked_axis_for_diagonal_move(self):
352
+ tile_map = {(4, 2): 0}
353
+ sprite = MockSprite(x=103, y=80)
354
+
355
+ result = self.runner.move_rpg(sprite, self.tileset, tile_map, 5, 5)
356
+
357
+ assert result.collided is True
358
+ assert result.hit_wall_x is True
359
+ assert result.hit_wall_y is False
360
+
244
361
 
245
362
  # ===========================================================================
246
363
  # Runner: One-Way Platforms
@@ -297,4 +414,4 @@ class TestRealisticMap:
297
414
  assert self.tileset.has_collision(8) is True
298
415
  tile_data = self.tileset.tiles.get(8)
299
416
  assert tile_data is not None
300
- assert tile_data.shapes[0].one_way is True
417
+ assert tile_data.shapes[0].one_way is True
File without changes