feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2
checkpoint / checkpoint (push) Successful in 6m32s
checkpoint / checkpoint (push) Successful in 6m32s
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user