scopedvr-map-editor/src/editor/build_tool.gd
Hermes (godot-vr) 7521db7f8d Map-Editor: RMB = Flug mit absolutem Vorrang (t_2470de89) — Terrain-Senken auf Strg+LMB, Hand-Objekt-Abwurf auf Esc.
- Terrain-Pinsel: Rechtsklick startet keinen Strich mehr; Strg+LMB invertiert
  (Heben↔Senken, Blender/Photoshop-Konvention; Malen: Strg+Klick = Gras zurück).
- Platzieren: RMB bricht nicht mehr ab — Objekt bleibt in der Hand, Abwurf nur
  über Esc. build_tool/spawn_tool: RMB-Deselect/Werkzeugwechsel entfernt
  (Auswahl-Deselect = Esc / LMB auf Leere).
- orbit_camera: rmb_filter entfernt — Flug startet in JEDEM Werkzeugzustand.
- docs/tasten.md + H-Overlay + Status-/Paneltexte aktualisiert.
- Neuer Test tests/test_rmb_vorrang.gd (32 Checks, echte Main-UI headless);
  in tools/run_tests.sh aufgenommen. Suite grün (test_glb_view: 12/22 FAIL auch
  auf origin/main — fehlende GLB-Quellen im Scratch-Workspace, umgebungsbedingt).
2026-10-17 17:37:09 +00:00

381 lines
14 KiB
GDScript

