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>
97 lines
3.3 KiB
GDScript
97 lines
3.3 KiB
GDScript
extends Node
|
|
class_name Ambience
|
|
|
|
## The provocations you can't interact with, which is exactly why they work.
|
|
##
|
|
## The task system provokes you when you make a mistake, so a good player can hold the
|
|
## meter down. Ambience can't be played around: the tube above your desk flickers, a
|
|
## phone rings four desks away and nobody picks it up, someone puts fish in the
|
|
## microwave. Each is a small periodic rage tick and a line of text, and together they're
|
|
## what makes the room feel hostile rather than merely beige.
|
|
##
|
|
## Each event announces itself in the HUD, because half the irritation of an office is
|
|
## knowing exactly what is doing it to you and being unable to stop it.
|
|
##
|
|
## Godot 4.7 GDScript 2.0.
|
|
|
|
## {gap: seconds between occurrences, rage: meter added, line: what the HUD says}
|
|
const EVENTS := [
|
|
{"gap": 17.0, "rage": 0.030, "line": "the tube above your desk flickers again"},
|
|
{"gap": 23.0, "rage": 0.035, "line": "somebody's phone has been ringing for a while"},
|
|
{"gap": 41.0, "rage": 0.055, "line": "someone has put fish in the microwave"},
|
|
{"gap": 29.0, "rage": 0.028, "line": "the air-con drips onto the carpet · tok · tok"},
|
|
{"gap": 34.0, "rage": 0.040, "line": "\"just following up on my last email\""},
|
|
{"gap": 47.0, "rage": 0.045, "line": "reply-all. forty-one recipients. \"thanks!\""},
|
|
{"gap": 26.0, "rage": 0.032, "line": "the printer says it is offline. it is not offline."},
|
|
{"gap": 53.0, "rage": 0.050, "line": "your chair has started doing the thing again"},
|
|
{"gap": 38.0, "rage": 0.038, "line": "somewhere, a laminator"},
|
|
]
|
|
|
|
var rage: Rage
|
|
var hud: Hud
|
|
var running := true
|
|
|
|
var _next: Array[float] = []
|
|
var _flicker: Array[OmniLight3D] = []
|
|
var _flicker_t := 0.0
|
|
var _flicker_until := 0.0
|
|
|
|
func setup(r: Rage, h: Hud) -> void:
|
|
rage = r
|
|
hud = h
|
|
_arm()
|
|
|
|
func _arm() -> void:
|
|
_next.clear()
|
|
for e in EVENTS:
|
|
# stagger the first occurrence so they don't all land together on the first minute
|
|
_next.append(randf_range(0.35, 1.0) * float(e["gap"]))
|
|
|
|
## Called by Main after a level build, so the flicker picks lights in the NEW room.
|
|
func adopt_lights(from: Node) -> void:
|
|
_flicker.clear()
|
|
var all: Array = []
|
|
_collect(from, all)
|
|
all.shuffle()
|
|
for i in mini(3, all.size()):
|
|
_flicker.append(all[i])
|
|
|
|
func _collect(n: Node, acc: Array) -> void:
|
|
if n is OmniLight3D:
|
|
acc.append(n)
|
|
for c in n.get_children():
|
|
_collect(c, acc)
|
|
|
|
func _process(dt: float) -> void:
|
|
if rage == null or not running:
|
|
return
|
|
# during a meltdown the office has already lost its power over you
|
|
if rage.state != Rage.State.WORKING:
|
|
return
|
|
for i in _next.size():
|
|
_next[i] -= dt
|
|
if _next[i] > 0.0:
|
|
continue
|
|
var e: Dictionary = EVENTS[i]
|
|
_next[i] = float(e["gap"]) * randf_range(0.75, 1.35)
|
|
rage.provoke(float(e["rage"]), String(e["line"]))
|
|
if hud != null:
|
|
hud.toast(String(e["line"]))
|
|
if i == 0:
|
|
_flicker_until = 2.4 # the tube event actually flickers the tube
|
|
_tick_flicker(dt)
|
|
|
|
## A failing fluorescent doesn't blink evenly — it stutters, catches, and stutters again.
|
|
func _tick_flicker(dt: float) -> void:
|
|
if _flicker_until <= 0.0:
|
|
return
|
|
_flicker_until -= dt
|
|
_flicker_t += dt
|
|
var on := true
|
|
if _flicker_until > 0.0:
|
|
var p := fposmod(_flicker_t * 13.0, 1.0)
|
|
on = p > 0.42 or fposmod(_flicker_t, 0.7) < 0.25
|
|
for l in _flicker:
|
|
if is_instance_valid(l):
|
|
l.light_energy = (2.0 if on else 0.25) if _flicker_until > 0.0 else 2.0
|