The game gets a shell: it buzzes in your hand, never turns sideways, and a swipe walks you back

A Capacitor iOS project (`npm run ios`), and the first three bullets of LATER item 2 built. The web
build is untouched and stays primary: every line here degrades to a no-op in a browser.

- HAPTICS ride the sound map, as the plan said they would: six call sites, all of them places an
  sfx already fires — your racket contact, the serve strike, `killZombie` (the one funnel every
  weapon routes through), `explode`, `hurtPlayer`, `killPlayer`, the weapon swap. The restraint
  rule lives in `haptics.js`, not at the call sites: a minimum gap per kind, so a bomb that kills
  twelve bodies is felt twice and the machine gun is a metronome rather than one long rumble.
  Pinned by a test. Untested on hardware — the simulator has no Taptic Engine.
- The plugin is reached through the INJECTED bridge, `Capacitor.nativePromise('Haptics', ...)`.
  `Capacitor.Plugins.Haptics` reads like the zero-dependency answer and is silently empty without
  @capacitor/core's registerPlugin — it would have fallen through to the web path inside the app
  and looked exactly like a phone with haptics switched off. No package JS ships.
- PORTRAIT is enforced where it can be: one orientation in Info.plist, so the app never rotates.
  That retires landscape as a surface rather than fixing it (OPEN 6). The web half is best-effort —
  `orientation` in both manifests and one guarded `screen.orientation.lock()`.
- SWIPE LEFT GOES BACK. The gesture finds the screen's one visible chevron and clicks it, so the
  shape of the wizard stays in the handlers that already own it. Not passive on purpose: a swipe
  inside a poster card also fires that card's click, and preventDefault is what stops the screen
  answering forward in the frame the chevron walked it back.
- The app has no server behind it, so `config.API` and one branch in `net.js` point the shell at the
  live host by name. Consequence: the app always talks to PROD, never staging.

`node test.js` green (105) over four seeds; builds, launches and reaches the native plugin on the
iOS 26.5 simulator; swipe-back walks the whole wizard in the browser pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-20 17:00:36 +02:00
1 parent c5eb6846e3
commit d71a26831a
35 files changed
+2124 -11

No files matched your search

