This commit is contained in:
parent
824f038084
commit
3efb87bcd8
24 changed files with 14 additions and 19 deletions
12
app/cell.module.scss
Normal file
12
app/cell.module.scss
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
.cell {
|
||||
display: table-cell;
|
||||
border: 1px solid transparent;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-highlight);
|
||||
}
|
||||
}
|
||||
186
app/cell.scss
Normal file
186
app/cell.scss
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
.solution {
|
||||
background-color: var(--color-maze-cell-solution);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-maze-cell-solution-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
.top {
|
||||
border-top-color: var(--color-maze-border);
|
||||
}
|
||||
|
||||
.right {
|
||||
border-right-color: var(--color-maze-border);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
border-bottom-color: var(--color-maze-border);
|
||||
}
|
||||
|
||||
.left {
|
||||
border-left-color: var(--color-maze-border);
|
||||
}
|
||||
|
||||
.userSELF {
|
||||
background: radial-gradient(
|
||||
ellipse 16% 16% at center,
|
||||
var(--color-maze-cell-user) 0,
|
||||
var(--color-maze-cell-user) 100%,
|
||||
#0000 100%
|
||||
);
|
||||
|
||||
&.solution {
|
||||
background: radial-gradient(
|
||||
ellipse 16% 16% at center,
|
||||
var(--color-maze-cell-user-solution) 0,
|
||||
var(--color-maze-cell-user-solution) 100%,
|
||||
#0000 100%
|
||||
);
|
||||
|
||||
&:hover {
|
||||
background: radial-gradient(
|
||||
ellipse 33% 33% at center,
|
||||
var(--color-maze-cell-user-solution) 0,
|
||||
var(--color-maze-cell-user-solution) 80%,
|
||||
#0000 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: radial-gradient(
|
||||
ellipse 33% 33% at center,
|
||||
var(--color-maze-cell-user) 0,
|
||||
var(--color-maze-cell-user) 80%,
|
||||
#0000 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.marker {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
|
||||
&:hover {
|
||||
background: #fc08;
|
||||
}
|
||||
}
|
||||
|
||||
.userUP .marker.UP {
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#0000 0,
|
||||
#0000 33%,
|
||||
var(--color-maze-cell-user) 33%,
|
||||
var(--color-maze-cell-user) 66%,
|
||||
#0000 66%,
|
||||
#0000 100%
|
||||
);
|
||||
}
|
||||
|
||||
.solution.userUP .marker.UP {
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#0000 0,
|
||||
#0000 33%,
|
||||
var(--color-maze-cell-user-solution) 33%,
|
||||
var(--color-maze-cell-user-solution) 66%,
|
||||
#0000 66%,
|
||||
#0000 100%
|
||||
);
|
||||
}
|
||||
|
||||
.userRIGHT .marker.RIGHT {
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
left: 50%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
#0000 0,
|
||||
#0000 33%,
|
||||
var(--color-maze-cell-user) 33%,
|
||||
var(--color-maze-cell-user) 66%,
|
||||
#0000 66%,
|
||||
#0000 100%
|
||||
);
|
||||
}
|
||||
|
||||
.solution.userRIGHT .marker.RIGHT {
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
left: 50%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
#0000 0,
|
||||
#0000 33%,
|
||||
var(--color-maze-cell-user-solution) 33%,
|
||||
var(--color-maze-cell-user-solution) 66%,
|
||||
#0000 66%,
|
||||
#0000 100%
|
||||
);
|
||||
}
|
||||
|
||||
.userDOWN .marker.DOWN {
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
top: 50%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#0000 0,
|
||||
#0000 33%,
|
||||
var(--color-maze-cell-user) 33%,
|
||||
var(--color-maze-cell-user) 66%,
|
||||
#0000 66%,
|
||||
#0000 100%
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
.solution.userDOWN .marker.DOWN {
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
top: 50%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#0000 0,
|
||||
#0000 33%,
|
||||
var(--color-maze-cell-user-solution) 33%,
|
||||
var(--color-maze-cell-user-solution) 66%,
|
||||
#0000 66%,
|
||||
#0000 100%
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
.userLEFT .marker.LEFT {
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
#0000 0,
|
||||
#0000 33%,
|
||||
var(--color-maze-cell-user) 33%,
|
||||
var(--color-maze-cell-user) 66%,
|
||||
#0000 66%,
|
||||
#0000 100%
|
||||
);
|
||||
}
|
||||
|
||||
.solution.userLEFT .marker.LEFT {
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
#0000 0,
|
||||
#0000 33%,
|
||||
var(--color-maze-cell-user-solution) 33%,
|
||||
var(--color-maze-cell-user-solution) 66%,
|
||||
#0000 66%,
|
||||
#0000 100%
|
||||
);
|
||||
}
|
||||
85
app/cell.tsx
Normal file
85
app/cell.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {MazeCell} from "./model/maze.ts";
|
||||
import Coordinates from "./model/coordinates.ts";
|
||||
import {Action, actionClickedCell} from "./state/action.ts";
|
||||
import styles from "./cell.module.scss";
|
||||
import "./cell.scss";
|
||||
import {State} from "./state/state.ts";
|
||||
import {ActionDispatch} from "react";
|
||||
|
||||
function getMarkedDirections(coords: Coordinates, marked: Coordinates[]): Direction[] {
|
||||
const cellIndex: number = marked.findIndex(e => e.x === coords.x && e.y === coords.y);
|
||||
if (cellIndex === -1) {
|
||||
return [];
|
||||
}
|
||||
if (marked.length === 1) {
|
||||
return [Direction.SELF];
|
||||
}
|
||||
if (cellIndex === 0) {
|
||||
const next: Coordinates = marked[1];
|
||||
return [Direction.SELF, getDirectionTo(coords, next)];
|
||||
} else {
|
||||
const previous = marked[cellIndex - 1];
|
||||
if (cellIndex === marked.length - 1) {
|
||||
return [Direction.SELF, getDirectionTo(coords, previous)];
|
||||
}
|
||||
const next: Coordinates = marked[cellIndex + 1];
|
||||
return [Direction.SELF, getDirectionTo(coords, previous), getDirectionTo(coords, next)];
|
||||
}
|
||||
}
|
||||
|
||||
function getDirectionTo(me: Coordinates, other: Coordinates): Direction {
|
||||
const xDiff = me.x - other.x;
|
||||
switch (xDiff) {
|
||||
case -1:
|
||||
return Direction.RIGHT;
|
||||
case 1:
|
||||
return Direction.LEFT;
|
||||
default:
|
||||
const yDiff = me.y - other.y;
|
||||
if (yDiff === -1) {
|
||||
return Direction.DOWN;
|
||||
}
|
||||
return Direction.UP;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Cell({x, y, state, dispatch}:
|
||||
{
|
||||
x: number,
|
||||
y: number,
|
||||
state: State,
|
||||
dispatch: ActionDispatch<[Action]>
|
||||
}) {
|
||||
const cell: MazeCell = state.maze!.grid[y][x];
|
||||
let classes = " 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 markedDirections = getMarkedDirections({x, y}, state.userPath);
|
||||
for (let i = 0; i < markedDirections.length; i++) {
|
||||
classes += ` user${Direction[markedDirections[i]]}`;
|
||||
}
|
||||
return (
|
||||
<div className={styles.cell + classes}
|
||||
onMouseEnter={(e) => {
|
||||
const leftPressed = e.buttons & 0x1;
|
||||
if (leftPressed) {
|
||||
dispatch(actionClickedCell(x, y));
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
dispatch(actionClickedCell(x, y));
|
||||
}}>
|
||||
<div className={"marker UP"}></div>
|
||||
<div className={"marker RIGHT"}></div>
|
||||
<div className={"marker DOWN"}></div>
|
||||
<div className={"marker LEFT"}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
enum Direction {
|
||||
UP, RIGHT, DOWN, LEFT, SELF
|
||||
}
|
||||
56
app/globals.scss
Normal file
56
app/globals.scss
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
:root {
|
||||
box-sizing: border-box;
|
||||
--color-background: #e8e6e3;
|
||||
--color-foreground: #181a1b;
|
||||
--color-form-error: #ff0000;
|
||||
--color-border: #5d6164;
|
||||
--color-background-highlight: #f8f7f4;
|
||||
--color-foreground-highlight: #292b2c;
|
||||
--color-border-highlight: #6e7275;
|
||||
--color-maze-background: #e8e6e3;
|
||||
--color-maze-border: #333333;
|
||||
--color-maze-cell-solution: #b1d5b1;
|
||||
--color-maze-cell-solution-highlight: #b9e8b9;
|
||||
--color-maze-cell-user: #ffcc00;
|
||||
--color-maze-cell-user-solution: #47e147;
|
||||
--color-accent: #ffcc00;
|
||||
--color-accent-inverse: #000000;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: #181a1b;
|
||||
--color-foreground: #e8e6e3;
|
||||
--color-form-error: #ff0000;
|
||||
--color-border: #5d6164;
|
||||
--color-background-highlight: #292b2c;
|
||||
--color-foreground-highlight: #f8f7f4;
|
||||
--color-border-highlight: #6e7275;
|
||||
--color-maze-background: #181a1b;
|
||||
--color-maze-border: #e8e6e3;
|
||||
--color-maze-cell-solution: #213d21;
|
||||
--color-maze-cell-solution-highlight: #3d6e3d;
|
||||
--color-maze-cell-user: #ffcc00;
|
||||
--color-maze-cell-user-solution: #00a421;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-background);
|
||||
font-family: "Cantarell", Arial, Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
input:hover, input:focus, select:hover, select:focus {
|
||||
background-color: var(--color-background-highlight);
|
||||
border-color: var(--color-border-highlight);
|
||||
color: var(--color-foreground-highlight);
|
||||
}
|
||||
15
app/input-form.module.scss
Normal file
15
app/input-form.module.scss
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.inputform {
|
||||
display: grid;
|
||||
grid-row-gap: 0.5em;
|
||||
grid-template-columns: 7em 14em auto;
|
||||
}
|
||||
|
||||
.submitbutton {
|
||||
background-color: var(--color-accent);
|
||||
border: 1px solid var(--color-foreground);
|
||||
color: var(--color-accent-inverse);
|
||||
padding: 0.5em;
|
||||
border-radius: 0.5em;
|
||||
margin-top: 2em;
|
||||
width: 11em;
|
||||
}
|
||||
21
app/input-form.scss
Normal file
21
app/input-form.scss
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
form {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-foreground);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-background-highlight);
|
||||
border-color: var(--color-border-highlight);
|
||||
color: var(--color-foreground-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
input:invalid {
|
||||
border-color: var(--color-form-error);
|
||||
}
|
||||
119
app/input-form.tsx
Normal file
119
app/input-form.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import {ActionDispatch, FormEvent, useState} from 'react';
|
||||
import {
|
||||
Action,
|
||||
actionLoadedMaze,
|
||||
actionLoadingFailed,
|
||||
actionStartedLoading,
|
||||
actionToggledShowSolution
|
||||
} from "./state/action.ts";
|
||||
import styles from "./input-form.module.scss";
|
||||
import "./input-form.scss";
|
||||
import {State} from "./state/state.ts";
|
||||
import {ValidatingInputNumberField, ValidatingInputRegExpField, ValidatorFunction} from "./validating-input-field.tsx";
|
||||
|
||||
export default function InputForm({state, dispatch}: {
|
||||
state: State,
|
||||
dispatch: ActionDispatch<[Action]>
|
||||
}) {
|
||||
const [width, setWidth] = useState(10);
|
||||
const [height, setHeight] = useState(10);
|
||||
const [id, setId] = useState<string>();
|
||||
const [algorithm, setAlgorithm] = useState('wilson');
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
dispatch(actionStartedLoading());
|
||||
dispatch(actionToggledShowSolution(false));
|
||||
const url = `https://manuel.friedli.info/labyrinth/create/json?w=${width}&h=${height}&id=${id || ''}&algorithm=${algorithm}`;
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
dispatch(actionLoadedMaze(result));
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error("Failed to fetch maze data.", reason);
|
||||
dispatch(actionLoadingFailed(reason));
|
||||
});
|
||||
};
|
||||
const validateSizeInput: ValidatorFunction<string, number> = (value: string) => {
|
||||
const numberValue = Number(value);
|
||||
if (isNaN(numberValue) || "" === value || (Math.floor(numberValue) !== numberValue)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "Must be an integer greater than 1."
|
||||
};
|
||||
}
|
||||
if (numberValue < 1) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "Must be greater than 1."
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
value: numberValue
|
||||
};
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={styles.inputform}>
|
||||
<ValidatingInputNumberField id={"width"}
|
||||
label={"Width"}
|
||||
value={width}
|
||||
constraints={{
|
||||
min: 2
|
||||
}}
|
||||
validatorFn={validateSizeInput}
|
||||
disabled={state.loading}
|
||||
onChange={setWidth}
|
||||
/>
|
||||
<ValidatingInputNumberField id={"height"}
|
||||
label={"Height"}
|
||||
value={height}
|
||||
constraints={{
|
||||
min: 2
|
||||
}}
|
||||
validatorFn={validateSizeInput}
|
||||
disabled={state.loading}
|
||||
onChange={setHeight}
|
||||
/>
|
||||
{/*<ValidatingInputBigIntField id={"id"}*/}
|
||||
{/* label={"ID (optional)"}*/}
|
||||
{/* value={id}*/}
|
||||
{/* validatorFn={validateIdInput}*/}
|
||||
{/* disabled={state.loading}*/}
|
||||
{/* onChange={setId}*/}
|
||||
{/* constraints={{*/}
|
||||
{/* min: -9223372036854775808n,*/}
|
||||
{/* max: 9223372036854775807n*/}
|
||||
{/* }}*/}
|
||||
{/*/>*/}
|
||||
<ValidatingInputRegExpField id={"id"}
|
||||
label={"ID (optional)"}
|
||||
value={id}
|
||||
disabled={state.loading}
|
||||
onChange={setId}
|
||||
constraints={[
|
||||
/^[0-9a-fA-F]{0,16}$/
|
||||
]}
|
||||
placeholder={"Hex-Number (without 0x prefix)"}
|
||||
/>
|
||||
<label htmlFor="algorithm">Algorithm:</label>
|
||||
<select id={"algorithm"}
|
||||
value={algorithm}
|
||||
disabled={state.loading}
|
||||
onChange={e => setAlgorithm(e.target.value)}>
|
||||
<option value="wilson">Wilson</option>
|
||||
<option value="random">Random Depth First</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type={"submit"}
|
||||
disabled={state.loading
|
||||
|| isNaN(width)
|
||||
|| isNaN(height)
|
||||
}
|
||||
className={styles.submitbutton}>{state.loading ? "Loading ..." : "Create Maze!"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
18
app/layout.tsx
Normal file
18
app/layout.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type {Metadata} from "next";
|
||||
import "./globals.scss";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "A-Maze-R! Create your own Maze!",
|
||||
description: "A Maze Generator by fritteli",
|
||||
icons: "/favicon.ico"
|
||||
};
|
||||
|
||||
export default function RootLayout({children}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
11
app/maze.module.css
Normal file
11
app/maze.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.maze {
|
||||
display: table;
|
||||
border-collapse: collapse;
|
||||
background-color: var(--color-maze-background);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: table-row;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
29
app/maze.tsx
Normal file
29
app/maze.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import Cell from "./cell.tsx";
|
||||
import styles from "./maze.module.css";
|
||||
import {State} from "./state/state.ts";
|
||||
import {ActionDispatch, JSX} from "react";
|
||||
import {Action} from "./state/action.ts";
|
||||
|
||||
export default function Maze({state, dispatch}:
|
||||
{
|
||||
state: State,
|
||||
dispatch: ActionDispatch<[Action]>
|
||||
}) {
|
||||
if (!state.maze) {
|
||||
return <div>No valid maze.</div>
|
||||
}
|
||||
|
||||
const maze: JSX.Element[] = [];
|
||||
for (let y = 0; y < state.maze.height; y++) {
|
||||
const 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}/>)
|
||||
}
|
||||
maze.push(<div key={`r${y}`} className={styles.row}>{row}</div>);
|
||||
}
|
||||
return (
|
||||
<div className={styles.maze}>
|
||||
{maze}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/message-banner.module.css
Normal file
10
app/message-banner.module.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.banner {
|
||||
height: 2em;
|
||||
}
|
||||
.empty {
|
||||
}
|
||||
|
||||
.message {
|
||||
border: 1px solid red;
|
||||
background-color: #ff000044;
|
||||
}
|
||||
24
app/message-banner.tsx
Normal file
24
app/message-banner.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {Action, actionClosedMessageBanner} from "./state/action.ts";
|
||||
import styles from "./message-banner.module.css";
|
||||
import {State} from "./state/state.ts";
|
||||
import {ActionDispatch} from "react";
|
||||
|
||||
export default function MessageBanner({state, dispatch}:
|
||||
{
|
||||
state: State;
|
||||
dispatch: ActionDispatch<[Action]>
|
||||
}) {
|
||||
function handleClose() {
|
||||
dispatch(actionClosedMessageBanner());
|
||||
}
|
||||
|
||||
if (!!state.errorMessage) {
|
||||
return (
|
||||
<div className={styles.banner + " " + styles.message}>
|
||||
{state.errorMessage}
|
||||
<button onClick={handleClose}>Dismiss message</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (<div className={styles.banner + " " + styles.empty}></div>);
|
||||
}
|
||||
4
app/model/coordinates.ts
Normal file
4
app/model/coordinates.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export default interface Coordinates {
|
||||
x: number,
|
||||
y: number
|
||||
}
|
||||
19
app/model/maze.ts
Normal file
19
app/model/maze.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Coordinates from "./coordinates.ts";
|
||||
|
||||
export default interface Maze {
|
||||
id: string,
|
||||
width: number,
|
||||
height: number,
|
||||
start: Coordinates,
|
||||
end: Coordinates,
|
||||
algorithm: string,
|
||||
grid: MazeCell[][]
|
||||
}
|
||||
|
||||
export interface MazeCell {
|
||||
top: boolean,
|
||||
right: boolean,
|
||||
bottom: boolean,
|
||||
left: boolean,
|
||||
solution: boolean
|
||||
}
|
||||
22
app/page.module.scss
Normal file
22
app/page.module.scss
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.page {
|
||||
h1.mainheading, h2.mainheading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2.mainheading {
|
||||
font-size: medium;
|
||||
}
|
||||
|
||||
a.downloadlink {
|
||||
background-color: var(--color-accent);
|
||||
border: 1px solid var(--color-foreground);
|
||||
border-radius: 0.5em;
|
||||
color: var(--color-accent-inverse);
|
||||
display: block;
|
||||
margin-bottom: 1em;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
padding: 0.5em;
|
||||
width: 11em;
|
||||
}
|
||||
}
|
||||
45
app/page.tsx
Normal file
45
app/page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
|
||||
import styles from "./page.module.scss";
|
||||
import {useReducer} from "react";
|
||||
import reduce from "./state/reducer.ts";
|
||||
import {INITIAL_STATE} from "./state/state.ts";
|
||||
import MessageBanner from "./message-banner.tsx";
|
||||
import InputForm from "./input-form.tsx";
|
||||
import {actionToggledShowSolution} from "./state/action.ts";
|
||||
import Maze from "./maze.tsx";
|
||||
|
||||
export default function Home() {
|
||||
const [state, dispatch] = useReducer(reduce, INITIAL_STATE);
|
||||
const hasValidMaze = !!state.maze;
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.mainheading}>A-Maze-R! Create your own maze!</h1>
|
||||
<h2 className={styles.mainheading}>A fun little maze generator written by fritteli</h2>
|
||||
<MessageBanner state={state}
|
||||
dispatch={dispatch}/>
|
||||
<InputForm state={state}
|
||||
dispatch={dispatch}/>
|
||||
{hasValidMaze &&
|
||||
<>
|
||||
<h1>The Maze ({state.maze!.width}x{state.maze!.height}, Algorithm: {state.maze!.algorithm},
|
||||
ID: {state.maze!.id})</h1>
|
||||
<a href={"https://manuel.friedli.info/labyrinth/create/pdffile?w="
|
||||
+ state.maze!.width
|
||||
+ "&h=" + state.maze!.height
|
||||
+ "&id=" + state.maze!.id
|
||||
+ "&a=" + state.maze!.algorithm}
|
||||
className={styles.downloadlink}>Download as PDF file</a>
|
||||
<input type={"checkbox"}
|
||||
checked={state.showSolution}
|
||||
onChange={(e) => {
|
||||
dispatch(actionToggledShowSolution(e.target.checked));
|
||||
}}
|
||||
id={"showSolution"}/><label htmlFor="showSolution">Show Solution</label>
|
||||
<Maze state={state}
|
||||
dispatch={dispatch}/>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
app/state/action.ts
Normal file
60
app/state/action.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import Maze from "../model/maze.ts";
|
||||
|
||||
export interface Action {
|
||||
type: string,
|
||||
|
||||
[key: string]: boolean | number | string | object | null | undefined;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
59
app/state/reducer.ts
Normal file
59
app/state/reducer.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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";
|
||||
import Maze from "../model/maze.ts";
|
||||
|
||||
export default function reduce(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case ID_ACTION_STARTED_LOADING: {
|
||||
return {
|
||||
...state,
|
||||
maze: null,
|
||||
loading: true,
|
||||
errorMessage: null
|
||||
}
|
||||
}
|
||||
case ID_ACTION_LOADED_MAZE: {
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
maze: action.maze as Maze,
|
||||
userPath: []
|
||||
}
|
||||
}
|
||||
case ID_ACTION_LOADING_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
errorMessage: `Failed to load maze. Reason: ${action.reason}`
|
||||
}
|
||||
}
|
||||
case ID_ACTION_TOGGLED_SHOW_SOLUTION: {
|
||||
return {
|
||||
...state,
|
||||
showSolution: action.value as boolean
|
||||
}
|
||||
}
|
||||
case ID_ACTION_CLOSED_MESSAGE_BANNER: {
|
||||
return {
|
||||
...state,
|
||||
errorMessage: null
|
||||
}
|
||||
}
|
||||
case ID_ACTION_CLICKED_CELL: {
|
||||
// There's so much logic involved, externalize that into its own file.
|
||||
return handleUserClicked(state, action.x as number, action.y as number);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown action: ${action.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
app/state/state.ts
Normal file
18
app/state/state.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import Coordinates from "../model/coordinates.ts";
|
||||
import Maze from "../model/maze.ts";
|
||||
|
||||
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: []
|
||||
};
|
||||
82
app/state/userpathhandler.ts
Normal file
82
app/state/userpathhandler.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import {State} from "./state";
|
||||
import Coordinates from "../model/coordinates.ts";
|
||||
import {MazeCell} from "../model/maze.ts";
|
||||
|
||||
export default function handleUserClicked(state: State, x: number, y: number): State {
|
||||
if (isClickAllowed(x, y, state)) {
|
||||
const maze = state.maze!;
|
||||
// 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 === maze.end.x && y === maze.end.y;
|
||||
return {
|
||||
...state,
|
||||
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?
|
||||
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),
|
||||
errorMessage: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not allowed to toggle that cell. Don't apply any change to the state.
|
||||
return 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
|
||||
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: Coordinates, state: State): MazeCell {
|
||||
return state.maze!.grid[coords.y][coords.x];
|
||||
}
|
||||
|
||||
function getLastCoordsFromUserPath(state: State): Coordinates | null {
|
||||
if (state.userPath.length > 0) {
|
||||
return state.userPath[state.userPath.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
173
app/validating-input-field.tsx
Normal file
173
app/validating-input-field.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import React, {ChangeEventHandler, useState} from 'react';
|
||||
|
||||
export function ValidatingInputBigIntField({
|
||||
id,
|
||||
label,
|
||||
value = 0n,
|
||||
constraints = undefined,
|
||||
validatorFn = (value) => {
|
||||
return {valid: true, value: BigInt(value)};
|
||||
},
|
||||
disabled = false,
|
||||
onChange = () => {
|
||||
}
|
||||
}:
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
value?: bigint;
|
||||
constraints?: { min?: bigint; max?: bigint; };
|
||||
validatorFn: ValidatorFunction<string, bigint>;
|
||||
disabled: boolean;
|
||||
onChange: (v: bigint) => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const handleValueChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const value = e.target.value;
|
||||
const validation = validatorFn(value);
|
||||
if (!validation.valid) {
|
||||
setError(validation.message);
|
||||
} else {
|
||||
setError(undefined);
|
||||
}
|
||||
if (validation.value !== undefined) {
|
||||
onChange(validation.value);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id}>{label}: </label>
|
||||
<input id={id}
|
||||
type={"number"}
|
||||
onChange={handleValueChange}
|
||||
value={value?.toString() || ""}
|
||||
min={constraints?.min?.toString()}
|
||||
max={constraints?.max?.toString()}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ValidatingInputNumberField({
|
||||
id,
|
||||
label,
|
||||
value = 0,
|
||||
constraints = undefined,
|
||||
validatorFn = (value) => {
|
||||
return {valid: true, value: Number(value)};
|
||||
},
|
||||
disabled = false,
|
||||
onChange = () => {
|
||||
}
|
||||
}:
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
value?: number;
|
||||
constraints?: { min?: number; max?: number; };
|
||||
validatorFn: ValidatorFunction<string, number>;
|
||||
disabled: boolean;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const handleValueChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const value = e.target.value;
|
||||
const validation = validatorFn(value);
|
||||
if (!validation.valid) {
|
||||
setError(validation.message);
|
||||
} else {
|
||||
setError(undefined);
|
||||
}
|
||||
if (validation.value) {
|
||||
onChange(validation.value);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id}>{label}: </label>
|
||||
<input id={id}
|
||||
type={"number"}
|
||||
onChange={handleValueChange}
|
||||
value={value || ""}
|
||||
min={constraints?.min}
|
||||
max={constraints?.max}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ValidatingInputRegExpField({
|
||||
id,
|
||||
label,
|
||||
value = undefined,
|
||||
constraints = undefined,
|
||||
validatorFn = (value) => {
|
||||
if (constraints === undefined) {
|
||||
console.log("no constraints, returning VALID")
|
||||
return {valid: true, value};
|
||||
}
|
||||
const allValid = constraints
|
||||
.map(expr => expr.test(value))
|
||||
.reduce((prev, curr) => prev && curr)
|
||||
?? true;
|
||||
console.log("valid?", allValid);
|
||||
return {valid: allValid, value: allValid ? value : undefined};
|
||||
},
|
||||
disabled = false,
|
||||
onChange = () => {
|
||||
},
|
||||
placeholder = ""
|
||||
}:
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
constraints?: RegExp[];
|
||||
validatorFn?: ValidatorFunction<string, string>;
|
||||
disabled: boolean;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const handleValueChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const value = e.target.value;
|
||||
const validation = validatorFn(value);
|
||||
if (!validation.valid) {
|
||||
setError(validation.message);
|
||||
} else {
|
||||
setError(undefined);
|
||||
}
|
||||
if (validation.value !== undefined) {
|
||||
console.log("setting value to:", validation.value);
|
||||
onChange(validation.value);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={id}>{label}: </label>
|
||||
<input id={id}
|
||||
type={"text"}
|
||||
placeholder={placeholder}
|
||||
onChange={handleValueChange}
|
||||
value={value?.toString() || ""}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{error}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Validation<T> {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
value?: T;
|
||||
}
|
||||
|
||||
export type ValidatorFunction<I, T> = (v: I) => Validation<T>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue