diff --git a/HANDOFF.md b/HANDOFF.md index ca2d718..757a864 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -317,8 +317,74 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `updateZombies`), never by handing back movement. - **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about ±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the - frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so - every legal ball stays catchable. + frame entirely. `xLimit()` in gameplay — the camera limit, floored at the sideline so every legal + ball stays catchable. The walk and the sidestep share it. +- **The sidestep is how you move outside `rally`, and it is not a walk.** One committed step + (`TUNE.dodgeDist`) on a cooldown, carrying `TUNE.dodgeIFrame` seconds that beat BOTH damage + channels — the chip damage in `hurtPlayer` and the instant-death contact test. It exists because a + boss fight is spent in `pretoss`, so the rule above left you welded to the spot for the whole + fight and Oliver died at every chapter boss without ever being able to move. +- **Which way you leave the deadzone IS the gesture.** Decided once, at the `DEAD` crossing, and + never revisited for that touch: down-ish = the slingshot, sideways = the sidestep. This is how the + new gesture stays inside "one gesture means one thing" — there is no timing window and no hidden + mode, only the direction you actually moved. The finger-steer modes (horde, burst) are untouched: + `stick.aimable` is false there, so the drag is still the absolute steer it always was. +- **Two type registers, and the register IS the message's category.** Silkscreen for the HUD, the + rack, the control hints and `RESUMING IN` — things you read at leisure. Bangers (`B()` in render) + for `SET`, `CHAPTER`, `EPIC!`, `PERFECT` and every free-floating flash — things shouted at you. + Free-floating text carries a 1px shadow (`shadowText`) and one shared y (`FLASH_Y`), because it + has no panel and has to survive the crowd, sunlit clay and blood behind it. + +## Session log: the sidestep, and two type registers (2026-08-03) + +Oliver's post-ship playtest. One blocker and seven polish items; all eight landed. + +**The blocker: he died at every chapter boss because he could not move.** Two independent causes, +and fixing either alone would have left him rooted. The movement block is `rally`-only (a settled +decision the six boss AI styles were written around), and a boss fight is spent in `pretoss`. On top +of that `canAimServe()` is true through a boss fight, so the one finger was already spent on the +slingshot — there was no gesture left over that *could* mean "move". The answer is a **sidestep**, +not a walk, so the settled decision stands; and the gesture is picked by the DIRECTION the drag +leaves the deadzone in, so nothing about "one gesture means one thing" bends. Both are in the +settled-decisions list above. + +Two guards make the dodge worth spending: `hurtPlayer` (the ranged channel) and the contact test in +`updateZombies` (the instant-death channel). Verified by pinning a shambler on the player: alive at +`dodgeI` 0.3, eaten the frame it hit 0. + +**The message system was seven unrelated looks; it is two registers now.** Bangers is bundled +alongside Silkscreen and the split is documented above. `drawCard`, `drawModal`'s title, `G.flash` +and the death summary shout; the control hints, the pull/dodge demos, the scoreboard and +`RESUMING IN` stay quiet. The types Oliver had not counted, for the record: the bottom control-hint +slot at `METER_Y`, the `drawPullHint` apron demo, the death summary, and the scoreboard/rack chrome. + +**Modals queue now.** `showModal` used to *drop* a second popup opened in the same beat, which is +why the trainer and the weapon had to be crammed into one card after a won duel. `G.modalQ` is four +lines and it retires that hack: the weapon modal, then a real `WOUNDS TREATED` modal with the +`x_cross` first-aid tin over it (the sprite had been preloaded and drawn by nothing since the merge). + +**One popup, one rule.** `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 for a whole run while a barrel weapon explained itself once. `deliverTin` gained the label +flash a barrel already had, or the fifth delivery onward would pay out in silence. + +**Ten rack slots, second row on demand.** `RACK_MAX` 4 → 9. Ten tiles across 480px would be ~39 CSS +px on a phone, under the thumb target `rackHit` was widened for, so past five it grows a second row +UPWARD and slots 0-4 stay welded to the bottom edge. `METER_Y` derives from `rackTop()` now, so the +hint line and the apron demo lift with it. + +**The apron demo alternates.** `demoSwipe(dx, dy, label)` is the old `drawPullHint` body with the +direction pulled out; `drawPullHint` alternates PULL DOWN and SWIPE TO DODGE on the 1.9s cycle, and +mimes away from whichever edge the player is standing against. + +**The transition punches in.** `G.zoom` is a flat 2D scale about a projected world point, applied in +the same place and for the same reason as `G.shake` — a perspective dolly would want `camZ`, +`aimCam` and every projected HUD anchor to agree for the sake of one 1.15s beat. Escalating shake at +3.4 / 4.8 / 8.2 s. + +**And the boss win holds.** `TUNE.bossWinT` (3.6s) is its own number so the routine SET card does not +drag with it. `EPIC!` in Bangers, chapter and crowd demoted to subs, and no `GAME n NEXT` line — +the bar under the scoreboard already prints the game number permanently. ## Session log: the second pass on the barrel weapons (2026-08-03) diff --git a/assets/fonts/Bangers-Regular.ttf b/assets/fonts/Bangers-Regular.ttf new file mode 100644 index 0000000..df7d5dc Binary files /dev/null and b/assets/fonts/Bangers-Regular.ttf differ diff --git a/config.js b/config.js index 7c35238..c9d92a5 100644 --- a/config.js +++ b/config.js @@ -27,6 +27,15 @@ export const TUNE = { aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09, aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile) + // ---- THE SIDESTEP. Free walking stays 'rally'-only (see the movement block in gameplay), which + // left you rooted to the spot through a whole boss fight — the drag is the slingshot and there was + // no gesture left over that could mean "move". A dodge is not a walk: it is one committed step + // with a cooldown, so it answers a volley without turning the serve stance into a joystick. + dodgeDist: 1.9, // m of ground the sidestep covers + dodgeT: 0.18, // s it takes — fast enough to be a reaction, slow enough to read as a step + dodgeCool: 0.5, // s before another. THE anti-spam knob: shorter and you skate across the court + dodgeIFrame: 0.28,// s untouchable, covering the step and a hair after. Beats BOTH damage channels + // (a boss volley and the instant-death body), which is the whole point of it zSpeed: 0.7, // zombie shamble speed m/s // ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why // a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed @@ -307,6 +316,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) + bossWinT: 3.6, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is + // the longest fight in the game and the win was over before it registered, but + // stretching setCardT would have made every routine set announcement drag too. 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. @@ -527,7 +539,12 @@ export const BARREL_WEAPONS = { laser: { weight: 2, from: 6 }, rocket: { weight: 3, from: 12 }, // the late-game answer: it should never turn up in game three }; -export const RACK_MAX = 4; // gathered weapons the rack holds; a 5th rides slot 0 until it is spent +// Gathered weapons the rack holds (plus slot 0 = BALL, so ten tiles). Was 4, and at four you were +// throwing away a barrel almost every time one popped — Oliver: "that makes replacing the ball rarer +// if not unnecessary at all". The `temp` valve below survives for the eleventh, which nobody will see. +export const RACK_MAX = 9; +export const RACK_ROW = 5; // tiles per row — past five the rack grows a SECOND row upward, so a tile + // never shrinks below the thumb target rackHit was widened for export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture // (a body is PLAYER_H 1.88), but big enough to be a thing on the court. diff --git a/gameplay.js b/gameplay.js index 9438b3d..ed6e909 100644 --- a/gameplay.js +++ b/gameplay.js @@ -310,12 +310,15 @@ function scoreKey() { } // ---- the modal: the only thing in the game that stops the world. Play resumes on a tap, not -// 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. +// on a timer, so a popup can be READ. It only ever touches G — never the zombie array it may be +// called from mid-iteration. +// A second popup opened in the same beat QUEUES rather than being dropped. It used to be dropped +// ("if (G.modal) return"), which is why the trainer and the weapon had to be crammed into one card +// after a won duel — the second one simply vanished. They are two things, so they are two popups. 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 + m.in = 0; // 0..1 entrance clock, ticked in update() behind the freeze + if (G.modal) { G.modalQ.push(m); return; } + G.modal = m; whoosh(); } export function dismissModal() { const m = G.modal; if (!m) return false; @@ -323,7 +326,9 @@ export function dismissModal() { // 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 = G.modalQ.shift() || null; + if (G.modal) { G.modal.in = 0; whoosh(); } // the next one flies in on its own entrance + if (m.onDismiss) m.onDismiss(); return true; // the caller must swallow the gesture: dismissing must never also serve } @@ -528,24 +533,28 @@ export function spendSlot(i) { // (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde // instead of spending it now. G.weapon picks which tin the next serve throws. const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out -export function deliverTin(healLine = null) { +export function deliverTin() { const id = TINS[G.deliveries++ % TINS.length]; const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking) grantWeapon(id); if (wasPlain) G.weapon = 0; sendBoy(); voice('new_balls', 0.6); aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently." - // healLine folds the trainer's visit INTO this popup. `showModal` deliberately never clobbers an - // open modal (a kill cascade can try to open two in one frame), so when both happened the heal - // modal opened first and the weapon one was lost entirely — Oliver: "the weapon message is lost". - weaponModal(id, healLine); + // From the fifth delivery on, weaponModal has nothing new to explain and stays quiet — so the tin + // needs the same free-floating label a barrel already gets, or a won duel pays out in silence. + G.flash = { text: WEAPONS[id].label, t: 1.1 }; + weaponModal(id); } // The one-per-run "what this thing does" popup, shared by the duel ladder and the barrels. -export function weaponModal(id, healLine = null) { +// THE GATE LIVES HERE, not at the call sites. The barrels gated themselves and the tin ladder did +// not, so the four delivered weapons re-explained themselves every three games for a whole run +// while a barrel weapon explained itself once — the same popup behaving two different ways. +export function weaponModal(id) { + if (G.seen['w' + id]) return; + G.seen['w' + id] = true; const w = WEAPONS[id]; - showModal({ title: w.label, body: healLine ? [...w.desc, '', healLine] : w.desc, - icon: w.icon, iconH: 40, color: UI.warm }); + showModal({ title: w.label, body: w.desc, icon: w.icon, iconH: 40, color: UI.warm }); } // ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing @@ -621,6 +630,7 @@ export function startBossFight(chapter) { G.seen.boss = true; showModal({ title: 'IT BRINGS A MACHINE', color: UI.bad, body: ['IT THROWS BALLS AT YOU', 'SWING TO RETURN THEM — OR SWISH TO BAT THEM AWAY', + 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY', 'A HIT COSTS BLOOD. WIN A DUEL TO HEAL.'] }); } } @@ -632,10 +642,14 @@ export function endBossFight() { zombieDuck(false); shots.length = 0; cheer(3.4, 0.9); voice('unbelievable', 0.3); - G.card = { title: 'CHAPTER ' + c + ' DOWN', sub1: 'THE STANDS ARE ON THEIR FEET', - sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT, + // The longest fight in the game deserves more than the two seconds a routine set gets. Big word, + // long hold — the chapter and the crowd move down to the subs where they belong. + // (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a + // card that repeats what the HUD already says is the clutter this pass is meant to remove.) + G.card = { title: 'EPIC!', sub1: 'CHAPTER ' + c + ' DOWN', + sub2: 'THE STANDS ARE ON THEIR FEET', color: UI.pick, t: TUNE.bossWinT, wait: TUNE.bossDeathT }; // ...after the body has finished coming apart - ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT + TUNE.bossDeathT; + ball.active = false; G.phase = 'over'; G.overT = TUNE.bossWinT + TUNE.bossDeathT; // hand back the game beat the boss interrupted — same contract resolveInterlude uses if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); } } @@ -647,16 +661,17 @@ export function resolveInterlude(side) { ball.active = false; G.phase = 'over'; G.overT = 2.2; if (side === 'P') { cheer(3, 0.7); - // winning the duel patches you up. THE reason to want a duel rather than merely tolerate it — - // it is the only healing in the game, and blood is the only thing you cannot buy back otherwise. - let healLine = null; + deliverTin(); // win the point, win the next tin + // ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is + // the only healing in the game, and blood is the only thing you cannot buy back otherwise. It + // gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line + // of body text under a weapon icon it read as a footnote to the tin rather than as the prize. if (G.hp < G.hpMax) { - const before = G.hp; G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel); voice('ump_trainer', 0.2, 0.95); - healLine = '+ WOUNDS TREATED · NOW ' + G.hp + '/' + G.hpMax; + showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick, + body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] }); } - deliverTin(healLine); // win the point, win the next tin — and the trainer, in ONE popup } else { voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel score.games[1]++; G.runGames++; // it goes on the scoreboard @@ -678,6 +693,30 @@ export function spentBall() { if (!G.changeover) G.overT = TUNE.splatterOverT; } +// ---- THE SIDESTEP. Free walking is 'rally'-only (see the movement block) and that is staying — +// but it left you welded to the spot through a whole boss fight, because a boss is fought from the +// serve stance and the one finger is already spent on the slingshot. So: a horizontal swipe is a +// committed step, not a joystick. It integrates OUTSIDE the movement block so it works in every +// phase, and it carries invulnerability, which is the only reason to make one. +export function dodge(dir) { + if (G.mode !== 'splatter' || G.modal || G.dodgeCd > 0 || G.dodgeT > 0) return; + G.dodgeDir = dir; G.dodgeT = TUNE.dodgeT; G.dodgeCd = TUNE.dodgeCool; G.dodgeI = TUNE.dodgeIFrame; + G.idleHintT = 0; // ...and the apron demo goes away, like any other gesture. The touch path nulls + // G.aim to fire this, and the demo's own guard only watches G.aim. + P.facing = dir; + // ...but a swing in flight keeps its pose. You can step out of the way mid-shot and the shot still + // lands (swingT is untouched); clobbering the pose only threw away the animation for it. + if (P.pose !== 'swing' && P.pose !== 'serve') { P.pose = 'run'; P.poseT = 0; } + swish(0.22); +} + +// How far out he may stand. P_BOUNDS.x is 6.5 m, but at the baseline in portrait the camera only +// shows about ±4 m either side of the centre line, so walking to the bound put him clean off the +// edge. Floored at the sideline so every legal tennis ball stays catchable — the court is never out +// of bounds. Shared by the movement block and the dodge; both were inline in the former. +const xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); +const xLimit = () => Math.max(xVisible(), COURT.W / 2); + // the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you function startHorde() { if (G.horde) return; @@ -725,16 +764,24 @@ function transition(dt) { const t = (G.transT += dt); const once = (key, at, fn) => { if (t >= at && !G.tFlags[key]) { G.tFlags[key] = true; fn(); } }; once('quiet', 1.8, () => voice('quiet_dark', 0, 0.82)); - once('turn', 3.4, () => { spawnZombie(O.x, O.z, 'c'); G.hideO = true; }); // the opponent... turns - once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); }); + // The turn is the moment the game tells you what it now is, and it used to happen in a wide + // static shot at the far baseline where it was three pixels tall. The camera PUNCHES IN on him + // for it (a flat 2D zoom, see render), and the ground goes with it. Each later beat shakes harder. + once('turn', 3.4, () => { + spawnZombie(O.x, O.z, 'c'); G.hideO = true; // the opponent... turns + G.zoom = { x: O.x, z: O.z, k: 1.9, t: 0, T: 1.15 }; + G.shake = { t: 0.45, mag: 4 }; + }); + once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; }); once('seated', 5.4, () => voice('remain_seated', 0, 0.95)); once('go', 8.2, () => { G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic(); + G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match // the mechanic changed under you — the one popup that gets an illustration, because it has // to TEACH the gesture, not just announce it showModal({ title: 'THE MATCH HAS CHANGED', color: UI.hot, icon: 'x_howto', iconH: 120, body: ['PULL BACK TO AIM — THEN LET GO', 'THE FURTHER YOU PULL, THE HARDER IT HITS', - 'A WEAK SHOT ONLY STAGGERS THEM'] }); + 'A WEAK SHOT ONLY STAGGERS THEM', 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY'] }); }); } @@ -1004,6 +1051,12 @@ function meleeCooldown() { } export function keyPressed(code) { + // Desktop sidestep: an arrow/A/D EDGE outside 'rally', where the walk axes do nothing anyway. + // Inside a rally the same keys still walk, so one key never means two things at one moment. + if (code === 'ArrowLeft' || code === 'KeyA' || code === 'ArrowRight' || code === 'KeyD') { + if (G.phase !== 'rally') dodge(code === 'ArrowLeft' || code === 'KeyA' ? -1 : 1); + return; + } if (code !== 'Space') return; audio(); // unlock on first gesture // a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the @@ -1132,6 +1185,7 @@ export function update(dt) { else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; } } if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; + if (G.zoom && (G.zoom.t += dt) >= G.zoom.T) G.zoom = null; // scripted punch-in (render applies it) if (G.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = null; if (G.boomFlash > 0) G.boomFlash -= dt; // A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers, @@ -1173,7 +1227,7 @@ export function update(dt) { // auto-fire steering is ABSOLUTE: the finger x IS the player x — zero lag, and clamped // to what the camera can see (portrait's world bounds are wider than its viewport) const mgMode = G.horde || G.mgT > 0; - const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); + const xVis = xVisible(); let mgSteer = false, mgMoving = false; if (mgMode && touchPX() !== null) { mgSteer = true; @@ -1200,17 +1254,14 @@ export function update(dt) { assist = dx; } } - // Stay ON SCREEN — always, not just in auto-fire. P_BOUNDS.x is 6.5 m, but at the baseline in - // portrait the camera only shows about ±4 m either side of the centre line, so walking to the - // bound put the player clean off the edge (x = -6.5 projects to screen -61 of 270). Floored at - // the sideline so every legal tennis ball stays catchable — the court is never out of bounds. - const xLim = Math.max(xVis, COURT.W / 2); + // Stay ON SCREEN — always, not just in auto-fire (x = -6.5 projects to screen -61 of 270). + const xLim = xLimit(); P.x = Math.max(-xLim, Math.min(xLim, P.x)); P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); // mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a // player sliding left and right under auto-fire counted as standing still and never ran — // "when he's moving left and right the run animation is not applied". - const moving = mgMoving || mx || mz || assist !== 0; + const moving = mgMoving || mx || mz || assist !== 0 || G.dodgeT > 0; P.runT += moving ? dt : 0; // Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then // faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic @@ -1223,7 +1274,19 @@ export function update(dt) { // ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only // handles swing/serve), and P.runT stops advancing outside the block, so a beat that began // mid-stride left him welded to a single run frame — the "stuck mid-animation" report. - else if (P.pose === 'run') { P.pose = 'idle'; P.poseT = 0; } + else if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } + // The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent + // in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him + // off the edge of the screen either. + if (G.dodgeCd > 0) G.dodgeCd -= dt; + if (G.dodgeI > 0) G.dodgeI -= dt; + if (G.dodgeT > 0) { + G.dodgeT -= dt; + const xLim = xLimit(); + P.x = Math.max(-xLim, Math.min(xLim, P.x + G.dodgeDir * (TUNE.dodgeDist / TUNE.dodgeT) * dt)); + P.runT += dt; // the legs move, so the run cycle advances + if (G.dodgeT <= 0) G.dodgeDir = 0; + } // splatter: you START at the baseline but you're free to advance up-court (bounded at the net by // P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still // recovers you toward the baseline between shots on touch; the duel is plain tennis as ever. diff --git a/index.html b/index.html index d1cecdb..8b1502c 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,11 @@ src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); } @font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block; src: url('assets/fonts/Silkscreen-Bold.ttf') format('truetype'); } + /* the ANNOUNCEMENT face. Silkscreen is the HUD and the instructions — small, even, quiet. The + things that shout (SET, CHAPTER, EPIC!, PERFECT) are a second register on purpose, so a message + tells you which kind it is before you have read a word of it. Bundled, like the other one. */ + @font-face { font-family: 'Bangers'; font-weight: 400; font-display: block; + src: url('assets/fonts/Bangers-Regular.ttf') format('truetype'); } /* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is laid out in the root's own box — and with the canvas position:fixed the root has no content and collapses, so the gradient render.js sets had nowhere to go. */ @@ -179,6 +184,6 @@ - + diff --git a/input.js b/input.js index 5127346..a125739 100644 --- a/input.js +++ b/input.js @@ -6,7 +6,7 @@ import { cv } from './dom.js'; import { audio, pock } from './audio.js'; import { TUNE, WEAPONS } from './config.js'; import { G, score } from './state.js'; -import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal, slotId } from './gameplay.js'; +import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal, slotId, dodge } from './gameplay.js'; import { rackHit } from './render.js'; import { togglePanel } from './panel.js'; @@ -15,7 +15,7 @@ addEventListener('keydown', e => { if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault(); if (dismissModal()) return; // a modal eats the whole keystroke — dismissing must never also serve if (e.key === 't' || e.key === 'T') togglePanel(); - if (e.key >= '1' && e.key <= '5') selectWeapon(+e.key - 1); // pick a rack slot + if (e.key >= '0' && e.key <= '9') selectWeapon((+e.key + 9) % 10); // pick a rack slot: 1..9 then 0 = the tenth if (!keys[e.code]) keyPressed(e.code); keys[e.code] = true; }); @@ -95,6 +95,14 @@ cv.addEventListener('touchmove', e => { const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); if (dist > DEAD) stick.moved = true; if (stick.aimable) { // slingshot: PULL BACK, launch forward — the shot is the inverse of the drag + // WHICH WAY YOU LEFT THE DEADZONE DECIDES WHAT THE GESTURE IS, once, and it never changes its + // mind after. Down-ish is the pull the game already teaches; SIDEWAYS is the sidestep — the + // only way to move in the serve stance, which is where a whole boss fight is spent. No timing + // window and no invisible mode: the direction you moved is the thing you asked for. + if (dist > DEAD && !stick.tossed && !stick.dodged && Math.abs(dx) > Math.abs(dy) * 1.6) { + stick.dodged = true; G.aim = null; dodge(Math.sign(dx)); + } + if (stick.dodged) continue; // spent: no toss, no reticle, and nothing fires on release if (dist > DEAD) { if (!stick.tossed) { serveToss(); stick.tossed = true; } // the pull kicks off the toss animation G.aim = { fx: -dx / dist, fy: -dy / dist, mag: dist }; diff --git a/main.js b/main.js index 8851fbd..8d9cf96 100644 --- a/main.js +++ b/main.js @@ -41,8 +41,9 @@ if (navigator.standalone || matchMedia('(display-mode: standalone)').matches) { requestAnimationFrame(() => { meta.content = real; requestAnimationFrame(fitViewport); }); } setupPoint(); -// warm the pixel HUD font so the canvas never paints a system-serif fallback frame -if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); } +// warm both faces so the canvas never paints a system-serif fallback frame +if (document.fonts) for (const f of ['700 8px Silkscreen', '400 8px Silkscreen', '400 20px Bangers']) + document.fonts.load(f); // The overlay is a three-stage machine: splash -> {courts, tutorial}. The title screen IS the menu — // a separate menu stage behind it showed the same logo on the same backdrop and read as the title @@ -89,7 +90,7 @@ const TUT = [ { pose: 2, shot: 'tut_serve', say: ["First it is just TENNIS.", "TAP once to toss, tap again to serve.", "FLICK toward where you want it.", - "DRAG to run — though he mostly runs himself."] }, + "He runs down the rest himself, mostly."] }, { pose: 4, say: ["Then, at some point, the other fellow...", "stops being the other fellow.", "Nobody stops the match. Nobody ever does."] }, @@ -97,6 +98,10 @@ const TUT = [ "PULL BACK from your man and let go.", "The further you pull, the harder it lands.", "A soft one only makes them cross."] }, + { pose: 4, say: ["You will not outrun them. Don't try.", + "SWIPE SIDEWAYS and he plants a foot", + "and is simply somewhere else.", + "Nothing touches him on the way. Nothing."] }, { pose: 2, shot: 'tut_rack', say: ["I bring tins out to the rack down there.", "BOMBS, TRIPLES, FIRE, and the GUN.", "Tap the rack to load one, then serve it.", diff --git a/render.js b/render.js index 90f2b05..293e5fd 100644 --- a/render.js +++ b/render.js @@ -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) diff --git a/state.js b/state.js index 63cb1ef..ae14cac 100644 --- a/state.js +++ b/state.js @@ -67,6 +67,12 @@ export const G = { idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the // pull gesture demonstrates itself (render.drawPullHint) meleeT: 0, // s of racket-swish cooldown left + // ---- the sidestep. The movement block is 'rally'-only on purpose, so this integrates OUTSIDE it + // and is the only way to move in the serve stance (which is where a boss fight is spent). + dodgeDir: 0, // -1 | 0 | 1 — which way the current step is going, 0 = standing + dodgeT: 0, // s of the step left + dodgeCd: 0, // s until another is allowed + dodgeI: 0, // s of invulnerability left (checked by hurtPlayer AND the eat test) // ---- the SECOND damage channel. Only ranged attacks (ball machine, chapter boss) touch this; // a shambler reaching you is still instant permadeath, deliberately. No HP bar anywhere — the // state is drawn as blood on the man (drawChar's spatter + bleedPlayer's live trickle). @@ -94,7 +100,9 @@ export const G = { // the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns) // and only a tap or key clears it, so a popup can be read without being eaten while you read it. modal: null, // {title, body: [lines], icon, iconH, color, onDismiss} + modalQ: [], // popups waiting behind the open one — see showModal seen: {}, // one-shot markers so a first-time-only modal fires once per run + zoom: null, // {x, z, k, t, T} — a scripted punch-in on a world point (the transition's turn) best: null, // best run {games, sets}, loaded from localStorage when you get eaten newBest: false, // this run beat the stored best — the eaten summary celebrates it lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde diff --git a/zombies.js b/zombies.js index 7bbfee5..8ea775f 100644 --- a/zombies.js +++ b/zombies.js @@ -204,8 +204,10 @@ function bossAI(zb, dt) { // ---- the player's second health channel. Only these two functions touch it. export function hurtPlayer(n) { - if (G.mode !== 'splatter' || G.hurtT > 0 || G.mode === 'eaten') return; // grace window: a volley - G.hp -= n; // must not delete you at once + // G.hurtT is the grace window: a volley must not delete you at once. G.dodgeI is the SIDESTEP — + // it beats this channel and the eat test below, which is the only reason to spend a dodge. + 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; G.shake = { t: 0.22, mag: 4 }; splat(0.7); aww(0.5); @@ -343,7 +345,7 @@ function popBarrel(zb) { grantWeapon(zb.wid); const w = WEAPONS[zb.wid]; G.flash = { text: w.label, t: 1.1 }; - if (!G.seen['w' + zb.wid]) { G.seen['w' + zb.wid] = true; weaponModal(zb.wid); } + weaponModal(zb.wid); // first sight only — the gate lives in weaponModal, for both callers } // dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop). @@ -891,6 +893,7 @@ export function updateZombies(dt) { // soften it. Two threats that demand two different responses is the point. The machine ('m') is // furniture and cannot eat you. if (!held && G.mode === 'splatter' && !'mo'.includes(zb.variant) // furniture and barrels cannot eat you + && !(G.dodgeI > 0) // ...and a sidestep gets you out from under one. It is the ONE escape from this && Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you killPlayer(); if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {