M2-Karte t_9aba04f1: Terrain-Werkzeug — Höhenfeld heben/senken/glätten (Pinsel + Undo)

TerrainField (src/svmap/terrain_field.gd): Höhenfeld-Mathematik auf dekodiertem
PackedFloat32Array — Pinsel mit Kosinus-Falloff (RAISE/LOWER/SMOOTH, zweiphasiges
3x3-Glättung), R16-Encode (uint16 LE row-major), ray-march+Bisektion-Cursor auf
dem GELÄNDE (nicht Basis-Ebene). EDITOR_MAX_RESOLUTION=129 (Editor-MVP-Perf-Limit,
Format bleibt 17-513 für Interop).

TerrainMeshView (src/viewport/terrain_mesh.gd): Höhenfeld als ArrayMesh mit
Höhen-Farbverlauf (Vertex-Farben); update_heights schreibt Vertices live neu
während des Strichs (res 129 = 16,6k Punkte, flott). Ersetzt die flache
Boden-Plane in map_view_builder (rebuild + rebuild_terrain_from_doc + update_terrain).

TerrainTool (src/editor/terrain_tool.gd): Pinsel-Modus im Viewport — Linksklick-
Ziehen formt (Heben/Senken/Glättten nach Panel-Wahl), Rechtsklick-Ziehen das
Gegenteil (kein Umschalten nötig). Pinsel-Ring (Torus) folgt dem Cursor auf dem
Terrain, farbcodiert pro Modus. GANZER Strich = EIN ActionHistory-Eintrag
(snapshot terrain VOR dem Strich, commit NACH dem Encode). Range-Expansion:
min/max_height_m weiten sich automatisch auf, wenn der Pinsel drüber hinaus will
(sonst wäre Senken auf flacher Map unmöglich) — Spannweite ≤ 500 m (V6), Undo
stellt die Range mit zurück. Doc wird nur ZUM Strich-Ende verändert.

main.gd: Werkzeug-Button 'Terrain formen' (groß, deutsch), Terrain-Panel mit
Heben/Senken/Glättten-Buttons + Pinsel-Größe/Stärke-Slider mit Live-Werten,
Modus-Wechsel objects<->terrain (Bibliothek-Klick wechselt zurück, ESC beendet),
interaction.gd ruht im Terrain-Modus. Performance-Zähler in der Statusleiste:
Terrain-Grid + Dreiecke (+ % vom Quest-3-Budget 524.288) + Editor-FPS.
Demo-Hook --terrain-demo [ordner]: 5 Szenarien inkl. Undo-Roundtrip als PNGs.

Neue Maps: res 129 (vorher 257) — erfüllt das Editor-MVP-Perf-Limit automatisch.
This commit is contained in:
Hermes (godot-vr) 2026-10-15 10:30:42 +00:00
parent 9b377a7720
commit 30379bcc7b
8 changed files with 1197 additions and 28 deletions

View file

@ -264,6 +264,8 @@ func delete_selected() -> void:
# ------------------------------------------------------------ Eingaben ------ # ------------------------------------------------------------ Eingaben ------
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if main.get("tool_mode") == "terrain":
return # Terrain-Pinsel hat das Zepter (terrain_tool.gd, Karte t_9aba04f1)
if event is InputEventMouseMotion: if event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion var mm := event as InputEventMouseMotion
if placing: if placing:

314
src/editor/terrain_tool.gd Normal file
View file

@ -0,0 +1,314 @@
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"]
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)
# Stroke-Zustand
var _stroking := false
var _stroke_mode: int = TerrainField.Mode.RAISE
var _heights := PackedFloat32Array()
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)
# --------------------------------------------------------------- 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).
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) -> 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
_stroking = true
_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)." % MODE_LABELS[mode])
func _process(delta: float) -> void:
if not _stroking or not _cursor_valid:
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 := _heights.min()
var hi := _heights.max()
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", {})
terrain["min_height_m"] = _hmin
terrain["max_height_m"] = _hmax
terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax)
main.history.commit("Terrain 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:
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)
_:
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

View file

@ -6,7 +6,9 @@ class_name SvmapDocument
## ##
## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd. ## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd.
const DEFAULT_RESOLUTION := 257 # mittleres Grid (17–513 erlaubt) const DEFAULT_RESOLUTION := 129 # Editor-MVP-Perf-Limit (Karte t_9aba04f1);
# das Format erlaubt 17–513, der Editor formt im MVP nur bis 129 —
# neue Maps erfüllen das Limit dadurch automatisch.
const DEFAULT_MIN_HEIGHT := 0.0 const DEFAULT_MIN_HEIGHT := 0.0
const DEFAULT_MAX_HEIGHT := 50.0 const DEFAULT_MAX_HEIGHT := 50.0
const DEFAULT_TARGET_GAME_VERSION := "0.1.0" const DEFAULT_TARGET_GAME_VERSION := "0.1.0"

199
src/svmap/terrain_field.gd Normal file
View file

