feat(characters): rig anchors, and a lab that edits any tuning group
The weapon lab could tune how a character HOLDS a gun. It could not tune where
the gun sits in the hand, and that is a different question with a different
scope: a hold is per character and weapon, an anchor is a fact about the hand.
RigAnchors adds it. A hand bone's origin is the WRIST, not the palm, and how far
down the palm a grip should sit — and how the gun rolls in the fingers — depends
on how big that character's hand is and how the artist posed the thumb. It
cannot be derived, it differs per character, and it is small. So it is an offset
that defaults to identity, and identity means exactly what the code derived
before anchors existed: an untuned character is bit-for-bit unchanged.
Deliberately NOT a fixed rotation on the mount. There used to be one, and a bone
attachment is expressed in the BONE's axes, which no two rigs agree on — that
constant is why the hand mount points were wrong on every character. The derived
mount stays derived; the anchor is a nudge on top of it, and nothing here spells
a bone name.
The layering, the JSON round trip and the res://-then-user:// write are now
TuningStore, because none of that was ever specific to weapons and two copies of
it would mean two places for "an exported build's tuning pass is silently
discarded" to come back. WeaponHoldTuning is built on it with its file format
unchanged.
The lab is a rig lab now, and it grew three things:
ANCHORS a second knob group. It cost a spec table — everything in the lab is
written against "which specs, which file, keyed on what" rather than
twice against the two groups, so anchors got sliders, live preview,
reset, save and clipboard for free. A third group is a third row in
GROUPS.
CLIP audition one animation on its own. The four pose buttons are the
states the game drives; watching a whole clip end to end is how you
see where a retarget went wrong, and there was no way to do it.
SURFACES what the importer decided each surface IS, and a click to isolate a
class. Isolating is how the decision gets CHECKED: click `hair` and
anything else still standing was misclassified. Hidden by swapping
in a transparent material rather than hiding the node, because a
mesh is not one class — Miku's body, face and hair are three
surfaces of one mesh.
And it takes the game's theme, applied to its own panel rather than only to the
Window, for the same reason the pause menu needed it: a CanvasLayer is not a
Control, so theme inheritance stops at one.
debug/rig_anchor_check.gd asserts the physical consequence rather than the
plumbing — an anchor system is easy to build so that the sliders move, the file
saves, the JSON round-trips and the gun does not budge. All six characters move
their weapon by exactly the offset asked for, measured in the attachment's frame
because a world-space delta on an animating skeleton is mostly the idle
animation, and all six return exactly to the derived mount when it is cleared.
Also fixes BoltRule, whose zigzag was self-intersecting: draw_colored_polygon
triangulates, and a crossing outline fails triangulation and draws nothing at
all except a console full of "Invalid polygon data". Same silhouette, walked as
a closed loop, with the ink edge as a polyline rather than a grown polygon —
growing a concave shape from its centroid reintroduces the crossing.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
26f7c2c622
commit
08ae85b286
@@ -0,0 +1 @@
|
||||
uid://br28g1yiljtpp
|
||||
@@ -0,0 +1,115 @@
|
||||
extends SceneTree
|
||||
|
||||
## Does a rig anchor actually move anything?
|
||||
##
|
||||
## The anchor system is easy to build so that it looks right and does nothing:
|
||||
## the lab shows sliders, the file saves, the JSON round-trips, and the gun does
|
||||
## not move a millimetre — because the value was read into a variable nobody
|
||||
## consumed, or because the weapon was measured before the offset was applied
|
||||
## and the hands are still being solved onto the old grip.
|
||||
##
|
||||
## So this asserts the physical consequence. Set an anchor, and the weapon must
|
||||
## move by that much, in the hand bone's own axes, on every character.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/rig_anchor_check.gd
|
||||
|
||||
const OFFSET := Vector3(0.03, -0.02, 0.05)
|
||||
## Millimetres of slack. The weapon is parented to a BoneAttachment3D that
|
||||
## follows an animating skeleton, so the two samples are a frame apart on a
|
||||
## moving arm; this has to be loose enough to survive that and tight enough that
|
||||
## "did not move at all" fails.
|
||||
const TOLERANCE := 0.004
|
||||
|
||||
var _fails := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
# Autoloads are not in the tree yet when a `-s` script's _init runs.
|
||||
await process_frame
|
||||
await process_frame
|
||||
var data = JSON.parse_string(FileAccess.get_file_as_string(
|
||||
"res://assets/characters/skins/skins.json"))
|
||||
var weapon := _first_weapon()
|
||||
if weapon == "":
|
||||
print("no weapon script in the loadout database")
|
||||
quit(1)
|
||||
return
|
||||
for entry in data["skins"]:
|
||||
await _check_skin(entry["id"], entry.get("model", ""), weapon)
|
||||
print("\n=== RIG ANCHORS ===\nFailures: %d" % _fails)
|
||||
quit(1 if _fails > 0 else 0)
|
||||
|
||||
|
||||
func _first_weapon() -> String:
|
||||
var db = root.get_node("LoadoutManager").weapon_db
|
||||
var ids: Array = db.keys()
|
||||
ids.sort()
|
||||
for id in ids:
|
||||
var script: String = db[id].get("script", "")
|
||||
if script != "" and ResourceLoader.exists(script):
|
||||
return script
|
||||
return ""
|
||||
|
||||
|
||||
func _check_skin(id: String, path: String, weapon: String) -> void:
|
||||
if path == "" or not ResourceLoader.exists(path):
|
||||
return
|
||||
var model := SkinnedPlayerModel.new()
|
||||
model.model_path = path
|
||||
model.skin_id = id
|
||||
root.add_child(model)
|
||||
for _i in 4:
|
||||
await process_frame
|
||||
model.set_weapon(weapon)
|
||||
for _i in 4:
|
||||
await process_frame
|
||||
|
||||
var w := _weapon_node(model)
|
||||
if w == null:
|
||||
print(" FAIL: '%s' did not mount a weapon at all" % id)
|
||||
_fails += 1
|
||||
model.queue_free()
|
||||
return
|
||||
|
||||
# Measured in the ATTACHMENT's frame, not the world's. The attachment tracks
|
||||
# a bone on an animating skeleton, so a world-space delta would be the sum of
|
||||
# the anchor and a frame of the idle animation — and the idle is bigger.
|
||||
var before: Vector3 = w.position
|
||||
model.set_anchors({"grip_offset": OFFSET})
|
||||
await process_frame
|
||||
w = _weapon_node(model)
|
||||
var moved: Vector3 = w.position - before
|
||||
|
||||
var err := (moved - OFFSET).length()
|
||||
if err <= TOLERANCE:
|
||||
print(" OK: '%s' grip anchor moved the weapon by %.1f, %.1f, %.1f mm"
|
||||
% [id, moved.x * 1000.0, moved.y * 1000.0, moved.z * 1000.0])
|
||||
else:
|
||||
print(" FAIL: '%s' asked for (%.3f, %.3f, %.3f), got (%.3f, %.3f, %.3f)"
|
||||
% [id, OFFSET.x, OFFSET.y, OFFSET.z, moved.x, moved.y, moved.z])
|
||||
_fails += 1
|
||||
|
||||
# ...and back to nothing tuned, which must restore the untouched mount
|
||||
# exactly. An anchor that cannot be cleared is a one-way door in a tool whose
|
||||
# whole purpose is trying things out.
|
||||
model.set_anchors({})
|
||||
await process_frame
|
||||
w = _weapon_node(model)
|
||||
if w.position.distance_to(before) <= TOLERANCE:
|
||||
print(" OK: '%s' clearing the anchor restores the derived mount" % id)
|
||||
else:
|
||||
print(" FAIL: '%s' did not return to the derived mount" % id)
|
||||
_fails += 1
|
||||
model.queue_free()
|
||||
|
||||
|
||||
## The attachment is a child of the SKELETON, which is nested somewhere inside
|
||||
## the loaded glTF scene rather than being a direct child of the model — so it
|
||||
## is reached from the skeleton, not by a path from the model.
|
||||
func _weapon_node(model) -> Node3D:
|
||||
if model.skeleton == null:
|
||||
return null
|
||||
var attach: Node = model.skeleton.get_node_or_null("WeaponAttachment")
|
||||
if attach == null or attach.get_child_count() == 0:
|
||||
return null
|
||||
return attach.get_child(0) as Node3D
|
||||
@@ -0,0 +1,613 @@
|
||||
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
|
||||
## 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],
|
||||
]
|
||||
|
||||
## 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}
|
||||
var _sliders: Dictionary = {"hold": {}, "anchors": {}}
|
||||
## "" 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])))
|
||||
_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["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
|
||||
|
||||
|
||||
# ── 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.
|
||||
|
||||
func _specs(group: String) -> Array:
|
||||
return RigAnchors.KNOBS if group == "anchors" else WeaponHoldTuning.KNOBS
|
||||
|
||||
|
||||
func _default_for(group: String, key: String):
|
||||
return RigAnchors.default_for(key) if group == "anchors" \
|
||||
else WeaponHoldTuning.default_for(key)
|
||||
|
||||
|
||||
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)
|
||||
for k in _sliders[group]:
|
||||
var entry = _sliders[group][k]
|
||||
var spec: Array = entry.spec
|
||||
if spec[4]:
|
||||
var v: Vector3 = _knobs[group].get(k, _default_for(group, 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[group].get(k,
|
||||
_default_for(group, k))))
|
||||
_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))
|
||||
# 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))
|
||||
box.add_child(UITheme.heading(GROUPS[group]["title"], 18))
|
||||
for spec in _specs(group):
|
||||
box.add_child(_knob_row(group, spec))
|
||||
box.add_child(_group_buttons(group))
|
||||
|
||||
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 = "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]
|
||||
|
||||
|
||||
func _reset(group: String) -> void:
|
||||
_knobs[group] = {}
|
||||
for k in _sliders[group]:
|
||||
var e = _sliders[group][k]
|
||||
for a in ["x", "y", "z"]:
|
||||
if e.has(a):
|
||||
# Back to the DEFAULT, not to 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.
|
||||
var d = _default_for(group, k)
|
||||
var v: float = (d[a] if d is Vector3 else float(d)) \
|
||||
if e.spec[4] else float(d)
|
||||
e[a].set_value_no_signal(v)
|
||||
_refresh_label(group, k)
|
||||
_push(group)
|
||||
_status.text = "Reset %s 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:
|
||||
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()
|
||||
# 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()
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3 uid="uid://c1aec1m0pwuk3"]
|
||||
|
||||
[ext_resource type="Script" path="res://debug/rig_lab.gd" id="1_lab"]
|
||||
|
||||
[node name="RigLab" type="Node3D"]
|
||||
script = ExtResource("1_lab")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnx7lbjpa6gpu
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2ucqqxfeaer5
|
||||
@@ -1,403 +0,0 @@
|
||||
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 <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])))
|
||||
_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()
|
||||
@@ -1,6 +0,0 @@
|
||||
[gd_scene format=3 uid="uid://c1aec1m0pwuk3"]
|
||||
|
||||
[ext_resource type="Script" path="res://debug/weapon_lab.gd" id="1_lab"]
|
||||
|
||||
[node name="WeaponLab" type="Node3D"]
|
||||
script = ExtResource("1_lab")
|
||||
Reference in New Issue
Block a user