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>
This commit is contained in:
Monster Robot Party 2026-07-31 18:12:20 +10:00
parent 4bcd7b49d9
commit e5f4e8c5e4
20 changed files with 1069 additions and 52 deletions

View File

@ -47,7 +47,12 @@ deliberately **not** money, so "I ended the shift owing them $2,400" stays a bra
---
## TO BUILD — more ways to be provoked
## BUILT — more ways to be provoked
STAPLES and GUILLOTINE shipped (see `Tasks.gd`), plus `Ambience.gd` for the passive
attrition. The table below is what's still open.
### Still to build
Each is a `Tasks.Kind` + a `panel_state()` case. The framework takes them without
changes; the design constraint is that each must have **one specific, identifiable way of
@ -71,7 +76,13 @@ rather than neutral.
---
## TO BUILD — modes
## BUILT — modes
All four ship in `Modes.gd`; M cycles. The Gauntlet is `Gauntlet.gd` + `Cabinet.gd`
(drawers slide out as their own bodies, folders inside go live, and the shredder eats a
whole open drawer). Versus is still the open one.
### The original design notes
1. **CUBICLE HELL** *(built — the core)*. Shift timer, work, snap, wreck, repeat, payslip.
2. **TOTAL DESTRUCTION** — no job, no pay. Destroy 100% of the level. Scored on time.
@ -102,7 +113,12 @@ rather than neutral.
---
## TO BUILD — the other three workplaces
## BUILT — the other three workplaces
Shipped as `Levels.gd` specs consumed by `Floorplan.gd`. The notes below are the design
they were built from.
### Design notes
Reference floor plans provided: **Parks & Rec** (municipal, partitioned, outdoor patio),
**Silicon Valley hacker house** (domestic-open-plan, kitchen + pool), **The IT Crowd**

View File

@ -12,8 +12,11 @@ rest of your day. Your score is your payslip: **wages earned minus damages cause
during a meltdown you want to wreck a lot of cheap rubbish rather than one expensive
filing cabinet. Falling Down, scored by payroll.
Level 01 is an open-plan office after the Dunder Mifflin floor plan. Design, built and
planned, lives in [`LANES/LANE7-cubicle-hell.md`](LANES/LANE7-cubicle-hell.md).
**Four sites** (Scranton branch, Pawnee city hall, a hacker house, a windowless IT
basement) and **four modes** (Cubicle Hell, the Gauntlet, Total Destruction, Against the
Clock). Sites are data, not code — see `game/scripts/Levels.gd`.
Design, built and planned, lives in [`LANES/LANE7-cubicle-hell.md`](LANES/LANE7-cubicle-hell.md).
## Monorepo layout — one lane per directory

View File

@ -20,13 +20,49 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
| **LMB** | swing the equipped weapon |
| **16 · wheel · Q** | pick a weapon · cycle · swap to previous |
| **H** | cycle HUD style (Arcade / Minimal / Work Order / Dev) |
| **M** | toggle game mode (FreePlay ⟷ Find-the-Misfiled-Disc) |
| **M** | cycle game mode (Cubicle Hell / Gauntlet / Total Destruction / Against the Clock) |
| **L** | cycle site (Scranton / Pawnee / The Incubator / Sub-level 4) |
| **G** | restart a Gauntlet run |
| **F** | report the disc in hand (Find-the-Misfiled-Disc) |
| **E** | work at the station you're standing at (locks you in place); otherwise grab a record |
| **drag ←→ · LMB · G** | with a record held: slide the disc out · throw it · drop |
| **digits · arrows · ENTER** | while working: type · move cell · commit |
| **B · R** | rain 500 bodies (stress gate) · reset the office |
## The four sites
`scripts/Floorplan.gd` builds a workplace from a **data spec**; `scripts/Levels.gd` holds
four of them. A site is a Dictionary — bounds, palette, walls with doors and glazing,
window runs, ceiling style, light grid and style, slabs, props, desk clusters, chairs,
smashables, gauntlet records-area and spawn — so a new one is authorable in minutes and a
generator can later emit the same structure.
| | palette / light | what makes it different |
|---|---|---|
| **SCRANTON BRANCH** | magnolia, grey carpet, troffers | the baseline office |
| **PAWNEE CITY HALL** | civic beige, blue-grey | a public counter, pinboards, over-partitioned |
| **THE INCUBATOR** | timber, white, 3 m, pendants | nobody has a desk; they work at a dining table |
| **SUB-LEVEL 4** | concrete, green strip lights | **no windows**, exposed services, a wall of servers |
`dev/probe_levels.gd` builds every one and reports residual motion after a full second.
All four must read `0.00 m/s`.
## The four modes
`scripts/Modes.gd`, cycled with M.
- **CUBICLE HELL** — the main one, below.
- **THE GAUNTLET**`scripts/Gauntlet.gd` + `scripts/Cabinet.gd`. Five identical
cabinets, each labelled with an alphabetical range. The brief names a **client**, never
a cabinet, so working out that HENDERSON lives in FJ is the puzzle — nothing glows.
Destruction is granular: pull a drawer, it slides out and goes live, and the folders
inside become individual smashables. Doing that by hand inside the clock isn't really
possible — **every site hides a shredder that eats a whole open drawer at once, and the
game never says so.** Noticing it is the level.
- **TOTAL DESTRUCTION** — no job, no pay. Clear the whole site, scored on time.
- **AGAINST THE CLOCK** — fixed timer, maximise damage *value*. The exact inverse of
Cubicle Hell: here the expensive things are the goal rather than the mistake.
## CUBICLE HELL
The mode the game is about. See [`../LANES/LANE7-cubicle-hell.md`](../LANES/LANE7-cubicle-hell.md)
@ -38,9 +74,20 @@ you SNAP into a forced destruction window that **stalls if you stop destroying**
creep further into frame, and a heartbeat that goes 62 → 172 BPM. All of it generated in
code; no textures, no shader files, no audio assets.
`Tasks.gd` is the job. The spreadsheet's selected cell **silently drifts one cell**
partway through entry, so the figure lands in the wrong place and you start again. The
photocopier jams and wants the right tray out of `1, 2, 2A, 3, 3B`.
`Tasks.gd` is the job — four stations, each with one designed way of wasting your time:
- **SPREADSHEET** — the selected cell **silently drifts one cell** partway through entry.
No sound, no animation. Commit without noticing and the figure is in the wrong cell.
- **PHOTOCOPIER** — jams, and wants the right tray out of `1, 2, 2A, 3, 3B`.
- **STAPLES** — SPACE lifts a staple, then it must be pulled the way it's bent. Dither
and the sheet tears and the bundle reprints.
- **GUILLOTINE** — align to a mark and cut. **The guide has a permanent offset** and
nothing on screen admits it; you have to learn to distrust the equipment.
`Ambience.gd` is the attrition you can't play around: the tube above your desk flickers
(and actually flickers, on the real lights), someone puts fish in the microwave, a phone
rings four desks away, reply-all to forty-one recipients. No interaction, just a periodic
tick on the meter — it's what makes the room hostile rather than merely beige.
`CubicleHell.gd` scores it as a payslip: **wages damages**. Rage drains a flat amount
per object but each object bills at its own value, so the meltdown decision is *smash a

