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:
parent
423b223454
commit
23ede45efd
9 changed files with 427 additions and 39 deletions
|
|
@ -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,6 +156,10 @@ 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).
|
||||||
|
# Im Malen-Modus malt Rechtsklick Gras zurück (Radierer-Semantik).
|
||||||
|
if brush_mode == TerrainField.Mode.PAINT:
|
||||||
|
_begin_stroke(TerrainField.Mode.PAINT, 0)
|
||||||
|
else:
|
||||||
var opposite: int = TerrainField.Mode.LOWER
|
var opposite: int = TerrainField.Mode.LOWER
|
||||||
if brush_mode == TerrainField.Mode.LOWER:
|
if brush_mode == TerrainField.Mode.LOWER:
|
||||||
opposite = TerrainField.Mode.RAISE
|
opposite = TerrainField.Mode.RAISE
|
||||||
|
|
@ -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,10 +273,15 @@ 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", {})
|
||||||
|
if _stroke_mode == TerrainField.Mode.PAINT:
|
||||||
|
terrain["materials_r8_b64"] = SvmapIO.encode_materials(_mats)
|
||||||
|
main.history.commit("Boden gemalt (%s)" % SvmapIO.MATERIAL_IDS[clampi(_stroke_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)],
|
||||||
|
before, main.history.snapshot(["terrain"]))
|
||||||
|
else:
|
||||||
terrain["min_height_m"] = _hmin
|
terrain["min_height_m"] = _hmin
|
||||||
terrain["max_height_m"] = _hmax
|
terrain["max_height_m"] = _hmax
|
||||||
terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax)
|
terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax)
|
||||||
main.history.commit("Terrain geformt (%s)" % MODE_LABELS[_stroke_mode],
|
main.history.commit("Boden geformt (%s)" % MODE_LABELS[_stroke_mode],
|
||||||
before, main.history.snapshot(["terrain"]))
|
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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue