From 23ede45efdd20e32fc87636d9b73c5b5447dfc22 Mon Sep 17 00:00:00 2001 From: Hermes godot-vr Date: Thu, 15 Oct 2026 15:41:53 +0000 Subject: [PATCH] =?UTF-8?q?M2-Karte=20t=5F603ec1a8:=20Terrain-Texturen=20(?= =?UTF-8?q?Gras/Sand/Fels)=20+=20Pinselgroessen.=20svmap=20v2:=20optionale?= =?UTF-8?q?=20Material-Schicht=20materials=5Fr8=5Fb64=20(uint8/res=C2=B2,?= =?UTF-8?q?=200=3DGras/1=3DSand/2=3DFels),=20V15-Validierung=20(Laenge+ID-?= =?UTF-8?q?Range),=20Legacy-v1=20laedt=20mit=20Default=20Gras,=20gespeiche?= =?UTF-8?q?rt=20wird=20immer=20v2.=20TerrainField:=20Mode.PAINT=20+=20appl?= =?UTF-8?q?y=5Fmaterial=5Fbrush=20(Kreis,=20crisp,=20Hoehe=20unangetastet)?= =?UTF-8?q?.=20terrain=5Ftool:=204.=20Pinsel-Modus=20Malen=20(Palette=20Gr?= =?UTF-8?q?as/Sand/Fels,=20Rechtsklick=20malt=20Gras=20zurueck),=20Ring=20?= =?UTF-8?q?in=20Materialfarbe;=20ganzer=20Strich=20=3D=20EIN=20ActionHisto?= =?UTF-8?q?ry-Eintrag=20(terrain-Block-Snapshot=20deckt=20Hoehe+Material?= =?UTF-8?q?=20ab).=20main.gd:=20Palette-Panel=20(nur=20im=20Malen-Modus),?= =?UTF-8?q?=20drei=20Pinselgroessen-Presets=20Klein(4m)/Mittel(12m)/Gross(?= =?UTF-8?q?32m)=20+=20freier=20Slider,=20deutsche=20Labels.=20terrain=5Fme?= =?UTF-8?q?sh:=20Vertex-Farben=20aus=20Material-Schicht=20x=20Hoehe-Shadin?= =?UTF-8?q?g=20(KEIN=20Custom-Shader,=20Quest-3-Perf-Vertrag=20=E2=80=94?= =?UTF-8?q?=20Entscheidung=20gegen=20Splattmap=20in=20DECISIONS.md).=20Tes?= =?UTF-8?q?ts:=20test=5Fterrain=20+3=20Gruppen=20(Material-Pinsel,=20Mal-S?= =?UTF-8?q?troke-Undo=20byte-identisch,=20v2/V15+Legacy)=20->=2053=20Check?= =?UTF-8?q?s;=20Suite=20231=20Checks=20gruen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/terrain_tool.gd | 80 +++++++++++++++--- src/svmap/map_document.gd | 8 +- src/svmap/svmap_io.gd | 71 ++++++++++++++-- src/svmap/terrain_field.gd | 41 ++++++++- src/ui/main.gd | 83 +++++++++++++++++- src/viewport/map_view_builder.gd | 6 +- src/viewport/terrain_mesh.gd | 33 ++++++-- tests/test_editor_svmap.gd | 4 +- tests/test_terrain.gd | 140 +++++++++++++++++++++++++++++++ 9 files changed, 427 insertions(+), 39 deletions(-) diff --git a/src/editor/terrain_tool.gd b/src/editor/terrain_tool.gd index 5fe2329..c47e33b 100644 --- a/src/editor/terrain_tool.gd +++ b/src/editor/terrain_tool.gd @@ -17,7 +17,7 @@ extends Node3D ## 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 MODE_LABELS := ["Heben", "Senken", "Glätten"] +const MODE_LABELS := ["Heben", "Senken", "Glätten", "Malen"] var main # Hauptfenster (script-erweitert, Variant) var camera: Camera3D @@ -28,11 +28,14 @@ var active := false var brush_mode: int = TerrainField.Mode.RAISE var brush_radius := 12.0 # Meter 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 var _stroking := false var _stroke_mode: int = TerrainField.Mode.RAISE +var _stroke_material: int = 0 var _heights := PackedFloat32Array() +var _mats := PackedByteArray() # Material-Schicht (res²) während des Mal-Strichs var _res := 0 var _sx := 0.0 var _sz := 0.0 @@ -129,6 +132,11 @@ func set_strength(mps: float) -> void: 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 --- 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: # Rechtsklick = das Gegenteil zum Modell: bei Heben senken, # bei Senken heben, bei Glätten senken (kein Umschalten nötig). - var opposite: int = TerrainField.Mode.LOWER - if brush_mode == TerrainField.Mode.LOWER: - opposite = TerrainField.Mode.RAISE - _begin_stroke(opposite) + # 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 + if brush_mode == TerrainField.Mode.LOWER: + opposite = TerrainField.Mode.RAISE + _begin_stroke(opposite) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_RIGHT and not mb.pressed: if _stroking: @@ -184,7 +196,7 @@ func _height_at_stroke(x: float, z: float) -> float: # ----------------------------------------------------------------- Stroke --- -func _begin_stroke(mode: int) -> void: +func _begin_stroke(mode: int, material_id: int = -1) -> void: if _stroking: return if not _cursor_valid: @@ -193,14 +205,33 @@ func _begin_stroke(mode: int) -> void: true) return _stroke_mode = mode + _stroke_material = paint_material if material_id < 0 else material_id _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() - 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: if not _stroking or not _cursor_valid: 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 if _stroke_mode == TerrainField.Mode.SMOOTH: 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. var before: Dictionary = main.history.snapshot(["terrain"]) var terrain: Dictionary = main.doc.get("terrain", {}) - terrain["min_height_m"] = _hmin - terrain["max_height_m"] = _hmax - terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax) - main.history.commit("Terrain geformt (%s)" % MODE_LABELS[_stroke_mode], - before, main.history.snapshot(["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["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") @@ -262,6 +298,10 @@ func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void: var save_x := _cursor.x var save_z := _cursor.z 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 if mode == TerrainField.Mode.SMOOTH: 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) TerrainField.Mode.LOWER: 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.emission_enabled = true mat.emission = (mat.albedo_color as Color) * 0.5 _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 diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 52c1043..7fdf1ca 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -1,7 +1,7 @@ extends RefCounted class_name SvmapDocument -## Map-Datenmodell des Editors: ein Dictionary, 1:1 das svmap-Format v1 -## (docs/map_format.md im Match-Server-Repo). Kein internes Zwischenformat → +## Map-Datenmodell des Editors: ein Dictionary, 1:1 das svmap-Format (v2, +## docs/map_format.md im Match-Server-Repo). Kein internes Zwischenformat → ## kein Konverter, Speichern/Laden direkt auf .svmap (README Architektur). ## ## 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 zero_heights := PackedByteArray() 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 data: Dictionary = { "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, "resolution": res, "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": [], "spawn_zones": _default_spawn_zones(size), diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index 7e3ae86..68097b0 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -1,21 +1,32 @@ extends RefCounted 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 -## 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 ## 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): ## parse_and_validate(text, manifest = {}) -> {ok, errors, data} ## load_from_file(path) -> {ok, errors, data} ## save_to_file(path, data) -> {ok, errors} (validiert VOR dem Schreiben) ## validate_map(data, manifest) -> PackedStringArray ## sign_map(data) -> void (setzt/erneuert Prüfsumme) - -const FORMAT_VERSION := 1 +## decode_materials(terrain) / encode_materials(mats) +## +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"] # 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: var errors := PackedStringArray() - # V3: Versions-Stempel + # V3: Versions-Stempel (v1 = Legacy ohne Material-Schicht, v2 = aktuell) var fv = data.get("format_version", null) - if not (fv is int or fv is float) or int(fv) != FORMAT_VERSION: - errors.append("V3: format_version muss %d sein (ist %s). Diese Datei ist keine .svmap-Map v1." % [FORMAT_VERSION, str(fv)]) + if not (fv is int or fv is float) or not SUPPORTED_FORMAT_VERSIONS.has(int(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. # 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: errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 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 @@ -380,3 +409,29 @@ static func decode_heights(terrain: Dictionary) -> PackedFloat32Array: var v: int = raw.decode_u16(i * 2) out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin) 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) diff --git a/src/svmap/terrain_field.gd b/src/svmap/terrain_field.gd index 0b226cc..715381d 100644 --- a/src/svmap/terrain_field.gd +++ b/src/svmap/terrain_field.gd @@ -10,7 +10,7 @@ class_name TerrainField ## ## 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 ## 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, hmin: float, hmax: float, cx: float, cz: float, radius: float, 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: return false 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 +## 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 ## 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, diff --git a/src/ui/main.gd b/src/ui/main.gd index f5fc6c0..f8164de 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -73,6 +73,12 @@ var terrain_panel: VBoxContainer var terrain_mode_raise: Button var terrain_mode_lower: 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_label: Label 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_lower = _terrain_mode_button("Senken ⬇", 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.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_paint.toggled.connect(_on_terrain_mode_changed) mode_row.add_child(terrain_mode_raise) mode_row.add_child(terrain_mode_lower) 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_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(_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.")) # --- Spawn-Zonen (Karte t_c4162c5d): Live-Status IMMER sichtbar --- @@ -1105,10 +1152,10 @@ func _enter_terrain_mode() -> void: return tool_mode = "terrain" 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_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: @@ -1152,6 +1199,8 @@ func _selected_terrain_mode() -> int: return TerrainField.Mode.LOWER if terrain_mode_smooth.button_pressed: return TerrainField.Mode.SMOOTH + if terrain_mode_paint.button_pressed: + return TerrainField.Mode.PAINT return TerrainField.Mode.RAISE @@ -1159,12 +1208,38 @@ func _sync_terrain_button() -> void: 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: + if terrain_palette != null: + terrain_palette.visible = _selected_terrain_mode() == TerrainField.Mode.PAINT if terrain_tool != null: 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: terrain_radius_label.text = "Pinsel-Größe: %d m" % int(value) if terrain_tool != null: diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 036e4b0..28acf42 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -77,9 +77,11 @@ func _build_terrain(data: Dictionary) -> void: ## Live-Update während eines Pinsel-Strichs (aus terrain_tool.gd): nur die ## 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): - 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 diff --git a/src/viewport/terrain_mesh.gd b/src/viewport/terrain_mesh.gd index 37fe8b2..7532343 100644 --- a/src/viewport/terrain_mesh.gd +++ b/src/viewport/terrain_mesh.gd @@ -8,11 +8,20 @@ extends Node3D ## schreiben (SOFT-war das Mesh live — kein Rebuild, ## kein Flackern, res 129 ≈ 16,6k Punkte = flott). ## -## Höhen-Farbverlauf über Vertex-Farben (dunkles Grün im Tal → helles Grün auf -## dem Hügel): Sly sieht Steigungen sofort, ohne Licht-Tricks. +## Farben (Karte t_603ec1a8): Vertex-Farben aus der Material-Schicht +## (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 COLOR_HIGH := Color(0.45, 0.62, 0.34) +const MATERIAL_COLORS: Array[Color] = [ + 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 _res := 0 @@ -30,7 +39,7 @@ func _ready() -> void: 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: var terrain: Dictionary = data.get("terrain", {}) if terrain.is_empty(): @@ -39,14 +48,17 @@ func rebuild_from_doc(data: Dictionary) -> void: _mi.mesh = null return var heights := SvmapIO.decode_heights(terrain) + var mats := SvmapIO.decode_materials(terrain) 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. ## 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, - size_z: float = 0.0) -> void: + size_z: float = 0.0, mats: PackedByteArray = PackedByteArray()) -> void: if res < 2: res = _res 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] normals[idx] = Vector3(hx_l - hx_r, 2.0 * cell_x, hz_d - hz_u).normalized() 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 z += 1 diff --git a/tests/test_editor_svmap.gd b/tests/test_editor_svmap.gd index 49de6f1..31e3711 100644 --- a/tests/test_editor_svmap.gd +++ b/tests/test_editor_svmap.gd @@ -46,7 +46,7 @@ func _errors_contain(errors: Array, needle: String) -> bool: func _run_tests() -> void: print("== Neue Map: default-Dokument ==") 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["weather"]) == "abend", "Wetter übernommen") _check((doc["objects"] as Array).is_empty(), "leere Objektliste") @@ -97,7 +97,7 @@ func _run_tests() -> void: res = SvmapIO.parse_and_validate("{{{definitiv kaputt") _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") var bad: Dictionary = _deep_copy(doc2) diff --git a/tests/test_terrain.gd b/tests/test_terrain.gd index 86da415..eada979 100644 --- a/tests/test_terrain.gd +++ b/tests/test_terrain.gd @@ -36,6 +36,9 @@ func _run_tests() -> void: _test_range_expansion() _test_svmap_roundtrip() _test_resolution_limit() + _test_material_brush() + _test_paint_undo_redo() + _test_material_format() ## 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)") +## 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: var s := 0.0 var count := 0