diff --git a/HANDOFF.md b/HANDOFF.md index ac84588..027208e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -106,6 +106,37 @@ gives clean manual stepping. `T.press('Space')` = tap. Dismiss the intro overlay human-like tapping (tap when incoming ball d<2.2–3.2) = 50 hits 0 whiffs; feet-bounce tap sweep ≈ 19/30 winning frames. +## POLISH ROUND — all 14 items of POLISH-PLAN.md are DONE (2026-07-27) +New foundations you must know before touching anything: +- **`G.modal`** (state.js) is the only thing that stops the world: `update()` early-returns on it + after the flash/card decay, so no zombie steps, no ball flight, no spawn timers — only `idleT` + ticks (for the blink). `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing + render() draws. Dismissal must SWALLOW the gesture: input.js `touchstart` returns on it, keydown + returns on it, and a `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch, + pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the + touchstart underneath serve. `G.seen.` gates first-time-only modals. +- **`UI` in config.js** is the accent palette (hot/warm/pick/cream/dim/bad/ball). Every HUD colour + goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. +- **`F(size, bold)` in render.js** builds every canvas font string. Bare `monospace` resolves to + Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never write `ctx.font =` by + hand. Widest string checked: `SERVE FIRES: … TAP THE RACK TO SWITCH` at 259px of 270 portrait. +- **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`. + The old game schedule now calls the DUEL instead (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`), + fired from `proceedAfterClear()` — the one beat where the court is guaranteed empty. The beat it + interrupts is parked in `G.afterDuel` and resumed when the duel resolves. `G.shameGame` is gone. +- **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside + a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST `continue`. + Flames are drawn procedurally (`drawFlames`), no sprite sheet. +- **Sprite heights follow the PNG now.** `drawBoy` and the brute wound stages scale by + `img.height / SPRITE_H`, because `_slice.py` common-scales a sheet: a deep mopping lean and a + headless brute are genuinely shorter and were being stretched back to full height. +- Benchmarks re-run and holding: CPU returns 23/24 serves; 30 human-tapped points = **50 hits / + 0 whiffs** — but you MUST set `T.TUNE.autoAssist = 1.15` first, the Browser pane reports + `pointer: fine` so the assist is off and every ball reads as "never reachable". +- Brute is 5 hp: worst-case (max ramp) it still needs 22.5s to cross, vs ~7s of serves to kill. +- `TUNE.burnSpread` 1.3 m chains reliably through shoulder-to-shoulder shamblers (~1.0 m apart); + at 1.2 m spacing the tail of a line can survive. It is a T-panel knob if Oliver wants it hotter. + ## Gotchas learned the hard way - Browser-pane verification: python http.server sends no cache headers, so edited ES modules often load STALE even after location.reload(). Reliable fix: preview_stop + @@ -122,6 +153,12 @@ gives clean manual stepping. `T.press('Space')` = tap. Dismiss the intro overlay half with --largest. Passing the existing sheet as --input (edit mode) nails the style. - drawChar mirrors sprites by ch.facing — any stance that must not mirror needs facing reset (setupPoint pins facing=1). +- Standalone art (logo, popup panels) uses `assets/_key.py` instead of `_slice.py`: chroma-key + + crop to the content bbox + optional downscale, with NO frame splitting and NO forced sprite + height. `python3 assets/_key.py sheet.png out.png [target_width]`. +- If a generated sheet's frames TOUCH (a mop handle bridging the gap), `_slice.py` finds 1 frame + and there is no column of magenta to split on — re-prompt for "at least 150px of empty magenta + between frames" and "the prop must stay inside its own frame". That worked; hand-splitting did not. - The scratchpad venv/scripts get wiped between sessions — recreate venv (requests+pillow) and slice.py lives only in scratchpad; its logic is described above if gone. - Don't trust single-sample gameplay sims; randomness (aiFlub 9%, aim error) needs ~12 runs diff --git a/assets/_key.py b/assets/_key.py new file mode 100644 index 0000000..b8cd46f --- /dev/null +++ b/assets/_key.py @@ -0,0 +1,26 @@ +# chroma-key a generated image on its corner colour, despeckle, crop to the content bbox, +# and (optionally) downscale to a target width. For standalone art (logo, popup panels) — +# unlike assets/_slice.py this does NOT split frames or force a sprite height. +import sys +from PIL import Image + +KEY_D2 = 60 * 60 + +def main(src, dst, target_w=None): + im = Image.open(src).convert('RGBA') + w, h = im.size + px = im.load() + bg = px[0, 0][:3] + for x in range(w): + for y in range(h): + r, g, b, a = px[x, y] + if (r - bg[0]) ** 2 + (g - bg[1]) ** 2 + (b - bg[2]) ** 2 < KEY_D2 or (min(r, b) > g + 40): + px[x, y] = (0, 0, 0, 0) + im = im.crop(im.getbbox()) + if target_w and im.width > target_w: + im = im.resize((target_w, max(1, round(im.height * target_w / im.width))), Image.LANCZOS) + im.save(dst) + print(dst, im.size) + +if __name__ == '__main__': + main(sys.argv[1], sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else None) diff --git a/assets/_sheet_boymop.png b/assets/_sheet_boymop.png new file mode 100644 index 0000000..0a8560f Binary files /dev/null and b/assets/_sheet_boymop.png differ diff --git a/assets/_sheet_gwound2.png b/assets/_sheet_gwound2.png new file mode 100644 index 0000000..71a156e Binary files /dev/null and b/assets/_sheet_gwound2.png differ diff --git a/assets/_sheet_howto.png b/assets/_sheet_howto.png new file mode 100644 index 0000000..0bd3ff0 Binary files /dev/null and b/assets/_sheet_howto.png differ diff --git a/assets/_sheet_logo.png b/assets/_sheet_logo.png new file mode 100644 index 0000000..dcbfe57 Binary files /dev/null and b/assets/_sheet_logo.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..29b214b Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/x_boymop1.png b/assets/x_boymop1.png new file mode 100644 index 0000000..f7c619d Binary files /dev/null and b/assets/x_boymop1.png differ diff --git a/assets/x_boymop2.png b/assets/x_boymop2.png new file mode 100644 index 0000000..cc5db5c Binary files /dev/null and b/assets/x_boymop2.png differ diff --git a/assets/x_boymop3.png b/assets/x_boymop3.png new file mode 100644 index 0000000..0466554 Binary files /dev/null and b/assets/x_boymop3.png differ diff --git a/assets/x_boymop4.png b/assets/x_boymop4.png new file mode 100644 index 0000000..22a0468 Binary files /dev/null and b/assets/x_boymop4.png differ diff --git a/assets/x_howto.png b/assets/x_howto.png new file mode 100644 index 0000000..ab67f56 Binary files /dev/null and b/assets/x_howto.png differ diff --git a/assets/z_gw2.png b/assets/z_gw2.png new file mode 100644 index 0000000..5d51036 Binary files /dev/null and b/assets/z_gw2.png differ diff --git a/assets/z_gw3.png b/assets/z_gw3.png new file mode 100644 index 0000000..44bbd6c Binary files /dev/null and b/assets/z_gw3.png differ diff --git a/assets/z_gw4.png b/assets/z_gw4.png new file mode 100644 index 0000000..6ddd66a Binary files /dev/null and b/assets/z_gw4.png differ diff --git a/config.js b/config.js index a0d71aa..daefb83 100644 --- a/config.js +++ b/config.js @@ -30,7 +30,8 @@ export const TUNE = { zSpawnStep: 0.22, // spawn-gap reduction per finished game zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests) zMaxCap: 14, // absolute shambler cap - changeoverT: 4.5, // s the changeover breather lasts (after odd games, as the rules demand) + changeoverT: 7.0, // s the changeover breather lasts (after odd games, as the rules demand) + boyMopSpeed: 4.0, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5) gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness) @@ -62,10 +63,15 @@ export const TUNE = { backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs - ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here) - ballChangeEvery: 4, // games between deliveries after the first + // The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round + // on its own so there is always a chance at one. Fires off the next hand-cleared wave. + duelFirst: 2, // game after which the first scheduled sudden-death duel is called + duelEvery: 3, // games between scheduled duels after the first bombMax: 3, // bomb balls in a fresh tin - wCap: 3, // per-type cap on the courtside rack — tins accumulate, but only so many + tinTriples: 3, // triple-serves in a fresh tin + tinFlames: 3, // flame balls in a fresh tin + tinGuns: 1, // machine-gun charges in a fresh tin + wCap: 9, // per-type cap on the courtside rack — tins accumulate, but only so many rackH: 22, // px height of the bottom weapon rack (feel knob: how much court it covers) bombRadius: 2.5, // m blast radius of a bomb ball hordeLen: 12, // s a horde wave lasts @@ -76,8 +82,14 @@ export const TUNE = { runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass + // fire: the flame ball no longer just kills what it touches, it starts an epidemic. + burnTime: 3.0, // s a body burns before the fire goes out + burnDps: 2.0, // hp lost per second while burning — burnTime*burnDps must exceed bruteHp or tanks walk out of the fire + burnSpread: 1.3, // m — a burning body ignites anything this close, every frame it burns + burnIgniteR: 2.6, // m — the flame ball's ignition radius around the body it kills + 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) - bruteHp: 2, // splat-quality hits (or pellets) a brute ('g') soaks before it dies + bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you racketHp: 3, // splat-quality hits a racket zombie (mini-boss) takes before it dies @@ -88,6 +100,17 @@ export const TUNE = { killPips: 1, // splatter: show the kills-to-next-point pips on the scoreboard }; +// the death animation's timeline, in seconds of G.eatenT. A: thrash · B: you burst into gore +// · C: the summary fades in. The restart tap is gated past the whole thing so a panic tap +// during the burst can't skip it. +export const EATEN_BURST = 0.5, EATEN_SUMMARY = 1.6, EATEN_RESTART = 2.2; + +// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately +// desaturated from the original arcade primaries: they were shouting over the artwork. +// (NOT the gore — blood and piles keep their loud reds; only text and interactive UI.) +export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc', + dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' }; + export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 }; COURT.svcFar = COURT.netZ - 6.4; // far service line COURT.svcNear = COURT.netZ + 6.4; // near service line diff --git a/gameplay.js b/gameplay.js index b6aeb1f..918a461 100644 --- a/gameplay.js +++ b/gameplay.js @@ -1,11 +1,11 @@ // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. -import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; +import { TUNE, UI, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js'; import { axisX, axisZ, takeFlickAim, touchPX } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies -import { updateZombies, spawnZombie, spawnRacketZombie, zombies, explode, meleeSwipe, extras, parts } from './zombies.js'; +import { updateZombies, spawnZombie, spawnRacketZombie, zombies, explode, meleeSwipe, extras, parts, playerGore } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -209,6 +209,17 @@ function scoreKey() { return N[s[0]] + '_' + N[s[1]]; } +// ---- 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. +export function showModal(m) { if (!G.modal) G.modal = m; } +export function dismissModal() { + const m = G.modal; if (!m) return false; + G.modal = null; if (m.onDismiss) m.onDismiss(); + return true; // the caller must swallow the gesture: dismissing must never also serve +} + export function cheer(seconds, vol) { sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies) crowd.cheer = Math.max(crowd.cheer, seconds); @@ -257,11 +268,11 @@ export function splatPoint(side, quiet = false) { const gameOver = advancePoint(side); if (gameOver) { G.runGames++; - if (side === 'O') G.shameGame = true; // they took a game — the next new-balls delivery is withheld - // the new-balls rule arms you: deliveries land at the changeover that follows the scheduled game - if (G.runGames === TUNE.ballChangeFirst || - (G.runGames > TUNE.ballChangeFirst && (G.runGames - TUNE.ballChangeFirst) % TUNE.ballChangeEvery === 0)) - G.tinDue = true; + // the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance + // at a weapon, but you still have to win the point to be handed one. Fires at the next clear. + if (G.runGames === TUNE.duelFirst || + (G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0)) + G.duelDue = true; voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan const i = side === 'P' ? 0 : 1; @@ -291,11 +302,15 @@ export function proceedAfterClear() { G.clearing = false; const mode = G.afterClear, side = G.clearWinner; G.afterClear = null; + // A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled + // duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.) + // The set/horde beat outranks it; the duel just waits for the next clear. + if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; } if (mode === 'set') { G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over) sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM', sub2: 'SETS ' + G.sets[0] + ' – ' + G.sets[1], - color: side === 'P' ? '#e6ee3a' : '#ff5050', t: TUNE.setCardT }; + color: side === 'P' ? UI.pick : UI.hot, t: TUNE.setCardT }; G.pendingHorde = true; // the horde begins when the card's pause ends ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT; } else if (mode === 'changeover') { @@ -310,13 +325,12 @@ export function proceedAfterClear() { function startChangeover() { G.changeover = true; G.phase = 'over'; G.overT = TUNE.changeoverT; - sendBoy(); - if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery - G.tinDue = false; - if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds - else deliverTin(); - G.shameGame = false; // the slate is clean either way - } else G.flash = { text: 'CHANGEOVER', t: 1.4 }; + // out with the mop, across the near court where the gore is — NOT to the ball: a dead ball + // parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is + // now long enough that an empty court reads as the game having stalled. + sendBoy(0, COURT.L * 0.62, true); + G.flash = { text: 'CHANGEOVER', t: 1.4 }; + // no tins here anymore — weapons are won in the duel, never handed out on a schedule } // ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is @@ -332,19 +346,44 @@ export function selectWeapon(i) { // the weapon ladder: each delivery opens the next tin in the cycle and ADDS it to the rack // (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 WDESC = { // what the tin you just won actually does — one line, shown on the pickup modal + BOMB: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'], + TRIPLE: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'], + FIRE: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'], + GUN: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'], +}; export function deliverTin() { const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k); if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax); - else if (w === 1) G.triples = add(G.triples + 3); - else if (w === 2) G.flames = add(G.flames + 3); - else G.guns = add(G.guns + 1); // one machine-gun charge — a burst you pop when you choose, not on arrival + else if (w === 1) G.triples = add(G.triples + TUNE.tinTriples); + else if (w === 2) G.flames = add(G.flames + TUNE.tinFlames); + else G.guns = add(G.guns + TUNE.tinGuns); // machine-gun charges — a burst you pop when you choose, not on arrival if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking) sendBoy(); voice('new_balls', 0.6); - G.flash = { text: 'NEW BALLS: ' + SLOTS[w + 1], t: 1.6 }; + const name = SLOTS[w + 1]; + showModal({ title: name, body: WDESC[name], icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w], + iconH: 40, color: UI.warm }); } // ---- the interlude: order is restored, one sudden-death point vs the racket zombie. // Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching. +// This is now the ONLY source of weapons in the game: win the point or leave empty-handed. +export function startDuel() { + if (G.interlude) return; + if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent + for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel + G.weapon = 0; // tennis rules for the duel: plain balls, no tins + G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces + ball.active = false; setupPoint(); + zombieDuck(true); // fade the zombie music out for the 1:1 duel + voice('play_resume', 0.2); voice('sudden_death', 2.4); + if (!G.seen.duel) { // the rules change back — worth one real interruption, once per run + G.seen.duel = true; + showModal({ title: 'SUDDEN DEATH', color: UI.hot, + body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'] }); + } else G.flash = { text: 'SUDDEN DEATH', t: 1.6 }; +} + export function resolveInterlude(side) { if (!G.interlude) return; G.interlude = false; @@ -355,12 +394,16 @@ export function resolveInterlude(side) { deliverTin(); // win the point, win the next tin — on the spot } else { voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel - score.games[1]++; G.runGames++; G.shameGame = true; // it goes on the scoreboard + score.games[1]++; G.runGames++; // it goes on the scoreboard if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; } G.flash = { text: 'GAME. THEM.', t: 1.6 }; for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b'); } + if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled + G.afterClear = G.afterDuel; G.afterDuel = null; + proceedAfterClear(); + } } // retire the splatter ball; a running changeover's long beat must not be clobbered short @@ -407,7 +450,14 @@ function transition(dt) { 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'); }); 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(); }); + once('go', 8.2, () => { + G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic(); + // 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'] }); + }); } function fault() { @@ -672,7 +722,11 @@ function expirePose(ch, dt) { export function update(dt) { if (G.hitstop > 0) { G.hitstop -= dt; return; } if (G.mode === 'eaten') { // the match is over in the truest sense - G.idleT += dt; G.eatenT += dt; + G.idleT += dt; + const was = G.eatenT; G.eatenT += dt; + if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore + if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; + if (G.boomFlash > 0) G.boomFlash -= dt; updateZombies(dt); return; } @@ -680,6 +734,9 @@ export function update(dt) { if (G.card) { 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.boomFlash > 0) G.boomFlash -= dt; + // A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers, + // so nobody gets eaten while reading a popup. idleT keeps running for the blink. + if (G.modal) { G.idleT += dt; return; } crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; const w = BOUNCE_IDLE.tempo; @@ -757,14 +814,11 @@ export function update(dt) { // --- interlude trigger: the court is clear, an opponent holds the far court, you're about // to serve — the umpire recognizes a legal match and restores order. One point. Sudden death. + // The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is + // pure upside for weapon supply. if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) - && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) { - zombies[0].dueled = true; // one duel per opponent — after that it just rallies - G.weapon = 0; // tennis rules for the duel: force plain balls, no tins - G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return - zombieDuck(true); // fade the zombie music out for the 1:1 duel - voice('play_resume', 0.2); voice('sudden_death', 2.4); - } + && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) + startDuel(); if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt; // the duel is played under tennis lights: lerp the palette home while it runs, back after G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8)); @@ -877,8 +931,9 @@ export function update(dt) { // ball boy: sprint across, scoop the ball in passing, despawn off the far side const boy = G.boy; if (boy) { - boy.x += boy.dir * 7.5 * dt; boy.runT += dt; - if (!ball.collected && !ball.active) { + // mopping is not sprinting: the mop crossing takes ~6.6s, which is what fills the changeover + boy.x += boy.dir * (boy.mop ? TUNE.boyMopSpeed : 7.5) * dt; boy.runT += dt; + if (!boy.mop && !ball.collected && !ball.active) { // a mop boy has no ball to chase — he must hold his lane and clean it const dz = ball.z - boy.z; boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; @@ -890,7 +945,13 @@ export function update(dt) { if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) { - if (G.pendingHorde) { G.pendingHorde = false; startHorde(); return; } // the set card's pause is over — release the horde + if (G.pendingHorde) { // the set card's pause is over — but the horde is a different CONTROL MODE + G.pendingHorde = false; // (racket auto-fires, the finger steers you absolutely), + showModal({ title: 'THEY KEEP COMING', color: UI.hot, // so it gets a real block, not just a card + body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'], + onDismiss: startHorde }); + return; + } if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes // auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into // setupPoint() -> pretoss left the player locked in the serve posture while the gun diff --git a/index.html b/index.html index e8ae0fc..7bdbda6 100644 --- a/index.html +++ b/index.html @@ -13,26 +13,23 @@ html, body { margin: 0; height: 100%; background: #10141c; display: grid; place-items: center; } canvas { image-rendering: pixelated; width: min(96vw, calc(96vh * 16 / 9)); aspect-ratio: 16/9; touch-action: none; } #hint { position: fixed; bottom: 8px; width: 100%; text-align: center; - font: 12px monospace; color: #6b7688; } + font: 400 10px Silkscreen, monospace; color: #6b7688; } @media (pointer: coarse) { #hint, #intro .kb { display: none; } } #intro { position: fixed; inset: 0; z-index: 10; touch-action: none; display: grid; place-items: center; background: radial-gradient(circle at 50% 34%, rgba(34,16,20,0.92), rgba(8,10,16,0.96)); } - #intro .card { font: 13px monospace; color: #dfe6f2; text-align: center; line-height: 1.9; + #intro .card { font: 400 11px Silkscreen, monospace; color: #dfe6f2; text-align: center; line-height: 1.9; padding: 18px 26px; max-width: 92vw; animation: drop 0.5s ease-out; } @keyframes drop { from { transform: translateY(-14px); opacity: 0; } } - #intro .logo { line-height: 0.9; margin-bottom: 8px; } - #intro .logo span { display: block; font-weight: 900; } - #intro .l1 { font-size: 46px; letter-spacing: 3px; color: #e0231a; - text-shadow: 0 2px 0 #7a0f0a, 0 4px 0 #550906, 0 6px 1px rgba(0,0,0,0.4), 0 0 20px rgba(224,35,26,0.35); } - #intro .l2 { font-size: 30px; letter-spacing: 9px; color: #eef23a; margin-top: 2px; - text-shadow: 0 2px 0 #59610f, 0 0 14px rgba(238,242,58,0.3); } - #intro .slogan { margin: 8px 0 16px; font-size: 13px; font-style: italic; color: #b6a0a4; letter-spacing: 0.5px; } - #intro .slogan b { color: #e0231a; font-style: normal; } + /* the logo is generated pixel art now (assets/logo.png), not stacked CSS text */ + #intro .logo { display: block; width: min(320px, 78vw); height: auto; margin: 0 auto 8px; + image-rendering: pixelated; filter: drop-shadow(0 3px 0 rgba(0,0,0,0.45)); } + #intro .slogan { margin: 8px 0 16px; font-size: 12px; color: #b6a0a4; letter-spacing: 0.5px; } + #intro .slogan b { color: #c9564a; } #intro .how { display: inline-block; text-align: left; color: #cbd4e2; line-height: 2; } #intro .how p { margin: 0; } #intro .how span { display: inline-block; width: 30px; } #intro em { color: #8a96ab; font-style: normal; font-size: 12px; } - #intro .start { margin-top: 18px; color: #f8e048; font-weight: bold; letter-spacing: 1px; animation: blink 1.1s step-end infinite; } + #intro .start { margin-top: 18px; color: #e0cc72; font-weight: bold; letter-spacing: 1px; animation: blink 1.1s step-end infinite; } @keyframes blink { 50% { opacity: 0.25; } } @@ -41,10 +38,11 @@
move: WASD/arrows  ·  serve/swing: SPACE  ·  aim: hold a direction while swinging  ·  T: tuning panel
- + +

