From 7fbb32d8b93c6a96afcaaeab660adf4d19409317 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Tue, 14 Apr 2026 11:02:26 +0000 Subject: [PATCH] feat: extract reusable pathing theory generator --- tools/pathing_theory/__init__.py | 1 + tools/pathing_theory/models.py | 19 ++ tools/pathing_theory/primitives.py | 214 +++++++++++++++++ tools/pathing_theory/simulator.py | 119 ++++++++++ tools/sim_jump_reach.py | 268 +--------------------- tools/tests/__init__.py | 1 + tools/tests/test_pathing_theory_matrix.py | 27 +++ 7 files changed, 389 insertions(+), 260 deletions(-) create mode 100644 tools/pathing_theory/__init__.py create mode 100644 tools/pathing_theory/models.py create mode 100644 tools/pathing_theory/primitives.py create mode 100644 tools/pathing_theory/simulator.py create mode 100644 tools/tests/__init__.py create mode 100644 tools/tests/test_pathing_theory_matrix.py diff --git a/tools/pathing_theory/__init__.py b/tools/pathing_theory/__init__.py new file mode 100644 index 00000000..befbaa38 --- /dev/null +++ b/tools/pathing_theory/__init__.py @@ -0,0 +1 @@ +"""Reusable theory generation helpers for pathing analysis tools.""" diff --git a/tools/pathing_theory/models.py b/tools/pathing_theory/models.py new file mode 100644 index 00000000..1474b288 --- /dev/null +++ b/tools/pathing_theory/models.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TheoryCase: + case_id: str + family: str + subfamily: str + movement_mode: str + momentum_ticks: int + gap_blocks: int | None + delta_y: float | None + ceiling_height: float | None + wall_width: int | None + expected_reachable: bool + landing_x: float | None + apex_y: float | None + margin: float | None + notes: str = "" diff --git a/tools/pathing_theory/primitives.py b/tools/pathing_theory/primitives.py new file mode 100644 index 00000000..8f035b60 --- /dev/null +++ b/tools/pathing_theory/primitives.py @@ -0,0 +1,214 @@ +from dataclasses import dataclass +from typing import Optional + +PLAYER_WIDTH = 0.6 +PLAYER_HEIGHT = 1.8 +STEP_HEIGHT = 0.6 + +GRAVITY = 0.08 +DRAG_Y = 0.98 +FRICTION_MULTIPLIER = 0.91 +DEFAULT_BLOCK_FRICTION = 0.6 +INPUT_FRICTION = 0.98 +GROUND_ACCEL_FACTOR = 0.21600002 +AIR_ACCEL = 0.02 +MOVEMENT_SPEED = 0.1 + +BASE_JUMP_POWER = 0.42 +SPRINT_JUMP_HORIZONTAL_BOOST = 0.2 + +HORIZONTAL_VELOCITY_THRESHOLD_SQR = 9.0e-6 +VERTICAL_VELOCITY_THRESHOLD = 0.003 + +HALF_WIDTH = PLAYER_WIDTH / 2.0 + + +@dataclass +class TickState: + tick: int = 0 + x: float = 0.0 + y: float = 0.0 + vx: float = 0.0 + vy: float = 0.0 + on_ground: bool = True + + +def get_ground_speed(block_friction: float = DEFAULT_BLOCK_FRICTION) -> float: + friction = block_friction * FRICTION_MULTIPLIER + return MOVEMENT_SPEED * (GROUND_ACCEL_FACTOR / (friction * friction * friction)) + + +def simulate_jump( + sprint: bool = True, + momentum_ticks: int = 12, + ceiling_y: Optional[float] = None, + landing_y: float = 0.0, + landing_x_start: float = 0.0, + max_ticks: int = 200, +) -> list[TickState]: + x, y, vx, vy = 0.0, 0.0, 0.0, 0.0 + on_ground = True + trajectory: list[TickState] = [] + jumped = False + ground_friction = DEFAULT_BLOCK_FRICTION * FRICTION_MULTIPLIER + + trajectory.append(TickState(0, x, y, vx, vy, on_ground)) + + for tick in range(1, max_ticks + 1): + if vx * vx < HORIZONTAL_VELOCITY_THRESHOLD_SQR: + vx = 0.0 + if abs(vy) < VERTICAL_VELOCITY_THRESHOLD: + vy = 0.0 + + do_jump = False + if not jumped and tick > momentum_ticks and on_ground: + do_jump = True + jumped = True + + if do_jump: + vy = max(BASE_JUMP_POWER, vy) + if sprint: + vx += SPRINT_JUMP_HORIZONTAL_BOOST + + forward_input = 1.0 * INPUT_FRICTION + speed = get_ground_speed() if on_ground else AIR_ACCEL + vx += forward_input * speed + + new_x = x + vx + new_y = y + vy + new_on_ground = False + + if ceiling_y is not None: + head_y = new_y + PLAYER_HEIGHT + if head_y > ceiling_y: + new_y = ceiling_y - PLAYER_HEIGHT + if vy > 0: + vy = 0.0 + + floor_y = 0.0 if new_x < landing_x_start else landing_y + + if jumped: + if new_x >= landing_x_start: + if landing_y >= 0: + if vy <= 0 and y >= landing_y and new_y <= landing_y: + new_y = landing_y + vy = 0.0 + new_on_ground = True + elif vy <= 0 and new_y <= landing_y: + new_y = landing_y + vy = 0.0 + new_on_ground = True + else: + if new_y <= landing_y: + new_y = landing_y + if vy < 0: + vy = 0.0 + new_on_ground = True + + if not new_on_ground and new_x < landing_x_start and new_y <= floor_y: + new_y = floor_y + if vy < 0: + vy = 0.0 + new_on_ground = True + elif new_y <= 0.0: + new_y = 0.0 + if vy < 0: + vy = 0.0 + new_on_ground = True + + x = new_x + y = new_y + on_ground = new_on_ground + + vy -= GRAVITY + vy *= DRAG_Y + + if on_ground: + vx *= ground_friction + else: + vx *= FRICTION_MULTIPLIER + + trajectory.append(TickState(tick, x, y, vx, vy, on_ground)) + + if jumped and on_ground: + break + + return trajectory + + +def get_landing( + sprint: bool, + target_y: float, + landing_x_start: float = 0.0, + momentum_ticks: int = 12, + ceiling_y: Optional[float] = None, +) -> Optional[tuple[float, float]]: + trajectory = simulate_jump( + sprint=sprint, + momentum_ticks=momentum_ticks, + ceiling_y=ceiling_y, + landing_y=target_y, + landing_x_start=landing_x_start, + ) + was_air = False + for state in trajectory: + if not state.on_ground: + was_air = True + if was_air and state.on_ground: + return state.x, state.y + return None + + +def get_apex( + sprint: bool, + momentum_ticks: int = 12, + ceiling_y: Optional[float] = None, +) -> tuple[float, float]: + trajectory = simulate_jump( + sprint=sprint, + momentum_ticks=momentum_ticks, + ceiling_y=ceiling_y, + landing_y=-1000.0, + landing_x_start=0.0, + max_ticks=300, + ) + best_y, best_x = 0.0, 0.0 + for state in trajectory: + if state.y > best_y: + best_y = state.y + best_x = state.x + return best_y, best_x + + +def can_reach_gap( + gap_blocks: int, + dy: float, + sprint: bool = True, + momentum_ticks: int = 12, +) -> tuple[bool, Optional[float], float]: + if dy > 1.252: + return False, None, 0.0 + + needed_x = 0.5 + gap_blocks + HALF_WIDTH + landing_platform_start = 0.5 + gap_blocks + + if gap_blocks == 0 and dy > 0: + landing_platform_start = 0.5 + + result = get_landing( + sprint=sprint, + target_y=dy, + landing_x_start=landing_platform_start, + momentum_ticks=momentum_ticks, + ) + if result is None: + return False, None, needed_x + + landing_x, landing_y = result + if abs(landing_y - dy) > 0.01: + return False, landing_x, needed_x + + if gap_blocks > 0 and landing_x < needed_x: + return False, landing_x, needed_x + + return True, landing_x, needed_x diff --git a/tools/pathing_theory/simulator.py b/tools/pathing_theory/simulator.py new file mode 100644 index 00000000..e7e80fab --- /dev/null +++ b/tools/pathing_theory/simulator.py @@ -0,0 +1,119 @@ +from tools.pathing_theory.models import TheoryCase +from tools.pathing_theory.primitives import PLAYER_WIDTH, can_reach_gap, get_apex, get_landing + + +def _float_token(value: float) -> str: + return f"{value:.1f}".replace("-", "m").replace(".", "p") + + +def build_theory_cases() -> list[TheoryCase]: + cases: list[TheoryCase] = [] + + for sprint, movement_mode, momentum_ticks in [ + (False, "walk", 12), + (True, "sprint", 0), + (True, "sprint", 12), + ]: + for gap in range(0, 7): + for delta_y in [0.0, 1.0, -1.0, -2.0]: + ok, landing_x, needed_x = can_reach_gap( + gap_blocks=gap, + dy=delta_y, + sprint=sprint, + momentum_ticks=momentum_ticks, + ) + apex_y, _ = get_apex(sprint=sprint, momentum_ticks=momentum_ticks) + subfamily = ( + "flat" + if delta_y == 0.0 + else "ascend" + if delta_y > 0.0 + else "descend" + ) + cases.append( + TheoryCase( + case_id=( + f"linear-{subfamily}-{movement_mode}-mm{momentum_ticks}" + f"-gap{gap}-dy{_float_token(delta_y)}" + ), + family="linear", + subfamily=subfamily, + movement_mode=movement_mode, + momentum_ticks=momentum_ticks, + gap_blocks=gap, + delta_y=delta_y, + ceiling_height=None, + wall_width=None, + expected_reachable=ok, + landing_x=landing_x, + apex_y=apex_y, + margin=None if landing_x is None else landing_x - needed_x, + ) + ) + + landing = get_landing( + sprint=True, + target_y=0.0, + landing_x_start=0.0, + momentum_ticks=12, + ) + for wall_width in [1, 2, 3, 4]: + landing_x = None if landing is None else landing[0] + needed_x = wall_width + PLAYER_WIDTH + margin = None if landing_x is None else landing_x - needed_x + cases.append( + TheoryCase( + case_id=f"neo-neo-sprint-mm12-wall{wall_width}", + family="neo", + subfamily="neo", + movement_mode="sprint", + momentum_ticks=12, + gap_blocks=None, + delta_y=0.0, + ceiling_height=None, + wall_width=wall_width, + expected_reachable=margin is not None and margin >= 0.0, + landing_x=landing_x, + apex_y=get_apex(sprint=True, momentum_ticks=12)[0], + margin=margin, + ) + ) + + for ceiling_height in [4.0, 3.0, 2.5, 2.0, 1.8125]: + for gap in [1, 2, 3, 4]: + landing = get_landing( + sprint=True, + target_y=0.0, + landing_x_start=0.5 + gap, + momentum_ticks=12, + ceiling_y=ceiling_height, + ) + landing_x = None if landing is None else landing[0] + needed_x = 0.5 + gap + (PLAYER_WIDTH / 2.0) + margin = None if landing_x is None else landing_x - needed_x + cases.append( + TheoryCase( + case_id=( + f"ceiling-headhitter-sprint-mm12-gap{gap}" + f"-ceil{str(ceiling_height).replace('.', 'p')}" + ), + family="ceiling", + subfamily="headhitter", + movement_mode="sprint", + momentum_ticks=12, + gap_blocks=gap, + delta_y=0.0, + ceiling_height=ceiling_height, + wall_width=None, + expected_reachable=margin is not None and margin >= 0.0, + landing_x=landing_x, + apex_y=get_apex( + sprint=True, + momentum_ticks=12, + ceiling_y=ceiling_height, + )[0], + margin=margin, + ) + ) + + return cases diff --git a/tools/sim_jump_reach.py b/tools/sim_jump_reach.py index a4c94bed..99ea810e 100644 --- a/tools/sim_jump_reach.py +++ b/tools/sim_jump_reach.py @@ -16,267 +16,15 @@ Usage: """ import argparse -import math import csv -from dataclasses import dataclass -from typing import Optional - -# ============================================================ -# Vanilla physics constants (match PhysicsConsts.cs) -# ============================================================ - -PLAYER_WIDTH = 0.6 -PLAYER_HEIGHT = 1.8 -STEP_HEIGHT = 0.6 - -GRAVITY = 0.08 -DRAG_Y = 0.98 -FRICTION_MULTIPLIER = 0.91 -DEFAULT_BLOCK_FRICTION = 0.6 -INPUT_FRICTION = 0.98 -GROUND_ACCEL_FACTOR = 0.21600002 -AIR_ACCEL = 0.02 -MOVEMENT_SPEED = 0.1 - -BASE_JUMP_POWER = 0.42 -SPRINT_JUMP_HORIZONTAL_BOOST = 0.2 - -HORIZONTAL_VELOCITY_THRESHOLD_SQR = 9.0e-6 -VERTICAL_VELOCITY_THRESHOLD = 0.003 - -HALF_WIDTH = PLAYER_WIDTH / 2.0 # 0.3 - - -@dataclass -class TickState: - tick: int = 0 - x: float = 0.0 - y: float = 0.0 - vx: float = 0.0 - vy: float = 0.0 - on_ground: bool = True - - -def get_ground_speed(block_friction: float = DEFAULT_BLOCK_FRICTION) -> float: - f = block_friction * FRICTION_MULTIPLIER - return MOVEMENT_SPEED * (GROUND_ACCEL_FACTOR / (f * f * f)) - - -def simulate_jump(sprint: bool = True, momentum_ticks: int = 12, - ceiling_y: Optional[float] = None, - landing_y: float = 0.0, - landing_x_start: float = 0.0, - max_ticks: int = 200) -> list[TickState]: - """ - Simulate a complete jump sequence: momentum phase on ground, then jump. - - The player starts at x=0, y=0 on a platform at y=0. - - landing_y: Y coordinate of the landing surface. - landing_x_start: the X coordinate where the landing surface begins. - For flat jumps (landing_y=0), this is 0 (same level everywhere). - For ascending jumps (landing_y>0), this is typically gap_start - (the landing platform isn't under the player at takeoff). - For descending jumps (landing_y<0), this is gap_start. - - The starting platform is at y=0 from x=-inf to x=landing_x_start. - The landing platform is at y=landing_y from x=landing_x_start onward. - """ - x, y, vx, vy = 0.0, 0.0, 0.0, 0.0 - on_ground = True - trajectory: list[TickState] = [] - jumped = False - f_ground = DEFAULT_BLOCK_FRICTION * FRICTION_MULTIPLIER - - trajectory.append(TickState(0, x, y, vx, vy, on_ground)) - - for tick in range(1, max_ticks + 1): - # --- Zero tiny velocity --- - if vx * vx < HORIZONTAL_VELOCITY_THRESHOLD_SQR: - vx = 0.0 - if abs(vy) < VERTICAL_VELOCITY_THRESHOLD: - vy = 0.0 - - # --- Jump on the tick after momentum --- - do_jump = False - if not jumped and tick > momentum_ticks and on_ground: - do_jump = True - jumped = True - - if do_jump: - vy = max(BASE_JUMP_POWER, vy) - if sprint: - vx += SPRINT_JUMP_HORIZONTAL_BOOST - - # --- Input acceleration --- - forward_input = 1.0 * INPUT_FRICTION - if on_ground: - speed = get_ground_speed() - else: - speed = AIR_ACCEL - vx += forward_input * speed - - # --- Move --- - new_x = x + vx - new_y = y + vy - new_on_ground = False - - # Ceiling collision - if ceiling_y is not None: - head_y = new_y + PLAYER_HEIGHT - if head_y > ceiling_y: - new_y = ceiling_y - PLAYER_HEIGHT - if vy > 0: - vy = 0.0 - - # Floor collision: two-region terrain model - # Region 1: x < landing_x_start -> floor at y=0 (starting platform) - # Region 2: x >= landing_x_start -> floor at y=landing_y - # Player bounding box trailing edge is at (new_x - HALF_WIDTH) - # Use player center for region determination - if new_x < landing_x_start: - floor_y = 0.0 - else: - floor_y = landing_y - - if jumped: - if new_x >= landing_x_start: - # Over the landing platform region - if landing_y >= 0: - # Ascending or flat: only land when falling DOWN through the surface - if vy <= 0 and y >= landing_y and new_y <= landing_y: - new_y = landing_y - vy = 0.0 - new_on_ground = True - elif vy <= 0 and new_y <= landing_y: - # Already below the surface (fell through on a prior tick - # that didn't trigger -- shouldn't happen but safety check) - new_y = landing_y - vy = 0.0 - new_on_ground = True - else: - # Descending: land when reaching the lower floor - if new_y <= landing_y: - new_y = landing_y - if vy < 0: - vy = 0.0 - new_on_ground = True - - if not new_on_ground and new_x < landing_x_start: - # Still over starting platform area or in the gap - if new_y <= 0.0: - new_y = 0.0 - if vy < 0: - vy = 0.0 - new_on_ground = True - else: - # Momentum phase: always on starting platform - if new_y <= 0.0: - new_y = 0.0 - if vy < 0: - vy = 0.0 - new_on_ground = True - - x = new_x - y = new_y - on_ground = new_on_ground - - # --- Post-move: gravity + friction/drag --- - vy -= GRAVITY - vy *= DRAG_Y - - if on_ground: - vx *= f_ground - else: - vx *= FRICTION_MULTIPLIER - - trajectory.append(TickState(tick, x, y, vx, vy, on_ground)) - - # Stop once landed after being airborne - if jumped and on_ground: - break - - return trajectory - - -def get_landing(sprint: bool, target_y: float, - landing_x_start: float = 0.0, - momentum_ticks: int = 12, - ceiling_y: Optional[float] = None) -> Optional[tuple[float, float]]: - """Get (x, y) where the player lands. Returns None if no landing.""" - traj = simulate_jump(sprint=sprint, momentum_ticks=momentum_ticks, - ceiling_y=ceiling_y, landing_y=target_y, - landing_x_start=landing_x_start) - was_air = False - for s in traj: - if not s.on_ground: - was_air = True - if was_air and s.on_ground: - return s.x, s.y - return None - - -def get_apex(sprint: bool, momentum_ticks: int = 12, - ceiling_y: Optional[float] = None) -> tuple[float, float]: - traj = simulate_jump(sprint=sprint, momentum_ticks=momentum_ticks, - ceiling_y=ceiling_y, landing_y=-1000.0, - landing_x_start=0.0, max_ticks=300) - best_y, best_x = 0.0, 0.0 - for s in traj: - if s.y > best_y: - best_y = s.y - best_x = s.x - return best_y, best_x - - -def can_reach_gap(gap_blocks: int, dy: float, sprint: bool = True, - momentum_ticks: int = 12) -> tuple[bool, Optional[float], float]: - """ - Check if the player can cross a gap of `gap_blocks` blocks to a surface - at height offset `dy`. - - Geometry (player starts centered on block, center at x=0): - - Starting platform right edge: x = 0.5 - - Gap: 0.5 to 0.5 + gap_blocks - - Landing platform left edge: x = 0.5 + gap_blocks - - Player center must reach x >= 0.5 + gap_blocks + HALF_WIDTH to land - (trailing bounding box edge clears the gap) - - For ascending jumps (dy > 0): - - Landing surface at y=dy begins at x = 0.5 + gap_blocks - - The gap region has NO floor (void) if gap > 0, or floor at dy if gap = 0 - - For gap = 0 and dy > 0: - - This means stepping up to an adjacent block 1m higher. - - Player just needs to jump and move forward 1 block. - """ - if dy > 1.252: - return False, None, 0.0 - - needed_x = 0.5 + gap_blocks + HALF_WIDTH - landing_platform_start = 0.5 + gap_blocks - - # For gap=0 ascending, the landing platform is right next to the start - if gap_blocks == 0 and dy > 0: - landing_platform_start = 0.5 - - result = get_landing(sprint=sprint, target_y=dy, - landing_x_start=landing_platform_start, - momentum_ticks=momentum_ticks) - if result is None: - return False, None, needed_x - - lx, ly = result - # Check if we actually landed on the target surface (not back on start) - if abs(ly - dy) > 0.01: - # Landed back on starting platform - return False, lx, needed_x - - # For gap > 0, check player center is past the gap - if gap_blocks > 0 and lx < needed_x: - return False, lx, needed_x - - return True, lx, needed_x +from tools.pathing_theory.primitives import ( + PLAYER_WIDTH, + can_reach_gap, + get_apex, + get_landing, + simulate_jump, +) +from tools.pathing_theory.simulator import build_theory_cases # ============================================================ diff --git a/tools/tests/__init__.py b/tools/tests/__init__.py new file mode 100644 index 00000000..440b7083 --- /dev/null +++ b/tools/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for Python tooling.""" diff --git a/tools/tests/test_pathing_theory_matrix.py b/tools/tests/test_pathing_theory_matrix.py new file mode 100644 index 00000000..f0a29384 --- /dev/null +++ b/tools/tests/test_pathing_theory_matrix.py @@ -0,0 +1,27 @@ +import unittest + +from tools.pathing_theory.simulator import build_theory_cases + + +class PathingTheoryMatrixTests(unittest.TestCase): + def test_build_theory_cases_returns_first_wave_families(self) -> None: + cases = build_theory_cases() + families = {(case.family, case.subfamily) for case in cases} + + self.assertIn(("linear", "flat"), families) + self.assertIn(("linear", "ascend"), families) + self.assertIn(("linear", "descend"), families) + self.assertIn(("neo", "neo"), families) + self.assertIn(("ceiling", "headhitter"), families) + + linear_boundary = next( + case + for case in cases + if case.case_id == "linear-flat-sprint-mm12-gap5-dy0p0" + ) + self.assertTrue(linear_boundary.expected_reachable) + self.assertGreater(linear_boundary.margin, 0.0) + + +if __name__ == "__main__": + unittest.main()