The lob is the flick you already have, and a game stops being a death

Three from Oliver.

THE LOB. He picked option (c) from the written comparison — "no additional
gesture to learn" — and corrected the premise: the zombie does come to the
net (racketAI chases to the far service line, the classic CPU to z 9.5). So
the forward flick keeps its one meaning, "put it deep behind him", and the
game picks the trajectory that actually does that: a smash while he is back,
over his head once he is in. oppAtNet() is the whole switch, and it reads the
BODY, not O — in the match O is the invisible tennis body and only moves
between points, so it would have answered "on his mark" all rally.

lobArm 7.0 m is measured, not taste: a lob peaking at lobApex passes a man 7 m
from the net at almost exactly CONTACT.maxY, so the shot arms exactly as far
out as it can still clear him. The pace is derived from the height, so the same
arc comes off the baseline and off mid-court, and SHOT_DEPTH.lob is deeper than
deep — at the ordinary deep target the ball passed 2.05 m over him at the edge
of the band, which is inside his reach.

PACE, SEEN FROM THE STRIKER. "the opponent sees the ball really fast - the
player barely sees the speed increase." Measured: the ball is the same ball on
both phones, the camera is not — it compresses your own ball into the distance
and expands theirs as it arrives. Pace untouched; three channels added on the
striker's side: the ghost streak now reads pace (3-8 ghosts, was 3 at every
speed), the crack is pitched by it, and a fast ball kicks the camera. The
serve's own ladder is 49.2 plain / 50.8-52.4 hard, because serveArc's loft
margin takes 10% of the +9 back — widening that is his call, and PROJECT.md
records the knob and the risk.

THE DEATH MATCH. "each player has 8 blood points but the player explodes for
the winner already at 0:40." Two clocks: a game is four points, a life is
eight. A won game is only a game now (flash, serve, heal); the burst moved to
the death itself, on the dead message, where both phones agree. The summary
waits 1.4 s for the gore, because update() freezes the world on G.done and
would have hung a still frame of an explosion behind the trophy screen. And
bleedO tracks THEIR blood off the pos stream instead of our points-this-game,
which reset every game and ignored the heal.

4 new tests, 113 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-22 12:58:48 +02:00
1 parent 6e07dc74bb
commit 4f4534e272
9 files changed
+355 -38

No files matched your search

