diff --git a/README.md b/README.md index 22fc07b..3ef6b22 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,31 @@ -# Minesweeper +# 💣 Business Minesweeper -A simple version of minesweeper built with react in about 1h. +This is a version of minesweeper with a expanding board after each stage. This also includes a account system with match history, spectating live matches and collectables. -![image](https://github.com/user-attachments/assets/25012972-ebe8-4610-bd28-c181ce8c4e2d) +## 🚀 Local Development -## Ideas +For local development you are required to have [bun](https://bun.sh/) installed. + +```bash +# Create a .env file for token signing +echo "SECRET=SOME_RANDOM_STRING" > .env +bun install +bun run dev +``` + +## 📦 Used Libraries + +- [Pixi.js](https://github.com/pixijs/pixi-react) +- [PixiViewport](https://github.com/davidfig/pixi-viewport) +- [Tanstack Query](https://github.com/TanStack/query) +- [Zod](https://github.com/colinhacks/zod) +- [Drizzle ORM](https://github.com/drizzle-team/drizzle-orm) +- [Tailwind CSS v4](https://github.com/tailwindlabs/tailwindcss) +- [React](https://github.com/facebook/react) + +## 📋 Ideas - Add global big board - Questinmark after flag - Earn points for wins +- Powerups diff --git a/backend/controller/gameController.ts b/backend/controller/gameController.ts index cb0856c..4de5f8c 100644 --- a/backend/controller/gameController.ts +++ b/backend/controller/gameController.ts @@ -3,6 +3,8 @@ import { createController, createEndpoint } from "./controller"; import { getCurrentGame, getGame, + getGames, + getTotalGamesPlayed, parseGameState, upsertGameState, } from "../repositories/gameRepository"; @@ -127,4 +129,32 @@ export const gameController = createController({ } }, ), + getGames: createEndpoint( + z.object({ + page: z.number().default(0), + user: z.string(), + }), + async ({ page, user }, { db }) => { + const perPage = 20; + const offset = page * perPage; + const games = await getGames(db, user); + const parsedGames = games + .slice(offset, offset + perPage) + .map((game) => parseGameState(game.gameState)); + const isLastPage = games.length <= offset + perPage; + return { + data: parsedGames, + nextPage: isLastPage ? undefined : page + 1, + }; + }, + ), + getTotalGamesPlayed: createEndpoint( + z.object({ + user: z.string().optional(), + }), + async ({ user }, { db }) => { + const total = await getTotalGamesPlayed(db, user); + return total; + }, + ), }); diff --git a/backend/repositories/gameRepository.test.ts b/backend/repositories/gameRepository.test.ts index 76a9525..ceccfc2 100644 --- a/backend/repositories/gameRepository.test.ts +++ b/backend/repositories/gameRepository.test.ts @@ -16,7 +16,7 @@ describe("GameRepository", () => { uuid: "TestUuid", user: "TestUser", stage: 1, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started, }); @@ -25,7 +25,7 @@ describe("GameRepository", () => { uuid: "TestUuid", user: "TestUser", stage: 1, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started, }); @@ -44,7 +44,7 @@ describe("GameRepository", () => { uuid: "TestUuid", user: "TestUser", stage: 1, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started, }); @@ -52,7 +52,7 @@ describe("GameRepository", () => { uuid: "TestUuid2", user: "TestUser", stage: 2, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started: started + 1, }); @@ -61,7 +61,7 @@ describe("GameRepository", () => { uuid: "TestUuid2", user: "TestUser", stage: 2, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started: started + 1, }); @@ -76,7 +76,7 @@ describe("GameRepository", () => { uuid, user: "TestUser", stage: 1, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started, }); @@ -92,7 +92,7 @@ describe("GameRepository", () => { uuid: "TestUuid", user: "TestUser", stage: 1, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started, }); @@ -101,7 +101,7 @@ describe("GameRepository", () => { uuid: "TestUuid", user: "TestUser", stage: 1, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started, }); @@ -114,7 +114,7 @@ describe("GameRepository", () => { uuid: "TestUuid", user: "TestUser", stage: 1, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started, }); @@ -122,7 +122,7 @@ describe("GameRepository", () => { uuid: "TestUuid", user: "TestUser", stage: 2, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started: started + 1, }); @@ -131,7 +131,7 @@ describe("GameRepository", () => { uuid: "TestUuid", user: "TestUser", stage: 2, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started: started + 1, }); diff --git a/backend/repositories/gameRepository.ts b/backend/repositories/gameRepository.ts index 53a1537..f626410 100644 --- a/backend/repositories/gameRepository.ts +++ b/backend/repositories/gameRepository.ts @@ -13,7 +13,7 @@ export const getGames = async (db: BunSQLiteDatabase, user: string) => { .select() .from(Game) .where(and(eq(Game.user, user), not(eq(Game.finished, 0)))) - .orderBy(Game.started, sql`desc`); + .orderBy(desc(Game.started)); }; export const getCurrentGame = async (db: BunSQLiteDatabase, user: string) => { @@ -76,6 +76,25 @@ export const upsertGameState = async ( }); }; +export const getTotalGamesPlayed = async ( + db: BunSQLiteDatabase, + user?: string, +) => { + if (user) + return ( + await db + .select({ count: sql`count(*)` }) + .from(Game) + .where(and(eq(Game.user, user), not(eq(Game.finished, 0)))) + )[0].count; + return ( + await db + .select({ count: sql`count(*)` }) + .from(Game) + .where(not(eq(Game.finished, 0))) + )[0].count; +}; + export const parseGameState = (gameState: Buffer) => { return decode(gameState) as ServerGame; }; diff --git a/backend/repositories/scoreRepository.test.ts b/backend/repositories/scoreRepository.test.ts index c48391d..3b1ac03 100644 --- a/backend/repositories/scoreRepository.test.ts +++ b/backend/repositories/scoreRepository.test.ts @@ -14,7 +14,7 @@ describe("ScoreRepository", () => { user: "TestUser", uuid: crypto.randomUUID(), stage: 1, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started: Date.now(), }); @@ -22,7 +22,7 @@ describe("ScoreRepository", () => { user: "TestUser", uuid: crypto.randomUUID(), stage: 10, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 1, started: Date.now(), }); @@ -30,7 +30,7 @@ describe("ScoreRepository", () => { user: "TestUser", uuid: crypto.randomUUID(), stage: 20, - gameState: "ANY", + gameState: Buffer.from("ANY"), finished: 0, started: Date.now(), }); diff --git a/bun.lockb b/bun.lockb index 6815fcd..8505b9d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/eslint.config.js b/eslint.config.js index a61a109..0d9d41f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,36 +1,39 @@ -import js from "@eslint/js"; import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; +import pluginJs from "@eslint/js"; import tseslint from "typescript-eslint"; +import pluginReact from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; -export default tseslint.config( - { ignores: ["dist"] }, +export default [ { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ["**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - "react-hooks": reactHooks, - "react-refresh": reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], + ignores: ["dist/", "node_modules/"], + }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ...pluginReact.configs.flat.recommended, + settings: { + react: { + version: "detect", + }, }, }, -); + { + plugins: { "react-hooks": reactHooks }, + rules: reactHooks.configs.recommended.rules, + }, + { + files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], + rules: { + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "react/display-name": "off", + }, + }, + { + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { ...globals.browser, ...globals.node }, + }, + }, +]; diff --git a/package.json b/package.json index 3fe4ab9..97caded 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "type": "module", "scripts": { "dev": "bun run dev.ts", - "dev:backend": "bun run backend/index.ts --watch", + "dev:backend": "bun run backend/index.ts --watch --hot", "dev:client": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", + "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", @@ -16,50 +16,47 @@ }, "dependencies": { "@msgpack/msgpack": "^3.0.0-beta2", - "@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", - "@radix-ui/react-switch": "^1.1.0", - "@tailwindcss/vite": "^4.0.0-alpha.24", - "@tanstack/react-query": "^5.56.2", - "@tanstack/react-query-devtools": "^5.0.0-alpha.91", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-switch": "^1.1.1", + "@tanstack/react-query": "^5.59.11", + "@tanstack/react-query-devtools": "^5.59.11", "@uidotdev/usehooks": "^2.4.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "drizzle-orm": "^0.33.0", - "framer-motion": "^11.5.6", + "drizzle-orm": "^0.34.1", + "framer-motion": "^11.11.8", "jotai": "^2.10.0", - "lucide-react": "^0.441.0", + "lucide-react": "^0.452.0", "pixi-viewport": "^5.0.3", - "pixi.js": "^7.4.2", + "pixi.js": "^7.0.0", "react": "^18.3.1", "react-confetti-boom": "^1.0.0", "react-dom": "^18.3.1", - "react-hot-toast": "^2.4.1", - "tailwind-merge": "^2.5.2", - "tailwindcss": "^4.0.0-alpha.24", + "tailwind-merge": "^2.5.3", "use-sound": "^4.0.3", - "vite-imagetools": "^7.0.4", "wouter": "^3.3.5", - "zod": "^3.23.8", - "zustand": "^4.5.5" + "zod": "^3.23.8" }, "devDependencies": { - "@eslint/js": "^9.10.0", + "vite-imagetools": "^7.0.4", + "tailwindcss": "^4.0.0-alpha.26", + "@eslint/compat": "^1.2.0", + "@eslint/js": "^9.12.0", "@types/bun": "latest", - "@types/react": "^18.3.8", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react-swc": "^3.7.0", - "drizzle-kit": "^0.24.2", - "eslint": "^9.10.0", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.12", - "globals": "^15.9.0", - "typescript": "^5.6.2", - "typescript-eslint": "^8.6.0", - "vite": "^5.4.6" - }, - "module": "index.ts" + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react-swc": "^3.7.1", + "drizzle-kit": "^0.25.0", + "eslint": "^9.12.0", + "eslint-plugin-react": "^7.37.1", + "eslint-plugin-react-hooks": "5.0.0", + "globals": "^15.11.0", + "typescript": "^5.6.3", + "typescript-eslint": "^8.8.1", + "vite": "^5.4.8", + "@tailwindcss/vite": "next" + } } diff --git a/shared/game.test.ts b/shared/game.test.ts index 5609c8f..8ef42a6 100644 --- a/shared/game.test.ts +++ b/shared/game.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { getValue, serverToClientGame } from "./game"; +import { getValue, ServerGame, serverToClientGame } from "./game"; describe("Game", () => { it("should get value", () => { @@ -16,7 +16,7 @@ describe("Game", () => { }); it("should convert server to client game", () => { - const serverGame = { + const serverGame: ServerGame = { mines: [ [false, false, true, true, true], [true, false, true, false, true], @@ -36,13 +36,20 @@ describe("Game", () => { [false, false, true, false, true], [true, false, false, false, false], ], - isGameOver: false, started: 1679599200000, finished: 0, lastClick: [0, 0] satisfies [number, number], uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17", width: 5, height: 4, + user: "TestUser", + stage: 1, + isQuestionMark: [ + [false, false, true, false, true], + [true, false, true, false, true], + [false, false, true, false, true], + [false, false, false, false, false], + ], }; expect(serverToClientGame(serverGame)).toEqual({ minesCount: 4, @@ -69,6 +76,14 @@ describe("Game", () => { uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17", width: 5, height: 4, + user: "TestUser", + stage: 1, + isQuestionMark: [ + [false, false, true, false, true], + [true, false, true, false, true], + [false, false, true, false, true], + [false, false, false, false, false], + ], }); }); }); diff --git a/shared/time.ts b/shared/time.ts new file mode 100644 index 0000000..220b64b --- /dev/null +++ b/shared/time.ts @@ -0,0 +1,60 @@ +export const formatTimeSpan = (timespan: number) => { + const days = Math.floor(timespan / (1000 * 60 * 60 * 24)); + const hours = Math.floor( + (timespan % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60), + ); + const minutes = Math.floor((timespan % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((timespan % (1000 * 60)) / 1000); + + const result = []; + + if (days > 0) { + result.push(`${days}d`); + } + + if (hours > 0) { + result.push(`${hours}h`); + } + + if (minutes > 0) { + result.push(`${minutes}m`); + } + + if (seconds > 0) { + result.push(`${seconds}s`); + } + + if (result.length === 0) { + return timespan + "ms"; + } + + return result.join(" "); +}; + +const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + +export const formatRelativeTime = (date: number) => { + const now = Date.now(); + const diff = date - now; + const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); + const hours = Math.ceil((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.ceil((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + if (days <= -1) { + return rtf.format(days, "day"); + } + + if (hours <= -1) { + return rtf.format(hours, "hour"); + } + + if (minutes <= -1) { + return rtf.format(minutes, "minute"); + } + + if (seconds <= -1) { + return rtf.format(seconds, "second"); + } + return "just now"; +}; diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 805047a..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { Button } from "./Button"; -import Timer from "./Timer"; -import explosion from "./sound/explosion.mp3"; -import useGameStore from "./GameState"; -import { useEffect, useState } from "react"; -import useSound from "use-sound"; -import { loseGame } from "./ws"; -import toast, { useToasterStore } from "react-hot-toast"; - -interface Score { - user: string; - stage: number; -} - -function useMaxToasts(max: number) { - const { toasts } = useToasterStore(); - - useEffect(() => { - toasts - .filter((t) => t.visible) // Only consider visible toasts - .filter((_, i) => i >= max) // Is toast index over limit? - .forEach((t) => toast.dismiss(t.id)); // Dismiss – Use toast.remove(t.id) for no exit animation - }, [toasts, max]); -} - -function App() { - const game = useGameStore(); - const [scores, setScores] = useState([]); - const [playSound] = useSound(explosion, { - volume: 0.5, - }); - - useEffect(() => { - if (game.isGameOver) { - playSound(); - loseGame(game.name, game.stage); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [game.isGameOver]); - useEffect(() => { - game.resetGame(4, 4, 2); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - fetch("https://mb.gordon.business") - .then((res) => res.json()) - .then((data) => { - setScores(data); - }); - const i = setInterval(() => { - fetch("https://mb.gordon.business") - .then((res) => res.json()) - .then((data) => { - setScores(data); - }); - }, 2000); - return () => clearInterval(i); - }, []); - - useMaxToasts(5); - - return ( -
- {import.meta.env.DEV && ( - - )} -
-
-

