diff --git a/package-lock.json b/package-lock.json
index 19d797f..f51810d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,8 @@
"react": "^18.3.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "^18.3.1",
- "use-sound": "^4.0.3"
+ "use-sound": "^4.0.3",
+ "zustand": "^4.5.5"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
@@ -1003,13 +1004,13 @@
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
- "dev": true
+ "devOptional": true
},
"node_modules/@types/react": {
"version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz",
"integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1416,7 +1417,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true
+ "devOptional": true
},
"node_modules/debug": {
"version": "4.3.7",
@@ -2527,6 +2528,14 @@
"react": ">=16.8"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
+ "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/vite": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.5.tgz",
@@ -2621,6 +2630,33 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "4.5.5",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.5.tgz",
+ "integrity": "sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==",
+ "dependencies": {
+ "use-sync-external-store": "1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
},
"dependencies": {
@@ -3130,13 +3166,13 @@
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
- "dev": true
+ "devOptional": true
},
"@types/react": {
"version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz",
"integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==",
- "dev": true,
+ "devOptional": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -3407,7 +3443,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true
+ "devOptional": true
},
"debug": {
"version": "4.3.7",
@@ -4174,6 +4210,12 @@
"howler": "^2.1.3"
}
},
+ "use-sync-external-store": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
+ "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==",
+ "requires": {}
+ },
"vite": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.5.tgz",
@@ -4206,6 +4248,14 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
+ },
+ "zustand": {
+ "version": "4.5.5",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.5.tgz",
+ "integrity": "sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==",
+ "requires": {
+ "use-sync-external-store": "1.2.2"
+ }
}
}
}
diff --git a/package.json b/package.json
index 288ce72..7205a61 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,8 @@
"react": "^18.3.1",
"react-confetti-boom": "^1.0.0",
"react-dom": "^18.3.1",
- "use-sound": "^4.0.3"
+ "use-sound": "^4.0.3",
+ "zustand": "^4.5.5"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
diff --git a/src/App.tsx b/src/App.tsx
index dc97e3b..02a5190 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,30 +1,28 @@
import { Button } from "./Button";
-import { updateGame, useGame } from "./GameContext";
import Timer from "./Timer";
import Options from "./Options";
+import useGameStore from "./GameState";
function App() {
- const game = useGame();
+ const game = useGameStore();
return (
Minesweeper
-
+
- {game?.mines[0].map((_, y) =>
- game?.mines.map((_, x) => (
+ {game.mines[0].map((_, y) =>
+ game.mines.map((_, x) => (
)),
)}
diff --git a/src/Button.tsx b/src/Button.tsx
index 6dd8ffe..20fddfd 100644
--- a/src/Button.tsx
+++ b/src/Button.tsx
@@ -1,6 +1,6 @@
import { ReactNode } from "react";
-import { updateGame, useGame } from "./GameContext";
import { Bomb, Flag } from "lucide-react";
+import useGameStore from "./GameState";
interface ButtonProps {
x: number;
@@ -19,15 +19,15 @@ export const colorMap: Record
= {
};
export const Button = ({ x, y }: ButtonProps) => {
- const game = useGame();
+ const game = useGameStore();
let content: ReactNode = "";
- if (game?.isRevealed[x][y]) {
- content = game?.isMine(x, y) ? : game?.getValue(x, y).toString();
+ if (game.isRevealed[x][y]) {
+ content = game.isMine(x, y) ? : game.getValue(x, y).toString();
}
- if (game?.isFlagged[x][y]) {
+ if (game.isFlagged[x][y]) {
content = ;
}
if (content === "0") content = "";
@@ -36,50 +36,43 @@ export const Button = ({ x, y }: ButtonProps) => {
0 ? "1.75rem" : undefined,
- cursor: game?.isRevealed[x][y] ? "default" : "pointer",
+ cursor: game.isRevealed[x][y] ? "default" : "pointer",
}}
onMouseUp={(e) => {
- if (game?.getHasWon() || game?.isGameOver) {
+ if (game.getHasWon() || game.isGameOver) {
return;
}
if (e.button === 0) {
// Left click
- if (!game?.isRevealed[x][y] && !game?.isFlagged[x][y]) {
- updateGame((game) => game?.reveal(x, y));
+ if (!game.isRevealed[x][y] && !game.isFlagged[x][y]) {
+ game.reveal(x, y);
} else {
const neighborFlagCount = game
?.getNeighborFlags(x, y)
.filter((n) => n).length;
- const value = game?.getValue(x, y);
+ const value = game.getValue(x, y);
if (neighborFlagCount === value) {
- updateGame((game) => {
- if (!game?.isFlagged[x - 1]?.[y]) game?.reveal(x - 1, y);
- if (!game?.isFlagged[x - 1]?.[y - 1])
- game?.reveal(x - 1, y - 1);
- if (!game?.isFlagged[x - 1]?.[y + 1])
- game?.reveal(x - 1, y + 1);
- if (!game?.isFlagged[x]?.[y - 1]) game?.reveal(x, y - 1);
- if (!game?.isFlagged[x]?.[y + 1]) game?.reveal(x, y + 1);
- if (!game?.isFlagged[x + 1]?.[y - 1])
- game?.reveal(x + 1, y - 1);
- if (!game?.isFlagged[x + 1]?.[y]) game?.reveal(x + 1, y);
- if (!game?.isFlagged[x + 1]?.[y + 1])
- game?.reveal(x + 1, y + 1);
- });
+ if (!game.isFlagged[x - 1]?.[y]) game.reveal(x - 1, y);
+ if (!game.isFlagged[x - 1]?.[y - 1]) game.reveal(x - 1, y - 1);
+ if (!game.isFlagged[x - 1]?.[y + 1]) game.reveal(x - 1, y + 1);
+ if (!game.isFlagged[x]?.[y - 1]) game.reveal(x, y - 1);
+ if (!game.isFlagged[x]?.[y + 1]) game.reveal(x, y + 1);
+ if (!game.isFlagged[x + 1]?.[y - 1]) game.reveal(x + 1, y - 1);
+ if (!game.isFlagged[x + 1]?.[y]) game.reveal(x + 1, y);
+ if (!game.isFlagged[x + 1]?.[y + 1]) game.reveal(x + 1, y + 1);
}
}
- } else if (e.button === 2 && !game?.isRevealed[x][y]) {
- // Right click
- updateGame((game) => game?.flag(x, y));
+ } else if (e.button === 2 && !game.isRevealed[x][y]) {
+ game.flag(x, y);
}
e.preventDefault();
}}
diff --git a/src/GameContext.tsx b/src/GameContext.tsx
index 7d55e0f..ac829c8 100644
--- a/src/GameContext.tsx
+++ b/src/GameContext.tsx
@@ -1,13 +1,8 @@
-import {
- createContext,
- ReactNode,
- useContext,
- useEffect,
- useState,
-} from "react";
+import { createContext, ReactNode, useContext, useEffect } from "react";
import { Game } from "./Game";
import useSound from "use-sound";
import explosion from "./sound/explosion.mp3";
+import useGameStore from "./GameState";
const GameContext = createContext
(null);
const useGame = () => useContext(GameContext);
@@ -16,39 +11,17 @@ let updateGame: (cb: (game: Game) => void) => void;
let resetGame: (width: number, height: number, mines: number) => void;
const GameProvider = ({ children }: { children: ReactNode }) => {
- const [game, setGame] = useState(null);
- const [counter, setCounter] = useState(0);
+ const game = useGameStore();
const [playSound] = useSound(explosion, {
volume: 0.5,
});
useEffect(() => {
- if (game?.isGameOver) {
+ if (game.isGameOver) {
playSound();
}
- }, [game?.isGameOver]);
-
- useEffect(() => {
- const game = new Game(30, 20, 100);
- setGame(game);
- updateGame = (cb: (game: Game) => void) => {
- cb(game);
- setCounter((c) => c + 1);
- };
- resetGame = (width: number, height: number, mines: number) => {
- const game = new Game(width, height, mines);
- setGame(game);
- updateGame = (cb: (game: Game) => void) => {
- cb(game);
- setCounter((c) => c + 1);
- };
- };
- }, []);
- return (
-
- {children}
-
- );
+ }, [game.isGameOver]);
+ return children;
};
export { GameProvider, useGame, updateGame, resetGame };
diff --git a/src/GameState.ts b/src/GameState.ts
new file mode 100644
index 0000000..02f981f
--- /dev/null
+++ b/src/GameState.ts
@@ -0,0 +1,249 @@
+import { create } from "zustand";
+
+interface GameState {
+ mines: boolean[][];
+ minesCount: number;
+ isRevealed: boolean[][];
+ isFlagged: boolean[][];
+ isGameOver: boolean;
+ startTime: number;
+ width: number;
+ height: number;
+
+ initializeGame: (width: number, height: number, mines: number) => void;
+ flag: (x: number, y: number) => void;
+ reveal: (x: number, y: number) => void;
+ 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;
+}
+
+const useGameStore = create((set, get) => ({
+ mines: [[]],
+ minesCount: 0,
+ isRevealed: [[]],
+ isFlagged: [[]],
+ isGameOver: false,
+ startTime: Date.now(),
+ width: 0,
+ height: 0,
+
+ initializeGame: (width, height, mines) => {
+ mines = Math.min(mines, width * height);
+
+ 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(),
+ });
+ },
+
+ flag: (x, y) => {
+ set((state) => {
+ const isFlagged = [...state.isFlagged];
+ isFlagged[x][y] = !isFlagged[x][y];
+ return { isFlagged };
+ });
+ },
+
+ reveal: (x, y) => {
+ const { mines, isRevealed, isGameOver, getValue } = get();
+ if (isGameOver || !get().isValid(x, y) || isRevealed[x][y]) return;
+
+ const newRevealed = [...isRevealed];
+ newRevealed[x][y] = true;
+
+ if (mines[x][y]) {
+ set({ isGameOver: true, isRevealed: newRevealed });
+ } else {
+ set({ isRevealed: newRevealed });
+ const value = getValue(x, y);
+ if (value === 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);
+ }
+ }
+ },
+
+ 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;
+ }
+ }
+
+ 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) => {
+ 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(),
+ });
+ },
+ 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
+ );
+ },
+}));
+
+export default useGameStore;
diff --git a/src/Options.tsx b/src/Options.tsx
index fec57c0..b3fc970 100644
--- a/src/Options.tsx
+++ b/src/Options.tsx
@@ -1,13 +1,36 @@
-import { useState } from "react";
-import { resetGame, useGame } from "./GameContext";
+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 = useGame();
- const [width, setWidth] = useState(game?.getWidth() || 20);
- const [height, setHeight] = useState(game?.getHeight() || 20);
- const [mines, setMines] = useState(game?.minesCount || 20);
+ 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]);
+
return (
{showOptions && (
<>
+
+ Presets:{" "}
+ {(Object.keys(presets) as Array).map(
+ (key) => (
+
+ ),
+ )}
+
Width:{" "}
setHeight(Number(e.target.value))}
/>
@@ -37,7 +77,6 @@ function Options() {
setMines(Number(e.target.value))}
/>
@@ -45,7 +84,7 @@ function Options() {
)}