feat(emotes): five dances, built like animation, behind a radial dial

The shared clip library ships exactly one `Dance_Loop`, and five copies of one
clip is not five dances. What the runtime does have is a procedural pose layer
over a real skeleton with spring-driven hair and cloth, which is enough — if
the motion is constructed the way an animator would construct it rather than
the way a programmer reaches for first.

Wiring sine waves to bones is that first reach, and everyone can tell. A raw
sine moves fastest through the middle and slowest at the ends by the same
amount on every channel, all in phase, forever. It floats. It has no weight, no
accent, and no sense that one part of the body is driving and the rest is
following. Four principles fix it, and all four are cheap:

  OVERLAP        the body is a chain. Hips lead, spine follows a beat later,
                 head last. One subtraction — `beat - lag * i` — and the spring
                 solver then carries it out through the hair and skirt for free,
                 because the dance layer runs before it.
  ACCENT         a dance HITS poses. `shape` bends the wave so it hangs at the
                 extremes and snaps between them, which is what a key-and-
                 breakdown pass produces by hand.
  WEIGHT         the HIPS translate, not just rotate. A body that never leaves
                 its own axis reads as a puppet on a stick.
  CONTRAST       Robot deliberately breaks all of the above — zero lag,
                 quantised motion — and reads as mechanical precisely because
                 the other four do not.

Spin spots its head: it holds a heading against the turn and whips round to
catch up, which is what a real dancer does to keep from getting dizzy and the
most recognisable thing about a turn.

The dial is a radial menu because every option is then the SAME DISTANCE from
where the pointer starts — the choice is a direction, and a direction becomes
muscle memory in a way "the fourth row down" does not. Selection is by ANGLE
alone, so a flick and a careful nudge do the same thing. HOLD to open, release
to commit; a tap too short to have aimed replays the last emote, which is what
the button did before, so the old habit still works. Pressing while already
dancing just stops — having to aim at something in order to STOP would be the
most annoying possible way to build this.

debug/dance_check.gd asserts the overlap, and getting it to measure that took
four wrong measurements, each of which is now a comment where it was made:

  - correlating the hips' TRANSLATION against the head's position relative to
    them compared two different quantities at different periods; it ranked the
    Robot, whose lag is zero by construction, as the most overlapped routine.
  - a signed scalar `angle * sign of the axis's largest component` is
    DISCONTINUOUS — as a rocking bone passes back through rest the axis flips —
    so smooth Two-Step measured a full-range jump per frame, which is exactly
    what quantised motion looks like.
  - a bone's GLOBAL rotation carries every ancestor's, so the head correlates
    with the hips at lag zero however delayed the head itself is.
  - and the hips and head are driven by different channels anyway.

Measuring two links of the SAME chain, as local rotation vectors, agrees with
the authored lag: Spin measures 9 frames against 8.4 authored, Two-Step 7
against 6.6, Robot 0. The Robot is checked on the property it actually has —
its jump per frame is 0.41 of its range against 0.03-0.06 for the others.

RigRoles is pulled out of ShooterPoseModifier so the dance layer resolves bones
the same way rather than carrying a second copy. Two copies is how a rig ends up
animating correctly under one modifier and not the other.