@ -0,0 +1,199 @@
extends RefCounted
class_name TerrainField
## Höhenfeld-Mathematik des Map-Editors (M2, Karte t_9aba04f1 — Terrain-Werkzeug).
##
## Alles static + rein: arbeitet auf einem dekodierten PackedFloat32Array
## (res×res, row-major, -Z → +Z) statt auf Base64-Strings — das Terrain-Werkzeug
## dekodiert EINMAL pro Stroke und arbeitet flott darauf. Das Doc (svmap-Format)
## bleibt der Besitzer der Daten: vor einem Stroke dekodieren, nach dem Stroke
## mit encode() zurück ins Doc.
##
## Der eigentliche Doc-Mutator bleibt main.gd/terrain_tool.gd — hier nur Mathematik.
enum Mode { RAISE, LOWER, SMOOTH }
## Editor-MVP-Perf-Limit (Karte t_9aba04f1): das Format erlaubt 17–513, aber
## der Editor formt im MVP nur Grids bis 129 (≈ 32k Dreiecke — flüssig beim
## Zeichnen, Ziel 60+ FPS). Neu-Maps werden direkt mit 129 angelegt.
const EDITOR_MAX_RESOLUTION := 129
## Höhe an Welt-X/Z auf einem bereits dekodierten Feld (bilinear).
## Gleiche Geometrie-Konvention wie SvmapDocument.terrain_height_at, aber ohne
## Base64-Neudekodierung pro Aufruf (macht den Pinsel flott).
static func height_at(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
x: float, z: float) -> float:
var last := float(res - 1)
var fx := clampf((x / size_x + 0.5) * last, 0.0, last)
var fz := clampf((z / size_z + 0.5) * last, 0.0, last)
var xi := int(floor(fx))
var zi := int(floor(fz))
var tx := fx - float(xi)
var tz := fz - float(zi)
var xi1 := mini(xi + 1, res - 1)
var zi1 := mini(zi + 1, res - 1)
var h00 := heights[zi * res + xi]
var h10 := heights[zi * res + xi1]
var h01 := heights[zi1 * res + xi]
var h11 := heights[zi1 * res + xi1]
return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz)
## Pinsel anwenden. amount:
## RAISE/LOWER → Meter (bereits strength × dt vom Aufrufer),
## SMOOTH → Mischfaktor 0–1 (Anteil Richtung 3×3-Umgebungsmittel).
## Gewicht: Kosinus-Falloff (1 in der Mitte, 0 am Rand) — weiche Hügel statt
## Kegel. Rückgabe true, wenn mindestens ein Punkt verändert wurde.
static func apply_brush(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
hmin: float, hmax: float, cx: float, cz: float, radius: float,
mode: int, amount: float) -> bool:
if res < 2 or radius <= 0.0 or amount <= 0.0 or heights.size() < res * res:
return false
var cell_x := size_x / float(res - 1)
var cell_z := size_z / float(res - 1)
var r_cells_x := radius / cell_x
var r_cells_z := radius / cell_z
var gx := (cx / size_x + 0.5) * float(res - 1)
var gz := (cz / size_z + 0.5) * float(res - 1)
var x0 := maxi(int(floor(gx - r_cells_x)) - 1, 0)
var x1 := mini(int(ceil(gx + r_cells_x)) + 1, res - 1)
var z0 := maxi(int(floor(gz - r_cells_z)) - 1, 0)
var z1 := mini(int(ceil(gz + r_cells_z)) + 1, res - 1)
if mode == Mode.SMOOTH:
return _apply_smooth(heights, res, hmin, hmax, gx, gz, r_cells_x, r_cells_z,
x0, x1, z0, z1, clampf(amount, 0.0, 1.0))
var changed := false
var z := z0
while z <= z1:
var x := x0
while x <= x1:
var dx := (float(x) - gx) * cell_x
var dz := (float(z) - gz) * cell_z
var d := sqrt(dx * dx + dz * dz)
if d >= radius:
x += 1
continue
var w := 0.5 * (1.0 + cos(PI * d / radius))
var idx := z * res + x
var h := heights[idx]
var nh := h + amount * w if mode == Mode.RAISE else h - amount * w
nh = clampf(nh, hmin, hmax)
if nh != h:
heights[idx] = nh
changed = true
x += 1
y += 1
return changed
## Glätten: Mischung Richtung 3×3-Umgebungsmittel (zweiphasig — das Mittel wird
## aus dem STAND der Feldes berechnet, sonst wandert die Welle in eine Richtung).
static func _apply_smooth(heights: PackedFloat32Array, res: int, hmin: float, hmax: float,
gx: float, gz: float, r_cells_x: float, r_cells_z: float,
x0: int, x1: int, z0: int, z1: int, amount: float) -> bool:
var avg := PackedFloat32Array()
avg.resize(heights.size())
var z := z0
while z <= z1:
var x := x0
while x <= x1:
var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001)
var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001)
var d := sqrt(dx * dx + dz * dz)
if d >= 1.0:
x += 1
continue
var sum := 0.0
var count := 0
var zi := maxi(z - 1, 0)
while zi <= mini(z + 1, res - 1):
var xi := maxi(x - 1, 0)
while xi <= mini(x + 1, res - 1):
sum += heights[zi * res + xi]
count += 1
xi += 1
zi += 1
avg[z * res + x] = sum / float(maxi(count, 1))
x += 1
z += 1
var changed := false
z = z0
while z <= z1:
var x := x0
while x <= x1:
var dx := (float(x) - gx) / maxf(r_cells_x, 0.0001)
var dz := (float(z) - gz) / maxf(r_cells_z, 0.0001)
var d := sqrt(dx * dx + dz * dz)
if d >= 1.0:
x += 1
continue
var w := 0.5 * (1.0 + cos(PI * d))
var idx := z * res + x
var nh := lerpf(heights[idx], avg[idx], clampf(amount * w, 0.0, 1.0))
nh = clampf(nh, hmin, hmax)
if nh != heights[idx]:
heights[idx] = nh
changed = true
x += 1
z += 1
return changed
## Höhenfeld → Base64-Rohdaten (svmap-Format: uint16 LE, row-major).
## Rundung halbwegs offen (round) — Fehler ≤ ½ Quantisierungsschritt.
static func encode(heights: PackedFloat32Array, hmin: float, hmax: float) -> String:
var span := maxf(hmax - hmin, 0.000001)
var bytes := PackedByteArray()
bytes.resize(heights.size() * 2)
for i in heights.size():
var v := int(round(clampf((heights[i] - hmin) / span, 0.0, 1.0) * 65535.0))
bytes.encode_u16(i * 2, v)
return Marshalls.raw_to_base64(bytes)
## Cursor-Strahl → erster Treffer auf dem Höhenfeld (ray-march + Bisektion).
## Für den Pinsel: der Kreis muss UNTER dem Mauszeiger auf dem GELÄNDE liegen,
## nicht auf der flachen Basis-Ebene. Rückgabe null = kein Treffer.
static func raycast(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
origin: Vector3, dir: Vector3, max_dist: float = 0.0) -> Variant:
if res < 2 or heights.size() < res * res:
return null
if max_dist <= 0.0:
max_dist = maxf(size_x, size_z) * 3.0
var d := dir
if d.length_squared() < 0.0000001:
return null
d = d.normalized()
var step := maxf(size_x, size_z) / float(res - 1) * 0.5
step = clampf(step, 0.5, 4.0)
var t := 0.0
var prev_t := 0.0
while t < max_dist:
t = minf(t + step, max_dist)
var f := _ray_error(heights, res, size_x, size_z, origin, d, t)
if f <= 0.0:
# Vorzeichenwechsel zwischen prev_t und t → Bisektion.
var lo := prev_t
var hi := t
for i in 18:
var mid := (lo + hi) * 0.5
if _ray_error(heights, res, size_x, size_z, origin, d, mid) <= 0.0:
hi = mid
else:
lo = mid
return origin + d * hi
prev_t = t
# Nichts getroffen → Basis-Ebene als Fallback (wie interaction._ground_point).
if absf(d.y) > 0.0001:
var plane_y := heights.min()
var t_plane := (plane_y - origin.y) / d.y
if t_plane > 0.0:
return origin + d * t_plane
return null
## f(t) < 0 → Strahl unter dem Terrain. (prev_f-Nutzung vermieden Godot-Warnung.)
static func _ray_error(heights: PackedFloat32Array, res: int, size_x: float, size_z: float,
origin: Vector3, dir: Vector3, t: float) -> float:
var p := origin + dir * t
return p.y - height_at(heights, res, size_x, size_z, p.x, p.z)

