249 lines
9.6 KiB
GDScript
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)
|