- 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).
381 lines
14 KiB
GDScript
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
|