Feat/fidelity pass 2 #23
@@ -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()`.
|
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
|
||||||
var _surfaces: SkinSurfaces = null
|
var _surfaces: SkinSurfaces = null
|
||||||
var _spring_mod: SpringBones
|
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
|
var is_holding_weapon: bool = false
|
||||||
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
|
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
|
||||||
## by `_process` (a blade releases the off arm) and by the checks.
|
## 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.fingers = _rig_info.get("fingers", {})
|
||||||
_pose_mod.name = "ShooterPose"
|
_pose_mod.name = "ShooterPose"
|
||||||
skeleton.add_child(_pose_mod)
|
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 —
|
# 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():
|
if not _rig_info.is_empty():
|
||||||
_spring_mod = SpringBones.new()
|
_spring_mod = SpringBones.new()
|
||||||
_spring_mod.name = "SpringBones"
|
_spring_mod.name = "SpringBones"
|
||||||
@@ -553,6 +564,14 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
|
|||||||
var _prev_state: String = ""
|
var _prev_state: String = ""
|
||||||
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
|
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
|
||||||
var _dancing: bool = false
|
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.
|
## 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
|
## Emote toggle (Dance). Shown while grounded and near-idle; any real
|
||||||
## movement breaks it (the controller clears the flag too).
|
## 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
|
_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.
|
## 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)
|
_cur_slide = lerpf(_cur_slide, slide_target, t)
|
||||||
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
||||||
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
|
_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)
|
_update_travel(delta, drive)
|
||||||
_pose_mod.strafe = _cur_strafe
|
_pose_mod.strafe = _cur_strafe
|
||||||
_pose_mod.fwd = _cur_fwd
|
_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 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 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 _idx: Dictionary = {}
|
||||||
var _resolved := false
|
var _resolved := false
|
||||||
@@ -1428,33 +1463,13 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
|
|
||||||
func _resolve() -> void:
|
func _resolve() -> void:
|
||||||
var skel := get_skeleton()
|
var skel := get_skeleton()
|
||||||
var names := SPINE + ["DEF-neck", "DEF-head",
|
# The canonical names are the LIBRARY skeleton's, and a model that kept
|
||||||
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
|
# its own rig names things differently — Taila's hips are DEF-spine, her
|
||||||
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
|
# head is DEF-spine.006, and she has no bone with "neck" in its name at
|
||||||
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
|
# all. Unresolved, every lean, aim pitch and slide head-lift below
|
||||||
# The names above are the LIBRARY skeleton's. A model that kept its own
|
# silently did nothing. RigRoles maps them through the sidecar; the dance
|
||||||
# rig names things differently and three of them simply do not exist on
|
# layer uses the same call rather than a second copy of it.
|
||||||
# it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has
|
_idx = RigRoles.resolve(skel, roles)
|
||||||
# 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
|
|
||||||
_resolve_hands(skel)
|
_resolve_hands(skel)
|
||||||
_resolved = true
|
_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_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
client_rep_config.add_property(":synced_is_dancing")
|
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_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
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_is_ads: bool = false
|
||||||
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
|
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
|
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)
|
# Anime speed-lines overlay (local player only)
|
||||||
var _speedlines: ColorRect = null
|
var _speedlines: ColorRect = null
|
||||||
@@ -831,21 +838,45 @@ func _physics_process(_delta: float) -> void:
|
|||||||
if Input.is_action_just_pressed("toggle_camera_view"):
|
if Input.is_action_just_pressed("toggle_camera_view"):
|
||||||
set_third_person(not third_person)
|
set_third_person(not third_person)
|
||||||
|
|
||||||
# Dance emote (B): toggles while grounded and idle-ish; any
|
# Emote (B): HOLD to open the radial dial and point at a dance,
|
||||||
# movement/jump/crouch input breaks it.
|
# 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"):
|
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 m := _ensure_machine()
|
||||||
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
|
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
|
synced_is_dancing = true
|
||||||
else:
|
|
||||||
synced_is_dancing = false
|
|
||||||
if synced_is_dancing:
|
if synced_is_dancing:
|
||||||
var m2 := _ensure_machine()
|
var m2 := _ensure_machine()
|
||||||
var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash
|
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"
|
var airborne: bool = m2 and m2.current_state != "ground"
|
||||||
if moving or airborne:
|
if moving or airborne:
|
||||||
synced_is_dancing = false
|
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()
|
var machine := _ensure_machine()
|
||||||
if machine:
|
if machine:
|
||||||
@@ -920,7 +951,7 @@ func _physics_process(_delta: float) -> void:
|
|||||||
if visual.has_method("set_wall_side"):
|
if visual.has_method("set_wall_side"):
|
||||||
visual.set_wall_side(sm.wall_side)
|
visual.set_wall_side(sm.wall_side)
|
||||||
if visual.has_method("set_dancing"):
|
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":
|
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
|
||||||
visual.set_grapple_target(synced_grapple_point)
|
visual.set_grapple_target(synced_grapple_point)
|
||||||
|
|
||||||
@@ -1005,7 +1036,7 @@ func _process(delta: float) -> void:
|
|||||||
if visual.has_method("set_wall_side"):
|
if visual.has_method("set_wall_side"):
|
||||||
visual.set_wall_side(synced_wall_side)
|
visual.set_wall_side(synced_wall_side)
|
||||||
if visual.has_method("set_dancing"):
|
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":
|
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
|
||||||
visual.set_grapple_target(synced_grapple_point)
|
visual.set_grapple_target(synced_grapple_point)
|
||||||
# Upper body follows the owner's synced camera pitch
|
# Upper body follows the owner's synced camera pitch
|
||||||
@@ -1113,6 +1144,11 @@ func _setup_hud() -> void:
|
|||||||
_hud.player = self
|
_hud.player = self
|
||||||
add_child(_hud)
|
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
|
# 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.
|
# and the ring is read by the reload logic — so keep the references it had.
|
||||||
death_screen = _hud.death_screen
|
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_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
client_rep_config.add_property(":synced_is_dancing")
|
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_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
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_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
client_rep_config.add_property(":synced_is_dancing")
|
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_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
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