The staff learn your habits, the walk-on waits for your tap, and the club opens a practice court
Four answers to one phone report: - THE READ: repeat a lane and the opponent anticipates it; break the habit and it is wrong-footed for that ball — scaled by rung, so surprise is the weapon that opens the ladder past the HEAD COACH (TUNE block THE READ). - The opponent walk-on is a real modal now (head, punchline, tap to dismiss) in arcade, tournament and training — readable without a clock. - THE TRAINING COURT: third card under ZOMBIE TENNIS, pick any rung, one game at a time, the felled body walks straight back on, nothing banks, bleeding out is a popup instead of a death. mode_train.png rolled in the banner style. - MOVEMENT READS: sprinting feet kick per-court dust and fast bodies leave a two-ghost speed streak (trailMin gates both; feel verdict pending). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a42dc6fc81
commit
43784c5b33
9 files changed
+360
-43
No files matched your search
+51
-22
@@ -356,9 +356,13 @@ export function watchdog(dt) {
|
||||
// `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is
|
||||
// optional and null means "roll each round as it comes", which is what the mode did before the
|
||||
// screen existed and is still what the #cup back door gets.
|
||||
export function startMatch(cup = false, draw = null) {
|
||||
// `train` (2026-08-28) is a RUNG NUMBER and turns the run into the TRAINING COURT: that one
|
||||
// opponent, one game at a time, nothing banked. The ladder is parked — d.opp never advances, a
|
||||
// felled body walks straight back on, and bleeding out costs a modal instead of the run. All of
|
||||
// it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint.
|
||||
export function startMatch(cup = false, draw = null, train = 0) {
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
|
||||
G.match = { opp: 1, ending: false };
|
||||
G.match = { opp: train || 1, ending: false, train: !!train };
|
||||
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
|
||||
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
|
||||
// scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies
|
||||
@@ -372,16 +376,19 @@ export function startMatch(cup = false, draw = null) {
|
||||
zombies.length = 0;
|
||||
G.boy = null;
|
||||
score.pts = [0, 0];
|
||||
matchOpponent();
|
||||
matchReady();
|
||||
zombieDuck(true);
|
||||
voice('play_resume', 0.2);
|
||||
if (cup && !G.seen.cup) {
|
||||
// The mode's rules modal fires BEFORE matchOpponent, so it lands FIRST and the opponent's own
|
||||
// walk-on modal queues behind it (showModal's modalQ keeps call order) — rules, then the body.
|
||||
if (train && !G.seen.train) {
|
||||
G.seen.train = true;
|
||||
showModal({ title: 'THE TRAINING COURT', color: UI.hot,
|
||||
body: ['ONE BODY, ONE GAME AT A TIME',
|
||||
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NOTHING IS BANKED HERE'] });
|
||||
} else if (cup && !G.seen.cup) {
|
||||
G.seen.cup = true;
|
||||
showModal({ title: 'THE TOURNAMENT', color: UI.hot,
|
||||
body: ['FOUR ROUNDS UP THE DEAD HIERARCHY',
|
||||
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
|
||||
} else if (!cup && !G.seen.match) {
|
||||
} else if (!cup && !train && !G.seen.match) {
|
||||
G.seen.match = true;
|
||||
// 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.
|
||||
@@ -389,6 +396,10 @@ export function startMatch(cup = false, draw = null) {
|
||||
body: ['WIN THE POINT — WOUND THE ZOMBIE',
|
||||
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
|
||||
}
|
||||
matchOpponent();
|
||||
matchReady();
|
||||
zombieDuck(true);
|
||||
voice('play_resume', 0.2);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -418,6 +429,8 @@ export function matchReady() { // exported for net.js: a paired death match re-p
|
||||
ball.active = false;
|
||||
G.serveNum = 1;
|
||||
if (G.match) G.match.decided = false; // the next rally may be decided early again
|
||||
// ...and THE READ's one-ball states die with the point: a lean only lasts the flight it answered
|
||||
if (G.match) G.match.surprised = G.match.expected = false;
|
||||
setupPoint();
|
||||
const rz = zombies.find(z => z.variant === 'r');
|
||||
// THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline
|
||||
@@ -627,13 +640,18 @@ 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)
|
||||
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art).
|
||||
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card
|
||||
// of the player should last longer"). In the cup the round names itself on the third line.
|
||||
// THE WALK-ON IS A MODAL NOW, not a timed card (2026-08-28, Oliver: "als richtiges Modal, das
|
||||
// man wegtappen muss — you can actually read it without time pressure, and tap it away fast when
|
||||
// you have read it a hundred times"). The walk-on is a guaranteed-calm court (the mop beat has
|
||||
// just ended, the intro bar is armed), so the popup policy allows it; update()'s freeze holds the
|
||||
// 1.6 s intro beat until the tap, and the head wears the same z_<fam>head crop the card wore.
|
||||
// In the cup the round names itself on the second line.
|
||||
const cup = G.match.cup;
|
||||
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam,
|
||||
sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
|
||||
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined };
|
||||
showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64,
|
||||
body: [zb.foe.line].concat(cup ? [CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
|
||||
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '')] : []) });
|
||||
// ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config)
|
||||
G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false;
|
||||
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
|
||||
// game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in
|
||||
// racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and
|
||||
@@ -680,10 +698,18 @@ function matchPoint(side) {
|
||||
// clip over a second long, so every one of these was being swallowed silently.
|
||||
aside(G.hp <= 2 ? 'ump_low_blood' : 'ump_wound_you2', 2.2);
|
||||
if (G.hp <= 0) {
|
||||
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud —
|
||||
// the other phone turns it into its win screen (netMatchWon)
|
||||
if (G.net && G.net.ready) netSendDead();
|
||||
return killPlayer(); // eaten — the run summary, as ever
|
||||
// THE TRAINING COURT HAS NO DEATH IN IT. Bleeding out costs a modal and a fresh pool — the
|
||||
// point still goes to the body (advancePoint below), so a game you were losing stays lost.
|
||||
if (d.train) {
|
||||
G.hp = G.hpMax;
|
||||
showModal({ title: 'IT BLED YOU OUT', color: UI.bad,
|
||||
body: ['THE TRAINER MOPS YOU UP — GO AGAIN', 'NOTHING IS BANKED HERE'] });
|
||||
} else {
|
||||
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud —
|
||||
// the other phone turns it into its win screen (netMatchWon)
|
||||
if (G.net && G.net.ready) netSendDead();
|
||||
return killPlayer(); // eaten — the run summary, as ever
|
||||
}
|
||||
}
|
||||
}
|
||||
const over = advancePoint(side); // banks the game and resets the points itself
|
||||
@@ -737,7 +763,7 @@ function matchGameWon(zb, banked = false) {
|
||||
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
|
||||
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
|
||||
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
|
||||
G.runGames++; // the ladder metric, same as every mode
|
||||
if (!d.train) G.runGames++; // the ladder metric — training banks nothing
|
||||
voice('game_player', 0.3); cheer(2.4, 0.6);
|
||||
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
|
||||
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
|
||||
@@ -745,8 +771,9 @@ function matchGameWon(zb, banked = false) {
|
||||
G.flash = { text: 'GAME.', t: 1.4 };
|
||||
// netplay: the SAME opponent walks back on — the body across the net is one particular friend,
|
||||
// and handing them the roster's next name and face would break the fiction (the ladder dials the
|
||||
// opp number drives are AI-only and dormant here anyway)
|
||||
if (!G.net) d.opp++;
|
||||
// opp number drives are AI-only and dormant here anyway). Training parks the ladder the same
|
||||
// way: the rung you picked is the rung that walks back on, as many times as you want it.
|
||||
if (!G.net && !d.train) 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).
|
||||
@@ -772,7 +799,9 @@ function matchGameWon(zb, banked = false) {
|
||||
}
|
||||
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
|
||||
d.afterFall = () => {
|
||||
matchHealBeat();
|
||||
// training resets the blood WITHOUT the WOUNDS TREATED ceremony — every game starts whole,
|
||||
// and a popup between every single rep is exactly the tap-tax the mode exists to avoid
|
||||
if (d.train) G.hp = G.hpMax; else matchHealBeat();
|
||||
// ...and the boy comes out to deal with what four wound stages left on the far court. Deliberately
|
||||
// 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.
|
||||
|
||||
+26
-1
@@ -599,6 +599,28 @@ export const TUNE = {
|
||||
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark
|
||||
// lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre)
|
||||
matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible
|
||||
// ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely
|
||||
// make mistakes any more — we need a twist"). The dead staff LEARN YOUR PATTERNS: land the same
|
||||
// lane readHabit times in a row and the opponent has the habit — an EXPECTED ball is anticipated
|
||||
// (its bounce-read climbs toward perfect, so spam feeds the machine), while BREAKING the habit
|
||||
// wrong-foots it for that one ball: the read collapses, the legs slow, the whiff climbs. Both
|
||||
// effects scale with the RUNG ((opp-1)/(LADDER-1)), so the shambler never learns anything and
|
||||
// the champion — "HE RETURNS EVERYTHING" — is exactly the one you beat by setting a habit and
|
||||
// breaking it on the big point. Skill, not grind, is what opens the ladder past the coach.
|
||||
// Rally lanes only (doHit's aimed player shots); serves keep their own game. Solo match only.
|
||||
readHabit: 3, // same-lane returns in a row before the opponent has learned the pattern
|
||||
readAnticipate: 0.97,// what aiRead climbs toward on an EXPECTED ball at the top rung
|
||||
surpRead: 0.85, // fraction of aiRead a wrong-footed opponent loses (x rung scale)
|
||||
surpSlow: 0.45, // fraction of chase speed it loses on the surprise ball (x rung scale)
|
||||
surpFlub: 0.35, // whiff ADDED at contact on the surprise ball at the top rung
|
||||
// ---- MOVEMENT READS (2026-08-28): the sprites run, the court answers. Two cheap effects, both
|
||||
// live-tunable: feet kick the surface's own dust once a body crosses trailMin, and a body moving
|
||||
// that fast leaves a two-ghost speed streak behind it (the modal masthead's own trick, sampled
|
||||
// back along the velocity). Procedural register only — dust and afterimage, never painted into
|
||||
// a sprite, and nothing draws at walking pace: trailMin sits above every shamble.
|
||||
trailMin: 3.6, // m/s of ground speed before a body earns dust + ghosts (0 = both off)
|
||||
trailK: 0.05, // s each ghost trails behind along the velocity (two samples: 1x and 2x)
|
||||
runDustEvery: 0.5, // m of ground covered per dust puff while sprinting
|
||||
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
|
||||
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
|
||||
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
|
||||
@@ -765,6 +787,9 @@ export const MODES = [
|
||||
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
|
||||
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
|
||||
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
|
||||
// TRAINING (2026-08-28): pick any rung, play it one game at a time, nothing is banked. The same
|
||||
// match machinery with the ladder parked — see startMatch's train parameter.
|
||||
{ id: 'train', name: 'TRAINING', sub: 'PICK A BODY · ONE GAME · GO AGAIN', art: 'mode_train' },
|
||||
// ...and gore no longer opens on a game of tennis (see gameplay.startGore), so its blurb stopped
|
||||
// promising one: the match already turned, you just arrived late
|
||||
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
|
||||
@@ -776,7 +801,7 @@ export const MODES = [
|
||||
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
|
||||
export const MENU = [
|
||||
{ mode: 'classic', diffs: true },
|
||||
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] },
|
||||
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup', 'train'] },
|
||||
{ mode: 'gore' },
|
||||
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
|
||||
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
|
||||
|
||||
+65
-8
@@ -1,6 +1,6 @@
|
||||
// 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, racketStage, matchWound, CUP_ROUNDS } from './config.js';
|
||||
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, racketStage, matchWound, CUP_ROUNDS, LADDER } from './config.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, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
||||
import { haptic } from './haptics.js';
|
||||
@@ -232,6 +232,21 @@ function doHit(ch, quality, aim) {
|
||||
aimedLane = true;
|
||||
}
|
||||
tx = Math.max(-5, Math.min(5, tx));
|
||||
// THE READ (see config's dial block): the opponent studies where your rally shots GO. Three
|
||||
// lanes, bucketed off the aimed target — run readHabit of the same lane together and the habit
|
||||
// is learned: the NEXT same-lane ball is `expected` (anticipated in racketAI), a lane break is
|
||||
// `surprised` (wrong-footed, for this one flight). Solo match rally shots only: netplay's
|
||||
// opponent is a human who does his own reading, and serves keep their own game.
|
||||
if (G.match && !G.net && ch.side === 'P' && aimedLane) {
|
||||
const d = G.match, lane = tx < -1.1 ? -1 : tx > 1.1 ? 1 : 0;
|
||||
d.surprised = (d.laneRun || 0) >= TUNE.readHabit && d.lastLane !== undefined && lane !== d.lastLane;
|
||||
d.expected = (d.laneRun || 0) >= TUNE.readHabit && lane === d.lastLane;
|
||||
d.laneRun = lane === d.lastLane ? (d.laneRun || 0) + 1 : 1;
|
||||
d.lastLane = lane;
|
||||
// the mechanic announces itself the moment it fires — on the rungs young enough to barely
|
||||
// lean, the flash is the whole lesson for later
|
||||
if (d.surprised && (d.opp - 1) / (LADDER - 1) > 0.2) G.flash = { text: 'WRONG-FOOTED.', t: 1.0 };
|
||||
}
|
||||
if (G.match && ch.side !== 'P') {
|
||||
// ...AND ITS PACE IS THE FOURTH. Width alone did not fix "unreachable" because the returns left
|
||||
// at ~27 m/s (baseShotSpeed + 0.55 quality) — crossing to your baseline in ~0.6 s, which is less
|
||||
@@ -1119,9 +1134,17 @@ function matchFallWillPlay() {
|
||||
// could not start in time (Oliver: the first ten opponents can be killed the moment you serve).
|
||||
// It lasts exactly as long as the serve does — G.wasServe is cleared at the first bounce and at
|
||||
// any return — so a rally is chased at the ordinary ladder speed and nothing else moves.
|
||||
// How much this body has LEARNED — THE READ's rung scale, 0 at the shambler, 1 at the champion.
|
||||
// One definition, because the read boost, the wrong-foot slow and the surprise whiff must all
|
||||
// answer "how smart is this one" identically or the twist pulls in three directions at once.
|
||||
const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0;
|
||||
const rzChaseSpeed = rz => TUNE.racketSpeed
|
||||
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1)
|
||||
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1);
|
||||
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
|
||||
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
|
||||
// so the intercept planner and the reach tests believe it too (a surprised champion plans like
|
||||
// a body that genuinely cannot get there, which is the whole point)
|
||||
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1);
|
||||
|
||||
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from
|
||||
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
|
||||
@@ -1413,8 +1436,17 @@ function racketAI(rz, dt) {
|
||||
// being wrong-footed is most of what makes the rally a rally. Serves only in spirit but applied
|
||||
// to every flight — the same lag was there in a rally, just hidden by the shorter distances.
|
||||
const pred = ball.y > 0.35 && ball.vy < 3 ? predictLanding() : null; // not while it is still climbing off the racket
|
||||
const aimX = pred ? ball.x + (pred.x - ball.x) * TUNE.aiRead : ball.x;
|
||||
const aimZ = pred ? ball.z + (pred.z - ball.z) * TUNE.aiRead : ball.z;
|
||||
// THE READ rides on aiRead: an EXPECTED ball (you repeated your habit) is read almost perfectly
|
||||
// — spam feeds the machine — and a SURPRISE ball collapses the read back toward the old pure
|
||||
// pursuit, which is exactly the wrong-footing aiRead exists to permit. Both scale with the rung
|
||||
// (learnK), so the shambler never learns and the champion is the one you beat by breaking a
|
||||
// habit you set on purpose. Dials in config's THE READ block; the streak lives in doHit.
|
||||
const learn = learnK(rz);
|
||||
const read = G.match && G.match.surprised ? TUNE.aiRead * (1 - TUNE.surpRead * learn)
|
||||
: G.match && G.match.expected ? TUNE.aiRead + (TUNE.readAnticipate - TUNE.aiRead) * learn
|
||||
: TUNE.aiRead;
|
||||
const aimX = pred ? ball.x + (pred.x - ball.x) * read : ball.x;
|
||||
const aimZ = pred ? ball.z + (pred.z - ball.z) * read : ball.z;
|
||||
const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court
|
||||
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
|
||||
if (d > 0.15) {
|
||||
@@ -1434,7 +1466,15 @@ function racketAI(rz, dt) {
|
||||
// vx is kept so a body that is knocked down mid-chase can carry the run into its collapse.
|
||||
earnFlip(rz, 'face', Math.abs(dx) > 0.2 ? (dx > 0 ? 1 : -1) : 0, dt);
|
||||
rz.vx = dx / d * step / dt;
|
||||
} else { rz.vx = 0; if (rz.pose === 'run') rz.pose = 'idle'; }
|
||||
// vz beside vx (drawZombie's speed ghosts read both), and MOVEMENT READS: a genuine sprint
|
||||
// kicks the surface up behind it — one puff per runDustEvery metres, the same per-court dust
|
||||
// the ball's bounce already throws, so the effect can never wear the wrong colour
|
||||
rz.vz = dz / d * step / dt;
|
||||
if (TUNE.trailMin && step / dt > TUNE.trailMin
|
||||
&& (rz.dustAcc = (rz.dustAcc || 0) + step) > TUNE.runDustEvery) {
|
||||
rz.dustAcc = 0; dustPuff(rz.x, rz.z, 2, 0.6);
|
||||
}
|
||||
} else { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; }
|
||||
// THE WOUNDS PLAY. Score-is-the-body was a picture until now: it lost an arm and swung exactly
|
||||
// as far, lost its head and missed exactly as often. Both dials read the same racketStage the
|
||||
// SKINS do, so what you see it lose is what it loses. Match only — see config.matchWound.
|
||||
@@ -1444,10 +1484,16 @@ function racketAI(rz, dt) {
|
||||
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
|
||||
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
|
||||
// which across a game is an opponent that beats itself.
|
||||
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
|
||||
: TUNE.racketFlub;
|
||||
const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
|
||||
: TUNE.racketFlub)
|
||||
// THE READ: a wrong-footed racket reaches the ball late even when the legs got there —
|
||||
// the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this
|
||||
// body had actually learned. The champion's near-zero flub is exactly what this opens.
|
||||
+ (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0);
|
||||
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
|
||||
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
|
||||
// the lean is SPENT at the racket, either way — matchReady clears it when a point ends unmet
|
||||
if (G.match) G.match.surprised = G.match.expected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1792,6 +1838,7 @@ export function update(dt) {
|
||||
// steer on the phase froze the player mid-burst the moment he was winning. The settled "no
|
||||
// walking between points" rule is untouched — nothing arms a burst except the player.
|
||||
if (G.phase === 'rally' || G.mgT > 0) {
|
||||
const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps
|
||||
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
|
||||
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
|
||||
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
|
||||
@@ -1860,11 +1907,21 @@ export function update(dt) {
|
||||
if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
|
||||
if (mx) { P.facing = mx > 0 ? 1 : -1; P.flipT = 0; } // manual: instant, and it resets the earn clock
|
||||
else earnFlip(P, 'facing', Math.abs(assist) > 0.25 ? (assist > 0 ? 1 : -1) : 0, dt);
|
||||
// MOVEMENT READS: the player's own travel this frame, measured AFTER the clamps so a body
|
||||
// pinned against the bound reads as standing (drawChar's speed ghosts read vx/vz). A real
|
||||
// sprint — the auto-run chase, the horde steer — kicks the same per-court dust the bodies do.
|
||||
P.vx = (P.x - px0) / dt; P.vz = (P.z - pz0) / dt;
|
||||
const pSpd = Math.hypot(P.vx, P.vz);
|
||||
if (TUNE.trailMin && pSpd > TUNE.trailMin
|
||||
&& (P.dustAcc = (P.dustAcc || 0) + pSpd * dt) > TUNE.runDustEvery) {
|
||||
P.dustAcc = 0; dustPuff(P.x, P.z, 2, 0.6);
|
||||
}
|
||||
}
|
||||
// ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only
|
||||
// handles swing/serve), and P.runT stops advancing outside the block, so a beat that began
|
||||
// mid-stride left him welded to a single run frame — the "stuck mid-animation" report.
|
||||
else if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; }
|
||||
// vx/vz die with the mover too, or the speed ghosts would trail a body that stopped dead.
|
||||
else { P.vx = 0; P.vz = 0; if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } }
|
||||
// The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent
|
||||
// in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him
|
||||
// off the edge of the screen either.
|
||||
|
||||
+23
-3
@@ -3,7 +3,7 @@
|
||||
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
||||
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
||||
|
||||
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
|
||||
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe, LADDER } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
|
||||
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
|
||||
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
|
||||
@@ -383,10 +383,24 @@ document.getElementById('brBack').addEventListener('click', () => {
|
||||
});
|
||||
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
|
||||
|
||||
// ---- THE TRAINING PICKER (Oliver, 2026-08-28: "ein Trainingsbereich — Auswahl des Gegners, ein
|
||||
// Spiel, tap to replay, bis man keine Lust mehr hat"). The whole ladder face-up, one tile per rung,
|
||||
// wearing the same z_*head crop the draw and the walk-on wear. Tap = start — the wizard's own
|
||||
// tap-advances rule; the SERVE UP button routed here instead of starting (see cStart below).
|
||||
let trainRung = 1;
|
||||
const trainList = document.getElementById('trainList');
|
||||
for (let n = 1; n <= LADDER; n++) {
|
||||
const f = matchFoe(n), b = document.createElement('button');
|
||||
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>RUNG ${n}</i>`;
|
||||
b.addEventListener('click', () => { trainRung = n; start('train'); });
|
||||
trainList.appendChild(b);
|
||||
}
|
||||
document.getElementById('trBack').addEventListener('click', () => go('setup'));
|
||||
|
||||
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
|
||||
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
|
||||
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
|
||||
const boardFor = m => m === 'zombie' || m === 'cup' ? stats.MODE_MATCH
|
||||
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
|
||||
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||||
|
||||
// what the setup footer says you are about to play, and what it is worth beating
|
||||
@@ -404,7 +418,9 @@ function summary() {
|
||||
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
|
||||
document.getElementById('sSum').innerHTML =
|
||||
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
|
||||
+ `<span>${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
|
||||
// training banks nothing, so there is no mark to beat and the footer must not invent one
|
||||
+ `<span>${m.id === 'train' ? 'NOTHING AT STAKE — JUST REPS'
|
||||
: best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
|
||||
}
|
||||
|
||||
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
|
||||
@@ -440,6 +456,8 @@ function start(modeId = pickedMode) {
|
||||
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
|
||||
// means "roll it yourself", which is what the mode did before the screen existed.
|
||||
if (modeId === 'cup') { intoSplatter(); startMatch(true, cupPicks); }
|
||||
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
|
||||
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
|
||||
}
|
||||
let stPeriod = 'all', stMode = stats.MODE_GORE;
|
||||
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
|
||||
@@ -523,6 +541,7 @@ nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getEle
|
||||
// carries its own start button, so this stays the one place a run begins from the setup screen.
|
||||
document.getElementById('cStart').addEventListener('click', () => {
|
||||
if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; }
|
||||
if (pickedMode === 'train') { go('train'); return; } // the picker starts it — tap advances
|
||||
start();
|
||||
});
|
||||
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack'])
|
||||
@@ -713,6 +732,7 @@ addEventListener('keydown', e => {
|
||||
// no-ops on an empty run, so closing the tab at the menu writes nothing.
|
||||
addEventListener('pagehide', () => {
|
||||
if (G.mode !== 'splatter') return;
|
||||
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
|
||||
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
|
||||
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
|
||||
if (G.match) stats.endRun(G.match.opp - 1, score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
|
||||
|
||||
+26
-3
@@ -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, HEAD_CROP, CUP_ROUNDS, TINT, SHOT_DEPTH } 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, CUP_ROUNDS, TINT, SHOT_DEPTH, matchFoe } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
|
||||
import { RAISERS, FRAMES } from './crowd.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
|
||||
@@ -805,9 +805,21 @@ function drawMachine(zb) {
|
||||
}
|
||||
|
||||
const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres
|
||||
function drawZombie(zb) {
|
||||
function drawZombie(zb, ghost) {
|
||||
if (zb.variant === 'o') return drawBarrel(zb);
|
||||
if (zb.variant === 'm') return drawMachine(zb);
|
||||
// MOVEMENT READS: a body genuinely sprinting (only racketAI writes vx/vz — the shamble never
|
||||
// crosses trailMin) leaves two ghosts sampled back along its own velocity, the modal masthead's
|
||||
// speed streak applied to a body. Ghosts draw first so the live sprite lands on top, and never
|
||||
// during a named frame — a collapsing corpse must not smear on stale velocity.
|
||||
if (!ghost && TUNE.trailMin && !zb.showFrame
|
||||
&& Math.hypot(zb.vx || 0, zb.vz || 0) > TUNE.trailMin) {
|
||||
for (const [k, a] of [[2, 0.10], [1, 0.22]]) {
|
||||
ctx.save(); ctx.globalAlpha *= a;
|
||||
drawZombie({ ...zb, x: zb.x - zb.vx * TUNE.trailK * k, z: zb.z - (zb.vz || 0) * TUNE.trailK * k }, true);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
const rk = zb.variant === 'r';
|
||||
// THE MATCH OPPONENT HAS ITS OWN SPRITES NOW. It used to borrow the turned CPU's ('c') — a body
|
||||
// drawn to SHAMBLE at the camera with its racket hanging at its side, which is what was playing
|
||||
@@ -1782,10 +1794,19 @@ export function drawLook(cv2, t, k, sk) {
|
||||
const bounceDx = () => tour.dx ?? BOUNCE_IDLE.dx;
|
||||
const bounceApex = () => tour.apex ?? BOUNCE_IDLE.apex;
|
||||
|
||||
function drawChar(ch) {
|
||||
function drawChar(ch, ghost) {
|
||||
// No split-step. The receiver used to be slid 0.12 m side to side while waiting on a serve, on top
|
||||
// of a swap to the run frames; both are gone. Sliding a sprite sideways is not footwork — it reads
|
||||
// as the man skating — and the idle pair's own weight shift is the whole of what he should be doing.
|
||||
// MOVEMENT READS: the same two-ghost speed streak the bodies wear (see drawZombie) — gameplay's
|
||||
// mover writes P.vx/vz, so only a genuine sprint (auto-run chase, horde steer) ever draws one.
|
||||
if (!ghost && TUNE.trailMin && Math.hypot(ch.vx || 0, ch.vz || 0) > TUNE.trailMin) {
|
||||
for (const [k, a] of [[2, 0.10], [1, 0.22]]) {
|
||||
ctx.save(); ctx.globalAlpha *= a;
|
||||
drawChar({ ...ch, x: ch.x - ch.vx * TUNE.trailK * k, z: ch.z - ch.vz * TUNE.trailK * k }, true);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
const p = proj(ch.x, 0, ch.z);
|
||||
// the readability boost is what makes the MATCH OPPONENT legible: at the far baseline he was
|
||||
// ~34 logical px of faithful anatomy, which is mush. The near player anchors the curve (farB
|
||||
@@ -2822,6 +2843,8 @@ export function render() {
|
||||
// the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here
|
||||
ctx.fillText(G.match && G.match.cup
|
||||
? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED`
|
||||
// training: the ladder is parked, so "opponent N · felled" would lie — name the rep instead
|
||||
: G.match && G.match.train ? `TRAINING · ${matchFoe(G.match.opp).short}`
|
||||
: G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED`
|
||||
: G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}`
|
||||
: `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
|
||||
|
||||
Reference in new issue
Block a user