View File

@ -48,32 +48,15 @@ func _find_player(n: Node) -> Player:
return null
func _build_script() -> void:
# A lap of all four sites. Each gets a settle beat (nothing must move on its own)
# and two vantage points, so the palette, lighting and layout can all be read.
_script = []
var tours := {
"scranton": [[Vector3(-8.6, 0, 6.4), Vector3(-4.0, 1.1, 0.0)],
[Vector3(-2.6, 0, 3.4), Vector3(-0.4, 1.0, -1.0)],
[Vector3(1.6, 0, -2.0), Vector3(8.0, 1.3, -6.5)]],
"pawnee": [[Vector3(-8.0, 0, 6.6), Vector3(-2.0, 1.1, 1.0)],
[Vector3(-1.0, 0, 2.6), Vector3(-2.6, 1.2, -5.2)],
[Vector3(4.0, 0, 0.6), Vector3(6.5, 1.2, -3.2)]],
"house": [[Vector3(-1.0, 0, 7.0), Vector3(-4.4, 1.2, 0.6)],
[Vector3(0.5, 0, 2.0), Vector3(6.0, 1.3, 6.0)],
[Vector3(-6.0, 0, -2.0), Vector3(4.0, 1.4, -6.0)]],
"basement": [[Vector3(1.0, 0, -5.2), Vector3(-8.0, 1.3, -1.0)],
[Vector3(-4.0, 0, -1.0), Vector3(-10.6, 1.4, -1.0)],
[Vector3(4.0, 0, 1.0), Vector3(9.0, 1.3, 5.4)]],
}
for id in ["scranton", "pawnee", "house", "basement"]:
var shots: Array = tours[id]
_script.append({"dur": 0.4, "move_to": shots[0][0], "look_at": shots[0][1],
"act": "level:" + id})
_script.append({"dur": 2.2, "move_to": null, "look_at": null, "act": ""})
for i in range(1, shots.size()):
_script.append({"dur": 2.4, "move_to": shots[i][0], "look_at": shots[i][1],
"act": ""})
_script.append({"dur": 1.2, "move_to": null, "look_at": null, "act": ""})
# THE GAUNTLET. Switching mode now teleports the player to the records area, so the
# tour just needs to stand back, read the labels, open a drawer and shred it.
_script = [
{"dur": 0.5, "move_to": null, "look_at": null, "act": "mode:1"},
{"dur": 3.0, "move_to": null, "look_at": null, "act": ""},
{"dur": 2.4, "move_to": null, "look_at": null, "act": "interact"},
{"dur": 2.4, "move_to": null, "look_at": null, "act": "interact"},
{"dur": 3.0, "move_to": null, "look_at": null, "act": ""},
]
func _motion(verbose := false) -> float:
var total := 0.0
@ -167,6 +150,16 @@ func _do(act: String) -> void:
if _main.has_method("_load_level"):
_main._load_level(act.substr(6))
return
if act.begins_with("mode:"):
var md = _main.get("_modes")
if md != null:
md.set_mode(int(act.substr(5)))
return
if act == "interact":
var gg = _main.get("_gauntlet")
if gg != null:
gg.interact()
return
if act.begins_with("rage:"):
var r = _main.get("_rage")
if r != null:

View File

@ -0,0 +1,54 @@
extends SceneTree
## Drive a whole Gauntlet run headlessly: switch mode, confirm the cabinet bank exists
## and is labelled, open every drawer of the target, and check the shredder actually
## empties them. Faster than eyeballing video, and it catches the failure that matters —
## a run you cannot finish because the files never become destroyable.
##
## Godot --headless --path game --script dev/probe_gauntlet.gd
func _initialize() -> void:
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
get_root().add_child(main)
for i in 4:
await physics_frame
var modes = main.get("_modes")
var g = main.get("_gauntlet")
modes.set_mode(Modes.M.GAUNTLET)
for i in 4:
await physics_frame
print("mode : ", modes.name_of())
print("client : ", g.client, " -> drawer ", g.target_range)
print("files on them : ", g.remaining(), " / ", g.total())
var cabs = g._cabs
print("cabinets : ", cabs.size(), " labels: ",
", ".join(PackedStringArray(cabs.map(func(c): return c.label_range))))
print("shredder found: ", g._shredder != null)
# open every drawer of the target cabinet and let them slide
var target = g._target
for i in Cabinet.DRAWERS:
target.open(i)
for i in 90:
await physics_frame
print("after opening : files still intact = ", g.remaining())
# stand at the shredder and feed it
var player = main.get("_player")
player.global_position = g._shredder.global_position
await physics_frame
print("at shredder : ", g.at_shredder())
g.interact()
for i in 10:
await physics_frame
print("after shred : files still intact = ", g.remaining())
print("running : ", g.running, " result: ", g.result)
# and the other modes at least arm
for m in [Modes.M.TOTAL, Modes.M.CLOCK, Modes.M.CUBICLE_HELL]:
modes.set_mode(m)
await physics_frame
print("mode %-20s -> %s" % [modes.name_of(), modes.hud_line().substr(0, 60)])
quit()

View File

@ -0,0 +1 @@
uid://bmfkvtbi00p83

View File

@ -0,0 +1 @@
uid://ba2ceindfyby6

96
game/scripts/Ambience.gd Normal file
View File

@ -0,0 +1,96 @@
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

View File

@ -0,0 +1 @@
uid://ccbsqme5ek8al

197
game/scripts/Cabinet.gd Normal file
View File

@ -0,0 +1,197 @@
extends Node3D
class_name Cabinet
## A filing cabinet you actually have to open.
##
## Destruction here is deliberately GRANULAR. You can't one-shot a cabinet and have its
## contents evaporate: you pull a drawer, the drawer slides out and becomes its own rigid
## body, and the folders inside become individual smashables you deal with one at a time
## — unless you find the level's shredder, which eats a whole drawer at once.
##
## Every cabinet carries a visible alphabetical range on a Label3D. That label is the
## entire search mechanic: the Gauntlet tells you a client's NAME, never a cabinet, and
## working out that HENDERSON lives in FJ is the puzzle.
##
## Godot 4.7 GDScript 2.0.
const CARCASS := "res://assets/store/cabinet-carcass.glb"
const DRAWER := "res://assets/store/cabinet-drawer.glb"
const FOLDER := "res://assets/store/file-folder.glb"
const DRAWERS := 4
const DRAWER_Y := [0.10, 0.42, 0.74, 1.06] # sill heights up the carcass
const OPEN_BY := 0.44 # how far a drawer slides out
const FILES_PER_DRAWER := 4
var label_range := "AE"
var is_target := false
var drawers: Array[Smashable] = []
var _open: Array[bool] = []
var _slide: Array[float] = [] # 0 closed, 1 fully out
var _files_of: Array = [] # per drawer: Array[Smashable]
var _file_offsets: Array = [] # per drawer: Array[Vector3] in cabinet space
var _label: Label3D
signal drawer_opened(cab: Cabinet, index: int)
## `host` is the node smashables should live under (Main's World).
func setup(host: Node3D, pos: Vector3, yaw: float, range_text: String,
wire: Callable) -> void:
label_range = range_text
host.add_child(self)
position = pos
rotation.y = yaw
var carcass := Smashable.new()
carcass.name = "cabinet"
carcass.kind = "steel"
carcass.start_frozen = true # it holds the drawers up
carcass.mass = 60.0
_mount(carcass, CARCASS)
add_child(carcass)
carcass.transform = Transform3D.IDENTITY
wire.call(carcass)
for i in DRAWERS:
var d := Smashable.new()
d.name = "drawer%d" % i
d.kind = "steel"
d.start_frozen = true # frozen until it's been pulled fully out
d.mass = 12.0
_mount(d, DRAWER)
host.add_child(d)
d.global_transform = global_transform * Transform3D(Basis.IDENTITY,
Vector3(0.0, DRAWER_Y[i], 0.0))
wire.call(d)
drawers.append(d)
_open.append(false)
_slide.append(0.0)
carcass.supports.append(d)
var fs: Array[Smashable] = []
var offs: Array = []
for f in FILES_PER_DRAWER:
var file := Smashable.new()
file.name = "file"
file.kind = "paper"
file.start_frozen = true
file.mass = 0.4
_mount(file, FOLDER)
host.add_child(file)
var off := Vector3(0.0, DRAWER_Y[i] + 0.06 + f * 0.026, 0.0)
file.global_transform = global_transform * Transform3D(Basis.IDENTITY, off)
wire.call(file)
fs.append(file)
offs.append(off)
_files_of.append(fs)
_file_offsets.append(offs)
# the label IS the search mechanic
_label = Label3D.new()
_label.text = label_range
_label.font_size = 96
_label.pixel_size = 0.0012
_label.modulate = Color(0.10, 0.10, 0.12)
_label.outline_size = 0
_label.billboard = BaseMaterial3D.BILLBOARD_DISABLED
_label.no_depth_test = false
add_child(_label)
_label.position = Vector3(0.0, 1.20, -0.315)
_label.rotation.y = PI
func _mount(body: Smashable, path: String) -> void:
if not ResourceLoader.exists(path):
return
var packed := load(path) as PackedScene
if packed == null:
return
var vis: Node3D = packed.instantiate()
body.add_child(vis)
var ab := Floorplan.mesh_aabb(vis)
if ab.size == Vector3.ZERO:
ab = AABB(Vector3(-0.2, 0.0, -0.2), Vector3(0.4, 0.4, 0.4))
var col := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = ab.size
col.shape = sh
col.position = ab.get_center()
body.add_child(col)
body.set_meta("spawn_pos", Vector3.ZERO)
# ---------------------------------------------------------------- interaction
## Index of the drawer nearest `from` that's still closed, or -1.
func nearest_closed(from: Vector3, reach: float) -> int:
var best := reach * reach
var idx := -1
for i in DRAWERS:
if _open[i] or not is_instance_valid(drawers[i]):
continue
var d: float = (drawers[i].global_position - from).length_squared()
if d < best:
best = d
idx = i
return idx
func open(i: int) -> void:
if i < 0 or i >= DRAWERS or _open[i]:
return
_open[i] = true
drawer_opened.emit(self, i)
func is_open(i: int) -> bool:
return i >= 0 and i < DRAWERS and _open[i]
## Files still intact across the whole cabinet.
func files_remaining() -> int:
var n := 0
for fs in _files_of:
for f in fs:
if is_instance_valid(f):
n += 1
return n
func files_total() -> int:
return DRAWERS * FILES_PER_DRAWER
## Every file in one drawer, for the shredder to eat in a single go.
func drawer_files(i: int) -> Array:
if i < 0 or i >= _files_of.size():
return []
return _files_of[i]
# ---------------------------------------------------------------- per-frame
## Drawers slide on a spring toward open, and their folders ride with them until the
## drawer is fully out — at which point the folders go live and can be destroyed.
func _physics_process(dt: float) -> void:
for i in DRAWERS:
var want := 1.0 if _open[i] else 0.0
if is_equal_approx(_slide[i], want):
continue
_slide[i] = move_toward(_slide[i], want, dt * 1.8)
var out := -OPEN_BY * _slide[i] # -Z is the cabinet front
var d: Smashable = drawers[i]
if is_instance_valid(d) and d.freeze:
d.global_transform = global_transform * Transform3D(Basis.IDENTITY,
Vector3(0.0, DRAWER_Y[i], out))
var fs: Array = _files_of[i]
var offs: Array = _file_offsets[i]
for j in fs.size():
var f: Smashable = fs[j]
if not is_instance_valid(f) or not f.freeze:
continue
var o: Vector3 = offs[j]
f.global_transform = global_transform * Transform3D(Basis.IDENTITY,
Vector3(o.x, o.y, o.z + out))
if _slide[i] >= 1.0:
_release(i)
## Fully open: the drawer and its folders become live physics, so they can be pulled
## out, tipped over, and destroyed piece by piece.
func _release(i: int) -> void:
var d: Smashable = drawers[i]
if is_instance_valid(d):
d.release()
for f in _files_of[i]:
if is_instance_valid(f):
(f as Smashable).release()

