destroyulator/game/scripts/Tasks.gd
Monster Robot Party e5f4e8c5e4 LANE7: the Gauntlet, four modes, two more provocations, and ambient attrition
THE GAUNTLET (Gauntlet.gd + Cabinet.gd)
Five identical cabinets, each with its alphabetical range on a Label3D. The brief names
a CLIENT, never a cabinet, so working out that HENDERSON lives in F-J is the puzzle —
no waypoint, nothing glows. Destruction is granular on purpose: pull a drawer, it
slides out on a spring and goes live, and the folders inside become individual
smashables. Doing that by hand inside the clock isn't really possible.

The perfect tool: every site hides a SHREDDER that eats a whole open drawer at once,
and the game never says so. Noticing it and working out what it's for IS the level.
Getting it wrong isn't fatal, just slow — the right shape for a secret, since it
rewards the observant without hard-failing everyone else.

Each site nominates its own records area in its spec. Building the bank 3.4 m in front
of wherever the player happened to be standing put it through walls.

MODES (Modes.gd, M cycles)
  CUBICLE HELL       the main loop
  THE GAUNTLET       above
  TOTAL DESTRUCTION  no job, no pay, clear the site, scored on time
  AGAINST THE CLOCK  fixed timer, maximise damage VALUE — the exact inverse of Cubicle
                     Hell, where expensive things become the goal rather than the
                     mistake. Same level, opposite instinct.
Entering a mode arms only what that mode needs; Cubicle Hell and the Gauntlet are
mutually exclusive, because you cannot be doing your job and shredding the Henderson
file at the same time.

TWO MORE PROVOCATIONS (Tasks.gd)
  STAPLES     SPACE lifts a staple, then it must be pulled the way it's bent — and it's
              bent differently each time. Dither and the sheet tears, bundle reprints.
  GUILLOTINE  align to a mark and cut. The guide has a PERMANENT offset and nothing on
              screen admits it. Once you work out the error it's easy, which is the
              joke: you have to learn to distrust the equipment.
Desks now alternate spreadsheet/staples so "a desk" isn't always the same minigame.

AMBIENCE (Ambience.gd) — the attrition you can't play around. The tube above your desk
flickers, and actually flickers the real lights. Fish in the microwave. A phone ringing
four desks away. Reply-all, forty-one recipients, "thanks!". Small periodic ticks with
no interaction at all; it's what makes the room hostile rather than merely beige.

Fixes: the payslip was rendering over the Gauntlet (it belongs to Cubicle Hell, and only
once the shift has actually ended); gauntlet drawer reach was 2.0 m when the true
distance from standing-at-a-cabinet to a bottom drawer is over 2 m.

dev/probe_gauntlet.gd drives a whole run headlessly — mode switch, labels, open every
drawer, shred, win — because that catches the failure that matters: a run you can't
finish because the files never become destroyable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:12:20 +10:00

432 lines
13 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.
## STAPLES ..... de-staple a bundle. Each staple wants SPACE then the right arrow key.
## Take too long on one and the sheet tears and you redo the bundle.
## GUILLOTINE .. align the sheet to a mark and cut. The guide reads 2 mm off, always
## has, and everyone who works here knows and nobody has fixed it.
##
## Completing work pays WAGES and soothes the meter a little — never quite enough.
##
## Godot 4.7 GDScript 2.0.
enum Kind { SPREADSHEET, COPIER, STAPLES, GUILLOTINE }
enum Phase { IDLE, SPREADSHEET, COPIER_RUN, COPIER_JAM, STAPLES, GUILLOTINE, 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
# --- staples state
var _staples_left := 0
var _staples_total := 0
var _staple_dir := 0 # which arrow the current staple wants
var _staple_armed := false # SPACE lifts it; then the arrow pulls it out
var _staple_clock := 0.0
# --- guillotine state
var _cut_target := 0.0 # where the sheet should sit, 0..1
var _cut_at := 0.5 # where it actually is
var _cut_bias := 0.0 # the guide's permanent, unacknowledged error
var _cuts_left := 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})
## Swapping sites invalidates every station position.
func clear_stations() -> void:
_stations.clear()
_near = {}
phase = Phase.IDLE
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()
Kind.STAPLES:
_begin_staples()
Kind.GUILLOTINE:
_begin_guillotine()
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)
# ---------------------------------------------------------------- staples
## A bundle to de-staple. SPACE lifts a staple, then it has to be pulled the way it's
## bent — and it's bent a different way each time. Dither and the sheet tears.
const STAPLE_ARROWS := [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN]
const STAPLE_NAMES := ["<", ">", "^", "v"]
const STAPLE_PATIENCE := 2.6
func _begin_staples() -> void:
phase = Phase.STAPLES
_staples_total = randi_range(6, 11)
_staples_left = _staples_total
_next_staple()
func _next_staple() -> void:
_staple_dir = randi() % 4
_staple_armed = false
_staple_clock = STAPLE_PATIENCE
func _tick_staples(dt: float) -> void:
_staple_clock -= dt
if _staple_clock > 0.0:
return
# too slow: the sheet tears and the whole bundle is reprinted
_fumble("the sheet tore. reprint the bundle.", 0.14)
_staples_left = _staples_total
_next_staple()
func _key_staples(k: int) -> void:
if k == KEY_SPACE:
_staple_armed = true
return
var idx := STAPLE_ARROWS.find(k)
if idx < 0:
return
if not _staple_armed:
_fumble("lift it first", 0.04)
return
if idx != _staple_dir:
_fumble("bent the staple", 0.07)
_next_staple()
return
_staples_left -= 1
if _staples_left <= 0:
_finish()
return
_next_staple()
# ---------------------------------------------------------------- guillotine
## Slide the sheet to a mark and cut. `_cut_bias` is the guide's permanent error: the
## number printed on the rule is not where the blade lands, and never has been. Once you
## work out the offset the task is easy, which is the joke — you have to learn to
## distrust the equipment.
const CUT_TOLERANCE := 0.045
func _begin_guillotine() -> void:
phase = Phase.GUILLOTINE
_cuts_left = randi_range(3, 5)
_cut_bias = randf_range(0.05, 0.09) * (1.0 if randf() < 0.5 else -1.0)
_next_cut()
func _next_cut() -> void:
_cut_target = randf_range(0.22, 0.78)
_cut_at = 0.5
func _key_guillotine(k: int) -> void:
match k:
KEY_LEFT:
_cut_at = clampf(_cut_at - 0.02, 0.0, 1.0)
KEY_RIGHT:
_cut_at = clampf(_cut_at + 0.02, 0.0, 1.0)
KEY_SPACE, KEY_ENTER, KEY_KP_ENTER:
var landed := _cut_at + _cut_bias # where the blade ACTUALLY comes down
if absf(landed - _cut_target) <= CUT_TOLERANCE:
_cuts_left -= 1
if _cuts_left <= 0:
_finish()
else:
_next_cut()
else:
_fumble("crooked. that's the whole ream.", 0.11)
_next_cut()
# ---------------------------------------------------------------- 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)
Phase.STAPLES:
_key_staples(k)
Phase.GUILLOTINE:
_key_guillotine(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",
}
Phase.STAPLES:
var done := _staples_total - _staples_left
return {
"kind": "staples", "title": "THE HENDERSON BUNDLE",
"instruction": ("pull it %s" % STAPLE_NAMES[_staple_dir]) if _staple_armed
else "staple %d of %d" % [done + 1, _staples_total],
"armed": _staple_armed,
"dir": STAPLE_NAMES[_staple_dir],
"progress": float(done) / float(maxi(_staples_total, 1)),
"urgency": clampf(1.0 - _staple_clock / STAPLE_PATIENCE, 0.0, 1.0),
"footer": "SPACE lift · arrow pull · E leave",
}
Phase.GUILLOTINE:
return {
"kind": "guillotine", "title": "ROTATRIM A3",
"instruction": "cut at %d mm · %d left" % [
int(round(_cut_target * 300.0)), _cuts_left],
"target": _cut_target, "at": _cut_at,
"progress": _cut_at,
"footer": "arrows slide · SPACE cut · E leave",
}
return {}