diff --git a/HANDOFF.md b/HANDOFF.md index edadee7..bae964e 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-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.` 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 diff --git a/assets/audio/weapon-click2.mp3 b/assets/audio/weapon-click2.mp3 new file mode 100644 index 0000000..f09cdbc Binary files /dev/null and b/assets/audio/weapon-click2.mp3 differ diff --git a/audio.js b/audio.js index ef52e51..29d9b52 100644 --- a/audio.js +++ b/audio.js @@ -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); }) diff --git a/config.js b/config.js index 358b06b..7d6e976 100644 --- a/config.js +++ b/config.js @@ -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) diff --git a/gameplay.js b/gameplay.js index 606d4d1..ff1ce42 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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 diff --git a/index.html b/index.html index b33210c..46ad05a 100644 --- a/index.html +++ b/index.html @@ -359,6 +359,6 @@ - + diff --git a/main.js b/main.js index 3095d5e..6e97cfd 100644 --- a/main.js +++ b/main.js @@ -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(); } }); diff --git a/render.js b/render.js index be76ebd..f854b57 100644 --- a/render.js +++ b/render.js @@ -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. diff --git a/state.js b/state.js index 95451a3..97845e3 100644 --- a/state.js +++ b/state.js @@ -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 diff --git a/zombies.js b/zombies.js index 778dec1..270f479 100644 --- a/zombies.js +++ b/zombies.js @@ -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() {