import Phaser from "phaser"; import PhaserNavMesh from "./phaser-navmesh"; import { buildPolysFromGridMap } from "navmesh/src/map-parsers"; /** * This class can create navigation meshes for use in Phaser 3. The navmeshes can be constructed * from convex polygons embedded in a Tiled map. The class that conforms to Phaser 3's plugin * structure. * * @export * @class PhaserNavMeshPlugin */ export default class PhaserNavMeshPlugin extends Phaser.Plugins.ScenePlugin { private phaserNavMeshes: Record = {}; public constructor( scene: Phaser.Scene, pluginManager: Phaser.Plugins.PluginManager, pluginKey: string ) { super(scene, pluginManager, pluginKey); } /** Phaser.Scene lifecycle event */ public boot() { const emitter = this.systems.events; emitter.once("destroy", this.destroy, this); } /** Phaser.Scene lifecycle event - noop in this plugin, but still required. */ public init() {} /** Phaser.Scene lifecycle event - noop in this plugin, but still required.*/ public start() {} /** Phaser.Scene lifecycle event - will destroy all navmeshes created. */ public destroy() { this.systems.events.off("boot", this.boot, this); this.removeAllMeshes(); } /** * Remove all the meshes from the navmesh. */ public removeAllMeshes() { const meshes = Object.values(this.phaserNavMeshes); this.phaserNavMeshes = {}; meshes.forEach((m) => m.destroy()); } /** * Remove the navmesh stored under the given key from the plugin. This does not destroy the * navmesh. * @param key */ public removeMesh(key: string) { if (this.phaserNavMeshes[key]) delete this.phaserNavMeshes[key]; } /** * This method attempts to automatically build a navmesh based on the give tilemap and tilemap * layer(s). It attempts to respect the x/y position and scale of the layer(s). Important note: it * doesn't support rotation/flip or multiple layers that have different positions/scales. This * method is a bit experimental. It will generate a valid mesh, but it won't necessarily be * optimal, so you may end up sometimes getting non-shortest paths. * * @param key Key to use when storing this navmesh within the plugin. * @param tilemap The tilemap to use for building the navmesh. * @param [tilemapLayers] An optional array of tilemap layers to use for building the mesh. * @param [isWalkable] An optional function to use to test if a tile is walkable. Defaults to * assuming non-colliding tiles are walkable. * @param [shrinkAmount=0] Amount to "shrink" the mesh away from the tiles. This adds more * polygons to the generated mesh, but can be helpful for preventing agents from getting caught on * edges. This supports values between 0 and tileWidth/tileHeight (whichever dimension is * smaller). */ public buildMeshFromTilemap( key: string, tilemap: Phaser.Tilemaps.Tilemap, tilemapLayers?: Phaser.Tilemaps.TilemapLayer[], isWalkable?: (tile: Phaser.Tilemaps.Tile) => boolean, shrinkAmount = 0 ) { // Use all layers in map, or just the specified ones. const dataLayers = tilemapLayers ? tilemapLayers.map((tl) => tl.layer) : tilemap.layers; if (!isWalkable) isWalkable = (tile: Phaser.Tilemaps.Tile) => !tile.collides; let offsetX = 0; let offsetY = 0; let scaleX = 1; let scaleY = 1; // Attempt to set position offset and scale from the 1st tilemap layer given. if (tilemapLayers) { const layer = tilemapLayers[0]; offsetX = layer.tileToWorldX(0); offsetY = layer.tileToWorldY(0); scaleX = layer.scaleX; scaleY = layer.scaleY; // Check and warn for layer settings that will throw off the calculation. for (const layer of tilemapLayers) { if ( offsetX !== layer.tileToWorldX(0) || offsetY !== layer.tileToWorldY(0) || scaleX !== layer.scaleX || scaleY !== layer.scaleY ) { console.warn( `PhaserNavMeshPlugin: buildMeshFromTilemap reads position & scale from the 1st TilemapLayer. Layer index ${layer.layerIndex} has a different position & scale from the 1st TilemapLayer.` ); } if (layer.rotation !== 0) { console.warn( `PhaserNavMeshPlugin: buildMeshFromTilemap doesn't support TilemapLayer with rotation. Layer index ${layer.layerIndex} is rotated.` ); } } } // Check and warn about DataLayer that have x/y position from Tiled. In the future, these could // be supported if A) only one colliding layer is offset, or B) the offset is a multiple of the // tile size. dataLayers.forEach((layer) => { if (layer.x !== 0 || layer.y !== 0) { console.warn( `PhaserNavMeshPlugin: buildMeshFromTilemap doesn't support layers with x/y positions from Tiled.` ); } }); // Build 2D array of walkable tiles across all given layers. const walkableAreas: boolean[][] = []; for (let ty = 0; ty < tilemap.height; ty += 1) { const row: boolean[] = []; for (let tx = 0; tx < tilemap.width; tx += 1) { let walkable = true; for (const layer of dataLayers) { const tile = layer.data[ty][tx]; if (tile && !isWalkable(tile)) { walkable = false; break; } } row.push(walkable); } walkableAreas.push(row); } let polygons = buildPolysFromGridMap( walkableAreas, tilemap.tileWidth, tilemap.tileHeight, (bool) => bool, shrinkAmount ); // Offset and scale each polygon if necessary. if (scaleX !== 1 && scaleY !== 1 && offsetX !== 0 && offsetY !== 0) { polygons = polygons.map((poly) => poly.map((p) => ({ x: p.x * scaleX + offsetX, y: p.y * scaleY + offsetY })) ); } const mesh = new PhaserNavMesh(this, this.scene, key, polygons, 0); this.phaserNavMeshes[key] = mesh; return mesh; } /** * Load a navmesh from Tiled. Currently assumes that the polygons are squares! Does not support * tilemap layer scaling, rotation or position. * @param key Key to use when storing this navmesh within the plugin. * @param objectLayer The ObjectLayer from a tilemap that contains the polygons that make up the * navmesh. * @param meshShrinkAmount The amount (in pixels) that the navmesh has been shrunk around * obstacles (a.k.a the amount obstacles have been expanded) */ public buildMeshFromTiled( key: string, objectLayer: Phaser.Tilemaps.ObjectLayer, meshShrinkAmount = 0 ) { if (this.phaserNavMeshes[key]) { console.warn(`NavMeshPlugin: a navmesh already exists with the given key: ${key}`); return this.phaserNavMeshes[key]; } if (!objectLayer || objectLayer.objects.length === 0) { console.warn( `NavMeshPlugin: The given tilemap object layer is empty or undefined: ${objectLayer}` ); } const objects = objectLayer.objects ?? []; // Loop over the objects and construct a polygon - assumes a rectangle for now! // TODO: support layer position, scale, rotation const polygons = objects.map((obj) => { const h = obj.height ?? 0; const w = obj.width ?? 0; const left = obj.x ?? 0; const top = obj.y ?? 0; const bottom = top + h; const right = left + w; return [ { x: left, y: top }, { x: left, y: bottom }, { x: right, y: bottom }, { x: right, y: top }, ]; }); const mesh = new PhaserNavMesh(this, this.scene, key, polygons, meshShrinkAmount); this.phaserNavMeshes[key] = mesh; return mesh; } }