Exact touchdown, the boy finishes his shift, healing announces itself, 4K heads

Five of Oliver's six (the sixth is a play-mode discussion):
- THE DOUBLE FAULTS WERE FRAME QUANTIZATION: the bounce registered a full
  integration step after true contact — ~0.75 m of travel at serve pace — so
  serves aimed half a metre safe were judged long. Classic enforces the
  service box, the match only the court, hence 'zombie tennis does not'.
  The bounce now rewinds to y=0 before anything reads the position.
  Measured in-pane: 30/40 faults on good taps before, 0/40 after (mistimed
  and smudged taps also 0/40; aimed hard serves keep their scatter risk).
  Also: the strike flick dead zone is 44 px so a smudged tap stays a tap,
  and the aim base clamps 0.18 m inside the lines — scatter, not the aim,
  is what faults.
- The mop beat holds the walk-on until the boy actually LEAVES the court
  (ordered off at beat end, 6 s cap).
- A match game win that restores blood shows the duel's WOUNDS TREATED
  popup; full-blood wins stay silent.
- The opponent walk-on card holds 4.0 s (was 2.4).
- Card heads are cut from the 4K sheets at 144 px (assets/_heads.py,
  z_<fam>head.png, HEAD_CROP stays as fallback) — the old 65 px crops were
  the 'very low res' heads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-13 21:09:04 +02:00
1 parent c847bcbfa1
commit c6218a6284
17 files changed
+178 -23

No files matched your search

+25 -2
View File
@@ -379,7 +379,16 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
else {
// target: diagonal service box. Serving from deuce side → target box is across.
const scatter = (0.8 + hard * TUNE.serveHardErr) * (1 - quality);
boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + sx * TUNE.serveAimX
// THE AIM BASE NEVER LEAVES THE BOX. A full sway used to carry the target ACROSS the centre
// line — an aimed serve was a fault by construction, not by risk. The sway now runs from the
// box centre to 0.18 m INSIDE either line, and only the timing scatter (added after) can
// carry the ball out — which is the tennis being asked for: the line is where you aim, the
// miss is what you risked. (Classic enforces the box; see fault()'s caller.)
const side = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1);
const half = COURT.W / 2;
const aim = side * 2.0 + sx * TUNE.serveAimX;
const lo = side < 0 ? -half + 0.18 : 0.18, hi = side < 0 ? -0.18 : half - 0.18;
boxX = Math.max(lo, Math.min(hi, aim))
+ (Math.random() * 2 - 1) * TUNE.aimError * scatter;
// DEPTH RIDES QUALITY (player serves only): a flush strike aims near the service line, the
// ONLY depth where flat pace survives the net-clearance loop — MEASURED with the replica
@@ -1296,7 +1305,10 @@ export function keyPressed(code) {
// unit components; mag carries the length). Keyboard: left/right sway, up = the hard one.
// A plain tap stays the safe centre-box serve — that is the second-serve decision.
const kb = { ax: axisX(), az: axisZ() };
const k = flick ? Math.max(0, Math.min(1, (flick.mag - 24) / 130)) : 0;
// the dead zone is 44 px, well above the 28 px flick threshold: a slightly smudged strike TAP
// must stay a safe serve — accidental sway was most of classic's "double fault very often",
// because classic's line call enforces the service box (the match's only checks the court)
const k = flick ? Math.max(0, Math.min(1, (flick.mag - 44) / 130)) : 0;
const sway = flick ? flick.fx * k : kb.ax * 0.75;
const hard = flick ? Math.max(0, -flick.fy) * k : (kb.az < 0 ? 0.8 : 0);
hitServe(P, q, sway || hard ? { sx: sway, hard } : null);
@@ -1688,6 +1700,17 @@ export function update(dt) {
}
}
if (ball.y <= 0 && ball.vy < 0) {
// EXACT TOUCHDOWN, not the frame after it. The integrated position lands up to one full
// step PAST the true contact — at serve pace that is ~0.75 m of travel in 1/60 s, so a
// serve aimed half a metre inside the service line was routinely JUDGED long. Oliver saw it
// as "classic tennis gives double fault very often" (the match's line call only checks the
// whole court, which is why zombie tennis seemed immune). Rewind the step to y = 0 before
// the bounce position is read by anything — the line call, the bounce dust, the mark.
const yOld = ball.y - ball.vy * dt; // where this step began (vy already g-updated)
if (yOld > 0) {
const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step
ball.x -= ball.vx * back; ball.z -= ball.vz * back;
}
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court