+57 -6
View File
@@ -16,6 +16,21 @@ import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFau
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
// `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a
// shambler's chest, and solving to the ground under one is what made the ball strike the ankles.
// The recorded racket sample, pitched by the pace of the ball that just left it — 20 m/s of rally
// dink at 0.93, a 52 m/s serve at 1.09. Deliberately narrow: past about a tenth either way the
// sample stops sounding like the same racket. Read off the LIVE ball, so it is one call site's
// worth of code for every strike in the game.
const strikeRate = () => 0.93 + Math.max(0, Math.min(0.16, (Math.hypot(ball.vx, ball.vz) - 20) / 200));
// ...and a fast ball KICKS THE CAMERA as it leaves. The third answer to the same report: the trail
// says how fast the ball is, the crack says how hard it was hit, and this says it in the one channel
// the striker cannot miss, because it moves the whole picture rather than something inside it.
// YOUR racket only (the far side's strike is something you watch — the same rule the haptic keeps),
// nothing under 26 m/s, and never over a shake that is already running: a bomb outranks a backhand.
const strikeKick = () => {
const p = Math.min(2.2, (Math.hypot(ball.vx, ball.vz) - 26) / 9);
if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at
};
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) {
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T };
@@ -142,6 +157,15 @@ export const swayOf = aim => aim.mag === undefined ? aim.ax
// as flat rather than resetting the lesson it just failed to use.
export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1);
// WHERE THE MAN ACROSS THE NET ACTUALLY IS — and it is not always O. In the duel and the match the
// opponent is the BODY: O is the invisible tennis body the serve machinery is driven off, and it
// only moves between points, so asking O where the opponent is would answer "on his mark" all
// rally. In classic and in netplay (both kinds) O is the opponent, drawn and driven.
const farBody = () => (!G.net && zombies.find(z => z.variant === 'r')) || O;
// HAS HE COME IN? The one question the lob is armed on — see TUNE.lobArm for why 7 m and not taste.
// Exported for the suite and for anything that ever wants to show the player that the shot is live.
export const oppAtNet = () => COURT.netZ - farBody().z < TUNE.lobArm;
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';
@@ -156,7 +180,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, aimedLane = false, sway = 0;
let tx, tz, aimedLane = false, sway = 0, lob = 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;
@@ -206,14 +230,30 @@ function doHit(ch, quality, aim) {
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
}
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
// THE LOB IS THE SAME FLICK, READ AGAINST WHERE HE IS STANDING (Oliver 2026-08-22, "lupfen").
// Forward flick = "put it deep behind him", and when he has come to the net the trajectory that
// does that is over his head, not through him. No new gesture, nothing to learn, and it cannot
// be mis-fired — with him on his baseline the same flick is the smash it has always been.
lob = ch.side === 'P' && aim.az < 0 && oppAtNet();
const tzBase = lob ? SHOT_DEPTH.lob
: aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
// depth and nothing else — the gesture had a direction with no consequence behind it. It buys
// PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot
// still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather
// than longer. A smash you can hit out on a whim would just be the bug above wearing a hat.
if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality;
if (ch.side === 'P' && aim.az < 0 && !lob) s *= 1 + TUNE.smashBoost * quality;
// PACE DERIVED FROM THE HEIGHT, not chosen: for the lob to peak at lobApex it must leave at
// vy = sqrt(2g(apex - y)), which fixes the hang time, and the hang time over the ground still
// to cover fixes the speed. Same arc from the baseline and from mid-court — a fixed speed
// would be a lob from one and a flat drive from the other. Timing still prices it: `tz` above
// already carries the same scatter every aimed ball does.
if (lob) {
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, TUNE.lobApex - ball.y));
const hang = (vy0 + Math.sqrt(vy0 * vy0 + 2 * TUNE.gravity * Math.max(0, ball.y - 0.08))) / TUNE.gravity;
s = Math.hypot(tx - ball.x, tz - ball.z) / hang;
}
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
@@ -247,7 +287,9 @@ function doHit(ch, quality, aim) {
}
// 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
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
// ...and a lob carries NO topspin: the dive multiplier is what makes a hard flat ball land in, and
// on a shot whose whole point is hang time it would just pull the arc down into the net.
const g = lob ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
// low contact → trade pace for loft until the net is cleared; good timing gets a
// comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's,
@@ -273,7 +315,11 @@ function doHit(ch, quality, aim) {
// (the landing ring that used to be stamped here confused testers — the umpire calls the ball,
// the court stays clean)
if (ch.side === 'P') G.aiReactT = 0; // CPU hasn't read the shot yet
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side
// ...and the CRACK carries the pace (same report as the ball streak): one recorded sample, pitched
// by how fast the ball actually left. It is the striker's cue at the one moment he is looking at
// his own racket rather than at the far court.
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded racket strike, per side
if (ch.side === 'P') strikeKick();
// CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not
// something your hand did, and buzzing for both halves of every rally is the numbing failure mode.
if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one
@@ -282,6 +328,10 @@ function doHit(ch, quality, aim) {
const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact
if (w) popText(ball.x, ball.y, ball.z, w);
}
// ...and the lob NAMES ITSELF. (c) buys "no gesture to learn" by letting context change what the
// flick means, and the price of that is that nothing on screen says which shot you just asked
// for. One word at the contact, in the praise words' own slot, is what pays it back.
if (lob) popText(ball.x, ball.y + 0.3, ball.z, 'LOB');
ch.pose = 'swing'; ch.poseT = 0;
if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit
}
@@ -515,7 +565,8 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
}
}
// (spendSlot already hands you back plain balls when the last one goes)
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded serve strike, per side
if (sv.side === 'P') strikeKick();
if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit
sv.pose = 'serve'; sv.poseT = 0;
if (perfect && sv.side === 'P') {