The receiver waits behind his line, and a missed serve buys a second one

Oliver, three notes; two of them are here.

"when the opponent serves the player does not stand at the baseline - but
that's how it should be." setupPoint's receiving marks sat over a metre
INSIDE the court on both sides (22.5 near, 1.2 far) — where you stand to
attack a second serve, not where you wait for a first one. They mirror the
server's depth now, in the one line every serving mode shares.

"when serving results in an out there should be a second service... the
umpire should announce 2nd service and the player must get another chance."
True in tennis mode since forever and absent everywhere else: the duel, the
match and the death match had no service rule at all, so a serve long or
into the net was judged as an ordinary ground stroke — a point, and in the
match a point costs blood. The out branch of onBounce's interlude block
routes a serve to fault() instead, so the second serve, the HUD line, the
umpire and the double fault all come for free.

Three things it dragged out with it:
 - fault()'s double-fault branch called pointTo — TENNIS scoring — which in
   an interlude banks a number and skips the mode that owns the court. It
   goes through resolveInterlude there now. Nothing had ever reached that
   line from a duel or a match, because those modes could not fault.
 - startDuel never reset G.serveNum: harmless while a duel could not fault,
   the next duel's death loop the moment it can.
 - the match puts the body back on its receiving mark for the second serve
   (placeReceiver, split out of matchReady, which resets the serve number).

"Fault. Second service." is one voiceSeq — a second voice() call would be
dropped, one man one microphone. New recording via the usual say/afconvert.

The service BOX is still not enforced in the match, deliberately: this is
strictly more forgiving than what shipped. The lob gesture is OPEN 14.

4 new tests, 110 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-22 09:40:48 +02:00
1 parent 3d7e66449a
commit 6e07dc74bb
7 files changed
+182 -19

No files matched your search

