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) ## Rechtsklick-Ziehen = Gegenteil (bei Heben senkt er, bei Senken hebt er — ## kein Umschalten nötig; bei Glätten senkt er) ## 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"] 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 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 var _hmin := 0.0 var _hmax := 0.0 var _cursor_valid := false var _cursor := Vector3.ZERO # Pinsel-Ring var _ring: MeshInstance3D 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) # ------------------------------------------------------------ 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 ## 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) return _heights.size() >= _res * _res # ------------------------------------------------------------ Pinsel-Optionen func set_brush_mode(mode: int) -> void: brush_mode = clampi(mode, 0, 2) _refresh_ring_mesh() func set_radius(m: float) -> void: brush_radius = clampf(m, 2.0, 64.0) _refresh_ring_mesh() 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: if not active: return if event is InputEventMouseMotion: _update_cursor((event as InputEventMouseMotion).position) return if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_LEFT: if mb.pressed: _begin_stroke(brush_mode) else: _end_stroke() get_viewport().set_input_as_handled() 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). # 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: _end_stroke() get_viewport().set_input_as_handled() 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 # 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)." % _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) else: amount = brush_strength * 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 := TerrainField.apply_brush(_heights, _res, _sx, _sz, -100000.0, 100000.0, _cursor.x, _cursor.z, brush_radius, _stroke_mode, amount) if changed: _expand_range_if_needed() view_builder.call("update_terrain", _heights) _ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3 ## 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 # 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) 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") ## 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: 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) else: amount = brush_strength * dt TerrainField.apply_brush(_heights, _res, _sx, _sz, -100000.0, 100000.0, save_x, save_z, brush_radius, mode, 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 # ----------------------------------------------------------- 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 _: 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