From 33d07b3717baf121e8eff599b4680250d72d4746 Mon Sep 17 00:00:00 2001 From: Nicholas Butzke <137417822+DottsGit@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:15:53 -0400 Subject: [PATCH] feat(tools): a weapon hold lab for tuning each character's grip in 3D MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I have been guessing at numbers that want an eye on them. This is the environment to set them instead. godot --path . res://debug/weapon_lab.tscn Pick a character, pick a weapon, pick a pose, drag sliders, press Save. Thirteen knobs — weapon size, support-hand distance along the barrel, grip to buttstock, muzzle pitch at low ready, both wrist rolls, three finger curl amounts, the stock pocket for low-ready and for aiming, and both elbow poles. Three markers show the points being solved for: red trigger grip, green support hand, blue buttstock. If a hand is not ON its marker the IK could not reach it, which is a different problem from the marker being in the wrong place, and the two used to be indistinguishable. Results land in assets/characters/weapon_holds.json, resolved in layers so a number can be set once and contradicted where it matters: defaults every character, every weapon skins.._all this character, every weapon skins.. this character, this weapon An empty file means "use what the code derives", so the game runs exactly as before until something is actually tuned. The knobs that CAN be derived from the skeleton still are — mount rotation, wrist frame, weapon size — and their sliders read "auto" at zero rather than silently overriding. Two things this had to get right to be honest rather than merely present. Slider defaults come from the same table as the code defaults, because a slider parked at 0 beside a code default of 1.0 means the first touch of that slider silently switches finger curl off. And the markers are depth-tested: drawn through the body they look like they are floating in front of the chest when they are really behind an arm, which is the exact wrong impression for judging whether a hand is on its target. `-- shot [skin] [weapon]` renders one framed close-up and quits, so the lab can be checked without a human at the controls — which is how both of those bugs were caught. Co-Authored-By: Claude Opus 5 --- characters/skinned_player_model.gd | 87 +++++- characters/weapon_hold_tuning.gd | 139 +++++++++ characters/weapon_hold_tuning.gd.uid | 1 + debug/weapon_lab.gd | 403 +++++++++++++++++++++++++++ debug/weapon_lab.gd.uid | 1 + debug/weapon_lab.tscn | 6 + 6 files changed, 628 insertions(+), 9 deletions(-) create mode 100644 characters/weapon_hold_tuning.gd create mode 100644 characters/weapon_hold_tuning.gd.uid create mode 100644 debug/weapon_lab.gd create mode 100644 debug/weapon_lab.gd.uid create mode 100644 debug/weapon_lab.tscn diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index e51f0c2..a380357 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -94,6 +94,12 @@ var _joint_helpers: Array = [] var _rig_info: Dictionary = {} var _spring_mod: SpringBones var is_holding_weapon: bool = false +## 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 = "" +## Live hold overrides. Written by debug/weapon_lab.gd while tuning and by +## set_weapon() from the saved table otherwise. +var hold_tune: Dictionary = {} # Animation blending: locomotion plays full-body through a Transition node; # gameplay one-shots (reload/throw/shoot/hit) play through an @@ -863,6 +869,22 @@ func get_lean_debug() -> float: return _cur_fwd +## Push a new hold tuning table in and re-seat the weapon with it. +## +## For debug/weapon_lab.gd: the knobs that live on the pose layer take effect on +## the next frame, but weapon SIZE and the grip offset are baked into the +## attachment when the weapon is seated, so those need the weapon re-measured. +func set_hold_tuning(t: Dictionary) -> void: + hold_tune = t + if _pose_mod: + _pose_mod.tune = t + if _weapon_attachment and _weapon_attachment.get_child_count() > 0: + var w := _weapon_attachment.get_child(0) as Node3D + if w: + w.transform = Transform3D.IDENTITY + _measure_weapon(w) + + ## Whether the locomotion cycle is running backwards, which points the stride ## the opposite way to the hips. For debug/travel_dir_check.gd. func stride_reversed_debug() -> bool: @@ -893,6 +915,12 @@ 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". + if hold_tune.is_empty(): + var sid := skin_id if skin_id != "" else model_path.get_file().get_basename() + hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid, + script_path.get_file().get_basename()) var script = load(script_path) if not script: @@ -1039,15 +1067,25 @@ func _measure_weapon(w: Node3D) -> void: # Size the gun to the arm that has to hold it, then seat the grip in the fist. var k := _weapon_scale(stock_u + fore_u) + # A tuned size wins over the derived one. The derivation keeps the handguard + # inside the support arm's reach, which is a floor on believability, not a + # statement about how big the gun should LOOK. + if hold_tune.get("weapon_scale", 0.0) > 0.01: + k = float(hold_tune["weapon_scale"]) w.scale = Vector3.ONE * k w.position -= w.transform.basis * grip _pose_mod.gun_fore = fore_u * k + if hold_tune.get("gun_fore", 0.0) > 0.0001: + _pose_mod.gun_fore = float(hold_tune["gun_fore"]) # Stock: not authored anywhere, so derive it from the barrel. Half the # grip-to-muzzle distance behind the grip lands the butt in the shoulder # pocket for every gun in the set. _pose_mod.gun_stock = stock_u * k + if hold_tune.get("gun_stock", 0.0) > 0.0001: + _pose_mod.gun_stock = float(hold_tune["gun_stock"]) + _pose_mod.tune = hold_tune # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -1172,6 +1210,25 @@ class ShooterPoseModifier extends SkeletonModifier3D: var roles: Dictionary = {} ## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar. var fingers: Dictionary = {} + ## Per-character hold overrides — see characters/weapon_hold_tuning.gd and + ## debug/weapon_lab.gd. A dictionary rather than a field per knob so a new + ## knob needs no plumbing: add it here, read it with _t/_tv, and the lab + ## picks it up from the same table. + var tune: Dictionary = {} + ## Where the hold put the weapon this frame, in skeleton space. The lab draws + ## these so the points being tuned are visible rather than inferred. + var dbg_grip: Vector3 = Vector3.ZERO + var dbg_fore: Vector3 = Vector3.ZERO + var dbg_stock: Vector3 = Vector3.ZERO + + func _t(key: String, fallback: float) -> float: + return float(tune.get(key, fallback)) + + func _tv(key: String, fallback: Vector3) -> Vector3: + var v = tune.get(key) + # A zero-length vector means "not set" — see WeaponHoldTuning.KNOBS. It + # is how the elbow poles keep their hip/ADS blend unless overridden. + return v if (v is Vector3 and v.length() > 0.0001) else fallback var _fing: Dictionary = {} # same, resolved to bone indices var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame ## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose. @@ -1488,12 +1545,14 @@ class ShooterPoseModifier extends SkeletonModifier3D: if ua_r < 0: return var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02 + var t_pocket_hip := _tv("pocket_hip", POCKET_HIP) + var t_pocket_ads := _tv("pocket_ads", POCKET_ADS) # ~7 degrees of muzzle rise per shot, stacking a little on full auto. var kick := recoil * 0.12 # 1. The gun's line: pitched down at low-ready, on the camera line at # ADS, kicked up by recoil. - var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe + var gun_pitch := lerpf(_t("pitch_hip", GUN_PITCH_HIP), -aim_pitch, ads) - kick + breathe var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \ * Vector3(0, 0, 1)).normalized() # Gun's "up" — perpendicular to the barrel in the vertical plane, so the @@ -1506,7 +1565,7 @@ class ShooterPoseModifier extends SkeletonModifier3D: # 2. Anchor the stock at the shoulder, then walk out along the barrel. var shoulder := skel.get_bone_global_pose(ua_r).origin - var pocket: Vector3 = POCKET_HIP.lerp(POCKET_ADS, ads) + var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads) var stock_pos := shoulder + pocket var grip_pos := stock_pos + aim_dir * gun_stock # The support hand rides as far out the handguard as it can actually @@ -1532,6 +1591,9 @@ class ShooterPoseModifier extends SkeletonModifier3D: if fore_dist <= floor_fore: break var fore_pos := grip_pos + aim_dir * fore_dist + dbg_grip = grip_pos + 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. @@ -1550,8 +1612,8 @@ class ShooterPoseModifier extends SkeletonModifier3D: l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20) # 4. Solve both arms onto those points. - var pole_r: Vector3 = POLE_R_HIP.lerp(POLE_R_ADS, ads).normalized() - var pole_l: Vector3 = POLE_L_HIP.lerp(POLE_L_ADS, ads).normalized() + var pole_r: Vector3 = _tv("pole_r", POLE_R_HIP.lerp(POLE_R_ADS, ads)).normalized() + var pole_l: Vector3 = _tv("pole_l", POLE_L_HIP.lerp(POLE_L_ADS, ads)).normalized() var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R", grip_pos, pole_r, _hold_r) var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L", @@ -1580,7 +1642,8 @@ class ShooterPoseModifier extends SkeletonModifier3D: var up_flat := (up_now - aim_dir * up_now.dot(aim_dir)) if up_flat.length_squared() > 0.0001: var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir) - arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc + arc = Quaternion(aim_dir, roll + _t("trigger_roll", + R_HAND_TWIST)) * arc _set_global_rot(skel, hand, g_fa_r, arc, _hold_r) # 6. Support hand: WRAP the handguard. @@ -1605,7 +1668,11 @@ class ShooterPoseModifier extends SkeletonModifier3D: 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, + # `support_roll` rolls the whole target frame about the barrel, + # which is the one axis a hand wrapping a cylinder is free in. + var roll_q := Quaternion(aim_dir, _t("support_roll", 0.0)) + var up_r := roll_q * gun_up + var want := Basis(up_r.cross(-aim_dir).normalized(), up_r, -aim_dir) var rest: Basis = _hand_frame["L"] var g_hand := (want * rest.inverse()).get_rotation_quaternion() \ @@ -1646,14 +1713,16 @@ class ShooterPoseModifier extends SkeletonModifier3D: if bones.is_empty(): continue # The right index rides the trigger; everything else wraps. - var amount: Array = CURL_TRIGGER if (side == "R" and digit == "index") else CURL_WRAP + var trigger: bool = side == "R" and digit == "index" + 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(): var a: float = amount[i] if i < amount.size() else amount[-1] - _add_space(skel, bones[i], Quaternion(axis, a * w)) + _add_space(skel, bones[i], Quaternion(axis, a * scale * w)) var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array()) for i in thumb.size(): var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1] - _add_space(skel, thumb[i], Quaternion(axis, a * w)) + _add_space(skel, thumb[i], Quaternion(axis, a * _t("curl_thumb", 1.0) * w)) ## Straight-arm length of an arm chain, from the rest pose. func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String, diff --git a/characters/weapon_hold_tuning.gd b/characters/weapon_hold_tuning.gd new file mode 100644 index 0000000..6caf448 --- /dev/null +++ b/characters/weapon_hold_tuning.gd @@ -0,0 +1,139 @@ +extends Object +class_name WeaponHoldTuning + +## Per-character, per-weapon overrides for how a gun is held. +## +## Every knob in the rifle hold used to be a constant tuned against one rig, and +## every one of them was wrong on the next character imported — the mount +## rotation, the wrist twist, the weapon size. The ones that CAN be derived from +## the skeleton now are. The rest are genuinely art direction: how high the stock +## rides, how far the elbow flares, how hard the fingers close. Those want an +## artist's eye and a slider, not another guess in code. +## +## This is where that judgement is stored. debug/weapon_lab.gd writes it; +## SkinnedPlayerModel reads it when a weapon is equipped. +## +## Resolution is layered, most general first, so a single number can be set once +## for everything and then contradicted where it matters: +## +## defaults every character, every weapon +## skins.._all this character, every weapon +## skins.. this character, this weapon +## +## An empty file means "use the built-in defaults", so the game runs perfectly +## well with no tuning at all — this only ever adds information. + +const PATH := "res://assets/characters/weapon_holds.json" +## Written to the project when running from source; falls back to user:// for an +## exported build, where res:// is read-only. +const USER_PATH := "user://weapon_holds.json" + +## key -> [label, minimum, maximum, is_vector, default] +## +## The lab builds its whole UI from this, so adding a knob here is all it takes +## to expose one. Ranges are what a plausible answer lives inside, not what the +## value can technically be. +## +## The DEFAULT must match what the code does when nothing is tuned, or the lab +## lies: a slider parked at 0 next to a code default of 1.0 means the first touch +## of that slider silently switches the behaviour off. Zero means "let the code +## decide" only where it is called out below. +const KNOBS := [ + ["weapon_scale", "Weapon size (0 = fit to arm)", 0.0, 1.4, false, 0.0], + ["gun_fore", "Support hand along barrel (0 = auto)", 0.0, 0.50, false, 0.0], + ["gun_stock", "Grip to buttstock (0 = auto)", 0.0, 0.45, false, 0.0], + ["pitch_hip", "Muzzle pitch, low ready", -0.6, 0.6, false, 0.16], + ["support_roll", "Support hand roll", -3.2, 3.2, false, 0.0], + ["trigger_roll", "Trigger hand roll", -3.2, 3.2, false, 0.0], + ["curl_wrap", "Finger wrap", 0.0, 2.0, false, 1.0], + ["curl_trigger", "Trigger finger", 0.0, 2.0, false, 1.0], + ["curl_thumb", "Thumb", 0.0, 2.0, false, 1.0], + ["pocket_hip", "Stock pocket, low ready", -0.30, 0.30, true, + Vector3(0.03, -0.07, 0.06)], + ["pocket_ads", "Stock pocket, aiming", -0.30, 0.30, true, + Vector3(0.05, 0.01, 0.07)], + # Zero means "use the code's own hip/ADS blend" for these two — see _tv in + # ShooterPoseModifier, which treats a zero-length vector as unset. + ["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO], + ["pole_l", "Support elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO], +] + + +## The built-in value for a knob, for a lab that has nothing saved yet. +static func default_for(key: String): + for spec in KNOBS: + if spec[0] == key: + return spec[5] + return 0.0 + + +static func _read(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) + return parsed if typeof(parsed) == TYPE_DICTIONARY else {} + + +static func load_all() -> Dictionary: + # user:// wins, so a tuning pass made in an exported build is not lost, and + # so the lab can be used without a writable project directory. + var base := _read(PATH) + var over := _read(USER_PATH) + if over.is_empty(): + return base + if base.is_empty(): + return over + # Shallow merge is enough: the layers below are merged per key anyway. + for k in over: + base[k] = over[k] + return base + + +## The resolved knob table for one character holding one weapon. +## +## Vectors survive the JSON round trip as three-element arrays, so they are +## rebuilt here rather than at every read site. +static func resolve(all: Dictionary, skin_id: String, weapon_id: String) -> Dictionary: + var out := {} + var skins: Dictionary = all.get("skins", {}) + var mine: Dictionary = skins.get(skin_id, {}) + for layer in [all.get("defaults", {}), mine.get("_all", {}), + mine.get(weapon_id, {})]: + if typeof(layer) != TYPE_DICTIONARY: + continue + for k in layer: + out[k] = layer[k] + for k in out.keys(): + var v = out[k] + if v is Array and v.size() == 3: + out[k] = Vector3(float(v[0]), float(v[1]), float(v[2])) + return out + + +## Store one character+weapon's knobs and write the file. Returns where it went. +static func save(all: Dictionary, skin_id: String, weapon_id: String, + knobs: Dictionary) -> String: + if not all.has("skins"): + all["skins"] = {} + if not all["skins"].has(skin_id): + all["skins"][skin_id] = {} + var flat := {} + for k in knobs: + var v = knobs[k] + flat[k] = [v.x, v.y, v.z] if v is Vector3 else v + all["skins"][skin_id][weapon_id] = flat + + var text := JSON.stringify(all, " ") + # Prefer the project copy so a tuning pass lands in version control with the + # character it belongs to; fall back to user:// when res:// is not writable. + var f := FileAccess.open(PATH, FileAccess.WRITE) + if f: + f.store_string(text) + f.close() + return PATH + f = FileAccess.open(USER_PATH, FileAccess.WRITE) + if f: + f.store_string(text) + f.close() + return USER_PATH + return "" diff --git a/characters/weapon_hold_tuning.gd.uid b/characters/weapon_hold_tuning.gd.uid new file mode 100644 index 0000000..0bf586e --- /dev/null +++ b/characters/weapon_hold_tuning.gd.uid @@ -0,0 +1 @@ +uid://cxbghp14y3i7v diff --git a/debug/weapon_lab.gd b/debug/weapon_lab.gd new file mode 100644 index 0000000..bbe93fc --- /dev/null +++ b/debug/weapon_lab.gd @@ -0,0 +1,403 @@ +extends Node3D + +## Weapon hold lab — tune how each character holds each gun, in 3D, live. +## +## godot --path . res://debug/weapon_lab.tscn +## +## Every knob in the rifle hold started life as a constant tuned against one rig, +## and every one of them was wrong on the next character imported. The ones that +## CAN be derived from the skeleton now are — the mount rotation, the wrist roll, +## the weapon size. What is left is genuinely art direction: how high the stock +## rides, how far the elbow flares, how hard the fingers close. Those want an eye +## and a slider, not another guess in code. +## +## Pick a character, pick a weapon, pick a pose, drag the sliders, press Save. +## The result lands in assets/characters/weapon_holds.json keyed by character and +## weapon, and the game reads it whenever that gun is equipped. +## +## CONTROLS +## left drag orbit wheel zoom +## middle drag pan F frame the hands +## R reset knobs S save C copy JSON to clipboard +## +## The three coloured markers are the points being solved for — red is the +## trigger grip, green the support hand on the handguard, blue the buttstock. If +## a hand is not on its marker the IK could not reach, which is a different +## problem from the marker being in the wrong place. + +const POSES := [ + ["Low ready", "ground", 0.0, 0.0], + ["Aiming", "ground", 0.0, 1.0], + ["Running", "ground", 9.0, 0.0], + ["Crouched", "ground", 0.0, 0.0], +] + +var _model: SkinnedPlayerModel +var _skins: Array = [] +var _weapons: Array = [] +var _skin := 0 +var _weapon := 0 +var _pose := 0 +var _knobs: Dictionary = {} +var _all: Dictionary = {} +var _sliders: Dictionary = {} +var _status: Label +var _cam: Camera3D +var _yaw := 0.6 +var _pitch := -0.1 +var _dist := 2.2 +var _pivot := Vector3(0, 1.25, 0) +var _markers: Array = [] +var _pickers: Dictionary = {} + + +## Frames to wait before the self-shot below. The model loads asynchronously and +## the cloth solver needs a moment to settle, so an immediate capture shows a +## half-built character. +const SHOT_WARMUP := 40 +var _shot_path := "" +var _frames := 0 + + +func _ready() -> void: + _build_world() + _collect_sources() + # `-- shot [skin] [weapon]` renders one frame and quits, so the lab + # can be checked without a human at the controls. + var args := OS.get_cmdline_user_args() + if args.size() >= 2 and String(args[0]) == "shot": + _shot_path = String(args[1]) + if args.size() > 2: + _skin = maxi(0, _index_of(_skins, String(args[2]))) + if args.size() > 3: + _weapon = maxi(0, _index_of(_weapons, String(args[3]))) + _build_ui() + _reload_model() + + +func _index_of(list: Array, id: String) -> int: + for i in list.size(): + if String(list[i].id) == id: + return i + return -1 + + +# ── scene ───────────────────────────────────────────────────────────────────── + +func _build_world() -> void: + var env := WorldEnvironment.new() + var e := Environment.new() + e.background_mode = Environment.BG_COLOR + e.background_color = Color(0.17, 0.18, 0.22) + e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + e.ambient_light_color = Color(0.55, 0.57, 0.65) + e.ambient_light_energy = 1.0 + env.environment = e + add_child(env) + + var key := DirectionalLight3D.new() + key.rotation_degrees = Vector3(-42, 132, 0) + key.light_energy = 1.5 + add_child(key) + var fill := DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-18, -40, 0) + fill.light_energy = 0.5 + add_child(fill) + + _cam = Camera3D.new() + _cam.fov = 45.0 + add_child(_cam) + _update_camera() + + # Grip / support / stock, so the points under the sliders are visible. + for c in [Color(1, 0.3, 0.3), Color(0.3, 1, 0.4), Color(0.4, 0.6, 1)]: + var m := MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 0.012 + sphere.height = 0.024 + m.mesh = sphere + var mat := StandardMaterial3D.new() + mat.albedo_color = c + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + # Depth-tested on purpose. Drawn through the body they look like they are + # floating in front of the chest when they are in fact behind an arm, + # which is exactly the wrong impression for judging whether a hand is on + # its target. + mat.no_depth_test = false + m.material_override = mat + add_child(m) + _markers.append(m) + + +func _collect_sources() -> void: + for id in SkinManager.skins: + var s = SkinManager.skins[id] + # GLB-backed skins only; the colour tints have no skeleton to pose. + if "model_path" in s and String(s.model_path) != "": + _skins.append({"id": id, "name": s.skin_name, "path": s.model_path}) + _skins.sort_custom(func(a, b): return a.id < b.id) + for id in LoadoutManager.weapon_db: + var w: Dictionary = LoadoutManager.weapon_db[id] + if String(w.get("script", "")) != "": + _weapons.append({"id": id, "name": w.get("name", id), + "script": w["script"]}) + _weapons.sort_custom(func(a, b): return a.id < b.id) + _all = WeaponHoldTuning.load_all() + + +func _reload_model() -> void: + if _model: + _model.queue_free() + _model = SkinnedPlayerModel.new() + _model.model_path = _skins[_skin].path + _model.skin_id = _skins[_skin].id + add_child(_model) + await get_tree().process_frame + _load_knobs() + _model.set_weapon(_weapons[_weapon].script) + _model.set_hold_tuning(_knobs) + _apply_pose() + + +func _apply_pose() -> void: + if not _model or not _model.loaded: + return + var p: Array = POSES[_pose] + _model.update_state(p[1], p[2], _pose == 3) + _model.set_locomotion(0.0, 1.0 if p[2] > 0.1 else 0.0, p[3]) + + +func _process(_delta: float) -> void: + _apply_pose() + if _shot_path != "": + _frames += 1 + if _frames == SHOT_WARMUP: + # Frame the hands, which is the only part anyone is judging. + if _model and _model._pose_mod: + _pivot = _model.skeleton.global_transform * _model._pose_mod.dbg_grip + _dist = 0.7 + _yaw = 1.15 + _pitch = -0.15 + _update_camera() + elif _frames > SHOT_WARMUP + 2: + var img := get_viewport().get_texture().get_image() + img.save_png(_shot_path) + print("weapon_lab: saved ", _shot_path) + get_tree().quit() + return + if _model and _model._pose_mod and _model.skeleton: + var to_world: Transform3D = _model.skeleton.global_transform + var pm = _model._pose_mod + var pts := [pm.dbg_grip, pm.dbg_fore, pm.dbg_stock] + for i in _markers.size(): + _markers[i].global_position = to_world * pts[i] + _markers[i].visible = pts[i] != Vector3.ZERO + + +# ── knobs ───────────────────────────────────────────────────────────────────── + +func _load_knobs() -> void: + _knobs = WeaponHoldTuning.resolve(_all, _skins[_skin].id, _weapons[_weapon].id) + for k in _sliders: + var entry = _sliders[k] + var spec: Array = entry.spec + if spec[4]: + var v: Vector3 = _knobs.get(k, WeaponHoldTuning.default_for(k)) + entry.x.set_value_no_signal(v.x) + entry.y.set_value_no_signal(v.y) + entry.z.set_value_no_signal(v.z) + else: + entry.x.set_value_no_signal(float(_knobs.get(k, + WeaponHoldTuning.default_for(k)))) + _refresh_label(k) + + +func _knob_changed(key: String) -> void: + var entry = _sliders[key] + var spec: Array = entry.spec + if spec[4]: + _knobs[key] = Vector3(entry.x.value, entry.y.value, entry.z.value) + else: + _knobs[key] = entry.x.value + _refresh_label(key) + if _model: + _model.set_hold_tuning(_knobs) + + +func _refresh_label(key: String) -> void: + var entry = _sliders[key] + var spec: Array = entry.spec + if spec[4]: + entry.label.text = "%s %.3f, %.3f, %.3f" % [spec[1], entry.x.value, + entry.y.value, entry.z.value] + else: + var v: float = entry.x.value + entry.label.text = "%s %s" % [spec[1], + "auto" if (key == "weapon_scale" and v < 0.01) else "%.3f" % v] + + +# ── ui ──────────────────────────────────────────────────────────────────────── + +func _build_ui() -> void: + var layer := CanvasLayer.new() + add_child(layer) + + var panel := PanelContainer.new() + panel.set_anchors_preset(Control.PRESET_LEFT_WIDE) + panel.custom_minimum_size = Vector2(430, 0) + layer.add_child(panel) + + var scroll := ScrollContainer.new() + panel.add_child(scroll) + var box := VBoxContainer.new() + box.custom_minimum_size = Vector2(410, 0) + box.add_theme_constant_override("separation", 2) + scroll.add_child(box) + + var title := Label.new() + title.text = "WEAPON HOLD LAB" + box.add_child(title) + + box.add_child(_picker("Character", _skins, func(i): + _skin = i + _reload_model())) + box.add_child(_picker("Weapon", _weapons, func(i): + _weapon = i + _load_knobs() + if _model: + _model.set_weapon(_weapons[_weapon].script) + _model.set_hold_tuning(_knobs))) + var poses: Array = [] + for p in POSES: + poses.append({"name": p[0]}) + box.add_child(_picker("Pose", poses, func(i): _pose = i)) + + box.add_child(HSeparator.new()) + for spec in WeaponHoldTuning.KNOBS: + box.add_child(_knob_row(spec)) + + box.add_child(HSeparator.new()) + var row := HBoxContainer.new() + for b in [["Save (S)", func(): _save()], ["Reset (R)", func(): _reset()], + ["Copy JSON (C)", func(): _copy()]]: + var btn := Button.new() + btn.text = b[0] + btn.pressed.connect(b[1]) + row.add_child(btn) + box.add_child(row) + + _status = Label.new() + _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _status.custom_minimum_size = Vector2(400, 40) + _status.text = "left drag orbit · wheel zoom · middle drag pan · F frame hands" + box.add_child(_status) + # Reflect whatever the shot arguments or the defaults selected, or the + # dropdown says one thing while the scene shows another. + _pickers["Character"].select(_skin) + _pickers["Weapon"].select(_weapon) + _pickers["Pose"].select(_pose) + + +func _picker(label: String, items: Array, on_pick: Callable) -> Control: + var row := HBoxContainer.new() + var l := Label.new() + l.text = label + l.custom_minimum_size = Vector2(90, 0) + row.add_child(l) + var opt := OptionButton.new() + opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL + for it in items: + opt.add_item(String(it.name)) + opt.item_selected.connect(on_pick) + row.add_child(opt) + _pickers[label] = opt + return row + + +func _knob_row(spec: Array) -> Control: + var key: String = spec[0] + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 0) + var label := Label.new() + box.add_child(label) + var entry := {"spec": spec, "label": label} + var axes := ["x", "y", "z"] if spec[4] else ["x"] + for a in axes: + var sl := HSlider.new() + sl.min_value = spec[2] + sl.max_value = spec[3] + sl.step = 0.001 + sl.custom_minimum_size = Vector2(400, 14) + sl.value_changed.connect(func(_v): _knob_changed(key)) + box.add_child(sl) + entry[a] = sl + _sliders[key] = entry + return box + + +func _save() -> void: + var where := WeaponHoldTuning.save(_all, _skins[_skin].id, + _weapons[_weapon].id, _knobs) + _status.text = "Saved %s + %s to %s" % [_skins[_skin].id, + _weapons[_weapon].id, where] + + +func _reset() -> void: + _knobs.clear() + for k in _sliders: + var e = _sliders[k] + for a in ["x", "y", "z"]: + if e.has(a): + e[a].set_value_no_signal(0.0) + _refresh_label(k) + if _model: + _model.set_hold_tuning({}) + _status.text = "Reset to what the code derives" + + +func _copy() -> void: + var flat := {} + for k in _knobs: + var v = _knobs[k] + flat[k] = [v.x, v.y, v.z] if v is Vector3 else v + DisplayServer.clipboard_set(JSON.stringify(flat, " ")) + _status.text = "Copied this character+weapon's knobs to the clipboard" + + +# ── camera ──────────────────────────────────────────────────────────────────── + +func _update_camera() -> void: + var b := Basis.from_euler(Vector3(_pitch, _yaw, 0)) + _cam.global_transform = Transform3D(b, _pivot + b * Vector3(0, 0, _dist)) + + +func _unhandled_input(e: InputEvent) -> void: + if e is InputEventMouseMotion: + if e.button_mask & MOUSE_BUTTON_MASK_LEFT: + _yaw -= e.relative.x * 0.006 + _pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4) + _update_camera() + elif e.button_mask & MOUSE_BUTTON_MASK_MIDDLE: + var b := _cam.global_transform.basis + _pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015 + _update_camera() + elif e is InputEventMouseButton and e.pressed: + if e.button_index == MOUSE_BUTTON_WHEEL_UP: + _dist = maxf(_dist * 0.9, 0.15) + _update_camera() + elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _dist = minf(_dist * 1.1, 8.0) + _update_camera() + elif e is InputEventKey and e.pressed and not e.echo: + match e.keycode: + KEY_F: + # Frame the hands, which is what is actually being judged. + if _model and _model._pose_mod: + _pivot = _model.skeleton.global_transform \ + * _model._pose_mod.dbg_grip + _dist = 0.55 + _update_camera() + KEY_S: _save() + KEY_R: _reset() + KEY_C: _copy() + KEY_ESCAPE: get_tree().quit() diff --git a/debug/weapon_lab.gd.uid b/debug/weapon_lab.gd.uid new file mode 100644 index 0000000..a4221ab --- /dev/null +++ b/debug/weapon_lab.gd.uid @@ -0,0 +1 @@ +uid://dmqu5ghj4pxl1 diff --git a/debug/weapon_lab.tscn b/debug/weapon_lab.tscn new file mode 100644 index 0000000..890d65a --- /dev/null +++ b/debug/weapon_lab.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://cwpnlabweapon01"] + +[ext_resource type="Script" path="res://debug/weapon_lab.gd" id="1_lab"] + +[node name="WeaponLab" type="Node3D"] +script = ExtResource("1_lab")