diff --git a/assets/_sheet_scoreboard.png b/assets/_sheet_scoreboard.png new file mode 100644 index 0000000..95bbca6 Binary files /dev/null and b/assets/_sheet_scoreboard.png differ diff --git a/assets/_sheet_wicons.png b/assets/_sheet_wicons.png new file mode 100644 index 0000000..842663f Binary files /dev/null and b/assets/_sheet_wicons.png differ diff --git a/assets/fonts/Silkscreen-Bold.ttf b/assets/fonts/Silkscreen-Bold.ttf new file mode 100644 index 0000000..6771252 Binary files /dev/null and b/assets/fonts/Silkscreen-Bold.ttf differ diff --git a/assets/fonts/Silkscreen-Regular.ttf b/assets/fonts/Silkscreen-Regular.ttf new file mode 100644 index 0000000..8caff81 Binary files /dev/null and b/assets/fonts/Silkscreen-Regular.ttf differ diff --git a/assets/scoreboard.png b/assets/scoreboard.png new file mode 100644 index 0000000..65a2fa0 Binary files /dev/null and b/assets/scoreboard.png differ diff --git a/assets/w_ball.png b/assets/w_ball.png new file mode 100644 index 0000000..8317d33 Binary files /dev/null and b/assets/w_ball.png differ diff --git a/assets/w_bomb.png b/assets/w_bomb.png new file mode 100644 index 0000000..6f3249e Binary files /dev/null and b/assets/w_bomb.png differ diff --git a/assets/w_fire.png b/assets/w_fire.png new file mode 100644 index 0000000..6dacdfe Binary files /dev/null and b/assets/w_fire.png differ diff --git a/assets/w_gun.png b/assets/w_gun.png new file mode 100644 index 0000000..8dfca59 Binary files /dev/null and b/assets/w_gun.png differ diff --git a/assets/w_triple.png b/assets/w_triple.png new file mode 100644 index 0000000..ae97798 Binary files /dev/null and b/assets/w_triple.png differ diff --git a/config.js b/config.js index 08a9f22..380cc45 100644 --- a/config.js +++ b/config.js @@ -35,6 +35,8 @@ export const TUNE = { ballChangeFirst: 3, // game of the first new-balls delivery (real rule: 7, then every 9 — too sparse here) ballChangeEvery: 4, // games between deliveries after the first bombMax: 3, // bomb balls in a fresh tin + wCap: 3, // per-type cap on the courtside rack — tins accumulate, but only so many + rackH: 22, // px height of the bottom weapon rack (feel knob: how much court it covers) bombRadius: 2.5, // m blast radius of a bomb ball hordeLen: 12, // s a horde wave lasts hordeRate: 2.5, // zombies spawned per second during a horde diff --git a/gameplay.js b/gameplay.js index 44bee01..c58f031 100644 --- a/gameplay.js +++ b/gameplay.js @@ -146,14 +146,15 @@ function hitServe(sv, quality, aim = null) { ball.lastHit = sv.side; ball.bounces = 0; ball.pow = quality; ball.kills = 0; // fresh serve: multi-kill streak restarts - // specials ride only DELIBERATE serves: a flick (or arrow-aimed keyboard serve) commits the - // tin, a plain tap keeps it in stock — so bombs wait for the enemy you mean them for - const armed = G.mode === 'splatter' && !!aim; - ball.bomb = armed && G.bombs > 0; + // the SELECTED tin arms the serve; slot 0 (PLAIN) throws a normal ball and banks your + // specials for later. Placement still follows the flick — only WHICH ball flies is the + // rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve. + const wsel = G.mode === 'splatter' ? G.weapon : 0; + ball.bomb = wsel === 1 && G.bombs > 0; if (ball.bomb) G.bombs--; - ball.flame = armed && !ball.bomb && G.flames > 0; + ball.flame = wsel === 3 && G.flames > 0; if (ball.flame) G.flames--; - if (armed && G.triples > 0) { // one serve, three balls: clones fanned ±6° + if (wsel === 2 && G.triples > 0) { // one serve, three balls: clones fanned ±6° G.triples--; for (const deg of [-6, 6]) { const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r); @@ -161,6 +162,7 @@ function hitServe(sv, quality, aim = null) { vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c }); } } + if (wsel > 0 && slotStock(wsel) <= 0) G.weapon = 0; // that was the last tin — hand back to plain balls if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !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; @@ -265,17 +267,28 @@ function startChangeover() { } else G.flash = { text: 'CHANGEOVER', t: 1.4 }; } -// the weapon ladder: each delivery of the run opens a nastier tin. One tin at a time — -// a fresh delivery replaces any unspent stock (simpler than stacking, and it keeps you spending). -function deliverTin() { - const w = G.deliveries++ % 4; - G.bombs = G.triples = G.flames = 0; G.mgT = 0; - if (w === 0) G.bombs = TUNE.bombMax; - else if (w === 1) G.triples = 3; - else if (w === 2) G.flames = 3; - else G.mgT = TUNE.mgPickupLen; // starts firing when play resumes +// ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is +// infinite plain balls. selectWeapon is the tap/key target — an empty special can't be picked. +export const SLOTS = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN']; +export const slotStock = i => [Infinity, G.bombs, G.triples, G.flames, G.guns][i]; +export function selectWeapon(i) { + if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only + if (i > 0 && slotStock(i) <= 0) return; // dud: no tins of that kind on the rack + G.weapon = i; pock(0.3, 1100); +} + +// the weapon ladder: each delivery opens the next tin in the cycle and ADDS it to the rack +// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde +// instead of spending it now. G.weapon picks which tin the next serve throws. +export function deliverTin() { + const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k); + if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax); + else if (w === 1) G.triples = add(G.triples + 3); + else if (w === 2) G.flames = add(G.flames + 3); + else G.guns = add(G.guns + 1); // one machine-gun charge — a burst you pop when you choose, not on arrival + if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking) sendBoy(); voice('new_balls', 0.6); - G.flash = { text: 'NEW BALLS: ' + ['BOMB', 'TRIPLE', 'FIRE', 'GUN'][w], t: 1.6 }; + G.flash = { text: 'NEW BALLS: ' + SLOTS[w + 1], t: 1.6 }; } // ---- the interlude: order is restored, one sudden-death point vs the racket zombie. @@ -519,11 +532,24 @@ function racketAI(rz, dt) { } } +// GUN in the rack isn't a ball — committing a charge starts an mgT auto-fire burst (the same +// mode a horde uses). With GUN selected, any serve gesture spends one charge instead of serving. +function tryGunBurst() { + if (G.mode !== 'splatter' || G.weapon !== 4 || G.guns <= 0) return false; + G.guns--; if (G.guns <= 0) G.weapon = 0; // spent the last tin — back to plain balls + G.mgT = TUNE.mgPickupLen; G.mgCool = 0; + ball.active = false; G.phase = 'rally'; + P.pose = 'idle'; P.poseT = 0; P.swingT = -1; + G.flash = { text: 'GUN', t: 1.0 }; + return true; +} + // release of the two-stage aim: an instant serve to the reticle (quality fixed — placement // is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling) export function dragServe(aim) { if (G.mode !== 'splatter' || G.phase !== 'pretoss' || score.server !== 'P') return; if (G.horde || G.mgT > 0 || G.changeover || (G.interlude && G.interlude.introT > 0)) return; + if (tryGunBurst()) return; ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; hitServe(P, 0.62, aim); @@ -539,6 +565,7 @@ export function keyPressed(code) { // splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } if (G.phase === 'pretoss' && score.server === 'P') { + if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball if (G.mode === 'splatter') { const flick = takeFlickAim(); if (flick) { // flick = a quick aimed serve; a plain tap still does the full toss ritual @@ -654,6 +681,7 @@ export function update(dt) { if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) { zombies[0].dueled = true; // one duel per opponent — after that it just rallies + G.weapon = 0; // tennis rules for the duel: force plain balls, no tins G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return zombieDuck(true); // fade the zombie music out for the 1:1 duel voice('play_resume', 0.2); voice('sudden_death', 2.4); diff --git a/index.html b/index.html index c50e27c..41af5ba 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,11 @@ Splatter Tennis