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://core/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://core/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://core/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()