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