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 }