461 lines
18 KiB
GDScript
461 lines
18 KiB
GDScript
class_name PlaytestAvatar
|
||
extends Node3D
|
||
## Playtest-Avatar (Karte t_17a35c59) — SPIEGEL des Bot-Avatars aus dem
|
||
## Quest-Client (scripts/match/bot_avatar.gd + MatchRemoteAvatar), Sly-Wunsch
|
||
## 20.10.: „das Player Model in den Map Editor … genau so wie im Spiel bei
|
||
## den Bots" + „direkt natürlich auch mit den Animationen bitte!".
|
||
##
|
||
## Quelle der Wahrheit: bot_avatar.gd. Dieses Modul übernimmt 1:1:
|
||
## - das GLB assets/avatars/quaternius_ual/ual1_standard.glb (Quaternius
|
||
## UAL, CC0) + das Rifle assets/weapons/m40_rifle.glb an hand_r —
|
||
## aufgelöst über den NACHBAR-KLON ../scopedvr-client (gleiche Auflösung
|
||
## wie GlbMeshStore/TestRun), NICHT dupliziert. Lädt mit GLTFDocument
|
||
## (die GLB liegt außerhalb res:// — kein Import-System).
|
||
## - die AnimationTree-Pipeline (A4/W14): BlendSpace1D Idle/Walk/Jog mit
|
||
## Blend-Punkten auf den Clip-Naturgeschwindigkeiten (0,0 / 0,979 /
|
||
## 2,620 m/s), sync = true, TimeScale-Subtrees für die Kadenz, darüber
|
||
## die StateMachine mit dem Motion-State. Crossfade ~0,2 s.
|
||
## - die Kadenz-Mathe (BOT2/vc23): Kadenz = m/s / Ziel-Schrittlänge
|
||
## (Walk 0,95 m / Jog 0,85 m), TimeScale = Kadenz × Zykluslänge / 2
|
||
## Schritte pro Zyklus, Klammern CADENCE 1,0–4,5 und FOOT_SYNC 0,6–2,0.
|
||
## - die Gait-Schwellen (A2 §6.2): ≤ 0,3 m/s Idle, ≤ 2,6 m/s Walk, sonst
|
||
## Jog — im Editor aus der ECHTEN PlaytestBody-Geschwindigkeit gespeist.
|
||
## - die Frontachse (vc21-Fix): das UAL-GLB blickt nach +Z, der Body-Yaw
|
||
## richtet −Z aus → MODEL_YAW_OFFSET = π auf dem Modell-Kind. Blick-
|
||
## richtung == Bewegungsrichtung.
|
||
## - den Blend-Ruckel-Fix: Sollwerte werden in _process weich nachgezogen
|
||
## (BLEND_SMOOTH_S = 0,25 s), Parameter-Writes nur bei Änderung.
|
||
##
|
||
## NICHT gespiegelt (Match-only, kein Trigger im Editor-Testlauf): Engage/
|
||
## Shoot/Hit/Death-One-Shots, Team-Tönung, Namensschild, Netzwerk-Interpo-
|
||
## lation, Ragdoll. Die StateMachine trägt deshalb nur den Motion-State —
|
||
## die Loop-Pipeline (die Sly sieht) ist identisch zum Spiel.
|
||
##
|
||
## Regel (wie KolliderBau/svmap_tint): Ändert der Client die Pipeline, wird
|
||
## dieser Spiegel MIT geändert. Bewusst KEINE Editor-eigenen Maße oder
|
||
## Anim-Parameter — Proportionen/Konstanten strikt aus der Client-Quelle.
|
||
##
|
||
## Fehlt der Client-Klon (oder die GLB): lade_avatar() gibt null zurück —
|
||
## PlaytestMode bleibt dann komplett in First Person (graceful, kein Crash).
|
||
|
||
## ------------------------------------------------ Quelle (Client-Klon) ----
|
||
## Relative Pfade IM Client-Repo (Nachbar-Klon, nicht dupliziert).
|
||
const MODEL_REL := "assets/avatars/quaternius_ual/ual1_standard.glb"
|
||
const RIFLE_REL := "assets/weapons/m40_rifle.glb"
|
||
|
||
## ------------------------------------- Clips (MatchRemoteAvatar-Erbe) -----
|
||
const CLIP_IDLE := "Idle"
|
||
const CLIP_WALK := "Walk"
|
||
const CLIP_JOG := "Jog_Fwd"
|
||
|
||
## Server-/Inferenz-States (A1-Vertrag, bot_avatar.gd): 0=IDLE 1=WALK 2=JOG.
|
||
const STATE_IDLE := 0
|
||
const STATE_WALK := 1
|
||
const STATE_JOG := 2
|
||
|
||
## Gait-Schwellen (A2 §6.2, bot_avatar.gd): Velocity → State.
|
||
const IDLE_SPEED_MAX_MPS := 0.3
|
||
const WALK_SPEED_MAX_MPS := 2.6
|
||
|
||
## Blend-Punkte = Clip-Naturgeschwindigkeiten (H1-Messung, bot_avatar.gd).
|
||
const WALK_NATURAL_MPS := 0.979
|
||
const JOG_NATURAL_MPS := 2.620
|
||
const BLEND_IDLE_MPS := 0.0
|
||
const BLEND_MAX_MPS := 4.0
|
||
|
||
## Kadenz-Kalibrierung (BOT2/vc23, bot_avatar.gd): Ziel-Schrittlängen + Klammern.
|
||
const WALK_STEP_M := 0.95
|
||
const JOG_STEP_M := 0.85
|
||
const CADENCE_MIN := 1.0
|
||
const CADENCE_MAX := 4.5
|
||
const FOOT_SYNC_MIN := 0.6
|
||
const FOOT_SYNC_MAX := 2.0
|
||
|
||
## Crossfade (A4) + Blend-Glättung (vc21-Ruckel-Fix, bot_avatar.gd).
|
||
const CROSSFADE_S := 0.2
|
||
const BLEND_SMOOTH_S := 0.25
|
||
|
||
## Modell-Anker (bot_avatar.gd): Knoten = Kopf-Anker in Augenhöhe (1,65 m),
|
||
## GLB-Kind hängt mit negativem Y-Versatz (Füße am Boden, Modell 1,83 m).
|
||
const MODEL_EYE_HEIGHT := 1.65
|
||
const MODEL_Y_OFFSET := -1.65
|
||
## Frontachse (vc21-Messung): GLB blickt nach +Z → 180°-Korrektur auf dem
|
||
## Modell-Kind, damit die Front auf das lokale −Z (Body-Vorwärts) zeigt.
|
||
const MODEL_FORWARD_LOCAL := Vector3(0.0, 0.0, 1.0)
|
||
const MODEL_YAW_OFFSET := PI
|
||
|
||
## Rifle-Mount (vc21-Messung, bot_avatar.gd): hand_r, Lauf → Hand-+Y,
|
||
## Rifle-oben → Hand-+Z = reine +90°-X-Drehung; Griff 0,067 m auf Hand-+Y.
|
||
const RIFLE_HAND_BONE := "hand_r"
|
||
const RIFLE_ROT_BASIS := Basis(Vector3(1.0, 0.0, 0.0), deg_to_rad(90.0))
|
||
const RIFLE_POS := Vector3(0.0, 0.067, 0.0)
|
||
|
||
## AnimationTree-Vertrag (bot_avatar.gd — Namen sind Teil des Vertrags).
|
||
const SM_MOTION := "Motion"
|
||
const P_BLEND := "parameters/Motion/Speed/blend_position"
|
||
const P_WALK_SCALE := "parameters/Motion/Speed/Walk/WalkTs/scale"
|
||
const P_JOG_SCALE := "parameters/Motion/Speed/Jog/JogTs/scale"
|
||
const P_PLAYBACK := "parameters/playback"
|
||
|
||
## GLB-Instanz (Modell-Wurzel), AnimationPlayer (Clip-Lieferant), Tree.
|
||
var _model: Node3D = null
|
||
var _anim: AnimationPlayer = null
|
||
var _tree: AnimationTree = null
|
||
var _playback: AnimationNodeStateMachinePlayback = null
|
||
## Sichtbarer Zustand (Tests/Debug): Soll-Clip + Soll-Blend.
|
||
var _cur_clip := ""
|
||
var state := STATE_IDLE
|
||
var speed_mps := 0.0
|
||
var _blend_cur := 0.0
|
||
var _blend_target := 0.0
|
||
var _walk_scale_cur := 1.0
|
||
var _walk_scale_target := 1.0
|
||
var _jog_scale_cur := 1.0
|
||
var _jog_scale_target := 1.0
|
||
|
||
|
||
## Client-Projektordner (Nachbar-Klon oder konfigurierter Pfad) — dieselbe
|
||
## Auflösung wie der alte Client-Start (test_run.gd). "" = keiner gefunden.
|
||
static func client_wurzel() -> String:
|
||
return TestRun.find_client(ProjectSettings.globalize_path("res://"),
|
||
TestRun.load_cfg())
|
||
|
||
|
||
## Absoluter Modell-/Rifle-Pfad im Client-Klon ("" = kein Klon gefunden).
|
||
static func model_pfad() -> String:
|
||
var root := client_wurzel()
|
||
if root.is_empty():
|
||
return ""
|
||
return root.path_join(MODEL_REL)
|
||
|
||
|
||
static func rifle_pfad() -> String:
|
||
var root := client_wurzel()
|
||
if root.is_empty():
|
||
return ""
|
||
return root.path_join(RIFLE_REL)
|
||
|
||
|
||
## State aus der echten Geschwindigkeit (Spiegel von _track_velocity,
|
||
## bot_avatar.gd — dieselben Schwellen, ohne Netzwerk-Glättung).
|
||
static func zustand_fuer(speed: float) -> int:
|
||
if speed <= IDLE_SPEED_MAX_MPS:
|
||
return STATE_IDLE
|
||
if speed <= WALK_SPEED_MAX_MPS:
|
||
return STATE_WALK
|
||
return STATE_JOG
|
||
|
||
|
||
## Avatar aus dem Client-Klon laden (null = fehlt/nicht ladbar → graceful
|
||
## Fallback auf First Person beim Aufrufer). Baut Modell + AnimationTree
|
||
## + Rifle-Mount exakt wie bot_avatar._ready/_build_model/_build_anim_tree.
|
||
static func lade_avatar() -> PlaytestAvatar:
|
||
var glb_pfad := model_pfad()
|
||
if glb_pfad.is_empty() or not FileAccess.file_exists(glb_pfad):
|
||
push_warning("PLAYTEST_AVATAR: Modell fehlt (%s) — Third Person nicht verfügbar" % glb_pfad)
|
||
return null
|
||
var gltf := GLTFDocument.new()
|
||
var state := GLTFState.new()
|
||
if gltf.append_from_file(glb_pfad, state) != OK:
|
||
push_warning("PLAYTEST_AVATAR: GLB nicht ladbar: %s" % glb_pfad)
|
||
return null
|
||
var szene: Node = gltf.generate_scene(state)
|
||
if szene == null:
|
||
push_warning("PLAYTEST_AVATAR: GLB-Szene leer: %s" % glb_pfad)
|
||
return null
|
||
var avatar := PlaytestAvatar.new()
|
||
avatar.name = "Avatar"
|
||
avatar._model = szene as Node3D
|
||
if avatar._model == null:
|
||
push_warning("PLAYTEST_AVATAR: GLB-Wurzel kein Node3D: %s" % glb_pfad)
|
||
szene.free()
|
||
return null
|
||
avatar._model.name = "Body"
|
||
# Kopf-Anker-Konvention (bot_avatar._build_model): das GLB-Kind hängt mit
|
||
# MODEL_Y_OFFSET nach unten (GLB-Ursprung = Füße) — der Avatar-Node selbst
|
||
# sitzt bei PlaytestBody in Augenhöhe über dem Füßpunkt (set_avatar).
|
||
# vc21-Frontachse: GLB blickt nach +Z → 180° auf das Modell-Kind, damit
|
||
# die Modell-Front auf das Avatar-lokale −Z zeigt (= Body-Vorwärts).
|
||
avatar._model.position = Vector3(0.0, MODEL_Y_OFFSET, 0.0)
|
||
avatar._model.rotation.y = MODEL_YAW_OFFSET
|
||
avatar.add_child(avatar._model)
|
||
for child in avatar._model.find_children("*", "AnimationPlayer", true, false):
|
||
avatar._anim = child
|
||
if avatar._anim == null:
|
||
push_warning("PLAYTEST_AVATAR: kein AnimationPlayer im GLB — Avatar unbrauchbar")
|
||
avatar.free()
|
||
return null
|
||
# Importer-Semantik nachbilden: der Godot-Editor-Import (Client .import)
|
||
# streicht Animations-Namens-ENDUNGEN "-loop"/"_loop" und setzt dann
|
||
# Loop-Mode LOOP_LINEAR. GLTFDocument (Runtime-Ladung) liefert die rohen
|
||
# Namen ("Idle_Loop", "Walk_Loop", …) OHNE Loop-Mode — ohne diese
|
||
# Normalisierung hätten die Loops den falschen Namen (Bot-Vertrag: Idle/
|
||
# Walk/Jog_Fwd) und würden nach einem Zyklus stehen bleiben.
|
||
avatar._normalisiere_clips()
|
||
if avatar._anim.get_animation_list().is_empty():
|
||
push_warning("PLAYTEST_AVATAR: kein AnimationPlayer/Clips im GLB — Avatar unbrauchbar")
|
||
avatar.free()
|
||
return null
|
||
avatar._baue_anim_tree()
|
||
avatar._baue_rifle()
|
||
# Start-Anim: Idle (wie bot_avatar._ready — kein T-Pose-Blitz).
|
||
avatar.apply_state(STATE_IDLE, 0.0)
|
||
return avatar
|
||
|
||
|
||
## Importer-Semantik (siehe Aufrufkommentar): "_loop"/"-loop"-Endungen
|
||
## streichen, LOOP_LINEAR setzen — genau das, was der Client-.import mit
|
||
## derselben GLB macht (dort heißen die Clips Idle/Walk/Jog_Fwd, Loop-Mode
|
||
## im Asset korrekt, siehe bot_avatar.gd-Clip-Doku).
|
||
func _normalisiere_clips() -> void:
|
||
for lib_name in _anim.get_animation_library_list():
|
||
var lib := _anim.get_animation_library(lib_name)
|
||
for clip_name in lib.get_animation_list():
|
||
var animation: Animation = lib.get_animation(clip_name)
|
||
var lower := String(clip_name).to_lower()
|
||
if lower.ends_with("_loop") or lower.ends_with("-loop"):
|
||
var clean := String(clip_name).substr(0, String(clip_name).length() - 5)
|
||
lib.rename_animation(clip_name, clean)
|
||
animation.loop_mode = Animation.LOOP_LINEAR
|
||
|
||
|
||
## AnimationTree (A4-Pipeline, Motion-Zweig): BlendSpace1D Idle/Walk/Jog +
|
||
## TimeScale-Subtrees, darüber die StateMachine mit dem Motion-State.
|
||
## Verdrahtungs-Fallen (bot_avatar.gd A4-Sektion, Probe 18.10.): Clips als
|
||
## EIGENE Library auf die Tree kopieren (anim_player-Compatibility-Pfad
|
||
## überspringt die GLB-Default-Library), root_node = Modell-Wurzel
|
||
## (Track-Pfade "Armature/Skeleton3D:*"), Player bleibt reiner Clip-Lieferant.
|
||
func _baue_anim_tree() -> void:
|
||
_anim.stop()
|
||
var tree := AnimationTree.new()
|
||
tree.name = "AnimTree"
|
||
_model.add_child(tree)
|
||
var lib := AnimationLibrary.new()
|
||
for clip in _anim.get_animation_list():
|
||
lib.add_animation(clip, _anim.get_animation(clip))
|
||
tree.add_animation_library("", lib)
|
||
tree.root_node = tree.get_path_to(_model)
|
||
var motion := AnimationNodeBlendTree.new()
|
||
var blend := AnimationNodeBlendSpace1D.new()
|
||
var idle := AnimationNodeAnimation.new()
|
||
idle.animation = CLIP_IDLE
|
||
blend.add_blend_point(idle, BLEND_IDLE_MPS, 0, "Idle")
|
||
blend.add_blend_point(_speed_scaled_clip(CLIP_WALK, "WalkAnim", "WalkTs"),
|
||
WALK_NATURAL_MPS, 1, "Walk")
|
||
blend.add_blend_point(_speed_scaled_clip(CLIP_JOG, "JogAnim", "JogTs"),
|
||
JOG_NATURAL_MPS, 2, "Jog")
|
||
blend.min_space = 0.0
|
||
blend.max_space = BLEND_MAX_MPS
|
||
blend.sync = true # vc21: gemeinsame Phase beim Blenden (kein Fuß-Pop)
|
||
motion.add_node("Speed", blend)
|
||
motion.connect_node("output", 0, "Speed")
|
||
var sm := AnimationNodeStateMachine.new()
|
||
sm.add_node(SM_MOTION, motion)
|
||
tree.tree_root = sm
|
||
tree.active = true
|
||
tree.process_callback = AnimationTree.ANIMATION_PROCESS_IDLE
|
||
_tree = tree
|
||
# _playback bewusst NICHT hier holen: der Baum hängt noch nicht im
|
||
# Szenenbaum — tree.get(P_PLAYBACK) liefert vorher null. Der Motion-
|
||
# Start passiert in apply_state() über _playback_holen() (nach dem
|
||
# Baum-Eintritt, wie _ensure_motion im Client).
|
||
|
||
|
||
## A4-Helfer (1:1 bot_avatar.gd): Clip-Subtree mit AnimationNodeTimeScale.
|
||
func _speed_scaled_clip(clip: String, anim_name: String, ts_name: String) -> AnimationNodeBlendTree:
|
||
var bt := AnimationNodeBlendTree.new()
|
||
var an := AnimationNodeAnimation.new()
|
||
an.animation = clip
|
||
var ts := AnimationNodeTimeScale.new()
|
||
bt.add_node(anim_name, an)
|
||
bt.add_node(ts_name, ts)
|
||
bt.connect_node(ts_name, 0, anim_name)
|
||
bt.connect_node("output", 0, ts_name)
|
||
return bt
|
||
|
||
|
||
## Rifle an hand_r (1:1 bot_avatar._build_rifle). Fehlt das Rifle-GLB oder
|
||
## der Knochen: warnen und ohne Waffe weiter (Avatar bleibt brauchbar).
|
||
func _baue_rifle() -> void:
|
||
var rifle_pfad_datei := rifle_pfad()
|
||
if rifle_pfad_datei.is_empty() or not FileAccess.file_exists(rifle_pfad_datei):
|
||
push_warning("PLAYTEST_AVATAR: Rifle fehlt (%s) — Avatar ohne Waffe" % rifle_pfad_datei)
|
||
return
|
||
var skeleton := _skeleton()
|
||
if skeleton == null:
|
||
push_warning("PLAYTEST_AVATAR: kein Skeleton3D — Rifle bleibt unattached")
|
||
return
|
||
if skeleton.find_bone(RIFLE_HAND_BONE) < 0:
|
||
push_warning("PLAYTEST_AVATAR: Hand-Knochen '%s' fehlt — Rifle bleibt unattached" % RIFLE_HAND_BONE)
|
||
return
|
||
var gltf := GLTFDocument.new()
|
||
var state := GLTFState.new()
|
||
if gltf.append_from_file(rifle_pfad_datei, state) != OK:
|
||
push_warning("PLAYTEST_AVATAR: Rifle-GLB nicht ladbar: %s" % rifle_pfad_datei)
|
||
return
|
||
var szene: Node = gltf.generate_scene(state)
|
||
if szene == null:
|
||
return
|
||
var attach := BoneAttachment3D.new()
|
||
attach.name = "RifleMount"
|
||
attach.bone_name = RIFLE_HAND_BONE
|
||
skeleton.add_child(attach)
|
||
var rifle := szene as Node3D
|
||
rifle.name = "Rifle"
|
||
rifle.basis = RIFLE_ROT_BASIS
|
||
rifle.position = RIFLE_POS
|
||
attach.add_child(rifle)
|
||
|
||
|
||
## Erstes Skeleton3D unter dem GLB (bot_avatar._build_model-Suche).
|
||
func _skeleton() -> Skeleton3D:
|
||
if _model == null:
|
||
return null
|
||
for child in _model.find_children("*", "Skeleton3D", true, false):
|
||
return child
|
||
return null
|
||
|
||
|
||
## ---------------------------------------------------------------- Anim ----
|
||
|
||
## Playtest-Movement-State anlegen (Kartenvorgabe: aus der ECHTEN Bewegung).
|
||
## mps < 0 = "nicht bekannt" → Kadenz unverändert lassen (wie bot_avatar).
|
||
func apply_state(new_state: int, new_speed_mps: float = -1.0) -> void:
|
||
if new_state != STATE_IDLE and new_state != STATE_WALK and new_state != STATE_JOG:
|
||
return # unbekannter State: nichts ändern (wie _clip_for_state "").
|
||
state = new_state
|
||
if new_speed_mps >= 0.0:
|
||
speed_mps = new_speed_mps
|
||
var clip := _clip_for_state(state)
|
||
if clip.is_empty():
|
||
return
|
||
_cur_clip = clip
|
||
if state == STATE_WALK:
|
||
_blend_target = WALK_NATURAL_MPS
|
||
_walk_scale_target = gait_scale_for(state, speed_mps)
|
||
_jog_scale_target = 1.0
|
||
elif state == STATE_JOG:
|
||
_blend_target = JOG_NATURAL_MPS
|
||
_jog_scale_target = gait_scale_for(state, speed_mps)
|
||
_walk_scale_target = 1.0
|
||
else:
|
||
_blend_target = BLEND_IDLE_MPS
|
||
_walk_scale_target = 1.0
|
||
_jog_scale_target = 1.0
|
||
if not is_processing():
|
||
# Ohne _process-Treiber (Tests vor dem ersten Frame) sofort schreiben.
|
||
_blend_cur = _blend_target
|
||
_walk_scale_cur = _walk_scale_target
|
||
_jog_scale_cur = _jog_scale_target
|
||
_write_blend_params()
|
||
var playback := _playback_holen()
|
||
if playback != null:
|
||
# Wie bot_avatar._ensure_motion: Loop-State halten bzw. zurück.
|
||
# Der pre-tree start() der StateMachine kann beim Baum-Eintritt
|
||
# verworfen werden (leerer Knoten) → hier sauber (neu) starten.
|
||
var node := String(playback.get_current_node())
|
||
if node.is_empty():
|
||
playback.start(SM_MOTION)
|
||
elif node != SM_MOTION:
|
||
playback.travel(SM_MOTION)
|
||
|
||
|
||
## Bot2-Kadenz (1:1 bot_avatar.gd): Kadenz = m/s / Ziel-Schrittlänge, geklemmt.
|
||
func gait_cadence_for(s: int, mps: float) -> float:
|
||
var step := WALK_STEP_M if s == STATE_WALK else JOG_STEP_M
|
||
if s != STATE_WALK and s != STATE_JOG:
|
||
return 0.0
|
||
return clampf(mps / maxf(step, 0.05), CADENCE_MIN, CADENCE_MAX)
|
||
|
||
|
||
## Bot2-TimeScale (1:1 bot_avatar.gd): Kadenz × Zykluslänge / 2 Schritte.
|
||
func gait_scale_for(s: int, mps: float) -> float:
|
||
var cadence := gait_cadence_for(s, mps)
|
||
if cadence <= 0.0:
|
||
return 1.0
|
||
var cycle := _cycle_len_for(s)
|
||
if cycle <= 0.0:
|
||
return 1.0
|
||
return clampf(cadence * cycle / 2.0, FOOT_SYNC_MIN, FOOT_SYNC_MAX)
|
||
|
||
|
||
func _cycle_len_for(s: int) -> float:
|
||
var clip := _clip_for_state(s)
|
||
if clip.is_empty() or _anim == null or not _anim.has_animation(clip):
|
||
return 0.0
|
||
return _anim.get_animation(clip).length
|
||
|
||
|
||
## StateMachine-Playback faul holen: erst NACH dem Baum-Eintritt kann die
|
||
## Tree den "parameters/playback"-Parameter auflösen (vorher null).
|
||
func _playback_holen() -> AnimationNodeStateMachinePlayback:
|
||
if _playback == null and _tree != null:
|
||
_playback = _tree.get(P_PLAYBACK) as AnimationNodeStateMachinePlayback
|
||
return _playback
|
||
|
||
|
||
## Clip-Auflösung (1:1 bot_avatar._clip_for_state, ohne ENGAGE — Match-only).
|
||
func _clip_for_state(s: int) -> String:
|
||
match s:
|
||
STATE_IDLE:
|
||
return CLIP_IDLE
|
||
STATE_WALK:
|
||
return CLIP_WALK
|
||
STATE_JOG:
|
||
return CLIP_JOG
|
||
_:
|
||
return ""
|
||
|
||
|
||
## vc21: Welt-Richtung, in die das MESH blickt (Vertrag wie
|
||
## bot_avatar.model_forward: Front == Bewegungsrichtung). Hier wird die
|
||
## ECHTE Modell-Transform gemessen — der MODEL_YAW_OFFSET sitzt in diesem
|
||
## Spiegel auf dem Modell-Kind (nicht auf dem Node wie im Client), die
|
||
## Messung über _model.global_transform ist davon unabhängig korrekt.
|
||
func model_forward() -> Vector3:
|
||
if _model == null:
|
||
return Vector3(0.0, 0.0, -1.0)
|
||
var f: Vector3 = _model.global_transform.basis * MODEL_FORWARD_LOCAL
|
||
f.y = 0.0
|
||
return f.normalized()
|
||
|
||
|
||
## Aktueller StateMachine-State ("" ohne Baum) — Tests/Debug.
|
||
func tree_state() -> String:
|
||
var playback := _playback_holen()
|
||
if playback == null:
|
||
return ""
|
||
return String(playback.get_current_node())
|
||
|
||
|
||
## Soll-Clip des aktuellen States (wie bot_avatar.current_clip, ohne One-Shots).
|
||
func current_clip() -> String:
|
||
return _cur_clip
|
||
|
||
|
||
## AnimationTree aktiv? (Tests: "animiert überhaupt".)
|
||
func anim_running() -> bool:
|
||
return _tree != null and _tree.active
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
# vc21: Blend/Kadenz weich nachziehen (kein sichtbares Springen).
|
||
if _tree == null:
|
||
return
|
||
var k := clampf(delta / BLEND_SMOOTH_S, 0.0, 1.0)
|
||
_blend_cur = lerpf(_blend_cur, _blend_target, k)
|
||
_walk_scale_cur = lerpf(_walk_scale_cur, _walk_scale_target, k)
|
||
_jog_scale_cur = lerpf(_jog_scale_cur, _jog_scale_target, k)
|
||
_write_blend_params()
|
||
|
||
|
||
## vc21: geglätteten Stand in die Tree schreiben (nur bei Änderung).
|
||
func _write_blend_params() -> void:
|
||
if _tree == null:
|
||
return
|
||
if absf(float(_tree.get(P_BLEND)) - _blend_cur) > 0.0005:
|
||
_tree.set(P_BLEND, _blend_cur)
|
||
if absf(float(_tree.get(P_WALK_SCALE)) - _walk_scale_cur) > 0.0005:
|
||
_tree.set(P_WALK_SCALE, _walk_scale_cur)
|
||
if absf(float(_tree.get(P_JOG_SCALE)) - _jog_scale_cur) > 0.0005:
|
||
_tree.set(P_JOG_SCALE, _jog_scale_cur)
|