View file

@ -39,12 +39,16 @@ var dirty := false
var history := ActionHistory.new() var history := ActionHistory.new()
var interaction # src/editor/interaction.gd (script-erweitert → untypisiert) var interaction # src/editor/interaction.gd (script-erweitert → untypisiert)
var terrain_tool # src/editor/terrain_tool.gd (Pinsel, Karte t_9aba04f1)
var tool_mode := "objects" # "objects" = Platzieren/Auswahl, "terrain" = Höhenfeld-Pinsel
# UI-Referenzen # UI-Referenzen
var content_tree: Tree var content_tree: Tree
var stats_objects: Label var stats_objects: Label
var stats_terrain: Label
var status_label: Label var status_label: Label
var file_label: Label var file_label: Label
var help_label: Label
var view_builder: Node3D var view_builder: Node3D
var camera: Camera3D var camera: Camera3D
var library_box: VBoxContainer var library_box: VBoxContainer
@ -65,6 +69,17 @@ var prop_version: SpinBox
var prop_weather: OptionButton var prop_weather: OptionButton
var terrain_info: Label var terrain_info: Label
# Terrain-Werkzeug-Panel (Karte t_9aba04f1)
var terrain_edit_btn: Button
var terrain_panel: VBoxContainer
var terrain_mode_raise: Button
var terrain_mode_lower: Button
var terrain_mode_smooth: Button
var terrain_radius_slider: HSlider
var terrain_radius_label: Label
var terrain_strength_slider: HSlider
var terrain_strength_label: Label
# Dialoge # Dialoge
var new_dialog: AcceptDialog var new_dialog: AcceptDialog
var open_dialog: FileDialog var open_dialog: FileDialog
@ -180,6 +195,16 @@ func _build_ui() -> void:
stats_objects.modulate = Color(1, 1, 1, 0.75) stats_objects.modulate = Color(1, 1, 1, 0.75)
status_h.add_child(stats_objects) status_h.add_child(stats_objects)
# Terrain-Anteil + Editor-FPS (Perf-Zähler, Karte t_9aba04f1)
stats_terrain = Label.new()
stats_terrain.modulate = Color(1, 1, 1, 0.75)
status_h.add_child(stats_terrain)
var fps_timer := Timer.new()
fps_timer.wait_time = 0.5
fps_timer.autostart = true
fps_timer.timeout.connect(_refresh_stats)
add_child(fps_timer)
# --- Dialoge --- # --- Dialoge ---
new_dialog = preload("res://src/ui/new_map_dialog.gd").new() new_dialog = preload("res://src/ui/new_map_dialog.gd").new()
new_dialog.confirmed.connect(_on_new_map_confirmed) new_dialog.confirmed.connect(_on_new_map_confirmed)
@ -293,15 +318,21 @@ func _build_center_viewport() -> SubViewportContainer:
interaction.setup(self, camera, view_builder) interaction.setup(self, camera, view_builder)
camera.set("wheel_filter", Callable(interaction, "wheel_blocked")) camera.set("wheel_filter", Callable(interaction, "wheel_blocked"))
# Terrain-Pinsel (Karte t_9aba04f1) — eigener Eingabe-Knoten im Weltraum.
terrain_tool = Node3D.new()
terrain_tool.set_script(load("res://src/editor/terrain_tool.gd"))
world.add_child(terrain_tool)
terrain_tool.setup(self, camera, view_builder, interaction)
# Bedien-Hinweis im Viewport (Overlay) # Bedien-Hinweis im Viewport (Overlay)
var help := Label.new() help_label = Label.new()
help.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück" help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
help.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) help_label.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
help.offset_left = 12 help_label.offset_left = 12
help.offset_bottom = -8 help_label.offset_bottom = -8
help.offset_top = -46 help_label.offset_top = -46
help.modulate = Color(1, 1, 1, 0.65) help_label.modulate = Color(1, 1, 1, 0.65)
container.add_child(help) container.add_child(help_label)
return container return container
@ -377,7 +408,62 @@ func _build_right_panel() -> PanelContainer:
terrain_info.text = "" terrain_info.text = ""
terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(terrain_info) vbox.add_child(terrain_info)
vbox.add_child(_muted_label("Terrain-Höhen bearbeiten kommt mit der nächsten Version."))
# --- Terrain-Werkzeug (Karte t_9aba04f1): großer deutscher Button ---
terrain_edit_btn = Button.new()
terrain_edit_btn.text = "⛰ Terrain formen"
terrain_edit_btn.custom_minimum_size = Vector2(0, 48)
terrain_edit_btn.toggle_mode = true
terrain_edit_btn.tooltip_text = "Boden heben, senken und glätten — direkt in der Karte malen"
terrain_edit_btn.pressed.connect(_on_terrain_btn_pressed)
vbox.add_child(terrain_edit_btn)
terrain_panel = VBoxContainer.new()
terrain_panel.add_theme_constant_override("separation", 6)
terrain_panel.visible = false
vbox.add_child(terrain_panel)
terrain_panel.add_child(_field_label("Pinsel-Werkzeug:"))
var mode_row := HBoxContainer.new()
mode_row.add_theme_constant_override("separation", 6)
terrain_panel.add_child(mode_row)
var mode_group := ButtonGroup.new()
terrain_mode_raise = _terrain_mode_button("Heben ⬆", mode_group)
terrain_mode_lower = _terrain_mode_button("Senken ⬇", mode_group)
terrain_mode_smooth = _terrain_mode_button("Glätten ≈", mode_group)
terrain_mode_raise.button_pressed = true
terrain_mode_raise.toggled.connect(_on_terrain_mode_changed)
terrain_mode_lower.toggled.connect(_on_terrain_mode_changed)
terrain_mode_smooth.toggled.connect(_on_terrain_mode_changed)
mode_row.add_child(terrain_mode_raise)
mode_row.add_child(terrain_mode_lower)
mode_row.add_child(terrain_mode_smooth)
terrain_radius_label = _field_label("Pinsel-Größe: 12 m")
terrain_panel.add_child(terrain_radius_label)
terrain_radius_slider = HSlider.new()
terrain_radius_slider.min_value = 2.0
terrain_radius_slider.max_value = 64.0
terrain_radius_slider.step = 1.0
terrain_radius_slider.value = 12.0
terrain_radius_slider.custom_minimum_size = Vector2(0, 32)
terrain_radius_slider.value_changed.connect(_on_terrain_radius_changed)
terrain_panel.add_child(terrain_radius_slider)
terrain_strength_label = _field_label("Stärke: 8 m pro Sekunde")
terrain_panel.add_child(terrain_strength_label)
terrain_strength_slider = HSlider.new()
terrain_strength_slider.min_value = 1.0
terrain_strength_slider.max_value = 20.0
terrain_strength_slider.step = 0.5
terrain_strength_slider.value = 8.0
terrain_strength_slider.custom_minimum_size = Vector2(0, 32)
terrain_strength_slider.value_changed.connect(_on_terrain_strength_changed)
terrain_panel.add_child(terrain_strength_slider)
terrain_panel.add_child(_muted_label(
"Linksklick-Ziehen formt den Boden, Rechtsklick-Ziehen macht das Gegenteil. "
+ "Jeder Strich ist EIN Rückgängig-Schritt (Strg+Z). ESC beendet den Terrain-Modus."))
# Signale (Änderungen → Dokument + Ansicht) # Signale (Änderungen → Dokument + Ansicht)
prop_name.text_changed.connect(_on_prop_changed.bind("name")) prop_name.text_changed.connect(_on_prop_changed.bind("name"))
@ -571,7 +657,11 @@ func _unhandled_key_input(event: InputEvent) -> void:
interaction.rotate_selected(delta) interaction.rotate_selected(delta)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
KEY_ESCAPE: KEY_ESCAPE:
if interaction.placing: if tool_mode == "terrain":
_set_tool_mode("objects")
_set_status("Terrain-Modus beendet — zurück zum Objekt-Werkzeug.")
get_viewport().set_input_as_handled()
elif interaction.placing:
interaction.cancel_placement() interaction.cancel_placement()
_set_status("Platzieren abgebrochen.") _set_status("Platzieren abgebrochen.")
elif interaction.selected_index != -1: elif interaction.selected_index != -1:
@ -589,6 +679,8 @@ func _unhandled_key_input(event: InputEvent) -> void:
# --------------------------------------------------------- Bibliothek ------ # --------------------------------------------------------- Bibliothek ------
func _on_library_pressed(oid: String) -> void: func _on_library_pressed(oid: String) -> void:
if tool_mode == "terrain":
_set_tool_mode("objects") # Bibliothek-Klick wechselt automatisch zurück
if interaction.placing and interaction.place_oid == oid: if interaction.placing and interaction.place_oid == oid:
# Nochmal geklickt → Modus beenden (An/Aus ohne Umwege). # Nochmal geklickt → Modus beenden (An/Aus ohne Umwege).
interaction.cancel_placement() interaction.cancel_placement()
@ -619,6 +711,107 @@ func _base_tool_status() -> void:
_set_status("Bereit. Links ein Objekt anklicken zum Platzieren oder in der Karte ein Objekt auswählen.") _set_status("Bereit. Links ein Objekt anklicken zum Platzieren oder in der Karte ein Objekt auswählen.")
# ------------------------------------------------------ Terrain-Werkzeug ----
# (Karte t_9aba04f1 — Höhenfeld heben/senken/glätten)
func _terrain_mode_button(text: String, group: ButtonGroup) -> Button:
var btn := Button.new()
btn.text = text
btn.toggle_mode = true
btn.button_group = group
btn.custom_minimum_size = Vector2(0, 40)
return btn
func _on_terrain_btn_pressed() -> void:
if terrain_edit_btn.button_pressed:
_set_tool_mode("terrain")
else:
_set_tool_mode("objects")
## Werkzeug-Modus wechseln: "objects" (Platzieren/Auswahl) ↔ "terrain" (Pinsel).
func _set_tool_mode(mode: String) -> void:
if mode == tool_mode:
_sync_terrain_button()
return
if mode == "terrain":
interaction.call("cancel_placement")
interaction.call("clear_selection", true)
if not terrain_tool.call("activate"):
# Abgelehnt (z. B. Auflösungs-Limit) — Button zurück, Meldung steht.
_sync_terrain_button()
return
tool_mode = "terrain"
terrain_panel.visible = true
help_label.text = "Linksklick-Ziehen: Boden formen · Rechtsklick-Ziehen: Gegenteil ·\nPinsel-Größe/Stärke rechts · Mittlere Maustaste: Kamera · Strg+Z: letzter Strich weg"
terrain_edit_btn.button_pressed = true
terrain_tool.call("set_brush_mode", _selected_terrain_mode())
_set_status("Terrain-Modus an: Ziehe mit LINKSKLICK über die Karte, um den Boden zu formen. Die Regler rechts stellen Pinsel und Stärke ein.")
else:
terrain_tool.call("deactivate")
tool_mode = "objects"
terrain_panel.visible = false
_sync_terrain_button()
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
_base_tool_status()
func _selected_terrain_mode() -> int:
if terrain_mode_lower.button_pressed:
return TerrainField.Mode.LOWER
if terrain_mode_smooth.button_pressed:
return TerrainField.Mode.SMOOTH
return TerrainField.Mode.RAISE
func _sync_terrain_button() -> void:
terrain_edit_btn.button_pressed = tool_mode == "terrain"
## Mode-Buttons (ButtonGroup) → Pinsel-Modus.
func _on_terrain_mode_changed(_pressed: bool) -> void:
if terrain_tool != null:
terrain_tool.call("set_brush_mode", _selected_terrain_mode())
func _on_terrain_radius_changed(value: float) -> void:
terrain_radius_label.text = "Pinsel-Größe: %d m" % int(value)
if terrain_tool != null:
terrain_tool.call("set_radius", value)
func _on_terrain_strength_changed(value: float) -> void:
terrain_strength_label.text = "Stärke: %.1f m pro Sekunde" % value
if terrain_tool != null:
terrain_tool.call("set_strength", value)
## Ein Pinsel-Strich ist fertig (Doc + History schon aktualisiert vom Tool).
func _on_terrain_stroke_committed() -> void:
dirty = true
_refresh_file_label()
_refresh_stats()
_refresh_history_buttons()
view_builder.call("rebuild_terrain_from_doc", doc)
var desc: String = history.undo_desc()
_set_status("%s — Strg+Z macht den ganzen Strich rückgängig." % desc)
## Terrain-Modus nach Laden/Neu/Undo mit dem Doc synchron halten (Cache frisch,
## Auflösungs-Limit prüfen — sonst dezenter Rückschalter).
func _recheck_terrain_mode() -> void:
if tool_mode != "terrain" or terrain_tool == null:
return
if not terrain_tool.call("refresh_from_doc"):
terrain_tool.call("deactivate")
tool_mode = "objects"
terrain_panel.visible = false
_sync_terrain_button()
help_label.text = "Linke Maustaste: auswählen & ziehen · R / Mausrad: drehen · Entf: löschen ·\nMittlere Maustaste: Kamera drehen (Shift: verschieben) · Strg+Z: zurück"
_set_status("Terrain-Modus aus: Diese Map passt nicht ins Editor-MVP (Terrain-Auflösung über %d) — die Objekte bleiben normal bearbeitbar." % TerrainField.EDITOR_MAX_RESOLUTION, true)
## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt. ## Der Baum ruft zurück, wenn der Nutzer dort ein Objekt auswählt.
func _on_tree_selected() -> void: func _on_tree_selected() -> void:
if _tree_select_lock: if _tree_select_lock:
@ -736,6 +929,7 @@ func _after_structure_change() -> void:
interaction.call("clear_selection", true) interaction.call("clear_selection", true)
view_builder.call("rebuild", doc, manifest) view_builder.call("rebuild", doc, manifest)
_refresh_tree() _refresh_tree()
_recheck_terrain_mode()
# --------------------------------------------------------------- Anzeige --- # --------------------------------------------------------------- Anzeige ---
@ -764,6 +958,7 @@ func _refresh_all() -> void:
_refresh_file_label() _refresh_file_label()
_refresh_history_buttons() _refresh_history_buttons()
_refresh_viewport() _refresh_viewport()
_recheck_terrain_mode()
func _refresh_viewport() -> void: func _refresh_viewport() -> void:
@ -810,6 +1005,17 @@ func _refresh_stats() -> void:
var budget := int(SvmapIO.MAX_TOTAL_POLY) var budget := int(SvmapIO.MAX_TOTAL_POLY)
stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%)" % [ stats_objects.text = "Objekte: %d / 5000 · Poly-Budget: %s / %s (%.0f %%)" % [
count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget] count, _fmt_int(int(poly)), _fmt_int(budget), 100.0 * poly / budget]
# Terrain-Anteil am Quest-3-Budget (res 513 = 524.288 Dreiecke, map_format.md §4)
var tris := 0
var res := 0
if view_builder != null and view_builder.get("terrain_view") != null:
tris = int(view_builder.terrain_view.call("triangle_count"))
res = int(view_builder.terrain_view.call("resolution"))
var quest_budget := 524288
var pct := 100.0 * float(tris) / float(quest_budget)
var fps := Engine.get_frames_per_second()
stats_terrain.text = " · Terrain: %s² Grid, %s Dreiecke (%.0f %% Quest-Budget) · %d FPS" % [
str(res) if res > 0 else "—", _fmt_int(tris), pct, fps]
func _refresh_file_label() -> void: func _refresh_file_label() -> void:
@ -843,6 +1049,9 @@ func _fmt_int(value: int) -> String:
## Screenshot die Vorschau zeigen kann (nur für Smoke-Tests). ## Screenshot die Vorschau zeigen kann (nur für Smoke-Tests).
func _maybe_start_demo_mode() -> void: func _maybe_start_demo_mode() -> void:
var args := OS.get_cmdline_user_args() var args := OS.get_cmdline_user_args()
if args.has("--terrain-demo"):
_start_terrain_demo(args)
return
if not args.has("--ghost-demo"): if not args.has("--ghost-demo"):
return return
var oid := "crate_wood" var oid := "crate_wood"
@ -875,4 +1084,77 @@ func _maybe_start_demo_mode() -> void:
camera.distance = 14.0 camera.distance = 14.0
camera.pitch_deg = 32.0 camera.pitch_deg = 32.0
camera.yaw_deg = 40.0 camera.yaw_deg = 40.0
camera.call("_apply") camera.call("_apply")
# ------------------------------------------------ Terrain-Demo-Screenshots --
## `godot ... -- --terrain-demo [ordner]` — Desktop-Smoke (Karte t_9aba04f1):
## fährt die Terrain-Szenarien programmatisch durch und speichert PNGs:
## Hügel heben, Senke senken, glätten, Undo-Roundtrip. Die App bleibt offen
## (Sly sieht das Ergebnis live auf dem Desktop).
func _start_terrain_demo(args: PackedStringArray) -> void:
var out_dir := "/tmp/scopedvr_terrain"
var idx := args.find("--terrain-demo")
if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"):
out_dir = args[idx + 1]
DirAccess.make_dir_recursive_absolute(out_dir)
_set_tool_mode("terrain")
terrain_radius_slider.value = 45.0
terrain_strength_slider.value = 10.0
terrain_mode_raise.button_pressed = true
# 1) Hügel heben — Pinsel-Ring bleibt als visueller Beweis im Bild.
terrain_tool.call("demo_stroke", -80.0, -60.0, TerrainField.Mode.RAISE, 1.4)
terrain_tool.call("demo_park_brush", -80.0, -60.0)
_demo_camera(Vector3(-80.0, 12.0, -60.0), 150.0, 32.0, 40.0)
_set_status("Demo 1/5: Hügel GEHOBEN (Radius 45 m, Stärke 10 m/s) — der grüne Ring ist der Pinsel.")
await _demo_shot(out_dir + "/terrain_raise.png")
# 2) Senke senken (roter Ring).
terrain_mode_lower.button_pressed = true
terrain_tool.call("demo_stroke", 90.0, 50.0, TerrainField.Mode.LOWER, 1.2)
terrain_tool.call("demo_park_brush", 90.0, 50.0)
_demo_camera(Vector3(90.0, 2.0, 50.0), 150.0, 32.0, -50.0)
_set_status("Demo 2/5: Senke GESSENKT (Rechtsklick-Ziehen senkt, ohne umzuschalten).")
await _demo_shot(out_dir + "/terrain_lower.png")
# 3) Hügelkuppe glätten (blauer Ring).
terrain_mode_smooth.button_pressed = true
terrain_tool.call("demo_stroke", -70.0, -50.0, TerrainField.Mode.SMOOTH, 0.9)
terrain_tool.call("demo_park_brush", -70.0, -50.0)
_demo_camera(Vector3(-70.0, 10.0, -50.0), 130.0, 35.0, 20.0)
_set_status("Demo 3/5: Kuppe GLÄTTET — rau wird weich, ohne die Form zu zerstören.")
await _demo_shot(out_dir + "/terrain_smooth.png")
# 4) Undo-Roundtrip: kleiner Strich → Undo → Boden wieder flach.
terrain_mode_raise.button_pressed = true
terrain_radius_slider.value = 30.0
terrain_tool.call("demo_stroke", 0.0, 150.0, TerrainField.Mode.RAISE, 0.9)
terrain_tool.call("demo_park_brush", 0.0, 150.0)
_demo_camera(Vector3(0.0, 8.0, 150.0), 120.0, 35.0, 0.0)
_set_status("Demo 4/5: Test-Hügel für den Undo-Vergleich geformt.")
await _demo_shot(out_dir + "/terrain_before_undo.png")
_do_undo()
_set_status("Demo 5/5: Strg+Z — der GANZE Strich ist zurück, der Boden ist wieder flach.")
await _demo_shot(out_dir + "/terrain_after_undo.png")
print("TERRAIN_DEMO_OK: 5 Screenshots in %s" % out_dir)
func _demo_camera(target: Vector3, dist: float, pitch: float, yaw: float) -> void:
camera.target = target
camera.distance = dist
camera.pitch_deg = pitch
camera.yaw_deg = yaw
camera.call("_apply")
func _demo_shot(path: String) -> void:
for i in 5:
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(path)
print("DEMO_SHOT: %s (%d×%d)" % [path, img.get_width(), img.get_height()])