View File

@ -0,0 +1 @@
uid://s2llgmvr8gp6

187
game/scripts/Gauntlet.gd Normal file
View File

@ -0,0 +1,187 @@
extends Node
class_name Gauntlet
## THE GAUNTLET — destroy every file on one client, before the clock runs out.
##
## A wall of identical cabinets. The brief names a CLIENT, never a cabinet. Every cabinet
## carries an alphabetical range on its front, so working out that HENDERSON lives in FJ
## is the whole puzzle — there is no waypoint and nothing glows.
##
## Then it's granular on purpose: pull each drawer, wait for it to slide, and destroy the
## folders individually. Doing that with bare hands inside the time limit is not really
## possible.
##
## THE PERFECT TOOL. Every level hides one tool that makes its gauntlet tractable — here
## it's the SHREDDER, which eats a whole open drawer in one go. The game never tells you
## that. Noticing the shredder in the corner and realising what it's for IS the level.
## Getting it wrong isn't fatal, just slow, which is the right shape for a secret: it
## rewards the observant without punishing everyone else with a hard fail.
##
## Godot 4.7 GDScript 2.0.
const RANGES := ["AE", "FJ", "KO", "PT", "UZ"]
const CLIENTS := {
"AE": ["ABERNATHY", "CULVER", "DUNMORE", "BRATTON"],
"FJ": ["HENDERSON", "GRIEVES", "IRONSIDE", "JAKUB"],
"KO": ["KOWALSKI", "MERRIWEATHER", "NAKAMURA", "OSGOOD"],
"PT": ["PENHALIGON", "QUAYLE", "SOMMERS", "TRENT"],
"UZ": ["VANCE", "WHITTAKER", "YARDLEY", "ZIEGLER"],
}
## Generous: you stand ~2.2 m back to read the labels, your eye is 1.6 m up and the
## bottom drawer is at 0.10 m, so the true distance to a drawer is well over 2 m even
## when you're clearly standing at it.
const REACH := 2.9
const SHRED_REACH := 1.9
@export var time_limit: float = 180.0
var running := false
var time_left := 0.0
var result := ""
var client := ""
var target_range := ""
var _cabs: Array[Cabinet] = []
var _target: Cabinet = null
var _shredder: Node3D = null
var _player: Node3D
var _hud: Hud
var _found_shredder := false
signal finished()
func setup(player: Node3D, hud: Hud) -> void:
_player = player
_hud = hud
## Build the bank of cabinets. `wire` hooks each Smashable into Main's signal routing.
func begin(host: Node3D, origin: Vector3, yaw: float, wire: Callable,
shredder: Node3D) -> void:
for c in _cabs:
if is_instance_valid(c):
c.queue_free()
_cabs.clear()
_shredder = shredder
_found_shredder = false
var ranges := RANGES.duplicate()
var basis := Basis(Vector3.UP, yaw)
for i in ranges.size():
var cab := Cabinet.new()
var off := basis * Vector3((float(i) - 2.0) * 0.62, 0.0, 0.0)
cab.setup(host, origin + off, yaw, String(ranges[i]), wire)
_cabs.append(cab)
_target = _cabs[randi() % _cabs.size()]
_target.is_target = true
target_range = _target.label_range
var pool: Array = CLIENTS[target_range]
client = String(pool[randi() % pool.size()])
running = true
time_left = time_limit
result = ""
if _hud != null:
_hud.toast("DESTROY THE %s FILE — ALL OF IT" % client)
## Files left on the target client.
func remaining() -> int:
return _target.files_remaining() if is_instance_valid(_target) else 0
func total() -> int:
return _target.files_total() if is_instance_valid(_target) else 0
# ---------------------------------------------------------------- interaction
## Player pressed E. Opens the nearest closed drawer, or feeds an open drawer to the
## shredder if you're standing at it. Returns true if it did something.
func interact() -> bool:
if not running or _player == null:
return false
var here := _player.global_position
# at the shredder with a drawer already open? feed it the lot.
if _shredder != null and is_instance_valid(_shredder):
if here.distance_to(_shredder.global_position) < SHRED_REACH:
return _shred()
var best_cab: Cabinet = null
var best_i := -1
var best_d := INF
for c in _cabs:
if not is_instance_valid(c):
continue
var i := c.nearest_closed(here, REACH)
if i < 0:
continue
var d: float = c.drawers[i].global_position.distance_to(here)
if d < best_d:
best_d = d
best_cab = c
best_i = i
if best_cab == null:
return false
best_cab.open(best_i)
return true
## The shredder eats every folder in every OPEN drawer within reach of it. This is the
## payoff for working out what the shredder is for.
func _shred() -> bool:
var ate := 0
for c in _cabs:
if not is_instance_valid(c):
continue
for i in Cabinet.DRAWERS:
if not c.is_open(i):
continue
for f in c.drawer_files(i):
if is_instance_valid(f):
(f as Smashable).shatter(Vector3.UP * 0.5)
ate += 1
if ate == 0:
if _hud != null:
_hud.toast("nothing to shred — open a drawer and bring it over")
return false
if not _found_shredder:
_found_shredder = true
if _hud != null:
_hud.toast("…oh. THAT'S what that's for.")
return true
## True when the player is close enough to the shredder for E to mean "shred".
func at_shredder() -> bool:
if _shredder == null or not is_instance_valid(_shredder) or _player == null:
return false
return _player.global_position.distance_to(_shredder.global_position) < SHRED_REACH
# ---------------------------------------------------------------- clock
func _process(dt: float) -> void:
if not running:
return
if remaining() <= 0:
_end(true)
return
time_left -= dt
if time_left <= 0.0:
time_left = 0.0
_end(false)
func _end(won: bool) -> void:
running = false
if won:
result = "%s IS GONE — %ds to spare" % [client, int(time_left)]
else:
result = "TIME. %s still has %d files. it was the %s drawer." % [
client, remaining(), target_range]
if _hud != null:
_hud.toast(result)
finished.emit()
func hud_line() -> String:
if not running:
return "GAUNTLET — %s (G restart · M mode)" % result
var m := int(time_left) / 60
var s := int(time_left) % 60
return "GAUNTLET %d:%02d · destroy every %s file · %d/%d left%s" % [
m, s, client, remaining(), total(),
" · E: SHRED" if at_shredder() else ""]

