@rpgjs/physic
Version:
A deterministic 2D top-down physics library for RPG, sandbox and MMO games
358 lines (357 loc) • 10.2 kB
JavaScript
import { createCollider } from "./index18.js";
class SpatialHashCell {
constructor() {
this.entities = [];
}
/**
* Adds an entity to this cell
*
* @param entity - Entity to add
*/
add(entity) {
if (this.entities.indexOf(entity) === -1) {
this.entities.push(entity);
}
}
/**
* Removes an entity from this cell
*
* @param entity - Entity to remove
*/
remove(entity) {
const index = this.entities.indexOf(entity);
if (index !== -1) {
const last = this.entities[this.entities.length - 1];
if (last) {
this.entities[index] = last;
}
this.entities.pop();
}
}
/**
* Clears all entities from this cell
*/
clear() {
this.entities.length = 0;
}
}
class SpatialHash {
/**
* Creates a new spatial hash
*
* @param cellSize - Size of each cell in world units
* @param gridWidth - Number of cells horizontally
* @param gridHeight - Number of cells vertically (default: same as width)
*/
constructor(cellSize, gridWidth, gridHeight) {
this.cellSize = cellSize;
this.gridWidth = gridWidth;
this.gridHeight = gridHeight ?? gridWidth;
this.cells = /* @__PURE__ */ new Map();
this.entityCells = /* @__PURE__ */ new WeakMap();
}
/**
* Converts world coordinates to grid coordinates
*
* @param x - World X coordinate
* @param y - World Y coordinate
* @returns Grid coordinates
*/
worldToGrid(x, y) {
return {
x: Math.floor(x / this.cellSize),
y: Math.floor(y / this.cellSize)
};
}
/**
* Creates a cell key from grid coordinates
*
* @param gridX - Grid X coordinate
* @param gridY - Grid Y coordinate
* @returns Numeric cell key
*/
getKey(gridX, gridY) {
return (gridX & 65535) << 16 | gridY & 65535;
}
/**
* Gets or creates a cell at grid coordinates
*
* @param key - Cell key
* @returns Cell instance
*/
getCell(key) {
let cell = this.cells.get(key);
if (!cell) {
cell = new SpatialHashCell();
this.cells.set(key, cell);
}
return cell;
}
/**
* Gets all cell keys that an entity's AABB overlaps
*
* @param entity - Entity to get cells for
* @param outKeys - Array to store keys in (to avoid allocation)
* @returns Number of keys added
*/
getEntityKeys(entity, outKeys) {
const collider = createCollider(entity);
if (!collider) {
return 0;
}
const bounds = collider.getBounds();
const minGrid = this.worldToGrid(bounds.minX, bounds.minY);
const maxGrid = this.worldToGrid(bounds.maxX, bounds.maxY);
let count = 0;
outKeys.length = 0;
for (let x = minGrid.x; x <= maxGrid.x; x++) {
for (let y = minGrid.y; y <= maxGrid.y; y++) {
const wrappedX = (x % this.gridWidth + this.gridWidth) % this.gridWidth;
const wrappedY = (y % this.gridHeight + this.gridHeight) % this.gridHeight;
outKeys.push(this.getKey(wrappedX, wrappedY));
count++;
}
}
return count;
}
/**
* Inserts an entity into the spatial hash
*
* @param entity - Entity to insert
*/
insert(entity) {
const newKeys = [];
this.getEntityKeys(entity, newKeys);
this.entityCells.set(entity, newKeys);
for (const key of newKeys) {
const cell = this.getCell(key);
cell.add(entity);
}
}
/**
* Removes an entity from the spatial hash
*
* @param entity - Entity to remove
*/
remove(entity) {
const keys = this.entityCells.get(entity);
if (!keys) {
return;
}
for (const key of keys) {
const cell = this.cells.get(key);
if (cell) {
cell.remove(entity);
}
}
this.entityCells.delete(entity);
}
/**
* Updates an entity's position in the spatial hash
*
* Removes and re-inserts the entity if it moved to different cells.
*
* @param entity - Entity to update
*/
update(entity) {
const oldKeys = this.entityCells.get(entity);
const newKeys = [];
this.getEntityKeys(entity, newKeys);
if (oldKeys && oldKeys.length === newKeys.length) {
let match = true;
for (let i = 0; i < oldKeys.length; i++) {
if (oldKeys[i] !== newKeys[i]) {
match = false;
break;
}
}
if (match) return;
}
if (oldKeys) {
for (const key of oldKeys) {
const cell = this.cells.get(key);
if (cell) {
cell.remove(entity);
}
}
}
this.entityCells.set(entity, newKeys);
for (const key of newKeys) {
const cell = this.getCell(key);
cell.add(entity);
}
}
/**
* Queries entities near a given entity
*
* @param entity - Entity to query around
* @param results - Optional Set to store results in (avoids allocation)
* @returns Set of nearby entities (excluding the query entity)
*/
query(entity, results = /* @__PURE__ */ new Set()) {
let keys = this.entityCells.get(entity);
if (!keys) {
keys = [];
this.getEntityKeys(entity, keys);
}
results.clear();
for (const key of keys) {
const cell = this.cells.get(key);
if (cell) {
const entities = cell.entities;
for (let i = 0; i < entities.length; i++) {
const other = entities[i];
if (other && other !== entity) {
results.add(other);
}
}
}
}
return results;
}
/**
* Queries entities in an AABB region
*
* @param bounds - AABB to query
* @returns Set of entities in the region
*/
queryAABB(bounds) {
const minGrid = this.worldToGrid(bounds.minX, bounds.minY);
const maxGrid = this.worldToGrid(bounds.maxX, bounds.maxY);
const results = /* @__PURE__ */ new Set();
for (let x = minGrid.x; x <= maxGrid.x; x++) {
for (let y = minGrid.y; y <= maxGrid.y; y++) {
const wrappedX = (x % this.gridWidth + this.gridWidth) % this.gridWidth;
const wrappedY = (y % this.gridHeight + this.gridHeight) % this.gridHeight;
const key = this.getKey(wrappedX, wrappedY);
const cell = this.cells.get(key);
if (cell) {
for (const entity of cell.entities) {
results.add(entity);
}
}
}
}
return results;
}
/**
* Clears all entities from the spatial hash
*/
clear() {
this.cells.clear();
}
/**
* Gets statistics about the spatial hash
*
* @returns Statistics object
*/
getStats() {
let totalEntities = 0;
for (const cell of this.cells.values()) {
totalEntities += cell.entities.length;
}
return {
totalCells: this.gridWidth * this.gridHeight,
usedCells: this.cells.size,
totalEntities,
averageEntitiesPerCell: this.cells.size > 0 ? totalEntities / this.cells.size : 0
};
}
/**
* Casts a ray against entities in the spatial hash
*
* @param ray - Ray to cast
* @param mask - Optional collision mask (layer)
* @param filter - Optional filter function (return true to include entity)
* @returns Raycast hit info if hit, null otherwise
*/
raycast(ray, mask, filter) {
const start = ray.origin;
const end = ray.getPoint(Math.min(ray.length, 1e4));
let x0 = start.x;
let y0 = start.y;
const x1 = end.x;
const y1 = end.y;
let gx0 = Math.floor(x0 / this.cellSize);
let gy0 = Math.floor(y0 / this.cellSize);
const gx1 = Math.floor(x1 / this.cellSize);
const gy1 = Math.floor(y1 / this.cellSize);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
const visitedEntities = /* @__PURE__ */ new Set();
let closestHit = null;
const checkCell = (gx, gy) => {
const wrappedX = (gx % this.gridWidth + this.gridWidth) % this.gridWidth;
const wrappedY = (gy % this.gridHeight + this.gridHeight) % this.gridHeight;
const key = this.getKey(wrappedX, wrappedY);
const cell = this.cells.get(key);
if (cell) {
for (const entity of cell.entities) {
if (visitedEntities.has(entity)) continue;
visitedEntities.add(entity);
if (mask !== void 0 && (entity.collisionCategory & mask) === 0) continue;
if (filter && !filter(entity)) {
continue;
}
const collider = createCollider(entity);
if (collider) {
const hit = collider.raycast(ray);
if (hit) {
if (!closestHit || hit.distance < closestHit.distance) {
closestHit = hit;
}
}
}
}
}
};
let x = gx0;
let y = gy0;
const stepX = sx;
const stepY = sy;
const tDeltaX = this.cellSize / Math.abs(ray.direction.x);
const tDeltaY = this.cellSize / Math.abs(ray.direction.y);
let tMaxX = ray.direction.x > 0 ? ((x + 1) * this.cellSize - start.x) / ray.direction.x : (start.x - x * this.cellSize) / -ray.direction.x;
if (ray.direction.x < 0) {
tMaxX = (start.x - x * this.cellSize) / -ray.direction.x;
} else {
tMaxX = ((x + 1) * this.cellSize - start.x) / ray.direction.x;
}
let tMaxY = ray.direction.y > 0 ? ((y + 1) * this.cellSize - start.y) / ray.direction.y : (start.y - y * this.cellSize) / -ray.direction.y;
if (ray.direction.y < 0) {
tMaxY = (start.y - y * this.cellSize) / -ray.direction.y;
} else {
tMaxY = ((y + 1) * this.cellSize - start.y) / ray.direction.y;
}
if (Math.abs(ray.direction.x) < 1e-9) {
tMaxX = Infinity;
}
if (Math.abs(ray.direction.y) < 1e-9) {
tMaxY = Infinity;
}
let steps = 0;
const maxSteps = Math.abs(gx1 - gx0) + Math.abs(gy1 - gy0) + 10;
while (steps < maxSteps) {
checkCell(x, y);
if (closestHit) {
if (closestHit.distance < Math.min(tMaxX, tMaxY)) {
return closestHit;
}
}
if (tMaxX < tMaxY) {
tMaxX += tDeltaX;
x += stepX;
} else {
tMaxY += tDeltaY;
y += stepY;
}
steps++;
if (closestHit && closestHit.distance < ray.length) ;
}
return closestHit;
}
}
export {
SpatialHash
};
//# sourceMappingURL=index14.js.map