Feedback round + Phase 13: the duel, free-aim, honest stakes

Oliver's six complaints, addressed:
1. Opponent never came — a racket zombie now spawns on EVERY game you win
   (if none alive); endHorde no longer double-spawns
2. No wave-cleared feeling — sweepCourt: at game end the surviving wave
   collapses, every game gets a real beat (gameBeatT 1.6 / changeover 4.5),
   and 'GAME N' announces the next wave when play re-arms
3. Serve-posture lockup during machine gun — root cause: any 'over' beat
   expiring into setupPoint() -> pretoss during auto-fire (no movement outside
   rally, no serving during horde/GUN). The over-expiry now returns auto-fire
   modes to 'rally', never the serve loop
4. Forced bomb spending — specials commit ONLY on deliberate serves: flick
   (or arrow-aimed keyboard serve) fires the tin, plain tap keeps it; serve
   prompt teaches it ('FLICK FIRES THE BOMB - TAP KEEPS IT')
5. Auto-aim — free-aim: the flick vector maps to a landing point (length =
   depth, up to 22m), zombies within zSnap 1.4 magnet the shot, otherwise the
   ball lands exactly where flicked; taps keep the old auto-target assist;
   flicks now also work at the toss apex
6. Zombies walking through you — option (a): touch is honest. eatenR 0.9
   (brute +0.2) matches the visual sprite overlap

Phase 13 — SUDDEN DEATH interlude: court clear + un-dueled racket zombie +
about to serve -> 'Quiet, please. Play will resume.' / 'Sudden death.' One
point under REAL tennis rules (line calls live, the boss plays every ball,
spawns paused). Win (line call, double bounce, or regicide by any weapon) =
the next tin delivered on the spot; lose (out, or its return passes you) =
a game on their scoreboard + an instant 5-shambler surge. One duel per
opponent. New lines: play_resume.m4a, sudden_death.m4a.

Verified in browser: horde 'over' beat -> rally (lockup dead); game win ->
sweep + boss + beat -> SUDDEN DEATH chains naturally; duel lose path (GAME.
THEM., surge 5, scoreboard), no re-duel, fresh-boss duel, bomb regicide ->
tin on the spot; tap serve keeps stock, real touch-flick consumes + lands
at the flicked point, magnet snaps when near; eaten at 0.85m; tennis 11/12;
no console errors. PLAN.md/HANDOFF updated: plan v2 complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 11:50:18 +02:00
1 parent 06bd24e6ea
commit 2f1419b1f2
10 files changed
+183 -285

No files matched your search

