big
This commit is contained in:
@@ -1,239 +0,0 @@
|
||||
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 +0,0 @@
|
||||
uid://bab4y7ba0yl87
|
||||
+24
-161
@@ -1,182 +1,43 @@
|
||||
extends Object
|
||||
class_name DanceRoutines
|
||||
|
||||
## The five emotes, as data.
|
||||
## Five authored emotes available through the radial wheel.
|
||||
##
|
||||
## 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.
|
||||
## Every entry names an imported motion-capture clip. The previous version
|
||||
## synthesized five routines by oscillating individual bones over one dance
|
||||
## clip; that produced the programmer-animation quality this project is moving
|
||||
## away from. Network IDs stay stable so existing peers and saved selections
|
||||
## remain compatible.
|
||||
const ROUTINES := [
|
||||
{
|
||||
"id": "two_step",
|
||||
"name": "Two-Step",
|
||||
"name": "Dance",
|
||||
"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,
|
||||
"clip": "Dance",
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"name": "Chest Stretch",
|
||||
"icon": "◆",
|
||||
"clip": "EmoteStretch",
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"name": "Call Out",
|
||||
"icon": "!",
|
||||
"clip": "EmoteCall",
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"name": "Yes!",
|
||||
"icon": "✓",
|
||||
"clip": "EmoteYes",
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"name": "No",
|
||||
"icon": "×",
|
||||
"clip": "EmoteNo",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -185,14 +46,16 @@ 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 clip_of(index: int) -> String:
|
||||
return String(get_routine(index).get("clip", "Dance"))
|
||||
|
||||
|
||||
static func name_of(index: int) -> String:
|
||||
return String(get_routine(index).get("name", ""))
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@ var calf_r: MeshInstance3D
|
||||
var current_state: String = "idle"
|
||||
var movement_speed: float = 0.0
|
||||
var _anim_time: float = 0.0
|
||||
var _locomotion_strafe: float = 0.0
|
||||
var _locomotion_fwd: float = 0.0
|
||||
var _brake_left: float = 0.0
|
||||
var _brake_fwd: float = 1.0
|
||||
var _brake_strength: float = 0.0
|
||||
var _brake_weight: float = 0.0
|
||||
var _brake_armed := true
|
||||
const BRAKE_DURATION := 0.46
|
||||
|
||||
func _ready() -> void:
|
||||
rotation_degrees.y = 180
|
||||
@@ -240,11 +248,44 @@ func _apply_color(col: Color) -> void:
|
||||
LevelMaterials.apply_toon_recursive(root_pivot, 0.004)
|
||||
|
||||
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
|
||||
var previous_speed := movement_speed
|
||||
var slowdown := previous_speed - speed
|
||||
if state in ["ground", "idle"]:
|
||||
if speed > 4.5 and slowdown < 0.05:
|
||||
_brake_armed = true
|
||||
if _brake_armed and previous_speed > 4.5 and slowdown > 0.12:
|
||||
_brake_left = BRAKE_DURATION
|
||||
_brake_strength = clampf(previous_speed / 10.0, 0.55, 1.0)
|
||||
_brake_armed = false
|
||||
if absf(_locomotion_fwd) > 0.15:
|
||||
_brake_fwd = signf(_locomotion_fwd)
|
||||
elif state not in ["ground", "idle"]:
|
||||
_brake_left = 0.0
|
||||
_brake_armed = true
|
||||
current_state = state
|
||||
movement_speed = speed
|
||||
if is_crouching and current_state == "ground":
|
||||
current_state = "crouch"
|
||||
|
||||
|
||||
func set_locomotion(strafe: float, fwd: float, _ads: float) -> void:
|
||||
_locomotion_strafe = clampf(strafe, -1.0, 1.0)
|
||||
_locomotion_fwd = clampf(fwd, -1.0, 1.0)
|
||||
|
||||
|
||||
func get_jet_socket_world_positions() -> Array[Vector3]:
|
||||
if not root_pivot:
|
||||
return []
|
||||
return [
|
||||
upper_arm_l_pivot.global_position \
|
||||
+ root_pivot.global_basis * Vector3(-0.04, -0.02, -0.16),
|
||||
upper_arm_r_pivot.global_position \
|
||||
+ root_pivot.global_basis * Vector3(0.04, -0.02, -0.16),
|
||||
calf_l_pivot.to_global(Vector3(0.0, -0.35, -0.045)),
|
||||
calf_r_pivot.to_global(Vector3(0.0, -0.35, -0.045)),
|
||||
]
|
||||
|
||||
|
||||
var is_holding_weapon: bool = false
|
||||
|
||||
func set_weapon(script_path: String) -> void:
|
||||
@@ -336,6 +377,17 @@ func _set_shadows_recursive(node: Node) -> void:
|
||||
_set_shadows_recursive(child)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var brake_target := 0.0
|
||||
if _brake_left > 0.0 and current_state in ["ground", "idle"]:
|
||||
_brake_left = maxf(0.0, _brake_left - delta)
|
||||
var progress := 1.0 - _brake_left / BRAKE_DURATION
|
||||
var envelope := smoothstep(0.0, 1.0, progress / 0.30) \
|
||||
if progress < 0.30 else \
|
||||
1.0 - smoothstep(0.0, 1.0, (progress - 0.30) / 0.70)
|
||||
brake_target = envelope * _brake_strength
|
||||
_brake_weight = lerpf(
|
||||
_brake_weight, brake_target,
|
||||
1.0 - exp((-22.0 if brake_target > _brake_weight else -10.0) * delta))
|
||||
var anim_speed = 1.0
|
||||
if current_state == "ground" and movement_speed > 1.0:
|
||||
anim_speed = movement_speed * 0.4
|
||||
@@ -358,7 +410,8 @@ func _process(delta: float) -> void:
|
||||
"ground", "idle":
|
||||
if movement_speed > 1.0:
|
||||
# Run cycle
|
||||
var swing = sin(_anim_time * 5.0)
|
||||
var reverse_cycle := -1.0 if _locomotion_fwd < -0.12 else 1.0
|
||||
var swing = sin(_anim_time * 5.0) * reverse_cycle
|
||||
# Hips
|
||||
t_thigh_l_rot.x = -swing * 1.6
|
||||
t_thigh_r_rot.x = swing * 1.6
|
||||
@@ -376,6 +429,11 @@ func _process(delta: float) -> void:
|
||||
|
||||
# Bounce slightly
|
||||
t_root_pos.y = abs(cos(_anim_time * 5.0)) * 0.1
|
||||
# Directional silhouette: reverse travel moves the shoulders
|
||||
# behind the hips instead of reusing the forward sprint lean.
|
||||
t_root_rot.x = deg_to_rad(10.0) if _locomotion_fwd < -0.12 \
|
||||
else deg_to_rad(-8.0)
|
||||
t_root_rot.z = deg_to_rad(-7.0) * _locomotion_strafe
|
||||
else:
|
||||
# Idle
|
||||
var breath = sin(_anim_time * 2.0)
|
||||
@@ -465,7 +523,7 @@ func _process(delta: float) -> void:
|
||||
t_calf_r_rot.x = deg_to_rad(10)
|
||||
|
||||
"dash":
|
||||
t_root_rot.x = deg_to_rad(-70)
|
||||
t_root_rot.x = deg_to_rad(-38)
|
||||
t_upper_arm_l_rot.x = deg_to_rad(-170)
|
||||
t_lower_arm_l_rot.x = 0.0
|
||||
t_upper_arm_r_rot.x = deg_to_rad(-170)
|
||||
@@ -487,7 +545,24 @@ func _process(delta: float) -> void:
|
||||
t_calf_l_rot.x = 0.0
|
||||
t_thigh_r_rot.x = deg_to_rad(10)
|
||||
t_calf_r_rot.x = 0.0
|
||||
|
||||
|
||||
# Blend a single catching step over the locomotion result. It rises quickly
|
||||
# and releases over the same span as the run-to-idle crossfade, preserving
|
||||
# momentum without freezing the whole mannequin in an exaggerated plant.
|
||||
if current_state in ["ground", "idle"] and _brake_weight > 0.001:
|
||||
var travel_sign := -1.0 if _brake_fwd < 0.0 else 1.0
|
||||
t_root_pos.y = lerpf(t_root_pos.y, -0.025, _brake_weight)
|
||||
t_root_rot.x = lerp_angle(
|
||||
t_root_rot.x, deg_to_rad(8.0) * travel_sign, _brake_weight)
|
||||
t_thigh_r_rot.x = lerp_angle(
|
||||
t_thigh_r_rot.x, deg_to_rad(-24.0) * travel_sign, _brake_weight)
|
||||
t_calf_r_rot.x = lerp_angle(
|
||||
t_calf_r_rot.x, deg_to_rad(12.0) * travel_sign, _brake_weight)
|
||||
t_thigh_l_rot.x = lerp_angle(
|
||||
t_thigh_l_rot.x, deg_to_rad(8.0) * travel_sign, _brake_weight)
|
||||
t_calf_l_rot.x = lerp_angle(
|
||||
t_calf_l_rot.x, deg_to_rad(14.0), _brake_weight)
|
||||
|
||||
# Override right arm (and left) if holding a weapon
|
||||
if is_holding_weapon and current_state != "death":
|
||||
# Right arm holds the grip: elbow tucked at the ribs, forearm level so
|
||||
@@ -503,7 +578,7 @@ func _process(delta: float) -> void:
|
||||
t_upper_arm_l_rot.z = 0.55
|
||||
t_lower_arm_l_rot.x = -1.0
|
||||
|
||||
var lerp_speed = 40.0 * delta
|
||||
var lerp_speed := 1.0 - exp(-14.0 * delta)
|
||||
root_pivot.position = root_pivot.position.lerp(t_root_pos, lerp_speed)
|
||||
root_pivot.rotation = _lerp_vec3(root_pivot.rotation, t_root_rot, lerp_speed)
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
extends Node3D
|
||||
class_name PlayerJetVFX
|
||||
|
||||
## Four restrained anime-thrust accents: two shoulder nozzles and two foot
|
||||
## nozzles. The geometry is deliberately graphic instead of particle-heavy so
|
||||
## the character remains 90% silhouette/animation and only 10% visible tech.
|
||||
|
||||
const DOUBLE_JUMP_TIME := 0.40
|
||||
const FLAME_LENGTH := 0.28
|
||||
const SHOULDER_SOCKETS := [
|
||||
Vector3(-0.27, 1.34, 0.16),
|
||||
Vector3(0.27, 1.34, 0.16),
|
||||
]
|
||||
const FOOT_SOCKETS := [
|
||||
Vector3(-0.16, 0.14, 0.08),
|
||||
Vector3(0.16, 0.14, 0.08),
|
||||
]
|
||||
|
||||
var _flames: Array[Node3D] = []
|
||||
var _burst_left := 0.0
|
||||
var _dash_active := false
|
||||
var _dash_tail := 0.0
|
||||
var _render_enabled := true
|
||||
var _exhaust_direction := Vector3.DOWN
|
||||
var _time := 0.0
|
||||
var _visual_model: Node3D = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for socket in SHOULDER_SOCKETS + FOOT_SOCKETS:
|
||||
_flames.append(_make_flame(socket))
|
||||
visible = false
|
||||
|
||||
|
||||
func _make_flame(socket: Vector3) -> Node3D:
|
||||
var flame := Node3D.new()
|
||||
flame.name = "JetFlame%02d" % _flames.size()
|
||||
flame.position = socket
|
||||
flame.set_meta("jet_flame", true)
|
||||
add_child(flame)
|
||||
|
||||
var outer := MeshInstance3D.new()
|
||||
outer.name = "OuterFlame"
|
||||
outer.mesh = _cone_mesh(0.045, FLAME_LENGTH)
|
||||
outer.material_override = _flame_material(
|
||||
Color(1.0, 0.24, 0.05, 0.72), Color(1.0, 0.12, 0.02), 3.2)
|
||||
outer.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
outer.position.y = FLAME_LENGTH * 0.5
|
||||
flame.add_child(outer)
|
||||
|
||||
var inner := MeshInstance3D.new()
|
||||
inner.name = "HotCore"
|
||||
inner.mesh = _cone_mesh(0.016, FLAME_LENGTH * 0.72)
|
||||
inner.material_override = _flame_material(
|
||||
Color(0.82, 0.96, 1.0, 0.86), Color(0.36, 0.82, 1.0), 3.2)
|
||||
inner.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
inner.position.y = FLAME_LENGTH * 0.36
|
||||
flame.add_child(inner)
|
||||
return flame
|
||||
|
||||
|
||||
func _cone_mesh(radius: float, height: float) -> CylinderMesh:
|
||||
var mesh := CylinderMesh.new()
|
||||
mesh.bottom_radius = radius
|
||||
mesh.top_radius = 0.002
|
||||
mesh.height = height
|
||||
mesh.radial_segments = 8
|
||||
mesh.rings = 1
|
||||
return mesh
|
||||
|
||||
|
||||
func _flame_material(albedo: Color, emission: Color, energy: float) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat.albedo_color = albedo
|
||||
mat.emission_enabled = true
|
||||
mat.emission = emission
|
||||
mat.emission_energy_multiplier = energy
|
||||
return mat
|
||||
|
||||
|
||||
func set_render_enabled(enabled: bool) -> void:
|
||||
_render_enabled = enabled
|
||||
if not enabled:
|
||||
visible = false
|
||||
|
||||
|
||||
func set_visual_model(model: Node3D) -> void:
|
||||
_visual_model = model
|
||||
|
||||
|
||||
func burst_double_jump() -> void:
|
||||
_burst_left = DOUBLE_JUMP_TIME
|
||||
|
||||
|
||||
func set_dash(active: bool, movement_local: Vector2) -> void:
|
||||
if _dash_active and not active:
|
||||
_dash_tail = 0.14
|
||||
_dash_active = active
|
||||
if not active:
|
||||
return
|
||||
var move := Vector3(movement_local.x, 0.0, -movement_local.y)
|
||||
if move.length_squared() < 0.01:
|
||||
move = Vector3.FORWARD
|
||||
# Exhaust opposes travel and dips slightly, which makes the force direction
|
||||
# readable without turning the flame into a large sci-fi effect.
|
||||
_exhaust_direction = (-move.normalized() + Vector3.DOWN * 0.20).normalized()
|
||||
|
||||
|
||||
func get_flame_count() -> int:
|
||||
return _flames.size()
|
||||
|
||||
|
||||
func get_exhaust_direction() -> Vector3:
|
||||
return _exhaust_direction
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_time += delta
|
||||
_update_sockets()
|
||||
_burst_left = maxf(0.0, _burst_left - delta)
|
||||
_dash_tail = maxf(0.0, _dash_tail - delta)
|
||||
var dash_visible := _dash_active or _dash_tail > 0.0
|
||||
var active := dash_visible or _burst_left > 0.0
|
||||
visible = _render_enabled and active
|
||||
if not visible:
|
||||
return
|
||||
|
||||
if _burst_left > 0.0 and not dash_visible:
|
||||
# The double-jump jets push mostly down, with just enough rear cant to
|
||||
# keep the four flames separated in the silhouette.
|
||||
_exhaust_direction = Vector3(0.0, -1.0, 0.16).normalized()
|
||||
|
||||
var pulse := 0.90 + sin(_time * 54.0) * 0.08 + sin(_time * 31.0) * 0.04
|
||||
if _burst_left > 0.0 and not _dash_active:
|
||||
pulse *= clampf(_burst_left / 0.08, 0.15, 1.0)
|
||||
for i in _flames.size():
|
||||
var flame := _flames[i]
|
||||
# A tiny outward fan keeps all four graphic wedges legible from the
|
||||
# chase camera, even when the centerline points almost straight at it.
|
||||
var side: float = [0.13, -0.13, 0.06, -0.06][i]
|
||||
var nozzle_dir := (_exhaust_direction + Vector3(side, 0.0, 0.0)).normalized()
|
||||
flame.quaternion = Quaternion(Vector3.UP, nozzle_dir)
|
||||
var stagger := 1.0 + sin(_time * 43.0 + float(i) * 1.7) * 0.05
|
||||
flame.scale = Vector3(1.0, pulse * stagger, 1.0)
|
||||
|
||||
|
||||
func _update_sockets() -> void:
|
||||
if not is_instance_valid(_visual_model) \
|
||||
or not _visual_model.has_method("get_jet_socket_world_positions"):
|
||||
return
|
||||
var sockets: Array = _visual_model.get_jet_socket_world_positions()
|
||||
if sockets.size() != _flames.size():
|
||||
return
|
||||
for i in _flames.size():
|
||||
_flames[i].position = to_local(sockets[i])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6fppxauwra26
|
||||
@@ -10,3 +10,16 @@ class_name PlayerSkin
|
||||
@export var model_path: String = "" # Path to .glb file, empty = use procedural
|
||||
@export var color_tint: Color = Color(0.2, 0.4, 0.8) # For procedural model
|
||||
@export var is_unlocked: bool = true
|
||||
|
||||
## The first-person arms use lightweight procedural geometry, but their
|
||||
## palette and sleeve coverage still identify the selected character.
|
||||
@export_group("Viewmodel Arms")
|
||||
@export var viewmodel_sleeve_color: Color = Color(0.16, 0.34, 0.72)
|
||||
@export var viewmodel_accent_color: Color = Color(0.28, 0.72, 1.0)
|
||||
@export var viewmodel_skin_color: Color = Color(0.96, 0.80, 0.72)
|
||||
@export var viewmodel_glove_color: Color = Color(0.10, 0.11, 0.16)
|
||||
@export var viewmodel_nail_color: Color = Color(0.92, 0.68, 0.72)
|
||||
## bare: exposed hand + nails; fingerless: glove palm with exposed fingertips;
|
||||
## glove: fully covered; android: segmented synthetic hand.
|
||||
@export_enum("bare", "fingerless", "glove", "android") var viewmodel_hand_style := "bare"
|
||||
@export_range(0.2, 0.9, 0.01) var viewmodel_sleeve_ratio: float = 0.68
|
||||
|
||||
@@ -18,10 +18,13 @@ class_name RigRoles
|
||||
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",
|
||||
const CANONICAL := [
|
||||
"DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003",
|
||||
"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"]
|
||||
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L",
|
||||
]
|
||||
|
||||
|
||||
## Resolve `names` against a skeleton, given the sidecar's role table.
|
||||
|
||||
@@ -130,6 +130,7 @@ static func update(skeleton: Skeleton3D, driven: Array) -> void:
|
||||
static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
|
||||
joint_helpers: Dictionary, chain_l: PackedVector3Array,
|
||||
chain_r: PackedVector3Array, limb_radius: float) -> void:
|
||||
var safe_bounds := mi.mesh.get_aabb()
|
||||
var skin: Skin = mi.skin
|
||||
var bone_of := {}
|
||||
for b in skin.get_bind_count():
|
||||
@@ -179,6 +180,13 @@ static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D,
|
||||
rebuilt.surface_set_material(i, e["material"])
|
||||
if e["name"] != "":
|
||||
rebuilt.surface_set_name(i, e["name"])
|
||||
# Preserve an explicit conservative bound across this second mesh rebuild.
|
||||
# See SkinLegRepair: sparse runtime Skin binds can otherwise produce more
|
||||
# bone AABBs than the renderer skeleton contains.
|
||||
var padding := Vector3.ONE * maxf(safe_bounds.size.length() * 0.12, 0.25)
|
||||
safe_bounds.position -= padding
|
||||
safe_bounds.size += padding * 2.0
|
||||
rebuilt.custom_aabb = safe_bounds
|
||||
mi.mesh = rebuilt
|
||||
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ static func _knee_height(skeleton: Skeleton3D) -> float:
|
||||
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float,
|
||||
chain_l: PackedVector3Array, chain_r: PackedVector3Array,
|
||||
limb_radius: float) -> Array:
|
||||
var safe_bounds := mi.mesh.get_aabb()
|
||||
var side := _side_map(mi.skin, skeleton)
|
||||
var torso_bone := _torso_map(mi.skin, skeleton)
|
||||
var surfaces: Array = []
|
||||
@@ -169,6 +170,14 @@ static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float,
|
||||
rebuilt.surface_set_material(i, entry["material"])
|
||||
if entry["name"] != "":
|
||||
rebuilt.surface_set_name(i, entry["name"])
|
||||
# ArrayMesh rebuilds per-bind bone bounds from ARRAY_BONES. Some runtime
|
||||
# GLBs have a sparse Skin bind table, so that derived array can be longer
|
||||
# than the renderer skeleton and mesh_get_aabb rejects it. A padded bind-pose
|
||||
# bound is conservative for humanoid animation and avoids that invalid path.
|
||||
var padding := Vector3.ONE * maxf(safe_bounds.size.length() * 0.12, 0.25)
|
||||
safe_bounds.position -= padding
|
||||
safe_bounds.size += padding * 2.0
|
||||
rebuilt.custom_aabb = safe_bounds
|
||||
mi.mesh = rebuilt
|
||||
return [snapped, removed]
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ func _add_color_skin(id: String, display_name: String, description: String, tint
|
||||
skin.skin_name = display_name
|
||||
skin.description = description
|
||||
skin.color_tint = tint
|
||||
skin.viewmodel_sleeve_color = tint.darkened(0.18)
|
||||
skin.viewmodel_accent_color = tint.lightened(0.22)
|
||||
skin.viewmodel_glove_color = tint.darkened(0.48)
|
||||
skin.viewmodel_nail_color = tint.lightened(0.34)
|
||||
skin.viewmodel_hand_style = "fingerless"
|
||||
skin.is_unlocked = true
|
||||
skins[id] = skin
|
||||
|
||||
@@ -61,9 +66,34 @@ func _load_skins_json() -> void:
|
||||
skin.description = entry.get("description", "")
|
||||
skin.model_path = entry.get("model", "")
|
||||
skin.is_unlocked = entry.get("unlocked", true)
|
||||
var viewmodel = entry.get("viewmodel", {})
|
||||
if viewmodel is Dictionary:
|
||||
skin.viewmodel_sleeve_color = _json_color(
|
||||
viewmodel.get("sleeve", ""), skin.viewmodel_sleeve_color)
|
||||
skin.viewmodel_accent_color = _json_color(
|
||||
viewmodel.get("accent", ""), skin.viewmodel_accent_color)
|
||||
skin.viewmodel_skin_color = _json_color(
|
||||
viewmodel.get("skin", ""), skin.viewmodel_skin_color)
|
||||
skin.viewmodel_glove_color = _json_color(
|
||||
viewmodel.get("glove", ""), skin.viewmodel_glove_color)
|
||||
skin.viewmodel_nail_color = _json_color(
|
||||
viewmodel.get("nail", ""), skin.viewmodel_nail_color)
|
||||
var hand_style: String = str(viewmodel.get(
|
||||
"hand_style", skin.viewmodel_hand_style))
|
||||
if hand_style in ["bare", "fingerless", "glove", "android"]:
|
||||
skin.viewmodel_hand_style = hand_style
|
||||
skin.viewmodel_sleeve_ratio = clampf(
|
||||
float(viewmodel.get("sleeve_ratio", skin.viewmodel_sleeve_ratio)),
|
||||
0.2, 0.9)
|
||||
skins[entry["id"]] = skin
|
||||
|
||||
|
||||
func _json_color(value: Variant, fallback: Color) -> Color:
|
||||
if value is String and not value.is_empty():
|
||||
return Color.from_string(value, fallback)
|
||||
return fallback
|
||||
|
||||
|
||||
# ── Selection ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func get_skin(skin_id: String) -> PlayerSkin:
|
||||
|
||||
+524
-407
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user