M2-Karte t_603ec1a8: Terrain-Texturen (Gras/Sand/Fels) + Pinselgroessen. svmap v2: optionale Material-Schicht materials_r8_b64 (uint8/res², 0=Gras/1=Sand/2=Fels), V15-Validierung (Laenge+ID-Range), Legacy-v1 laedt mit Default Gras, gespeichert wird immer v2. TerrainField: Mode.PAINT + apply_material_brush (Kreis, crisp, Hoehe unangetastet). terrain_tool: 4. Pinsel-Modus Malen (Palette Gras/Sand/Fels, Rechtsklick malt Gras zurueck), Ring in Materialfarbe; ganzer Strich = EIN ActionHistory-Eintrag (terrain-Block-Snapshot deckt Hoehe+Material ab). main.gd: Palette-Panel (nur im Malen-Modus), drei Pinselgroessen-Presets Klein(4m)/Mittel(12m)/Gross(32m) + freier Slider, deutsche Labels. terrain_mesh: Vertex-Farben aus Material-Schicht x Hoehe-Shading (KEIN Custom-Shader, Quest-3-Perf-Vertrag — Entscheidung gegen Splattmap in DECISIONS.md). Tests: test_terrain +3 Gruppen (Material-Pinsel, Mal-Stroke-Undo byte-identisch, v2/V15+Legacy) -> 53 Checks; Suite 231 Checks gruen.

This commit is contained in:
Hermes godot-vr 2026-10-15 15:41:53 +00:00
parent 423b223454
commit 23ede45efd
9 changed files with 427 additions and 39 deletions

View file

