diff --git a/LANES/LANE7-cubicle-hell.md b/LANES/LANE7-cubicle-hell.md index 78696b5..48a970b 100644 --- a/LANES/LANE7-cubicle-hell.md +++ b/LANES/LANE7-cubicle-hell.md @@ -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** diff --git a/README.md b/README.md index b7e4fd2..044a33e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/game/README.md b/game/README.md index 68072b5..3182af0 100644 --- a/game/README.md +++ b/game/README.md @@ -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 diff --git a/game/dev/DemoDriver.gd b/game/dev/DemoDriver.gd index d7dae01..1f9ba20 100644 --- a/game/dev/DemoDriver.gd +++ b/game/dev/DemoDriver.gd @@ -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: diff --git a/game/dev/probe_gauntlet.gd b/game/dev/probe_gauntlet.gd new file mode 100644 index 0000000..1a174da --- /dev/null +++ b/game/dev/probe_gauntlet.gd @@ -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() diff --git a/game/dev/probe_gauntlet.gd.uid b/game/dev/probe_gauntlet.gd.uid new file mode 100644 index 0000000..9b9b922 --- /dev/null +++ b/game/dev/probe_gauntlet.gd.uid @@ -0,0 +1 @@ +uid://bmfkvtbi00p83 diff --git a/game/dev/probe_levels.gd.uid b/game/dev/probe_levels.gd.uid new file mode 100644 index 0000000..396068d --- /dev/null +++ b/game/dev/probe_levels.gd.uid @@ -0,0 +1 @@ +uid://ba2ceindfyby6 diff --git a/game/scripts/Ambience.gd b/game/scripts/Ambience.gd new file mode 100644 index 0000000..131f419 --- /dev/null +++ b/game/scripts/Ambience.gd @@ -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 diff --git a/game/scripts/Ambience.gd.uid b/game/scripts/Ambience.gd.uid new file mode 100644 index 0000000..4584937 --- /dev/null +++ b/game/scripts/Ambience.gd.uid @@ -0,0 +1 @@ +uid://ccbsqme5ek8al diff --git a/game/scripts/Cabinet.gd b/game/scripts/Cabinet.gd new file mode 100644 index 0000000..f7d1e22 --- /dev/null +++ b/game/scripts/Cabinet.gd @@ -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() diff --git a/game/scripts/Cabinet.gd.uid b/game/scripts/Cabinet.gd.uid new file mode 100644 index 0000000..4e7c900 --- /dev/null +++ b/game/scripts/Cabinet.gd.uid @@ -0,0 +1 @@ +uid://s2llgmvr8gp6 diff --git a/game/scripts/Gauntlet.gd b/game/scripts/Gauntlet.gd new file mode 100644 index 0000000..26fbe03 --- /dev/null +++ b/game/scripts/Gauntlet.gd @@ -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 ""] diff --git a/game/scripts/Gauntlet.gd.uid b/game/scripts/Gauntlet.gd.uid new file mode 100644 index 0000000..b4ca76c --- /dev/null +++ b/game/scripts/Gauntlet.gd.uid @@ -0,0 +1 @@ +uid://1pi0rnqwm81e diff --git a/game/scripts/Hud.gd b/game/scripts/Hud.gd index ff457b1..88f70cf 100644 --- a/game/scripts/Hud.gd +++ b/game/scripts/Hud.gd @@ -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. diff --git a/game/scripts/Levels.gd b/game/scripts/Levels.gd index e313b6b..d430ba5 100644 --- a/game/scripts/Levels.gd +++ b/game/scripts/Levels.gd @@ -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)}, } diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index 465c995..5f1d80c 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -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 "", diff --git a/game/scripts/Modes.gd b/game/scripts/Modes.gd new file mode 100644 index 0000000..89d5e29 --- /dev/null +++ b/game/scripts/Modes.gd @@ -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 diff --git a/game/scripts/Modes.gd.uid b/game/scripts/Modes.gd.uid new file mode 100644 index 0000000..969dea6 --- /dev/null +++ b/game/scripts/Modes.gd.uid @@ -0,0 +1 @@ +uid://da03ejkca426j diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index 4e1bbd2..1b1e20e 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -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: diff --git a/game/scripts/Tasks.gd b/game/scripts/Tasks.gd index f56d6b0..a02b0a0 100644 --- a/game/scripts/Tasks.gd +++ b/game/scripts/Tasks.gd @@ -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 {}