The game stopped being a smash sandbox. You work a shift, the work provokes you, a
meter fills, you snap, and you cannot go back to your desk until you have broken
enough things. Then you sit back down and try to earn the rest of your day.
The score is your payslip: WAGES (tasks completed) - DAMAGES (everything you broke).
The load-bearing detail is that rage drains a FLAT amount per object destroyed while
each object bills at its OWN value — so during a meltdown the play is to wreck a lot
of cheap rubbish fast rather than put the sledgehammer through a filing cabinet. You
don't choose whether you lose your temper, you choose how much it costs. FURY (objects
broken while melting down) is tracked separately and deliberately isn't money, so
"I ended the shift owing them $2,400" stays a brag.
Rage.gd — WORKING -> MELTDOWN -> COOLDOWN. During a meltdown the clock STALLS if you
stop destroying; you must actually be breaking things.
RageOverlay.gd — the blood vessel. A vignette that closes in with rage and jumps
inward on every heartbeat, procedural veins that creep further into frame the angrier
you get, and a synthesized lub-dub going 62 -> 172 BPM. Zero assets: the shader is
built in code, the veins are draw_polyline from a seeded RNG, the heartbeat is a
generated WAV. Tops out as a heavy frame rather than a red screen — you have to be
able to see what you're about to destroy.
Tasks.gd — stations you walk to and are LOCKED IN PLACE at, because you're at your
desk and that's the joke. Two so far:
SPREADSHEET the selected cell silently drifts one cell partway through entry, with
no sound and no animation. Commit without noticing and the figure lands
in the wrong cell and you start again. This is the whole thesis of the
game in about forty lines.
PHOTOCOPIER jams, and wants the right tray out of 1, 2, 2A, 3, 3B.
Snapping ejects you from the station at no penalty — you didn't choose to walk away.
CubicleHell.gd — 5-minute shift, wages vs damages, meltdown count, end-of-shift
payslip with a verdict line.
Also: HUD gets a monospace face (Godot's fallback is proportional, so the spreadsheet
grid and the work-order docket never lined up), and the arcade HUD's headline becomes
net pay, red when negative.
LANES/LANE7-cubicle-hell.md captures the rest of the design: more provocations, the
GAUNTLET mode (find which of many cabinets, pull drawers, destroy files individually,
and work out which tool the level wants without being told), the other three
workplaces, and why doratracyer/floor_plan can't help — it's OpenSCAD SVG->STL for
3D printing, no generation, no room polygons, GPL-3.0. Office.gd is already most of
a parametric floor-plan builder; lifting its numbers into a data spec is the real path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
6.3 KiB
GDScript
186 lines
6.3 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 — branching polylines creeping in from the corners, thickening
|
|
## with each thump. 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 = [] # each entry: PackedVector2Array in 0..1 screen space
|
|
|
|
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)
|
|
|
|
func _grow(from: Vector2, dir: Vector2, length: float, depth: int,
|
|
rng: RandomNumberGenerator) -> void:
|
|
if depth <= 0 or length < 0.02:
|
|
return
|
|
var pts := PackedVector2Array()
|
|
pts.append(from)
|
|
var p := from
|
|
var d := dir
|
|
var steps := 4
|
|
for i in steps:
|
|
d = d.rotated(rng.randf_range(-0.42, 0.42)).normalized()
|
|
p += d * (length / float(steps))
|
|
pts.append(p)
|
|
_paths.append(pts)
|
|
# branch
|
|
if rng.randf() < 0.85:
|
|
_grow(p, d.rotated(rng.randf_range(0.35, 0.95)), length * 0.62, depth - 1, rng)
|
|
if rng.randf() < 0.7:
|
|
_grow(p, d.rotated(rng.randf_range(-0.95, -0.35)), length * 0.58, depth - 1, rng)
|
|
|
|
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 col := Color(0.62, 0.04, 0.05, a * (0.30 + 0.45 * pulse))
|
|
var w := (1.4 + 4.2 * r) * (0.80 + 0.55 * pulse)
|
|
# only the fraction of each vein proportional to rage is drawn, so they visibly
|
|
# creep further into the frame the angrier you get
|
|
var reveal := clampf(0.25 + r * 0.85, 0.0, 1.0)
|
|
for path in _paths:
|
|
var pts: PackedVector2Array = path
|
|
var n := int(ceil(pts.size() * reveal))
|
|
if n < 2:
|
|
continue
|
|
var scaled := PackedVector2Array()
|
|
for i in n:
|
|
scaled.append(Vector2(pts[i].x * size.x, pts[i].y * size.y))
|
|
_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
|