From 8f64a65a2a7b35dfeba8cfcb90f805ca56ffaa5f Mon Sep 17 00:00:00 2001 From: Manuel Friedli Date: Mon, 17 Apr 2023 01:19:42 +0200 Subject: [PATCH] Implemented a half-way okay-ish user interaction pattern: mouse drag. --- src/App.js | 35 +++++++++++++------- src/Cell.js | 42 ++++++++++++++++-------- src/InputForm.js | 43 +++++++++++-------------- src/Maze.js | 18 +++++++---- src/MessageBanner.js | 17 ++++++++++ src/Row.js | 15 --------- src/reducer.js | 48 +++++++++++++++++++++++++++ src/styles.css | 14 +++++++- src/userpathhandler.js | 73 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 233 insertions(+), 72 deletions(-) create mode 100644 src/MessageBanner.js delete mode 100644 src/Row.js create mode 100644 src/reducer.js create mode 100644 src/userpathhandler.js diff --git a/src/App.js b/src/App.js index 1cd1180..e02172d 100644 --- a/src/App.js +++ b/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 ( <> - + + {hasValidMaze && <> -

The Maze ({maze.width}x{maze.height}, ID: {maze.id})

+

The Maze ({state.maze.width}x{state.maze.height}, ID: {state.maze.id})

{ - setShowSolution(e.target.checked); + dispatch({ + type: 'toggled_show_solution', + value: e.target.checked + }); }} id={"showSolution"}/> - + } diff --git a/src/Cell.js b/src/Cell.js index 29bb3e4..90bf733 100644 --- a/src/Cell.js +++ b/src/Cell.js @@ -1,20 +1,30 @@ -import React, {useState} from 'react'; +import React from 'react'; -export default function Cell({spec, rowIndex, cellIndex, showSolution}) { - const [mark, setMark] = useState(false); - 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 (mark) 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 (
{ const leftPressed = e.buttons & 0x1; if (leftPressed) { - setMark(!mark); + dispatch({ + type: 'clicked_cell', + x, + y, + e + }); } }} onMouseLeave={(e) => { @@ -23,7 +33,13 @@ export default function Cell({spec, rowIndex, cellIndex, showSolution}) { } }} onClick={(e) => { - setMark(!mark); - }}/> + dispatch({ + type: 'clicked_cell', + x, + y, + e + }); + }}> +
); } diff --git a/src/InputForm.js b/src/InputForm.js index 800a589..8c69ede 100644 --- a/src/InputForm.js +++ b/src/InputForm.js @@ -1,39 +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("ready"); // "ready", "submitting" - if (status === "submitted") { - return ; - } - const callAPI = () => { - handleResult({}); + 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("ready"); + 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 { @@ -77,7 +72,7 @@ export default function InputForm({handleResult}) { min: 2 }} validatorFn={validateWidthHeightInput} - disabled={status === "submitting"} + disabled={state.loading} onChange={setWidth} />


); diff --git a/src/Maze.js b/src/Maze.js index 887db13..4adbf2f 100644 --- a/src/Maze.js +++ b/src/Maze.js @@ -1,16 +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
No valid maze.
} - const maze = labyrinth.grid.map((row, rowIdx) => ); + let maze = []; + for (let y = 0; y < state.maze.height; y++) { + let row = []; + for (let x = 0; x < state.maze.width; x++) { + row.push() + } + maze.push(
{row}
); + } return (
{maze} diff --git a/src/MessageBanner.js b/src/MessageBanner.js new file mode 100644 index 0000000..1e3083a --- /dev/null +++ b/src/MessageBanner.js @@ -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 (
+ {state.errorMessage} + +
); + } + return <>; +} diff --git a/src/Row.js b/src/Row.js deleted file mode 100644 index f890f8c..0000000 --- a/src/Row.js +++ /dev/null @@ -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) => ) - return ( -
- {cells} -
- ); -} diff --git a/src/reducer.js b/src/reducer.js new file mode 100644 index 0000000..17cffce --- /dev/null +++ b/src/reducer.js @@ -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}`); + } + } +} diff --git a/src/styles.css b/src/styles.css index 3676a15..b38b3a4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/userpathhandler.js b/src/userpathhandler.js new file mode 100644 index 0000000..c967f8d --- /dev/null +++ b/src/userpathhandler.js @@ -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; +}