874 lines
33 KiB
GDScript
874 lines
33 KiB
GDScript
extends CharacterBody3D
|
||
|
||
## NS-23 path-follow; NS-24 idle. 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).
|
||
const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 96
|
||
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
|
||
## 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
|
||
const WALK_VERT_ROUTE_LATCH_OFF_SEP: float = 0.038
|
||
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
|
||
## 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.9991–0.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 = 8
|
||
## Consecutive [code]raw_stable[/code] ticks required before first idle anchor latch (entry debounce).
|
||
const STABLE_IDLE_ENTER_STREAK_FRAMES: int = 2
|
||
## 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]).
|
||
const WALK_SUPPORT_PROBE_DEPTH: float = 0.22
|
||
const WALK_SUPPORT_PROBE_MIN_UP_DOT: float = 0.42
|
||
## Only second-guess [code]is_on_floor()[/code] when the reported floor is fairly level (skip
|
||
## stair risers / seam normals where a down ray often hits vertical mesh).
|
||
const WALK_LEDGE_PROBE_MAX_FLOOR_UP_DOT: float = 0.92
|
||
## 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
|
||
## Schmitt latch for vertical routing (see [member WALK_VERT_ROUTE_LATCH_ON_SEP]).
|
||
var _walk_vert_route_latched: bool = false
|
||
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||
|
||
|
||
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
|
||
_nav_agent.set_target_position(world_pos)
|
||
|
||
|
||
func clear_nav_goal() -> void:
|
||
velocity = Vector3.ZERO
|
||
_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
|
||
_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 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
|
||
# 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
|
||
_nav_agent.set_target_position(settled)
|
||
reset_physics_interpolation()
|
||
|
||
|
||
## 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
|
||
|
||
|
||
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:
|
||
return false
|
||
if _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
|
||
# Compute want direction before the support/blocking checks so both can use it.
|
||
var want: Vector3 = to_h.normalized()
|
||
if not _step_assist_wallish_blocks(want):
|
||
return false
|
||
if not _step_assist_has_support(want):
|
||
return false
|
||
# Target capsule centre once standing on the goal surface.
|
||
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()
|
||
velocity = dh * MOVE_SPEED
|
||
velocity.y = 0.0
|
||
|
||
|
||
func _walk_has_close_floor_probe_below() -> bool:
|
||
var w3d := get_world_3d()
|
||
if w3d == null:
|
||
return true
|
||
var space := w3d.direct_space_state
|
||
var feet: Vector3 = global_position - Vector3(0.0, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS, 0.0)
|
||
# Start slightly above the foot line so the ray is not born inside the deck collider after snap.
|
||
var probe_from: Vector3 = feet + Vector3.UP * 0.04
|
||
var probe_end: Vector3 = feet + Vector3.DOWN * WALK_SUPPORT_PROBE_DEPTH
|
||
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"):
|
||
return false
|
||
var n: Vector3 = res["normal"]
|
||
return n.dot(Vector3.UP) > WALK_SUPPORT_PROBE_MIN_UP_DOT
|
||
|
||
|
||
## Airborne walk ticks only. Do **not** add gravity just because the nav goal is below the
|
||
## feet — that stays true for the whole cross of a raised pad (e.g. TerracePlatformA / B)
|
||
## toward a floor click and poisons horizontal motion / slide resolution.
|
||
## Do apply gravity when [code]is_on_floor()[/code] is still true but there is no walkable surface
|
||
## within [member WALK_SUPPORT_PROBE_DEPTH] under the feet (ledge / void while moving).
|
||
func _apply_walk_air_gravity(delta: float) -> void:
|
||
var apply_gravity: bool = not is_on_floor()
|
||
if (
|
||
not apply_gravity
|
||
and _has_walk_goal
|
||
and is_on_floor()
|
||
and get_floor_normal().dot(Vector3.UP) > WALK_LEDGE_PROBE_MAX_FLOOR_UP_DOT
|
||
and not _walk_has_close_floor_probe_below()
|
||
):
|
||
apply_gravity = true
|
||
if apply_gravity:
|
||
velocity += get_gravity() * delta
|
||
|
||
|
||
func _walk_floor_snap_length(feet_y: float, want_goal_h: Vector3) -> float:
|
||
if _step_assist_active:
|
||
return WALK_STEP_ASSIST_SNAP
|
||
if _has_walk_goal:
|
||
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
|
||
if (
|
||
_auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN
|
||
and feet_y > _auth_walk_goal.y + 0.08
|
||
):
|
||
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:
|
||
# Do **not** scan `get_current_navigation_path()` for the first point with
|
||
# `dh.length_squared() > 0.0025` (5 cm). Jolt nudges the capsule across that radius every
|
||
# physics tick → the chosen waypoint alternates → velocity sign flips 180° while
|
||
# `needs_vertical_routing && _walk_nav_column_steering` (NEON-16 debug trace).
|
||
if _nav_agent.is_navigation_finished():
|
||
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
||
return
|
||
var next_pos: Vector3 = _nav_agent.get_next_path_position()
|
||
var dh: Vector3 = Vector3(
|
||
next_pos.x - global_position.x, 0.0, next_pos.z - global_position.z
|
||
)
|
||
if dh.length_squared() < 1e-8:
|
||
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
|
||
else:
|
||
_set_horizontal_velocity_toward(next_pos, fallback_goal_xz)
|
||
|
||
|
||
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 += get_gravity() * 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
|
||
|
||
|
||
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
|
||
if floor_normal.dot(Vector3.UP) < min_flat_up_dot:
|
||
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 idle_slide_contacts_are_ridged(slide_normals)
|
||
and floor_normal.dot(Vector3.UP) < 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(
|
||
(
|
||
(
|
||
"NEON-16 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(
|
||
(
|
||
(
|
||
"NEON-16 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 shallow_floor: bool = (
|
||
is_on_floor() and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT
|
||
)
|
||
if not shallow_floor and not _idle_ridged_floor_contacts():
|
||
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(delta: float) -> void:
|
||
_debug_trace_frame += 1
|
||
# Do not tie wide floor angle to `_floor_angle_loose_ticks` alone: after a walk on **flat**
|
||
# ground that still widened angle for ~0.8 s, `move_and_slide` + idle snap caused visible XZ
|
||
# jitter. 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
|
||
if shallow_idle_floor or _idle_ridged_floor_contacts():
|
||
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
|
||
)
|
||
if not _has_walk_goal:
|
||
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:
|
||
_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 = 0
|
||
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
|
||
# Airborne: do not freeze XZ without a physics step — same mid-air hang as idle tick.
|
||
_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()
|
||
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)
|
||
# Use actual capsule bottom (CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS = 0.9) so that a
|
||
# step-surface goal (e.g. Y=0.3) is never considered "arrived" while the player is still at
|
||
# floor height. Using only CAPSULE_HALF_HEIGHT (0.5) gives code-feet at Y=0.4, which is too
|
||
# close to step surfaces and causes premature arrival when Jolt nudges the body down to ~0.8
|
||
# while the capsule hemisphere contacts the step edge.
|
||
var vert_err: float = vertical_arrival_error(
|
||
_auth_walk_goal.y, global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
|
||
)
|
||
if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS:
|
||
velocity = Vector3.ZERO
|
||
_has_walk_goal = 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
|
||
_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
|
||
|
||
_idle_manual_correction_ticks = 0
|
||
|
||
var feet_y: float = capsule_feet_y(
|
||
global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
|
||
)
|
||
_apply_floor_block_for_descend_at_wall(feet_y)
|
||
|
||
# Horizontal motion toward the **authoritative** click target (XZ) by default. A **global**
|
||
# `get_next_path_position()` steer regressed under Jolt / 120 Hz (“stuck” / slow advance).
|
||
# Use the baked path only when vertical routing is latched **and** column hysteresis says so;
|
||
# then **`_set_horizontal_velocity_from_nav_path_or_goal`** uses **`get_next_path_position()`**
|
||
# (not a 5 cm path scan — that ping-ponged waypoints every tick).
|
||
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
|
||
if needs_vertical_routing and _walk_nav_column_steering:
|
||
_set_horizontal_velocity_from_nav_path_or_goal(want_goal_h)
|
||
else:
|
||
_set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h)
|
||
|
||
_apply_walk_air_gravity(delta)
|
||
floor_snap_length = _walk_floor_snap_length(feet_y, want_goal_h)
|
||
move_and_slide()
|
||
_after_walk_move_and_slide()
|
||
_clear_step_assist_after_walk_move()
|
||
_debug_trace_transform("physics")
|
||
_snap_capsule_upright()
|