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>
This commit is contained in:
Monster Robot Party 2026-07-31 17:34:34 +10:00
parent d17e2314aa
commit dd905d908d
15 changed files with 1296 additions and 16 deletions

164
LANES/LANE7-cubicle-hell.md Normal file
View File

@ -0,0 +1,164 @@
# LANE 7 — CUBICLE HELL
*The game stopped being a smash sandbox and became a game about a man having a bad day.*
## The pitch
You are at work. The work is designed to provoke you. A meter fills. When it fills you
**snap**, and for half a minute you physically cannot go back to your desk until you've
broken enough things. Then you sit back down and try to earn the rest of your shift.
Falling Down, but the payslip is the scoreboard.
## The score IS your payslip
```
NET = WAGES (tasks completed) DAMAGES (everything you broke)
```
The load-bearing detail: **rage drains a flat amount per object destroyed, but each
object bills at its own value.** During a meltdown the optimal play is therefore to smash
a lot of cheap rubbish very fast — cartons, paperwork, monitors — rather than put the
sledgehammer through a filing cabinet. You don't choose whether you lose your temper. You
choose how expensive losing your temper is.
`FURY` (objects broken while actually melting down) is tracked separately and is
deliberately **not** money, so "I ended the shift owing them $2,400" stays a brag.
---
## BUILT (this lane, shipped)
| Piece | File | What it does |
|---|---|---|
| Tilt meter | `game/scripts/Rage.gd` | WORKING → MELTDOWN → COOLDOWN. Provocations fill it, clean work drains it slightly, ambient office drains your patience passively. During a meltdown the clock **stalls if you stop destroying** — you MUST break things. |
| The blood vessel | `game/scripts/RageOverlay.gd` | Red vignette that closes in with rage and jumps inward on every heartbeat; procedural **veins** that creep further into frame the angrier you get; a synthesized lub-dub whose BPM goes 62 → 172. Zero assets: shader built in code, veins are `draw_polyline`, heartbeat is a generated WAV. |
| The job | `game/scripts/Tasks.gd` | Stations you walk to and are **locked in place at** (you're at your desk — that's the joke). Two tasks so far, below. |
| The shift | `game/scripts/CubicleHell.gd` | 5-minute shift, wages vs damages, meltdown count, end-of-shift payslip with a verdict line. |
### The two tasks that exist
- **SPREADSHEET** — enter a figure in a named cell. Partway through, the selection
**silently drifts one cell** — no sound, no animation. Commit without noticing and the
figure lands in the wrong cell, you eat a big provoke, and you start again. This one
task is the whole thesis of the game in about forty lines.
- **PHOTOCOPIER** — run a copy job. It jams. Clearing it needs the **right tray**, and
the trays are labelled `1, 2, 2A, 3, 3B` because that's what head office decided.
---
## TO BUILD — more ways to be provoked
Each is a `Tasks.Kind` + a `panel_state()` case. The framework takes them without
changes; the design constraint is that each must have **one specific, identifiable way of
wasting your time**, not just "it's slow".
| Task | The provocation |
|---|---|
| **Staple removal** | A stack with N staples. Click each. Too slow and the page tears — redo the sheet. The remover slips and takes a chunk out of the page. |
| **Guillotine / paper slicer** | Align the sheet to a line, pull the handle. Misalign and the cut is crooked → redo. The alignment guide is 2mm off and always has been. |
| **The phone** | Rings mid-task. You must answer it. It's hold music. Not answering keeps it ringing and ringing raises rage faster than answering does. |
| **Printer driver** | The printer is "offline". Toggling it takes four clicks in a dialog that moves its buttons. |
| **Reply-all** | An email thread with 40 recipients. You must reply to the sender only. The default is Reply All. |
| **Toner** | Replace the cartridge. It's the wrong model number by one digit. |
| **Timesheet** | Enter hours. It won't accept decimals. It also won't accept fractions. |
### Passive ambient provocations (no interaction, just attrition)
Someone microwaves fish. The air-con drips. A colleague's ringtone. The fluorescent tube
above your desk flickers at a frequency chosen to be maximally irritating. Each is a
small periodic `rage.provoke()` — cheap to add, and they're what makes the room feel hostile
rather than neutral.
---
## TO BUILD — modes
1. **CUBICLE HELL** *(built — the core)*. Shift timer, work, snap, wreck, repeat, payslip.
2. **TOTAL DESTRUCTION** — no job, no pay. Destroy 100% of the level. Scored on time.
3. **AGAINST THE CLOCK** — fixed timer, maximise damage value. The inverse of Cubicle Hell:
here expensive things are the *goal*.
4. **THE GAUNTLET** *(designed below — the most interesting one)*.
5. **VERSUS** — two players, one office. Explore later; the interesting version is
asymmetric (one working, one wrecking) rather than both wrecking.
### THE GAUNTLET (design)
> *"you need to destroy every file in a specific cabinet but which one, heaps cabinets,
> real subtle hints, time limit, and you have to actually pull each drawer out, empty
> files out, destroy them individually or in small lots unless you have a specific tool"*
- A room of **many** identical filing cabinets. Exactly one holds the target files.
- **Subtle hints only**: a memo on a desk names a client; a labelled folder on the copier;
a sticky note with half a reference number. No waypoint, no glow.
- Destruction is **granular**, not one-hit: pull a drawer → drawer slides out as its own
body → files inside become individual smashables → destroy singly or in small lots.
- **The perfect tool.** Each level has ONE tool that makes its gauntlet tractable — an
industrial shredder for files, a crowbar for the drawers, a lighter for a wastepaper
bin. It is somewhere in the level, and **the game does not tell you what it is.**
Working out "what would actually make this fast" IS the puzzle. With the wrong tool the
gauntlet is possible but you will not beat the clock.
- Needs: `Drawer.gd` (pull-out sub-body with its own contents), a `Container` concept on
Smashable, and a `Tool` flag on Weapon marking it as a level's key tool.
---
## TO BUILD — the other three workplaces
Reference floor plans provided: **Parks & Rec** (municipal, partitioned, outdoor patio),
**Silicon Valley hacker house** (domestic-open-plan, kitchen + pool), **The IT Crowd**
(basement, two rooms, glorious junk), plus the generic open-plan bullpen.
Each is a new script alongside `Office.gd` implementing the same interface:
```gdscript
build() # shell, fittings, lighting
configure_environment(env)
desk_spots() / chair_spots() / copier_spot()
spawn_point() / spawn_yaw()
```
They differ in **materials and hazards**, which is what stops them being reskins:
| Level | Signature material | Signature provocation |
|---|---|---|
| Municipal (Parks & Rec) | laminate, pinboard, civic beige | the public counter; forms in triplicate |
| Hacker house (Silicon Valley) | glass, whiteboard, drywall, a pool | standup meetings; someone's monitor wall |
| Basement IT (IT Crowd) | server steel, cardboard mountains, CRTs | "have you tried turning it off and on again" |
### On procedurally generating floor plans
The repo you found — `doratracyer/floor_plan`**won't help**. It's OpenSCAD that turns
hand-drawn SVG wall paths into 3D-printable STLs. It doesn't generate layouts, doesn't
parse plan images, and produces no room polygons or graph you could build a level from.
It's also GPL-3.0, which we would not want to link against.
**Better path, and we're most of the way there already:** `Office.gd` is a parametric
floor-plan builder that just happens to have its numbers hard-coded. Lift those into a
data spec —
```gdscript
{ rooms: [{name, x0, x1, z0, z1, walls, door_at}],
fittings: [{kind, pos, yaw}],
lights: {grid_x, grid_z},
spawn: {pos, yaw} }
```
— and a generic `Floorplan.gd` builds any of them. Then each workplace is a **data file**,
new levels are authorable in minutes, and a *generator* becomes possible later (rooms as a
BSP split of the footprint, doors on shared walls, fittings by room type) because the
consumer is already data-driven. Do the spec first, generation second.
---
## Asset needs (MODELBEAST / `tools/gen_office_props.py`)
Already generated procedurally: office chair, vending machine, microwave, potted plant.
Needed next, in rough priority order:
`filing cabinet with pullable drawers` (split mesh per drawer) · `desk phone` ·
`guillotine / paper slicer` · `stapler + staple remover` · `wall clock` · `whiteboard` ·
`server rack` · `pinboard` · `water cooler bottle (separate from base)` · `wastepaper bin` ·
`hole punch` · `shredder` (the gauntlet's perfect tool)
Rule of thumb established this lane: **if it's a box, build it in code**; if its silhouette
can't be faked by a box, generate it.

View File

@ -3,9 +3,17 @@
*Clock out. Smash everything.*
A comedic, non-violent, first-person workplace-destruction game from Monster Robot
Party. Level 01 is an open-plan office laid out after the Dunder Mifflin floor plan —
bullpen, conference room, break room, and a photocopier that fights back. Nobody gets
hurt but the furniture.
Party. Nobody gets hurt but the furniture.
**The game is CUBICLE HELL.** You work a shift doing deliberately infuriating office
tasks. A rage meter fills. When it fills you *snap*, and you physically cannot go back to
your desk until you've broken enough things. Then you sit back down and try to earn the
rest of your day. Your score is your payslip: **wages earned minus damages caused** — so
during a meltdown you want to wreck a lot of cheap rubbish rather than one expensive
filing cabinet. Falling Down, scored by payroll.
Level 01 is an open-plan office after the Dunder Mifflin floor plan. Design, built and
planned, lives in [`LANES/LANE7-cubicle-hell.md`](LANES/LANE7-cubicle-hell.md).
## Monorepo layout — one lane per directory

View File

@ -19,11 +19,32 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
| **mouse** | look (Esc releases the cursor, click recaptures) |
| **LMB** | swing the equipped weapon |
| **16 · wheel · Q** | pick a weapon · cycle · swap to previous |
| **E · drag ←→ · LMB · G** | grab a record · slide the disc out · throw it · drop |
| **H** | cycle HUD style (Arcade / Minimal / Work Order / Dev) |
| **M** | toggle game mode (FreePlay ⟷ Find-the-Misfiled-Disc) |
| **F** | report the disc in hand (Find-the-Misfiled-Disc) |
| **B · R** | rain 500 bodies (stress gate) · reset the store |
| **E** | work at the station you're standing at (locks you in place); otherwise grab a record |
| **drag ←→ · LMB · G** | with a record held: slide the disc out · throw it · drop |
| **digits · arrows · ENTER** | while working: type · move cell · commit |
| **B · R** | rain 500 bodies (stress gate) · reset the office |
## CUBICLE HELL
The mode the game is about. See [`../LANES/LANE7-cubicle-hell.md`](../LANES/LANE7-cubicle-hell.md)
for the full design.
`Rage.gd` is the tilt meter: work provokes it, clean work drains it slightly, and at 100%
you SNAP into a forced destruction window that **stalls if you stop destroying**.
`RageOverlay.gd` is the blood vessel — a vignette that closes in, procedural veins that
creep further into frame, and a heartbeat that goes 62 → 172 BPM. All of it generated in
code; no textures, no shader files, no audio assets.
`Tasks.gd` is the job. The spreadsheet's selected cell **silently drifts one cell**
partway through entry, so the figure lands in the wrong place and you start again. The
photocopier jams and wants the right tray out of `1, 2, 2A, 3, 3B`.
`CubicleHell.gd` scores it as a payslip: **wages damages**. Rage drains a flat amount
per object but each object bills at its own value, so the meltdown decision is *smash a
lot of cheap things fast* versus *one expensive swing*.
## The weapon-vs-material matrix

View File

@ -61,6 +61,25 @@ func _build_script() -> void:
{"dur": 2.5, "move_to": null, "look_at": null, "act": ""},
]
# ---- ACT 0b: CUBICLE HELL. Sit at a desk, do the spreadsheet, get wound up until
# ---- the meter snaps, then wreck the place until it drains.
_script += [
{"dur": 2.4, "move_to": Vector3(-4.6, 0, -0.55), "look_at": Vector3(-4.6, 1.0, -1.4), "act": ""},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "task"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "type"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "commit"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "rage:0.30"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "rage:0.30"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "rage:0.35"},
# SNAP — the vignette closes in, veins crawl, heartbeat goes. Now wreck things.
{"dur": 1.0, "move_to": Vector3(-4.6, 0, 0.3), "look_at": Vector3(-4.6, 0.9, -0.7), "act": "weapon:2"},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.6, "move_to": Vector3(-2.6, 0, 4.8), "look_at": Vector3(-2.3, 0.25, 5.2), "act": ""},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"},
]
# ---- ACT 1: a walk through the floor plan, so the rooms read.
_script += [
{"dur": 2.2, "move_to": Vector3(-8.4, 0, 2.0), "look_at": Vector3(-4.6, 1.1, 1.0), "act": ""},
@ -196,7 +215,27 @@ func _do(act: String) -> void:
if act.begins_with("weapon:"):
_player.select(int(act.substr(7)))
return
if act.begins_with("rage:"):
var r = _main.get("_rage")
if r != null:
r.provoke(float(act.substr(5)), "the demo is provoking you")
return
match act:
"task": # press E at the nearest station
var t = _main.get("_tasks")
if t != null:
t.try_start()
"type": # enter the requested figure correctly
var t2 = _main.get("_tasks")
if t2 != null and t2.is_working():
var st: Dictionary = t2.panel_state()
var want := String(st.get("instruction", "")).split(" ")[1]
for ch in want:
t2.handle_key(KEY_0 + int(ch))
"commit":
var t3 = _main.get("_tasks")
if t3 != null:
t3.handle_key(KEY_ENTER)
"swing":
_player._queued = true # same path a left-click takes
"hud":

118
game/scripts/CubicleHell.gd Normal file
View File

@ -0,0 +1,118 @@
extends Node
class_name CubicleHell
## CUBICLE HELL — the mode the game is actually about.
##
## You work a shift. The work is designed to provoke you. The meter fills. You snap, and
## for half a minute you are physically unable to go back to your desk until you've
## broken enough things. Then you sit back down and try to earn the rest of your day.
##
## THE SCORE IS YOUR PAYSLIP:
##
## NET = WAGES (tasks completed) DAMAGES (everything you broke)
##
## Rage drains a flat amount per object destroyed, but each object bills at its own
## value — so the play during a meltdown is to smash a lot of cheap rubbish quickly
## rather than put a sledgehammer through a filing cabinet. You don't get to choose
## whether you lose your temper. You choose how much it costs.
##
## FURY is a separate, non-monetary stat: things broken while actually melting down.
## It exists because "I ended the shift owing them money" should still be a brag.
##
## Godot 4.7 GDScript 2.0.
@export var shift_time: float = 300.0 ## a five-minute shift
var rage: Rage
var tasks: Tasks
var time_left: float = 0.0
var running: bool = false
var damages: int = 0 ## $ of property destroyed
var fury: int = 0 ## objects broken during a meltdown
var meltdowns: int = 0
var result: String = ""
signal shift_ended()
func setup(r: Rage, t: Tasks) -> void:
rage = r
tasks = t
if rage != null:
rage.snapped.connect(_on_snapped)
func begin() -> void:
running = true
time_left = shift_time
damages = 0
fury = 0
meltdowns = 0
result = ""
func _on_snapped() -> void:
meltdowns += 1
# you cannot keep filling in the spreadsheet while you're kicking the printer
if tasks != null:
tasks.eject()
## Main calls this for every object destroyed, with what it was worth.
func on_smashed(value: int) -> void:
if not running:
return
damages += value
if rage != null and rage.is_meltdown():
fury += 1
func wages() -> int:
return tasks.wages if tasks != null else 0
func net() -> int:
return wages() - damages
func _process(dt: float) -> void:
if not running:
return
time_left -= dt
if time_left <= 0.0:
time_left = 0.0
_end()
func _end() -> void:
running = false
var n := net()
if n >= 600:
result = "EMPLOYEE OF THE MONTH"
elif n >= 0:
result = "SATISFACTORY"
elif n >= -900:
result = "A LETTER WILL BE PLACED ON YOUR FILE"
else:
result = "SECURITY WILL ESCORT YOU OUT"
shift_ended.emit()
## The line the HUD shows while the shift runs.
func hud_line() -> String:
if not running:
return "SHIFT OVER — %s · net $%d (M cycles mode · R new shift)" % [result, net()]
var m := int(time_left) / 60
var s := int(time_left) % 60
return "CUBICLE HELL %d:%02d · pay $%d damages $%d · %d meltdown%s" % [
m, s, wages(), damages, meltdowns, "" if meltdowns == 1 else "s"]
## End-of-shift payslip, as lines.
func payslip() -> PackedStringArray:
var out := PackedStringArray()
out.append("MONSTER ROBOT PARTY · PAYSLIP")
out.append("")
out.append("%-26s %8d" % ["Tasks completed", tasks.tasks_done if tasks else 0])
out.append("%-26s %8d" % ["Tasks fumbled", tasks.tasks_fumbled if tasks else 0])
out.append("%-26s %8s" % ["Gross wages", "$%d" % wages()])
out.append("")
out.append("%-26s %8d" % ["Meltdowns", meltdowns])
out.append("%-26s %8d" % ["Objects destroyed in rage", fury])
out.append("%-26s %8s" % ["Deductions (damages)", "-$%d" % damages])
out.append("--------------------------------------")
out.append("%-26s %8s" % ["NET", "$%d" % net()])
out.append("")
out.append(result)
return out

View File

@ -0,0 +1 @@
uid://mholfli3giyk

View File

@ -20,6 +20,21 @@ class_name Hud
enum Style { ARCADE, MINIMAL, WORKORDER, DEV }
const STYLE_NAMES := ["ARCADE", "MINIMAL", "WORK ORDER", "DEV"]
# The task panel, the interaction prompt and the payslip sit OUTSIDE the style system:
# they're the CUBICLE HELL mode's own furniture and have to be visible whatever HUD
# skin is selected, so they get their own always-on layer.
var _work_root: Control
var _work_panel: ColorRect
var _work_title: Label
var _work_instr: Label
var _work_grid: Label
var _work_foot: Label
var _work_bar_bg: ColorRect
var _work_bar: ColorRect
var _prompt: Label
var _slip_root: Control
var _slip: Label
const PINK := Color(1.0, 0.36, 0.62)
const CREAM := Color(0.94, 0.92, 0.86)
const INK := Color(0.11, 0.10, 0.12)
@ -58,6 +73,8 @@ func _ready() -> void:
_build_workorder()
_build_dev()
_apply()
_build_work()
_build_payslip()
func cycle() -> void:
style = (style + 1) % STYLE_NAMES.size()
@ -80,8 +97,18 @@ func toast(msg: String) -> void:
_toast_left = 1.7
# ---------------------------------------------------------------- helpers
func _label(parent: Control, size: int, col: Color, shadow := true) -> Label:
## A monospace face for anything drawn as a text grid. Godot's fallback font is
## proportional, so column padding with %-9s lands wherever it likes — the spreadsheet
## and the work-order docket both need real fixed pitch to line up.
static func _mono() -> Font:
var f := SystemFont.new()
f.font_names = PackedStringArray(["Menlo", "SF Mono", "Courier New", "monospace"])
return f
func _label(parent: Control, size: int, col: Color, shadow := true, mono := false) -> Label:
var l := Label.new()
if mono:
l.add_theme_font_override("font", _mono())
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
if shadow:
@ -234,7 +261,7 @@ func _build_workorder() -> void:
_w_head.offset_top = 24
_w_head.offset_right = 366
_w_lines = _label(r, 14, INK, false)
_w_lines = _label(r, 14, INK, false, true)
_w_lines.offset_left = 30
_w_lines.offset_top = 62
_w_lines.offset_right = 362
@ -247,6 +274,181 @@ func _build_dev() -> void:
_d_text.offset_left = 16
_d_text.offset_top = 12
# ---------------------------------------------------------------- work panel
## The thing you stare at while doing your job. Deliberately drab: a beige box with a
## monospace grid, so that when the vignette goes red around it the contrast lands.
func _build_work() -> void:
_work_root = Control.new()
_work_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_work_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
_work_root.visible = false
add_child(_work_root)
_work_panel = _panel(_work_root, Color(0.90, 0.89, 0.84, 0.97))
_work_panel.anchor_left = 0.5
_work_panel.anchor_right = 0.5
_work_panel.anchor_top = 0.5
_work_panel.anchor_bottom = 0.5
_work_panel.offset_left = -300
_work_panel.offset_right = 300
_work_panel.offset_top = -180
_work_panel.offset_bottom = 150
var bar := _panel(_work_root, INK)
bar.anchor_left = 0.5
bar.anchor_right = 0.5
bar.anchor_top = 0.5
bar.anchor_bottom = 0.5
bar.offset_left = -300
bar.offset_right = 300
bar.offset_top = -180
bar.offset_bottom = -146
_work_title = _label(_work_root, 15, CREAM, false)
_work_title.anchor_left = 0.5
_work_title.anchor_right = 0.5
_work_title.anchor_top = 0.5
_work_title.offset_left = -288
_work_title.offset_right = 288
_work_title.offset_top = -173
_work_instr = _label(_work_root, 19, INK, false)
_work_instr.anchor_left = 0.5
_work_instr.anchor_right = 0.5
_work_instr.anchor_top = 0.5
_work_instr.offset_left = -288
_work_instr.offset_right = 288
_work_instr.offset_top = -134
_work_instr.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_work_grid = _label(_work_root, 16, INK, false, true)
_work_grid.anchor_left = 0.5
_work_grid.anchor_right = 0.5
_work_grid.anchor_top = 0.5
_work_grid.offset_left = -288
_work_grid.offset_right = 288
_work_grid.offset_top = -96
_work_bar_bg = _panel(_work_root, Color(0, 0, 0, 0.18))
_work_bar_bg.anchor_left = 0.5
_work_bar_bg.anchor_right = 0.5
_work_bar_bg.anchor_top = 0.5
_work_bar_bg.offset_left = -250
_work_bar_bg.offset_right = 250
_work_bar_bg.offset_top = -30
_work_bar_bg.offset_bottom = -8
_work_bar = _panel(_work_root, Color(0.16, 0.42, 0.22))
_work_bar.anchor_left = 0.5
_work_bar.anchor_right = 0.5
_work_bar.anchor_top = 0.5
_work_bar.offset_left = -250
_work_bar.offset_right = -250
_work_bar.offset_top = -30
_work_bar.offset_bottom = -8
_work_foot = _label(_work_root, 13, Color(0.35, 0.34, 0.32), false)
_work_foot.anchor_left = 0.5
_work_foot.anchor_right = 0.5
_work_foot.anchor_top = 0.5
_work_foot.offset_left = -288
_work_foot.offset_right = 288
_work_foot.offset_top = 122
_work_foot.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_prompt = _label(_work_root, 18, Color.WHITE)
_prompt.anchor_left = 0.5
_prompt.anchor_right = 0.5
_prompt.anchor_top = 0.5
_prompt.anchor_bottom = 0.5
_prompt.offset_left = -240
_prompt.offset_right = 240
_prompt.offset_top = 52
_prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
func _build_payslip() -> void:
_slip_root = Control.new()
_slip_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_slip_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
_slip_root.visible = false
add_child(_slip_root)
var p := _panel(_slip_root, CREAM)
p.anchor_left = 0.5
p.anchor_right = 0.5
p.anchor_top = 0.5
p.anchor_bottom = 0.5
p.offset_left = -230
p.offset_right = 230
p.offset_top = -200
p.offset_bottom = 200
_slip = _label(_slip_root, 15, INK, false, true)
_slip.anchor_left = 0.5
_slip.anchor_right = 0.5
_slip.anchor_top = 0.5
_slip.offset_left = -206
_slip.offset_right = 206
_slip.offset_top = -178
## Main pushes the task/prompt/payslip state here, separately from `feed`, because it
## has to render regardless of which HUD skin is selected.
func feed_work(task: Dictionary, prompt_text: String, payslip: PackedStringArray) -> void:
if _work_root == null:
return
var busy := not task.is_empty()
var slip := payslip.size() > 0
_slip_root.visible = slip
if slip:
_slip.text = "\n".join(payslip)
_work_root.visible = (busy or prompt_text != "") and not slip
_work_panel.visible = busy
_work_title.visible = busy
_work_instr.visible = busy
_work_grid.visible = busy
_work_foot.visible = busy
_prompt.visible = (not busy) and prompt_text != ""
_prompt.text = prompt_text
var show_bar := busy and task.has("progress")
_work_bar_bg.visible = show_bar
_work_bar.visible = show_bar
if not busy:
return
_work_title.text = String(task.get("title", ""))
_work_instr.text = String(task.get("instruction", ""))
_work_foot.text = String(task.get("footer", ""))
if show_bar:
var f := clampf(float(task["progress"]), 0.0, 1.0)
_work_bar.offset_right = -250.0 + 500.0 * f
if String(task.get("kind", "")) == "spreadsheet":
_work_grid.text = _spreadsheet_grid(task)
elif bool(task.get("jam", false)):
var trays: Array = task.get("trays", [])
var line := " ".join(PackedStringArray(trays.map(func(t): return "[%s]" % t)))
_work_grid.text = "\n TRAYS: " + line
else:
_work_grid.text = ""
## The spreadsheet, drawn as text. The selected cell is bracketed — which is the ONLY
## indication that it has quietly moved, and that is entirely the point.
func _spreadsheet_grid(task: Dictionary) -> String:
var cols: Array = task.get("cols", [])
var rows: int = int(task.get("rows", 0))
var sel: Vector2i = task.get("sel", Vector2i.ZERO)
var typed: String = String(task.get("typed", ""))
var out := PackedStringArray()
var head := " "
for c in cols:
head += "%-9s" % String(c)
out.append(head)
for r in range(rows):
var line := "%-5s" % str(r + 1)
for c in range(cols.size()):
if Vector2i(c, r) == sel:
line += "%-9s" % ("[" + (typed if typed != "" else "_") + "]")
else:
line += "%-9s" % "."
out.append(line)
return "\n".join(out)
# ---------------------------------------------------------------- per-frame
func _process(dt: float) -> void:
if _data.is_empty():
@ -266,7 +468,16 @@ func _g(k: String, dflt = 0):
return _data.get(k, dflt)
func _tick_arcade() -> void:
_a_score.text = "%s" % _comma(int(_g("score")))
# in CUBICLE HELL the headline number is your payslip, not an arcade score —
# net pay is the thing you're actually playing against
var state := String(_g("rage_state", ""))
if state != "":
var net := int(_g("net"))
_a_score.text = ("$%s" % _comma(net)) if net >= 0 else ("-$%s" % _comma(-net))
_a_score.add_theme_color_override("font_color",
Color(1, 0.42, 0.38) if net < 0 else Color.WHITE)
else:
_a_score.text = "%s" % _comma(int(_g("score")))
var combo := int(_g("combo"))
if combo > 1:
_a_combo.text = "COMBO x%d" % combo
@ -280,7 +491,11 @@ func _tick_arcade() -> void:
_a_weapon.text = "%d · %s" % [int(_g("slot")) + 1, str(_g("weapon", ""))]
_a_weapon_sub.text = str(_g("weapon_blurb", ""))
_a_mode.text = str(_g("mode_line", ""))
var mode := str(_g("mode_line", ""))
var status := str(_g("rage_status", ""))
if status != "":
mode += "\n" + status
_a_mode.text = mode
_a_toast.modulate.a = clampf(_toast_left / 0.5, 0.0, 1.0)
func _tick_minimal() -> void:

View File

@ -31,6 +31,10 @@ var _player: Player # kept so GameMode can reach player.grab
var _records: Array[Record] = [] # the live records, handed to GameMode each round
var _game_mode: GameMode
var _office: Office # the level shell: walls, fittings, lighting
var _rage: Rage # the tilt meter — see Rage.gd
var _rage_hud: RageOverlay
var _tasks: Tasks
var _shift: CubicleHell
func _ready() -> void:
randomize()
@ -41,7 +45,25 @@ func _ready() -> void:
_populate()
_spawn_guard = SPAWN_GUARD_TIME
# rules layer: starts in FreePlay; keys 1/2 switch, F reports in FindMisfiled
# --- CUBICLE HELL: the tilt meter, the job, and the payslip that scores both ---
_rage = Rage.new()
add_child(_rage)
_rage_hud = RageOverlay.new()
_rage_hud.setup(_rage)
add_child(_rage_hud)
_tasks = Tasks.new()
add_child(_tasks)
_tasks.setup(_player, _rage)
_register_stations()
_player.tasks = _tasks
_shift = CubicleHell.new()
add_child(_shift)
_shift.setup(_rage, _tasks)
_shift.begin()
# rules layer: the record modes still exist behind M
_game_mode = GameMode.new()
add_child(_game_mode)
_game_mode.setup(_player.grab)
@ -49,6 +71,16 @@ func _ready() -> void:
_setup_hud()
## Every desk is a spreadsheet you have to fill in; the copier is the copier.
func _register_stations() -> void:
var i := 1
for t in _office.desk_spots():
_tasks.register(Tasks.Kind.SPREADSHEET, (t as Transform3D).origin,
"work at desk %d" % i)
i += 1
_tasks.register(Tasks.Kind.COPIER, _office.copier_spot().origin,
"use the photocopier")
# ---------------------------------------------------------------- world / camera
func _setup_world() -> void:
# The level shell: floor, walls, ceiling, windows, fittings and ALL the lighting.
@ -387,6 +419,13 @@ func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
_score += int(POINTS.get(kind, 15)) * maxi(_juice.combo(), 1)
else:
_score += int(POINTS.get(kind, 15))
# Rage drains a FLAT amount per object; the payslip bills each object at its own
# value. That gap is the whole decision during a meltdown — break a lot of cheap
# things fast, or take one expensive swing and pay for it.
if _rage != null:
_rage.on_smashed()
if _shift != null:
_shift.on_smashed(int(POINTS.get(kind, 15)))
if _game_mode != null:
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
@ -488,6 +527,11 @@ func _process(_dt: float) -> void:
var bodies := get_tree().get_nodes_in_group("smashable").size() \
+ get_tree().get_nodes_in_group("debris").size()
var w: Weapon = _player.weapon() if _player != null else null
if _tasks != null:
var slip := PackedStringArray()
if _shift != null and not _shift.running:
slip = _shift.payslip()
_hud.feed_work(_tasks.panel_state(), _tasks.prompt(), slip)
_hud.feed({
"fps": Engine.get_frames_per_second(),
"bodies": bodies,
@ -500,6 +544,13 @@ func _process(_dt: float) -> void:
"weapon_blurb": w.blurb if w != null else "",
"weapon_stats": ("pow %.1f cd %.2fs reach %.1fm" % [w.power, w.cooldown, w.reach]) if w != null else "",
"slot": _player.slot() if _player != null else 0,
"mode_line": _game_mode.hud_line() if _game_mode != null else "",
"mode_line": _shift.hud_line() if _shift != null else (
_game_mode.hud_line() if _game_mode != null else ""),
"keys": KEYS_LINE,
"rage": _rage.value if _rage != null else 0.0,
"rage_state": _rage.state_name() if _rage != null else "",
"rage_status": _rage.status_line() if _rage != null else "",
"pay": _shift.wages() if _shift != null else 0,
"damages": _shift.damages if _shift != null else 0,
"net": _shift.net() if _shift != null else 0,
})

View File

@ -29,6 +29,9 @@ const SWING_DROP := 0.32
var camera: Camera3D # Main reads this for the crosshair raycast
var grab: GrabController # the record grab/extract/throw hands
var viewmodel: ViewModel # the arms + weapon rig
## Set by Main. While a task is open the player is LOCKED at the station: no walking, no
## looking, and keys go to the task instead. You're at your desk — that's the joke.
var tasks: Tasks = null
var _head: Node3D
var _pitch := 0.0
@ -140,6 +143,13 @@ func _build_crosshair() -> void:
# ---------------------------------------------------------------- input
func _unhandled_input(event: InputEvent) -> void:
# a task swallows the keyboard first, so digits and arrows edit the spreadsheet
# instead of switching weapons and walking away from the desk
if tasks != null and tasks.is_working():
if event is InputEventKey and event.pressed and not event.echo:
tasks.handle_key((event as InputEventKey).keycode)
return
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
var mm := event as InputEventMouseMotion
_look_delta += mm.relative * 0.01
@ -177,8 +187,12 @@ func _unhandled_input(event: InputEvent) -> void:
var k := (event as InputEventKey).keycode
if k == KEY_ESCAPE:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif k == KEY_E and grab != null:
grab.try_grab() # grab the record under the crosshair
elif k == KEY_E:
# a station in reach wins over the record ritual
if tasks != null and tasks.try_start():
pass
elif grab != null:
grab.try_grab() # grab the record under the crosshair
elif k == KEY_G and grab != null:
grab.drop() # drop / trash whatever's in hand
elif k == KEY_Q:
@ -226,9 +240,12 @@ func _physics_process(delta: float) -> void:
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
velocity.y = jump_velocity
var input_dir := Vector2(
float(Input.is_key_pressed(KEY_D)) - float(Input.is_key_pressed(KEY_A)),
float(Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_W)))
var seated := tasks != null and tasks.is_working()
var input_dir := Vector2.ZERO
if not seated:
input_dir = Vector2(
float(Input.is_key_pressed(KEY_D)) - float(Input.is_key_pressed(KEY_A)),
float(Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_W)))
var wish := transform.basis * Vector3(input_dir.x, 0.0, input_dir.y)
wish.y = 0.0
if wish.length() > 1.0:

