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
+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()