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>
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 · serve/swing: SPACE · aim: hold a direction while swinging · T: tuning panel</div>
|
<div id="hint">move: WASD/arrows · serve/swing: SPACE · aim: hold a direction while swinging · 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 & 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>
|
||||||
@@ -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());
|
||||||
@@ -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; } };
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||