added game engien and game configurator
This commit is contained in:
parent
f0672b9bc1
commit
a77bac304e
@ -31,6 +31,7 @@
|
|||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
"@radix-ui/react-slider": "^1.2.3",
|
"@radix-ui/react-slider": "^1.2.3",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.69.0",
|
"@tanstack/react-query": "^5.69.0",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
@ -39,6 +40,7 @@
|
|||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|||||||
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
@ -41,6 +41,9 @@ importers:
|
|||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/react@19.0.12)(react@19.0.0)
|
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':
|
'@t3-oss/env-nextjs':
|
||||||
specifier: ^0.12.0
|
specifier: ^0.12.0
|
||||||
version: 0.12.0(typescript@5.8.2)(zod@3.24.2)
|
version: 0.12.0(typescript@5.8.2)(zod@3.24.2)
|
||||||
@ -65,6 +68,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
dotenv:
|
||||||
specifier: ^16.4.7
|
specifier: ^16.4.7
|
||||||
version: 16.4.7
|
version: 16.4.7
|
||||||
@ -1205,6 +1211,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-use-callback-ref@1.1.0':
|
||||||
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
|
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1732,6 +1751,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@ -4179,6 +4204,21 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.0.12
|
'@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)':
|
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.0.12)(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
@ -4703,6 +4743,18 @@ snapshots:
|
|||||||
|
|
||||||
cluster-key-slot@1.1.2: {}
|
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:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|||||||
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 |
59
src/app/_components/game/configurator/game-config-form.tsx
Normal file
59
src/app/_components/game/configurator/game-config-form.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { gameConfigPatchSchema } from "@/lib/validations/game";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
export default function GameConfigForm() {
|
||||||
|
// ...
|
||||||
|
const form = useForm<z.infer<typeof gameConfigPatchSchema>>({
|
||||||
|
resolver: zodResolver(gameConfigPatchSchema),
|
||||||
|
defaultValues: {
|
||||||
|
allowHints: false,
|
||||||
|
scoreMultiplier: 1,
|
||||||
|
timeLimit: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Define a submit handler.
|
||||||
|
function onSubmit(values: z.infer<typeof gameConfigPatchSchema>) {
|
||||||
|
// Do something with the form values.
|
||||||
|
// ✅ This will be type-safe and validated.
|
||||||
|
console.log(values);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowHints"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="container-bg flex flex-row items-center justify-between p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Allow hints</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <Button type="submit">Submit</Button> */}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/_components/game/configurator/game-configurator.tsx
Normal file
52
src/app/_components/game/configurator/game-configurator.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
function GameConfigurator() {
|
||||||
|
const selectGame = useGameStore((state) => state.setSelectedGame);
|
||||||
|
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col justify-between p-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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 />
|
||||||
|
<GameConfigForm />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedGame && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button>Start Game</Button>
|
||||||
|
<Button
|
||||||
|
variant={"ghost"}
|
||||||
|
size={"icon"}
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => selectGame(undefined)}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GameConfigurator;
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
"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() {
|
||||||
|
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||||
|
const variants = getGameVariants(selectedGame?.id!);
|
||||||
|
if (!variants.length) return null;
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
className="w-full rounded-2xl"
|
||||||
|
onSelect={() => {}}
|
||||||
|
initialValue={variants[0]?.value}
|
||||||
|
data={variants}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GameVariantSelector;
|
||||||
@ -1,60 +1,21 @@
|
|||||||
|
import type { IMinigame } from "@/game-engien";
|
||||||
|
import { gameLibary } from "@/game-engien/games";
|
||||||
|
import { useGameStore } from "@/lib/store/game-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React from "react";
|
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,
|
function GameSelector() {
|
||||||
minPlayers: 2,
|
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||||
},
|
const setSelectedGame = useGameStore((state) => state.setSelectedGame);
|
||||||
{
|
|
||||||
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 (
|
return (
|
||||||
<div className="container-bg grid w-full grid-cols-3 gap-4 p-6">
|
<div className="grid w-full grid-cols-4 gap-4">
|
||||||
{mokGames.map((game) => (
|
{gameLibary.map((game) => (
|
||||||
<GameCard
|
<GameCard
|
||||||
key={game.id}
|
key={game.id}
|
||||||
game={game}
|
game={game}
|
||||||
selected={selected === game.id}
|
selected={selectedGame?.id === game.id}
|
||||||
onClick={() => setSelected(game.id)}
|
onClick={() => setSelectedGame(game)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -65,7 +26,7 @@ const GameCard = ({
|
|||||||
selected,
|
selected,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
game: Game;
|
game: IMinigame;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
@ -82,7 +43,7 @@ const GameCard = ({
|
|||||||
<Image
|
<Image
|
||||||
fill
|
fill
|
||||||
alt="game-image"
|
alt="game-image"
|
||||||
src={game.image}
|
src={game.thumbnail}
|
||||||
className={cn(
|
className={cn(
|
||||||
"object-cover opacity-50 transition-all duration-300 ease-in-out group-hover:opacity-90",
|
"object-cover opacity-50 transition-all duration-300 ease-in-out group-hover:opacity-90",
|
||||||
selected && "opacity-100",
|
selected && "opacity-100",
|
||||||
@ -9,11 +9,12 @@ import CopyToClip from "@/components/copy-to-clip";
|
|||||||
import { appRoutes } from "@/config/app.routes";
|
import { appRoutes } from "@/config/app.routes";
|
||||||
import LobbySettingsDialog from "./lobby-settings-dialog";
|
import LobbySettingsDialog from "./lobby-settings-dialog";
|
||||||
import { getBaseUrl } from "@/lib/utils";
|
import { getBaseUrl } from "@/lib/utils";
|
||||||
import GameSelector from "./game-selector";
|
import GameSelector from "../game/game-selector";
|
||||||
import LobbyProvider from "./lobby-provider";
|
import LobbyProvider from "./lobby-provider";
|
||||||
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||||
import { useLobbyStore } from "@/lib/store/lobby-store";
|
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||||
import { Share2, UserPlus } from "lucide-react";
|
import { Share2, UserPlus } from "lucide-react";
|
||||||
|
import GameConfigurator from "../game/configurator/game-configurator";
|
||||||
|
|
||||||
function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
|
function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
|
||||||
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
|
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
|
||||||
@ -27,7 +28,7 @@ function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LobbyProvider initialLobby={initialLobby}>
|
<LobbyProvider initialLobby={initialLobby}>
|
||||||
<div className="grid max-w-4xl grid-cols-3 grid-rows-2 gap-4">
|
<div className="grid w-full 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="container-bg col-span-2 w-full space-y-4 p-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CopyToClip
|
<CopyToClip
|
||||||
@ -93,8 +94,10 @@ function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="container-bg col-span-1 size-full"></div>
|
<div className="container-bg col-span-1 size-full">
|
||||||
<div className="col-span-3">
|
<GameConfigurator />
|
||||||
|
</div>
|
||||||
|
<div className="container-bg col-span-3 h-max w-full p-4">
|
||||||
<GameSelector />
|
<GameSelector />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 };
|
||||||
36
src/game-engien/games/index.ts
Normal file
36
src/game-engien/games/index.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type { IMinigame } from "..";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
12
src/lib/store/game-store.ts
Normal file
12
src/lib/store/game-store.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { IMinigame } from "@/game-engien";
|
||||||
|
|
||||||
|
type GameStore = {
|
||||||
|
selectedGame?: IMinigame;
|
||||||
|
setSelectedGame: (game?: IMinigame) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGameStore = create<GameStore>((set) => ({
|
||||||
|
selectedGame: undefined,
|
||||||
|
setSelectedGame: (game) => set({ selectedGame: game }),
|
||||||
|
}));
|
||||||
9
src/lib/validations/game.ts
Normal file
9
src/lib/validations/game.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const gameConfigPatchSchema = z.object({
|
||||||
|
timeLimit: z.number().default(2),
|
||||||
|
scoreMultiplier: z.number().optional(),
|
||||||
|
allowHints: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GameConfig = z.infer<typeof gameConfigPatchSchema>;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { db } from "@/server/db";
|
import { db } from "@/server/db";
|
||||||
import { redis } from "..";
|
import { redis } from "@/server/redis";
|
||||||
import { lobbies, lobbyMembers } from "@/server/db/schema";
|
import { lobbies, lobbyMembers } from "@/server/db/schema";
|
||||||
import { count, eq } from "drizzle-orm";
|
import { count, eq } from "drizzle-orm";
|
||||||
import { ee } from "@/server/api/routers/lobby";
|
import { ee } from "@/server/api/routers/lobby";
|
||||||
@ -18,7 +18,7 @@ import {
|
|||||||
decreaseLobbyMemberCount,
|
decreaseLobbyMemberCount,
|
||||||
increaseLobbyMemberCount,
|
increaseLobbyMemberCount,
|
||||||
handleLobbyAfterLeave,
|
handleLobbyAfterLeave,
|
||||||
} from "@/server/redis/utils/lobby-utils";
|
} from "@/server/api/mutation-utils/lobby-utils";
|
||||||
|
|
||||||
type EventMap<T> = Record<keyof T, any[]>;
|
type EventMap<T> = Record<keyof T, any[]>;
|
||||||
class IterableEventEmitter<T extends EventMap<T>> extends EventEmitter<T> {
|
class IterableEventEmitter<T extends EventMap<T>> extends EventEmitter<T> {
|
||||||
|
|||||||
@ -137,4 +137,8 @@
|
|||||||
.text-shadow-primary {
|
.text-shadow-primary {
|
||||||
text-shadow: 1px 1px 2px var(--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 */
|
/* Path Aliases */
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"@game/*": ["./src/game-engien/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user