diff --git a/HANDOFF.md b/HANDOFF.md index 169f31b..65ed409 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -52,6 +52,15 @@ zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is t it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch, pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the touchstart underneath serve. `G.seen.` gates first-time-only modals. + **`m.in` (0..1 over `TUNE.modalIn`) is the arcade entrance** and the ONE thing that animates behind + the freeze — it is ticked inside that same early-return, so it advances while nothing else does. + Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the + left, all on `easeOutBack`; every x is `Math.round`ed, because a fractional x on a nearest-neighbour + scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with no + `in` field draws fully landed, so older/test call sites still work. + While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but + won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there + would drop that tap through into a serve instead. - **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. - **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to diff --git a/audio.js b/audio.js index b09c9da..9685a2c 100644 --- a/audio.js +++ b/audio.js @@ -114,18 +114,22 @@ function noiseBuf(ac, len) { return b; } -export function swish(vol = 0.3) { // racket cutting air — the sound of a whiff +// Filtered noise sweep. Default = the racket cutting air (rising, short) — the sound of a whiff. +// The sweep is parameterised because a DESCENDING, longer one is a panel swooshing in and settling: +// same six lines of Web Audio, opposite reading. See whoosh() below. +export function swish(vol = 0.3, f0 = 1600, f1 = 3200, dur = 0.16) { try { const ac = audio(), t = ac.currentTime; - const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.16); - const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1600, t); - bp.frequency.exponentialRampToValueAtTime(3200, t + 0.12); bp.Q.value = 1.5; + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur); + const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(f0, t); + bp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.75); bp.Q.value = 1.5; const g = ac.createGain(); - g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.05); - g.gain.exponentialRampToValueAtTime(0.001, t + 0.16); + g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); src.connect(bp).connect(g).connect(ac.destination); src.start(); } catch (e) {} } +export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock try { diff --git a/config.js b/config.js index a7c25a7..5d5e747 100644 --- a/config.js +++ b/config.js @@ -105,6 +105,9 @@ export const TUNE = { // full pierce simply killed the whole lane outright and nothing was left to catch fire. burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting) setCardT: 2.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde) + modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the + // left, each on easeOutBack. The gesture that dismisses is swallowed until it + // lands, so a panic tap can't skip a popup before it has been read. bruteStride: 0.5, // m the brute covers per lurch frame — it is the slowest thing on court, so // its cycle is driven by ground covered rather than by the clock bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage diff --git a/gameplay.js b/gameplay.js index adb6cb4..d6956bb 100644 --- a/gameplay.js +++ b/gameplay.js @@ -2,7 +2,7 @@ import { TUNE, UI, APPLAUSE_T, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; -import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js'; +import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js'; import { axisX, axisZ, takeFlickAim, touchPX } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateZombies, spawnZombie, spawnRacketZombie, zombies, gore, explode, meleeSwipe, extras, parts, pellets, playerGore } from './zombies.js'; @@ -226,9 +226,16 @@ function scoreKey() { // on a timer, so a popup can be READ. showModal never clobbers an open one (a kill cascade can // try to open two in the same frame) and only ever touches G — never the zombie array it may // be called from mid-iteration. -export function showModal(m) { if (!G.modal) G.modal = m; } +export function showModal(m) { + if (G.modal) return; + m.in = 0; G.modal = m; whoosh(); // 0..1 entrance clock, ticked in update() behind the freeze +} export function dismissModal() { const m = G.modal; if (!m) return false; + // Still flying in? Eat the gesture but DON'T dismiss. A modal you haven't read yet must not be + // skippable by the same panic tap that was mid-air when it appeared — and returning false here + // would let that tap fall through into a serve, which is the swallowed-gesture bug all over again. + if (m.in < 1) return true; G.modal = null; if (m.onDismiss) m.onDismiss(); return true; // the caller must swallow the gesture: dismissing must never also serve } @@ -798,7 +805,12 @@ export function update(dt) { if (G.boomFlash > 0) G.boomFlash -= dt; // A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers, // so nobody gets eaten while reading a popup. idleT keeps running for the blink. - if (G.modal) { G.idleT += dt; return; } + if (G.modal) { + G.idleT += dt; + const m = G.modal; // the entrance is the ONE thing that animates behind the freeze + if (m.in < 1 && (m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud + return; + } crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; // "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any diff --git a/index.html b/index.html index b2cfe2c..0462428 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@

TAP TO START

- + diff --git a/render.js b/render.js index 58074e2..7bf83ac 100644 --- a/render.js +++ b/render.js @@ -420,8 +420,26 @@ function drawPullHint() { // The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update // early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away. +// easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet +// menu ever arrived by decelerating politely into place. +const BACK_C = 1.70158; +const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2; + function drawModal(m) { - ctx.fillStyle = 'rgba(8,6,10,0.82)'; ctx.fillRect(0, 0, W, H); + // The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and + // title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each + // on its own staggered easeOutBack. Every x is ROUNDED — a fractional x on a nearest-neighbour + // scaled image re-snaps its pixel columns every frame, which is the same boiling that once read + // as HUD flicker. Legacy/test modals with no `in` field draw fully landed. + const t = m.in === undefined ? 1 : m.in; + const STAG = 0.08, RUN = 0.62; // per-element delay, and travel time + const lane = k => { // 0 = off screen, 1 = home (may overshoot) + const p = Math.max(0, Math.min(1, (t - k * STAG) / RUN)); + return p >= 1 ? 1 : easeBack(p); + }; + const dx = (k, from) => Math.round(from * (1 - lane(k)) * W * 0.85); // from: -1 left, 1 right + ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; // the dim washes in first + ctx.fillRect(0, 0, W, H); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const body = m.body || [], img = m.icon && SPR[m.icon]; // cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a @@ -429,16 +447,24 @@ function drawModal(m) { const iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0; const blockH = (iconH ? iconH + 12 : 0) + 22 + body.length * 13; let y = Math.round(H / 2 - blockH / 2); + const headX = dx(0, 1); // icon and title travel together, as one masthead if (img) { const iw = Math.min(W - 24, iconH * img.width / img.height); - ctx.drawImage(img, Math.round(W / 2 - iw / 2), y, Math.round(iw), iconH); + ctx.drawImage(img, Math.round(W / 2 - iw / 2) + headX, y, Math.round(iw), iconH); y += iconH + 12; } ctx.font = F(14); ctx.fillStyle = m.color || UI.warm; - ctx.fillText(m.title, W / 2, y + 8); y += 22; + if (headX) for (const [f, a] of [[0.7, 0.12], [0.35, 0.26]]) { // speed streak: ghosts strung out behind it + ctx.save(); ctx.globalAlpha = a; + ctx.fillText(m.title, W / 2 + Math.round(headX * (1 + f)), y + 8); + ctx.restore(); + } + ctx.fillText(m.title, W / 2 + headX, y + 8); y += 22; ctx.font = F(8, 0); ctx.fillStyle = UI.cream; - for (const l of body) { ctx.fillText(l, W / 2, y + 6); y += 13; } - if (Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy + body.forEach((l, i) => { ctx.fillText(l, W / 2 + dx(i + 1, -1), y + 6); y += 13; }); + // the prompt only exists once everything has landed — offering the tap mid-flight would be a lie, + // dismissModal swallows it until then + if (t >= 1 && Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy ctx.font = F(8); ctx.fillStyle = UI.warm; ctx.fillText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22)); }