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:
1 parent
c5eb6846e3
commit
d71a26831a
35 files changed
+2124
-11
No files matched your search
@@ -1,6 +1,17 @@
|
||||
// All tuning and constants. TUNE is live-editable in game via the T panel;
|
||||
// the exported consts below are structural values that code shares but the panel doesn't show.
|
||||
|
||||
// THE APP HAS NO SERVER BEHIND IT. Inside the Capacitor shell the page is served out of the app
|
||||
// bundle (capacitor://localhost), so every page-relative call — the world board, the netplay socket
|
||||
// — would resolve against the app's own files and 404. The shell therefore talks to the live host
|
||||
// BY NAME; the web build keeps the page-relative form, which is the only reason staging, localhost
|
||||
// and prod all work off one file with no switch. Consequence worth remembering: THE APP ALWAYS TALKS
|
||||
// TO PROD, never to staging. Deliberately no `location` here — this module is evaluated by the
|
||||
// headless test suite, which has no DOM; net.js reads the protocol inside its connect function.
|
||||
export const NATIVE = !!globalThis.Capacitor?.isNativePlatform?.();
|
||||
export const HOST = 'splatter-tennis.wuwi.one';
|
||||
export const API = NATIVE ? 'https://' + HOST + '/api/' : 'api/';
|
||||
|
||||
export const TUNE = {
|
||||
camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport
|
||||
gravity: 9.2, // slightly floaty vs 9.8 for readability
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, CUP_ROUNDS } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
|
||||
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
||||
import { haptic } from './haptics.js';
|
||||
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
|
||||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||||
import { updateDrill } from './drill.js';
|
||||
@@ -273,6 +274,9 @@ function doHit(ch, quality, aim) {
|
||||
// the court stays clean)
|
||||
if (ch.side === 'P') G.aiReactT = 0; // CPU hasn't read the shot yet
|
||||
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side
|
||||
// CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not
|
||||
// something your hand did, and buzzing for both halves of every rally is the numbing failure mode.
|
||||
if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one
|
||||
if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs)
|
||||
G.hitstop = 0.05; pock(0.5, 1600);
|
||||
const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact
|
||||
@@ -512,6 +516,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
||||
}
|
||||
// (spendSlot already hands you back plain balls when the last one goes)
|
||||
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
|
||||
if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit
|
||||
sv.pose = 'serve'; sv.poseT = 0;
|
||||
if (perfect && sv.side === 'P') {
|
||||
G.hitstop = 0.05;
|
||||
@@ -807,6 +812,7 @@ export function selectWeapon(i) {
|
||||
}
|
||||
G.weapon = i; // temp has just been cleared, and then it is BALL again)
|
||||
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
|
||||
haptic('select'); // ...and it is felt, which is what a physical rack would do
|
||||
}
|
||||
|
||||
// Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides
|
||||
@@ -984,6 +990,7 @@ function transition(dt) {
|
||||
// rather than as a glitch — the eye is given something to blame the change on.
|
||||
once('turn', 3.4, () => {
|
||||
G.hitstop = 0.16; // the world holds its breath
|
||||
haptic('heavy'); // ...and the hand feels the world stop
|
||||
G.boomFlash = 0.16; G.shake = { t: 0.7, mag: 7 };
|
||||
shriek(); splat(0.9); boom();
|
||||
for (let k = 0; k < 40; k++) parts.push({ // he bursts, and what stands up is not him
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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 };
|
||||
+39
-1
@@ -12,6 +12,7 @@ import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './ren
|
||||
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
||||
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
|
||||
import { cv } from './dom.js';
|
||||
import { haptic } from './haptics.js';
|
||||
import * as stats from './stats.js';
|
||||
import './input.js';
|
||||
|
||||
@@ -45,6 +46,14 @@ function frame(now) {
|
||||
update(dt); render();
|
||||
}
|
||||
|
||||
// PORTRAIT ONLY, from 2026-08-20. The app enforces it where it can actually be enforced — the
|
||||
// shell's Info.plist lists one supported orientation, so an iPhone running the store build simply
|
||||
// never rotates. This line is the WEB half of the same decision and is best-effort by nature:
|
||||
// Chrome/Android honours a lock once the page is installed or fullscreen, and iOS Safari has no
|
||||
// Screen Orientation lock at all, so the browser build keeps doing whatever landscape does today
|
||||
// (PROJECT.md OPEN 6 — landscape is untested, not a surface). Nothing downstream may assume it took.
|
||||
try { screen.orientation?.lock?.('portrait')?.catch(() => {}); } catch {}
|
||||
|
||||
fitViewport();
|
||||
// iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env()
|
||||
// safe-area inset once, against a provisional geometry, and never revisits them until the geometry
|
||||
@@ -528,6 +537,35 @@ document.getElementById('sBack').addEventListener('click', () => {
|
||||
});
|
||||
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
|
||||
|
||||
// SWIPE TO GO BACK. Every screen but the splash wears exactly one chevron in its header (.hdr in
|
||||
// index.html) and that button ALREADY knows where back is for its own screen — the wizard's shape
|
||||
// lives in the handlers above and nowhere else. So the gesture finds the one visible chevron and
|
||||
// clicks it, rather than keeping a second map that would drift the first time a stage is added.
|
||||
// A screen with no chevron (splash, newbest) has nowhere to go and the swipe is simply ignored.
|
||||
// Touch only: a trackpad two-finger scroll is not a swipe and must not navigate.
|
||||
const SWIPE_MIN = 60; // px of travel before it counts — a tap that slides is not a swipe
|
||||
let swipe = null;
|
||||
intro.addEventListener('touchstart', e => {
|
||||
swipe = e.touches.length === 1 ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null;
|
||||
}, { passive: true });
|
||||
intro.addEventListener('touchend', e => {
|
||||
const from = swipe; swipe = null;
|
||||
if (!from || e.touches.length) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - from.x, dy = t.clientY - from.y;
|
||||
// leftward, and clearly horizontal: the stats and board screens scroll vertically under the finger
|
||||
if (dx > -SWIPE_MIN || Math.abs(dy) > Math.abs(dx) * 0.7) return;
|
||||
const back = [...intro.querySelectorAll('.chev')].find(b => b.offsetParent); // the one on screen
|
||||
if (!back) return;
|
||||
// NOT passive, and this is why: a swipe that starts and ends inside one of the big poster cards
|
||||
// still produces that card's click, which would answer the screen forward in the same frame the
|
||||
// chevron walked it back. preventDefault on touchend cancels the compatibility mouse events, so
|
||||
// the gesture means one thing only.
|
||||
e.preventDefault();
|
||||
haptic('select');
|
||||
back.click();
|
||||
});
|
||||
|
||||
// ---- THE BACK ROOM: five taps in the TOP-LEFT CORNER of the splash (#hot, an invisible box in
|
||||
// index.html), and nothing on the screen says so. It replaces the triple-tap on the in-play
|
||||
// scoreboard, which sat on top of the miss-log tap and which the testteam kept opening by accident.
|
||||
@@ -639,7 +677,7 @@ const tick = setInterval(function watchLoad() {
|
||||
// real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme
|
||||
// is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has
|
||||
// guard), so coming back from a run picks up the track already playing rather than restarting it.
|
||||
intro.addEventListener('pointerdown', () => menuMusic());
|
||||
intro.addEventListener('pointerdown', e => { menuMusic(); if (e.target.closest('button')) haptic('select'); });
|
||||
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
|
||||
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
|
||||
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
|
||||
|
||||
+3
-2
@@ -17,7 +17,7 @@
|
||||
//
|
||||
// ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded
|
||||
// plan calls reconnection "the bulk of the real work" and a prototype must not start there.
|
||||
import { COURT, UI, KITS, SKINS, TOURS } from './config.js';
|
||||
import { COURT, UI, KITS, SKINS, TOURS, NATIVE, HOST } from './config.js';
|
||||
import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin, tour } from './state.js';
|
||||
import { pointTo, fault, tossBall } from './gameplay.js';
|
||||
import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module
|
||||
@@ -239,7 +239,8 @@ export function netConnect(code, death = false, courtId = null) {
|
||||
// joining carries only the code — the host's room already knows the mode and the court
|
||||
const q = code ? ['room=' + encodeURIComponent(code)]
|
||||
: [death ? 'death=1' : '', courtId ? 'court=' + encodeURIComponent(courtId) : ''].filter(Boolean);
|
||||
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host
|
||||
const url = (NATIVE ? 'wss://' + HOST // the app: the live host, by name
|
||||
: (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host)
|
||||
+ '/ws' + (q.length ? '?' + q.join('&') : '');
|
||||
ws = new WebSocket(url);
|
||||
ws.onmessage = e => { let m; try { m = JSON.parse(e.data); } catch { return; } netMsg(m); };
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@
|
||||
// knows nothing about stats — fine today, and exactly the kind of coupling that breaks quietly the
|
||||
// first time the reset order changes. endRun() is the only thing that empties this bag.
|
||||
|
||||
import { WEAPONS, ENEMIES } from './config.js';
|
||||
import { WEAPONS, ENEMIES, API } from './config.js';
|
||||
|
||||
const KEY = 'splatterRuns';
|
||||
// ponytail: the array is capped, so "ALL TIME" means "your last CAP runs". At ~170 bytes a record
|
||||
@@ -197,7 +197,7 @@ let last = null;
|
||||
export function lastRun() { const r = last; last = null; return r; }
|
||||
|
||||
export async function board(mode = MODE_GORE) {
|
||||
const r = await fetch('api/scores?m=' + mode, { cache: 'no-store' });
|
||||
const r = await fetch(API + 'scores?m=' + mode, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('board ' + r.status);
|
||||
return (await r.json()).scores || [];
|
||||
}
|
||||
@@ -221,7 +221,7 @@ export const saveName = s => {
|
||||
export async function submit(rawName, k, g, mode = MODE_GORE) {
|
||||
const name = saveName(rawName);
|
||||
if (!name) throw new Error('empty name');
|
||||
const r = await fetch('api/scores', { method: 'POST',
|
||||
const r = await fetch(API + 'scores', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) });
|
||||
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
||||
import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js';
|
||||
import { haptic } from './haptics.js';
|
||||
import { drillKill, drillCaught } from './drill.js';
|
||||
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
|
||||
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
|
||||
@@ -266,6 +267,7 @@ export function hurtPlayer(n) {
|
||||
if (G.mode !== 'splatter' || G.hurtT > 0 || G.dodgeI > 0 || G.mode === 'eaten') return;
|
||||
G.hp -= n;
|
||||
G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3;
|
||||
haptic('heavy'); // taking one is rare (hurtGrace gates it) and is the loudest thing the hand gets
|
||||
G.shake = { t: 0.22, mag: 4 };
|
||||
splat(0.7); aww(0.5);
|
||||
for (let k = 0; k < 14; k++) parts.push({ // he takes it in the chest
|
||||
@@ -292,6 +294,7 @@ export function killPlayer() {
|
||||
splat(1);
|
||||
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans
|
||||
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
|
||||
haptic('dead'); // the notification pattern, once a run — the only place it is ever used
|
||||
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
|
||||
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
|
||||
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; G.gunId = null; // everything stops
|
||||
@@ -485,6 +488,7 @@ function bossDeath(zb) {
|
||||
|
||||
function killZombie(i, quiet) {
|
||||
const zb = zombies[i];
|
||||
haptic('medium'); // every weapon routes through here, so the kill is felt exactly once per kill
|
||||
// The drill's ONE rule, read at the single point every weapon routes through — hooking the ball
|
||||
// path instead would have the swish, the pellets and the blast silently exempt from the order.
|
||||
const mul = GORE_MUL[zb.variant] || 1;
|
||||
@@ -856,6 +860,7 @@ function tickHorde(dt) {
|
||||
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
|
||||
export function explode(x, z, why = 'bomb') {
|
||||
CAUSE = why; // the rocket detonates through here too, and it is its own row
|
||||
haptic('heavy'); // the blast, before the kills it causes — heavy and medium throttle separately
|
||||
for (let i = zombies.length - 1; i >= 0; i--) { // backwards: splatter() splices (and can sweep) the array
|
||||
const zb = zombies[i];
|
||||
if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue;
|
||||
|
||||
Reference in new issue
Block a user