diff --git a/src/app/(frontend)/flappy/actions.ts b/src/app/(frontend)/flappy/actions.ts new file mode 100644 index 0000000..dcb445a --- /dev/null +++ b/src/app/(frontend)/flappy/actions.ts @@ -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 { + 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> { + 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 }; + } +} diff --git a/src/app/(frontend)/flappy/page.tsx b/src/app/(frontend)/flappy/page.tsx new file mode 100644 index 0000000..d52bc99 --- /dev/null +++ b/src/app/(frontend)/flappy/page.tsx @@ -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 ( +
+
+
+ +

Flight Simulator

+
+

+ An unofficial side activity. Earn 1 XP per 15 pipes passed in a single run. +

+
+ + +
+ ); +} diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index e806d50..b0762db 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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, }, }, }), diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx index b069e5d..12f3fff 100644 --- a/src/app/(frontend)/page.tsx +++ b/src/app/(frontend)/page.tsx @@ -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[]; 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() {

Welcome back, {profileUser?.displayName ?? user.username}

-

- Here's your operations dashboard. -

+

Here's your operations dashboard.

diff --git a/src/app/(frontend)/profile/[username]/page.tsx b/src/app/(frontend)/profile/[username]/page.tsx index de2f992..2ea5cb9 100644 --- a/src/app/(frontend)/profile/[username]/page.tsx +++ b/src/app/(frontend)/profile/[username]/page.tsx @@ -55,6 +55,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) { qualifications: true, xp: true, scoreboard: true, + minigames: true, }, createdAt: true, }, diff --git a/src/collections/users/Profiles.ts b/src/collections/users/Profiles.ts index 79304f9..812b79c 100644 --- a/src/collections/users/Profiles.ts +++ b/src/collections/users/Profiles.ts @@ -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 })?.scoreboard || {}; + const progressionData = siblingData as { + awards?: number[]; + scoreboard?: Record; + 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, + }, + ], + }, + ], + }, ], }, ], diff --git a/src/components/frontend/blocks/NavUser.tsx b/src/components/frontend/blocks/NavUser.tsx index 152acaf..c99d45e 100644 --- a/src/components/frontend/blocks/NavUser.tsx +++ b/src/components/frontend/blocks/NavUser.tsx @@ -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 + + + + Flappy Bird + + diff --git a/src/components/frontend/flappy/FlappyBird.tsx b/src/components/frontend/flappy/FlappyBird.tsx new file mode 100644 index 0000000..b77530c --- /dev/null +++ b/src/components/frontend/flappy/FlappyBird.tsx @@ -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(null); + const wrapperRef = useRef(null); + const phaseRef = useRef("ready"); + const gameRef = useRef({ + birdY: START_Y, + birdVy: 0, + pipes: [], + score: 0, + wingT: 0, + groundOffset: 0, + runTime: 0, + }); + const submittingRef = useRef(false); + const statsRef = useRef(serverStats); + + const [phase, setPhase] = useState("ready"); + const [score, setScore] = useState(0); + const [stats, setStats] = useState(serverStats); + const [lastRun, setLastRun] = useState<{ + score: number; + xpEarned: number; + best: number; + error?: string; + } | null>(null); + const [submitting, setSubmitting] = useState(false); + + const setPhaseSafe = useCallback((p: Phase) => { + phaseRef.current = p; + setPhase(p); + }, []); + + 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 ( +
+
+

Flappy Bird

+

+ Side training sim · +1 XP per {PASSES_PER_XP} pipes in a single run +

+
+ +
{ + if (e.button !== 0) return; + e.preventDefault(); + wrapperRef.current?.focus({ preventScroll: true }); + flap(); + }} + > + + + {phase === "ready" && ( +
+ + Flappy Bird + + + Tap or press Space to flap + + + Avoid the pipes. Best run: {stats.bestScore} + +
+ )} + + {phase === "over" && ( +
+ + Game Over + +
+
+
Score
+
{lastRun?.score ?? score}
+
+
+
Best
+
{lastRun?.best ?? stats.bestScore}
+
+
+ {lastRun && lastRun.xpEarned > 0 && ( + +{lastRun.xpEarned} XP + )} + {lastRun?.error && {lastRun.error}} + {submitting && Recording score…} + + Tap or press Space to play again + +
+ )} +
+ +
+
+ Games + {stats.gamesPlayed} +
+
+ Pipes passed + {stats.totalPasses} +
+
+ Best run + {stats.bestScore} +
+
+ XP from Flappy + {stats.flappyXp} +
+
+
+ ); +} diff --git a/src/components/frontend/flappy/sounds.ts b/src/components/frontend/flappy/sounds.ts new file mode 100644 index 0000000..03bb4e1 --- /dev/null +++ b/src/components/frontend/flappy/sounds.ts @@ -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 }); +} diff --git a/src/payload-types.ts b/src/payload-types.ts index cbaf95d..e6e222e 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 { airKills?: T; deaths?: T; }; + minigames?: + | T + | { + flappyBird?: + | T + | { + gamesPlayed?: T; + totalPasses?: T; + bestScore?: T; + xpEarned?: T; + }; + }; }; updatedAt?: T; createdAt?: T; diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index 86aec07..bdeaf8f 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -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];