spawn smoke 0 failures, 11/11 movement, 21/21 weapon-hold pairs, contrast 108/108.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 12:20:33 -04:00
co-authored by Claude Opus 5
parent a13ae50f95
commit f1a4f7df52
16 changed files with 1302 additions and 37 deletions
+204
View File
@@ -0,0 +1,204 @@
extends Object
class_name DanceRoutines
## The five emotes, as data.
##
## They are procedural rather than authored clips because the shared animation
## library ships exactly one `Dance_Loop`, and five copies of one clip is not
## five dances. What the runtime DOES have is a working procedural pose layer
## over a real skeleton with spring-driven hair and cloth, which is enough to
## build a dance out of if the motion is constructed the way an animator would
## construct it rather than the way a programmer reaches for first.
##
## ── Why not just wire sine waves to the bones ───────────────────────────────
##
## Because that is what "programmer animation" looks like, and everyone can tell.
## A raw sine moves fastest through the middle and slowest at the ends by exactly
## the same amount on every channel, all in phase, forever. The result floats. It
## has no weight, no accent, and no sense that one part of the body is driving and
## the rest is following.
##
## Four principles fix that, and all four are cheap:
##
## OVERLAP the body is a chain, and a chain does not move as one piece.
## Hips lead, spine follows a beat later, chest later still,
## head last. `lag` below is that, in seconds per link. It is
## the single largest difference between "a rig oscillating"
## and "a person moving", and the spring bones then carry it
## out through the hair and the skirt for free.
## ACCENT a dance HITS poses. `shape` bends the wave so it hangs at
## the extremes and snaps between them — the same asymmetry a
## key-and-breakdown pass produces by hand. `sin` is shape 1.0;
## above that it gets punchier.
## WEIGHT a body that never leaves its own axis reads as a puppet. Real
## dances move the HIPS — side to side, up and down — and the
## rest of the body reacts. `hip_swing` and `hip_bob` are
## translations, not rotations, and they are what make the legs
## look like they are carrying someone.
## ANTICIPATION the counter-move before the move. Handled per routine by
## running a channel at a fraction of a beat AHEAD of the one it
## precedes, rather than by a separate mechanism.
##
## ── The shape of a routine ──────────────────────────────────────────────────
##
## Everything is derived from one `bpm`, so no two channels can drift apart no
## matter how long the emote runs — which is the other thing that goes wrong when
## channels are given independent frequencies that are not exact ratios.
##
## Channel amounts are radians (rotations) or metres (the two hip translations).
## `beats` is how many beats that channel takes for one full cycle, so 2 is a
## side-to-side that takes two beats to return, 1 is once per beat, and 0.5 is
## twice per beat. Fractions of a beat are how a routine gets a cross-rhythm
## without leaving the grid.
## `id` is what gets networked, so these strings must stay stable.
const ROUTINES := [
{
"id": "two_step",
"name": "Two-Step",
"icon": "",
"bpm": 104.0,
"lag": 0.055,
"shape": 1.35,
# The foundation step: weight rocks side to side, the shoulders counter
# the hips, the arms hang and swing off the shoulders a beat behind.
"hip_swing": 0.075,
"hip_bob": 0.022,
"hip_bob_beats": 1.0,
"hip_roll": 0.16,
"hip_yaw": 0.20,
"spine_roll": 0.13,
"spine_counter": 0.55,
"head_roll": 0.22,
"head_bob": 0.10,
"arm_swing": 0.55,
"arm_out": 0.42,
"arm_beats": 2.0,
"elbow": 0.75,
"knee": 0.30,
},
{
"id": "body_wave",
"name": "Body Wave",
"icon": "",
"bpm": 88.0,
# The whole point of this one is the lag: a wave travelling up the spine
# IS overlap, made visible. At 0.13 s per link the crest takes most of a
# beat to get from the hips to the head.
"lag": 0.13,
"shape": 1.15,
"hip_swing": 0.03,
"hip_bob": 0.045,
"hip_bob_beats": 2.0,
"hip_pitch": 0.26,
"spine_pitch": 0.30,
"spine_counter": 0.0,
"head_pitch": 0.22,
"head_roll": 0.06,
"arm_out": 0.85,
"arm_swing": 0.20,
"arm_beats": 4.0,
"elbow": 0.55,
"knee": 0.18,
},
{
"id": "robot",
"name": "Robot",
"icon": "",
"bpm": 112.0,
# No lag, and the motion is QUANTISED — see `steps`. Both are deliberate
# violations of everything above, and they work for exactly that reason:
# the robot reads as mechanical because the viewer has been shown four
# other routines that do not.
"lag": 0.0,
"shape": 1.0,
"steps": 4,
"hip_swing": 0.04,
"hip_bob": 0.012,
"hip_bob_beats": 1.0,
"hip_yaw": 0.30,
"spine_yaw": 0.34,
"spine_counter": 0.0,
"head_yaw": 0.42,
"arm_out": 1.05,
"arm_swing": 0.85,
"arm_beats": 2.0,
"elbow": 1.35,
"knee": 0.10,
},
{
"id": "bounce",
"name": "Bounce",
"icon": "",
"bpm": 128.0,
"lag": 0.035,
# The punchiest shape in the set. A bounce lives entirely in the accent:
# the body hangs at the top and slams through the bottom, which is a
# gravity read, and a plain sine cannot express it.
"shape": 2.2,
"hip_swing": 0.03,
"hip_bob": 0.070,
"hip_bob_beats": 1.0,
"hip_roll": 0.08,
"spine_pitch": 0.14,
"spine_counter": 0.30,
"head_bob": 0.16,
"head_roll": 0.10,
"arm_swing": 0.95,
"arm_out": 0.30,
"arm_beats": 1.0,
"elbow": 1.05,
# Deep knees. This is the routine where the legs do the work, and a bounce
# with straight legs looks like a character being shaken.
"knee": 0.85,
},
{
"id": "spin",
"name": "Spin",
"icon": "",
"bpm": 96.0,
"lag": 0.07,
"shape": 1.5,
"hip_swing": 0.05,
"hip_bob": 0.030,
"hip_bob_beats": 2.0,
"hip_yaw": 0.85,
"spine_yaw": 0.30,
"spine_counter": 0.0,
"spine_roll": 0.14,
# SPOTTING: the head holds its heading while the body turns under it, then
# whips round to catch up. It is what a real dancer does to keep from
# getting dizzy, and it is the most recognisable thing about a turn. See
# `spot` in DanceModifier — this is not a wave, it is a hold and a snap.
"spot": 1.0,
"head_yaw": 0.0,
"arm_out": 1.15,
"arm_swing": 0.25,
"arm_beats": 4.0,
"elbow": 0.35,
"knee": 0.22,
},
]
static func count() -> int:
return ROUTINES.size()
## A routine by index, wrapped so an out-of-range network value cannot crash a
## remote peer's model.
static func get_routine(index: int) -> Dictionary:
if ROUTINES.is_empty():
return {}
return ROUTINES[posmod(index, ROUTINES.size())]
static func name_of(index: int) -> String:
return String(get_routine(index).get("name", ""))
static func index_of(id: String) -> int:
for i in ROUTINES.size():
if ROUTINES[i]["id"] == id:
return i
return 0