├── .babelrc ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── README.md ├── eslint.config.js ├── package.json ├── public ├── assets │ ├── agent.png │ └── tilemaps │ │ └── tiles │ │ ├── ground_1x1.acorn │ │ └── ground_1x1.png └── index.html ├── src ├── demo │ ├── demoSprite.js │ ├── demoState.js │ └── index.js └── lib │ ├── astar │ ├── aStar.js │ ├── aStarPath.js │ ├── funnel.js │ ├── funnelPoint.js │ ├── portal.js │ └── priorityQueue.js │ ├── config.js │ ├── debug.js │ ├── delaunay │ ├── cluster.js │ ├── delaunayCluster.js │ ├── delaunayGenerator.js │ ├── edgePoint.js │ ├── hulls.js │ └── marchingSquares.js │ ├── map │ ├── grid.js │ ├── sprite.js │ └── tile.js │ ├── navMesh.js │ ├── navMeshPlugin.js │ ├── navMeshPolygon.js │ └── utils.js ├── webpack ├── build.config.js ├── conf.js ├── defaults.config.js └── dev.config.js ├── yarn-error.log └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { 5 | "browsers": [ 6 | ">0.25%", 7 | "not ie 11", 8 | "not op_mini all" 9 | ] 10 | }, 11 | "modules": false 12 | }] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | public/*.bundle.js 3 | public/*.map 4 | .idea/ 5 | node_modules/* 6 | *.code-workspace 7 | .vscode -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | webpack/ 3 | public/ 4 | .idea/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.2.1] - 07 Oct 2017 4 | 5 | ### Added 6 | - Included support for 'sprites' to mark blocked areas of navMesh that isn't an explicit 7 | collision tile. This can be accessed through `addSprite()` method of plugin (use `removeSprite` to remove the sprite by `uuid` 8 | - Tilelayer data is now flattened from 2D Phaser format into 1D array; this makes it faster to iterate across the whole grid. 9 | - Added `updatedAt` timestamp to generated navMesh 10 | - Added `createdAt` timestamp for generated paths 11 | 12 | ## [0.1.1] - 29 Sept 2017 13 | 14 | ### Added 15 | - Added UUID and calculated polygons to the returned Pathing data. 16 | - Included a `CHANGELOG.md`, the file you're reading right now ;-) 17 | 18 | ## [0.1.0] - 27 Sept 2017 19 | 20 | ### Added 21 | - Config value to use the `midPoints` of all cluster edges during Delaunay calculation 22 | 23 | ### Changed 24 | 25 | - Fixed issue with NPM package not working properly when imported from other projects -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phaser-navmesh-generation 2 | 3 | ### Warning: this plugin is still Work in Progress (WIP). It's possibly not stable enough for use in a production product - use at your own risk (for now!) 4 | 5 | This Phaser `ScenePlugin` generates Navigation Mesh (navmesh) data from supplied `Phaser.TilemapLayer` data and collison indices thereof. Contains configuration options for fine-grain control 6 | 7 | ### Getting Started: 8 | 9 | import it as you would any other module & include it as a `ScenePlugin` within your game's configuration object: 10 | 11 | ``` 12 | import NavMeshPlugin from 'phaser-navmesh-generation'; 13 | 14 | const game = { 15 | // ... 16 | // other Game config values ... 17 | // ... 18 | plugins: { 19 | scene: [ 20 | { key: 'NavMeshPlugin', plugin: NavMeshPlugin, mapping: 'navMeshPlugin' } 21 | ] 22 | }, 23 | 24 | ``` 25 | 26 | #### Usage: 27 | 28 | 1. First, we need to generate a new navigation mesh based on the Tilemap / Tilelayer you want to use to calculate collision data from. Use the `mapping` value provided within your `Scene` to access the freshly injected plugin: 29 | 30 | 31 | ``` 32 | var navMesh = this.navMeshPlugin.buildFromTileLayer(tileMap, tileLayer, { 33 | collisionIndices: [1, 2, 3], 34 | midPointThreshold: 0, 35 | useMidPoint: false, 36 | debug: { 37 | hulls: false, 38 | navMesh: false, 39 | navMeshNodes: false, 40 | polygonBounds: false, 41 | aStarPath: false 42 | } 43 | }); 44 | ``` 45 | Params: 46 | * `collisionIndices`: an `Array` of collision indices that your tilemap uses for collisions **(required)** 47 | * `midPointThreshold`: a `Number` value telling how narrow a navmesh triangle needs to be before it's ignored during pathing (optional; default `0`) 48 | * `timingInfo`: Show in the console how long it took to build the NavMesh - and search for paths (optional; default `false`) 49 | * `useMidPoint`: a `Boolean` value on whether to include all triangle edge mid-points in calculating triangulation (optional; default: `true`) 50 | * `offsetHullsBy`: a `Number` value to offset (expand) each hull cluster by. Useful to use a small value to prevent excessively parallel edges (optional; default: `0.1`) 51 | * `debug`: various optional debug options to Render the stages of NavMesh calculation: 52 | * `hulls`: Every (recursive) 'chunk' of impassable tiles found on the tilemap 53 | * `navMesh`: Draw all the actual triangles generated for this navmesh 54 | * `navMeshNodes`: Draw all connections found between neighbouring triangles 55 | * `polygonBounds`: Draw the bonding radius between each navmesh triangle 56 | * `aStarPath`: Draw the aStar path found between points (WIP debug, will remove later) 57 | 58 | 2. Then, to find a path between two `Phaser.Geom.Point` instances, call: 59 | ``` 60 | navMesh.getPath(position, destination, offset); 61 | ``` 62 | Params: 63 | * `position` is a `Phaser.Geom.Point` of your starting _world_ position **(required)** 64 | * `destination` is a `Phaser.Geom.Point` of the destination / end _world_ position **(required)** 65 | * `offset` is an offset value to keep a distance (optional, default `0`) 66 | 67 | This method returns two useful pieces of data: 68 | 69 | `path` an `Array` of Points that is the shortest path to your destination 70 | `offsetPath` an `Array` containing the _offset_ path, relative to the `offset` value given in `getPath` 71 | 72 | 73 | #### Other methods: 74 | `const sprite = plugin.addSprite(x, y, width, height, refresh);` 75 | 76 | Your map may have Sprites that act as impassable areas (houses, trees etc), and you can mark this area of the map using the above method 77 | 78 | Params: 79 | * `x` the Tile X location of the sprite **(required)** 80 | * `y` the Tile Y location of the sprite **(required)** 81 | * `width` the Width of the sprite, expressed as tile units **(required)** 82 | * `height` the Height of the sprite, expressed as tile units **(required)** 83 | * `refresh`: If you wish the navMesh to be re-calculated after removing the sprite (optional, default `true`) 84 | 85 | Returns: 86 | * The internal instance of the sprite; includes a `uuid` that can be used for later removal 87 | 88 | `plugin.removeSprite(uuid, refresh);` 89 | 90 | Params: 91 | * `uuid`: the String UUID of the sprite you wish to remove **(required)** 92 | * `refresh`: If you wish the navMesh to be re-calculated after removing the sprite (optional, default `true`) -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import js from "@eslint/js"; 3 | import stylisticJs from '@stylistic/eslint-plugin' 4 | 5 | export default [ 6 | { 7 | "ignores": ["dist/*", "webpack/*"], 8 | "languageOptions": { 9 | globals: { 10 | ...globals.browser, 11 | Phaser: false, 12 | }, 13 | }, 14 | "plugins": { 15 | '@stylistic/js': stylisticJs, 16 | }, 17 | "rules": { 18 | ...js.configs.recommended.rules, 19 | '@stylistic/js/no-multiple-empty-lines': ["error", {max: 1}], 20 | '@stylistic/js/indent': ['error', 2], 21 | 22 | 'max-lines': ['error', 300], 23 | "no-console": ["error", { allow: ["warn", "error", "time", "timeEnd"] }], 24 | "no-unused-vars": "error", 25 | }, 26 | }, 27 | ]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phaser-navmesh-generation", 3 | "version": "0.3.1", 4 | "main": "dist/navmesh-plugin.js", 5 | "keywords": [], 6 | "repository": "git@github.com:amaccann/phaser-navmesh-generation.git", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "yarn lint && webpack --config webpack/build.config.js", 10 | "deploy": "yarn build && yarn publish", 11 | "lint": "eslint", 12 | "watch": "webpack --watch", 13 | "start": "webpack-dev-server --open --config webpack/dev.config.js" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.26.0", 17 | "@babel/preset-env": "^7.26.0", 18 | "@stylistic/eslint-plugin": "^2.12.1", 19 | "babel-loader": "^9.2.1", 20 | "cdt2d": "^1.0.0", 21 | "clean-webpack-plugin": "^4.0.0", 22 | "eslint": "^9.17.0", 23 | "expose-loader": "^5.0.0", 24 | "globals": "^15.14.0", 25 | "phaser": "^3.87.0", 26 | "terser-webpack-plugin": "^5.3.11", 27 | "uuid": "^11.0.4", 28 | "webpack": "^5.97.1", 29 | "webpack-cli": "^6.0.1", 30 | "webpack-dev-server": "^5.2.0" 31 | }, 32 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 33 | } 34 | -------------------------------------------------------------------------------- /public/assets/agent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amaccann/phaser-navmesh-generation/5f9ba03b9d8678e1f46913f821990db775a2f6a6/public/assets/agent.png -------------------------------------------------------------------------------- /public/assets/tilemaps/tiles/ground_1x1.acorn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amaccann/phaser-navmesh-generation/5f9ba03b9d8678e1f46913f821990db775a2f6a6/public/assets/tilemaps/tiles/ground_1x1.acorn -------------------------------------------------------------------------------- /public/assets/tilemaps/tiles/ground_1x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amaccann/phaser-navmesh-generation/5f9ba03b9d8678e1f46913f821990db775a2f6a6/public/assets/tilemaps/tiles/ground_1x1.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Phaser NavMesh Generation 6 | 7 | 8 |
9 |

10 | Left click to draw random colliding tile to map; 11 | Right click to move sprites to that point on map; 12 | 'P' to pause; 13 | 'S' to toggle the 'sprite' dropper; 14 | 'K' to remove the last 'sprite' and refresh 15 |

16 | 17 | 18 | -------------------------------------------------------------------------------- /src/demo/demoSprite.js: -------------------------------------------------------------------------------- 1 | const SPEED = 125; 2 | 3 | const NINETY_DEGREES_IN_RADIANS = 1.5708; 4 | const DEFAULT_PATH = { 5 | offsetPath: [], 6 | path: [] 7 | }; 8 | 9 | export default class DemoSprite extends Phaser.GameObjects.Sprite { 10 | /** 11 | * @constructor 12 | * @param {Phaser.Game} game 13 | * @param {Number} x 14 | * @param {Number} y 15 | * @param {Phaser.Group} group 16 | */ 17 | constructor(scene, x, y, group) { 18 | super(scene, x, y, 'agent'); 19 | scene.add.existing(this); 20 | this.path = DEFAULT_PATH; 21 | // this.anchor.setTo(ANCHOR, ANCHOR); 22 | scene.physics.add.existing(this, 0); 23 | 24 | scene.children.bringToTop(this); 25 | group.add(this); 26 | 27 | } 28 | 29 | /** 30 | * @method addPath 31 | */ 32 | addPath(path = DEFAULT_PATH) { 33 | this.path = path; 34 | } 35 | 36 | /** 37 | * @method update 38 | */ 39 | update() { 40 | const { path, scene } = this; 41 | const offsetPath = path.offsetPath || []; 42 | 43 | if (!offsetPath.length) { 44 | return this.body.stop(); 45 | } 46 | 47 | const [ current ] = offsetPath; 48 | if (Phaser.Math.Distance.BetweenPoints(this, current) < 5) { 49 | path.offsetPath = offsetPath.slice(1); 50 | return; 51 | } 52 | 53 | this.rotation = Phaser.Math.Angle.BetweenPoints(this, current) + NINETY_DEGREES_IN_RADIANS// arcade.angleBetween(position, current) + NINETY_DEGREES_IN_RADIANS; 54 | scene.physics.moveToObject(this, current, SPEED); 55 | 56 | scene.physics.collide(this, scene.tileLayer); 57 | } 58 | } -------------------------------------------------------------------------------- /src/demo/demoState.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-lines */ 2 | 3 | import DemoSprite from './demoSprite'; 4 | 5 | const COLLISION_INDICES = [0, 1, 2]; 6 | const WIDTH_TILES = 40; 7 | const HEIGHT_TILES = 30; 8 | const TILE_SIZE = 32; 9 | let PATH_GRAPHICS; 10 | let timeout; 11 | 12 | export default class DemoState extends Phaser.Scene { 13 | /** 14 | * @method preload 15 | */ 16 | preload() { 17 | // Load the demo assets 18 | this.load.image('ground_1x1', 'assets/tilemaps/tiles/ground_1x1.png'); 19 | this.load.image('agent', 'assets/agent.png'); 20 | } 21 | 22 | /** 23 | * @method create 24 | */ 25 | create() { 26 | // Create blank tilemap 27 | this.tileMap = this.make.tilemap({key: 'map', tileWidth: TILE_SIZE, tileHeight: TILE_SIZE}); 28 | this.tileSet = this.tileMap.addTilesetImage('ground_1x1'); 29 | this.tileLayer = this.tileMap.createBlankLayer('Layer1', this.tileSet, 8, 8, WIDTH_TILES, HEIGHT_TILES) 30 | this.tileMap.setCollision(COLLISION_INDICES); 31 | 32 | this.drawAllGround(); 33 | this.drawInitGrid(); 34 | this.updateNavMesh(); 35 | 36 | // game.input.addMoveCallback(this.updateMarker, this); 37 | // game.input.on(this.onUp, this); 38 | this.input.on('pointerdown', this.onMouseUp, this) 39 | this.input.mouse.disableContextMenu() 40 | 41 | const cursors = this.input.keyboard.createCursorKeys(); 42 | const controlConfig = { 43 | camera: this.cameras.main, 44 | left: cursors.left, 45 | right: cursors.right, 46 | up: cursors.up, 47 | down: cursors.down, 48 | speed: 0.5 49 | }; 50 | 51 | this.controls = new Phaser.Cameras.Controls.FixedKeyControl(controlConfig); 52 | this.cameras.main.setBounds(0, 0, this.tileLayer.width, this.tileLayer.height); 53 | 54 | // this.isSpriteStamp = false; 55 | // this.spriteUUIDs = []; 56 | // game.input.keyboard.addKey(Keyboard.P).onDown.add(() => game.paused = !game.paused, this); 57 | // game.input.keyboard.addKey(Keyboard.SPACEBAR).onDown.add(() => game.paused = !game.paused, this); 58 | // this.input.keyboard.addKey(Keyboard.S).onDown.add(() => this.isSpriteStamp = !this.isSpriteStamp); 59 | // game.input.keyboard.addKey(Keyboard.K).onDown.add(() => this.removeSprite()); 60 | 61 | this.spriteGroup = new Phaser.GameObjects.Group(this); 62 | 63 | this.sprite = new DemoSprite(this, 200, 500, this.spriteGroup); 64 | } 65 | 66 | /** 67 | * @method drawAllGround 68 | */ 69 | drawAllGround() { 70 | const { tileLayer} = this; 71 | const { width, height } = tileLayer; 72 | let y = 0; 73 | let x; 74 | for (y; y < height; y++) { 75 | x = 0; 76 | for (x; x < width; x++) { 77 | tileLayer.putTileAt(24, x, y, false) 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * @method drawInitGrid 84 | * @description Draw a quick, 'enclosed' area 85 | */ 86 | drawInitGrid() { 87 | const { tileLayer } = this; 88 | const startAtX = 3; 89 | const startAtY = 3; 90 | const yLength = startAtY + 10; 91 | let y = startAtY; 92 | let xLength; 93 | let x; 94 | let tileIndex; 95 | 96 | for (y; y < yLength; y++) { 97 | x = startAtX; 98 | xLength = startAtX + 20; 99 | for (x; x < xLength; x++) { 100 | if (x !== startAtX && y !== startAtY && x !== xLength - 4 && y !== yLength - 1) { 101 | continue; 102 | } 103 | 104 | tileIndex = Math.floor(Phaser.Math.Between(0, COLLISION_INDICES.length - 1)); 105 | tileLayer.putTileAt(tileIndex, x, y, false); 106 | } 107 | } 108 | } 109 | 110 | /** 111 | * @method onRightClick 112 | */ 113 | onRightClick(e) { 114 | e.preventDefault(); 115 | } 116 | 117 | /** 118 | * @method onUp 119 | * @param {Phaser.Pointer} pointer 120 | */ 121 | onMouseUp(pointer) { 122 | const isRightButton = pointer.button === 2; 123 | const isLeftButton = pointer.button === 0; 124 | const { worldX, worldY } = pointer; 125 | 126 | switch (true) { 127 | case isLeftButton: 128 | return this.updateMarker(pointer); 129 | case isRightButton: 130 | return this.getNavMeshPath(new Phaser.Geom.Point(worldX, worldY)); 131 | default: 132 | return null; 133 | } 134 | } 135 | 136 | getNavMeshPath (destination) { 137 | const sprites = this.spriteGroup.getChildren() || []; 138 | sprites.forEach(sprite => { 139 | const { width, height } = sprite; 140 | const size = Math.max(width, height); 141 | const path = this.navMesh.getPath(sprite, destination, size); 142 | 143 | // If no path found, do nothing with the sprite 144 | if (!path) { 145 | return sprite.addPath([]); 146 | } 147 | 148 | // paths.push(path); 149 | this.sprite.addPath(path); 150 | this.renderPaths([path]) 151 | }, this); 152 | }; 153 | 154 | /** 155 | * @method buildNavMesh 156 | */ 157 | buildNavMesh() { 158 | const { tileMap, tileLayer } = this; 159 | 160 | this.navMesh = this.navMeshPlugin.buildFromTileLayer(tileMap, tileLayer, { 161 | collisionIndices: COLLISION_INDICES, 162 | timingInfo: true, 163 | midPointThreshold: 0, 164 | debug: { 165 | hulls: false, 166 | navMesh: true, 167 | navMeshNodes: true, 168 | polygonBounds: false, 169 | aStarPath: false 170 | } 171 | }); 172 | 173 | timeout = undefined; 174 | 175 | // game.world.bringToTop(this.spriteGroup); 176 | } 177 | 178 | /** 179 | * @method renderPaths 180 | */ 181 | renderPaths(paths = []) { 182 | if (!PATH_GRAPHICS) { 183 | PATH_GRAPHICS = this.add.graphics(0, 0); 184 | } else { 185 | PATH_GRAPHICS.clear(); 186 | } 187 | 188 | paths.forEach(data => { 189 | const { path, offsetPath } = data; 190 | const [pathStart, ...otherPathPoints] = path; 191 | const [offsetStart, ...otherOffsetPoints] = offsetPath; 192 | const [polygonStart, ...polygons] = data.polygons || []; 193 | 194 | function renderPoint(point) { 195 | PATH_GRAPHICS.lineTo(point.x, point.y); 196 | 197 | PATH_GRAPHICS.fillStyle(0xff0000); 198 | PATH_GRAPHICS.fillCircle(point.x, point.y, 10); 199 | PATH_GRAPHICS.moveTo(point.x, point.y); 200 | } 201 | 202 | // Render the PATHS 203 | PATH_GRAPHICS.fillStyle(0xff0000); 204 | PATH_GRAPHICS.fillCircle(pathStart.x, pathStart.y, 10); 205 | PATH_GRAPHICS.moveTo(pathStart.x, pathStart.y); 206 | 207 | PATH_GRAPHICS.lineStyle(2, 0x6666ff, 1); 208 | otherPathPoints.forEach(renderPoint); 209 | 210 | // Render the OFFSET PATHS 211 | PATH_GRAPHICS.fillStyle(0x00f000); 212 | PATH_GRAPHICS.fillCircle(offsetStart.x, offsetStart.y, 10); 213 | PATH_GRAPHICS.moveTo(offsetStart.x, offsetStart.y); 214 | 215 | PATH_GRAPHICS.lineStyle(2, 0x33ff33, 1); 216 | otherOffsetPoints.forEach(renderPoint); 217 | 218 | if (data?.polygons?.length) { 219 | PATH_GRAPHICS.fillStyle(0x3333ff, 0.25); 220 | PATH_GRAPHICS.fillPoints(polygonStart?.points); 221 | 222 | PATH_GRAPHICS.fillStyle(0xff3333, 0.25); 223 | polygons.forEach((poly) => { 224 | PATH_GRAPHICS.fillPoints(poly.points); 225 | }) 226 | } 227 | }); 228 | } 229 | 230 | /** 231 | * @method update 232 | */ 233 | update(time, delta) { 234 | this.controls.update(delta); 235 | this.sprite.update(); 236 | // this.spriteGroup.update(); 237 | } 238 | 239 | /** 240 | * @method updateMarker 241 | */ 242 | updateMarker({worldX, worldY } = {}) { 243 | const { tileLayer } = this; 244 | 245 | // if (this.isSpriteStamp) { 246 | // return this.updateSprite(leftButton, worldX, worldY); 247 | // } 248 | // this.stamp && this.stamp.clear(); 249 | 250 | const tileIndex = Math.floor(Phaser.Math.Between(0, COLLISION_INDICES.length - 1)); 251 | const tile = tileLayer.getTileAtWorldXY(worldX, worldY); 252 | if (tile) { 253 | tile.index = tileIndex; 254 | tileLayer.calculateFacesAt(tile.x, tile.y); 255 | // @TODO Update navmesh 256 | this.updateNavMesh(1000); 257 | } 258 | } 259 | 260 | /** 261 | * @method updateNavMesh 262 | * @description Update / rebuild the NavMesh 263 | */ 264 | updateNavMesh(delay = 0) { 265 | if (timeout) { 266 | clearTimeout(timeout); 267 | } 268 | timeout = setTimeout(() => this.buildNavMesh(), delay); 269 | } 270 | 271 | /** 272 | * @method updateSprite 273 | * @description Track the mouse movement and 'stamp' a test cluster onto the map 274 | */ 275 | updateSprite(leftButton, x, y) { 276 | const { tileWidth, tileHeight } = this.tileMap; 277 | const stampWidth = 3; 278 | const stampHeight = 2; 279 | 280 | if (!this.stamp) { 281 | this.stamp = this.game.add.graphics(0, 0); 282 | } else { 283 | this.stamp.clear(); 284 | } 285 | 286 | this.stamp.beginFill(0x0000ff, 0.8); 287 | const tileX = Math.floor(x / 32); 288 | const tileY = Math.floor(y / 32); 289 | this.stamp.drawRect(tileX * tileWidth, tileY * tileHeight, stampWidth * tileWidth, stampHeight * tileHeight); 290 | this.stamp.endFill(); 291 | 292 | if (!leftButton.isDown) { 293 | return; 294 | } 295 | 296 | const sprite = this.plugin.addSprite(tileX, tileY, stampWidth, stampHeight); 297 | if (sprite) { 298 | this.spriteUUIDs.push(sprite.uuid); 299 | } 300 | } 301 | 302 | /** 303 | * @method removeSprite 304 | * @description Pop and remove the last sprite 305 | */ 306 | removeSprite() { 307 | if (!this.spriteUUIDs.length) { 308 | return; 309 | } 310 | 311 | this.plugin.removeSprite(this.spriteUUIDs.pop()); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/demo/index.js: -------------------------------------------------------------------------------- 1 | import {Game} from 'phaser'; 2 | import DemoState from './demoState'; 3 | import NavMeshPlugin from '../lib/navMeshPlugin'; 4 | 5 | const config = { 6 | width: 1200, 7 | height: 900, 8 | physics: { default: 'arcade', 9 | arcade: { 10 | // gravity: { y: 200 }, 11 | debug: true 12 | } 13 | }, 14 | plugins: { 15 | scene: [ 16 | { key: 'NavMeshPlugin', plugin: NavMeshPlugin, mapping: 'navMeshPlugin' } 17 | ] 18 | }, 19 | scene: [ 20 | DemoState 21 | ], 22 | }; 23 | 24 | const game = new Game(config); 25 | console.warn('game', game); -------------------------------------------------------------------------------- /src/lib/astar/aStar.js: -------------------------------------------------------------------------------- 1 | import AStarPath from './aStarPath'; 2 | import PriorityQueue from './priorityQueue'; 3 | 4 | const SEARCH_CEILING = 1000; 5 | 6 | export default class AStar { 7 | /** 8 | * @constructor 9 | * @param {NavMesh} navMesh 10 | */ 11 | constructor(navMesh) { 12 | this.navMesh = navMesh; 13 | } 14 | 15 | /** 16 | * @method search 17 | * @description Taken from http://jceipek.com/Olin-Coding-Tutorials/pathing.html 18 | * @param {Phaser.Geom.Point} start 19 | * @param {Phaser.Geom.Point} end 20 | * @returns {NavMeshPolygon[]|Boolean} 21 | */ 22 | search(start, end) { 23 | const { navMesh } = this; 24 | const startPolygon = navMesh.getPolygonByXY(start.x, start.y); 25 | const endPolygon = navMesh.getPolygonByXY(end.x, end.y); 26 | const pathNodes = []; 27 | 28 | if (!startPolygon || !endPolygon) { 29 | return false; 30 | } 31 | 32 | const frontier = new PriorityQueue({ low: true }); // Still to explore 33 | const explored = []; 34 | 35 | const pathTo = {}; // Map keeping track of the path thus far 36 | const gCost = {}; // Map of the 'G cost' for traveling to each node from starting poly 37 | 38 | let MAIN_LOOP = 0; 39 | let isConnected = true; 40 | let i; 41 | let neighborsLength; 42 | let leafNode; 43 | 44 | pathTo[startPolygon.uuid] = null; 45 | gCost[startPolygon.uuid] = 0.0; 46 | 47 | frontier.push(startPolygon, startPolygon.distanceTo(endPolygon)); 48 | 49 | while (!frontier.empty()) { 50 | if (MAIN_LOOP >= SEARCH_CEILING) { 51 | console.error('TOO MANY WHILE CYCLES - GETTING OUTTA HERE'); 52 | isConnected = false; 53 | break; 54 | } 55 | 56 | leafNode = frontier.top(); 57 | 58 | // When we find the end poly, reconstruct the path 59 | if (leafNode === endPolygon) { 60 | let pointer = endPolygon; 61 | 62 | while (pointer !== null) { 63 | pathNodes.push(pointer); 64 | pointer = pathTo[pointer.uuid]; 65 | } 66 | 67 | break; 68 | } 69 | frontier.pop(); 70 | explored.push(leafNode); 71 | neighborsLength = leafNode.neighbors.length; 72 | i = 0; 73 | 74 | for (i; i < neighborsLength; i++) { 75 | const connectedNode = leafNode.neighbors[i]; 76 | const isExplored = explored.find(node => node === connectedNode); 77 | 78 | if (!isExplored && !frontier.includes(connectedNode)) { 79 | gCost[connectedNode.uuid] = gCost[leafNode.uuid] + leafNode.distanceTo(connectedNode); 80 | pathTo[connectedNode.uuid] = leafNode; 81 | frontier.push(connectedNode, gCost[connectedNode.uuid] + connectedNode.distanceTo(endPolygon)); 82 | } 83 | } 84 | 85 | MAIN_LOOP++; 86 | } 87 | 88 | return new AStarPath(pathNodes, { start, end, startPolygon, endPolygon, isConnected }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/lib/astar/aStarPath.js: -------------------------------------------------------------------------------- 1 | import Funnel from './funnel'; 2 | import Config from '../config'; 3 | import {v4} from 'uuid'; 4 | import { areLinesEqual } from '../utils'; 5 | 6 | export default class AStarPath { 7 | 8 | /** 9 | * @constructor 10 | * @param {NavMeshPolygon[]} polygons 11 | * @param {Object} options 12 | */ 13 | constructor(polygons = [], options = {}) { 14 | const { start, end, startPolygon, endPolygon, isConnected } = options; 15 | this.polygons = polygons; 16 | this.isConnected = isConnected; 17 | 18 | this.startPoint = start; 19 | this.endPoint = end; 20 | 21 | this.startPolygon = startPolygon; 22 | this.endPolygon = endPolygon; 23 | this.portals = []; 24 | this.uuid = v4(); 25 | 26 | this.initPortals(); 27 | this.initFunnel(); 28 | } 29 | 30 | /** 31 | * @method path 32 | */ 33 | get path() { 34 | return this.funnel.path; 35 | } 36 | 37 | /** 38 | * @method initPortals 39 | * @description Find the matching portal lines that take the Actor from startPoint to endPoint 40 | */ 41 | initPortals() { 42 | const { polygons } = this; 43 | const length = polygons.length; 44 | let i = 0; 45 | let node; 46 | let nextNode; 47 | let portal; 48 | 49 | this.portals = []; 50 | 51 | for (i; i < length; i++) { 52 | node = polygons[i]; 53 | nextNode = polygons[i + 1]; 54 | if (!nextNode) { 55 | continue; 56 | } 57 | 58 | // Find the matching Line segment in the next node along the path. 59 | portal = node.portals.find(portal => nextNode.portals.find(p => areLinesEqual(p, portal))); 60 | if (portal) { 61 | this.portals.push(portal); 62 | } 63 | } 64 | this.portals.reverse(); 65 | } 66 | 67 | /** 68 | * @method initFunnel 69 | */ 70 | initFunnel() { 71 | const { portals, startPoint, endPoint } = this; 72 | const midPointThreshold = Config.get('midPointThreshold'); 73 | const length = portals.length; 74 | let i = 0; 75 | 76 | this.funnel = new Funnel(midPointThreshold); 77 | this.funnel.add(startPoint); 78 | 79 | for (i; i < length; i++) { 80 | this.funnel.add(portals[i].getPointA(), portals[i].getPointB()); 81 | } 82 | 83 | this.funnel.add(endPoint); 84 | this.funnel.update(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/astar/funnel.js: -------------------------------------------------------------------------------- 1 | import Portal from './portal'; 2 | import { triarea2 } from '../utils'; 3 | import FunnelPoint from './funnelPoint'; 4 | 5 | export default class Funnel { 6 | 7 | /** 8 | * @constructor 9 | * @param {Number} midPointThreshold 10 | */ 11 | constructor(midPointThreshold) { 12 | this.path = []; 13 | this.portals = []; 14 | this.midPointThreshold = midPointThreshold; 15 | } 16 | 17 | /** 18 | * @method add 19 | * @param {Phaser.Geom.Point} left 20 | * @param {Phaser.Geom.Point} right 21 | */ 22 | add(left, right) { 23 | const { portals } = this; 24 | 25 | return portals.push(new Portal(left, right || left)); 26 | } 27 | 28 | /** 29 | * @method addPointToPath 30 | * @param {Phaser.Geom.Point} portal 31 | * @param {Boolean} isNarrow 32 | */ 33 | addPointToPath(portal, isNarrow = false) { 34 | const { path } = this; 35 | const exists = path.find(p => Phaser.Geom.Point.Equals(p, portal)); 36 | if (!exists) { 37 | path.push(new FunnelPoint(portal.x, portal.y, isNarrow)); 38 | } 39 | } 40 | 41 | /** 42 | * @method update 43 | * @description JS variant of http://digestingduck.blogspot.com/2010/03/simple-stupid-funnel-algorithm.html 44 | * @TODO Should check if there are any gaps, edges missing points, maybe we could fill with mid-point fallbacks... 45 | */ 46 | update() { 47 | const { path, portals, midPointThreshold } = this; 48 | const portalsLength = portals.length; 49 | 50 | let apexIndex = 0; 51 | let leftIndex = 0; 52 | let rightIndex = 0; 53 | 54 | let apex = portals[0].left; 55 | let portalLeft = portals[0].left; 56 | let portalRight = portals[0].right; 57 | let i = 1; 58 | let left; 59 | let right; 60 | 61 | this.addPointToPath(apex); 62 | 63 | // Reset values and make current apex as ${portal} 64 | /** 65 | * @function setApexAndReset 66 | * @param {Phaser.Geom.Point} point 67 | * @param {Boolean} isNarrow 68 | * @param {Number} index 69 | */ 70 | const setApexAndReset = (point, index, isNarrow = false) => { 71 | this.addPointToPath(point, isNarrow); 72 | apex = point; 73 | apexIndex = index; 74 | 75 | portalLeft = apex; 76 | portalRight = apex; 77 | leftIndex = apexIndex; 78 | rightIndex = apexIndex; 79 | i = apexIndex; 80 | }; 81 | 82 | for (i; i < portalsLength; i++) { 83 | left = portals[i].left; 84 | right = portals[i].right; 85 | if (portals[i].isTooNarrow(midPointThreshold)) { 86 | setApexAndReset(portals[i].midPoint, i, true); 87 | continue; 88 | } 89 | 90 | // Update right vertex. 91 | if (triarea2(apex, portalRight, right) <= 0.0) { 92 | if (Phaser.Geom.Point.Equals(apex, portalRight) || triarea2(apex, portalLeft, right) > 0.0) { 93 | portalRight = right; // Tighten the funnel 94 | rightIndex = i; 95 | } else { // Vertices crossed over, left so now be part of path 96 | setApexAndReset(portalLeft, leftIndex); 97 | continue; 98 | } 99 | } 100 | 101 | if (triarea2(apex, portalLeft, left) >= 0.0) { 102 | if (Phaser.Geom.Point.Equals(apex, portalLeft) || triarea2(apex, portalRight, left) < 0.0) { 103 | portalLeft = left; // Tighten the funnel. 104 | leftIndex = i; 105 | } else { // left crossed right, so right vertex now part of the path 106 | setApexAndReset(portalRight, rightIndex); 107 | continue; 108 | } 109 | } 110 | } 111 | 112 | if (!path.length || (! Phaser.Geom.Point.Equals(path[path.length - 1], portals[portalsLength - 1].left))) { 113 | this.addPointToPath(portals[portals.length - 1].left); 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/lib/astar/funnelPoint.js: -------------------------------------------------------------------------------- 1 | export default class FunnelPoint extends Phaser.Geom.Point { 2 | constructor(x, y, isNarrow = false) { 3 | super(x, y); 4 | this.isNarrow = isNarrow; 5 | } 6 | } -------------------------------------------------------------------------------- /src/lib/astar/portal.js: -------------------------------------------------------------------------------- 1 | export default class Portal { 2 | /** 3 | * @constructor 4 | * @param {Phaser.Geom.Point} left 5 | * @param {Phaser.Geom.Point} right 6 | */ 7 | constructor(left, right) { 8 | this.left = left; 9 | this.right = right; 10 | this.midPoint = Phaser.Geom.Point.GetCentroid([ left, right ]); 11 | this.length = Phaser.Geom.Line.Length(left, right); 12 | } 13 | 14 | /** 15 | * @method isTooNarrow 16 | * @description If this portal is considered too 'narrow' to conduct funneling, we'll simply use the midpoint instead 17 | * @param {Number} threshold 18 | * @return {Boolean} 19 | */ 20 | isTooNarrow(threshold) { 21 | return this.length && this.length < threshold; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/astar/priorityQueue.js: -------------------------------------------------------------------------------- 1 | function sortByLowest(a, b) { 2 | return b.priority - a.priority; 3 | } 4 | 5 | function sortByHighest(a, b) { 6 | return a.priority - b.priority; 7 | } 8 | 9 | export default class PriorityQueue { 10 | constructor(options = {}) { 11 | this.contents = []; 12 | 13 | this.sorted = false; 14 | this.sortStyle = options.low ? sortByLowest : sortByHighest; 15 | } 16 | 17 | /** 18 | * @method pop 19 | * @description Removes then returns the next element in the queue. 20 | */ 21 | pop() { 22 | if (!this.sorted) { 23 | this.sort(); 24 | } 25 | 26 | const element = this.contents.pop(); 27 | 28 | return element ? element.object : null; 29 | } 30 | 31 | /** 32 | * @method top 33 | * @description Returns the next element in the queue 34 | */ 35 | top() { 36 | if (!this.sorted) { 37 | this.sort(); 38 | } 39 | 40 | const element = this.contents[this.contents.length - 1]; 41 | 42 | return element ? element.object : null; 43 | } 44 | 45 | /** 46 | * @method includes 47 | * @description Checks if object is present in the queue 48 | * @return {Boolean} 49 | */ 50 | includes(object) { 51 | return !!this.contents.find(o => o === object); 52 | } 53 | 54 | /** 55 | * @method empty 56 | */ 57 | empty() { 58 | return this.contents.length === 0; 59 | } 60 | 61 | /** 62 | * @method push 63 | * @description Add a new object to the queue 64 | */ 65 | push(object, priority, sort = false) { 66 | this.contents.push({ object, priority }); 67 | this.sorted = false; 68 | 69 | if (sort) { 70 | this.sort(); 71 | } 72 | } 73 | 74 | sort() { 75 | this.contents.sort(this.sortStyle); 76 | this.sorted = true; 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/config.js: -------------------------------------------------------------------------------- 1 | import MapGrid from './map/grid'; 2 | 3 | const defaultConfig = { 4 | collisionIndices: [], 5 | midPointThreshold: 0, 6 | offsetHullsBy: 0.1, 7 | tileMap: null, 8 | tileLayer: null, 9 | timingInfo: false, 10 | useMidPoint: false 11 | }; 12 | 13 | class Config { 14 | constructor() { 15 | this._c = {...defaultConfig}; 16 | } 17 | 18 | /** 19 | * @method get 20 | * @param {String} key 21 | */ 22 | get(key) { 23 | return this._c[key]; 24 | } 25 | 26 | get mapGrid() { 27 | return MapGrid; 28 | } 29 | 30 | /** 31 | * @method getTileAt 32 | * @param {Number} x 33 | * @param {Number} y 34 | * @return {MapTile} 35 | */ 36 | getTileAt(x, y) { 37 | return MapGrid.getAt(x, y); 38 | } 39 | 40 | /** 41 | * @method gridDimensions 42 | */ 43 | get gridDimensions() { 44 | const { width, height } = MapGrid; 45 | return { width, height }; 46 | } 47 | 48 | /** 49 | * @method mapDimensions 50 | * @return {Object} 51 | */ 52 | get mapDimensions() { 53 | const {layer} = this._c.tileLayer; 54 | const { width, height, tileWidth, tileHeight, widthInPixels, heightInPixels } = layer; 55 | return { width, height, tileWidth, tileHeight, widthInPixels, heightInPixels }; 56 | } 57 | 58 | get tileLayer() { 59 | return this._c.tileLayer; 60 | } 61 | 62 | /** 63 | * @method set 64 | * @param {Object} config 65 | */ 66 | set(config = defaultConfig) { 67 | this._c = { ...defaultConfig, ...config }; 68 | MapGrid.copyFrom(config.tileLayer.layer.data); 69 | } 70 | } 71 | 72 | export default new Config(); -------------------------------------------------------------------------------- /src/lib/debug.js: -------------------------------------------------------------------------------- 1 | const types = [ 2 | 'hulls', 3 | 'navMesh', 4 | 'navMeshNodes', 5 | 'polygonBounds' 6 | ]; 7 | 8 | const DEBUG_DIAMETER = 5; 9 | const DEBUG_COLOUR_YELLOW = 0xffff00; 10 | 11 | const defaultOptions = {}; 12 | types.forEach(type => defaultOptions[type] = false); 13 | 14 | class Debug { 15 | constructor(options = {}) { 16 | this.set(null, null, options); 17 | } 18 | 19 | /** 20 | * @method draw 21 | * @param {DelaunayGenerator} delaunay 22 | */ 23 | draw(delaunay) { 24 | const { settings } = this; 25 | const { hulls, navMesh, navMeshNodes, polygonBounds } = settings; 26 | this.initGraphics(); 27 | 28 | if ((hulls || navMesh || navMeshNodes || polygonBounds) && delaunay) { 29 | this.drawDelaunay(delaunay); 30 | } 31 | } 32 | 33 | /** 34 | * @method tileDimensions 35 | */ 36 | get tileDimensions() { 37 | const { data } = this.tileLayer.layer; 38 | const tile = data[0][0]; 39 | const { width, height } = tile; 40 | 41 | return { width, height }; 42 | } 43 | 44 | /** 45 | * @method getWorldXY 46 | * @param {Phaser.Geom.Point|Object} point 47 | */ 48 | getWorldXY(point) { 49 | const { width, height } = this.tileDimensions; 50 | return { 51 | x: point.x * width, 52 | y: point.y * height 53 | }; 54 | } 55 | 56 | /** 57 | * @method drawDelaunay 58 | */ 59 | drawDelaunay(delaunay) { 60 | const { gfx, settings } = this; 61 | const { polygons } = delaunay; 62 | const { allEdges } = delaunay.hulls; 63 | gfx.clear(); 64 | 65 | function drawEdge(edge) { 66 | gfx.lineStyle(2, DEBUG_COLOUR_YELLOW); 67 | gfx.moveTo(edge.x1, edge.y1); 68 | gfx.lineTo(edge.x2, edge.y2); 69 | gfx.lineStyle(0); 70 | } 71 | 72 | /** 73 | * @description Render the hulls found using the Marching Squares algorithm 74 | */ 75 | if (settings.hulls) { 76 | allEdges.forEach(drawEdge, this); 77 | } 78 | 79 | /** 80 | * @method Render the Delaunay triangles generated... 81 | */ 82 | if (settings.navMesh) { 83 | gfx.lineStyle(1, 0xffffff, 1); 84 | polygons.forEach(({points}) => { 85 | // const text = this.scene.add.text(centroid.x, centroid.y, uuid, { fontFamily: 'Arial', fontSize: 16 }); 86 | // text.setOrigin(0.5); 87 | gfx.strokePoints(points); 88 | }); 89 | } 90 | 91 | /** 92 | * @description Render the connecting NavMesh nodes between triangles 93 | */ 94 | if (settings.navMeshNodes) { 95 | const lineWidth = 3; 96 | 97 | gfx.lineStyle(lineWidth, 0x00b2ff, 1); 98 | polygons.forEach((poly) => { 99 | poly.neighbors.forEach(neighbour => { 100 | gfx.beginPath(); 101 | gfx.moveTo(poly.centroid.x, poly.centroid.y); 102 | gfx.lineTo(neighbour.centroid.x, neighbour.centroid.y); 103 | gfx.closePath(); 104 | gfx.strokePath(); 105 | }); 106 | 107 | gfx.fillStyle(0xffff00); 108 | gfx.fillCircle(poly.centroid.x, poly.centroid.y, DEBUG_DIAMETER); 109 | }); 110 | } 111 | 112 | /** 113 | * @description Render the bounding circles of each NavMesh triangle 114 | */ 115 | if (settings.polygonBounds) { 116 | polygons.forEach(polygon => { 117 | gfx.lineStyle(2, DEBUG_COLOUR_YELLOW, 1); 118 | gfx.fillCircle(polygon.centroid.x, polygon.centroid.y, polygon.boundsRadius * 2) 119 | }); 120 | gfx.lineStyle(0, 0xffffff); 121 | } 122 | } 123 | 124 | /** 125 | * @method initGraphics 126 | */ 127 | initGraphics() { 128 | const { scene } = this; 129 | if (!this.gfx && !!scene) { 130 | this.gfx = scene.add.graphics(0, 0); 131 | } else { 132 | this.gfx.clear(); 133 | } 134 | } 135 | 136 | /** 137 | * @set 138 | * @param {Phaser.Game} scene 139 | * @param {Phaser.TilemapLayer} tileLayer 140 | * @param {Object} options 141 | */ 142 | set(scene, tileLayer, options = {}) { 143 | this.scene = scene; 144 | this.tileLayer = tileLayer; 145 | this.settings = Object.assign({}, defaultOptions, options); 146 | if (scene) { 147 | this.initGraphics(); 148 | } 149 | 150 | return this.settings; 151 | } 152 | } 153 | 154 | export default new Debug() -------------------------------------------------------------------------------- /src/lib/delaunay/cluster.js: -------------------------------------------------------------------------------- 1 | import { optimiseEdges } from '../utils'; 2 | import MarchingSquares from './marchingSquares'; 3 | import Config from '../config'; 4 | 5 | /** 6 | * @class Cluster 7 | */ 8 | export default class Cluster extends MarchingSquares { 9 | constructor(contours, edges, invert = false) { 10 | super(); 11 | 12 | this.polygon = new Phaser.Geom.Polygon(contours); 13 | this.edges = edges; 14 | this.invert = invert; 15 | 16 | optimiseEdges(this.edges); 17 | 18 | this.setBounds(); 19 | this.generate(); 20 | } 21 | 22 | /** 23 | * @method generate 24 | * @description 25 | */ 26 | generate() { 27 | const { invert } = this; 28 | 29 | this.children = []; 30 | super.generate((contours, edges) => { 31 | this.children.push(new Cluster(contours, edges, !invert)); 32 | }); 33 | } 34 | 35 | /** 36 | * @method getStartingPoint 37 | */ 38 | getStartingPoint() { 39 | const offsetPoint = new Phaser.Math.Vector2(); 40 | const { bounds } = this; 41 | const { x, y, width, height } = bounds; 42 | 43 | const yLength = y + height; 44 | const xLength = x + width; 45 | let yy = y; 46 | let xx; 47 | 48 | for (yy; yy < yLength; yy++) { 49 | xx = x; 50 | for (xx; xx < xLength; xx++) { 51 | offsetPoint.x = xx; 52 | offsetPoint.y = yy; 53 | if (this.isValidTile(xx, yy)) { 54 | return offsetPoint; 55 | } 56 | } 57 | } 58 | return null; 59 | } 60 | 61 | /** 62 | * @method get 63 | * @description Only return a Tile that is WITHIN this cluster's bounding-box 64 | * @param {Number} tileX 65 | * @param {Number} tileY 66 | */ 67 | get(tileX, tileY) { 68 | const { bounds } = this; 69 | const { x, y, width, height } = bounds; 70 | 71 | if (tileX < x || tileX > width + x || tileY < y || tileY > height + y) { 72 | return false; 73 | } 74 | 75 | return Config.getTileAt(tileX, tileY); 76 | } 77 | 78 | /** 79 | * @method isValidTile 80 | * @description Because we only want tiles WITHIN the Cluster, check first that it's actually part of that Cluster's 81 | * polygon and NOT simply one of the trailing tiles caught up within the bounding-box of the Cluster. 82 | * @param {Number} x 83 | * @param {Number} y 84 | */ 85 | isValidTile(x, y) { 86 | const collisionIndices = Config.get('collisionIndices'); 87 | const { invert, polygon } = this; 88 | const tile = this.get(x, y); 89 | 90 | if (!polygon.contains(x, y) || this.isChild(x, y) || !tile) { 91 | return false; 92 | } 93 | 94 | if (invert) { 95 | return collisionIndices.indexOf(tile.index) > -1 || tile.blocked; 96 | } else { 97 | return collisionIndices.indexOf(tile.index) === -1 && !tile.blocked; 98 | } 99 | } 100 | 101 | /** 102 | * @method isChild 103 | */ 104 | isChild(x, y) { 105 | const { children } = this; 106 | const length = children.length; 107 | let i = 0; 108 | 109 | for (i; i < length; i++) { 110 | if (children[i].polygon.contains(x, y)) { 111 | return true; 112 | } 113 | } 114 | return false; 115 | } 116 | 117 | /** 118 | * @method setBounds 119 | */ 120 | setBounds() { 121 | const [ first, ...rest ] = this.edges; 122 | const firstStart = first.getPointA(); 123 | const firstEnd = first.getPointB(); 124 | let startingX = Math.min(firstStart.x, firstEnd.x); 125 | let startingY = Math.min(firstStart.y, firstEnd.y); 126 | let endX = Math.max(firstStart.x, firstEnd.x); 127 | let endY = Math.max(firstStart.y, firstEnd.y); 128 | 129 | rest.forEach(edge => { 130 | const start = edge.getPointA(); 131 | const end = edge.getPointB(); 132 | const minX = Math.min(start.x, end.x); 133 | const minY = Math.min(start.y, end.y); 134 | const maxX = Math.max(start.x, end.x); 135 | const maxY = Math.max(start.y, end.y); 136 | 137 | startingX = minX < startingX ? minX : startingX; 138 | startingY = minY < startingY ? minY : startingY; 139 | endX = maxX > endX ? maxX : endX; 140 | endY = maxY > endY ? maxY : endY; 141 | }); 142 | 143 | this.bounds = { 144 | x: startingX, 145 | y: startingY, 146 | width: endX - startingX, 147 | height: endY - startingY 148 | }; 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /src/lib/delaunay/delaunayCluster.js: -------------------------------------------------------------------------------- 1 | import cdt2d from 'cdt2d'; 2 | import Config from '../config'; 3 | 4 | /** 5 | * @class DelaunayCluster 6 | * @description Takes a suite of edges & calculates Constrained Delaunay triangles based on provided gen. options 7 | */ 8 | export default class DelaunayCluster { 9 | /** 10 | * @constructor 11 | * @param {Phaser.Line[]} edges 12 | * @param {Phaser.Line[]} parentEdges 13 | * @param {Phaser.Line[]} allChildEdges 14 | * @param {Object} options 15 | */ 16 | constructor(edges = [], parentEdges = [], allChildEdges = [], options = {}) { 17 | this.points = []; 18 | this.edges = []; 19 | this.polygons = []; 20 | this.options = options; 21 | 22 | this.generate(edges, parentEdges, allChildEdges); 23 | } 24 | 25 | /** 26 | * @method addPoint 27 | * @description Adds new vertex point to Array. Returns index of newly pushed point, or existing. 28 | * (Note that we must take into account any Offset involved in the TilemapLayer) 29 | * @param {Phaser.Geom.Point} point 30 | * @return {Number} 31 | */ 32 | addPoint(point) { 33 | const {tileLayer} = Config; 34 | const {layer, x: offsetX, y: offsetY} = tileLayer; 35 | const {tileHeight, tileWidth} = layer || {}; 36 | const { x, y } = point; 37 | const { points } = this; 38 | 39 | const worldX = (x * tileWidth) + offsetX; 40 | const worldY = (y * tileHeight) + offsetY; 41 | 42 | const index = points.findIndex(p => p[0] === worldX && p[1] === worldY); 43 | if (index !== -1) { 44 | return index; 45 | } 46 | 47 | points.push([ worldX, worldY ]); 48 | return points.length - 1; 49 | } 50 | 51 | /** 52 | * @method generate 53 | * @param {Phaser.Line[]} polygonEdges 54 | * @param {Phaser.Line[]} parentEdges 55 | * @param {Phaser.Line[]} childClusterEdge 56 | */ 57 | generate(polygonEdges, parentEdges, childClusterEdge) { 58 | const { edges, points, options } = this; 59 | let startIndex; 60 | let endIndex; 61 | let midPointIndex; 62 | let delaunay; 63 | 64 | const addEdgeToPoints = edge => { 65 | const start = edge.getPointA(); 66 | const end = edge.getPointB(); 67 | startIndex = this.addPoint(start); 68 | endIndex = this.addPoint(end); 69 | if (Config.get('useMidPoint')) { 70 | midPointIndex = this.addPoint(Phaser.Geom.Line.GetMidPoint(edge)); 71 | } 72 | }; 73 | 74 | const addToEdges = () => { 75 | if (Config.get('useMidPoint')) { 76 | edges.push([ startIndex, midPointIndex ]); 77 | edges.push([ midPointIndex, endIndex]); 78 | } else { 79 | edges.push([ startIndex, endIndex ]); 80 | } 81 | }; 82 | 83 | parentEdges.forEach(addEdgeToPoints, this); 84 | 85 | childClusterEdge.forEach(edge => { 86 | addEdgeToPoints(edge); 87 | addToEdges(); 88 | }); 89 | 90 | polygonEdges.forEach(edge => { 91 | addEdgeToPoints(edge); 92 | addToEdges(); 93 | }); 94 | 95 | delaunay = cdt2d(points, edges, options) || []; 96 | this.polygons = delaunay.map(triangle => triangle.map(index => points[index])); 97 | } 98 | } -------------------------------------------------------------------------------- /src/lib/delaunay/delaunayGenerator.js: -------------------------------------------------------------------------------- 1 | import Hulls from './hulls'; 2 | import NavMeshPolygon from '../navMeshPolygon'; 3 | import {areLinesEqual, offsetEdges, sortLine} from '../utils'; 4 | import DelaunayCluster from './delaunayCluster'; 5 | import Config from '../config'; 6 | 7 | /** 8 | * @class DelaunayGenerator 9 | * @description Helper class to generate the delaunay triangles used in building the NavMesh 10 | */ 11 | export default class DelaunayGenerator { 12 | constructor() { 13 | this.points = []; 14 | this.polygons = []; 15 | } 16 | 17 | /** 18 | * @method generatePolygonEdges 19 | * @description Find all neighbours for each Polygon generated; we assume all are potentially connected given Delaunay 20 | * @param {NavMeshPolygon[]} polygons 21 | * 22 | */ 23 | calculateClusterNeighbours(polygons = []) { 24 | const polyLength = polygons.length; 25 | let i = 0; 26 | let polygon; 27 | let otherPolygon; 28 | 29 | for (i; i < polyLength; i++) { 30 | polygon = polygons[i]; 31 | 32 | for (let j = i + 1; j < polyLength; j++) { 33 | otherPolygon = polygons[j]; 34 | 35 | for (const edge of polygon.edges) { 36 | for (const otherEdge of otherPolygon.edges) { 37 | 38 | if (!areLinesEqual(edge, otherEdge)) { 39 | continue; 40 | } 41 | 42 | const sortedLine = sortLine(edge); 43 | const start = sortedLine.getPointA(); 44 | const end = sortedLine.getPointB(); 45 | 46 | polygon.addNeighbor(otherPolygon); 47 | otherPolygon.addNeighbor(polygon); 48 | 49 | polygon.addPortalFromEdge(edge, start, end); 50 | otherPolygon.addPortalFromEdge(otherEdge, start, end); 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * @method generate 59 | * @description Find (recursively) all outlines of Hulls in the map, and generate Delaunay triangulation from them 60 | */ 61 | generate() { 62 | const options = { exterior: false }; 63 | 64 | if (this.hulls) { 65 | this.hulls.generate(); 66 | } else { 67 | this.hulls = new Hulls(); 68 | } 69 | 70 | this.parseHullClusters(); 71 | 72 | /** 73 | * @method getOffsetChildEdges 74 | * @param {Cluster} cluster 75 | * @return {Phaser.Line[]} 76 | */ 77 | const getOffsetChildEdges = cluster => { 78 | const { children } = cluster; 79 | let edges = []; 80 | 81 | children.forEach(child => edges = edges.concat(offsetEdges(child.edges, false, cluster.children))); 82 | return edges; 83 | }; 84 | 85 | /** 86 | * @function parseCluster 87 | * @param {Cluster} cluster 88 | */ 89 | const parseCluster = cluster => { 90 | const clusterPolygons = []; 91 | 92 | cluster.children.forEach(child => { 93 | const parentEdges = cluster.edges; 94 | const edges = offsetEdges(child.edges, true, child.children); 95 | const allChildEdges = getOffsetChildEdges(child); 96 | const { polygons } = new DelaunayCluster(edges, parentEdges, allChildEdges, options); 97 | 98 | polygons.forEach(poly => clusterPolygons.push(new NavMeshPolygon(poly))); 99 | 100 | if (child.children.length) { 101 | child.children.forEach(parseCluster); 102 | } 103 | }); 104 | 105 | this.polygons = this.polygons.concat(clusterPolygons); 106 | this.calculateClusterNeighbours(clusterPolygons); 107 | }; 108 | 109 | this.hulls.clusters.forEach(parseCluster); 110 | } 111 | 112 | /** 113 | * @method parseHullClusters 114 | * @description Create initial triangulation of "root" clusters of hulls 115 | */ 116 | parseHullClusters() { 117 | const { hulls } = this; 118 | const { width, height } = Config.mapDimensions; 119 | const parentEdges = [ 120 | new Phaser.Geom.Line(0, 0, width, 0), 121 | new Phaser.Geom.Line(width, 0, width, height), 122 | new Phaser.Geom.Line(width, height, 0, height), 123 | new Phaser.Geom.Line(0, height, 0, 0) 124 | ]; 125 | let edges = []; 126 | 127 | this.polygons = []; 128 | hulls.clusters.forEach(cluster => edges = edges.concat(offsetEdges(cluster.edges, false, hulls.clusters))); 129 | 130 | const { polygons } = new DelaunayCluster(edges, parentEdges, [], { interior: false }); 131 | polygons.forEach(p => this.polygons.push(new NavMeshPolygon(p))); 132 | this.calculateClusterNeighbours(this.polygons); 133 | } 134 | } -------------------------------------------------------------------------------- /src/lib/delaunay/edgePoint.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class EdgePoint 3 | * @extends Phaser.Geom.Point 4 | */ 5 | export default class EdgePoint extends Phaser.Math.Vector2 { 6 | constructor(point) { 7 | super(point.x, point.y); 8 | this.sources = [point]; 9 | } 10 | 11 | /** 12 | * @method addSource 13 | * @description Add a reference to a matching {Phaser.Geom.Point} object 14 | * @param {Phaser.Geom.Point} point 15 | */ 16 | addSource(point) { 17 | this.sources.push(point); 18 | } 19 | 20 | /** 21 | * @method updateSources 22 | * @description Update all the source {Phaser.Geom.Point] instances that were originally matched to this. 23 | */ 24 | updateSources() { 25 | const { sources, x, y } = this; 26 | sources.forEach(point => point.setTo(x, y)); 27 | } 28 | } -------------------------------------------------------------------------------- /src/lib/delaunay/hulls.js: -------------------------------------------------------------------------------- 1 | import MarchingSquares from './marchingSquares'; 2 | import Cluster from './cluster'; 3 | import Config from '../config'; 4 | 5 | /** 6 | * @class Hulls 7 | */ 8 | export default class Hulls extends MarchingSquares { 9 | /** 10 | * @constructor 11 | */ 12 | constructor() { 13 | super(); 14 | this.generate(); 15 | } 16 | 17 | /** 18 | * @method generate 19 | * @description Recursively search for outline clusters across the grid; if a cluster is found then 20 | * the interior of that Cluster is checked for any 'reverse' 21 | */ 22 | generate() { 23 | this.clusters = []; 24 | super.generate((contours, edges) => { 25 | this.clusters.push(new Cluster(contours, edges)); 26 | }); 27 | 28 | this.extractAllEdges(); 29 | } 30 | 31 | /** 32 | * @method extractAllEdges 33 | * @description Extract all edges from all clusters 34 | */ 35 | extractAllEdges() { 36 | const { width, height, tileWidth, tileHeight } = Config.mapDimensions; 37 | const w = width * tileWidth; 38 | const h = height * tileHeight; 39 | this.allEdges = [ 40 | new Phaser.Geom.Line(0, 0, w, 0), 41 | new Phaser.Geom.Line(w, 0, w, h), 42 | new Phaser.Geom.Line(w, h, 0, h), 43 | new Phaser.Geom.Line(0, h, 0, 0) 44 | ]; 45 | 46 | const parseCluster = ({ children, edges }) => { 47 | this.allEdges = this.allEdges.concat(edges.map((edge) => { 48 | const start = new Phaser.Math.Vector2(edge.x1, edge.y1); 49 | const end = new Phaser.Math.Vector2(edge.x2, edge.y2); 50 | 51 | return { 52 | start: start.multiply(tileWidth, tileHeight), 53 | end: end.multiply(tileWidth, tileHeight) 54 | }; 55 | })); 56 | children.forEach(parseCluster); 57 | }; 58 | 59 | this.clusters.forEach(parseCluster); 60 | } 61 | 62 | /** 63 | * @method getStartingPoint 64 | */ 65 | getStartingPoint() { 66 | const { width, height } = Config.gridDimensions; 67 | let y = 0; 68 | let x; 69 | const offsetPoint = new Phaser.Geom.Point(); 70 | 71 | for (y; y < height; y++) { 72 | x = 0; 73 | for (x; x < width; x++) { 74 | offsetPoint.x = x; 75 | offsetPoint.y = y; 76 | if (this.isValidTile(x, y)) { 77 | return offsetPoint; 78 | } 79 | } 80 | } 81 | return null; 82 | } 83 | 84 | /** 85 | * @method isValidTile 86 | * @description If the x|y coordinate is already within a cluster polygon, therefore it's already 87 | * part of a discovered outline of a chunk, so it's safe to ignore 88 | */ 89 | isValidTile(x, y) { 90 | const collisionIndices = Config.get('collisionIndices'); 91 | 92 | if (this.isPartOfCluster(x, y)) { 93 | return false; 94 | } 95 | 96 | const tile = this.get(x, y); 97 | return tile && (collisionIndices.indexOf(tile.index) > -1 || tile.blocked); 98 | } 99 | 100 | /** 101 | * @method isPartOfCluster 102 | */ 103 | isPartOfCluster(x, y) { 104 | const { clusters } = this; 105 | const length = clusters.length; 106 | let i = 0; 107 | for (i; i < length; i++) { 108 | if (clusters[i].polygon.contains(x, y)) { 109 | return true; 110 | } 111 | } 112 | return false; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/lib/delaunay/marchingSquares.js: -------------------------------------------------------------------------------- 1 | import Config from '../config'; 2 | 3 | const WHILE_CEILING = 500; 4 | const DIRECTIONS = { 5 | NONE: 0, 6 | UP: -1, 7 | LEFT: -1, 8 | RIGHT: 1, 9 | DOWN: 1 10 | }; 11 | 12 | export default class MarchingSquares { 13 | /** 14 | * @method generate 15 | * @param {Function} onClusterFound 16 | */ 17 | generate(onClusterFound) { 18 | let CEILING_LOOP = 0; 19 | let walked = []; 20 | let contours; 21 | 22 | // Once generate() returns no points => IE, no new clusters found, break out of the do/while 23 | do { 24 | walked = this.walkPerimeter() || {}; 25 | contours = walked.contours || []; 26 | 27 | if (contours.length) { 28 | onClusterFound(contours, walked.lines); 29 | } 30 | 31 | CEILING_LOOP++; 32 | if (CEILING_LOOP >= WHILE_CEILING) { 33 | break; 34 | } 35 | } while (contours.length); 36 | } 37 | 38 | /** 39 | * @method getSquareValue 40 | * @param {Point} point 41 | * @description Evalulate the square value for a 2x2 grid, the 'actual' tile as bottom-right 42 | */ 43 | getSquareValue(point) { 44 | const { x, y } = point; 45 | let squareValue = 0; 46 | 47 | // checking upper left pixel 48 | if (this.isValidTile(x - 1, y - 1)) { // UPPER-LEFT 49 | squareValue += 1; 50 | } 51 | // checking upper pixel 52 | if (this.isValidTile(x, y - 1)) { // UP 53 | squareValue += 2; 54 | } 55 | 56 | if (this.isValidTile(x - 1, y)) { // LEFT 57 | squareValue += 4; 58 | } 59 | 60 | if (this.isValidTile(x, y)) { 61 | squareValue += 8; 62 | } 63 | return squareValue; 64 | } 65 | 66 | /** 67 | * @description MUST be defined by the class extending this 68 | */ 69 | getStartingPoint() {} 70 | 71 | /** 72 | * @method get 73 | */ 74 | get(x, y) { 75 | return Config.getTileAt(x, y); 76 | } 77 | 78 | /** 79 | * @method isValidTile 80 | * @description MUST be defined in the inheriting class 81 | */ 82 | isValidTile() {} 83 | 84 | /** 85 | * @method walkPerimeter 86 | * @description Find a cluster of colliding tiles, and create a Polygon from these tile coordinates 87 | */ 88 | walkPerimeter() { 89 | const startPoint = this.getStartingPoint(); 90 | const currentPoint = new Phaser.Math.Vector2(); 91 | const contours = []; 92 | const lines = []; 93 | let clone; 94 | let LOOP_CEILING = 0; 95 | 96 | if (!startPoint) { 97 | return null; 98 | } 99 | 100 | currentPoint.copy(startPoint); 101 | const step = new Phaser.Math.Vector2(); 102 | const previous = new Phaser.Math.Vector2(); // Save the previous step... 103 | 104 | let closed = false; 105 | while (!closed) { 106 | const squareValue = this.getSquareValue(currentPoint); 107 | switch (squareValue) { 108 | // UP 109 | case 1 : 110 | case 5 : 111 | case 13 : 112 | step.setTo(DIRECTIONS.NONE, DIRECTIONS.UP); 113 | break; 114 | // DOWN 115 | case 8 : 116 | case 10 : 117 | case 11 : 118 | step.setTo(DIRECTIONS.NONE, DIRECTIONS.DOWN); 119 | break; 120 | // LEFT 121 | case 4 : 122 | case 12 : 123 | case 14 : 124 | step.setTo(DIRECTIONS.LEFT, DIRECTIONS.NONE); 125 | break; 126 | // RIGHT 127 | case 2 : 128 | case 3 : 129 | case 7 : 130 | step.setTo(DIRECTIONS.RIGHT, DIRECTIONS.NONE); 131 | break; 132 | case 6 : 133 | // SPECIAL DIAGONAL CASE #1 134 | if (previous.x === DIRECTIONS.NONE && previous.y === DIRECTIONS.UP) { 135 | step.setTo(DIRECTIONS.LEFT, DIRECTIONS.NONE); 136 | } else { 137 | step.setTo(DIRECTIONS.RIGHT, DIRECTIONS.NONE); 138 | } 139 | break; 140 | case 9 : 141 | // SPECIAL DIAGONAL CASE #2 142 | if (previous.x === DIRECTIONS.RIGHT && previous.y === DIRECTIONS.NONE) { 143 | step.setTo(DIRECTIONS.NONE, DIRECTIONS.LEFT); 144 | } else { 145 | step.setTo(DIRECTIONS.NONE, DIRECTIONS.RIGHT); 146 | } 147 | break; 148 | } 149 | 150 | clone = currentPoint.clone(); 151 | contours.push(clone); // save contour 152 | currentPoint.add(step); 153 | 154 | // Create a line from the current point & next one along... 155 | lines.push(new Phaser.Geom.Line(clone.x, clone.y, currentPoint.x, currentPoint.y)); 156 | 157 | previous.copy(step); 158 | 159 | // If we return to first point, loop is done. 160 | if (currentPoint.equals(startPoint)) { 161 | closed = true; 162 | } 163 | 164 | LOOP_CEILING++; 165 | if (LOOP_CEILING >= WHILE_CEILING) { 166 | closed = true; 167 | } 168 | } 169 | 170 | // If no contour points found, then there were no passing clusters found 171 | return { contours, lines }; 172 | } 173 | } -------------------------------------------------------------------------------- /src/lib/map/grid.js: -------------------------------------------------------------------------------- 1 | import MapTile from './tile'; 2 | import MapSprite from './sprite'; 3 | 4 | /** 5 | * @class MapGrid 6 | * @description Contains a flattened, 1D Array copy of the Phaser.TilemapLayer imported from copyFrom() 7 | */ 8 | class MapGrid { 9 | /** 10 | * @constructor 11 | */ 12 | constructor() { 13 | this.data = []; 14 | this.sprites = []; 15 | this.width = null; 16 | this.height = null; 17 | } 18 | 19 | /** 20 | * @method findSprite 21 | * @param {Number} x 22 | * @param {Number} y 23 | * @param {Number} width 24 | * @param {Number} height 25 | */ 26 | findSprite(x, y, width, height) { 27 | return this.sprites.find(s => s.x === x && s.y === y && s.width === width && s.height === height); 28 | } 29 | 30 | /** 31 | * @method get 32 | */ 33 | get() { 34 | return this.data; 35 | } 36 | 37 | /** 38 | * @method getAt 39 | * @param {Number} x 40 | * @param {Number} y 41 | */ 42 | getAt(x, y) { 43 | const i = y * this.width + x; 44 | return this.get()[i]; 45 | } 46 | 47 | /** 48 | * @method copyFrom 49 | * @param {Array} tileLayer 50 | */ 51 | copyFrom(tileLayer = []) { 52 | if (!tileLayer.length) { 53 | return; 54 | } 55 | 56 | this.data = []; 57 | this.width = tileLayer[0].length; 58 | this.height = tileLayer.length; 59 | let y = 0; 60 | let x; 61 | 62 | for (y; y < this.height; y++) { 63 | x = 0; 64 | for (x; x < this.width; x++) { 65 | this.data.push(new MapTile(tileLayer[y][x])); 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * @method addSprite 72 | * @description Add a 'sprite' that acts as a blocker within the map-grid 73 | */ 74 | addSprite(x, y, width, height) { 75 | const tiles = []; 76 | const yLength = y + height; 77 | const xLength = x + width; 78 | let yy = y; 79 | let xx; 80 | let tile; 81 | 82 | // If we already added a sprite with these exact dimensions, ignore 83 | if (this.findSprite(x, y, width, height)) { 84 | return false; 85 | } 86 | 87 | for (yy; yy < yLength; yy++) { 88 | xx = x; 89 | for (xx; xx < xLength; xx++) { 90 | tile = this.getAt(xx, yy); 91 | if (tile) { 92 | tile.blocked = true; 93 | tiles.push(tile); 94 | } 95 | } 96 | } 97 | 98 | const sprite = new MapSprite(x, y, width, height, tiles); 99 | this.sprites.push(sprite); 100 | 101 | return sprite; 102 | } 103 | 104 | /** 105 | * @method removeSprite 106 | * @description Remove sprite, toggle tiles back to false (but only those not overlapping with other sprites) 107 | * @param {String} uuid 108 | */ 109 | removeSprite(uuid) { 110 | const { tiles } = this.sprites.find(sprite => sprite.uuid === uuid); 111 | let overlapping; 112 | 113 | // First, remove the sprite matching the provided UUID 114 | this.sprites = this.sprites.filter(sprite => sprite.uuid !== uuid); 115 | 116 | // Now, iterate through the removed Sprite's tiles and toggle to FALSE only those NOT also in other Sprites 117 | tiles.forEach(tile => { 118 | overlapping = this.sprites.find(sprite => sprite.tiles.find(t => t.x === tile.x && t.y === tile.y)); 119 | if (!overlapping) { 120 | tile.blocked = false; 121 | } 122 | }); 123 | } 124 | 125 | /** 126 | * @method toggleBlocked 127 | * @description Set the tile to ${blocked} if param present, otherwise toggle the value 128 | * @param {Number} x 129 | * @param {Number} y 130 | * @param {Boolean} blocked 131 | */ 132 | toggleBlocked(x, y, blocked) { 133 | const tile = this.getAt(x, y); 134 | if (tile) { 135 | tile.blocked = blocked !== undefined ? blocked : !tile.blocked; 136 | } 137 | } 138 | 139 | } 140 | 141 | export default new MapGrid(); -------------------------------------------------------------------------------- /src/lib/map/sprite.js: -------------------------------------------------------------------------------- 1 | import {v4} from 'uuid'; 2 | 3 | export default class MapSprite { 4 | constructor(x, y, width, height, tiles = []) { 5 | this.x = x; 6 | this.y = y; 7 | this.width = width; 8 | this.height = height; 9 | this.uuid = v4(); 10 | this.tiles = tiles; 11 | } 12 | } -------------------------------------------------------------------------------- /src/lib/map/tile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class MapTile 3 | */ 4 | export default class MapTile { 5 | /** 6 | * @constructor 7 | * @param {Object} tile 8 | */ 9 | constructor({ x, y, index }) { 10 | this.x = x; 11 | this.y = y; 12 | this.index = index; 13 | this.blocked = false; 14 | } 15 | } -------------------------------------------------------------------------------- /src/lib/navMesh.js: -------------------------------------------------------------------------------- 1 | 2 | import AStar from './astar/aStar'; 3 | import Debug from './debug'; 4 | import { offsetFunnelPath } from './utils'; 5 | import DelaunayGenerator from './delaunay/delaunayGenerator'; 6 | import Config from './config'; 7 | 8 | /** 9 | * @class NavMesh 10 | */ 11 | export default class NavMesh { 12 | constructor(game) { 13 | this.game = game; 14 | 15 | this.delaunay = new DelaunayGenerator(); 16 | this.generate(); 17 | } 18 | 19 | /** 20 | * @method generate 21 | */ 22 | generate() { 23 | const timerName = '[NavMeshPlugin] 🛠 Building NavMesh. Beep Boop Boop 🤖'; 24 | const collisionIndices = Config.get('collisionIndices'); 25 | 26 | if (!collisionIndices || !collisionIndices.length) { 27 | console.error('[NavMeshPlugin] No collision-indices found, cannot generate NavMesh. Exiting...'); 28 | } 29 | 30 | Config.get('timingInfo') && console.time(timerName); 31 | this.delaunay.generate(); 32 | this.aStar = new AStar(this); // Calculate the a-star grid for the polygons. 33 | this.updatedAt = Date.now(); 34 | 35 | Config.get('timingInfo') && console.timeEnd(timerName); 36 | Debug.draw(this.delaunay); 37 | } 38 | 39 | /** 40 | * @method getPath 41 | * @param {Phaser.Geom.Point} startPosition 42 | * @param {Phaser.Geom.Point} endPosition 43 | * @param {Number} offset 44 | */ 45 | getPath(startPosition, endPosition, offset) { 46 | const timerName = '[NavMeshPlugin] 🛠 Search for optimal path...'; 47 | const { aStar } = this; 48 | Config.get('timingInfo') && console.time(timerName); 49 | 50 | const aStarPath = aStar.search(startPosition, endPosition); 51 | if (!aStarPath) { 52 | Config.get('timingInfo') && console.timeEnd(timerName); 53 | return false; 54 | } 55 | 56 | const { path, polygons, uuid } = aStarPath; 57 | const offsetPath = offsetFunnelPath(path, offset); 58 | const createdAt = Date.now(); 59 | Config.get('timingInfo') && console.timeEnd(timerName); 60 | 61 | return { createdAt, path, polygons, offsetPath, uuid }; 62 | } 63 | 64 | /** 65 | * @method getPolygonByXY 66 | */ 67 | getPolygonByXY(x, y) { 68 | return this.delaunay.polygons.find(polygon => polygon.contains(x, y)); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/navMeshPlugin.js: -------------------------------------------------------------------------------- 1 | import NavMesh from './navMesh'; 2 | import Config from './config'; 3 | import Debug from './debug'; 4 | 5 | function err() { 6 | return console.error('[NavMeshPlugin] no TileMap / TileLayer found'); 7 | } 8 | 9 | export default class NavMeshPlugin extends Phaser.Plugins.ScenePlugin { 10 | constructor(game, manager) { 11 | super(game, manager); 12 | } 13 | 14 | /** 15 | * @method buildFromTileLayer 16 | * @param {Tilemap} tileMap 17 | * @param {TilemapLayer} tileLayer 18 | * @param {Object} options 19 | */ 20 | buildFromTileLayer(tileMap, tileLayer, options = {}) { 21 | if (!tileMap || !tileLayer) { 22 | return err(); 23 | } 24 | 25 | Config.set({ tileMap, tileLayer, ...options }); 26 | Debug.set(this.scene, tileLayer, options.debug); 27 | 28 | if (this.navMesh) { 29 | this.navMesh.generate(); 30 | } else { 31 | this.navMesh = new NavMesh(this.game); 32 | } 33 | 34 | return this.navMesh; 35 | } 36 | 37 | /** 38 | * @method addSprite 39 | * @param {Number} x 40 | * @param {Number} y 41 | * @param {Number} width 42 | * @param {Number} height 43 | * @param {Boolean} refresh 44 | */ 45 | addSprite(x, y, width, height, refresh = true) { 46 | const tileLayer = Config.get('tileLayer'); 47 | if (!tileLayer) { 48 | return err(); 49 | } 50 | 51 | const sprite = Config.mapGrid.addSprite(x, y, width, height); 52 | if (sprite && refresh) { 53 | this.navMesh.generate(); 54 | } 55 | 56 | return sprite; 57 | } 58 | 59 | /** 60 | * @method removeSprite 61 | * @param {String} uuid 62 | * @param {Boolean} refresh 63 | */ 64 | removeSprite(uuid, refresh = true) { 65 | const tileLayer = Config.get('tileLayer'); 66 | if (!tileLayer) { 67 | return err(); 68 | } 69 | 70 | Config.mapGrid.removeSprite(uuid); 71 | if (refresh) { 72 | this.navMesh.generate(); 73 | } 74 | } 75 | } 76 | 77 | window.NavMeshPlugin = NavMeshPlugin; -------------------------------------------------------------------------------- /src/lib/navMeshPolygon.js: -------------------------------------------------------------------------------- 1 | import {v4} from 'uuid'; 2 | import { angleDifference } from './utils'; 3 | 4 | const getAngle = (a, b) => Phaser.Math.Angle.BetweenPoints(a, b); 5 | 6 | export default class NavMeshPolygon extends Phaser.Geom.Polygon { 7 | constructor(points = []) { 8 | super(points); 9 | 10 | this.centroid = Phaser.Geom.Point.GetCentroid(this.points); 11 | this.edges = []; 12 | this.neighbors = []; 13 | this.portals = []; 14 | this.uuid = v4(); 15 | this.initialiseEdges(); 16 | this.initialiseRadius(); 17 | } 18 | 19 | /** 20 | * @method addNeighbor 21 | */ 22 | addNeighbor(polygon) { 23 | this.neighbors.push(polygon); 24 | } 25 | 26 | /** 27 | * @method addPortalFromEdge 28 | * @description build a portal from an edge 29 | * @param {Phaser.Line} edge 30 | * @param {Phaser.Geom.Point} point1 31 | * @param {Phaser.Geom.Point} point2 32 | */ 33 | addPortalFromEdge(edge, point1, point2) { 34 | const { centroid, portals } = this; 35 | 36 | const edgeStartAngle = getAngle(centroid, edge.getPointA()); 37 | 38 | const angleToStart = getAngle(centroid, point1); 39 | const angleToEnd = getAngle(centroid, point2); 40 | 41 | const d1 = angleDifference(edgeStartAngle, angleToStart); 42 | const d2 = angleDifference(edgeStartAngle, angleToEnd); 43 | 44 | if (d1 > d2) { 45 | portals.push(new Phaser.Geom.Line(point1.x, point1.y, point2.x, point2.y)); 46 | } else { 47 | portals.push(new Phaser.Geom.Line(point2.x, point2.y, point1.x, point1.y)); 48 | } 49 | } 50 | 51 | /** 52 | * @method distanceTo 53 | * @param {NavMeshPolygon} polygon 54 | * @return {Number} 55 | */ 56 | distanceTo(polygon) { 57 | const distance = Phaser.Math.Distance.BetweenPoints(this.centroid, polygon.centroid); 58 | return distance; 59 | } 60 | 61 | /** 62 | * @method initialiseEdges 63 | * @description Loop through Polygon points and calculate 'edges' (ie, Lines connecting each vertex) 64 | */ 65 | initialiseEdges() { 66 | const { points } = this; 67 | const length = points.length; 68 | let i = 0; 69 | let j; 70 | 71 | for (i; i < length; i++) { 72 | j = i; 73 | 74 | for (j; j < length; j++) { 75 | if (i !== j) { 76 | this.edges.push(new Phaser.Geom.Line(points[i].x, points[i].y, points[j].x, points[j].y)); 77 | } 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * @method initialiseRadius 84 | */ 85 | initialiseRadius() { 86 | const { centroid, points } = this; 87 | const length = points.length; 88 | let boundingRadius = 0; 89 | let point; 90 | let i = 0; 91 | let d; 92 | 93 | for (i; i < length; i++) { 94 | point = points[i]; 95 | d = Phaser.Math.Distance.BetweenPoints(centroid, point); 96 | if (d > boundingRadius) { 97 | boundingRadius = d; 98 | } 99 | } 100 | this.boundsRadius = d; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | import EdgePoint from './delaunay/edgePoint'; 2 | import Config from './config'; 3 | 4 | const THREE_SIXTY_DEGREES = Math.PI * 2; 5 | 6 | export function areLinesEqual(line1, line2) { 7 | // return Phaser.Geom.Line.Equals(line1, line2); 8 | const line1Start = line1.getPointA(); 9 | const line1End = line1.getPointB(); 10 | const line2Start = line2.getPointA(); 11 | const line2End = line2.getPointB(); 12 | 13 | const startEqual = Phaser.Geom.Point.Equals(line1Start, line2Start) || Phaser.Geom.Point.Equals(line1Start, line2End); 14 | const endEqual = Phaser.Geom.Point.Equals(line1End, line2Start) || Phaser.Geom.Point.Equals(line1End, line2End); 15 | 16 | return startEqual && endEqual; 17 | } 18 | 19 | export function getRandomColour() { 20 | return Phaser.Color.HSLtoRGB(Math.random(), 1, 0.5).color; 21 | } 22 | 23 | /** 24 | * @function triarea2 25 | * @description 26 | */ 27 | export function triarea2(a, b, c) { 28 | const ax = b.x - a.x; 29 | const ay = b.y - a.y; 30 | const bx = c.x - a.x; 31 | const by = c.y - a.y; 32 | 33 | return bx * ay - ax * by; 34 | } 35 | 36 | /** 37 | * @description https://stackoverflow.com/questions/1878907/the-smallest-difference-between-2-angles 38 | */ 39 | export function angleDifference(x, y) { 40 | let diff = x - y; 41 | const i = diff + Math.PI; 42 | 43 | diff = i - Math.floor(i / THREE_SIXTY_DEGREES) * THREE_SIXTY_DEGREES; 44 | 45 | return diff - Math.PI; 46 | } 47 | 48 | /** 49 | * @method sortLine 50 | * @description Sort a line by its end-points. Prioritise first by the X values, but if they're equal, sort by Y 51 | */ 52 | export function sortLine(line) { 53 | const start = line.getPointA(); 54 | const end = line.getPointB(); 55 | if (end.x < start.x) { 56 | return new Phaser.Geom.Line(end.x, end.y, start.x, start.y); 57 | } else if (end.x > start.x) { 58 | return line; 59 | } else if (end.y < start.y) { 60 | return new Phaser.Geom.Line(end.x, end.y, start.x, start.y); 61 | } 62 | return line; 63 | } 64 | 65 | /** 66 | * @function getCrossProduct 67 | * @description Calculate the cross-product between a corner (3 points) 68 | * @param {Phaser.Geom.Point} point 69 | * @param {Phaser.Geom.Point} previous 70 | * @param {Phaser.Geom.Point} next 71 | */ 72 | export function getCrossProduct(point, previous, next) { 73 | const vector1 = new Phaser.Math.Vector2(point.x - previous.x, point.y - previous.y); 74 | const vector2 = new Phaser.Math.Vector2(point.x - next.x, point.y - next.y); 75 | return vector1.cross(vector2); 76 | } 77 | 78 | /** 79 | * @method getDotProduct 80 | * @description Calculate the dot-product between a corner 81 | * @param {Phaser.Geom.Point} point 82 | * @param {Phaser.Geom.Point} previous 83 | * @param {Phaser.Geom.Point} next 84 | */ 85 | export function getDotProduct(point, previous, next) { 86 | const normal1 = new Phaser.Math.Vector2(previous.x - point.x, previous.y - point.y).normalize(); 87 | const normal2 = new Phaser.Math.Vector2(next.x - point.x, next.y - point.y).normalize(); 88 | return normal1.dot(normal2); 89 | } 90 | 91 | /** 92 | * @method offsetFunnelPath 93 | * @description Offset the funnel path by ${inflateBy} so steering doesn't get too close to the corners 94 | * @param {FunnelPoint[]} paths 95 | * @param {number} inflateBy 96 | */ 97 | export function offsetFunnelPath(paths = [], inflateBy = 0) { 98 | const length = paths.length; 99 | if (!length) { 100 | return []; 101 | } 102 | 103 | const inflated = [ new Phaser.Math.Vector2(paths[0].x, paths[0].y) ]; 104 | const offsetPoint = new Phaser.Math.Vector2(); 105 | let i = 0; 106 | let nextCurrent; 107 | let previous; 108 | let current; 109 | let cross; 110 | let dot; 111 | let isAntiClockwise; 112 | let angle; 113 | let next; 114 | 115 | for (i; i < length; i++) { 116 | current = paths[i]; 117 | previous = paths[i - 1]; 118 | next = paths[i + 1]; 119 | 120 | // Ignore the start & end vertices 121 | if (!previous || !next) { 122 | continue; 123 | } else if (current.isNarrow) { // If this was evaluated as too narrow for funneling, just add it & move on. 124 | inflated.push(new Phaser.Math.Vector2(current.x, current.y)); 125 | continue; 126 | } 127 | 128 | nextCurrent = new Phaser.Geom.Line(current.x, current.y, next.x, next.y); 129 | 130 | cross = getCrossProduct(current, previous, next); 131 | dot = getDotProduct(current, previous, next); 132 | isAntiClockwise = cross >= 0; 133 | angle = Math.acos(dot) * (isAntiClockwise ? -1 : 1); 134 | 135 | // Rotate the line segment between current & next points by half the vertex angle; then extend this segment 136 | // See: https://stackoverflow.com/questions/8292508/algorithm-for-extending-a-line-segment 137 | const rotated = Phaser.Geom.Line.RotateAroundPoint(Phaser.Geom.Line.Clone(nextCurrent), current, angle / 2); 138 | const start = rotated.getPointA(); 139 | const end = rotated.getPointB(); 140 | const length = Phaser.Geom.Line.Length(rotated); 141 | offsetPoint.x = start.x + (start.x - end.x) / length * inflateBy; 142 | offsetPoint.y = start.y + (start.y - end.y) / length * inflateBy; 143 | 144 | inflated.push(new Phaser.Math.Vector2(offsetPoint.x, offsetPoint.y)) 145 | } 146 | 147 | // Add the last point, without inflating it 148 | inflated.push(new Phaser.Math.Vector2(paths[length - 1])); 149 | 150 | return inflated; 151 | } 152 | 153 | /** 154 | * @method optimiseEdges 155 | * @description Iterate across lines: 156 | * 1. Check the triarea created by ${i} and the next one along. 157 | * 2. If zero, then they are lines along the same axis 158 | * 3. Create a new Line() merge of the two, splice into the array. 159 | * 3. Restart the iteration from the previous index. 160 | */ 161 | export function optimiseEdges(edges) { 162 | let i = 0; 163 | let line; 164 | 165 | for (i; i < edges.length; i++) { 166 | const line1 = edges[i]; 167 | const line2 = edges[i + 1]; 168 | if (!line2) { 169 | continue; 170 | } 171 | 172 | const line1Start = line1.getPointA(); 173 | const line1End = line1.getPointB(); 174 | const line2End = line2.getPointB(); 175 | const area = triarea2(line1Start, line1End, line2End); 176 | line = new Phaser.Geom.Line(line1Start.x, line1Start.y, line2End.x, line2End.y); 177 | if (!area) { 178 | edges.splice(i, 2, line); 179 | i--; // start again 180 | } 181 | } 182 | 183 | return edges; 184 | } 185 | 186 | /** 187 | * @method offsetPolygon 188 | * @param {Phaser.Geom.Point[]} points 189 | * @param {Boolean} invert 190 | * @param {Cluster[]} clusters 191 | */ 192 | export function offsetPolygon(points = [], invert, clusters = []) { 193 | const { width, height } = Config.mapDimensions; 194 | const offsetBy = Config.get('offsetHullsBy') * (invert ? -1 : 1); 195 | const pointsLength = points.length; 196 | const offsetPoint = new Phaser.Math.Vector2(); 197 | let i = 0; 198 | let current; 199 | let previous; 200 | let next; 201 | let nextCurrent; 202 | let cross; 203 | let dot; 204 | let angle; 205 | let isAntiClockwise; 206 | let area; 207 | 208 | for (i; i < pointsLength; i++) { 209 | previous = points[i === 0 ? pointsLength - 1 : i - 1]; 210 | current = points[i]; 211 | next = points[i === pointsLength - 1 ? 0 : i + 1]; 212 | nextCurrent = new Phaser.Geom.Line(current.x, current.y, next.x, next.y); 213 | area = triarea2(previous, current, next); 214 | 215 | dot = getDotProduct(current, previous, next); 216 | cross = getCrossProduct(current, previous, next); 217 | isAntiClockwise = cross >= 0; 218 | angle = Math.acos(dot) * (isAntiClockwise ? -1 : 1); 219 | 220 | if (current.x === 0 || current.y === 0 || current.x === width || current.y === height) { 221 | continue; 222 | } 223 | 224 | const rotated = Phaser.Geom.Line.RotateAroundPoint( Phaser.Geom.Line.Clone(nextCurrent), current, (angle / 2)) 225 | const length = Phaser.Geom.Line.Length(rotated); 226 | const start = rotated.getPointA(); 227 | const end = rotated.getPointB(); 228 | 229 | if (area < 0) { 230 | offsetPoint.x = start.x + (end.x - start.x) / length * offsetBy; 231 | offsetPoint.y = start.y + (end.y - start.y) / length * offsetBy; 232 | } else { 233 | offsetPoint.x = start.x - (end.x - start.x) / length * offsetBy; 234 | offsetPoint.y = start.y - (end.y - start.y) / length * offsetBy; 235 | } 236 | 237 | // Only update the edge point IF the new offset does NOT overlap with any sibling cluster 238 | if (!clusters.find(cluster => cluster.polygon.contains(offsetPoint.x, offsetPoint.y))) { 239 | current.copy(offsetPoint); 240 | } 241 | } 242 | 243 | return points; 244 | } 245 | 246 | /** 247 | * @method offsetEdges 248 | * @param {Phaser.Line[]} edges 249 | * @param {Boolean} invert 250 | * @param {Cluster[]} clusters 251 | */ 252 | export function offsetEdges(edges = [], invert = false, clusters = []) { 253 | const allPoints = []; 254 | const length = edges.length; 255 | let i = 0; 256 | let exists; 257 | let offsetPoints; 258 | 259 | const addPoint = point => { 260 | exists = allPoints.find(p => p.equals(point)); 261 | if (exists) { 262 | exists.addSource(point); 263 | } else { 264 | allPoints.push(new EdgePoint(point)); 265 | } 266 | }; 267 | 268 | for (i; i < length; i++) { 269 | addPoint(edges[i].getPointA()); 270 | addPoint(edges[i].getPointB()); 271 | } 272 | 273 | offsetPoints = offsetPolygon(allPoints, invert, clusters); 274 | offsetPoints.forEach(point => point.updateSources()); 275 | 276 | return edges; 277 | } 278 | -------------------------------------------------------------------------------- /webpack/build.config.js: -------------------------------------------------------------------------------- 1 | const TerserPlugin = require('terser-webpack-plugin'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | 4 | const { APP_DIR, DIST_DIR } = require('./conf'); 5 | const defaultConfig = require('./defaults.config'); 6 | 7 | module.exports = Object.assign({}, defaultConfig, { 8 | mode: 'production', 9 | entry: { 10 | navmesh: `${APP_DIR}/lib/navMeshPlugin.js` 11 | }, 12 | output: { 13 | filename: '[name]-plugin.js', 14 | path: DIST_DIR, 15 | library: 'phaser-navmesh-generation', 16 | libraryTarget: 'umd', 17 | umdNamedDefine: true 18 | }, 19 | externals: ['phaser'], 20 | optimization: { 21 | minimizer: [ 22 | new TerserPlugin({ 23 | terserOptions: { 24 | output: { 25 | comments: false, 26 | }, 27 | }, 28 | }), 29 | ], 30 | }, 31 | plugins: [ 32 | new CleanWebpackPlugin(), 33 | ] 34 | }); 35 | -------------------------------------------------------------------------------- /webpack/conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | APP_DIR: path.resolve(__dirname, '../src'), 5 | BUILD_DIR: path.resolve(__dirname, '../public'), 6 | DIST_DIR: path.resolve(__dirname, '../dist'), 7 | PHASER_DIR: path.join(__dirname, '../node_modules/phaser-ce'), 8 | NODE_ENV: process.env.NODE_ENV 9 | }; -------------------------------------------------------------------------------- /webpack/defaults.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { APP_DIR, PHASER_DIR, BUILD_DIR } = require('./conf'); 3 | 4 | module.exports = { 5 | output: { 6 | filename: '[name].bundle.js', 7 | path: BUILD_DIR 8 | }, 9 | devtool: 'source-map', 10 | resolve: { 11 | extensions: ['.js', '.jsx', '.json'], 12 | modules: [APP_DIR, 'node_modules'], 13 | // alias: { 14 | // constants: `${APP_DIR}/constants`, // https://github.com/webpack/webpack/issues/4666 15 | // phaser: path.join(PHASER_DIR, 'build/custom/phaser-split.js'), 16 | // pixi: path.join(PHASER_DIR, 'build/custom/pixi.js'), 17 | // p2: path.join(PHASER_DIR, 'build/custom/p2.js') 18 | // } 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.js$/, 24 | exclude: /(node_modules|bower_components)/, 25 | include: APP_DIR, 26 | use: { 27 | loader: 'babel-loader', 28 | } 29 | }, 30 | ] 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /webpack/dev.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | 4 | const { APP_DIR, BUILD_DIR, NODE_ENV } = require('./conf'); 5 | const defaultConfig = require('./defaults.config'); 6 | 7 | const minimizer = NODE_ENV === 'production' ? [ 8 | new TerserPlugin({ 9 | terserOptions: { 10 | output: { 11 | comments: false, 12 | }, 13 | }, 14 | }), 15 | ] : []; 16 | 17 | module.exports = Object.assign({}, defaultConfig, { 18 | mode: NODE_ENV || 'development', 19 | entry: { 20 | demo: `${APP_DIR}/demo/index.js` 21 | }, 22 | plugins: NODE_ENV === 'production' ? [ new webpack.optimize.UglifyJsPlugin({ 23 | drop_console: true, 24 | minimize: true, 25 | output: { 26 | comments: false 27 | } 28 | } 29 | )] : [], 30 | optimization: { 31 | minimizer 32 | }, 33 | devServer: { 34 | // contentBase: BUILD_DIR, 35 | port: 9999 36 | } 37 | }); 38 | --------------------------------------------------------------------------------