Files
Papay-Shooter/debug/weapon_hold_check.gd
2026-08-02 02:20:02 -04:00

249 lines
9.6 KiB
GDScript

extends SceneTree
## Does the character actually hold each weapon DIFFERENTLY?
##
## godot --path . -s res://debug/weapon_hold_check.gd
##
## Every weapon used to be solved as a rifle: stock in the shoulder pocket,
## support hand out along the barrel, muzzle on the aim line. So a knife, an AWP
## and a rocket launcher produced the same pose, and the only thing telling a
## viewer what was being carried was the weapon mesh itself — which at the
## distance an enemy is usually seen is a few pixels.
##
## This asserts the consequence, not the plumbing. Setting `support_mode` and
## reading it back proves nothing; a hold system is easy to build so that the
## profile loads, the enum is stored and the JSON round-trips while the arms do
## not move. So it measures WHERE THE HANDS AND HEAD ACTUALLY ARE, per weapon,
## and requires the poses to be distinguishable from each other.
##
## ── Measured from inside the modifier pass ──────────────────────────────────
##
## Godot restores every bone's local pose after `SkeletonModifier3D` runs, so
## reading `get_bone_global_pose` from a SceneTree script recomputes the globals
## from the ANIMATION alone — the shooter hold is not in what you measure, and
## every weapon would report an identical pose whether or not this feature
## exists. That is the single most expensive trap in this repo; see
## `references/verification.md`. The `PoseProbe` below is the fix.
##
## ── Measured in the SHOULDER's frame ────────────────────────────────────────
##
## Not in world space, and not even in skeleton space. The authored idle clip
## moves the whole torso, so a world-space sample would mix body motion into the
## hold. Taking each hand relative to the right shoulder joint in the chest's
## own basis cancels that motion.
const LAB := "res://debug/rig_lab.tscn"
## One weapon per style, plus the two rifles, so the table covers every branch
## and also shows that two weapons of the SAME style stay close together.
const CASES := [
["ak47", WeaponHoldProfiles.RIFLE],
["m4", WeaponHoldProfiles.RIFLE],
["mp7", WeaponHoldProfiles.SMG],
["awp", WeaponHoldProfiles.SNIPER],
["double_barrel_shotgun", WeaponHoldProfiles.SHOTGUN],
["rocket_launcher", WeaponHoldProfiles.LAUNCHER],
["knife", WeaponHoldProfiles.BLADE],
]
## Two weapons of DIFFERENT styles must place their hands at least this far
## apart, in metres, measured in the shoulder frame. Small — these are stylised
## characters with ~0.47 m arms — but far above the millimetre of noise the
## shoulder-frame measurement leaves behind.
const MIN_STYLE_SEPARATION := 0.045
## Two weapons of the SAME style should agree to within this.
const MAX_SAME_STYLE := 0.06
var _fails := 0
var _probe: PoseProbe = null
## Snapshot the pose from INSIDE the modifier pass. See the note above.
class PoseProbe extends SkeletonModifier3D:
var pose: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
func _init() -> void:
await process_frame
var lab: Node = load(LAB).instantiate()
root.add_child(lab)
for _i in 200:
await process_frame
if lab._model == null or lab._model._pose_mod == null:
_expect(false, "the lab built a character with a pose layer")
_done()
return
var skel: Skeleton3D = lab._model.skeleton
_probe = PoseProbe.new()
skel.add_child(_probe)
# AFTER the pose layer, so what it reads is what renders.
skel.move_child(_probe, skel.get_child_count() - 1)
var samples := {}
for case in CASES:
var weapon_id: String = case[0]
var want_style: String = case[1]
_expect(WeaponHoldProfiles.style_for(weapon_id) == want_style,
"%s is held as a %s" % [weapon_id, want_style])
# Whether this character has a SAVED hold for this weapon, which is
# allowed to disagree with the style profile. See `_compare`.
var tuned: bool = not WeaponHoldTuning.resolve(
WeaponHoldTuning.load_all(), lab._skins[lab._skin].id,
weapon_id).is_empty()
lab._model.hold_tune = {}
lab._model.set_weapon("res://weapons/%s.gd" % weapon_id)
# Long enough for HOLD_SMOOTH to arrive and for the weapon's `ready` to
# have measured it. The hold blends in at ~8/s, so ~60 frames is several
# time constants.
for _i in 90:
await process_frame
samples[weapon_id] = _sample(skel, lab._model)
if not samples[weapon_id].is_empty():
samples[weapon_id]["tuned"] = tuned
_report(samples)
_compare(samples)
_done()
## Both hands and the head, relative to the right shoulder, in the chest's basis.
func _sample(skel: Skeleton3D, model) -> Dictionary:
var mod = model._pose_mod
var sh_i: int = mod._idx.get("DEF-upper_arm.R", -1)
var chest_i: int = mod._idx.get("DEF-spine.003", -1)
if chest_i < 0:
chest_i = mod._idx.get("DEF-spine.002", -1)
var hand_r: int = mod._idx.get("DEF-hand.R", -1)
var hand_l: int = mod._idx.get("DEF-hand.L", -1)
var head_i: int = mod._idx.get("DEF-head", -1)
if sh_i < 0 or hand_r < 0 or hand_l < 0:
return {}
var pose: Array = _probe.pose
if pose.size() <= maxi(maxi(sh_i, hand_r), hand_l):
return {}
var shoulder: Vector3 = pose[sh_i].origin
# The chest's rotation, so a torso lean does not read as a moved hand.
var frame: Basis = Basis.IDENTITY
if chest_i >= 0 and chest_i < pose.size():
frame = pose[chest_i].basis.orthonormalized()
var inv := frame.inverse()
var out := {
"r": inv * (pose[hand_r].origin - shoulder),
"l": inv * (pose[hand_l].origin - shoulder),
"style": model.hold_style,
"support": mod.support_mode,
"hold_l": mod._hold_l,
}
if head_i >= 0 and head_i < pose.size():
# The head's TILT, which is what a cheek weld is. Taken as the angle
# between the head's up axis and the chest's, signed about forward, so a
# weld (toward the weapon) and a lean-away come out opposite.
var head_up: Vector3 = pose[head_i].basis.orthonormalized().y
var local := inv * head_up
out["cheek_deg"] = rad_to_deg(atan2(local.x, local.y))
return out
func _report(samples: Dictionary) -> void:
print("\n=== HOLD POSE PER WEAPON (metres, in the shoulder's frame) ===")
for id in samples:
var s: Dictionary = samples[id]
if s.is_empty():
continue
print(" %-22s %-9s support=%-7s handR=(%.3f %.3f %.3f) handL=(%.3f %.3f %.3f) offhand=%.2f tilt=%+.1f deg"
% [id, s["style"], s["support"],
s["r"].x, s["r"].y, s["r"].z, s["l"].x, s["l"].y, s["l"].z,
s["hold_l"], s.get("cheek_deg", 0.0)])
func _compare(samples: Dictionary) -> void:
# Every pair of DIFFERENT styles must be distinguishable.
var ids: Array = samples.keys()
for i in ids.size():
for j in range(i + 1, ids.size()):
var a: Dictionary = samples[ids[i]]
var b: Dictionary = samples[ids[j]]
if a.is_empty() or b.is_empty():
continue
# The blade releases its off hand to the animation, so comparing its
# left hand measures the idle clip, not the hold. Its trigger hand
# and its free offhand weight are what distinguish it.
var d: float = float(a["r"].distance_to(b["r"]))
if a["hold_l"] > 0.5 and b["hold_l"] > 0.5:
d = maxf(d, a["l"].distance_to(b["l"]))
if a["style"] == b["style"]:
# ...unless an artist has tuned one of them for THIS character.
# The profile is only a defaults layer; a saved hold is meant to
# be able to disagree with it, and aria's hand-tuned AK-47 sits
# 0.22 m from the untuned M4 for exactly that reason. Asserting
# they match would be asserting that the rig lab does nothing.
if a["tuned"] or b["tuned"]:
print(" -- %s and %s are both %s but %s is hand-tuned (%.3f m apart)"
% [ids[i], ids[j], a["style"],
ids[i] if a["tuned"] else ids[j], d])
continue
_expect(d <= MAX_SAME_STYLE,
"%s and %s are both %s and hold alike (%.3f m apart)"
% [ids[i], ids[j], a["style"], d])
else:
_expect(d >= MIN_STYLE_SEPARATION,
"%s (%s) and %s (%s) are held differently (%.3f m apart)"
% [ids[i], a["style"], ids[j], b["style"], d])
# The blade must actually let go of the off arm — that released arm is most
# of what makes a one-handed weapon read as one-handed.
if samples.has("knife"):
_expect(samples["knife"]["hold_l"] < 0.05,
"the knife releases the off arm to the animation (%.2f)"
% samples["knife"]["hold_l"])
for id in ["ak47", "awp", "rocket_launcher"]:
if samples.has(id):
_expect(samples[id]["hold_l"] > 0.9,
"%s keeps both hands on the weapon" % id)
# The cheek weld, and its inverse on a shouldered tube. These are the two
# poses that read at the greatest distance, so they get their own assertion
# rather than relying on the pairwise distance.
if samples.has("awp") and samples.has("ak47"):
var d: float = float(samples["awp"].get("cheek_deg", 0.0)) - float(samples["ak47"].get("cheek_deg", 0.0))
_expect(absf(d) > 1.5,
"the sniper welds its head to the stock (%+.1f deg vs the rifle)" % d)
if samples.has("rocket_launcher") and samples.has("ak47"):
var dl: float = float(samples["rocket_launcher"].get("cheek_deg", 0.0))
var dr: float = float(samples["ak47"].get("cheek_deg", 0.0))
var da: float = float(samples["awp"].get("cheek_deg", 0.0))
_expect((dl - dr) * (da - dr) < 0.0,
"the launcher leans the head AWAY, opposite the sniper (%+.1f vs %+.1f)"
% [dl - dr, da - dr])
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== WEAPON HOLD SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)