extends Node3D
## When the player is ON THE GROUND, do the feet still find the GROUND — even if
## wall_run_active happens to be set?
##
## WHY THIS EXISTS. _detect_walls runs in the AIRBORNE state and its Method 2
## sphere-sweeps 2 m in three directions whenever sprint is held — which is how
## you run. On a mountainous map is_on_floor() flickers as you cross steep
## ground, so the player dips into AIRBORNE constantly, and anything steeper than
## 60 degrees passes the wall test (`absf(n.dot(up_direction)) < 0.5`). So
## wall_run_active can latch while you are, to every appearance, walking.
##
## That was harmless for as long as wall_run_active only added an 8 degree
## cosmetic lean. It stopped being harmless when the animator started routing
## FootIK's sweep direction off it: procedural_animator passes
## `ik_up = (-wall_run_normal).normalized() if wall_run_active else local_up`, so
## the feet hunt SIDEWAYS along the wall normal while the player stands on a floor.
##
## The invariant: GROUND CONTACT WINS. If is_grounded, the feet sweep down.
##
## THE RIG RUNS A LIVE PLAYER, and that is the fix to this gate rather than to the
## game. It used to set process_mode = PROCESS_MODE_DISABLED and then call
## move_and_slide() itself — but a disabled node is removed from the physics
## space, so every one of those calls failed with "body->get_space() is null" and
## did nothing. The player never landed, is_on_floor() was never true, and the
## gate reported FAIL for a reason that had nothing to do with the invariant. It
## was not measuring the game at all. (#47 section 2.1, again.)
##
## Bone poses are read in the skeleton_updated signal, NOT in _process:
## get_bone_global_pose() does not see a SkeletonModifier3D's writes, because
## Godot restores the pre-modifier pose after the skeleton update.
##
## Run:
## "$HYPER_TPS_GODOT" --headless res://scenes/test/wall_run_ground_gate.tscn
const FLOOR_Y := 0.0
const WALL_X := 0.6
const SETTLE := 70 ## land, and let the landing crouch spring settle
const HOLD := 30 ## frames with wall_run_active forced on
var _player: CharacterBody3D = null
var _f := 0
var _skel: Node = null
var _done := false
var _sampled := false
var _hits: Array = [false, false]
var _pts: Array = [Vector3.ZERO, Vector3.ZERO]
var _on_floor := false
var _wr := false
func _ready() -> void:
process_priority = 100
var floor_body := StaticBody3D.new()
var fcs := CollisionShape3D.new()
var fbox := BoxShape3D.new()
fbox.size = Vector3(40.0, 1.0, 40.0)
fcs.shape = fbox
floor_body.add_child(fcs)
floor_body.position = Vector3(0.0, FLOOR_Y - 0.5, 0.0)
add_child(floor_body)
# A wall beside it, so a sideways sweep has something to find. Without it the
# broken behaviour is a MISS rather than a wrong hit, and the gate would pass
# for the wrong reason.
var wall := StaticBody3D.new()
var wcs := CollisionShape3D.new()
var wbox := BoxShape3D.new()
wbox.size = Vector3(0.4, 20.0, 20.0)
wcs.shape = wbox
wall.add_child(wcs)
wall.position = Vector3(WALL_X + 0.2, 5.0, 0.0)
add_child(wall)
var scene: PackedScene = load("res://scenes/player/player_procedural.tscn")
_player = scene.instantiate()
add_child(_player)
_player.global_position = Vector3(0.0, FLOOR_Y + 0.6, 0.0)
_player.camera_head.global_transform = Transform3D(Basis.IDENTITY, Vector3.ZERO)
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)
_f += 1
if _f <= SETTLE:
return
# The state _detect_walls can latch on steep ground while sprint is held.
# Set every frame, before the player's own _physics_process runs.
_player.wall_run_active = true
_player.wall_run_normal = Vector3(1.0, 0.0, 0.0) # into the wall
func _on_skeleton_updated() -> void:
# Sample over 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 _f < SETTLE + HOLD - 10:
return
var anim: Node = _find(_player, "ProceduralAnimator")
if anim == null:
return
var fik: Variant = anim.get("_foot_ik")
if fik == null:
return
_hits = (fik.get("_ground_hit") as Array).duplicate()
_pts = (fik.get("debug_ground_points") as Array).duplicate()
_on_floor = _player.is_on_floor()
_wr = _player.wall_run_active
_sampled = true
func _process(_d: float) -> void:
if _done or _player == null or _f < SETTLE + HOLD:
return
if not _sampled and _f < SETTLE + HOLD + 60:
return # give the skeleton update a chance to arrive
_done = true
print("")
if not _sampled:
print("RIG FAILED: never sampled the skeleton — animator=%s skel=%s."
% [str(_find(_player, "ProceduralAnimator") != null), str(_skel != null)])
get_tree().quit(1)
return
print("on_floor = %s, wall_run_active = %s" % [str(_on_floor), str(_wr)])
if not _on_floor:
print("")
print("RIG FAILED: the player is not standing on the floor, so this says")
print("nothing about whether ground contact wins.")
get_tree().quit(1)
return
if not _wr:
print("")
print("RIG FAILED: wall_run_active did not stay set, so the conflict this")
print("gate exists to test never happened.")
get_tree().quit(1)
return
var ok := true
for i in range(2):
var p: Vector3 = _pts[i]
var on_ground: bool = bool(_hits[i]) and absf(p.y - FLOOR_Y) < 0.15
var on_wall: bool = bool(_hits[i]) and absf(p.x - WALL_X) < 0.15
print(" foot %d hit=%s point=%s -> %s" % [i, str(_hits[i]), str(p),
"GROUND" if on_ground else ("WALL" if on_wall else "neither")])
if not on_ground:
ok = false
print("")
if ok:
print("PASS grounded feet find the ground even with wall_run_active set")
get_tree().quit(0)
else:
print("FAIL the feet are not on the ground — ground contact must win")
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