From 3f0ba543e16feca8faf51a60b4f6dfce7f6ba929 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 22 Sep 2026 20:11:02 +0000 Subject: [PATCH] feat(studio): Add Quick Start Guide, Viewport HUD, 3D raycast picking, quick add toolbar, and starter Baseplate --- scripts/editor/editor_core.gd | 4 +- scripts/editor/object_catalog.gd | 10 + scripts/studio/mbworld_project_io.gd | 40 ++- scripts/studio/studio_help_dialog.gd | 221 +++++++++++++ scripts/studio/studio_hierarchy_dock.gd | 2 +- scripts/studio/studio_inspector_dock.gd | 5 +- scripts/studio/studio_main.gd | 398 +++++++++++++++++++++--- 7 files changed, 633 insertions(+), 47 deletions(-) create mode 100644 scripts/studio/studio_help_dialog.gd diff --git a/scripts/editor/editor_core.gd b/scripts/editor/editor_core.gd index 04ab7e9..5223c0e 100644 --- a/scripts/editor/editor_core.gd +++ b/scripts/editor/editor_core.gd @@ -90,7 +90,8 @@ func place_object(catalog_id: String, position: Vector3, func place_object_stacked(catalog_id: String, target_object_id: String = "", hit_point: Vector3 = Vector3.ZERO, surface_normal: Vector3 = Vector3(0, 0, 1), - elevation_offset: float = 0.0, align_to_normal: bool = false) -> String: + elevation_offset: float = 0.0, align_to_normal: bool = false, + extra_properties: Dictionary = {}) -> String: var target_obj: ChunkDataDecoder.WorldObjectData = null if target_object_id != "": target_obj = model.find_object(target_object_id) @@ -103,6 +104,7 @@ func place_object_stacked(catalog_id: String, target_object_id: String = "", props["stacked_on"] = stack_info["parent_id"] if elevation_offset != 0.0: props["elevation_offset"] = elevation_offset + props.merge(extra_properties) return place_object(catalog_id, stack_info["position"], stack_info["rotation"], Vector3.ONE, props) diff --git a/scripts/editor/object_catalog.gd b/scripts/editor/object_catalog.gd index 08006cb..3d21345 100644 --- a/scripts/editor/object_catalog.gd +++ b/scripts/editor/object_catalog.gd @@ -52,6 +52,16 @@ func resolve(id: String) -> Dictionary: return _catalog[id] if (id.contains("bizon") or id.contains("bison")) and _catalog.has("bison"): return _catalog["bison"] + if (id == "tree_pine" or id.begins_with("tree")) and _catalog.has("tree_pine_01"): + return _catalog["tree_pine_01"] + if (id == "lamp_post" or id.begins_with("lamp")) and _catalog.has("lamp_post_01"): + return _catalog["lamp_post_01"] + if (id == "chest" or id.begins_with("chest")) and _catalog.has("chest_01"): + return _catalog["chest_01"] + if (id == "rock" or id.begins_with("rock")) and _catalog.has("rock_01"): + return _catalog["rock_01"] + if (id == "spawn_pad" or id.begins_with("spawn")) and _catalog.has("spawn_point"): + return _catalog["spawn_point"] return {} diff --git a/scripts/studio/mbworld_project_io.gd b/scripts/studio/mbworld_project_io.gd index bd83d36..a32c508 100644 --- a/scripts/studio/mbworld_project_io.gd +++ b/scripts/studio/mbworld_project_io.gd @@ -66,12 +66,48 @@ static func create_template(template_name: String) -> Dictionary: base["objects"] = [ { "id": "spawn_pad", - "catalog_id": "spawn_pad", + "catalog_id": "crate_small", "asset_id": "props/crate_small.glb", "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [2, 2, 0.2], - "properties": {"display_name": "Spawn Pad"} + "properties": {"display_name": "Spawn Pad", "catalog_id": "crate_small"} + }, + { + "id": "modbison_mascot", + "catalog_id": "bison", + "asset_id": "props/bison.glb", + "position": [0, 4, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1], + "properties": {"display_name": "ModBison", "catalog_id": "bison"} + }, + { + "id": "crate_base", + "catalog_id": "crate_small", + "asset_id": "props/crate_small.glb", + "position": [4, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1], + "properties": {"display_name": "Wooden Crate", "catalog_id": "crate_small"} + }, + { + "id": "crate_stacked", + "catalog_id": "crate_small", + "asset_id": "props/crate_small.glb", + "position": [4, 0, 1], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1], + "properties": {"display_name": "Stacked Crate", "catalog_id": "crate_small", "parent_id": "crate_base"} + }, + { + "id": "lamp_01", + "catalog_id": "lamp_post_01", + "asset_id": "props/lamp_post_01.glb", + "position": [-3, 3, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1], + "properties": {"display_name": "Street Lamp", "catalog_id": "lamp_post_01"} } ] "terrain": diff --git a/scripts/studio/studio_help_dialog.gd b/scripts/studio/studio_help_dialog.gd new file mode 100644 index 0000000..5318ae4 --- /dev/null +++ b/scripts/studio/studio_help_dialog.gd @@ -0,0 +1,221 @@ +class_name StudioHelpDialog +extends PanelContainer +## Interactive Welcome, Quick Start Guide & Controls Cheatsheet for ModBison Studio. + +signal closed + +const SETTINGS_PATH := "user://studio_settings.json" + +var _check_show_on_start: CheckBox + + +func _init() -> void: + name = "StudioHelpDialog" + custom_minimum_size = Vector2(720, 520) + _build_ui() + + +static func should_show_on_startup() -> bool: + if not FileAccess.file_exists(SETTINGS_PATH): + return true + var fa := FileAccess.open(SETTINGS_PATH, FileAccess.READ) + if fa == null: + return true + var parsed = JSON.parse_string(fa.get_as_text()) + fa.close() + if parsed is Dictionary and parsed.has("show_help_on_startup"): + return bool(parsed["show_help_on_startup"]) + return true + + +static func save_show_on_startup(show_help: bool) -> void: + var data := {"show_help_on_startup": show_help} + var fa := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) + if fa != null: + fa.store_string(JSON.stringify(data)) + fa.close() + + +func _build_ui() -> void: + # Style panel + var style := StyleBoxFlat.new() + style.bg_color = Color(0.14, 0.16, 0.20, 0.98) + style.border_color = Color(0.9, 0.75, 0.25) + style.set_border_width_all(2) + style.set_corner_radius_all(8) + style.content_margin_left = 24 + style.content_margin_top = 20 + style.content_margin_right = 24 + style.content_margin_bottom = 20 + add_theme_stylebox_override("panel", style) + + var root_vbox := VBoxContainer.new() + root_vbox.add_theme_constant_override("separation", 14) + add_child(root_vbox) + + # Header Row + var header_hbox := HBoxContainer.new() + var title_lbl := Label.new() + title_lbl.text = "๐Ÿฆฌ Welcome to ModBison Studio!" + title_lbl.add_theme_font_size_override("font_size", 20) + title_lbl.add_theme_color_override("font_color", Color(1.0, 0.88, 0.35)) + header_hbox.add_child(title_lbl) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header_hbox.add_child(spacer) + + var close_x := Button.new() + close_x.text = "โœ•" + close_x.custom_minimum_size = Vector2(32, 28) + close_x.pressed.connect(_on_close_pressed) + header_hbox.add_child(close_x) + root_vbox.add_child(header_hbox) + + var subtitle := Label.new() + subtitle.text = "Your creator workbench for designing 3D worlds, placing interactive props, and playtesting in real-time." + subtitle.add_theme_font_size_override("font_size", 12) + subtitle.add_theme_color_override("font_color", Color(0.75, 0.78, 0.82)) + root_vbox.add_child(subtitle) + + # Tab Container for Guide & Shortcuts + var tabs := TabContainer.new() + tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL + + # Tab 1: Quick Start Guide + var guide_scroll := ScrollContainer.new() + guide_scroll.name = "Quick Start Guide" + guide_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + var guide_content := VBoxContainer.new() + guide_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + guide_content.add_theme_constant_override("separation", 14) + + _add_guide_step(guide_content, "๐ŸŽฅ 1. Navigate the 3D World", + "โ€ข Fly Camera: Hold Right Mouse Button (RMB) + W, A, S, D, Q, E\n" + + "โ€ข Orbit Camera: Hold Middle Mouse Button (MMB) and move mouse\n" + + "โ€ข Zoom: Scroll mouse wheel up/down\n" + + "โ€ข Focus View: Press [F] to instantly center and zoom onto the selected object") + + _add_guide_step(guide_content, "๐Ÿ–ฑ๏ธ 2. Select & Transform Props", + "โ€ข Click any 3D model directly in the viewport (or in the Scene Hierarchy on the left)\n" + + "โ€ข Switch transformation tools via top Toolbar or hotkeys:\n" + + " [1] โ†– Select Mode [2] โœ› Move (drag arrows) [3] โ†ป Rotate [4] โคข Scale\n" + + "โ€ข Snapping: Set grid snap step (0.5m, 1.0m, etc.) from the Toolbar snap box\n" + + "โ€ข Duplicate: Press [Ctrl + D] to duplicate, [Delete] to remove") + + _add_guide_step(guide_content, "๐Ÿ“ฆ 3. Add Models & Stacking", + "โ€ข Quick Insert: Click [+ Bison], [+ Crate], [+ Tree], or [+ Lamp] on the top toolbar\n" + + "โ€ข Collections Dock: Open the Collections tab on the right to browse theme packs and drag & drop models directly onto the ground!\n" + + "โ€ข Object Stacking: Place items directly on top of other items โ€” Studio automatically snaps surfaces so they stick together!") + + _add_guide_step(guide_content, "โ–ถ๏ธ 4. Playtest Your World (F5)", + "โ€ข Hit [F5] or click the green 'โ–ถ Play' button on the toolbar\n" + + "โ€ข Your player avatar spawns immediately with full walking, jumping, and riding physics!\n" + + "โ€ข Hit [F5] again or click 'โน Stop' to cleanly return to Edit mode (all objects roll back to their original positions)") + + _add_guide_step(guide_content, "๐Ÿ’พ 5. Save & Return to Game", + "โ€ข Save: Press [Ctrl + S] or File -> Save Project\n" + + "โ€ข Return: Click '๐Ÿ  Main Menu' in the top-right corner to return to the game menu anytime") + + guide_scroll.add_child(guide_content) + tabs.add_child(guide_scroll) + + # Tab 2: Keyboard & Mouse Reference + var keys_scroll := ScrollContainer.new() + keys_scroll.name = "Shortcuts & Controls" + keys_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + var keys_vbox := VBoxContainer.new() + keys_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + keys_vbox.add_theme_constant_override("separation", 10) + + _add_shortcut_row(keys_vbox, "RMB + WASDQE", "Fly camera through scene (Hold Shift to fly faster, Ctrl for precision)") + _add_shortcut_row(keys_vbox, "MMB + Drag", "Pan camera across scene") + _add_shortcut_row(keys_vbox, "Mouse Wheel", "Zoom camera in / out") + _add_shortcut_row(keys_vbox, "LMB (Left Click)", "Select object in 3D viewport or click gizmo handles") + _add_shortcut_row(keys_vbox, "F Key", "Focus camera on selected object") + _add_shortcut_row(keys_vbox, "1, 2, 3, 4 Keys", "Switch tool: [1] Select, [2] Move, [3] Rotate, [4] Scale") + _add_shortcut_row(keys_vbox, "7, 1, 3 Keys", "View Presets: [7] Top Ortho, [1] Front Ortho, [3] Side Ortho") + _add_shortcut_row(keys_vbox, "5 Key", "Toggle Perspective / Orthographic projection") + _add_shortcut_row(keys_vbox, "F5 Key", "Toggle Playtest (Play Solo / Stop Playtest)") + _add_shortcut_row(keys_vbox, "Ctrl + Z / Ctrl + Y", "Undo / Redo world modifications") + _add_shortcut_row(keys_vbox, "Ctrl + D", "Duplicate selected object") + _add_shortcut_row(keys_vbox, "Delete / Backspace", "Delete selected object") + _add_shortcut_row(keys_vbox, "Ctrl + S", "Save project to disk (.mbworld)") + _add_shortcut_row(keys_vbox, "F1 Key", "Toggle this Help & Controls guide") + + keys_scroll.add_child(keys_vbox) + tabs.add_child(keys_scroll) + + root_vbox.add_child(tabs) + + # Footer Row + var footer_hbox := HBoxContainer.new() + _check_show_on_start = CheckBox.new() + _check_show_on_start.text = "Show this guide when opening Studio" + _check_show_on_start.button_pressed = should_show_on_startup() + _check_show_on_start.toggled.connect(func(val): save_show_on_startup(val)) + footer_hbox.add_child(_check_show_on_start) + + var f_spacer := Control.new() + f_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + footer_hbox.add_child(f_spacer) + + var btn_close := Button.new() + btn_close.text = "โœจ Let's Build! (Close)" + btn_close.add_theme_color_override("font_color", Color(1.0, 0.9, 0.35)) + btn_close.custom_minimum_size = Vector2(160, 34) + btn_close.pressed.connect(_on_close_pressed) + footer_hbox.add_child(btn_close) + + root_vbox.add_child(footer_hbox) + + +func _add_guide_step(parent: VBoxContainer, title: String, text: String) -> void: + var box := VBoxContainer.new() + var t := Label.new() + t.text = title + t.add_theme_font_size_override("font_size", 13) + t.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0)) + box.add_child(t) + + var b := Label.new() + b.text = text + b.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + b.add_theme_font_size_override("font_size", 11) + b.add_theme_color_override("font_color", Color(0.85, 0.87, 0.9)) + box.add_child(b) + parent.add_child(box) + + +func _add_shortcut_row(parent: VBoxContainer, key: String, desc: String) -> void: + var row := HBoxContainer.new() + var k_lbl := Label.new() + k_lbl.text = key + k_lbl.custom_minimum_size = Vector2(180, 0) + k_lbl.add_theme_font_size_override("font_size", 11) + k_lbl.add_theme_color_override("font_color", Color(1.0, 0.85, 0.4)) + row.add_child(k_lbl) + + var d_lbl := Label.new() + d_lbl.text = desc + d_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + d_lbl.add_theme_font_size_override("font_size", 11) + d_lbl.add_theme_color_override("font_color", Color(0.82, 0.85, 0.88)) + row.add_child(d_lbl) + parent.add_child(row) + + +func _on_close_pressed() -> void: + visible = false + closed.emit() + + +func _unhandled_input(event: InputEvent) -> void: + if not visible: + return + if event is InputEventKey: + var k := event as InputEventKey + if k.pressed and not k.echo: + if k.keycode == KEY_ESCAPE or k.keycode == KEY_F1: + _on_close_pressed() + get_viewport().set_input_as_handled() diff --git a/scripts/studio/studio_hierarchy_dock.gd b/scripts/studio/studio_hierarchy_dock.gd index add1126..1eda050 100644 --- a/scripts/studio/studio_hierarchy_dock.gd +++ b/scripts/studio/studio_hierarchy_dock.gd @@ -138,7 +138,7 @@ func _on_core_selection_changed(obj_id: String) -> void: _tree.deselect_all() if obj_id != "" and _items_by_id.has(obj_id): _items_by_id[obj_id].select(0) - _items_by_id[obj_id].scroll_to_visible() + _tree.scroll_to_item(_items_by_id[obj_id]) _updating = false diff --git a/scripts/studio/studio_inspector_dock.gd b/scripts/studio/studio_inspector_dock.gd index 9888533..3725b85 100644 --- a/scripts/studio/studio_inspector_dock.gd +++ b/scripts/studio/studio_inspector_dock.gd @@ -50,9 +50,10 @@ func _build_ui() -> void: add_child(_title_label) _empty_label = Label.new() - _empty_label.text = "(No selection)" + _empty_label.text = "\nโ†– No Object Selected\n\nClick any 3D model in the viewport or Scene Hierarchy to inspect its transform, animations, and movement.\n\n๐Ÿ’ก Tip:\nUse '+ Add' on the top toolbar or open the 'Collections' tab to place props!" + _empty_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _empty_label.add_theme_font_size_override("font_size", 11) - _empty_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + _empty_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.75)) add_child(_empty_label) _scroll = ScrollContainer.new() diff --git a/scripts/studio/studio_main.gd b/scripts/studio/studio_main.gd index 2b10a2c..b7c642e 100644 --- a/scripts/studio/studio_main.gd +++ b/scripts/studio/studio_main.gd @@ -2,8 +2,8 @@ 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 gizmos, hierarchy, inspector, collections dock, -## and simulation harness. +## 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") @@ -12,9 +12,26 @@ const StudioHierarchyDockClass = preload("res://scripts/studio/studio_hierarchy_ 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 = "" @@ -32,16 +49,19 @@ 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 -# Docks +# 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 @@ -60,6 +80,15 @@ func _ready() -> void: _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() @@ -87,7 +116,7 @@ func _build_studio_layout() -> void: var bg := ColorRect.new() bg.set_anchors_preset(Control.PRESET_FULL_RECT) - bg.color = Color(0.14, 0.15, 0.18) + bg.color = Color(0.13, 0.14, 0.17) add_child(bg) var main_vbox := VBoxContainer.new() @@ -97,7 +126,7 @@ func _build_studio_layout() -> void: # 1. Top Menu Bar _build_top_menu_bar(main_vbox) - # 2. Main Action Toolbar + # 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) @@ -105,7 +134,7 @@ func _build_studio_layout() -> void: workspace_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL main_vbox.add_child(workspace_hbox) - # Left Dock: Hierarchy + # 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 @@ -114,22 +143,24 @@ func _build_studio_layout() -> void: left_panel.add_child(_hierarchy_dock) workspace_hbox.add_child(left_panel) - # Center: 3D Viewport - var vp_container := SubViewportContainer.new() - 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) + # 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) + _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(280, 0) + 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 @@ -152,7 +183,7 @@ func _build_studio_layout() -> void: 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 | Grid: 1.0m | Mode: Select" + _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) @@ -164,6 +195,12 @@ func _build_studio_layout() -> void: _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() @@ -172,15 +209,16 @@ func _build_top_menu_bar(parent: Control) -> void: # File Menu var file_menu := PopupMenu.new() file_menu.name = "File" - file_menu.add_item("New Project...", 0, KEY_MASK_CTRL | KEY_N) + 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 (Ctrl+S)", 2, KEY_MASK_CTRL | KEY_S) + 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("Exit Studio", 5) + 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) @@ -210,10 +248,12 @@ func _build_top_menu_bar(parent: Control) -> void: # Model Menu var model_menu := PopupMenu.new() model_menu.name = "Model" - model_menu.add_item("Add Crate Prop", 0) - model_menu.add_item("Add Lamp Post", 1) + 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 ModBison", 3) + 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) @@ -234,6 +274,16 @@ func _build_top_menu_bar(parent: Control) -> void: 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) @@ -242,13 +292,13 @@ func _build_toolbar(parent: Control) -> void: _toolbar.custom_minimum_size = Vector2(0, 36) _toolbar.add_theme_constant_override("separation", 6) - _btn_select = _add_tool_btn("โ†– Select", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.NONE)) - _btn_move = _add_tool_btn("โœ› Move", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE)) - _btn_rotate = _add_tool_btn("โ†ป Rotate", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.ROTATE)) - _btn_scale = _add_tool_btn("โคข Scale", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.SCALE)) + _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 vsep := VSeparator.new() - _toolbar.add_child(vsep) + var vsep1 := VSeparator.new() + _toolbar.add_child(vsep1) var snap_lbl := Label.new() snap_lbl.text = "Snap:" @@ -266,10 +316,26 @@ func _build_toolbar(parent: Control) -> void: ) _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)) @@ -283,6 +349,24 @@ func _build_toolbar(parent: Control) -> void: _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) @@ -294,6 +378,33 @@ func _add_tool_btn(text: String, callback: Callable) -> Button: 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 new_id := core.place_object(cat_id, spawn_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { + "display_name": display_name, + "catalog_id": cat_id + }) + if new_id != "": + core.select_object(new_id) + _spawn_visual_node(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() @@ -301,7 +412,7 @@ func _setup_3d_viewport_scene(vp: SubViewport) -> void: 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.6, 0.6, 0.65) + environment.ambient_light_color = Color(0.65, 0.65, 0.7) env.environment = environment vp.add_child(env) @@ -311,7 +422,17 @@ func _setup_3d_viewport_scene(vp: SubViewport) -> void: sun.shadow_enabled = true vp.add_child(sun) - # 3D Grid floor + # Studio Baseplate Ground Mesh + _baseplate_mesh = MeshInstance3D.new() + var plane := PlaneMesh.new() + plane.size = Vector2(64, 64) + 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) + + # 3D Grid floor lines var grid := _create_3d_grid(32, 1.0) vp.add_child(grid) @@ -332,21 +453,45 @@ func _setup_3d_viewport_scene(vp: SubViewport) -> void: 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 to Select | โœ› [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.4, 0.45, 0.5) + 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, -half)) - imm.surface_add_vertex(Vector3(p, 0, half)) - imm.surface_add_vertex(Vector3(-half, 0, p)) - imm.surface_add_vertex(Vector3(half, 0, p)) + 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 @@ -372,19 +517,152 @@ func _unhandled_key_input(event: InputEvent) -> void: 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_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 = core.selection.get_selected_id() if core.selection.has_method("get_selected_id") else "" + 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 _set_gizmo_mode(mode: TransformGizmo3DClass.Mode) -> void: if _gizmo_3d != null: _gizmo_3d.set_mode(mode) - _status_bar_label.text = "Gizmo Mode: %s" % str(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 & Raycast Picking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func handle_viewport_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: + var hit_obj := _raycast_find_object(mb.position) + if hit_obj != null: + 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 handles or [2-4] switch tools" % hit_obj.name + else: + core.select_object("") + _status_bar_label.text = "Deselected | Click an object or [+ Add] to build" + + +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 != "": + new_id = core.place_object_stacked(cat_id, target_obj.name, drop_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: + new_id = core.place_object(cat_id, drop_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 != "": + core.select_object(new_id) + _spawn_visual_node(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 + + +# โ”€โ”€ Selection & Scene Visual Sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + func _on_selection_changed(obj_id: String) -> void: if obj_id == "": _gizmo_3d.attach_to_node(null) @@ -398,8 +676,11 @@ func _on_selection_changed(obj_id: String) -> void: func _on_collection_model_selected(model_data: Dictionary) -> void: var cat_id := str(model_data.get("catalog_id", "crate_small")) - var new_pos := Vector3(0, 0, 0) - var new_id := core.place_object(cat_id, new_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { + var spawn_pos: Vector3 = Vector3.ZERO + if _camera_3d != null: + spawn_pos = _camera_3d.focal_point + spawn_pos.y = 0.0 + var new_id := core.place_object(cat_id, spawn_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", "") @@ -408,6 +689,8 @@ func _on_collection_model_selected(model_data: Dictionary) -> void: core.select_object(new_id) _spawn_visual_node(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: @@ -436,6 +719,8 @@ func _refresh_scene_visuals() -> void: _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") @@ -451,6 +736,11 @@ func load_template(template_name: String) -> void: obj.get("properties", {}) ) _refresh_scene_visuals() + # Select mascot or first object to showcase inspector & gizmo + if not core.model.payload.objects.is_empty(): + core.select_object(core.model.payload.objects[0].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: @@ -482,11 +772,16 @@ func _update_simulation_ui(playing: bool) -> void: _status_bar_label.text = "Simulation: PLAYING (F5 to Stop)" if playing else "Simulation: STOPPED (Edit mode)" +# โ”€โ”€ Menu Bar Dispatch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + func _on_file_menu_pressed(id: int) -> void: match id: 0: load_template("baseplate") 2: save_current_project() - 5: get_tree().quit(0) + 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: @@ -503,12 +798,25 @@ func _on_view_menu_pressed(id: int) -> void: 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 = core.selection.get_selected_id() if core.selection.has_method("get_selected_id") else "" + 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_ids := ["crate_small", "lamp_post", "tree_pine", "bison"] - if id >= 0 and id < cat_ids.size(): - _on_collection_model_selected({"catalog_id": cat_ids[id], "name": cat_ids[id].capitalize()}) + 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: @@ -523,6 +831,14 @@ func _on_publish_menu_pressed(id: int) -> void: 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()):