Three modes, picked up front — and CLASSIC TENNIS is one of them
The setup screen picks WHAT before WHO and WHERE. Three rows out of a table, in the court row's own markup: CLASSIC TENNIS is not the opener with the turn switched off and left there — it is the mode the other two are built on, so it gets real match scoring: six games with a two-game lead is a set, best of TUNE.classicSets is the match, the board shows the sets column (hidden until now outside splatter) and the bar under it says which set this is. The CPU stops rubber-banding and flubs less: aiFlubLoss exists to get a new player PAST the opener, and a mode whose whole promise is a real match cannot be won by an opponent that falls apart the moment he goes ahead. No tiebreak — 6-6 plays on to 8-6. Marked ponytail. That needed a second ENDING, the one with no corpse in it: G.done, the same summary and gated tap as the death screen, its own words (GAME, SET, MATCH. / BEATEN.) and the man still standing. The three endings now share one block rather than three copies of it. Caught by the first classic test: stats banked only the FIRST classic run of a session. `banked` was cleared by the next KILL, and a classic match has no kills in it at all — every later match was swallowed by the double-bank guard. beginRun() is the honest reset and restartRun calls it. ZOMBIE TENNIS from the menu is one line; the back room keeps its door. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
66fdad3d74
commit
c89d4a3ff9
31 files changed
+472
-123
No files matched your search
+61
-4
@@ -6,6 +6,7 @@ import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zom
|
||||
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
|
||||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||||
import { updateDrill } from './drill.js';
|
||||
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
|
||||
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
|
||||
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
|
||||
|
||||
@@ -485,10 +486,13 @@ function pointTo(side, reason) {
|
||||
if (side === 'O') aww(0.8); // you dropped the point — the crowd sighs
|
||||
if (reason) voice(reason);
|
||||
if (gameOver) {
|
||||
if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned) return startTransition();
|
||||
// ...unless this is CLASSIC TENNIS, where nothing turns and the first game you win is just the
|
||||
// first game you win. That check is the whole of the mode's isolation from the gore path.
|
||||
if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned && !G.classic) return startTransition();
|
||||
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
|
||||
cheer(2.8, 0.6);
|
||||
score.server = other(score.server);
|
||||
if (G.classic) { G.runGames++; if (classicGame(side)) return; } // ...then a set, then the match
|
||||
} else {
|
||||
voice(scoreKey(), 0.7);
|
||||
if (side === 'P') cheer(1.2, 0.3);
|
||||
@@ -496,6 +500,44 @@ function pointTo(side, reason) {
|
||||
G.serveNum = 1;
|
||||
}
|
||||
|
||||
// CLASSIC TENNIS: a game has just been banked by advancePoint — is it also a set, and is the set
|
||||
// also the match? Returns true when it ended the match, so pointTo stops handing the point flow on.
|
||||
// Six games AND a two-game lead, the real rule; no tiebreak, so 6-6 simply plays on to 8-6. That is
|
||||
// the one simplification here and it is deliberate — a tiebreak is a different scoring mode bolted
|
||||
// onto a mode that is not yet proven, and 7-5 sets are not what anybody is complaining about.
|
||||
// ponytail: add the tiebreak when a real match actually stalls at 6-6.
|
||||
// Exported for the suite: the whole of a mode's scoring rule is worth pinning down, and the only
|
||||
// other way in is winning twelve games through real rallies.
|
||||
export function classicGame(side) {
|
||||
const i = side === 'P' ? 0 : 1, j = 1 - i;
|
||||
if (score.games[i] < 6 || score.games[i] - score.games[j] < 2) return false;
|
||||
G.sets[i]++; score.games = [0, 0];
|
||||
const mine = i === 0;
|
||||
if (G.sets[i] >= TUNE.classicSets) { endClassic(mine); return true; }
|
||||
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), sub1: mine ? 'THE SET IS YOURS' : 'SET TO THEM',
|
||||
color: mine ? UI.pick : UI.hot, t: TUNE.setCardT };
|
||||
G.overT = Math.max(G.overT, TUNE.setCardT);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The match is over and NOBODY DIED — the one end-of-run state that is not a corpse. It reuses the
|
||||
// death screen's whole shape (a held summary, then a gated tap that restarts) because that is the
|
||||
// only thing about the two endings that is the same; the words, the colour and the still-standing
|
||||
// player are all its own. update() freezes on G.done, exactly as it does on G.mode === 'eaten'.
|
||||
export function endClassic(won) {
|
||||
if (G.done) return;
|
||||
G.done = { won, t: 0 };
|
||||
ball.active = false; G.flash = null; G.modal = null; G.card = null;
|
||||
if (won) { cheer(4, 1); applause(1, 0.2); voice('unbelievable', 0.4); }
|
||||
else { aww(1); voice('game_opponent', 0.3); }
|
||||
// g = GAMES PLAYED, s = SETS YOU WON — this mode's two headline numbers in the record's two slots.
|
||||
// `dead` carries the RESULT: a match you lost is the run ending badly, a match you won is not,
|
||||
// so the stats screen's DEATHS column reads as matches lost and (runs − deaths) as matches won,
|
||||
// with no extra field to keep in step.
|
||||
const b = endRun(G.runGames, G.sets[0], G.splatT, !won, MODE_CLASSIC);
|
||||
G.best = b; G.newBest = b.newBest;
|
||||
}
|
||||
|
||||
// splatter-mode scoring: points land mid-flight without interrupting play.
|
||||
// quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point.
|
||||
// The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde,
|
||||
@@ -752,6 +794,7 @@ const xLimit = () => Math.max(xVisible(), COURT.W / 2);
|
||||
// back themselves, so nothing here has to know the shape of any of it.
|
||||
export function restartRun() {
|
||||
resetZombies(); resetInput(); resetState(); setupPoint();
|
||||
beginRun(); // ...and the stats bag, which is the only state that does not live on G
|
||||
}
|
||||
|
||||
export function forceZombies() {
|
||||
@@ -1230,6 +1273,16 @@ function mopRound(b, dt) {
|
||||
// ---------------------------------------------------------------- update ----
|
||||
export function update(dt) {
|
||||
if (G.hitstop > 0) { G.hitstop -= dt; return; }
|
||||
// THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the
|
||||
// crowd is still clapping it down — and the only thing running is the clock the summary and its
|
||||
// restart tap are gated on. Same shape as the block below, without a corpse in it.
|
||||
if (G.done) {
|
||||
G.idleT += dt; G.done.t += dt;
|
||||
if (G.done.t >= EATEN_SUMMARY && !G.done.handed) { G.done.handed = true; menuDuck(false); }
|
||||
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||||
if (G.card && (G.card.t -= dt) <= 0) G.card = null;
|
||||
return;
|
||||
}
|
||||
if (G.mode === 'eaten') { // the match is over in the truest sense
|
||||
G.idleT += dt;
|
||||
const was = G.eatenT; G.eatenT += dt;
|
||||
@@ -1470,11 +1523,15 @@ export function update(dt) {
|
||||
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0;
|
||||
if (near) {
|
||||
// rubber band: every game HE has taken makes him flub more — the opening match must not
|
||||
// be a wall a new player grinds against for rounds (see aiFlubLoss in config)
|
||||
if (Math.random() < TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss) G.aiSwingCool = 0.5; // whiff: hesitates and misses
|
||||
// be a wall a new player grinds against for rounds (see aiFlubLoss in config).
|
||||
// CLASSIC TENNIS turns the band OFF and flubs less: the band exists to get a new player
|
||||
// PAST the opener, and a mode whose whole promise is a real match cannot be won by an
|
||||
// opponent that falls apart the moment he goes ahead.
|
||||
const flub = G.classic ? TUNE.classicFlub : TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss;
|
||||
if (Math.random() < flub) G.aiSwingCool = 0.5; // whiff: hesitates and misses
|
||||
else {
|
||||
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
|
||||
trySwing(O, true); O.swingT = -1;
|
||||
trySwing(O, true, G.classic ? TUNE.classicQualityMax : TUNE.aiQualityMax); O.swingT = -1;
|
||||
G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user