diff --git a/LANES/LANE7-cubicle-hell.md b/LANES/LANE7-cubicle-hell.md new file mode 100644 index 0000000..78696b5 --- /dev/null +++ b/LANES/LANE7-cubicle-hell.md @@ -0,0 +1,164 @@ +# LANE 7 — CUBICLE HELL + +*The game stopped being a smash sandbox and became a game about a man having a bad day.* + +## The pitch + +You are at work. The work is designed to provoke you. A meter fills. When it fills you +**snap**, and for half a minute you physically cannot go back to your desk until you've +broken enough things. Then you sit back down and try to earn the rest of your shift. + +Falling Down, but the payslip is the scoreboard. + +## The score IS your payslip + +``` + NET = WAGES (tasks completed) − DAMAGES (everything you broke) +``` + +The load-bearing detail: **rage drains a flat amount per object destroyed, but each +object bills at its own value.** During a meltdown the optimal play is therefore to smash +a lot of cheap rubbish very fast — cartons, paperwork, monitors — rather than put the +sledgehammer through a filing cabinet. You don't choose whether you lose your temper. You +choose how expensive losing your temper is. + +`FURY` (objects broken while actually melting down) is tracked separately and is +deliberately **not** money, so "I ended the shift owing them $2,400" stays a brag. + +--- + +## BUILT (this lane, shipped) + +| Piece | File | What it does | +|---|---|---| +| Tilt meter | `game/scripts/Rage.gd` | WORKING → MELTDOWN → COOLDOWN. Provocations fill it, clean work drains it slightly, ambient office drains your patience passively. During a meltdown the clock **stalls if you stop destroying** — you MUST break things. | +| The blood vessel | `game/scripts/RageOverlay.gd` | Red vignette that closes in with rage and jumps inward on every heartbeat; procedural **veins** that creep further into frame the angrier you get; a synthesized lub-dub whose BPM goes 62 → 172. Zero assets: shader built in code, veins are `draw_polyline`, heartbeat is a generated WAV. | +| The job | `game/scripts/Tasks.gd` | Stations you walk to and are **locked in place at** (you're at your desk — that's the joke). Two tasks so far, below. | +| The shift | `game/scripts/CubicleHell.gd` | 5-minute shift, wages vs damages, meltdown count, end-of-shift payslip with a verdict line. | + +### The two tasks that exist + +- **SPREADSHEET** — enter a figure in a named cell. Partway through, the selection + **silently drifts one cell** — no sound, no animation. Commit without noticing and the + figure lands in the wrong cell, you eat a big provoke, and you start again. This one + task is the whole thesis of the game in about forty lines. +- **PHOTOCOPIER** — run a copy job. It jams. Clearing it needs the **right tray**, and + the trays are labelled `1, 2, 2A, 3, 3B` because that's what head office decided. + +--- + +## TO BUILD — more ways to be provoked + +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 +wasting your time**, not just "it's slow". + +| Task | The provocation | +|---|---| +| **Staple removal** | A stack with N staples. Click each. Too slow and the page tears — redo the sheet. The remover slips and takes a chunk out of the page. | +| **Guillotine / paper slicer** | Align the sheet to a line, pull the handle. Misalign and the cut is crooked → redo. The alignment guide is 2mm off and always has been. | +| **The phone** | Rings mid-task. You must answer it. It's hold music. Not answering keeps it ringing and ringing raises rage faster than answering does. | +| **Printer driver** | The printer is "offline". Toggling it takes four clicks in a dialog that moves its buttons. | +| **Reply-all** | An email thread with 40 recipients. You must reply to the sender only. The default is Reply All. | +| **Toner** | Replace the cartridge. It's the wrong model number by one digit. | +| **Timesheet** | Enter hours. It won't accept decimals. It also won't accept fractions. | + +### Passive ambient provocations (no interaction, just attrition) +Someone microwaves fish. The air-con drips. A colleague's ringtone. The fluorescent tube +above your desk flickers at a frequency chosen to be maximally irritating. Each is a +small periodic `rage.provoke()` — cheap to add, and they're what makes the room feel hostile +rather than neutral. + +--- + +## TO BUILD — modes + +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. +3. **AGAINST THE CLOCK** — fixed timer, maximise damage value. The inverse of Cubicle Hell: + here expensive things are the *goal*. +4. **THE GAUNTLET** *(designed below — the most interesting one)*. +5. **VERSUS** — two players, one office. Explore later; the interesting version is + asymmetric (one working, one wrecking) rather than both wrecking. + +### THE GAUNTLET (design) + +> *"you need to destroy every file in a specific cabinet but which one, heaps cabinets, +> real subtle hints, time limit, and you have to actually pull each drawer out, empty +> files out, destroy them individually or in small lots unless you have a specific tool"* + +- A room of **many** identical filing cabinets. Exactly one holds the target files. +- **Subtle hints only**: a memo on a desk names a client; a labelled folder on the copier; + a sticky note with half a reference number. No waypoint, no glow. +- Destruction is **granular**, not one-hit: pull a drawer → drawer slides out as its own + body → files inside become individual smashables → destroy singly or in small lots. +- **The perfect tool.** Each level has ONE tool that makes its gauntlet tractable — an + industrial shredder for files, a crowbar for the drawers, a lighter for a wastepaper + bin. It is somewhere in the level, and **the game does not tell you what it is.** + Working out "what would actually make this fast" IS the puzzle. With the wrong tool the + gauntlet is possible but you will not beat the clock. +- Needs: `Drawer.gd` (pull-out sub-body with its own contents), a `Container` concept on + Smashable, and a `Tool` flag on Weapon marking it as a level's key tool. + +--- + +## TO BUILD — the other three workplaces + +Reference floor plans provided: **Parks & Rec** (municipal, partitioned, outdoor patio), +**Silicon Valley hacker house** (domestic-open-plan, kitchen + pool), **The IT Crowd** +(basement, two rooms, glorious junk), plus the generic open-plan bullpen. + +Each is a new script alongside `Office.gd` implementing the same interface: + +```gdscript +build() # shell, fittings, lighting +configure_environment(env) +desk_spots() / chair_spots() / copier_spot() +spawn_point() / spawn_yaw() +``` + +They differ in **materials and hazards**, which is what stops them being reskins: + +| Level | Signature material | Signature provocation | +|---|---|---| +| Municipal (Parks & Rec) | laminate, pinboard, civic beige | the public counter; forms in triplicate | +| Hacker house (Silicon Valley) | glass, whiteboard, drywall, a pool | standup meetings; someone's monitor wall | +| Basement IT (IT Crowd) | server steel, cardboard mountains, CRTs | "have you tried turning it off and on again" | + +### On procedurally generating floor plans + +The repo you found — `doratracyer/floor_plan` — **won't help**. It's OpenSCAD that turns +hand-drawn SVG wall paths into 3D-printable STLs. It doesn't generate layouts, doesn't +parse plan images, and produces no room polygons or graph you could build a level from. +It's also GPL-3.0, which we would not want to link against. + +**Better path, and we're most of the way there already:** `Office.gd` is a parametric +floor-plan builder that just happens to have its numbers hard-coded. Lift those into a +data spec — + +```gdscript +{ rooms: [{name, x0, x1, z0, z1, walls, door_at}], + fittings: [{kind, pos, yaw}], + lights: {grid_x, grid_z}, + spawn: {pos, yaw} } +``` + +— and a generic `Floorplan.gd` builds any of them. Then each workplace is a **data file**, +new levels are authorable in minutes, and a *generator* becomes possible later (rooms as a +BSP split of the footprint, doors on shared walls, fittings by room type) because the +consumer is already data-driven. Do the spec first, generation second. + +--- + +## Asset needs (MODELBEAST / `tools/gen_office_props.py`) + +Already generated procedurally: office chair, vending machine, microwave, potted plant. +Needed next, in rough priority order: + +`filing cabinet with pullable drawers` (split mesh per drawer) · `desk phone` · +`guillotine / paper slicer` · `stapler + staple remover` · `wall clock` · `whiteboard` · +`server rack` · `pinboard` · `water cooler bottle (separate from base)` · `wastepaper bin` · +`hole punch` · `shredder` (the gauntlet's perfect tool) + +Rule of thumb established this lane: **if it's a box, build it in code**; if its silhouette +can't be faked by a box, generate it. diff --git a/README.md b/README.md index 812c41a..b7e4fd2 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,17 @@ *Clock out. Smash everything.* A comedic, non-violent, first-person workplace-destruction game from Monster Robot -Party. Level 01 is an open-plan office laid out after the Dunder Mifflin floor plan — -bullpen, conference room, break room, and a photocopier that fights back. Nobody gets -hurt but the furniture. +Party. Nobody gets hurt but the furniture. + +**The game is CUBICLE HELL.** You work a shift doing deliberately infuriating office +tasks. A rage meter fills. When it fills you *snap*, and you physically cannot go back to +your desk until you've broken enough things. Then you sit back down and try to earn the +rest of your day. Your score is your payslip: **wages earned minus damages caused** — so +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). ## Monorepo layout — one lane per directory diff --git a/game/README.md b/game/README.md index d461c6d..68072b5 100644 --- a/game/README.md +++ b/game/README.md @@ -19,11 +19,32 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics. | **mouse** | look (Esc releases the cursor, click recaptures) | | **LMB** | swing the equipped weapon | | **1–6 · wheel · Q** | pick a weapon · cycle · swap to previous | -| **E · drag ←→ · LMB · G** | grab a record · slide the disc out · throw it · drop | | **H** | cycle HUD style (Arcade / Minimal / Work Order / Dev) | | **M** | toggle game mode (FreePlay ⟷ Find-the-Misfiled-Disc) | | **F** | report the disc in hand (Find-the-Misfiled-Disc) | -| **B · R** | rain 500 bodies (stress gate) · reset the store | +| **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 | + +## CUBICLE HELL + +The mode the game is about. See [`../LANES/LANE7-cubicle-hell.md`](../LANES/LANE7-cubicle-hell.md) +for the full design. + +`Rage.gd` is the tilt meter: work provokes it, clean work drains it slightly, and at 100% +you SNAP into a forced destruction window that **stalls if you stop destroying**. +`RageOverlay.gd` is the blood vessel — a vignette that closes in, procedural veins that +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`. + +`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 +lot of cheap things fast* versus *one expensive swing*. ## The weapon-vs-material matrix diff --git a/game/dev/DemoDriver.gd b/game/dev/DemoDriver.gd index ec50b4c..7b6ecc6 100644 --- a/game/dev/DemoDriver.gd +++ b/game/dev/DemoDriver.gd @@ -61,6 +61,25 @@ func _build_script() -> void: {"dur": 2.5, "move_to": null, "look_at": null, "act": ""}, ] + # ---- ACT 0b: CUBICLE HELL. Sit at a desk, do the spreadsheet, get wound up until + # ---- the meter snaps, then wreck the place until it drains. + _script += [ + {"dur": 2.4, "move_to": Vector3(-4.6, 0, -0.55), "look_at": Vector3(-4.6, 1.0, -1.4), "act": ""}, + {"dur": 1.2, "move_to": null, "look_at": null, "act": "task"}, + {"dur": 1.6, "move_to": null, "look_at": null, "act": "type"}, + {"dur": 1.6, "move_to": null, "look_at": null, "act": "commit"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "rage:0.30"}, + {"dur": 1.4, "move_to": null, "look_at": null, "act": "rage:0.30"}, + {"dur": 1.6, "move_to": null, "look_at": null, "act": "rage:0.35"}, + # SNAP — the vignette closes in, veins crawl, heartbeat goes. Now wreck things. + {"dur": 1.0, "move_to": Vector3(-4.6, 0, 0.3), "look_at": Vector3(-4.6, 0.9, -0.7), "act": "weapon:2"}, + {"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 1.6, "move_to": Vector3(-2.6, 0, 4.8), "look_at": Vector3(-2.3, 0.25, 5.2), "act": ""}, + {"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"}, + {"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"}, + ] + # ---- ACT 1: a walk through the floor plan, so the rooms read. _script += [ {"dur": 2.2, "move_to": Vector3(-8.4, 0, 2.0), "look_at": Vector3(-4.6, 1.1, 1.0), "act": ""}, @@ -196,7 +215,27 @@ func _do(act: String) -> void: if act.begins_with("weapon:"): _player.select(int(act.substr(7))) return + if act.begins_with("rage:"): + var r = _main.get("_rage") + if r != null: + r.provoke(float(act.substr(5)), "the demo is provoking you") + return match act: + "task": # press E at the nearest station + var t = _main.get("_tasks") + if t != null: + t.try_start() + "type": # enter the requested figure correctly + var t2 = _main.get("_tasks") + if t2 != null and t2.is_working(): + var st: Dictionary = t2.panel_state() + var want := String(st.get("instruction", "")).split(" ")[1] + for ch in want: + t2.handle_key(KEY_0 + int(ch)) + "commit": + var t3 = _main.get("_tasks") + if t3 != null: + t3.handle_key(KEY_ENTER) "swing": _player._queued = true # same path a left-click takes "hud": diff --git a/game/scripts/CubicleHell.gd b/game/scripts/CubicleHell.gd new file mode 100644 index 0000000..0631490 --- /dev/null +++ b/game/scripts/CubicleHell.gd @@ -0,0 +1,118 @@ +extends Node +class_name CubicleHell + +## CUBICLE HELL — the mode the game is actually about. +## +## You work a shift. The work is designed to provoke you. The meter fills. You snap, and +## for half a minute you are physically unable to go back to your desk until you've +## broken enough things. Then you sit back down and try to earn the rest of your day. +## +## THE SCORE IS YOUR PAYSLIP: +## +## NET = WAGES (tasks completed) − DAMAGES (everything you broke) +## +## Rage drains a flat amount per object destroyed, but each object bills at its own +## value — so the play during a meltdown is to smash a lot of cheap rubbish quickly +## rather than put a sledgehammer through a filing cabinet. You don't get to choose +## whether you lose your temper. You choose how much it costs. +## +## FURY is a separate, non-monetary stat: things broken while actually melting down. +## It exists because "I ended the shift owing them money" should still be a brag. +## +## Godot 4.7 GDScript 2.0. + +@export var shift_time: float = 300.0 ## a five-minute shift + +var rage: Rage +var tasks: Tasks + +var time_left: float = 0.0 +var running: bool = false +var damages: int = 0 ## $ of property destroyed +var fury: int = 0 ## objects broken during a meltdown +var meltdowns: int = 0 +var result: String = "" + +signal shift_ended() + +func setup(r: Rage, t: Tasks) -> void: + rage = r + tasks = t + if rage != null: + rage.snapped.connect(_on_snapped) + +func begin() -> void: + running = true + time_left = shift_time + damages = 0 + fury = 0 + meltdowns = 0 + result = "" + +func _on_snapped() -> void: + meltdowns += 1 + # you cannot keep filling in the spreadsheet while you're kicking the printer + if tasks != null: + tasks.eject() + +## Main calls this for every object destroyed, with what it was worth. +func on_smashed(value: int) -> void: + if not running: + return + damages += value + if rage != null and rage.is_meltdown(): + fury += 1 + +func wages() -> int: + return tasks.wages if tasks != null else 0 + +func net() -> int: + return wages() - damages + +func _process(dt: float) -> void: + if not running: + return + time_left -= dt + if time_left <= 0.0: + time_left = 0.0 + _end() + +func _end() -> void: + running = false + var n := net() + if n >= 600: + result = "EMPLOYEE OF THE MONTH" + elif n >= 0: + result = "SATISFACTORY" + elif n >= -900: + result = "A LETTER WILL BE PLACED ON YOUR FILE" + else: + result = "SECURITY WILL ESCORT YOU OUT" + shift_ended.emit() + +## The line the HUD shows while the shift runs. +func hud_line() -> String: + if not running: + return "SHIFT OVER — %s · net $%d (M cycles mode · R new shift)" % [result, net()] + var m := int(time_left) / 60 + var s := int(time_left) % 60 + return "CUBICLE HELL %d:%02d · pay $%d damages $%d · %d meltdown%s" % [ + m, s, wages(), damages, meltdowns, "" if meltdowns == 1 else "s"] + +## End-of-shift payslip, as lines. +func payslip() -> PackedStringArray: + var out := PackedStringArray() + out.append("MONSTER ROBOT PARTY · PAYSLIP") + out.append("") + out.append("%-26s %8d" % ["Tasks completed", tasks.tasks_done if tasks else 0]) + out.append("%-26s %8d" % ["Tasks fumbled", tasks.tasks_fumbled if tasks else 0]) + out.append("%-26s %8s" % ["Gross wages", "$%d" % wages()]) + out.append("") + out.append("%-26s %8d" % ["Meltdowns", meltdowns]) + out.append("%-26s %8d" % ["Objects destroyed in rage", fury]) + out.append("%-26s %8s" % ["Deductions (damages)", "-$%d" % damages]) + out.append("--------------------------------------") + out.append("%-26s %8s" % ["NET", "$%d" % net()]) + out.append("") + out.append(result) + return out diff --git a/game/scripts/CubicleHell.gd.uid b/game/scripts/CubicleHell.gd.uid new file mode 100644 index 0000000..a2038aa --- /dev/null +++ b/game/scripts/CubicleHell.gd.uid @@ -0,0 +1 @@ +uid://mholfli3giyk diff --git a/game/scripts/Hud.gd b/game/scripts/Hud.gd index a78aa71..ff457b1 100644 --- a/game/scripts/Hud.gd +++ b/game/scripts/Hud.gd @@ -20,6 +20,21 @@ class_name Hud enum Style { ARCADE, MINIMAL, WORKORDER, DEV } const STYLE_NAMES := ["ARCADE", "MINIMAL", "WORK ORDER", "DEV"] +# The task panel, the interaction prompt and the payslip sit OUTSIDE the style system: +# they're the CUBICLE HELL mode's own furniture and have to be visible whatever HUD +# skin is selected, so they get their own always-on layer. +var _work_root: Control +var _work_panel: ColorRect +var _work_title: Label +var _work_instr: Label +var _work_grid: Label +var _work_foot: Label +var _work_bar_bg: ColorRect +var _work_bar: ColorRect +var _prompt: Label +var _slip_root: Control +var _slip: Label + const PINK := Color(1.0, 0.36, 0.62) const CREAM := Color(0.94, 0.92, 0.86) const INK := Color(0.11, 0.10, 0.12) @@ -58,6 +73,8 @@ func _ready() -> void: _build_workorder() _build_dev() _apply() + _build_work() + _build_payslip() func cycle() -> void: style = (style + 1) % STYLE_NAMES.size() @@ -80,8 +97,18 @@ func toast(msg: String) -> void: _toast_left = 1.7 # ---------------------------------------------------------------- helpers -func _label(parent: Control, size: int, col: Color, shadow := true) -> Label: +## A monospace face for anything drawn as a text grid. Godot's fallback font is +## proportional, so column padding with %-9s lands wherever it likes — the spreadsheet +## and the work-order docket both need real fixed pitch to line up. +static func _mono() -> Font: + var f := SystemFont.new() + f.font_names = PackedStringArray(["Menlo", "SF Mono", "Courier New", "monospace"]) + return f + +func _label(parent: Control, size: int, col: Color, shadow := true, mono := false) -> Label: var l := Label.new() + if mono: + l.add_theme_font_override("font", _mono()) l.add_theme_font_size_override("font_size", size) l.add_theme_color_override("font_color", col) if shadow: @@ -234,7 +261,7 @@ func _build_workorder() -> void: _w_head.offset_top = 24 _w_head.offset_right = 366 - _w_lines = _label(r, 14, INK, false) + _w_lines = _label(r, 14, INK, false, true) _w_lines.offset_left = 30 _w_lines.offset_top = 62 _w_lines.offset_right = 362 @@ -247,6 +274,181 @@ func _build_dev() -> void: _d_text.offset_left = 16 _d_text.offset_top = 12 +# ---------------------------------------------------------------- work panel +## The thing you stare at while doing your job. Deliberately drab: a beige box with a +## monospace grid, so that when the vignette goes red around it the contrast lands. +func _build_work() -> void: + _work_root = Control.new() + _work_root.set_anchors_preset(Control.PRESET_FULL_RECT) + _work_root.mouse_filter = Control.MOUSE_FILTER_IGNORE + _work_root.visible = false + add_child(_work_root) + + _work_panel = _panel(_work_root, Color(0.90, 0.89, 0.84, 0.97)) + _work_panel.anchor_left = 0.5 + _work_panel.anchor_right = 0.5 + _work_panel.anchor_top = 0.5 + _work_panel.anchor_bottom = 0.5 + _work_panel.offset_left = -300 + _work_panel.offset_right = 300 + _work_panel.offset_top = -180 + _work_panel.offset_bottom = 150 + + var bar := _panel(_work_root, INK) + bar.anchor_left = 0.5 + bar.anchor_right = 0.5 + bar.anchor_top = 0.5 + bar.anchor_bottom = 0.5 + bar.offset_left = -300 + bar.offset_right = 300 + bar.offset_top = -180 + bar.offset_bottom = -146 + + _work_title = _label(_work_root, 15, CREAM, false) + _work_title.anchor_left = 0.5 + _work_title.anchor_right = 0.5 + _work_title.anchor_top = 0.5 + _work_title.offset_left = -288 + _work_title.offset_right = 288 + _work_title.offset_top = -173 + + _work_instr = _label(_work_root, 19, INK, false) + _work_instr.anchor_left = 0.5 + _work_instr.anchor_right = 0.5 + _work_instr.anchor_top = 0.5 + _work_instr.offset_left = -288 + _work_instr.offset_right = 288 + _work_instr.offset_top = -134 + _work_instr.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + _work_grid = _label(_work_root, 16, INK, false, true) + _work_grid.anchor_left = 0.5 + _work_grid.anchor_right = 0.5 + _work_grid.anchor_top = 0.5 + _work_grid.offset_left = -288 + _work_grid.offset_right = 288 + _work_grid.offset_top = -96 + + _work_bar_bg = _panel(_work_root, Color(0, 0, 0, 0.18)) + _work_bar_bg.anchor_left = 0.5 + _work_bar_bg.anchor_right = 0.5 + _work_bar_bg.anchor_top = 0.5 + _work_bar_bg.offset_left = -250 + _work_bar_bg.offset_right = 250 + _work_bar_bg.offset_top = -30 + _work_bar_bg.offset_bottom = -8 + + _work_bar = _panel(_work_root, Color(0.16, 0.42, 0.22)) + _work_bar.anchor_left = 0.5 + _work_bar.anchor_right = 0.5 + _work_bar.anchor_top = 0.5 + _work_bar.offset_left = -250 + _work_bar.offset_right = -250 + _work_bar.offset_top = -30 + _work_bar.offset_bottom = -8 + + _work_foot = _label(_work_root, 13, Color(0.35, 0.34, 0.32), false) + _work_foot.anchor_left = 0.5 + _work_foot.anchor_right = 0.5 + _work_foot.anchor_top = 0.5 + _work_foot.offset_left = -288 + _work_foot.offset_right = 288 + _work_foot.offset_top = 122 + _work_foot.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + + _prompt = _label(_work_root, 18, Color.WHITE) + _prompt.anchor_left = 0.5 + _prompt.anchor_right = 0.5 + _prompt.anchor_top = 0.5 + _prompt.anchor_bottom = 0.5 + _prompt.offset_left = -240 + _prompt.offset_right = 240 + _prompt.offset_top = 52 + _prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + +func _build_payslip() -> void: + _slip_root = Control.new() + _slip_root.set_anchors_preset(Control.PRESET_FULL_RECT) + _slip_root.mouse_filter = Control.MOUSE_FILTER_IGNORE + _slip_root.visible = false + add_child(_slip_root) + var p := _panel(_slip_root, CREAM) + p.anchor_left = 0.5 + p.anchor_right = 0.5 + p.anchor_top = 0.5 + p.anchor_bottom = 0.5 + p.offset_left = -230 + p.offset_right = 230 + p.offset_top = -200 + p.offset_bottom = 200 + _slip = _label(_slip_root, 15, INK, false, true) + _slip.anchor_left = 0.5 + _slip.anchor_right = 0.5 + _slip.anchor_top = 0.5 + _slip.offset_left = -206 + _slip.offset_right = 206 + _slip.offset_top = -178 + +## Main pushes the task/prompt/payslip state here, separately from `feed`, because it +## has to render regardless of which HUD skin is selected. +func feed_work(task: Dictionary, prompt_text: String, payslip: PackedStringArray) -> void: + if _work_root == null: + return + var busy := not task.is_empty() + var slip := payslip.size() > 0 + _slip_root.visible = slip + if slip: + _slip.text = "\n".join(payslip) + _work_root.visible = (busy or prompt_text != "") and not slip + _work_panel.visible = busy + _work_title.visible = busy + _work_instr.visible = busy + _work_grid.visible = busy + _work_foot.visible = busy + _prompt.visible = (not busy) and prompt_text != "" + _prompt.text = prompt_text + var show_bar := busy and task.has("progress") + _work_bar_bg.visible = show_bar + _work_bar.visible = show_bar + if not busy: + return + _work_title.text = String(task.get("title", "")) + _work_instr.text = String(task.get("instruction", "")) + _work_foot.text = String(task.get("footer", "")) + 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 + else: + _work_grid.text = "" + +## 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. +func _spreadsheet_grid(task: Dictionary) -> String: + var cols: Array = task.get("cols", []) + var rows: int = int(task.get("rows", 0)) + var sel: Vector2i = task.get("sel", Vector2i.ZERO) + var typed: String = String(task.get("typed", "")) + var out := PackedStringArray() + var head := " " + for c in cols: + head += "%-9s" % String(c) + out.append(head) + for r in range(rows): + var line := "%-5s" % str(r + 1) + for c in range(cols.size()): + if Vector2i(c, r) == sel: + line += "%-9s" % ("[" + (typed if typed != "" else "_") + "]") + else: + line += "%-9s" % "." + out.append(line) + return "\n".join(out) + # ---------------------------------------------------------------- per-frame func _process(dt: float) -> void: if _data.is_empty(): @@ -266,7 +468,16 @@ func _g(k: String, dflt = 0): return _data.get(k, dflt) func _tick_arcade() -> void: - _a_score.text = "%s" % _comma(int(_g("score"))) + # in CUBICLE HELL the headline number is your payslip, not an arcade score — + # net pay is the thing you're actually playing against + var state := String(_g("rage_state", "")) + if state != "": + var net := int(_g("net")) + _a_score.text = ("$%s" % _comma(net)) if net >= 0 else ("-$%s" % _comma(-net)) + _a_score.add_theme_color_override("font_color", + Color(1, 0.42, 0.38) if net < 0 else Color.WHITE) + else: + _a_score.text = "%s" % _comma(int(_g("score"))) var combo := int(_g("combo")) if combo > 1: _a_combo.text = "COMBO x%d" % combo @@ -280,7 +491,11 @@ func _tick_arcade() -> void: _a_weapon.text = "%d · %s" % [int(_g("slot")) + 1, str(_g("weapon", "—"))] _a_weapon_sub.text = str(_g("weapon_blurb", "")) - _a_mode.text = str(_g("mode_line", "")) + var mode := str(_g("mode_line", "")) + var status := str(_g("rage_status", "")) + if status != "": + mode += "\n" + status + _a_mode.text = mode _a_toast.modulate.a = clampf(_toast_left / 0.5, 0.0, 1.0) func _tick_minimal() -> void: diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index 845e3dd..a2ca70b 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -31,6 +31,10 @@ var _player: Player # kept so GameMode can reach player.grab var _records: Array[Record] = [] # the live records, handed to GameMode each round var _game_mode: GameMode var _office: Office # the level shell: walls, fittings, lighting +var _rage: Rage # the tilt meter — see Rage.gd +var _rage_hud: RageOverlay +var _tasks: Tasks +var _shift: CubicleHell func _ready() -> void: randomize() @@ -41,7 +45,25 @@ func _ready() -> void: _populate() _spawn_guard = SPAWN_GUARD_TIME - # rules layer: starts in FreePlay; keys 1/2 switch, F reports in FindMisfiled + # --- CUBICLE HELL: the tilt meter, the job, and the payslip that scores both --- + _rage = Rage.new() + add_child(_rage) + _rage_hud = RageOverlay.new() + _rage_hud.setup(_rage) + add_child(_rage_hud) + + _tasks = Tasks.new() + add_child(_tasks) + _tasks.setup(_player, _rage) + _register_stations() + _player.tasks = _tasks + + _shift = CubicleHell.new() + add_child(_shift) + _shift.setup(_rage, _tasks) + _shift.begin() + + # rules layer: the record modes still exist behind M _game_mode = GameMode.new() add_child(_game_mode) _game_mode.setup(_player.grab) @@ -49,6 +71,16 @@ func _ready() -> void: _setup_hud() +## 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 _office.desk_spots(): + _tasks.register(Tasks.Kind.SPREADSHEET, (t as Transform3D).origin, + "work at desk %d" % i) + i += 1 + _tasks.register(Tasks.Kind.COPIER, _office.copier_spot().origin, + "use the photocopier") + # ---------------------------------------------------------------- world / camera func _setup_world() -> void: # The level shell: floor, walls, ceiling, windows, fittings and ALL the lighting. @@ -387,6 +419,13 @@ func _on_smashed(kind: String, at: Vector3, shards: int) -> void: _score += int(POINTS.get(kind, 15)) * maxi(_juice.combo(), 1) else: _score += int(POINTS.get(kind, 15)) + # Rage drains a FLAT amount per object; the payslip bills each object at its own + # value. That gap is the whole decision during a meltdown — break a lot of cheap + # things fast, or take one expensive swing and pay for it. + if _rage != null: + _rage.on_smashed() + if _shift != null: + _shift.on_smashed(int(POINTS.get(kind, 15))) if _game_mode != null: _game_mode.on_smashed() # scores the mess during a FindMisfiled round @@ -488,6 +527,11 @@ func _process(_dt: float) -> void: var bodies := get_tree().get_nodes_in_group("smashable").size() \ + get_tree().get_nodes_in_group("debris").size() var w: Weapon = _player.weapon() if _player != null else null + if _tasks != null: + var slip := PackedStringArray() + if _shift != null and not _shift.running: + slip = _shift.payslip() + _hud.feed_work(_tasks.panel_state(), _tasks.prompt(), slip) _hud.feed({ "fps": Engine.get_frames_per_second(), "bodies": bodies, @@ -500,6 +544,13 @@ 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": _game_mode.hud_line() if _game_mode != null else "", + "mode_line": _shift.hud_line() if _shift != null else ( + _game_mode.hud_line() if _game_mode != null else ""), "keys": KEYS_LINE, + "rage": _rage.value if _rage != null else 0.0, + "rage_state": _rage.state_name() if _rage != null else "", + "rage_status": _rage.status_line() if _rage != null else "", + "pay": _shift.wages() if _shift != null else 0, + "damages": _shift.damages if _shift != null else 0, + "net": _shift.net() if _shift != null else 0, }) diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index 5fb6a26..4e1bbd2 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -29,6 +29,9 @@ const SWING_DROP := 0.32 var camera: Camera3D # Main reads this for the crosshair raycast var grab: GrabController # the record grab/extract/throw hands 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 var _head: Node3D var _pitch := 0.0 @@ -140,6 +143,13 @@ func _build_crosshair() -> void: # ---------------------------------------------------------------- input func _unhandled_input(event: InputEvent) -> void: + # a task swallows the keyboard first, so digits and arrows edit the spreadsheet + # instead of switching weapons and walking away from the desk + if tasks != null and tasks.is_working(): + if event is InputEventKey and event.pressed and not event.echo: + tasks.handle_key((event as InputEventKey).keycode) + return + if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: var mm := event as InputEventMouseMotion _look_delta += mm.relative * 0.01 @@ -177,8 +187,12 @@ func _unhandled_input(event: InputEvent) -> void: var k := (event as InputEventKey).keycode if k == KEY_ESCAPE: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE - elif k == KEY_E and grab != null: - grab.try_grab() # grab the record under the crosshair + elif k == KEY_E: + # a station in reach wins over the record ritual + if tasks != null and tasks.try_start(): + pass + elif grab != null: + grab.try_grab() # grab the record under the crosshair elif k == KEY_G and grab != null: grab.drop() # drop / trash whatever's in hand elif k == KEY_Q: @@ -226,9 +240,12 @@ func _physics_process(delta: float) -> void: if Input.is_key_pressed(KEY_SPACE) and is_on_floor(): velocity.y = jump_velocity - var input_dir := Vector2( - float(Input.is_key_pressed(KEY_D)) - float(Input.is_key_pressed(KEY_A)), - float(Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_W))) + var seated := tasks != null and tasks.is_working() + var input_dir := Vector2.ZERO + if not seated: + input_dir = Vector2( + float(Input.is_key_pressed(KEY_D)) - float(Input.is_key_pressed(KEY_A)), + float(Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_W))) var wish := transform.basis * Vector3(input_dir.x, 0.0, input_dir.y) wish.y = 0.0 if wish.length() > 1.0: diff --git a/game/scripts/Rage.gd b/game/scripts/Rage.gd new file mode 100644 index 0000000..044b8f5 --- /dev/null +++ b/game/scripts/Rage.gd @@ -0,0 +1,161 @@ +extends Node +class_name Rage + +## The tilt meter — the thing the whole game now hangs off. +## +## You are at work. Work is provoking. The meter fills. When it fills all the way you +## SNAP, and for the next half minute you are not allowed to be a functioning employee: +## you have to wreck things until it drains. Then you go back to your desk and try again. +## +## THE SCORING TENSION (this is the design, not an implementation detail): +## rage drains a FIXED amount per thing destroyed, but the damages bill is that thing's +## value. So during a meltdown the optimal play is to smash a lot of cheap rubbish very +## fast — cartons, paperwork, monitors — rather than putting the sledgehammer through a +## filing cabinet. You are forced to lose your temper; you get to choose how expensive +## losing your temper is. +## +## States: +## WORKING ..... doing tasks. Provocations raise the meter, clean work lowers it. +## MELTDOWN .... forced destruction window. Destroying drains. Standing still does not +## — the timer stalls if you aren't actually breaking anything, because +## "you MUST destroy shit when you are falling down". +## COOLDOWN .... a couple of seconds of shaking hands before work resumes. +## +## Godot 4.7 GDScript 2.0. + +enum State { WORKING, MELTDOWN, COOLDOWN } + +## Seconds a meltdown lasts if you never stop swinging. +@export var meltdown_time: float = 32.0 +## Seconds of trembling before you're expected to be productive again. +@export var cooldown_time: float = 2.5 +## Fraction of the meter one destroyed object drains, regardless of what it was worth. +@export var drain_per_smash: float = 0.075 +## Meter gained per second just from existing in an office. +@export var ambient_provoke: float = 0.008 +## Meter lost per second while a task is going cleanly. +@export var focus_soothe: float = 0.010 +## If you go this long during a meltdown without breaking anything, the clock stalls. +@export var idle_stall_after: float = 2.0 + +var state: int = State.WORKING +var value: float = 0.0 ## 0..1 +var meltdown_left: float = 0.0 +var last_reason: String = "" + +var _phase: float = 0.0 # heartbeat phase, 0..1, drives the throb +var _since_smash: float = 0.0 +var _cooldown_left: float = 0.0 +var _stalled: bool = false + +signal snapped() ## the meter hit 1.0 — meltdown begins +signal calmed() ## back to work +signal provoked(amount: float, reason: String) + +# ---------------------------------------------------------------- inputs +## Something at work just happened to you. +func provoke(amount: float, reason: String) -> void: + if state != State.WORKING: + return + value = clampf(value + amount, 0.0, 1.0) + last_reason = reason + provoked.emit(amount, reason) + if value >= 1.0: + _snap() + +## A task went right. Small relief — never enough to keep up on its own. +func soothe(amount: float) -> void: + if state != State.WORKING: + return + value = clampf(value - amount, 0.0, 1.0) + +## Main calls this for every object destroyed, in any state. +func on_smashed() -> void: + _since_smash = 0.0 + _stalled = false + if state == State.MELTDOWN: + value = clampf(value - drain_per_smash, 0.0, 1.0) + if value <= 0.0: + _calm() + +func _snap() -> void: + state = State.MELTDOWN + value = 1.0 + meltdown_left = meltdown_time + _since_smash = 0.0 + _stalled = false + snapped.emit() + +func _calm() -> void: + state = State.COOLDOWN + _cooldown_left = cooldown_time + value = clampf(value, 0.0, 0.25) + meltdown_left = 0.0 + calmed.emit() + +# ---------------------------------------------------------------- per-frame +func _process(dt: float) -> void: + # heartbeat runs in every state; it's the player's read on how close they are + _phase = fposmod(_phase + dt * bpm() / 60.0, 1.0) + + match state: + State.WORKING: + value = clampf(value + ambient_provoke * dt, 0.0, 1.0) + if value >= 1.0: + _snap() + State.MELTDOWN: + _since_smash += dt + # the clock only runs while you're actually wrecking something + _stalled = _since_smash > idle_stall_after + if not _stalled: + meltdown_left -= dt + if meltdown_left <= 0.0: + _calm() + State.COOLDOWN: + _cooldown_left -= dt + value = maxf(0.0, value - dt * 0.10) + if _cooldown_left <= 0.0: + state = State.WORKING + +# ---------------------------------------------------------------- reads +## Resting 62 bpm, 168 at the edge — the audible version of the meter. +func bpm() -> float: + if state == State.MELTDOWN: + return 172.0 + return lerpf(62.0, 168.0, value * value) + +## 0..1 within the current heartbeat. Two thumps per cycle (lub-dub). +func pulse() -> float: + var p := _phase + var lub := exp(-pow((p - 0.02) / 0.055, 2.0)) + var dub := exp(-pow((p - 0.20) / 0.070, 2.0)) * 0.6 + return clampf(lub + dub, 0.0, 1.0) + +func is_meltdown() -> bool: + return state == State.MELTDOWN + +func is_stalled() -> bool: + return state == State.MELTDOWN and _stalled + +func state_name() -> String: + match state: + State.MELTDOWN: return "MELTDOWN" + State.COOLDOWN: return "COOLDOWN" + return "WORKING" + +## Short line for the HUD, in the voice of the game. +func status_line() -> String: + match state: + State.MELTDOWN: + if _stalled: + return "BREAK SOMETHING" + return "%02ds" % int(ceil(meltdown_left)) + State.COOLDOWN: + return "…breathe…" + if value > 0.86: + return "you are about to do something" + if value > 0.62: + return "unclench your jaw" + if value > 0.34: + return "it's fine. it's fine." + return "" diff --git a/game/scripts/Rage.gd.uid b/game/scripts/Rage.gd.uid new file mode 100644 index 0000000..7914cf7 --- /dev/null +++ b/game/scripts/Rage.gd.uid @@ -0,0 +1 @@ +uid://cieqkhganxwim diff --git a/game/scripts/RageOverlay.gd b/game/scripts/RageOverlay.gd new file mode 100644 index 0000000..36f34eb --- /dev/null +++ b/game/scripts/RageOverlay.gd @@ -0,0 +1,185 @@ +extends CanvasLayer +class_name RageOverlay + +## The blood vessel in your eye, throbbing faster as you lose it. +## +## Three layers, all driven by the same Rage instance: +## 1. a red vignette that closes in as the meter fills, pulsing on the heartbeat +## 2. actual VEINS — branching polylines creeping in from the corners, thickening +## with each thump. Generated once from a fixed seed so they don't crawl. +## 3. a synthesized lub-dub heartbeat whose rate IS the meter (Rage.bpm) +## +## No textures and no shader files: the vignette is a small ShaderMaterial built in code +## and the veins are draw_polyline, so this drops into a project with zero asset deps. +## +## Godot 4.7 GDScript 2.0. + +const VIGNETTE_SHADER := """ +shader_type canvas_item; +uniform float rage : hint_range(0.0, 1.0) = 0.0; +uniform float pulse : hint_range(0.0, 1.0) = 0.0; +uniform float meltdown : hint_range(0.0, 1.0) = 0.0; +void fragment() { + // squash y so the vignette matches a wide viewport instead of going circular + vec2 d = (UV - vec2(0.5)) * vec2(1.0, 0.62); + float r = length(d) * 2.0; + // The tunnel closes in as rage climbs and jumps inward on every thump — but the + // CENTRE stays readable. You have to be able to see what you're about to destroy, + // so this tops out as a heavy frame rather than a red screen. + float inner = mix(0.98, 0.44, rage) - pulse * 0.09 * rage; + float v = smoothstep(inner, 1.30, r); + float a = v * (rage * 0.62 + meltdown * 0.16); + vec3 col = mix(vec3(0.52, 0.02, 0.03), vec3(0.75, 0.05, 0.02), meltdown); + COLOR = vec4(col, clamp(a, 0.0, 0.95)); +} +""" + +var rage: Rage + +var _vignette: ColorRect +var _veins: Control +var _heart: AudioStreamPlayer +var _beat_wav: AudioStreamWAV +var _last_pulse := 0.0 +var _paths: Array = [] # each entry: PackedVector2Array in 0..1 screen space + +func setup(r: Rage) -> void: + rage = r + layer = 20 # above the HUD + + _vignette = ColorRect.new() + _vignette.set_anchors_preset(Control.PRESET_FULL_RECT) + _vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE + var sh := Shader.new() + sh.code = VIGNETTE_SHADER + var m := ShaderMaterial.new() + m.shader = sh + _vignette.material = m + add_child(_vignette) + + _veins = Control.new() + _veins.set_anchors_preset(Control.PRESET_FULL_RECT) + _veins.mouse_filter = Control.MOUSE_FILTER_IGNORE + _veins.draw.connect(_draw_veins) + add_child(_veins) + _build_veins() + + _heart = AudioStreamPlayer.new() + _beat_wav = _make_beat() + _heart.stream = _beat_wav + _heart.volume_db = -6.0 + add_child(_heart) + +# ---------------------------------------------------------------- veins +## Branching polylines rooted at the screen corners and growing inward. Built once with +## a seeded RNG so the pattern is stable — veins that re-randomise every frame read as +## static, not as anatomy. +func _build_veins() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 0x8100D + _paths.clear() + var roots := [ + [Vector2(0.0, 0.10), Vector2(1.0, 0.6)], [Vector2(0.0, 0.86), Vector2(1.0, -0.5)], + [Vector2(1.0, 0.14), Vector2(-1.0, 0.6)], [Vector2(1.0, 0.90), Vector2(-1.0, -0.5)], + [Vector2(0.16, 0.0), Vector2(0.4, 1.0)], [Vector2(0.82, 0.0), Vector2(-0.4, 1.0)], + [Vector2(0.28, 1.0), Vector2(0.3, -1.0)], [Vector2(0.72, 1.0), Vector2(-0.3, -1.0)], + ] + for r in roots: + _grow(r[0], (r[1] as Vector2).normalized(), 0.30, 5, rng) + +func _grow(from: Vector2, dir: Vector2, length: float, depth: int, + rng: RandomNumberGenerator) -> void: + if depth <= 0 or length < 0.02: + return + var pts := PackedVector2Array() + pts.append(from) + var p := from + var d := dir + var steps := 4 + for i in steps: + d = d.rotated(rng.randf_range(-0.42, 0.42)).normalized() + p += d * (length / float(steps)) + pts.append(p) + _paths.append(pts) + # branch + if rng.randf() < 0.85: + _grow(p, d.rotated(rng.randf_range(0.35, 0.95)), length * 0.62, depth - 1, rng) + if rng.randf() < 0.7: + _grow(p, d.rotated(rng.randf_range(-0.95, -0.35)), length * 0.58, depth - 1, rng) + +func _draw_veins() -> void: + if rage == null: + return + var r := rage.value + if r < 0.12 and not rage.is_meltdown(): + return + var size := _veins.size + var pulse := rage.pulse() + var a := clampf((r - 0.10) * 1.25, 0.0, 1.0) + if rage.is_meltdown(): + a = 1.0 + var col := Color(0.62, 0.04, 0.05, a * (0.30 + 0.45 * pulse)) + var w := (1.4 + 4.2 * r) * (0.80 + 0.55 * pulse) + # only the fraction of each vein proportional to rage is drawn, so they visibly + # creep further into the frame the angrier you get + var reveal := clampf(0.25 + r * 0.85, 0.0, 1.0) + for path in _paths: + var pts: PackedVector2Array = path + var n := int(ceil(pts.size() * reveal)) + if n < 2: + continue + var scaled := PackedVector2Array() + for i in n: + scaled.append(Vector2(pts[i].x * size.x, pts[i].y * size.y)) + _veins.draw_polyline(scaled, col, w, true) + +# ---------------------------------------------------------------- heartbeat +## A lub-dub built from two damped low sines. Same zero-asset trick as Juice.gd. +func _make_beat() -> AudioStreamWAV: + var rate := 22050 + var dur := 0.42 + var n := int(rate * dur) + var out := PackedFloat32Array() + out.resize(n) + for i in n: + var t := float(i) / float(rate) + var s := 0.0 + # lub + var e1 := exp(-t * 26.0) + s += sin(TAU * 52.0 * t) * e1 * 0.9 + s += sin(TAU * 78.0 * t) * e1 * 0.3 + # dub, a beat later and softer + var t2 := t - 0.16 + if t2 > 0.0: + var e2 := exp(-t2 * 30.0) + s += sin(TAU * 44.0 * t2) * e2 * 0.55 + s += sin(TAU * 66.0 * t2) * e2 * 0.2 + out[i] = clampf(s, -1.0, 1.0) + var wav := AudioStreamWAV.new() + wav.format = AudioStreamWAV.FORMAT_16_BITS + wav.mix_rate = rate + wav.stereo = false + var bytes := PackedByteArray() + bytes.resize(out.size() * 2) + for i in out.size(): + bytes.encode_s16(i * 2, int(out[i] * 32767.0)) + wav.data = bytes + return wav + +# ---------------------------------------------------------------- per-frame +func _process(_dt: float) -> void: + if rage == null: + return + var m := _vignette.material as ShaderMaterial + var p := rage.pulse() + m.set_shader_parameter("rage", rage.value) + m.set_shader_parameter("pulse", p) + m.set_shader_parameter("meltdown", 1.0 if rage.is_meltdown() else 0.0) + _veins.queue_redraw() + + # fire the thump on the rising edge of each beat + if p > 0.55 and _last_pulse <= 0.55 and (rage.value > 0.18 or rage.is_meltdown()): + _heart.volume_db = lerpf(-20.0, -3.0, clampf(rage.value, 0.0, 1.0)) + _heart.pitch_scale = lerpf(0.92, 1.16, rage.value) + _heart.play() + _last_pulse = p diff --git a/game/scripts/RageOverlay.gd.uid b/game/scripts/RageOverlay.gd.uid new file mode 100644 index 0000000..3ff95e2 --- /dev/null +++ b/game/scripts/RageOverlay.gd.uid @@ -0,0 +1 @@ +uid://nu18glf3rknt diff --git a/game/scripts/Tasks.gd b/game/scripts/Tasks.gd new file mode 100644 index 0000000..588368c --- /dev/null +++ b/game/scripts/Tasks.gd @@ -0,0 +1,297 @@ +extends Node +class_name Tasks + +## The job. Deliberately, precisely annoying. +## +## Stations sit in the world (a desk, the photocopier). You walk up, press E, and get +## locked in place doing something mundane — because you ARE at your desk, and being +## unable to walk away is part of it. Every station has a designed way of wasting your +## time, and every wasted second is fuel for Rage. +## +## SPREADSHEET — enter a figure in a named cell. The selection silently jumps to a +## neighbouring cell partway through. Commit without noticing and the +## figure lands in the wrong cell and you start again. This one is the +## 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. +## +## 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 } + +const REACH := 2.1 ## metres you must be within to work at a station +const COLS := ["A", "B", "C", "D", "E", "F"] +const ROWS := 5 + +## Trays are labelled in the order someone at head office thought was obvious. +const TRAYS := ["1", "2", "2A", "3", "3B"] + +@export var wage_per_task: int = 120 +@export var soothe_per_task: float = 0.12 + +var rage: Rage +var _player: Node3D +var _stations: Array = [] # {kind, pos, label} +var _near: Dictionary = {} # the station currently in reach, or empty + +var phase: int = Phase.IDLE +var wages: int = 0 +var tasks_done: int = 0 +var tasks_fumbled: int = 0 + +# --- spreadsheet state +var _target_cell := Vector2i.ZERO # where the figure is SUPPOSED to go +var _sel := Vector2i.ZERO # where the cursor actually is +var _want_value := "" +var _typed := "" +var _jump_at := 0.0 # seconds into the task the selection will drift +var _jumped := false +var _t := 0.0 + +# --- copier state +var _pages_left := 0 +var _job_pages := 0 +var _copy_t := 0.0 +var _jam_tray := "" +var _jam_in := 0.0 + +signal task_completed() +signal task_fumbled(reason: String) + +func setup(player: Node3D, r: Rage) -> void: + _player = player + rage = r + +func register(kind: int, pos: Vector3, label: String) -> void: + _stations.append({"kind": kind, "pos": pos, "label": label}) + +func is_working() -> bool: + return phase != Phase.IDLE and phase != Phase.DONE + +## What the HUD should show as an interaction prompt, or "". +func prompt() -> String: + if is_working(): + return "" + if _near.is_empty(): + return "" + return "E — %s" % String(_near["label"]) + +func nearest_label() -> String: + return String(_near["label"]) if not _near.is_empty() else "" + +# ---------------------------------------------------------------- proximity +func _process(dt: float) -> void: + _t += dt + if not is_working(): + _update_near() + return + match phase: + Phase.SPREADSHEET: + _tick_spreadsheet(dt) + Phase.COPIER_RUN: + _tick_copier(dt) + Phase.COPIER_JAM: + pass # waits on input + # working cleanly is mildly calming, which is what makes the drift so infuriating + if rage != null and phase != Phase.COPIER_JAM: + rage.soothe(rage.focus_soothe * dt) + +func _update_near() -> void: + _near = {} + if _player == null: + return + var best := REACH * REACH + for s in _stations: + var d: float = (_player.global_position - (s["pos"] as Vector3)).length_squared() + if d < best: + best = d + _near = s + +# ---------------------------------------------------------------- entry / exit +## Player pressed E. Returns true if it started (so the caller can skip other uses of E). +func try_start() -> bool: + if is_working() or _near.is_empty(): + return false + match int(_near["kind"]): + Kind.SPREADSHEET: + _begin_spreadsheet() + Kind.COPIER: + _begin_copier() + return true + +func abandon() -> void: + if not is_working(): + return + phase = Phase.IDLE + tasks_fumbled += 1 + task_fumbled.emit("walked away") + if rage != null: + rage.provoke(0.05, "you'll have to start that again") + +## You snapped. You are no longer at your desk — the chair is somewhere behind you. +## Unlike abandon() this costs nothing: you didn't choose to walk away from the job. +func eject() -> void: + if not is_working(): + return + phase = Phase.IDLE + +func _finish() -> void: + phase = Phase.IDLE + tasks_done += 1 + wages += wage_per_task + task_completed.emit() + if rage != null: + rage.soothe(soothe_per_task) + +func _fumble(reason: String, amount: float) -> void: + tasks_fumbled += 1 + task_fumbled.emit(reason) + if rage != null: + rage.provoke(amount, reason) + +# ---------------------------------------------------------------- spreadsheet +func _begin_spreadsheet() -> void: + phase = Phase.SPREADSHEET + _target_cell = Vector2i(randi() % COLS.size(), randi() % ROWS) + _sel = _target_cell + _want_value = str(randi_range(1000, 9999)) + _typed = "" + _jumped = false + # it drifts once, early enough that you've started typing and are looking at the keys + _jump_at = _t + randf_range(1.6, 3.4) + +func _tick_spreadsheet(_dt: float) -> void: + if _jumped or _t < _jump_at: + return + _jumped = true + # nudge the selection one cell in a random direction. No sound, no animation. + var dirs := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] + var d: Vector2i = dirs[randi() % dirs.size()] + var moved := Vector2i(clampi(_sel.x + d.x, 0, COLS.size() - 1), + clampi(_sel.y + d.y, 0, ROWS - 1)) + if moved == _sel: # clamped against an edge; go the other way + moved = Vector2i(clampi(_sel.x - d.x, 0, COLS.size() - 1), + clampi(_sel.y - d.y, 0, ROWS - 1)) + _sel = moved + +func _key_spreadsheet(k: int) -> void: + if k >= KEY_0 and k <= KEY_9: + if _typed.length() < 6: + _typed += str(k - KEY_0) + return + match k: + KEY_BACKSPACE: + _typed = _typed.substr(0, maxi(_typed.length() - 1, 0)) + KEY_LEFT: + _sel.x = clampi(_sel.x - 1, 0, COLS.size() - 1) + KEY_RIGHT: + _sel.x = clampi(_sel.x + 1, 0, COLS.size() - 1) + KEY_UP: + _sel.y = clampi(_sel.y - 1, 0, ROWS - 1) + KEY_DOWN: + _sel.y = clampi(_sel.y + 1, 0, ROWS - 1) + KEY_ENTER, KEY_KP_ENTER: + _commit_cell() + +func _commit_cell() -> void: + if _typed != _want_value: + _fumble("#VALUE! — that isn't the figure", 0.09) + _typed = "" + return + if _sel != _target_cell: + # the drift got you. The figure is now in the wrong cell and you have to redo it. + _fumble("it went in %s%d. it was meant to go in %s%d." % [ + COLS[_sel.x], _sel.y + 1, COLS[_target_cell.x], _target_cell.y + 1], 0.17) + _typed = "" + _sel = _target_cell + _jumped = false + _jump_at = _t + randf_range(2.0, 4.0) + return + _finish() + +# ---------------------------------------------------------------- copier +func _begin_copier() -> void: + phase = Phase.COPIER_RUN + _job_pages = randi_range(8, 18) + _pages_left = _job_pages + _copy_t = 0.0 + _jam_in = randf_range(1.2, 2.8) + +func _tick_copier(dt: float) -> void: + _copy_t += dt + if _copy_t >= 0.42: + _copy_t = 0.0 + _pages_left -= 1 + if _pages_left <= 0: + _finish() + return + _jam_in -= dt + if _jam_in <= 0.0: + phase = Phase.COPIER_JAM + _jam_tray = TRAYS[randi() % TRAYS.size()] + if rage != null: + rage.provoke(0.06, "PAPER JAM") + +func _key_copier(k: int) -> void: + if phase != Phase.COPIER_JAM: + return + var pressed := "" + if k >= KEY_1 and k <= KEY_5: + pressed = TRAYS[k - KEY_1] + if pressed == "": + return + if pressed == _jam_tray: + phase = Phase.COPIER_RUN + _jam_in = randf_range(2.0, 5.5) + else: + _fumble("that's not tray %s" % _jam_tray, 0.08) + +# ---------------------------------------------------------------- input +## Player forwards keys here while a task is open. Returns true if consumed. +func handle_key(k: int) -> bool: + if not is_working(): + return false + if k == KEY_ESCAPE or k == KEY_E: + abandon() + return true + match phase: + Phase.SPREADSHEET: + _key_spreadsheet(k) + Phase.COPIER_RUN, Phase.COPIER_JAM: + _key_copier(k) + return true + +# ---------------------------------------------------------------- HUD feed +## Everything the task panel needs to draw itself, as plain data. +func panel_state() -> Dictionary: + match phase: + Phase.SPREADSHEET: + return { + "kind": "spreadsheet", + "title": "QUARTERLY RETURNS.XLS", + "instruction": "Enter %s in cell %s%d" % [ + _want_value, COLS[_target_cell.x], _target_cell.y + 1], + "cols": COLS, "rows": ROWS, + "sel": _sel, "typed": _typed, + "footer": "digits · arrows move · ENTER commit · E leave", + } + Phase.COPIER_RUN: + return { + "kind": "copier", "title": "MULTIFUNCTION DEVICE", + "instruction": "Copying… %d of %d" % [ + _job_pages - _pages_left + 1, _job_pages], + "progress": 1.0 - float(_pages_left) / float(maxi(_job_pages, 1)), + "footer": "E leave", + } + Phase.COPIER_JAM: + return { + "kind": "copier", "title": "MULTIFUNCTION DEVICE", + "instruction": "PAPER JAM — CLEAR TRAY %s" % _jam_tray, + "jam": true, "trays": TRAYS, + "progress": 1.0 - float(_pages_left) / float(maxi(_job_pages, 1)), + "footer": "press the tray number · E leave", + } + return {} diff --git a/game/scripts/Tasks.gd.uid b/game/scripts/Tasks.gd.uid new file mode 100644 index 0000000..3f33f03 --- /dev/null +++ b/game/scripts/Tasks.gd.uid @@ -0,0 +1 @@ +uid://b85liu7c0dj0o