+107 -20
View File
@@ -4,7 +4,7 @@ import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DE
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, applause, voice } from './audio.js';
import { axisX, axisZ, takeFlickAim } from './input.js';
import { updateZombies, spawnZombie, zombies, explode, meleeSwipe, extras, parts } from './zombies.js';
import { updateZombies, spawnZombie, spawnRacketZombie, sweepCourt, zombies, explode, meleeSwipe, extras, parts } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
@@ -21,8 +21,23 @@ export function predictLanding() { // first-bounce point, closed form — exact,
return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t };
}
// splatter aiming: your flick picks a lane (ax) and depth band (az); the shot locks onto
// the best-matching zombie anywhere on the court — including your own side of the net.
// splatter FREE-AIM: the flick vector maps to a landing point from where you stand — a short
// flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that
// point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked.
function flickTarget(ch, aim) {
const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> meters of reach
const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R));
const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z)
let zt = null, bd = TUNE.zSnap;
for (const zb of zombies) {
const d = Math.hypot(zb.x - tx, zb.z - tz);
if (d < bd) { bd = d; zt = zb; }
}
return { tx, tz, zt };
}
// splatter tap/lane aiming (no flick): the shot locks onto the best-matching zombie
// anywhere on the court — including your own side of the net.
function zombieTarget(aim) {
if (!zombies.length) return null;
const none = !aim || (!aim.ax && !aim.az);
@@ -49,11 +64,15 @@ function doHit(ch, quality, aim) {
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
let tx, tz;
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
const zt = G.mode !== 'tennis' && ch.side === 'P' ? zombieTarget(aim) : null;
if (zt) {
const ft = G.mode !== 'tennis' && ch.side === 'P'
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
if (ft && ft.zt) {
const zt = ft.zt;
tx = zt.x + err() * 0.4;
const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s;
tz = Math.min(23, zt.z + zt.v * T0) + err() * 0.3; // lead the shambler's walk
} else if (ft && ft.tx !== undefined) {
tx = ft.tx + err() * 0.4; tz = ft.tz + err() * 0.3; // no magnet: the ball goes where you flicked
} else {
tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
@@ -103,11 +122,15 @@ function hitServe(sv, quality, aim = null) {
const toFar = sv.side === 'P';
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
let boxX, tzs;
const zt = G.mode !== 'tennis' ? zombieTarget(aim) : null;
if (zt) { // splatter serves auto-track the most advanced zombie
const ft = G.mode !== 'tennis'
? (aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }) : null;
if (ft && ft.zt) { // magnet hit, or (tap) the most advanced zombie
const zt = ft.zt;
boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8;
const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s;
tzs = Math.min(23, zt.z + zt.v * T0);
} else if (ft && ft.tx !== undefined) { // free-aim, no magnet: serve to the flicked spot
boxX = ft.tx; tzs = ft.tz;
} else {
// target: diagonal service box. Serving from deuce side → target box is across.
boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality);
@@ -121,12 +144,14 @@ function hitServe(sv, quality, aim = null) {
ball.lastHit = sv.side; ball.bounces = 0;
ball.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts
// the armed tin auto-loads the serve — one-thumb rule, no weapon-select input
ball.bomb = G.mode === 'splatter' && G.bombs > 0;
// specials ride only DELIBERATE serves: a flick (or arrow-aimed keyboard serve) commits the
// tin, a plain tap keeps it in stock — so bombs wait for the enemy you mean them for
const armed = G.mode === 'splatter' && !!aim;
ball.bomb = armed && G.bombs > 0;
if (ball.bomb) G.bombs--;
ball.flame = G.mode === 'splatter' && !ball.bomb && G.flames > 0;
ball.flame = armed && !ball.bomb && G.flames > 0;
if (ball.flame) G.flames--;
if (G.mode === 'splatter' && G.triples > 0) { // one serve, three balls: clones fanned ±6°
if (armed && G.triples > 0) { // one serve, three balls: clones fanned ±6°
G.triples--;
for (const deg of [-6, 6]) {
const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r);
@@ -204,11 +229,17 @@ export function splatPoint(side, quiet = false) {
G.tinDue = true;
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
cheer(2.2, 0.5);
sweepCourt(); // the wave is OVER: survivors collapse where they stand — a game ends visibly
ball.active = false; // the beat owns the court
G.pendingGame = true; // 'GAME N' announces the next wave when play resumes
const i = side === 'P' ? 0 : 1;
if (score.games[i] >= 6) { // set to the winner — and the court answers with a horde
G.sets[i]++; score.games = [0, 0];
startHorde();
} else if ((score.games[0] + score.games[1]) % 2 === 1) startChangeover(); // real rule: after odd games
else { G.phase = 'over'; G.overT = TUNE.gameBeatT; } // even games get a real beat too, not a 0.1s blink
if (side === 'P' && !zombies.some(z => z.variant === 'r'))
spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it)
} else if (!quiet || G.idleT - G.lastScoreT > 2) {
voice(scoreKey(), 0.45); G.lastScoreT = G.idleT;
if (side === 'P' && !quiet) cheer(0.7, 0.25);
@@ -242,6 +273,25 @@ function deliverTin() {
G.flash = { text: 'NEW BALLS: ' + ['BOMB', 'TRIPLE', 'FIRE', 'GUN'][w], t: 1.6 };
}
// ---- 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.
export function resolveInterlude(side) {
if (!G.interlude) return;
G.interlude = false;
ball.active = false; G.phase = 'over'; G.overT = 2.2;
if (side === 'P') {
cheer(3, 0.7);
deliverTin(); // win the point, win the next tin — on the spot
} else {
voice('game_opponent', 0.4);
score.games[1]++; G.runGames++; G.shameGame = true; // it goes on the scoreboard
if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
G.flash = { text: 'GAME. THEM.', t: 1.6 };
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');
}
}
// retire the splatter ball; a running changeover's long beat must not be clobbered short
export function spentBall() {
ball.active = false;
@@ -253,6 +303,7 @@ export function spentBall() {
function startHorde() {
if (G.horde) return;
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
G.pendingGame = false; // the horde IS the announcement
G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 };
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
@@ -307,6 +358,13 @@ function onBounce() {
explode(ball.x, ball.z); ball.bomb = false;
spentBall(); return;
}
if (G.mode === 'splatter' && G.interlude) { // sudden death: the lines are LIVE again
if (ball.bounces === 1) {
const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ;
if (!inCourt(ball.x, ball.z) || !okSide) { ball.active = false; resolveInterlude(other(ball.lastHit)); voice('out'); }
} else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); }
return;
}
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
if (ball.bounces === 1) {
ball.firstLand = { x: ball.x, z: ball.z };
@@ -436,7 +494,8 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
function racketAI(rz, dt) {
expirePose(rz, dt);
rz.swingCool -= dt;
const incoming = ball.active && ball.lastHit === 'P' && (ball.pow || 0) < TUNE.zSplatQ && ball.vz < 0;
const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0
&& (G.interlude || (ball.pow || 0) < TUNE.zSplatQ); // in the duel it plays EVERY ball — it's tennis now
if (!incoming) { if (rz.pose === 'run') rz.pose = 'idle'; return; }
const tz = Math.max(1.5, Math.min(COURT.svcFar, ball.z)); // chase laterally, hold deep on the far court
const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
@@ -481,7 +540,10 @@ export function keyPressed(code) {
let q = Math.max(0.05, 1 - dy / 0.9);
if (dy < 0.12) q = 1;
G.wasServe = true;
hitServe(P, q);
// a flick at the apex (or held arrows) makes this a DELIBERATE serve: aimed + consumes a special
const flick = G.mode === 'splatter' ? takeFlickAim() : null;
const kb = { ax: axisX(), az: axisZ() };
hitServe(P, q, flick || (kb.ax || kb.az ? kb : null));
return;
}
if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; }
@@ -557,6 +619,16 @@ export function update(dt) {
if (G.mode !== 'tennis') updateZombies(dt);
if (G.mode === 'transition') transition(dt);
// --- 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.
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) {
zombies[0].dueled = true; // one duel per opponent — after that it just rallies
G.interlude = true;
voice('play_resume', 0.2); voice('sudden_death', 2.4);
G.flash = { text: 'SUDDEN DEATH', t: 1.8 };
}
// --- AI (the opponent only exists in tennis mode)
if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') {
G.aiServeT -= dt;
@@ -621,7 +693,10 @@ export function update(dt) {
onBounce();
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
ball.active = false;
if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming
if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter
if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel
else resolveInterlude(ball.lastHit);
} else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming
spentBall(); sendBoy();
if (Math.random() < 0.12) voice('new_balls', 0.3);
} else if (ball.bounces === 1) pointTo(ball.lastHit, null);
@@ -629,16 +704,19 @@ export function update(dt) {
}
// a racket zombie's return that gets past you (crosses your baseline) is a point for THEM
if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
if (!G.horde) splatPoint('O'); // may start a changeover — spentBall respects its beat
spentBall();
ball.active = false;
if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel
else { if (!G.horde) splatPoint('O'); spentBall(); }
}
// splatter: retire spent or hopeless balls fast so the next serve comes right up.
// z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26)
// heads to the far court (vz<0), so it must not read as "out the back" on frame one.
// (during the interlude only spent balls retire early — flying balls must reach their
// bounce so the line call can happen; the umpire is watching again)
if (G.mode === 'splatter' && ball.active &&
((ball.lastHit === 'X' && ball.bounces >= 1)
|| (ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0)
|| Math.abs(ball.x) > 7.5)) {
|| (!G.interlude &&
((ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0) || Math.abs(ball.x) > 7.5)))) {
spentBall();
}
if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss
@@ -664,10 +742,19 @@ export function update(dt) {
G.overT -= dt;
if (G.overT <= 0) {
if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes
if (G.mgT > 0) { // a GUN tin was delivered: no serving, the racket fires itself, drag steers
// 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
// fired (no movement outside 'rally', no serving during auto-fire). Back to rally.
if (G.horde || G.mgT > 0) {
G.phase = 'rally'; ball.active = false;
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
} else setupPoint();
} else {
setupPoint();
if (G.pendingGame && G.mode === 'splatter') { // the next wave is announced as it arms
G.pendingGame = false;
G.flash = { text: 'GAME ' + (G.runGames + 1), t: 1.1 };
}
}
}
}
}