added presets

This commit is contained in:
MasterGordon 2024-09-15 00:20:20 +02:00
parent 3127575eaf
commit 3c29265b79
8 changed files with 403 additions and 100 deletions

64
package-lock.json generated
View File

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

View File

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

View File

@ -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 (
<div className="App">
<h1>Minesweeper</h1>
<Options />
<button onClick={() => updateGame((game) => game?.quickStart())}>
Quick Start
</button>
<button onClick={() => game.quickStart()}>Quick Start</button>
<div className="game-wrapper">
<div>
<Timer />
<div
className="game-board"
style={{
gridTemplateColumns: `repeat(${game?.getWidth()}, 1fr)`,
gridTemplateRows: `repeat(${game?.getHeight()}, 1fr)`,
gridTemplateColumns: `repeat(${game.getWidth()}, 1fr)`,
gridTemplateRows: `repeat(${game.getHeight()}, 1fr)`,
}}
>
{game?.mines[0].map((_, y) =>
game?.mines.map((_, x) => (
{game.mines[0].map((_, y) =>
game.mines.map((_, x) => (
<Button key={`${x},${y}`} x={x} y={y} />
)),
)}

View File

@ -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<string, string> = {
};
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) ? <Bomb /> : game?.getValue(x, y).toString();
if (game.isRevealed[x][y]) {
content = game.isMine(x, y) ? <Bomb /> : game.getValue(x, y).toString();
}
if (game?.isFlagged[x][y]) {
if (game.isFlagged[x][y]) {
content = <Flag fill="red" />;
}
if (content === "0") content = "";
@ -36,50 +36,43 @@ export const Button = ({ x, y }: ButtonProps) => {
<div
className="mine-button"
style={{
background: game?.isRevealed[x][y] ? "#444" : undefined,
borderRight: !game?.isRevealed[x][y] ? "3px solid black" : undefined,
borderTop: !game?.isRevealed[x][y] ? "3px solid #999" : undefined,
borderLeft: !game?.isRevealed[x][y] ? "3px solid #999" : undefined,
borderBottom: !game?.isRevealed[x][y] ? "3px solid black" : undefined,
color: game?.isRevealed[x][y]
background: game.isRevealed[x][y] ? "#444" : undefined,
borderRight: !game.isRevealed[x][y] ? "3px solid black" : undefined,
borderTop: !game.isRevealed[x][y] ? "3px solid #999" : undefined,
borderLeft: !game.isRevealed[x][y] ? "3px solid #999" : undefined,
borderBottom: !game.isRevealed[x][y] ? "3px solid black" : undefined,
color: game.isRevealed[x][y]
? colorMap[String(content)] ?? "#eee"
: undefined,
fontSize: Number(content) > 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();
}}

View File

@ -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<Game | null>(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<Game | null>(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 (
<GameContext.Provider key={counter} value={game}>
{children}
</GameContext.Provider>
);
}, [game.isGameOver]);
return children;
};
export { GameProvider, useGame, updateGame, resetGame };

249
src/GameState.ts Normal file
View File

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

View File

@ -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 (
<div>
<button onClick={() => setShowOptions(!showOptions)}>
@ -15,6 +38,24 @@ function 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
@ -28,7 +69,6 @@ function Options() {
<input
type="number"
value={height}
max="40"
onChange={(e) => setHeight(Number(e.target.value))}
/>
</p>
@ -37,7 +77,6 @@ function Options() {
<input
type="number"
value={mines}
max="40"
onChange={(e) => setMines(Number(e.target.value))}
/>
</p>
@ -45,7 +84,7 @@ function Options() {
)}
<button
onClick={() => {
resetGame(width, height, mines);
game.resetGame(width, height, mines);
}}
>
Reset

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from "react";
import { useGame } from "./GameContext";
import Confetti from "react-confetti-boom";
import useGameStore from "./GameState";
const Timer = () => {
const game = useGame();
const game = useGameStore();
const [currentTime, setCurrentTime] = useState(Date.now());
useEffect(() => {
if (game?.isGameOver || game?.getHasWon()) {
if (game.isGameOver || game.getHasWon()) {
return;
}
const interval = setInterval(() => {
@ -15,21 +15,21 @@ const Timer = () => {
}, 1000);
return () => clearInterval(interval);
}, []);
}, [game.isGameOver, game.getHasWon()]);
return (
<div className="timer">
<p style={{ width: "100px" }}>{game?.getMinesLeft()}</p>
<p style={{ width: "100px" }}>{game.getMinesLeft()}</p>
<p
style={{
fontSize: "2rem",
}}
>
{game?.getHasWon() ? "😎" : game?.isGameOver ? "😢" : "😐"}
{game?.getHasWon() && <Confetti mode="boom" particleCount={301} />}
{game.getHasWon() ? "😎" : game.isGameOver ? "😢" : "😐"}
{game.getHasWon() && <Confetti mode="boom" particleCount={301} />}
</p>
<p style={{ width: "100px", textAlign: "right" }}>
{Math.max(0, Math.floor((currentTime - (game?.startTime || 0)) / 1000))}
{Math.max(0, Math.floor((currentTime - (game.startTime || 0)) / 1000))}
</p>
</div>
);