Polish round: modal system, softened palette, pixel font, and the 14 plan items

The three foundations first, since everything else leans on them:

- G.modal is the only thing that stops the world. update() early-returns on it
  so no zombie steps, no ball flight, no spawn timers run while a popup is on
  screen — you can read it without being eaten. Dismissal swallows the gesture;
  the pointerdown dismisser is registered only on pointer:fine, because on touch
  pointerdown beats touchstart and a shared handler cleared the modal and then
  served with the same thumb.
- UI in config.js is the accent palette. The arcade primaries were shouting over
  the artwork; the gore reds deliberately stay loud.
- Every canvas font string now goes through F(). Bare `monospace` resolved to
  Courier — a slab serif — which was the "generic serif" bug, not taste.

Then: weapons are only ever WON. deliverTin() has exactly one caller now
(resolveInterlude('P')); the old game schedule calls the DUEL instead, fired off
proceedAfterClear() where the court is guaranteed empty. Four popups (splatter
tutorial with a generated illustration, first duel, weapon received, horde),
wCap 9 with the tin amounts tunable, a 5-hp brute that comes apart over four
wound skins, fire that ignites the neighbours and chains outward, a three-stage
death where you become the biggest gore mound on the court, and a 7s changeover
with a ball boy who finally has a mop.

New art: assets/logo.png, x_howto, z_gw2-4, x_boymop1-4, plus assets/_key.py for
standalone art (chroma-key + crop, no frame splitting, no forced sprite height).

Benchmarks re-run and holding: CPU returns 23/24 serves, 30 human-tapped points
= 50 hits / 0 whiffs, 26 simulated games with no exceptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-27 05:28:04 +03:00
1 parent 5faaa99467
commit a09a5d7559
23 files changed
+392 -103

No files matched your search

