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
@@ -0,0 +1,239 @@
|
||||
extends SkeletonModifier3D
|
||||
class_name DanceModifier
|
||||
|
||||
## Drives a dance routine onto the skeleton, over whatever clip is playing.
|
||||
##
|
||||
## Runs AFTER the shooter pose layer and blends over it, so starting a dance
|
||||
## takes the arms off the weapon smoothly rather than cutting, and stopping one
|
||||
## hands them back the same way. The spring solver runs after both, so hair and
|
||||
## cloth follow the dance without anything being asked to make that happen.
|
||||
##
|
||||
## The routine data — and the reasoning behind why the motion is built the way it
|
||||
## is — lives in characters/dance_routines.gd. This is the machine that plays it.
|
||||
|
||||
## The routine to play. See DanceRoutines.ROUTINES.
|
||||
var routine: Dictionary = {}
|
||||
## 0..1. Eased by the owner, so a dance fades in and out rather than snapping.
|
||||
var weight: float = 0.0
|
||||
## `<model>.rig.json` roles, for bone resolution. See RigRoles.
|
||||
var roles: Dictionary = {}
|
||||
|
||||
var _idx: Dictionary = {}
|
||||
var _resolved := false
|
||||
var _t: float = 0.0
|
||||
|
||||
## Per-link overlap, in seconds, from the hips outward. Index 0 is the hips.
|
||||
const CHAIN := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
|
||||
|
||||
|
||||
func _process_modification() -> void:
|
||||
var skel := get_skeleton()
|
||||
if skel == null or weight <= 0.001 or routine.is_empty():
|
||||
return
|
||||
if not _resolved:
|
||||
_idx = RigRoles.resolve(skel, roles)
|
||||
_resolved = true
|
||||
|
||||
var delta := get_physics_process_delta_time() if Engine.is_in_physics_frame() \
|
||||
else get_process_delta_time()
|
||||
_t += delta
|
||||
|
||||
var bpm: float = float(routine.get("bpm", 100.0))
|
||||
var beat := _t * bpm / 60.0
|
||||
var lag: float = float(routine.get("lag", 0.05)) * bpm / 60.0
|
||||
var w := weight
|
||||
|
||||
# ── Hips: the driver ────────────────────────────────────────────────────
|
||||
#
|
||||
# Translated, not just rotated. A body that never leaves its own axis reads
|
||||
# as a puppet on a stick; moving the hips is what makes the legs look like
|
||||
# they are carrying someone, and every other channel below is a reaction to
|
||||
# this one.
|
||||
var swing := _wave(beat, 2.0, 0.0)
|
||||
var bob := _wave(beat, float(routine.get("hip_bob_beats", 1.0)), 0.25)
|
||||
var hips: int = _idx.get("DEF-hips", -1)
|
||||
if hips >= 0:
|
||||
# Character-right is -X in skeleton space, up is +Y.
|
||||
var offset := Vector3(-swing * float(routine.get("hip_swing", 0.0)),
|
||||
bob * float(routine.get("hip_bob", 0.0)), 0.0) * w
|
||||
_offset_bone(skel, hips, offset)
|
||||
_add_space(skel, hips,
|
||||
Quaternion(Vector3(0, 0, 1), swing * float(routine.get("hip_roll", 0.0)) * w)
|
||||
* Quaternion(Vector3(0, 1, 0), swing * float(routine.get("hip_yaw", 0.0)) * w)
|
||||
* Quaternion(Vector3(1, 0, 0), bob * float(routine.get("hip_pitch", 0.0)) * w))
|
||||
|
||||
# ── Spine: the same motion, later ───────────────────────────────────────
|
||||
#
|
||||
# Each link reads the wave at `beat - lag * i`, which is the whole of
|
||||
# overlapping action. It is one subtraction and it is the difference between
|
||||
# a rig oscillating and a person moving.
|
||||
var counter: float = float(routine.get("spine_counter", 0.0))
|
||||
var links := CHAIN.slice(1)
|
||||
var n := maxf(links.size(), 1)
|
||||
for i in links.size():
|
||||
var b: int = _idx.get(links[i], -1)
|
||||
if b < 0:
|
||||
continue
|
||||
var at := beat - lag * float(i + 1)
|
||||
var s := _wave(at, 2.0, 0.0)
|
||||
var v := _wave(at, float(routine.get("hip_bob_beats", 1.0)), 0.25)
|
||||
var q := Quaternion(Vector3(0, 0, 1),
|
||||
s * float(routine.get("spine_roll", 0.0)) * w / n) \
|
||||
* Quaternion(Vector3(0, 1, 0),
|
||||
(s * float(routine.get("spine_yaw", 0.0))
|
||||
- s * float(routine.get("hip_yaw", 0.0)) * counter) * w / n) \
|
||||
* Quaternion(Vector3(1, 0, 0),
|
||||
v * float(routine.get("spine_pitch", 0.0)) * w / n)
|
||||
_add_space(skel, b, q)
|
||||
|
||||
_head(skel, beat, lag, w)
|
||||
_arms(skel, beat, lag, w)
|
||||
_legs(skel, beat, w)
|
||||
|
||||
|
||||
## Head and neck: the last link in the chain, and the one carrying the spot.
|
||||
func _head(skel: Skeleton3D, beat: float, lag: float, w: float) -> void:
|
||||
var at := beat - lag * float(CHAIN.size())
|
||||
var s := _wave(at, 2.0, 0.0)
|
||||
var v := _wave(at, float(routine.get("hip_bob_beats", 1.0)), 0.25)
|
||||
|
||||
var yaw: float = s * float(routine.get("head_yaw", 0.0))
|
||||
|
||||
# SPOTTING. A dancer turning keeps their head pointed at one place for as
|
||||
# long as they can, then whips it round to catch up. It is what stops them
|
||||
# getting dizzy, and it is the single most recognisable thing about a turn —
|
||||
# a head that simply rotates with the shoulders reads as a mannequin on a
|
||||
# turntable.
|
||||
#
|
||||
# So this is not a wave. The head COUNTERS the body's yaw exactly while the
|
||||
# hold lasts, then releases over a short window and lets the neck catch up.
|
||||
var spot: float = float(routine.get("spot", 0.0))
|
||||
if spot > 0.001:
|
||||
var body_yaw: float = s * float(routine.get("hip_yaw", 0.0)) \
|
||||
+ s * float(routine.get("spine_yaw", 0.0))
|
||||
# Where in the two-beat turn we are, 0..1.
|
||||
var u := fposmod(at / 2.0, 1.0)
|
||||
# Hold for the first 70%, then whip round over the next 20%, then arrive.
|
||||
var hold := 1.0 - smoothstep(0.70, 0.90, u)
|
||||
yaw -= body_yaw * spot * hold
|
||||
|
||||
var q := Quaternion(Vector3(0, 1, 0), yaw * w) \
|
||||
* Quaternion(Vector3(0, 0, 1), s * float(routine.get("head_roll", 0.0)) * w) \
|
||||
* Quaternion(Vector3(1, 0, 0),
|
||||
(v * float(routine.get("head_bob", 0.0))
|
||||
+ v * float(routine.get("head_pitch", 0.0))) * w)
|
||||
# Split, so the whole column leans rather than the skull hinging off a rigid
|
||||
# neck.
|
||||
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(q, 0.4))
|
||||
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(q, 0.6))
|
||||
|
||||
|
||||
## Arms: out from the body, swinging in opposition, elbows folding on the beat.
|
||||
##
|
||||
## The two arms are half a cycle apart, which is what opposition is. Both read
|
||||
## the wave later than the spine did, so the hands are the last thing to arrive —
|
||||
## the end of the chain, where overlap is most visible.
|
||||
func _arms(skel: Skeleton3D, beat: float, lag: float, w: float) -> void:
|
||||
var at := beat - lag * float(CHAIN.size() + 1)
|
||||
var beats: float = float(routine.get("arm_beats", 2.0))
|
||||
var out: float = float(routine.get("arm_out", 0.0))
|
||||
var swing: float = float(routine.get("arm_swing", 0.0))
|
||||
var elbow: float = float(routine.get("elbow", 0.0))
|
||||
|
||||
for sign_i in 2:
|
||||
var right := sign_i == 0
|
||||
var side := 1.0 if right else -1.0
|
||||
var s := _wave(at + (0.0 if right else beats * 0.5), beats, 0.0)
|
||||
var ua: int = _idx.get("DEF-upper_arm." + ("R" if right else "L"), -1)
|
||||
var fa: int = _idx.get("DEF-forearm." + ("R" if right else "L"), -1)
|
||||
if ua >= 0:
|
||||
# Character-right is -X, so a positive Z rotation lifts the LEFT arm
|
||||
# and drops the right — hence the side flip. `out` is a static lift
|
||||
# that the swing then rides on top of, which is what stops the arms
|
||||
# from passing through the body at the bottom of the stroke.
|
||||
_add_space(skel, ua,
|
||||
Quaternion(Vector3(0, 0, 1), -side * (out + s * 0.35 * swing) * w)
|
||||
* Quaternion(Vector3(1, 0, 0), s * swing * w))
|
||||
if fa >= 0:
|
||||
# The elbow only ever folds, never hyperextends: a signed wave here
|
||||
# bends the forearm backwards through the joint on half of every
|
||||
# cycle, which is the most obvious possible tell.
|
||||
var fold := (0.5 + 0.5 * s) * elbow
|
||||
_add_space(skel, fa, Quaternion(Vector3(1, 0, 0), fold * w))
|
||||
|
||||
|
||||
## Legs: knees absorbing the bob, out of phase with each other so the weight
|
||||
## visibly transfers from one to the other.
|
||||
func _legs(skel: Skeleton3D, beat: float, w: float) -> void:
|
||||
var knee: float = float(routine.get("knee", 0.0))
|
||||
if knee <= 0.001:
|
||||
return
|
||||
var beats: float = float(routine.get("hip_bob_beats", 1.0))
|
||||
for sign_i in 2:
|
||||
var right := sign_i == 0
|
||||
var s := _wave(beat + (0.0 if right else beats * 0.5), beats, 0.25)
|
||||
var thigh: int = _idx.get("DEF-thigh." + ("R" if right else "L"), -1)
|
||||
var shin: int = _idx.get("DEF-shin." + ("R" if right else "L"), -1)
|
||||
var bend := (0.5 + 0.5 * s) * knee
|
||||
# Thigh forward and shin back by twice as much, so the foot stays roughly
|
||||
# under the hip instead of the whole leg swinging out in front.
|
||||
_add_space(skel, thigh, Quaternion(Vector3(1, 0, 0), -bend * 0.5 * w))
|
||||
_add_space(skel, shin, Quaternion(Vector3(1, 0, 0), bend * w))
|
||||
|
||||
|
||||
# ── The wave ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
## One channel's value at `beat`, in -1..1.
|
||||
##
|
||||
## `shape` bends a sine so it HANGS at the extremes and SNAPS between them, which
|
||||
## is what an animator's key-and-breakdown pass produces and what a raw sine
|
||||
## cannot. At shape 1 this is exactly `sin`; above it the curve gets punchier
|
||||
## while staying continuous and staying in -1..1, so no amount of shaping can
|
||||
## make a channel overshoot its authored amplitude.
|
||||
##
|
||||
## `steps` quantises the result instead, for the robot — the one routine whose
|
||||
## whole point is that it does NOT move like the others.
|
||||
func _wave(beat: float, beats: float, phase: float) -> float:
|
||||
if beats <= 0.001:
|
||||
return 0.0
|
||||
var u := beat / beats + phase
|
||||
var steps: int = int(routine.get("steps", 0))
|
||||
if steps > 0:
|
||||
# Hold a value for a whole step, then jump. Rounded rather than floored
|
||||
# so the extremes are actually reached — a floor never returns +1.
|
||||
return sin(TAU * (round(u * float(steps)) / float(steps)))
|
||||
var s := sin(TAU * u)
|
||||
var shape: float = float(routine.get("shape", 1.0))
|
||||
if is_equal_approx(shape, 1.0):
|
||||
return s
|
||||
return signf(s) * pow(absf(s), 1.0 / shape)
|
||||
|
||||
|
||||
# ── Bone plumbing ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
## Move a bone by an offset expressed in SKELETON space.
|
||||
##
|
||||
## A bone's pose position is in its PARENT's space, so the offset has to be
|
||||
## rotated out of skeleton space by the parent's rest basis first. Skipping that
|
||||
## sends the hips sideways in whatever direction the rig happens to have called
|
||||
## "x", which differs per character.
|
||||
func _offset_bone(skel: Skeleton3D, idx: int, offset: Vector3) -> void:
|
||||
if idx < 0 or offset == Vector3.ZERO:
|
||||
return
|
||||
var parent := skel.get_bone_parent(idx)
|
||||
var local := offset
|
||||
if parent >= 0:
|
||||
local = skel.get_bone_global_rest(parent).basis.inverse() * offset
|
||||
skel.set_bone_pose_position(idx, skel.get_bone_rest(idx).origin + local)
|
||||
|
||||
|
||||
## Compose a skeleton-space rotation onto a bone's animated local pose. Same
|
||||
## contract as ShooterPoseModifier._add_space.
|
||||
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
|
||||
if idx < 0:
|
||||
return
|
||||
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
|
||||
var local := b.inverse() * q_space * b
|
||||
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bab4y7ba0yl87
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://8p4yow40ni8p
|
||||
@@ -0,0 +1,56 @@
|
||||
extends Object
|
||||
class_name RigRoles
|
||||
|
||||
## Canonical bone names -> this rig's actual bone indices.
|
||||
##
|
||||
## Pulled out of `ShooterPoseModifier._resolve` so the dance layer can use the
|
||||
## same resolution instead of carrying a second copy of it. Two copies is how a
|
||||
## rig ends up animating correctly under one modifier and not the other.
|
||||
##
|
||||
## Non-negotiable: never look a bone up by name. `tools/rig_map.py` resolves
|
||||
## every rig to ROLES and writes them to the `<model>.rig.json` sidecar, and this
|
||||
## reads that. The name fallback below exists only for a model with no sidecar,
|
||||
## and it must never be the first thing tried — four characters could not hold a
|
||||
## gun because of exactly one hardcoded spelling.
|
||||
|
||||
## The library skeleton's spine, hips first. A rig that kept its own names maps
|
||||
## onto this through the sidecar's `spine` chain.
|
||||
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
|
||||
|
||||
## Everything either pose layer asks for.
|
||||
const CANONICAL := 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"]
|
||||
|
||||
|
||||
## Resolve `names` against a skeleton, given the sidecar's role table.
|
||||
##
|
||||
## The spine needs its own handling because a rig that kept its own skeleton
|
||||
## names things differently AND has a different number of spine bones — Taila's
|
||||
## hips are `DEF-spine` and her head is `DEF-spine.006`, and she has no bone with
|
||||
## "neck" in its name at all. Unresolved, every lean and aim pitch silently did
|
||||
## nothing.
|
||||
static func resolve(skel: Skeleton3D, roles: Dictionary,
|
||||
names: Array = CANONICAL) -> Dictionary:
|
||||
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]
|
||||
|
||||
var idx := {}
|
||||
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(String(n).trim_prefix("DEF-"), n))
|
||||
var b := skel.find_bone(actual)
|
||||
if b < 0:
|
||||
b = skel.find_bone(String(n))
|
||||
idx[n] = b
|
||||
return idx
|
||||
@@ -0,0 +1 @@
|
||||
uid://coh341vhya5x5
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
extends SceneTree
|
||||
|
||||
## Do the five emotes move the character, differ from each other, and OVERLAP?
|
||||
##
|
||||
## godot --path . -s res://debug/dance_check.gd
|
||||
##
|
||||
## Three properties, and the third is the one worth checking. "It moves" and
|
||||
## "they are different" are easy to satisfy by accident — five sine waves at five
|
||||
## frequencies would pass both and would still look like programmer animation.
|
||||
## What separates a dance from an oscillation is that the body moves as a CHAIN:
|
||||
## the hips lead and the head arrives later. That is measurable, so it is.
|
||||
##
|
||||
## Sampled from inside the modifier pass, like every other pose check here.
|
||||
## Outside it Godot restores the local poses and what gets measured is the
|
||||
## animation clip alone — every routine would report identical motion whether or
|
||||
## not the dance layer exists at all.
|
||||
|
||||
const CAPTURE_BEATS := 4.0
|
||||
const SAMPLES := 90
|
||||
|
||||
## A routine has to move the character at least this far, in metres of total
|
||||
## head travel over the sample window. Below this it is not an emote.
|
||||
const MIN_TRAVEL := 0.05
|
||||
## Two routines must differ by at least this, comparing their per-frame pose
|
||||
## trajectories.
|
||||
const MIN_DISTINCT := 0.02
|
||||
|
||||
var _fails := 0
|
||||
var _probe: Probe = null
|
||||
|
||||
|
||||
class Probe extends SkeletonModifier3D:
|
||||
var pose: Array = []
|
||||
## Each bone's OWN local pose rotation, which is what a phase measurement
|
||||
## needs. A bone's GLOBAL rotation contains every ancestor's rotation too, so
|
||||
## the head's global carries the hips' un-lagged swing as a large component
|
||||
## and correlates with it at a lag of zero no matter how much the head itself
|
||||
## is delayed. Measuring globals reported Two-Step as having no overlap at
|
||||
## all when its head is delayed by five links.
|
||||
var local: Array = []
|
||||
|
||||
func _process_modification() -> void:
|
||||
var skel := get_skeleton()
|
||||
if skel == null:
|
||||
return
|
||||
pose.resize(skel.get_bone_count())
|
||||
local.resize(skel.get_bone_count())
|
||||
for i in skel.get_bone_count():
|
||||
pose[i] = skel.get_bone_global_pose(i)
|
||||
local[i] = skel.get_bone_pose_rotation(i)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
await process_frame
|
||||
|
||||
var mgr = root.get_node_or_null("SkinManager")
|
||||
var skin = mgr.get_skin("taila") if mgr else null
|
||||
if skin == null or skin.model_path == "":
|
||||
print("dance_check: no rigged skin to test with")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var model := SkinnedPlayerModel.new()
|
||||
root.add_child(model)
|
||||
model.skin_id = "taila"
|
||||
model.load_model(skin.model_path)
|
||||
for _i in 60:
|
||||
model.update_state("idle", 0.0, false)
|
||||
await process_frame
|
||||
|
||||
if model._dance_mod == null:
|
||||
_expect(false, "the model built a dance layer")
|
||||
_done()
|
||||
return
|
||||
|
||||
var skel: Skeleton3D = model.skeleton
|
||||
_probe = Probe.new()
|
||||
skel.add_child(_probe)
|
||||
skel.move_child(_probe, skel.get_child_count() - 1)
|
||||
|
||||
_expect(DanceRoutines.count() == 5,
|
||||
"there are five emotes (%d)" % DanceRoutines.count())
|
||||
|
||||
var tracks := {}
|
||||
for i in DanceRoutines.count():
|
||||
tracks[i] = await _sample(model, skel, i)
|
||||
|
||||
_report(tracks)
|
||||
_compare(tracks)
|
||||
_done()
|
||||
|
||||
|
||||
## One routine's trajectory: the hips' and head's positions, per frame, in the
|
||||
## character's own space, plus the elbow angles for the joint-limit check.
|
||||
func _sample(model, skel: Skeleton3D, index: int) -> Dictionary:
|
||||
model.set_dancing(true, index)
|
||||
# Let the blend arrive fully before recording, or the first routine sampled
|
||||
# reports smaller motion than the rest purely because it was still fading in.
|
||||
for _i in 40:
|
||||
model.update_state("idle", 0.0, false)
|
||||
await process_frame
|
||||
|
||||
var mod = model._pose_mod
|
||||
var hips: int = mod._idx.get("DEF-hips", -1)
|
||||
var head: int = mod._idx.get("DEF-head", -1)
|
||||
# Overlap is measured between two links of the SAME chain, not between the
|
||||
# hips and the head.
|
||||
#
|
||||
# Every spine link is driven by the same channels (`spine_roll`, `spine_yaw`,
|
||||
# `spine_pitch`) at `beat - lag * i`, so the only difference between them IS
|
||||
# the lag. The hips and the head are driven by DIFFERENT channels, often at
|
||||
# different periods — Two-Step's hips roll on a two-beat cycle while its head
|
||||
# bobs on a one-beat one — so correlating those two compares signals that do
|
||||
# not have a phase relationship to find.
|
||||
var link_a: int = mod._idx.get("DEF-spine.001", -1)
|
||||
var link_b: int = mod._idx.get("DEF-spine.003", -1)
|
||||
var fa_r: int = mod._idx.get("DEF-forearm.R", -1)
|
||||
var ua_r: int = mod._idx.get("DEF-upper_arm.R", -1)
|
||||
var hand_r: int = mod._idx.get("DEF-hand.R", -1)
|
||||
|
||||
var hip_track: Array = []
|
||||
var head_track: Array = []
|
||||
# The SAME quantity at two points in the chain: how far each bone has been
|
||||
# rotated away from its own rest pose, signed. Correlating the hips' world
|
||||
# TRANSLATION against the head's position RELATIVE to the hips was comparing
|
||||
# two different physical quantities driven by different channels at different
|
||||
# periods, and the peak landed anywhere — it reported the Robot, whose lag is
|
||||
# zero by construction, as the most overlapped routine in the set.
|
||||
var hip_rot: Array = []
|
||||
var head_rot: Array = []
|
||||
var worst_elbow := 180.0
|
||||
|
||||
for _i in SAMPLES:
|
||||
model.update_state("idle", 0.0, false)
|
||||
await process_frame
|
||||
var pose: Array = _probe.pose
|
||||
if pose.size() <= maxi(hips, head) or hips < 0 or head < 0:
|
||||
continue
|
||||
var origin: Vector3 = pose[hips].origin
|
||||
hip_track.append(origin)
|
||||
head_track.append(pose[head].origin - origin)
|
||||
hip_rot.append(_twist(skel, _probe.local, link_a))
|
||||
head_rot.append(_twist(skel, _probe.local, link_b))
|
||||
# The elbow must never open past straight. A signed wave on a forearm
|
||||
# bends it backwards through the joint on half of every cycle, which is
|
||||
# the single most obvious tell in procedural animation.
|
||||
if ua_r >= 0 and fa_r >= 0 and hand_r >= 0 and pose.size() > hand_r:
|
||||
var upper: Vector3 = (pose[ua_r].origin - pose[fa_r].origin).normalized()
|
||||
var lower: Vector3 = (pose[hand_r].origin - pose[fa_r].origin).normalized()
|
||||
worst_elbow = minf(worst_elbow, rad_to_deg(acos(clampf(
|
||||
upper.dot(lower), -1.0, 1.0))))
|
||||
|
||||
model.set_dancing(false)
|
||||
for _i in 30:
|
||||
model.update_state("idle", 0.0, false)
|
||||
await process_frame
|
||||
|
||||
return {"hips": hip_track, "head": head_track, "elbow": worst_elbow,
|
||||
"hip_rot": hip_rot, "head_rot": head_rot}
|
||||
|
||||
|
||||
func _report(tracks: Dictionary) -> void:
|
||||
print("\n=== EMOTES ===")
|
||||
for i in tracks:
|
||||
var t: Dictionary = tracks[i]
|
||||
var travel := _travel(t["head"])
|
||||
var hip_travel := _travel(t["hips"])
|
||||
var lag := _lag(t["hip_rot"], t["head_rot"])
|
||||
print(" %-12s head %.3f m hips %.3f m upper spine lags lower by %d frames min elbow %.0f deg"
|
||||
% [DanceRoutines.name_of(i), travel, hip_travel, lag, t["elbow"]])
|
||||
|
||||
|
||||
func _compare(tracks: Dictionary) -> void:
|
||||
for i in tracks:
|
||||
var t: Dictionary = tracks[i]
|
||||
var nm := DanceRoutines.name_of(i)
|
||||
_expect(_travel(t["head"]) >= MIN_TRAVEL,
|
||||
"%s actually moves the character (%.3f m)" % [nm, _travel(t["head"])])
|
||||
# 8 degrees of slack: the IK and the idle clip underneath both contribute,
|
||||
# and an elbow that never quite straightens is correct anyway.
|
||||
_expect(t["elbow"] >= 8.0,
|
||||
"%s never hyperextends the elbow (min %.0f deg)" % [nm, t["elbow"]])
|
||||
|
||||
var ids: Array = tracks.keys()
|
||||
for i in ids.size():
|
||||
for j in range(i + 1, ids.size()):
|
||||
var d := _difference(tracks[ids[i]]["head"], tracks[ids[j]]["head"])
|
||||
_expect(d >= MIN_DISTINCT,
|
||||
"%s and %s are different dances (%.3f)"
|
||||
% [DanceRoutines.name_of(ids[i]), DanceRoutines.name_of(ids[j]), d])
|
||||
|
||||
# OVERLAP. The head must trail the hips, because the body is a chain — this
|
||||
# is the property that separates a dance from five bones oscillating in
|
||||
# phase, and it is the whole reason `lag` exists in the routine data.
|
||||
#
|
||||
# The robot is exempt and deliberately so: its lag is zero on purpose, which
|
||||
# is what makes it read as mechanical against the other four.
|
||||
for i in tracks:
|
||||
var rid: String = String(DanceRoutines.get_routine(i).get("id", ""))
|
||||
# Robot: lag zero by construction, which is the point of it.
|
||||
# Spin: the head SPOTS — it holds its heading against the turn and whips
|
||||
# round to catch up, so it is deliberately not a delayed copy of the
|
||||
# hips. Asserting that it follows them would be asserting the opposite of
|
||||
# the technique.
|
||||
if rid == "robot" or rid == "spin":
|
||||
continue
|
||||
var lag := _lag(tracks[i]["hip_rot"], tracks[i]["head_rot"])
|
||||
_expect(lag > 0,
|
||||
"%s moves as a chain — the upper spine trails the lower by %d frames"
|
||||
% [DanceRoutines.name_of(i), lag])
|
||||
# The Robot's own property is that its motion is QUANTISED: it holds a pose
|
||||
# and jumps, where the others move continuously. That is what `steps` in the
|
||||
# routine data produces and what makes it read as mechanical against the
|
||||
# other four.
|
||||
#
|
||||
# Its LAG is deliberately not asserted. Zero lag ought to correlate perfectly
|
||||
# at shift 0, but the signal is a staircase with 16-frame plateaus, so many
|
||||
# shifts score nearly identically and the measured peak wanders — it reported
|
||||
# 21 frames. Asserting a number the measurement cannot resolve would be
|
||||
# asserting noise; the hold fraction below is the property that is actually
|
||||
# there.
|
||||
var robot := DanceRoutines.index_of("robot")
|
||||
var robot_step := _step_size(tracks[robot]["head_rot"])
|
||||
for i in tracks:
|
||||
if i == robot:
|
||||
continue
|
||||
var other := _step_size(tracks[i]["head_rot"])
|
||||
_expect(robot_step > other * 1.5,
|
||||
"Robot JUMPS between poses where %s flows (%.2f vs %.2f of range per frame)"
|
||||
% [DanceRoutines.name_of(i), robot_step, other])
|
||||
|
||||
|
||||
## The largest single-frame change, as a fraction of the track's whole range.
|
||||
##
|
||||
## This is what quantised motion looks like from the outside: long flat stretches
|
||||
## punctuated by one big jump. A smooth wave never moves more than a few percent
|
||||
## of its range in a frame however punchy its easing.
|
||||
##
|
||||
## Measured as a JUMP rather than as time-spent-still, which was the first
|
||||
## attempt and does not separate them: a shaped wave hangs at its extremes by
|
||||
## design, so Two-Step scored the same 0.97 "holding" as the Robot did. The
|
||||
## routines differ in HOW THEY LEAVE a pose, not in how long they sit in one.
|
||||
func _step_size(rot_track: Array) -> float:
|
||||
var track := _project(rot_track)
|
||||
var n := track.size()
|
||||
if n < 4:
|
||||
return 0.0
|
||||
var lo := 1e30
|
||||
var hi := -1e30
|
||||
for v in track:
|
||||
lo = minf(lo, v)
|
||||
hi = maxf(hi, v)
|
||||
var span: float = hi - lo
|
||||
if span < 0.000001:
|
||||
return 0.0
|
||||
var biggest := 0.0
|
||||
for i in range(1, n):
|
||||
biggest = maxf(biggest, absf(track[i] - track[i - 1]))
|
||||
return biggest / span
|
||||
|
||||
|
||||
## Total path length of a track.
|
||||
func _travel(track: Array) -> float:
|
||||
var sum := 0.0
|
||||
for i in range(1, track.size()):
|
||||
sum += (track[i] as Vector3).distance_to(track[i - 1])
|
||||
return sum
|
||||
|
||||
|
||||
## Mean per-frame distance between two tracks, after removing each one's own
|
||||
## average position — otherwise two identical dances at different heights would
|
||||
## read as different, and two different dances at the same height as the same.
|
||||
func _difference(a: Array, b: Array) -> float:
|
||||
var n := mini(a.size(), b.size())
|
||||
if n == 0:
|
||||
return 0.0
|
||||
var ca := Vector3.ZERO
|
||||
var cb := Vector3.ZERO
|
||||
for i in n:
|
||||
ca += a[i]
|
||||
cb += b[i]
|
||||
ca /= float(n)
|
||||
cb /= float(n)
|
||||
var sum := 0.0
|
||||
for i in n:
|
||||
sum += ((a[i] - ca) - (b[i] - cb)).length()
|
||||
return sum / float(n)
|
||||
|
||||
|
||||
## How many frames the head's rotation trails the hips', by NORMALISED
|
||||
## cross-correlation.
|
||||
##
|
||||
## Both signals are the same quantity — a bone's rotation away from its own rest
|
||||
## pose — sampled at two ends of the same chain, so the only thing that can
|
||||
## differ between them is timing. That is the whole point: an unnormalised
|
||||
## correlation between two DIFFERENT quantities peaks wherever their amplitudes
|
||||
## happen to line up, which reported the Robot (lag zero by construction) as the
|
||||
## most overlapped routine in the set.
|
||||
##
|
||||
## Pearson, so amplitude cannot influence where the peak falls — a head that
|
||||
## moves further than the hips must not read as a head that moves later.
|
||||
func _lag(hips: Array, head: Array) -> int:
|
||||
# BOTH ends projected onto the HIPS' axis, not each onto its own.
|
||||
#
|
||||
# Overlap is "the same motion, later", so the measurement has to be of the
|
||||
# same motion. Projecting each end onto its own dominant axis compares
|
||||
# whatever channel happens to dominate at that end, and routines drive
|
||||
# different channels at the two ends: Two-Step's hips are dominated by a
|
||||
# two-beat roll while its head is dominated by a one-beat bob, so the
|
||||
# correlation was between signals of different PERIOD and peaked wherever.
|
||||
var axis := _dominant_axis(hips)
|
||||
var a := _centre(_project(hips, axis))
|
||||
var b := _centre(_project(head, axis))
|
||||
var n := mini(a.size(), b.size())
|
||||
if n < 16:
|
||||
return 0
|
||||
var best := 0
|
||||
var best_score := -1e30
|
||||
# Out to half the window. The correlation of a periodic signal repeats every
|
||||
# period, so the search must stay inside one; Body Wave has the largest lag
|
||||
# in the set by design (0.13 s per link over five links, most of a beat at
|
||||
# 88 bpm) and a short window could not see it at all.
|
||||
for shift in range(0, n / 2):
|
||||
var sum := 0.0
|
||||
var na := 0.0
|
||||
var nb := 0.0
|
||||
for i in range(0, n - shift):
|
||||
sum += a[i] * b[i + shift]
|
||||
na += a[i] * a[i]
|
||||
nb += b[i + shift] * b[i + shift]
|
||||
if na < 0.000001 or nb < 0.000001:
|
||||
continue
|
||||
var score: float = sum / sqrt(na * nb)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = shift
|
||||
return best
|
||||
|
||||
|
||||
## Mean-removed copy of a scalar track.
|
||||
func _centre(track: Array) -> Array:
|
||||
var n := track.size()
|
||||
if n == 0:
|
||||
return []
|
||||
var mean := 0.0
|
||||
for v in track:
|
||||
mean += v
|
||||
mean /= float(n)
|
||||
var out: Array = []
|
||||
for v in track:
|
||||
out.append(v - mean)
|
||||
return out
|
||||
|
||||
|
||||
## How far a bone has been rotated away from its rest pose, as a ROTATION VECTOR
|
||||
## (axis times angle).
|
||||
##
|
||||
## A vector, not a signed scalar. The first version returned `angle * sign of the
|
||||
## axis's largest component`, and that is discontinuous: as a rocking bone passes
|
||||
## back through its rest pose the angle goes to zero and the axis FLIPS, so the
|
||||
## signal jumped the full width of its range in a single frame. Two-Step measured
|
||||
## a per-frame step of 0.99 of its own range — which looked exactly like the
|
||||
## quantised motion the Robot is supposed to have exclusively, on a routine that
|
||||
## is perfectly smooth.
|
||||
##
|
||||
## The rotation vector passes through zero and comes out the other side pointing
|
||||
## the opposite way, which is continuous, and projecting it onto a fixed axis
|
||||
## afterwards gives the signed wave the analysis actually wants.
|
||||
func _twist(skel: Skeleton3D, local: Array, idx: int) -> Vector3:
|
||||
if idx < 0 or idx >= local.size():
|
||||
return Vector3.ZERO
|
||||
# The bone's OWN rotation away from its rest — not its global, which carries
|
||||
# every ancestor's along with it. See Probe.local.
|
||||
var rest: Quaternion = skel.get_bone_rest(idx).basis.get_rotation_quaternion()
|
||||
var d := (rest.inverse() * (local[idx] as Quaternion)).normalized()
|
||||
# Shortest arc, so a rotation just past 180 degrees does not read as one just
|
||||
# under -180.
|
||||
if d.w < 0.0:
|
||||
d = Quaternion(-d.x, -d.y, -d.z, -d.w)
|
||||
var ang := d.get_angle()
|
||||
if ang < 0.000001:
|
||||
return Vector3.ZERO
|
||||
return d.get_axis() * ang
|
||||
|
||||
|
||||
## The axis a track of rotation vectors varies most about.
|
||||
func _dominant_axis(track: Array) -> Vector3:
|
||||
var n := track.size()
|
||||
if n == 0:
|
||||
return Vector3.ZERO
|
||||
var mean := Vector3.ZERO
|
||||
for v in track:
|
||||
mean += v
|
||||
mean /= float(n)
|
||||
var axis := Vector3.ZERO
|
||||
var best := 0.0
|
||||
for v in track:
|
||||
var d: Vector3 = v - mean
|
||||
if d.length() > best:
|
||||
best = d.length()
|
||||
axis = d
|
||||
return axis.normalized() if axis.length() > 0.000001 else Vector3.ZERO
|
||||
|
||||
|
||||
## A track of rotation vectors flattened to one signed scalar per frame, along
|
||||
## `axis` — or along the track's own dominant axis if none is given.
|
||||
func _project(track: Array, axis: Vector3 = Vector3.ZERO) -> Array:
|
||||
var n := track.size()
|
||||
if n == 0:
|
||||
return []
|
||||
var use := axis if axis.length() > 0.000001 else _dominant_axis(track)
|
||||
if use.length() < 0.000001:
|
||||
return []
|
||||
var mean := Vector3.ZERO
|
||||
for v in track:
|
||||
mean += v
|
||||
mean /= float(n)
|
||||
var out: Array = []
|
||||
for v in track:
|
||||
out.append((v - mean).dot(use))
|
||||
return out
|
||||
|
||||
|
||||
func _expect(ok: bool, what: String) -> void:
|
||||
if ok:
|
||||
print(" OK: ", what)
|
||||
else:
|
||||
print(" FAIL: ", what)
|
||||
_fails += 1
|
||||
|
||||
|
||||
func _done() -> void:
|
||||
print("\n=== DANCE SUMMARY ===")
|
||||
print("Failures: %d" % _fails)
|
||||
quit(1 if _fails > 0 else 0)
|
||||
@@ -0,0 +1,54 @@
|
||||
extends SceneTree
|
||||
|
||||
## Photograph the radial emote dial, resting and with a wedge aimed at.
|
||||
##
|
||||
## godot --path . --windowed --resolution 1280x720 \
|
||||
## -s res://debug/emote_wheel_capture.gd -- <out_dir>
|
||||
##
|
||||
## Two shots, because the hover state is the one that goes wrong: a wedge whose
|
||||
## fill changes without its label changing with it is the exact failure the
|
||||
## theme's contrast work exists to prevent, and it is invisible in a shot of the
|
||||
## wheel at rest.
|
||||
|
||||
var _out := "."
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
if args.size() > 0:
|
||||
_out = String(args[0])
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
UITheme.apply_global(self)
|
||||
|
||||
var layer := CanvasLayer.new()
|
||||
root.add_child(layer)
|
||||
|
||||
# Something behind it, so the ink edges are being judged against a real
|
||||
# backdrop rather than against black.
|
||||
var back := ColorRect.new()
|
||||
back.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
back.color = Color(0.42, 0.55, 0.72)
|
||||
layer.add_child(back)
|
||||
|
||||
var wheel := EmoteWheel.new()
|
||||
layer.add_child(wheel)
|
||||
wheel.open()
|
||||
for _i in 30:
|
||||
await process_frame
|
||||
await process_frame
|
||||
root.get_texture().get_image().save_png("%s/emote_wheel_rest.png" % _out)
|
||||
print("emote_wheel_capture: saved rest")
|
||||
|
||||
# Aim up-and-right, which lands on the second wedge.
|
||||
wheel.aim_by_vector(Vector2(0.7, -0.7))
|
||||
for _i in 12:
|
||||
await process_frame
|
||||
await process_frame
|
||||
root.get_texture().get_image().save_png("%s/emote_wheel_hover.png" % _out)
|
||||
print("emote_wheel_capture: saved hover, picked=%d" % wheel._hover)
|
||||
|
||||
quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c25wpt58v328r
|
||||
@@ -274,6 +274,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_is_dancing")
|
||||
# Which of the five emotes, so other players see the one that was chosen.
|
||||
client_rep_config.add_property(":synced_dance_index")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,6 +113,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_is_dancing")
|
||||
# Which of the five emotes, so other players see the one that was chosen.
|
||||
client_rep_config.add_property(":synced_dance_index")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
@@ -82,6 +82,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_rep_config.add_property(":synced_is_ads")
|
||||
client_rep_config.add_property(":synced_wall_side")
|
||||
client_rep_config.add_property(":synced_is_dancing")
|
||||
# Which of the five emotes, so other players see the one that was chosen.
|
||||
client_rep_config.add_property(":synced_dance_index")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnxnbswrl04u8
|
||||
Reference in New Issue
Block a user