diff --git a/game.js b/game.js index d44a286..3697bb8 100644 --- a/game.js +++ b/game.js @@ -32,6 +32,7 @@ const B_PTS = 100; // points per letter let chars = []; let dead = []; let particles = []; +let ripples = []; let dividers = []; let ball = {}; @@ -95,6 +96,7 @@ function initPhysics() { dead = []; particles = []; + ripples = []; bPhase = 'playing'; bLives = B_LIVES_MAX; bScore = 0; @@ -160,6 +162,9 @@ function killChar(ch) { color: ch.color, }); } + + // Ripple — expanding ring that displaces nearby alive letters as it passes + ripples.push({ x: ch.x + ch.w * 0.5, y: ch.y - ch.h * 0.5, r: 0, strength: 1 }); } /* ── Prevent nearly-horizontal ball ──────────────────────────────── */ @@ -285,6 +290,14 @@ function update() { p.life -= p.decay; if (p.life <= 0) particles.splice(i, 1); } + + // ── Ripples — expand and fade + for (let i = ripples.length - 1; i >= 0; i--) { + const rp = ripples[i]; + rp.r += 4.5; + rp.strength -= 0.03; + if (rp.strength <= 0) ripples.splice(i, 1); + } } /* ═══════════════════════════════════════════════════════════════════ @@ -332,12 +345,24 @@ function draw() { // ── Alive characters ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; + const RIPPLE_BW = 30; // ring bandwidth (px) + const RIPPLE_AMP = 7; // max vertical displacement (px) let lastFont = null, lastColor = null; for (const ch of chars) { if (!ch.alive) continue; if (ch.font !== lastFont) { ctx.font = ch.font; lastFont = ch.font; } if (ch.color !== lastColor) { ctx.fillStyle = ch.color; lastColor = ch.color; } - ctx.fillText(ch.char, ch.x, ch.y); + let dy = 0; + if (ripples.length > 0) { + const cx = ch.x + ch.w * 0.5, cy = ch.y - ch.h * 0.5; + for (const rp of ripples) { + const dwave = Math.hypot(cx - rp.x, cy - rp.y) - rp.r; + if (Math.abs(dwave) < RIPPLE_BW) { + dy += Math.sin((dwave / RIPPLE_BW) * Math.PI) * RIPPLE_AMP * rp.strength; + } + } + } + ctx.fillText(ch.char, ch.x, ch.y + dy); } // ── Ball trail diff --git a/pacman.js b/pacman.js index 5eddae8..b227b8b 100644 --- a/pacman.js +++ b/pacman.js @@ -1,29 +1,34 @@ 'use strict'; /* ═══════════════════════════════════════════════════════════════════ - CV PAC-MAN — Pac-Man eats individual CV letters while avoiding - three ghosts. Clear the whole CV to win. - Controls: Arrow keys or WASD to change direction - Mobile: Swipe to change direction + CV PAC-MAN + Pac-Man slides rows of CV text left/right as he moves horizontally + through them (and column lanes up/down when moving vertically). + Each letter hides a dot at its original position. Push the letters + away to reveal the dots, then eat them for points. + Controls: Arrow keys / WASD — Mobile: swipe to change direction ═══════════════════════════════════════════════════════════════════ */ -/* ── Shared globals from game.js / layout.js ───────────────────────── - canvas, ctx, W, H, initCanvas(), FONT, buildLayoutData() - ─────────────────────────────────────────────────────────────────── */ +/* Shared globals: canvas, ctx, W, H, DPR, initCanvas(), FONT, buildLayoutData() */ /* ── Constants ───────────────────────────────────────────────────── */ const P_BG = '#f5f1ea'; -const P_DIV_COLOR = 'rgba(0,0,0,0.1)'; +const P_DIV_COLOR = 'rgba(0,0,0,0.06)'; const P_PAC_COLOR = '#f72585'; +const P_DOT_COLOR = '#955f3b'; const P_GHOST_COLORS = ['#1e1a18', '#5a5149', '#8a8178']; -const P_SPEED = 2.2; +const P_SPEED = 2.5; const P_GHOST_SPEED = 1.35; const P_PAC_RADIUS = 12; const P_GHOST_RADIUS = 13; -const P_EAT_RADIUS = P_PAC_RADIUS + 6; // eat at reflow boundary +const P_EAT_RADIUS = 10; // px to eat a revealed dot +const P_DOT_RADIUS = 2.5; // drawn dot size +const P_DOT_MIN_DISP = 14; // px a char must travel before its dot is exposed +const P_ROW_BAND = 22; // Y half-range to detect "on this row" +const P_COL_BAND = 26; // X half-range for column-lane push const P_INVINCIBLE_F = 150; -const P_DEAD_DELAY = 100; +const P_DEAD_DELAY = 90; const P_DIRS = { right: { dx: 1, dy: 0 }, @@ -32,43 +37,27 @@ const P_DIRS = { up: { dx: 0, dy: -1 }, }; -/* ── State ────────────────────────────────────────────────────────── */ -let pChars = []; -let pDividers = []; -let pContentLeft = 0; -let pContentRight = 0; -let pEaten = []; // fading-out eaten chars -let pParticles = []; -let pPac = null; -let pGhosts = []; -let pLives = 3; -let pDeadTimer = 0; -let pPhase = 'playing'; -let pAnimId = null; -let pStartTime = 0; -let pScore = 0; -let pKeys = {}; +/* ── State ───────────────────────────────────────────────────────── */ +let pChars = []; // layout chars, each extended with offsetX / offsetY +let pDots = []; // parallel to pChars: { x, y, eaten } +let pDividers = []; +let pParticles = []; +let pPac = null; +let pGhosts = []; +let pLives = 3; +let pDeadTimer = 0; +let pPhase = 'playing'; +let pAnimId = null; +let pScore = 0; +let pKeys = {}; let pTouchStart = null; -/* ═══════════════════════════════════════════════════════════════════ - LAYOUT - ═══════════════════════════════════════════════════════════════════ */ -function pBuildLayout() { - const data = buildLayoutData(ctx, W, H); - pChars = data.chars; - pDividers = data.dividers; - pContentLeft = data.contentLeft; - pContentRight = data.contentRight; -} - -/* ═══════════════════════════════════════════════════════════════════ - HELPERS - ═══════════════════════════════════════════════════════════════════ */ -function pCircleAABB(cx, cy, r, rx, ry, rw, rh) { - const nx = Math.max(rx, Math.min(cx, rx + rw)); - const ny = Math.max(ry, Math.min(cy, ry + rh)); - const dx = cx - nx, dy = cy - ny; - return dx * dx + dy * dy < r * r; +/* ── Helpers ─────────────────────────────────────────────────────── */ +// Shortest signed displacement on a wrapped axis (handles offsetX that grew past W) +function pWrappedDisp(offset, span) { + let d = ((offset % span) + span) % span; + if (d > span / 2) d -= span; + return d; } function pWrap(obj, r) { @@ -79,6 +68,20 @@ function pWrap(obj, r) { if (obj.y > H + pad) obj.y = -pad; } +/* ═══════════════════════════════════════════════════════════════════ + LAYOUT + ═══════════════════════════════════════════════════════════════════ */ +function pBuildLayout() { + const data = buildLayoutData(ctx, W, H); + pDividers = data.dividers; + pChars = data.chars; + pDots = pChars.map(ch => { + ch.offsetX = 0; + ch.offsetY = 0; + return { x: ch._baseX + ch.w * 0.5, y: ch._baseY - ch.h * 0.5, eaten: false }; + }); +} + /* ═══════════════════════════════════════════════════════════════════ SPAWN ═══════════════════════════════════════════════════════════════════ */ @@ -96,7 +99,6 @@ function pSpawnPac() { } function pSpawnGhosts() { - // Place ghosts at corners of the content area, well away from center const positions = [ { x: W * 0.15, y: H * 0.35 }, { x: W * 0.85, y: H * 0.35 }, @@ -117,16 +119,15 @@ function pSpawnGhosts() { INIT ═══════════════════════════════════════════════════════════════════ */ function pInit() { - pChars = []; - pDividers = []; - pEaten = []; - pParticles = []; - pLives = 3; - pDeadTimer = 0; - pPhase = 'playing'; - pStartTime = Date.now(); - pScore = 0; - pKeys = {}; + pChars = []; + pDots = []; + pDividers = []; + pParticles = []; + pLives = 3; + pDeadTimer = 0; + pPhase = 'playing'; + pScore = 0; + pKeys = {}; pTouchStart = null; pBuildLayout(); pSpawnPac(); @@ -144,34 +145,23 @@ function pUpdate() { pDeadTimer--; if (pDeadTimer <= 0) { pSpawnPac(); - pPhase = 'playing'; + pPhase = 'playing'; pParticles = []; } - for (let i = pParticles.length - 1; i >= 0; i--) { - const p = pParticles[i]; - p.x += p.vx; p.y += p.vy; - p.life -= p.decay; - if (p.life <= 0) pParticles.splice(i, 1); - } - for (let i = pEaten.length - 1; i >= 0; i--) { - pEaten[i].alpha -= 0.06; - if (pEaten[i].alpha <= 0) pEaten.splice(i, 1); - } + _pTickParticles(); return; } const pac = pPac; - // ── Apply queued direction (no walls, so always applies) - if (pac.nextDir) { pac.dir = pac.nextDir; pac.nextDir = null; } + // ── Apply queued / held direction (no walls, always succeeds immediately) + if (pac.nextDir) { pac.dir = pac.nextDir; pac.nextDir = null; } + if (pKeys.right) { pac.dir = 'right'; pKeys.right = false; } + else if (pKeys.left) { pac.dir = 'left'; pKeys.left = false; } + else if (pKeys.down) { pac.dir = 'down'; pKeys.down = false; } + else if (pKeys.up) { pac.dir = 'up'; pKeys.up = false; } - // ── Keyboard direction - if (pKeys.right) { pac.dir = 'right'; pKeys.right = false; } - else if (pKeys.left) { pac.dir = 'left'; pKeys.left = false; } - else if (pKeys.down) { pac.dir = 'down'; pKeys.down = false; } - else if (pKeys.up) { pac.dir = 'up'; pKeys.up = false; } - - // ── Move Pac-Man + // ── Move Pac-Man and wrap const d = P_DIRS[pac.dir]; pac.x += d.dx * P_SPEED; pac.y += d.dy * P_SPEED; @@ -183,38 +173,52 @@ function pUpdate() { if (pac.mouthAngle < 0.02) { pac.mouthAngle = 0.02; pac.mouthDir = 1; } // ── Invincibility countdown - if (pac.invincible) { - pac.invincibleTimer--; - if (pac.invincibleTimer <= 0) pac.invincible = false; + if (pac.invincible && --pac.invincibleTimer <= 0) pac.invincible = false; + + // ── Push row (horizontal) or column lane (vertical) + if (d.dx !== 0) { + // Moving horizontally: slide every char on the row Pac-Man is passing through + for (const ch of pChars) { + if (Math.abs(ch._baseY - pac.y) < P_ROW_BAND) { + ch.offsetX += d.dx * P_SPEED; + } + } + } else { + // Moving vertically: slide chars whose horizontal center is near Pac-Man's X + for (const ch of pChars) { + if (Math.abs(ch._baseX + ch.w * 0.5 - pac.x) < P_COL_BAND) { + ch.offsetY += d.dy * P_SPEED; + } + } } - // ── Eat characters - for (const ch of pChars) { - if (!ch.alive) continue; - if (pCircleAABB(pac.x, pac.y, P_EAT_RADIUS, ch.x, ch.y - ch.h, ch.w, ch.h)) { - ch.alive = false; - pScore += 100; - pEaten.push({ char: ch.char, font: ch.font, color: ch.color, x: ch.x, y: ch.y, w: ch.w, h: ch.h, alpha: 1 }); - // Small burst of particles - for (let i = 0; i < 3; i++) { + // ── Eat revealed dots + for (let i = 0; i < pChars.length; i++) { + const ch = pChars[i]; + const dot = pDots[i]; + if (dot.eaten) continue; + // A dot is hidden while its char still sits on top of it + if (Math.abs(pWrappedDisp(ch.offsetX, W)) < P_DOT_MIN_DISP && + Math.abs(pWrappedDisp(ch.offsetY, H)) < P_DOT_MIN_DISP) continue; + if (Math.hypot(pac.x - dot.x, pac.y - dot.y) < P_EAT_RADIUS) { + dot.eaten = true; + pScore += 10; + for (let j = 0; j < 4; j++) { const a = Math.random() * Math.PI * 2; - const s = 1 + Math.random() * 3; + const s = 0.8 + Math.random() * 2; pParticles.push({ - x: ch.x + ch.w * 0.5, - y: ch.y - ch.h * 0.5, - vx: Math.cos(a) * s, - vy: Math.sin(a) * s, - life: 1, - decay: 0.07 + Math.random() * 0.06, - r: 1 + Math.random() * 1.5, - color: P_PAC_COLOR, + x: dot.x, y: dot.y, + vx: Math.cos(a) * s, vy: Math.sin(a) * s, + life: 1, decay: 0.07 + Math.random() * 0.06, + r: 1 + Math.random(), + color: P_DOT_COLOR, }); } } } - // ── Win condition - if (pPhase === 'playing' && pChars.length > 0 && pChars.every(c => !c.alive)) { + // ── Win + if (pDots.length > 0 && pDots.every(dot => dot.eaten)) { pPhase = 'cleared'; showScoreModal(pScore, 'pacman', null); return; @@ -224,35 +228,25 @@ function pUpdate() { for (const g of pGhosts) { g.modeTimer--; if (g.modeTimer <= 0) { - // Decide mode: chase if within 220px of Pac-Man, else scatter - const dist = Math.hypot(g.x - pac.x, g.y - pac.y); - if (dist < 220) { + if (Math.hypot(g.x - pac.x, g.y - pac.y) < 220) { g.mode = 'chase'; g.modeTimer = 50 + Math.floor(Math.random() * 40); } else { g.mode = 'scatter'; g.modeTimer = 90 + Math.floor(Math.random() * 60); - // Random direction change const dirs = Object.keys(P_DIRS); g.dir = dirs[Math.floor(Math.random() * dirs.length)]; } } if (g.mode === 'chase') { - // Move toward Pac-Man: pick dominant axis - const dx = pac.x - g.x; - const dy = pac.y - g.y; - if (Math.abs(dx) > Math.abs(dy)) { - g.dir = dx > 0 ? 'right' : 'left'; - } else { - g.dir = dy > 0 ? 'down' : 'up'; - } - } else { - // Scatter: continue direction, randomly turn 30% of the time - if (Math.random() < 0.012) { - const dirs = Object.keys(P_DIRS); - g.dir = dirs[Math.floor(Math.random() * dirs.length)]; - } + const gdx = pac.x - g.x, gdy = pac.y - g.y; + g.dir = Math.abs(gdx) > Math.abs(gdy) + ? (gdx > 0 ? 'right' : 'left') + : (gdy > 0 ? 'down' : 'up'); + } else if (Math.random() < 0.012) { + const dirs = Object.keys(P_DIRS); + g.dir = dirs[Math.floor(Math.random() * dirs.length)]; } const gd = P_DIRS[g.dir]; @@ -260,39 +254,34 @@ function pUpdate() { g.y += gd.dy * g.speed; pWrap(g, P_GHOST_RADIUS); - // ── Ghost-Pac collision - if (!pac.invincible) { - const dist = Math.hypot(g.x - pac.x, g.y - pac.y); - if (dist < P_GHOST_RADIUS + P_PAC_RADIUS - 4) { - // Spawn particles at Pac-Man position - for (let i = 0; i < 12; i++) { - const a = Math.random() * Math.PI * 2; - const s = 2 + Math.random() * 4; - pParticles.push({ - x: pac.x, - y: pac.y, - vx: Math.cos(a) * s, - vy: Math.sin(a) * s, - life: 1, - decay: 0.03 + Math.random() * 0.04, - r: 2 + Math.random() * 2, - color: P_PAC_COLOR, - }); - } - pLives--; - if (pLives <= 0) { - pPhase = 'gameover'; - showScoreModal(pScore, 'pacman', null); - } else { - pPhase = 'dead'; - pDeadTimer = P_DEAD_DELAY; - } - return; + if (!pac.invincible && Math.hypot(g.x - pac.x, g.y - pac.y) < P_GHOST_RADIUS + P_PAC_RADIUS - 4) { + for (let i = 0; i < 12; i++) { + const a = Math.random() * Math.PI * 2; + const s = 2 + Math.random() * 4; + pParticles.push({ + x: pac.x, y: pac.y, + vx: Math.cos(a) * s, vy: Math.sin(a) * s, + life: 1, decay: 0.03 + Math.random() * 0.04, + r: 2 + Math.random() * 2, + color: P_PAC_COLOR, + }); } + pLives--; + if (pLives <= 0) { + pPhase = 'gameover'; + showScoreModal(pScore, 'pacman', null); + } else { + pPhase = 'dead'; + pDeadTimer = P_DEAD_DELAY; + } + return; } } - // ── Particles + _pTickParticles(); +} + +function _pTickParticles() { for (let i = pParticles.length - 1; i >= 0; i--) { const p = pParticles[i]; p.x += p.vx; @@ -300,12 +289,6 @@ function pUpdate() { p.life -= p.decay; if (p.life <= 0) pParticles.splice(i, 1); } - - // ── Eaten chars fade out - for (let i = pEaten.length - 1; i >= 0; i--) { - pEaten[i].alpha -= 0.06; - if (pEaten[i].alpha <= 0) pEaten.splice(i, 1); - } } /* ═══════════════════════════════════════════════════════════════════ @@ -315,37 +298,39 @@ function pDraw() { ctx.fillStyle = P_BG; ctx.fillRect(0, 0, W, H); - // ── Dividers + // ── Dividers (static structural guides, drawn faintly) ctx.strokeStyle = P_DIV_COLOR; ctx.lineWidth = 1; - for (const d of pDividers) { + for (const dv of pDividers) { ctx.beginPath(); - ctx.moveTo(d.x, d.y); - ctx.lineTo(d.x + d.w, d.y); + ctx.moveTo(dv.x, dv.y); + ctx.lineTo(dv.x + dv.w, dv.y); ctx.stroke(); } - // ── Alive chars + // ── Revealed uneaten dots + ctx.fillStyle = P_DOT_COLOR; + for (let i = 0; i < pChars.length; i++) { + const ch = pChars[i]; + const dot = pDots[i]; + if (dot.eaten) continue; + if (Math.abs(pWrappedDisp(ch.offsetX, W)) < P_DOT_MIN_DISP && + Math.abs(pWrappedDisp(ch.offsetY, H)) < P_DOT_MIN_DISP) continue; + ctx.beginPath(); + ctx.arc(dot.x, dot.y, P_DOT_RADIUS, 0, Math.PI * 2); + ctx.fill(); + } + + // ── CV chars at displaced (wrapped) positions ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; let lastFont = null, lastColor = null; for (const ch of pChars) { - if (!ch.alive) continue; if (ch.font !== lastFont) { ctx.font = ch.font; lastFont = ch.font; } if (ch.color !== lastColor) { ctx.fillStyle = ch.color; lastColor = ch.color; } - ctx.fillText(ch.char, ch.x, ch.y); - } - - // ── Eaten chars (fade out) - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - for (const e of pEaten) { - ctx.save(); - ctx.globalAlpha = Math.max(0, e.alpha); - ctx.font = e.font; - ctx.fillStyle = P_PAC_COLOR; - ctx.fillText(e.char, e.x + e.w * 0.5, e.y - e.h * 0.5); - ctx.restore(); + const drawX = ((ch._baseX + ch.offsetX) % W + W) % W; + const drawY = ((ch._baseY + ch.offsetY) % H + H) % H; + ctx.fillText(ch.char, drawX, drawY); } // ── Particles @@ -365,64 +350,56 @@ function pDraw() { } // ── Pac-Man - if (pPac && pPhase === 'playing') { - const pac = pPac; - const visible = true; - if (visible) { - const dirAngles = { right: 0, down: Math.PI / 2, left: Math.PI, up: -Math.PI / 2 }; - const baseAngle = dirAngles[pac.dir] || 0; - const mouth = pac.mouthAngle; - ctx.save(); - ctx.fillStyle = P_PAC_COLOR; - ctx.beginPath(); - ctx.moveTo(pac.x, pac.y); - ctx.arc(pac.x, pac.y, P_PAC_RADIUS, baseAngle + mouth, baseAngle + Math.PI * 2 - mouth); - ctx.closePath(); - ctx.fill(); - ctx.restore(); - } + if (pPac && pPhase !== 'dead') { + const pac = pPac; + const dirAngles = { right: 0, down: Math.PI / 2, left: Math.PI, up: -Math.PI / 2 }; + const base = dirAngles[pac.dir] || 0; + ctx.save(); + ctx.fillStyle = P_PAC_COLOR; + ctx.beginPath(); + ctx.moveTo(pac.x, pac.y); + ctx.arc(pac.x, pac.y, P_PAC_RADIUS, base + pac.mouthAngle, base + Math.PI * 2 - pac.mouthAngle); + ctx.closePath(); + ctx.fill(); + ctx.restore(); } - // ── HUD: lives + score — top-left + // ── HUD: lives + score const _pHudY = 50; for (let i = 0; i < 3; i++) { const lx = 26 + i * 24; - const r = 8; ctx.save(); if (i < pLives) { ctx.fillStyle = P_PAC_COLOR; ctx.beginPath(); ctx.moveTo(lx, _pHudY); - ctx.arc(lx, _pHudY, r, 0.25, Math.PI * 2 - 0.25); + ctx.arc(lx, _pHudY, 8, 0.25, Math.PI * 2 - 0.25); ctx.closePath(); ctx.fill(); } else { ctx.strokeStyle = 'rgba(247,37,133,0.2)'; ctx.lineWidth = 1.2; ctx.beginPath(); - ctx.arc(lx, _pHudY, r, 0, Math.PI * 2); + ctx.arc(lx, _pHudY, 8, 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); } - if (pPhase === 'playing' || pPhase === 'dead') { - ctx.save(); - ctx.font = `600 12px ${FONT}`; - ctx.fillStyle = 'rgba(0,0,0,0.25)'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - ctx.fillText(pScore.toLocaleString(), 26 + 3 * 24 + 10, _pHudY); - ctx.restore(); - } + ctx.save(); + ctx.font = `600 12px ${FONT}`; + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(pScore.toLocaleString(), 26 + 3 * 24 + 10, _pHudY); + ctx.restore(); // ── Overlays if (pPhase === 'cleared' || pPhase === 'gameover') { ctx.save(); - ctx.fillStyle = 'rgba(245,241,234,0.94)'; + ctx.fillStyle = 'rgba(245,241,234,0.94)'; ctx.fillRect(0, 0, W, H); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - if (pPhase === 'cleared') { ctx.fillStyle = '#1e1a18'; ctx.font = `700 28px ${FONT}`; @@ -430,9 +407,6 @@ function pDraw() { ctx.fillStyle = '#8a8178'; ctx.font = `400 13px ${FONT}`; ctx.fillText('benjamin.m.stern@gmail.com', W / 2, H / 2 + 14); - ctx.fillStyle = '#bfb5aa'; - ctx.font = `400 11px ${FONT}`; - ctx.fillText('click to play again', W / 2, H / 2 + 38); } else { ctx.fillStyle = '#1e1a18'; ctx.font = `700 28px ${FONT}`; @@ -440,11 +414,10 @@ function pDraw() { ctx.fillStyle = '#8a8178'; ctx.font = `400 13px ${FONT}`; ctx.fillText(`Score: ${pScore.toLocaleString()}`, W / 2, H / 2 + 14); - ctx.fillStyle = '#bfb5aa'; - ctx.font = `400 11px ${FONT}`; - ctx.fillText('click to play again', W / 2, H / 2 + 38); } - + ctx.fillStyle = '#bfb5aa'; + ctx.font = `400 11px ${FONT}`; + ctx.fillText('click to play again', W / 2, H / 2 + 38); ctx.restore(); } } @@ -453,47 +426,25 @@ function pDraw() { function _pDrawGhost(x, y, r, color) { ctx.save(); ctx.fillStyle = color; - ctx.beginPath(); - // Top half-circle ctx.arc(x, y, r, Math.PI, 0); - // Right side straight down ctx.lineTo(x + r, y + r * 1.15); - // Three wavy bumps across the bottom (drawn right-to-left) - const bumpR = r / 3; - ctx.arc(x + r - bumpR, y + r * 1.15, bumpR, 0, Math.PI, false); - ctx.arc(x, y + r * 1.15, bumpR, 0, Math.PI, false); - ctx.arc(x - r + bumpR, y + r * 1.15, bumpR, 0, Math.PI, false); - // Left side straight up back to start + const br = r / 3; + ctx.arc(x + r - br, y + r * 1.15, br, 0, Math.PI, false); + ctx.arc(x, y + r * 1.15, br, 0, Math.PI, false); + ctx.arc(x - r + br, y + r * 1.15, br, 0, Math.PI, false); ctx.lineTo(x - r, y); ctx.closePath(); ctx.fill(); - // Eyes - const eyeOffX = r * 0.32; - const eyeOffY = r * 0.1; - const eyeRX = r * 0.22; - const eyeRY = r * 0.28; - - // White of eyes + const ex = r * 0.32, ey = r * 0.1, erx = r * 0.22, ery = r * 0.28; ctx.fillStyle = 'rgba(245,241,234,0.9)'; - ctx.beginPath(); - ctx.ellipse(x - eyeOffX, y - eyeOffY, eyeRX, eyeRY, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.beginPath(); - ctx.ellipse(x + eyeOffX, y - eyeOffY, eyeRX, eyeRY, 0, 0, Math.PI * 2); - ctx.fill(); - - // Pupils + ctx.beginPath(); ctx.ellipse(x - ex, y - ey, erx, ery, 0, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.ellipse(x + ex, y - ey, erx, ery, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1e1a18'; - const pupilR = r * 0.1; - ctx.beginPath(); - ctx.arc(x - eyeOffX + eyeRX * 0.25, y - eyeOffY, pupilR, 0, Math.PI * 2); - ctx.fill(); - ctx.beginPath(); - ctx.arc(x + eyeOffX + eyeRX * 0.25, y - eyeOffY, pupilR, 0, Math.PI * 2); - ctx.fill(); - + const pr = r * 0.1; + ctx.beginPath(); ctx.arc(x - ex + erx * 0.25, y - ey, pr, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x + ex + erx * 0.25, y - ey, pr, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } @@ -508,31 +459,19 @@ function pLoop() { canvas.height = H * DPR; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } - - // Reflow CV text around Pac-Man and ghosts each frame - const pObstacles = []; - if (pPac && pPhase === 'playing') { - pObstacles.push({ cx: pPac.x, cy: pPac.y, r: P_PAC_RADIUS + 8, hPad: 5, vPad: 2 }); - for (const g of pGhosts) { - pObstacles.push({ cx: g.x, cy: g.y, r: P_GHOST_RADIUS + 6, hPad: 4, vPad: 2 }); - } - } - reflowChars(pChars, pObstacles, pContentLeft, pContentRight); - pUpdate(); pDraw(); pAnimId = requestAnimationFrame(pLoop); } /* ═══════════════════════════════════════════════════════════════════ - INPUT HANDLERS + INPUT ═══════════════════════════════════════════════════════════════════ */ function _pOnKeyDown(e) { if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') pKeys.right = true; if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') pKeys.left = true; if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') pKeys.down = true; if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') pKeys.up = true; - // Prevent arrow keys scrolling the page if (e.key.startsWith('Arrow')) e.preventDefault(); } @@ -548,14 +487,10 @@ function _pOnTouchMove(e) { const t = e.touches[0]; const dx = t.clientX - pTouchStart.x; const dy = t.clientY - pTouchStart.y; - if (Math.hypot(dx, dy) < 28) return; // threshold before committing - // Whichever axis dominates - if (Math.abs(dx) > Math.abs(dy)) { - pPac.nextDir = dx > 0 ? 'right' : 'left'; - } else { - pPac.nextDir = dy > 0 ? 'down' : 'up'; - } - // Reset so continued dragging can update direction + if (Math.hypot(dx, dy) < 28) return; + pPac.nextDir = Math.abs(dx) > Math.abs(dy) + ? (dx > 0 ? 'right' : 'left') + : (dy > 0 ? 'down' : 'up'); pTouchStart = { x: t.clientX, y: t.clientY }; } @@ -574,11 +509,11 @@ function _pOnClick() { function startPacman() { initCanvas(); pInit(); - document.addEventListener('keydown', _pOnKeyDown); - canvas.addEventListener('touchstart', _pOnTouchStart, { passive: false }); - canvas.addEventListener('touchmove', _pOnTouchMove, { passive: false }); - canvas.addEventListener('touchend', _pOnTouchEnd, { passive: false }); - canvas.addEventListener('click', _pOnClick); + document.addEventListener('keydown', _pOnKeyDown); + canvas.addEventListener('touchstart', _pOnTouchStart, { passive: false }); + canvas.addEventListener('touchmove', _pOnTouchMove, { passive: false }); + canvas.addEventListener('touchend', _pOnTouchEnd, { passive: false }); + canvas.addEventListener('click', _pOnClick); pLoop(); } @@ -587,9 +522,9 @@ function stopPacman() { pAnimId = null; pKeys = {}; pTouchStart = null; - document.removeEventListener('keydown', _pOnKeyDown); - canvas.removeEventListener('touchstart', _pOnTouchStart); - canvas.removeEventListener('touchmove', _pOnTouchMove); - canvas.removeEventListener('touchend', _pOnTouchEnd); - canvas.removeEventListener('click', _pOnClick); + document.removeEventListener('keydown', _pOnKeyDown); + canvas.removeEventListener('touchstart', _pOnTouchStart); + canvas.removeEventListener('touchmove', _pOnTouchMove); + canvas.removeEventListener('touchend', _pOnTouchEnd); + canvas.removeEventListener('click', _pOnClick); }