diff --git a/backend/controller/gameController.ts b/backend/controller/gameController.ts index e12b83b..edf0574 100644 --- a/backend/controller/gameController.ts +++ b/backend/controller/gameController.ts @@ -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, + }); + }, + ), }); diff --git a/backend/controller/userController.ts b/backend/controller/userController.ts index 433df26..5adbddc 100644 --- a/backend/controller/userController.ts +++ b/backend/controller/userController.ts @@ -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; diff --git a/backend/entities/game.ts b/backend/entities/game.ts index 783cfb7..9022755 100644 --- a/backend/entities/game.ts +++ b/backend/entities/game.ts @@ -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); + } + }, }; diff --git a/backend/events.ts b/backend/events.ts index a64480d..68b0ac6 100644 --- a/backend/events.ts +++ b/backend/events.ts @@ -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>(); diff --git a/backend/index.ts b/backend/index.ts index 9560b57..1cc5ba1 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -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"); diff --git a/backend/router.ts b/backend/router.ts index 1d0524a..73b2323 100644 --- a/backend/router.ts +++ b/backend/router.ts @@ -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, ) => { - // 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); } }; diff --git a/bun.lockb b/bun.lockb index bc27cb3..69c5b87 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e459c12..800007d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/events.ts b/shared/events.ts new file mode 100644 index 0000000..4042ef1 --- /dev/null +++ b/shared/events.ts @@ -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; + }; diff --git a/shared/game.ts b/shared/game.ts index 335aa7c..aece234 100644 --- a/shared/game.ts +++ b/shared/game.ts @@ -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; export type ServerGame = z.infer; +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) => { diff --git a/sqlite.db b/sqlite.db index 06eaec2..3668c43 100644 Binary files a/sqlite.db and b/sqlite.db differ diff --git a/src/Shell.tsx b/src/Shell.tsx index 0fff75e..131bc8b 100644 --- a/src/Shell.tsx +++ b/src/Shell.tsx @@ -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 = ({ children }) => { const [isOpen, setIsOpen] = useState(false); + const drawerRef = useRef(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 ( -
+
@@ -74,17 +93,19 @@ const Shell: React.FC = () => {
- + - +
-
-
+ {children} + {/*
*/} + {/*
*/}
diff --git a/src/assets/themes/default/1.png b/src/assets/themes/default/1.png new file mode 100644 index 0000000..2550fec Binary files /dev/null and b/src/assets/themes/default/1.png differ diff --git a/src/assets/themes/default/2.png b/src/assets/themes/default/2.png new file mode 100644 index 0000000..8132417 Binary files /dev/null and b/src/assets/themes/default/2.png differ diff --git a/src/assets/themes/default/3.png b/src/assets/themes/default/3.png new file mode 100644 index 0000000..3dad458 Binary files /dev/null and b/src/assets/themes/default/3.png differ diff --git a/src/assets/themes/default/4.png b/src/assets/themes/default/4.png new file mode 100644 index 0000000..4676248 Binary files /dev/null and b/src/assets/themes/default/4.png differ diff --git a/src/assets/themes/default/5.png b/src/assets/themes/default/5.png new file mode 100644 index 0000000..0f25d5b Binary files /dev/null and b/src/assets/themes/default/5.png differ diff --git a/src/assets/themes/default/6.png b/src/assets/themes/default/6.png new file mode 100644 index 0000000..601f912 Binary files /dev/null and b/src/assets/themes/default/6.png differ diff --git a/src/assets/themes/default/7.png b/src/assets/themes/default/7.png new file mode 100644 index 0000000..d0660b7 Binary files /dev/null and b/src/assets/themes/default/7.png differ diff --git a/src/assets/themes/default/8.png b/src/assets/themes/default/8.png new file mode 100644 index 0000000..72b8f81 Binary files /dev/null and b/src/assets/themes/default/8.png differ diff --git a/src/assets/themes/default/flag.png b/src/assets/themes/default/flag.png new file mode 100644 index 0000000..f395871 Binary files /dev/null and b/src/assets/themes/default/flag.png differ diff --git a/src/assets/themes/default/last-pos.png b/src/assets/themes/default/last-pos.png new file mode 100644 index 0000000..9b9a14b Binary files /dev/null and b/src/assets/themes/default/last-pos.png differ diff --git a/src/assets/themes/default/mine.png b/src/assets/themes/default/mine.png new file mode 100644 index 0000000..a0de10e Binary files /dev/null and b/src/assets/themes/default/mine.png differ diff --git a/src/assets/themes/default/question-mark.png b/src/assets/themes/default/question-mark.png new file mode 100644 index 0000000..ce1c637 Binary files /dev/null and b/src/assets/themes/default/question-mark.png differ diff --git a/src/assets/themes/default/revealed.png b/src/assets/themes/default/revealed.png new file mode 100644 index 0000000..2cdf0f7 Binary files /dev/null and b/src/assets/themes/default/revealed.png differ diff --git a/src/assets/themes/default/tile.png b/src/assets/themes/default/tile.png new file mode 100644 index 0000000..1ded545 Binary files /dev/null and b/src/assets/themes/default/tile.png differ diff --git a/src/assets/themes/devart/flag.png b/src/assets/themes/devart/flag.png new file mode 100644 index 0000000..13d8f43 Binary files /dev/null and b/src/assets/themes/devart/flag.png differ diff --git a/src/assets/themes/devart/last-pos.png b/src/assets/themes/devart/last-pos.png new file mode 100644 index 0000000..51e3964 Binary files /dev/null and b/src/assets/themes/devart/last-pos.png differ diff --git a/src/assets/themes/devart/mine.png b/src/assets/themes/devart/mine.png new file mode 100644 index 0000000..1c78169 Binary files /dev/null and b/src/assets/themes/devart/mine.png differ diff --git a/src/assets/themes/devart/question-mark.png b/src/assets/themes/devart/question-mark.png new file mode 100644 index 0000000..0a44491 Binary files /dev/null and b/src/assets/themes/devart/question-mark.png differ diff --git a/src/assets/themes/devart/revealed.png b/src/assets/themes/devart/revealed.png new file mode 100644 index 0000000..c925de7 Binary files /dev/null and b/src/assets/themes/devart/revealed.png differ diff --git a/src/assets/themes/devart/tile.png b/src/assets/themes/devart/tile.png new file mode 100644 index 0000000..648e90e Binary files /dev/null and b/src/assets/themes/devart/tile.png differ diff --git a/src/atoms.ts b/src/atoms.ts index c7b54e3..0d94a76 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -1,8 +1,8 @@ import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; -export const gameId = atom(undefined); -export const loginToken = atomWithStorage( +export const gameIdAtom = atom(undefined); +export const loginTokenAtom = atomWithStorage( "loginToken", undefined, ); diff --git a/src/components/Auth/LoginButton.tsx b/src/components/Auth/LoginButton.tsx index 3bc122d..0be97c4 100644 --- a/src/components/Auth/LoginButton.tsx +++ b/src/components/Auth/LoginButton.tsx @@ -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)} /> - setPassword(e.target.value)} - /> +
+ {error &&

{error}

} - + diff --git a/src/components/Auth/PasswordInput.tsx b/src/components/Auth/PasswordInput.tsx new file mode 100644 index 0000000..595d7e8 --- /dev/null +++ b/src/components/Auth/PasswordInput.tsx @@ -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 ( +
+ onChange(e.target.value)} + className="w-full p-2 border-white/10 border-1 rounded-md" + /> +
+ +
+
+ ); +}; + +export default PasswordInput; diff --git a/src/components/Auth/RegisterButton.tsx b/src/components/Auth/RegisterButton.tsx index 27f7f33..ceacb32 100644 --- a/src/components/Auth/RegisterButton.tsx +++ b/src/components/Auth/RegisterButton.tsx @@ -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)} /> - setPassword(e.target.value)} - /> + + {error &&

{error}

} - + diff --git a/src/components/Board.tsx b/src/components/Board.tsx new file mode 100644 index 0000000..5d3d59b --- /dev/null +++ b/src/components/Board.tsx @@ -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 = (props) => { + const { game } = props; + const { data: user } = useWSQuery("user.getSelf", null); + const ref = useRef(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 ( +
+ {theme && ( + + + {game.isRevealed.map((_, i) => { + return game.isRevealed[0].map((_, j) => { + return ( + + ); + }); + })} + + + )} +
+ ); +}; + +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 ? ( + + ) : ( + + ); + let content: ReactNode = null; + if (isMine) { + content = ; + } else if (value !== -1 && isRevealed) { + const img = theme[value.toString() as keyof Theme] as string; + content = img ? : null; + } else if (isFlagged) { + content = ; + } else if (isQuestionMark) { + content = ; + } + const extra = isLastPos ? : null; + const touchStart = useRef(0); + const isMove = useRef(false); + const startX = useRef(0); + const startY = useRef(0); + + return ( + { + 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} + + ); +}; + +export default Board; diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 65e3747..8aa2f6c 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -35,7 +35,7 @@ const DialogContent = React.forwardRef< { const [, setLocation] = useLocation(); const { data: username } = useWSQuery("user.getSelf", null); + const queryClient = useQueryClient(); + const logout = useWSMutation("user.logout", () => { + queryClient.invalidateQueries(); + }); + return (
- - +
+ {username ? ( @@ -42,7 +44,9 @@ const Header = () => { Settings - Logout + logout.mutate(null)}> + Logout + ) : ( diff --git a/src/components/pixi/PixiViewport.tsx b/src/components/pixi/PixiViewport.tsx new file mode 100644 index 0000000..ce55e7e --- /dev/null +++ b/src/components/pixi/PixiViewport.tsx @@ -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 ; +}; + +export default Viewport; diff --git a/src/hooks.ts b/src/hooks.ts index 83df5fe..e5f9f15 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -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> @@ -24,6 +25,7 @@ export const useWSQuery = < const result = await wsClient.dispatch(action, payload); return result; }, + enabled, }); }; diff --git a/src/main.tsx b/src/main.tsx index 5b0c196..8885830 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - - - - - {/* */} - - - , -); + if (token) { + try { + await wsClient.dispatch("user.loginWithToken", { + token: JSON.parse(token), + }); + } catch (e) { + console.error(e); + } + } +}; + +setup().then(() => { + createRoot(document.getElementById("root")!).render( + + + + + + + + {/* */} + + {/* */} + + + , + ); +}); diff --git a/src/queryClient.ts b/src/queryClient.ts new file mode 100644 index 0000000..af1fcfe --- /dev/null +++ b/src/queryClient.ts @@ -0,0 +1,9 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: true, + }, + }, +}); diff --git a/src/themes/Theme.ts b/src/themes/Theme.ts new file mode 100644 index 0000000..b838e2c --- /dev/null +++ b/src/themes/Theme.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; + +type Png = typeof import("*.png"); +type LazySprite = () => Promise; + +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, string> & { + size: number; +}; + +export const useTheme = (theme: Theme) => { + const [loadedTheme, setLoadedTheme] = useState( + 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; +}; diff --git a/src/themes/default.ts b/src/themes/default.ts new file mode 100644 index 0000000..e56766e --- /dev/null +++ b/src/themes/default.ts @@ -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"), +}; diff --git a/src/views/endless/Endless.tsx b/src/views/endless/Endless.tsx new file mode 100644 index 0000000..44a75dc --- /dev/null +++ b/src/views/endless/Endless.tsx @@ -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 ( + <> +
+

Endless

+

A game where you have to click on the mines

+
+ +
+
+ {game && ( + { + reveal.mutateAsync({ x, y }); + }} + onRightClick={(x, y) => { + toast.success(`Right click ${x},${y}`); + }} + /> + )} + + ); +}; + +export default Endless; diff --git a/src/wsClient.ts b/src/wsClient.ts index 0c04087..22c7d4a 100644 --- a/src/wsClient.ts +++ b/src/wsClient.ts @@ -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 <