diff --git a/package.json b/package.json index 9d588d3..89068ec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ead892..b61a8c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -1205,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: @@ -1732,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'} @@ -4179,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 @@ -4703,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 diff --git a/public/game-placeholder.jpg b/public/game-placeholder.jpg deleted file mode 100644 index 15c96a6..0000000 Binary files a/public/game-placeholder.jpg and /dev/null differ diff --git a/public/games/quiz.jpg b/public/games/quiz.jpg new file mode 100644 index 0000000..7c4c924 Binary files /dev/null and b/public/games/quiz.jpg differ diff --git a/public/games/reaction.jpg b/public/games/reaction.jpg new file mode 100644 index 0000000..5e936a3 Binary files /dev/null and b/public/games/reaction.jpg differ diff --git a/public/games/trivia.jpg b/public/games/trivia.jpg new file mode 100644 index 0000000..d450aca Binary files /dev/null and b/public/games/trivia.jpg differ diff --git a/src/app/_components/game/configurator/game-config-form.tsx b/src/app/_components/game/configurator/game-config-form.tsx new file mode 100644 index 0000000..37a1089 --- /dev/null +++ b/src/app/_components/game/configurator/game-config-form.tsx @@ -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>({ + resolver: zodResolver(gameConfigPatchSchema), + defaultValues: { + allowHints: false, + scoreMultiplier: 1, + timeLimit: 2, + }, + }); + + // 2. Define a submit handler. + function onSubmit(values: z.infer) { + // Do something with the form values. + // ✅ This will be type-safe and validated. + console.log(values); + } + return ( +
+ + ( + +
+ Allow hints +
+ + + +
+ )} + /> + {/* */} + + + ); +} diff --git a/src/app/_components/game/configurator/game-configurator.tsx b/src/app/_components/game/configurator/game-configurator.tsx new file mode 100644 index 0000000..a68e343 --- /dev/null +++ b/src/app/_components/game/configurator/game-configurator.tsx @@ -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 ( +
+
+
+

+ {selectedGame?.name ?? "Select a game to get started"} +

+ {selectedGame && ( + + )} +
+ + {selectedGame && ( + <> + + + + )} +
+ + {selectedGame && ( +
+ + +
+ )} +
+ ); +} + +export default GameConfigurator; diff --git a/src/app/_components/game/configurator/game-variant-selector.tsx b/src/app/_components/game/configurator/game-variant-selector.tsx new file mode 100644 index 0000000..36c5f0a --- /dev/null +++ b/src/app/_components/game/configurator/game-variant-selector.tsx @@ -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 ( + {}} + initialValue={variants[0]?.value} + data={variants} + /> + ); +} + +export default GameVariantSelector; diff --git a/src/app/_components/lobby/game-selector.tsx b/src/app/_components/game/game-selector.tsx similarity index 53% rename from src/app/_components/lobby/game-selector.tsx rename to src/app/_components/game/game-selector.tsx index b5e7380..f404334 100644 --- a/src/app/_components/lobby/game-selector.tsx +++ b/src/app/_components/game/game-selector.tsx @@ -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 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 = [ - { - 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); +function GameSelector() { + const selectedGame = useGameStore((state) => state.selectedGame); + const setSelectedGame = useGameStore((state) => state.setSelectedGame); return ( -
- {mokGames.map((game) => ( +
+ {gameLibary.map((game) => ( setSelected(game.id)} + selected={selectedGame?.id === game.id} + onClick={() => setSelectedGame(game)} /> ))}
@@ -65,7 +26,7 @@ const GameCard = ({ selected, onClick, }: { - game: Game; + game: IMinigame; selected?: boolean; onClick?: () => void; }) => { @@ -82,7 +43,7 @@ const GameCard = ({ game-image state.sessionPlayer); @@ -27,7 +28,7 @@ function LobbyPage({ initialLobby }: { initialLobby: Lobby }) { return ( -
+
-
-
+
+ +
+
diff --git a/src/components/combobox.tsx b/src/components/combobox.tsx new file mode 100644 index 0000000..eefb517 --- /dev/null +++ b/src/components/combobox.tsx @@ -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 ( + + + + + + { + 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 && ( + + )} + + {messageUi?.empty ?? "Nothing found."} + + {data.map((item) => ( + { + const newValue = currentValue === value ? "" : currentValue; + setValue(newValue); + onSelect(newValue); + setOpen(false); + }} + > +
+ {item?.Icon && } + {item.label} +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..4ca5349 --- /dev/null +++ b/src/components/ui/command.tsx @@ -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) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + ...props +}: React.ComponentProps & { + title?: string + description?: string +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..13e2ed3 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -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) { + return ( + + + + ); +} + +export { Switch }; diff --git a/src/game-engien/games/index.ts b/src/game-engien/games/index.ts new file mode 100644 index 0000000..f40d651 --- /dev/null +++ b/src/game-engien/games/index.ts @@ -0,0 +1,36 @@ +import type { IMinigame } from ".."; + +export const gameLibary: Array = [ + { + 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, + }, +]; diff --git a/src/game-engien/games/quiz/index.ts b/src/game-engien/games/quiz/index.ts new file mode 100644 index 0000000..d9938c4 --- /dev/null +++ b/src/game-engien/games/quiz/index.ts @@ -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; + category: string; + difficulty: "easy" | "medium" | "hard"; + tags: Array; +} + +interface QuizGameConfig extends IGameConfig { + randomizeQuestions?: boolean; + revealAnswersImmediately?: boolean; + pointsPerQuestion?: number; +} + +interface QuizGameState extends IGameState { + currentQuestion?: QuizQuestion; + answeredQuestions: Set; + playerAnswers: Record>; + leaderboard: Array; +} + +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 = 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(), + 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(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 = 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()); + } +} diff --git a/src/game-engien/games/quiz/quiz-data.ts b/src/game-engien/games/quiz/quiz-data.ts new file mode 100644 index 0000000..ef0a4f5 --- /dev/null +++ b/src/game-engien/games/quiz/quiz-data.ts @@ -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... + ], +}; diff --git a/src/game-engien/index.ts b/src/game-engien/index.ts new file mode 100644 index 0000000..436fe7c --- /dev/null +++ b/src/game-engien/index.ts @@ -0,0 +1,65 @@ +import type { GameConfig } from "@/lib/validations/game"; +import type { Player } from "@/server/db/schema"; + +export type GamePlayer = Pick & { + 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; + currentRound: number; + totalRounds: number; + timeRemaining?: number; +} + +// Base Game abstract class +export abstract class Game { + protected meta: IMinigame; + protected players: Array = []; + 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; +} diff --git a/src/lib/store/game-store.ts b/src/lib/store/game-store.ts new file mode 100644 index 0000000..4bbb837 --- /dev/null +++ b/src/lib/store/game-store.ts @@ -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((set) => ({ + selectedGame: undefined, + setSelectedGame: (game) => set({ selectedGame: game }), +})); diff --git a/src/lib/validations/game.ts b/src/lib/validations/game.ts new file mode 100644 index 0000000..1eff03a --- /dev/null +++ b/src/lib/validations/game.ts @@ -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; diff --git a/src/server/redis/utils/lobby-utils.ts b/src/server/api/mutation-utils/lobby-utils.ts similarity index 98% rename from src/server/redis/utils/lobby-utils.ts rename to src/server/api/mutation-utils/lobby-utils.ts index c948db9..7e7f737 100644 --- a/src/server/redis/utils/lobby-utils.ts +++ b/src/server/api/mutation-utils/lobby-utils.ts @@ -1,5 +1,5 @@ import { db } from "@/server/db"; -import { redis } from ".."; +import { redis } from "@/server/redis"; import { lobbies, lobbyMembers } from "@/server/db/schema"; import { count, eq } from "drizzle-orm"; import { ee } from "@/server/api/routers/lobby"; diff --git a/src/server/api/routers/lobby.ts b/src/server/api/routers/lobby.ts index 8c645d4..57179f3 100644 --- a/src/server/api/routers/lobby.ts +++ b/src/server/api/routers/lobby.ts @@ -18,7 +18,7 @@ import { decreaseLobbyMemberCount, increaseLobbyMemberCount, handleLobbyAfterLeave, -} from "@/server/redis/utils/lobby-utils"; +} from "@/server/api/mutation-utils/lobby-utils"; type EventMap = Record; class IterableEventEmitter> extends EventEmitter { diff --git a/src/styles/globals.css b/src/styles/globals.css index cbccb46..9d6429d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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); + } } diff --git a/tsconfig.json b/tsconfig.json index 2c2a5bf..9da16f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ /* Path Aliases */ "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@game/*": ["./src/game-engien/*"] } }, "include": [