class_name StudioMain extends Control ## Standalone ModBison Studio Application Shell. ## Implements multi-panel docking layout, top menu bar with keyboard shortcuts, ## 3D interactive viewport with raycast picking, gizmos, hierarchy, inspector, ## collections dock, quick insert toolbar, instructions HUD, and simulation harness. const MBWorldProjectIOClass = preload("res://scripts/studio/mbworld_project_io.gd") const StudioViewportCameraClass = preload("res://scripts/studio/studio_viewport_camera.gd") const TransformGizmo3DClass = preload("res://scripts/studio/gizmos/transform_gizmo_3d.gd") const StudioHierarchyDockClass = preload("res://scripts/studio/studio_hierarchy_dock.gd") const StudioInspectorDockClass = preload("res://scripts/studio/studio_inspector_dock.gd") const StudioCollectionsDockClass = preload("res://scripts/studio/studio_collections_dock.gd") const StudioSimulationManagerClass = preload("res://scripts/studio/studio_simulation_manager.gd") const StudioHelpDialogClass = preload("res://scripts/studio/studio_help_dialog.gd") const StaticObjectNodeClass = preload("res://scripts/static_object_node.gd") const WorldObjectClass = preload("res://scripts/world_object.gd") # Custom SubViewportContainer supporting drag & drop model placement and click picking class StudioViewportContainer extends SubViewportContainer: var studio_main: Node = null func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: return data is Dictionary and data.get("type") == "model_placement" func _drop_data(at_position: Vector2, data: Variant) -> void: if studio_main != null and studio_main.has_method("handle_viewport_drop"): studio_main.handle_viewport_drop(at_position, data) func _gui_input(event: InputEvent) -> void: if studio_main != null and studio_main.has_method("handle_viewport_input"): studio_main.handle_viewport_input(event) # Core state var core: EditorCore = null var current_project_path: String = "" var project_title: String = "Untitled World" # Top Menu & Toolbar var _menu_bar: MenuBar var _toolbar: HBoxContainer var _btn_select: Button var _btn_move: Button var _btn_rotate: Button var _btn_scale: Button var _btn_play: Button var _btn_stop: Button var _snap_spin: SpinBox # Main Viewport & 3D Elements var _vp_container: StudioViewportContainer var _sub_viewport: SubViewport var _camera_3d: Camera3D var _world_container: Node3D var _gizmo_3d: Node3D var _baseplate_mesh: MeshInstance3D # Drag & Drop / Transformation Interaction State var _is_dragging: bool = false var _drag_axis: int = 0 var _drag_start_mouse: Vector2 = Vector2.ZERO var _drag_start_ground: Vector3 = Vector3.ZERO var _drag_start_node_pos: Vector3 = Vector3.ZERO var _drag_start_node_rot_deg: Vector3 = Vector3.ZERO var _drag_start_node_scale: Vector3 = Vector3.ONE var _drag_accum_y: float = 0.0 var _drag_accum_rot: float = 0.0 var _drag_accum_scale: float = 0.0 # Docks & Dialogs var _hierarchy_dock = null var _inspector_dock = null var _collections_dock = null var _simulation_mgr = null var _help_dialog = null var _status_bar_label: Label var _is_built: bool = false func _init() -> void: name = "StudioMain" if not _is_built: _init_core_model() _build_studio_layout() func _ready() -> void: if not _is_built: _init_core_model() _build_studio_layout() _setup_shortcuts() _parse_cmdline_arguments() # If the world has no objects yet, load the Baseplate starter template automatically if core != null and core.model != null and core.model.payload.objects.is_empty(): load_template("baseplate") # Show Help & Quick Start Guide on first launch if enabled in settings if _help_dialog != null and StudioHelpDialogClass.should_show_on_startup(): _help_dialog.visible = true _help_dialog.move_to_front() func _init_core_model() -> void: var payload := ChunkDataDecoder.ChunkPayload.new() var layer := ChunkDataDecoder.TileLayerData.new() layer.name = "ground" layer.layer_id = 0 layer.width = 32 layer.height = 32 layer.depth = 1 layer.tiles = PackedInt32Array() layer.tiles.resize(32 * 32) payload.tile_layers.append(layer) var model := ChunkEditorModel.new(payload) var catalog := EditorObjectCatalog.new() catalog.load_catalog() core = EditorCore.new(model, catalog) func _build_studio_layout() -> void: if _is_built: return _is_built = true set_anchors_preset(Control.PRESET_FULL_RECT) var bg := ColorRect.new() bg.set_anchors_preset(Control.PRESET_FULL_RECT) bg.color = Color(0.13, 0.14, 0.17) add_child(bg) var main_vbox := VBoxContainer.new() main_vbox.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(main_vbox) # 1. Top Menu Bar _build_top_menu_bar(main_vbox) # 2. Main Action Toolbar (Tools, Snap, Quick Insert, Simulation, Help) _build_toolbar(main_vbox) # 3. Center Workspace (Horizontal Split: Left Dock | Center Viewport | Right Docks) var workspace_hbox := HBoxContainer.new() workspace_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL main_vbox.add_child(workspace_hbox) # Left Dock: Scene Hierarchy var left_panel := PanelContainer.new() left_panel.custom_minimum_size = Vector2(240, 0) left_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL _hierarchy_dock = StudioHierarchyDockClass.new() _hierarchy_dock.setup(core) _hierarchy_dock.delete_requested.connect(_delete_selected_object) left_panel.add_child(_hierarchy_dock) workspace_hbox.add_child(left_panel) # Center: 3D Viewport with interactive picking & drop support _vp_container = StudioViewportContainer.new() _vp_container.studio_main = self _vp_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL _vp_container.size_flags_vertical = Control.SIZE_EXPAND_FILL _vp_container.stretch = true workspace_hbox.add_child(_vp_container) _sub_viewport = SubViewport.new() _sub_viewport.handle_input_locally = true _vp_container.add_child(_sub_viewport) _setup_3d_viewport_scene(_sub_viewport) _setup_viewport_hud_overlay(_vp_container) # Right Dock: TabContainer with Inspector & Collections var right_panel := PanelContainer.new() right_panel.custom_minimum_size = Vector2(300, 0) right_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL var tabs := TabContainer.new() tabs.size_flags_horizontal = Control.SIZE_EXPAND_FILL tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL _inspector_dock = StudioInspectorDockClass.new() _inspector_dock.setup(core) _inspector_dock.property_changed.connect(_on_inspector_property_changed) _inspector_dock.delete_requested.connect(_delete_selected_object) tabs.add_child(_inspector_dock) tabs.set_tab_title(0, "Inspector") _collections_dock = StudioCollectionsDockClass.new() _collections_dock.model_selected.connect(_on_collection_model_selected) tabs.add_child(_collections_dock) tabs.set_tab_title(1, "Collections") right_panel.add_child(tabs) workspace_hbox.add_child(right_panel) # 4. Bottom Status Bar var status_panel := PanelContainer.new() status_panel.custom_minimum_size = Vector2(0, 24) _status_bar_label = Label.new() _status_bar_label.text = "ModBison Studio Ready | Baseplate Loaded | Press [F1] for Quick Start Guide" _status_bar_label.add_theme_font_size_override("font_size", 10) status_panel.add_child(_status_bar_label) main_vbox.add_child(status_panel) # Simulation Manager _simulation_mgr = StudioSimulationManagerClass.new() _simulation_mgr.setup(core, _world_container) _simulation_mgr.simulation_started.connect(func(_m): _update_simulation_ui(true)) _simulation_mgr.simulation_stopped.connect(func(): _update_simulation_ui(false)) add_child(_simulation_mgr) # Help Dialog Overlay _help_dialog = StudioHelpDialogClass.new() _help_dialog.visible = false _help_dialog.set_anchors_preset(Control.PRESET_CENTER) add_child(_help_dialog) func _build_top_menu_bar(parent: Control) -> void: _menu_bar = MenuBar.new() _menu_bar.custom_minimum_size = Vector2(0, 28) # File Menu var file_menu := PopupMenu.new() file_menu.name = "File" file_menu.add_item("New Project (Baseplate)", 0, KEY_MASK_CTRL | KEY_N) file_menu.add_item("Open Project...", 1, KEY_MASK_CTRL | KEY_O) file_menu.add_separator() file_menu.add_item("Save Project (Ctrl+S)", 2, KEY_MASK_CTRL | KEY_S) file_menu.add_item("Save As...", 3) file_menu.add_separator() file_menu.add_item("Publish to Cloud...", 4) file_menu.add_separator() file_menu.add_item("Return to Main Menu", 5) file_menu.add_item("Exit Studio (Quit)", 6) file_menu.id_pressed.connect(_on_file_menu_pressed) _menu_bar.add_child(file_menu) # Edit Menu var edit_menu := PopupMenu.new() edit_menu.name = "Edit" edit_menu.add_item("Undo", 0, KEY_MASK_CTRL | KEY_Z) edit_menu.add_item("Redo", 1, KEY_MASK_CTRL | KEY_Y) edit_menu.add_separator() edit_menu.add_item("Duplicate", 2, KEY_MASK_CTRL | KEY_D) edit_menu.add_item("Delete", 3, KEY_DELETE) edit_menu.id_pressed.connect(_on_edit_menu_pressed) _menu_bar.add_child(edit_menu) # View Menu var view_menu := PopupMenu.new() view_menu.name = "View" view_menu.add_item("Perspective", 0, KEY_P) view_menu.add_item("Top View", 1, KEY_7) view_menu.add_item("Front View", 2, KEY_1) view_menu.add_item("Side View", 3, KEY_3) view_menu.add_separator() view_menu.add_item("Focus Selection", 4, KEY_F) view_menu.id_pressed.connect(_on_view_menu_pressed) _menu_bar.add_child(view_menu) # Model Menu var model_menu := PopupMenu.new() model_menu.name = "Model" model_menu.add_item("Add ModBison", 0) model_menu.add_item("Add Wooden Crate", 1) model_menu.add_item("Add Pine Tree", 2) model_menu.add_item("Add Street Lamp", 3) model_menu.add_item("Add Treasure Chest", 4) model_menu.add_item("Add Buffalo Chip Coin", 5) model_menu.id_pressed.connect(_on_model_menu_pressed) _menu_bar.add_child(model_menu) # Test Menu var test_menu := PopupMenu.new() test_menu.name = "Test" test_menu.add_item("Play Solo", 0, KEY_F5) test_menu.add_item("Run Simulation", 1, KEY_F6) test_menu.add_item("Stop Playtest", 2, KEY_MASK_SHIFT | KEY_F5) test_menu.id_pressed.connect(_on_test_menu_pressed) _menu_bar.add_child(test_menu) # Publish Menu var pub_menu := PopupMenu.new() pub_menu.name = "Publish" pub_menu.add_item("Export .mbworld Package", 0) pub_menu.add_item("Sync to Game Server", 1) pub_menu.id_pressed.connect(_on_publish_menu_pressed) _menu_bar.add_child(pub_menu) # Help Menu var help_menu := PopupMenu.new() help_menu.name = "Help" help_menu.add_item("Quick Start Guide (F1)", 0, KEY_F1) help_menu.add_item("Keyboard & Mouse Controls", 1) help_menu.add_separator() help_menu.add_item("Reset Camera to Center", 2) help_menu.id_pressed.connect(_on_help_menu_pressed) _menu_bar.add_child(help_menu) parent.add_child(_menu_bar) func _build_toolbar(parent: Control) -> void: _toolbar = HBoxContainer.new() _toolbar.custom_minimum_size = Vector2(0, 36) _toolbar.add_theme_constant_override("separation", 6) _btn_select = _add_tool_btn("โ†– [1] Select", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.NONE)) _btn_move = _add_tool_btn("โœ› [2] Move", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE)) _btn_rotate = _add_tool_btn("โ†ป [3] Rotate", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.ROTATE)) _btn_scale = _add_tool_btn("โคข [4] Scale", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.SCALE)) var vsep1 := VSeparator.new() _toolbar.add_child(vsep1) var snap_lbl := Label.new() snap_lbl.text = "Snap:" snap_lbl.add_theme_font_size_override("font_size", 10) _toolbar.add_child(snap_lbl) _snap_spin = SpinBox.new() _snap_spin.min_value = 0.5 _snap_spin.max_value = 10.0 _snap_spin.step = 0.5 _snap_spin.value = 1.0 _snap_spin.value_changed.connect(func(v): if _gizmo_3d != null: _gizmo_3d.snap_translate = v ) _toolbar.add_child(_snap_spin) var vsep2 := VSeparator.new() _toolbar.add_child(vsep2) # Quick Insert Bar var add_lbl := Label.new() add_lbl.text = "+ Quick Add:" add_lbl.add_theme_font_size_override("font_size", 10) add_lbl.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4)) _toolbar.add_child(add_lbl) _add_quick_insert_btn("๐Ÿฆฌ Bison", "bison", "ModBison") _add_quick_insert_btn("๐Ÿ“ฆ Crate", "crate_small", "Wooden Crate") _add_quick_insert_btn("๐ŸŒฒ Tree", "tree_pine_01", "Pine Tree") _add_quick_insert_btn("๐Ÿ’ก Lamp", "lamp_post_01", "Street Lamp") var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL _toolbar.add_child(spacer) # Simulation Controls _btn_play = Button.new() _btn_play.text = "โ–ถ Play (F5)" _btn_play.add_theme_color_override("font_color", Color(0.3, 0.9, 0.4)) _btn_play.pressed.connect(func(): _simulation_mgr.start_play(true)) _toolbar.add_child(_btn_play) _btn_stop = Button.new() _btn_stop.text = "โน Stop" _btn_stop.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) _btn_stop.disabled = true _btn_stop.pressed.connect(func(): _simulation_mgr.stop_simulation()) _toolbar.add_child(_btn_stop) var vsep3 := VSeparator.new() _toolbar.add_child(vsep3) # Help & Return to Main Menu var btn_help := Button.new() btn_help.text = "โ“ Help & Guide (F1)" btn_help.add_theme_color_override("font_color", Color(1.0, 0.9, 0.3)) btn_help.tooltip_text = "Open Quick Start Guide and Controls Reference (F1)" btn_help.pressed.connect(func(): _toggle_help_dialog()) _toolbar.add_child(btn_help) var btn_home := Button.new() btn_home.text = "๐Ÿ  Main Menu" btn_home.add_theme_color_override("font_color", Color(0.82, 0.86, 0.95)) btn_home.tooltip_text = "Return to ModBison Main Menu" btn_home.pressed.connect(_return_to_main_menu) _toolbar.add_child(btn_home) parent.add_child(_toolbar) func _add_tool_btn(text: String, callback: Callable) -> Button: var btn := Button.new() btn.text = text btn.pressed.connect(callback) _toolbar.add_child(btn) return btn func _add_quick_insert_btn(btn_text: String, cat_id: String, display_name: String) -> Button: var btn := Button.new() btn.text = btn_text btn.custom_minimum_size = Vector2(0, 26) btn.tooltip_text = "Spawn %s into the world at camera focus point" % display_name btn.pressed.connect(func(): _quick_insert(cat_id, display_name)) _toolbar.add_child(btn) return btn func _quick_insert(cat_id: String, display_name: String) -> void: var spawn_pos: Vector3 = Vector3.ZERO if _camera_3d != null: spawn_pos = _camera_3d.focal_point spawn_pos.y = 0.0 var chunk_pos := _godot_to_chunk_pos(spawn_pos) var new_id := core.place_object(cat_id, chunk_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { "display_name": display_name, "catalog_id": cat_id }) if new_id != "": _spawn_visual_node(new_id) core.select_object(new_id) _on_selection_changed(new_id) _hierarchy_dock.refresh_tree() _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _status_bar_label.text = "Spawned %s at (%.1f, 0, %.1f) | โœ› Move gizmo active" % [display_name, spawn_pos.x, spawn_pos.z] func _setup_3d_viewport_scene(vp: SubViewport) -> void: # World Environment var env := WorldEnvironment.new() var environment := Environment.new() environment.background_mode = Environment.BG_COLOR environment.background_color = Color(0.18, 0.22, 0.28) environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR environment.ambient_light_color = Color(0.65, 0.65, 0.7) env.environment = environment vp.add_child(env) # Directional Sun Light var sun := DirectionalLight3D.new() sun.rotation_degrees = Vector3(-45, 30, 0) sun.shadow_enabled = true vp.add_child(sun) # Studio Baseplate Ground Mesh _baseplate_mesh = MeshInstance3D.new() var plane := PlaneMesh.new() plane.size = Vector2(128, 128) var gmat := StandardMaterial3D.new() gmat.albedo_color = Color(0.20, 0.23, 0.27) gmat.roughness = 0.85 _baseplate_mesh.mesh = plane vp.add_child(_baseplate_mesh) # Solid Physics Floor Collider (layer 1, 500x500m surface at Y=0) var floor_body := StaticBody3D.new() floor_body.name = "StudioFloorBody" floor_body.set_collision_layer_value(1, true) floor_body.set_collision_mask_value(1, true) var floor_col := CollisionShape3D.new() var floor_box := BoxShape3D.new() floor_box.size = Vector3(500.0, 2.0, 500.0) floor_col.shape = floor_box floor_col.position = Vector3(0, -1.0, 0) floor_body.add_child(floor_col) vp.add_child(floor_body) # 3D Grid floor lines var grid := _create_3d_grid(32, 1.0) vp.add_child(grid) # Camera _camera_3d = StudioViewportCameraClass.new() vp.add_child(_camera_3d) # World Object Container _world_container = Node3D.new() _world_container.name = "WorldContainer" vp.add_child(_world_container) # Interactive Gizmo _gizmo_3d = TransformGizmo3DClass.new() _gizmo_3d.name = "TransformGizmo3D" vp.add_child(_gizmo_3d) core.selection.selection_changed.connect(_on_selection_changed) func _setup_viewport_hud_overlay(parent: Control) -> void: var hud_bar := PanelContainer.new() var h_style := StyleBoxFlat.new() h_style.bg_color = Color(0.08, 0.10, 0.14, 0.88) h_style.border_color = Color(0.3, 0.45, 0.6, 0.6) h_style.set_border_width_all(1) h_style.set_corner_radius_all(6) h_style.content_margin_left = 12 h_style.content_margin_right = 12 h_style.content_margin_top = 6 h_style.content_margin_bottom = 6 hud_bar.add_theme_stylebox_override("panel", h_style) hud_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE hud_bar.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) hud_bar.position = Vector2(16, -42) var hud_text := Label.new() hud_text.text = "๐ŸŽฅ RMB+WASD: Fly | ๐Ÿ–ฑ๏ธ LMB: Click/Drag to Move | โœ› [2] Move | โ†ป [3] Rotate | [F] Focus | โ–ถ [F5] Play | โ“ [F1] Help" hud_text.add_theme_font_size_override("font_size", 11) hud_text.add_theme_color_override("font_color", Color(0.9, 0.92, 0.96)) hud_bar.add_child(hud_text) parent.add_child(hud_bar) func _create_3d_grid(size: int, step: float) -> Node3D: var mi := MeshInstance3D.new() var imm := ImmediateMesh.new() var mat := StandardMaterial3D.new() mat.albedo_color = Color(0.35, 0.42, 0.5, 0.6) mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED imm.surface_begin(Mesh.PRIMITIVE_LINES, mat) var half := (size * step) / 2.0 for i in range(-size / 2, size / 2 + 1): var p := i * step imm.surface_add_vertex(Vector3(p, 0.01, -half)) imm.surface_add_vertex(Vector3(p, 0.01, half)) imm.surface_add_vertex(Vector3(-half, 0.01, p)) imm.surface_add_vertex(Vector3(half, 0.01, p)) imm.surface_end() mi.mesh = imm return mi # โ”€โ”€ Keyboard Shortcuts & Nudge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ func _setup_shortcuts() -> void: set_process_unhandled_key_input(true) func _unhandled_key_input(event: InputEvent) -> void: var k := event as InputEventKey if not k.pressed or k.echo: return if k.is_command_or_control_pressed(): if k.keycode == KEY_Z: core.undo() _refresh_scene_visuals() elif k.keycode == KEY_Y: core.redo() _refresh_scene_visuals() elif k.keycode == KEY_S: save_current_project() elif k.keycode == KEY_D: _hierarchy_dock._on_duplicate_pressed() elif k.keycode == KEY_F1: _toggle_help_dialog() elif k.keycode == KEY_F5: if _simulation_mgr.is_playing(): _simulation_mgr.stop_simulation() else: _simulation_mgr.start_play(true) elif k.keycode == KEY_DELETE or k.keycode == KEY_BACKSPACE: var sel := _get_selected_id() if sel != "": _delete_selected_object(sel) elif k.keycode == KEY_BRACKETLEFT and not k.is_command_or_control_pressed(): _nudge_selected_rotation(-45.0) elif k.keycode == KEY_BRACKETRIGHT and not k.is_command_or_control_pressed(): _nudge_selected_rotation(45.0) elif k.keycode == KEY_LEFT and not k.is_command_or_control_pressed(): _nudge_selected_position(Vector3(-1, 0, 0)) elif k.keycode == KEY_RIGHT and not k.is_command_or_control_pressed(): _nudge_selected_position(Vector3(1, 0, 0)) elif k.keycode == KEY_UP and not k.is_command_or_control_pressed(): _nudge_selected_position(Vector3(0, 0, -1)) elif k.keycode == KEY_DOWN and not k.is_command_or_control_pressed(): _nudge_selected_position(Vector3(0, 0, 1)) elif k.keycode == KEY_PAGEUP: _nudge_selected_position(Vector3(0, 1, 0)) elif k.keycode == KEY_PAGEDOWN: _nudge_selected_position(Vector3(0, -1, 0)) elif k.keycode == KEY_1 and not k.is_command_or_control_pressed(): _set_gizmo_mode(TransformGizmo3DClass.Mode.NONE) elif k.keycode == KEY_2 and not k.is_command_or_control_pressed(): _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) elif k.keycode == KEY_3 and not k.is_command_or_control_pressed(): _set_gizmo_mode(TransformGizmo3DClass.Mode.ROTATE) elif k.keycode == KEY_4 and not k.is_command_or_control_pressed(): _set_gizmo_mode(TransformGizmo3DClass.Mode.SCALE) elif k.keycode == KEY_F and not k.is_command_or_control_pressed(): var sel_id := _get_selected_id() if sel_id != "": var n := _world_container.get_node_or_null(sel_id) if n is Node3D and _camera_3d != null: _camera_3d.focus_on(n.global_position) func _nudge_selected_position(dir: Vector3) -> void: var sel := _get_selected_id() if sel == "": return var node := _world_container.get_node_or_null(sel) as Node3D if node == null: return var step: float = _snap_spin.value if _snap_spin else 1.0 var new_pos := node.position + dir * step new_pos.y = maxf(0.0, new_pos.y) node.position = new_pos if _gizmo_3d != null: _gizmo_3d.position = node.position var chunk_pos := _godot_to_chunk_pos(new_pos) core.move_object(sel, chunk_pos) if _inspector_dock != null: _inspector_dock.set_transform_values(new_pos, node.rotation_degrees.y, node.scale) _status_bar_label.text = "Nudged %s to (%.1f, %.1f, %.1f)" % [sel, new_pos.x, new_pos.y, new_pos.z] func _nudge_selected_rotation(delta_deg: float) -> void: var sel := _get_selected_id() if sel == "": return var node := _world_container.get_node_or_null(sel) as Node3D if node == null: return var new_yaw := fmod(node.rotation_degrees.y + delta_deg, 360.0) node.rotation_degrees.y = new_yaw if _gizmo_3d != null: _gizmo_3d.quaternion = node.quaternion var q := node.quaternion var chunk_q := _godot_to_chunk_quat(q) core.rotate_object(sel, Vector4(chunk_q.x, chunk_q.y, chunk_q.z, chunk_q.w)) if _inspector_dock != null: _inspector_dock.set_transform_values(node.position, new_yaw, node.scale) _status_bar_label.text = "Rotated %s to %.0fยฐ" % [sel, new_yaw] func _delete_selected_object(obj_id: String) -> void: if obj_id == "": obj_id = _get_selected_id() if obj_id == "": return core.delete_object(obj_id) var node := _world_container.get_node_or_null(obj_id) if node != null: node.queue_free() core.select_object("") _hierarchy_dock.refresh_tree() _status_bar_label.text = "Deleted object '%s' | Undo with [Ctrl+Z]" % obj_id func _set_gizmo_mode(mode: TransformGizmo3DClass.Mode) -> void: if _gizmo_3d != null: _gizmo_3d.set_mode(mode) var mode_name := "Select" match mode: TransformGizmo3DClass.Mode.TRANSLATE: mode_name = "Move (โœ›)" TransformGizmo3DClass.Mode.ROTATE: mode_name = "Rotate (โ†ป)" TransformGizmo3DClass.Mode.SCALE: mode_name = "Scale (โคข)" _status_bar_label.text = "Tool: %s | Snap: %.1fm | [F1] Help" % [mode_name, _snap_spin.value if _snap_spin else 1.0] func _toggle_help_dialog() -> void: if _help_dialog == null: return _help_dialog.visible = not _help_dialog.visible if _help_dialog.visible: _help_dialog.move_to_front() func _return_to_main_menu() -> void: if _simulation_mgr != null and _simulation_mgr.is_playing(): _simulation_mgr.stop_simulation() get_tree().change_scene_to_file("res://scenes/main_menu.tscn") # โ”€โ”€ Interactive 3D Viewport Input & Picking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ func handle_viewport_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_LEFT: if mb.pressed: _handle_left_press(mb) else: _handle_left_release(mb) elif event is InputEventMouseMotion and _is_dragging: var mm := event as InputEventMouseMotion _handle_drag_motion(mm) func _handle_left_press(mb: InputEventMouseButton) -> void: if _camera_3d == null or _world_container == null: return var ray_from: Vector3 = _camera_3d.project_ray_origin(mb.position) var ray_dir: Vector3 = _camera_3d.project_ray_normal(mb.position) var sel_id: String = _get_selected_id() # 1. First priority: Check if clicking on Gizmo handle if _gizmo_3d != null and _gizmo_3d.visible and sel_id != "": var picked_axis: TransformGizmo3DClass.Axis = _gizmo_3d.pick_axis(ray_from, ray_dir) if picked_axis != TransformGizmo3DClass.Axis.NONE: _start_drag(picked_axis, mb.position) return # 2. Second priority: Check if clicking on a 3D world object var hit_obj := _raycast_find_object(mb.position) if hit_obj != null: if hit_obj.name == sel_id and _gizmo_3d != null and _gizmo_3d.current_mode != TransformGizmo3DClass.Mode.NONE: # Directly drag already selected object _start_drag(TransformGizmo3DClass.Axis.XY, mb.position) return # Select new object core.select_object(hit_obj.name) if mb.double_click and _camera_3d != null: _camera_3d.focus_on(hit_obj.global_position) _status_bar_label.text = "Selected: %s | โœ› Drag model or handles to move" % hit_obj.name return # 3. Third: Clicked empty space -> deselect _is_dragging = false core.select_object("") _status_bar_label.text = "Deselected | Click an object or [+ Add] to build" func _start_drag(axis: TransformGizmo3DClass.Axis, mouse_pos: Vector2) -> void: var sel_id := _get_selected_id() if sel_id == "": return var node := _world_container.get_node_or_null(sel_id) as Node3D if node == null: return _is_dragging = true _drag_axis = axis _drag_start_mouse = mouse_pos _drag_start_ground = _raycast_ground_plane(mouse_pos) _drag_start_node_pos = node.position _drag_start_node_rot_deg = node.rotation_degrees _drag_start_node_scale = node.scale _drag_accum_y = 0.0 _drag_accum_rot = 0.0 _drag_accum_scale = 0.0 func _handle_drag_motion(mm: InputEventMouseMotion) -> void: var sel_id := _get_selected_id() if sel_id == "": _is_dragging = false return var node := _world_container.get_node_or_null(sel_id) as Node3D if node == null: _is_dragging = false return var snap_step: float = _snap_spin.value if _snap_spin else 1.0 match _gizmo_3d.current_mode if _gizmo_3d else TransformGizmo3DClass.Mode.TRANSLATE: TransformGizmo3DClass.Mode.TRANSLATE: if _drag_axis == TransformGizmo3DClass.Axis.Y: var dy: float = -mm.relative.y * 0.05 _drag_accum_y += dy var new_y := maxf(0.0, _drag_start_node_pos.y + _drag_accum_y) if snap_step > 0.0: new_y = snappedf(new_y, snap_step) node.position.y = new_y elif _drag_axis == TransformGizmo3DClass.Axis.X: var curr_ground := _raycast_ground_plane(mm.position) var dx := curr_ground.x - _drag_start_ground.x if snap_step > 0.0: dx = snappedf(dx, snap_step) node.position.x = _drag_start_node_pos.x + dx elif _drag_axis == TransformGizmo3DClass.Axis.Z: var curr_ground := _raycast_ground_plane(mm.position) var dz := curr_ground.z - _drag_start_ground.z if snap_step > 0.0: dz = snappedf(dz, snap_step) node.position.z = _drag_start_node_pos.z + dz else: # Free ground plane movement (XZ) var curr_ground := _raycast_ground_plane(mm.position) var delta := curr_ground - _drag_start_ground var new_x := _drag_start_node_pos.x + delta.x var new_z := _drag_start_node_pos.z + delta.z if snap_step > 0.0: new_x = snappedf(new_x, snap_step) new_z = snappedf(new_z, snap_step) node.position.x = new_x node.position.z = new_z if _gizmo_3d != null: _gizmo_3d.position = node.position if _inspector_dock != null: _inspector_dock.set_transform_values(node.position, node.rotation_degrees.y, node.scale) _status_bar_label.text = "Moving %s to (%.1f, %.1f, %.1f)" % [sel_id, node.position.x, node.position.y, node.position.z] TransformGizmo3DClass.Mode.ROTATE: var rot_step: float = 15.0 _drag_accum_rot += mm.relative.x * 0.8 var new_yaw := _drag_start_node_rot_deg.y - _drag_accum_rot if rot_step > 0.0: new_yaw = snappedf(new_yaw, rot_step) node.rotation_degrees.y = new_yaw if _gizmo_3d != null: _gizmo_3d.quaternion = node.quaternion if _inspector_dock != null: _inspector_dock.set_transform_values(node.position, new_yaw, node.scale) _status_bar_label.text = "Rotating %s to %.0fยฐ" % [sel_id, new_yaw] TransformGizmo3DClass.Mode.SCALE: var scale_step: float = 0.1 _drag_accum_scale += -mm.relative.y * 0.02 var factor := maxf(0.05, 1.0 + _drag_accum_scale) var new_scale := _drag_start_node_scale * factor if scale_step > 0.0: new_scale.x = snappedf(new_scale.x, scale_step) new_scale.y = snappedf(new_scale.y, scale_step) new_scale.z = snappedf(new_scale.z, scale_step) node.scale = new_scale if _gizmo_3d != null: _gizmo_3d.attach_to_node(node) if _inspector_dock != null: _inspector_dock.set_transform_values(node.position, node.rotation_degrees.y, new_scale) _status_bar_label.text = "Scaling %s to (%.2f, %.2f, %.2f)" % [sel_id, new_scale.x, new_scale.y, new_scale.z] func _handle_left_release(_mb: InputEventMouseButton) -> void: if not _is_dragging: return _is_dragging = false var sel_id := _get_selected_id() if sel_id == "": return var node := _world_container.get_node_or_null(sel_id) as Node3D if node == null: return match _gizmo_3d.current_mode if _gizmo_3d else TransformGizmo3DClass.Mode.TRANSLATE: TransformGizmo3DClass.Mode.TRANSLATE: var chunk_pos := _godot_to_chunk_pos(node.position) core.move_object(sel_id, chunk_pos) _status_bar_label.text = "Moved %s to (%.1f, %.1f, %.1f) | Undo with [Ctrl+Z]" % [sel_id, node.position.x, node.position.y, node.position.z] TransformGizmo3DClass.Mode.ROTATE: var q := node.quaternion var chunk_q := _godot_to_chunk_quat(q) core.rotate_object(sel_id, Vector4(chunk_q.x, chunk_q.y, chunk_q.z, chunk_q.w)) _status_bar_label.text = "Rotated %s to %.0fยฐ | Undo with [Ctrl+Z]" % [sel_id, node.rotation_degrees.y] TransformGizmo3DClass.Mode.SCALE: var chunk_scale := _godot_to_chunk_scale(node.scale) core.scale_object(sel_id, chunk_scale) _status_bar_label.text = "Scaled %s to (%.2f, %.2f, %.2f) | Undo with [Ctrl+Z]" % [sel_id, node.scale.x, node.scale.y, node.scale.z] _hierarchy_dock.refresh_tree() func handle_viewport_drop(at_pos: Vector2, data: Variant) -> void: if not (data is Dictionary): return var cat_id := str(data.get("catalog_id", "crate_small")) var drop_pos := _raycast_ground_plane(at_pos) var target_obj := _raycast_find_object(at_pos) var new_id: String = "" if target_obj != null and target_obj.name != "": var target_chunk_pos := _godot_to_chunk_pos(drop_pos) new_id = core.place_object_stacked(cat_id, target_obj.name, target_chunk_pos, Vector3(0, 0, 1), 0.0, false, { "display_name": data.get("name", cat_id), "catalog_id": cat_id, "asset_id": data.get("asset_id", "") }) else: var chunk_pos := _godot_to_chunk_pos(drop_pos) new_id = core.place_object(cat_id, chunk_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { "display_name": data.get("name", cat_id), "catalog_id": cat_id, "asset_id": data.get("asset_id", "") }) if new_id != "": _spawn_visual_node(new_id) core.select_object(new_id) _on_selection_changed(new_id) _hierarchy_dock.refresh_tree() _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _status_bar_label.text = "Placed %s at (%.1f, %.1f, %.1f)" % [data.get("name", cat_id), drop_pos.x, drop_pos.y, drop_pos.z] func _raycast_find_object(screen_pos: Vector2) -> Node3D: if _camera_3d == null or _world_container == null: return null var from: Vector3 = _camera_3d.project_ray_origin(screen_pos) var dir: Vector3 = _camera_3d.project_ray_normal(screen_pos) # 1. Physics raycast against colliders in viewport world if _sub_viewport != null and _sub_viewport.world_3d != null: var space = _sub_viewport.world_3d.direct_space_state var query := PhysicsRayQueryParameters3D.create(from, from + dir * 500.0) var res := space.intersect_ray(query) if not res.is_empty() and res.collider != null: var curr: Node = res.collider while curr != null and curr != _world_container: if curr.get_parent() == _world_container and curr is Node3D: return curr as Node3D curr = curr.get_parent() # 2. Geometric fallback (ray-to-object closest distance) var best_node: Node3D = null var best_dist: float = 1e9 for child in _world_container.get_children(): if not (child is Node3D): continue var obj_pos: Vector3 = (child as Node3D).global_position var to_obj: Vector3 = obj_pos - from var proj: float = to_obj.dot(dir) if proj < 0.2: continue var closest: Vector3 = from + dir * proj var dist: float = closest.distance_to(obj_pos) if dist < 1.8 and proj < best_dist: best_dist = proj best_node = child as Node3D return best_node func _raycast_ground_plane(screen_pos: Vector2) -> Vector3: if _camera_3d == null: return Vector3.ZERO var from: Vector3 = _camera_3d.project_ray_origin(screen_pos) var dir: Vector3 = _camera_3d.project_ray_normal(screen_pos) if absf(dir.y) < 0.0001: return from + dir * 10.0 var t: float = -from.y / dir.y if t < 0.0: return from + dir * 10.0 return from + dir * t # โ”€โ”€ Coordinate Conversions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ static func _godot_to_chunk_pos(g: Vector3) -> Vector3: return Vector3(g.x, g.z, g.y) static func _chunk_to_godot_pos(c: Vector3) -> Vector3: return Vector3(c.x, c.z, c.y) static func _godot_to_chunk_quat(q: Quaternion) -> Quaternion: return Quaternion(q.x, q.z, q.y, q.w) static func _chunk_to_godot_quat(q: Quaternion) -> Quaternion: return Quaternion(q.x, q.z, q.y, q.w) static func _godot_to_chunk_scale(s: Vector3) -> Vector3: return Vector3(s.x, s.z, s.y) static func _chunk_to_godot_scale(s: Vector3) -> Vector3: return Vector3(s.x, s.z, s.y) # โ”€โ”€ Selection & Scene Visual Sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ func _get_selected_id() -> String: if core != null and core.selection != null: return core.selection.get_selected() return "" func _on_selection_changed(obj_id: String) -> void: if obj_id == "": if _gizmo_3d != null: _gizmo_3d.attach_to_node(null) return var obj_node := _world_container.get_node_or_null(obj_id) if obj_node is Node3D and _gizmo_3d != null: if _gizmo_3d.current_mode == TransformGizmo3DClass.Mode.NONE: _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _gizmo_3d.attach_to_node(obj_node) func _on_inspector_property_changed(obj_id: String, prop_name: String, val: Variant) -> void: var node := _world_container.get_node_or_null(obj_id) as Node3D if node == null: return match prop_name: "position": node.position = val as Vector3 if _gizmo_3d != null: _gizmo_3d.position = node.position "rotation": node.quaternion = val as Quaternion if _gizmo_3d != null: _gizmo_3d.quaternion = node.quaternion "scale": node.scale = val as Vector3 func _on_collection_model_selected(model_data: Dictionary) -> void: var cat_id := str(model_data.get("catalog_id", "crate_small")) var spawn_pos: Vector3 = Vector3.ZERO if _camera_3d != null: spawn_pos = _camera_3d.focal_point spawn_pos.y = 0.0 var chunk_pos := _godot_to_chunk_pos(spawn_pos) var new_id := core.place_object(cat_id, chunk_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { "display_name": model_data.get("name", cat_id), "catalog_id": cat_id, "asset_id": model_data.get("asset_id", "") }) if new_id != "": _spawn_visual_node(new_id) core.select_object(new_id) _on_selection_changed(new_id) _hierarchy_dock.refresh_tree() _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _status_bar_label.text = "Placed %s at (%.1f, 0, %.1f)" % [model_data.get("name", cat_id), spawn_pos.x, spawn_pos.z] func _spawn_visual_node(obj_id: String) -> void: var obj = core.model.find_object(obj_id) if obj == null: return var wo := WorldObjectClass.new() wo.id = obj.id wo.type = obj.type wo.asset_id = obj.asset_id wo.position = obj.position wo.properties = obj.properties.duplicate(true) var node = StaticObjectNodeClass.new() node.name = obj.id node.world_object = wo node.position = _chunk_to_godot_pos(obj.position) node.quaternion = _chunk_to_godot_quat(Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)) node.scale = _chunk_to_godot_scale(obj.scale) _world_container.add_child(node) func _refresh_scene_visuals() -> void: for c in _world_container.get_children(): c.queue_free() for obj in core.model.payload.objects: _spawn_visual_node(obj.id) _hierarchy_dock.refresh_tree() # โ”€โ”€ Templates & Project IO โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ func load_template(template_name: String) -> void: var data := MBWorldProjectIOClass.create_template(template_name) project_title = data.get("title", "Untitled World") for obj in data.get("objects", []): var p: Array = obj.get("position", [0, 0, 0]) var r: Array = obj.get("rotation", [0, 0, 0, 1]) var s: Array = obj.get("scale", [1, 1, 1]) core.place_object( obj.get("catalog_id", "crate_small"), Vector3(p[0], p[1], p[2]), Vector4(r[0], r[1], r[2], r[3]), Vector3(s[0], s[1], s[2]), obj.get("properties", {}) ) _refresh_scene_visuals() if not core.model.payload.objects.is_empty(): var first_id: String = core.model.payload.objects[0].id core.select_object(first_id) _on_selection_changed(first_id) _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) _status_bar_label.text = "Loaded template: %s | Press [F1] for Controls" % project_title func save_current_project(path: String = "") -> bool: if path == "": path = "user://saved_world.mbworld" var data := { "title": project_title, "objects": [] } for obj in core.model.payload.objects: data["objects"].append({ "id": obj.id, "catalog_id": obj.properties.get("catalog_id", "crate_small"), "asset_id": obj.asset_id, "position": [obj.position.x, obj.position.y, obj.position.z], "rotation": [obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w], "scale": [obj.scale.x, obj.scale.y, obj.scale.z], "properties": obj.properties }) var ok := MBWorldProjectIOClass.save_project(path, data) if ok: _status_bar_label.text = "Saved world project to %s" % path return ok func _update_simulation_ui(playing: bool) -> void: _btn_play.disabled = playing _btn_stop.disabled = not playing _status_bar_label.text = "Simulation: PLAYING (F5 to Stop)" if playing else "Simulation: STOPPED (Edit mode)" if not playing: if _camera_3d != null: _camera_3d.current = true Input.mouse_mode = Input.MOUSE_MODE_VISIBLE # โ”€โ”€ Menu Bar Dispatch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ func _on_file_menu_pressed(id: int) -> void: match id: 0: load_template("baseplate") 2: save_current_project() 3: save_current_project("user://saved_world_as.mbworld") 4: save_current_project("user://published_world.mbworld") 5: _return_to_main_menu() 6: get_tree().quit(0) func _on_edit_menu_pressed(id: int) -> void: match id: 0: core.undo(); _refresh_scene_visuals() 1: core.redo(); _refresh_scene_visuals() 2: _hierarchy_dock._on_duplicate_pressed() 3: _delete_selected_object(_get_selected_id()) func _on_view_menu_pressed(id: int) -> void: match id: 0: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.PERSPECTIVE) 1: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.TOP) 2: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.FRONT) 3: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.SIDE) 4: var sel_id := _get_selected_id() if sel_id != "": var n := _world_container.get_node_or_null(sel_id) if n is Node3D and _camera_3d != null: _camera_3d.focus_on(n.global_position) func _on_model_menu_pressed(id: int) -> void: var cat_info := [ {"catalog_id": "bison", "name": "ModBison"}, {"catalog_id": "crate_small", "name": "Wooden Crate"}, {"catalog_id": "tree_pine_01", "name": "Pine Tree"}, {"catalog_id": "lamp_post_01", "name": "Street Lamp"}, {"catalog_id": "chest_01", "name": "Treasure Chest"}, {"catalog_id": "buffalo_chip_coin", "name": "Buffalo Chip Coin"} ] if id >= 0 and id < cat_info.size(): _quick_insert(cat_info[id]["catalog_id"], cat_info[id]["name"]) func _on_test_menu_pressed(id: int) -> void: match id: 0: _simulation_mgr.start_play(true) 1: _simulation_mgr.start_play(false) 2: _simulation_mgr.stop_simulation() func _on_publish_menu_pressed(id: int) -> void: if id == 0: save_current_project("user://exported_world.mbworld") func _on_help_menu_pressed(id: int) -> void: match id: 0, 1: _toggle_help_dialog() 2: if _camera_3d != null: _camera_3d.focus_on(Vector3.ZERO, 5.0) func _parse_cmdline_arguments() -> void: var args := OS.get_cmdline_args() for i in range(args.size()): var arg := args[i] if arg == "--template" and i + 1 < args.size(): load_template(args[i + 1]) elif arg.ends_with(".mbworld") and FileAccess.file_exists(arg): var d := MBWorldProjectIOClass.load_project(arg) if not d.is_empty(): project_title = d.get("title", "Loaded World")