added endless mode and scoreboard
This commit is contained in:
parent
3c29265b79
commit
3867f40907
68
src/App.tsx
68
src/App.tsx
|
|
@ -1,16 +1,74 @@
|
|||
import { Button } from "./Button";
|
||||
import Timer from "./Timer";
|
||||
import Options from "./Options";
|
||||
import explosion from "./sound/explosion.mp3";
|
||||
import useGameStore from "./GameState";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSound from "use-sound";
|
||||
|
||||
interface Score {
|
||||
user: string;
|
||||
stage: number;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const game = useGameStore();
|
||||
const [scores, setScores] = useState<Score[]>([]);
|
||||
const [showScores, setShowScores] = useState(false);
|
||||
const [playSound] = useSound(explosion, {
|
||||
volume: 0.5,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (game.isGameOver) {
|
||||
playSound();
|
||||
}
|
||||
}, [game.isGameOver]);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
game.resetGame(4, 4, 2);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<h1>Minesweeper</h1>
|
||||
<Options />
|
||||
<button onClick={() => game.quickStart()}>Quick Start</button>
|
||||
<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>
|
||||
<button onClick={() => setShowScores(!showScores)}>
|
||||
{showScores ? "Hide" : "Show"} Scores
|
||||
</button>
|
||||
{showScores && (
|
||||
<div>
|
||||
{scores.map((score) => (
|
||||
<p key={score.user}>
|
||||
{score.user} - {score.stage}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="game-wrapper">
|
||||
<div>
|
||||
<Timer />
|
||||
|
|
@ -30,7 +88,7 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<pre>Version: 1.0.1</pre>
|
||||
<pre>Version: 1.1.0</pre>
|
||||
<pre>
|
||||
Made by MasterGordon -{" "}
|
||||
<a target="_blank" href="https://github.com/MasterGordon/minesweeper">
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ export const Button = ({ x, y }: ButtonProps) => {
|
|||
content = <Flag fill="red" />;
|
||||
}
|
||||
if (content === "0") content = "";
|
||||
if (
|
||||
window.location.href.includes("xray") &&
|
||||
game.isMine(x, y) &&
|
||||
!game.isFlagged[x][y]
|
||||
)
|
||||
content = <Bomb />;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -57,7 +63,7 @@ export const Button = ({ x, y }: ButtonProps) => {
|
|||
game.reveal(x, y);
|
||||
} else {
|
||||
const neighborFlagCount = game
|
||||
?.getNeighborFlags(x, y)
|
||||
.getNeighborFlags(x, y)
|
||||
.filter((n) => n).length;
|
||||
const value = game.getValue(x, y);
|
||||
if (neighborFlagCount === value) {
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
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);
|
||||
|
||||
let updateGame: (cb: (game: Game) => void) => void;
|
||||
let resetGame: (width: number, height: number, mines: number) => void;
|
||||
|
||||
const GameProvider = ({ children }: { children: ReactNode }) => {
|
||||
const game = useGameStore();
|
||||
const [playSound] = useSound(explosion, {
|
||||
volume: 0.5,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (game.isGameOver) {
|
||||
playSound();
|
||||
}
|
||||
}, [game.isGameOver]);
|
||||
return children;
|
||||
};
|
||||
|
||||
export { GameProvider, useGame, updateGame, resetGame };
|
||||
139
src/GameState.ts
139
src/GameState.ts
|
|
@ -9,6 +9,8 @@ interface GameState {
|
|||
startTime: number;
|
||||
width: number;
|
||||
height: number;
|
||||
stage: number;
|
||||
name: string;
|
||||
|
||||
initializeGame: (width: number, height: number, mines: number) => void;
|
||||
flag: (x: number, y: number) => void;
|
||||
|
|
@ -25,6 +27,9 @@ interface GameState {
|
|||
getWidth: () => number;
|
||||
getHeight: () => number;
|
||||
isTouched: () => boolean;
|
||||
triggerPostGame: () => void;
|
||||
expandBoard: () => void;
|
||||
setName: (name: string) => void;
|
||||
}
|
||||
|
||||
const useGameStore = create<GameState>((set, get) => ({
|
||||
|
|
@ -36,6 +41,8 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
startTime: Date.now(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
stage: 1,
|
||||
name: localStorage.getItem("name") || "No Name",
|
||||
|
||||
initializeGame: (width, height, mines) => {
|
||||
mines = Math.min(mines, width * height);
|
||||
|
|
@ -78,10 +85,12 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
isFlagged[x][y] = !isFlagged[x][y];
|
||||
return { isFlagged };
|
||||
});
|
||||
const { triggerPostGame } = get();
|
||||
triggerPostGame();
|
||||
},
|
||||
|
||||
reveal: (x, y) => {
|
||||
const { mines, isRevealed, isGameOver, getValue } = get();
|
||||
const { mines, isRevealed, isGameOver, getValue, triggerPostGame } = get();
|
||||
if (isGameOver || !get().isValid(x, y) || isRevealed[x][y]) return;
|
||||
|
||||
const newRevealed = [...isRevealed];
|
||||
|
|
@ -92,7 +101,10 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
} else {
|
||||
set({ isRevealed: newRevealed });
|
||||
const value = getValue(x, y);
|
||||
if (value === 0) {
|
||||
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);
|
||||
|
|
@ -109,6 +121,7 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
revealNeighbors(x + 1, y + 1);
|
||||
}
|
||||
}
|
||||
triggerPostGame();
|
||||
},
|
||||
|
||||
getValue: (x, y) => {
|
||||
|
|
@ -134,6 +147,7 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +258,127 @@ const useGameStore = create<GameState>((set, get) => ({
|
|||
isFlagged.flat().filter((flag) => flag).length > 0
|
||||
);
|
||||
},
|
||||
triggerPostGame: () => {
|
||||
const { isGameOver, getHasWon, expandBoard } = get();
|
||||
if (getHasWon()) {
|
||||
expandBoard();
|
||||
}
|
||||
},
|
||||
expandBoard: () => {
|
||||
const { width, height, stage, mines, isFlagged, isRevealed } = get();
|
||||
const dir = stage % 2 === 0 ? "down" : "right";
|
||||
// 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 });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useGameStore;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,46 @@ 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(() => {
|
||||
|
|
@ -18,20 +52,34 @@ const Timer = () => {
|
|||
}, [game.isGameOver, game.getHasWon()]);
|
||||
|
||||
return (
|
||||
<div className="timer">
|
||||
<p style={{ width: "100px" }}>{game.getMinesLeft()}</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "2rem",
|
||||
}}
|
||||
>
|
||||
{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))}
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<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.getHasWon() && <Confetti mode="boom" particleCount={301} />}
|
||||
</p>
|
||||
<p style={{ width: "100px", textAlign: "right" }}>
|
||||
{Math.max(
|
||||
0,
|
||||
Math.floor((currentTime - (game.startTime || 0)) / 1000),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
html {
|
||||
|
|
@ -57,3 +58,8 @@ body {
|
|||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stage {
|
||||
font-size: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { StrictMode } from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import { GameProvider } from "./GameContext.tsx";
|
||||
|
||||
document.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -10,8 +9,6 @@ document.addEventListener("contextmenu", (event) => {
|
|||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<GameProvider>
|
||||
<App />
|
||||
</GameProvider>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/App.tsx","./src/Button.tsx","./src/Game.ts","./src/GameContext.tsx","./src/Options.tsx","./src/Timer.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.6.2"}
|
||||
{"root":["./src/App.tsx","./src/Button.tsx","./src/Game.ts","./src/GameContext.tsx","./src/GameState.ts","./src/Options.tsx","./src/Timer.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.6.2"}
|
||||
Loading…
Reference in New Issue