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

+1 -1
View File
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=211"></script>
<script type="module" src="src/main.js?v=212"></script>
</body>
</html>
+36 -9
View File
@@ -93,7 +93,7 @@ export function startBossFight(chapter) {
ball.active = false; setupPoint();
spawnChapterBoss(chapter);
zombieDuck(true); // the music drops out for it
voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85);
aside('ump_boss_arrives'); voice('sudden_death', 2.6, 0.85); // the stake is an event; the quip is benched (see aside())
const art = bossArt(chapter);
// THE WALK-ON IS AN OVERLAY, not a band card. A card announced the biggest fight in the game in
// the same slot and size as a routine set, and it read as the boss simply being there. This is on
@@ -337,8 +337,11 @@ export function startMatch() {
voice('play_resume', 0.2);
if (!G.seen.match) {
G.seen.match = true;
showModal({ title: 'EXHIBITION MATCH', color: UI.hot,
body: ['WIN THE GAME — EVERY POINT WOUNDS IT', 'ITS POINTS DRAW YOUR BLOOD'] });
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
// number, so the modal can never drift from the pool it describes.
showModal({ title: 'DEATH MATCH', color: UI.hot,
body: ['WIN THE POINT — WOUND THE ZOMBIE',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
}
return true;
}
@@ -409,6 +412,17 @@ function startFall(from) {
// OPPONENT'S SERVE POSES, because both are the same job — naming the frame while the body is doing
// something the walk cycle has no picture for.
export function tickMatchFall(dt) {
// THE MOP BEAT runs here, on the same clock discipline as the fall: its own timer, never the
// 'over' beat (see the section note above). When the boy's time is up the next opponent walks
// on with its own intro card, exactly as it used to on the kill frame.
const d = G.match;
if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) {
d.mopT = 0;
matchOpponent();
d.ending = false; // play re-opens only now that a body is on court
matchReady();
G.phase = 'over'; G.overT = 2.2; // matchReady's setupPoint re-arms pretoss; keep the old beat
}
if (!G.matchFall) return tickMatchServe(dt);
const f = G.matchFall;
const zb = zombies.find(z => z.variant === 'r');
@@ -492,7 +506,8 @@ function matchOpponent() {
// so killZombie can file the kill under it without asking G which opponent we were on.
zb.foe = matchFoe(G.match.opp);
zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS)
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 2.4 };
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art)
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 2.4, head: zb.fam };
zb.hp = zb.hpMax = TUNE.matchOppHp; // a FLAT pool: with regicide gone the body is pure
// scoreboard (a quarter per point), so a deeper pool would
// change nothing — the ladder escalates in racketAI instead
@@ -569,15 +584,27 @@ function matchGameWon(zb, banked = false) {
// the FAR side: that is where the body has been standing and bleeding, and it keeps him out of the
// near court where the player is about to serve. Between games only — see sendBoy's match note.
sendBoy(0, 4.5, true);
// THE MOP BEAT (Oliver: "mopboy must mop for 5 seconds and really remove some gore visibly
// before the new opponent arrives"). The fresh opponent used to spawn on the same frame as the
// kill, so the boy always mopped behind the next game's back. Now the court belongs to him
// first: the full job allowance (a between-games sendBoy books one stain), and matchOpponent
// waits on d.mopT (ticked in tickMatchFall). A court with nothing worth mopping skips the wait —
// an empty beat is a stall, not a show.
if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; }
else d.mopT = 0.6;
// Serving stays blocked for the whole beat: the match's permanent interlude carries it, and
// keyPressed's introT bar sits ABOVE its over-beat re-arm, so a tap cannot serve at an empty
// court. matchOpponent replaces this object and re-arms the normal 1.6 s intro when it runs.
G.interlude = { introT: d.mopT + 1.6 };
d.opp++;
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
score.server = other(score.server);
matchOpponent(); // fresh body, its own intro beat
d.ending = false;
matchReady();
G.phase = 'over'; G.overT = 2.2;
// d.ending STAYS TRUE for the whole beat — the empty-court check in resolveInterlude reads
// "no body + not ending" as regicide, so a stray resolve during the beat would bank a phantom
// game. The walk-on tick below is what re-opens play, exactly where the old code did.
G.phase = 'over'; G.overT = d.mopT + 2.2; // ticks down through the beat; reset at the walk-on
}
function matchGameLost() {
@@ -610,7 +637,7 @@ export function resolveInterlude(side) {
// of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
if (G.hp < G.hpMax) {
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
voice('ump_trainer', 0.2, 0.95);
aside('ump_trainer', 0.2); // benched with the rest of Daniel's personality — see aside()
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
}
+29 -3
View File
@@ -222,7 +222,11 @@ export const TUNE = {
changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand)
boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5)
boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one
boyHurry: 4.6, // m/s he covers ground during the MATCH mop beat — brisk, so the 5 s window
// is spent scrubbing rather than walking into shot
boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever
matchMopT: 5, // s the mop beat holds the court between match games — the boy visibly cleans
// before the next opponent walks on (skipped when there is nothing to mop)
boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so
// the feet cannot slide: change a boy's speed and his legs keep up on their own.
gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games
@@ -613,7 +617,7 @@ export const MODES = [
export const TOURS = [
{ id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78 },
{ id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78, tossDx: -0.59 },
];
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
@@ -806,6 +810,23 @@ export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
// the top of _master.py for the fix its next attempt needs.
export const ZFAMS = ['r', 'br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj'];
// HEAD CROPS for the opponent intro card — a straight source-pixel crop out of each family's own
// healthy frame (z_<fam>1.png), so the card's icon is the body on court, never separate art.
// MEASURED offline (silhouette-width profile: the head band ends where the shoulders widen; br and
// gk hand-extended past the rule — the brute's shoulders start early, the keeper's hat brim wider
// than his face). Re-run the measurement when a family sheet is regenerated. A family missing here
// simply gets a text-only card, so a new roster row never breaks the walk-on.
export const HEAD_CROP = {
r: { x: 3, y: 0, w: 71, h: 73 },
br: { x: 0, y: 0, w: 87, h: 61 },
sh: { x: 8, y: 0, w: 62, h: 65 },
bb: { x: 7, y: 0, w: 62, h: 65 },
st: { x: 14, y: 0, w: 57, h: 55 },
ru: { x: 14, y: 0, w: 57, h: 55 },
gk: { x: 6, y: 0, w: 73, h: 65 },
lj: { x: 6, y: 0, w: 74, h: 56 },
};
// THE RACKET FAMILY'S THREE STAGES, as a FRACTION of the pool — never against literal hp, the rule
// this whole file already holds (see woundStage). It lived inline in drawZombie; the FALL BEAT needs
// the same answer to know which body collapses and which one gets back up, and two copies of a
@@ -1005,8 +1026,13 @@ export const HOME = { P: { x: 0, z: 22.8 }, O: { x: 0, z: 2.5 } };
// where the near player is allowed to move
export const P_BOUNDS = { x: 6.5, zMin: COURT.netZ + 1.2, zMax: 26 };
// serve toss: release height and upward speed (apex derives from these + gravity)
export const TOSS = { y0: 1.8, vy: 4.6 };
// serve toss: release height and upward speed (apex derives from these + gravity).
// dx/oDx: the tossing HAND's x offset from the frame centre, in metres, at facing +1 — the ball
// must rise over the raised palm, not the head (Oliver's report). MEASURED off the toss frames
// (topmost opaque row = the fingertips, × PLAYER_H/SPRITE_H): p_toss −52 px → −0.44 m,
// o_toss +50 px → +0.42 m (he is drawn from the front, so his left hand is the viewer's right).
// The women's frame reaches further (p_toss_f −70 px → −0.59 m) — TOURS overrides tossDx.
export const TOSS = { y0: 1.8, vy: 4.6, dx: -0.44, oDx: 0.42 };
// shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short)
export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 };
+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++;
}
}
+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,
-1
View File
@@ -80,7 +80,6 @@ export const G = {
meleeT: 0, // s of racket-swish cooldown left
rackGlow: null, // {slot, t} — a freshly gathered barrel weapon pulses its rack tile briefly
// instead of equipping itself (popBarrel keeps your current selection)
tossDrops: 0, // consecutive DROPPED tosses (tennis) — ≥2 shows the ghost-hand apex hint
passStreak: 0, // consecutive rally balls that passed untouched (tennis) — ≥2 shows TAP TO HIT
// ---- the sidestep. The movement block is 'rally'-only on purpose, so this integrates OUTSIDE it
// and is the only way to move in the serve stance (which is where a boss fight is spent).
+2 -2
View File
@@ -113,7 +113,7 @@ export function spawnBarrel() {
// shoots at you until you deal with it.
export function spawnMachine() {
spawnZombie((Math.random() * 2 - 1) * 3.6, 2.2 + Math.random() * 3, 'm');
voice('ump_machine', 0.4, 0.95);
aside('ump_machine', 0.4); // benched with the rest of Daniel's personality — see aside()
if (!G.seen.mach) {
G.seen.mach = true;
G.card = { title: 'SWING TO RETURN — OR SWISH IT AWAY', color: UI.warm, t: 2.6 };
@@ -273,7 +273,7 @@ export function hurtPlayer(n) {
vx: (Math.random() * 2 - 1) * 2.2, vy: 0.8 + Math.random() * 2, vz: (Math.random() * 2 - 1) * 2.2,
t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.5 });
if (G.hp <= 0) killPlayer();
else voice('ump_quiet_bleeding', 0.3, 0.95); // the umpire objects on your behalf. He is still watching.
else aside('ump_quiet_bleeding', 0.3); // benched with the rest of Daniel's personality — see aside()
}
// Death, from EITHER channel: a shambler reaching you, or your hp running out. Extracted so the two
+17 -2
View File
@@ -505,6 +505,9 @@ test('endDrill hands the court back', () => {
const startMatch = () => { const r = _startMatch(); G.modal = null; G.modalQ.length = 0; return r; };
const oppOf = () => zombies.find(z => z.variant === 'r');
// a won game now opens the MOP BEAT (TUNE.matchMopT) before the next opponent walks on —
// tests that play across a game boundary step through it here
const passMopBeat = () => { for (let i = 0; i < (TUNE.matchMopT + 1) * 60; i++) update(DT); };
test('a match owns the court like every other mode', () => {
assert.ok(startMatch(), 'a match starts on a clean court');
@@ -532,8 +535,14 @@ test('a point won tears a wound stage off the body; only the game point fells it
assert.equal(zb.hp, 1, 'three points in: one stage left');
resolveInterlude('P'); // the game point
assert.ok(!zombies.includes(zb), 'the game-winning point IS the kill');
// THE MOP BEAT: the corpse's gore holds the court first — no fresh body until the boy has had
// his time (TUNE.matchMopT, skipped only on a clean court; the explode() above guarantees gore).
assert.ok(!oppOf(), 'no fresh opponent on the kill frame — the mop beat owns the court');
assert.ok(G.match.mopT > 0, 'the beat is armed');
assert.ok(G.interlude && G.interlude.introT > 0, 'and serving is barred for its whole length');
for (let i = 0; i < (TUNE.matchMopT + 1) * 10; i++) update(0.1);
const next = oppOf();
assert.ok(next && next !== zb, 'a fresh opponent shambled on');
assert.ok(next && next !== zb, 'a fresh opponent shambled on after the mop beat');
assert.equal(next.hpMax, TUNE.matchOppHp, 'same flat pool — the ladder escalates in speed/flub, not hp');
assert.equal(G.runGames, 1, 'the ladder banked the game');
assert.deepEqual(score.pts, [0, 0], 'and the points reset');
@@ -753,6 +762,7 @@ test('service alternates by game, and the opponent actually serves', () => {
for (let i = 0; i < 2 * 60 && G.matchFall; i++) update(DT); // let each collapse finish
}
assert.equal(score.server, 'O', 'their serve next game');
for (let i = 0; i < (TUNE.matchMopT + 1) * 60; i++) update(DT); // the mop beat holds the court first
// the opponent stands behind ITS baseline to serve, not on the receiving mark
const rz = zombies.find(z => z.variant === 'r');
assert.ok(rz.z < 1.0, 'it steps back to serve: z=' + rz.z.toFixed(2));
@@ -879,13 +889,17 @@ test('regicide: killing the body mid-game wins the game outright', () => {
resolveInterlude('P'); // killZombie's exit lands here with the court empty
assert.equal(score.games[0], games + 1, 'the game banks');
assert.deepEqual(score.pts, [0, 0], 'the part-played points reset');
passMopBeat();
assert.ok(oppOf(), 'and the next opponent is on');
});
test('the match measures OPPONENTS FELLED, banked as its own kind of run', () => {
localStorage.removeItem('splatterRuns');
startMatch();
for (let k = 0; k < 8; k++) resolveInterlude('P'); // two games = two bodies down
for (let g = 0; g < 2; g++) { // two games = two bodies down
for (let k = 0; k < 4; k++) resolveInterlude('P');
passMopBeat(); // each corpse gets its mop beat
}
assert.equal(G.match.opp, 3, 'on the third rung');
G.hp = 1; resolveInterlude('O');
assert.equal(G.mode, 'eaten', 'bled out');
@@ -894,6 +908,7 @@ test('the match measures OPPONENTS FELLED, banked as its own kind of run', () =>
// a shorter second run must NOT overwrite the mark, and must know what it is chasing
G.mode = 'splatter'; endMatch(); startMatch();
for (let k = 0; k < 4; k++) resolveInterlude('P');
passMopBeat(); // no points settle while the boy owns the court
G.hp = 1; resolveInterlude('O');
assert.equal(G.match.felled, 1);
assert.equal(G.match.best, 2, 'the mark to beat is shown');