scopedvr-map-editor/tests/test_playtest_avatar.gd
2026-11-01 19:34:05 +01:00

429 lines
20 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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()