diff --git a/src/App.tsx b/src/App.tsx index 02a5190..58d9ad5 100644 --- a/src/App.tsx +++ b/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([]); + 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 (
-

Minesweeper

- - +

+ Minesweeper Endless{" "} + +

+

+ Name:{" "} + game.setName(e.target.value)} + /> +

+ + {showScores && ( +
+ {scores.map((score) => ( +

+ {score.user} - {score.stage} +

+ ))} +
+ )}
@@ -30,7 +88,7 @@ function App() {
-
Version: 1.0.1
+
Version: 1.1.0
           Made by MasterGordon -{" "}
           
diff --git a/src/Button.tsx b/src/Button.tsx
index 20fddfd..8046ad1 100644
--- a/src/Button.tsx
+++ b/src/Button.tsx
@@ -31,6 +31,12 @@ export const Button = ({ x, y }: ButtonProps) => {
     content = ;
   }
   if (content === "0") content = "";
+  if (
+    window.location.href.includes("xray") &&
+    game.isMine(x, y) &&
+    !game.isFlagged[x][y]
+  )
+    content = ;
 
   return (
     
{ 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) { diff --git a/src/GameContext.tsx b/src/GameContext.tsx deleted file mode 100644 index ac829c8..0000000 --- a/src/GameContext.tsx +++ /dev/null @@ -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(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 }; diff --git a/src/GameState.ts b/src/GameState.ts index 02f981f..884754e 100644 --- a/src/GameState.ts +++ b/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((set, get) => ({ @@ -36,6 +41,8 @@ const useGameStore = create((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((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((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((set, get) => ({ revealNeighbors(x + 1, y + 1); } } + triggerPostGame(); }, getValue: (x, y) => { @@ -134,6 +147,7 @@ const useGameStore = create((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((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; diff --git a/src/Timer.tsx b/src/Timer.tsx index 28004bf..24b8d2e 100644 --- a/src/Timer.tsx +++ b/src/Timer.tsx @@ -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 ( -
-

{game.getMinesLeft()}

-

- {game.getHasWon() ? "😎" : game.isGameOver ? "😢" : "😐"} - {game.getHasWon() && } -

-

- {Math.max(0, Math.floor((currentTime - (game.startTime || 0)) / 1000))} -

-
+ <> +
+

+ Stage: {game.stage} ({game.getWidth()}x{game.getHeight()}) +

+
+
+

{game.getMinesLeft()}

+

+ {game.getHasWon() + ? "😎" + : game.isGameOver + ? "😢" + : emoteByStage[game.stage] || "😐"} + {game.getHasWon() && } +

+

+ {Math.max( + 0, + Math.floor((currentTime - (game.startTime || 0)) / 1000), + )} +

+
+ ); }; diff --git a/src/index.css b/src/index.css index 3b2dd8b..aaaf0b2 100644 --- a/src/index.css +++ b/src/index.css @@ -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; +} diff --git a/src/main.tsx b/src/main.tsx index fce7a4b..c763382 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - - - + , ); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index ab4d0c7..5257aba 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file