diff --git a/characters/dance_modifier.gd b/characters/dance_modifier.gd new file mode 100644 index 0000000..b996364 --- /dev/null +++ b/characters/dance_modifier.gd @@ -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 +## `.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) diff --git a/characters/dance_modifier.gd.uid b/characters/dance_modifier.gd.uid new file mode 100644 index 0000000..f5cfb65 --- /dev/null +++ b/characters/dance_modifier.gd.uid @@ -0,0 +1 @@ +uid://bab4y7ba0yl87 diff --git a/characters/dance_routines.gd b/characters/dance_routines.gd new file mode 100644 index 0000000..0a03b4b --- /dev/null +++ b/characters/dance_routines.gd @@ -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 diff --git a/characters/dance_routines.gd.uid b/characters/dance_routines.gd.uid new file mode 100644 index 0000000..1fc988b --- /dev/null +++ b/characters/dance_routines.gd.uid @@ -0,0 +1 @@ +uid://8p4yow40ni8p diff --git a/characters/rig_roles.gd b/characters/rig_roles.gd new file mode 100644 index 0000000..d9084f1 --- /dev/null +++ b/characters/rig_roles.gd @@ -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 `.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 diff --git a/characters/rig_roles.gd.uid b/characters/rig_roles.gd.uid new file mode 100644 index 0000000..f79655b --- /dev/null +++ b/characters/rig_roles.gd.uid @@ -0,0 +1 @@ +uid://coh341vhya5x5 diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index 2245ab9..451c59c 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -96,6 +96,8 @@ var _rig_info: Dictionary = {} ## the same sidecar; drives the per-class cel look and answers `surfaces_of()`. var _surfaces: SkinSurfaces = null var _spring_mod: SpringBones +## The emote layer, between the shooter pose and the cloth springs. +var _dance_mod: DanceModifier var is_holding_weapon: bool = false ## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read ## by `_process` (a blade releases the off arm) and by the checks. @@ -242,8 +244,17 @@ func load_model(path: String) -> void: _pose_mod.fingers = _rig_info.get("fingers", {}) _pose_mod.name = "ShooterPose" skeleton.add_child(_pose_mod) + # Dance AFTER the shooter pose, so an emote blends over the weapon hold + # instead of fighting it — the arms come off the gun as the dance weight + # rises and are handed back the same way. + _dance_mod = DanceModifier.new() + _dance_mod.name = "Dance" + _dance_mod.roles = _rig_info.get("roles", {}) + skeleton.add_child(_dance_mod) # Cloth and hair last, so the springs react to the FINAL body pose — - # animation plus the shooter lean/slide layer. + # animation plus the shooter lean/slide layer, plus any dance. Hair and + # a skirt following a dance is entirely this ordering; nothing else is + # needed to make it happen. if not _rig_info.is_empty(): _spring_mod = SpringBones.new() _spring_mod.name = "SpringBones" @@ -553,6 +564,14 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void: var _prev_state: String = "" var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback var _dancing: bool = false +## Which of DanceRoutines.ROUTINES is playing, and the eased 0..1 blend of the +## dance layer over everything below it. +var _dance_index: int = 0 +var _cur_dance: float = 0.0 +## How fast a dance takes the body and gives it back. Slower than the pose +## layer's other blends on purpose: an emote starting is not a reaction, and +## snapping into one looks like a bug rather than like a decision. +const DANCE_SMOOTH := 5.0 ## Play a one-shot clip over locomotion for `lock_time` seconds. @@ -577,8 +596,16 @@ func play_oneshot(canonical: String, lock_time: float = 0.35) -> void: ## Emote toggle (Dance). Shown while grounded and near-idle; any real ## movement breaks it (the controller clears the flag too). -func set_dancing(on: bool) -> void: +## +## `which` selects one of the five routines in DanceRoutines. The base clip stays +## the library's single `Dance_Loop` underneath — the routine is layered over it +## by DanceModifier, which is what makes five distinct emotes out of one clip. +func set_dancing(on: bool, which: int = -1) -> void: _dancing = on + if which >= 0: + _dance_index = which + if _dance_mod: + _dance_mod.routine = DanceRoutines.get_routine(_dance_index) ## Play a named gameplay action (reload / throw / shoot) as a one-shot. @@ -734,6 +761,12 @@ func _process(delta: float) -> void: _cur_slide = lerpf(_cur_slide, slide_target, t) var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0 _cur_wall = lerpf(_cur_wall, wall_target, lean_t) + # The dance blend. Eased both ways, so an emote arrives and leaves rather + # than cutting — and so the arms come off the weapon smoothly. + if _dance_mod: + _cur_dance = lerpf(_cur_dance, 1.0 if _dancing else 0.0, + 1.0 - exp(-DANCE_SMOOTH * delta)) + _dance_mod.weight = _cur_dance _update_travel(delta, drive) _pose_mod.strafe = _cur_strafe _pose_mod.fwd = _cur_fwd @@ -1352,7 +1385,9 @@ class ShooterPoseModifier extends SkeletonModifier3D: const WALL_ARM_OUT := 0.9 # inner arm reaches out to touch the wall const HOLD_SMOOTH := 8.0 # how fast the hold takes/releases the arms - const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"] + ## The library skeleton's spine, hips first. Shared with the dance layer via + ## RigRoles, which also owns the mapping onto a rig that kept its own names. + const SPINE := RigRoles.SPINE var _idx: Dictionary = {} var _resolved := false @@ -1428,33 +1463,13 @@ class ShooterPoseModifier extends SkeletonModifier3D: func _resolve() -> void: var skel := get_skeleton() - var names := SPINE + ["DEF-neck", "DEF-head", - "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R", - "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L", - "DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"] - # The names above are the LIBRARY skeleton's. A model that kept its own - # rig names things differently and three of them simply do not exist on - # it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has - # no bone with "neck" in its name at all. Unresolved, every lean, aim - # pitch and slide head-lift below silently did nothing. - var alias := {} - if not roles.is_empty(): - var neck: String = roles.get("neck", "") - var head: String = roles.get("head", "") - var torso: Array = [] - for n in roles.get("spine", []): - if n != neck and n != head: - torso.append(n) - for i in mini(torso.size(), SPINE.size() - 1): - alias[SPINE[i + 1]] = torso[i] - for n in names: - # Canonical names are the role keys with the DEF- prefix, so the - # limbs, hips, neck and head all map straight through. - var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n)) - var b := skel.find_bone(actual) - if b < 0: - b = skel.find_bone(n) - _idx[n] = b + # The canonical names are the LIBRARY skeleton's, and a model that kept + # its own rig names things differently — Taila's hips are DEF-spine, her + # head is DEF-spine.006, and she has no bone with "neck" in its name at + # all. Unresolved, every lean, aim pitch and slide head-lift below + # silently did nothing. RigRoles maps them through the sidecar; the dance + # layer uses the same call rather than a second copy of it. + _idx = RigRoles.resolve(skel, roles) _resolve_hands(skel) _resolved = true diff --git a/debug/dance_check.gd b/debug/dance_check.gd new file mode 100644 index 0000000..c5c50e1 --- /dev/null +++ b/debug/dance_check.gd @@ -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) diff --git a/debug/emote_wheel_capture.gd b/debug/emote_wheel_capture.gd new file mode 100644 index 0000000..e31b5f4 --- /dev/null +++ b/debug/emote_wheel_capture.gd @@ -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 -- +## +## 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) diff --git a/debug/hold_capture.gd.uid b/debug/hold_capture.gd.uid new file mode 100644 index 0000000..12fd8bf --- /dev/null +++ b/debug/hold_capture.gd.uid @@ -0,0 +1 @@ +uid://c25wpt58v328r diff --git a/debug/test_level_builder.gd b/debug/test_level_builder.gd index 0b559f7..efb973e 100644 --- a/debug/test_level_builder.gd +++ b/debug/test_level_builder.gd @@ -274,6 +274,8 @@ func _spawn_player(pid: int) -> CharacterBody3D: client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_is_dancing") + # Which of the five emotes, so other players see the one that was chosen. + client_rep_config.add_property(":synced_dance_index") client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_skin_id") diff --git a/movement/player_movement_controller.gd b/movement/player_movement_controller.gd index 4c802c3..93cf781 100644 --- a/movement/player_movement_controller.gd +++ b/movement/player_movement_controller.gd @@ -88,6 +88,13 @@ var synced_velocity: Vector3 = Vector3.ZERO var synced_is_ads: bool = false var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean) var synced_is_dancing: bool = false # dance emote (B), shown on the model +## Which of the five routines in DanceRoutines is playing. Replicated, so other +## players see the emote that was actually chosen rather than always the first. +var synced_dance_index: int = 0 +## The radial dial, and how long the emote button has been held. -1 means this +## press was consumed by stopping a dance and must not open the wheel. +var _emote_wheel: EmoteWheel = null +var _emote_held: float = -1.0 # Anime speed-lines overlay (local player only) var _speedlines: ColorRect = null @@ -831,21 +838,45 @@ func _physics_process(_delta: float) -> void: if Input.is_action_just_pressed("toggle_camera_view"): set_third_person(not third_person) - # Dance emote (B): toggles while grounded and idle-ish; any - # movement/jump/crouch input breaks it. + # Emote (B): HOLD to open the radial dial and point at a dance, + # release to commit. A tap too short to have aimed anything just + # toggles the last one, which is exactly what the button did before + # the wheel existed — so the old muscle memory still works. if Input.is_action_just_pressed("emote"): + _emote_held = 0.0 + if synced_is_dancing: + # Already dancing: the press stops it, and no wheel opens. + # Having to aim at something in order to STOP is the most + # annoying possible way to build this. + synced_is_dancing = false + _emote_held = -1.0 + elif _emote_wheel: + _emote_wheel.open() + elif Input.is_action_pressed("emote") and _emote_held >= 0.0: + _emote_held += _delta + elif Input.is_action_just_released("emote") and _emote_held >= 0.0: + var aimed := _emote_wheel.close() if _emote_wheel else -1 + # A tap replays the last emote; a hold plays whatever was aimed + # at. Either way the same grounded-and-slow gate applies. + var pick := aimed if aimed >= 0 else synced_dance_index var m := _ensure_machine() var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0 - if not synced_is_dancing and m and m.current_state == "ground" and slow: + if m and m.current_state == "ground" and slow: + synced_dance_index = pick synced_is_dancing = true - else: - synced_is_dancing = false if synced_is_dancing: var m2 := _ensure_machine() var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash var airborne: bool = m2 and m2.current_state != "ground" if moving or airborne: synced_is_dancing = false + # The wheel eats aiming while it is open, so the player picking an emote + # does not also spin their character round. Movement is deliberately NOT + # blocked — a wheel that roots you in the open is a wheel nobody uses. + if _emote_wheel and _emote_wheel.visible and head_pivot: + head_pivot.set_process_input(false) + elif head_pivot and not head_pivot.is_processing_input() and not is_dead: + head_pivot.set_process_input(true) var machine := _ensure_machine() if machine: @@ -920,7 +951,7 @@ func _physics_process(_delta: float) -> void: if visual.has_method("set_wall_side"): visual.set_wall_side(sm.wall_side) if visual.has_method("set_dancing"): - visual.set_dancing(synced_is_dancing) + visual.set_dancing(synced_is_dancing, synced_dance_index) if visual.has_method("set_grapple_target") and sm.current_state == "grapple": visual.set_grapple_target(synced_grapple_point) @@ -1005,7 +1036,7 @@ func _process(delta: float) -> void: if visual.has_method("set_wall_side"): visual.set_wall_side(synced_wall_side) if visual.has_method("set_dancing"): - visual.set_dancing(synced_is_dancing) + visual.set_dancing(synced_is_dancing, synced_dance_index) if visual.has_method("set_grapple_target") and synced_movement_state == "grapple": visual.set_grapple_target(synced_grapple_point) # Upper body follows the owner's synced camera pitch @@ -1113,6 +1144,11 @@ func _setup_hud() -> void: _hud.player = self add_child(_hud) + # The emote dial rides on the HUD's canvas, above the viewmodel. + _emote_wheel = EmoteWheel.new() + _emote_wheel.name = "EmoteWheel" + _hud.add_child(_emote_wheel) + # The controller still owns these two — it toggles the death screen on death # and the ring is read by the reload logic — so keep the references it had. death_screen = _hud.death_screen diff --git a/scenes/maps/level_runtime.gd b/scenes/maps/level_runtime.gd index 759f5f8..b5f8ab7 100644 --- a/scenes/maps/level_runtime.gd +++ b/scenes/maps/level_runtime.gd @@ -113,6 +113,8 @@ func _spawn_player(pid: int) -> CharacterBody3D: client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_is_dancing") + # Which of the five emotes, so other players see the one that was chosen. + client_rep_config.add_property(":synced_dance_index") client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_skin_id") diff --git a/scenes/maps/procedural_arena/procedural_arena_runtime.gd b/scenes/maps/procedural_arena/procedural_arena_runtime.gd index db0d9c2..337e92f 100644 --- a/scenes/maps/procedural_arena/procedural_arena_runtime.gd +++ b/scenes/maps/procedural_arena/procedural_arena_runtime.gd @@ -82,6 +82,8 @@ func _spawn_player(pid: int) -> CharacterBody3D: client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_is_dancing") + # Which of the five emotes, so other players see the one that was chosen. + client_rep_config.add_property(":synced_dance_index") client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_skin_id") diff --git a/ui/emote_wheel.gd b/ui/emote_wheel.gd new file mode 100644 index 0000000..89f8376 --- /dev/null +++ b/ui/emote_wheel.gd @@ -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)) diff --git a/ui/emote_wheel.gd.uid b/ui/emote_wheel.gd.uid new file mode 100644 index 0000000..b8962f8 --- /dev/null +++ b/ui/emote_wheel.gd.uid @@ -0,0 +1 @@ +uid://dnxnbswrl04u8