The weapon is a child of a BoneAttachment3D on the trigger hand, so the two were welded by construction. Every degree `wrist_r` turned swung the barrel the same degree off the aim line — and took with it every control that could have corrected for it, because they are all expressed relative to that same hand. There was no combination of sliders that aligned a hand to a gun, which is the one thing the knob exists for. Both outcomes are now computed where the hand's local pose is set: the rotation the hand would take without the wrist offset, and the one it takes with it. The hand gets the second; the difference between them is exactly the counter-rotation the weapon mount needs, in the hand's own local frame, and `SkinnedPlayerModel._hold_weapon_still` applies it to the mount each frame. The gun ends up precisely where the solver put it. That also gives the two controls a clean split, which is what makes them usable together: TRIGGER / SUPPORT WRIST (hold) turns the HAND, gun stays on the aim line Grip roll / pitch / yaw (anchors) turns the GUN inside the hand Identity when `wrist_r` is untuned, so a character nobody has tuned mounts its weapon exactly as before. Applied in `_process` rather than inside the modifier pass on purpose. The gun's mount is not something the skeleton owns, and the compensated value only changes when a slider moves or the ADS blend travels, so one frame of lag is a fraction of a degree; reaching into the modifier to touch a scene node would be worse. wrist_gun_check asserts both halves, because only asserting the first is how this shipped broken: the hand must TURN, or the knob does nothing, and the gun must NOT, or the knob cannot be used. Across both poses and all three axes the hand turns 28.2-28.7 degrees for a 0.5 rad knob and the gun moves 0.1-0.6 — against the ~28 it would move if it were still following the wrist. The residue is the arm's own IK settling, since the hand's rotation feeds the chain that places the shoulder. Co-Authored-By: Claude Opus 5 <[email protected]>
893 lines
33 KiB
GDScript
893 lines
33 KiB
GDScript
extends Node3D
|
||
|
||
## Rig lab — the room where a character's rig is adjusted by eye, in 3D, live.
|
||
##
|
||
## godot --path . res://debug/rig_lab.tscn
|
||
##
|
||
## Everything about a rig that CAN be derived from the skeleton is derived: the
|
||
## bone roles, the mount rotation, the wrist roll, the weapon size, which
|
||
## surfaces are hair. What is left over is not a missing derivation, it is
|
||
## judgement — how high a stock rides, how far an elbow flares, how far down the
|
||
## palm a grip sits on THIS character's hand. Judgement wants an eye and a
|
||
## slider, not another constant tuned against one rig and wrong on the next.
|
||
##
|
||
## This is the environment for that. Three things can be adjusted here:
|
||
##
|
||
## HOLD how this character holds this weapon. Per character AND weapon,
|
||
## because a rifle and a pistol are not held alike.
|
||
## -> assets/characters/weapon_holds.json
|
||
## ANCHORS where the grip sits in the palm and how the gun rolls in the
|
||
## fingers. Per character only — that is a fact about the hand.
|
||
## -> assets/characters/rig_anchors.json
|
||
## SURFACES what the importer decided each surface of the model IS. Read-only
|
||
## here, but isolating a class is how you check the decision: click
|
||
## `hair` and only the hair should remain.
|
||
##
|
||
## Both tuning files are layered (defaults -> this character -> this weapon) and
|
||
## an absent file means "use what the code derives", so nothing here is required
|
||
## for the game to run correctly.
|
||
##
|
||
## CONTROLS
|
||
## drag a MARKER move that anchor left drag orbit
|
||
## middle drag pan wheel zoom
|
||
## F frame the hands
|
||
## R reset knobs S save C copy JSON to clipboard
|
||
##
|
||
## The three coloured markers ARE the anchors — red is the trigger grip, green
|
||
## the support hand on the handguard, blue the buttstock — and they can be
|
||
## dragged. The marker under the mouse swells; drag it and the hand follows.
|
||
##
|
||
## Dragging an anchor is not the same as any slider above it. The sliders move
|
||
## the GUN: `grip_offset` slides the weapon around inside the fist, and
|
||
## `gun_fore` / `gun_stock` slide the hands along the weapon's own axis. Neither
|
||
## can take a hand OFF that axis, which is what a handguard below the bore, an
|
||
## angled foregrip or a pistol grip all need. A drag writes `grip_shift` or
|
||
## `fore_shift`, in the gun's own across/up/along frame, so a nudge sideways
|
||
## stays sideways as the weapon pitches; the buttstock marker writes the shoulder
|
||
## pocket for whichever pose is showing.
|
||
##
|
||
## If a hand is not ON its marker, the IK could not reach — a different problem
|
||
## from the marker being in the wrong place, and dragging the marker further will
|
||
## not fix it.
|
||
##
|
||
## HAND versus GUN. These rotate different things, and the split is the point:
|
||
##
|
||
## TRIGGER/SUPPORT WRIST (hold) turns the HAND. The gun stays exactly where
|
||
## the solver put it, on the aim line.
|
||
## Grip roll/pitch/yaw (anchors) turns the GUN inside the hand.
|
||
##
|
||
## The weapon is a child of a BoneAttachment3D on the trigger hand, so the two
|
||
## used to be welded — every degree the wrist turned swung the barrel the same
|
||
## degree, and took with it every control that could have corrected for it. The
|
||
## pose layer now publishes the counter-rotation and the mount applies it, so
|
||
## the wrist sliders align the hand TO the gun rather than dragging it around.
|
||
|
||
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],
|
||
]
|
||
|
||
## The two editable knob groups, and everything the UI needs to build, resolve
|
||
## and save each one. Adding a third group is adding a row here.
|
||
##
|
||
## `subject` is what the group is keyed on inside its file: a weapon id for the
|
||
## hold, a fixed word for anchors, because an anchor is per character and
|
||
## re-tuning it per gun would be re-answering the same question.
|
||
const GROUPS := {
|
||
"hold": {
|
||
"title": "HOLD · this character, this weapon",
|
||
"per_weapon": true,
|
||
},
|
||
"anchors": {
|
||
"title": "ANCHORS · this character",
|
||
"per_weapon": false,
|
||
},
|
||
}
|
||
|
||
var _model: SkinnedPlayerModel
|
||
var _skins: Array = []
|
||
var _weapons: Array = []
|
||
var _skin := 0
|
||
var _weapon := 0
|
||
var _pose := 0
|
||
## group -> resolved knob table, and group -> the whole file it came from.
|
||
var _knobs: Dictionary = {"hold": {}, "anchors": {}}
|
||
var _all: Dictionary = {"hold": {}, "anchors": {}}
|
||
## group -> key -> {spec, label, x, y, z}. Only ever holds the knobs currently
|
||
## ON SCREEN, which for the hold means only the selected pose's.
|
||
var _sliders: Dictionary = {"hold": {}, "anchors": {}}
|
||
## group -> the container its slider rows live in, and its heading, so the hold
|
||
## can be rebuilt when the pose changes without rebuilding the whole panel.
|
||
var _knob_boxes: Dictionary = {}
|
||
var _headings: Dictionary = {}
|
||
## "" means the pose buttons drive the model; anything else is a canonical clip
|
||
## being played on its own so a single animation can be watched end to end.
|
||
var _clip := ""
|
||
var _clip_picker: OptionButton
|
||
var _surface_box: VBoxContainer
|
||
var _isolated := ""
|
||
## MeshInstance3D -> surface index -> the override material displaced by
|
||
## isolation, so it can be put back without rebuilding the character.
|
||
var _hidden_surfaces: 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 <path> [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])))
|
||
if args.size() > 4:
|
||
# Which pose to photograph — the panel differs per pose now, so a
|
||
# shot of only the default one says nothing about the others.
|
||
_pose = clampi(int(args[4]), 0, POSES.size() - 1)
|
||
_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. `_process` lifts this for the marker under the mouse, so
|
||
# the one you are about to grab is the one you can see.
|
||
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["hold"] = WeaponHoldTuning.load_all()
|
||
_all["anchors"] = RigAnchors.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)
|
||
_push("hold")
|
||
_push("anchors")
|
||
_clip = ""
|
||
_refresh_clip_picker()
|
||
_rebuild_surface_list()
|
||
_apply_pose()
|
||
|
||
|
||
## Fill the clip list from what this character actually HAS.
|
||
##
|
||
## Canonical names, from the model's own resolution table, rather than the raw
|
||
## clip names in the GLB — those differ per character (one rig's "CrouchIdle" is
|
||
## another's "Crouch_Idle_Loop") and the canonical name is the one the game asks
|
||
## for, so it is the one worth being able to audition.
|
||
func _refresh_clip_picker() -> void:
|
||
if _clip_picker == null:
|
||
return
|
||
_clip_picker.clear()
|
||
_clip_picker.add_item("(pose driven)")
|
||
if _model:
|
||
var names: Array = _model.clip_names_debug()
|
||
names.sort()
|
||
for n in names:
|
||
_clip_picker.add_item(n)
|
||
_clip_picker.select(0)
|
||
|
||
|
||
func _select_clip(index: int) -> void:
|
||
_clip = "" if index <= 0 else _clip_picker.get_item_text(index)
|
||
if _clip != "" and _model:
|
||
_model.play_clip_debug(_clip)
|
||
_status.text = "Pose buttons driving" if _clip == "" \
|
||
else "Playing '%s' on its own" % _clip
|
||
|
||
|
||
func _apply_pose() -> void:
|
||
if not _model or not _model.loaded:
|
||
return
|
||
# A clip chosen in the scrubber owns playback: driving update_state as well
|
||
# would blend straight back to whatever the pose implies, and the clip would
|
||
# never be seen.
|
||
if _clip != "":
|
||
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("rig_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
|
||
# The one being dragged, or the one the mouse is over, swells AND
|
||
# draws through the body.
|
||
#
|
||
# Depth testing is right for the other two: drawn through the mesh
|
||
# they look like they are floating in front of the chest when they
|
||
# are in fact behind an arm, which is the wrong impression for
|
||
# judging whether a hand reached its target. But it is wrong for the
|
||
# one being grabbed — at any useful framing the hands occlude all
|
||
# three markers, and a handle you cannot see is a handle you cannot
|
||
# find. `_marker_under` never cared about occlusion; this makes that
|
||
# visible rather than a secret.
|
||
var hot: bool = (i == _drag_marker) or (_drag_marker < 0 and i == _hover_marker)
|
||
_markers[i].scale = Vector3.ONE * (1.9 if hot else 1.0)
|
||
var mat := _markers[i].material_override as StandardMaterial3D
|
||
if mat:
|
||
mat.no_depth_test = hot
|
||
|
||
|
||
# ── dragging the anchors ──────────────────────────────────────────────────────
|
||
#
|
||
# The markers are the anchor POINTS the hands are solved onto. Until now they
|
||
# could only be moved through the sliders, and two of the three could only move
|
||
# along the barrel — `gun_stock` and `gun_fore` are distances along the gun's own
|
||
# axis, so the trigger and support hands slid up and down the weapon and nowhere
|
||
# else. What the sliders DID move freely was the gun itself, under anchors that
|
||
# stayed put.
|
||
#
|
||
# So: grab a marker and drag it. Each one writes to the knob that expresses that
|
||
# motion, in the frame that knob is written in — which is why the pose layer now
|
||
# publishes the gun's basis. A drag left has to mean "left across the weapon"
|
||
# whether the muzzle is down at low ready or level at ADS.
|
||
|
||
## marker index -> [knob key, frame]. "gun" is the weapon's own across/up/along
|
||
## basis; "skeleton" is the model's, which is where the shoulder pocket lives.
|
||
const MARKER_KNOB := [
|
||
["grip_shift", "gun"],
|
||
["fore_shift", "gun"],
|
||
["pocket", "skeleton"], # resolved to pocket_hip / pocket_ads by pose
|
||
]
|
||
## How close, in pixels, the mouse has to be to a marker to take hold of it.
|
||
const GRAB_RADIUS := 26.0
|
||
|
||
var _drag_marker := -1
|
||
var _hover_marker := -1
|
||
## The plane a drag is measured on: fixed at grab time so it does not drift
|
||
## toward the camera as the marker follows the mouse.
|
||
var _drag_plane_at := Vector3.ZERO
|
||
var _drag_last := Vector3.ZERO
|
||
|
||
|
||
## The stock anchor is a blend of two knobs; a drag edits whichever one this
|
||
## pose is actually showing. Dragging at low ready must not silently rewrite the
|
||
## aiming pocket.
|
||
func _stock_knob() -> String:
|
||
return "pocket_%s" % _hold_pose()
|
||
|
||
|
||
func _knob_for(marker: int) -> String:
|
||
var key: String = MARKER_KNOB[marker][0]
|
||
return _stock_knob() if key == "pocket" else key
|
||
|
||
|
||
func _marker_under(mouse: Vector2) -> int:
|
||
var best := -1
|
||
var best_d := GRAB_RADIUS
|
||
for i in _markers.size():
|
||
if not _markers[i].visible:
|
||
continue
|
||
var p: Vector3 = _markers[i].global_position
|
||
# Behind the camera projects to a nonsense point that can still land
|
||
# within the grab radius.
|
||
if _cam.is_position_behind(p):
|
||
continue
|
||
var d := _cam.unproject_position(p).distance_to(mouse)
|
||
if d < best_d:
|
||
best_d = d
|
||
best = i
|
||
return best
|
||
|
||
|
||
## Where the mouse ray meets the drag plane — through the grab point, facing the
|
||
## camera.
|
||
func _plane_hit(mouse: Vector2) -> Vector3:
|
||
var origin := _cam.project_ray_origin(mouse)
|
||
var dir := _cam.project_ray_normal(mouse)
|
||
var n := -_cam.global_transform.basis.z
|
||
var denom := dir.dot(n)
|
||
if absf(denom) < 1e-5:
|
||
return _drag_last
|
||
return origin + dir * (((_drag_plane_at - origin).dot(n)) / denom)
|
||
|
||
|
||
func _begin_drag(marker: int, mouse: Vector2) -> void:
|
||
_drag_marker = marker
|
||
_drag_plane_at = _markers[marker].global_position
|
||
_drag_last = _plane_hit(mouse)
|
||
_status.text = "Dragging the %s anchor (%s)" % [
|
||
["trigger hand", "support hand", "buttstock"][marker], _knob_for(marker)]
|
||
|
||
|
||
func _drag_to(mouse: Vector2) -> void:
|
||
if _model == null or _model._pose_mod == null or _model.skeleton == null:
|
||
return
|
||
var now := _plane_hit(mouse)
|
||
var world_delta := now - _drag_last
|
||
_drag_last = now
|
||
|
||
# World -> skeleton, because that is the space the pose layer works in.
|
||
var delta: Vector3 = _model.skeleton.global_transform.basis.inverse() * world_delta
|
||
if MARKER_KNOB[_drag_marker][1] == "gun":
|
||
# ...and on into the gun's frame for the two hand anchors. Orthonormal,
|
||
# so the inverse is the transpose, but say what is meant.
|
||
delta = _model._pose_mod.dbg_gun_basis.inverse() * delta
|
||
|
||
var key := _knob_for(_drag_marker)
|
||
var spec := _spec_for("hold", key)
|
||
var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
|
||
if not (cur is Vector3):
|
||
cur = Vector3.ZERO
|
||
var lo: float = spec[2]
|
||
var hi: float = spec[3]
|
||
_set_knob("hold", key, Vector3(
|
||
clampf(cur.x + delta.x, lo, hi),
|
||
clampf(cur.y + delta.y, lo, hi),
|
||
clampf(cur.z + delta.z, lo, hi)))
|
||
|
||
|
||
## The spec for a key, searched across every pose rather than the screenful on
|
||
## show. A drag writes the selected pose's key, which is on screen; a reset
|
||
## touches both. Looking only at `_specs` would silently hand back the
|
||
## catch-all below for anything belonging to the other pose.
|
||
func _spec_for(group: String, key: String) -> Array:
|
||
for spec in _all_specs(group):
|
||
if spec[0] == key:
|
||
return spec
|
||
return ["", "", -1.0, 1.0, true, Vector3.ZERO]
|
||
|
||
|
||
## Write a knob from somewhere other than its own slider, and keep the slider in
|
||
## step. Without the write-back the sliders would silently disagree with the
|
||
## model the moment anything was dragged, and Save would store the sliders.
|
||
func _set_knob(group: String, key: String, value) -> void:
|
||
_knobs[group][key] = value
|
||
var entry = _sliders[group].get(key)
|
||
if entry != null:
|
||
if entry.spec[4]:
|
||
entry.x.set_value_no_signal(value.x)
|
||
entry.y.set_value_no_signal(value.y)
|
||
entry.z.set_value_no_signal(value.z)
|
||
else:
|
||
entry.x.set_value_no_signal(float(value))
|
||
_refresh_label(group, key)
|
||
_push(group)
|
||
|
||
|
||
# ── knobs ─────────────────────────────────────────────────────────────────────
|
||
#
|
||
# The two groups differ only in which spec table describes them, which file they
|
||
# live in and what they are keyed on. Everything below is written once against
|
||
# those three facts rather than twice against the two groups, so the anchors got
|
||
# a full editor — sliders, live preview, reset, save, clipboard — for the cost of
|
||
# a spec table.
|
||
|
||
## The spec table for a group, for the pose CURRENTLY being adjusted.
|
||
##
|
||
## The hold's table is a function of the pose: half its knobs mean something
|
||
## different at low ready than they do down the sights, and one of them —
|
||
## muzzle pitch — does not exist down the sights at all, because there the
|
||
## muzzle follows the camera. Showing all of them at once meant every slider on
|
||
## screen was for one of two poses and nothing said which.
|
||
func _specs(group: String) -> Array:
|
||
if group == "anchors":
|
||
return RigAnchors.KNOBS
|
||
return WeaponHoldTuning.knobs_for(_hold_pose())
|
||
|
||
|
||
## Which hold the selected pose is tuning.
|
||
##
|
||
## The runtime blends between exactly TWO holds, on `ads`. "Running" and
|
||
## "Crouched" are locomotion states that still use the low-ready hold, so they
|
||
## edit the same numbers as "Low ready" — and the heading says so, rather than
|
||
## letting someone tune "Running" and wonder why standing still changed.
|
||
func _hold_pose() -> String:
|
||
return WeaponHoldTuning.pose_for_ads(POSES[_pose][3])
|
||
|
||
|
||
func _default_for(group: String, key: String):
|
||
return RigAnchors.default_for(key) if group == "anchors" \
|
||
else WeaponHoldTuning.default_for(key)
|
||
|
||
|
||
## Every knob of a group across BOTH poses. Reset works on the whole table, not
|
||
## just the screenful on show — resetting while low ready is selected must not
|
||
## leave the aiming pocket half-tuned and invisible.
|
||
func _all_specs(group: String) -> Array:
|
||
return RigAnchors.KNOBS if group == "anchors" else WeaponHoldTuning.all_knobs()
|
||
|
||
|
||
func _load_knobs() -> void:
|
||
for group in GROUPS:
|
||
if group == "anchors":
|
||
_knobs[group] = RigAnchors.resolve(_all[group], _skins[_skin].id)
|
||
else:
|
||
_knobs[group] = WeaponHoldTuning.resolve(_all[group],
|
||
_skins[_skin].id, _weapons[_weapon].id)
|
||
_sync_sliders()
|
||
|
||
|
||
## Build the slider rows for each group, for the pose being adjusted.
|
||
##
|
||
## Called on startup and whenever the pose changes. Nothing about the underlying
|
||
## VALUES changes here — `_knobs` holds every pose's numbers at once, and Save
|
||
## writes all of them. This only decides which are on screen, so that every
|
||
## slider you can see belongs to the pose in the picker above it.
|
||
func _rebuild_knobs() -> void:
|
||
for group in GROUPS:
|
||
var rows: VBoxContainer = _knob_boxes.get(group)
|
||
if rows == null:
|
||
continue
|
||
for c in rows.get_children():
|
||
rows.remove_child(c)
|
||
c.queue_free()
|
||
_sliders[group].clear()
|
||
for spec in _specs(group):
|
||
rows.add_child(_knob_row(group, spec))
|
||
var head: Label = _headings.get(group)
|
||
if head:
|
||
head.text = _heading_for(group)
|
||
_sync_sliders()
|
||
|
||
|
||
func _heading_for(group: String) -> String:
|
||
if group != "hold":
|
||
return GROUPS[group]["title"]
|
||
var pose := _hold_pose()
|
||
var shown: String = WeaponHoldTuning.POSE_NAMES[pose]
|
||
# Say outright when the selected pose is not its own hold. Running and
|
||
# Crouched use the low-ready numbers, and someone tuning "Running" and
|
||
# finding standing still had changed too would rightly call that a bug.
|
||
var via := ""
|
||
if POSES[_pose][0].to_lower() != shown:
|
||
via = " (%s uses the %s hold)" % [POSES[_pose][0], shown]
|
||
return "HOLD · %s · this character, this weapon%s" % [shown.to_upper(), via]
|
||
|
||
|
||
## Push the stored values into whatever sliders are currently on screen.
|
||
func _sync_sliders() -> void:
|
||
for group in GROUPS:
|
||
for k in _sliders[group]:
|
||
var entry = _sliders[group][k]
|
||
var v = _knobs[group].get(k, _default_for(group, k))
|
||
if entry.spec[4]:
|
||
if not (v is Vector3):
|
||
v = Vector3.ZERO
|
||
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(v))
|
||
_refresh_label(group, k)
|
||
|
||
|
||
func _knob_changed(group: String, key: String) -> void:
|
||
var entry = _sliders[group][key]
|
||
var spec: Array = entry.spec
|
||
if spec[4]:
|
||
_knobs[group][key] = Vector3(entry.x.value, entry.y.value, entry.z.value)
|
||
else:
|
||
_knobs[group][key] = entry.x.value
|
||
_refresh_label(group, key)
|
||
_push(group)
|
||
|
||
|
||
func _push(group: String) -> void:
|
||
if _model == null:
|
||
return
|
||
if group == "anchors":
|
||
_model.set_anchors(_knobs[group])
|
||
else:
|
||
_model.set_hold_tuning(_knobs[group])
|
||
|
||
|
||
func _refresh_label(group: String, key: String) -> void:
|
||
var entry = _sliders[group][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)
|
||
|
||
# The lab is a dev tool, but it is still a screen in this game, and the
|
||
# theme is meant to be the ONE theme. It also has to be applied to this
|
||
# Control rather than only to the Window: a CanvasLayer is not a Control, so
|
||
# theme inheritance stops at it.
|
||
UITheme.apply_global(get_tree())
|
||
var panel := PanelContainer.new()
|
||
panel.theme = UITheme.build()
|
||
panel.set_anchors_preset(Control.PRESET_LEFT_WIDE)
|
||
panel.custom_minimum_size = Vector2(440, 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)
|
||
|
||
box.add_child(UITheme.title("RIG LAB", 34, 0.0))
|
||
box.add_child(UITheme.divider(0.3))
|
||
|
||
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)
|
||
_push("hold")
|
||
_push("anchors")))
|
||
var poses: Array = []
|
||
for p in POSES:
|
||
poses.append({"name": p[0]})
|
||
box.add_child(_picker("Pose", poses, func(i):
|
||
_pose = i
|
||
# The hold's knobs ARE the pose's knobs, so changing pose rebuilds them.
|
||
_rebuild_knobs()))
|
||
# Clip scrubber. The four poses above are the states the game drives; this
|
||
# plays one clip on its own, which is the only way to watch a whole
|
||
# animation end to end and see where a retarget went wrong.
|
||
var clip_row := _picker("Clip", [{"name": "(pose driven)"}],
|
||
func(i): _select_clip(i))
|
||
_clip_picker = clip_row.get_child(1)
|
||
box.add_child(clip_row)
|
||
|
||
for group in GROUPS:
|
||
box.add_child(UITheme.divider(0.5))
|
||
var head := UITheme.heading("", 18)
|
||
_headings[group] = head
|
||
box.add_child(head)
|
||
# The hold's rows are rebuilt whenever the pose changes, so they live in
|
||
# their own container rather than loose in the panel.
|
||
var rows := VBoxContainer.new()
|
||
rows.add_theme_constant_override("separation", 2)
|
||
_knob_boxes[group] = rows
|
||
box.add_child(rows)
|
||
box.add_child(_group_buttons(group))
|
||
_rebuild_knobs()
|
||
|
||
box.add_child(UITheme.divider(0.7))
|
||
box.add_child(UITheme.heading("SURFACES · click to isolate", 18))
|
||
_surface_box = VBoxContainer.new()
|
||
_surface_box.add_theme_constant_override("separation", 1)
|
||
box.add_child(_surface_box)
|
||
|
||
_status = Label.new()
|
||
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
_status.custom_minimum_size = Vector2(400, 40)
|
||
_status.text = "DRAG A MARKER to move that anchor · 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(group: String, spec: Array) -> Control:
|
||
var key: String = spec[0]
|
||
var box := VBoxContainer.new()
|
||
box.add_theme_constant_override("separation", 0)
|
||
var label := Label.new()
|
||
label.add_theme_font_size_override("font_size", 16)
|
||
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(group, key))
|
||
box.add_child(sl)
|
||
entry[a] = sl
|
||
_sliders[group][key] = entry
|
||
return box
|
||
|
||
|
||
func _group_buttons(group: String) -> Control:
|
||
var row := HBoxContainer.new()
|
||
for b in [["Save", func(): _save(group)], ["Reset", func(): _reset(group)],
|
||
["Copy", func(): _copy(group)]]:
|
||
var btn := Button.new()
|
||
btn.text = b[0]
|
||
btn.pressed.connect(b[1])
|
||
row.add_child(btn)
|
||
return row
|
||
|
||
|
||
func _save(group: String) -> void:
|
||
var where := ""
|
||
if group == "anchors":
|
||
where = RigAnchors.save(_all[group], _skins[_skin].id, _knobs[group])
|
||
_status.text = "Saved %s anchors to %s" % [_skins[_skin].id, where]
|
||
else:
|
||
where = WeaponHoldTuning.save(_all[group], _skins[_skin].id,
|
||
_weapons[_weapon].id, _knobs[group])
|
||
_status.text = "Saved %s + %s hold to %s" % [_skins[_skin].id,
|
||
_weapons[_weapon].id, where]
|
||
|
||
|
||
## Clear a whole group — every pose, not just the one on screen.
|
||
##
|
||
## The sliders then take their DEFAULTS, not zero. Those are the same thing for
|
||
## every anchor and for most of the hold, but not for all of it: a slider parked
|
||
## at 0 next to a code default of 1.0 means reset quietly switched that behaviour
|
||
## off rather than restoring it.
|
||
func _reset(group: String) -> void:
|
||
_knobs[group] = {}
|
||
_sync_sliders()
|
||
_push(group)
|
||
_status.text = "Reset every %s knob, both poses, to what the code derives" % group
|
||
|
||
|
||
func _copy(group: String) -> void:
|
||
DisplayServer.clipboard_set(JSON.stringify(
|
||
TuningStore.flatten(_knobs[group]), " "))
|
||
_status.text = "Copied %s to the clipboard" % group
|
||
|
||
|
||
# ── surfaces ──────────────────────────────────────────────────────────────────
|
||
#
|
||
# What the importer decided each surface of this model IS. Read-only, because
|
||
# the decision belongs in the sidecar where the whole game reads it — but
|
||
# ISOLATING a class is how the decision gets checked. Click `hair` and only the
|
||
# hair should be left standing. Anything else still visible was misclassified.
|
||
|
||
func _rebuild_surface_list() -> void:
|
||
for c in _surface_box.get_children():
|
||
c.queue_free()
|
||
_isolated = ""
|
||
_hidden_surfaces.clear()
|
||
if _model == null:
|
||
return
|
||
var counts := {}
|
||
for cls in [SkinSurfaces.BODY, SkinSurfaces.CLOTH, SkinSurfaces.HAIR,
|
||
SkinSurfaces.ACCESSORY, SkinSurfaces.LINEWORK]:
|
||
var n: int = _model.surfaces_of(cls).size()
|
||
if n > 0:
|
||
counts[cls] = n
|
||
for cls in counts:
|
||
var btn := Button.new()
|
||
btn.text = "%s ×%d" % [cls, counts[cls]]
|
||
btn.toggle_mode = true
|
||
btn.pressed.connect(func(): _isolate(cls if _isolated != cls else ""))
|
||
_surface_box.add_child(btn)
|
||
if counts.is_empty():
|
||
_surface_box.add_child(UITheme.heading("no surface table", 16))
|
||
|
||
|
||
## Show only one surface class, or "" for all of them.
|
||
##
|
||
## Hidden by swapping in a fully transparent material rather than by hiding the
|
||
## MeshInstance, because a mesh is not one class: Miku's body, face and hair are
|
||
## three surfaces of a single mesh, and hiding the node would take all three.
|
||
## The displaced materials are kept so this is reversible without rebuilding the
|
||
## character — which would also throw away whatever is being tuned.
|
||
func _isolate(cls: String) -> void:
|
||
for mi in _hidden_surfaces:
|
||
if is_instance_valid(mi):
|
||
for s in _hidden_surfaces[mi]:
|
||
mi.set_surface_override_material(s, _hidden_surfaces[mi][s])
|
||
_hidden_surfaces.clear()
|
||
_isolated = cls
|
||
for b in _surface_box.get_children():
|
||
if b is Button:
|
||
b.set_pressed_no_signal(b.text.begins_with(cls) and cls != "")
|
||
if cls == "" or _model == null:
|
||
_status.text = "Showing every surface"
|
||
return
|
||
|
||
var keep := {}
|
||
for pair in _model.surfaces_of(cls):
|
||
keep["%s|%d" % [pair[0].get_instance_id(), pair[1]]] = true
|
||
var blank := StandardMaterial3D.new()
|
||
blank.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||
blank.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||
blank.albedo_color = Color(0, 0, 0, 0)
|
||
for mi in _model.find_children("*", "MeshInstance3D", true, false):
|
||
if mi.mesh == null:
|
||
continue
|
||
for s in mi.mesh.get_surface_count():
|
||
if keep.has("%s|%d" % [mi.get_instance_id(), s]):
|
||
continue
|
||
if not _hidden_surfaces.has(mi):
|
||
_hidden_surfaces[mi] = {}
|
||
_hidden_surfaces[mi][s] = mi.get_surface_override_material(s)
|
||
mi.set_surface_override_material(s, blank)
|
||
_status.text = "Isolated '%s' — anything else still visible is misclassified" % cls
|
||
|
||
|
||
# ── 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:
|
||
# An anchor drag OWNS the left button — otherwise grabbing a marker
|
||
# would also orbit the camera, and the anchor would appear to move
|
||
# because the view did.
|
||
if _drag_marker >= 0:
|
||
_drag_to(e.position)
|
||
return
|
||
_hover_marker = _marker_under(e.position) \
|
||
if e.button_mask == 0 else _hover_marker
|
||
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.button_index == MOUSE_BUTTON_LEFT:
|
||
if e.pressed:
|
||
var hit := _marker_under(e.position)
|
||
if hit >= 0:
|
||
_begin_drag(hit, e.position)
|
||
elif _drag_marker >= 0:
|
||
_status.text = "%s is now %s — Save to keep it" % [
|
||
_knob_for(_drag_marker),
|
||
_knobs["hold"].get(_knob_for(_drag_marker), Vector3.ZERO)]
|
||
_drag_marker = -1
|
||
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()
|
||
# The shortcuts act on the HOLD group, which is what they meant
|
||
# when it was the only group. Anchors have their own buttons.
|
||
KEY_S: _save("hold")
|
||
KEY_R: _reset("hold")
|
||
KEY_C: _copy("hold")
|
||
KEY_ESCAPE: get_tree().quit()
|