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:
1 parent
f13cbef28a
commit
a89e8ff419
10 files changed
+242
-79
No files matched your search
+62
-4
@@ -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
|
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
|
||||||
detail of any individual change, read the git log.
|
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
|
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
|
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
|
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 +
|
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 ·
|
`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 ·
|
`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,
|
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).
|
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
|
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,
|
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
|
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.
|
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
|
**`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.
|
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
|
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
|
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
|
||||||
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
|
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
|
||||||
|
|
||||||
|
## Session log: the 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)
|
## 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.
|
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
|
## TEST MODE — triple-tap the scoreboard
|
||||||
|
|
||||||
A sandbox for trying new content without playing twenty games to reach it. Three taps on the
|
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.
|
`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
|
- 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`.
|
`T.flickTarget`, `T.showModal`/`T.dismissModal`, `T.render`.
|
||||||
|
|
||||||
**Benchmarks that must keep holding** (re-run after any physics/aim change):
|
**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.
|
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
|
**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
|
counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU
|
||||||
|
|||||||
Binary file not shown.
@@ -64,7 +64,8 @@ export function sfx(name, vol = 1, opts = {}) {
|
|||||||
src.start();
|
src.start();
|
||||||
};
|
};
|
||||||
if (SFX[name]) return play(SFX[name]);
|
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(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
|
||||||
.then(ab => ac.decodeAudioData(ab))
|
.then(ab => ac.decodeAudioData(ab))
|
||||||
.then(buf => { SFX[name] = buf; play(buf); })
|
.then(buf => { SFX[name] = buf; play(buf); })
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ export const TUNE = {
|
|||||||
// Heavy topspin lets it leave flat and DIVE, which is what a smash actually is.
|
// Heavy topspin lets it leave flat and DIVE, which is what a smash actually is.
|
||||||
aimError: 2.4, // m of scatter at quality 0
|
aimError: 2.4, // m of scatter at quality 0
|
||||||
perfectMs: 45, // |offset from window center| for PERFECT
|
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
|
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)
|
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
|
// ---- 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.
|
// 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
|
// ---- 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)
|
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
|
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
|
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)
|
barrelH: 1.15, // drawn height (a body is 1.8)
|
||||||
|
|||||||
+37
-38
@@ -112,6 +112,7 @@ function zombieTarget(aim) {
|
|||||||
function doHit(ch, quality, 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)
|
// 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';
|
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
|
// snap the ball to the racket at the contact frame so the hit visually connects
|
||||||
ball.x = ch.x + ch.facing * 0.5;
|
ball.x = ch.x + ch.facing * 0.5;
|
||||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
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 };
|
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
|
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;
|
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; }
|
if (perfect) { praise(); G.hitstop = 0.05; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +404,7 @@ function pointTo(side, reason) {
|
|||||||
const n = G.rallyNear;
|
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`,
|
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) });
|
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);
|
const gameOver = advancePoint(side);
|
||||||
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
|
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
|
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
|
||||||
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
|
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
|
||||||
spawnMachine();
|
spawnMachine();
|
||||||
// ...and a weapon barrel on its own schedule. One at a time: two glowing pink things on court at
|
// ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp),
|
||||||
// once would out-shout everything the palette uses to say "this one is dangerous".
|
// because the late game is where the rack has to carry you. Still one at a time: two glowing
|
||||||
if (side === 'P' && G.runGames % TUNE.barrelEvery === 0
|
// 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)
|
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
|
||||||
spawnBarrel();
|
spawnBarrel();
|
||||||
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
|
// 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) {
|
export function selectWeapon(i) {
|
||||||
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
|
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
|
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
|
// 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;
|
if (wasPlain) G.weapon = 0;
|
||||||
sendBoy(); voice('new_balls', 0.6);
|
sendBoy(); voice('new_balls', 0.6);
|
||||||
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
|
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
|
// THE PRIZE GETS ITS OWN CARD, with the tin's own icon on it — the announcement AND the whole
|
||||||
// same channel as PERFECT and DOUBLE KILL — so the one thing you played the duel FOR arrived as a
|
// explanation. The first-sight weapon MODAL is gone (with popBarrel's): the testteam never read
|
||||||
// word in the praise slot and read as another compliment. The first time a tin is seen the modal
|
// them, and a popup that freezes a live court mid-run costs runs. The card carries the one desc
|
||||||
// announces it (and teaches its rules), so the card would be the SAME news twice, back to back —
|
// line that matters and does not want a tap: you have just won a point, not been asked a question.
|
||||||
// it only speaks when the modal doesn't. Every time after, the card is the announcement, and it
|
G.card = { title: WEAPONS[id].label, sub1: 'DELIVERED TO THE RACK',
|
||||||
// does not want a tap: you have just won a point, not been asked a question.
|
sub2: (WEAPONS[id].desc && WEAPONS[id].desc[0]) || '',
|
||||||
if (!weaponModal(id))
|
icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing
|
// ---- 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, () => {
|
once('go', 8.2, () => {
|
||||||
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
|
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
|
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
|
// NO modal here anymore — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches
|
||||||
// to TEACH the gesture, not just announce it
|
// the pull instead: prime the idle clock so the apron demo starts miming IMMEDIATELY rather
|
||||||
showModal({ title: 'THE MATCH HAS CHANGED', color: UI.hot, icon: 'x_howto', iconH: 120,
|
// than after the usual beat of inaction, and it keeps coming back whenever the player stalls.
|
||||||
body: ['PULL BACK TO AIM — THEN LET GO', 'THE FURTHER YOU PULL, THE HARDER IT HITS',
|
G.idleHintT = TUNE.pullHintAfter;
|
||||||
'A WEAK SHOT ONLY STAGGERS THEM', 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY'] });
|
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,6 +1446,7 @@ export function update(dt) {
|
|||||||
if (G.mode !== 'tennis') updateZombies(dt);
|
if (G.mode !== 'tennis') updateZombies(dt);
|
||||||
tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
|
tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
|
||||||
if (G.meleeT > 0) G.meleeT -= dt;
|
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);
|
watchdog(dt);
|
||||||
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
|
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
|
||||||
if (G.mode === 'transition') transition(dt);
|
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
|
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;
|
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0;
|
||||||
if (near) {
|
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 {
|
else {
|
||||||
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
|
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
|
||||||
trySwing(O, true); O.swingT = -1;
|
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)
|
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';
|
ball.active = false; G.phase = 'pretoss';
|
||||||
if (score.server === 'O') G.aiServeT = 0.8;
|
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
|
// 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.
|
// 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
|
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),
|
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
|
if (!G.seen.horde) { // so its FIRST arrival gets a real block...
|
||||||
body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'],
|
G.seen.horde = true;
|
||||||
onDismiss: startHorde });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into
|
// auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into
|
||||||
|
|||||||
+1
-1
@@ -359,6 +359,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="main.js?v=127"></script>
|
<script type="module" src="main.js?v=128"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
||||||
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
||||||
|
|
||||||
import { TUNE, 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 { 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 { 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';
|
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
|
||||||
@@ -268,9 +268,11 @@ const maybeRestart = () => {
|
|||||||
cv.addEventListener('pointerdown', maybeRestart);
|
cv.addEventListener('pointerdown', maybeRestart);
|
||||||
addEventListener('keydown', maybeRestart);
|
addEventListener('keydown', maybeRestart);
|
||||||
|
|
||||||
// hidden shortcuts: three taps on the UMPIRE summons zombie mode early, three on the SCOREBOARD
|
// hidden shortcut: three taps on the SCOREBOARD opens test mode (a medium wave plus every barrel
|
||||||
// opens test mode (a medium wave plus every barrel weapon — see gameplay.toggleTestMode)
|
// weapon — see gameplay.toggleTestMode). The umpire triple-tap (skip to zombie mode) is REMOVED:
|
||||||
let umpTaps = [], boardTaps = [];
|
// 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 => {
|
cv.addEventListener('pointerdown', e => {
|
||||||
const r = cv.getBoundingClientRect();
|
const r = cv.getBoundingClientRect();
|
||||||
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
|
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
|
// overlay on behind you
|
||||||
toggleTestMode();
|
toggleTestMode();
|
||||||
} else G.logShow = !G.logShow; // a single tap is still the miss-log overlay
|
} 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(); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
|
||||||
|
|
||||||
import { cv, ctx } from './dom.js';
|
import { cv, ctx } from './dom.js';
|
||||||
import { 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 { P, O, ball, score, crowd, G, court, tour } from './state.js';
|
||||||
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
||||||
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.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 sel = G.weapon === i, dim = stockI <= 0;
|
||||||
const label = i === 0 && !temp ? 'BALL' : w ? w.label : '';
|
const label = i === 0 && !temp ? 'BALL' : w ? w.label : '';
|
||||||
const icon = i === 0 && !temp ? 'w_ball' : w ? w.icon : null;
|
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,
|
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
|
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
|
// icon: left, vertically centred, padded off the edge
|
||||||
const box = g.h - pad * 2, icx = x + pad + box / 2, icy = y0 + g.h / 2;
|
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
|
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);
|
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.
|
// 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 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
|
// 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
|
// 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.
|
// 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);
|
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.globalAlpha = gone ? 0 : 0.9;
|
||||||
ctx.fillStyle = UI.cream;
|
drawGhostHand(ax + dx * len, ay + dy * len, 1);
|
||||||
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();
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1246,6 +1281,51 @@ function drawPullHint() {
|
|||||||
demoSwipe(dir, 0, 'SWIPE TO DODGE');
|
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
|
// 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.
|
// 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
|
// 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 —
|
// 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
|
// 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 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
|
const txt = G.mode === 'splatter' && !G.interlude
|
||||||
? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`)
|
? (gunLike ? (touch ? `TAP TO UNLOAD THE ${sel}` : `SPACE UNLOADS THE ${sel}`)
|
||||||
: G.aim ? 'PULL DOWN: FURTHER = HARDER'
|
: G.aim ? 'PULL DOWN: FURTHER = HARDER'
|
||||||
@@ -1689,10 +1770,17 @@ export function render() {
|
|||||||
: demo ? ''
|
: demo ? ''
|
||||||
: touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN TO AIM · SPACE SERVES')
|
: touch ? 'PULL DOWN TO AIM · RELEASE TO FIRE' : 'DRAG DOWN TO AIM · SPACE SERVES')
|
||||||
: G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
|
: 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');
|
: (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 (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 (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,
|
// 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
|
// 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.
|
// directly on the crowd. It keeps its 1px jitter; the shadow is what makes it legible anywhere.
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export const G = {
|
|||||||
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
|
idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the
|
||||||
// pull gesture demonstrates itself (render.drawPullHint)
|
// pull gesture demonstrates itself (render.drawPullHint)
|
||||||
meleeT: 0, // s of racket-swish cooldown left
|
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
|
// ---- 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).
|
// 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
|
dodgeDir: 0, // -1 | 0 | 1 — which way the current step is going, 0 = standing
|
||||||
|
|||||||
+22
-14
@@ -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 { 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 { 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 { 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';
|
import { tally, multiKill, endRun } from './stats.js';
|
||||||
|
|
||||||
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
|
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.
|
// upside — it never attacks, never scores, and rolls off on its own if you ignore it.
|
||||||
export function spawnBarrel() {
|
export function spawnBarrel() {
|
||||||
spawnZombie((Math.random() * 2 - 1) * 3.2, 4.5 + Math.random() * 3, 'o');
|
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) {
|
if (!G.seen.barrel) {
|
||||||
G.seen.barrel = true;
|
G.seen.barrel = true;
|
||||||
showModal({ title: 'A GLOWING BARREL', color: UI.pink,
|
G.card = { title: 'A GLOWING BARREL', sub1: 'A WEAPON IS INSIDE — SHOOT IT',
|
||||||
body: ['THERE IS A WEAPON INSIDE IT', 'SHOOT IT AND IT IS YOURS', 'PINK MEANS TAKE THE SHOT'] });
|
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);
|
voice('ump_machine', 0.4, 0.95);
|
||||||
if (!G.seen.mach) {
|
if (!G.seen.mach) {
|
||||||
G.seen.mach = true;
|
G.seen.mach = true;
|
||||||
showModal({ title: 'A BALL MACHINE', color: UI.warm,
|
G.card = { title: 'A BALL MACHINE', sub1: 'IT FIRES AT YOU ON A TIMER',
|
||||||
body: ['IT FIRES AT YOU ON A TIMER', 'SWING TO RETURN THE SHOT', 'OR SWISH TO BAT IT AWAY'] });
|
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.
|
// one synchronous frame: nothing interleaves between the stamp and the kill it causes.
|
||||||
let CAUSE = 'ball';
|
let CAUSE = 'ball';
|
||||||
|
|
||||||
// A barrel bursts and hands you what was inside, armed. Pink debris rather than gore — it is a
|
// A barrel bursts and hands you what was inside. Pink debris rather than gore — it is a container,
|
||||||
// container, not a body — and the modal only fires the FIRST time you see each weapon, so the tenth
|
// not a body. It does NOT equip itself: shooting a barrel mid-plan used to yank the gun into your
|
||||||
// rocket does not stop the world to explain itself.
|
// 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) {
|
function popBarrel(zb) {
|
||||||
boom(0.7); G.shake = { t: 0.22, mag: 4 }; G.boomFlash = 0.08;
|
boom(0.7); G.shake = { t: 0.22, mag: 4 }; G.boomFlash = 0.08;
|
||||||
for (let k = 0; k < 30; k++) parts.push({
|
for (let k = 0; k < 30; k++) parts.push({
|
||||||
x: zb.x, y: 0.7, z: zb.z,
|
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,
|
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 });
|
t: 0.35 + Math.random() * 0.5, pink: true });
|
||||||
grantWeapon(zb.wid);
|
const keep = G.weapon;
|
||||||
// first sight only — the gate lives in weaponModal, for both callers — and the flash keeps quiet
|
const got = grantWeapon(zb.wid);
|
||||||
// when the modal spoke, so the same weapon isn't named twice in the same beat
|
G.weapon = keep;
|
||||||
if (!weaponModal(zb.wid)) G.flash = { text: WEAPONS[zb.wid].label, t: 1.1 };
|
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).
|
// 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 });
|
hp, hpMax: hp });
|
||||||
if (!G.seen.racket) { // it is not a shambler and it does not behave like one — say so, once
|
if (!G.seen.racket) { // it is not a shambler and it does not behave like one — say so, once
|
||||||
G.seen.racket = true;
|
G.seen.racket = true;
|
||||||
showModal({ title: 'IT HAS A RACKET', color: UI.hot,
|
G.card = { title: 'IT HAS A RACKET', sub1: 'IT RETURNS YOUR WEAK SHOTS',
|
||||||
body: ['IT RETURNS YOUR WEAK SHOTS', 'ONLY A FULL PULL WOUNDS IT'] });
|
sub2: 'ONLY A FULL PULL WOUNDS IT', color: UI.hot, t: 2.6 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function endHorde() {
|
function endHorde() {
|
||||||
|
|||||||
Reference in new issue
Block a user