This commit is contained in:
Nicholas Butzke
2026-08-02 02:20:02 -04:00
parent 61669627db
commit 922983429e
226 changed files with 34032 additions and 18521 deletions
-239
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://bab4y7ba0yl87
+24 -161
View File
@@ -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", ""))
+79 -4
View File
@@ -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)
+159
View File
@@ -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])
+1
View File
@@ -0,0 +1 @@
uid://b6fppxauwra26
+13
View File
@@ -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
+5 -2
View File
@@ -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.
+8
View File
@@ -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
+9
View File
@@ -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]
+30
View File
@@ -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:
File diff suppressed because it is too large Load Diff