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;
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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's your operations dashboard.</p>
|
||||||
Here'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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue