diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index df527f4..2245ab9 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -97,6 +97,9 @@ var _rig_info: Dictionary = {} var _surfaces: SkinSurfaces = null var _spring_mod: SpringBones var is_holding_weapon: bool = false +## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read +## by `_process` (a blade releases the off arm) and by the checks. +var hold_style: String = WeaponHoldProfiles.RIFLE ## Which skin this is, so per-character hold tuning can be looked up. Set by ## whoever spawns the model; falls back to the GLB's basename. var skin_id: String = "" @@ -782,7 +785,12 @@ func _process(delta: float) -> void: var hold_l := 0.0 if is_holding_weapon and not clip_owns_arms: hold_r = 1.0 - hold_l = 1.0 + # A one-handed weapon RELEASES the off arm back to the animation, so it + # swings with the run cycle instead of gripping a handguard that is not + # there. That released arm is most of what makes a knife read as a knife + # from across a map. + hold_l = 0.0 if _pose_mod.support_mode == WeaponHoldProfiles.SUPPORT_FREE \ + else 1.0 match st: "slide": hold_l = 0.0 # trailing arm braces the ground @@ -1018,12 +1026,27 @@ func set_weapon(script_path: String) -> void: is_holding_weapon = script_path != "" if script_path == "" or not skeleton: return - # Per-character, per-weapon hold overrides, if any have been tuned. Empty is - # the normal case and means "use what the code derives". + # How this KIND of weapon is held, then the per-character tuning on top. + # + # The profile is a defaults layer: it answers "what sort of thing is this" + # for a weapon nobody has tuned, and every knob an artist saved in the rig lab + # still wins, because the JSON is merged over it with overwrite. Aria's + # hand-tuned AK-47 hold is byte-for-byte what it was. var sid := skin_id if skin_id != "" else model_path.get_file().get_basename() + var weapon_id := script_path.get_file().get_basename() if hold_tune.is_empty(): - hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid, - script_path.get_file().get_basename()) + hold_tune = WeaponHoldProfiles.knobs_for(weapon_id) + hold_tune.merge(WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), + sid, weapon_id), true) + if _pose_mod: + # Structural, not tunable — see WeaponHoldProfiles. These decide where the + # off hand goes and how it is turned there, and whether the head comes + # down to the stock, which no slider on the rifle solve could express. + var style := WeaponHoldProfiles.style_for(weapon_id) + _pose_mod.support_mode = WeaponHoldProfiles.support_for(weapon_id) + _pose_mod.cheek = WeaponHoldProfiles.cheek_for(weapon_id) + _pose_mod.full_fist = style == WeaponHoldProfiles.BLADE + hold_style = WeaponHoldProfiles.style_for(weapon_id) # Anchors are per character, not per weapon — where a grip sits in a palm is # a fact about the hand — so unlike hold_tune they are not re-read per gun # unless the lab has pushed a live set in. @@ -1297,6 +1320,16 @@ class ShooterPoseModifier extends SkeletonModifier3D: var gun_stock: float = 0.20 # 0..1 through a reload — drives the support hand to the mag well and back. var reload_phase: float = 0.0 + # What KIND of weapon is being held. See WeaponHoldProfiles: these three are + # the differences a slider cannot express, and they are what make a launcher + # read as a launcher rather than as a very large rifle. + var support_mode: String = WeaponHoldProfiles.SUPPORT_BARREL + ## How far the head comes down and across to the stock, 0..1. Negative leans + ## it away, which is what a tube over the shoulder needs. + var cheek: float = 0.0 + ## Whether the trigger finger closes with the rest. True for a blade, which + ## has nothing to keep a finger straight along. + var full_fist: bool = false # [child_bone, helper_bone] pairs; see SkinJointHelper. var joint_helpers: Array = [] @@ -1514,6 +1547,8 @@ class ShooterPoseModifier extends SkeletonModifier3D: _apply_grapple(skel) if _hold_r > 0.01 or _hold_l > 0.01: _apply_rifle_hold(skel) + if absf(cheek) > 0.01: + _apply_cheek(skel) _close_hands(skel) if recoil > 0.01: _apply_recoil(skel) @@ -1763,9 +1798,22 @@ class ShooterPoseModifier extends SkeletonModifier3D: dbg_fore = fore_pos dbg_stock = stock_pos - # 3. Support hand goes to the mag well during a reload (under the - # receiver — the correct side), otherwise to the handguard. + # 3. Where the SUPPORT hand goes, which is most of what tells a viewer + # what is being held. See WeaponHoldProfiles. + # + # All four modes reuse the geometry above — the weapon is still placed + # first and the arms still solved onto it — they differ in which point + # on it the off hand is sent to. var l_target := fore_pos + match support_mode: + WeaponHoldProfiles.SUPPORT_CUPPED: + # Both hands together on the grip. Down and to the character's + # LEFT of the firing fist (`side` is character-right), so the two + # hands stack rather than collide. + l_target = grip_pos + gun_basis * Vector3(-0.048, -0.038, 0.012) + WeaponHoldProfiles.SUPPORT_TUBE: + # Hooked under the tube from below, forward of the shoulder. + l_target = fore_pos - gun_up * 0.075 if reload_phase > 0.001: var mag_well := grip_pos + aim_dir * (gun_fore * 0.35) - gun_up * 0.10 var drop := mag_well - gun_up * 0.22 - aim_dir * 0.05 @@ -1853,10 +1901,32 @@ class ShooterPoseModifier extends SkeletonModifier3D: and _hand_frame.has("L"): var hand_l: int = _idx.get("DEF-hand.L", -1) if hand_l >= 0: - # -aim_dir so the hand comes at the handguard from the body side - # rather than reaching over it backwards. - var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up, - -aim_dir) + # The hand's target frame, as (along, palm, curl) — the same + # three axes `_hand_frame` measured off the rest pose. + # + # Which way the PALM faces and which axis the fingers CURL about + # is the whole difference between wrapping a handguard, cupping a + # fist and hooking under a tube. Sending the hand to a different + # POSITION without changing its orientation gives a hand that has + # been teleported to the new spot still shaped for the old one. + # + # -aim_dir throughout, so the hand comes at the weapon from the + # body side rather than reaching over it backwards. + var palm := gun_up + var curl := -aim_dir + match support_mode: + WeaponHoldProfiles.SUPPORT_CUPPED: + # Palm presses inward against the grip's exposed panel; + # fingers still close along the barrel, over the firing + # hand's. + palm = side + WeaponHoldProfiles.SUPPORT_TUBE: + # A vertical foregrip: the palm faces BACK toward the + # body and the fingers close about the handle's own + # up-axis, not about the tube. + palm = -aim_dir + curl = gun_up + var want := Basis(palm.cross(curl).normalized(), palm, curl) # Then the artist's wrist. Rolling about the barrel is the one # axis a hand wrapping a cylinder is genuinely free in, and it # used to be the only one offered — which left no way to cock the @@ -1871,6 +1941,42 @@ class ShooterPoseModifier extends SkeletonModifier3D: * skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion() _set_global_rot(skel, hand_l, g_fa_l, g_hand.normalized(), _hold_l) + # ── The head against the stock ─────────────────────────────────────────── + # + # A cheek weld is the single most recognisable thing about how a marksman + # holds a rifle, and its inverse — the head leaning AWAY — is what says a tube + # is resting on that shoulder. Both are silhouette at any distance, long after + # the weapon mesh itself has become a few pixels. + # + # Three axes, all toward the right shoulder, because that is where the weapon + # is: down onto the comb, rolled over it, and turned slightly along it. + ## Full-weld amounts, in radians, at cheek = 1. + const CHEEK_PITCH := 0.20 # down onto the comb + const CHEEK_ROLL := 0.26 # over toward the shoulder + const CHEEK_YAW := 0.09 # turned along the stock + ## How much of the weld is present at low ready. Not zero: the pose has to + ## read before the character shoulders the weapon, and a marksman carrying a + ## rifle already holds their head differently from someone carrying a knife. + const CHEEK_HIP := 0.35 + + func _apply_cheek(skel: Skeleton3D) -> void: + var k: float = cheek * lerpf(CHEEK_HIP, 1.0, ads) * _hold_r + if absf(k) < 0.005: + return + # Positive X pitches the head DOWN and positive Z rolls it toward the + # character's right — the same sign conventions as the aim pitch and the + # wall-run lean respectively. + var q := Quaternion(Vector3(1, 0, 0), CHEEK_PITCH * k) \ + * Quaternion(Vector3(0, 0, 1), CHEEK_ROLL * k) \ + * Quaternion(Vector3(0, 1, 0), CHEEK_YAW * k) + # Split across neck and head so the whole column leans rather than the + # skull hinging off a rigid neck. Weighted toward the head, which is what + # actually happens when someone lowers a cheek onto a stock. + _add_space(skel, _idx.get("DEF-neck", -1), + Quaternion.IDENTITY.slerp(q, 0.35)) + _add_space(skel, _idx.get("DEF-head", -1), + Quaternion.IDENTITY.slerp(q, 0.65)) + # How far each segment of a finger closes, knuckle -> tip, in radians. # # Two different grips. The SUPPORT hand wraps a handguard, so all four @@ -1904,8 +2010,11 @@ class ShooterPoseModifier extends SkeletonModifier3D: PackedInt32Array()) if bones.is_empty(): continue - # The right index rides the trigger; everything else wraps. - var trigger: bool = side == "R" and digit == "index" + # The right index rides the trigger; everything else wraps. A blade + # has no trigger, and an index left straight along a knife handle + # reads as a mistake rather than as discipline — so a full fist + # closes every finger the same. + var trigger: bool = side == "R" and digit == "index" and not full_fist var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0) for i in bones.size(): diff --git a/characters/weapon_hold_tuning.gd b/characters/weapon_hold_tuning.gd index 25fb7f8..70637ad 100644 --- a/characters/weapon_hold_tuning.gd +++ b/characters/weapon_hold_tuning.gd @@ -169,7 +169,19 @@ static func all_knobs() -> Array: ## ## Across ALL poses, not just the one on screen: a reset or a save has to know ## what `pocket_ads` defaults to even while low ready is being adjusted. -static func default_for(key: String): +## +## WEAPON-AWARE, and it has to be. The hold profile for a weapon class supplies +## better defaults than the generic spec table — a knife's stock pocket is at the +## hip, a launcher's is above the shoulder — and the lab SAVES every knob it +## shows, not just the ones that were moved. Without the weapon here, opening the +## lab on the knife and pressing save would silently overwrite the blade profile +## with the rifle spec's values, and the character would go back to holding a +## knife like an AK with no indication that anything had happened. +static func default_for(key: String, weapon_id: String = ""): + if weapon_id != "": + var profile := WeaponHoldProfiles.knobs_for(weapon_id) + if profile.has(key): + return profile[key] return TuningStore.default_for(all_knobs(), key) diff --git a/debug/hold_capture.gd b/debug/hold_capture.gd new file mode 100644 index 0000000..6c29c2a --- /dev/null +++ b/debug/hold_capture.gd @@ -0,0 +1,105 @@ +extends SceneTree + +## One photograph of a character holding each weapon, from far enough away to +## see the SILHOUETTE. +## +## godot --path . --windowed --resolution 640x900 \ +## -s res://debug/hold_capture.gd -- [skin] +## +## The rig lab's camera is a close-up on the hands, which is right for tuning a +## grip and useless for the question this feature exists to answer: can you tell +## what someone is carrying from across a map. That is a whole-body question, so +## this frames the whole body, side-on, where the difference between a shouldered +## tube and a low-ready rifle actually lives. +## +## Side-on and not three-quarter, deliberately. A profile is the harshest test of +## a hold — it shows exactly how far the weapon sits from the shoulder and how +## the head is tilted, with no foreshortening to hide behind. + +const WEAPONS := ["m4", "mp7", "awp", "double_barrel_shotgun", + "rocket_launcher", "knife"] + +var _out := "." +var _skin := "taila" + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + _out = String(args[0]) + if args.size() > 1: + _skin = String(args[1]) + _run() + + +func _run() -> void: + # `_initialize` runs BEFORE the autoloads' `_ready`, so SkinManager's table is + # still empty here and even its "default" fallback is missing — asking it for + # a skin at this point fails on the fallback rather than on the skin asked + # for, which reads as "no skin 'taila'" and sends you looking in the wrong + # place entirely. + await process_frame + + var world := Node3D.new() + root.add_child(world) + + var env := WorldEnvironment.new() + env.environment = LevelEnvironment.make_environment("sunset") + world.add_child(env) + + var key := DirectionalLight3D.new() + key.rotation_degrees = Vector3(-38, 155, 0) + key.light_energy = 2.0 + world.add_child(key) + var fill := DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-20, -40, 0) + fill.light_energy = 0.7 + fill.light_color = Color(0.6, 0.7, 1.0) + world.add_child(fill) + + var cam := Camera3D.new() + world.add_child(cam) + # Side-on, at chest height, far enough back that the whole figure and the + # whole weapon are in frame. + cam.position = Vector3(3.4, 1.05, 0.15) + cam.look_at(Vector3(0, 0.95, 0)) + cam.fov = 42.0 + + var mgr = root.get_node_or_null("SkinManager") + if mgr == null: + print("hold_capture: no SkinManager") + quit(1) + return + var skin = mgr.get_skin(_skin) + if skin == null: + print("hold_capture: no skin '%s'" % _skin) + quit(1) + return + + for weapon in WEAPONS: + var model := SkinnedPlayerModel.new() + model.skin_id = _skin + model.first_person_mode = false + world.add_child(model) + model.load_model(skin.model_path) + for _i in 40: + await process_frame + + model.set_weapon("res://weapons/%s.gd" % weapon) + model.update_state("idle", 0.0, false) + # The hold blends in at ~8/s and the weapon measures itself on `ready`, + # so give it several time constants before believing the pose. + for _i in 100: + model.update_state("idle", 0.0, false) + await process_frame + + await process_frame + var img := root.get_texture().get_image() + var path := "%s/hold_%s.png" % [_out, weapon] + img.save_png(path) + print("hold_capture: saved ", path) + model.queue_free() + for _i in 4: + await process_frame + + quit(0) diff --git a/debug/rig_lab.gd b/debug/rig_lab.gd index 290e702..42db753 100644 --- a/debug/rig_lab.gd +++ b/debug/rig_lab.gd @@ -420,7 +420,8 @@ func _drag_to(mouse: Vector2) -> void: var key := _knob_for(_drag_marker) var spec := _spec_for("hold", key) - var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key)) + var cur: Vector3 = _knobs["hold"].get(key, + WeaponHoldTuning.default_for(key, _weapons[_weapon].id)) if not (cur is Vector3): cur = Vector3.ZERO var lo: float = spec[2] @@ -490,9 +491,15 @@ func _hold_pose() -> String: return WeaponHoldTuning.pose_for_ads(POSES[_pose][3]) +## A knob's resting value when nothing has been saved for this character. +## +## The weapon is passed through, because the hold's defaults depend on what KIND +## of weapon it is — see WeaponHoldTuning.default_for. Without it the lab would +## show a knife the rifle spec's numbers and then save them over the blade +## profile the moment anyone pressed save. func _default_for(group: String, key: String): return RigAnchors.default_for(key) if group == "anchors" \ - else WeaponHoldTuning.default_for(key) + else WeaponHoldTuning.default_for(key, _weapons[_weapon].id) ## Every knob of a group across BOTH poses. Reset works on the whole table, not diff --git a/debug/weapon_hold_check.gd b/debug/weapon_hold_check.gd new file mode 100644 index 0000000..4d12877 --- /dev/null +++ b/debug/weapon_hold_check.gd @@ -0,0 +1,250 @@ +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 hold breathes — a +## `sin(_time * 2.2) * 0.012` on the muzzle pitch — and the idle clip moves the +## whole torso, so no hand is ever at the same place twice and two samples of the +## SAME weapon would differ by more than two different weapons do. Taking each +## hand relative to the right shoulder joint, in the chest's own basis, cancels +## both, because they move the shoulder and the hand together. + +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) diff --git a/debug/weapon_hold_check.gd.uid b/debug/weapon_hold_check.gd.uid new file mode 100644 index 0000000..47859be --- /dev/null +++ b/debug/weapon_hold_check.gd.uid @@ -0,0 +1 @@ +uid://f7600k001iqr diff --git a/weapons/weapon_hold_profiles.gd b/weapons/weapon_hold_profiles.gd new file mode 100644 index 0000000..dc8cd24 --- /dev/null +++ b/weapons/weapon_hold_profiles.gd @@ -0,0 +1,292 @@ +extends Object +class_name WeaponHoldProfiles + +## How each weapon in the set is HELD — one archetype per class of weapon, +## rather than one rifle hold for all twelve. +## +## ── The problem this exists to fix ─────────────────────────────────────────── +## +## `ShooterPoseModifier._apply_rifle_hold` did exactly what its name says, to +## everything. A knife, a rocket launcher and an AK were all solved as a rifle: +## stock in the shoulder pocket, support hand out along the barrel, muzzle on the +## aim line. In third person every character therefore stood in the same pose +## regardless of what they were carrying, and the ONLY thing distinguishing a +## sniper from a shotgun was the ~30 cm of gun mesh in their hands — which at the +## distance an enemy is usually seen is nothing. +## +## That matters beyond looking wrong. In a shooter the reason a character's pose +## is readable at range is that it is the fastest available answer to "what is +## about to happen to me". A shouldered tube means take cover; a blade held low +## means they have to close the distance; a rifle at low ready means they have not +## seen you yet. A single hold throws all of that away. +## +## It is also the specific thing HoYoverse's team say they chase in Zenless Zone +## Zero: characters read by SILHOUETTE first, and their designers deliberately +## refuse to settle on one construction method because a single method limits how +## distinguishable the results can be. The same argument applies one level down, +## to how a character holds a thing. +## +## ── What a profile controls ───────────────────────────────────────────────── +## +## A style is not a bundle of slider values. Three of the differences below +## cannot be expressed as a number on the existing rifle solve at all, and those +## are the ones that make the silhouette: +## +## support where the off hand goes, and how it is ORIENTED there — +## wrapped round a handguard, cupped under a pistol grip, hooked +## under a tube, or released entirely so the animation owns it +## mount whether the weapon's rear sits IN the shoulder pocket, ON TOP +## of the shoulder, or nowhere near it +## head whether the head comes down to the stock (a cheek weld) or +## leans away to clear a tube +## +## Everything else — the pocket offsets, the muzzle pitch at low ready, the elbow +## poles, the finger curls — is an ordinary knob, and the profile just supplies a +## better DEFAULT for that weapon than one global constant could. +## +## ── Layering ──────────────────────────────────────────────────────────────── +## +## This is a defaults layer, underneath everything an artist has tuned: +## +## code constants the rifle solve's own fallbacks +## THIS FILE per weapon: what kind of thing it is +## weapon_holds.json defaults -> skins.._all -> skins.. +## +## So a character with tuning saved from the rig lab is completely unaffected — +## aria's hand-tuned AK-47 hold still wins on every knob it sets — and a weapon +## nobody has tuned stops being held like an AK. + +# ── The styles ─────────────────────────────────────────────────────────────── + +const RIFLE := "rifle" +const SMG := "smg" +const SNIPER := "sniper" +const SHOTGUN := "shotgun" +const LAUNCHER := "launcher" +const PISTOL := "pistol" +const BLADE := "blade" + +# ── Where the support hand goes, and how it is turned ──────────────────────── + +## Wrapped round a handguard, fingers closing ACROSS the barrel. The rifle case. +const SUPPORT_BARREL := "barrel" +## Cupped under and around the firing fist. Two hands together, no shoulder +## contact — a pistol, or any weapon light enough to be held out in front. +const SUPPORT_CUPPED := "cupped" +## Hooked UNDER a tube from below, palm up, well forward of the shoulder. The +## launcher case, where there is no handguard to wrap and the weight is carried +## rather than aimed. +const SUPPORT_TUBE := "tube" +## No support hand at all. The animation keeps the arm, which is what a character +## carrying a blade should look like — the off hand swings with the run cycle. +const SUPPORT_FREE := "free" + + +## Style -> the structural rules for it. +## +## `pocket_hip` / `pocket_ads` are where the weapon's REAR sits, relative to the +## right shoulder joint, in skeleton space (x across, y up, z forward). They are +## the single most important number here: it is what decides whether a weapon +## reads as shouldered, carried, or held out. +## +## `cheek` is how far the head comes down and across to meet the stock, 0..1, +## applied only as the character shoulders the weapon. Negative leans the head +## AWAY, which is what a tube over the shoulder requires. +## +## `pitch_hip` is the muzzle's droop at low ready, in radians. A launcher's tube +## rides nose-UP because that is how you carry something you do not want pointed +## at your own feet; a blade points forward and in. +const STYLES := { + RIFLE: { + "support": SUPPORT_BARREL, + "pocket_hip": Vector3(0.03, -0.07, 0.06), + "pocket_ads": Vector3(0.05, 0.01, 0.07), + "pitch_hip": 0.16, + "cheek": 0.18, + "pole_r_hip": Vector3(-0.55, -0.85, -0.20), + "pole_l_hip": Vector3(0.45, -0.90, -0.10), + "curl_wrap": 1.0, + "curl_trigger": 1.0, + }, + # Compact: the whole weapon is carried closer in, the support hand cannot go + # far because there is not much gun in front of the grip, and the firing elbow + # tucks rather than flares. This is what stops an MP7 from being posed as a + # short rifle with the support arm reaching for a handguard that ended. + SMG: { + "support": SUPPORT_BARREL, + "pocket_hip": Vector3(0.04, -0.10, 0.03), + "pocket_ads": Vector3(0.05, 0.00, 0.05), + "pitch_hip": 0.24, + "cheek": 0.10, + "pole_r_hip": Vector3(-0.40, -0.95, -0.15), + "pole_l_hip": Vector3(0.35, -0.95, -0.05), + "gun_fore": 0.17, + "curl_wrap": 1.05, + "curl_trigger": 1.0, + }, + # The cheek weld IS the sniper silhouette. The head comes down onto the stock, + # the pocket sits high and tight so the optic lands at eye height, and the + # support hand goes far out because a long barrel gives it somewhere to go. + SNIPER: { + "support": SUPPORT_BARREL, + # Rides HIGH. A scoped rifle is held so the optic meets the eye, not so + # the butt meets the pocket, and raising the whole weapon is what makes + # that read — pushing the support hand further out does NOT, because the + # reach solver slides it back down the handguard until the arm can get + # there, so a longer `gun_fore` on these stylised arms lands the off hand + # in exactly the same place as a rifle's. Measured: 0.388 vs 0.387 m. + "pocket_hip": Vector3(0.015, 0.005, 0.045), + "pocket_ads": Vector3(0.040, 0.075, 0.055), + "pitch_hip": 0.12, + "cheek": 0.85, + "pole_r_hip": Vector3(-0.72, -0.55, -0.22), + "pole_l_hip": Vector3(0.22, -1.05, -0.02), + "gun_fore": 0.34, + "curl_wrap": 1.0, + "curl_trigger": 0.85, + }, + # Held lower and squarer than a rifle, with the support hand back on the + # forend rather than out at the muzzle, and a hard wrap — a shotgun is gripped, + # not balanced. + SHOTGUN: { + "support": SUPPORT_BARREL, + # Carried low and FORWARD, the opposite of the sniper's high tuck. A + # shotgun is pointed rather than aimed and the elbows go wide, because + # the recoil comes back through them. + # + # Low and INBOARD does not work, however tempting the symmetry: +x is + # toward the character's centreline, so dropping the pocket and pushing + # it across at the same time swings the barrel through the chest. It is + # visible immediately in debug/hold_capture.gd and in no assertion — + # the hands were still exactly where they had been asked to go. + "pocket_hip": Vector3(0.030, -0.130, 0.080), + "pocket_ads": Vector3(0.045, -0.040, 0.075), + "pitch_hip": 0.30, + "cheek": 0.22, + "pole_r_hip": Vector3(-0.62, -0.80, -0.22), + "pole_l_hip": Vector3(0.58, -0.72, -0.16), + "gun_fore": 0.22, + "curl_wrap": 1.2, + "curl_trigger": 1.0, + }, + # The tube goes ON the shoulder, not into it, and the head leans AWAY to clear + # it. The support hand hooks under from below, well forward, because there is + # nothing to wrap and the job of that arm is to carry weight. + LAUNCHER: { + "support": SUPPORT_TUBE, + "pocket_hip": Vector3(0.055, 0.06, 0.01), + "pocket_ads": Vector3(0.065, 0.105, 0.03), + "pitch_hip": -0.16, + "cheek": -0.30, + "pole_r_hip": Vector3(-0.85, -0.45, -0.10), + "pole_l_hip": Vector3(0.25, -0.85, 0.10), + "gun_stock": 0.30, + "gun_fore": 0.30, + "curl_wrap": 1.0, + "curl_trigger": 0.9, + }, + # No shoulder contact at all. The pocket is pushed forward and outboard of the + # joint, which puts both arms out in front of the chest, and the support hand + # cups the firing fist instead of reaching for a barrel. + PISTOL: { + "support": SUPPORT_CUPPED, + "pocket_hip": Vector3(0.02, -0.16, 0.16), + "pocket_ads": Vector3(0.00, -0.04, 0.30), + "pitch_hip": 0.30, + "cheek": 0.05, + "pole_r_hip": Vector3(-0.45, -0.95, -0.05), + "pole_l_hip": Vector3(0.45, -0.95, -0.05), + "gun_stock": 0.03, + "gun_fore": 0.05, + "weapon_scale": 1.0, + "curl_wrap": 1.15, + "curl_trigger": 0.9, + }, + # One hand. The off arm is RELEASED back to the animation, so it swings with + # the run cycle instead of gripping a handguard that does not exist — which is + # most of what makes a knife read as a knife at any distance. + BLADE: { + "support": SUPPORT_FREE, + "pocket_hip": Vector3(0.09, -0.20, 0.10), + "pocket_ads": Vector3(0.05, -0.06, 0.22), + "pitch_hip": 0.55, + "cheek": 0.0, + "pole_r_hip": Vector3(-0.35, -1.00, -0.10), + "pole_l_hip": Vector3(0.45, -0.90, -0.10), + "gun_stock": 0.02, + "gun_fore": 0.0, + "weapon_scale": 1.0, + # A blade is held in a full fist — there is no trigger to keep a finger + # straight along, and an extended index on a knife handle looks like a + # mistake rather than like discipline. + "curl_wrap": 1.25, + "curl_trigger": 1.25, + }, +} + + +## Weapon id (the script's basename) -> style. +## +## Keyed on the basename rather than on the class, so this table does not have to +## load twelve weapon scripts to be read, and so a weapon that has not been +## written yet can be listed here the moment its file exists. +const WEAPON_STYLES := { + "ak47": RIFLE, + "m4": RIFLE, + "plasma_gun": RIFLE, + "mp7": SMG, + "nail_gun": SMG, + "dmr": SNIPER, + "awp": SNIPER, + "double_barrel_shotgun": SHOTGUN, + "rocket_launcher": LAUNCHER, + "rocket_swarm": LAUNCHER, + "mortar": LAUNCHER, + "knife": BLADE, +} + +## What an unlisted weapon is held as. A rifle is the safe assumption: it is the +## only style that reaches for a handguard, and a weapon with no handguard held +## as a rifle looks odd, where a rifle held as anything else looks broken. +const FALLBACK := RIFLE + + +## The style name for a weapon id or script path. +static func style_for(weapon: String) -> String: + var id := _id_of(weapon) + return WEAPON_STYLES.get(id, FALLBACK) + + +## The default knob table for a weapon — the style's values, ready to be merged +## under whatever the JSON tuning says. +## +## Returns a COPY, because the caller merges the artist's values into it and a +## shared dictionary would accumulate one character's tuning into every other's. +static func knobs_for(weapon: String) -> Dictionary: + var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK]) + var out := style.duplicate(true) + # `support` and `cheek` are structural, not knobs — they are read straight + # off the style by the pose layer and must not end up in the tuning table, + # where the rig lab would offer sliders for them. + out.erase("support") + out.erase("cheek") + return out + + +## How the off hand is used for this weapon. See the SUPPORT_* constants. +static func support_for(weapon: String) -> String: + var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK]) + return style.get("support", SUPPORT_BARREL) + + +## How far the head comes to the stock, 0..1, negative to lean away. +static func cheek_for(weapon: String) -> float: + var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK]) + return float(style.get("cheek", 0.0)) + + +## A weapon id from either an id or a `res://weapons/.gd` path. +static func _id_of(weapon: String) -> String: + if weapon.ends_with(".gd"): + return weapon.get_file().get_basename() + return weapon diff --git a/weapons/weapon_hold_profiles.gd.uid b/weapons/weapon_hold_profiles.gd.uid new file mode 100644 index 0000000..6402656 --- /dev/null +++ b/weapons/weapon_hold_profiles.gd.uid @@ -0,0 +1 @@ +uid://bhfyjgkpsvb2x