diff --git a/docs/bauen.md b/docs/bauen.md index 5a41668..25135c5 100644 --- a/docs/bauen.md +++ b/docs/bauen.md @@ -30,6 +30,25 @@ Ein Bauteil ist ein **ganz normales Bibliotheks-Objekt** (`bau_*`, Kategorie | Dach & Giebel (groß 6 m) | Giebelwand 6 m, Satteldach 6×6, Pyramidendach 6×6 (Spitze 3 m) | | Treppe | Treppe 4 × 2 m, 3 m hoch (15 Stufen) | +## Begehbare Vorlagen + +Die Pack-Gebäude Kaserne, Checkpoint und Container sind innen geschlossen +(einseitige Wände, Türen nur aufgemalt) — Spieler laufen dagegen, nicht hinein. Statt die +Pack-Modelle zu verbiegen, setzt der Bauen-Tab unter „Begehbare Vorlagen“ per Klick ein +fertiges Gebäude aus Bauteilen in die Bildmitte (`src/editor/bau_vorlagen.gd`, ein +Undo-Schritt, danach als Gruppe markiert): + +| Vorlage | Größe | Ausstattung | +| --- | --- | --- | +| Wachhaus (Checkpoint) | 4 × 4 m | Tür vorn, Fenster seitlich, Flachdach | +| Kaserne | 6 × 12 m | Tür vorn + hinten, Fenster an den Längsseiten, Flachdach | +| Lagerraum (Container-Größe) | 4 × 6 m | Tür vorn, Schießscharten seitlich | + +Alles sind normale `bau_*`-Objekte (Trimesh, Türen/Fenster echte Löcher), Quest-tauglich: +kein Innenausbau, ein Draw-Call je Teil. `tests/test_bau_vorlagen.gd` beweist per Strahl +durch die echte Kollision: Tür frei (0,5–2,1 m), Wand massiv, Boden und Dach da, ≥ 2,4 m +Stehhöhe, ein Undo nimmt das Gebäude zurück. + Fertige Pack-Objekte (Container, Hangar-Tore, Betonsperren) stehen NICHT im Bauen-Tab, sondern wie alle Objekte in der Objekt-Bibliothek unten. diff --git a/src/editor/bau_vorlagen.gd b/src/editor/bau_vorlagen.gd new file mode 100644 index 0000000..bf116cf --- /dev/null +++ b/src/editor/bau_vorlagen.gd @@ -0,0 +1,110 @@ +extends RefCounted +class_name BauVorlagen +## Begehbare Gebäude-Vorlagen aus Bauteilen (Karte 25.10.: Kaserne, Checkpoint und +## Container aus dem Pack sind GESCHLOSSENE Hüllen — einseitige Wände, Türen nur +## aufgemalt, kein Innenraum; begehbar werden sie nur, wenn man sie neu baut). +## +## Eine Vorlage setzt ein ganzes Gebäude mit EINEM Klick (ein Undo-Schritt) als neue +## Gruppe. Jedes Teil läuft über dieselbe Snap-Logik wie ein Handklick (BuildSnap) und +## ist danach ein normales Objekt — verschieb-, dreh-, kopier- und löschbar, Türen und +## Fenster sind echte Löcher in der Trimesh-Kollision, Quest-tauglich (Flachdach aus +## Bodenplatten, keine Innenmöbel, ein Draw-Call je Teil). +## +## Zellen sind 2×2 m (BauKatalog.RASTER_M); die Vorlage steht mit der nord-west-Ecke +## auf der Zellecke unter dem Zielpunkt. Vorderseite = Seite mit kleinem z (Tür nach -Z). + +const VORLAGEN := { + "wachhaus": { + "label": "Wachhaus (Checkpoint)", "nx": 2, "nz": 2, "tuer_vorne": 0, "tuer_hinten": -1, + "info": "4 × 4 m, Tür vorn, Fenster seitlich, Flachdach — Ersatz für den geschlossenen Pack-Checkpoint", + }, + "kaserne": { + "label": "Kaserne", "nx": 3, "nz": 6, "tuer_vorne": 1, "tuer_hinten": 1, + "info": "6 × 12 m Schlafsaal, Tür vorn und hinten, Fenster an den Längsseiten, Flachdach", + }, + "lagerraum": { + "label": "Lagerraum (Container-Größe)", "nx": 2, "nz": 3, "tuer_vorne": 0, "tuer_hinten": -1, + "info": "4 × 6 m, Tür vorn, Schießscharten an den Seiten — begehbarer Ersatz für den Container", + }, +} + + +static func schluessel() -> Array: + return VORLAGEN.keys() + + +static func label(key: String) -> String: + return str((VORLAGEN.get(key, {}) as Dictionary).get("label", key)) + + +## Klick-Schritte [oid, Treffpunkt(Vector3), Drehung°, (optional) true = Höhe fest] für die Vorlage `key` mit der +## Nordwest-Ecke bei (ox, oz) auf Höhe y. Reihenfolge = Bauordnung (Boden → Wände → Dach). +static func schritte(key: String, ox: float, oz: float, y: float) -> Array: + var v: Dictionary = VORLAGEN[key] + var nx := int(v["nx"]) + var nz := int(v["nz"]) + var r := BauKatalog.RASTER_M + var x1 := ox + nx * r + var z1 := oz + nz * r + var out: Array = [] + for i in nx: + for j in nz: + out.append(["bau_fundament", Vector3(ox + r * (i + 0.5), y, oz + r * (j + 0.5)), 0.0]) + var seite_oid := "bau_wand_scharte" if key == "lagerraum" else "bau_wand_fenster" + for i in nx: # Vorder- und Rückwand (entlang X) + var cx := ox + r * (i + 0.5) + out.append([_wand(i, int(v["tuer_vorne"]), "bau_wand_fenster" if nx > 2 and i != 1 else "bau_wand"), + Vector3(cx, y, oz + 0.1), 0.0]) + out.append([_wand(i, int(v["tuer_hinten"]), "bau_wand"), Vector3(cx, y, z1 - 0.1), 0.0]) + for j in nz: # Seitenwände (entlang Z): jede zweite Zelle mit Fenster/Scharte + var cz := oz + r * (j + 0.5) + var s := seite_oid if j % 2 == 1 else "bau_wand" + out.append([s, Vector3(ox + 0.1, y, cz), 90.0]) + out.append([s, Vector3(x1 - 0.1, y, cz), 90.0]) + # Flachdach: Bodenplatten auf die Wandoberkante + var oben := y + BauKatalog.WAND_H + for i in nx: + for j in nz: + out.append(["bau_boden", Vector3(ox + r * (i + 0.5), oben, oz + r * (j + 0.5)), 0.0, true]) + return out + + +static func _wand(i: int, tuer_slot: int, sonst: String) -> String: + return "bau_wand_tuer" if i == tuer_slot else sonst + + +## Vorlage setzen: neue Gruppe, alle Teile über BuildSnap, EIN Undo-Eintrag. +## mitte = Zielpunkt (Bodenhöhe = mitte.y). history darf null sein (Tests). +## Rückgabe: {gid, indizes (Array[int] der neuen Objekte), uebersprungen (int)}. +static func setze(doc: Dictionary, history: Variant, key: String, mitte: Vector3) -> Dictionary: + var v: Dictionary = VORLAGEN[key] + var r := BauKatalog.RASTER_M + # Mitte der Grundfläche auf den Zielpunkt, Ecke auf die Zellecke runden + var ox := roundf((mitte.x - int(v["nx"]) * r * 0.5) / r) * r + var oz := roundf((mitte.z - int(v["nz"]) * r * 0.5) / r) * r + var vorher: Dictionary = history.snapshot(["objects", "group_names"]) if history != null else {} + var gid := GroupOps.next_group_id(doc) + GroupState.set_group_name(doc, gid, "%s %d" % [label(key), BauOps.gebaeude_zahl(doc) + 1]) + var objects: Array = doc.get("objects", []) + var start := objects.size() + var gelaende := func(_x: float, _z: float) -> float: return mitte.y + var uebersprungen := 0 + for st in schritte(key, ox, oz, mitte.y): + var teile := BuildSnap.sammle_teile(doc) + var snap := BuildSnap.berechne(str(st[0]), st[1] as Vector3, float(st[2]), teile, gelaende) + # Dachplatten über Zellen ohne Wand darunter: BuildSnap fände dort nur das + # Fundament als Ebene → Höhe der Vorlage erzwingen (= Wandoberkante). + if bool(snap["ok"]) and st.size() > 3 and bool(st[3]): + var p: Vector3 = snap["pos"] + snap["pos"] = Vector3(p.x, (st[1] as Vector3).y, p.z) + if bool(snap["ok"]): + BauOps.setze_teil(doc, str(st[0]), snap, gid, SymmetryCore.Mode.OFF) + else: + uebersprungen += 1 + objects = doc.get("objects", []) + var indizes: Array = [] + for i in range(start, objects.size()): + indizes.append(i) + if history != null: + history.commit("%s gebaut" % label(key), vorher, history.snapshot(["objects", "group_names"])) + return {"gid": gid, "indizes": indizes, "uebersprungen": uebersprungen} diff --git a/src/editor/bau_vorlagen.gd.uid b/src/editor/bau_vorlagen.gd.uid new file mode 100644 index 0000000..67ebed0 --- /dev/null +++ b/src/editor/bau_vorlagen.gd.uid @@ -0,0 +1 @@ +uid://c47romnnk250k diff --git a/src/ui/main.gd b/src/ui/main.gd index 6676497..c0e0d1e 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -1794,6 +1794,25 @@ func _bau_neues_gebaeude() -> void: _set_status("Neues Gebäude: das nächste Bauteil beginnt eine neue Gruppe.") +## Begehbare Vorlage (Kaserne, Wachhaus, Lagerraum) in die Bildmitte setzen — +## EIN Undo-Schritt, danach ist das ganze Gebäude markiert (verschieben/drehen). +func _bau_vorlage_setzen(key: String) -> void: + if doc.is_empty(): + return + if tool_mode != "objects": + _set_tool_mode("objects") + var ziel: Vector3 = camera.get("target") + ziel.y = SvmapDocument.terrain_height_at(doc, ziel.x, ziel.z) + var res := BauVorlagen.setze(doc, history, key, ziel) + _after_structure_change() + interaction.call("select_many", res["indizes"], true) + var hinweis := "" + if int(res["uebersprungen"]) > 0: + hinweis = " (%d Teile passten nicht ins Raster und fehlen)" % int(res["uebersprungen"]) + _set_status("%s gesetzt (%d Teile)%s — Ziehen verschiebt, R dreht, Strg+Z nimmt es zurück." % [ + BauVorlagen.label(key), (res["indizes"] as Array).size(), hinweis]) + + ## Alle Teile des Gebäudes wählen, zu dem die Auswahl gehört. func _bau_gebaeude_waehlen() -> bool: var sel: Array = interaction.selected_indices diff --git a/src/ui/panels/bauen_panel.gd b/src/ui/panels/bauen_panel.gd index c485d1f..af91b63 100644 --- a/src/ui/panels/bauen_panel.gd +++ b/src/ui/panels/bauen_panel.gd @@ -27,6 +27,12 @@ func build() -> void: add_child(_field_label(str(gruppe["label"]))) add_child(_knopf_raster(gruppe["oids"] as Array)) add_child(_separator()) + add_child(_header_label("Begehbare Vorlagen")) + add_child(_muted_label("Pack-Kaserne, -Checkpoint und -Container sind innen geschlossen. Diese Vorlagen setzen ein begehbares Gebäude mit einem Klick in die Bildmitte (Tür, Fenster, Dach, Strg+Z nimmt alles zurück).")) + for key in BauVorlagen.schluessel(): + add_child(_aktion("🏠 %s" % BauVorlagen.label(key), str(BauVorlagen.VORLAGEN[key]["info"]), + func() -> void: main.call("_bau_vorlage_setzen", key))) + add_child(_separator()) add_child(_header_label("Gebäude")) info_label = _muted_label("") add_child(info_label) diff --git a/tests/test_bau_vorlagen.gd b/tests/test_bau_vorlagen.gd new file mode 100644 index 0000000..a48ff17 --- /dev/null +++ b/tests/test_bau_vorlagen.gd @@ -0,0 +1,121 @@ +extends SceneTree +## Suite: begehbare Gebäude-Vorlagen (Kaserne, Wachhaus, Lagerraum). +## Ausführen: godot4 --headless --path . --script res://tests/test_bau_vorlagen.gd +## Beweis „begehbar“: Strahlen durch die ECHTE Trimesh-Kollision aller gesetzten Teile — +## durch die Tür frei bis in die Raummitte, Wand daneben massiv, Raum hat Boden und Decke. + +var failures := 0 +var checks := 0 + + +func _init() -> void: + _run_tests() + 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) + + +## Alle Kollisions-Dreiecke der Objekt-Indizes in Weltkoordinaten. +func _welt_dreiecke(doc: Dictionary, indizes: Array) -> PackedVector3Array: + var out := PackedVector3Array() + var cache := {} + for i in indizes: + var o: Dictionary = doc["objects"][i] + var oid := str(o["id"]) + if not cache.has(oid): + cache[oid] = BauKatalog.mesh(oid).get_faces() + var b := Basis(Vector3.UP, deg_to_rad(float(o.get("rot_y_deg", 0.0)))) + var p := Vector3(float(o["pos"][0]), float(o["pos"][1]), float(o["pos"][2])) + for f in (cache[oid] as PackedVector3Array): + out.append(b * f + p) + return out + + +func _trifft(tris: PackedVector3Array, origin: Vector3, dir: Vector3, laenge: float) -> bool: + for k in int(tris.size() / 3): + var t := SelectionMath.ray_tri(origin, dir, tris[k * 3], tris[k * 3 + 1], tris[k * 3 + 2]) + if t >= 0.0 and t <= laenge: + return true + return false + + +func _run_tests() -> void: + var lib: Dictionary = SvmapIO._manifest_or({})["objects"] + for key in BauVorlagen.schluessel(): + print("Vorlage ", key) + var v: Dictionary = BauVorlagen.VORLAGEN[key] + var doc := SvmapDocument.create_new("V", 256.0, "mittag") + doc["objects"] = [{"id": "barrier_001", "pos": [40.0, 0.0, 40.0], "scale": 1.0, "rot_y_deg": 0.0}] + var hist := ActionHistory.new() + hist.begin(doc) + var res := BauVorlagen.setze(doc, hist, key, Vector3(60.0, 0.0, 60.0)) + var idx: Array = res["indizes"] + var nx := int(v["nx"]) + var nz := int(v["nz"]) + var erwartet := nx * nz * 2 + 2 * (nx + nz) + _check(int(res["uebersprungen"]) == 0, "%s: kein Teil abgelehnt" % key) + _check(idx.size() == erwartet, "%s: %d Teile (Soll %d)" % [key, idx.size(), erwartet]) + var gruppen := {} + var alle_in_bib := true + for i in idx: + gruppen[GroupOps.group_id(doc["objects"][i])] = true + alle_in_bib = alle_in_bib and lib.has(str(doc["objects"][i]["id"])) + _check(gruppen.size() == 1 and gruppen.has(int(res["gid"])), "%s: alle Teile in EINER neuen Gruppe" % key) + _check(alle_in_bib, "%s: alle Teile stehen in der Bibliothek (Server/Client kennen sie)" % key) + _check(SvmapIO.validate_map(_signiert(doc)).is_empty(), "%s: Map mit Vorlage besteht die Validierung" % key) + + # begehbar: Tür frei, Wand massiv + var tris := _welt_dreiecke(doc, idx) + var r := BauKatalog.RASTER_M + var ox := roundf((60.0 - nx * r * 0.5) / r) * r + var oz := roundf((60.0 - nz * r * 0.5) / r) * r + var tuer_x := ox + r * (int(v["tuer_vorne"]) + 0.5) + var mitte_z := oz + nz * r * 0.5 + var laenge := mitte_z - (oz - 3.0) + for h in [0.5, 1.0, 1.8, 2.1]: + for dx in [-0.4, 0.0, 0.4]: + _check(not _trifft(tris, Vector3(tuer_x + dx, h, oz - 3.0), Vector3(0, 0, 1), laenge), + "%s: Tür frei bei Höhe %.1f, Versatz %.1f" % [key, h, dx]) + # daneben (andere Zelle der Vorderwand) oder Seitenwand: massiv + var wand_x := ox + r * (1 - int(v["tuer_vorne"]) + 0.5) if nx > 1 else ox + _check(_trifft(tris, Vector3(wand_x, 1.0, oz - 3.0), Vector3(0, 0, 1), laenge), + "%s: Wand neben der Tür ist massiv" % key) + # Seitenwand (Zelle j=0 ist ohne Fenster) + _check(_trifft(tris, Vector3(ox - 3.0, 1.0, oz + r * 0.5), Vector3(1, 0, 0), 3.0 + nx * r * 0.5), + "%s: Seitenwand ist massiv" % key) + # Raum hat Boden (y≈0.2) und Decke (≥ 2.5 m Stehhöhe) + var cx := ox + nx * r * 0.5 + 0.3 + _check(_trifft(tris, Vector3(cx, 2.0, mitte_z), Vector3.DOWN, 2.0), "%s: Boden unter dem Innenraum" % key) + _check(not _trifft(tris, Vector3(cx, 0.25, mitte_z), Vector3.UP, 2.4), "%s: ≥ 2,4 m Stehhöhe über dem Boden" % key) + _check(_trifft(tris, Vector3(cx, 0.25, mitte_z), Vector3.UP, 3.3), "%s: Dach schließt den Raum" % key) + # Hintertür (Kaserne): frei bis zur Raummitte von hinten + if int(v["tuer_hinten"]) >= 0: + var hx := ox + r * (int(v["tuer_hinten"]) + 0.5) + var z1 := oz + nz * r + _check(not _trifft(tris, Vector3(hx, 1.0, z1 + 3.0), Vector3(0, 0, -1), z1 + 3.0 - mitte_z), + "%s: Hintertür frei" % key) + + # Undo/Redo: ein Schritt nimmt das ganze Gebäude zurück + var n_nach := (doc["objects"] as Array).size() + hist.undo() + _check((doc["objects"] as Array).size() == 1, "%s: ein Undo entfernt das ganze Gebäude" % key) + hist.redo() + _check((doc["objects"] as Array).size() == n_nach, "%s: Redo bringt es zurück" % key) + + +func _signiert(doc: Dictionary) -> Dictionary: + var d := doc.duplicate(true) + SvmapIO.sign_map(d) + return d diff --git a/tests/test_bau_vorlagen.gd.uid b/tests/test_bau_vorlagen.gd.uid new file mode 100644 index 0000000..b851f19 --- /dev/null +++ b/tests/test_bau_vorlagen.gd.uid @@ -0,0 +1 @@ +uid://dj0e5rfsirlrc