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