extends SceneTree
## GATE: a head turn is spread down the spine, not bent at one joint.
##
## DISPLAY=:0 "$G" --path . --rendering-driver vulkan --script res://scenes/test/gate_spine_look.gd
##
## Reported 2026-10-01: "when the player looks around it looks like it is rotating
## only on one spine". ChestIK put half the turn on DEF-spine.002 and half on .003,
## leaving .001 (and the pelvis) straight. Overgrowth's DrawBody spreads the chest
## key's rotation along the chain. The player stands still; the camera turns
## TURN_DEG to the side; each spine bone's LOCAL rotation change against the
## straight-ahead baseline is its share of the twist. Passes when every one of
## .001/.002/.003 carries at least MIN_SHARE and none more than MAX_SHARE.
const TURN_DEG := 60.0
const SETTLE := 2.0
const BONES := ["DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
const MIN_SHARE := 0.15
const MAX_SHARE := 0.5
var _player: Node3D
var _skel: Skeleton3D
var _anim: Node
var _ran := false
class StubGame extends Node3D:
var current_map: Node = null
func _process(_d: float) -> bool:
if not _ran:
_ran = true
_run()
return false
func _find_skel(n: Node) -> Skeleton3D:
if n is Skeleton3D:
return n
for c in n.get_children():
var r := _find_skel(c)
if r != null:
return r
return null
func _rots() -> Array:
var out := []
for b: String in BONES:
out.append(_skel.get_bone_pose_rotation(_skel.find_bone(b)))
return out
func _avg_rots(frames: int) -> Array:
# Averaged over frames: the idle gaze adds a little noise on top of the look.
var acc: Array = []
for i in frames:
await _skel.skeleton_updated
var r := _rots()
if acc.is_empty():
acc = r
else:
for k in r.size():
acc[k] = (acc[k] as Quaternion).slerp(r[k], 1.0 / float(i + 1))
return acc
func _run() -> void:
get_multiplayer().multiplayer_peer = OfflineMultiplayerPeer.new()
var game := StubGame.new()
game.name = "Game"
root.add_child(game)
var fb := StaticBody3D.new()
var fc := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(200, 2, 200)
fc.shape = box
fb.add_child(fc)
fb.position = Vector3(0, -1, 0)
game.add_child(fb)
_player = (load("res://scenes/player/player_procedural.tscn") as PackedScene).instantiate()
game.add_child(_player)
_player.global_position = Vector3(0, 0.3, 0)
_skel = _find_skel(_player)
var head: Node3D = _player.get_node("CameraHead")
# PIN THE IDLE GAZE. Standing still blends 40% of a random gaze into the look,
# re-picked every 0.8-2 s, which swung the measured totals 24-122 deg between
# runs of the same code. No new random target for the run; none now.
var anim: Node = _find_anim(_player)
if anim != null:
anim.set("_random_look_delay", 1.0e9)
anim.set("_random_look_dir", Vector3.ZERO)
# Look the way the body faces first. The rig's visual forward is the model's +Z
# (180 deg import flip) and a freshly spawned player faces the default camera,
# so the controller hands the animator NO look direction at all (it requires
# cam_look . model.z > 0) -- the first run measured 0.3 deg of twist for a 60
# deg turn because nothing was looking. In play you face away from the camera
# after the first step; this is that state.
head.rotation.y = PI
await create_timer(SETTLE).timeout
var base: Array = await _avg_rots(30)
head.rotation.y += deg_to_rad(TURN_DEG)
await create_timer(SETTLE).timeout
var turned: Array = await _avg_rots(30)
# SWING-TWIST about the bone's own axis (+Y on this rig, measured): a head turn
# should be TWIST down the spine. Bend (swing) is reported so a rotation on the
# wrong axis cannot pass as a turn.
var deltas: Array[float] = []
var total := 0.0
var bend := ""
for k in BONES.size():
var q: Quaternion = (base[k] as Quaternion).inverse() * (turned[k] as Quaternion)
if q.w < 0.0:
q = -q
var twist: float = rad_to_deg(2.0 * atan2(q.y, q.w))
var tq := Quaternion(Vector3.UP, deg_to_rad(twist))
var swing: float = rad_to_deg((tq.inverse() * q).get_angle())
bend += "%s bend %.1f " % [BONES[k], swing]
deltas.append(absf(twist))
total += absf(twist)
var ok := total > 1.0
var line := ""
for k in BONES.size():
var share: float = deltas[k] / maxf(total, 0.0001)
line += "%s %.1f deg (%.0f%%) " % [BONES[k], deltas[k], share * 100.0]
if share < MIN_SHARE or share > MAX_SHARE:
ok = false
print("[SPINE] turn %.0f deg, TWIST: %s total %.1f deg" % [TURN_DEG, line, total])
print("[SPINE] bend (should stay small): %s" % bend)
print("RESULT: %s" % ("PASS" if ok else "FAIL"))
quit(0 if ok else 1)
func _find_anim(n: Node) -> Node:
if n.name == "ProceduralAnimator":
return n
for c in n.get_children():
var r := _find_anim(c)
if r != null:
return r
return null