shellquest
Version:
Terminal-based procedurally generated dungeon crawler
330 lines (287 loc) • 9.94 kB
text/typescript
import type {Entity} from '../entities/Entity';
import {TILE_SIZE} from '../TileMap.ts';
import type {GameLevel} from '../level/types.ts';
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
export interface CollisionEntity extends Entity {
// Collision box offset from position (optional)
collisionOffsetX?: number;
collisionOffsetY?: number;
// Custom collision box size (optional, defaults to width/height)
collisionWidth?: number;
collisionHeight?: number;
// Entity type for filtering collisions
collisionType?: 'player' | 'enemy' | 'projectile' | 'solid';
// Whether this entity blocks movement
solid?: boolean;
}
interface SpatialHashCell {
entities: Set<CollisionEntity>;
}
export class CollisionSystem {
private spatialHash: Map<string, SpatialHashCell> = new Map();
private cellSize: number;
private entities: Set<CollisionEntity> = new Set();
constructor(cellSize: number = TILE_SIZE * 2) {
this.cellSize = cellSize;
}
/**
* Clear all entities from the collision system
*/
clear(): void {
this.spatialHash.clear();
this.entities.clear();
}
/**
* Add an entity to the collision system
*/
addEntity(entity: CollisionEntity): void {
this.entities.add(entity);
this.updateEntityInHash(entity);
}
/**
* Remove an entity from the collision system
*/
removeEntity(entity: CollisionEntity): void {
this.removeEntityFromHash(entity);
this.entities.delete(entity);
}
/**
* Update an entity's position in the spatial hash
*/
updateEntity(entity: CollisionEntity): void {
this.removeEntityFromHash(entity);
this.updateEntityInHash(entity);
}
/**
* Get the bounding box for an entity
*/
getBoundingBox(entity: CollisionEntity): BoundingBox {
const offsetX = entity.collisionOffsetX || 0;
const offsetY = entity.collisionOffsetY || 0;
const width = entity.collisionWidth || entity.width;
const height = entity.collisionHeight || entity.height;
return {
x: entity.x + offsetX,
y: entity.y + offsetY,
width,
height,
};
}
/**
* Check if two bounding boxes overlap
*/
boxesOverlap(a: BoundingBox, b: BoundingBox): boolean {
return (
a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
);
}
/**
* Get all potential collision candidates for an entity
*/
getPotentialCollisions(entity: CollisionEntity): CollisionEntity[] {
const box = this.getBoundingBox(entity);
const candidates = new Set<CollisionEntity>();
// Get all cells that the entity's bounding box overlaps
const minCellX = Math.floor(box.x / this.cellSize);
const maxCellX = Math.floor((box.x + box.width) / this.cellSize);
const minCellY = Math.floor(box.y / this.cellSize);
const maxCellY = Math.floor((box.y + box.height) / this.cellSize);
for (let cellY = minCellY; cellY <= maxCellY; cellY++) {
for (let cellX = minCellX; cellX <= maxCellX; cellX++) {
const key = this.getCellKey(cellX, cellY);
const cell = this.spatialHash.get(key);
if (cell) {
for (const other of cell.entities) {
if (other !== entity) {
candidates.add(other);
}
}
}
}
}
return Array.from(candidates);
}
/**
* Check collisions for an entity at a potential new position
*/
checkCollisions(entity: CollisionEntity, newX: number, newY: number): CollisionEntity[] {
// Create a test bounding box at the new position
const testBox: BoundingBox = {
x: newX + (entity.collisionOffsetX || 0),
y: newY + (entity.collisionOffsetY || 0),
width: entity.collisionWidth || entity.width,
height: entity.collisionHeight || entity.height,
};
const collisions: CollisionEntity[] = [];
const candidates = this.getPotentialCollisions(entity);
for (const other of candidates) {
const otherBox = this.getBoundingBox(other);
if (this.boxesOverlap(testBox, otherBox)) {
collisions.push(other);
}
}
return collisions;
}
/**
* Move an entity with collision detection and sliding
*/
moveWithCollision(
entity: CollisionEntity,
dx: number,
dy: number,
level: GameLevel,
): {x: number; y: number; collided: boolean} {
const currentX = entity.x;
const currentY = entity.y;
const targetX = currentX + dx;
const targetY = currentY + dy;
// Get entity's collision box
const box = this.getBoundingBox(entity);
const halfWidth = box.width / 2;
const halfHeight = box.height / 2;
// Check tile collisions at corners and edges
const tileCollisions = this.checkTileCollisions(
targetX + (entity.collisionOffsetX || 0),
targetY + (entity.collisionOffsetY || 0),
box.width,
box.height,
level,
);
// Check entity collisions
const entityCollisions = this.checkCollisions(entity, targetX, targetY);
const solidEntityCollisions = entityCollisions.filter((e) => e.solid !== false);
// If no collisions, move normally
if (!tileCollisions.any && solidEntityCollisions.length === 0) {
return {x: targetX, y: targetY, collided: false};
}
// Try sliding along axes
let finalX = currentX;
let finalY = currentY;
let collided = false;
// Try moving only on X axis
if (dx !== 0) {
const xTileCollisions = this.checkTileCollisions(
targetX + (entity.collisionOffsetX || 0),
currentY + (entity.collisionOffsetY || 0),
box.width,
box.height,
level,
);
const xEntityCollisions = this.checkCollisions(entity, targetX, currentY);
const xSolidCollisions = xEntityCollisions.filter((e) => e.solid !== false);
if (!xTileCollisions.any && xSolidCollisions.length === 0) {
finalX = targetX;
} else {
collided = true;
}
}
// Try moving only on Y axis
if (dy !== 0) {
const yTileCollisions = this.checkTileCollisions(
finalX + (entity.collisionOffsetX || 0),
targetY + (entity.collisionOffsetY || 0),
box.width,
box.height,
level,
);
const yEntityCollisions = this.checkCollisions(entity, finalX, targetY);
const ySolidCollisions = yEntityCollisions.filter((e) => e.solid !== false);
if (!yTileCollisions.any && ySolidCollisions.length === 0) {
finalY = targetY;
} else {
collided = true;
}
}
return {x: finalX, y: finalY, collided};
}
/**
* Check for tile collisions at a given position
*/
private checkTileCollisions(
x: number,
y: number,
width: number,
height: number,
level: GameLevel,
): {any: boolean; tiles: Array<{x: number; y: number}>} {
const collisionTiles: Array<{x: number; y: number}> = [];
// Check corners and edges
const points = [
// Corners
{x: x, y: y}, // Top-left
{x: x + width - 1, y: y}, // Top-right
{x: x, y: y + height - 1}, // Bottom-left
{x: x + width - 1, y: y + height - 1}, // Bottom-right
// Edge midpoints
{x: x + width / 2, y: y}, // Top-center
{x: x + width / 2, y: y + height - 1}, // Bottom-center
{x: x, y: y + height / 2}, // Left-center
{x: x + width - 1, y: y + height / 2}, // Right-center
];
for (const point of points) {
const tileX = Math.floor(point.x / TILE_SIZE);
const tileY = Math.floor(point.y / TILE_SIZE);
if (level.isSolid(tileX, tileY)) {
collisionTiles.push({x: tileX, y: tileY});
}
}
return {
any: collisionTiles.length > 0,
tiles: collisionTiles,
};
}
/**
* Update spatial hash for all entities
*/
updateAllEntities(): void {
this.spatialHash.clear();
for (const entity of this.entities) {
this.updateEntityInHash(entity);
}
}
private getCellKey(cellX: number, cellY: number): string {
return `${cellX},${cellY}`;
}
private removeEntityFromHash(entity: CollisionEntity): void {
const box = this.getBoundingBox(entity);
const minCellX = Math.floor(box.x / this.cellSize);
const maxCellX = Math.floor((box.x + box.width) / this.cellSize);
const minCellY = Math.floor(box.y / this.cellSize);
const maxCellY = Math.floor((box.y + box.height) / this.cellSize);
for (let cellY = minCellY; cellY <= maxCellY; cellY++) {
for (let cellX = minCellX; cellX <= maxCellX; cellX++) {
const key = this.getCellKey(cellX, cellY);
const cell = this.spatialHash.get(key);
if (cell) {
cell.entities.delete(entity);
if (cell.entities.size === 0) {
this.spatialHash.delete(key);
}
}
}
}
}
private updateEntityInHash(entity: CollisionEntity): void {
const box = this.getBoundingBox(entity);
const minCellX = Math.floor(box.x / this.cellSize);
const maxCellX = Math.floor((box.x + box.width) / this.cellSize);
const minCellY = Math.floor(box.y / this.cellSize);
const maxCellY = Math.floor((box.y + box.height) / this.cellSize);
for (let cellY = minCellY; cellY <= maxCellY; cellY++) {
for (let cellX = minCellX; cellX <= maxCellX; cellX++) {
const key = this.getCellKey(cellX, cellY);
let cell = this.spatialHash.get(key);
if (!cell) {
cell = {entities: new Set()};
this.spatialHash.set(key, cell);
}
cell.entities.add(entity);
}
}
}
}