@ -17,7 +17,7 @@ extends Node3D
## liest daraus. Undo/Redo läuft über die gemeinsame ActionHistory (terrain-Block). ## liest daraus. Undo/Redo läuft über die gemeinsame ActionHistory (terrain-Block).
const SMOOTH_RATE_DIVISOR := 20.0 # Stärke-Slider (m/s) → Glätt-Tempo (Anteil/s) const SMOOTH_RATE_DIVISOR := 20.0 # Stärke-Slider (m/s) → Glätt-Tempo (Anteil/s)
const MODE_LABELS := ["Heben", "Senken", "Glätten"] const MODE_LABELS := ["Heben", "Senken", "Glätten", "Malen"]
var main # Hauptfenster (script-erweitert, Variant) var main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D var camera: Camera3D
@ -28,11 +28,14 @@ var active := false
var brush_mode: int = TerrainField.Mode.RAISE var brush_mode: int = TerrainField.Mode.RAISE
var brush_radius := 12.0 # Meter var brush_radius := 12.0 # Meter
var brush_strength := 8.0 # Meter pro Sekunde (Heben/Senken) var brush_strength := 8.0 # Meter pro Sekunde (Heben/Senken)
var paint_material: int = 0 # Material-ID im Malen-Modus (0=Gras, 1=Sand, 2=Fels)
# Stroke-Zustand # Stroke-Zustand
var _stroking := false var _stroking := false
var _stroke_mode: int = TerrainField.Mode.RAISE var _stroke_mode: int = TerrainField.Mode.RAISE
var _stroke_material: int = 0
var _heights := PackedFloat32Array() var _heights := PackedFloat32Array()
var _mats := PackedByteArray() # Material-Schicht (res²) während des Mal-Strichs
var _res := 0 var _res := 0
var _sx := 0.0 var _sx := 0.0
var _sz := 0.0 var _sz := 0.0
@ -129,6 +132,11 @@ func set_strength(mps: float) -> void:
brush_strength = clampf(mps, 1.0, 20.0) brush_strength = clampf(mps, 1.0, 20.0)
func set_paint_material(id: int) -> void:
paint_material = clampi(id, 0, SvmapIO.MATERIAL_IDS.size() - 1)
_refresh_ring_mesh()
# --------------------------------------------------------------- Eingaben --- # --------------------------------------------------------------- Eingaben ---
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
@ -148,10 +156,14 @@ func _unhandled_input(event: InputEvent) -> void:
elif mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: elif mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
# Rechtsklick = das Gegenteil zum Modell: bei Heben senken, # Rechtsklick = das Gegenteil zum Modell: bei Heben senken,
# bei Senken heben, bei Glätten senken (kein Umschalten nötig). # bei Senken heben, bei Glätten senken (kein Umschalten nötig).
var opposite: int = TerrainField.Mode.LOWER # Im Malen-Modus malt Rechtsklick Gras zurück (Radierer-Semantik).
if brush_mode == TerrainField.Mode.LOWER: if brush_mode == TerrainField.Mode.PAINT:
opposite = TerrainField.Mode.RAISE _begin_stroke(TerrainField.Mode.PAINT, 0)
_begin_stroke(opposite) else:
var opposite: int = TerrainField.Mode.LOWER
if brush_mode == TerrainField.Mode.LOWER:
opposite = TerrainField.Mode.RAISE
_begin_stroke(opposite)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif mb.button_index == MOUSE_BUTTON_RIGHT and not mb.pressed: elif mb.button_index == MOUSE_BUTTON_RIGHT and not mb.pressed:
if _stroking: if _stroking:
@ -184,7 +196,7 @@ func _height_at_stroke(x: float, z: float) -> float:
# ----------------------------------------------------------------- Stroke --- # ----------------------------------------------------------------- Stroke ---
func _begin_stroke(mode: int) -> void: func _begin_stroke(mode: int, material_id: int = -1) -> void:
if _stroking: if _stroking:
return return
if not _cursor_valid: if not _cursor_valid:
@ -193,14 +205,33 @@ func _begin_stroke(mode: int) -> void:
true) true)
return return
_stroke_mode = mode _stroke_mode = mode
_stroke_material = paint_material if material_id < 0 else material_id
_stroking = true _stroking = true
# Im Malen-Modus lebt die Material-Schicht während des Strichs
# (dekodiert — Legacy-Maps ohne Schicht starten mit alles-Gras).
if mode == TerrainField.Mode.PAINT:
_mats = SvmapIO.decode_materials(main.doc.get("terrain", {}))
_refresh_ring_mesh() _refresh_ring_mesh()
main.call("_set_status", "%s läuft — Maustaste gedrückt halten. Beim Loslassen wird der ganze Strich EIN Rückgängig-Schritt (Strg+Z)." % MODE_LABELS[mode]) main.call("_set_status", "%s läuft — Maustaste gedrückt halten. Beim Loslassen wird der ganze Strich EIN Rückgängig-Schritt (Strg+Z)." % _stroke_label())
## Lesbarer Stroke-Name: Heben/Senken/Glätten bzw. „Malen (Sand)".
func _stroke_label() -> String:
if _stroke_mode == TerrainField.Mode.PAINT:
return "Malen (%s)" % SvmapIO.MATERIAL_IDS[clampi(_stroke_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)]
return MODE_LABELS[_stroke_mode]
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not _stroking or not _cursor_valid: if not _stroking or not _cursor_valid:
return return
if _stroke_mode == TerrainField.Mode.PAINT:
var painted := TerrainField.apply_material_brush(_mats, _res, _sx, _sz,
_cursor.x, _cursor.z, brush_radius, _stroke_material)
if painted:
view_builder.call("update_terrain", _heights, _mats)
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
return
var amount: float var amount: float
if _stroke_mode == TerrainField.Mode.SMOOTH: if _stroke_mode == TerrainField.Mode.SMOOTH:
amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * delta, 0.0, 1.0) amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * delta, 0.0, 1.0)
@ -242,11 +273,16 @@ func _end_stroke() -> void:
# Doc erst jetzt ändern: der GANZE Strich ist ein History-Eintrag. # Doc erst jetzt ändern: der GANZE Strich ist ein History-Eintrag.
var before: Dictionary = main.history.snapshot(["terrain"]) var before: Dictionary = main.history.snapshot(["terrain"])
var terrain: Dictionary = main.doc.get("terrain", {}) var terrain: Dictionary = main.doc.get("terrain", {})
terrain["min_height_m"] = _hmin if _stroke_mode == TerrainField.Mode.PAINT:
terrain["max_height_m"] = _hmax terrain["materials_r8_b64"] = SvmapIO.encode_materials(_mats)
terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax) main.history.commit("Boden gemalt (%s)" % SvmapIO.MATERIAL_IDS[clampi(_stroke_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)],
main.history.commit("Terrain geformt (%s)" % MODE_LABELS[_stroke_mode], before, main.history.snapshot(["terrain"]))
before, main.history.snapshot(["terrain"])) else:
terrain["min_height_m"] = _hmin
terrain["max_height_m"] = _hmax
terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax)
main.history.commit("Boden geformt (%s)" % MODE_LABELS[_stroke_mode],
before, main.history.snapshot(["terrain"]))
main.call("_on_terrain_stroke_committed") main.call("_on_terrain_stroke_committed")
@ -262,6 +298,10 @@ func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void:
var save_x := _cursor.x var save_x := _cursor.x
var save_z := _cursor.z var save_z := _cursor.z
for i in steps: for i in steps:
if mode == TerrainField.Mode.PAINT:
TerrainField.apply_material_brush(_mats, _res, _sx, _sz,
save_x, save_z, brush_radius, _stroke_material)
continue
var amount: float var amount: float
if mode == TerrainField.Mode.SMOOTH: if mode == TerrainField.Mode.SMOOTH:
amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt, 0.0, 1.0) amount = clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt, 0.0, 1.0)
@ -307,8 +347,24 @@ func _refresh_ring_mesh() -> void:
mat.albedo_color = Color(0.4, 1.0, 0.45, 0.9) mat.albedo_color = Color(0.4, 1.0, 0.45, 0.9)
TerrainField.Mode.LOWER: TerrainField.Mode.LOWER:
mat.albedo_color = Color(1.0, 0.45, 0.3, 0.9) mat.albedo_color = Color(1.0, 0.45, 0.3, 0.9)
TerrainField.Mode.PAINT:
# Ring in der Farbe des gewählten Materials (Karte t_603ec1a8).
var mc: int = clampi(paint_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)
mat.albedo_color = (terrain_mesh_color(mc) as Color)
mat.albedo_color.a = 0.9
_: _:
mat.albedo_color = Color(0.4, 0.7, 1.0, 0.9) mat.albedo_color = Color(0.4, 0.7, 1.0, 0.9)
mat.emission_enabled = true mat.emission_enabled = true
mat.emission = (mat.albedo_color as Color) * 0.5 mat.emission = (mat.albedo_color as Color) * 0.5
_ring.material_override = mat _ring.material_override = mat
## Material-ID → Renderer-Farbe (gleiche Palette wie terrain_mesh.gd).
func terrain_mesh_color(id: int) -> Color:
match id:
1:
return Color(0.78, 0.70, 0.45) # Sand
2:
return Color(0.45, 0.44, 0.42) # Fels
_:
return Color(0.30, 0.45, 0.22) # Gras

View file

@ -1,7 +1,7 @@
extends RefCounted extends RefCounted
class_name SvmapDocument class_name SvmapDocument
## Map-Datenmodell des Editors: ein Dictionary, 1:1 das svmap-Format v1 ## Map-Datenmodell des Editors: ein Dictionary, 1:1 das svmap-Format (v2,
## (docs/map_format.md im Match-Server-Repo). Kein internes Zwischenformat → ## docs/map_format.md im Match-Server-Repo). Kein internes Zwischenformat →
## kein Konverter, Speichern/Laden direkt auf .svmap (README Architektur). ## kein Konverter, Speichern/Laden direkt auf .svmap (README Architektur).
## ##
## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd. ## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd.
@ -24,6 +24,8 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict
var res := DEFAULT_RESOLUTION var res := DEFAULT_RESOLUTION
var zero_heights := PackedByteArray() var zero_heights := PackedByteArray()
zero_heights.resize(res * res * 2) # alles 0 → flaches Terrain auf min_height zero_heights.resize(res * res * 2) # alles 0 → flaches Terrain auf min_height
var zero_materials := PackedByteArray()
zero_materials.resize(res * res) # alles 0 → komplette Map Gras
var half := size / 2.0 var half := size / 2.0
var data: Dictionary = { var data: Dictionary = {
"format_version": SvmapIO.FORMAT_VERSION, "format_version": SvmapIO.FORMAT_VERSION,
@ -43,6 +45,8 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict
"max_height_m": DEFAULT_MAX_HEIGHT, "max_height_m": DEFAULT_MAX_HEIGHT,
"resolution": res, "resolution": res,
"heightmap_r16_b64": Marshalls.raw_to_base64(zero_heights), "heightmap_r16_b64": Marshalls.raw_to_base64(zero_heights),
# v2 (t_603ec1a8): Material-Schicht — neue Maps starten komplett Gras.
"materials_r8_b64": Marshalls.raw_to_base64(zero_materials),
}, },
"objects": [], "objects": [],
"spawn_zones": _default_spawn_zones(size), "spawn_zones": _default_spawn_zones(size),

View file

@ -1,21 +1,32 @@
extends RefCounted extends RefCounted
class_name SvmapIO class_name SvmapIO
## SvmapIO — Editor-seitige Implementierung des `.svmap`-Map-Paket-Formats (v1). ## SvmapIO — Editor-seitige Implementierung des `.svmap`-Map-Paket-Formats (v2).
## ##
## Grundlage: docs/map_format.md im scopedvr-match-server-Repo (Karte t_74976e9f). ## Grundlage: docs/map_format.md im scopedvr-match-server-Repo (Karte t_74976e9f
## + v2-Erweiterung t_603ec1a8: Material-Schicht am Höhenfeld).
## Bewusst EIGENE Implementierung (kein Match-Server-Import): das Format-Doku ## Bewusst EIGENE Implementierung (kein Match-Server-Import): das Format-Doku
## ist der Vertrag. Validierungs-IDs V1..V14 und Kanonisierung/Prüfsumme sind ## ist der Vertrag. Validierungs-IDs V1..V15 und Kanonisierung/Prüfsumme sind
## 1:1 zur Doku übernommen, damit eine vom Editor gespeicherte Map vom ## 1:1 zur Doku übernommen, damit eine vom Editor gespeicherte Map vom
## Match-Server akzeptiert wird (und umgekehrt). ## Match-Server akzeptiert wird (und umgekehrt).
## ##
## Format v2: terrain.materials_r8_b64 (OPTIONAL, Base64 uint8 je Sample,
## 0=Gras/1=Sand/2=Fels). Fehlt es (Legacy-v1), gilt alles Gras. Geladen
## werden v1 UND v2; gespeichert wird immer v2 (mit Schicht).
##
## API (alles static, testbar ohne UI): ## API (alles static, testbar ohne UI):
## parse_and_validate(text, manifest = {}) -> {ok, errors, data} ## parse_and_validate(text, manifest = {}) -> {ok, errors, data}
## load_from_file(path) -> {ok, errors, data} ## load_from_file(path) -> {ok, errors, data}
## save_to_file(path, data) -> {ok, errors} (validiert VOR dem Schreiben) ## save_to_file(path, data) -> {ok, errors} (validiert VOR dem Schreiben)
## validate_map(data, manifest) -> PackedStringArray ## validate_map(data, manifest) -> PackedStringArray
## sign_map(data) -> void (setzt/erneuert Prüfsumme) ## sign_map(data) -> void (setzt/erneuert Prüfsumme)
## decode_materials(terrain) / encode_materials(mats)
const FORMAT_VERSION := 1 ##
const FORMAT_VERSION := 2
## Akzeptierte Format-Versionen (V3): 1 = Legacy ohne Material-Schicht,
## 2 = aktuell. Neu gespeicherte Maps tragen immer FORMAT_VERSION.
const SUPPORTED_FORMAT_VERSIONS: Array[int] = [1, 2]
## Material-IDs (Index = ID). 0 = Gras = Default für Maps ohne Schicht.
const MATERIAL_IDS: Array[String] = ["gras", "sand", "fels"]
const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"] const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"]
# Perf-/Plausibilitäts-Limits — identisch zum Server-Loader (docs/map_format.md §4). # Perf-/Plausibilitäts-Limits — identisch zum Server-Loader (docs/map_format.md §4).
@ -118,10 +129,13 @@ static func sign_map(data: Dictionary) -> void:
static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray: static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray:
var errors := PackedStringArray() var errors := PackedStringArray()
# V3: Versions-Stempel # V3: Versions-Stempel (v1 = Legacy ohne Material-Schicht, v2 = aktuell)
var fv = data.get("format_version", null) var fv = data.get("format_version", null)
if not (fv is int or fv is float) or int(fv) != FORMAT_VERSION: if not (fv is int or fv is float) or not SUPPORTED_FORMAT_VERSIONS.has(int(fv)):
errors.append("V3: format_version muss %d sein (ist %s). Diese Datei ist keine .svmap-Map v1." % [FORMAT_VERSION, str(fv)]) var wanted := ""
for v in SUPPORTED_FORMAT_VERSIONS:
wanted += (" oder " if wanted != "" else "") + str(v)
errors.append("V3: format_version muss %s sein (ist %s). Diese Datei ist keine .svmap-Map." % [wanted, str(fv)])
return errors # Ohne passende Version ist alles Weitere sinnlos. return errors # Ohne passende Version ist alles Weitere sinnlos.
# V4: Pflichtblöcke + Typen (checksum prüft validate_map separat). # V4: Pflichtblöcke + Typen (checksum prüft validate_map separat).
@ -198,6 +212,21 @@ static func _validate_terrain(terrain: Dictionary) -> PackedStringArray:
if raw.size() != res_i * res_i * 2: if raw.size() != res_i * res_i * 2:
errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 2)." % [ errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 2)." % [
raw.size(), res_i * res_i * 2]) raw.size(), res_i * res_i * 2])
# V15: Material-Schicht (v2, optional — fehlt sie, gilt alles Gras).
var mats = terrain.get("materials_r8_b64", "")
if mats is String and (mats as String) != "":
var mraw := Marshalls.base64_to_raw(mats as String)
if mraw.size() != res_i * res_i:
errors.append("V15: materials_r8_b64 dekodiert zu %d Bytes, erwartet %d (= resolution²)." % [
mraw.size(), res_i * res_i])
else:
for v in mraw:
if int(v) >= MATERIAL_IDS.size():
errors.append("V15: materials_r8_b64 enthält unbekannte Material-ID %d (erlaubt: 0–%d)." % [
int(v), MATERIAL_IDS.size() - 1])
break
elif not (mats is String):
errors.append("V15: materials_r8_b64 muss ein Base64-String sein (ist %s)." % str(mats))
return errors return errors
@ -380,3 +409,29 @@ static func decode_heights(terrain: Dictionary) -> PackedFloat32Array:
var v: int = raw.decode_u16(i * 2) var v: int = raw.decode_u16(i * 2)
out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin) out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin)
return out return out
## Material-Schicht dekodieren (v2): uint8 je Sample, 0=Gras/1=Sand/2=Fels.
## Fehlt das Feld (Legacy-v1) → alles Gras (Default). Identische Indexierung
## wie das Höhenfeld — TerrainField.apply_material_brush arbeitet darauf.
static func decode_materials(terrain: Dictionary) -> PackedByteArray:
var res := int(terrain.get("resolution", 0))
var out := PackedByteArray()
out.resize(maxi(res * res, 0)) # PackedByteArray füllt mit 0 = Gras
var mats = terrain.get("materials_r8_b64", "")
if mats is String and (mats as String) != "":
var mraw := Marshalls.base64_to_raw(mats as String)
var n := res * res
for i in n:
if i < mraw.size() and int(mraw[i]) < MATERIAL_IDS.size():
out[i] = mraw[i]
return out
## Material-Schicht kodieren: uint8 je Sample (Werte 0..MATERIAL_IDS.size()-1).
static func encode_materials(mats: PackedByteArray) -> String:
var bytes := PackedByteArray()
bytes.resize(mats.size())
for i in mats.size():
bytes[i] = clampi(mats[i], 0, MATERIAL_IDS.size() - 1)
return Marshalls.raw_to_base64(bytes)

View file

@ -10,7 +10,7 @@ class_name TerrainField
## ##
## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik. ## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik.
enum Mode { RAISE, LOWER, SMOOTH } enum Mode { RAISE, LOWER, SMOOTH, PAINT }
## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber ## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber
## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim ## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim
@ -47,6 +47,11 @@ static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size
static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float, static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
hmin: float, hmax: float, cx: float, cz: float, radius: float, hmin: float, hmax: float, cx: float, cz: float, radius: float,
mode: int, amount: float) -> bool: mode: int, amount: float) -> bool:
if mode == Mode.PAINT:
# PAINT läuft über apply_material_brush (Material-Schicht, Karte
# t_603ec1a8) — Höhe bleibt unangetastet. Hier ohne Material-Argument
# aufrufbar, aber bewusst NEUTRAL: kein Pattern, keine Höhe.
return false
if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res: if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res:
return false return false
var cell_x := size_x / float(res - 1) var cell_x := size_x / float(res - 1)
@ -86,6 +91,40 @@ static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, si
return changed return changed
## Material-Pinsel (Karte t_603ec1a8): setzt die Material-ID aller Zellen im
## Kreis (d < radius) auf material_id — Malen statt Formen, Höhe bleibt unangetastet.
## Kante bewusst CRISP (d < radius): Malen fühlt sich wie ein Malprogramm an,
## die Vertex-Interpolation des Meshes glättet die Grenze optisch.
## mats = dekodierte Material-Schicht (PackedByteArray, res² Einträge).
static func apply_material_brush(mats: PackedByteArray, res: int, size_x: float, size_z: float,
cx: float, cz: float, radius: float, material_id: int) -> bool:
if res < 2 or radius <= 0.0 or mats.size() < res * res:
return false
var gx := (cx / size_x + 0.5) * float(res - 1)
var gz := (cz / size_z + 0.5) * float(res - 1)
var r_cells_x := radius / (size_x / float(res - 1))
var r_cells_z := radius / (size_z / float(res - 1))
var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0)
var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1)
var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0)
var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1)
var changed := false
var z := z0
while z <= z1:
var x := x0
while x <= x1:
var dx := (float(x) - gx) * (size_x / float(res - 1))
var dz := (float(z) - gz) * (size_z / float(res - 1))
if dx * dx + dz * dz < radius * radius:
var idx := z * res + x
if mats[idx] != material_id:
mats[idx] = material_id
changed = true
x += 1
z += 1
return changed
## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird ## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird
## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung). ## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung).
static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float, static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float,

View file

@ -73,6 +73,12 @@ var terrain_panel: VBoxContainer
var terrain_mode_raise: Button var terrain_mode_raise: Button
var terrain_mode_lower: Button var terrain_mode_lower: Button
var terrain_mode_smooth: Button var terrain_mode_smooth: Button
var terrain_mode_paint: Button
var terrain_palette: VBoxContainer
var terrain_mat_buttons: Array = [] # Gras/Sand/Fels (ButtonGroup)
var terrain_size_btn_small: Button
var terrain_size_btn_medium: Button
var terrain_size_btn_large: Button
var terrain_radius_slider: HSlider var terrain_radius_slider: HSlider
var terrain_radius_label: Label var terrain_radius_label: Label
var terrain_strength_slider: HSlider var terrain_strength_slider: HSlider
@ -606,13 +612,54 @@ func _build_right_panel() -> PanelContainer:
terrain_mode_raise = _terrain_mode_button("Heben ⬆", mode_group) terrain_mode_raise = _terrain_mode_button("Heben ⬆", mode_group)
terrain_mode_lower = _terrain_mode_button("Senken ⬇", mode_group) terrain_mode_lower = _terrain_mode_button("Senken ⬇", mode_group)
terrain_mode_smooth = _terrain_mode_button("Glätten ≈", mode_group) terrain_mode_smooth = _terrain_mode_button("Glätten ≈", mode_group)
terrain_mode_paint = _terrain_mode_button("Malen 🖌", mode_group)
terrain_mode_raise.button_pressed = true terrain_mode_raise.button_pressed = true
terrain_mode_raise.toggled.connect(_on_terrain_mode_changed) terrain_mode_raise.toggled.connect(_on_terrain_mode_changed)
terrain_mode_lower.toggled.connect(_on_terrain_mode_changed) terrain_mode_lower.toggled.connect(_on_terrain_mode_changed)
terrain_mode_smooth.toggled.connect(_on_terrain_mode_changed) terrain_mode_smooth.toggled.connect(_on_terrain_mode_changed)
terrain_mode_paint.toggled.connect(_on_terrain_mode_changed)
mode_row.add_child(terrain_mode_raise) mode_row.add_child(terrain_mode_raise)
mode_row.add_child(terrain_mode_lower) mode_row.add_child(terrain_mode_lower)
mode_row.add_child(terrain_mode_smooth) mode_row.add_child(terrain_mode_smooth)
mode_row.add_child(terrain_mode_paint)
# Material-Palette (Karte t_603ec1a8): nur sichtbar im Malen-Modus.
terrain_palette = VBoxContainer.new()
terrain_palette.add_theme_constant_override("separation", 4)
terrain_palette.visible = false
terrain_panel.add_child(terrain_palette)
terrain_palette.add_child(_field_label("Material:"))
var mat_row := HBoxContainer.new()
mat_row.add_theme_constant_override("separation", 6)
terrain_palette.add_child(mat_row)
var mat_group := ButtonGroup.new()
var mat_labels := ["🌿 Gras", "🏖 Sand", "🪨 Fels"]
for mat_id in SvmapIO.MATERIAL_IDS.size():
var mat_btn := Button.new()
mat_btn.text = str(mat_labels[mat_id])
mat_btn.custom_minimum_size = Vector2(0, 40)
mat_btn.toggle_mode = true
mat_btn.button_group = mat_group
mat_btn.tooltip_text = "Malen: %s" % str(SvmapIO.MATERIAL_IDS[mat_id])
if mat_id == 0:
mat_btn.button_pressed = true
mat_btn.toggled.connect(_on_terrain_material_changed.bind(mat_id))
terrain_mat_buttons.append(mat_btn)
mat_row.add_child(mat_btn)
# Pinselgrößen-Presets (Karte t_603ec1a8): drei große Buttons + freier Slider.
terrain_panel.add_child(_field_label("Pinselgröße:"))
var size_row := HBoxContainer.new()
size_row.add_theme_constant_override("separation", 6)
terrain_panel.add_child(size_row)
var size_group := ButtonGroup.new()
terrain_size_btn_small = _terrain_size_button("Klein\n(4 m)", size_group, 4.0)
terrain_size_btn_medium = _terrain_size_button("Mittel\n(12 m)", size_group, 12.0)
terrain_size_btn_large = _terrain_size_button("Groß\n(32 m)", size_group, 32.0)
terrain_size_btn_medium.button_pressed = true
size_row.add_child(terrain_size_btn_small)
size_row.add_child(terrain_size_btn_medium)
size_row.add_child(terrain_size_btn_large)
terrain_radius_label = _field_label("Pinsel-Größe: 12 m") terrain_radius_label = _field_label("Pinsel-Größe: 12 m")
terrain_panel.add_child(terrain_radius_label) terrain_panel.add_child(terrain_radius_label)
@ -637,7 +684,7 @@ func _build_right_panel() -> PanelContainer:
terrain_panel.add_child(terrain_strength_slider) terrain_panel.add_child(terrain_strength_slider)
terrain_panel.add_child(_muted_label( terrain_panel.add_child(_muted_label(
"Linksklick-Ziehen formt den Boden, Rechtsklick-Ziehen macht das Gegenteil. " "Linksklick-Ziehen formt bzw. malt (je nach Werkzeug), Rechtsklick-Ziehen macht das Gegenteil — im Malen-Modus malt Rechtsklick Gras zurück. "
+ "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Terrain-Modus.")) + "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Terrain-Modus."))
# --- Spawn-Zonen (Karte t_c4162c5d): Live-Status IMMER sichtbar --- # --- Spawn-Zonen (Karte t_c4162c5d): Live-Status IMMER sichtbar ---
@ -1105,10 +1152,10 @@ func _enter_terrain_mode() -> void:
return return
tool_mode = "terrain" tool_mode = "terrain"
terrain_panel.visible = true terrain_panel.visible = true
help_label.text = "Linksklick-Ziehen: Boden formen · Rechtsklick-Ziehen: Gegenteil ·\nPinsel-Größe/Stärke rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg" help_label.text = "Linksklick-Ziehen: Boden formen oder malen · Rechtsklick-Ziehen: Gegenteil / Gras zurück ·\nPinselgröße & Material rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg"
terrain_edit_btn.button_pressed = true terrain_edit_btn.button_pressed = true
terrain_tool.call("set_brush_mode", _selected_terrain_mode()) terrain_tool.call("set_brush_mode", _selected_terrain_mode())
_set_status("Terrain-Modus an: Ziehe mit LINKSKLICK über die Karte, um den Boden zu formen. Die Regler rechts stellen Pinsel und Stärke ein.") _set_status("Terrain-Modus an: LINKSKLICK-Ziehen formt den Boden, im Malen-Modus färbt er das Gelände (Gras/Sand/Fels). Regler rechts stellen Pinsel und Stärke ein.")
func _leave_terrain_mode() -> void: func _leave_terrain_mode() -> void:
@ -1152,6 +1199,8 @@ func _selected_terrain_mode() -> int:
return TerrainField.Mode.LOWER return TerrainField.Mode.LOWER
if terrain_mode_smooth.button_pressed: if terrain_mode_smooth.button_pressed:
return TerrainField.Mode.SMOOTH return TerrainField.Mode.SMOOTH
if terrain_mode_paint.button_pressed:
return TerrainField.Mode.PAINT
return TerrainField.Mode.RAISE return TerrainField.Mode.RAISE
@ -1159,12 +1208,38 @@ func _sync_terrain_button() -> void:
terrain_edit_btn.button_pressed = tool_mode == "terrain" terrain_edit_btn.button_pressed = tool_mode == "terrain"
## Mode-Buttons (ButtonGroup) → Pinsel-Modus. ## Mode-Buttons (ButtonGroup) → Pinsel-Modus; Palette nur im Malen-Modus.
func _on_terrain_mode_changed(_pressed: bool) -> void: func _on_terrain_mode_changed(_pressed: bool) -> void:
if terrain_palette != null:
terrain_palette.visible = _selected_terrain_mode() == TerrainField.Mode.PAINT
if terrain_tool != null: if terrain_tool != null:
terrain_tool.call("set_brush_mode", _selected_terrain_mode()) terrain_tool.call("set_brush_mode", _selected_terrain_mode())
## Material-Palette (Karte t_603ec1a8): gewähltes Material an den Pinsel.
func _on_terrain_material_changed(pressed: bool, mat_id: int) -> void:
if pressed and terrain_tool != null:
terrain_tool.call("set_paint_material", mat_id)
## Pinselgrößen-Presets (Karte t_603ec1a8): setzen den Radius-Slider
## (der Label-Text + Ring-Optik hängen am value_changed-Signal).
func _terrain_size_button(text: String, group: ButtonGroup, radius_m: float) -> Button:
var btn := Button.new()
btn.text = text
btn.toggle_mode = true
btn.button_group = group
btn.custom_minimum_size = Vector2(0, 48)
btn.tooltip_text = "Pinselradius %d m" % int(radius_m)
btn.toggled.connect(_on_terrain_size_preset.bind(radius_m))
return btn
func _on_terrain_size_preset(pressed: bool, radius_m: float) -> void:
if pressed:
terrain_radius_slider.value = radius_m
func _on_terrain_radius_changed(value: float) -> void: func _on_terrain_radius_changed(value: float) -> void:
terrain_radius_label.text = "Pinsel-Größe: %d m" % int(value) terrain_radius_label.text = "Pinsel-Größe: %d m" % int(value)
if terrain_tool != null: if terrain_tool != null:

View file

@ -77,9 +77,11 @@ func _build_terrain(data: Dictionary) -> void:
## Live-Update während eines Pinsel-Strichs (aus terrain_tool.gd): nur die ## Live-Update während eines Pinsel-Strichs (aus terrain_tool.gd): nur die
## Vertices werden neu geschrieben — Grid/Objekte/Marker bleiben unangetastet. ## Vertices werden neu geschrieben — Grid/Objekte/Marker bleiben unangetastet.
func update_terrain(heights: PackedFloat32Array) -> void: ## mats (optional) = Material-Schicht (Karte t_603ec1a8, Malen-Modus) —
## leer = Farben nur aus der Höhe (Legacy).
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void:
if terrain_view != null and is_instance_valid(terrain_view): if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("update_heights", heights) terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats)
## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und ## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und

View file

@ -8,11 +8,20 @@ extends Node3D
## schreiben (SOFT-war das Mesh live — kein Rebuild, ## schreiben (SOFT-war das Mesh live — kein Rebuild,
## kein Flackern, res 129 ≈ 16,6k Punkte = flott). ## kein Flackern, res 129 ≈ 16,6k Punkte = flott).
## ##
## Höhen-Farbverlauf über Vertex-Farben (dunkles Grün im Tal → helles Grün auf ## Farben (Karte t_603ec1a8): Vertex-Farben aus der Material-Schicht
## dem Hügel): Sly sieht Steigungen sofort, ohne Licht-Tricks. ## (terrain.materials_r8_b64, v2) — Gras/Sand/Fels, plus Höhen-Faktor
## (dunkler im Tal, heller oben), damit Steigungen weiterhin ablesbar sind.
## Ohne Material-Schicht (Legacy-v1) gilt alles Gras = Default. KEIN Custom-
## Shader: vertex_color_use_as_albedo bleibt die einzige Material-Pipeline
## (Quest-3-Perf-Vertrag, keine Shader-Varianten).
const COLOR_LOW := Color(0.16, 0.26, 0.16) const MATERIAL_COLORS: Array[Color] = [
const COLOR_HIGH := Color(0.45, 0.62, 0.34) Color(0.30, 0.45, 0.22), # 0 = Gras
Color(0.78, 0.70, 0.45), # 1 = Sand
Color(0.45, 0.44, 0.42), # 2 = Fels
]
const SHADING_LOW := 0.72 # Tal-Faktor (Material-Farbe × Faktor)
const SHADING_HIGH := 1.15 # Hügel-Faktor
var _mi: MeshInstance3D var _mi: MeshInstance3D
var _res := 0 var _res := 0
@ -30,7 +39,7 @@ func _ready() -> void:
add_child(_mi) add_child(_mi)
## Kompletter Neuaufbau aus dem svmap-terrain-Block. ## Kompletter Neuaufbau aus dem svmap-terrain-Block (inkl. Material-Schicht).
func rebuild_from_doc(data: Dictionary) -> void: func rebuild_from_doc(data: Dictionary) -> void:
var terrain: Dictionary = data.get("terrain", {}) var terrain: Dictionary = data.get("terrain", {})
if terrain.is_empty(): if terrain.is_empty():
@ -39,14 +48,17 @@ func rebuild_from_doc(data: Dictionary) -> void:
_mi.mesh = null _mi.mesh = null
return return
var heights := SvmapIO.decode_heights(terrain) var heights := SvmapIO.decode_heights(terrain)
var mats := SvmapIO.decode_materials(terrain)
update_heights(heights, int(terrain.get("resolution", 0)), update_heights(heights, int(terrain.get("resolution", 0)),
float(terrain.get("size_x_m", 1.0)), float(terrain.get("size_z_m", 1.0))) float(terrain.get("size_x_m", 1.0)), float(terrain.get("size_z_m", 1.0)), mats)
## Mesh aus einem (neuen) Höhenfeld bauen — auch live während des Strichs. ## Mesh aus einem (neuen) Höhenfeld bauen — auch live während des Strichs.
## res/size 0 = zuletzt verwendete Werte weiterverwenden (Live-Pfad). ## res/size 0 = zuletzt verwendete Werte weiterverwenden (Live-Pfad).
## mats = Material-Schicht (res² Bytes, 0=Gras/1=Sand/2=Fels); leer/zu klein
## → alles Gras (Legacy-Verhalten).
func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0, func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0,
size_z: float = 0.0) -> void: size_z: float = 0.0, mats: PackedByteArray = PackedByteArray()) -> void:
if res < 2: if res < 2:
res = _res res = _res
if size_x <= 0.0: if size_x <= 0.0:
@ -94,7 +106,12 @@ func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0
var hz_u := heights[mini(z + 1, res - 1) * res + x] var hz_u := heights[mini(z + 1, res - 1) * res + x]
normals[idx] = Vector3(hx_l - hx_r, 2.0 * cell_x, hz_d - hz_u).normalized() normals[idx] = Vector3(hx_l - hx_r, 2.0 * cell_x, hz_d - hz_u).normalized()
var t := (heights[idx] - hmin) / span var t := (heights[idx] - hmin) / span
colors[idx] = COLOR_LOW.lerp(COLOR_HIGH, t) # Material-Farbe × Höhen-Faktor (Steigungen bleiben ablesbar).
var mat_id := 0
if idx < mats.size():
mat_id = clampi(mats[idx], 0, MATERIAL_COLORS.size() - 1)
var shade := lerpf(SHADING_LOW, SHADING_HIGH, t)
colors[idx] = (MATERIAL_COLORS[mat_id] as Color) * shade
x += 1 x += 1
z += 1 z += 1

View file

@ -46,7 +46,7 @@ func _errors_contain(errors: Array, needle: String) -> bool:
func _run_tests() -> void: func _run_tests() -> void:
print("== Neue Map: default-Dokument ==") print("== Neue Map: default-Dokument ==")
var doc := SvmapDocument.create_new("Testmap", 1024.0, "abend") var doc := SvmapDocument.create_new("Testmap", 1024.0, "abend")
_check(int(doc["format_version"]) == 1, "format_version 1") _check(int(doc["format_version"]) == 2, "format_version 2 (Material-Schicht-Format)")
_check(str(doc["info"]["name"]) == "Testmap", "Name übernommen") _check(str(doc["info"]["name"]) == "Testmap", "Name übernommen")
_check(str(doc["weather"]) == "abend", "Wetter übernommen") _check(str(doc["weather"]) == "abend", "Wetter übernommen")
_check((doc["objects"] as Array).is_empty(), "leere Objektliste") _check((doc["objects"] as Array).is_empty(), "leere Objektliste")
@ -97,7 +97,7 @@ func _run_tests() -> void:
res = SvmapIO.parse_and_validate("{{{definitiv kaputt") res = SvmapIO.parse_and_validate("{{{definitiv kaputt")
_check(not res["ok"] and _errors_contain(res["errors"], "V2"), "kaputtes JSON → V2") _check(not res["ok"] and _errors_contain(res["errors"], "V2"), "kaputtes JSON → V2")
res = SvmapIO.parse_and_validate('{"format_version": 2}') res = SvmapIO.parse_and_validate('{"format_version": 3}')
_check(not res["ok"] and _errors_contain(res["errors"], "V3"), "falsche Version → V3") _check(not res["ok"] and _errors_contain(res["errors"], "V3"), "falsche Version → V3")
var bad: Dictionary = _deep_copy(doc2) var bad: Dictionary = _deep_copy(doc2)

View file

