view react_games/src/Minesweeper/main.tsx @ 148:76cd7afa6b8e

[Configs] Updated configs and finally added ctags.
author June Park <parkjune1995@gmail.com>
date Sat, 10 Jan 2026 05:04:19 -0800
parents fb9bcd3145cb
children
line wrap: on
line source

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,
}