From ab173ac8aab078c2be513a98f6e3b7808f3fd827 Mon Sep 17 00:00:00 2001 From: scopedvr-operator Date: Tue, 20 Oct 2026 09:43:05 +0000 Subject: [PATCH] T2 Ueberall installierbar (Operator-Push): Modelle aus mitgeliefertem models/-Ordner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker hatte die Arbeit fertig, aber NICHT committet/gepusht (3. Fall heute) — aus dem Karten-Arbeitsordner gerettet und hier gepusht. - GlbMeshStore.quellen(): Reihenfolge EINE Stelle — Setting -> Sibling-Repos -> mitgelieferter Ordner models/ direkt neben der App (dedupliziert, typisiert) - Klartext-Meldung FEHLT_TEXT statt stillem Platzhalter; Notfall-Modus: Terrain und Bauteile laufen ohne GLBs - neu: tools/sync_models.sh (fuellt models/, --check), utils/modelle_status.gd (Autoload ModelleStatus), tools/probe_gen_map.gd - neu: README_INSTALL.md (85 Zeilen, Schritt fuer Schritt + §5 Lizenzhinweis: ITHappy-Rohdaten NICHT weitergeben, App fuer Slys eigene Rechner) - export_presets.cfg (eigenstaendiger Linux-/Windows-Export), .gitignore: models/ + export/ bleiben draussen (Lizenz + Groesse) - Tests: test_model_sources (26/27; die rote ist umgebungsabhaengig: 'lager_ist_leer' erwartet fehlende Sibling-Repos) Belegt: Import 0 Fehler, gui_smoke 92, ui_layout 71, playtest_spawn 56. --- .gitignore | 8 ++ README_INSTALL.md | 85 ++++++++++++ export_presets.cfg | 29 ++++ project.godot | 7 + src/viewport/glb_mesh_store.gd | 103 ++++++++++++-- tests/test_model_sources.gd | 229 ++++++++++++++++++++++++++++++++ tests/test_model_sources.gd.uid | 1 + tools/probe_gen_map.gd | 26 ++++ tools/probe_gen_map.gd.uid | 1 + tools/sync_models.sh | 185 ++++++++++++++++++++++++++ utils/modelle_status.gd | 102 ++++++++++++++ utils/modelle_status.gd.uid | 1 + 12 files changed, 769 insertions(+), 8 deletions(-) create mode 100644 README_INSTALL.md create mode 100644 export_presets.cfg create mode 100644 tests/test_model_sources.gd create mode 100644 tests/test_model_sources.gd.uid create mode 100644 tools/probe_gen_map.gd create mode 100644 tools/probe_gen_map.gd.uid create mode 100755 tools/sync_models.sh create mode 100644 utils/modelle_status.gd create mode 100644 utils/modelle_status.gd.uid diff --git a/.gitignore b/.gitignore index bcad1fe..b470d72 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,13 @@ .godot/ *.tmp +# Mitgelieferte Modelle (Karte t_12cf94fe): werden per tools/sync_models.sh +# aus dem Client-Klon gefüllt — NICHT ins Repo (ITHappy-Lizenz erlaubt keine +# Weitergabe, siehe README_INSTALL.md §5). +models/ + +# Export-Builds der eigenständigen App +export/ + # System .DS_Store diff --git a/README_INSTALL.md b/README_INSTALL.md new file mode 100644 index 0000000..bc449e6 --- /dev/null +++ b/README_INSTALL.md @@ -0,0 +1,85 @@ +# ScopedVR Map Builder auf einem anderen PC installieren + +Diese Anleitung erklärt Schritt für Schritt, wie du den Map Builder auf einem +beliebigen PC zum Laufen bringst und dort eine Map testen kannst — **ohne** +Godot-Installation, **ohne** ScopedVR-Client, **ohne** Programmierkenntnisse. + +--- + +## 1. Voraussetzungen + +- Ein normaler PC (Linux oder Windows). Kein Godot, keine Kommandozeile nötig, + wenn die App einmal exportiert wurde. +- Die exportierte App (Ein Ordner oder eine EXE, siehe Schritt 2). +- Den Ordner `models/` mit den 3D-Modellen (siehe Schritt 3). + +## 2. Die App bekommen + +Die App ist eine eigenständige Datei (z. B. `MapEditor` unter Linux, +`MapEditor.exe` unter Windows). Sie wurde aus dem Map-Editor-Repo exportiert +(`export_presets.cfg`, Preset „Linux“). Auf dem Ziel-PC brauchst du sie nur +in einen beliebigen Ordner zu kopieren, z. B.: + + ~/MapBuilder/ (Linux) + C:\MapBuilder\ (Windows) + +Doppelklick drauf — die App startet. Das war's. Keine Installation. + +## 3. Die Modelle danebenlegen (WICHTIG) + +Ohne Modelle zeigt die App nur einfache Platzhalter-Kisten. Damit die echten +3D-Modelle erscheinen, muss der Ordner `models/` **direkt neben** der App +liegen (genau so wie hier): + + ~/MapBuilder/MapEditor ← die App + ~/MapBuilder/models/ ← die Modelle + +Wie der `models/`-Ordner gefüllt wird: Auf einem PC, auf dem der +Client-Klon liegt, im Map-Editor-Repo laufen lassen: + + tools/sync_models.sh + +Das Script kopiert alle Objekte (GLB) und die benötigten Texturen in den +`models/`-Ordner — idempotent, du kannst es also gefahrlos mehrmals laufen +lassen. Mit `--check` prüft es nur (Exit 0 = alles aktuell, Exit 1 = es fehlt +etwas). Die Größe wird am Ende angezeigt. + +Falls die Modelle fehlen, sagt die App beim Start deutlich: +> „Für die Anzeige fehlen die Modelle. So bekommst du sie: …“ + +Terrain und Bauteile (Wände, Dächer usw.) funktionieren **immer** — auch ganz +ohne Modelle (Notfall-Modus). + +## 4. Eine Map testen + +1. App starten. +2. Map öffnen: Datei → Map öffnen, dann die gewünschte `.svmap`-Datei wählen. +3. Zum Testen: Taste **F5** drücken. Du läufst dann selbst durch die Map + (WASD = gehen, Maus = umsehen, Shift = rennen, Leertaste = springen). +4. **ESC** zurück in den Editor. + +## 5. Lizenz-Hinweis (bitte wirklich lesen) + +Die 3D-Modelle und Texturen im `models/`-Ordner stammen aus dem +**ITHappy-Pack**. Die Lizenz dieses Packs erlaubt es **nicht**, die +Roh-Modelle und Texturen weiterzugeben. Das heißt konkret: + +- Die App mit `models/`-Ordner ist für **Slys eigene Rechner** gedacht. +- Der Ordner **darf nicht** an andere Leute geschickt oder hochgeladen werden. +- Die App selbst (ohne `models/`-Ordner) darf geteilt werden — sie zeigt dann + eben nur Platzhalter, bis der Ordner dazukommt. + +## 6. Wenn etwas nicht geht + +| Problem | Lösung | +| --- | --- | +| Nur Kisten statt echter Modelle | `models/`-Ordner direkt neben die App legen (Schritt 3) | +| Meldung „Modelle fehlen“ | `tools/sync_models.sh` ausführen (Schritt 3) | +| App startet gar nicht | Auf Linux: Datei per Rechtsklick → „Ausführen“ erlauben, oder im Terminal `chmod +x MapEditor` | +| Map lässt sich nicht öffnen | Ist es wirklich eine `.svmap`-Datei? | + +--- +*Technischer Hintergrund: die Modelle werden zur Laufzeit per GLTFDocument +aus dem `models/`-Ordner geladen (siehe `src/viewport/glb_mesh_store.gd`, +Quellen-Reihenfolge: Einstellungen → Nachbar-Ordner `../scopedvr-client` → +`models/`). Erster Treffer gewinnt.* diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..4d4cb65 --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,29 @@ +[preset.0] + +name="Linux" +platform="Linux" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="models/*" +export_path="export/MapEditor.x86_64" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=0 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +binary_format/architecture="x86_64" +ssh_remote_deploy/enabled=false diff --git a/project.godot b/project.godot index 87ea766..9c61cd1 100644 --- a/project.godot +++ b/project.godot @@ -11,6 +11,13 @@ run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.7") config/icon="res://icon.svg" +[autoload] + +; Modell-Status + Probe (Karte t_12cf94fe): Klartext-Banner, wenn Modelle +; fehlen, und --probe-map=/pfad für den Eigenständigkeits-Beweis der +; exportierten App. +ModelleStatus="*res://utils/modelle_status.gd" + [display] window/size/viewport_width=1600 diff --git a/src/viewport/glb_mesh_store.gd b/src/viewport/glb_mesh_store.gd index 4442d53..200195f 100644 --- a/src/viewport/glb_mesh_store.gd +++ b/src/viewport/glb_mesh_store.gd @@ -24,6 +24,21 @@ class_name GlbMeshStore const QUELLEN_SETTING := "scopedvr/editor/glb_quellen" ## Voreinstellung = dieselben Quellen wie tools/gen_thumbs.gd (Sibling-Repos). const DEFAULT_QUELLEN := ["../scopedvr-match-server/assets/objekte", "../scopedvr-client/assets/objekte"] +## Mitgelieferter Modell-Ordner (Karte t_12cf94fe): direkt neben der App +## (im Editor = Projektordner, in der exportierten App = neben der EXE). +## Der sync über tools/sync_models.sh legt hier die GLBs + Pack-Texturen +## ab — dann läuft der Map Builder auf einem fremden PC OHNE Sibling-Repos. +## Der Ordner trägt eine .gdignore (kein Import/PCK-Aufnahme; FileAccess +## mit absolutem Pfad funktioniert trotzdem). +const MODELS_DIR := "models" +## Klarer Hinweistext (Karte t_12cf94fe, Auftrag 3), wenn Modelle fehlen: +## wird als Klartext-Meldung gezeigt — kein stiller Platzhalter. +## Notfall-Modus: Terrain/Bauteile brauchen keine GLBs und funktionieren immer. +const FEHLT_TEXT := "Für die Anzeige fehlen die Modelle. So bekommst du sie:\n" \ + + " 1. tools/sync_models.sh aus dem Map-Editor-Ordner laufen lassen\n" \ + + " (kopiert die Modelle in den Ordner %s),\n" \ + + " 2. oder in den Einstellungen einen Ordner mit den Objekt-GLBs setzen.\n" \ + + "Terrain und Bauteile funktionieren trotzdem vollständig (Notfall-Modus)." ## Registry (A6): liefert das Feld "glb" für die Kategorie-verschachtelte Ablage. const MANIFEST_PATH := "res://library/objects.json" static var _manifest: Dictionary = {} @@ -63,16 +78,60 @@ static var _camo_ersetzung_cache: Dictionary = {} static var _cache: Dictionary = {} -## Kandidaten-Pfade der Quellen (ProjectSettings → Fallback-Konstante). +## Kandidaten-Pfade der Quellen in Ladereihenfolge (Karte t_12cf94fe, +## Auftrag 1 — EINE Stelle, erster Treffer gewinnt): +## 1. in den Einstellungen gesetzter Pfad (QUELLEN_SETTING), +## 2. bisheriges Verhalten = Sibling-Repos (DEFAULT_QUELLEN), +## 3. mitgelieferter Ordner models/ direkt neben der App (MODELS_DIR). +## Doppelnennungen werden gefiltert (Reihenfolge bleibt erhalten); ein +## ungültiger Settings-Wert (kein Array) fällt komplett auf die Vorgabe. static func quellen() -> Array: - var setting: Variant = ProjectSettings.get_setting(QUELLEN_SETTING, DEFAULT_QUELLEN) - if not (setting is Array or setting is PackedStringArray): - return DEFAULT_QUELLEN.duplicate() var out: Array = [] - for q in setting: - if q is String and not (q as String).strip_edges().is_empty(): - out.append((q as String).strip_edges()) - return out if not out.is_empty() else DEFAULT_QUELLEN.duplicate() + var setting: Variant = ProjectSettings.get_setting(QUELLEN_SETTING, DEFAULT_QUELLEN) + if setting is Array or setting is PackedStringArray: + for q in setting: + _quelle_add(out, q) + for q in DEFAULT_QUELLEN: + _quelle_add(out, q) + _quelle_add(out, MODELS_DIR) + return out + + +## Gültigen Quellpfad dedupliziert anhängen (typisiert, keine impliziten casts). +static func _quelle_add(out: Array, q: Variant) -> void: + if not (q is String): + return + var s := (q as String).strip_edges() + if s.is_empty() or out.has(s): + return + out.append(s) + + +## Absoluter Pfad des mitgelieferten Modell-Ordners (für die Klartext- +## Meldung und tools/sync_models.sh-Verweis). Im Editor = Projektordner, +## in der exportierten App = Ordner der EXE. +static func models_pfad() -> String: + return ProjectSettings.globalize_path("res://").path_join(MODELS_DIR) + + +## Erster EXISTIERENDER Dateipfad für die OID (ohne Laden/Parse — billiger +## Datei-Check über alle Kandidaten in Quellen-Reihenfolge). Leerer String = +## kein Quellpfad hat die Datei. Für Klartext-Meldungen/Probe-Logs: zeigt, +## AUS WELCHEM Ordner die Modelle kommen. +static func probe_source_fuer(oid: String) -> String: + if oid.is_empty(): + return "" + for pfad in _kandidaten(oid): + if FileAccess.file_exists(pfad): + return pfad + return "" + + +## Ausgefüllter Klartext-Hinweis (FEHLT_TEXT + echter models/-Pfad) — +## diese EINE Funktion liefert die Meldung, die die App zeigt, wenn +## Modelle fehlen (Karte t_12cf94fe, Auftrag 3). +static func fehl_text() -> String: + return FEHLT_TEXT % models_pfad() ## Kandidaten-Pfade (absolut) in Ladereihenfolge: Registry-`glb` (A6, Kategorie- @@ -139,6 +198,34 @@ static func fehlt(oid: String) -> bool: return true +## true = KEINE Quelle (Einstellungen/Siblings/models/) enthält irgend eine +## GLB-Datei → die App hat nirgends echte Modelle. Für das Klartext-Banner +## beim Start (Karte t_12cf94fe, Auftrag 3). Nicht-cachend, einmal pro Start. +static func lager_ist_leer() -> bool: + for quelle in quellen(): + if _quelle_hat_glb(ProjectSettings.globalize_path("res://").path_join(str(quelle))): + return false + return true + + +## Enthält der Ordner (rekursiv) mindestens eine .glb-Datei? +static func _quelle_hat_glb(basis: String) -> bool: + var d := DirAccess.open(basis) + if d == null: + return false + d.list_dir_begin() + var name := d.get_next() + while name != "": + var pfad := basis.path_join(name) + if d.current_is_dir(): + if _quelle_hat_glb(pfad): + return true + elif name.ends_with(".glb"): + return true + name = d.get_next() + return false + + ## Cache leeren (Tests / nach manuellem Eingriff ins Mesh-Lager). static func cache_leeren() -> void: _cache.clear() diff --git a/tests/test_model_sources.gd b/tests/test_model_sources.gd new file mode 100644 index 0000000..a3a68ba --- /dev/null +++ b/tests/test_model_sources.gd @@ -0,0 +1,229 @@ +extends SceneTree +## Editor-Tests (headless): Karte t_12cf94fe — Modell-Quellen-Reihenfolge, +## mitgelieferter models/-Ordner, Klartext-Banner, Notfall-Modus. +## Ausführen: godot --headless --path . --script res://tests/test_model_sources.gd +## Exit 0 = alles grün. Prüft: +## 1. quellen(): Reihenfolge Einstellungs-Pfad → Sibling-Defaults → models/, +## Duplikate gefiltert, ungültiger Settings-Wert fällt auf die Vorgabe. +## 2. probe_source_fuer(oid): erster EXISTIERENDER Kandidat gewinnt, ohne +## Parse (billiger FileAccess-Check), leerer String ohne Fund. +## 3. fehl_text(): Klartext-Meldung enthält models/-Pfad + sync_models.sh. +## 4. lager_ist_leer(): true ohne GLB-Quellen, false sobald eine Quelle eine +## GLB hat (auch rekursiv verschachtelt). +## 5. Notfall-Modus: fehlende OID → fehlt()=true, mesh_fuer()=null (Platzhalter +## ist Sache des view_builders — hier nur der Store-Vertrag). +## 6. models_pfad(): endet auf /models, neben der App (res://-Wurzel). +## +## Die Tests arbeiten auf TEMP-Ordnern und ProjectSettings im Speicher — +## sie berühren KEINE echten Dateien im Repo. + +var failures := 0 +var checks := 0 + + +func _init() -> void: + # Vorgabe-Zustand: keine Einstellungs-Quellen (Sly-PCs ohne Siblings sind + # der Normalfall dieser Karte). + if ProjectSettings.has_setting(GlbMeshStore.QUELLEN_SETTING): + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, PackedStringArray()) + GlbMeshStore.cache_leeren() + _test_quellen_reihenfolge() + _test_probe_source() + _test_fehl_text() + _test_lager_ist_leer() + _test_notfall_modus() + _test_models_pfad() + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _ok(bedingung: bool, name: String, detail := "") -> void: + checks += 1 + if bedingung: + print(" ok: %s" % name) + else: + failures += 1 + print(" FEHLER: %s%s" % [name, " — " + detail if detail != "" else ""]) + + +func _temp_ordner(na: String) -> String: + var p := OS.get_environment("TMPDIR") + if p.is_empty(): + p = "/tmp" + var out := p.path_join("t12cf94fe_" + na + "_" + str(randi() % 100000)) + DirAccess.make_dir_recursive_absolute(out) + return out + + +## 1. Reihenfolge: (1) Settings-Pfad, (2) Defaults, (3) models/ — Duplikate raus. +func _test_quellen_reihenfolge() -> void: + # Ohne Setting: Defaults + models/ am Ende, keine Duplikate. + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, PackedStringArray()) + var q: Array = GlbMeshStore.quellen() + _ok(q.size() == 3, "quellen: ohne Setting = 2 Defaults + models/", str(q)) + _ok(q[0] == GlbMeshStore.DEFAULT_QUELLEN[0] and q[1] == GlbMeshStore.DEFAULT_QUELLEN[1], + "quellen: Defaults zuerst (bisheriges Verhalten)", str(q)) + _ok(q[2] == GlbMeshStore.MODELS_DIR, "quellen: models/ zuletzt", str(q)) + # Mit Setting: der gesetzte Pfad gewinnt (erster Treffer). + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, + PackedStringArray(["/eigener/pfad", q[0] as String])) + var q2: Array = GlbMeshStore.quellen() + _ok(q2[0] == "/eigener/pfad", "quellen: gesetzter Pfad gewinnt", str(q2)) + _ok(q2.find(GlbMeshStore.MODELS_DIR) == q2.size() - 1, + "quellen: models/ bleibt am Ende", str(q2)) + _ok(q2.size() == 4, "quellen: Duplikat Default[0] gefiltert", str(q2)) + # Leere/whitespace Einträge fliegen raus. + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, + PackedStringArray(["", " ", "/x"])) + var q3: Array = GlbMeshStore.quellen() + _ok(q3[0] == "/x", "quellen: leere Einträge gefiltert", str(q3)) + # Ungültiger Settings-Wert (kein Array) → fällt auf Vorgabe-Verhalten. + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, "kein-array") + var q4: Array = GlbMeshStore.quellen() + _ok(q4.size() >= 3 and q4[0] == GlbMeshStore.DEFAULT_QUELLEN[0], + "quellen: ungültiger Wert → Vorgabe", str(q4)) + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, PackedStringArray()) + + +## 2. probe_source_fuer: erster EXISTIERENDER Pfad, Reihenfolge respektiert, +## verschachtelter Registry-Pfad VOR flacher Alt-Ablage, kein Parse. +## Der verschachtelte Test nutzt eine echte Registry-OID (sonst erzeugt +## _kandidaten keinen verschachtelten Kandidaten-Pfad). +func _test_probe_source() -> void: + var basis := _temp_ordner("probe") + var a := basis.path_join("a_objekte") + var b := basis.path_join("b_objekte") + var models := basis.path_join("models") + # Echte OID mit verschachteltem glb-Pfad aus der Registry holen. + var manifest: Dictionary = {} + var parsed: Variant = JSON.parse_string( + FileAccess.get_file_as_string(GlbMeshStore.MANIFEST_PATH)) + if parsed is Dictionary: + manifest = (parsed as Dictionary).get("objects", {}) + var oid := "" + var sub := "" + for key: String in manifest: + var eintrag: Variant = manifest[key] + if eintrag is Dictionary: + var glb := str((eintrag as Dictionary).get("glb", "")) + if glb.begins_with("res://"): + oid = key + sub = glb.substr(6) + break + if oid.is_empty(): + print(" SKIP: kein OID mit res://-glb in der Registry — verschachtelter Pfad nicht testbar") + return + DirAccess.make_dir_recursive_absolute(a.path_join(sub.get_base_dir())) + DirAccess.make_dir_recursive_absolute(b) + DirAccess.make_dir_recursive_absolute(models) + # (1) verschachtelt in a (Registry-Pfad), (2) flach in b, (3) models/. + var glb_a := a.path_join(sub) + var glb_b := b.path_join(oid + ".glb") + var glb_m := models.path_join(oid + ".glb") + for p: String in [glb_a, glb_b, glb_m]: + var f := FileAccess.open(p, FileAccess.WRITE) + f.store_8(0) # Inhalt egal — probe nur file_exists, kein Parse + f.close() + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, PackedStringArray( + [_relativ(basis, a), _relativ(basis, b), _relativ(basis, models)])) + GlbMeshStore.cache_leeren() + _ok(GlbMeshStore.probe_source_fuer(oid).simplify_path() == glb_a.simplify_path(), + "probe: verschachtelter Registry-Pfad in Quelle 1 gewinnt", + GlbMeshStore.probe_source_fuer(oid)) + _ok(GlbMeshStore.probe_source_fuer(oid).contains("/a_objekte/"), + "probe: Pfad enthält Quelle 1", GlbMeshStore.probe_source_fuer(oid)) + # Nur models/ hat die Datei → Pfad aus models/. + var oid2 := "nur_models_oid" + var glb_m2 := models.path_join(oid2 + ".glb") + var f2 := FileAccess.open(glb_m2, FileAccess.WRITE) + f2.store_8(0) + f2.close() + _ok(GlbMeshStore.probe_source_fuer(oid2).simplify_path() == glb_m2.simplify_path(), + "probe: models/-Pfad gefunden, wenn nur dort vorhanden", + GlbMeshStore.probe_source_fuer(oid2)) + _ok(GlbMeshStore.probe_source_fuer(oid2).contains("/models/"), + "probe: Pfad enthält /models/", GlbMeshStore.probe_source_fuer(oid2)) + _ok(GlbMeshStore.probe_source_fuer("gaenzlich_fehlt") == "", + "probe: leerer String ohne Fund") + _ok(GlbMeshStore.probe_source_fuer("") == "", "probe: leere OID → leerer String") + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, PackedStringArray()) + GlbMeshStore.cache_leeren() + + +## 3. Klartext-Meldung: models/-Pfad + sync_models.sh-Verweis, mehrzeilig. +func _test_fehl_text() -> void: + var text := GlbMeshStore.fehl_text() + _ok(text.contains(GlbMeshStore.models_pfad()), "fehl_text: models/-Pfad drin") + _ok(text.contains("sync_models.sh"), "fehl_text: sync_models.sh-Verweis drin") + _ok(text.contains("Notfall-Modus"), "fehl_text: Notfall-Modus erwähnt") + _ok(text.contains("\n"), "fehl_text: mehrzeilig (klar lesbar)") + _ok(not text.contains("%s"), "fehl_text: kein unfüllter Platzhalter") + + +## 4. lager_ist_leer: ohne Quellen true; false, sobald eine Quelle (auch +## verschachtelt) eine GLB trägt. models/ zählt als Quelle. +func _test_lager_ist_leer() -> void: + var basis := _temp_ordner("leer") + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, PackedStringArray()) + GlbMeshStore.cache_leeren() + _ok(GlbMeshStore.lager_ist_leer(), + "lager_ist_leer: true ohne GLB-Quellen (Testumgebung hat keine Siblings)") + var tief := basis.path_join("tief/verschachtelt") + DirAccess.make_dir_recursive_absolute(tief) + var f := FileAccess.open(tief.path_join("eins.glb"), FileAccess.WRITE) + f.store_8(0) + f.close() + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, + PackedStringArray([_relativ(basis, basis)])) + _ok(not GlbMeshStore.lager_ist_leer(), + "lager_ist_leer: false mit verschachtelter GLB in Quelle") + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, PackedStringArray()) + GlbMeshStore.cache_leeren() + + +## 5. Notfall-Modus: fehlende OID → fehlt()=true, mesh_fuer()=null. Ohne Modelle +## darf der Store nicht crashen (Platzhalter übernimmt der view_builder). +func _test_notfall_modus() -> void: + ProjectSettings.set_setting(GlbMeshStore.QUELLEN_SETTING, PackedStringArray()) + GlbMeshStore.cache_leeren() + _ok(GlbMeshStore.fehlt("notfall_test_oid"), "notfall: fehlt()=true ohne Quellen") + _ok(GlbMeshStore.mesh_fuer("notfall_test_oid") == null, + "notfall: mesh_fuer()=null ohne Quellen (view_builder zeigt Platzhalter)") + _ok(GlbMeshStore.probe_source_fuer("notfall_test_oid") == "", + "notfall: probe_source_fuer() leer ohne Quellen") + + +## 6. models_pfad: direkt neben der App (res://-Wurzel), endet auf /models. +func _test_models_pfad() -> void: + var p := GlbMeshStore.models_pfad() + _ok(p.ends_with("/models"), "models_pfad: endet auf /models", p) + _ok(p.begins_with(ProjectSettings.globalize_path("res://")), + "models_pfad: neben der App (res://-Wurzel)", p) + _ok(p == ProjectSettings.globalize_path("res://").path_join("models"), + "models_pfad: exakt res://-Wurzel/models", p) + + +## Relativer Pfad (vom res://-Wurzel aus) — Quellen sind basis-relativ. +## (_basis wird ignoriert: die Funktion rechnet IMMER von der res://-Wurzel, +## wie _relativ_zu in test_glb_view.gd — sonst zeigt der Pfad in den falschen +## Ordner, wenn _kandidaten ihn wieder an die Wurzel hängt.) +static func _relativ(_basis: String, pfad: String) -> String: + var von := ProjectSettings.globalize_path("res://").simplify_path() + var n := pfad.simplify_path() + var v: PackedStringArray = von.split("/", false) + var teile_n: PackedStringArray = n.split("/", false) + var i := 0 + while i < v.size() and i < teile_n.size() and v[i] == teile_n[i]: + i += 1 + var out := PackedStringArray() + for _j: int in range(i, v.size()): + out.append("..") + for k: int in range(i, teile_n.size()): + out.append(teile_n[k]) + if out.is_empty(): + return "." + return "/".join(out) diff --git a/tests/test_model_sources.gd.uid b/tests/test_model_sources.gd.uid new file mode 100644 index 0000000..aa8dd64 --- /dev/null +++ b/tests/test_model_sources.gd.uid @@ -0,0 +1 @@ +uid://bff2puoer0n1i diff --git a/tools/probe_gen_map.gd b/tools/probe_gen_map.gd new file mode 100644 index 0000000..ad42e97 --- /dev/null +++ b/tools/probe_gen_map.gd @@ -0,0 +1,26 @@ +extends SceneTree +## Probe-Map-Generator (Karte t_12cf94fe): erstellt eine .svmap mit Objekten, +## die in der Bibliothek existieren, um den Eigenständigkeits-Beweis der +## exportierten App zu fahren. +## Aufruf: godot4 --headless --path . --script res://tools/probe_gen_map.gd \ +## -- --out=/pfad/zu/probe_map.svmap + + +func _init() -> void: + var out := "/tmp/scopedvr_sandbox/probe_map.svmap" + for a in OS.get_cmdline_user_args(): + if a.begins_with("--out="): + out = a.substr(6) + var doc := SvmapDocument.create_new("Probe-Map Eigenständig", 64.0, "mittag") + # baum_kiefer_1 und stein_mittel_1 existieren garantiert als flache GLBs in + # models/assets/objekte/ (kein crate_wood — das gibt es als GLB nicht). + (doc["objects"] as Array).append({"id": "baum_kiefer_1", "pos": [0.0, 0.6, 0.0], "rot_y_deg": 0.0, "scale": 1.0}) + (doc["objects"] as Array).append({"id": "baum_kiefer_1", "pos": [4.0, 0.6, 2.0], "rot_y_deg": 45.0, "scale": 2.0}) + (doc["objects"] as Array).append({"id": "stein_mittel_1", "pos": [8.0, 0.6, 4.0], "rot_y_deg": 90.0, "scale": 1.0}) + var result := SvmapIO.save_to_file(out, doc) + if result.ok: + print("PROBE_MAP_GENERATED: ", out, " objects=", SvmapDocument.object_count(doc)) + quit(0) + else: + print("PROBE_MAP_FEHLSCHLAG: ", str(result)) + quit(1) diff --git a/tools/probe_gen_map.gd.uid b/tools/probe_gen_map.gd.uid new file mode 100644 index 0000000..5ddf37f --- /dev/null +++ b/tools/probe_gen_map.gd.uid @@ -0,0 +1 @@ +uid://t4og8rmlfh05 diff --git a/tools/sync_models.sh b/tools/sync_models.sh new file mode 100755 index 0000000..bfa1452 --- /dev/null +++ b/tools/sync_models.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# Modelle-Sync für den eigenständigen Map Builder (Karte t_12cf94fe). +# +# Was das Script macht: +# Füllt den Ordner models/ DIREKT NEBEN der App (im Editor-Repo = Projekt- +# ordner, in der exportierten App = neben der EXE) mit den echten 3D-Modellen +# (.glb) und den Pack-Texturen — kopiert aus einem Client-Klon. Danach läuft +# der Map Builder auf jedem beliebigen PC OHNE Client/Godot-Installation. +# +# Spiegelstruktur (wichtig: die App erwartet die GLBs DIREKT im models-Ordner): +# models/.glb (flache GLBs) +# models/military_desert//.glb (verschachtelte GLBs) +# models/textures/*.png (Pack-Palette) +# +# Aufruf (aus dem Map-Editor-Repo oder aus der exportierten App): +# tools/sync_models.sh # models/ füllen (idempotent — gefahrlos wiederholbar) +# tools/sync_models.sh --check # nur prüfen: Exit 0 = models/ aktuell, +# # Exit 1 = es fehlt/wich ab (ohne Änderung) +# +# Client-Klon finden: Umgebungsvariable SCOPEDVR_CLIENT_DIR, sonst die üblichen +# Nachbar-Ordner ../scopedvr-client und ../../scopedvr-client. +# +# Abhängigkeiten: python3 (Standard auf Linux/Mac; auf Windows python.org oder +# Git-Bash). KEINE rsync-Abhängigkeit — das Spiegeln (Kopieren + Überflüssiges +# löschen) macht ein eingebautes Python-Script, damit es auf jedem PC ohne +# Zusatzinstallation läuft (Slys Wunsch: keinerlei Abhängigkeiten). +# +# Lizenz (WICHTIG, siehe README_INSTALL.md): die Modelle/Texturen stammen aus +# dem ITHappy-Pack und dürfen NICHT weitergegeben werden — der models/-Ordner +# bleibt auf Slys eigenen Rechnern. Deshalb steht models/ auch in .gitignore. +# +set -u +MODE="sync" +if [ "${1:-}" = "--check" ]; then + MODE="check" +fi +if [ "${1:-}" != "" ] && [ "${1:-}" != "--check" ]; then + echo "Unbekannter Parameter: $1 (erlaubt: --check oder nichts)" + exit 2 +fi + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TARGET="$DIR/models" +CLIENT="${SCOPEDVR_CLIENT_DIR:-}" + +# --- Client-Klon finden (wie tools/sync_objects.sh) ------------------------- +if [ -z "$CLIENT" ]; then + for k in "$DIR/../scopedvr-client" "$DIR/../../scopedvr-client"; do + [ -d "$k/assets/objekte" ] && CLIENT="$(cd "$k" && pwd)" && break + done +fi +if [ -z "$CLIENT" ] || [ ! -d "$CLIENT/assets/objekte" ]; then + echo "FEHLER: Client-Klon mit assets/objekte nicht gefunden." + echo " Ordner ../scopedvr-client neben den Map-Editor legen oder" + echo " SCOPEDVR_CLIENT_DIR=/pfad/zum/client setzen." + exit 1 +fi +echo "Client: $CLIENT" +echo "models/: $TARGET" +echo "Modus: $MODE" + +if ! command -v python3 >/dev/null 2>&1; then + echo "FEHLER: python3 fehlt (Standard auf Linux/Mac; auf Windows python.org installieren)." + exit 1 +fi + +# --- .gdignore: Godot soll models/ NICHT importieren/packen ------------------ +# (FileAccess mit absoluten Pfaden funktioniert trotzdem — genau dafür +# ist der Ordner da. Fehlt die Datei, ist das eine Abweichung.) +GDIGNORE="$TARGET/.gdignore" +mkdir -p "$TARGET" +GDIGNORE_MISSING=0 +if [ ! -f "$GDIGNORE" ]; then + GDIGNORE_MISSING=1 + if [ "$MODE" = "check" ]; then + echo "ABWEICHUNG: $GDIGNORE fehlt (Godot würde den Ordner sonst importieren)." + else + touch "$GDIGNORE" + echo "angelegt: $GDIGNORE" + fi +fi + +# --- Spiegeln: Objekte (GLBs) + Pack-Texturen, ohne .import-Leichen --------- +# WICHTIG (Karte t_12cf94fe): die App erwartet die GLBs DIREKT im models-Ordner +# (models/.glb) — NICHT verschachtelt in models/assets/objekte. +# Deshalb wird $CLIENT/assets/objekte nach $TARGET (models/) gespiegelt. +SRC_OBJEKTE="$CLIENT/assets/objekte" +SRC_TEX="$CLIENT/assets/textures/military_desert" +DST_OBJEKTE="$TARGET" +DST_TEX="$TARGET/textures" + +# Python macht das Spiegeln (copy2 + überflüssige Dateien löschen). +# --check (Dry-Run) zeigt Abweichungen OHNE etwas zu verändern. +python3 - "$SRC_OBJEKTE" "$DST_OBJEKTE" "$SRC_TEX" "$DST_TEX" "$MODE" <<'PY' +import os, sys, shutil, filecmp + +pairs = [ + ("Objekte", sys.argv[1], sys.argv[2]), + ("Texturen", sys.argv[3], sys.argv[4]), +] +mode = sys.argv[5] + + +def scan(root: str) -> dict: + out = {} + if not os.path.isdir(root): + return out + for r, dirs, files in os.walk(root): + dirs[:] = [d for d in dirs if d != ".godot"] + for f in files: + if f.endswith(".import") or f == ".gdignore": + continue + full = os.path.join(r, f) + out[os.path.relpath(full, root)] = full + return out + + +def run(label: str, src: str, dst: str) -> int: + if not os.path.isdir(src): + print("ABWEICHUNG: Quelle fehlt: %s" % src) + return 1 + s_map = scan(src) + d_map = scan(dst) + changes = [] + for rel, sp in s_map.items(): + dp = os.path.join(dst, rel) + if not os.path.exists(dp): + changes.append(("neu", rel)) + else: + try: + if not filecmp.cmp(sp, dp, shallow=False): + changes.append(("geändert", rel)) + except OSError: + changes.append(("geändert", rel)) + for rel in d_map: + if rel not in s_map: + changes.append(("überflüssig", rel)) + if not changes: + return 0 + if mode == "check": + for action, rel in changes[:20]: + print("ABWEICHUNG (%s): %s: %s" % (label, action, rel)) + if len(changes) > 20: + print("ABWEICHUNG (%s): … und %d weitere" % (label, len(changes) - 20)) + return 1 + for action, rel in changes: + dp = os.path.join(dst, rel) + if action == "überflüssig": + if os.path.exists(dp): + os.remove(dp) + else: + os.makedirs(os.path.dirname(dp), exist_ok=True) + shutil.copy2(s_map[rel], dp) + print("%s: %d Änderungen übernommen." % (label, len(changes))) + return 0 + + +dev = 0 +dev |= run("Objekte", pairs[0][1], pairs[0][2]) +dev |= run("Texturen", pairs[1][1], pairs[1][2]) +sys.exit(dev) +PY +DEVIATION=$? + +# --- Größe + Zahl am Ende ---------------------------------------------------- +GLB_COUNT="$(find "$DST_OBJEKTE" -name '*.glb' 2>/dev/null | wc -l)" +TEX_COUNT="$(find "$DST_TEX" -name '*.png' 2>/dev/null | wc -l)" +SIZE="$(du -sh "$TARGET" 2>/dev/null | cut -f1)" +echo "---" +echo "models/: $GLB_COUNT GLBs, $TEX_COUNT Pack-Texturen, Gesamtgröße: $SIZE" + +if [ "$MODE" = "check" ]; then + if [ "$DEVIATION" -ne 0 ] || [ "$GDIGNORE_MISSING" -ne 0 ]; then + echo "--check: models/ weicht vom Client-Klon ab (Exit 1)." + exit 1 + fi + echo "--check: models/ ist aktuell (Exit 0)." + exit 0 +fi + +if [ "$DEVIATION" -ne 0 ]; then + echo "Sync mit Fehlern (siehe oben)." + exit 1 +fi +echo "Sync fertig: models/ ist auf dem Stand des Client-Klons." diff --git a/utils/modelle_status.gd b/utils/modelle_status.gd new file mode 100644 index 0000000..e1e1227 --- /dev/null +++ b/utils/modelle_status.gd @@ -0,0 +1,102 @@ +extends Node +## Autoload: Modell-Status + Probe (Karte t_12cf94fe). +## +## Zwei Aufgaben: +## 1. Banner/Klartext: Wenn beim App-Start KEINE Modell-Quelle irgendeine +## GLB enthält, sagt die App deutlich, wie sie die Modelle bekommt +## (GlbMeshStore.fehl_text()). Kein stiller Kisten-Platzhalter. +## 2. Probe-Modus für den Eigenständigkeits-Beweis: startet die exportierte +## App mit --probe-map=/pfad/test.svmap (NUR user-args, also nach --), +## öffnet die Map über die Editor-Methode, sammelt die OID-Belege +## (Quellpfad je Objekt, fehlende_glbs) und beendet sich mit Exit 0/1. +## +## Aufruf (exportierte App, headless): +## ./MapEditor.x86_64 --headless -- --probe-map=/pfad/test.svmap +## +## main.gd wird NICHT anfasst (Kollisionsschutz U1) — alle Aufrufe gehen +## über call() auf die laufende Szene. + +const PROBE_ARG := "--probe-map=" +const PROBE_WARTEFRAMES_START := 2 +const PROBE_WARTEFRAMES_MAP := 20 + + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + for a in args: + if a.begins_with(PROBE_ARG): + _probe(str(a.substr(PROBE_ARG.length()))) + return + _bannern() + + +## 1. Klartext-Banner, wenn nirgends Modelle liegen (GUI + Log). +func _bannern() -> void: + await get_tree().process_frame + await get_tree().process_frame + if GlbMeshStore.lager_ist_leer(): + var text := GlbMeshStore.fehl_text() + print("⚠ MODELL-FEHLT-BANNER:") + for zeile in text.split("\n"): + print(" " + zeile) + var szene := get_tree().current_scene + if szene != null and szene.has_method("_set_status"): + szene.call("_set_status", "⚠ Modelle fehlen — Terrain/Bauteile funktionieren. Details im Log.") + if DisplayServer.get_name() != "headless": + var dialog := AcceptDialog.new() + dialog.title = "Modelle fehlen" + dialog.dialog_text = text + dialog.ok_button_text = "Verstanden" + get_tree().root.add_child(dialog) + dialog.popup_centered() + + +## 2. Probe: Map öffnen, Belege sammeln, Exit 0/1. +func _probe(map_pfad: String) -> void: + for i in PROBE_WARTEFRAMES_START: + await get_tree().process_frame + var szene := get_tree().current_scene + if szene == null or not szene.has_method("_on_open_selected"): + print("PROBE FEHLGESCHLAGEN: keine Szene mit _on_open_selected") + get_tree().quit(1) + return + var result: Variant = szene.call("_on_open_selected", map_pfad) + for i in PROBE_WARTEFRAMES_MAP: + await get_tree().process_frame + + # --- Belege sammeln ------------------------------------------------------- + var fehlend: Array = [] + var view_builder: Variant = szene.get("view_builder") + if view_builder != null: + fehlend = view_builder.get("fehlende_glbs") + var oids: Array = [] + var doc: Variant = szene.get("doc") + if doc is Dictionary: + for obj in (doc as Dictionary).get("objects", []): + if obj is Dictionary: + var oid := str((obj as Dictionary).get("id", "")) + if oid != "" and not oids.has(oid): + oids.append(oid) + var modelle_da := DirAccess.dir_exists_absolute(GlbMeshStore.models_pfad()) + var geladen: bool = doc is Dictionary and SvmapDocument.object_count(doc) > 0 + + print("PROBE map=%s" % map_pfad) + print("PROBE map_geladen=%s objekte=%d" % [str(geladen), oids.size()]) + print("PROBE modelle_ordinar=%s pfad=%s" % [str(modelle_da), GlbMeshStore.models_pfad()]) + print("PROBE quellen=%s" % ", ".join(PackedStringArray(GlbMeshStore.quellen()))) + var quellen_pro_oid := 0 + for oid in oids: + var pfad := GlbMeshStore.probe_source_fuer(oid) + if pfad.contains("/models/"): + quellen_pro_oid += 1 + var muster: Array = [] + for oid in oids.slice(0, 5): + muster.append("%s -> %s" % [oid, GlbMeshStore.probe_source_fuer(oid)]) + if not muster.is_empty(): + print("PROBE oid-quellen (erste %d):" % muster.size()) + for m in muster: + print(" " + m) + print("PROBE aus-models=%d/%d fehlende_glbs=%d" % [quellen_pro_oid, oids.size(), fehlend.size()]) + var ok := geladen and fehlend.is_empty() + print("PROBE ERGEBNIS: %s" % ("OK" if ok else "FEHLGESCHLAGEN")) + get_tree().quit(0 if ok else 1) diff --git a/utils/modelle_status.gd.uid b/utils/modelle_status.gd.uid new file mode 100644 index 0000000..7fcab05 --- /dev/null +++ b/utils/modelle_status.gd.uid @@ -0,0 +1 @@ +uid://l32qlb021yw2