The three things the greengrocer was still missing, plus the physics bug that
finding them uncovered.
HANGING SCALES. Real PinJoint3D pendulums, not animations: a static dial and
rod, and a scale-pan rigid body pinned at its own origin so it can only rotate
about the pivot. They hang at chest height down the aisles — dial at eye level,
dish below — where a real shop scale hangs and where you keep walking into it.
The dish swings 0.40 m off a light knock and the pin holds the pivot to 1.1 mm.
The pan is steel so it never breaks; what you get is a heavy brass weight loose
in a room full of stacked fruit.
THE BAG YOU CANNOT OPEN. Tearing one off the roll is the easy half — that's the
setup. Then you have to open it, and the bag has two ends, only one of which
opens, and nothing tells you which. Rubbing alternates arrow keys, because
mashing one key is not rubbing. Either the meter climbs or it doesn't, and the
only way to learn which end you're holding is to have already lost several
seconds to the other one. SPACE turns it over. That is the whole solution and
the game never says so.
CARRY AND THROW. E picks up anything under 6 kg that isn't a record; LMB throws
it at 11 m/s, six times any brittle threshold in the game. Thrown fruit bursts
on landing through the same brittle_speed path a collapse uses — no separate
thrown-object code at all.
Then the part that took the longest. Round produce got sphere colliders (a box
on an apple is why nothing ever rolled) and every display in the shop instantly
fell over. Three separate things were wrong:
- the stack radii were TYPED, not measured. The table said a cabbage was
84 mm; the model is 213 mm, so that pyramid was built with every row driven
a third of the way into the row below it. Main._glb_radius() reads the asset.
- the lattice was box geometry. For spheres the rise per row is
sqrt(4r^2 - step^2/2) — the height at which a fruit touches all four
beneath it. Anything else spawns every row above the first in mid-air.
- there was no tray. A pyramid of spheres on a bare flat table cannot stand;
nothing holds the bottom row in, so the weight above wedges it outward and
the display walks itself apart in a second. Main._stack_tray() frames each
pile in four low timber walls sized to its base row, which is what every
greengrocer on earth already does.
All 310 bodies asleep within 3 s.
While chasing that, the spawn guard fired on a cardboard box in the OFFICE. The
guard is a net, not a test: it only catches a pair Jolt happens to resolve
violently on the frames it's watching, and this one had been interpenetrating
in four levels for weeks. dev/probe_overlap.gd now finds them by measurement,
comparing every pair of dynamic colliders across all six sites. It found 15,
including a row of filing cabinets 0.70 m apart that are 0.80 m wide, and a
stapler inside a monitor. The box asset is 1.35 m across, so boxes are now
stacked into piles rather than dotted about, and the Backrooms scatter uses
rejection sampling with a 1.6 m minimum. All six sites read CLEAN.
Also: the rage veins were drawing every branch from its own start point
regardless of how far the trunk had grown, so at low rage you got disconnected
fragments floating mid-screen that read as biro scribble rather than blood. A
branch now can't appear before the trunk carrying it, and trunks are thick and
dark where capillaries are fine and pale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
11 KiB
GDScript
247 lines
11 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))
|
|
|
|
## Has this already come apart? Freed-but-not-yet-collected pieces linger in the
|
|
## `smashable` group for a frame or two, so anything iterating the group needs this.
|
|
func is_broken() -> bool:
|
|
return _shattered
|
|
|
|
## 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))
|