extends Node3D
## Wall running: is the body tilted, are the feet on the WALL, is the head upright?
##
## THE SPEC, as stated: the player is tilted at roughly 45 degrees, the feet do
## their run-cycle IK on the WALL surface, and the torso counter-rotates so the
## head stays roughly perpendicular to the GROUND rather than to the wall.
##
## Three independent quantities, so three separate measurements. A single
## "looks right" would hide which of the three is missing, and at the time this
## gate was written the answer was all three.
##
## THE RIG RUNS A LIVE PLAYER, and that was a repair to this gate, not to the game.
## It used to set process_mode = PROCESS_MODE_DISABLED so that _physics_process
## could not fight the state it set, then drive _process_local and
## _process_modification by hand. But a disabled node is removed from the physics
## space: every move_and_slide inside _process_local failed with
## "body->get_space() is null", on all 40 frames, and the player never moved at
## all. What survived was the sphere-sweep half of _detect_walls, so the gate
## passed while testing a wall run that had no motion in it. (#47 section 2.1.)
##
## Now the engine runs the player, which also means the skeleton gets its normal
## pre-modifier bone reset each frame -- so poses can no longer compound, and the
## manual single-pass trick that worked around that is gone with it. Bone poses
## are read in the skeleton_updated signal, because get_bone_global_pose() does
## not see a SkeletonModifier3D's writes from _process.
##
## Run:
## "$HYPER_TPS_GODOT" --headless res://scenes/test/wall_run_gate.tscn
const WALL_X := 0.6 ## wall plane, just to the player's right
## In CONTACT with the wall, which is what a wall run is: the capsule radius is
## 0.3 (player_procedural.tscn:14), so the body centre sits 0.3 m off the face.
const PLAYER_X := WALL_X - 0.3
const SPAWN_Y := 60.0 ## high, and there is no floor: the run must be a
## wall run for its whole length, never a landing.
const SETTLE := 25 ## the lean slerp and the IK springs
const FRAMES := 70
## The spec's numbers. Tolerances are generous because this is a FEEL feature --
## the gate exists to catch "not happening at all" and "hinged the wrong way",
## not to pin a degree.
const TILT_TARGET_DEG := 45.0
const TILT_TOL_DEG := 12.0
## Measured against the head's own UPRIGHT-BODY orientation, not against world
## up. The first run of this gate compared the head bone's local Y to Vector3.UP
## and read 123.9 deg at ZERO body tilt — this Rigify rig's head bone simply is
## not axis-aligned that way. That was the instrument, so the reference is now
## the same head in a no-wall-run frame, captured by this gate.
const HEAD_UPRIGHT_TOL_DEG := 20.0
const FOOT_ON_WALL_TOL := 0.25 ## metres from the wall plane
## The model must SETTLE. Reported as the largest change in the model's up over
## the last SETTLE_WINDOW frames: a converged lean moves ~0, an orientation being
## fought over by two mechanisms oscillates.
const SETTLE_WINDOW := 10
const SETTLE_TOL_DEG := 2.0
## And it must face along the WALL. The run direction is the wall tangent; a lean
## that preserves whatever facing it started with is the "not even forward"
## half of the report.
const FACING_TOL_DEG := 25.0
## A yaw deliberately NOT aligned with the wall tangent. The first version of this
## gate left the model at yaw 0 against an axis-aligned wall, which put the lean's
## roll axis exactly on the model's local Z -- and the Euler round-trip in the
## `else` branch is a no-op in precisely that configuration. It passed while the
## real game flipped sideways.
const START_YAW_DEG := 50.0
## INTO the wall, which is what _detect_walls stores: it sets
## `wall_normal_detected = -col.get_normal()`, and Godot's slide normal points
## out of the surface toward the body. Wall is at +X, so this is +X.
const WALL_NORMAL := Vector3(1.0, 0.0, 0.0)
var _player: CharacterBody3D = null
var _wall: StaticBody3D = null
var _skel: Node = null
var _phase := 0 ## 0 = upright baseline, 1 = wall run
var _f := 0
var _done := false
var _roll_override: float = -1.0
var _up_history: Array = []
## Deviation from the ANALYTIC target lean, every frame after the slerp has had
## time to arrive. The settle window only looks at the last 10 frames, so it
## cannot see an orientation that wobbles on the way in and happens to be still
## at the end -- which is exactly what two mechanisms fighting looks like.
var _worst_track := 0.0
var _head_up_ref := Vector3.ZERO
var _model_up_ref := Vector3.ZERO
## Sampled inside skeleton_updated, because that is the only place the modifier's
## writes are visible.
var _s_head_up := Vector3.ZERO
var _s_hits: Array = [false, false]
var _s_pts: Array = [Vector3.ZERO, Vector3.ZERO]
var _s_ok := false
func _ready() -> void:
process_priority = 100
var args: PackedStringArray = OS.get_cmdline_user_args()
for i in range(args.size()):
if args[i] == "--roll" and i + 1 < args.size():
_roll_override = float(args[i + 1])
_wall = StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(0.4, 400.0, 400.0)
cs.shape = box
_wall.add_child(cs)
# Wall face at x = WALL_X, so its outward normal toward the player is -X.
_wall.position = Vector3(WALL_X + 0.2, 0.0, -180.0)
add_child(_wall)
# A floor for the BASELINE phase only, far from the wall. The reference pose
# has to be comparable to the wall run or the head comparison is measuring the
# difference between two ANIMATIONS. Taking it from a falling player read 90.4
# deg against a running one -- a fall pose and a run pose, not a counter-roll.
# So the baseline runs, at the same speed, on the ground, with FootIK planting
# just as it does on the wall.
var floor_body := StaticBody3D.new()
var fcs := CollisionShape3D.new()
var fbox := BoxShape3D.new()
fbox.size = Vector3(60.0, 1.0, 400.0)
fcs.shape = fbox
floor_body.add_child(fcs)
floor_body.position = Vector3(-40.0, -0.5, -180.0)
add_child(floor_body)
_spawn()
func _spawn() -> void:
var scene: PackedScene = load("res://scenes/player/player_procedural.tscn")
_player = scene.instantiate()
add_child(_player)
# PHASE 0 is the control: the same player, wall run OFF, well away from the
# wall and falling, so FootIK fades out and the model stays upright. It
# supplies the head's upright reference, and it is what catches the gate
# measuring its own convention instead of the game.
# Phase 1 hangs in the air against the wall; phase 0 runs on the floor slab,
# which does not extend under the wall.
if _phase == 1:
_player.global_position = Vector3(PLAYER_X, SPAWN_Y, 16.0)
elif OS.has_environment("HTPS_BASE_AIR"):
_player.global_position = Vector3(-40.0, SPAWN_Y, 16.0)
else:
_player.global_position = Vector3(-40.0, 0.6, 16.0)
_player.camera_head.global_transform = Transform3D(Basis.IDENTITY, Vector3.ZERO)
# Face somewhere other than along the wall (see START_YAW_DEG).
var m: Node3D = _player.get_node("Model")
m.global_transform.basis = Basis(Vector3.UP, deg_to_rad(START_YAW_DEG))
# Along the wall, and into it, so move_and_slide produces the contact
# _detect_walls reads.
_player.velocity = Vector3(3.0, 0.0, -8.0) if _phase == 1 else Vector3(0.0, 0.0, -8.0)
_skel = null
_f = 0
_up_history.clear()
_worst_track = 0.0
_s_ok = false
func _physics_process(_d: float) -> void:
if _done or _player == null:
return
if _skel == null:
_skel = _find_skel(_player)
if _skel != null and _skel.has_signal("skeleton_updated"):
_skel.skeleton_updated.connect(_on_skeleton_updated)
# sprint is what gates a wall run, and Input.action_press works headless.
# THE INPUT MUST BE HELD EVERY FRAME. Setting velocity once at spawn is not a
# run: _apply_surface_move drives velocity toward a `desired` built from the
# input, so with no input the player decelerates to a standstill ON the wall
# and the gate then measures a stationary hang. Measured that way the model up
# settles to (0,0,-1) with velocity (0,-0.1,0) -- which is the near-zero
# along-wall velocity corner #47 said no gate could reach, and worth its own
# row one day, but it is not what this gate is for.
# HTPS_SLOW stages a wall run with no along-wall input -- the state a player is
# in a moment after grabbing a wall, before they have any speed along it.
_player.input_sync.input_direction = (Vector2.ZERO
if OS.has_environment("HTPS_SLOW") else Vector2(0.0, -1.0))
# Overrides are applied EVERY FRAME, not at spawn: both objects are created
# lazily inside the first modification pass, so a spawn-time assignment finds
# null and silently does nothing.
# DIAGNOSTIC SWITCHES. Every mechanism that can touch the head, individually
# defeatable, because "the head is wrong" does not say WHICH of five things is
# doing it. HTPS_ROLL / HTPS_TRUNK / HTPS_HEADIK / HTPS_CHESTIK / HTPS_FOOTIK /
# HTPS_TILT, and HTPS_BASE_AIR to take the baseline in the air instead of on
# the ground. Applied EVERY FRAME: these objects are created lazily inside the
# first modification pass, so a spawn-time assignment finds null and silently
# does nothing.
var an: Node = _find(_player, "ProceduralAnimator")
if an != null:
var tr2: Variant = an.get("_torso_roll")
if tr2 != null and OS.has_environment("HTPS_ROLL"):
tr2.strength = float(OS.get_environment("HTPS_ROLL"))
var fi2: Variant = an.get("_foot_ik")
if fi2 != null and OS.has_environment("HTPS_TRUNK"):
fi2.trunk_counter = float(OS.get_environment("HTPS_TRUNK"))
if OS.has_environment("HTPS_HEADIK"):
an.enable_head_ik = OS.get_environment("HTPS_HEADIK") == "1"
if OS.has_environment("HTPS_FOOTIK"):
an.enable_foot_ik = OS.get_environment("HTPS_FOOTIK") == "1"
if OS.has_environment("HTPS_CHESTIK"):
an.enable_chest_ik = OS.get_environment("HTPS_CHESTIK") == "1"
if OS.has_environment("HTPS_TILT"):
_player.wall_run_tilt = float(OS.get_environment("HTPS_TILT"))
if _phase == 1:
Input.action_press("sprint")
else:
Input.action_release("sprint")
_f += 1
if _phase == 1 and _f > SETTLE:
var mu: Vector3 = (_player.get_node("Model") as Node3D).global_transform.basis.y.normalized()
_up_history.append(mu)
if _up_history.size() > SETTLE_WINDOW:
_up_history.pop_front()
var dv: float = rad_to_deg(mu.angle_to(_expected_up()))
if dv > _worst_track:
_worst_track = dv
func _on_skeleton_updated() -> void:
# A WINDOW, not one frame: the skeleton update does not line up with any
# particular physics frame, so a single-frame trigger silently misses.
if _done or _player == null or _skel == null or _f < FRAMES - 10:
return
var hidx: int = _skel.find_bone("DEF-spine.006")
if hidx >= 0:
_s_head_up = (_skel.global_transform * _skel.get_bone_global_pose(hidx)).basis.y.normalized()
var anim: Node = _find(_player, "ProceduralAnimator")
if anim != null:
var fik: Variant = anim.get("_foot_ik")
if fik != null:
_s_hits = (fik.get("_ground_hit") as Array).duplicate()
_s_pts = (fik.get("debug_ground_points") as Array).duplicate()
_s_ok = true
func _process(_d: float) -> void:
if _done or _player == null or _f < FRAMES:
return
if not _s_ok and _f < FRAMES + 60:
return
if not _s_ok:
print("RIG FAILED: never sampled the skeleton.")
_done = true
get_tree().quit(1)
return
if _phase == 0:
_model_up_ref = (_player.get_node("Model") as Node3D).global_transform.basis.y.normalized()
_head_up_ref = _s_head_up
print("baseline (wall run OFF): model up %s, head up %s — head %.1f deg from world up"
% [str(_model_up_ref), str(_head_up_ref), rad_to_deg(_head_up_ref.angle_to(Vector3.UP))])
_player.queue_free()
_player = null
_phase = 1
_spawn()
return
_done = true
_report()
## The lean the code is supposed to hold: world up rotated wall_run_tilt of the
## way toward the wall's OUTWARD normal.
func _expected_up() -> Vector3:
var outward: Vector3 = (-WALL_NORMAL).normalized()
var axis: Vector3 = Vector3.UP.cross(outward).normalized()
var ang: float = Vector3.UP.angle_to(outward) * float(_player.wall_run_tilt)
return (Basis(axis, ang) * Vector3.UP).normalized()
func _report() -> void:
var anim: Node = _find(_player, "ProceduralAnimator")
var model: Node3D = _player.get_node("Model")
print("")
print(" wall_run_active = %s, wall_run_normal = %s (if false, the rig never"
% [str(_player.wall_run_active), str(_player.wall_run_normal)])
print(" entered a wall run and everything below is measuring nothing)")
print("wall-run gate — wall plane x = %.2f, surface normal %s" % [WALL_X, str(WALL_NORMAL)])
print("")
# --- 1. body tilt ---
var model_up: Vector3 = model.global_transform.basis.y.normalized()
var tilt: float = rad_to_deg(model_up.angle_to(Vector3.UP))
var toward_wall: float = model_up.dot(-WALL_NORMAL) # >0 means leaning INTO the wall
# The gate follows the SHIPPED configuration rather than asserting a lean that
# is switched off. wall_run_tilt 0 means the body stays upright on purpose;
# raising it re-enables the lean and these checks assert it.
var lean_on: bool = float(_player.wall_run_tilt) > 0.0
# The target TRACKS the knob rather than a hardcoded 45: wall_run_tilt is the
# fraction of the way from upright to the wall's outward normal, so the analytic
# lean is 90 * wall_run_tilt. Hardcoding it meant retuning the feel silently
# moved the gate's own reference.
var want_tilt: float = (90.0 * float(_player.wall_run_tilt)) if lean_on else 0.0
var tilt_ok: bool = absf(tilt - want_tilt) <= (TILT_TOL_DEG if lean_on else 2.0)
print("1. BODY TILT %6.1f deg from world up (target %.0f, lean %s) %s"
% [tilt, want_tilt, "ON" if lean_on else "OFF", "ok" if tilt_ok else "<< FAIL"])
print(" model up %s, component toward wall %+.3f" % [str(model_up), toward_wall])
# --- 2. head upright ---
# With the lean off, what this must catch is the counter-roll firing when
# there is nothing to counter. It is NOT a "head unmoved" check: the baseline
# phase has FootIK switched off entirely (not grounded, not wall running, so
# it fades out and returns), while this phase has it planting on the wall --
# and FootIK compensates the HIP, which moves the head.
var head_deg: float = rad_to_deg(_s_head_up.angle_to(_head_up_ref))
var head_ok: bool = head_deg <= (HEAD_UPRIGHT_TOL_DEG if lean_on else 12.0)
var body_deg: float = rad_to_deg(model_up.angle_to(_model_up_ref))
# The counter-roll's own inputs, because "the head moved too far" does not say
# whether the axis, the sign or the magnitude is wrong.
if anim != null:
var tr: Variant = anim.get("_torso_roll")
if tr != null:
print(" counter-roll: axis(facing) %s model_up %s roll %.1f deg"
% [str(anim.get("facing_direction")), str(anim.get("model_up")),
rad_to_deg(float(tr.get("_roll")))])
print(" head up %s (ref %s), model up %s"
% [str(_s_head_up), str(_head_up_ref), str(model_up)])
print(" head vs world up %.1f deg, body vs world up %.1f deg — the head should"
% [rad_to_deg(_s_head_up.angle_to(Vector3.UP)), tilt])
print(" be the SMALLER of the two if the counter-roll is doing its job.")
print("2. HEAD UPRIGHT %6.1f deg from its UPRIGHT-BODY reference (<= %.0f) %s"
% [head_deg, HEAD_UPRIGHT_TOL_DEG, "ok" if head_ok else "<< FAIL"])
print(" the body moved %.1f deg over the same interval — the counter-roll is"
% body_deg)
print(" worth having only if the head moved a lot less than that.")
# --- 3. feet on the wall ---
var n_hit := 0
var worst := 0.0
for i in range(2):
if bool(_s_hits[i]):
n_hit += 1
var d: float = absf((_s_pts[i] as Vector3).x - WALL_X)
if d > worst:
worst = d
var feet_ok: bool = n_hit > 0 and worst <= FOOT_ON_WALL_TOL
print("3. FEET ON WALL %d/2 feet hit, worst %.3f m off the plane %s"
% [n_hit, worst, "ok" if feet_ok else "<< FAIL"])
for i in range(2):
print(" foot %d hit=%s point=%s" % [i, str(_s_hits[i]), str(_s_pts[i])])
# --- 4. does the lean SETTLE? ---
var wobble := 0.0
for i in range(1, _up_history.size()):
var dd: float = rad_to_deg((_up_history[i - 1] as Vector3).angle_to(_up_history[i]))
if dd > wobble:
wobble = dd
var settle_ok: bool = wobble <= SETTLE_TOL_DEG and _worst_track <= 5.0
print("4. SETTLES %6.2f deg largest frame-to-frame swing over the last %d %s"
% [wobble, SETTLE_WINDOW, "ok" if settle_ok else "<< FAIL"])
print(" worst deviation from the analytic lean after frame %d: %.2f deg (<= 5)"
% [SETTLE, _worst_track])
# --- 5. does it face along the wall? ---
# The run direction is the wall tangent, horizontal, matching the velocity.
var tangent: Vector3 = Vector3(-WALL_NORMAL.z, 0.0, WALL_NORMAL.x).normalized()
if tangent.dot(_player.velocity) < 0.0:
tangent = -tangent
var fwd: Vector3 = -model.global_transform.basis.z.normalized()
var face_deg: float = rad_to_deg(fwd.angle_to(tangent))
# Only asserted while the lean is driving the model basis. With the lean off
# the facing is the ordinary turn-toward-velocity behaviour, which is not
# this feature's business.
var face_ok: bool = face_deg <= FACING_TOL_DEG or not lean_on
print("5. FACES THE RUN %6.1f deg off the wall tangent (<= %.0f) %s"
% [face_deg, FACING_TOL_DEG, "ok" if face_ok else "<< FAIL"])
print(" forward %s, run direction %s" % [str(fwd), str(tangent)])
print("")
if not _player.wall_run_active:
print("RIG FAILED the player never entered a wall run, so nothing above is")
print(" about wall running.")
get_tree().quit(1)
elif tilt_ok and head_ok and feet_ok and settle_ok and face_ok:
print("PASS tilted, feet planted on the wall, head upright")
get_tree().quit(0)
else:
print("FAIL wall running does not meet the spec")
get_tree().quit(1)
func _find(n: Node, nm: String) -> Node:
if n.name == nm:
return n
for c in n.get_children():
var f: Node = _find(c, nm)
if f != null:
return f
return null
func _find_skel(n: Node) -> Node:
if n.get_class() == "Skeleton3D":
return n
for c in n.get_children():
var f: Node = _find_skel(c)
if f != null:
return f
return null