Move to Typescript.
This commit is contained in:
parent
34618860a4
commit
ac964ddd3f
16 changed files with 482 additions and 119 deletions
|
@ -1,18 +1,13 @@
|
|||
import React, {useReducer} from 'react';
|
||||
import Maze from "./Maze";
|
||||
import InputForm from "./InputForm";
|
||||
import reduce from "./reducer";
|
||||
import MessageBanner from "./MessageBanner";
|
||||
import {useReducer} from 'react';
|
||||
import Maze from "./Maze.tsx";
|
||||
import InputForm from "./InputForm.tsx";
|
||||
import reduce from "./state/reducer.ts";
|
||||
import MessageBanner from "./MessageBanner.tsx";
|
||||
import {INITIAL_STATE} from "./state/state.ts";
|
||||
import {actionToggledShowSolution} from "./state/action.ts";
|
||||
|
||||
export default function App() {
|
||||
const [state, dispatch] = useReducer(reduce, {
|
||||
maze: null,
|
||||
loading: false,
|
||||
errorMessage: null,
|
||||
showSolution: false,
|
||||
userPath: []
|
||||
},
|
||||
undefined);
|
||||
const [state, dispatch] = useReducer(reduce, INITIAL_STATE);
|
||||
const hasValidMaze = !!state.maze;
|
||||
return (
|
||||
<>
|
||||
|
@ -25,10 +20,7 @@ export default function App() {
|
|||
<h1>The Maze ({state.maze.width}x{state.maze.height}, ID: {state.maze.id})</h1>
|
||||
<input type={"checkbox"}
|
||||
onChange={(e) => {
|
||||
dispatch({
|
||||
type: 'toggled_show_solution',
|
||||
value: e.target.checked
|
||||
});
|
||||
dispatch(actionToggledShowSolution(e.target.checked));
|
||||
}}
|
||||
id={"showSolution"}/><label htmlFor="showSolution">Show Solution</label>
|
||||
<Maze state={state}
|
|
@ -1,11 +1,13 @@
|
|||
import React from 'react';
|
||||
import {MazeCell} from "./model/Maze";
|
||||
import Coordinates from "./model/Coordinates";
|
||||
import {actionClickedCell} from "./state/action.ts";
|
||||
|
||||
function isMarked(x, y, marked) {
|
||||
function isMarked(x: number, y: number, marked: Coordinates[]): boolean {
|
||||
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];
|
||||
const cell: MazeCell = state.maze.grid[y][x];
|
||||
let classes = "cell r" + y + " c" + x;
|
||||
if (cell.top) classes += " top";
|
||||
if (cell.right) classes += " right";
|
||||
|
@ -19,19 +21,11 @@ export default function Cell({x, y, state, dispatch}) {
|
|||
onMouseEnter={(e) => {
|
||||
const leftPressed = e.buttons & 0x1;
|
||||
if (leftPressed) {
|
||||
dispatch({
|
||||
type: 'clicked_cell',
|
||||
x,
|
||||
y
|
||||
});
|
||||
dispatch(actionClickedCell(x, y));
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
dispatch({
|
||||
type: 'clicked_cell',
|
||||
x,
|
||||
y
|
||||
});
|
||||
dispatch(actionClickedCell(x, y));
|
||||
}}>
|
||||
</div>
|
||||
);
|
|
@ -1,32 +1,25 @@
|
|||
import React, {useState} from 'react';
|
||||
import ValidatingInputNumberField from "./ValidatingInputNumberField";
|
||||
import {useState} from 'react';
|
||||
import ValidatingInputNumberField from "./ValidatingInputNumberField.tsx";
|
||||
import {actionLoadedMaze, actionLoadingFailed, actionStartedLoading} from "./state/action.ts";
|
||||
|
||||
export default function InputForm({state, dispatch}) {
|
||||
const [width, setWidth] = useState(10);
|
||||
const [height, setHeight] = useState(10);
|
||||
const [id, setId] = useState(null);
|
||||
const [id, setId] = useState(null as number);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
dispatch({
|
||||
type: 'started_loading'
|
||||
});
|
||||
dispatch(actionStartedLoading());
|
||||
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 => {
|
||||
dispatch({
|
||||
type: 'loaded_maze',
|
||||
maze: result
|
||||
});
|
||||
dispatch(actionLoadedMaze(result));
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error("Failed to fetch maze data.", reason);
|
||||
dispatch({
|
||||
type: 'loading_failed',
|
||||
reason
|
||||
});
|
||||
dispatch(actionLoadingFailed(reason));
|
||||
});
|
||||
};
|
||||
const validateWidthHeightInput = value => {
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import Cell from "./Cell";
|
||||
import Cell from "./Cell.tsx";
|
||||
|
||||
|
||||
export default function Maze({state, dispatch}) {
|
||||
|
@ -7,9 +6,9 @@ export default function Maze({state, dispatch}) {
|
|||
return <div>No valid maze.</div>
|
||||
}
|
||||
|
||||
let maze = [];
|
||||
let maze: JSX.Element[] = [];
|
||||
for (let y = 0; y < state.maze.height; y++) {
|
||||
let row = [];
|
||||
let row: JSX.Element[] = [];
|
||||
for (let x = 0; x < state.maze.width; x++) {
|
||||
row.push(<Cell key={`${x}x${y}`} x={x} y={y} state={state} dispatch={dispatch}/>)
|
||||
}
|
|
@ -1,10 +1,8 @@
|
|||
import React from "react";
|
||||
import {actionClosedMessageBanner} from "./state/action.ts";
|
||||
|
||||
export default function MessageBanner({state, dispatch}) {
|
||||
function handleClose() {
|
||||
dispatch({
|
||||
type: 'closed_message_banner'
|
||||
})
|
||||
dispatch(actionClosedMessageBanner());
|
||||
}
|
||||
|
||||
if (!!state.errorMessage) {
|
12
src/index.js
12
src/index.js
|
@ -1,12 +0,0 @@
|
|||
import React, { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles.css";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
const root = createRoot(document.getElementById("root"));
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
12
src/index.tsx
Normal file
12
src/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import {StrictMode} from "react";
|
||||
import {createRoot, Root} from "react-dom/client";
|
||||
import "./styles.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
|
||||
const root: Root = createRoot(document.getElementById("root"));
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App/>
|
||||
</StrictMode>
|
||||
);
|
4
src/model/Coordinates.ts
Normal file
4
src/model/Coordinates.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default interface Coordinates {
|
||||
x: number,
|
||||
y: number
|
||||
}
|
18
src/model/Maze.ts
Normal file
18
src/model/Maze.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import Coordinates from "./Coordinates";
|
||||
|
||||
export default interface Maze {
|
||||
id: string,
|
||||
width: number,
|
||||
height: number,
|
||||
start: Coordinates,
|
||||
end: Coordinates,
|
||||
grid: MazeCell[][]
|
||||
}
|
||||
|
||||
export interface MazeCell {
|
||||
top: boolean,
|
||||
right: boolean,
|
||||
bottom: boolean,
|
||||
left: boolean,
|
||||
solution: boolean
|
||||
}
|
60
src/state/action.ts
Normal file
60
src/state/action.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import Maze from "../model/Maze";
|
||||
|
||||
export interface Action {
|
||||
type: string,
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const ID_ACTION_STARTED_LOADING = 'started_loading';
|
||||
|
||||
export function actionStartedLoading(): Action {
|
||||
return {
|
||||
type: ID_ACTION_STARTED_LOADING
|
||||
}
|
||||
}
|
||||
|
||||
export const ID_ACTION_LOADED_MAZE = 'loaded_maze';
|
||||
|
||||
export function actionLoadedMaze(maze: Maze): Action {
|
||||
return {
|
||||
type: ID_ACTION_LOADED_MAZE,
|
||||
maze
|
||||
}
|
||||
}
|
||||
|
||||
export const ID_ACTION_LOADING_FAILED = 'loading_failed';
|
||||
|
||||
export function actionLoadingFailed(reason: string): Action {
|
||||
return {
|
||||
type: ID_ACTION_LOADING_FAILED,
|
||||
reason
|
||||
};
|
||||
}
|
||||
|
||||
export const ID_ACTION_TOGGLED_SHOW_SOLUTION = 'toggled_show_solution';
|
||||
|
||||
export function actionToggledShowSolution(value: boolean): Action {
|
||||
return {
|
||||
type: ID_ACTION_TOGGLED_SHOW_SOLUTION,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
export const ID_ACTION_CLOSED_MESSAGE_BANNER = 'closed_message_banner';
|
||||
|
||||
export function actionClosedMessageBanner(): Action {
|
||||
return {
|
||||
type: ID_ACTION_CLOSED_MESSAGE_BANNER
|
||||
}
|
||||
}
|
||||
|
||||
export const ID_ACTION_CLICKED_CELL = 'clicked_cell';
|
||||
|
||||
export function actionClickedCell(x: number, y: number): Action {
|
||||
return {
|
||||
type: ID_ACTION_CLICKED_CELL,
|
||||
x,
|
||||
y
|
||||
}
|
||||
}
|
|
@ -1,8 +1,18 @@
|
|||
import handleUserClicked from "./userpathhandler";
|
||||
import handleUserClicked from "./userpathhandler.ts";
|
||||
import {State} from "./state";
|
||||
import {
|
||||
Action,
|
||||
ID_ACTION_CLICKED_CELL,
|
||||
ID_ACTION_CLOSED_MESSAGE_BANNER,
|
||||
ID_ACTION_LOADED_MAZE,
|
||||
ID_ACTION_LOADING_FAILED,
|
||||
ID_ACTION_STARTED_LOADING,
|
||||
ID_ACTION_TOGGLED_SHOW_SOLUTION
|
||||
} from "./action.ts";
|
||||
|
||||
export default function reduce(state, action) {
|
||||
export default function reduce(state: State, action: Action) {
|
||||
switch (action.type) {
|
||||
case 'started_loading': {
|
||||
case ID_ACTION_STARTED_LOADING: {
|
||||
return {
|
||||
...state,
|
||||
maze: null,
|
||||
|
@ -10,7 +20,7 @@ export default function reduce(state, action) {
|
|||
errorMessage: null
|
||||
}
|
||||
}
|
||||
case 'loaded_maze': {
|
||||
case ID_ACTION_LOADED_MAZE: {
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
|
@ -18,26 +28,26 @@ export default function reduce(state, action) {
|
|||
userPath: []
|
||||
}
|
||||
}
|
||||
case 'loading_failed': {
|
||||
case ID_ACTION_LOADING_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
errorMessage: `Failed to load maze. Reason: ${action.reason}`
|
||||
}
|
||||
}
|
||||
case 'toggled_show_solution': {
|
||||
case ID_ACTION_TOGGLED_SHOW_SOLUTION: {
|
||||
return {
|
||||
...state,
|
||||
showSolution: action.value
|
||||
}
|
||||
}
|
||||
case 'closed_message_banner': {
|
||||
case ID_ACTION_CLOSED_MESSAGE_BANNER: {
|
||||
return {
|
||||
...state,
|
||||
errorMessage: null
|
||||
}
|
||||
}
|
||||
case 'clicked_cell': {
|
||||
case ID_ACTION_CLICKED_CELL: {
|
||||
// There's so much logic involved, externalize that into its own file.
|
||||
return handleUserClicked(state, action.x, action.y);
|
||||
}
|
18
src/state/state.ts
Normal file
18
src/state/state.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import Coordinates from "../model/Coordinates";
|
||||
import Maze from "../model/Maze";
|
||||
|
||||
export interface State {
|
||||
errorMessage: string | null,
|
||||
loading: boolean,
|
||||
maze: Maze | null,
|
||||
showSolution: boolean,
|
||||
userPath: Coordinates[]
|
||||
}
|
||||
|
||||
export const INITIAL_STATE: State = {
|
||||
errorMessage: null,
|
||||
loading: false,
|
||||
maze: null,
|
||||
showSolution: false,
|
||||
userPath: []
|
||||
};
|
|
@ -1,12 +1,19 @@
|
|||
export default function handleUserClicked(state, x, y) {
|
||||
import {State} from "./state";
|
||||
import Coordinates from "../model/Coordinates";
|
||||
import {MazeCell} from "../model/Maze";
|
||||
|
||||
export default function handleUserClicked(state: State, x: number, y: number): State {
|
||||
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.
|
||||
// If it's the end tile, also show a congratulation message
|
||||
const showMessage = x === state.maze.end.x && y === state.maze.end.y;
|
||||
return {
|
||||
...state,
|
||||
userPath: [...state.userPath, {x: x, y: y}]
|
||||
userPath: [...state.userPath, {x, y}],
|
||||
errorMessage: showMessage ? "Congratulations! You won!" : state.errorMessage
|
||||
};
|
||||
} else {
|
||||
// The clicked cell IS part of the userpath. Is it the last cell of it?
|
||||
|
@ -15,7 +22,8 @@ export default function handleUserClicked(state, x, 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)
|
||||
userPath: state.userPath.filter(step => step.x !== x || step.y !== y),
|
||||
errorMessage: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +32,7 @@ export default function handleUserClicked(state, x, y) {
|
|||
return state;
|
||||
}
|
||||
|
||||
function isClickAllowed(x, y, state) {
|
||||
function isClickAllowed(x: number, y: number, state: State): boolean {
|
||||
const lastCoordsFromUserPath = getLastCoordsFromUserPath(state);
|
||||
if (!lastCoordsFromUserPath) {
|
||||
// when nothing has been marked yet, we can only toggle the starting position
|
||||
|
@ -61,11 +69,11 @@ function isClickAllowed(x, y, state) {
|
|||
return false;
|
||||
}
|
||||
|
||||
function getCellAt(coords, state) {
|
||||
function getCellAt(coords: Coordinates, state: State): MazeCell {
|
||||
return state.maze.grid[coords.y][coords.x];
|
||||
}
|
||||
|
||||
function getLastCoordsFromUserPath(state) {
|
||||
function getLastCoordsFromUserPath(state: State): Coordinates | null {
|
||||
if (state.userPath.length > 0) {
|
||||
return state.userPath[state.userPath.length - 1];
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue