destroyulator/game/scripts/Tasks.gd
Monster Robot Party 4bcd7b49d9 LANE7: four workplaces, built from data
Office.gd is gone. Floorplan.gd consumes a spec Dictionary — bounds, palette, walls
with doors and glazing styles, window runs with mullions and blinds, ceiling style,
light grid and style, slabs, static props, desk clusters, chairs, level-specific
smashables and spawn — and Levels.gd holds four of them.

Making this data rather than four subclasses means a site is authorable in minutes,
levels can be diffed, and a real generator can later emit the same structure (a BSP
split of the footprint -> rooms -> doors on shared walls -> fittings by room type).
That was the actual answer to "can we use this git to generate plans": the repo John
found is OpenSCAD SVG->STL for 3D printing, with no generation, no plan parsing, no
room polygons and GPL-3.0, so it can't help — but Office.gd was already most of a
parametric plan builder, and lifting its numbers out is the real path.

The four sites are deliberately not reskins; they differ in the three things a player
actually reads — palette, light, and what the walls are made of:

  SCRANTON      magnolia, grey carpet, drop ceiling, fluorescent troffers, daylight
                down one glazed wall. The baseline.
  PAWNEE        civic beige and blue-grey, low partitions everywhere, a public
                counter, pinboards. Municipal and over-partitioned.
  THE INCUBATOR timber floor, white walls, 3 m ceiling, PENDANT lights, glass wall
                onto a pool. Nobody has an office; they work at a dining table.
  SUB-LEVEL 4   concrete, NO windows at all, exposed services, bare strip lights, a
                wall of server racks. The light is green and everything is junk.

L cycles sites in-game; _load_level tears down the shell, rebuilds, re-registers task
stations and re-arms the shift.

tools/gen_level_props.py adds ten more procedural props. The filing cabinet is the
important one: it exports as a CARCASS plus a separate DRAWER, each with its own
floor-centre origin, so the Gauntlet can pull a drawer out as its own rigid body and
spill the files. Also file folder, desk phone, guillotine, shredder, server rack,
sofa, wastebin, stapler.

dev/probe_levels.gd builds every level and reports residual motion after a full
second. All four read 0.00 m/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:58:41 +10:00

304 lines
9.1 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})
## 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()
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 {}