Files
modbison/scripts/studio/studio_main.gd
T

1182 lines
41 KiB
GDScript

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")