View File

@ -0,0 +1 @@
uid://1pi0rnqwm81e

View File

@ -418,14 +418,71 @@ func feed_work(task: Dictionary, prompt_text: String, payslip: PackedStringArray
if show_bar:
var f := clampf(float(task["progress"]), 0.0, 1.0)
_work_bar.offset_right = -250.0 + 500.0 * f
if String(task.get("kind", "")) == "spreadsheet":
_work_grid.text = _spreadsheet_grid(task)
elif bool(task.get("jam", false)):
var trays: Array = task.get("trays", [])
var line := " ".join(PackedStringArray(trays.map(func(t): return "[%s]" % t)))
_work_grid.text = "\n TRAYS: " + line
match String(task.get("kind", "")):
"spreadsheet":
_work_grid.text = _spreadsheet_grid(task)
"staples":
_work_grid.text = _staples_art(task)
"guillotine":
_work_grid.text = _guillotine_art(task)
_:
if bool(task.get("jam", false)):
var trays: Array = task.get("trays", [])
var line := " ".join(PackedStringArray(
trays.map(func(t): return "[%s]" % t)))
_work_grid.text = "\n TRAYS: " + line
else:
_work_grid.text = ""
## The bundle, drawn as text. The arrow only appears once the staple is lifted, so you
## can't pre-empt it — you lift, read, and pull, under a clock.
func _staples_art(task: Dictionary) -> String:
var armed := bool(task.get("armed", false))
var d := String(task.get("dir", "?"))
var urg := float(task.get("urgency", 0.0))
var bar := ""
var n := int(round(urg * 22.0))
for i in 22:
bar += "#" if i < n else "."
var out := PackedStringArray()
out.append("")
out.append(" +--------------------------+")
out.append(" | |")
if armed:
out.append(" | << %s >> |" % d)
else:
_work_grid.text = ""
out.append(" | [ lift ] |")
out.append(" | |")
out.append(" +--------------------------+")
out.append("")
out.append(" tearing: " + bar)
return "\n".join(out)
## A rule, a mark, and the sheet. The blade does NOT land where the sheet sits — the
## guide has a permanent offset — and nothing on screen tells you that.
func _guillotine_art(task: Dictionary) -> String:
var target := float(task.get("target", 0.5))
var at := float(task.get("at", 0.5))
const W := 46
var rule := ""
for i in W:
rule += "|" if i % 5 == 0 else "-"
var marks := ""
var ti := int(round(target * (W - 1)))
for i in W:
marks += "v" if i == ti else " "
var sheet := ""
var ai := int(round(at * (W - 1)))
for i in W:
sheet += "#" if i == ai else ("=" if absi(i - ai) < 6 else " ")
var out := PackedStringArray()
out.append("")
out.append(" " + marks)
out.append(" " + rule)
out.append(" " + sheet)
out.append("")
out.append(" the guide has always been a little out")
return "\n".join(out)
## The spreadsheet, drawn as text. The selected cell is bracketed — which is the ONLY
## indication that it has quietly moved, and that is entirely the point.

View File

@ -144,8 +144,12 @@ static func scranton() -> Dictionary:
{"glb": "rack", "kind": "wood", "at": Vector2(9.4, -6.6), "frozen": true},
{"glb": "rack", "kind": "wood", "at": Vector2(9.4, -4.3), "frozen": true},
{"glb": "turntable", "kind": "wood", "at": Vector2(6.9, -5.9)},
{"glb": "guillotine", "kind": "steel", "at": Vector2(4.5, 1.2)},
{"glb": "stapler", "kind": "steel", "at": Vector2(-4.6, -2.9), "sit_on": 0.75},
],
"records": [Vector2(6.2, -6.6), Vector2(7.0, -6.9), Vector2(7.8, -6.6)],
"gauntlet": {"at": Vector3(0.0, 0.0, -3.9), "yaw": PI,
"stand": Vector3(0.0, 0.0, -1.7), "shredder": Vector3(2.7, 0.0, -2.4)},
"spawn": {"at": Vector3(-8.6, 0.0, 7.2), "yaw": deg_to_rad(196.0)},
}
@ -243,6 +247,8 @@ static func pawnee() -> Dictionary:
{"glb": "crt-monitor", "kind": "glass", "at": Vector2(-7.6, -5.2), "sit_on": 0.78},
],
"records": [],
"gauntlet": {"at": Vector3(7.2, 0.0, -5.8), "yaw": PI,
"stand": Vector3(7.2, 0.0, -3.9), "shredder": Vector3(5.2, 0.0, -6.2)},
"spawn": {"at": Vector3(-8.0, 0.0, 6.6), "yaw": deg_to_rad(178.0)},
}
@ -331,8 +337,11 @@ static func house() -> Dictionary:
{"glb": "filing-cabinet", "kind": "steel", "at": Vector2(-9.3, -1.6)},
{"glb": "turntable", "kind": "wood", "at": Vector2(-9.4, -6.6)},
{"glb": "desk-phone", "kind": "plastic", "at": Vector2(4.0, 5.6), "sit_on": 0.99},
{"glb": "guillotine", "kind": "steel", "at": Vector2(-9.4, -3.4)},
],
"records": [Vector2(-8.4, -7.0), Vector2(-7.6, -7.0)],
"gauntlet": {"at": Vector3(-7.2, 0.0, -7.2), "yaw": PI,
"stand": Vector3(-7.2, 0.0, -5.2), "shredder": Vector3(-5.0, 0.0, -6.8)},
"spawn": {"at": Vector3(-1.0, 0.0, 7.0), "yaw": deg_to_rad(186.0)},
}
@ -422,7 +431,10 @@ static func basement() -> Dictionary:
{"glb": "shredder", "kind": "plastic", "at": Vector2(-2.2, -2.0)},
{"glb": "desk-phone", "kind": "plastic", "at": Vector2(8.3, 5.4), "sit_on": 0.78},
{"glb": "turntable", "kind": "wood", "at": Vector2(-9.0, 4.4)},
{"glb": "guillotine", "kind": "steel", "at": Vector2(-6.0, -4.2)},
],
"records": [Vector2(-8.2, 5.0), Vector2(-7.5, 5.0)],
"gauntlet": {"at": Vector3(-3.4, 0.0, -3.4), "yaw": PI,
"stand": Vector3(-3.4, 0.0, -1.5), "shredder": Vector3(-2.2, 0.0, -2.0)},
"spawn": {"at": Vector3(1.0, 0.0, -5.2), "yaw": deg_to_rad(0.0)},
}

