Merge pull request 'feature/add-user-interaction' (#3) from feature/add-user-interaction into main
Reviewed-on: #3
This commit is contained in:
commit
34618860a4
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…
Reference in a new issue