Files
Papay-Shooter/debug/rig_lab.gd
T
Nicholas ButzkeandClaude Opus 5 08ae85b286 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]>
2026-07-27 14:56:03 -04:00

614 lines
21 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()