From 5e259d73e859c0713ae257add5409db450c036e6 Mon Sep 17 00:00:00 2001 From: MasterGordon Date: Sat, 12 Oct 2024 00:52:20 +0200 Subject: [PATCH] added match history and improved stats --- README.md | 28 +- backend/controller/gameController.ts | 30 ++ backend/repositories/gameRepository.test.ts | 22 +- backend/repositories/gameRepository.ts | 21 +- backend/repositories/scoreRepository.test.ts | 6 +- bun.lockb | Bin 184754 -> 223605 bytes eslint.config.js | 63 +-- package.json | 65 ++-- shared/game.test.ts | 21 +- shared/time.ts | 60 +++ src/App.tsx | 130 ------- src/Button.tsx | 123 ------ src/Game.ts | 144 ------- src/GameState.ts | 367 ------------------ src/Options.tsx | 96 ----- src/Timer.tsx | 92 ----- .../themes/{cyber-punk => retro-wave}/1.png | Bin .../themes/{cyber-punk => retro-wave}/2.png | Bin .../themes/{cyber-punk => retro-wave}/3.png | Bin .../themes/{cyber-punk => retro-wave}/4.png | Bin .../themes/{cyber-punk => retro-wave}/5.png | Bin .../themes/{cyber-punk => retro-wave}/6.png | Bin .../themes/{cyber-punk => retro-wave}/7.png | Bin .../themes/{cyber-punk => retro-wave}/8.png | Bin .../{cyber-punk => retro-wave}/flag.png | Bin .../{cyber-punk => retro-wave}/last-pos.png | Bin .../{cyber-punk => retro-wave}/mine.png | Bin .../question-mark.png | Bin .../retro-wave.aseprite} | Bin .../{cyber-punk => retro-wave}/revealed.png | Bin .../{cyber-punk => retro-wave}/tile.png | Bin src/components/Board.tsx | 3 +- src/components/LeaderboardButton.tsx | 5 +- src/components/PastMatch.tsx | 37 ++ src/components/Tag.tsx | 43 +- src/hooks.ts | 24 ++ src/main.tsx | 15 +- src/themes/cyber-punk.ts | 19 - src/themes/index.ts | 145 +++++++ src/themes/retro-wave.ts | 19 + src/views/endless/Endless.tsx | 15 +- src/views/home/Home.tsx | 24 +- src/views/match-history/MatchHistory.tsx | 46 +++ src/ws.ts | 52 --- src/wsClient.ts | 4 +- 45 files changed, 557 insertions(+), 1162 deletions(-) create mode 100644 shared/time.ts delete mode 100644 src/App.tsx delete mode 100644 src/Button.tsx delete mode 100644 src/Game.ts delete mode 100644 src/GameState.ts delete mode 100644 src/Options.tsx delete mode 100644 src/Timer.tsx rename src/assets/themes/{cyber-punk => retro-wave}/1.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/2.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/3.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/4.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/5.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/6.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/7.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/8.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/flag.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/last-pos.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/mine.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/question-mark.png (100%) rename src/assets/themes/{cyber-punk/cyber-punk.aseprite => retro-wave/retro-wave.aseprite} (100%) rename src/assets/themes/{cyber-punk => retro-wave}/revealed.png (100%) rename src/assets/themes/{cyber-punk => retro-wave}/tile.png (100%) create mode 100644 src/components/PastMatch.tsx delete mode 100644 src/themes/cyber-punk.ts create mode 100644 src/themes/index.ts create mode 100644 src/themes/retro-wave.ts create mode 100644 src/views/match-history/MatchHistory.tsx delete mode 100644 src/ws.ts 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 6815fcd3bd77e41416da5599f9b7e95e2b09e99e..8505b9da6deb9a225f9e2e329eea0dc3ea3087a4 100755 GIT binary patch delta 69882 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 delta 45862 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 CPj*}Y 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 ( - - ); -} - -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 <