destroyulator/game/scripts/Tasks.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

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 {}