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>
298 lines
8.9 KiB
GDScript
298 lines
8.9 KiB
GDScript
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 {}
|