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
@@ -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));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user