diff --git a/config.js b/config.js index 8b8e6d4..08a9f22 100644 --- a/config.js +++ b/config.js @@ -50,7 +50,8 @@ export const TUNE = { racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU) racketFlub: 0.2, // chance the racket zombie whiffs a return - missHud: 1, // show the last miss reason on screen (full log: T.G.missLog) + missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) β€” off; dev-only + killPips: 1, // splatter: show the kills-to-next-point pips on the scoreboard }; export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 }; diff --git a/index.html b/index.html index f96f26f..c50e27c 100644 --- a/index.html +++ b/index.html @@ -10,15 +10,24 @@ #hint { position: fixed; bottom: 8px; width: 100%; text-align: center; font: 12px monospace; color: #6b7688; } @media (pointer: coarse) { #hint, #intro .kb { display: none; } } - #intro { position: fixed; inset: 0; background: rgba(10, 13, 20, 0.88); display: grid; - place-items: center; z-index: 10; touch-action: none; } - #intro .card { font: 16px monospace; color: #dfe6f2; text-align: left; line-height: 2; - border: 2px solid #5a6a85; background: #141a26; padding: 24px 32px; } - #intro h1 { font-size: 18px; margin: 0 0 12px; color: #f8e048; text-align: center; } - #intro p { margin: 0; } - #intro p span { display: inline-block; width: 32px; } - #intro em { color: #8a96ab; font-style: normal; font-size: 13px; } - #intro .start { margin-top: 16px; text-align: center; color: #f8e048; animation: blink 1.1s step-end infinite; } + #intro { position: fixed; inset: 0; z-index: 10; touch-action: none; display: grid; place-items: center; + background: radial-gradient(circle at 50% 34%, rgba(34,16,20,0.92), rgba(8,10,16,0.96)); } + #intro .card { font: 13px monospace; color: #dfe6f2; text-align: center; line-height: 1.9; + padding: 18px 26px; max-width: 92vw; animation: drop 0.5s ease-out; } + @keyframes drop { from { transform: translateY(-14px); opacity: 0; } } + #intro .logo { line-height: 0.9; margin-bottom: 8px; } + #intro .logo span { display: block; font-weight: 900; } + #intro .l1 { font-size: 46px; letter-spacing: 3px; color: #e0231a; + text-shadow: 0 2px 0 #7a0f0a, 0 4px 0 #550906, 0 6px 1px rgba(0,0,0,0.4), 0 0 20px rgba(224,35,26,0.35); } + #intro .l2 { font-size: 30px; letter-spacing: 9px; color: #eef23a; margin-top: 2px; + text-shadow: 0 2px 0 #59610f, 0 0 14px rgba(238,242,58,0.3); } + #intro .slogan { margin: 8px 0 16px; font-size: 13px; font-style: italic; color: #b6a0a4; letter-spacing: 0.5px; } + #intro .slogan b { color: #e0231a; font-style: normal; } + #intro .how { display: inline-block; text-align: left; color: #cbd4e2; line-height: 2; } + #intro .how p { margin: 0; } + #intro .how span { display: inline-block; width: 30px; } + #intro em { color: #8a96ab; font-style: normal; font-size: 12px; } + #intro .start { margin-top: 18px; color: #f8e048; font-weight: bold; letter-spacing: 1px; animation: blink 1.1s step-end infinite; } @keyframes blink { 50% { opacity: 0.25; } } @@ -27,14 +36,17 @@
move: WASD/arrows  Β·  serve/swing: SPACE  Β·  aim: hold a direction while swinging  Β·  T: tuning panel
-

🎾 SPLATTER TENNIS

-

πŸ‘† tap β€” serve & swing

-

πŸ‘‰ flick β€” aimed shot

-

βœ‹ drag β€” run (auto-run is on)

-

⌨️ WASD / arrows + SPACE

+ +

Fifteen. Thirty. Forty. Dead.

+
+

πŸ‘† tap β€” serve & swing

+

πŸ‘‰ flick β€” aimed shot

+

