scopedvr-map-editor/tests/test_playtest_spawn.gd
slyphax 491a47d3f1 Editor-Bibliothek auf das Military-Desert-Pack umgestellt
- 423 Objekte in 19 deutschen Kategorien (Panzer, Radfahrzeuge, Flugzeuge, Gebaeude,
  Zelte, Tuerme, Mauern, Sperren, Kisten, Technik, Felsen, Boden, Decals ...);
  Gruppen "Bauwerke" und "Kisten & Barrieren" samt Test-Asset-Namenslisten entfernt
- LibraryBrowser: Gruppen/Namen aus dem Manifest (kategorie, name_de), unbekannte
  Kategorien bekommen automatisch eine eigene Gruppe
- Thumbs neu, library/ synchronisiert (inkl. neuer Kollision)
- Neue Identitaetsprobe: Kollision ALLER Objekte Editor = Client = Server
- Tests auf Pack-Objekte umgestellt

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 16:31:28 +01:00

402 lines
17 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

extends SceneTree
## Editor-Test (headless): Karte t_96e3c4ee — Test-Startpunkt per Klick
## („hinklicken, wo man beim Testen spawnt", statt nur Team-Spawnpunkte).
## Ausführen: godot4 --headless --path . --script res://tests/test_playtest_spawn.gd
## Exit 0 = alles grün.
##
## Deckt die Karten-Pflichten ab:
## 1. Setzen/Löschen ändert die .svmap NICHT (Byte-Vergleich) + kein Undo.
## 2. Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm).
## 3. Fallback-Reihenfolge je einmal belegt (Test-Start → Team-Spawn → Mitte).
## 4. Nach „Neustart" wiederhergestellt (Sidecar laden/leeren).
## 5. T im Testlauf versetzt genau dorthin, Kapsel-Physik (0.65 über Mitte), Kollision
## beachtet (blocked = ok=false, Position unverändert; Kisten-Dach = oben).
## 6. Marker existiert nur bei gesetztem Punkt.
## 7. Test-Start taucht in KEINER Spawn-Zone/Analyse auf.
var failures := 0
var checks := 0
var main: Control
var subvp: SubViewport
func _initialize() -> void:
_run_all.call_deferred()
func _run_all() -> void:
# Sauberer Sidecar-Start (Suite darf von früheren Läufen nichts erben).
TestStartStore.clear_point("")
var scene: PackedScene = load("res://scenes/main.tscn")
_check(scene != null, "scenes/main.tscn laedt")
if scene == null:
_finish()
return
main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
main.call("_test_start_reload") # frischer Zustand nach dem Key-Clear
var container := _find_viewport_container(main)
if container != null:
subvp = container.get_child(0) as SubViewport
subvp.size = Vector2i(800, 600) # deterministisches Fadenkreuz
await process_frame
# Test-Map: hügeliges Terrain (Höhe steigt mit x), Team-0-Spawn, Objekt.
var doc: Dictionary = SvmapDocument.create_new("Test-Start-Karte", 100.0, "mittag")
var terrain: Dictionary = doc.get("terrain", {})
var res := int(terrain.get("resolution", 0))
var heights := PackedFloat32Array()
heights.resize(res * res)
for zi in res:
for xi in res:
heights[zi * res + xi] = 2.0 + float(xi) * 0.1 # 2.0 … +Ramp
terrain["min_height_m"] = 2.0
terrain["max_height_m"] = 2.0 + float(res - 1) * 0.1
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 2.0, 2.0 + float(res - 1) * 0.1)
doc["objects"] = [
{"id": "box_001", "pos": [10.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0},
]
# V11-konform: 4 Punkte pro Team (sonst lehnt der Validator das Speichern ab).
doc["spawn_zones"] = {
"0": {"points": [
{"id": "A1", "pos": [-20.0, 0.0, 0.0]}, {"id": "A2", "pos": [-24.0, 0.0, 2.0]},
{"id": "A3", "pos": [-22.0, 0.0, -3.0]}, {"id": "A4", "pos": [-26.0, 0.0, -2.0]},
]},
"1": {"points": [
{"id": "B1", "pos": [20.0, 0.0, 0.0]}, {"id": "B2", "pos": [24.0, 0.0, 2.0]},
{"id": "B3", "pos": [22.0, 0.0, -3.0]}, {"id": "B4", "pos": [26.0, 0.0, -2.0]},
]},
}
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")
main.call("_test_start_reload")
await process_frame
await process_frame
_test_svmap_unchanged()
_test_height_snaps_to_terrain()
_test_fallback_order()
_test_sidecar_roundtrip()
_test_marker_visibility()
_test_t_key_toggles_tool()
_test_not_a_game_spawn()
await _test_playtest_teleport()
_finish()
func _finish() -> void:
TestStartStore.clear_point("") # Suite hinterlässt keinen Müll
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)
func _find_viewport_container(node: Node) -> SubViewportContainer:
if node is SubViewportContainer:
return node as SubViewportContainer
for child in node.get_children():
var found := _find_viewport_container(child)
if found != null:
return found
return null
func _tool() -> Node:
return main.get("test_start_tool")
func _marker() -> Node3D:
var t := _tool()
if t == null:
return null
return t.get("_marker")
## --- 1: Setzen/Löschen ändert die .svmap nicht ------------------------------
func _test_svmap_unchanged() -> void:
print("Testgruppe: Setzen/Löschen ändert die .svmap NICHT (Byte-Vergleich)")
var path := "user://test_playtest_spawn_a.svmap"
var r1 := SvmapIO.save_to_file(path, main.get("doc"))
_check(bool(r1.get("ok")), "Map vor dem Setzen speicherbar (Validator ok)")
var bytes_before := FileAccess.get_file_as_bytes(path)
var doc_before := str(main.get("doc"))
var undo_before: bool = main.get("history").call("can_undo")
_tool().call("demo_set", 20.0, -30.0)
_check(main.get("test_start_point") != null, "Test-Start gesetzt (main.test_start_point)")
var r2 := SvmapIO.save_to_file(path, main.get("doc"))
_check(bool(r2.get("ok")), "Map nach dem Setzen speicherbar")
var bytes_after_set := FileAccess.get_file_as_bytes(path)
_check(bytes_after_set == bytes_before, "Setzen: .svmap byte-identisch")
_check(str(main.get("doc")) == doc_before, "Setzen: Dokument unverändert")
_check(not main.get("history").call("can_undo"), "Setzen: KEIN Undo-Eintrag")
main.call("_test_start_delete")
_check(main.get("test_start_point") == null, "Test-Start gelöscht")
var bytes_after_del := FileAccess.get_file_as_bytes(path)
_check(bytes_after_del == bytes_before, "Löschen: .svmap byte-identisch")
_check(str(main.get("doc")) == doc_before, "Löschen: Dokument unverändert")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## --- 2: Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm) --------------------
func _test_height_snaps_to_terrain() -> void:
print("Testgruppe: Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm)")
var doc: Dictionary = main.get("doc")
_tool().call("demo_set", 30.0, 5.0)
var expected := SvmapDocument.terrain_height_at(doc, 30.0, 5.0)
var marker := _marker()
_check(marker != null and marker.visible, "Marker sichtbar nach dem Setzen")
_check(absf(marker.position.y - expected) <= 0.01,
"Marker-Höhe == Terrainhöhe (%.3f == %.3f, Δ %.4f m)" % [
marker.position.y, expected, absf(marker.position.y - expected)])
_check(absf(marker.position.x - 30.0) < 0.001 and absf(marker.position.z - 5.0) < 0.001,
"Marker x/z exakt am Klickpunkt")
# Klick auf ein Objekt: Punkt auf dem Boden DARUNTER (gleiche Höhe-Regel).
_tool().call("demo_set", 10.0, 0.0, "auf dem Boden unter dem Objekt")
var expected_obj := SvmapDocument.terrain_height_at(doc, 10.0, 0.0)
_check(absf(marker.position.y - expected_obj) <= 0.01,
"Objekt-Treffer: Marker auf dem Boden DARUNTER (%.3f == %.3f)" % [
marker.position.y, expected_obj])
var status: String = main.status_label.text
_check(status.contains("Boden darunter") or status.contains("unter dem Objekt"),
"Statuszeile meldet Klartext „unter dem Objekt“: '%s'" % status)
main.call("_test_start_delete")
## --- 3: Fallback-Reihenfolge -------------------------------------------------
func _test_fallback_order() -> void:
print("Testgruppe: Fallback-Reihenfolge (Test-Start → Team-Spawn → Mitte)")
var doc: Dictionary = main.get("doc")
# a) Test-Start gewinnt.
var r: Dictionary = TestStart.resolve_start(doc, Vector3(33.0, 0.0, -44.0))
_check(str(r.get("quelle")) == "test", "Fallback 1: gesetzter Test-Start gewinnt")
_check(absf((r.get("pos") as Vector3).x - 33.0) < 0.001
and absf((r.get("pos") as Vector3).z + 44.0) < 0.001,
"Fallback 1: Position == Test-Start (x/z)")
_check(absf((r.get("pos") as Vector3).y - SvmapDocument.terrain_height_at(doc, 33.0, -44.0)) <= 0.01,
"Fallback 1: Höhe automatisch vom Terrain")
# b) Kein Test-Start → erster Team-Spawnpunkt (Team Blau = „0“).
r = TestStart.resolve_start(doc, null)
_check(str(r.get("quelle")) == "team", "Fallback 2: erster Team-Spawnpunkt")
_check(absf((r.get("pos") as Vector3).x + 20.0) < 0.001,
"Fallback 2: Position == Team-Blau-Spawn A1 (-20, 0)")
# c) Weder noch → Kartenmitte.
var ohne: Dictionary = doc.duplicate(true)
ohne["spawn_zones"] = {}
r = TestStart.resolve_start(ohne, null)
_check(str(r.get("quelle")) == "mitte", "Fallback 3: Kartenmitte")
var bmin: Array = doc.get("bounds", {}).get("min", [])
var bmax: Array = doc.get("bounds", {}).get("max", [])
_check(absf((r.get("pos") as Vector3).x - (float(bmin[0]) + float(bmax[0])) * 0.5) < 0.001,
"Fallback 3: Mitte == Bounds-Mitte")
# Klartext-Hinweise je Stufe.
_check(str(r.get("status")).length() > 0 and str(TestStart.resolve_start(doc, Vector3.ZERO)["status"]).length() > 0,
"Jede Fallback-Stufe liefert einen Klartext-Hinweis")
## --- 4: Nach Neustart wiederhergestellt (Sidecar) ----------------------------
func _test_sidecar_roundtrip() -> void:
print("Testgruppe: Sidecar — nach Neustart wiederhergestellt")
var pfad := "/tmp/scopedvr_test_karte.svmap"
TestStartStore.save_point(pfad, Vector3(12.5, 0.0, -7.25))
var loaded: Variant = TestStartStore.load_point(pfad)
_check(loaded != null, "Sidecar: Punkt gespeichert + lesbar")
if loaded != null:
var v := loaded as Vector3
_check(absf(v.x - 12.5) < 0.001 and absf(v.z + 7.25) < 0.001,
"Sidecar: x/z exakt (%.2f, %.2f)" % [v.x, v.z])
# editor-Flow: setzen → main verliert den Zustand („Neustart") → reload holt ihn zurück.
_tool().call("demo_set", 3.0, 4.0)
main.set("test_start_point", null)
main.call("_test_start_reload")
var p: Variant = main.get("test_start_point")
_check(p != null, "Neustart-Simulation: Punkt aus dem Sidecar wieder da")
if p != null:
var pv := p as Vector3
_check(absf(pv.x - 3.0) < 0.001 and absf(pv.z - 4.0) < 0.001,
"Neustart-Simulation: gleiche Position (%.1f, %.1f)" % [pv.x, pv.z])
main.call("_test_start_delete")
_check(TestStartStore.load_point("") == null, "Löschen entfernt den Sidecar-Eintrag")
## --- 6: Marker existiert nur bei gesetztem Punkt ------------------------------
func _test_marker_visibility() -> void:
print("Testgruppe: Marker existiert nur bei gesetztem Punkt")
var marker := _marker()
_check(marker != null and not marker.visible, "Ohne Punkt: Marker unsichtbar")
_tool().call("demo_set", -5.0, 8.0)
_check(marker.visible, "Mit Punkt: Marker sichtbar")
main.call("_test_start_delete")
_check(not marker.visible, "Nach Löschen: Marker wieder unsichtbar")
## --- 6b: Taste T schaltet das Werkzeug im Editor um ---------------------------
func _test_t_key_toggles_tool() -> void:
print("Testgruppe: Taste T schaltet das Test-Start-Werkzeug um")
_check(str(main.get("tool_mode")) == "objects", "Start im Objekt-Werkzeug (Vorbedingung)")
main._unhandled_key_input(_press_key(KEY_T))
_check(str(main.get("tool_mode")) == "teststart", "T: Test-Start-Werkzeug an")
var btn: Button = main.get("test_start_btn")
_check(btn != null and btn.button_pressed, "Toolbar-Knopf leuchtet mit")
var panel: Control = main.get("test_start_panel")
_check(panel != null and panel.visible, "Panel (Info + Löschen) sichtbar")
main._unhandled_key_input(_press_key(KEY_T))
_check(str(main.get("tool_mode")) == "objects", "T nochmal: zurück zum Objekt-Werkzeug")
_check(not panel.visible, "Panel wieder zu")
# ESC im Werkzeug beendet es, der Punkt bleibt.
main._unhandled_key_input(_press_key(KEY_T))
_tool().call("demo_set", 1.0, 2.0)
main._unhandled_key_input(_press_key(KEY_ESCAPE))
_check(str(main.get("tool_mode")) == "objects", "ESC: Werkzeug beendet")
_check(main.get("test_start_point") != null, "ESC: gesetzter Test-Start bleibt")
main.call("_test_start_delete")
## --- 7: Test-Start taucht in keiner Spawn-Zone/Analyse auf --------------------
func _test_not_a_game_spawn() -> void:
print("Testgruppe: Kein Spiel-Spawnpunkt (Spawn-Zonen/Analyse unberührt)")
var doc: Dictionary = main.get("doc")
var zones_before := str(doc.get("spawn_zones", {}))
var check_before := SpawnOps.ready_report(doc, main.get("manifest"))
_tool().call("demo_set", 44.0, 44.0)
_check(str(doc.get("spawn_zones", {})) == zones_before,
"Setzen: spawn_zones im Dokument byte-identisch")
var check_after := SpawnOps.ready_report(doc, main.get("manifest"))
_check(str(check_after) == str(check_before), "Setzen: Map-Check-Ergebnis unverändert")
var live_before := SpawnOps.live_status(doc, main.get("manifest"))
_tool().call("demo_set", -44.0, 33.0)
_check(SpawnOps.live_status(doc, main.get("manifest")) == live_before,
"Weiteres Setzen: Live-Spawn-Status unverändert")
main.call("_test_start_delete")
_check(str(doc.get("spawn_zones", {})) == zones_before,
"Löschen: spawn_zones im Dokument byte-identisch")
## --- 5: T im Testlauf versetzt genau dorthin (Augenhöhe, Kollision) ----------
func _test_playtest_teleport() -> void:
print("Testgruppe: T im Testlauf — versetzt genau dorthin, Augenhöhe, Kollision")
main.set("test_start_point", null)
main.call("_test_start_reload")
main.call("_on_test_run")
await process_frame
var pt: Node = main.get("playtest")
_check(pt != null and bool(pt.get("active")), "Testlauf läuft (Vorbedingung)")
if pt == null:
return
var body: Node3D = pt.get("body")
var doc: Dictionary = main.get("doc")
# a) Fadenkreuz-Treffer: Blick 45° nach vorn-unten. Das Ramp-Terrain hängt
# nur von x ab — der Strahl (yaw 0) bleibt auf konstantem x, also ist
# die Ziel-Höhe analytisch exakt.
body.set("yaw_deg", 0.0) # Blick nach -Z
body.set("pitch_deg", -45.0)
body.call("_apply_look") # yaw/pitch erst durch _apply_look auf die Kamera
var cam: Camera3D = body.get("look_cam")
var eye: Vector3 = cam.global_position # Kapsel-Mitte + EYE_HEIGHT (0.65)
var dir: Vector3 = cam.global_transform.basis * Vector3(0.0, 0.0, -1.0)
_check(absf(dir.y + 0.7071) < 0.01, "Kamera-Blick zeigt 45° nach unten (dir.y ≈ -0.707)")
var h0: float = body.call("terrain_height", eye.x, eye.z)
var th := absf((eye.y - h0) / dir.y)
var expected := eye + dir * th
main._unhandled_key_input(_press_key(KEY_T)) # ECHTER T-Druck (main-Verdrahtung)
await process_frame
await physics_frame
var np := body.position
_check(absf(np.x - expected.x) < 0.15 and absf(np.z - expected.z) < 0.15,
"Teleport-Ziel == Fadenkreuz-Punkt (%.2f,%.2f ≈ %.2f,%.2f)" % [
np.x, np.z, expected.x, expected.z])
var ground_here: float = body.call("terrain_height", np.x, np.z)
_check(absf((np.y - PlaytestBody.HALF_H) - ground_here) <= 0.05,
"Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf((np.y - PlaytestBody.HALF_H) - ground_here))
_check(absf(float(cam.position.y) - 0.65) < 0.001,
"Kamera-Offset 0.65 m über Kapsel-Mitte (identisch TestmapPlayer)")
var stored: Variant = main.get("test_start_point")
_check(stored != null and absf((stored as Vector3).x - np.x) < 0.01
and absf((stored as Vector3).z - np.z) < 0.01,
"T hat den Test-Start AUF den Fadenkreuz-Punkt gesetzt")
var msg: String = (pt.get("hud_msg") as Label).text
_check(msg.contains("Test-Start"), "HUD meldet Klartext: '%s'" % msg)
# b) Kollision: hohe Wand vor dem Spieler (echter StaticBody in der
# Physik-Welt), flacher Blick (−10°) trifft die Wand → ABGELEHNT
# (Kapsel am Zielpunkt nicht frei — wie im Spiel).
var welt: Node3D = main.get("world3d")
var vor := Vector3(body.position)
var wand := StaticBody3D.new()
var wcs := CollisionShape3D.new()
var wshape := BoxShape3D.new()
wshape.size = Vector3(4.0, 20.0, 0.6)
wcs.shape = wshape
wand.add_child(wcs)
wand.position = Vector3(vor.x, 5.0, vor.z - 2.7)
welt.add_child(wand)
body.set("yaw_deg", 0.0)
body.set("pitch_deg", -10.0)
body.call("_apply_look")
main._unhandled_key_input(_press_key(KEY_T))
await process_frame
await physics_frame
_check(body.position.distance_to(vor) < 0.001, "Im Objekt: Teleport abgelehnt, Position unverändert")
var blocked_msg: String = (pt.get("hud_msg") as Label).text
_check(blocked_msg.contains("nicht landen") or blocked_msg.contains("Weg"),
"Abgelehnt: Klartext im HUD: '%s'" % blocked_msg)
wand.queue_free()
await physics_frame
# c) Kisten-Dach: echter Kisten-StaticBody → oben drauf landen (Boden
# DARAUFD, nie im Objekt). Strahl von oben senkrecht auf die Kiste.
var cground: float = body.call("terrain_height", np.x + 5.0, np.z)
var kiste := StaticBody3D.new()
var ccs := CollisionShape3D.new()
var cshape := BoxShape3D.new()
cshape.size = Vector3(1.0, 1.0, 1.0)
ccs.shape = cshape
kiste.add_child(ccs)
kiste.position = Vector3(np.x + 5.0, cground + 0.5, np.z)
welt.add_child(kiste)
var roof: Dictionary = body.call("teleport_to_ground",
Vector3(np.x + 5.0, cground + 3.0, np.z), Vector3(0.0, -1.0, 0.0))
_check(bool(roof.get("ok")) and absf(float(roof.get("ground")) - (cground + 1.0)) < 0.01,
"Kisten-Dach: auf der Decke gelandet (ground %.2f == %.2f)" % [
float(roof.get("ground")), cground + 1.0])
kiste.queue_free()
await physics_frame
# d) ESC: zurück, Editor-Zustand intakt (Punkt bleibt gesetzt).
var doc_before := str(doc)
pt.call("handle_escape")
await process_frame
_check(main.get("playtest") == null, "ESC: zurück im Editor")
_check(str(main.get("doc")) == doc_before, "ESC: Dokument byte-identisch")
_check(main.get("test_start_point") != null, "ESC: gesetzter Test-Start bleibt")
main.call("_test_start_delete")
func _press_key(code: Key) -> InputEventKey:
var key := InputEventKey.new()
key.keycode = code
key.physical_keycode = code
key.pressed = true
return key