VR-Host: main läuft ohne Fenster, Modelle aus dem Projekt, Terrain in Stücken (nur Quest)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-11-01 19:38:42 +01:00
parent d196086cce
commit 83af25b6fb
7 changed files with 234 additions and 8 deletions

View file

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

View file

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

View file

@ -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.
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,6 +2402,7 @@ 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.
if not vr_host:
get_window().title = "%s — %s%s" % [APP_TITLE, map_name,
" (ungespeichert)" if dirty else ""]

View file

@ -56,6 +56,7 @@ func build() -> void:
popup.add_separator()
popup.add_item("▶ Karte testen", 5)
popup.add_separator()
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)

View file

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

View file

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

View file

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