scopedvr-map-core/editor/playtest/playtest_avatar.gd
2026-11-01 19:31:33 +01:00

461 lines
18 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)