Serve reads, the mop beat, the head-icon card, and Daniel benched

Oliver's batch, the code half:
- The tennis serve lost its ghost fingers (idle tap demo + apex rider deleted
  with their counters; the rally-return hint survives).
- The toss rises over the tossing HAND, measured off the toss frames' raised
  fingertips (TOSS.dx/oDx, women's tour override) — not over the head.
- An uncaught toss returns to the bounce stance instead of flashing the smash
  frame the player never earned.
- Between match games the court belongs to the mop boy: 5 s (TUNE.matchMopT),
  full job allowance, a hurry speed between stains, and the far-court reach
  limit lifted for the match (his stains are exactly where opponents die).
  The next opponent walks on only after — d.ending guards the empty court.
- The walk-on card wears the opponent's own head: config.HEAD_CROP, straight
  crops measured off the family sheets, no new art.
- The match intro is DEATH MATCH, counting the real blood pool.
- Daniel is benched: every ump_* personality line routes through aside()'s
  early return; officiating calls keep speaking. Personality pass later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-13 17:10:45 +02:00
1 parent 0e01894f4d
commit c084d53497
8 files changed
+141 -70

No files matched your search

+29 -40
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
@@ -1708,16 +1708,31 @@ function cardBody(c, dx) {
// ...and the title FITS. Cards carry whole sentences now ("SWING TO RETURN — OR SWISH IT AWAY")
// and boss names of any length, and Bangers 22 ran a long one off both edges of a phone. Measure
// once and shrink to the band; 22 stays 22 for everything that already fit.
// THE OPPONENT CARD CARRIES ITS HEAD (Oliver: the punchline plus a picture of his head). The
// icon is a straight crop out of the family's own healthy frame — config.HEAD_CROP, measured,
// never separate art — drawn left of the text block, which shifts right to keep the whole
// group centred as one. A family with no crop row degrades to the plain text card.
const hd = c.head && HEAD_CROP[c.head] && SPR['z_' + c.head + '1'] ? HEAD_CROP[c.head] : null;
const ih = hd ? CARD_H - 14 : 0, iw = hd ? Math.round(hd.w * ih / hd.h) : 0;
const room = W - 24 - (hd ? iw + 8 : 0);
ctx.font = B(22);
const tw = ctx.measureText(title).width;
const tSize = tw > W - 24 ? Math.max(13, Math.floor(22 * (W - 24) / tw)) : 22;
const tSize = tw > room ? Math.max(13, Math.floor(22 * room / tw)) : 22;
const lines = [[title, B(tSize), 0, color]];
if (sub1) lines.push([sub1, F(8), 30, UI.cream]);
if (sub2) lines.push([sub2, F(8), 44, UI.cream]);
const s = inkSpan(lines);
plate(s.bot - s.top);
plate(Math.max(s.bot - s.top, ih));
const y = Math.round(CARD_MID - (s.top + s.bot) / 2);
for (const [t, f, rel, col] of lines) { ctx.font = f; shadowText(t, W / 2, y + rel, col); }
const cx = Math.round(W / 2 + (hd ? (iw + 8) / 2 : 0));
if (hd) {
let blockW = 0;
for (const [t, f] of lines) { ctx.font = f; blockW = Math.max(blockW, ctx.measureText(t).width); }
ctx.imageSmoothingEnabled = false;
ctx.drawImage(SPR['z_' + c.head + '1'], hd.x, hd.y, hd.w, hd.h,
Math.round(cx - blockW / 2 - 8 - iw), Math.round(CARD_MID - ih / 2), iw, ih);
}
for (const [t, f, rel, col] of lines) { ctx.font = f; shadowText(t, cx, y + rel, col); }
ctx.textAlign = 'center'; ctx.restore(); // the band's neighbours (flash, countdown) assume centred
}
// The plate, sized to the `h` px of content it carries and centred on the axis. Capped at CARD_H:
@@ -1841,37 +1856,10 @@ function drawPullHint() {
demoSwipe(dir, 0, 'SWIPE TO DODGE');
}
// ---- the TENNIS lessons, same ghost hand. Tennis has no slingshot to mime, but the testteam
// struggled to grasp the tap ritual too — so the hand TAPS: on the apron when the player sits idle
// in the serve stance, riding the toss when they keep dropping it, and beside the player when
// returns keep sailing past untouched. Each appears on the exact struggle it answers and goes
// away the moment the player does the thing (the counters live in state.G, reset in gameplay).
function drawTapHint() {
const p0 = proj(P.x, 0, P.z);
const ax = Math.round(p0.x) + 20, ay = Math.round(p0.y) + 14; // beside his feet, on the apron
const t = (G.idleHintT - TUNE.pullHintAfter) % 1.6; // hover · press · hold · lift
const press = t < 0.6 ? 0 : t < 0.75 ? (t - 0.6) / 0.15 : t < 0.95 ? 1 : Math.max(0, 1 - (t - 0.95) / 0.2);
ctx.save(); ctx.globalAlpha = 0.9;
if (t >= 0.72 && t < 1.32) drawTapRing(ax, ay, (t - 0.72) / 0.6);
drawGhostHand(ax, ay, press);
ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText('TAP TO TOSS', ax - 20, ay + 40, UI.warm);
shadowText('TAP AGAIN AT THE TOP', ax - 20, ay + 54, UI.warm);
ctx.restore();
}
// two dropped tosses in a row: the hand rides the ball itself and presses exactly at the apex,
// so the TIMING is the thing being shown, not just the verb
function drawApexHint() {
const b = proj(ball.x, ball.y, ball.z);
const apex = TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity);
const press = Math.max(0, 1 - Math.abs(ball.y - apex) / 0.45);
ctx.save(); ctx.globalAlpha = 0.9;
if (press > 0.55) drawTapRing(b.x + 4, b.y + 4, 1 - press); // tight at the apex, blooming as it passes
drawGhostHand(b.x + 4, b.y + 4, press);
ctx.font = F(10); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText('TAP AT THE TOP', b.x, b.y - 28, UI.warm);
ctx.restore();
}
// ---- the TENNIS lesson, same ghost hand. The serve hints (an idle tap demo on the apron, an
// apex rider on a dropped toss) lived here too and were DELETED on Oliver's report — a ghost
// finger over the running serve animation read as clutter, and the zombie duel serves fine with
// no teacher at all. Only the return lesson below survives.
// two returns passed untouched: the hand presses harder as the ball closes on the player
function drawHitHint() {
const p0 = proj(P.x, 0, P.z);
@@ -2423,7 +2411,11 @@ export function render() {
// after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words —
// so this slot shuts up rather than printing the same instruction a second time 40 px below it
const demo = G.mode === 'splatter' && !G.interlude && !gunLike && G.idleHintT > TUNE.pullHintAfter;
const tapDemo = G.mode === 'tennis' && G.serveNum !== 2 && G.idleHintT > TUNE.pullHintAfter;
// NO GHOST HAND ON THE TENNIS SERVE — not the idle tap demo, not the apex rider. Oliver: "this
// ghost finger over the running serve animation is distracting. this must be removed. it is
// not in zombie tennis mode" — and the duel serving fine without a teacher is the argument.
// The rally-return hint (drawHitHint) stays: it answers a different struggle and he did not
// flag it. drawTapHint/drawApexHint were deleted with their counters.
// The gesture-teaching strings ('PULL DOWN TO AIM…', 'TAP TO TOSS…') are GONE — the ghost hand
// is the teacher now, and saying the same thing twice in two registers was clutter. This slot
// only carries STATE the mime cannot: which weapon the serve fires, and a second serve.
@@ -2435,11 +2427,8 @@ export function render() {
: '';
if (txt) shadowText(txt, W / 2, METER_Y(), UI.warm); // same slot the draw meter uses
if (demo) drawPullHint(); // show the gesture instead of only naming it
if (tapDemo) drawTapHint();
}
// the tennis struggle hints: each rides the exact failure it answers (see drawTapHint's block)
if (G.mode === 'tennis' && score.server === 'P' && G.phase === 'toss' && G.tossDrops >= 2 && ball.active)
drawApexHint();
// the one surviving tennis struggle hint: rides the exact failure it answers
if (G.mode === 'tennis' && G.phase === 'rally' && G.passStreak >= 2 && ball.active && ball.lastHit === 'O')
drawHitHint();
// The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL,