extends CharacterBody3D
## Overgrowth-style player controller
## Ported from aschar.as, aircontrols.as, playercontrol.as, fliproll.as, ledgegrab.as
## All constants match Overgrowth defaults unless noted.
# ---- State Machine ----
enum State { MOVEMENT, AIRBORNE, WALL_CLIMB, GRAPPLE, GRAPPLE_WINCH, FLY, SWIM }
var state: int = State.MOVEMENT
var fly_mode: bool = false
var _pre_fly_position: Vector3 = Vector3.ZERO # saved position before fly mode
## Quake liquid swim state (local player only)
var _in_liquid: bool = false
var _water_level: int = 0 # SV_CheckWater: 0 dry, 1 feet, 2 mid (swim), 3 eye (blend)
var _liquid_overlay: ColorRect = null
# ---- Movement Constants (Overgrowth exact) ----
const BASE_RUN_SPEED: float = 8.0
const TRUE_MAX_SPEED: float = 12.0
const WALK_ACCEL: float = 35.0
const STANDING_FRICTION: float = 0.95
const WALK_SPEED: float = 0.2 ## Walk key clamps target_velocity magnitude
const E: float = 2.71828
# ---- Gravity ----
const GRAVITY: float = 9.8
## Along-wall speed below which the facing keeps the direction it already had.
## Without hysteresis the sign flips every time along-wall velocity crosses zero.
const WALL_RUN_FACING_FLIP_SPEED: float = 1.5
## Per-frame fraction the smoothed tangent moves toward the raw one. Low enough to
## average out facet chatter, high enough to follow a wall that genuinely curves.
const WALL_RUN_TANGENT_SMOOTH: float = 0.08
const TERMINAL_VELOCITY: float = 50.0
const TERMINAL_DAMPING: float = 0.99
# ---- Jump ----
const JUMP_INITIAL_VELOCITY: float = 12.0 ## 3x Overgrowth (was 5.0) for higher jumps
const JUMP_AIR_CONTROL: float = 3.0
const AIR_SPEED_CAP: float = 14.0 ## Max horizontal speed from air control alone
const AIR_DECEL: float = 0.98 ## Friction when no input in air (per frame at 60fps)
const JUMP_FUEL: float = 5.0
const JUMP_FUEL_BURN: float = 10.0
# ---- Wall Run ----
const WALL_RUN_MIN_SPEED: float = 4.0
const WALL_RUN_FALL_LIMIT: float = -4.0
# ---- Wall Climb (Lizard Walk) ----
const STICK_FORCE: float = 10.0 ## Pushes body into surface for floor detection
const SURFACE_ALIGN_SPEED: float = 6.0 ## Slerp speed for capsule reorientation
const MODEL_ALIGN_SPEED: float = 8.0 ## Slerp speed for model basis alignment
const DEFAULT_FLOOR_MAX_ANGLE: float = 1.047 ## ~60 degrees (Godot default)
# ---- Crouch ----
const DUCK_ACCEL: float = 200.0 ## Stiffer spring (Overgrowth=120, ours higher for snappier feel)
const DUCK_VEL_INERTIA: float = 0.82 ## Less damping = faster settle (Overgrowth=0.89)
const DUCK_SPEED_MULT: float = 0.5
# ---- Thresholds ----
const RUN_THRESHOLD: float = 0.8
const WALK_THRESHOLD: float = 0.6
const GROUND_NORMAL_Y_THRESHOLD: float = 0.5
# ---- Fall Damage ----
const SHOCK_DAMAGE_THRESHOLD: float = 30.0
const SHOCK_DAMAGE_MULTIPLIER: float = 0.1
# ---- Turn ----
const TURN_VELOCITY: float = 15.0
const MOUSE_SENSITIVITY: float = 0.3
## Quake liquid swim (WinQuake/sv_user.c SV_WaterMove) + content blend (view.c V_CalcBlend).
const WATER_SPEED_SCALE: float = 0.7 # wishspeed *= 0.7 (water is 70% of ground speed)
const WATER_FRICTION: float = 4.0 # sv_friction
const WATER_ACCEL: float = 10.0 # sv_accelerate
const WATER_IDLE_SINK: float = 0.18 # idle: drift toward the bottom (Quake wishvel[2]-=60)
const LIQUID_BLEND := {
"water": Color(130.0 / 255.0, 80.0 / 255.0, 50.0 / 255.0, 128.0 / 255.0),
"lava": Color(255.0 / 255.0, 80.0 / 255.0, 0.0 / 255.0, 150.0 / 255.0),
"slime": Color(0.0 / 255.0, 25.0 / 255.0, 5.0 / 255.0, 150.0 / 255.0),
}
const ZOOM_STEP: float = 0.5
const ZOOM_MIN: float = 1.0
const ZOOM_MAX: float = 20.0
# ---- Ledge Grab ----
const LEDGE_GRAB_MAX_FALL_SPEED: float = -20.0 ## Can't grab if falling faster
const LEDGE_GRAB_REACH: float = 1.2 ## How far above to check for ledge
const LEDGE_GRAB_FORWARD: float = 1.5 ## How far forward to check
const LEDGE_CLIMB_SPEED: float = 3.0 ## Speed of climb-up lerp
const WALL_GRAB_RANGE: float = 2.0 ## Raycast range for shift+wall grab
# ---- Landing Particles ----
const LandingParticles := preload("res://scenes/player/landing_particles.tscn")
const ProceduralAnimatorScript := preload("res://scenes/player/procedural/procedural_animator.gd")
const WallClimbClass := preload("res://scenes/player/procedural/wall_climb.gd")
const GrappleSystemClass := preload("res://scenes/player/procedural/grapple_system.gd")
# ---- Character Profile (exported for future species) ----
@export_group("Character Profile")
@export var run_speed: float = 8.0
@export var leg_sphere_size: float = 0.45
# ---- Procedural Animation (forwarded to ProceduralAnimator at runtime) ----
@export_group("Procedural Animation")
@export var hip_drop_max: float = 0.45 ## Max hip drop when crouching (meters)
@export var walk_stride_length: float = 4.0
@export var run_stride_length: float = 6.0
@export var walk_speed_threshold: float = 0.6
@export var run_speed_threshold: float = 5.0
@export var enable_foot_ik: bool = true
@export var enable_chest_ik: bool = true
@export var enable_head_ik: bool = true
# ---- Jump Timing (forwarded to animator + controller) ----
@export_group("Jump Timing")
@export_range(0.0, 0.3, 0.01) var pre_jump_delay: float = 0.04 ## Anticipation crouch duration
@export_range(0.0, 0.3, 0.01) var jump_threshold_time: float = 0.05 ## Min ground time before jump
@export_range(5.0, 30.0, 1.0) var fade_speed_jump: float = 18.0 ## Crossfade into jump pose
@export_range(5.0, 30.0, 1.0) var fade_speed_land: float = 18.0 ## Crossfade into land pose
@export_range(5.0, 30.0, 1.0) var fade_speed_fall: float = 16.0 ## Crossfade into fall pose
@export_range(0.05, 0.8, 0.05) var land_duration: float = 0.2 ## Land pose hold time
# ---- Grapple (forwarded to GrappleSystem at runtime) ----
@export_group("Grapple")
@export_enum("Rope", "Winch") var grapple_mode: int = 1 ## 0=rope swing, 1=winch pull
@export var grapple_max_range: float = 50.0
@export var grapple_max_speed: float = 25.0
@export var grapple_reel_speed: float = 5.0
@export var grapple_rope_damping: float = 0.98
@export var grapple_launch_speed: float = 8.0 ## Initial impulse toward anchor when firing from ground
@export var grapple_pull_force: float = 15.0 ## Direct winch force when reeling in (Shift)
@export var grapple_ground_grace: float = 0.3 ## Seconds before ground contact releases grapple
@export_subgroup("Winch")
@export var winch_base_speed: float = 12.0 ## Default pull speed toward anchor (m/s)
@export var winch_fast_speed: float = 22.0 ## Pull speed when holding Shift
@export var winch_slow_speed: float = 5.0 ## Pull speed when holding Ctrl
@export var winch_gravity_scale: float = 0.6 ## Gravity multiplier during winch (0=no gravity, 1=full)
@export var winch_arrive_dist: float = 2.0 ## Auto-release when this close to anchor
# ---- Fly Mode ----
@export_group("Fly Mode")
## Fly speed. Raised for the 917 km world: at the old 60 m/s sprint, crossing it
## took over four hours. These are the BASE speeds; fly_speed_scale multiplies
## them and is what you actually reach for.
@export var fly_speed: float = 50.0
@export var fly_speed_fast: float = 250.0
## Runtime multiplier on both, adjusted with ALT + mouse wheel while flying.
##
## NOT ctrl: ctrl is crouch, which in fly mode is descend, so ctrl+wheel would
## mean "go down and change speed" at the same time. NOT shift either: that is
## sprint, i.e. the fast fly speed. Alt is the only free modifier -- alt_pressed
## appears zero times in project.godot's input map.
##
## Plain wheel still zooms the spring arm, which _unhandled_input's own comment
## says is intended while flying, so this takes a modifier rather than stealing
## the wheel outright.
##
## The range spans four orders of magnitude on purpose: this world is 917 km
## across and a fixed speed cannot serve both "inspect a rock face" and "get to
## that mountain range". Top end is 250 * 200 = 50 km/s, which crosses the world
## in about 18 seconds.
var fly_speed_scale: float = 1.0
const FLY_SCALE_MIN := 0.1
const FLY_SCALE_MAX := 200.0
const FLY_SCALE_STEP := 1.4
## HOLD TO SKI, the way Hudbot's HappyJump did it: "the built in ski script, to
## be bound to Turn Left. When true, you can bind your jump key to it and use it
## instead of a ski script" (Tribes/Hudbot/New Prefs.txt). Tribes 2 later built
## the same thing in as hold-jump.
##
## Emergent skiing only starts where traction has already lost to gravity, past
## about 40 degrees. This is the agency half: hold jump and you ski at ANY angle,
## including the flat, where Tribes players kept "100% of your speed and
## momentum".
##
## A TAP STILL JUMPS. The hold has to outlast ski_hold_delay before the servo is
## suppressed, and pre_jump_delay (0.08 s) is shorter -- so holding gives you one
## hop and then skis, which is exactly what a Tribes player looked like.
@export_group("Skiing")
@export var ski_hold_enabled: bool = true
## How long jump must be held, on the ground, before it means ski rather than jump.
@export_range(0.0, 0.5, 0.01) var ski_hold_delay: float = 0.15
## Traction while holding. 0.0 is Tribes Classic: no grip, pure momentum.
@export_range(0.0, 1.0, 0.05) var ski_hold_traction: float = 0.0
## SKIING is emergent now, not a setting. In Tribes it was the suspension of
## ground friction while staying in contact for gravity -- players jump-spammed
## so friction never applied, and happymod/Hudbot's HappyJump automated it. There
## is no friction here any more, so what remains is traction: on steep ground the
## feet cannot push, gravity along the surface wins, and you slide and accumulate
## speed. Same mechanic, no knob.
## An OPTIONAL top-speed penalty for climbing, in the v = v_flat / (1 + k * tan)
## sense (Naismith). DEFAULT 0: the budget model already makes steep ground
## expensive by taking away traction, and stacking a speed cap on top of that is
## the double-charging that made moderate slopes feel sticky. Raise it only if
## climbing should also feel slow where you still have grip.
## THE GRADE AT WHICH THE FEET STOP HOLDING. Below it you can walk, with less
## left over to move with the steeper it gets; AT it the whole budget goes on not
## sliding, so you stop; ABOVE it the feet cannot hold and the ground carries you.
##
## One number, and it decides BOTH where climbing ends and where skiing begins --
## they are the same instant, because holding is paid before moving. That is not a
## simplification, it is what the model says.
##
## It used to be an unnamed 39.3 degrees that fell out of a smoothstep tied to
## floor_max_angle, so floor_max_angle read 60 and the character stopped at 39.
## 45 degrees is mu = 1.0, rubber on rock, and leaves a 15 degree band up to
## floor_max_angle where steep ground carries you.
@export var slide_angle: float = deg_to_rad(45.0)
@export_range(0.0, 4.0, 0.1) var slope_climb_cost: float = 0.0
# ---- Wall Run ----
@export_group("Wall Run")
## Traction GRANTED on a wall, which has none of its own. Reaches this value at
## WALL_RUN_MIN_SPEED along the wall and fades to nothing as that speed is lost.
@export_range(0.0, 2.0, 0.05) var wall_run_traction: float = 0.9
## How hard you press into the wall. Without a normal force there is no traction
## to grant, and no contact for _detect_walls to keep finding.
@export_range(0.0, 8.0, 0.5) var wall_run_press: float = 2.0
## How far the body leans off vertical during a wall run, as a fraction of the
## way from world up to the wall's outward normal. 0.5 would be ~45 degrees.
##
## ON, at ~45 degrees. It was 0 while the lean went through the two-mechanism
## path that flipped the player; it now has a single mechanism and a forward that
## cannot invert (see update_model_orientation), and the sign-flipping vector that
## caused both failures is deleted. 0 still turns it off.
## Fraction of the way from upright to the wall's outward normal, so 0.5 is 45
## degrees. Brought back toward vertical a little on request -- 0.4 is 36 degrees.
@export_range(0.0, 1.0, 0.05) var wall_run_tilt: float = 0.4
# ---- Lizard Walk Tuning ----
@export_group("Lizard Walk")
@export var lizard_back_offset: float = 0.8 ## Backward shift along facing (centers torso between grips)
@export var lizard_height: float = 0.4 ## Height above surface (bent-leg clearance)
@export var lizard_speed: float = 6.0 ## Max crawl speed (m/s)
@export var lizard_hand_forward: float = 0.40 ## Hand grip forward offset from body center
@export var lizard_hand_lateral: float = 0.5 ## Hand grip lateral spread (wider = arms further apart)
@export var lizard_foot_back: float = 0.3 ## Foot grip backward offset from body center
@export var lizard_foot_lateral: float = 0.5 ## Foot grip lateral spread
# ---- State Variables ----
var ground_normal: Vector3 = Vector3.UP
var gravity_dir: Vector3 = Vector3.DOWN ## Set externally for spherical gravity
var ground_time: float = 0.0
var air_time: float = 0.0
var surface_friction: float = 1.0
var feet_moving: bool = false ## Overgrowth: tracks if character has movement intent
var max_speed: float = 8.0 ## Recalculated per frame (slope, duck, sprint)
# Grapple
var grapple_grace_timer: float = 0.0 ## Grace period before ground-release kicks in
var _winch_anchor: Vector3 = Vector3.ZERO ## Anchor point for winch mode
var _winch_active: bool = false
var _dc_terrain: Node3D = null ## Cached DCTerrain for LOD freeze (found at runtime)
# Jump
var jump_fuel_remaining: float = 0.0
var down_jump_fuel: float = 0.0
var pre_jump_timer: float = 0.0
var jump_held: bool = false
## Hold-to-ski state. Accumulates only while grounded, so a jump in mid-air
## cannot arm it.
var _jump_hold_time: float = 0.0
var ski_holding: bool = false
# Sprint / Walk
var sprinting: bool = false
var walking: bool = false ## Walk toggle (caps speed to 0.2x)
# Crouch
var duck_amount: float = 0.0
var duck_vel: float = 0.0
var target_duck_amount: float = 0.0
# Flip/Roll
var _has_double_jumped: bool = false
# Wall run
var wall_contact: bool = false
var wall_normal_detected: Vector3 = Vector3.ZERO
var wall_collider: Object = null
var wall_run_active: bool = false
var wall_run_time: float = 0.0
var wall_run_normal: Vector3 = Vector3.ZERO
## Which way along the wall tangent the model faces. Latched, see above.
var _wall_run_tangent_sign: float = 1.0
## Low-passed wall tangent, cosmetic facing only.
var _wall_run_tangent_s: Vector3 = Vector3.ZERO
var wall_run_direction: Vector3 = Vector3.ZERO
var _has_hit_wall: bool = false ## Overgrowth: prevents re-triggering wall run
var _wall_climb: WallClimb = null
var _grapple: GrappleSystem = null
var _lizard_mode: bool = false ## Debug toggle: force lizard walk on flat ground
var _climb_debug_timer: float = 0.0
var _climb_debug_visual: Node3D = null ## Debug visualization for capsule/grips
var _climb_debug_frame_skip: int = 0
var _climb_debug_enabled: bool = false ## Toggle with K key at runtime (OFF by default for performance)
## Wall-run diagnostics. The gate cannot reproduce a flickering wall contact --
## it holds the player against one wall -- so these log the two things that would
## make the lean tumble in play: the state toggling, and the target up swinging.
var _wr_was_active: bool = false
var _wr_last_up: Vector3 = Vector3.UP
var _climb_bob_phase: float = 0.0 ## Sine wave phase for torso bob during lizard walk
var _grip_particles: Array[CPUParticles3D] = [] ## 4 particle emitters at grip positions
var _climb_model_quat: Quaternion = Quaternion.IDENTITY ## Slerped model orientation
# Surface alignment (lizard walk / wall climb)
var surface_up: Vector3 = Vector3.UP ## Current capsule up direction
var _target_surface_up: Vector3 = Vector3.UP ## Slerped toward each frame
var _last_desired_velocity: Vector3 = Vector3.ZERO ## Pre-slide velocity for post-slide recovery
var _camera_climb_forward: Vector3 = Vector3.FORWARD ## Camera forward on surface tangent (top_level)
var _body_climb_forward: Vector3 = Vector3.FORWARD ## Body forward on surface tangent (avoids wall_up degeneration)
var _grapple_rope_visual: MeshInstance3D = null
# Ledge grab
var _on_ledge: bool = false
var _ledge_point: Vector3 = Vector3.ZERO
var _ledge_normal: Vector3 = Vector3.ZERO
var _ledge_climb_progress: float = 0.0
var _ledge_start_pos: Vector3 = Vector3.ZERO
var _ledge_delay: float = 0.0
# Sync
var sync_state: PackedByteArray = PackedByteArray()
var _decoded_position: Vector3 = Vector3.ZERO
var _decoded_rotation_y: float = 0.0
var _decoded_mesh_rotation_y: float = 0.0
var _decoded_velocity: Vector3 = Vector3.ZERO
var _decoded_on_floor: bool = true
var _decoded_state: int = State.MOVEMENT
var _decoded_surface_up: Vector3 = Vector3.UP
var _last_sync_time: float = 0.0
var _last_synced_position: Vector3 = Vector3.ZERO
var _sync_velocity: Vector3 = Vector3.ZERO
const SYNC_INTERVAL: float = 0.05
const SNAP_DISTANCE: float = 5.0
# ---- STEP-UP (docs/player-step-up-design.md; user 2026-10-05) ----------------------
# The capsule (r 0.3, floor_max_angle 60°) rides an edge only up to ~0.15 m on its own,
# and our stairs rise 0.224 m a step, so every step was a wall (gate_step_up 6).
# walk band h <= step_up (16 units, 0.45 m — user 2026-10-05: "we need to be able to
# run up much higher, say 16 max"): the body is MOVED onto the tread — no
# velocity (the on-floor up-velocity clamp stays right), no airborne frame
# leap band step_up < h <= hop_max, RUNNING: a running leap launched BEFORE the riser
# at the run's own speed, harder for a taller step (user: "the character
# has to leap harder with every higher step so it needs to happen at a
# similar speed as a normal run") — _try_leap
# From (near) standstill: the single-leg crouch-hop (hop_crouch_s)
# above unchanged: jump / ledge grab
## Highest step walked up without leaving the floor (m). 0 = off.
@export var step_up: float = 0.45
## Highest step hopped up automatically when running into it (m). 0 = off.
@export var hop_max: float = 0.79
## Speed into the step needed to hop (m/s): brushing past a counter must not launch you.
@export var hop_min_speed: float = 1.0
## The lead leg's crouch before the push (s), at hop_max; shorter for lower steps.
@export var hop_crouch_s: float = 0.12
## How fast the drawn body catches up with a step (m/s): 3.6 = 6 cm a frame at 60 fps,
## above the ~3 m/s climb of running up our stairs.
@export var step_catchup_speed: float = 3.6
## Drawn-height offset (m) the model and camera start at after a sudden rise/drop
## while grounded; decays to 0 — a step reads as a step, not a teleport.
var _step_smooth: float = 0.0
var _hop_timer: float = 0.0 # > 0 while the lead leg crouches
var _hop: Dictionary = {} # {h, dir, land} for the pending push
var _step_hopping: bool = false # airborne part of a hop
var _hop_lead: int = 0 # 0 = left, 1 = right (alternates up a flight)
var step_stats := {"ups": 0, "downs": 0, "hops": 0}
# Internal
var _was_on_floor: bool = true
var _y_before_slide: float = 0.0
var _footstep_timer: float = 0.0
const FOOTSTEP_INTERVAL: float = 0.35
var _roll_face_angle: float = 0.0 ## Locked facing during roll
# Safety / recovery
## Auto-respawn floor, an ABSOLUTE Y. -200 is right for the hand-built maps,
## whose ground sits near zero. A GENERATED map declares its own vertical band
## and can sit far below that -- DC Erosion centres its 10.6 km of relief on
## zero (a physics constraint: Godot character physics degrades with ALTITUDE,
## measured in scenes/test/slope_travel_gate.gd), so its floor is -10900 and its
## valleys reach -5300. Against a fixed -200 every player standing in a valley
## would be auto-respawned where they stood.
##
## Footsteps played, and which foot each was (0 left, 1 right) — gate_footstep_rhythm.
var footstep_count: int = 0
var footstep_feet: Array[int] = []
## So it is resolved from the terrain's own floor_y, once, and left at the
## default when there is no generator to ask -- the built-in-generator maps,
## whose ground really is near zero.
const FALL_RESPAWN_Y_DEFAULT: float = -200.0
const FALL_RESPAWN_MARGIN: float = 500.0 ## below the floor, so the hard clip is never the trigger
var _fall_respawn_y: float = FALL_RESPAWN_Y_DEFAULT
var _fall_respawn_resolved: bool = false
const SAFETY_CHECK_INTERVAL: float = 0.25
var _safety_timer: float = 0.0
var _camera_stuck_time: float = 0.0
var _camera_unstick_pending_mask: int = -1 ## >=0 means restore mask next physics frame
# ---- Node References ----
@onready var model: Node3D = $Model
@onready var camera_head: Node3D = $CameraHead
@onready var camera: Camera3D = $CameraHead/CameraPivot/SpringArm3D/Camera3D
@onready var camera_pivot: Node3D = $CameraHead/CameraPivot
@onready var spring_arm: SpringArm3D = $CameraHead/CameraPivot/SpringArm3D
@onready var ground_ray: RayCast3D = $GroundRay
@onready var collision_standing: CollisionShape3D = $CollisionStanding
@onready var collision_crouching: CollisionShape3D = $CollisionCrouching
@onready var flashlight: OmniLight3D = $OmniLight3D
@onready var input_sync: Node = $InputSync
@onready var name_label: Label3D = $NameLabel
@onready var snd_jump: AudioStreamPlayer3D = $Sounds/Jump
@onready var snd_land: AudioStreamPlayer3D = $Sounds/Land
@onready var snd_footstep_1: AudioStreamPlayer3D = $Sounds/Footstep1
@onready var snd_footstep_2: AudioStreamPlayer3D = $Sounds/Footstep2
@onready var snd_footstep_3: AudioStreamPlayer3D = $Sounds/Footstep3
@onready var snd_light: AudioStreamPlayer3D = $Sounds/ToggleLight
var _footstep_sounds: Array[AudioStreamPlayer3D] = []
# ---- Procedural Animation ----
var _proc_animator: Node = null # ProceduralAnimator (SkeletonModifier3D)
func _ready() -> void:
_diag("[diag] player _ready: _dbg_model_on=%s env=%s authority=%s" % [str(_dbg_model_on), str(OS.has_environment("HTPS_MODELLOG")), str(is_multiplayer_authority())])
# Discoverable by other systems (CreatureSpawner, etc.) via group lookup —
# avoids hard NodePath refs that break when scene tree layout shifts (e.g.
# game.gd spawning the player under Players/<id> instead of map-relative).
add_to_group("player")
_footstep_sounds = [snd_footstep_1, snd_footstep_2, snd_footstep_3]
# Exclude own body from SpringArm3D collision
spring_arm.add_excluded_object(get_rid())
# Create ProceduralAnimator and attach to skeleton inside tumble model
var tumble_model: Node3D = $Model/TumbleModel
if tumble_model:
var skeleton: Skeleton3D = _find_child_of_type(tumble_model, "Skeleton3D") as Skeleton3D
if skeleton:
var animator: SkeletonModifier3D = ProceduralAnimatorScript.new()
animator.name = "ProceduralAnimator"
# Load pose library if it exists
var lib_path: String = "res://scenes/player/procedural/tumble_poses.tres"
if ResourceLoader.exists(lib_path):
animator.pose_lib = load(lib_path)
print("[PlayerProcedural] Loaded PoseLibrary from ", lib_path)
else:
push_warning("[PlayerProcedural] No PoseLibrary at ", lib_path, " — extract poses first")
skeleton.add_child(animator)
# Disable the AnimationPlayer so it doesn't fight us
var anim_player: AnimationPlayer = _find_child_of_type(tumble_model, "AnimationPlayer") as AnimationPlayer
if anim_player:
anim_player.active = false
_proc_animator = animator
# Forward exported tuning values
animator.hip_drop_max = hip_drop_max
animator.walk_stride_length = walk_stride_length
animator.run_stride_length = run_stride_length
animator.walk_speed_threshold = walk_speed_threshold
animator.run_speed_threshold = run_speed_threshold
animator.enable_foot_ik = enable_foot_ik
animator.enable_chest_ik = enable_chest_ik
animator.enable_head_ik = enable_head_ik
animator.fade_speed_jump = fade_speed_jump
animator.fade_speed_land = fade_speed_land
animator.fade_speed_fall = fade_speed_fall
animator.foot_planted.connect(_on_foot_planted)
animator.LAND_DURATION = land_duration
print("[PlayerProcedural] ProceduralAnimator attached to ", skeleton.get_path())
else:
push_warning("[PlayerProcedural] No Skeleton3D found in TumbleModel")
# Forward jump timing exports to controller vars
# pre_jump_delay and jump_threshold_time used directly from @export
_wall_climb = WallClimbClass.new()
_wall_climb.set_exclude(get_rid())
_grapple = GrappleSystemClass.new()
_grapple.max_range = grapple_max_range
_grapple.max_grapple_speed = grapple_max_speed
_grapple.reel_speed = grapple_reel_speed
_grapple.rope_damping = grapple_rope_damping
# Grapple rope visual
var rope_mesh_inst := MeshInstance3D.new()
rope_mesh_inst.name = "GrappleRopeVisual"
var rope_imm_mesh := ImmediateMesh.new()
rope_mesh_inst.mesh = rope_imm_mesh
var rope_mat := StandardMaterial3D.new()
rope_mat.albedo_color = Color(0.4, 0.35, 0.3) # Brown rope
rope_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
rope_mesh_inst.material_override = rope_mat
# Add as child with top_level so it doesn't rotate but gets freed with player
rope_mesh_inst.top_level = true
add_child(rope_mesh_inst)
_grapple_rope_visual = rope_mesh_inst
if is_local_player():
camera.current = true
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
name_label.visible = false
_setup_liquid_overlay()
else:
camera.current = false
_init_nameplate()
func is_local_player() -> bool:
return is_multiplayer_authority()
## -1 = never pushed, 0 = false, 1 = true. Only cross into the GDExtension on
## a CHANGE: _derive_terrain_lod_frozen() calls this every physics frame.
var _lod_frozen_applied: int = -1
func _set_terrain_lod_frozen(frozen: bool) -> void:
var want := 1 if frozen else 0
if want == _lod_frozen_applied:
return
if _dc_terrain == null:
# Find DCTerrain node once (has set_lod_frozen method from GDExtension)
_dc_terrain = _find_node_with_method(get_tree().root, "set_lod_frozen")
if _dc_terrain:
_dc_terrain.set_lod_frozen(frozen)
_lod_frozen_applied = want
## THE LOD FREEZE IS DERIVED, NOT LATCHED -- and that is a bug fix, not a tidy-up.
##
## lod_frozen_ gates the ENTIRE octree update (dc_terrain.cpp:690), so if it is
## ever left true the world stops refining and everything behind you keeps
## whatever LOD it had when the flag stuck. It used to be driven by twelve
## scattered calls -- three freeze sites (winch fire, grapple fire,
## _enter_wall_climb_on_surface) against nine unfreeze sites -- and one missed
## exit path out of any of those three states leaves it on for the rest of the
## session. Nine unfreeze sites is not thoroughness, it is the shape of a
## latch that has already been patched several times.
##
## A DERIVED FLAG CANNOT BE LOST. It is recomputed from `state` every physics
## frame, so however the player leaves WALL_CLIMB / GRAPPLE / GRAPPLE_WINCH --
## including paths nobody has written yet -- the freeze lifts on the next frame.
##
## Called at the TOP of _physics_process on purpose: _process_local has early
## returns, so anything at the bottom is exactly what a missed path skips.
## Using the previous frame's state costs one frame of lag on a signal that
## controls terrain refinement, which is invisible.
func _derive_terrain_lod_frozen() -> void:
_set_terrain_lod_frozen(
state == State.WALL_CLIMB
or state == State.GRAPPLE
or state == State.GRAPPLE_WINCH)
## Read the auto-respawn floor off the terrain's generator. Latches once the
## TERRAIN is found, not once this is first called: at the first safety check
## the map may still be loading, and latching then would pin the default for the
## rest of the session.
func _resolve_fall_respawn_y() -> void:
if _dc_terrain == null:
_dc_terrain = _find_node_with_method(get_tree().root, "set_lod_frozen")
if _dc_terrain == null:
return # no terrain yet -- try again on the next safety check
_fall_respawn_resolved = true
var gen: Variant = _dc_terrain.get("generator")
if gen == null or not gen.has_method("get_floor_y"):
return # built-in generator: the -200 default is correct
_fall_respawn_y = float(gen.get_floor_y()) - FALL_RESPAWN_MARGIN
DebugLog.log("game", "[player] auto-respawn floor from terrain floor_y: %.0f"
% _fall_respawn_y)
func _find_node_with_method(node: Node, method: String) -> Node:
if node.has_method(method):
return node
for child in node.get_children():
var found: Node = _find_node_with_method(child, method)
if found:
return found
return null
# ---- Input ----
func _unhandled_input(event: InputEvent) -> void:
if not is_local_player():
return
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
# Degrees per pixel from Settings (Controls tab; was the fixed MOUSE_SENSITIVITY 0.3);
# y is negative when "Invert mouse Y" is on.
var look: Vector2 = Settings.mouse_look_scale()
if state == State.WALL_CLIMB:
# Camera is top_level during climb (Fix 2): rotate forward vector
# around surface_up for yaw, pitch on pivot as normal.
var yaw_angle: float = deg_to_rad(-event.relative.x * look.x)
var yaw_rot: Basis = Basis(surface_up, yaw_angle)
_camera_climb_forward = (yaw_rot * _camera_climb_forward).normalized()
camera_pivot.rotate_x(deg_to_rad(-event.relative.y * look.y))
camera_pivot.rotation.x = clampf(camera_pivot.rotation.x, deg_to_rad(-89), deg_to_rad(89))
else:
camera_head.rotate_y(deg_to_rad(-event.relative.x * look.x))
camera_pivot.rotate_x(deg_to_rad(-event.relative.y * look.y))
camera_pivot.rotation.x = clampf(camera_pivot.rotation.x, deg_to_rad(-80), deg_to_rad(80))
if state == State.WALL_CLIMB:
# Camera is top_level — derive yaw from forward vector
input_sync.camera_rotation_y = atan2(-_camera_climb_forward.x, -_camera_climb_forward.z)
else:
input_sync.camera_rotation_y = camera_head.rotation.y
input_sync.camera_pitch = camera_pivot.rotation.x
# THE WHEEL BELONGS TO THE UI WHEN THERE IS A CURSOR. This was a raw
# InputEventMouseButton check with no context gate — exactly the shape the
# comment below describes for the winch — so in build mode's CURSOR sub-mode
# scrolling a panel also zoomed the camera behind it. During RMB-hold temporary
# look wants_pointer() is false, so the wheel zooms again, which is right: you
# are flying, not pointing.
if event is InputEventMouseButton and not GameMode.wants_pointer():
var mb := event as InputEventMouseButton
if mb.pressed:
# ALT + wheel while flying sets fly speed instead of zooming. Ctrl is
# crouch (descend in fly mode) and shift is sprint, so both are taken;
# without the modifier the wheel keeps zooming, which is intentional.
var alt_held: bool = Input.is_key_pressed(KEY_ALT)
# Inside the Shift+F fly modal the PLAIN wheel is speed (Blender's), no Alt.
if state == State.FLY and (alt_held or GameMode.is_fly_modal()):
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
fly_speed_scale = minf(fly_speed_scale * FLY_SCALE_STEP, FLY_SCALE_MAX)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
fly_speed_scale = maxf(fly_speed_scale / FLY_SCALE_STEP, FLY_SCALE_MIN)
if mb.button_index == MOUSE_BUTTON_WHEEL_UP or mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
print("[fly] speed x%.2f -> %.0f m/s walk, %.0f m/s sprint" % [
fly_speed_scale, fly_speed * fly_speed_scale,
fly_speed_fast * fly_speed_scale])
elif mb.button_index == MOUSE_BUTTON_WHEEL_UP:
spring_arm.spring_length = maxf(spring_arm.spring_length - ZOOM_STEP, ZOOM_MIN)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
spring_arm.spring_length = minf(spring_arm.spring_length + ZOOM_STEP, ZOOM_MAX)
# THROUGH THE KEYMAP, so the CONTEXT governs it. As a raw is_action_pressed this
# fired in build mode too — where `player` is suppressed and you have no body —
# so a left click while flying fired the winch, overwrote `velocity` and threw
# `state` out of FLY. That is "clicking left mouse button messes up flymode", and
# it is the same shape as Ctrl+Z toggling slow motion: an action check no context
# could see. Keymap.tapped consults the stack, so build mode simply never sees it.
if Keymap.tapped(event, "grapple"):
if _grapple.active or _winch_active:
# Release — carry momentum
if _winch_active:
_winch_active = false
_set_terrain_lod_frozen(false)
state = State.AIRBORNE
air_time = 0.0
else:
var carry_vel: Vector3 = _grapple.release()
_set_terrain_lod_frozen(false)
velocity = carry_vel
state = State.AIRBORNE
air_time = 0.0
else:
# Fire — raycast from camera crosshair into the world.
var space := get_world_3d().direct_space_state
var cam_dir: Vector3 = -camera.global_transform.basis.z
var right_vec: Vector3 = camera_head.global_transform.basis.x
var fire_origin: Vector3 = global_position + (-gravity_dir) * 1.2 + right_vec * 0.3
if grapple_mode == 1:
# Winch mode — simple raycast, no rope sim
var query := PhysicsRayQueryParameters3D.new()
query.from = fire_origin
query.to = fire_origin + cam_dir * grapple_max_range
query.exclude = [get_rid()]
query.collision_mask = 0xFFFFFFFF
var result: Dictionary = space.intersect_ray(query)
if not result.is_empty():
_winch_anchor = result["position"]
_winch_active = true
_set_terrain_lod_frozen(true)
state = State.GRAPPLE_WINCH
grapple_grace_timer = grapple_ground_grace
wall_run_active = false
_on_ledge = false
if _wall_climb and _wall_climb.active:
_wall_climb.stop()
# Immediate launch toward anchor
var to_anchor: Vector3 = (_winch_anchor - global_position).normalized()
velocity = to_anchor * winch_base_speed
if is_on_floor():
var winch_up: Vector3 = -gravity_dir
var winch_vert: float = velocity.dot(winch_up)
if winch_vert < winch_base_speed * 0.4:
velocity += winch_up * (winch_base_speed * 0.4 - winch_vert)
DebugLog.log("game", "[Winch] FIRED → anchor=%s dist=%.1f" % [_winch_anchor, global_position.distance_to(_winch_anchor)])
else:
DebugLog.log("game", "[Winch] MISS")
else:
# Rope mode — full rope simulation
if _grapple.fire(fire_origin, cam_dir, space, [get_rid()]):
_set_terrain_lod_frozen(true)
state = State.GRAPPLE
grapple_grace_timer = grapple_ground_grace
wall_run_active = false
_on_ledge = false
if _wall_climb and _wall_climb.active:
_wall_climb.stop()
var to_anchor: Vector3 = (_grapple.anchor_point - global_position).normalized()
var launch_vel: Vector3 = to_anchor * grapple_launch_speed
if is_on_floor():
var grapple_up: Vector3 = -gravity_dir
var vert_comp: float = launch_vel.dot(grapple_up)
if vert_comp < grapple_launch_speed * 0.5:
launch_vel += grapple_up * (grapple_launch_speed * 0.5 - vert_comp)
velocity = launch_vel
DebugLog.log("game", "[Grapple] FIRED → anchor=%s dist=%.1f" % [_grapple.anchor_point, fire_origin.distance_to(_grapple.anchor_point)])
else:
DebugLog.log("game", "[Grapple] MISS")
if Keymap.tapped(event, "toggle_light"):
_toggle_light.rpc()
if Keymap.tapped(event, "respawn"):
_respawn.rpc()
# Debug visual toggle. MOVED OFF K, which is the level editor's carve key — one
# press used to do both.
if Keymap.tapped(event, "player.climb_debug"):
_climb_debug_enabled = !_climb_debug_enabled
_wall_climb.debug_enabled = _climb_debug_enabled
if not _climb_debug_enabled:
# Clear debug visuals immediately
if _climb_debug_visual:
for child in _climb_debug_visual.get_children():
child.queue_free()
_wall_climb._clear_debug()
DebugLog.log("game", "Climb visuals: %s" % ("ON" if _climb_debug_enabled else "OFF"))
# Camera unstick (F8 key) — resets spring_arm to its zoom default and forces a
# collision re-evaluation. Use when the camera is jammed against geometry and
# refuses to extend back. Only addresses stale spring state — won't help if the
# camera origin (CameraHead/CameraPivot) is itself inside a wall.
if Keymap.tapped(event, "player.camera_unstick"):
var prev_mask: int = spring_arm.collision_mask
spring_arm.spring_length = 3.0
spring_arm.collision_mask = 0
await get_tree().physics_frame
spring_arm.collision_mask = prev_mask
DebugLog.log("game", "Camera unstuck (F8)")
# F10: DUMP EVERYTHING, ON DEMAND. Four conditional instruments in a row read
# nothing while the body was visibly wrong on screen, because each was gated on
# the mechanism I happened to suspect -- so each could only confirm a guess, and
# its silence was indistinguishable from the guess being wrong. This is gated on
# nothing except you pressing it at the moment you can see the problem.
if Keymap.tapped(event, "debug.player_dump"): # Ctrl+F10 (was bare F10)
_dump_state()
# Lizard mode debug toggle -- Ctrl+Shift+F6. Was a bare `keycode == KEY_L`,
# which is also builder.entity_links on the level map: one press did both.
if Keymap.tapped(event, "player.lizard_debug"):
_lizard_mode = !_lizard_mode
if _lizard_mode:
# Enter lizard walk on ground — use ground normal as surface
var sn: Vector3 = ground_normal if is_on_floor() else Vector3.UP
_enter_wall_climb_on_surface(sn)
_measure_skeleton_anatomy()
DebugLog.log("game", "[Lizard] ON — surface_up=%s" % sn)
else:
_exit_wall_climb()
state = State.MOVEMENT
ground_time = 0.0
DebugLog.log("game", "[Lizard] OFF")
if event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_VISIBLE:
# See player.gd: ask the mode, never name a panel.
if not GameMode.wants_pointer():
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# ---- Target Velocity (Overgrowth: GetTargetVelocity in playercontrol.as) ----
func _get_target_velocity() -> Vector3:
var input_dir: Vector2 = input_sync.input_direction
var local_up: Vector3 = -gravity_dir
var forward := -camera_head.global_transform.basis.z
# Project onto tangent plane (perpendicular to local up)
forward = (forward - local_up * forward.dot(local_up)).normalized()
var right := camera_head.global_transform.basis.x
right = (right - local_up * right.dot(local_up)).normalized()
var target_vel := forward * -input_dir.y + right * input_dir.x
# Overgrowth: walk key clamps to kWalkSpeed (0.2)
if walking:
if target_vel.length_squared() > WALK_SPEED * WALK_SPEED:
target_vel = target_vel.normalized() * WALK_SPEED
else:
target_vel = target_vel.limit_length(1.0)
return target_vel
# ---- Main Loop ----
func _physics_process(delta: float) -> void:
# Authoritative every frame; the scattered call sites below are now
# redundant and can only be wrong for at most one frame.
#
# ⚠ LOCAL PLAYER ONLY. _physics_process runs for REMOTE players too, and
# the terrain LOD is a property of THIS client's camera -- a remote peer
# entering wall-climb must not freeze our octree. The scattered calls were
# accidentally safe here only because remote players never take the input
# paths that reach them; a derived flag is evaluated for everyone, so the
# gate has to be explicit.
if is_local_player():
_derive_terrain_lod_frozen()
_process_local(delta)
else:
_process_remote(delta)
func _process_local(delta: float) -> void:
# Restore camera collision_mask after a one-frame auto-unstick.
if _camera_unstick_pending_mask >= 0:
spring_arm.collision_mask = _camera_unstick_pending_mask
_camera_unstick_pending_mask = -1
# Auto safety: fall-under-terrain respawn + auto camera-unstick.
_safety_timer += delta
if _safety_timer >= SAFETY_CHECK_INTERVAL:
_safety_timer = 0.0
# Fall-under-terrain → respawn
if not _fall_respawn_resolved:
_resolve_fall_respawn_y()
# ARRIVING: held at the arrival point, no gravity, no input, no fall-respawn,
# until the ground under it is real (see arrive()).
if _arrival_t >= 0.0:
_arrival_tick(delta)
return
if global_position.y < _fall_respawn_y:
DebugLog.log("game", "Auto-respawn: fell under terrain (y=%.1f < %.1f)" % [global_position.y, _fall_respawn_y])
_respawn.rpc()
return
# Camera stuck against geometry → drop collision for one physics frame so spring extends
if spring_arm.spring_length > 0.5:
var actual_dist: float = (camera.global_position - spring_arm.global_position).length()
if actual_dist < spring_arm.spring_length * 0.2:
_camera_stuck_time += SAFETY_CHECK_INTERVAL
if _camera_stuck_time >= 1.0:
_camera_stuck_time = 0.0
_camera_unstick_pending_mask = spring_arm.collision_mask
spring_arm.collision_mask = 0
else:
_camera_stuck_time = 0.0
var _dbg_pos_start: Vector3 = global_position
# Track input states
jump_held = Input.is_action_pressed("jump")
# Hold-to-ski: only while actually standing on something, so the timer cannot
# arm mid-flight and turn a landing into a slide.
if jump_held and is_on_floor():
_jump_hold_time += delta
else:
_jump_hold_time = 0.0
ski_holding = ski_hold_enabled and _jump_hold_time >= ski_hold_delay
sprinting = Input.is_action_pressed("sprint")
walking = Input.is_action_pressed("walk") if InputMap.has_action("walk") else false
# Pre-jump timer (Overgrowth: pre_jump_time countdown)
# Only fire jump if still in MOVEMENT state (prevents jump from ledge walk-off)
if pre_jump_timer > 0.0:
pre_jump_timer -= delta
if pre_jump_timer <= 0.0:
if state == State.MOVEMENT:
var flipping_now: bool = _proc_animator != null and _proc_animator.is_flipping()
if not flipping_now:
_execute_jump()
else:
pre_jump_timer = 0.0 # Cancel pre-jump if no longer on ground
# STEP HOP: the lead leg's crouch runs out -> push (cancelled if we left the ground).
if _hop_timer > 0.0:
_hop_timer -= delta
if _hop_timer <= 0.0:
if state == State.MOVEMENT and not _hop.is_empty():
_launch_hop()
else:
_hop = {}
# Flip/roll: suppress normal movement when active
var flipping: bool = _proc_animator != null and _proc_animator.is_flipping()
# Quake liquid swim (SV_WaterMove) overrides ground/air locomotion + gravity. Special
# states (fly/grapple/wall-climb) keep their own physics. Only the DISPATCH is swapped —
# `state` stays MOVEMENT/AIRBORNE so the post-match move_and_slide + sync write run normally.
_dbg_record()
_update_water_state()
var _disp_state := state
if _in_liquid and (state == State.MOVEMENT or state == State.AIRBORNE):
_disp_state = State.SWIM
# State dispatch
match _disp_state:
State.SWIM:
_apply_water_move(delta)
air_time += delta
State.MOVEMENT:
_update_ground_normal()
_update_surface_friction()
_update_duck(delta)
if not flipping:
_update_movement(delta)
_check_roll()
_check_jump()
ground_time += delta
# Roll velocity override (Overgrowth: UpdateRollVelocity with inertia blend)
if flipping and _proc_animator:
var roll_vel: Vector3 = _proc_animator.get_roll_velocity()
if roll_vel != Vector3.ZERO:
var frames: float = delta * 60.0
velocity.x = lerpf(roll_vel.x, velocity.x, pow(0.95, frames))
velocity.z = lerpf(roll_vel.z, velocity.z, pow(0.95, frames))
# Lock facing during roll
model.rotation.y = _roll_face_angle
_dbg_model("roll_face_lock")
State.AIRBORNE when _step_hopping:
# A STEP HOP is not a jump: no fuel, no double jump, no flip, no wall or ledge
# grabs — a ballistic arc onto the step, with the horizontal push HELD so the
# riser it rises against cannot slide it to a stop.
_apply_gravity(delta)
velocity = _hop_push + (-gravity_dir) * velocity.dot(-gravity_dir)
air_time += delta
State.AIRBORNE:
_update_jump_fuel(delta)
if not flipping:
_update_air_control(delta)
# A HANDSPRING OPENS OUT IN THE AIR: the crouch is otherwise frozen while
# airborne, so the roll's full duck (and its 0.45 m hip drop) rode the whole
# way over. The duck spring runs here only for it; FlipRoll asks for 0.
if _proc_animator and _proc_animator._flip_roll and _proc_animator._flip_roll.handspring:
_update_duck(delta)
_apply_gravity(delta)
_check_double_jump()
_check_air_flip()
_detect_walls()
_update_wall_run(delta)
_check_wall_jump()
_check_ledge_grab(delta)
air_time += delta
State.WALL_CLIMB:
_update_wall_climb_state(delta)
State.GRAPPLE:
_update_grapple(delta)
State.GRAPPLE_WINCH:
_update_winch(delta)
State.FLY:
_update_fly(delta)
# Ledge grab: freeze position while on ledge
if _on_ledge:
_update_ledge_hold(delta)
elif _ledge_climb_progress > 0.0:
_update_ledge_climb(delta)
else:
# Terminal velocity
if velocity.length() > TERMINAL_VELOCITY:
velocity *= TERMINAL_DAMPING
# Physics step (fly/grapple/winch handle their own move_and_slide)
if state != State.FLY and state != State.GRAPPLE and state != State.GRAPPLE_WINCH:
if state == State.MOVEMENT and not flipping:
if not _try_leap():
_try_step_up(delta)
var _pre_slide_xf := global_transform
var _pre_slide_v := velocity
_y_before_slide = velocity.dot(-gravity_dir)
var _blk_from: Vector3 = global_position
var _blk_want: Vector3 = velocity * delta
var _floor_n: Vector3 = get_floor_normal() if (state == State.MOVEMENT and is_on_floor()) else Vector3.ZERO
move_and_slide()
_record_block(delta, _blk_from, _blk_want)
_smooth_unexpected_rise(_blk_from, _floor_n)
if state == State.MOVEMENT and not flipping:
_step_after_slide(_pre_slide_xf, _pre_slide_v, delta)
_keep_step_speed()
# Fork A: Post-slide velocity recovery during wall climb.
# If move_and_slide consumed velocity (stuck at surface classification
# boundary), re-inject tangent velocity (Bug 5: threshold 0.3→0.5, injection 0.5→0.7).
if state == State.WALL_CLIMB and _last_desired_velocity.length_squared() > 0.01:
var post_speed: float = velocity.length()
var desired_speed: float = _last_desired_velocity.length()
if post_speed < desired_speed * 0.5:
var tangent: Vector3 = _last_desired_velocity - surface_up * _last_desired_velocity.dot(surface_up)
velocity += tangent * 0.7
# Bug 5: position nudge along tangent when stuck
if tangent.length_squared() > 0.01:
global_position += tangent.normalized() * 0.02
# State transitions after physics
match state:
State.MOVEMENT:
if not is_on_floor() and not _on_ledge and not _try_step_down():
ground_time = 0.0
state = State.AIRBORNE
wall_run_active = false
wall_run_time = 0.0
_has_hit_wall = false
State.AIRBORNE:
if is_on_floor():
_on_land()
State.GRAPPLE:
pass # Grapple handles its own transitions
State.GRAPPLE_WINCH:
pass # Winch handles its own transitions
State.FLY:
pass # Fly mode — no state transitions
# Surface alignment recovery: when NOT wall climbing, force default capsule orientation.
# Collision shape transforms are in parent-local space — only rotate during WALL_CLIMB
# where we control the parent orientation. In normal states (including spherical gravity
# where the parent body is rotated externally), keep default local transforms.
if state != State.WALL_CLIMB:
surface_up = -gravity_dir
_target_surface_up = surface_up
# Collision shapes stay in default parent-local orientation
collision_standing.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.79, 0))
collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))
update_model_orientation(delta, flipping)
_update_step_smooth(delta)
# Feed state to procedural animator
_update_proc_animator()
# Update grapple rope visual
_update_grapple_rope_visual()
# Footsteps: on the animator's foot_planted (_on_foot_planted), not a timer — a fixed
# 0.35 s gave ~2.9 steps/s at any speed (gate_footstep_rhythm: 18 steps at half speed
# where the stride makes 8.7).
_was_on_floor = is_on_floor()
# Debug: detect position teleport or NaN
if is_nan(global_position.x) or is_nan(global_position.y) or is_nan(global_position.z):
print("NaN POSITION! state=%s vel=%s gravity=%s" % [state, velocity, gravity_dir])
global_position = _dbg_pos_start
velocity = Vector3.ZERO
# Sync state
sync_state = _encode_sync_state(global_position, camera_head.rotation.y,
model.rotation.y, velocity, is_on_floor(), state, surface_up)
# ---- MOVEMENT State ----
## Orient the MODEL: upright normally, leaned toward the wall during a wall run,
## and turned toward the direction of travel once it is upright.
##
## Extracted from _process_local so a gate can drive it without also driving
## input, gravity and move_and_slide. scenes/test/wall_run_gate.gd measured a
## flat 0 degrees of tilt purely because it could not reach this code.
func update_model_orientation(delta: float, flipping: bool) -> void:
# Model rotation toward movement (Overgrowth: InterpDirections with pow(0.85, frames))
if not flipping and state != State.WALL_CLIMB: # Wall climb handles its own model orientation
# During a wall run the model leans toward the wall instead of standing
# upright. Only the MODEL: the collision capsule stays in its default
# parent-local orientation (the block above), because rotating the body
# would change what counts as a floor mid-run.
var local_up: Vector3 = _wall_run_model_up()
# ONE MECHANISM DURING A WALL RUN, AND A FORWARD THAT CANNOT INVERT.
#
# Two earlier attempts flipped the player. The second named its own cause:
# the model's forward followed wall_run_direction, computed as
# if (dot(dir, velocity) < 0) dir = -dir
# which inverts frame to frame when along-wall velocity passes through
# zero, spinning the model 180 degrees back and forth. bec6a02 removed the
# last use of that vector and it is deleted outright now.
#
# The forward here is the model's OWN current forward, projected onto the
# leaned plane. It is continuous by construction -- it cannot invert,
# because it is derived from where the model already points rather than
# from a velocity that can cross zero.
#
# And it is ONE mechanism: the branches below write `model.rotation.y`, a
# single local Euler component, which does not preserve a 45 degree roll
# and fights the slerp every frame. A wall run never reaches them.
if wall_run_active and wall_run_tilt > 0.0:
var wl_up: Vector3 = _wall_run_model_up()
# POINT ALONG THE WALL, NOT WHEREVER THE MODEL HAPPENED TO BE POINTING.
# This took the model's OWN forward and projected it onto the leaned plane,
# which is unanchored: with the up steady the target equals the current
# basis, so the facing keeps whatever it entered with and picks up a
# vertical component from the lean. Measured 30.3 degrees off the wall
# tangent, which reads as running up the wall at an angle.
#
# The anchor is the WALL TANGENT: horizontal, derived from the wall normal
# and gravity, so it is as stable as the wall is. Only the SIGN comes from
# velocity, and it is hysteretic -- a bare `if dot < 0: flip` inverts every
# time along-wall speed crosses zero, which spun the model 180 degrees back
# and forth in an earlier attempt (bec6a02). Anchoring to the velocity
# DIRECTION instead was tried too and was worse: on a faceted wall the
# velocity is deflected frame to frame and the facing chased the noise.
var up_w: Vector3 = -gravity_dir
var tangent: Vector3 = up_w.cross(wall_run_normal)
var wl_fwd: Vector3
if tangent.length_squared() > 1e-6:
tangent = tangent.normalized()
var along: float = tangent.dot(velocity)
# 0.0 means "not yet chosen for this run" -- take the first reading of
# any sign at all, rather than defaulting to +1 and waiting for the
# hysteresis threshold to overturn a direction that was never chosen.
if _wall_run_tangent_sign == 0.0:
if absf(along) > 0.01:
_wall_run_tangent_sign = 1.0 if along > 0.0 else -1.0
elif absf(along) > WALL_RUN_FACING_FLIP_SPEED:
_wall_run_tangent_sign = 1.0 if along > 0.0 else -1.0
tangent *= (_wall_run_tangent_sign if _wall_run_tangent_sign != 0.0 else 1.0)
# SMOOTHED, because the tangent inherits every wobble in the wall normal.
# Anchoring to the raw tangent is exact on a plane (0.0 deg off) and
# chases facets on anything else -- measured 11.4 deg off on a wall of
# +/- 3 degree segments, worse than the 3.1 the unanchored version got.
# This is a COSMETIC direction only: it feeds the model's facing, never
# contact or physics, so filtering it cannot cost grip the way rejecting
# wall_run_normal samples did.
# Snap rather than slerp when the target is behind us: a slerp between
# near-opposite vectors has no well-defined axis and crawls.
if _wall_run_tangent_s.length_squared() < 0.5 \
or _wall_run_tangent_s.dot(tangent) < -0.5:
_wall_run_tangent_s = tangent
else:
_wall_run_tangent_s = _wall_run_tangent_s.slerp(tangent,
clampf(WALL_RUN_TANGENT_SMOOTH * delta * 60.0, 0.0, 1.0)).normalized()
wl_fwd = _wall_run_tangent_s - wl_up * _wall_run_tangent_s.dot(wl_up)
else:
wl_fwd = -model.global_transform.basis.z
wl_fwd -= wl_up * wl_fwd.dot(wl_up)
if wl_fwd.length_squared() < 0.01:
wl_fwd = -camera_head.global_transform.basis.z
wl_fwd -= wl_up * wl_fwd.dot(wl_up)
if wl_fwd.length_squared() > 0.01:
_slerp_model_to(wl_up, wl_fwd.normalized(), delta)
return
# If model basis is tilted (post-wall-climb transition), slerp back to surface-upright
var model_up: Vector3 = model.global_transform.basis.y.normalized()
if model_up.angle_to(local_up) > 0.05:
# Slerp model basis toward surface-upright while preserving facing
var current_quat: Quaternion = model.global_transform.basis.orthonormalized().get_rotation_quaternion()
var model_fwd: Vector3 = -model.global_transform.basis.z
var flat_fwd: Vector3 = (model_fwd - local_up * model_fwd.dot(local_up))
if flat_fwd.length_squared() < 0.01:
flat_fwd = (-global_transform.basis.z - local_up * (-global_transform.basis.z).dot(local_up))
if flat_fwd.length_squared() < 0.01:
flat_fwd = Vector3.FORWARD
flat_fwd = flat_fwd.normalized()
var target_z: Vector3 = -flat_fwd
var target_x: Vector3 = local_up.cross(target_z)
if target_x.length_squared() < 0.001:
target_x = Vector3.RIGHT
target_x = target_x.normalized()
target_z = target_x.cross(local_up).normalized()
var target_basis: Basis = Basis(target_x, local_up, target_z)
var target_quat: Quaternion = target_basis.orthonormalized().get_rotation_quaternion()
var result_quat: Quaternion = current_quat.slerp(target_quat, clampf(MODEL_ALIGN_SPEED * delta, 0.0, 1.0))
model.global_transform.basis = Basis(result_quat)
_dbg_model("upright_recovery")
else:
# Turn toward movement velocity projected onto surface tangent plane
var local_vel: Vector3 = global_transform.basis.inverse() * velocity
if Vector3(local_vel.x, 0, local_vel.z).length() > 0.3:
var target_angle := atan2(-local_vel.x, -local_vel.z)
model.rotation.y = lerp_angle(model.rotation.y, target_angle, TURN_VELOCITY * delta)
_dbg_model("turn_toward_vel")
## Slerp the model toward the basis with `up` up and `fwd` forward. One place that
## builds a target basis, so the wall-run lean and the upright recovery cannot
## drift apart.
func _slerp_model_to(up: Vector3, fwd: Vector3, delta: float) -> void:
var target_z: Vector3 = -fwd
var target_x: Vector3 = up.cross(target_z)
if target_x.length_squared() < 0.001:
target_x = Vector3.RIGHT
target_x = target_x.normalized()
target_z = target_x.cross(up).normalized()
var target_quat: Quaternion = Basis(target_x, up, target_z).orthonormalized().get_rotation_quaternion()
var current_quat: Quaternion = model.global_transform.basis.orthonormalized().get_rotation_quaternion()
model.global_transform.basis = Basis(
current_quat.slerp(target_quat, clampf(MODEL_ALIGN_SPEED * delta, 0.0, 1.0)))
_dbg_model("slerp_model_to")
## The direction the MODEL treats as up. Vertical normally; during a wall run,
## tilted `wall_run_tilt` of the way toward the wall's outward normal.
##
## THE SIGN. `_detect_walls` stores `wall_normal_detected = -col.get_normal()`,
## and Godot's slide normal points OUT of the surface toward the body -- so
## `wall_run_normal` points INTO the wall and the OUTWARD normal is its negation.
## A wall runner's head leans AWAY from the wall while the feet press into it, so
## up tilts toward the outward normal. FootIK is handed the same vector (the
## animator), which is what makes the feet sweep toward the wall rather than
## looking for ground below.
func _wall_run_model_up() -> Vector3:
var up: Vector3 = -gravity_dir
var result: Vector3 = up
if wall_run_active and wall_run_tilt > 0.0 and wall_run_normal.length_squared() >= 0.01:
var outward: Vector3 = (-wall_run_normal).normalized()
var axis: Vector3 = up.cross(outward)
if axis.length_squared() >= 1e-6:
result = (Basis(axis.normalized(), up.angle_to(outward) * wall_run_tilt) * up).normalized()
# else: wall normal parallel to gravity — not a wall, stay upright
# Diagnostics, logged only on a transition or a large swing, so this is quiet
# in normal play and loud in exactly the case the gate cannot stage.
if wall_run_active != _wr_was_active:
DebugLog.log("game", "[wall-run] %s normal=%s target_up=%s"
% ["ENTER" if wall_run_active else "EXIT", str(wall_run_normal), str(result)])
_wr_was_active = wall_run_active
elif wall_run_active:
var swing: float = rad_to_deg(result.angle_to(_wr_last_up))
if swing > 15.0:
DebugLog.log("game", "[wall-run] target up SWUNG %.0f deg in one frame — "
% swing + "normal=%s up=%s" % [str(wall_run_normal), str(result)])
_wr_last_up = result
return result
func _update_ground_normal() -> void:
if ground_ray.is_colliding():
var contact_normal := ground_ray.get_collision_normal()
ground_normal = ground_normal * 0.9 + contact_normal * 0.1
elif is_on_floor():
ground_normal = get_floor_normal()
func _update_surface_friction() -> void:
surface_friction = 1.0
for i in get_slide_collision_count():
var col := get_slide_collision(i)
var collider := col.get_collider()
if collider is StaticBody3D and collider.physics_material_override:
surface_friction = collider.physics_material_override.friction
break
## Move along a contact surface: spend the traction budget on HOLDING against the
## fall line first, then on reaching `desired` with whatever is left.
##
## Shared by walking and wall running because they are the same problem. A wall is
## a slope at 90 degrees; the only difference is that a wall has no natural
## traction, so something has to grant it.
##
## Never scales velocity -- it clamps ACCELERATION, which is what conserves
## momentum (Torque3D Player::updateMove).
func _apply_surface_move(delta: float, surface_normal: Vector3,
desired: Vector3, traction: float) -> void:
# Gravity along the surface. On the flat this is exactly zero; on a wall it is
# the whole of gravity.
var g_along: Vector3 = gravity_dir * GRAVITY
g_along -= surface_normal * g_along.dot(surface_normal)
var budget: float = WALK_ACCEL * traction * delta
# HOLDING COMES FIRST. One clamped vector cannot both resist the fall line and
# reach a target speed; when the budget is near what gravity demands it
# alternates between them, which is the fight between traversing and sliding.
# Edging is the real answer: grip pays for not falling, and the remainder
# carries you along.
#
# APPLY THE NET, NEVER ADD-THEN-CANCEL. This used to be `velocity += g_along*dt`
# followed by `velocity += -g_along*dt * (spend/hold_need)`, which is the same
# number in algebra and NOT the same number in float: v + a - a leaves ~1e-7 of
# residue, sign essentially random.
#
# That residue was the downhill bounce. Godot skips floor snapping entirely
# when velocity.dot(up_direction) > 0 -- it reads any upward velocity as a jump
# -- so a residue of +6e-8 m/s, sixty nanometres per second, made move_and_slide
# return on_floor=false with zero collisions. MEASURED: every frame with a zero
# or negative residue kept the floor; the first positive one lost it. From there
# State.MOVEMENT stops running and the character bounces down the hill.
#
# When the feet can cover the whole demand the factor below is exactly 1.0 - 1.0
# = 0.0, so velocity is left bit-for-bit untouched. When they cannot, the
# remainder is what slides you -- the same physics, computed once.
var g_step: Vector3 = g_along * delta
var hold_need: float = g_step.length()
if hold_need > 0.0001:
var spend: float = minf(hold_need, budget)
velocity += g_step * (1.0 - spend / hold_need)
budget -= spend
else:
velocity += g_step
var run_acc: Vector3 = desired - velocity
run_acc -= surface_normal * run_acc.dot(surface_normal)
var need: float = run_acc.length()
if need > budget and need > 0.0001:
run_acc *= budget / need
velocity += run_acc
func _update_movement(delta: float) -> void:
# THE VELOCITY LAYER IS TRIBES, THE BODY LAYER IS OVERGROWTH.
#
# Overgrowth treats velocity as AUTHORED: you ask for a speed and the
# character is corrected toward it every frame, `horiz *= pow(E,
# -WALK_ACCEL*dt/max_speed)`. Momentum is an error to be removed. That is the
# right call for deliberate, contact-rich movement at human scale, and it is
# why Overgrowth can afford its body layer -- which we keep, unchanged.
#
# It is the wrong call for a 917 km map with 60 km peaks, and it was the real
# reason the player felt glued to the terrain. Every slope fix before this one
# -- the metabolic speed curve, the threshold retune, the ski weight -- was a
# patch on a model that destroys momentum by construction.
#
# Torque3D's Player::updateMove is the lineal descendant of the Tribes engine
# (Darkstar -> Tribes 2 -> Torque -> Torque3D, MIT). It has NO friction term
# at all:
#
# VectorF runAcc = pv - (mVelocity + acc); // desired minus current
# if (runSpeed > maxAcc) runAcc *= maxAcc / runSpeed; // clamp ACCEL
# acc += runAcc; // never scale velocity
#
# Input is an acceleration BUDGET, not a velocity target. Below
# horizResistSpeed (38 m/s) nothing resists you at all, and runSurfaceAngle is
# 80 degrees.
#
# THREE THINGS FALL OUT OF THE SWAP, and they are all deletions:
#
# - The slope branches dissolve. Overgrowth strips and redirects uphill
# input because a velocity-TARGET model produces targets the character
# cannot achieve on steep ground. With a budget, walking uphill simply
# means the budget cannot overcome gravity: you slow, stop, and slide.
# - The momentum ratchet goes. `max_speed = max(speed*0.98, max_speed)`
# existed to stop the friction term eating momentum it should not have.
# - SKIING IS EMERGENT. It was a bolted-on weight that suppressed friction;
# now it is simply what happens when traction approaches zero, which is
# what steep ground means. Same mechanic, no special case.
var target_dir := _get_target_velocity()
feet_moving = target_dir.length_squared() > 0.0
var up: Vector3 = -gravity_dir
var flat_n: Vector3 = ground_normal - up * ground_normal.dot(up)
var slope_sin: float = flat_n.length() # sin(ground angle)
# TRACTION: how much acceleration the feet can actually generate here. Full on
# the flat, fading to nothing as the ground approaches the limit the body
# itself calls a floor. This is the ONE place terrain enters the model, and it
# is physically what a slope does to you -- it does not cap your speed, it
# takes away your ability to push.
# THE THRESHOLD IS NOW EXACT AND NAMED, WHICH IT WAS NOT. The old curve was
# smoothstep(1 - sin(t)/sin(floor_max_angle)). It reached zero at
# floor_max_angle -- but holding against the fall line is paid FIRST out of this
# budget, so what was left to MOVE with ran out at 39.3 degrees. floor_max_angle
# said 60, the character stopped at 39, and no constant anywhere said 39.
#
# `hold_frac` is the grip needed just to stand. `margin` is what is available
# above that: +1 on the flat, exactly 0 at slide_angle, negative beyond. So the
# budget covers holding and nothing else precisely AT slide_angle, and past it
# cannot cover holding at all.
#
# NOT a Coulomb cone, deliberately. A real cone caps flat acceleration at mu*g
# and slides at atan(mu), so WALK_ACCEL = 35 m/s^2 would imply mu >= 3.57 and no
# sliding below 74 degrees. 35 is a responsiveness number, not a friction
# coefficient, so traction stays a grip FRACTION and slide_angle is set directly.
var slope_cos: float = sqrt(maxf(1.0 - slope_sin * slope_sin, 0.0001))
var hold_frac: float = GRAVITY * slope_sin / WALK_ACCEL
var margin: float = 1.0 - (slope_sin / slope_cos) / tan(clampf(slide_angle, 0.05, 1.5))
var traction: float = clampf(hold_frac + (1.0 - hold_frac) * margin, 0.0, 1.0)
# Ice and the like reduce what you can push against, rather than scaling the
# velocity you already have.
traction *= clampf(surface_friction, 0.0, 1.0)
# HOLD TO SKI. The same mechanism as steep ground -- take the grip away -- so
# there is one thing that makes you slide and two reasons it can happen.
if ski_holding:
traction = minf(traction, ski_hold_traction)
# The DESIRED velocity, along the contact plane so you walk across a slope
# rather than into it.
var speed: float = TRUE_MAX_SPEED if sprinting else run_speed
speed = lerpf(speed, speed * DUCK_SPEED_MULT, duck_amount)
if slope_climb_cost > 0.0 and slope_sin > 0.0:
var cs: float = sqrt(maxf(1.0 - slope_sin * slope_sin, 0.0001))
speed /= (1.0 + slope_climb_cost * (slope_sin / cs))
var desired: Vector3 = target_dir * speed
desired -= ground_normal * desired.dot(ground_normal)
max_speed = speed # reported, no longer a governor
# THE BUDGET'S OWN INPUTS, recorded where they are decided.
#
# The motion trace showed the character decelerating to a standstill with
# ZERO contacts and then unable to accelerate again -- so the thing stopping
# it is in here, not in the terrain. Which term is doing it cannot be read
# off velocity: traction is a product of slope, slide_angle, surface_friction
# and the ski hold, and any one of them at zero produces the same standstill.
_dbg_fresh = true
_dbg_traction = traction
_dbg_slope_sin = slope_sin
_dbg_hold_frac = hold_frac
_dbg_margin = margin
_dbg_desired = desired.length()
_dbg_input = target_dir.length()
_dbg_friction = surface_friction
_dbg_ski = ski_holding
_apply_surface_move(delta, ground_normal, desired, traction)
# NEVER HAND GODOT AN UPWARD VELOCITY WHILE STANDING ON SOMETHING.
# move_and_slide skips floor snapping entirely whenever
# velocity.dot(up_direction) > 0 -- it reads ANY upward velocity as a jump, and
# returns on_floor = false with zero collisions.
#
# The projections in _apply_surface_move are exact in algebra and not in float.
# `run_acc -= n * run_acc.dot(n)` should leave nothing in the normal direction
# and instead leaves ~1e-7. MEASURED on a 5 degree descent: every frame whose
# residue was zero or negative kept the floor, and the first frame it came out
# at +0.000000059605 lost it. Sixty nanometres per second, and from there
# State.MOVEMENT stops running -- no traction, no surface projection, no budget
# -- until the character lands. That is the whole downhill bounce, and it is why
# it appeared at slopes as gentle as 5 degrees and in no particular order.
#
# Clamping the up component costs nothing real: Godot slides it out itself while
# on the floor, so a positive value was never going to survive the move, and
# climbing works through Godot projecting the MOTION onto the floor plane rather
# than through velocity.y. What must NOT be touched is the horizontal content --
# projecting the whole velocity onto the contact plane instead compounds a
# factor of cos^2(t) per frame against the clamped budget and settles a 20
# degree descent at 2.6 m/s against a target of 7.5.
#
# _check_jump runs AFTER _update_movement, so jumps are unaffected.
if is_on_floor():
var up_v: float = velocity.dot(-gravity_dir)
if up_v > 0.0:
velocity -= (-gravity_dir) * up_v
# ---- AIRBORNE State ----
func _apply_gravity(delta: float) -> void:
if not is_on_floor() and not _on_ledge:
velocity += gravity_dir * GRAVITY * delta
func _update_jump_fuel(delta: float) -> void:
# Overgrowth exact: aircontrols.as UpdateAirControls
var local_up: Vector3 = -gravity_dir
var vertical_speed: float = velocity.dot(local_up)
if jump_held and jump_fuel_remaining > 0.0 and vertical_speed > 0.0:
jump_fuel_remaining -= JUMP_FUEL_BURN * delta
velocity += local_up * JUMP_FUEL_BURN * delta
elif not jump_held:
jump_fuel_remaining = 0.0
# Overgrowth: down_jetpack_fuel pushes down after apex
if down_jump_fuel > 0.0:
down_jump_fuel -= JUMP_FUEL_BURN * delta
velocity += gravity_dir * JUMP_FUEL_BURN * delta
func _update_air_control(delta: float) -> void:
if _on_ledge:
return
var frames: float = delta * 60.0
var target_vel := _get_target_velocity()
var has_input: bool = target_vel.length_squared() > 0.01
if has_input:
# Apply air control force on tangent plane
var local_up_air: Vector3 = -gravity_dir
velocity += target_vel * JUMP_AIR_CONTROL * delta
# Clamp horizontal speed so air control can't exceed cap
var vert_air: float = velocity.dot(local_up_air)
var horiz_air: Vector3 = velocity - local_up_air * vert_air
var h_speed: float = horiz_air.length()
if h_speed > AIR_SPEED_CAP:
horiz_air = horiz_air * (AIR_SPEED_CAP / h_speed)
velocity = horiz_air + local_up_air * vert_air
else:
# Gentle air deceleration when no input (preserves most momentum)
var local_up_air: Vector3 = -gravity_dir
var vert_air: float = velocity.dot(local_up_air)
var horiz_air: Vector3 = velocity - local_up_air * vert_air
var decel: float = pow(AIR_DECEL, frames)
horiz_air *= decel
velocity = horiz_air + local_up_air * vert_air
# ---- Wall Run (Overgrowth: aircontrols.as HitWall/UpdateWallRun/LostWallContact) ----
func _detect_walls() -> void:
## Detect walls via slide collisions OR shift-grab raycast
wall_contact = false
wall_normal_detected = Vector3.ZERO
wall_collider = null
# Method 1: Slide collision detection (passive — touching a wall)
for i in get_slide_collision_count():
var col := get_slide_collision(i)
var normal := col.get_normal()
if absf(normal.dot(up_direction)) < 0.3:
wall_contact = true
wall_normal_detected = -normal
wall_collider = col.get_collider()
break
# Method 2: Shift-grab sphere sweep (active — reach toward nearest wall)
if not wall_contact and Input.is_action_pressed("sprint"):
var space := get_world_3d().direct_space_state
var local_up: Vector3 = -gravity_dir
var origin := global_position + local_up * 1.0
var h_vel := velocity - local_up * velocity.dot(local_up)
var cast_dir: Vector3
if h_vel.length() > 0.5:
cast_dir = h_vel.normalized()
else:
cast_dir = -model.global_transform.basis.z
cast_dir.y = 0.0
cast_dir = cast_dir.normalized()
var dirs: Array[Vector3] = [cast_dir]
dirs.append(Vector3(-cast_dir.z, 0, cast_dir.x))
dirs.append(Vector3(cast_dir.z, 0, -cast_dir.x))
var excludes: Array[RID] = [get_rid()]
for d in dirs:
var result := SphereSweep.sweep(space, origin, origin + d * WALL_GRAB_RANGE, 0.2, excludes)
if not result.is_empty():
var n: Vector3 = result["normal"]
if absf(n.dot(up_direction)) < 0.5: # Wall-ish (allows overhangs up to ~60°)
wall_contact = true
wall_normal_detected = -n
break
# WHY THE WALL RUN DID OR DID NOT START. Three instruments in a row read
# nothing while the body was visibly wrong on screen -- no [wall-run], no
# [model], no [bones] -- which taken together say nothing is ROTATING anything,
# and the pose on screen is a FALL pose: horizontal, all four limbs splayed.
# That is what a player looks like sliding down a wall having never entered a
# wall run at all. So log the PRECONDITIONS rather than the consequences.
if OS.has_environment("HTPS_MODELLOG") and state == State.AIRBORNE and Engine.get_physics_frames() % 6 == 0:
_diag("[wr?] f=%d contact=%-5s sprint=%-5s hspd=%5.2f wr=%-5s climb_gate=%s anim=%d"
% [Engine.get_physics_frames(), str(wall_contact),
str(Input.is_action_pressed("sprint")), _horizontal_speed(),
str(wall_run_active), str(_horizontal_speed() < 3.0),
(_proc_animator._anim_state if _proc_animator != null else -1)])
# Transition to wall climbing if conditions met.
#
# NOT OUT OF A WALL RUN. A climb and a run are entered on the SAME conditions --
# wall contact, sprint held, airborne -- and the only thing telling them apart is
# horizontal speed under 3 m/s. Nothing used to exclude a run already in
# progress, so any wall run that decayed below 3 m/s silently became a climb, and
# the climb writes the model basis itself (Basis(_climb_model_quat)) to cling the
# body FLAT to the surface. That is the reported "wall run just spins the player
# perpendicular to the wall", and the screenshots of a player lying flat and
# upside-down on a wall face.
#
# MEASURED with every model-basis writer logged (HTPS_MODELLOG): the wall run
# starts at frame 72 with slerp_model_to writing and the lean growing correctly
# through 6 degrees; at frame 84 the state flips to WALL_CLIMB and wallclimb_quat
# becomes the only writer, tilt 42.7 and then a flat 90.0 for the rest of the
# run. Twelve frames -- 0.2 s -- of wall run. The same rig at 12 m/s never enters
# WALL_CLIMB at all, which is why every gate that held a steady speed passed.
#
# Slowing down during a run now ends it through _update_wall_run's own checks,
# which is what they are for. Approaching a wall slowly still climbs.
if wall_contact and Input.is_action_pressed("sprint") and state == State.AIRBORNE \
and not wall_run_active:
if _horizontal_speed() < 3.0:
_enter_wall_climb()
func _update_wall_run(delta: float) -> void:
if not wall_contact and not wall_run_active:
return
# Need shift held to start or maintain wall run
var grab_held: bool = Input.is_action_pressed("sprint")
if wall_contact and not wall_run_active and grab_held:
wall_run_active = true
_has_hit_wall = true
wall_run_time = 0.0
# EACH RUN STARTS ITS OWN FACING. The smoothed tangent and its sign are
# latched, and they used to survive from the previous wall run -- so running
# one way, dropping off, and running back the other way began with the OLD
# direction still held. Worse, slerping a vector toward its own opposite is
# ill-conditioned (the rotation axis is undefined), so it barely moved and
# the model kept facing the first way for the whole second run.
_wall_run_tangent_s = Vector3.ZERO
_wall_run_tangent_sign = 0.0
wall_run_normal = wall_normal_detected
return
if not wall_run_active:
return
# Release shift = drop off wall
if not grab_held:
_lost_wall_contact()
return
wall_run_time += delta
# Lose wall after falling too fast (but more generous with shift held)
if wall_run_time > 0.2 and velocity.dot(-gravity_dir) < WALL_RUN_FALL_LIMIT:
_lost_wall_contact()
return
if not wall_contact:
if wall_run_time > 0.25:
_lost_wall_contact()
return
wall_run_normal = wall_normal_detected
# wall_run_direction USED TO BE COMPUTED HERE, with
# if (dot(dir, velocity) < 0) dir = -dir
# which inverts frame to frame whenever along-wall velocity passes through
# zero. Nothing reads it any more (bec6a02 removed the last use), and leaving
# a sign-flipping vector lying around is how it got picked up twice.
# A WALL IS A SLOPE AT 90 DEGREES. It has no natural traction, so wall running
# is not a special case that cancels gravity -- it is something GRANTING the
# hold on a surface that has none, after which the ordinary surface move
# traverses it: hold against the fall line, move along with the remainder.
#
# What this replaces: 85% of gravity cancelled by hand, then
# velocity = wall_run_direction * max(along_wall, WALL_RUN_MIN_SPEED)
# + wall_up * vert_speed
# which OVERWROTE velocity, forced a minimum speed along the wall, and took
# its direction from wall_run_direction -- whose sign flips frame to frame
# when along-wall velocity passes through zero. That sign flip is what spun
# the model 180 degrees back and forth in two earlier attempts at the lean.
#
# The grant FADES WITH SPEED, which is physical rather than arbitrary: you can
# only run along a wall while you still have speed along it, which is both the
# real technique and the game convention. Lose the speed and traction goes
# with it, and you slide off exactly as a steep slope would drop you.
var wall_out: Vector3 = (-wall_run_normal).normalized()
var along: Vector3 = velocity - wall_out * velocity.dot(wall_out)
var along_speed: float = along.length()
var grant: float = wall_run_traction * clampf(along_speed / maxf(WALL_RUN_MIN_SPEED, 0.01), 0.0, 1.0)
# Where the player wants to go, on the wall's plane.
var wish: Vector3 = _get_target_velocity()
wish -= wall_out * wish.dot(wall_out)
var wall_desired: Vector3 = Vector3.ZERO
if wish.length_squared() > 0.0001:
wall_desired = wish.normalized() * (TRUE_MAX_SPEED if sprinting else run_speed)
_apply_surface_move(delta, wall_out, wall_desired, grant)
# PRESSING INTO THE WALL IS WHAT MAKES THE GRANT PHYSICAL: there is no traction
# on a vertical surface without a normal force. Scaled by the grant, so it
# fades with speed alongside it -- and it is also what keeps the contact alive
# for _detect_walls, which the old code did with a bare `+= into_wall * 2.0`.
velocity += wall_run_normal.normalized() * wall_run_press * grant * delta
func _lost_wall_contact() -> void:
## Overgrowth: LostWallContact.
##
## THE FACING IS NOT SNAPPED, BECAUSE THE VECTOR IT SNAPPED TO WAS NOT A FACING.
## This read `model.rotation.y = atan2(-_wall_run_facing.x, -_wall_run_facing.z)`
## where _wall_run_facing was captured on entry as `-wall_run_normal` -- a wall
## NORMAL, which points out of the wall and is therefore roughly PERPENDICULAR
## to the direction you are running. So every dropout turned the model about 90
## degrees, instantly, and on ground that makes contact flicker it dropped out
## and re-engaged over and over. That is the model spinning about its hip.
##
## MEASURED (wall_run_curve_gate): on a wall faceted +/- 3 degrees the ONLY
## frame that moves the facing more than 3 degrees is the frame wall_run_active
## goes false, and it moves 92.25 degrees. The lean itself is stable -- the flat
## control travels 0.0 degrees over the same run, which is why the flat gate
## never saw this.
##
## Nothing needs to replace it. The model keeps the facing it has, and
## update_model_orientation's upright-recovery branch slerps the lean out while
## PRESERVING that facing -- which is what "restore facing" was meant to do.
wall_run_active = false
func _check_wall_jump() -> void:
## Overgrowth: WantsToJumpOffWall → StartWallJump(wall_dir * -1)
if not wall_run_active:
return
if not input_sync.wants_jump:
return
# Jump away from wall (Overgrowth: velocity = GetJumpVelocity * 0.5 + original horizontal)
var away_dir := -wall_run_normal # away from wall
away_dir.y = 0.0
away_dir = away_dir.normalized()
# Half-strength jump + away velocity
var wall_up: Vector3 = -gravity_dir
velocity = away_dir * run_speed * 0.5
# Set vertical component along local up
velocity = velocity - wall_up * velocity.dot(wall_up) + wall_up * JUMP_INITIAL_VELOCITY
# Reset fuel for wall jump
jump_fuel_remaining = JUMP_FUEL
down_jump_fuel = JUMP_FUEL * 0.5
_has_double_jumped = false
wall_run_active = false
_has_hit_wall = false
_play_pool(snd_jump, JUMP_POOL, 1.0)
# ---- Ledge Grab (Overgrowth: ledgegrab.as CheckLedges) ----
func _check_ledge_grab(delta: float) -> void:
if _on_ledge:
return
if _ledge_delay > 0.0:
_ledge_delay -= delta
return
# Need shift held to grab ledges
if not Input.is_action_pressed("sprint"):
return
if velocity.dot(-gravity_dir) < LEDGE_GRAB_MAX_FALL_SPEED:
return
var flipping: bool = _proc_animator != null and _proc_animator.is_flipping()
if flipping:
return
var space := get_world_3d().direct_space_state
var local_up: Vector3 = -gravity_dir
var head_pos := global_position + local_up * 1.6
# Cast in multiple directions to find a ledge (forward + left/right)
var forward := -model.global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var right := Vector3(-forward.z, 0, forward.x)
var dirs: Array[Vector3] = [forward]
dirs.append((forward + right * 0.5).normalized())
dirs.append((forward - right * 0.5).normalized())
dirs.append(right)
dirs.append(-right)
var excludes: Array[RID] = [get_rid()]
for dir in dirs:
# Sphere sweep toward wall
var wall_result := SphereSweep.sweep(space, head_pos, head_pos + dir * LEDGE_GRAB_FORWARD, 0.15, excludes)
if wall_result.is_empty():
continue
# Check for surface above wall hit
var wall_hit_pos: Vector3 = wall_result["point"]
var above_wall: Vector3 = wall_hit_pos + local_up * LEDGE_GRAB_REACH
var top_result := SphereSweep.sweep(space, above_wall, above_wall - local_up * LEDGE_GRAB_REACH * 2.0, 0.1, excludes)
if top_result.is_empty():
continue
var top_normal: Vector3 = top_result["normal"]
if top_normal.dot(local_up) < 0.5:
continue
var top_pos: Vector3 = top_result["point"]
var ledge_height: float = top_pos.dot(local_up)
var feet_height: float = global_position.dot(local_up)
if ledge_height < feet_height - 0.5 or ledge_height > feet_height + 3.5:
continue
# Grab the ledge
_on_ledge = true
_ledge_point = top_pos
_ledge_normal = wall_result["normal"]
_ledge_climb_progress = 0.0
_ledge_start_pos = global_position
velocity = Vector3.ZERO
_has_hit_wall = false
wall_run_active = false
return
func _update_ledge_hold(delta: float) -> void:
## Hang on ledge — release shift or press crouch to drop, jump to climb
if not Input.is_action_pressed("sprint") or Input.is_action_just_pressed("crouch"):
_drop_from_ledge()
return
if input_sync.wants_jump:
_climb_ledge()
return
# Hold position at ledge (hands at ledge height, body below)
var hang_pos := _ledge_point + _ledge_normal * 0.3 - (-gravity_dir) * 1.4
global_position = global_position.lerp(hang_pos, 10.0 * delta)
velocity = Vector3.ZERO
# Face the wall
var face_dir := -_ledge_normal
face_dir.y = 0.0
face_dir = face_dir.normalized()
model.rotation.y = atan2(-face_dir.x, -face_dir.z)
_dbg_model("ledge_hang_face")
func _drop_from_ledge() -> void:
_on_ledge = false
_ledge_delay = 0.3 # Overgrowth: ledge_delay prevents re-grab
velocity = gravity_dir.normalized() # Small downward push
func _climb_ledge() -> void:
## Timed climb to top of ledge (0.35s lerp instead of instant teleport)
_on_ledge = false
_ledge_climb_progress = 0.001 # Start climb (>0 signals climbing)
_ledge_start_pos = global_position
_ledge_delay = 0.3
velocity = Vector3.ZERO
_has_hit_wall = false
# Disable collision during climb to prevent clipping on ledge edge
collision_standing.disabled = true
collision_crouching.disabled = true
func _update_ledge_climb(delta: float) -> void:
## Arc from hang position up and over the ledge (avoids clipping through terrain)
if _ledge_climb_progress <= 0.0 or _ledge_climb_progress >= 1.0:
return
_ledge_climb_progress += delta / 0.35 # 0.35s climb duration
var t: float = clampf(_ledge_climb_progress, 0.0, 1.0)
var ledge_up: Vector3 = -gravity_dir
var target_pos: Vector3 = _ledge_point + _ledge_normal * 0.5 + ledge_up * 0.1
# Two-phase arc: first go UP past the ledge, then move OVER to the top
# Phase 1 (t 0→0.5): rise vertically past ledge height + push away from wall
# Phase 2 (t 0.5→1): move horizontally onto ledge top
var ledge_top: Vector3 = _ledge_point + ledge_up * 0.6
# Project start pos onto tangent plane, then set height to ledge_top height along up
var start_tangent: Vector3 = _ledge_start_pos - ledge_up * _ledge_start_pos.dot(ledge_up)
var top_height_along_up: float = ledge_top.dot(ledge_up)
var normal_tangent: Vector3 = _ledge_normal - ledge_up * _ledge_normal.dot(ledge_up)
var rise_pos: Vector3 = start_tangent + normal_tangent * 0.3 + ledge_up * top_height_along_up
if t < 0.5:
# Rising phase
var phase_t: float = t / 0.5
global_position = _ledge_start_pos.lerp(rise_pos, phase_t)
else:
# Over phase
var phase_t: float = (t - 0.5) / 0.5
global_position = rise_pos.lerp(target_pos, phase_t)
velocity = Vector3.ZERO
if _ledge_climb_progress >= 1.0 or _ledge_climb_progress > 3.0:
# Complete (or safety timeout — 3x normal duration)
_ledge_climb_progress = 0.0
state = State.MOVEMENT
ground_time = 0.0
# Re-enable collision
collision_standing.disabled = false
collision_crouching.disabled = true
# ---- Wall Climb ----
func _enter_wall_climb() -> void:
# wall_normal_detected points INTO wall; negate for surface normal (away from wall)
_enter_wall_climb_on_surface(-wall_normal_detected)
## surface_normal: the normal pointing AWAY from the surface (same as wall_climb.wall_normal).
func _enter_wall_climb_on_surface(surface_normal: Vector3) -> void:
state = State.WALL_CLIMB
wall_run_active = false
_set_terrain_lod_frozen(true)
# Use crouching capsule during lizard mode — shorter capsule clips less on curves
collision_standing.disabled = true
collision_crouching.disabled = false
# Set target surface up for capsule reorientation
_target_surface_up = surface_normal.normalized()
# Allow any surface angle to count as floor during climb
floor_max_angle = PI
_climb_model_quat = model.transform.basis.orthonormalized().get_rotation_quaternion()
# Push exported grip layout to wall climb system
_wall_climb.set_grip_layout(lizard_hand_forward, lizard_hand_lateral, lizard_foot_back, lizard_foot_lateral)
# Reset collision to default local transforms (body rotation handles surface alignment)
collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))
_last_desired_velocity = Vector3.ZERO
# Camera Fix 2: detach from body rotation, track forward vector separately
var cam_fwd: Vector3 = -camera_head.global_transform.basis.z
_camera_climb_forward = (cam_fwd - _target_surface_up * cam_fwd.dot(_target_surface_up))
if _camera_climb_forward.length_squared() < 0.01:
_camera_climb_forward = Vector3.FORWARD
_camera_climb_forward = _camera_climb_forward.normalized()
# Body forward: init from model facing, project onto surface
var body_fwd: Vector3 = -model.global_transform.basis.z
_body_climb_forward = (body_fwd - _target_surface_up * body_fwd.dot(_target_surface_up))
if _body_climb_forward.length_squared() < 0.01:
_body_climb_forward = Vector3.FORWARD
_body_climb_forward = _body_climb_forward.normalized()
camera_head.top_level = true
# Snap camera position on entry (Bug 3: avoid separation)
camera_head.global_position = global_position + _target_surface_up * 0.5
# Start grip system (wall_normal = away from wall surface)
var attach_pos := global_position + surface_up * lizard_height
_wall_climb.start(attach_pos, surface_normal.normalized(), -gravity_dir)
velocity = Vector3.ZERO
# Debug visualization handled by _update_climb_debug_visual (K toggle)
_wall_climb.debug_enabled = _climb_debug_enabled
# Create grip surface particles
_create_grip_particles()
func _update_wall_climb_state(delta: float) -> void:
# Release sprint = fall (unless in lizard debug mode)
if not _lizard_mode and not Input.is_action_pressed("sprint"):
_exit_wall_climb()
return
# Jump = wall jump away
if input_sync.wants_jump:
var climb_up: Vector3 = -gravity_dir
var away: Vector3 = _wall_climb.wall_normal
away = (away - climb_up * away.dot(climb_up)).normalized()
velocity = away * run_speed * 0.5
velocity = velocity - climb_up * velocity.dot(climb_up) + climb_up * JUMP_INITIAL_VELOCITY
_exit_wall_climb()
state = State.AIRBORNE
_lizard_mode = false
_play_pool(snd_jump, JUMP_POOL, 1.0)
return
# Climb input: WASD relative to wall surface
var input_dir := Vector2.ZERO
if Input.is_action_pressed("move_left"):
input_dir.x -= 1.0
if Input.is_action_pressed("move_right"):
input_dir.x += 1.0
if Input.is_action_pressed("move_forward"):
input_dir.y += 1.0
if Input.is_action_pressed("move_back"):
input_dir.y -= 1.0
# Movement via move_and_slide (keeps collision detection working).
# Dynamic up_direction tracks the surface so walls/ceilings count as floor.
var move_dir: Vector3 = (_wall_climb.wall_right * input_dir.x + _wall_climb.wall_up * input_dir.y)
if move_dir.length_squared() > 0.01:
move_dir = move_dir.normalized()
velocity = velocity.lerp(move_dir * lizard_speed, 15.0 * delta)
# Stick force into surface (replaces gravity during climb)
velocity += -surface_up * STICK_FORCE * delta
# Dynamic up_direction = current surface normal
up_direction = surface_up
# Anti-stick settings for wall transitions
floor_block_on_wall = false
floor_snap_length = 0.5
# Save pre-slide velocity for post-slide recovery (Fork A)
_last_desired_velocity = velocity
# move_and_slide runs later in _process_local (line ~595)
# After physics: update surface normal from floor detection + raycasts
var space := get_world_3d().direct_space_state
if is_on_floor():
var floor_n: Vector3 = get_floor_normal()
if floor_n.length_squared() > 0.5:
_target_surface_up = floor_n
else:
# Not on floor — raycast to find surface
var ray_from: Vector3 = global_position + surface_up * 0.5
var ray_to: Vector3 = global_position - surface_up * 2.0
var ray_result: Dictionary = SphereSweep.sweep(space, ray_from, ray_to, 0.15, [get_rid()])
if not ray_result.is_empty():
_target_surface_up = ray_result["normal"]
else:
var grip_normal: Vector3 = _wall_climb.get_averaged_normal()
if grip_normal.length_squared() > 0.5:
_target_surface_up = grip_normal
# Predictive surface raycast (Fix 4): sweep 0.5m ahead in movement direction.
# Blends into _target_surface_up so body begins rotating before reaching transition.
var pred_vel: Vector3 = velocity - surface_up * velocity.dot(surface_up)
if pred_vel.length_squared() > 0.1:
var look_ahead: Vector3 = global_position + pred_vel.normalized() * 0.5
var pred_from: Vector3 = look_ahead + surface_up * 0.5
var pred_to: Vector3 = look_ahead - surface_up * 2.0
var pred_result: Dictionary = SphereSweep.sweep(space, pred_from, pred_to, 0.15, [get_rid()])
if not pred_result.is_empty():
var normal_change: float = _target_surface_up.angle_to(pred_result["normal"])
_target_surface_up = _target_surface_up.slerp(pred_result["normal"], 0.3)
# Bug 5: sharp edge — reduce snap to prevent old-surface pull-back
if normal_change > deg_to_rad(45.0):
floor_snap_length = 0.05
# Update body climb forward: re-project onto tangent plane, steer toward movement
_body_climb_forward = (_body_climb_forward - surface_up * _body_climb_forward.dot(surface_up))
if _body_climb_forward.length_squared() < 0.01:
_body_climb_forward = _wall_climb.wall_up if _wall_climb.wall_up.length_squared() > 0.01 else Vector3.FORWARD
_body_climb_forward = _body_climb_forward.normalized()
var body_move_dir: Vector3 = velocity - surface_up * velocity.dot(surface_up)
if body_move_dir.length_squared() > 0.1:
_body_climb_forward = _body_climb_forward.slerp(body_move_dir.normalized(), 4.0 * delta).normalized()
# Rotate CharacterBody3D itself to match surface (Fix 1)
_rotate_body_to_surface(delta)
# Sync grip system — use MODEL facing for grip placement
var model_facing: Vector3 = model.global_transform.basis.y
var surface_facing: Vector3 = (model_facing - surface_up * model_facing.dot(surface_up))
if surface_facing.length_squared() < 0.01:
surface_facing = _wall_climb.wall_up
surface_facing = surface_facing.normalized()
_wall_climb.set_grip_layout(lizard_hand_forward, lizard_hand_lateral, lizard_foot_back, lizard_foot_lateral)
_wall_climb.sync(global_position + surface_up * lizard_height, -gravity_dir, surface_facing)
# Update grips
var tangent_vel: Vector3 = velocity - surface_up * velocity.dot(surface_up)
var valid_grips: int = _wall_climb.update(space, delta, tangent_vel)
# Orient model to surface
_orient_model_to_surface(delta)
# Camera Fix 2: top_level camera follows body, oriented to surface.
# Re-project forward onto surface tangent (handles surface_up changes smoothly).
_camera_climb_forward = (_camera_climb_forward - surface_up * _camera_climb_forward.dot(surface_up))
if _camera_climb_forward.length_squared() < 0.01:
_camera_climb_forward = _wall_climb.wall_up if _wall_climb.wall_up.length_squared() > 0.01 else Vector3.FORWARD
_camera_climb_forward = _camera_climb_forward.normalized()
# Build camera basis: Y = surface_up, -Z = forward
var cam_right: Vector3 = _camera_climb_forward.cross(surface_up)
if cam_right.length_squared() < 0.001:
cam_right = Vector3.RIGHT
cam_right = cam_right.normalized()
_camera_climb_forward = surface_up.cross(cam_right).normalized()
camera_head.global_transform.basis = Basis(cam_right, surface_up, -_camera_climb_forward)
# Position: lerp to body + offset along surface normal (Bug 3: rate 10→20, snap if >2m)
var cam_target_pos: Vector3 = global_position + surface_up * 0.5
var cam_dist: float = camera_head.global_position.distance_to(cam_target_pos)
if cam_dist > 2.0:
camera_head.global_position = cam_target_pos
else:
camera_head.global_position = camera_head.global_position.lerp(cam_target_pos, 20.0 * delta)
# Update grip particles
_update_grip_particles()
# Debug visualization (K key toggle)
if _climb_debug_enabled:
_climb_debug_frame_skip += 1
if _climb_debug_frame_skip >= 3:
_climb_debug_frame_skip = 0
_update_climb_debug_visual()
# Fall if not enough grips (but not in lizard debug mode)
if valid_grips < 2 and not _lizard_mode:
_exit_wall_climb()
return
# Ledge check: only on actual walls, NEVER in lizard mode.
# Lizard mode crawls over edges — no ledge grab.
if not _lizard_mode:
var is_actual_wall: bool = _wall_climb.wall_normal.dot(-gravity_dir) < 0.7
if input_dir.y > 0.0 and is_actual_wall:
var above := _wall_climb.body_position + _wall_climb.wall_up * 1.0
var wall_above := SphereSweep.sweep(space,
above + _wall_climb.wall_normal * 0.5,
above - _wall_climb.wall_normal * 0.5,
0.15, [get_rid()])
if wall_above.is_empty():
_try_ledge_from_climb()
func _exit_wall_climb() -> void:
_wall_climb.stop()
_set_terrain_lod_frozen(false)
state = State.AIRBORNE
velocity = gravity_dir.normalized() # Small push in gravity direction
_has_hit_wall = false
# Snap model to upright immediately — no slerp from prone (causes shaking)
_target_surface_up = -gravity_dir
surface_up = -gravity_dir
up_direction = surface_up
# Capture model world-space facing before resetting body
var exit_up: Vector3 = -gravity_dir
var exit_model_fwd: Vector3 = -model.global_transform.basis.z
# Reset CharacterBody3D basis to upright (undo surface rotation)
global_transform.basis = Basis.IDENTITY
# Reset model to upright using captured facing
var flat_fwd: Vector3 = (exit_model_fwd - exit_up * exit_model_fwd.dot(exit_up))
if flat_fwd.length_squared() < 0.01:
flat_fwd = Vector3.FORWARD
flat_fwd = flat_fwd.normalized()
var exit_z: Vector3 = -flat_fwd
var exit_x: Vector3 = exit_up.cross(exit_z).normalized() # up × backward = RIGHT
exit_z = exit_x.cross(exit_up).normalized() # re-orthogonalize
model.transform.basis = Basis(exit_x, exit_up, exit_z).orthonormalized()
_dbg_model("wallclimb_exit")
model.position = Vector3.ZERO
_climb_model_quat = Quaternion.IDENTITY
# Restore floor properties and camera
floor_max_angle = DEFAULT_FLOOR_MAX_ANGLE
floor_snap_length = 0.1
floor_block_on_wall = true
# Camera Fix 2: restore as child, compute yaw from climb forward
camera_head.top_level = false
camera_head.position = Vector3(0, 1.5, 0)
var exit_cam_fwd: Vector3 = _camera_climb_forward
exit_cam_fwd.y = 0
if exit_cam_fwd.length_squared() > 0.01:
exit_cam_fwd = exit_cam_fwd.normalized()
camera_head.rotation = Vector3(0, atan2(-exit_cam_fwd.x, -exit_cam_fwd.z), 0)
else:
camera_head.rotation = Vector3.ZERO
# Restore collision
collision_standing.disabled = false
collision_crouching.disabled = true
collision_standing.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.79, 0))
collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))
# Clean up debug visual
if _climb_debug_visual:
for child in _climb_debug_visual.get_children():
child.queue_free()
# Clean up grip particles
_destroy_grip_particles()
## Rotate the CharacterBody3D itself so local Y = surface_up.
## This aligns the physics hull with the logical orientation — collision shapes
## stay at DEFAULT local transforms and the parent rotation handles surface alignment.
## Only called during WALL_CLIMB state.
func _rotate_body_to_surface(delta: float) -> void:
# Slerp surface_up toward target (world space)
var angle: float = surface_up.angle_to(_target_surface_up)
if angle > 0.001:
var t: float = clampf(SURFACE_ALIGN_SPEED * delta, 0.0, 1.0)
surface_up = surface_up.slerp(_target_surface_up, t).normalized()
# Set CharacterBody3D up_direction (world space)
up_direction = surface_up
# Build target basis for CharacterBody3D: local Y = surface_up.
# Use _body_climb_forward as forward reference (avoids wall_up degeneration at sphere equator).
var ref_fwd: Vector3 = _body_climb_forward
if ref_fwd.length_squared() < 0.01:
var grav_up: Vector3 = -gravity_dir
ref_fwd = (grav_up - surface_up * grav_up.dot(surface_up))
if ref_fwd.length_squared() < 0.01:
ref_fwd = Vector3.FORWARD
ref_fwd = ref_fwd.normalized()
# Orthogonalize: X = fwd x up, Z = X x up (Godot: -Z = forward)
var target_x: Vector3 = ref_fwd.cross(surface_up)
if target_x.length_squared() < 0.001:
target_x = Vector3.RIGHT
target_x = target_x.normalized()
var target_z: Vector3 = target_x.cross(surface_up).normalized()
var target_basis: Basis = Basis(target_x, surface_up, target_z)
# Slerp the CharacterBody3D's global basis toward target
var current_quat: Quaternion = global_transform.basis.orthonormalized().get_rotation_quaternion()
var target_quat: Quaternion = target_basis.orthonormalized().get_rotation_quaternion()
var body_t: float = clampf(SURFACE_ALIGN_SPEED * delta, 0.0, 1.0)
global_transform.basis = Basis(current_quat.slerp(target_quat, body_t))
# Collision shapes at DEFAULT local transforms — parent rotation handles alignment
collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))
## Orient model to lie flat on surface (prone/lizard pose).
## Also positions the model so skeleton midpoint aligns with grip body center.
## Sets model.transform (local) to avoid global/local conflicts.
func _orient_model_to_surface(delta: float) -> void:
# Facing = camera forward projected onto surface tangent plane
var cam_fwd: Vector3 = -camera_head.global_transform.basis.z
var facing: Vector3 = (cam_fwd - surface_up * cam_fwd.dot(surface_up))
if facing.length_squared() < 0.01:
facing = _wall_climb.wall_up if _wall_climb.wall_up.length_squared() > 0.01 else Vector3.FORWARD
facing = facing.normalized()
# Build prone basis in WORLD space:
# Z = surface_up (back faces up), Y = facing (head forward), X = right
var basis_z: Vector3 = surface_up
var basis_y: Vector3 = facing
var basis_x: Vector3 = basis_y.cross(basis_z)
if basis_x.length_squared() < 0.001:
basis_x = basis_y.cross(Vector3.UP)
if basis_x.length_squared() < 0.001:
basis_x = basis_y.cross(Vector3.RIGHT)
basis_x = basis_x.normalized()
basis_y = basis_z.cross(basis_x).normalized()
var target_world_basis: Basis = Basis(basis_x, basis_y, basis_z)
# Convert to parent-local basis
var parent_basis: Basis = global_transform.basis
var target_local_basis: Basis = parent_basis.inverse() * target_world_basis
# Slerp in local space
var current_quat: Quaternion = model.transform.basis.orthonormalized().get_rotation_quaternion()
var target_quat: Quaternion = target_local_basis.orthonormalized().get_rotation_quaternion()
var t: float = clampf(TURN_VELOCITY * delta, 0.0, 1.0)
_climb_model_quat = _climb_model_quat.slerp(target_quat, t)
# Store for use in _update_proc_animator — position is set there
model.transform.basis = Basis(_climb_model_quat)
_dbg_model("wallclimb_quat")
# Spider-style body positioning: model follows grip centroid.
# The grip centroid IS the center of the 4 feet — body is always centered.
# Offset along averaged surface normal for body clearance.
var grip_center: Vector3 = _wall_climb.get_grip_centroid()
var avg_normal: Vector3 = _wall_climb.get_averaged_normal()
# Position model at grip centroid + offset along surface normal + spine backward shift
var target_world: Vector3 = grip_center + avg_normal * lizard_height
# Shift backward along facing so skeleton midpoint (not hip) aligns with centroid
target_world -= _wall_climb.wall_up * lizard_back_offset
# Breathing bob
var dt: float = get_physics_process_delta_time()
var climb_speed: float = _horizontal_speed()
if climb_speed > 0.3:
_climb_bob_phase += dt * climb_speed * 4.0
target_world += avg_normal * sin(_climb_bob_phase) * 0.04
else:
_climb_bob_phase += dt * 0.8
target_world += avg_normal * sin(_climb_bob_phase) * 0.012
# Convert to parent-local and lerp smoothly
var target_local: Vector3 = global_transform.affine_inverse() * target_world
model.position = model.position.lerp(target_local, 10.0 * dt)
func _update_climb_debug_visual() -> void:
if _climb_debug_visual == null:
_climb_debug_visual = Node3D.new()
_climb_debug_visual.name = "ClimbDebugVisual"
_climb_debug_visual.top_level = true
add_child(_climb_debug_visual)
# Clear previous frame — use free() not queue_free() to prevent mesh accumulation
var children := _climb_debug_visual.get_children()
for i in range(children.size() - 1, -1, -1):
children[i].free()
# Capsule up direction (cyan line from player center)
_climb_debug_visual.add_child(_make_debug_line_node(
global_position, global_position + surface_up * 1.5, Color.CYAN))
# Target surface up (yellow line)
_climb_debug_visual.add_child(_make_debug_line_node(
global_position, global_position + _target_surface_up * 1.2, Color.YELLOW))
# Body position for grips (white sphere)
_climb_debug_visual.add_child(_make_debug_sphere_node(
_wall_climb.body_position, 0.08, Color.WHITE))
# Model/skeleton position (orange sphere — where the skeleton actually is)
var skel_world_pos: Vector3 = model.global_position
_climb_debug_visual.add_child(_make_debug_sphere_node(
skel_world_pos, 0.06, Color.ORANGE))
# Wall normal from body (magenta)
_climb_debug_visual.add_child(_make_debug_line_node(
_wall_climb.body_position,
_wall_climb.body_position + _wall_climb.wall_normal * 0.5, Color.MAGENTA))
# Grip IK targets (colored spheres + lines to body)
var colors: Array[Color] = [Color.RED, Color.BLUE, Color.YELLOW, Color.GREEN]
for i in range(4):
var gp: Vector3 = _wall_climb.grip_positions[i]
var valid: bool = _wall_climb.grip_valid[i]
var stepping: bool = _wall_climb.grip_stepping[i]
var c: Color = colors[i] if valid else Color(0.3, 0.3, 0.3)
# Current position (solid sphere)
_climb_debug_visual.add_child(_make_debug_sphere_node(gp, 0.06, c))
# Line from body to current grip
_climb_debug_visual.add_child(_make_debug_line_node(
_wall_climb.body_position, gp, Color(c, 0.4)))
# Planned target (wireframe-style smaller sphere + dashed line) — only when stepping
if stepping:
var target: Vector3 = _wall_climb.grip_targets[i]
_climb_debug_visual.add_child(_make_debug_sphere_node(target, 0.04, Color(c, 0.5)))
_climb_debug_visual.add_child(_make_debug_line_node(gp, target, Color(c, 0.3)))
# Ideal position (tiny white sphere — where the grip WANTS to be)
var ideal: Vector3 = _wall_climb.body_position \
+ _wall_climb.wall_right * _wall_climb._get_grip_offset(i).x \
+ _wall_climb.wall_up * _wall_climb._get_grip_offset(i).y
_climb_debug_visual.add_child(_make_debug_sphere_node(ideal, 0.025, Color(1, 1, 1, 0.3)))
# Capsule visualization (green wireframe showing active collision shape)
var active_col: CollisionShape3D = collision_crouching if collision_standing.disabled else collision_standing
var cap_shape: CapsuleShape3D = active_col.shape as CapsuleShape3D
if cap_shape:
var cap_world: Transform3D = global_transform * active_col.transform
_climb_debug_visual.add_child(_make_debug_capsule(
cap_world, cap_shape.radius, cap_shape.height, Color(0, 1, 0, 0.35)))
# Spine bone chain visualization (pink spheres connected by lines)
var tumble_model: Node3D = model.get_node_or_null("TumbleModel")
if tumble_model:
var skeleton: Skeleton3D = _find_child_of_type(tumble_model, "Skeleton3D") as Skeleton3D
if skeleton:
var gx: Transform3D = skeleton.global_transform
var spine_names: Array[String] = [
"DEF-spine", # hip
"DEF-spine.001", # spine
"DEF-spine.003", # chest
"DEF-spine.005", # neck
"DEF-spine.006", # head
]
var prev_pos: Vector3 = Vector3.ZERO
for j in range(spine_names.size()):
var idx: int = skeleton.find_bone(spine_names[j])
if idx < 0:
continue
var bone_world: Vector3 = (gx * skeleton.get_bone_global_pose(idx)).origin
var size: float = 0.04 if j > 0 and j < spine_names.size() - 1 else 0.05
_climb_debug_visual.add_child(_make_debug_sphere_node(
bone_world, size, Color(1, 0.4, 0.7))) # Pink
if j > 0 and prev_pos != Vector3.ZERO:
_climb_debug_visual.add_child(_make_debug_line_node(
prev_pos, bone_world, Color(1, 0.4, 0.7, 0.6)))
prev_pos = bone_world
func _make_debug_sphere_node(pos: Vector3, radius: float, color: Color) -> MeshInstance3D:
var mesh := SphereMesh.new()
mesh.radius = radius
mesh.height = radius * 2.0
mesh.radial_segments = 8
mesh.rings = 4
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.no_depth_test = true
mesh.material = mat
var inst := MeshInstance3D.new()
inst.mesh = mesh
inst.position = pos
return inst
func _make_debug_capsule(xform: Transform3D, radius: float, height: float, color: Color) -> MeshInstance3D:
var im := ImmediateMesh.new()
var half_h: float = height * 0.5 - radius # Half-height of cylinder section
var segments: int = 16
var rings: int = 8
im.surface_begin(Mesh.PRIMITIVE_LINES)
im.surface_set_color(color)
# Vertical lines along the cylinder
for i in range(segments):
var angle: float = float(i) / float(segments) * TAU
var x: float = cos(angle) * radius
var z: float = sin(angle) * radius
var top: Vector3 = xform * Vector3(x, half_h, z)
var bot: Vector3 = xform * Vector3(x, -half_h, z)
im.surface_add_vertex(top)
im.surface_add_vertex(bot)
# Horizontal rings at top, middle, bottom of cylinder
for ring_y in [-half_h, 0.0, half_h]:
for i in range(segments):
var a0: float = float(i) / float(segments) * TAU
var a1: float = float(i + 1) / float(segments) * TAU
var p0: Vector3 = xform * Vector3(cos(a0) * radius, ring_y, sin(a0) * radius)
var p1: Vector3 = xform * Vector3(cos(a1) * radius, ring_y, sin(a1) * radius)
im.surface_add_vertex(p0)
im.surface_add_vertex(p1)
# Top hemisphere arcs (2 perpendicular arcs)
for axis in range(2):
for i in range(rings):
var a0: float = float(i) / float(rings) * PI * 0.5
var a1: float = float(i + 1) / float(rings) * PI * 0.5
var p0: Vector3
var p1: Vector3
if axis == 0:
p0 = xform * Vector3(cos(a0) * radius, half_h + sin(a0) * radius, 0)
p1 = xform * Vector3(cos(a1) * radius, half_h + sin(a1) * radius, 0)
else:
p0 = xform * Vector3(0, half_h + sin(a0) * radius, cos(a0) * radius)
p1 = xform * Vector3(0, half_h + sin(a1) * radius, cos(a1) * radius)
im.surface_add_vertex(p0)
im.surface_add_vertex(p1)
# Mirror for the other side of the arc
if axis == 0:
p0 = xform * Vector3(-cos(a0) * radius, half_h + sin(a0) * radius, 0)
p1 = xform * Vector3(-cos(a1) * radius, half_h + sin(a1) * radius, 0)
else:
p0 = xform * Vector3(0, half_h + sin(a0) * radius, -cos(a0) * radius)
p1 = xform * Vector3(0, half_h + sin(a1) * radius, -cos(a1) * radius)
im.surface_add_vertex(p0)
im.surface_add_vertex(p1)
# Bottom hemisphere arcs
for axis in range(2):
for i in range(rings):
var a0: float = float(i) / float(rings) * PI * 0.5
var a1: float = float(i + 1) / float(rings) * PI * 0.5
if axis == 0:
var p0: Vector3 = xform * Vector3(cos(a0) * radius, -half_h - sin(a0) * radius, 0)
var p1: Vector3 = xform * Vector3(cos(a1) * radius, -half_h - sin(a1) * radius, 0)
im.surface_add_vertex(p0)
im.surface_add_vertex(p1)
p0 = xform * Vector3(-cos(a0) * radius, -half_h - sin(a0) * radius, 0)
p1 = xform * Vector3(-cos(a1) * radius, -half_h - sin(a1) * radius, 0)
im.surface_add_vertex(p0)
im.surface_add_vertex(p1)
else:
var p0: Vector3 = xform * Vector3(0, -half_h - sin(a0) * radius, cos(a0) * radius)
var p1: Vector3 = xform * Vector3(0, -half_h - sin(a1) * radius, cos(a1) * radius)
im.surface_add_vertex(p0)
im.surface_add_vertex(p1)
p0 = xform * Vector3(0, -half_h - sin(a0) * radius, -cos(a0) * radius)
p1 = xform * Vector3(0, -half_h - sin(a1) * radius, -cos(a1) * radius)
im.surface_add_vertex(p0)
im.surface_add_vertex(p1)
im.surface_end()
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.no_depth_test = true
mat.vertex_color_use_as_albedo = true
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
var inst := MeshInstance3D.new()
inst.mesh = im
inst.material_override = mat
return inst
func _make_debug_line_node(from: Vector3, to: Vector3, color: Color) -> MeshInstance3D:
var im := ImmediateMesh.new()
im.surface_begin(Mesh.PRIMITIVE_LINES)
im.surface_set_color(color)
im.surface_add_vertex(from)
im.surface_add_vertex(to)
im.surface_end()
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.no_depth_test = true
mat.vertex_color_use_as_albedo = true
var inst := MeshInstance3D.new()
inst.mesh = im
inst.material_override = mat
return inst
# ---- Grip Surface Particles ----
func _create_grip_particles() -> void:
_destroy_grip_particles()
var colors: Array[Color] = [Color.RED, Color.BLUE, Color.YELLOW, Color.GREEN]
# 0-3: Current grip positions (bright). 4-7: Predicted targets (dim).
for i in range(8):
var gi: int = i % 4
var is_target: bool = i >= 4
var p := CPUParticles3D.new()
p.name = "GripParticle_%d" % i
p.top_level = true
p.emitting = true
p.amount = 4 if is_target else 6
p.lifetime = 0.8 if is_target else 0.6
p.one_shot = false
p.explosiveness = 0.0
p.spread = 20.0 if is_target else 15.0
p.gravity = Vector3.ZERO
p.initial_velocity_min = 0.05 if is_target else 0.1
p.initial_velocity_max = 0.15 if is_target else 0.3
p.direction = Vector3.UP
p.scale_amount_min = 0.02 if is_target else 0.03
p.scale_amount_max = 0.04 if is_target else 0.06
p.color = Color(colors[gi], 0.4) if is_target else colors[gi]
add_child(p)
_grip_particles.append(p)
func _update_grip_particles() -> void:
for i in range(mini(_grip_particles.size(), 8)):
if i < 4:
# Current grip positions (bright, larger)
var p: CPUParticles3D = _grip_particles[i]
if _wall_climb.grip_valid[i]:
p.global_position = _wall_climb.grip_positions[i]
p.direction = _wall_climb.grip_normals[i]
p.emitting = true
else:
p.emitting = false
else:
# Predicted target positions (dimmer, smaller)
var gi: int = i - 4
var p: CPUParticles3D = _grip_particles[i]
if _wall_climb.grip_valid[gi]:
p.global_position = _wall_climb.grip_targets[gi]
p.direction = _wall_climb.grip_normals[gi]
p.emitting = true
else:
p.emitting = false
func _destroy_grip_particles() -> void:
for p in _grip_particles:
if is_instance_valid(p):
p.free()
_grip_particles.clear()
func _try_ledge_from_climb() -> void:
var space := get_world_3d().direct_space_state
var climb_up: Vector3 = -gravity_dir
var top := _wall_climb.body_position + _wall_climb.wall_up * 1.2
var down_result := SphereSweep.sweep(space,
top + climb_up * 0.5, top - climb_up * 1.0, 0.1, [get_rid()])
if not down_result.is_empty() and down_result["normal"].dot(climb_up) > 0.5:
_on_ledge = true
_ledge_point = down_result["point"]
_ledge_normal = -_wall_climb.wall_normal
_ledge_climb_progress = 0.0
_ledge_start_pos = global_position
_wall_climb.stop()
state = State.AIRBORNE
velocity = Vector3.ZERO
# ---- Grapple ----
func _update_fly(delta: float) -> void:
# NO PERMANENT WASD (keymap step 1, docs/keymap-blender-279.md §2a): the build camera
# moves ONLY while flying — RMB held, or the Shift+F fly modal. The rest of the time
# W A S D, Space, Ctrl, Shift are free for commands (the `fly` context, exclusive,
# is what makes that safe: no command fires while you fly).
var km := get_node_or_null("/root/Keymap")
if km != null and not km.flying():
velocity = Vector3.ZERO
return
var input_dir: Vector2 = input_sync.input_direction
var forward := -camera_head.global_transform.basis.z
var right := camera_head.global_transform.basis.x
var move_dir: Vector3 = (forward * -input_dir.y + right * input_dir.x).normalized()
var mods: bool = km == null or km.fly_modifiers_allowed()
var spd: float = (fly_speed_fast if (sprinting and mods) else fly_speed) * fly_speed_scale
var fly_vel: Vector3 = move_dir * spd
# Up / down: Blender's E / Q inside the Shift+F modal (Space there is CONFIRM);
# Space / Ctrl while RMB-looking, as before.
var modal: bool = GameMode.is_fly_modal()
var up: bool = (km != null and km.held("fly.up")) if modal else (mods and jump_held)
var down: bool = (km != null and km.held("fly.down")) if modal else (mods and Input.is_action_pressed("crouch"))
if up:
fly_vel += -gravity_dir * spd
elif down:
fly_vel += gravity_dir * spd
# Direct position update — no collision in fly mode
var pos_before: Vector3 = global_position
global_position += fly_vel * delta
velocity = Vector3.ZERO # Prevent accumulation
# Debug: detect teleportation
# Threshold scales with the speed actually commanded. A fixed 100 m fires on
# every frame once fly_speed_scale is above ~2, which turns a teleport
# detector into log spam and hides the thing it exists to catch.
if pos_before.distance_to(global_position) > maxf(100.0, spd * delta * 4.0):
print("FLY TELEPORT! before=%s after=%s vel=%s" % [pos_before, global_position, fly_vel])
func _update_grapple(delta: float) -> void:
if not _grapple.active:
_set_terrain_lod_frozen(false)
state = State.AIRBORNE
return
# Tick grace timer
if grapple_grace_timer > 0.0:
grapple_grace_timer -= delta
# Apply gravity
velocity += gravity_dir * GRAVITY * delta
# Get rope constraint force and apply it
var reel_in: bool = Input.is_action_pressed("sprint")
var reel_out: bool = Input.is_action_pressed("crouch")
var rope_force: Vector3 = _grapple.update(delta, global_position, velocity, reel_in, reel_out)
# Check if entity death caused auto-release during update
if not _grapple.active:
# rope_force is actually the release velocity from entity death
velocity = rope_force
_set_terrain_lod_frozen(false)
state = State.AIRBORNE
return
velocity += rope_force * delta
# Direct pull force toward anchor when reeling in (winch behavior)
if reel_in:
var to_anchor: Vector3 = (_grapple.anchor_point - global_position).normalized()
velocity += to_anchor * grapple_pull_force * delta
# Cap speed
if velocity.length() > _grapple.max_grapple_speed:
velocity = velocity.normalized() * _grapple.max_grapple_speed
# Reduce collision cost during grapple — disable floor snap and limit slides
var saved_snap: float = floor_snap_length
var saved_slides: int = max_slides
floor_snap_length = 0.0
max_slides = 2
move_and_slide()
floor_snap_length = saved_snap
max_slides = saved_slides
# If we hit the ground while swinging, transition to movement
# (skip during grace period so ground-fire works)
if is_on_floor() and grapple_grace_timer <= 0.0:
_grapple.release()
_set_terrain_lod_frozen(false)
_on_land()
func _update_winch(delta: float) -> void:
if not _winch_active:
state = State.AIRBORNE
return
# Tick grace timer
if grapple_grace_timer > 0.0:
grapple_grace_timer -= delta
# Direction to anchor
var to_anchor: Vector3 = _winch_anchor - global_position
var dist: float = to_anchor.length()
# Auto-release when close enough
if dist < winch_arrive_dist:
_winch_active = false
_set_terrain_lod_frozen(false)
state = State.AIRBORNE
return
var dir: Vector3 = to_anchor / dist # normalized
# Choose pull speed based on input
var pull_speed: float = winch_base_speed
if Input.is_action_pressed("sprint"):
pull_speed = winch_fast_speed
elif Input.is_action_pressed("crouch"):
pull_speed = winch_slow_speed
# Gravity accumulates — creates arc sag below straight line to anchor
velocity += gravity_dir * GRAVITY * winch_gravity_scale * delta
# Steer velocity toward anchor without overriding gravity.
# Blend current velocity toward pull direction — like a steering force.
var target_vel: Vector3 = dir * pull_speed
velocity = velocity.lerp(target_vel, 2.0 * delta)
# Cap speed
if velocity.length() > grapple_max_speed:
velocity = velocity.normalized() * grapple_max_speed
# Move — reduce collision cost during winch
var saved_snap: float = floor_snap_length
var saved_slides: int = max_slides
floor_snap_length = 0.0
max_slides = 2
move_and_slide()
floor_snap_length = saved_snap
max_slides = saved_slides
# Ground contact releases (after grace period)
if is_on_floor() and grapple_grace_timer <= 0.0:
_winch_active = false
_set_terrain_lod_frozen(false)
_on_land()
# ---- Jump ----
func _check_jump() -> void:
# Overgrowth: WantsToJump && on_ground_time > kjump_threshold_time && !pre_jump
# Holding jump means SKI, not jump. The tap has already fired by the time this
# latches (pre_jump_delay 0.08 < ski_hold_delay 0.15), so a tap is unaffected.
# JUMP DURING A ROLL IS A HANDSPRING (user 2026-10-05) — before the ski hold and the
# pre-jump crouch: the roll IS the wind-up, so it launches now.
if input_sync.wants_jump and _proc_animator and _proc_animator.can_handspring():
_start_handspring()
return
if ski_holding:
return
if input_sync.wants_jump and ground_time >= jump_threshold_time and pre_jump_timer <= 0.0:
pre_jump_timer = pre_jump_delay
# Overgrowth: duck_vel = 30 * (1 - duck_amount * 0.6) for jump anticipation
duck_vel = 30.0 * (1.0 - duck_amount * 0.6)
target_duck_amount = 0.5
func _check_roll() -> void:
if not _proc_animator:
return
# Overgrowth: WantsToRoll() = GetInputPressed("crouch") — single press starts roll
# If already rolling and crouch still held, chain another when current finishes
if Input.is_action_just_pressed("crouch") and _horizontal_speed() > 1.0:
if not _proc_animator.is_flipping():
var move_dir := Vector3(velocity.x, 0, velocity.z).normalized()
_roll_face_angle = model.rotation.y # Lock facing at roll start
_proc_animator.start_roll(move_dir)
elif Input.is_action_pressed("crouch") and _horizontal_speed() > 1.0:
# Chain rolls: if current roll finished and crouch still held, start another
if not _proc_animator.is_flipping():
var move_dir := Vector3(velocity.x, 0, velocity.z).normalized()
_roll_face_angle = model.rotation.y
_proc_animator.start_roll(move_dir)
func _check_double_jump() -> void:
## Our addition (not in Overgrowth): double-jump with velocity boost
if not _proc_animator:
return
if _has_double_jumped:
return
if _proc_animator.is_flipping():
return
if not input_sync.wants_jump:
return
if _on_ledge:
return
_has_double_jumped = true
var dj_up: Vector3 = -gravity_dir
var facing := -model.global_transform.basis.z
facing = (facing - dj_up * facing.dot(dj_up)).normalized()
_proc_animator.start_flip(facing)
# Upward velocity boost (slightly weaker than first jump)
var dj_vert: float = velocity.dot(dj_up)
velocity = velocity - dj_up * dj_vert + dj_up * JUMP_INITIAL_VELOCITY * 0.8
_play_varied(snd_jump, 1.0)
func _check_air_flip() -> void:
## Overgrowth: WantsToFlip() in air → flip without velocity boost
## (separate from double jump — crouch in air = flip)
if not _proc_animator:
return
if _proc_animator.is_flipping():
return
if _on_ledge:
return
if Input.is_action_just_pressed("crouch"):
var facing := -model.global_transform.basis.z
facing.y = 0.0
facing = facing.normalized()
_proc_animator.start_flip(facing)
## Upward launch of a handspring (m/s): about a metre up and ~0.9 s in the air at 9.8,
## long enough for the turn to finish before the feet come down.
const HANDSPRING_UP := 4.5
## Opening kick on the duck spring at launch (duck units per second).
const HANDSPRING_OPEN := 12.0
## The roll's forward speed, kept, plus a hop; the turn carries on in the animator.
## No jump fuel (it is not a held jump) and no double jump out of it.
func _start_handspring() -> void:
if not _proc_animator.start_handspring():
return
var up: Vector3 = -gravity_dir
var hv: Vector3 = velocity - up * velocity.dot(up)
velocity = hv + up * HANDSPRING_UP
# THE PUSH-OFF OPENS THE BODY: the duck spring alone takes ~10 frames to come out of
# the roll's full crouch, and the body is upside down ~4 frames after launch — so it
# went over the top still tucked. Kick it open, as the jump's anticipation kicks it.
duck_vel = -HANDSPRING_OPEN
pre_jump_timer = 0.0
jump_fuel_remaining = 0.0
down_jump_fuel = 0.0
state = State.AIRBORNE
ground_time = 0.0
air_time = 0.0
_has_double_jumped = true
_has_hit_wall = false
_play_pool(snd_jump, JUMP_POOL, 1.0)
func _execute_jump() -> void:
# Overgrowth: GetJumpVelocity adjusts for slope
var target_vel := _get_target_velocity()
var jump_vel := _get_jump_velocity(target_vel, ground_normal)
velocity = jump_vel
jump_fuel_remaining = JUMP_FUEL
down_jump_fuel = JUMP_FUEL * 0.5
state = State.AIRBORNE
ground_time = 0.0
air_time = 0.0
_has_double_jumped = false
_has_hit_wall = false
_play_pool(snd_jump, JUMP_POOL, 1.0)
func _get_jump_velocity(target_vel: Vector3, gnd_normal: Vector3) -> Vector3:
## Overgrowth exact: GetJumpVelocity (aircontrols.as:387-420)
## Adjusts jump direction based on ground slope
var local_up: Vector3 = -gravity_dir
# Project target_vel onto tangent plane and add upward component
var tangent_vel: Vector3 = target_vel - local_up * target_vel.dot(local_up)
var jump_vel := tangent_vel * run_speed + local_up * JUMP_INITIAL_VELOCITY
var jump_dir := jump_vel.normalized()
if jump_dir.dot(gnd_normal) < 0.3:
# On steep slope: reconstruct jump in ground space
var ground_up := gnd_normal
var ground_front := target_vel
if ground_front.length_squared() < 0.001:
ground_front = local_up.cross(Vector3(1, 0, 0))
if ground_front.length_squared() < 0.001:
ground_front = local_up.cross(Vector3(0, 0, 1))
ground_front = ground_front.normalized()
var ground_right := ground_up.cross(ground_front)
ground_right = (ground_right - local_up * ground_right.dot(local_up)).normalized()
ground_front = ground_right.cross(ground_up).normalized()
ground_up = ground_front.cross(ground_right).normalized()
# Reconstruct in ground space
jump_vel = ground_right * 0.0 \
+ ground_up * JUMP_INITIAL_VELOCITY \
+ ground_front * target_vel.length() * run_speed
# Clamp upward component
var up_speed: float = jump_vel.dot(local_up)
if up_speed > JUMP_INITIAL_VELOCITY:
jump_vel += local_up * (JUMP_INITIAL_VELOCITY - up_speed)
return jump_vel
func _on_land() -> void:
state = State.MOVEMENT
wall_run_active = false
wall_run_time = 0.0
jump_fuel_remaining = 0.0
down_jump_fuel = 0.0
_has_double_jumped = false
_has_hit_wall = false
# Fall damage check (Overgrowth: shock > 30 → damage = (shock-30) * 0.1)
var shock := absf(_y_before_slide)
if shock > SHOCK_DAMAGE_THRESHOLD:
var _damage := (shock - SHOCK_DAMAGE_THRESHOLD) * SHOCK_DAMAGE_MULTIPLIER
# TODO: apply to health system, trigger ragdoll on high damage
# Overgrowth Land() lines 8429-8431: feed impact into crouch spring
# duck_amount = 1.0; duck_vel = land_speed * 0.3
# We scale duck_amount by impact speed: small hops = partial, big falls = full crouch
var land_speed: float = clampf(-_y_before_slide, 2.0, 30.0)
if land_speed > 3.0:
# Overgrowth always sets duck_amount=1.0, we scale: full at land_speed≥8
duck_amount = clampf(land_speed / 8.0, duck_amount, 1.0)
duck_vel = land_speed * 0.4 # Stronger downward spring impulse
target_duck_amount = 0.0 # Spring bounces back to standing
air_time = 0.0
# A STEP HOP lands as a step, not a fall: no squash, no landing sound.
if _step_hopping:
_step_hopping = false
duck_amount = minf(duck_amount, 0.2)
duck_vel = 0.0
return
# Landing sound + particles
if not _was_on_floor:
_play_pool(snd_land, LAND_POOL, 1.0)
if _y_before_slide < -17.0:
var particles := LandingParticles.instantiate()
get_tree().current_scene.add_child(particles)
particles.global_position = global_position
# ---- STEP-UP (docs/player-step-up-design.md) -----------------------------------
#
# The classic stair-step move (Quake / Source StepMove), done with test_move so nothing
# is moved until the whole step is known to work: lift, carry forward, drop onto the
# tread. Run only when this frame's horizontal motion is BLOCKED by something steeper
# than floor_max_angle — a slope is move_and_slide's business.
## Forward carry at the lifted height, at least this much (m). With a 0.3 m capsule the
## drop then meets a tread edge at <= ~53°, which counts as floor (60°); a slower step
## (a walk at 1.6 m/s moves 2.7 cm a frame) would meet it at 65° and be refused.
const STEP_PROBE_MIN := 0.06
## How far onto the top a hop is aimed past the edge it found (m).
const HOP_LAND_IN := 0.25
## Clearance a hop's apex has over the step (m).
const HOP_CLEARANCE := 0.12
## Hops this close together alternate legs (a flight); otherwise the gait phase decides.
const HOP_FLIGHT_S := 1.0
var _hop_push: Vector3 = Vector3.ZERO
## Why the last step attempt did or did not step (gates and probes read it).
var step_last := ""
## The horizontal velocity a step-up was taken at (ZERO = no step this frame).
var _step_keep_hv: Vector3 = Vector3.ZERO
## THE STEP, AFTER THE FACT (Quake/Source StepMove: try the move, and if a wall ate it,
## try it stepped). The pre-check (_try_step_up) tests only this frame's horizontal
## motion, and the real slide — with its vertical part and iterations — still hit risers
## it had called "clear": 0 m/s at every riser of 0.45 m-tread stairs (gate_step_up 11,
## 2026-10-05). If the slide lost most of the speed against something steeper than a
## floor whose top is within step_up, redo the move as a step from where it started.
func _step_after_slide(pre_xf: Transform3D, pre_v: Vector3, delta: float) -> void:
if step_up <= 0.0 or _step_keep_hv != Vector3.ZERO or _hop_timer > 0.0 or pre_jump_timer > 0.0:
return
var up := -gravity_dir
var pre_hv := pre_v - up * pre_v.dot(up)
var now_hv := velocity - up * velocity.dot(up)
if pre_hv.length() < 1.0 or now_hv.length() > pre_hv.length() * 0.6:
return
var dir := pre_hv.normalized()
var contact := Vector3.INF
for i in get_slide_collision_count():
var col := get_slide_collision(i)
if col.get_normal().dot(up) < cos(floor_max_angle) - 0.01 and col.get_normal().dot(dir) < -0.3:
contact = col.get_position()
break
if contact == Vector3.INF:
return
var top := _obstacle_top_at(up, contact + dir * 0.03)
if top < 0.02 or top > step_up + STEP_BAND_TOL:
return
var reach := ((contact - pre_xf.origin) - up * (contact - pre_xf.origin).dot(up)).length()
var carry := maxf(pre_hv.length() * delta, reach - 0.3 + 0.07)
var s := _step_probe(pre_xf, up, step_up, dir * maxf(carry, STEP_PROBE_MIN))
step_last = "after-slide top %.3f %s" % [top, "FAILED" if s.is_empty() else "rise %.3f" % float(s["rise"])]
if s.is_empty() or float(s["rise"]) <= 0.02:
return
var rise: float = float(s["rise"]) - (global_position - pre_xf.origin).dot(up)
global_position = s["pos"]
_step_smooth -= rise
step_stats["ups"] = int(step_stats["ups"]) + 1
_steps_recent += 1.0
_step_keep_hv = pre_hv
## A STEP KEEPS THE RUN'S SPEED (Quake's StepMove keeps the stepped move's velocity).
## The step lands the capsule on the tread's EDGE, and that frame's slide against the
## corner stripped the speed: the run re-accelerated from ~0.6 m/s on every step
## (_probe_stairs_feet, 2026-10-05). Restored after the slide, horizontal only, while
## grounded — the slide's vertical part (and the next frame's up-clamp) is unchanged.
func _keep_step_speed() -> void:
if _step_keep_hv == Vector3.ZERO:
return
var keep := _step_keep_hv
_step_keep_hv = Vector3.ZERO
if not is_on_floor():
return
var up := -gravity_dir
velocity = keep + up * velocity.dot(up)
var _remote_hop: bool = false
var _remote_was_grounded: bool = true
var _last_hop_t: float = -10.0
func _try_step_up(delta: float) -> void:
if step_up <= 0.0 or _hop_timer > 0.0 or pre_jump_timer > 0.0:
return
var up := -gravity_dir
var hv := velocity - up * velocity.dot(up)
var speed := hv.length()
if speed < 0.05:
return
var dir := hv / speed
var motion := hv * delta
var xf := global_transform
var c := KinematicCollision3D.new()
# FROM 2 cm UP: from the feet the first contact reported was often the tread UNDER
# the capsule (normal ~(0, 0.99, 0.13)), read as "a slope, the slide will climb it"
# — while the riser ahead then stopped the run dead (step_last trace, 2026-10-05).
# 2 cm clears what we stand on; any riser taller than that still blocks.
if not test_move(xf.translated(up * 0.02), motion, c):
step_last = "clear"
return
var n := c.get_normal()
if n.dot(up) >= cos(floor_max_angle) - 0.01:
step_last = "slope n=%s" % str(n.snapped(Vector3.ONE * 0.01))
return # a climbable slope or an edge it rides
# THE BAND IS DECIDED BY THE OBSTACLE'S TOP, measured — not by where the walk probe
# lands: the capsule's round bottom rests on the tread EDGE a few cm low and then
# rides the rest, so a 0.45 m ledge "probed" at 0.39 and was walked up (no hop).
# AT THE POINT HIT, just past the riser — not a fixed distance ahead: 0.41 m ahead is
# past a 0.30 m tread, onto the step AFTER the one in the way, so ordinary stairs
# read as 0.448 m, nothing stepped, and the riser stopped the run dead every step
# (user 2026-10-05: "player stair step is all wrong"; _probe_stairs_feet: 2.24 m/s
# on the stairs against 8.00 on the flat, 0 m/s at each riser).
var top := _obstacle_top_at(up, c.get_position() + dir * 0.03)
step_last = "top %.3f n=%s at %s" % [top, str(n.snapped(Vector3.ONE * 0.01)), str(c.get_position().snapped(Vector3.ONE * 0.01))]
if top > hop_max + 0.01:
return # too tall: jump / ledge grab as before
if top <= step_up + STEP_BAND_TOL:
# CARRY PAST THE CONTACT, not just this frame's motion: with the riser 4 cm ahead a
# 7 cm carry left the capsule's axis 0.27 m short of the tread edge, the drop met
# the corner at 64° (steeper than the 60° floor) and the step was REFUSED — the
# riser then stopped the run dead, every step (step_last trace, 2026-10-05).
# travel + 0.07 puts the axis within ~0.23 m of the edge: a walkable corner.
var carry := maxf(motion.length(), c.get_travel().length() + 0.07)
var s := _step_probe(xf, up, step_up, dir * maxf(carry, STEP_PROBE_MIN))
step_last += " walk-probe %s" % ("FAILED" if s.is_empty() else "rise %.3f" % float(s["rise"]))
if not s.is_empty():
var rise: float = s["rise"]
if rise > 0.02:
global_position = s["pos"]
_step_smooth -= rise
step_stats["ups"] = int(step_stats["ups"]) + 1
_steps_recent += 1.0
_step_keep_hv = hv
return
# Too tall to walk up: a HOP, if we are actually going INTO it.
if hop_max <= step_up:
return
var face := n - up * n.dot(up)
if face.length() < 1.0e-3 or hv.dot(-face.normalized()) < hop_min_speed:
return
var s2 := _step_probe(xf, up, hop_max + 0.02, dir * maxf(motion.length(), 0.35))
if s2.is_empty():
return
var h: float = s2["rise"]
if h <= step_up or h > hop_max + 0.01:
return
_begin_hop(h, dir, s2["pos"])
## Height (above the feet) of the top of whatever is under `point` (a spot just past the
## face that blocked us), or -1 if there is nothing there, or INF if it is taller than
## the ray reaches (the ray starts inside it).
func _obstacle_top_at(up: Vector3, point: Vector3, from_h: float = -1.0) -> float:
var space := get_world_3d().direct_space_state
# Down through `point`, from above the hop band (or `from_h` over the feet — the
# SECOND riser of a flight is a whole step higher, and a ray starting inside it
# reads INF), to just under the feet.
var at := point - up * (point - global_position).dot(up)
var start_h := from_h if from_h > 0.0 else hop_max + 0.1
var q := PhysicsRayQueryParameters3D.create(at + up * start_h, at - up * 0.05)
q.exclude = [get_rid()]
q.hit_from_inside = true
var hit := space.intersect_ray(q)
if hit.is_empty():
return -1.0
if (hit["normal"] as Vector3) == Vector3.ZERO:
return INF
return ((hit["position"] as Vector3) - global_position).dot(up)
## Lift by `lift_h` (less under a low ceiling), carry `fwd`, drop onto a walkable top.
## {pos, rise} of the resting place, or {} if any leg of it fails.
func _step_probe(xf: Transform3D, up: Vector3, lift_h: float, fwd: Vector3) -> Dictionary:
var c := KinematicCollision3D.new()
var lift := up * lift_h
if test_move(xf, lift, c):
lift = c.get_travel()
var x2 := xf.translated(lift)
if test_move(x2, fwd, c):
return {}
var x3 := x2.translated(fwd)
if not test_move(x3, -up * (lift.length() + 0.05), c):
return {}
if c.get_normal().dot(up) < cos(floor_max_angle):
return {}
var land: Vector3 = x3.origin + c.get_travel()
return {"pos": land, "rise": (land - xf.origin).dot(up)}
## THE RUNNING LEAP (user 2026-10-05: "a leaping run up higher inclines instead of the
## not effective current jump ... it needs to happen at a similar speed as a normal
## run"). The crouch-hop STOPPED at the riser, crouched and pushed at the 1-2 m/s that
## reached the top. Running, a step in the leap band is seen AHEAD (a test_move along
## the run, as far as a leap could be launched from) and the leap leaves the ground
## BEFORE the riser, at the run's own horizontal speed, with exactly the up-speed that
## step needs: sqrt(2 g (h + clearance)) — a taller step, a harder leap, the same pace.
## The horizontal speed is held through the air (the airborne hop branch), so it lands
## running and the next leap of a flight follows straight on.
##
## LAUNCH POINT: the capsule reaches the riser after d/speed seconds; it must by then be
## at least as high as the top (after t1, the first crossing of `top`) and still rising
## (before the apex). It leaves at the first frame d <= speed * (t1 + t_apex) / 2 — the
## middle of that window. Too close already (a late sighting) still leaves: the held
## push slides it up the riser and over.
const LEAP_MIN_SPEED := 3.0
## Up a flight, each leap stays above the line of nosings this long (s) — one running
## stride; it lands a tread or two up and leaps again.
const LEAP_INCLINE_T := 0.25
## The hardest leap (m/s up). A jump leaves at 12, so remote derivation (1-10 m/s off
## the ground) still tells them apart.
const LEAP_MAX_UP := 9.0
## The walk band's measuring tolerance: the feet rest a few mm into the floor, so a
## 16-unit (0.448 m) step measured 0.456 and was leapt instead of walked.
const STEP_BAND_TOL := 0.02
func _try_leap() -> bool:
if hop_max <= step_up or _hop_timer > 0.0 or pre_jump_timer > 0.0 or _step_hopping:
return false
var up := -gravity_dir
var hv := velocity - up * velocity.dot(up)
var speed := hv.length()
if speed < LEAP_MIN_SPEED:
return false
var dir := hv / speed
var v_max := sqrt(2.0 * GRAVITY * (hop_max + HOP_CLEARANCE))
var look := speed * v_max / GRAVITY + 0.05
var c := KinematicCollision3D.new()
if not test_move(global_transform.translated(up * 0.02), dir * look, c):
return false
var n := c.get_normal()
if n.dot(up) >= cos(floor_max_angle) - 0.01 or n.dot(dir) > -0.3:
return false
var d := c.get_travel().length()
var top := _obstacle_top_at(up, c.get_position() + dir * 0.03)
if top <= step_up + STEP_BAND_TOL or top > hop_max + 0.01:
return false
# AN INCLINE, or one step? A second riser past this one makes a flight: at run speed
# no leap can land tread by tread on steep steps (a 0.6 m leap at 8 m/s carries
# ~3.4 m and hit the NEXT riser), so the leap is sized to the SLOPE instead: clear
# this nosing, then stay above the line of nosings for LEAP_INCLINE_T — it lands on
# whichever tread that brings, still running, and the next leap follows.
var slope := 0.0
var c2 := KinematicCollision3D.new()
var past := global_transform.translated(up * (top + 0.05)).translated(dir * (d + 0.05))
var seen := "none"
if test_move(past, dir * look, c2):
seen = "n=%s" % str(c2.get_normal().snapped(Vector3.ONE * 0.01))
if c2.get_normal().dot(up) < cos(floor_max_angle) - 0.01:
var top2 := _obstacle_top_at(up, c2.get_position() + dir * 0.03, top + hop_max + 0.1)
var d2 := d + 0.05 + c2.get_travel().length()
seen += " top2 %.2f d2 %.2f" % [top2, d2]
if top2 > top + 0.05 and top2 - top <= hop_max + 0.01 and d2 > d + 0.1:
slope = (top2 - top) / (d2 - d)
var v_up := sqrt(2.0 * GRAVITY * (top + HOP_CLEARANCE))
if slope > 0.05:
var v := slope * speed + GRAVITY * LEAP_INCLINE_T * 0.5 + 1.0
for _i in 3: # t* depends on v: iterate
var ts := _first_crossing(v, top + 0.05)
v = slope * speed + GRAVITY * (ts + LEAP_INCLINE_T * 0.5)
v_up = clampf(maxf(v, v_up), v_up, LEAP_MAX_UP)
# THE LATEST CLEAN LAUNCH: leave when the riser is exactly as far as the run covers
# while rising to its top — never earlier (an early leap overshoots into the next
# riser); closer than that (a landing right at a riser) leaves at once, and the held
# push slides it up the face and over.
var t_star := _first_crossing(v_up, top + 0.05)
if d > speed * t_star:
step_last = "leap: %.2f m to a %.2f m step (slope %.2f), not yet" % [d, top, slope]
return false
# Room to land: the top, a capsule past the riser, has standing space.
if _step_probe(global_transform, up, top + 0.05, dir * (d + 0.35)).is_empty():
step_last = "leap: no room on the %.2f m step" % top
return false
_pick_lead()
_hop_push = hv
velocity = hv + up * v_up
state = State.AIRBORNE
_step_hopping = true
ground_time = 0.0
air_time = 0.0
jump_fuel_remaining = 0.0
down_jump_fuel = 0.0
step_stats["hops"] = int(step_stats["hops"]) + 1
step_last = "leap: %.2f m step from %.2f m at %.1f m/s, up %.2f m/s (slope %.2f; next %s)" % [top, d, speed, v_up, slope, seen]
return true
## Seconds until a launch at `v_up` first reaches height `h` (INF if it never does).
static func _first_crossing(v_up: float, h: float) -> float:
var disc := v_up * v_up - 2.0 * GRAVITY * h
if disc < 0.0:
return INF
return (v_up - sqrt(disc)) / GRAVITY
## THE LEAD LEG: up a flight the legs alternate; a first hop takes the foot that is in
## front in the stride (the gait phase's first half leads with the left).
func _pick_lead() -> void:
var now := Time.get_ticks_msec() / 1000.0
if now - _last_hop_t < HOP_FLIGHT_S:
_hop_lead = 1 - _hop_lead
else:
var ph: float = float(_proc_animator.get("_cycle_phase")) if _proc_animator != null else 0.0
_hop_lead = 0 if ph < 0.5 else 1
_last_hop_t = now
func _begin_hop(h: float, dir: Vector3, land: Vector3) -> void:
_hop = {"h": h, "dir": dir, "land": land}
_hop_timer = hop_crouch_s * clampf(h / maxf(hop_max, 0.01), 0.5, 1.0)
_pick_lead()
# The crouch: the hip drops (the duck spring), harder for a taller step. Kept under
# 0.5 so the capsule never swaps to the crouching shape mid-hop.
target_duck_amount = 0.35 * clampf(h / maxf(hop_max, 0.01), 0.5, 1.0)
duck_vel = 12.0 * clampf(h / maxf(hop_max, 0.01), 0.5, 1.0)
## The push: a ballistic arc whose apex clears the step by HOP_CLEARANCE and whose
## horizontal speed reaches HOP_LAND_IN past the edge by the time it is back down.
func _launch_hop() -> void:
var up := -gravity_dir
var h: float = _hop["h"]
var dir: Vector3 = _hop["dir"]
var target: Vector3 = (_hop["land"] as Vector3) + dir * HOP_LAND_IN
var v_up := sqrt(2.0 * GRAVITY * (h + HOP_CLEARANCE))
var t := v_up / GRAVITY + sqrt(2.0 * HOP_CLEARANCE / GRAVITY)
var flat := target - global_position
flat -= up * flat.dot(up)
var v_f := clampf(flat.length() / t, hop_min_speed, run_speed)
_hop_push = dir * v_f
velocity = _hop_push + up * v_up
state = State.AIRBORNE
_step_hopping = true
ground_time = 0.0
air_time = 0.0
jump_fuel_remaining = 0.0
down_jump_fuel = 0.0
target_duck_amount = 0.0
_hop = {}
step_stats["hops"] = int(step_stats["hops"]) + 1
## Walking DOWN: the floor went away this frame, but the next tread is within step_up
## below — snap onto it instead of going airborne (no FALL/LAND every tread).
func _try_step_down() -> bool:
if step_up <= 0.0 or not _was_on_floor or _step_hopping or pre_jump_timer > 0.0 \
or _hop_timer > 0.0 or velocity.dot(-gravity_dir) > 0.1:
return false
var up := -gravity_dir
var c := KinematicCollision3D.new()
if not test_move(global_transform, -up * step_up, c):
return false
if c.get_normal().dot(up) < cos(floor_max_angle):
return false
var drop: float = -c.get_travel().dot(up)
global_position += c.get_travel()
apply_floor_snap()
if not is_on_floor():
global_position -= c.get_travel()
return false
_step_smooth += drop
step_stats["downs"] = int(step_stats["downs"]) + 1
_steps_recent += 1.0
return true
## A grounded body that moved UP or DOWN more than its floor's slope explains (the
## capsule riding a small edge, a step-up, a floor snap) has its DRAWN height eased
## instead: the difference goes into _step_smooth. On a slope the rise is explained by
## the slope (the floor normal before the slide), so climbing a hill is not lagged.
func _smooth_unexpected_rise(from: Vector3, floor_n: Vector3) -> void:
if floor_n == Vector3.ZERO or state != State.MOVEMENT or not is_on_floor():
return
var up := -gravity_dir
var d := global_position - from
var dy := d.dot(up)
var dh := d - up * dy
# THE SLOPE IS THE GROUND RAY'S (ground_normal: the ray at the feet, smoothed), NOT
# the contact's floor normal. At a tread EDGE the contact normal is the capsule
# resting on the corner (~34° just after a step): read as a slope it "explains" the
# rest of a step-up and predicts rises/drops that never happen, so both directions
# popped (gate_step_up 7). The ray sees the tread tops on stairs and the real slope
# on a hill.
# The RAY's own hit, not ground_normal: when the ray misses (it starts a few mm
# inside a tread the capsule is resting on the edge of) _update_ground_normal falls
# back to get_floor_normal() — the edge again. A miss here means "no slope known":
# flat. On a real slope the ray (0.3 m below the feet) always hits.
var gn: Vector3 = ground_ray.get_collision_normal().normalized() if ground_ray.is_colliding() else up
var ny := gn.dot(up)
if ny < 0.1:
return
var expected := -(gn - up * ny).dot(dh) / ny
var un := dy - expected
if absf(un) > 0.01:
_step_smooth -= un
## The drawn height catches up at a FIXED RATE (step_catchup_speed), not a spring: a
## spring soft enough not to pop a 0.4 m step lags 0.3-0.4 m behind a body running up
## stairs (~3 m/s of climb for 0.224 m treads at run speed), which sinks the feet into
## the treads. A rate above the climb rate keeps up and still removes the pops.
##
## AND AT LEAST THE CLIMB RATE: once the stairs are climbed at speed (~6 m/s up 0.224 m
## risers on 0.30 m treads at a run) a fixed 3.6 m/s let the offset ACCUMULATE — the
## body and camera rode ~0.4 m low and the feet sank into the treads
## (_probe_stairs_feet, 2026-10-05). The rate tracks 1.3x the body's smoothed vertical
## speed: one step still eases in, a flight is drawn at the speed it is climbed.
func _update_step_smooth(delta: float) -> void:
var rate := maxf(step_catchup_speed, absf(_climb_rate) * 1.3)
# BOUNDED WHILE CLIMBING: a run up a short flight (8 m/s, ~6 m/s of climb) is over
# before any rate estimate converges, and the drawn body rode 0.6 m low. Climbing
# faster than 1.5 m/s, the body is drawn at most STEP_LAG_CLIMBING below where it
# is; a single step (no sustained climb) keeps the full ease.
# A FLIGHT, not a lone step: steps coming back to back (a decaying count over ~0.3 s
# past 1.5) or a sustained climb. A run up a short flight is over in 0.25 s — before
# a 0.4 s climb average converges — and rode 0.6 m low; capping a LONE step instead
# popped it (gate_step_up 7).
var flight := _steps_recent > 1.5 or absf(_climb_sustained) > 2.0
var cap := STEP_LAG_CLIMBING if flight else 1.0
_steps_recent = maxf(0.0, _steps_recent - delta / 0.3)
_step_smooth = clampf(move_toward(_step_smooth, 0.0, rate * delta), -cap, cap)
# The body's vertical speed over the last ~0.15 s (step-ups and slides alike).
if delta > 0.0:
var vy := (global_position.y - _climb_last_y) / delta
_climb_rate = lerpf(_climb_rate, vy, 1.0 - exp(-delta / 0.15))
_climb_sustained = lerpf(_climb_sustained, vy, 1.0 - exp(-delta / 0.4))
_climb_last_y = global_position.y
var _climb_rate := 0.0
var _climb_sustained := 0.0
var _steps_recent := 0.0 # +1 per step, decays over ~0.3 s
var _climb_last_y := 0.0
const STEP_LAG_CLIMBING := 0.15
# ---- Crouch (Overgrowth exact: UpdateDuckAmount) ----
func _update_duck(delta: float) -> void:
var frames: float = delta * 60.0
# Overgrowth: WantsToCrouch + speed-based partial crouch while moving
if Input.is_action_pressed("crouch"):
target_duck_amount = 1.0
# Overgrowth: while moving fast, partial crouch (0.3 at speed 5+)
if _horizontal_speed() > 1.0:
target_duck_amount = lerpf(1.0, 0.3, minf(1.0, sqrt(_horizontal_speed()) / sqrt(5.0)))
elif pre_jump_timer <= 0.0 and _hop_timer <= 0.0:
target_duck_amount = 0.0
# Flip/roll duck override
if _proc_animator and _proc_animator.is_flipping():
var flip_duck: float = _proc_animator._flip_roll.get_duck_amount() if _proc_animator._flip_roll else -1.0
if flip_duck >= 0.0:
target_duck_amount = flip_duck
# Overgrowth exact spring-damper: duck_vel += (target - current) * dt * 120
duck_vel += (target_duck_amount - duck_amount) * delta * DUCK_ACCEL
duck_vel *= pow(DUCK_VEL_INERTIA, frames)
duck_amount += duck_vel * delta
duck_amount = clampf(duck_amount, 0.0, 1.0)
# Toggle collision shapes
if duck_amount > 0.5 and not collision_standing.disabled:
collision_standing.disabled = true
collision_crouching.disabled = false
elif duck_amount <= 0.5 and not collision_crouching.disabled:
collision_crouching.disabled = true
collision_standing.disabled = false
# ---- Remote ----
func _process_remote(delta: float) -> void:
var d: Dictionary = _decode_sync_state(sync_state)
if d.is_empty():
return
_decoded_position = d["position"]
_decoded_rotation_y = d["rotation_y"]
_decoded_mesh_rotation_y = d["mesh_rotation_y"]
_decoded_velocity = d["velocity"]
_decoded_on_floor = d["on_floor"]
_decoded_state = d["state"]
_decoded_surface_up = d["surface_up"]
var now: float = Time.get_ticks_msec() / 1000.0
var time_since_sync: float = now - _last_sync_time
if _decoded_position != _last_synced_position:
_sync_velocity = _decoded_velocity
_last_synced_position = _decoded_position
_last_sync_time = now
time_since_sync = 0.0
var target_pos: Vector3 = _decoded_position
if time_since_sync > SYNC_INTERVAL:
target_pos += _sync_velocity * (time_since_sync - SYNC_INTERVAL)
if global_position.distance_to(target_pos) > SNAP_DISTANCE:
global_position = target_pos
else:
global_position = global_position.lerp(target_pos, 10.0 * delta)
velocity = _decoded_velocity
# Sync controller state for remote wall climb / lizard walk
var remote_climbing := (_decoded_state == State.WALL_CLIMB)
if remote_climbing:
surface_up = surface_up.slerp(_decoded_surface_up, SURFACE_ALIGN_SPEED * delta)
state = State.WALL_CLIMB
# Orient body and model to match remote surface
_rotate_body_to_surface(delta)
_orient_model_to_surface(delta)
# Reconstruct grip targets from position + surface for animator
if _wall_climb and not _wall_climb.active:
_wall_climb.start(global_position, -_decoded_surface_up, Vector3.UP)
if _wall_climb and _wall_climb.active:
var facing := velocity.normalized() if velocity.length() > 0.1 else -model.global_transform.basis.z
_wall_climb.sync(global_position + surface_up * lizard_height, -gravity_dir, facing)
else:
if state == State.WALL_CLIMB and _wall_climb and _wall_climb.active:
_wall_climb.stop()
state = _decoded_state
surface_up = surface_up.slerp(Vector3.UP, 10.0 * delta)
model.rotation.y = lerp_angle(model.rotation.y, _decoded_mesh_rotation_y, 10.0 * delta)
_dbg_model("remote_avatar")
camera_head.rotation.y = lerp_angle(camera_head.rotation.y, input_sync.camera_rotation_y, 10.0 * delta)
camera_pivot.rotation.x = lerp_angle(camera_pivot.rotation.x, input_sync.camera_pitch, 10.0 * delta)
# A REMOTE STEP HOP, derived (user 2026-10-05: "derive remotely" — no sync bytes):
# leaving the ground with an up-speed a hop has and a jump does not. A jump leaves
# at 12 m/s and a double jump at 9.6; a hop to hop_max at most sqrt(2 g (0.79 +
# 0.12)) = 4.2 m/s. It lasts until they are grounded again; the lead leg alternates
# like the local one (the crouch itself is not seen remotely — it happens grounded).
var v_up_r: float = _decoded_velocity.dot(-gravity_dir)
if not _remote_hop and _remote_was_grounded and _decoded_state == State.AIRBORNE \
and v_up_r > 1.0 and v_up_r < 10.0: # a leap up a flight reaches 9 (LEAP_MAX_UP)
_remote_hop = true
_hop_lead = 1 - _hop_lead
elif _remote_hop and (_decoded_on_floor or _decoded_state != State.AIRBORNE):
_remote_hop = false
_remote_was_grounded = _decoded_on_floor and _decoded_state == State.MOVEMENT
# Feed state to procedural animator for remote players too
_update_proc_animator()
# ---- Sync Encoding (same format as player.gd for interop) ----
static func _encode_sync_state(pos: Vector3, rot_y: float, mesh_rot_y: float,
vel: Vector3, on_floor: bool, ctrl_state: int = 0,
surf_up: Vector3 = Vector3.UP) -> PackedByteArray:
var buf := PackedByteArray()
buf.resize(24)
buf.encode_s16(0, clampi(roundi(pos.x * 100.0), -32768, 32767))
buf.encode_s16(2, clampi(roundi(pos.y * 100.0), -32768, 32767))
buf.encode_s16(4, clampi(roundi(pos.z * 100.0), -32768, 32767))
buf.encode_u16(6, roundi(fmod(rot_y + TAU, TAU) / TAU * 65535.0) % 65536)
buf.encode_u16(8, roundi(fmod(mesh_rot_y + TAU, TAU) / TAU * 65535.0) % 65536)
buf.encode_s16(10, clampi(roundi(vel.x * 100.0), -32768, 32767))
buf.encode_s16(12, clampi(roundi(vel.y * 100.0), -32768, 32767))
buf.encode_s16(14, clampi(roundi(vel.z * 100.0), -32768, 32767))
buf[16] = 1 if on_floor else 0
buf[17] = clampi(ctrl_state, 0, 255)
# Surface up normal (unit vector, s16 at 0.01 precision)
buf.encode_s16(18, clampi(roundi(surf_up.x * 16383.0), -16383, 16383))
buf.encode_s16(20, clampi(roundi(surf_up.y * 16383.0), -16383, 16383))
buf.encode_s16(22, clampi(roundi(surf_up.z * 16383.0), -16383, 16383))
return buf
static func _decode_sync_state(buf: PackedByteArray) -> Dictionary:
if buf.size() < 18:
return {}
var d := {
"position": Vector3(
buf.decode_s16(0) / 100.0,
buf.decode_s16(2) / 100.0,
buf.decode_s16(4) / 100.0),
"rotation_y": buf.decode_u16(6) / 65535.0 * TAU,
"mesh_rotation_y": buf.decode_u16(8) / 65535.0 * TAU,
"velocity": Vector3(
buf.decode_s16(10) / 100.0,
buf.decode_s16(12) / 100.0,
buf.decode_s16(14) / 100.0),
"on_floor": (buf[16] & 1) != 0,
"state": buf[17] if buf.size() > 17 else 0,
"surface_up": Vector3.UP,
}
if buf.size() >= 24:
d["surface_up"] = Vector3(
buf.decode_s16(18) / 16383.0,
buf.decode_s16(20) / 16383.0,
buf.decode_s16(22) / 16383.0).normalized()
return d
# ---- Grapple Rope Visual ----
func _update_grapple_rope_visual() -> void:
if _grapple_rope_visual == null:
return
var imm: ImmediateMesh = _grapple_rope_visual.mesh as ImmediateMesh
if imm == null:
return
imm.clear_surfaces()
# Get actual right hand bone position from animator
var hand_pos: Vector3 = _get_right_hand_world_pos()
# Winch mode — straight line from hand to anchor
if _winch_active:
imm.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
imm.surface_add_vertex(hand_pos)
imm.surface_add_vertex(_winch_anchor)
imm.surface_end()
return
# Rope mode — particle chain, override player end with hand bone
if not _grapple.active:
return
var points: PackedVector3Array = _grapple.get_rope_points()
if points.size() < 2:
return
imm.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
# First point is anchor, last is player — replace last with hand bone
for i in range(points.size()):
if i == points.size() - 1:
imm.surface_add_vertex(hand_pos)
else:
imm.surface_add_vertex(points[i])
imm.surface_end()
# ---- Procedural Animation Bridge ----
func _update_proc_animator() -> void:
if not _proc_animator:
return
# Hip drop: lower model node based on duck_amount (Overgrowth: collision sphere offset)
# Foot IK compensates so feet stay at ground level.
if state == State.WALL_CLIMB:
# Model position and orientation are set in _orient_model_to_surface.
pass
else:
# + the STEP-UP ease: after a step the drawn body starts where it was and catches
# up (critically damped), the camera with it — a step, not a teleport.
model.position = Vector3(0, -duck_amount * hip_drop_max + _step_smooth, 0)
if state == State.MOVEMENT or state == State.AIRBORNE:
camera_head.position = Vector3(0, 1.5 + _step_smooth, 0)
# STEP HOP: the crouch (lead leg loading) and the arc — remote players derive it from
# the replicated state (see _process_remote), so no sync bytes were added.
_proc_animator.is_step_hopping = _step_hopping or _hop_timer > 0.0 or _remote_hop
_proc_animator.step_crouching = _hop_timer > 0.0
_proc_animator.step_lead = _hop_lead
_proc_animator.horizontal_speed = _horizontal_speed()
var anim_up: Vector3 = -gravity_dir
_proc_animator.local_up = anim_up
_proc_animator.vertical_velocity = velocity.dot(anim_up)
_proc_animator.velocity = velocity # Full 3D velocity for acceleration tilt
# Remote avatars run no physics, so is_on_floor() is always false → the
# animator would pick "fall" forever. Use the synced grounded flag for them.
_proc_animator.is_grounded = is_on_floor() if is_multiplayer_authority() else _decoded_on_floor
_proc_animator.is_jumping = (state == State.AIRBORNE and velocity.dot(anim_up) > 0.5)
_proc_animator.duck_amount = duck_amount
_proc_animator.facing_direction = -model.global_transform.basis.z
# The MODEL's up, which is what the torso counter-roll measures against.
# NOT the skeleton's: the rig carries an import rotation, so its basis.y is
# not the body's up and a roll computed from it is meaningless.
_proc_animator.model_up = model.global_transform.basis.y.normalized()
_proc_animator.wall_run_active = wall_run_active
_proc_animator.wall_run_normal = wall_run_normal
_proc_animator.on_ledge = _on_ledge
_proc_animator.ledge_normal = _ledge_normal
_proc_animator.is_climbing_ledge = (_ledge_climb_progress > 0.0 and _ledge_climb_progress < 1.0)
_proc_animator.is_wall_climbing = (state == State.WALL_CLIMB)
_proc_animator.surface_up = surface_up
_proc_animator.ground_normal = ground_normal
_proc_animator.ski_holding = ski_holding
if state == State.WALL_CLIMB:
_proc_animator.climb_grip_positions = _wall_climb.grip_positions.duplicate()
_proc_animator.climb_grip_weights = _wall_climb.grip_weights.duplicate()
_proc_animator.climb_grip_normals = _wall_climb.grip_normals.duplicate()
_proc_animator.climb_wall_normal = _wall_climb.wall_normal
_proc_animator.is_grappling = _grapple.active or _winch_active
if _grapple.active:
_proc_animator.grapple_anchor_direction = _grapple.get_anchor_direction(global_position)
_proc_animator.grapple_swing_speed = Vector2(velocity.x, velocity.z).length()
elif _winch_active:
_proc_animator.grapple_anchor_direction = (_winch_anchor - global_position).normalized()
_proc_animator.grapple_swing_speed = Vector2(velocity.x, velocity.z).length()
# Always feed camera aim direction for right-arm IK (ready-to-fire pose)
_proc_animator.aim_direction = -camera.global_transform.basis.z
_proc_animator.is_pre_jumping = (pre_jump_timer > 0.0)
# Feed camera look direction to chest/head IK.
# During wall climb: disable chest IK but feed look_direction for head-only look.
if state == State.WALL_CLIMB:
# Head should point where camera looks (away from camera, into scene).
# The model is prone so we need the full 3D look direction, not just
# tangent projection — the head IK handles the bone-space conversion.
var cam_look_climb: Vector3 = -camera.global_transform.basis.z
_proc_animator.look_direction = cam_look_climb
elif state == State.GRAPPLE and _grapple.active:
# During grapple: head looks toward the anchor point
var to_anchor: Vector3 = (_grapple.anchor_point - global_position).normalized()
_proc_animator.look_direction = to_anchor
elif state == State.GRAPPLE_WINCH and _winch_active:
var to_anchor: Vector3 = (_winch_anchor - global_position).normalized()
_proc_animator.look_direction = to_anchor
else:
var cam_look: Vector3 = -camera.global_transform.basis.z
var char_forward: Vector3 = model.global_transform.basis.z
if cam_look.dot(char_forward) > 0.0:
_proc_animator.look_direction = cam_look
else:
_proc_animator.look_direction = Vector3.ZERO
# Pass character body RID so foot IK raycasts exclude our own collider
if _proc_animator._foot_ik != null and not _proc_animator._foot_ik.exclude_rid.is_valid():
_proc_animator._foot_ik.exclude_rid = get_rid()
# ---- Helpers ----
func _get_right_hand_world_pos() -> Vector3:
if _proc_animator and _proc_animator.right_hand_world_pos != Vector3.ZERO:
return _proc_animator.right_hand_world_pos
# Fallback: offset from body
var right_vec: Vector3 = camera_head.global_transform.basis.x
return global_position + (-gravity_dir) * 1.2 + right_vec * 0.3
func _measure_skeleton_anatomy() -> void:
var tumble_model: Node3D = model.get_node_or_null("TumbleModel")
if not tumble_model:
return
var skeleton: Skeleton3D = _find_child_of_type(tumble_model, "Skeleton3D") as Skeleton3D
if not skeleton:
return
var gx: Transform3D = skeleton.global_transform
var bones: Dictionary = {}
var names: Array[String] = [
"DEF-spine", "DEF-spine.001", "DEF-spine.003", "DEF-spine.005", "DEF-spine.006",
"DEF-shoulder.L", "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
"DEF-shoulder.R", "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
"DEF-thigh.L", "DEF-shin.L", "DEF-foot.L",
"DEF-thigh.R", "DEF-shin.R", "DEF-foot.R",
]
for bn in names:
var idx: int = skeleton.find_bone(bn)
if idx >= 0:
bones[bn] = (gx * skeleton.get_bone_global_pose(idx)).origin
# Print positions relative to hip
var hip: Vector3 = bones.get("DEF-spine", global_position)
print("=== SKELETON ANATOMY (world-space, relative to hip) ===")
for bn in names:
if bones.has(bn):
var rel: Vector3 = bones[bn] - hip
print(" %s: (%.3f, %.3f, %.3f) dist_from_hip=%.3f" % [bn, rel.x, rel.y, rel.z, rel.length()])
# Print limb segment lengths
var _dist := func(a: String, b: String) -> float:
if bones.has(a) and bones.has(b):
return bones[a].distance_to(bones[b])
return -1.0
print("=== LIMB LENGTHS ===")
print(" Upper arm L: %.3f" % _dist.call("DEF-upper_arm.L", "DEF-forearm.L"))
print(" Forearm L: %.3f" % _dist.call("DEF-forearm.L", "DEF-hand.L"))
print(" Total arm L: %.3f" % (_dist.call("DEF-upper_arm.L", "DEF-forearm.L") + _dist.call("DEF-forearm.L", "DEF-hand.L")))
print(" Thigh L: %.3f" % _dist.call("DEF-thigh.L", "DEF-shin.L"))
print(" Shin L: %.3f" % _dist.call("DEF-shin.L", "DEF-foot.L"))
print(" Total leg L: %.3f" % (_dist.call("DEF-thigh.L", "DEF-shin.L") + _dist.call("DEF-shin.L", "DEF-foot.L")))
print(" Hip to head: %.3f" % _dist.call("DEF-spine", "DEF-spine.006"))
print(" Shoulder span: %.3f" % _dist.call("DEF-upper_arm.L", "DEF-upper_arm.R"))
print(" Hip span: %.3f" % _dist.call("DEF-thigh.L", "DEF-thigh.R"))
func _horizontal_speed() -> float:
var local_up: Vector3 = -gravity_dir
var horiz: Vector3 = velocity - local_up * velocity.dot(local_up)
return horiz.length()
func _find_child_of_type(node: Node, type_name: String) -> Node:
for child in node.get_children():
if child.get_class() == type_name:
return child
var found: Node = _find_child_of_type(child, type_name)
if found:
return found
return null
# ---- RPCs ----
@rpc("any_peer", "call_local", "reliable")
func _toggle_light() -> void:
flashlight.visible = not flashlight.visible
_play_varied(snd_light, 0.5)
## MICRO-VARIATION on every play (cozy-SFX research 2026-10-06: an identical repeat is
## what makes a sound grate): ±`semitones` of pitch and ±1.5 dB, re-rolled each time.
## Not AudioStreamRandomizer: with one sample per sound it adds nothing over this, and a
## randomizer on Land crashed the engine with unbounded recursion every run of
## gate_step_up (bare repros — shared, freed, bursts — did not; cause not found).
## VARIANT POOLS (assets/sounds/player/cozy, Kenney CC0 — see its CREDITS.md). preload:
## only dependencies reach the PCK (export_filter "selected_scenes").
const _COZY := "res://assets/sounds/player/cozy/"
const JUMP_POOL: Array[AudioStream] = [preload(_COZY + "jump_0.ogg"), preload(_COZY + "jump_1.ogg"),
preload(_COZY + "jump_2.ogg"), preload(_COZY + "jump_3.ogg")]
const LAND_POOL: Array[AudioStream] = [preload(_COZY + "land_0.ogg"), preload(_COZY + "land_1.ogg"),
preload(_COZY + "land_2.ogg"), preload(_COZY + "land_3.ogg"), preload(_COZY + "land_4.ogg")]
const STEP_TERRAIN: Array[AudioStream] = [preload(_COZY + "step_terrain_0.ogg"), preload(_COZY + "step_terrain_1.ogg"),
preload(_COZY + "step_terrain_2.ogg"), preload(_COZY + "step_terrain_3.ogg"), preload(_COZY + "step_terrain_4.ogg")]
const STEP_HARD: Array[AudioStream] = [preload(_COZY + "step_hard_0.ogg"), preload(_COZY + "step_hard_1.ogg"),
preload(_COZY + "step_hard_2.ogg"), preload(_COZY + "step_hard_3.ogg"), preload(_COZY + "step_hard_4.ogg"),
preload(_COZY + "step_hard_5.ogg"), preload(_COZY + "step_hard_6.ogg"), preload(_COZY + "step_hard_7.ogg"),
preload(_COZY + "step_hard_8.ogg"), preload(_COZY + "step_hard_9.ogg")]
## A FOOTFALL from the animation (FootIK's swing returning to planted, while grounded).
## COZY STEPS (Kenney CC0): the terrain's chunks are bodiless server bodies (null
## collider) — soft dirt; anything with a node (brushes, floors, props) — the hard set.
## The step node sits at -8 dB, under the mix.
func _on_foot_planted(foot: int) -> void:
if not is_on_floor() or Vector2(velocity.x, velocity.z).length() < 0.5:
return
var col := get_last_slide_collision()
var on_terrain := col != null and col.get_collider() == null
_play_pool(_footstep_sounds[0], STEP_TERRAIN if on_terrain else STEP_HARD, 1.5)
footstep_count += 1
footstep_feet.append(foot)
## A NO-REPEAT pick from `pool` (never the variant this player played last — an
## identical repeat is what makes a step grate), then the pitch / volume jitter.
## Our own picker, not AudioStreamRandomizer (see _play_varied).
func _play_pool(p: AudioStreamPlayer3D, pool: Array[AudioStream], semitones: float) -> void:
var last: int = p.get_meta("sfx_last", -1)
var i := randi() % pool.size()
if pool.size() > 1 and i == last:
i = (i + 1 + randi() % (pool.size() - 1)) % pool.size()
p.set_meta("sfx_last", i)
p.stream = pool[i]
_play_varied(p, semitones)
func _play_varied(p: AudioStreamPlayer3D, semitones: float) -> void:
# Around the node's AUTHORED values, captured on first use.
if not p.has_meta("sfx_base"):
p.set_meta("sfx_base", Vector2(p.pitch_scale, p.volume_db))
var base: Vector2 = p.get_meta("sfx_base")
p.pitch_scale = base.x * pow(2.0, randf_range(-semitones, semitones) / 12.0)
p.volume_db = base.y + randf_range(-1.5, 1.5)
p.play()
@rpc("any_peer", "call_local", "reliable")
func _respawn() -> void:
var spawns := get_tree().get_nodes_in_group("spawn_points")
# If no spawn points, stay at current position (don't reset to origin)
var at: Vector3 = spawns[randi() % spawns.size()].global_position if not spawns.is_empty() else global_position
arrive(at)
# ---- Arrival ----------------------------------------------------------------
#
# THE ONE WAY INTO A POSITION: first spawn, teleport / map change, R and the fall
# respawn, a restored saved position. Each used to set global_position and let
# gravity run — onto spawn points 17-121 m in the air, 12 m inside rock, or onto
# terrain whose collision did not exist yet (gate_arrival, ArrivalGround).
## Give up waiting for the ground after this long and stand on the best answer.
const ARRIVAL_TIMEOUT := 15.0
## A hold shorter than this (flat floors, built collision) shows no veil at all.
const ARRIVAL_VEIL_AFTER := 0.2
## Feet this far above the resolved ground; the capsule's origin is at its feet.
const ARRIVAL_CLEARANCE := 0.02
var _arrival_point := Vector3.ZERO
## Where the arrival ASKED to be. The ground is always resolved from here, never from
## the hold point: held on a first, coarse candidate, a down-ray from the hold missed
## the finer surface that then appeared 1.5 m ABOVE it and stood the player on an
## older one inside the terrain (map_mountain: released at 123.78 under a surface at
## 125.27, fell through 1.1 s later).
var _arrival_origin := Vector3.ZERO
var _arrival_snap := true
## < 0 = not arriving; otherwise seconds held so far.
var _arrival_t := -1.0
var _arrival_veil: CanvasLayer = null
## Preloaded, not by class_name: a new class_name is unknown to a tree whose class
## cache predates it, and that parse error takes the whole player script down.
const _Arrival := preload("res://scenes/player/arrival_ground.gd")
## Arrive at `point`: held there until the ground is real, then stood on it.
## snap_down = false keeps an AIRBORNE point in the air (a saved position recorded
## mid-jump); a buried point is lifted regardless.
func arrive(point: Vector3, snap_down: bool = true) -> void:
_reset_motion_state()
global_position = point
if not is_local_player():
return # a remote copy's position comes from its owner's sync
_arrival_point = point
_arrival_origin = point
_arrival_snap = snap_down
_arrival_t = 0.0
func is_arriving() -> bool:
return _arrival_t >= 0.0
func _arrival_tick(delta: float) -> void:
_arrival_t += delta
velocity = Vector3.ZERO
global_position = _arrival_point
var excl: Array[RID] = [get_rid()]
var r: Dictionary = _Arrival.resolve(get_world_3d().direct_space_state, _arrival_origin, _arrival_snap,
excl, _Arrival.find_terrain(self))
if not r["ready"] and _arrival_t < ARRIVAL_TIMEOUT:
if _arrival_t >= ARRIVAL_VEIL_AFTER:
_set_arrival_veil(true)
# A candidate below (or above, buried) that is not final yet: HOLD THERE. The
# camera rides with the body and drives the LOD, so held 121 m up (map_mountain)
# the ground below never reached its finest LOD and only the timeout let go.
if r["found"] and _arrival_snap:
_arrival_point = (r["ground"] as Vector3) + Vector3(0, ARRIVAL_CLEARANCE, 0)
return
if not r["ready"]:
push_warning("[Player] arrival at %s: ground not ready after %.0f s (%s) — releasing anyway"
% [_arrival_origin, ARRIVAL_TIMEOUT, r["why"]])
var g: Vector3 = r["ground"]
global_position = g + Vector3(0, ARRIVAL_CLEARANCE, 0) if g != _arrival_origin else g
DebugLog.log("game", "ARRIVED at %s after %.2f s: %s" % [global_position, _arrival_t, r["why"]])
_arrival_t = -1.0
_set_arrival_veil(false)
func _set_arrival_veil(on: bool) -> void:
if on:
if _arrival_veil != null:
return
_arrival_veil = CanvasLayer.new()
_arrival_veil.layer = 100
var bg := ColorRect.new()
bg.color = Color.BLACK
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_arrival_veil.add_child(bg)
var l := Label.new()
l.text = "Arriving…"
l.set_anchors_preset(Control.PRESET_CENTER)
bg.add_child(l)
add_child(_arrival_veil)
return
if _arrival_veil == null:
return
var veil := _arrival_veil
_arrival_veil = null
var bg2 := veil.get_child(0) as ColorRect
var tw := create_tween()
tw.tween_property(bg2, "modulate:a", 0.0, 0.4)
tw.tween_callback(veil.queue_free)
## Everything a teleport / respawn must clear: velocity, state, crouch, ledge,
## wall run, wall climb.
func _reset_motion_state() -> void:
velocity = Vector3.ZERO
state = State.MOVEMENT
ground_time = 0.0
duck_amount = 0.0
target_duck_amount = 0.0
_on_ledge = false
_ledge_climb_progress = 0.0
_has_hit_wall = false
wall_run_active = false
if _wall_climb and _wall_climb.active:
_wall_climb.stop()
_lizard_mode = false
_destroy_grip_particles()
surface_up = Vector3.UP
_target_surface_up = Vector3.UP
up_direction = Vector3.UP
floor_max_angle = DEFAULT_FLOOR_MAX_ANGLE
# Ensure collision shapes are in valid state
collision_standing.disabled = false
collision_crouching.disabled = true
collision_standing.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.79, 0))
collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))
# Reset model to upright
model.transform.basis = Basis.IDENTITY
_dbg_model("hard_reset")
model.position = Vector3.ZERO
_climb_model_quat = Quaternion.IDENTITY
# Reset winch
_winch_active = false
_set_terrain_lod_frozen(false)
if _grapple and _grapple.active:
_grapple.release()
# ---- Quake liquids (water / lava / slime) — ported for the procedural player ----
func _setup_liquid_overlay() -> void:
# Full-screen tint for the Quake content color-shift (V_CalcBlend) when the head is
# submerged. ColorRect on its own CanvasLayer; the colour's alpha carries the blend %.
var layer := CanvasLayer.new()
layer.name = "LiquidBlend"
layer.layer = 1
add_child(layer)
var rect := ColorRect.new()
rect.set_anchors_preset(Control.PRESET_FULL_RECT)
rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
rect.visible = false
layer.add_child(rect)
_liquid_overlay = rect
func _update_water_state() -> void:
# Quake waterlevel (SV_CheckWater): feet / mid / eye samples. global_position is the capsule
# feet; centre 0.79, head/eye 1.5. Swim engages at >= 2 (mid submerged); blend at 3 (head under).
var tree := get_tree()
var mid_liq := LiquidVolume.content_at(tree, global_position + Vector3(0, 0.79, 0))
var eye_liq := LiquidVolume.content_at(tree, global_position + Vector3(0, 1.5, 0))
_water_level = 0
if not LiquidVolume.content_at(tree, global_position + Vector3(0, 0.1, 0)).is_empty():
_water_level = 1
if not mid_liq.is_empty():
_water_level = 2
if not eye_liq.is_empty():
_water_level = 3
_in_liquid = _water_level >= 2
_update_liquid_blend(eye_liq) # non-empty only when the head (eye) point is submerged
func _update_liquid_blend(view_liquid: Dictionary) -> void:
if _liquid_overlay == null:
return
var cls := String(view_liquid.get("class", ""))
if cls != "" and LIQUID_BLEND.has(cls):
_liquid_overlay.color = LIQUID_BLEND[cls]
_liquid_overlay.visible = true
else:
_liquid_overlay.visible = false
func _apply_water_move(delta: float) -> void:
# Faithful SV_WaterMove (WinQuake/sv_user.c) at BASE_RUN_SPEED. You swim where you LOOK —
# wish vector from the camera basis (full 3D incl. pitch), like Quake's v_angle vectors.
var input_dir: Vector2 = input_sync.input_direction
var fwd := -camera.global_transform.basis.z
var right := camera.global_transform.basis.x
var up := (-gravity_dir).normalized() if gravity_dir.length_squared() > 0.5 else Vector3.UP
var wishvel := fwd * -input_dir.y + right * input_dir.x
if input_sync.wants_jump:
wishvel += up # jump == swim up (Quake upmove)
if input_dir == Vector2.ZERO and not input_sync.wants_jump:
wishvel = -up * WATER_IDLE_SINK # idle → drift toward the bottom
var wishspeed := minf(wishvel.length(), 1.0) * BASE_RUN_SPEED * WATER_SPEED_SCALE
var speed := velocity.length()
var newspeed := 0.0
if speed > 0.0:
newspeed = maxf(speed - delta * speed * WATER_FRICTION, 0.0)
velocity *= newspeed / speed
if wishspeed > 0.0:
var addspeed := wishspeed - newspeed
if addspeed > 0.0:
var accelspeed := minf(WATER_ACCEL * wishspeed * delta, addspeed)
velocity += wishvel.normalized() * accelspeed
## Enter or leave free flight. Called by GameMode, not by a key: build mode IS the
## free-fly camera, so there is exactly one way into this state and it cannot drift
## from the mode the rest of the game thinks it is in.
##
## The lift on entry is deliberate — you start flying slightly above where you stood,
## so a body inside a floor does not begin its flight embedded in geometry. Position
## is set AFTER the state change, because a state transition would otherwise override it.
func set_fly_mode(enabled: bool) -> void:
if enabled == fly_mode:
return
fly_mode = enabled
_set_builder_camera(enabled)
if fly_mode:
var saved_pos: Vector3 = global_position
var lift_dir: Vector3 = -gravity_dir if gravity_dir.length_squared() > 0.5 else Vector3.UP
_pre_fly_position = saved_pos
state = State.FLY
velocity = Vector3.ZERO
global_position = saved_pos + lift_dir * 5.0
DebugLog.log("game", "FLY ON: was=%s now=%s" % [saved_pos, global_position])
else:
state = State.AIRBORNE
velocity = Vector3.ZERO
DebugLog.log("game", "FLY OFF at %s" % global_position)
## BUILD MODE IS A CAMERA, NOT A FLYING PLAYER. Entering fly used to leave you as a
## body: third-person spring arm, your own model in front of the crosshair, and a
## collider that still blocked people and triggers. You could fly, but you were still
## playing — which is what "doesn't properly switch to player-less flying" is.
##
## Three things make it a camera, and each is restored exactly on the way out:
##
## FIRST PERSON. The spring arm collapses to zero, so the view is from the head
## rather than from behind a body you are trying to see past.
## NO BODY. $Model is hidden LOCALLY only. Other peers still see you — you have not
## left the room, you are building in it, and vanishing from other people's screens
## would be a worse lie than being visible.
## NO COLLIDER. The shapes are disabled so you do not block a doorway someone else
## is walking through while you edit it. Fly movement already bypasses collision for
## its own motion (it sets global_position directly), so this only affects what
## OTHERS collide with.
func _set_builder_camera(building: bool) -> void:
if building:
_pre_build_spring = spring_arm.spring_length
spring_arm.spring_length = 0.0 if building else _pre_build_spring
if model != null:
model.visible = not building
# SAVE AND RESTORE THE FLAGS THEMSELVES. There is no crouch boolean in this
# controller — which shape is enabled IS the state (standing, crouching and wall
# climb each set the pair directly), so reconstructing it on the way out would be
# guessing at something already written down.
if building:
_pre_build_standing = collision_standing.disabled
_pre_build_crouching = collision_crouching.disabled
collision_standing.disabled = true
collision_crouching.disabled = true
else:
collision_standing.disabled = _pre_build_standing
collision_crouching.disabled = _pre_build_crouching
var _pre_build_spring: float = 3.0
var _pre_build_standing: bool = false
var _pre_build_crouching: bool = true
## EVERY WRITER TO THE MODEL BASIS, TAGGED. HTPS_MODELLOG prints one line per
## write per frame, in the order they land. Several mechanisms orient the model
## and the Euler ones do not preserve a roll, so "which wrote last" is the only
## thing that settles an argument about the final orientation.
## Cached at load: this is called nine times a frame and an environment lookup
## per call is not free.
static var _dbg_model_on: bool = OS.has_environment("HTPS_MODELLOG")
## LAST 3 SECONDS, ALWAYS RECORDING. F10 used to dump the CURRENT frame, which is
## useless: you cannot be mid-wall-run and press a key on the same frame. It now
## dumps history, so you press it AFTER the thing you saw.
const DBG_HIST := 180
var _dbg_hist: Array = []
var _dbg_skel: Skeleton3D = null
func _dbg_model(tag: String) -> void:
if not OS.has_environment("HTPS_MODELLOG"):
return
# TRIGGER ON THE SYMPTOM, NOT ON THE SUSPECT. This was gated on wall_run_active
# or WALL_CLIMB, and a play session that reproduced the body going flat printed
# NOTHING -- which says the state I assumed was involved may not be. Gating a
# diagnostic on the mechanism you are trying to identify can only confirm the
# guess you already made.
#
# So: fire whenever the model is actually tilted, whatever put it there. Quiet
# on an upright character, loud for exactly the frames in the screenshots.
if absf(model.global_transform.basis.y.normalized().dot(-gravity_dir)) > 0.985:
return # within ~10 degrees of upright — nothing to explain
var u: Vector3 = model.global_transform.basis.y.normalized()
var flipping: bool = _proc_animator != null and _proc_animator.is_flipping()
_diag("[model] f=%d %-18s up=(%+.3f,%+.3f,%+.3f) tilt=%5.1f state=%d wr=%s climb=%s ledge=%s flip=%s"
% [Engine.get_physics_frames(), tag, u.x, u.y, u.z,
rad_to_deg(u.angle_to(-gravity_dir)), state, str(wall_run_active),
str(state == State.WALL_CLIMB), str(_on_ledge), str(flipping)])
## One frame of everything that could put the body in a wrong pose, printed on
## demand (F10). No env var, no category, no threshold -- the point is that it
## cannot be silent for a reason I failed to anticipate.
func _dbg_record() -> void:
var mu: Vector3 = model.global_transform.basis.y.normalized()
# THE BONES, EVERY FRAME. The dump used to sample them only when F10 was
# pressed -- which is after the thing you saw, so every bone number in this
# investigation came from a standing character or from a gate. The hip angle
# DURING the failure was never once measured.
var hipdeg := -1.0
var headdeg := -1.0
if _dbg_skel == null:
_dbg_skel = _find_skel_dump(self)
if _dbg_skel != null:
var hb: int = _dbg_skel.find_bone("DEF-spine")
if hb >= 0:
hipdeg = rad_to_deg((_dbg_skel.global_transform * _dbg_skel.get_bone_global_pose(hb)).basis.y.normalized().angle_to(-gravity_dir))
var hd: int = _dbg_skel.find_bone("DEF-spine.006")
if hd >= 0:
headdeg = rad_to_deg((_dbg_skel.global_transform * _dbg_skel.get_bone_global_pose(hd)).basis.y.normalized().angle_to(-gravity_dir))
var anim: int = _proc_animator._anim_state if _proc_animator != null else -1
var flipping: bool = _proc_animator != null and _proc_animator.is_flipping()
var tl: float = _proc_animator._tilt.length() if _proc_animator != null else -1.0
_dbg_hist.append("f=%d st=%d anim=%2d floor=%d wr=%d cont=%d spr=%d ledge=%d climbp=%.2f hspd=%5.2f modeltilt=%5.1f flip=%d |tilt|=%5.2f HIP=%5.1f HEAD=%5.1f"
% [Engine.get_physics_frames(), state, anim, int(is_on_floor()),
int(wall_run_active), int(wall_contact),
int(Input.is_action_pressed("sprint")), int(_on_ledge), _ledge_climb_progress,
_horizontal_speed(), rad_to_deg(mu.angle_to(-gravity_dir)), int(flipping), tl, hipdeg, headdeg])
if _dbg_hist.size() > DBG_HIST:
_dbg_hist.pop_front()
## F10. Prints the last DBG_HIST frames, collapsing runs of identical state so a
## 3-second window is readable. Legend included because nobody should have to go
## and look the enums up while comparing this to what they just watched.
func _dump_state() -> void:
_diag("[DUMP] ==== last %d frames ==== st: 0=MOVE 1=AIR 2=WALLCLIMB 3=GRAP 4=WINCH 5=FLY 6=SWIM" % _dbg_hist.size())
_diag("[DUMP] anim: 0=IDLE 1=WALK 2=RUN 3=JUMP 4=FALL 5=LAND 6=ROLL 7=WALLRUN 8=HANG 9=LCLIMB 10=WCLIMB 11=GRAP")
var prev := ""
var reps := 0
for line in _dbg_hist:
var key: String = (line as String).split(" hspd=")[0]
var sig: String = key.substr(key.find(" st="))
if sig == prev:
reps += 1
continue
if reps > 0:
_diag("[DUMP] ... x%d" % reps)
reps = 0
prev = sig
_diag("[DUMP] " + line)
if reps > 0:
_diag("[DUMP] ... x%d" % reps)
var sk: Skeleton3D = _find_skel_dump(self)
if sk != null:
for bn in ["DEF-spine", "DEF-spine.006"]:
var bi: int = sk.find_bone(bn)
if bi >= 0:
var bu: Vector3 = (sk.global_transform * sk.get_bone_global_pose(bi)).basis.y.normalized()
_diag("[DUMP] now: bone %-14s %.1f deg from world up" % [bn, rad_to_deg(bu.angle_to(Vector3.UP))])
_diag("[DUMP] ================================")
func _find_skel_dump(n: Node) -> Skeleton3D:
if n is Skeleton3D:
return n
for c in n.get_children():
var f: Skeleton3D = _find_skel_dump(c)
if f != null:
return f
return null
## DIAGNOSTICS GO TO A FILE, NOT TO print().
## Measured: the player spawns every session (/tmp/hypertps-game.log records
## _spawn_player_for at 20:35:54), and game.gd's _log() does BOTH print() and a
## FileAccess write with an explicit flush(). The FileAccess copy has every line;
## the piped stdout copy does not. So print() is not reliable through dev-run's
## pipeline and FileAccess is, and every "the log is empty" in this investigation
## was that difference rather than anything about the game.
static var _diag_file: FileAccess = null
func _diag(line: String) -> void:
if _diag_file == null:
_diag_file = FileAccess.open("/tmp/hypertps-diag.log", FileAccess.WRITE)
if _diag_file == null:
return
_diag_file.seek_end()
_diag_file.store_line(line)
_diag_file.flush()
# ---------------------------------------------------------------------------
# BLOCKED-MOTION RECORDER
#
# "The collision is very hard to pass" is a statement about frames where the
# character asked to move and did not. Nothing in this project recorded those,
# so every diagnosis so far has had to start from a guess about WHY -- and four
# instruments in a row (hyper-games #51 section 2.3) were each gated on the
# mechanism their author already suspected, so each could only confirm a guess
# and its silence proved nothing.
#
# This is gated on the SYMPTOM instead: horizontal displacement much smaller
# than the horizontal velocity asked for. It records what was in the way and how
# steep it was, and it runs while you walk rather than where you think to look.
#
# Kept in a ring buffer on the player so the terrain probe can read it out;
# nothing is written to disk until something asks.
const BLOCK_LOG_MAX := 240
## 15 seconds at 60 Hz of raw per-frame motion, judged by nobody.
const MOTION_LOG_MAX := 900
var _motion_log: Array = []
var _dbg_traction := -1.0
var _dbg_slope_sin := 0.0
var _dbg_hold_frac := 0.0
var _dbg_margin := 0.0
var _dbg_desired := 0.0
var _dbg_input := 0.0
var _dbg_friction := 0.0
var _dbg_ski := false
var _dbg_fresh := false
const BLOCK_MIN_WANT := 0.02 # m per frame -- below this you asked for nothing
const BLOCK_RATIO := 0.5 # got less than half of what you asked for
var block_log: Array = []
func _record_block(delta: float, from_pos: Vector3, want: Vector3) -> void:
if delta <= 0.0:
return
var up: Vector3 = -gravity_dir
var want_h: Vector3 = want - up * want.dot(up)
var want_len: float = want_h.length()
var got: Vector3 = global_position - from_pos
var got_h: Vector3 = got - up * got.dot(up)
# Progress ALONG the direction asked for. A slide that carries you sideways
# is not being blocked; being carried backwards is worse than being stopped.
var progress: float = 0.0 if want_len < 1e-9 else got_h.dot(want_h / want_len)
# Contact geometry, for the frames that ARE trying to move. The block log had
# these and the motion trace did not, so the rows that matter most -- pushing
# against something and going nowhere -- were the ones missing the evidence.
var m_depth := 0.0
var m_worst := -1.0
# WHAT the contacts are, kept per frame rather than summarised away. A
# contact normal 168 degrees from up -- a ceiling -- was measured on ground
# whose steepest triangle is 11.8 degrees, with nothing overhead and no
# stale parent body. That leaves "it is not what I assume it is", and the
# only way to answer it is to write down the collider and the position at
# the moment it happens.
var m_contacts: Array = []
for ci in range(get_slide_collision_count()):
var cc := get_slide_collision(ci)
if cc.get_depth() > m_depth:
m_depth = cc.get_depth()
var cd: float = rad_to_deg(acos(clampf(cc.get_normal().dot(up), -1.0, 1.0)))
if cd > m_worst:
m_worst = cd
if m_contacts.size() < 8:
var ob: Object = cc.get_collider()
var desc := "bodiless (terrain chunk)"
if ob != null:
desc = ob.get_class()
if ob is Node:
desc = "%s:%s" % [(ob as Node).name, ob.get_class()]
m_contacts.append({
"deg": cd,
"depth": cc.get_depth(),
"who": desc,
"at": cc.get_position(),
"n": cc.get_normal(),
})
# EVERY FRAME, UNCONDITIONALLY.
#
# This used to record only frames where progress fell below half of what was
# asked -- my definition of "blocked" -- and on the map the user reports as
# unwalkable it recorded NOTHING while the good map also recorded nothing.
# A movement problem that is stuttering, or being slowed, or losing the
# floor, produces no such frames at all, so the instrument answered "there
# is no problem" about a problem that was on the screen.
#
# So the trace makes no judgement. It stores what happened; the reader
# decides what bad looks like, and can change its mind without another
# session of walking around.
_motion_log.append({
"t": Time.get_ticks_msec(),
"want": want_len,
"got": progress,
"speed": (got_h / delta).length(),
"vy": velocity.dot(up),
"on_floor": is_on_floor(),
"contacts": get_slide_collision_count(),
"ground_deg": rad_to_deg(acos(clampf(ground_normal.dot(up), -1.0, 1.0))),
"state": state,
"input": _dbg_input,
"desired": _dbg_desired,
"traction": _dbg_traction,
"friction": _dbg_friction,
"hold_frac": _dbg_hold_frac,
"margin": _dbg_margin,
"ski": _dbg_ski,
"depth": m_depth,
"worst_deg": m_worst,
"contact_info": m_contacts,
"gnormal": ground_normal,
# WAS _update_movement EVEN CALLED THIS FRAME? Every term above it is
# only written inside that function, so when it is skipped -- flipping,
# a roll, any state that is not MOVEMENT -- the trace repeats the last
# healthy values and reads as a character that is fine and simply not
# going anywhere. That is indistinguishable from the real thing without
# this flag.
"fresh": _dbg_fresh,
# `flipping` is a LOCAL in _physics_process, not a member, so ask the
# animator the same question it asks.
"flipping": _proc_animator != null and _proc_animator.is_flipping(),
})
_dbg_fresh = false
if _motion_log.size() > MOTION_LOG_MAX:
_motion_log.pop_front()
if want_len < BLOCK_MIN_WANT:
return
if progress > want_len * BLOCK_RATIO:
return
var worst_deg := -1.0
var worst_normal := Vector3.ZERO
var walls := 0
var max_depth := 0.0
var colliders: Array = []
for i in range(get_slide_collision_count()):
var col := get_slide_collision(i)
var nrm: Vector3 = col.get_normal()
var deg: float = rad_to_deg(acos(clampf(nrm.dot(up), -1.0, 1.0)))
if deg > floor_max_angle_deg():
walls += 1
if deg > worst_deg:
worst_deg = deg
worst_normal = nrm
# WHAT is touching you, and how far INTO it you are.
#
# Neither was recorded, so "three contacts on a 13 degree face" could
# equally be the terrain, a prop, another player, or the same terrain
# body reported three times -- and depth separates a surface you are
# sliding along from one you are embedded in and being pushed out of.
# A depenetration fight and a slope you cannot climb look identical in
# the progress number alone.
if col.get_depth() > max_depth:
max_depth = col.get_depth()
var who: Object = col.get_collider()
var label := "<freed>"
if who != null:
label = "%s(%s)" % [who.get_class(), str(who.get("name")) if who.has_method("get") else "?"]
if not colliders.has(label):
colliders.append(label)
block_log.append({
"t": Time.get_ticks_msec(),
"pos": global_position,
"want": want_len,
"got": progress,
"frac": progress / want_len,
"collisions": get_slide_collision_count(),
"walls": walls,
"worst_deg": worst_deg,
"worst_normal": worst_normal,
"depth": max_depth,
"colliders": colliders,
"on_floor": is_on_floor(),
"state": state,
})
if block_log.size() > BLOCK_LOG_MAX:
block_log.pop_front()
func floor_max_angle_deg() -> float:
return rad_to_deg(floor_max_angle)
## HOW WELL DID YOU ACTUALLY MOVE, over the window. No threshold, no
## definition of "blocked" -- just what fraction of the motion asked for was
## delivered, how often the floor was lost, and how ragged the delivery was.
##
## Travel efficiency is the number to compare BETWEEN MAPS: it is meaningful
## whether the loss comes from stopping, stuttering, sliding or bouncing.
func motion_summary(window_ms: int = 10000) -> Dictionary:
var now: int = Time.get_ticks_msec()
var rows: Array = []
for e in _motion_log:
if now - int(e["t"]) <= window_ms and float(e["want"]) > 0.001:
rows.append(e)
var out := {
"frames": rows.size(), "window_ms": window_ms,
"want_total": 0.0, "got_total": 0.0, "efficiency": 0.0,
"on_floor_pct": 0.0, "floor_transitions": 0,
"p10": 0.0, "p50": 0.0, "p90": 0.0,
"mean_contacts": 0.0, "max_vy": 0.0, "min_vy": 0.0,
"mean_ground_deg": 0.0,
"mean_input": 0.0, "mean_desired": 0.0, "min_traction": 9.9,
"mean_traction": 0.0, "min_friction": 9.9, "ski_frames": 0,
"stale_frames": 0, "flip_frames": 0,
"trying_frames": 0, "trying_eff": 0.0, "trying_contacts": 0.0,
"trying_depth": 0.0, "trying_worst_deg": 0.0,
}
if rows.is_empty():
return out
var fracs := PackedFloat32Array()
var on_floor := 0
var prev_floor: bool = bool(rows[0]["on_floor"])
var contacts := 0.0
var gdeg := 0.0
for e in rows:
out["want_total"] = float(out["want_total"]) + float(e["want"])
out["got_total"] = float(out["got_total"]) + float(e["got"])
fracs.append(float(e["got"]) / maxf(float(e["want"]), 1e-9))
if bool(e["on_floor"]):
on_floor += 1
if bool(e["on_floor"]) != prev_floor:
out["floor_transitions"] = int(out["floor_transitions"]) + 1
prev_floor = bool(e["on_floor"])
contacts += float(e["contacts"])
gdeg += float(e["ground_deg"])
var vy: float = float(e["vy"])
if vy > float(out["max_vy"]): out["max_vy"] = vy
if vy < float(out["min_vy"]): out["min_vy"] = vy
out["mean_input"] = float(out["mean_input"]) + float(e.get("input", 0.0))
out["mean_desired"] = float(out["mean_desired"]) + float(e.get("desired", 0.0))
out["mean_traction"] = float(out["mean_traction"]) + float(e.get("traction", 0.0))
if float(e.get("traction", 9.9)) < float(out["min_traction"]):
out["min_traction"] = e.get("traction", 9.9)
if float(e.get("friction", 9.9)) < float(out["min_friction"]):
out["min_friction"] = e.get("friction", 9.9)
if bool(e.get("ski", false)):
out["ski_frames"] = int(out["ski_frames"]) + 1
if not bool(e.get("fresh", false)):
out["stale_frames"] = int(out["stale_frames"]) + 1
if bool(e.get("flipping", false)):
out["flip_frames"] = int(out["flip_frames"]) + 1
# THE FRAMES WHERE A KEY WAS ACTUALLY DOWN. Standing still and being pinned
# both show want = 0, because want comes from velocity; separating them is
# the difference between "the player let go" and "the player cannot move".
var tw := 0.0
var tg := 0.0
var tc := 0.0
var td := 0.0
var tdeg := 0.0
var tn := 0
for e in rows:
if float(e.get("input", 0.0)) < 0.01:
continue
tn += 1
tw += float(e["want"])
tg += float(e["got"])
tc += float(e["contacts"])
td = maxf(td, float(e.get("depth", 0.0)))
tdeg = maxf(tdeg, float(e.get("worst_deg", -1.0)))
# THE WORST FRAME, INTACT. Averages cannot answer "what was touching me";
# one real frame can.
var worst_row: Dictionary = {}
var worst_score := 1e30
for e in rows:
if float(e.get("input", 0.0)) < 0.01:
continue
if float(e["want"]) < 0.01:
continue
var fr: float = float(e["got"]) / maxf(float(e["want"]), 1e-9)
if fr < worst_score:
worst_score = fr
worst_row = e
out["worst_frame"] = worst_row
out["trying_frames"] = tn
if tn > 0:
out["trying_eff"] = tg / maxf(tw, 1e-9)
out["trying_contacts"] = tc / float(tn)
out["trying_depth"] = td
out["trying_worst_deg"] = tdeg
out["efficiency"] = float(out["got_total"]) / maxf(float(out["want_total"]), 1e-9)
out["on_floor_pct"] = 100.0 * float(on_floor) / float(rows.size())
out["mean_contacts"] = contacts / float(rows.size())
out["mean_input"] = float(out["mean_input"]) / float(rows.size())
out["mean_desired"] = float(out["mean_desired"]) / float(rows.size())
out["mean_traction"] = float(out["mean_traction"]) / float(rows.size())
out["mean_ground_deg"] = gdeg / float(rows.size())
fracs.sort()
out["p10"] = fracs[int(floor(float(fracs.size() - 1) * 0.10))]
out["p50"] = fracs[int(floor(float(fracs.size() - 1) * 0.50))]
out["p90"] = fracs[int(floor(float(fracs.size() - 1) * 0.90))]
return out
## The raw trace, for writing to disk. Summaries hide shapes.
func motion_trace_csv(window_ms: int = 15000) -> String:
var now: int = Time.get_ticks_msec()
var lines: Array[String] = ["t_ms,want_m,got_m,frac,speed_ms,vy_ms,on_floor,contacts,ground_deg,state,input,desired,traction,friction,hold_frac,margin,ski,depth,worst_deg,fresh,flipping"]
for e in _motion_log:
if now - int(e["t"]) > window_ms:
continue
lines.append("%d,%.5f,%.5f,%.4f,%.3f,%.4f,%d,%d,%.2f,%d,%.3f,%.3f,%.4f,%.3f,%.4f,%.4f,%d,%.5f,%.2f,%d,%d" % [
int(e["t"]) - now, e["want"], e["got"],
float(e["got"]) / maxf(float(e["want"]), 1e-9),
e["speed"], e["vy"], 1 if bool(e["on_floor"]) else 0,
e["contacts"], e["ground_deg"], e["state"],
e.get("input", 0.0), e.get("desired", 0.0), e.get("traction", -1.0),
e.get("friction", 0.0), e.get("hold_frac", 0.0), e.get("margin", 0.0),
1 if bool(e.get("ski", false)) else 0,
e.get("depth", 0.0), e.get("worst_deg", -1.0),
1 if bool(e.get("fresh", false)) else 0,
1 if bool(e.get("flipping", false)) else 0])
return "\n".join(PackedStringArray(lines))
## Summary for the terrain probe. Kept here so the definition of "blocked" has
## exactly one home.
func block_summary(window_ms: int = 10000) -> Dictionary:
var now: int = Time.get_ticks_msec()
var recent: Array = []
for e in block_log:
if now - int(e["t"]) <= window_ms:
recent.append(e)
var out := {
"events_total": block_log.size(),
"events_window": recent.size(),
"window_ms": window_ms,
"floor_max_angle_deg": floor_max_angle_deg(),
"worst_deg": 0.0, "mean_frac": 0.0, "wall_blocked": 0, "full_stops": 0,
"max_depth": 0.0,
"last": null,
}
if recent.is_empty():
return out
var facc := 0.0
for e in recent:
facc += float(e["frac"])
if float(e["worst_deg"]) > float(out["worst_deg"]):
out["worst_deg"] = e["worst_deg"]
if int(e["walls"]) > 0:
out["wall_blocked"] = int(out["wall_blocked"]) + 1
if float(e["frac"]) < 0.05:
out["full_stops"] = int(out["full_stops"]) + 1
if float(e.get("depth", 0.0)) > float(out["max_depth"]):
out["max_depth"] = e["depth"]
out["mean_frac"] = facc / float(recent.size())
out["last"] = recent[recent.size() - 1]
return out
# ---- nameplate ---------------------------------------------------------------
## THE LABEL IS THE VERIFIED KEET MEMBER'S ROSTER NAME (hyper-games #89 §3.3).
##
## It used to be self-asserted: an any_peer `_set_player_name` RPC (any peer could
## relabel any other) plus a realtime player-name state copy that went out unreliable
## and was never replayed, so a late joiner saw "Player". KeetService.peer_identity
## binds each mesh peer to a keet member by a proof checked against the key the
## swarm handshake authenticated, and names it from the room roster; this only
## displays that, and follows it by signal (claims, roster, peers leaving).
func _init_nameplate() -> void:
if KeetService == null or KeetService.peer_identity == null:
return
_apply_nameplate()
if not KeetService.peer_identity.names_changed.is_connected(_apply_nameplate):
KeetService.peer_identity.names_changed.connect(_apply_nameplate)
func _apply_nameplate() -> void:
if not is_instance_valid(name_label) or KeetService == null or KeetService.peer_identity == null:
return
name_label.text = KeetService.peer_identity.name_for_peer(get_multiplayer_authority())
func _exit_tree() -> void:
if KeetService == null or KeetService.peer_identity == null:
return
if KeetService.peer_identity.names_changed.is_connected(_apply_nameplate):
KeetService.peer_identity.names_changed.disconnect(_apply_nameplate)