Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 36:84672efec192 | 37:fb9bcd3145cb |
|---|---|
| 1 import { CSSProperties, memo, useCallback, useReducer } from 'react'; | |
| 2 | |
| 3 /** | |
| 4 * | |
| 5 * Build a simplified Minesweeper game in React where the user can click cells to reveal them. | |
| 6 * Some cells contain mines, others show the number of adjacent mines. | |
| 7 * If a cell with zero adjacent mines is revealed, all connected zero-cells should be revealed | |
| 8 * automatically. | |
| 9 * The game ends if a mine is revealed, and the player wins if all non-mine cells are revealed. | |
| 10 * | |
| 11 * The board should be 10×10. | |
| 12 * | |
| 13 * Clicking a cell reveals it: | |
| 14 * | |
| 15 * If it’s a mine, the game ends with a “Game Over” message. | |
| 16 * | |
| 17 * If it’s not a mine, show the number of adjacent mines (0–8). | |
| 18 * | |
| 19 * If the number is 0, automatically reveal all connected empty cells and their numbered border. | |
| 20 * | |
| 21 * The player wins when all non-mine cells are revealed. | |
| 22 * | |
| 23 * Include a Reset button to restart the game with a new random layout. | |
| 24 * | |
| 25 * Optional: Allow right-clicking a cell to toggle a flag icon (to mark suspected mines). | |
| 26 */ | |
| 27 | |
| 28 enum CellValue { | |
| 29 EMPTY, | |
| 30 BOMB, | |
| 31 } | |
| 32 | |
| 33 interface Cell { | |
| 34 value: CellValue; | |
| 35 numberOfAdjacentBomb: number; | |
| 36 isShown: boolean; | |
| 37 isFlag: boolean, | |
| 38 } | |
| 39 | |
| 40 | |
| 41 type Board = Cell[][]; | |
| 42 | |
| 43 enum GameStatus { | |
| 44 PLAYABLE="Reveal all the map while not clicking on mine", | |
| 45 WON="YOU WON", | |
| 46 LOST="YOU LOST" | |
| 47 } | |
| 48 | |
| 49 interface GameState { | |
| 50 board: Board; | |
| 51 status: GameStatus; | |
| 52 } | |
| 53 | |
| 54 const MAX_SIZE_LEN = 10; | |
| 55 const N_BOMBS = 10; | |
| 56 const DIRECTION = [ | |
| 57 [0,1], | |
| 58 [1,1], | |
| 59 [1,0], | |
| 60 [1,-1], | |
| 61 [0,-1], | |
| 62 [-1,-1], | |
| 63 [-1,0], | |
| 64 [-1,1], | |
| 65 ] | |
| 66 | |
| 67 const constructGameState = (): GameState => { | |
| 68 let board: Board = Array.from( | |
| 69 { length: MAX_SIZE_LEN }, () => Array.from( | |
| 70 { length: MAX_SIZE_LEN }, (): Cell => ( | |
| 71 { | |
| 72 value: CellValue.EMPTY, numberOfAdjacentBomb: 0, isShown: false , | |
| 73 isFlag: false, | |
| 74 }) | |
| 75 ) | |
| 76 ); | |
| 77 | |
| 78 // Place the bombs | |
| 79 let placedBomb = 0; | |
| 80 const bombPlacments: number[][] = []; | |
| 81 while (placedBomb < N_BOMBS) { | |
| 82 const bombRowIdx = Math.floor(Math.random() * MAX_SIZE_LEN); | |
| 83 const bombColIdx = Math.floor(Math.random() * MAX_SIZE_LEN); | |
| 84 if (board[bombRowIdx][bombColIdx].value === CellValue.EMPTY) { | |
| 85 board[bombRowIdx][bombColIdx] = { | |
| 86 ...board[bombRowIdx][bombColIdx], | |
| 87 value: CellValue.BOMB, | |
| 88 } | |
| 89 placedBomb++; | |
| 90 bombPlacments.push([bombRowIdx, bombColIdx]); | |
| 91 } | |
| 92 } | |
| 93 | |
| 94 // Find all adjacent cells and add values | |
| 95 for (const [bombR, bombC] of bombPlacments) { | |
| 96 for (const [dr, dc] of DIRECTION) { | |
| 97 if (board[bombR+dr]?.[bombC+dc]?.value === CellValue.EMPTY) { | |
| 98 board[bombR+dr][bombC+dc] = { | |
| 99 ...board[bombR+dr][bombC+dc], | |
| 100 numberOfAdjacentBomb: board[bombR+dr][bombC+dc].numberOfAdjacentBomb + 1 | |
| 101 } | |
| 102 } | |
| 103 } | |
| 104 } | |
| 105 | |
| 106 return { | |
| 107 board, | |
| 108 status: GameStatus.PLAYABLE, | |
| 109 } | |
| 110 } | |
| 111 | |
| 112 enum GameActionEnum { | |
| 113 CHECK, | |
| 114 RESET, | |
| 115 FLAG, | |
| 116 } | |
| 117 | |
| 118 interface Position { | |
| 119 row: number; | |
| 120 col: number; | |
| 121 } | |
| 122 | |
| 123 type GameAction = | |
| 124 | { type: GameActionEnum.CHECK, position: Position } | |
| 125 | { type: GameActionEnum.FLAG, position: Position } | |
| 126 | { type: GameActionEnum.RESET } | |
| 127 | |
| 128 const gameStateReducer = (state: GameState, action: GameAction): GameState => { | |
| 129 switch(action.type) { | |
| 130 case GameActionEnum.CHECK: | |
| 131 const {position} = action; | |
| 132 const board = state.board.map(row=>row.map(col=>({...col}))); | |
| 133 const start = board[position.row][position.col]; | |
| 134 | |
| 135 if (start.value === CellValue.BOMB) { | |
| 136 for (const row of board) for (const cell of row) cell.isShown = true; | |
| 137 return { board, status: GameStatus.LOST }; | |
| 138 } | |
| 139 | |
| 140 if (!start.isShown) start.isShown = true; | |
| 141 | |
| 142 if (start.numberOfAdjacentBomb === 0) { | |
| 143 const q: Position[] = [position]; | |
| 144 while (q.length) { | |
| 145 const { row, col } = q.shift()!; | |
| 146 for (const [dr, dc] of DIRECTION) { | |
| 147 const r = row + dr, c = col + dc; | |
| 148 const n = board[r]?.[c]; | |
| 149 if (!n || n.isShown || n.value === CellValue.BOMB) continue; | |
| 150 n.isShown = true; | |
| 151 if (n.numberOfAdjacentBomb === 0) q.push({ row: r, col: c }); | |
| 152 } | |
| 153 } | |
| 154 } | |
| 155 | |
| 156 const hidden = board.flat().filter(c => !c.isShown).length; | |
| 157 const status = hidden === N_BOMBS ? GameStatus.WON : GameStatus.PLAYABLE; | |
| 158 return { board, status }; | |
| 159 | |
| 160 case GameActionEnum.FLAG: | |
| 161 const newBoard = state.board.map( | |
| 162 (row, rowIdx) => rowIdx === action.position.row ? | |
| 163 row.map( | |
| 164 (col, colIdx): Cell => | |
| 165 colIdx === action.position.col ? | |
| 166 ({...col, isFlag: true }) : col | |
| 167 ) : row | |
| 168 ); | |
| 169 console.log(newBoard); | |
| 170 return { | |
| 171 ...state, | |
| 172 board: newBoard, | |
| 173 } | |
| 174 case GameActionEnum.RESET: | |
| 175 return constructGameState(); | |
| 176 } | |
| 177 } | |
| 178 | |
| 179 interface StylesProp { | |
| 180 board: CSSProperties; | |
| 181 cell: CSSProperties; | |
| 182 } | |
| 183 | |
| 184 const styles: StylesProp = { | |
| 185 board: { | |
| 186 display: "grid", | |
| 187 gridTemplateColumns: `repeat(${MAX_SIZE_LEN}, 1fr)`, | |
| 188 width: 400, | |
| 189 }, | |
| 190 cell: { | |
| 191 display: "flex", | |
| 192 justifyContent: "center", | |
| 193 alignItems: "center", | |
| 194 width: "100%", | |
| 195 aspectRatio: "1 / 1", | |
| 196 border: "1px solid black", | |
| 197 backgroundColor: "#e6e6e6" | |
| 198 } | |
| 199 } | |
| 200 | |
| 201 interface CellComponentProp { | |
| 202 cellValue: Cell; | |
| 203 row: number; | |
| 204 col: number; | |
| 205 placeDispatch: (row: number, col: number) => void; | |
| 206 flagDispatch: (row: number, col: number) => void; | |
| 207 } | |
| 208 | |
| 209 const CellComponent = memo(({ | |
| 210 cellValue, row, | |
| 211 col, placeDispatch, | |
| 212 flagDispatch | |
| 213 }: CellComponentProp) => { | |
| 214 console.log("re-render"); | |
| 215 const handleOnClikck = useCallback(() => { | |
| 216 placeDispatch(row, col); | |
| 217 }, [row, col, placeDispatch]) | |
| 218 | |
| 219 const handleOnRightClikck = useCallback((event: React.MouseEvent<HTMLDivElement>) => { | |
| 220 event.preventDefault(); | |
| 221 flagDispatch(row, col); | |
| 222 }, [row, col, placeDispatch]) | |
| 223 | |
| 224 | |
| 225 return ( | |
| 226 <div | |
| 227 style={{ | |
| 228 ...styles.cell, | |
| 229 cursor: "pointer", | |
| 230 backgroundColor: cellValue.isShown ? "#fafafa" : "#e6e6e6", | |
| 231 }} | |
| 232 onClick={handleOnClikck} | |
| 233 onContextMenu={handleOnRightClikck} | |
| 234 > | |
| 235 {cellValue.isShown | |
| 236 ? cellValue.value === CellValue.BOMB | |
| 237 ? "💣" | |
| 238 : cellValue.numberOfAdjacentBomb || "" | |
| 239 : cellValue.isFlag | |
| 240 ? "🚩" | |
| 241 : ""} | |
| 242 </div> | |
| 243 ) | |
| 244 }, (prev, next) => | |
| 245 prev.cellValue.isShown === next.cellValue.isShown && | |
| 246 prev.cellValue.isFlag === next.cellValue.isFlag) | |
| 247 | |
| 248 const Minesweeper = () => { | |
| 249 const [state, dispatch] = useReducer(gameStateReducer, null, constructGameState); | |
| 250 | |
| 251 const placeDispatch = useCallback( | |
| 252 (row: number, col: number) => { | |
| 253 dispatch({type: GameActionEnum.CHECK, position: { row, col }}) | |
| 254 }, [dispatch]) | |
| 255 | |
| 256 const flagDispatch = useCallback( | |
| 257 (row: number, col: number) => { | |
| 258 dispatch({type: GameActionEnum.FLAG, position: { row, col }}) | |
| 259 }, [dispatch]) | |
| 260 | |
| 261 return ( | |
| 262 <> | |
| 263 <h1> {state.status} </h1> | |
| 264 <div style={styles.board}> | |
| 265 {state.board.map( | |
| 266 (row, rowIdx) => row.map( | |
| 267 (cell, colIdx) => ( | |
| 268 <CellComponent | |
| 269 key={`${rowIdx}-${colIdx}`} | |
| 270 cellValue={cell} | |
| 271 row={rowIdx} | |
| 272 col={colIdx} | |
| 273 placeDispatch={placeDispatch} | |
| 274 flagDispatch={flagDispatch} />) | |
| 275 ) | |
| 276 )} | |
| 277 </div> | |
| 278 <button onClick={()=>{dispatch({type: GameActionEnum.RESET})}}> reset </button> | |
| 279 </> | |
| 280 ) | |
| 281 } | |
| 282 | |
| 283 export { | |
| 284 Minesweeper, | |
| 285 } |