destroyulator/game/scripts/Smashable.gd
Monster Robot Party c96c2f1a67 LANE8: the greengrocer — soft stock, stacks that collapse, and litres
The only site whose contents are mostly SOFT, and that one material change rewrites the
level. `produce` is a new Smashable material that throws no shards at all — Splat.gd
bursts it instead: coloured pulp, a puddle that STAYS, a squelch, and litres counted,
because "how much juice did you make" is a far better score for a greengrocer than "how
many objects did you destroy".

Two knock-ons make the level play itself, both falling out of existing systems rather
than needing new ones:
  * produce has a low brittle_speed, so anything heavy landing on it squashes it — which
    means a pyramid crushes its OWN bottom layer as it collapses, with no special code.
    Knock one off the top and the pile does the rest. Shove test: 257 items -> 45, and
    42 litres of juice out of the collapse.
  * puddles are slippery, cutting ACCELERATION rather than top speed so it reads as
    sliding rather than as being slowed down. The more mess you make, the less the floor
    cooperates.

Stacks are built bottom-up by Main._build_stack and nothing is glued or frozen — they
stand because they are stacked, so pulling one out of the bottom row does what you'd
hope. 258 produce items spawn and settle to 0.00 m/s with no spawn crush. The glass
bottles behind the juice bar are the deliberate exception: one shelf in the room that
still rewards a proper swing, and the contrast between litres and shards is the joke.

Round produce is authored CENTRED, not floor-centred like every other prop in this repo,
because a rolling body whose origin sits on the floor plane wobbles like a loaded die.
_glb_piece grew a `centred` flag for it.

REAL BUG FOUND, and it was costing hits in every level: _strike took the NEAREST collider
of any kind, so a static surface could eat a swing. Leaning over a display table, the
table edge is a few centimetres nearer than the fruit piled on it, so every swing hit the
table and nothing broke — 0/306 for the whole first take. It now prefers a Smashable and
only falls back to loose bodies if there isn't one. The offices were quietly losing hits
on desks and shelves the same way.

tools/gen_produce.py: apple, orange, tomato, watermelon, cabbage, banana hand, glass
juice bottle, display crate. The melon's stripes were radial boxes first time and it came
out looking like a sea mine; they're thin lenses through the middle now.

dev/probe_grocer.gd verifies stacks settle, that produce yields litres and NO debris,
that a collapse crushes, and that none of it leaks to the next site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:38:35 +10:00

242 lines
10 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", "plastic", "paper", "produce") 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
## Scales this prop's hit points above its material default. The printer boss is 5.0.
@export var toughness_scale: float = 1.0
## Boss props get a bigger death burst (more juice particles).
@export var is_boss: bool = false
var _hp := 1.0
var _hp_max := 1.0
# per-material behaviour — this table is where "material identity" is authored.
# hp ........... damage it soaks. A swing deals Weapon.power * Weapon.vs[kind], so
# this is the other half of the weapon-vs-material matrix.
# 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, "hp": 2.2},
"cardboard": {"color": Color(0.66, 0.5, 0.32), "shards": 4, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.6},
"vinyl": {"color": Color(0.04, 0.04, 0.05), "shards": 6, "brittle_speed": 2.2, "bounce": 0.12, "hp": 0.9},
"glass": {"color": Color(0.55, 0.8, 0.85), "shards": 12, "brittle_speed": 1.6, "bounce": 0.0, "hp": 1.0},
"steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25, "hp": 4.5},
"plastic": {"color": Color(0.82, 0.78, 0.70), "shards": 6, "brittle_speed": 0.0, "bounce": 0.15, "hp": 1.4},
"paper": {"color": Color(0.92, 0.90, 0.84), "shards": 3, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.3},
# Produce does NOT throw shards — Splat.gd bursts it instead (see shatter()). The
# low brittle_speed is what makes a collapsing pyramid crush its own bottom layer
# with no special code: anything landing on it hard enough squashes it.
"produce": {"color": Color(0.80, 0.42, 0.20), "shards": 0, "brittle_speed": 1.8, "bounce": 0.10, "hp": 0.35},
}
signal smashed(kind: String, at: Vector3, shard_count: int)
## A hit landed but this weapon can't meaningfully hurt this material — the
## box-cutter-on-a-filing-cabinet clank. Juice turns it into a sound + a small shake.
signal resisted(kind: String, at: Vector3)
## A hit that hurt but didn't kill. `frac` is remaining hp, 0..1.
signal damaged(kind: String, at: Vector3, frac: float)
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"])
_hp_max = float(p["hp"]) * maxf(toughness_scale, 0.01)
_hp = _hp_max
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)
## Take a swing. `damage` is what the weapon deals to THIS material (see Weapon.vs);
## the default of -1 means "whatever it takes", which keeps thrown-object and scripted
## smashes working without needing a weapon.
##
## Three outcomes, and the middle one is the point of the whole matrix:
## damage kills it ....... shatter
## damage dents it ....... knockback + `damaged`, so heavy props are a fight
## damage is futile ...... `resisted` — the clank that tells you to switch weapon
func smash(impulse: Vector3, damage: float = -1.0) -> void:
if _shattered:
return
if damage < 0.0:
shatter(impulse)
return
if damage < _hp_max * 0.10:
# too weak to matter: bounce off, make a noise, leave the prop intact
sleeping = false
apply_central_impulse(impulse.limit_length(4.0) * 0.10)
resisted.emit(kind, global_position)
return
_hp -= damage
if _hp <= 0.0:
shatter(impulse)
return
# survived — a physical knockback so it visibly reacts to the beating
sleeping = false
apply_central_impulse(impulse.limit_length(6.0) * 0.25)
damaged.emit(kind, global_position, clampf(_hp / _hp_max, 0.0, 1.0))
## Remaining hp as 0..1, for HUD damage reads.
func hp_frac() -> float:
return clampf(_hp / maxf(_hp_max, 0.001), 0.0, 1.0)
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
# produce bursts rather than shattering; Main routes it to Splat off `smashed`
if kind == "produce":
pass
elif 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))