From f5a4ab54d029da03c19665ae7a834aa919c7e5fe Mon Sep 17 00:00:00 2001 From: "Hermes (scopedvr-dev)" Date: Wed, 21 Oct 2026 06:13:59 +0000 Subject: [PATCH] Wasser pro Map (t_28fdef03): V20-Validator, Panel-Regler, Vorschau-Renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/svmap/svmap_io.gd: _validate_wasser (V20) — optional, endliche Zahl, Terrain-Hoehenbereich +/-10 m; identisch zum Match-Server - src/ui/panels/map_panel.gd: Regler 'Wasserhoehe' mit Sofort-Rebuild; ganz links (-10) = Feld ENTFERNEN (Legacy-sauber, keine Migration) - src/viewport/map_view_builder.gd: _build_wasser nach _build_terrain (Senken fuellen sich); Legacy ohne Feld = kein Wasser-Node - src/viewport/terrain_mesh.gd: Matsch-Ring in relief_color (Ufer-Faktor, dieselbe Formel wie der Client) - library/svmap_wasser.gd + src/viewport/wasser.gdshader: Spiegel-Modul, byteidentisch zum Client (Identitaets-Vertrag der Vorschau) - tests/test_wasser.gd: 33 Checks (W1 V20, W2 Panel, W3 Vorschau + Matsch-Mathe, W4 Spiegel) Verifiziert: Editor-Suite 33/33, test_editor_svmap 31, test_terrain 155, test_gui_smoke 92, test_skybox 23 — alle OK --- library/svmap_wasser.gd | 240 +++++++++++++++++++++++++++++++ library/svmap_wasser.gd.uid | 1 + src/svmap/svmap_io.gd | 27 ++++ src/ui/panels/map_panel.gd | 60 ++++++++ src/viewport/map_view_builder.gd | 20 +++ src/viewport/terrain_mesh.gd | 15 +- src/viewport/wasser.gdshader | 92 ++++++++++++ src/viewport/wasser.gdshader.uid | 1 + tests/test_wasser.gd | 202 ++++++++++++++++++++++++++ tests/test_wasser.gd.uid | 1 + 10 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 library/svmap_wasser.gd create mode 100644 library/svmap_wasser.gd.uid create mode 100644 src/viewport/wasser.gdshader create mode 100644 src/viewport/wasser.gdshader.uid create mode 100644 tests/test_wasser.gd create mode 100644 tests/test_wasser.gd.uid diff --git a/library/svmap_wasser.gd b/library/svmap_wasser.gd new file mode 100644 index 0000000..755f89b --- /dev/null +++ b/library/svmap_wasser.gd @@ -0,0 +1,240 @@ +class_name SvmapWasser +extends RefCounted +## Wasser pro Map (Karte t_28fdef03) — SPIEGELGLEICHES Modul in Client UND +## Map-Editor (Muster: library/terrain_arrays.gd, t_ab408127 — identischer +## Pfad in beiden Repos, identischer Code; Repos bleiben entkoppelt). +## +## Sly-Entscheidungen 20.10.: Wasserlevel-Ansatz (EINE Höhe pro Map — Senken +## unter der Höhe füllen sich automatisch), Wat-Trägheit (knietief, kein +## Schwimmen), Ufer-Automatik (schlammiges Overlay knapp unter Wasserhöhe). +## +## Verantwortung dieses Moduls (Dictionary rein, Node out — wie SvmapTerrain): +## - hat()/level(): optionales svmap-Feld `water_level` (float, y-Höhe in m). +## Abwesenheit oder Fehltyp = KEIN Wasser (Legacy-Maps unverändert). +## - baue(): EINE transparente Fläche auf Wasserhöhe über der ganzen Map +## (1 Draw-Call, keine Geometrie-Wogen — Wellen NUR im Fragment-Shader). +## - ufer_bild(): Tiefe-Feld (R8, depth = wasser − Terrainhöhe, geklemmt auf +## tiefe_max) als Textur — der Shader liest daraus Ufer-Schaum + Flach- +## Wasser-Transparenz, OHNE Screen-Space-Tricks. +## - ufer_faktor()/matsch_anwenden(): Ufer-Automatik — Zellen knapp unter +## Wasserhöhe bekommen den Matsch-Ton. BEWUSST nur eine abgeleitete Tönung +## (kein gespeichertes Feld): die Material-Schicht bleibt malbar, der +## Matsch liegt als Höhen-Regel drüber — Client UND Editor-Vorschau +## rechnen IDENTISCH (dieselbe Datei). +## - make_material(): dasselbe Material wie der Client (Identitäts-Vertrag +## der Vorschau) — Shader-Pfad je Repo über SHADER_KANDIDATEN. +## +## KEINE echte Spiegelung/Brechung/SSR (bewusste Absage, Performance): +## Spiegel-Impression = Skybox-Farbreflex (uniform) + Fresnel-Glanz. +## +## Wasserdistanz-Regel (Perf-Beweis-Plan): ferne Fragmente vereinfachen im +## Shader selbst (kein Wellen-/Schaum-Aufwand) — keine Per-Frame-Logik nötig. + +## Shader-Pfade je Repo (erster vorhandener gewinnt — Spiegel-Dateien sind +## byteidentisch, siehe tools/sync_wasser.sh im Map-Editor). +const SHADER_KANDIDATEN: Array[String] = [ + "res://shaders/wasser.gdshader", # scopedvr-client + "res://src/viewport/wasser.gdshader", # scopedvr-map-editor (Spiegel) +] + +## Plausibilitätsbereich des svmap-Feldes `water_level` (Meter, y-Höhe). +## GLEICHE Werte wie V18 im Match-Server (svmap_loader) und im Editor +## (svmap_io) — nur Dokumentations-Konstante hier. +const LEVEL_MIN := -5.0 +const LEVEL_MAX := 500.0 + +## Ufer-Bild: Tiefe in 0..tiefe_max m → 0..255 (R8). Mehr Tiefe braucht kein +## Detail — Schaum lebt in den ersten Dezimetern, Transparenz in den ersten Metern. +const TIEFE_MAX := 4.0 + +## Schaum-Band am Ufer (Meter Wassertiefe, in denen Schaum sichtbar ist). +const SCHAUM_BAND := 0.35 + +## Matsch-Ring (Ufer-Automatik): Zellen, deren Terrainhöhe weniger als +## MATSCH_HOEHE unter dem Wasserlevel liegt (bis MATSCH_UEBER leicht darüber, +## damit die Wasserlinie selbst matschig ist), werden zum Matsch-Ton überblendet. +const MATSCH_HOEHE := 0.55 +const MATSCH_UEBER := 0.06 +const MATSCH_FARBE := Color(0.34, 0.26, 0.18) # schlammiges Braun-Betonung + +## Wetter-Farbkopplung (Entscheidung der Atmosphären-Karte): Wasser-Tint je +## Preset. farbe_flach = Seicht-Wasser, farbe_tief = tiefe Mitte, reflex = +## Skybox-Farbreflex (Himmelsfarbe des Presets), glanz = Glint-Stärke. +## Schlüssel = Client-Wetter-Keys (WeatherPresets). Unbekannt → "mittag". +const WETTER_WASSER := { + "mittag": { + "farbe_flach": Color(0.10, 0.30, 0.34), + "farbe_tief": Color(0.02, 0.10, 0.15), + "reflex": Color(0.72, 0.83, 0.92), + "glanz": 1.0, + }, + "abend": { + "farbe_flach": Color(0.18, 0.26, 0.28), + "farbe_tief": Color(0.04, 0.07, 0.11), + "reflex": Color(0.95, 0.62, 0.38), + "glanz": 1.2, + }, + "regen": { + "farbe_flach": Color(0.20, 0.24, 0.26), + "farbe_tief": Color(0.06, 0.09, 0.11), + "reflex": Color(0.55, 0.58, 0.62), + "glanz": 0.35, + }, + "nacht": { + "farbe_flach": Color(0.02, 0.05, 0.09), + "farbe_tief": Color(0.0, 0.015, 0.03), + "reflex": Color(0.16, 0.22, 0.38), + "glanz": 0.9, + }, +} + + +## Optionales svmap-Feld `water_level` vorhanden und brauchbar? Abwesenheit +## ODER Fehltyp = kein Wasser (Legacy-Maps unverändert, Fehltyp toleriert). +static func hat(data: Dictionary) -> bool: + var v: Variant = data.get("water_level", null) + return v is float or v is int + + +## Wasserhöhe aus der Map (INF = kein Wasser). +static func level(data: Dictionary) -> float: + var v: Variant = data.get("water_level", null) + if v is float or v is int: + return float(v) + return INF + + +## Wasserfläche bauen. terrain = der geparste terrain-Block, wasser = Wasser- +## Höhe (Meter), wetter = Preset-Key ("mittag"/"abend"/"regen"/"nacht"). +## Rückgabe: Node3D mit EINER transparenten Fläche — null, wenn kein Wasser +## sinnvoll ist (kein Feld, kein Terrain, Wasserhöhe unter dem Tiefpunkt, +## ungültige Maße). Die Senken "füllen sich" von selbst: alles unter der +## Höhe liegt hinterher unter der Fläche. +static func baue(terrain: Dictionary, wasser: float, wetter: String) -> Node3D: + if not is_finite(wasser): + return null + var res := int(terrain.get("resolution", 0)) + var size_x := float(terrain.get("size_x_m", 0.0)) + var size_z := float(terrain.get("size_z_m", 0.0)) + if res < 2 or size_x <= 0.0 or size_z <= 0.0: + return null + var heights := _decode_heights(terrain) + if heights.size() < res * res: + return null + var hmin := heights[0] + for h in heights: + if h < hmin: + hmin = h + if wasser <= hmin: + return null # Wasserhöhe unter dem Tiefpunkt — nichts gefüllt. + var root := Node3D.new() + root.name = "Wasser" + var mi := MeshInstance3D.new() + mi.name = "WasserFlaeche" + var plane := PlaneMesh.new() + plane.size = Vector2(size_x, size_z) + mi.mesh = plane + mi.position = Vector3(0.0, wasser, 0.0) + mi.material_override = make_material(wetter, ufer_bild(heights, res, wasser)) + root.add_child(mi) + return root + + +## Ufer-Tiefe-Bild (R8): Pixel = Terrainhöhe der Zelle, R-Wert = Tiefe unter +## dem Wasserlevel (0 = Wasserlinie/Ufer, 255 = tiefe_max oder tiefer). +## Identische Index-Konvention wie das Höhenfeld: (0,0) = −X/−Z-Ecke, x → +X, +## z → +Z (svmap-Vertrag). Der PlaneMesh des Wassers überdeckt denselben +## Bereich (zentriert, UV 0..1), der Shader sampelt direkt mit UV. +static func ufer_bild(heights: PackedFloat32Array, res: int, wasser: float) -> Image: + var img := Image.create(res, res, false, Image.FORMAT_R8) + for z in res: + for x in res: + var d := wasser - heights[z * res + x] # Tiefe (negativ = Land) + var v := int(round(clampf(d / TIEFE_MAX, 0.0, 1.0) * 255.0)) + img.set_pixel(x, z, Color8(v, 0, 0)) + return img + + +## Ufer-Automatik (Matsch-Ring): Faktor 0 (Land/tief) … 1 (Uferstreifen). +## Band = MATSCH_HOEHE unter bis MATSCH_UEBER über dem Wasserlevel, weich +## (smoothstep) an beiden Kanten — kein Harter Pixel-Kantensprung. +static func ufer_faktor(h: float, wasser: float) -> float: + if not is_finite(wasser): + return 0.0 + var d := wasser - h # Tiefe der Zelle (negativ = über Wasser) + var oben := clampf((d + MATSCH_UEBER) / MATSCH_UEBER, 0.0, 1.0) + if oben <= 0.0: + return 0.0 + var unten := clampf(d / MATSCH_HOEHE, 0.0, 1.0) + var f := minf(oben, 1.0 - unten * unten * (3.0 - 2.0 * unten)) + return clampf(f, 0.0, 1.0) + + +## Matsch-Ton auf eine Terrain-Zellfarbe anwenden (Client UND Editor rufen +## dieselbe Formel — Identität der Vorschau). faktor = ufer_faktor(...). +## BEWUSST nur RGB: die Alpha der Terrain-Zellfarbe trägt die gepackte +## Material-ID (m/N, Karte t_ab408127) — die darf der Matsch nie anfassen, +## sonst landen die Zellen im Splat-Shader auf dem falschen Textur-Layer. +static func matsch_anwenden(farbe: Color, faktor: float) -> Color: + var gemischt := farbe.lerp(MATSCH_FARBE, faktor * 0.75) # 0.75: Ton bleibt sichtbar + gemischt.a = farbe.a + return gemischt + + +## Wasser-Material (dasselbe im Client UND in der Editor-Vorschau — +## Identitäts-Vertrag): Custom-Shader + Wetter-Tint + Ufer-Tiefen-Textur. +## Fehlt der Shader (Frisch-Klon ohne Spiegel), fällt das Material auf ein +## simples transparentes StandardMaterial zurück — Wasser bleibt sichtbar. +static func make_material(wetter: String, ufer: Image = null) -> Material: + var preset: Dictionary = WETTER_WASSER.get(wetter, WETTER_WASSER["mittag"]) + var shader := _shader_laden() + if shader != null: + var mat := ShaderMaterial.new() + mat.shader = shader + mat.set_shader_parameter("farbe_flach", preset["farbe_flach"]) + mat.set_shader_parameter("farbe_tief", preset["farbe_tief"]) + mat.set_shader_parameter("reflex", preset["reflex"]) + mat.set_shader_parameter("glanz", float(preset["glanz"])) + if ufer != null: + mat.set_shader_parameter("ufer_tex", ImageTexture.create_from_image(ufer)) + return mat + # Fallback ohne Shader (Robustheit — Wasser bleibt sichtbar, nur ohne Wellen). + var std := StandardMaterial3D.new() + std.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + std.albedo_color = Color(preset["farbe_flach"], 0.6) + std.roughness = 0.15 + std.metallic = 0.1 + return std + + +## Shader aus den Repo-Pfaden laden (erster vorhandener gewinnt). +static func _shader_laden() -> Shader: + for pfad in SHADER_KANDIDATEN: + if ResourceLoader.exists(pfad): + var s := load(pfad) + if s is Shader: + return s + return null + + +## Höhenfeld dekodieren (R16, identische Formel wie SvmapTerrain/SvmapIO/ +## match-server — hier NOCHMAL lokal, damit dieses Spiegel-Modul von keinem +## Repo-Skript abhängt; 15 Zeilen Duplikat gegen Repo-Kopplung getauscht). +static func _decode_heights(terrain: Dictionary) -> PackedFloat32Array: + var res := int(terrain.get("resolution", 0)) + var out := PackedFloat32Array() + if res < 2: + return out + out.resize(res * res) + var raw: PackedByteArray = Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", ""))) + if raw.size() < res * res * 2: + return PackedFloat32Array() # kaputtes/fehlendes Höhenfeld → kein Wasser + var hmin := float(terrain.get("min_height_m", 0.0)) + var hmax := float(terrain.get("max_height_m", 1.0)) + if hmax <= hmin: + hmax = hmin + 1.0 + var n := mini(raw.size() / 2, res * res) + for i in n: + var v: int = raw.decode_u16(i * 2) + out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin) + return out diff --git a/library/svmap_wasser.gd.uid b/library/svmap_wasser.gd.uid new file mode 100644 index 0000000..6fa8227 --- /dev/null +++ b/library/svmap_wasser.gd.uid @@ -0,0 +1 @@ +uid://cppnjm0fhofpa diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index d752acc..f01b8dc 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -275,6 +275,8 @@ static func _validate_terrain(terrain: Dictionary) -> PackedStringArray: break elif not (mats is String): errors.append("V15: materials_r8_b64 muss ein Base64-String sein (ist %s)." % str(mats)) + # V20 (Karte t_28fdef03): Wasser-Level (optional). + errors.append_array(_validate_wasser(terrain)) return errors @@ -673,3 +675,28 @@ static func rot_setzen(obj: Dictionary, euler: Vector3) -> void: obj.erase("rot") else: obj["rot"] = [euler.x, euler.y, euler.z] + + +# ------------------------------------------------- Wasser (Karte t_28fdef03) -- + +## V20 (Karte t_28fdef03): optionales svmap-Feld terrain.water_level (float y). +## Abwesenheit = kein Wasser (Legacy-Maps unverändert). Gleiche Regel wie im +## Match-Server (svmap_loader._validate_wasser): endliche Zahl im/am Terrain- +## Höhenbereich ±10 m — sonst wäre das Level sinnlos (unendlich tief unter +## Terrain oder meilen über dem Gelände schwebend). +static func _validate_wasser(terrain: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var wl = terrain.get("water_level", null) + if wl == null: + return errors + if not _is_finite_num(wl): + errors.append("V20: terrain.water_level muss eine endliche Zahl sein (ist %s)." % str(wl)) + return errors + var hmin = terrain.get("min_height_m", 0.0) + var hmax = terrain.get("max_height_m", 0.0) + if not _is_finite_num(hmin) or not _is_finite_num(hmax): + return errors # V6 meldet das bereits; hier nichts doppelt. + if float(wl) < float(hmin) - 10.0 or float(wl) > float(hmax) + 10.0: + errors.append("V20: terrain.water_level (%.2f m) liegt außerhalb des Terrain-Höhenbereichs [%.2f, %.2f] ±10 m." % [ + float(wl), float(hmin), float(hmax)]) + return errors diff --git a/src/ui/panels/map_panel.gd b/src/ui/panels/map_panel.gd index 00aec5e..97a8adb 100644 --- a/src/ui/panels/map_panel.gd +++ b/src/ui/panels/map_panel.gd @@ -11,10 +11,16 @@ var prop_desc: LineEdit var prop_version: SpinBox var prop_weather: OptionButton var prop_sky: OptionButton +var prop_water: HSlider var terrain_info: Label var _loading_ui_values := false # verhindert dirty beim Auffüllen der Felder +## Karte t_28fdef03: Slider-Stellung für „Kein Wasser“ (Meta-Vertrag für +## Tests/Verdrahtung). Slider unterhalb davon (−10 m) = Wasser AUS — das doc- +## Feld wird dann ENTFERNT (Legacy-sauber, kein Wert 0 versehentlich gespeichert). +const KEIN_WASSER_WERT := -10.0 + static func spec() -> Dictionary: return {"titel": "Map", "gruppe": "rechts", "order": 30} @@ -66,6 +72,23 @@ func build() -> void: prop_sky.add_item("Pack-Skybox (Wüste)") add_child(prop_sky) + # Wasser (Karte t_28fdef03): Wasserhöhe der Map — Senken unter der Höhe + # füllen sich automatisch. Ganz links = „Kein Wasser“ (Feld entfernen, + # alte Maps unverändert). Die Live-Vorschau nutzt dasselbe Material wie + # der Client (SvmapWasser.make_material — Identitäts-Vertrag). + add_child(_field_label("Wasserhöhe")) + prop_water = HSlider.new() + prop_water.min_value = -10.0 + prop_water.max_value = 100.0 + prop_water.step = 0.25 + prop_water.value = KEIN_WASSER_WERT + prop_water.custom_minimum_size = UIScale.v2(0, 32) + prop_water.tooltip_text = "Wasser: ganz links = kein Wasser, sonst Höhe der Wasseroberfläche über dem Terrain. Senken unter dieser Höhe füllen sich mit Wasser." + prop_water.set_meta("kein_wasser_value", KEIN_WASSER_WERT) + add_child(prop_water) + var wasser_hinweis := _muted_label("Senken unter der Wasserhöhe füllen sich automatisch. Wasser betreten = knietief, träge (kein Schwimmen).") + add_child(wasser_hinweis) + add_child(_separator()) add_child(_header_label("Terrain")) terrain_info = Label.new() @@ -80,6 +103,7 @@ func build() -> void: prop_version.value_changed.connect(_on_version_changed) prop_weather.item_selected.connect(_on_weather_changed) prop_sky.item_selected.connect(_on_sky_changed) + prop_water.value_changed.connect(_on_water_changed) func _on_prop_changed(text: String, field: String) -> void: @@ -137,6 +161,38 @@ func _on_sky_changed(index: int) -> void: main.call("_set_status", "Himmel geändert auf „%s“." % ["Prozedural (Standard)", "Pack-Skybox (Wüste)"][index]) +## Wasser (Karte t_28fdef03): Slider → doc["water_level"]. Ganz links +## („Kein Wasser“, KEIN_WASSER_WERT) = Feld ENTFERNEN (Legacy-sauber). +## Sofort-Neuaufbau wie Wetter/Himmel — die Vorschau zeigt die Fläche live +## (dasselbe Material wie der Client). +func _on_water_changed(value: float) -> void: + if _loading_ui_values or main.doc.is_empty(): + return + if value <= KEIN_WASSER_WERT + 0.0001: + main.doc.erase("water_level") + main.call("_set_status", "Wasser entfernt (kein Wasser in dieser Karte).") + else: + var terrain: Dictionary = main.doc.get("terrain", {}) + var ok := true + if not terrain.is_empty(): + # Range-Bewacher (V20-Band): sinnlose Werte gar nicht erst + # ins doc schreiben — der Slider klemmt am Höhenbereich. + var hmin := float(terrain.get("min_height_m", -5.0)) + var hmax := float(terrain.get("max_height_m", 500.0)) + var geklemmt := clampf(value, hmin - 10.0, hmax + 10.0) + if geklemmt != value: + ok = false + value = geklemmt + prop_water.set_value_no_signal(geklemmt) + main.call("_set_status", "Wasserhöhe an den Terrain-Höhenbereich geklemmt (%.1f m)." % geklemmt) + if ok or value != KEIN_WASSER_WERT: + main.doc["water_level"] = value + main.dirty = true + main.call("_refresh_file_label") + main.view_builder.call("rebuild", main.doc, main.manifest) + main.call("_sync_grid_to_builder") + + ## Felder aus dem doc auffüllen (aus main.gd _refresh_all-Anfang ausgezogen). func refresh_values() -> void: _loading_ui_values = true @@ -157,4 +213,8 @@ func refresh_values() -> void: int(terrain.get("resolution", 0)), int(terrain.get("resolution", 0)), float(terrain.get("min_height_m", 0.0)), float(terrain.get("max_height_m", 0.0))] + # Wasser (Karte t_28fdef03): Regler aus dem doc füllen (ohne dirty). + var wl: Variant = main.doc.get("water_level", null) + prop_water.set_value_no_signal( + float(wl) if (wl is float or wl is int) else KEIN_WASSER_WERT) _loading_ui_values = false diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index dccfc0e..063fd6a 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -146,6 +146,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void: _build_environment(weather, weather_sky) _build_terrain(data) + _build_wasser(data) # Karte t_28fdef03: nach dem Terrain (Senken füllen sich) _build_grid(size_x, size_z, ground_y) # Gitter default AUS über sichtbarem Terrain (Karte t_7ac70014): das # Terrain ist die Bodendarstellung — das Gitter lag als Linien-Overlay @@ -167,6 +168,25 @@ func _build_terrain(data: Dictionary) -> void: terrain_view.call("rebuild_from_doc", data) +## Wasserfläche (Karte t_28fdef03): EINE transparente Fläche auf `water_level` +## über der GANZEN Map (SvmapWasser.baue — Senken unter der Höhe füllen sich +## automatisch). OHNE Feld → kein Wasser (Legacy-Maps unverändert). Dasselbe +## Material-Verfahren wie der Client (SvmapWasser.make_material — Identitäts- +## Vertrag der Vorschau: Editor zeigt exakt, was der Quest-Client zeigt). +## Leicht ÜBER dem Terrain eingehängt (y-Offset +0.01 über Reihenfolge), aber +## die Fläche selbst liegt exakt auf der Wasserhöhe (Z-Fight-Vermeidung macht +## der Shader über die Tiefen-Textur, nicht über einen Off-Aufschlag). +func _build_wasser(data: Dictionary) -> void: + var level := SvmapWasser.level(data) + if not is_finite(level): + return # Legacy/fehlendes Feld — kein Wasser, nichts bauen. + var terrain: Dictionary = data.get("terrain", {}) + var wetter := str(data.get("weather", "mittag")) + var wasser := SvmapWasser.baue(terrain, level, wetter) + if wasser != null: + add_child(wasser) + + ## Live-Update während eines Pinsel-Strichs (aus terrain_tool.gd): nur die ## Vertices werden neu geschrieben — Grid/Objekte/Marker bleiben unangetastet. ## mats (optional) = Material-Schicht (Karte t_603ec1a8, Malen-Modus) — diff --git a/src/viewport/terrain_mesh.gd b/src/viewport/terrain_mesh.gd index b45e7ae..ab7054e 100644 --- a/src/viewport/terrain_mesh.gd +++ b/src/viewport/terrain_mesh.gd @@ -79,6 +79,8 @@ var _size_z := 0.0 var _mats_cache := PackedByteArray() # zuletzt gesehene Material-Schicht # (res²) — Live-Pfad-Fallback, Karte # t_55ed041a. +var _wasser_level := INF # Karte t_28fdef03: Wasserhöhe aus dem doc + # (INF = kein Wasser — Matsch-Ring aus). var _triangles := 0 var _contours_pref := true # UI-Toggle „Höhenlinien“ (Default AN) var _shader: Shader @@ -163,6 +165,9 @@ func rebuild_from_doc(data: Dictionary) -> void: if _mi != null: _mi.mesh = null return + # Wasser (Karte t_28fdef03): Höhe merken — der Matsch-Ring (Ufer-Automatik) + # mischt sich in relief_color ein. INF = kein Wasser (Legacy unverändert). + _wasser_level = SvmapWasser.level(data) var heights := SvmapIO.decode_heights(terrain) var mats := SvmapIO.decode_materials(terrain) update_heights(heights, int(terrain.get("resolution", 0)), @@ -253,7 +258,15 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0 if idx < mats.size(): mat_id = clampi(mats[idx], 0, MATERIAL_COLORS.size() - 1) # Material-Farbe × Höhen-Kurve × Pseudo-Licht (Relief lesbar). - colors[idx] = relief_color(mat_id, heights[idx], hmin, hmax, normals[idx]) + var farbe := relief_color(mat_id, heights[idx], hmin, hmax, normals[idx]) + # Ufer-Automatik (Karte t_28fdef03): Zellen knapp unter der Wasser- + # höhe bekommen den Matsch-Ring — IDENTISCHE Formel wie der Client + # (SvmapWasser.ufer_faktor/matsch_anwenden — dieselbe Spiegel-Datei). + # Alpha (gepackte Material-ID) bleibt unangetastet (Splat-Vertrag). + if _wasser_level < INF: + farbe = SvmapWasser.matsch_anwenden(farbe, + SvmapWasser.ufer_faktor(heights[idx], _wasser_level)) + colors[idx] = farbe heights_uv[idx] = Vector2(heights[idx], 0.0) x += 1 z += 1 diff --git a/src/viewport/wasser.gdshader b/src/viewport/wasser.gdshader new file mode 100644 index 0000000..1504512 --- /dev/null +++ b/src/viewport/wasser.gdshader @@ -0,0 +1,92 @@ +shader_type spatial; +// Wasser-Oberfläche pro Map (Karte t_28fdef03) — SPIEGELGLEICHE Datei: +// scopedvr-client → res://shaders/wasser.gdshader +// scopedvr-map-editor → res://src/viewport/wasser.gdshader (byteidentischer +// Spiegel, sync via tools/sync_wasser.sh — wie terrain_splat/terrain_arrays). +// +// Quest-3-Perf-Vertrag (bewusste Absagen der Spec): KEINE echte Spiegelung, +// KEINE Brechung, KEIN SSR. Spiegel-Impression = Skybox-Farbreflex (uniform) +// + Fresnel-Glanz. Wellen NUR im Fragment-Shader (analytische 2-Layer-Sinus- +// Normalen — keine Wellen-Texturen, keine Geometrie-Bewegung). Ufer-Schaum + +// Flachwasser-Transparenz aus der Tiefe-Textur (ufer_tex, R8 = Tiefe/TIEFE_MAX, +// siehe SvmapWasser.ufer_bild). Ferne Fragmente vereinfachen sich selbst +// (Wasserdistanz-Regel): nur Grundfarbe, keine Wellen/Schaum-Mathematik. +// +// Eine Fläche, ein Draw-Call — Overdraw ist die einzige echte Kostenquelle. + +render_mode blend_mix, depth_draw_never, cull_back, unshaded, shadows_disabled; + +uniform vec4 farbe_flach : source_color = vec4(0.10, 0.30, 0.34, 1.0); +uniform vec4 farbe_tief : source_color = vec4(0.02, 0.10, 0.15, 1.0); +uniform vec3 reflex : source_color = vec3(0.72, 0.83, 0.92); // Skybox-Farbreflex (Wetter) +uniform float glanz : hint_range(0.0, 2.0) = 1.0; // Glint-Stärke (Wetter) +uniform sampler2D ufer_tex : filter_linear; // R = Tiefe 0..1 (0..TIEFE_MAX) +uniform float tiefe_max = 4.0; // SvmapWasser.TIEFE_MAX (Spiegel-Konstante) +uniform float schaum_band = 0.35; // SvmapWasser.SCHAUM_BAND (Meter) +uniform float wellen_scale = 0.55; // Wellenfrequenz (1/m) +uniform float wellen_staerke = 0.16; // Normal-Neigung (flach = Spiegel-Optik) + +varying vec3 welt_pos; // Welt-Position des Fragments (Fresnel + Distanz-Regel) + +void vertex() { + welt_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +// Analytische Wellen-Normale aus 2 Scroll-Ebenen (kleine Skala, verschiedene +// Richtungen/Geschwindigkeiten — läuft nie synchron): h = Σ A·sin(dot(p,d)·f + t·s), +// n ∝ (−∂h/∂x, 1, −∂h/∂z) mit ∂h/∂p = Σ A·f·d·cos(…). +vec3 wellen_normal(vec2 p, float t) { + vec2 d1 = vec2(0.94, 0.34); + vec2 d2 = vec2(-0.42, 0.91); + float f1 = wellen_scale * 2.1; + float f2 = wellen_scale * 3.7; + float s1 = 1.25; + float s2 = 1.75; + float a1 = wellen_staerke; + float a2 = wellen_staerke * 0.6; + vec2 g1 = d1 * (f1 * a1 * cos(dot(p, d1) * f1 + t * s1)); + vec2 g2 = d2 * (f2 * a2 * cos(dot(p, d2) * f2 + t * s2)); + vec2 g = g1 + g2; + return normalize(vec3(-g.x, 1.0, -g.y)); +} + +void fragment() { + // Tiefe am Fragment (ufer_tex, UV deckt die Map 1:1 ab — SvmapWasser.ufer_bild). + float tiefe01 = texture(ufer_tex, UV).r; + float tiefe = tiefe01 * tiefe_max; + + // Wasserdistanz-Regel: ferne Fragmente = vereinfachter Modus (Grundfarbe, + // flache Normale, kein Schaum). Weiches Fenster 45→90 m, pro Fragment. + float fern = smoothstep(45.0, 90.0, length(welt_pos - CAMERA_POSITION_WORLD)); + float detail = 1.0 - fern; + + vec3 n = wellen_normal(welt_pos.xz, TIME); + n = normalize(mix(vec3(0.0, 1.0, 0.0), n, detail)); + + vec3 v = normalize(CAMERA_POSITION_WORLD - welt_pos); + float fresnel = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 5.0); + + // Grundfarbe: seicht heller/durchsichtiger, tief dunkler. + vec3 grund = mix(farbe_flach.rgb, farbe_tief.rgb, clamp(tiefe01 * 1.4, 0.0, 1.0)); + + // Spiegel-Impression: Skybox-Farbreflex via Fresnel + Sonnen-Glint. + vec3 reflex_f = reflex * mix(0.25, 1.0, fresnel); + vec3 l = normalize(vec3(-0.45, 0.75, 0.5)); // SvmapTerrain.RELIEF_SUN (Spiegel) + float glint = pow(max(dot(reflect(-v, n), l), 0.0), 48.0) * glanz; + + // Ufer-Schaum: schmales Band an der Wasserlinie, Kante leicht wellig + // (Wellen-Phase pulsiert das Band ±20 %). + float schaum_grenze = schaum_band * (0.9 + 0.2 * sin(welt_pos.x * 1.7 + welt_pos.z * 1.3 + TIME * 1.2)); + float schaum = (1.0 - smoothstep(0.0, max(schaum_grenze, 0.02), tiefe)) * detail; + + vec3 farbe = grund + reflex_f + vec3(glint) + vec3(schaum * 0.85); + // Transparenz: Ufer fast klar, tiefes Wasser dicht. Schaum deckt voll. + float alpha = mix(0.38, 0.88, clamp(tiefe01 * 1.6, 0.0, 1.0)); + alpha = max(alpha, schaum * 0.9); + + // spatial-Vertrag: Farbe über ALBEDO, Transparenz über ALPHA (COLOR gibt + // es nur im canvas_item-Shader — schreibend dort = „Constants cannot be + // modified“). + ALBEDO = farbe; + ALPHA = alpha; +} diff --git a/src/viewport/wasser.gdshader.uid b/src/viewport/wasser.gdshader.uid new file mode 100644 index 0000000..71814f5 --- /dev/null +++ b/src/viewport/wasser.gdshader.uid @@ -0,0 +1 @@ +uid://c7s4uhqs208da diff --git a/tests/test_wasser.gd b/tests/test_wasser.gd new file mode 100644 index 0000000..24c3f96 --- /dev/null +++ b/tests/test_wasser.gd @@ -0,0 +1,202 @@ +extends SceneTree +## Editor-Test (headless): Wasser pro Map (Karte t_28fdef03). +## Ausführen: godot --headless --path . --script res://tests/test_wasser.gd +## Exit 0 = alles grün. +## +## W1 Format (SvmapIO V20): water_level optional; gültige Zahl innerhalb +## des Terrain-Höhenbereichs ±10 m → keine Fehler; String/INF/draußen → +## V20-Fehler; Legacy (ohne Feld) validiert unverändert. +## W2 Map-Panel „Wasserhöhe“: Regler existiert, doc-Roundtrip +## (slider.value = Wasserhöhe; „Kein Wasser“ = Feld ENTFERNEN; +## refresh_values füllt aus dem doc auf; Slider-Nutzung markiert dirty). +## W3 Editor-Vorschau (map_view_builder): doc mit water_level → +## Wasser/WasserFlaeche existiert, liegt exakt auf der Höhe, Material = +## dasselbe Shader-Verfahren wie der Client (SvmapWasser.make_material); +## ohne Feld → kein Wasser-Node (alte Maps unverändert); Matsch-Ring +## in den Terrain-Zellfarben (Ufer-Automatik, gleiche Formel wie Client). +## W4 Validator-Spiegel: V20-Band/-Grenzen identisch zum Match-Server. + +var failures := 0 +var checks := 0 +var main +var map_panel + + +func _initialize() -> void: + _run_all.call_deferred() + + +func _check(cond: bool, label: String) -> void: + checks += 1 + print("%s: %s" % ["ok " if cond else "FAIL", label]) + if not cond: + failures += 1 + + +func _run_all() -> void: + var scene: PackedScene = load("res://scenes/main.tscn") + _check(scene != null, "Aufbau: scenes/main.tscn laedt") + if scene == null: + _finish() + return + main = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + + map_panel = main.get("map_mod") + _check(map_panel != null, "Aufbau: Map-Panel in main eingebaut") + _check(main.get("terrain_mod") != null, "Aufbau: Terrain-Panel eingebaut") + _check(main.view_builder != null, "Aufbau: view_builder vorhanden") + if main == null or map_panel == null or main.view_builder == null: + _finish() + return + + await _w1_validierung() + await _w2_panel() + await _w3_vorschau() + _w4_validator_spiegel() + _finish() + + +func _w1_validierung() -> void: + print("== W1: svmap-Validierung water_level (V20) ==") + var terrain: Dictionary = main.doc["terrain"] + var hmin := float(terrain["min_height_m"]) + var hmax := float(terrain["max_height_m"]) + # Legacy ohne Feld: unverändert gültig. + var alte: Dictionary = main.doc.duplicate(true) + alte.erase("water_level") + SvmapIO.sign_map(alte) + var f0: PackedStringArray = SvmapIO.validate_map(alte) + _check(f0.is_empty(), "W1 Karte ohne water_level validiert (%s)" % str(f0)) + # Gültige Werte. + for wert in [hmin, hmax, (hmin + hmax) * 0.5]: + var d: Dictionary = main.doc.duplicate(true) + (d["terrain"] as Dictionary)["water_level"] = wert + SvmapIO.sign_map(d) + var f: PackedStringArray = SvmapIO.validate_map(d) + _check(f.is_empty(), "W1 water_level=%.1f (Höhenbereich) validiert (%s)" % [wert, str(f)]) + # Ungültige Werte → V20 (_validate_core, ohne Prüfsumme). + for wert in ["hoch", INF, hmax + 10.5, hmin - 10.5]: + var d2: Dictionary = main.doc.duplicate(true) + (d2["terrain"] as Dictionary)["water_level"] = wert + var f2: PackedStringArray = SvmapIO._validate_core(d2) + var hat_v20 := false + for e in f2: + if str(e).begins_with("V20"): + hat_v20 = true + _check(hat_v20, "W1 water_level=%s → V20-Fehler (%s)" % [str(wert), str(f2)]) + + +func _w2_panel() -> void: + print("== W2: Map-Panel Wasserhöhe ==") + var slider: HSlider = map_panel.get("prop_water") + _check(slider != null, "W2 prop_water (Regler) existiert") + if slider == null: + return + _check(is_equal_approx(float(slider.get_meta("kein_wasser_value", 0.0)), -10.0), + "W2 Slider trägt die Kein-Wasser-Kennung (−10)") + # doc OHNE Feld → Regler auf "Kein Wasser". + main.doc.erase("water_level") + map_panel.call("refresh_values") + _check(is_equal_approx(float(slider.value), -10.0), + "W2 ohne Feld → Regler auf „Kein Wasser“") + # doc MIT Feld → Regler zeigt die Höhe. + main.doc["water_level"] = 3.5 + map_panel.call("refresh_values") + _check(is_equal_approx(float(slider.value), 3.5), "W2 doc=3.5 → Regler 3.5") + # Regler bewegen → doc + dirty. Erst das dirty-Flag aus dem refresh löschen. + main.set("dirty", false) + slider.value_changed.emit(4.25) + _check(bool(main.get("dirty")), "W2 Regler-Bewegung markiert dirty") + _check(is_equal_approx(float(main.doc.get("water_level", -999.0)), 4.25), + "W2 Regler schreibt water_level ins doc") + # Regler zurück auf „Kein Wasser“ → Feld ENTFERNEN (Legacy-sauber). + slider.set_value_no_signal(-10.0) + slider.value_changed.emit(-10.0) + _check(not main.doc.has("water_level"), "W2 „Kein Wasser“ entfernt das Feld") + _check(bool(main.get("dirty")), "W2 Entfernen markiert ebenfalls dirty") + # Für W3: Feld wieder setzen (per Panel-Handler — dieselbe Verdrahtung). + slider.set_value_no_signal(2.0) + slider.value_changed.emit(2.0) + _check(is_equal_approx(float(main.doc.get("water_level", -999.0)), 2.0), + "W2 Feld wieder gesetzt (2.0) für die Vorschau-Prüfung") + + +func _w3_vorschau() -> void: + print("== W3: Editor-Vorschau (map_view_builder) ==") + var vb = main.view_builder + # Terrain mit einer echten Senke bauen, damit Wasser sichtbar ist. + var terrain: Dictionary = main.doc["terrain"] + var res := int(terrain["resolution"]) + var hmin := float(terrain["min_height_m"]) + var heights: PackedFloat32Array = SvmapIO.decode_heights(terrain) + for i in heights.size(): + heights[i] = hmin + # Senke in der Map-Mitte unter die Wasserhöhe (2.0) graben. + var mitte := int(float(res) * 0.5) + for z in range(maxi(mitte - 4, 0), mini(mitte + 5, res)): + for x in range(maxi(mitte - 4, 0), mini(mitte + 5, res)): + heights[z * res + x] = 2.0 - 1.5 + terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, float(terrain["max_height_m"])) + SvmapIO.sign_map(main.doc) + await process_frame + await process_frame # queue_free der alten Kinder aus den W2-Rebuilds + vb.call("rebuild", main.doc, main.manifest) + await process_frame + await process_frame # queue_free der alten Kinder braucht 2 Frames + var wasser: Node3D = _wasser_node(vb) + _check(wasser != null, "W3 Wasser-Node in der Vorschau (Senke gefüllt)") + if wasser == null: + return + var mi := wasser.get_node_or_null("WasserFlaeche") as MeshInstance3D + _check(mi != null, "W3 WasserFlaeche existiert") + if mi != null: + _check(is_equal_approx(mi.position.y, 2.0), "W3 Fläche auf Wasserhöhe (y=2.0)") + var mat := mi.material_override + _check(mat is ShaderMaterial and (mat as ShaderMaterial).shader != null, + "W3 Material = Wasser-Shader (dasselbe Verfahren wie der Client)") + if mat is ShaderMaterial: + var sp: Variant = (mat as ShaderMaterial).get_shader_parameter("ufer_tex") + _check(sp != null, "W3 Ufer-Tiefen-Textur gesetzt") + # Matsch-Ring: dieselbe Formel wie der Client (Spiegel-Modul) mischt in + # die Terrain-Zellfarben ein — direkt gegen die Spiegel-Mathe geprüft. + # Knapp unter der Wasserlinie = fast voller Matsch (ufer_faktor ≈ 1), + # 0,25 m tiefe Zelle = im weichen Band-Auslauf, Tiefwasser = 0. + var f_ring := SvmapWasser.ufer_faktor(2.0 - 0.05, 2.0) + var f_mitte := SvmapWasser.ufer_faktor(2.0 - 0.25, 2.0) + var f_tief := SvmapWasser.ufer_faktor(-4.0, 2.0) + _check(f_ring > 0.9, "W3 Matsch-Ring an der Wasserlinie (ufer_faktor > 0.9, ist %.2f)" % f_ring) + _check(f_mitte > 0.4 and f_mitte < f_ring, "W3 Matsch-Band fällt weich ab (0,25 m Tiefe = %.2f)" % f_mitte) + _check(f_tief == 0.0, "W3 Tiefwasser ohne Matsch (ufer_faktor = 0)") + var base := Color(0.9, 0.9, 0.9, 0.25) # Alpha = gepackte Material-ID + var gemischt := SvmapWasser.matsch_anwenden(base, 1.0) + _check(gemischt.a == base.a, "W3 Matsch fasst die Material-ID (Alpha) nicht an") + # Feld entfernen → kein Wasser (alte Maps unverändert). + main.doc.erase("water_level") + vb.call("rebuild", main.doc, main.manifest) + await process_frame + await process_frame # queue_free der alten Kinder braucht 2 Frames + _check(_wasser_node(vb) == null, "W3 ohne water_level → kein Wasser-Node (Legacy)") + + +func _wasser_node(vb) -> Node3D: + return vb.get_node_or_null("Wasser") as Node3D + + +func _w4_validator_spiegel() -> void: + print("== W4: Validator-Spiegel (V20-Band wie Match-Server) ==") + # Die Konstanten des Spiegel-Moduls decken die Server-Bandbreite ab. + _check(SvmapWasser.LEVEL_MIN == -5.0 and SvmapWasser.LEVEL_MAX == 500.0, + "W4 Plausibilitäts-Band Spiegel = Server (−5..500)") + # Editor-Validator testet Terrain-Höhenbereich ±10 m — die Server-Regel + # hat der Server-Suite identisch gezeigt; hier der Shader-Spiegel-Vertrag: + _check(ResourceLoader.exists("res://src/viewport/wasser.gdshader"), + "W4 Spiegel-Shader im Editor-Pfad vorhanden") + + +func _finish() -> void: + print("--------------------------------------------------") + print("CHECKS: %d FAILURES: %d" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/test_wasser.gd.uid b/tests/test_wasser.gd.uid new file mode 100644 index 0000000..6112946 --- /dev/null +++ b/tests/test_wasser.gd.uid @@ -0,0 +1 @@ +uid://bhuk6ug5n81ly