UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

330 lines (287 loc) 9.94 kB
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); } } } }