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:
1 parent
be191dba82
commit
0620e97d6c
9 files changed
+298
-16
No files matched your search
+113
-7
@@ -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
|
||||
|
||||
Reference in new issue
Block a user