From 55c0f75c3751619733d0e07b3230f58d11710e16 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Mon, 3 Aug 2026 21:48:24 +0200 Subject: [PATCH] Give him a sidestep, and split the messages into two voices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- HANDOFF.md | 70 ++++++++++++- assets/fonts/Bangers-Regular.ttf | Bin 0 -> 91216 bytes config.js | 19 +++- gameplay.js | 131 +++++++++++++++++------ index.html | 7 +- input.js | 12 ++- main.js | 11 +- render.js | 173 ++++++++++++++++++++++--------- state.js | 8 ++ zombies.js | 9 +- 10 files changed, 345 insertions(+), 95 deletions(-) create mode 100644 assets/fonts/Bangers-Regular.ttf 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 0000000000000000000000000000000000000000..df7d5dc4aa9f777cfb0a8c5491be960e60f4b9e7 GIT binary patch literal 91216 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

;qKL2oHM;G=W%*9U%bPq+WDNXEs~4n61h|^ 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 SBHnk=K7}XW*!VsrN&gL=LFMQG literal 0 HcmV?d00001 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) {