added reveal and create game
|
|
@ -1,6 +1,10 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createController, createEndpoint } from "./controller";
|
import { createController, createEndpoint } from "./controller";
|
||||||
import { getGame, upsertGameState } from "../repositories/gameRepository";
|
import {
|
||||||
|
getCurrentGame,
|
||||||
|
getGame,
|
||||||
|
upsertGameState,
|
||||||
|
} from "../repositories/gameRepository";
|
||||||
import {
|
import {
|
||||||
serverGame,
|
serverGame,
|
||||||
serverToClientGame,
|
serverToClientGame,
|
||||||
|
|
@ -42,4 +46,18 @@ export const gameController = createController({
|
||||||
});
|
});
|
||||||
return newGame;
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const signString = (payload: string) => {
|
||||||
|
|
||||||
export const userController = createController({
|
export const userController = createController({
|
||||||
getSelf: createEndpoint(z.null(), async (_, { user }) => {
|
getSelf: createEndpoint(z.null(), async (_, { user }) => {
|
||||||
return user;
|
return user || null;
|
||||||
}),
|
}),
|
||||||
login: createEndpoint(
|
login: createEndpoint(
|
||||||
z.object({ username: z.string(), password: z.string() }),
|
z.object({ username: z.string(), password: z.string() }),
|
||||||
|
|
@ -47,7 +47,13 @@ export const userController = createController({
|
||||||
resetSessionUser(ws);
|
resetSessionUser(ws);
|
||||||
}),
|
}),
|
||||||
register: createEndpoint(
|
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 }) => {
|
async (input, { db, ws }) => {
|
||||||
await registerUser(db, input.username, input.password);
|
await registerUser(db, input.username, input.password);
|
||||||
const user = input.username;
|
const user = input.username;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { getValue } from "../../shared/game";
|
||||||
import type { ServerGame } from "../../shared/game";
|
import type { ServerGame } from "../../shared/game";
|
||||||
|
|
||||||
interface CreateGameOptions {
|
interface CreateGameOptions {
|
||||||
|
|
@ -8,6 +9,26 @@ interface CreateGameOptions {
|
||||||
mines: number;
|
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 = {
|
export const game = {
|
||||||
createGame: (options: CreateGameOptions): ServerGame => {
|
createGame: (options: CreateGameOptions): ServerGame => {
|
||||||
const { uuid, user, width, height, mines } = options;
|
const { uuid, user, width, height, mines } = options;
|
||||||
|
|
@ -24,6 +45,9 @@ export const game = {
|
||||||
const isFlaggedArray = Array.from({ length: width }, () =>
|
const isFlaggedArray = Array.from({ length: width }, () =>
|
||||||
new Array(height).fill(false),
|
new Array(height).fill(false),
|
||||||
);
|
);
|
||||||
|
const isQuestionMarkArray = Array.from({ length: width }, () =>
|
||||||
|
new Array(height).fill(false),
|
||||||
|
);
|
||||||
|
|
||||||
let remainingMines = mines;
|
let remainingMines = mines;
|
||||||
while (remainingMines > 0) {
|
while (remainingMines > 0) {
|
||||||
|
|
@ -45,9 +69,57 @@ export const game = {
|
||||||
mines: minesArray,
|
mines: minesArray,
|
||||||
isRevealed: isRevealedArray,
|
isRevealed: isRevealedArray,
|
||||||
isFlagged: isFlaggedArray,
|
isFlagged: isFlaggedArray,
|
||||||
|
isQuestionMark: isQuestionMarkArray,
|
||||||
stage: 1,
|
stage: 1,
|
||||||
lastClick: [-1, -1],
|
lastClick: [-1, -1],
|
||||||
minesCount: mines,
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,4 @@
|
||||||
import type { ClientGame } from "../shared/game";
|
import type { Events } from "../shared/events";
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const listeners = new Set<(event: Events) => void>();
|
const listeners = new Set<(event: Events) => void>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { on } from "./events";
|
||||||
import { handleRequest } from "./router";
|
import { handleRequest } from "./router";
|
||||||
|
|
||||||
const allowCors = {
|
const allowCors = {
|
||||||
|
|
@ -36,5 +37,8 @@ const server = Bun.serve({
|
||||||
},
|
},
|
||||||
port: 8076,
|
port: 8076,
|
||||||
});
|
});
|
||||||
|
on((event) => {
|
||||||
|
server.publish("minesweeper-global", JSON.stringify(event));
|
||||||
|
});
|
||||||
|
|
||||||
console.log("Listening on port 8076");
|
console.log("Listening on port 8076");
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { Controller, Endpoint } from "./controller/controller";
|
||||||
import { gameController } from "./controller/gameController";
|
import { gameController } from "./controller/gameController";
|
||||||
import { db } from "./database/db";
|
import { db } from "./database/db";
|
||||||
import { userController } from "./controller/userController";
|
import { userController } from "./controller/userController";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
const controllers = {
|
const controllers = {
|
||||||
game: gameController,
|
game: gameController,
|
||||||
|
|
@ -24,8 +25,7 @@ export const handleRequest = async (
|
||||||
message: unknown,
|
message: unknown,
|
||||||
ws: ServerWebSocket<unknown>,
|
ws: ServerWebSocket<unknown>,
|
||||||
) => {
|
) => {
|
||||||
// TODO: Remove this
|
const sessionUser = userName.get(ws) || undefined;
|
||||||
const sessionUser = userName.get(ws) || "Gordon";
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
user: sessionUser,
|
user: sessionUser,
|
||||||
db,
|
db,
|
||||||
|
|
@ -50,8 +50,17 @@ export const handleRequest = async (
|
||||||
const result = await endpoint.handler(input, ctx);
|
const result = await endpoint.handler(input, ctx);
|
||||||
ws.send(JSON.stringify({ id, payload: result }));
|
ws.send(JSON.stringify({ id, payload: result }));
|
||||||
return;
|
return;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
ws.send(JSON.stringify({ id, error: "Bad Request" }));
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
|
"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": {
|
"dependencies": {
|
||||||
|
"@pixi/events": "^7.4.2",
|
||||||
|
"@pixi/react": "^7.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
|
|
@ -27,6 +30,8 @@
|
||||||
"framer-motion": "^11.5.6",
|
"framer-motion": "^11.5.6",
|
||||||
"jotai": "^2.10.0",
|
"jotai": "^2.10.0",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
|
"pixi-viewport": "^5.0.3",
|
||||||
|
"pixi.js": "^7.4.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-confetti-boom": "^1.0.0",
|
"react-confetti-boom": "^1.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@ export const clientGame = z.object({
|
||||||
height: z.number(),
|
height: z.number(),
|
||||||
isRevealed: z.array(z.array(z.boolean())),
|
isRevealed: z.array(z.array(z.boolean())),
|
||||||
isFlagged: 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())),
|
values: z.array(z.array(z.number())),
|
||||||
minesCount: z.number(),
|
minesCount: z.number(),
|
||||||
lastClick: z.tuple([z.number(), z.number()]),
|
lastClick: z.tuple([z.number(), z.number()]),
|
||||||
|
|
@ -21,6 +22,7 @@ export const serverGame = z.object({
|
||||||
height: z.number(),
|
height: z.number(),
|
||||||
isRevealed: z.array(z.array(z.boolean())),
|
isRevealed: z.array(z.array(z.boolean())),
|
||||||
isFlagged: 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())),
|
mines: z.array(z.array(z.boolean())),
|
||||||
minesCount: z.number(),
|
minesCount: z.number(),
|
||||||
lastClick: z.tuple([z.number(), 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 ClientGame = z.infer<typeof clientGame>;
|
||||||
export type ServerGame = z.infer<typeof serverGame>;
|
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) => {
|
export const getValue = (mines: boolean[][], x: number, y: number) => {
|
||||||
const neighbors = [
|
const neighbors = [
|
||||||
mines[x - 1]?.[y - 1],
|
mines[x - 1]?.[y - 1],
|
||||||
|
|
@ -54,6 +61,7 @@ export const serverToClientGame = (game: ServerGame): ClientGame => {
|
||||||
height: game.height,
|
height: game.height,
|
||||||
isRevealed: game.isRevealed,
|
isRevealed: game.isRevealed,
|
||||||
isFlagged: game.isFlagged,
|
isFlagged: game.isFlagged,
|
||||||
|
isQuestionMark: game.isQuestionMark,
|
||||||
minesCount: game.minesCount,
|
minesCount: game.minesCount,
|
||||||
values: game.mines.map((_, i) =>
|
values: game.mines.map((_, i) =>
|
||||||
game.mines[0].map((_, j) => {
|
game.mines[0].map((_, j) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from "react";
|
import { PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "./components/Button";
|
import { Button } from "./components/Button";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,8 +17,9 @@ import Header from "./components/Header";
|
||||||
const drawerWidth = 256;
|
const drawerWidth = 256;
|
||||||
const drawerWidthWithPadding = drawerWidth;
|
const drawerWidthWithPadding = drawerWidth;
|
||||||
|
|
||||||
const Shell: React.FC = () => {
|
const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const drawerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const x = isOpen ? 0 : -drawerWidthWithPadding;
|
const x = isOpen ? 0 : -drawerWidthWithPadding;
|
||||||
const width = isOpen ? drawerWidthWithPadding : 0;
|
const width = isOpen ? drawerWidthWithPadding : 0;
|
||||||
|
|
@ -26,11 +27,29 @@ const Shell: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOpen(!isMobile);
|
setIsOpen(!isMobile);
|
||||||
}, [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 (
|
return (
|
||||||
<div className="relative bg-black min-h-screen">
|
<div className="bg-black min-h-screen">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="bg-black p-4 absolute h-screen w-64 flex border-white/10 border-1"
|
className="bg-black p-4 absolute h-screen w-64 flex border-white/10 border-1"
|
||||||
|
ref={drawerRef}
|
||||||
animate={{ x }}
|
animate={{ x }}
|
||||||
transition={{ type: "tween" }}
|
transition={{ type: "tween" }}
|
||||||
>
|
>
|
||||||
|
|
@ -74,17 +93,19 @@ const Shell: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div className="flex">
|
<motion.div className="flex max-w-[100vw]">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="hidden md:block"
|
||||||
animate={{ width: width }}
|
animate={{ width: width }}
|
||||||
transition={{ type: "tween" }}
|
transition={{ type: "tween" }}
|
||||||
layout
|
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">
|
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
|
||||||
<Header />
|
<Header />
|
||||||
<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 className="bg-gray-950 p-4 rounded-lg w-full"></div> */}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 195 B |
|
After Width: | Height: | Size: 247 B |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 208 B |
|
After Width: | Height: | Size: 228 B |
|
After Width: | Height: | Size: 247 B |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 302 B |
|
After Width: | Height: | Size: 264 B |
|
After Width: | Height: | Size: 458 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 119 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 136 B |
|
After Width: | Height: | Size: 119 B |
|
After Width: | Height: | Size: 129 B |
|
|
@ -1,8 +1,8 @@
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomWithStorage } from "jotai/utils";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
|
|
||||||
export const gameId = atom<string | undefined>(undefined);
|
export const gameIdAtom = atom<string | undefined>(undefined);
|
||||||
export const loginToken = atomWithStorage<string | undefined>(
|
export const loginTokenAtom = atomWithStorage<string | undefined>(
|
||||||
"loginToken",
|
"loginToken",
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,20 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../Dialog";
|
} 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 LoginButton = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const login = useWSMutation("user.login");
|
||||||
|
const [, setToken] = useAtom(loginTokenAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUsername("");
|
setUsername("");
|
||||||
|
|
@ -36,18 +45,29 @@ const LoginButton = () => {
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<label className="text-white/70 font-bold">Password</label>
|
<label className="text-white/70 font-bold">Password</label>
|
||||||
<input
|
<PasswordInput value={password} onChange={setPassword} />
|
||||||
className="border-white/10 border-2 rounded-md p-2"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{error && <p className="text-red-500">{error}</p>}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => setIsOpen(false)}>
|
<Button variant="ghost" onClick={() => setIsOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -8,11 +8,20 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../Dialog";
|
} 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 RegisterButton = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const register = useWSMutation("user.register");
|
||||||
|
const [, setToken] = useAtom(loginTokenAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUsername("");
|
setUsername("");
|
||||||
|
|
@ -36,18 +45,29 @@ const RegisterButton = () => {
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<label className="text-white/70 font-bold">Password</label>
|
<label className="text-white/70 font-bold">Password</label>
|
||||||
<input
|
<PasswordInput value={password} onChange={setPassword} />
|
||||||
className="border-white/10 border-2 rounded-md p-2"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{error && <p className="text-red-500">{error}</p>}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => setIsOpen(false)}>
|
<Button variant="ghost" onClick={() => setIsOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -35,7 +35,7 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,25 @@ import {
|
||||||
} from "./DropdownMenu";
|
} from "./DropdownMenu";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import LoginButton from "./Auth/LoginButton";
|
import LoginButton from "./Auth/LoginButton";
|
||||||
import { useWSQuery } from "../hooks";
|
import { useWSMutation, useWSQuery } from "../hooks";
|
||||||
import RegisterButton from "./Auth/RegisterButton";
|
import RegisterButton from "./Auth/RegisterButton";
|
||||||
import banner from "../images/banner.png";
|
import banner from "../images/banner.png";
|
||||||
import mine from "../images/mine.png";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { data: username } = useWSQuery("user.getSelf", null);
|
const { data: username } = useWSQuery("user.getSelf", null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const logout = useWSMutation("user.logout", () => {
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex gap-4">
|
<div className="w-full flex gap-4">
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
<img src={banner} className="w-auto h-16" />
|
<img src={banner} className="w-auto h-16 hidden sm:block" />
|
||||||
<img
|
|
||||||
src={mine}
|
|
||||||
className="w-auto h-16 drop-shadow-[0px_0px_10px_#fff] -rotate-12"
|
|
||||||
/>
|
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
|
|
||||||
{username ? (
|
{username ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
@ -42,7 +44,9 @@ const Header = () => {
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => logout.mutate(null)}>
|
||||||
|
Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -14,6 +14,7 @@ export const useWSQuery = <
|
||||||
action: `${TController}.${TAction}`,
|
action: `${TController}.${TAction}`,
|
||||||
// @ts-expect-error We dont care since this is internal api
|
// @ts-expect-error We dont care since this is internal api
|
||||||
payload: Routes[TController][TAction]["validate"]["_input"],
|
payload: Routes[TController][TAction]["validate"]["_input"],
|
||||||
|
enabled?: boolean,
|
||||||
): UseQueryResult<
|
): UseQueryResult<
|
||||||
// @ts-expect-error We dont care since this is internal api
|
// @ts-expect-error We dont care since this is internal api
|
||||||
Awaited<ReturnType<Routes[TController][TAction]["handler"]>>
|
Awaited<ReturnType<Routes[TController][TAction]["handler"]>>
|
||||||
|
|
@ -24,6 +25,7 @@ export const useWSQuery = <
|
||||||
const result = await wsClient.dispatch(action, payload);
|
const result = await wsClient.dispatch(action, payload);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
40
src/main.tsx
|
|
@ -1,34 +1,46 @@
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { connectWS } from "./ws.ts";
|
import { connectWS } from "./ws.ts";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import {
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
QueryCache,
|
|
||||||
QueryClient,
|
|
||||||
QueryClientProvider,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import Shell from "./Shell.tsx";
|
import Shell from "./Shell.tsx";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import { wsClient } from "./wsClient.ts";
|
||||||
document.addEventListener("contextmenu", (event) => {
|
import { Route, Switch } from "wouter";
|
||||||
event.preventDefault();
|
import Endless from "./views/endless/Endless.tsx";
|
||||||
});
|
import { queryClient } from "./queryClient.ts";
|
||||||
|
|
||||||
connectWS();
|
connectWS();
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const setup = async () => {
|
||||||
queryCache: new QueryCache(),
|
const token = localStorage.getItem("loginToken");
|
||||||
});
|
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await wsClient.dispatch("user.loginWithToken", {
|
||||||
|
token: JSON.parse(token),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setup().then(() => {
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Toaster position="top-right" reverseOrder={false} />
|
<Toaster position="top-right" reverseOrder={false} />
|
||||||
<Shell />
|
<Shell>
|
||||||
|
<Switch>
|
||||||
|
<Route path="/play" component={Endless} />
|
||||||
|
</Switch>
|
||||||
|
{/* <App /> */}
|
||||||
|
</Shell>
|
||||||
{/* <App /> */}
|
{/* <App /> */}
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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"),
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type { Routes } from "../backend/router";
|
import type { Routes } from "../backend/router";
|
||||||
|
import { Events } from "../shared/events";
|
||||||
|
import { queryClient } from "./queryClient";
|
||||||
|
|
||||||
const connectionString = import.meta.env.DEV
|
const connectionString = import.meta.env.DEV
|
||||||
? "ws://localhost:8076/ws"
|
? "ws://localhost:8076/ws"
|
||||||
|
|
@ -20,8 +22,13 @@ const createWSClient = () => {
|
||||||
const ws = new WebSocket(connectionString);
|
const ws = new WebSocket(connectionString);
|
||||||
ws.onmessage = emitMessage;
|
ws.onmessage = emitMessage;
|
||||||
addMessageListener((event: MessageEvent) => {
|
addMessageListener((event: MessageEvent) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data) as Events;
|
||||||
console.log(data);
|
if (data.type === "updateGame") {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["game.getGameState", data.game],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("Received message", data);
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatch = async <
|
const dispatch = async <
|
||||||
|
|
|
||||||