shaku
Version:
A simple and effective JavaScript game development framework that knows its place!
630 lines (554 loc) • 22.5 kB
JavaScript
/**
* Implement the collision manager.
*
* |-- copyright and license --|
* @module Shaku
* @file shaku\src\collision\collision_world.js
* @author Ronen Ness (ronenness@gmail.com | http://ronenness.com)
* @copyright (c) 2021 Ronen Ness
* @license MIT
* |-- end copyright and license --|
*
*/
'use strict';
const Color = require("../utils/color");
const Vector2 = require("../utils/vector2");
const Circle = require("../utils/circle");
const CollisionTestResult = require("./result");
const CollisionShape = require("./shapes/shape");
const gfx = require('./../gfx');
const Rectangle = require("../utils/rectangle");
const CollisionResolver = require("./resolver");
const PointShape = require("./shapes/point");
const CircleShape = require("./shapes/circle");
const ShapesBatch = require("../gfx/draw_batches/shapes_batch");
const _logger = require('../logger.js').getLogger('collision');
/**
* A collision world is a set of collision shapes that interact with each other.
* You can use different collision worlds to represent different levels or different parts of your game world.
*/
class CollisionWorld
{
/**
* Create the collision world.
* @param {CollisionResolver} resolver Collision resolver to use for this world.
* @param {Number|Vector2} gridCellSize For optimize collision testing, the collision world is divided into a collision grid. This param determine the grid cell size.
*/
constructor(resolver, gridCellSize)
{
/**
* Collision resolver used in this collision world.
* By default, will inherit the collision manager default resolver.
*/
this.resolver = resolver;
// set grid cell size
if (typeof gridCellSize === 'undefined') { gridCellSize = new Vector2(512, 512); }
else if (typeof gridCellSize === 'number') { gridCellSize = new Vector2(gridCellSize, gridCellSize); }
else { gridCellSize = gridCellSize.clone(); }
this._gridCellSize = gridCellSize;
// create collision grid
this._grid = {};
// shapes that need updates and grid chunks to delete
this._shapesToUpdate = new Set();
this._cellsToDelete = new Set();
// reset stats data
this.resetStats();
}
/**
* Reset stats.
*/
resetStats()
{
this._stats = {
updatedShapes: 0,
addedShapes: 0,
deletedGridCells: 0,
createdGridCell: 0,
broadPhaseShapesChecksPrePredicate: 0,
broadPhaseShapesChecksPostPredicate: 0,
broadPhaseCalls: 0,
collisionChecks: 0,
collisionMatches: 0
}
}
/**
* Get current stats.
* @returns {*} Dictionary with the following stats:
* updatedShapes: number of times we updated or added new shapes.
* addedShapes: number of new shapes added.
* deletedGridCells: grid cells that got deleted after they were empty.
* createdGridCell: new grid cells created.
* broadPhaseShapesChecksPrePredicate: how many shapes were tested in a broadphase check, before the predicate method was called.
* broadPhaseShapesChecksPostPredicate: how many shapes were tested in a broadphase check, after the predicate method was called.
* broadPhaseCalls: how many broadphase calls were made
* collisionChecks: how many shape-vs-shape collision checks were actually made.
* collisionMatches: how many collision checks were positive.
*/
get stats()
{
return this._stats;
}
/**
* Do collision world updates, if we have any.
* @private
*/
#_performUpdates()
{
// delete empty grid cells
if (this._cellsToDelete.size > 0) {
this._stats.deletedGridCells += this._cellsToDelete.size;
for (let key of this._cellsToDelete) {
if (this._grid[key] && this._grid[key].size === 0) {
delete this._grid[key];
}
}
this._cellsToDelete.clear();
}
// update all shapes
if (this._shapesToUpdate.size > 0) {
for (let shape of this._shapesToUpdate) {
this.#_updateShape(shape);
}
this._shapesToUpdate.clear();
}
}
/**
* Get or create cell.
* @private
*/
#_getCell(i, j)
{
let key = i + ',' + j;
let ret = this._grid[key];
if (!ret) {
this._stats.createdGridCells++;
this._grid[key] = ret = new Set();
}
return ret;
}
/**
* Update a shape in collision world after it moved or changed.
* @private
*/
#_updateShape(shape)
{
// sanity - if no longer in this collision world, skip
if (shape._world !== this) {
return;
}
// update shapes
this._stats.updatedShapes++;
// get new range
let bb = shape._getBoundingBox();
let minx = Math.floor(bb.left / this._gridCellSize.x);
let miny = Math.floor(bb.top / this._gridCellSize.y);
let maxx = Math.ceil(bb.right / this._gridCellSize.x);
let maxy = Math.ceil(bb.bottom / this._gridCellSize.y);
// change existing grid cells
if (shape._worldRange)
{
// range is the same? skip
if (shape._worldRange[0] === minx &&
shape._worldRange[1] === miny &&
shape._worldRange[2] === maxx &&
shape._worldRange[3] === maxy) {
return;
}
// get old range
let ominx = shape._worldRange[0];
let ominy = shape._worldRange[1];
let omaxx = shape._worldRange[2];
let omaxy = shape._worldRange[3];
// first remove from old chunks we don't need
for (let i = ominx; i < omaxx; ++i) {
for (let j = ominy; j < omaxy; ++j) {
// if also in new range, don't remove
if (i >= minx && i < maxx && j >= miny && j < maxy) {
continue;
}
// remove from cell
let key = i + ',' + j;
let currSet = this._grid[key];
if (currSet) {
currSet.delete(shape);
if (currSet.size === 0) {
this._cellsToDelete.add(key);
}
}
}
}
// now add to new cells
for (let i = minx; i < maxx; ++i) {
for (let j = miny; j < maxy; ++j) {
// if was in old range, don't add
if (i >= ominx && i < omaxx && j >= ominy && j < omaxy) {
continue;
}
// add to new cell
let currSet = this.#_getCell(i, j);
currSet.add(shape);
}
}
}
// first-time adding to grid
else {
this._stats.addedShapes++;
for (let i = minx; i < maxx; ++i) {
for (let j = miny; j < maxy; ++j) {
let currSet = this.#_getCell(i, j);
currSet.add(shape);
}
}
}
// update new range
shape._worldRange = [minx, miny, maxx, maxy];
}
/**
* Request update for this shape on next updates call.
* @private
*/
_queueUpdate(shape)
{
this._shapesToUpdate.add(shape);
}
/**
* Iterate all shapes in world.
* @param {Function} callback Callback to invoke on all shapes. Return false to break iteration.
*/
iterateShapes(callback)
{
for (let key in this._grid) {
let cell = this._grid[key];
if (cell) {
for (let shape of cell)
{
if (callback(shape) === false) {
return;
}
}
}
}
}
/**
* Add a collision shape to this world.
* @param {CollisionShape} shape Shape to add.
*/
addShape(shape)
{
// add shape
shape._setParent(this);
// add shape to grid
this.#_updateShape(shape);
// do general updates
this.#_performUpdates();
}
/**
* Remove a collision shape from this world.
* @param {CollisionShape} shape Shape to remove.
*/
removeShape(shape)
{
// sanity
if (shape._world !== this) {
_logger.warn("Shape to remove is not in this collision world!");
return;
}
// remove from grid
if (shape._worldRange) {
let minx = shape._worldRange[0];
let miny = shape._worldRange[1];
let maxx = shape._worldRange[2];
let maxy = shape._worldRange[3];
for (let i = minx; i < maxx; ++i) {
for (let j = miny; j < maxy; ++j) {
let key = i + ',' + j;
let currSet = this._grid[key];
if (currSet) {
currSet.delete(shape);
if (currSet.size === 0) {
this._cellsToDelete.add(key);
}
}
}
}
}
// remove shape
this._shapesToUpdate.delete(shape);
shape._setParent(null);
// do general updates
this.#_performUpdates();
}
/**
* Iterate shapes that match broad phase test.
* @private
* @param {CollisionShape} shape Shape to test.
* @param {Function} handler Method to run on all shapes in phase. Return true to continue iteration, false to break.
* @param {Number} mask Optional mask of bits to match against shapes collisionFlags. Will only return shapes that have at least one common bit.
* @param {Function} predicate Optional filter to run on any shape we're about to test collision with.
*/
#_iterateBroadPhase(shape, handler, mask, predicate)
{
// get grid range
let bb = shape._getBoundingBox();
let minx = Math.floor(bb.left / this._gridCellSize.x);
let miny = Math.floor(bb.top / this._gridCellSize.y);
let maxx = Math.ceil(bb.right / this._gridCellSize.x);
let maxy = Math.ceil(bb.bottom / this._gridCellSize.y);
// update stats
this._stats.broadPhaseCalls++;
// shapes we checked
let checked = new Set();
// iterate options
for (let i = minx; i < maxx; ++i) {
for (let j = miny; j < maxy; ++j) {
// get current grid chunk
let key = i + ',' + j;
let currSet = this._grid[key];
// iterate shapes in grid chunk
if (currSet) {
for (let other of currSet) {
// check collision flags
if (mask && ((other.collisionFlags & mask) === 0)) {
continue;
}
// skip if checked
if (checked.has(other)) {
continue;
}
checked.add(other);
// skip self
if (other === shape) {
continue;
}
// update stats
this._stats.broadPhaseShapesChecksPrePredicate++;
// use predicate
if (predicate && !predicate(other)) {
continue;
}
// update stats
this._stats.broadPhaseShapesChecksPostPredicate++;
// invoke handler on shape
let proceedLoop = Boolean(handler(other));
// break loop
if (!proceedLoop) {
return;
}
}
}
}
}
}
/**
* Test collision with shapes in world, and return just the first result found.
* @param {CollisionShape} sourceShape Source shape to check collision for. If shape is in world, it will not collide with itself.
* @param {Boolean} sortByDistance If true will return the nearest collision found (based on center of shapes).
* @param {Number} mask Optional mask of bits to match against shapes collisionFlags. Will only return shapes that have at least one common bit.
* @param {Function} predicate Optional filter to run on any shape we're about to test collision with. If the predicate returns false, we will skip this shape.
* @returns {CollisionTestResult} A collision test result, or null if not found.
*/
testCollision(sourceShape, sortByDistance, mask, predicate)
{
// do updates before check
this.#_performUpdates();
// result to return
var result = null;
// hard case - single result, sorted by distance
if (sortByDistance)
{
// build options array
var options = [];
this.#_iterateBroadPhase(sourceShape, (other) => {
options.push(other);
return true;
}, mask, predicate);
// sort options
sortByDistanceShapes(sourceShape, options);
// check collision sorted
var handlers = this.resolver.getHandlers(sourceShape);
for (let other of options) {
this._stats.collisionChecks++;
result = this.resolver.testWithHandler(sourceShape, other, handlers[other.shapeId]);
if (result) {
this._stats.collisionMatches++;
break;
}
}
}
// easy case - single result, not sorted
else
{
// iterate possible shapes and test collision
var handlers = this.resolver.getHandlers(sourceShape);
this.#_iterateBroadPhase(sourceShape, (other) => {
// test collision and continue iterating if we don't have a result
this._stats.collisionChecks++;
result = this.resolver.testWithHandler(sourceShape, other, handlers[other.shapeId]);
if (result) { this._stats.collisionMatches++; }
return !result;
}, mask, predicate);
}
// return result
return result;
}
/**
* Test collision with shapes in world, and return all results found.
* @param {CollisionShape} sourceShape Source shape to check collision for. If shape is in world, it will not collide with itself.
* @param {Boolean} sortByDistance If true will sort results by distance.
* @param {Number} mask Optional mask of bits to match against shapes collisionFlags. Will only return shapes that have at least one common bit.
* @param {Function} predicate Optional filter to run on any shape we're about to test collision with. If the predicate returns false, we will skip this shape.
* @param {Function} intermediateProcessor Optional method to run after each positive result with the collision result as param. Return false to stop and return results.
* @returns {Array<CollisionTestResult>} An array of collision test results, or empty array if none found.
*/
testCollisionMany(sourceShape, sortByDistance, mask, predicate, intermediateProcessor)
{
// do updates before check
this.#_performUpdates();
// get collisions
var ret = [];
var handlers = this.resolver.getHandlers(sourceShape);
this.#_iterateBroadPhase(sourceShape, (other) => {
this._stats.collisionChecks++;
let result = this.resolver.testWithHandler(sourceShape, other, handlers[other.shapeId]);
if (result) {
this._stats.collisionMatches++;
ret.push(result);
if (intermediateProcessor && intermediateProcessor(result) === false) {
return false;
}
}
return true;
}, mask, predicate);
// sort by distance
if (sortByDistance) {
sortByDistanceResults(sourceShape, ret);
}
// return results
return ret;
}
/**
* Return array of shapes that touch a given position, with optional radius.
* @example
* let shapes = world.pick(Shaku.input.mousePosition);
* @param {*} position Position to pick.
* @param {*} radius Optional picking radius to use a circle instead of a point.
* @param {*} sortByDistance If true, will sort results by distance from point.
* @param {*} mask Collision mask to filter by.
* @param {*} predicate Optional predicate method to filter by.
* @returns {Array<CollisionShape>} Array with collision shapes we picked.
*/
pick(position, radius, sortByDistance, mask, predicate)
{
let shape = ((radius || 0) <= 1) ? new PointShape(position) : new CircleShape(new Circle(position, radius));
let ret = this.testCollisionMany(shape, sortByDistance, mask, predicate);
return ret.map(x => x.second);
}
/**
* Set the shapes batch to use for debug-drawing this collision world.
* @param {ShapesBatch} batch Batch to use for debug draw.
*/
setDebugDrawBatch(batch)
{
this.__debugDrawBatch = batch;
}
/**
* Return the currently set debug draw batch, or create a new one if needed.
* @returns {ShapesBatch} Shapes batch instance used to debug-draw collision world.
*/
getOrCreateDebugDrawBatch()
{
if (!this.__debugDrawBatch) {
this.setDebugDrawBatch(new gfx.ShapesBatch());
}
return this.__debugDrawBatch;
}
/**
* Debug-draw the current collision world.
* @param {Color} gridColor Optional grid color (default to black).
* @param {Color} gridHighlitColor Optional grid color for cells with shapes in them (default to red).
* @param {Number} opacity Optional opacity factor (default to 0.5).
* @param {Camera} camera Optional camera for offset and viewport.
*/
debugDraw(gridColor, gridHighlitColor, opacity, camera)
{
// if we don't have a debug-draw batch, create it
let shapesBatch = this.getOrCreateDebugDrawBatch();
// begin drawing
shapesBatch.begin();
// do updates before check
this.#_performUpdates();
// default grid colors
if (!gridColor) {
gridColor = Color.black;
gridColor.a *= 0.75;
}
if (!gridHighlitColor) {
gridHighlitColor = Color.red;
gridHighlitColor.a *= 0.75;
}
// default opacity
if (opacity === undefined) {
opacity = 0.5;
}
// set grid color opacity
gridColor.a *= opacity * 0.75;
gridHighlitColor.a *= opacity * 0.75;
// all shapes we rendered
let renderedShapes = new Set();
// get visible grid cells
let bb = camera ? camera.getRegion() : gfx._internal.getRenderingRegionInternal(false);
let minx = Math.floor(bb.left / this._gridCellSize.x);
let miny = Math.floor(bb.top / this._gridCellSize.y);
let maxx = minx + Math.ceil(bb.width / this._gridCellSize.x);
let maxy = miny + Math.ceil(bb.height / this._gridCellSize.y);
for (let i = minx; i <= maxx; ++i) {
for (let j = miny; j <= maxy; ++j) {
// get current cell
let cell = this._grid[i + ',' + j];
// draw grid cell
let color = (cell && cell.size) ? gridHighlitColor : gridColor;
let cellRect1 = new Rectangle(i * this._gridCellSize.x, j * this._gridCellSize.y, this._gridCellSize.x, 2);
let cellRect2 = new Rectangle(i * this._gridCellSize.x, j * this._gridCellSize.y, 2, this._gridCellSize.y);
shapesBatch.drawRectangle(cellRect1, color);
shapesBatch.drawRectangle(cellRect2, color);
// draw shapes in grid
if (cell) {
for (let shape of cell)
{
if (renderedShapes.has(shape)) {
continue;
}
renderedShapes.add(shape);
shape.debugDraw(opacity, shapesBatch);
}
}
}
}
// finish drawing
shapesBatch.end();
}
}
/**
* Sort array by distance from source shape.
* @private
*/
function sortByDistanceShapes(sourceShape, options)
{
let sourceCenter = sourceShape.getCenter();
options.sort((a, b) =>
(a.getCenter().distanceTo(sourceCenter) - a._getRadius()) -
(b.getCenter().distanceTo(sourceCenter) - b._getRadius()));
}
/**
* Sort array by distance from source shape.
* @private
*/
function sortByDistanceResults(sourceShape, options)
{
let sourceCenter = sourceShape.getCenter();
options.sort((a, b) =>
(a.second.getCenter().distanceTo(sourceCenter) - a.second._getRadius()) -
(b.second.getCenter().distanceTo(sourceCenter) - b.second._getRadius()));
}
// export collision world
module.exports = CollisionWorld;