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