- 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).
429 lines
20 KiB
GDScript
429 lines
20 KiB
GDScript
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()
|