added reveal and create game

This commit is contained in:
MasterGordon 2024-09-28 03:17:36 +02:00
parent a268ab6878
commit 825448c8f3
47 changed files with 708 additions and 90 deletions

View File

@ -1,6 +1,10 @@
import { z } from "zod";
import { createController, createEndpoint } from "./controller";
import { getGame, upsertGameState } from "../repositories/gameRepository";
import {
getCurrentGame,
getGame,
upsertGameState,
} from "../repositories/gameRepository";
import {
serverGame,
serverToClientGame,
@ -42,4 +46,18 @@ export const gameController = createController({
});
return newGame;
}),
reveal: createEndpoint(
z.object({ x: z.number(), y: z.number() }),
async ({ x, y }, { db, user }) => {
if (!user) throw new UnauthorizedError("Unauthorized");
const dbGame = await getCurrentGame(db, user);
const serverGame = JSON.parse(dbGame.gameState);
game.reveal(serverGame, x, y);
upsertGameState(db, serverGame);
emit({
type: "updateGame",
game: dbGame.uuid,
});
},
),
});

View File

@ -12,7 +12,7 @@ const signString = (payload: string) => {
export const userController = createController({
getSelf: createEndpoint(z.null(), async (_, { user }) => {
return user;
return user || null;
}),
login: createEndpoint(
z.object({ username: z.string(), password: z.string() }),
@ -47,7 +47,13 @@ export const userController = createController({
resetSessionUser(ws);
}),
register: createEndpoint(
z.object({ username: z.string().max(15), password: z.string().min(6) }),
z.object({
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(15, "Username cannot be longer than 15 characters"),
password: z.string().min(6, "Password must be at least 6 characters"),
}),
async (input, { db, ws }) => {
await registerUser(db, input.username, input.password);
const user = input.username;

View File

@ -1,3 +1,4 @@
import { getValue } from "../../shared/game";
import type { ServerGame } from "../../shared/game";
interface CreateGameOptions {
@ -8,6 +9,26 @@ interface CreateGameOptions {
mines: number;
}
const isValid = (game: ServerGame, x: number, y: number) => {
const { width, height } = game;
return x >= 0 && x < width && y >= 0 && y < height;
};
const getNeighborFlagCount = (game: ServerGame, x: number, y: number) => {
const { isFlagged } = game;
const neighbors = [
isFlagged[x - 1]?.[y - 1],
isFlagged[x]?.[y - 1],
isFlagged[x + 1]?.[y - 1],
isFlagged[x - 1]?.[y],
isFlagged[x + 1]?.[y],
isFlagged[x - 1]?.[y + 1],
isFlagged[x]?.[y + 1],
isFlagged[x + 1]?.[y + 1],
];
return neighbors.filter((n) => n).length;
};
export const game = {
createGame: (options: CreateGameOptions): ServerGame => {
const { uuid, user, width, height, mines } = options;
@ -24,6 +45,9 @@ export const game = {
const isFlaggedArray = Array.from({ length: width }, () =>
new Array(height).fill(false),
);
const isQuestionMarkArray = Array.from({ length: width }, () =>
new Array(height).fill(false),
);
let remainingMines = mines;
while (remainingMines > 0) {
@ -45,9 +69,57 @@ export const game = {
mines: minesArray,
isRevealed: isRevealedArray,
isFlagged: isFlaggedArray,
isQuestionMark: isQuestionMarkArray,
stage: 1,
lastClick: [-1, -1],
minesCount: mines,
};
},
reveal: (serverGame: ServerGame, x: number, y: number) => {
const { mines, isRevealed, isFlagged, isQuestionMark, finished } =
serverGame;
if (finished) return;
if (isQuestionMark[x][y]) return;
if (isFlagged[x][y]) return;
if (!isValid(serverGame, x, y)) return;
serverGame.lastClick = [x, y];
if (mines[x][y]) {
serverGame.finished = Date.now();
return;
}
const value = getValue(serverGame.mines, x, y);
const neighborFlagCount = getNeighborFlagCount(serverGame, x, y);
if (isRevealed[x][y] && value === neighborFlagCount) {
if (!isFlagged[x - 1]?.[y]) game.reveal(serverGame, x - 1, y);
if (!isFlagged[x - 1]?.[y - 1]) game.reveal(serverGame, x - 1, y - 1);
if (!isFlagged[x - 1]?.[y + 1]) game.reveal(serverGame, x - 1, y + 1);
if (!isFlagged[x]?.[y - 1]) game.reveal(serverGame, x, y - 1);
if (!isFlagged[x]?.[y + 1]) game.reveal(serverGame, x, y + 1);
if (!isFlagged[x + 1]?.[y - 1]) game.reveal(serverGame, x + 1, y - 1);
if (!isFlagged[x + 1]?.[y]) game.reveal(serverGame, x + 1, y);
if (!isFlagged[x + 1]?.[y + 1]) game.reveal(serverGame, x + 1, y + 1);
}
serverGame.isRevealed[x][y] = true;
if (value === 0 && neighborFlagCount === 0) {
const revealNeighbors = (nx: number, ny: number) => {
if (isValid(serverGame, nx, ny) && !isRevealed[nx]?.[ny]) {
game.reveal(serverGame, nx, ny);
}
};
revealNeighbors(x - 1, y - 1);
revealNeighbors(x, y - 1);
revealNeighbors(x + 1, y - 1);
revealNeighbors(x - 1, y);
revealNeighbors(x + 1, y);
revealNeighbors(x - 1, y + 1);
revealNeighbors(x, y + 1);
revealNeighbors(x + 1, y + 1);
}
},
};

View File

@ -1,28 +1,4 @@
import type { ClientGame } from "../shared/game";
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
type Events =
| {
type: "new";
user: string;
}
| {
type: "loss";
user: string;
stage: number;
}
| {
type: "updateGame";
game: string;
data: ClientGame;
}
| {
type: "updateStage";
game: string;
stage: number;
started: number;
};
import type { Events } from "../shared/events";
const listeners = new Set<(event: Events) => void>();

View File

@ -1,3 +1,4 @@
import { on } from "./events";
import { handleRequest } from "./router";
const allowCors = {
@ -36,5 +37,8 @@ const server = Bun.serve({
},
port: 8076,
});
on((event) => {
server.publish("minesweeper-global", JSON.stringify(event));
});
console.log("Listening on port 8076");

View File

@ -4,6 +4,7 @@ import type { Controller, Endpoint } from "./controller/controller";
import { gameController } from "./controller/gameController";
import { db } from "./database/db";
import { userController } from "./controller/userController";
import { ZodError } from "zod";
const controllers = {
game: gameController,
@ -24,8 +25,7 @@ export const handleRequest = async (
message: unknown,
ws: ServerWebSocket<unknown>,
) => {
// TODO: Remove this
const sessionUser = userName.get(ws) || "Gordon";
const sessionUser = userName.get(ws) || undefined;
const ctx = {
user: sessionUser,
db,
@ -50,8 +50,17 @@ export const handleRequest = async (
const result = await endpoint.handler(input, ctx);
ws.send(JSON.stringify({ id, payload: result }));
return;
} catch (_) {
ws.send(JSON.stringify({ id, error: "Bad Request" }));
} catch (e) {
if (e instanceof ZodError) {
ws.send(
JSON.stringify({ id, error: e.issues[0].message, type: message.type }),
);
} else if (e instanceof Error) {
ws.send(JSON.stringify({ id, error: e.message, type: message.type }));
} else {
ws.send(JSON.stringify({ id, error: "Bad Request", type: message.type }));
}
console.error(e);
}
};

BIN
bun.lockb

Binary file not shown.

View File

@ -11,9 +11,12 @@
"lint": "eslint .",
"preview": "vite preview",
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
"drizzle:migrate": "bun run backend/migrate.ts"
"drizzle:migrate": "bun run backend/migrate.ts",
"nukedb": "rm sqlite.db && bun run backend/migrate.ts"
},
"dependencies": {
"@pixi/events": "^7.4.2",
"@pixi/react": "^7.1.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-popover": "^1.1.1",
@ -27,6 +30,8 @@
"framer-motion": "^11.5.6",
"jotai": "^2.10.0",
"lucide-react": "^0.441.0",
"pixi-viewport": "^5.0.3",
"pixi.js": "^7.4.2",
"react": "^18.3.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "^18.3.1",

22
shared/events.ts Normal file
View File

@ -0,0 +1,22 @@
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
export type Events =
| {
type: "new";
user: string;
}
| {
type: "loss";
user: string;
stage: number;
}
| {
type: "updateGame";
game: string;
}
| {
type: "updateStage";
game: string;
stage: number;
started: number;
};

View File

@ -7,6 +7,7 @@ export const clientGame = z.object({
height: z.number(),
isRevealed: z.array(z.array(z.boolean())),
isFlagged: z.array(z.array(z.boolean())),
isQuestionMark: z.array(z.array(z.boolean())),
values: z.array(z.array(z.number())),
minesCount: z.number(),
lastClick: z.tuple([z.number(), z.number()]),
@ -21,6 +22,7 @@ export const serverGame = z.object({
height: z.number(),
isRevealed: z.array(z.array(z.boolean())),
isFlagged: z.array(z.array(z.boolean())),
isQuestionMark: z.array(z.array(z.boolean())),
mines: z.array(z.array(z.boolean())),
minesCount: z.number(),
lastClick: z.tuple([z.number(), z.number()]),
@ -32,6 +34,11 @@ export const serverGame = z.object({
export type ClientGame = z.infer<typeof clientGame>;
export type ServerGame = z.infer<typeof serverGame>;
export const isServerGame = (game: ServerGame | ClientGame) => "mines" in game;
export const isClientGame = (
game: ServerGame | ClientGame,
): game is ClientGame => !("mines" in game);
export const getValue = (mines: boolean[][], x: number, y: number) => {
const neighbors = [
mines[x - 1]?.[y - 1],
@ -54,6 +61,7 @@ export const serverToClientGame = (game: ServerGame): ClientGame => {
height: game.height,
isRevealed: game.isRevealed,
isFlagged: game.isFlagged,
isQuestionMark: game.isQuestionMark,
minesCount: game.minesCount,
values: game.mines.map((_, i) =>
game.mines[0].map((_, j) => {

BIN
sqlite.db

Binary file not shown.

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { PropsWithChildren, useEffect, useRef, useState } from "react";
import { Button } from "./components/Button";
import { motion } from "framer-motion";
import {
@ -17,8 +17,9 @@ import Header from "./components/Header";
const drawerWidth = 256;
const drawerWidthWithPadding = drawerWidth;
const Shell: React.FC = () => {
const Shell: React.FC<PropsWithChildren> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const drawerRef = useRef<HTMLDivElement>(null);
const x = isOpen ? 0 : -drawerWidthWithPadding;
const width = isOpen ? drawerWidthWithPadding : 0;
@ -26,11 +27,29 @@ const Shell: React.FC = () => {
useEffect(() => {
setIsOpen(!isMobile);
}, [isMobile]);
useEffect(() => {
const onOutsideClick = (e: MouseEvent) => {
if (
drawerRef.current &&
!drawerRef.current.contains(e.target as Node) &&
isMobile
) {
setIsOpen(false);
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener("click", onOutsideClick);
return () => {
document.removeEventListener("click", onOutsideClick);
};
});
return (
<div className="relative bg-black min-h-screen">
<div className="bg-black min-h-screen">
<motion.div
className="bg-black p-4 absolute h-screen w-64 flex border-white/10 border-1"
ref={drawerRef}
animate={{ x }}
transition={{ type: "tween" }}
>
@ -74,17 +93,19 @@ const Shell: React.FC = () => {
</Button>
</div>
</motion.div>
<motion.div className="flex">
<motion.div className="flex max-w-[100vw]">
<motion.div
className="hidden md:block"
animate={{ width: width }}
transition={{ type: "tween" }}
layout
/>
<motion.div className="flex flex-col gap-4 grow max-w-6xl mx-auto">
<motion.div className="flex flex-col gap-4 grow max-w-6xl mx-auto w-[calc(100vw-256px)]">
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
<Header />
<div className="bg-gray-950 p-4 rounded-lg w-full"></div>
<div className="bg-gray-950 p-4 rounded-lg w-full"></div>
{children}
{/* <div className="bg-gray-950 p-4 rounded-lg w-full"></div> */}
{/* <div className="bg-gray-950 p-4 rounded-lg w-full"></div> */}
</div>
</motion.div>
</motion.div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 B

View File

@ -1,8 +1,8 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const gameId = atom<string | undefined>(undefined);
export const loginToken = atomWithStorage<string | undefined>(
export const gameIdAtom = atom<string | undefined>(undefined);
export const loginTokenAtom = atomWithStorage<string | undefined>(
"loginToken",
undefined,
);

View File

@ -8,11 +8,20 @@ import {
DialogTitle,
DialogTrigger,
} from "../Dialog";
import { useQueryClient } from "@tanstack/react-query";
import { useWSMutation } from "../../hooks";
import { useAtom } from "jotai";
import { loginTokenAtom } from "../../atoms";
import PasswordInput from "./PasswordInput";
const LoginButton = () => {
const [isOpen, setIsOpen] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const queryClient = useQueryClient();
const login = useWSMutation("user.login");
const [, setToken] = useAtom(loginTokenAtom);
useEffect(() => {
setUsername("");
@ -36,18 +45,29 @@ const LoginButton = () => {
onChange={(e) => setUsername(e.target.value)}
/>
<label className="text-white/70 font-bold">Password</label>
<input
className="border-white/10 border-2 rounded-md p-2"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<PasswordInput value={password} onChange={setPassword} />
</div>
{error && <p className="text-red-500">{error}</p>}
<DialogFooter>
<Button variant="ghost" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button variant="primary">Login</Button>
<Button
variant="primary"
onClick={() => {
login
.mutateAsync({ username, password })
.then((res) => {
setToken(res.token);
queryClient.invalidateQueries();
})
.catch((e) => {
setError(e);
});
}}
>
Login
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -0,0 +1,32 @@
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
interface PasswordInputProps {
value: string;
onChange: (value: string) => void;
}
const PasswordInput = ({ value, onChange }: PasswordInputProps) => {
const [show, setShow] = useState(false);
return (
<div className="relative">
<input
type={show ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full p-2 border-white/10 border-1 rounded-md"
/>
<div className="absolute right-2 top-2">
<button
className="bg-transparent text-white/70 hover:bg-white/05"
onClick={() => setShow(!show)}
tabIndex={-1}
>
{show ? <EyeOff /> : <Eye />}
</button>
</div>
</div>
);
};
export default PasswordInput;

View File

@ -8,11 +8,20 @@ import {
DialogTitle,
DialogTrigger,
} from "../Dialog";
import { useWSMutation } from "../../hooks";
import { useAtom } from "jotai";
import { loginTokenAtom } from "../../atoms";
import { useQueryClient } from "@tanstack/react-query";
import PasswordInput from "./PasswordInput";
const RegisterButton = () => {
const [isOpen, setIsOpen] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const queryClient = useQueryClient();
const register = useWSMutation("user.register");
const [, setToken] = useAtom(loginTokenAtom);
useEffect(() => {
setUsername("");
@ -36,18 +45,29 @@ const RegisterButton = () => {
onChange={(e) => setUsername(e.target.value)}
/>
<label className="text-white/70 font-bold">Password</label>
<input
className="border-white/10 border-2 rounded-md p-2"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<PasswordInput value={password} onChange={setPassword} />
</div>
{error && <p className="text-red-500">{error}</p>}
<DialogFooter>
<Button variant="ghost" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button variant="primary">Register</Button>
<Button
variant="primary"
onClick={() => {
register
.mutateAsync({ username, password })
.then((res) => {
setToken(res.token);
queryClient.invalidateQueries();
})
.catch((e) => {
setError(e);
});
}}
>
Register
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

185
src/components/Board.tsx Normal file
View File

@ -0,0 +1,185 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { LoadedTheme, Theme, useTheme } from "../themes/Theme";
import { Container, Sprite, Stage } from "@pixi/react";
import Viewport from "./pixi/PixiViewport";
import {
ClientGame,
getValue,
isServerGame,
ServerGame,
} from "../../shared/game";
import { useWSQuery } from "../hooks";
interface BoardProps {
theme: Theme;
game: ServerGame | ClientGame;
onLeftClick: (x: number, y: number) => void;
onRightClick: (x: number, y: number) => void;
}
const Board: React.FC<BoardProps> = (props) => {
const { game } = props;
const { data: user } = useWSQuery("user.getSelf", null);
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const showLastPos = game.user !== user || isServerGame(game);
useEffect(() => {
if (!ref.current) return;
setWidth(ref.current.clientWidth);
setHeight(ref.current.clientHeight);
const resizeObserver = new ResizeObserver(() => {
if (ref.current) {
setWidth(ref.current.clientWidth);
setHeight(ref.current.clientHeight);
}
});
resizeObserver.observe(ref.current);
return () => resizeObserver.disconnect();
}, []);
const theme = useTheme(props.theme);
const boardWidth = game.width * (theme?.size || 0);
const boardHeight = game.height * (theme?.size || 0);
return (
<div
className="w-full h-[70vh] overflow-hidden border-red-500 border-2 flex select-none"
ref={ref}
>
{theme && (
<Stage
options={{ hello: true, antialias: false }}
width={width}
height={height}
>
<Viewport
worldWidth={boardWidth}
worldHeight={boardHeight}
width={width}
height={height}
clamp={{
left: -theme.size,
right: boardWidth + theme.size,
top: -theme.size,
bottom: boardHeight + theme.size,
}}
>
{game.isRevealed.map((_, i) => {
return game.isRevealed[0].map((_, j) => {
return (
<Tile
key={`${i},${j}`}
x={i}
y={j}
game={game}
theme={theme}
showLastPos={showLastPos}
onLeftClick={props.onLeftClick}
onRightClick={props.onRightClick}
/>
);
});
})}
</Viewport>
</Stage>
)}
</div>
);
};
interface TileProps {
x: number;
y: number;
game: ServerGame | ClientGame;
theme: LoadedTheme;
showLastPos: boolean;
onLeftClick: (x: number, y: number) => void;
onRightClick: (x: number, y: number) => void;
}
const Tile = ({
game,
x,
y,
theme,
showLastPos,
onRightClick,
onLeftClick,
}: TileProps) => {
const i = x;
const j = y;
const isRevealed = game.isRevealed[i][j];
const value = isServerGame(game)
? getValue(game.mines, i, j)
: game.values[i][j];
const isMine = isServerGame(game) ? game.mines[i][j] : false;
const isLastPos = showLastPos
? game.lastClick[0] === i && game.lastClick[1] === j
: false;
const isFlagged = game.isFlagged[i][j];
const isQuestionMark = game.isQuestionMark[i][j];
const base = isRevealed ? (
<Sprite image={theme.revealed} />
) : (
<Sprite image={theme.tile} />
);
let content: ReactNode = null;
if (isMine) {
content = <Sprite image={theme.mine} />;
} else if (value !== -1 && isRevealed) {
const img = theme[value.toString() as keyof Theme] as string;
content = img ? <Sprite image={img} /> : null;
} else if (isFlagged) {
content = <Sprite image={theme.flag} />;
} else if (isQuestionMark) {
content = <Sprite image={theme.questionMark} />;
}
const extra = isLastPos ? <Sprite image={theme.lastPos} /> : null;
const touchStart = useRef<number>(0);
const isMove = useRef<boolean>(false);
const startX = useRef<number>(0);
const startY = useRef<number>(0);
return (
<Container
eventMode="static"
interactive
x={i * theme.size}
y={j * theme.size}
key={`${i},${j}`}
onrightup={() => {
onRightClick(i, j);
}}
onpointerup={(e) => {
if (e.button !== 0) return;
if (isMove.current) return;
if (Date.now() - touchStart.current > 300) {
onRightClick(i, j);
} else {
onLeftClick(i, j);
}
}}
onpointerdown={(e) => {
isMove.current = false;
touchStart.current = Date.now();
startX.current = e.global.x;
startY.current = e.global.y;
}}
onpointermove={(e) => {
if (
Math.abs(startX.current - e.global.x) > 10 ||
Math.abs(startY.current - e.global.y) > 10
) {
isMove.current = true;
}
}}
>
{base}
{content}
{extra}
</Container>
);
};
export default Board;

View File

@ -35,7 +35,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg border-white/20 text-white/90",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg border-white/20 text-white/90 bg-black",
className,
)}
{...props}

View File

@ -9,23 +9,25 @@ import {
} from "./DropdownMenu";
import { useLocation } from "wouter";
import LoginButton from "./Auth/LoginButton";
import { useWSQuery } from "../hooks";
import { useWSMutation, useWSQuery } from "../hooks";
import RegisterButton from "./Auth/RegisterButton";
import banner from "../images/banner.png";
import mine from "../images/mine.png";
import { useQueryClient } from "@tanstack/react-query";
const Header = () => {
const [, setLocation] = useLocation();
const { data: username } = useWSQuery("user.getSelf", null);
const queryClient = useQueryClient();
const logout = useWSMutation("user.logout", () => {
queryClient.invalidateQueries();
});
return (
<div className="w-full flex gap-4">
<div className="grow" />
<img src={banner} className="w-auto h-16" />
<img
src={mine}
className="w-auto h-16 drop-shadow-[0px_0px_10px_#fff] -rotate-12"
/>
<img src={banner} className="w-auto h-16 hidden sm:block" />
<div className="grow" />
{username ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -42,7 +44,9 @@ const Header = () => {
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Logout</DropdownMenuItem>
<DropdownMenuItem onClick={() => logout.mutate(null)}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (

View File

@ -0,0 +1,73 @@
import React from "react";
import * as PIXI from "pixi.js";
import { Viewport as PixiViewport } from "pixi-viewport";
import { PixiComponent, useApp } from "@pixi/react";
import { BaseTexture, SCALE_MODES } from "pixi.js";
BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST;
export interface ViewportProps {
width: number;
height: number;
worldWidth: number;
worldHeight: number;
children?: React.ReactNode;
clamp?: {
left: number;
right: number;
top: number;
bottom: number;
};
}
export interface PixiComponentViewportProps extends ViewportProps {
app: PIXI.Application;
}
const PixiComponentViewport = PixiComponent("Viewport", {
create: (props: PixiComponentViewportProps) => {
const viewport = new PixiViewport({
screenWidth: props.width,
screenHeight: props.height,
worldWidth: props.worldWidth,
worldHeight: props.worldHeight,
ticker: props.app.ticker,
events: props.app.renderer.events,
disableOnContextMenu: true,
allowPreserveDragOutside: true,
});
viewport
.drag({
ignoreKeyToPressOnTouch: true,
mouseButtons: "middle",
})
.pinch()
.wheel();
if (props.clamp) {
viewport.clamp(props.clamp);
}
return viewport;
},
applyProps: (
viewport: PixiViewport,
oldProps: ViewportProps,
newProps: ViewportProps,
) => {
if (
oldProps.width !== newProps.width ||
oldProps.height !== newProps.height
) {
viewport.resize(newProps.width, newProps.height);
}
if (oldProps.clamp !== newProps.clamp) {
viewport.clamp(newProps.clamp);
}
},
});
const Viewport = (props: ViewportProps) => {
const app = useApp();
return <PixiComponentViewport app={app} {...props} />;
};
export default Viewport;

View File

@ -14,6 +14,7 @@ export const useWSQuery = <
action: `${TController}.${TAction}`,
// @ts-expect-error We dont care since this is internal api
payload: Routes[TController][TAction]["validate"]["_input"],
enabled?: boolean,
): UseQueryResult<
// @ts-expect-error We dont care since this is internal api
Awaited<ReturnType<Routes[TController][TAction]["handler"]>>
@ -24,6 +25,7 @@ export const useWSQuery = <
const result = await wsClient.dispatch(action, payload);
return result;
},
enabled,
});
};

View File

@ -1,34 +1,46 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { connectWS } from "./ws.ts";
import { Toaster } from "react-hot-toast";
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import Shell from "./Shell.tsx";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
document.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
import { wsClient } from "./wsClient.ts";
import { Route, Switch } from "wouter";
import Endless from "./views/endless/Endless.tsx";
import { queryClient } from "./queryClient.ts";
connectWS();
const queryClient = new QueryClient({
queryCache: new QueryCache(),
});
const setup = async () => {
const token = localStorage.getItem("loginToken");
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Toaster position="top-right" reverseOrder={false} />
<Shell />
{/* <App /> */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>,
);
if (token) {
try {
await wsClient.dispatch("user.loginWithToken", {
token: JSON.parse(token),
});
} catch (e) {
console.error(e);
}
}
};
setup().then(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Toaster position="top-right" reverseOrder={false} />
<Shell>
<Switch>
<Route path="/play" component={Endless} />
</Switch>
{/* <App /> */}
</Shell>
{/* <App /> */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>,
);
});

9
src/queryClient.ts Normal file
View File

@ -0,0 +1,9 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
},
},
});

46
src/themes/Theme.ts Normal file
View File

@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
type Png = typeof import("*.png");
type LazySprite = () => Promise<Png>;
export interface Theme {
size: number;
mine: LazySprite;
tile: LazySprite;
revealed: LazySprite;
flag: LazySprite;
questionMark: LazySprite;
lastPos: LazySprite;
1: LazySprite;
2: LazySprite;
3: LazySprite;
4: LazySprite;
5: LazySprite;
6: LazySprite;
7: LazySprite;
8: LazySprite;
}
export type LoadedTheme = Record<Exclude<keyof Theme, "size">, string> & {
size: number;
};
export const useTheme = (theme: Theme) => {
const [loadedTheme, setLoadedTheme] = useState<LoadedTheme | undefined>(
undefined,
);
useEffect(() => {
const loadTheme = async () => {
const loadedEntries = await Promise.all(
Object.entries(theme).map(async ([key, value]) => {
const loaded =
typeof value === "function" ? (await value()).default : value;
return [key, loaded] as const;
}),
);
setLoadedTheme(Object.fromEntries(loadedEntries) as LoadedTheme);
};
loadTheme();
}, [theme]);
return loadedTheme;
};

19
src/themes/default.ts Normal file
View File

@ -0,0 +1,19 @@
import { Theme } from "./Theme";
export const defaultTheme: Theme = {
size: 32,
mine: () => import("../assets/themes/default/mine.png"),
tile: () => import("../assets/themes/default/tile.png"),
revealed: () => import("../assets/themes/default/revealed.png"),
flag: () => import("../assets/themes/default/flag.png"),
questionMark: () => import("../assets/themes/default/question-mark.png"),
lastPos: () => import("../assets/themes/default/last-pos.png"),
1: () => import("../assets/themes/default/1.png"),
2: () => import("../assets/themes/default/2.png"),
3: () => import("../assets/themes/default/3.png"),
4: () => import("../assets/themes/default/4.png"),
5: () => import("../assets/themes/default/5.png"),
6: () => import("../assets/themes/default/6.png"),
7: () => import("../assets/themes/default/7.png"),
8: () => import("../assets/themes/default/8.png"),
};

View File

@ -0,0 +1,48 @@
import { defaultTheme } from "../../themes/default";
import Board from "../../components/Board";
import toast from "react-hot-toast";
import { useWSMutation, useWSQuery } from "../../hooks";
import { useAtom } from "jotai";
import { gameIdAtom } from "../../atoms";
import { Button } from "../../components/Button";
const Endless = () => {
const [gameId, setGameId] = useAtom(gameIdAtom);
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
const startGame = useWSMutation("game.createGame");
const reveal = useWSMutation("game.reveal");
return (
<>
<div className="w-full flex flex-col text-white/90 gap-4">
<h1>Endless</h1>
<p>A game where you have to click on the mines</p>
<div className="flex gap-4">
<Button
onClick={async () => {
const gameId = await startGame.mutateAsync(null);
setGameId(gameId.uuid);
}}
>
Start
</Button>
</div>
</div>
{game && (
<Board
key={game.uuid}
theme={defaultTheme}
game={game}
onLeftClick={(x, y) => {
reveal.mutateAsync({ x, y });
}}
onRightClick={(x, y) => {
toast.success(`Right click ${x},${y}`);
}}
/>
)}
</>
);
};
export default Endless;

View File

@ -1,4 +1,6 @@
import type { Routes } from "../backend/router";
import { Events } from "../shared/events";
import { queryClient } from "./queryClient";
const connectionString = import.meta.env.DEV
? "ws://localhost:8076/ws"
@ -20,8 +22,13 @@ const createWSClient = () => {
const ws = new WebSocket(connectionString);
ws.onmessage = emitMessage;
addMessageListener((event: MessageEvent) => {
const data = JSON.parse(event.data);
console.log(data);
const data = JSON.parse(event.data) as Events;
if (data.type === "updateGame") {
queryClient.invalidateQueries({
queryKey: ["game.getGameState", data.game],
});
}
console.log("Received message", data);
});
const dispatch = async <