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:
parent
4bcd7b49d9
commit
e5f4e8c5e4
@ -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**
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -20,13 +20,49 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
|
||||
| **LMB** | swing the equipped weapon |
|
||||
| **1–6 · 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 F–J 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
|
||||
|
||||
@ -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:
|
||||
|
||||
54
game/dev/probe_gauntlet.gd
Normal file
54
game/dev/probe_gauntlet.gd
Normal 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()
|
||||
1
game/dev/probe_gauntlet.gd.uid
Normal file
1
game/dev/probe_gauntlet.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bmfkvtbi00p83
|
||||
1
game/dev/probe_levels.gd.uid
Normal file
1
game/dev/probe_levels.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://ba2ceindfyby6
|
||||
96
game/scripts/Ambience.gd
Normal file
96
game/scripts/Ambience.gd
Normal 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
|
||||
1
game/scripts/Ambience.gd.uid
Normal file
1
game/scripts/Ambience.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://ccbsqme5ek8al
|
||||
197
game/scripts/Cabinet.gd
Normal file
197
game/scripts/Cabinet.gd
Normal 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 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()
|
||||
1
game/scripts/Cabinet.gd.uid
Normal file
1
game/scripts/Cabinet.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://s2llgmvr8gp6
|
||||
187
game/scripts/Gauntlet.gd
Normal file
187
game/scripts/Gauntlet.gd
Normal 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 F–J
|
||||
## 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 := ["A–E", "F–J", "K–O", "P–T", "U–Z"]
|
||||
const CLIENTS := {
|
||||
"A–E": ["ABERNATHY", "CULVER", "DUNMORE", "BRATTON"],
|
||||
"F–J": ["HENDERSON", "GRIEVES", "IRONSIDE", "JAKUB"],
|
||||
"K–O": ["KOWALSKI", "MERRIWEATHER", "NAKAMURA", "OSGOOD"],
|
||||
"P–T": ["PENHALIGON", "QUAYLE", "SOMMERS", "TRENT"],
|
||||
"U–Z": ["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 ""]
|
||||
1
game/scripts/Gauntlet.gd.uid
Normal file
1
game/scripts/Gauntlet.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://1pi0rnqwm81e
|
||||
@ -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.
|
||||
|
||||
@ -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)},
|
||||
}
|
||||
|
||||
@ -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
124
game/scripts/Modes.gd
Normal 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
|
||||
1
game/scripts/Modes.gd.uid
Normal file
1
game/scripts/Modes.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://da03ejkca426j
|
||||
@ -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:
|
||||
|
||||
@ -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 {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user