diff --git a/editor/terrain_tool.gd b/editor/terrain_tool.gd index 0404d18..d2e85ff 100644 --- a/editor/terrain_tool.gd +++ b/editor/terrain_tool.gd @@ -355,6 +355,7 @@ func _process(delta: float) -> void: var changed := _height_step(_cursor.x, _cursor.z, amount) if changed: _expand_range_if_needed() + _melde_geaenderte_hoehen() # Karte t_55ed041a: Material-Schicht MITGEBEN — ohne sie hat der # Live-Rebuild alles als Gras gerendert (Sand/Fels „verschwanden" # während des Heben/Senk-Strichs und kehrten erst am Stroke-Ende @@ -363,6 +364,19 @@ func _process(delta: float) -> void: _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 +func _melde_geaenderte_hoehen() -> void: + if not view_builder.has_method("mark_terrain_dirty"): + return + var r := _brush_region(_world_to_grid(_cursor.x, _cursor.z)) + view_builder.call("mark_terrain_dirty", r) + if _sym_active(): + var ecke_a: Vector2i = SymmetryCore.mirror_cell(_sym_mode(), r.position.x, r.position.y, _res) + var ecke_b: Vector2i = SymmetryCore.mirror_cell(_sym_mode(), r.end.x - 1, r.end.y - 1, _res) + var spiegel := Rect2i(mini(ecke_a.x, ecke_b.x), mini(ecke_a.y, ecke_b.y), + absi(ecke_a.x - ecke_b.x) + 1, absi(ecke_a.y - ecke_b.y) + 1) + view_builder.call("mark_terrain_dirty", spiegel) + + ## Stärke dieses Frames: Meter (Heben/Senken) bzw. Mischfaktor 0–1 (Glätten/ ## Einebnen) — skaliert mit dem Druck. func _step_amount(mode: int, dt: float) -> float: diff --git a/svmap/skybox_katalog.gd b/svmap/skybox_katalog.gd index 93d6589..5cb2efe 100644 --- a/svmap/skybox_katalog.gd +++ b/svmap/skybox_katalog.gd @@ -98,7 +98,9 @@ static func panorama_textur(id: String) -> Texture2D: return _tex_cache[id] var tex: Texture2D = null var pfad := textur_pfad(id) - if not pfad.is_empty() and FileAccess.file_exists(pfad): + if pfad.begins_with("res://"): + tex = load(pfad) as Texture2D + elif not pfad.is_empty() and FileAccess.file_exists(pfad): var img := Image.load_from_file(pfad) if img != null: if img.is_compressed(): diff --git a/ui/main.gd b/ui/main.gd index 8a91d07..769efd6 100644 --- a/ui/main.gd +++ b/ui/main.gd @@ -194,6 +194,11 @@ var test_btn: Button var _dock_offsets: Dictionary = {} var _split_lagen_playtest: Dictionary = {} # Playtest: Trenner-Lagen (Sly-Befund 20.10.) +## Läuft im Spiel auf der Brille: kein Fenster, kein Selbst-Update, Karten in user://. +var vr_host := false +signal vr_test_gewuenscht +signal vr_beenden_gewuenscht + const GizmoScript := preload("res://core/viewport/gizmo.gd") const INSPECTOR_PANEL := preload("res://core/ui/inspector_panel.gd") @@ -210,10 +215,13 @@ func _ready() -> void: # Fenster-Zustand (Karte t_297221b5): Default MAXIMIERT (kein Vollbild); # wer auf Fenstergröße umschaltet, bekommt die gemerkte Lage wieder. # Close-Request selbst behandeln, damit die Lage VOR dem Quit gemerkt wird. - get_tree().set_auto_accept_quit(false) - _apply_window_state() + if not vr_host: + get_tree().set_auto_accept_quit(false) + _apply_window_state() _load_dock_state() _start_with_new_map() + if vr_host: + return _maybe_start_demo_mode() # Selbst-Update (Karte t_d95f087a): STILLER Start-Check im Hintergrund — # nur bei „Update verfügbar“ erscheint das Banner; offline/Tunnel weg @@ -361,6 +369,9 @@ func _build_group_tabs(gruppe: String) -> TabContainer: tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL tabs.add_theme_constant_override("separation", UIScale.px(8)) var entries: Array = PanelRegistryScript.entries(gruppe) + if vr_host: + # Server-Verwaltung braucht den Admin-Schlüssel und bleibt am PC. + entries = entries.filter(func(e: Dictionary) -> bool: return str(e["id"]) != "server") for entry in entries: var script: GDScript = load(str(entry["skript"])) var panel: Control = script.new() @@ -514,7 +525,10 @@ func _build_dialogs_and_overlays() -> void: open_dialog = FileDialog.new() open_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE - open_dialog.access = FileDialog.ACCESS_FILESYSTEM + open_dialog.access = FileDialog.ACCESS_USERDATA if vr_host else FileDialog.ACCESS_FILESYSTEM + if vr_host: + DirAccess.make_dir_recursive_absolute("user://maps") + open_dialog.current_dir = "user://maps" open_dialog.title = "Map öffnen" open_dialog.filters = PackedStringArray(["*.svmap ; ScopedVR Map"]) open_dialog.size = UIScale.v2i(800, 560) @@ -523,7 +537,9 @@ func _build_dialogs_and_overlays() -> void: save_dialog = FileDialog.new() save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE - save_dialog.access = FileDialog.ACCESS_FILESYSTEM + save_dialog.access = FileDialog.ACCESS_USERDATA if vr_host else FileDialog.ACCESS_FILESYSTEM + if vr_host: + save_dialog.current_dir = "user://maps" save_dialog.title = "Map speichern unter" save_dialog.filters = PackedStringArray(["*.svmap ; ScopedVR Map"]) save_dialog.size = UIScale.v2i(800, 560) @@ -900,6 +916,10 @@ func _save_window_state() -> void: ## Ein Ausgang für beide Beenden-Wege (Menü + Fenster-X). func _quit_editor() -> void: + if vr_host: + _save_dock_state() + vr_beenden_gewuenscht.emit() + return _save_window_state() _save_dock_state() get_tree().quit() @@ -1248,6 +1268,9 @@ func _on_test_run() -> void: if doc.is_empty(): _set_status("Erst eine Map anlegen (Datei → Neue Map), dann testen.", true) return + if vr_host: + vr_test_gewuenscht.emit() + return _start_playtest() @@ -2379,7 +2402,8 @@ func _refresh_file_label() -> void: file_label.tooltip_text = file_label.text # U7 (Karte t_2336c58e): ungespeicherte Änderungen auch im FENSTERTITEL — # so sieht man sie selbst dann, wenn die Datei-Leiste egalisiert ist. - get_window().title = "%s — %s%s" % [APP_TITLE, map_name, + if not vr_host: + get_window().title = "%s — %s%s" % [APP_TITLE, map_name, " (ungespeichert)" if dirty else ""] diff --git a/ui/panels/toolbar.gd b/ui/panels/toolbar.gd index 45050c9..193219c 100644 --- a/ui/panels/toolbar.gd +++ b/ui/panels/toolbar.gd @@ -56,8 +56,9 @@ func build() -> void: popup.add_separator() popup.add_item("▶ Karte testen", 5) popup.add_separator() - popup.add_item("🖐 Hand-Haltung der Waffen…", 8) - popup.add_item("🔄 Updates prüfen", 7) + if not main.vr_host: + popup.add_item("🖐 Hand-Haltung der Waffen…", 8) + popup.add_item("🔄 Updates prüfen", 7) popup.add_item("Beenden", 4) # FIX 20.10. (Sly-Befund „Kartengröße ändern tut nichts“): id_pressed sendet # die Menü-ID. main.call.bind("_on_menu_action") hängt die ID aber ALS diff --git a/viewport/glb_mesh_store.gd b/viewport/glb_mesh_store.gd index e4f3eda..bd51570 100644 --- a/viewport/glb_mesh_store.gd +++ b/viewport/glb_mesh_store.gd @@ -184,6 +184,14 @@ static func mesh_fuer(oid: String) -> Mesh: return BauKatalog.mesh(oid) if _cache.has(oid): return _cache[oid] if _cache[oid] is Mesh else null + # Im Spiel sind die Modelle importierter Teil des Projekts. + var im_projekt := _projekt_pfad(oid) + if not im_projekt.is_empty(): + var szene: Node = (load(im_projekt) as PackedScene).instantiate() + var aus_projekt := _zusammenbacken(szene) + szene.free() + _cache[oid] = aus_projekt if aus_projekt != null else false + return aus_projekt for pfad in _kandidaten(oid): if FileAccess.file_exists(pfad): var m := _glb_mesh(pfad) @@ -198,6 +206,13 @@ static func mesh_fuer(oid: String) -> Mesh: return null +static func _projekt_pfad(oid: String) -> String: + var rel := str(_eintrag(oid).get("glb", "")) + if rel.begins_with("res://") and ResourceLoader.exists(rel, "PackedScene"): + return rel + return "" + + ## Liegt für die OID überhaupt eine GLB-Datei im Lager? (Billiger Check ohne ## Parse — für Statuszeilen-Hinweise beim Platzieren.) static func fehlt(oid: String) -> bool: @@ -207,6 +222,8 @@ static func fehlt(oid: String) -> bool: return false if _cache.has(oid): return not (_cache[oid] is Mesh) + if not _projekt_pfad(oid).is_empty(): + return false for pfad in _kandidaten(oid): if FileAccess.file_exists(pfad): return false diff --git a/viewport/map_view_builder.gd b/viewport/map_view_builder.gd index aec3570..fa3f264 100644 --- a/viewport/map_view_builder.gd +++ b/viewport/map_view_builder.gd @@ -197,6 +197,12 @@ func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedB terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats, splat_rect_cells) +## Pinsel meldet die geänderten Höhenpunkte (nur im Stücke-Modus genutzt). +func mark_terrain_dirty(rect: Rect2i) -> void: + if terrain_view != null and is_instance_valid(terrain_view): + terrain_view.call("markiere_hoehen", rect) + + ## Misch-Schicht für das nächste Live-Update (Malen mit weichem Rand, Karte 25.10.). func set_terrain_mix(mix_b: PackedByteArray, mix_w: PackedByteArray) -> void: if terrain_view != null and is_instance_valid(terrain_view): diff --git a/viewport/terrain_mesh.gd b/viewport/terrain_mesh.gd index 9f63186..687e5e7 100644 --- a/viewport/terrain_mesh.gd +++ b/viewport/terrain_mesh.gd @@ -1,4 +1,5 @@ extends Node3D +class_name TerrainMeshView ## Terrain-Ansicht: baut das Höhenfeld (svmap terrain-Block) als Mesh im ## Editor-Viewport (M2, Karte t_9aba04f1). ## @@ -73,6 +74,20 @@ const CONTOUR_MIN_SPAN := 2.0 const CONTOUR_SHADER := "res://core/viewport/terrain_contour.gdshader" var _mi: MeshInstance3D + +## Quest: Terrain in Stücken, ein Pinselschritt schreibt nur die getroffenen +## Punkte neu (Messung 31.10.: 3 ms statt 132 ms bei Raster 257). Am PC reicht +## das ganze Mesh, dort läuft außerdem der Kontur-Shader. +static var stuecke_an := OS.has_feature("android") +## Eigenes Terrain-Material der App (Quest: der leichte Spiel-Shader). +static var material_fabrik := Callable() +const STUECK_ZELLEN := 32 +var _stuecke: Array[Dictionary] = [] +var _stuecke_res := 0 +var _stuecke_groesse := Vector2.ZERO +var _dirty: Array[Rect2i] = [] +var _hmin := 0.0 +var _hmax := 1.0 var _res := 0 var _size_x := 0.0 var _size_z := 0.0 @@ -123,6 +138,8 @@ func _ensure_mesh_instance() -> void: ## StandardMaterial3D zurück, wenn der Shader nicht ladbar ist (Robustheit — ## Terrain bleibt immer sichtbar). func _make_material() -> Material: + if material_fabrik.is_valid(): + return material_fabrik.call() _shader = load(CONTOUR_SHADER) if _shader != null: var mat := ShaderMaterial.new() @@ -181,6 +198,7 @@ func rebuild_from_doc(data: Dictionary) -> void: _triangles = 0 if _mi != null: _mi.mesh = null + _stuecke_leeren() return # Splat-Textur verwerfen — sie wird im update_heights frisch aus der # (nach Undo/Load evtl. geänderten) Zell-Schicht abgeleitet. @@ -227,6 +245,12 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0 elif not mats.is_empty(): _mats_cache = mats var mres := mat_res_of(mats, res) + if stuecke_an: + # Mal-Schritt (Rechteck gesetzt): Vertices hängen nicht am Material. + if not splat_rect_cells.has_area(): + _stuecke_schreiben(heights, res, size_x, size_z, mats, mres) + _splat_aktualisieren(size_x, size_z, mats, mres) + return var last := float(res - 1) var positions := PackedVector3Array() var normals := PackedVector3Array() @@ -319,6 +343,10 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0 _mi.mesh = mesh _triangles = indices.size() / 3 + _splat_aktualisieren(size_x, size_z, mats, mres) + + +func _splat_aktualisieren(size_x: float, size_z: float, mats: PackedByteArray, mres: int) -> void: # --- Splat-Gewichtstextur (t_7a0a6b79): map_size + Sub-Rect-Update -------- # map_size_x/z in Weltmetern (Shader rechnet map_uv = Weltkoord. über Map). # Mal-Stroke: splat_rect_cells = betroffenes Zellrechteck → NUR die Region @@ -343,6 +371,140 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0 _build_splat(shm, mats, mres, size_x, size_z) +## Pinsel meldet die geänderten Höhenpunkte, das nächste Update schreibt nur sie. +func markiere_hoehen(rect: Rect2i) -> void: + _dirty.append(rect) + + +func _stuecke_leeren() -> void: + for s in _stuecke: + (s["mi"] as Node).queue_free() + _stuecke.clear() + _stuecke_res = 0 + _dirty.clear() + + +func _stuecke_schreiben(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, + mats: PackedByteArray, mres: int) -> void: + var groesse := Vector2(size_x, size_z) + var neu_bauen := _stuecke_res != res or _stuecke_groesse != groesse + if neu_bauen: + _stuecke_leeren() + _stuecke_res = res + _stuecke_groesse = groesse + var je_seite := int(ceil(float(res - 1) / float(STUECK_ZELLEN))) + for cz in je_seite: + for cx in je_seite: + _stuecke.append(_stueck_anlegen(cx, cz, res)) + _triangles = (res - 1) * (res - 1) * 2 + var alles := neu_bauen or _dirty.is_empty() + if alles: + # Farbskala nur beim vollen Durchlauf neu, im Strich bleibt sie stehen. + _hmin = INF + _hmax = -INF + for v in heights: + _hmin = minf(_hmin, v) + _hmax = maxf(_hmax, v) + _last_span = maxf(_hmax - _hmin, 0.0001) + _apply_contour_state(_last_span >= CONTOUR_MIN_SPAN) + for s in _stuecke: + var ganz := Rect2i(s["x0"], s["z0"], s["x1"] - s["x0"] + 1, s["z1"] - s["z0"] + 1) + var getroffen := alles + if alles: + _stueck_punkte(s, ganz, heights, res, size_x, size_z, mats, mres) + else: + for r in _dirty: + # Normalen hängen an den Nachbarn: ein Punkt Rand mitnehmen. + var teil := r.grow(1).intersection(ganz) + if teil.has_area(): + _stueck_punkte(s, teil, heights, res, size_x, size_z, mats, mres) + getroffen = true + if getroffen: + var mesh: ArrayMesh = (s["mi"] as MeshInstance3D).mesh + mesh.clear_surfaces() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, s["arrays"]) + _dirty.clear() + + +func _stueck_anlegen(cx: int, cz: int, res: int) -> Dictionary: + var x0 := cx * STUECK_ZELLEN + var z0 := cz * STUECK_ZELLEN + var x1 := mini(x0 + STUECK_ZELLEN, res - 1) + var z1 := mini(z0 + STUECK_ZELLEN, res - 1) + var w := x1 - x0 + 1 + var h := z1 - z0 + 1 + var pos := PackedVector3Array() + var nor := PackedVector3Array() + var col := PackedColorArray() + var uv2 := PackedVector2Array() + var idx := PackedInt32Array() + pos.resize(w * h) + nor.resize(w * h) + col.resize(w * h) + uv2.resize(w * h) + idx.resize((w - 1) * (h - 1) * 6) + var n := 0 + for z in h - 1: + for x in w - 1: + var i := z * w + x + # Wicklung wie im ganzen Mesh (update_heights). + idx[n] = i + idx[n + 1] = i + 1 + idx[n + 2] = i + w + idx[n + 3] = i + 1 + idx[n + 4] = i + w + 1 + idx[n + 5] = i + w + n += 6 + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = pos + arrays[Mesh.ARRAY_NORMAL] = nor + arrays[Mesh.ARRAY_COLOR] = col + arrays[Mesh.ARRAY_TEX_UV2] = uv2 + arrays[Mesh.ARRAY_INDEX] = idx + var mi := MeshInstance3D.new() + mi.mesh = ArrayMesh.new() + mi.material_override = _mi.material_override + add_child(mi) + return {"x0": x0, "z0": z0, "x1": x1, "z1": z1, "w": w, "arrays": arrays, "mi": mi} + + +func _stueck_punkte(s: Dictionary, r: Rect2i, heights: PackedFloat32Array, res: int, + size_x: float, size_z: float, mats: PackedByteArray, mres: int) -> void: + var arrays: Array = s["arrays"] + var pos: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var nor: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL] + var col: PackedColorArray = arrays[Mesh.ARRAY_COLOR] + var uv2: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV2] + var x0: int = s["x0"] + var z0: int = s["z0"] + var w: int = s["w"] + var cell_x := size_x / float(res - 1) + var cell_z := size_z / float(res - 1) + for z in range(r.position.y, r.end.y): + for x in range(r.position.x, r.end.x): + var i := z * res + x + var v := (z - z0) * w + (x - x0) + var hv := heights[i] + pos[v] = Vector3(-size_x * 0.5 + float(x) * cell_x, hv, -size_z * 0.5 + float(z) * cell_z) + var n := Vector3( + (heights[z * res + maxi(x - 1, 0)] - heights[z * res + mini(x + 1, res - 1)]) * cell_z, + 2.0 * cell_x * cell_z, + (heights[maxi(z - 1, 0) * res + x] - heights[mini(z + 1, res - 1) * res + x]) * cell_x).normalized() + nor[v] = n + var mat_id := clampi(SvmapIO.material_at_height_point(mats, mres, res, x, z), + 0, MATERIAL_COLORS.size() - 1) + var farbe := relief_color(mat_id, hv, _hmin, _hmax, n) + if _wasser_level < INF: + farbe = SvmapWasser.matsch_anwenden(farbe, SvmapWasser.ufer_faktor(hv, _wasser_level)) + col[v] = farbe + uv2[v] = Vector2(hv, 0.0) + arrays[Mesh.ARRAY_VERTEX] = pos + arrays[Mesh.ARRAY_NORMAL] = nor + arrays[Mesh.ARRAY_COLOR] = col + arrays[Mesh.ARRAY_TEX_UV2] = uv2 + + ## Misch-Schicht fürs nächste update_heights (Live-Malen, Karte 25.10.). func set_mix(mix_b: PackedByteArray, mix_w: PackedByteArray) -> void: _mix_b_cache = mix_b