370 lines
13 KiB
GDScript
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
|