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>
198 lines
6.2 KiB
GDScript
198 lines
6.2 KiB
GDScript
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 F–J 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 := "A–E"
|
||
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()
|