LANE7: CUBICLE HELL — the tilt loop, the job, and the payslip
The game stopped being a smash sandbox. You work a shift, the work provokes you, a
meter fills, you snap, and you cannot go back to your desk until you have broken
enough things. Then you sit back down and try to earn the rest of your day.
The score is your payslip: WAGES (tasks completed) - DAMAGES (everything you broke).
The load-bearing detail is that rage drains a FLAT amount per object destroyed while
each object bills at its OWN value — so during a meltdown the play is to wreck a lot
of cheap rubbish fast rather than put the sledgehammer through a filing cabinet. You
don't choose whether you lose your temper, you choose how much it costs. FURY (objects
broken while melting down) is tracked separately and deliberately isn't money, so
"I ended the shift owing them $2,400" stays a brag.
Rage.gd — WORKING -> MELTDOWN -> COOLDOWN. During a meltdown the clock STALLS if you
stop destroying; you must actually be breaking things.
RageOverlay.gd — the blood vessel. A vignette that closes in with rage and jumps
inward on every heartbeat, procedural veins that creep further into frame the angrier
you get, and a synthesized lub-dub going 62 -> 172 BPM. Zero assets: the shader is
built in code, the veins are draw_polyline from a seeded RNG, the heartbeat is a
generated WAV. Tops out as a heavy frame rather than a red screen — you have to be
able to see what you're about to destroy.
Tasks.gd — stations you walk to and are LOCKED IN PLACE at, because you're at your
desk and that's the joke. Two so far:
SPREADSHEET the selected cell silently drifts one cell partway through entry, with
no sound and no animation. Commit without noticing and the figure lands
in the wrong cell and you start again. This is the whole thesis of the
game in about forty lines.
PHOTOCOPIER jams, and wants the right tray out of 1, 2, 2A, 3, 3B.
Snapping ejects you from the station at no penalty — you didn't choose to walk away.
CubicleHell.gd — 5-minute shift, wages vs damages, meltdown count, end-of-shift
payslip with a verdict line.
Also: HUD gets a monospace face (Godot's fallback is proportional, so the spreadsheet
grid and the work-order docket never lined up), and the arcade HUD's headline becomes
net pay, red when negative.
LANES/LANE7-cubicle-hell.md captures the rest of the design: more provocations, the
GAUNTLET mode (find which of many cabinets, pull drawers, destroy files individually,
and work out which tool the level wants without being told), the other three
workplaces, and why doratracyer/floor_plan can't help — it's OpenSCAD SVG->STL for
3D printing, no generation, no room polygons, GPL-3.0. Office.gd is already most of
a parametric floor-plan builder; lifting its numbers into a data spec is the real path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d17e2314aa
commit
dd905d908d
164
LANES/LANE7-cubicle-hell.md
Normal file
164
LANES/LANE7-cubicle-hell.md
Normal file
@ -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.
|
||||||
14
README.md
14
README.md
@ -3,9 +3,17 @@
|
|||||||
*Clock out. Smash everything.*
|
*Clock out. Smash everything.*
|
||||||
|
|
||||||
A comedic, non-violent, first-person workplace-destruction game from Monster Robot
|
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 —
|
Party. Nobody gets hurt but the furniture.
|
||||||
bullpen, conference room, break room, and a photocopier that fights back. 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
|
## Monorepo layout — one lane per directory
|
||||||
|
|
||||||
|
|||||||
@ -19,11 +19,32 @@ Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
|
|||||||
| **mouse** | look (Esc releases the cursor, click recaptures) |
|
| **mouse** | look (Esc releases the cursor, click recaptures) |
|
||||||
| **LMB** | swing the equipped weapon |
|
| **LMB** | swing the equipped weapon |
|
||||||
| **1–6 · wheel · Q** | pick a weapon · cycle · swap to previous |
|
| **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) |
|
| **H** | cycle HUD style (Arcade / Minimal / Work Order / Dev) |
|
||||||
| **M** | toggle game mode (FreePlay ⟷ Find-the-Misfiled-Disc) |
|
| **M** | toggle game mode (FreePlay ⟷ Find-the-Misfiled-Disc) |
|
||||||
| **F** | report the disc in hand (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
|
## The weapon-vs-material matrix
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,25 @@ func _build_script() -> void:
|
|||||||
{"dur": 2.5, "move_to": null, "look_at": null, "act": ""},
|
{"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.
|
# ---- ACT 1: a walk through the floor plan, so the rooms read.
|
||||||
_script += [
|
_script += [
|
||||||
{"dur": 2.2, "move_to": Vector3(-8.4, 0, 2.0), "look_at": Vector3(-4.6, 1.1, 1.0), "act": ""},
|
{"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:"):
|
if act.begins_with("weapon:"):
|
||||||
_player.select(int(act.substr(7)))
|
_player.select(int(act.substr(7)))
|
||||||
return
|
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:
|
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":
|
"swing":
|
||||||
_player._queued = true # same path a left-click takes
|
_player._queued = true # same path a left-click takes
|
||||||
"hud":
|
"hud":
|
||||||
|
|||||||
118
game/scripts/CubicleHell.gd
Normal file
118
game/scripts/CubicleHell.gd
Normal file
@ -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
|
||||||
1
game/scripts/CubicleHell.gd.uid
Normal file
1
game/scripts/CubicleHell.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://mholfli3giyk
|
||||||
@ -20,6 +20,21 @@ class_name Hud
|
|||||||
enum Style { ARCADE, MINIMAL, WORKORDER, DEV }
|
enum Style { ARCADE, MINIMAL, WORKORDER, DEV }
|
||||||
const STYLE_NAMES := ["ARCADE", "MINIMAL", "WORK ORDER", "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 PINK := Color(1.0, 0.36, 0.62)
|
||||||
const CREAM := Color(0.94, 0.92, 0.86)
|
const CREAM := Color(0.94, 0.92, 0.86)
|
||||||
const INK := Color(0.11, 0.10, 0.12)
|
const INK := Color(0.11, 0.10, 0.12)
|
||||||
@ -58,6 +73,8 @@ func _ready() -> void:
|
|||||||
_build_workorder()
|
_build_workorder()
|
||||||
_build_dev()
|
_build_dev()
|
||||||
_apply()
|
_apply()
|
||||||
|
_build_work()
|
||||||
|
_build_payslip()
|
||||||
|
|
||||||
func cycle() -> void:
|
func cycle() -> void:
|
||||||
style = (style + 1) % STYLE_NAMES.size()
|
style = (style + 1) % STYLE_NAMES.size()
|
||||||
@ -80,8 +97,18 @@ func toast(msg: String) -> void:
|
|||||||
_toast_left = 1.7
|
_toast_left = 1.7
|
||||||
|
|
||||||
# ---------------------------------------------------------------- helpers
|
# ---------------------------------------------------------------- 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()
|
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_font_size_override("font_size", size)
|
||||||
l.add_theme_color_override("font_color", col)
|
l.add_theme_color_override("font_color", col)
|
||||||
if shadow:
|
if shadow:
|
||||||
@ -234,7 +261,7 @@ func _build_workorder() -> void:
|
|||||||
_w_head.offset_top = 24
|
_w_head.offset_top = 24
|
||||||
_w_head.offset_right = 366
|
_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_left = 30
|
||||||
_w_lines.offset_top = 62
|
_w_lines.offset_top = 62
|
||||||
_w_lines.offset_right = 362
|
_w_lines.offset_right = 362
|
||||||
@ -247,6 +274,181 @@ func _build_dev() -> void:
|
|||||||
_d_text.offset_left = 16
|
_d_text.offset_left = 16
|
||||||
_d_text.offset_top = 12
|
_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
|
# ---------------------------------------------------------------- per-frame
|
||||||
func _process(dt: float) -> void:
|
func _process(dt: float) -> void:
|
||||||
if _data.is_empty():
|
if _data.is_empty():
|
||||||
@ -266,6 +468,15 @@ func _g(k: String, dflt = 0):
|
|||||||
return _data.get(k, dflt)
|
return _data.get(k, dflt)
|
||||||
|
|
||||||
func _tick_arcade() -> void:
|
func _tick_arcade() -> void:
|
||||||
|
# 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")))
|
_a_score.text = "%s" % _comma(int(_g("score")))
|
||||||
var combo := int(_g("combo"))
|
var combo := int(_g("combo"))
|
||||||
if combo > 1:
|
if combo > 1:
|
||||||
@ -280,7 +491,11 @@ func _tick_arcade() -> void:
|
|||||||
|
|
||||||
_a_weapon.text = "%d · %s" % [int(_g("slot")) + 1, str(_g("weapon", "—"))]
|
_a_weapon.text = "%d · %s" % [int(_g("slot")) + 1, str(_g("weapon", "—"))]
|
||||||
_a_weapon_sub.text = str(_g("weapon_blurb", ""))
|
_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)
|
_a_toast.modulate.a = clampf(_toast_left / 0.5, 0.0, 1.0)
|
||||||
|
|
||||||
func _tick_minimal() -> void:
|
func _tick_minimal() -> void:
|
||||||
|
|||||||
@ -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 _records: Array[Record] = [] # the live records, handed to GameMode each round
|
||||||
var _game_mode: GameMode
|
var _game_mode: GameMode
|
||||||
var _office: Office # the level shell: walls, fittings, lighting
|
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:
|
func _ready() -> void:
|
||||||
randomize()
|
randomize()
|
||||||
@ -41,7 +45,25 @@ func _ready() -> void:
|
|||||||
_populate()
|
_populate()
|
||||||
_spawn_guard = SPAWN_GUARD_TIME
|
_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()
|
_game_mode = GameMode.new()
|
||||||
add_child(_game_mode)
|
add_child(_game_mode)
|
||||||
_game_mode.setup(_player.grab)
|
_game_mode.setup(_player.grab)
|
||||||
@ -49,6 +71,16 @@ func _ready() -> void:
|
|||||||
|
|
||||||
_setup_hud()
|
_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
|
# ---------------------------------------------------------------- world / camera
|
||||||
func _setup_world() -> void:
|
func _setup_world() -> void:
|
||||||
# The level shell: floor, walls, ceiling, windows, fittings and ALL the lighting.
|
# 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)
|
_score += int(POINTS.get(kind, 15)) * maxi(_juice.combo(), 1)
|
||||||
else:
|
else:
|
||||||
_score += int(POINTS.get(kind, 15))
|
_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:
|
if _game_mode != null:
|
||||||
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
|
_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() \
|
var bodies := get_tree().get_nodes_in_group("smashable").size() \
|
||||||
+ get_tree().get_nodes_in_group("debris").size()
|
+ get_tree().get_nodes_in_group("debris").size()
|
||||||
var w: Weapon = _player.weapon() if _player != null else null
|
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({
|
_hud.feed({
|
||||||
"fps": Engine.get_frames_per_second(),
|
"fps": Engine.get_frames_per_second(),
|
||||||
"bodies": bodies,
|
"bodies": bodies,
|
||||||
@ -500,6 +544,13 @@ func _process(_dt: float) -> void:
|
|||||||
"weapon_blurb": w.blurb if w != null else "",
|
"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 "",
|
"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,
|
"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,
|
"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,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -29,6 +29,9 @@ const SWING_DROP := 0.32
|
|||||||
var camera: Camera3D # Main reads this for the crosshair raycast
|
var camera: Camera3D # Main reads this for the crosshair raycast
|
||||||
var grab: GrabController # the record grab/extract/throw hands
|
var grab: GrabController # the record grab/extract/throw hands
|
||||||
var viewmodel: ViewModel # the arms + weapon rig
|
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 _head: Node3D
|
||||||
var _pitch := 0.0
|
var _pitch := 0.0
|
||||||
@ -140,6 +143,13 @@ func _build_crosshair() -> void:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- input
|
# ---------------------------------------------------------------- input
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
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:
|
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
var mm := event as InputEventMouseMotion
|
var mm := event as InputEventMouseMotion
|
||||||
_look_delta += mm.relative * 0.01
|
_look_delta += mm.relative * 0.01
|
||||||
@ -177,7 +187,11 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
var k := (event as InputEventKey).keycode
|
var k := (event as InputEventKey).keycode
|
||||||
if k == KEY_ESCAPE:
|
if k == KEY_ESCAPE:
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
elif k == KEY_E and grab != null:
|
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
|
grab.try_grab() # grab the record under the crosshair
|
||||||
elif k == KEY_G and grab != null:
|
elif k == KEY_G and grab != null:
|
||||||
grab.drop() # drop / trash whatever's in hand
|
grab.drop() # drop / trash whatever's in hand
|
||||||
@ -226,7 +240,10 @@ func _physics_process(delta: float) -> void:
|
|||||||
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
|
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
|
||||||
velocity.y = jump_velocity
|
velocity.y = jump_velocity
|
||||||
|
|
||||||
var input_dir := Vector2(
|
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_D)) - float(Input.is_key_pressed(KEY_A)),
|
||||||
float(Input.is_key_pressed(KEY_S)) - float(Input.is_key_pressed(KEY_W)))
|
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)
|
var wish := transform.basis * Vector3(input_dir.x, 0.0, input_dir.y)
|
||||||
|
|||||||
161
game/scripts/Rage.gd
Normal file
161
game/scripts/Rage.gd
Normal file
@ -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 ""
|
||||||
1
game/scripts/Rage.gd.uid
Normal file
1
game/scripts/Rage.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://cieqkhganxwim
|
||||||
185
game/scripts/RageOverlay.gd
Normal file
185
game/scripts/RageOverlay.gd
Normal file
@ -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
|
||||||
1
game/scripts/RageOverlay.gd.uid
Normal file
1
game/scripts/RageOverlay.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://nu18glf3rknt
|
||||||
297
game/scripts/Tasks.gd
Normal file
297
game/scripts/Tasks.gd
Normal file
@ -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 {}
|
||||||
1
game/scripts/Tasks.gd.uid
Normal file
1
game/scripts/Tasks.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://b85liu7c0dj0o
|
||||||
Loading…
Reference in New Issue
Block a user