Seven verdicts from the test group

- opening CPU beatable in a game or two (softer cap, more whiffs, rubber band)
- modals are for calm courts only; live-court news is a card now
- the transition howto is gone: a ghost hand teaches the pull, the toss,
  the apex and the swing, each on the exact struggle it answers
- a shot barrel keeps your weapon and glows its slot instead
- switching weapons clicks, the live tile is unmistakable
- barrels come faster the deeper the run goes
- the umpire triple-tap cheat is deleted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-08 19:49:27 +02:00
1 parent f13cbef28a
commit a89e8ff419
10 files changed
+242 -79

No files matched your search

+37 -38
View File
@@ -112,6 +112,7 @@ function zombieTarget(aim) {
function doHit(ch, quality, aim) {
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
const toFar = ch.side === 'P';
if (ch.side === 'P') G.passStreak = 0; // a landed swing ends the tap-to-hit lesson
// snap the ball to the racket at the contact frame so the hit visually connects
ball.x = ch.x + ch.facing * 0.5;
ball.z = ch.z + (toFar ? -0.3 : 0.3);
@@ -330,6 +331,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
sv.pose = 'serve'; sv.poseT = 0;
if (sv.side === 'P') G.tossDrops = 0; // a served toss ends the apex lesson
if (perfect) { praise(); G.hitstop = 0.05; }
}
@@ -402,6 +404,7 @@ function pointTo(side, reason) {
const n = G.rallyNear;
logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`,
dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) });
G.passStreak++; // two of these in a row and the ghost hand shows the tap (render.drawHitHint)
}
const gameOver = advancePoint(side);
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
@@ -467,9 +470,10 @@ export function splatPoint(side, quiet = false) {
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
spawnMachine();
// ...and a weapon barrel on its own schedule. One at a time: two glowing pink things on court at
// once would out-shout everything the palette uses to say "this one is dangerous".
if (side === 'P' && G.runGames % TUNE.barrelEvery === 0
// ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp),
// because the late game is where the rack has to carry you. Still one at a time: two glowing
// pink things on court would out-shout everything the palette uses to say "this one is dangerous".
if (side === 'P' && G.runGames % Math.max(1, TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp)) === 0
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
spawnBarrel();
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
@@ -544,7 +548,8 @@ const slotOf = id => { // -1 if you are not carrying it
export function selectWeapon(i) {
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
G.weapon = i; pock(0.3, 1100); // temp has just been cleared, and then it is BALL again)
G.weapon = i; // temp has just been cleared, and then it is BALL again)
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
}
// Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides
@@ -607,30 +612,13 @@ export function deliverTin() {
if (wasPlain) G.weapon = 0;
sendBoy(); voice('new_balls', 0.6);
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
// THE PRIZE GETS ITS OWN CARD, with the tin's own icon on it. It used to borrow G.flash — the
// same channel as PERFECT and DOUBLE KILL — so the one thing you played the duel FOR arrived as a
// word in the praise slot and read as another compliment. The first time a tin is seen the modal
// announces it (and teaches its rules), so the card would be the SAME news twice, back to back —
// it only speaks when the modal doesn't. Every time after, the card is the announcement, and it
// does not want a tap: you have just won a point, not been asked a question.
if (!weaponModal(id))
G.card = { title: WEAPONS[id].label, sub1: 'DELIVERED TO THE RACK',
sub2: (WEAPONS[id].desc && WEAPONS[id].desc[0]) || '',
icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT };
}
// The one-per-run "what this thing does" popup, shared by the duel ladder and the barrels.
// THE GATE LIVES HERE, not at the call sites. The barrels gated themselves and the tin ladder did
// not, so the four delivered weapons re-explained themselves every three games for a whole run
// while a barrel weapon explained itself once — the same popup behaving two different ways.
// Returns whether it actually announced, so a caller with its own announcement (the tin card, the
// barrel's flash) can stay quiet rather than say the same thing twice.
export function weaponModal(id) {
if (G.seen['w' + id]) return false;
G.seen['w' + id] = true;
const w = WEAPONS[id];
showModal({ title: w.label, body: w.desc, icon: w.icon, iconH: 40, color: UI.warm });
return true;
// THE PRIZE GETS ITS OWN CARD, with the tin's own icon on it — the announcement AND the whole
// explanation. The first-sight weapon MODAL is gone (with popBarrel's): the testteam never read
// them, and a popup that freezes a live court mid-run costs runs. The card carries the one desc
// line that matters and does not want a tap: you have just won a point, not been asked a question.
G.card = { title: WEAPONS[id].label, sub1: 'DELIVERED TO THE RACK',
sub2: (WEAPONS[id].desc && WEAPONS[id].desc[0]) || '',
icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT };
}
// ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing
@@ -905,11 +893,11 @@ function transition(dt) {
once('go', 8.2, () => {
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
// 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', 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY'] });
// NO modal here anymore — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches
// the pull instead: prime the idle clock so the apron demo starts miming IMMEDIATELY rather
// than after the usual beat of inaction, and it keeps coming back whenever the player stalls.
G.idleHintT = TUNE.pullHintAfter;
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
});
}
@@ -1458,6 +1446,7 @@ export function update(dt) {
if (G.mode !== 'tennis') updateZombies(dt);
tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
if (G.meleeT > 0) G.meleeT -= dt;
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out
watchdog(dt);
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.mode === 'transition') transition(dt);
@@ -1498,7 +1487,9 @@ export function update(dt) {
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0;
if (near) {
if (Math.random() < TUNE.aiFlub) G.aiSwingCool = 0.5; // whiff: hesitates and misses
// 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
else {
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
trySwing(O, true); O.swingT = -1;
@@ -1577,6 +1568,9 @@ export function update(dt) {
if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0 && !G.aim) { // uncaught toss → retoss (a held slingshot aim keeps the toss up)
ball.active = false; G.phase = 'pretoss';
if (score.server === 'O') G.aiServeT = 0.8;
// dropping the toss twice running means the second tap isn't landing — the ghost hand then
// rides the next toss and presses at the apex (render.drawApexHint)
else if (G.mode === 'tennis') G.tossDrops++;
}
}
@@ -1593,10 +1587,15 @@ export function update(dt) {
// card, because 30 is both a set and a boss. The wave then poured onto the boss stage. Leave the
// latch alone and let the beat AFTER the fight cash it: the set still gets its horde, later.
if (G.pendingHorde && !G.bossFight && !G.interlude) { // 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 });
G.pendingHorde = false; // (racket auto-fires, the finger steers you absolutely),
if (!G.seen.horde) { // so its FIRST arrival gets a real block...
G.seen.horde = true;
showModal({ title: 'THEY KEEP COMING', color: UI.hot,
body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'],
onDismiss: startHorde });
} else startHorde(); // ...and every later set starts on the flash alone. Re-showing the
// same popup at the hottest moment of every set was one of the
// "popups get me killed" reports — you know the rules by set two.
return;
}
// auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into