- 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
240 lines
10 KiB
GDScript
240 lines
10 KiB
GDScript
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
|