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
+65
@@ -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
|
||||
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)
|
||||
|
||||
### 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
@@ -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
|
||||
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
|
||||
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; }
|
||||
/* 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 */
|
||||
@@ -186,6 +186,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
#intro[data-stage="secret"] #secret,
|
||||
#intro[data-stage="bosses"] #bosses,
|
||||
#intro[data-stage="bracket"] #bracket,
|
||||
#intro[data-stage="train"] #train,
|
||||
#intro[data-stage="vs"] #vs,
|
||||
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
|
||||
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
|
||||
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. */
|
||||
#bossList { 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, #trainList { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-bottom: 14px; }
|
||||
#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);
|
||||
color: inherit; font: inherit; cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent; }
|
||||
#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; }
|
||||
#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; }
|
||||
/* 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
|
||||
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
|
||||
@@ -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>
|
||||
</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">‹</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),
|
||||
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
|
||||
@@ -664,6 +675,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=308"></script>
|
||||
<script type="module" src="src/main.js?v=309"></script>
|
||||
</body>
|
||||
</html>
|
||||
+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);
|
||||
|
||||
@@ -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
|
||||
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); }
|
||||
// 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', () => {
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
// ---- 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`);
|
||||
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|
||||
Reference in new issue
Block a user