161
game/scripts/Rage.gd Normal file
View File

@ -0,0 +1,161 @@
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 ""

1
game/scripts/Rage.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://cieqkhganxwim

185
game/scripts/RageOverlay.gd Normal file
View File

@ -0,0 +1,185 @@
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

View File

@ -0,0 +1 @@
uid://nu18glf3rknt

297
game/scripts/Tasks.gd Normal file
View File

@ -0,0 +1,297 @@
extends Node
class_name Tasks
## The job. Deliberately, precisely annoying.
##
## Stations sit in the world (a desk, the photocopier). You walk up, press E, and get
## locked in place doing something mundane — because you ARE at your desk, and being
## unable to walk away is part of it. Every station has a designed way of wasting your
## time, and every wasted second is fuel for Rage.
##
## SPREADSHEET — enter a figure in a named cell. The selection silently jumps to a
## neighbouring cell partway through. Commit without noticing and the
## figure lands in the wrong cell and you start again. This one is the
## whole thesis of the game in about forty lines.
## COPIER ...... run a copy job. It jams. Clearing it needs the RIGHT tray, named in
## tiny text, and the trays are not numbered in a sensible order.
##
## Completing work pays WAGES and soothes the meter a little — never quite enough.
##
## Godot 4.7 GDScript 2.0.
enum Kind { SPREADSHEET, COPIER }
enum Phase { IDLE, SPREADSHEET, COPIER_RUN, COPIER_JAM, DONE }
const REACH := 2.1 ## metres you must be within to work at a station
const COLS := ["A", "B", "C", "D", "E", "F"]
const ROWS := 5
## Trays are labelled in the order someone at head office thought was obvious.
const TRAYS := ["1", "2", "2A", "3", "3B"]
@export var wage_per_task: int = 120
@export var soothe_per_task: float = 0.12
var rage: Rage
var _player: Node3D
var _stations: Array = [] # {kind, pos, label}
var _near: Dictionary = {} # the station currently in reach, or empty
var phase: int = Phase.IDLE
var wages: int = 0
var tasks_done: int = 0
var tasks_fumbled: int = 0
# --- spreadsheet state
var _target_cell := Vector2i.ZERO # where the figure is SUPPOSED to go
var _sel := Vector2i.ZERO # where the cursor actually is
var _want_value := ""
var _typed := ""
var _jump_at := 0.0 # seconds into the task the selection will drift
var _jumped := false
var _t := 0.0
# --- copier state
var _pages_left := 0
var _job_pages := 0
var _copy_t := 0.0
var _jam_tray := ""
var _jam_in := 0.0
signal task_completed()
signal task_fumbled(reason: String)
func setup(player: Node3D, r: Rage) -> void:
_player = player
rage = r
func register(kind: int, pos: Vector3, label: String) -> void:
_stations.append({"kind": kind, "pos": pos, "label": label})
func is_working() -> bool:
return phase != Phase.IDLE and phase != Phase.DONE
## What the HUD should show as an interaction prompt, or "".
func prompt() -> String:
if is_working():
return ""
if _near.is_empty():
return ""
return "E — %s" % String(_near["label"])
func nearest_label() -> String:
return String(_near["label"]) if not _near.is_empty() else ""
# ---------------------------------------------------------------- proximity
func _process(dt: float) -> void:
_t += dt
if not is_working():
_update_near()
return
match phase:
Phase.SPREADSHEET:
_tick_spreadsheet(dt)
Phase.COPIER_RUN:
_tick_copier(dt)
Phase.COPIER_JAM:
pass # waits on input
# working cleanly is mildly calming, which is what makes the drift so infuriating
if rage != null and phase != Phase.COPIER_JAM:
rage.soothe(rage.focus_soothe * dt)
func _update_near() -> void:
_near = {}
if _player == null:
return
var best := REACH * REACH
for s in _stations:
var d: float = (_player.global_position - (s["pos"] as Vector3)).length_squared()
if d < best:
best = d
_near = s
# ---------------------------------------------------------------- entry / exit
## Player pressed E. Returns true if it started (so the caller can skip other uses of E).
func try_start() -> bool:
if is_working() or _near.is_empty():
return false
match int(_near["kind"]):
Kind.SPREADSHEET:
_begin_spreadsheet()
Kind.COPIER:
_begin_copier()
return true
func abandon() -> void:
if not is_working():
return
phase = Phase.IDLE
tasks_fumbled += 1
task_fumbled.emit("walked away")
if rage != null:
rage.provoke(0.05, "you'll have to start that again")
## You snapped. You are no longer at your desk — the chair is somewhere behind you.
## Unlike abandon() this costs nothing: you didn't choose to walk away from the job.
func eject() -> void:
if not is_working():
return
phase = Phase.IDLE
func _finish() -> void:
phase = Phase.IDLE
tasks_done += 1
wages += wage_per_task
task_completed.emit()
if rage != null:
rage.soothe(soothe_per_task)
func _fumble(reason: String, amount: float) -> void:
tasks_fumbled += 1
task_fumbled.emit(reason)
if rage != null:
rage.provoke(amount, reason)
# ---------------------------------------------------------------- spreadsheet
func _begin_spreadsheet() -> void:
phase = Phase.SPREADSHEET
_target_cell = Vector2i(randi() % COLS.size(), randi() % ROWS)
_sel = _target_cell
_want_value = str(randi_range(1000, 9999))
_typed = ""
_jumped = false
# it drifts once, early enough that you've started typing and are looking at the keys
_jump_at = _t + randf_range(1.6, 3.4)
func _tick_spreadsheet(_dt: float) -> void:
if _jumped or _t < _jump_at:
return
_jumped = true
# nudge the selection one cell in a random direction. No sound, no animation.
var dirs := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
var d: Vector2i = dirs[randi() % dirs.size()]
var moved := Vector2i(clampi(_sel.x + d.x, 0, COLS.size() - 1),
clampi(_sel.y + d.y, 0, ROWS - 1))
if moved == _sel: # clamped against an edge; go the other way
moved = Vector2i(clampi(_sel.x - d.x, 0, COLS.size() - 1),
clampi(_sel.y - d.y, 0, ROWS - 1))
_sel = moved
func _key_spreadsheet(k: int) -> void:
if k >= KEY_0 and k <= KEY_9:
if _typed.length() < 6:
_typed += str(k - KEY_0)
return
match k:
KEY_BACKSPACE:
_typed = _typed.substr(0, maxi(_typed.length() - 1, 0))
KEY_LEFT:
_sel.x = clampi(_sel.x - 1, 0, COLS.size() - 1)
KEY_RIGHT:
_sel.x = clampi(_sel.x + 1, 0, COLS.size() - 1)
KEY_UP:
_sel.y = clampi(_sel.y - 1, 0, ROWS - 1)
KEY_DOWN:
_sel.y = clampi(_sel.y + 1, 0, ROWS - 1)
KEY_ENTER, KEY_KP_ENTER:
_commit_cell()
func _commit_cell() -> void:
if _typed != _want_value:
_fumble("#VALUE! — that isn't the figure", 0.09)
_typed = ""
return
if _sel != _target_cell:
# the drift got you. The figure is now in the wrong cell and you have to redo it.
_fumble("it went in %s%d. it was meant to go in %s%d." % [
COLS[_sel.x], _sel.y + 1, COLS[_target_cell.x], _target_cell.y + 1], 0.17)
_typed = ""
_sel = _target_cell
_jumped = false
_jump_at = _t + randf_range(2.0, 4.0)
return
_finish()
# ---------------------------------------------------------------- copier
func _begin_copier() -> void:
phase = Phase.COPIER_RUN
_job_pages = randi_range(8, 18)
_pages_left = _job_pages
_copy_t = 0.0
_jam_in = randf_range(1.2, 2.8)
func _tick_copier(dt: float) -> void:
_copy_t += dt
if _copy_t >= 0.42:
_copy_t = 0.0
_pages_left -= 1
if _pages_left <= 0:
_finish()
return
_jam_in -= dt
if _jam_in <= 0.0:
phase = Phase.COPIER_JAM
_jam_tray = TRAYS[randi() % TRAYS.size()]
if rage != null:
rage.provoke(0.06, "PAPER JAM")
func _key_copier(k: int) -> void:
if phase != Phase.COPIER_JAM:
return
var pressed := ""
if k >= KEY_1 and k <= KEY_5:
pressed = TRAYS[k - KEY_1]
if pressed == "":
return
if pressed == _jam_tray:
phase = Phase.COPIER_RUN
_jam_in = randf_range(2.0, 5.5)
else:
_fumble("that's not tray %s" % _jam_tray, 0.08)
# ---------------------------------------------------------------- input
## Player forwards keys here while a task is open. Returns true if consumed.
func handle_key(k: int) -> bool:
if not is_working():
return false
if k == KEY_ESCAPE or k == KEY_E:
abandon()
return true
match phase:
Phase.SPREADSHEET:
_key_spreadsheet(k)
Phase.COPIER_RUN, Phase.COPIER_JAM:
_key_copier(k)
return true
# ---------------------------------------------------------------- HUD feed
## Everything the task panel needs to draw itself, as plain data.
func panel_state() -> Dictionary:
match phase:
Phase.SPREADSHEET:
return {
"kind": "spreadsheet",
"title": "QUARTERLY RETURNS.XLS",
"instruction": "Enter %s in cell %s%d" % [
_want_value, COLS[_target_cell.x], _target_cell.y + 1],
"cols": COLS, "rows": ROWS,
"sel": _sel, "typed": _typed,
"footer": "digits · arrows move · ENTER commit · E leave",
}
Phase.COPIER_RUN:
return {
"kind": "copier", "title": "MULTIFUNCTION DEVICE",
"instruction": "Copying… %d of %d" % [
_job_pages - _pages_left + 1, _job_pages],
"progress": 1.0 - float(_pages_left) / float(maxi(_job_pages, 1)),
"footer": "E leave",
}
Phase.COPIER_JAM:
return {
"kind": "copier", "title": "MULTIFUNCTION DEVICE",
"instruction": "PAPER JAM — CLEAR TRAY %s" % _jam_tray,
"jam": true, "trays": TRAYS,
"progress": 1.0 - float(_pages_left) / float(maxi(_job_pages, 1)),
"footer": "press the tray number · E leave",
}
return {}

View File

@ -0,0 +1 @@
uid://b85liu7c0dj0o