├── .eslintrc.js
├── .gitignore
├── LICENSE
├── README.md
├── babel.config.json
├── docs
└── .nojekyll
├── examples
├── javascript
│ ├── index.html
│ └── main.js
└── typescript
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ ├── index.html
│ └── index.ts
│ ├── tsconfig.json
│ └── webpack.config.js
├── images
├── ascii-screenshot.png
└── emoji-dungeon-optimized.gif
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── create-2d-array.ts
├── debug.ts
├── dungeon.test.ts
├── dungeon.ts
├── index.ts
├── math.test.ts
├── math.ts
├── point.ts
├── random.test.ts
├── random.ts
├── room.ts
└── tiles.ts
├── tsconfig.json
├── typedoc.json
└── webpack.config.js
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ["eslint:recommended", "prettier"],
3 | plugins: ["prettier"],
4 | rules: {
5 | "no-var": 1,
6 | "brace-style": ["warn", "1tbs"],
7 | "no-unused-vars": ["error", { args: "after-used" }],
8 | indent: ["warn", 2, { SwitchCase: 1 }],
9 | "max-len": ["warn", 100, { ignoreUrls: true, ignoreTemplateLiterals: true }],
10 | "no-console": "off"
11 | },
12 | env: {
13 | browser: true,
14 | commonjs: true,
15 | es6: true
16 | },
17 | parserOptions: {
18 | ecmaVersion: 6,
19 | sourceType: "module",
20 | ecmaFeatures: {
21 | modules: true
22 | }
23 | },
24 | overrides: [
25 | {
26 | files: ["**/*test.js"],
27 | env: {
28 | jest: true
29 | }
30 | }
31 | ]
32 | };
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /dist
3 | /docs/**
4 | !/docs/.nojekyll
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013, Nick Gravelyn.
2 |
3 | This software is provided 'as-is', without any express or implied
4 | warranty. In no event will the authors be held liable for any damages
5 | arising from the use of this software.
6 |
7 | Permission is granted to anyone to use this software for any purpose,
8 | including commercial applications, and to alter it and redistribute it
9 | freely, subject to the following restrictions:
10 |
11 | 1. The origin of this software must not be misrepresented; you must not
12 | claim that you wrote the original software. If you use this software
13 | in a product, an acknowledgment in the product documentation would be
14 | appreciated but is not required.
15 |
16 | 2. Altered source versions must be plainly marked as such, and must not be
17 | misrepresented as being the original software.
18 |
19 | 3. This notice may not be removed or altered from any source
20 | distribution.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Dungeon
2 |
3 | 
4 |
5 | 
6 |
7 | ## Installation
8 |
9 | ### As a Script
10 |
11 | Grab the [minified js](https://github.com/mikewesthad/dungeon/releases/latest/download/dungeon.min.js) file & optional source [map](https://github.com/mikewesthad/dungeon/releases/latest/download/dungeon.min.js.map) (or the [unminified js](https://github.com/mikewesthad/dungeon/releases/latest/download/dungeon.js) file & optional source [map](https://github.com/mikewesthad/dungeon/releases/latest/download/dungeon.js.map)).
12 |
13 | ```html
14 |
15 | ```
16 |
17 | Or use the jsdelivr CDN:
18 |
19 | ```html
20 |
21 | ```
22 |
23 | This will give you a `Dungeon` global variable.
24 |
25 | ### As a Module
26 |
27 | ```
28 | npm i @mikewesthad/dungeon
29 | ```
30 |
31 | ```js
32 | import Dungeon from "@mikewesthad/dungeon";
33 | ```
34 |
35 | ## Usage
36 |
37 | See the examples folder for [a JavaScript example](./examples/javascript/) and [a TypeScript example](./examples/typescript/).
38 |
39 | ```js
40 | const dungeon = new Dungeon({
41 | width: 50,
42 | height: 50,
43 | doorPadding: 1, // Experimental, minimum number of tiles between a door and a room corner (>= 1)
44 | randomSeed: "hello", // Leave undefined if you don't want to control the seed
45 | rooms: {
46 | width: {
47 | min: 5,
48 | max: 10,
49 | onlyOdd: true // Or onlyEven: true
50 | },
51 | height: {
52 | min: 8,
53 | max: 20,
54 | onlyOdd: true // Or onlyEven: true
55 | },
56 | maxArea: 150,
57 | maxRooms: 50
58 | }
59 | });
60 |
61 | // Make sure you resize your console (see guide that gets printed out in the console)
62 | dungeon.drawToConsole({
63 | empty: " ",
64 | emptyColor: "rgb(0, 0, 0)",
65 | wall: "#",
66 | wallColor: "rgb(255, 0, 0)",
67 | floor: "0",
68 | floorColor: "rgb(210, 210, 210)",
69 | door: "x",
70 | doorColor: "rgb(0, 0, 255)",
71 | fontSize: "8px"
72 | });
73 |
74 | // Helper method for debugging by dumping the map into an HTML fragment (
)
75 | const html = dungeon.drawToHtml({
76 | empty: " ",
77 | emptyAttributes: { class: "dungeon__empty", style: "color: rgb(0, 0, 0)" },
78 | wall: "#",
79 | wallAttributes: { class: "dungeon__wall", style: "color: rgb(255, 0, 0)" },
80 | floor: "0",
81 | floorAttributes: { class: "dungeon__floor", style: "color: rgb(210, 210, 210)" },
82 | door: "x",
83 | doorAttributes: { class: "dungeon__door", style: "color: rgb(0, 0, 255)" },
84 | containerAttributes: { class: "dungeon", style: "font-size: 15px" }
85 | });
86 | document.body.appendChild(html);
87 |
88 | dungeon.rooms; // Array of Room instances
89 | dungeon.tiles; // 2D array of tile IDs - see Tile.js for types
90 |
91 | // Get a 2D array of tiles where each tile type is remapped to a custom value. Useful if you are
92 | // using this in a tilemap, or if you want to map the tiles to something else, e.g. this is used
93 | // internally to convert a dungeon to an HTML string.
94 | var mappedTiles = dungeon.getMappedTiles({
95 | empty: 0,
96 | floor: 1,
97 | door: 2,
98 | wall: 3
99 | });
100 | ```
101 |
102 | ## Docs
103 |
104 | See the web docs for the API [here](https://mikewesthad.github.com/dungeon/). The most useful page is the [Dungeon class](https://mikewesthad.github.com/dungeon/classes/_dungeon_.dungeon.html)
105 |
106 | ## Changelog
107 |
108 | - 2.0.0
109 | - Rewrite in Typescript. API changes requiring more of the DungeonConfig to be specified in the constructor.
110 |
111 | ## Contributors
112 |
113 | - [@mktcode](https://github.com/mktcode) - fix filename case.
114 | - [@gobah](https://github.com/gobah) - PR for getting the library working on node.
115 | - And, of course, [@nickgravelyn](https://github.com/nickgravelyn/dungeon) who wrote the library that this fork is based on.
116 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "useBuiltIns": "usage",
7 | "targets": "> 0.25%, not dead",
8 | "corejs": 3
9 | }
10 | ]
11 | ]
12 | }
--------------------------------------------------------------------------------
/docs/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikewesthad/dungeon/0e125f46ff33ea40bb18576d747711afcd168c90/docs/.nojekyll
--------------------------------------------------------------------------------
/examples/javascript/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Dungeon Example
8 |
9 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/examples/javascript/main.js:
--------------------------------------------------------------------------------
1 | const dungeon = new Dungeon({
2 | width: 100,
3 | height: 30,
4 | doorPadding: 1, // Experimental, minimum number of tiles between a door and a room corner (>= 1)
5 | randomSeed: "hello", // Leave undefined if you don't want to control the seed
6 | rooms: {
7 | width: {
8 | min: 5,
9 | max: 15,
10 | onlyOdd: true // or onlyEven: true
11 | },
12 | height: {
13 | min: 5,
14 | max: 15,
15 | onlyOdd: true // or onlyEven: true
16 | },
17 | maxArea: 150,
18 | maxRooms: 50
19 | }
20 | });
21 |
22 | // Make sure you resize your console (see guide that gets printed out in the console)
23 | dungeon.drawToConsole({
24 | empty: " ",
25 | emptyColor: "rgb(0, 0, 0)",
26 | wall: "#",
27 | wallColor: "rgb(255, 0, 0)",
28 | floor: "0",
29 | floorColor: "rgb(210, 210, 210)",
30 | door: "x",
31 | doorColor: "rgb(0, 0, 255)",
32 | fontSize: "8px"
33 | });
34 |
35 | // Helper method for debugging by dumping the map into an HTML fragment ()
36 | const html = dungeon.drawToHtml({
37 | empty: " ",
38 | emptyAttributes: { class: "dungeon__empty" },
39 | wall: "#",
40 | wallAttributes: { class: "dungeon__wall", style: "color: #950fe2" },
41 | floor: "x",
42 | floorAttributes: { class: "dungeon__floor", style: "color: #d2e9ef" },
43 | door: "*",
44 | doorAttributes: { class: "dungeon__door", style: "font-weight: bold; color: #f900c3" },
45 | containerAttributes: { class: "dungeon", style: "font-size: 15px" }
46 | });
47 | document.body.appendChild(html);
48 |
49 | dungeon.rooms; // Array of Room instances
50 | dungeon.tiles; // 2D array of tile IDs - see Tile.js for types
51 |
52 | // Get a 2D array of tiles where each tile type is remapped to a custom value. Useful if you are
53 | // using this in a tilemap, or if you want to map the tiles to something else, e.g. this is used
54 | // internally to convert a dungeon to an HTML string.
55 | const mappedTiles = dungeon.getMappedTiles({
56 | empty: 0,
57 | floor: 1,
58 | door: 2,
59 | wall: 3
60 | });
61 |
--------------------------------------------------------------------------------
/examples/typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dunegon-typescript-example",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "webpack-dev-server --mode development --open"
8 | },
9 | "author": "",
10 | "license": "ISC",
11 | "devDependencies": {
12 | "html-webpack-plugin": "^4.3.0",
13 | "ts-loader": "^7.0.5",
14 | "typescript": "^3.9.5",
15 | "webpack": "^4.43.0",
16 | "webpack-cli": "^3.3.11",
17 | "webpack-dev-server": "^3.11.0"
18 | }
19 | }
--------------------------------------------------------------------------------
/examples/typescript/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Dungeon Example
8 |
9 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/examples/typescript/src/index.ts:
--------------------------------------------------------------------------------
1 | import Dungeon from "../../../dist/dungeon";
2 |
3 | const dungeon = new Dungeon({
4 | width: 100,
5 | height: 30,
6 | doorPadding: 1, // Experimental, minimum number of tiles between a door and a room corner (>= 1)
7 | randomSeed: "hello", // Leave undefined if you don't want to control the seed
8 | rooms: {
9 | width: {
10 | min: 5,
11 | max: 15,
12 | onlyOdd: true // or onlyEven: true
13 | },
14 | height: {
15 | min: 5,
16 | max: 15,
17 | onlyOdd: true // or onlyEven: true
18 | },
19 | maxArea: 150,
20 | maxRooms: 50
21 | }
22 | });
23 |
24 | // Make sure you resize your console (see guide that gets printed out in the console)
25 | dungeon.drawToConsole({
26 | empty: " ",
27 | emptyColor: "rgb(0, 0, 0)",
28 | wall: "#",
29 | wallColor: "rgb(255, 0, 0)",
30 | floor: "0",
31 | floorColor: "rgb(210, 210, 210)",
32 | door: "x",
33 | doorColor: "rgb(0, 0, 255)",
34 | fontSize: "8px"
35 | });
36 |
37 | // Helper method for debugging by dumping the map into an HTML fragment ()
38 | const html = dungeon.drawToHtml({
39 | empty: " ",
40 | emptyAttributes: { class: "dungeon__empty" },
41 | wall: "#",
42 | wallAttributes: { class: "dungeon__wall", style: "color: #950fe2" },
43 | floor: "x",
44 | floorAttributes: { class: "dungeon__floor", style: "color: #d2e9ef" },
45 | door: "*",
46 | doorAttributes: { class: "dungeon__door", style: "font-weight: bold; color: #f900c3" },
47 | containerAttributes: { class: "dungeon", style: "font-size: 15px" }
48 | });
49 | document.body.appendChild(html);
50 |
51 | dungeon.rooms; // Array of Room instances
52 | dungeon.tiles; // 2D array of tile IDs - see Tile.js for types
53 |
54 | // Get a 2D array of tiles where each tile type is remapped to a custom value. Useful if you are
55 | // using this in a tilemap, or if you want to map the tiles to something else, e.g. this is used
56 | // internally to convert a dungeon to an HTML string.
57 | const mappedTiles = dungeon.getMappedTiles({
58 | empty: 0,
59 | floor: 1,
60 | door: 2,
61 | wall: 3
62 | });
63 |
--------------------------------------------------------------------------------
/examples/typescript/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "module": "es6",
5 | "sourceMap": true,
6 | "allowSyntheticDefaultImports": true,
7 | "lib": ["dom", "esnext"],
8 | "outDir": "dist/",
9 | "moduleResolution": "node"
10 | },
11 | "include": ["src/**/*"],
12 | "exclude": ["node_modules"]
13 | }
14 |
--------------------------------------------------------------------------------
/examples/typescript/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const HtmlWebpackPlugin = require("html-webpack-plugin");
3 |
4 | module.exports = {
5 | entry: "./index.ts",
6 | context: path.resolve(__dirname, "src"),
7 | module: {
8 | rules: [{ test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ }]
9 | },
10 | resolve: {
11 | extensions: [".tsx", ".ts", ".js"]
12 | },
13 | plugins: [new HtmlWebpackPlugin({ template: "./index.html" })]
14 | };
15 |
--------------------------------------------------------------------------------
/images/ascii-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikewesthad/dungeon/0e125f46ff33ea40bb18576d747711afcd168c90/images/ascii-screenshot.png
--------------------------------------------------------------------------------
/images/emoji-dungeon-optimized.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikewesthad/dungeon/0e125f46ff33ea40bb18576d747711afcd168c90/images/emoji-dungeon-optimized.gif
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: "ts-jest",
3 | testEnvironment: "node"
4 | };
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mikewesthad/dungeon",
3 | "version": "2.0.1",
4 | "description": "A simple 2D dungeon generator",
5 | "types": "dist/index.d.ts",
6 | "dependencies": {
7 | "@types/seedrandom": "^2.4.28",
8 | "core-js": "^3.6.4",
9 | "seedrandom": "^3.0.5"
10 | },
11 | "devDependencies": {
12 | "@babel/core": "^7.8.4",
13 | "@babel/preset-env": "^7.8.4",
14 | "@types/jest": "^25.1.3",
15 | "babel-jest": "^25.1.0",
16 | "eslint": "^6.8.0",
17 | "eslint-config-prettier": "^6.10.0",
18 | "eslint-plugin-prettier": "^3.1.2",
19 | "gh-pages": "^2.2.0",
20 | "jest": "^25.1.0",
21 | "prettier": "^1.19.1",
22 | "release-it": "^12.6.1",
23 | "ts-jest": "^25.2.1",
24 | "ts-loader": "^6.2.1",
25 | "typedoc": "^0.16.10",
26 | "typescript": "^3.8.2",
27 | "uglifyjs-webpack-plugin": "^2.2.0",
28 | "webpack": "^4.41.6",
29 | "webpack-cli": "^3.3.11"
30 | },
31 | "scripts": {
32 | "build": "webpack --mode production",
33 | "dev": "webpack --mode development --watch",
34 | "doc": "typedoc --options typedoc.json && cp -r images docs/images",
35 | "deploy:doc": "gh-pages -d docs -b gh-pages --dotfiles",
36 | "prepublishOnly": "npm run build",
37 | "release": "release-it",
38 | "test": "jest"
39 | },
40 | "repository": {
41 | "type": "git",
42 | "url": "git+https://github.com/mikewesthad/dungeon.git"
43 | },
44 | "prettier": {
45 | "printWidth": 100
46 | },
47 | "author": "",
48 | "license": "ISC",
49 | "bugs": {
50 | "url": "https://github.com/mikewesthad/dungeon/issues"
51 | },
52 | "homepage": "https://github.com/mikewesthad/dungeon#readme",
53 | "keywords": [
54 | "dungeon generator",
55 | "2D"
56 | ],
57 | "main": "dist/dungeon.min.js",
58 | "files": [
59 | "dist/**/*",
60 | "src/**/*"
61 | ]
62 | }
--------------------------------------------------------------------------------
/src/create-2d-array.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Creates a 2D array of width x height, filled with the given value.
3 | */
4 | export default function create2DArray(width: number, height: number, value?: T): T[][] {
5 | return [...Array(height)].map(() => Array(width).fill(value));
6 | }
7 |
--------------------------------------------------------------------------------
/src/debug.ts:
--------------------------------------------------------------------------------
1 | import TILES from "./tiles";
2 | import Dungeon from "./dungeon";
3 |
4 | type AttributesObject = { [key: string]: string };
5 |
6 | const attributesToHtmlString = (attrObj: AttributesObject) =>
7 | Object.entries(attrObj)
8 | .map(([key, val]) => `${key}="${val}"`)
9 | .join(" ");
10 |
11 | // Debug by dumping a table to the console where each element in the map is the number of rooms in
12 | // that location
13 | export function debugRoomGrid(dungeon: Dungeon) {
14 | const table = dungeon.roomGrid.map(row => row.map(elem => `${elem.length}`.padStart(2)));
15 | console.log(table.map(row => row.join(" ")).join("\n"));
16 | }
17 |
18 | // Debug by dumping the dungeon into an HTML fragment that can be inserted into HTML. The structure
19 | // is:
20 | //
21 | //
22 | //
23 | // | # | # | # | # |
24 | // | # | | | # |
25 | // | # | | | / |
26 | // | # | | | # |
27 | // | # | # | # | # |
28 | //
29 | //
30 | //
31 |
32 | export type DebugHtmlConfig = {
33 | empty?: string;
34 | emptyAttributes?: AttributesObject;
35 | wall?: string;
36 | wallAttributes?: AttributesObject;
37 | floor?: string;
38 | floorAttributes?: AttributesObject;
39 | door?: string;
40 | doorAttributes?: AttributesObject;
41 | containerAttributes?: AttributesObject;
42 | };
43 | type DebugHtmlConfigRequired = Required;
44 |
45 | export function debugHtmlStringMap(dungeon: Dungeon, config: DebugHtmlConfig = {}) {
46 | const c: DebugHtmlConfigRequired = Object.assign(
47 | {},
48 | {
49 | empty: " ",
50 | emptyAttributes: { class: "dungeon__empty" },
51 | wall: "#",
52 | wallAttributes: { class: "dungeon__wall" },
53 | floor: "_",
54 | floorAttributes: { class: "dungeon__wall" },
55 | door: ".",
56 | doorAttributes: { class: "dungeon__door" },
57 | containerAttributes: { class: "dungeon" }
58 | },
59 | config
60 | );
61 |
62 | const tiles = dungeon.getMappedTiles({
63 | empty: `${c.empty} | `,
64 | floor: `${c.floor} | `,
65 | door: `${c.door} | `,
66 | wall: `${c.wall} | `
67 | });
68 |
69 | const tilesHtml = tiles.map(row => `${row.join("")}
`).join("");
70 | const htmlString = ``;
73 |
74 | return htmlString;
75 | }
76 |
77 | export function debugHtmlMap(dungeon: Dungeon, config: DebugHtmlConfig = {}) {
78 | const htmlString = debugHtmlStringMap(dungeon, config);
79 | const htmlFragment = document.createRange().createContextualFragment(htmlString);
80 | return htmlFragment;
81 | }
82 |
83 | // Debug by returning a colored(!) table string where each tile in the map is represented with an
84 | // ASCII string
85 | export type DebugConsoleConfig = {
86 | empty?: string;
87 | emptyColor?: string;
88 | wall?: string;
89 | wallColor?: string;
90 | floor?: string;
91 | floorColor?: string;
92 | door?: string;
93 | doorColor?: string;
94 | fontSize?: string;
95 | };
96 | type DebugConsoleConfigRequired = Required;
97 | export function debugMap(dungeon: Dungeon, config: DebugConsoleConfig = {}) {
98 | const c: DebugConsoleConfigRequired = Object.assign(
99 | {},
100 | {
101 | empty: " ",
102 | emptyColor: "rgb(0, 0, 0)",
103 | wall: "#",
104 | wallColor: "rgb(255, 0, 0)",
105 | floor: "_",
106 | floorColor: "rgb(210, 210, 210)",
107 | door: ".",
108 | doorColor: "rgb(0, 0, 255)",
109 | fontSize: "15px"
110 | },
111 | config
112 | );
113 |
114 | let string = "";
115 | let styles = [];
116 |
117 | // First line in the browser console window has console line mapping (e.g. "dungeon.js:724") which
118 | // throws off the table. Kill two birds by displaying a guide on the first two lines.
119 | string += `Dungeon: the console window should be big enough to see all of the guide on the next line:\n`;
120 | string += `%c|${"=".repeat(dungeon.width * 2 - 2)}|\n\n`;
121 | styles.push(`font-size: ${c.fontSize}`);
122 |
123 | for (let y = 0; y < dungeon.height; y += 1) {
124 | for (let x = 0; x < dungeon.width; x += 1) {
125 | const tile = dungeon.tiles[y][x];
126 | if (tile === TILES.EMPTY) {
127 | string += `%c${c.empty}`;
128 | styles.push(`color: ${c.emptyColor}; font-size: ${c.fontSize}`);
129 | } else if (tile === TILES.WALL) {
130 | string += `%c${c.wall}`;
131 | styles.push(`color: ${c.wallColor}; font-size: ${c.fontSize}`);
132 | } else if (tile === TILES.FLOOR) {
133 | string += `%c${c.floor}`;
134 | styles.push(`color: ${c.floorColor}; font-size: ${c.fontSize}`);
135 | } else if (tile === TILES.DOOR) {
136 | string += `%c${c.door}`;
137 | styles.push(`color: ${c.doorColor}; font-size: ${c.fontSize}`);
138 | }
139 | string += " ";
140 | }
141 | string += "\n";
142 | }
143 | console.log(string, ...styles);
144 | }
145 |
--------------------------------------------------------------------------------
/src/dungeon.test.ts:
--------------------------------------------------------------------------------
1 | import Dungeon from "./dungeon";
2 | import TILES from "./tiles";
3 |
4 | describe("Dungeon constructor", () => {
5 | test("should apply default config values for optional properties not specified", () => {
6 | const config = {
7 | width: 20,
8 | height: 10,
9 | rooms: {
10 | width: { min: 4, max: 9 },
11 | height: { min: 3, max: 4 }
12 | }
13 | };
14 | const d = new Dungeon(config);
15 | const maxArea = config.rooms.width.max * config.rooms.height.max;
16 | const minArea = config.rooms.width.min * config.rooms.height.min;
17 | const maxRooms = Math.floor((config.width * config.height) / minArea);
18 | expect(d.getConfig()).toMatchObject({
19 | width: config.width,
20 | height: config.height,
21 | doorPadding: 1,
22 | randomSeed: undefined,
23 | rooms: {
24 | width: {
25 | min: config.rooms.width.min,
26 | max: config.rooms.width.max,
27 | onlyOdd: false,
28 | onlyEven: false
29 | },
30 | height: {
31 | min: config.rooms.height.min,
32 | max: config.rooms.height.max,
33 | onlyOdd: false,
34 | onlyEven: false
35 | },
36 | maxArea,
37 | maxRooms
38 | }
39 | });
40 | });
41 |
42 | test("should throw error if max room size is bigger than dungeon size", () => {
43 | expect(() => {
44 | const config = {
45 | width: 20,
46 | height: 10,
47 | rooms: {
48 | width: { min: 4, max: 20 },
49 | height: { min: 3, max: 20 }
50 | }
51 | };
52 | const d = new Dungeon(config);
53 | }).toThrowError(/exceed dungeon/);
54 | });
55 |
56 | test("should throw error if maxArea too small", () => {
57 | expect(() => {
58 | const config = {
59 | width: 20,
60 | height: 10,
61 | rooms: {
62 | width: { min: 3, max: 5 },
63 | height: { min: 3, max: 5 },
64 | maxArea: 2
65 | }
66 | };
67 | const d = new Dungeon(config);
68 | }).toThrowError(/exceeds the given maxArea/);
69 | });
70 |
71 | test("should not allow rooms smaller than 3 x 3", () => {
72 | expect(() => {
73 | const config = {
74 | width: 20,
75 | height: 10,
76 | rooms: {
77 | width: { min: 2, max: 3 },
78 | height: { min: 0, max: 3 }
79 | }
80 | };
81 | const d = new Dungeon(config);
82 | }).toThrowError(/width and height must be >= 3/);
83 | });
84 |
85 | test("should not allow room size min less than max", () => {
86 | expect(() => {
87 | const config = {
88 | width: 20,
89 | height: 10,
90 | rooms: {
91 | width: { min: 5, max: 3 },
92 | height: { min: 5, max: 3 }
93 | }
94 | };
95 | const d = new Dungeon(config);
96 | }).toThrowError(/width and height max must be >= min/);
97 | });
98 |
99 | test("should not allow room min/max that cannot match even parity", () => {
100 | const spy = jest.spyOn(console, "log").mockImplementation(() => {});
101 | expect(() => {
102 | const config = {
103 | width: 20,
104 | height: 10,
105 | rooms: {
106 | width: { min: 3, max: 3, onlyEven: true },
107 | height: { min: 3, max: 5 }
108 | }
109 | };
110 | const d = new Dungeon(config);
111 | }).toThrowError(/width and height max must be >= min/);
112 | spy.mockRestore();
113 | });
114 |
115 | test("should not allow room min/max that cannot match odd parity", () => {
116 | const spy = jest.spyOn(console, "log").mockImplementation(() => {});
117 | expect(() => {
118 | const config = {
119 | width: 20,
120 | height: 10,
121 | rooms: {
122 | width: { min: 4, max: 4, onlyOdd: true },
123 | height: { min: 3, max: 5 }
124 | }
125 | };
126 | const d = new Dungeon(config);
127 | }).toThrowError(/width and height max must be >= min/);
128 | spy.mockRestore();
129 | });
130 | });
131 |
132 | describe("A dungeon", () => {
133 | test("should not have doors in the corners of rooms", () => {
134 | const d = new Dungeon({
135 | width: 500,
136 | height: 500,
137 | rooms: {
138 | width: { min: 3, max: 11 },
139 | height: { min: 3, max: 11 },
140 | maxRooms: 1000
141 | }
142 | });
143 | for (const room of d.rooms) {
144 | expect(room.getTileAt(0, 0)).not.toBe(TILES.DOOR);
145 | expect(room.getTileAt(0, room.height - 1)).not.toBe(TILES.DOOR);
146 | expect(room.getTileAt(room.width - 1, 0)).not.toBe(TILES.DOOR);
147 | expect(room.getTileAt(room.width - 1, room.height - 1)).not.toBe(TILES.DOOR);
148 | }
149 | });
150 |
151 | test("should only have doors and walls along the edges", () => {
152 | const d = new Dungeon({
153 | width: 500,
154 | height: 500,
155 | rooms: {
156 | width: { min: 3, max: 11 },
157 | height: { min: 3, max: 11 },
158 | maxRooms: 1000
159 | }
160 | });
161 | const acceptableEdgeTiles = new RegExp(`${TILES.DOOR}|${TILES.WALL}`);
162 | for (const room of d.rooms) {
163 | for (let y = 0; y < room.height; y++) {
164 | expect(room.getTileAt(0, y).toString()).toMatch(acceptableEdgeTiles);
165 | expect(room.getTileAt(room.width - 1, y).toString()).toMatch(acceptableEdgeTiles);
166 | }
167 | for (let x = 0; x < room.width; x++) {
168 | expect(room.getTileAt(x, 0).toString()).toMatch(acceptableEdgeTiles);
169 | expect(room.getTileAt(x, room.height - 1).toString()).toMatch(acceptableEdgeTiles);
170 | }
171 | }
172 | });
173 |
174 | test("should generate rooms that match the dimensions", () => {
175 | const config = {
176 | width: 500,
177 | height: 500,
178 | rooms: {
179 | width: { min: 4, max: 7 },
180 | height: { min: 3, max: 9 },
181 | maxRooms: 1000
182 | }
183 | };
184 | const d = new Dungeon(config);
185 | for (const room of d.rooms) {
186 | expect(room.width).toBeGreaterThanOrEqual(config.rooms.width.min);
187 | expect(room.width).toBeLessThanOrEqual(config.rooms.width.max);
188 | expect(room.height).toBeGreaterThanOrEqual(config.rooms.height.min);
189 | expect(room.height).toBeLessThanOrEqual(config.rooms.height.max);
190 | }
191 | });
192 |
193 | test("should generate only odd dimension rooms when requested", () => {
194 | const config = {
195 | width: 500,
196 | height: 500,
197 | rooms: {
198 | width: { min: 5, max: 7, onlyOdd: true },
199 | height: { min: 3, max: 9, onlyOdd: true },
200 | maxRooms: 1000
201 | }
202 | };
203 | const d = new Dungeon(config);
204 | for (const room of d.rooms) {
205 | expect(room.width % 2 == 0).toBe(false);
206 | expect(room.height % 2 == 0).toBe(false);
207 | }
208 | });
209 |
210 | test("should generate only even dimension rooms when requested", () => {
211 | const config = {
212 | width: 500,
213 | height: 500,
214 | rooms: {
215 | width: { min: 4, max: 6, onlyEven: true },
216 | height: { min: 4, max: 8, onlyEven: true },
217 | maxRooms: 1000
218 | }
219 | };
220 | const d = new Dungeon(config);
221 | for (const room of d.rooms) {
222 | expect(room.width % 2 == 0).toBe(true);
223 | expect(room.height % 2 == 0).toBe(true);
224 | }
225 | });
226 | });
227 |
--------------------------------------------------------------------------------
/src/dungeon.ts:
--------------------------------------------------------------------------------
1 | import Random from "./random";
2 | import Room from "./room";
3 | import TILES, { DebugTileMap } from "./tiles";
4 | import { debugMap, debugHtmlMap, DebugHtmlConfig, DebugConsoleConfig } from "./debug";
5 | import create2DArray from "./create-2d-array";
6 | import Point from "./point";
7 | import { isOdd, isEven } from "./math";
8 |
9 | type DimensionConfig = { min: number; max: number; onlyOdd?: boolean; onlyEven?: boolean };
10 | type DimensionConfigRequired = Required;
11 | type RoomConfig = {
12 | width: DimensionConfig;
13 | height: DimensionConfig;
14 | maxArea?: number;
15 | maxRooms?: number;
16 | };
17 | export type DungeonConfig = {
18 | width: number;
19 | height: number;
20 | randomSeed?: string;
21 | doorPadding?: number;
22 | rooms: RoomConfig;
23 | };
24 |
25 | export default class Dungeon {
26 | public height: number;
27 | public width: number;
28 | public tiles: TILES[][];
29 | public rooms: Room[] = [];
30 | private doorPadding: number;
31 | private r: Random;
32 | // 2D grid matching map dimensions where every element contains an array of all the rooms in
33 | // that location.
34 | public roomGrid: Room[][][] = [];
35 | private maxRooms: number;
36 | private maxRoomArea: number;
37 | private roomWidthConfig: DimensionConfigRequired;
38 | private roomHeightConfig: DimensionConfigRequired;
39 | private randomSeed?: string;
40 |
41 | constructor(config: DungeonConfig) {
42 | this.width = config.width;
43 | this.height = config.height;
44 | this.doorPadding = config.doorPadding ?? 1;
45 | this.rooms = [];
46 | this.randomSeed = config.randomSeed;
47 | this.r = new Random(this.randomSeed);
48 |
49 | const rooms = config.rooms;
50 | const roomWidth = rooms.width;
51 | const roomHeight = rooms.height;
52 | const maxPossibleRoomArea = roomWidth.max * roomHeight.max;
53 | const minPossibleRoomArea = roomWidth.min * roomHeight.min;
54 | const maxPossibleRooms = Math.floor((this.width * this.height) / minPossibleRoomArea);
55 | this.roomWidthConfig = {
56 | min: roomWidth.min,
57 | max: roomWidth.max,
58 | onlyOdd: roomWidth.onlyOdd ?? false,
59 | onlyEven: roomWidth.onlyEven ?? false
60 | };
61 | this.roomHeightConfig = {
62 | min: roomHeight.min,
63 | max: roomHeight.max,
64 | onlyOdd: roomHeight.onlyOdd ?? false,
65 | onlyEven: roomHeight.onlyEven ?? false
66 | };
67 | this.maxRooms = rooms.maxRooms ?? maxPossibleRooms;
68 | this.maxRoomArea = rooms.maxArea ?? maxPossibleRoomArea;
69 |
70 | this.adjustDimensionConfigForParity(this.roomWidthConfig);
71 | this.adjustDimensionConfigForParity(this.roomHeightConfig);
72 | this.checkDimensionConfig(this.roomWidthConfig);
73 | this.checkDimensionConfig(this.roomHeightConfig);
74 |
75 | // Validate the room width and height settings.
76 | if (this.roomWidthConfig.max > this.width) {
77 | throw new Error("Room max width cannot exceed dungeon width.");
78 | }
79 | if (this.roomHeightConfig.max > this.height) {
80 | throw new Error("Room max height cannot exceed dungeon height.");
81 | }
82 | // Validate the max area based on min dimensions.
83 | if (this.maxRoomArea < minPossibleRoomArea) {
84 | throw new Error("The minimum dimensions specified exceeds the given maxArea.");
85 | }
86 |
87 | this.generate();
88 | this.tiles = this.getTiles();
89 | }
90 |
91 | /**
92 | * Adjust the given dimension config for parity settings (onlyOdd, onlyEven) so that min/max are
93 | * adjusted to reflect actual possible values.
94 | * @param dimensionConfig
95 | */
96 | private adjustDimensionConfigForParity(dimensionConfig: DimensionConfigRequired) {
97 | if (dimensionConfig.onlyOdd) {
98 | if (isEven(dimensionConfig.min)) {
99 | dimensionConfig.min++;
100 | console.log("Dungeon: warning, min dimension adjusted to match onlyOdd setting.");
101 | }
102 | if (isEven(dimensionConfig.max)) {
103 | dimensionConfig.max--;
104 | console.log("Dungeon: warning, max dimension adjusted to match onlyOdd setting.");
105 | }
106 | } else if (dimensionConfig.onlyEven) {
107 | if (isOdd(dimensionConfig.min)) {
108 | dimensionConfig.min++;
109 | console.log("Dungeon: warning, min dimension adjusted to match onlyEven setting.");
110 | }
111 | if (isOdd(dimensionConfig.max)) {
112 | dimensionConfig.max--;
113 | console.log("Dungeon: warning, max dimension adjusted to match onlyEven setting.");
114 | }
115 | }
116 | }
117 |
118 | private checkDimensionConfig(dimensionConfig: DimensionConfigRequired) {
119 | const { max, min, onlyEven, onlyOdd } = dimensionConfig;
120 | if (onlyEven && onlyOdd) {
121 | throw new Error("Cannot use both onlyEven and onlyOdd in room's width/height config.");
122 | }
123 | if (max < min) {
124 | throw new Error("Room width and height max must be >= min.");
125 | }
126 | if (min < 3) {
127 | throw new Error("Room width and height must be >= 3.");
128 | }
129 | }
130 |
131 | public getConfig(): DungeonConfig {
132 | return {
133 | width: this.width,
134 | height: this.height,
135 | doorPadding: this.doorPadding,
136 | randomSeed: this.randomSeed,
137 | rooms: {
138 | width: this.roomWidthConfig,
139 | height: this.roomHeightConfig,
140 | maxArea: this.maxRoomArea,
141 | maxRooms: this.maxRooms
142 | }
143 | };
144 | }
145 |
146 | public drawToConsole(config: DebugConsoleConfig) {
147 | debugMap(this, config);
148 | }
149 |
150 | public drawToHtml(config: DebugHtmlConfig) {
151 | return debugHtmlMap(this, config);
152 | }
153 |
154 | public getMappedTiles(tileMapping: DebugTileMap = {}) {
155 | tileMapping = Object.assign({}, { empty: 0, wall: 1, floor: 2, door: 3 }, tileMapping);
156 | return this.tiles.map(row =>
157 | row.map(tile => {
158 | if (tile === TILES.EMPTY) return tileMapping.empty;
159 | else if (tile === TILES.WALL) return tileMapping.wall;
160 | else if (tile === TILES.FLOOR) return tileMapping.floor;
161 | else if (tile === TILES.DOOR) return tileMapping.door;
162 | })
163 | );
164 | }
165 |
166 | public getCenter(): Point {
167 | return {
168 | x: Math.floor(this.width / 2),
169 | y: Math.floor(this.height / 2)
170 | };
171 | }
172 |
173 | public generate() {
174 | this.rooms = [];
175 | this.roomGrid = [];
176 |
177 | for (let y = 0; y < this.height; y++) {
178 | this.roomGrid.push([]);
179 | for (let x = 0; x < this.width; x++) {
180 | this.roomGrid[y].push([]);
181 | }
182 | }
183 |
184 | // Seed the map with a starting randomly sized room in the center of the map.
185 | const mapCenter = this.getCenter();
186 | const room = this.createRandomRoom();
187 | room.setPosition(
188 | mapCenter.x - Math.floor(room.width / 2),
189 | mapCenter.y - Math.floor(room.height / 2)
190 | );
191 | this.addRoom(room);
192 |
193 | // Continue generating rooms until we hit our cap or have hit our maximum iterations (generally
194 | // due to not being able to fit any more rooms in the map).
195 | let attempts = 0;
196 | const maxAttempts = this.maxRooms * 5;
197 | while (this.rooms.length < this.maxRooms && attempts < maxAttempts) {
198 | this.generateRoom();
199 | attempts++;
200 | }
201 | }
202 |
203 | public hasRoomAt(x: number, y: number) {
204 | return x < 0 || y < 0 || x >= this.width || y >= this.height || this.roomGrid[y][x].length > 0;
205 | }
206 |
207 | public getRoomAt(x: number, y: number): Room | null {
208 | if (this.hasRoomAt(x, y)) {
209 | // Assumes 1 room per tile, which is valid for now
210 | return this.roomGrid[y][x][0];
211 | } else {
212 | return null;
213 | }
214 | }
215 |
216 | /**
217 | * Attempt to add a room and return true/false based on whether it was successful.
218 | * @param room
219 | */
220 | private addRoom(room: Room): Boolean {
221 | if (!this.canFitRoom(room)) return false;
222 | this.rooms.push(room);
223 | // Update all tiles in the roomGrid to indicate that this room is sitting on them.
224 | for (let y = room.top; y <= room.bottom; y++) {
225 | for (let x = room.left; x <= room.right; x++) {
226 | this.roomGrid[y][x].push(room);
227 | }
228 | }
229 | return true;
230 | }
231 |
232 | private canFitRoom(room: Room) {
233 | // Make sure the room fits inside the dungeon.
234 | if (room.x < 0 || room.right > this.width - 1) return false;
235 | if (room.y < 0 || room.bottom > this.height - 1) return false;
236 |
237 | // Make sure this room doesn't intersect any existing rooms.
238 | for (let i = 0; i < this.rooms.length; i++) {
239 | if (room.overlaps(this.rooms[i])) return false;
240 | }
241 |
242 | return true;
243 | }
244 |
245 | private createRandomRoom(): Room {
246 | let width = 0;
247 | let height = 0;
248 | let area = 0;
249 |
250 | // Find width and height using min/max sizes while keeping under the maximum area.
251 | const { roomWidthConfig, roomHeightConfig } = this;
252 | do {
253 | width = this.r.randomInteger(roomWidthConfig.min, roomWidthConfig.max, {
254 | onlyEven: roomWidthConfig.onlyEven,
255 | onlyOdd: roomWidthConfig.onlyOdd
256 | });
257 | height = this.r.randomInteger(roomHeightConfig.min, roomHeightConfig.max, {
258 | onlyEven: roomHeightConfig.onlyEven,
259 | onlyOdd: roomHeightConfig.onlyOdd
260 | });
261 | area = width * height;
262 | } while (area > this.maxRoomArea);
263 |
264 | return new Room(width, height);
265 | }
266 |
267 | private generateRoom() {
268 | const room = this.createRandomRoom();
269 |
270 | // Only allow 150 tries at placing the room
271 | let i = 150;
272 | while (i > 0) {
273 | // Attempt to find another room to attach this one to
274 | const result = this.findRoomAttachment(room);
275 |
276 | room.setPosition(result.x, result.y);
277 | // Try to add it. If successful, add the door between the rooms and break the loop.
278 | if (this.addRoom(room)) {
279 | const [door1, door2] = this.findNewDoorLocation(room, result.target);
280 | this.addDoor(door1);
281 | this.addDoor(door2);
282 | break;
283 | }
284 |
285 | i -= 1;
286 | }
287 | }
288 |
289 | public getTiles() {
290 | const tiles = create2DArray(this.width, this.height, TILES.EMPTY);
291 | this.rooms.forEach(room => {
292 | room.forEachTile((point, tile) => {
293 | tiles[room.y + point.y][room.x + point.x] = tile;
294 | });
295 | });
296 | return tiles;
297 | }
298 |
299 | private getPotentiallyTouchingRooms(room: Room) {
300 | const touchingRooms: Room[] = [];
301 |
302 | // function that checks the list of rooms at a point in our grid for any potential touching
303 | // rooms
304 | const checkRoomList = (x: number, y: number) => {
305 | const r = this.roomGrid[y][x];
306 | for (let i = 0; i < r.length; i++) {
307 | // make sure this room isn't the one we're searching around and that it isn't already in the
308 | // list
309 | if (r[i] != room && touchingRooms.indexOf(r[i]) === -1) {
310 | // make sure this isn't a corner of the room (doors can't go into corners)
311 | const lx = x - r[i].x;
312 | const ly = y - r[i].y;
313 | if ((lx > 0 && lx < r[i].width - 1) || (ly > 0 && ly < r[i].height - 1)) {
314 | touchingRooms.push(r[i]);
315 | }
316 | }
317 | }
318 | };
319 |
320 | // iterate the north and south walls, looking for other rooms in those tile locations
321 | for (let x = room.x + 1; x < room.x + room.width - 1; x++) {
322 | checkRoomList(x, room.y);
323 | checkRoomList(x, room.y + room.height - 1);
324 | }
325 |
326 | // iterate the west and east walls, looking for other rooms in those tile locations
327 | for (let y = room.y + 1; y < room.y + room.height - 1; y++) {
328 | checkRoomList(room.x, y);
329 | checkRoomList(room.x + room.width - 1, y);
330 | }
331 |
332 | return touchingRooms;
333 | }
334 |
335 | private findNewDoorLocation(room1: Room, room2: Room): [Point, Point] {
336 | const door1 = { x: -1, y: -1 };
337 | const door2 = { x: -1, y: -1 };
338 |
339 | if (room1.y === room2.y - room1.height) {
340 | // North
341 | door1.x = door2.x = this.r.randomInteger(
342 | Math.floor(Math.max(room2.left, room1.left) + this.doorPadding),
343 | Math.floor(Math.min(room2.right, room1.right) - this.doorPadding)
344 | );
345 | door1.y = room1.y + room1.height - 1;
346 | door2.y = room2.y;
347 | } else if (room1.x == room2.x - room1.width) {
348 | // West
349 | door1.x = room1.x + room1.width - 1;
350 | door2.x = room2.x;
351 | door1.y = door2.y = this.r.randomInteger(
352 | Math.floor(Math.max(room2.top, room1.top) + this.doorPadding),
353 | Math.floor(Math.min(room2.bottom, room1.bottom) - this.doorPadding)
354 | );
355 | } else if (room1.x == room2.x + room2.width) {
356 | // East
357 | door1.x = room1.x;
358 | door2.x = room2.x + room2.width - 1;
359 | door1.y = door2.y = this.r.randomInteger(
360 | Math.floor(Math.max(room2.top, room1.top) + this.doorPadding),
361 | Math.floor(Math.min(room2.bottom, room1.bottom) - this.doorPadding)
362 | );
363 | } else if (room1.y == room2.y + room2.height) {
364 | // South
365 | door1.x = door2.x = this.r.randomInteger(
366 | Math.floor(Math.max(room2.left, room1.left) + this.doorPadding),
367 | Math.floor(Math.min(room2.right, room1.right) - this.doorPadding)
368 | );
369 | door1.y = room1.y;
370 | door2.y = room2.y + room2.height - 1;
371 | }
372 |
373 | return [door1, door2];
374 | }
375 |
376 | private findRoomAttachment(room: Room) {
377 | const r = this.r.randomPick(this.rooms);
378 |
379 | let x = 0;
380 | let y = 0;
381 | const pad = 2 * this.doorPadding; // 2x padding to account for the padding both rooms need
382 |
383 | // Randomly position this room on one of the sides of the random room.
384 | switch (this.r.randomInteger(0, 3)) {
385 | // north
386 | case 0:
387 | // x = r.left - (room.width - 1) would have rooms sharing exactly 1x tile
388 | x = this.r.randomInteger(r.left - (room.width - 1) + pad, r.right - pad);
389 | y = r.top - room.height;
390 | break;
391 | // west
392 | case 1:
393 | x = r.left - room.width;
394 | y = this.r.randomInteger(r.top - (room.height - 1) + pad, r.bottom - pad);
395 | break;
396 | // east
397 | case 2:
398 | x = r.right + 1;
399 | y = this.r.randomInteger(r.top - (room.height - 1) + pad, r.bottom - pad);
400 | break;
401 | // south
402 | case 3:
403 | x = this.r.randomInteger(r.left - (room.width - 1) + pad, r.right - pad);
404 | y = r.bottom + 1;
405 | break;
406 | }
407 |
408 | // Return the position for this new room and the target room
409 | return {
410 | x: x,
411 | y: y,
412 | target: r
413 | };
414 | }
415 |
416 | private addDoor(doorPos: Point) {
417 | // Get all the rooms at the location of the door
418 | const rooms = this.roomGrid[doorPos.y][doorPos.x];
419 | rooms.forEach(room => {
420 | room.setTileAt(doorPos.x - room.x, doorPos.y - room.y, TILES.DOOR);
421 | });
422 | }
423 | }
424 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import Dungeon, { DungeonConfig } from "./dungeon";
2 | import Point from "./point";
3 | import Room from "./room";
4 | import TILES, { DebugTileMap } from "./tiles";
5 | import { DebugHtmlConfig, DebugConsoleConfig } from "./debug";
6 |
7 | export default Dungeon;
8 | export { DungeonConfig, Point, Room, TILES, DebugTileMap, DebugHtmlConfig, DebugConsoleConfig };
9 |
--------------------------------------------------------------------------------
/src/math.test.ts:
--------------------------------------------------------------------------------
1 | import { isEven, isOdd } from "./math";
2 |
3 | describe("Math parity methods", () => {
4 | test("isEven should return true for even integers", () => {
5 | expect(isEven(0)).toBe(true);
6 | expect(isEven(2)).toBe(true);
7 | expect(isEven(-2)).toBe(true);
8 | });
9 |
10 | test("isEven should return false for odd integers", () => {
11 | expect(isEven(1)).toBe(false);
12 | expect(isEven(-1)).toBe(false);
13 | });
14 |
15 | test("isEven should return false for floating point numbers", () => {
16 | expect(isEven(0.5)).toBe(false);
17 | expect(isEven(-0.5)).toBe(false);
18 | });
19 |
20 | test("isOdd should return true for odd integers", () => {
21 | expect(isOdd(1)).toBe(true);
22 | expect(isOdd(-1)).toBe(true);
23 | });
24 |
25 | test("isOdd should return false for even integers", () => {
26 | expect(isOdd(0)).toBe(false);
27 | expect(isOdd(2)).toBe(false);
28 | expect(isOdd(-2)).toBe(false);
29 | });
30 |
31 | test("isOdd should return false for floating point numbers", () => {
32 | expect(isOdd(0.5)).toBe(false);
33 | expect(isOdd(-0.5)).toBe(false);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/math.ts:
--------------------------------------------------------------------------------
1 | function isEven(value: number) {
2 | return Number.isInteger(value) && value % 2 === 0;
3 | }
4 |
5 | function isOdd(value: number) {
6 | return Number.isInteger(value) && value % 2 !== 0;
7 | }
8 |
9 | export { isEven, isOdd };
10 |
--------------------------------------------------------------------------------
/src/point.ts:
--------------------------------------------------------------------------------
1 | export default interface Point {
2 | x: number;
3 | y: number;
4 | }
5 |
--------------------------------------------------------------------------------
/src/random.test.ts:
--------------------------------------------------------------------------------
1 | import Random from "./random";
2 |
3 | describe("randomInteger", () => {
4 | test("should return a number between min and max", () => {
5 | const r = new Random("seed");
6 | Array(100)
7 | .map(() => r.randomInteger(3, 5))
8 | .forEach(r => {
9 | expect(r).toBeGreaterThanOrEqual(3);
10 | expect(r).toBeLessThanOrEqual(5);
11 | });
12 | });
13 |
14 | test("should return a number between min and max when min > max", () => {
15 | const r = new Random("seed");
16 | Array(100)
17 | .map(() => r.randomInteger(5, 3))
18 | .forEach(r => {
19 | expect(r).toBeGreaterThanOrEqual(3);
20 | expect(r).toBeLessThanOrEqual(5);
21 | });
22 | });
23 |
24 | test("should return an even number when onlyEven option is used", () => {
25 | const r = new Random("seed");
26 | Array(100)
27 | .map(() => r.randomInteger(2, 30, { onlyEven: true }))
28 | .forEach(r => {
29 | expect(r % 2).toBe(0);
30 | });
31 | });
32 |
33 | test("should return an even number when onlyEven option is used and parameters are odd", () => {
34 | const r = new Random("seed");
35 | Array(100)
36 | .map(() => r.randomInteger(3, 31, { onlyEven: true }))
37 | .forEach(r => {
38 | expect(r % 2).toBe(0);
39 | });
40 | });
41 |
42 | test("should return an odd number when onlyOdd option is used", () => {
43 | const r = new Random("seed");
44 | Array(100)
45 | .map(() => r.randomInteger(3, 31, { onlyOdd: true }))
46 | .forEach(r => {
47 | expect(r % 2).toBe(1);
48 | });
49 | });
50 |
51 | test("should return an odd number when onlyOdd option is used and parameters are even", () => {
52 | const r = new Random("seed");
53 | Array(100)
54 | .map(() => r.randomInteger(2, 30, { onlyOdd: true }))
55 | .forEach(r => {
56 | expect(r % 2).toBe(1);
57 | });
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/random.ts:
--------------------------------------------------------------------------------
1 | import * as seedrandom from "seedrandom";
2 |
3 | export default class Random {
4 | private rng: seedrandom.prng;
5 |
6 | constructor(seedValue?: string) {
7 | this.rng = seedrandom(seedValue);
8 | }
9 |
10 | randomInteger(min: number, max: number, { onlyOdd = false, onlyEven = false } = {}) {
11 | if (onlyOdd) return this.randomOddInteger(min, max);
12 | else if (onlyEven) return this.randomEvenInteger(min, max);
13 | else return Math.floor(this.rng() * (max - min + 1) + min);
14 | }
15 |
16 | randomEvenInteger(min: number, max: number) {
17 | if (min % 2 !== 0 && min < max) min++;
18 | if (max % 2 !== 0 && max > min) max--;
19 | const range = (max - min) / 2;
20 | return Math.floor(this.rng() * (range + 1)) * 2 + min;
21 | }
22 |
23 | randomOddInteger(min: number, max: number) {
24 | if (min % 2 === 0) min++;
25 | if (max % 2 === 0) max--;
26 | const range = (max - min) / 2;
27 | return Math.floor(this.rng() * (range + 1)) * 2 + min;
28 | }
29 |
30 | randomPick(array: T[]): T {
31 | return array[this.randomInteger(0, array.length - 1)];
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/room.ts:
--------------------------------------------------------------------------------
1 | import TILES from "./tiles";
2 | import Point from "./point";
3 | import create2DArray from "./create-2d-array";
4 |
5 | export default class Room {
6 | private tiles: TILES[][];
7 | public x = 0;
8 | public y = 0;
9 | public left = 0;
10 | public right = 0;
11 | public top = 0;
12 | public bottom = 0;
13 | public centerX = 0;
14 | public centerY = 0;
15 |
16 | constructor(public width: number, public height: number) {
17 | this.width = width;
18 | this.height = height;
19 |
20 | this.setPosition(0, 0);
21 |
22 | this.tiles = create2DArray(width, height, TILES.FLOOR);
23 |
24 | // Place walls around edges of room.
25 | for (let y = 0; y < this.height; y++) {
26 | this.setTileAt(0, y, TILES.WALL);
27 | this.setTileAt(width - 1, y, TILES.WALL);
28 | }
29 | for (let x = 0; x < this.width; x++) {
30 | this.setTileAt(x, 0, TILES.WALL);
31 | this.setTileAt(x, height - 1, TILES.WALL);
32 | }
33 | }
34 |
35 | public forEachTile(fn: (p: Point, t: TILES) => void) {
36 | for (let y = 0; y < this.height; y++) {
37 | for (let x = 0; x < this.width; x++) {
38 | fn({ x, y }, this.getTileAt(x, y));
39 | }
40 | }
41 | }
42 |
43 | public setPosition(x: number, y: number) {
44 | this.x = x;
45 | this.y = y;
46 | this.left = x;
47 | this.right = x + (this.width - 1);
48 | this.top = y;
49 | this.bottom = y + (this.height - 1);
50 | this.centerX = x + Math.floor(this.width / 2);
51 | this.centerY = y + Math.floor(this.height / 2);
52 | }
53 |
54 | public getDoorLocations(): Point[] {
55 | const doors = [];
56 | for (let y = 0; y < this.height; y++) {
57 | for (let x = 0; x < this.width; x++) {
58 | if (this.getTileAt(x, y) == TILES.DOOR) {
59 | doors.push({ x, y });
60 | }
61 | }
62 | }
63 | return doors;
64 | }
65 |
66 | public overlaps(otherRoom: Room) {
67 | if (this.right < otherRoom.left) return false;
68 | else if (this.left > otherRoom.right) return false;
69 | else if (this.bottom < otherRoom.top) return false;
70 | else if (this.top > otherRoom.bottom) return false;
71 | else return true;
72 | }
73 |
74 | /**
75 | * Check if the given local coordinates are within the dimensions of the room.
76 | * @param x
77 | * @param y
78 | */
79 | public isInBounds(x: number, y: number) {
80 | return x >= 0 && x < this.width - 1 && y >= 0 && y < this.height - 1;
81 | }
82 |
83 | /**
84 | * Get the tile at the given local coordinates of the room.
85 | * @param x
86 | * @param y
87 | */
88 | public getTileAt(x: number, y: number): TILES {
89 | return this.tiles[y][x];
90 | }
91 |
92 | /**
93 | * Set the tile at the given local coordinates of the room.
94 | * @param x
95 | * @param y
96 | * @param tile
97 | */
98 | public setTileAt(x: number, y: number, tile: TILES) {
99 | this.tiles[y][x] = tile;
100 | }
101 |
102 | /**
103 | * Check if two rooms share a door between them.
104 | * @param otherRoom
105 | */
106 | public isConnectedTo(otherRoom: Room) {
107 | const doors = this.getDoorLocations();
108 | for (const d of doors) {
109 | // Find door position relative to otherRoom's local coordinate system.
110 | const dx = this.x + d.x - otherRoom.x;
111 | const dy = this.y + d.y - otherRoom.y;
112 |
113 | if (otherRoom.isInBounds(dx, dy) && otherRoom.getTileAt(dx, dy) === TILES.DOOR) {
114 | return true;
115 | }
116 | }
117 | return false;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/tiles.ts:
--------------------------------------------------------------------------------
1 | enum TILES {
2 | EMPTY = 0,
3 | WALL = 1,
4 | FLOOR = 2,
5 | DOOR = 3
6 | }
7 |
8 | type DebugTileMap = {
9 | empty?: number | string;
10 | wall?: number | string;
11 | floor?: number | string;
12 | door?: number | string;
13 | };
14 |
15 | export default TILES;
16 | export { DebugTileMap };
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "module": "es6",
5 | "target": "es5",
6 | "sourceMap": true,
7 | "allowSyntheticDefaultImports": true,
8 | "declaration": true,
9 | "lib": ["dom", "esnext"],
10 | "outDir": "dist/",
11 | "moduleResolution": "node"
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules", "src/**/*.test.ts"]
15 | }
16 |
--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "inputFiles": [
3 | "./src"
4 | ],
5 | "mode": "modules",
6 | "out": "docs",
7 | "excludeNotExported": false,
8 | "excludePrivate": true,
9 | "theme": "default",
10 | "readme": "README.md"
11 | }
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 |
3 | const path = require("path");
4 | const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
5 | const root = __dirname;
6 |
7 | module.exports = function(env, argv) {
8 | const isDev = argv.mode === "development";
9 |
10 | return {
11 | context: path.join(root, "src"),
12 | entry: {
13 | dungeon: "./index.ts",
14 | "dungeon.min": "./index.ts"
15 | },
16 | output: {
17 | filename: "[name].js",
18 | path: path.join(root, "dist"),
19 | library: "Dungeon",
20 | libraryTarget: "umd",
21 | libraryExport: "default",
22 | globalObject: `typeof self !== 'undefined' ? self : this`
23 | },
24 | optimization: {
25 | minimizer: [new UglifyJsPlugin({ include: /\.min\.js$/, sourceMap: true })]
26 | },
27 | module: {
28 | rules: [
29 | {
30 | test: /\.ts$/,
31 | use: "ts-loader",
32 | exclude: /node_modules/
33 | },
34 | {
35 | test: /\.js$/,
36 | exclude: /node_modules/,
37 | use: "babel-loader"
38 | }
39 | ]
40 | },
41 | resolve: {
42 | extensions: [".ts", ".js"]
43 | },
44 | devtool: isDev ? "eval-source-map" : "source-map"
45 | };
46 | };
47 |
--------------------------------------------------------------------------------