Fly the modals in with a swoosh instead of cutting to them
m.in (0..1 over TUNE.modalIn, 0.34 s) is the entrance clock, ticked inside update()'s modal early-return — the one thing that animates while the world is frozen. Icon and title fly in from the right trailing two ghost copies as a speed streak, the body lines cascade in from the left one after another, everything on easeOutBack so it overshoots and settles rather than decelerating politely into place. The dim washes in over the first third. A descending noise sweep on arrival (swish is parameterised now — same six lines of Web Audio, opposite reading) and a low pock as it lands. Every x is rounded: a fractional x on a nearest-neighbour scaled image re-snaps its pixel columns, which is the boiling that once read as HUD flicker. Dismissal is gated on the entrance: while m.in < 1 dismissModal swallows the gesture but doesn't clear the modal, so a tap already in the air when a popup appears can't skip it — and swallowing rather than returning false is what keeps that tap from falling through into a serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
79fb43f60d
commit
4cf54603f3
6 files changed
+69
-15
No files matched your search
@@ -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,
|
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
|
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
|
||||||
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
|
touchstart underneath serve. `G.seen.<key>` 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
|
- **`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.
|
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
|
- **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to
|
||||||
|
|||||||
@@ -114,18 +114,22 @@ function noiseBuf(ac, len) {
|
|||||||
return b;
|
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 {
|
try {
|
||||||
const ac = audio(), t = ac.currentTime;
|
const ac = audio(), t = ac.currentTime;
|
||||||
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.16);
|
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur);
|
||||||
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1600, t);
|
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(f0, t);
|
||||||
bp.frequency.exponentialRampToValueAtTime(3200, t + 0.12); bp.Q.value = 1.5;
|
bp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.75); bp.Q.value = 1.5;
|
||||||
const g = ac.createGain();
|
const g = ac.createGain();
|
||||||
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.05);
|
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3);
|
||||||
g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
||||||
src.connect(bp).connect(g).connect(ac.destination); src.start();
|
src.connect(bp).connect(g).connect(ac.destination); src.start();
|
||||||
} catch (e) {}
|
} 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
|
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ export const TUNE = {
|
|||||||
// full pierce simply killed the whole lane outright and nothing was left to catch fire.
|
// 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)
|
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)
|
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
|
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
|
// 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
|
bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage
|
||||||
|
|||||||
+15
-3
@@ -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 { 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 { 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 { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
|
||||||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
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';
|
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
|
// 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
|
// try to open two in the same frame) and only ever touches G — never the zombie array it may
|
||||||
// be called from mid-iteration.
|
// 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() {
|
export function dismissModal() {
|
||||||
const m = G.modal; if (!m) return false;
|
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();
|
G.modal = null; if (m.onDismiss) m.onDismiss();
|
||||||
return true; // the caller must swallow the gesture: dismissing must never also serve
|
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;
|
if (G.boomFlash > 0) G.boomFlash -= dt;
|
||||||
// A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers,
|
// 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.
|
// 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);
|
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||||||
G.idleT += dt;
|
G.idleT += dt;
|
||||||
// "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any
|
// "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any
|
||||||
|
|||||||
+1
-1
@@ -60,6 +60,6 @@
|
|||||||
<p class="start">TAP TO START</p>
|
<p class="start">TAP TO START</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=37"></script>
|
<script type="module" src="main.js?v=38"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -420,8 +420,26 @@ function drawPullHint() {
|
|||||||
|
|
||||||
// The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update
|
// 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.
|
// 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) {
|
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';
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||||
const body = m.body || [], img = m.icon && SPR[m.icon];
|
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
|
// 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 iconH = img ? Math.min(m.iconH || 40, H * 0.28) : 0;
|
||||||
const blockH = (iconH ? iconH + 12 : 0) + 22 + body.length * 13;
|
const blockH = (iconH ? iconH + 12 : 0) + 22 + body.length * 13;
|
||||||
let y = Math.round(H / 2 - blockH / 2);
|
let y = Math.round(H / 2 - blockH / 2);
|
||||||
|
const headX = dx(0, 1); // icon and title travel together, as one masthead
|
||||||
if (img) {
|
if (img) {
|
||||||
const iw = Math.min(W - 24, iconH * img.width / img.height);
|
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;
|
y += iconH + 12;
|
||||||
}
|
}
|
||||||
ctx.font = F(14); ctx.fillStyle = m.color || UI.warm;
|
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;
|
ctx.font = F(8, 0); ctx.fillStyle = UI.cream;
|
||||||
for (const l of body) { ctx.fillText(l, W / 2, y + 6); y += 13; }
|
body.forEach((l, i) => { ctx.fillText(l, W / 2 + dx(i + 1, -1), y + 6); y += 13; });
|
||||||
if (Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy
|
// 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.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));
|
ctx.fillText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user