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:
1 parent
0e01894f4d
commit
c084d53497
8 files changed
+141
-70
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user