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:
wuwiandClaude Fable 5 committed 2026-08-28 16:08:18 +02:00
1 parent a42dc6fc81
commit 43784c5b33
9 files changed
+360 -43

No files matched your search

+65
View File
@@ -491,6 +491,71 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: THE READ, the walk-on modal, the training court, movement reads (2026-08-28)
Four items from one phone report. PHONE VERDICT PENDING on all of them.
### 1. THE READ — the twist for the post-COACH wall
Oliver: past the HEAD COACH *"the opponents are very fast and barely make mistakes any more — we
need a twist, maybe a kind of cognitive fatigue when you make the same move ten times and then
surprise them."* Built as HABITUATION, his own idea with the numbers made playable:
- **doHit buckets every aimed player rally shot into three lanes** (target x at ±1.1) and keeps a
same-lane run on `G.match.laneRun`. `TUNE.readHabit` (3) same-lane shots = the habit is learned.
- **One more of the same lane is `expected`**: the opponent's `aiRead` climbs toward
`TUNE.readAnticipate` (0.97) — spam feeds the machine, so the late ladder punishes one-note play.
- **Breaking the habit is `surprised`, for that one flight**: the read collapses (`surpRead`), the
chase speed drops (`surpSlow`, applied inside `rzChaseSpeed` so the intercept planner and the
early-fall test believe it too), and `surpFlub` lands on top of a whiff rate the champion had
reduced to almost nothing. A "WRONG-FOOTED." flash announces it (rungs past ~20% only).
- **Everything scales with the rung** (`learnK`, (opp−1)/(LADDER−1)): the shambler never learns,
the champion — "HE RETURNS EVERYTHING" — is exactly the one you beat by setting a habit and
breaking it on the big point. Solo match only (netplay's opponent is a human), rally lanes only
(serves keep their own game — open: should serve placement feed the same book?). The one-ball
states are cleared at the racket and at every point end (`matchReady`); a fresh walk-on resets
the book. Tests pin the state machine (real swings through `keyPressed`) and the dial
relationships (a wrong-footed champion runs slower than the head coach at full stride).
### 2. THE WALK-ON IS A MODAL (tournament + arcade + training)
Oliver: *"die Intros der einzelnen Spieler bitte als richtiges Modal, das man wegtappen muss —
man kann dann wirklich lesen was da steht, und schnell wegklicken wenn man es schon 100x gelesen
hat."* `matchOpponent` now calls `showModal` (head icon `z_<fam>head`, the punchline, the cup's
round line) instead of the timed 4 s card. The walk-on court is guaranteed-calm (the mop beat has
just ended), so the popup policy allows it; the freeze holds the 1.6 s intro bar until the tap.
The mode-rules popup fires BEFORE `matchOpponent` in `startMatch` so it lands first and the
opponent queues behind it. Netplay untouched (no walk-on there at all). The test harness's
`passMopBeat` now taps the walk-on away like a player would.
### 3. THE TRAINING COURT (`MODES` id `train`, third kid of ZOMBIE TENNIS)
Oliver: *"ein Trainingsbereich — Auswahl des Gegners, ein Spiel, tap to replay, bis man keine
Lust mehr hat."* One flag (`d.train`, set by `startMatch(false, null, rung)`) on the whole match
machinery:
- SERVE UP routes to a picker screen (`#train`, built from the roster like the boss picker, the
same `z_*head` crops the draw uses); tapping a rung IS the start — tap-advances, like every
other screen. `mode_train.png` rolled in the banner style (golden-hour practice court, ball
basket, cones, no text) via the famroll OpenRouter route.
- The rung is PARKED: `d.opp` never advances, a felled body walks back on as itself, and the
walk-on modal (item 2) is the "tap to replay" gate for free. Between reps the blood resets
silently (no WOUNDS TREATED ceremony — a popup per rep is the tap-tax the mode avoids).
- NOTHING BANKS: no `G.runGames`, no `endRun` (pagehide guarded), and bleeding out is a modal
("IT BLED YOU OUT") plus a fresh pool, never the death screen. The quit chip is the exit.
- The board's progress slot reads `TRAINING · <SHORT>`; the setup footer says "NOTHING AT STAKE".
### 4. MOVEMENT READS — dust and speed ghosts (feel-tunable, verdict pending)
Oliver: *"die Bewegung der Spieler optisch anreichern — motion blur / particles / shader, hier
bin ich auf deinen Input angewiesen."* Two cheap effects in the existing registers, no shaders:
- **Sprint dust**: feet crossing `TUNE.trailMin` (3.6 m/s — above every shamble) kick one
per-court `dustPuff` per `runDustEvery` metres. racketAI's chase and the player's mover
(auto-run, horde steer) both emit; the mover measures its travel AFTER the clamps so a body
pinned at a bound reads as standing.
- **Speed ghosts**: `drawZombie`/`drawChar` draw two alpha ghosts sampled back along the body's
own velocity (`trailK` s × 1 and 2) — the modal masthead's speed-streak trick applied to
bodies. Only genuine sprints qualify; never during a named frame (`showFrame` — a collapsing
corpse must not smear on stale velocity). racketAI now records `vz` beside `vx`; the player's
`P.vx/vz` are written by the mover and zeroed outside it.
Both are panel-tunable (`trailMin` 0 kills both). Not tried, on the shelf if these fail the
phone: a one-frame lean/skid sprite on hard direction changes (needs drawn frames, per the
draw-frames rule), and court-colored skid streaks on the ground behind a hard stop.
## Session log: THE LOB (BUILT, THEN CUT) AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day) ## Session log: THE LOB (BUILT, THEN CUT) AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day)
### THE LOB WAS BUILT AND REMOVED THE SAME DAY — and the reason is the useful part ### THE LOB WAS BUILT AND REMOVED THE SAME DAY — and the reason is the useful part
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+17 -6
View File
@@ -174,7 +174,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame /* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */ court through between menus. */
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs { display: none; } #splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs, #train { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; } #intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a /* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */ screen whose content does not fill it */
@@ -186,6 +186,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro[data-stage="secret"] #secret, #intro[data-stage="secret"] #secret,
#intro[data-stage="bosses"] #bosses, #intro[data-stage="bosses"] #bosses,
#intro[data-stage="bracket"] #bracket, #intro[data-stage="bracket"] #bracket,
#intro[data-stage="train"] #train,
#intro[data-stage="vs"] #vs, #intro[data-stage="vs"] #vs,
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1; #intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
animation: drop 0.3s ease-out; } animation: drop 0.3s ease-out; }
@@ -264,16 +265,19 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* THE BOSS PICKER (back room). Two columns of portraits, because you pick a boss by looking at /* THE BOSS PICKER (back room). Two columns of portraits, because you pick a boss by looking at
him — the tile is mostly art. Same plate as the tour tiles above; the grid is what makes ten him — the tile is mostly art. Same plate as the tour tiles above; the grid is what makes ten
of them fit on a phone. */ of them fit on a phone. */
#bossList { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; } #bossList, #trainList { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; }
#bossList button { display: flex; flex-direction: column; align-items: center; gap: 5px; #bossList button, #trainList button { display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 8px 6px 10px; background: var(--panel); border: 2px solid var(--line); padding: 8px 6px 10px; background: var(--panel); border: 2px solid var(--line);
color: inherit; font: inherit; cursor: pointer; color: inherit; font: inherit; cursor: pointer;
-webkit-tap-highlight-color: transparent; } -webkit-tap-highlight-color: transparent; }
#bossList img { height: 78px; width: auto; display: block; image-rendering: pixelated; } #bossList img { height: 78px; width: auto; display: block; image-rendering: pixelated; }
#bossList b { font: 400 9px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; #bossList b, #trainList b { font: 400 9px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
text-align: center; line-height: 1.35; } text-align: center; line-height: 1.35; }
#bossList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal; #bossList i, #trainList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal;
letter-spacing: 1px; } letter-spacing: 1px; }
/* the training picker is 18 rungs deep — the tile carries the HEAD (the same z_*head crop the
draw and the walk-on wear), which reads at a third of the boss portrait's height */
#trainList img { height: 44px; width: auto; display: block; image-rendering: pixelated; }
/* THE DRAW. Four rungs read top-down as a ladder, because that is what the mode is — the bracket /* THE DRAW. Four rungs read top-down as a ladder, because that is what the mode is — the bracket
shape a real draw is printed in needs width a phone has not got, and half of it would be blank shape a real draw is printed in needs width a phone has not got, and half of it would be blank
anyway (you only ever play one side of it). The rows spin their portrait and their name while anyway (you only ever play one side of it). The rows spin their portrait and their name while
@@ -561,6 +565,13 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<button class="btn big" id="cupGo" disabled>ENTER THE DRAW</button> <button class="btn big" id="cupGo" disabled>ENTER THE DRAW</button>
</div> </div>
</div> </div>
<!-- THE TRAINING COURT's opponent picker: the whole ladder face-up, tap a body to drill it.
Built in main.js from config's roster (matchFoe/LADDER), so a new rung needs no markup. -->
<div id="train">
<div class="hdr"><button class="chev" id="trBack">&lsaquo;</button><h2>TRAINING</h2></div>
<h3>PICK YOUR OPPONENT<span>ONE GAME · GO AGAIN</span></h3>
<div id="trainList"></div>
</div>
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player), <!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
the host's one decision, and the friend's code. #vsWait replaces the picker in place once the host's one decision, and the friend's code. #vsWait replaces the picker in place once
a socket is open, so the room code and its SHARE button stand where the buttons were and a socket is open, so the room code and its SHARE button stand where the buttons were and
@@ -664,6 +675,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=308"></script> <script type="module" src="src/main.js?v=309"></script>
</body> </body>
</html> </html>
+51 -22
View File
@@ -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 // `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 // 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. // 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; 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 // 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 // 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 // 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; zombies.length = 0;
G.boy = null; G.boy = null;
score.pts = [0, 0]; score.pts = [0, 0];
matchOpponent(); // The mode's rules modal fires BEFORE matchOpponent, so it lands FIRST and the opponent's own
matchReady(); // walk-on modal queues behind it (showModal's modalQ keeps call order) — rules, then the body.
zombieDuck(true); if (train && !G.seen.train) {
voice('play_resume', 0.2); G.seen.train = true;
if (cup && !G.seen.cup) { 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; G.seen.cup = true;
showModal({ title: 'THE TOURNAMENT', color: UI.hot, showModal({ title: 'THE TOURNAMENT', color: UI.hot,
body: ['FOUR ROUNDS UP THE DEAD HIERARCHY', body: ['FOUR ROUNDS UP THE DEAD HIERARCHY',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] }); '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; G.seen.match = true;
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real // 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. // 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', body: ['WIN THE POINT — WOUND THE ZOMBIE',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] }); 'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
} }
matchOpponent();
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
return true; return true;
} }
@@ -418,6 +429,8 @@ export function matchReady() { // exported for net.js: a paired death match re-p
ball.active = false; ball.active = false;
G.serveNum = 1; G.serveNum = 1;
if (G.match) G.match.decided = false; // the next rally may be decided early again 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(); setupPoint();
const rz = zombies.find(z => z.variant === 'r'); 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 // 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. // so killZombie can file the kill under it without asking G which opponent we were on.
zb.foe = matchFoe(G.match.opp); zb.foe = matchFoe(G.match.opp);
zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS) 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). // THE WALK-ON IS A MODAL NOW, not a timed card (2026-08-28, Oliver: "als richtiges Modal, das
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card // man wegtappen muss — you can actually read it without time pressure, and tap it away fast when
// of the player should last longer"). In the cup the round names itself on the third line. // 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; const cup = G.match.cup;
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam, showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64,
sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1 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' : '') : undefined }; ? ' · 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 // 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 // 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 // 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. // 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); aside(G.hp <= 2 ? 'ump_low_blood' : 'ump_wound_you2', 2.2);
if (G.hp <= 0) { if (G.hp <= 0) {
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud — // THE TRAINING COURT HAS NO DEATH IN IT. Bleeding out costs a modal and a fresh pool — the
// the other phone turns it into its win screen (netMatchWon) // point still goes to the body (advancePoint below), so a game you were losing stays lost.
if (G.net && G.net.ready) netSendDead(); if (d.train) {
return killPlayer(); // eaten — the run summary, as ever 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 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 // 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. // 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]; } 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); 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 // 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 // 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 }; G.flash = { text: 'GAME.', t: 1.4 };
// netplay: the SAME opponent walks back on — the body across the net is one particular friend, // 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 // 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) // opp number drives are AI-only and dormant here anyway). Training parks the ladder the same
if (!G.net) d.opp++; // 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 // 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 // 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). // 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. // Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
d.afterFall = () => { 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 // ...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 // 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. // near court where the player is about to serve. Between games only — see sendBoy's match note.
+26 -1
View File
@@ -599,6 +599,28 @@ export const TUNE = {
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark 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) // 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 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) 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, 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 // 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: '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: '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' }, { 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 // ...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 // 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' }, { 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`). // (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
export const MENU = [ export const MENU = [
{ mode: 'classic', diffs: true }, { 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' }, { mode: 'gore' },
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN, // 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) // and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
+65 -8
View File
@@ -1,6 +1,6 @@
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. // 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 { 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 { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { haptic } from './haptics.js'; import { haptic } from './haptics.js';
@@ -232,6 +232,21 @@ function doHit(ch, quality, aim) {
aimedLane = true; aimedLane = true;
} }
tx = Math.max(-5, Math.min(5, tx)); 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') { if (G.match && ch.side !== 'P') {
// ...AND ITS PACE IS THE FOURTH. Width alone did not fix "unreachable" because the returns left // ...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 // 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). // 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 // 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. // 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 const rzChaseSpeed = rz => TUNE.racketSpeed
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1) * (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 // 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 // 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 // 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. // 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 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; // THE READ rides on aiRead: an EXPECTED ball (you repeated your habit) is read almost perfectly
const aimZ = pred ? ball.z + (pred.z - ball.z) * TUNE.aiRead : ball.z; // — 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 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); const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) { 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. // 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); earnFlip(rz, 'face', Math.abs(dx) > 0.2 ? (dx > 0 ? 1 : -1) : 0, dt);
rz.vx = dx / d * step / 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 // 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 // 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. // 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) { && 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, // ...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. // 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 const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
: TUNE.racketFlub; : 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) 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; } 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 // 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. // walking between points" rule is untouched — nothing arms a burst except the player.
if (G.phase === 'rally' || G.mgT > 0) { 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 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); 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 }; 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 (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 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); 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 // ...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 // 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. // 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 // 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 // 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. // off the edge of the screen either.
+23 -3
View File
@@ -3,7 +3,7 @@
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // 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) // 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 { 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 { 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'; 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')); 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 // 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 // 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. // 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; : m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// what the setup footer says you are about to play, and what it is worth beating // 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; ? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML = document.getElementById('sSum').innerHTML =
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>` `<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 // 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 // ...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. // means "roll it yourself", which is what the mode did before the screen existed.
if (modeId === 'cup') { intoSplatter(); startMatch(true, cupPicks); } 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; 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 // 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. // carries its own start button, so this stays the one place a run begins from the setup screen.
document.getElementById('cStart').addEventListener('click', () => { document.getElementById('cStart').addEventListener('click', () => {
if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; } if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; }
if (pickedMode === 'train') { go('train'); return; } // the picker starts it — tap advances
start(); start();
}); });
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack']) 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. // no-ops on an empty run, so closing the tab at the menu writes nothing.
addEventListener('pagehide', () => { addEventListener('pagehide', () => {
if (G.mode !== 'splatter') return; 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 — // ...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. // 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); if (G.match) stats.endRun(G.match.opp - 1, score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
+26 -3
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js'; 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 { P, O, ball, score, crowd, G, court, tour, kit, skin } from './state.js';
import { RAISERS, FRAMES } from './crowd.js'; import { RAISERS, FRAMES } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.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 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 === 'o') return drawBarrel(zb);
if (zb.variant === 'm') return drawMachine(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'; const rk = zb.variant === 'r';
// THE MATCH OPPONENT HAS ITS OWN SPRITES NOW. It used to borrow the turned CPU's ('c') — a body // 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 // 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 bounceDx = () => tour.dx ?? BOUNCE_IDLE.dx;
const bounceApex = () => tour.apex ?? BOUNCE_IDLE.apex; 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 // 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 // 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. // 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); const p = proj(ch.x, 0, ch.z);
// the readability boost is what makes the MATCH OPPONENT legible: at the far baseline he was // 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 // ~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 // the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here
ctx.fillText(G.match && G.match.cup ctx.fillText(G.match && G.match.cup
? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED` ? `${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.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED`
: G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}` : 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); : `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8);
+87
View File
@@ -523,6 +523,9 @@ const passMopBeat = () => { // ...stopping at the walk-on: past it, the next o
// the KILL FALL plays first — the felled body is still on court, so oppOf() must not count it // the KILL FALL plays first — the felled body is still on court, so oppOf() must not count it
for (let i = 0; i < 5 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); } for (let i = 0; i < 5 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !oppOf(); i++) { G.modal = null; G.modalQ.length = 0; update(DT); } for (let i = 0; i < (TUNE.matchMopT + 8) * 60 && !oppOf(); i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
// the walk-on is a MODAL now (2026-08-28) and it arrives WITH the opponent — tap it away too,
// or every step after this one runs against a frozen world
G.modal = null; G.modalQ.length = 0;
}; };
test('a match owns the court like every other mode', () => { test('a match owns the court like every other mode', () => {
@@ -2028,5 +2031,89 @@ test('the stand reads the SIZE of the moment, and a small cheer inside a big one
crowd.cheer = crowd.pop = crowd.burst = 0; crowd.cheer = crowd.pop = crowd.burst = 0;
}); });
// ---- 2026-08-28: the walk-on modal, the training court, and THE READ ---------------------------
test('the walk-on is a MODAL the player taps away, and it holds the world', () => {
// Oliver: "die Intros bitte als richtiges Modal, das man wegtappen muss — you can actually read
// it without time pressure, and tap it away fast when you know it." The timed 4 s card is gone.
G.seen.match = true; // the rules popup is not the subject here
assert.ok(_startMatch());
assert.ok(G.modal, 'the opponent announces as a modal, not a band card');
assert.equal(G.modal.title, cfg.matchFoe(1).name, 'it names the body');
assert.equal(G.modal.icon, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head');
const z0 = oppOf().z, intro0 = G.interlude.introT;
for (let i = 0; i < 60; i++) update(DT);
assert.equal(oppOf().z, z0, 'the frozen court holds while it is read');
assert.equal(G.interlude.introT, intro0, '...and the intro beat holds with it, however long that takes');
G.modal = null; G.modalQ.length = 0;
endMatch();
});
test('THE TRAINING COURT: the rung is parked, nothing banks, and bleeding out is a modal', () => {
assert.ok(_startMatch(false, null, 5), 'training rides the match machinery');
G.modal = null; G.modalQ.length = 0;
const d = G.match;
assert.ok(d.train, 'flagged as the training court');
assert.equal(d.opp, 5, 'the picked rung is the one that walks on');
const runGames0 = G.runGames;
G.interlude.introT = 0;
G.hp = 3; // a scuffed pool, to prove the reset
for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; }
passMopBeat(); // the kill fall, the mop, the SAME body walking on
assert.equal(G.match.opp, 5, 'a felled body comes back as itself — the ladder never advances');
assert.equal(G.runGames, runGames0, 'no ladder metric moves on the training court');
assert.equal(G.hp, G.hpMax, 'every rep starts whole — the reset, without the trainer ceremony');
// ...and running out of blood is a popup and a fresh pool, never the death screen
G.interlude.introT = 0;
G.hp = 1;
resolveInterlude('O');
assert.notEqual(G.mode, 'eaten', 'the training court has no death in it');
assert.ok(G.modal && G.modal.title === 'IT BLED YOU OUT', 'bleeding out announces itself');
assert.equal(G.hp, G.hpMax, '...and hands the pool back');
G.modal = null; G.modalQ.length = 0;
endMatch();
});
test('THE READ: a habit is learned by repetition and a break wrong-foots the racket', () => {
// The twist for the post-COACH wall ("very fast, barely make mistakes"): repeat a lane and the
// opponent anticipates it, break an established habit and it is wrong-footed for that ball.
G.seen.match = true;
_startMatch(); G.modal = null; G.modalQ.length = 0;
const zb = oppOf(), d = G.match;
d.opp = cfg.LADDER; zb.foe = cfg.matchFoe(cfg.LADDER); // the champion's dials, live
G.interlude.introT = 0;
const hit = fx => { // one real player swing, aimed by a committed flick
G.phase = 'rally'; P.x = 0; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingCool = 999; // it must not return the rig's ball
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = P.x; ball.z = P.z - 0.4; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1;
G.swipe = { ax: Math.sign(fx), fx, fy: 0, mag: TUNE.flickFull };
keyPressed('Space');
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
G.swipe = null;
assert.equal(ball.lastHit, 'P', 'the rig landed a swing');
ball.active = false; // the rally is not the subject
};
for (let i = 0; i < TUNE.readHabit; i++) hit(-1);
assert.equal(d.laneRun, TUNE.readHabit, 'a run of the same lane goes on the books');
assert.ok(!d.surprised && !d.expected, 'the habit itself surprises nobody');
hit(-1); // one more of the same: the anticipated ball
assert.ok(d.expected && !d.surprised, 'the repeated ball is anticipated — spam feeds the machine');
hit(1); // the break
assert.ok(d.surprised && !d.expected, 'the break wrong-foots him for this one flight');
assert.equal(d.laneRun, 1, '...and the new lane starts its own book');
G.modal = null; G.modalQ.length = 0;
endMatch();
// the dials must MATTER at the top and vanish at the bottom — pinned so they cannot drift:
const spd = n => (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (cfg.matchFoe(n).speed ?? 1);
assert.ok(spd(cfg.LADDER) * (1 - TUNE.surpSlow) < spd(14),
'a wrong-footed champion runs slower than the head coach at full stride');
const champFlub = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, cfg.LADDER - 1) * cfg.matchFoe(cfg.LADDER).flub;
assert.ok(champFlub < 0.02, '(the champion barely misses on his own — the wall being answered)');
assert.ok(champFlub + TUNE.surpFlub > 0.25, 'the surprise opens a real whiff window in that racket');
assert.ok(TUNE.readAnticipate <= 1, 'anticipation never reads better than perfect');
assert.ok(TUNE.readHabit >= 2 && TUNE.readHabit <= 6, 'a habit is learnable inside one game');
});
console.log(`\n${passed} tests passed`); console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report