I have been guessing at numbers that want an eye on them. This is the environment to set them instead. godot --path . res://debug/weapon_lab.tscn Pick a character, pick a weapon, pick a pose, drag sliders, press Save. Thirteen knobs — weapon size, support-hand distance along the barrel, grip to buttstock, muzzle pitch at low ready, both wrist rolls, three finger curl amounts, the stock pocket for low-ready and for aiming, and both elbow poles. Three markers show the points being solved for: red trigger grip, green support hand, blue buttstock. If a hand is not ON its marker the IK could not reach it, which is a different problem from the marker being in the wrong place, and the two used to be indistinguishable. Results land in assets/characters/weapon_holds.json, resolved in layers so a number can be set once and contradicted where it matters: defaults every character, every weapon skins.<skin>._all this character, every weapon skins.<skin>.<weapon> this character, this weapon An empty file means "use what the code derives", so the game runs exactly as before until something is actually tuned. The knobs that CAN be derived from the skeleton still are — mount rotation, wrist frame, weapon size — and their sliders read "auto" at zero rather than silently overriding. Two things this had to get right to be honest rather than merely present. Slider defaults come from the same table as the code defaults, because a slider parked at 0 beside a code default of 1.0 means the first touch of that slider silently switches finger curl off. And the markers are depth-tested: drawn through the body they look like they are floating in front of the chest when they are really behind an arm, which is the exact wrong impression for judging whether a hand is on its target. `-- shot <path> [skin] [weapon]` renders one framed close-up and quits, so the lab can be checked without a human at the controls — which is how both of those bugs were caught. Co-Authored-By: Claude Opus 5 <[email protected]>
404 lines
13 KiB
GDScript
404 lines
13 KiB
GDScript
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()
|