|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useCallback, useEffect, useRef } from 'react' |
| 4 | + |
| 5 | +// ── game constants ──────────────────────────────────────────────────────────── |
| 6 | +const PW = 10 // paddle width (px, CSS coords) |
| 7 | +const PH = 52 // paddle height |
| 8 | +const BS = 8 // ball size |
| 9 | +const GAP = 24 // paddle-to-wall gap |
| 10 | +const SPEED_INIT = 3.2 |
| 11 | +const SPEED_MAX = 6.2 |
| 12 | +const SPEED_INC = 0.22 |
| 13 | +const AI_SPEED = 2.6 |
| 14 | +const TRAIL_LEN = 7 |
| 15 | + |
| 16 | +// ── palette (always dark – arcade aesthetic regardless of page theme) ───────── |
| 17 | +const CLR = { |
| 18 | + bg: '#070f1c', |
| 19 | + bgDim: '#060d19', |
| 20 | + border: 'rgba(13, 141, 155, 0.18)', |
| 21 | + center: 'rgba(13, 141, 155, 0.20)', |
| 22 | + watermark:'rgba(13, 141, 155, 0.11)', |
| 23 | + paddle: '#48c8cf', |
| 24 | + paddleGlo:'rgba(72, 200, 207, 0.45)', |
| 25 | + ball: '#ffffff', |
| 26 | + ballGlo: 'rgba(255, 255, 255, 0.55)', |
| 27 | + trail0: 'rgba(255,255,255,0.28)', |
| 28 | + hint: 'rgba(72, 200, 207, 0.40)', |
| 29 | +} |
| 30 | + |
| 31 | +type State = { |
| 32 | + bx: number; by: number |
| 33 | + vx: number; vy: number |
| 34 | + lpy: number; rpy: number |
| 35 | + spd: number |
| 36 | + flash: number // frames remaining for hit flash |
| 37 | + trail: Array<{ x: number; y: number }> |
| 38 | +} |
| 39 | + |
| 40 | +// ── helpers ─────────────────────────────────────────────────────────────────── |
| 41 | +function mkState(w: number, h: number): State { |
| 42 | + const angle = (Math.random() * 50 - 25) * (Math.PI / 180) |
| 43 | + const dir = Math.random() > 0.5 ? 1 : -1 |
| 44 | + return { |
| 45 | + bx: w / 2, by: h / 2, |
| 46 | + vx: Math.cos(angle) * SPEED_INIT * dir, |
| 47 | + vy: Math.sin(angle) * SPEED_INIT, |
| 48 | + lpy: h / 2 - PH / 2, |
| 49 | + rpy: h / 2 - PH / 2, |
| 50 | + spd: SPEED_INIT, |
| 51 | + flash: 0, |
| 52 | + trail: [], |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +function normalize(vx: number, vy: number, spd: number): [number, number] { |
| 57 | + const mag = Math.sqrt(vx * vx + vy * vy) |
| 58 | + return [(vx / mag) * spd, (vy / mag) * spd] |
| 59 | +} |
| 60 | + |
| 61 | +// ── component ───────────────────────────────────────────────────────────────── |
| 62 | +export default function PingPongGame() { |
| 63 | + const canvasRef = useRef<HTMLCanvasElement>(null) |
| 64 | + const stateRef = useRef<State | null>(null) |
| 65 | + const rafRef = useRef<number>(0) |
| 66 | + const wRef = useRef(560) |
| 67 | + const hRef = useRef(220) |
| 68 | + const reducedRef = useRef(false) |
| 69 | + const keys = useRef({ up: false, down: false }) |
| 70 | + |
| 71 | + // ── draw one frame ────────────────────────────────────────────────────────── |
| 72 | + const draw = useCallback((ctx: CanvasRenderingContext2D, s: State, w: number, h: number) => { |
| 73 | + // fill background with scanline effect |
| 74 | + ctx.fillStyle = CLR.bg |
| 75 | + ctx.fillRect(0, 0, w, h) |
| 76 | + ctx.fillStyle = CLR.bgDim |
| 77 | + for (let y = 1; y < h; y += 2) ctx.fillRect(0, y, w, 1) |
| 78 | + |
| 79 | + // faint "404" watermark |
| 80 | + ctx.save() |
| 81 | + ctx.fillStyle = CLR.watermark |
| 82 | + ctx.font = `700 ${Math.min(w * 0.28, 108)}px ui-monospace, monospace` |
| 83 | + ctx.textAlign = 'center' |
| 84 | + ctx.textBaseline = 'middle' |
| 85 | + ctx.fillText('404', w / 2, h / 2) |
| 86 | + ctx.restore() |
| 87 | + |
| 88 | + // center dashed divider |
| 89 | + ctx.save() |
| 90 | + ctx.setLineDash([5, 5]) |
| 91 | + ctx.strokeStyle = CLR.center |
| 92 | + ctx.lineWidth = 1 |
| 93 | + ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke() |
| 94 | + ctx.setLineDash([]) |
| 95 | + ctx.restore() |
| 96 | + |
| 97 | + // ── ball trail ────────────────────────────────────────────────────────── |
| 98 | + s.trail.forEach((pt, i) => { |
| 99 | + const a = (i / s.trail.length) * 0.28 |
| 100 | + ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})` |
| 101 | + const sz = BS * (i / s.trail.length) * 0.7 |
| 102 | + ctx.fillRect(Math.round(pt.x - sz / 2), Math.round(pt.y - sz / 2), sz, sz) |
| 103 | + }) |
| 104 | + |
| 105 | + // ── ball ──────────────────────────────────────────────────────────────── |
| 106 | + const bsz = s.flash > 0 ? BS + 3 : BS |
| 107 | + if (s.flash > 0) { |
| 108 | + ctx.save() |
| 109 | + ctx.shadowColor = CLR.ballGlo |
| 110 | + ctx.shadowBlur = 14 |
| 111 | + } |
| 112 | + ctx.fillStyle = CLR.ball |
| 113 | + ctx.fillRect(Math.round(s.bx - bsz / 2), Math.round(s.by - bsz / 2), bsz, bsz) |
| 114 | + if (s.flash > 0) ctx.restore() |
| 115 | + |
| 116 | + // ── left paddle (player) ──────────────────────────────────────────────── |
| 117 | + ctx.save() |
| 118 | + ctx.shadowColor = CLR.paddleGlo |
| 119 | + ctx.shadowBlur = s.flash > 0 ? 18 : 10 |
| 120 | + ctx.fillStyle = CLR.paddle |
| 121 | + ctx.fillRect(GAP, Math.round(s.lpy), PW, PH) |
| 122 | + ctx.restore() |
| 123 | + // pixel "4" label beside paddle (outside the canvas left) |
| 124 | + ctx.fillStyle = CLR.hint |
| 125 | + ctx.font = '700 10px ui-monospace, monospace' |
| 126 | + ctx.textAlign = 'center' |
| 127 | + ctx.textBaseline = 'middle' |
| 128 | + ctx.fillText('4', GAP + PW + 9, Math.round(s.lpy) + PH / 2) |
| 129 | + |
| 130 | + // ── right paddle (AI) ─────────────────────────────────────────────────── |
| 131 | + const rx = w - GAP - PW |
| 132 | + ctx.save() |
| 133 | + ctx.shadowColor = CLR.paddleGlo |
| 134 | + ctx.shadowBlur = 10 |
| 135 | + ctx.fillStyle = CLR.paddle |
| 136 | + ctx.fillRect(rx, Math.round(s.rpy), PW, PH) |
| 137 | + ctx.restore() |
| 138 | + ctx.fillStyle = CLR.hint |
| 139 | + ctx.font = '700 10px ui-monospace, monospace' |
| 140 | + ctx.textAlign = 'center' |
| 141 | + ctx.textBaseline = 'middle' |
| 142 | + ctx.fillText('4', rx - 9, Math.round(s.rpy) + PH / 2) |
| 143 | + |
| 144 | + // ── control hint (first render) ───────────────────────────────────────── |
| 145 | + ctx.fillStyle = CLR.hint |
| 146 | + ctx.font = '400 9px ui-monospace, monospace' |
| 147 | + ctx.textAlign = 'center' |
| 148 | + ctx.textBaseline = 'bottom' |
| 149 | + ctx.fillText('move mouse / touch · arrow keys when focused', w / 2, h - 5) |
| 150 | + }, []) |
| 151 | + |
| 152 | + // ── game tick ─────────────────────────────────────────────────────────────── |
| 153 | + const tick = useCallback(() => { |
| 154 | + const canvas = canvasRef.current |
| 155 | + if (!canvas) return |
| 156 | + const ctx = canvas.getContext('2d') |
| 157 | + if (!ctx) return |
| 158 | + const s = stateRef.current |
| 159 | + if (!s) return |
| 160 | + const w = wRef.current |
| 161 | + const h = hRef.current |
| 162 | + |
| 163 | + // keyboard paddle |
| 164 | + if (keys.current.up) s.lpy = Math.max(0, s.lpy - 4) |
| 165 | + if (keys.current.down) s.lpy = Math.min(h - PH, s.lpy + 4) |
| 166 | + |
| 167 | + // advance ball |
| 168 | + s.bx += s.vx |
| 169 | + s.by += s.vy |
| 170 | + |
| 171 | + // trail |
| 172 | + s.trail.push({ x: s.bx, y: s.by }) |
| 173 | + if (s.trail.length > TRAIL_LEN) s.trail.shift() |
| 174 | + |
| 175 | + // top / bottom wall |
| 176 | + if (s.by - BS / 2 <= 0) { |
| 177 | + s.by = BS / 2; s.vy = Math.abs(s.vy) |
| 178 | + } else if (s.by + BS / 2 >= h) { |
| 179 | + s.by = h - BS / 2; s.vy = -Math.abs(s.vy) |
| 180 | + } |
| 181 | + |
| 182 | + // AI right paddle – tracks ball with speed cap & slight lag |
| 183 | + const rCenter = s.rpy + PH / 2 |
| 184 | + const lag = 0.82 // reduce to make AI easier to beat |
| 185 | + if (rCenter < s.by - 2) s.rpy = Math.min(h - PH, s.rpy + AI_SPEED * lag) |
| 186 | + else if (rCenter > s.by + 2) s.rpy = Math.max(0, s.rpy - AI_SPEED * lag) |
| 187 | + |
| 188 | + // ── left paddle collision ─────────────────────────────────────────────── |
| 189 | + const lEdge = GAP + PW |
| 190 | + if (s.vx < 0 && s.bx - BS / 2 <= lEdge && s.bx > GAP && |
| 191 | + s.by + BS / 2 >= s.lpy && s.by - BS / 2 <= s.lpy + PH) { |
| 192 | + s.bx = lEdge + BS / 2 |
| 193 | + s.spd = Math.min(SPEED_MAX, s.spd + SPEED_INC) |
| 194 | + const rel = (s.by - (s.lpy + PH / 2)) / (PH / 2) // -1 to 1 |
| 195 | + s.vx = Math.abs(s.vx) |
| 196 | + s.vy = rel * s.spd * 0.75 |
| 197 | + ;[s.vx, s.vy] = normalize(s.vx, s.vy, s.spd) |
| 198 | + s.flash = 5 |
| 199 | + } |
| 200 | + |
| 201 | + // ── right paddle collision ────────────────────────────────────────────── |
| 202 | + const rEdge = w - GAP - PW |
| 203 | + if (s.vx > 0 && s.bx + BS / 2 >= rEdge && s.bx < w - GAP && |
| 204 | + s.by + BS / 2 >= s.rpy && s.by - BS / 2 <= s.rpy + PH) { |
| 205 | + s.bx = rEdge - BS / 2 |
| 206 | + s.spd = Math.min(SPEED_MAX, s.spd + SPEED_INC) |
| 207 | + const rel = (s.by - (s.rpy + PH / 2)) / (PH / 2) |
| 208 | + s.vx = -Math.abs(s.vx) |
| 209 | + s.vy = rel * s.spd * 0.75 |
| 210 | + ;[s.vx, s.vy] = normalize(s.vx, s.vy, s.spd) |
| 211 | + s.flash = 5 |
| 212 | + } |
| 213 | + |
| 214 | + if (s.flash > 0) s.flash-- |
| 215 | + |
| 216 | + // ── out of bounds → reset ──────────────────────────────────────────────── |
| 217 | + if (s.bx < -30 || s.bx > w + 30) { |
| 218 | + const next = mkState(w, h) |
| 219 | + // keep paddle positions so player doesn't lose their spot |
| 220 | + next.lpy = s.lpy |
| 221 | + next.rpy = s.rpy |
| 222 | + Object.assign(s, next) |
| 223 | + } |
| 224 | + |
| 225 | + draw(ctx, s, w, h) |
| 226 | + rafRef.current = requestAnimationFrame(tick) |
| 227 | + }, [draw]) |
| 228 | + |
| 229 | + // ── setup ─────────────────────────────────────────────────────────────────── |
| 230 | + useEffect(() => { |
| 231 | + const canvas = canvasRef.current |
| 232 | + if (!canvas) return |
| 233 | + |
| 234 | + reducedRef.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches |
| 235 | + |
| 236 | + // DPR-aware resize |
| 237 | + const resize = () => { |
| 238 | + const dpr = window.devicePixelRatio || 1 |
| 239 | + const cssW = canvas.offsetWidth || 560 |
| 240 | + const cssH = canvas.offsetHeight || 220 |
| 241 | + canvas.width = Math.round(cssW * dpr) |
| 242 | + canvas.height = Math.round(cssH * dpr) |
| 243 | + const ctx = canvas.getContext('2d')! |
| 244 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) |
| 245 | + wRef.current = cssW |
| 246 | + hRef.current = cssH |
| 247 | + if (!stateRef.current) { |
| 248 | + stateRef.current = mkState(cssW, cssH) |
| 249 | + } else { |
| 250 | + // clamp to new dimensions |
| 251 | + stateRef.current.lpy = Math.min(stateRef.current.lpy, cssH - PH) |
| 252 | + stateRef.current.rpy = Math.min(stateRef.current.rpy, cssH - PH) |
| 253 | + } |
| 254 | + if (reducedRef.current) { |
| 255 | + draw(ctx, stateRef.current, cssW, cssH) |
| 256 | + } |
| 257 | + } |
| 258 | + |
| 259 | + resize() |
| 260 | + const ro = new ResizeObserver(resize) |
| 261 | + ro.observe(canvas) |
| 262 | + |
| 263 | + if (!reducedRef.current) { |
| 264 | + rafRef.current = requestAnimationFrame(tick) |
| 265 | + } |
| 266 | + |
| 267 | + // ── mouse ──────────────────────────────────────────────────────────────── |
| 268 | + const onMouseMove = (e: MouseEvent) => { |
| 269 | + const rect = canvas.getBoundingClientRect() |
| 270 | + const y = e.clientY - rect.top |
| 271 | + if (stateRef.current) { |
| 272 | + stateRef.current.lpy = Math.max(0, Math.min(hRef.current - PH, y - PH / 2)) |
| 273 | + } |
| 274 | + } |
| 275 | + canvas.addEventListener('mousemove', onMouseMove) |
| 276 | + |
| 277 | + // ── touch ──────────────────────────────────────────────────────────────── |
| 278 | + const onTouchMove = (e: TouchEvent) => { |
| 279 | + e.preventDefault() |
| 280 | + const rect = canvas.getBoundingClientRect() |
| 281 | + const y = e.touches[0].clientY - rect.top |
| 282 | + if (stateRef.current) { |
| 283 | + stateRef.current.lpy = Math.max(0, Math.min(hRef.current - PH, y - PH / 2)) |
| 284 | + } |
| 285 | + } |
| 286 | + canvas.addEventListener('touchmove', onTouchMove, { passive: false }) |
| 287 | + |
| 288 | + // ── keyboard (only when canvas is focused) ──────────────────────────────── |
| 289 | + const onKeyDown = (e: KeyboardEvent) => { |
| 290 | + if (document.activeElement !== canvas) return |
| 291 | + if (e.key === 'ArrowUp') { e.preventDefault(); keys.current.up = true } |
| 292 | + if (e.key === 'ArrowDown') { e.preventDefault(); keys.current.down = true } |
| 293 | + } |
| 294 | + const onKeyUp = (e: KeyboardEvent) => { |
| 295 | + if (e.key === 'ArrowUp') keys.current.up = false |
| 296 | + if (e.key === 'ArrowDown') keys.current.down = false |
| 297 | + } |
| 298 | + window.addEventListener('keydown', onKeyDown) |
| 299 | + window.addEventListener('keyup', onKeyUp) |
| 300 | + |
| 301 | + return () => { |
| 302 | + cancelAnimationFrame(rafRef.current) |
| 303 | + ro.disconnect() |
| 304 | + canvas.removeEventListener('mousemove', onMouseMove) |
| 305 | + canvas.removeEventListener('touchmove', onTouchMove) |
| 306 | + window.removeEventListener('keydown', onKeyDown) |
| 307 | + window.removeEventListener('keyup', onKeyUp) |
| 308 | + } |
| 309 | + }, [tick, draw]) |
| 310 | + |
| 311 | + return ( |
| 312 | + <canvas |
| 313 | + ref={canvasRef} |
| 314 | + className="pingPongCanvas" |
| 315 | + role="img" |
| 316 | + aria-label="Decorative pixel-art ping-pong animation. The left paddle is player-controlled." |
| 317 | + tabIndex={0} |
| 318 | + /> |
| 319 | + ) |
| 320 | +} |
0 commit comments