View file

@ -1,6 +1,6 @@
extends Node3D extends Node3D
## Baut die visuelle Repräsentation der Map im Editor-Viewport: ## Baut die visuelle Repräsentation der Map im Editor-Viewport:
## Boden-Referenz (Plane in Terrain-Größe) + Maßstabsgitter + Himmel/Licht ## Terrain-Höhenfeld-Mesh + Maßstabsgitter + Himmel/Licht
## + Platzhalter-Meshes für Bibliotheks-Objekte + Spawn-Marker mit Beschriftung. ## + Platzhalter-Meshes für Bibliotheks-Objekte + Spawn-Marker mit Beschriftung.
## ## ## ##
## Objekt-Marker werden in object_markers gemerkt (Meta: obj_index, ## Objekt-Marker werden in object_markers gemerkt (Meta: obj_index,
@ -9,7 +9,6 @@ extends Node3D
const GRID_COLOR := Color(0.35, 0.45, 0.55, 0.6) const GRID_COLOR := Color(0.35, 0.45, 0.55, 0.6)
const AXIS_COLOR := Color(0.9, 0.7, 0.2, 0.9) const AXIS_COLOR := Color(0.9, 0.7, 0.2, 0.9)
const GROUND_COLOR := Color(0.23, 0.32, 0.24)
const OBJECT_COLOR := Color(0.55, 0.75, 0.95) const OBJECT_COLOR := Color(0.55, 0.75, 0.95)
const SELECT_COLOR := Color(1.0, 0.6, 0.15) # Auswahl klar orange const SELECT_COLOR := Color(1.0, 0.6, 0.15) # Auswahl klar orange
const TEAM_COLORS := [Color(0.3, 0.6, 1.0), Color(1.0, 0.35, 0.3)] # Blau / Rot const TEAM_COLORS := [Color(0.3, 0.6, 1.0), Color(1.0, 0.35, 0.3)] # Blau / Rot
@ -24,6 +23,7 @@ const WEATHER_LIGHT := {
var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt) var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt)
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects) var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
var _base_mat: StandardMaterial3D var _base_mat: StandardMaterial3D
var _select_mat: StandardMaterial3D var _select_mat: StandardMaterial3D
@ -57,12 +57,35 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
var weather := str(data.get("weather", "mittag")) var weather := str(data.get("weather", "mittag"))
_build_environment(weather) _build_environment(weather)
_build_ground(size_x, size_z, ground_y) _build_terrain(data)
_build_grid(size_x, size_z, ground_y) _build_grid(size_x, size_z, ground_y)
_build_objects(data.get("objects", []), manifest) _build_objects(data.get("objects", []), manifest)
_build_spawns(data.get("spawn_zones", {})) _build_spawns(data.get("spawn_zones", {}))
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
## der Terrain-Pinsel schreibt die Vertices live neu (terrain_view.update_terrain).
func _build_terrain(data: Dictionary) -> void:
terrain_view = Node3D.new()
terrain_view.set_script(load("res://src/viewport/terrain_mesh.gd"))
add_child(terrain_view)
terrain_view.call("rebuild_from_doc", data)
## Live-Update während eines Pinsel-Strichs (aus terrain_tool.gd): nur die
## Vertices werden neu geschrieben — Grid/Objekte/Marker bleiben unangetastet.
func update_terrain(heights: PackedFloat32Array) -> void:
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("update_heights", heights)
## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und
## Auswahl bleiben bestehen, kein Flackern der ganzen Szene.
func rebuild_terrain_from_doc(data: Dictionary) -> void:
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("rebuild_from_doc", data)
func _build_environment(weather: String) -> void: func _build_environment(weather: String) -> void:
var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"]) var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"])
var env := Environment.new() var env := Environment.new()
@ -90,19 +113,6 @@ func _build_environment(weather: String) -> void:
add_child(sun) add_child(sun)
func _build_ground(size_x: float, size_z: float, ground_y: float) -> void:
var mi := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(size_x, size_z)
mi.mesh = plane
mi.position.y = ground_y
var mat := StandardMaterial3D.new()
mat.albedo_color = GROUND_COLOR
mat.roughness = 1.0
mi.material_override = mat
add_child(mi)
## Maßstabsgitter: Linien alle 100 m, gelbe Mittelachsen (X rot? — Z-Achse ## Maßstabsgitter: Linien alle 100 m, gelbe Mittelachsen (X rot? — Z-Achse
## blau wäre Godot-Konvention; hier neutral gelb für gute Sichtbarkeit). ## blau wäre Godot-Konvention; hier neutral gelb für gute Sichtbarkeit).
func _build_grid(size_x: float, size_z: float, ground_y: float) -> void: func _build_grid(size_x: float, size_z: float, ground_y: float) -> void:

