Compare commits
5 Commits
dev
...
feature/ga
| Author | SHA1 | Date | |
|---|---|---|---|
| d1baf097fa | |||
| f9ad545e45 | |||
| a77bac304e | |||
| f0672b9bc1 | |||
| abc698998e |
@ -9,6 +9,7 @@ const config = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@trpc/client": "^11.0.0",
|
||||
@ -39,6 +40,7 @@
|
||||
"@types/express": "^5.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"express": "^4.21.2",
|
||||
@ -58,7 +60,8 @@
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tsx": "^4.19.3",
|
||||
"tw-animate-css": "^1.2.4",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
|
||||
78
pnpm-lock.yaml
generated
78
pnpm-lock.yaml
generated
@ -41,6 +41,9 @@ importers:
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react@19.0.12)(react@19.0.0)
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@t3-oss/env-nextjs':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(typescript@5.8.2)(zod@3.24.2)
|
||||
@ -65,6 +68,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.4.7
|
||||
@ -125,6 +131,9 @@ importers:
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.24.2
|
||||
zustand:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3(@types/react@19.0.12)(react@19.0.0)
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.1
|
||||
@ -1202,6 +1211,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-switch@1.1.3':
|
||||
resolution: {integrity: sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.0':
|
||||
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
|
||||
peerDependencies:
|
||||
@ -1729,6 +1751,12 @@ packages:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
cmdk@1.1.1:
|
||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@ -3382,6 +3410,24 @@ packages:
|
||||
zod@3.24.2:
|
||||
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
|
||||
|
||||
zustand@5.0.3:
|
||||
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=18.0.0'
|
||||
use-sync-external-store: '>=1.2.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
use-sync-external-store:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
@ -4158,6 +4204,21 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.12
|
||||
|
||||
'@radix-ui/react-switch@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.1
|
||||
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0)
|
||||
'@radix-ui/react-context': 1.1.1(@types/react@19.0.12)(react@19.0.0)
|
||||
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.12)(react@19.0.0)
|
||||
'@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.12)(react@19.0.0)
|
||||
'@radix-ui/react-use-size': 1.1.0(@types/react@19.0.12)(react@19.0.0)
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.12
|
||||
'@types/react-dom': 19.0.4(@types/react@19.0.12)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.0.12)(react@19.0.0)':
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
@ -4682,6 +4743,18 @@ snapshots:
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0)
|
||||
'@radix-ui/react-dialog': 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-id': 1.1.0(@types/react@19.0.12)(react@19.0.0)
|
||||
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@ -6498,3 +6571,8 @@ snapshots:
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod@3.24.2: {}
|
||||
|
||||
zustand@5.0.3(@types/react@19.0.12)(react@19.0.0):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.12
|
||||
react: 19.0.0
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 MiB |
BIN
public/games/quiz.jpg
Normal file
BIN
public/games/quiz.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/games/reaction.jpg
Normal file
BIN
public/games/reaction.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 413 KiB |
BIN
public/games/trivia.jpg
Normal file
BIN
public/games/trivia.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@ -1,16 +1,22 @@
|
||||
import React from "react";
|
||||
import Navbar from "@/components/navbar";
|
||||
import Footer from "@/components/footer";
|
||||
import { api } from "@/trpc/server";
|
||||
import AppProvider from "../_components/app-provider";
|
||||
|
||||
async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const sessionPlayer = await api.player.getBySession();
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="gradient-bg min-h-screen">
|
||||
<Navbar />
|
||||
<div className="container mb-8 flex size-full min-h-screen flex-col items-center space-y-6">
|
||||
{children}
|
||||
<AppProvider initialSessionPlayer={sessionPlayer}>
|
||||
<div className="gradient-bg min-h-screen">
|
||||
<Navbar />
|
||||
<div className="container mb-8 flex size-full min-h-screen flex-col items-center space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -10,12 +10,11 @@ async function Page({
|
||||
id: string;
|
||||
}>;
|
||||
}) {
|
||||
const sessionPlayer = await api.player.getBySession();
|
||||
const { id } = await params;
|
||||
const lobby = await api.lobby.get({ id });
|
||||
if (!lobby) return notFound();
|
||||
if (!lobby) return <div>Lobby not found</div>;
|
||||
|
||||
return <LobbyPage initialLobby={lobby} sessionPlayer={sessionPlayer} />;
|
||||
return <LobbyPage initialLobby={lobby} />;
|
||||
}
|
||||
|
||||
export default Page;
|
||||
|
||||
@ -5,11 +5,12 @@ import { Button } from "@/components/ui/button";
|
||||
import LobbyPage from "@/app/_components/lobby/lobby-page";
|
||||
import { redirect } from "next/navigation";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
import { auth } from "@/server/auth";
|
||||
|
||||
async function Page() {
|
||||
const sessionPlayer = await api.player.getBySession();
|
||||
if (!sessionPlayer) return redirect(appRoutes.signIn);
|
||||
const lobby = sessionPlayer ? await api.lobby.getCurrentLobby() : null;
|
||||
const session = await auth();
|
||||
if (!session?.user) return redirect(appRoutes.signIn);
|
||||
const lobby = await api.lobby.getCurrentLobby();
|
||||
if (!lobby)
|
||||
return (
|
||||
<div className="flex w-full gap-4">
|
||||
@ -23,7 +24,7 @@ async function Page() {
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return <LobbyPage initialLobby={lobby} sessionPlayer={sessionPlayer} />;
|
||||
return <LobbyPage initialLobby={lobby} />;
|
||||
}
|
||||
|
||||
export default Page;
|
||||
|
||||
22
src/app/_components/app-provider.tsx
Normal file
22
src/app/_components/app-provider.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||
import type { Player } from "@/server/db/schema";
|
||||
import React from "react";
|
||||
|
||||
function AppProvider({
|
||||
initialSessionPlayer,
|
||||
children,
|
||||
}: {
|
||||
initialSessionPlayer?: Player | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const setSessionPlayer = useSessionPlayerStore(
|
||||
(state) => state.setSessionPlayer,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
setSessionPlayer(initialSessionPlayer);
|
||||
}, [initialSessionPlayer]);
|
||||
return children;
|
||||
}
|
||||
|
||||
export default AppProvider;
|
||||
123
src/app/_components/game/configurator/game-config-form.tsx
Normal file
123
src/app/_components/game/configurator/game-config-form.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { gameConfigPatchSchema } from "@/lib/validations/game";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useGameStore } from "@/lib/store/game-store";
|
||||
import { api } from "@/trpc/react";
|
||||
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { getMinMax } from "@/lib/validations/utils";
|
||||
import { debounce } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||
|
||||
const formSchema = gameConfigPatchSchema.pick({
|
||||
allowHints: true,
|
||||
// scoreMultiplier: true,
|
||||
timeLimit: true,
|
||||
});
|
||||
|
||||
export default function GameConfigForm() {
|
||||
const updateGameConfig = api.gameConfig.update.useMutation();
|
||||
const gameConfig = useGameStore((state) => state.gameConfig);
|
||||
const lobbyId = useLobbyStore((state) => state.lobby.id);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
allowHints: gameConfig.allowHints,
|
||||
timeLimit: gameConfig.timeLimit,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
updateGameConfig.mutate({
|
||||
lobbyId,
|
||||
config: {
|
||||
...gameConfig,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
}
|
||||
const save = form.handleSubmit(onSubmit);
|
||||
React.useEffect(() => {
|
||||
form.reset({
|
||||
allowHints: gameConfig.allowHints,
|
||||
timeLimit: gameConfig.timeLimit,
|
||||
});
|
||||
}, [gameConfig]);
|
||||
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowHints"
|
||||
render={({ field }) => (
|
||||
<FormItem className="container-bg bg-background/10 flex flex-row items-center justify-between p-3">
|
||||
<FormLabel>Allow hints</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="cursor-pointer"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
save();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeLimit"
|
||||
render={({ field }) => {
|
||||
const { min, max } = getMinMax(formSchema.shape.timeLimit);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="container-bg bg-background/10 space-y-2 p-3">
|
||||
<FormLabel>
|
||||
Time Limit
|
||||
<span className="text-xl font-bold">
|
||||
{`${field.value}`} min
|
||||
</span>
|
||||
</FormLabel>
|
||||
{isLobbyAdmin && (
|
||||
<FormControl>
|
||||
<Slider
|
||||
className="w-full max-w-xs"
|
||||
value={[field.value]}
|
||||
max={max ?? 100}
|
||||
min={min ?? 1}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
field.onChange(value);
|
||||
debounce(save, 1000)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
80
src/app/_components/game/configurator/game-configurator.tsx
Normal file
80
src/app/_components/game/configurator/game-configurator.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useGameStore } from "@/lib/store/game-store";
|
||||
import { InfoIcon, XIcon } from "lucide-react";
|
||||
import GameConfigForm from "./game-config-form";
|
||||
import GameVariantSelector from "./game-variant-selector";
|
||||
import { api } from "@/trpc/react";
|
||||
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||
import { isCallOrNewExpression } from "typescript";
|
||||
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||
|
||||
function GameConfigurator() {
|
||||
const updateGameConfig = api.gameConfig.update.useMutation();
|
||||
const selectGame = useGameStore((state) => state.setSelectedGame);
|
||||
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||
const gameConfig = useGameStore((state) => state.gameConfig);
|
||||
const lobbyId = useLobbyStore((state) => state.lobby.id);
|
||||
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
|
||||
const handleUnselectGame = () => {
|
||||
selectGame(undefined);
|
||||
updateGameConfig.mutate({
|
||||
lobbyId,
|
||||
config: {
|
||||
...gameConfig,
|
||||
gameId: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleVariantSelect = (gameVariantId?: string) => {
|
||||
updateGameConfig.mutate({
|
||||
lobbyId,
|
||||
config: {
|
||||
...gameConfig,
|
||||
gameVariantId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{selectedGame?.name ?? "Select a game to get started"}
|
||||
</h2>
|
||||
{selectedGame && (
|
||||
<Button size={"icon"} variant={"ghost"} className="ml-auto">
|
||||
<InfoIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedGame && (
|
||||
<>
|
||||
<GameVariantSelector onSelect={handleVariantSelect} />
|
||||
<GameConfigForm />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedGame && (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Button>Start Game</Button>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
className="ml-auto"
|
||||
onClick={handleUnselectGame}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GameConfigurator;
|
||||
@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { Combobox } from "@/components/combobox";
|
||||
import { useGameStore } from "@/lib/store/game-store";
|
||||
|
||||
const getGameVariants = (id: string) => {
|
||||
switch (id) {
|
||||
case "quiz":
|
||||
return [
|
||||
{
|
||||
label: "Airplains",
|
||||
value: "variant_id_1",
|
||||
},
|
||||
{
|
||||
label: "Cars",
|
||||
value: "variant_id_0",
|
||||
},
|
||||
{
|
||||
label: "Trains",
|
||||
value: "variant_id_2",
|
||||
},
|
||||
];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
function GameVariantSelector({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (gameVariantId?: string) => void;
|
||||
}) {
|
||||
const gameId = useGameStore((state) => state.gameConfig.gameId);
|
||||
const selectedVariant = useGameStore(
|
||||
(state) => state.gameConfig.gameVariantId,
|
||||
);
|
||||
const setSelectedVariant = useGameStore((state) => state.updateGameConfig);
|
||||
const variants = getGameVariants(gameId!);
|
||||
if (!variants.length) return null;
|
||||
React.useEffect(() => {
|
||||
if (!selectedVariant)
|
||||
setSelectedVariant({ gameVariantId: variants[0]?.value });
|
||||
}, [selectedVariant]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Combobox
|
||||
className="bg-background/10 w-full rounded-2xl"
|
||||
onSelect={(value) => {
|
||||
if (!value?.length) return;
|
||||
onSelect(value);
|
||||
setSelectedVariant({
|
||||
gameVariantId: value,
|
||||
});
|
||||
}}
|
||||
initialValue={selectedVariant}
|
||||
data={variants}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GameVariantSelector;
|
||||
83
src/app/_components/game/game-selector.tsx
Normal file
83
src/app/_components/game/game-selector.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import type { IMinigame } from "@/game-engien";
|
||||
import { gameLibary } from "@/game-engien/games";
|
||||
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||
import { useGameStore } from "@/lib/store/game-store";
|
||||
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/trpc/react";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function GameSelector() {
|
||||
const updateGameConfig = api.gameConfig.update.useMutation();
|
||||
const setGameConfig = useGameStore((state) => state.updateGameConfig);
|
||||
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||
const setSelectedGame = useGameStore((state) => state.setSelectedGame);
|
||||
const gameConfig = useGameStore((state) => state.gameConfig);
|
||||
const lobbyId = useLobbyStore((state) => state.lobby.id);
|
||||
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
|
||||
|
||||
const handleGameSelect = (game: IMinigame) => {
|
||||
if (!isLobbyAdmin) return toast.error("Only admins can change games");
|
||||
setSelectedGame(game);
|
||||
const newConfig = { ...gameConfig, gameId: game.id };
|
||||
setGameConfig(newConfig);
|
||||
updateGameConfig.mutate({
|
||||
lobbyId,
|
||||
config: newConfig,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="grid w-full grid-cols-4 gap-4">
|
||||
{gameLibary.map((game) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
game={game}
|
||||
selected={selectedGame?.id === game.id}
|
||||
onClick={() => handleGameSelect(game)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const GameCard = ({
|
||||
game,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
game: IMinigame;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"container-bg group w-full cursor-pointer items-center justify-between space-y-1 overflow-hidden",
|
||||
selected &&
|
||||
"before:bg-primary/40 before:absolute before:inset-0 before:-z-10 before:w-full before:animate-pulse before:rounded-md before:blur-xl",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="relative h-20 w-full">
|
||||
<Image
|
||||
fill
|
||||
alt="game-image"
|
||||
src={game.thumbnail}
|
||||
className={cn(
|
||||
"object-cover opacity-50 transition-all duration-300 ease-in-out group-hover:opacity-90",
|
||||
selected && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<div>
|
||||
<h4 className="font-meidum text-xl font-bold">{game.name}</h4>
|
||||
<div className="text-xs text-white/75">{game.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameSelector;
|
||||
@ -1,102 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
type Game = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
};
|
||||
const mokGames: Array<Game> = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Mok Game 1",
|
||||
description: "A simple game of strategy and luck",
|
||||
image: "/game-placeholder.jpg",
|
||||
|
||||
maxPlayers: 2,
|
||||
minPlayers: 2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Mok Game 2",
|
||||
description: "A simple game of strategy and luck",
|
||||
image: "/game-placeholder.jpg",
|
||||
|
||||
maxPlayers: 2,
|
||||
minPlayers: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Mok Game 3",
|
||||
description: "A simple game of strategy and luck",
|
||||
image: "/game-placeholder.jpg",
|
||||
|
||||
maxPlayers: 2,
|
||||
minPlayers: 2,
|
||||
},
|
||||
];
|
||||
|
||||
function GameSelector({
|
||||
selectGame,
|
||||
selectedGame,
|
||||
}: {
|
||||
selectGame?: (gameId: Game["id"]) => void;
|
||||
selectedGame?: Game["id"];
|
||||
}) {
|
||||
const [selected, setSelected] = React.useState(selectedGame ?? 1);
|
||||
return (
|
||||
<div className="container-bg grid w-full grid-cols-3 gap-4 p-6">
|
||||
{mokGames.map((game) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
game={game}
|
||||
selected={selected === game.id}
|
||||
onClick={() => setSelected(game.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const GameCard = ({
|
||||
game,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
game: Game;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"container-bg group w-full cursor-pointer items-center justify-between space-y-1 overflow-hidden",
|
||||
selected &&
|
||||
"before:bg-primary/40 before:absolute before:inset-0 before:-z-10 before:w-full before:animate-pulse before:rounded-md before:blur-xl",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="relative h-20 w-full">
|
||||
<Image
|
||||
fill
|
||||
alt="game-image"
|
||||
src={game.image}
|
||||
className={cn(
|
||||
"object-cover opacity-50 transition-all duration-300 ease-in-out group-hover:opacity-90",
|
||||
selected && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<div>
|
||||
<h4 className="font-meidum text-xl font-bold">{game.name}</h4>
|
||||
<div className="text-xs text-white/75">{game.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GameSelector;
|
||||
@ -37,7 +37,7 @@ function LobbyForm({
|
||||
const form = useForm<z.infer<typeof lobbyPatchSchema>>({
|
||||
resolver: zodResolver(lobbyPatchSchema),
|
||||
defaultValues: {
|
||||
name: server_lobby?.name ?? "",
|
||||
name: server_lobby?.name ?? "Random Name",
|
||||
maxPlayers: server_lobby?.maxPlayers ?? 2,
|
||||
},
|
||||
});
|
||||
@ -107,7 +107,10 @@ function LobbyForm({
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !form.formState.isDirty}
|
||||
disabled={
|
||||
loading ||
|
||||
(Boolean(server_lobby?.id?.length) && !form.formState.isDirty)
|
||||
}
|
||||
className="ml-auto"
|
||||
>
|
||||
{server_lobby?.id?.length ? "Update" : "Create"} Lobby
|
||||
|
||||
@ -26,19 +26,39 @@ function LobbyMembershipDialog({
|
||||
join: boolean;
|
||||
}) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const membership = api.lobby.membership.useMutation();
|
||||
const labelText = join ? "join" : "leave";
|
||||
const router = useRouter();
|
||||
const membership = api.lobby.membership.useMutation({
|
||||
onSuccess(data) {
|
||||
if (data?.success) {
|
||||
toast.success(`Successfully ${labelText} the lobby.`);
|
||||
if (join) router.push(appRoutes.currentlobby);
|
||||
else router.push(appRoutes.lobby(lobbyId));
|
||||
router.refresh();
|
||||
} else {
|
||||
if (data?.knownError) {
|
||||
toast.message(data.error, {
|
||||
action: {
|
||||
label: "Jump to Lobby",
|
||||
onClick: () => router.push(appRoutes.currentlobby),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
const result = await membership.mutateAsync({ lobbyId, join });
|
||||
if (result) {
|
||||
toast.success(`Successfully ${labelText} the lobby.`);
|
||||
if (join) router.push(appRoutes.currentlobby);
|
||||
else router.push(appRoutes.lobby(lobbyId));
|
||||
try {
|
||||
await membership.mutateAsync({
|
||||
lobbyId,
|
||||
join,
|
||||
});
|
||||
} catch (e) {
|
||||
// Catching any errors here will prevent them from reaching Next.js's error boundary
|
||||
console.error("Handled mutation error:", e);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} else toast.error("Something went wrong");
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
|
||||
@ -1,89 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Lobby, Player } from "@/server/db/schema";
|
||||
import LobbyPlayerCard from "@/app/_components/lobby/lobby-player/lobby-player-card";
|
||||
import type { Lobby } from "@/server/db/schema";
|
||||
import LobbyPlayerCard from "@/app/_components/lobby/lobby-player/player-card";
|
||||
import LobbyMembershipDialog from "@/app/_components/lobby/lobby-membership-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import CopyToClip from "@/components/copy-to-clip";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
import LobbySettingsDialog from "./lobby-settings-dialog";
|
||||
import { useRealtimeLobby } from "@/hooks/use-lobby";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
import GameSelector from "./game-selector";
|
||||
|
||||
function LobbyPage({
|
||||
sessionPlayer,
|
||||
initialLobby,
|
||||
}: {
|
||||
sessionPlayer?: Player | null;
|
||||
initialLobby: Lobby;
|
||||
}) {
|
||||
const { members, lobby } = useRealtimeLobby({ initialLobby, sessionPlayer });
|
||||
|
||||
const isJoined = members.find((m) => m.playerId === sessionPlayer?.id);
|
||||
const isAdmin = isJoined?.role === "admin";
|
||||
import GameSelector from "../game/game-selector";
|
||||
import LobbyProvider from "./lobby-provider";
|
||||
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||
import { Share2, UserPlus } from "lucide-react";
|
||||
import GameConfigurator from "../game/configurator/game-configurator";
|
||||
import { useGameStore } from "@/lib/store/game-store";
|
||||
|
||||
function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
|
||||
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
|
||||
const lobby = useLobbyStore((state) => state.lobby);
|
||||
const members = useLobbyStore((state) => state.members);
|
||||
const isJoined = sessionPlayer
|
||||
? members.find((m) => m.playerId === sessionPlayer.id)
|
||||
: null;
|
||||
const isAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
|
||||
const gameConfig = useGameStore((state) => state.gameConfig);
|
||||
return (
|
||||
<div className="grid max-w-4xl grid-cols-3 grid-rows-2 gap-4">
|
||||
<div className="container-bg col-span-2 w-full space-y-4 p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{isJoined && (
|
||||
<CopyToClip
|
||||
target="invite link"
|
||||
text={getBaseUrl() + appRoutes.lobby(lobby.id)}
|
||||
buttonProps={{ variant: "outline" }}
|
||||
>
|
||||
Invite Players
|
||||
</CopyToClip>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
{sessionPlayer && (
|
||||
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
|
||||
)}
|
||||
<div className="size-full h-screen pb-40">
|
||||
<LobbyProvider initialLobby={initialLobby}>
|
||||
<div className="mx-auto grid size-full max-w-4xl grid-cols-5 grid-rows-4 gap-4">
|
||||
<div className="container-bg col-span-3 row-span-3 size-full space-y-4 overflow-y-auto p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyToClip
|
||||
target="invite link"
|
||||
text={getBaseUrl() + appRoutes.lobby(lobby.id)}
|
||||
buttonProps={{ variant: "outline" }}
|
||||
>
|
||||
{isJoined ? (
|
||||
<>
|
||||
<UserPlus className="size-4" />
|
||||
<span>Invite Players</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share2 className="size-4" />
|
||||
<span>Share Lobby</span>
|
||||
</>
|
||||
)}
|
||||
</CopyToClip>
|
||||
|
||||
<div className="ml-auto">
|
||||
{sessionPlayer && (
|
||||
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between text-nowrap">
|
||||
<h1 className="w-full overflow-hidden text-2xl font-bold text-ellipsis">
|
||||
{lobby.name}
|
||||
</h1>
|
||||
{isAdmin && <LobbySettingsDialog lobby={lobby} />}
|
||||
</div>
|
||||
<div className="border-border/20 flex items-center justify-between border-b pb-4">
|
||||
<h2 className="text-sm font-medium">
|
||||
Players ({members?.length || 0}/{lobby.maxPlayers || 8})
|
||||
</h2>
|
||||
<span className="text-xs">
|
||||
{members?.length === (lobby.maxPlayers || 8)
|
||||
? "Lobby Full"
|
||||
: "Waiting..."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{members?.map((member, idx) => (
|
||||
<li key={idx}>
|
||||
<LobbyPlayerCard
|
||||
lobbyId={lobby.id}
|
||||
highlight={member?.player?.id === sessionPlayer?.id}
|
||||
player={member?.player!}
|
||||
className="relative"
|
||||
showOptions={
|
||||
isAdmin && member?.playerId !== sessionPlayer?.id
|
||||
}
|
||||
>
|
||||
{member?.role === "admin" && (
|
||||
<Badge className="absolute -top-2 right-2 z-20 p-px px-2 text-white">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</LobbyPlayerCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="container-bg col-span-2 row-span-3 size-full overflow-y-auto p-6">
|
||||
<GameConfigurator />
|
||||
</div>
|
||||
<div className="container-bg col-span-full h-max w-full p-4">
|
||||
<GameSelector />
|
||||
{/* {JSON.stringify(gameConfig)} */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between text-nowrap">
|
||||
<h1 className="w-full overflow-hidden text-2xl font-bold text-ellipsis">
|
||||
{lobby.name}
|
||||
</h1>
|
||||
{isAdmin && <LobbySettingsDialog lobby={lobby} />}
|
||||
</div>
|
||||
<div className="border-border/20 flex items-center justify-between border-b pb-4">
|
||||
<h2 className="text-sm font-medium">
|
||||
Players ({members?.length || 0}/{lobby.maxPlayers || 8})
|
||||
</h2>
|
||||
<span className="text-xs">
|
||||
{members?.length === (lobby.maxPlayers || 8)
|
||||
? "Lobby Full"
|
||||
: "Waiting..."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{members?.map((member, idx) => (
|
||||
<li key={idx}>
|
||||
<LobbyPlayerCard
|
||||
lobbyId={lobby.id}
|
||||
highlight={member?.player?.id === sessionPlayer?.id}
|
||||
player={member?.player!}
|
||||
className="relative"
|
||||
showOptions={isAdmin && member?.playerId !== sessionPlayer?.id}
|
||||
>
|
||||
{member?.role === "admin" && (
|
||||
<Badge className="absolute -top-2 right-2 z-20 p-px px-2 text-white">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</LobbyPlayerCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="container-bg col-span-1 size-full"></div>
|
||||
<div className="col-span-3">
|
||||
<GameSelector />
|
||||
</div>
|
||||
</LobbyProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import React from "react";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Player } from "@/server/db/schema";
|
||||
import LobbyPlayerOptions from "./lobby-player-options";
|
||||
import LobbyPlayerOptions from "./player-options";
|
||||
|
||||
function LobbyPlayerCard({
|
||||
lobbyId,
|
||||
@ -23,7 +23,7 @@ import type { Player } from "@/server/db/schema";
|
||||
import Link from "next/link";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
import KickPlayerDialog from "./kick-player-dialog";
|
||||
import KickPlayerDialog from "./player-kick-dialog";
|
||||
import { api } from "@/trpc/react";
|
||||
function LobbyPlayerOptions({
|
||||
player,
|
||||
112
src/app/_components/lobby/lobby-provider.tsx
Normal file
112
src/app/_components/lobby/lobby-provider.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
import { getGame } from "@/game-engien/games";
|
||||
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||
import { useGameStore } from "@/lib/store/game-store";
|
||||
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||
import type { GameConfig } from "@/lib/validations/game";
|
||||
import type { Lobby, LobbyMember } from "@/server/db/schema";
|
||||
import { api } from "@/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function LobbyProvider({
|
||||
children,
|
||||
initialLobby,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialLobby: Lobby;
|
||||
}) {
|
||||
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
|
||||
const lobby = useLobbyStore((state) => state.lobby);
|
||||
const updateLobby = useLobbyStore((state) => state.updateLobby);
|
||||
const resetLobby = useLobbyStore((state) => state.resetLobby);
|
||||
const setMembers = useLobbyStore((state) => state.setMembers);
|
||||
const addMember = useLobbyStore((state) => state.addMember);
|
||||
const removeMember = useLobbyStore((state) => state.removeMember);
|
||||
const setGameConfig = useGameStore((state) => state.updateGameConfig);
|
||||
const setSelectedGame = useGameStore((state) => state.setSelectedGame);
|
||||
const setIsLobbyAdmin = useSessionPlayerStore(
|
||||
(state) => state.setIsLobbyAdmin,
|
||||
);
|
||||
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||
const router = useRouter();
|
||||
|
||||
const lobbyId = lobby.id ?? "";
|
||||
|
||||
api.lobby.onMemberUpdate.useSubscription(
|
||||
{ lobbyId },
|
||||
{
|
||||
onData({ data }) {
|
||||
if (data.joined) {
|
||||
const member = data.member as LobbyMember;
|
||||
addMember(member);
|
||||
} else {
|
||||
const memberId = data.member as string;
|
||||
removeMember(memberId);
|
||||
if (data?.kicked && memberId === sessionPlayer?.id) {
|
||||
router.push(appRoutes.lobby(lobby.id));
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
api.lobby.onUpdate.useSubscription(
|
||||
{ lobbyId },
|
||||
{
|
||||
onData({ data }) {
|
||||
if (data.deleted) {
|
||||
resetLobby(undefined);
|
||||
router.push(appRoutes.home);
|
||||
router.refresh();
|
||||
toast.error("Lobby got deleted");
|
||||
return;
|
||||
}
|
||||
if (data.lobby) updateLobby(data.lobby);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
api.gameConfig.onUpdate.useSubscription(
|
||||
{ lobbyId },
|
||||
{
|
||||
onData({ data: config }) {
|
||||
if (config) {
|
||||
console.log("Game Config Update", config);
|
||||
|
||||
setGameConfig(config);
|
||||
if (config.gameId !== selectedGame?.id) {
|
||||
const game = getGame(config.gameId!);
|
||||
setSelectedGame(game);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const isAdmin =
|
||||
initialLobby?.members?.find((m) => m.playerId === sessionPlayer?.id)
|
||||
?.role === "admin";
|
||||
setIsLobbyAdmin(isAdmin);
|
||||
}, [initialLobby, sessionPlayer, setIsLobbyAdmin]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const setConfigWithGame = () => {
|
||||
const initialConfig = initialLobby?.gameConfig?.config;
|
||||
setGameConfig(initialConfig ?? ({} as GameConfig));
|
||||
const game = initialConfig?.gameId?.length
|
||||
? getGame(initialConfig?.gameId!)
|
||||
: undefined;
|
||||
setSelectedGame(game);
|
||||
};
|
||||
if (initialLobby?.gameConfig?.config) setConfigWithGame();
|
||||
resetLobby(initialLobby);
|
||||
setMembers(initialLobby?.members ?? []);
|
||||
}, [initialLobby, setGameConfig, setSelectedGame, getGame]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default LobbyProvider;
|
||||
140
src/components/combobox.tsx
Normal file
140
src/components/combobox.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export type ComboboxProps = {
|
||||
data: {
|
||||
label: string;
|
||||
value: string;
|
||||
Icon?: LucideIcon;
|
||||
}[];
|
||||
onSelect: (value?: string) => void;
|
||||
messageUi?: {
|
||||
select?: string;
|
||||
selectIcon?: LucideIcon;
|
||||
empty?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
initialValue?: string;
|
||||
className?: string;
|
||||
hideSearch?: boolean;
|
||||
buttonProps?: ButtonProps;
|
||||
};
|
||||
|
||||
export function Combobox({
|
||||
data,
|
||||
initialValue,
|
||||
messageUi,
|
||||
className,
|
||||
hideSearch,
|
||||
buttonProps,
|
||||
onSelect,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState(initialValue ?? "");
|
||||
const selectedItem = data.find((item) => item.value === initialValue)!;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"bg-background/20 hover:bg-background/30 container-bg w-[200px] justify-between text-white shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...buttonProps}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-white/75",
|
||||
selectedItem && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{selectedItem?.Icon ? (
|
||||
<selectedItem.Icon className="size-4" />
|
||||
) : messageUi?.selectIcon ? (
|
||||
<messageUi.selectIcon className="size-4" />
|
||||
) : null}
|
||||
|
||||
<span className="text-white">
|
||||
{selectedItem?.label
|
||||
? selectedItem.label
|
||||
: (messageUi?.select ?? "Select...")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-content-width-same-as-its-trigger w-full bg-transparent p-0">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (!search.trim()) return 1; // Show all when no search input
|
||||
|
||||
const entry = data.find((item) => item.value === value); // Assuming dataset is an array of objects with id and name
|
||||
if (!entry) return 0; // If no matching entry is found, exclude it
|
||||
|
||||
return entry.label.toLowerCase().includes(search.toLowerCase())
|
||||
? 1
|
||||
: 0;
|
||||
}}
|
||||
>
|
||||
{!hideSearch && (
|
||||
<CommandInput
|
||||
placeholder={messageUi?.placeholder ?? "Search..."}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
<CommandList className="bg-transparent">
|
||||
<CommandEmpty>{messageUi?.empty ?? "Nothing found."}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{data.map((item) => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
onSelect={(currentValue) => {
|
||||
const newValue = currentValue === value ? "" : currentValue;
|
||||
setValue(newValue);
|
||||
onSelect(newValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item?.Icon && <item.Icon className="ml-auto opacity-50" />}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === item.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function NavLink({ label, path, className }: NavLink & { className?: string }) {
|
||||
const active = usePathname() === path;
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"group hover:bg-border/20 h-10 rounded-none font-bold text-white/50 hover:text-white/50",
|
||||
active && "bg-border/20 text-white hover:text-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link href={path}>
|
||||
<span
|
||||
className={"transition-transform duration-150 group-hover:scale-105"}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavLink;
|
||||
@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import Link from "next/link";
|
||||
import UserPopover from "@/app/_components/profile-popover";
|
||||
import { appConfig } from "@/config/app.config";
|
||||
import NavLink from "./nav-link";
|
||||
import { api } from "@/trpc/server";
|
||||
|
||||
import { Icons } from "./icons";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
async function Navbar() {
|
||||
const sessionPlayer = await api.player.getBySession();
|
||||
const currentLobby = sessionPlayer ? await api.lobby.getCurrentLobby() : null;
|
||||
function Navbar() {
|
||||
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
|
||||
const currentLobby = useLobbyStore((state) => state.lobby);
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex w-full items-center justify-center p-4">
|
||||
@ -18,12 +24,17 @@ async function Navbar() {
|
||||
<menu className="flex items-center">
|
||||
{appConfig.navigation.map((navLink, idx) => (
|
||||
<li key={idx}>
|
||||
<NavLink {...navLink} />
|
||||
<NavLink {...navLink} active={pathname === navLink.path} />
|
||||
</li>
|
||||
))}
|
||||
</menu>
|
||||
|
||||
<div className="bg-border/10 border-border/20 flex items-center rounded-full border-l-2">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border/10 border-border/20 hover:bg-background/20 flex items-center rounded-full border-l-2",
|
||||
pathname === appRoutes.currentlobby && "bg-background/20",
|
||||
)}
|
||||
>
|
||||
{currentLobby ? (
|
||||
<Link href={appRoutes.currentlobby}>
|
||||
<div className="border-border/20 flex h-10 items-center justify-center gap-2 pr-2 pl-4 font-semibold">
|
||||
@ -45,4 +56,31 @@ async function Navbar() {
|
||||
);
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
label,
|
||||
path,
|
||||
active,
|
||||
className,
|
||||
}: NavLink & { className?: string; active?: boolean }) => {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"group hover:bg-background/20 h-10 rounded-none font-bold text-white/50 hover:text-white/50",
|
||||
active && "bg-background/20 text-white hover:text-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link href={path}>
|
||||
<span
|
||||
className={"transition-transform duration-150 group-hover:scale-105"}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
||||
177
src/components/ui/command.tsx
Normal file
177
src/components/ui/command.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
31
src/components/ui/switch.tsx
Normal file
31
src/components/ui/switch.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input/20 focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background/40 dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
40
src/game-engien/games/index.ts
Normal file
40
src/game-engien/games/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { IMinigame } from "..";
|
||||
|
||||
export const getGame = (id: string) => {
|
||||
return gameLibary.find((g) => g.id === id);
|
||||
};
|
||||
|
||||
export const gameLibary: Array<IMinigame> = [
|
||||
{
|
||||
id: "quiz",
|
||||
name: "Quiz",
|
||||
description: "Quiz minigame",
|
||||
thumbnail: "/games/quiz.jpg",
|
||||
minPlayers: 1,
|
||||
maxPlayers: 12,
|
||||
},
|
||||
{
|
||||
id: "reaction",
|
||||
name: "Reaction",
|
||||
description: "Reaction minigame",
|
||||
thumbnail: "/games/reaction.jpg",
|
||||
minPlayers: 1,
|
||||
maxPlayers: 12,
|
||||
},
|
||||
{
|
||||
id: "trivia",
|
||||
name: "Trivia",
|
||||
description: "Trivia minigame",
|
||||
thumbnail: "/games/trivia.jpg",
|
||||
minPlayers: 1,
|
||||
maxPlayers: 12,
|
||||
},
|
||||
{
|
||||
id: "place",
|
||||
name: "Placeholder",
|
||||
description: "Placeholder Lorem Ipsum",
|
||||
thumbnail: "/games/trivia.jpg",
|
||||
minPlayers: 1,
|
||||
maxPlayers: 12,
|
||||
},
|
||||
];
|
||||
414
src/game-engien/games/quiz/index.ts
Normal file
414
src/game-engien/games/quiz/index.ts
Normal file
@ -0,0 +1,414 @@
|
||||
import {
|
||||
Game,
|
||||
type GamePlayer,
|
||||
type IGameConfig,
|
||||
type IGameState,
|
||||
} from "@/game-engien";
|
||||
|
||||
interface QuizQuestion {
|
||||
id: string;
|
||||
text: string;
|
||||
options: string[];
|
||||
correctOptionIndex: number;
|
||||
imageUrl?: string;
|
||||
timeLimit?: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface QuizData {
|
||||
version: string;
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
questions: Array<QuizQuestion>;
|
||||
category: string;
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
tags: Array<string>;
|
||||
}
|
||||
|
||||
interface QuizGameConfig extends IGameConfig {
|
||||
randomizeQuestions?: boolean;
|
||||
revealAnswersImmediately?: boolean;
|
||||
pointsPerQuestion?: number;
|
||||
}
|
||||
|
||||
interface QuizGameState extends IGameState {
|
||||
currentQuestion?: QuizQuestion;
|
||||
answeredQuestions: Set<string>;
|
||||
playerAnswers: Record<string, Record<string, number>>;
|
||||
leaderboard: Array<GamePlayer>;
|
||||
}
|
||||
|
||||
type PlayerQuizAction =
|
||||
| {
|
||||
type: "answer";
|
||||
questionId: string;
|
||||
selectedOptionIndex: number;
|
||||
}
|
||||
| {
|
||||
type: "ready";
|
||||
}
|
||||
| {
|
||||
type: "requestHint";
|
||||
questionId: string;
|
||||
};
|
||||
|
||||
export class QuizGame extends Game {
|
||||
private quizData: QuizData;
|
||||
private questions: QuizQuestion[];
|
||||
private quizState: QuizGameState;
|
||||
private timers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor(quizData: QuizData, config: QuizGameConfig = {}) {
|
||||
super(
|
||||
{
|
||||
name: "quiz",
|
||||
id: quizData.id,
|
||||
title: quizData.title,
|
||||
description: quizData.description,
|
||||
version: quizData.version,
|
||||
},
|
||||
{
|
||||
timeLimit: 15, // Default 15 seconds per question
|
||||
scoreMultiplier: 1,
|
||||
allowHints: false,
|
||||
...config,
|
||||
},
|
||||
);
|
||||
|
||||
this.quizData = quizData;
|
||||
this.questions = [...quizData.questions];
|
||||
this.quizState = {
|
||||
...this.state,
|
||||
answeredQuestions: new Set<string>(),
|
||||
playerAnswers: {},
|
||||
leaderboard: [],
|
||||
totalRounds: this.questions.length,
|
||||
};
|
||||
this.state = this.quizState;
|
||||
}
|
||||
|
||||
public initialize(config?: QuizGameConfig): void {
|
||||
// Override the default config with any provided config
|
||||
if (config) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize player answers tracking
|
||||
this.players.forEach((player) => {
|
||||
this.quizState.playerAnswers[player.id] = {};
|
||||
});
|
||||
|
||||
// Randomize questions if configured
|
||||
if ((this.config as QuizGameConfig).randomizeQuestions) {
|
||||
this.questions = this.shuffleArray([...this.questions]);
|
||||
}
|
||||
|
||||
this.state.totalRounds = this.questions.length;
|
||||
this.state.status = "waiting";
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.state.status !== "waiting") {
|
||||
throw new Error("Game must be in waiting state to start");
|
||||
}
|
||||
|
||||
this.state.status = "active";
|
||||
this.nextQuestion();
|
||||
}
|
||||
|
||||
public end(): void {
|
||||
this.clearTimers();
|
||||
this.state.status = "completed";
|
||||
this.quizState.leaderboard = [...this.players].sort(
|
||||
(a, b) => b.score - a.score,
|
||||
);
|
||||
|
||||
// Notify players of game end
|
||||
this.broadcastGameEnd();
|
||||
}
|
||||
|
||||
public handlePlayerAction(playerId: string, action: PlayerQuizAction): void {
|
||||
const player = this.players.find((p) => p.id === playerId);
|
||||
|
||||
if (!player) {
|
||||
throw new Error(`Player ${playerId} not found`);
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case "answer":
|
||||
this.handlePlayerAnswer(
|
||||
player,
|
||||
action.questionId,
|
||||
action.selectedOptionIndex,
|
||||
);
|
||||
break;
|
||||
case "ready":
|
||||
this.handlePlayerReady(player);
|
||||
break;
|
||||
case "requestHint":
|
||||
this.handleRequestHint(player, action.questionId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${(action as any).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handlePlayerAnswer(
|
||||
player: GamePlayer,
|
||||
questionId: string,
|
||||
selectedOptionIndex: number,
|
||||
): void {
|
||||
const currentQuestion = this.quizState.currentQuestion;
|
||||
|
||||
if (!currentQuestion || currentQuestion.id !== questionId) {
|
||||
throw new Error("Invalid question ID");
|
||||
}
|
||||
|
||||
// Record the answer
|
||||
this.quizState.playerAnswers[player.id][questionId] = selectedOptionIndex;
|
||||
|
||||
// Check if answer is correct and update score
|
||||
if (selectedOptionIndex === currentQuestion.correctOptionIndex) {
|
||||
const points =
|
||||
currentQuestion.points * (this.config.scoreMultiplier || 1);
|
||||
player.score += points;
|
||||
|
||||
// Broadcast correct answer event
|
||||
this.broadcastPlayerCorrectAnswer(player, questionId, points);
|
||||
}
|
||||
|
||||
// Check if all players have answered
|
||||
const allAnswered = this.players.every(
|
||||
(p) => questionId in (this.quizState.playerAnswers[p.id] || {}),
|
||||
);
|
||||
|
||||
if (allAnswered) {
|
||||
this.clearTimers();
|
||||
|
||||
// If configured to reveal answers immediately, wait briefly before next question
|
||||
if ((this.config as QuizGameConfig).revealAnswersImmediately) {
|
||||
this.broadcastQuestionResults(currentQuestion);
|
||||
setTimeout(() => this.nextQuestion(), 3000);
|
||||
} else {
|
||||
this.nextQuestion();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handlePlayerReady(player: GamePlayer): void {
|
||||
// Could implement a ready-up system before starting each question
|
||||
// For now, just acknowledging the ready state
|
||||
}
|
||||
|
||||
private handleRequestHint(player: GamePlayer, questionId: string): void {
|
||||
if (!this.config.allowHints) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentQuestion = this.quizState.currentQuestion;
|
||||
|
||||
if (!currentQuestion || currentQuestion.id !== questionId) {
|
||||
throw new Error("Invalid question ID");
|
||||
}
|
||||
|
||||
// Implementation of hint system
|
||||
// For example, eliminate one wrong option
|
||||
this.provideHint(player, currentQuestion);
|
||||
}
|
||||
|
||||
private nextQuestion(): void {
|
||||
if (this.state.status !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTimers();
|
||||
|
||||
// Check if we've gone through all questions
|
||||
if (this.state.currentRound >= this.questions.length) {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextQuestion = this.questions[this.state.currentRound];
|
||||
this.quizState.currentQuestion = nextQuestion;
|
||||
this.state.currentRound++;
|
||||
this.quizState.answeredQuestions.add(nextQuestion.id);
|
||||
|
||||
// Set timer for question
|
||||
const timeLimit = nextQuestion.timeLimit || this.config.timeLimit || 15;
|
||||
this.state.timeRemaining = timeLimit;
|
||||
|
||||
// Broadcast the new question to all players
|
||||
this.broadcastQuestion(nextQuestion);
|
||||
|
||||
// Start the timer
|
||||
const timer = setInterval(() => {
|
||||
if (this.state.timeRemaining && this.state.timeRemaining > 0) {
|
||||
this.state.timeRemaining--;
|
||||
} else {
|
||||
this.clearTimers();
|
||||
this.timeExpired(nextQuestion);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.timers.set("questionTimer", timer);
|
||||
}
|
||||
|
||||
private timeExpired(question: QuizQuestion): void {
|
||||
// Handle when time expires for a question
|
||||
// Auto-submit blank answers for players who didn't answer
|
||||
this.players.forEach((player) => {
|
||||
if (!(question.id in (this.quizState.playerAnswers[player.id] || {}))) {
|
||||
this.quizState.playerAnswers[player.id][question.id] = -1; // -1 means no answer
|
||||
}
|
||||
});
|
||||
|
||||
if ((this.config as QuizGameConfig).revealAnswersImmediately) {
|
||||
this.broadcastQuestionResults(question);
|
||||
setTimeout(() => this.nextQuestion(), 3000);
|
||||
} else {
|
||||
this.nextQuestion();
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
this.timers.forEach((timer) => clearInterval(timer));
|
||||
this.timers.clear();
|
||||
}
|
||||
|
||||
private shuffleArray<T>(array: T[]): T[] {
|
||||
const newArray = [...array];
|
||||
for (let i = newArray.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
|
||||
private provideHint(player: GamePlayer, question: QuizQuestion): void {
|
||||
// Implementation of hint system
|
||||
// This is a placeholder for the hint logic
|
||||
|
||||
// Example: Find a wrong answer to eliminate
|
||||
const wrongOptions = question.options
|
||||
.map((option, index) => ({ option, index }))
|
||||
.filter((item) => item.index !== question.correctOptionIndex);
|
||||
|
||||
if (wrongOptions.length > 0) {
|
||||
const randomWrongOption =
|
||||
wrongOptions[Math.floor(Math.random() * wrongOptions.length)];
|
||||
this.broadcastHint(player, question.id, randomWrongOption.index);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcasting methods (these would connect to your websocket/realtime system)
|
||||
private broadcastQuestion(question: QuizQuestion): void {
|
||||
// In a real implementation, this would send the question to all players
|
||||
console.log("Broadcasting question:", question.text);
|
||||
|
||||
// Example of what to broadcast (would be sent via websocket)
|
||||
const broadcastData = {
|
||||
type: "question",
|
||||
questionId: question.id,
|
||||
text: question.text,
|
||||
options: question.options,
|
||||
imageUrl: question.imageUrl,
|
||||
timeLimit: question.timeLimit || this.config.timeLimit,
|
||||
points: question.points,
|
||||
};
|
||||
|
||||
// Send to all players
|
||||
// this.broadcastToPlayers(broadcastData);
|
||||
}
|
||||
|
||||
private broadcastQuestionResults(question: QuizQuestion): void {
|
||||
// In a real implementation, this would send the results to all players
|
||||
console.log("Broadcasting question results for:", question.text);
|
||||
|
||||
// Example of what to broadcast
|
||||
const broadcastData = {
|
||||
type: "questionResults",
|
||||
questionId: question.id,
|
||||
correctOptionIndex: question.correctOptionIndex,
|
||||
playerAnswers: this.quizState.playerAnswers,
|
||||
};
|
||||
|
||||
// Send to all players
|
||||
// this.broadcastToPlayers(broadcastData);
|
||||
}
|
||||
|
||||
private broadcastPlayerCorrectAnswer(
|
||||
player: GamePlayer,
|
||||
questionId: string,
|
||||
points: number,
|
||||
): void {
|
||||
// Example of what to broadcast when a player answers correctly
|
||||
const broadcastData = {
|
||||
type: "correctAnswer",
|
||||
playerId: player.id,
|
||||
playerName: player.displayName,
|
||||
questionId: questionId,
|
||||
points: points,
|
||||
};
|
||||
|
||||
// Send to all players
|
||||
// this.broadcastToPlayers(broadcastData);
|
||||
}
|
||||
|
||||
private broadcastGameEnd(): void {
|
||||
// Example of what to broadcast when the game ends
|
||||
const broadcastData = {
|
||||
type: "gameEnd",
|
||||
leaderboard: this.quizState.leaderboard,
|
||||
};
|
||||
|
||||
// Send to all players
|
||||
// this.broadcastToPlayers(broadcastData);
|
||||
}
|
||||
|
||||
private broadcastHint(
|
||||
player: GamePlayer,
|
||||
questionId: string,
|
||||
eliminatedOptionIndex: number,
|
||||
): void {
|
||||
// Example of what to broadcast when providing a hint
|
||||
const broadcastData = {
|
||||
type: "hint",
|
||||
playerId: player.id,
|
||||
questionId: questionId,
|
||||
eliminatedOptionIndex: eliminatedOptionIndex,
|
||||
};
|
||||
|
||||
// Send to specific player
|
||||
// this.sendToPlayer(player.id, broadcastData);
|
||||
}
|
||||
}
|
||||
|
||||
// Game Registry to manage different games
|
||||
class GameRegistry {
|
||||
private games: Map<string, any> = new Map();
|
||||
|
||||
registerGame(gameClass: any): void {
|
||||
const tempInstance = new gameClass({} as any);
|
||||
this.games.set(tempInstance.getGameId(), gameClass);
|
||||
}
|
||||
|
||||
createGameInstance(gameId: string, ...args: any[]): Game {
|
||||
const GameClass = this.games.get(gameId);
|
||||
|
||||
if (!GameClass) {
|
||||
throw new Error(`Game ${gameId} not found in registry`);
|
||||
}
|
||||
|
||||
return new GameClass(...args);
|
||||
}
|
||||
|
||||
listAvailableGames(): string[] {
|
||||
return Array.from(this.games.keys());
|
||||
}
|
||||
}
|
||||
29
src/game-engien/games/quiz/quiz-data.ts
Normal file
29
src/game-engien/games/quiz/quiz-data.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { QuizData } from ".";
|
||||
|
||||
export const exampleQuizData: QuizData = {
|
||||
version: "1.0.0",
|
||||
id: "sample-quiz-001",
|
||||
title: "General Knowledge Quiz",
|
||||
description: "Test your knowledge on various topics",
|
||||
author: "user123",
|
||||
category: "general",
|
||||
difficulty: "medium",
|
||||
tags: ["general", "knowledge", "trivia"],
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
text: "What is the capital of France?",
|
||||
options: ["London", "Berlin", "Paris", "Madrid"],
|
||||
correctOptionIndex: 2,
|
||||
points: 10,
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
text: "Which planet is known as the Red Planet?",
|
||||
options: ["Venus", "Mars", "Jupiter", "Saturn"],
|
||||
correctOptionIndex: 1,
|
||||
points: 10,
|
||||
},
|
||||
// More questions...
|
||||
],
|
||||
};
|
||||
65
src/game-engien/index.ts
Normal file
65
src/game-engien/index.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { GameConfig } from "@/lib/validations/game";
|
||||
import type { Player } from "@/server/db/schema";
|
||||
|
||||
export type GamePlayer = Pick<Player, "id" | "displayName" | "avatar"> & {
|
||||
score: number;
|
||||
};
|
||||
|
||||
export interface IMinigame {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
maxPlayers: number;
|
||||
minPlayers: number;
|
||||
}
|
||||
|
||||
export interface IGameState {
|
||||
status: "waiting" | "active" | "paused" | "completed";
|
||||
players: Array<GamePlayer>;
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
timeRemaining?: number;
|
||||
}
|
||||
|
||||
// Base Game abstract class
|
||||
export abstract class Game {
|
||||
protected meta: IMinigame;
|
||||
protected players: Array<GamePlayer> = [];
|
||||
protected config: GameConfig;
|
||||
protected state: IGameState;
|
||||
|
||||
constructor(meta: IMinigame, config: GameConfig) {
|
||||
this.meta = meta;
|
||||
this.config = config;
|
||||
this.state = {
|
||||
status: "waiting",
|
||||
players: [],
|
||||
currentRound: 0,
|
||||
totalRounds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
public getGameId(): string {
|
||||
return this.meta.id;
|
||||
}
|
||||
|
||||
public getState(): IGameState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
public addPlayer(player: GamePlayer): void {
|
||||
this.players.push(player);
|
||||
this.state.players = [...this.players];
|
||||
}
|
||||
|
||||
public removePlayer(playerId: string): void {
|
||||
this.players = this.players.filter((p) => p.id !== playerId);
|
||||
this.state.players = [...this.players];
|
||||
}
|
||||
|
||||
abstract initialize(config?: GameConfig): void;
|
||||
abstract start(): void;
|
||||
abstract end(): void;
|
||||
abstract handlePlayerAction(playerId: string, action: any): void;
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
import type { Lobby, LobbyMember, Player } from "@/server/db/schema";
|
||||
import type { LobbyMemberLeaveEventData } from "@/server/redis/events";
|
||||
import { api } from "@/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
export function useRealtimeLobby({
|
||||
initialLobby,
|
||||
sessionPlayer,
|
||||
}: {
|
||||
initialLobby: Lobby;
|
||||
sessionPlayer?: Player | null;
|
||||
}) {
|
||||
const [lobby, setLobby] = React.useState(initialLobby);
|
||||
const [members, setMembers] = React.useState<Array<LobbyMember>>(
|
||||
initialLobby?.members ?? [],
|
||||
);
|
||||
const router = useRouter();
|
||||
api.lobby.onMemberUpdate.useSubscription(undefined, {
|
||||
onData({ data: _data }) {
|
||||
const joined = _data.joined;
|
||||
if (joined) {
|
||||
const data = _data.membership as LobbyMember;
|
||||
|
||||
setMembers((prev) => {
|
||||
if (prev.find((m) => m.playerId === data.playerId)) return prev;
|
||||
return [...prev, data];
|
||||
});
|
||||
} else {
|
||||
const data = _data.membership as LobbyMemberLeaveEventData;
|
||||
setMembers((prev) => prev.filter((m) => m.playerId !== data.playerId));
|
||||
if (data?.kicked && data?.playerId === sessionPlayer?.id) {
|
||||
router.push(appRoutes.lobby(lobby.id));
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
api.lobby.onUpdate.useSubscription(undefined, {
|
||||
onData({ data }) {
|
||||
if (!data?.lobby) return;
|
||||
setLobby((prev) => ({ ...data.lobby, members: prev.members }));
|
||||
},
|
||||
});
|
||||
return { lobby, members };
|
||||
}
|
||||
17
src/lib/store/current-player-store.ts
Normal file
17
src/lib/store/current-player-store.ts
Normal file
@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
import { create } from "zustand";
|
||||
import type { Player } from "@/server/db/schema";
|
||||
|
||||
type SessionPlayerStore = {
|
||||
sessionPlayer?: Player | null;
|
||||
setSessionPlayer: (player?: Player | null) => void;
|
||||
isLobbyAdmin: boolean;
|
||||
setIsLobbyAdmin: (isAdmin: boolean) => void;
|
||||
};
|
||||
|
||||
export const useSessionPlayerStore = create<SessionPlayerStore>((set) => ({
|
||||
sessionPlayer: undefined,
|
||||
setSessionPlayer: (player) => set({ sessionPlayer: player }),
|
||||
isLobbyAdmin: false,
|
||||
setIsLobbyAdmin: (isAdmin) => set({ isLobbyAdmin: isAdmin }),
|
||||
}));
|
||||
21
src/lib/store/game-store.ts
Normal file
21
src/lib/store/game-store.ts
Normal file
@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
import { create } from "zustand";
|
||||
import type { IMinigame } from "@/game-engien";
|
||||
import type { GameConfig } from "../validations/game";
|
||||
|
||||
type GameStore = {
|
||||
selectedGame?: IMinigame;
|
||||
setSelectedGame: (game?: IMinigame) => void;
|
||||
gameConfig: GameConfig;
|
||||
setGameConfig: (config: GameConfig) => void;
|
||||
updateGameConfig: (config: Partial<GameConfig>) => void;
|
||||
};
|
||||
|
||||
export const useGameStore = create<GameStore>((set, get) => ({
|
||||
selectedGame: undefined,
|
||||
setSelectedGame: (game) => set({ selectedGame: game }),
|
||||
gameConfig: {} as GameConfig,
|
||||
setGameConfig: (config) => set({ gameConfig: config }),
|
||||
updateGameConfig: (config) =>
|
||||
set({ gameConfig: { ...get().gameConfig, ...config } }),
|
||||
}));
|
||||
39
src/lib/store/lobby-store.ts
Normal file
39
src/lib/store/lobby-store.ts
Normal file
@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Lobby, LobbyMember } from "@/server/db/schema";
|
||||
|
||||
type LobbyStore = {
|
||||
lobby: Lobby;
|
||||
updateLobby: (lobby: Lobby) => void;
|
||||
resetLobby: (lobby?: Lobby) => void;
|
||||
members: Array<LobbyMember>;
|
||||
setMembers: (members: Array<LobbyMember>) => void;
|
||||
findMember: (playerId: string) => LobbyMember | null | undefined;
|
||||
addMember: (member: LobbyMember) => void;
|
||||
removeMember: (playerId: string, kicked?: boolean) => void;
|
||||
selectedGame: number;
|
||||
setSelectedGame: (gameId: number) => void;
|
||||
};
|
||||
|
||||
export const useLobbyStore = create<LobbyStore>((set, get) => ({
|
||||
lobby: {} as Lobby,
|
||||
members: [],
|
||||
selectedGame: 0,
|
||||
setSelectedGame: (gameId) => set({ selectedGame: gameId }),
|
||||
updateLobby: (lobby) =>
|
||||
set((state) => ({ lobby: { ...state, ...lobby, members: state.members } })),
|
||||
setMembers: (members) => set({ members }),
|
||||
resetLobby: (lobby) => set({ lobby: lobby ?? ({} as Lobby) }),
|
||||
findMember: (playerId) => get().members.find((m) => m.playerId === playerId),
|
||||
addMember: (member) =>
|
||||
set((state) => {
|
||||
if (state.members.find((m) => m.playerId === member.playerId))
|
||||
return state;
|
||||
return { members: [...state.members, member] };
|
||||
}),
|
||||
removeMember: (playerId) =>
|
||||
set((state) => ({
|
||||
members: state.members.filter((m) => m.playerId !== playerId),
|
||||
})),
|
||||
}));
|
||||
@ -14,8 +14,23 @@ export const formatDate = (date: Date) =>
|
||||
minute: "numeric",
|
||||
});
|
||||
|
||||
export function getBaseUrl() {
|
||||
export const getBaseUrl = () => {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
waitFor: number,
|
||||
) => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<T>): ReturnType<T> => {
|
||||
let result: any;
|
||||
timeout && clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
result = callback(...args);
|
||||
}, waitFor);
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
11
src/lib/validations/game.ts
Normal file
11
src/lib/validations/game.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const gameConfigPatchSchema = z.object({
|
||||
gameId: z.string().optional(),
|
||||
gameVariantId: z.string().optional(),
|
||||
timeLimit: z.number().min(2).max(120).default(2),
|
||||
scoreMultiplier: z.number().optional().optional(),
|
||||
allowHints: z.boolean().default(false).optional(),
|
||||
});
|
||||
|
||||
export type GameConfig = z.infer<typeof gameConfigPatchSchema>;
|
||||
11
src/server/api/mutation-utils/game-config.ts
Normal file
11
src/server/api/mutation-utils/game-config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { DBType } from "@/server/db";
|
||||
import { gameConfigurations } from "@/server/db/schema";
|
||||
|
||||
export async function createGameConfig(lobbyId: string, db: DBType) {
|
||||
await db
|
||||
.insert(gameConfigurations)
|
||||
.values({
|
||||
lobbyId,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
59
src/server/api/mutation-utils/lobby-utils.ts
Normal file
59
src/server/api/mutation-utils/lobby-utils.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { db } from "@/server/db";
|
||||
import { redis } from "@/server/redis";
|
||||
import { lobbies, lobbyMembers } from "@/server/db/schema";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { ee } from "@/server/sse";
|
||||
|
||||
export async function getLobbyMemberCount(lobbyId: string) {
|
||||
// Check if the count is in the cache
|
||||
const cachedCount = await redis.get(`lobby:${lobbyId}:memberCount`);
|
||||
if (cachedCount !== null) {
|
||||
return parseInt(cachedCount, 10);
|
||||
}
|
||||
|
||||
// If not in cache, query the database
|
||||
const [rawMemberCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(lobbyMembers)
|
||||
.where(eq(lobbyMembers.lobbyId, lobbyId));
|
||||
|
||||
const memberCount = rawMemberCount?.count ?? 0;
|
||||
// Cache the result
|
||||
await redis.set(`lobby:${lobbyId}:memberCount`, memberCount);
|
||||
return memberCount;
|
||||
}
|
||||
|
||||
export async function deleteLobbyIfEmpty(lobbyId: string) {
|
||||
const activeMemberCount = await getLobbyMemberCount(lobbyId);
|
||||
if (activeMemberCount > 0) return;
|
||||
|
||||
const [realMemberCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(lobbyMembers)
|
||||
.where(eq(lobbyMembers.lobbyId, lobbyId));
|
||||
if (realMemberCount?.count === 0) {
|
||||
await db.delete(lobbies).where(eq(lobbies.id, lobbyId));
|
||||
console.log(`Lobby with ID ${lobbyId} has been deleted. (EMPTY_LOBBY)`);
|
||||
// Optionally, remove the cached count
|
||||
await redis.del(`lobby:${lobbyId}:memberCount`);
|
||||
ee.emit("lobby:update", { id: lobbyId }, true);
|
||||
} else {
|
||||
console.error(
|
||||
"!!!!== Redis Cache is out of sync with the database. ==!!!!",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addAdminToLobby(lobbyId: string, playerId: string) {}
|
||||
|
||||
export async function handleLobbyAfterLeave(lobbyId: string) {
|
||||
await deleteLobbyIfEmpty(lobbyId);
|
||||
}
|
||||
|
||||
export async function decreaseLobbyMemberCount(lobbyId: string) {
|
||||
await redis.decr(`lobby:${lobbyId}:memberCount`);
|
||||
}
|
||||
|
||||
export async function increaseLobbyMemberCount(lobbyId: string) {
|
||||
await redis.incr(`lobby:${lobbyId}:memberCount`);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { lobbyRouter } from "@/server/api/routers/lobby";
|
||||
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
|
||||
import { playerRouter } from "./routers/player";
|
||||
import { gameRouter } from "./routers/game";
|
||||
import { gameConfigRouter } from "./routers/game-config";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@ -10,6 +12,8 @@ import { playerRouter } from "./routers/player";
|
||||
export const appRouter = createTRPCRouter({
|
||||
lobby: lobbyRouter,
|
||||
player: playerRouter,
|
||||
game: gameRouter,
|
||||
gameConfig: gameConfigRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
59
src/server/api/routers/game-config.ts
Normal file
59
src/server/api/routers/game-config.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { gameConfigurations } from "@/server/db/schema";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { z } from "zod";
|
||||
import { gameConfigPatchSchema } from "@/lib/validations/game";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ee } from "@/server/sse";
|
||||
import { tracked } from "@trpc/server";
|
||||
import type { EventArgs } from "@/server/sse/events";
|
||||
|
||||
export const gameConfigRouter = createTRPCRouter({
|
||||
// game configurator
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
lobbyId: z.string(),
|
||||
config: gameConfigPatchSchema,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
console.log("Check if user is admin");
|
||||
|
||||
const [config] = await ctx.db
|
||||
.update(gameConfigurations)
|
||||
.set({ config: input.config })
|
||||
.where(eq(gameConfigurations.lobbyId, input.lobbyId))
|
||||
.returning({
|
||||
config: gameConfigurations.config,
|
||||
});
|
||||
if (config?.config)
|
||||
ee.emit("game:config:update", {
|
||||
lobbyId: input.lobbyId,
|
||||
config: config.config,
|
||||
});
|
||||
|
||||
return config;
|
||||
}),
|
||||
|
||||
// subscriptions
|
||||
onUpdate: publicProcedure
|
||||
.input(z.object({ lobbyId: z.string() }))
|
||||
.subscription(async function* (opts) {
|
||||
const iterable = ee.toIterable("game:config:update", {
|
||||
signal: opts.signal,
|
||||
});
|
||||
|
||||
function* maybeYield([
|
||||
{ config, lobbyId },
|
||||
]: EventArgs["game:config:update"]) {
|
||||
if (lobbyId !== opts.input.lobbyId) {
|
||||
return;
|
||||
}
|
||||
yield tracked(lobbyId, config);
|
||||
}
|
||||
|
||||
for await (const args of iterable) {
|
||||
yield* maybeYield(args);
|
||||
}
|
||||
}),
|
||||
});
|
||||
15
src/server/api/routers/game.ts
Normal file
15
src/server/api/routers/game.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { gameConfigurations } from "@/server/db/schema";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { z } from "zod";
|
||||
import { gameConfigPatchSchema } from "@/lib/validations/game";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ee } from "@/server/sse";
|
||||
|
||||
export const gameRouter = createTRPCRouter({
|
||||
// game sessions
|
||||
createGameSession: publicProcedure
|
||||
.input(z.object({ lobbyId: z.string() }))
|
||||
.query(async ({ ctx }) => {
|
||||
return null;
|
||||
}),
|
||||
});
|
||||
@ -11,14 +11,15 @@ import {
|
||||
lobbyPatchSchema,
|
||||
} from "@/lib/validations/lobby";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
combineRedisIterators,
|
||||
redisAsyncIterator,
|
||||
redisPublish,
|
||||
} from "@/server/redis/sse-redis";
|
||||
import { tracked } from "@trpc/server";
|
||||
import { trcpSubscriptionInput } from "@/lib/validations/trcp";
|
||||
import type { LobbyMemberLeaveEventData } from "@/server/redis/events";
|
||||
import type { EventArgs } from "@/server/sse/events";
|
||||
import {
|
||||
decreaseLobbyMemberCount,
|
||||
increaseLobbyMemberCount,
|
||||
handleLobbyAfterLeave,
|
||||
} from "@/server/api/mutation-utils/lobby-utils";
|
||||
import { createGameConfig } from "../mutation-utils/game-config";
|
||||
import { ee } from "@/server/sse";
|
||||
|
||||
export const lobbyRouter = createTRPCRouter({
|
||||
// queries
|
||||
@ -28,6 +29,7 @@ export const lobbyRouter = createTRPCRouter({
|
||||
with: {
|
||||
lobby: {
|
||||
with: {
|
||||
gameConfig: true,
|
||||
members: {
|
||||
with: {
|
||||
player: true,
|
||||
@ -51,6 +53,7 @@ export const lobbyRouter = createTRPCRouter({
|
||||
await ctx.db.query.lobbies.findFirst({
|
||||
where: eq(lobbies.id, input.id),
|
||||
with: {
|
||||
gameConfig: true,
|
||||
members: {
|
||||
with: {
|
||||
player: true,
|
||||
@ -76,14 +79,21 @@ export const lobbyRouter = createTRPCRouter({
|
||||
})
|
||||
.returning({ id: lobbies.id });
|
||||
if (!lobby) throw new Error("Error creating lobby");
|
||||
await ctx.db.insert(lobbyMembers).values({
|
||||
lobbyId: lobby.id,
|
||||
playerId: ctx.session.user.id,
|
||||
isReady: false,
|
||||
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
const [member] = await ctx.db
|
||||
.insert(lobbyMembers)
|
||||
.values({
|
||||
lobbyId: lobby.id,
|
||||
playerId: ctx.session.user.id,
|
||||
isReady: false,
|
||||
role: "admin",
|
||||
})
|
||||
.returning({
|
||||
id: lobbyMembers.playerId,
|
||||
});
|
||||
if (member && lobby) {
|
||||
increaseLobbyMemberCount(lobby.id);
|
||||
createGameConfig(lobby.id, ctx.db);
|
||||
}
|
||||
return lobby;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
@ -107,7 +117,7 @@ export const lobbyRouter = createTRPCRouter({
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (lobby) redisPublish("lobby:update", lobby);
|
||||
if (lobby) ee.emit("lobby:update", lobby);
|
||||
return lobby;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
@ -140,22 +150,49 @@ export const lobbyRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.join) {
|
||||
const [member] = await ctx.db
|
||||
.insert(lobbyMembers)
|
||||
.values({
|
||||
lobbyId: input.lobbyId,
|
||||
playerId: ctx.session.user.id,
|
||||
isReady: false,
|
||||
role: "player",
|
||||
})
|
||||
.returning();
|
||||
const player = member
|
||||
? await ctx.db.query.players.findFirst({
|
||||
where: eq(players.id, member.playerId),
|
||||
try {
|
||||
const [member] = await ctx.db
|
||||
.insert(lobbyMembers)
|
||||
.values({
|
||||
lobbyId: input.lobbyId,
|
||||
playerId: ctx.session.user.id,
|
||||
isReady: false,
|
||||
role: "player",
|
||||
})
|
||||
: undefined;
|
||||
if (member) redisPublish("lobby:member:join", { ...member, player });
|
||||
return member;
|
||||
.returning();
|
||||
const player = member
|
||||
? await ctx.db.query.players.findFirst({
|
||||
where: eq(players.id, member.playerId),
|
||||
})
|
||||
: undefined;
|
||||
if (member) {
|
||||
ee.emit("lobby:member:membership", input.lobbyId, true, {
|
||||
...member,
|
||||
player,
|
||||
});
|
||||
increaseLobbyMemberCount(input.lobbyId);
|
||||
return { success: true, member };
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
if (
|
||||
e.message.includes(
|
||||
"duplicate key value violates unique constraint",
|
||||
)
|
||||
)
|
||||
return {
|
||||
knownError: true,
|
||||
succes: false,
|
||||
error: "You can only be in one lobby at a time.",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
knownError: true,
|
||||
succes: false,
|
||||
error: "Error joining lobby",
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [member] = await ctx.db
|
||||
.delete(lobbyMembers)
|
||||
@ -166,9 +203,17 @@ export const lobbyRouter = createTRPCRouter({
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (member)
|
||||
redisPublish("lobby:member:leave", { playerId: member.playerId });
|
||||
return member;
|
||||
if (member) {
|
||||
ee.emit(
|
||||
"lobby:member:membership",
|
||||
input.lobbyId,
|
||||
false,
|
||||
member.playerId,
|
||||
);
|
||||
decreaseLobbyMemberCount(input.lobbyId);
|
||||
handleLobbyAfterLeave(input.lobbyId);
|
||||
return { success: true, member };
|
||||
}
|
||||
}
|
||||
}),
|
||||
// admin mutaions
|
||||
@ -193,7 +238,7 @@ export const lobbyRouter = createTRPCRouter({
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (member) redisPublish("lobby:member:update", member);
|
||||
if (member) ee.emit("lobby:member:update", member);
|
||||
return member;
|
||||
}),
|
||||
|
||||
@ -217,59 +262,58 @@ export const lobbyRouter = createTRPCRouter({
|
||||
)
|
||||
.returning();
|
||||
if (member)
|
||||
redisPublish("lobby:member:leave", {
|
||||
playerId: member.playerId,
|
||||
kicked: true,
|
||||
});
|
||||
ee.emit(
|
||||
"lobby:member:membership",
|
||||
input.lobbyId,
|
||||
false,
|
||||
member.playerId,
|
||||
true,
|
||||
);
|
||||
decreaseLobbyMemberCount(input.lobbyId);
|
||||
return member;
|
||||
}),
|
||||
|
||||
// subscriptions
|
||||
onUpdate: publicProcedure
|
||||
.input(trcpSubscriptionInput)
|
||||
.input(z.object({ lobbyId: z.string() }))
|
||||
.subscription(async function* (opts) {
|
||||
if (opts.input?.lastEventId) {
|
||||
// fetch posts from a database that were missed.
|
||||
const iterable = ee.toIterable("lobby:update", {
|
||||
signal: opts.signal,
|
||||
});
|
||||
|
||||
function* maybeYield([lobby, deleted]: EventArgs["lobby:update"]) {
|
||||
if (lobby.id !== opts.input.lobbyId) {
|
||||
return;
|
||||
}
|
||||
yield tracked(lobby.id, { lobby, deleted });
|
||||
}
|
||||
for await (const lobby of redisAsyncIterator("lobby:update")) {
|
||||
yield tracked(lobby?.updatedAt?.toString(), {
|
||||
lobby,
|
||||
});
|
||||
|
||||
for await (const args of iterable) {
|
||||
yield* maybeYield(args);
|
||||
}
|
||||
}),
|
||||
|
||||
onMemberUpdate: publicProcedure
|
||||
.input(trcpSubscriptionInput)
|
||||
.input(z.object({ lobbyId: z.string() }))
|
||||
.subscription(async function* (opts) {
|
||||
if (opts.input?.lastEventId) {
|
||||
// fetch posts from a database that were missed.
|
||||
const iterable = ee.toIterable("lobby:member:membership", {
|
||||
signal: opts.signal,
|
||||
});
|
||||
|
||||
function* maybeYield([
|
||||
lobbyId,
|
||||
joined,
|
||||
member,
|
||||
kicked,
|
||||
]: EventArgs["lobby:member:membership"]) {
|
||||
if (lobbyId !== opts.input.lobbyId) {
|
||||
return;
|
||||
}
|
||||
yield tracked(lobbyId, { member, joined, kicked });
|
||||
}
|
||||
|
||||
for await (const { event, data: membership } of combineRedisIterators([
|
||||
"lobby:member:join",
|
||||
"lobby:member:leave",
|
||||
"lobby:member:update",
|
||||
])) {
|
||||
switch (event) {
|
||||
case "lobby:member:join":
|
||||
yield tracked(String(membership.playerId), {
|
||||
joined: true,
|
||||
membership,
|
||||
});
|
||||
break;
|
||||
case "lobby:member:leave":
|
||||
const data = membership as LobbyMemberLeaveEventData;
|
||||
yield tracked(String(membership.playerId), {
|
||||
joined: false,
|
||||
membership: data,
|
||||
});
|
||||
break;
|
||||
// case "lobby:member:update":
|
||||
// yield tracked(String(membership.playerId), {
|
||||
// membership,
|
||||
// });
|
||||
// break;
|
||||
}
|
||||
for await (const args of iterable) {
|
||||
yield* maybeYield(args);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { players } from "@/server/db/schema";
|
||||
import { players, type Player } from "@/server/db/schema";
|
||||
|
||||
export const playerRouter = createTRPCRouter({
|
||||
getBySession: publicProcedure.query(async ({ ctx }) => {
|
||||
return ctx?.session?.user
|
||||
? await ctx.db.query.players.findFirst({
|
||||
? ((await ctx.db.query.players.findFirst({
|
||||
where: eq(players.id, ctx.session.user.id),
|
||||
})
|
||||
})) as Player)
|
||||
: null;
|
||||
}),
|
||||
});
|
||||
|
||||
@ -3,12 +3,8 @@ import { pgTableCreator, index, primaryKey } from "drizzle-orm/pg-core";
|
||||
import { type AdapterAccount } from "next-auth/adapters";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { LobbyMemberRole } from "@/lib/validations/lobby";
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
* database instance for multiple projects.
|
||||
*
|
||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
||||
*/
|
||||
import type { GameConfig } from "@/lib/validations/game";
|
||||
|
||||
export const createTable = pgTableCreator((name) => `game-master_${name}`);
|
||||
|
||||
const defaultTimeStamp = (name: string, d: any) =>
|
||||
@ -17,6 +13,23 @@ const defaultTimeStamp = (name: string, d: any) =>
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull();
|
||||
|
||||
export const gameConfigurations = createTable(
|
||||
"game_configuration",
|
||||
(d) => ({
|
||||
lobbyId: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => lobbies.id, { onDelete: "cascade" }),
|
||||
config: d.jsonb().$type<GameConfig>(),
|
||||
}),
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.lobbyId] }),
|
||||
index("game_configuration_lobby_id_idx").on(t.lobbyId),
|
||||
],
|
||||
);
|
||||
|
||||
export type GameConfigurationTable = typeof gameConfigurations.$inferSelect;
|
||||
|
||||
export const lobbies = createTable("lobby", (d) => ({
|
||||
id: d
|
||||
.varchar({ length: 255 })
|
||||
@ -35,19 +48,24 @@ export const lobbies = createTable("lobby", (d) => ({
|
||||
.$onUpdate(() => new Date()),
|
||||
}));
|
||||
|
||||
export type Lobby = typeof lobbies.$inferSelect & {
|
||||
members?: LobbyMember[];
|
||||
leader?: Player;
|
||||
};
|
||||
|
||||
export const lobbyRelations = relations(lobbies, ({ many, one }) => ({
|
||||
leader: one(players, {
|
||||
fields: [lobbies.createdById],
|
||||
references: [players.id],
|
||||
}),
|
||||
members: many(lobbyMembers),
|
||||
gameConfig: one(gameConfigurations, {
|
||||
fields: [lobbies.id],
|
||||
references: [gameConfigurations.lobbyId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Lobby = typeof lobbies.$inferSelect & {
|
||||
members?: LobbyMember[];
|
||||
leader?: Player;
|
||||
gameConfig?: GameConfigurationTable;
|
||||
};
|
||||
|
||||
export const lobbyMembers = createTable(
|
||||
"lobby_member",
|
||||
(d) => ({
|
||||
|
||||
10
src/server/redis/events.d.ts
vendored
10
src/server/redis/events.d.ts
vendored
@ -1,10 +0,0 @@
|
||||
import type { LobbyMember, Post } from "../db/schema";
|
||||
|
||||
export type LobbyMemberLeaveEventData = { playerId: string; kicked?: boolean };
|
||||
|
||||
export type PubSubEvents = {
|
||||
"lobby:update": Lobby;
|
||||
"lobby:member:update": LobbyMember;
|
||||
"lobby:member:join": LobbyMember;
|
||||
"lobby:member:leave": LobbyMemberLeaveEventData;
|
||||
};
|
||||
11
src/server/redis/index.ts
Normal file
11
src/server/redis/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { env } from "@/env";
|
||||
import Redis, { type Redis as RedisType } from "ioredis";
|
||||
|
||||
const globalForRedis = globalThis as unknown as {
|
||||
redis: RedisType | undefined;
|
||||
};
|
||||
|
||||
const redisInstance = globalForRedis.redis ?? new Redis();
|
||||
if (env.NODE_ENV !== "production") globalForRedis.redis = redisInstance;
|
||||
|
||||
export const redis = redisInstance;
|
||||
@ -1,73 +0,0 @@
|
||||
import Redis from "ioredis";
|
||||
import type { PubSubEvents } from "./events";
|
||||
|
||||
const redisPub = new Redis();
|
||||
const redisSub = new Redis();
|
||||
|
||||
export const redisPublish = <T extends keyof PubSubEvents>(
|
||||
event: T,
|
||||
data: PubSubEvents[T],
|
||||
) => {
|
||||
redisPub.publish(event, JSON.stringify(data));
|
||||
};
|
||||
|
||||
const redisSubscribe = <T extends keyof PubSubEvents>(
|
||||
event: T,
|
||||
callback: (data: PubSubEvents[T]) => void,
|
||||
) => {
|
||||
redisSub.subscribe(event, (err) => {
|
||||
if (err) console.error(`Redis subscription error for ${event}:`, err);
|
||||
});
|
||||
|
||||
redisSub.on("message", (channel, message) => {
|
||||
if (channel === event) {
|
||||
callback(JSON.parse(message) as PubSubEvents[T]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an async iterator for a Redis subscription channel.
|
||||
* @param event - The Redis event to subscribe to.
|
||||
* @returns An async iterator that yields tracked events.
|
||||
*/
|
||||
export function redisAsyncIterator<K extends keyof PubSubEvents>(
|
||||
event: K,
|
||||
): AsyncIterableIterator<PubSubEvents[K]> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
async next(): Promise<IteratorResult<PubSubEvents[K]>> {
|
||||
return new Promise((resolve) => {
|
||||
redisSubscribe(event, (data: PubSubEvents[K]) => {
|
||||
resolve({ value: data, done: false });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function* combineRedisIterators<T extends keyof PubSubEvents>(
|
||||
events: T[],
|
||||
) {
|
||||
const iterators = events.map((event) =>
|
||||
redisAsyncIterator(event)[Symbol.asyncIterator](),
|
||||
);
|
||||
|
||||
while (true) {
|
||||
// Wait for the next event from any iterator
|
||||
const result = await Promise.race(
|
||||
iterators.map((iterator, index) =>
|
||||
iterator.next().then((untypedRes) => {
|
||||
const res = untypedRes as IteratorResult<PubSubEvents[T]>;
|
||||
return { res, event: events[index] };
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (result.res.done) break;
|
||||
|
||||
yield { event: result.event, data: result.res.value };
|
||||
}
|
||||
}
|
||||
37
src/server/sse/events.d.ts
vendored
Normal file
37
src/server/sse/events.d.ts
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
import type { GameConfig } from "@/lib/validations/game";
|
||||
import type { LobbyMember, Post } from "../db/schema";
|
||||
|
||||
export type LobbyMemberLeaveEventData = { playerId: string; kicked?: boolean };
|
||||
|
||||
export type EventArgs = {
|
||||
"lobby:member:membership": [
|
||||
// lobbyId
|
||||
string,
|
||||
// joined
|
||||
boolean,
|
||||
// member
|
||||
LobbyMember | string,
|
||||
// kicked
|
||||
boolean?,
|
||||
];
|
||||
"lobby:update": [
|
||||
//updated Lobby
|
||||
Lobby,
|
||||
// deleted
|
||||
boolean?,
|
||||
];
|
||||
"game:config:update": [
|
||||
{
|
||||
lobbyId: string;
|
||||
config: GameConfig;
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export type SSE_EVENTS = {
|
||||
"lobby:update": EventArgs["lobby:update"];
|
||||
"lobby:member:update": [LobbyMember];
|
||||
"lobby:member:membership": EventArgs["lobby:member:membership"];
|
||||
|
||||
"game:config:update": EventArgs["game:config:update"];
|
||||
};
|
||||
14
src/server/sse/index.ts
Normal file
14
src/server/sse/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import EventEmitter, { on } from "node:events";
|
||||
import type { SSE_EVENTS } from "./events";
|
||||
|
||||
type EventMap<T> = Record<keyof T, any[]>;
|
||||
class IterableEventEmitter<T extends EventMap<T>> extends EventEmitter<T> {
|
||||
toIterable<TEventName extends keyof T & string>(
|
||||
eventName: TEventName,
|
||||
opts?: NonNullable<Parameters<typeof on>[2]>,
|
||||
): AsyncIterable<T[TEventName]> {
|
||||
return on(this as any, eventName, opts) as any;
|
||||
}
|
||||
}
|
||||
|
||||
export const ee = new IterableEventEmitter<SSE_EVENTS>();
|
||||
@ -137,4 +137,8 @@
|
||||
.text-shadow-primary {
|
||||
text-shadow: 1px 1px 2px var(--primary);
|
||||
}
|
||||
.popover-content-width-same-as-its-trigger {
|
||||
width: var(--radix-popover-trigger-width);
|
||||
max-height: var(--radix-popover-content-available-height);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,8 @@
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@game/*": ["./src/game-engien/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user