Give him a sidestep, and split the messages into two voices
He died at every chapter boss because he could not move, and there were two independent reasons for it. The movement block is 'rally'-only — a settled feel decision the six boss AI styles were written around — and a boss is fought from 'pretoss'. On top of that canAimServe() is true through a boss fight, so his one finger was already spent on the slingshot: there was no gesture left over that could have meant "move". So it is a SIDESTEP, not a walk. One committed step on a cooldown, carrying invulnerability that beats both damage channels — the chip damage in hurtPlayer and the instant-death contact test. The rule about walking stands untouched. The gesture is picked by WHICH WAY the drag leaves the deadzone, decided once and never revisited for that touch: down-ish is the pull the game already teaches, sideways is the step. No timing window, no invisible mode, so "one gesture means one thing" still holds. The finger-steer modes are untouched — stick.aimable is false in a horde, so the drag is the absolute steer it always was. The apron demo now alternates PULL DOWN and SWIPE TO DODGE, and Marvin has a page for it. The rest of the playtest list: - Bangers is bundled beside Silkscreen, and the register IS the category. The quiet face keeps the HUD, the rack, the control hints and RESUMING IN; the loud one takes SET, CHAPTER, EPIC!, PERFECT and every free-floating flash. Floating text gets one shared y and a drop shadow, so it survives crowd, clay and blood. - Modals queue instead of being dropped. That was why the trainer and the weapon had to be crammed into one card after a won duel; the heal gets its own popup with the x_cross tin over it — a sprite loaded since that merge and drawn by nothing. - weaponModal's first-sight gate moved inside the function. The barrels gated themselves and the tin ladder did not, so four weapons re-explained themselves every three games while a barrel weapon explained itself once. - Ten rack slots. Past five it grows a second row upward rather than shrinking ten tiles under the thumb target; slots 0-4 stay welded to the bottom edge. - The boss win holds for its own 3.6s and says EPIC!, with no GAME n NEXT line — the bar under the scoreboard already prints that permanently. That bar went from 6px to 7px, which is the smallest type in the game becoming words. - The turn punches the camera in on him and the ground shakes harder at each beat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99774ab4be
commit
55c0f75c37
10 files changed
+345
-95
No files matched your search
@@ -1,7 +1,7 @@
|
||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||
|
||||
import { cv, ctx } from './dom.js';
|
||||
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS } from './config.js';
|
||||
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court } from './state.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
|
||||
@@ -31,7 +31,22 @@ function palette() {
|
||||
// Every string on the canvas goes through this. Bare `monospace` resolves to Courier — a slab
|
||||
// SERIF — which is what made the HUD look generic; Silkscreen is the bundled pixel face.
|
||||
const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`;
|
||||
// ...and the ANNOUNCEMENT face. Two registers, and which one a message uses is its category:
|
||||
// Silkscreen — the HUD, the rack, the control hints, RESUMING IN. Things you read at leisure.
|
||||
// Bangers — SET, CHAPTER, EPIC!, PERFECT, GAME N. Things shouted at you.
|
||||
// It was one face for everything, so a milestone and a control hint looked like the same object at
|
||||
// two sizes. Falls back to Silkscreen (not monospace) if the TTF ever fails to load.
|
||||
const B = sz => `400 ${sz}px Bangers, Silkscreen, monospace`;
|
||||
const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`;
|
||||
// Free-floating text has no panel behind it, so it has to carry its own contrast: over the crowd,
|
||||
// over sunlit clay, over blood. A 1px drop shadow does it and a box would cover the artwork.
|
||||
// (RESUMING IN hand-rolled exactly this; now everything that floats shares it.)
|
||||
function shadowText(txt, x, y, fill) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.62)';
|
||||
ctx.fillText(txt, x + 1, y + 1);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fillText(txt, x, y);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- camera ----
|
||||
const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC
|
||||
@@ -930,10 +945,17 @@ const RACK_BOT = 0; // px below the rack: none — it sits ON the bottom edge.
|
||||
const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, it runs to the screen edge)
|
||||
// The bottom text slot. Named for the draw meter that used to live here; the meter is gone (the
|
||||
// arc band carries power now) and the apron below is left to the gesture demo.
|
||||
export const METER_Y = () => H - TUNE.rackH - RACK_BOT - 17 - SAFE.bottom;
|
||||
// The rack holds ten now, and ten tiles across a 480px canvas would be ~39 CSS px on a phone —
|
||||
// under the thumb target rackHit was widened for in the first place. So it grows a SECOND ROW
|
||||
// UPWARD instead, and only once you are actually carrying more than five: the common case is still
|
||||
// one row on the bottom edge, exactly as before.
|
||||
const rackRows = () => (G.rack.length + 1 > RACK_ROW ? 2 : 1);
|
||||
export const rackTop = () => H - TUNE.rackH * rackRows() - RACK_BOT - SAFE.bottom;
|
||||
export const METER_Y = () => rackTop() - 17;
|
||||
function rackGeom() {
|
||||
const h = TUNE.rackH, n = 5, tw = Math.min(64, (W - 4) / n), rw = tw * n;
|
||||
return { x0: Math.round((W - rw) / 2), y0: H - h - RACK_BOT - SAFE.bottom, tw, h, n, rw };
|
||||
const h = TUNE.rackH, tw = Math.min(64, (W - 4) / RACK_ROW), rw = tw * RACK_ROW;
|
||||
const rows = rackRows();
|
||||
return { x0: Math.round((W - rw) / 2), y0: rackTop(), tw, h, rows, n: rows * RACK_ROW, rw };
|
||||
}
|
||||
export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot index, or -1
|
||||
if (G.mode !== 'splatter' || G.interlude) return -1; // no rack during the tennis-rules duel
|
||||
@@ -942,26 +964,33 @@ export function rackHit(nx, ny) { // normalized [0,1] canvas coords -> slot inde
|
||||
// the screen edge, reclaiming the dead margin below. At 22px tall the tiles were ~30 CSS px on a
|
||||
// phone — well under the 44px everyone's thumb expects — which is why switching tins felt fiddly.
|
||||
if (cy < g.y0 - RACK_GRAB || cx < g.x0 || cx >= g.x0 + g.rw) return -1;
|
||||
return Math.max(0, Math.min(g.n - 1, ((cx - g.x0) / g.tw) | 0));
|
||||
// The FIRST five never move: they stay welded to the bottom edge and the second five stack above,
|
||||
// so growing the rack doesn't slide the ball out from under a thumb that already knows where it is.
|
||||
// Clamped rather than rejected — the RACK_GRAB overhang sits above the top row.
|
||||
const fromTop = Math.max(0, Math.min(g.rows - 1, ((cy - g.y0) / g.h) | 0));
|
||||
const col = Math.max(0, Math.min(RACK_ROW - 1, ((cx - g.x0) / g.tw) | 0));
|
||||
return (g.rows - 1 - fromTop) * RACK_ROW + col;
|
||||
}
|
||||
function drawRack() {
|
||||
const g = rackGeom(), pad = 3;
|
||||
// Built from what you are CARRYING, not from three fixed arrays. Slot 0 is BALL unless a fifth
|
||||
// gathered weapon is riding it (G.temp), and an empty slot is a blank socket rather than a
|
||||
// Built from what you are CARRYING, not from three fixed arrays. Slot 0 is BALL unless an
|
||||
// overflow weapon is riding it (G.temp), and an empty slot is a blank socket rather than a
|
||||
// permanently dimmed weapon you never had.
|
||||
const W_ = i => WEAPONS[slotId(i)];
|
||||
ctx.save(); ctx.lineWidth = 1;
|
||||
for (let i = 0; i < g.n; i++) {
|
||||
const w = W_(i), stockI = slotStock(i), temp = i === 0 && G.temp;
|
||||
const x = g.x0 + i * g.tw, sel = G.weapon === i, dim = stockI <= 0;
|
||||
const x = g.x0 + (i % RACK_ROW) * g.tw;
|
||||
const y0 = g.y0 + (g.rows - 1 - ((i / RACK_ROW) | 0)) * g.h; // slots 0-4 on the bottom edge, always
|
||||
const sel = G.weapon === i, dim = stockI <= 0;
|
||||
const label = i === 0 && !temp ? 'BALL' : w ? w.label : '';
|
||||
const icon = i === 0 && !temp ? 'w_ball' : w ? w.icon : null;
|
||||
// rounded slots: five separate tiles read as five buttons, which is what they are
|
||||
panel(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3, 4,
|
||||
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4,
|
||||
sel ? '#123a24' : '#0a2418', // sunk slot
|
||||
sel ? UI.pick : '#05160e'); // gold frame when live
|
||||
// icon: left, vertically centred, padded off the edge
|
||||
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = g.y0 + g.h / 2;
|
||||
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = y0 + g.h / 2;
|
||||
if (!icon) { ctx.globalAlpha = 1; continue; } // an empty socket: nothing gathered for it yet
|
||||
ctx.globalAlpha = dim ? 0.35 : 1;
|
||||
const spr = SPR[icon];
|
||||
@@ -974,12 +1003,12 @@ function drawRack() {
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.globalAlpha = dim ? 0.5 : 1;
|
||||
ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc';
|
||||
ctx.fillText(stockI === Infinity ? '∞' : '×' + stockI, tx, g.y0 + g.h * 0.33); // ball = ∞, else ×count
|
||||
ctx.fillText(stockI === Infinity ? '∞' : '×' + stockI, tx, y0 + g.h * 0.33); // ball = ∞, else ×count
|
||||
ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : temp ? UI.pink : '#8fa89a';
|
||||
ctx.fillText(label, tx, g.y0 + g.h * 0.72); // weapon label, under its count
|
||||
ctx.fillText(label, tx, y0 + g.h * 0.72); // weapon label, under its count
|
||||
ctx.globalAlpha = 1;
|
||||
// the burst countdown belongs to whichever slot is holding the armed weapon now, not to slot 4
|
||||
if (G.mgT > 0 && slotId(i) === G.gunId) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)), 1.5); }
|
||||
if (G.mgT > 0 && slotId(i) === G.gunId) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / (WEAPONS[G.gunId]?.len || TUNE.mgPickupLen)), 1.5); }
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -987,12 +1016,14 @@ function drawRack() {
|
||||
// the centered milestone 'block': a real pause, front and center. Shared by the sudden-death duel
|
||||
// and the SET announcement so both read as the same interruption. Assumes textAlign is already set.
|
||||
function drawCard(title, sub1, sub2, color) {
|
||||
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64);
|
||||
ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 42, W, 72);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = color; ctx.font = F(16); ctx.fillText(title, W / 2, H / 2 - 26);
|
||||
// the title SHOUTS (Bangers, twice the old size); the subs stay Silkscreen, because they are the
|
||||
// detail you read second and a whole card in the loud face is just noise
|
||||
ctx.font = B(30); shadowText(title, W / 2, H / 2 - 34, color);
|
||||
ctx.fillStyle = UI.cream; ctx.font = F(8);
|
||||
if (sub1) ctx.fillText(sub1, W / 2, H / 2 - 2);
|
||||
if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 12);
|
||||
if (sub1) ctx.fillText(sub1, W / 2, H / 2 + 4);
|
||||
if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 18);
|
||||
}
|
||||
|
||||
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides
|
||||
@@ -1002,9 +1033,12 @@ function drawCard(title, sub1, sub2, color) {
|
||||
// whole time it matters (the arc band carries power now, up-court where you can see it), while
|
||||
// the demo only ever plays when your thumb is NOT there. So it gets the whole apron and is drawn
|
||||
// at a size you can read at arm's length.
|
||||
function drawPullHint() {
|
||||
// One mimed gesture: a dotted track out of the player's feet, an arrowhead at the far end, the
|
||||
// ghost thumb sliding along it, and the words. (dx, dy) is the unit direction — the only thing that
|
||||
// differs between the two lessons, which is why this is one function and not two near-copies.
|
||||
function demoSwipe(dx, dy, label) {
|
||||
const cyc = 1.9, t = (G.idleHintT - TUNE.pullHintAfter) % cyc;
|
||||
const draw = Math.min(1, t / 1.15); // the pull itself, then a beat held at full
|
||||
const draw = Math.min(1, t / 1.15); // the stroke itself, then a beat held at full
|
||||
const gone = t > 1.45; // released — the thumb lifts off
|
||||
const feet = proj(P.x, 0, P.z).y;
|
||||
const room = Math.max(30, METER_Y() - 10 - feet); // apron between his feet and the text slot
|
||||
@@ -1013,31 +1047,50 @@ function drawPullHint() {
|
||||
const len = travel * draw;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = gone ? 0 : 0.85;
|
||||
// the dotted track the thumb travels, straight down-screen
|
||||
// the dotted track the thumb travels
|
||||
ctx.fillStyle = UI.dim;
|
||||
for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax - 1, ay + d, 2, 2);
|
||||
// A DOWN arrowhead at the far end of the track, and nothing pointing up anywhere. The demo used
|
||||
// to grow chevrons UP-court as the pull grew — the shot going the opposite way to the thumb was
|
||||
// meant to be the lesson, but an up arrow on screen is an instruction to swipe up, and that is
|
||||
// the one gesture this does not have. The only direction drawn is the one you actually make.
|
||||
for (let d = 7; d < travel + 4; d += 7) ctx.fillRect(ax + dx * d - 1, ay + dy * d, 2, 2);
|
||||
// An arrowhead at the far end, pointing the way you actually move. The demo used to grow chevrons
|
||||
// UP-court as the pull grew — the shot going the opposite way to the thumb was meant to be the
|
||||
// lesson, but an up arrow on screen is an instruction to swipe up, and that gesture does not exist.
|
||||
ctx.fillStyle = UI.warm;
|
||||
const av = ay + travel + 16, s = 5; // clear of where the thumb lands at full draw
|
||||
const av = travel + 16, s = 5; // clear of where the thumb lands at full stroke
|
||||
const hx = ax + dx * av, hy = ay + dy * av; // arrow tip, and the two barbs behind it
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, av + s); ctx.lineTo(ax + s, av - s * 0.7); ctx.lineTo(ax - s, av - s * 0.7);
|
||||
ctx.moveTo(hx + dx * s, hy + dy * s);
|
||||
ctx.lineTo(hx - dx * s * 0.7 + dy * s, hy - dy * s * 0.7 + dx * s);
|
||||
ctx.lineTo(hx - dx * s * 0.7 - dy * s, hy - dy * s * 0.7 - dx * s);
|
||||
ctx.fill();
|
||||
// ...and say it in words. The mime alone was too abstract to read as "drag your thumb down".
|
||||
// ...and say it in words. The mime alone was too abstract to read as an instruction.
|
||||
ctx.fillStyle = UI.warm; ctx.font = F(8);
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||
ctx.fillText('PULL DOWN', ax, av + 8);
|
||||
// The label sits under the TRACK, not under the arrowhead — following the arrow sideways would
|
||||
// carry it off the edge of the screen, and clearing the full travel below a horizontal stroke
|
||||
// left it floating in the middle of an empty apron with nothing to attach itself to.
|
||||
ctx.fillText(label, ax, ay + (dy ? travel + 24 : 18));
|
||||
// the ghost thumb
|
||||
ctx.globalAlpha = gone ? 0 : 0.9;
|
||||
ctx.fillStyle = UI.cream;
|
||||
ctx.beginPath(); ctx.arc(ax, ay + len, 6, 0, 7); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 6, 0, 7); ctx.fill();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
||||
ctx.beginPath(); ctx.arc(ax, ay + len, 2.5, 0, 7); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 2.5, 0, 7); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// The apron plays the two gestures in alternation — pull, dodge, pull, dodge. Both are one-thumb
|
||||
// moves made from the same stance and the direction is the ONLY thing that tells them apart, so
|
||||
// showing them in the same place, one after the other, is what makes that legible. The sidestep
|
||||
// alternates sides too, or it reads as a move that only goes right.
|
||||
function drawPullHint() {
|
||||
const n = ((G.idleHintT - TUNE.pullHintAfter) / 1.9) | 0;
|
||||
if (n % 2 === 0) { demoSwipe(0, 1, 'PULL DOWN'); return; }
|
||||
// ...and it mimes AWAY from whichever edge he is standing against, or the stroke it is teaching
|
||||
// runs off the side of the screen. Only the alternation gives way; the lesson does not.
|
||||
const px = proj(P.x, 0, P.z).x;
|
||||
const dir = px < W * 0.3 ? 1 : px > W * 0.7 ? -1 : (n % 4 === 1 ? -1 : 1);
|
||||
demoSwipe(dir, 0, 'SWIPE TO DODGE');
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1065,7 +1118,7 @@ function drawModal(m) {
|
||||
// cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a
|
||||
// portrait-sized icon pushed the TAP prompt down onto the weapon rack
|
||||
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) + 26 + 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) {
|
||||
@@ -1073,13 +1126,13 @@ function drawModal(m) {
|
||||
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.font = B(24); ctx.fillStyle = m.color || UI.warm; // an announcement, so the announcement face
|
||||
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.fillText(m.title, W / 2 + headX, y + 8); y += 26;
|
||||
ctx.font = F(8, 0); ctx.fillStyle = UI.cream;
|
||||
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,
|
||||
@@ -1093,6 +1146,9 @@ function drawModal(m) {
|
||||
|
||||
// ------------------------------------------------------------------ frame ----
|
||||
const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text
|
||||
const FLASH_Y = 68; // the ONE slot every free-floating message uses. Clear of the scoreboard block
|
||||
// (6 + 42 + 15 = 63 at its tallest), which the old 60 was not in portrait —
|
||||
// there the board is over half the width, so a centred flash landed on it.
|
||||
export function render() {
|
||||
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
|
||||
// ease the aim camera FIRST, before anything projects — one camera per frame (see proj)
|
||||
@@ -1101,6 +1157,17 @@ export function render() {
|
||||
const m = G.shake.mag * G.shake.t / 0.35;
|
||||
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
|
||||
}
|
||||
// A scripted PUNCH-IN on a world point, in the same place and for the same reason as the shake:
|
||||
// it is a camera move on the drawn frame, not on proj(). A perspective dolly would want camZ,
|
||||
// aimCam and every projected HUD anchor to agree with each other for the sake of one 1.15s beat
|
||||
// in the transition — this is a flat 2D scale about a point, it only ever crops inward (so no
|
||||
// background edge can show), and it costs three lines.
|
||||
if (G.zoom) {
|
||||
const p = Math.min(1, G.zoom.t / G.zoom.T);
|
||||
const k = 1 + (G.zoom.k - 1) * Math.sin(p * Math.PI); // in and back out, no cut at either end
|
||||
const q = proj(G.zoom.x, 0, G.zoom.z);
|
||||
ctx.translate(q.x, q.y); ctx.scale(k, k); ctx.translate(-q.x, -q.y);
|
||||
}
|
||||
|
||||
const pal = palette(), pt = paletteT();
|
||||
// CLEAR THE WHOLE FRAME to the apron colour first. There was no clear at all: the stands cover the
|
||||
@@ -1417,16 +1484,19 @@ export function render() {
|
||||
// unreadable over a sunlit clay crowd or a floodlit blue one, so it gets its own panel — the
|
||||
// same trick the scoreboard art above it already relies on.
|
||||
ctx.lineWidth = 1;
|
||||
panel(0.5, bH + 1.5, bW - 1, 12, 3, 'rgba(8,26,18,0.82)', hexA(bd.hi, 0.35));
|
||||
// 6px was the smallest type in the game and it read as texture rather than as words. Both
|
||||
// strings are 7 and the panel grew to carry them — they are the only permanent readout of how
|
||||
// far into a run you are.
|
||||
panel(0.5, bH + 1.5, bW - 1, 14, 3, 'rgba(8,26,18,0.82)', hexA(bd.hi, 0.35));
|
||||
// which game you're in, and the sets banked, permanently — the 1.1s GAME N flash and the
|
||||
// scoreboard's own per-row sets column were the only places either showed.
|
||||
ctx.textAlign = 'right'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim;
|
||||
ctx.fillText(`GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 7);
|
||||
ctx.textAlign = 'right'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim;
|
||||
ctx.fillText(`GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
|
||||
}
|
||||
if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
|
||||
ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 7);
|
||||
ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8);
|
||||
for (let k = 0; k < TUNE.killsPerPoint; k++) {
|
||||
ctx.beginPath(); ctx.arc(32 + k * 8, bH + 7, 2.4, 0, 7);
|
||||
ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7);
|
||||
if (k < G.killCount) { ctx.fillStyle = bd.gold; ctx.fill(); }
|
||||
else { ctx.strokeStyle = '#5a6a5a'; ctx.lineWidth = 1; ctx.stroke(); }
|
||||
}
|
||||
@@ -1439,20 +1509,22 @@ export function render() {
|
||||
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);
|
||||
ctx.save(); ctx.globalAlpha = fade;
|
||||
ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = UI.bad; ctx.font = F(14);
|
||||
ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40);
|
||||
// The announcement face, at 26 rather than the card's 30: this is the longest string in the game
|
||||
// and at 30 it ran edge to edge on a 270px-wide portrait canvas.
|
||||
ctx.font = B(26);
|
||||
shadowText('GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad);
|
||||
ctx.fillStyle = UI.cream; ctx.font = F(9);
|
||||
ctx.fillText('SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14);
|
||||
if (G.newBest) { // a fresh record crows; otherwise show the mark to beat
|
||||
const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4));
|
||||
ctx.fillStyle = hexA(UI.warm, pulse); ctx.font = F(10);
|
||||
ctx.fillText('★ NEW BEST ★', W / 2, H / 2 + 4);
|
||||
ctx.font = B(20);
|
||||
shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse));
|
||||
} else if (G.best && G.best.games > 0) {
|
||||
ctx.fillStyle = UI.dim; ctx.font = F(8);
|
||||
ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4);
|
||||
}
|
||||
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
|
||||
if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); }
|
||||
if (G.eatenT > EATEN_RESTART) { ctx.fillStyle = UI.warm; ctx.font = F(8); ctx.fillText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32); }
|
||||
ctx.restore();
|
||||
}
|
||||
// Timed freezes swallow every input until they run out, which reads as the game having hung
|
||||
@@ -1468,15 +1540,15 @@ export function render() {
|
||||
// Kept clear of the ad strip — centred on H alone it landed right on the boards, which is the
|
||||
// busiest band on the screen. The strip's own projected line is the ceiling, so it stays legible
|
||||
// whatever the phone's height does to the layout. A 1px shadow, not a box, carries it over the crowd.
|
||||
// Plain Silkscreen on purpose — a countdown is information, not an announcement.
|
||||
const txt = 'RESUMING IN ' + Math.ceil(waitT);
|
||||
const y = Math.min(Math.round(H * 0.34), Math.round(proj(0, 0, -6).y) - AD_H - 16);
|
||||
ctx.font = F(10);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillText(txt, W / 2 + 1, y + 1);
|
||||
ctx.fillStyle = hexA(UI.warm, 0.92); ctx.fillText(txt, W / 2, y);
|
||||
shadowText(txt, W / 2, y, hexA(UI.warm, 0.92));
|
||||
}
|
||||
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot
|
||||
ctx.fillStyle = UI.warm; ctx.font = F(8);
|
||||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
|
||||
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-0 TO SWITCH';
|
||||
const selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN';
|
||||
// "is the selected weapon one you UNLOAD rather than serve" — it used to be `G.weapon === 4`,
|
||||
// which is meaningless once the slots hold whatever you gathered
|
||||
@@ -1498,9 +1570,12 @@ export function render() {
|
||||
if (txt) ctx.fillText(txt, W / 2, METER_Y()); // same slot the draw meter uses
|
||||
if (demo) drawPullHint(); // show the gesture instead of only naming it
|
||||
}
|
||||
// The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL,
|
||||
// RETURNED, CHANGEOVER, weapon labels — everything that used to be plain white 12px text sitting
|
||||
// directly on the crowd. It keeps its 1px jitter; the shadow is what makes it legible anywhere.
|
||||
if (G.flash) {
|
||||
ctx.font = F(12); ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1);
|
||||
ctx.font = B(24);
|
||||
shadowText(G.flash.text, W / 2, FLASH_Y + Math.sin(G.flash.t * 20) * 1, '#ffffff');
|
||||
}
|
||||
// the SET milestone card (the duel's own announcement is a real modal now, not a timed card)
|
||||
if (G.card && G.card.t > 0 && !(G.card.wait > 0)) // `wait` holds it OFF the screen (a boss dying under it)
|
||||
|
||||
Reference in new issue
Block a user