Consume Lane 2's pre-fractured props in the Godot game. Smashable.shatter() now prefers a <name>.fractured.glb sibling: it instances the fractured template and turns each chunk_00…chunk_NN mesh into its own debris RigidBody3D that inherits the intact body's velocity + the smash impulse (box collider per chunk, debris group so the HUD count + Juice keep working), falling back to primitive shards when no fractured sibling exists. _glb_piece auto-wires the sibling, so every GLB prop upgrades for free. Smashable gains hits_to_break + is_boss: the office PRINTER is the level boss — it sits on a desk, soaks 5 hits (rocking with each), then bursts in a bigger juice cloud into its 16 GLB chunks. Six more Lane 2 props (crt-monitor, filing-cabinet, water-cooler, cardboard-box, turntable, office-desk) are scattered as smashable furniture; the store crate/rack also pick up real 8-chunk shatter from their mirrored fractured GLBs. Assets pulled into game/assets/store/ (intact + .fractured.glb): office-printer, office-desk, crt-monitor, filing-cabinet, water-cooler, cardboard-box, turntable + record/crate/rack fractured siblings (generated via meshgod's Voronoi fracture CLI). Verified headless: smoke test clean (0 script errors), printer boss soaks 4 hits then spawns 16 chunk bodies, a crate spawns 8 — both real GLB chunks, not primitive shards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
8.3 KiB
GDScript
203 lines
8.3 KiB
GDScript
extends RigidBody3D
|
|
class_name Smashable
|
|
|
|
## A piece of the world that can be smashed.
|
|
##
|
|
## The cascade lives here: frame pieces start FROZEN (they act static and hold the
|
|
## stuff above them up). When you smash a frame piece it vanishes into shards, so
|
|
## everything resting on it falls — no scripted "release" needed, gravity does it.
|
|
## Brittle kinds (vinyl, glass) also shatter on their own when they hit something
|
|
## hard enough — that's the satisfying TAIL of the cascade: the record survives the
|
|
## tumble out of its jacket, then cracks a beat later when it smacks the floor.
|
|
|
|
@export_enum("wood", "cardboard", "vinyl", "glass", "steel") var kind: String = "wood"
|
|
@export var start_frozen: bool = false
|
|
|
|
## Pre-fractured sibling (Lane 2 contract #2): a scene whose leaf `chunk_*` meshes are
|
|
## the real shards. When set, shatter() spawns those instead of primitive cubes. Main
|
|
## auto-assigns it from a `<name>.fractured.glb` next to the intact GLB.
|
|
@export var fractured_scene: PackedScene = null
|
|
## Hits to destroy. 1 = normal (one smash breaks it). The printer boss takes several.
|
|
@export var hits_to_break: int = 1
|
|
## Boss props get a bigger death burst (more juice particles) + survive extra hits.
|
|
@export var is_boss: bool = false
|
|
var _hits_taken := 0
|
|
|
|
# per-material behaviour — this table is where "material identity" is authored.
|
|
# brittle_speed 0.0 == not brittle (won't self-shatter on impact).
|
|
const PROFILES := {
|
|
"wood": {"color": Color(0.42, 0.28, 0.15), "shards": 7, "brittle_speed": 0.0, "bounce": 0.05},
|
|
"cardboard": {"color": Color(0.66, 0.5, 0.32), "shards": 4, "brittle_speed": 0.0, "bounce": 0.0},
|
|
"vinyl": {"color": Color(0.04, 0.04, 0.05), "shards": 6, "brittle_speed": 2.2, "bounce": 0.12},
|
|
"glass": {"color": Color(0.55, 0.8, 0.85), "shards": 12, "brittle_speed": 1.6, "bounce": 0.0},
|
|
"steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25},
|
|
}
|
|
|
|
signal smashed(kind: String, at: Vector3, shard_count: int)
|
|
|
|
var _shattered := false
|
|
var _speed := 0.0 # last physics-frame speed, sampled before any collision resolves
|
|
|
|
## Pieces this one holds up. When THIS shatters, these get released (unfrozen) so
|
|
## gravity drops them. Wired by Main._build_rack (e.g. post.supports.append(shelf)).
|
|
## Losing ANY primary support drops the load — arcade feel, no partial-support math.
|
|
var supports: Array[Node] = []
|
|
var _released := false # so a piece is only released once (idempotent, cycle-safe)
|
|
|
|
func _ready() -> void:
|
|
add_to_group("smashable")
|
|
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
|
|
physics_material_override = PhysicsMaterial.new()
|
|
physics_material_override.bounce = p["bounce"]
|
|
if start_frozen:
|
|
freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
|
|
freeze = true
|
|
if float(p["brittle_speed"]) > 0.0:
|
|
contact_monitor = true
|
|
max_contacts_reported = 4
|
|
body_entered.connect(_on_body_entered)
|
|
|
|
func _physics_process(_dt: float) -> void:
|
|
if not freeze:
|
|
_speed = linear_velocity.length()
|
|
|
|
func _on_body_entered(_body: Node) -> void:
|
|
if _shattered:
|
|
return
|
|
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
|
|
if _speed >= float(p["brittle_speed"]):
|
|
shatter(Vector3.ZERO)
|
|
|
|
## Called by the hammer. Bosses (hits_to_break > 1) soak hits and visibly rock before
|
|
## the final blow shatters them; a normal prop (hits_to_break == 1) breaks on hit one.
|
|
func smash(impulse: Vector3) -> void:
|
|
if _shattered:
|
|
return
|
|
_hits_taken += 1
|
|
if _hits_taken < hits_to_break:
|
|
# survives this hit — a physical knockback so the boss reacts to the beating
|
|
sleeping = false
|
|
apply_central_impulse(impulse.limit_length(6.0) * 0.25)
|
|
return
|
|
shatter(impulse)
|
|
|
|
func shatter(impulse: Vector3) -> void:
|
|
if _shattered:
|
|
return
|
|
_shattered = true
|
|
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
|
|
var burst := 24 if is_boss else int(p["shards"]) # boss dies in a bigger cloud of juice
|
|
smashed.emit(kind, global_position, burst)
|
|
_release_supported() # drop what I was holding up BEFORE I leave the tree — the cascade
|
|
if fractured_scene != null:
|
|
_spawn_chunks(impulse) # real GLB shards (Lane 2)
|
|
else:
|
|
_spawn_shards(int(p["shards"]), p["color"], impulse)
|
|
queue_free()
|
|
|
|
## Wake every piece this one was holding up. Guarded on both ends (each child's own
|
|
## _released flag) so a shared load or an accidental cycle can't loop.
|
|
func _release_supported() -> void:
|
|
for node in supports:
|
|
if is_instance_valid(node) and node is Smashable:
|
|
node.release()
|
|
|
|
## Turn a frozen support into a live dynamic body and give it a comedic nudge so it
|
|
## visibly topples instead of sinking straight down. Idempotent: two legs releasing
|
|
## the same shelf only drop it once.
|
|
func release() -> void:
|
|
if _released:
|
|
return
|
|
_released = true
|
|
if _shattered:
|
|
return # already gone; nothing to drop
|
|
if freeze:
|
|
freeze = false # gravity + forces resume immediately
|
|
sleeping = false # defensive against a Jolt sleep frame
|
|
apply_central_impulse(Vector3(randf_range(-0.6, 0.6), 0.15, randf_range(-0.6, 0.6)))
|
|
|
|
## Real destruction: instance the pre-fractured sibling and turn each `chunk_*` mesh into
|
|
## its own rigid body that inherits our velocity + the smash impulse. Chunks are named
|
|
## chunk_00…chunk_NN with each origin at its own centroid, in this model's local space
|
|
## (Lane 2 contract #2). We add the template at our transform so each chunk's global
|
|
## transform is already correct, then reparent it onto a fresh body. Debris group so the
|
|
## HUD count + Juice keep working. Falls back to primitive shards if the template is empty.
|
|
func _spawn_chunks(impulse: Vector3) -> void:
|
|
var parent := get_parent()
|
|
if parent == null:
|
|
return
|
|
var tmpl: Node3D = fractured_scene.instantiate()
|
|
parent.add_child(tmpl)
|
|
tmpl.global_transform = global_transform # chunks now sit exactly where the intact body was
|
|
var chunks: Array[MeshInstance3D] = []
|
|
_collect_chunk_meshes(tmpl, chunks)
|
|
if chunks.is_empty():
|
|
tmpl.queue_free()
|
|
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
|
|
_spawn_shards(int(p["shards"]), p["color"], impulse)
|
|
return
|
|
var inherited := linear_velocity
|
|
var here := global_position
|
|
for chunk in chunks:
|
|
var gt := chunk.global_transform
|
|
var body := RigidBody3D.new()
|
|
body.add_to_group("debris")
|
|
body.mass = 0.25
|
|
# reparent the chunk mesh onto the body, centred on the body origin
|
|
chunk.owner = null
|
|
chunk.get_parent().remove_child(chunk)
|
|
body.add_child(chunk)
|
|
chunk.transform = Transform3D.IDENTITY
|
|
var ab := chunk.get_aabb()
|
|
var col := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = ab.size.max(Vector3(0.02, 0.02, 0.02))
|
|
col.shape = box
|
|
col.position = ab.get_center()
|
|
body.add_child(col)
|
|
parent.add_child(body)
|
|
body.global_transform = gt
|
|
# fling outward from the smash centre + inherit motion + the hit impulse
|
|
var out := (gt.origin - here)
|
|
out = out.normalized() if out.length() > 0.001 else Vector3(randf_range(-1, 1), 0.5, randf_range(-1, 1)).normalized()
|
|
body.linear_velocity = inherited + out * randf_range(1.2, 3.2) + impulse * 0.3
|
|
body.angular_velocity = Vector3(randf_range(-8, 8), randf_range(-8, 8), randf_range(-8, 8))
|
|
tmpl.queue_free() # the now-empty template wrapper
|
|
|
|
func _collect_chunk_meshes(n: Node, acc: Array[MeshInstance3D]) -> void:
|
|
if n is MeshInstance3D and n.name.to_lower().begins_with("chunk"):
|
|
acc.append(n)
|
|
for c in n.get_children():
|
|
_collect_chunk_meshes(c, acc)
|
|
|
|
func _spawn_shards(n: int, color: Color, impulse: Vector3) -> void:
|
|
if n <= 0:
|
|
return
|
|
var parent := get_parent()
|
|
if parent == null:
|
|
return
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = color
|
|
var inherited := linear_velocity
|
|
for i in n:
|
|
var s := RigidBody3D.new()
|
|
s.add_to_group("debris")
|
|
s.mass = 0.08
|
|
var sz := randf_range(0.03, 0.09)
|
|
var mesh := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = Vector3(sz, sz, sz)
|
|
mesh.mesh = bm
|
|
mesh.material_override = mat
|
|
s.add_child(mesh)
|
|
var col := CollisionShape3D.new()
|
|
var shape := BoxShape3D.new()
|
|
shape.size = bm.size
|
|
col.shape = shape
|
|
s.add_child(col)
|
|
parent.add_child(s)
|
|
s.global_position = global_position + Vector3(randf_range(-0.08, 0.08), randf_range(-0.08, 0.08), randf_range(-0.08, 0.08))
|
|
var kick := Vector3(randf_range(-1, 1), randf_range(0.3, 1.4), randf_range(-1, 1)).normalized() * randf_range(0.6, 2.2)
|
|
s.linear_velocity = inherited + kick + impulse * 0.3
|
|
s.angular_velocity = Vector3(randf_range(-7, 7), randf_range(-7, 7), randf_range(-7, 7))
|