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 { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { gameConfigPatchSchema } from "@/lib/validations/game";
import { Switch } from "@/components/ui/switch";
import { useGameStore } from "@/lib/store/game-store";
import { api } from "@/trpc/react";
import { useLobbyStore } from "@/lib/store/lobby-store";
import { Slider } from "@/components/ui/slider";
import { getMinMax } from "@/lib/validations/utils";
import { debounce } from "@/lib/utils";
import React from "react";
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
const formSchema = gameConfigPatchSchema.pick({
allowHints: true,
// scoreMultiplier: true,
timeLimit: true,
});
export default function GameConfigForm() {
// ...
const form = useForm<z.infer<typeof gameConfigPatchSchema>>({
resolver: zodResolver(gameConfigPatchSchema),
const updateGameConfig = api.gameConfig.update.useMutation();
const gameConfig = useGameStore((state) => state.gameConfig);
const lobbyId = useLobbyStore((state) => state.lobby.id);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
allowHints: false,
scoreMultiplier: 1,
timeLimit: 2,
allowHints: gameConfig.allowHints,
timeLimit: gameConfig.timeLimit,
},
});
// 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);
function onSubmit(values: z.infer<typeof formSchema>) {
updateGameConfig.mutate({
lobbyId,
config: {
...gameConfig,
...values,
},
});
}
const save = form.handleSubmit(onSubmit);
React.useEffect(() => {
form.reset({
allowHints: gameConfig.allowHints,
timeLimit: gameConfig.timeLimit,
});
}, [gameConfig]);
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<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>
<FormItem className="container-bg bg-background/10 flex flex-row items-center justify-between p-3">
<FormLabel>Allow hints</FormLabel>
<FormControl>
<Switch
className="cursor-pointer"
checked={field.value}
onCheckedChange={field.onChange}
onCheckedChange={(checked) => {
field.onChange(checked);
save();
}}
/>
</FormControl>
</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>
);

View File

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

View File

@ -25,17 +25,38 @@ const getGameVariants = (id: string) => {
return [];
}
};
function GameVariantSelector() {
const selectedGame = useGameStore((state) => state.selectedGame);
const variants = getGameVariants(selectedGame?.id!);
function GameVariantSelector({
onSelect,
}: {
onSelect: (gameVariantId?: string) => void;
}) {
const gameId = useGameStore((state) => state.gameConfig.gameId);
const selectedVariant = useGameStore(
(state) => state.gameConfig.gameVariantId,
);
const setSelectedVariant = useGameStore((state) => state.updateGameConfig);
const variants = getGameVariants(gameId!);
if (!variants.length) return null;
React.useEffect(() => {
if (!selectedVariant)
setSelectedVariant({ gameVariantId: variants[0]?.value });
}, [selectedVariant]);
return (
<Combobox
className="w-full rounded-2xl"
onSelect={() => {}}
initialValue={variants[0]?.value}
data={variants}
/>
<>
<Combobox
className="bg-background/10 w-full rounded-2xl"
onSelect={(value) => {
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 { gameLibary } from "@/game-engien/games";
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
import { useGameStore } from "@/lib/store/game-store";
import { useLobbyStore } from "@/lib/store/lobby-store";
import { cn } from "@/lib/utils";
@ -8,21 +9,23 @@ import Image from "next/image";
import React from "react";
import { toast } from "sonner";
function GameSelector({ isAdmin }: { isAdmin: boolean }) {
function GameSelector() {
const updateGameConfig = api.gameConfig.update.useMutation();
const setGameConfig = useGameStore((state) => state.updateGameConfig);
const selectedGame = useGameStore((state) => state.selectedGame);
const setSelectedGame = useGameStore((state) => state.setSelectedGame);
const gameConfig = useGameStore((state) => state.gameConfig);
const lobbyId = useLobbyStore((state) => state.lobby.id);
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
const handleGameSelect = (game: IMinigame) => {
if (!isAdmin) return toast.error("Only admins can change games");
if (!isLobbyAdmin) return toast.error("Only admins can change games");
setSelectedGame(game);
const newConfig = { ...gameConfig, gameId: game.id };
setGameConfig(newConfig);
updateGameConfig.mutate({
lobbyId,
config: {
...gameConfig,
gameId: game.id,
},
config: newConfig,
});
};
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,8 +14,23 @@ export const formatDate = (date: Date) =>
minute: "numeric",
});
export function getBaseUrl() {
export const getBaseUrl = () => {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
};
export const debounce = <T extends (...args: any[]) => any>(
callback: T,
waitFor: number,
) => {
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>): ReturnType<T> => {
let result: any;
timeout && clearTimeout(timeout);
timeout = setTimeout(() => {
result = callback(...args);
}, waitFor);
return result;
};
};

View File

@ -2,7 +2,8 @@ import { z } from "zod";
export const gameConfigPatchSchema = z.object({
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(),
allowHints: z.boolean().default(false).optional(),
});