765 lines
29 KiB
GDScript
765 lines
29 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)
|
||
## 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()
|
||
# 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
|
||
|
||
|
||
## 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))
|