+21 -12
View File
@@ -71,6 +71,10 @@ export function startDuel() {
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
G.serveNum = 1; // ...on a FIRST serve. A duel now faults (gameplay's onBounce), and
// nothing else resets it here — a fault in the last duel would have
// armed second-serve in the next one, which is the death loop this
// file already carries a note about, one mode over.
G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
ball.active = false; setupPoint();
zombieDuck(true); // fade the zombie music out for the 1:1 duel
@@ -420,18 +424,23 @@ export function matchReady() { // exported for net.js: a paired death match re-p
// when it is the one serving. setupPoint has just placed O — the invisible tennis body the serve
// machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for
// the mark, which is what stops the picture and the ballistics disagreeing about where it stands.
if (rz) {
// WHERE A RETURNER ACTUALLY STANDS. The tennis mark is 1.3 m off the centre line, and from
// there he bisects nothing: the T is one step away and the corner of the box is four. Measured
// headless, a flush serve with full sway into that corner was returned 0 times out of 20 at
// EVERY rung of the ladder — Oliver: "the first ten zombies can be killed the moment you
// serve". He now stands out on the bisector of the two serves he has to cover, which is what a
// real receiver does, and O takes the same mark so the picture and the ballistics still agree.
if (score.server === 'P') O.x = Math.sign(O.x || 1) * TUNE.matchRecvX;
rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0;
rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3;
rz.showFrame = null; rz.serveHold = 0;
}
if (rz) placeReceiver(rz);
}
// WHERE A RETURNER ACTUALLY STANDS. The tennis mark is 1.3 m off the centre line, and from there he
// bisects nothing: the T is one step away and the corner of the box is four. Measured headless, a
// flush serve with full sway into that corner was returned 0 times out of 20 at EVERY rung of the
// ladder — Oliver: "the first ten zombies can be killed the moment you serve". He now stands out on
// the bisector of the two serves he has to cover, which is what a real receiver does, and O takes
// the same mark so the picture and the ballistics still agree.
// Split out of matchReady because a SECOND SERVE needs the same mark without matchReady's rest:
// that call resets G.serveNum, which is the one thing a fault has just spent (see fault()).
export function placeReceiver(rz = zombies.find(z => z.variant === 'r')) {
if (!rz) return;
if (score.server === 'P') O.x = Math.sign(O.x || 1) * TUNE.matchRecvX;
rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0;
rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3;
rz.showFrame = null; rz.serveHold = 0;
}
// ---- THE FALL. A point won knocks the body DOWN and it drags itself back up MINUS THE LIMB.
+29 -4
View File
@@ -2,14 +2,14 @@
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, CUP_ROUNDS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { haptic } from './haptics.js';
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, updateBench, benchKey, tickMatchFall } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
// ------------------------------------------------------------- ballistics ----
@@ -1048,8 +1048,22 @@ export function fault() { // exported for net.js: the receiver's fault call re-a
if (G.serveNum === 1) {
if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double
// fault below goes out through pointTo as the point it is
G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy();
// "Fault. Second service." — the second half is the whole point of the call: a lone "fault"
// never said that the player still had a serve coming (Oliver, 2026-08-22). voiceSeq, not two
// voice() calls: the umpire is one man with one microphone and the second line would be DROPPED.
G.serveNum = 2; voiceSeq(['fault', 'second_service']); G.phase = 'over'; G.overT = 1.4; sendBoy();
// ...and in a MATCH the body goes back on its receiving mark for the second serve, exactly as
// matchReady puts it there for a first one — it is standing wherever it chased the fault to.
// Not in netplay: the called ball is left rolling there (net.js), and matchReady's ball.active
// = false would freeze it mid-roll on the far phone.
if (G.match && !G.net) placeReceiver();
}
// A DOUBLE FAULT IS A POINT, AND A POINT IS SCORED BY THE MODE THAT OWNS THE COURT. pointTo is
// TENNIS scoring; the duel and the match settle points through resolveInterlude (blood, the fall,
// the ladder) and a raw pointTo there would bank a number and skip all of it — the same seam the
// interlude branch of onBounce is written around. Nothing reached this line from an interlude
// before, because those modes had no fault in them at all.
else if (G.interlude) { voice('double_fault'); resolveInterlude(other(score.server)); }
else pointTo(other(score.server), 'double_fault');
}
@@ -1147,7 +1161,18 @@ function onBounce() {
if (G.net && ball.lastHit === 'P') return;
if (ball.bounces === 1) {
const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ;
if (!inCourt(ball.x, ball.z) || !okSide) { endFlight(); resolveInterlude(other(ball.lastHit)); voice('out'); }
// A MISSED SERVE BUYS A SECOND ONE, IT DOES NOT LOSE THE POINT (Oliver, 2026-08-22: "when
// serving results in an out there should be a second service"). These modes had no service
// rule at all: the first serve was judged as an ordinary ground stroke, so a serve into the
// net or over the baseline handed the point away on the spot — in the match, that is a point
// and blood for a first serve. It goes through the same fault() the tennis modes use, so the
// second serve, the umpire's call and the double fault all come for free.
// The BOX is deliberately still not enforced here (see the note in hitServe): a deep-but-in
// serve stays legal in the match, which is the concession that mode was tuned around.
if (!inCourt(ball.x, ball.z) || !okSide) {
endFlight();
if (G.wasServe) fault(); else { resolveInterlude(other(ball.lastHit)); voice('out'); }
}
// THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know
// this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just
// bounced in and is provably beyond the body's reach has already decided the point, so it
+6 -1
View File
@@ -217,7 +217,12 @@ export function setupPoint() {
const sx = deuceSide() ? 1.3 : -1.3;
sv.x = sv.side === 'P' ? sx : -sx; // deuce side mirrors across the net
sv.z = sv.side === 'P' ? 24.3 : -0.5;
rc.x = -sv.x; rc.z = rc.side === 'P' ? 22.5 : HOME.O.z - 1.3;
// A RECEIVER STANDS BEHIND HIS BASELINE (Oliver, 2026-08-22: "when the opponent serves the
// player does not stand at the baseline — but that's how it should be"). Both marks used to sit
// over a metre INSIDE the court (22.5 near, 1.2 far), which is where you stand to return a second
// serve, not a first one — and next to a server drawn behind his own line it read as a man who
// had wandered in. They now mirror the server's depth: 0.4-0.5 m behind the line on both sides.
rc.x = -sv.x; rc.z = rc.side === 'P' ? 24.2 : -0.43;
P.pose = O.pose = 'idle'; P.swingT = O.swingT = -1;
P.facing = O.facing = 1; // canonical righty stance — bounce/serve sprites must not render mirrored
G.aiServeT = score.server === 'O' ? 1.4 : 0;