diff --git a/gameplay.js b/gameplay.js index 97a2b93..c46f72b 100644 --- a/gameplay.js +++ b/gameplay.js @@ -3,7 +3,8 @@ import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; 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 { axisX, axisZ, takeFlickAim, touchPX } from './input.js'; +import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateZombies, spawnZombie, spawnRacketZombie, sweepCourt, zombies, explode, meleeSwipe, extras, parts } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- @@ -25,6 +26,7 @@ export function predictLanding() { // first-bounce point, closed form — exact, // flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that // point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked. export function flickTarget(ch, aim) { // exported: render draws the live reticle with it + if (!aim.mag) return { zt: zombieTarget(null) }; // pure hold, no direction yet: auto-target the most advanced const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> meters of reach const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R)); const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z) @@ -517,7 +519,7 @@ function racketAI(rz, dt) { // 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) return; + if (G.horde || G.mgT > 0 || G.changeover || (G.interlude && G.interlude.introT > 0)) 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); @@ -529,6 +531,7 @@ export function keyPressed(code) { if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers 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 + if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel // 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') { @@ -586,8 +589,20 @@ export function update(dt) { const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z }; } + // auto-fire steering is ABSOLUTE: the finger x IS the player x — zero lag, and clamped + // to what the camera can see (portrait's world bounds are wider than its viewport) + const mgMode = G.horde || G.mgT > 0; + const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); + let mgSteer = false; + if (mgMode && touchPX() !== null) { + mgSteer = true; + const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis)); + if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; P.runT += dt; P.pose = 'run'; } + else P.pose = 'idle'; + P.x = tx; + } const slow = P.swingT >= 0 ? 0.35 : 1; - const mx = axisX(), mz = axisZ(); + const mx = mgSteer ? 0 : axisX(), mz = mgSteer ? 0 : axisZ(); P.x += mx * TUNE.playerSpeed * slow * dt; P.z += mz * TUNE.playerSpeed * slow * dt; let assist = 0; @@ -603,11 +618,12 @@ export function update(dt) { assist = dx; } } - P.x = Math.max(-P_BOUNDS.x, Math.min(P_BOUNDS.x, P.x)); + const xLim = mgMode ? xVis : P_BOUNDS.x; // auto-fire keeps even keyboard players on screen + P.x = Math.max(-xLim, Math.min(xLim, P.x)); P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); const moving = mx || mz || assist !== 0; P.runT += moving ? dt : 0; - if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle'; + if (!mgSteer && P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle'; if (mx) P.facing = mx > 0 ? 1 : -1; else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing } @@ -634,10 +650,12 @@ 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.interlude = true; + G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return voice('play_resume', 0.2); voice('sudden_death', 2.4); - G.flash = { text: 'SUDDEN DEATH', t: 1.8 }; } + if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt; + // the duel is played under tennis lights: lerp the palette home while it runs, back after + G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8)); // --- AI (the opponent only exists in tennis mode) if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') { diff --git a/input.js b/input.js index 4b1b3ee..b262a06 100644 --- a/input.js +++ b/input.js @@ -41,13 +41,21 @@ export function takeFlickAim(maxAgeMs = 450) { // (G.aim, drawn by render); release fires the serve there, dragging back home cancels. const canAimServe = () => G.mode === 'splatter' && G.phase === 'pretoss' && score.server === 'P' - && !G.horde && !(G.mgT > 0) && !G.changeover; + && !G.horde && !(G.mgT > 0) && !G.changeover && !(G.interlude && G.interlude.introT > 0); + +let steerPX = null; // live absolute finger x (-1..1 across the canvas) — machine-gun steering reads it +export const touchPX = () => steerPX; cv.addEventListener('touchstart', e => { e.preventDefault(); audio(); for (const t of e.changedTouches) { - if (!stick) stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), - moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve + if (!stick) { + stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), + moved: false, aimable: canAimServe() }; // context locked at touch start — a movement drag never morphs into a serve + // the reticle appears the moment the thumb lands: mag 0 = auto-target the most advanced + if (stick.aimable) G.aim = { fx: 0, fy: 0, mag: 0 }; + else { const r = cv.getBoundingClientRect(); steerPX = (t.clientX - r.left) / r.width * 2 - 1; } + } else keyPressed('Space'); } }, { passive: false }); @@ -59,10 +67,12 @@ cv.addEventListener('touchmove', e => { if (t.identifier !== stick.id) continue; const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); if (dist > DEAD) stick.moved = true; - if (stick.aimable) { // aiming, not steering: the reticle tracks the thumb - G.aim = dist > DEAD ? { fx: dx / dist, fy: dy / dist, mag: dist } : null; + if (stick.aimable) { // aiming, not steering: the reticle tracks the thumb (home = auto-target) + G.aim = dist > DEAD ? { fx: dx / dist, fy: dy / dist, mag: dist } : { fx: 0, fy: 0, mag: 0 }; continue; } + const r = cv.getBoundingClientRect(); + steerPX = (t.clientX - r.left) / r.width * 2 - 1; touchAxis.x = Math.abs(dx) > DEAD ? Math.sign(dx) : 0; touchAxis.z = Math.abs(dy) > DEAD ? Math.sign(dy) : 0; } @@ -74,10 +84,11 @@ const endTouch = e => { if (!stick || t.identifier !== stick.id) continue; const dt = performance.now() - stick.t0; const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); - if (stick.aimable && stick.moved) { // two-stage release - const aim = G.aim; G.aim = null; - if (aim && dist > DEAD * 1.5) dragServe(aim); // on target — serve - // else: dragged back home — cancel, the tin stays in stock + const aim = G.aim; G.aim = null; steerPX = null; + if (stick.aimable && (stick.moved || dt >= 250)) { // two-stage release + // a pure HOLD (never moved) fires at the auto-target; a drag fires at the reticle; + // a drag walked back home cancels — the tin stays in stock + if (aim && (!stick.moved || dist > DEAD * 1.5)) dragServe(aim); } else if (!stick.moved && dt < 250) keyPressed('Space'); // tap else if (dt < 300 && dist > 28) { // flick = aimed swing diff --git a/main.js b/main.js index edbad95..a7281ab 100644 --- a/main.js +++ b/main.js @@ -14,9 +14,9 @@ import './input.js'; let last = performance.now(); function frame(now) { + requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good) const dt = Math.min(0.033, (now - last) / 1000); last = now; update(dt); render(); - requestAnimationFrame(frame); } fitViewport(); diff --git a/render.js b/render.js index fdc7bdb..e990e50 100644 --- a/render.js +++ b/render.js @@ -20,7 +20,8 @@ const hexLerp = (a, b, t) => { return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`; }; function palette() { - const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 : Math.min(1, G.transT / 6); + // splatter lerps back toward the tennis palette while a duel runs — order, restored + const t = G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6); if (t === 0) return PAL.tennis; if (t === 1) return PAL.splatter; const m = {}; @@ -302,7 +303,7 @@ export function render() { drawRing(ft.zt.x, ft.zt.z, '#ff5050'); const hp = proj(ft.zt.x, 2.2, ft.zt.z); ctx.fillStyle = '#ff5050'; ctx.fillRect(hp.x - 1, hp.y - 4, 3, 3); - } else drawRing(ft.tx, ft.tz, '#f8e048'); + } else if (ft.tx !== undefined) drawRing(ft.tx, ft.tz, '#f8e048'); } // far-side drawables in z order (far → near), then the net, then the near side @@ -401,7 +402,8 @@ export function render() { const touch = matchMedia('(pointer: coarse)').matches; const stock = G.bombs ? 'BOMB' : G.triples ? 'TRIPLE' : G.flames ? 'FIRE' : null; const txt = G.mode === 'splatter' - ? (stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT` + ? (G.aim ? (stock ? `RELEASE FIRES THE ${stock} · DRAG BACK TO CANCEL` : 'RELEASE TO SERVE · DRAG BACK TO CANCEL') + : stock ? `AIMED SERVES FIRE THE ${stock} · TAP KEEPS IT` : touch ? 'TAP TO SERVE · HOLD + DRAG TO AIM' : 'SPACE SERVES · ARROWS AIM') : G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'); @@ -411,6 +413,14 @@ export function render() { ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1); } + if (G.interlude && G.interlude.introT > 0) { // the duel card: a real pause, front and center + ctx.fillStyle = 'rgba(10,6,8,0.66)'; ctx.fillRect(0, H / 2 - 36, W, 64); + ctx.fillStyle = '#ff5050'; ctx.font = 'bold 18px monospace'; + ctx.fillText('SUDDEN DEATH', W / 2, H / 2 - 26); + ctx.fillStyle = '#e8e8dc'; ctx.font = 'bold 8px monospace'; + ctx.fillText('ONE POINT · THE LINES ARE LIVE', W / 2, H / 2 - 2); + ctx.fillText('WIN IT FOR THE NEXT TIN', W / 2, H / 2 + 12); + } if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060'; ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); diff --git a/state.js b/state.js index a379242..4379402 100644 --- a/state.js +++ b/state.js @@ -41,7 +41,8 @@ export const G = { tinDue: false, // the rules called for new balls — deliver at the next changeover pendingGame: false, // a game just ended — flash 'GAME N' when the next one arms aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss) - interlude: false, // sudden-death point vs the racket zombie: tennis rules apply, shamblers spectate + interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing + duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving runGames: 0, // games finished this run (either side) — the difficulty director sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde diff --git a/zombies.js b/zombies.js index 55ceccf..851517c 100644 --- a/zombies.js +++ b/zombies.js @@ -80,8 +80,9 @@ export function sweepCourt() { export function meleeSwipe(x, z, range) { let hit = 0; for (let i = zombies.length - 1; i >= 0; i--) { - if (zombies[i].variant === 'r') continue; - if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < range) { killZombie(i, !!G.horde); hit++; } + const zb = zombies[i]; + if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop + if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; } } if (hit) swish(0.35); return hit; @@ -128,8 +129,8 @@ function tickHorde(dt) { // bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so // the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom. export function explode(x, z) { - for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices the array - if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i); + for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array + if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i); gore.push({ x, z, r: 1.6, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent gore.push({ x, z, r: 1.4, a: 0.9 }); if (gore.length > 40) gore.shift(); const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6); @@ -170,6 +171,7 @@ export function updateZombies(dt) { for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i]; + if (!zb) continue; // a kill can cascade into sweepCourt (game won mid-loop) and gut the array under us zb.walkT += dt; zb.z += zb.v * dt; if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out @@ -250,7 +252,7 @@ export function updateZombies(dt) { let hit = false; for (let j = zombies.length - 1; j >= 0; j--) { const zb = zombies[j]; - if (zb.variant === 'r') continue; + if (!zb || zb.variant === 'r') continue; if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) { if (zb.variant === 'g' && --zb.hp > 0) pock(0.5, 260); // the brute soaks pellets too else killZombie(j, true); @@ -268,7 +270,7 @@ export function updateZombies(dt) { 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 (!zb || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop 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) {