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")