extends Node3D ## Maus-Interaktion im Editor-Viewport (M2 UX-Fundament, Karte t_40d855d0): ## - Ghost-Preview: beim Platzieren schwebt eine transparente Vorschau am ## Cursor, Klick setzt das Objekt (kein "überraschtes" Ergebnis). ## - Auswahl: angeklickte Objekte färben sich orange, Ziehen verschiebt, ## R-Taste ODER Mausrad dreht (15°-Raster), Entf löscht. ## Alle Meldungen laufen über main._set_status — Klartext + nächsten Schritt ## vorschlagen (Chef-Grundsatz, map-editor-README). ## ## Lebt im selben SubViewport-Welt-Knoten wie Kamera + Viewport-Bauer; ## main.gd verdrahtet Referenzen per setup(). const ROT_STEP_DEG := 15.0 const DRAG_THRESHOLD_PX := 4.0 const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4) var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup()) var camera: Camera3D var view_builder # script-erweitert, Variant # Platzier-Modus var placing := false var place_oid := "" var place_rot_deg := 0.0 var _ghost: MeshInstance3D var _ghost_active := false # Ghost sichtbar weil Cursor über der Map ist # Auswahl + Greifen var selected_index := -1 var _dragging := false var _drag_moved := false var _press_pos := Vector2.ZERO var _grab_offset := Vector3.ZERO func setup(p_main, p_camera: Camera3D, p_builder) -> void: main = p_main camera = p_camera view_builder = p_builder ## Kamera fragt: dreht das Mausrad gerade ein Objekt/eine Vorschau ## (dann zoomt das Rad NICHT)? func wheel_blocked() -> bool: return placing or selected_index >= 0 func _ground_point(viewport_pos: Vector2) -> Variant: # Cursor-Strahl → Punkt auf der Boden-Referenzebene (Terrain-Basis). var terrain: Dictionary = main.doc.get("terrain", {}) if terrain.is_empty(): return null var plane_y := float(terrain.get("min_height_m", 0.0)) var origin := camera.project_ray_origin(viewport_pos) var dir := camera.project_ray_normal(viewport_pos) if absf(dir.y) < 0.0001: return null var t := (plane_y - origin.y) / dir.y if t <= 0.0: return null return origin + dir * t func _terrain_half() -> Vector2: var terrain: Dictionary = main.doc.get("terrain", {}) return Vector2(float(terrain.get("size_x_m", 1024.0)), float(terrain.get("size_z_m", 1024.0))) * 0.5 ## Cursor-Strahl → getroffenes Objekt (Kugel-Approximation der Marker). ## Rückgabe: Index in doc.objects oder -1. func _pick_object(viewport_pos: Vector2) -> int: var origin := camera.project_ray_origin(viewport_pos) var dir := camera.project_ray_normal(viewport_pos) var ground: Variant = _ground_point(viewport_pos) var ground_t := INF if ground != null: ground_t = (ground as Vector3).distance_to(origin) var best := -1 var best_t := INF for marker in view_builder.object_markers: if marker == null or not is_instance_valid(marker): continue var center: Vector3 = marker.global_position + Vector3.UP * float(marker.get_meta("half_height", 1.0)) var to_center := center - origin var tca := to_center.dot(dir) if tca <= 0.0: continue var d2 := to_center.length_squared() - tca * tca var radius := float(marker.get_meta("pick_radius", 1.0)) if d2 > radius * radius: continue var t := tca - sqrt(radius * radius - d2) if t < best_t and t < ground_t + 0.01: best = int(marker.get_meta("obj_index", -1)) best_t = t return best # ------------------------------------------------------------ Platzieren ---- func start_placement(oid: String) -> void: place_oid = oid placing = true place_rot_deg = 0.0 _ghost_active = false _ensure_ghost() _ensure_ghost_visible() var label: String = main.object_label(oid) main.call("_set_status", "%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, Rechtsklick/ESC bricht ab." % label) main.call("_highlight_library", oid) func cancel_placement() -> void: if _ghost != null and is_instance_valid(_ghost): _ghost.visible = false placing = false place_oid = "" _ghost_active = false main.call("_highlight_library", "") main.call("_base_tool_status") func _ensure_ghost() -> void: if _ghost != null and is_instance_valid(_ghost): return _ghost = MeshInstance3D.new() var mat := StandardMaterial3D.new() mat.albedo_color = Color(0.6, 0.9, 1.0, 0.45) mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.emission_enabled = true mat.emission = Color(0.2, 0.5, 0.8) * 0.4 _ghost.material_override = mat add_child(_ghost) _ghost.visible = false func _ghost_refresh_mesh() -> void: var lib: Dictionary = main.manifest.get("objects", {}) var entry: Dictionary = lib.get(place_oid, {}) var col: Dictionary = entry.get("collision", {}) var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0) _ghost.mesh = mesh _ghost.position.y = MeshShapes.half_height(mesh) func _ensure_ghost_visible() -> void: _ghost_refresh_mesh() _ghost.rotation.y = deg_to_rad(place_rot_deg) _ghost.visible = true ## Raster-Snap (wenn in main angeschaltet): X/Z auf ganze Meter rasten — ## Objekte passen dann nebeneinander wie Bauklötze. Höhe bleibt Terrain. func _apply_grid(pos: Vector3) -> Vector3: if not bool(main.get("grid_snap")): return pos pos.x = ObjectOps.snap_coord(pos.x, true, GRID_M) pos.z = ObjectOps.snap_coord(pos.z, true, GRID_M) return pos func _ghost_update(viewport_pos: Vector2) -> void: var p: Variant = _ground_point(viewport_pos) if p == null: _ghost_active = false _ghost.visible = false return _ghost_active = true var pos := _apply_grid(p as Vector3) var half := _terrain_half() pos.x = clampf(pos.x, -half.x, half.x) pos.z = clampf(pos.z, -half.y, half.y) pos.y = SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z) _ghost.position = pos _ghost.visible = true func _place_object(viewport_pos: Vector2) -> void: var p: Variant = _ground_point(viewport_pos) if p == null: main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true) return var pos: Vector3 = _apply_grid(p as Vector3) var half := _terrain_half() if absf(pos.x) > half.x or absf(pos.z) > half.y: main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true) return # Poly-Budget prüfen, BEVOR etwas passiert (hilfreiche Meldung statt Fehler). var lib: Dictionary = main.manifest.get("objects", {}) var entry: Dictionary = lib.get(place_oid, {}) var poly := float(entry.get("poly", 0)) var used := SvmapDocument.poly_usage(main.doc, main.manifest) var budget := float(SvmapIO.MAX_TOTAL_POLY) if used + poly > budget: main.call("_set_status", "Kein Platz mehr im Poly-Budget (%s von %s belegt) — lass ein Objekt weg oder drück Strg+Z, um etwas zurückzunehmen." % [main._fmt_int(int(used)), main._fmt_int(int(budget))], true) return var before: Dictionary = main.history.snapshot(["objects"]) var pos_f := [float(pos.x), float(SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z)), float(pos.z)] (main.doc["objects"] as Array).append({ "id": place_oid, "pos": pos_f, "scale": 1.0, "rot_y_deg": float(place_rot_deg), }) main.history.commit("%s platzieren" % main.object_label(place_oid), before, main.history.snapshot(["objects"])) var new_index: int = (main.doc["objects"] as Array).size() - 1 main.call("_after_structure_change") main.call("_set_status", "%s platziert. Tipp: anklicken zum Verschieben (Ziehen) und Drehen (R oder Mausrad) — Entf löscht es." % main.object_label(place_oid)) # Platzier-Modus bleibt aktiv (Mehrfach-Platzieren ohne erneutes Klicken), # Auswahl auf das frische Objekt legen? Nein — Ghost bleibt Vorrang; ESC/Rechtsklick beendet. main.call("_mark_tree_selection", new_index) # --------------------------------------------------------------- Auswahl ---- func select_object(idx: int, from_tree := false) -> void: selected_index = idx view_builder.call("set_selected", idx) if not from_tree: main.call("_mark_tree_selection", idx) main.call("_sync_selection_panel") main.call("_selection_status", idx) func clear_selection(silent := false) -> void: if selected_index != -1: selected_index = -1 view_builder.call("set_selected", -1) if not silent: main.call("_set_status", "Auswahl aufgehoben.") main.call("_sync_selection_panel") func rotate_selected(delta_deg: float) -> void: if selected_index < 0: return var objects: Array = main.doc.get("objects", []) if selected_index >= objects.size(): return var before: Dictionary = main.history.snapshot(["objects"]) var obj: Dictionary = objects[selected_index] var marker: Node3D = view_builder.object_markers[selected_index] if selected_index < view_builder.object_markers.size() else null var new_rot := fmod(float(obj.get("rot_y_deg", 0.0)) + delta_deg + 3600.0, 360.0) obj["rot_y_deg"] = float(new_rot) if marker != null and is_instance_valid(marker): marker.rotation.y = deg_to_rad(new_rot) main.history.commit("Objekt gedreht", before, main.history.snapshot(["objects"]), "rot:%d" % selected_index) main.call("_refresh_history_buttons") main.call("_refresh_stats") func delete_selected() -> void: if selected_index < 0: main.call("_set_status", "Nichts ausgewählt — klicke zuerst ein Objekt an, das gelöscht werden soll.", true) return var objects: Array = main.doc.get("objects", []) if selected_index >= objects.size(): clear_selection(true) return var before: Dictionary = main.history.snapshot(["objects"]) var label: String = main.object_label(str(objects[selected_index].get("id", ""))) objects.remove_at(selected_index) main.history.commit("%s löschen" % label, before, main.history.snapshot(["objects"])) clear_selection(true) main.call("_after_structure_change") main.call("_set_status", "%s gelöscht — Strg+Z (Rückgängig) holt es zurück." % label) # ------------------------------------------------------------ Eingaben ------ func _unhandled_input(event: InputEvent) -> void: var mode: String = main.get("tool_mode") if mode == "terrain" or mode == "spawns": return # Terrain-Pinsel / Spawn-Zonen haben das Zepter (eigene Werkzeuge) if event is InputEventMouseMotion: var mm := event as InputEventMouseMotion if placing: _ghost_update(mm.position) if _dragging and not placing and selected_index >= 0: var p: Variant = _ground_point(mm.position) if p == null: return var new_pos: Vector3 = _apply_grid((p as Vector3) + _grab_offset) var half := _terrain_half() new_pos.x = clampf(new_pos.x, -half.x, half.x) new_pos.z = clampf(new_pos.z, -half.y, half.y) new_pos.y = SvmapDocument.terrain_height_at(main.doc, new_pos.x, new_pos.z) var dist := Vector2(mm.position.x - _press_pos.x, mm.position.y - _press_pos.y).length() if dist > DRAG_THRESHOLD_PX: _drag_moved = true if _drag_moved: var marker: Node3D = view_builder.object_markers[selected_index] if marker != null and is_instance_valid(marker): marker.position = new_pos return if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: _on_left_press(mb) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.pressed: _on_left_release(mb) elif mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: if placing: cancel_placement() main.call("_set_status", "Platzieren abgebrochen.") get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: if placing: place_rot_deg = fmod(place_rot_deg + ROT_STEP_DEG + 3600.0, 360.0) _ghost.rotation.y = deg_to_rad(place_rot_deg) get_viewport().set_input_as_handled() elif selected_index >= 0: rotate_selected(ROT_STEP_DEG) get_viewport().set_input_as_handled() elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: if placing: place_rot_deg = fmod(place_rot_deg - ROT_STEP_DEG + 3600.0, 360.0) _ghost.rotation.y = deg_to_rad(place_rot_deg) get_viewport().set_input_as_handled() elif selected_index >= 0: rotate_selected(-ROT_STEP_DEG) get_viewport().set_input_as_handled() func _on_left_press(mb: InputEventMouseButton) -> void: if placing: _place_object(mb.position) return _press_pos = mb.position var hit := _pick_object(mb.position) if hit >= 0: if selected_index != hit: select_object(hit) _dragging = true _drag_moved = false var p: Variant = _ground_point(mb.position) var marker: Node3D = view_builder.object_markers[selected_index] if p != null and marker != null and is_instance_valid(marker): _grab_offset = marker.position - (p as Vector3) _grab_offset.y = 0.0 else: _grab_offset = Vector3.ZERO else: # Leere Stelle: Auswahl aufheben (klicken statt auswendig lernen). if selected_index != -1: clear_selection() func _on_left_release(_mb: InputEventMouseButton) -> void: if _dragging: _dragging = false if _drag_moved and selected_index >= 0: var objects: Array = main.doc.get("objects", []) var marker: Node3D = null if selected_index < view_builder.object_markers.size(): marker = view_builder.object_markers[selected_index] if selected_index < objects.size() and marker != null and is_instance_valid(marker): var before: Dictionary = main.history.snapshot(["objects"]) var pos := marker.position objects[selected_index]["pos"] = [float(pos.x), float(pos.y), float(pos.z)] main.history.commit("Objekt verschoben", before, main.history.snapshot(["objects"]), "move:%d" % selected_index) main.call("_refresh_history_buttons") _drag_moved = false # ---------------------------------------------------- Screenshot-Hilfe ------ ## Demo-Hook (Desktop-Smoke): Ghost außerhalb echter Maus-Ereignisse parken, ## damit ein Screenshot die Vorschau zeigen kann. func demo_park_ghost(world_pos: Vector3, oid: String) -> void: start_placement(oid) placing = true place_oid = oid _ghost_refresh_mesh() _ghost.position = world_pos _ghost.rotation.y = deg_to_rad(place_rot_deg) _ghost.visible = true _ghost_active = true main.call("_highlight_library", oid) main.call("_set_status", "%s: führe die Maus über die Karte — Klick setzt es. Mausrad dreht vorher, Rechtsklick/ESC bricht ab." % main.object_label(oid))