+75 -1
View File
@@ -20,7 +20,9 @@ anything, so if you add a `return` for an awkward deal, check the body still run
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
Last updated 2026-08-20 (the friend across the net gets his OWN sheets — the player's kit, drawn
Last updated 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call
sites, portrait enforced in Info.plist, and swipe-left-to-go-back in the menus — the web build
unchanged and still primary. See "THE SHELL". Before that, same day: the friend across the net gets his OWN sheets — the player's kit, drawn
from the front, so his pick dyes the same garment yours does; the setup screen shows that front too.
See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything
IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the
@@ -2536,6 +2538,74 @@ The gates, and the numbers they were calibrated on:
**SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found
27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet.
## THE SHELL — CAPACITOR, HAPTICS, PORTRAIT, SWIPE-BACK (2026-08-20)
The first three bullets of LATER item 2 ("What the shell buys GAMEPLAY-wise") are built. **The web
build is unchanged and stays the primary surface** — every line below degrades to a no-op in a
browser, and `splatter-tennis.wuwi.one` keeps deploying off the same repo on every push.
**The zero-dependency rule bends exactly once and exactly here.** `package.json`, `node_modules/` and
`ios/` exist for the SHELL. **No JS from any package ships.** `src/haptics.js` calls the bridge the
shell injects — `Capacitor.nativePromise('Haptics', 'impact', { style })` — never
`import ... from '@capacitor/haptics'`. There is no bundler in this project, so a bare specifier
inside a `<script type="module">` is a hard 404 the moment the same file is served from the web.
`@capacitor/haptics` stays an npm dependency for one reason only: `cap sync` installs its Swift pod.
**Anything added to the shell later has to reach the game the same way.**
> **THE TRAP, and it cost a rebuild to find.** `Capacitor.Plugins.Haptics` reads like the obvious
> zero-dependency answer and it is **silently wrong**. The injected `native-bridge.js` defines
> `Capacitor`, `isNativePlatform` and `nativePromise`, but leaves `Plugins` EMPTY — it is
> `@capacitor/core`'s `registerPlugin`, i.e. exactly the JS we are not shipping, that fills that
> object in. Going through `Plugins` therefore falls straight through to the web branch *inside the
> app*, where `navigator.vibrate` does not exist on iOS — which looks identical to a phone with
> haptics switched off, and would have been diagnosed as "the palette feels flat". Proven the only
> way worth trusting: `simctl launch --console-pty` shows `⚡️ To Native -> Haptics impact` for the
> `nativePromise` form. **That is the check to repeat for the next plugin**; `simctl spawn log
> stream` will NOT show it, because Capacitor `print()`s those lines to stdout.
- **`npm run www`** rsyncs `index.html`, the manifests, `src/` and `assets/` into `www/` with the
same `assets/_*` exclusion `.dockerignore` uses — 32 MB, not the 463 MB the sprite pipeline sits
on. `npm run sync` = tests + that copy + `npx cap sync ios`; `npm run ios` opens Xcode after it.
`www/` and `node_modules/` are gitignored; `ios/` IS committed (Capacitor's own `ios/.gitignore`
keeps the copied payload, Pods and Xcode's per-user junk out of it).
- **Portrait is enforced where it can be**: `Info.plist` lists ONE supported orientation, so the app
never rotates. That formally retires landscape as a surface (OPEN 6) rather than fixing it. The web
half is best-effort — `orientation: portrait` in both manifests and one guarded
`screen.orientation.lock()` in `main.js` — because iOS Safari has no lock at all and the browser
build keeps doing whatever landscape does today.
- **HAPTICS ride the sound map**, as planned: six call sites, all of them places an sfx already fires.
Your racket contact (light, medium on a perfect hit), the serve strike, `killZombie` — the ONE
funnel every weapon routes through — `explode`, `hurtPlayer`, `killPlayer`, and the weapon swap.
**The restraint rule is enforced in `haptics.js`, not at the call sites**: every kind carries a
minimum gap, so a bomb that kills twelve bodies is felt twice (one blast, one kill) and the machine
gun is a metronome rather than one long rumble. `test.js` pins exactly that. No mute switch on
purpose — iOS already routes these through the system's own vibration setting.
**Untested on hardware.** The simulator has no Taptic Engine, so every weight and gap below is a
guess on paper and needs its own phone round before any of it is assumed good.
- **SWIPE LEFT GOES BACK** in the menus. Every screen but the splash wears exactly one chevron, and
that button already knows where back is for its own screen — so the gesture finds the visible
`.chev` and clicks it rather than keeping a second map of the wizard that would drift the first
time a stage is added. Threshold 60 px, horizontal-dominant so the stats and board screens still
scroll. The `touchend` listener is deliberately **not passive**: a swipe that starts and ends
inside one of the big poster cards also produces that card's click, and `preventDefault` is what
stops the screen answering forward in the same frame the chevron walked it back.
**iOS convention is the opposite** (the system pop gesture drags rightward from the left edge).
Built to the brief as asked; flipping it is the sign on one comparison in `main.js`.
- **The app has no server behind it.** Inside the shell the page is served from the bundle
(`capacitor://localhost`), so `fetch('api/scores')` and the netplay socket would have resolved
against the app's own files. `config.NATIVE`/`config.API` and one branch in `net.js` point the
shell at the live host by name; the web build keeps the page-relative form, which is the only
reason localhost, staging and prod all work off one file with no switch. Consequence to remember:
**the app always talks to PROD**, never to staging.
Verified: `node test.js` green (105) across four seeds; the app builds and launches on the iOS 26.5
simulator; `isNativePlatform()` reads true inside it (so the board really does point at prod) and
both bridge calls reach the native plugin; swipe-back walks the whole wizard (mode -> modesub ->
back -> back -> splash) in the browser pane, and vertical drags, short slides and rightward swipes
correctly do nothing.
**Not verified: anything that needs a real device** — the Taptic Engine, 60 fps under WKWebView at
`RES` 4 (OPEN 1, and LATER named this as the second place that risk lands), and WKWebView audio.
## THE FRIEND ACROSS THE NET WEARS YOUR KIT, FROM THE FRONT (2026-08-20)
Oliver, on a netplay match: *"the opponent color is displayed correctly, but the shirt is colored not
@@ -3551,6 +3621,10 @@ Nothing here is broken; these are known gaps and unverified risks.
6. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive
height and the safe-area work are all portrait-only; landscape still runs the old camera and a fixed
480x270. Portrait is the stated primary target, but landscape has drifted and is untested.
**RETIRED IN THE APP, 2026-08-20** — `Info.plist` lists one orientation, so the shell never rotates
and landscape is officially not-a-surface there. The WEB build can still be turned sideways (iOS
Safari has no orientation lock), so this stays open, at a lower stake: it is now a browser-only
cosmetic gap rather than something a store reviewer will see.
7. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh
(`TUNE.modalIn`). Offered and not yet requested.
8. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever