Source: raycaster-core.js

/**
* @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;