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

+43 -11
View File
@@ -8,7 +8,7 @@ import { W } from './render.js'; // runtime-only cycle (render imports gameplay)
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 } 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 } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -130,7 +130,7 @@ function doHit(ch, quality, aim) {
ball.x = ch.x + ch.facing * 0.5;
ball.z = ch.z + (toFar ? -0.3 : 0.3);
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
let tx, tz;
let tx, tz, aimedLane = false;
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
@@ -142,9 +142,20 @@ function doHit(ch, quality, aim) {
} 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
// AN AIMED SHOT REACHES ONLY AS FAR AS YOUR TIMING EARNED IT. The singles line is 4.11 m out.
// This used to aim a flick at a flat 3.3 m and then add err() on top — up to +-2.4 m at quality
// 0 — so a flicked return with ordinary timing landed as wide as 5.7 m and the only clamp was
// at 5. An UNAIMED return, targeting 0 + the same scatter, was always in. The flick, the one
// thing in the mode that is supposed to be skill, was what lost the point: "8/10 times the ball
// lands out". Reach now scales with the same quality the scatter shrinks with, so the two can
// never add up past the line — worst case is 1.49 + 2.4 = 3.89 at quality 0, and a perfectly
// timed flick is the full 3.3 with no scatter left on it at all. Placement is still earned;
// it just stops being a coin toss you are punished for calling.
const reach = ch.side === 'P' ? TUNE.flickReach + (1 - TUNE.flickReach) * quality : 1;
tx = aim.ax * (toFar ? 3.3 : 2.7) * reach + err();
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
if (G.match && ch.side !== 'P') {
@@ -165,10 +176,15 @@ function doHit(ch, quality, aim) {
const wide = (TUNE.matchWideBase + (G.match.opp - 1) * TUNE.matchWidePer) * (ch.foe?.wide || 1);
tx = Math.max(-wide, Math.min(wide, tx));
}
// THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a
// fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or
// held direction actually aimed it: an unaimed hit marking its scatter would teach noise.
else if (aim && (aim.ax || aim.az || aim.fx !== undefined)) G.placeMark = { x: tx, z: tz, t: 0.8 };
}
// THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a
// fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or
// held direction actually aimed it: an unaimed hit marking its scatter would teach noise.
// It carries `q` now, so the ring also says HOW MUCH OF THE FLICK LANDED — tight and hot when your
// timing earned the full reach, wide and dim when it barely applied. Without that the new
// quality-scaled reach above is invisible: the shot simply goes less wide and nothing says why.
if (aimedLane && ch.side === 'P' && aim && (aim.ax || aim.az)) {
G.placeMark = { x: tx, z: tz, t: 0.8, q: quality };
}
// topspin: quality shots dive under extra effective gravity, which is what lets real
// pace arc over the net and still land in — the core tennis trade-off
@@ -1506,11 +1522,22 @@ export function update(dt) {
G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8));
// --- AI (the opponent only exists in tennis mode)
if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') {
// THE MATCH SERVES TOO. This was gated on `G.mode === 'tennis'` and the match runs in splatter
// under a permanent interlude, so the corpse could only ever RETURN — return timing, half the
// sport, was never asked of the player. setupPoint already places a non-player server behind its
// own baseline and arms aiServeT; this is the one condition that was keeping it switched off.
// The intro beat holds it: a body still walking on must not open with a serve.
const oServes = (G.mode === 'tennis' || G.match) && score.server === 'O'
&& !(G.interlude && G.interlude.introT > 0);
if (oServes && G.phase === 'pretoss') {
G.aiServeT -= dt;
if (G.aiServeT <= 0) tossBall(O);
} else if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'toss') {
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); }
} else if (oServes && G.phase === 'toss') {
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) {
G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35);
// ...and the body that just served holds the strike, so the swing reads as its own (beats)
if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; }
}
}
if (G.mode === 'tennis' && G.phase === 'rally') {
G.aiReactT += dt;
@@ -1549,7 +1576,9 @@ export function update(dt) {
// --- racket zombies rally from the far court (splatter only, between hordes)
if (G.mode === 'splatter' && !G.horde)
if (!G.bench) for (const rz of zombies) if (rz.variant === 'r') racketAI(rz, dt); // the bench drives its own body
// ...and not while the body is on the ground: the fall owns the sprite AND the pose for its
// whole run, and racketAI would put a collapsing corpse back on its feet to chase a stray ball.
if (!G.bench && !G.matchFall) for (const rz of zombies) if (rz.variant === "r") racketAI(rz, dt); // the bench drives its own body
// --- ball physics
if (ball.active) {
@@ -1644,6 +1673,9 @@ export function update(dt) {
G.hordeWarn = 0;
if (!startHorde()) G.pendingHorde = true;
}
// ...and the match's FALL runs on its own clock for exactly the same reason, above the 'over'
// block so it can hand the point back before that block puts the player in a serve stance.
tickMatchFall(dt);
if (G.phase === 'over') {
G.overT -= dt;
if (G.overT <= 0) {