destroyulator/scripts/Smashable.gd
Monster Robot Party a8b735f79e Destroyulator prototype: first-person cascade smasher (Godot 4.7)
Mac-native (Apple Silicon / Metal), Jolt physics.
- First-person controller + semi-opaque viewmodel hands
- Punch (L-click) / kick (R-click) via a swappable MeleeAttack resource
- Structural support-graph cascade: knock the legs -> the rack pancakes
- Brittle vinyl records shatter on hard impact
- Day-0 stress gate (B: rain 500) — holds 60fps on the M3 Ultra

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:25:35 +10:00

126 lines
4.9 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
# 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.
func smash(impulse: Vector3) -> void:
shatter(impulse)
func shatter(impulse: Vector3) -> void:
if _shattered:
return
_shattered = true
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
smashed.emit(kind, global_position, int(p["shards"]))
_release_supported() # drop what I was holding up BEFORE I leave the tree — the cascade
_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)))
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))