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
+215
View File
@@ -0,0 +1,215 @@
extends Control
class_name EmoteWheel
## The radial emote dial: hold the emote button, point, release.
##
## A radial menu is the right shape for this and a list is not, for one reason:
## every option is the SAME DISTANCE from where the pointer starts. There is no
## scanning and no travel budget — the choice is a direction, and a direction can
## be learned as muscle memory in a way that "the fourth row down" cannot. After
## a few uses the player stops reading the wheel and just flicks.
##
## Which is why the selection is by ANGLE ALONE and not by distance. Pointing
## anywhere in a wedge selects it, however far out the cursor is, so a fast flick
## and a careful nudge do the same thing. A dead zone in the middle is the only
## exception, and it exists so releasing without moving cancels rather than
## picking whatever happened to be under the cursor at rest.
##
## Opened by HOLDING the button rather than toggled by tapping it, because an
## emote is a thing you do in a lull and a menu you have to close again is a
## thing that gets you killed. Release commits. A tap too short to have aimed
## replays the last emote instead, which is what the button did before the wheel
## existed.
signal picked(index: int)
signal cancelled
## The wheel is drawn at this radius, and a wedge is selected by pointing at it
## from further out than the dead zone.
const RADIUS := 190.0
const INNER := 76.0
const DEAD_ZONE := 46.0
const LABEL_R := 250.0
## How long the open/close ease takes. Short — this is a fast interaction and a
## slow bloom would defeat the point of it.
const OPEN_TIME := 0.11
var _routines: Array = []
var _hover: int = -1
var _open: float = 0.0
var _target_open: float = 0.0
## The direction the pointer has travelled from the wheel's centre since it
## opened. Accumulated from relative mouse motion rather than read from the
## cursor position, because the game captures the mouse and the OS cursor does
## not move.
var _aim: Vector2 = Vector2.ZERO
func _ready() -> void:
_routines = DanceRoutines.ROUTINES
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
set_process(false)
set_process_input(false)
## Show the wheel and start tracking the pointer.
func open() -> void:
_aim = Vector2.ZERO
_hover = -1
_target_open = 1.0
visible = true
set_process(true)
set_process_input(true)
queue_redraw()
## Hide it, and report what was pointed at. Returns the index, or -1 for a
## cancel (nothing aimed at, or the pointer never left the dead zone).
func close() -> int:
_target_open = 0.0
set_process_input(false)
var chosen := _hover
if chosen >= 0:
picked.emit(chosen)
else:
cancelled.emit()
_hover = -1
queue_redraw()
return chosen
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
# Relative motion, because the game holds the mouse captured — the
# cursor's absolute position never changes and reading it would leave
# the wheel permanently pointing at nothing.
_aim += (event as InputEventMouseMotion).relative
_update_hover()
## Also drivable from a stick or the keyboard, for a controller or for a player
## who would rather not move the mouse. Same wedge maths, different source.
func aim_by_vector(v: Vector2) -> void:
_aim = v * (DEAD_ZONE + 1.0) if v.length() > 0.01 else Vector2.ZERO
_update_hover()
func _update_hover() -> void:
var was := _hover
if _aim.length() < DEAD_ZONE or _routines.is_empty():
_hover = -1
else:
# Angle from straight UP, clockwise, so the first emote is at twelve
# o'clock — the position a player will reach for without thinking.
var a := fposmod(atan2(_aim.x, -_aim.y), TAU)
var step := TAU / float(_routines.size())
_hover = int(floor((a + step * 0.5) / step)) % _routines.size()
if _hover != was:
queue_redraw()
var am := get_tree().root.get_node_or_null("AudioManager")
if am and _hover >= 0:
am.play_ui("ui_hover")
func _process(delta: float) -> void:
var t := 1.0 - exp(-delta / maxf(OPEN_TIME, 0.001))
_open = lerpf(_open, _target_open, t)
if _target_open <= 0.0 and _open < 0.01:
_open = 0.0
visible = false
set_process(false)
queue_redraw()
func _draw() -> void:
if _open < 0.01 or _routines.is_empty():
return
var mid := size * 0.5
# Scale up from 88% as it opens, and fade in. Small, because the wheel has to
# be usable the instant it appears — an animation the player has to wait out
# is an animation that makes the feature feel slower than the old toggle.
var k: float = lerpf(0.88, 1.0, _open)
var a: float = _open
var count := _routines.size()
var step := TAU / float(count)
# A scrim, so the wheel reads over a bright skybox without needing a heavier
# outline than everything else in the UI uses.
draw_circle(mid, RADIUS * k * 1.06,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.55 * a))
for i in count:
var mid_angle := step * float(i)
var from := mid_angle - step * 0.5
var hovered := i == _hover
# Fill follows the state, and the label follows the fill — the theme's
# one rule for staying readable. A hovered wedge is papaya and takes ink
# glyphs; a resting one is near-black and takes paper.
var fill: Color = UITheme.PAPAYA if hovered else UITheme.INK_SOFT
_wedge(mid, from, step, INNER * k, RADIUS * k,
Color(fill.r, fill.g, fill.b, (0.95 if hovered else 0.82) * a))
var dir := Vector2(sin(mid_angle), -cos(mid_angle))
var r := (INNER + RADIUS) * 0.5 * k
_glyph(mid + dir * r, String(_routines[i].get("icon", "*")), 40,
UITheme.ink_for(fill) if hovered else UITheme.PAPER, a)
# The name sits OUTSIDE the ring rather than inside the wedge, so a long
# one is never clipped by its own slice and the type size does not have
# to shrink as emotes are added.
_glyph(mid + dir * (LABEL_R * k), String(_routines[i].get("name", "")), 22,
UITheme.VOLT if hovered else UITheme.PAPER, a)
# The hub. Volt while a wedge is aimed at, so committing is confirmed before
# the button is released rather than after.
var hub: Color = UITheme.VOLT if _hover >= 0 else UITheme.INK
draw_circle(mid, INNER * k * 0.42,
Color(hub.r, hub.g, hub.b, (0.9 if _hover >= 0 else 0.75) * a))
draw_arc(mid, INNER * k * 0.42, 0, TAU, 40,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, a), 3.0, true)
if _hover < 0:
_glyph(mid, "RELEASE TO CANCEL", 16, UITheme.PAPER_DIM, a * 0.9)
## One slice of the ring, as a triangle strip between the inner and outer radii.
func _wedge(mid: Vector2, from: float, span: float, r0: float, r1: float,
col: Color) -> void:
var segs := 14
var pts := PackedVector2Array()
for i in segs + 1:
var ang := from + span * (float(i) / float(segs))
# A one-degree gap either side, so adjacent wedges read as separate
# choices rather than as a solid ring with colour changes in it.
ang = from + deg_to_rad(1.2) + (span - deg_to_rad(2.4)) * (float(i) / float(segs))
var d := Vector2(sin(ang), -cos(ang))
pts.append(mid + d * r0)
pts.append(mid + d * r1)
# Build an outline path around the strip so the ink edge can be stroked.
var outer := PackedVector2Array()
var inner := PackedVector2Array()
for i in range(0, pts.size(), 2):
inner.append(pts[i])
outer.append(pts[i + 1])
var poly := PackedVector2Array()
poly.append_array(outer)
inner.reverse()
poly.append_array(inner)
draw_colored_polygon(poly, col)
poly.append(poly[0])
draw_polyline(poly, Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, col.a),
3.0)
## Centred text with the theme's ink outline under it.
func _glyph(at: Vector2, text: String, fs: int, col: Color, alpha: float) -> void:
var font := get_theme_default_font()
if font == null or text == "":
return
var w := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
var pos := at - Vector2(w.x * 0.5, -w.y * 0.32)
draw_string_outline(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, 6,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, alpha))
draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
Color(col.r, col.g, col.b, alpha))
+1
View File
@@ -0,0 +1 @@
uid://dnxnbswrl04u8