feat(flappy): add flight simulator minigame with per-run XP rewards
This commit is contained in:
parent
0a2a8a87b9
commit
d754b2954a
11 changed files with 879 additions and 35 deletions
126
src/app/(frontend)/flappy/actions.ts
Normal file
126
src/app/(frontend)/flappy/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
62
src/app/(frontend)/flappy/page.tsx
Normal file
62
src/app/(frontend)/flappy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,9 +38,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
let isLogistics = false;
|
||||
|
||||
if (user) {
|
||||
isLogistics = await hasLogisticsQualification(payload, user).catch(
|
||||
() => false,
|
||||
);
|
||||
isLogistics = await hasLogisticsQualification(payload, user).catch(() => false);
|
||||
const [profileRes, experienceRes] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "profiles",
|
||||
|
|
@ -48,7 +46,9 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||
select: {
|
||||
progression: {
|
||||
awards: true,
|
||||
scoreboard: true,
|
||||
xp: true,
|
||||
minigames: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import type {
|
||||
User,
|
||||
Rank,
|
||||
Media,
|
||||
Assignment,
|
||||
Campaign,
|
||||
Map,
|
||||
GameStructure,
|
||||
} from "@/payload-types";
|
||||
import type { User, Rank, Media, Assignment, Campaign, Map, GameStructure } from "@/payload-types";
|
||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";
|
||||
import { MissionBriefing } from "@/components/frontend/dashboard/MissionBriefing";
|
||||
|
|
@ -35,7 +27,7 @@ export default async function HomePage() {
|
|||
select: {
|
||||
user: true,
|
||||
rank: true,
|
||||
progression: { xp: true, awards: true },
|
||||
progression: { xp: true, awards: true, scoreboard: true, minigames: true },
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
|
|
@ -58,19 +50,14 @@ export default async function HomePage() {
|
|||
const xpLevel = resolveLevel(xp, experienceRes.docs);
|
||||
const profileUser = profile?.user as User | undefined;
|
||||
const rank = profile?.rank as Rank | undefined;
|
||||
const assignments = assignmentRes.docs as Pick<
|
||||
Assignment,
|
||||
"name" | "type" | "callsign"
|
||||
>[];
|
||||
const assignments = assignmentRes.docs as Pick<Assignment, "name" | "type" | "callsign">[];
|
||||
|
||||
const rankData = rank
|
||||
? {
|
||||
name: rank.name,
|
||||
abbreviation: rank.abbreviation,
|
||||
insigniaUrl:
|
||||
rank.insignia && typeof rank.insignia === "object"
|
||||
? (rank.insignia as Media).url
|
||||
: null,
|
||||
rank.insignia && typeof rank.insignia === "object" ? (rank.insignia as Media).url : null,
|
||||
}
|
||||
: { name: "Unknown", abbreviation: "?" };
|
||||
|
||||
|
|
@ -156,11 +143,7 @@ export default async function HomePage() {
|
|||
message: e.message,
|
||||
system: e.system ?? true,
|
||||
actor: e.actor as number | User | null | undefined,
|
||||
structure: e.structure as
|
||||
| number
|
||||
| GameStructure
|
||||
| null
|
||||
| undefined,
|
||||
structure: e.structure as number | GameStructure | null | undefined,
|
||||
}));
|
||||
|
||||
// Fetch active campaigns for the sidebar
|
||||
|
|
@ -188,10 +171,12 @@ export default async function HomePage() {
|
|||
where: { status: { equals: "inProgress" } },
|
||||
limit: 0,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "missions",
|
||||
limit: 0,
|
||||
}).catch(() => ({ totalDocs: 0 })),
|
||||
payload
|
||||
.find({
|
||||
collection: "missions",
|
||||
limit: 0,
|
||||
})
|
||||
.catch(() => ({ totalDocs: 0 })),
|
||||
payload.find({
|
||||
collection: "game-structures",
|
||||
limit: 0,
|
||||
|
|
@ -212,9 +197,7 @@ export default async function HomePage() {
|
|||
<h1 className="text-lg font-semibold">
|
||||
Welcome back, {profileUser?.displayName ?? user.username}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Here's your operations dashboard.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Here's your operations dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
qualifications: true,
|
||||
xp: true,
|
||||
scoreboard: true,
|
||||
minigames: true,
|
||||
},
|
||||
createdAt: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -74,8 +74,14 @@ export const Profiles: CollectionConfig = {
|
|||
async ({ req, siblingData }) => {
|
||||
let xpAcc = 0;
|
||||
|
||||
const awardsList = (siblingData as { awards?: number[] })?.awards || [];
|
||||
const scoreboard = (siblingData as { scoreboard?: Record<string, number | null> })?.scoreboard || {};
|
||||
const progressionData = siblingData as {
|
||||
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) {
|
||||
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;
|
||||
},
|
||||
],
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
"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 { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
@ -96,6 +105,12 @@ export function NavUser({
|
|||
Service Record
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/flappy">
|
||||
<Bird />
|
||||
Flappy Bird
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
|
|
|
|||
493
src/components/frontend/flappy/FlappyBird.tsx
Normal file
493
src/components/frontend/flappy/FlappyBird.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/frontend/flappy/sounds.ts
Normal file
95
src/components/frontend/flappy/sounds.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -1188,6 +1188,14 @@ export interface Profile {
|
|||
airKills?: number | null;
|
||||
deaths?: number | null;
|
||||
};
|
||||
minigames?: {
|
||||
flappyBird?: {
|
||||
gamesPlayed?: number | null;
|
||||
totalPasses?: number | null;
|
||||
bestScore?: number | null;
|
||||
xpEarned?: number | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
|
@ -2126,6 +2134,18 @@ export interface ProfilesSelect<T extends boolean = true> {
|
|||
airKills?: T;
|
||||
deaths?: T;
|
||||
};
|
||||
minigames?:
|
||||
| T
|
||||
| {
|
||||
flappyBird?:
|
||||
| T
|
||||
| {
|
||||
gamesPlayed?: T;
|
||||
totalPasses?: T;
|
||||
bestScore?: T;
|
||||
xpEarned?: T;
|
||||
};
|
||||
};
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ export const EventTypes = {
|
|||
|
||||
// Game Tick
|
||||
GameTick: "game:tick",
|
||||
|
||||
// Minigames
|
||||
MinigameFlappy: "minigame:flappy",
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||
|
|
|
|||
Loading…
Reference in a new issue