- Minesweeper Endless{" "} - -

-

- Name:{" "} - game.setName(e.target.value)} - /> -

-

- Feed:{" "} - -

-
-
- {scores.slice(0, 10).map((score) => ( -

- {score.user} - {score.stage} -

- ))} -
-
-
-
- -
- {game.mines[0].map((_, y) => - game.mines.map((_, x) => ( -
-
-
-
-
Version: 1.1.6
-
-          Made by MasterGordon -{" "}
-          
-            Source Code
-          
-        
-
-
- ); -} - -export default App; diff --git a/src/Button.tsx b/src/Button.tsx deleted file mode 100644 index 0c73c6f..0000000 --- a/src/Button.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { ReactNode, useRef } from "react"; -import { Bomb, Flag } from "lucide-react"; -import useGameStore from "./GameState"; -import { useLongPress } from "@uidotdev/usehooks"; - -interface ButtonProps { - x: number; - y: number; -} - -// eslint-disable-next-line react-refresh/only-export-components -export const colorMap: Record = { - "1": "#049494", - "2": "#8c9440", - "3": "#cc6666", - "4": "#b294bb", - "5": "#f7c530", - "6": "#81a2be", - "7": "#707880", - "8": "#b5bd68", -}; - -export const Button = ({ x, y }: ButtonProps) => { - const { - isRevealed, - isFlagged, - isMine, - getValue, - reveal, - flag, - getNeighborFlags, - isGameOver, - getHasWon, - } = useGameStore(); - - let content: ReactNode = ""; - - if (isRevealed[x][y]) { - content = isMine(x, y) ? : getValue(x, y).toString(); - } - - const attrs = useLongPress( - () => { - if (isRevealed[x][y]) return; - flag(x, y); - }, - { - threshold: 400, - }, - ); - - if (isFlagged[x][y]) { - content = ; - } - if (content === "0") content = ""; - if ( - import.meta.env.DEV && - window.location.href.includes("xray") && - isMine(x, y) && - !isFlagged[x][y] - ) - content = ; - - const touchStart = useRef(0); - - return ( -
0 ? "1.75rem" : undefined, - cursor: isRevealed[x][y] ? "default" : "pointer", - }} - onMouseDown={() => { - touchStart.current = Date.now(); - }} - onMouseUp={(e) => { - if (Date.now() - touchStart.current > 400 && !isRevealed[x][y]) { - flag(x, y); - return; - } - if (getHasWon() || isGameOver) { - return; - } - if (e.button === 0) { - // Left click - if (isFlagged[x][y]) return; - if (!isRevealed[x][y]) { - reveal(x, y); - } else { - const neighborFlagCount = getNeighborFlags(x, y).filter( - (n) => n, - ).length; - const value = getValue(x, y); - if (neighborFlagCount === value) { - if (!isFlagged[x - 1]?.[y]) if (reveal(x - 1, y)) return; - if (!isFlagged[x - 1]?.[y - 1]) if (reveal(x - 1, y - 1)) return; - if (!isFlagged[x - 1]?.[y + 1]) if (reveal(x - 1, y + 1)) return; - if (!isFlagged[x]?.[y - 1]) if (reveal(x, y - 1)) return; - if (!isFlagged[x]?.[y + 1]) if (reveal(x, y + 1)) return; - if (!isFlagged[x + 1]?.[y - 1]) if (reveal(x + 1, y - 1)) return; - if (!isFlagged[x + 1]?.[y]) if (reveal(x + 1, y)) return; - if (!isFlagged[x + 1]?.[y + 1]) if (reveal(x + 1, y + 1)) return; - } - } - } else if (e.button === 2 && !isRevealed[x][y]) { - flag(x, y); - } - e.preventDefault(); - }} - > - {content} -
- ); -}; diff --git a/src/Game.ts b/src/Game.ts deleted file mode 100644 index 32043d1..0000000 --- a/src/Game.ts +++ /dev/null @@ -1,144 +0,0 @@ -export class Game { - mines: boolean[][] = []; - minesCount: number = 0; - isRevealed: boolean[][] = []; - isFlagged: boolean[][] = []; - isGameOver: boolean = false; - startTime: number = Date.now(); - - constructor(width: number, height: number, mines: number) { - if (mines > width * height) { - throw new Error("Too many mines"); - } - this.minesCount = mines; - for (let i = 0; i < width; i++) { - this.mines.push(new Array(height).fill(false)); - this.isRevealed.push(new Array(height).fill(false)); - this.isFlagged.push(new Array(height).fill(false)); - } - while (mines > 0) { - const x = Math.floor(Math.random() * width); - const y = Math.floor(Math.random() * height); - if (!this.mines[x][y]) { - this.mines[x][y] = true; - mines--; - } - } - } - - getWidth() { - return this.mines.length; - } - - getHeight() { - return this.mines[0].length; - } - - isMine(x: number, y: number) { - return this.mines[x][y]; - } - - flag(x: number, y: number) { - this.isFlagged[x][y] = !this.isFlagged[x][y]; - } - - isValid(x: number, y: number) { - return x >= 0 && x < this.getWidth() && y >= 0 && y < this.getHeight(); - } - - reveal(x: number, y: number) { - if (!this.isValid(x, y)) return; - this.isRevealed[x][y] = true; - if (this.isMine(x, y)) { - this.isGameOver = true; - return; - } - const value = this.getValue(x, y); - if (value === 0) { - if (this.isValid(x - 1, y - 1) && !this.isRevealed[x - 1]?.[y - 1]) - this.reveal(x - 1, y - 1); - if (this.isValid(x, y - 1) && !this.isRevealed[x]?.[y - 1]) - this.reveal(x, y - 1); - if (this.isValid(x + 1, y - 1) && !this.isRevealed[x + 1]?.[y - 1]) - this.reveal(x + 1, y - 1); - if (this.isValid(x - 1, y) && !this.isRevealed[x - 1]?.[y]) - this.reveal(x - 1, y); - if (this.isValid(x + 1, y) && !this.isRevealed[x + 1]?.[y]) - this.reveal(x + 1, y); - if (this.isValid(x - 1, y + 1) && !this.isRevealed[x - 1]?.[y + 1]) - this.reveal(x - 1, y + 1); - if (this.isValid(x, y + 1) && !this.isRevealed[x]?.[y + 1]) - this.reveal(x, y + 1); - if (this.isValid(x + 1, y + 1) && !this.isRevealed[x + 1]?.[y + 1]) - this.reveal(x + 1, y + 1); - } - } - - getHasWon() { - if (this.isGameOver) { - return false; - } - for (let i = 0; i < this.getWidth(); i++) { - for (let j = 0; j < this.getHeight(); j++) { - if (!this.isRevealed[i][j] && !this.isFlagged[i][j]) { - return false; - } - if (this.isMine(i, j) && !this.isFlagged[i][j]) { - return false; - } - } - } - return true; - } - - getMinesLeft() { - return this.minesCount - this.isFlagged.flat().filter((m) => m).length; - } - - getNeighborFlags(x: number, y: number) { - const neighbors = [ - this.isFlagged[x - 1]?.[y - 1], - this.isFlagged[x]?.[y - 1], - this.isFlagged[x + 1]?.[y - 1], - this.isFlagged[x - 1]?.[y], - this.isFlagged[x + 1]?.[y], - this.isFlagged[x - 1]?.[y + 1], - this.isFlagged[x]?.[y + 1], - this.isFlagged[x + 1]?.[y + 1], - ]; - return neighbors; - } - - getNeighborMines(x: number, y: number) { - const neighbors = [ - this.mines[x - 1]?.[y - 1], - this.mines[x]?.[y - 1], - this.mines[x + 1]?.[y - 1], - this.mines[x - 1]?.[y], - this.mines[x + 1]?.[y], - this.mines[x - 1]?.[y + 1], - this.mines[x]?.[y + 1], - this.mines[x + 1]?.[y + 1], - ]; - return neighbors; - } - - getValue(x: number, y: number) { - const neighbors = this.getNeighborMines(x, y); - const mines = neighbors.filter((n) => n).length; - return mines; - } - - quickStart() { - for (let i = 0; i < this.getWidth(); i++) { - for (let j = 0; j < this.getHeight(); j++) { - const value = this.getValue(i, j); - const isMine = this.isMine(i, j); - if (value === 0 && !isMine) { - this.reveal(i, j); - return; - } - } - } - } -} diff --git a/src/GameState.ts b/src/GameState.ts deleted file mode 100644 index 6a6d132..0000000 --- a/src/GameState.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { create } from "zustand"; -import { newGame } from "./ws"; - -interface GameState { - showFeed: boolean; - mines: boolean[][]; - minesCount: number; - isRevealed: boolean[][]; - isFlagged: boolean[][]; - isGameOver: boolean; - startTime: number; - width: number; - height: number; - stage: number; - name: string; - - flag: (x: number, y: number) => void; - reveal: (x: number, y: number) => boolean; - getValue: (x: number, y: number) => number; - getHasWon: () => boolean; - getMinesLeft: () => number; - quickStart: () => void; - isValid: (x: number, y: number) => boolean; - resetGame: (width: number, height: number, mines: number) => void; - isMine: (x: number, y: number) => boolean; - getNeighborMines: (x: number, y: number) => boolean[]; - getNeighborFlags: (x: number, y: number) => boolean[]; - getWidth: () => number; - getHeight: () => number; - isTouched: () => boolean; - triggerPostGame: () => boolean; - expandBoard: () => void; - setName: (name: string) => void; - setShowFeed: (showFeed: boolean) => void; -} - -const useGameStore = create((set, get) => ({ - mines: [[]], - minesCount: 0, - isRevealed: [[]], - isFlagged: [[]], - isGameOver: false, - startTime: Date.now(), - width: 0, - height: 0, - stage: 1, - name: localStorage.getItem("name") || "No Name", - showFeed: !localStorage.getItem("showFeed") - ? true - : localStorage.getItem("showFeed") === "true", - - flag: (x, y) => { - set((state) => { - const isFlagged = [...state.isFlagged]; - isFlagged[x][y] = !isFlagged[x][y]; - return { isFlagged }; - }); - const { triggerPostGame } = get(); - triggerPostGame(); - }, - - reveal: (x, y) => { - const { mines, isRevealed, isGameOver, getValue, triggerPostGame } = get(); - if (isGameOver || !get().isValid(x, y) || isRevealed[x][y]) return false; - - const newRevealed = [...isRevealed]; - newRevealed[x][y] = true; - - if (mines[x][y]) { - set({ isGameOver: true, isRevealed: newRevealed }); - return true; - } else { - set({ isRevealed: newRevealed }); - const value = getValue(x, y); - const neighborFlagCount = get() - .getNeighborFlags(x, y) - .filter((n) => n).length; - if (value === 0 && neighborFlagCount === 0) { - const revealNeighbors = (nx: number, ny: number) => { - if (get().isValid(nx, ny) && !isRevealed[nx]?.[ny]) { - get().reveal(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); - } - } - return triggerPostGame(); - }, - - getValue: (x, y) => { - const { mines } = get(); - const neighbors = [ - mines[x - 1]?.[y - 1], - mines[x]?.[y - 1], - mines[x + 1]?.[y - 1], - mines[x - 1]?.[y], - mines[x + 1]?.[y], - mines[x - 1]?.[y + 1], - mines[x]?.[y + 1], - mines[x + 1]?.[y + 1], - ]; - return neighbors.filter((n) => n).length; - }, - - getHasWon: () => { - const { mines, isRevealed, isFlagged, isGameOver, width, height } = get(); - if (isGameOver) return false; - - for (let i = 0; i < width; i++) { - for (let j = 0; j < height; j++) { - if (!isRevealed[i][j] && !isFlagged[i][j]) return false; - if (mines[i][j] && !isFlagged[i][j]) return false; - if (isFlagged[i][j] && !mines[i][j]) return false; - } - } - - return true; - }, - - getMinesLeft: () => { - const { minesCount, isFlagged } = get(); - return minesCount - isFlagged.flat().filter((flag) => flag).length; - }, - - quickStart: () => { - const { width, height, mines, getValue, reveal } = get(); - for (let i = 0; i < width; i++) { - for (let j = 0; j < height; j++) { - const value = getValue(i, j); - if (value === 0 && !mines[i][j]) { - reveal(i, j); - return; - } - } - } - }, - isValid: (x: number, y: number) => { - const { width, height } = get(); - return x >= 0 && x < width && y >= 0 && y < height; - }, - resetGame: (width: number, height: number, mines: number) => { - const { name } = get(); - newGame(name); - if (mines > width * height) { - throw new Error("Too many mines"); - } - - const minesArray = Array.from({ length: width }, () => - new Array(height).fill(false), - ); - const isRevealedArray = Array.from({ length: width }, () => - new Array(height).fill(false), - ); - const isFlaggedArray = Array.from({ length: width }, () => - new Array(height).fill(false), - ); - - let remainingMines = mines; - while (remainingMines > 0) { - const x = Math.floor(Math.random() * width); - const y = Math.floor(Math.random() * height); - if (!minesArray[x][y]) { - minesArray[x][y] = true; - remainingMines--; - } - } - - set({ - width, - height, - mines: minesArray, - isRevealed: isRevealedArray, - isFlagged: isFlaggedArray, - minesCount: mines, - isGameOver: false, - startTime: Date.now(), - stage: 1, - }); - }, - isMine: (x: number, y: number) => { - const { mines } = get(); - return mines[x][y]; - }, - getNeighborMines: (x: number, y: number) => { - const { mines } = get(); - const neighbors = [ - mines[x - 1]?.[y - 1], - mines[x]?.[y - 1], - mines[x + 1]?.[y - 1], - mines[x - 1]?.[y], - mines[x + 1]?.[y], - mines[x - 1]?.[y + 1], - mines[x]?.[y + 1], - mines[x + 1]?.[y + 1], - ]; - return neighbors; - }, - getNeighborFlags: (x: number, y: number) => { - const { isFlagged } = get(); - 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; - }, - getWidth: () => { - const { width } = get(); - return width; - }, - getHeight: () => { - const { height } = get(); - return height; - }, - isTouched: () => { - const { isRevealed, isFlagged } = get(); - return ( - isRevealed.flat().filter((flag) => flag).length > 0 || - isFlagged.flat().filter((flag) => flag).length > 0 - ); - }, - triggerPostGame: () => { - const { getHasWon, expandBoard } = get(); - if (getHasWon()) { - expandBoard(); - return true; - } - return false; - }, - expandBoard: () => { - const { width, height, stage, mines, isFlagged, isRevealed } = get(); - let dir = stage % 2 === 0 ? "down" : "right"; - if (stage > 11) { - dir = "down"; - } - // Expand the board by the current board size 8x8 -> 16x8 - if (dir === "down") { - const newHeight = Math.floor(height * 1.5); - const newWidth = width; - const newMinesCount = Math.floor( - width * height * 0.5 * (0.2 + 0.003 * stage), - ); - // expand mines array - const newMines = Array.from({ length: newWidth }, () => - new Array(newHeight).fill(false), - ); - const newIsRevealed = Array.from({ length: newWidth }, () => - new Array(newHeight).fill(false), - ); - const newIsFlagged = Array.from({ length: newWidth }, () => - new Array(newHeight).fill(false), - ); - for (let i = 0; i < newWidth; i++) { - for (let j = 0; j < newHeight; j++) { - const x = i; - const y = j; - if (mines[x]?.[y]) { - newMines[i][j] = true; - } - if (isRevealed[x]?.[y]) { - newIsRevealed[i][j] = true; - } - if (isFlagged[x]?.[y]) { - newIsFlagged[i][j] = true; - } - } - } - // generate new mines - let remainingMines = newMinesCount; - while (remainingMines > 0) { - const x = Math.floor(Math.random() * width); - const y = height + Math.floor(Math.random() * (newHeight - height)); - if (!newMines[x][y]) { - newMines[x][y] = true; - remainingMines--; - } - } - set({ - width: newWidth, - height: newHeight, - mines: newMines, - minesCount: newMinesCount, - stage: stage + 1, - isRevealed: newIsRevealed, - isFlagged: newIsFlagged, - }); - } - if (dir === "right") { - const newWidth = Math.floor(width * 1.5); - const newHeight = height; - const newMinesCount = Math.floor( - width * height * 0.5 * (0.2 + 0.003 * stage), - ); - // expand mines array - const newMines = Array.from({ length: newWidth }, () => - new Array(newHeight).fill(false), - ); - const newIsRevealed = Array.from({ length: newWidth }, () => - new Array(newHeight).fill(false), - ); - const newIsFlagged = Array.from({ length: newWidth }, () => - new Array(newHeight).fill(false), - ); - for (let i = 0; i < newWidth; i++) { - for (let j = 0; j < newHeight; j++) { - const x = i; - const y = j; - if (mines[x]?.[y]) { - newMines[i][j] = true; - } - if (isRevealed[x]?.[y]) { - newIsRevealed[i][j] = true; - } - if (isFlagged[x]?.[y]) { - newIsFlagged[i][j] = true; - } - } - } - // generate new mines - let remainingMines = newMinesCount; - while (remainingMines > 0) { - const x = width + Math.floor(Math.random() * (newWidth - width)); - const y = Math.floor(Math.random() * height); - if (!newMines[x][y]) { - newMines[x][y] = true; - remainingMines--; - } - } - set({ - width: newWidth, - height: newHeight, - mines: newMines, - minesCount: newMinesCount, - stage: stage + 1, - isRevealed: newIsRevealed, - isFlagged: newIsFlagged, - }); - } - const newMinesCount = get() - .mines.flat() - .filter((m) => m).length; - set({ minesCount: newMinesCount }); - }, - setName: (name) => { - localStorage.setItem("name", name); - set({ name }); - }, - setShowFeed: (showFeed) => { - localStorage.setItem("showFeed", showFeed.toString()); - set({ showFeed }); - }, -})); - -export default useGameStore; diff --git a/src/Options.tsx b/src/Options.tsx deleted file mode 100644 index ccfc56e..0000000 --- a/src/Options.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect, useState } from "react"; -import useGameStore from "./GameState"; - -const presets = { - Easy: { width: 10, height: 10, mines: 20 }, - Medium: { width: 16, height: 16, mines: 32 }, - Expert: { width: 30, height: 16, mines: 99 }, - "Max Mode": { width: 40, height: 40, mines: 350 }, -} as const; - -function Options() { - const game = useGameStore(); - const [width, setWidth] = useState(16); - const [height, setHeight] = useState(16); - const [mines, setMines] = useState(32); - const [showOptions, setShowOptions] = useState(false); - - useEffect(() => { - const fixWidth = Math.min(40, width); - const fixHeight = Math.min(40, height); - setWidth(fixWidth); - setHeight(fixHeight); - }, [width, height]); - - useEffect(() => { - if (!game.isTouched()) { - if (width <= 0 || height <= 0 || mines <= 0) { - return; - } - game.resetGame(width, height, mines); - } - }, [width, height, mines, game]); - - return ( -
- - {showOptions && ( - <> -

- Presets:{" "} - {(Object.keys(presets) as Array).map( - (key) => ( - - ), - )} -

-

- Width:{" "} - setWidth(Number(e.target.value))} - /> -

-

- Height:{" "} - setHeight(Number(e.target.value))} - /> -

-

- Mines:{" "} - setMines(Number(e.target.value))} - /> -

- - )} - -
- ); -} - -export default Options; diff --git a/src/Timer.tsx b/src/Timer.tsx deleted file mode 100644 index 8359db3..0000000 --- a/src/Timer.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect, useState } from "react"; -import Confetti from "react-confetti-boom"; -import useGameStore from "./GameState"; - -const emoteByStage = [ - "😐", - "😐", - "🙂", - "🤔", - "👀", - "😎", - "💀", - "🤯", - "🐐", - "⚡", - "🦸", - "🔥", - "💥", - "🐶", - "🦉", - "🚀", - "👾", -]; - -const Timer = () => { - const game = useGameStore(); - const [currentTime, setCurrentTime] = useState(Date.now()); - - useEffect(() => { - if (game.isGameOver || game.getHasWon()) { - if (game.stage === 1) return; - const name = game.name; - if (name) { - fetch("https://mb.gordon.business/submit", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - user: name, - stage: game.stage, - }), - }); - } - return; - } - const interval = setInterval(() => { - setCurrentTime(Date.now()); - }, 1000); - - return () => clearInterval(interval); - }, [game, game.isGameOver]); - - return ( - <> -
-

- Stage: {game.stage} ({game.getWidth()}x{game.getHeight()}) -

-
-
-

{game.getMinesLeft()}

-

- {game.getHasWon() - ? "😎" - : game.isGameOver - ? "😢" - : emoteByStage[game.stage] || "😐"} - {game.stage > 1 && ( - - )} -

-

- {Math.max( - 0, - Math.floor((currentTime - (game.startTime || 0)) / 1000), - )} -

-
- - ); -}; - -export default Timer; diff --git a/src/assets/themes/cyber-punk/1.png b/src/assets/themes/retro-wave/1.png similarity index 100% rename from src/assets/themes/cyber-punk/1.png rename to src/assets/themes/retro-wave/1.png diff --git a/src/assets/themes/cyber-punk/2.png b/src/assets/themes/retro-wave/2.png similarity index 100% rename from src/assets/themes/cyber-punk/2.png rename to src/assets/themes/retro-wave/2.png diff --git a/src/assets/themes/cyber-punk/3.png b/src/assets/themes/retro-wave/3.png similarity index 100% rename from src/assets/themes/cyber-punk/3.png rename to src/assets/themes/retro-wave/3.png diff --git a/src/assets/themes/cyber-punk/4.png b/src/assets/themes/retro-wave/4.png similarity index 100% rename from src/assets/themes/cyber-punk/4.png rename to src/assets/themes/retro-wave/4.png diff --git a/src/assets/themes/cyber-punk/5.png b/src/assets/themes/retro-wave/5.png similarity index 100% rename from src/assets/themes/cyber-punk/5.png rename to src/assets/themes/retro-wave/5.png diff --git a/src/assets/themes/cyber-punk/6.png b/src/assets/themes/retro-wave/6.png similarity index 100% rename from src/assets/themes/cyber-punk/6.png rename to src/assets/themes/retro-wave/6.png diff --git a/src/assets/themes/cyber-punk/7.png b/src/assets/themes/retro-wave/7.png similarity index 100% rename from src/assets/themes/cyber-punk/7.png rename to src/assets/themes/retro-wave/7.png diff --git a/src/assets/themes/cyber-punk/8.png b/src/assets/themes/retro-wave/8.png similarity index 100% rename from src/assets/themes/cyber-punk/8.png rename to src/assets/themes/retro-wave/8.png diff --git a/src/assets/themes/cyber-punk/flag.png b/src/assets/themes/retro-wave/flag.png similarity index 100% rename from src/assets/themes/cyber-punk/flag.png rename to src/assets/themes/retro-wave/flag.png diff --git a/src/assets/themes/cyber-punk/last-pos.png b/src/assets/themes/retro-wave/last-pos.png similarity index 100% rename from src/assets/themes/cyber-punk/last-pos.png rename to src/assets/themes/retro-wave/last-pos.png diff --git a/src/assets/themes/cyber-punk/mine.png b/src/assets/themes/retro-wave/mine.png similarity index 100% rename from src/assets/themes/cyber-punk/mine.png rename to src/assets/themes/retro-wave/mine.png diff --git a/src/assets/themes/cyber-punk/question-mark.png b/src/assets/themes/retro-wave/question-mark.png similarity index 100% rename from src/assets/themes/cyber-punk/question-mark.png rename to src/assets/themes/retro-wave/question-mark.png diff --git a/src/assets/themes/cyber-punk/cyber-punk.aseprite b/src/assets/themes/retro-wave/retro-wave.aseprite similarity index 100% rename from src/assets/themes/cyber-punk/cyber-punk.aseprite rename to src/assets/themes/retro-wave/retro-wave.aseprite diff --git a/src/assets/themes/cyber-punk/revealed.png b/src/assets/themes/retro-wave/revealed.png similarity index 100% rename from src/assets/themes/cyber-punk/revealed.png rename to src/assets/themes/retro-wave/revealed.png diff --git a/src/assets/themes/cyber-punk/tile.png b/src/assets/themes/retro-wave/tile.png similarity index 100% rename from src/assets/themes/cyber-punk/tile.png rename to src/assets/themes/retro-wave/tile.png diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 6deab0e..eaf831e 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -91,8 +91,7 @@ const Board: React.FC = (props) => { setTimeout(() => { if (viewportRef.current) onViewportChange(viewportRef.current); }, 200); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [game.width, game.height]); + }, [game.width, game.height, onViewportChange]); useEffect(() => { if (!ref.current) return; setWidth(ref.current.clientWidth); diff --git a/src/components/LeaderboardButton.tsx b/src/components/LeaderboardButton.tsx index c93599c..65995c0 100644 --- a/src/components/LeaderboardButton.tsx +++ b/src/components/LeaderboardButton.tsx @@ -1,3 +1,4 @@ +import { Fragment } from "react"; import { useWSQuery } from "../hooks"; import { Button } from "./Button"; import { @@ -28,7 +29,7 @@ const LeaderboardButton = ({ Leaderboard
{leaderboard?.map((_, i) => ( - <> +
{i + 1}.
{leaderboard?.[i]?.user ?? "No User"} @@ -36,7 +37,7 @@ const LeaderboardButton = ({
Stage {leaderboard?.[i]?.stage ?? 0}
- + ))}
diff --git a/src/components/PastMatch.tsx b/src/components/PastMatch.tsx new file mode 100644 index 0000000..5c60d41 --- /dev/null +++ b/src/components/PastMatch.tsx @@ -0,0 +1,37 @@ +import { ServerGame } from "../../shared/game"; +import { formatRelativeTime, formatTimeSpan } from "../../shared/time"; +import { Button } from "./Button"; + +interface PastMatchProps { + game: ServerGame; +} + +const PastMatch = ({ game }: PastMatchProps) => { + return ( +
+
+
+
Endless
+
+ {formatRelativeTime(game.finished)} +
+
+
+
Stage {game.stage}
+
+ Mines Remaining:{" "} + {game.minesCount - game.isFlagged.flat().filter((f) => f).length} +
+
+
+
Duration: {formatTimeSpan(game.finished - game.started)}
+
+
+ +
+
+
+ ); +}; + +export default PastMatch; diff --git a/src/components/Tag.tsx b/src/components/Tag.tsx index 4d86321..43bcd08 100644 --- a/src/components/Tag.tsx +++ b/src/components/Tag.tsx @@ -2,29 +2,32 @@ import { cva, type VariantProps } from "class-variance-authority"; import { forwardRef } from "react"; import { cn } from "../lib/utils"; -const tagVariants = cva("font-semibold py-2 px-4 rounded-md flex gap-2", { - variants: { - variant: { - default: "bg-gray-900 text-white/95", - ghost: "bg-transparent text-white/95 hover:bg-white/05", - outline: - "bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1", - outline2: - "bg-transparent [background:var(--bg-brand)] [-webkit-text-fill-color:transparent] [-webkit-background-clip:text!important] bg-white/05 border-primary border-1", - primary: - "[background:var(--bg-brand)] text-white/95 hover:bg-white/05 hover:animate-gradientmove", +const tagVariants = cva( + "font-semibold py-2 px-4 rounded-md flex whitespace-pre", + { + variants: { + variant: { + default: "bg-gray-900 text-white/95", + ghost: "bg-transparent text-white/95 hover:bg-white/05", + outline: + "bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1", + outline2: + "bg-transparent [background:var(--bg-brand)] [-webkit-text-fill-color:transparent] [-webkit-background-clip:text!important] bg-white/05 border-primary border-1", + primary: + "[background:var(--bg-brand)] text-white/95 hover:bg-white/05 hover:animate-gradientmove", + }, + size: { + default: "h-10 py-2 px-4", + sm: "h-7 py-2 px-2 rounded-md text-xs", + lg: "h-11 px-8 rounded-md", + }, }, - size: { - default: "h-10 py-2 px-4", - sm: "h-7 py-2 px-2 rounded-md text-xs", - lg: "h-11 px-8 rounded-md", + defaultVariants: { + variant: "default", + size: "default", }, }, - defaultVariants: { - variant: "default", - size: "default", - }, -}); +); export type ButtonProps = React.HTMLAttributes & VariantProps; diff --git a/src/hooks.ts b/src/hooks.ts index 945ec6d..0ecb0ae 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,5 +1,6 @@ import { keepPreviousData, + useInfiniteQuery, useMutation, UseMutationResult, useQuery, @@ -73,3 +74,26 @@ export const useWSInvalidation = < queryClient.invalidateQueries({ queryKey: [action] }); }; }; + +export const useInfiniteGames = (user: string | null | undefined) => { + const { data, hasNextPage, fetchNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: ["game.getGames", { user }], + enabled: !!user, + queryFn: async ({ pageParam }) => { + const result = await wsClient.dispatch("game.getGames", { + user: user!, + page: pageParam, + }); + return result; + }, + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 0, + }); + return { + data, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + }; +}; diff --git a/src/main.tsx b/src/main.tsx index 72f83b4..3036607 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; -import { connectWS } from "./ws.ts"; -import { Toaster } from "react-hot-toast"; import { QueryClientProvider } from "@tanstack/react-query"; import Shell from "./Shell.tsx"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; @@ -12,8 +10,7 @@ import Endless from "./views/endless/Endless.tsx"; import { queryClient } from "./queryClient.ts"; import Home from "./views/home/Home.tsx"; import Settings from "./views/settings/Settings.tsx"; - -connectWS(); +import MatchHistory from "./views/match-history/MatchHistory.tsx"; const setup = async () => { const token = localStorage.getItem("loginToken"); @@ -33,22 +30,14 @@ setup().then(() => { createRoot(document.getElementById("root")!).render( - - ( -

Comming Soon

- )} - /> +
- {/* */}
- {/* */}
, diff --git a/src/themes/cyber-punk.ts b/src/themes/cyber-punk.ts deleted file mode 100644 index f73375c..0000000 --- a/src/themes/cyber-punk.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Theme } from "./Theme"; - -export const cyberPunkTheme: Theme = { - size: 32, - mine: () => import("../assets/themes/cyber-punk/mine.png"), - tile: () => import("../assets/themes/cyber-punk/tile.png"), - revealed: () => import("../assets/themes/cyber-punk/revealed.png"), - flag: () => import("../assets/themes/cyber-punk/flag.png"), - questionMark: () => import("../assets/themes/cyber-punk/question-mark.png"), - lastPos: () => import("../assets/themes/cyber-punk/last-pos.png"), - 1: () => import("../assets/themes/cyber-punk/1.png"), - 2: () => import("../assets/themes/cyber-punk/2.png"), - 3: () => import("../assets/themes/cyber-punk/3.png"), - 4: () => import("../assets/themes/cyber-punk/4.png"), - 5: () => import("../assets/themes/cyber-punk/5.png"), - 6: () => import("../assets/themes/cyber-punk/6.png"), - 7: () => import("../assets/themes/cyber-punk/7.png"), - 8: () => import("../assets/themes/cyber-punk/8.png"), -}; diff --git a/src/themes/index.ts b/src/themes/index.ts new file mode 100644 index 0000000..571a68d --- /dev/null +++ b/src/themes/index.ts @@ -0,0 +1,145 @@ +import { basicTheme } from "./basic"; +import { blackAndWhiteTheme } from "./black-and-white"; +import { catsTheme } from "./cats"; +import { defaultTheme } from "./default"; +import { dinoTheme } from "./dinos"; +import { eldenRingTheme } from "./elden-ring"; +import { flowersTheme } from "./flowers"; +import { janitorTreshTheme } from "./janitor-tresh"; +import { leagueTeemoTheme } from "./league-teemo"; +import { leagueZiggsTheme } from "./league-ziggs"; +import { mineDogsTheme } from "./mine-dogs"; +import { minecraftNetherTheme } from "./minecraft-nether"; +import { minecraftOverworldTheme } from "./minecraft-overworld"; +import { retroWaveTheme } from "./retro-wave"; +import { romanceTheme } from "./romance"; +import { techiesDireTheme } from "./techies-dire"; +import { techiesRadiantTheme } from "./techies-radiant"; +import { Theme } from "./Theme"; +import { tronBlueTheme } from "./tron-blue"; +import { tronOrangeTheme } from "./tron-orange"; + +interface ThemeEntry { + name: string; + tags: string[]; + /** dont't ever change this! */ + id: string; + theme: Theme; +} + +export const themes: ThemeEntry[] = [ + { + name: "Default", + tags: ["Simple"], + id: "default", + theme: defaultTheme, + }, + { + name: "Basic", + tags: ["Simple"], + id: "basic", + theme: basicTheme, + }, + { + name: "Black and White", + tags: ["Simple", "Monochrome"], + id: "black-and-white", + theme: blackAndWhiteTheme, + }, + { + name: "Cats", + tags: ["Animals"], + id: "cats", + theme: catsTheme, + }, + { + name: "Retro Wave", + tags: ["Retro", "High Contrast"], + id: "retro-wave", + theme: retroWaveTheme, + }, + { + name: "Dinos", + tags: ["Animals"], + id: "dinos", + theme: dinoTheme, + }, + { + name: "Elden Ring", + tags: ["Video Games"], + id: "elden-ring", + theme: eldenRingTheme, + }, + { + name: "Flowers", + tags: ["No Numbers"], + id: "flowers", + theme: flowersTheme, + }, + { + name: "Janitor Tresh", + tags: ["Video Games"], + id: "janitor-tresh", + theme: janitorTreshTheme, + }, + { + name: "Teemo", + tags: ["Video Games"], + id: "teemo", + theme: leagueTeemoTheme, + }, + { + name: "Ziggs", + tags: ["Video Games"], + id: "ziggs", + theme: leagueZiggsTheme, + }, + { + name: "Mine Dogs", + tags: ["Animals"], + id: "mine-dogs", + theme: mineDogsTheme, + }, + { + name: "Minecraft Nether", + tags: ["Video Games"], + id: "minecraft-nether", + theme: minecraftNetherTheme, + }, + { + name: "Minecraft", + tags: ["Video Games"], + id: "minecraft-overworld", + theme: minecraftOverworldTheme, + }, + { + name: "Romance", + tags: [], + id: "romance", + theme: romanceTheme, + }, + { + name: "Techies Dire", + tags: ["Video Games"], + id: "techies-dire", + theme: techiesDireTheme, + }, + { + name: "Techies Radiant", + tags: ["Video Games"], + id: "techies-radiant", + theme: techiesRadiantTheme, + }, + { + name: "Tron Blue", + tags: ["Video Games"], + id: "tron-blue", + theme: tronBlueTheme, + }, + { + name: "Tron Orange", + tags: ["Video Games"], + id: "tron-orange", + theme: tronOrangeTheme, + }, +]; diff --git a/src/themes/retro-wave.ts b/src/themes/retro-wave.ts new file mode 100644 index 0000000..8b859f9 --- /dev/null +++ b/src/themes/retro-wave.ts @@ -0,0 +1,19 @@ +import { Theme } from "./Theme"; + +export const retroWaveTheme: Theme = { + size: 32, + mine: () => import("../assets/themes/retro-wave/mine.png"), + tile: () => import("../assets/themes/retro-wave/tile.png"), + revealed: () => import("../assets/themes/retro-wave/revealed.png"), + flag: () => import("../assets/themes/retro-wave/flag.png"), + questionMark: () => import("../assets/themes/retro-wave/question-mark.png"), + lastPos: () => import("../assets/themes/retro-wave/last-pos.png"), + 1: () => import("../assets/themes/retro-wave/1.png"), + 2: () => import("../assets/themes/retro-wave/2.png"), + 3: () => import("../assets/themes/retro-wave/3.png"), + 4: () => import("../assets/themes/retro-wave/4.png"), + 5: () => import("../assets/themes/retro-wave/5.png"), + 6: () => import("../assets/themes/retro-wave/6.png"), + 7: () => import("../assets/themes/retro-wave/7.png"), + 8: () => import("../assets/themes/retro-wave/8.png"), +}; diff --git a/src/views/endless/Endless.tsx b/src/views/endless/Endless.tsx index 2acb9a8..b1c7286 100644 --- a/src/views/endless/Endless.tsx +++ b/src/views/endless/Endless.tsx @@ -5,7 +5,7 @@ import { useAtom } from "jotai"; import { gameIdAtom } from "../../atoms"; import { Button } from "../../components/Button"; import LeaderboardButton from "../../components/LeaderboardButton"; -import { useEffect } from "react"; +import { Fragment, useEffect } from "react"; const Endless = () => { const [gameId, setGameId] = useAtom(gameIdAtom); @@ -22,7 +22,6 @@ const Endless = () => { setGameId(undefined); }; }, [setGameId]); - console.log("set", setGameId); return game ? ( <> @@ -81,10 +80,10 @@ const Endless = () => {

How to play

- Endless minesweeper is just like regular minesweeper but you can't - win. Every time you clear the field you just proceed to the next - stage. Try to get as far as possible. You might be rewarded for great - performance! + Endless minesweeper is just like regular minesweeper but you + can't win. Every time you clear the field you just proceed to the + next stage. Try to get as far as possible. You might be rewarded for + great performance!

Good luck! @@ -96,7 +95,7 @@ const Endless = () => {

{new Array(10).fill(0).map((_, i) => ( - <> +
{i + 1}.
{leaderboard?.[i]?.user ?? "No User"} @@ -104,7 +103,7 @@ const Endless = () => {
Stage {leaderboard?.[i]?.stage ?? 0}
- + ))}
diff --git a/src/views/home/Home.tsx b/src/views/home/Home.tsx index 5d6072f..d444161 100644 --- a/src/views/home/Home.tsx +++ b/src/views/home/Home.tsx @@ -13,23 +13,33 @@ import { Link } from "wouter"; const Home = () => { const { data: userCount } = useWSQuery("user.getUserCount", null); + const { data: gameCount } = useWSQuery("game.getTotalGamesPlayed", {}); const { data: username } = useWSQuery("user.getSelf", null); - const from = (userCount ?? 0) / 2; - const to = userCount ?? 0; + const usersFrom = (userCount ?? 0) / 2; + const usersTo = userCount ?? 0; + const gamesFrom = (gameCount ?? 0) / 2; + const gamesTo = gameCount ?? 0; - const count = useMotionValue(from); - const rounded = useTransform(count, (latest) => Math.round(latest)); + const usersCount = useMotionValue(usersFrom); + const roundedUsers = useTransform(usersCount, (latest) => Math.round(latest)); + const gamesCount = useMotionValue(gamesFrom); + const roundedGames = useTransform(gamesCount, (latest) => Math.round(latest)); useEffect(() => { - const controls = animate(count, to, { duration: 1.5 }); + const controls = animate(usersCount, usersTo, { duration: 1.5 }); return controls.stop; - }, [count, to]); + }, [usersCount, usersTo]); + useEffect(() => { + const controls = animate(gamesCount, gamesTo, { duration: 1.5 }); + return controls.stop; + }, [gamesCount, gamesTo]); return (
- {rounded} Users + {roundedUsers} Users Played{" "} + {roundedGames} Games

Business Minesweeper diff --git a/src/views/match-history/MatchHistory.tsx b/src/views/match-history/MatchHistory.tsx new file mode 100644 index 0000000..ab475eb --- /dev/null +++ b/src/views/match-history/MatchHistory.tsx @@ -0,0 +1,46 @@ +import { useInfiniteGames, useWSQuery } from "../../hooks"; +import PastMatch from "../../components/PastMatch"; +import { useEffect, useRef } from "react"; + +const MatchHistory = () => { + const { data: user } = useWSQuery("user.getSelf", null); + const { data, hasNextPage, fetchNextPage, isFetchingNextPage } = + useInfiniteGames(user); + const loadMoreRef = useRef(null); + useEffect(() => { + const intersectionObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !isFetchingNextPage) { + fetchNextPage(); + } + }); + }); + const target = loadMoreRef.current; + if (target) { + intersectionObserver.observe(target); + } + return () => { + if (target) { + intersectionObserver.unobserve(target); + } + }; + }, [fetchNextPage, isFetchingNextPage, hasNextPage]); + + return ( +
+ {data?.pages.map((page) => + page.data.map((game) => ), + )} + {hasNextPage && ( +
+ Loading... +
+ )} +
+ ); +}; + +export default MatchHistory; diff --git a/src/ws.ts b/src/ws.ts deleted file mode 100644 index ffa0dec..0000000 --- a/src/ws.ts +++ /dev/null @@ -1,52 +0,0 @@ -import toast from "react-hot-toast"; -import useGameStore from "./GameState"; - -let ws: WebSocket; - -export const connectWS = () => { - ws = new WebSocket("wss://mb.gordon.business/ws"); - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - const name = localStorage.getItem("name"); - if (data.user === name) { - return; - } - if (!useGameStore.getState().showFeed) return; - switch (data.type) { - case "new": - toast(data.user + " started a new game", { - icon: "🚀", - style: { - borderRadius: "10px", - background: "#333", - color: "#fff", - }, - }); - break; - case "loss": - toast("Game over by " + data.user + " stage " + data.stage, { - icon: "😢", - style: { - borderRadius: "10px", - background: "#333", - color: "#fff", - }, - }); - break; - } - }; -}; - -export const newGame = (user: string) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "new", user })); - } else { - setTimeout(() => { - newGame(user); - }, 100); - } -}; - -export const loseGame = (user: string, stage: number) => { - ws.send(JSON.stringify({ type: "loss", user, stage })); -}; diff --git a/src/wsClient.ts b/src/wsClient.ts index 2571ed5..08b72ce 100644 --- a/src/wsClient.ts +++ b/src/wsClient.ts @@ -33,7 +33,9 @@ const createWSClient = () => { queryKey: ["scoreboard.getScoreBoard", 10], }); } - console.log("Received message", data); + if (import.meta.env.DEV) { + console.log("Received message", data); + } }); const dispatch = async <