Wasser pro Map (t_28fdef03): V20-Validator, Panel-Regler, Vorschau-Renderer

- 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
This commit is contained in:
Hermes (scopedvr-dev) 2026-10-21 06:13:59 +00:00
parent 8468b89456
commit f5a4ab54d0
10 changed files with 658 additions and 1 deletions

240
library/svmap_wasser.gd Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

202
tests/test_wasser.gd Normal file
View file

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

1
tests/test_wasser.gd.uid Normal file
View file

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