diff --git a/HANDOFF.md b/HANDOFF.md index 50fe4a9..ee2eabd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,7 +5,7 @@ everything they specified is shipped, and what still matters from them is record long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the detail of any individual change, read the git log. -Last updated 2026-07-30, after Oliver's THIRD playtest round (the readability pass). All playtest +Last updated 2026-07-30, after Oliver's FOURTH playtest round (viewport, horde survivability). All playtest rounds are closed at the bottom. Step 6 (crowd turning, ball-boy catcher) stays PARKED by his decision. **Awaiting his next phone playtest.** @@ -166,6 +166,22 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu "too much and not situation aware.. they seem random". Each line is now tied to the thing that justifies it — a new variant appearing, the court filling up, one crossing your baseline, a game lost, a tin delivered, a horde, a changeover, a machine, a boss, the trainer, taking a hit. +- **The portrait viewport ADAPTS to the device, and `TUNE.horizon` must ride with it.** `fitViewport` + picks `h = 270 * clamp(innerHeight/innerWidth, 1.6, 2.3)` — a fixed 270x480 is 9:16 while a modern + iPhone is ~0.46, so width bound the fit and ~150 CSS px of screen went unused. `proj()` places the + court in ABSOLUTE logical px off `horizon`, and the rack/meter hang off `H - ...`, so extra height + lands entirely in the apron between them: leaving `horizon` fixed just grew a dead green band. + It is computed as `45 + (h - 480) * 0.72` for that reason. `RES` is 4 (was 3) so an iPhone's ~1125 + device px across is nearly 1:1 with our 1080 — **`RES` is the first knob to turn down if the phone + ever stutters**, it is the whole fill-rate budget. + Note `#hint` (the desktop key legend) is already `display:none` on `pointer: coarse`; it only + overlaps the rack in the desktop test pane, which is not a real bug. +- **The swish cooldown scales with how swarmed you are** (`meleeCooldown()` in gameplay.js): + `meleeCool / (1 + (near-1) * meleeCrowd)`, floored at `meleeCoolMin`, counting bodies within + `meleeCrowdR`. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde + because the wave grows with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the + melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills + everything in reach, so counting after counts the survivors and the cooldown barely moves. - **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. - **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to @@ -329,6 +345,37 @@ Known generator quirks, all still true: --- +# CLOSED — Oliver's fourth playtest (2026-07-30) + +### The game did not use the full height of his iPhone +Fixed by making the portrait logical height adaptive and letting `horizon` ride with it — see the +invariant above. On a 375x812 pane: logical 270x585 (was 270x480), canvas CSS 375x812, **100% of both +axes**. `RES` 3 -> 4 for the "extra pixels" he asked about. + +### Dying on every game-20 horde +The swish cooldown now scales with crowding — see the invariant. Note the ordering trap recorded +there; the first implementation measured the crowd after the swipe had already killed it, so the +cooldown only moved once ~14 bodies were present. + +### The weapon message was lost when the heal popup fired +`showModal` deliberately never clobbers an open modal, so winning a duel while wounded opened the +heal modal and the weapon modal was silently dropped. `deliverTin(healLine)` now folds the trainer +into the WEAPON popup as an extra line, so there is one popup carrying both. An unwounded duel win +still shows the plain weapon popup. + +### The mop boy walked out with nothing to mop +`sendBoy` now returns early when `mop` is set and no stain passes the same test `mopRound` uses to +pick a target (`a >= 0.15 && z >= COURT.netZ - 2`) — he was walking on, finding nothing reachable, +and walking straight off. This needs `state.js` to read `gore`, which adds a **third runtime-only +import cycle** (state <-> zombies) alongside input<->gameplay and zombies<->gameplay; it is safe for +the same reason, `gore` is only touched inside `sendBoy` at runtime, never during module evaluation. + +### Regression net +30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters, +`mgT` drains, gore 40 / piles 30 / bodies capped at 34. + +--- + # CLOSED — Oliver's third playtest: audio, hit timing, readability (2026-07-30) ### The umpire talked too much and at random diff --git a/config.js b/config.js index 05f3074..cb93afb 100644 --- a/config.js +++ b/config.js @@ -163,6 +163,13 @@ export const TUNE = { backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default + meleeCrowd: 0.38, // ...but the panic button PANICS. The cooldown shortens with how many bodies are + // actually on top of you, so a late horde stops outrunning your only close-range + // answer: cool = meleeCool / (1 + (near-1)*meleeCrowd), floored at meleeCoolMin. + // 1 body 1.00s, 4 bodies 0.47s, 8 bodies 0.28s. + meleeCoolMin: 0.22, + meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange, + // so the swish speeds up as they close in, not only once they are already on you) zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs // The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round // on its own so there is always a chance at one. Fires off the next hand-cleared wave. diff --git a/gameplay.js b/gameplay.js index f6ac929..ed35331 100644 --- a/gameplay.js +++ b/gameplay.js @@ -411,7 +411,7 @@ const WDESC = { // what the tin you just won actually does — one line, shown o FIRE: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'], GUN: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'], }; -export function deliverTin() { +export function deliverTin(healLine = null) { const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k); if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax); else if (w === 1) G.triples = add(G.triples + TUNE.tinTriples); @@ -422,8 +422,11 @@ export function deliverTin() { aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently." const name = SLOTS[w + 1]; - showModal({ title: name, body: WDESC[name], icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w], - iconH: 40, color: UI.warm }); + // healLine folds the trainer's visit INTO this popup. `showModal` deliberately never clobbers an + // open modal (a kill cascade can try to open two in one frame), so when both happened the heal + // modal opened first and the weapon one was lost entirely — Oliver: "the weapon message is lost". + showModal({ title: name, body: healLine ? [...WDESC[name], '', healLine] : WDESC[name], + icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w], iconH: 40, color: UI.warm }); } // ---- the interlude: order is restored, one sudden-death point vs the racket zombie. @@ -496,17 +499,14 @@ export function resolveInterlude(side) { cheer(3, 0.7); // winning the duel patches you up. THE reason to want a duel rather than merely tolerate it — // it is the only healing in the game, and blood is the only thing you cannot buy back otherwise. + let healLine = null; if (G.hp < G.hpMax) { const before = G.hp; G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel); - // a real popup, not a flash: getting patched up is the only way blood comes back, so it deserves - // to stop the world for a beat and say so plainly. The trainer came on. It's still tennis. voice('ump_trainer', 0.2, 0.95); - showModal({ title: 'WOUNDS TREATED', color: UI.pick, icon: 'x_cross', iconH: 34, - body: ['THE TRAINER PATCHED YOU UP', - (G.hp - before) + ' OF ' + G.hpMax + ' BACK · NOW ' + G.hp + '/' + G.hpMax] }); + healLine = '+ WOUNDS TREATED · NOW ' + G.hp + '/' + G.hpMax; } - deliverTin(); // win the point, win the next tin — on the spot + deliverTin(healLine); // win the point, win the next tin — and the trainer, in ONE popup } else { voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel score.games[1]++; G.runGames++; // it goes on the scoreboard @@ -793,12 +793,26 @@ export function dragServe(aim) { hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all } +// The swish cooldown, scaled by how crowded you are. Oliver died on every game-20 horde: the wave +// grows with hordeGrow but the panic button was a flat 1 s, so past a certain density it simply could +// not keep up. He picked "melee fires more frequently with horde density" over shrinking the horde. +function meleeCooldown() { + let near = 0; + for (const zb of zombies) if (zb && Math.hypot(zb.x - P.x, zb.z - P.z) < TUNE.meleeCrowdR) near++; + return Math.max(TUNE.meleeCoolMin, TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); +} + export function keyPressed(code) { if (code !== 'Space') return; audio(); // unlock on first gesture // a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the // default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool. - if (G.mode === 'splatter' && G.meleeT <= 0 && meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = TUNE.meleeCool; + if (G.mode === 'splatter' && G.meleeT <= 0) { + // measure the crowd BEFORE the swipe — it kills everything in reach, so counting afterwards + // counts the SURVIVORS and the cooldown barely moved no matter how swarmed you were + const cool = meleeCooldown(); + if (meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = cool; + } // AN INCOMING SHOT OWNS THE TAP. The parry used to live behind the `phase === 'rally'` branch at the // bottom of this function — and in splatter you are in `pretoss` almost all of the time, so a tap // aimed at a machine's shot tossed the serve ball instead and the return was, in practice, diff --git a/index.html b/index.html index 0011e2b..49bcd1b 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@
TAP TO START
- +