added match history and improved stats
28
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.
|
||||
|
||||

|
||||
## 🚀 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<number>`count(*)` })
|
||||
.from(Game)
|
||||
.where(and(eq(Game.user, user), not(eq(Game.finished, 0))))
|
||||
)[0].count;
|
||||
return (
|
||||
await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(Game)
|
||||
.where(not(eq(Game.finished, 0)))
|
||||
)[0].count;
|
||||
};
|
||||
|
||||
export const parseGameState = (gameState: Buffer) => {
|
||||
return decode(gameState) as ServerGame;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
ignores: ["dist/", "node_modules/"],
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
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: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/display-name": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
languageOptions: {
|
||||
...pluginReact.configs.flat.recommended.languageOptions,
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
},
|
||||
);
|
||||
];
|
||||
|
|
|
|||
65
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
130
src/App.tsx
|
|
@ -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<Score[]>([]);
|
||||
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 (
|
||||
<div className="App">
|
||||
{import.meta.env.DEV && (
|
||||
<button onClick={() => game.expandBoard()}>Expand</button>
|
||||
)}
|
||||
<div className="header">
|
||||
<div>
|
||||
<h1>
|
||||
Minesweeper Endless{" "}
|
||||
<button onClick={() => game.resetGame(4, 4, 2)}>Reset</button>
|
||||
</h1>
|
||||
<p>
|
||||
Name:{" "}
|
||||
<input
|
||||
value={game.name}
|
||||
onChange={(e) => game.setName(e.target.value)}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
Feed:{" "}
|
||||
<button
|
||||
onClick={() => game.setShowFeed(!game.showFeed)}
|
||||
style={{ padding: "0.5rem" }}
|
||||
>
|
||||
{game.showFeed ? "Shown" : "Hidden"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div className="scores">
|
||||
{scores.slice(0, 10).map((score) => (
|
||||
<p key={score.user}>
|
||||
{score.user} - {score.stage}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="game-wrapper">
|
||||
<div>
|
||||
<Timer />
|
||||
<div
|
||||
className="game-board"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${game.getWidth()}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${game.getHeight()}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{game.mines[0].map((_, y) =>
|
||||
game.mines.map((_, x) => (
|
||||
<Button key={`${x},${y}`} x={x} y={y} />
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<pre>Version: 1.1.6</pre>
|
||||
<pre>
|
||||
Made by MasterGordon -{" "}
|
||||
<a target="_blank" href="https://github.com/MasterGordon/minesweeper">
|
||||
Source Code
|
||||
</a>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
123
src/Button.tsx
|
|
@ -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<string, string> = {
|
||||
"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) ? <Bomb /> : getValue(x, y).toString();
|
||||
}
|
||||
|
||||
const attrs = useLongPress(
|
||||
() => {
|
||||
if (isRevealed[x][y]) return;
|
||||
flag(x, y);
|
||||
},
|
||||
{
|
||||
threshold: 400,
|
||||
},
|
||||
);
|
||||
|
||||
if (isFlagged[x][y]) {
|
||||
content = <Flag fill="red" />;
|
||||
}
|
||||
if (content === "0") content = "";
|
||||
if (
|
||||
import.meta.env.DEV &&
|
||||
window.location.href.includes("xray") &&
|
||||
isMine(x, y) &&
|
||||
!isFlagged[x][y]
|
||||
)
|
||||
content = <Bomb />;
|
||||
|
||||
const touchStart = useRef<number>(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mine-button"
|
||||
{...attrs}
|
||||
style={{
|
||||
background: isRevealed[x][y] ? "#444" : undefined,
|
||||
borderRight: !isRevealed[x][y] ? "3px solid black" : undefined,
|
||||
borderTop: !isRevealed[x][y] ? "3px solid #999" : undefined,
|
||||
borderLeft: !isRevealed[x][y] ? "3px solid #999" : undefined,
|
||||
borderBottom: !isRevealed[x][y] ? "3px solid black" : undefined,
|
||||
color: isRevealed[x][y]
|
||||
? colorMap[String(content)] ?? "#eee"
|
||||
: undefined,
|
||||
fontSize: Number(content) > 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}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
144
src/Game.ts
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
367
src/GameState.ts
|
|
@ -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<GameState>((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;
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<button onClick={() => setShowOptions(!showOptions)}>
|
||||
{showOptions ? "Hide" : "Show"} Options
|
||||
</button>
|
||||
{showOptions && (
|
||||
<>
|
||||
<p>
|
||||
Presets:{" "}
|
||||
{(Object.keys(presets) as Array<keyof typeof presets>).map(
|
||||
(key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
const { width, height, mines } = presets[key];
|
||||
setWidth(width);
|
||||
setHeight(height);
|
||||
setMines(mines);
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
Width:{" "}
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
Height:{" "}
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(Number(e.target.value))}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
Mines:{" "}
|
||||
<input
|
||||
type="number"
|
||||
value={mines}
|
||||
onChange={(e) => setMines(Number(e.target.value))}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
game.resetGame(width, height, mines);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Options;
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<div className="stage">
|
||||
<p>
|
||||
Stage: {game.stage} ({game.getWidth()}x{game.getHeight()})
|
||||
</p>
|
||||
</div>
|
||||
<div className="timer">
|
||||
<p style={{ width: "100px" }}>{game.getMinesLeft()}</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "2rem",
|
||||
}}
|
||||
>
|
||||
{game.getHasWon()
|
||||
? "😎"
|
||||
: game.isGameOver
|
||||
? "😢"
|
||||
: emoteByStage[game.stage] || "😐"}
|
||||
{game.stage > 1 && (
|
||||
<Confetti
|
||||
mode="boom"
|
||||
particleCount={20 * game.stage}
|
||||
key={game.stage}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<p style={{ width: "100px", textAlign: "right" }}>
|
||||
{Math.max(
|
||||
0,
|
||||
Math.floor((currentTime - (game.startTime || 0)) / 1000),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timer;
|
||||
|
Before Width: | Height: | Size: 277 B After Width: | Height: | Size: 277 B |
|
Before Width: | Height: | Size: 353 B After Width: | Height: | Size: 353 B |
|
Before Width: | Height: | Size: 425 B After Width: | Height: | Size: 425 B |
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 306 B |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 380 B |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 332 B |
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 341 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 816 B |
|
Before Width: | Height: | Size: 370 B After Width: | Height: | Size: 370 B |
|
Before Width: | Height: | Size: 156 B After Width: | Height: | Size: 156 B |
|
Before Width: | Height: | Size: 289 B After Width: | Height: | Size: 289 B |
|
|
@ -91,8 +91,7 @@ const Board: React.FC<BoardProps> = (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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Fragment } from "react";
|
||||
import { useWSQuery } from "../hooks";
|
||||
import { Button } from "./Button";
|
||||
import {
|
||||
|
|
@ -28,7 +29,7 @@ const LeaderboardButton = ({
|
|||
<DialogTitle>Leaderboard</DialogTitle>
|
||||
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||
{leaderboard?.map((_, i) => (
|
||||
<>
|
||||
<Fragment key={i}>
|
||||
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
||||
<div className="p-4 text-white/90">
|
||||
{leaderboard?.[i]?.user ?? "No User"}
|
||||
|
|
@ -36,7 +37,7 @@ const LeaderboardButton = ({
|
|||
<div className="p-4 text-white/90">
|
||||
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||
</div>
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-4 items-center w-full">
|
||||
<div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-4 p-4">
|
||||
<div className="flex-col flex">
|
||||
<div className="text-white/90 text-lg">Endless</div>
|
||||
<div className="text-white/50 text-lg">
|
||||
{formatRelativeTime(game.finished)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-white/90 text-lg">
|
||||
<div>Stage {game.stage}</div>
|
||||
<div>
|
||||
Mines Remaining:{" "}
|
||||
{game.minesCount - game.isFlagged.flat().filter((f) => f).length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-white/80 text-lg">
|
||||
<div>Duration: {formatTimeSpan(game.finished - game.started)}</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline">Show Board</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PastMatch;
|
||||
|
|
@ -2,7 +2,9 @@ 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", {
|
||||
const tagVariants = cva(
|
||||
"font-semibold py-2 px-4 rounded-md flex whitespace-pre",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-gray-900 text-white/95",
|
||||
|
|
@ -24,7 +26,8 @@ const tagVariants = cva("font-semibold py-2 px-4 rounded-md flex gap-2", {
|
|||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonProps = React.HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof tagVariants>;
|
||||
|
|
|
|||
24
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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
15
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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster position="top-right" reverseOrder={false} />
|
||||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/play" component={Endless} />
|
||||
<Route
|
||||
path="/history"
|
||||
component={() => (
|
||||
<h2 className="text-white/80 text-2xl">Comming Soon</h2>
|
||||
)}
|
||||
/>
|
||||
<Route path="/history" component={MatchHistory} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
</Switch>
|
||||
{/* <App /> */}
|
||||
</Shell>
|
||||
{/* <App /> */}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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"),
|
||||
};
|
||||
|
|
@ -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 = () => {
|
|||
</Button>
|
||||
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
|
||||
<p className="text-white/90">
|
||||
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!
|
||||
<br />
|
||||
<br />
|
||||
Good luck!
|
||||
|
|
@ -96,7 +95,7 @@ const Endless = () => {
|
|||
</h2>
|
||||
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||
{new Array(10).fill(0).map((_, i) => (
|
||||
<>
|
||||
<Fragment key={i}>
|
||||
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
||||
<div className="p-4 text-white/90">
|
||||
{leaderboard?.[i]?.user ?? "No User"}
|
||||
|
|
@ -104,7 +103,7 @@ const Endless = () => {
|
|||
<div className="p-4 text-white/90">
|
||||
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||
</div>
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<LeaderboardButton />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-8 mb-32">
|
||||
<div className="flex flex-col gap-8 items-center py-48">
|
||||
<Tag variant="outline2">
|
||||
<motion.span>{rounded}</motion.span> Users
|
||||
<motion.span>{roundedUsers}</motion.span> Users Played{" "}
|
||||
<motion.span>{roundedGames}</motion.span> Games
|
||||
</Tag>
|
||||
<h1 className="text-white/80 font-black font-mono text-3xl md:text-6xl text-center">
|
||||
Business Minesweeper
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{data?.pages.map((page) =>
|
||||
page.data.map((game) => <PastMatch key={game.uuid} game={game} />),
|
||||
)}
|
||||
{hasNextPage && (
|
||||
<div
|
||||
className="text-white/80 flex justify-center w-full"
|
||||
ref={loadMoreRef}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchHistory;
|
||||
52
src/ws.ts
|
|
@ -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 }));
|
||||
};
|
||||
|
|
@ -33,7 +33,9 @@ const createWSClient = () => {
|
|||
queryKey: ["scoreboard.getScoreBoard", 10],
|
||||
});
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Received message", data);
|
||||
}
|
||||
});
|
||||
|
||||
const dispatch = async <
|
||||
|
|
|
|||