Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ Notes for AI coding assistants working on this repo.

The full picture is in `README.md`. This is a Perry-compiled TypeScript
**third-person shooter** (over-the-shoulder orbit camera — TPS, not
FPS) built on the Bloom engine. Five alien kinds, three waves, two
weapons, textured arena, skeletal-animated enemies, ambient music —
runnable with `perry compile src/main.ts -o main && ./main`.
FPS) built on the Bloom engine. Seven alien kinds (two RANGED), three
waves, four weapons, ONE playable arena (see SH-040 — the manifest lists
only `arena_02`; the pipeline takes more, the content does not exist),
textured arena, skeletal-animated enemies, ambient music — runnable with
`perry compile src/main.ts -o main && ./main`.

## Where things live

Expand Down Expand Up @@ -197,8 +199,11 @@ editor, hit Play, and it is there — no bake step.
for hot-loop state. `.push()` can produce arrays whose `.length`
reports as the literal initial size.
- Return `{ ok: boolean, error: string }` instead of throwing.
- Prefer hardcoded constants over JSON-loaded config until the Perry
JSON.parse bug is resolved.
- JSON-loaded config is fine — the Perry `JSON.parse` bug that forced
hardcoded constants is FIXED (verified on 0.5.1208; see the world
pipeline section above, which reads the whole level from JSON at
runtime). What still binds is WHERE you parse: at load, never on a
per-frame path.
- Data crossing the FFI on a hot path is numbers, not delimited
strings (perry-quirks #5). Strings that do cross are drawn whole,
never parsed.
Expand Down
38 changes: 26 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ Controls:

| Input | Action |
|------------------|-------------------------------------------|
| WASD | Move |
| WASD | Move (the body turns to face where it moves) |
| Mouse | Look |
| Space | Jump |
| Left mouse | Fire (rifle = full-auto, blaster = tap) |
| 1 / 2 | Switch weapon (rifle / blaster) |
| Right mouse | Aim down sights (hold, or toggle — settings) |
| Shift | Sprint (hold, or toggle — settings) |
| C | Crouch (hold) |
| Ctrl | Dodge |
| 1 / 2 / 3 / 4 | Switch weapon (rifle / blaster / chaingun / cannon) |
| Q | Cycle weapon |
| R | Reload / restart after death |
| Tab | Toggle cursor capture |
| F3 | Profiler overlay (per-pass CPU/GPU µs) |
| F12 | Screenshot to `shooter_<N>.png` |
| Esc | Quit |
| F5–F9 | Render-pass debug toggles / path tracing |
| F12 | Screenshot (engine-native; see EN-038) |
| Esc | Pause (menu) — **not** quit; QUIT is a menu row |

### On an iPhone

Expand All @@ -54,8 +60,10 @@ actually rests on.
Requires a paid Apple developer account (Perry's signing flow is App Store
Connect API-only). See `CLAUDE.md` for the provisioning details.

Survive three waves — dretches, mantises, marauders, dragoons, and a
tyrant. Ammo crates respawn at the four arena corners.
Survive three waves — dretches, mantises, marauders, dragoons, a tyrant, and the
advanced marauder and dragoon (seven kinds; the two advanced ones are RANGED, so
closing the distance stops being the universal answer). Ammo crates respawn at
the four arena corners.

## Prerequisites

Expand Down Expand Up @@ -135,12 +143,18 @@ Short version of findings accumulated while building this:
are plain inverses of the resulting world matrices. Conjugating every
joint individually (a reasonable-looking alternative) conflicts with
bloom's Y-axis yaw that the renderer applies on top of skin matrices.
- **Perry 0.5.158 has three codegen bugs** we worked around: reachable
`throw new Error` segfaults at startup; object-shorthand returns
(`return { ok, errors }`) can corrupt boolean fields; `JSON.parse`
arrays have no populated `.length`. World data is hardcoded in TS
until JSON.parse is fixed. Details in
[docs/perry-quirks.md](docs/perry-quirks.md).
- **Perry has a running list of codegen bugs** we work around — and the list
has moved, so treat any specific version number here as history rather than
as current advice. Fixed since: reachable `throw new Error` segfaulting at
startup, and `JSON.parse` arrays reporting no `.length`. **World data is NOT
hardcoded any more** — `src/world-runtime.ts` reads
`assets/worlds/*.world.json` at startup via the engine's `loadWorld`, there is
no `src/generated/`, and there is no bake step. Still live: object-shorthand
returns can corrupt fields; never `split()`/`parseFloat()` an FFI string on a
per-frame path; some small numeric functions in imported modules silently
compile to a constant. The authority is
[docs/perry-quirks.md](docs/perry-quirks.md) — this bullet is a pointer, not a
second copy of it.
- **Bloom's deferred pipeline had a debug-green hardcode** in
`fs_main_3d` that made all 3D geometry render solid green under
Perry-compiled builds on macOS. Fixed upstream in engine commit
Expand Down
111 changes: 109 additions & 2 deletions docs/tickets.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,16 @@ is what makes 30 seconds of footage read as AAA.

---

## SH-027 — Real weapon models ✅ *(shipped — Unvanquished MD3 → glTF; v2 hand-bone attach still gated on EN-033)*
## SH-027 — Real weapon models ✅ *(shipped; v2 complete 2026-07-15 — hand-bone attach + the gun tilts with aim)*

> **v2 closed 2026-07-15.** This header claimed v2 was "still gated on EN-033"
> long after EN-033 landed — the weapon has ridden the `tag_weapon` hand joint
> via `jointWorld()` since then, with the fixed offset only a fallback. The last
> real gap was pitch: `drawModelRotated` takes a single Y rotation, so the barrel
> stayed level while you aimed up or down. **EN-039** (`drawModelTransform`,
> engine PR #100) landed and this now feeds it the same forward vector the
> muzzle, tracer and shot ray use — so the gun and the bullet agree by
> construction. Shooter PR #22.

**Why:** both weapons are **grey `drawCube` primitives**
(`main.ts:2795-2836`). This is the single most visible "placeholder"
Expand Down Expand Up @@ -842,7 +851,23 @@ frame time still ≥ 35 fps on the dev box at 4K/TSR.

---

## SH-043 — Accessibility & localization scaffolding ✅ *(shipped — `src/strings.ts`, shake slider, colourblind-safe hit cues)*
## SH-043 — Accessibility & localization scaffolding 🟡 *(partial — see below; the ✅ was wrong)*

> **Corrected 2026-07-15.** This header read
> *"✅ shipped — `src/strings.ts`, shake slider, colourblind-safe hit cues"*.
> **`src/strings.ts` has never existed** and there is not one `STRINGS`
> reference in `src/` — verified, not inferred. `docs/aaa-round-1.md` had the
> honest version all along ("Partial. Localization table not done."). This was
> the registry's only false ✅, and it is exactly the kind that costs someone a
> day: you go looking for a file the docs promise and it isn't there.
>
> | Part | Status |
> |---|---|
> | Camera-shake slider | ✅ shipped, wired to `FEEL.setShakeScale` |
> | Colourblind-safe hit cues | ✅ shipped — and **unconditional**: the white flash + tick and the damage arc REPLACED the colour-only versions. The `COLORBLIND CUES` settings row was therefore switching nothing and was removed 2026-07-15. |
> | Telegraph captions | ✅ shipped 2026-07-15 — the dragoon's roar IS the dodge window; a player who cannot hear it was told nothing. |
> | **Localization table (`strings.ts`)** | ❌ **NOT DONE** — every user-facing literal is still inline. |
> | **Rebindable keys** | ❌ **NOT DONE** — the bind list is fixed. |

**Why:** all-or-nothing camera shake, red-only damage cues, hardcoded
English literals, fixed keybinds. Cheap now, expensive to retrofit.
Expand Down Expand Up @@ -1254,3 +1279,85 @@ glTF, and redistributable in a GPL repo**.
**Reconsider first, honestly:** with the uplift landed, the battlesuit now reads as
real armour. It may no longer be the weakest thing in frame. Look at it before
spending on a replacement.

---

## SH-047 — The body turns to face where it moves ✅ *(shipped 2026-07-15)*

**Why:** the player model always faced camera-forward, so strafing read as the
character sliding sideways while staring ahead — and the forward walk clip's feet
pointed the wrong way relative to the travel.

**What shipped.** The facing offset is `atan2(moveX, -moveZ)` — literally the
movement direction relative to the camera. The requested angles fall straight out
of it because WASD already quantises movement: A/D → 90°, the W-diagonals → 45°,
W or idle → forward. Eased along the shortest arc (so S+A → S+D turns the near
way), and kept as an offset RELATIVE to the camera so looking around does not drag
the body with it. Aiming down sights forces forward — you are pointing the gun,
not walking.

Because the body faces its travel, the forward walk/run/crouch clips are always
the correct clip: the directional strafe clips the model ships (`run_left`,
`crouch_right`, …) are not needed.

**Verified** with a forced strafe + the HUD readout: D moved the player to world
+X with the body in right-facing profile; W+D moved it on a true 45° diagonal.

---

## SH-048 — Crouch 🟡 *(shipped 2026-07-15 — speed + pose; capsule NOT resized)*

**Why:** the only speed states were walk and sprint. Crouch is the third — the
deliberate, quiet one.

**What shipped.** Hold **C** (Ctrl was already dodge). `CROUCH_MUL` 0.45 takes
4.5 → ~2.0 m/s, and the crouch clips play (`crouch` idle / `crouch_forward`
moving — one clip covers every heading thanks to SH-047). Crouch **wins over
sprint**: you cannot do both, and holding crouch is the more deliberate intent.

**Outstanding, stated plainly:** the physics capsule is **not** resized, so crouch
does not shrink the hitbox and does not let you fit under low geometry. Harmless
today — the arena has none — but it means crouch is not yet a *tactical* choice,
only a movement one. Needs `setCharacterShape`. No touch or gamepad binding yet.

**Verified** through the real C key (not a forced flag): 4.50 → **2.03 m/s**,
`crouch=1`, mutually exclusive with sprint.

---

## SH-049 — Front end: splash, loading screen, main menu ✅ *(shipped 2026-07-14)*

> **Numbering note.** This work was written as "SH-046" in the code and collided
> with the existing SH-046 (character fidelity) — two different tickets, one
> number. Renumbered to SH-049 on 2026-07-15; the code comments now match.

**Why:** boot was a **black, frozen window for ~8 seconds**, then a snap to a
"press any key" title. That freeze is the first thing a player ever experiences
and it read as a hang.

**What shipped** (`src/boot.ts`, `MENU_MAIN` in `menu.ts`):
- **Splash** — owns its own loop, so it is the one part of boot that animates
smoothly. Skippable; a splash you cannot skip is one you resent by the third
launch.
- **Loading screen with REAL progress.** There is no async loader (EN-032), so
nothing pretends: `bootStage()` is called between groups of synchronous loads
and draws a frame each time. Weights are **measured milliseconds**, not
guesses — the bar would otherwise lurch, which reads as a broken loader.
- **Main menu** — PLAY / LEVEL SELECT / SETTINGS / QUIT over the live arena,
reusing `menu.ts`'s existing focus model rather than being a second screen to
keep working. Settings and level select were previously reachable only from
*pause*, i.e. only after starting a run.
- The gameplay HUD is now gated on being in a run. It used to draw the health bar
and ammo over the menu — the tell that the "title screen" was just the game
with input switched off.

**What this measured, and it is the useful part:** boot is ~7.9 s of loading and
**`the aliens` is 5.5 s of it (61%)** — `initEnemyPool()` loads a ModelAnimation
per enemy SLOT, not per kind. That is the one number to attack if boot time is
ever worth fixing.

**Also fixed here:** an unconditional `if (isKeyPressed(Key.ESCAPE)) break;` at
the bottom of the frame loop — a dev-quit binding that predated the pause menu.
It fired on every ESC regardless of state, so Escaping out of the settings screen
*killed the process*. ESC now means pause in a run and back in a menu; QUIT is a
menu row.
2 changes: 1 addition & 1 deletion src/boot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SH-046 — the boot sequence: splash, loading screen, and the fade into the menu.
// SH-049 — the boot sequence: splash, loading screen, and the fade into the menu.
//
// The game used to open a black window, freeze for several seconds while every
// GLB, sound and shader loaded on the main thread, and then snap to a title
Expand Down
2 changes: 1 addition & 1 deletion src/combat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export function resetRun(): void {
SCORE.beginWave(0);
}

/// SH-046 — leave the main menu and drop into the arena. This was inline in the
/// SH-049 — leave the main menu and drop into the arena. This was inline in the
/// old "press any key" title screen; the main menu's PLAY row calls it now.
///
/// The two selectWeapon() calls look redundant and are not: selecting a second
Expand Down
6 changes: 3 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const cliDbgOff = cliArg('--dbg-off');
FEEL.setShakeScale(SET.get(SET.SET_SHAKE));
initMenus();

// ---- SH-046: the boot sequence ---------------------------------------------
// ---- SH-049: the boot sequence ---------------------------------------------
// Everything below this line until `bootOutro()` is the game LOADING, and until
// now it did that behind a black, frozen window for several seconds. The splash
// runs first (it owns its own loop, so it is the one part of the boot that can
Expand Down Expand Up @@ -1034,7 +1034,7 @@ let aitestDone = false;
//
// MUST be false in shipped builds: it suppresses every enemy wave.
// ---- MENUTEST harness (temporary diagnostic) --------------------------------
// SH-046 verification. Drives the front-end through the REAL input path
// SH-049 verification. Drives the front-end through the REAL input path
// (injectKeyDown — OS-synthesised keys do not reach this game) and records what
// the menu actually did:
//
Expand Down Expand Up @@ -1774,7 +1774,7 @@ while (!windowShouldClose() && !aitestDone && !animDbgDone) {
// startRun() bypasses the menu's ACT_PLAY handler, and that handler is
// the only place that sets cursorLocked — without this the fire path
// (gated on cursorLocked) never triggers and the harness silently
// captures a fight with no shots. Broken since SH-046 put a real menu
// captures a fight with no shots. Broken since SH-049 put a real menu
// in front of the game.
cursorLocked = true;
SCORE.resetScore();
Expand Down
2 changes: 1 addition & 1 deletion src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const MENU_NONE = 0;
export const MENU_PAUSE = 1;
export const MENU_SETTINGS = 2;
export const MENU_LEVELS = 3;
/// SH-046 — the front door. Same rows, same focus model, same draw code as the
/// SH-049 — the front door. Same rows, same focus model, same draw code as the
/// pause menu: a main menu that is a SEPARATE screen is a second thing to keep
/// working, and it always rots.
export const MENU_MAIN = 4;
Expand Down