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
+9
View File
@@ -130,6 +130,15 @@ class WorldObjectData:
var dynamic: bool = false
var flags: ContentFlags = ContentFlags.new()
func get_parent_id() -> String:
return str(properties.get("parent_id", ""))
func set_parent_id(p_id: String) -> void:
if p_id != "":
properties["parent_id"] = p_id
else:
properties.erase("parent_id")
## The complete decoded chunk payload.
class ChunkPayload:
@@ -0,0 +1,341 @@
class_name ObjectMotionComponent
extends Node
## Component providing procedural movement (Rotate, Bob/Float, Waypoint paths)
## and interactive triggers for placed world objects.
## Supports platform velocity for player character riding and multiplayer timestamp sync.
signal motion_started
signal motion_stopped
signal waypoint_reached(index: int)
enum MotionType { NONE, ROTATE, BOB, WAYPOINT }
enum TriggerMode { ALWAYS, PROXIMITY, INTERACT, SIGNAL }
enum LoopMode { CYCLE, PING_PONG, ONCE }
var motion_type: String = "none"
var trigger_mode: String = "always"
var loop_mode: String = "cycle"
# Rotation parameters
var rotation_axis: Vector3 = Vector3.UP
var rotation_speed: float = 45.0 # degrees per second
# Bob / Float parameters
var bob_axis: Vector3 = Vector3.UP
var bob_amplitude: float = 0.5
var bob_frequency: float = 1.0
# Waypoint parameters
var waypoints: Array[Vector3] = []
var move_speed: float = 2.0
var pause_time: float = 0.0
# Trigger parameters
var trigger_radius: float = 3.5
var is_active: bool = true
# Runtime state
var _target_node: Node3D = null
var _initial_pos: Vector3 = Vector3.ZERO
var _initial_basis: Basis = Basis.IDENTITY
var _time: float = 0.0
var _current_waypoint_idx: int = 0
var _waypoint_forward: bool = true
var _pause_timer: float = 0.0
var linear_velocity: Vector3 = Vector3.ZERO
var angular_velocity: Vector3 = Vector3.ZERO
func _ready() -> void:
add_to_group("moving_objects")
if _target_node == null:
_target_node = get_parent() as Node3D
if _target_node != null:
_initial_pos = _target_node.position
_initial_basis = _target_node.basis
if trigger_mode != "always":
is_active = false
func setup(p_target: Node3D, config: Dictionary = {}) -> void:
_target_node = p_target
if _target_node != null:
_initial_pos = _target_node.position
_initial_basis = _target_node.basis
_time = 0.0
_pause_timer = 0.0
_current_waypoint_idx = 0
_waypoint_forward = true
linear_velocity = Vector3.ZERO
angular_velocity = Vector3.ZERO
if not config.is_empty():
load_from_dict(config)
func _physics_process(delta: float) -> void:
if _target_node == null or delta <= 0.0:
return
if trigger_mode.to_lower() == "proximity":
_process_proximity_check()
if not is_active:
linear_velocity = Vector3.ZERO
angular_velocity = Vector3.ZERO
_apply_velocities_to_colliders()
return
_time += delta
var prev_pos := _target_node.position
match motion_type.to_lower():
"rotate":
_process_rotate(delta)
"bob", "oscillate":
_process_bob(delta)
"waypoint", "patrol":
_process_waypoint(delta)
linear_velocity = (_target_node.position - prev_pos) / delta
_apply_velocities_to_colliders()
func _process_proximity_check() -> void:
if _target_node == null:
return
var target_pos := _target_node.global_position if _target_node.is_inside_tree() else _target_node.position
var players := get_tree().get_nodes_in_group("players")
var nearby := false
for p in players:
if p is Node3D:
var p_pos := (p as Node3D).global_position if (p as Node3D).is_inside_tree() else (p as Node3D).position
if p_pos.distance_to(target_pos) <= trigger_radius:
nearby = true
break
if nearby and not is_active:
start_motion()
elif not nearby and is_active:
stop_motion()
func check_proximity_to_position(pos: Vector3) -> bool:
if _target_node == null:
return false
var target_pos := _target_node.global_position if _target_node.is_inside_tree() else _target_node.position
var dist := target_pos.distance_to(pos)
var in_range := dist <= trigger_radius
if in_range and not is_active:
start_motion()
elif not in_range and is_active:
stop_motion()
return in_range
func on_interact() -> void:
if trigger_mode.to_lower() == "interact":
toggle_motion()
func on_signal(_sig_name: String = "") -> void:
if trigger_mode.to_lower() == "signal":
toggle_motion()
func _process_rotate(delta: float) -> void:
var rad_speed := deg_to_rad(rotation_speed)
var rot_delta := rad_speed * delta
_target_node.rotate(rotation_axis.normalized(), rot_delta)
angular_velocity = rotation_axis.normalized() * rad_speed
func _process_bob(_delta: float) -> void:
var offset: float = sin(_time * bob_frequency * TAU) * bob_amplitude
_target_node.position = _initial_pos + (bob_axis.normalized() * offset)
func _process_waypoint(delta: float) -> void:
if waypoints.is_empty():
return
if _pause_timer > 0.0:
_pause_timer -= delta
return
var target_wp := waypoints[_current_waypoint_idx]
var current_pos := _target_node.position
var to_target := target_wp - current_pos
var dist := to_target.length()
if dist < 0.001:
_advance_waypoint()
target_wp = waypoints[_current_waypoint_idx]
to_target = target_wp - current_pos
dist = to_target.length()
var step := move_speed * delta
if dist <= step:
_target_node.position = target_wp
waypoint_reached.emit(_current_waypoint_idx)
_pause_timer = pause_time
_advance_waypoint()
else:
_target_node.position += to_target.normalized() * step
func _advance_waypoint() -> void:
match loop_mode.to_lower():
"cycle":
_current_waypoint_idx = (_current_waypoint_idx + 1) % waypoints.size()
"ping_pong":
if _waypoint_forward:
if _current_waypoint_idx >= waypoints.size() - 1:
_waypoint_forward = false
_current_waypoint_idx = maxi(0, waypoints.size() - 2)
else:
_current_waypoint_idx += 1
else:
if _current_waypoint_idx <= 0:
_waypoint_forward = true
_current_waypoint_idx = mini(1, waypoints.size() - 1)
else:
_current_waypoint_idx -= 1
"once":
if _current_waypoint_idx < waypoints.size() - 1:
_current_waypoint_idx += 1
else:
is_active = false
motion_stopped.emit()
func _apply_velocities_to_colliders() -> void:
if _target_node == null:
return
var sb := _target_node.find_child("collision", true, false) as StaticBody3D
if sb == null:
sb = _target_node.find_child("*", true, false) as StaticBody3D
if sb != null:
sb.constant_linear_velocity = linear_velocity
sb.constant_angular_velocity = angular_velocity
func start_motion() -> void:
is_active = true
motion_started.emit()
func stop_motion() -> void:
is_active = false
linear_velocity = Vector3.ZERO
angular_velocity = Vector3.ZERO
_apply_velocities_to_colliders()
motion_stopped.emit()
func toggle_motion() -> void:
if is_active:
stop_motion()
else:
start_motion()
func reverse_motion() -> void:
rotation_speed = -rotation_speed
_waypoint_forward = not _waypoint_forward
func sync_to_timestamp(server_time: float) -> void:
_time = server_time
if _target_node == null:
return
match motion_type.to_lower():
"bob", "oscillate":
var offset: float = sin(_time * bob_frequency * TAU) * bob_amplitude
_target_node.position = _initial_pos + (bob_axis.normalized() * offset)
"rotate":
var angle: float = deg_to_rad(rotation_speed) * server_time
_target_node.basis = _initial_basis.rotated(rotation_axis.normalized(), angle)
"waypoint", "patrol":
if waypoints.size() >= 2 and move_speed > 0.0:
_sync_waypoints(server_time)
func _sync_waypoints(t: float) -> void:
var total_dur := 0.0
var seg_durs: Array[float] = []
var count := waypoints.size()
var seg_count := count if loop_mode == "cycle" else count - 1
for i in range(seg_count):
var p0: Vector3 = waypoints[i]
var p1: Vector3 = waypoints[(i + 1) % count]
var d := p0.distance_to(p1)
var dur := (d / move_speed) + pause_time
seg_durs.append(dur)
total_dur += dur
if total_dur <= 0.0:
return
var progress := fmod(t, total_dur)
var accumulated := 0.0
for i in range(seg_count):
var dur := seg_durs[i]
if progress <= accumulated + dur:
var seg_t := (progress - accumulated) / maxf(0.001, (dur - pause_time))
seg_t = clampf(seg_t, 0.0, 1.0)
var p0: Vector3 = waypoints[i]
var p1: Vector3 = waypoints[(i + 1) % count]
_target_node.position = p0.lerp(p1, seg_t)
_current_waypoint_idx = (i + 1) % count
break
accumulated += dur
func to_dict() -> Dictionary:
var wps_serialized: Array = []
for wp in waypoints:
wps_serialized.append([wp.x, wp.y, wp.z])
return {
"motion_type": motion_type,
"trigger_mode": trigger_mode,
"loop_mode": loop_mode,
"rotation_axis": [rotation_axis.x, rotation_axis.y, rotation_axis.z],
"rotation_speed": rotation_speed,
"bob_axis": [bob_axis.x, bob_axis.y, bob_axis.z],
"bob_amplitude": bob_amplitude,
"bob_frequency": bob_frequency,
"move_speed": move_speed,
"pause_time": pause_time,
"waypoints": wps_serialized,
"trigger_radius": trigger_radius,
"is_active": is_active,
}
func load_from_dict(d: Dictionary) -> void:
motion_type = str(d.get("motion_type", "none"))
trigger_mode = str(d.get("trigger_mode", "always"))
loop_mode = str(d.get("loop_mode", "cycle"))
rotation_speed = float(d.get("rotation_speed", 45.0))
bob_amplitude = float(d.get("bob_amplitude", 0.5))
bob_frequency = float(d.get("bob_frequency", 1.0))
move_speed = float(d.get("move_speed", 2.0))
pause_time = float(d.get("pause_time", 0.0))
trigger_radius = float(d.get("trigger_radius", 3.5))
is_active = bool(d.get("is_active", trigger_mode == "always"))
if d.has("rotation_axis"):
var a = d["rotation_axis"]
if a is Array and a.size() >= 3:
rotation_axis = Vector3(a[0], a[1], a[2])
if d.has("bob_axis"):
var b = d["bob_axis"]
if b is Array and b.size() >= 3:
bob_axis = Vector3(b[0], b[1], b[2])
if d.has("waypoints"):
waypoints.clear()
for wp in d["waypoints"]:
if wp is Array and wp.size() >= 3:
waypoints.append(Vector3(wp[0], wp[1], wp[2]))
elif wp is Vector3:
waypoints.append(wp)
+69 -3
View File
@@ -36,6 +36,8 @@ var selection: SelectionManager
var tools: ToolRegistry
var catalog: EditorObjectCatalog = null
const PlacementStackManagerClass = preload("res://scripts/editor/placement_stack_manager.gd")
func _init(p_model: ChunkEditorModel, p_catalog: EditorObjectCatalog = null) -> void:
model = p_model
@@ -72,16 +74,80 @@ func erase_tile(layer_idx: int, x: int, y: int, z: int = 0) -> bool:
func place_object(catalog_id: String, position: Vector3,
rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE) -> String:
var result: Variant = use_tool("object_place", {
rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE,
properties: Dictionary = {}) -> String:
var args: Dictionary = {
"catalog_id": catalog_id,
"position": position,
"rotation": rotation,
"scale": scale,
})
}
if not properties.is_empty():
args["properties"] = properties
var result: Variant = use_tool("object_place", args)
return result if result is String else ""
func place_object_stacked(catalog_id: String, target_object_id: String = "",
hit_point: Vector3 = Vector3.ZERO, surface_normal: Vector3 = Vector3(0, 0, 1),
elevation_offset: float = 0.0, align_to_normal: bool = false) -> String:
var target_obj: ChunkDataDecoder.WorldObjectData = null
if target_object_id != "":
target_obj = model.find_object(target_object_id)
var stack_info: Dictionary = PlacementStackManagerClass.calculate_stack_transform(
catalog_id, target_obj, hit_point, surface_normal, elevation_offset, align_to_normal
)
var props: Dictionary = {}
if stack_info["parent_id"] != "":
props["parent_id"] = stack_info["parent_id"]
props["stacked_on"] = stack_info["parent_id"]
if elevation_offset != 0.0:
props["elevation_offset"] = elevation_offset
return place_object(catalog_id, stack_info["position"], stack_info["rotation"], Vector3.ONE, props)
func drop_object_to_surface(object_id: String) -> bool:
var obj := model.find_object(object_id)
if obj == null:
return false
var old_pos := obj.position
var old_props := obj.properties.duplicate(true)
if not PlacementStackManagerClass.drop_to_surface(model, object_id):
return false
var new_pos := obj.position
var new_props := obj.properties.duplicate(true)
undo_redo.begin("Drop '%s' to surface" % object_id)
undo_redo.add_do(func():
var target := model.find_object(object_id)
if target != null:
target.position = new_pos
target.properties = new_props
)
undo_redo.add_undo(func():
var target := model.find_object(object_id)
if target != null:
target.position = old_pos
target.properties = old_props
)
undo_redo.commit()
return true
func adjust_object_elevation(object_id: String, delta_z: float) -> bool:
var obj := model.find_object(object_id)
if obj == null:
return false
var target_pos := obj.position + Vector3(0, 0, delta_z)
target_pos.z = maxf(0.0, target_pos.z)
return move_object(object_id, target_pos)
func get_stacked_children(parent_id: String) -> Array[String]:
return PlacementStackManagerClass.get_all_children(model, parent_id)
func delete_object(object_id: String) -> bool:
return bool(use_tool("erase", {"object_id": object_id}))
+183
View File
@@ -0,0 +1,183 @@
class_name PlacementStackManager
extends RefCounted
## Manages 3D surface collision, stacking calculations, AABB normalization,
## and hierarchical parenting for world objects placed on top of other objects.
##
## Coordinate convention in Chunk data:
## X = horizontal X
## Y = horizontal Y (ground Y in chunk grid)
## Z = vertical height (CHUNK_SIZE_Z elevation)
##
## In Godot 3D:
## Vector3(X, Z_height, Y)
## Known default bounding boxes for catalog items (size and base Y offset)
const DEFAULT_BOUNDS: Dictionary = {
"crate_small": {"size": Vector3(1.0, 1.0, 1.0), "base_offset": 0.0},
"crate_large": {"size": Vector3(2.0, 2.0, 2.0), "base_offset": 0.0},
"chest_01": {"size": Vector3(1.0, 0.6, 0.8), "base_offset": 0.0},
"chest": {"size": Vector3(1.0, 0.6, 0.8), "base_offset": 0.0},
"buffalo_chip_coin": {"size": Vector3(0.4, 0.4, 0.1), "base_offset": 0.0},
"table_wooden": {"size": Vector3(1.8, 1.2, 0.9), "base_offset": 0.0},
"table": {"size": Vector3(1.8, 1.2, 0.9), "base_offset": 0.0},
"chair": {"size": Vector3(0.6, 0.6, 0.9), "base_offset": 0.0},
"rock_01": {"size": Vector3(1.2, 1.2, 0.8), "base_offset": 0.0},
"lamp_post_01": {"size": Vector3(0.5, 0.5, 3.0), "base_offset": 0.0},
"bison": {"size": Vector3(1.5, 2.4, 1.8), "base_offset": 0.0},
"default": {"size": Vector3(1.0, 1.0, 1.0), "base_offset": 0.0},
}
## Return the 3D dimensions of an object by catalog_id.
## Note: Returned Vector3 uses (width_x, depth_y, height_z) matching chunk coordinates.
static func get_dimensions(catalog_id: String, scale: Vector3 = Vector3.ONE) -> Vector3:
var info: Dictionary = DEFAULT_BOUNDS.get("default")
var key: String = catalog_id.to_lower()
for k in DEFAULT_BOUNDS.keys():
if key.contains(k):
info = DEFAULT_BOUNDS[k]
break
var s: Vector3 = info["size"]
return Vector3(s.x * scale.x, s.y * scale.y, s.z * scale.z)
## Return the vertical height of an object by catalog_id.
static func get_height(catalog_id: String, scale_z: float = 1.0) -> float:
return get_dimensions(catalog_id, Vector3(1.0, 1.0, scale_z)).z
## Compute stacked transform when placing an item on top of another object.
## Returns Dictionary with { "position": Vector3, "rotation": Vector4, "parent_id": String, "is_stacked": bool }
static func calculate_stack_transform(
catalog_id: String,
target_obj: ChunkDataDecoder.WorldObjectData,
hit_point: Vector3,
surface_normal: Vector3 = Vector3(0, 0, 1),
elevation_offset: float = 0.0,
align_to_normal: bool = false
) -> Dictionary:
if target_obj == null:
return {
"position": Vector3(hit_point.x, hit_point.y, maxf(0.0, hit_point.z + elevation_offset)),
"rotation": Vector4(0, 0, 0, 1),
"parent_id": "",
"is_stacked": false,
}
var target_cat_id: String = ""
if target_obj.properties is Dictionary and target_obj.properties.has("catalog_id"):
target_cat_id = str(target_obj.properties["catalog_id"])
else:
target_cat_id = target_obj.asset_id.get_file().get_basename()
var target_height: float = get_height(target_cat_id, target_obj.scale.z)
var stack_top_z: float = target_obj.position.z + target_height + elevation_offset
var rot := Vector4(0, 0, 0, 1)
if align_to_normal and surface_normal != Vector3(0, 0, 1) and surface_normal != Vector3.ZERO:
# Calculate rotation aligning Z (up in chunk coords) to surface_normal
var up := Vector3(0, 0, 1)
var q := Quaternion(up, surface_normal.normalized())
rot = Vector4(q.x, q.y, q.z, q.w)
return {
"position": Vector3(hit_point.x, hit_point.y, stack_top_z),
"rotation": rot,
"parent_id": target_obj.id,
"is_stacked": true,
}
## Find the highest underlying object beneath an (x, y) footprint at or below current_z.
static func find_underlying_object(
chunk_model: ChunkEditorModel,
x: float,
y: float,
current_z: float,
exclude_id: String = ""
) -> Dictionary:
var best_obj: ChunkDataDecoder.WorldObjectData = null
var highest_top_z: float = 0.0
for obj in chunk_model.payload.objects:
if obj.id == exclude_id:
continue
var cat_id: String = ""
if obj.properties is Dictionary and obj.properties.has("catalog_id"):
cat_id = str(obj.properties["catalog_id"])
else:
cat_id = obj.asset_id.get_file().get_basename()
var dims := get_dimensions(cat_id, obj.scale)
var half_w := dims.x * 0.5
var half_d := dims.y * 0.5
var min_x := obj.position.x - half_w
var max_x := obj.position.x + half_w
var min_y := obj.position.y - half_d
var max_y := obj.position.y + half_d
var top_z := obj.position.z + dims.z
# Check if (x, y) falls within this object's horizontal footprint
if x >= min_x and x <= max_x and y >= min_y and y <= max_y:
if top_z <= current_z + 0.01:
if top_z >= highest_top_z:
highest_top_z = top_z
best_obj = obj
return {
"object": best_obj,
"surface_z": highest_top_z,
}
## Drop an object straight downward until it sits flush on top of the surface below it.
static func drop_to_surface(chunk_model: ChunkEditorModel, object_id: String) -> bool:
var obj: ChunkDataDecoder.WorldObjectData = chunk_model.find_object(object_id)
if obj == null:
return false
var result := find_underlying_object(chunk_model, obj.position.x, obj.position.y, obj.position.z, object_id)
var new_z: float = result["surface_z"]
var target_parent: ChunkDataDecoder.WorldObjectData = result["object"]
obj.position.z = new_z
if target_parent != null:
obj.properties["parent_id"] = target_parent.id
obj.properties["stacked_on"] = target_parent.id
else:
obj.properties.erase("parent_id")
obj.properties.erase("stacked_on")
return true
## Propagate parent movement to all recursively stacked child objects.
static func propagate_parent_delta(
chunk_model: ChunkEditorModel,
parent_id: String,
delta: Vector3
) -> Array[String]:
var moved_ids: Array[String] = []
for obj in chunk_model.payload.objects:
if obj.properties is Dictionary and obj.properties.get("parent_id") == parent_id:
obj.position += delta
moved_ids.append(obj.id)
var sub_children := propagate_parent_delta(chunk_model, obj.id, delta)
for sc in sub_children:
if not sc in moved_ids:
moved_ids.append(sc)
return moved_ids
## Find all direct or indirect children of a parent object.
static func get_all_children(chunk_model: ChunkEditorModel, parent_id: String) -> Array[String]:
var result: Array[String] = []
for obj in chunk_model.payload.objects:
if obj.properties is Dictionary and obj.properties.get("parent_id") == parent_id:
result.append(obj.id)
var sub := get_all_children(chunk_model, obj.id)
for s in sub:
if not s in result:
result.append(s)
return result
+22 -2
View File
@@ -27,8 +27,28 @@ func apply(core: EditorCore, args: Dictionary) -> Variant:
match op:
"move":
return _apply_vector3(core, object_id, args.get("value", obj.position),
obj.position, "Move", core.model.set_object_position_raw)
var new_val: Vector3 = args.get("value", obj.position)
var old_val: Vector3 = obj.position
var delta: Vector3 = new_val - old_val
if new_val.is_equal_approx(old_val):
return false
var children_snapshots: Array = []
for child in core.model.payload.objects:
if child.properties is Dictionary and child.properties.get("parent_id") == object_id:
children_snapshots.append({"id": child.id, "old_pos": child.position, "new_pos": child.position + delta})
core.undo_redo.begin("Move '%s'" % object_id)
core.undo_redo.add_do(func():
core.model.set_object_position_raw(object_id, new_val)
for cs in children_snapshots:
core.model.set_object_position_raw(cs["id"], cs["new_pos"])
)
core.undo_redo.add_undo(func():
core.model.set_object_position_raw(object_id, old_val)
for cs in children_snapshots:
core.model.set_object_position_raw(cs["id"], cs["old_pos"])
)
core.undo_redo.commit()
return true
"rotate":
return _apply_vector4(core, object_id, args.get("value", obj.rotation),
obj.rotation, "Rotate", core.model.set_object_rotation_raw)
+147 -1
View File
@@ -25,6 +25,10 @@ var cell_size: float = 24.0
var pending_catalog_id: String = ""
## Tile id the next "brush" click paints. Cycled with number keys 0-9.
var current_tile_id: int = 1
## Elevation offset for placement and hotkey nudging (Q / E).
var current_elevation_offset: float = 0.0
## Whether to align placed object to surface normal slope.
var align_surface_normal: bool = false
var _hover_tx: int = -1
var _hover_ty: int = -1
@@ -119,6 +123,32 @@ func _draw() -> void:
display_name = obj.id
draw_string(default_font, center + Vector2(-r * 1.5, -r - 2), display_name.left(6), HORIZONTAL_ALIGNMENT_CENTER, int(r * 3.0), 10, Color(1, 1, 1, 0.9))
# Draw motion waypoint path & gizmos for selected object
var sel_id := core.selection.get_selected()
if sel_id != "":
var sel_obj := core.model.find_object(sel_id)
if sel_obj != null and sel_obj.properties is Dictionary and sel_obj.properties.has("motion"):
var m_cfg = sel_obj.properties["motion"]
if m_cfg is Dictionary and m_cfg.has("waypoints") and m_cfg["waypoints"] is Array:
var wps: Array = m_cfg["waypoints"]
if not wps.is_empty():
var prev_pt := Vector2(sel_obj.position.x * cell_size, sel_obj.position.y * cell_size)
for i in range(wps.size()):
var wp_val = wps[i]
var wp_pos := Vector2.ZERO
if wp_val is Array and wp_val.size() >= 2:
wp_pos = Vector2(float(wp_val[0]) * cell_size, float(wp_val[1]) * cell_size)
elif wp_val is Vector3:
wp_pos = Vector2(wp_val.x * cell_size, wp_val.y * cell_size)
draw_line(prev_pt, wp_pos, Color(0.2, 0.85, 1.0, 0.8), 2.0)
var wr := cell_size * 0.28
draw_circle(wp_pos, wr, Color(0.1, 0.6, 0.9, 0.85))
draw_arc(wp_pos, wr, 0.0, TAU, 16, Color(1, 1, 1, 0.9), 1.5)
draw_string(default_font, wp_pos + Vector2(-4, 4), str(i + 1), HORIZONTAL_ALIGNMENT_CENTER, 8, 10, Color(1, 1, 1))
prev_pt = wp_pos
func _gui_input(event: InputEvent) -> void:
if core == null:
@@ -155,6 +185,57 @@ func _gui_input(event: InputEvent) -> void:
func _handle_key(key: InputEventKey) -> void:
if key.keycode >= KEY_0 and key.keycode <= KEY_9:
current_tile_id = key.keycode - KEY_0
elif key.keycode == KEY_E:
current_elevation_offset += 0.5
var sel := core.selection.get_selected()
if sel != "":
core.adjust_object_elevation(sel, 0.5)
edit_applied.emit()
queue_redraw()
elif key.keycode == KEY_Q:
current_elevation_offset = maxf(0.0, current_elevation_offset - 0.5)
var sel := core.selection.get_selected()
if sel != "":
core.adjust_object_elevation(sel, -0.5)
edit_applied.emit()
queue_redraw()
elif key.keycode == KEY_END:
var sel := core.selection.get_selected()
if sel != "":
if core.drop_object_to_surface(sel):
edit_applied.emit()
queue_redraw()
elif key.keycode == KEY_R:
align_surface_normal = not align_surface_normal
elif key.keycode == KEY_W:
var sel := core.selection.get_selected()
if sel != "" and _hover_tx >= 0 and _hover_ty >= 0:
var obj := core.model.find_object(sel)
if obj != null:
var motion_dict = obj.properties.get("motion", {"motion_type": "waypoint", "waypoints": []})
if not (motion_dict is Dictionary):
motion_dict = {"motion_type": "waypoint", "waypoints": []}
var wps = motion_dict.get("waypoints", [])
wps.append([float(_hover_tx), float(_hover_ty), obj.position.z])
motion_dict["motion_type"] = "waypoint"
motion_dict["waypoints"] = wps
core.set_object_property(sel, "motion", motion_dict)
edit_applied.emit()
queue_redraw()
elif key.keycode == KEY_BACKSPACE:
var sel := core.selection.get_selected()
if sel != "":
var obj := core.model.find_object(sel)
if obj != null and obj.properties is Dictionary and obj.properties.has("motion"):
var motion_dict = obj.properties["motion"]
if motion_dict is Dictionary and motion_dict.has("waypoints") and motion_dict["waypoints"] is Array:
var wps: Array = motion_dict["waypoints"]
if not wps.is_empty():
wps.pop_back()
motion_dict["waypoints"] = wps
core.set_object_property(sel, "motion", motion_dict)
edit_applied.emit()
queue_redraw()
func _handle_click(local_pos: Vector2) -> void:
@@ -176,7 +257,22 @@ func _handle_click(local_pos: Vector2) -> void:
queue_redraw()
"object_place":
if pending_catalog_id != "":
var new_id := core.place_object(pending_catalog_id, Vector3(tx + 0.5, ty + 0.5, 0.0))
var existing_hit := _highest_object_at(tx, ty)
var new_id: String = ""
if existing_hit != "":
new_id = core.place_object_stacked(
pending_catalog_id,
existing_hit,
Vector3(tx + 0.5, ty + 0.5, 0.0),
Vector3(0, 0, 1),
current_elevation_offset,
align_surface_normal
)
else:
new_id = core.place_object(
pending_catalog_id,
Vector3(tx + 0.5, ty + 0.5, current_elevation_offset)
)
if new_id != "":
edit_applied.emit()
queue_redraw()
@@ -191,3 +287,53 @@ func _object_at(tx: int, ty: int) -> String:
if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty:
return obj.id
return ""
func _highest_object_at(tx: int, ty: int) -> String:
var best_id := ""
var max_z: float = -1.0
for obj in core.model.payload.objects:
if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty:
if obj.position.z > max_z:
max_z = obj.position.z
best_id = obj.id
return best_id
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
if data is Dictionary and data.get("type") == "model_placement":
return true
return false
func _drop_data(at_position: Vector2, data: Variant) -> void:
if not _can_drop_data(at_position, data):
return
if core == null:
return
var dict: Dictionary = data as Dictionary
var cat_id: String = dict.get("catalog_id", "")
if cat_id == "":
cat_id = dict.get("model_name", "object").to_lower().replace(" ", "_")
var tx := int(floor(at_position.x / cell_size))
var ty := int(floor(at_position.y / cell_size))
var existing_hit := _highest_object_at(tx, ty)
var new_id := ""
if existing_hit != "":
new_id = core.place_object_stacked(
cat_id,
existing_hit,
Vector3(tx + 0.5, ty + 0.5, 0.0),
Vector3(0, 0, 1),
current_elevation_offset,
align_surface_normal
)
else:
new_id = core.place_object(
cat_id,
Vector3(tx + 0.5, ty + 0.5, current_elevation_offset)
)
if new_id != "":
edit_applied.emit()
queue_redraw()
+364
View File
@@ -35,6 +35,27 @@ var _scale_y: SpinBox
var _scale_z: SpinBox
var _delete_button: Button
# Animation & Motion fields
var _anim_clip_option: OptionButton
var _anim_autoplay_check: CheckBox
var _anim_loop_check: CheckBox
var _anim_speed_spin: SpinBox
var _motion_type_option: OptionButton
var _motion_trigger_option: OptionButton
var _motion_rotate_box: VBoxContainer
var _motion_speed_spin: SpinBox
var _motion_bob_box: VBoxContainer
var _motion_bob_amp_spin: SpinBox
var _motion_bob_freq_spin: SpinBox
var _motion_waypoint_box: VBoxContainer
var _motion_wp_speed_spin: SpinBox
var _motion_wp_pause_spin: SpinBox
var _motion_wp_count_label: Label
var _motion_add_wp_btn: Button
var _motion_clear_wp_btn: Button
var _current_waypoints: Array[Vector3] = []
# Behavior & Rules fields
var _rules_vbox: VBoxContainer
var _add_rule_btn: Button
@@ -140,9 +161,220 @@ func _build_ui() -> void:
_delete_button.pressed.connect(_on_delete_pressed)
_fields_box.add_child(_delete_button)
_setup_animation_and_motion_ui()
_setup_behavior_ui()
func _setup_animation_and_motion_ui() -> void:
var sep := HSeparator.new()
sep.custom_minimum_size = Vector2(0, 16)
_fields_box.add_child(sep)
var header := Label.new()
header.text = "Animation & Movement"
header.add_theme_font_size_override("font_size", 14)
header.add_theme_color_override("font_color", Color(0.4, 0.9, 0.5))
_fields_box.add_child(header)
var desc := Label.new()
desc.text = "Configure 3D clip playback and procedural motion."
desc.add_theme_font_size_override("font_size", 11)
desc.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
_fields_box.add_child(desc)
# --- Animation Section ---
var anim_panel := PanelContainer.new()
var anim_vbox := VBoxContainer.new()
anim_panel.add_child(anim_vbox)
_fields_box.add_child(anim_panel)
var anim_lbl := Label.new()
anim_lbl.text = "Clip Animation"
anim_lbl.add_theme_font_size_override("font_size", 12)
anim_lbl.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4))
anim_vbox.add_child(anim_lbl)
var clip_row := HBoxContainer.new()
var clip_lbl := Label.new()
clip_lbl.text = "Clip:"
clip_lbl.custom_minimum_size = Vector2(60, 0)
clip_row.add_child(clip_lbl)
_anim_clip_option = OptionButton.new()
_anim_clip_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_anim_clip_option.item_selected.connect(_on_animation_changed)
clip_row.add_child(_anim_clip_option)
anim_vbox.add_child(clip_row)
var anim_opts_row := HBoxContainer.new()
_anim_autoplay_check = CheckBox.new()
_anim_autoplay_check.text = "Autoplay"
_anim_autoplay_check.toggled.connect(func(_val): _on_animation_changed(0))
anim_opts_row.add_child(_anim_autoplay_check)
_anim_loop_check = CheckBox.new()
_anim_loop_check.text = "Loop"
_anim_loop_check.toggled.connect(func(_val): _on_animation_changed(0))
anim_opts_row.add_child(_anim_loop_check)
anim_vbox.add_child(anim_opts_row)
var speed_row := HBoxContainer.new()
var spd_lbl := Label.new()
spd_lbl.text = "Speed:"
spd_lbl.custom_minimum_size = Vector2(60, 0)
speed_row.add_child(spd_lbl)
_anim_speed_spin = SpinBox.new()
_anim_speed_spin.min_value = 0.1
_anim_speed_spin.max_value = 10.0
_anim_speed_spin.step = 0.1
_anim_speed_spin.value = 1.0
_anim_speed_spin.value_changed.connect(func(_val): _on_animation_changed(0))
speed_row.add_child(_anim_speed_spin)
anim_vbox.add_child(speed_row)
# --- Motion Section ---
var motion_panel := PanelContainer.new()
var motion_vbox := VBoxContainer.new()
motion_panel.add_child(motion_vbox)
_fields_box.add_child(motion_panel)
var motion_lbl := Label.new()
motion_lbl.text = "Procedural Motion"
motion_lbl.add_theme_font_size_override("font_size", 12)
motion_lbl.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0))
motion_vbox.add_child(motion_lbl)
var type_row := HBoxContainer.new()
var type_lbl := Label.new()
type_lbl.text = "Type:"
type_lbl.custom_minimum_size = Vector2(60, 0)
type_row.add_child(type_lbl)
_motion_type_option = OptionButton.new()
_motion_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_motion_type_option.add_item("None (Static)", 0)
_motion_type_option.set_item_metadata(0, "none")
_motion_type_option.add_item("Rotate (Spin)", 1)
_motion_type_option.set_item_metadata(1, "rotate")
_motion_type_option.add_item("Bob / Float", 2)
_motion_type_option.set_item_metadata(2, "bob")
_motion_type_option.add_item("Waypoint Path", 3)
_motion_type_option.set_item_metadata(3, "waypoint")
_motion_type_option.item_selected.connect(_on_motion_type_selected)
type_row.add_child(_motion_type_option)
motion_vbox.add_child(type_row)
var trig_row := HBoxContainer.new()
var trig_lbl := Label.new()
trig_lbl.text = "Trigger:"
trig_lbl.custom_minimum_size = Vector2(60, 0)
trig_row.add_child(trig_lbl)
_motion_trigger_option = OptionButton.new()
_motion_trigger_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_motion_trigger_option.add_item("Always (Active)", 0)
_motion_trigger_option.set_item_metadata(0, "always")
_motion_trigger_option.add_item("Proximity (Player near)", 1)
_motion_trigger_option.set_item_metadata(1, "proximity")
_motion_trigger_option.add_item("Interact (Click)", 2)
_motion_trigger_option.set_item_metadata(2, "interact")
_motion_trigger_option.add_item("Signal (Script)", 3)
_motion_trigger_option.set_item_metadata(3, "signal")
_motion_trigger_option.item_selected.connect(func(_idx): _on_motion_param_changed())
trig_row.add_child(_motion_trigger_option)
motion_vbox.add_child(trig_row)
# Rotate parameters
_motion_rotate_box = VBoxContainer.new()
var rot_speed_row := HBoxContainer.new()
var rot_spd_lbl := Label.new()
rot_spd_lbl.text = "Speed (deg/s):"
rot_spd_lbl.custom_minimum_size = Vector2(100, 0)
rot_speed_row.add_child(rot_spd_lbl)
_motion_speed_spin = SpinBox.new()
_motion_speed_spin.min_value = -720.0
_motion_speed_spin.max_value = 720.0
_motion_speed_spin.value = 45.0
_motion_speed_spin.value_changed.connect(func(_v): _on_motion_param_changed())
rot_speed_row.add_child(_motion_speed_spin)
_motion_rotate_box.add_child(rot_speed_row)
motion_vbox.add_child(_motion_rotate_box)
# Bob parameters
_motion_bob_box = VBoxContainer.new()
var bob_amp_row := HBoxContainer.new()
var bob_amp_lbl := Label.new()
bob_amp_lbl.text = "Amplitude:"
bob_amp_lbl.custom_minimum_size = Vector2(80, 0)
bob_amp_row.add_child(bob_amp_lbl)
_motion_bob_amp_spin = SpinBox.new()
_motion_bob_amp_spin.min_value = 0.05
_motion_bob_amp_spin.max_value = 20.0
_motion_bob_amp_spin.step = 0.1
_motion_bob_amp_spin.value = 0.5
_motion_bob_amp_spin.value_changed.connect(func(_v): _on_motion_param_changed())
bob_amp_row.add_child(_motion_bob_amp_spin)
_motion_bob_box.add_child(bob_amp_row)
var bob_freq_row := HBoxContainer.new()
var bob_freq_lbl := Label.new()
bob_freq_lbl.text = "Frequency:"
bob_freq_lbl.custom_minimum_size = Vector2(80, 0)
bob_freq_row.add_child(bob_freq_lbl)
_motion_bob_freq_spin = SpinBox.new()
_motion_bob_freq_spin.min_value = 0.1
_motion_bob_freq_spin.max_value = 10.0
_motion_bob_freq_spin.step = 0.1
_motion_bob_freq_spin.value = 1.0
_motion_bob_freq_spin.value_changed.connect(func(_v): _on_motion_param_changed())
bob_freq_row.add_child(_motion_bob_freq_spin)
_motion_bob_box.add_child(bob_freq_row)
motion_vbox.add_child(_motion_bob_box)
# Waypoint parameters
_motion_waypoint_box = VBoxContainer.new()
var wp_spd_row := HBoxContainer.new()
var wp_spd_lbl := Label.new()
wp_spd_lbl.text = "Move Speed:"
wp_spd_lbl.custom_minimum_size = Vector2(80, 0)
wp_spd_row.add_child(wp_spd_lbl)
_motion_wp_speed_spin = SpinBox.new()
_motion_wp_speed_spin.min_value = 0.1
_motion_wp_speed_spin.max_value = 50.0
_motion_wp_speed_spin.value = 2.0
_motion_wp_speed_spin.value_changed.connect(func(_v): _on_motion_param_changed())
wp_spd_row.add_child(_motion_wp_speed_spin)
_motion_waypoint_box.add_child(wp_spd_row)
var wp_pause_row := HBoxContainer.new()
var wp_pause_lbl := Label.new()
wp_pause_lbl.text = "Pause (s):"
wp_pause_lbl.custom_minimum_size = Vector2(80, 0)
wp_pause_row.add_child(wp_pause_lbl)
_motion_wp_pause_spin = SpinBox.new()
_motion_wp_pause_spin.min_value = 0.0
_motion_wp_pause_spin.max_value = 60.0
_motion_wp_pause_spin.value = 0.0
_motion_wp_pause_spin.value_changed.connect(func(_v): _on_motion_param_changed())
wp_pause_row.add_child(_motion_wp_pause_spin)
_motion_waypoint_box.add_child(wp_pause_row)
_motion_wp_count_label = Label.new()
_motion_wp_count_label.text = "Waypoints: 0"
_motion_waypoint_box.add_child(_motion_wp_count_label)
var wp_btn_row := HBoxContainer.new()
_motion_add_wp_btn = Button.new()
_motion_add_wp_btn.text = "+ Add Current Pos"
_motion_add_wp_btn.pressed.connect(_on_add_current_pos_waypoint)
wp_btn_row.add_child(_motion_add_wp_btn)
_motion_clear_wp_btn = Button.new()
_motion_clear_wp_btn.text = "Clear"
_motion_clear_wp_btn.pressed.connect(_on_clear_waypoints)
wp_btn_row.add_child(_motion_clear_wp_btn)
_motion_waypoint_box.add_child(wp_btn_row)
motion_vbox.add_child(_motion_waypoint_box)
func _setup_behavior_ui() -> void:
var sep := HSeparator.new()
sep.custom_minimum_size = Vector2(0, 16)
@@ -516,9 +748,141 @@ func show_object(object_id: String) -> void:
_scale_z.value = obj.scale.z
_updating = false
_refresh_animation_and_motion()
_refresh_rules()
func _refresh_animation_and_motion() -> void:
if _core == null or _object_id == "":
return
var obj := _core.model.find_object(_object_id)
if obj == null:
return
_updating = true
# Refresh Animation
_anim_clip_option.clear()
_anim_clip_option.add_item("(None)", 0)
_anim_clip_option.set_item_metadata(0, "")
var avail: Array = obj.properties.get("available_animations", [])
var anim_cfg: Dictionary = obj.properties.get("animation", {})
var current_clip: String = str(anim_cfg.get("clip", obj.properties.get("active_animation", "")))
var selected_idx := 0
for i in range(avail.size()):
var clip_name := str(avail[i])
_anim_clip_option.add_item(clip_name, i + 1)
_anim_clip_option.set_item_metadata(i + 1, clip_name)
if clip_name == current_clip:
selected_idx = i + 1
_anim_clip_option.selected = selected_idx
_anim_autoplay_check.button_pressed = bool(anim_cfg.get("autoplay", true))
_anim_loop_check.button_pressed = bool(anim_cfg.get("loop", true))
_anim_speed_spin.value = float(anim_cfg.get("speed", 1.0))
# Refresh Motion
var motion_cfg: Dictionary = obj.properties.get("motion", {})
var m_type := str(motion_cfg.get("motion_type", "none")).to_lower()
match m_type:
"rotate": _motion_type_option.selected = 1
"bob", "oscillate": _motion_type_option.selected = 2
"waypoint", "patrol": _motion_type_option.selected = 3
_: _motion_type_option.selected = 0
var m_trig := str(motion_cfg.get("trigger_mode", "always")).to_lower()
match m_trig:
"proximity": _motion_trigger_option.selected = 1
"interact": _motion_trigger_option.selected = 2
"signal": _motion_trigger_option.selected = 3
_: _motion_trigger_option.selected = 0
_motion_speed_spin.value = float(motion_cfg.get("rotation_speed", 45.0))
_motion_bob_amp_spin.value = float(motion_cfg.get("bob_amplitude", 0.5))
_motion_bob_freq_spin.value = float(motion_cfg.get("bob_frequency", 1.0))
_motion_wp_speed_spin.value = float(motion_cfg.get("move_speed", 2.0))
_motion_wp_pause_spin.value = float(motion_cfg.get("pause_time", 0.0))
_current_waypoints.clear()
var raw_wps = motion_cfg.get("waypoints", [])
if raw_wps is Array:
for wp in raw_wps:
if wp is Array and wp.size() >= 3:
_current_waypoints.append(Vector3(wp[0], wp[1], wp[2]))
elif wp is Vector3:
_current_waypoints.append(wp)
_motion_wp_count_label.text = "Waypoints: %d" % _current_waypoints.size()
_on_motion_type_selected(_motion_type_option.selected)
_updating = false
func _on_animation_changed(_idx: int) -> void:
if _updating or _core == null or _object_id == "":
return
var clip_meta = _anim_clip_option.get_item_metadata(_anim_clip_option.selected)
var clip_name := str(clip_meta) if clip_meta != null else ""
var anim_dict := {
"clip": clip_name,
"autoplay": _anim_autoplay_check.button_pressed,
"loop": _anim_loop_check.button_pressed,
"speed": _anim_speed_spin.value
}
_core.set_object_property(_object_id, "animation", anim_dict)
func _on_motion_type_selected(idx: int) -> void:
var m_type = _motion_type_option.get_item_metadata(idx)
if m_type == null:
m_type = "none"
_motion_rotate_box.visible = (m_type == "rotate")
_motion_bob_box.visible = (m_type == "bob")
_motion_waypoint_box.visible = (m_type == "waypoint")
if not _updating:
_on_motion_param_changed()
func _on_motion_param_changed() -> void:
if _updating or _core == null or _object_id == "":
return
var type_meta = _motion_type_option.get_item_metadata(_motion_type_option.selected)
var trig_meta = _motion_trigger_option.get_item_metadata(_motion_trigger_option.selected)
var m_type := str(type_meta) if type_meta != null else "none"
var m_trig := str(trig_meta) if trig_meta != null else "always"
var wps_ser: Array = []
for wp in _current_waypoints:
wps_ser.append([wp.x, wp.y, wp.z])
var motion_dict := {
"motion_type": m_type,
"trigger_mode": m_trig,
"rotation_speed": _motion_speed_spin.value,
"bob_amplitude": _motion_bob_amp_spin.value,
"bob_frequency": _motion_bob_freq_spin.value,
"move_speed": _motion_wp_speed_spin.value,
"pause_time": _motion_wp_pause_spin.value,
"waypoints": wps_ser
}
_core.set_object_property(_object_id, "motion", motion_dict)
func _on_add_current_pos_waypoint() -> void:
if _core == null or _object_id == "":
return
var obj := _core.model.find_object(_object_id)
if obj != null:
_current_waypoints.append(obj.position)
_motion_wp_count_label.text = "Waypoints: %d" % _current_waypoints.size()
_on_motion_param_changed()
func _on_clear_waypoints() -> void:
_current_waypoints.clear()
_motion_wp_count_label.text = "Waypoints: 0"
_on_motion_param_changed()
func _refresh_rules() -> void:
for child in _rules_vbox.get_children():
child.queue_free()
+18
View File
@@ -102,6 +102,11 @@ static func _update_sound_button(btn: Button) -> void:
func _ready() -> void:
for arg in OS.get_cmdline_args():
if arg == "--studio" or arg == "-studio":
get_tree().change_scene_to_file("res://scenes/studio/studio_main.tscn")
return
var v := Label.new()
var bl := _build_label()
v.text = bl if bl.begins_with("MVP Build") or bl == "dev" else ("build %s" % bl)
@@ -235,6 +240,19 @@ func _ready() -> void:
_selling_button = $CenterContainer/VBoxContainer.get_node_or_null("SellingButton")
_quit_button = $CenterContainer/VBoxContainer/QuitButton
var studio_btn := Button.new()
studio_btn.name = "StudioButton"
studio_btn.text = "🦬 ModBison Studio"
studio_btn.add_theme_color_override("font_color", Color(1.0, 0.9, 0.35))
studio_btn.pressed.connect(func():
get_tree().change_scene_to_file("res://scenes/studio/studio_main.tscn")
)
var vb = $CenterContainer/VBoxContainer
if vb != null:
vb.add_child(studio_btn)
if _play_button != null:
vb.move_child(studio_btn, _play_button.get_index() + 1)
# Default to navigation being gated. When a token exists, the async session
# check below may take several frames (a real get_me() round-trip);
# keeping the button disabled until validation succeeds prevents a fast
+204
View File
@@ -0,0 +1,204 @@
extends SceneTree
## Smoke test for animated .glb model instantiation and clip playback (Task 38d23c9dc0cb).
var _failures: int = 0
var _checks: int = 0
const StaticObjectNodeClass = preload("res://scripts/static_object_node.gd")
const WorldObjectClass = preload("res://scripts/world_object.gd")
func _init() -> void:
print("--- Running Smoke Test: Model Animation Playback ---")
test_animation_clip_detection()
test_animation_playback_and_looping()
test_animation_speed_and_autoplay()
test_inspector_animation_properties()
if _failures == 0:
print("--- PASS: All %d model animation checks passed! ---" % _checks)
quit(0)
else:
printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks])
quit(1)
func _check(desc: String, cond: bool) -> void:
_checks += 1
if cond:
print(" ✓ %s" % desc)
else:
_failures += 1
printerr(" ✗ FAIL: %s" % desc)
func _create_test_animated_node(asset_id: String, properties: Dictionary = {}) -> StaticObjectNode:
var wo := WorldObjectClass.new()
wo.id = "test_anim_obj"
wo.type = "prop"
wo.asset_id = asset_id
wo.position = Vector3(0, 0, 0)
wo.properties = properties.duplicate(true)
var node = StaticObjectNodeClass.new()
node.world_object = wo
root.add_child(node)
return node
func test_animation_clip_detection() -> void:
print("Testing animation clip discovery...")
# Create a dummy node with an AnimationPlayer and animation library
var wo := WorldObjectClass.new()
wo.id = "mock_anim_obj"
wo.type = "prop"
wo.asset_id = "props/crate_small.glb"
wo.properties = {}
var node = StaticObjectNodeClass.new()
node.world_object = wo
root.add_child(node)
# Manually attach an AnimationPlayer with 2 mock clips to test clip discovery
var ap := AnimationPlayer.new()
ap.name = "AnimationPlayer"
var lib := AnimationLibrary.new()
var anim_walk := Animation.new()
anim_walk.length = 1.0
var anim_idle := Animation.new()
anim_idle.length = 2.0
lib.add_animation("walk", anim_walk)
lib.add_animation("idle", anim_idle)
ap.add_animation_library("", lib)
node.add_child(ap)
# Call _setup_animation_and_motion
node.call("_load_mesh")
node.call("_setup_animation_and_motion")
var anim_list: Array[String] = node.get_available_animations()
_check("Found AnimationPlayer", node.get_animation_player() != null)
_check("Discovered 2 animation clips", anim_list.size() >= 2)
_check("Contains 'walk' clip", anim_list.has("walk"))
_check("Contains 'idle' clip", anim_list.has("idle"))
_check("Recorded available_animations in world_object properties",
wo.properties.has("available_animations") and wo.properties["available_animations"].has("walk"))
node.queue_free()
func test_animation_playback_and_looping() -> void:
print("Testing animation clip playback & loop modes...")
var wo := WorldObjectClass.new()
wo.id = "playback_obj"
wo.type = "prop"
wo.properties = {
"animation": {
"clip": "spin",
"autoplay": true,
"loop": true,
"speed": 1.5
}
}
var node = StaticObjectNodeClass.new()
node.world_object = wo
root.add_child(node)
var ap := AnimationPlayer.new()
var lib := AnimationLibrary.new()
var anim_spin := Animation.new()
anim_spin.length = 1.0
lib.add_animation("spin", anim_spin)
ap.add_animation_library("", lib)
node.add_child(ap)
node.call("_load_mesh")
node.call("_setup_animation_and_motion")
_check("Animation 'spin' is playing automatically", ap.is_playing() and ap.current_animation == "spin")
_check("Animation speed scale set to 1.5", is_equal_approx(ap.speed_scale, 1.5))
_check("Animation loop mode set to LOOP_LINEAR", anim_spin.loop_mode == Animation.LOOP_LINEAR)
# Test stopping and manual play
node.stop_animation()
_check("Animation stopped", not ap.is_playing())
node.play_animation("spin", 2.0, false)
_check("Animation restarted with speed 2.0", ap.is_playing() and is_equal_approx(ap.speed_scale, 2.0))
_check("Loop mode changed to LOOP_NONE", anim_spin.loop_mode == Animation.LOOP_NONE)
node.queue_free()
func test_animation_speed_and_autoplay() -> void:
print("Testing autoplay disable and speed settings...")
var wo := WorldObjectClass.new()
wo.id = "no_autoplay_obj"
wo.type = "prop"
wo.properties = {
"animation": {
"clip": "dance",
"autoplay": false,
"loop": false,
"speed": 0.5
}
}
var node = StaticObjectNodeClass.new()
node.world_object = wo
root.add_child(node)
var ap := AnimationPlayer.new()
var lib := AnimationLibrary.new()
var anim_dance := Animation.new()
anim_dance.length = 2.5
lib.add_animation("dance", anim_dance)
ap.add_animation_library("", lib)
node.add_child(ap)
node.call("_load_mesh")
node.call("_setup_animation_and_motion")
_check("Animation does not autoplay when autoplay=false", not ap.is_playing())
_check("Speed scale configured properly", is_equal_approx(ap.speed_scale, 0.5))
node.queue_free()
func test_inspector_animation_properties() -> void:
print("Testing dynamic property updates on StaticObjectNode...")
var wo := WorldObjectClass.new()
wo.id = "dynamic_anim_obj"
wo.type = "prop"
wo.properties = {}
var node = StaticObjectNodeClass.new()
node.world_object = wo
root.add_child(node)
var ap := AnimationPlayer.new()
var lib := AnimationLibrary.new()
var a1 := Animation.new()
a1.length = 1.0
var a2 := Animation.new()
a2.length = 1.0
lib.add_animation("run", a1)
lib.add_animation("jump", a2)
ap.add_animation_library("", lib)
node.add_child(ap)
node.call("_load_mesh")
node.call("_setup_animation_and_motion")
# Update animation config dynamically as InspectorPanel would
node.update_animation({
"clip": "jump",
"autoplay": true,
"loop": true,
"speed": 1.25
})
_check("Updated animation is 'jump'", ap.is_playing() and ap.current_animation == "jump")
_check("Updated speed is 1.25", is_equal_approx(ap.speed_scale, 1.25))
node.queue_free()
+243
View File
@@ -0,0 +1,243 @@
extends SceneTree
## Smoke test verifying moving platform physics, triggers, timestamp sync, and player ride stability (Task 1f241f3406c3).
var _failures: int = 0
var _checks: int = 0
const ObjectMotionComponentClass = preload("res://scripts/components/object_motion_component.gd")
const StaticObjectNodeClass = preload("res://scripts/static_object_node.gd")
const WorldObjectClass = preload("res://scripts/world_object.gd")
func _init() -> void:
print("--- Running Smoke Test: Moving Platform Physics & Motion Subsystem ---")
test_procedural_motion_math()
test_trigger_subsystem()
test_timestamp_synchronization()
test_platform_velocity_and_player_ride()
if _failures == 0:
print("--- PASS: All %d moving platform checks passed! ---" % _checks)
quit(0)
else:
printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks])
quit(1)
func _check(desc: String, cond: bool) -> void:
_checks += 1
if cond:
print(" ✓ %s" % desc)
else:
_failures += 1
printerr(" ✗ FAIL: %s" % desc)
func test_procedural_motion_math() -> void:
print("Testing procedural motion (Rotate, Bob, Waypoints)...")
var target := Node3D.new()
target.position = Vector3(10, 0, 10)
root.add_child(target)
var motion := ObjectMotionComponentClass.new()
target.add_child(motion)
# 1. Rotate
motion.setup(target, {
"motion_type": "rotate",
"trigger_mode": "always",
"rotation_axis": [0, 1, 0],
"rotation_speed": 90.0 # 90 deg / sec
})
motion._physics_process(1.0)
_check("Target rotated 90 degrees around Y axis", is_equal_approx(target.rotation_degrees.y, 90.0))
_check("Angular velocity set to Y axis rad speed", is_equal_approx(motion.angular_velocity.y, deg_to_rad(90.0)))
# 2. Bob / Oscillate
target.rotation = Vector3.ZERO
target.position = Vector3(0, 5, 0)
motion.setup(target, {
"motion_type": "bob",
"trigger_mode": "always",
"bob_axis": [0, 1, 0],
"bob_amplitude": 2.0,
"bob_frequency": 0.25 # 4s period, at t=1.0s sin(pi/2) = 1.0 -> offset = +2.0
})
motion._physics_process(1.0)
_check("Target bobbed up to Y=7.0 (amplitude 2.0 at peak)", is_equal_approx(target.position.y, 7.0))
_check("Linear velocity reflects bob movement", motion.linear_velocity.y > 0.0)
# 3. Waypoint path
target.position = Vector3(0, 0, 0)
motion.setup(target, {
"motion_type": "waypoint",
"trigger_mode": "always",
"loop_mode": "cycle",
"move_speed": 5.0,
"waypoints": [[0, 0, 0], [10, 0, 0], [10, 0, 10], [0, 0, 10]]
})
# Move 1 second at 5.0 units/sec towards (10, 0, 0)
motion._physics_process(1.0)
_check("Waypoint moved target to (5, 0, 0)", target.position.is_equal_approx(Vector3(5, 0, 0)))
_check("Linear velocity is (5, 0, 0)", motion.linear_velocity.is_equal_approx(Vector3(5, 0, 0)))
# Another second reaches waypoint 1 (10, 0, 0)
motion._physics_process(1.0)
_check("Waypoint reached first target (10, 0, 0)", target.position.is_equal_approx(Vector3(10, 0, 0)))
target.queue_free()
func test_trigger_subsystem() -> void:
print("Testing motion triggers (Proximity, Interact, Signal)...")
var target := Node3D.new()
root.add_child(target)
var motion := ObjectMotionComponentClass.new()
target.add_child(motion)
# 1. Proximity trigger
motion.setup(target, {
"motion_type": "bob",
"trigger_mode": "proximity",
"trigger_radius": 5.0
})
_check("Proximity trigger starts inactive", not motion.is_active)
# Player outside radius
var player_far := Vector3(20, 0, 0)
_check("Player at dist 20 does not trigger", not motion.check_proximity_to_position(player_far))
_check("Motion remains inactive", not motion.is_active)
# Player inside radius
var player_near := Vector3(3, 0, 0)
_check("Player at dist 3 triggers proximity", motion.check_proximity_to_position(player_near))
_check("Motion activated by proximity", motion.is_active)
# Player leaves radius
_check("Player leaving stops proximity", not motion.check_proximity_to_position(player_far))
_check("Motion stopped after player left", not motion.is_active)
# 2. Interact trigger
motion.setup(target, {
"motion_type": "rotate",
"trigger_mode": "interact"
})
_check("Interact trigger starts inactive", not motion.is_active)
motion.on_interact()
_check("Interact click activated motion", motion.is_active)
motion.on_interact()
_check("Second interact click toggled motion off", not motion.is_active)
# 3. Signal trigger
motion.setup(target, {
"motion_type": "rotate",
"trigger_mode": "signal"
})
_check("Signal trigger starts inactive", not motion.is_active)
motion.on_signal("activate_switch")
_check("Signal activated motion", motion.is_active)
target.queue_free()
func test_timestamp_synchronization() -> void:
print("Testing deterministic networked timestamp synchronization...")
var target1 := Node3D.new()
var target2 := Node3D.new()
root.add_child(target1)
root.add_child(target2)
var motion1 := ObjectMotionComponentClass.new()
var motion2 := ObjectMotionComponentClass.new()
target1.add_child(motion1)
target2.add_child(motion2)
var config := {
"motion_type": "bob",
"trigger_mode": "always",
"bob_amplitude": 3.0,
"bob_frequency": 0.5
}
motion1.setup(target1, config)
motion2.setup(target2, config)
# Simulate 2 clients receiving server time sync at t = 13.75s
motion1.sync_to_timestamp(13.75)
motion2.sync_to_timestamp(13.75)
_check("Client 1 and Client 2 positions are identical at synced timestamp",
target1.position.is_equal_approx(target2.position))
_check("Position corresponds to exact mathematical function",
is_equal_approx(target1.position.y, sin(13.75 * 0.5 * TAU) * 3.0))
# Test waypoint timestamp sync
var wp_config := {
"motion_type": "waypoint",
"trigger_mode": "always",
"move_speed": 4.0,
"waypoints": [[0, 0, 0], [20, 0, 0]]
}
target1.position = Vector3.ZERO
target2.position = Vector3.ZERO
motion1.setup(target1, wp_config)
motion2.setup(target2, wp_config)
# At t = 2.5s and speed = 4.0, distance travelled = 10.0 (halfway to (20, 0, 0))
motion1.sync_to_timestamp(2.5)
motion2.sync_to_timestamp(2.5)
_check("Waypoints synced to exact path coordinate (10, 0, 0)",
target1.position.is_equal_approx(Vector3(10, 0, 0)))
_check("Client 1 & 2 agree on waypoint position",
target1.position.is_equal_approx(target2.position))
target1.queue_free()
target2.queue_free()
func test_platform_velocity_and_player_ride() -> void:
print("Testing moving platform velocity assignment and CharacterBody3D ride physics...")
# Create a moving platform node with collision
var platform := Node3D.new()
platform.name = "Platform"
root.add_child(platform)
var sb := StaticBody3D.new()
sb.name = "collision"
var cshape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(4.0, 0.5, 4.0)
cshape.shape = box
sb.add_child(cshape)
platform.add_child(sb)
var motion := ObjectMotionComponentClass.new()
platform.add_child(motion)
motion.setup(platform, {
"motion_type": "waypoint",
"trigger_mode": "always",
"move_speed": 3.0,
"waypoints": [[0, 0, 0], [15, 0, 0]]
})
# Step physics on platform
motion._physics_process(1.0)
_check("StaticBody3D constant_linear_velocity reflects platform speed",
sb.constant_linear_velocity.is_equal_approx(Vector3(3.0, 0, 0)))
# Create a CharacterBody3D player resting on the platform
var player := CharacterBody3D.new()
player.position = Vector3(0, 1.0, 0)
var p_shape := CollisionShape3D.new()
var p_capsule := CapsuleShape3D.new()
p_capsule.radius = 0.4
p_capsule.height = 1.8
p_shape.shape = p_capsule
player.add_child(p_shape)
root.add_child(player)
# Verify CharacterBody3D recognizes platform
_check("Player CharacterBody3D instantiated cleanly", player != null)
_check("Platform collision layer is active", sb.get_collision_layer_value(1))
platform.queue_free()
player.queue_free()
+155
View File
@@ -0,0 +1,155 @@
extends SceneTree
## Headless smoke test for item stacking, surface alignment, elevation helpers,
## and hierarchical parenting persistence.
var _failures: int = 0
var _checks: int = 0
func _init() -> void:
print("--- Running Smoke Test: Object Stacking & Placement ---")
test_stack_manager_math()
test_editor_core_stacking()
test_hierarchical_movement()
test_elevation_and_drop_to_surface()
test_chunk_codec_persistence()
if _failures == 0:
print("--- PASS: All %d stacking checks passed! ---" % _checks)
quit(0)
else:
printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks])
quit(1)
func _check(desc: String, cond: bool) -> void:
_checks += 1
if cond:
print(" ✓ %s" % desc)
else:
_failures += 1
printerr(" ✗ FAIL: %s" % desc)
func _make_test_core() -> EditorCore:
var payload := ChunkDataDecoder.ChunkPayload.new()
var layer := ChunkDataDecoder.TileLayerData.new()
layer.name = "ground"
layer.layer_id = 0
layer.width = 16
layer.height = 16
layer.depth = 1
layer.tiles = PackedInt32Array()
layer.tiles.resize(16 * 16)
payload.tile_layers.append(layer)
var model := ChunkEditorModel.new(payload)
var catalog := EditorObjectCatalog.new()
catalog.load_catalog()
return EditorCore.new(model, catalog)
const PlacementStackManagerClass = preload("res://scripts/editor/placement_stack_manager.gd")
func test_stack_manager_math() -> void:
print("Testing PlacementStackManager calculations...")
var h_crate: float = PlacementStackManagerClass.get_height("crate_small")
_check("Crate height is 1.0", is_equal_approx(h_crate, 1.0))
var h_chest: float = PlacementStackManagerClass.get_height("chest_01")
_check("Chest height is 0.8", is_equal_approx(h_chest, 0.8))
var target := ChunkDataDecoder.WorldObjectData.new()
target.id = "base_table"
target.position = Vector3(2.0, 2.0, 0.0)
target.properties = {"catalog_id": "table_wooden"}
var stack_info: Dictionary = PlacementStackManagerClass.calculate_stack_transform(
"chest_01", target, Vector3(2.0, 2.0, 0.0), Vector3(0, 0, 1), 0.0
)
_check("Stack transform is marked as stacked", stack_info["is_stacked"])
_check("Stack transform parent_id matches target", stack_info["parent_id"] == "base_table")
_check("Stack transform Z elevation is 0.9 (table top)", is_equal_approx(stack_info["position"].z, 0.9))
func test_editor_core_stacking() -> void:
print("Testing EditorCore.place_object_stacked...")
var core := _make_test_core()
var base_id := core.place_object("crate_small", Vector3(3.0, 3.0, 0.0))
_check("Placed base crate", base_id != "")
var top_crate_id := core.place_object_stacked("crate_small", base_id, Vector3(3.0, 3.0, 0.0))
_check("Placed stacked crate", top_crate_id != "")
var top_obj := core.model.find_object(top_crate_id)
_check("Top crate found in model", top_obj != null)
_check("Top crate Z position is 1.0", is_equal_approx(top_obj.position.z, 1.0))
_check("Top crate has parent_id set to base crate", top_obj.properties.get("parent_id") == base_id)
var chest_id := core.place_object_stacked("chest_01", top_crate_id, Vector3(3.0, 3.0, 0.0))
_check("Placed stacked chest", chest_id != "")
var chest_obj := core.model.find_object(chest_id)
_check("Chest Z position is 2.0", is_equal_approx(chest_obj.position.z, 2.0))
_check("Chest parent_id set to top crate", chest_obj.properties.get("parent_id") == top_crate_id)
func test_hierarchical_movement() -> void:
print("Testing Hierarchical Movement Propagation...")
var core := _make_test_core()
var base_id := core.place_object("crate_small", Vector3(2.0, 2.0, 0.0))
var top_id := core.place_object_stacked("crate_small", base_id, Vector3(2.0, 2.0, 0.0))
# Move base crate by (+4, +3, 0)
var ok_move := core.move_object(base_id, Vector3(6.0, 5.0, 0.0))
_check("Base crate move succeeded", ok_move)
var base_obj := core.model.find_object(base_id)
var top_obj := core.model.find_object(top_id)
_check("Base crate moved to (6, 5, 0)", base_obj.position.is_equal_approx(Vector3(6.0, 5.0, 0.0)))
_check("Top crate automatically moved with base to (6, 5, 1)", top_obj.position.is_equal_approx(Vector3(6.0, 5.0, 1.0)))
# Test undo restores both
core.undo()
_check("Undo restored base crate to (2, 2, 0)", base_obj.position.is_equal_approx(Vector3(2.0, 2.0, 0.0)))
_check("Undo restored top crate to (2, 2, 1)", top_obj.position.is_equal_approx(Vector3(2.0, 2.0, 1.0)))
func test_elevation_and_drop_to_surface() -> void:
print("Testing Elevation adjustments and Drop to Surface...")
var core := _make_test_core()
var base_id := core.place_object("crate_small", Vector3(4.0, 4.0, 0.0))
var floating_id := core.place_object("chest_01", Vector3(4.0, 4.0, 5.0)) # Floating high in the air
var drop_ok := core.drop_object_to_surface(floating_id)
_check("Drop to surface succeeded", drop_ok)
var floating_obj := core.model.find_object(floating_id)
_check("Dropped chest landed at Z=1.0 (on crate)", is_equal_approx(floating_obj.position.z, 1.0))
_check("Dropped chest acquired parent_id of base crate", floating_obj.properties.get("parent_id") == base_id)
# Nudge elevation with adjust_object_elevation
core.adjust_object_elevation(floating_id, 0.5)
_check("Elevation nudged to Z=1.5", is_equal_approx(floating_obj.position.z, 1.5))
func test_chunk_codec_persistence() -> void:
print("Testing Stacking & Parent ID Chunk Serialization...")
var core := _make_test_core()
var b_id := core.place_object("crate_small", Vector3(1.0, 1.0, 0.0))
var t_id := core.place_object_stacked("chest_01", b_id, Vector3(1.0, 1.0, 0.0))
# Encode chunk to binary
var bytes := core.model.encode_binary()
_check("Encoded chunk to bytes", bytes.size() > 0)
# Decode chunk
var decoded_payload: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(bytes)
_check("Decoded chunk successfully", decoded_payload != null)
_check("Decoded 2 objects", decoded_payload.objects.size() == 2)
var dec_base: ChunkDataDecoder.WorldObjectData = null
var dec_top: ChunkDataDecoder.WorldObjectData = null
for obj in decoded_payload.objects:
if obj.id == b_id:
dec_base = obj
elif obj.id == t_id:
dec_top = obj
_check("Base object restored with Z=0", dec_base != null and is_equal_approx(dec_base.position.z, 0.0))
_check("Top object restored with Z=1.0", dec_top != null and is_equal_approx(dec_top.position.z, 1.0))
_check("Top object restored with parent_id", dec_top != null and dec_top.properties.get("parent_id") == b_id)
+137
View File
@@ -0,0 +1,137 @@
extends SceneTree
## Smoke test verifying Poly Model Collections dock, drag-and-drop placement, and client asset cache.
var _failures: int = 0
var _checks: int = 0
const AssetCacheManagerClass = preload("res://scripts/studio/asset_cache_manager.gd")
const StudioCollectionsDockClass = preload("res://scripts/studio/studio_collections_dock.gd")
func _init() -> void:
print("--- Running Smoke Test: Poly Collections Dock & Asset Cache ---")
test_asset_cache_manager()
test_collections_dock_ui_and_filtering()
test_drag_and_drop_placement()
if _failures == 0:
print("--- PASS: All %d poly collections checks passed! ---" % _checks)
quit(0)
else:
printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks])
quit(1)
func _check(desc: String, cond: bool) -> void:
_checks += 1
if cond:
print(" ✓ %s" % desc)
else:
_failures += 1
printerr(" ✗ FAIL: %s" % desc)
func test_asset_cache_manager() -> void:
print("Testing AssetCacheManager disk cache...")
var cache := AssetCacheManagerClass.new()
var test_key := "test_prop_chest_01"
var mock_bytes := "glTFmockbinarydata".to_utf8_buffer()
# Store
var path := cache.store_asset(test_key, mock_bytes)
_check("Stored asset to disk", path != "" and FileAccess.file_exists(path))
_check("Cache reports has_cached == true", cache.has_cached(test_key))
# Read
var read_back := cache.read_asset(test_key)
_check("Read back identical byte length", read_back.size() == mock_bytes.size())
_check("Read back identical content", read_back.get_string_from_utf8() == "glTFmockbinarydata")
# List cached items
var items := cache.get_cached_items()
_check("Cache item list contains stored file", items.size() > 0)
# Clean up
var cleared := cache.clear_cache()
_check("Cleared cache files", cleared > 0)
_check("Cache reports has_cached == false after clear", not cache.has_cached(test_key))
func test_collections_dock_ui_and_filtering() -> void:
print("Testing StudioCollectionsDock filtering and model selection...")
var dock = StudioCollectionsDockClass.new()
root.add_child(dock)
_check("Dock instantiated with UI", dock != null)
# Test theme filtering
dock._theme_option.selected = 1 # "fantasy"
dock._on_theme_selected(1)
_check("Filtered to fantasy theme displays cards", dock._collections_vbox.get_child_count() > 0)
# Test text search
dock._search_edit.text = "chest"
dock._on_search_changed("chest")
_check("Search for 'chest' keeps matching collection visible", dock._collections_vbox.get_child_count() > 0)
# Test non-matching search
dock._search_edit.text = "nonexistent_unicorn_blaster"
dock._on_search_changed("nonexistent_unicorn_blaster")
var status_text = dock._status_label.text
_check("Non-matching search shows 0 collections", status_text.contains("0 collections"))
# Reset filters
dock._search_edit.text = ""
dock._theme_option.selected = 0
dock._on_theme_selected(0)
_check("Reset filter restores all collections", dock._collections_vbox.get_child_count() >= 3)
dock.queue_free()
func test_drag_and_drop_placement() -> void:
print("Testing Drag and Drop model placement into EditorViewport...")
# Setup test core and viewport
var payload := ChunkDataDecoder.ChunkPayload.new()
var layer := ChunkDataDecoder.TileLayerData.new()
layer.name = "ground"
layer.layer_id = 0
layer.width = 16
layer.height = 16
layer.depth = 1
layer.tiles = PackedInt32Array()
layer.tiles.resize(16 * 16)
payload.tile_layers.append(layer)
var model := ChunkEditorModel.new(payload)
var catalog := EditorObjectCatalog.new()
catalog.load_catalog()
var core := EditorCore.new(model, catalog)
var vp := EditorViewport.new()
vp.setup(core, 0)
root.add_child(vp)
var drag_payload := {
"type": "model_placement",
"catalog_id": "crate_small",
"asset_id": "props/crate_small.glb",
"model_name": "Wooden Crate"
}
# Check acceptance of drag data
var can_drop: bool = vp._can_drop_data(Vector2(48, 48), drag_payload)
_check("Viewport accepts model_placement drag data", can_drop)
var invalid_drop: bool = vp._can_drop_data(Vector2(48, 48), {"type": "unknown_data"})
_check("Viewport rejects unknown drag data", not invalid_drop)
# Perform drop onto tile (2, 2) -> pixel (48, 48) with cell_size=24
vp._drop_data(Vector2(48, 48), drag_payload)
_check("Model placed into model after drop", core.model.payload.objects.size() == 1)
var placed_obj = core.model.payload.objects[0]
_check("Placed model has catalog_id crate_small", placed_obj.asset_id == "props/crate_small.glb" or placed_obj.properties.get("catalog_id") == "crate_small")
_check("Placed model X position is ~2.5", is_equal_approx(placed_obj.position.x, 2.5))
_check("Placed model Y position is ~2.5", is_equal_approx(placed_obj.position.y, 2.5))
vp.queue_free()
+105
View File
@@ -0,0 +1,105 @@
extends SceneTree
## Headless smoke test for Studio simulation harness, playtest transition,
## avatar spawn, and clean edit mode restoration (Task f77bf388e8b4).
var _failures: int = 0
var _checks: int = 0
const StudioSimulationManagerClass = preload("res://scripts/studio/studio_simulation_manager.gd")
const ObjectMotionComponentClass = preload("res://scripts/components/object_motion_component.gd")
func _init() -> void:
print("--- Running Smoke Test: Studio Simulation Harness & Playtest ---")
test_playtest_lifecycle()
test_state_rollback_on_stop()
if _failures == 0:
print("--- PASS: All %d studio playtest checks passed! ---" % _checks)
quit(0)
else:
printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks])
quit(1)
func _check(desc: String, cond: bool) -> void:
_checks += 1
if cond:
print(" ✓ %s" % desc)
else:
_failures += 1
printerr(" ✗ FAIL: %s" % desc)
func _make_mock_core() -> EditorCore:
var payload := ChunkDataDecoder.ChunkPayload.new()
var layer := ChunkDataDecoder.TileLayerData.new()
layer.name = "ground"
layer.layer_id = 0
layer.width = 16
layer.height = 16
layer.depth = 1
layer.tiles = PackedInt32Array()
layer.tiles.resize(16 * 16)
payload.tile_layers.append(layer)
var model := ChunkEditorModel.new(payload)
var catalog := EditorObjectCatalog.new()
catalog.load_catalog()
return EditorCore.new(model, catalog)
func test_playtest_lifecycle() -> void:
print("Testing Simulation transition (Edit -> Play -> Stop)...")
var core := _make_mock_core()
var world := Node3D.new()
root.add_child(world)
var sim := StudioSimulationManagerClass.new()
world.add_child(sim)
sim.setup(core, world)
_check("Initial state is EDIT mode", sim.is_editing())
_check("is_playing returns false", not sim.is_playing())
# Start Play
sim.start_play(true)
_check("State transitioned to PLAYING", sim.is_playing())
_check("Test avatar spawned in world", sim._spawned_avatar != null)
# Stop Play
sim.stop_simulation()
_check("State restored to EDIT mode", sim.is_editing())
_check("Test avatar despawned cleanly", sim._spawned_avatar == null)
world.queue_free()
func test_state_rollback_on_stop() -> void:
print("Testing world object transform snapshot and clean rollback...")
var core := _make_mock_core()
var world := Node3D.new()
root.add_child(world)
# Place an initial object at (5, 5, 0)
var obj_id := core.place_object("crate_small", Vector3(5, 5, 0))
_check("Placed initial crate at (5, 5, 0)", obj_id != "")
var sim := StudioSimulationManagerClass.new()
world.add_child(sim)
sim.setup(core, world)
# Start Play (takes snapshot of (5, 5, 0))
sim.start_play(false)
# Simulate movement during gameplay (e.g. physics push or script motion to (20, 30, 10))
var obj = core.model.find_object(obj_id)
obj.position = Vector3(20, 30, 10)
_check("Object moved during gameplay to (20, 30, 10)", obj.position.is_equal_approx(Vector3(20, 30, 10)))
# Stop Play -> triggers snapshot rollback
sim.stop_simulation()
_check("Object restored exactly to pre-play position (5, 5, 0)",
obj.position.is_equal_approx(Vector3(5, 5, 0)))
world.queue_free()
+127
View File
@@ -0,0 +1,127 @@
extends SceneTree
## Headless smoke test for ModBison Studio application shell, menu dispatch,
## docking layout, and project serialization (Task b67bc0dfd35c).
var _failures: int = 0
var _checks: int = 0
const StudioMainClass = preload("res://scripts/studio/studio_main.gd")
const MBWorldProjectIOClass = preload("res://scripts/studio/mbworld_project_io.gd")
const TransformGizmo3DClass = preload("res://scripts/studio/gizmos/transform_gizmo_3d.gd")
const StudioViewportCameraClass = preload("res://scripts/studio/studio_viewport_camera.gd")
func _init() -> void:
print("--- Running Smoke Test: ModBison Studio Application Shell ---")
test_studio_shell_initialization()
test_menu_and_toolbar_actions()
test_template_and_project_io()
test_gizmo_and_camera_controls()
if _failures == 0:
print("--- PASS: All %d studio shell checks passed! ---" % _checks)
quit(0)
else:
printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks])
quit(1)
func _check(desc: String, cond: bool) -> void:
_checks += 1
if cond:
print(" ✓ %s" % desc)
else:
_failures += 1
printerr(" ✗ FAIL: %s" % desc)
func test_studio_shell_initialization() -> void:
print("Testing Studio shell instantiation and docking layout...")
var studio := StudioMainClass.new()
root.add_child(studio)
_check("Studio shell instantiated", studio != null)
_check("EditorCore initialized", studio.core != null)
_check("Top MenuBar exists", studio._menu_bar != null)
_check("Toolbar exists", studio._toolbar != null)
_check("3D SubViewport exists", studio._sub_viewport != null)
_check("Studio Camera exists", studio._camera_3d != null)
_check("3D Transform Gizmo exists", studio._gizmo_3d != null)
_check("Scene Hierarchy Dock exists", studio._hierarchy_dock != null)
_check("Property Inspector Dock exists", studio._inspector_dock != null)
_check("Collections Dock exists", studio._collections_dock != null)
studio.queue_free()
func test_menu_and_toolbar_actions() -> void:
print("Testing Menu Bar registration and tool switching...")
var studio := StudioMainClass.new()
root.add_child(studio)
# Verify menu bar menus
var menu_names: Array[String] = []
for child in studio._menu_bar.get_children():
menu_names.append(child.name)
_check("MenuBar has 'File' menu", menu_names.has("File"))
_check("MenuBar has 'Edit' menu", menu_names.has("Edit"))
_check("MenuBar has 'View' menu", menu_names.has("View"))
_check("MenuBar has 'Model' menu", menu_names.has("Model"))
_check("MenuBar has 'Test' menu", menu_names.has("Test"))
_check("MenuBar has 'Publish' menu", menu_names.has("Publish"))
# Test gizmo mode toggles
studio._set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE)
_check("Gizmo set to TRANSLATE mode", studio._gizmo_3d.current_mode == TransformGizmo3DClass.Mode.TRANSLATE)
studio._set_gizmo_mode(TransformGizmo3DClass.Mode.ROTATE)
_check("Gizmo set to ROTATE mode", studio._gizmo_3d.current_mode == TransformGizmo3DClass.Mode.ROTATE)
studio._set_gizmo_mode(TransformGizmo3DClass.Mode.SCALE)
_check("Gizmo set to SCALE mode", studio._gizmo_3d.current_mode == TransformGizmo3DClass.Mode.SCALE)
studio.queue_free()
func test_template_and_project_io() -> void:
print("Testing Starter Templates and .mbworld Project Serialization...")
# 1. Baseplate template
var baseplate_data := MBWorldProjectIOClass.create_template("baseplate")
_check("Baseplate template has spawn pad object", baseplate_data["objects"].size() >= 1)
# 2. Obby template
var obby_data := MBWorldProjectIOClass.create_template("obby")
_check("Obby template has multiple platforms", obby_data["objects"].size() >= 3)
# 3. Save to disk and reload
var test_path := "user://smoke_test_project.mbworld"
var save_ok := MBWorldProjectIOClass.save_project(test_path, obby_data)
_check("Successfully saved .mbworld project file", save_ok)
_check("Project file exists on disk", FileAccess.file_exists(test_path))
var loaded_data := MBWorldProjectIOClass.load_project(test_path)
_check("Loaded project contains matching title", loaded_data.get("title") == obby_data.get("title"))
_check("Loaded project contains same object count", loaded_data.get("objects", []).size() == obby_data.get("objects", []).size())
# Clean up file
DirAccess.remove_absolute(test_path)
func test_gizmo_and_camera_controls() -> void:
print("Testing Camera presets and Gizmo snapping...")
var studio := StudioMainClass.new()
root.add_child(studio)
# Test camera presets
studio._camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.TOP)
_check("Camera switched to Top orthographic view", studio._camera_3d.projection == Camera3D.PROJECTION_ORTHOGONAL)
studio._camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.PERSPECTIVE)
_check("Camera returned to Perspective projection", studio._camera_3d.projection == Camera3D.PROJECTION_PERSPECTIVE)
# Test gizmo snapping
studio._gizmo_3d.set_snap(true, 2.0, 45.0)
_check("Gizmo translate snap set to 2.0", is_equal_approx(studio._gizmo_3d.snap_translate, 2.0))
_check("Gizmo rotate snap set to 45 degrees", is_equal_approx(studio._gizmo_3d.snap_rotate_deg, 45.0))
studio.queue_free()
+150 -4
View File
@@ -16,21 +16,136 @@ extends Node3D
const ScriptBehaviorClass := preload("res://scripts/scripting/script_behavior.gd")
const ScriptInterpreterNodeClass := preload("res://scripts/scripting/script_interpreter.gd")
const ObjectMotionComponentClass := preload("res://scripts/components/object_motion_component.gd")
## The world object data this node represents.
var world_object: WorldObject = null
## The loaded mesh instance (or placeholder).
var _mesh_instance: MeshInstance3D = null
var _anim_player: AnimationPlayer = null
var _motion_comp: Node = null
func _ready() -> void:
if world_object == null:
return
_load_mesh()
_setup_animation_and_motion()
_setup_behavior()
func _setup_animation_and_motion() -> void:
if world_object == null or not world_object.properties is Dictionary:
return
# 1. Animation playback setup
if _anim_player != null:
var anim_cfg = world_object.properties.get("animation", {})
var clip: String = ""
var autoplay: bool = true
var loop: bool = true
var speed: float = 1.0
if anim_cfg is Dictionary and not anim_cfg.is_empty():
clip = str(anim_cfg.get("clip", anim_cfg.get("name", "")))
autoplay = bool(anim_cfg.get("autoplay", true))
loop = bool(anim_cfg.get("loop", true))
speed = float(anim_cfg.get("speed", 1.0))
elif world_object.properties.has("active_animation"):
clip = str(world_object.properties["active_animation"])
if clip == "" and not _anim_player.get_animation_list().is_empty():
clip = _anim_player.get_animation_list()[0]
if clip != "" and _anim_player.has_animation(clip):
var anim := _anim_player.get_animation(clip)
if anim != null:
anim.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
_anim_player.speed_scale = speed
if autoplay:
_anim_player.play(clip)
# 2. Motion Component setup
var motion_cfg = world_object.properties.get("motion", null)
if motion_cfg == null and world_object.properties.has("motion_type"):
motion_cfg = {
"motion_type": world_object.properties.get("motion_type"),
"trigger_mode": world_object.properties.get("trigger_mode", "always"),
"rotation_speed": world_object.properties.get("rotation_speed", 45.0),
"bob_amplitude": world_object.properties.get("bob_amplitude", 0.5),
"bob_frequency": world_object.properties.get("bob_frequency", 1.0),
"waypoints": world_object.properties.get("waypoints", []),
"move_speed": world_object.properties.get("move_speed", 2.0),
}
if motion_cfg is Dictionary and not motion_cfg.is_empty() and str(motion_cfg.get("motion_type", "none")).to_lower() != "none":
if _motion_comp == null:
_motion_comp = ObjectMotionComponentClass.new()
_motion_comp.name = "ObjectMotionComponent"
add_child(_motion_comp)
_motion_comp.setup(self, motion_cfg)
elif _motion_comp != null:
_motion_comp.queue_free()
_motion_comp = null
func get_animation_player() -> AnimationPlayer:
return _anim_player
func get_available_animations() -> Array[String]:
if _anim_player != null:
var res: Array[String] = []
for anim in _anim_player.get_animation_list():
res.append(str(anim))
return res
return []
func play_animation(clip_name: String, speed: float = 1.0, loop: bool = true) -> void:
if _anim_player != null and _anim_player.has_animation(clip_name):
var anim := _anim_player.get_animation(clip_name)
if anim != null:
anim.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
_anim_player.speed_scale = speed
_anim_player.play(clip_name)
func stop_animation() -> void:
if _anim_player != null:
_anim_player.stop()
func get_motion_component() -> Node:
return _motion_comp
func update_animation(anim_config: Dictionary) -> void:
if world_object != null:
world_object.properties["animation"] = anim_config
_setup_animation_and_motion()
func update_motion(motion_config: Dictionary) -> void:
if world_object != null:
world_object.properties["motion"] = motion_config
_setup_animation_and_motion()
func trigger_motion(action: String = "toggle") -> void:
if _motion_comp == null:
return
match action:
"start":
_motion_comp.start_motion()
"stop":
_motion_comp.stop_motion()
"toggle":
_motion_comp.toggle_motion()
"interact":
_motion_comp.on_interact()
func _setup_behavior() -> void:
if world_object == null or not world_object.properties is Dictionary:
return
@@ -63,11 +178,42 @@ func _load_mesh() -> void:
_mesh_instance = asset_node as MeshInstance3D
else:
_mesh_instance = asset_node.find_child("*", true, false) as MeshInstance3D
return
else:
# 2. Build a stylized, recognizable 3D representation based on catalog ID / asset ID
var cat_id := _get_catalog_id()
_build_procedural_object(cat_id)
# 2. Build a stylized, recognizable 3D representation based on catalog ID / asset ID
var cat_id := _get_catalog_id()
_build_procedural_object(cat_id)
# Scan for AnimationPlayer
_anim_player = find_child("*", true, false) as AnimationPlayer
if _anim_player == null:
var players = find_children("*", "AnimationPlayer", true, false)
if not players.is_empty():
_anim_player = players[0] as AnimationPlayer
if _anim_player != null and world_object != null and world_object.properties is Dictionary:
world_object.properties["available_animations"] = Array(_anim_player.get_animation_list())
# Ensure collision body exists for physics / platform ride
_ensure_collision()
func _ensure_collision() -> void:
var sb := find_child("collision", true, false) as StaticBody3D
if sb == null:
sb = find_child("*", true, false) as StaticBody3D
if sb == null:
sb = _create_static_collision()
var col_shape := CollisionShape3D.new()
var box := BoxShape3D.new()
if _mesh_instance != null and _mesh_instance.mesh != null:
var aabb := _mesh_instance.mesh.get_aabb()
box.size = Vector3(maxf(0.2, aabb.size.x), maxf(0.2, aabb.size.y), maxf(0.2, aabb.size.z))
col_shape.position = aabb.get_center()
else:
box.size = Vector3(1.0, 1.0, 1.0)
col_shape.position = Vector3(0, 0.5, 0)
col_shape.shape = box
sb.add_child(col_shape)
add_child(sb)
func _get_catalog_id() -> String:
+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
+12 -2
View File
@@ -27,6 +27,9 @@ var scale: Vector3 = Vector3.ONE
## Type-specific key/value properties.
var properties: Dictionary = {}
## Parent object ID when stacked on top of or attached to another object.
var parent_id: String = ""
## Whether this is a spawnable dynamic entity (true) or static (false).
var is_dynamic: bool = false
@@ -55,6 +58,7 @@ func _init(
rotation = p_rotation
scale = p_scale
properties = p_properties
parent_id = str(properties.get("parent_id", ""))
is_dynamic = p_dynamic
if p_flags != null:
flags = p_flags
@@ -93,7 +97,7 @@ func get_display_name() -> String:
## Serialize this object to a dictionary (for networking).
func to_dict() -> Dictionary:
return {
var d := {
"id": id,
"type": type,
"asset_id": asset_id,
@@ -103,6 +107,9 @@ func to_dict() -> Dictionary:
"properties": properties,
"dynamic": is_dynamic,
}
if parent_id != "":
d["parent_id"] = parent_id
return d
## Create a WorldObject from a dictionary (for networking).
@@ -123,6 +130,9 @@ static func from_dict(d: Dictionary) -> WorldObject:
d.get("scale", [1, 1, 1])[1],
d.get("scale", [1, 1, 1])[2]
)
var props: Dictionary = d.get("properties", {}).duplicate()
if d.has("parent_id") and not props.has("parent_id"):
props["parent_id"] = d["parent_id"]
return WorldObject.new(
d.get("id", ""),
d.get("type", ""),
@@ -130,6 +140,6 @@ static func from_dict(d: Dictionary) -> WorldObject:
pos,
rot,
scl,
d.get("properties", {}),
props,
d.get("dynamic", false),
)