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
|
- Add global big board
|
||||||
- Questinmark after flag
|
- Questinmark after flag
|
||||||
- Earn points for wins
|
- Earn points for wins
|
||||||
|
- Powerups
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { createController, createEndpoint } from "./controller";
|
||||||
import {
|
import {
|
||||||
getCurrentGame,
|
getCurrentGame,
|
||||||
getGame,
|
getGame,
|
||||||
|
getGames,
|
||||||
|
getTotalGamesPlayed,
|
||||||
parseGameState,
|
parseGameState,
|
||||||
upsertGameState,
|
upsertGameState,
|
||||||
} from "../repositories/gameRepository";
|
} 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",
|
uuid: "TestUuid",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started,
|
started,
|
||||||
});
|
});
|
||||||
|
|
@ -25,7 +25,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid",
|
uuid: "TestUuid",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started,
|
started,
|
||||||
});
|
});
|
||||||
|
|
@ -44,7 +44,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid",
|
uuid: "TestUuid",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started,
|
started,
|
||||||
});
|
});
|
||||||
|
|
@ -52,7 +52,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid2",
|
uuid: "TestUuid2",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 2,
|
stage: 2,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started: started + 1,
|
started: started + 1,
|
||||||
});
|
});
|
||||||
|
|
@ -61,7 +61,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid2",
|
uuid: "TestUuid2",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 2,
|
stage: 2,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started: started + 1,
|
started: started + 1,
|
||||||
});
|
});
|
||||||
|
|
@ -76,7 +76,7 @@ describe("GameRepository", () => {
|
||||||
uuid,
|
uuid,
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started,
|
started,
|
||||||
});
|
});
|
||||||
|
|
@ -92,7 +92,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid",
|
uuid: "TestUuid",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started,
|
started,
|
||||||
});
|
});
|
||||||
|
|
@ -101,7 +101,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid",
|
uuid: "TestUuid",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started,
|
started,
|
||||||
});
|
});
|
||||||
|
|
@ -114,7 +114,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid",
|
uuid: "TestUuid",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started,
|
started,
|
||||||
});
|
});
|
||||||
|
|
@ -122,7 +122,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid",
|
uuid: "TestUuid",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 2,
|
stage: 2,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started: started + 1,
|
started: started + 1,
|
||||||
});
|
});
|
||||||
|
|
@ -131,7 +131,7 @@ describe("GameRepository", () => {
|
||||||
uuid: "TestUuid",
|
uuid: "TestUuid",
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
stage: 2,
|
stage: 2,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started: started + 1,
|
started: started + 1,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const getGames = async (db: BunSQLiteDatabase, user: string) => {
|
||||||
.select()
|
.select()
|
||||||
.from(Game)
|
.from(Game)
|
||||||
.where(and(eq(Game.user, user), not(eq(Game.finished, 0))))
|
.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) => {
|
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) => {
|
export const parseGameState = (gameState: Buffer) => {
|
||||||
return decode(gameState) as ServerGame;
|
return decode(gameState) as ServerGame;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ describe("ScoreRepository", () => {
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
uuid: crypto.randomUUID(),
|
uuid: crypto.randomUUID(),
|
||||||
stage: 1,
|
stage: 1,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started: Date.now(),
|
started: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
@ -22,7 +22,7 @@ describe("ScoreRepository", () => {
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
uuid: crypto.randomUUID(),
|
uuid: crypto.randomUUID(),
|
||||||
stage: 10,
|
stage: 10,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 1,
|
finished: 1,
|
||||||
started: Date.now(),
|
started: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
@ -30,7 +30,7 @@ describe("ScoreRepository", () => {
|
||||||
user: "TestUser",
|
user: "TestUser",
|
||||||
uuid: crypto.randomUUID(),
|
uuid: crypto.randomUUID(),
|
||||||
stage: 20,
|
stage: 20,
|
||||||
gameState: "ANY",
|
gameState: Buffer.from("ANY"),
|
||||||
finished: 0,
|
finished: 0,
|
||||||
started: Date.now(),
|
started: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,39 @@
|
||||||
import js from "@eslint/js";
|
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import pluginJs from "@eslint/js";
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
import pluginReact from "eslint-plugin-react";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default [
|
||||||
{ ignores: ["dist"] },
|
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
ignores: ["dist/", "node_modules/"],
|
||||||
files: ["**/*.{ts,tsx}"],
|
},
|
||||||
languageOptions: {
|
pluginJs.configs.recommended,
|
||||||
ecmaVersion: 2020,
|
...tseslint.configs.recommended,
|
||||||
globals: globals.browser,
|
{
|
||||||
},
|
...pluginReact.configs.flat.recommended,
|
||||||
plugins: {
|
settings: {
|
||||||
"react-hooks": reactHooks,
|
react: {
|
||||||
"react-refresh": reactRefresh,
|
version: "detect",
|
||||||
},
|
},
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
"react-refresh/only-export-components": [
|
|
||||||
"warn",
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
argsIgnorePattern: "^_",
|
|
||||||
varsIgnorePattern: "^_",
|
|
||||||
caughtErrorsIgnorePattern: "^_",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
{
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
|
||||||
65
package.json
|
|
@ -5,10 +5,10 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run dev.ts",
|
"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",
|
"dev:client": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
|
"drizzle:schema": "drizzle-kit generate --dialect sqlite --schema ./backend/schema.ts --out ./backend/drizzle",
|
||||||
"drizzle:migrate": "bun run backend/migrate.ts",
|
"drizzle:migrate": "bun run backend/migrate.ts",
|
||||||
|
|
@ -16,50 +16,47 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||||
"@pixi/events": "^7.4.2",
|
|
||||||
"@pixi/react": "^7.1.2",
|
"@pixi/react": "^7.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
"@radix-ui/react-switch": "^1.1.0",
|
"@radix-ui/react-switch": "^1.1.1",
|
||||||
"@tailwindcss/vite": "^4.0.0-alpha.24",
|
"@tanstack/react-query": "^5.59.11",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query-devtools": "^5.59.11",
|
||||||
"@tanstack/react-query-devtools": "^5.0.0-alpha.91",
|
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.34.1",
|
||||||
"framer-motion": "^11.5.6",
|
"framer-motion": "^11.11.8",
|
||||||
"jotai": "^2.10.0",
|
"jotai": "^2.10.0",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.452.0",
|
||||||
"pixi-viewport": "^5.0.3",
|
"pixi-viewport": "^5.0.3",
|
||||||
"pixi.js": "^7.4.2",
|
"pixi.js": "^7.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-confetti-boom": "^1.0.0",
|
"react-confetti-boom": "^1.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"tailwind-merge": "^2.5.3",
|
||||||
"tailwind-merge": "^2.5.2",
|
|
||||||
"tailwindcss": "^4.0.0-alpha.24",
|
|
||||||
"use-sound": "^4.0.3",
|
"use-sound": "^4.0.3",
|
||||||
"vite-imagetools": "^7.0.4",
|
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8"
|
||||||
"zustand": "^4.5.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/bun": "latest",
|
||||||
"@types/react": "^18.3.8",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||||
"drizzle-kit": "^0.24.2",
|
"drizzle-kit": "^0.25.0",
|
||||||
"eslint": "^9.10.0",
|
"eslint": "^9.12.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react": "^7.37.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.12",
|
"eslint-plugin-react-hooks": "5.0.0",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.11.0",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.6.0",
|
"typescript-eslint": "^8.8.1",
|
||||||
"vite": "^5.4.6"
|
"vite": "^5.4.8",
|
||||||
},
|
"@tailwindcss/vite": "next"
|
||||||
"module": "index.ts"
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { getValue, serverToClientGame } from "./game";
|
import { getValue, ServerGame, serverToClientGame } from "./game";
|
||||||
|
|
||||||
describe("Game", () => {
|
describe("Game", () => {
|
||||||
it("should get value", () => {
|
it("should get value", () => {
|
||||||
|
|
@ -16,7 +16,7 @@ describe("Game", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should convert server to client game", () => {
|
it("should convert server to client game", () => {
|
||||||
const serverGame = {
|
const serverGame: ServerGame = {
|
||||||
mines: [
|
mines: [
|
||||||
[false, false, true, true, true],
|
[false, false, true, true, true],
|
||||||
[true, false, true, false, true],
|
[true, false, true, false, true],
|
||||||
|
|
@ -36,13 +36,20 @@ describe("Game", () => {
|
||||||
[false, false, true, false, true],
|
[false, false, true, false, true],
|
||||||
[true, false, false, false, false],
|
[true, false, false, false, false],
|
||||||
],
|
],
|
||||||
isGameOver: false,
|
|
||||||
started: 1679599200000,
|
started: 1679599200000,
|
||||||
finished: 0,
|
finished: 0,
|
||||||
lastClick: [0, 0] satisfies [number, number],
|
lastClick: [0, 0] satisfies [number, number],
|
||||||
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
||||||
width: 5,
|
width: 5,
|
||||||
height: 4,
|
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({
|
expect(serverToClientGame(serverGame)).toEqual({
|
||||||
minesCount: 4,
|
minesCount: 4,
|
||||||
|
|
@ -69,6 +76,14 @@ describe("Game", () => {
|
||||||
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
uuid: "C270D7CD-AF97-42CE-A6C9-CB765102CA17",
|
||||||
width: 5,
|
width: 5,
|
||||||
height: 4,
|
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(() => {
|
setTimeout(() => {
|
||||||
if (viewportRef.current) onViewportChange(viewportRef.current);
|
if (viewportRef.current) onViewportChange(viewportRef.current);
|
||||||
}, 200);
|
}, 200);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [game.width, game.height, onViewportChange]);
|
||||||
}, [game.width, game.height]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
setWidth(ref.current.clientWidth);
|
setWidth(ref.current.clientWidth);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Fragment } from "react";
|
||||||
import { useWSQuery } from "../hooks";
|
import { useWSQuery } from "../hooks";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,7 +29,7 @@ const LeaderboardButton = ({
|
||||||
<DialogTitle>Leaderboard</DialogTitle>
|
<DialogTitle>Leaderboard</DialogTitle>
|
||||||
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||||
{leaderboard?.map((_, i) => (
|
{leaderboard?.map((_, i) => (
|
||||||
<>
|
<Fragment key={i}>
|
||||||
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
||||||
<div className="p-4 text-white/90">
|
<div className="p-4 text-white/90">
|
||||||
{leaderboard?.[i]?.user ?? "No User"}
|
{leaderboard?.[i]?.user ?? "No User"}
|
||||||
|
|
@ -36,7 +37,7 @@ const LeaderboardButton = ({
|
||||||
<div className="p-4 text-white/90">
|
<div className="p-4 text-white/90">
|
||||||
Stage {leaderboard?.[i]?.stage ?? 0}
|
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</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,29 +2,32 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
const tagVariants = cva("font-semibold py-2 px-4 rounded-md flex gap-2", {
|
const tagVariants = cva(
|
||||||
variants: {
|
"font-semibold py-2 px-4 rounded-md flex whitespace-pre",
|
||||||
variant: {
|
{
|
||||||
default: "bg-gray-900 text-white/95",
|
variants: {
|
||||||
ghost: "bg-transparent text-white/95 hover:bg-white/05",
|
variant: {
|
||||||
outline:
|
default: "bg-gray-900 text-white/95",
|
||||||
"bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1",
|
ghost: "bg-transparent text-white/95 hover:bg-white/05",
|
||||||
outline2:
|
outline:
|
||||||
"bg-transparent [background:var(--bg-brand)] [-webkit-text-fill-color:transparent] [-webkit-background-clip:text!important] bg-white/05 border-primary border-1",
|
"bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1",
|
||||||
primary:
|
outline2:
|
||||||
"[background:var(--bg-brand)] text-white/95 hover:bg-white/05 hover:animate-gradientmove",
|
"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: {
|
defaultVariants: {
|
||||||
default: "h-10 py-2 px-4",
|
variant: "default",
|
||||||
sm: "h-7 py-2 px-2 rounded-md text-xs",
|
size: "default",
|
||||||
lg: "h-11 px-8 rounded-md",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
);
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ButtonProps = React.HTMLAttributes<HTMLDivElement> &
|
export type ButtonProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
VariantProps<typeof tagVariants>;
|
VariantProps<typeof tagVariants>;
|
||||||
|
|
|
||||||
24
src/hooks.ts
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
UseMutationResult,
|
UseMutationResult,
|
||||||
useQuery,
|
useQuery,
|
||||||
|
|
@ -73,3 +74,26 @@ export const useWSInvalidation = <
|
||||||
queryClient.invalidateQueries({ queryKey: [action] });
|
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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { connectWS } from "./ws.ts";
|
|
||||||
import { Toaster } from "react-hot-toast";
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import Shell from "./Shell.tsx";
|
import Shell from "./Shell.tsx";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
|
@ -12,8 +10,7 @@ import Endless from "./views/endless/Endless.tsx";
|
||||||
import { queryClient } from "./queryClient.ts";
|
import { queryClient } from "./queryClient.ts";
|
||||||
import Home from "./views/home/Home.tsx";
|
import Home from "./views/home/Home.tsx";
|
||||||
import Settings from "./views/settings/Settings.tsx";
|
import Settings from "./views/settings/Settings.tsx";
|
||||||
|
import MatchHistory from "./views/match-history/MatchHistory.tsx";
|
||||||
connectWS();
|
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
const token = localStorage.getItem("loginToken");
|
const token = localStorage.getItem("loginToken");
|
||||||
|
|
@ -33,22 +30,14 @@ setup().then(() => {
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Toaster position="top-right" reverseOrder={false} />
|
|
||||||
<Shell>
|
<Shell>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
<Route path="/play" component={Endless} />
|
<Route path="/play" component={Endless} />
|
||||||
<Route
|
<Route path="/history" component={MatchHistory} />
|
||||||
path="/history"
|
|
||||||
component={() => (
|
|
||||||
<h2 className="text-white/80 text-2xl">Comming Soon</h2>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/settings" component={Settings} />
|
||||||
</Switch>
|
</Switch>
|
||||||
{/* <App /> */}
|
|
||||||
</Shell>
|
</Shell>
|
||||||
{/* <App /> */}
|
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</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 { gameIdAtom } from "../../atoms";
|
||||||
import { Button } from "../../components/Button";
|
import { Button } from "../../components/Button";
|
||||||
import LeaderboardButton from "../../components/LeaderboardButton";
|
import LeaderboardButton from "../../components/LeaderboardButton";
|
||||||
import { useEffect } from "react";
|
import { Fragment, useEffect } from "react";
|
||||||
|
|
||||||
const Endless = () => {
|
const Endless = () => {
|
||||||
const [gameId, setGameId] = useAtom(gameIdAtom);
|
const [gameId, setGameId] = useAtom(gameIdAtom);
|
||||||
|
|
@ -22,7 +22,6 @@ const Endless = () => {
|
||||||
setGameId(undefined);
|
setGameId(undefined);
|
||||||
};
|
};
|
||||||
}, [setGameId]);
|
}, [setGameId]);
|
||||||
console.log("set", setGameId);
|
|
||||||
|
|
||||||
return game ? (
|
return game ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -81,10 +80,10 @@ const Endless = () => {
|
||||||
</Button>
|
</Button>
|
||||||
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
|
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
|
||||||
<p className="text-white/90">
|
<p className="text-white/90">
|
||||||
Endless minesweeper is just like regular minesweeper but you can't
|
Endless minesweeper is just like regular minesweeper but you
|
||||||
win. Every time you clear the field you just proceed to the next
|
can't win. Every time you clear the field you just proceed to the
|
||||||
stage. Try to get as far as possible. You might be rewarded for great
|
next stage. Try to get as far as possible. You might be rewarded for
|
||||||
performance!
|
great performance!
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
Good luck!
|
Good luck!
|
||||||
|
|
@ -96,7 +95,7 @@ const Endless = () => {
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||||
{new Array(10).fill(0).map((_, i) => (
|
{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/80 text-right">{i + 1}.</div>
|
||||||
<div className="p-4 text-white/90">
|
<div className="p-4 text-white/90">
|
||||||
{leaderboard?.[i]?.user ?? "No User"}
|
{leaderboard?.[i]?.user ?? "No User"}
|
||||||
|
|
@ -104,7 +103,7 @@ const Endless = () => {
|
||||||
<div className="p-4 text-white/90">
|
<div className="p-4 text-white/90">
|
||||||
Stage {leaderboard?.[i]?.stage ?? 0}
|
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<LeaderboardButton />
|
<LeaderboardButton />
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,33 @@ import { Link } from "wouter";
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { data: userCount } = useWSQuery("user.getUserCount", null);
|
const { data: userCount } = useWSQuery("user.getUserCount", null);
|
||||||
|
const { data: gameCount } = useWSQuery("game.getTotalGamesPlayed", {});
|
||||||
const { data: username } = useWSQuery("user.getSelf", null);
|
const { data: username } = useWSQuery("user.getSelf", null);
|
||||||
const from = (userCount ?? 0) / 2;
|
const usersFrom = (userCount ?? 0) / 2;
|
||||||
const to = userCount ?? 0;
|
const usersTo = userCount ?? 0;
|
||||||
|
const gamesFrom = (gameCount ?? 0) / 2;
|
||||||
|
const gamesTo = gameCount ?? 0;
|
||||||
|
|
||||||
const count = useMotionValue(from);
|
const usersCount = useMotionValue(usersFrom);
|
||||||
const rounded = useTransform(count, (latest) => Math.round(latest));
|
const roundedUsers = useTransform(usersCount, (latest) => Math.round(latest));
|
||||||
|
const gamesCount = useMotionValue(gamesFrom);
|
||||||
|
const roundedGames = useTransform(gamesCount, (latest) => Math.round(latest));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controls = animate(count, to, { duration: 1.5 });
|
const controls = animate(usersCount, usersTo, { duration: 1.5 });
|
||||||
return controls.stop;
|
return controls.stop;
|
||||||
}, [count, to]);
|
}, [usersCount, usersTo]);
|
||||||
|
useEffect(() => {
|
||||||
|
const controls = animate(gamesCount, gamesTo, { duration: 1.5 });
|
||||||
|
return controls.stop;
|
||||||
|
}, [gamesCount, gamesTo]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 mb-32">
|
<div className="flex flex-col gap-8 mb-32">
|
||||||
<div className="flex flex-col gap-8 items-center py-48">
|
<div className="flex flex-col gap-8 items-center py-48">
|
||||||
<Tag variant="outline2">
|
<Tag variant="outline2">
|
||||||
<motion.span>{rounded}</motion.span> Users
|
<motion.span>{roundedUsers}</motion.span> Users Played{" "}
|
||||||
|
<motion.span>{roundedGames}</motion.span> Games
|
||||||
</Tag>
|
</Tag>
|
||||||
<h1 className="text-white/80 font-black font-mono text-3xl md:text-6xl text-center">
|
<h1 className="text-white/80 font-black font-mono text-3xl md:text-6xl text-center">
|
||||||
Business Minesweeper
|
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],
|
queryKey: ["scoreboard.getScoreBoard", 10],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log("Received message", data);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("Received message", data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatch = async <
|
const dispatch = async <
|
||||||
|
|
|
||||||