realtime game configuration

This commit is contained in:
mr-shortman 2025-03-31 20:26:42 +02:00
parent f9ad545e45
commit d1baf097fa
11 changed files with 262 additions and 122 deletions

View File

@ -3,56 +3,120 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { gameConfigPatchSchema } from "@/lib/validations/game"; import { gameConfigPatchSchema } from "@/lib/validations/game";
import { Switch } from "@/components/ui/switch"; 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() { export default function GameConfigForm() {
// ... const updateGameConfig = api.gameConfig.update.useMutation();
const form = useForm<z.infer<typeof gameConfigPatchSchema>>({ const gameConfig = useGameStore((state) => state.gameConfig);
resolver: zodResolver(gameConfigPatchSchema), const lobbyId = useLobbyStore((state) => state.lobby.id);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
allowHints: false, allowHints: gameConfig.allowHints,
scoreMultiplier: 1, timeLimit: gameConfig.timeLimit,
timeLimit: 2,
}, },
}); });
// 2. Define a submit handler. function onSubmit(values: z.infer<typeof formSchema>) {
function onSubmit(values: z.infer<typeof gameConfigPatchSchema>) { updateGameConfig.mutate({
// Do something with the form values. lobbyId,
// ✅ This will be type-safe and validated. config: {
console.log(values); ...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 ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="allowHints" name="allowHints"
render={({ field }) => ( render={({ field }) => (
<FormItem className="container-bg flex flex-row items-center justify-between p-3"> <FormItem className="container-bg bg-background/10 flex flex-row items-center justify-between p-3">
<div className="space-y-0.5"> <FormLabel>Allow hints</FormLabel>
<FormLabel>Allow hints</FormLabel>
</div>
<FormControl> <FormControl>
<Switch <Switch
className="cursor-pointer"
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={(checked) => {
field.onChange(checked);
save();
}}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
{/* <Button type="submit">Submit</Button> */} <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>
</Form> </Form>
); );

View File

@ -7,14 +7,16 @@ import GameConfigForm from "./game-config-form";
import GameVariantSelector from "./game-variant-selector"; import GameVariantSelector from "./game-variant-selector";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { useLobbyStore } from "@/lib/store/lobby-store"; import { useLobbyStore } from "@/lib/store/lobby-store";
import { isCallOrNewExpression } from "typescript";
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
function GameConfigurator() { function GameConfigurator() {
const updateGameConfig = api.gameConfig.update.useMutation(); const updateGameConfig = api.gameConfig.update.useMutation();
const selectGame = useGameStore((state) => state.setSelectedGame); const selectGame = useGameStore((state) => state.setSelectedGame);
const selectedGame = useGameStore((state) => state.selectedGame); const selectedGame = useGameStore((state) => state.selectedGame);
const setGameConfig = useGameStore((state) => state.setGameConfig);
const gameConfig = useGameStore((state) => state.gameConfig); const gameConfig = useGameStore((state) => state.gameConfig);
const lobbyId = useLobbyStore((state) => state.lobby.id); const lobbyId = useLobbyStore((state) => state.lobby.id);
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
const handleUnselectGame = () => { const handleUnselectGame = () => {
selectGame(undefined); selectGame(undefined);
updateGameConfig.mutate({ updateGameConfig.mutate({
@ -26,9 +28,19 @@ function GameConfigurator() {
}); });
}; };
const handleVariantSelect = (gameVariantId?: string) => {
updateGameConfig.mutate({
lobbyId,
config: {
...gameConfig,
gameVariantId,
},
});
};
return ( return (
<div className="flex size-full flex-col justify-between p-6"> <div className="flex size-full flex-col justify-between">
<div className="space-y-2"> <div className="space-y-4">
<div className="flex items-center"> <div className="flex items-center">
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
{selectedGame?.name ?? "Select a game to get started"} {selectedGame?.name ?? "Select a game to get started"}
@ -42,14 +54,14 @@ function GameConfigurator() {
{selectedGame && ( {selectedGame && (
<> <>
<GameVariantSelector /> <GameVariantSelector onSelect={handleVariantSelect} />
<GameConfigForm /> <GameConfigForm />
</> </>
)} )}
</div> </div>
{selectedGame && ( {selectedGame && (
<div className="flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
<Button>Start Game</Button> <Button>Start Game</Button>
<Button <Button
variant={"ghost"} variant={"ghost"}

View File

@ -25,17 +25,38 @@ const getGameVariants = (id: string) => {
return []; return [];
} }
}; };
function GameVariantSelector() { function GameVariantSelector({
const selectedGame = useGameStore((state) => state.selectedGame); onSelect,
const variants = getGameVariants(selectedGame?.id!); }: {
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; if (!variants.length) return null;
React.useEffect(() => {
if (!selectedVariant)
setSelectedVariant({ gameVariantId: variants[0]?.value });
}, [selectedVariant]);
return ( return (
<Combobox <>
className="w-full rounded-2xl" <Combobox
onSelect={() => {}} className="bg-background/10 w-full rounded-2xl"
initialValue={variants[0]?.value} onSelect={(value) => {
data={variants} if (!value?.length) return;
/> onSelect(value);
setSelectedVariant({
gameVariantId: value,
});
}}
initialValue={selectedVariant}
data={variants}
/>
</>
); );
} }

View File

@ -1,5 +1,6 @@
import type { IMinigame } from "@/game-engien"; import type { IMinigame } from "@/game-engien";
import { gameLibary } from "@/game-engien/games"; import { gameLibary } from "@/game-engien/games";
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
import { useGameStore } from "@/lib/store/game-store"; import { useGameStore } from "@/lib/store/game-store";
import { useLobbyStore } from "@/lib/store/lobby-store"; import { useLobbyStore } from "@/lib/store/lobby-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -8,21 +9,23 @@ import Image from "next/image";
import React from "react"; import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
function GameSelector({ isAdmin }: { isAdmin: boolean }) { function GameSelector() {
const updateGameConfig = api.gameConfig.update.useMutation(); const updateGameConfig = api.gameConfig.update.useMutation();
const setGameConfig = useGameStore((state) => state.updateGameConfig);
const selectedGame = useGameStore((state) => state.selectedGame); const selectedGame = useGameStore((state) => state.selectedGame);
const setSelectedGame = useGameStore((state) => state.setSelectedGame); const setSelectedGame = useGameStore((state) => state.setSelectedGame);
const gameConfig = useGameStore((state) => state.gameConfig); const gameConfig = useGameStore((state) => state.gameConfig);
const lobbyId = useLobbyStore((state) => state.lobby.id); const lobbyId = useLobbyStore((state) => state.lobby.id);
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
const handleGameSelect = (game: IMinigame) => { const handleGameSelect = (game: IMinigame) => {
if (!isAdmin) return toast.error("Only admins can change games"); if (!isLobbyAdmin) return toast.error("Only admins can change games");
setSelectedGame(game); setSelectedGame(game);
const newConfig = { ...gameConfig, gameId: game.id };
setGameConfig(newConfig);
updateGameConfig.mutate({ updateGameConfig.mutate({
lobbyId, lobbyId,
config: { config: newConfig,
...gameConfig,
gameId: game.id,
},
}); });
}; };
return ( return (

View File

@ -24,85 +24,87 @@ function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
const isJoined = sessionPlayer const isJoined = sessionPlayer
? members.find((m) => m.playerId === sessionPlayer.id) ? members.find((m) => m.playerId === sessionPlayer.id)
: null; : null;
const isAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
const isAdmin = isJoined?.role === "admin";
const gameConfig = useGameStore((state) => state.gameConfig); const gameConfig = useGameStore((state) => state.gameConfig);
return ( return (
<LobbyProvider initialLobby={initialLobby}> <div className="size-full h-screen pb-40">
<div className="grid w-full max-w-4xl grid-cols-3 grid-rows-2 gap-4"> <LobbyProvider initialLobby={initialLobby}>
<div className="container-bg col-span-2 w-full space-y-4 p-6"> <div className="mx-auto grid size-full max-w-4xl grid-cols-5 grid-rows-4 gap-4">
<div className="flex items-center gap-2"> <div className="container-bg col-span-3 row-span-3 size-full space-y-4 overflow-y-auto p-6">
<CopyToClip <div className="flex items-center gap-2">
target="invite link" <CopyToClip
text={getBaseUrl() + appRoutes.lobby(lobby.id)} target="invite link"
buttonProps={{ variant: "outline" }} text={getBaseUrl() + appRoutes.lobby(lobby.id)}
> buttonProps={{ variant: "outline" }}
{isJoined ? ( >
<> {isJoined ? (
<UserPlus className="size-4" /> <>
<span>Invite Players</span> <UserPlus className="size-4" />
</> <span>Invite Players</span>
) : ( </>
<> ) : (
<Share2 className="size-4" /> <>
<span>Share Lobby</span> <Share2 className="size-4" />
</> <span>Share Lobby</span>
)} </>
</CopyToClip> )}
</CopyToClip>
<div className="ml-auto"> <div className="ml-auto">
{sessionPlayer && ( {sessionPlayer && (
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} /> <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> </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"> <ul className="space-y-2">
{members?.map((member, idx) => ( {members?.map((member, idx) => (
<li key={idx}> <li key={idx}>
<LobbyPlayerCard <LobbyPlayerCard
lobbyId={lobby.id} lobbyId={lobby.id}
highlight={member?.player?.id === sessionPlayer?.id} highlight={member?.player?.id === sessionPlayer?.id}
player={member?.player!} player={member?.player!}
className="relative" className="relative"
showOptions={ showOptions={
isAdmin && member?.playerId !== sessionPlayer?.id isAdmin && member?.playerId !== sessionPlayer?.id
} }
> >
{member?.role === "admin" && ( {member?.role === "admin" && (
<Badge className="absolute -top-2 right-2 z-20 p-px px-2 text-white"> <Badge className="absolute -top-2 right-2 z-20 p-px px-2 text-white">
Admin Admin
</Badge> </Badge>
)} )}
</LobbyPlayerCard> </LobbyPlayerCard>
</li> </li>
))} ))}
</ul> </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>
<div className="container-bg col-span-1 size-full"> </LobbyProvider>
<GameConfigurator /> </div>
</div>
<div className="container-bg col-span-3 h-max w-full p-4">
<GameSelector isAdmin={isAdmin} />
</div>
</div>
</LobbyProvider>
); );
} }

View File

@ -3,6 +3,7 @@ import { getGame } from "@/game-engien/games";
import { useSessionPlayerStore } from "@/lib/store/current-player-store"; import { useSessionPlayerStore } from "@/lib/store/current-player-store";
import { useGameStore } from "@/lib/store/game-store"; import { useGameStore } from "@/lib/store/game-store";
import { useLobbyStore } from "@/lib/store/lobby-store"; import { useLobbyStore } from "@/lib/store/lobby-store";
import type { GameConfig } from "@/lib/validations/game";
import type { Lobby, LobbyMember } from "@/server/db/schema"; import type { Lobby, LobbyMember } from "@/server/db/schema";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -23,14 +24,16 @@ function LobbyProvider({
const setMembers = useLobbyStore((state) => state.setMembers); const setMembers = useLobbyStore((state) => state.setMembers);
const addMember = useLobbyStore((state) => state.addMember); const addMember = useLobbyStore((state) => state.addMember);
const removeMember = useLobbyStore((state) => state.removeMember); const removeMember = useLobbyStore((state) => state.removeMember);
const setGameConfig = useGameStore((state) => state.updateGameConfig);
const setGameConfig = useGameStore((state) => state.setGameConfig);
const setSelectedGame = useGameStore((state) => state.setSelectedGame); const setSelectedGame = useGameStore((state) => state.setSelectedGame);
const setIsLobbyAdmin = useSessionPlayerStore(
(state) => state.setIsLobbyAdmin,
);
const selectedGame = useGameStore((state) => state.selectedGame); const selectedGame = useGameStore((state) => state.selectedGame);
const router = useRouter(); const router = useRouter();
const lobbyId = lobby.id ?? ""; const lobbyId = lobby.id ?? "";
api.lobby.onMemberUpdate.useSubscription( api.lobby.onMemberUpdate.useSubscription(
{ lobbyId }, { lobbyId },
{ {
@ -71,6 +74,8 @@ function LobbyProvider({
{ {
onData({ data: config }) { onData({ data: config }) {
if (config) { if (config) {
console.log("Game Config Update", config);
setGameConfig(config); setGameConfig(config);
if (config.gameId !== selectedGame?.id) { if (config.gameId !== selectedGame?.id) {
const game = getGame(config.gameId!); const game = getGame(config.gameId!);
@ -80,11 +85,17 @@ function LobbyProvider({
}, },
}, },
); );
React.useEffect(() => {
const isAdmin =
initialLobby?.members?.find((m) => m.playerId === sessionPlayer?.id)
?.role === "admin";
setIsLobbyAdmin(isAdmin);
}, [initialLobby, sessionPlayer, setIsLobbyAdmin]);
React.useEffect(() => { React.useEffect(() => {
const setConfigWithGame = () => { const setConfigWithGame = () => {
const initialConfig = initialLobby?.gameConfig?.config; const initialConfig = initialLobby?.gameConfig?.config;
setGameConfig(initialConfig ?? {}); setGameConfig(initialConfig ?? ({} as GameConfig));
const game = initialConfig?.gameId?.length const game = initialConfig?.gameId?.length
? getGame(initialConfig?.gameId!) ? getGame(initialConfig?.gameId!)
: undefined; : undefined;
@ -93,7 +104,7 @@ function LobbyProvider({
if (initialLobby?.gameConfig?.config) setConfigWithGame(); if (initialLobby?.gameConfig?.config) setConfigWithGame();
resetLobby(initialLobby); resetLobby(initialLobby);
setMembers(initialLobby?.members ?? []); setMembers(initialLobby?.members ?? []);
}, [initialLobby]); }, [initialLobby, setGameConfig, setSelectedGame, getGame]);
return children; return children;
} }

View File

@ -1,12 +1,17 @@
"use client";
import { create } from "zustand"; import { create } from "zustand";
import type { Player } from "@/server/db/schema"; import type { Player } from "@/server/db/schema";
type SessionPlayerStore = { type SessionPlayerStore = {
sessionPlayer?: Player | null; sessionPlayer?: Player | null;
setSessionPlayer: (player?: Player | null) => void; setSessionPlayer: (player?: Player | null) => void;
isLobbyAdmin: boolean;
setIsLobbyAdmin: (isAdmin: boolean) => void;
}; };
export const useSessionPlayerStore = create<SessionPlayerStore>((set) => ({ export const useSessionPlayerStore = create<SessionPlayerStore>((set) => ({
sessionPlayer: undefined, sessionPlayer: undefined,
setSessionPlayer: (player) => set({ sessionPlayer: player }), setSessionPlayer: (player) => set({ sessionPlayer: player }),
isLobbyAdmin: false,
setIsLobbyAdmin: (isAdmin) => set({ isLobbyAdmin: isAdmin }),
})); }));

View File

@ -1,3 +1,4 @@
"use client";
import { create } from "zustand"; import { create } from "zustand";
import type { IMinigame } from "@/game-engien"; import type { IMinigame } from "@/game-engien";
import type { GameConfig } from "../validations/game"; import type { GameConfig } from "../validations/game";
@ -7,11 +8,14 @@ type GameStore = {
setSelectedGame: (game?: IMinigame) => void; setSelectedGame: (game?: IMinigame) => void;
gameConfig: GameConfig; gameConfig: GameConfig;
setGameConfig: (config: GameConfig) => void; setGameConfig: (config: GameConfig) => void;
updateGameConfig: (config: Partial<GameConfig>) => void;
}; };
export const useGameStore = create<GameStore>((set) => ({ export const useGameStore = create<GameStore>((set, get) => ({
selectedGame: undefined, selectedGame: undefined,
setSelectedGame: (game) => set({ selectedGame: game }), setSelectedGame: (game) => set({ selectedGame: game }),
gameConfig: {} as GameConfig, gameConfig: {} as GameConfig,
setGameConfig: (config) => set({ gameConfig: config }), setGameConfig: (config) => set({ gameConfig: config }),
updateGameConfig: (config) =>
set({ gameConfig: { ...get().gameConfig, ...config } }),
})); }));

View File

@ -1,3 +1,5 @@
"use client";
import { create } from "zustand"; import { create } from "zustand";
import type { Lobby, LobbyMember } from "@/server/db/schema"; import type { Lobby, LobbyMember } from "@/server/db/schema";

View File

@ -14,8 +14,23 @@ export const formatDate = (date: Date) =>
minute: "numeric", minute: "numeric",
}); });
export function getBaseUrl() { export const getBaseUrl = () => {
if (typeof window !== "undefined") return window.location.origin; if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`; 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;
};
};

View File

@ -2,7 +2,8 @@ import { z } from "zod";
export const gameConfigPatchSchema = z.object({ export const gameConfigPatchSchema = z.object({
gameId: z.string().optional(), gameId: z.string().optional(),
timeLimit: z.number().default(2).optional(), gameVariantId: z.string().optional(),
timeLimit: z.number().min(2).max(120).default(2),
scoreMultiplier: z.number().optional().optional(), scoreMultiplier: z.number().optional().optional(),
allowHints: z.boolean().default(false).optional(), allowHints: z.boolean().default(false).optional(),
}); });