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

+27 -12
View File
@@ -1,7 +1,7 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS } from './config.js';
import { P, O, ball, score, crowd, G, court, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
@@ -232,11 +232,11 @@ function doHit(ch, quality, aim) {
// ----------------------------------------------------------------- serve ----
function tossBall(sv) {
ball.active = true; ball.bounces = 0; ball.collected = false;
// The player is right-handed: he tosses with his LEFT hand (viewer's left) and hits with the
// racket on the viewer's right, so the ball goes up almost over his head — a big offset either way
// lands it on the wrong side of one of those two frames. The opponent is drawn from the front and
// keeps the reach he was drawn with.
ball.x = sv.x + (sv.side === 'P' ? 0.12 : -0.35); ball.z = sv.z; ball.y = TOSS.y0;
// The ball rises over the tossing HAND, not the head — TOSS.dx/oDx are measured off the toss
// frames' raised fingertips (see config). `* facing` because drawChar mirrors the whole sprite
// when the server faces left, exactly like the idle bounce's offset.
ball.x = sv.x + sv.facing * (sv.side === 'P' ? (tour.tossDx ?? TOSS.dx) : TOSS.oDx);
ball.z = sv.z; ball.y = TOSS.y0;
ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity;
// THE TOSS IS THE SERVER'S BALL. lastHit was left stale from the previous rally, and if the
// OPPONENT had touched it last, the freshly tossed ball read as *their* ball in flight — which
@@ -424,7 +424,6 @@ 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
sv.pose = 'serve'; sv.poseT = 0;
if (sv.side === 'P') G.tossDrops = 0; // a served toss ends the apex lesson
if (perfect && sv.side === 'P') {
G.hitstop = 0.05;
// the word is ARMED here and pops at the IMPACT — first body the ball touches, or its first
@@ -474,6 +473,13 @@ export function dismissModal() {
// and not situation aware.. they seem random". `aside()` is the single entry point and it enforces
// one shared cooldown (TUNE.asideEvery), so several triggers firing at once still yields one remark.
export function aside(name, delay = 0.2) {
// DANIEL IS BENCHED (2026-08-13). Oliver: "daniel is very repetetive. please let him just
// announce the normal tennis game events for now.. this is a task for another time." So the
// umpire keeps every OFFICIATING call (score, game/set, fault, out, let, time, play) and loses
// ALL personality asides — this early return is the whole cut, since every ump_* remark routes
// through here. The situation triggers and the cooldown model below survive intact for the
// personality pass that will re-open him; do not delete them.
return;
// THE MATCH IS THE EXCEPTION to the interlude bar, exactly as sendBoy is: the match runs on a
// PERMANENT interlude, so this line silently muted the umpire for an entire mode — the one mode
// that is literally a tennis match he is sitting over. The shared asideEvery cooldown still holds,
@@ -1294,7 +1300,10 @@ function mopRound(b, dt) {
if (b.tx === undefined && b.jobs > 0) {
let best = null, bs = -1;
for (const d of gore) {
if (d.a < 0.15 || d.z < COURT.netZ - 2) continue; // faded, or up the far end where he can't work
// faded, or up the far end where he can't work — EXCEPT in the match, where the far end is
// exactly where every opponent dies: the mop beat holds an empty court for him, so the
// gore-mode reach limit would leave him jobless two steps from the mess he was sent for
if (d.a < 0.15 || (!G.match && d.z < COURT.netZ - 2)) continue;
const score = d.a * d.r - Math.hypot(d.x - b.x, d.z - b.z) * 0.04; // big and close first
if (score > bs) { bs = score; best = d; }
}
@@ -1304,7 +1313,10 @@ function mopRound(b, dt) {
const working = b.tx !== undefined && b.jobs > 0;
const gx = working ? b.tx : b.dir * 14, gz = working ? b.tz : b.z; // no target left: walk off the wing
const dx = gx - b.x, dz = gz - b.z, d = Math.hypot(dx, dz);
const step = Math.min(TUNE.boyMopSpeed * dt, d);
// during the match's mop beat he HURRIES between stains (the beat is his stage and it is 5 s
// long; at walking pace he spent 4 of them getting into shot) — the push itself keeps its pace
const spd = G.match && G.match.mopT > 0 ? TUNE.boyHurry : TUNE.boyMopSpeed;
const step = Math.min(spd * dt, d);
if (d > 0.001) { b.x += dx / d * step; b.z += dz / d * step; b.dist = (b.dist || 0) + step; }
if (Math.abs(dx) > 0.05) b.dir = dx > 0 ? 1 : -1; // face the way he's walking
if (working && d <= 0.12) { b.mopT = TUNE.boyMopDwell; b.tx = undefined; b.tz = undefined; } // arrived: get to work
@@ -1671,10 +1683,13 @@ export function update(dt) {
}
if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0 && !G.aim) { // uncaught toss → retoss (a held slingshot aim keeps the toss up)
ball.active = false; G.phase = 'pretoss';
// an uncaught toss is NOT a swing. The 'serve' pose used to survive into pretoss, and
// drawChar's pick (phase !== 'toss' → smash) flashed the SMASH frame at a ball the player
// never hit — Oliver: "the smash frame is always played even if I did not tap a second
// time". Back to idle, where the bounce stance picks him up.
const sv = score.server === 'P' ? P : O;
if (sv.pose === 'serve') { sv.pose = 'idle'; sv.poseT = 0; }
if (score.server === 'O') G.aiServeT = 0.8;
// dropping the toss twice running means the second tap isn't landing — the ghost hand then
// rides the next toss and presses at the apex (render.drawApexHint)
else if (G.mode === 'tennis') G.tossDrops++;
}
}