Zombie tennis: one game per opponent, blood only

Triage failed the only test that matters — "i think the normal player does
not grasp this" — and the lesson across four drill designs is that every
invented rule needs teaching, while the rules the scene already implies need
none. So the new mode is the game's own oldest rule: tennis.

MATCH TEST in the back room (#match). One game per opponent, real 15/30/40
scoring. Built on top of the duel machinery: G.match runs a permanent
interlude, which is already what makes serving tennis-like, hides the rack,
quiets the spawn director and wakes racketAI. The one change is in
resolveInterlude — a settled point feeds matchPoint instead of ending
anything. About 90 lines for a whole mode, because the deepest system in the
repo was already built.

The score is the body: a quarter of its pool per point won, floored at 1, so
a love game walks the racket zombie through every wound stage and the game
point fells it. Its points draw your blood — hp--, eaten at zero, matchHeal 2
per game won is how you last. Regicide is inherited: kill the body mid-game
and the game banks by hand.

The timing is drawn, per Oliver's ask: an approach ring around the incoming
ball that kisses a fixed inner ring exactly at the sweet contact distance
(gold inside the band), and an apex band beside the toss spanning the same
0.12m keyPressed scores as perfect, with a tick riding the live ball. Both
cues use the mechanics' own numbers, so the picture cannot lie.

Verified by autopilot: 7 games in 90 simulated seconds, opponents 1..8, pools
4/6/8/10, wounds tracking points exactly.

36 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 22:19:37 +02:00
1 parent be191dba82
commit 0620e97d6c
9 files changed
+298 -16

No files matched your search

+37
View File
@@ -816,6 +816,43 @@ walking with no kills.
them back. The teacher steps in exactly when you demonstrably need it and out when you don't,
and `drillMark: 0` still kills them outright.
**THIRTEENTH PASS — ZOMBIE TENNIS, and the drill steps aside.** Oliver on triage: *"i think the
normal player does not grasp this"* — the lesson across four drill designs being that **every
invented rule needs teaching, and the only rules that don't are the ones the scene already implies**.
Brainstormed again; he picked the dark horse: real tennis against the dead. One game per opponent,
blood only. *"i can live simple tapping and timing"* — the tap-and-timing scheme, not the slingshot.
**MATCH TEST in the back room (`#match`).** Built ON TOP of the duel machinery, deliberately:
`G.match` runs a PERMANENT interlude — which is already what makes serving tennis-like, hides the
rack, quiets the spawn director and wakes `racketAI`. The one change is in `resolveInterlude`: a
settled point feeds `matchPoint` instead of ending anything. ~90 lines in beats.js for a whole mode,
because the deepest system in the repo (swings, volleys, lanes, the AI) was already built.
- **The score IS the body**: a quarter of the pool per point won, floored at 1 — a love game walks
the racket zombie through every wound stage and the game point fells it (`explode` at the body).
The scoreboard and the corpse agree all the way down. `matchOppHp` 4 + 2 per opponent felled.
- **Its points draw your blood**: `G.hp--`, eaten at zero, the ordinary channel. Four clean points
against you IS the run — `matchHeal` 2 per game won is how you last.
- **Regicide inherited free**: smashing the ball into the body still wounds it, killing it outright
wins the game early (killZombie -> resolveInterlude('P') lands with the court empty; the game is
banked BY HAND there, since advancePoint never saw a game point).
- **THE TIMING IS DRAWN** — his ask: *"what is the possible tap window what is the sweet spot"*.
Two cues riding the mechanics' own numbers so the picture cannot lie: the APPROACH RING (a ring
around the incoming ball that kisses a fixed inner ring exactly at `matchRingSweet` 2.7 m — the
centre of the tapped-points benchmark's window — gold inside `matchRingBand`), and the APEX BAND
(a guide beside the toss, gold across serveApexY ± the same 0.12 m keyPressed scores as perfect,
a white tick riding the live ball). Match-only for now; they would serve plain tennis and the
duel too if Oliver wants them there.
- Verified by autopilot in the browser: 7 games in 90 simulated seconds, opponents 1..8, pools
4/6/8/10, wounds tracking points exactly, heal capping at hpMax.
- **One harness trap that cost a round**: startMatch queues TWO first-sight modals (EXHIBITION
MATCH, then the racket zombie's own) — and a dismiss during a modal's entrance (`m.in < 1`) is
swallowed BY DESIGN. A scripted playtest must clear `G.modal` AND `G.modalQ`, or the world is
frozen and sixty seconds of stepping measures nothing.
**The drill stays in the back room beside it** — triage is a decent idea that failed the
grasp-in-five-seconds test, and the match is now the lead candidate for the middle mode.
**Open, and every one of them is Oliver's call after he plays it:**
1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by
the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
+2 -1
View File
@@ -374,6 +374,7 @@
<button class="btn big" id="scWeapons">WEAPON TEST</button>
<button class="btn big" id="scBosses">BOSS TEST</button>
<button class="btn big" id="scDrill">DRILL TEST</button>
<button class="btn big" id="scMatch">MATCH TEST</button>
</div>
</div>
<!-- one tile per chapter boss, built from config.bossArt in main.js: adding a design to the
@@ -399,6 +400,6 @@
</div>
</div>
</div>
<script type="module" src="src/main.js?v=164"></script>
<script type="module" src="src/main.js?v=165"></script>
</body>
</html>
+113 -7
View File
@@ -10,8 +10,8 @@
import { TUNE, UI, bossArt, COURT, HOME } from './config.js';
import { G, P, ball, score, setupPoint, sendBoy } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie } from './zombies.js';
import { cheer, aside, endBurst, deliverTin, showModal, logSwing } from './gameplay.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js';
import { startDrillStream } from './drill.js';
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
@@ -63,7 +63,7 @@ function startChangeover() {
export function startDuel() {
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
// another control mode owns the court. Each of these has ended a run when they overlapped.
if (G.interlude || G.horde || G.bossFight || G.drill) return;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match) 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
@@ -86,7 +86,7 @@ export function startDuel() {
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
// instant death. G.bossFight holds the chapter number while it runs.
export function startBossFight(chapter) {
if (G.bossFight || G.drill) return;
if (G.bossFight || G.drill || G.match) return;
G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
@@ -135,7 +135,7 @@ export function endBossFight() {
// was walking around a moment ago is still walking around inside a drill round. The drill's own
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
export function startDrill() {
if (G.drill || G.interlude || G.horde || G.bossFight) return false;
if (G.drill || G.interlude || G.horde || G.bossFight || G.match) return false;
G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null };
G.changeover = false; G.clearing = false; G.afterClear = null;
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
@@ -203,8 +203,114 @@ export function watchdog(dt) {
stuckT = 0; stuckSig = '';
}
// ---- ZOMBIE TENNIS: the exhibition match. One GAME per opponent, real 15/30/40 scoring, and the
// score IS the body — every point you win tears a wound stage off the racket zombie (the same
// fraction-of-hpMax skins the duel uses), every point it wins draws your blood (the ordinary hp
// channel; at zero you are eaten as usual). Win the game and the corpse comes apart; a fresh
// opponent shambles on with more hp. Games won stays the ladder metric.
//
// Deliberately built ON TOP of the duel machinery rather than beside it: G.interlude is what makes
// serving tennis-like, keeps the reticle and the rack away, quiets the spawn director and wakes
// racketAI — all already true. The ONE difference is where a finished point goes: resolveInterlude
// hands it to matchPoint instead of ending anything, and the interlude simply never resolves until
// the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
// lands in matchGameWon via the empty-court check).
export function startMatch() {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match) return false;
G.match = { opp: 1, ending: false };
G.weapon = 0; G.mgT = 0; endBurst(); pellets.length = 0; extras.length = 0; shots.length = 0;
zombies.length = 0;
G.boy = null;
score.pts = [0, 0];
matchOpponent();
ball.active = false; setupPoint();
zombieDuck(true);
voice('play_resume', 0.2);
if (!G.seen.match) {
G.seen.match = true;
showModal({ title: 'EXHIBITION MATCH', color: UI.hot,
body: ['WIN THE GAME — EVERY POINT WOUNDS IT', 'ITS POINTS DRAW YOUR BLOOD'] });
}
return true;
}
function matchOpponent() {
spawnRacketZombie();
const zb = zombies.find(z => z.variant === 'r');
zb.dueled = true; // wakes racketAI, same as a fresh duel
zb.hp = zb.hpMax = TUNE.matchOppHp + (G.match.opp - 1) * TUNE.matchOppHpPer;
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
}
export function endMatch() {
if (!G.match) return;
G.match = null; G.interlude = false;
zombies.length = 0; shots.length = 0;
zombieDuck(false);
ball.active = false; setupPoint();
}
// One tennis point is settled — the ONLY entry is resolveInterlude's hook below, so every way a
// point can end (bounce out, double bounce, a body hit, a whiff) funnels through the duel's own
// plumbing untouched.
function matchPoint(side) {
const d = G.match;
if (d.ending) return;
const zb = zombies.find(z => z.variant === 'r');
if (side === 'P' && zb) {
// a QUARTER of its pool per point, floored at 1 — four points walk it through every wound stage
// and only the game-winning point actually fells it, so the body and the scoreboard agree all
// the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.)
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / 4));
}
if (side === 'O') {
G.hp--; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.6;
aww(0.8);
if (G.hp <= 0) return killPlayer(); // eaten — the run summary, as ever
}
const over = advancePoint(side); // banks the game and resets the points itself
if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost();
voice(scoreKey(), 0.4);
if (side === 'P') cheer(0.8, 0.3);
ball.active = false; setupPoint(); // next point, same opponent
}
function matchGameWon(zb, banked = false) {
const d = G.match;
d.ending = true; // killZombie below re-enters resolveInterlude
if (zb && zombies.includes(zb)) explode(zb.x, zb.z); // the game-winning point IS the kill
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++; // the ladder metric, same as every mode
G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last
voice('game_player', 0.3); cheer(2.4, 0.6);
G.card = { title: 'GAME.', sub1: 'NEXT OPPONENT', color: UI.pick, t: 2.2 };
d.opp++;
matchOpponent(); // fresh body, more hp, its own intro beat
d.ending = false;
ball.active = false; setupPoint();
G.phase = 'over'; G.overT = 2.2;
}
function matchGameLost() {
// Reachable only through deuce (four straight points is exactly your hp) — the body keeps its
// wounds, the scoreboard banks their game, and the match simply plays on.
voice('game_opponent', 0.3); aww(1);
ball.active = false; setupPoint();
G.phase = 'over'; G.overT = 1.8;
}
export function resolveInterlude(side) {
if (!G.interlude) return;
// THE MATCH HOOK: a settled point feeds the game score instead of ending the interlude — the
// interlude is permanent while a match runs (it is what keeps the serving tennis-like and the
// spawn director quiet). Regicide arrives here too, as a 'P' with the body already gone.
if (G.match) {
if (!zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null);
return matchPoint(side);
}
G.interlude = false;
zombieDuck(false); // duel over — bring the music back up
ball.active = false; G.phase = 'over'; G.overT = 2.2;
@@ -240,7 +346,7 @@ export function resolveInterlude(side) {
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
// counts it down under the flash), and startHorde still owns the transition itself.
export function warnHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill) return false; // same court-ownership contract as the rest
if (G.horde || G.interlude || G.bossFight || G.drill || G.match) return false; // same court-ownership contract as the rest
G.hordeWarn = TUNE.hordeWarnT;
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
@@ -251,7 +357,7 @@ export function warnHorde() {
// Returns false when another mode owns the court — the caller re-arms the latch rather than
// silently eating the wave the set paid for.
export function startHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill) return false; // same contract as startDuel: one mode owns the court
if (G.horde || G.interlude || G.bossFight || G.drill || G.match) return false; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
+13
View File
@@ -379,6 +379,19 @@ export const TUNE = {
// AND a 1 hp body skips every ZWOUND stage, so the gore art never renders.
// Also flattens triage — pre-softening a heavy IS the planning layer
drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset
// ---- ZOMBIE TENNIS (beats.startMatch) — the exhibition match. One game per opponent, blood only.
matchOppHp: 4, // the first opponent's pool. FOUR, deliberately: a quarter comes off per point
// won, so a love game walks it through every wound stage and the game point
// fells it — the scoreboard and the body agree all the way down
matchOppHpPer: 2, // extra pool per opponent felled — later corpses soak more regicide punishment
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate) — a clean game is how
// you last; their every point costs 1 of your playerHp 4
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
matchRingFar: 9, // m — the approach ring fades in once the incoming ball is this close
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark
// lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre)
matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
+3 -3
View File
@@ -194,7 +194,7 @@ function tossBall(sv) {
ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity;
G.phase = 'toss';
}
function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
// how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target
// sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35).
@@ -379,7 +379,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
}
// ---------------------------------------------------------------- scoring ----
function scoreKey() {
export function scoreKey() {
const N = ['love', 'fifteen', 'thirty', 'forty'];
const s = score.server === 'P' ? [score.pts[0], score.pts[1]] : [score.pts[1], score.pts[0]];
if (s[0] >= 3 && s[1] >= 3) {
@@ -432,7 +432,7 @@ export function cheer(seconds, vol) {
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
}
function advancePoint(side) { // shared scoring math; returns whether a game was won
export function advancePoint(side) { // shared scoring math; returns whether a game was won
const i = side === 'P' ? 0 : 1, j = 1 - i;
score.pts[i]++;
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) {
+7 -2
View File
@@ -6,7 +6,7 @@
import { TUNE, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } from './beats.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch } from './beats.js';
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
@@ -227,6 +227,10 @@ document.getElementById('scDrill').addEventListener('click', () => {
start(); intoSplatter();
startDrill();
});
document.getElementById('scMatch').addEventListener('click', () => {
start(); intoSplatter();
startMatch();
});
// The list is built by ASKING bossArt which design each chapter gets, so it can never drift from
// what the run actually spawns — including the champion sitting on chapter 10 rather than 7.
@@ -386,10 +390,11 @@ function laserFire() {
if (location.hash === '#laser') setTimeout(laserScene, 600);
// #drill — the same door from a URL, so a phone can be sent straight into the mode
if (location.hash === '#drill') setTimeout(() => { start(); intoSplatter(); startDrill(); }, 700);
if (location.hash === '#match') setTimeout(() => { start(); intoSplatter(); startMatch(); }, 700);
requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill,
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch,
stats, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+50 -2
View File
@@ -3,7 +3,7 @@
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
@@ -680,6 +680,53 @@ function drawZombie(zb) {
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
}
// THE MATCH'S TIMING, DRAWN. Oliver: "the tennis timing mechanic would need to be visualized
// somehow (what is the possible tap window what is the sweet spot)". Two cues, both riding numbers
// the mechanics already use, so the picture cannot drift from the physics:
//
// THE APPROACH RING (the rally tap): a ring around the incoming ball whose radius tracks its
// distance to the sweet contact range — the rings kiss exactly when the ball is where the tap
// lands a clean hit (matchRingSweet, the centre of the tapped-points benchmark's 2.2..3.2 m), and
// the ring turns gold inside the band. The landing ring set this precedent: a marker that
// contracts toward its moment IS a timing cue, no legend needed.
function drawApproachRing() {
if (!G.match || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return;
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the CONTACT test's own metric
if (d > TUNE.matchRingFar || ball.vz < 0) return; // far away, or already going back
const p = proj(ball.x, ball.y, ball.z);
if (p.s <= 0) return;
const inBand = Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand;
const rSweet = Math.max(6, p.s * 0.055); // the fixed target ring
const r = rSweet * Math.max(1, d / TUNE.matchRingSweet); // the closing ring — they kiss at sweet
ctx.lineWidth = 1.5;
ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 7); ctx.stroke();
ctx.strokeStyle = inBand ? UI.warm : 'rgba(255,255,255,0.3)';
ctx.beginPath(); ctx.arc(p.x, p.y, rSweet, 0, 7); ctx.stroke();
}
// THE APEX BAND (the serve tap): a thin guide beside the tossed ball with a gold band spanning the
// perfect window (serveApexY +- the same 0.12 m keyPressed scores as perfect) and a wider white
// zone for the playable one. A tick rides the live ball height. The ghost hand MIMES this moment;
// the band makes it a target you can see coming.
function drawApexBand() {
if (!G.match || G.phase !== 'toss' || score.server !== 'P') return;
const gx = ball.x + 0.55; // beside the toss, out of the ball's way
const apex = serveApexY();
const yTop = proj(gx, apex + 0.45, ball.z), yBot = proj(gx, 1.2, ball.z);
if (yTop.s <= 0) return;
const x = Math.round(yTop.x);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; // the guide
ctx.beginPath(); ctx.moveTo(x, yTop.y); ctx.lineTo(x, yBot.y); ctx.stroke();
const bTop = proj(gx, apex + 0.12, ball.z).y, bBot = proj(gx, apex - 0.12, ball.z).y;
ctx.fillStyle = hexA(UI.warm, 0.8); // the perfect window, gold
ctx.fillRect(x - 3, Math.round(bTop), 6, Math.max(2, Math.round(bBot - bTop)));
const tick = proj(gx, ball.y, ball.z).y; // where the ball is NOW
ctx.fillStyle = '#fff';
ctx.fillRect(x - 5, Math.round(tick), 10, 2);
}
// THE TRIAGE READOUT: score, multiplier, best — and the calibration marker. There is no strip and
// no called list any more; the court is the puzzle, so the HUD only carries what the court cannot
// say: how much the run is worth and how hot the streak is.
@@ -1975,7 +2022,8 @@ export function render() {
ctx.restore();
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
if (G.mode === 'splatter' && !G.interlude && !G.drill) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
drawDrillHud(); // the called list — no-op unless a drill is running
drawDrillHud(); // the triage readout — no-op unless a drill is running
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running
// 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);
+3
View File
@@ -104,6 +104,9 @@ export const G = {
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
match: null, // ZOMBIE TENNIS (beats.startMatch): {opp, ending}. One GAME per opponent,
// real scoring — every point you win wounds the racket zombie, every point it
// wins draws your blood. Runs ON TOP of a permanent interlude.
drill: null, // the TRIAGE drill (src/drill.js): {t, spawnT, score, streak, kills, faults,
// lost, best, msg}. Owns the court like every other mode — see beats.startDrill.
// One rule: always kill the body that would reach you first. `t` is time
+70 -1
View File
@@ -83,7 +83,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill } = await import('./src/beats.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch } = await import('./src/beats.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court
const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; };
@@ -498,5 +498,74 @@ test('endDrill hands the court back', () => {
assert.ok(startDuel() !== false && G.interlude, 'and an ordinary mode can have the court again');
});
// ---- zombie tennis: one game per opponent, blood only ------------------------------------------
const startMatch = () => { const r = _startMatch(); G.modal = null; G.modalQ.length = 0; return r; };
const oppOf = () => zombies.find(z => z.variant === 'r');
test('a match owns the court like every other mode', () => {
assert.ok(startMatch(), 'a match starts on a clean court');
assert.ok(G.interlude, 'and runs on a permanent interlude — that is the design');
startDuel(); // interlude guard catches it
forceZombies();
assert.equal(G.horde, null, 'no horde into a match');
startBossFight(1);
assert.equal(G.bossFight, 0, 'no boss into a match');
assert.equal(startDrill(), false, 'no drill into a match');
assert.equal(startMatch(), false, 'no second match');
endMatch();
assert.ok(startDrill(), 'endMatch hands the court back');
});
test('a point won tears a wound stage off the body; only the game point fells it', () => {
startMatch();
const zb = oppOf();
assert.equal(zb.hpMax, TUNE.matchOppHp, 'opponent one carries the base pool');
for (let k = 1; k <= 3; k++) {
resolveInterlude('P');
assert.ok(zombies.includes(zb), 'point ' + k + ': still standing');
assert.ok(zb.hp < zb.hpMax, '...and visibly wounded (' + zb.hp + '/' + zb.hpMax + ')');
}
assert.equal(zb.hp, 1, 'three points in: one stage left');
resolveInterlude('P'); // the game point
assert.ok(!zombies.includes(zb), 'the game-winning point IS the kill');
const next = oppOf();
assert.ok(next && next !== zb, 'a fresh opponent shambled on');
assert.equal(next.hpMax, TUNE.matchOppHp + TUNE.matchOppHpPer, 'with a deeper pool');
assert.equal(G.runGames, 1, 'the ladder banked the game');
assert.deepEqual(score.pts, [0, 0], 'and the points reset');
});
test('their points draw blood, and the fourth is the run', () => {
startMatch();
G.hp = TUNE.playerHp;
for (let k = 1; k <= 3; k++) {
resolveInterlude('O');
assert.equal(G.hp, TUNE.playerHp - k, 'point ' + k + ' cost a wound');
assert.notEqual(G.mode, 'eaten');
}
resolveInterlude('O');
assert.equal(G.mode, 'eaten', 'blood only — and it runs out the way it always does');
});
test('a game won heals, so a clean player can go the distance', () => {
startMatch();
G.hp = 1;
for (let k = 0; k < 4; k++) resolveInterlude('P');
assert.equal(G.hp, Math.min(G.hpMax, 1 + TUNE.matchHeal), 'the game paid its heal');
});
test('regicide: killing the body mid-game wins the game outright', () => {
startMatch();
const zb = oppOf();
resolveInterlude('P'); // 15-0, so the score is mid-game
const games = score.games[0];
zombies.length = 0; // the body is destroyed by a shot, not by the score
resolveInterlude('P'); // killZombie's exit lands here with the court empty
assert.equal(score.games[0], games + 1, 'the game banks');
assert.deepEqual(score.pts, [0, 0], 'the part-played points reset');
assert.ok(oppOf(), 'and the next opponent is on');
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report