added endless mode and scoreboard

This commit is contained in:
MasterGordon 2024-09-15 02:18:39 +02:00
parent 3c29265b79
commit 3867f40907
8 changed files with 277 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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