feature/algorithm #5
43 changed files with 3450 additions and 18685 deletions
31
.gitignore
vendored
31
.gitignore
vendored
|
@ -1,20 +1,41 @@
|
||||||
/.idea
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env.local
|
*.pem
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
../next-env.d.ts
|
||||||
|
|
16
eslint.config.mjs
Normal file
16
eslint.config.mjs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
9
next.config.ts
Normal file
9
next.config.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import type {NextConfig} from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
output: "export",
|
||||||
|
assetPrefix: "./"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
18936
package-lock.json
generated
18936
package-lock.json
generated
File diff suppressed because it is too large
Load diff
50
package.json
50
package.json
|
@ -1,37 +1,25 @@
|
||||||
{
|
{
|
||||||
"dependencies": {
|
|
||||||
"@types/jest": "^29.5.0",
|
|
||||||
"@types/node": "^18.15.11",
|
|
||||||
"@types/react": "^18.0.35",
|
|
||||||
"@types/react-dom": "^18.0.11",
|
|
||||||
"react": "^18.0.0",
|
|
||||||
"react-dom": "^18.0.0",
|
|
||||||
"typescript": "^5.0.4"
|
|
||||||
},
|
|
||||||
"main": "/index.js",
|
|
||||||
"homepage": ".",
|
|
||||||
"devDependencies": {
|
|
||||||
"react-scripts": "^5.0.1"
|
|
||||||
},
|
|
||||||
"name": "a-maze-r",
|
"name": "a-maze-r",
|
||||||
"description": "The A-Maze-Ing Generator!",
|
"version": "0.1.0",
|
||||||
"version": "0.0.0",
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"dev": "next dev",
|
||||||
"build": "react-scripts build",
|
"build": "next build",
|
||||||
"test": "react-scripts test --env=jsdom",
|
"start": "next start",
|
||||||
"eject": "react-scripts eject"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"dependencies": {
|
||||||
"production": [
|
"react": "^19.0.0",
|
||||||
">0.2%",
|
"react-dom": "^19.0.0",
|
||||||
"not dead",
|
"next": "15.1.2"
|
||||||
"not op_mini all"
|
},
|
||||||
],
|
"devDependencies": {
|
||||||
"development": [
|
"typescript": "^5",
|
||||||
"last 1 chrome version",
|
"@types/node": "^20",
|
||||||
"last 1 firefox version",
|
"@types/react": "^19",
|
||||||
"last 1 safari version"
|
"@types/react-dom": "^19",
|
||||||
]
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.1.2",
|
||||||
|
"@eslint/eslintrc": "^3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 24 KiB |
|
@ -1,38 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<meta name="theme-color" content="#000000">
|
|
||||||
<!--
|
|
||||||
manifest.json provides metadata used when your web app is added to the
|
|
||||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
|
||||||
-->
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
|
||||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
|
||||||
<!--
|
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
|
||||||
|
|
||||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
|
||||||
work correctly both with client-side routing and a non-root public URL.
|
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
|
||||||
-->
|
|
||||||
<title>A-Maze-R</title>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"short_name": "React App",
|
|
||||||
"name": "Create React App Sample",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "favicon.ico",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"start_url": "./index.html",
|
|
||||||
"display": "standalone",
|
|
||||||
"theme_color": "#000000",
|
|
||||||
"background_color": "#ffffff"
|
|
||||||
}
|
|
32
src/Cell.tsx
32
src/Cell.tsx
|
@ -1,32 +0,0 @@
|
||||||
import {MazeCell} from "./model/Maze";
|
|
||||||
import Coordinates from "./model/Coordinates";
|
|
||||||
import {actionClickedCell} from "./state/action.ts";
|
|
||||||
|
|
||||||
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: MazeCell = 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 (
|
|
||||||
<div className={classes}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
const leftPressed = e.buttons & 0x1;
|
|
||||||
if (leftPressed) {
|
|
||||||
dispatch(actionClickedCell(x, y));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
dispatch(actionClickedCell(x, y));
|
|
||||||
}}>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,96 +0,0 @@
|
||||||
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 as number);
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
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(actionLoadedMaze(result));
|
|
||||||
})
|
|
||||||
.catch(reason => {
|
|
||||||
console.error("Failed to fetch maze data.", reason);
|
|
||||||
dispatch(actionLoadingFailed(reason));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const validateWidthHeightInput = value => {
|
|
||||||
if (isNaN(value) || "" === value || (Math.floor(value) !== Number(value))) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
message: "Must be an integer greater than 1.",
|
|
||||||
value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (value < 1) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
message: "Must be greater than 1.",
|
|
||||||
value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
value
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const validateIdInput = value => {
|
|
||||||
// FIXME doesn't handle strings with characters correctly (e.g. "asdf" yields an empty value, due to "type=number").
|
|
||||||
if (isNaN(value) || ("" !== value && ((Math.floor(value) !== Number(value))))) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
message: "Must be empty or an integer",
|
|
||||||
value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
value
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<ValidatingInputNumberField id={"width"}
|
|
||||||
label={"Width"}
|
|
||||||
value={width}
|
|
||||||
constraints={{
|
|
||||||
min: 2
|
|
||||||
}}
|
|
||||||
validatorFn={validateWidthHeightInput}
|
|
||||||
disabled={state.loading}
|
|
||||||
onChange={setWidth}
|
|
||||||
/><br/>
|
|
||||||
<ValidatingInputNumberField id={"height"}
|
|
||||||
label={"Height"}
|
|
||||||
value={height}
|
|
||||||
constraints={{
|
|
||||||
min: 2
|
|
||||||
}}
|
|
||||||
validatorFn={validateWidthHeightInput}
|
|
||||||
disabled={state.loading}
|
|
||||||
onChange={setHeight}
|
|
||||||
/><br/>
|
|
||||||
<ValidatingInputNumberField id={"id"}
|
|
||||||
label={"ID (optional)"}
|
|
||||||
value={id}
|
|
||||||
validatorFn={validateIdInput}
|
|
||||||
disabled={state.loading}
|
|
||||||
onChange={setId}
|
|
||||||
/><br/>
|
|
||||||
<button type={"submit"}
|
|
||||||
disabled={state.loading
|
|
||||||
|| isNaN(width)
|
|
||||||
|| isNaN(height)
|
|
||||||
}>{state.loading ? "Loading ..." : "Create Maze!"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
22
src/Maze.tsx
22
src/Maze.tsx
|
@ -1,22 +0,0 @@
|
||||||
import Cell from "./Cell.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
export default function Maze({state, dispatch}) {
|
|
||||||
if (!state.maze) {
|
|
||||||
return <div>No valid maze.</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
let maze: JSX.Element[] = [];
|
|
||||||
for (let y = 0; y < state.maze.height; y++) {
|
|
||||||
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}/>)
|
|
||||||
}
|
|
||||||
maze.push(<div key={`r${y}`} className={"row"}>{row}</div>);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={"maze"}>
|
|
||||||
{maze}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
import {actionClosedMessageBanner} from "./state/action.ts";
|
|
||||||
|
|
||||||
export default function MessageBanner({state, dispatch}) {
|
|
||||||
function handleClose() {
|
|
||||||
dispatch(actionClosedMessageBanner());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!!state.errorMessage) {
|
|
||||||
return (<div className={"message-banner"}>
|
|
||||||
{state.errorMessage}
|
|
||||||
<button onClick={handleClose}>Dismiss message</button>
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
return <></>;
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
import React, {useState} from 'react';
|
|
||||||
|
|
||||||
export default function ValidatingInputNumberField({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
value = 0,
|
|
||||||
constraints = {},
|
|
||||||
validatorFn = (value) => {
|
|
||||||
return {valid: true, value};
|
|
||||||
},
|
|
||||||
disabled = false,
|
|
||||||
onChange = _ => {
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
const handleValueChange = (e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
const validation = validatorFn(value);
|
|
||||||
if (!validation.valid) {
|
|
||||||
setError(validation.message);
|
|
||||||
} else {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
onChange(validation.value);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<label htmlFor={id}>{label}: </label>
|
|
||||||
<input id={id}
|
|
||||||
type={"number"}
|
|
||||||
onChange={handleValueChange}
|
|
||||||
value={value || ""}
|
|
||||||
min={constraints.min || null}
|
|
||||||
max={constraints.max || null}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
{!!error && <span>{error}</span>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
35
src/app/cell.css
Normal file
35
src/app/cell.css
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
.solution {
|
||||||
|
background-color: var(--color-maze-cell-solution);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user{
|
||||||
|
background-color: var(--color-maze-cell-user);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user:hover {
|
||||||
|
background-color: var(--color-maze-cell-user-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.solution.user {
|
||||||
|
background-color: #c8ff00;
|
||||||
|
}
|
11
src/app/cell.module.css
Normal file
11
src/app/cell.module.css
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.cell {
|
||||||
|
display: table-cell;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
height: 2em;
|
||||||
|
width: 2em;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell:hover {
|
||||||
|
background-color: var(--color-background-highlight);
|
||||||
|
}
|
42
src/app/cell.tsx
Normal file
42
src/app/cell.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import {MazeCell} from "./model/maze.ts";
|
||||||
|
import Coordinates from "./model/coordinates.ts";
|
||||||
|
import {Action, actionClickedCell} from "./state/action.ts";
|
||||||
|
import styles from "./cell.module.css";
|
||||||
|
import "./cell.css";
|
||||||
|
import {State} from "./state/state.ts";
|
||||||
|
import {ActionDispatch} from "react";
|
||||||
|
|
||||||
|
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}:
|
||||||
|
{
|
||||||
|
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 marked = isMarked(x, y, state.userPath);
|
||||||
|
if (marked) classes += " user";
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 894 B |
54
src/app/globals.css
Normal file
54
src/app/globals.css
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
: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-highlight: #ffdd22;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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-highlight: #ffdd22;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
21
src/app/input-form.css
Normal file
21
src/app/input-form.css
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:invalid {
|
||||||
|
border-color: var(--color-form-error);
|
||||||
|
}
|
14
src/app/input-form.module.css
Normal file
14
src/app/input-form.module.css
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
.inputform {
|
||||||
|
display: grid;
|
||||||
|
grid-row-gap: 0.5em;
|
||||||
|
grid-template-columns: 7em 14em auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitbutton {
|
||||||
|
background-color: #fc0;
|
||||||
|
border: 1px solid var(--color-foreground);
|
||||||
|
padding: 0.5em;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
margin-top: 2em;
|
||||||
|
width: 11em;
|
||||||
|
}
|
118
src/app/input-form.tsx
Normal file
118
src/app/input-form.tsx
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import {ActionDispatch, FormEvent, useState} from 'react';
|
||||||
|
import ValidatingInputNumberField, {ValidatorFunction} from "./validating-input-number-field.tsx";
|
||||||
|
import {Action, actionLoadedMaze, actionLoadingFailed, actionStartedLoading} from "./state/action.ts";
|
||||||
|
import styles from "./input-form.module.css";
|
||||||
|
import "./input-form.css";
|
||||||
|
import {State} from "@/app/state/state.ts";
|
||||||
|
|
||||||
|
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<number>();
|
||||||
|
const [algorithm, setAlgorithm] = useState('wilson');
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dispatch(actionStartedLoading());
|
||||||
|
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 => new Promise(resolve => setTimeout(resolve, 600, result)))
|
||||||
|
.then(result => {
|
||||||
|
dispatch(actionLoadedMaze(result));
|
||||||
|
})
|
||||||
|
.catch(reason => {
|
||||||
|
console.error("Failed to fetch maze data.", reason);
|
||||||
|
dispatch(actionLoadingFailed(reason));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const validateWidthHeightInput: ValidatorFunction<string, number> = value => {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const validateIdInput: ValidatorFunction<string, number> = value => {
|
||||||
|
if ("" === value) {
|
||||||
|
return {
|
||||||
|
valid: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const numberValue = Number(value);
|
||||||
|
// FIXME doesn't handle strings with characters correctly (e.g. "asdf" yields an empty value, due to "type=number").
|
||||||
|
if (isNaN(numberValue) || Math.floor(numberValue) !== numberValue) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: "Must be empty or an integer"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
value: numberValue
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.inputform}>
|
||||||
|
<ValidatingInputNumberField id={"width"}
|
||||||
|
label={"Width"}
|
||||||
|
value={width}
|
||||||
|
constraints={{
|
||||||
|
min: 2
|
||||||
|
}}
|
||||||
|
validatorFn={validateWidthHeightInput}
|
||||||
|
disabled={state.loading}
|
||||||
|
onChange={setWidth}
|
||||||
|
/>
|
||||||
|
<ValidatingInputNumberField id={"height"}
|
||||||
|
label={"Height"}
|
||||||
|
value={height}
|
||||||
|
constraints={{
|
||||||
|
min: 2
|
||||||
|
}}
|
||||||
|
validatorFn={validateWidthHeightInput}
|
||||||
|
disabled={state.loading}
|
||||||
|
onChange={setHeight}
|
||||||
|
/>
|
||||||
|
<ValidatingInputNumberField id={"id"}
|
||||||
|
label={"ID (optional)"}
|
||||||
|
value={id}
|
||||||
|
validatorFn={validateIdInput}
|
||||||
|
disabled={state.loading}
|
||||||
|
onChange={setId}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
19
src/app/layout.tsx
Normal file
19
src/app/layout.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import type {Metadata} from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "A-Maze-R! Create your own Maze!",
|
||||||
|
description: "A Maze Generator by fritteli",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({children}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
11
src/app/maze.module.css
Normal file
11
src/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
src/app/maze.tsx
Normal file
29
src/app/maze.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import Cell from "./cell.tsx";
|
||||||
|
import styles from "./maze.module.css";
|
||||||
|
import {State} from "@/app/state/state.ts";
|
||||||
|
import {ActionDispatch, JSX} from "react";
|
||||||
|
import {Action} from "@/app/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
src/app/message-banner.module.css
Normal file
10
src/app/message-banner.module.css
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.banner {
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
border: 1px solid red;
|
||||||
|
background-color: #ff000044;
|
||||||
|
}
|
24
src/app/message-banner.tsx
Normal file
24
src/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 "@/app/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>);
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import Coordinates from "./Coordinates";
|
import Coordinates from "./coordinates.ts";
|
||||||
|
|
||||||
export default interface Maze {
|
export default interface Maze {
|
||||||
id: string,
|
id: string,
|
||||||
|
@ -6,6 +6,7 @@ export default interface Maze {
|
||||||
height: number,
|
height: number,
|
||||||
start: Coordinates,
|
start: Coordinates,
|
||||||
end: Coordinates,
|
end: Coordinates,
|
||||||
|
algorithm: string,
|
||||||
grid: MazeCell[][]
|
grid: MazeCell[][]
|
||||||
}
|
}
|
||||||
|
|
179
src/app/page.module.css
Normal file
179
src/app/page.module.css
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
.page {
|
||||||
|
}
|
||||||
|
|
||||||
|
.page h1.mainheading, .page h2.mainheading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page h2.mainheading {
|
||||||
|
font-size: medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*.page {*/
|
||||||
|
/* --gray-rgb: 0, 0, 0;*/
|
||||||
|
/* --gray-alpha-200: rgba(var(--gray-rgb), 0.08);*/
|
||||||
|
/* --gray-alpha-100: rgba(var(--gray-rgb), 0.05);*/
|
||||||
|
|
||||||
|
/* --button-primary-hover: #383838;*/
|
||||||
|
/* --button-secondary-hover: #f2f2f2;*/
|
||||||
|
|
||||||
|
/* display: grid;*/
|
||||||
|
/* grid-template-rows: 20px 1fr 20px;*/
|
||||||
|
/* align-items: center;*/
|
||||||
|
/* justify-items: center;*/
|
||||||
|
/* min-height: 100svh;*/
|
||||||
|
/* padding: 80px;*/
|
||||||
|
/* gap: 64px;*/
|
||||||
|
/* font-family: var(--font-geist-sans);*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*@media (prefers-color-scheme: dark) {*/
|
||||||
|
/* .page {*/
|
||||||
|
/* --gray-rgb: 255, 255, 255;*/
|
||||||
|
/* --gray-alpha-200: rgba(var(--gray-rgb), 0.145);*/
|
||||||
|
/* --gray-alpha-100: rgba(var(--gray-rgb), 0.06);*/
|
||||||
|
|
||||||
|
/* --button-primary-hover: #ccc;*/
|
||||||
|
/* --button-secondary-hover: #1a1a1a;*/
|
||||||
|
/* }*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.main {*/
|
||||||
|
/* display: flex;*/
|
||||||
|
/* flex-direction: column;*/
|
||||||
|
/* gap: 32px;*/
|
||||||
|
/* grid-row-start: 2;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.main ol {*/
|
||||||
|
/* font-family: var(--font-geist-mono);*/
|
||||||
|
/* padding-left: 0;*/
|
||||||
|
/* margin: 0;*/
|
||||||
|
/* font-size: 14px;*/
|
||||||
|
/* line-height: 24px;*/
|
||||||
|
/* letter-spacing: -0.01em;*/
|
||||||
|
/* list-style-position: inside;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.main li:not(:last-of-type) {*/
|
||||||
|
/* margin-bottom: 8px;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.main code {*/
|
||||||
|
/* font-family: inherit;*/
|
||||||
|
/* background: var(--gray-alpha-100);*/
|
||||||
|
/* padding: 2px 4px;*/
|
||||||
|
/* border-radius: 4px;*/
|
||||||
|
/* font-weight: 600;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.ctas {*/
|
||||||
|
/* display: flex;*/
|
||||||
|
/* gap: 16px;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.ctas a {*/
|
||||||
|
/* appearance: none;*/
|
||||||
|
/* border-radius: 128px;*/
|
||||||
|
/* height: 48px;*/
|
||||||
|
/* padding: 0 20px;*/
|
||||||
|
/* border: none;*/
|
||||||
|
/* border: 1px solid transparent;*/
|
||||||
|
/* transition:*/
|
||||||
|
/* background 0.2s,*/
|
||||||
|
/* color 0.2s,*/
|
||||||
|
/* border-color 0.2s;*/
|
||||||
|
/* cursor: pointer;*/
|
||||||
|
/* display: flex;*/
|
||||||
|
/* align-items: center;*/
|
||||||
|
/* justify-content: center;*/
|
||||||
|
/* font-size: 16px;*/
|
||||||
|
/* line-height: 20px;*/
|
||||||
|
/* font-weight: 500;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*a.primary {*/
|
||||||
|
/* background: var(--foreground);*/
|
||||||
|
/* color: var(--background);*/
|
||||||
|
/* gap: 8px;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*a.secondary {*/
|
||||||
|
/* border-color: var(--gray-alpha-200);*/
|
||||||
|
/* min-width: 180px;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.footer {*/
|
||||||
|
/* grid-row-start: 3;*/
|
||||||
|
/* display: flex;*/
|
||||||
|
/* gap: 24px;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.footer a {*/
|
||||||
|
/* display: flex;*/
|
||||||
|
/* align-items: center;*/
|
||||||
|
/* gap: 8px;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*.footer img {*/
|
||||||
|
/* flex-shrink: 0;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*!* Enable hover only on non-touch devices *!*/
|
||||||
|
/*@media (hover: hover) and (pointer: fine) {*/
|
||||||
|
/* a.primary:hover {*/
|
||||||
|
/* background: var(--button-primary-hover);*/
|
||||||
|
/* border-color: transparent;*/
|
||||||
|
/* }*/
|
||||||
|
|
||||||
|
/* a.secondary:hover {*/
|
||||||
|
/* background: var(--button-secondary-hover);*/
|
||||||
|
/* border-color: transparent;*/
|
||||||
|
/* }*/
|
||||||
|
|
||||||
|
/* .footer a:hover {*/
|
||||||
|
/* text-decoration: underline;*/
|
||||||
|
/* text-underline-offset: 4px;*/
|
||||||
|
/* }*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*@media (max-width: 600px) {*/
|
||||||
|
/* .page {*/
|
||||||
|
/* padding: 32px;*/
|
||||||
|
/* padding-bottom: 80px;*/
|
||||||
|
/* }*/
|
||||||
|
|
||||||
|
/* .main {*/
|
||||||
|
/* align-items: center;*/
|
||||||
|
/* }*/
|
||||||
|
|
||||||
|
/* .main ol {*/
|
||||||
|
/* text-align: center;*/
|
||||||
|
/* }*/
|
||||||
|
|
||||||
|
/* .ctas {*/
|
||||||
|
/* flex-direction: column;*/
|
||||||
|
/* }*/
|
||||||
|
|
||||||
|
/* .ctas a {*/
|
||||||
|
/* font-size: 14px;*/
|
||||||
|
/* height: 40px;*/
|
||||||
|
/* padding: 0 16px;*/
|
||||||
|
/* }*/
|
||||||
|
|
||||||
|
/* a.secondary {*/
|
||||||
|
/* min-width: auto;*/
|
||||||
|
/* }*/
|
||||||
|
|
||||||
|
/* .footer {*/
|
||||||
|
/* flex-wrap: wrap;*/
|
||||||
|
/* align-items: center;*/
|
||||||
|
/* justify-content: center;*/
|
||||||
|
/* }*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
/*@media (prefers-color-scheme: dark) {*/
|
||||||
|
/* .logo {*/
|
||||||
|
/* filter: invert();*/
|
||||||
|
/* }*/
|
||||||
|
/*}*/
|
|
@ -1,23 +1,29 @@
|
||||||
import {useReducer} from 'react';
|
'use client'
|
||||||
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() {
|
import styles from "./page.module.css";
|
||||||
|
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 [state, dispatch] = useReducer(reduce, INITIAL_STATE);
|
||||||
const hasValidMaze = !!state.maze;
|
const hasValidMaze = !!state.maze;
|
||||||
return (
|
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}
|
<MessageBanner state={state}
|
||||||
dispatch={dispatch}/>
|
dispatch={dispatch}/>
|
||||||
<InputForm state={state}
|
<InputForm state={state}
|
||||||
dispatch={dispatch}/>
|
dispatch={dispatch}/>
|
||||||
{hasValidMaze &&
|
{hasValidMaze &&
|
||||||
<>
|
<>
|
||||||
<h1>The Maze ({state.maze.width}x{state.maze.height}, ID: {state.maze.id})</h1>
|
<h1>The Maze ({state.maze!.width}x{state.maze!.height}, Algorithm: {state.maze!.algorithm},
|
||||||
|
ID: {state.maze!.id})</h1>
|
||||||
<input type={"checkbox"}
|
<input type={"checkbox"}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
dispatch(actionToggledShowSolution(e.target.checked));
|
dispatch(actionToggledShowSolution(e.target.checked));
|
||||||
|
@ -27,6 +33,6 @@ export default function App() {
|
||||||
dispatch={dispatch}/>
|
dispatch={dispatch}/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import Maze from "../model/Maze";
|
import Maze from "../model/maze.ts";
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
type: string,
|
type: string,
|
||||||
|
|
||||||
[key: string]: any
|
[key: string]: boolean | number | string | object | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ID_ACTION_STARTED_LOADING = 'started_loading';
|
export const ID_ACTION_STARTED_LOADING = 'started_loading';
|
|
@ -9,8 +9,9 @@ import {
|
||||||
ID_ACTION_STARTED_LOADING,
|
ID_ACTION_STARTED_LOADING,
|
||||||
ID_ACTION_TOGGLED_SHOW_SOLUTION
|
ID_ACTION_TOGGLED_SHOW_SOLUTION
|
||||||
} from "./action.ts";
|
} from "./action.ts";
|
||||||
|
import Maze from "@/app/model/maze.ts";
|
||||||
|
|
||||||
export default function reduce(state: State, action: Action) {
|
export default function reduce(state: State, action: Action): State {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ID_ACTION_STARTED_LOADING: {
|
case ID_ACTION_STARTED_LOADING: {
|
||||||
return {
|
return {
|
||||||
|
@ -24,7 +25,7 @@ export default function reduce(state: State, action: Action) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
loading: false,
|
loading: false,
|
||||||
maze: action.maze,
|
maze: action.maze as Maze,
|
||||||
userPath: []
|
userPath: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,7 +39,7 @@ export default function reduce(state: State, action: Action) {
|
||||||
case ID_ACTION_TOGGLED_SHOW_SOLUTION: {
|
case ID_ACTION_TOGGLED_SHOW_SOLUTION: {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
showSolution: action.value
|
showSolution: action.value as boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ID_ACTION_CLOSED_MESSAGE_BANNER: {
|
case ID_ACTION_CLOSED_MESSAGE_BANNER: {
|
||||||
|
@ -49,7 +50,7 @@ export default function reduce(state: State, action: Action) {
|
||||||
}
|
}
|
||||||
case ID_ACTION_CLICKED_CELL: {
|
case ID_ACTION_CLICKED_CELL: {
|
||||||
// There's so much logic involved, externalize that into its own file.
|
// There's so much logic involved, externalize that into its own file.
|
||||||
return handleUserClicked(state, action.x, action.y);
|
return handleUserClicked(state, action.x as number, action.y as number);
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Unknown action: ${action.type}`);
|
throw new Error(`Unknown action: ${action.type}`);
|
|
@ -1,5 +1,5 @@
|
||||||
import Coordinates from "../model/Coordinates";
|
import Coordinates from "../model/coordinates.ts";
|
||||||
import Maze from "../model/Maze";
|
import Maze from "../model/maze.ts";
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
errorMessage: string | null,
|
errorMessage: string | null,
|
|
@ -1,15 +1,16 @@
|
||||||
import {State} from "./state";
|
import {State} from "./state";
|
||||||
import Coordinates from "../model/Coordinates";
|
import Coordinates from "../model/coordinates.ts";
|
||||||
import {MazeCell} from "../model/Maze";
|
import {MazeCell} from "../model/maze.ts";
|
||||||
|
|
||||||
export default function handleUserClicked(state: State, x: number, y: number): State {
|
export default function handleUserClicked(state: State, x: number, y: number): State {
|
||||||
if (isClickAllowed(x, y, 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)
|
// 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.
|
// and that's not blocked by a wall. Now let's see.
|
||||||
if (-1 === state.userPath.findIndex(step => step.x === x && step.y === y)) {
|
if (-1 === state.userPath.findIndex(step => step.x === x && step.y === y)) {
|
||||||
// The clicked cell is not yet part of the userpath --> add it.
|
// The clicked cell is not yet part of the userpath --> add it.
|
||||||
// If it's the end tile, also show a congratulation message
|
// If it's the end tile, also show a congratulation message
|
||||||
const showMessage = x === state.maze.end.x && y === state.maze.end.y;
|
const showMessage = x === maze.end.x && y === maze.end.y;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
userPath: [...state.userPath, {x, y}],
|
userPath: [...state.userPath, {x, y}],
|
||||||
|
@ -17,7 +18,7 @@ export default function handleUserClicked(state: State, x: number, y: number): S
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// The clicked cell IS part of the userpath. Is it the last cell of it?
|
// The clicked cell IS part of the userpath. Is it the last cell of it?
|
||||||
const lastCoordsFromUserPath = getLastCoordsFromUserPath(state);
|
const lastCoordsFromUserPath = getLastCoordsFromUserPath(state)!;
|
||||||
if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) {
|
if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) {
|
||||||
// Yes, it's the last cell of the userpath --> remove it.
|
// Yes, it's the last cell of the userpath --> remove it.
|
||||||
return {
|
return {
|
||||||
|
@ -36,7 +37,7 @@ function isClickAllowed(x: number, y: number, state: State): boolean {
|
||||||
const lastCoordsFromUserPath = getLastCoordsFromUserPath(state);
|
const lastCoordsFromUserPath = getLastCoordsFromUserPath(state);
|
||||||
if (!lastCoordsFromUserPath) {
|
if (!lastCoordsFromUserPath) {
|
||||||
// when nothing has been marked yet, we can only toggle the starting position
|
// when nothing has been marked yet, we can only toggle the starting position
|
||||||
return x === state.maze.start.x && y === state.maze.start.y;
|
return x === state.maze!.start.x && y === state.maze!.start.y;
|
||||||
}
|
}
|
||||||
if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) {
|
if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) {
|
||||||
// toggling the last position in the path is always allowed
|
// toggling the last position in the path is always allowed
|
||||||
|
@ -70,7 +71,7 @@ function isClickAllowed(x: number, y: number, state: State): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCellAt(coords: Coordinates, state: State): MazeCell {
|
function getCellAt(coords: Coordinates, state: State): MazeCell {
|
||||||
return state.maze.grid[coords.y][coords.x];
|
return state.maze!.grid[coords.y][coords.x];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastCoordsFromUserPath(state: State): Coordinates | null {
|
function getLastCoordsFromUserPath(state: State): Coordinates | null {
|
60
src/app/validating-input-number-field.tsx
Normal file
60
src/app/validating-input-number-field.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import React, {ChangeEventHandler, useState} from 'react';
|
||||||
|
|
||||||
|
export default 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 interface Validation<T> {
|
||||||
|
valid: boolean;
|
||||||
|
message?: string;
|
||||||
|
value?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidatorFunction<I, T> = (v: I) => Validation<T>;
|
|
@ -1,52 +0,0 @@
|
||||||
// In production, we register a service worker to serve assets from local cache.
|
|
||||||
|
|
||||||
// This lets the app load faster on subsequent visits in production, and gives
|
|
||||||
// it offline capabilities. However, it also means that developers (and users)
|
|
||||||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
|
||||||
// cached resources are updated in the background.
|
|
||||||
|
|
||||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
|
||||||
// This link also includes instructions on opting out of this behavior.
|
|
||||||
|
|
||||||
export default function register() {
|
|
||||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
|
||||||
navigator.serviceWorker
|
|
||||||
.register(swUrl)
|
|
||||||
.then(registration => {
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
registration.onupdatefound = () => {
|
|
||||||
const installingWorker = registration.installing;
|
|
||||||
installingWorker.onstatechange = () => {
|
|
||||||
if (installingWorker.state === 'installed') {
|
|
||||||
if (navigator.serviceWorker.controller) {
|
|
||||||
// At this point, the old content will have been purged and
|
|
||||||
// the fresh content will have been added to the cache.
|
|
||||||
// It's the perfect time to display a "New content is
|
|
||||||
// available; please refresh." message in your web app.
|
|
||||||
console.log('New content is available; please refresh.'); // eslint-disable-line no-console
|
|
||||||
} else {
|
|
||||||
// At this point, everything has been precached.
|
|
||||||
// It's the perfect time to display a
|
|
||||||
// "Content is cached for offline use." message.
|
|
||||||
console.log('Content is cached for offline use.'); // eslint-disable-line no-console
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error during service worker registration:', error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregister() {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.ready.then(registration => {
|
|
||||||
registration.unregister();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.0.0",
|
|
||||||
"react-dom": "^18.0.0",
|
|
||||||
"react-scripts": "^5.0.0"
|
|
||||||
},
|
|
||||||
"main": "/index.js",
|
|
||||||
"devDependencies": {}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>A-Maze-R</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
151
src/styles.css
151
src/styles.css
|
@ -1,151 +0,0 @@
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.maze {
|
|
||||||
display: table;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.row {
|
|
||||||
display: table-row;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.cell {
|
|
||||||
display: table-cell;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
height: 2em;
|
|
||||||
width: 2em;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.cell.solution {
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.cell.top {
|
|
||||||
border-top-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.cell.right {
|
|
||||||
border-right-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.cell.bottom {
|
|
||||||
border-bottom-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: sans-serif;
|
|
||||||
margin: 20px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.square {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #999;
|
|
||||||
float: left;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
line-height: 34px;
|
|
||||||
height: 34px;
|
|
||||||
margin-right: -1px;
|
|
||||||
margin-top: -1px;
|
|
||||||
padding: 0;
|
|
||||||
text-align: center;
|
|
||||||
width: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board-row:after {
|
|
||||||
clear: both;
|
|
||||||
content: '';
|
|
||||||
display: table;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-info {
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
|
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"a-maze-r/node_modules"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in a new issue