Feat/fidelity pass 2 #23

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