"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions"; import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick"; import { playFlap, playHit, playXp } from "./sounds"; const CANVAS_W = 420; const CANVAS_H = 640; const GROUND_H = 80; const GROUND_Y = CANVAS_H - GROUND_H; const BIRD_X = 96; const BIRD_R = 14; const START_Y = CANVAS_H * 0.42; const GRAVITY = 0.38; const FLAP_VY = -7.8; const MAX_VY = 8.5; const PIPE_W = 72; const PIPE_GAP = 168; const PIPE_SPEED = 1.8; const PIPE_SPACING = 250; const PASSES_PER_XP = 15; const RAMP_SECONDS = 60; const MAX_SPEED_SCALE = 2; const STARS = Array.from({ length: 70 }, (_, i) => ({ x: (i * 97) % CANVAS_W, y: (i * 53) % (CANVAS_H - GROUND_H), r: (i % 3) + 0.5, })); const DAY_CYCLE_SECONDS = 60; const DAY_SKY_TOP: [number, number, number] = [56, 189, 248]; const DAY_SKY_BOTTOM: [number, number, number] = [186, 230, 253]; const NIGHT_SKY_TOP: [number, number, number] = [11, 18, 32]; const NIGHT_SKY_BOTTOM: [number, number, number] = [30, 41, 59]; function mix(a: [number, number, number], b: [number, number, number], t: number): string { const r = Math.round(a[0] + (b[0] - a[0]) * t); const g = Math.round(a[1] + (b[1] - a[1]) * t); const bl = Math.round(a[2] + (b[2] - a[2]) * t); return `rgb(${r}, ${g}, ${bl})`; } type Phase = "ready" | "playing" | "over"; interface Pipe { x: number; gapY: number; scored: boolean; seed: number; } interface GameState { birdY: number; birdVy: number; pipes: Pipe[]; score: number; wingT: number; groundOffset: number; runTime: number; cycleT: number; } function drawTopBuilding(ctx: CanvasRenderingContext2D, p: Pipe, light: number) { const bottom = p.gapY; if (bottom <= 2) return; const x = p.x; const s = p.seed; const body = ctx.createLinearGradient(x, 0, x + PIPE_W, 0); body.addColorStop(0, mix([13, 22, 38], [148, 163, 184], light)); body.addColorStop(0.35, mix([24, 36, 54], [166, 180, 198], light)); body.addColorStop(1, mix([11, 19, 32], [100, 116, 139], light)); ctx.fillStyle = body; ctx.fillRect(x, 0, PIPE_W, bottom); ctx.fillStyle = mix([34, 48, 66], [203, 213, 225], light); ctx.fillRect(x - 3, bottom - 6, PIPE_W + 6, 6); ctx.fillStyle = mix([26, 37, 54], [148, 163, 184], light); ctx.fillRect(x + 8, bottom - 12, 10, 6); ctx.fillRect(x + PIPE_W - 22, bottom - 13, 8, 7); ctx.fillStyle = `rgba(148, 163, 184, ${(0.14 + 0.18 * light).toFixed(2)})`; ctx.fillRect(x + PIPE_W - 4, 0, 4, bottom); ctx.fillRect(x, 0, 1.5, bottom); const litAlpha = Math.max(0, 1 - light * 1.15); const cols = Math.floor((PIPE_W - 14) / 9); const rows = Math.floor((bottom - 18) / 12); for (let c = 0; c < cols; c++) { for (let r = 0; r < rows; r++) { const wx = x + 7 + c * 9; const wy = 9 + r * 12; const v = (s + c * 7 + r * 13) % 10; ctx.fillStyle = `rgba(15, 23, 42, ${(0.35 + 0.3 * light).toFixed(2)})`; ctx.fillRect(wx, wy, 4, 6); if (v < 4 && litAlpha > 0.02) { ctx.fillStyle = `rgba(253, 224, 71, ${(v === 0 ? litAlpha : litAlpha * 0.45).toFixed(2)})`; ctx.fillRect(wx, wy, 4, 6); } else if (v === 4 && litAlpha > 0.02) { ctx.fillStyle = `rgba(125, 211, 252, ${(litAlpha * 0.65).toFixed(2)})`; ctx.fillRect(wx, wy, 4, 6); } } } } function drawBuilding(ctx: CanvasRenderingContext2D, p: Pipe, light: number) { const bottomY = p.gapY + PIPE_GAP; const h = GROUND_Y - bottomY; if (h <= 2) return; const x = p.x; const s = p.seed; const body = ctx.createLinearGradient(x, 0, x + PIPE_W, 0); body.addColorStop(0, mix([13, 22, 38], [148, 163, 184], light)); body.addColorStop(0.35, mix([24, 36, 54], [166, 180, 198], light)); body.addColorStop(1, mix([11, 19, 32], [100, 116, 139], light)); ctx.fillStyle = body; ctx.fillRect(x, bottomY, PIPE_W, h); ctx.fillStyle = mix([34, 48, 66], [203, 213, 225], light); ctx.fillRect(x - 3, bottomY, PIPE_W + 6, 6); ctx.fillStyle = mix([26, 37, 54], [148, 163, 184], light); ctx.fillRect(x + 8, bottomY - 5, 10, 5); ctx.fillRect(x + PIPE_W - 22, bottomY - 3, 8, 3); ctx.fillStyle = `rgba(148, 163, 184, ${(0.14 + 0.18 * light).toFixed(2)})`; ctx.fillRect(x + PIPE_W - 4, bottomY, 4, h); ctx.fillRect(x, bottomY, 1.5, h); const litAlpha = Math.max(0, 1 - light * 1.15); const cols = Math.floor((PIPE_W - 14) / 9); const rows = Math.floor((h - 16) / 12); for (let c = 0; c < cols; c++) { for (let r = 0; r < rows; r++) { const wx = x + 7 + c * 9; const wy = bottomY + 9 + r * 12; const v = (s + c * 7 + r * 13) % 10; ctx.fillStyle = `rgba(15, 23, 42, ${(0.35 + 0.3 * light).toFixed(2)})`; ctx.fillRect(wx, wy, 4, 6); if (v < 4 && litAlpha > 0.02) { ctx.fillStyle = `rgba(253, 224, 71, ${(v === 0 ? litAlpha : litAlpha * 0.45).toFixed(2)})`; ctx.fillRect(wx, wy, 4, 6); } else if (v === 4 && litAlpha > 0.02) { ctx.fillStyle = `rgba(125, 211, 252, ${(litAlpha * 0.65).toFixed(2)})`; ctx.fillRect(wx, wy, 4, 6); } } } } function drawObstacle(ctx: CanvasRenderingContext2D, p: Pipe, light: number) { drawTopBuilding(ctx, p, light); drawBuilding(ctx, p, light); } function drawHelicopter( ctx: CanvasRenderingContext2D, y: number, vy: number, wingT: number, dead: boolean, light: number, ) { const angle = dead ? Math.PI / 2 : Math.max(-0.45, Math.min(vy * 0.05, 0.8)); const rotorAngle = wingT * 4; ctx.save(); ctx.translate(BIRD_X, y); ctx.rotate(angle); ctx.lineCap = "round"; ctx.strokeStyle = "#0c1220"; ctx.lineWidth = 3; const blades = 4; const rotorR = 24; for (let i = 0; i < blades; i++) { const a = rotorAngle + (i * Math.PI * 2) / blades; ctx.beginPath(); ctx.moveTo(0, -7); ctx.lineTo(Math.cos(a) * rotorR, -7 - Math.sin(a) * rotorR); ctx.stroke(); } ctx.fillStyle = "#0c1220"; ctx.beginPath(); ctx.arc(0, -7, 2.6, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "#0c1220"; ctx.lineWidth = 2.4; for (let i = 0; i < 2; i++) { const a = rotorAngle * 2 + i * Math.PI; ctx.beginPath(); ctx.moveTo(-21, -3); ctx.lineTo(-21 - Math.cos(a) * 5, -3 - Math.sin(a) * 5); ctx.stroke(); } ctx.fillStyle = "#232a33"; ctx.beginPath(); ctx.moveTo(-8, -2.5); ctx.lineTo(-20, -2); ctx.lineTo(-20, 1.5); ctx.lineTo(-8, 2); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#1a2028"; ctx.beginPath(); ctx.moveTo(-20, -1.5); ctx.lineTo(-20, -8.5); ctx.lineTo(-15.5, -1.5); ctx.closePath(); ctx.fill(); const hull = ctx.createLinearGradient(0, -6, 0, 4); hull.addColorStop(0, "#343c48"); hull.addColorStop(0.5, "#232a33"); hull.addColorStop(1, "#151a21"); ctx.fillStyle = hull; ctx.beginPath(); ctx.moveTo(-6, -3); ctx.lineTo(2, -6.5); ctx.quadraticCurveTo(10, -7, 13, -3); ctx.quadraticCurveTo(16.5, 1.5, 10, 3.5); ctx.lineTo(-2, 3.5); ctx.lineTo(-7, 1); ctx.closePath(); ctx.fill(); ctx.strokeStyle = "#0b0f15"; ctx.lineWidth = 1; ctx.stroke(); ctx.strokeStyle = "rgba(148, 163, 184, 0.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(-1, -5.5); ctx.lineTo(9, -5.5); ctx.stroke(); ctx.fillStyle = "rgba(125, 211, 252, 0.85)"; ctx.beginPath(); ctx.moveTo(5, -5); ctx.quadraticCurveTo(14, -5, 15.5, -1); ctx.quadraticCurveTo(13.5, 2, 7, 2.5); ctx.closePath(); ctx.fill(); ctx.strokeStyle = "rgba(148, 163, 184, 0.4)"; ctx.lineWidth = 0.8; ctx.stroke(); ctx.strokeStyle = "rgba(148, 163, 184, 0.22)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(-2, -1); ctx.lineTo(-2, 3.2); ctx.stroke(); ctx.strokeStyle = "#0c1220"; ctx.lineWidth = 1.8; ctx.beginPath(); ctx.moveTo(-6, 3.5); ctx.lineTo(-8, 7.5); ctx.moveTo(8, 3.5); ctx.lineTo(8, 7.5); ctx.moveTo(-8, 7.5); ctx.lineTo(9, 7.5); ctx.stroke(); ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(-1, 3.5); ctx.lineTo(-1, 7.5); ctx.moveTo(4, 3.5); ctx.lineTo(4, 7.5); ctx.stroke(); ctx.fillStyle = "rgba(253, 224, 71, 0.9)"; ctx.beginPath(); ctx.arc(15.5, 2.5, 1.3, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "rgba(251, 146, 60, 0.45)"; ctx.fillRect(-6, -4, 4, 1.6); const night = 1 - light; if (night > 0.05) { if (Math.sin(wingT * 0.105) > 0.3) { const glow = ctx.createRadialGradient(-19, -4, 0, -19, -4, 7); glow.addColorStop(0, `rgba(239, 68, 68, ${(0.55 * night).toFixed(2)})`); glow.addColorStop(1, "rgba(239, 68, 68, 0)"); ctx.fillStyle = glow; ctx.fillRect(-26, -11, 14, 14); ctx.fillStyle = `rgba(239, 68, 68, ${(0.95 * night).toFixed(2)})`; ctx.beginPath(); ctx.arc(-19, -4, 1.7, 0, Math.PI * 2); ctx.fill(); } if (Math.sin(wingT * 0.3) > 0.5) { const glow = ctx.createRadialGradient(12, 3, 0, 12, 3, 7); glow.addColorStop(0, `rgba(226, 232, 240, ${(0.5 * night).toFixed(2)})`); glow.addColorStop(1, "rgba(226, 232, 240, 0)"); ctx.fillStyle = glow; ctx.fillRect(5, -4, 14, 14); ctx.fillStyle = `rgba(248, 250, 252, ${(0.95 * night).toFixed(2)})`; ctx.beginPath(); ctx.arc(12, 3, 1.5, 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); } function birdHitsPipe(birdX: number, birdY: number, pipe: Pipe): boolean { if (birdX + BIRD_R < pipe.x || birdX - BIRD_R > pipe.x + PIPE_W) return false; const topBlocked = birdY - BIRD_R < pipe.gapY; const bottomBlocked = birdY + BIRD_R > pipe.gapY + PIPE_GAP; return topBlocked || bottomBlocked; } export function FlappyBird({ initialStats: serverStats, onGameRecorded, onPhaseChange, }: { initialStats: FlappyStats; onGameRecorded?: () => void; onPhaseChange?: (phase: Phase) => void; }) { const router = useRouter(); const canvasRef = useRef(null); const wrapperRef = useRef(null); const phaseRef = useRef("ready"); const gameRef = useRef({ birdY: START_Y, birdVy: 0, pipes: [], score: 0, wingT: 0, groundOffset: 0, runTime: 0, cycleT: 0, }); const submittingRef = useRef(false); const statsRef = useRef(serverStats); const [phase, setPhase] = useState("ready"); const [score, setScore] = useState(0); const [stats, setStats] = useState(serverStats); const [lastRun, setLastRun] = useState<{ score: number; xpEarned: number; best: number; error?: string; } | null>(null); const [submitting, setSubmitting] = useState(false); const setPhaseSafe = useCallback((p: Phase) => { phaseRef.current = p; setPhase(p); onPhaseChange?.(p); }, [onPhaseChange]); const reset = useCallback(() => { const g = gameRef.current; g.birdY = START_Y; g.birdVy = 0; g.pipes = []; g.score = 0; g.wingT = 0; g.groundOffset = 0; g.runTime = 0; g.cycleT = 0; setScore(0); setPhaseSafe("ready"); }, [setPhaseSafe]); const handleGameOver = useCallback( async (finalScore: number) => { if (submittingRef.current) return; if (finalScore <= 0) { setLastRun({ score: finalScore, xpEarned: 0, best: statsRef.current.bestScore, }); return; } submittingRef.current = true; setSubmitting(true); const res = await submitFlappyScore({ passes: finalScore }); submittingRef.current = false; setSubmitting(false); if (res.success && res.data) { statsRef.current = res.data; setStats(res.data); onGameRecorded?.(); if (res.data.xpEarned > 0) { playXp(); window.dispatchEvent( new CustomEvent(PROFILE_UPDATE_EVENT, { detail: { reason: "xp", xpGained: res.data.xpEarned }, }), ); window.setTimeout(() => router.refresh(), 1700); } setLastRun({ score: finalScore, xpEarned: res.data.xpEarned, best: res.data.bestScore, }); } else { setLastRun({ score: finalScore, xpEarned: 0, best: statsRef.current.bestScore, error: res.error ?? "Could not record score.", }); } }, [onGameRecorded, router], ); const handleGameOverRef = useRef(handleGameOver); useEffect(() => { handleGameOverRef.current = handleGameOver; }, [handleGameOver]); const flap = useCallback(() => { const p = phaseRef.current; if (p === "ready") { const g = gameRef.current; g.birdY = START_Y; g.birdVy = FLAP_VY; setPhaseSafe("playing"); playFlap(); } else if (p === "playing") { gameRef.current.birdVy = FLAP_VY; playFlap(); } else if (p === "over") { reset(); } }, [reset, setPhaseSafe]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; canvas.width = CANVAS_W * dpr; canvas.height = CANVAS_H * dpr; canvas.style.width = `${CANVAS_W}px`; canvas.style.height = `${CANVAS_H}px`; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.scale(dpr, dpr); let raf = 0; let lastTime = performance.now(); const die = () => { const g = gameRef.current; setPhaseSafe("over"); playHit(); handleGameOverRef.current(g.score); }; const step = (now: number) => { raf = requestAnimationFrame(step); const dt = Math.min(Math.max((now - lastTime) / 16.6667, 0), 3); lastTime = now; const g = gameRef.current; const p = phaseRef.current; if (p === "ready") { g.wingT += dt; } else if (p === "playing") { g.wingT += dt; g.runTime += dt; g.cycleT += dt / 60; const speedScale = Math.min(1 + g.runTime / RAMP_SECONDS, MAX_SPEED_SCALE); g.birdVy = Math.min(g.birdVy + GRAVITY * dt, MAX_VY); g.birdY += g.birdVy * dt; g.groundOffset = (g.groundOffset + PIPE_SPEED * dt * speedScale) % 24; if (g.pipes.length === 0 || g.pipes[g.pipes.length - 1].x < CANVAS_W - PIPE_SPACING) { const margin = 40; const maxGapY = GROUND_Y - PIPE_GAP - margin; g.pipes.push({ x: CANVAS_W, gapY: margin + Math.random() * (maxGapY - margin), scored: false, seed: Math.floor(Math.random() * 1000), }); } for (const pipe of g.pipes) pipe.x -= PIPE_SPEED * dt * speedScale; let passed = 0; for (const pipe of g.pipes) { if (!pipe.scored && pipe.x + PIPE_W < BIRD_X - BIRD_R) { pipe.scored = true; passed += 1; } } if (passed > 0) { g.score += passed; setScore(g.score); } g.pipes = g.pipes.filter((pipe) => pipe.x + PIPE_W > -20); if (g.birdY + BIRD_R > GROUND_Y) { die(); return; } if (g.birdY - BIRD_R < 0) { g.birdY = BIRD_R; g.birdVy = 0; } for (const pipe of g.pipes) { if (birdHitsPipe(BIRD_X, g.birdY, pipe)) { die(); return; } } } ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); const cycle = (g.cycleT / DAY_CYCLE_SECONDS) * Math.PI * 2; const light = (Math.cos(cycle) + 1) / 2; const sky = ctx.createLinearGradient(0, 0, 0, CANVAS_H); sky.addColorStop(0, mix(NIGHT_SKY_TOP, DAY_SKY_TOP, light)); sky.addColorStop(1, mix(NIGHT_SKY_BOTTOM, DAY_SKY_BOTTOM, light)); ctx.fillStyle = sky; ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); const starAlpha = 0.6 * (1 - light); for (const s of STARS) { ctx.fillStyle = `rgba(255, 255, 255, ${starAlpha.toFixed(2)})`; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); } const horizonY = GROUND_Y - 28; const sunX = 180 + 110 * Math.sin(cycle); const sunY = horizonY - 380 * Math.cos(cycle); const moonX = 240 - 110 * Math.sin(cycle); const moonY = horizonY + 380 * Math.cos(cycle); const twilight = Math.abs(Math.sin(cycle)); if (twilight > 0.05) { const horizonGlow = ctx.createLinearGradient(0, GROUND_Y - 110, 0, GROUND_Y); horizonGlow.addColorStop(0, "rgba(251, 146, 60, 0)"); horizonGlow.addColorStop(1, `rgba(251, 146, 60, ${(twilight * 0.4).toFixed(2)})`); ctx.fillStyle = horizonGlow; ctx.fillRect(0, GROUND_Y - 110, CANVAS_W, 110); } if (sunY < horizonY + 60) { ctx.save(); ctx.globalAlpha = Math.min(1, light * 1.4); const glow = ctx.createRadialGradient(sunX, sunY, 8, sunX, sunY, 48); glow.addColorStop(0, "rgba(253, 224, 71, 0.85)"); glow.addColorStop(1, "rgba(253, 224, 71, 0)"); ctx.fillStyle = glow; ctx.fillRect(sunX - 48, sunY - 48, 96, 96); ctx.fillStyle = "#fde68a"; ctx.beginPath(); ctx.arc(sunX, sunY, 17, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#fbbf24"; ctx.beginPath(); ctx.arc(sunX, sunY, 13, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } if (moonY < horizonY + 60) { ctx.save(); ctx.globalAlpha = Math.max(0, 1 - light); ctx.fillStyle = "#f1f5f9"; ctx.beginPath(); ctx.arc(moonX, moonY, 26, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#cbd5e1"; ctx.beginPath(); ctx.arc(moonX - 8, moonY - 6, 5, 0, Math.PI * 2); ctx.arc(moonX + 8, moonY + 8, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } for (const pipe of g.pipes) drawObstacle(ctx, pipe, light); ctx.fillStyle = mix([11, 15, 20], [100, 116, 139], light); ctx.fillRect(0, GROUND_Y, CANVAS_W, GROUND_H); ctx.fillStyle = mix([28, 37, 48], [148, 163, 184], light); ctx.fillRect(0, GROUND_Y, CANVAS_W, 5); ctx.fillStyle = mix([58, 70, 87], [203, 213, 225], light); ctx.fillRect(0, GROUND_Y, CANVAS_W, 2); ctx.fillStyle = `rgba(148, 163, 184, ${(0.45 + 0.3 * light).toFixed(2)})`; for (let x = -g.groundOffset; x < CANVAS_W; x += 24) { ctx.fillRect(x, GROUND_Y + 14, 14, 3); } const birdY = p === "ready" ? START_Y + Math.sin(g.wingT * 0.06) * 4 : g.birdY; drawHelicopter(ctx, birdY, g.birdVy, g.wingT, p === "over", light); if (p === "playing" || p === "over") { ctx.font = "bold 52px ui-monospace, monospace"; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillStyle = "rgba(0, 0, 0, 0.45)"; ctx.fillText(String(g.score), CANVAS_W / 2 + 3, 34 + 3); ctx.fillStyle = "#ffffff"; ctx.fillText(String(g.score), CANVAS_W / 2, 34); } }; raf = requestAnimationFrame(step); const onKeyDown = (e: KeyboardEvent) => { if (e.code === "Space" || e.code === "ArrowUp" || e.code === "KeyW") { e.preventDefault(); e.stopPropagation(); flap(); } }; const onKeyUp = (e: KeyboardEvent) => { if (e.code === "Space") { e.preventDefault(); e.stopPropagation(); } }; window.addEventListener("keydown", onKeyDown, { capture: true }); window.addEventListener("keyup", onKeyUp, { capture: true }); return () => { cancelAnimationFrame(raf); window.removeEventListener("keydown", onKeyDown, { capture: true }); window.removeEventListener("keyup", onKeyUp, { capture: true }); }; }, [flap, setPhaseSafe]); return (

"Flight Simulator"

+1 XP per {PASSES_PER_XP} obstacles in a single run

{ if (e.button !== 0) return; e.preventDefault(); wrapperRef.current?.focus({ preventScroll: true }); flap(); }} > {phase === "ready" && (
Flight Simulator (definitely not Flappy Bird) Tap or press Space to flap Avoid the towers above and below. Best run: {stats.bestScore}
)} {phase === "over" && (
Game Over
Score
{lastRun?.score ?? score}
Best
{lastRun?.best ?? stats.bestScore}
{lastRun && lastRun.xpEarned > 0 && ( +{lastRun.xpEarned} XP )} {lastRun?.error && {lastRun.error}} {submitting && Recording score…} Tap or press Space to play again
)}
Games {stats.gamesPlayed}
Obstacles {stats.totalPasses}
Best run {stats.bestScore}
XP from the sim {stats.flappyXp}
); }