1
0
Fork 0

feat(flappy): add flight simulator minigame with per-run XP rewards

This commit is contained in:
Jason Fraley 2026-08-02 12:55:54 -04:00
parent 0a2a8a87b9
commit d754b2954a
11 changed files with 879 additions and 35 deletions

View file

@ -0,0 +1,126 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
export interface FlappyStats {
gamesPlayed: number;
totalPasses: number;
bestScore: number;
flappyXp: number;
xpEarned: number;
}
const PASSES_PER_XP = 15;
const MAX_PASSES_PER_GAME = 5000;
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!user) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function submitFlappyScore(input: {
passes: number;
}): Promise<ActionResult<FlappyStats>> {
try {
const { payload, user } = await authenticate();
const passes = Math.floor(input.passes);
if (!Number.isFinite(passes) || passes < 0) {
return { success: false, error: "Invalid score." };
}
if (passes > MAX_PASSES_PER_GAME) {
return { success: false, error: "Score too large." };
}
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: user.id as number } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const profile = profileRes.docs[0];
if (!profile) {
return { success: false, error: "No profile found for this user." };
}
const progression = (profile.progression ?? {}) as {
xp?: unknown;
minigames?: {
flappyBird?: {
gamesPlayed?: number;
totalPasses?: number;
bestScore?: number;
xpEarned?: number;
};
};
};
const current = progression.minigames?.flappyBird ?? {};
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
const totalPasses = (current.totalPasses ?? 0) + passes;
const bestScore = Math.max(current.bestScore ?? 0, passes);
const runXp = Math.floor(passes / PASSES_PER_XP);
const flappyXp = (current.xpEarned ?? 0) + runXp;
const { xp: _xp, ...progressionRest } = progression;
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
...progressionRest,
minigames: {
...(progressionRest.minigames ?? {}),
flappyBird: { gamesPlayed, totalPasses, bestScore, xpEarned: flappyXp },
},
},
},
overrideAccess: true,
depth: 0,
});
if (passes > 0) {
await emitGameEvent(payload, {
type: EventTypes.MinigameFlappy,
message: `Completed a Flappy Bird run passing ${passes} pipe${passes === 1 ? "" : "s"} (+${runXp} XP)`,
actor: user.id as number,
targetCollection: "users",
targetId: user.id as number,
data: { passes, xpEarned: runXp, bestScore, totalPasses, flappyXp },
});
}
return {
success: true,
data: { gamesPlayed, totalPasses, bestScore, flappyXp, xpEarned: runXp },
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}

View file

@ -0,0 +1,62 @@
import { Metadata } from "next";
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { Bird } from "lucide-react";
import { FlappyBird } from "@/components/frontend/flappy/FlappyBird";
export const metadata: Metadata = {
title: "Flappy Bird — Polaris Task Force",
description: "Side training sim. Pass pipes to earn XP.",
};
export default async function FlappyPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect("/login");
}
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: user.id as number } },
limit: 1,
depth: 0,
overrideAccess: true,
select: {
progression: {
minigames: true,
},
},
});
const flappy = profileRes.docs[0]?.progression?.minigames?.flappyBird ?? {};
const stats = {
gamesPlayed: flappy.gamesPlayed ?? 0,
totalPasses: flappy.totalPasses ?? 0,
bestScore: flappy.bestScore ?? 0,
flappyXp: flappy.xpEarned ?? 0,
xpEarned: 0,
};
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Bird className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Flight Simulator</h1>
</div>
<p className="text-sm text-muted-foreground">
An unofficial side activity. Earn 1 XP per 15 pipes passed in a single run.
</p>
</div>
<FlappyBird initialStats={stats} />
</div>
);
}

View file

