/** * @author Marcin Walczak <contact@marcin-walczak.pl> * @copyright 2023 Marcin Walczak * @license {@link https://github.com/wiserim/phaser-raycaster/blob/master/LICENSE|MIT License} */ /** * @classdesc * * Raycaster class responsible for creating ray objects and managing mapped objects. * * @namespace Raycaster * @class Raycaster * @constructor * @since 0.6.0 * * @param {object} [options] - Raycaster's configuration options. May include: * @param {Phaser.Scene} [options.scene] - Scene in which Raycaster will be used. * @param {number} [options.mapSegmentCount = 0] - Number of segments of circle maps. If set to 0, map will be teste * @param {(object|object[])} [options.objects] - Game object or array of game objects to map. * @param {Phaser.Geom.Rectangle} [options.boundingBox] - Raycaster's bounding box. If not passed, {@link Raycaster Raycaster} will set it's bounding box based on Arcade Physics / Matter physics world bounds. * @param {boolean} [options.autoUpdate = true] - If set true, automatically update dynamic maps on scene update event. * @param {boolean|object} [options.debug] - Enable debug mode or configure it {@link Raycaster#debugOptions debugOptions}. */ export function Raycaster(options) { /** * Plugin version. * * @name Raycaster#version * @type {string} * @readonly * @since 0.6.0 */ this.version = '0.10.10'; /** * Raycaster's scene * * @name Raycaster#scene * @type {Phaser.Scene} * @private * @since 0.6.0 */ this.scene; /** * Raycaster's graphics object used for debug * * @name Raycaster#graphics * @type {Phaser.GameObjects.Graphics} * @private * @since 0.10.0 */ this.graphics; /** * Raycaster's debug config * * @name Raycaster#debugOptions * @type {Object} * @since 0.10.0 * * @property {boolean} [enable = false] Enable debug mode * @property {boolean} [maps = true] - Enable maps debug * @param {boolean} [rays = true] - Enable rays debug * @property {boolean} graphics - Debug graphics options * @property {boolean|number} [graphics.ray = 0x00ff00] - Debug ray color. Set false to disable. * @property {boolean|number} [graphics.rayPoint = 0xff00ff] - Debug ray point color. Set false to disable. * @property {boolean|number} [graphics.mapPoint = 0x00ffff] - debug map point color. Set false to disable. * @property {boolean|number} [graphics.mapSegment = 0x0000ff] - Debug map segment color. Set false to disable. * @property {boolean|number} [graphics.mapBoundingBox = 0xff0000] - Debug map bounding box color. Set false to disable. */ this.debugOptions = { enabled: false, maps: true, rays: true, graphics: { ray: 0x00ff00, rayPoint: 0xff00ff, mapPoint: 0x00ffff, mapSegment: 0x0000ff, mapBoundingBox: 0xff0000 } }; /** * Raycaster statistics. * * @name Raycaster.Raycaster#_stats * @type {object} * @private * @since 0.10.0 * * @property {object} mappedObjects Mapped objects statistics. * @property {number} mappedObjects.total Mapped objects total. * @property {number} mappedObjects.static Static maps. * @property {number} mappedObjects.dynamic Dynamic maps. * @property {number} mappedObjects.rectangleMaps Rectangle maps. * @property {number} mappedObjects.polygonMaps Polygon maps. * @property {number} mappedObjects.circleMaps Circle maps. * @property {number} mappedObjects.lineMaps Line maps. * @property {number} mappedObjects.containerMaps Container maps. * @property {number} mappedObjects.tilemapMaps Tilemap maps. * @property {number} mappedObjects.matterMaps Matter body maps. */ this._stats = { mappedObjects: { total: 0, static: 0, dynamic: 0, rectangleMaps: 0, polygonMaps: 0, circleMaps: 0, lineMaps: 0, containerMaps: 0, tilemapMaps: 0, matterMaps: 0 } }; /** * Raycaster's bounding box. By default it's size is based on Arcade Physics / Matter physics world bounds. * If world size will change after creation of Raycaster, bounding box needs to be updated. * * @name Raycaster#boundingBox * @type {Phaser.Geom.Rectangle} * @default false * @private * @since 0.6.0 */ this.boundingBox = false; /** * Array of mapped game objects. * * @name Raycaster#mappedObjects * @type {object[]} * @since 0.6.0 */ this.mappedObjects = []; /** * Array of dynamic mapped game objects. * * @name Raycaster#dynamicMappedObjects * @type {object[]} * @since 0.10.6 */ this.dynamicMappedObjects = []; /** * Number of segments of circle maps. * * @name Raycaster#mapSegmentCount * @type {number} * @default 0 * @since 0.6.0 */ this.mapSegmentCount = 0; if(options !== undefined) { if(options.boundingBox === undefined && options.scene !== undefined) { if(options.scene.physics !== undefined) options.boundingBox = options.scene.physics.world.bounds; else if(options.scene.matter !== undefined) { let walls = options.scene.matter.world.walls; if(walls.top !== null) { options.boundingBox = new Phaser.Geom.Rectangle( walls.top.vertices[3].x, walls.top.vertices[3].y, walls.bottom.vertices[1].x - walls.top.vertices[3].x, walls.bottom.vertices[1].y - walls.top.vertices[3].y ); } } } this.setOptions(options); if(options.autoUpdate === undefined || options.autoUpdate) //automatically update event this.scene.events.on('update', this.update, this); } else //automatically update event this.scene.events.on('update', this.update, this); return this; } Raycaster.prototype = { /** * Configure raycaster. * * @method Raycaster#setOptions * @memberof Raycaster * @instance * @since 0.6.0 * * @param {object} [options] - Raycaster's congfiguration options. May include: * @param {Phaser.Scene} [options.scene] - Scene in which Raycaster will be used. * @param {number} [options.mapSegmentCount = 0] - Number of segments of circle maps. * @param {(object|object[])} [options.objects] - Game object or array of game objects to map. * @param {Phaser.Geom.Rectangle} [options.boundingBox] - Raycaster's bounding box. * @param {boolean|object} [options.debug] - Enable debug mode or cofigure {@link Raycaster#debugOptions debugOptions}. * * @return {Raycaster} {@link Raycaster Raycaster} instance */ setOptions: function(options) { if(options.scene !== undefined) { this.scene = options.scene; this.graphics = this.scene.add.graphics({ lineStyle: { width: 1, color: 0x00ff00}, fillStyle: { color: 0xff00ff } }); this.graphics.setDepth(999); } if(options.debug !== undefined && options.debug !== false) { this.debugOptions.enabled = true; if(typeof options.debug === 'object') Object.assign(this.debugOptions, options.debug); } if(options.mapSegmentCount !== undefined) this.mapSegmentCount = options.mapSegmentCount; if(options.objects !== undefined) this.mapGameObjects(options.objects); if(options.boundingBox !== undefined) this.setBoundingBox(options.boundingBox.x, options.boundingBox.y, options.boundingBox.width, options.boundingBox.height) return this; }, /** * Set Raycaster's bounding box. * * @method Raycaster#setBoundingBox * @memberof Raycaster * @instance * @since 0.6.0 * * @param {number} x - The X coordinate of the top left corner of bounding box. * @param {number} y - The Y coordinate of the top left corner of bounding box. * @param {number} width - The width of bounding box. * @param {number} height - The height of bounding box. * * @return {Raycaster} {@link Raycaster Raycaster} instance */ setBoundingBox: function(x, y, width, height) { this.boundingBox = { rectangle: new Phaser.Geom.Rectangle(x, y, width, height), points: [], segments: [] } //set points let points = [ new Phaser.Geom.Point(this.boundingBox.rectangle.left, this.boundingBox.rectangle.top), new Phaser.Geom.Point(this.boundingBox.rectangle.right, this.boundingBox.rectangle.top), new Phaser.Geom.Point(this.boundingBox.rectangle.right, this.boundingBox.rectangle.bottom), new Phaser.Geom.Point(this.boundingBox.rectangle.left, this.boundingBox.rectangle.bottom) ]; this.boundingBox.points = points; //set segments for(let i = 0, length = this.boundingBox.points.length; i < length; i++) { if(i+1 < length) this.boundingBox.segments.push(new Phaser.Geom.Line(points[i].x, points[i].y, points[i+1].x, points[i+1].y)); else this.boundingBox.segments.push(new Phaser.Geom.Line(points[i].x, points[i].y, points[0].x, points[0].y)); } }, /** * Map game objects * * @method Raycaster#mapGameObjects * @memberof Raycaster * @instance * @since 0.6.0 * * @param {object|object[]} objects - Game object / matter body or array of game objects / matter bodies to map. * @param {boolean} [dynamic = false] - {@link Raycaster.Map Raycaster.Map} dynamic flag (determines map will be updated automatically). * @param {object} [options] - Additional options for {@link Raycaster.Map Raycaster.Map} * * @return {Raycaster} {@link Raycaster Raycaster} instance */ mapGameObjects: function(objects, dynamic = false, options = {}) { options.dynamic = dynamic; options.segmentCount = (options.segmentCount !== undefined) ? options.segmentCount : this.segmentCount; if(!Array.isArray(objects)) objects = [objects]; for(let object of objects) { if(this.mappedObjects.includes(object)) continue; //if object is not supported if(object.data && object.data.get('raycasterMapNotSupported')) continue; let config = {}; for(let option in options) { config[option] = options[option]; } config.object = object; let map = new this.Map(config, this); if(map.notSupported) { map.destroy(); continue; } if(object.type === 'body' || object.type === 'composite') { object.raycasterMap = map; } else if(!object.data) { object.setDataEnabled(); object.data.set('raycasterMap', map); } else { object.data.set('raycasterMap', map); } this.mappedObjects.push(object); //update stats switch(object.type) { case 'Polygon': this._stats.mappedObjects.polygonMaps++; break; case 'Arc': this._stats.mappedObjects.circleMaps++; break; case 'Line': this._stats.mappedObjects.lineMaps++; break; case 'Container': this._stats.mappedObjects.containerMaps++; break; case 'StaticTilemapLayer': this._stats.mappedObjects.tilemapMaps++; break; case 'DynamicTilemapLayer': this._stats.mappedObjects.tilemapMaps++; break; case 'TilemapLayer': this._stats.mappedObjects.tilemapMaps++; break; case 'MatterBody': this._stats.mappedObjects.matterMaps++; break; default: this._stats.mappedObjects.rectangleMaps++; } } this._stats.mappedObjects.total = this.mappedObjects.length; this._stats.mappedObjects.static = this._stats.mappedObjects.total - this.dynamicMappedObjects.length; return this; }, /** * Remove game object's {@link Raycaster.Map Raycaster.Map} maps. * * @method Raycaster#removeMappedObjects * @memberof Raycaster * @instance * @since 0.6.0 * * @param {(object|object[])} objects - Game object or array of game objects which maps will be removed. * * @return {Raycaster} {@link Raycaster Raycaster} instance */ removeMappedObjects: function(objects) { if(!Array.isArray(objects)) objects = [objects]; for(let object of objects) { //remove object from mapped objects list let index = this.mappedObjects.indexOf(object); if(index === -1) { continue; } this.mappedObjects.splice(index, 1); //remove object from dynamic mapped objects list index = this.dynamicMappedObjects.indexOf(object); if(index >= 0) this.dynamicMappedObjects.splice(index, 1); if(object.type === 'body' || object.type === 'composite') { object.raycasterMap.destroy(); } else { object.data.get('raycasterMap').destroy(); } //update stats switch(object.type) { case 'Polygon': this._stats.mappedObjects.polygonMaps--; break; case 'Arc': this._stats.mappedObjects.circleMaps--; break; case 'Line': this._stats.mappedObjects.lineMaps--; break; case 'Container': this._stats.mappedObjects.containerMaps--; break; case 'StaticTilemapLayer': this._stats.mappedObjects.tilemapMaps--; break; case 'DynamicTilemapLayer': this._stats.mappedObjects.tilemapMaps--; break; case 'TilemapLayer': this._stats.mappedObjects.tilemapMaps--; break; case 'MatterBody': this._stats.mappedObjects.matterMaps--; break; default: this._stats.mappedObjects.rectangleMaps--; } } this._stats.mappedObjects.total = this.mappedObjects.length; this._stats.mappedObjects.dynamic = this.dynamicMappedObjects.length; this._stats.mappedObjects.static = this._stats.mappedObjects.total - this.dynamicMappedObjects.length; return this; }, /** * Enable game object's {@link Raycaster.Map Raycaster.Map} maps. * * @method Raycaster#enableMaps * @memberof Raycaster * @instance * @since 0.7.2 * * @param {(object|object[])} objects - Game object or array of game objects which maps will be enabled. * * @return {Raycaster} {@link Raycaster Raycaster} instance */ enableMaps: function(objects) { if(!Array.isArray(objects)) objects = [objects]; for(let object of objects) { let map; if(object.type === 'body' || object.type === 'composite') { map = object.raycasterMap; } else if(object.data) { map = object.data.get('raycasterMap'); } if(map) map.active = true; } return this; }, /** * Disable game object's {@link Raycaster.Map Raycaster.Map} maps. * * @method Raycaster#disableMaps * @memberof Raycaster * @instance * @since 0.7.2 * * @param {(object|object[])} objects - Game object or array of game objects which maps will be disabled. * * @return {Raycaster} {@link Raycaster Raycaster} instance */ disableMaps: function(objects) { if(!Array.isArray(objects)) objects = [objects]; for(let object of objects) { let map; if(object.type === 'body' || object.type === 'composite') { map = object.raycasterMap; } else if(object.data) { map = object.data.get('raycasterMap'); } if(map) map.active = false; } return this; }, /** * Updates all {@link Raycaster.Map Raycaster.Map} dynamic maps. Fired on Phaser.Scene update event. * * @method Raycaster#update * @memberof Raycaster * @instance * @since 0.6.0 * * @return {Raycaster} {@link Raycaster Raycaster} instance */ update: function() { //update dynamic maps if(this.dynamicMappedObjects.length > 0) { for(let mapppedObject of this.dynamicMappedObjects) { let map; if(mapppedObject.type === 'body' || mapppedObject.type === 'composite') { map = mapppedObject.raycasterMap; } else if(mapppedObject.data) { map = mapppedObject.data.get('raycasterMap'); } if(!map) continue; if(map.active) { map.updateMap(); } } } //debug if(this.debugOptions.enabled) this.drawDebug(); return this; }, /** * Create {@link Raycaster.Ray Raycaster.Ray} object. * * @method Raycaster#createRay * @memberof Raycaster * @instance * @since 0.6.0 * * @param {object} [options] - Ray's congfiguration options. May include: * @param {Phaser.Geom.Point|Point} [options.origin = {x:0, y:0}] - Ray's position. * @param {number} [options.angle = 0] - Ray's angle in radians. * @param {number} [options.angleDeg = 0] - Ray's angle in degrees. * @param {number} [options.cone = 0] - Ray's cone angle in radians. * @param {number} [options.coneDeg = 0] - Ray's cone angle in degrees. * @param {number} [options.range = Phaser.Math.MAX_SAFE_INTEGER] - Ray's range. * @param {number} [options.collisionRange = Phaser.Math.MAX_SAFE_INTEGER] - Ray's maximum collision range of ray's field of view. * @param {number} [options.detectionRange = Phaser.Math.MAX_SAFE_INTEGER] - Maximum distance between ray's position and tested objects bounding boxes. * @param {boolean} [options.ignoreNotIntersectedRays = true] - If set true, ray returns false when it didn't hit anything. Otherwise returns ray's target position. * @param {boolean} [options.autoSlice = false] - If set true, ray will automatically slice intersections into array of triangles and store it in {@link Raycaster.Ray#slicedIntersections Ray.slicedIntersections}. * @param {boolean} [options.round = false] - If set true, point where ray hit will be rounded. * @param {(boolean|'arcade'|'matter')} [options.enablePhysics = false] - Add to ray physics body. Body will be a circle with radius equal to {@link Raycaster.Ray#collisionRange Ray.collisionRange}. If set true, arcade physics body will be added. * * @return {Raycaster.Ray} {@link Raycaster.Ray Raycaster.Ray} instance */ createRay: function(options = {}) { return new this.Ray(options, this); }, /** * Get raycaster statistics. * * @method Raycaster#getStats * @memberof Raycaster * @instance * @since 0.10.0 * * @return {object} Raycaster statistics. */ getStats: function() { return this._stats; }, /** * Draw maps in debug mode * * @method Raycaster#drawDebug * @memberof Raycaster * @private * @since 0.10.0 * * @return {Raycaster} {@link Raycaster Raycaster} instance */ drawDebug: function() { if(this.graphics === undefined || !this.debugOptions.enabled) return this; //clear this.graphics.clear(); if(!this.debugOptions.maps) return this; for(let object of this.mappedObjects) { let map; if(object.type === 'body' || object.type === 'composite') map = object.raycasterMap; else if(object.data) map = object.data.get('raycasterMap'); if(!map) continue; //draw bounding box if(this.debugOptions.graphics.mapBoundingBox) { this.graphics.lineStyle(1, this.debugOptions.graphics.mapBoundingBox); this.graphics.strokeRectShape(map.getBoundingBox()); } //draw segments if(this.debugOptions.graphics.mapSegment) { this.graphics.lineStyle(1, this.debugOptions.graphics.mapSegment); for(let segment of map.getSegments()) { this.graphics.strokeLineShape(segment); } } //draw points if(this.debugOptions.graphics.mapPoint) { this.graphics.fillStyle(this.debugOptions.graphics.mapPoint); for(let point of map.getPoints()) { this.graphics.fillPoint(point.x, point.y, 3) } } } return this; }, /** * Destroy object and all mapped objects. * * @method Raycaster#destroy * @memberof Raycaster * @instance * @since 0.10.3 */ destroy: function() { this.removeMappedObjects(this.mappedObjects); if(this.graphics) this.graphics.destroy(); if(this.scene) { this.scene.events.removeListener('update', null, this); } for(let key in this) { delete this[key]; } } } Raycaster.prototype.Map = require('./map/map-core.js').Map; Raycaster.prototype.Ray = require('./ray/ray-core.js').Ray;