T2 Ueberall installierbar (Operator-Push): Modelle aus mitgeliefertem models/-Ordner

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.
This commit is contained in:
scopedvr-operator 2026-10-20 09:43:05 +00:00
parent 0f86472cfa
commit ab173ac8aa
12 changed files with 769 additions and 8 deletions

8
.gitignore vendored
View file

@ -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

85
README_INSTALL.md Normal file
View file

@ -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.*

29
export_presets.cfg Normal file
View file

@ -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

View file

@ -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

View file

@ -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 = []
var setting: Variant = ProjectSettings.get_setting(QUELLEN_SETTING, DEFAULT_QUELLEN)
if setting is Array or setting is PackedStringArray:
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()
_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()

229
tests/test_model_sources.gd Normal file
View file

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

View file

@ -0,0 +1 @@
uid://bff2puoer0n1i

26
tools/probe_gen_map.gd Normal file
View file

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

View file

@ -0,0 +1 @@
uid://t4og8rmlfh05

185
tools/sync_models.sh Executable file
View file

@ -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/<oid>.glb (flache GLBs)
# models/military_desert/<kategorie>/<oid>.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/<oid>.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."

102
utils/modelle_status.gd Normal file
View file

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

View file

@ -0,0 +1 @@
uid://l32qlb021yw2