diff react_games/src/Minesweeper/main.tsx @ 37:fb9bcd3145cb

[ReactGames] Few games I made using react just to practice few things.
author MrJuneJune <me@mrjunejune.com>
date Mon, 01 Dec 2025 20:22:47 -0800
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/react_games/src/Minesweeper/main.tsx	Mon Dec 01 20:22:47 2025 -0800
@@ -0,0 +1,285 @@
+import { CSSProperties, memo, useCallback, useReducer } from 'react';
+
+/**
+ *
+ *  Build a simplified Minesweeper game in React where the user can click cells to reveal them. 
+ *  Some cells contain mines, others show the number of adjacent mines. 
+ *  If a cell with zero adjacent mines is revealed, all connected zero-cells should be revealed 
+ *  automatically. 
+ *  The game ends if a mine is revealed, and the player wins if all non-mine cells are revealed.
+ *
+ *  The board should be 10×10.
+  * 
+  * Clicking a cell reveals it:
+  * 
+  * If it’s a mine, the game ends with a “Game Over” message.
+  * 
+  * If it’s not a mine, show the number of adjacent mines (0–8).
+  * 
+  * If the number is 0, automatically reveal all connected empty cells and their numbered border.
+  * 
+  * The player wins when all non-mine cells are revealed.
+  * 
+  * Include a Reset button to restart the game with a new random layout.
+  * 
+  * Optional: Allow right-clicking a cell to toggle a flag icon (to mark suspected mines).
+ */
+
+enum CellValue {
+  EMPTY,
+  BOMB,
+}
+
+interface Cell { 
+  value: CellValue;
+  numberOfAdjacentBomb: number;
+  isShown: boolean;
+  isFlag: boolean,
+}
+
+
+type Board = Cell[][];
+
+enum GameStatus {
+   PLAYABLE="Reveal all the map while not clicking on mine",
+   WON="YOU WON",
+   LOST="YOU LOST"
+}
+
+interface GameState {
+  board: Board;
+  status: GameStatus;
+}
+
+const MAX_SIZE_LEN = 10;
+const N_BOMBS = 10;
+const DIRECTION = [
+  [0,1],
+  [1,1],
+  [1,0],
+  [1,-1],
+  [0,-1],
+  [-1,-1],
+  [-1,0],
+  [-1,1],
+]
+
+const constructGameState = (): GameState => {
+  let board: Board = Array.from(
+    { length: MAX_SIZE_LEN }, () => Array.from(
+      { length: MAX_SIZE_LEN }, (): Cell => (
+        {
+          value: CellValue.EMPTY, numberOfAdjacentBomb: 0, isShown: false ,
+          isFlag: false,
+        })
+    )
+  );
+
+  // Place the bombs
+  let placedBomb = 0; 
+  const bombPlacments: number[][] = [];
+  while (placedBomb < N_BOMBS) {
+    const bombRowIdx = Math.floor(Math.random() * MAX_SIZE_LEN);
+    const bombColIdx = Math.floor(Math.random() * MAX_SIZE_LEN);
+    if (board[bombRowIdx][bombColIdx].value === CellValue.EMPTY) {
+      board[bombRowIdx][bombColIdx] = {
+        ...board[bombRowIdx][bombColIdx],
+        value: CellValue.BOMB,
+      }
+      placedBomb++;
+      bombPlacments.push([bombRowIdx, bombColIdx]);
+    }
+  }
+
+  // Find all adjacent cells and add values
+  for (const [bombR, bombC] of bombPlacments) {
+    for (const [dr, dc] of DIRECTION) {
+      if (board[bombR+dr]?.[bombC+dc]?.value === CellValue.EMPTY) {
+        board[bombR+dr][bombC+dc] = {
+          ...board[bombR+dr][bombC+dc],
+          numberOfAdjacentBomb: board[bombR+dr][bombC+dc].numberOfAdjacentBomb + 1
+        }
+      }
+    }
+  }
+
+  return {
+    board,
+    status: GameStatus.PLAYABLE,
+  }
+}
+
+enum GameActionEnum {
+  CHECK,
+  RESET,
+  FLAG,
+}
+
+interface Position {
+  row: number;
+  col: number;
+}
+
+type GameAction = 
+  | { type: GameActionEnum.CHECK, position: Position }
+  | { type: GameActionEnum.FLAG, position: Position }
+  | { type: GameActionEnum.RESET }
+
+const gameStateReducer = (state: GameState, action: GameAction): GameState => {
+  switch(action.type) {
+    case GameActionEnum.CHECK:
+      const {position} = action;
+      const board = state.board.map(row=>row.map(col=>({...col})));
+      const start = board[position.row][position.col];
+
+      if (start.value === CellValue.BOMB) {
+        for (const row of board) for (const cell of row) cell.isShown = true;
+        return { board, status: GameStatus.LOST };
+      }
+
+      if (!start.isShown) start.isShown = true;
+
+      if (start.numberOfAdjacentBomb === 0) {
+        const q: Position[] = [position];
+        while (q.length) {
+          const { row, col } = q.shift()!;
+          for (const [dr, dc] of DIRECTION) {
+            const r = row + dr, c = col + dc;
+            const n = board[r]?.[c];
+            if (!n || n.isShown || n.value === CellValue.BOMB) continue;
+            n.isShown = true;
+            if (n.numberOfAdjacentBomb === 0) q.push({ row: r, col: c });
+          }
+        }
+      }
+
+      const hidden = board.flat().filter(c => !c.isShown).length;
+      const status = hidden === N_BOMBS ? GameStatus.WON : GameStatus.PLAYABLE;
+      return { board, status };
+
+    case GameActionEnum.FLAG:
+      const newBoard = state.board.map(
+        (row, rowIdx) => rowIdx === action.position.row ?
+          row.map(
+            (col, colIdx): Cell => 
+              colIdx === action.position.col ? 
+                ({...col, isFlag: true }) : col
+        ) : row
+      );
+      console.log(newBoard);
+      return {
+        ...state,
+        board: newBoard,
+      }
+    case GameActionEnum.RESET:
+      return constructGameState();
+  }
+}
+
+interface StylesProp {
+  board: CSSProperties;
+  cell: CSSProperties;
+}
+
+const styles: StylesProp = {
+  board: {
+    display: "grid",
+    gridTemplateColumns: `repeat(${MAX_SIZE_LEN}, 1fr)`,
+    width: 400,
+  },
+  cell: {
+    display: "flex",
+    justifyContent: "center",
+    alignItems: "center",
+    width: "100%",
+    aspectRatio: "1 / 1",
+    border: "1px solid black",
+    backgroundColor: "#e6e6e6"
+  }
+}
+
+interface CellComponentProp {
+  cellValue: Cell;
+  row: number;
+  col: number;
+  placeDispatch: (row: number, col: number) => void;
+  flagDispatch: (row: number, col: number) => void;
+}
+
+const CellComponent = memo(({
+  cellValue, row,
+  col, placeDispatch,
+  flagDispatch
+}: CellComponentProp) => {
+  console.log("re-render");
+  const handleOnClikck = useCallback(() => {
+    placeDispatch(row, col);
+  }, [row, col, placeDispatch])
+
+  const handleOnRightClikck = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
+    event.preventDefault();
+    flagDispatch(row, col);
+  }, [row, col, placeDispatch])
+
+
+  return (
+    <div
+      style={{
+        ...styles.cell,
+        cursor: "pointer",
+        backgroundColor: cellValue.isShown ? "#fafafa" : "#e6e6e6",
+      }}
+      onClick={handleOnClikck}
+      onContextMenu={handleOnRightClikck} 
+    >
+      {cellValue.isShown
+        ? cellValue.value === CellValue.BOMB
+          ? "💣"
+          : cellValue.numberOfAdjacentBomb || ""
+        : cellValue.isFlag
+        ? "🚩" 
+        : ""}
+    </div>
+  )
+}, (prev, next) =>
+  prev.cellValue.isShown ===  next.cellValue.isShown &&
+  prev.cellValue.isFlag ===  next.cellValue.isFlag)
+
+const Minesweeper = () => {
+  const [state, dispatch] = useReducer(gameStateReducer, null, constructGameState);
+
+  const placeDispatch = useCallback(
+    (row: number, col: number) => {
+      dispatch({type: GameActionEnum.CHECK, position: { row, col }})
+  }, [dispatch])
+
+  const flagDispatch = useCallback(
+    (row: number, col: number) => {
+      dispatch({type: GameActionEnum.FLAG, position: { row, col }})
+  }, [dispatch])
+
+  return (
+    <>
+      <h1> {state.status} </h1>
+      <div style={styles.board}> 
+        {state.board.map(
+          (row, rowIdx) => row.map(
+            (cell, colIdx) => (
+              <CellComponent
+                key={`${rowIdx}-${colIdx}`}
+                cellValue={cell} 
+                row={rowIdx}
+                col={colIdx}
+                placeDispatch={placeDispatch}
+                flagDispatch={flagDispatch} />)
+          )
+        )} 
+      </div>
+      <button onClick={()=>{dispatch({type: GameActionEnum.RESET})}}> reset </button>
+    </>
+  )
+}
+
+export {
+  Minesweeper,
+}