View File

@ -37,6 +37,9 @@ var _rage_hud: RageOverlay
var _tasks: Tasks
var _shift: CubicleHell
var _env_node: WorldEnvironment
var _ambience: Ambience
var _modes: Modes
var _gauntlet: Gauntlet
func _ready() -> void:
randomize()
@ -65,6 +68,16 @@ func _ready() -> void:
_shift.setup(_rage, _tasks)
_shift.begin()
# the provocations you can't play around: flickering tube, fish in the microwave
_ambience = Ambience.new()
add_child(_ambience)
_modes = Modes.new()
add_child(_modes)
_modes.changed.connect(_on_mode_changed)
_gauntlet = Gauntlet.new()
add_child(_gauntlet)
# rules layer: the record modes still exist behind M
_game_mode = GameMode.new()
add_child(_game_mode)
@ -72,17 +85,34 @@ func _ready() -> void:
_game_mode.begin_free_play(_records)
_setup_hud()
_ambience.setup(_rage, _hud)
_ambience.adopt_lights(_plan)
_gauntlet.setup(_player, _hud)
_player.gauntlet = _gauntlet
_modes.begin(get_tree().get_nodes_in_group("smashable").size())
## Every desk is a spreadsheet you have to fill in; the copier is the copier.
func _register_stations() -> void:
var i := 1
for t in _plan.desk_spots():
_tasks.register(Tasks.Kind.SPREADSHEET, (t as Transform3D).origin,
"work at desk %d" % i)
# alternating, so walking to "a desk" isn't always the same minigame
if i % 2 == 1:
_tasks.register(Tasks.Kind.SPREADSHEET, (t as Transform3D).origin,
"work at desk %d" % i)
else:
_tasks.register(Tasks.Kind.STAPLES, (t as Transform3D).origin,
"de-staple the bundle on desk %d" % i)
i += 1
if _plan.has_copier():
_tasks.register(Tasks.Kind.COPIER, _plan.copier_spot().origin,
"use the photocopier")
# a station wherever the site put a guillotine
for e in _plan.smashables():
var d: Dictionary = e
if String(d.get("glb", "")) == "guillotine":
var a: Vector2 = d["at"]
_tasks.register(Tasks.Kind.GUILLOTINE, Vector3(a.x, 0.9, a.y),
"trim the ream")
# ---------------------------------------------------------------- world / camera
func _setup_world() -> void:
@ -423,6 +453,8 @@ func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
_rage.on_smashed()
if _shift != null:
_shift.on_smashed(int(POINTS.get(kind, 15)))
if _modes != null:
_modes.on_smashed(int(POINTS.get(kind, 15)))
if _game_mode != null:
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
@ -455,9 +487,11 @@ func _unhandled_input(event: InputEvent) -> void:
KEY_R:
_reset()
KEY_M:
_switch_mode(GameMode.Mode.FIND_MISFILED
if _game_mode.mode == GameMode.Mode.FREE_PLAY
else GameMode.Mode.FREE_PLAY)
if _modes != null:
_modes.cycle()
KEY_G:
if _modes != null and _modes.mode == Modes.M.GAUNTLET:
_start_gauntlet()
KEY_H:
if _hud != null:
_hud.cycle()
@ -501,6 +535,8 @@ func _load_level(id: String) -> void:
_tasks.clear_stations()
_register_stations()
if _ambience != null:
_ambience.adopt_lights(_plan)
if _game_mode != null:
_game_mode.begin_free_play(_records)
if _shift != null:
@ -508,6 +544,50 @@ func _load_level(id: String) -> void:
if _hud != null:
_hud.toast("%s%s" % [_plan.level_name(), _plan.subtitle()])
## Switching mode re-arms whatever that mode needs. Cubicle Hell and the Gauntlet are
## mutually exclusive — you cannot be doing your job and shredding the Henderson file at
## the same time — so entering one shuts the other down.
func _on_mode_changed(m: int) -> void:
var hell := m == Modes.M.CUBICLE_HELL
_shift.running = hell
_ambience.running = hell
_tasks.clear_stations()
if hell:
_register_stations()
_shift.begin()
_gauntlet.running = false
if m == Modes.M.GAUNTLET:
_start_gauntlet()
_modes.begin(get_tree().get_nodes_in_group("smashable").size())
if _hud != null:
_hud.toast(_modes.name_of())
## Build the cabinet bank in this site's records area and stand the player at the row.
## Placing it relative to wherever the player happened to be standing put it through
## walls and behind them; every level now nominates the spot.
func _start_gauntlet() -> void:
var g: Dictionary = _plan.spec.get("gauntlet", {})
var origin: Vector3 = g.get("at", _player.global_position
+ (-_player.global_transform.basis.z) * 3.4)
var yaw := float(g.get("yaw", _player.rotation.y + PI))
_gauntlet.begin(_world, origin, yaw, _wire, _find_shredder(g))
if g.has("stand"):
_player.global_position = g["stand"]
_player.velocity = Vector3.ZERO
_player.rotation.y = yaw + PI # face the row
## The level's perfect tool. Deliberately NOT signposted — see Gauntlet.gd.
func _find_shredder(g: Dictionary) -> Node3D:
for n in get_tree().get_nodes_in_group("smashable"):
if (n as Node).name.begins_with("shredder"):
return n as Node3D
# the site doesn't already have one, so put it where the spec says — in the room,
# in plain sight, and completely unremarked upon
var at: Vector3 = g.get("shredder", Vector3(
_player.global_position.x + 2.2, 0.0, _player.global_position.z - 2.6))
var res := _glb_piece(P + "shredder.glb", "plastic", Vector2(at.x, at.z), false)
return res["piece"] as Node3D
# The Day-0 stress gate: rain N bodies, watch the FPS counter hold.
func _rain(n: int) -> void:
for i in n:
@ -559,6 +639,17 @@ func _setup_hud() -> void:
if _player != null:
_player.weapon_changed.connect(_on_weapon_changed)
## Whichever mode is running gets the status line.
func _mode_line() -> String:
if _modes == null:
return ""
match _modes.mode:
Modes.M.CUBICLE_HELL:
return _shift.hud_line() if _shift != null else ""
Modes.M.GAUNTLET:
return _gauntlet.hud_line() if _gauntlet != null else ""
return _modes.hud_line()
func _on_weapon_changed(w: Weapon) -> void:
if _hud != null and w != null:
_hud.toast(w.display_name)
@ -573,7 +664,8 @@ func _process(_dt: float) -> void:
var w: Weapon = _player.weapon() if _player != null else null
if _tasks != null:
var slip := PackedStringArray()
if _shift != null and not _shift.running:
if _modes != null and _modes.mode == Modes.M.CUBICLE_HELL \
and _shift != null and not _shift.running and _shift.time_left <= 0.0:
slip = _shift.payslip()
_hud.feed_work(_tasks.panel_state(), _tasks.prompt(), slip)
_hud.feed({
@ -588,8 +680,9 @@ func _process(_dt: float) -> void:
"weapon_blurb": w.blurb if w != null else "",
"weapon_stats": ("pow %.1f cd %.2fs reach %.1fm" % [w.power, w.cooldown, w.reach]) if w != null else "",
"slot": _player.slot() if _player != null else 0,
"mode_line": _shift.hud_line() if _shift != null else (
_game_mode.hud_line() if _game_mode != null else ""),
"mode_line": _mode_line(),
"mode_name": _modes.name_of() if _modes != null else "",
"headline": _modes.headline() if _modes != null else "",
"keys": KEYS_LINE,
"rage": _rage.value if _rage != null else 0.0,
"rage_state": _rage.state_name() if _rage != null else "",

124
game/scripts/Modes.gd Normal file
View File

@ -0,0 +1,124 @@
extends Node
class_name Modes
## Which game you're playing. M cycles.
##
## CUBICLE HELL ...... the main one. Work a shift, get provoked, snap, wreck things,
## go back to your desk. Scored as wages minus damages.
## THE GAUNTLET ...... find which cabinet holds one client's file and destroy all of
## it, against a clock, with a hidden perfect tool.
## TOTAL DESTRUCTION . no job, no pay. Wreck the whole site. Scored on time.
## AGAINST THE CLOCK . fixed timer, maximise damage VALUE. The exact inverse of
## Cubicle Hell, where the expensive things are the goal rather
## than the mistake — same level, opposite instinct.
##
## Modes own only their rules. The level, the rage meter, the tasks and the destruction
## all exist regardless; a mode decides which of them are switched on and how the number
## at the top of the screen is computed.
##
## Godot 4.7 GDScript 2.0.
enum M { CUBICLE_HELL, GAUNTLET, TOTAL, CLOCK }
const NAMES := ["CUBICLE HELL", "THE GAUNTLET", "TOTAL DESTRUCTION", "AGAINST THE CLOCK"]
const ORDER := [M.CUBICLE_HELL, M.GAUNTLET, M.TOTAL, M.CLOCK]
@export var clock_time: float = 120.0
var mode: int = M.CUBICLE_HELL
var running := false
var time_left := 0.0
var result := ""
# TOTAL DESTRUCTION
var _to_break := 0
var _broken := 0
var _elapsed := 0.0
# AGAINST THE CLOCK
var damage_value := 0
signal changed(mode: int)
func cycle() -> void:
mode = ORDER[(ORDER.find(mode) + 1) % ORDER.size()]
changed.emit(mode)
func set_mode(m: int) -> void:
mode = m
changed.emit(mode)
func name_of() -> String:
return NAMES[mode]
## Called by Main after a level is populated, with how many smashables exist.
func begin(total_bodies: int) -> void:
running = true
result = ""
_elapsed = 0.0
_broken = 0
damage_value = 0
_to_break = total_bodies
time_left = clock_time
func on_smashed(value: int) -> void:
if not running:
return
_broken += 1
damage_value += value
if mode == M.TOTAL and _broken >= _to_break:
running = false
result = "SITE CLEARED in %s" % _mmss(_elapsed)
func _process(dt: float) -> void:
if not running:
return
match mode:
M.TOTAL:
_elapsed += dt
M.CLOCK:
time_left -= dt
if time_left <= 0.0:
time_left = 0.0
running = false
result = "$%s of damage" % _comma(damage_value)
func progress() -> float:
if mode != M.TOTAL or _to_break <= 0:
return 0.0
return clampf(float(_broken) / float(_to_break), 0.0, 1.0)
func hud_line() -> String:
match mode:
M.TOTAL:
if not running:
return "TOTAL DESTRUCTION — %s (R again · M mode)" % result
return "TOTAL DESTRUCTION %s · %d / %d (%d%%)" % [
_mmss(_elapsed), _broken, _to_break, int(progress() * 100.0)]
M.CLOCK:
if not running:
return "AGAINST THE CLOCK — %s (R again · M mode)" % result
return "AGAINST THE CLOCK %02d:%02d · $%s · the expensive things" % [
int(time_left) / 60, int(time_left) % 60, _comma(damage_value)]
return ""
func headline() -> String:
match mode:
M.TOTAL:
return "%d%%" % int(progress() * 100.0)
M.CLOCK:
return "$%s" % _comma(damage_value)
return ""
func _mmss(t: float) -> String:
return "%d:%02d" % [int(t) / 60, int(t) % 60]
func _comma(n: int) -> String:
var s := str(absi(n))
var out := ""
var c := 0
for i in range(s.length() - 1, -1, -1):
out = s[i] + out
c += 1
if c % 3 == 0 and i > 0:
out = "," + out
return ("-" if n < 0 else "") + out

View File

@ -0,0 +1 @@
uid://da03ejkca426j

View File

@ -32,6 +32,8 @@ var viewmodel: ViewModel # the arms + weapon rig
## Set by Main. While a task is open the player is LOCKED at the station: no walking, no
## looking, and keys go to the task instead. You're at your desk — that's the joke.
var tasks: Tasks = null
## Set by Main. E opens cabinet drawers / feeds the shredder when a run is live.
var gauntlet: Gauntlet = null
var _head: Node3D
var _pitch := 0.0
@ -191,6 +193,8 @@ func _unhandled_input(event: InputEvent) -> void:
# a station in reach wins over the record ritual
if tasks != null and tasks.try_start():
pass
elif gauntlet != null and gauntlet.interact():
pass
elif grab != null:
grab.try_grab() # grab the record under the crosshair
elif k == KEY_G and grab != null:

View File

@ -14,13 +14,17 @@ class_name Tasks
## 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 }
enum Phase { IDLE, SPREADSHEET, COPIER_RUN, COPIER_JAM, DONE }
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"]
@ -51,6 +55,19 @@ var _jump_at := 0.0 # seconds into the task the selection wil
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
@ -126,6 +143,10 @@ func try_start() -> bool:
_begin_spreadsheet()
Kind.COPIER:
_begin_copier()
Kind.STAPLES:
_begin_staples()
Kind.GUILLOTINE:
_begin_guillotine()
return true
func abandon() -> void:
@ -255,6 +276,88 @@ func _key_copier(k: int) -> void:
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:
@ -268,6 +371,10 @@ func handle_key(k: int) -> bool:
_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
@ -300,4 +407,25 @@ func panel_state() -> Dictionary:
"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 {}