@ -38,9 +38,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
let isLogistics = false; let isLogistics = false;
if (user) { if (user) {
isLogistics = await hasLogisticsQualification(payload, user).catch( isLogistics = await hasLogisticsQualification(payload, user).catch(() => false);
() => false,
);
const [profileRes, experienceRes] = await Promise.all([ const [profileRes, experienceRes] = await Promise.all([
payload.find({ payload.find({
collection: "profiles", collection: "profiles",
@ -48,7 +46,9 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
select: { select: {
progression: { progression: {
awards: true, awards: true,
scoreboard: true,
xp: true, xp: true,
minigames: true,
}, },
}, },
}), }),

View file

@ -1,15 +1,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { import type { User, Rank, Media, Assignment, Campaign, Map, GameStructure } from "@/payload-types";
User,
Rank,
Media,
Assignment,
Campaign,
Map,
GameStructure,
} from "@/payload-types";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary"; import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";
import { MissionBriefing } from "@/components/frontend/dashboard/MissionBriefing"; import { MissionBriefing } from "@/components/frontend/dashboard/MissionBriefing";
@ -35,7 +27,7 @@ export default async function HomePage() {
select: { select: {
user: true, user: true,
rank: true, rank: true,
progression: { xp: true, awards: true }, progression: { xp: true, awards: true, scoreboard: true, minigames: true },
createdAt: true, createdAt: true,
}, },
}), }),
@ -58,19 +50,14 @@ export default async function HomePage() {
const xpLevel = resolveLevel(xp, experienceRes.docs); const xpLevel = resolveLevel(xp, experienceRes.docs);
const profileUser = profile?.user as User | undefined; const profileUser = profile?.user as User | undefined;
const rank = profile?.rank as Rank | undefined; const rank = profile?.rank as Rank | undefined;
const assignments = assignmentRes.docs as Pick< const assignments = assignmentRes.docs as Pick<Assignment, "name" | "type" | "callsign">[];
Assignment,
"name" | "type" | "callsign"
>[];
const rankData = rank const rankData = rank
? { ? {
name: rank.name, name: rank.name,
abbreviation: rank.abbreviation, abbreviation: rank.abbreviation,
insigniaUrl: insigniaUrl:
rank.insignia && typeof rank.insignia === "object" rank.insignia && typeof rank.insignia === "object" ? (rank.insignia as Media).url : null,
? (rank.insignia as Media).url
: null,
} }
: { name: "Unknown", abbreviation: "?" }; : { name: "Unknown", abbreviation: "?" };
@ -156,11 +143,7 @@ export default async function HomePage() {
message: e.message, message: e.message,
system: e.system ?? true, system: e.system ?? true,
actor: e.actor as number | User | null | undefined, actor: e.actor as number | User | null | undefined,
structure: e.structure as structure: e.structure as number | GameStructure | null | undefined,
| number
| GameStructure
| null
| undefined,
})); }));
// Fetch active campaigns for the sidebar // Fetch active campaigns for the sidebar
@ -188,10 +171,12 @@ export default async function HomePage() {
where: { status: { equals: "inProgress" } }, where: { status: { equals: "inProgress" } },
limit: 0, limit: 0,
}), }),
payload.find({ payload
collection: "missions", .find({
limit: 0, collection: "missions",
}).catch(() => ({ totalDocs: 0 })), limit: 0,
})
.catch(() => ({ totalDocs: 0 })),
payload.find({ payload.find({
collection: "game-structures", collection: "game-structures",
limit: 0, limit: 0,
@ -212,9 +197,7 @@ export default async function HomePage() {
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
Welcome back, {profileUser?.displayName ?? user.username} Welcome back, {profileUser?.displayName ?? user.username}
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Here&apos;s your operations dashboard.</p>
Here&apos;s your operations dashboard.
</p>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">

View file

@ -55,6 +55,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
qualifications: true, qualifications: true,
xp: true, xp: true,
scoreboard: true, scoreboard: true,
minigames: true,
}, },
createdAt: true, createdAt: true,
}, },

View file

@ -74,8 +74,14 @@ export const Profiles: CollectionConfig = {
async ({ req, siblingData }) => { async ({ req, siblingData }) => {
let xpAcc = 0; let xpAcc = 0;
const awardsList = (siblingData as { awards?: number[] })?.awards || []; const progressionData = siblingData as {
const scoreboard = (siblingData as { scoreboard?: Record<string, number | null> })?.scoreboard || {}; awards?: number[];
scoreboard?: Record<string, number | null>;
minigames?: { flappyBird?: { xpEarned?: number | null } };
};
const awardsList = progressionData?.awards || [];
const scoreboard = progressionData?.scoreboard || {};
const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0;
if (awardsList.length > 0) { if (awardsList.length > 0) {
await req.payload await req.payload
@ -111,6 +117,9 @@ export const Profiles: CollectionConfig = {
} }
}); });
// Flappy Bird training sim: 1 XP per 15 pipes passed in a single run
xpAcc += flappyXp;
return xpAcc; return xpAcc;
}, },
], ],
@ -168,6 +177,43 @@ export const Profiles: CollectionConfig = {
}, },
], ],
}, },
{
name: "minigames",
label: "Minigames",
type: "group",
fields: [
{
name: "flappyBird",
label: "Flappy Bird",
type: "group",
fields: [
{
name: "gamesPlayed",
type: "number",
defaultValue: 0,
},
{
name: "totalPasses",
label: "Total Pipes Passed",
type: "number",
defaultValue: 0,
},
{
name: "bestScore",
label: "Best Run (Pipes)",
type: "number",
defaultValue: 0,
},
{
name: "xpEarned",
label: "XP Earned",
type: "number",
defaultValue: 0,
},
],
},
],
},
], ],
}, },
], ],

