├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── css └── main.css ├── images ├── finish.png └── player.png ├── index.html ├── js ├── config.js ├── main.js ├── model.js ├── stateMachine.js └── view.js └── notes.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "js/lib"] 2 | path = js/lib 3 | url = git@github.com:codebox/maze.js.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rob Dawson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Maze Generator 2 | 3 | Here is an online [maze generator](https://codebox.net/pages/maze-generator/online) that can create mazes using square, 4 | triangular, hexagonal or circular grids: 5 | 6 | Maze using a square grid Maze using a circular grid Maze using a hexagonal grid Maze using a triangular grid 7 | 8 | As well as creating mazes the generator has many other features, for example it can render a 'distance map', 9 | colouring each location in the grid according how far away it is from a selected point: 10 | 11 | Maze distance map 12 | 13 | The generator offers a choice of 10 different algorithms, which each produce mazes with different characteristics. 14 | All mazes created by these algorithms are 'perfect' mazes, i.e. there is exactly one path connecting any pair of 15 | locations within the grid, and therefore one unique solution to each maze. 16 | 17 | If you want to try solving one of the mazes yourself then you can! The generator lets you navigate through the maze 18 | using mouse/keyboard controls, and can automatically move you forward to the next junction in the maze to save you 19 | time. Once you finish a maze your time is displayed, together with an 'optimality score' showing how close your 20 | solution was to the optimal one. Of course, you can also give up at any point and see where you should have gone: 21 | 22 | Maze game in progress Maze solution 23 | 24 | The generator can either create mazes instantly, or slow down the process so that you can watch the algorithms at work. 25 | Some algorithms work using a process of trial and error, and can take a long time to finish, whereas others are guaranteed 26 | to complete quickly: 27 | 28 | 34 | 35 | By creating a mask you can remove cells from the default grids to create interesting shapes: 36 | 37 | Maze using a square grid with masking Maze using a circular grid with masking Maze using a hexagonal grid with masking Maze using a triangular grid with masking 38 | 39 | Normally the generator creates a completely unique random maze each time you use it, however if you want to play around with a 40 | particular maze without changing the layout then just take a note of the 'Seed Value' that is displayed alongside it. 41 | Entering this value into the 'Seed' input field will make sure you get the same pattern again when you click the 'New Maze' button. 42 | 43 | If you make a maze that you would like to keep you can download your creation as an SVG file, 44 | either with or without the solution displayed. 45 | 46 | Many thanks to Jamis Buck for his excellent book [Mazes for Programmers](http://mazesforprogrammers.com) which taught me 47 | everything I needed to know to make this. 48 | 49 | ## Running Locally 50 | You can [try out the maze generator online here](https://codebox.net/pages/maze-generator/online). 51 | If you want to run your own copy then: 52 | * Clone the repository, including the 'mazejs' submodule: 53 | 54 | `git clone --recurse-submodules git@github.com:codebox/mazes.git` 55 | 56 | * Go into the 'mazes' directory: 57 | 58 | `cd mazes` 59 | 60 | * Start a web server at this location: 61 | 62 | `python3 -m http.server` 63 | -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-colour: #444; 3 | --border-colour: #222; 4 | --alt-colour: #006BB7; 5 | --panel-bg-colour: #EEE; 6 | --small-font-size: 0.8em; 7 | --wide-margin: 10px; 8 | --narrow-margin: 5px; 9 | } 10 | * { 11 | box-sizing: border-box 12 | } 13 | html, body { 14 | width: 100%; 15 | height: 100%; 16 | margin: 0; 17 | font-family: sans-serif; 18 | color: var(--font-colour); 19 | } 20 | #container { 21 | display: flex; 22 | flex-direction: row; 23 | height: 100%; 24 | padding: var(--wide-margin); 25 | } 26 | #mobileTitle { 27 | display: none; 28 | } 29 | #mazeContainer { 30 | flex: 1 1 auto; 31 | min-width: 0; 32 | } 33 | #sidebarContainer { 34 | flex: 0 0 220px; 35 | padding: var(--narrow-margin); 36 | border: 1px solid var(--border-colour); 37 | overflow-y: auto; 38 | user-select: none; 39 | } 40 | #info{ 41 | user-select: text; 42 | } 43 | #sidebarContainer li.selected, p.selected { 44 | background-color: var(--alt-colour); 45 | color: white; 46 | } 47 | #sidebarContainer div, .title { 48 | border: 1px solid var(--border-colour); 49 | background-color: var(--panel-bg-colour); 50 | border-radius: 3px; 51 | } 52 | .title { 53 | text-align: center; 54 | padding: 15px 0; 55 | margin-bottom: var(--wide-margin); 56 | } 57 | h1 { 58 | margin: 0; 59 | font-weight:normal; 60 | font-size: 1.5em; 61 | } 62 | .title a { 63 | font-size: var(--small-font-size); 64 | } 65 | .title a, .title a:visited { 66 | color: var(--alt-colour); 67 | } 68 | button, li, .selectable { 69 | cursor: pointer; 70 | } 71 | li, #applyMask p { 72 | padding: 2px 5px; 73 | } 74 | #sidebarContainer ul { 75 | border: 1px solid var(--border-colour); 76 | padding: var(--narrow-margin) 0; 77 | list-style-type: none; 78 | font-size: var(--small-font-size); 79 | background-color: var(--panel-bg-colour); 80 | border-radius: 3px; 81 | margin: var(--narrow-margin) 0; 82 | } 83 | #applyMask { 84 | padding: var(--narrow-margin) 0; 85 | font-size: var(--small-font-size); 86 | margin: var(--narrow-margin) 0; 87 | } 88 | #applyMask p { 89 | margin: 0; 90 | } 91 | button { 92 | display: block; 93 | width: 100%; 94 | background-color: var(--panel-bg-colour); 95 | margin: 5px 0; 96 | border: 1px solid var(--border-colour); 97 | border-radius: 3px; 98 | padding: var(--narrow-margin) 0; 99 | font-size: 1em; 100 | } 101 | #go { 102 | font-weight: bold; 103 | } 104 | #maskNotSupported, #info { 105 | text-align: center; 106 | } 107 | div#info, div#details { 108 | margin-top: var(--wide-margin); 109 | background-color: white; 110 | padding: var(--wide-margin); 111 | } 112 | div#details em { 113 | font-style: normal; 114 | color: var(--alt-colour); 115 | } 116 | ul label { 117 | display: inline-block; 118 | width: 50px; 119 | } 120 | #shapeSelector li, #sizeParameters label { 121 | text-transform: capitalize; 122 | } 123 | #seedInput { 124 | width: 100px; 125 | } 126 | #play, #changeParams { 127 | margin-top: var(--wide-margin); 128 | } 129 | @media screen and (max-width: 500px) { 130 | #container { 131 | flex-direction: column; 132 | height: auto; 133 | } 134 | #mazeContainer { 135 | margin-bottom: 10px; 136 | } 137 | #maze { 138 | margin: 0 auto; 139 | display: block; 140 | } 141 | .title { 142 | display: none; 143 | } 144 | #mobileTitle { 145 | display: block; 146 | padding: 5px; 147 | } 148 | #sidebarContainer { 149 | flex: none; 150 | overflow: auto; 151 | } 152 | } -------------------------------------------------------------------------------- /images/finish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebox/mazes/9bc65d619ab750c325960e0026422428633861cb/images/finish.png -------------------------------------------------------------------------------- /images/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebox/mazes/9bc65d619ab750c325960e0026422428633861cb/images/player.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Maze Generator 5 | 6 | 7 | 8 | 9 |
10 |
11 |

Maze Generator

12 |
13 |
14 | 15 |
16 |
17 |
18 |

Maze Generator

19 | Project Home 20 |
21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /js/config.js: -------------------------------------------------------------------------------- 1 | import {ALGORITHM_RECURSIVE_BACKTRACK} from './lib/constants.js'; 2 | 3 | export const config = Object.freeze({ 4 | shapes: { 5 | 'square': { 6 | description: 'Square Grid', 7 | parameters: { 8 | width: { 9 | min: 2, 10 | max: 50, 11 | initial: 10 12 | }, 13 | height: { 14 | min: 2, 15 | max: 50, 16 | initial: 10 17 | } 18 | }, 19 | defaultAlgorithm: ALGORITHM_RECURSIVE_BACKTRACK 20 | }, 21 | 'triangle': { 22 | description: 'Triangle Grid', 23 | parameters: { 24 | width: { 25 | min: 4, 26 | max: 85, 27 | initial: 17 28 | }, 29 | height: { 30 | min: 2, 31 | max: 50, 32 | initial: 10 33 | } 34 | }, 35 | defaultAlgorithm: ALGORITHM_RECURSIVE_BACKTRACK 36 | }, 37 | 'hexagon': { 38 | description: 'Hexagon Grid', 39 | parameters: { 40 | width: { 41 | min: 2, 42 | max: 50, 43 | initial: 10 44 | }, 45 | height: { 46 | min: 2, 47 | max: 50, 48 | initial: 10 49 | } 50 | }, 51 | defaultAlgorithm: ALGORITHM_RECURSIVE_BACKTRACK 52 | }, 53 | 'circle': { 54 | description: 'Circular', 55 | parameters: { 56 | layers: { 57 | min: 2, 58 | max: 30, 59 | initial: 10 60 | } 61 | }, 62 | defaultAlgorithm: ALGORITHM_RECURSIVE_BACKTRACK 63 | } 64 | } 65 | }); -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | import {buildModel} from './model.js'; 2 | import {buildView} from './view.js'; 3 | import {buildMaze} from './lib/main.js'; 4 | import {buildStateMachine, STATE_INIT, STATE_DISPLAYING, STATE_PLAYING, STATE_MASKING, STATE_DISTANCE_MAPPING, STATE_RUNNING_ALGORITHM} from './stateMachine.js'; 5 | import {shapes} from './lib/shapes.js'; 6 | import {drawingSurfaces} from './lib/drawingSurfaces.js'; 7 | import { 8 | EVENT_MAZE_SHAPE_SELECTED, EVENT_SIZE_PARAMETER_CHANGED, EVENT_ALGORITHM_SELECTED, EVENT_GO_BUTTON_CLICKED, EVENT_WINDOW_RESIZED, 9 | EVENT_SHOW_MAP_BUTTON_CLICKED, EVENT_CLEAR_MAP_BUTTON_CLICKED, EVENT_CREATE_MASK_BUTTON_CLICKED, 10 | EVENT_SAVE_MASK_BUTTON_CLICKED, EVENT_CLEAR_MASK_BUTTON_CLICKED, EVENT_FINISH_RUNNING_BUTTON_CLICKED, EVENT_DELAY_SELECTED, 11 | EVENT_CHANGE_PARAMS_BUTTON_CLICKED, EVENT_EXITS_SELECTED, EVENT_SOLVE_BUTTON_CLICKED, EVENT_PLAY_BUTTON_CLICKED, EVENT_STOP_BUTTON_CLICKED, 12 | EVENT_KEY_PRESS, EVENT_DOWNLOAD_CLICKED 13 | } from './view.js'; 14 | import {config} from './config.js'; 15 | import {algorithms} from './lib/algorithms.js'; 16 | import {buildRandom} from './lib/random.js'; 17 | import { 18 | ALGORITHM_NONE, METADATA_MASKED, METADATA_END_CELL, METADATA_START_CELL, EVENT_CLICK, EXITS_NONE, EXITS_HARDEST, EXITS_HORIZONTAL, EXITS_VERTICAL, 19 | METADATA_PLAYER_CURRENT, METADATA_PLAYER_VISITED, METADATA_PATH, METADATA_VISITED, 20 | DIRECTION_NORTH, DIRECTION_SOUTH, DIRECTION_EAST, DIRECTION_WEST, DIRECTION_NORTH_WEST, DIRECTION_NORTH_EAST, DIRECTION_SOUTH_WEST, DIRECTION_SOUTH_EAST, 21 | DIRECTION_CLOCKWISE, DIRECTION_ANTICLOCKWISE, DIRECTION_INWARDS, DIRECTION_OUTWARDS, 22 | SHAPE_SQUARE, SHAPE_TRIANGLE, SHAPE_HEXAGON, SHAPE_CIRCLE 23 | } from './lib/constants.js'; 24 | 25 | window.onload = () => { 26 | "use strict"; 27 | const model = buildModel(), 28 | stateMachine = buildStateMachine(), 29 | view = buildView(model, stateMachine); 30 | 31 | function isMaskAvailableForCurrentConfig() { 32 | const currentMask = model.mask[getModelMaskKey()]; 33 | return currentMask && currentMask.length; 34 | } 35 | 36 | function setupShapeParameter() { 37 | Object.keys(shapes).forEach(name => { 38 | view.addShape(name); 39 | }); 40 | 41 | function onShapeSelected(shapeName) { 42 | view.setShape(model.shape = shapeName); 43 | view.updateMaskButtonCaption(isMaskAvailableForCurrentConfig()); 44 | } 45 | onShapeSelected(model.shape); 46 | 47 | view.on(EVENT_MAZE_SHAPE_SELECTED, shapeName => { 48 | onShapeSelected(shapeName); 49 | setupSizeParameters(); 50 | setupAlgorithms(); 51 | showEmptyGrid(true); 52 | }); 53 | } 54 | 55 | function setupSizeParameters() { 56 | const shape = model.shape, 57 | parameters = config.shapes[shape].parameters; 58 | 59 | model.size = {}; 60 | view.clearSizeParameters(); 61 | 62 | Object.entries(parameters).forEach(([paramName, paramValues]) => { 63 | view.addSizeParameter(paramName, paramValues.min, paramValues.max); 64 | }); 65 | 66 | function onParameterChanged(name, value) { 67 | model.size[name] = value; 68 | view.setSizeParameter(name, value); 69 | view.updateMaskButtonCaption(isMaskAvailableForCurrentConfig()); 70 | } 71 | Object.entries(parameters).forEach(([paramName, paramValues]) => { 72 | onParameterChanged(paramName, paramValues.initial); 73 | }); 74 | 75 | view.on(EVENT_SIZE_PARAMETER_CHANGED, data => { 76 | if (view.getValidSizeParameters().includes(data.name)) { 77 | onParameterChanged(data.name, data.value); 78 | showEmptyGrid(true); 79 | setupAlgorithms(); 80 | } 81 | }); 82 | } 83 | 84 | function setupAlgorithms() { 85 | const shape = model.shape; 86 | 87 | view.clearAlgorithms(); 88 | 89 | Object.entries(algorithms).filter(([algorithmId, algorithm]) => algorithmId !== ALGORITHM_NONE).forEach(([algorithmId, algorithm]) => { 90 | if (algorithm.metadata.shapes.includes(shape) && (algorithm.metadata.maskable || !isMaskAvailableForCurrentConfig())) { 91 | view.addAlgorithm(algorithm.metadata.description, algorithmId); 92 | } 93 | }); 94 | 95 | function onAlgorithmChanged(algorithmId) { 96 | view.setAlgorithm(model.algorithm = algorithmId); 97 | } 98 | onAlgorithmChanged(config.shapes[shape].defaultAlgorithm); 99 | 100 | view.on(EVENT_ALGORITHM_SELECTED, onAlgorithmChanged); 101 | } 102 | 103 | function setupAlgorithmDelay() { 104 | view.addAlgorithmDelay('Instant Mazes', 0); 105 | view.addAlgorithmDelay('Show Algorithm Steps', 5000); 106 | 107 | view.on(EVENT_DELAY_SELECTED, algorithmDelay => { 108 | model.algorithmDelay = algorithmDelay; 109 | view.setAlgorithmDelay(algorithmDelay); 110 | }); 111 | view.setAlgorithmDelay(model.algorithmDelay); 112 | } 113 | 114 | function setupExitConfigs() { 115 | view.addExitConfiguration('No Entrance/Exit', EXITS_NONE); 116 | view.addExitConfiguration('Bottom to Top', EXITS_VERTICAL); 117 | view.addExitConfiguration('Left to Right', EXITS_HORIZONTAL); 118 | view.addExitConfiguration('Hardest Entrance/Exit', EXITS_HARDEST); 119 | 120 | view.on(EVENT_EXITS_SELECTED, exitConfig => { 121 | view.setExitConfiguration(model.exitConfig = exitConfig); 122 | }); 123 | view.setExitConfiguration(model.exitConfig); 124 | } 125 | 126 | setupShapeParameter(); 127 | setupSizeParameters(); 128 | setupExitConfigs(); 129 | setupAlgorithmDelay(); 130 | setupAlgorithms(); 131 | showEmptyGrid(true); 132 | 133 | function buildMazeUsingModel(overrides={}) { 134 | if (model.maze) { 135 | model.maze.dispose(); 136 | } 137 | 138 | const grid = Object.assign({'cellShape': model.shape}, model.size), 139 | maze = buildMaze({ 140 | grid, 141 | 'algorithm': overrides.algorithm || model.algorithm, 142 | 'randomSeed' : model.randomSeed, 143 | 'element': overrides.element || document.getElementById('maze'), 144 | 'mask': overrides.mask || model.mask[getModelMaskKey()], 145 | 'exitConfig': overrides.exitConfig || model.exitConfig 146 | }); 147 | 148 | model.maze = maze; 149 | 150 | maze.on(EVENT_CLICK, ifStateIs(STATE_DISTANCE_MAPPING).then(event => { 151 | maze.findDistancesFrom(...event.coords); 152 | maze.render(); 153 | })); 154 | 155 | maze.on(EVENT_CLICK, ifStateIs(STATE_MASKING).then(event => { 156 | const cell = maze.getCellByCoordinates(event.coords); 157 | cell.metadata[METADATA_MASKED] = !cell.metadata[METADATA_MASKED]; 158 | maze.render(); 159 | })); 160 | 161 | maze.on(EVENT_CLICK, ifStateIs(STATE_PLAYING).then(event => { 162 | const currentCell = model.playState.currentCell, 163 | direction = maze.getClosestDirectionForClick(currentCell, event); 164 | navigate(direction, event.shift || view.isMobileLayout, event.alt || view.isMobileLayout); 165 | maze.render(); 166 | })); 167 | 168 | const algorithmDelay = overrides.algorithmDelay !== undefined ? overrides.algorithmDelay : model.algorithmDelay, 169 | runAlgorithm = maze.runAlgorithm; 170 | if (algorithmDelay) { 171 | model.runningAlgorithm = {run: runAlgorithm}; 172 | return new Promise(resolve => { 173 | stateMachine.runningAlgorithm(); 174 | model.runningAlgorithm.interval = setInterval(() => { 175 | const done = runAlgorithm.oneStep(); 176 | maze.render(); 177 | if (done) { 178 | clearInterval(model.runningAlgorithm.interval); 179 | delete model.runningAlgorithm; 180 | stateMachine.displaying(); 181 | resolve(); 182 | } 183 | }, algorithmDelay/maze.cellCount); 184 | }); 185 | 186 | } else { 187 | runAlgorithm.toCompletion(); 188 | maze.render(); 189 | return Promise.resolve(); 190 | } 191 | 192 | } 193 | 194 | function showEmptyGrid(deleteMaskedCells) { 195 | buildMazeUsingModel({algorithmDelay: 0, exitConfig: EXITS_NONE, algorithm: ALGORITHM_NONE, mask: deleteMaskedCells ? model.mask[getModelMaskKey()] : []}) 196 | .then(() => model.maze.render()); 197 | } 198 | 199 | function ifStateIs(...states) { 200 | return { 201 | then(handler) { 202 | return event => { 203 | if (states.includes(stateMachine.state)) { 204 | handler(event); 205 | } 206 | }; 207 | } 208 | } 209 | } 210 | 211 | view.on(EVENT_GO_BUTTON_CLICKED, () => { 212 | model.randomSeed = Number(view.getSeed() || buildRandom().int(Math.pow(10,9))); 213 | view.showSeedValue(); 214 | 215 | const errors = view.inputErrorMessage(); 216 | if (errors) { 217 | alert(errors); 218 | } else { 219 | buildMazeUsingModel().then(() => { 220 | view.toggleSolveButtonCaption(true); 221 | model.maze.render(); 222 | stateMachine.displaying(); 223 | }); 224 | } 225 | }); 226 | view.on(EVENT_SHOW_MAP_BUTTON_CLICKED, () => { 227 | stateMachine.distanceMapping(); 228 | const [startCell, _1] = findStartAndEndCells(), 229 | coords = (startCell || model.maze.randomCell()).coords; 230 | model.maze.findDistancesFrom(...coords); 231 | model.maze.render(); 232 | }); 233 | view.on(EVENT_CLEAR_MAP_BUTTON_CLICKED, () => { 234 | stateMachine.displaying(); 235 | model.maze.clearDistances(); 236 | model.maze.render(); 237 | }); 238 | 239 | view.on(EVENT_FINISH_RUNNING_BUTTON_CLICKED, () => { 240 | clearInterval(model.runningAlgorithm.interval); 241 | model.runningAlgorithm.run.toCompletion(); 242 | delete model.runningAlgorithm; 243 | stateMachine.displaying(); 244 | model.maze.render(); 245 | }); 246 | 247 | stateMachine.onStateChange(newState => { 248 | view.updateForNewState(newState); 249 | }); 250 | view.updateForNewState(stateMachine.state); 251 | 252 | function getModelMaskKey() { 253 | if (model.shape && model.size) { 254 | return `${model.shape}-${Object.values(model.size).join('-')}`; 255 | } 256 | } 257 | 258 | view.on(EVENT_CREATE_MASK_BUTTON_CLICKED, () => { 259 | stateMachine.masking(); 260 | showEmptyGrid(false); 261 | (model.mask[getModelMaskKey()] || []).forEach(maskedCoords => { 262 | const cell = model.maze.getCellByCoordinates(maskedCoords); 263 | cell.metadata[METADATA_MASKED] = true; 264 | }); 265 | model.maze.render(); 266 | }); 267 | 268 | function validateMask() { 269 | const isNotMasked = cell => !cell.metadata[METADATA_MASKED], 270 | startCell = model.maze.randomCell(isNotMasked); 271 | let unmaskedCellCount = 0; 272 | 273 | model.maze.forEachCell(cell => { 274 | if (isNotMasked(cell)) { 275 | unmaskedCellCount++; 276 | } 277 | }); 278 | if (!startCell) { 279 | throw 'No unmasked cells remain'; 280 | } 281 | if (unmaskedCellCount < 4) { 282 | throw 'Not enough unmasked cells to build a maze'; 283 | } 284 | 285 | function countUnmasked(cell) { 286 | cell.metadata[METADATA_VISITED] = true; 287 | let count = 1; 288 | cell.neighbours.toArray(isNotMasked).forEach(neighbourCell => { 289 | if (!neighbourCell.metadata[METADATA_VISITED]) { 290 | count += countUnmasked(neighbourCell); 291 | } 292 | }); 293 | return count; 294 | } 295 | 296 | model.maze.forEachCell(cell => { 297 | delete cell.metadata[METADATA_VISITED]; 298 | }); 299 | 300 | if (unmaskedCellCount !== countUnmasked(startCell)) { 301 | throw 'Your mask has cut off one or more cells so they are not reachable from the rest of the maze.'; 302 | } 303 | 304 | if (model.shape === SHAPE_CIRCLE && model.maze.getCellByCoordinates(0,0).metadata[METADATA_MASKED]) { 305 | throw 'You can\'t mask out the centre of a circular maze'; 306 | } 307 | } 308 | 309 | view.on(EVENT_SAVE_MASK_BUTTON_CLICKED, () => { 310 | try { 311 | validateMask(); 312 | stateMachine.init(); 313 | const mask = model.mask[getModelMaskKey()] = []; 314 | model.maze.forEachCell(cell => { 315 | if (cell.metadata[METADATA_MASKED]) { 316 | mask.push(cell.coords); 317 | } 318 | }); 319 | showEmptyGrid(true); 320 | setupAlgorithms(); 321 | view.updateMaskButtonCaption(isMaskAvailableForCurrentConfig()); 322 | } catch (err) { 323 | alert(err); 324 | } 325 | }); 326 | 327 | view.on(EVENT_CLEAR_MASK_BUTTON_CLICKED, () => { 328 | model.maze.forEachCell(cell => { 329 | delete cell.metadata[METADATA_MASKED]; 330 | }); 331 | model.maze.render(); 332 | }); 333 | 334 | view.on(EVENT_WINDOW_RESIZED, () => { 335 | model.maze.render(); 336 | }); 337 | 338 | view.on(EVENT_CHANGE_PARAMS_BUTTON_CLICKED, () => { 339 | showEmptyGrid(true); 340 | stateMachine.init(); 341 | }); 342 | 343 | function findStartAndEndCells() { 344 | let startCell, endCell; 345 | model.maze.forEachCell(cell => { 346 | if (cell.metadata[METADATA_START_CELL]) { 347 | startCell = cell; 348 | } 349 | if (cell.metadata[METADATA_END_CELL]) { 350 | endCell = cell; 351 | } 352 | }); 353 | return [startCell, endCell]; 354 | } 355 | view.on(EVENT_SOLVE_BUTTON_CLICKED, () => { 356 | const [startCell, endCell] = findStartAndEndCells(); 357 | if (!(startCell && endCell)) { 358 | alert('You must generate a maze with exits in order to solve'); 359 | return; 360 | } 361 | if (model.maze.metadata[METADATA_PATH]) { 362 | model.maze.clearPathAndSolution(); 363 | view.toggleSolveButtonCaption(true); 364 | } else { 365 | const [startCell, endCell] = findStartAndEndCells(); 366 | console.assert(startCell); 367 | console.assert(endCell); 368 | model.maze.findPathBetween(startCell.coords, endCell.coords); 369 | view.toggleSolveButtonCaption(false); 370 | } 371 | model.maze.render(); 372 | }); 373 | 374 | function getNavigationInstructions() { 375 | const isMobile = view.isMobileLayout, 376 | MOBILE_INSTRUCTIONS = 'Tap to move through the maze to the next junction', 377 | MOUSE_INSTRUCTIONS = 'Click to move through the maze', 378 | ALT_SHIFT_INSTRUCTIONS = 'Holding down SHIFT will move you as far as possible in one direction

Holding down ALT and SHIFT will move you to the next junction'; 379 | 380 | if (isMobile) { 381 | return MOBILE_INSTRUCTIONS; 382 | } 383 | 384 | return { 385 | [SHAPE_SQUARE]: `${MOUSE_INSTRUCTIONS} or use the arrow keys

${ALT_SHIFT_INSTRUCTIONS}`, 386 | [SHAPE_TRIANGLE]: `${MOUSE_INSTRUCTIONS} or use the arrow keys

${ALT_SHIFT_INSTRUCTIONS}`, 387 | [SHAPE_HEXAGON]: `${MOUSE_INSTRUCTIONS}

${ALT_SHIFT_INSTRUCTIONS}`, 388 | [SHAPE_CIRCLE]: `${MOUSE_INSTRUCTIONS}

${ALT_SHIFT_INSTRUCTIONS}` 389 | }[model.shape]; 390 | } 391 | 392 | view.on(EVENT_PLAY_BUTTON_CLICKED, () => { 393 | const [startCell, endCell] = findStartAndEndCells(); 394 | if (!(startCell && endCell)) { 395 | alert('You must generate a maze with exits in order to play'); 396 | return; 397 | } 398 | model.maze.clearPathAndSolution(); 399 | model.playState = {startCell, endCell, currentCell: startCell, startTime: Date.now()}; 400 | startCell.metadata[METADATA_PLAYER_CURRENT] = true; 401 | startCell.metadata[METADATA_PLAYER_VISITED] = true; 402 | model.maze.render(); 403 | stateMachine.playing(); 404 | view.setNavigationInstructions(getNavigationInstructions()); 405 | }); 406 | 407 | view.on(EVENT_STOP_BUTTON_CLICKED, () => { 408 | model.maze.clearMetadata(METADATA_PLAYER_CURRENT, METADATA_PLAYER_VISITED); 409 | model.maze.render(); 410 | stateMachine.displaying(); 411 | }); 412 | 413 | const keyCodeToDirection = { 414 | 38: DIRECTION_NORTH, 415 | 40: DIRECTION_SOUTH, 416 | 39: DIRECTION_EAST, 417 | 37: DIRECTION_WEST, 418 | 65: DIRECTION_NORTH_WEST, // A 419 | 83: DIRECTION_NORTH_EAST, // S 420 | 90: DIRECTION_SOUTH_WEST, // Z 421 | 88: DIRECTION_SOUTH_EAST, // X 422 | 81: DIRECTION_CLOCKWISE, // Q 423 | 87: DIRECTION_ANTICLOCKWISE, // W 424 | 80: DIRECTION_INWARDS, // P 425 | 76: `${DIRECTION_OUTWARDS}_1`, // L 426 | 186: `${DIRECTION_OUTWARDS}_0` // ; 427 | }; 428 | 429 | function padNum(num) { 430 | return num < 10 ? '0' + num : num; 431 | } 432 | function formatTime(millis) { 433 | const hours = Math.floor(millis / (1000 * 60 * 60)), 434 | minutes = Math.floor((millis % (1000 * 60 * 60)) / (1000 * 60)), 435 | seconds = Math.floor((millis % (1000 * 60)) / 1000); 436 | 437 | return `${padNum(hours)}:${padNum(minutes)}:${padNum(seconds)}`; 438 | } 439 | 440 | function onMazeCompleted() { 441 | const timeMs = Date.now() - model.playState.startTime, 442 | time = formatTime(timeMs), 443 | {startCell, endCell} = model.playState; 444 | 445 | model.playState.finished = true; 446 | 447 | model.maze.findPathBetween(startCell.coords, endCell.coords); 448 | const optimalPathLength = model.maze.metadata[METADATA_PATH].length; 449 | delete model.maze.metadata[METADATA_PATH]; 450 | 451 | let visitedCells = 0; 452 | model.maze.forEachCell(cell => { 453 | if (cell.metadata[METADATA_PLAYER_VISITED]) { 454 | visitedCells++; 455 | } 456 | }); 457 | 458 | const cellsPerSecond = visitedCells / (timeMs / 1000); 459 | model.maze.render(); 460 | stateMachine.displaying(); 461 | view.showInfo(` 462 | Finish Time: ${time}
463 | Visited Cells: ${visitedCells}
464 | Optimal Route: ${optimalPathLength}

465 | Optimality: ${Math.floor(100 * optimalPathLength / visitedCells)}%
466 | Cells per Second: ${Math.round(cellsPerSecond)} 467 | `); 468 | } 469 | 470 | function navigate(direction, shift, alt) { 471 | while (true) { 472 | const currentCell = model.playState.currentCell, 473 | targetCell = currentCell.neighbours[direction], 474 | moveOk = targetCell && targetCell.isLinkedTo(currentCell); 475 | 476 | if (moveOk) { 477 | delete currentCell.metadata[METADATA_PLAYER_CURRENT]; 478 | targetCell.metadata[METADATA_PLAYER_VISITED] = true; 479 | targetCell.metadata[METADATA_PLAYER_CURRENT] = true; 480 | model.playState.previousCell = currentCell; 481 | model.playState.currentCell = targetCell; 482 | 483 | if (targetCell.metadata[METADATA_END_CELL]) { 484 | onMazeCompleted(); 485 | } 486 | 487 | if (model.playState.finished) { 488 | break; 489 | } else if (!shift) { 490 | break; 491 | } else if (alt) { 492 | const linkedDirections = targetCell.neighbours.linkedDirections(); 493 | if (linkedDirections.length === 2) { 494 | direction = linkedDirections.find(neighbourDirection => targetCell.neighbours[neighbourDirection] !== model.playState.previousCell); 495 | } else { 496 | break; 497 | } 498 | } 499 | 500 | } else { 501 | break; 502 | } 503 | } 504 | } 505 | 506 | view.on(EVENT_KEY_PRESS, ifStateIs(STATE_PLAYING).then(event => { 507 | const {keyCode, shift, alt} = event, 508 | direction = keyCodeToDirection[keyCode]; 509 | 510 | navigate(direction, shift, alt); 511 | 512 | model.maze.render(); 513 | })); 514 | 515 | view.on(EVENT_DOWNLOAD_CLICKED, () => { 516 | function saveSvg(svgEl, name) { 517 | const svgData = svgEl.outerHTML, 518 | prolog = '', 519 | blob = new Blob([prolog, svgData], {type: 'image/svg+xml;charset=utf-8'}), 520 | blobAsUrl = URL.createObjectURL(blob), 521 | downloadLink = document.createElement('a'); 522 | downloadLink.href = blobAsUrl; 523 | downloadLink.download = name; 524 | downloadLink.click(); 525 | } 526 | 527 | const SVG_SIZE = 500, 528 | elSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 529 | elSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); 530 | elSvg.setAttribute('width', SVG_SIZE); 531 | elSvg.setAttribute('height', SVG_SIZE); 532 | 533 | const svgDrawingSurface = drawingSurfaces.svg({el: elSvg}), 534 | fileName = `maze_${model.shape}_${Object.values(model.size).join('_')}_${model.randomSeed}.svg`; 535 | model.maze.render(svgDrawingSurface); 536 | saveSvg(elSvg, fileName); 537 | }); 538 | }; -------------------------------------------------------------------------------- /js/model.js: -------------------------------------------------------------------------------- 1 | export function buildModel() { 2 | const model = { 3 | shape: 'square', 4 | mask: {}, 5 | algorithmDelay: 0, 6 | exitConfig: 'vertical' 7 | }; 8 | 9 | return model; 10 | } -------------------------------------------------------------------------------- /js/stateMachine.js: -------------------------------------------------------------------------------- 1 | import {buildEventTarget} from './lib/utils.js'; 2 | 3 | export const STATE_INIT = 'Init', 4 | STATE_MASKING = 'Masking', 5 | STATE_DISPLAYING = 'Displaying', 6 | STATE_DISTANCE_MAPPING = 'Distance Mapping', 7 | STATE_RUNNING_ALGORITHM = 'Running Algorithm', 8 | STATE_PLAYING = 'Playing'; 9 | 10 | export function buildStateMachine() { 11 | "use strict"; 12 | const eventTarget = buildEventTarget('stateMachine'), 13 | EVENT_STATE_CHANGED = 'stateChanged'; 14 | let state = STATE_INIT; 15 | 16 | function ifStateIsOneOf(...validStates) { 17 | return { 18 | thenChangeTo(newState) { 19 | if (validStates.includes(state)) { 20 | console.debug('State changed to', newState); 21 | state = newState; 22 | eventTarget.trigger(EVENT_STATE_CHANGED, newState); 23 | 24 | } else if (state === newState) { 25 | console.debug('Ignoring redundant state transition', state); 26 | 27 | } else { 28 | console.warn(`Unexpected state transition requested: ${state} -> ${newState}`); 29 | } 30 | } 31 | } 32 | } 33 | 34 | return { 35 | get state() { 36 | return state; 37 | }, 38 | init() { 39 | ifStateIsOneOf(STATE_DISPLAYING, STATE_MASKING, STATE_DISTANCE_MAPPING) 40 | .thenChangeTo(STATE_INIT); 41 | }, 42 | masking() { 43 | ifStateIsOneOf(STATE_INIT, STATE_DISPLAYING) 44 | .thenChangeTo(STATE_MASKING); 45 | }, 46 | displaying() { 47 | ifStateIsOneOf(STATE_INIT, STATE_MASKING, STATE_PLAYING, STATE_DISTANCE_MAPPING, STATE_RUNNING_ALGORITHM) 48 | .thenChangeTo(STATE_DISPLAYING); 49 | }, 50 | distanceMapping() { 51 | ifStateIsOneOf(STATE_DISPLAYING) 52 | .thenChangeTo(STATE_DISTANCE_MAPPING); 53 | }, 54 | playing() { 55 | ifStateIsOneOf(STATE_DISPLAYING) 56 | .thenChangeTo(STATE_PLAYING); 57 | }, 58 | runningAlgorithm() { 59 | ifStateIsOneOf(STATE_INIT, STATE_DISPLAYING) 60 | .thenChangeTo(STATE_RUNNING_ALGORITHM); 61 | }, 62 | onStateChange(handler) { 63 | eventTarget.on(EVENT_STATE_CHANGED, handler); 64 | } 65 | }; 66 | 67 | } -------------------------------------------------------------------------------- /js/view.js: -------------------------------------------------------------------------------- 1 | import {buildEventTarget} from './lib/utils.js'; 2 | 3 | export const 4 | EVENT_MAZE_SHAPE_SELECTED = 'mazeShapeSelected', 5 | EVENT_SIZE_PARAMETER_CHANGED = 'mazeSizeParameterChanged', 6 | EVENT_DELAY_SELECTED = 'runModeSelected', 7 | EVENT_ALGORITHM_SELECTED = 'algorithmSelected', 8 | EVENT_GO_BUTTON_CLICKED = 'goButtonClicked', 9 | EVENT_SHOW_MAP_BUTTON_CLICKED = 'showDistanceMapButtonClicked', 10 | EVENT_CLEAR_MAP_BUTTON_CLICKED = 'clearDistanceMapButtonClicked', 11 | EVENT_CREATE_MASK_BUTTON_CLICKED = 'createMaskButtonClicked', 12 | EVENT_SAVE_MASK_BUTTON_CLICKED = 'saveMaskButtonClicked', 13 | EVENT_CLEAR_MASK_BUTTON_CLICKED = 'clearMaskButtonClicked', 14 | EVENT_FINISH_RUNNING_BUTTON_CLICKED = 'finishRunningButtonClicked', 15 | EVENT_CHANGE_PARAMS_BUTTON_CLICKED = 'changeParamsButtonClicked', 16 | EVENT_SOLVE_BUTTON_CLICKED = 'solveButtonClicked', 17 | EVENT_PLAY_BUTTON_CLICKED = 'playButtonClicked', 18 | EVENT_STOP_BUTTON_CLICKED = 'stopButtonClicked', 19 | EVENT_DOWNLOAD_CLICKED = 'downloadClicked', 20 | EVENT_KEY_PRESS = 'keyPress', 21 | EVENT_WINDOW_RESIZED = 'windowResized', 22 | EVENT_EXITS_SELECTED = 'exitsSelected'; 23 | 24 | 25 | import {STATE_INIT, STATE_DISPLAYING, STATE_PLAYING, STATE_MASKING, STATE_DISTANCE_MAPPING, STATE_RUNNING_ALGORITHM} from './stateMachine.js'; 26 | 27 | export function buildView(model, stateMachine) { 28 | "use strict"; 29 | 30 | const eventTarget = buildEventTarget('view'), 31 | elCanvas = document.getElementById('maze'), 32 | elMazeContainer = document.getElementById('mazeContainer'), 33 | elGoButton = document.getElementById('go'), 34 | elShowDistanceMapButton = document.getElementById('showDistanceMap'), 35 | elClearDistanceMapButton = document.getElementById('clearDistanceMap'), 36 | elCreateMaskButton = document.getElementById('createMask'), 37 | elSaveMaskButton = document.getElementById('saveMask'), 38 | elClearMaskButton = document.getElementById('clearMask'), 39 | elFinishRunningButton = document.getElementById('finishRunning'), 40 | elSolveButton = document.getElementById('solve'), 41 | elPlayButton = document.getElementById('play'), 42 | elStopButton = document.getElementById('stop'), 43 | elChangeParamsButton = document.getElementById('changeParams'), 44 | elDownloadButton = document.getElementById('download'), 45 | elInfo = document.getElementById('info'), 46 | elSeedInput = document.getElementById('seedInput'), 47 | elSizeParameterList = document.getElementById('sizeParameters'), 48 | elSeedParameterList = document.getElementById('seedParameters'), 49 | elMazeShapeList = document.getElementById('shapeSelector'), 50 | elMazeAlgorithmList = document.getElementById('algorithmSelector'), 51 | elAlgorithmDelayList = document.getElementById('delaySelector'), 52 | elExitsList = document.getElementById('exitSelector'), 53 | elMobileTitle = document.getElementById('mobileTitle'), 54 | 55 | isMobileLayout = !! elMobileTitle.offsetParent; 56 | 57 | 58 | elGoButton.onclick = () => eventTarget.trigger(EVENT_GO_BUTTON_CLICKED); 59 | elShowDistanceMapButton.onclick = () => eventTarget.trigger(EVENT_SHOW_MAP_BUTTON_CLICKED); 60 | elClearDistanceMapButton.onclick = () => eventTarget.trigger(EVENT_CLEAR_MAP_BUTTON_CLICKED); 61 | elCreateMaskButton.onclick = () => eventTarget.trigger(EVENT_CREATE_MASK_BUTTON_CLICKED); 62 | elSaveMaskButton.onclick = () => eventTarget.trigger(EVENT_SAVE_MASK_BUTTON_CLICKED); 63 | elClearMaskButton.onclick = () => eventTarget.trigger(EVENT_CLEAR_MASK_BUTTON_CLICKED); 64 | elFinishRunningButton.onclick = () => eventTarget.trigger(EVENT_FINISH_RUNNING_BUTTON_CLICKED); 65 | elChangeParamsButton.onclick = () => eventTarget.trigger(EVENT_CHANGE_PARAMS_BUTTON_CLICKED); 66 | elSolveButton.onclick = () => eventTarget.trigger(EVENT_SOLVE_BUTTON_CLICKED); 67 | elPlayButton.onclick = () => eventTarget.trigger(EVENT_PLAY_BUTTON_CLICKED); 68 | elStopButton.onclick = () => eventTarget.trigger(EVENT_STOP_BUTTON_CLICKED); 69 | elDownloadButton.onclick = () => eventTarget.trigger(EVENT_DOWNLOAD_CLICKED); 70 | 71 | window.onkeydown = event => eventTarget.trigger(EVENT_KEY_PRESS, {keyCode: event.keyCode, alt: event.altKey, shift: event.shiftKey}); 72 | 73 | function fitCanvasToContainer() { 74 | if (isMobileLayout) { 75 | elMazeContainer.style.height = `${elMazeContainer.clientWidth}px`; 76 | } 77 | 78 | elCanvas.width = elMazeContainer.clientWidth; 79 | elCanvas.height = elMazeContainer.clientHeight; 80 | } 81 | window.onresize = () => { 82 | fitCanvasToContainer(); 83 | eventTarget.trigger(EVENT_WINDOW_RESIZED); 84 | }; 85 | fitCanvasToContainer(); 86 | 87 | function toggleElementVisibility(el, display) { 88 | el.style.display = display ? 'block' : 'none'; 89 | } 90 | 91 | return { 92 | // Shape 93 | addShape(shapeName) { 94 | const elMazeShapeItem = document.createElement('li'); 95 | elMazeShapeItem.innerHTML = shapeName; 96 | elMazeShapeItem.onclick = () => eventTarget.trigger(EVENT_MAZE_SHAPE_SELECTED, shapeName); 97 | elMazeShapeList.appendChild(elMazeShapeItem); 98 | elMazeShapeItem.dataset.value = shapeName; 99 | }, 100 | setShape(shapeName) { 101 | [...elMazeShapeList.querySelectorAll('li')].forEach(el => { 102 | el.classList.toggle('selected', el.dataset.value === shapeName); 103 | }); 104 | }, 105 | 106 | // Size 107 | clearSizeParameters() { 108 | elSizeParameterList.innerHTML = ''; 109 | }, 110 | addSizeParameter(name, minimumValue, maximumValue) { 111 | const elParameterItem = document.createElement('li'), 112 | elParameterName = document.createElement('label'), 113 | elParameterValue = document.createElement('input'); 114 | 115 | elParameterName.innerHTML = name; 116 | 117 | elParameterValue.setAttribute('type', 'number'); 118 | elParameterValue.setAttribute('required', 'required'); 119 | elParameterValue.setAttribute('min', minimumValue); 120 | elParameterValue.setAttribute('max', maximumValue); 121 | elParameterValue.oninput = () => eventTarget.trigger(EVENT_SIZE_PARAMETER_CHANGED, { 122 | name, 123 | value: Number(elParameterValue.value) 124 | }); 125 | elParameterValue.dataset.value = name; 126 | 127 | elParameterItem.appendChild(elParameterName); 128 | elParameterItem.appendChild(elParameterValue); 129 | elSizeParameterList.appendChild(elParameterItem); 130 | }, 131 | setSizeParameter(name, value) { 132 | const elParamInput = [...elSizeParameterList.querySelectorAll('input')].find(el => el.dataset.value === name); 133 | elParamInput.value = value; 134 | }, 135 | 136 | // Exits 137 | addExitConfiguration(description, value) { 138 | const elExitsItem = document.createElement('li'); 139 | elExitsItem.innerHTML = description; 140 | elExitsItem.onclick = () => eventTarget.trigger(EVENT_EXITS_SELECTED, value); 141 | elExitsList.appendChild(elExitsItem); 142 | elExitsItem.dataset.value = value; 143 | }, 144 | setExitConfiguration(exitConfiguration) { 145 | [...elExitsList.querySelectorAll('li')].forEach(el => { 146 | el.classList.toggle('selected', el.dataset.value === exitConfiguration); 147 | }); 148 | }, 149 | 150 | // Algorithm Delay 151 | addAlgorithmDelay(description, value) { 152 | const elDelayItem = document.createElement('li'); 153 | elDelayItem.innerHTML = description; 154 | elDelayItem.onclick = () => eventTarget.trigger(EVENT_DELAY_SELECTED, value); 155 | elAlgorithmDelayList.appendChild(elDelayItem); 156 | elDelayItem.dataset.value = value; 157 | }, 158 | setAlgorithmDelay(algorithmDelay) { 159 | [...elAlgorithmDelayList.querySelectorAll('li')].forEach(el => { 160 | el.classList.toggle('selected', Number(el.dataset.value) === algorithmDelay); 161 | }); 162 | }, 163 | 164 | // Algorithm 165 | clearAlgorithms() { 166 | elMazeAlgorithmList.innerHTML = ''; 167 | }, 168 | addAlgorithm(description, algorithmId) { 169 | const elAlgorithmItem = document.createElement('li'); 170 | elAlgorithmItem.innerHTML = description; 171 | elAlgorithmItem.onclick = () => eventTarget.trigger(EVENT_ALGORITHM_SELECTED, algorithmId); 172 | elMazeAlgorithmList.appendChild(elAlgorithmItem); 173 | elAlgorithmItem.dataset.value = algorithmId; 174 | }, 175 | setAlgorithm(algorithmId) { 176 | [...elMazeAlgorithmList.querySelectorAll('li')].forEach(el => { 177 | el.classList.toggle('selected', el.dataset.value === algorithmId); 178 | }); 179 | }, 180 | 181 | toggleSolveButtonCaption(solve) { 182 | elSolveButton.innerHTML = solve ? 'Solve' : 'Clear Solution'; 183 | }, 184 | 185 | getSeed() { 186 | return elSeedInput.value; 187 | }, 188 | 189 | getValidSizeParameters() { 190 | return [...elSizeParameterList.querySelectorAll('input')].filter(elInput => elInput.checkValidity()).map(el => el.dataset.value); 191 | }, 192 | 193 | inputErrorMessage() { 194 | const errors = []; 195 | 196 | [...elSizeParameterList.querySelectorAll('input')].forEach(elInput => { 197 | if (!elInput.checkValidity()) { 198 | errors.push(`Enter a number between ${elInput.min} and ${elInput.max} for ${elInput.dataset.value}`); 199 | } 200 | }); 201 | 202 | if (!elSeedInput.checkValidity()) { 203 | errors.push('Enter between 1 and 9 digits for the Seed'); 204 | } 205 | 206 | return errors.join('\n'); 207 | }, 208 | isMobileLayout, 209 | 210 | updateForNewState(state) { 211 | toggleElementVisibility(elMazeShapeList, [STATE_INIT].includes(state)); 212 | toggleElementVisibility(elMazeAlgorithmList, [STATE_INIT].includes(state)); 213 | toggleElementVisibility(elSizeParameterList, [STATE_INIT].includes(state)); 214 | toggleElementVisibility(elSeedParameterList, [STATE_INIT].includes(state)); 215 | toggleElementVisibility(elExitsList, [STATE_INIT].includes(state)); 216 | toggleElementVisibility(elAlgorithmDelayList, [STATE_INIT].includes(state)); 217 | toggleElementVisibility(elCreateMaskButton, [STATE_INIT].includes(state)); 218 | 219 | toggleElementVisibility(elGoButton, [STATE_INIT, STATE_DISPLAYING].includes(state)); 220 | toggleElementVisibility(elDownloadButton, [STATE_DISPLAYING, STATE_DISTANCE_MAPPING].includes(state)); 221 | 222 | toggleElementVisibility(elChangeParamsButton, [STATE_DISPLAYING].includes(state)); 223 | toggleElementVisibility(elShowDistanceMapButton, [STATE_DISPLAYING].includes(state)); 224 | toggleElementVisibility(elSolveButton, [STATE_DISPLAYING].includes(state)); 225 | toggleElementVisibility(elPlayButton, [STATE_DISPLAYING].includes(state)); 226 | toggleElementVisibility(elStopButton, [STATE_PLAYING].includes(state)); 227 | 228 | toggleElementVisibility(elClearDistanceMapButton, [STATE_DISTANCE_MAPPING].includes(state)); 229 | 230 | toggleElementVisibility(elSaveMaskButton, [STATE_MASKING].includes(state)); 231 | toggleElementVisibility(elClearMaskButton, [STATE_MASKING].includes(state)); 232 | toggleElementVisibility(elFinishRunningButton, [STATE_RUNNING_ALGORITHM].includes(state)); 233 | 234 | switch(state) { 235 | case STATE_INIT: 236 | this.showInfo('Select parameters for your maze and then click New Maze'); 237 | break; 238 | case STATE_DISPLAYING: 239 | this.showSeedValue(); 240 | this.toggleSolveButtonCaption(true); 241 | break; 242 | case STATE_DISTANCE_MAPPING: 243 | this.showInfo('Click somewhere in the maze to generate a distance map for that location.

Cells are coloured according to how difficult they are to reach from your chosen point.'); 244 | break; 245 | case STATE_PLAYING: 246 | this.showInfo(''); 247 | break; 248 | case STATE_RUNNING_ALGORITHM: 249 | this.showInfo('The maze generation algorithm has been slowed down.

Click FINISH to skip to the end.'); 250 | break; 251 | case STATE_MASKING: 252 | this.showInfo('Define a mask by selecting cells from the grid.

Masked cells will not be included in your maze'); 253 | break; 254 | default: 255 | console.assert(false, 'unexpected state value: ' + state); 256 | } 257 | }, 258 | 259 | updateMaskButtonCaption(maskAvailable) { 260 | elCreateMaskButton.innerHTML = maskAvailable ? 'Edit Mask' : 'Create Mask'; 261 | }, 262 | 263 | showSeedValue() { 264 | this.showInfo(`Seed Value:
${model.randomSeed}`); 265 | }, 266 | showInfo(msg) { 267 | toggleElementVisibility(elInfo, msg); 268 | elInfo.innerHTML = msg; 269 | }, 270 | setNavigationInstructions(instructions) { 271 | this.showInfo(instructions); 272 | }, 273 | 274 | on(eventName, handler) { 275 | eventTarget.on(eventName, handler); 276 | } 277 | }; 278 | } -------------------------------------------------------------------------------- /notes.txt: -------------------------------------------------------------------------------- 1 | TODO Later 2 | ---------- 3 | Add arrows to solution path 4 | Split maze types into separate files 5 | Show maze stats/details 6 | Mask shift key selection 7 | --------------------------------------------------------------------------------