Skip to content

Commit 612f8a8

Browse files
committed
Add 404 page not found
1 parent a39b5bc commit 612f8a8

5 files changed

Lines changed: 517 additions & 1 deletion

File tree

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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

Comments
 (0)