view react_games/src/Minesweeper/main.tsx @ 71:75de5903355c

Giagantic changes that update Dowa library to be more align with stb style array and hashmap. Updated Seobeo to be caching on server side instead of file level caching. Deleted bunch of things I don't really use.
author June Park <parkjune1995@gmail.com>
date Sun, 28 Dec 2025 20:34:22 -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,
}