feat(client): purchased UAL tiers wired — lib/Clip addressing, stance enter/exits, turn-in-place (T-1088)
ual1.glb (120 clips) + ual2.glb (134) replace the free-tier subsets; CharacterVisual loads both under explicit library names with lib/Clip exact addressing (bare names keep cross-library search). Gait table re-pointed; two new one-shot overlays in the gait machine: stance transitions (Sprint/Crouch Enter/Exit, destination-Enter priority, clip-length timer, movement never stalls) and turn-in-place (Turn90/180 L/R from shortest-arc sign, >=60/135 deg idle yaw jumps, retrigger-guarded). All clip names verified by dumping the imported GLBs. 70 gait tests + live smoke green (both libraries load with exact counts). S9 live-tune list: turn handedness (unverified headless — one-line swap), TURN_SPEED_SCALE=2.0 compromise vs the snappy yaw ease, turn-on-stop feel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,16 @@ extends Node3D
|
||||
|
||||
const BASE_PATH := "res://assets/characters/"
|
||||
const SKELETON_PATH := BASE_PATH + "skeleton/armature.glb"
|
||||
const ANIM_LIBRARY_PATH := BASE_PATH + "animations/ual_standard.glb"
|
||||
## Purchased UAL tiers (D-248 / T-1088): each GLB's imported default library ("") is
|
||||
## registered under an explicit name so "lib/Clip" addressing resolves exactly and the
|
||||
## two GLBs' identically-named "" libraries do not collide. Bare-name play_animation()
|
||||
## still works via a cross-library search (back-compat). UAL1 = base locomotion +
|
||||
## Turn90 + Sprint/Crouch Enter/Exit; UAL2 = Turn180 + 8-dir walk sets + life-sim idles.
|
||||
## The importer strips the "_Loop" suffix and sets loop mode (verified 2026-07-06).
|
||||
const ANIM_LIBRARIES := {
|
||||
"ual1": BASE_PATH + "animations/ual1.glb",
|
||||
"ual2": BASE_PATH + "animations/ual2.glb",
|
||||
}
|
||||
const SKIN_TONE_DIR := BASE_PATH + "skin_tones/"
|
||||
const EYE_IRIS_MASK_PATH := BASE_PATH + "bodies/eye_iris_mask.png"
|
||||
const TOON_SHADER_PATH := BASE_PATH + "shaders/toon.gdshader"
|
||||
@@ -732,61 +741,89 @@ func _rebuild_outlines() -> void:
|
||||
func _load_animations() -> void:
|
||||
if _skeleton == null:
|
||||
return
|
||||
var anim_scene: PackedScene = load(ANIM_LIBRARY_PATH) as PackedScene
|
||||
if anim_scene == null:
|
||||
push_warning("CharacterVisual: animation library not found at %s" % ANIM_LIBRARY_PATH)
|
||||
return
|
||||
var anim_root: Node = anim_scene.instantiate()
|
||||
# Find the AnimationPlayer in the imported GLB scene
|
||||
var source_player: AnimationPlayer = null
|
||||
for child in anim_root.get_children():
|
||||
if child is AnimationPlayer:
|
||||
source_player = child as AnimationPlayer
|
||||
break
|
||||
if source_player == null:
|
||||
# Try deeper — some GLB imports nest the player
|
||||
for child in anim_root.get_children():
|
||||
for grandchild in child.get_children():
|
||||
if grandchild is AnimationPlayer:
|
||||
source_player = grandchild as AnimationPlayer
|
||||
break
|
||||
if source_player:
|
||||
break
|
||||
if source_player == null:
|
||||
push_warning("CharacterVisual: no AnimationPlayer found in animation library")
|
||||
anim_root.queue_free()
|
||||
return
|
||||
# Parent AnimationPlayer to _body_root (the imported scene root) so that
|
||||
# animation track paths like "Armature/Skeleton3D:bone_name" resolve correctly.
|
||||
# The imported GLB has structure: root > Armature > Skeleton3D.
|
||||
_anim_player = AnimationPlayer.new()
|
||||
_anim_player.name = "AnimPlayer"
|
||||
_body_root.add_child(_anim_player)
|
||||
# Copy animation libraries from the source player
|
||||
for lib_name in source_player.get_animation_library_list():
|
||||
var lib: AnimationLibrary = source_player.get_animation_library(lib_name)
|
||||
_anim_player.add_animation_library(lib_name, lib.duplicate())
|
||||
anim_root.queue_free()
|
||||
# Load every purchased UAL tier, registering each GLB's imported default ("")
|
||||
# library under its explicit name (D-248 / T-1088) — "ual1", "ual2".
|
||||
var loaded := 0
|
||||
for lib_name: String in ANIM_LIBRARIES:
|
||||
loaded += _load_animation_library(lib_name, ANIM_LIBRARIES[lib_name])
|
||||
if loaded == 0:
|
||||
push_warning("CharacterVisual: no animation libraries loaded")
|
||||
return
|
||||
# Play idle if available
|
||||
play_animation("idle")
|
||||
|
||||
|
||||
## Play a named animation. Searches all libraries for a matching name.
|
||||
## Load one UAL GLB and register its libraries. The imported default library ("")
|
||||
## carries the clips; it is re-registered under the explicit `lib_name` so
|
||||
## "lib/Clip" addressing resolves and a second GLB's "" library cannot collide.
|
||||
## Returns the number of libraries added (0 on failure).
|
||||
func _load_animation_library(lib_name: String, path: String) -> int:
|
||||
var anim_scene: PackedScene = load(path) as PackedScene
|
||||
if anim_scene == null:
|
||||
push_warning("CharacterVisual: animation library not found at %s" % path)
|
||||
return 0
|
||||
var anim_root: Node = anim_scene.instantiate()
|
||||
var source_player := _find_anim_player(anim_root)
|
||||
if source_player == null:
|
||||
push_warning("CharacterVisual: no AnimationPlayer found in %s" % path)
|
||||
anim_root.queue_free()
|
||||
return 0
|
||||
var added := 0
|
||||
for src_lib_name in source_player.get_animation_library_list():
|
||||
# Default "" library -> the explicit tier name; any pre-named source library
|
||||
# is kept but namespaced under the tier so tiers never collide.
|
||||
var target_name: String = lib_name if src_lib_name == "" else lib_name + "_" + str(src_lib_name)
|
||||
if _anim_player.has_animation_library(target_name):
|
||||
continue
|
||||
var lib: AnimationLibrary = source_player.get_animation_library(src_lib_name)
|
||||
_anim_player.add_animation_library(target_name, lib.duplicate())
|
||||
added += 1
|
||||
anim_root.queue_free()
|
||||
return added
|
||||
|
||||
|
||||
## Recursively find the first AnimationPlayer under `root` (GLB imports sometimes
|
||||
## nest it below the scene root).
|
||||
static func _find_anim_player(root: Node) -> AnimationPlayer:
|
||||
if root is AnimationPlayer:
|
||||
return root as AnimationPlayer
|
||||
for child in root.get_children():
|
||||
var found := _find_anim_player(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
## Play a named animation. Two addressing forms (T-1088 / D-248):
|
||||
## - Explicit "lib/Clip" (name contains "/") resolves to that library's clip
|
||||
## exactly — no cross-library search, so tier placement is unambiguous.
|
||||
## - A bare name searches every library and plays the first match (back-compat
|
||||
## for existing callers and the "idle" convenience alias below).
|
||||
## blend_time >= 0.0 is passed to AnimationPlayer.play() as custom_blend (crossfade
|
||||
## seconds); the -1.0 default preserves the original hard-cut behavior for existing
|
||||
## callers (T-1088 design §6.3 — the locomotion gait machine is the first blend user).
|
||||
## seconds); the -1.0 default preserves the original hard-cut behavior (design §6.3 —
|
||||
## the locomotion gait machine is the first blend user).
|
||||
func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
|
||||
if _anim_player == null:
|
||||
return
|
||||
# Search across all libraries for the animation
|
||||
# Explicit "lib/Clip" addressing — resolve exactly.
|
||||
if anim_name.contains("/"):
|
||||
if _anim_player.has_animation(anim_name):
|
||||
_play_resolved(anim_name, blend_time)
|
||||
return
|
||||
push_warning("CharacterVisual: animation '%s' not found (explicit lib/Clip)" % anim_name)
|
||||
return
|
||||
# Bare name — search across all libraries for a matching clip.
|
||||
for lib_name in _anim_player.get_animation_library_list():
|
||||
var lib: AnimationLibrary = _anim_player.get_animation_library(lib_name)
|
||||
if lib.has_animation(anim_name):
|
||||
var full_name: String = lib_name + "/" + anim_name if lib_name != "" else anim_name
|
||||
if blend_time >= 0.0:
|
||||
_anim_player.play(full_name, blend_time)
|
||||
else:
|
||||
_anim_player.play(full_name)
|
||||
_play_resolved(full_name, blend_time)
|
||||
return
|
||||
# Try common idle variants
|
||||
for variant in ["Idle", "idle_01", "Idle_01", "breathing_idle", "Breathing_Idle"]:
|
||||
@@ -796,6 +833,15 @@ func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
|
||||
push_warning("CharacterVisual: animation '%s' not found in any library" % anim_name)
|
||||
|
||||
|
||||
# Play a fully-qualified animation name ("lib/Clip" or a default-library bare name),
|
||||
# honoring the custom-blend contract: blend_time >= 0.0 crossfades, -1.0 hard-cuts.
|
||||
func _play_resolved(full_name: String, blend_time: float) -> void:
|
||||
if blend_time >= 0.0:
|
||||
_anim_player.play(full_name, blend_time)
|
||||
else:
|
||||
_anim_player.play(full_name)
|
||||
|
||||
|
||||
## Stop all animations and return to rest pose.
|
||||
func stop_animation() -> void:
|
||||
if _anim_player:
|
||||
|
||||
@@ -5,6 +5,20 @@ extends RefCounted
|
||||
## velocity (never input), transitions are edge-triggered crossfades, and playback
|
||||
## rate is cadence-synced so foot fall matches actual ground speed (§6.2).
|
||||
##
|
||||
## Two one-shot overlays sit on top of the gait loop (T-1088, UAL Enter/Exit + Turn
|
||||
## clips):
|
||||
## - STANCE TRANSITIONS: on a stance change with a Sprint/Crouch Enter/Exit clip,
|
||||
## play it once (loop=0) then blend into the target loop. The rig position channel
|
||||
## is independent, so the character keeps gliding — movement never stalls.
|
||||
## - TURN-IN-PLACE: when IDLE and the yaw TARGET jumps >= TURN_TRIGGER_DEG (a fast
|
||||
## mouse reface), play Turn90/Turn180 L/R once while the rig's lerp_angle does the
|
||||
## actual yaw work, then fall back to the idle clip. Retrigger is guarded by the
|
||||
## active-one-shot window.
|
||||
## Both overlays use a clip-length timer (decremented in update(delta)) rather than the
|
||||
## AnimationPlayer.animation_finished signal: it fits the existing pure-polling drive,
|
||||
## needs no signal lifecycle on a RefCounted across load_descriptor() player rebuilds,
|
||||
## and is directly headless-testable by advancing update() calls.
|
||||
##
|
||||
## Wiring (sandbox root, design §14 group C):
|
||||
## var anim := LocomotionAnim.new()
|
||||
## anim.setup(player_rig, character_visual) # after both exist in-tree
|
||||
@@ -32,10 +46,18 @@ signal gait_changed(clip: StringName)
|
||||
## acceptably (tune-by-eye; record the outcome on T-1088 per design §11.9).
|
||||
var skip_phase_seek: bool = false
|
||||
|
||||
var _rig = null # duck-typed rig (locomotion_rig.gd): stance / is_moving / current_speed
|
||||
var _rig = null # duck-typed rig (locomotion_rig.gd): stance / is_moving / current_speed / yaw_target
|
||||
var _visual = null # duck-typed CharacterVisual: play_animation() / get_animation_player()
|
||||
var _current_clip: StringName = &"" # empty = nothing played yet (first play hard-cuts)
|
||||
var _current_moving: bool = false
|
||||
var _current_is_oneshot: bool = false # true while _current_clip is an Enter/Exit/Turn one-shot
|
||||
|
||||
# One-shot overlay state (stance transitions + turn-in-place).
|
||||
var _last_stance: String = "" # stance-change edge detector (empty = not yet seeded)
|
||||
var _turn_ref_yaw: float = 0.0 # yaw-target reference for idle turn-jump detection (rad)
|
||||
var _oneshot_clip: StringName = &"" # active one-shot clip (empty = none)
|
||||
var _oneshot_remaining_s: float = 0.0 # seconds left on the active one-shot (<= 0 = none)
|
||||
var _oneshot_is_turn: bool = false # true if the active one-shot is a turn (idle-only)
|
||||
|
||||
|
||||
## Pure gait table lookup (§6.1). Unknown stances warn and fall back to the Walk row
|
||||
@@ -48,13 +70,22 @@ static func gait(stance: String, is_moving: bool) -> StringName:
|
||||
return StringName(row["moving"]) if is_moving else StringName(row["idle"])
|
||||
|
||||
|
||||
## Clip basename — the part after "lib/", or the whole name when unqualified. Lets the
|
||||
## name-based logic (crouch detection, NATIVE_MPS lookup) stay addressing-agnostic now
|
||||
## that gait clips carry explicit "ual1/..."-style library prefixes (D-248).
|
||||
static func bare(clip: StringName) -> String:
|
||||
var s := String(clip)
|
||||
var slash := s.rfind("/")
|
||||
return s.substr(slash + 1) if slash >= 0 else s
|
||||
|
||||
|
||||
## Pure blend-table selection (§6.3). Any transition into or out of a Crouch_* clip
|
||||
## takes the crouch blend; otherwise the idle/gait edge decides.
|
||||
static func blend_for(
|
||||
from_clip: StringName, to_clip: StringName, from_moving: bool, to_moving: bool
|
||||
) -> float:
|
||||
var blends: Dictionary = SandboxConstants.BLEND
|
||||
if String(from_clip).begins_with("Crouch") or String(to_clip).begins_with("Crouch"):
|
||||
if bare(from_clip).begins_with("Crouch") or bare(to_clip).begins_with("Crouch"):
|
||||
return float(blends["crouch"])
|
||||
if not from_moving and to_moving:
|
||||
return float(blends["idle_to_gait"])
|
||||
@@ -63,6 +94,37 @@ static func blend_for(
|
||||
return float(blends["gait_to_gait"])
|
||||
|
||||
|
||||
## Signed shortest arc from one yaw to another, in [-PI, PI). Local copy so the machine
|
||||
## stays free of any rig-class dependency (it is duck-typed against the rig).
|
||||
static func shortest_arc(from_yaw: float, to_yaw: float) -> float:
|
||||
return wrapf(to_yaw - from_yaw, -PI, PI)
|
||||
|
||||
|
||||
## Stance-transition one-shot for a stance change, or &"" if none applies (§ T-1088).
|
||||
## Destination Enter wins over source Exit — entering the new stance is the salient
|
||||
## action; the only case both apply is Crouch<->Sprint. Same stance in/out -> no clip.
|
||||
static func transition_clip_for(from_stance: String, to_stance: String) -> StringName:
|
||||
if from_stance == to_stance:
|
||||
return &""
|
||||
if SandboxConstants.STANCE_ENTER_CLIP.has(to_stance):
|
||||
return StringName(SandboxConstants.STANCE_ENTER_CLIP[to_stance])
|
||||
if SandboxConstants.STANCE_EXIT_CLIP.has(from_stance):
|
||||
return StringName(SandboxConstants.STANCE_EXIT_CLIP[from_stance])
|
||||
return &""
|
||||
|
||||
|
||||
## Turn-in-place one-shot for a signed idle yaw-target jump (rad), or &"" if the jump is
|
||||
## below TURN_TRIGGER_DEG. >= TURN_180_DEG selects the 180 clip (UAL2); the sign picks
|
||||
## L (positive) vs R (negative) — see TURN_CLIP for the live-verify handedness note.
|
||||
static func turn_clip_for(delta_rad: float) -> StringName:
|
||||
var mag := absf(delta_rad)
|
||||
if mag < deg_to_rad(SandboxConstants.TURN_TRIGGER_DEG):
|
||||
return &""
|
||||
var arc := "180" if mag >= deg_to_rad(SandboxConstants.TURN_180_DEG) else "90"
|
||||
var hand := "L" if delta_rad > 0.0 else "R"
|
||||
return StringName(SandboxConstants.TURN_CLIP["%s_%s" % [arc, hand]])
|
||||
|
||||
|
||||
## The clip currently driven (empty StringName until the first play) — surfaced by
|
||||
## the sandbox root for the DebugHud "clip" readout.
|
||||
func get_current_clip() -> StringName:
|
||||
@@ -85,10 +147,101 @@ func _on_rig_teleported(_pos_m: Vector3) -> void:
|
||||
|
||||
|
||||
## Per-frame drive — call after the rig's own interpolation for the frame.
|
||||
func update(_delta: float) -> void:
|
||||
## Order: (1) first-frame seed, (2) drive an active one-shot to completion, (3) detect a
|
||||
## stance change or an idle yaw-jump and start a new one-shot, (4) otherwise normal gait.
|
||||
func update(delta: float) -> void:
|
||||
if _rig == null or _visual == null:
|
||||
return
|
||||
_apply_state(_rig.stance, _rig.is_moving, _rig.current_speed)
|
||||
var stance: String = _rig.stance
|
||||
var moving: bool = _rig.is_moving
|
||||
var speed: float = _rig.current_speed
|
||||
|
||||
# (1) First-ever frame: hard-cut to the current gait and seed the edge baselines.
|
||||
if _current_clip == &"":
|
||||
_apply_state(stance, moving, speed)
|
||||
_last_stance = stance
|
||||
_turn_ref_yaw = _read_yaw_target()
|
||||
return
|
||||
|
||||
# (2) An active stance-transition / turn one-shot owns the clip until it elapses.
|
||||
if _oneshot_remaining_s > 0.0:
|
||||
_oneshot_remaining_s -= delta
|
||||
# Keep the turn reference fresh so a mouse sweep DURING the one-shot doesn't
|
||||
# burst into a turn the instant it ends.
|
||||
_turn_ref_yaw = _read_yaw_target()
|
||||
# A turn is idle-only: if the rig started moving, abandon it so the leg gait
|
||||
# shows without stalling.
|
||||
if _oneshot_is_turn and moving:
|
||||
_oneshot_remaining_s = 0.0
|
||||
if _oneshot_remaining_s > 0.0:
|
||||
return
|
||||
# Just elapsed — clear and fall through so _apply_state blends into the loop.
|
||||
_oneshot_clip = &""
|
||||
_oneshot_is_turn = false
|
||||
|
||||
# (3a) Stance change -> maybe a transition one-shot (Sprint/Crouch Enter/Exit).
|
||||
if stance != _last_stance:
|
||||
var trans := transition_clip_for(_last_stance, stance)
|
||||
_last_stance = stance
|
||||
if not String(trans).is_empty():
|
||||
_play_oneshot(trans, SandboxConstants.STANCE_TRANSITION_BLEND, false, moving)
|
||||
return
|
||||
|
||||
# (3b) Idle yaw-target jump -> maybe a turn-in-place one-shot.
|
||||
var yaw_target := _read_yaw_target()
|
||||
if not moving:
|
||||
var d := shortest_arc(_turn_ref_yaw, yaw_target)
|
||||
_turn_ref_yaw = yaw_target
|
||||
if absf(d) >= deg_to_rad(SandboxConstants.TURN_TRIGGER_DEG):
|
||||
var turn := turn_clip_for(d)
|
||||
if not String(turn).is_empty():
|
||||
_play_oneshot(turn, SandboxConstants.TURN_BLEND, true, false)
|
||||
return
|
||||
else:
|
||||
# Moving: body yaw follows the legs (D-252); keep the reference current so a
|
||||
# post-stop reface measures only the jump that happens AFTER arrival.
|
||||
_turn_ref_yaw = yaw_target
|
||||
|
||||
# (4) Normal gait selection.
|
||||
_apply_state(stance, moving, speed)
|
||||
|
||||
|
||||
# Rig yaw target (rad, WorldRoot-local) for idle turn-jump detection, duck-typed so a
|
||||
# rig/stub without a yaw_target simply never triggers turns (delta stays 0).
|
||||
func _read_yaw_target() -> float:
|
||||
if _rig != null and "yaw_target" in _rig:
|
||||
return float(_rig.yaw_target)
|
||||
return _turn_ref_yaw
|
||||
|
||||
|
||||
# Play a one-shot overlay (stance transition or turn): crossfade in, hold for the clip's
|
||||
# (speed-scaled) duration, then let update() blend into the target loop. Turns play at
|
||||
# TURN_SPEED_SCALE so the deliberate UAL turn clip doesn't drag far past the rig's snappy
|
||||
# idle yaw ease; transitions play at native rate.
|
||||
func _play_oneshot(clip: StringName, blend: float, is_turn: bool, moving: bool) -> void:
|
||||
var scale := SandboxConstants.TURN_SPEED_SCALE if is_turn else 1.0
|
||||
_visual.play_animation(clip, blend)
|
||||
var player = _visual.get_animation_player()
|
||||
if player != null:
|
||||
player.speed_scale = scale
|
||||
_oneshot_clip = clip
|
||||
_oneshot_is_turn = is_turn
|
||||
_oneshot_remaining_s = _clip_length(clip) / maxf(scale, 0.01)
|
||||
_current_clip = clip
|
||||
_current_moving = moving
|
||||
_current_is_oneshot = true
|
||||
gait_changed.emit(clip)
|
||||
|
||||
|
||||
# Clip length (s) from the real (or stubbed) AnimationPlayer, 0.0 when unavailable.
|
||||
func _clip_length(clip: StringName) -> float:
|
||||
var player = _visual.get_animation_player()
|
||||
if player == null:
|
||||
return 0.0
|
||||
var anim: Animation = player.get_animation(clip)
|
||||
if anim == null:
|
||||
return 0.0
|
||||
return anim.length
|
||||
|
||||
|
||||
## Teleport hard reset (§6.3): a cross-map jump must not smear — replay the correct
|
||||
@@ -96,6 +249,12 @@ func update(_delta: float) -> void:
|
||||
func notify_teleport() -> void:
|
||||
if _rig == null or _visual == null:
|
||||
return
|
||||
# A teleport aborts any in-flight one-shot overlay — the destination pose is a hard
|
||||
# cut, not a continuation of an Enter/Exit/Turn.
|
||||
_oneshot_clip = &""
|
||||
_oneshot_remaining_s = 0.0
|
||||
_oneshot_is_turn = false
|
||||
_current_is_oneshot = false
|
||||
var moving: bool = _rig.is_moving
|
||||
var clip := gait(_rig.stance, moving)
|
||||
var changed := clip != _current_clip
|
||||
@@ -106,6 +265,10 @@ func notify_teleport() -> void:
|
||||
player.seek(0.0, false)
|
||||
_current_clip = clip
|
||||
_current_moving = moving
|
||||
# Re-seed the edge baselines so the post-teleport state does not fire a spurious
|
||||
# stance transition or turn on the next frame.
|
||||
_last_stance = _rig.stance
|
||||
_turn_ref_yaw = _read_yaw_target()
|
||||
_update_speed_scale(clip, moving, _rig.current_speed)
|
||||
if changed:
|
||||
gait_changed.emit(clip)
|
||||
@@ -128,15 +291,17 @@ func _transition_to(clip: StringName, moving: bool) -> void:
|
||||
if not first_play:
|
||||
blend = blend_for(_current_clip, clip, _current_moving, moving)
|
||||
# gait<->gait phase preservation (§6.3): capture the leg beat before switching,
|
||||
# then re-seek into the new clip so feet keep their rhythm across the blend.
|
||||
# then re-seek into the new clip so feet keep their rhythm across the blend. Skipped
|
||||
# when leaving a one-shot (Enter/Exit/Turn) — its phase does not map to a gait loop.
|
||||
var phase := -1.0
|
||||
if not first_play and _current_moving and moving and not skip_phase_seek:
|
||||
if not first_play and _current_moving and moving and not skip_phase_seek and not _current_is_oneshot:
|
||||
phase = _capture_phase()
|
||||
_visual.play_animation(clip, blend)
|
||||
if phase >= 0.0:
|
||||
_seek_phase(clip, phase)
|
||||
_current_clip = clip
|
||||
_current_moving = moving
|
||||
_current_is_oneshot = false
|
||||
gait_changed.emit(clip)
|
||||
|
||||
|
||||
@@ -151,7 +316,7 @@ func _update_speed_scale(clip: StringName, moving: bool, speed: float) -> void:
|
||||
if not moving:
|
||||
player.speed_scale = 1.0
|
||||
return
|
||||
var native: float = SandboxConstants.NATIVE_MPS.get(String(clip), 0.0)
|
||||
var native: float = SandboxConstants.NATIVE_MPS.get(bare(clip), 0.0)
|
||||
if native <= 0.0:
|
||||
player.speed_scale = 1.0 # moving clip missing from NATIVE_MPS — table drift
|
||||
return
|
||||
|
||||
@@ -56,20 +56,23 @@ const TURN_BUDGET_DEG := {
|
||||
|
||||
# -- Animation (design §6) -------------------------------------------------------
|
||||
|
||||
## Gait table (§6.1) — the ONLY place animation clip strings live.
|
||||
## Clip names are the imported bare names: library "", "_Loop" stripped by the glTF
|
||||
## importer, case-sensitive (design-input §2.2). Careful = Walk_Formal (D-053 stance
|
||||
## readability at ortho distance; fallback if it reads "parade march": Walk at 0.6x —
|
||||
## one table cell). Jog_Fwd is the reserve if Sprint reads too aggressive at 2.5 m/s.
|
||||
## Gait table (§6.1) — the ONLY place gait clip strings live. Explicit "lib/Clip"
|
||||
## addressing (D-248 / T-1088): every locomotion loop lives in the UAL1 tier. Names
|
||||
## are the verified imported names (dumped 2026-07-06 from ual1.glb): the glTF importer
|
||||
## strips the "_Loop" suffix and sets loop mode, so "Walk_Loop" -> "Walk" (loop=1).
|
||||
## Careful = Walk_Formal (D-053 stance readability at ortho distance; fallback if it
|
||||
## reads "parade march": Walk at 0.6x — one table cell). Jog_Fwd (ual1) is the reserve
|
||||
## if Sprint reads too aggressive at 2.5 m/s.
|
||||
const GAIT_CLIP := {
|
||||
"Sprint": {"idle": &"Idle", "moving": &"Sprint"},
|
||||
"Walk": {"idle": &"Idle", "moving": &"Walk"},
|
||||
"Careful": {"idle": &"Idle", "moving": &"Walk_Formal"},
|
||||
"Crouch": {"idle": &"Crouch_Idle", "moving": &"Crouch_Fwd"},
|
||||
"Sprint": {"idle": &"ual1/Idle", "moving": &"ual1/Sprint"},
|
||||
"Walk": {"idle": &"ual1/Idle", "moving": &"ual1/Walk"},
|
||||
"Careful": {"idle": &"ual1/Idle", "moving": &"ual1/Walk_Formal"},
|
||||
"Crouch": {"idle": &"ual1/Crouch_Idle", "moving": &"ual1/Crouch_Fwd"},
|
||||
}
|
||||
|
||||
## Native clip ground speed (m/s) for cadence sync (§6.2):
|
||||
## speed_scale = clamp(rig_speed / NATIVE_MPS[clip], SPEED_SCALE_CLAMP.x, .y).
|
||||
## Keyed by clip BASENAME (the part after "lib/") so it is addressing-agnostic.
|
||||
## Initial guesses — tuned live against the DebugHud readout.
|
||||
const NATIVE_MPS := {"Walk": 1.4, "Walk_Formal": 1.2, "Sprint": 3.2, "Crouch_Fwd": 0.9}
|
||||
const SPEED_SCALE_CLAMP := Vector2(0.6, 1.8)
|
||||
@@ -84,6 +87,58 @@ const BLEND := {
|
||||
"teleport": 0.0,
|
||||
}
|
||||
|
||||
# -- Stance-transition one-shots (T-1088; UAL1 Enter/Exit clips) ----------------------
|
||||
#
|
||||
# On a stance change, if the destination stance has an Enter clip OR the source stance
|
||||
# has an Exit clip, play that one-shot (loop=0) and blend into the target loop when it
|
||||
# elapses. The rig position channel is independent, so the character keeps gliding while
|
||||
# the transition plays (movement never stalls). Only Sprint and Crouch have Enter/Exit
|
||||
# clips in UAL1 (verified 2026-07-06); Walk/Careful transition with a plain gait blend.
|
||||
# Priority when both apply (only Crouch<->Sprint): destination Enter wins over source
|
||||
# Exit — "entering the new stance" is the salient action. Live-tune the edge if it reads
|
||||
# wrong. Names are verified imported names (all loop=0 one-shots).
|
||||
const STANCE_ENTER_CLIP := {
|
||||
"Sprint": &"ual1/Sprint_Enter",
|
||||
"Crouch": &"ual1/Crouch_Enter",
|
||||
}
|
||||
const STANCE_EXIT_CLIP := {
|
||||
"Sprint": &"ual1/Sprint_Exit",
|
||||
"Crouch": &"ual1/Crouch_Exit",
|
||||
}
|
||||
## Cross-fade (s) INTO a stance-transition one-shot. The blend back OUT to the target
|
||||
## loop reuses the BLEND table (crouch clips take BLEND.crouch).
|
||||
const STANCE_TRANSITION_BLEND := 0.12
|
||||
|
||||
# -- Turn-in-place one-shots (T-1088; UAL1 Turn90, UAL2 Turn180) ----------------------
|
||||
#
|
||||
# When IDLE and the yaw TARGET jumps (mouse-driven octant snap) by >= TURN_TRIGGER_DEG,
|
||||
# play a turn one-shot synced to the rig's yaw ease, then fall back to the stance idle
|
||||
# clip. L/R is chosen from the sign of the shortest-arc delta; >= TURN_180_DEG uses the
|
||||
# 180 clip (UAL2). Retrigger is guarded — a fresh turn cannot start until the current
|
||||
# one elapses. Octants snap at 45 deg, so effective triggers are 90 deg -> Turn90 and
|
||||
# 135/180 deg -> Turn180. Names are verified imported names (all loop=0 one-shots).
|
||||
## Minimum yaw-target jump (deg) that triggers a turn-in-place. Below this, the rig's
|
||||
## normal yaw ease handles the reface with no clip.
|
||||
const TURN_TRIGGER_DEG := 60.0
|
||||
## At/above this jump (deg), use the Turn180 clip instead of Turn90.
|
||||
const TURN_180_DEG := 135.0
|
||||
## Turn one-shot clips keyed by "<arc>_<hand>". Turn90 is UAL1, Turn180 is UAL2.
|
||||
## Hand: positive shortest-arc yaw delta -> "_L", negative -> "_R" (VERIFY handedness
|
||||
## live — a swap is a one-line fix if the model turns the wrong way).
|
||||
const TURN_CLIP := {
|
||||
"90_L": &"ual1/Turn90_L",
|
||||
"90_R": &"ual1/Turn90_R",
|
||||
"180_L": &"ual2/Turn180_L",
|
||||
"180_R": &"ual2/Turn180_R",
|
||||
}
|
||||
## Cross-fade (s) into and out of a turn one-shot.
|
||||
const TURN_BLEND := 0.15
|
||||
## Playback rate for turn one-shots. The UAL turn clips are deliberate (~1.7-2.0 s); the
|
||||
## rig's idle yaw ease is snappy (~0.3 s for 180 deg), so at 1.0x the turn shuffle drags
|
||||
## long past the settled body. 2.0x is an initial compromise — S9 live-tunes it against
|
||||
## the on-screen yaw-ease duration (raise to shorten the turn, lower for weightier turns).
|
||||
const TURN_SPEED_SCALE := 2.0
|
||||
|
||||
# -- Camera (design §7; D-148, D-015, D-158) -------------------------------------
|
||||
|
||||
## Pitch presets in degrees from horizontal — D-148 preset list is authoritative;
|
||||
|
||||
Reference in New Issue
Block a user