destroyulator/game/scripts/Gauntlet.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

188 lines
5.8 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ""]