Seven verdicts from the test group

- opening CPU beatable in a game or two (softer cap, more whiffs, rubber band)
- modals are for calm courts only; live-court news is a card now
- the transition howto is gone: a ghost hand teaches the pull, the toss,
  the apex and the swing, each on the exact struggle it answers
- a shot barrel keeps your weapon and glows its slot instead
- switching weapons clicks, the live tile is unmistakable
- barrels come faster the deeper the run goes
- the umpire triple-tap cheat is deleted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-08 19:49:27 +02:00
1 parent f13cbef28a
commit a89e8ff419
10 files changed
+242 -79

No files matched your search

+62 -4
View File
@@ -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-31 (serve wind-up pass). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
Last updated 2026-08-08 (testgroup round: popup policy, ghost hand, barrel economy). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
should be handled, the second is everything known to be outstanding in the game, the third is the
demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed
@@ -52,7 +52,8 @@ P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve
transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state +
`updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick ·
`audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel ·
`main.js` boot, loop, umpire-tap cheat, `window.T` test surface.
`main.js` boot, loop, scoreboard test-mode taps, `window.T` test surface (the umpire-tap cheat is
removed — the testteam found it; `T.forceZombies()` is the console replacement).
THREE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay,
and state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for).
All three are safe for the same reason: the imported binding is only ever touched inside a function at
@@ -80,6 +81,10 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch,
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
**POLICY (2026-08-08, testgroup round): a modal may only fire on a guaranteed-calm court** (duel
intro, boss walk-on, post-duel healing, test mode, the horde's first-ever arrival). Anything that
can land on a live court announces as a CARD or a flash — the freeze itself is safe (measured:
a body moves 0.000 m behind a modal), but the stolen tap and the broken rhythm cost runs.
**`m.in` (0..1 over `TUNE.modalIn`) is the arcade entrance** and the ONE thing that animates behind
the freeze — it is ticked inside that same early-return, so it advances while nothing else does.
Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the
@@ -371,6 +376,55 @@ 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 testgroup round — popups, ghost hand, barrel economy (2026-08-08)
First round of feedback from the small test group (via Oliver), seven items, all landed.
**The popup policy changed: a modal is for CALM courts only.** The report was "popups block
interaction while the game loop continues and the zombies arrive". Measured first, per the
meta-lesson: the world genuinely FREEZES behind a modal (a shambler moved 0.000 m over 3 s with one
up — the invariant holds). What kills is the CONTROL steal: a popup lands mid-crowd, eats the next
tap, and hands the court back with the player's rhythm gone. So every announcement that can land on
a live court is a CARD now (non-blocking): the weapon first-sight modals are deleted outright
(deliverTin's card and popBarrel's new card carry the one desc line that matters), and the
barrel/machine/racket introductions are first-sight cards. The horde popup shows as a modal ONCE
per run (it changes the control mode) and every later set starts on the flash alone — re-teaching
the same rules at the hottest moment of every set was the worst offender. Still modals: sudden
death, the chapter boss, WOUNDS TREATED, test mode — all fire on guaranteed-calm courts.
**The transition howto modal is gone; a GHOST HAND teaches instead.** "Nobody seems to read this."
`drawGhostHand` (render.js) is a drawn pointing hand — canvas paths, UI palette, deliberately not a
sprite — with `drawTapRing` for the contact ripple that makes a mimed tap read as a tap. The apron
demo's old thumb-dot now rides its strokes, and the transition primes `G.idleHintT` so the pull
demo starts miming the moment splatter begins. TENNIS got three lessons on the same hand, each
keyed to the exact failure it answers and reset by doing the thing: idle in the serve stance →
tap demo on the apron (`drawTapHint`); `G.tossDrops` ≥ 2 → the hand rides the toss and PRESSES AT
THE APEX (`drawApexHint` — the timing is the lesson); `G.passStreak` ≥ 2 untouched passes → the
hand presses as the ball closes (`drawHitHint`).
**The opening CPU is beatable in a game or two now.** Testers ground for rounds. `aiFlub` 0.09 →
0.18, `aiQualityMax` 0.85 → 0.72, plus a rubber band: every game the CPU takes adds `aiFlubLoss`
(0.08) to its whiff chance. Tennis mode only exists before the first transition, so these are
purely the opening-match dials (`racket*` covers the duels). **The old "CPU returns ~91.7%"
benchmark is deliberately retired by this** — the expected first-swing return rate is now ~82%
at 0–0 and falls as the CPU loses games.
**A shot barrel no longer yanks the new weapon into your hand.** `popBarrel` restores `G.weapon`
after `grantWeapon` and pulses the new slot instead (`G.rackGlow`, drawn in drawRack) — "the
current weapon should stay active, the new slot should glow briefly". The duel's `deliverTin`
still auto-equips; that one lands on a calm court and IS the prize.
**Switching weapons clicks** (`assets/audio/weapon-click2.mp3`, 0.45 gain — `sfx()` now accepts
names carrying their own extension) **and the live rack tile is unmistakable**: brighter well,
2 px gold frame.
**Barrels ramp with the run** (`barrelRamp`): the gap between barrels shrinks by one every 8
finished games, floored at every game (g0–7 every 3rd, g8–15 every 2nd, g16+ every game). The
late game was "fast tapping until your thumb gives out" once the tins capped; the rack is the
strategic answer, so the run keeps feeding it.
**The umpire triple-tap cheat is deleted** — the test group found it. `T.forceZombies()` remains.
## Session log: the sidestep, and two type registers (2026-08-03)
Oliver's post-ship playtest. One blocker and seven polish items; all eight landed.
@@ -630,7 +684,7 @@ something looks. `T.pause(false)` runs again.
## TEST MODE — triple-tap the scoreboard
A sandbox for trying new content without playing twenty games to reach it. Three taps on the
scoreboard (the same shape as the umpire's triple-tap cheat next to it in main.js) toggles
scoreboard (main.js; the umpire's neighbouring triple-tap cheat is deleted) toggles
`G.testMode`; three more turns it off, and a SINGLE tap is still the miss-log overlay.
- a fixed MEDIUM wave: `testZMax` 8 bodies, `testZSpeed` 1.15 m/s, `testSpawnEvery` 1.6 s — it
@@ -669,7 +723,11 @@ Clear the intro first: `document.getElementById('intro')?.remove()`.
`T.flickTarget`, `T.showModal`/`T.dismissModal`, `T.render`.
**Benchmarks that must keep holding** (re-run after any physics/aim change):
- CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%).
- **RETIRED 2026-08-08**: the ~91.7% CPU return rate. The opening CPU was deliberately softened
(aiFlub 0.18 + aiFlubLoss rubber band, see that session log) — expect ~82% at 0–0, less as it
drops games. The differential method below is still how to prove "no unintended regression".
The old record, for the archaeology:
CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%).
Watch this — the last few sit under the bar rather than scattered around it.
**The harness for this number was never written down and I could not reconstruct it.** A rig that
counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU
Binary file not shown.
+2 -1
View File
@@ -64,7 +64,8 @@ export function sfx(name, vol = 1, opts = {}) {
src.start();
};
if (SFX[name]) return play(SFX[name]);
fetch('assets/audio/' + name + '.wav')
// a name with an extension is used as-is (weapon-click2.mp3); bare names keep the .wav default
fetch('assets/audio/' + (name.includes('.') ? name : name + '.wav'))
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
.then(ab => ac.decodeAudioData(ab))
.then(buf => { SFX[name] = buf; play(buf); })
+11 -1
View File
@@ -24,7 +24,13 @@ export const TUNE = {
// Heavy topspin lets it leave flat and DIVE, which is what a smash actually is.
aimError: 2.4, // m of scatter at quality 0
perfectMs: 45, // |offset from window center| for PERFECT
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
// The tennis CPU only exists in the OPENING match (after the transition, tennis never comes
// back — duels use racket* dials), so these three are the "how long until the first game is won"
// knobs. Testers needed several games to get past him: softer cap, twice the whiffs, and
// aiFlubLoss rubber-bands him — every game HE takes adds that much flub, so a struggling player
// faces an opponent that visibly starts falling apart rather than a wall.
aiQualityMin: 0.45, aiQualityMax: 0.72, aiFlub: 0.18,
aiFlubLoss: 0.08,
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
// ---- THE SIDESTEP. Free walking stays 'rally'-only (see the movement block in gameplay), which
@@ -298,6 +304,10 @@ export const TUNE = {
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRamp: 8, // ...and the gap SHRINKS by one for every this many games finished, floored at
// every game (g8 every 2, g16+ every game). Late runs live on the barrels: past
// the point where every tin is capped, fast tapping was the only way left to
// keep up, and "more inventory for strategic use" is the answer to that.
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
barrelH: 1.15, // drawn height (a body is 1.8)
+37 -38
View File
@@ -112,6 +112,7 @@ function zombieTarget(aim) {
function doHit(ch, quality, aim) {
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
const toFar = ch.side === 'P';
if (ch.side === 'P') G.passStreak = 0; // a landed swing ends the tap-to-hit lesson
// snap the ball to the racket at the contact frame so the hit visually connects
ball.x = ch.x + ch.facing * 0.5;
ball.z = ch.z + (toFar ? -0.3 : 0.3);
@@ -330,6 +331,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
sv.pose = 'serve'; sv.poseT = 0;
if (sv.side === 'P') G.tossDrops = 0; // a served toss ends the apex lesson
if (perfect) { praise(); G.hitstop = 0.05; }
}
@@ -402,6 +404,7 @@ function pointTo(side, reason) {
const n = G.rallyNear;
logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`,
dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) });
G.passStreak++; // two of these in a row and the ghost hand shows the tap (render.drawHitHint)
}
const gameOver = advancePoint(side);
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
@@ -467,9 +470,10 @@ export function splatPoint(side, quiet = false) {
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
spawnMachine();
// ...and a weapon barrel on its own schedule. One at a time: two glowing pink things on court at
// once would out-shout everything the palette uses to say "this one is dangerous".
if (side === 'P' && G.runGames % TUNE.barrelEvery === 0
// ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp),
// because the late game is where the rack has to carry you. Still one at a time: two glowing
// pink things on court would out-shout everything the palette uses to say "this one is dangerous".
if (side === 'P' && G.runGames % Math.max(1, TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp)) === 0
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
spawnBarrel();
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
@@ -544,7 +548,8 @@ const slotOf = id => { // -1 if you are not carrying it
export function selectWeapon(i) {
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
G.weapon = i; pock(0.3, 1100); // temp has just been cleared, and then it is BALL again)
G.weapon = i; // temp has just been cleared, and then it is BALL again)
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
}
// Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides
@@ -607,30 +612,13 @@ export function deliverTin() {
if (wasPlain) G.weapon = 0;
sendBoy(); voice('new_balls', 0.6);
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
// THE PRIZE GETS ITS OWN CARD, with the tin's own icon on it. It used to borrow G.flash — the
// same channel as PERFECT and DOUBLE KILL — so the one thing you played the duel FOR arrived as a
// word in the praise slot and read as another compliment. The first time a tin is seen the modal
// announces it (and teaches its rules), so the card would be the SAME news twice, back to back —
// it only speaks when the modal doesn't. Every time after, the card is the announcement, and it
// does not want a tap: you have just won a point, not been asked a question.
if (!weaponModal(id))
G.card = { title: WEAPONS[id].label, sub1: 'DELIVERED TO THE RACK',
sub2: (WEAPONS[id].desc && WEAPONS[id].desc[0]) || '',
icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT };
}
// The one-per-run "what this thing does" popup, shared by the duel ladder and the barrels.
// THE GATE LIVES HERE, not at the call sites. The barrels gated themselves and the tin ladder did
// not, so the four delivered weapons re-explained themselves every three games for a whole run
// while a barrel weapon explained itself once — the same popup behaving two different ways.
// Returns whether it actually announced, so a caller with its own announcement (the tin card, the
// barrel's flash) can stay quiet rather than say the same thing twice.
export function weaponModal(id) {
if (G.seen['w' + id]) return false;
G.seen['w' + id] = true;
const w = WEAPONS[id];
showModal({ title: w.label, body: w.desc, icon: w.icon, iconH: 40, color: UI.warm });
return true;
// THE PRIZE GETS ITS OWN CARD, with the tin's own icon on it — the announcement AND the whole
// explanation. The first-sight weapon MODAL is gone (with popBarrel's): the testteam never read
// them, and a popup that freezes a live court mid-run costs runs. The card carries the one desc
// line that matters and does not want a tap: you have just won a point, not been asked a question.
G.card = { title: WEAPONS[id].label, sub1: 'DELIVERED TO THE RACK',
sub2: (WEAPONS[id].desc && WEAPONS[id].desc[0]) || '',
icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT };
}
// ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing
@@ -905,11 +893,11 @@ function transition(dt) {
once('go', 8.2, () => {
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
// the mechanic changed under you — the one popup that gets an illustration, because it has
// to TEACH the gesture, not just announce it
showModal({ title: 'THE MATCH HAS CHANGED', color: UI.hot, icon: 'x_howto', iconH: 120,
body: ['PULL BACK TO AIM — THEN LET GO', 'THE FURTHER YOU PULL, THE HARDER IT HITS',
'A WEAK SHOT ONLY STAGGERS THEM', 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY'] });
// NO modal here anymore — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches
// the pull instead: prime the idle clock so the apron demo starts miming IMMEDIATELY rather
// than after the usual beat of inaction, and it keeps coming back whenever the player stalls.
G.idleHintT = TUNE.pullHintAfter;
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
});
}
@@ -1458,6 +1446,7 @@ export function update(dt) {
if (G.mode !== 'tennis') updateZombies(dt);
tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
if (G.meleeT > 0) G.meleeT -= dt;
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out
watchdog(dt);
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.mode === 'transition') transition(dt);
@@ -1498,7 +1487,9 @@ export function update(dt) {
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0;
if (near) {
if (Math.random() < TUNE.aiFlub) G.aiSwingCool = 0.5; // whiff: hesitates and misses
// rubber band: every game HE has taken makes him flub more — the opening match must not
// be a wall a new player grinds against for rounds (see aiFlubLoss in config)
if (Math.random() < TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss) G.aiSwingCool = 0.5; // whiff: hesitates and misses
else {
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
trySwing(O, true); O.swingT = -1;
@@ -1577,6 +1568,9 @@ export function update(dt) {
if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0 && !G.aim) { // uncaught toss → retoss (a held slingshot aim keeps the toss up)
ball.active = false; G.phase = 'pretoss';
if (score.server === 'O') G.aiServeT = 0.8;
// dropping the toss twice running means the second tap isn't landing — the ghost hand then
// rides the next toss and presses at the apex (render.drawApexHint)
else if (G.mode === 'tennis') G.tossDrops++;
}
}
@@ -1593,10 +1587,15 @@ export function update(dt) {
// card, because 30 is both a set and a boss. The wave then poured onto the boss stage. Leave the
// latch alone and let the beat AFTER the fight cash it: the set still gets its horde, later.
if (G.pendingHorde && !G.bossFight && !G.interlude) { // the set card's pause is over — but the horde is a different CONTROL MODE
G.pendingHorde = false; // (racket auto-fires, the finger steers you absolutely),
showModal({ title: 'THEY KEEP COMING', color: UI.hot, // so it gets a real block, not just a card
body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'],
onDismiss: startHorde });
G.pendingHorde = false; // (racket auto-fires, the finger steers you absolutely),
if (!G.seen.horde) { // so its FIRST arrival gets a real block...
G.seen.horde = true;
showModal({ title: 'THEY KEEP COMING', color: UI.hot,
body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'],
onDismiss: startHorde });
} else startHorde(); // ...and every later set starts on the flash alone. Re-showing the
// same popup at the hottest moment of every set was one of the
// "popups get me killed" reports — you know the rules by set two.
return;
}
// auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into
+1 -1
View File
@@ -359,6 +359,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=127"></script>
<script type="module" src="main.js?v=128"></script>
</body>
</html>
+6 -11
View File
@@ -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, COURT, COURTS, TOURS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
import { TUNE, COURTS, TOURS, EATEN_RESTART } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight , toggleTestMode } from './gameplay.js';
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
@@ -268,9 +268,11 @@ const maybeRestart = () => {
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
// hidden shortcuts: three taps on the UMPIRE summons zombie mode early, three on the SCOREBOARD
// opens test mode (a medium wave plus every barrel weapon — see gameplay.toggleTestMode)
let umpTaps = [], boardTaps = [];
// hidden shortcut: three taps on the SCOREBOARD opens test mode (a medium wave plus every barrel
// weapon — see gameplay.toggleTestMode). The umpire triple-tap (skip to zombie mode) is REMOVED:
// the testteam found it, and a cheat a player can trip by accident is a bug. T.forceZombies()
// still exists for the console.
let boardTaps = [];
cv.addEventListener('pointerdown', e => {
const r = cv.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
@@ -286,14 +288,7 @@ cv.addEventListener('pointerdown', e => {
// overlay on behind you
toggleTestMode();
} else G.logShow = !G.logShow; // a single tap is still the miss-log overlay
return;
}
const u = proj(UMP_X, 0, COURT.netZ);
if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
const now = performance.now();
umpTaps = umpTaps.filter(t => now - t < 1600);
umpTaps.push(now);
if (umpTaps.length >= 3) { umpTaps = []; forceZombies(); }
});
+97 -9
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_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 } from './config.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
@@ -1130,10 +1130,22 @@ function drawRack() {
const sel = G.weapon === i, dim = stockI <= 0;
const label = i === 0 && !temp ? 'BALL' : w ? w.label : '';
const icon = i === 0 && !temp ? 'w_ball' : w ? w.icon : null;
// rounded slots: five separate tiles read as five buttons, which is what they are
// rounded slots: five separate tiles read as five buttons, which is what they are.
// The LIVE one has to read at a glance ("the active weapon should be more pronounced"):
// a visibly brighter well and a 2px gold frame, not the same hairline every other tile gets.
if (sel) ctx.lineWidth = 2;
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4,
sel ? '#123a24' : '#0a2418', // sunk slot
sel ? '#1c5230' : '#0a2418', // sunk slot — the live one sits visibly lit
sel ? UI.pick : '#05160e'); // gold frame when live
if (sel) ctx.lineWidth = 1;
// a slot that just came out of a barrel announces itself: a pink pulse for a moment, WITHOUT
// stealing the selection (grabbing the gun mid-plan was the complaint — see popBarrel)
if (G.rackGlow && G.rackGlow.slot === i) {
const p = 0.35 + 0.55 * Math.abs(Math.sin(G.rackGlow.t * 7));
ctx.save(); ctx.lineWidth = 2;
panel(x + 1.5, y0 + 1.5, g.tw - 3, g.h - 3, 4, null, hexA(UI.pink, p));
ctx.restore();
}
// icon: left, vertically centred, padded off the edge
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = y0 + g.h / 2;
if (!icon) { ctx.globalAlpha = 1; continue; } // an empty socket: nothing gathered for it yet
@@ -1181,7 +1193,33 @@ function drawCard(title, sub1, sub2, color, icon) {
if (sub2) shadowText(sub2, W / 2, H / 2 + 18, UI.cream);
}
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost thumb slides
// A GHOST HAND, fingertip at (x, y) — the tutorial finger every gesture hint shares. A drawn
// pointing hand rather than the old abstract dot: testers did not read a sliding circle as "your
// finger goes here". `press` (0..1) dips it toward the glass, which is what makes a mimed tap
// read as a tap and not as a cursor hovering. Pure canvas paths, no sprite: it is HUD chrome in
// the UI palette, same family as the arrowheads and the dotted track, not a body on the court.
function drawGhostHand(x, y, press = 0) {
ctx.save();
ctx.translate(Math.round(x), Math.round(y) - 2 + press * 3);
ctx.rotate(-0.18); // the slight tilt a right hand actually lands with
ctx.fillStyle = UI.cream; ctx.strokeStyle = 'rgba(8,12,10,0.75)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(-2.2, 0, 4.4, 11, 2.2); ctx.fill(); ctx.stroke(); // index finger, tip at origin
ctx.beginPath(); ctx.roundRect(-3.2, 8, 11.5, 10, 4); ctx.fill(); ctx.stroke(); // the curled fist behind it
ctx.beginPath(); ctx.ellipse(-2.6, 12.5, 2.4, 3.6, 0.55, 0, 7); ctx.fill(); ctx.stroke(); // folded thumb
ctx.strokeStyle = 'rgba(8,12,10,0.35)'; // two knuckle creases — what makes the blob read as fingers
ctx.beginPath(); ctx.moveTo(2.4, 9.5); ctx.lineTo(2.4, 13.5); ctx.moveTo(5.4, 9.5); ctx.lineTo(5.4, 13.5); ctx.stroke();
ctx.restore();
}
// the contact ring a mimed tap leaves on the glass: grows and fades under the fingertip
function drawTapRing(x, y, p) {
ctx.save();
ctx.globalAlpha *= Math.max(0, 0.9 * (1 - p));
ctx.strokeStyle = UI.warm; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(x, y, 3 + p * 12, 0, 7); ctx.stroke();
ctx.restore();
}
// Sit in the serve stance doing nothing and the gesture demonstrates itself: a ghost hand slides
// BACK from the player while chevrons grow FORWARD up the court, then it releases and repeats.
// THE APRON IS THIS DEMO'S ROOM, and now it is the only thing in it. The draw meter used to share
// the space, which was backwards on both counts: a live readout down here is under your thumb the
@@ -1223,12 +1261,9 @@ function demoSwipe(dx, dy, label) {
// carry it off the edge of the screen, and clearing the full travel below a horizontal stroke
// left it floating in the middle of an empty apron with nothing to attach itself to.
shadowText(label, ax, ay + (dy ? travel + 24 : 18), UI.warm);
// the ghost thumb
// the ghost hand riding the stroke, pressed into the glass for the whole draw
ctx.globalAlpha = gone ? 0 : 0.9;
ctx.fillStyle = UI.cream;
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 6, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath(); ctx.arc(ax + dx * len, ay + dy * len, 2.5, 0, 7); ctx.fill();
drawGhostHand(ax + dx * len, ay + dy * len, 1);
ctx.restore();
}
@@ -1246,6 +1281,51 @@ function drawPullHint() {
demoSwipe(dir, 0, 'SWIPE TO DODGE');
}
// ---- the TENNIS lessons, same ghost hand. Tennis has no slingshot to mime, but the testteam
// struggled to grasp the tap ritual too — so the hand TAPS: on the apron when the player sits idle
// in the serve stance, riding the toss when they keep dropping it, and beside the player when
// returns keep sailing past untouched. Each appears on the exact struggle it answers and goes
// away the moment the player does the thing (the counters live in state.G, reset in gameplay).
function drawTapHint() {
const p0 = proj(P.x, 0, P.z);
const ax = Math.round(p0.x) + 20, ay = Math.round(p0.y) + 14; // beside his feet, on the apron
const t = (G.idleHintT - TUNE.pullHintAfter) % 1.6; // hover · press · hold · lift
const press = t < 0.6 ? 0 : t < 0.75 ? (t - 0.6) / 0.15 : t < 0.95 ? 1 : Math.max(0, 1 - (t - 0.95) / 0.2);
ctx.save(); ctx.globalAlpha = 0.9;
if (t >= 0.72 && t < 1.32) drawTapRing(ax, ay, (t - 0.72) / 0.6);
drawGhostHand(ax, ay, press);
ctx.font = F(8); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText('TAP TO TOSS', ax - 20, ay + 26, UI.warm);
shadowText('TAP AGAIN AT THE TOP', ax - 20, ay + 38, UI.warm);
ctx.restore();
}
// two dropped tosses in a row: the hand rides the ball itself and presses exactly at the apex,
// so the TIMING is the thing being shown, not just the verb
function drawApexHint() {
const b = proj(ball.x, ball.y, ball.z);
const apex = TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity);
const press = Math.max(0, 1 - Math.abs(ball.y - apex) / 0.45);
ctx.save(); ctx.globalAlpha = 0.9;
if (press > 0.55) drawTapRing(b.x + 4, b.y + 4, 1 - press); // tight at the apex, blooming as it passes
drawGhostHand(b.x + 4, b.y + 4, press);
ctx.font = F(8); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText('TAP AT THE TOP', b.x, b.y - 24, UI.warm);
ctx.restore();
}
// two returns passed untouched: the hand presses harder as the ball closes on the player
function drawHitHint() {
const p0 = proj(P.x, 0, P.z);
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the same distance trySwing tests
const press = Math.max(0, Math.min(1, 1 - (d - 1.6) / 2.2));
const hx = Math.round(p0.x) + 26, hy = Math.round(p0.y) - 52; // over his shoulder, off the sprite
ctx.save(); ctx.globalAlpha = 0.9;
if (press >= 1) drawTapRing(hx, hy, (G.idleT * 2) % 1);
drawGhostHand(hx, hy, press);
ctx.font = F(8); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText('TAP AS IT ARRIVES', hx - 26, hy + 34, UI.warm);
ctx.restore();
}
// The modal: drawn LAST, over everything, and the world behind it is frozen (gameplay.update
// early-returns). Unlike drawCard this is a wall — nothing moves until it is tapped away.
// easeOutBack: overshoots past the target and settles. The arcade snap — nothing in a cabinet
@@ -1682,6 +1762,7 @@ export function render() {
// after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words —
// so this slot shuts up rather than printing the same instruction a second time 40 px below it
const demo = G.mode === 'splatter' && !G.interlude && !gunLike && G.idleHintT > TUNE.pullHintAfter;
const tapDemo = G.mode === 'tennis' && G.serveNum !== 2 && G.idleHintT > TUNE.pullHintAfter;
const txt = G.mode === 'splatter' && !G.interlude
? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`)
: G.aim ? 'PULL DOWN: FURTHER = HARDER'
@@ -1689,10 +1770,17 @@ export function render() {
: demo ? ''
: touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN TO AIM · SPACE SERVES')
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
: tapDemo ? '' // the ghost hand says it in mime and in its own words
: (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE');
if (txt) shadowText(txt, W / 2, METER_Y(), UI.warm); // same slot the draw meter uses
if (demo) drawPullHint(); // show the gesture instead of only naming it
if (tapDemo) drawTapHint();
}
// the tennis struggle hints: each rides the exact failure it answers (see drawTapHint's block)
if (G.mode === 'tennis' && score.server === 'P' && G.phase === 'toss' && G.tossDrops >= 2 && ball.active)
drawApexHint();
if (G.mode === 'tennis' && G.phase === 'rally' && G.passStreak >= 2 && ball.active && ball.lastHit === 'O')
drawHitHint();
// The free-floating register: no panel, one fixed slot, shouted. PERFECT, GAME N, DOUBLE KILL,
// RETURNED, CHANGEOVER, weapon labels — everything that used to be plain white 12px text sitting
// directly on the crowd. It keeps its 1px jitter; the shadow is what makes it legible anywhere.
+4
View File
@@ -79,6 +79,10 @@ export const G = {
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
// pull gesture demonstrates itself (render.drawPullHint)
meleeT: 0, // s of racket-swish cooldown left
rackGlow: null, // {slot, t} — a freshly gathered barrel weapon pulses its rack tile briefly
// instead of equipping itself (popBarrel keeps your current selection)
tossDrops: 0, // consecutive DROPPED tosses (tennis) — ≥2 shows the ghost-hand apex hint
passStreak: 0, // consecutive rally balls that passed untouched (tennis) — ≥2 shows TAP TO HIT
// ---- the sidestep. The movement block is 'rally'-only on purpose, so this integrates OUTSIDE it
// and is the only way to move in the serve stance (which is where a boss fight is spent).
dodgeDir: 0, // -1 | 0 | 1 — which way the current step is going, 0 = standing
+22 -14
View File
@@ -6,7 +6,7 @@
import { TUNE, UI, COURT, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal, endBurst } from './gameplay.js';
import { splatPoint, cheer, spentBall, resolveInterlude, endBossFight, aside, grantWeapon, endBurst } from './gameplay.js';
import { tally, multiKill, endRun } from './stats.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
@@ -89,10 +89,13 @@ function pickBarrelWeapon() {
// upside — it never attacks, never scores, and rolls off on its own if you ignore it.
export function spawnBarrel() {
spawnZombie((Math.random() * 2 - 1) * 3.2, 4.5 + Math.random() * 3, 'o');
// a CARD, not a modal: it lands at a game-over beat that can have a standing wave on court, and
// a world-stopping popup there is exactly the "popups get me killed" report. Same for the
// machine's and the racket zombie's introductions below.
if (!G.seen.barrel) {
G.seen.barrel = true;
showModal({ title: 'A GLOWING BARREL', color: UI.pink,
body: ['THERE IS A WEAPON INSIDE IT', 'SHOOT IT AND IT IS YOURS', 'PINK MEANS TAKE THE SHOT'] });
G.card = { title: 'A GLOWING BARREL', sub1: 'A WEAPON IS INSIDE — SHOOT IT',
sub2: 'PINK MEANS TAKE THE SHOT', color: UI.pink, t: 2.6 };
}
}
@@ -103,8 +106,8 @@ export function spawnMachine() {
voice('ump_machine', 0.4, 0.95);
if (!G.seen.mach) {
G.seen.mach = true;
showModal({ title: 'A BALL MACHINE', color: UI.warm,
body: ['IT FIRES AT YOU ON A TIMER', 'SWING TO RETURN THE SHOT', 'OR SWISH TO BAT IT AWAY'] });
G.card = { title: 'A BALL MACHINE', sub1: 'IT FIRES AT YOU ON A TIMER',
sub2: 'SWING TO RETURN — OR SWISH IT AWAY', color: UI.warm, t: 2.6 };
}
}
@@ -349,19 +352,24 @@ const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m
// one synchronous frame: nothing interleaves between the stamp and the kill it causes.
let CAUSE = 'ball';
// A barrel bursts and hands you what was inside, armed. Pink debris rather than gore — it is a
// container, not a body — and the modal only fires the FIRST time you see each weapon, so the tenth
// rocket does not stop the world to explain itself.
// A barrel bursts and hands you what was inside. Pink debris rather than gore — it is a container,
// not a body. It does NOT equip itself: shooting a barrel mid-plan used to yank the gun into your
// hand and the next serve fired the wrong thing — the current weapon stays live and the new slot
// pulses pink instead (render.drawRack reads G.rackGlow). The announcement is a CARD, never a
// modal: a barrel pops mid-combat, and a popup that stops the world there costs lives, not reads.
function popBarrel(zb) {
boom(0.7); G.shake = { t: 0.22, mag: 4 }; G.boomFlash = 0.08;
for (let k = 0; k < 30; k++) parts.push({
x: zb.x, y: 0.7, z: zb.z,
vx: (Math.random() * 2 - 1) * 4, vy: 1.2 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 4,
t: 0.35 + Math.random() * 0.5, pink: true });
grantWeapon(zb.wid);
// first sight only — the gate lives in weaponModal, for both callers — and the flash keeps quiet
// when the modal spoke, so the same weapon isn't named twice in the same beat
if (!weaponModal(zb.wid)) G.flash = { text: WEAPONS[zb.wid].label, t: 1.1 };
const keep = G.weapon;
const got = grantWeapon(zb.wid);
G.weapon = keep;
G.rackGlow = { slot: got, t: 2.2 };
const w = WEAPONS[zb.wid];
G.card = { title: w.label, sub1: (w.desc && w.desc[0]) || '', sub2: 'TAP ITS RACK SLOT TO ARM IT',
icon: w.icon, color: UI.pink, t: TUNE.tinCardT };
}
// dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop).
@@ -671,8 +679,8 @@ export function spawnRacketZombie() { // one takes up residence on the far court
hp, hpMax: hp });
if (!G.seen.racket) { // it is not a shambler and it does not behave like one — say so, once
G.seen.racket = true;
showModal({ title: 'IT HAS A RACKET', color: UI.hot,
body: ['IT RETURNS YOUR WEAK SHOTS', 'ONLY A FULL PULL WOUNDS IT'] });
G.card = { title: 'IT HAS A RACKET', sub1: 'IT RETURNS YOUR WEAK SHOTS',
sub2: 'ONLY A FULL PULL WOUNDS IT', color: UI.hot, t: 2.6 };
}
}
function endHorde() {