diff --git a/config.js b/config.js index 2cd565e..a66e201 100644 --- a/config.js +++ b/config.js @@ -35,6 +35,7 @@ export const TUNE = { hordeLen: 12, // s a horde wave lasts hordeRate: 2.5, // zombies spawned per second during a horde mgRate: 6, // machine-gun pellets fired per second during a horde + mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble bruteHp: 2, // splat-quality hits (or pellets) a brute ('g') soaks before it dies shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window diff --git a/gameplay.js b/gameplay.js index 8164b9e..eea023a 100644 --- a/gameplay.js +++ b/gameplay.js @@ -4,7 +4,7 @@ import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DE import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; import { audio, pock, swish, applause, voice } from './audio.js'; import { axisX, axisZ, takeFlickAim } from './input.js'; -import { updateZombies, spawnZombie, zombies, explode, meleeSwipe } from './zombies.js'; +import { updateZombies, spawnZombie, zombies, explode, meleeSwipe, extras, parts } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. @@ -79,7 +79,7 @@ function doHit(ch, quality, aim) { G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return ball.pow = quality; // carried pace: zombies splatter only above zSplatQ ball.kills = 0; // fresh shot: multi-kill streak restarts - ball.bomb = false; // only a bomb-consuming serve is a bomb; any other hit clears it + ball.bomb = false; ball.flame = false; // only a consuming serve arms these; any other hit clears them if (ch.side === 'P') { // shot marker: where this will land — red means it's going out const ok = G.mode !== 'tennis' || (Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ); G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 }; @@ -121,8 +121,19 @@ 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 - ball.bomb = G.mode === 'splatter' && G.bombs > 0; // auto-consume a stocked bomb ball + // the armed tin auto-loads the serve — one-thumb rule, no weapon-select input + ball.bomb = G.mode === 'splatter' && G.bombs > 0; if (ball.bomb) G.bombs--; + ball.flame = G.mode === 'splatter' && !ball.bomb && G.flames > 0; + if (ball.flame) G.flames--; + if (G.mode === 'splatter' && 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); + extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, + vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c }); + } + } if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 }; G.phase = 'rally'; pock(1, 1100); sv.pose = 'serve'; sv.poseT = 0; @@ -213,11 +224,24 @@ function startChangeover() { if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery G.tinDue = false; if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds - else { G.bombs = TUNE.bombMax; voice('new_balls', 0.6); G.flash = { text: 'NEW BALLS', t: 1.6 }; } + else deliverTin(); G.shameGame = false; // the slate is clean either way } 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 + sendBoy(); voice('new_balls', 0.6); + G.flash = { text: 'NEW BALLS: ' + ['BOMB', 'TRIPLE', 'FIRE', 'GUN'][w], t: 1.6 }; +} + // retire the splatter ball; a running changeover's long beat must not be clobbered short export function spentBall() { ball.active = false; @@ -434,7 +458,7 @@ export function keyPressed(code) { if (code !== 'Space') return; audio(); // unlock on first gesture if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers - if (G.horde) return; // horde: the racket auto-fires; a tap only swishes, never serves + if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves if (G.changeover) return; // the breather is mandatory — the melee swish above still works // 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(); } @@ -495,7 +519,7 @@ export function update(dt) { P.x += mx * TUNE.playerSpeed * slow * dt; P.z += mz * TUNE.playerSpeed * slow * dt; let assist = 0; - if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde) { // horde: you aim by hand, no assist + if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist const chasing = ball.lastHit === 'O'; // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55); @@ -575,6 +599,9 @@ export function update(dt) { // --- ball physics if (ball.active) { + if (ball.flame) parts.push({ // fire trail: one ember per frame, brief and rising + x: ball.x, y: ball.y, z: ball.z, t: 0.22, fire: true, + vx: (Math.random() * 2 - 1) * 0.6, vy: 0.8, vz: (Math.random() * 2 - 1) * 0.6 }); ball.vy -= (ball.g || TUNE.gravity) * dt; const oldZ = ball.z; ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt; @@ -637,7 +664,10 @@ export function update(dt) { G.overT -= dt; if (G.overT <= 0) { if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes - setupPoint(); + if (G.mgT > 0) { // a GUN tin was delivered: no serving, the racket fires itself, drag steers + G.phase = 'rally'; ball.active = false; + P.pose = 'idle'; P.poseT = 0; P.swingT = -1; + } else setupPoint(); } } } diff --git a/main.js b/main.js index 8a5c621..edbad95 100644 --- a/main.js +++ b/main.js @@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint } from './state.js'; import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint } from './gameplay.js'; import { fitViewport, render, proj } from './render.js'; import { voice } from './audio.js'; -import { zombies, spawnZombie, piles, gore, parts, pellets, explode } from './zombies.js'; +import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js'; import { cv } from './dom.js'; import './input.js'; @@ -54,6 +54,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, extras, explode, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index 0b75626..8a6597d 100644 --- a/render.js +++ b/render.js @@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { P, O, ball, score, crowd, G } from './state.js'; import { predictLanding } from './gameplay.js'; -import { zombies, gore, piles, parts, pellets } from './zombies.js'; +import { zombies, gore, piles, parts, pellets, extras } from './zombies.js'; // ---------------------------------------------------------------- palette ---- // tennis palette -> drained splatter palette; the transition lerps between them @@ -91,7 +91,7 @@ function drawShadow(px, py, rx, ry, alpha = 0.28) { ctx.fillStyle = `rgba(0,0,0,${alpha})`; ctx.beginPath(); ctx.ellipse(px, py, rx, Math.max(1, ry), 0, 0, 7); ctx.fill(); } -function drawBall(x, y, z, glint = false, bomb = false) { +function drawBall(x, y, z, glint = false, bomb = false, flame = false) { const sh = proj(x, 0, z); drawShadow(sh.x, sh.y, Math.max(1.5, sh.s * 0.09), sh.s * 0.04, 0.3); const bp = proj(x, y, z); @@ -101,6 +101,11 @@ function drawBall(x, y, z, glint = false, bomb = false) { if (Math.floor(G.idleT * 20) % 2) { ctx.fillStyle = '#ff8010'; ctx.fillRect(bp.x - 0.5, bp.y - r - 1, Math.max(1, r * 0.5), Math.max(1, r * 0.6)); } return; } + if (flame) { // white-hot core in an orange shell, flickering + ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r + (Math.floor(G.idleT * 24) % 2 ? 1 : 0), 0, 7); ctx.fill(); + ctx.fillStyle = '#ffe8a0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.5), 0, 7); ctx.fill(); + return; + } ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill(); if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); } } @@ -228,7 +233,8 @@ function drawMgRacket() { // horde: the player braces the racket forward and up- ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke(); ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward ctx.beginPath(); ctx.ellipse(tip.x, tip.y, Math.max(2, tip.s * 0.14), Math.max(3, tip.s * 0.22), 0, 0, 7); ctx.stroke(); - if (G.horde && G.horde.mgT > 1 / TUNE.mgRate - 0.06) { // muzzle flash right after a shot + const cool = G.horde ? G.horde.mgT : G.mgCool; // horde or GUN-tin pickup — same racket + if (cool > 1 / TUNE.mgRate - 0.06) { // muzzle flash right after a shot ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(tip.x, tip.y, Math.max(2, tip.s * 0.12), 0, 7); ctx.fill(); } } @@ -318,12 +324,14 @@ export function render() { const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * BOUNCE_IDLE.apex; drawBall(bx, by, sv.z); } - // the match ball - if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb); - // splatter particles + // the match ball (and any triple-serve side balls in flight) + if ((ball.active || G.phase === 'over') && !ball.collected) drawBall(ball.x, ball.y, ball.z, true, ball.bomb, ball.flame); + for (const e of extras) drawBall(e.x, e.y, e.z); + // splatter particles (fire embers ride the same array) for (const pt of parts) { const pp = proj(pt.x, pt.y, pt.z); - ctx.fillStyle = pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)'; + ctx.fillStyle = pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010') + : pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)'; ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2); } // machine-gun pellets: flat tracers with a tiny ground shadow @@ -333,7 +341,7 @@ export function render() { ctx.fillStyle = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2); } if (G.mode !== 'eaten') drawChar(P); // there is no P anymore - if (G.horde && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player + if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,255,255,${Math.min(1, G.boomFlash / 0.08) * 0.85})`; ctx.fillRect(0, 0, W, H); } @@ -352,6 +360,18 @@ export function render() { ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(96 + b * 8, 14, 2.6, 0, 7); ctx.fill(); ctx.strokeStyle = '#ff8010'; ctx.lineWidth = 0.75; ctx.stroke(); } + for (let b = 0; b < G.triples; b++) { // triple serves: a yellow pip flanked by two dots + ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(96 + b * 8, 14, 2.6, 0, 7); ctx.fill(); + ctx.fillRect(96 + b * 8 - 4, 13, 1.5, 1.5); ctx.fillRect(96 + b * 8 + 3, 13, 1.5, 1.5); + } + for (let b = 0; b < G.flames; b++) { // flame balls: orange pip, hot core + ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(96 + b * 8, 14, 2.6, 0, 7); ctx.fill(); + ctx.fillStyle = '#ffe8a0'; ctx.fillRect(96 + b * 8 - 1, 13, 2, 2); + } + if (G.mgT > 0) { // machine-gun time: a draining ammo bar + ctx.fillStyle = '#333'; ctx.fillRect(96, 11, 34, 5); + ctx.fillStyle = '#f8e048'; ctx.fillRect(96, 11, 34 * Math.min(1, G.mgT / TUNE.mgPickupLen), 5); + } ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc'; ctx.fillText((G.mode === 'tennis' ? 'CPU ' : 'THEM ') + setsTxt(1) + score.games[1] + ' ' + ptTxt(1), 12, 22); ctx.textAlign = 'center'; @@ -367,7 +387,7 @@ export function render() { } if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 28); } } - if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde) { + if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; const touch = matchMedia('(pointer: coarse)').matches; const txt = G.mode === 'splatter' diff --git a/state.js b/state.js index 8758b0e..977ba84 100644 --- a/state.js +++ b/state.js @@ -5,7 +5,7 @@ import { HOME } from './config.js'; export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2, - active: false, lastHit: 'P', bounces: 0, firstLand: null, collected: false, kills: 0, bomb: false }; + active: false, lastHit: 'P', bounces: 0, firstLand: null, collected: false, kills: 0, bomb: false, flame: false }; export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.buildCrowd @@ -32,7 +32,12 @@ export const G = { aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction wasServe: false, // the ball in flight is a serve (first bounce must hit the box) splatT: 0, // time survived in splatter mode — drives the difficulty ramp - bombs: 0, // bomb-ball stock (max TUNE.bombMax); the next serve auto-consumes one + bombs: 0, // bomb-ball stock; the next serve auto-consumes one (delivery 1 of the ladder) + triples: 0, // triple-serve stock — a serve fans into three balls (delivery 2) + flames: 0, // flame-ball stock — kills on any touch, full pierce (delivery 3) + mgT: 0, // s of machine-gun time left (delivery 4) — auto-fire, no serving + mgCool: 0, // s until the next pickup-mode pellet + deliveries: 0, // tins delivered this run — indexes the weapon ladder tinDue: false, // the rules called for new balls — deliver at the next changeover horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving runGames: 0, // games finished this run (either side) — the difficulty director diff --git a/zombies.js b/zombies.js index 69e8ccf..8075086 100644 --- a/zombies.js +++ b/zombies.js @@ -13,6 +13,7 @@ export const gore = []; // lawn decals {x, z, r, a} — the flat ground laye export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark} export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t} +export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow, t} — ballistic clones let spawnT = 1.5, moanT = 3; @@ -135,7 +136,12 @@ export function updateZombies(dt) { if (G.mode === 'splatter') { // transition spawns are scripted, not timed G.splatT += dt; if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap - else if (!G.changeover) { // changeovers are a real breather — no fresh spawns + else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules + G.mgT -= dt; + if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; } + if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes + } + if (!G.horde && !G.changeover) { // changeovers are a real breather — no fresh spawns spawnT -= dt; // the spawn director: the match's age (games finished) sets both crowd size and tempo const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames); @@ -166,7 +172,7 @@ export function updateZombies(dt) { splat(1); voice('game_set_massacre', 0.8); G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience - G.horde = null; pellets.length = 0; // the swarm stops; the eaten screen takes over + G.horde = null; pellets.length = 0; extras.length = 0; G.mgT = 0; // everything stops; the eaten screen takes over try { // the run is over — settle it against the best (games survived, sets as tiebreak) const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 }; if (G.runGames > b.games) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); } @@ -180,6 +186,15 @@ export function updateZombies(dt) { explode(ball.x, ball.z); ball.bomb = false; spentBall(); break; // zombies mutated — stop this frame's loop } + if (ball.flame) { // burns through anything it touches — no quality gate, no chair, no pace tax + if (zb.variant === 'r') { // the boss only takes a wound + zb.hp--; splat(0.5); + if (zb.hp <= 0) { splatter(i); } continue; + } + const vx0 = ball.vx, vz0 = ball.vz; + splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane + continue; + } if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI) if ((ball.pow || 0) >= TUNE.zSplatQ) { zb.hp--; splat(0.5); pock(0.6, 300); @@ -229,6 +244,27 @@ export function updateZombies(dt) { if (hit || pe.t <= 0) pellets.splice(i, 1); } + for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones + const e = extras[i]; + e.t -= dt; e.vy -= TUNE.gravity * dt; + e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt; + if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; } + let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5; + if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) { + const zb = zombies[j]; + if (zb.variant === 'r') continue; // side balls pass the boss — only the match ball duels it + if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)) { + if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too + else if (e.pow >= TUNE.zSplatQ) { + if (zb.variant === 'g' && --zb.hp > 0) splat(0.4); // wounds the brute + else killZombie(j, !!G.horde); + } else { zb.z -= TUNE.zPushback; pock(0.5, 300); } + dead = true; break; // a side ball is spent on its first touch + } + } + if (dead) extras.splice(i, 1); + } + for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; p.t -= dt; p.vy -= 14 * dt;