+37
View File
@@ -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 human-like tapping (tap when incoming ball d<2.2–3.2) = 50 hits 0 whiffs; feet-bounce
tap sweep ≈ 19/30 winning frames. 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.<key>` 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 ## Gotchas learned the hard way
- Browser-pane verification: python http.server sends no cache headers, so edited ES - 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 + 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. 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 - drawChar mirrors sprites by ch.facing — any stance that must not mirror needs facing reset
(setupPoint pins facing=1). (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) - 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. 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 - Don't trust single-sample gameplay sims; randomness (aiFlub 9%, aim error) needs ~12 runs
+26
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+28 -5
View File
@@ -30,7 +30,8 @@ export const TUNE = {
zSpawnStep: 0.22, // spawn-gap reduction per finished game 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) 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 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 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 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) 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 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 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 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) // The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round
ballChangeEvery: 4, // games between deliveries after the first // 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 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) 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 bombRadius: 2.5, // m blast radius of a bomb ball
hordeLen: 12, // s a horde wave lasts hordeLen: 12, // s a horde wave lasts
@@ -76,8 +82,14 @@ export const TUNE = {
runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble 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 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 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) 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 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 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 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 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 }; 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.svcFar = COURT.netZ - 6.4; // far service line
COURT.svcNear = COURT.netZ + 6.4; // near service line COURT.svcNear = COURT.netZ + 6.4; // near service line
+93 -32
View File
@@ -1,11 +1,11 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. // 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 { 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 { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.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 { 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 ---- // ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. // 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]]; 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) { 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) 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); crowd.cheer = Math.max(crowd.cheer, seconds);
@@ -257,11 +268,11 @@ export function splatPoint(side, quiet = false) {
const gameOver = advancePoint(side); const gameOver = advancePoint(side);
if (gameOver) { if (gameOver) {
G.runGames++; G.runGames++;
if (side === 'O') G.shameGame = true; // they took a game — the next new-balls delivery is withheld // the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance
// the new-balls rule arms you: deliveries land at the changeover that follows the scheduled game // at a weapon, but you still have to win the point to be handed one. Fires at the next clear.
if (G.runGames === TUNE.ballChangeFirst || if (G.runGames === TUNE.duelFirst ||
(G.runGames > TUNE.ballChangeFirst && (G.runGames - TUNE.ballChangeFirst) % TUNE.ballChangeEvery === 0)) (G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
G.tinDue = true; G.duelDue = true;
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak 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 if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
const i = side === 'P' ? 0 : 1; const i = side === 'P' ? 0 : 1;
@@ -291,11 +302,15 @@ export function proceedAfterClear() {
G.clearing = false; G.clearing = false;
const mode = G.afterClear, side = G.clearWinner; const mode = G.afterClear, side = G.clearWinner;
G.afterClear = null; 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') { if (mode === 'set') {
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over) 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', sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM',
sub2: 'SETS ' + G.sets[0] + ' – ' + G.sets[1], 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 G.pendingHorde = true; // the horde begins when the card's pause ends
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT; ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
} else if (mode === 'changeover') { } else if (mode === 'changeover') {
@@ -310,13 +325,12 @@ export function proceedAfterClear() {
function startChangeover() { function startChangeover() {
G.changeover = true; G.changeover = true;
G.phase = 'over'; G.overT = TUNE.changeoverT; G.phase = 'over'; G.overT = TUNE.changeoverT;
sendBoy(); // out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery // parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
G.tinDue = false; // now long enough that an empty court reads as the game having stalled.
if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds sendBoy(0, COURT.L * 0.62, true);
else deliverTin(); G.flash = { text: 'CHANGEOVER', t: 1.4 };
G.shameGame = false; // the slate is clean either way // no tins here anymore — weapons are won in the duel, never handed out on a schedule
} else G.flash = { text: 'CHANGEOVER', t: 1.4 };
} }
// ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is // ---- 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 // 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 // (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. // 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() { export function deliverTin() {
const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k); const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k);
if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax); if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax);
else if (w === 1) G.triples = add(G.triples + 3); else if (w === 1) G.triples = add(G.triples + TUNE.tinTriples);
else if (w === 2) G.flames = add(G.flames + 3); else if (w === 2) G.flames = add(G.flames + TUNE.tinFlames);
else G.guns = add(G.guns + 1); // one machine-gun charge — a burst you pop when you choose, not on arrival 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) 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); 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. // ---- 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. // 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) { export function resolveInterlude(side) {
if (!G.interlude) return; if (!G.interlude) return;
G.interlude = false; G.interlude = false;
@@ -355,12 +394,16 @@ export function resolveInterlude(side) {
deliverTin(); // win the point, win the next tin — on the spot deliverTin(); // win the point, win the next tin — on the spot
} else { } else {
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel 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]; } if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
G.flash = { text: 'GAME. THEM.', t: 1.6 }; G.flash = { text: 'GAME. THEM.', t: 1.6 };
for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge 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'); 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 // 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('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('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('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() { function fault() {
@@ -672,7 +722,11 @@ function expirePose(ch, dt) {
export function update(dt) { export function update(dt) {
if (G.hitstop > 0) { G.hitstop -= dt; return; } if (G.hitstop > 0) { G.hitstop -= dt; return; }
if (G.mode === 'eaten') { // the match is over in the truest sense 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); updateZombies(dt);
return; 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.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.shake && (G.shake.t -= dt) <= 0) G.shake = null;
if (G.boomFlash > 0) G.boomFlash -= dt; 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); crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt; G.idleT += dt;
const w = BOUNCE_IDLE.tempo; 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 // --- 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. // 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) 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) { && 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 startDuel();
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);
}
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt; 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 // 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)); 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 // ball boy: sprint across, scoop the ball in passing, despawn off the far side
const boy = G.boy; const boy = G.boy;
if (boy) { if (boy) {
boy.x += boy.dir * 7.5 * dt; boy.runT += dt; // mopping is not sprinting: the mop crossing takes ~6.6s, which is what fills the changeover
if (!ball.collected && !ball.active) { 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; const dz = ball.z - boy.z;
boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little 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; 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') { if (G.phase === 'over') {
G.overT -= dt; G.overT -= dt;
if (G.overT <= 0) { 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 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 // 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 // setupPoint() -> pretoss left the player locked in the serve posture while the gun
+12 -14
View File
@@ -13,26 +13,23 @@
html, body { margin: 0; height: 100%; background: #10141c; display: grid; place-items: center; } 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; } 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; #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; } } @media (pointer: coarse) { #hint, #intro .kb { display: none; } }
#intro { position: fixed; inset: 0; z-index: 10; touch-action: none; display: grid; place-items: center; #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)); } 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; } padding: 18px 26px; max-width: 92vw; animation: drop 0.5s ease-out; }
@keyframes drop { from { transform: translateY(-14px); opacity: 0; } } @keyframes drop { from { transform: translateY(-14px); opacity: 0; } }
#intro .logo { line-height: 0.9; margin-bottom: 8px; } /* the logo is generated pixel art now (assets/logo.png), not stacked CSS text */
#intro .logo span { display: block; font-weight: 900; } #intro .logo { display: block; width: min(320px, 78vw); height: auto; margin: 0 auto 8px;
#intro .l1 { font-size: 46px; letter-spacing: 3px; color: #e0231a; image-rendering: pixelated; filter: drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
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 .slogan { margin: 8px 0 16px; font-size: 12px; color: #b6a0a4; letter-spacing: 0.5px; }
#intro .l2 { font-size: 30px; letter-spacing: 9px; color: #eef23a; margin-top: 2px; #intro .slogan b { color: #c9564a; }
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; }
#intro .how { display: inline-block; text-align: left; color: #cbd4e2; line-height: 2; } #intro .how { display: inline-block; text-align: left; color: #cbd4e2; line-height: 2; }
#intro .how p { margin: 0; } #intro .how p { margin: 0; }
#intro .how span { display: inline-block; width: 30px; } #intro .how span { display: inline-block; width: 30px; }
#intro em { color: #8a96ab; font-style: normal; font-size: 12px; } #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; } } @keyframes blink { 50% { opacity: 0.25; } }
</style> </style>
</head> </head>
@@ -41,10 +38,11 @@
<div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div> <div id="hint">move: WASD/arrows &nbsp;·&nbsp; serve/swing: SPACE &nbsp;·&nbsp; aim: hold a direction while swinging &nbsp;·&nbsp; T: tuning panel</div>
<div id="intro"> <div id="intro">
<div class="card"> <div class="card">
<div class="logo"><span class="l1">SPLATTER</span><span class="l2">TENNIS</span></div> <img class="logo" src="assets/logo.png" alt="Splatter Tennis">
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p> <p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
<div class="how"> <div class="how">
<p><span>👆</span> tap — serve &amp; swing</p> <p><span>👆</span> tap — serve + swing</p>
<p><span>👉</span> flick — aimed shot</p> <p><span>👉</span> flick — aimed shot</p>
<p><span>✋</span> drag — run <em>(auto-run is on)</em></p> <p><span>✋</span> drag — run <em>(auto-run is on)</em></p>
<p class="kb"><span>⌨️</span> WASD / arrows + SPACE</p> <p class="kb"><span>⌨️</span> WASD / arrows + SPACE</p>
@@ -52,6 +50,6 @@
<p class="start">TAP TO START</p> <p class="start">TAP TO START</p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=20"></script> <script type="module" src="main.js?v=26"></script>
</body> </body>
</html> </html>
+11 -2
View File
@@ -6,13 +6,14 @@ import { cv } from './dom.js';
import { audio, pock } from './audio.js'; import { audio, pock } from './audio.js';
import { TUNE } from './config.js'; import { TUNE } from './config.js';
import { G, score } from './state.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 { rackHit } from './render.js';
import { togglePanel } from './panel.js'; import { togglePanel } from './panel.js';
const keys = {}; const keys = {};
addEventListener('keydown', e => { addEventListener('keydown', e => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) e.preventDefault(); 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 === 't' || e.key === 'T') togglePanel();
if (e.key >= '1' && e.key <= '5') selectWeapon(+e.key - 1); // pick a rack slot if (e.key >= '1' && e.key <= '5') selectWeapon(+e.key - 1); // pick a rack slot
if (!keys[e.code]) keyPressed(e.code); 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 // 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. // "drag walks the player" mode between shots was invisible state and read as a bug.
const canAimServe = () => 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 && 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 && !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(); e.preventDefault(); audio();
const r = cv.getBoundingClientRect(); const r = cv.getBoundingClientRect();
for (const t of e.changedTouches) { 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 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); const slot = rackHit((t.clientX - r.left) / r.width, (t.clientY - r.top) / r.height);
if (slot >= 0) { selectWeapon(slot); continue; } if (slot >= 0) { selectWeapon(slot); continue; }
@@ -121,3 +124,9 @@ const endTouch = e => {
}; };
cv.addEventListener('touchend', endTouch, { passive: false }); cv.addEventListener('touchend', endTouch, { passive: false });
cv.addEventListener('touchcancel', 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());
+6 -5
View File
@@ -3,9 +3,9 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // 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) // 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 { 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 { fitViewport, render, proj, RES } from './render.js';
import { voice, ambient } from './audio.js'; import { voice, ambient } from './audio.js';
import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.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(); }); intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
addEventListener('keydown', () => dismissIntro(), { once: true }); addEventListener('keydown', () => dismissIntro(), { once: true });
// eaten: any tap or key (after a beat, so death-spam doesn't skip the screen) restarts // eaten: any tap or key restarts — but only once the death animation has played out, so a
const maybeRestart = () => { if (G.mode === 'eaten' && G.eatenT > 0.7) location.reload(); }; // ponytail: reload = honest full reset // 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); cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart); addEventListener('keydown', maybeRestart);
@@ -57,6 +58,6 @@ cv.addEventListener('pointerdown', e => {
requestAnimationFrame(frame); requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests // 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, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+109 -35
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js'; 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 { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding, flickTarget } from './gameplay.js'; import { predictLanding, flickTarget } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras } from './zombies.js'; import { zombies, gore, piles, parts, pellets, extras } from './zombies.js';
@@ -32,6 +32,11 @@ function palette() {
return m; 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 ---- // ---------------------------------------------------------------- camera ----
const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // hoisted: a matchMedia call per frame churns GC 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) 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', 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', '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_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_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_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) '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 ---- // -------------------------------------------------------------- primitives ----
function line(x1, z1, x2, z2, w = 1) { function line(x1, z1, x2, z2, w = 1) {
const a = proj(x1, 0, z1), b = proj(x2, 0, z2); 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(); 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) 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(); ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill();
return; 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)); } 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); ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
} }
function drawBoy(b) { 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 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); 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); if (b.dir < 0) ctx.scale(-1, 1);
ctx.drawImage(img, -w / 2, -h, w, h); ctx.drawImage(img, -w / 2, -h, w, h);
ctx.restore(); ctx.restore();
@@ -157,17 +169,37 @@ function drawZombie(zb) {
// shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in // 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. // zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded.
let name = 'z_' + v + frame; 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 ((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 <= 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'; 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); 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 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; 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 = '#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); 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) { function drawRing(x, z, color) {
const p = proj(x, 0, z); const p = proj(x, 0, z);
// integer-snapped so the moving ring steps whole pixels instead of shimmering // 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++) { 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; 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.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 // 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 = g.y0 + g.h / 2;
ctx.globalAlpha = dim ? 0.35 : 1; ctx.globalAlpha = dim ? 0.35 : 1;
@@ -306,17 +338,17 @@ function drawRack() {
if (spr) { if (spr) {
const k = Math.min(box / spr.width, box / spr.height), iw = spr.width * k, ih = spr.height * k; 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); 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 // 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; const tx = x + pad + box + (g.tw - pad * 2 - box) / 2;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.globalAlpha = dim ? 0.5 : 1; 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.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.fillText(labels[i], tx, g.y0 + g.h - pad - 3); // weapon label, under its count
ctx.globalAlpha = 1; 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(); ctx.restore();
} }
@@ -326,12 +358,39 @@ function drawRack() {
function drawCard(title, sub1, sub2, color) { 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 - 36, W, 64);
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillStyle = color; ctx.font = 'bold 18px monospace'; ctx.fillText(title, W / 2, H / 2 - 26); ctx.fillStyle = color; ctx.font = F(16); ctx.fillText(title, W / 2, H / 2 - 26);
ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace'; ctx.fillStyle = UI.cream; ctx.font = F(8);
if (sub1) ctx.fillText(sub1, W / 2, H / 2 - 2); if (sub1) ctx.fillText(sub1, W / 2, H / 2 - 2);
if (sub2) ctx.fillText(sub2, W / 2, H / 2 + 12); 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 ---- // ------------------------------------------------------------------ frame ----
export function render() { export function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density 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(); const l = predictLanding();
if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)'); 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 // 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 // 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. // 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; const kills = ft.q >= TUNE.zSplatQ;
// cool blue-white = this pull only staggers. It stays READABLE on the dark lawn: you still // 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. // 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) { if (tx !== undefined) {
const a = proj(P.x, 1.4, P.z), b = proj(tx, 0.35, tz); 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; 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) { 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 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); 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 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 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. // status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro.
const splat = G.mode !== 'tennis'; const splat = G.mode !== 'tennis';
const bd = { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', slot: '#0b2b1c', slotSvr: '#2b1a46', 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 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 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') ? (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), // 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. // 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'; ctx.textBaseline = 'middle';
const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
@@ -555,24 +625,29 @@ export function render() {
ctx.restore(); ctx.restore();
ctx.textBaseline = 'top'; ctx.textAlign = 'center'; 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 === '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 = '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.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); 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 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)); 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); ctx.fillText('★ NEW BEST ★', W / 2, H / 2 + 4);
} else if (G.best && G.best.games > 0) { } 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); 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)) { 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 touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-5 TO SWITCH';
const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon]; const sel = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'][G.weapon];
const txt = G.mode === 'splatter' 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 ctx.fillText(txt, W / 2, H - TUNE.rackH - RACK_BOT - 17); // sit clear above the rack
} }
if (G.flash) { 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); 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 // the SET milestone card (the duel's own announcement is a real modal now, not a timed card)
if (G.interlude && G.interlude.introT > 0) if (G.card && G.card.t > 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)
drawCard(G.card.title, G.card.sub1, G.card.sub2, G.card.color); 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 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); ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28);
} }
if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly
const list = G.missLog.slice(-9), lh = 10, top = 40; 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.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.font = F(7, 0); ctx.textAlign = 'left';
ctx.fillStyle = '#8fa0b8'; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5); ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5);
const maxCh = Math.floor((W - 20) / 4.3); const maxCh = Math.floor((W - 20) / 4.3);
list.forEach((e, i) => { list.forEach((e, i) => {
ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060'; ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060';
ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh); 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'; ctx.textAlign = 'left';
} }
+12 -5
View File
@@ -42,7 +42,8 @@ export const G = {
mgT: 0, // s of machine-gun burst left — auto-fire, no serving (spent from G.guns) 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 mgCool: 0, // s until the next pickup-mode pellet
deliveries: 0, // tins delivered this run — indexes the weapon ladder 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 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) 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 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) 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 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' 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 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 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 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 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 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) 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; 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 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 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 };
} }
+58 -5
View File
@@ -67,10 +67,52 @@ function killZombie(i, quiet) {
}); } }); }
splat(); splat();
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst 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 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 // 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. // kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel.
function woundBurst(zb) { function woundBurst(zb) {
@@ -158,7 +200,14 @@ export function explode(x, z) {
function bleed(zb) { function bleed(zb) {
if (parts.length > 90) return; if (parts.length > 90) return;
let wounds; 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 <= 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 === '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 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]; const zb = zombies[i];
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
zb.walkT += dt; 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 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 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); splat(1);
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans 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 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) 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 }; const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
@@ -247,8 +299,9 @@ export function updateZombies(dt) {
zb.hp--; splat(0.5); zb.hp--; splat(0.5);
if (zb.hp <= 0) { splatter(i); } continue; 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 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; continue;
} }
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI) if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)