scopedvr-map-editor/src/editor/terrain_tool.gd

370 lines
13 KiB
GDScript

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