View file

@ -1,6 +1,15 @@
"use client"; "use client";
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, FileTextIcon, LogOut, Sparkles } from "lucide-react"; import {
BadgeCheck,
Bell,
Bird,
ChevronsUpDown,
CreditCard,
FileTextIcon,
LogOut,
Sparkles,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@ -96,6 +105,12 @@ export function NavUser({
Service Record Service Record
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/flappy">
<Bird />
Flappy Bird
</Link>
</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>

View file

@ -0,0 +1,493 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { submitFlappyScore, type FlappyStats } from "@/app/(frontend)/flappy/actions";
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,
}));
type Phase = "ready" | "playing" | "over";
interface Pipe {
x: number;
gapY: number;
scored: boolean;
}
interface GameState {
birdY: number;
birdVy: number;
pipes: Pipe[];
score: number;
wingT: number;
groundOffset: number;
runTime: number;
}
function drawPipe(ctx: CanvasRenderingContext2D, p: Pipe) {
const topH = p.gapY;
const bottomY = p.gapY + PIPE_GAP;
const bodyColor = "#16a34a";
const edgeColor = "#14532d";
const highlight = "#22c55e";
const capW = PIPE_W + 10;
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);
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);
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 = 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);
}
function drawBird(
ctx: CanvasRenderingContext2D,
y: number,
vy: number,
wingT: number,
dead: boolean,
) {
const angle = dead ? Math.PI / 2 : Math.max(-0.5, Math.min(vy * 0.06, 1.1));
ctx.save();
ctx.translate(BIRD_X, y);
ctx.rotate(angle);
const wingPhase = Math.sin(wingT * 0.35) * 0.9;
ctx.fillStyle = "#d97706";
ctx.beginPath();
ctx.ellipse(-6, wingPhase * 5, 11, 6, wingPhase * 0.4, 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.fillStyle = "#ffffff";
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.closePath();
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 }: { initialStats: FlappyStats }) {
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,
});
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);
}, []);
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;
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);
if (res.data.xpEarned > 0) {
playXp();
}
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.",
});
}
},
[],
);
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;
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,
});
}
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 sky = ctx.createLinearGradient(0, 0, 0, CANVAS_H);
sky.addColorStop(0, "#0b1220");
sky.addColorStop(1, "#1e293b");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
for (const s of STARS) {
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
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();
for (const pipe of g.pipes) drawPipe(ctx, pipe);
ctx.fillStyle = "#111827";
ctx.fillRect(0, GROUND_Y, CANVAS_W, GROUND_H);
ctx.fillStyle = "#22c55e";
ctx.fillRect(0, GROUND_Y, CANVAS_W, 4);
ctx.fillStyle = "#374151";
for (let x = -g.groundOffset; x < CANVAS_W; x += 24) {
ctx.fillRect(x, GROUND_Y + 12, 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");
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">Flappy Bird</h1>
<p className="text-sm text-muted-foreground">
Side training sim · +1 XP per {PASSES_PER_XP} pipes 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="Flappy Bird game"
/>
{phase === "ready" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center pointer-events-none">
<span className="text-4xl font-black uppercase tracking-widest drop-shadow-lg">
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 pipes. 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>Pipes passed</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 Flappy</span>
<span className="font-semibold text-foreground">{stats.flappyXp}</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,95 @@
let audioCtx: AudioContext | null = null;
function getCtx(): AudioContext | null {
if (typeof window === "undefined") return null;
const AudioCtor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtor) return null;
if (!audioCtx) audioCtx = new AudioCtor();
if (audioCtx.state === "suspended") {
void audioCtx.resume();
}
return audioCtx;
}
interface ToneOptions {
frequency: number;
endFrequency?: number;
duration: number;
type?: OscillatorType;
volume?: number;
delay?: number;
}
function playTone(ctx: AudioContext, opts: ToneOptions) {
const { frequency, endFrequency, duration, type = "sine", volume = 0.2, delay = 0 } = opts;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t0 = ctx.currentTime + delay;
osc.type = type;
osc.frequency.setValueAtTime(Math.max(frequency, 1), t0);
if (endFrequency && endFrequency !== frequency) {
osc.frequency.exponentialRampToValueAtTime(Math.max(endFrequency, 1), t0 + duration);
}
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t0);
osc.stop(t0 + duration + 0.05);
}
function playNoise(
ctx: AudioContext,
opts: { duration: number; volume?: number; delay?: number; lowpass?: number },
) {
const { duration, volume = 0.2, delay = 0, lowpass = 800 } = opts;
const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * duration));
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(lowpass, ctx.currentTime + delay);
const gain = ctx.createGain();
const t0 = ctx.currentTime + delay;
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.005);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start(t0);
}
export function playFlap() {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, {
frequency: 260,
endFrequency: 560,
duration: 0.08,
type: "triangle",
volume: 0.12,
});
}
export function playHit() {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, { frequency: 160, endFrequency: 45, duration: 0.25, type: "square", volume: 0.2 });
playNoise(ctx, { duration: 0.18, volume: 0.25, lowpass: 900 });
}
export function playXp() {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, { frequency: 660, duration: 0.09, type: "sine", volume: 0.18 });
playTone(ctx, { frequency: 880, duration: 0.16, type: "sine", volume: 0.18, delay: 0.09 });
}

View file

@ -1188,6 +1188,14 @@ export interface Profile {
airKills?: number | null; airKills?: number | null;
deaths?: number | null; deaths?: number | null;
}; };
minigames?: {
flappyBird?: {
gamesPlayed?: number | null;
totalPasses?: number | null;
bestScore?: number | null;
xpEarned?: number | null;
};
};
}; };
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@ -2126,6 +2134,18 @@ export interface ProfilesSelect<T extends boolean = true> {
airKills?: T; airKills?: T;
deaths?: T; deaths?: T;
}; };
minigames?:
| T
| {
flappyBird?:
| T
| {
gamesPlayed?: T;
totalPasses?: T;
bestScore?: T;
xpEarned?: T;
};
};
}; };
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;

View file

@ -34,6 +34,9 @@ export const EventTypes = {
// Game Tick // Game Tick
GameTick: "game:tick", GameTick: "game:tick",
// Minigames
MinigameFlappy: "minigame:flappy",
} as const; } as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes]; export type EventType = (typeof EventTypes)[keyof typeof EventTypes];