View file

@ -0,0 +1,122 @@
extends Node3D
## Terrain-Ansicht: baut das Höhenfeld (svmap terrain-Block) als Mesh im
## Editor-Viewport (M2, Karte t_9aba04f1).
##
## Zwei Wege:
## rebuild_from_doc(data) — nach Laden/Neu/Undo: Base64 dekodieren + Mesh neu.
## update_heights(...) — während eines Pinsel-Strichs: nur Vertices neu
## schreiben (SOFT-war das Mesh live — kein Rebuild,
## kein Flackern, res 129 ≈ 16,6k Punkte = flott).
##
## Höhen-Farbverlauf über Vertex-Farben (dunkles Grün im Tal → helles Grün auf
## dem Hügel): Sly sieht Steigungen sofort, ohne Licht-Tricks.
const COLOR_LOW := Color(0.16, 0.26, 0.16)
const COLOR_HIGH := Color(0.45, 0.62, 0.34)
var _mi: MeshInstance3D
var _res := 0
var _size_x := 0.0
var _size_z := 0.0
var _triangles := 0
func _ready() -> void:
_mi = MeshInstance3D.new()
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
mat.roughness = 1.0
_mi.material_override = mat
add_child(_mi)
## Kompletter Neuaufbau aus dem svmap-terrain-Block.
func rebuild_from_doc(data: Dictionary) -> void:
var terrain: Dictionary = data.get("terrain", {})
if terrain.is_empty():
_triangles = 0
if _mi != null:
_mi.mesh = null
return
var heights := SvmapIO.decode_heights(terrain)
update_heights(heights, int(terrain.get("resolution", 0)),
float(terrain.get("size_x_m", 1.0)), float(terrain.get("size_z_m", 1.0)))
## Mesh aus einem (neuen) Höhenfeld bauen — auch live während des Strichs.
## res/size 0 = zuletzt verwendete Werte weiterverwenden (Live-Pfad).
func update_heights(heights: PackedFloat32Array, res: int = 0, size_x: float = 0.0,
size_z: float = 0.0) -> void:
if res < 2:
res = _res
if size_x <= 0.0:
size_x = _size_x
if size_z <= 0.0:
size_z = _size_z
if _mi == null or res < 2 or heights.size() < res * res or size_x <= 0.0 or size_z <= 0.0:
return
_res = res
_size_x = size_x
_size_z = size_z
var last := float(res - 1)
var positions := PackedVector3Array()
var normals := PackedVector3Array()
var colors := PackedColorArray()
var indices := PackedInt32Array()
positions.resize(res * res)
normals.resize(res * res)
colors.resize(res * res)
var hmin := heights.min()
var hmax := heights.max()
var span := maxf(hmax - hmin, 0.0001)
var cell_x := size_x / last
var cell_z := size_z / last
var z := 0
while z < res:
var x := 0
while x < res:
var idx := z * res + x
positions[idx] = Vector3(
-x / last * size_x + size_x * 0.5,
heights[idx],
-z / last * size_z + size_z * 0.5)
# Normale aus zentralen Differenzen (Rand: einseitig geklemmt).
var hx_l := heights[z * res + maxi(x - 1, 0)]
var hx_r := heights[z * res + mini(x + 1, res - 1)]
var hz_d := heights[maxi(z - 1, 0) * res + x]
var hz_u := heights[mini(z + 1, res - 1) * res + x]
normals[idx] = Vector3(hx_l - hx_r, 2.0 * cell_x, hz_d - hz_u).normalized()
var t := (heights[idx] - hmin) / span
colors[idx] = COLOR_LOW.lerp(COLOR_HIGH, t)
x += 1
z += 1
z = 0
while z < res - 1:
var x := 0
while x < res - 1:
var i := z * res + x
indices.append_array([i, i + res, i + 1, i + 1, i + res, i + res + 1])
x += 1
z += 1
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = positions
arrays[Mesh.ARRAY_NORMAL] = normals
arrays[Mesh.ARRAY_COLOR] = colors
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
_mi.mesh = mesh
_triangles = indices.size() / 3
func triangle_count() -> int:
return _triangles
func resolution() -> int:
return _res

