added match history and improved stats

This commit is contained in:
MasterGordon 2024-10-12 00:52:20 +02:00
parent a2d2cf1710
commit 5e259d73e8
45 changed files with 557 additions and 1162 deletions

View File

@ -1,11 +1,31 @@
# Minesweeper
# 💣 Business Minesweeper
A simple version of minesweeper built with react in about 1h.
This is a version of minesweeper with a expanding board after each stage. This also includes a account system with match history, spectating live matches and collectables.
![image](https://github.com/user-attachments/assets/25012972-ebe8-4610-bd28-c181ce8c4e2d)
## 🚀 Local Development
## Ideas
For local development you are required to have [bun](https://bun.sh/) installed.
```bash
# Create a .env file for token signing
echo "SECRET=SOME_RANDOM_STRING" > .env
bun install
bun run dev
```
## 📦 Used Libraries
- [Pixi.js](https://github.com/pixijs/pixi-react)
- [PixiViewport](https://github.com/davidfig/pixi-viewport)
- [Tanstack Query](https://github.com/TanStack/query)
- [Zod](https://github.com/colinhacks/zod)
- [Drizzle ORM](https://github.com/drizzle-team/drizzle-orm)
- [Tailwind CSS v4](https://github.com/tailwindlabs/tailwindcss)
- [React](https://github.com/facebook/react)
## 📋 Ideas
- Add global big board
- Questinmark after flag
- Earn points for wins
- Powerups

View File

@ -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;
},
),
});

View File

@ -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,
});

View File

@ -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;
};

View File

@ -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(),
});

BIN
bun.lockb

Binary file not shown.

View File

@ -1,36 +1,39 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
{ ignores: ["dist"] },
export default [
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
ignores: ["dist/", "node_modules/"],
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
...pluginReact.configs.flat.recommended,
settings: {
react: {
version: "detect",
},
},
},
);
{
plugins: { "react-hooks": reactHooks },
rules: reactHooks.configs.recommended.rules,
},
{
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
rules: {
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/display-name": "off",
},
},
{
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: { ...globals.browser, ...globals.node },
},
},
];

View File

@ -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"
}
}

View File

@ -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],
],
});
});
});

60
shared/time.ts Normal file
View File

@ -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";
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;
}
}
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

Before

Width:  |  Height:  |  Size: 277 B

After

Width:  |  Height:  |  Size: 277 B

View File

Before

Width:  |  Height:  |  Size: 353 B

After

Width:  |  Height:  |  Size: 353 B

View File

Before

Width:  |  Height:  |  Size: 425 B

After

Width:  |  Height:  |  Size: 425 B

View File

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 306 B

View File

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 348 B

View File

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 380 B

View File

Before

Width:  |  Height:  |  Size: 288 B

After

Width:  |  Height:  |  Size: 288 B

View File

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

View File

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 341 B

View File

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 406 B

View File

Before

Width:  |  Height:  |  Size: 816 B

After

Width:  |  Height:  |  Size: 816 B

View File

Before

Width:  |  Height:  |  Size: 370 B

After

Width:  |  Height:  |  Size: 370 B

View File

Before

Width:  |  Height:  |  Size: 156 B

After

Width:  |  Height:  |  Size: 156 B

View File

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 289 B

View File

@ -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);

View File

@ -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>

View File

@ -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;

View File

@ -2,29 +2,32 @@ import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "../lib/utils";
const tagVariants = cva("font-semibold py-2 px-4 rounded-md flex gap-2", {
variants: {
variant: {
default: "bg-gray-900 text-white/95",
ghost: "bg-transparent text-white/95 hover:bg-white/05",
outline:
"bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1",
outline2:
"bg-transparent [background:var(--bg-brand)] [-webkit-text-fill-color:transparent] [-webkit-background-clip:text!important] bg-white/05 border-primary border-1",
primary:
"[background:var(--bg-brand)] text-white/95 hover:bg-white/05 hover:animate-gradientmove",
const tagVariants = cva(
"font-semibold py-2 px-4 rounded-md flex whitespace-pre",
{
variants: {
variant: {
default: "bg-gray-900 text-white/95",
ghost: "bg-transparent text-white/95 hover:bg-white/05",
outline:
"bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1",
outline2:
"bg-transparent [background:var(--bg-brand)] [-webkit-text-fill-color:transparent] [-webkit-background-clip:text!important] bg-white/05 border-primary border-1",
primary:
"[background:var(--bg-brand)] text-white/95 hover:bg-white/05 hover:animate-gradientmove",
},
size: {
default: "h-10 py-2 px-4",
sm: "h-7 py-2 px-2 rounded-md text-xs",
lg: "h-11 px-8 rounded-md",
},
},
size: {
default: "h-10 py-2 px-4",
sm: "h-7 py-2 px-2 rounded-md text-xs",
lg: "h-11 px-8 rounded-md",
defaultVariants: {
variant: "default",
size: "default",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
);
export type ButtonProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof tagVariants>;

View File

@ -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,
};
};

View File

@ -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>,

View File

@ -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"),
};

145
src/themes/index.ts Normal file
View File

@ -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,
},
];

19
src/themes/retro-wave.ts Normal file
View File

@ -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"),
};

View File

@ -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&apos;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 />

View File

@ -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

View File

@ -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;

View File

@ -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 }));
};

View File

@ -33,7 +33,9 @@ const createWSClient = () => {
queryKey: ["scoreboard.getScoreBoard", 10],
});
}
console.log("Received message", data);
if (import.meta.env.DEV) {
console.log("Received message", data);
}
});
const dispatch = async <