Merge pull request 'feature/add-user-interaction' (#3) from feature/add-user-interaction into main

Reviewed-on: #3
This commit is contained in:
Manuel Friedli 2023-04-17 01:25:57 +02:00
commit 34618860a4
10 changed files with 240 additions and 71 deletions

View file

@ -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"
}, },

View file

@ -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}/>
</> </>
} }
</> </>

View file

@ -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>
); );
} }

View file

@ -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>
); );

View file

@ -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
View 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 <></>;
}

View file

@ -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
View 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}`);
}
}
}

View file

@ -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
View 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;
}