feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2
checkpoint / checkpoint (push) Successful in 6m32s

This commit is contained in:
2026-09-22 18:30:57 +00:00
parent 654c1ec6bf
commit 721dbe5ae7
37 changed files with 5202 additions and 17 deletions
+116
View File
@@ -0,0 +1,116 @@
class_name AssetCacheManager
extends RefCounted
## Manages client-side disk caching of downloaded 3D model assets and collections.
## Caches assets locally under user://asset_cache/ to prevent duplicate network fetches.
const CACHE_DIR: String = "user://asset_cache"
static var _instance = null
static func get_instance():
if _instance == null:
var script := load("res://scripts/studio/asset_cache_manager.gd")
_instance = script.new()
return _instance
func _init() -> void:
_ensure_cache_dir()
func _ensure_cache_dir() -> void:
if not DirAccess.dir_exists_absolute(CACHE_DIR):
DirAccess.make_dir_recursive_absolute(CACHE_DIR)
func get_cache_path(asset_key: String) -> String:
var clean_key := asset_key.replace("/", "_").replace("\\", "_").replace(":", "_")
if not clean_key.ends_with(".glb") and not clean_key.ends_with(".gltf"):
clean_key += ".glb"
return "%s/%s" % [CACHE_DIR, clean_key]
func has_cached(asset_key: String) -> bool:
var p := get_cache_path(asset_key)
return FileAccess.file_exists(p)
func get_cached_path_if_exists(asset_key: String) -> String:
var p := get_cache_path(asset_key)
if FileAccess.file_exists(p):
return p
return ""
func store_asset(asset_key: String, data: PackedByteArray) -> String:
_ensure_cache_dir()
var path := get_cache_path(asset_key)
var fa := FileAccess.open(path, FileAccess.WRITE)
if fa != null:
fa.store_buffer(data)
fa.close()
return path
return ""
func read_asset(asset_key: String) -> PackedByteArray:
var path := get_cache_path(asset_key)
if not FileAccess.file_exists(path):
return PackedByteArray()
var fa := FileAccess.open(path, FileAccess.READ)
if fa != null:
var buf := fa.get_buffer(fa.get_length())
fa.close()
return buf
return PackedByteArray()
func load_model(asset_key: String) -> Node:
var path := get_cached_path_if_exists(asset_key)
if path == "":
return null
if path.ends_with(".glb") or path.ends_with(".gltf"):
var gltf := GLTFDocument.new()
var state := GLTFState.new()
var err := gltf.append_from_file(path, state)
if err == OK:
return gltf.generate_scene(state)
elif ResourceLoader.exists(path):
var res := load(path)
if res is PackedScene:
return (res as PackedScene).instantiate()
return null
func get_cached_items() -> Array[String]:
_ensure_cache_dir()
var items: Array[String] = []
var dir := DirAccess.open(CACHE_DIR)
if dir != null:
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if not dir.current_is_dir() and not file_name.begins_with("."):
items.append(file_name)
file_name = dir.get_next()
dir.list_dir_end()
return items
func clear_cache() -> int:
_ensure_cache_dir()
var count := 0
var dir := DirAccess.open(CACHE_DIR)
if dir != null:
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if not dir.current_is_dir():
DirAccess.remove_absolute("%s/%s" % [CACHE_DIR, file_name])
count += 1
file_name = dir.get_next()
dir.list_dir_end()
return count
+165
View File
@@ -0,0 +1,165 @@
class_name TransformGizmo3D
extends Node3D
## Interactive 3D manipulator gizmo supporting Translate, Rotate, and Scale modes with snapping.
signal transform_started
signal transform_updated(mode: String, delta: Variant)
signal transform_finished(final_pos: Vector3, final_rot: Vector3, final_scale: Vector3)
enum Mode { TRANSLATE, ROTATE, SCALE, NONE }
enum Axis { NONE, X, Y, Z, XY, XZ, YZ }
var current_mode: Mode = Mode.TRANSLATE
var active_axis: Axis = Axis.NONE
var is_dragging: bool = false
# Snapping configuration
var snap_enabled: bool = true
var snap_translate: float = 1.0
var snap_rotate_deg: float = 15.0
var snap_scale: float = 0.5
var target_node: Node3D = null
var _drag_start_pos: Vector3 = Vector3.ZERO
var _drag_start_rot: Vector3 = Vector3.ZERO
var _drag_start_scale: Vector3 = Vector3.ONE
func _ready() -> void:
_build_gizmo_visuals()
func attach_to_node(node: Node3D) -> void:
target_node = node
if target_node != null:
visible = true
global_position = target_node.global_position
global_rotation = target_node.global_rotation
else:
visible = false
func set_mode(new_mode: Mode) -> void:
current_mode = new_mode
_update_visual_mode()
func set_snap(enabled: bool, translate_step: float = 1.0, rotate_step: float = 15.0) -> void:
snap_enabled = enabled
snap_translate = translate_step
snap_rotate_deg = rotate_step
func _build_gizmo_visuals() -> void:
# Build colored 3D axis handles for X (Red), Y (Green), Z (Blue)
var x_mat := StandardMaterial3D.new()
x_mat.albedo_color = Color(0.9, 0.2, 0.2)
x_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
var y_mat := StandardMaterial3D.new()
y_mat.albedo_color = Color(0.2, 0.85, 0.2)
y_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
var z_mat := StandardMaterial3D.new()
z_mat.albedo_color = Color(0.2, 0.4, 0.95)
z_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
# X axis line + arrow
var x_axis := _create_axis_cylinder(Vector3(1, 0, 0), x_mat)
x_axis.name = "AxisX"
add_child(x_axis)
# Y axis line + arrow
var y_axis := _create_axis_cylinder(Vector3(0, 1, 0), y_mat)
y_axis.name = "AxisY"
add_child(y_axis)
# Z axis line + arrow
var z_axis := _create_axis_cylinder(Vector3(0, 0, 1), z_mat)
z_axis.name = "AxisZ"
add_child(z_axis)
func _create_axis_cylinder(dir: Vector3, mat: Material) -> Node3D:
var container := Node3D.new()
var shaft := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = 0.03
cyl.bottom_radius = 0.03
cyl.height = 1.5
cyl.material = mat
shaft.mesh = cyl
# Point cylinder along direction
if dir == Vector3(1, 0, 0):
shaft.rotation_degrees = Vector3(0, 0, -90)
shaft.position = Vector3(0.75, 0, 0)
elif dir == Vector3(0, 1, 0):
shaft.position = Vector3(0, 0.75, 0)
elif dir == Vector3(0, 0, 1):
shaft.rotation_degrees = Vector3(90, 0, 0)
shaft.position = Vector3(0, 0, 0.75)
container.add_child(shaft)
return container
func _update_visual_mode() -> void:
visible = (target_node != null and current_mode != Mode.NONE)
func start_drag(axis: Axis) -> void:
if target_node == null:
return
active_axis = axis
is_dragging = true
_drag_start_pos = target_node.position
_drag_start_rot = target_node.rotation_degrees
_drag_start_scale = target_node.scale
transform_started.emit()
func apply_drag_delta(delta_vec: Vector3) -> void:
if not is_dragging or target_node == null:
return
match current_mode:
Mode.TRANSLATE:
var new_pos := target_node.position + delta_vec
if snap_enabled and snap_translate > 0.0:
new_pos.x = snappedf(new_pos.x, snap_translate)
new_pos.y = snappedf(new_pos.y, snap_translate)
new_pos.z = snappedf(new_pos.z, snap_translate)
target_node.position = new_pos
global_position = target_node.global_position
transform_updated.emit("translate", new_pos)
Mode.ROTATE:
var rot := target_node.rotation_degrees + delta_vec * 10.0
if snap_enabled and snap_rotate_deg > 0.0:
rot.x = snappedf(rot.x, snap_rotate_deg)
rot.y = snappedf(rot.y, snap_rotate_deg)
rot.z = snappedf(rot.z, snap_rotate_deg)
target_node.rotation_degrees = rot
transform_updated.emit("rotate", rot)
Mode.SCALE:
var sc := target_node.scale + delta_vec
sc.x = maxf(0.1, sc.x)
sc.y = maxf(0.1, sc.y)
sc.z = maxf(0.1, sc.z)
if snap_enabled and snap_scale > 0.0:
sc.x = snappedf(sc.x, snap_scale)
sc.y = snappedf(sc.y, snap_scale)
sc.z = snappedf(sc.z, snap_scale)
target_node.scale = sc
transform_updated.emit("scale", sc)
func finish_drag() -> void:
if not is_dragging:
return
is_dragging = false
active_axis = Axis.NONE
if target_node != null:
transform_finished.emit(target_node.position, target_node.rotation_degrees, target_node.scale)
+164
View File
@@ -0,0 +1,164 @@
class_name MBWorldProjectIO
extends RefCounted
## Manages serialization and deserialization of local .mbworld project files
## and cloud sync with ModBison backend.
const RECENT_PROJECTS_PATH: String = "user://recent_projects.json"
static func save_project(path: String, project_data: Dictionary) -> bool:
var fa := FileAccess.open(path, FileAccess.WRITE)
if fa == null:
push_error("Failed to open %s for write" % path)
return false
var json_str := JSON.stringify(project_data, " ")
fa.store_string(json_str)
fa.close()
record_recent_project(path)
return true
static func load_project(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
push_error("Project file does not exist: %s" % path)
return {}
var fa := FileAccess.open(path, FileAccess.READ)
if fa == null:
return {}
var text := fa.get_as_text()
fa.close()
var parsed = JSON.parse_string(text)
if parsed is Dictionary:
record_recent_project(path)
return parsed
return {}
static func create_template(template_name: String) -> Dictionary:
var base := {
"format_version": 1,
"title": "Untitled World",
"template": template_name,
"created_at": Time.get_datetime_string_from_system(),
"objects": [],
"tile_layers": [
{
"name": "ground",
"width": 32,
"height": 32,
"tiles": []
}
],
"settings": {
"ambient_color": [0.4, 0.4, 0.45],
"sky_color": [0.3, 0.6, 0.9],
"gravity": 9.8
}
}
match template_name.to_lower():
"baseplate":
base["title"] = "Baseplate World"
base["objects"] = [
{
"id": "spawn_pad",
"catalog_id": "spawn_pad",
"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"}
}
]
"terrain":
base["title"] = "Rolling Hills World"
base["objects"] = [
{
"id": "tree_01",
"catalog_id": "tree_pine",
"asset_id": "props/tree_pine_01.glb",
"position": [4, 4, 0],
"rotation": [0, 0, 0, 1],
"scale": [1, 1, 1],
"properties": {"display_name": "Pine Tree"}
},
{
"id": "bison_01",
"catalog_id": "bison",
"asset_id": "props/bison.glb",
"position": [0, 6, 0],
"rotation": [0, 0, 0, 1],
"scale": [1, 1, 1],
"properties": {"display_name": "Wild Bison"}
}
]
"obby", "platformer":
base["title"] = "Obby Parkour World"
base["objects"] = [
{
"id": "start_platform",
"catalog_id": "crate_small",
"asset_id": "props/crate_small.glb",
"position": [0, 0, 0],
"rotation": [0, 0, 0, 1],
"scale": [3, 3, 0.5],
"properties": {"display_name": "Start Platform"}
},
{
"id": "moving_plat_01",
"catalog_id": "crate_small",
"asset_id": "props/crate_small.glb",
"position": [0, 6, 1],
"rotation": [0, 0, 0, 1],
"scale": [2, 2, 0.3],
"properties": {
"display_name": "Moving Elevator",
"motion": {
"motion_type": "waypoint",
"move_speed": 3.0,
"waypoints": [[0, 6, 1], [0, 6, 6]]
}
}
},
{
"id": "goal_platform",
"catalog_id": "crate_small",
"asset_id": "props/crate_small.glb",
"position": [0, 14, 6],
"rotation": [0, 0, 0, 1],
"scale": [3, 3, 0.5],
"properties": {"display_name": "Finish Goal"}
}
]
return base
static func record_recent_project(path: String) -> void:
var recents: Array = get_recent_projects()
if path in recents:
recents.erase(path)
recents.push_front(path)
if recents.size() > 10:
recents.resize(10)
var fa := FileAccess.open(RECENT_PROJECTS_PATH, FileAccess.WRITE)
if fa != null:
fa.store_string(JSON.stringify(recents))
fa.close()
static func get_recent_projects() -> Array:
if not FileAccess.file_exists(RECENT_PROJECTS_PATH):
return []
var fa := FileAccess.open(RECENT_PROJECTS_PATH, FileAccess.READ)
if fa == null:
return []
var parsed = JSON.parse_string(fa.get_as_text())
fa.close()
if parsed is Array:
return parsed
return []
+268
View File
@@ -0,0 +1,268 @@
class_name StudioCollectionsDock
extends VBoxContainer
## Model Collections Browser Dock for ModBison Studio.
## Provides collection browsing, theme filtering, item previews,
## and drag-and-drop model placement into the 3D viewport.
signal model_selected(model_data: Dictionary)
signal model_drag_started(model_data: Dictionary)
signal collection_selected(collection_id: int)
const AssetCacheManagerClass = preload("res://scripts/studio/asset_cache_manager.gd")
var _search_edit: LineEdit
var _theme_option: OptionButton
var _collections_scroll: ScrollContainer
var _collections_vbox: VBoxContainer
var _models_grid: GridContainer
var _status_label: Label
var _collections_data: Array[Dictionary] = []
var _selected_collection_id: int = -1
var _cache_mgr = null
var _is_built: bool = false
func _init() -> void:
name = "StudioCollectionsDock"
custom_minimum_size = Vector2(280, 400)
size_flags_vertical = Control.SIZE_EXPAND_FILL
_cache_mgr = AssetCacheManagerClass.get_instance()
if not _is_built:
_build_ui()
_load_default_preset_collections()
func _ready() -> void:
if not _is_built:
_build_ui()
_load_default_preset_collections()
func _build_ui() -> void:
if _is_built:
return
_is_built = true
var header := Label.new()
header.text = "Poly Model Collections"
header.add_theme_font_size_override("font_size", 14)
header.add_theme_color_override("font_color", Color(1.0, 0.9, 0.35))
add_child(header)
var sub := Label.new()
sub.text = "Curated themed 3D model packs. Drag onto viewport to place."
sub.add_theme_font_size_override("font_size", 10)
sub.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
sub.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(sub)
# Search Bar
_search_edit = LineEdit.new()
_search_edit.placeholder_text = "🔍 Search collections & models..."
_search_edit.text_changed.connect(_on_search_changed)
add_child(_search_edit)
# Theme Filter
var filter_row := HBoxContainer.new()
var theme_lbl := Label.new()
theme_lbl.text = "Theme:"
theme_lbl.add_theme_font_size_override("font_size", 11)
filter_row.add_child(theme_lbl)
_theme_option = OptionButton.new()
_theme_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_theme_option.add_item("All Themes", 0)
_theme_option.set_item_metadata(0, "")
var themes := ["fantasy", "medieval", "nature", "sci-fi", "urban", "industrial", "props"]
for i in range(themes.size()):
var t: String = themes[i]
_theme_option.add_item(t.capitalize(), i + 1)
_theme_option.set_item_metadata(i + 1, t)
_theme_option.item_selected.connect(_on_theme_selected)
filter_row.add_child(_theme_option)
add_child(filter_row)
# Scroll area for collections
_collections_scroll = ScrollContainer.new()
_collections_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_collections_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(_collections_scroll)
_collections_vbox = VBoxContainer.new()
_collections_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_collections_scroll.add_child(_collections_vbox)
_status_label = Label.new()
_status_label.text = "Ready"
_status_label.add_theme_font_size_override("font_size", 10)
_status_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
add_child(_status_label)
func _load_default_preset_collections() -> void:
# Built-in collections for offline / studio design
var fantasy_pack := {
"id": 101,
"title": "Fantasy Ruins & Treasure",
"theme": "fantasy",
"description": "Stone pillars, treasure chests, and fantasy dungeon props",
"items": [
{"id": 1, "name": "Treasure Chest", "catalog_id": "chest_01", "asset_id": "props/chest.glb", "theme": "fantasy"},
{"id": 2, "name": "Buffalo Coin", "catalog_id": "coin_01", "asset_id": "props/buffalo_chip_coin.glb", "theme": "fantasy"},
{"id": 3, "name": "Pine Tree", "catalog_id": "tree_pine", "asset_id": "props/tree_pine_01.glb", "theme": "nature"},
]
}
var props_pack := {
"id": 102,
"title": "Town & Village Props",
"theme": "medieval",
"description": "Street lamps, wooden crates, and boundary rocks",
"items": [
{"id": 4, "name": "Wooden Crate", "catalog_id": "crate_small", "asset_id": "props/crate_small.glb", "theme": "props"},
{"id": 5, "name": "Street Lamp", "catalog_id": "lamp_post", "asset_id": "props/lamp_post_01.glb", "theme": "urban"},
{"id": 6, "name": "Garden Rock", "catalog_id": "rock_01", "asset_id": "props/rock_01.glb", "theme": "nature"},
]
}
var bison_pack := {
"id": 103,
"title": "Wildlife & Creatures",
"theme": "nature",
"description": "Wild Bison and terrain fauna",
"items": [
{"id": 7, "name": "ModBison", "catalog_id": "bison", "asset_id": "props/bison.glb", "theme": "nature"},
]
}
_collections_data = [fantasy_pack, props_pack, bison_pack]
_refresh_collections_display()
func add_collection(col_dict: Dictionary) -> void:
_collections_data.append(col_dict)
_refresh_collections_display()
func set_collections(cols: Array[Dictionary]) -> void:
_collections_data = cols
_refresh_collections_display()
func _on_search_changed(_new_text: String) -> void:
_refresh_collections_display()
func _on_theme_selected(_idx: int) -> void:
_refresh_collections_display()
func _refresh_collections_display() -> void:
for c in _collections_vbox.get_children():
c.queue_free()
var search_text := _search_edit.text.strip_edges().to_lower()
var selected_theme := ""
if _theme_option != null:
var meta = _theme_option.get_item_metadata(_theme_option.selected)
if meta != null:
selected_theme = str(meta).to_lower()
var matched_count := 0
for col in _collections_data:
var col_theme: String = str(col.get("theme", "")).to_lower()
var col_title: String = str(col.get("title", ""))
var col_desc: String = str(col.get("description", ""))
if selected_theme != "" and col_theme != selected_theme:
continue
if search_text != "":
var matches_title := col_title.to_lower().contains(search_text)
var matches_desc := col_desc.to_lower().contains(search_text)
var matches_item := false
for item in col.get("items", []):
if str(item.get("name", "")).to_lower().contains(search_text):
matches_item = true
break
if not matches_title and not matches_desc and not matches_item:
continue
var card := _create_collection_card(col)
_collections_vbox.add_child(card)
matched_count += 1
if matched_count == 0:
var empty_lbl := Label.new()
empty_lbl.text = "No collections found matching filter."
empty_lbl.add_theme_font_size_override("font_size", 11)
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
_collections_vbox.add_child(empty_lbl)
_status_label.text = "Showing %d collections" % matched_count
func _create_collection_card(col: Dictionary) -> Control:
var panel := PanelContainer.new()
var vbox := VBoxContainer.new()
panel.add_child(vbox)
var title_lbl := Label.new()
title_lbl.text = "📦 " + str(col.get("title", "Collection"))
title_lbl.add_theme_font_size_override("font_size", 12)
title_lbl.add_theme_color_override("font_color", Color(0.3, 0.85, 1.0))
vbox.add_child(title_lbl)
var theme_lbl := Label.new()
theme_lbl.text = "Theme: %s | %d models" % [str(col.get("theme", "general")).capitalize(), (col.get("items", []) as Array).size()]
theme_lbl.add_theme_font_size_override("font_size", 10)
theme_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
vbox.add_child(theme_lbl)
# Grid of models in this collection
var items: Array = col.get("items", [])
var grid := GridContainer.new()
grid.columns = 2
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
for item in items:
if item is Dictionary:
var btn := _create_model_item_button(item)
grid.add_child(btn)
vbox.add_child(grid)
return panel
func _create_model_item_button(item: Dictionary) -> Button:
var btn := ModelDragButton.new()
btn.model_data = item
btn.text = "🔹 " + str(item.get("name", "Model"))
btn.custom_minimum_size = Vector2(120, 28)
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
btn.tooltip_text = "Click to select or drag directly into 3D viewport"
btn.pressed.connect(func():
model_selected.emit(item)
_status_label.text = "Selected: %s" % item.get("name")
)
btn.drag_started.connect(func(data):
model_drag_started.emit(data)
)
return btn
class ModelDragButton extends Button:
signal drag_started(data: Dictionary)
var model_data: Dictionary = {}
func _get_drag_data(_at_position: Vector2) -> Variant:
var preview := Label.new()
preview.text = "📦 " + str(model_data.get("name", "Model"))
preview.add_theme_color_override("font_color", Color(1.0, 0.95, 0.2))
set_drag_preview(preview)
drag_started.emit(model_data)
return {
"type": "model_placement",
"catalog_id": model_data.get("catalog_id", ""),
"asset_id": model_data.get("asset_id", ""),
"model_name": model_data.get("name", ""),
"model_data": model_data
}
+181
View File
@@ -0,0 +1,181 @@
class_name StudioHierarchyDock
extends VBoxContainer
## Scene Hierarchy Tree Dock for ModBison Studio.
## Displays all world objects, supports search filtering, selection sync,
## parent-child hierarchies, duplicate, and deletion.
signal object_selected(object_id: String)
signal delete_requested(object_id: String)
signal duplicate_requested(object_id: String)
signal group_created(object_ids: Array[String])
var _search_edit: LineEdit
var _tree: Tree
var _tree_root: TreeItem
var _btn_delete: Button
var _btn_duplicate: Button
var _btn_group: Button
var _items_by_id: Dictionary = {}
var _updating: bool = false
var _core: EditorCore = null
func _init() -> void:
name = "StudioHierarchyDock"
custom_minimum_size = Vector2(240, 300)
size_flags_vertical = Control.SIZE_EXPAND_FILL
_build_ui()
func setup(core: EditorCore) -> void:
_core = core
if _core != null and _core.selection != null:
_core.selection.selection_changed.connect(_on_core_selection_changed)
refresh_tree()
func _build_ui() -> void:
var header := Label.new()
header.text = "Scene Hierarchy"
header.add_theme_font_size_override("font_size", 13)
header.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4))
add_child(header)
_search_edit = LineEdit.new()
_search_edit.placeholder_text = "🔍 Search hierarchy..."
_search_edit.text_changed.connect(_on_search_changed)
add_child(_search_edit)
_tree = Tree.new()
_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
_tree.select_mode = Tree.SELECT_MULTI
_tree.item_selected.connect(_on_tree_item_selected)
add_child(_tree)
# Action buttons row
var btn_row := HBoxContainer.new()
_btn_duplicate = Button.new()
_btn_duplicate.text = "Duplicate"
_btn_duplicate.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_btn_duplicate.pressed.connect(_on_duplicate_pressed)
btn_row.add_child(_btn_duplicate)
_btn_group = Button.new()
_btn_group.text = "Group"
_btn_group.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_btn_group.pressed.connect(_on_group_pressed)
btn_row.add_child(_btn_group)
_btn_delete = Button.new()
_btn_delete.text = "Delete"
_btn_delete.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
_btn_delete.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_btn_delete.pressed.connect(_on_delete_pressed)
btn_row.add_child(_btn_delete)
add_child(btn_row)
func refresh_tree() -> void:
_tree.clear()
_items_by_id.clear()
_tree_root = _tree.create_item()
_tree_root.set_text(0, "World Scene")
if _core == null or _core.model == null:
return
var filter := _search_edit.text.strip_edges().to_lower()
var objects = _core.model.payload.objects
# First pass: identify parents and root objects
for obj in objects:
var parent_id := ""
if obj.properties is Dictionary:
parent_id = str(obj.properties.get("parent_id", ""))
var dname: String = str(obj.id)
if obj.properties is Dictionary and obj.properties.has("display_name"):
dname = str(obj.properties["display_name"])
if filter != "" and not dname.to_lower().contains(filter) and not obj.id.to_lower().contains(filter):
continue
var item := _tree.create_item(_tree_root)
item.set_text(0, "🔹 " + dname)
item.set_metadata(0, obj.id)
_items_by_id[obj.id] = item
# Highlight current selection
if _core.selection != null:
var sel := _core.selection.get_selected()
if sel != "" and _items_by_id.has(sel):
_items_by_id[sel].select(0)
func _on_search_changed(_text: String) -> void:
refresh_tree()
func _on_tree_item_selected() -> void:
if _updating:
return
var selected_item := _tree.get_selected()
if selected_item != null:
var obj_id: String = str(selected_item.get_metadata(0))
if obj_id != "":
object_selected.emit(obj_id)
if _core != null:
_updating = true
_core.select_object(obj_id)
_updating = false
func _on_core_selection_changed(obj_id: String) -> void:
if _updating:
return
_updating = true
_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()
_updating = false
func _on_delete_pressed() -> void:
if _core == null:
return
var sel := _core.selection.get_selected()
if sel != "":
delete_requested.emit(sel)
_core.delete_object(sel)
refresh_tree()
func _on_duplicate_pressed() -> void:
if _core == null:
return
var sel := _core.selection.get_selected()
if sel != "":
var obj := _core.model.find_object(sel)
if obj != null:
var new_pos := obj.position + Vector3(1.0, 1.0, 0.0)
var cat_id := str(obj.properties.get("catalog_id", "crate_small"))
var new_id := _core.place_object(cat_id, new_pos, obj.rotation, obj.scale, obj.properties.duplicate(true))
if new_id != "":
duplicate_requested.emit(new_id)
_core.select_object(new_id)
refresh_tree()
func _on_group_pressed() -> void:
var selected_ids: Array[String] = []
var item := _tree.get_next_selected(null)
while item != null:
var meta = item.get_metadata(0)
if meta != null and str(meta) != "":
selected_ids.append(str(meta))
item = _tree.get_next_selected(item)
if selected_ids.size() >= 2:
group_created.emit(selected_ids)
+185
View File
@@ -0,0 +1,185 @@
class_name StudioInspectorDock
extends VBoxContainer
## Property Inspector Dock for ModBison Studio.
## Renders editable properties for selected world object(s) with undo/redo support.
signal property_changed(object_id: String, key: String, value: Variant)
signal delete_requested(object_id: String)
var _core: EditorCore = null
var _selected_id: String = ""
var _updating: bool = false
var _title_label: Label
var _empty_label: Label
var _scroll: ScrollContainer
var _content_vbox: VBoxContainer
# Transform fields
var _pos_x: SpinBox
var _pos_y: SpinBox
var _pos_z: SpinBox
var _rot_y: SpinBox
var _scale_x: SpinBox
var _scale_y: SpinBox
var _scale_z: SpinBox
# Metadata
var _name_edit: LineEdit
var _asset_label: Label
func _init() -> void:
name = "StudioInspectorDock"
custom_minimum_size = Vector2(240, 300)
size_flags_vertical = Control.SIZE_EXPAND_FILL
_build_ui()
func setup(core: EditorCore) -> void:
_core = core
if _core != null and _core.selection != null:
_core.selection.selection_changed.connect(_on_selection_changed)
func _build_ui() -> void:
_title_label = Label.new()
_title_label.text = "Properties"
_title_label.add_theme_font_size_override("font_size", 13)
_title_label.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4))
add_child(_title_label)
_empty_label = Label.new()
_empty_label.text = "(No selection)"
_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))
add_child(_empty_label)
_scroll = ScrollContainer.new()
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(_scroll)
_content_vbox = VBoxContainer.new()
_content_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_content_vbox.visible = false
_scroll.add_child(_content_vbox)
# Name field
var name_lbl := Label.new()
name_lbl.text = "Display Name:"
name_lbl.add_theme_font_size_override("font_size", 10)
_content_vbox.add_child(name_lbl)
_name_edit = LineEdit.new()
_name_edit.text_submitted.connect(_on_name_changed)
_content_vbox.add_child(_name_edit)
_asset_label = Label.new()
_asset_label.text = "Asset: None"
_asset_label.add_theme_font_size_override("font_size", 10)
_asset_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
_content_vbox.add_child(_asset_label)
# Transform header
var t_header := Label.new()
t_header.text = "Transform"
t_header.add_theme_font_size_override("font_size", 11)
t_header.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0))
_content_vbox.add_child(t_header)
_pos_x = _add_spin("Pos X", _on_pos_changed)
_pos_y = _add_spin("Pos Y", _on_pos_changed)
_pos_z = _add_spin("Pos Z", _on_pos_changed)
_rot_y = _add_spin("Yaw (°)", _on_rot_changed, -360.0, 360.0, 1.0)
_scale_x = _add_spin("Scale X", _on_scale_changed, 0.05, 50.0, 0.1)
_scale_y = _add_spin("Scale Y", _on_scale_changed, 0.05, 50.0, 0.1)
_scale_z = _add_spin("Scale Z", _on_scale_changed, 0.05, 50.0, 0.1)
func _add_spin(label: String, callback: Callable, min_val: float = -500.0, max_val: float = 500.0, step: float = 0.5) -> SpinBox:
var row := HBoxContainer.new()
var lbl := Label.new()
lbl.text = label + ":"
lbl.custom_minimum_size = Vector2(70, 0)
lbl.add_theme_font_size_override("font_size", 10)
row.add_child(lbl)
var spin := SpinBox.new()
spin.min_value = min_val
spin.max_value = max_val
spin.step = step
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.value_changed.connect(callback)
row.add_child(spin)
_content_vbox.add_child(row)
return spin
func _on_selection_changed(obj_id: String) -> void:
_selected_id = obj_id
if _selected_id == "" or _core == null:
_empty_label.visible = true
_content_vbox.visible = false
return
var obj = _core.model.find_object(_selected_id)
if obj == null:
_empty_label.visible = true
_content_vbox.visible = false
return
_updating = true
_empty_label.visible = false
_content_vbox.visible = true
var dname: String = str(obj.id)
if obj.properties is Dictionary and obj.properties.has("display_name"):
dname = str(obj.properties["display_name"])
_name_edit.text = dname
_asset_label.text = "Asset: %s" % obj.asset_id
_pos_x.value = obj.position.x
_pos_y.value = obj.position.y
_pos_z.value = obj.position.z
var euler = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w).get_euler()
_rot_y.value = rad_to_deg(euler.y)
_scale_x.value = obj.scale.x
_scale_y.value = obj.scale.y
_scale_z.value = obj.scale.z
_updating = false
func _on_name_changed(new_name: String) -> void:
if _updating or _core == null or _selected_id == "":
return
_core.set_object_property(_selected_id, "display_name", new_name)
property_changed.emit(_selected_id, "display_name", new_name)
func _on_pos_changed(_val: float) -> void:
if _updating or _core == null or _selected_id == "":
return
var new_pos := Vector3(_pos_x.value, _pos_y.value, _pos_z.value)
_core.move_object(_selected_id, new_pos)
property_changed.emit(_selected_id, "position", new_pos)
func _on_rot_changed(_val: float) -> void:
if _updating or _core == null or _selected_id == "":
return
var quat := Quaternion.from_euler(Vector3(0, deg_to_rad(_rot_y.value), 0))
var rot_vec := Vector4(quat.x, quat.y, quat.z, quat.w)
_core.rotate_object(_selected_id, rot_vec)
property_changed.emit(_selected_id, "rotation", rot_vec)
func _on_scale_changed(_val: float) -> void:
if _updating or _core == null or _selected_id == "":
return
var new_scale := Vector3(_scale_x.value, _scale_y.value, _scale_z.value)
_core.scale_object(_selected_id, new_scale)
property_changed.emit(_selected_id, "scale", new_scale)
+535
View File
@@ -0,0 +1,535 @@
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.
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 StaticObjectNodeClass = preload("res://scripts/static_object_node.gd")
const WorldObjectClass = preload("res://scripts/world_object.gd")
# 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 _sub_viewport: SubViewport
var _camera_3d: Camera3D
var _world_container: Node3D
var _gizmo_3d: Node3D
# Docks
var _hierarchy_dock = null
var _inspector_dock = null
var _collections_dock = null
var _simulation_mgr = 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()
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.14, 0.15, 0.18)
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
_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: 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)
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)
_sub_viewport = SubViewport.new()
_sub_viewport.handle_input_locally = true
vp_container.add_child(_sub_viewport)
_setup_3d_viewport_scene(_sub_viewport)
# Right Dock: TabContainer with Inspector & Collections
var right_panel := PanelContainer.new()
right_panel.custom_minimum_size = Vector2(280, 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)
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 | Grid: 1.0m | Mode: Select"
_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)
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...", 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 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.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 Crate Prop", 0)
model_menu.add_item("Add Lamp Post", 1)
model_menu.add_item("Add Pine Tree", 2)
model_menu.add_item("Add ModBison", 3)
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)
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("↖ 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))
var vsep := VSeparator.new()
_toolbar.add_child(vsep)
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.1
_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 spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_toolbar.add_child(spacer)
_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)
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 _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.6, 0.6, 0.65)
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)
# 3D Grid floor
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 _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.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_end()
mi.mesh = imm
return mi
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_F5:
if _simulation_mgr.is_playing():
_simulation_mgr.stop_simulation()
else:
_simulation_mgr.start_play(true)
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)
func _on_selection_changed(obj_id: String) -> void:
if obj_id == "":
_gizmo_3d.attach_to_node(null)
return
var obj_node = _world_container.get_node_or_null(obj_id)
if obj_node is Node3D:
_gizmo_3d.attach_to_node(obj_node)
_camera_3d.focus_on(obj_node.global_position)
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, {
"display_name": model_data.get("name", cat_id),
"catalog_id": cat_id,
"asset_id": model_data.get("asset_id", "")
})
if new_id != "":
core.select_object(new_id)
_spawn_visual_node(new_id)
_hierarchy_dock.refresh_tree()
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 = Vector3(obj.position.x, obj.position.z, obj.position.y)
_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()
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()
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)"
func _on_file_menu_pressed(id: int) -> void:
match id:
0: load_template("baseplate")
2: save_current_project()
5: 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: _hierarchy_dock._on_delete_pressed()
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)
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()})
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 _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")
+140
View File
@@ -0,0 +1,140 @@
class_name StudioSimulationManager
extends Node
## Manages Play / Run / Stop simulation harness for ModBison Studio.
## Takes full state snapshot before play and restores on stop.
signal simulation_started(mode: String)
signal simulation_stopped
enum SimulationState { EDIT, PLAYING, PAUSED }
var state: SimulationState = SimulationState.EDIT
var _snapshot: Array[Dictionary] = []
var _spawned_avatar: Node = null
var _core: EditorCore = null
var _world_container: Node3D = null
func setup(core: EditorCore, world_container: Node3D = null) -> void:
_core = core
_world_container = world_container
func start_play(spawn_player: bool = true) -> void:
if state == SimulationState.PLAYING:
return
# 1. Capture snapshot of all objects
_capture_snapshot()
state = SimulationState.PLAYING
# 2. Spawn test avatar if requested
if spawn_player and _world_container != null:
_spawn_test_avatar()
# 3. Activate moving objects
for m in _get_moving_nodes():
if m.has_method("start_motion"):
m.start_motion()
simulation_started.emit("play" if spawn_player else "run")
func pause_simulation() -> void:
if state != SimulationState.PLAYING:
return
state = SimulationState.PAUSED
for m in _get_moving_nodes():
if m.has_method("stop_motion"):
m.stop_motion()
func resume_simulation() -> void:
if state != SimulationState.PAUSED:
return
state = SimulationState.PLAYING
for m in _get_moving_nodes():
if m.has_method("start_motion"):
m.start_motion()
func stop_simulation() -> void:
if state == SimulationState.EDIT:
return
# 1. Stop all moving objects
for m in _get_moving_nodes():
if m.has_method("stop_motion"):
m.stop_motion()
# 2. Despawn test avatar
if _spawned_avatar != null and is_instance_valid(_spawned_avatar):
_spawned_avatar.queue_free()
_spawned_avatar = null
# 3. Restore state snapshot
_restore_snapshot()
state = SimulationState.EDIT
simulation_stopped.emit()
func _get_moving_nodes() -> Array:
if is_inside_tree() and get_tree() != null:
return get_tree().get_nodes_in_group("moving_objects")
var main_loop = Engine.get_main_loop()
if main_loop is SceneTree:
return (main_loop as SceneTree).get_nodes_in_group("moving_objects")
return []
func _capture_snapshot() -> void:
_snapshot.clear()
if _core == null or _core.model == null:
return
for obj in _core.model.payload.objects:
_snapshot.append({
"id": obj.id,
"position": obj.position,
"rotation": obj.rotation,
"scale": obj.scale,
"properties": obj.properties.duplicate(true)
})
func _restore_snapshot() -> void:
if _core == null or _core.model == null:
return
for snap in _snapshot:
var obj = _core.model.find_object(snap["id"])
if obj != null:
obj.position = snap["position"]
obj.rotation = snap["rotation"]
obj.scale = snap["scale"]
obj.properties = snap["properties"].duplicate(true)
_snapshot.clear()
func _spawn_test_avatar() -> void:
var player_scene = load("res://scenes/player.tscn")
if player_scene != null:
var p = player_scene.instantiate()
p.name = "TestStudioPlayer"
p.position = Vector3(0, 2, 0)
_world_container.add_child(p)
_spawned_avatar = p
else:
var p: CharacterBody3D = CharacterBody3D.new()
p.name = "TestStudioPlayer"
p.position = Vector3(0, 2, 0)
_world_container.add_child(p)
_spawned_avatar = p
func is_playing() -> bool:
return state == SimulationState.PLAYING
func is_editing() -> bool:
return state == SimulationState.EDIT
+152
View File
@@ -0,0 +1,152 @@
class_name StudioStartScreen
extends Control
## Studio Start Screen offering starter templates and recent projects browser.
signal template_selected(template_name: String)
signal project_opened(path: String)
const MBWorldProjectIOClass = preload("res://scripts/studio/mbworld_project_io.gd")
var _recent_list: ItemList
var _templates_container: HBoxContainer
var _btn_open_file: Button
var _file_dialog: FileDialog
func _ready() -> void:
_build_ui()
_populate_recent_projects()
func _build_ui() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
var bg := ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0.12, 0.13, 0.16)
add_child(bg)
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 60)
margin.add_theme_constant_override("margin_top", 40)
margin.add_theme_constant_override("margin_right", 60)
margin.add_theme_constant_override("margin_bottom", 40)
add_child(margin)
var root_vbox := VBoxContainer.new()
root_vbox.add_theme_constant_override("separation", 24)
margin.add_child(root_vbox)
# Header
var title_row := HBoxContainer.new()
var title := Label.new()
title.text = "🦬 ModBison Studio"
title.add_theme_font_size_override("font_size", 28)
title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.3))
title_row.add_child(title)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title_row.add_child(spacer)
_btn_open_file = Button.new()
_btn_open_file.text = "📂 Open from Disk..."
_btn_open_file.pressed.connect(_on_open_file_pressed)
title_row.add_child(_btn_open_file)
root_vbox.add_child(title_row)
# Starter Templates Section
var tmpl_lbl := Label.new()
tmpl_lbl.text = "Starter Templates"
tmpl_lbl.add_theme_font_size_override("font_size", 16)
tmpl_lbl.add_theme_color_override("font_color", Color(0.3, 0.85, 1.0))
root_vbox.add_child(tmpl_lbl)
_templates_container = HBoxContainer.new()
_templates_container.add_theme_constant_override("separation", 16)
root_vbox.add_child(_templates_container)
_create_template_card("Baseplate", "Clean empty 32x32 world grid ready for custom building.", "baseplate")
_create_template_card("Rolling Hills", "Natural outdoor terrain with pine trees and fauna.", "terrain")
_create_template_card("Obby Parkour", "Action platformer obstacle course with moving elevators.", "obby")
# Recent Projects Section
var rec_lbl := Label.new()
rec_lbl.text = "Recent Worlds"
rec_lbl.add_theme_font_size_override("font_size", 16)
rec_lbl.add_theme_color_override("font_color", Color(0.4, 0.9, 0.5))
root_vbox.add_child(rec_lbl)
_recent_list = ItemList.new()
_recent_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
_recent_list.custom_minimum_size = Vector2(0, 160)
_recent_list.item_activated.connect(_on_recent_item_activated)
root_vbox.add_child(_recent_list)
# File Dialog
_file_dialog = FileDialog.new()
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
_file_dialog.filters = PackedStringArray(["*.mbworld ; ModBison World Files"])
_file_dialog.file_selected.connect(_on_file_dialog_selected)
add_child(_file_dialog)
func _create_template_card(title: String, desc: String, template_id: String) -> void:
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(220, 120)
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 8)
panel.add_child(vbox)
var t_lbl := Label.new()
t_lbl.text = title
t_lbl.add_theme_font_size_override("font_size", 14)
t_lbl.add_theme_color_override("font_color", Color(1.0, 0.95, 0.4))
vbox.add_child(t_lbl)
var d_lbl := Label.new()
d_lbl.text = desc
d_lbl.add_theme_font_size_override("font_size", 10)
d_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
d_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
d_lbl.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.add_child(d_lbl)
var btn := Button.new()
btn.text = "Create World"
btn.pressed.connect(func(): template_selected.emit(template_id))
vbox.add_child(btn)
_templates_container.add_child(panel)
func _populate_recent_projects() -> void:
_recent_list.clear()
var recents: Array = MBWorldProjectIOClass.get_recent_projects()
if recents.is_empty():
_recent_list.add_item("(No recent projects)")
_recent_list.set_item_disabled(0, true)
else:
for path in recents:
var fname = str(path).get_file()
_recent_list.add_item("📄 %s (%s)" % [fname, path])
func _on_recent_item_activated(index: int) -> void:
var recents: Array = MBWorldProjectIOClass.get_recent_projects()
if index >= 0 and index < recents.size():
var path: String = str(recents[index])
project_opened.emit(path)
func _on_open_file_pressed() -> void:
_file_dialog.popup_centered(Vector2i(700, 450))
func _on_file_dialog_selected(path: String) -> void:
project_opened.emit(path)
+173
View File
@@ -0,0 +1,173 @@
class_name StudioViewportCamera
extends Camera3D
## Professional 3D Studio Camera controller supporting:
## - Freecam flying (WASD + Q/E)
## - Turntable Orbit & Panning (RMB/MMB drag)
## - Selection framing/focus (F key)
## - Orthographic view presets (Top, Front, Side, Perspective)
signal view_preset_changed(preset_name: String)
enum ViewPreset { PERSPECTIVE, TOP, FRONT, SIDE }
var current_preset: ViewPreset = ViewPreset.PERSPECTIVE
# Camera movement parameters
var move_speed: float = 12.0
var fast_multiplier: float = 2.5
var slow_multiplier: float = 0.3
var mouse_sensitivity: float = 0.003
var zoom_speed: float = 1.5
# Orbit / focus state
var focal_point: Vector3 = Vector3.ZERO
var orbit_distance: float = 15.0
var _yaw: float = -0.75
var _pitch: float = -0.5
var _rmb_down: bool = false
var _mmb_down: bool = false
var _move_input: Vector3 = Vector3.ZERO
func _ready() -> void:
current = true
_update_orbit_transform()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_RIGHT:
_rmb_down = mb.pressed
elif mb.button_index == MOUSE_BUTTON_MIDDLE:
_mmb_down = mb.pressed
elif mb.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom_camera(-zoom_speed)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
zoom_camera(zoom_speed)
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _rmb_down:
# Freecam / FPS mouse look
_yaw -= mm.relative.x * mouse_sensitivity
_pitch = clampf(_pitch - mm.relative.y * mouse_sensitivity, -PI * 0.49, PI * 0.49)
rotation = Vector3(_pitch, _yaw, 0.0)
elif _mmb_down:
# Pan camera parallel to viewport
var pan_speed := orbit_distance * 0.0015
var right := global_transform.basis.x
var up := global_transform.basis.y
var delta := (-right * mm.relative.x + up * mm.relative.y) * pan_speed
global_position += delta
focal_point += delta
elif event is InputEventKey:
var k := event as InputEventKey
if k.pressed and not k.echo:
if k.keycode == KEY_F:
focus_on(focal_point, 5.0)
elif k.keycode == KEY_1:
set_view_preset(ViewPreset.FRONT)
elif k.keycode == KEY_3:
set_view_preset(ViewPreset.SIDE)
elif k.keycode == KEY_7:
set_view_preset(ViewPreset.TOP)
elif k.keycode == KEY_5:
toggle_perspective_ortho()
func _process(delta: float) -> void:
if _rmb_down:
_process_keyboard_flight(delta)
func _process_keyboard_flight(delta: float) -> void:
var dir := Vector3.ZERO
if Input.is_physical_key_pressed(KEY_W):
dir -= global_transform.basis.z
if Input.is_physical_key_pressed(KEY_S):
dir += global_transform.basis.z
if Input.is_physical_key_pressed(KEY_A):
dir -= global_transform.basis.x
if Input.is_physical_key_pressed(KEY_D):
dir += global_transform.basis.x
if Input.is_physical_key_pressed(KEY_E):
dir += Vector3.UP
if Input.is_physical_key_pressed(KEY_Q):
dir -= Vector3.UP
if dir.length_squared() > 0.01:
var speed := move_speed
if Input.is_physical_key_pressed(KEY_SHIFT):
speed *= fast_multiplier
elif Input.is_physical_key_pressed(KEY_CTRL):
speed *= slow_multiplier
global_position += dir.normalized() * speed * delta
focal_point = global_position - global_transform.basis.z * orbit_distance
func zoom_camera(amount: float) -> void:
orbit_distance = clampf(orbit_distance + amount, 1.0, 300.0)
if projection == PROJECTION_ORTHOGONAL:
size = clampf(size + amount, 1.0, 200.0)
else:
_update_orbit_transform()
func focus_on(target_pos: Vector3, radius: float = 3.0) -> void:
focal_point = target_pos
orbit_distance = clampf(radius * 2.5, 3.0, 50.0)
_update_orbit_transform()
func _update_orbit_transform() -> void:
var rot_basis := Basis.from_euler(Vector3(_pitch, _yaw, 0.0))
var offset := rot_basis * Vector3(0.0, 0.0, orbit_distance)
_set_camera_pos(focal_point + offset)
rotation = Vector3(_pitch, _yaw, 0.0)
func _set_camera_pos(pos: Vector3) -> void:
if is_inside_tree():
global_position = pos
else:
position = pos
func set_view_preset(preset: ViewPreset) -> void:
current_preset = preset
match preset:
ViewPreset.TOP:
projection = PROJECTION_ORTHOGONAL
size = orbit_distance
rotation_degrees = Vector3(-90, 0, 0)
_set_camera_pos(focal_point + Vector3(0, orbit_distance, 0))
view_preset_changed.emit("Top")
ViewPreset.FRONT:
projection = PROJECTION_ORTHOGONAL
size = orbit_distance
rotation_degrees = Vector3(0, 0, 0)
_set_camera_pos(focal_point + Vector3(0, 0, orbit_distance))
view_preset_changed.emit("Front")
ViewPreset.SIDE:
projection = PROJECTION_ORTHOGONAL
size = orbit_distance
rotation_degrees = Vector3(0, 90, 0)
_set_camera_pos(focal_point + Vector3(orbit_distance, 0, 0))
view_preset_changed.emit("Side")
ViewPreset.PERSPECTIVE:
projection = PROJECTION_PERSPECTIVE
_pitch = -0.5
_yaw = -0.75
_update_orbit_transform()
view_preset_changed.emit("Perspective")
func toggle_perspective_ortho() -> void:
if projection == PROJECTION_PERSPECTIVE:
projection = PROJECTION_ORTHOGONAL
size = orbit_distance
else:
projection = PROJECTION_PERSPECTIVE