extends Node3D
## Bauen-Werkzeug (Karte t_97ea59c5): Modular-Builder-Pieces in der App setzen —
## Fundament/Wand/Dach/Treppe/Tür/Fenster auf einem 2-m-Modulraster.
##
## Bedienung (Chef-Grundsatz Sly: easy, deutsch):
## Bauteil rechts anklicken → Vorschau folgt dem Cursor (rastet aufs Raster)
## Linksklick → Bauteil setzen (auch AUF andere Teile — zum
## Stapeln: Wand oben drauf, Dach auf die Wände)
## Umschalt+Klick → gesetztes Bauteil AUSWÄHLEN (orange Rahmen)
## R → Vorschau drehen (90°) / Auswahl drehen
## Bild↑/Bild↓ → Auswahl anheben/absenken (0,2 m — Wände aufs
## Fundament setzen)
## Entf → Auswahl löschen
## ESC → Auswahl weg, nochmal = Werkzeug beenden
## (Rechte Maustaste = immer Flug, t_2470de89)
##
## UNDO: EIN Rückgängig-Schritt PRO Bauteil (ActionHistory, Block "gebaeude").
const MODE_HELP := "Linksklick: Bauteil setzen · Umschalt+Klick: Bauteil auswählen · R: drehen (90°) ·\nBild↑/↓: anheben/absenken · Entf: löschen · Mittlere Maustaste: Kamera · Strg+Z: zurück"
var main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D
var view_builder # Welt-Bauer (script-erweitert, Variant)
var interaction # Objekt-Interaktion (für Auswahl-Entkoppelung)
var active := false
var kind_id := "fundament"
var ghost_rot := 0.0 # 90°-Schritte
var selected_index := -1
var _ghost: MeshInstance3D
var _cursor_valid := false
var _cursor := Vector3.ZERO
# Terrain-Cache für den Boden-Raycast (lesen)
var _heights := PackedFloat32Array()
var _res := 0
var _sx := 0.0
var _sz := 0.0
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_ghost()
func _ensure_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
return
_ghost = MeshInstance3D.new()
_ghost.visible = false
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.75, 0.2, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.emission_enabled = true
mat.emission = Color(1.0, 0.6, 0.1) * 0.3
_ghost.material_override = mat
add_child(_ghost)
# ------------------------------------------------------------ Aktivierung ---
func activate() -> bool:
active = true
selected_index = -1
ghost_rot = 0.0
_cursor_valid = false
_refresh_from_doc()
_rebuild_ghost_mesh()
if view_builder != null:
view_builder.call("set_gebaeude_selected", -1)
return true
func deactivate() -> void:
active = false
selected_index = -1
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
if view_builder != null:
view_builder.call("set_gebaeude_selected", -1)
## Nach Undo/Redo/Laden/Neu: Cache frisch, Auswahl nur halten, wenn das Teil
## noch existiert.
func refresh_from_doc() -> void:
_refresh_from_doc()
_rebuild_ghost_mesh()
if selected_index >= 0 and selected_index >= BuildOps.piece_count(main.doc):
selected_index = -1
if active:
view_builder.call("set_gebaeude_selected", selected_index)
func _refresh_from_doc() -> void:
var terrain: Dictionary = main.doc.get("terrain", {})
_res = int(terrain.get("resolution", 0))
_sx = float(terrain.get("size_x_m", 0.0))
_sz = float(terrain.get("size_z_m", 0.0))
if _res >= 2 and _sx > 0.0 and _sz > 0.0:
_heights = SvmapIO.decode_heights(terrain)
else:
_heights = PackedFloat32Array()
# ------------------------------------------------------------- Werkzeug-UI --
func set_kind(next_kind: String) -> void:
if not BuildOps.KINDS.has(next_kind):
return
kind_id = next_kind
selected_index = -1
if view_builder != null:
view_builder.call("set_gebaeude_selected", -1)
_rebuild_ghost_mesh()
if active:
main.call("_set_status", "%s ausgewählt — Linksklick setzt es aufs Raster (auch auf andere Bauteile zum Stapeln). Umschalt+Klick wählt gesetzte Teile aus." % BuildOps.kind_label(kind_id))
func _rebuild_ghost_mesh() -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
var probe := BuildOps.make_piece(kind_id, Vector3.ZERO, 0.0)
_ghost.mesh = BuildOps.piece_mesh(probe)
# --------------------------------------------------------------- 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 and mb.pressed:
if mb.shift_pressed:
_select_at(mb.position)
else:
_place_piece()
get_viewport().set_input_as_handled()
# Rechte Maustaste: absichtlich NICHT behandelt (Karte t_2470de89) —
# sie gehört mit absolutem Vorrang dem Freelook-Flug. Deselect/Werkzeug
# beenden läuft über ESC (main.gd), die Auswahl bleibt im Flug erhalten.
func _update_cursor(viewport_pos: Vector2) -> void:
var hit: Variant = _ray_hit(viewport_pos)
if hit == null:
_cursor_valid = false
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
return
_cursor_valid = true
_cursor = hit as Vector3
if _ghost != null and is_instance_valid(_ghost):
_ghost.position = BuildOps.snapped_pos(kind_id, _cursor)
_ghost.rotation.y = deg_to_rad(ghost_rot)
_ghost.visible = true
## Strahl → Trefferpunkt: ZUERST auf die gebackenen Bauteile (Stapeln!),
## sonst auf das Terrain. Entfernung entscheidet.
func _ray_hit(viewport_pos: Vector2) -> Variant:
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
var best: Variant = null
var best_t := INF
var baked: Dictionary = view_builder.call("gebaeude_baked")
if not baked.get("faces", PackedVector3Array()).is_empty():
var pick := BuildOps.ray_pick(baked, origin, dir)
if not pick.is_empty():
best_t = (origin - (pick["punkt"] as Vector3)).length()
best = pick["punkt"]
var terrain: Dictionary = main.doc.get("terrain", {})
if _res >= 2 and _heights.size() >= _res * _res:
var ground: Variant = TerrainField.raycast(_heights, _res, _sx, _sz, origin, dir)
if ground != null:
var t := (origin - (ground as Vector3)).length()
if t < best_t:
best = ground
return best
# ------------------------------------------------------------ Platzieren ----
func _place_piece() -> void:
if not _cursor_valid:
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return
var before: Dictionary = main.history.snapshot(["gebaeude"])
var pos: Vector3 = BuildOps.snapped_pos(kind_id, _cursor)
var teil := BuildOps.make_piece(kind_id, pos, ghost_rot)
if teil.is_empty():
return
BuildOps.ensure_block(main.doc).append(teil)
var label := BuildOps.kind_label(kind_id)
main.history.commit("%s gesetzt" % label, before, main.history.snapshot(["gebaeude"]))
_after_build_change()
main.call("_set_status", "%s gesetzt (%d Bauteile). Weiter klicken zum Bauen — R dreht, Strg+Z nimmt das letzte Teil zurück." % [
label, BuildOps.piece_count(main.doc)])
func _select_at(viewport_pos: Vector2) -> void:
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
var baked: Dictionary = view_builder.call("gebaeude_baked")
var pick := BuildOps.ray_pick(baked, origin, dir)
if pick.is_empty():
selected_index = -1
view_builder.call("set_gebaeude_selected", -1)
main.call("_set_status", "Kein Bauteil getroffen — Umschalt+Klick direkt AUF ein Bauteil (Wand, Dach …).")
return
selected_index = int(pick["piece"])
view_builder.call("set_gebaeude_selected", selected_index)
var label := _piece_label(selected_index)
main.call("_set_status", "%s ausgewählt — R drehen · Bild↑/↓ anheben · Entf löschen · Esc: Auswahl weg." % label)
func _piece_label(idx: int) -> String:
var list := BuildOps.pieces(main.doc)
if idx < 0 or idx >= list.size():
return "Bauteil"
return "%s (Teil %d von %d)" % [BuildOps.kind_label(str((list[idx] as Dictionary).get("id", "?"))), idx + 1, list.size()]
func rotate_selected() -> void:
if selected_index < 0:
return
var list := BuildOps.pieces(main.doc)
if selected_index >= list.size():
return
var teil: Dictionary = list[selected_index]
var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["rot"] = wrapf(float(teil.get("rot", 0.0)) + 90.0, 0.0, 360.0)
main.history.commit("%s gedreht (90°)" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"]), "bauen-rot:%d" % selected_index)
_after_build_change()
view_builder.call("set_gebaeude_selected", selected_index)
## Auswahl anheben/absenken (0,2 m — passend zur Fundament-Dicke), merge-gekoppelt.
func lift_selected(delta_m: float) -> void:
if selected_index < 0:
return
var list := BuildOps.pieces(main.doc)
if selected_index >= list.size():
return
var teil: Dictionary = list[selected_index]
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["pos"] = [float(pos[0]), BuildOps.snap_y(float(pos[1]) + delta_m), float(pos[2])]
main.history.commit("%s %s" % [_piece_label(selected_index),
"angehoben" if delta_m > 0.0 else "abgesenkt"],
before, main.history.snapshot(["gebaeude"]), "bauen-lift:%d" % selected_index)
_after_build_change()
view_builder.call("set_gebaeude_selected", selected_index)
## Pfeiltasten (Karte t_55257792): Bauteil im 2-m-Modulraster verschieben —
## EINE Zelle pro Tastendruck. Das exakte ±2-m-Delta erhält die Raster-
## Ausrichtung ("ecke" = gerade Meter, "mitte" = ungerade Meter bleibt), Y
## bleibt (Höhe ändert die Pfeiltaste nicht). EIN Undo-Schritt pro Tastendruck.
func nudge_selected(delta: Vector3) -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus, dann verschieben die Pfeiltasten es im 2-m-Raster.")
return
var list := BuildOps.pieces(main.doc)
if selected_index >= list.size():
selected_index = -1
return
var teil: Dictionary = list[selected_index]
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var before: Dictionary = main.history.snapshot(["gebaeude"])
var nx := float(pos[0]) + delta.x
var nz := float(pos[2]) + delta.z
teil["pos"] = [nx, float(pos[1]), nz]
main.history.commit("%s verschoben (Pfeiltaste)" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"]))
_after_build_change()
view_builder.call("set_gebaeude_selected", selected_index)
main.call("_set_status", "%s — 2 m pro Tastendruck, Raster bleibt eingehalten." % _piece_label(selected_index))
## Strg+Bild↑ (Karte t_55257792): Bauteil direkt auf das Terrain absetzen
## (Fußpunkt = Terrain-Höhe, auf 5 cm gerastet). EIN Undo-Schritt.
func snap_selected_to_floor() -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus, dann setzt Strg+Bild↑ es auf den Boden.", true)
return
var list := BuildOps.pieces(main.doc)
if selected_index >= list.size():
selected_index = -1
return
var teil: Dictionary = list[selected_index]
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var ground := SvmapDocument.terrain_height_at(main.doc, float(pos[0]), float(pos[2]))
var target := BuildOps.snap_y(ground)
if absf(float(pos[1]) - target) < 0.0001:
main.call("_set_status", "%s liegt schon auf dem Boden." % _piece_label(selected_index))
return
var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["pos"] = [float(pos[0]), target, float(pos[2])]
main.history.commit("%s auf den Boden gesetzt" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"]))
_after_build_change()
view_builder.call("set_gebaeude_selected", selected_index)
main.call("_set_status", "%s auf den Boden gesetzt — Strg+Z nimmt es zurück." % _piece_label(selected_index))
func delete_selected() -> void:
if selected_index < 0:
main.call("_set_status", "Nichts ausgewählt — Umschalt+Klick wählt ein Bauteil aus (Entf löscht es dann).", true)
return
var list: Array = BuildOps.pieces(main.doc)
if selected_index >= list.size():
selected_index = -1
return
var label := _piece_label(selected_index)
var before: Dictionary = main.history.snapshot(["gebaeude"])
list.remove_at(selected_index)
main.history.commit("%s gelöscht" % label, before, main.history.snapshot(["gebaeude"]))
selected_index = -1
_after_build_change()
main.call("_set_status", "%s — Strg+Z holt es zurück." % label.replace(" (Teil", " gelöscht (Teil"))
func _after_build_change() -> void:
main.call("_after_structure_change")
if selected_index >= 0 and selected_index < BuildOps.piece_count(main.doc):
view_builder.call("set_gebaeude_selected", selected_index)
## R-Taste: Auswahl drehen — sonst die Vorschau (90°-Raster).
func rotate_input() -> void:
if selected_index >= 0:
rotate_selected()
return
ghost_rot = wrapf(ghost_rot + 90.0, 0.0, 360.0)
if _ghost != null and is_instance_valid(_ghost):
_ghost.rotation.y = deg_to_rad(ghost_rot)
main.call("_set_status", "Vorschau gedreht auf %.0f° — Linksklick setzt das Bauteil so." % ghost_rot)
## Teil per Baum/Index anwählen (ohne Maus — auch für Demos/Tests).
func select_index(idx: int) -> void:
if idx < 0 or idx >= BuildOps.piece_count(main.doc):
selected_index = -1
view_builder.call("set_gebaeude_selected", -1)
return
selected_index = idx
view_builder.call("set_gebaeude_selected", idx)
main.call("_set_status", "%s ausgewählt — R drehen · Bild↑/↓ anheben · Entf löschen · Rechtsklick: Auswahl weg." % _piece_label(idx))
# ---------------------------------------------------------- Demo/Headless ---
## Demo-Hook: Bauteil programmatisch setzen (inkl. History) — exakt wie Klick.
func demo_place(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> Dictionary:
var before: Dictionary = main.history.snapshot(["gebaeude"])
var teil := BuildOps.make_piece(p_kind, pos, rot_deg)
BuildOps.ensure_block(main.doc).append(teil)
main.history.commit("%s gesetzt" % BuildOps.kind_label(p_kind),
before, main.history.snapshot(["gebaeude"]))
return teil
## Demo-Hook: Ghost an Position parken (für Screenshots ohne Maus).
func demo_park_ghost(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> void:
set_kind(p_kind)
_cursor = pos
_cursor_valid = true
if _ghost != null and is_instance_valid(_ghost):
_ghost.position = BuildOps.snapped_pos(p_kind, pos)
_ghost.rotation.y = deg_to_rad(rot_deg)
_ghost.visible = true