destroyulator/game/README.md
Monster Robot Party d17e2314aa LANE6: LEVEL 01 is an office — Dunder Mifflin floor plan, and a spawn guard
The game had no room. Props sat on a grey disc in a black void, which quietly broke
the premise: a game about wrecking your workplace needs a workplace.

Office.gd — LEVEL 01
- Open-plan office laid out after The Office (US) floor plan: bullpen of facing desk
  pairs behind cubicle partitions, reception, glass-walled manager's office, conference
  room, break room with vending machines, copier alcove, warehouse roller door.
- Drop ceiling on a T-bar grid with fluorescent troffers that ARE the light sources
  (the old scene lit an interior with one outdoor directional lamp, which is exactly
  why it read as "props on a plane"), window wall with blinds, magnolia and carpet.
- Office owns the shell + static fittings; Main._populate() owns everything smashable
  and asks Office where things go. Walls/desks/counters are boxes because they ARE
  boxes; chairs, vending machines, microwave and plant come from a new Blender
  generator (tools/gen_office_props.py) because a box reads as wrong for those.

The level no longer falls over on its own
- Records were stacked 3 cm apart vertically while being 30 cm TALL, so Jolt resolved
  27 cm of interpenetration explosively on frame one and shoved the furniture over
  before the player touched anything. They now stand side by side.
- Per-material mass (MASSES): everything was 1 kg, so a thrown record could tip a
  filing cabinet.
- Spawn guard: placing ~40 props by formula guarantees an occasional overlap, and
  depenetration is violent (a chair left the building at 500 m/s). Dynamic bodies are
  speed-limited for 0.75 s, and each offender is named once with the position it was
  PLACED at, so the cause stays visible instead of being papered over. It then found
  the real bug: bullpen rows 3.6 m apart left the two rows' chairs meeting
  back-to-back with 3 cm to spare. Rows are now 4.8 m apart.
- dev/DemoDriver.gd act 0 touches nothing for 5 s and prints total body speed. Now
  reads 0.00 m/s with zero spawn warnings.

Two real melee bugs found while testing the level
- The hit test was a SPHERE parked at `reach`, so anything CLOSER than the weapon's
  reach fell in front of it and was missed — you could stand against the printer with
  a sledgehammer and swing straight through it. Now a capsule swept from the camera.
- Swings started at eye height (1.6 m), so a carton on the floor was ~1.5 m away even
  standing over it and short-reach weapons could never touch anything on the ground.
  Swings now originate at hand height.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:13:23 +10:00

