UNPKG

phaser-arcade-slopes

Version:

A Phaser CE plugin that brings sloped tile collision handling to Phaser's Arcade Physics engine

1,525 lines (1,288 loc) 154 kB
/** * @author Chris Andrew <chris@hexus.io> * @copyright 2016-2021 Chris Andrew * @license MIT */ /** * Arcade Slopes provides sloped tile functionality for tilemaps that use * Phaser's Arcade physics engine. * * @class Phaser.Plugin.ArcadeSlopes * @constructor * @extends Phaser.Plugin * @param {Phaser.Game} game - A reference to the game using this plugin. * @param {any} parent - The object that owns this plugin, usually a Phaser.PluginManager. * @param {integer} defaultSolver - The default collision solver type to use for sloped tiles. */ Phaser.Plugin.ArcadeSlopes = function (game, parent, defaultSolver) { Phaser.Plugin.call(this, game, parent); /** * The collision solvers provided by the plugin. * * Maps solver constants to their respective instances. * * @property {object} solvers */ var solvers = {}; solvers[Phaser.Plugin.ArcadeSlopes.SAT] = new Phaser.Plugin.ArcadeSlopes.SatSolver(); /** * The Arcade Slopes facade. * * @property {Phaser.Plugin.ArcadeSlopes.Facade} facade */ this.facade = new Phaser.Plugin.ArcadeSlopes.Facade( new Phaser.Plugin.ArcadeSlopes.TileSlopeFactory(), solvers, defaultSolver || Phaser.Plugin.ArcadeSlopes.SAT ); // Give the facade a reference to the plugin; this makes it easier to remove // it at runtime this.facade.plugin = this; }; Phaser.Plugin.ArcadeSlopes.prototype = Object.create(Phaser.Plugin.prototype); Phaser.Plugin.ArcadeSlopes.prototype.constructor = Phaser.Plugin.ArcadeSlopes; /** * The Arcade Slopes plugin version number. * * @constant * @type {string} */ Phaser.Plugin.ArcadeSlopes.VERSION = '0.3.2'; /** * The Separating Axis Theorem collision solver type. * * Uses the excellent SAT.js library. * * @constant * @type {string} */ Phaser.Plugin.ArcadeSlopes.SAT = 'sat'; /** * Initializes the plugin. * * @method Phaser.Plugin.ArcadeSlopes#init */ Phaser.Plugin.ArcadeSlopes.prototype.init = function () { // Give the game an Arcade Slopes facade this.game.slopes = this.game.slopes || this.facade; // Keep a reference to the original Arcade.collideSpriteVsTilemapLayer method this.originalCollideSpriteVsTilemapLayer = Phaser.Physics.Arcade.prototype.collideSpriteVsTilemapLayer; // Replace the original method with the Arcade Slopes override, along with // some extra methods that break down the functionality a little more Phaser.Physics.Arcade.prototype.collideSpriteVsTile = Phaser.Plugin.ArcadeSlopes.Overrides.collideSpriteVsTile; Phaser.Physics.Arcade.prototype.collideSpriteVsTiles = Phaser.Plugin.ArcadeSlopes.Overrides.collideSpriteVsTiles; Phaser.Physics.Arcade.prototype.collideSpriteVsTilemapLayer = Phaser.Plugin.ArcadeSlopes.Overrides.collideSpriteVsTilemapLayer; // Add some extra neighbour methods to the Tilemap class Phaser.Tilemap.prototype.getTileTopLeft = Phaser.Plugin.ArcadeSlopes.Overrides.getTileTopLeft; Phaser.Tilemap.prototype.getTileTopRight = Phaser.Plugin.ArcadeSlopes.Overrides.getTileTopRight; Phaser.Tilemap.prototype.getTileBottomLeft = Phaser.Plugin.ArcadeSlopes.Overrides.getTileBottomLeft; Phaser.Tilemap.prototype.getTileBottomRight = Phaser.Plugin.ArcadeSlopes.Overrides.getTileBottomRight; // Keep a reference to the original TilemapLayer.renderDebug method this.originalRenderDebug = Phaser.TilemapLayer.prototype.renderDebug; // Add some overrides and helper methods to the TilemapLayer class Phaser.TilemapLayer.prototype.getCollisionOffsetX = Phaser.Plugin.ArcadeSlopes.Overrides.getCollisionOffsetX; Phaser.TilemapLayer.prototype.getCollisionOffsetY = Phaser.Plugin.ArcadeSlopes.Overrides.getCollisionOffsetY; Phaser.TilemapLayer.prototype.renderDebug = Phaser.Plugin.ArcadeSlopes.Overrides.renderDebug; }; /** * Destroys the plugin and nulls its references. Restores any overriden methods. * * @method Phaser.Plugin.ArcadeSlopes#destroy */ Phaser.Plugin.ArcadeSlopes.prototype.destroy = function () { // Null the game's reference to the facade this.game.slopes = null; // Restore the original collideSpriteVsTilemapLayer method and null the rest Phaser.Physics.Arcade.prototype.collideSpriteVsTile = null; Phaser.Physics.Arcade.prototype.collideSpriteVsTiles = null; Phaser.Physics.Arcade.prototype.collideSpriteVsTilemapLayer = this.originalCollideSpriteVsTilemapLayer; // Remove the extra neighbour methods from the Tilemap class Phaser.Tilemap.prototype.getTileTopLeft = null; Phaser.Tilemap.prototype.getTileTopRight = null; Phaser.Tilemap.prototype.getTileBottomLeft = null; Phaser.Tilemap.prototype.getTileBottomRight = null; // Remove the overrides and helper methods from the TilemapLayer class Phaser.TilemapLayer.prototype.getCollisionOffsetX = null; Phaser.TilemapLayer.prototype.getCollisionOffsetY = null; Phaser.TilemapLayer.prototype.renderDebug = this.originalRenderDebug; // Call the parent destroy method Phaser.Plugin.prototype.destroy.call(this); }; /** * @author Chris Andrew <chris@hexus.io> * @copyright 2016-2021 Chris Andrew * @license MIT */ /** * A facade class to attach to a Phaser game. * * @class Phaser.Plugin.ArcadeSlopes.Facade * @constructor * @param {Phaser.Plugin.ArcadeSlopes.TileSlopeFactory} factory - A tile slope factory. * @param {object} solvers - A set of collision solvers. * @param {integer} defaultSolver - The default collision solver type to use for sloped tiles. */ Phaser.Plugin.ArcadeSlopes.Facade = function (factory, solvers, defaultSolver) { /** * A tile slope factory. * * @property {Phaser.Plugin.ArcadeSlopes.TileSlopeFactory} factory */ this.factory = factory; /** * A set of collision solvers. * * Maps solver constants to their respective instances. * * @property {object} solvers */ this.solvers = solvers; /** * The default collision solver type to use for sloped tiles. * * @property {string} defaultSolver * @default */ this.defaultSolver = defaultSolver || Phaser.Plugin.ArcadeSlopes.SAT; /** * The plugin this facade belongs to. * * @property {Phaser.Plugin.ArcadeSlopes} plugin */ this.plugin = null; }; /** * Enable the physics body of the given object for sloped tile interaction. * * @method Phaser.Plugin.ArcadeSlopes.Facade#enable * @param {Phaser.Sprite|Phaser.Group} object - The object to enable sloped tile physics for. */ Phaser.Plugin.ArcadeSlopes.Facade.prototype.enable = function (object) { if (Array.isArray(object)) { for (var i = 0; i < object.length; i++) { this.enable(object[i]); } } else { if (object instanceof Phaser.Group) { this.enable(object.children); } else { if (object.hasOwnProperty('body')) { this.enableBody(object.body); } if (object.hasOwnProperty('children') && object.children.length > 0) { this.enable(object.children); } } } }; /** * Enable the given physics body for sloped tile collisions. * * @method Phaser.Plugin.ArcadeSlopes.Facade#enableBody * @param {Phaser.Physics.Arcade.Body} body - The physics body to enable. */ Phaser.Plugin.ArcadeSlopes.Facade.prototype.enableBody = function (body) { // Create an SAT shape for the body // TODO: Rename body.polygon to body.shape or body.slopes.shape if (body.isCircle) { body.polygon = new SAT.Circle( new SAT.Vector( body.x + body.halfWidth, body.y + body.halfHeight ), body.radius ); } else { body.polygon = new SAT.Box( new SAT.Vector(body.x, body.y), body.width * body.sprite.scale.x, body.height * body.sprite.scale.y ).toPolygon(); } // Attach a new set of properties that configure the body's interaction // with sloped tiles, if they don't exist (TODO: Formalize as a class) body.slopes = body.slopes || { debug: false, friction: new Phaser.Point(), preferY: false, pullUp: 0, pullDown: 0, pullLeft: 0, pullRight: 0, pullTopLeft: 0, pullTopRight: 0, pullBottomLeft: 0, pullBottomRight: 0, sat: { response: null, }, skipFriction: false, tile: null, velocity: new SAT.Vector() }; }; /** * Converts a layer of the given tilemap. * * Attaches Phaser.Plugin.ArcadeSlopes.TileSlope objects that are used to define * how the tile should collide with a physics body. * * @method Phaser.Plugin.ArcadeSlopes.Facade#convertTilemap * @param {Phaser.Tilemap} map - The map containing the layer to convert. * @param {number|string|Phaser.TileMapLayer} layer - The layer of the map to convert. * @param {string|object} slopeMap - A mapping type string, or a map of tilemap indexes to ArcadeSlope.TileSlope constants. * @param {integer} index - An optional first tile index (firstgid). * @return {Phaser.Tilemap} - The converted tilemap. */ Phaser.Plugin.ArcadeSlopes.Facade.prototype.convertTilemap = function (map, layer, slopeMap, index) { return this.factory.convertTilemap(map, layer, slopeMap, index); }; /** * Converts a tilemap layer. * * @method Phaser.Plugin.ArcadeSlopes.Facade#convertTilemapLayer * @param {Phaser.TilemapLayer} layer - The tilemap layer to convert. * @param {string|object} slopeMap - A mapping type string, or a map of tilemap indexes to ArcadeSlope.TileSlope constants. * @param {integer} index - An optional first tile index (firstgid). * @return {Phaser.TilemapLayer} - The converted tilemap layer. */ Phaser.Plugin.ArcadeSlopes.Facade.prototype.convertTilemapLayer = function (layer, slopeMap, index) { return this.factory.convertTilemapLayer(layer, slopeMap, index); }; /** * Collides a physics body against a tile. * * @method Phaser.Plugin.ArcadeSlopes.Facade#collide * @param {integer} i - The tile index. * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {Phaser.Tile} tile - The tile. * @param {Phaser.TilemapLayer} tilemapLayer - The tilemap layer. * @param {boolean} overlapOnly - Whether to only check for an overlap. * @return {boolean} - Whether the body was separated. */ Phaser.Plugin.ArcadeSlopes.Facade.prototype.collide = function (i, body, tile, tilemapLayer, overlapOnly) { return this.solvers.sat.collide(i, body, tile, tilemapLayer, overlapOnly); }; /** * Reset all the collision properties on a physics body. * * Resets body.touching, body.blocked, body.overlap*, body.slopes.sat.response. * * Leaves wasTouching alone. * * @method Phaser.Plugin.ArcadeSlopes.Facade#resetBodyFlags * @param {Phaser.Physics.Arcade.Body} body - The physics body. */ Phaser.Plugin.ArcadeSlopes.Facade.prototype.resetCollision = function (body) { body.touching.none = true; body.touching.up = false; body.touching.down = false; body.touching.left = false; body.touching.right = false; body.blocked.none = true; body.blocked.up = false; body.blocked.down = false; body.blocked.left = false; body.blocked.right = false; body.overlapX = 0; body.overlapY = 0; if (!body.slopes) { return; } body.slopes.sat.response = null; }; /** * Whether to prefer Y axis separation in an attempt to prevent physics bodies * from sliding down slopes when they are separated. * * Disabled by default. Only relevant in a game that uses gravity. * * @name Phaser.Plugin.ArcadeSlopes.Facade#preferY * @property {boolean} preferY */ Object.defineProperty(Phaser.Plugin.ArcadeSlopes.Facade.prototype, 'preferY', { get: function () { return this.solvers.sat.options.preferY; }, set: function (enabled) { this.solvers.sat.options.preferY = !!enabled; } }); /** * Whether to use heuristics to avoid collisions with the internal edges between * connected tiles. * * Enabled by default. Relevant to platformers. * * @name Phaser.Plugin.ArcadeSlopes.Facade#heuristics * @property {boolean} heuristics */ Object.defineProperty(Phaser.Plugin.ArcadeSlopes.Facade.prototype, 'heuristics', { get: function () { return this.solvers.sat.options.restrain; }, set: function (enabled) { this.solvers.sat.options.restrain = !!enabled; } }); /** * @author Chris Andrew <chris@hexus.io> * @copyright 2016-2021 Chris Andrew * @license MIT */ /** * A static class with override methods for Phaser's tilemap collisions and tile * neighbour checks. * * @static * @class Phaser.Plugin.ArcadeSlopes.Override */ Phaser.Plugin.ArcadeSlopes.Overrides = {}; /** * Collide a sprite against a single tile. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#collideSpriteVsTile * @param {integer} i - The tile index. * @param {Phaser.Sprite} sprite - The sprite to check. * @param {Phaser.Tile} tile - The tile to check. * @param {Phaser.TilemapLayer} tilemapLayer - The tilemap layer the tile belongs to. * @param {function} [collideCallback] - An optional collision callback. * @param {function} [processCallback] - An optional overlap processing callback. * @param {object} [callbackContext] - The context in which to run the callbacks. * @param {boolean} [overlapOnly] - Whether to only check for an overlap. * @return {boolean} - Whether a collision occurred. */ Phaser.Plugin.ArcadeSlopes.Overrides.collideSpriteVsTile = function (i, sprite, tile, tilemapLayer, collideCallback, processCallback, callbackContext, overlapOnly) { if (!sprite.body || !tile || !tilemapLayer) { return false; } if (tile.hasOwnProperty('slope')) { if (this.game.slopes.collide(i, sprite.body, tile, tilemapLayer, overlapOnly)) { this._total++; if (collideCallback) { collideCallback.call(callbackContext, sprite, tile); } return true; } } else if (this.separateTile(i, sprite.body, tile, tilemapLayer, overlapOnly)) { this._total++; if (collideCallback) { collideCallback.call(callbackContext, sprite, tile); } return true; } return false; }; /** * Collide a sprite against a set of tiles. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#collideSpriteVsTiles * @param {Phaser.Sprite} sprite - The sprite to check. * @param {Phaser.Tile[]} tiles - The tiles to check. * @param {Phaser.TilemapLayer} tilemapLayer - The tilemap layer the tiles belong to. * @param {function} [collideCallback] - An optional collision callback. * @param {function} [processCallback] - An optional overlap processing callback. * @param {object} [callbackContext] - The context in which to run the callbacks. * @param {boolean} [overlapOnly] - Whether to only check for an overlap. * @return {boolean} - Whether a collision occurred. */ Phaser.Plugin.ArcadeSlopes.Overrides.collideSpriteVsTiles = function (sprite, tiles, tilemapLayer, collideCallback, processCallback, callbackContext, overlapOnly) { if (!sprite.body || !tiles || !tiles.length || !tilemapLayer) { return false; } var collided = false; for (var i = 0; i < tiles.length; i++) { if (processCallback) { if (processCallback.call(callbackContext, sprite, tiles[i])) { collided = this.collideSpriteVsTile(i, sprite, tiles[i], tilemapLayer, collideCallback, processCallback, callbackContext, overlapOnly) || collided; } } else { collided = this.collideSpriteVsTile(i, sprite, tiles[i], tilemapLayer, collideCallback, processCallback, callbackContext, overlapOnly) || collided; } } return collided; }; /** * Collide a sprite against a tile map layer. * * This is used to override Phaser.Physics.Arcade.collideSpriteVsTilemapLayer(). * * @override Phaser.Physics.Arcade#collideSpriteVsTilemapLayer * @method Phaser.Plugin.ArcadeSlopes.Overrides#collideSpriteVsTilemapLayer * @param {Phaser.Sprite} sprite - The sprite to check. * @param {Phaser.TilemapLayer} tilemapLayer - The tilemap layer to check. * @param {function} collideCallback - An optional collision callback. * @param {function} processCallback - An optional overlap processing callback. * @param {object} callbackContext - The context in which to run the callbacks. * @param {boolean} overlapOnly - Whether to only check for an overlap. * @return {boolean} - Whether a collision occurred. */ Phaser.Plugin.ArcadeSlopes.Overrides.collideSpriteVsTilemapLayer = function (sprite, tilemapLayer, collideCallback, processCallback, callbackContext, overlapOnly) { if (!sprite.body || !tilemapLayer) { return false; } var tiles = tilemapLayer.getTiles( sprite.body.position.x - sprite.body.tilePadding.x - tilemapLayer.getCollisionOffsetX(), sprite.body.position.y - sprite.body.tilePadding.y - tilemapLayer.getCollisionOffsetY(), sprite.body.width + sprite.body.tilePadding.x, sprite.body.height + sprite.body.tilePadding.y, true, false ); if (tiles.length === 0) { return false; } // TODO: Sort by distance from body center to tile center? var collided = this.collideSpriteVsTiles(sprite, tiles, tilemapLayer, collideCallback, processCallback, callbackContext, overlapOnly); return collided; }; /** * Gets the tile to the top left of the coordinates given. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#getTileTopLeft * @param {integer} layer - The index of the layer to read the tile from. * @param {integer} x - The X coordinate, in tiles, to get the tile from. * @param {integer} y - The Y coordinate, in tiles, to get the tile from. * @return {Phaser.Tile} - The tile found. */ Phaser.Plugin.ArcadeSlopes.Overrides.getTileTopLeft = function(layer, x, y) { if (x > 0 && y > 0) { return this.layers[layer].data[y - 1][x - 1]; } return null; }; /** * Gets the tile to the top right of the coordinates given. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#getTileTopRight * @param {integer} layer - The index of the layer to read the tile from. * @param {integer} x - The X coordinate, in tiles, to get the tile from. * @param {integer} y - The Y coordinate, in tiles, to get the tile from. * @return {Phaser.Tile} - The tile found. */ Phaser.Plugin.ArcadeSlopes.Overrides.getTileTopRight = function(layer, x, y) { if (x < this.layers[layer].width - 1 && y > 0) { return this.layers[layer].data[y - 1][x + 1]; } return null; }; /** * Gets the tile to the bottom left of the coordinates given. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#getTileBottomLeft * @param {integer} layer - The index of the layer to read the tile from. * @param {integer} x - The X coordinate, in tiles, to get the tile from. * @param {integer} y - The Y coordinate, in tiles, to get the tile from. * @return {Phaser.Tile} - The tile found. */ Phaser.Plugin.ArcadeSlopes.Overrides.getTileBottomLeft = function(layer, x, y) { if (x > 0 && y < this.layers[layer].height - 1) { return this.layers[layer].data[y + 1][x - 1]; } return null; }; /** * Gets the tile to the bottom right of the coordinates given. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#getTileBottomRight * @param {integer} layer - The index of the layer to read the tile from. * @param {integer} x - The X coordinate, in tiles, to get the tile from. * @param {integer} y - The Y coordinate, in tiles, to get the tile from. * @return {Phaser.Tile} - The tile found. */ Phaser.Plugin.ArcadeSlopes.Overrides.getTileBottomRight = function(layer, x, y) { if (x < this.layers[layer].width - 1 && y < this.layers[layer].height - 1) { return this.layers[layer].data[y + 1][x + 1]; } return null; }; /** * Get the X axis collision offset for the tilemap layer. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#getCollisionOffsetY * @return {number} */ Phaser.Plugin.ArcadeSlopes.Overrides.getCollisionOffsetX = function () { if (this.getTileOffsetX) { return this.getTileOffsetX(); } return !this.fixedToCamera ? this.position.x : 0; }; /** * Get the Y axis collision offset for the tilemap layer. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#getCollisionOffsetY * @return {number} */ Phaser.Plugin.ArcadeSlopes.Overrides.getCollisionOffsetY = function () { if (this.getTileOffsetY) { return this.getTileOffsetY(); } return !this.fixedToCamera ? this.position.y : 0; }; /** * Renders a tilemap debug overlay on-top of the canvas. * * Called automatically by render when `debug` is true. * * See `debugSettings` for assorted configuration options. * * This override renders extra information regarding Arcade Slopes collisions. * * @method Phaser.Plugin.ArcadeSlopes.Overrides#renderDebug * @private */ Phaser.Plugin.ArcadeSlopes.Overrides.renderDebug = function () { var scrollX = this._mc.scrollX; var scrollY = this._mc.scrollY; var context = this.context; var renderW = this.canvas.width; var renderH = this.canvas.height; var scaleX = this.tileScale ? this.tileScale.x : 1.0 / this.scale.x; var scaleY = this.tileScale ? this.tileScale.y : 1.0 / this.scale.y; var width = this.layer.width; var height = this.layer.height; var tw = this._mc.tileWidth * scaleX; // Tile width var th = this._mc.tileHeight * scaleY; // Tile height var htw = tw / 2; // Half-tile width var hth = th / 2; // Half-tile height var qtw = tw / 4; // Quarter-tile width var qth = th / 4; // Quarter-tile height var cw = this._mc.cw * scaleX; var ch = this._mc.ch * scaleY; var m = this._mc.edgeMidpoint; var left = Math.floor(scrollX / tw); var right = Math.floor((renderW - 1 + scrollX) / tw); var top = Math.floor(scrollY / th); var bottom = Math.floor((renderH - 1 + scrollY) / th); if (!this._wrap) { if (left <= right) { left = Math.max(0, left); right = Math.min(width - 1, right); } if (top <= bottom) { top = Math.max(0, top); bottom = Math.min(height - 1, bottom); } } var baseX = (left * tw) - scrollX; var baseY = (top * th) - scrollY; var normStartX = (left + ((1 << 20) * width)) % width; var normStartY = (top + ((1 << 20) * height)) % height; var tx, ty, x, y, xmax, ymax, polygon, i, j, a, b, norm, gx, gy, line; for (y = normStartY, ymax = bottom - top, ty = baseY; ymax >= 0; y++, ymax--, ty += th) { if (y >= height) { y -= height; } var row = this.layer.data[y]; for (x = normStartX, xmax = right - left, tx = baseX; xmax >= 0; x++, xmax--, tx += tw) { if (x >= width) { x -= width; } var tile = row[x]; if (!tile || tile.index < 0 || !tile.collides) { continue; } if (this.debugSettings.collidingTileOverfill) { context.fillStyle = this.debugSettings.collidingTileOverfill; context.fillRect(tx, ty, cw, ch); } if (this.debugSettings.facingEdgeStroke) { context.beginPath(); context.lineWidth = 1; context.strokeStyle = this.debugSettings.facingEdgeStroke; if (tile.faceTop) { context.moveTo(tx, ty); context.lineTo(tx + cw, ty); } if (tile.faceBottom) { context.moveTo(tx, ty + ch); context.lineTo(tx + cw, ty + ch); } if (tile.faceLeft) { context.moveTo(tx, ty); context.lineTo(tx, ty + ch); } if (tile.faceRight) { context.moveTo(tx + cw, ty); context.lineTo(tx + cw, ty + ch); } context.closePath(); context.stroke(); // Render the tile slope polygons if (tile.slope) { // Fill polygons and stroke their edges if (this.debugSettings.slopeEdgeStroke || this.debugSettings.slopeFill) { context.beginPath(); context.lineWidth = 1; polygon = tile.slope.polygon; // Move to the first vertex context.moveTo(tx + polygon.points[0].x * scaleX, ty + polygon.points[0].y * scaleY); // Draw a path through all vertices for (i = 0; i < polygon.points.length; i++) { j = (i + 1) % polygon.points.length; context.lineTo(tx + polygon.points[j].x * scaleX, ty + polygon.points[j].y * scaleY); } context.closePath(); if (this.debugSettings.slopeEdgeStroke) { context.strokeStyle = this.debugSettings.slopeEdgeStroke; context.stroke(); } if (this.debugSettings.slopeFill) { context.fillStyle = this.debugSettings.slopeFill; context.fill(); } } // Stroke the colliding edges and edge normals if (this.debugSettings.slopeCollidingEdgeStroke) { // Colliding edges context.beginPath(); context.lineWidth = this.debugSettings.slopeCollidingEdgeStrokeWidth || 1; context.strokeStyle = this.debugSettings.slopeCollidingEdgeStroke; polygon = tile.slope.polygon; for (i = 0; i < polygon.points.length; i++) { // Skip the edges with ignored normals if (polygon.normals[i].ignore) { continue; } j = (i + 1) % polygon.points.length; context.moveTo(tx + polygon.points[i].x * scaleX, ty + polygon.points[i].y * scaleY); context.lineTo(tx + polygon.points[j].x * scaleX, ty + polygon.points[j].y * scaleY); } context.closePath(); context.stroke(); // Edge normals for (i = 0; i < polygon.points.length; i++) { context.beginPath(); if (polygon.normals[i].ignore) { context.lineWidth = this.debugSettings.slopeNormalStrokeWidth; context.strokeStyle = this.debugSettings.slopeNormalStroke; } else { context.lineWidth = this.debugSettings.slopeCollidingNormalStrokeWidth; context.strokeStyle = this.debugSettings.slopeCollidingNormalStroke; } j = (i + 1) % polygon.points.length; a = polygon.points[i]; b = polygon.points[j]; norm = polygon.normals[i]; // Midpoint of the edge m.x = (a.x + b.x) / 2; m.y = (a.y + b.y) / 2; // Draw from the midpoint outwards using the normal context.moveTo(tx + m.x * scaleX, ty + m.y * scaleY); context.lineTo(tx + m.x * scaleX + norm.x * qtw, ty + m.y * scaleY + norm.y * qth); context.closePath(); context.stroke(); } // Ignormals if (tile.slope.ignormals) { for (i = 0; i < tile.slope.ignormals.length; i++) { context.beginPath(); context.lineWidth = 1; context.strokeStyle = 'rgba(255, 0, 0, 1)'; gx = tile.slope.ignormals[i].x; gy = tile.slope.ignormals[i].y; context.moveTo(tx + htw, ty + hth); context.lineTo(tx + htw + gx * qtw, ty + hth + gy * qth); context.closePath(); context.stroke(); } } } // Slope line segments if (this.debugSettings.slopeLineStroke && tile.slope.line) { line = tile.slope.line; context.beginPath(); context.lineWidth = this.debugSettings.slopeLineWidth || 2; context.strokeStyle = this.debugSettings.slopeLineStroke; context.moveTo(line.start.x - scrollX, line.start.y - scrollY); context.lineTo(line.end.x - scrollX, line.end.y - scrollY); context.closePath(); context.stroke(); } } } } } }; /** * @author Chris Andrew <chris@hexus.io> * @copyright 2016-2021 Chris Andrew * @license MIT */ /** * Solves tile collisions using the Separating Axis Theorem. * * @class Phaser.Plugin.ArcadeSlopes.SatSolver * @constructor * @param {object} options - Options for the SAT solver. */ Phaser.Plugin.ArcadeSlopes.SatSolver = function (options) { /** * Options for the SAT solver. * * @property {object} options */ this.options = Phaser.Utils.mixin(options || {}, { // Whether to store debug data with all encountered physics bodies debug: false, // Whether to prefer the minimum Y offset over the smallest separation preferY: false }); /** * A pool of arrays to use for calculations. * * @property {Array[]} arrayPool */ this.arrayPool = []; for (var i = 0; i < 10; i++) { this.arrayPool.push([]); } /** * A pool of vectors to use for calculations. * * @property {SAT.Vector[]} vectorPool */ this.vectorPool = []; for (i = 0; i < 20; i++) { this.vectorPool.push(new SAT.Vector()); } /** * A pool of responses to use for collision tests. * * @property {SAT.Response[]} responsePool */ this.responsePool = []; for (i = 0; i < 20; i++) { this.responsePool.push(new SAT.Response()); } }; /** * Prepare the given SAT response by inverting the overlap vectors. * * @static * @method Phaser.Plugin.ArcadeSlopes.SatSolver#prepareResponse * @param {SAT.Response} response * @return {SAT.Response} */ Phaser.Plugin.ArcadeSlopes.SatSolver.prepareResponse = function (response) { // Invert our overlap vectors so that we have them facing outwards response.overlapV.scale(-1); response.overlapN.scale(-1); return response; }; /** * Reset the given SAT response's properties to their default values. * * @static * @method Phaser.Plugin.ArcadeSlopes.SatSolver#resetResponse * @param {SAT.Response} response * @return {SAT.Response} */ Phaser.Plugin.ArcadeSlopes.SatSolver.resetResponse = function (response) { response.overlapN.x = 0; response.overlapN.y = 0; response.overlapV.x = 0; response.overlapV.y = 0; response.clear(); return response; }; /** * Copy the values of one SAT response to another. * * @static * @method Phaser.Plugin.ArcadeSlopes.SatSolver#copyResponse * @param {SAT.Response} a - The source response. * @param {SAT.Response} b - The target response. * @return {SAT.Response} */ Phaser.Plugin.ArcadeSlopes.SatSolver.copyResponse = function (a, b) { b.a = a.a; b.b = a.b; b.aInB = a.aInB; b.bInA = a.bInA; b.overlap = a.overlap; b.overlapN.copy(a.overlapN); b.overlapV.copy(a.overlapV); return b; }; /** * Calculate the minimum X offset given an overlap vector. * * @static * @method Phaser.Plugin.ArcadeSlopes.SatSolver#minimumOffsetX * @param {SAT.Vector} vector - The overlap vector. * @return {integer} */ Phaser.Plugin.ArcadeSlopes.SatSolver.minimumOffsetX = function (vector) { return ((vector.y * vector.y) / vector.x) + vector.x; }; /** * Calculate the minimum Y offset given an overlap vector. * * @static * @method Phaser.Plugin.ArcadeSlopes.SatSolver#minimumOffsetY * @param {SAT.Vector} vector - The overlap vector. * @return {integer} */ Phaser.Plugin.ArcadeSlopes.SatSolver.minimumOffsetY = function (vector) { return ((vector.x * vector.x) / vector.y) + vector.y; }; /** * Determine whether the given body is moving against the overlap vector of the * given response on the Y axis. * * @static * @method Phaser.Plugin.ArcadeSlopes.SatSolver#movingAgainstY * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {SAT.Response} response - The SAT response. * @return {boolean} - Whether the body is moving against the overlap vector. */ Phaser.Plugin.ArcadeSlopes.SatSolver.movingAgainstY = function (body, response) { return (response.overlapV.y < 0 && body.velocity.y > 0) || (response.overlapV.y > 0 && body.velocity.y < 0); }; // TODO: shouldPreferX() /** * Determine whether a body should be separated on the Y axis only, given an SAT * response. * * Returns true if options.preferY is true, the overlap vector is non-zero * for each axis and the body is moving against the overlap vector. * * TODO: Adapt for circle bodies, somehow. Disable for now? * TODO: Would be amazing to check to ensure that there are no other surrounding collisions. * * @method Phaser.Plugin.ArcadeSlopes.SatSolver#shouldPreferY * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {SAT.Response} response - The SAT response. * @return {boolean} - Whether to separate on the Y axis only. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.shouldPreferY = function (body, response) { return (this.options.preferY || body.slopes.preferY) && // Enabled globally or on the body response.overlapV.y !== 0 && response.overlapV.x !== 0 && // There's an overlap on both axes Phaser.Plugin.ArcadeSlopes.SatSolver.movingAgainstY(body, response); // And we're moving into the shape }; /** * Separate a body from a tile using the given SAT response. * * @method Phaser.Plugin.ArcadeSlopes.SatSolver#separate * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {Phaser.Tile} tile - The tile. * @param {SAT.Response} response - The SAT response. * @param {boolean} force - Whether to force separation. * @return {boolean} - Whether the body was separated. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.separate = function (body, tile, response, force) { // Test whether we need to separate from the tile by checking its edge // properties and any separation constraints if (!force && !this.shouldSeparate(tile.index, body, tile, response)) { return false; } // Run any custom tile callbacks, with local callbacks taking priority over // layer level callbacks if (tile.collisionCallback && !tile.collisionCallback.call(tile.collisionCallbackContext, body.sprite, tile)) { return false; } else if (tile.layer.callbacks[tile.index] && !tile.layer.callbacks[tile.index].callback.call(tile.layer.callbacks[tile.index].callbackContext, body.sprite, tile)) { return false; } // Separate the body from the tile, using the minimum Y offset if preferred if (this.shouldPreferY(body, response)) { body.position.y += Phaser.Plugin.ArcadeSlopes.SatSolver.minimumOffsetY(response.overlapV); } else { body.position.x += response.overlapV.x; body.position.y += response.overlapV.y; } return true; }; /** * Apply velocity changes (friction and bounce) to a body given a tile and * SAT collision response. * * TODO: Optimize by pooling bounce and friction vectors. * * @method Phaser.Plugin.ArcadeSlopes.SatSolver#applyVelocity * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {Phaser.Tile} tile - The tile. * @param {SAT.Response} response - The SAT response. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.applyVelocity = function (body, tile, response) { // Project our velocity onto the overlap normal for the bounce vector (Vn) var bounce = this.vectorPool.pop().copy(body.slopes.velocity).projectN(response.overlapN); // Then work out the surface vector (Vt) var friction = this.vectorPool.pop().copy(body.slopes.velocity).sub(bounce); // Apply bounce coefficients bounce.x = bounce.x * (-body.bounce.x); bounce.y = bounce.y * (-body.bounce.y); // Apply friction coefficients friction.x = friction.x * (1 - body.slopes.friction.x - tile.slope.friction.x); friction.y = friction.y * (1 - body.slopes.friction.y - tile.slope.friction.y); // Now we can get our new velocity by adding the bounce and friction vectors body.velocity.x = bounce.x + friction.x; body.velocity.y = bounce.y + friction.y; // Process collision pulling this.pull(body, response); // Recycle the vectors we used for bounce and friction this.vectorPool.push(bounce, friction); }; /** * Update the position and velocity values of the slopes body. * * @method Phaser.Plugin.ArcadeSlopes.SatSolver#updateValues * @param {Phaser.Physics.Arcade.Body} body - The physics body. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.updateValues = function (body) { // Update the body polygon position body.polygon.pos.x = body.x; body.polygon.pos.y = body.y; // Update the body's velocity vector body.slopes.velocity.x = body.velocity.x; body.slopes.velocity.y = body.velocity.y; }; /** * Update the flags of a physics body using a given SAT response. * * @method Phaser.Plugin.ArcadeSlopes.SatSolver#updateFlags * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {SAT.Response} response - The SAT response. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.updateFlags = function (body, response) { // Set the touching values body.touching.up = body.touching.up || response.overlapV.y > 0; body.touching.down = body.touching.down || response.overlapV.y < 0; body.touching.left = body.touching.left || response.overlapV.x > 0; body.touching.right = body.touching.right || response.overlapV.x < 0; body.touching.none = !body.touching.up && !body.touching.down && !body.touching.left && !body.touching.right; // Set the blocked values body.blocked.up = body.blocked.up || response.overlapV.x === 0 && response.overlapV.y > 0; body.blocked.down = body.blocked.down || response.overlapV.x === 0 && response.overlapV.y < 0; body.blocked.left = body.blocked.left || response.overlapV.y === 0 && response.overlapV.x > 0; body.blocked.right = body.blocked.right || response.overlapV.y === 0 && response.overlapV.x < 0; }; /** * Pull the body into a collision response based on its slopes options. * * TODO: Don't return after any condition is met, accumulate values into a * single SAT.Vector and apply at the end. * * @method Phaser.Plugin.ArcadeSlopes.SatSolver#pull * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {SAT.Response} response - The SAT response. * @return {boolean} - Whether the body was pulled. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.pull = function (body, response) { if (!body.slopes.pullUp && !body.slopes.pullDown && !body.slopes.pullLeft && !body.slopes.pullRight && !body.slopes.pullTopLeft && !body.slopes.pullTopRight && !body.slopes.pullBottomLeft && !body.slopes.pullBottomRight) { return false; } // Clone and flip the overlap normal so that it faces into the collision var overlapN = response.overlapN.clone().scale(-1); if (body.slopes.pullUp && overlapN.y < 0) { // Scale it by the configured amount pullUp = overlapN.clone().scale(body.slopes.pullUp); // Apply it to the body velocity body.velocity.x += pullUp.x; body.velocity.y += pullUp.y; return true; } if (body.slopes.pullDown && overlapN.y > 0) { pullDown = overlapN.clone().scale(body.slopes.pullDown); body.velocity.x += pullDown.x; body.velocity.y += pullDown.y; return true; } if (body.slopes.pullLeft && overlapN.x < 0) { pullLeft = overlapN.clone().scale(body.slopes.pullLeft); body.velocity.x += pullLeft.x; body.velocity.y += pullLeft.y; return true; } if (body.slopes.pullRight && overlapN.x > 0) { pullRight = overlapN.clone().scale(body.slopes.pullRight); body.velocity.x += pullRight.x; body.velocity.y += pullRight.y; return true; } if (body.slopes.pullTopLeft && overlapN.x < 0 && overlapN.y < 0) { pullUp = overlapN.clone().scale(body.slopes.pullTopLeft); body.velocity.x += pullUp.x; body.velocity.y += pullUp.y; return true; } if (body.slopes.pullTopRight && overlapN.x > 0 && overlapN.y < 0) { pullDown = overlapN.clone().scale(body.slopes.pullTopRight); body.velocity.x += pullDown.x; body.velocity.y += pullDown.y; return true; } if (body.slopes.pullBottomLeft && overlapN.x < 0 && overlapN.y > 0) { pullLeft = overlapN.clone().scale(body.slopes.pullBottomLeft); body.velocity.x += pullLeft.x; body.velocity.y += pullLeft.y; return true; } if (body.slopes.pullBottomRight && overlapN.x > 0 && overlapN.y > 0) { pullRight = overlapN.clone().scale(body.slopes.pullBottomRight); body.velocity.x += pullRight.x; body.velocity.y += pullRight.y; return true; } return false; }; /** * Determine whether everything required to process a collision is available. * * @method Phaser.Plugin.ArcadeSlopes.SatSolver#shouldCollide * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {Phaser.Tile} tile - The tile. * @return {boolean} */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.shouldCollide = function (body, tile) { return body.enable && body.polygon && body.slopes && tile.collides && tile.slope && tile.slope.polygon; }; /** * Flattens the specified array of points onto a unit vector axis, * resulting in a one dimensional range of the minimum and * maximum value on that axis. * * Copied verbatim from SAT.flattenPointsOn. * * @see SAT.flattenPointsOn * @static * @method Phaser.Plugin.ArcadeSlopes.SatSolver#flattenPointsOn * @param {SAT.Vector[]} points - The points to flatten. * @param {SAT.Vector} normal - The unit vector axis to flatten on. * @param {number[]} result - An array. After calling this, * result[0] will be the minimum value, * result[1] will be the maximum value. */ Phaser.Plugin.ArcadeSlopes.SatSolver.flattenPointsOn = function (points, normal, result) { var min = Number.MAX_VALUE; var max = -Number.MAX_VALUE; var len = points.length; for (var i = 0; i < len; i++ ) { // The magnitude of the projection of the point onto the normal var dot = points[i].dot(normal); if (dot < min) { min = dot; } if (dot > max) { max = dot; } } result[0] = min; result[1] = max; }; /** * Determine whether two polygons are separated by a given axis. * * Tailored to only push out in the direction of the given axis. * * Adapted from SAT.isSeparatingAxis. * * @see {SAT.isSeparatingAxis} * @method Phaser.Plugin.ArcadeSlopes.SatSolver#isSeparatingAxis * @param {SAT.Polygon} a - The first polygon. * @param {SAT.Polygon} b - The second polygon. * @param {SAT.Vector} axis - The axis (unit sized) to test against. * The points of both polygons are projected * onto this axis. * @param {SAT.Response} response - The response to populate if the polygons are * not separated by the given axis. * @return {boolean} true if it is a separating axis, false otherwise. If false, * and a response is passed in, information about how much overlap and * the direction of the overlap will be populated. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.isSeparatingAxis = function (a, b, axis, response) { var aPos = a.pos; var bPos = b.pos; var aPoints = a.calcPoints; var bPoints = b.calcPoints; var rangeA = this.arrayPool.pop(); var rangeB = this.arrayPool.pop(); // The magnitude of the offset between the two polygons var offsetV = this.vectorPool.pop().copy(bPos).sub(aPos); var projectedOffset = offsetV.dot(axis); // Project the polygons onto the axis. Phaser.Plugin.ArcadeSlopes.SatSolver.flattenPointsOn(aPoints, axis, rangeA); Phaser.Plugin.ArcadeSlopes.SatSolver.flattenPointsOn(bPoints, axis, rangeB); // Move B's range to its position relative to A. rangeB[0] += projectedOffset; rangeB[1] += projectedOffset; // Check if there is a gap. If there is, this is a separating axis and we can stop if (rangeA[0] >= rangeB[1] || rangeB[0] >= rangeA[1]) { this.vectorPool.push(offsetV); this.arrayPool.push(rangeA); this.arrayPool.push(rangeB); return true; } var option1, option2; // This is not a separating axis. If we're calculating a response, calculate // the overlap var overlap = 0; if (rangeA[0] < rangeB[0]) { // A starts further left than B response.aInB = false; if (rangeA[1] < rangeB[1]) { // A ends before B does. We have to pull A out of B //overlap = rangeA[1] - rangeB[0]; response.bInA = false; }// else { // B is fully inside A. Pick the shortest way out. //option1 = rangeA[1] - rangeB[0]; //option2 = rangeB[1] - rangeA[0]; //overlap = option1 < option2 ? option1 : -option2; //} } else { // B starts further left than A response.bInA = false; if (rangeA[1] > rangeB[1]) { // B ends before A ends. We have to push A out of B overlap = rangeA[0] - rangeB[1]; response.aInB = false; } else { // A is fully inside B. Pick the shortest way out. option1 = rangeA[1] - rangeB[0]; option2 = rangeB[1] - rangeA[0]; //overlap = option1 < option2 ? option1 : -option2; if (option1 >= option2) { overlap = -option2; } } } // If this is the smallest amount of overlap we've seen so far, set it // as the minimum overlap. var absOverlap = Math.abs(overlap); if (absOverlap < response.overlap) { response.overlap = absOverlap; response.overlapN.copy(axis); if (overlap < 0) { response.overlapN.reverse(); } } this.vectorPool.push(offsetV); this.arrayPool.push(rangeA); this.arrayPool.push(rangeB); return false; }; /** * Test whether two polygons overlap. * * Takes a response object that will be populated with the shortest * viable separation vector. Ignores collision responses that don't oppose * velocity enough. * * Returns true if there is a collision and false otherwise. * * Tailored to work with an AABB as the first polygon. * * Adapted from SAT.testPolygonPolygon. * * @see {SAT.testPolygonPolygon} * @method Phaser.Plugin.ArcadeSlopes.SatSolver#testPolygonPolygon * @param {SAT.Polygon} a - The first polygon. * @param {SAT.Polygon} b - The second polygon. * @param {SAT.Response} response - The response object to populate with overlap information. * @param {SAT.Vector} velocity - The velocity vector to ignore. * @param {SAT.Vector[]} ignore - The axes to ignore. * @return {boolean} - Whether the the two polygons overlap. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.testPolygonPolygon = function (a, b, response, velocity, ignore) { var aPoints = a.calcPoints; var aLen = aPoints.length; var bPoints = b.calcPoints; var bLen = bPoints.length; var i, j, k; var responses = this.arrayPool.pop(); var axes = this.arrayPool.pop(); responses.length = 0; axes.length = 0; // If any of the edge normals of A is a separating axis, no intersection for (i = 0; i < aLen; i++) { responses[i] = this.responsePool.pop(); responses[i].clear(); axes[i] = a.normals[i]; if (this.isSeparatingAxis(a, b, a.normals[i], responses[i])) { for (k = 0; k < responses.length; k++) { this.responsePool.push(responses[k]); } this.arrayPool.push(responses, axes); return false; } } // If any of the edge normals of B is a separating axis, no intersection for (i = 0, j = aLen; i < bLen; i++, j++) { responses[j] = this.responsePool.pop(); responses[j].clear(); axes[j] = b.normals[i]; if (this.isSeparatingAxis(a, b, b.normals[i], responses[j])) { for (k = 0; k < responses.length; k++) { this.responsePool.push(responses[k]); } this.arrayPool.push(responses, axes); return false; } } // Since none of the edge normals of A or B are a separating axis, there is // an intersection var viable = false; var ignored = false; var velocityTestVector = this.vectorPool.pop(); // Determine the shortest desirable and viable separation from the responses for (i = 0; i < responses.length; i++) { // Is the overlap in the range we want? // TODO: Less than the max of tile width/height? if (!(responses[i].overlap > 0 && responses[i].overlap < Number.MAX_VALUE)) { continue; } // Is the overlap direction too close to that of the velocity direction? if (velocity && velocityTestVector.copy(responses[i].overlapN).scale(-1).dot(velocity) > 0) { continue; } ignored = false; // Is the axis of the overlap in the extra ignore list? for (j = 0; j < ignore.length; j++) { if (axes[i].x === ignore[j].x && axes[i].y === ignore[j].y) { ignored = true; break; } } // Skip this response if its normal is ignored if (ignored) { continue; } // Is this response's overlap shorter than that of the current? if (responses[i].overlap < response.overlap) { viable = true; response.aInB = responses[i].aInB; response.bInA = responses[i].bInA; response.overlap = responses[i].overlap; response.overlapN = responses[i].overlapN; } } // Set the polygons on the response and calculate the overlap vector if (viable) { response.a = a; response.b = b; response.overlapV.copy(response.overlapN).scale(response.overlap); } // Recycle the temporary responses, arrays and vectors used for calculations for (k = 0; k < responses.length; k++) { this.responsePool.push(responses[k]); } this.arrayPool.push(responses, axes); this.vectorPool.push(velocityTestVector); return viable; }; /** * Separate the given body and tile from each other and apply any relevant * changes to the body's velocity. * * @method Phaser.Plugin.ArcadeSlopes.SatSolver#collide * @param {integer} i - The tile index. * @param {Phaser.Physics.Arcade.Body} body - The physics body. * @param {Phaser.Tile} tile - The tile. * @param {Phaser.TilemapLayer} tilemapLayer - The tilemap layer. * @param {boolean} overlapOnly - Whether to only check for an overlap. * @return {boolean} - Whether the body was separated. */ Phaser.Plugin.ArcadeSlopes.SatSolver.prototype.collide = function (i, body, tile, tilemapLayer, overlapOnly) { // Update the body's polygon position and velocity vector this.updateValues(body); // Bail out if we don't have everything we need if (!this.shouldCollide(body, tile)) {