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