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:
co-authored by
Claude Opus 5
parent
a13ae50f95
commit
f1a4f7df52
@@ -96,6 +96,8 @@ var _rig_info: Dictionary = {}
|
||||
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
|
||||
var _surfaces: SkinSurfaces = null
|
||||
var _spring_mod: SpringBones
|
||||
## The emote layer, between the shooter pose and the cloth springs.
|
||||
var _dance_mod: DanceModifier
|
||||
var is_holding_weapon: bool = false
|
||||
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
|
||||
## by `_process` (a blade releases the off arm) and by the checks.
|
||||
@@ -242,8 +244,17 @@ func load_model(path: String) -> void:
|
||||
_pose_mod.fingers = _rig_info.get("fingers", {})
|
||||
_pose_mod.name = "ShooterPose"
|
||||
skeleton.add_child(_pose_mod)
|
||||
# Dance AFTER the shooter pose, so an emote blends over the weapon hold
|
||||
# instead of fighting it — the arms come off the gun as the dance weight
|
||||
# rises and are handed back the same way.
|
||||
_dance_mod = DanceModifier.new()
|
||||
_dance_mod.name = "Dance"
|
||||
_dance_mod.roles = _rig_info.get("roles", {})
|
||||
skeleton.add_child(_dance_mod)
|
||||
# Cloth and hair last, so the springs react to the FINAL body pose —
|
||||
# animation plus the shooter lean/slide layer.
|
||||
# animation plus the shooter lean/slide layer, plus any dance. Hair and
|
||||
# a skirt following a dance is entirely this ordering; nothing else is
|
||||
# needed to make it happen.
|
||||
if not _rig_info.is_empty():
|
||||
_spring_mod = SpringBones.new()
|
||||
_spring_mod.name = "SpringBones"
|
||||
@@ -553,6 +564,14 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
|
||||
var _prev_state: String = ""
|
||||
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
|
||||
var _dancing: bool = false
|
||||
## Which of DanceRoutines.ROUTINES is playing, and the eased 0..1 blend of the
|
||||
## dance layer over everything below it.
|
||||
var _dance_index: int = 0
|
||||
var _cur_dance: float = 0.0
|
||||
## How fast a dance takes the body and gives it back. Slower than the pose
|
||||
## layer's other blends on purpose: an emote starting is not a reaction, and
|
||||
## snapping into one looks like a bug rather than like a decision.
|
||||
const DANCE_SMOOTH := 5.0
|
||||
|
||||
|
||||
## Play a one-shot clip over locomotion for `lock_time` seconds.
|
||||
@@ -577,8 +596,16 @@ func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
|
||||
|
||||
## Emote toggle (Dance). Shown while grounded and near-idle; any real
|
||||
## movement breaks it (the controller clears the flag too).
|
||||
func set_dancing(on: bool) -> void:
|
||||
##
|
||||
## `which` selects one of the five routines in DanceRoutines. The base clip stays
|
||||
## the library's single `Dance_Loop` underneath — the routine is layered over it
|
||||
## by DanceModifier, which is what makes five distinct emotes out of one clip.
|
||||
func set_dancing(on: bool, which: int = -1) -> void:
|
||||
_dancing = on
|
||||
if which >= 0:
|
||||
_dance_index = which
|
||||
if _dance_mod:
|
||||
_dance_mod.routine = DanceRoutines.get_routine(_dance_index)
|
||||
|
||||
|
||||
## Play a named gameplay action (reload / throw / shoot) as a one-shot.
|
||||
@@ -734,6 +761,12 @@ func _process(delta: float) -> void:
|
||||
_cur_slide = lerpf(_cur_slide, slide_target, t)
|
||||
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
||||
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
|
||||
# The dance blend. Eased both ways, so an emote arrives and leaves rather
|
||||
# than cutting — and so the arms come off the weapon smoothly.
|
||||
if _dance_mod:
|
||||
_cur_dance = lerpf(_cur_dance, 1.0 if _dancing else 0.0,
|
||||
1.0 - exp(-DANCE_SMOOTH * delta))
|
||||
_dance_mod.weight = _cur_dance
|
||||
_update_travel(delta, drive)
|
||||
_pose_mod.strafe = _cur_strafe
|
||||
_pose_mod.fwd = _cur_fwd
|
||||
@@ -1352,7 +1385,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
const WALL_ARM_OUT := 0.9 # inner arm reaches out to touch the wall
|
||||
const HOLD_SMOOTH := 8.0 # how fast the hold takes/releases the arms
|
||||
|
||||
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
|
||||
## The library skeleton's spine, hips first. Shared with the dance layer via
|
||||
## RigRoles, which also owns the mapping onto a rig that kept its own names.
|
||||
const SPINE := RigRoles.SPINE
|
||||
|
||||
var _idx: Dictionary = {}
|
||||
var _resolved := false
|
||||
@@ -1428,33 +1463,13 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
|
||||
func _resolve() -> void:
|
||||
var skel := get_skeleton()
|
||||
var names := SPINE + ["DEF-neck", "DEF-head",
|
||||
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
|
||||
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
|
||||
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
|
||||
# The names above are the LIBRARY skeleton's. A model that kept its own
|
||||
# rig names things differently and three of them simply do not exist on
|
||||
# it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has
|
||||
# no bone with "neck" in its name at all. Unresolved, every lean, aim
|
||||
# pitch and slide head-lift below silently did nothing.
|
||||
var alias := {}
|
||||
if not roles.is_empty():
|
||||
var neck: String = roles.get("neck", "")
|
||||
var head: String = roles.get("head", "")
|
||||
var torso: Array = []
|
||||
for n in roles.get("spine", []):
|
||||
if n != neck and n != head:
|
||||
torso.append(n)
|
||||
for i in mini(torso.size(), SPINE.size() - 1):
|
||||
alias[SPINE[i + 1]] = torso[i]
|
||||
for n in names:
|
||||
# Canonical names are the role keys with the DEF- prefix, so the
|
||||
# limbs, hips, neck and head all map straight through.
|
||||
var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n))
|
||||
var b := skel.find_bone(actual)
|
||||
if b < 0:
|
||||
b = skel.find_bone(n)
|
||||
_idx[n] = b
|
||||
# The canonical names are the LIBRARY skeleton's, and a model that kept
|
||||
# its own rig names things differently — Taila's hips are DEF-spine, her
|
||||
# head is DEF-spine.006, and she has no bone with "neck" in its name at
|
||||
# all. Unresolved, every lean, aim pitch and slide head-lift below
|
||||
# silently did nothing. RigRoles maps them through the sidecar; the dance
|
||||
# layer uses the same call rather than a second copy of it.
|
||||
_idx = RigRoles.resolve(skel, roles)
|
||||
_resolve_hands(skel)
|
||||
_resolved = true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user