βœ‹ drag β€” run (auto-run is on)

+

⌨️ WASD / arrows + SPACE

+

TAP TO START

- + diff --git a/render.js b/render.js index d9d5945..6581704 100644 --- a/render.js +++ b/render.js @@ -385,46 +385,62 @@ export function render() { 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); } - // HUD β€” scoreboard - ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30); - ctx.strokeStyle = '#5a6a85'; ctx.strokeRect(6.5, 6.5, 131, 29); - ctx.font = 'bold 9px monospace'; ctx.textBaseline = 'top'; - const ptTxt = i => score.pts[i] >= 3 && 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')) + // HUD β€” broadcast scoreboard: two player rows (server ball Β· name Β· sets Β· games Β· points), + // then a splatter status strip (kills-to-next-point pips + weapon stock). + const splat = G.mode !== 'tennis'; + const sbW = 132, sbH = splat ? 37 : 25; + ctx.fillStyle = 'rgba(10,14,22,0.85)'; ctx.fillRect(6, 6, sbW, sbH); + ctx.strokeStyle = '#42506e'; ctx.strokeRect(6.5, 6.5, sbW - 1, sbH - 1); + 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') : ['0', '15', '30', '40'][Math.min(score.pts[i], 3)]; - const setsTxt = i => G.mode === 'tennis' ? '' : G.sets[i] + 'Β·'; // splatter scoreboard: SETSΒ·GAMES PTS - ctx.fillStyle = score.server === 'P' ? '#f8e048' : '#c8d0dc'; - ctx.fillText('YOU ' + setsTxt(0) + score.games[0] + ' ' + ptTxt(0), 12, 10); - for (let b = 0; b < G.bombs; b++) { // stocked bomb balls: black pips with an orange fuse rim - 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(); + ctx.textBaseline = 'middle'; + const names = splat ? ['YOU', 'THEM'] : ['YOU', 'CPU']; + for (let i = 0; i < 2; i++) { + const rowY = 12 + i * 11, serving = (score.server === 'P') === (i === 0); + ctx.textAlign = 'left'; + if (serving) { ctx.fillStyle = '#f8e048'; ctx.beginPath(); ctx.arc(15, rowY, 2.3, 0, 7); ctx.fill(); } // the server holds the ball + ctx.font = 'bold 9px monospace'; ctx.fillStyle = serving ? '#ffffff' : '#9aa6ba'; + ctx.fillText(names[i], 21, rowY + 0.5); + ctx.textAlign = 'center'; + if (splat) { ctx.fillStyle = '#78849a'; ctx.font = 'bold 8px monospace'; ctx.fillText(String(G.sets[i]), 56, rowY + 0.5); } // sets column + ctx.fillStyle = '#c2ccda'; ctx.font = 'bold 9px monospace'; ctx.fillText(String(score.games[i]), 72, rowY + 0.5); // games column + ctx.fillStyle = serving ? 'rgba(248,224,72,0.15)' : 'rgba(255,255,255,0.05)'; ctx.fillRect(84, rowY - 6, 30, 12); // points cell + ctx.fillStyle = serving ? '#f8e048' : '#dfe6f2'; ctx.font = 'bold 9px monospace'; ctx.fillText(ptTxt(i), 99, rowY + 0.5); } - 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); + if (splat) { // status strip under the two rows + const sy = 34; + ctx.textAlign = 'left'; + if (TUNE.killPips && TUNE.killsPerPoint > 1) { // "how many zombies to the next point" β€” empty pips are kills to go + ctx.fillStyle = '#6b768a'; ctx.font = 'bold 6px monospace'; ctx.fillText('NEXT', 12, sy + 0.5); + for (let k = 0; k < TUNE.killsPerPoint; k++) { + ctx.beginPath(); ctx.arc(35 + k * 7, sy, 2.2, 0, 7); + if (k < G.killCount) { ctx.fillStyle = '#e8f03a'; ctx.fill(); } + else { ctx.strokeStyle = '#54607a'; ctx.lineWidth = 1; ctx.stroke(); } + } + } + const wx = 66; // weapon stock, moved off the player rows so the board reads clean + for (let b = 0; b < G.bombs; b++) { ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(wx + b * 8, sy, 2.6, 0, 7); ctx.fill(); ctx.strokeStyle = '#ff8010'; ctx.lineWidth = 0.75; ctx.stroke(); } + for (let b = 0; b < G.triples; b++) { ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(wx + b * 8, sy, 2.6, 0, 7); ctx.fill(); ctx.fillRect(wx + b * 8 - 4, sy - 1, 1.5, 1.5); ctx.fillRect(wx + b * 8 + 3, sy - 1, 1.5, 1.5); } + for (let b = 0; b < G.flames; b++) { ctx.fillStyle = '#ff8010'; ctx.beginPath(); ctx.arc(wx + b * 8, sy, 2.6, 0, 7); ctx.fill(); ctx.fillStyle = '#ffe8a0'; ctx.fillRect(wx + b * 8 - 1, sy - 1, 2, 2); } + if (G.mgT > 0) { ctx.fillStyle = '#333'; ctx.fillRect(wx, sy - 2.5, 40, 5); ctx.fillStyle = '#f8e048'; ctx.fillRect(wx, sy - 2.5, 40 * Math.min(1, G.mgT / TUNE.mgPickupLen), 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'; + ctx.textBaseline = 'top'; ctx.textAlign = 'center'; if (G.mode === 'eaten') { // the run summary β€” permadeath makes this scoreboard the whole game ctx.fillStyle = 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#c02020'; ctx.font = 'bold 16px monospace'; - ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 32); + ctx.fillText('GAME, SET, MASSACRE.', W / 2, H / 2 - 40); ctx.fillStyle = '#c8d0dc'; ctx.font = 'bold 10px monospace'; - ctx.fillText('SURVIVED ' + G.runGames + ' GAMES Β· ' + G.sets[0] + ' SETS', W / 2, H / 2 - 6); - if (G.best) { + ctx.fillText('SURVIVED ' + G.runGames + ' GAMES Β· ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14); + if (G.newBest) { // a fresh record crows; otherwise show the mark to beat + const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4)); + ctx.fillStyle = `rgba(248,224,72,${pulse})`; ctx.font = 'bold 11px monospace'; + ctx.fillText('β˜… NEW BEST β˜…', W / 2, H / 2 + 4); + } else if (G.best && G.best.games > 0) { ctx.fillStyle = '#8fa0b8'; ctx.font = 'bold 8px monospace'; - ctx.fillText('BEST ' + G.best.games + ' GAMES Β· ' + G.best.sets + ' SETS', W / 2, H / 2 + 10); + ctx.fillText('BEST ' + G.best.games + ' GAMES Β· ' + G.best.sets + ' SETS', W / 2, H / 2 + 4); } - 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.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 26); } } if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0)) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; diff --git a/state.js b/state.js index bafdd81..d8dccf3 100644 --- a/state.js +++ b/state.js @@ -50,6 +50,7 @@ export const G = { changeover: false, // the odd-game breather: no spawns, no serving, the boys mop shameGame: false, // they took a game β€” the next new-balls delivery is withheld best: null, // best run {games, sets}, loaded from localStorage when you get eaten + newBest: false, // this run beat the stored best β€” the eaten summary celebrates it lastScoreT: -9, // idleT of the last score voice β€” rate-limits umpire during a horde eatenT: 0, // time since you were eaten β€” gates the restart tap missLog: [], // swing diagnostics ring buffer (tennis mode) β€” inspect via T.G.missLog diff --git a/zombies.js b/zombies.js index d849b51..9260897 100644 --- a/zombies.js +++ b/zombies.js @@ -200,7 +200,8 @@ export function updateZombies(dt) { 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); } + G.newBest = G.runGames > b.games; // beat it β†’ the summary crows about it + if (G.newBest) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); } G.best = b; } catch (e) { G.best = null; } // private mode etc. β€” the summary just skips the best line }