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