238
tests/test_terrain.gd Normal file
View file

@ -0,0 +1,238 @@
extends SceneTree
## Editor-Tests (headless): Karte t_9aba04f1 — Terrain-Werkzeug (Höhenfeld
## heben/senken/glätten, Pinsel-Mathematik, Stroke-Undo, svmap-Roundtrip).
## Ausführen: godot --headless --path . --script res://tests/test_terrain.gd
## Exit 0 = alles grün. GUI-Seiten (Ring sichtbar, Mesh folgt live) werden im
## Desktop-Smoke mit Screenshots verifiziert (--terrain-demo), nicht hier.
var failures := 0
var checks := 0
func _init() -> void:
_run_tests()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _run_tests() -> void:
_test_encode_roundtrip()
_test_brush_raise_lower()
_test_brush_smooth()
_test_stroke_undo_redo()
_test_range_expansion()
_test_svmap_roundtrip()
_test_resolution_limit()
## Decode → Encode bleibt innerhalb eines Quantisierungsschritts (R16).
func _test_encode_roundtrip() -> void:
print("Testgruppe: R16-Encode/Decode-Roundtrip")
var doc := SvmapDocument.create_new("Quant", 1024.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain)
_check(heights.size() == 129 * 129, "Neue Map: 129×129 Höhenpunkte (Editor-MVP-Limit)")
for i in heights.size():
heights[i] = 13.0 + float(i % 17) * 1.5
var b64 := TerrainField.encode(heights, 0.0, 50.0)
var terrain2: Dictionary = terrain.duplicate(true)
terrain2["heightmap_r16_b64"] = b64
var back := SvmapIO.decode_heights(terrain2)
var worst := 0.0
for i in heights.size():
worst = maxf(worst, absf(back[i] - heights[i]))
_check(worst <= 50.0 / 65535.0 + 0.0001,
"Encode-Fehler ≤ ½ Quantisierungsschritt (worst %.6f m)" % worst)
## Heben/Senken: Kosinus-Falloff, außerhalb des Radius nichts verändert,
## Senken unter min_height geht dank Range-Expansion (getestet separat).
func _test_brush_raise_lower() -> void:
print("Testgruppe: Pinsel heben/senken")
var res := 33
var size := 320.0
var heights := PackedFloat32Array()
heights.resize(res * res)
TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
0.0, 0.0, 40.0, TerrainField.Mode.RAISE, 10.0)
var center := heights[16 * res + 16]
var mid := heights[16 * res + 18] # 20 m vom Zentrum (Zelle = 10 m)
var far := heights[2 * res + 16] # 140 m weg — außerhalb des Radius
_check(center > 9.5, "Heben: Zentrum um ~10 m angehoben (ist %.2f)" % center)
_check(mid > 0.0 and mid < center, "Heben: Falloff zwischen Zentrum und Rand")
_check(far == 0.0, "Heben: außerhalb des Radius unverändert")
var before := heights.duplicate()
TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
0.0, 0.0, 40.0, TerrainField.Mode.LOWER, 4.0)
_check(heights[16 * res + 16] < center, "Senken: Zentrum wieder tiefer")
var diff := false
for i in heights.size():
if not before.is_empty() and absf(heights[i] - before[i]) > 0.0001:
diff = true
break
_check(diff, "Senken: Feld hat sich tatsächlich geändert")
## Glätten: Variance sinkt IN DER PINSEL-FLÄCHE, Form bleibt (Mittel-Mischung).
func _test_brush_smooth() -> void:
print("Testgruppe: Pinsel glätten")
var res := 33
var size := 320.0
var heights := PackedFloat32Array()
heights.resize(res * res)
# Zackiger Sägezahn in der Pinsel-Mitte.
for i in heights.size():
var x := i % res
heights[i] = 10.0 + (5.0 if x % 2 == 0 else -5.0)
var variance_before := _variance_block(heights, res, 14, 18, 14, 18)
var moved := false
for pass_i in 2:
moved = TerrainField.apply_brush(heights, res, size, size, 0.0, 100.0,
160.0, 160.0, 60.0, TerrainField.Mode.SMOOTH, 1.0) or moved
_check(moved, "Glätten: Feld wurde verändert")
var variance_after := _variance_block(heights, res, 14, 18, 14, 18)
_check(variance_after < variance_before * 0.4,
"Glätten: Varianz in der Pinsel-Fläche deutlich gesunken (%.1f → %.1f)" % [
variance_before, variance_after])
_check(absf(_mean(heights) - 10.0) < 1.0,
"Glätten: Gesamt-Mittelhöhe bleibt ~10 m (Form bleibt)")
## Der GANZE Stroke = EIN Undo-Eintrag (Chef-Kriterium): viele Einzel-Schritte
## auf derselben Stelle → Strg+Z stellt die Form VOR dem Strich wieder her.
func _test_stroke_undo_redo() -> void:
print("Testgruppe: Stroke-Undo (ganzer Strich)")
var doc := SvmapDocument.create_new("Stroke", 1024.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var terrain0: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain0)
# Ein Stroke = 12 Anwendungs-Schritte + EIN commit am Ende (wie terrain_tool).
var before: Dictionary = hist.snapshot(["terrain"])
for step in 12:
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, 0.0, 50.0,
0.0, 0.0, 60.0, TerrainField.Mode.RAISE, 0.8)
terrain0["heightmap_r16_b64"] = TerrainField.encode(heights, 0.0, 50.0)
hist.commit("Terrain geformt (Heben)", before, hist.snapshot(["terrain"]))
_check(hist.entries.size() == 1, "12 Pinsel-Schritte → EIN History-Eintrag")
var after_b64 := str(terrain0["heightmap_r16_b64"])
var raised := SvmapIO.decode_heights(terrain0)
_check(raised[64 * 129 + 64] > 5.0, "Stroke: Mitte ist deutlich angehoben")
var desc := hist.undo()
_check(desc == "Terrain geformt (Heben)", "Undo-Desc nennt den Stroke")
_check(str(terrain0["heightmap_r16_b64"]) != after_b64, "Undo: Höhenfeld geändert")
var restored := SvmapIO.decode_heights(terrain0)
_check(absf(restored[64 * 129 + 64]) < 0.01, "Undo: Boden wieder FLACH (ganzer Strich)")
desc = hist.redo()
_check(desc == "Terrain geformt (Heben)", "Redo-Desc stimmt")
_check(str(terrain0["heightmap_r16_b64"]) == after_b64,
"Redo: byte-identisches Höhenfeld zurück (kein Quantisierungs-Drift)")
## Range-Expansion: Senken unter min_height und Heben über max_height
## weiten min/max_height_m automatisch auf (Format-gültig, Spannweite ≤ 500).
func _test_range_expansion() -> void:
print("Testgruppe: Range-Expansion (Senken auf flacher Map)")
var doc := SvmapDocument.create_new("Dig", 1024.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain) # alles 0 = min_height
# Wie terrain_tool: ohne Clamp anwenden, Range danach expandieren.
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
0.0, 0.0, 50.0, TerrainField.Mode.LOWER, 6.0)
_check(heights[64 * 129 + 64] < -5.0, "Senken UNTER 0 m möglich (kein Clamp an min_height)")
var hmin := floorf(heights.min() - 1.0)
var hmax := maxf(float(terrain["max_height_m"]), ceilf(heights.max() + 1.0))
terrain["min_height_m"] = hmin
terrain["max_height_m"] = hmax
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax)
var path := "/tmp/scopedvr_test_terrain_dig.svmap"
var save := SvmapIO.save_to_file(path, doc)
_check(save["ok"], "Expandierte Map bleibt V1–V14-gültig (Speichern 0 Fehler)")
var load := SvmapIO.load_from_file(path)
_check(load["ok"], "Expandierte Map lädt sauber zurück (inkl. Prüfsumme)")
var reloaded := SvmapIO.decode_heights(load["data"]["terrain"])
_check(absf(reloaded[64 * 129 + 64] - heights[64 * 129 + 64]) < 0.01,
"Range-Expansion: Tiefe überlebt Encode/Decode")
## svmap-Roundtrip: geformtes Terrain speichern → laden → Höhenfeld byte-identisch,
## Prüfsumme gültig, Loader nimmt die Map an.
func _test_svmap_roundtrip() -> void:
print("Testgruppe: svmap-Roundtrip (Speichern/Laden)")
var doc := SvmapDocument.create_new("Roundtrip", 1024.0, "mittag")
var terrain: Dictionary = doc["terrain"]
var heights := SvmapIO.decode_heights(terrain)
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
-200.0, 100.0, 80.0, TerrainField.Mode.RAISE, 18.0)
TerrainField.apply_brush(heights, 129, 1024.0, 1024.0, -100000.0, 100000.0,
300.0, -300.0, 60.0, TerrainField.Mode.LOWER, 9.0)
terrain["min_height_m"] = floorf(heights.min() - 1.0)
terrain["max_height_m"] = ceilf(heights.max() + 1.0)
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, float(terrain["min_height_m"]), float(terrain["max_height_m"]))
var path := "/tmp/scopedvr_test_terrain_roundtrip.svmap"
var save := SvmapIO.save_to_file(path, doc)
_check(save["ok"], "Speichern: 0 Fehler (Validierung V1–V14 vor dem Schreiben)")
var load := SvmapIO.load_from_file(path)
_check(load["ok"], "Laden: 0 Fehler (inkl. Prüfsumme)")
var t_in: Dictionary = load["data"]["terrain"]
_check(str(t_in["heightmap_r16_b64"]) == str(terrain["heightmap_r16_b64"]),
"Höhenfeld-Diff LEER (byte-identisch nach Speichern→Laden)")
_check(absf(float(t_in["min_height_m"]) - float(terrain["min_height_m"])) < 0.0001
and absf(float(t_in["max_height_m"]) - float(terrain["max_height_m"])) < 0.0001,
"Höhen-Range bleibt erhalten")
func _test_resolution_limit() -> void:
print("Testgruppe: Auflösungs-Limit (Editor-MVP)")
_check(TerrainField.EDITOR_MAX_RESOLUTION == 129, "EDITOR_MAX_RESOLUTION = 129")
_check(SvmapDocument.DEFAULT_RESOLUTION == 129, "Neue Maps: res 129 (Perf-Limit)")
_check(SvmapIO.MAX_TERRAIN_RESOLUTION == 513, "Format-Limit 513 unverändert (Interop)")
func _variance_block(a: PackedFloat32Array, res: int, x0: int, x1: int, z0: int, z1: int) -> float:
var s := 0.0
var count := 0
var z := z0
while z <= z1:
var x := x0
while x <= x1:
s += a[z * res + x]
count += 1
x += 1
z += 1
var block_mean := s / float(count)
var sq := 0.0
z = z0
while z <= z1:
var x := x0
while x <= x1:
var d := a[z * res + x] - block_mean
sq += d * d
x += 1
z += 1
return sq / float(count)
func _mean(a: PackedFloat32Array) -> float:
var s := 0.0
for v in a:
s += v
return s / float(a.size())