destroyulator/game/scripts/RageOverlay.gd
Monster Robot Party 81c9c4a349 LANE8: scales that swing, bags that don't open, and fruit you can throw
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>
2026-08-01 11:22:47 +10:00

203 lines
7.4 KiB
GDScript

extends CanvasLayer
class_name RageOverlay
## The blood vessel in your eye, throbbing faster as you lose it.
##
## Three layers, all driven by the same Rage instance:
## 1. a red vignette that closes in as the meter fills, pulsing on the heartbeat
## 2. actual VEINS — a branching tree rooted at the screen edges that grows INWARD as
## the meter fills: thick dark trunks first, fine capillaries only when you're
## really going, and a branch never appears before the trunk carrying it. Generated
## once from a fixed seed so they don't crawl.
## 3. a synthesized lub-dub heartbeat whose rate IS the meter (Rage.bpm)
##
## No textures and no shader files: the vignette is a small ShaderMaterial built in code
## and the veins are draw_polyline, so this drops into a project with zero asset deps.
##
## Godot 4.7 GDScript 2.0.
const VIGNETTE_SHADER := """
shader_type canvas_item;
uniform float rage : hint_range(0.0, 1.0) = 0.0;
uniform float pulse : hint_range(0.0, 1.0) = 0.0;
uniform float meltdown : hint_range(0.0, 1.0) = 0.0;
void fragment() {
// squash y so the vignette matches a wide viewport instead of going circular
vec2 d = (UV - vec2(0.5)) * vec2(1.0, 0.62);
float r = length(d) * 2.0;
// The tunnel closes in as rage climbs and jumps inward on every thump — but the
// CENTRE stays readable. You have to be able to see what you're about to destroy,
// so this tops out as a heavy frame rather than a red screen.
float inner = mix(0.98, 0.44, rage) - pulse * 0.09 * rage;
float v = smoothstep(inner, 1.30, r);
float a = v * (rage * 0.62 + meltdown * 0.16);
vec3 col = mix(vec3(0.52, 0.02, 0.03), vec3(0.75, 0.05, 0.02), meltdown);
COLOR = vec4(col, clamp(a, 0.0, 0.95));
}
"""
var rage: Rage
var _vignette: ColorRect
var _veins: Control
var _heart: AudioStreamPlayer
var _beat_wav: AudioStreamWAV
var _last_pulse := 0.0
var _paths: Array = [] # {pts: 0..1 screen space, depth, t0, span}
func setup(r: Rage) -> void:
rage = r
layer = 20 # above the HUD
_vignette = ColorRect.new()
_vignette.set_anchors_preset(Control.PRESET_FULL_RECT)
_vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sh := Shader.new()
sh.code = VIGNETTE_SHADER
var m := ShaderMaterial.new()
m.shader = sh
_vignette.material = m
add_child(_vignette)
_veins = Control.new()
_veins.set_anchors_preset(Control.PRESET_FULL_RECT)
_veins.mouse_filter = Control.MOUSE_FILTER_IGNORE
_veins.draw.connect(_draw_veins)
add_child(_veins)
_build_veins()
_heart = AudioStreamPlayer.new()
_beat_wav = _make_beat()
_heart.stream = _beat_wav
_heart.volume_db = -6.0
add_child(_heart)
# ---------------------------------------------------------------- veins
## Branching polylines rooted at the screen corners and growing inward. Built once with
## a seeded RNG so the pattern is stable — veins that re-randomise every frame read as
## static, not as anatomy.
func _build_veins() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 0x8100D
_paths.clear()
var roots := [
[Vector2(0.0, 0.10), Vector2(1.0, 0.6)], [Vector2(0.0, 0.86), Vector2(1.0, -0.5)],
[Vector2(1.0, 0.14), Vector2(-1.0, 0.6)], [Vector2(1.0, 0.90), Vector2(-1.0, -0.5)],
[Vector2(0.16, 0.0), Vector2(0.4, 1.0)], [Vector2(0.82, 0.0), Vector2(-0.4, 1.0)],
[Vector2(0.28, 1.0), Vector2(0.3, -1.0)], [Vector2(0.72, 1.0), Vector2(-0.3, -1.0)],
]
for r in roots:
_grow(r[0], (r[1] as Vector2).normalized(), 0.30, 5, rng, 0.0, 0.34)
## One segment plus its children. `t0`/`span` are WHERE ALONG THE REVEAL this segment
## lives: a branch cannot appear before the trunk that carries it has finished growing,
## which is the difference between veins creeping in from the edge and a screen of
## disconnected scratches.
func _grow(from: Vector2, dir: Vector2, length: float, depth: int,
rng: RandomNumberGenerator, t0: float, span: float) -> void:
if depth <= 0 or length < 0.02 or t0 >= 1.0:
return
var pts := PackedVector2Array()
pts.append(from)
var p := from
var d := dir
var steps := 4
for i in steps:
# wander, but keep being pulled the way the vein set off: a vessel meanders,
# it doesn't change its mind
d = d.rotated(rng.randf_range(-0.30, 0.30)).lerp(dir, 0.25).normalized()
p += d * (length / float(steps))
pts.append(p)
_paths.append({"pts": pts, "depth": depth, "t0": t0, "span": span})
if rng.randf() < 0.85:
_grow(p, d.rotated(rng.randf_range(0.35, 0.95)), length * 0.62, depth - 1, rng,
t0 + span, span * 0.66)
if rng.randf() < 0.7:
_grow(p, d.rotated(rng.randf_range(-0.95, -0.35)), length * 0.58, depth - 1, rng,
t0 + span, span * 0.66)
func _draw_veins() -> void:
if rage == null:
return
var r := rage.value
if r < 0.12 and not rage.is_meltdown():
return
var size := _veins.size
var pulse := rage.pulse()
var a := clampf((r - 0.10) * 1.25, 0.0, 1.0)
if rage.is_meltdown():
a = 1.0
var base_w := (1.1 + 5.0 * r) * (0.80 + 0.55 * pulse)
# how far the growth has got. Trunks first; the fine stuff only at real rage.
var reveal := clampf(0.18 + r * 1.05, 0.0, 1.0)
for e in _paths:
var path: Dictionary = e
var t0: float = path["t0"]
if reveal <= t0:
continue # its parent hasn't got here yet
var pts: PackedVector2Array = path["pts"]
var f := clampf((reveal - t0) / maxf(float(path["span"]), 0.001), 0.0, 1.0)
var n := int(ceil(pts.size() * f))
if n < 2:
continue
var scaled := PackedVector2Array()
for i in n:
scaled.append(Vector2(pts[i].x * size.x, pts[i].y * size.y))
# thick dark trunks, fine pale capillaries — a flat width for both is what made
# them read as biro scribble instead of a vessel
var deep := float(path["depth"]) / 5.0
var w := base_w * (0.22 + 0.78 * deep)
var col := Color(0.62, 0.04, 0.05, a * (0.22 + 0.40 * pulse) * (0.45 + 0.55 * deep))
_veins.draw_polyline(scaled, col, w, true)
# ---------------------------------------------------------------- heartbeat
## A lub-dub built from two damped low sines. Same zero-asset trick as Juice.gd.
func _make_beat() -> AudioStreamWAV:
var rate := 22050
var dur := 0.42
var n := int(rate * dur)
var out := PackedFloat32Array()
out.resize(n)
for i in n:
var t := float(i) / float(rate)
var s := 0.0
# lub
var e1 := exp(-t * 26.0)
s += sin(TAU * 52.0 * t) * e1 * 0.9
s += sin(TAU * 78.0 * t) * e1 * 0.3
# dub, a beat later and softer
var t2 := t - 0.16
if t2 > 0.0:
var e2 := exp(-t2 * 30.0)
s += sin(TAU * 44.0 * t2) * e2 * 0.55
s += sin(TAU * 66.0 * t2) * e2 * 0.2
out[i] = clampf(s, -1.0, 1.0)
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = rate
wav.stereo = false
var bytes := PackedByteArray()
bytes.resize(out.size() * 2)
for i in out.size():
bytes.encode_s16(i * 2, int(out[i] * 32767.0))
wav.data = bytes
return wav
# ---------------------------------------------------------------- per-frame
func _process(_dt: float) -> void:
if rage == null:
return
var m := _vignette.material as ShaderMaterial
var p := rage.pulse()
m.set_shader_parameter("rage", rage.value)
m.set_shader_parameter("pulse", p)
m.set_shader_parameter("meltdown", 1.0 if rage.is_meltdown() else 0.0)
_veins.queue_redraw()
# fire the thump on the rising edge of each beat
if p > 0.55 and _last_pulse <= 0.55 and (rage.value > 0.18 or rage.is_meltdown()):
_heart.volume_db = lerpf(-20.0, -3.0, clampf(rage.value, 0.0, 1.0))
_heart.pitch_scale = lerpf(0.92, 1.16, rage.value)
_heart.play()
_last_pulse = p