neon-sprawl/client/scripts/player.gd

1523 lines
61 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

extends CharacterBody3D
## NS-23 path-follow; NS-24 idle. NEO-22: WASD locomotion via [method set_locomotion_wish_world_xz].
## Details in `client/README.md` (movement, idle).
##
## Summary: Jolt, no interp; dual floor_max_angle; rim/straddle, bump lip escape.
## Snap upright after motion. Do not set global_transform in _process.
## Walk assist may run a second move_and_slide().
const MOVE_SPEED: float = 5.0
## Moving: floor angle for seams onto stepped QA bumps (~50°).
const FLOOR_MAX_ANGLE_MOVING_DEG: float = 50.0
## Idle: tighter angle to reduce vertex / vertical-face flicker (~35°).
const FLOOR_MAX_ANGLE_IDLE_DEG: float = 35.0
## Physics ticks to keep moving floor angle after walk goal clears (arrival).
## Post-walk window for wider [member floor_max_angle] (~0.4 s @ 120 Hz); shorter tightens idle
## contact sooner and helps the anchor latch feel snappy on flat / mild slopes.
const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 48
const ARRIVE_EPS: float = 0.35
const VERT_ARRIVE_EPS: float = 0.055
const DIRECT_APPROACH_RADIUS: float = 0.85
## Hysteresis for nav-path column steering (see `_physics_process`). At exactly
## [member DIRECT_APPROACH_RADIUS] the old test flipped every frame: path nudges
## tangentially → [code]horiz_dist[/code] crosses the radius → direct vs path
## alternates → velocity sign flips (Jolt ping-pong while [code]has_goal[/code]).
const NAV_COLUMN_STEER_ENTER_DIST: float = 0.74
const NAV_COLUMN_STEER_EXIT_DIST: float = 0.97
## First path waypoint used for column steer must be at least this far in XZ. [method
## NavigationAgent3D.get_next_path_position]
## can sit ~4 cm from the capsule; Jolt slides ~4 cm/tick → the “toward next” vector flips 180°
## every frame
## (`vlat && ncol` NEON-16 traces). A 22 cm gate matches foot-scale probes and stays stable on
## treads.
const NAV_PATH_STEER_MIN_LOOKAHEAD: float = 0.22
## If lookahead steering points more than ~105° from the click direction (XZ), use direct goal steer
## instead.
const NAV_PATH_STEER_MIN_GOAL_DOT: float = -0.25
## When the new lookahead unit is almost opposite the last applied nav steer, slerp slowly — stops
## ±[code]MOVE_SPEED[/code]
## flips while [code]vlat && ncol[/code] (Jolt nudges XZ → different first waypoint past [member
## NAV_PATH_STEER_MIN_LOOKAHEAD]).
const NAV_PATH_STEER_SMOOTH_DOT: float = 0.22
const NAV_PATH_STEER_SMOOTH_SLERP: float = 0.58
const NAV_PATH_STEER_SMOOTH_SLERP_OPPOSING: float = 0.16
## While [code]vlat && ncol[/code], ledge peel + zero floor snap fight [method move_and_slide] on
## approach treads
## (3-cycle NEON-16 traces: same XZ triplet, Y/feet_y ping-pong). Suspend only when
## [code]vsep[/code] is
## **tread-scale** — if this matched tall-pad descents (~0.48 m), column steering stays true while
## [code]vsep[/code]
## shrinks from metres to zero and peel was off for the last ~half metre (orange block slow float).
## Just above **two** QA tread drops (~0.208 m) so double risers still suspend.
const WALK_PEEL_COLUMN_SUSPEND_MAX_VSEP: float = 0.23
## Fallback when [code]vlat[/code] has unlatched (feet and goal both near the same slab Y) but
## [code]ncol[/code] is false because [code]horiz_dist[/code] is past column hysteresis — peel +
## zero
## snap still oscillates on violet treads (NEON-16 ~y=0.92). Slightly below a single tall lip so
## shallow
## lips (~0.350.47 m) still peel vs [member WALK_PEEL_SUSPEND_HORIZ_VERT_SEP].
const WALK_PEEL_SUSPEND_HORIZ_VERT_SEP: float = 0.46
const WALK_PEEL_SUSPEND_HORIZ_DIST: float = 5.0
## Broad [method _walk_peel_suspend_near_goal] (horiz + [member WALK_PEEL_SUSPEND_HORIZ_VERT_SEP])
## is for
## tread-height wobble when XZ is already near the click. On **tall pads** (orange reject block,
## terraces),
## feet can stay tens of cm above the goal while [code]horiz_dist < 5[/code] — suspend then kills
## ledge peel
## for the whole last ~0.46 m of drop → [code]is_on_floor()[/code] + no [code]velocity.y[/code]
## feels like a slow float.
## Only allow that broad branch once the feet are within this of the goal surface **while
## descending**.
const WALK_PEEL_SUSPEND_BROAD_MAX_FEET_ABOVE_GOAL: float = 0.22
## With [method _walk_peel_suspend_near_goal] (tread snap/peel stability), zero XZ [member velocity]
## when
## close in XZ but vertical arrival still unresolved — avoids band-edge flicker from a tight
## [code]vert_sep[/code]
## window. [member WALK_PEEL_SUSPEND_HORIZ_DIST] can be 5 m; cap XZ here so we never freeze long
## crosses.
const WALK_TREAD_FREEZE_HORIZ_MAX: float = 0.38
## Freeze only while vertical error is in a “tread wobble” band (past arrival noise, not a big
## drop).
const WALK_TREAD_FREEZE_VERT_ERR_MAX: float = 0.18
## [method _walk_has_close_floor_probe_below] often flips on stairs: next tread is ~0.104 m down but
## [member WALK_CONTINUATION_MAX_BELOW_FEET] is 0.078 m, so rays reject the hit → peel + zero snap
## alternate with full snap each tick (~y≈1.0 jitter). Require this many consecutive probe-fail
## frames
## before arming peel/snap aggression; [member WALK_DEEP_DESCENT_FEET_ABOVE_GOAL] still arms
## immediately.
const WALK_PEEL_PROBE_FALSE_FRAMES: int = 2
## [member VERT_ARRIVE_EPS] is too tight on column stairs: feet oscillate ~±6 cm vs goal surface
## while XZ is
## already inside [member ARRIVE_EPS] → never [code]has_goal=false[/code], full [member MOVE_SPEED]
## path
## steer flips forever (looks like “idle” jitter; NEON-16 traces).
const WALK_COLUMN_NEAR_ARRIVE_VERT: float = 0.36
## Only apply [member WALK_COLUMN_NEAR_ARRIVE_VERT] when vertical error is already in this band —
## avoids
## treating a **plaza / block** drop (feet still >20 cm above goal) as “column stair wobble” while
## [code]horiz_dist[/code] is inside column steering (NEON-29-style skate + slow float after XZ).
const WALK_COLUMN_NEAR_ARRIVE_GATE_VERT_ERR: float = 0.22
## If [code]vlat && ncol[/code] and net displacement stays below this for [member
## WALK_COLUMN_STUCK_FRAMES],
## clear the walk goal — path lookahead limit cycle with no progress (backup if widened arrival is
## not enough).
const WALK_COLUMN_STUCK_FRAMES: int = 28
const WALK_COLUMN_STUCK_MAX_DRIFT: float = 0.045
const WALK_COLUMN_STUCK_HORIZ_MAX: float = 0.68
## After [method _walk_clip_horizontal_velocity_against_vertical_contacts], Jolt can alternate which
## vertical face “wins” on stair/column seams → post-clip [code]velocity.xz[/code] flips ~180° at
## ~[code]MOVE_SPEED[/code] while [code]horiz_dist[/code] stays large ([code]ncol[/code]
## hysteresis).
## Dot below this vs last ticks post-clip XZ → zero horizontal + brief suppress (NEO-14 traces).
const WALK_COL_SEAM_FLIP_DOT: float = -0.7
## Treat post-clip speed as “full fight” when above this fraction of [member MOVE_SPEED].
const WALK_COL_SEAM_SPEED_FRAC: float = 0.82
## Physics ticks to hold [code]velocity.xz == 0[/code] after a seam flip (odd/even frame
## alternation).
const WALK_COL_SEAM_SUPPRESS_TICKS: int = 3
## Hysteresis for [code]needs_vertical_routing[/code]: [code]feet_y[/code] wobbles with
## [code]move_and_slide[/code] on treads (~7 cm), so a single margin (see [member
## DESCEND_GOAL_Y_MARGIN])
## can flip path vs direct steering every tick → velocity sign ping-pong while
## [code]has_goal[/code].
const WALK_VERT_ROUTE_LATCH_ON_SEP: float = 0.10
## Stay below [member WALK_VERT_ROUTE_LATCH_ON_SEP]. Old 0.038 unlatched while descending toward
## floor
## (vert_sep under ~4 cm) → [code]vlat[/code]/[code]ncol[/code] false → peel suspend never ran.
const WALK_VERT_ROUTE_LATCH_OFF_SEP: float = 0.085
const FLOOR_SNAP_MOVING: float = 0.32
## Idle floor snap length (stronger to pin rim contacts).
const FLOOR_SNAP_IDLE: float = 0.11
## Below this floor-normal dot up, idle tick uses **moving** `floor_max_angle` (rim).
const IDLE_RIM_MIN_FLOOR_UP_DOT: float = 0.968
## Uniform walkable slopes (e.g. physics QA ramp ~18°): stable idle when not tread+riser ridged.
## Below ~cos(28°): treat as too steep for this fast-path (leave corrective / rim logic).
const IDLE_SLOPE_STABLE_MIN_UP_DOT: float = 0.88
## Stable flat idle support: skip corrective idle motion when support is effectively level.
## 0.998 (~3.6°): 0.999 still saw open-floor HUD drift — Jolt [code]get_floor_normal()[/code] often
## reports ~0.99910.9992 on a flat box, so the old 0.999 gate rarely latched the idle anchor and
## [code]move_and_slide[/code] + rim/bump nudges kept nudging XZ.
const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.998
## While [member _idle_stable_latched], allow slightly tilted reported normals (tread/riser edge).
const STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT: float = 0.992
## Rare edge contacts can keep corrective nudges alive forever; budget them, then hold x/z.
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 5
## Consecutive [code]raw_stable[/code] ticks before first idle anchor latch. Two was safe but Jolt
## heightmaps / slopes often insert a one-frame false negative → ~1 s extra jitter before latch.
const STABLE_IDLE_ENTER_STREAK_FRAMES: int = 1
## When not [code]raw_stable[/code] and not yet latched, decay the enter streak instead of zeroing
## (brief floor-normal flicker no longer wipes two good ticks).
const STABLE_IDLE_ENTER_MISS_DECAY: int = 1
## Consecutive unstable-idle physics ticks before leaving latched stable idle (thin tread lip).
const STABLE_IDLE_UNLATCH_TICKS: int = 10
## Horizontal nudge per tick for rim / straddle settle (**`_maybe_idle_rim_settle_nudge`**).
const IDLE_RIM_SETTLE_STEP: float = 0.004
## Used by tests and vertical routing checks (feet vs goal surface).
const DESCEND_GOAL_Y_MARGIN: float = 0.06
## Cap snap while dropping off a pad so Jolt cannot glue the capsule to the upper surface.
const DESCEND_LIP_SNAP_CAP: float = 0.1
## Y lift when blocked by wall-ish slide but nav goal is higher (stepped bumps).
const WALK_STEP_ASSIST_DELTA: float = 0.11
const WALK_STEP_ASSIST_COOLDOWN_TICKS: int = 8
## Floor-snap length while step-climbing. Small enough that it cannot reach the lower floor
## (~0.11 m away after the first lift) but large enough to catch the step surface once the
## capsule clears the face (~0.03 m away after the third lift for a 0.3 m step).
const WALK_STEP_ASSIST_SNAP: float = 0.09
## Do not step-assist when the goal surface is farther above the feet than this (meters).
## Matches [NavigationMesh] `agent_max_climb` in `main.tscn` so repeated lifts cannot bypass a
## two-level climb in one click (e.g. floor → **TerracePlatformB** violet deck at ~0.6 m — use
## **TerraceStepB** gold first, or a second click after standing on the step).
const WALK_STEP_ASSIST_MAX_SURFACE_DELTA: float = 0.35
## Ray below capsule feet while walking: if [method CharacterBody3D.is_on_floor] stays true off a
## ledge (snap + lip contacts), we still need gravity toward the lower floor — without re-applying
## gravity for every “goal below feet” tick on a whole upper deck (see [member
## _apply_walk_air_gravity]).
## Down-ray length under the capsule foot disk while walking (~[member
## NavigationMesh.agent_max_climb]).
const WALK_SUPPORT_PROBE_DEPTH: float = 0.32
const WALK_SUPPORT_PROBE_MIN_UP_DOT: float = 0.42
## Ignore ray hits farther below the feet than this (m). Set just **above** QA tread rise (~0.104 m
## in
## [code]main.tscn[/code]) so the next tread usually counts as continuation — stabilizes debounced
## peel/snap
## on violet stairs. Must stay **below** ~0.25 m so a gold→floor (~0.3 m) void still rejects the
## slab hit
## (no horizontal skate off the deck).
const WALK_CONTINUATION_MAX_BELOW_FEET: float = 0.108
## If capsule feet are farther above the clicks surface Y than this, always arm ledge peel (bypass
## probe streak). Keep this **just above** a single QA tread drop (~0.104 m) so stair treads still
## use
## [method _walk_has_close_floor_probe_below]; use a **small** value so tall drops (block ~2 m,
## terrace
## ~0.6 m) never exit this “deep” band until feet are almost on the goal surface — otherwise the
## last
## ~40 cm reverted to probe-only, lip [code]is_on_floor()[/code] + full snap could **horizontal
## skate**
## with almost no peel until XZ arrival.
const WALK_DEEP_DESCENT_FEET_ABOVE_GOAL: float = 0.12
## [method get_gravity] can be ~0 with missing/odd project defaults; ledge peel + airborne walk
## still need a pull.
const WALK_FALLBACK_GRAVITY_Y: float = -9.81
## Multiplier on project gravity for walk ledge peel, walk-airborne, and idle-airborne (arcade
## descent).
const PLAYER_VERTICAL_ACCEL_MULTIPLIER: float = 9.0
## Scale [method _apply_walk_post_slide_ledge_peel] integration when feet are still far above a
## **lower**
## goal (block / terrace lip). Keeps peel sub-linear time from feeling like a hover; tread-sized
## drops stay ~1×.
const WALK_LEDGE_PEEL_BOOST_SEP_1: float = 0.28
const WALK_LEDGE_PEEL_BOOST_SEP_2: float = 0.75
const WALK_LEDGE_PEEL_BOOST_SEP_3: float = 1.35
const WALK_LEDGE_PEEL_BOOST_MUL_1: float = 1.65
const WALK_LEDGE_PEEL_BOOST_MUL_2: float = 2.75
const WALK_LEDGE_PEEL_BOOST_MUL_3: float = 4.25
## CapsuleShape3D in scene: height = 1.0 (cylinder portion), radius = 0.4.
## Total half-height from body origin to physical bottom = 0.5 + 0.4 = 0.9
## (`CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS`).
## `CAPSULE_HALF_HEIGHT` alone (0.5) is the cylinder half; used only where that
## distinction matters (e.g. the descend-bypass feet estimate).
const CAPSULE_HALF_HEIGHT: float = 0.5
const BUMP_COLLISION_CONSTS_SCRIPT: Script = preload(
"res://scripts/random_floor_bump_collision_constants.gd"
)
## Idle radial push per tick off bump lip / wall (meters; 120 Hz physics).
const IDLE_BUMP_ESCAPE_STEP: float = 0.014
## Player capsule radius; wall touch can happen past axis distance **`col_r`**.
const PLAYER_CAPSULE_RADIUS: float = 0.4
const DEBUG_IDLE_TRACE_DELTA_EPS: float = 0.0001
const DEBUG_IDLE_TRACE_HEARTBEAT_FRAMES: int = 60
const DEBUG_TRANSFORM_TRACE_DELTA_EPS: float = 0.000001
@export var debug_idle_trace: bool = false
var _has_walk_goal: bool = false
var _auth_walk_goal: Vector3 = Vector3.ZERO
var _floor_angle_loose_ticks: int = 0
var _step_assist_cooldown: int = 0
## True while the player is actively climbing a step; suppresses the normal large floor
## snap so it cannot pull the capsule back to the lower surface between assist cycles.
var _step_assist_active: bool = false
var _idle_anchor_active: bool = false
var _idle_anchor_xz: Vector2 = Vector2.ZERO
var _idle_anchor_y: float = 0.0
## Latched stable idle: stay on zero-vel + anchor through brief Jolt false negatives on treads.
var _idle_stable_latched: bool = false
var _idle_stable_unlatch_streak: int = 0
var _idle_stable_enter_streak: int = 0
var _idle_manual_correction_ticks: int = 0
var _debug_last_idle_xz: Vector2 = Vector2.INF
var _debug_idle_heartbeat: int = 0
var _debug_last_transform_xz: Vector2 = Vector2.INF
var _debug_trace_frame: int = 0
## True while we use [method _set_horizontal_velocity_from_nav_path_or_goal] for vertical routing.
var _walk_nav_column_steering: bool = false
## Last horizontal nav-path steer unit (XZ); eases 180° waypoint switches (see [member
## NAV_PATH_STEER_SMOOTH_DOT]).
var _walk_nav_path_steer_hz: Vector3 = Vector3.ZERO
## Schmitt latch for vertical routing (see [member WALK_VERT_ROUTE_LATCH_ON_SEP]).
var _walk_vert_route_latched: bool = false
## GROUNDED [method move_and_slide] clears downward [code]velocity.y[/code] on floor; accumulate
## peel
## speed here and apply [code]global_position.y[/code] so terrace lips can actually drop.
var _walk_ledge_peel_vy: float = 0.0
## Pre-[method move_and_slide] debounce for probe flicker (see [member
## WALK_PEEL_PROBE_FALSE_FRAMES]).
var _walk_peel_no_probe_streak: int = 0
## True after streak / deep-descent check; drives zero snap and [method
## _apply_walk_post_slide_ledge_peel].
var _walk_debounced_wants_ledge_peel: bool = false
var _walk_column_stuck_frame: int = 0
var _walk_column_stuck_origin: Vector3 = Vector3.ZERO
## Post-clip XZ from last physics tick; used with [member WALK_COL_SEAM_FLIP_DOT] while [code]vlat
## && ncol[/code].
var _walk_col_seam_prev_hz: Vector2 = Vector2.ZERO
var _walk_col_seam_suppress_ticks: int = 0
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
## Normalized world XZ direction from WASD (Y ignored). Zero when idle.
var _locomotion_wish_world_xz: Vector3 = Vector3.ZERO
func _ready() -> void:
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
_nav_agent.avoidance_enabled = false
_nav_agent.set_target_position(global_position)
floor_block_on_wall = true
if debug_idle_trace:
set_notify_transform(true)
func set_authoritative_nav_goal(world_pos: Vector3) -> void:
_auth_walk_goal = world_pos
_has_walk_goal = true
_floor_angle_loose_ticks = 0
_step_assist_active = false
_idle_anchor_active = false
_idle_manual_correction_ticks = 0
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_walk_nav_path_steer_hz = Vector3.ZERO
_walk_ledge_peel_vy = 0.0
_walk_peel_no_probe_streak = 0
_walk_debounced_wants_ledge_peel = false
_walk_column_stuck_frame = 0
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(world_pos)
func clear_nav_goal() -> void:
velocity = Vector3.ZERO
_walk_ledge_peel_vy = 0.0
_has_walk_goal = false
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_step_assist_active = false
_idle_anchor_active = false
_idle_manual_correction_ticks = 0
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_walk_nav_path_steer_hz = Vector3.ZERO
_walk_peel_no_probe_streak = 0
_walk_debounced_wants_ledge_peel = false
_walk_column_stuck_frame = 0
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(global_position)
## Repath on a new nav map (e.g. after [NavigationRegion3D] rebake when geometry is removed).
## Rebaking can leave [NavigationAgent3D] on a stale map RID; rebind to the world's map first.
func sync_navigation_agent_after_map_rebuild(nav_region: NavigationRegion3D) -> void:
var map_rid: RID = RID()
var w3d := get_world_3d()
if w3d != null:
map_rid = w3d.navigation_map
if map_rid == RID() and is_instance_valid(nav_region):
map_rid = NavigationServer3D.region_get_map(nav_region.get_region_rid())
if map_rid != RID():
_nav_agent.set_navigation_map(map_rid)
if _has_walk_goal:
_nav_agent.set_target_position(_auth_walk_goal)
else:
_nav_agent.set_target_position(global_position)
func set_locomotion_wish_world_xz(dir: Vector3) -> void:
var h := Vector3(dir.x, 0.0, dir.z)
if h.length_squared() < 1e-10:
_locomotion_wish_world_xz = Vector3.ZERO
else:
_locomotion_wish_world_xz = h.normalized()
func snap_to_server(world_pos: Vector3) -> void:
# The server stores the surface Y of the last move target, not the capsule body-centre Y.
# Clamping to the minimum standing height (total half = 0.9 m) prevents the player from
# being placed underground (which, combined with a stale is_on_floor() returning true,
# would skip all corrective move_and_slide() calls and leave the capsule stuck).
var total_half: float = CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
var settled := Vector3(world_pos.x, maxf(world_pos.y, total_half), world_pos.z)
global_position = settled
velocity = Vector3.ZERO
_has_walk_goal = false
_walk_ledge_peel_vy = 0.0
_walk_peel_no_probe_streak = 0
_walk_debounced_wants_ledge_peel = false
# Force corrective idle ticks so Jolt resolves any residual physics state before walking.
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_step_assist_cooldown = 0
_step_assist_active = false
_idle_anchor_active = false
_idle_manual_correction_ticks = 0
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_walk_nav_path_steer_hz = Vector3.ZERO
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(settled)
reset_physics_interpolation()
_locomotion_wish_world_xz = Vector3.ZERO
func _physics_process_locomotion_wasd(delta: float) -> void:
if _has_walk_goal:
clear_nav_goal()
floor_block_on_wall = true
var feet_y: float = capsule_feet_y(
global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
)
velocity.x = _locomotion_wish_world_xz.x * MOVE_SPEED
velocity.z = _locomotion_wish_world_xz.z * MOVE_SPEED
if is_on_floor():
velocity.y = 0.0
else:
_apply_walk_air_gravity(delta, feet_y, Vector2.ZERO)
_walk_clip_horizontal_velocity_against_vertical_contacts(_locomotion_wish_world_xz)
floor_snap_length = FLOOR_SNAP_MOVING
move_and_slide()
_snap_capsule_upright()
_debug_trace_transform("physics")
## Returns true when the capsule has something to stand on **or** is pressing against a step face
## in the direction of travel. `want_dir_xz` is the normalised horizontal goal direction.
## Using `is_on_wall()` as a blanket short-circuit was too broad: any floor-bump side-contact
## satisfied it, causing the assist to fire prematurely and then be unable to snap back.
func _step_assist_has_support(want_dir_xz: Vector3) -> bool:
if is_on_floor():
return true
var w2 := Vector2(want_dir_xz.x, want_dir_xz.z)
var have_dir: bool = w2.length_squared() > 1e-8
if have_dir:
w2 = w2.normalized()
for i: int in get_slide_collision_count():
var n: Vector3 = get_slide_collision(i).get_normal()
if n.y > 0.35:
return true # upward-facing = floor support
# Wall-ish contact opposing forward direction = step face immediately ahead.
if have_dir and abs(n.y) < 0.52:
var n2 := Vector2(n.x, n.z)
if n2.length_squared() > 1e-8 and n2.normalized().dot(w2) < -0.2:
return true
return false
## True when a vertical-ish contact opposes motion toward the goal. Jolt often reports a wall
## via `is_on_wall()` + `get_wall_normal()` at box lips while slide normals are ambiguous; the
## old slide-only loop missed head-on climbs onto e.g. TerracePlatformA (teal pad).
func _step_assist_wallish_blocks(want_dir_xz: Vector3) -> bool:
var w2 := Vector2(want_dir_xz.x, want_dir_xz.z)
if w2.length_squared() < 1e-8:
return false
w2 = w2.normalized()
if is_on_wall():
var wn: Vector3 = get_wall_normal()
var wnh := Vector2(wn.x, wn.z)
if wnh.length_squared() > 1e-10:
wnh = wnh.normalized()
# Wall pushes back against forward (not parallel skimming along the face).
if wnh.dot(w2) < -0.02:
return true
for i: int in get_slide_collision_count():
var n: Vector3 = get_slide_collision(i).get_normal()
if n.y > 0.52:
continue
var n2 := Vector2(n.x, n.z)
if n2.length_squared() < 1e-8:
continue
n2 = n2.normalized()
if n2.dot(w2) < -0.02:
return true
return false
## Remove horizontal velocity into **vertical** contacts using last tick's slide normals (and
## [method get_wall_normal] when [method is_on_wall]). Capsule lips / floor seams often never set
## [method is_on_wall] while [method move_and_slide] still fights full [code]MOVE_SPEED[/code] into
## the face → jitter. Same plane test as [method _step_assist_wallish_blocks]: [code]dot <
## -0.02[/code].
func _walk_clip_horizontal_velocity_against_vertical_contacts(want_goal_h: Vector3) -> void:
var v2 := Vector2(velocity.x, velocity.z)
for _iter: int in range(4):
var v_before: Vector2 = v2
for i: int in get_slide_collision_count():
var n: Vector3 = get_slide_collision(i).get_normal()
if n.y > 0.52:
continue
var n2 := Vector2(n.x, n.z)
if n2.length_squared() < 1e-10:
continue
n2 = n2.normalized()
var dot_v: float = v2.dot(n2)
if dot_v < -0.02:
v2 -= n2 * dot_v
if is_on_wall():
var wn: Vector3 = get_wall_normal()
if wn.y <= 0.35:
var wnh := Vector2(wn.x, wn.z)
if wnh.length_squared() > 1e-10:
wnh = wnh.normalized()
var dot_w: float = v2.dot(wnh)
if dot_w < -0.02:
v2 -= wnh * dot_w
if v2.distance_squared_to(v_before) < 1e-12:
break
# Goal through a vertical blocker but only micro tangential slip survives → kill floaty XZ.
var want3 := Vector3(want_goal_h.x, 0.0, want_goal_h.z)
if want3.length_squared() > 1e-10 and _step_assist_wallish_blocks(want3.normalized()):
if v2.length_squared() < 0.0025:
v2 = Vector2.ZERO
velocity.x = v2.x
velocity.z = v2.y
## While [code]_walk_vert_route_latched && _walk_nav_column_steering[/code], seam contacts can make
## [method _walk_clip_horizontal_velocity_against_vertical_contacts] output ~opposite
## [code]velocity.xz[/code]
## on consecutive ticks at nearly [member MOVE_SPEED] (slide normal order / riser vs tread).
func _walk_damp_vert_route_column_seam_flip() -> void:
if not (_walk_vert_route_latched and _walk_nav_column_steering):
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
return
if _walk_col_seam_suppress_ticks > 0:
_walk_col_seam_suppress_ticks -= 1
velocity.x = 0.0
velocity.z = 0.0
_walk_col_seam_prev_hz = Vector2.ZERO
return
var hz := Vector2(velocity.x, velocity.z)
var len2: float = hz.length_squared()
var thr: float = MOVE_SPEED * WALK_COL_SEAM_SPEED_FRAC
var thr2: float = thr * thr
if len2 < thr2 * 0.2:
if len2 > 1e-8:
_walk_col_seam_prev_hz = hz
else:
_walk_col_seam_prev_hz = Vector2.ZERO
return
var pl2: float = _walk_col_seam_prev_hz.length_squared()
if pl2 < thr2 * 0.16:
_walk_col_seam_prev_hz = hz
return
if hz.normalized().dot(_walk_col_seam_prev_hz.normalized()) < WALK_COL_SEAM_FLIP_DOT:
velocity.x = 0.0
velocity.z = 0.0
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = WALK_COL_SEAM_SUPPRESS_TICKS
else:
_walk_col_seam_prev_hz = hz
func _step_assist_can_raise_by(dy: float) -> bool:
if dy <= 0.0001:
return false
return not test_move(global_transform, Vector3(0.0, dy, 0.0), null, safe_margin)
func _try_walk_step_assist() -> bool:
if not _has_walk_goal:
return false
# Use actual capsule bottom (cylindrical half-height + hemisphere radius) so the guard
# triggers whenever the goal surface is meaningfully above the player's feet — not the
# capsule centre, which sits ~0.9 m above the floor.
var actual_feet_y: float = global_position.y - CAPSULE_HALF_HEIGHT - PLAYER_CAPSULE_RADIUS
if (
_auth_walk_goal.y < actual_feet_y + 0.025
or _auth_walk_goal.y > actual_feet_y + WALK_STEP_ASSIST_MAX_SURFACE_DELTA
):
return false
var pos: Vector3 = global_position
var to_h: Vector3 = Vector3(_auth_walk_goal.x - pos.x, 0.0, _auth_walk_goal.z - pos.z)
if to_h.length_squared() < 0.03:
return false
var want: Vector3 = to_h.normalized()
if not (_step_assist_wallish_blocks(want) and _step_assist_has_support(want)):
return false
var target_center_y: float = _auth_walk_goal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
var lift: float = clampf(target_center_y - global_position.y, 0.0, WALK_STEP_ASSIST_DELTA)
if lift <= 0.002 or not _step_assist_can_raise_by(lift):
return false
global_position.y += lift
return true
func _after_walk_move_and_slide() -> void:
if _step_assist_cooldown > 0:
_step_assist_cooldown -= 1
if _step_assist_cooldown > 0:
return
if _try_walk_step_assist():
_step_assist_active = true
_step_assist_cooldown = WALK_STEP_ASSIST_COOLDOWN_TICKS
# Reduced snap so the lower floor (0.11 m below lifted capsule) is out of range.
floor_snap_length = WALK_STEP_ASSIST_SNAP
move_and_slide()
func _clear_step_assist_after_walk_move() -> void:
if not _step_assist_active:
return
if is_on_floor() and not is_on_wall():
_step_assist_active = false
elif not is_on_floor() and get_slide_collision_count() == 0:
_step_assist_active = false
func _set_horizontal_velocity_toward(
point: Vector3, fallback_dir_xz: Vector3 = Vector3.ZERO
) -> void:
var pos := global_position
var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z)
if dh.length_squared() < 1e-10:
var fb2 := Vector2(fallback_dir_xz.x, fallback_dir_xz.z)
if fb2.length_squared() > 1e-10:
fb2 = fb2.normalized()
dh = Vector3(fb2.x, 0.0, fb2.y)
else:
dh = Vector3(1.0, 0.0, 0.0)
else:
dh = dh.normalized()
# Same tick as [method _apply_walk_air_gravity]: zeroing [code]velocity.y[/code] every frame while
# airborne capped fall to one gravity delta per tick (~constant sink rate off tall pads).
var vy_air: float = velocity.y
velocity = dh * MOVE_SPEED
velocity.y = 0.0 if is_on_floor() else vy_air
func _walk_probe_xz_offsets(move_dir_xz: Vector2) -> Array[Vector2]:
var offs: Array[Vector2] = [Vector2.ZERO]
var len2: float = move_dir_xz.length_squared()
var r: float = PLAYER_CAPSULE_RADIUS
if len2 > 1e-8:
var inv_len: float = 1.0 / sqrt(len2)
var f := Vector2(move_dir_xz.x * inv_len, move_dir_xz.y * inv_len)
var perp := Vector2(-f.y, f.x)
offs.append(f * r * 0.58)
offs.append(f * r * 0.30 + perp * r * 0.42)
offs.append(f * r * 0.30 - perp * r * 0.42)
else:
offs.append(Vector2(1.0, 0.0) * r * 0.52)
offs.append(Vector2(-1.0, 0.0) * r * 0.52)
offs.append(Vector2(0.0, 1.0) * r * 0.52)
offs.append(Vector2(0.0, -1.0) * r * 0.52)
return offs
func _walk_ray_hit_up_floor(space: PhysicsDirectSpaceState3D, off_xz: Vector2) -> bool:
var feet: Vector3 = (
global_position - Vector3(0.0, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS, 0.0)
)
var probe_from := Vector3(feet.x + off_xz.x, feet.y + 0.04, feet.z + off_xz.y)
var probe_end := Vector3(
feet.x + off_xz.x, feet.y - WALK_SUPPORT_PROBE_DEPTH, feet.z + off_xz.y
)
var q := PhysicsRayQueryParameters3D.create(probe_from, probe_end)
q.collision_mask = collision_mask
q.exclude = [get_rid()]
var res: Dictionary = space.intersect_ray(q)
if res.is_empty():
return false
if not res.has("normal") or not res.has("position"):
return false
var hit_y: float = (res["position"] as Vector3).y
if hit_y < feet.y - WALK_CONTINUATION_MAX_BELOW_FEET:
return false
var n: Vector3 = res["normal"]
return n.dot(Vector3.UP) > WALK_SUPPORT_PROBE_MIN_UP_DOT
## Horizontal bearing for walk ledge / continuation rays. Must not stay ~zero while descending: a
## floor click **under** the capsule ([code]want_goal_h ≈ 0[/code]) used to leave
## [code]walk_move_dir_xz[/code]
## empty so [method _walk_has_close_floor_probe_below] took the center-only fast path and never
## applied ledge gravity until XZ arrival.
func _resolve_walk_probe_dir_xz(feet_y: float, want_goal_h: Vector3) -> Vector2:
var pos := global_position
var to_g := Vector2(_auth_walk_goal.x - pos.x, _auth_walk_goal.z - pos.z)
var vxz := Vector2(velocity.x, velocity.z)
var wh := Vector2(want_goal_h.x, want_goal_h.z)
var descending: bool = _has_walk_goal and _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN
var out: Vector2
if descending:
if to_g.length_squared() >= 1e-6:
out = to_g
elif wh.length_squared() >= 1e-8:
out = wh
else:
var picked_from_path: bool = false
var path: PackedVector3Array = _nav_agent.get_current_navigation_path()
var min_l2: float = NAV_PATH_STEER_MIN_LOOKAHEAD * NAV_PATH_STEER_MIN_LOOKAHEAD
for i: int in range(path.size()):
var d := Vector2(path[i].x - pos.x, path[i].z - pos.z)
if d.length_squared() >= min_l2:
out = d
picked_from_path = true
break
if not picked_from_path:
if to_g.length_squared() > 1e-12:
out = to_g
elif vxz.length_squared() >= 1e-8:
out = vxz
else:
out = Vector2(1.0, 0.0)
else:
if vxz.length_squared() < 1e-10:
out = wh
else:
out = vxz
return out
## Walkable support for **continued** forward motion within [member WALK_SUPPORT_PROBE_DEPTH].
## Requires a hit under the foot origin **and** at least one sample **ahead** in [param move_dir_xz]
## (same construction as [method _walk_probe_xz_offsets]). If only the center hits while the leading
## foot is over a void, [method CharacterBody3D.is_on_floor] can stay true from a lip — returning
## true here would suppress gravity and keep moving snap until XZ arrival (ledge skate).
## When [param move_dir_xz] is ~zero (idle / no horizontal intent), center-only is enough.
func _walk_has_close_floor_probe_below(move_dir_xz: Vector2) -> bool:
var w3d := get_world_3d()
if w3d == null:
return true
var space: PhysicsDirectSpaceState3D = w3d.direct_space_state
if not _walk_ray_hit_up_floor(space, Vector2.ZERO):
return false
var len2: float = move_dir_xz.length_squared()
if len2 < 1e-10:
return true
var inv_len: float = 1.0 / sqrt(len2)
var md := Vector2(move_dir_xz.x * inv_len, move_dir_xz.y * inv_len)
for off2: Vector2 in _walk_probe_xz_offsets(move_dir_xz):
if off2.length_squared() < 1e-12:
continue
if off2.dot(md) > 0.0:
if _walk_ray_hit_up_floor(space, off2):
return true
return false
## Airborne walk ticks only. Do **not** add gravity just because the nav goal is below the feet —
## that stays true for the whole pad cross and poisons horizontal motion.
## Do apply gravity when on floor but no walkable surface lies under the foot disk (ledge void).
## With GROUNDED [method move_and_slide], [code]velocity.y[/code] clears on floor; terrace lips use
## [method _apply_walk_post_slide_ledge_peel] instead of gravity-on-velocity alone.
func _apply_walk_air_gravity(
delta: float, _feet_y: float, _move_dir_xz: Vector2 = Vector2.ZERO
) -> void:
if not is_on_floor():
velocity += _player_vertical_accel(delta) * delta
func _walk_try_release_column_stuck_orbit(horiz_dist: float) -> bool:
if not _has_walk_goal or not is_on_floor():
_walk_column_stuck_frame = 0
return false
if not (_walk_vert_route_latched and _walk_nav_column_steering):
_walk_column_stuck_frame = 0
return false
if horiz_dist > WALK_COLUMN_STUCK_HORIZ_MAX:
_walk_column_stuck_frame = 0
return false
if _walk_column_stuck_frame == 0:
_walk_column_stuck_origin = global_position
_walk_column_stuck_frame += 1
if _walk_column_stuck_frame < WALK_COLUMN_STUCK_FRAMES:
return false
var drift: float = global_position.distance_to(_walk_column_stuck_origin)
_walk_column_stuck_frame = 0
if drift > WALK_COLUMN_STUCK_MAX_DRIFT:
return false
clear_nav_goal()
return true
func _walk_peel_suspend_near_goal(feet_y: float, horiz_dist: float) -> bool:
var vsep: float = absf(_auth_walk_goal.y - feet_y)
if (
_walk_vert_route_latched
and _walk_nav_column_steering
and vsep < WALK_PEEL_COLUMN_SUSPEND_MAX_VSEP
):
return true
var descending: bool = _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN
if descending and feet_y > _auth_walk_goal.y + WALK_PEEL_SUSPEND_BROAD_MAX_FEET_ABOVE_GOAL:
return false
return vsep <= WALK_PEEL_SUSPEND_HORIZ_VERT_SEP and horiz_dist <= WALK_PEEL_SUSPEND_HORIZ_DIST
func _walk_tread_freeze_horizontal_active(_feet_y: float, horiz_dist: float) -> bool:
if not is_on_floor() or not _has_walk_goal:
return false
if _auth_walk_goal.y > _feet_y + 0.04:
return false
if not _walk_peel_suspend_near_goal(_feet_y, horiz_dist):
return false
if horiz_dist > WALK_TREAD_FREEZE_HORIZ_MAX:
return false
var cap_half: float = CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
var verr: float = vertical_arrival_error(_auth_walk_goal.y, global_position.y, cap_half)
return verr > VERT_ARRIVE_EPS and verr <= WALK_TREAD_FREEZE_VERT_ERR_MAX
func _walk_refresh_ledge_peel_debounce(feet_y: float, move_dir_xz: Vector2) -> void:
var probe_ok: bool = _walk_has_close_floor_probe_below(move_dir_xz)
var deep: bool = feet_y > _auth_walk_goal.y + WALK_DEEP_DESCENT_FEET_ABOVE_GOAL
if deep:
_walk_peel_no_probe_streak = WALK_PEEL_PROBE_FALSE_FRAMES
elif not probe_ok:
_walk_peel_no_probe_streak += 1
else:
_walk_peel_no_probe_streak = 0
_walk_debounced_wants_ledge_peel = (
deep or _walk_peel_no_probe_streak >= WALK_PEEL_PROBE_FALSE_FRAMES
)
## After [method move_and_slide], peel the capsule down when the authoritative goal is below the
## feet
## but the engine still reports floor contact (GROUNDED mode ate negative [code]velocity.y[/code]).
## Uses a small accumulated peel speed so displacement matches [code]∫ g dt[/code], not [code]g
## Δt[/code] (wrong units).
func _apply_walk_post_slide_ledge_peel(
delta: float, feet_y: float, _move_dir_xz: Vector2, horiz_dist: float
) -> void:
if not _has_walk_goal:
_walk_ledge_peel_vy = 0.0
return
if not is_on_floor():
_walk_ledge_peel_vy = 0.0
return
# Do not use [member DESCEND_GOAL_Y_MARGIN] here: peel stopped while feet were still ~6 cm above
# the goal surface (slow float to rest at XZ). Stop only once vertical arrival matches.
var cap_half: float = CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
if vertical_arrival_error(_auth_walk_goal.y, global_position.y, cap_half) <= VERT_ARRIVE_EPS:
_walk_ledge_peel_vy = 0.0
return
if _auth_walk_goal.y > feet_y + 0.04:
_walk_ledge_peel_vy = 0.0
return
if _walk_peel_suspend_near_goal(feet_y, horiz_dist):
_walk_ledge_peel_vy = 0.0
return
if not _walk_debounced_wants_ledge_peel:
_walk_ledge_peel_vy = 0.0
return
# Debounce was computed pre-slide; do not re-probe here (post-slide rays re-flicker on treads).
var sep_below_feet: float = feet_y - _auth_walk_goal.y
var peel_boost: float = 1.0
if sep_below_feet > WALK_LEDGE_PEEL_BOOST_SEP_3:
peel_boost = WALK_LEDGE_PEEL_BOOST_MUL_3
elif sep_below_feet > WALK_LEDGE_PEEL_BOOST_SEP_2:
peel_boost = WALK_LEDGE_PEEL_BOOST_MUL_2
elif sep_below_feet > WALK_LEDGE_PEEL_BOOST_SEP_1:
peel_boost = WALK_LEDGE_PEEL_BOOST_MUL_1
_walk_ledge_peel_vy += _player_vertical_accel(delta).y * delta * peel_boost
var dy: float = _walk_ledge_peel_vy * delta
if absf(dy) > 1e-9:
# Raw [code]global_position[/code] edits may be overwritten vs Jolt after [method move_and_slide].
move_and_collide(Vector3(0.0, dy, 0.0), false, safe_margin, false)
func _walk_floor_snap_length(
feet_y: float,
want_goal_h: Vector3,
_move_dir_xz: Vector2 = Vector2.ZERO,
horiz_dist: float = INF,
) -> float:
if _step_assist_active:
return WALK_STEP_ASSIST_SNAP
if _has_walk_goal:
# Strong moving snap + lip [code]is_on_floor()[/code] otherwise cancels gravity every tick.
if (
is_on_floor()
and not _walk_peel_suspend_near_goal(feet_y, horiz_dist)
and _walk_debounced_wants_ledge_peel
):
return 0.0
if want_goal_h.length_squared() > 1e-10:
var want: Vector3 = want_goal_h.normalized()
# Climb: cap snap only for a **meaningful** rise (not shallow tread-to-tread), or we
# fight the approach stairs with snap ↔ assist oscillation.
if _auth_walk_goal.y > feet_y + 0.12 and _step_assist_wallish_blocks(want):
return WALK_STEP_ASSIST_SNAP
# Lip cap only while debounced peel is armed. Otherwise [code]feet_y[/code] wobble on treads
# crosses [code]feet_y > goal + 0.08[/code] / margins → 0.32 m vs 0.1 m snap flips each tick
# (~23 cm body Y jitter on green stairs, x≈19).
var descend_lip_geom: bool = (
_auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN and feet_y > _auth_walk_goal.y + 0.08
)
if (
descend_lip_geom
and _walk_debounced_wants_ledge_peel
and not _walk_peel_suspend_near_goal(feet_y, horiz_dist)
):
return minf(FLOOR_SNAP_MOVING, DESCEND_LIP_SNAP_CAP)
return FLOOR_SNAP_MOVING
## Descend only: relax `floor_block_on_wall` when pressed against a vertical face so Jolt can
## slide off terrace lips. Never when the goal is above the feet (climb) — avoids platform-wide
## toggling that broke horizontal motion.
func _apply_floor_block_for_descend_at_wall(feet_y: float) -> void:
floor_block_on_wall = true
if not _has_walk_goal:
return
if _auth_walk_goal.y >= feet_y - DESCEND_GOAL_Y_MARGIN:
return
if is_on_wall():
floor_block_on_wall = false
return
# Terrace lip: floor normal tilts off UP when straddling an internal edge (teal pad, etc.).
if is_on_floor() and get_floor_normal().dot(Vector3.UP) < 0.992:
floor_block_on_wall = false
return
for i: int in range(get_slide_collision_count()):
var n: Vector3 = get_slide_collision(i).get_normal()
if absf(n.y) < 0.58:
floor_block_on_wall = false
return
## When the authoritative goal shares XZ with the capsule (e.g. terrace above), horizontal
## `full_to_goal` is zero and steering must follow the baked path toward a ramp/step, not +X.
func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -> void:
# `get_next_path_position()` alone still ping-pongs: the engines “next” point can sit within
# Jolts per-tick slide distance, so the horizontal seek vector flips 180° each tick.
# Scan the baked path for the first XZ waypoint at least [member NAV_PATH_STEER_MIN_LOOKAHEAD]
# away (unlike the old 5 cm scan). Reject segments that point opposite the click (XZ).
if _nav_agent.is_navigation_finished():
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
return
var pos := global_position
var min_l2: float = NAV_PATH_STEER_MIN_LOOKAHEAD * NAV_PATH_STEER_MIN_LOOKAHEAD
var steer_point: Vector3 = _auth_walk_goal
var path: PackedVector3Array = _nav_agent.get_current_navigation_path()
if path.size() > 0:
var chosen: bool = false
for i: int in range(path.size()):
var wp: Vector3 = path[i]
var dx: float = wp.x - pos.x
var dz: float = wp.z - pos.z
if dx * dx + dz * dz >= min_l2:
steer_point = wp
chosen = true
break
if not chosen:
steer_point = path[path.size() - 1]
var dh: Vector3 = Vector3(steer_point.x - pos.x, 0.0, steer_point.z - pos.z)
if dh.length_squared() < 1e-10:
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
return
var dir: Vector3 = dh.normalized()
var gh: Vector3 = Vector3(fallback_goal_xz.x, 0.0, fallback_goal_xz.z)
if gh.length_squared() > 1e-10:
gh = gh.normalized()
if dir.dot(gh) < NAV_PATH_STEER_MIN_GOAL_DOT:
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
return
var dir_hz := Vector3(dir.x, 0.0, dir.z)
if dir_hz.length_squared() < 1e-10:
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
return
dir_hz = dir_hz.normalized()
if _walk_nav_path_steer_hz.length_squared() > 1e-10:
var prev := Vector3(_walk_nav_path_steer_hz.x, 0.0, _walk_nav_path_steer_hz.z).normalized()
var dotp: float = dir_hz.dot(prev)
var t: float = (
NAV_PATH_STEER_SMOOTH_SLERP_OPPOSING
if dotp < NAV_PATH_STEER_SMOOTH_DOT
else NAV_PATH_STEER_SMOOTH_SLERP
)
dir_hz = prev.slerp(dir_hz, t).normalized()
_walk_nav_path_steer_hz = dir_hz
var vy_air: float = velocity.y
velocity = dir_hz * MOVE_SPEED
velocity.y = 0.0 if is_on_floor() else vy_air
func _effective_gravity(_delta: float) -> Vector3:
var g: Vector3 = get_gravity()
if g.length_squared() < 0.25:
return Vector3(0.0, WALK_FALLBACK_GRAVITY_Y, 0.0)
return g
func _player_vertical_accel(_delta: float) -> Vector3:
return _effective_gravity(_delta) * PLAYER_VERTICAL_ACCEL_MULTIPLIER
static func capsule_feet_y(body_origin_y: float, capsule_half_height: float) -> float:
return body_origin_y - capsule_half_height
static func vertical_arrival_error(
goal_y: float, body_origin_y: float, capsule_half_height: float
) -> float:
return absf(goal_y - capsule_feet_y(body_origin_y, capsule_half_height))
func _physics_idle_tick(delta: float) -> void:
if is_on_floor():
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE
else:
# Ledge / post-arrival hover: without gravity, vy stays 0 and FLOOR_SNAP_IDLE (0.11 m)
# cannot reach the lower floor — the capsule idles in mid-air until a new walk goal runs.
velocity.x = 0.0
velocity.z = 0.0
velocity += _player_vertical_accel(delta) * delta
floor_snap_length = FLOOR_SNAP_MOVING
move_and_slide()
static func idle_slide_contacts_are_ridged(slide_normals: Array[Vector3]) -> bool:
var upish := 0
var wallish := 0
for n: Vector3 in slide_normals:
var ny: float = n.y
if ny > 0.65:
upish += 1
# Steeper than ~70° from horizontal — shallow seam faces (ny≈0.4) are not “lip” walls.
elif ny < 0.35:
wallish += 1
return upish >= 1 and wallish >= 1
## [method idle_slide_contacts_are_ridged] is true on heightmap facet seams (up + shallow “wall”
## micro-normals) while the floor normals up component still sits in the smooth single-slope band.
## Treat
## that like a uniform ramp: do not run idle rim nudges / stair-lip stability against it (QA hill).
## Same for **nearly level** aggregate normals (hill crest / corner): facet pairs are not
## tread+riser.
static func idle_ridged_stair_lip_only(slide_normals: Array[Vector3], floor_up_dot: float) -> bool:
if not idle_slide_contacts_are_ridged(slide_normals):
return false
if floor_up_dot >= STABLE_IDLE_FLOOR_MIN_UP_DOT:
return false
if floor_up_dot >= IDLE_SLOPE_STABLE_MIN_UP_DOT and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT:
return false
return true
static func idle_support_is_stable(
on_floor: bool,
floor_normal: Vector3,
slide_normals: Array[Vector3],
loose_ticks: int,
min_flat_up_dot: float = STABLE_IDLE_FLOOR_MIN_UP_DOT,
) -> bool:
if not on_floor:
return false
var up_dot: float = floor_normal.dot(Vector3.UP)
var ridged: bool = idle_ridged_stair_lip_only(slide_normals, up_dot)
# Single-surface inclines (walk ramps): flatter than [member STABLE_IDLE_FLOOR_MIN_UP_DOT] but not
# stair lip geometry — latch stable idle instead of endless corrective [method _physics_idle_tick].
if (
not ridged
and up_dot >= IDLE_SLOPE_STABLE_MIN_UP_DOT
and up_dot < STABLE_IDLE_FLOOR_MIN_UP_DOT
):
return true
# Entering stable idle uses [member STABLE_IDLE_FLOOR_MIN_UP_DOT] (0.998). On stair treads,
# [code]get_floor_normal()[/code] often sits ~0.9960.997 with a riser contact — never latches,
# so idle stays in corrective [method _maybe_idle_rim_settle_nudge] + [method _physics_idle_tick]
# forever (HUD jitter while **not** walking). Use the latched hold threshold (0.992) when ridged.
var required_up: float = min_flat_up_dot
if ridged and min_flat_up_dot >= STABLE_IDLE_FLOOR_MIN_UP_DOT - 1e-6:
required_up = STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT
if up_dot < required_up:
return false
# Post-stop **ridged** slides (tread + riser) used to force unstable idle for the entire
# `loose_ticks` window even when the **floor** is level — e.g. idle on a flat approach tread
# hugging a vertical face stayed in corrective `move_and_slide` ~0.8 s (HUD X/Y/Z noise).
# Only demand rim settle when the reported floor is already shallow (lip straddle), not mere
# proximity to a riser on an otherwise flat tread.
if loose_ticks > 0 and ridged and up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT:
return false
return true
func _current_slide_normals() -> Array[Vector3]:
var normals: Array[Vector3] = []
for i: int in get_slide_collision_count():
normals.append(get_slide_collision(i).get_normal())
return normals
func _stable_idle_support() -> bool:
var min_dot: float = (
STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT if _idle_stable_latched else STABLE_IDLE_FLOOR_MIN_UP_DOT
)
return idle_support_is_stable(
is_on_floor(),
get_floor_normal(),
_current_slide_normals(),
_floor_angle_loose_ticks,
min_dot
)
func _hold_idle_anchor() -> void:
var pos := global_position
if not _idle_anchor_active:
_idle_anchor_xz = Vector2(pos.x, pos.z)
_idle_anchor_y = pos.y
_idle_anchor_active = true
return
global_position = Vector3(_idle_anchor_xz.x, _idle_anchor_y, _idle_anchor_xz.y)
func _debug_trace_idle_state(tag: String) -> void:
if not debug_idle_trace or _has_walk_goal:
_debug_idle_heartbeat = 0
_debug_last_idle_xz = Vector2.INF
return
var pos_xz := Vector2(global_position.x, global_position.z)
var should_log: bool = false
if _debug_last_idle_xz == Vector2.INF:
should_log = true
elif pos_xz.distance_to(_debug_last_idle_xz) > DEBUG_IDLE_TRACE_DELTA_EPS:
should_log = true
elif _debug_idle_heartbeat >= DEBUG_IDLE_TRACE_HEARTBEAT_FRAMES:
should_log = true
if should_log:
push_warning(
(
(
"NEO-14 idle trace [%s] pos=%s vel=%s on_floor=%s loose=%d "
+ "stable=%s anchor=%s anchor_xz=%s floor=%s slides=%s"
)
% [
tag,
global_position,
velocity,
is_on_floor(),
_floor_angle_loose_ticks,
_stable_idle_support(),
_idle_anchor_active,
_idle_anchor_xz,
get_floor_normal(),
_current_slide_normals(),
]
)
)
_debug_last_idle_xz = pos_xz
_debug_idle_heartbeat = 0
else:
_debug_idle_heartbeat += 1
func _debug_trace_transform(tag: String) -> void:
if not debug_idle_trace:
return
var pos_xz := Vector2(global_position.x, global_position.z)
if (
_debug_last_transform_xz != Vector2.INF
and pos_xz.distance_to(_debug_last_transform_xz) < DEBUG_TRANSFORM_TRACE_DELTA_EPS
and tag != "physics"
):
return
var extra: String = ""
if _has_walk_goal:
var fy: float = capsule_feet_y(
global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
)
var vsep: float = absf(_auth_walk_goal.y - fy)
extra = (
" feet_y=%s vert_sep=%.4f vlat=%s ncol=%s"
% [fy, vsep, _walk_vert_route_latched, _walk_nav_column_steering]
)
print(
(
(
"NEO-14 transform trace [%s] frame=%d pos=%s vel=%s has_goal=%s "
+ "on_floor=%s loose=%d stable=%s anchor=%s anchor_xz=%s%s"
)
% [
tag,
_debug_trace_frame,
global_position,
velocity,
_has_walk_goal,
is_on_floor(),
_floor_angle_loose_ticks,
_stable_idle_support(),
_idle_anchor_active,
_idle_anchor_xz,
extra,
]
)
)
_debug_last_transform_xz = pos_xz
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSFORM_CHANGED:
_debug_trace_transform("notify")
func _idle_ridged_floor_contacts() -> bool:
return idle_slide_contacts_are_ridged(_current_slide_normals())
func _maybe_idle_rim_settle_nudge() -> bool:
if get_slide_collision_count() < 1:
return false
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
var shallow_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
var ridged: bool = idle_ridged_stair_lip_only(_current_slide_normals(), floor_up_dot)
# Uniform ramps (one sloped floor, no tread+riser) still read “shallow” vs
# [member IDLE_RIM_MIN_FLOOR_UP_DOT]; rim nudges slide along the ramp every tick (jitter).
if shallow_floor and not ridged:
return false
if not shallow_floor and not ridged:
return false
var h := Vector3.ZERO
if shallow_floor:
h = Vector3(get_floor_normal().x, 0.0, get_floor_normal().z)
if h.length_squared() < 1e-12:
for i: int in get_slide_collision_count():
var n: Vector3 = get_slide_collision(i).get_normal()
if n.y < 0.5:
var nh: Vector3 = Vector3(n.x, 0.0, n.z)
if nh.length_squared() > h.length_squared():
h = nh
if h.length_squared() < 1e-12:
return false
var step: Vector3 = -h.normalized() * IDLE_RIM_SETTLE_STEP
if test_move(global_transform, step, null, safe_margin):
return false
global_position += step
return true
func _maybe_idle_bump_proximity_escape() -> bool:
var feet_y: float = global_position.y - CAPSULE_HALF_HEIGHT
for node: Node in get_tree().get_nodes_in_group(
BUMP_COLLISION_CONSTS_SCRIPT.RANDOM_FLOOR_BUMP_MESH_GROUP
):
if not node is MeshInstance3D:
continue
var mi := node as MeshInstance3D
var mesh: Mesh = mi.mesh
if not mesh is CylinderMesh:
continue
var cyl := mesh as CylinderMesh
var c: Vector3 = mi.global_position
var r_mesh: float = cyl.top_radius
var h: float = cyl.height
var col_r: float = minf(
r_mesh + BUMP_COLLISION_CONSTS_SCRIPT.COLLISION_RADIUS_EXTRA,
BUMP_COLLISION_CONSTS_SCRIPT.COLLISION_RADIUS_MAX
)
var dx: float = global_position.x - c.x
var dz: float = global_position.z - c.z
var d: float = sqrt(dx * dx + dz * dz)
if d < 1e-5:
continue
var bottom_y: float = c.y - h * 0.5
var top_y: float = c.y + h * 0.5
if feet_y < bottom_y - 0.22 or feet_y > top_y + 0.4:
continue
var radial: Vector3 = Vector3(dx / d, 0.0, dz / d)
var away: Vector3 = radial * IDLE_BUMP_ESCAPE_STEP
var wall_band_outer: float = col_r + PLAYER_CAPSULE_RADIUS + 0.22
var should_escape: bool = d > r_mesh * 0.9 and d < col_r + 0.32
# On / above the disc, hugging the **visual** rim — step onto open slab.
should_escape = (
should_escape
or (
feet_y >= bottom_y - 0.06
and feet_y <= top_y + 0.1
and d >= r_mesh * 0.86
and d <= r_mesh + 0.14
)
)
# Beside vertical cylinder: axis distance may exceed **`col_r`** while capsule still touches wall.
should_escape = (
should_escape
or (
feet_y <= top_y + 0.14
and d > r_mesh * 0.82
and d < wall_band_outer
and (is_on_wall() or _idle_ridged_floor_contacts())
)
)
if not should_escape:
continue
if test_move(global_transform, away, null, safe_margin):
return false
global_position += away
return true
return false
func _apply_idle_manual_correction() -> bool:
# Prefer explicit bump-rim escape over the generic shallow-floor settle so spawned
# cylinder edges resolve radially off the lip instead of orbiting around it.
if _maybe_idle_bump_proximity_escape():
return true
return _maybe_idle_rim_settle_nudge()
func _snap_capsule_upright() -> void:
# TODO(NS-24 follow-on): With facing yaw, clear pitch/roll only; keep Y rotation.
# Full **`Basis.IDENTITY`** would erase look direction.
if global_transform.basis.orthonormalized().is_equal_approx(Basis.IDENTITY):
return
var p: Vector3 = global_position
global_transform = Transform3D(Basis.IDENTITY, p)
func _physics_process_no_walk_goal(delta: float) -> void:
floor_block_on_wall = true
var raw_stable: bool = _stable_idle_support()
if raw_stable:
_idle_stable_unlatch_streak = 0
if not _idle_stable_latched:
_idle_stable_enter_streak += 1
if _idle_stable_enter_streak >= STABLE_IDLE_ENTER_STREAK_FRAMES:
_idle_stable_latched = true
else:
_idle_stable_enter_streak = 0
elif _idle_stable_latched:
var crest_quiet: bool = (
is_on_floor()
and get_floor_normal().dot(Vector3.UP) >= STABLE_IDLE_FLOOR_MIN_UP_DOT - 0.001
)
if raw_stable or crest_quiet:
_idle_stable_unlatch_streak = 0
else:
_idle_stable_unlatch_streak += 1
if _idle_stable_unlatch_streak >= STABLE_IDLE_UNLATCH_TICKS:
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_idle_anchor_active = false
else:
_idle_stable_enter_streak = maxi(
0, _idle_stable_enter_streak - STABLE_IDLE_ENTER_MISS_DECAY
)
if _idle_stable_latched:
_idle_manual_correction_ticks = 0
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE
_hold_idle_anchor()
_debug_trace_idle_state("stable")
_debug_trace_transform("physics")
_snap_capsule_upright()
return
if _idle_manual_correction_ticks >= IDLE_MANUAL_CORRECTION_MAX_TICKS:
if is_on_floor():
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE
if _floor_angle_loose_ticks > 0:
_floor_angle_loose_ticks -= 1
_hold_idle_anchor()
_debug_trace_idle_state("budgeted")
_debug_trace_transform("physics")
_snap_capsule_upright()
return
_idle_anchor_active = false
_physics_idle_tick(delta)
if _floor_angle_loose_ticks > 0:
_floor_angle_loose_ticks -= 1
_debug_trace_idle_state("budgeted")
_debug_trace_transform("physics")
_snap_capsule_upright()
return
_idle_anchor_active = false
_physics_idle_tick(delta)
if _apply_idle_manual_correction():
_idle_manual_correction_ticks += 1
else:
_idle_manual_correction_ticks = 0
if _floor_angle_loose_ticks > 0:
_floor_angle_loose_ticks -= 1
_debug_trace_idle_state("corrective")
_debug_trace_transform("physics")
_snap_capsule_upright()
func _physics_process(delta: float) -> void:
_debug_trace_frame += 1
# Do not tie wide floor angle to `_floor_angle_loose_ticks` alone: flat walks that kept a wide
# angle for the whole loose window made `move_and_slide` + idle snap jitter XZ. Use moving max
# angle only while walking or when idle support is shallow / ridged.
var use_loose_floor_angle: bool = _has_walk_goal
if not _has_walk_goal:
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
var shallow_idle_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
# Raw facet ridging reads true on a heightmap **crest** while [code]get_floor_normal()[/code] is
# already ~level; widening [member floor_max_angle] there makes [method move_and_slide] hunt
# forever (HUD jitter on the pink hill tip).
if (
(shallow_idle_floor or idle_slide_contacts_are_ridged(_current_slide_normals()))
and floor_up_dot < STABLE_IDLE_FLOOR_MIN_UP_DOT
):
use_loose_floor_angle = true
floor_max_angle = deg_to_rad(
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG
)
var wish_active: bool = _locomotion_wish_world_xz.length_squared() > 1e-10
if wish_active:
use_loose_floor_angle = true
floor_max_angle = deg_to_rad(FLOOR_MAX_ANGLE_MOVING_DEG)
_physics_process_locomotion_wasd(delta)
return
if not _has_walk_goal:
_physics_process_no_walk_goal(delta)
return
var full_to_goal: Vector3 = _auth_walk_goal - global_position
var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length()
var want_goal_h: Vector3 = Vector3(full_to_goal.x, 0.0, full_to_goal.z)
# Feet + vertical routing before arrival: column stairs keep [code]vert_err[/code] above
# [member VERT_ARRIVE_EPS] while XZ is inside [member ARRIVE_EPS]. Widen vertical tolerance only
# for [code]vlat && ncol[/code] on floor (see [member WALK_COLUMN_NEAR_ARRIVE_VERT]).
var feet_y: float = capsule_feet_y(
global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
)
var vert_sep: float = absf(_auth_walk_goal.y - feet_y)
if not _walk_vert_route_latched:
if vert_sep > WALK_VERT_ROUTE_LATCH_ON_SEP:
_walk_vert_route_latched = true
else:
if vert_sep < WALK_VERT_ROUTE_LATCH_OFF_SEP:
_walk_vert_route_latched = false
var needs_vertical_routing: bool = _walk_vert_route_latched
if not needs_vertical_routing:
_walk_nav_column_steering = false
elif horiz_dist <= NAV_COLUMN_STEER_ENTER_DIST:
_walk_nav_column_steering = true
elif horiz_dist >= NAV_COLUMN_STEER_EXIT_DIST:
_walk_nav_column_steering = false
var vert_err: float = vertical_arrival_error(
_auth_walk_goal.y, global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
)
var vert_ok: float = VERT_ARRIVE_EPS
if (
is_on_floor()
and _walk_vert_route_latched
and _walk_nav_column_steering
and vert_err < WALK_COLUMN_NEAR_ARRIVE_GATE_VERT_ERR
):
vert_ok = maxf(VERT_ARRIVE_EPS, WALK_COLUMN_NEAR_ARRIVE_VERT)
if horiz_dist <= ARRIVE_EPS and vert_err <= vert_ok:
velocity = Vector3.ZERO
_has_walk_goal = false
_walk_ledge_peel_vy = 0.0
_walk_peel_no_probe_streak = 0
_walk_debounced_wants_ledge_peel = false
floor_block_on_wall = true
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_step_assist_active = false
_idle_anchor_active = false
_idle_manual_correction_ticks = 0
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(global_position)
_physics_idle_tick(delta)
if _stable_idle_support():
_idle_stable_latched = true
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_idle_manual_correction_ticks = 0
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE
_hold_idle_anchor()
_debug_trace_idle_state("arrival_stable")
_debug_trace_transform("physics")
_snap_capsule_upright()
return
if _apply_idle_manual_correction():
_idle_manual_correction_ticks += 1
_debug_trace_transform("physics")
_snap_capsule_upright()
return
if _walk_try_release_column_stuck_orbit(horiz_dist):
floor_block_on_wall = true
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_physics_idle_tick(delta)
_debug_trace_transform("physics")
_snap_capsule_upright()
return
_idle_manual_correction_ticks = 0
_apply_floor_block_for_descend_at_wall(feet_y)
# Horizontal motion toward the **authoritative** click target (XZ) by default. Use the baked
# path only while **airborne** with column vertical routing (`on_floor=false` + `vlat && ncol`):
# mid-air the next waypoint can carry tangential intent around a column. On **floor**, the same
# path sampling on a narrow nav ribbon + shallow rim normals still flipped ±[code]MOVE_SPEED[/code]
# every tick (Z seam traces: [code]ncol=true[/code] stable [code]on_floor[/code], ~4 cm Z wobble).
# Shallow on-floor column approach uses direct goal steer plus
# [method _walk_clip_horizontal_velocity_against_vertical_contacts].
if needs_vertical_routing and _walk_nav_column_steering and not is_on_floor():
_set_horizontal_velocity_from_nav_path_or_goal(want_goal_h)
else:
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h)
_walk_clip_horizontal_velocity_against_vertical_contacts(want_goal_h)
_walk_damp_vert_route_column_seam_flip()
if _walk_tread_freeze_horizontal_active(feet_y, horiz_dist):
velocity.x = 0.0
velocity.z = 0.0
var walk_move_dir_xz: Vector2 = _resolve_walk_probe_dir_xz(feet_y, want_goal_h)
_walk_refresh_ledge_peel_debounce(feet_y, walk_move_dir_xz)
_apply_walk_air_gravity(delta, feet_y, walk_move_dir_xz)
floor_snap_length = _walk_floor_snap_length(feet_y, want_goal_h, walk_move_dir_xz, horiz_dist)
move_and_slide()
var feet_after: float = capsule_feet_y(
global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
)
_apply_walk_post_slide_ledge_peel(delta, feet_after, walk_move_dir_xz, horiz_dist)
_after_walk_move_and_slide()
_clear_step_assist_after_walk_move()
_debug_trace_transform("physics")
_snap_capsule_upright()