scopedvr-map-editor/library/svmap_wasser.gd
Hermes (scopedvr-dev) f5a4ab54d0 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
2026-10-21 06:13:59 +00:00

240 lines
10 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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