From e5f544f10350cb1eac23b70ba724173d91f3a0fd Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 2 Aug 2026 14:32:51 -0400 Subject: [PATCH] feat(flappy): rework obstacles as lit buildings with day-night cycle --- src/components/frontend/flappy/FlappyBird.tsx | 392 ++++++++++++++---- 1 file changed, 310 insertions(+), 82 deletions(-) diff --git a/src/components/frontend/flappy/FlappyBird.tsx b/src/components/frontend/flappy/FlappyBird.tsx index 6c9e6c5..603dbfd 100644 --- a/src/components/frontend/flappy/FlappyBird.tsx +++ b/src/components/frontend/flappy/FlappyBird.tsx @@ -29,12 +29,27 @@ const STARS = Array.from({ length: 70 }, (_, i) => ({ 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 { @@ -45,93 +60,257 @@ interface GameState { wingT: number; groundOffset: number; runTime: number; + cycleT: number; } -function drawPipe(ctx: CanvasRenderingContext2D, p: Pipe) { - const topH = p.gapY; +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 bodyColor = "#16a34a"; - const edgeColor = "#14532d"; - const highlight = "#22c55e"; - const capW = PIPE_W + 10; + const h = GROUND_Y - bottomY; + if (h <= 2) return; + const x = p.x; + const s = p.seed; - ctx.fillStyle = edgeColor; - ctx.fillRect(p.x, 0, PIPE_W, topH); - ctx.fillStyle = bodyColor; - ctx.fillRect(p.x + 4, 0, PIPE_W - 8, topH); - ctx.fillStyle = highlight; - ctx.fillRect(p.x + 6, 0, 3, topH); + 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 = edgeColor; - ctx.fillRect(p.x - 3, topH - 26, capW, 26); - ctx.fillStyle = bodyColor; - ctx.fillRect(p.x + 1, topH - 24, capW - 8, 22); - ctx.fillStyle = highlight; - ctx.fillRect(p.x + 3, topH - 24, 3, 20); + 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); - const bottomH = GROUND_Y - bottomY; - ctx.fillStyle = edgeColor; - ctx.fillRect(p.x, bottomY, PIPE_W, bottomH); - ctx.fillStyle = bodyColor; - ctx.fillRect(p.x + 4, bottomY, PIPE_W - 8, bottomH); - ctx.fillStyle = highlight; - ctx.fillRect(p.x + 6, bottomY, 3, bottomH); + 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); - ctx.fillStyle = edgeColor; - ctx.fillRect(p.x - 3, bottomY, capW, 26); - ctx.fillStyle = bodyColor; - ctx.fillRect(p.x + 1, bottomY + 2, capW - 8, 22); - ctx.fillStyle = highlight; - ctx.fillRect(p.x + 3, bottomY + 2, 3, 20); + 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 drawBird( +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.5, Math.min(vy * 0.06, 1.1)); + 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"; - const wingPhase = Math.sin(wingT * 0.35) * 0.9; - ctx.fillStyle = "#d97706"; + 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.ellipse(-6, wingPhase * 5, 11, 6, wingPhase * 0.4, 0, Math.PI * 2); + ctx.arc(0, -7, 2.6, 0, Math.PI * 2); ctx.fill(); - const bodyGrad = ctx.createRadialGradient(-4, -6, 4, 0, 0, BIRD_R + 4); - bodyGrad.addColorStop(0, "#fde047"); - bodyGrad.addColorStop(1, "#f59e0b"); - ctx.fillStyle = bodyGrad; - ctx.beginPath(); - ctx.arc(0, 0, BIRD_R, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "#92400e"; - ctx.lineWidth = 1.5; - ctx.stroke(); + 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 = "#ffffff"; + ctx.fillStyle = "#232a33"; ctx.beginPath(); - ctx.arc(6, -5, 5, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "#0f172a"; - ctx.beginPath(); - ctx.arc(8, -5, 2.4, 0, Math.PI * 2); - ctx.fill(); - - ctx.fillStyle = "#f97316"; - ctx.beginPath(); - ctx.moveTo(12, 1); - ctx.lineTo(21, 4); - ctx.lineTo(12, 7); + 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(); } @@ -160,6 +339,7 @@ export function FlappyBird({ wingT: 0, groundOffset: 0, runTime: 0, + cycleT: 0, }); const submittingRef = useRef(false); const statsRef = useRef(serverStats); @@ -189,6 +369,7 @@ export function FlappyBird({ g.wingT = 0; g.groundOffset = 0; g.runTime = 0; + g.cycleT = 0; setScore(0); setPhaseSafe("ready"); }, [setPhaseSafe]); @@ -288,6 +469,7 @@ export function FlappyBird({ } 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; @@ -300,6 +482,7 @@ export function FlappyBird({ x: CANVAS_W, gapY: margin + Math.random() * (maxGapY - margin), scored: false, + seed: Math.floor(Math.random() * 1000), }); } @@ -337,42 +520,87 @@ export function FlappyBird({ 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, "#0b1220"); - sky.addColorStop(1, "#1e293b"); + 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, 0.6)"; + 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(); } - ctx.fillStyle = "#f1f5f9"; - ctx.beginPath(); - ctx.arc(CANVAS_W - 70, 70, 26, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "#cbd5e1"; - ctx.beginPath(); - ctx.arc(CANVAS_W - 78, 64, 5, 0, Math.PI * 2); - ctx.arc(CANVAS_W - 62, 78, 3.5, 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); - for (const pipe of g.pipes) drawPipe(ctx, pipe); + 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); + } - ctx.fillStyle = "#111827"; + 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 = "#22c55e"; - ctx.fillRect(0, GROUND_Y, CANVAS_W, 4); - ctx.fillStyle = "#374151"; + 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 + 12, 14, 3); + ctx.fillRect(x, GROUND_Y + 14, 14, 3); } const birdY = p === "ready" ? START_Y + Math.sin(g.wingT * 0.06) * 4 : g.birdY; - drawBird(ctx, birdY, g.birdVy, g.wingT, p === "over"); + drawHelicopter(ctx, birdY, g.birdVy, g.wingT, p === "over", light); if (p === "playing" || p === "over") { ctx.font = "bold 52px ui-monospace, monospace"; @@ -415,7 +643,7 @@ export function FlappyBird({

"Flight Simulator"

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

@@ -437,7 +665,7 @@ export function FlappyBird({ /> {phase === "ready" && ( -
+
Flight Simulator @@ -446,7 +674,7 @@ export function FlappyBird({ Tap or press Space to flap - Avoid the pipes. Best run: {stats.bestScore} + Avoid the towers above and below. Best run: {stats.bestScore}
)} @@ -484,7 +712,7 @@ export function FlappyBird({ {stats.gamesPlayed}
- Pipes passed + Obstacles {stats.totalPasses}