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>
55 lines
3.3 KiB
JavaScript
55 lines
3.3 KiB
JavaScript
// The Taptic Engine, behind one call. iOS Safari exposes no vibration API of any kind, so on the WEB
|
|
// build this is navigator.vibrate on Android and a silent no-op on iPhone — the feature is really
|
|
// only alive inside the Capacitor shell, which is the point (PROJECT.md, "What the shell buys").
|
|
//
|
|
// Reached through the bridge the shell INJECTS — `Capacitor.nativePromise('Haptics', ...)` — and not
|
|
// through `import { Haptics } from '@capacitor/haptics'`. Two reasons, and the second is the one
|
|
// that bites:
|
|
// 1. This project has no bundler and no build step, so a bare specifier inside a
|
|
// <script type="module"> is a hard 404 the moment the same file is served on the web.
|
|
// 2. `Capacitor.Plugins.Haptics` LOOKS like the zero-dependency answer and is not: the injected
|
|
// native-bridge.js declares `Capacitor` and `nativePromise` but leaves `Plugins` empty — it is
|
|
// @capacitor/core's registerPlugin, i.e. JS we deliberately do not ship, that fills it in. Going
|
|
// through Plugins therefore silently falls through to the web path INSIDE the app, which looks
|
|
// exactly like a phone whose haptics are switched off. `nativePromise` needs no JS package;
|
|
// @capacitor/haptics stays an npm dependency only so `cap sync` installs its Swift pod.
|
|
//
|
|
// THE MAP IS THE SOUND MAP. The sfx taxonomy already decided which moments in this game deserve
|
|
// punctuation, so the haptics ride the same handful of call sites instead of inventing a second
|
|
// opinion about what matters. Restraint rule: contact is a tick, kills punctuate, heavy is rare — a
|
|
// game that buzzes continuously numbs the hand inside a minute and then means nothing at all.
|
|
//
|
|
// That rule is enforced HERE rather than at the call sites, by a minimum gap per kind. The machine
|
|
// gun may reach `haptic('medium')` twenty times a second and a bomb may kill twelve bodies in one
|
|
// frame; the hand still feels a metronome instead of one long rumble, and no caller has to know it.
|
|
//
|
|
// No mute switch on purpose: iOS already routes every one of these through the system's own
|
|
// vibration setting, so a player who has turned haptics off has turned THESE off.
|
|
|
|
const KINDS = {
|
|
select: { gap: 0.05, impact: 'LIGHT', ms: 8 }, // a menu answer, a weapon swapped
|
|
light: { gap: 0.06, impact: 'LIGHT', ms: 12 }, // racket contact — the metronome of a rally
|
|
medium: { gap: 0.08, impact: 'MEDIUM', ms: 20 }, // a body comes apart
|
|
heavy: { gap: 0.13, impact: 'HEAVY', ms: 35 }, // a blast, a beat, a hit you took
|
|
dead: { gap: 1.0, notify: 'ERROR', ms: [0, 40, 90, 40, 90, 110] }, // eaten — once a run, if that
|
|
};
|
|
const at = Object.create(null); // last fire per kind, ms
|
|
|
|
export function haptic(kind) {
|
|
const k = KINDS[kind];
|
|
if (!k) return;
|
|
const now = performance.now();
|
|
if (now - (at[kind] ?? -1e9) < k.gap * 1000) return;
|
|
at[kind] = now;
|
|
const cap = globalThis.Capacitor;
|
|
try { // a buzz is never worth a thrown frame
|
|
if (cap?.isNativePlatform?.())
|
|
cap.nativePromise('Haptics', k.notify ? 'notification' : 'impact',
|
|
k.notify ? { type: k.notify } : { style: k.impact })?.catch?.(() => {});
|
|
else globalThis.navigator?.vibrate?.(k.ms);
|
|
} catch {}
|
|
}
|
|
|
|
// test hook: what the throttle would let through, without a platform under it
|
|
export const hapticTest = { KINDS, at };
|