137 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Destroyulator — the game
Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
## Run it
**Editor:** open `/Applications/Godot.app`, import this folder (`game/`), press **F5** / ▶.
**CLI:**
```sh
/Applications/Godot.app/Contents/MacOS/Godot --path /Users/m3ultra/Documents/Destroyulater/game
```
## Controls
| Input | Does |
|---|---|
| **WASD / Space** | move / jump |
| **mouse** | look (Esc releases the cursor, click recaptures) |
| **LMB** | swing the equipped weapon |
| **16 · 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 |
## The weapon-vs-material matrix
This is the decision the game is built on. A swing deals `Weapon.power × Weapon.vs[material]`
against that material's `hp`, and there are three outcomes: it **breaks**, it **dents**
(knockback + a chip of damage), or it's **futile** — a dead clank, no score, no combo,
and a HUD nudge to switch tools.
| | cardboard | paper | vinyl | glass | wood | steel |
|---|---|---|---|---|---|---|
| **Bare Hands** | ok | good | ok | ok | poor | useless |
| **Box Cutter** | **shreds** | **shreds** | good | poor | useless | useless |
| **Cricket Bat** | good | poor | **great** | **great** | ok | poor |
| **Crowbar** | ok | poor | ok | good | good | **great** |
| **Sledgehammer** | ok | poor | ok | **great** | **great** | **great** |
| **Fire Extinguisher** | ok | poor | ok | **great** | good | good |
Tuned in one place: `scripts/Weapon.gd` (`vs` tables) and `scripts/Smashable.gd`
(`PROFILES[kind].hp`). The office printer is the level boss — steel at `toughness_scale
= 5.0`, so it's a real fight with anything but the sledge.
Weapons also differ in **when** they connect, not just how hard: the hit lands at
`Weapon.contact` through the swing animation, so the sledgehammer commits late and heavy
while the box cutter is near-instant.
## The viewmodel
`scripts/ViewModel.gd`. Real rigged hands (mixamorig, all 20 finger bones per hand)
holding real weapon meshes.
The arms rig is **instanced once per hand** and each instance is *placed* so its own hand
bone lands on the grip — no IK solver. That works because in first person you only ever
see forearm and hand. Placement solves two aims at once: the fist's bore lines up with
the weapon shaft, and the forearm runs back toward where that shoulder would be. Both
directions are **measured off the rig** at load (pinky-knuckle → index-knuckle for the
bore, elbow → hand for the forearm), so the grip survives re-tuning `grip_rest_rot`
instead of needing a fresh set of hand-authored euler angles.
Motion is layered on one node: look-sway with spring-back, walk bob scaled by speed and
weapon heft, idle breathing, landing dip, and a keyframed swing arc per archetype
(`overhead` / `horizontal` / `jab` / `thrust`).
## Assets
| Path | Source |
|---|---|
| `assets/viewmodel/fps_arms.glb` | `tools/gen_fps_arms.py` — cut from the GODVERSE modular character kit |
| `assets/viewmodel/{bat,sledge,crowbar,cutter,extinguisher}.glb` | `tools/gen_viewmodel.py` — procedural, grip at origin, shaft +Y |
| `assets/store/*.glb` + `*.fractured.glb` | Lane 2 prop batch; the fractured sibling drives real chunk destruction |
| `assets/art/sleeves_*.jpg` | 3×3 sheets of generated cover art, windowed per record via uv1 offset |
Regenerate either set with:
```sh
/Applications/Blender.app/Contents/MacOS/Blender --background --python tools/gen_viewmodel.py -- --render
```
## Dev harness
`dev/` is not shipped. Because macOS screen-recording permission isn't available to the
CLI, the game records **itself**:
```sh
Godot --path game --resolution 1280x720 --write-movie /tmp/cap.avi --quit-after 2250 dev/demo.tscn
```
`dev/DemoDriver.gd` drives the player through a scripted tour (every weapon, the material
matrix, the boss, all four HUDs) so a change can be eyeballed frame by frame without a
human at the keyboard. `dev/probe_*.gd` print rig/scale/placement numbers — renders are
too coarse to tell a 5 cm error from a 50 cm one.
## Smoke test before committing
```sh
/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --quit-after 300
```
Zero script errors required. After adding any `class_name` script, run
`Godot --path game --editor --quit` first — headless Godot won't rescan a stale class cache.
## The level
`scripts/Office.gd` builds LEVEL 01: an open-plan office after the Dunder Mifflin floor
plan — bullpen of facing desk pairs behind cubicle partitions, reception, a glass-walled
manager's office, conference room, break room with vending machines, a copier alcove for
the printer boss, and a warehouse roller door. Drop ceiling, fluorescent troffers (which
are the actual light sources), magnolia walls, grey carpet, a window wall with blinds.
Office owns the **shell and static fittings**; `Main._populate()` owns everything
**smashable**, and asks Office where things go (`desk_spots()`, `chair_spots()`,
`copier_spot()`). Walls, desks and counters are boxes built in code because they are
boxes; chairs, vending machines, the microwave and the plant come from
`tools/gen_office_props.py`, because a box reads as obviously wrong for those.
### The spawn guard
Placing ~40 props by formula means something will eventually spawn a few centimetres
inside something else, and Jolt's depenetration is not gentle — a chair overlapping a
desk left the building at 500 m/s on frame one, knocking over the furniture on its way.
`Main._physics_process()` speed-limits dynamic bodies for the first 0.75 s: enough for a
real overlap to push apart calmly, far too little to launch anything. Every offender is
named once in a warning with **the position it was placed at**, so the underlying overlap
stays visible and fixable rather than silently papered over.
Verify a level is genuinely at rest with:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --quit-after 300 dev/demo.tscn
```
The demo's first act touches nothing and prints total body speed each second. It must
read `0.00 m/s`, and no `[spawn]` warnings should appear.