@ -36,6 +36,9 @@ func _run_tests() -> void:
_test_range_expansion() _test_range_expansion()
_test_svmap_roundtrip() _test_svmap_roundtrip()
_test_resolution_limit() _test_resolution_limit()
_test_material_brush()
_test_paint_undo_redo()
_test_material_format()
## Decode → Encode bleibt innerhalb eines Quantisierungsschritts (R16). ## Decode → Encode bleibt innerhalb eines Quantisierungsschritts (R16).
@ -207,6 +210,143 @@ func _test_resolution_limit() -> void:
_check(SvmapIO.MAX_TERRAIN_RESOLUTION == 513, "Format-Limit 513 unverändert (Interop)") _check(SvmapIO.MAX_TERRAIN_RESOLUTION == 513, "Format-Limit 513 unverändert (Interop)")
## Material-Pinsel (Karte t_603ec1a8): Kreisfläche malt die Material-ID,
## außerhalb bleibt, Höhenfeld bleibt unangetastet, Gras zurückmalen räumt auf.
func _test_material_brush() -> void:
print("Testgruppe: Material-Pinsel (Malen)")
var res := 33
var size := 320.0
var mats := PackedByteArray()
mats.resize(res * res)
var heights := PackedFloat32Array()
heights.resize(res * res)
for i in heights.size():
heights[i] = 7.0 + float(i % 5)
var changed := TerrainField.apply_material_brush(mats, res, size, size,
0.0, 0.0, 40.0, 1)
_check(changed, "Malen: apply meldet Änderung")
_check(mats[16 * res + 16] == 1, "Malen: Zentrum = Sand (1)")
_check(mats[2 * res + 16] == 0, "Malen: 140 m weg — außerhalb des Radius unverändert")
# Höhenfeld bleibt vom Material-Pinsel komplett unberührt.
var heights_before := heights.duplicate()
TerrainField.apply_material_brush(mats, res, size, size, 0.0, 0.0, 40.0, 2)
var same := true
for i in heights.size():
if not is_equal_approx(heights[i], heights_before[i]):
same = false
break
_check(same, "Malen: Höhenfeld bleibt unangetastet (Malen statt Formen)")
# Radierer-Semantik: mit 0 (Gras) übermalt ist die Fläche wieder leer.
TerrainField.apply_material_brush(mats, res, size, size, 0.0, 0.0, 40.0, 0)
var grass := true
for m in mats:
if m != 0:
grass = false
break
_check(grass, "Malen: Gras zurückmalen räumt die Fläche leer")
# Ungültige Eingaben: zu kleines Grid wird abgelehnt; PAINT über
# apply_brush (Höhen-API) ist bewusst neutral.
_check(not TerrainField.apply_material_brush(mats, 1, size, size,
0.0, 0.0, 10.0, 1), "Malen: res < 2 wird abgelehnt")
_check(not TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
0.0, 0.0, 10.0, TerrainField.Mode.PAINT, 5.0),
"apply_brush mit PAINT ist neutral (keine Höhe, kein Crash)")
## Mal-Stroke = EIN Undo-Eintrag (Chef-Kriterium wie beim Formen): Sand-Streifen
## über mehrere Anwendungen, Strg+Z → komplett Gras, Strg+Shift+Z → byte-identisch.
func _test_paint_undo_redo() -> void:
print("Testgruppe: Mal-Stroke-Undo (ganzer Strich)")
var doc := SvmapDocument.create_new("Paint", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var terrain: Dictionary = doc["terrain"]
var mats := SvmapIO.decode_materials(terrain)
_check(mats.size() == 129 * 129, "Neue Map: Material-Schicht 129² (alles Gras)")
var before: Dictionary = hist.snapshot(["terrain"])
for step in 6: # Streifen von links nach rechts, wie ein Zieh-Stroke
TerrainField.apply_material_brush(mats, 129, 1024.0, 1024.0,
-300.0 + float(step) * 60.0, 0.0, 24.0, 1)
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
hist.commit("Boden gemalt (sand)", before, hist.snapshot(["terrain"]))
_check(hist.entries.size() == 1, "6 Mal-Schritte → EIN History-Eintrag")
var painted := SvmapIO.decode_materials(terrain)
_check(painted[64 * 129 + 64] == 1, "Stroke-Mitte = Sand")
_check(painted[10 * 129 + 10] == 0, "Außerhalb des Streifens bleibt Gras")
var painted_b64 := str(terrain["materials_r8_b64"])
var desc := hist.undo()
_check(desc == "Boden gemalt (sand)", "Undo-Desc nennt den Mal-Stroke")
_check(str(terrain["materials_r8_b64"]) != painted_b64, "Undo: Material-Schicht geändert")
var restored := SvmapIO.decode_materials(terrain)
var all_grass := true
for m in restored:
if m != 0:
all_grass = false
break
_check(all_grass, "Undo: komplett Gras (Zustand vor dem Strich)")
desc = hist.redo()
_check(desc == "Boden gemalt (sand)", "Redo-Desc stimmt")
_check(str(terrain["materials_r8_b64"]) == painted_b64,
"Redo: byte-identische Material-Schicht zurück")
## Format v2/V15: Material-Schicht speichert/lädt, falsche Länge + unbekannte
## ID werden abgelehnt, Legacy-v1 OHNE Schicht lädt mit Default Gras.
func _test_material_format() -> void:
print("Testgruppe: Material-Format (v2/V15 + Legacy)")
var doc := SvmapDocument.create_new("Format", 1024.0, "mittag")
_check(int(doc["format_version"]) == 2, "Neue Maps tragen format_version 2")
var path := "/tmp/scopedvr_test_terrain_material.svmap"
var save := SvmapIO.save_to_file(path, doc)
_check(save["ok"], "v2-Map (mit Schicht) speichert ohne Fehler")
var load := SvmapIO.load_from_file(path)
_check(load["ok"], "v2-Map lädt sauber zurück (inkl. Prüfsumme)")
var mats := SvmapIO.decode_materials(load["data"]["terrain"])
_check(mats.size() == 129 * 129, "v2: Material-Schicht komplett dekodiert")
# V15: falsche Länge → Ablehnung.
var bad: Dictionary = doc.duplicate(true)
(bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(
PackedByteArray([0, 1, 2]))
_check(_has_err(SvmapIO.validate_map(bad, {}), "V15"),
"V15: falsche Material-Länge abgelehnt")
# V15: unbekannte Material-ID → Ablehnung.
bad = doc.duplicate(true)
var mraw := PackedByteArray()
mraw.resize(129 * 129)
mraw[5000] = 3
(bad["terrain"] as Dictionary)["materials_r8_b64"] = Marshalls.raw_to_base64(mraw)
_check(_has_err(SvmapIO.validate_map(bad, {}), "V15"),
"V15: unbekannte Material-ID abgelehnt")
# Legacy-v1: OHNE Material-Schicht lädt und gilt als alles Gras.
var legacy: Dictionary = doc.duplicate(true)
legacy["format_version"] = 1
(legacy["terrain"] as Dictionary).erase("materials_r8_b64")
SvmapIO.sign_map(legacy)
var legacy_path := "/tmp/scopedvr_test_terrain_legacy_v1.svmap"
var lsave := SvmapIO.save_to_file(legacy_path, legacy)
_check(lsave["ok"], "Legacy-v1 (ohne Schicht) bleibt speicherbar")
var lload := SvmapIO.load_from_file(legacy_path)
_check(lload["ok"], "Legacy-v1 lädt sauber (V3 akzeptiert 1 und 2)")
var lmats := SvmapIO.decode_materials(lload["data"]["terrain"])
var l_grass := true
for m in lmats:
if m != 0:
l_grass = false
break
_check(l_grass and lmats.size() == 129 * 129,
"Legacy-v1: fehlende Schicht → Default Gras überall")
func _has_err(errors: PackedStringArray, needle: String) -> bool:
for e in errors:
if str(e).contains(needle):
return true
return false
func _variance_block(a: PackedFloat32Array, res: int, x0: int, x1: int, z0: int, z1: int) -> float: func _variance_block(a: PackedFloat32Array, res: int, x0: int, x1: int, z0: int, z1: int) -> float:
var s := 0.0 var s := 0.0
var count := 0 var count := 0