diff --git a/assets/x_tin.png b/assets/x_tin.png deleted file mode 100644 index d8d31f0..0000000 Binary files a/assets/x_tin.png and /dev/null differ diff --git a/audio.js b/audio.js index ec32698..ef52e51 100644 --- a/audio.js +++ b/audio.js @@ -138,7 +138,7 @@ export const zombieDuck = out => fadeBed('zombie-mode.m4a', out); // key must ma export const menuDuck = out => fadeBed('background-music.m4a', out); let sysVoice = null; -export function say(text, delay = 0) { // fallback when a recorded umpire line is missing +function say(text, delay = 0) { // fallback when a recorded umpire line is missing try { if (!sysVoice) sysVoice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null; const u = new SpeechSynthesisUtterance(text); diff --git a/config.js b/config.js index dff986a..358b06b 100644 --- a/config.js +++ b/config.js @@ -362,7 +362,7 @@ export const APPLAUSE_T = 4.2; // and what is left is the mound · C (EATEN_SUMMARY): the summary fades in over them still feeding. // It is deliberately long. Oliver: "the movies surround and bite them, blood spills, and then the // person basically drowns in the zombies" — that cannot happen in half a second. -export const EATEN_GRAB = 0.9, EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6; +export const EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6; export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs around the body // UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately diff --git a/gameplay.js b/gameplay.js index 2749744..2d71486 100644 --- a/gameplay.js +++ b/gameplay.js @@ -29,11 +29,11 @@ export function predictLanding() { // first-bounce point, closed form — exact, // further, which is the whole fix: a shambler at the far baseline costs the same short pull as // one on top of you, so the gesture always fits on the screen. The first body standing in the // lane is the target; the cone widens with range so distant lanes stay pickable. -export const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin))); +const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin))); export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pullQMin); // pullQuality inverted: hitServe is handed a quality, not a drag, and the band it leaves behind is // drawn at the same weight the live preview had. -export const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (1 - TUNE.pullQMin))); +const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (1 - TUNE.pullQMin))); // A pull that STOPS in the band at the top of the draw, rather than merely reaching it. See // TUNE.perfectBand for why the old "quality == 1" test praised almost every serve. export const pullPerfect = mag => mag >= TUNE.pullFull && mag <= TUNE.pullFull * TUNE.perfectBand; @@ -139,7 +139,7 @@ function doHit(ch, quality, aim) { } ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; - ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null; + ball.lastHit = ch.side; ball.bounces = 0; 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 @@ -162,7 +162,7 @@ function doHit(ch, quality, aim) { // ----------------------------------------------------------------- serve ---- function tossBall(sv) { - ball.active = true; ball.bounces = 0; ball.firstLand = null; ball.collected = false; + ball.active = true; ball.bounces = 0; ball.collected = false; // The player is right-handed: he tosses with his LEFT hand (viewer's left) and hits with the // racket on the viewer's right, so the ball goes up almost over his head — a big offset either way // lands it on the wrong side of one of those two frames. The opponent is drawn from the front and @@ -171,7 +171,7 @@ function tossBall(sv) { ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity; G.phase = 'toss'; } -export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95 +function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95 // how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target // sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35). @@ -522,10 +522,10 @@ function startChangeover() { // that ("[0,1,2,3,4].find(...)"), so the moment the rack grew past five, a burst weapon sitting in // slot 5..9 could never be found and never spent: beacon, flamer and rocket sat at their pickup // count forever. One accessor, one place that knows the range. -export const slotEntry = i => (i === 0 ? G.temp : G.rack[i - 1]) || null; +const slotEntry = i => (i === 0 ? G.temp : G.rack[i - 1]) || null; export const slotId = i => slotEntry(i)?.id || (i === 0 ? 'ball' : null); export const slotStock = i => { const e = slotEntry(i); return e ? e.n : (i === 0 ? Infinity : 0); }; -export const slotOf = id => { // -1 if you are not carrying it +const slotOf = id => { // -1 if you are not carrying it for (let i = 0; i <= RACK_MAX; i++) if (slotId(i) === id) return i; return -1; }; @@ -549,7 +549,7 @@ export function grantWeapon(id, n = WEAPONS[id].tin) { // Spend one from a slot, and hand you back plain balls when it runs dry. The temp weapon does not // leave an empty dimmed slot behind the way a rack entry does — slot 0 simply becomes BALL again. -export function spendSlot(i) { +function spendSlot(i) { const e = slotEntry(i); if (!e) return; if (--e.n > 0) return; @@ -646,7 +646,7 @@ function stockTestRack() { else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin }); } } -export function tickTestMode(dt) { +function tickTestMode(dt) { if (!G.testMode) return; stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and } // one rolling across the sandbox was just something else to look at @@ -934,7 +934,6 @@ function onBounce() { } if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns if (ball.bounces === 1) { - ball.firstLand = { x: ball.x, z: ball.z }; const wasServe = G.wasServe; G.wasServe = false; if (wasServe) { if (!inServeBox(ball.x, ball.z)) { ball.active = false; fault(); } @@ -1156,7 +1155,7 @@ export function dragServe(aim) { // walked the player around between shots was the confusing part, not the fire rate. if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g, wid: ball.wid }); - ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; + ball.active = true; ball.collected = false; ball.bounces = 0; ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall // a full pull is quality 1.0, but only a pull that STOPS at the top of the draw is praised hitServe(P, pullQuality(aim.mag), aim, pullPerfect(aim.mag)); diff --git a/render.js b/render.js index 22474c0..c387daf 100644 --- a/render.js +++ b/render.js @@ -1,7 +1,7 @@ // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). import { cv, ctx } from './dom.js'; -import { TUNE, UI, EATEN_GRAB, 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 } from './config.js'; import { P, O, ball, score, crowd, G, court, tour } from './state.js'; import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js'; import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; @@ -67,7 +67,7 @@ export let W = 480, H = 270; // BOTTOM — the canvas deliberately runs to the PHYSICAL bottom edge so the court art fills the // screen (the home indicator is a slim bar, not opaque UI). Only the interactive RACK is // lifted clear of it, by SAFE.bottom below. -export const SAFE = { top: 0, bottom: 0, topCss: 0 }; +const SAFE = { top: 0, bottom: 0, topCss: 0 }; // Logical px of slack under a full draw, on top of the rack: the home indicator (~34 CSS px) plus // enough that a pull ENDING near the edge is still nowhere near STARTING there. See fitViewport. const PULL_ROOM = 30; @@ -263,7 +263,7 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' } // ------------------------------------------------------------------ crowd ---- -export function buildCrowd() { +function buildCrowd() { crowd.dots.length = 0; const rows = Math.floor((proj(0, 0, 0).y - 26) / 5.5), cols = Math.ceil(W / 8); for (let row = 0; row < rows; row++) for (let i = 0; i < cols; i++) crowd.dots.push({ @@ -1095,8 +1095,8 @@ const RACK_GRAB = 7; // px the TOUCH band extends above the drawn rack (below, // UPWARD instead, and only once you are actually carrying more than five: the common case is still // one row on the bottom edge, exactly as before. const rackRows = () => (G.rack.length + 1 > RACK_ROW ? 2 : 1); -export const rackTop = () => H - TUNE.rackH * rackRows() - RACK_BOT - SAFE.bottom; -export const METER_Y = () => rackTop() - 17; +const rackTop = () => H - TUNE.rackH * rackRows() - RACK_BOT - SAFE.bottom; +const METER_Y = () => rackTop() - 17; function rackGeom() { const h = TUNE.rackH, tw = Math.min(64, (W - 4) / RACK_ROW), rw = tw * RACK_ROW; const rows = rackRows(); @@ -1539,8 +1539,8 @@ export function render() { // sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed // status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro. const splat = G.mode !== 'tennis'; - const bd = { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', slot: '#0b2b1c', slotSvr: '#2b1a46', - spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick, engHi: '#2c7a4f' }; + const bd = { face: '#1e5b3a', hi: '#3f9560', lo: '#0e3624', + spine: '#472a70', spineHi: '#5f3d94', cream: '#f2ead6', dim: '#89a08f', gold: UI.pick }; const R = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(x, y, w, h); }; const ptTxt = i => score.pts[0] >= 3 && score.pts[1] >= 3 ? (score.pts[i] === score.pts[1 - i] ? '40' : score.pts[i] > score.pts[1 - i] ? 'AD' : '40') diff --git a/state.js b/state.js index 84392ff..79ed231 100644 --- a/state.js +++ b/state.js @@ -7,7 +7,7 @@ import { gore } from './zombies.js'; // runtime-only cycle (zombies imports stat 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, flame: false, + active: false, lastHit: 'P', bounces: 0, collected: false, kills: 0, bomb: false, flame: false, // which weapon launched the ball in flight — the stats screen files a kill under it long after // the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free. wid: 'ball' }; @@ -147,7 +147,7 @@ export const other = s => s === 'P' ? 'O' : 'P'; export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side export function setupPoint() { - G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.firstLand = null; + G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; G.swingSnaps = null; G.rallyNear = null; G.swung = false; if (G.mode === 'splatter') { // always your serve, from wherever you're standing score.server = 'P'; diff --git a/zombies.js b/zombies.js index 121181d..719e395 100644 --- a/zombies.js +++ b/zombies.js @@ -3,7 +3,7 @@ // your baseline — one crossing it is a point for THEM. // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) -import { TUNE, UI, COURT, VOLLEY, 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 { 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'; @@ -461,7 +461,7 @@ function killZombie(i, quiet) { // ---- fire. The flame ball doesn't just kill what it touches, it starts an epidemic: the kill // ignites its neighbours, and every burning body keeps igniting whatever it shambles past. // Read-only over zombies[] (it only sets burnT), so it is safe to call from inside a kill loop. -export function ignite(x, z, r) { +function ignite(x, z, r) { for (const zb of zombies) { if (!zb || zb.burnT > 0) continue; // the boss DOES catch now — just at TUNE.bossBurnMul the rate, // so fire is a real answer to a big body instead of a no-op