741 lines
23 KiB
TypeScript
741 lines
23 KiB
TypeScript
"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<HTMLCanvasElement | null>(null);
|
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
const phaseRef = useRef<Phase>("ready");
|
|
const gameRef = useRef<GameState>({
|
|
birdY: START_Y,
|
|
birdVy: 0,
|
|
pipes: [],
|
|
score: 0,
|
|
wingT: 0,
|
|
groundOffset: 0,
|
|
runTime: 0,
|
|
cycleT: 0,
|
|
});
|
|
const submittingRef = useRef(false);
|
|
const statsRef = useRef<FlappyStats>(serverStats);
|
|
|
|
const [phase, setPhase] = useState<Phase>("ready");
|
|
const [score, setScore] = useState(0);
|
|
const [stats, setStats] = useState<FlappyStats>(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 (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="text-center space-y-1">
|
|
<h1 className="text-2xl font-bold uppercase tracking-widest">"Flight Simulator"</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
+1 XP per {PASSES_PER_XP} obstacles in a single run
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
ref={wrapperRef}
|
|
tabIndex={-1}
|
|
className="relative cursor-pointer select-none outline-none"
|
|
onPointerDown={(e) => {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
wrapperRef.current?.focus({ preventScroll: true });
|
|
flap();
|
|
}}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="block max-w-full rounded-xl border border-border shadow-lg"
|
|
aria-label="Flight Simulator game"
|
|
/>
|
|
|
|
{phase === "ready" && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center bg-black/25 pointer-events-none">
|
|
<span className="text-4xl font-black uppercase tracking-wider drop-shadow-lg">
|
|
Flight Simulator
|
|
</span>
|
|
<span className="text-xs text-white/70 italic">(definitely not Flappy Bird)</span>
|
|
<span className="text-sm text-white/80">
|
|
Tap or press <span className="font-semibold">Space</span> to flap
|
|
</span>
|
|
<span className="text-xs text-white/60">
|
|
Avoid the towers above and below. Best run: {stats.bestScore}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{phase === "over" && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-center bg-black/60 rounded-xl pointer-events-none">
|
|
<span className="text-3xl font-black uppercase tracking-widest text-red-400 drop-shadow-lg">
|
|
Game Over
|
|
</span>
|
|
<div className="flex items-baseline gap-6">
|
|
<div>
|
|
<div className="text-xs uppercase tracking-wider text-white/60">Score</div>
|
|
<div className="text-3xl font-bold">{lastRun?.score ?? score}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs uppercase tracking-wider text-white/60">Best</div>
|
|
<div className="text-3xl font-bold">{lastRun?.best ?? stats.bestScore}</div>
|
|
</div>
|
|
</div>
|
|
{lastRun && lastRun.xpEarned > 0 && (
|
|
<span className="text-sm font-semibold text-emerald-400">+{lastRun.xpEarned} XP</span>
|
|
)}
|
|
{lastRun?.error && <span className="text-xs text-red-400">{lastRun.error}</span>}
|
|
{submitting && <span className="text-xs text-white/70">Recording score…</span>}
|
|
<span className="mt-1 text-xs text-white/60">
|
|
Tap or press <span className="font-semibold">Space</span> to play again
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-1.5">
|
|
<span>Games</span>
|
|
<span className="font-semibold text-foreground">{stats.gamesPlayed}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span>Obstacles</span>
|
|
<span className="font-semibold text-foreground">{stats.totalPasses}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span>Best run</span>
|
|
<span className="font-semibold text-foreground">{stats.bestScore}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span>XP from the sim</span>
|
|
<span className="font-semibold text-foreground">{stats.flappyXp}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|