extends Node3D ## Terrain-Pinsel (M2, Karte t_9aba04f1): Höhenfeld heben/senken/glätten im ## 3D-Viewport — im Terrain-Modus (Chef-Grundsatz Sly: maximal easy + Spaß). ## ## Bedienung: ## Linksklick-Ziehen = gewählter Pinsel-Modus (Heben / Senken / Glätten) ## Strg+Linksklick = das Gegenteil (bei Heben senkt er, bei Senken hebt ## er — Invert-Konvention wie Blender/Photoshop; im ## Malen-Modus malt Strg+Klick Gras zurück). Karte ## t_2470de89: die RECHTE Maustaste gehört IMMER dem ## Freelook-Flug — sie startet hier keinen Strich mehr. ## Der ganze Strich = EIN Undo-Eintrag (Strg+Z stellt die Form VOR dem ## Strich wieder her, nicht nur eine Zwischenstufe). ## ## Feedback: Pinsel-Ring folgt dem Cursor AUF dem Gelände (ray-march auf das ## Höhenfeld), das Mesh folgt dem Strich LIVE (Vertices pro Frame neu). ## ## Der Doc wird nur ZUM Strich-Ende verändert (encode → heightmap_r16_b64) ## — während des Strichs lebt ein dekodiertes PackedFloat32Array, das Mesh ## 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", "Malen", "Einebnen", "Rampe"] const FLATTEN_RATE_DIVISOR := 8.0 # Stärke-Slider → Einebnen-Tempo (Anteil/s) var main # Hauptfenster (script-erweitert, Variant) var camera: Camera3D var view_builder # TerrainMesh-Ansicht (script-erweitert, Variant) var interaction # Objekt-Interaktion (für Modus-Wechsel) var active := false var brush_mode: int = TerrainField.Mode.RAISE var brush_radius := 12.0 # Meter ## Kante beim Malen: 0 = hart, 1 = ganz weich (Karte 25.10.). var brush_weichheit := 0.4 ## Pinsel-Falloff (Karte 25.10.): 0 = hart (linear), 1 = weich (quadratisch). var brush_falloff := 0.5 ## Druck 0.05–1 (Pen-Druck / Controller-Trigger / Maus = 1.0), skaliert die Stärke. var pressure := 1.0 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 _flatten_target := 0.0 # Einebnen: Höhe unter dem Cursor beim Strichstart var _ramp_a := Vector2.ZERO # Rampe: Startpunkt (Welt-X/Z) + Höhe var _ramp_ha := 0.0 var _ramp_b := Vector2.ZERO # Rampe: aktueller Endpunkt var _heights := PackedFloat32Array() var _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_ # _from_doc immer dekodiert (t_55ed041a), # Mal-Strich schreibt live hinein. var _res := 0 var _mres := 0 # Raster der Material-Schicht (feines Raster, Karte 24.10.; alt = _res) var _mix_b := PackedByteArray() # zweites Material je Punkt (weiche Ränder, Karte 25.10.) var _mix_w := PackedByteArray() # Anteil des zweiten Materials 0–255 var _sx := 0.0 var _sz := 0.0 var _hmin := 0.0 var _hmax := 0.0 var _cursor_valid := false var _cursor := Vector3.ZERO # Pinsel-Ring var _ring: MeshInstance3D var _ramp_line: MeshInstance3D # Rampen-Vorschau: Linie Start → Cursor var _ring_radius := -1.0 var _ring_mode := -1 func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void: main = p_main camera = p_camera view_builder = p_builder interaction = p_interaction _ensure_ring() func _ensure_ring() -> void: if _ring != null and is_instance_valid(_ring): return _ring = MeshInstance3D.new() _ring.visible = false add_child(_ring) _ramp_line = MeshInstance3D.new() _ramp_line.visible = false _ramp_line.mesh = ImmediateMesh.new() var lm := StandardMaterial3D.new() lm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED lm.albedo_color = Color(1.0, 0.85, 0.3) lm.no_depth_test = true _ramp_line.material_override = lm add_child(_ramp_line) # ------------------------------------------------------------ Aktivierung --- ## Terrain-Modus einschalten. false = abgelehnt (Auflösungs-Limit) — main ## bleibt dann im Objekt-Modus und erklärt warum. func activate() -> bool: var terrain: Dictionary = main.doc.get("terrain", {}) var res := int(terrain.get("resolution", 0)) if res > TerrainField.EDITOR_MAX_RESOLUTION: main.call("_set_status", "Diese Map hat eine Terrain-Auflösung von %d — das Editor-MVP formt Terrain nur bis %d (Performance). Neu angelegte Maps erfüllen das automatisch." % [res, TerrainField.EDITOR_MAX_RESOLUTION], true) return false if not _refresh_from_doc(): return false active = true _stroking = false _ring.visible = false return true func deactivate() -> void: active = false if _stroking: _end_stroke() if _ring != null and is_instance_valid(_ring): _ring.visible = false if _ramp_line != null and is_instance_valid(_ramp_line): _ramp_line.visible = false ## Nach Undo/Redo/Laden/Neue-Map: Feld neu dekodieren (Cache ist sonst alt). func refresh_from_doc() -> bool: if not _refresh_from_doc(): return false return true func _refresh_from_doc() -> bool: var terrain: Dictionary = main.doc.get("terrain", {}) if terrain.is_empty(): return false _res = int(terrain.get("resolution", 0)) _sx = float(terrain.get("size_x_m", 0.0)) _sz = float(terrain.get("size_z_m", 0.0)) _hmin = float(terrain.get("min_height_m", 0.0)) _hmax = float(terrain.get("max_height_m", 0.0)) if _res < 2 or _sx <= 0.0 or _sz <= 0.0 or _hmax <= _hmin: return false _heights = SvmapIO.decode_heights(terrain) # Material-Schicht mit dekodieren (Karte t_55ed041a): der Live-Rebuild # beim Heben/Senken/Glätten braucht sie, sonst fällt das Mesh während # des Strichs auf alles-Gras zurück (Sand/Fels „verschwinden" live). _mats = SvmapIO.decode_materials(terrain) _mres = SvmapIO.material_resolution(terrain) _load_mix(terrain) return _heights.size() >= _res * _res # ------------------------------------------------------------ Pinsel-Optionen func set_brush_mode(mode: int) -> void: # Clamp bis PAINT (3) — der alte Clamp 0..2 hätte den Malen-Modus still # zu Glätten degradiert (Karte t_55257792, Ziffer 4 = Malen). brush_mode = clampi(mode, 0, MODE_LABELS.size() - 1) _refresh_ring_mesh() func set_radius(m: float) -> void: brush_radius = clampf(m, 1.0, 64.0) # ab 1 m (Karte 25.10., feines Raster) _refresh_ring_mesh() ## Misch-Schicht (Karte 25.10.) aus dem terrain-Block — fehlt sie, Nullen. func _load_mix(terrain: Dictionary) -> void: var mix := SvmapIO.decode_mix(terrain) _mix_b = mix[0] _mix_w = mix[1] ## Materialpunkt als ein int (a | b<<8 | w<<16) — Spiegeln kopiert alle drei. func _punkt(idx: int) -> int: return int(_mats[idx]) | (int(_mix_b[idx]) << 8) | (int(_mix_w[idx]) << 16) ## Anteil (0–255) des Materials m an einem gepackten Punkt. static func _anteil(v: int, m: int) -> int: var a := v & 0xFF var b := (v >> 8) & 0xFF var w := (v >> 16) & 0xFF return (255 - w if a == m else 0) + (w if b == m else 0) func _setze_punkt(idx: int, v: int) -> void: _mats[idx] = v & 0xFF _mix_b[idx] = (v >> 8) & 0xFF _mix_w[idx] = (v >> 16) & 0xFF ## Regler „Kante: hart ↔ weich“ (nur Malen). func set_weichheit(w: float) -> void: brush_weichheit = clampf(w, 0.0, 1.0) func set_falloff(f: float) -> void: brush_falloff = clampf(f, 0.0, 1.0) ## Druck setzen (XR-Controller-Trigger 0–1 ruft das direkt; Pen/Maus laufen ## über _unhandled_input). Wert wird über TerrainField.pressure_of begrenzt. func set_pressure(p: float) -> void: pressure = TerrainField.pressure_of(p, 0.0) 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: var p := MousePointer.from_input(event) if p == null: return # Stift: pressure > 0; Maus meldet 0 (oder 1) → bleibt voll. if p.kind == PointerEvent.Kind.MOVE: p.pressure = TerrainField.pressure_of(p.pressure, _trigger_axis()) if handle_pointer(p): get_viewport().set_input_as_handled() ## Rechte Maustaste kommt hier nie an: sie gehört mit absolutem Vorrang dem ## Freelook-Flug (orbit_camera.gd, Karte t_2470de89), in jedem Werkzeugzustand. func handle_pointer(p: PointerEvent) -> bool: if not active: return false match p.kind: PointerEvent.Kind.MOVE: _update_cursor(p.position) pressure = p.pressure PointerEvent.Kind.PRESS: # Karte t_2470de89: Strg = Invert-Modifier. Heben↔Senken # tauschen (Blender/Photoshop-Konvention), im Malen-Modus # malt Strg+Klick Gras zurück (alter Radierer-Weg). var mode := brush_mode var mat := -1 if p.ctrl: match brush_mode: TerrainField.Mode.RAISE: mode = TerrainField.Mode.LOWER TerrainField.Mode.LOWER: mode = TerrainField.Mode.RAISE TerrainField.Mode.PAINT: mat = 0 # Gras zurückmalen _begin_stroke(mode, mat) return true PointerEvent.Kind.RELEASE: _end_stroke() return true return false ## Gamepad-Trigger (rechts) des ersten Pads; 0 ohne Pad. Nur Eingabe-Lesen, ## keine OS-Annahme. func _trigger_axis() -> float: var pads := Input.get_connected_joypads() if pads.is_empty(): return 0.0 return Input.get_joy_axis(pads[0], JOY_AXIS_TRIGGER_RIGHT) func _update_cursor(viewport_pos: Vector2) -> void: var hit: Variant = TerrainField.raycast(_heights, _res, _sx, _sz, camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos)) if hit == null: _cursor_valid = false if _ring != null: _ring.visible = false return _cursor_valid = true _cursor = hit as Vector3 var half_x := _sx * 0.5 var half_z := _sz * 0.5 _cursor.x = clampf(_cursor.x, -half_x, half_x) _cursor.z = clampf(_cursor.z, -half_z, half_z) # Ring minimal über dem Gelände, damit er nicht im Mesh verschwindet. _ring.position = Vector3(_cursor.x, _height_at_stroke(_cursor.x, _cursor.z) + 0.3, _cursor.z) _ring.visible = true func _height_at_stroke(x: float, z: float) -> float: return TerrainField.height_at(_heights, _res, _sx, _sz, x, z) # ----------------------------------------------------------------- Stroke --- func _begin_stroke(mode: int, material_id: int = -1) -> void: if _stroking: return if not _cursor_valid: main.call("_set_status", "Der Pinsel braucht einen Punkt AUF dem Gelände — führe die Maus über die Karte und halte dann die Maustaste gedrückt.", true) return _stroke_mode = mode _stroke_material = paint_material if material_id < 0 else material_id _stroking = true if mode == TerrainField.Mode.FLATTEN: _flatten_target = _height_at_stroke(_cursor.x, _cursor.z) elif mode == TerrainField.Mode.RAMP: _ramp_a = Vector2(_cursor.x, _cursor.z) _ramp_ha = _height_at_stroke(_cursor.x, _cursor.z) _ramp_b = _ramp_a # 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", {})) _mres = SvmapIO.material_resolution(main.doc.get("terrain", {})) _load_mix(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)." % _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 := _paint_step(_cursor.x, _cursor.z) if painted: # Karte t_7a0a6b79: Malen lebt im Sub-Rect — update_terrain bekommt # das betroffene Zellrechteck (inkl. Spiegelzellen) und leitet nur # die Splat-Region neu ab; das Mesh selbst bleibt unangetastet # (Vertex-Farben hängen NICHT vom Material ab). if view_builder.has_method("set_terrain_mix"): view_builder.call("set_terrain_mix", _mix_b, _mix_w) view_builder.call("update_terrain", _heights, _mats, _splat_rect) _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 return if _stroke_mode == TerrainField.Mode.RAMP: # Rampe wirkt erst beim Loslassen — hier nur der Endpunkt + Ring-Hinweis. _ramp_b = Vector2(_cursor.x, _cursor.z) _update_ramp_line() return var amount := _step_amount(_stroke_mode, delta) # Während des Strichs NICHT auf die Format-Range clampen — die zieht der # Range-Expander danach nach (sonst friert Heben/Senken an der Kante ein). var changed := _height_step(_cursor.x, _cursor.z, amount) if changed: _expand_range_if_needed() _melde_geaenderte_hoehen() # Karte t_55ed041a: Material-Schicht MITGEBEN — ohne sie hat der # Live-Rebuild alles als Gras gerendert (Sand/Fels „verschwanden" # während des Heben/Senk-Strichs und kehrten erst am Stroke-Ende # mit dem Vollrebuild zurück). view_builder.call("update_terrain", _heights, _mats) _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 func _melde_geaenderte_hoehen() -> void: if not view_builder.has_method("mark_terrain_dirty"): return var r := _brush_region(_world_to_grid(_cursor.x, _cursor.z)) view_builder.call("mark_terrain_dirty", r) if _sym_active(): var ecke_a: Vector2i = SymmetryCore.mirror_cell(_sym_mode(), r.position.x, r.position.y, _res) var ecke_b: Vector2i = SymmetryCore.mirror_cell(_sym_mode(), r.end.x - 1, r.end.y - 1, _res) var spiegel := Rect2i(mini(ecke_a.x, ecke_b.x), mini(ecke_a.y, ecke_b.y), absi(ecke_a.x - ecke_b.x) + 1, absi(ecke_a.y - ecke_b.y) + 1) view_builder.call("mark_terrain_dirty", spiegel) ## Stärke dieses Frames: Meter (Heben/Senken) bzw. Mischfaktor 0–1 (Glätten/ ## Einebnen) — skaliert mit dem Druck. func _step_amount(mode: int, dt: float) -> float: match mode: TerrainField.Mode.SMOOTH: return clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt * pressure, 0.0, 1.0) TerrainField.Mode.FLATTEN: return clampf(brush_strength / FLATTEN_RATE_DIVISOR * dt * pressure, 0.0, 1.0) return brush_strength * dt * pressure ## Höhe spannt das Höhenfeld automatisch auf, wenn der Pinsel über die ## Format-Grenzen (min/max_height) hinaus will — sonst wäre Senken auf einer ## frisch angelegten (flachen) Map unmöglich und langes Heben würde clampen. ## Die Range gehört zum terrain-Block → Undo stellt sie mit zurück. Spannweite ## bleibt ≤ 500 m (V6). func _expand_range_if_needed() -> void: var lo := TerrainField._min_h(_heights) var hi := TerrainField._max_h(_heights) if lo < _hmin: _hmin = floorf(lo - 1.0) if hi > _hmax: _hmax = ceilf(hi + 1.0) if _hmax - _hmin > 500.0: if lo < _hmin: _hmin = _hmax - 500.0 else: _hmax = _hmin + 500.0 func _end_stroke() -> void: if not _stroking: return _stroking = false if _stroke_mode == TerrainField.Mode.RAMP: _ramp_line.visible = false if not _ramp_apply(): main.call("_set_status", "Rampe: Start und Ende liegen zu nah beieinander — Klick, ziehen, loslassen.") return # nichts geändert → kein leerer Undo-Eintrag # 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", {}) if _stroke_mode == TerrainField.Mode.PAINT: terrain["materials_r8_b64"] = SvmapIO.encode_materials(_mats) SvmapIO.encode_mix(terrain, _mix_b, _mix_w) 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") ## Rampe anwenden (Strichende): Start-Höhe = Höhe beim Klick, End-Höhe = Höhe ## unter dem Endpunkt. Breite = 2 × Radius. Symmetrie wie bei den Pinseln. func _ramp_apply() -> bool: if _ramp_a.distance_to(_ramp_b) < 1.0: return false var hb := _height_at_stroke(_ramp_b.x, _ramp_b.y) var width := brush_radius * 2.0 var snap := {} var g := _world_to_grid(_ramp_a.x, _ramp_a.y) if _sym_active(): var g2 := _world_to_grid(_ramp_b.x, _ramp_b.y) var r := _brush_region(g).merge(_brush_region(g2)) snap = _snap_heights(r) var changed := TerrainField.apply_ramp(_heights, _res, _sx, _sz, _ramp_a, _ramp_b, _ramp_ha, hb, width, brush_falloff) if changed and not snap.is_empty(): _mirror_heights(snap, g) if changed: _expand_range_if_needed() view_builder.call("update_terrain", _heights, _mats) return changed ## Rampen-Vorschau: gelbe Linie vom Startpunkt zum Cursor (auf Geländehöhe). func _update_ramp_line() -> void: var im := _ramp_line.mesh as ImmediateMesh im.clear_surfaces() im.surface_begin(Mesh.PRIMITIVE_LINES) im.surface_add_vertex(Vector3(_ramp_a.x, _ramp_ha + 0.5, _ramp_a.y)) im.surface_add_vertex(Vector3(_ramp_b.x, _height_at_stroke(_ramp_b.x, _ramp_b.y) + 0.5, _ramp_b.y)) im.surface_end() _ramp_line.visible = true ## Programmgesteuerter Stroke (Demo/Screenshot-Hook): macht exakt, was eine ## Maus auch tun würde — inkl. History-Eintrag. func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void: _refresh_from_doc() _cursor = Vector3(x, 0.0, z) _cursor_valid = true _begin_stroke(mode) var steps := int(ceil(seconds / 0.05)) var dt := seconds / float(maxi(steps, 1)) var save_x := _cursor.x var save_z := _cursor.z for i in steps: var amount := _step_amount(mode, dt) if mode == TerrainField.Mode.PAINT: _paint_step(save_x, save_z) continue _height_step(save_x, save_z, amount) _expand_range_if_needed() _end_stroke() _refresh_from_doc() ## Demo-Hook: Pinsel-Ring sichtbar an einer Weltposition parken (für ## Screenshots ohne echte Maus-Ereignisse). func demo_park_brush(x: float, z: float) -> void: _refresh_ring_mesh() _cursor = Vector3(x, 0.0, z) _cursor_valid = true _ring.position = Vector3(x, _height_at_stroke(x, z) + 0.3, z) _ring.visible = true # ------------------------------------------------------------- Symmetrie ---- ## M2 (Karte t_f9eff526): Im Symmetrie-Modus (main.sym_mode ≠ AUS) schreibt ## jeder Strich seine Zellen EXAKT gespiegelt in die andere Kartenseite — ## Höhe ist ein Skalar → 1:1 identisch übernommen, Material ebenso. Die ## Zell-Abbildung kommt aus M1 (SymmetryCore.mirror_cell), nichts neu erfunden. ## ## Ablauf pro Pinsel-Schritt: ## 1. Pinsel EINMAL an der Strich-Position anwenden — bewusst NICHT zweimal ## (Strichpunkt + Spiegelpunkt): sonst bekämen Zellen im Überlappband den ## Effekt doppelt und an der Rastermitte entstünde ein Grat. ## 2. Die seit dem Snapshot geänderten Zellen des Pinsel-Rechtecks ermitteln ## und jede Änderung in die gespiegelte Schwesterzelle kopieren. ## Liegt der Strich genau auf der Mitte/Achse, bildet mirror_cell die Zellen ## auf sich selbst bzw. auf Partner mit IDENTISCHEm Wert ab (spiegelsymmetrischer ## Fußabdruck, gleicher Falloff-Abstand) → nichts wird gedoppelt. ## Überlappen Fußabdruck und Spiegelbild (Strich nahe der Mitte), gewinnt pro ## Zellpaar die Seite, die NÄHER am Strichpunkt liegt — die andere Hälfte ist ## exakte Kopie der Strich-Seite; bei exaktem Gleichstand entscheidet der ## kleinere Grid-Index (deterministisch, Row-major-Verarbeitung). ## Bei Modus AUS: keine Snapshot-/Kopierarbeit — exakt das alte Verhalten. ## Zusätzlicher Rand um die grobe Pinsel-Region (TerrainField arbeitet mit ## floor/ceil ± 1; hier großzügig ± 2 — der Snapshot-Vergleich entscheidet ## ohnehin zellgenau, was wirklich geändert wurde). const SYM_REGION_MARGIN := 2 func _sym_mode() -> int: if main == null: return SymmetryCore.Mode.OFF # .get statt direkter Property: Haupts ohne sym_mode-Feld (z. B. alte # Test-Stubs, Tools außerhalb des Editors) fallen auf AUS zurück — # exakt das Verhalten vor M2. var mode: Variant = main.get("sym_mode") if mode == null or not (mode is int): return SymmetryCore.Mode.OFF return int(mode) func _sym_active() -> bool: return _sym_mode() != SymmetryCore.Mode.OFF ## Welt-X/Z → Raster-Koordinaten (gleiche Konvention wie TerrainField.height_at: ## Index 0 = −X/−Z-Ecke, x-Index läuft MIT +WeltX). func _world_to_grid(x: float, z: float) -> Vector2: return _world_to_grid_r(x, z, _res) ## Wie _world_to_grid, aber auf einem beliebigen Raster (Material: _mres). func _world_to_grid_r(x: float, z: float, r: int) -> Vector2: var last := float(r - 1) return Vector2((x / _sx + 0.5) * last, (z / _sz + 0.5) * last) ## Grobes Rechteck um die Raster-Position (g), das den Pinsel-Fußabdruck ## jedes Modus garantiert abdeckt (Radius in Zellen + Margin). func _brush_region(g: Vector2) -> Rect2i: return _brush_region_r(g, _res) ## Wie _brush_region, auf dem Raster r (Material-Schicht: _mres). func _brush_region_r(g: Vector2, r: int) -> Rect2i: var cell_x := _sx / float(r - 1) var cell_z := _sz / float(r - 1) var rx := brush_radius / cell_x + float(SYM_REGION_MARGIN) var rz := brush_radius / cell_z + float(SYM_REGION_MARGIN) var x0 := maxi(int(floorf(g.x - rx)), 0) var x1 := mini(int(ceilf(g.x + rx)), r - 1) var z0 := maxi(int(floorf(g.y - rz)), 0) var z1 := mini(int(ceilf(g.y + rz)), r - 1) return Rect2i(x0, z0, x1 - x0 + 1, z1 - z0 + 1) ## Abstand einer Rasterzelle zum Strichpunkt in Metern (gleiche Zell-Metrik ## wie TerrainField.apply_brush: Zellindex-Abstand × Zellgröße). func _cell_dist(pos: Vector2i, g: Vector2, cell_x: float, cell_z: float) -> float: var dx := (float(pos.x) - g.x) * cell_x var dz := (float(pos.y) - g.y) * cell_z return sqrt(dx * dx + dz * dz) ## Ein Pinsel-Schritt HÖHE (Heben/Senken/Glätten) inkl. Spiegel-Kopie. ## amount: Meter (Heben/Senken) bzw. Mischfaktor 0–1 (Glätten) — unverändert ## zur bisherigen apply_brush-Semantik; ohne Clamp (Range-Expander zieht nach). func _height_step(x: float, z: float, amount: float) -> bool: var g := _world_to_grid(x, z) var snap := {} if _sym_active(): snap = _snap_heights(_brush_region(g)) var changed := TerrainField.apply_brush(_heights, _res, _sx, _sz, -100000.0, 100000.0, x, z, brush_radius, _stroke_mode, amount, brush_falloff, _flatten_target) if changed and not snap.is_empty(): _mirror_heights(snap, g) return changed ## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie. func _paint_step(x: float, z: float) -> bool: # Material lebt auf seinem eigenen (feinen) Raster _mres (Karte 24.10.). var g := _world_to_grid_r(x, z, _mres) # VOR dem Pinsel: Zellrect des Fußabdrucks merken (deckt Spiegelzellen mit) # — der Live-Splat-Refresh (t_7a0a6b79) leitet genau diese Region neu ab. _splat_rect = _splat_region(g) var snap := {} if _sym_active() and _mats.size() >= _mres * _mres: snap = _snap_mats(_brush_region_r(g, _mres)) var changed := TerrainField.apply_material_brush_mix(_mats, _mix_b, _mix_w, _mres, _sx, _sz, x, z, brush_radius, _stroke_material, brush_weichheit, pressure) if changed and not snap.is_empty(): _mirror_mats(snap, g) return changed ## Snapshot {Vector2i(x, z) → Höhe} über das Pinsel-Rechteck (NUR bei ## Symmetrie AN erzeugt — bei AUS bleibt es bei exakt dem alten Verhalten). func _snap_heights(rect: Rect2i) -> Dictionary: var snap := {} for z: int in range(rect.position.y, rect.end.y): for x: int in range(rect.position.x, rect.end.x): snap[Vector2i(x, z)] = _heights[z * _res + x] return snap ## Spiegel-Pass für die Höhe: jede seit dem Snapshot geänderte Zelle wird in ## ihre gespiegelte Schwester kopiert (Strich-Seite ist Master, siehe oben). func _mirror_heights(snap: Dictionary, g: Vector2) -> void: var sym := _sym_mode() var cell_x := _sx / float(_res - 1) var cell_z := _sz / float(_res - 1) var done := {} var positions: Array = snap.keys() # Row-major (kleinster Index zuerst): der Gleichstand-Tie-Break „kleinerer # Index gewinnt“ ist damit deterministisch, und ein Paar wird genau einmal # an seinem kleineren Index entschieden. positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: return (a.y * _res + a.x) < (b.y * _res + b.x)) for pos: Vector2i in positions: if done.has(pos): continue var idx: int = pos.y * _res + pos.x var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _res) done[pos] = true if mirror == pos: continue # Selbstabbildung (Rastermitte/Achse) — nichts zu kopieren var midx: int = mirror.y * _res + mirror.x var value: float = _heights[idx] if snap.has(mirror): # Beide Seiten wurden vom Pinsel geändert (Fußabdruck überlappt # sein Spiegelbild): näher am Strichpunkt gewinnt, Gleichstand # → kleinere Index (== diese pos, da Row-major zuerst kam). if _cell_dist(mirror, g, cell_x, cell_z) < _cell_dist(pos, g, cell_x, cell_z): value = _heights[midx] _heights[idx] = value _heights[midx] = value done[mirror] = true ## Snapshot {Vector2i(x, z) → Material-ID} über das Pinsel-Rechteck. func _snap_mats(rect: Rect2i) -> Dictionary: var snap := {} for z: int in range(rect.position.y, rect.end.y): for x: int in range(rect.position.x, rect.end.x): snap[Vector2i(x, z)] = _punkt(z * _mres + x) return snap ## Spiegel-Pass für die Material-Schicht (identische Paar-Logik wie _mirror_heights). func _mirror_mats(snap: Dictionary, g: Vector2) -> void: var sym := _sym_mode() var cell_x := _sx / float(_mres - 1) var cell_z := _sz / float(_mres - 1) var done := {} var positions: Array = snap.keys() positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: return (a.y * _mres + a.x) < (b.y * _mres + b.x)) # Karte 25.10. (Sly: harte gerade Kanten mit Symmetrie): NUR Punkte spiegeln, # die dieser Pinsel-Schritt wirklich verändert hat. Vorher wurde das ganze # Snapshot-Quadrat auf die Gegenseite kopiert — weiche Verläufe drüben # wurden an den Quadratkanten hart abgeschnitten. for pos: Vector2i in positions: if done.has(pos): continue done[pos] = true var idx: int = pos.y * _mres + pos.x if _punkt(idx) == int(snap[pos]): continue # nicht bemalt → Gegenseite bleibt unangetastet var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _mres) if mirror == pos: continue var midx: int = mirror.y * _mres + mirror.x var value: int = _punkt(idx) if snap.has(mirror) and _punkt(midx) != int(snap[mirror]): # Beide Seiten bemalt (Pinsel überlappt sein Spiegelbild, z. B. nahe # der Kartenmitte): der Wert mit dem GRÖSSEREN Anteil des Mal- # Materials gewinnt. Vorher „näher am Strichpunkt“ — das zog entlang # der Mittelsenkrechte eine harte gerade Kante durch weiche Verläufe # (Sly 25.10.). Max ist stetig und symmetrisch. if _anteil(_punkt(midx), _stroke_material) > _anteil(value, _stroke_material): value = _punkt(midx) _setze_punkt(idx, value) done[mirror] = true _setze_punkt(midx, value) # ----------------------------------------------------------- Ring-Optik ----- func _refresh_ring_mesh() -> void: if _ring == null: return if _ring_radius == brush_radius and _ring_mode == brush_mode: return _ring_radius = brush_radius _ring_mode = brush_mode var torus := TorusMesh.new() torus.inner_radius = brush_radius - 0.25 torus.outer_radius = brush_radius + 0.25 torus.rings = 48 torus.ring_segments = 8 _ring.mesh = torus var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA match brush_mode: TerrainField.Mode.RAISE: 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 TerrainField.Mode.FLATTEN: mat.albedo_color = Color(1.0, 0.85, 0.3, 0.9) TerrainField.Mode.RAMP: mat.albedo_color = Color(0.85, 0.5, 1.0, 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 # ------------------------------------------------ Live-Splat-Sub-Rect (Karte # t_7a0a6b79): Zellrechteck des letzten Mal-Schritts für das Live-Update der # Splat-Gewichtstextur. Am Dateiende angefügt (Karten-Regel). ## Zellrect des letzten Mal-Schritts (Pinsel-Fußabdruck ∪ gespiegelte ## Schwesterzellen) — vom Stroke-Code als 3. Parameter an update_terrain ## (view_builder) weitergereicht. var _splat_rect := Rect2i() ## Zellrect des Pinsel-Fußabdrucks (∪ Spiegelzellen): dieselben Region- ## Grenzen wie apply_material_brush, mit dem SYM_REGION_MARGIN-Rand — der ## deckt auch die Kernel-Nachbarschaft der Splat-Ableitung mit ab. ## g und Ergebnis im Material-Raster (_mres) — so rechnet auch die Splat. func _splat_region(g: Vector2) -> Rect2i: var rect := _brush_region_r(g, _mres) var sym := _sym_mode() if sym == SymmetryCore.Mode.OFF: return rect var a := SymmetryCore.mirror_cell(sym, rect.position.x, rect.position.y, _mres) var b := SymmetryCore.mirror_cell(sym, rect.end.x - 1, rect.end.y - 1, _mres) return rect.merge(Rect2i(mini(a.x, b.x), mini(a.y, b.y), absi(a.x - b.x) + 1, absi(a.y - b.y) + 1))