Fifteen. Thirty. Forty. Dead.

-

👆 tap — serve & swing

+

👆 tap — serve + swing

👉 flick — aimed shot

✋ drag — run (auto-run is on)

⌨️ WASD / arrows + SPACE

@@ -52,6 +50,6 @@

TAP TO START

- + diff --git a/input.js b/input.js index 8b288d8..da9fecb 100644 --- a/input.js +++ b/input.js @@ -6,13 +6,14 @@ import { cv } from './dom.js'; import { audio, pock } from './audio.js'; import { TUNE } from './config.js'; import { G, score } from './state.js'; -import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality } from './gameplay.js'; +import { keyPressed, dragServe, selectWeapon, serveToss, pullQuality, dismissModal } from './gameplay.js'; import { rackHit } from './render.js'; import { togglePanel } from './panel.js'; const keys = {}; 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 (!keys[e.code]) keyPressed(e.code); @@ -45,7 +46,8 @@ export function takeFlickAim(maxAgeMs = 450) { // EVERY phase of splatter play, so the drag never silently turns into something else. The old // "drag walks the player" mode between shots was invisible state and read as a bug. const canAimServe = () => - G.mode === 'splatter' // every phase of it; 'eaten' is its own mode and never gets here + !G.modal // the world is frozen behind a popup — the thumb that clears it must not draw a bow + && G.mode === 'splatter' // every phase of it; 'eaten' is its own mode and never gets here && score.server === 'P' && G.weapon !== 4 // GUN pops on tap, no reticle && !G.interlude && !G.horde && !(G.mgT > 0) && !G.changeover; // the duel serves like tennis: no reticle @@ -56,6 +58,7 @@ cv.addEventListener('touchstart', e => { e.preventDefault(); audio(); const r = cv.getBoundingClientRect(); for (const t of e.changedTouches) { + if (dismissModal()) return; // the popup owns this gesture and nothing else sees it if (!G.horde && !(G.mgT > 0)) { // rack tap selects a tin — but never during finger-steer modes const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height); if (slot >= 0) { selectWeapon(slot); continue; } @@ -121,3 +124,9 @@ const endTouch = e => { }; cv.addEventListener('touchend', endTouch, { passive: false }); cv.addEventListener('touchcancel', endTouch, { passive: false }); + +// Mouse/desktop dismissal. Registered ONLY on fine pointers: on a touch screen pointerdown +// fires before touchstart, so a shared handler would clear the modal and let the touchstart +// below it fall through into a serve — exactly the swallowed-gesture bug. +if (!matchMedia('(pointer: coarse)').matches) + cv.addEventListener('pointerdown', () => dismissModal()); diff --git a/main.js b/main.js index f64511d..205b4b8 100644 --- a/main.js +++ b/main.js @@ -3,9 +3,9 @@ // Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI) -import { TUNE, COURT, UMP_H, UMP_X } from './config.js'; +import { TUNE, COURT, UMP_H, UMP_X, EATEN_RESTART } from './config.js'; import { P, O, ball, score, G, setupPoint } from './state.js'; -import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon } from './gameplay.js'; +import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude } from './gameplay.js'; import { fitViewport, render, proj, RES } from './render.js'; import { voice, ambient } from './audio.js'; import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js'; @@ -34,8 +34,9 @@ function dismissIntro() { intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); }); addEventListener('keydown', () => dismissIntro(), { once: true }); -// eaten: any tap or key (after a beat, so death-spam doesn't skip the screen) restarts -const maybeRestart = () => { if (G.mode === 'eaten' && G.eatenT > 0.7) location.reload(); }; // ponytail: reload = honest full reset +// eaten: any tap or key restarts — but only once the death animation has played out, so a +// panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js). +const maybeRestart = () => { if (!G.modal && G.mode === 'eaten' && G.eatenT > EATEN_RESTART) location.reload(); }; // ponytail: reload = honest full reset cv.addEventListener('pointerdown', maybeRestart); addEventListener('keydown', maybeRestart); @@ -57,6 +58,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index 8a6ca14..3b12971 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, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js'; +import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { P, O, ball, score, crowd, G } from './state.js'; import { predictLanding, flickTarget } from './gameplay.js'; import { zombies, gore, piles, parts, pellets, extras } from './zombies.js'; @@ -32,6 +32,11 @@ function palette() { return m; } +// 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`; +const hexA = (h, a) => `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},${a})`; + // ---------------------------------------------------------------- camera ---- const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC export const RES = 3; // physical pixels per logical pixel — 3x so phone screens downscale (crisp) instead of upscaling (soft) @@ -71,6 +76,8 @@ const SPR = {}; for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2', 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_ads', + 'x_boymop1','x_boymop2','x_boymop3','x_boymop4','x_howto', // the mopping boy, and the slingshot tutorial panel + 'z_gw2','z_gw3','z_gw4', // the brute comes apart in stages (4/5 hp = z_gw, down to 1/5 = z_gw4) 'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2', 'z_f1','z_f2','z_f3','z_f4','z_g1','z_g2','z_gw','z_s1','z_s2','z_s3', 'z_cw1','z_cw2','z_aw','z_bw', // wounded skins for multi-hit enemies (racket boss, brute, and now the classic shamblers) @@ -94,7 +101,7 @@ export function buildCrowd() { // -------------------------------------------------------------- primitives ---- function line(x1, z1, x2, z2, w = 1) { const a = proj(x1, 0, z1), b = proj(x2, 0, z2); - ctx.strokeStyle = '#e8e8dc'; ctx.lineWidth = w; + ctx.strokeStyle = UI.cream; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trapezoid) @@ -121,7 +128,7 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) { ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill(); return; } - ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill(); + ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill(); if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); } } @@ -134,11 +141,16 @@ function drawStatic(name, x, z, hMeters, bob = 0) { ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h); } function drawBoy(b) { - const img = SPR['x_boyrun' + (Math.floor(b.runT * 14) % 4 + 1)]; if (!img) return; + // mopping is not sprinting: its own sheet and a slower push cycle. Falls back to the run + // frames until the mop PNGs have loaded. + const mop = b.mop && SPR.x_boymop1; + const img = SPR[(mop ? 'x_boymop' : 'x_boyrun') + (Math.floor(b.runT * (mop ? 8 : 14)) % 4 + 1)]; if (!img) return; const p = proj(b.x, 0, b.z); - const h = BOY_H * p.s, w = h * img.width / img.height; + // scale off the frame's OWN pixel height, not a fixed BOY_H: the sheets are common-scaled per + // sheet, so a deep mopping lean is genuinely shorter and must not be stretched back upright + const h = BOY_H * p.s * (img.height / SPRITE_H), w = h * img.width / img.height; drawShadow(p.x, p.y, w * 0.3, h * 0.04, 0.25); - ctx.save(); ctx.translate(p.x, p.y - (Math.floor(b.runT * 20) % 2)); + ctx.save(); ctx.translate(p.x, p.y - (mop ? 0 : Math.floor(b.runT * 20) % 2)); if (b.dir < 0) ctx.scale(-1, 1); ctx.drawImage(img, -w / 2, -h, w, h); ctx.restore(); @@ -157,17 +169,37 @@ function drawZombie(zb) { // shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in // zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded. let name = 'z_' + v + frame; - if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw'; + if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out + const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))]; + if (SPR[stage]) name = stage; else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded + if (SPR[name]) h *= SPR[name].height / SPRITE_H; // the headless stage really is shorter — don't stretch it back + } else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp && SPR['z_' + zb.variant + 'w']) name = 'z_' + zb.variant + 'w'; // gashed shambler else if (rk && zb.hp <= 1 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do else if (rk && zb.hp === 2 && SPR.z_cw1) name = 'z_cw1'; drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2); + if (zb.burnT > 0) drawFlames(zb, h); if (rk) { // HP bar floating above the mini-boss const p = proj(zb.x, 2.05, zb.z), w = Math.max(10, p.s * 0.5), x = p.x - w / 2; ctx.fillStyle = '#300'; ctx.fillRect(x, p.y, w, 3); ctx.fillStyle = '#e03030'; ctx.fillRect(x, p.y, w * Math.max(0, zb.hp) / TUNE.racketHp, 3); } } +// Fire, drawn procedurally instead of as a sprite sheet: a few vertically-stretched quads whose +// offsets ride each body's own phase (walkT), so no two burning zombies flicker in sync — and it +// reads at every burn stage, from fresh yellow flare to guttering smoke, for free. +function drawFlames(zb, hMeters) { + const p = proj(zb.x, 0, zb.z), s = p.s; + const k = Math.max(0, Math.min(1, zb.burnT / TUNE.burnTime)); // 1 = just lit, 0 = about to gutter out + const col = k > 0.6 ? '#ffc030' : k > 0.3 ? '#ff8010' : '#6b5a52'; + for (let i = 0; i < 5; i++) { + const ph = G.idleT * (7 + i * 1.7) + zb.walkT * 3.1 + i * 2; + const fx = p.x + Math.sin(ph) * s * 0.11 + (i - 2) * s * 0.05; + const fy = p.y - (0.2 + i * 0.15 + 0.25 * k) * hMeters * s - Math.abs(Math.sin(ph * 1.6)) * s * 0.08; + ctx.fillStyle = i === 0 && k > 0.5 ? '#ffe8a0' : col; // one white-hot core tongue while it rages + ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2))))); + } +} function drawRing(x, z, color) { const p = proj(x, 0, z); // integer-snapped so the moving ring steps whole pixels instead of shimmering @@ -298,7 +330,7 @@ function drawRack() { for (let i = 0; i < g.n; i++) { const x = g.x0 + i * g.tw, sel = G.weapon === i, dim = stock[i] <= 0 && i > 0; ctx.fillStyle = sel ? '#123a24' : '#0a2418'; ctx.fillRect(x + 1, g.y0 + 1, g.tw - 2, g.h - 2); // sunk slot - ctx.strokeStyle = sel ? '#e6ee3a' : '#05160e'; ctx.strokeRect(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3); // gold frame when live + ctx.strokeStyle = sel ? UI.pick : '#05160e'; ctx.strokeRect(x + 1.5, g.y0 + 1.5, g.tw - 3, g.h - 3); // 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; ctx.globalAlpha = dim ? 0.35 : 1; @@ -306,17 +338,17 @@ function drawRack() { if (spr) { const k = Math.min(box / spr.width, box / spr.height), iw = spr.width * k, ih = spr.height * k; ctx.drawImage(spr, icx - iw / 2, icy - ih / 2, iw, ih); - } else { ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(icx, icy, 3, 0, 7); ctx.fill(); } // fallback until the PNG loads + } else { ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(icx, icy, 3, 0, 7); ctx.fill(); } // fallback until the PNG loads // count over label, both centred on the text column right of the icon — aligned as one unit const tx = x + pad + box + (g.tw - pad * 2 - box) / 2; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.globalAlpha = dim ? 0.5 : 1; - ctx.font = '700 8px Silkscreen, monospace'; ctx.fillStyle = sel ? '#e6ee3a' : '#e8f0dc'; + ctx.font = F(8); ctx.fillStyle = sel ? UI.pick : '#e8f0dc'; ctx.fillText(i === 0 ? '∞' : '×' + stock[i], tx, g.y0 + pad + 5); // ball = ∞, else ×count - ctx.font = '400 6px Silkscreen, monospace'; ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a'; + ctx.font = F(6, 0); ctx.fillStyle = sel ? '#f2ead6' : '#8fa89a'; ctx.fillText(labels[i], tx, g.y0 + g.h - pad - 3); // weapon label, under its count ctx.globalAlpha = 1; - if (i === 4 && G.mgT > 0) { ctx.fillStyle = '#e6ee3a'; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown + if (i === 4 && G.mgT > 0) { ctx.fillStyle = UI.pick; ctx.fillRect(x + 2, g.y0 + g.h - 2, (g.tw - 4) * Math.min(1, G.mgT / TUNE.mgPickupLen), 1.5); } // burst countdown } ctx.restore(); } @@ -326,12 +358,39 @@ function drawRack() { function drawCard(title, sub1, sub2, color) { ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64); ctx.textAlign = 'center'; - ctx.fillStyle = color; ctx.font = 'bold 18px monospace'; ctx.fillText(title, W / 2, H / 2 - 26); - ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace'; + ctx.fillStyle = color; ctx.font = F(16); ctx.fillText(title, W / 2, H / 2 - 26); + 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); } +// 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. +function drawModal(m) { + ctx.fillStyle = 'rgba(8,6,10,0.82)'; ctx.fillRect(0, 0, W, H); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const body = m.body || [], img = m.icon && SPR[m.icon]; + // cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a + // 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; + let y = Math.round(H / 2 - blockH / 2); + if (img) { + const iw = Math.min(W - 24, iconH * img.width / img.height); + ctx.drawImage(img, Math.round(W / 2 - iw / 2), y, Math.round(iw), iconH); + y += iconH + 12; + } + ctx.font = F(14); ctx.fillStyle = m.color || UI.warm; + ctx.fillText(m.title, W / 2, y + 8); y += 22; + ctx.font = F(8, 0); ctx.fillStyle = UI.cream; + for (const l of body) { ctx.fillText(l, W / 2, y + 6); y += 13; } + if (Math.floor(G.idleT * 2) % 2) { // the prompt blinks so it never reads as part of the copy + ctx.font = F(8); ctx.fillStyle = UI.warm; + ctx.fillText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, Math.round(H / 2 + blockH / 2 + 22)); + } + ctx.textBaseline = 'top'; +} + // ------------------------------------------------------------------ frame ---- export function render() { ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density @@ -402,7 +461,7 @@ export function render() { const l = predictLanding(); if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)'); } - if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? '#ff5050' : '#f8e048'); + if (G.outMarker) drawRing(G.outMarker.x, G.outMarker.z, G.outMarker.out ? UI.hot : UI.warm); // slingshot aim. Three readings, all of them power: a chevron trail running down the lane, an // arrowhead on the target, and a notched meter over your head. The notch is the point of the // whole thing — it puts the kill threshold on screen instead of leaving it to be discovered. @@ -412,7 +471,7 @@ export function render() { const kills = ft.q >= TUNE.zSplatQ; // cool blue-white = this pull only staggers. It stays READABLE on the dark lawn: you still // need to see where you're pointing, you just need to know it won't kill. - const col = !kills ? '#9db4d0' : ft.zt ? '#ff5050' : '#f8e048'; + const col = !kills ? '#9db4d0' : ft.zt ? UI.hot : UI.warm; if (tx !== undefined) { const a = proj(P.x, 1.4, P.z), b = proj(tx, 0.35, tz); const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1; @@ -501,9 +560,21 @@ export function render() { for (const pe of pellets) { const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25); const pp = proj(pe.x, pe.y, pe.z); - ctx.fillStyle = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2); + ctx.fillStyle = UI.warm; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2); + } + // death, in three stages. A: you are still there, thrashing and going red as they crowd in. + // B (from EATEN_BURST): the sprite is gone and you ARE the gore — playerGore() drops the mound. + // C: the summary fades in over it, so the biggest pile on the court stays visible underneath. + if (G.mode !== 'eaten') drawChar(P); + else if (G.eatenT < EATEN_BURST) { + const k = G.eatenT / EATEN_BURST, j = 1 + k * 3; // the shake grows as they pull you down + ctx.save(); + ctx.translate((Math.random() * 2 - 1) * j, (Math.random() * 2 - 1) * j * 0.6); + ctx.filter = `sepia(1) saturate(${2 + 7 * k}) hue-rotate(-25deg) brightness(${1 - 0.25 * k})`; + drawChar(P); + ctx.filter = 'none'; // ponytail: unsupported filter just means no tint — the shake still reads + ctx.restore(); } - if (G.mode !== 'eaten') drawChar(P); // there is no P anymore if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady @@ -515,7 +586,7 @@ export function render() { // status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro. const splat = G.mode !== 'tennis'; const bd = { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', slot: '#0b2b1c', slotSvr: '#2b1a46', - spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: '#e6ee3a', engHi: '#2c7a4f' }; + spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick, engHi: '#2c7a4f' }; const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); }; const ptTxt = i => score.pts[0] >= 3 && score.pts[1] >= 3 ? (score.pts[i] === score.pts[1 - i] ? '40' : score.pts[i] > score.pts[1 - i] ? 'AD' : '40') @@ -531,7 +602,6 @@ export function render() { } // live text laid straight onto the art's own regions (spine ~x14, two rows, right slot ~x105-147), // Silkscreen pixel font — no code-drawn slots or bevels painted on top of the art. - const F = (sz, b = 1) => `${b ? 700 : 400} ${sz}px Silkscreen, monospace`; ctx.textBaseline = 'middle'; const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; for (let i = 0; i < 2; i++) { @@ -555,24 +625,29 @@ export function render() { ctx.restore(); ctx.textBaseline = 'top'; ctx.textAlign = 'center'; if (G.mode === 'splatter' && !G.interlude) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel) - if (G.mode === 'eaten') { // the run summary — permadeath makes this scoreboard the whole game + // phase C: the summary fades UP over the death animation instead of cutting on top of it + if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) { + 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 = '#c02020'; ctx.font = 'bold 16px monospace'; + ctx.fillStyle = UI.bad; ctx.font = F(14); ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40); - ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace'; + 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 = `rgba(248,224,72,${pulse})`; ctx.font = 'bold 11px monospace'; + ctx.fillStyle = hexA(UI.warm, pulse); ctx.font = F(10); ctx.fillText('★ NEW BEST ★', W / 2, H / 2 + 4); } else if (G.best && G.best.games > 0) { - ctx.fillStyle = '#8fa0b8'; ctx.font = 'bold 8px monospace'; + ctx.fillStyle = UI.dim; ctx.font = F(8); ctx.fillText('BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4); } - if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); } + // 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); } + ctx.restore(); } if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) { - ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; + ctx.fillStyle = UI.warm; ctx.font = F(8); const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH'; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon]; const txt = G.mode === 'splatter' @@ -585,28 +660,27 @@ export function render() { ctx.fillText(txt, W / 2, H - TUNE.rackH - RACK_BOT - 17); // sit clear above the rack } if (G.flash) { - ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; + ctx.font = F(12); ctx.fillStyle = '#ffffff'; ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1); } - // the sudden-death duel card, then (below it in priority) the SET milestone card — same block - if (G.interlude && G.interlude.introT > 0) - drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050'); - else if (G.card && G.card.t > 0) + // 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) drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color); if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live - ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060'; + ctx.font = F(8); ctx.fillStyle = '#ff9060'; ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); } if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly const list = G.missLog.slice(-9), lh = 10, top = 40; ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16); - ctx.font = 'bold 7px monospace'; ctx.textAlign = 'left'; - ctx.fillStyle = '#8fa0b8'; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5); + ctx.font = F(7, 0); ctx.textAlign = 'left'; + ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5); const maxCh = Math.floor((W - 20) / 4.3); list.forEach((e, i) => { ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060'; ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh); }); } + if (G.modal) drawModal(G.modal); // last: it is a wall, nothing draws over it ctx.textAlign = 'left'; } diff --git a/state.js b/state.js index 900142c..17d74f2 100644 --- a/state.js +++ b/state.js @@ -42,7 +42,8 @@ export const G = { mgT: 0, // s of machine-gun burst left — auto-fire, no serving (spent from G.guns) mgCool: 0, // s until the next pickup-mode pellet deliveries: 0, // tins delivered this run — indexes the weapon ladder - tinDue: false, // the rules called for new balls — deliver at the next changeover + duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court + afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss) meleeT: 0, // s of racket-swish cooldown left @@ -58,11 +59,15 @@ export const G = { clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card) pendingHorde: false,// a set was won; the horde starts after the set card's pause card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block' - shameGame: false, // they took a game — the next new-balls delivery is withheld + // 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} + seen: {}, // one-shot markers so a first-time-only modal fires once per run 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 - eatenT: 0, // time since you were eaten — gates the restart tap + eatenT: 0, // time since you were eaten — the death-animation clock, and it gates the restart tap + eatenAt: null, // {x, z} where you fell — the gore mound blooms there missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log rallyNear: null, // closest the incoming ball got this rally (for never-swung passes) @@ -92,8 +97,10 @@ export function setupPoint() { G.aiServeT = score.server === 'O' ? 1.4 : 0; } -export function sendBoy(tx = ball.x, tz = ball.z) { // fetch a dead ball — or mop a splat +// fetch a dead ball — or, with mop, come out to CLEAN: a different sprite sheet and a +// slower walk, because mopping is not sprinting. The gore-fading pass is the same either way. +export function sendBoy(tx = ball.x, tz = ball.z, mop = false) { if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run - G.boy = { x: dir > 0 ? -13 : 13, z: tz, dir, runT: 0 }; + G.boy = { x: dir > 0 ? -13 : 13, z: tz, dir, runT: 0, mop }; } diff --git a/zombies.js b/zombies.js index 9e37191..52c35e1 100644 --- a/zombies.js +++ b/zombies.js @@ -67,10 +67,52 @@ function killZombie(i, quiet) { }); } splat(); sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst - sendBoy(zb.x, zb.z); // a boy sprints out to mop + sendBoy(zb.x, zb.z, true); // a boy comes out with the mop if (!G.horde && !G.clearing) splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores } +// ---- fire. The flame ball doesn't just kill what it touches, it starts an epidemic: the kill +// ignites its neighbours, and every burning body keeps igniting whatever it shambles past. +// Read-only over zombies[] (it only sets burnT), so it is safe to call from inside a kill loop. +export function ignite(x, z, r) { + for (const zb of zombies) { + if (!zb || zb.variant === 'r' || zb.burnT > 0) continue; // the duel opponent doesn't burn + if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; if (zb.hp === undefined) zb.hp = 1; } + } +} + +// one burning body's frame: damage, spread, embers. Returns true if the fire killed it (the +// caller must then stop touching zb — killZombie splices, and splatPoint can cascade further). +function burnTick(zb, i, dt) { + zb.burnT -= dt; + ignite(zb.x, zb.z, TUNE.burnSpread); + zb.burnD = (zb.burnD || 0) + TUNE.burnDps * dt; + while (zb.burnD >= 1) { zb.burnD -= 1; zb.hp--; } + if (parts.length < 110) for (let k = 0; k < 2; k++) parts.push({ // embers off the body + x: zb.x + (Math.random() * 2 - 1) * 0.3, y: 0.5 + Math.random() * 1.4, z: zb.z, + vx: (Math.random() * 2 - 1) * 0.5, vy: 1.2 + Math.random(), vz: (Math.random() * 2 - 1) * 0.5, + t: 0.25 + Math.random() * 0.2, fire: true }); + if (zb.hp > 0) return false; + killZombie(i, !!G.horde); + return true; +} + +// phase B of the death animation (EATEN_BURST, config.js): the player IS the gore now. Reuses killZombie's machinery — +// the biggest mound on the court, and it stays visible under the summary scrim. That's the point. +export function playerGore() { + const at = G.eatenAt || { x: P.x, z: P.z }; + gore.push({ x: at.x, z: at.z, r: 1.7, a: 1 }); if (gore.length > 40) gore.shift(); + piles.push({ x: at.x, z: at.z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); + if (piles.length > 30) piles.shift(); + for (let k = 0; k < 90; k++) parts.push({ + x: at.x, y: 1.0, z: at.z, + vx: (Math.random() * 2 - 1) * 5.5, vy: 1 + Math.random() * 6, vz: (Math.random() * 2 - 1) * 5.5, + t: 0.6 + Math.random() * 0.9, dark: k % 3 === 0 }); + splat(1); boom(); + G.shake = { t: 0.35, mag: 7 }; + G.boomFlash = 0.08; +} + // a small dark spatter the instant a multi-hit shambler is wounded — subtle impact pop, NOT the // kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel. function woundBurst(zb) { @@ -158,7 +200,14 @@ export function explode(x, z) { function bleed(zb) { if (parts.length > 90) return; let wounds; - if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) wounds = [[-0.3, 1.95, 1.1]]; // brute: torn-off arm + if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { + // the brute opens up stage by stage: one arm, then both, then the head. Still sparse — + // more ANCHORS, not a heavier flow (the settled feel note: blood stays thin). + wounds = [[-0.3, 1.95, 1.1]]; + if (zb.hp <= TUNE.bruteHp - 2) wounds.push([0.42, 1.9, 1.0]); + if (zb.hp <= TUNE.bruteHp - 3) wounds.push([0.05, 2.35, 2.0]); + if (zb.hp <= 1) wounds.push([0.0, 1.2, 0.7]); + } else if (zb.variant === 'r' && zb.hp <= 1) wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm else if (zb.variant === 'r' && zb.hp === 2) wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp) wounds = [[0.28, 1.35, 0.85]]; // shambler: gashed shoulder @@ -206,7 +255,8 @@ export function updateZombies(dt) { const zb = zombies[i]; if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us zb.walkT += dt; - zb.z += zb.v * dt; + if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us + zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt; bleed(zb); // open wounds bleed every frame they're standing if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out @@ -226,7 +276,9 @@ export function updateZombies(dt) { }); splat(1); voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans - G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience + // the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after + G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z }; + G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience G.horde = null; pellets.length = 0; extras.length = 0; G.mgT = 0; // everything stops; the eaten screen takes over try { // the run is over — settle it against the best (games survived, sets as tiebreak) const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 }; @@ -247,8 +299,9 @@ export function updateZombies(dt) { zb.hp--; splat(0.5); if (zb.hp <= 0) { splatter(i); } continue; } - const vx0 = ball.vx, vz0 = ball.vz; + const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z; splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane + ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up continue; } if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)