From 6f00737dd3b97906b8b2ca0b23f88e1adb910c34 Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Tue, 20 Oct 2026 21:18:25 +0000 Subject: [PATCH] Playtest-Avatar (t_17a35c59): Spieler-Modell = Bot-Rig + First/Third-Person (Taste V) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - playtest_avatar.gd (NEU): Spiegel-Modul des Bot-Avatars (Quelle bot_avatar.gd/MatchRemoteAvatar) — UAL-GLB + m40-Rifle aus dem Nachbar-Klon ../scopedvr-client (GLTFDocument, kein Duplikat), AnimationTree-Pipeline 1:1 (BlendSpace1D Idle/Walk/Jog auf den Clip-Naturgeschwindigkeiten 0/0,979/2,620 m/s, sync, TimeScale-Kadenz nach BOT2: Schrittlängen 0,95/0,85 m, Klammern 1,0–4,5 / 0,6–2,0, Blend-Glättung 0,25 s), Gait-Schwellen 0,3/2,6 m/s aus der ECHTEN PlaytestBody-Geschwindigkeit, Frontachse +Z → MODEL_YAW_OFFSET = PI. Importer-Semantik nachgebildet: "_loop"-Endungen gestrichen + LOOP_LINEAR (GLTFDocument liefert rohe GLTF-Namen). - playtest_body.gd: Körper trägt den Blick-Yaw (1:1 TestmapPlayer rotate_y — vorher weltachsen-fixierte WASD-Bewegung, Beweis-Probe), Avatar-Halterung (Kopf-Anker 1,65 m, GLB-Füße am Boden), Third-Person- SpringArm3D (3,5 m + 0,3 m Margin, eigener Body ausgeschlossen), toggle_perspektive/set_perspektive, horizontale_geschwindigkeit(). - playtest_settings.gd: persistenter Eintrag dritte_person (Default First). - playtest_mode.gd: Avatar-Bau in enter() (graceful ohne Client-Klon: First Person + Klartext-HUD-Hinweis), Taste V, Gait-Speisung pro Frame, HUD-Hinweis + F1-Hilfe erweitert. - settings_panel.gd: Perspektive-Auswahl (Erste/Dritte) im Einstellungen- Panel, live wirksam + persistiert. - tests/test_playtest_avatar.gd (NEU, 84 checks): Identitäts-Vertrag gegen die Client-Quelle (Konstanten aus bot_avatar/match_remote_avatar/ testmap_player gelesen), GLB-Ladung, Anim-Pipeline, Kadenz-Mathe, Modellhöhe == Kapsel == Client (1,83 m), Rifle-Mount, V-Umschalter + Persistenz, SpringArm (Blick zum Körper, kein Clipping durch Wand), Blickrichtung == Bewegungsrichtung, Playtest-Integration (enter + Gait). --- src/editor/playtest/playtest_avatar.gd | 461 +++++++++++++++++++++ src/editor/playtest/playtest_avatar.gd.uid | 1 + src/editor/playtest/playtest_body.gd | 123 +++++- src/editor/playtest/playtest_mode.gd | 58 ++- src/editor/playtest/playtest_settings.gd | 31 ++ src/ui/panels/settings_panel.gd | 27 ++ tests/test_playtest_avatar.gd | 429 +++++++++++++++++++ tests/test_playtest_avatar.gd.uid | 1 + 8 files changed, 1128 insertions(+), 3 deletions(-) create mode 100644 src/editor/playtest/playtest_avatar.gd create mode 100644 src/editor/playtest/playtest_avatar.gd.uid create mode 100644 tests/test_playtest_avatar.gd create mode 100644 tests/test_playtest_avatar.gd.uid diff --git a/src/editor/playtest/playtest_avatar.gd b/src/editor/playtest/playtest_avatar.gd new file mode 100644 index 0000000..8554ed2 --- /dev/null +++ b/src/editor/playtest/playtest_avatar.gd @@ -0,0 +1,461 @@ +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) diff --git a/src/editor/playtest/playtest_avatar.gd.uid b/src/editor/playtest/playtest_avatar.gd.uid new file mode 100644 index 0000000..4651d03 --- /dev/null +++ b/src/editor/playtest/playtest_avatar.gd.uid @@ -0,0 +1 @@ +uid://cvj6x35cenpbm diff --git a/src/editor/playtest/playtest_body.gd b/src/editor/playtest/playtest_body.gd index 12bb8b9..b891550 100644 --- a/src/editor/playtest/playtest_body.gd +++ b/src/editor/playtest/playtest_body.gd @@ -99,15 +99,61 @@ func _ready() -> void: look_cam.far = 4000.0 add_child(look_cam) look_cam.current = true + _baue_dritte_kamera() _apply_look() -## Blickrichtung auf die Kamera übertragen (first person: Kamera schaut -Z). +## Third-Person-Kamera (Karte t_17a35c59): SpringArm3D = RayCast hinter dem +## Spieler — die Kamera wird an die Kollision zurückgezogen (kein Clipping +## durch Wände). Pivot auf Augenhöhe, Arm-Länge 3,5 m + 0,3 m Margin, der +## eigene Body ist ausgeschlossen (sonst trifft der Strahl die eigene Kapsel). +func _baue_dritte_kamera() -> void: + spring = SpringArm3D.new() + spring.name = "ThirdPersonArm" + spring.position = Vector3(0.0, EYE_HEIGHT, 0.0) + spring.spring_length = 3.5 + spring.margin = 0.3 + spring.collision_mask = 1 + tpp_cam = Camera3D.new() + tpp_cam.name = "ThirdPersonCamera" + tpp_cam.far = 4000.0 + spring.add_child(tpp_cam) + add_child(spring) + spring.add_excluded_object(get_rid()) + tpp_cam.current = false + + +## Kinder: die Third-Person-Kamera (SpringArm + Kamera) — Karte t_17a35c59. +var spring: SpringArm3D = null +var tpp_cam: Camera3D = null + +## Playtest-Avatar (Spiegel des Bot-Rigs, playtest_avatar.gd). null = kein +## Client-Klon/Modell → Third Person bleibt gesperrt (graceful Fallback). +var avatar: Node3D = null + +## Perspektive (Taste V, persistiert in PlaytestSettings): false = First +## Person (Standard), true = Third Person (Avatar sichtbar, Kamera hinten). +var perspektive_dritte := false + + +## Blickrichtung auf Körper + Kameras übertragen. +## Der BODY rotiert mit dem Blick-Yaw (1:1 TestmapPlayer._unhandled_input +## rotate_y) — damit folgt die WASD-Bewegung der Blickrichtung wie im +## Client. Vor t_17a35c59 blieb der Body ungedreht (Beweis-Probe +## tests/probe_move_dir.gd: yaw −90° + W → −Z statt +X = weltachsen-fixiert, +## Abweichung vom Client-Mirror). func _apply_look() -> void: + # 1:1 TestmapPlayer: der BODY trägt den Yaw (rotate_y), die Kamera nur + # den Pitch — sonst doppelt sich die Drehung (Kamera hängt am Körper). + rotation.y = deg_to_rad(yaw_deg) if look_cam == null: return - look_cam.rotation = Vector3(deg_to_rad(pitch_deg), deg_to_rad(yaw_deg), 0.0) + look_cam.rotation = Vector3(deg_to_rad(pitch_deg), 0.0, 0.0) look_cam.position = Vector3(0.0, EYE_HEIGHT, 0.0) + # Third Person: die SpringArm-Kamera teilt dieselbe Blickachse (Yaw über + # den Körper, Pitch über den Arm) — Blickrichtung == Kamerarichtung. + if spring != null: + spring.rotation = Vector3(deg_to_rad(pitch_deg), 0.0, 0.0) ## Füß-Höhe über der Welt (Kapsel-Mitte minus Halbhöhe) — HUD/Tests. @@ -457,3 +503,76 @@ func _sample(xi: int, zi: int) -> float: func set_sensitivity(mult: float) -> void: var m := clampf(mult, PlaytestSettings.MIN_SENS, PlaytestSettings.MAX_SENS) mouse_sens = DEFAULT_MOUSE_SENS * m + + +# ------------------------------------------ Avatar + Perspektive (V) ------- +# Karte t_17a35c59: derselbe Avatar wie die Bots im Spiel (Spiegel-Modul +# playtest_avatar.gd, GLB aus dem Nachbar-Klon), umschaltbar First/Third. + +## Avatar (aus PlaytestAvatar.lade_avatar) anhängen. Anker: Kopf-Anker- +## Konvention des Clients (bot_avatar.gd) — Avatar-Node in Augenhöhe +## (MODEL_EYE_HEIGHT 1,65 m) über dem Füßpunkt, GLB-Kind hängt mit +## MODEL_Y_OFFSET nach unten. null = kein Modell (Third bleibt gesperrt). +func set_avatar(node: Node3D) -> void: + if avatar != null and is_instance_valid(avatar): + avatar.queue_free() + avatar = node + if avatar == null: + perspektive_dritte = false + _apply_perspektive() + return + # add_child reparentet NICHT — ein Node mit altem Parent würde still + # abgelehnt (Probe 20.10.: Avatar blieb unter root). Erst lösen, dann + # als Kind anhängen. + if avatar.get_parent() != null: + avatar.get_parent().remove_child(avatar) + add_child(avatar) + avatar.position = Vector3(0.0, + PlaytestAvatar.MODEL_EYE_HEIGHT - HALF_H, 0.0) + # Start-Zustand sauber in den Baum schreiben (apply_state vor add_child + # könnte Tree-Parameter noch nicht flushen). + avatar.call("apply_state", PlaytestAvatar.STATE_IDLE, 0.0) + _apply_perspektive() + + +## Taste V: Perspektive umschalten. Rückgabe: neuer Zustand (true = Third). +## Ohne Avatar bleibt First Person aktiv (graceful Fallback — Meldung macht +## der Aufrufer/PlaytestMode). +func toggle_perspektive() -> bool: + var neu := not perspektive_dritte + if avatar == null: + neu = false + return set_perspektive(neu) + + +## Perspektive direkt setzen (Enter-Pfad liest PlaytestSettings). Rückgabe: +## der tatsächlich aktive Zustand (ohne Avatar nie Third). +func set_perspektive(dritte: bool) -> bool: + if avatar == null: + dritte = false + perspektive_dritte = dritte + _apply_perspektive() + return perspektive_dritte + + +## Sichtbarkeit + aktive Kamera je Perspektive: +## First Person — Avatar unsichtbar (Kapsel bleibt physisch aktiv, +## Kamera auf Augenhöhe wie Client-TestmapPlayer), +## Third Person — Avatar sichtbar, SpringArm-Kamera hinter/über dem +## Spieler mit Wand-Kollision (kein Clipping). +func _apply_perspektive() -> void: + if avatar != null and is_instance_valid(avatar): + avatar.visible = perspektive_dritte + if look_cam != null: + look_cam.current = not perspektive_dritte + if tpp_cam != null: + tpp_cam.current = perspektive_dritte + + +## Echte horizontale Geschwindigkeit (m/s) aus dem letzten Physik-Schritt — +## Quelle für die Avatar-Gait-Speisung (Stillstand/Walk/Jog aus der realen +## Bewegung, nicht aus dem Input-Wunsch). +func horizontale_geschwindigkeit() -> float: + var v := velocity + v.y = 0.0 + return v.length() diff --git a/src/editor/playtest/playtest_mode.gd b/src/editor/playtest/playtest_mode.gd index ad620a3..741944b 100644 --- a/src/editor/playtest/playtest_mode.gd +++ b/src/editor/playtest/playtest_mode.gd @@ -24,6 +24,7 @@ extends Node const BODY_SCRIPT := "res://src/editor/playtest/playtest_body.gd" const BUILD_OPS_SCRIPT := "res://src/editor/build_ops.gd" const KOLLIDER_SCRIPT := "res://src/svmap/kollider_bau.gd" +const AVATAR_SCRIPT := "res://src/editor/playtest/playtest_avatar.gd" ## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik) var body: Node3D = null @@ -55,6 +56,9 @@ var _axis_node: Node3D = null var _fps_smooth := 0.0 ## Ist der Lauf-Modus gerade aktiv? (Reentrancy-Schutz für enter/exit) var active := false +## Avatar-Modelle waren beim Start verfügbar? (Karte t_17a35c59 — ohne +## Client-Klon bleibt Third Person gesperrt, graceful Fallback.) +var _avatar_da := false signal exit_requested @@ -93,6 +97,21 @@ func enter(doc: Dictionary, view_builder: Node3D, start_override: Variant = null # gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis. body.call("set_sensitivity", PlaytestSettings.load_sens()) + # Playtest-Avatar (Karte t_17a35c59): derselbe Rig wie die Bots im Spiel + # (GLB aus dem Nachbar-Klon ../scopedvr-client). Fehlt der Klon/Modell: + # graceful — First Person bleibt, Third Person wird per Meldung gesperrt. + var avatar: Node3D = load(AVATAR_SCRIPT).call("lade_avatar") as Node3D + _avatar_da = avatar != null + if _avatar_da: + body.call("set_avatar", avatar) + # Persistierte Perspektive (Taste V): Third nur mit Avatar — sonst + # First Person wie bisher + Klartext-Hinweis. + if PlaytestSettings.load_third(): + if _avatar_da: + body.call("set_perspektive", true) + else: + show_message("Gespeicherte Third-Person-Einstellung ist ohne Avatar-Modelle nicht verfügbar (Client-Klon ../scopedvr-client fehlt) — First Person aktiv.") + _build_hud() exit_requested.connect(_on_exit_requested, CONNECT_ONE_SHOT) @@ -212,7 +231,7 @@ func _build_hud() -> void: hud_hint = Label.new() hud_msg = Label.new() hud_dir = Label.new() - hud_hint.text = "WASD + Maus · Shift = rennen · Space = springen/steigen · G = Fliegen · 1-4 = Spawnpunkt · T = Test-Start · H = HUD · X = Achsen · F1 = Hilfe · ESC = zurück zum Editor" + hud_hint.text = "WASD + Maus · Shift = rennen · Space = springen/steigen · G = Fliegen · V = 1./3.-Person · 1-4 = Spawnpunkt · T = Test-Start · H = HUD · X = Achsen · F1 = Hilfe · ESC = zurück zum Editor" hud_msg.text = "" for l: Label in [hud_pos, hud_dir, hud_fps, hud_hint, hud_msg]: l.add_theme_font_size_override("font_size", 16) @@ -252,6 +271,7 @@ func teleport_crosshair() -> Dictionary: func _process(delta: float) -> void: if not active or body == null: return + _speise_avatar() # FPS glätten (Anzeige-Genauigkeit, keine Physik-Betroffenheit) if _fps_smooth <= 0.0: _fps_smooth = 1.0 / maxf(delta, 0.0001) @@ -298,6 +318,8 @@ func _unhandled_input(event: InputEvent) -> void: match key: KEY_G: toggle_fly() + KEY_V: + toggle_perspektive() KEY_1: jump_to_spawn(1) KEY_2: @@ -325,6 +347,39 @@ func apply_sensitivity(mult: float) -> void: body.call("set_sensitivity", mult) +## Taste V (Karte t_17a35c59): First/Third-Person umschalten. Der neue +## Zustand wird persistiert (PlaytestSettings, wirksam über Neustarts). +## Ohne Avatar-Modelle (kein Client-Klon ../scopedvr-client) bleibt First +## Person aktiv und der Grund steht im HUD — kein Crash (graceful Fallback). +func toggle_perspektive() -> bool: + if body == null: + return false + var neu: bool = body.call("toggle_perspektive") + if not _avatar_da: + show_message("Kein Avatar-Modell gefunden (Client-Klon ../scopedvr-client fehlt) — Third Person nicht verfügbar.") + return false + PlaytestSettings.save_third(neu) + if neu: + show_message("Dritte Person AN — derselbe Avatar wie die Bots im Spiel. V = zurück zur ersten Person.") + else: + show_message("Erste Person AN (Standard). V = dritte Person.") + return neu + + +## Avatar-Gait aus der ECHTEN Bewegung speisen (Kartenvorgabe: Stillstand/ +## Walk/Jog aus der tatsächlichen Geschwindigkeit, Blickrichtung == +## Bewegungsrichtung über die Body-Rotation). Jeden Frame — die Kadenz- +## Mathe des Avatars glättet selbst (BLEND_SMOOTH_S, wie die Bots). +func _speise_avatar() -> void: + if body == null: + return + var av: Node3D = body.get("avatar") as Node3D + if av == null or not is_instance_valid(av): + return + var speed: float = body.call("horizontale_geschwindigkeit") + av.call("apply_state", PlaytestAvatar.zustand_fuer(speed), speed) + + ## Fliegen / God-Mode umschalten (Taste G). Rückgabe: neuer Zustand. func toggle_fly() -> bool: if body == null: @@ -431,6 +486,7 @@ func _build_help() -> void: WASD — bewegen · Maus — umsehen Shift — rennen · Space — springen / im Flug steigen G — Fliegen (God-Mode) an/aus: keine Gravitation, Blickrichtung fliegen +V — Perspektive umschalten (erste / dritte Person) 1 — Sprung zu Team A (Spawnpunkt) · 2 — Team B 3 / 4 — weitere Spawnpunkte (Zonen 2/3) T — Test-Start auf den Blickpunkt setzen (sofort dort) diff --git a/src/editor/playtest/playtest_settings.gd b/src/editor/playtest/playtest_settings.gd index 8e71780..f7b5360 100644 --- a/src/editor/playtest/playtest_settings.gd +++ b/src/editor/playtest/playtest_settings.gd @@ -18,9 +18,14 @@ const KEY_SENS := "maus_empfindlichkeit" const DEFAULT_SENS := 1.0 const MIN_SENS := 0.1 const MAX_SENS := 5.0 +## Perspektive im Testlauf (Karte t_17a35c59, Taste V): false = First Person +## (Standard, wie bisher), true = Third Person (Avatar sichtbar). +const KEY_THIRD := "dritte_person" ## Cache (null = noch nicht gelesen). static var ist Godot 4.1+. static var _cache: Variant = null +## Cache für den Perspektive-Eintrag (null = noch nicht gelesen). +static var _cache_third: Variant = null ## Gespeicherte Empfindlichkeit lesen (geclampt auf 0,1–5,0), Standard 1,0. @@ -48,6 +53,32 @@ static func save_sens(value: float) -> float: return val +## Gespeicherte Perspektive lesen (Karte t_17a35c59): true = Third Person. +## Default FALSE = First Person — der Testlauf startet so wie bisher. +static func load_third() -> bool: + if _cache_third != null: + return bool(_cache_third) + var cfg := ConfigFile.new() + var raw: Variant = false + if cfg.load(CFG_PATH) == OK: + raw = cfg.get_value(SECTION, KEY_THIRD, false) + var val := bool(raw) + _cache_third = val + return val + + +## Perspektive speichern (persistiert über Editor-Neustarts) und Cache frisch +## halten. Rückgabe: der tatsächlich gespeicherte Wert. +static func save_third(value: bool) -> bool: + var cfg := ConfigFile.new() + cfg.load(CFG_PATH) # Fehler → leere Config, nur unser Key wird angelegt. + cfg.set_value(SECTION, KEY_THIRD, value) + cfg.save(CFG_PATH) + _cache_third = value + return value + + ## Cache verwerfen (für Tests / frisches Neulesen von der Platte). static func reset_cache() -> void: _cache = null + _cache_third = null diff --git a/src/ui/panels/settings_panel.gd b/src/ui/panels/settings_panel.gd index 233dc98..4595107 100644 --- a/src/ui/panels/settings_panel.gd +++ b/src/ui/panels/settings_panel.gd @@ -8,6 +8,9 @@ extends "res://src/ui/panels/editor_panel.gd" ## Maus-Empfindlichkeit SOFORT über main.playtest.apply_sensitivity wirksam. var sens_box: SpinBox +## Perspektive im Testlauf (Karte t_17a35c59): Erste/Dritte Person, derselbe +## Eintrag wie Taste V (PlaytestSettings, persistiert). +var perspektive_box: OptionButton static func spec() -> Dictionary: @@ -32,6 +35,16 @@ func build() -> void: sens_box.value_changed.connect(_on_sens_changed) add_child(sens_box) + add_child(_field_label("Perspektive im Testlauf")) + perspektive_box = OptionButton.new() + perspektive_box.add_item("Erste Person (Standard)") + perspektive_box.add_item("Dritte Person (Bot-Avatar)") + perspektive_box.selected = 1 if PlaytestSettings.load_third() else 0 + perspektive_box.custom_minimum_size = UIScale.v2(0, 36) + perspektive_box.tooltip_text = "Dritte Person zeigt denselben Avatar wie die Bots im Spiel (Taste V schaltet im Testlauf ebenfalls). Dritte Person braucht die Avatar-Modelle aus dem Client-Klon." + perspektive_box.item_selected.connect(_on_perspektive_changed) + add_child(perspektive_box) + add_child(_separator()) add_child(_muted_label("Gilt im Lauf-Modus („▶ Karte testen“). Standard 1,0 — jede PC-/Maus-Kombination ist anders. Wird automatisch gespeichert.")) @@ -41,3 +54,17 @@ func _on_sens_changed(value: float) -> void: var pt: Variant = main.get("playtest") if pt != null and pt.has_method("apply_sensitivity"): pt.call("apply_sensitivity", value) + + +func _on_perspektive_changed(index: int) -> void: + var dritte := index == 1 + PlaytestSettings.save_third(dritte) + # Live anwenden, falls der Testlauf gerade läuft. Ohne Avatar-Modelle + # bleibt First Person aktiv (set_perspektive lehnt Third ab) — der + # Klartext-Grund steht beim Start/V-Druck im HUD. + var pt: Variant = main.get("playtest") + if pt == null: + return + var body: Variant = pt.get("body") + if body != null: + body.call("set_perspektive", dritte) diff --git a/tests/test_playtest_avatar.gd b/tests/test_playtest_avatar.gd new file mode 100644 index 0000000..c0ce2b1 --- /dev/null +++ b/tests/test_playtest_avatar.gd @@ -0,0 +1,429 @@ +extends SceneTree +## Editor-Test (headless): Karte t_17a35c59 — Playtest-Avatar (Bot-Rig aus dem +## Client-Klon) + First/Third-Person-Umschalter (Taste V). +## Ausführen: godot4 --headless --path . --import && godot4 --headless --path . --script res://tests/test_playtest_avatar.gd +## Exit 0 = alles grün. +## +## Abgedeckt: +## 1. Identität zum Client: Spiegel-Konstanten == bot_avatar.gd / +## MatchRemoteAvatar / TestmapPlayer (aus dem NACHBAR-KLON gelesen — +## Quelle der Wahrheit, keine Editor-eigenen Maße). +## 2. Avatar lädt aus dem Nachbar-Klon (GLTFDocument, GLB außerhalb res://), +## AnimationTree-Pipeline (BlendSpace1D + Kadenz-Mathe wie BOT2), +## Modellhöhe == Kapsel-Konvention == Client-Avatar, Rifle an hand_r. +## 3. Perspektive: Default First Person, Taste V schaltet, persistiert, +## Third zeigt Avatar + SpringArm-Kamera (Blick zum Körper, kein +## Clipping durch eine Wand hinter dem Spieler), graceful ohne Avatar. +## 4. Blickrichtung == Bewegungsrichtung: Body-Yaw dreht die WASD-Bewegung +## (1:1 TestmapPlayer) und die Avatar-Front zeigt in Bewegungsrichtung. +## 5. Playtest-Integration: enter() baut den Avatar, die Gait-Speisung +## leitet Stillstand/Walk/Jog aus der ECHTEN Geschwindigkeit ab. + +var failures := 0 +var checks := 0 + +const HALF_H := 0.9 +const BEWEIS_TOLERANZ_HOEHE := 0.1 + + +func _initialize() -> void: + _run_all.call_deferred() + + +func _run_all() -> void: + _test_identitaet_konstanten() + var avatar: Node3D = await _test_avatar_laden() + await _test_perspektive_und_kamera(avatar) + await _test_integration_playtest() + _finish() + + +func _finish() -> void: + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) + + +## ------------------------------------------------------------- Helfer ------ + +## Roher Konstanten-Wert aus einer Client-GDScript-Datei ("" = fehlt). +## Muster: `const NAME := WERT` — WERT als Text (Vergleich über str()). +func _const_wert(text: String, name: String) -> String: + var zeilen := text.split("\n") + for zeile in zeilen: + var t := zeile.strip_edges() + if t.begins_with("const " + name + " :=") or t.begins_with("const " + name + "="): + return t.substr(("const " + name + " :=").length()).strip_edges() + return "" + + +func _client_datei(rel: String) -> String: + var root: String = PlaytestAvatar.client_wurzel() + if root.is_empty(): + return "" + return root.path_join(rel) + + +## --------------------------------------------------- 1. Identität ---------- + +func _test_identitaet_konstanten() -> void: + print("Testgruppe: Identität — Spiegel-Konstanten == Client-Quelle") + _check(not PlaytestAvatar.client_wurzel().is_empty(), + "Client-Klon auflösbar (../scopedvr-client)") + var bot_text := FileAccess.get_file_as_string(_client_datei("scripts/match/bot_avatar.gd")) + _check(not bot_text.is_empty(), "bot_avatar.gd lesbar (Quelle der Wahrheit)") + var parent_text := FileAccess.get_file_as_string(_client_datei("scripts/match/match_remote_avatar.gd")) + _check(not parent_text.is_empty(), "match_remote_avatar.gd lesbar") + var player_text := FileAccess.get_file_as_string(_client_datei("scripts/world/testmap_player.gd")) + _check(not player_text.is_empty(), "testmap_player.gd lesbar") + + # Zahl-Konstanten: exakter Text-Vergleich Quelle ↔ Spiegel. + var zahl_paare := [ + ["bot_avatar.gd", bot_text, "IDLE_SPEED_MAX_MPS", str(PlaytestAvatar.IDLE_SPEED_MAX_MPS)], + ["bot_avatar.gd", bot_text, "WALK_SPEED_MAX_MPS", str(PlaytestAvatar.WALK_SPEED_MAX_MPS)], + ["bot_avatar.gd", bot_text, "WALK_STEP_M", str(PlaytestAvatar.WALK_STEP_M)], + ["bot_avatar.gd", bot_text, "JOG_STEP_M", str(PlaytestAvatar.JOG_STEP_M)], + ["bot_avatar.gd", bot_text, "CADENCE_MIN", str(PlaytestAvatar.CADENCE_MIN)], + ["bot_avatar.gd", bot_text, "CADENCE_MAX", str(PlaytestAvatar.CADENCE_MAX)], + ["bot_avatar.gd", bot_text, "FOOT_SYNC_MIN", str(PlaytestAvatar.FOOT_SYNC_MIN)], + ["bot_avatar.gd", bot_text, "FOOT_SYNC_MAX", str(PlaytestAvatar.FOOT_SYNC_MAX)], + ["bot_avatar.gd", bot_text, "CROSSFADE_S", str(PlaytestAvatar.CROSSFADE_S)], + ["bot_avatar.gd", bot_text, "BLEND_SMOOTH_S", str(PlaytestAvatar.BLEND_SMOOTH_S)], + ["bot_avatar.gd", bot_text, "MODEL_EYE_HEIGHT", str(PlaytestAvatar.MODEL_EYE_HEIGHT)], + ["bot_avatar.gd", bot_text, "MODEL_Y_OFFSET", str(PlaytestAvatar.MODEL_Y_OFFSET)], + ["bot_avatar.gd", bot_text, "RIFLE_POS", "Vector3(0.0, 0.067, 0.0)"], + ["match_remote_avatar.gd", parent_text, "WALK_NATURAL_MPS", str(PlaytestAvatar.WALK_NATURAL_MPS)], + ["match_remote_avatar.gd", parent_text, "JOG_NATURAL_MPS", str(PlaytestAvatar.JOG_NATURAL_MPS)], + ["testmap_player.gd", player_text, "EYE_HEIGHT", str(PlaytestBody.EYE_HEIGHT)], + ["testmap_player.gd", player_text, "WALK_SPEED", str(PlaytestBody.WALK_SPEED)], + ["testmap_player.gd", player_text, "SPRINT_SPEED", str(PlaytestBody.SPRINT_SPEED)], + ["testmap_player.gd", player_text, "JUMP_SPEED", str(PlaytestBody.JUMP_SPEED)], + ["testmap_player.gd", player_text, "GRAVITY", str(PlaytestBody.GRAVITY)], + ] + for paar in zahl_paare: + var quelle := _const_wert(paar[1], paar[2]) + var quell_zahl := float(quelle) if not quelle.is_empty() else -999.0 + _check(is_equal_approx(quell_zahl, float(paar[3])), + "%s.%s == %s (Quelle: %s)" % [paar[0], paar[2], paar[3], quelle]) + + # Text-Konstanten (Clips, Knochen). + _check(_const_wert(parent_text, "CLIP_IDLE") == "\"Idle\"" and PlaytestAvatar.CLIP_IDLE == "Idle", + "CLIP_IDLE == Client (Idle)") + _check(_const_wert(parent_text, "CLIP_WALK") == "\"Walk\"" and PlaytestAvatar.CLIP_WALK == "Walk", + "CLIP_WALK == Client (Walk)") + _check(_const_wert(parent_text, "CLIP_JOG") == "\"Jog_Fwd\"" and PlaytestAvatar.CLIP_JOG == "Jog_Fwd", + "CLIP_JOG == Client (Jog_Fwd)") + _check(_const_wert(bot_text, "RIFLE_HAND_BONE") == "\"hand_r\"", + "RIFLE_HAND_BONE == Client (hand_r)") + + # Frontachse: Quelle deklariert MODEL_YAW_OFFSET = PI (vc21-Rückwärts-Fix). + _check(_const_wert(bot_text, "MODEL_YAW_OFFSET") == "PI" + and is_equal_approx(PlaytestAvatar.MODEL_YAW_OFFSET, PI), + "MODEL_YAW_OFFSET == PI (GLB-Frontachse +Z, vc21)") + _check(PlaytestAvatar.MODEL_FORWARD_LOCAL == Vector3(0.0, 0.0, 1.0), + "MODEL_FORWARD_LOCAL == (0, 0, 1) (vc21-Messung)") + + # Gait-Schwellen spiegeln die Velocity-Inferenz des Clients. + _check(PlaytestAvatar.zustand_fuer(0.1) == PlaytestAvatar.STATE_IDLE + and PlaytestAvatar.zustand_fuer(1.0) == PlaytestAvatar.STATE_WALK + and PlaytestAvatar.zustand_fuer(6.0) == PlaytestAvatar.STATE_JOG, + "zustand_fuer spiegelt die Client-Schwellen (0,3/2,6 m/s)") + + +## --------------------------------------------------- 2. Avatar laden ------- + +func _test_avatar_laden() -> Node3D: + print("Testgruppe: Avatar aus dem Nachbar-Klon laden (GLTF + AnimationTree)") + var pfad := PlaytestAvatar.model_pfad() + _check(not pfad.is_empty() and FileAccess.file_exists(pfad), + "Avatar-GLB im Client-Klon gefunden (kein Duplikat im Editor)") + var avatar: Node3D = PlaytestAvatar.lade_avatar() + _check(avatar != null, "Avatar lädt (GLTFDocument, GLB außerhalb res://)") + if avatar == null: + return null + root.add_child(avatar) + await process_frame + + # AnimationTree-Pipeline: Motion-State aktiv, Idle als Start-Zustand. + _check(avatar.get("_tree") != null, "AnimationTree gebaut (A4-Pipeline)") + # apply_state (wie _speise_avatar im Betrieb) startet den Motion-State + # sauber nach dem Baum-Eintritt; die StateMachine schreibt den Knoten + # beim nächsten Prozess-Tick — daher ein Frame abwarten. + avatar.call("apply_state", PlaytestAvatar.STATE_IDLE, 0.0) + await process_frame + _check(avatar.call("tree_state") == "Motion", "StateMachine startet im Motion-State") + _check(avatar.call("current_clip") == "Idle", "Start-Anim = Idle (kein T-Pose-Blitz)") + _check(avatar.call("anim_running"), "AnimationTree aktiv") + # Loop-Mode: Idle/Walk/Jog laufen endlos (Importer-Semantik nachgebildet). + var anim: AnimationPlayer = avatar.get("_anim") + _check(anim.has_animation("Idle") and anim.has_animation("Walk") + and anim.has_animation("Jog_Fwd"), + "Clips unter den Bot-Namen (Idle/Walk/Jog_Fwd — _loop-Endung gestrichen)") + _check(anim.get_animation("Idle").loop_mode == Animation.LOOP_LINEAR, + "Idle-Clip loopt (GLTFDocument liefert sonst Einmal-Abspiel)") + + # Gait-Speisung: Walk/Jog-Sollwerte wie bot_avatar._set_state_animation. + avatar.call("apply_state", PlaytestAvatar.STATE_WALK, 2.0) + _check(avatar.call("current_clip") == "Walk", "STATE_WALK → Walk-Clip") + _check(is_equal_approx(float(avatar.get("_blend_target")), PlaytestAvatar.WALK_NATURAL_MPS), + "Blend-Soll = Walk-Natur (0,979 m/s) — der State wählt den Gang") + avatar.call("apply_state", PlaytestAvatar.STATE_JOG, 6.0) + _check(avatar.call("current_clip") == "Jog_Fwd", "STATE_JOG → Jog-Clip") + _check(is_equal_approx(float(avatar.get("_blend_target")), PlaytestAvatar.JOG_NATURAL_MPS), + "Blend-Soll = Jog-Natur (2,620 m/s)") + + # Kadenz-Mathe (BOT2): TimeScale = Kadenz × Zykluslänge / 2, geklemmt. + var walk_len: float = anim.get_animation("Walk").length + var jog_len: float = anim.get_animation("Jog_Fwd").length + var walk_soll: float = clampf(2.0 / PlaytestAvatar.WALK_STEP_M * walk_len / 2.0, + PlaytestAvatar.FOOT_SYNC_MIN, PlaytestAvatar.FOOT_SYNC_MAX) + var jog_soll: float = clampf(6.0 / PlaytestAvatar.JOG_STEP_M * jog_len / 2.0, + PlaytestAvatar.FOOT_SYNC_MIN, PlaytestAvatar.FOOT_SYNC_MAX) + _check(is_equal_approx(avatar.call("gait_scale_for", PlaytestAvatar.STATE_WALK, 2.0), walk_soll), + "Walk-Kadenz = BOT2-Formel (m/s / 0,95 m → TimeScale %.2f)" % walk_soll) + _check(is_equal_approx(avatar.call("gait_scale_for", PlaytestAvatar.STATE_JOG, 6.0), jog_soll), + "Jog-Kadenz = BOT2-Formel (m/s / 0,85 m → TimeScale %.2f, geklemmt)" % jog_soll) + # 6 m/s Gehen im Playtest ist CLIENT-seitig schneller als jeder Bot — die + # Kadenz-Klammer (FOOT_SYNC_MAX 2,0) muss greifen, kein Ausreißer. + _check(avatar.call("gait_scale_for", PlaytestAvatar.STATE_JOG, 6.0) <= PlaytestAvatar.FOOT_SYNC_MAX, + "Kadenz-Klammer greift bei Playtest-Tempi (6 m/s)") + avatar.call("apply_state", PlaytestAvatar.STATE_IDLE, 0.0) + + # Identität der Proportionen: Modellhöhe ≈ Client (1,83 m), Kapsel 1,8 m. + var hoehe := _modell_hoehe(avatar) + _check(absf(hoehe - 1.83) < BEWEIS_TOLERANZ_HOEHE, + "Modellhöhe ≈ Client-Avatar (gemessen %.2f m ≈ 1,83 m)" % hoehe) + _check(absf(hoehe - PlaytestBody.KAPSEL_HEIGHT) < BEWEIS_TOLERANZ_HOEHE, + "Avatar-Höhe == Kapsel-Höhe-Konvention (%.2f ≈ %.2f m)" % [hoehe, PlaytestBody.KAPSEL_HEIGHT]) + # Füße am Boden: GLB-Ursprung = Füße → Unterkante ≈ MODEL_Y_OFFSET unterm + # Kopf-Anker (= exakt Füßpunkt des Körpers bei Anker 1,65 − Halbhöhe 0,9). + var unten := _modell_unterkante(avatar) + _check(absf(unten - PlaytestAvatar.MODEL_Y_OFFSET) < 0.05, + "Modell-Füße am Anker minus 1,65 m (gemessen %.2f)" % unten) + + # Rifle-Mount (wie die Bots): BoneAttachment3D + Rifle-GLB. Pfad über + # Suche (Skeleton hängt unter Armature — fester Node-Pfad ist spröde). + var mount: Node3D = null + for b in avatar.find_children("RifleMount", "BoneAttachment3D", true, false): + mount = b + _check(mount != null, "RifleMount (BoneAttachment3D hand_r) vorhanden") + _check(mount != null and mount.get_node_or_null("Rifle") != null, + "Rifle hängt am Mount (m40_rifle.glb aus dem Client-Klon)") + return avatar + + +## Vereinigte AABB-Höhe aller Meshes unter dem Avatar (Weltbezug des Knotens). +func _modell_hoehe(avatar: Node3D) -> float: + var box := _modell_box(avatar) + return box.size.y + + +## Unterkante der vereinigten AABB relativ zum Avatar-Node (Kopf-Anker). +func _modell_unterkante(avatar: Node3D) -> float: + return _modell_box(avatar).position.y + + +func _modell_box(avatar: Node3D) -> AABB: + var lokal := avatar.global_transform.affine_inverse() + var ergebnis := AABB(Vector3.ZERO, Vector3.ZERO) + var erste := true + for mi in avatar.find_children("*", "MeshInstance3D", true, false): + var m := mi as MeshInstance3D + if m.mesh == null: + continue + var welt := m.global_transform * m.mesh.get_aabb() + var hier := lokal * welt + if erste: + ergebnis = hier + erste = false + else: + ergebnis = ergebnis.merge(hier) + return ergebnis + + +## ------------------------------------------- 3. Perspektive + Kamera ------- + +func _test_perspektive_und_kamera(avatar: Node3D) -> void: + print("Testgruppe: Perspektive (Taste V) — First/Third, SpringArm, Persistenz") + + # Persistenz: Default First Person. + PlaytestSettings.reset_cache() + PlaytestSettings.save_third(false) + PlaytestSettings.reset_cache() + _check(not PlaytestSettings.load_third(), "Default = First Person (persistiert false)") + + # Leichter Body ohne Avatar (graceful Fallback, kein Crash). + var body_leer: CharacterBody3D = load("res://src/editor/playtest/playtest_body.gd").new() + var boden := StaticBody3D.new() + var boden_form := CollisionShape3D.new() + var boden_box := BoxShape3D.new() + boden_box.size = Vector3(400.0, 0.2, 400.0) + boden_form.shape = boden_box + boden.add_child(boden_form) + boden.position = Vector3(500.0, -0.1, 500.0) # getrennt vom Haupt-Body + root.add_child(boden) + body_leer.position = Vector3(500.0, 1.0, 500.0) + root.add_child(body_leer) + await physics_frame + _check(not body_leer.call("toggle_perspektive"), "Ohne Avatar: V schaltet NICHT (First bleibt)") + _check(not bool(body_leer.get("perspektive_dritte")), "Ohne Avatar: Zustand bleibt First") + + # Mit Avatar: V schaltet, Third zeigt Körper, SpringArm-Kamera übernimmt. + var body: CharacterBody3D = load("res://src/editor/playtest/playtest_body.gd").new() + root.add_child(body) + body.position = Vector3(0.0, 1.0, 0.0) + await physics_frame + body.call("set_avatar", avatar) + _check(body.get("avatar") == avatar, "Avatar am Body (Kopf-Anker-Konvention)") + _check(not bool(body.get("perspektive_dritte")), "Start = First Person") + _check(not avatar.visible, "First Person: Avatar-Körper unsichtbar (Kapsel bleibt)") + var look: Camera3D = body.get("look_cam") + _check(look.current, "First Person: Augen-Kamera aktiv") + + _check(body.call("toggle_perspektive"), "V schaltet auf Third Person") + _check(avatar.visible, "Third Person: Bot-Körper sichtbar") + _check(not look.current, "First-Kamera abgegeben") + var tpp: Camera3D = body.get("tpp_cam") + _check(tpp != null and tpp.current, "Third-Kamera aktiv (SpringArm)") + var spring: SpringArm3D = body.get("spring") + _check(spring != null and is_equal_approx(spring.spring_length, 3.5), + "SpringArm-Länge 3,5 m") + + # Kamera steht HINTER dem Spieler und schaut AUF IHN (kein 180°-Dreher). + await process_frame + await physics_frame + var kamera_pos: Vector3 = tpp.global_position + var pivot_pos: Vector3 = spring.global_position + var hinter: Vector3 = kamera_pos - body.global_position + _check(hinter.z > 1.0, "Kamera hinter dem Spieler (Z +%.2f m bei Blick −Z)" % hinter.z) + var blick: Vector3 = tpp.global_transform.basis * Vector3(0.0, 0.0, -1.0) + var zum_koerper := (pivot_pos - kamera_pos).normalized() + _check(blick.dot(zum_koerper) > 0.9, "Third-Kamera schaut auf den Spieler (dot %.2f)" % blick.dot(zum_koerper)) + + # Kein Clipping: Wand 1,0 m hinter dem Spieler → Kamera zieht vor die Wand. + var wand := StaticBody3D.new() + var wand_form := CollisionShape3D.new() + var wand_box := BoxShape3D.new() + wand_box.size = Vector3(8.0, 12.0, 0.4) + wand_form.shape = wand_box + wand.add_child(wand_form) + wand.position = Vector3(body.global_position.x, 1.0, body.global_position.z + 1.2) + root.add_child(wand) + await physics_frame + await physics_frame + var abstand: float = tpp.global_position.distance_to(spring.global_position) + _check(abstand < 1.0, + "Kamera zieht vor die Wand zurück (Arm %.2f m < 1,0 m — kein Clipping)" % abstand) + wand.queue_free() + + # Zurück zu First: V again — Avatar unsichtbar, Augen-Kamera aktiv. + _check(not body.call("toggle_perspektive"), "V schaltet zurück auf First") + _check(not avatar.visible and look.current, "First wiederhergestellt (Körper weg, Augen-Kamera aktiv)") + + # Persistenz: Third speichern + zurücklesen. + PlaytestSettings.save_third(true) + PlaytestSettings.reset_cache() + _check(PlaytestSettings.load_third(), "Third Person persistiert (Neustart-Simulation)") + PlaytestSettings.save_third(false) + + # Blickrichtung == Bewegungsrichtung: yaw −90° (Blick +x) + W → +x. + body.set("yaw_deg", -90.0) + body.set("auto_input", {"fwd": true, "back": false, "left": false, + "right": false, "run": false, "jump": false}) + var start := Vector3(body.position) + for i in 30: + await physics_frame + body.set("auto_input", {}) + var delta := Vector3(body.position) - start + _check(delta.x > 1.0 and absf(delta.z) < 0.2, + "Bewegung folgt der Blickrichtung (W bei Blick +x → Δx %.2f, Δz %.2f)" % [delta.x, delta.z]) + # Avatar-Front == Bewegungsrichtung (vc21-Vertrag, MODEL_YAW_OFFSET). + avatar.call("apply_state", PlaytestAvatar.STATE_WALK, 6.0) + _check(avatar.call("model_forward").dot(Vector3(1.0, 0.0, 0.0)) > 0.9, + "Avatar-Front == Bewegungsrichtung (+x, MODEL_YAW_OFFSET korrekt)") + + body_leer.queue_free() + body.queue_free() + + +## ------------------------------------------------ 4. Playtest-Integration -- + +func _test_integration_playtest() -> void: + print("Testgruppe: Integration — enter() baut Avatar, Gait aus echter Bewegung") + var scene: PackedScene = load("res://scenes/main.tscn") + var main: Control = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + + var doc: Dictionary = SvmapDocument.create_new("Avatar-Test", 100.0, "klar") + main.set("doc", doc) + main.set("current_path", "") + main.set("dirty", false) + main.get("history").begin(doc) + main.call("_clear_edit_state") + main.call("_refresh_all") + await process_frame + + main.call("_on_test_run") + await process_frame + await process_frame + var pt: Node = main.get("playtest") + _check(pt != null, "PlaytestMode angelegt") + if pt == null: + main.queue_free() + return + var body: Node = pt.get("body") + _check(bool(pt.get("_avatar_da")), "enter() fand die Avatar-Modelle (Client-Klon)") + var avatar: Node3D = body.get("avatar") as Node3D + _check(avatar != null, "Avatar am Playtest-Body") + + # Gait-Speisung aus der ECHTEN Geschwindigkeit: loslaufen → JOG. + body.set("auto_move_dir", Vector3(1, 0, 0)) + for i in 30: + await physics_frame + var speed: float = body.call("horizontale_geschwindigkeit") + _check(speed > 5.0, "Spieler wirklich in Bewegung (%.1f m/s)" % speed) + _check(int(avatar.get("state")) == PlaytestAvatar.STATE_JOG + and avatar.call("current_clip") == "Jog_Fwd", + "Laufen (6 m/s) → Jog-State wie die Bots im Spiel") + body.set("auto_move_dir", Vector3.ZERO) + for i in 30: + await physics_frame + _check(int(avatar.get("state")) == PlaytestAvatar.STATE_IDLE + and avatar.call("current_clip") == "Idle", + "Stillstand → Idle-State") + + # Taste V über den PlaytestMode (persistiert + Meldung). + _check(bool(pt.call("toggle_perspektive")), "PlaytestMode.toggle_perspektive → Third") + _check(bool(body.get("perspektive_dritte")) and avatar.visible, + "Third aktiv (Avatar sichtbar)") + _check(bool(PlaytestSettings.load_third()), "Umschalten persistiert") + _check(not bool(pt.call("toggle_perspektive")), "PlaytestMode.toggle_perspektive → First") + _check(not PlaytestSettings.load_third(), "Rückschalten persistiert (Default First)") + + # HUD-Hinweis nennt die Taste V. + var hint_text := "" + for label in pt.get("hud").find_children("*", "Label", true, false): + var l := label as Label + if l.text.begins_with("WASD"): + hint_text = l.text + _check(hint_text.contains("V = 1./3.-Person"), "HUD-Hinweis nennt Taste V") + + # ESC → Editor-Zustand wiederhergestellt (Avatar mit dem Body abgeräumt). + pt.call("handle_escape") + await process_frame + _check(main.get("playtest") == null, "PlaytestMode abgeräumt (Avatar mit weg)") + main.queue_free() diff --git a/tests/test_playtest_avatar.gd.uid b/tests/test_playtest_avatar.gd.uid new file mode 100644 index 0000000..a98c16a --- /dev/null +++ b/tests/test_playtest_avatar.gd.uid @@ -0,0 +1 @@ +uid://de8r5nfunbbpb