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