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>
216 lines
7.0 KiB
GDScript
216 lines
7.0 KiB
GDScript
extends Node
|
|
class_name Splat
|
|
|
|
## Produce doesn't break. It bursts.
|
|
##
|
|
## Everything else in this game shatters into shards, which is the wrong verb entirely
|
|
## for a tomato. So `produce` gets its own death: a wet burst of pulp in the fruit's own
|
|
## colour, a puddle left on the floor that STAYS, a squelch, and a litre counter — 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 that make the level play itself:
|
|
## * Anything heavy enough landing on produce squashes it, so a collapsing pyramid
|
|
## crushes its own bottom layer without any special code. Knock one melon off the top
|
|
## and the pile does the rest.
|
|
## * Puddles are SLIPPERY. Make enough juice and the floor stops cooperating, which is
|
|
## a self-inflicted hazard — the more fun you have, the worse the footing gets.
|
|
##
|
|
## Godot 4.7 GDScript 2.0.
|
|
|
|
## Litres per item, roughly by size. A watermelon is worth a lot of tomatoes.
|
|
const YIELD := {
|
|
"apple": 0.18, "orange": 0.22, "tomato": 0.12, "melon": 2.40,
|
|
"cabbage": 0.35, "banana": 0.14, "generic": 0.20,
|
|
}
|
|
|
|
## Juice colour per item. This is the whole visual identity of the level's mess.
|
|
const JUICE := {
|
|
"apple": Color(0.86, 0.78, 0.34),
|
|
"orange": Color(0.94, 0.52, 0.08),
|
|
"tomato": Color(0.68, 0.10, 0.07),
|
|
"melon": Color(0.88, 0.24, 0.30),
|
|
"cabbage": Color(0.72, 0.82, 0.48),
|
|
"banana": Color(0.92, 0.85, 0.52),
|
|
"generic": Color(0.85, 0.55, 0.25),
|
|
}
|
|
|
|
## A body must land at least this hard to squash what it lands on.
|
|
const CRUSH_SPEED := 2.2
|
|
## Puddles beyond this are recycled oldest-first, so a long session stays bounded.
|
|
const MAX_PUDDLES := 90
|
|
## How close to a puddle counts as standing in it.
|
|
const PUDDLE_R := 0.55
|
|
|
|
var litres := 0.0
|
|
var squashed := 0
|
|
|
|
var _host: Node3D
|
|
var _player: Node3D
|
|
var _puddles: Array = [] # [{node, pos, r}]
|
|
var _snd: Array[AudioStreamWAV] = []
|
|
var _players: Array[AudioStreamPlayer3D] = []
|
|
var _next_player := 0
|
|
|
|
func setup(host: Node3D, player: Node3D) -> void:
|
|
_host = host
|
|
_player = player
|
|
for i in 3:
|
|
_snd.append(_squelch(0.9 + i * 0.15))
|
|
# a small pool, because a collapsing pyramid squashes a dozen things in one frame
|
|
for i in 8:
|
|
var p := AudioStreamPlayer3D.new()
|
|
p.unit_size = 5.0
|
|
p.max_db = 2.0
|
|
host.add_child(p)
|
|
_players.append(p)
|
|
|
|
func reset() -> void:
|
|
litres = 0.0
|
|
squashed = 0
|
|
for e in _puddles:
|
|
var n = e["node"]
|
|
if is_instance_valid(n):
|
|
n.queue_free()
|
|
_puddles.clear()
|
|
|
|
## Which produce is this? Derived from the node name, which Main sets from the GLB
|
|
## filename (`produce-apple` -> `apple`).
|
|
static func kind_of(n: Node) -> String:
|
|
var s := String(n.name).to_lower()
|
|
for k in YIELD.keys():
|
|
if s.contains(k):
|
|
return k
|
|
return "generic"
|
|
|
|
# ---------------------------------------------------------------- the burst
|
|
## Called instead of the usual shard spawn when a `produce` Smashable dies.
|
|
func burst(at: Vector3, item: String, host: Node3D) -> void:
|
|
var col: Color = JUICE.get(item, JUICE["generic"])
|
|
var vol: float = YIELD.get(item, YIELD["generic"])
|
|
litres += vol
|
|
squashed += 1
|
|
_pulp(at, col, host, vol)
|
|
_puddle(at, col, vol)
|
|
_squelch_at(at)
|
|
|
|
func _pulp(at: Vector3, col: Color, host: Node3D, vol: float) -> void:
|
|
var p := GPUParticles3D.new()
|
|
p.one_shot = true
|
|
p.explosiveness = 1.0
|
|
p.amount = clampi(int(28 + vol * 40.0), 24, 140)
|
|
p.lifetime = 1.5
|
|
p.local_coords = false
|
|
var pm := ParticleProcessMaterial.new()
|
|
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
|
pm.emission_sphere_radius = 0.05
|
|
pm.direction = Vector3(0, 1, 0)
|
|
pm.spread = 80.0
|
|
pm.initial_velocity_min = 1.4
|
|
pm.initial_velocity_max = 4.6 + vol
|
|
pm.gravity = Vector3(0, -12.0, 0)
|
|
pm.scale_min = 0.5
|
|
pm.scale_max = 1.6
|
|
pm.angular_velocity_min = -500.0
|
|
pm.angular_velocity_max = 500.0
|
|
p.process_material = pm
|
|
var mesh := BoxMesh.new()
|
|
mesh.size = Vector3(0.028, 0.028, 0.028)
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = col
|
|
mat.roughness = 0.35
|
|
mesh.material = mat
|
|
p.draw_pass_1 = mesh
|
|
host.add_child(p)
|
|
p.global_position = at
|
|
p.emitting = true
|
|
p.finished.connect(p.queue_free)
|
|
|
|
## A flat quad on the floor. Not a decal projector — this is one unshaded quad slightly
|
|
## above the carpet, which is cheap, and at grazing angles it reads exactly the same.
|
|
func _puddle(at: Vector3, col: Color, vol: float) -> void:
|
|
var r := clampf(0.22 + vol * 0.30, 0.22, 0.95)
|
|
var mi := MeshInstance3D.new()
|
|
var q := QuadMesh.new()
|
|
q.size = Vector2(r * 2.0, r * 2.0)
|
|
mi.mesh = q
|
|
var m := StandardMaterial3D.new()
|
|
m.albedo_color = Color(col.r, col.g, col.b, 0.72)
|
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
m.render_priority = -1
|
|
mi.material_override = m
|
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
_host.add_child(mi)
|
|
mi.global_position = Vector3(at.x, 0.012, at.z)
|
|
mi.rotation = Vector3(-PI * 0.5, randf_range(0.0, TAU), 0.0)
|
|
_puddles.append({"node": mi, "pos": mi.global_position, "r": r})
|
|
while _puddles.size() > MAX_PUDDLES:
|
|
var old = _puddles.pop_front()
|
|
if is_instance_valid(old["node"]):
|
|
old["node"].queue_free()
|
|
|
|
func _squelch_at(at: Vector3) -> void:
|
|
if _players.is_empty():
|
|
return
|
|
var p := _players[_next_player]
|
|
_next_player = (_next_player + 1) % _players.size()
|
|
p.stream = _snd[randi() % _snd.size()]
|
|
p.pitch_scale = randf_range(0.86, 1.18)
|
|
p.global_position = at
|
|
p.play()
|
|
|
|
# ---------------------------------------------------------------- slipping
|
|
## How slippery the ground is under the player, 0..1. Player reads this to cut its
|
|
## acceleration — the more juice you've made, the less the floor cooperates.
|
|
func slip_under_player() -> float:
|
|
if _player == null:
|
|
return 0.0
|
|
var here := _player.global_position
|
|
var worst := 0.0
|
|
for e in _puddles:
|
|
var p: Vector3 = e["pos"]
|
|
var d := Vector2(here.x - p.x, here.z - p.z).length()
|
|
var reach: float = float(e["r"]) + PUDDLE_R * 0.5
|
|
if d < reach:
|
|
worst = maxf(worst, 1.0 - d / reach)
|
|
return worst
|
|
|
|
func hud_line() -> String:
|
|
if squashed <= 0:
|
|
return ""
|
|
var slip := slip_under_player()
|
|
return "%.1f litres · %d squashed%s" % [
|
|
litres, squashed, " · SLIPPERY" if slip > 0.35 else ""]
|
|
|
|
# ---------------------------------------------------------------- audio
|
|
## Wet, short, and low. Noise through a falling low-pass, roughly.
|
|
func _squelch(pitch: float) -> AudioStreamWAV:
|
|
var rate := 22050
|
|
var dur := 0.30
|
|
var n := int(rate * dur)
|
|
var out := PackedFloat32Array()
|
|
out.resize(n)
|
|
var lp := 0.0
|
|
for i in n:
|
|
var t := float(i) / float(rate)
|
|
var env := exp(-t * 14.0) * (1.0 - exp(-t * 200.0))
|
|
var raw := (randf() * 2.0 - 1.0)
|
|
# sweep the filter closed so it goes from a splat to a glop
|
|
var k := clampf(0.55 - t * 1.4, 0.06, 0.55)
|
|
lp += (raw - lp) * k
|
|
var s := lp * 1.5 * env
|
|
s += sin(TAU * 120.0 * pitch * t) * 0.22 * env
|
|
out[i] = clampf(s, -1.0, 1.0)
|
|
var w := AudioStreamWAV.new()
|
|
w.format = AudioStreamWAV.FORMAT_16_BITS
|
|
w.mix_rate = rate
|
|
w.stereo = false
|
|
var b := PackedByteArray()
|
|
b.resize(n * 2)
|
|
for i in n:
|
|
b.encode_s16(i * 2, int(out[i] * 32767.0))
|
|
w.data = b
|
|
return w
|