destroyulator/game/scripts/Rage.gd
Monster Robot Party dd905d908d LANE7: CUBICLE HELL — the tilt loop, the job, and the payslip
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>
2026-07-31 17:34:34 +10:00

162 lines
5.3 KiB
GDScript

extends Node
class_name Rage
## The tilt meter — the thing the whole game now hangs off.
##
## You are at work. Work is provoking. The meter fills. When it fills all the way you
## SNAP, and for the next half minute you are not allowed to be a functioning employee:
## you have to wreck things until it drains. Then you go back to your desk and try again.
##
## THE SCORING TENSION (this is the design, not an implementation detail):
## rage drains a FIXED amount per thing destroyed, but the damages bill is that thing's
## value. So during a meltdown the optimal play is to smash a lot of cheap rubbish very
## fast — cartons, paperwork, monitors — rather than putting the sledgehammer through a
## filing cabinet. You are forced to lose your temper; you get to choose how expensive
## losing your temper is.
##
## States:
## WORKING ..... doing tasks. Provocations raise the meter, clean work lowers it.
## MELTDOWN .... forced destruction window. Destroying drains. Standing still does not
## — the timer stalls if you aren't actually breaking anything, because
## "you MUST destroy shit when you are falling down".
## COOLDOWN .... a couple of seconds of shaking hands before work resumes.
##
## Godot 4.7 GDScript 2.0.
enum State { WORKING, MELTDOWN, COOLDOWN }
## Seconds a meltdown lasts if you never stop swinging.
@export var meltdown_time: float = 32.0
## Seconds of trembling before you're expected to be productive again.
@export var cooldown_time: float = 2.5
## Fraction of the meter one destroyed object drains, regardless of what it was worth.
@export var drain_per_smash: float = 0.075
## Meter gained per second just from existing in an office.
@export var ambient_provoke: float = 0.008
## Meter lost per second while a task is going cleanly.
@export var focus_soothe: float = 0.010
## If you go this long during a meltdown without breaking anything, the clock stalls.
@export var idle_stall_after: float = 2.0
var state: int = State.WORKING
var value: float = 0.0 ## 0..1
var meltdown_left: float = 0.0
var last_reason: String = ""
var _phase: float = 0.0 # heartbeat phase, 0..1, drives the throb
var _since_smash: float = 0.0
var _cooldown_left: float = 0.0
var _stalled: bool = false
signal snapped() ## the meter hit 1.0 — meltdown begins
signal calmed() ## back to work
signal provoked(amount: float, reason: String)
# ---------------------------------------------------------------- inputs
## Something at work just happened to you.
func provoke(amount: float, reason: String) -> void:
if state != State.WORKING:
return
value = clampf(value + amount, 0.0, 1.0)
last_reason = reason
provoked.emit(amount, reason)
if value >= 1.0:
_snap()
## A task went right. Small relief — never enough to keep up on its own.
func soothe(amount: float) -> void:
if state != State.WORKING:
return
value = clampf(value - amount, 0.0, 1.0)
## Main calls this for every object destroyed, in any state.
func on_smashed() -> void:
_since_smash = 0.0
_stalled = false
if state == State.MELTDOWN:
value = clampf(value - drain_per_smash, 0.0, 1.0)
if value <= 0.0:
_calm()
func _snap() -> void:
state = State.MELTDOWN
value = 1.0
meltdown_left = meltdown_time
_since_smash = 0.0
_stalled = false
snapped.emit()
func _calm() -> void:
state = State.COOLDOWN
_cooldown_left = cooldown_time
value = clampf(value, 0.0, 0.25)
meltdown_left = 0.0
calmed.emit()
# ---------------------------------------------------------------- per-frame
func _process(dt: float) -> void:
# heartbeat runs in every state; it's the player's read on how close they are
_phase = fposmod(_phase + dt * bpm() / 60.0, 1.0)
match state:
State.WORKING:
value = clampf(value + ambient_provoke * dt, 0.0, 1.0)
if value >= 1.0:
_snap()
State.MELTDOWN:
_since_smash += dt
# the clock only runs while you're actually wrecking something
_stalled = _since_smash > idle_stall_after
if not _stalled:
meltdown_left -= dt
if meltdown_left <= 0.0:
_calm()
State.COOLDOWN:
_cooldown_left -= dt
value = maxf(0.0, value - dt * 0.10)
if _cooldown_left <= 0.0:
state = State.WORKING
# ---------------------------------------------------------------- reads
## Resting 62 bpm, 168 at the edge — the audible version of the meter.
func bpm() -> float:
if state == State.MELTDOWN:
return 172.0
return lerpf(62.0, 168.0, value * value)
## 0..1 within the current heartbeat. Two thumps per cycle (lub-dub).
func pulse() -> float:
var p := _phase
var lub := exp(-pow((p - 0.02) / 0.055, 2.0))
var dub := exp(-pow((p - 0.20) / 0.070, 2.0)) * 0.6
return clampf(lub + dub, 0.0, 1.0)
func is_meltdown() -> bool:
return state == State.MELTDOWN
func is_stalled() -> bool:
return state == State.MELTDOWN and _stalled
func state_name() -> String:
match state:
State.MELTDOWN: return "MELTDOWN"
State.COOLDOWN: return "COOLDOWN"
return "WORKING"
## Short line for the HUD, in the voice of the game.
func status_line() -> String:
match state:
State.MELTDOWN:
if _stalled:
return "BREAK SOMETHING"
return "%02ds" % int(ceil(meltdown_left))
State.COOLDOWN:
return "…breathe…"
if value > 0.86:
return "you are about to do something"
if value > 0.62:
return "unclench your jaw"
if value > 0.34:
return "it's fine. it's fine."
return ""