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
@@ -88,6 +88,13 @@ var synced_velocity: Vector3 = Vector3.ZERO
|
||||
var synced_is_ads: bool = false
|
||||
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
|
||||
var synced_is_dancing: bool = false # dance emote (B), shown on the model
|
||||
## Which of the five routines in DanceRoutines is playing. Replicated, so other
|
||||
## players see the emote that was actually chosen rather than always the first.
|
||||
var synced_dance_index: int = 0
|
||||
## The radial dial, and how long the emote button has been held. -1 means this
|
||||
## press was consumed by stopping a dance and must not open the wheel.
|
||||
var _emote_wheel: EmoteWheel = null
|
||||
var _emote_held: float = -1.0
|
||||
|
||||
# Anime speed-lines overlay (local player only)
|
||||
var _speedlines: ColorRect = null
|
||||
@@ -831,21 +838,45 @@ func _physics_process(_delta: float) -> void:
|
||||
if Input.is_action_just_pressed("toggle_camera_view"):
|
||||
set_third_person(not third_person)
|
||||
|
||||
# Dance emote (B): toggles while grounded and idle-ish; any
|
||||
# movement/jump/crouch input breaks it.
|
||||
# Emote (B): HOLD to open the radial dial and point at a dance,
|
||||
# release to commit. A tap too short to have aimed anything just
|
||||
# toggles the last one, which is exactly what the button did before
|
||||
# the wheel existed — so the old muscle memory still works.
|
||||
if Input.is_action_just_pressed("emote"):
|
||||
_emote_held = 0.0
|
||||
if synced_is_dancing:
|
||||
# Already dancing: the press stops it, and no wheel opens.
|
||||
# Having to aim at something in order to STOP is the most
|
||||
# annoying possible way to build this.
|
||||
synced_is_dancing = false
|
||||
_emote_held = -1.0
|
||||
elif _emote_wheel:
|
||||
_emote_wheel.open()
|
||||
elif Input.is_action_pressed("emote") and _emote_held >= 0.0:
|
||||
_emote_held += _delta
|
||||
elif Input.is_action_just_released("emote") and _emote_held >= 0.0:
|
||||
var aimed := _emote_wheel.close() if _emote_wheel else -1
|
||||
# A tap replays the last emote; a hold plays whatever was aimed
|
||||
# at. Either way the same grounded-and-slow gate applies.
|
||||
var pick := aimed if aimed >= 0 else synced_dance_index
|
||||
var m := _ensure_machine()
|
||||
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
|
||||
if not synced_is_dancing and m and m.current_state == "ground" and slow:
|
||||
if m and m.current_state == "ground" and slow:
|
||||
synced_dance_index = pick
|
||||
synced_is_dancing = true
|
||||
else:
|
||||
synced_is_dancing = false
|
||||
if synced_is_dancing:
|
||||
var m2 := _ensure_machine()
|
||||
var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash
|
||||
var airborne: bool = m2 and m2.current_state != "ground"
|
||||
if moving or airborne:
|
||||
synced_is_dancing = false
|
||||
# The wheel eats aiming while it is open, so the player picking an emote
|
||||
# does not also spin their character round. Movement is deliberately NOT
|
||||
# blocked — a wheel that roots you in the open is a wheel nobody uses.
|
||||
if _emote_wheel and _emote_wheel.visible and head_pivot:
|
||||
head_pivot.set_process_input(false)
|
||||
elif head_pivot and not head_pivot.is_processing_input() and not is_dead:
|
||||
head_pivot.set_process_input(true)
|
||||
|
||||
var machine := _ensure_machine()
|
||||
if machine:
|
||||
@@ -920,7 +951,7 @@ func _physics_process(_delta: float) -> void:
|
||||
if visual.has_method("set_wall_side"):
|
||||
visual.set_wall_side(sm.wall_side)
|
||||
if visual.has_method("set_dancing"):
|
||||
visual.set_dancing(synced_is_dancing)
|
||||
visual.set_dancing(synced_is_dancing, synced_dance_index)
|
||||
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
|
||||
visual.set_grapple_target(synced_grapple_point)
|
||||
|
||||
@@ -1005,7 +1036,7 @@ func _process(delta: float) -> void:
|
||||
if visual.has_method("set_wall_side"):
|
||||
visual.set_wall_side(synced_wall_side)
|
||||
if visual.has_method("set_dancing"):
|
||||
visual.set_dancing(synced_is_dancing)
|
||||
visual.set_dancing(synced_is_dancing, synced_dance_index)
|
||||
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
|
||||
visual.set_grapple_target(synced_grapple_point)
|
||||
# Upper body follows the owner's synced camera pitch
|
||||
@@ -1113,6 +1144,11 @@ func _setup_hud() -> void:
|
||||
_hud.player = self
|
||||
add_child(_hud)
|
||||
|
||||
# The emote dial rides on the HUD's canvas, above the viewmodel.
|
||||
_emote_wheel = EmoteWheel.new()
|
||||
_emote_wheel.name = "EmoteWheel"
|
||||
_hud.add_child(_emote_wheel)
|
||||
|
||||
# The controller still owns these two — it toggles the death screen on death
|
||||
# and the ring is read by the reload logic — so keep the references it had.
|
||||
death_screen = _hud.death_screen
|
||||
|
||||
Reference in New Issue
Block a user