feature/add-user-interaction #3
					 10 changed files with 240 additions and 71 deletions
				
			
		|  | @ -4,6 +4,7 @@ | |||
|     "react-dom": "^18.0.0" | ||||
|   }, | ||||
|   "main": "/index.js", | ||||
|   "homepage": ".", | ||||
|   "devDependencies": { | ||||
|     "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 InputForm from "./InputForm"; | ||||
| import reduce from "./reducer"; | ||||
| import MessageBanner from "./MessageBanner"; | ||||
| 
 | ||||
| export default function App() { | ||||
|     const [maze, setMaze] = useState({}); | ||||
|     const [showSolution, setShowSolution] = useState(false); | ||||
|     const hasValidMaze = !!maze.width && | ||||
|         !!maze.height && | ||||
|         !!maze.id && | ||||
|         !!maze.grid; | ||||
|     const [state, dispatch] = useReducer(reduce, { | ||||
|             maze: null, | ||||
|             loading: false, | ||||
|             errorMessage: null, | ||||
|             showSolution: false, | ||||
|             userPath: [] | ||||
|         }, | ||||
|         undefined); | ||||
|     const hasValidMaze = !!state.maze; | ||||
|     return ( | ||||
|         <> | ||||
|             <InputForm handleResult={setMaze}/> | ||||
|             <MessageBanner state={state} | ||||
|                            dispatch={dispatch}/> | ||||
|             <InputForm state={state} | ||||
|                        dispatch={dispatch}/> | ||||
|             {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"} | ||||
|                            onChange={(e) => { | ||||
|                                setShowSolution(e.target.checked); | ||||
|                                dispatch({ | ||||
|                                    type: 'toggled_show_solution', | ||||
|                                    value: e.target.checked | ||||
|                                }); | ||||
|                            }} | ||||
|                            id={"showSolution"}/><label htmlFor="showSolution">Show Solution</label> | ||||
|                     <Maze labyrinth={maze} | ||||
|                           showSolution={showSolution}/> | ||||
|                     <Maze state={state} | ||||
|                           dispatch={dispatch}/> | ||||
|                 </> | ||||
|             } | ||||
|         </> | ||||
|  |  | |||
							
								
								
									
										42
									
								
								src/Cell.js
									
										
									
									
									
								
							
							
						
						
									
										42
									
								
								src/Cell.js
									
										
									
									
									
								
							|  | @ -1,14 +1,38 @@ | |||
| import React from 'react'; | ||||
| 
 | ||||
| export default function Cell({spec, rowIndex, cellIndex, showSolution}) { | ||||
|     let classes = "cell r" + rowIndex + " c" + cellIndex; | ||||
|     if (spec.top) classes += " top"; | ||||
|     if (spec.right) classes += " right"; | ||||
|     if (spec.bottom) classes += " bottom"; | ||||
|     if (spec.left) classes += " left"; | ||||
|     if (spec.solution && showSolution) classes += " solution"; | ||||
|     if (spec.user) classes += " user"; | ||||
| function isMarked(x, y, marked) { | ||||
|     return !!marked.find(e => e.x === x && e.y === y); | ||||
| } | ||||
| 
 | ||||
| export default function Cell({x, y, state, dispatch}) { | ||||
|     const cell = state.maze.grid[y][x]; | ||||
|     let classes = "cell r" + y + " c" + x; | ||||
|     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 ( | ||||
|         <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 ValidatingInputNumberField from "./ValidatingInputNumberField"; | ||||
| 
 | ||||
| export default function InputForm({handleResult}) { | ||||
| export default function InputForm({state, dispatch}) { | ||||
|     const [width, setWidth] = useState(10); | ||||
|     const [height, setHeight] = useState(10); | ||||
|     const [id, setId] = useState(null); | ||||
|     const [status, setStatus] = useState("typing"); // "typing", "submitting"
 | ||||
| 
 | ||||
|     if (status === "submitted") { | ||||
|         return <span/>; | ||||
|     } | ||||
|     const callAPI = () => { | ||||
|         let url = "https://manuel.friedli.info/labyrinth/create/json?w=" + width + | ||||
|             "&h=" + height; | ||||
|         if (!!id) { | ||||
|             url += "&id=" + id; | ||||
|         } | ||||
|     const handleSubmit = (e) => { | ||||
|         e.preventDefault(); | ||||
|         dispatch({ | ||||
|             type: 'started_loading' | ||||
|         }); | ||||
|         const url = `https://manuel.friedli.info/labyrinth/create/json?w=${width}&h=${height}&id=${id || ''}`; | ||||
|         fetch(url) | ||||
|             .then(response => response.json()) | ||||
|             // .then(result => new Promise(resolve => setTimeout(resolve, 600, result)))
 | ||||
|             .then(result => { | ||||
|                 handleResult(result); | ||||
|                 setId(_ => result.id); | ||||
|                 dispatch({ | ||||
|                     type: 'loaded_maze', | ||||
|                     maze: result | ||||
|                 }); | ||||
|             }) | ||||
|             .catch(reason => { | ||||
|                 console.error("Failed to fetch maze data.", reason); | ||||
|                 // FIXME alert is not user friendly
 | ||||
|                 alert("Failed to fetch maze data: " + reason); | ||||
|             }) | ||||
|             .finally(() => { | ||||
|                 setStatus("typing"); | ||||
|                 dispatch({ | ||||
|                     type: 'loading_failed', | ||||
|                     reason | ||||
|                 }); | ||||
|             }); | ||||
|     }; | ||||
|     const handleSubmit = (e) => { | ||||
|         e.preventDefault(); | ||||
|         setStatus("submitting"); | ||||
|         callAPI(); | ||||
|     }; | ||||
|     const validateWidthHeightInput = value => { | ||||
|         if (isNaN(value) || "" === value || (Math.floor(value) !== Number(value))) { | ||||
|             return { | ||||
|  | @ -79,7 +72,7 @@ export default function InputForm({handleResult}) { | |||
|                                             min: 2 | ||||
|                                         }} | ||||
|                                         validatorFn={validateWidthHeightInput} | ||||
|                                         disabled={status === "submitting"} | ||||
|                                         disabled={state.loading} | ||||
|                                         onChange={setWidth} | ||||
|             /><br/> | ||||
|             <ValidatingInputNumberField id={"height"} | ||||
|  | @ -89,21 +82,21 @@ export default function InputForm({handleResult}) { | |||
|                                             min: 2 | ||||
|                                         }} | ||||
|                                         validatorFn={validateWidthHeightInput} | ||||
|                                         disabled={status === "submitting"} | ||||
|                                         disabled={state.loading} | ||||
|                                         onChange={setHeight} | ||||
|             /><br/> | ||||
|             <ValidatingInputNumberField id={"id"} | ||||
|                                         label={"ID (optional)"} | ||||
|                                         value={id} | ||||
|                                         validatorFn={validateIdInput} | ||||
|                                         disabled={status === "submitting"} | ||||
|                                         disabled={state.loading} | ||||
|                                         onChange={setId} | ||||
|             /><br/> | ||||
|             <button type={"submit"} | ||||
|                     disabled={status === "submitting" | ||||
|                     disabled={state.loading | ||||
|                         || isNaN(width) | ||||
|                         || isNaN(height) | ||||
|                     }>GO! | ||||
|                     }>{state.loading ? "Loading ..." : "Create Maze!"} | ||||
|             </button> | ||||
|         </form> | ||||
|     ); | ||||
|  |  | |||
							
								
								
									
										17
									
								
								src/Maze.js
									
										
									
									
									
								
							
							
						
						
									
										17
									
								
								src/Maze.js
									
										
									
									
									
								
							|  | @ -1,15 +1,20 @@ | |||
| import React from 'react'; | ||||
| import Row from "./Row"; | ||||
| import Cell from "./Cell"; | ||||
| 
 | ||||
| 
 | ||||
| export default function Maze({labyrinth, showSolution = false}) { | ||||
|     if (!labyrinth.grid) { | ||||
| export default function Maze({state, dispatch}) { | ||||
|     if (!state.maze) { | ||||
|         return <div>No valid maze.</div> | ||||
|     } | ||||
| 
 | ||||
|     const maze = labyrinth.grid.map((row, rowIdx) => <Row key={"r" + rowIdx} spec={row} | ||||
|                                                           index={rowIdx} | ||||
|                                                           showSolution={showSolution}/>); | ||||
|     let maze = []; | ||||
|     for (let y = 0; y < state.maze.height; y++) { | ||||
|         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 ( | ||||
|         <div className={"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; | ||||
|     height: 2em; | ||||
|     width: 2em; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
| } | ||||
| 
 | ||||
|  | @ -41,15 +40,28 @@ div.cell.bottom { | |||
| div.cell.left { | ||||
|     border-left-color: #000; | ||||
| } | ||||
| 
 | ||||
| div.cell.user { | ||||
|     background-color: hotpink; | ||||
| } | ||||
| 
 | ||||
| div.cell.user2 { | ||||
|     border-radius: 0.5em; | ||||
| } | ||||
| 
 | ||||
| div.cell.solution.user { | ||||
|     background-color: darkred; | ||||
| } | ||||
| 
 | ||||
| div.message-banner { | ||||
|     border: 1px solid red; | ||||
|     background-color: #ff000044; | ||||
| } | ||||
| 
 | ||||
| input:invalid { | ||||
|     border-color: #f00; | ||||
| } | ||||
| 
 | ||||
| body { | ||||
|     font-family: sans-serif; | ||||
|     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