feature/add-user-interaction #3
					 10 changed files with 240 additions and 71 deletions
				
			
		|  | @ -4,6 +4,7 @@ | ||||||
|     "react-dom": "^18.0.0" |     "react-dom": "^18.0.0" | ||||||
|   }, |   }, | ||||||
|   "main": "/index.js", |   "main": "/index.js", | ||||||
|  |   "homepage": ".", | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "react-scripts": "^5.0.1" |     "react-scripts": "^5.0.1" | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
							
								
								
									
										35
									
								
								src/App.js
									
										
									
									
									
								
							
							
						
						
									
										35
									
								
								src/App.js
									
										
									
									
									
								
							|  | @ -1,27 +1,38 @@ | ||||||
| import React, {useState} from 'react'; | import React, {useReducer} from 'react'; | ||||||
| import Maze from "./Maze"; | import Maze from "./Maze"; | ||||||
| import InputForm from "./InputForm"; | import InputForm from "./InputForm"; | ||||||
|  | import reduce from "./reducer"; | ||||||
|  | import MessageBanner from "./MessageBanner"; | ||||||
| 
 | 
 | ||||||
| export default function App() { | export default function App() { | ||||||
|     const [maze, setMaze] = useState({}); |     const [state, dispatch] = useReducer(reduce, { | ||||||
|     const [showSolution, setShowSolution] = useState(false); |             maze: null, | ||||||
|     const hasValidMaze = !!maze.width && |             loading: false, | ||||||
|         !!maze.height && |             errorMessage: null, | ||||||
|         !!maze.id && |             showSolution: false, | ||||||
|         !!maze.grid; |             userPath: [] | ||||||
|  |         }, | ||||||
|  |         undefined); | ||||||
|  |     const hasValidMaze = !!state.maze; | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             <InputForm handleResult={setMaze}/> |             <MessageBanner state={state} | ||||||
|  |                            dispatch={dispatch}/> | ||||||
|  |             <InputForm state={state} | ||||||
|  |                        dispatch={dispatch}/> | ||||||
|             {hasValidMaze && |             {hasValidMaze && | ||||||
|                 <> |                 <> | ||||||
|                     <h1>The Maze ({maze.width}x{maze.height}, ID: {maze.id})</h1> |                     <h1>The Maze ({state.maze.width}x{state.maze.height}, ID: {state.maze.id})</h1> | ||||||
|                     <input type={"checkbox"} |                     <input type={"checkbox"} | ||||||
|                            onChange={(e) => { |                            onChange={(e) => { | ||||||
|                                setShowSolution(e.target.checked); |                                dispatch({ | ||||||
|  |                                    type: 'toggled_show_solution', | ||||||
|  |                                    value: e.target.checked | ||||||
|  |                                }); | ||||||
|                            }} |                            }} | ||||||
|                            id={"showSolution"}/><label htmlFor="showSolution">Show Solution</label> |                            id={"showSolution"}/><label htmlFor="showSolution">Show Solution</label> | ||||||
|                     <Maze labyrinth={maze} |                     <Maze state={state} | ||||||
|                           showSolution={showSolution}/> |                           dispatch={dispatch}/> | ||||||
|                 </> |                 </> | ||||||
|             } |             } | ||||||
|         </> |         </> | ||||||
|  |  | ||||||
							
								
								
									
										42
									
								
								src/Cell.js
									
										
									
									
									
								
							
							
						
						
									
										42
									
								
								src/Cell.js
									
										
									
									
									
								
							|  | @ -1,14 +1,38 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| 
 | 
 | ||||||
| export default function Cell({spec, rowIndex, cellIndex, showSolution}) { | function isMarked(x, y, marked) { | ||||||
|     let classes = "cell r" + rowIndex + " c" + cellIndex; |     return !!marked.find(e => e.x === x && e.y === y); | ||||||
|     if (spec.top) classes += " top"; | } | ||||||
|     if (spec.right) classes += " right"; | 
 | ||||||
|     if (spec.bottom) classes += " bottom"; | export default function Cell({x, y, state, dispatch}) { | ||||||
|     if (spec.left) classes += " left"; |     const cell = state.maze.grid[y][x]; | ||||||
|     if (spec.solution && showSolution) classes += " solution"; |     let classes = "cell r" + y + " c" + x; | ||||||
|     if (spec.user) classes += " user"; |     if (cell.top) classes += " top"; | ||||||
|  |     if (cell.right) classes += " right"; | ||||||
|  |     if (cell.bottom) classes += " bottom"; | ||||||
|  |     if (cell.left) classes += " left"; | ||||||
|  |     if (cell.solution && state.showSolution) classes += " solution"; | ||||||
|  |     const marked = isMarked(x, y, state.userPath); | ||||||
|  |     if (marked) classes += " user"; | ||||||
|     return ( |     return ( | ||||||
|         <div className={classes}/> |         <div className={classes} | ||||||
|  |              onMouseEnter={(e) => { | ||||||
|  |                  const leftPressed = e.buttons & 0x1; | ||||||
|  |                  if (leftPressed) { | ||||||
|  |                      dispatch({ | ||||||
|  |                          type: 'clicked_cell', | ||||||
|  |                          x, | ||||||
|  |                          y | ||||||
|  |                      }); | ||||||
|  |                  } | ||||||
|  |              }} | ||||||
|  |              onClick={(e) => { | ||||||
|  |                  dispatch({ | ||||||
|  |                      type: 'clicked_cell', | ||||||
|  |                      x, | ||||||
|  |                      y | ||||||
|  |                  }); | ||||||
|  |              }}> | ||||||
|  |         </div> | ||||||
|     ); |     ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,41 +1,34 @@ | ||||||
| import React, {useState} from 'react'; | import React, {useState} from 'react'; | ||||||
| import ValidatingInputNumberField from "./ValidatingInputNumberField"; | import ValidatingInputNumberField from "./ValidatingInputNumberField"; | ||||||
| 
 | 
 | ||||||
| export default function InputForm({handleResult}) { | export default function InputForm({state, dispatch}) { | ||||||
|     const [width, setWidth] = useState(10); |     const [width, setWidth] = useState(10); | ||||||
|     const [height, setHeight] = useState(10); |     const [height, setHeight] = useState(10); | ||||||
|     const [id, setId] = useState(null); |     const [id, setId] = useState(null); | ||||||
|     const [status, setStatus] = useState("typing"); // "typing", "submitting"
 |  | ||||||
| 
 | 
 | ||||||
|     if (status === "submitted") { |     const handleSubmit = (e) => { | ||||||
|         return <span/>; |         e.preventDefault(); | ||||||
|     } |         dispatch({ | ||||||
|     const callAPI = () => { |             type: 'started_loading' | ||||||
|         let url = "https://manuel.friedli.info/labyrinth/create/json?w=" + width + |         }); | ||||||
|             "&h=" + height; |         const url = `https://manuel.friedli.info/labyrinth/create/json?w=${width}&h=${height}&id=${id || ''}`; | ||||||
|         if (!!id) { |  | ||||||
|             url += "&id=" + id; |  | ||||||
|         } |  | ||||||
|         fetch(url) |         fetch(url) | ||||||
|             .then(response => response.json()) |             .then(response => response.json()) | ||||||
|  |             // .then(result => new Promise(resolve => setTimeout(resolve, 600, result)))
 | ||||||
|             .then(result => { |             .then(result => { | ||||||
|                 handleResult(result); |                 dispatch({ | ||||||
|                 setId(_ => result.id); |                     type: 'loaded_maze', | ||||||
|  |                     maze: result | ||||||
|  |                 }); | ||||||
|             }) |             }) | ||||||
|             .catch(reason => { |             .catch(reason => { | ||||||
|                 console.error("Failed to fetch maze data.", reason); |                 console.error("Failed to fetch maze data.", reason); | ||||||
|                 // FIXME alert is not user friendly
 |                 dispatch({ | ||||||
|                 alert("Failed to fetch maze data: " + reason); |                     type: 'loading_failed', | ||||||
|             }) |                     reason | ||||||
|             .finally(() => { |                 }); | ||||||
|                 setStatus("typing"); |  | ||||||
|             }); |             }); | ||||||
|     }; |     }; | ||||||
|     const handleSubmit = (e) => { |  | ||||||
|         e.preventDefault(); |  | ||||||
|         setStatus("submitting"); |  | ||||||
|         callAPI(); |  | ||||||
|     }; |  | ||||||
|     const validateWidthHeightInput = value => { |     const validateWidthHeightInput = value => { | ||||||
|         if (isNaN(value) || "" === value || (Math.floor(value) !== Number(value))) { |         if (isNaN(value) || "" === value || (Math.floor(value) !== Number(value))) { | ||||||
|             return { |             return { | ||||||
|  | @ -79,7 +72,7 @@ export default function InputForm({handleResult}) { | ||||||
|                                             min: 2 |                                             min: 2 | ||||||
|                                         }} |                                         }} | ||||||
|                                         validatorFn={validateWidthHeightInput} |                                         validatorFn={validateWidthHeightInput} | ||||||
|                                         disabled={status === "submitting"} |                                         disabled={state.loading} | ||||||
|                                         onChange={setWidth} |                                         onChange={setWidth} | ||||||
|             /><br/> |             /><br/> | ||||||
|             <ValidatingInputNumberField id={"height"} |             <ValidatingInputNumberField id={"height"} | ||||||
|  | @ -89,21 +82,21 @@ export default function InputForm({handleResult}) { | ||||||
|                                             min: 2 |                                             min: 2 | ||||||
|                                         }} |                                         }} | ||||||
|                                         validatorFn={validateWidthHeightInput} |                                         validatorFn={validateWidthHeightInput} | ||||||
|                                         disabled={status === "submitting"} |                                         disabled={state.loading} | ||||||
|                                         onChange={setHeight} |                                         onChange={setHeight} | ||||||
|             /><br/> |             /><br/> | ||||||
|             <ValidatingInputNumberField id={"id"} |             <ValidatingInputNumberField id={"id"} | ||||||
|                                         label={"ID (optional)"} |                                         label={"ID (optional)"} | ||||||
|                                         value={id} |                                         value={id} | ||||||
|                                         validatorFn={validateIdInput} |                                         validatorFn={validateIdInput} | ||||||
|                                         disabled={status === "submitting"} |                                         disabled={state.loading} | ||||||
|                                         onChange={setId} |                                         onChange={setId} | ||||||
|             /><br/> |             /><br/> | ||||||
|             <button type={"submit"} |             <button type={"submit"} | ||||||
|                     disabled={status === "submitting" |                     disabled={state.loading | ||||||
|                         || isNaN(width) |                         || isNaN(width) | ||||||
|                         || isNaN(height) |                         || isNaN(height) | ||||||
|                     }>GO! |                     }>{state.loading ? "Loading ..." : "Create Maze!"} | ||||||
|             </button> |             </button> | ||||||
|         </form> |         </form> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
							
								
								
									
										17
									
								
								src/Maze.js
									
										
									
									
									
								
							
							
						
						
									
										17
									
								
								src/Maze.js
									
										
									
									
									
								
							|  | @ -1,15 +1,20 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import Row from "./Row"; | import Cell from "./Cell"; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| export default function Maze({labyrinth, showSolution = false}) { | export default function Maze({state, dispatch}) { | ||||||
|     if (!labyrinth.grid) { |     if (!state.maze) { | ||||||
|         return <div>No valid maze.</div> |         return <div>No valid maze.</div> | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const maze = labyrinth.grid.map((row, rowIdx) => <Row key={"r" + rowIdx} spec={row} |     let maze = []; | ||||||
|                                                           index={rowIdx} |     for (let y = 0; y < state.maze.height; y++) { | ||||||
|                                                           showSolution={showSolution}/>); |         let row = []; | ||||||
|  |         for (let x = 0; x < state.maze.width; x++) { | ||||||
|  |             row.push(<Cell key={`${x}x${y}`} x={x} y={y} state={state} dispatch={dispatch}/>) | ||||||
|  |         } | ||||||
|  |         maze.push(<div key={`r${y}`} className={"row"}>{row}</div>); | ||||||
|  |     } | ||||||
|     return ( |     return ( | ||||||
|         <div className={"maze"}> |         <div className={"maze"}> | ||||||
|             {maze} |             {maze} | ||||||
|  |  | ||||||
							
								
								
									
										17
									
								
								src/MessageBanner.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/MessageBanner.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | ||||||
|  | import React from "react"; | ||||||
|  | 
 | ||||||
|  | export default function MessageBanner({state, dispatch}) { | ||||||
|  |     function handleClose() { | ||||||
|  |         dispatch({ | ||||||
|  |             type: 'closed_message_banner' | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (!!state.errorMessage) { | ||||||
|  |         return (<div className={"message-banner"}> | ||||||
|  |             {state.errorMessage} | ||||||
|  |             <button onClick={handleClose}>Dismiss message</button> | ||||||
|  |         </div>); | ||||||
|  |     } | ||||||
|  |     return <></>; | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								src/Row.js
									
										
									
									
									
								
							
							
						
						
									
										15
									
								
								src/Row.js
									
										
									
									
									
								
							|  | @ -1,15 +0,0 @@ | ||||||
| import React from 'react'; |  | ||||||
| import Cell from "./Cell"; |  | ||||||
| 
 |  | ||||||
| export default function Row({spec, index, showSolution}) { |  | ||||||
|     const cells = spec.map((cell, cellIdx) => <Cell key={"c" + index + "-" + cellIdx} |  | ||||||
|                                                     spec={cell} |  | ||||||
|                                                     rowIndex={index} |  | ||||||
|                                                     cellIndex={cellIdx} |  | ||||||
|                                                     showSolution={showSolution}/>) |  | ||||||
|     return ( |  | ||||||
|         <div className={"row"}> |  | ||||||
|             {cells} |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| } |  | ||||||
							
								
								
									
										48
									
								
								src/reducer.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/reducer.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,48 @@ | ||||||
|  | import handleUserClicked from "./userpathhandler"; | ||||||
|  | 
 | ||||||
|  | export default function reduce(state, action) { | ||||||
|  |     switch (action.type) { | ||||||
|  |         case 'started_loading': { | ||||||
|  |             return { | ||||||
|  |                 ...state, | ||||||
|  |                 maze: null, | ||||||
|  |                 loading: true, | ||||||
|  |                 errorMessage: null | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         case 'loaded_maze': { | ||||||
|  |             return { | ||||||
|  |                 ...state, | ||||||
|  |                 loading: false, | ||||||
|  |                 maze: action.maze, | ||||||
|  |                 userPath: [] | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         case 'loading_failed': { | ||||||
|  |             return { | ||||||
|  |                 ...state, | ||||||
|  |                 loading: false, | ||||||
|  |                 errorMessage: `Failed to load maze. Reason: ${action.reason}` | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         case 'toggled_show_solution': { | ||||||
|  |             return { | ||||||
|  |                 ...state, | ||||||
|  |                 showSolution: action.value | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         case 'closed_message_banner': { | ||||||
|  |             return { | ||||||
|  |                 ...state, | ||||||
|  |                 errorMessage: null | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         case 'clicked_cell': { | ||||||
|  |             // There's so much logic involved, externalize that into its own file.
 | ||||||
|  |             return handleUserClicked(state, action.x, action.y); | ||||||
|  |         } | ||||||
|  |         default: { | ||||||
|  |             throw new Error(`Unknown action: ${action.type}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -18,7 +18,6 @@ div.cell { | ||||||
|     border: 1px solid transparent; |     border: 1px solid transparent; | ||||||
|     height: 2em; |     height: 2em; | ||||||
|     width: 2em; |     width: 2em; | ||||||
|     margin: 0; |  | ||||||
|     padding: 0; |     padding: 0; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -41,15 +40,28 @@ div.cell.bottom { | ||||||
| div.cell.left { | div.cell.left { | ||||||
|     border-left-color: #000; |     border-left-color: #000; | ||||||
| } | } | ||||||
|  | 
 | ||||||
| div.cell.user { | div.cell.user { | ||||||
|     background-color: hotpink; |     background-color: hotpink; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | div.cell.user2 { | ||||||
|  |     border-radius: 0.5em; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| div.cell.solution.user { | div.cell.solution.user { | ||||||
|     background-color: darkred; |     background-color: darkred; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | div.message-banner { | ||||||
|  |     border: 1px solid red; | ||||||
|  |     background-color: #ff000044; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| input:invalid { | input:invalid { | ||||||
|     border-color: #f00; |     border-color: #f00; | ||||||
| } | } | ||||||
|  | 
 | ||||||
| body { | body { | ||||||
|     font-family: sans-serif; |     font-family: sans-serif; | ||||||
|     margin: 20px; |     margin: 20px; | ||||||
|  |  | ||||||
							
								
								
									
										73
									
								
								src/userpathhandler.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/userpathhandler.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | ||||||
|  | export default function handleUserClicked(state, x, y) { | ||||||
|  |     if (isClickAllowed(x, y, state)) { | ||||||
|  |         // Okay, we clicked a cell that's adjacent to the end of the userpath (or which IS the end of the userpath)
 | ||||||
|  |         // and that's not blocked by a wall. Now let's see.
 | ||||||
|  |         if (-1 === state.userPath.findIndex(step => step.x === x && step.y === y)) { | ||||||
|  |             // The clicked cell is not yet part of the userpath --> add it.
 | ||||||
|  |             return { | ||||||
|  |                 ...state, | ||||||
|  |                 userPath: [...state.userPath, {x: x, y: y}] | ||||||
|  |             }; | ||||||
|  |         } else { | ||||||
|  |             // The clicked cell IS part of the userpath. Is it the last cell of it?
 | ||||||
|  |             const lastCoordsFromUserPath = getLastCoordsFromUserPath(state); | ||||||
|  |             if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) { | ||||||
|  |                 // Yes, it's the last cell of the userpath --> remove it.
 | ||||||
|  |                 return { | ||||||
|  |                     ...state, | ||||||
|  |                     userPath: state.userPath.filter(step => step.x !== x || step.y !== y) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     // Not allowed to toggle that cell. Don't apply any change to the state.
 | ||||||
|  |     return state; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function isClickAllowed(x, y, state) { | ||||||
|  |     const lastCoordsFromUserPath = getLastCoordsFromUserPath(state); | ||||||
|  |     if (!lastCoordsFromUserPath) { | ||||||
|  |         // when nothing has been marked yet, we can only toggle the starting position
 | ||||||
|  |         return x === state.maze.start.x && y === state.maze.start.y; | ||||||
|  |     } | ||||||
|  |     if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) { | ||||||
|  |         // toggling the last position in the path is always allowed
 | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (Math.abs(x - lastCoordsFromUserPath.x) + Math.abs(y - lastCoordsFromUserPath.y) !== 1) { | ||||||
|  |         // It's not a neighbor. So it's not allowed.
 | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const lastCell = getCellAt(lastCoordsFromUserPath, state); | ||||||
|  |     if (x === lastCoordsFromUserPath.x + 1) { | ||||||
|  |         // There must be no wall to the right of the last cell.
 | ||||||
|  |         return !lastCell.right; | ||||||
|  |     } | ||||||
|  |     if (x === lastCoordsFromUserPath.x - 1) { | ||||||
|  |         // There must be no wall to the left of the last cell.
 | ||||||
|  |         return !lastCell.left; | ||||||
|  |     } | ||||||
|  |     if (y === lastCoordsFromUserPath.y + 1) { | ||||||
|  |         // There must be no wall below the last cell.
 | ||||||
|  |         return !lastCell.bottom; | ||||||
|  |     } | ||||||
|  |     if (y === lastCoordsFromUserPath.y - 1) { | ||||||
|  |         // There must be no wall above the last cell.
 | ||||||
|  |         return !lastCell.top; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return false; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function getCellAt(coords, state) { | ||||||
|  |     return state.maze.grid[coords.y][coords.x]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function getLastCoordsFromUserPath(state) { | ||||||
|  |     if (state.userPath.length > 0) { | ||||||
|  |         return state.userPath[state.userPath.length - 1]; | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  | } | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue