The flick stops losing the point, and the corpse falls and serves

THE FLICK. "when returning a ball with a spin it seems too sensitive, 8/10
times the ball lands out." Arithmetic, not feel. The singles line is 4.115 m;
an aimed shot targeted a flat ax*3.3 and THEN added err() on top — up to
+-2.4 m at quality 0 — so a flicked return landed as wide as 5.7 m, and the
only clamp in the path sat at +-5, outside the court. An UNAIMED return
targets 0 + the same scatter and is therefore always in: aiming was the thing
losing the point, which is backwards for the mode's one act of skill.

TUNE.flickReach scales the aim width by the same quality the scatter shrinks
with, so the two can never sum past the line — worst case 1.49+2.4 = 3.89, and
a perfectly timed flick still gets the full 3.3 with nothing left to scatter.
The test asserts that sum across the whole quality range rather than sampling
rallies. Not named `aimReach`: that key already exists further down TUNE (the
slingshot's 22 m aim ray) and the duplicate silently won until a test caught it.

The placement ring carries `q` now and is the flick's receipt — tight and hot
when your timing earned the full width, wide and dim when you barely got a
racket on it. Without it the rule is invisible: the shot just goes less wide.

THE FALL (handoff item 4). A point that takes a limb knocks the body down and
it drags itself up short of it. The three collapse frames wear the stage it
STILL WAS and the rise wears the stage it has BECOME — that order is the whole
beat, and it is what turns a popping wound stage into something the point did.
Own clock beside the horde's warning, for the reason this file keeps relearning
(a beat that moves the phase under a latch strands it); the 'over' beat it sets
outlives the fall so it cannot expire into setupPoint() mid-collapse. Only
fires when the stage actually moved — a quarter off a full pool is 0.75, so
point one has no limb to explain and a second of collapse for nothing is a beat
the player waits out.

THE OPPONENT SERVES (handoff item 7), and it was mostly switched off rather
than missing. setupPoint already placed a non-player server behind its own
baseline and armed aiServeT; update() already tossed and struck for O. Both
were gated on `G.mode === 'tennis'` and the match runs in splatter under a
permanent interlude — one condition. Genuinely new: service alternates at each
game boundary (the match calls advancePoint directly and the flip lives in
tennis's pointTo, which it bypasses — the same shape as the G.serveNum bug),
the zombie takes O's mark so picture and ballistics agree on where it stands,
and the serve poses are named onto it because O is invisible here.

racketStage() moves to config: drawZombie and the fall must not answer the
same question twice, which is how the boss's skins and its blood drifted apart.

Docs: PROJECT.md gains THE ZOMBIE FAMILY PIPELINE — the measured API contract
(4K is a parameter and the default is 1K; n is 1 so "return both" is not a
thing; aspect is an enum so pad to it), why magenta stays despite green being
the documented key, and the two things _zsheet.py must do globally.

Verified in the browser: brute in the match, z_brfall2 -> z_brw1rise on a
wounding point, and bounce -> toss -> serve off the opponent's own baseline.
58 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 19:08:02 +02:00
1 parent cba2fb8a8b
commit c1f7268a04
9 files changed
+412 -37

No files matched your search

+92 -4
View File
@@ -7,8 +7,8 @@
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME } from './config.js';
import { G, P, O, ball, score, setupPoint, sendBoy } from './state.js';
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage } from './config.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.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';
@@ -305,6 +305,7 @@ export function watchdog(dt) {
export function startMatch() {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
G.match = { opp: 1, ending: false };
G.matchFall = null;
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
// mode's 4 was "too weak" here, felt not calculated
score.server = 'P'; // you serve every point (the corpse never learned to toss)
@@ -337,7 +338,83 @@ function matchReady() {
G.serveNum = 1;
setupPoint();
const rz = zombies.find(z => z.variant === 'r');
if (rz) { rz.x = O.x; rz.z = 1.0; rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; }
// THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline
// 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) {
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.
//
// It exists to explain a thing the mode was doing silently: the wound stage POPPED between points,
// so an arm simply stopped being there and nothing said why. Now the three collapse frames are the
// body it STILL WAS and the rise is the body it has BECOME — the order is the whole beat, and it is
// what makes the missing arm read as something the point did rather than a sprite swap.
//
// On its OWN clock, deliberately, and not on the 'over' beat it also sets. Any beat that moves the
// phase underneath a latch strands it forever — that is the mgT lock this file already carries a
// section about, and the horde's warning is on its own clock for the same reason. The 'over' beat is
// only there to keep the court quiet, and it is set to outlive the fall so it cannot expire into
// setupPoint() mid-collapse and hand the player a serve over a body that is still on the ground.
const FALL = [['fall1', 0.16], ['fall2', 0.18], ['fall3', 0.30], ['rise', 0.34]];
export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0);
function startFall(from) {
G.matchFall = { t: 0, from };
ball.active = false;
G.phase = 'over'; G.overT = FALL_LEN + 0.6; // outlives the fall on purpose — see above
}
// Ticked from update(), beside the horde's warning, for the same reason it is. It also drives the
// OPPONENT'S SERVE POSES, because both are the same job — naming the frame while the body is doing
// something the walk cycle has no picture for.
export function tickMatchFall(dt) {
if (!G.matchFall) return tickMatchServe(dt);
const f = G.matchFall;
const zb = zombies.find(z => z.variant === 'r');
if (!zb) { G.matchFall = null; return matchReady(); } // felled by something else mid-fall
f.t += dt;
let acc = 0, cur = null;
for (const [suf, len] of FALL) { acc += len; if (f.t < acc) { cur = suf; break; } }
if (!cur) { // back on its feet: hand the point back
G.matchFall = null; zb.showFrame = null;
return matchReady();
}
// ...and this is the line the beat is for: the collapse wears the OLD stage, the rise wears the
// NEW one. drawZombie's showFrame override degrades a missing stage to the healthy frame, so a
// family whose fall rows have not been drawn yet still plays the beat instead of blinking out.
const st = cur === 'rise' ? racketStage(zb) : f.from;
zb.showFrame = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '') + cur;
zb.pose = 'idle'; zb.swingT = -1;
}
// ---- THE OPPONENT SERVES. Alternating service is real tennis, and it doubles the mode's skill
// surface: until now the corpse only ever RETURNED, so return timing — half of the sport — was never
// asked for. Almost all of it already existed and was switched off: setupPoint's tennis branch
// already places a non-player server behind its own baseline and arms G.aiServeT, and update()
// already tosses and strikes for O. Both were gated on `G.mode === 'tennis'`, and the match runs in
// splatter with a permanent interlude, so the whole machine was one condition away from running.
//
// What is genuinely new is the PICTURE. O is invisible here — the body you see is the zombie — so
// the serve poses have to be named onto it, exactly as the fall names its collapse: bounce while it
// waits, toss as the ball goes up, and the strike held briefly after contact so the swing reads as
// something the body did rather than a ball that simply departed.
function tickMatchServe(dt) {
if (!G.match || score.server !== 'O') return;
const zb = zombies.find(z => z.variant === 'r');
if (!zb) return;
if (zb.serveHold > 0) zb.serveHold -= dt;
const st = racketStage(zb);
const pre = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '');
zb.showFrame = zb.serveHold > 0 ? pre + 'serve'
: G.phase === 'pretoss' ? pre + 'bounce'
: G.phase === 'toss' ? pre + 'toss'
: null;
}
function matchOpponent() {
@@ -359,7 +436,7 @@ function matchOpponent() {
export function endMatch() {
if (!G.match) return;
G.match = null; G.interlude = false;
G.match = null; G.interlude = false; G.matchFall = null; // a latch left armed outlives its mode
zombies.length = 0; shots.length = 0;
zombieDuck(false);
ball.active = false; setupPoint();
@@ -372,11 +449,16 @@ function matchPoint(side) {
const d = G.match;
if (d.ending) return;
const zb = zombies.find(z => z.variant === 'r');
let fellFrom = -1;
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.)
const was = racketStage(zb);
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / 4));
// ONLY when the stage actually moved. A point that chips the pool without crossing a threshold
// has no limb to explain, and a second of collapse for nothing is a beat the player waits out.
if (racketStage(zb) !== was) fellFrom = was;
}
if (side === 'O') {
G.hp--; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.6;
@@ -387,6 +469,7 @@ function matchPoint(side) {
if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost();
voice(scoreKey(), 0.4);
if (side === 'P') cheer(0.8, 0.3);
if (fellFrom >= 0) return startFall(fellFrom); // it goes down, and gets up short of a limb
matchReady(); // next point, same opponent, both on their marks
}
@@ -405,6 +488,10 @@ function matchGameWon(zb, banked = false) {
// once — and the plate is the better card anyway: it announces the win by replacing the loser.
G.flash = { text: 'GAME.', t: 1.4 };
d.opp++;
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
score.server = other(score.server);
matchOpponent(); // fresh body, its own intro beat
d.ending = false;
matchReady();
@@ -415,6 +502,7 @@ 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);
score.server = other(score.server); // their game, and now their serve
matchReady();
G.phase = 'over'; G.overT = 1.8;
}