UNPKG

bump-ts

Version:

![version](https://img.shields.io/npm/v/bump-ts) ![maintenance](https://img.shields.io/maintenance/yes/2021) ![open-issues](https://img.shields.io/github/issues-raw/hood/bump.ts)

761 lines (601 loc) 17.3 kB
import assertIsPositiveNumber from './helpers/generic/assertIsPositiveNumber'; import { rect_containsPoint, rect_detectCollision, rect_getDiff, rect_getNearestCorner, rect_getSegmentIntersectionIndices, rect_getSquareDistance, rect_isIntersecting, } from './rect'; import { grid_toCell, grid_toCellRect, grid_toWorld, grid_traverse, } from './grid'; import assertIsRect from './helpers/generic/assertIsRect'; import { bounce, cross, Response, slide, touch, } from './helpers/world/responses'; import sortByTiAndDistance from './helpers/world/sortByTiAndDistance'; function defaultFilter(): 'slide' { return 'slide'; } type ResponseType = 'bounce' | 'slide' | 'cross' | 'touch'; type Filter = (item: string, other: string) => ResponseType; interface IItemInfo { item: string; ti1: number; ti2: number; weight: number; } export interface IRect { x: number; y: number; w: number; h: number; } export interface ICoords { x: number; y: number; } export interface Collision { other: any | null | undefined; item: string | null | undefined; type?: ResponseType; overlaps: boolean; ti: number; move: ICoords; normal: ICoords; touch: ICoords; itemRect: IRect; otherRect: IRect; slide?: ICoords; bounce?: ICoords; } export type Cell = { ID: string; x: number; y: number; items: Record<string, boolean>; }; function sortByWeight(a: IItemInfo, b: IItemInfo): number { return a.weight - b.weight; } function getCellsTouchedBySegment( self: World, x1: number, y1: number, x2: number, y2: number ): Cell[] { const cells: Cell[] = []; const visited: Record<string, boolean> = {}; grid_traverse(self.cellSize, x1, y1, x2, y2, function( cx: number, cy: number ) { let row: Cell[] = self.rows[cy]; if (!row) return; let cell = row[cx]; if (!cell || visited[cell.ID]) return; visited[cell.ID] = true; cells.push(cell); }); return cells; } function getInfoAboutItemsTouchedBySegment( self: World, x1: number, y1: number, x2: number, y2: number, filter?: (other?: string) => boolean ): { item: string; ti1: number; ti2: number; weight: number | null }[] { let cells = getCellsTouchedBySegment(self, x1, y1, x2, y2); let rect, ti1, ti2; let visited: Record<string, boolean> = {}; let itemInfo: IItemInfo[] = []; for (let i = 0; i < cells.length; i++) { const cell = cells[i]; if (cell?.items) for (const itemID of Object.keys(cell.items)) { if (!visited[itemID]) { visited[itemID] = true; if (!filter || filter(itemID)) { rect = self['getRect'](itemID); const intersections1 = rect_getSegmentIntersectionIndices( rect, x1, y1, x2, y2, 0, 1 ); if (!intersections1) continue; ti1 = intersections1[0]; ti2 = intersections1[1]; if ( !isNaN(ti1 as number) && ((0 < ti1! && ti1! < 1) || (0 < ti2! && ti2! < 1)) ) { // -- the sorting is according to the t of an infinite line, not the segment const intersections2 = rect_getSegmentIntersectionIndices( rect, x1, y1, x2, y2, -Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER ); if (!intersections2) continue; const [tii0, tii1] = intersections2; itemInfo.push({ item: itemID, ti1: ti1!, ti2: ti2!, weight: Math.min(tii0 || 0, tii1 || 0), }); } } } } } return itemInfo.sort(sortByWeight); } export class World { responses: Record<ResponseType, Response> = {} as Record< ResponseType, Response >; cellSize: number = 0; rows: Cell[][]; rects: Map<string, IRect> = new Map<string, IRect>(); nonEmptyCells: Record<string, boolean>; constructor(input: { cellSize: number; rects: {}; rows: []; nonEmptyCells: {}; responses: Record<ResponseType, Response>; }) { this.cellSize = input.cellSize; this.rects = new Map(Object.entries(input.rects)); this.rows = input.rows; this.nonEmptyCells = input.nonEmptyCells; this.responses = input.responses; } addResponse(name: ResponseType, response: Response): void { this.responses[name] = response; } getResponseByName(name: ResponseType): Response { const response = this.responses[name]; if (!response) throw new Error(`Unknown collision type: ${name} (${typeof name})`); return response; } project( itemID: string | null, rect: IRect, goalX?: number, goalY?: number, filter?: (item: string, other: string) => ResponseType | false ): Collision[] { const _goalX = goalX ?? rect.x; const _goalY = goalY ?? rect.y; const _filter = filter || defaultFilter; const collisions: Collision[] = []; let visited: Set<string> = new Set(); if (itemID) visited.add(itemID); // This could probably be done with less cells using a polygon raster over // the cells instead of a bounding rect of the whole movement. Conditional // to building a queryPolygon method. const tl: number = _goalX !== rect.x ? Math.min(_goalX, rect.x) : _goalX; const tt: number = _goalY !== rect.y ? Math.min(_goalY, rect.y) : _goalY; const tr: number = _goalX !== rect.x ? Math.max(_goalX + rect.w, rect.x + rect.w) : _goalX + rect.w; const tb: number = _goalY !== rect.y ? Math.max(_goalY + rect.h, rect.y + rect.h) : _goalY + rect.h; const tw: number = tr - tl; const th: number = tb - tt; const cellRect = grid_toCellRect(this.cellSize, { x: tl, y: tt, w: tw, h: th, }); const itemsInCellRect = this.getItemsInCellRect(cellRect); for (let i = 0; i < itemsInCellRect.length; i++) { const other = itemsInCellRect[i]; if (!visited.has(other)) { visited.add(other); if (!this.hasItem(other)) continue; const responseName: ResponseType | false = _filter(itemID!, other); if (responseName !== false) { const collision: | Partial<Collision> | undefined = rect_detectCollision( rect, this.getRect(other), _goalX, _goalY ); if (collision) collisions.push({ ...collision, other, item: itemID, type: responseName, } as Collision); } } } return collisions.sort(sortByTiAndDistance); } countCells(): number { let count = 0; for (const row of this.rows.filter(row => !!row)) for (const _col of row) if (!!_col) count++; return count; } hasItem(item: string): boolean { return this.rects.has(item); } getItems(): IRect[] { return Array.from(this.rects.values()); } countItems(): number { return this.rects.size; } private addItemToCell(itemID: string, cx: number, cy: number): void { this.rows[cy] = this.rows[cy] || []; const row = this.rows[cy]; // Initialize a cell if no cell is present at this point. if (!row[cx]) row[cx] = { ID: `Cell_${cx}:${cy}`, x: cx, y: cy, items: {}, }; const cell = row[cx]; this.nonEmptyCells[cell.ID] = true; if (!cell.items[itemID]) cell.items[itemID] = true; } getRect(itemID: string): IRect { const rect = this.rects.get(itemID); if (!rect) throw new Error( `Item "${itemID}" must be added to the world before getting its rect. Use world:add(item, x,y,w,h) to add it first.` ); return rect; } private getItemsInCellRect(cellRect: IRect): IItemInfo['item'][] { const results = []; for (let cy = cellRect.y; cy <= cellRect.y + cellRect.h - 1; cy++) { let row = this.rows[cy]; if (row) for (let cx = cellRect.x; cx <= cellRect.x + cellRect.w - 1; cx++) { let cell = row[cx]; if (cell?.items && Object.keys(cell.items)?.length > 0) // no cell.itemCount > 1 because tunneling results.push(Object.keys(cell.items)); } } return results.flat(); } // Optimized version of getDictItemsInCellRect only used in // queryPoint, made to avoid an unneeded object creation. getItemsInCellPoint(x: number, y: number): IItemInfo['item'][] { const items: string[] = []; for (let cy = y; cy <= y; cy++) { let row = this.rows[cy]; if (row) for (let cx = x; cx <= x; cx++) { let cell = row[cx]; if (cell?.items && Object.keys(cell.items)?.length > 0) // no cell.itemCount > 1 because tunneling items.push(...Object.keys(cell.items)); } } return items; } private removeItemFromCell(itemID: string, cx: number, cy: number): boolean { let row = this.rows[cy]; if (!row?.[cx]?.['items']?.[itemID]) return false; let cell = row[cx]; delete cell.items[itemID]; if (Object.keys(cell.items)?.length === 0) delete this.nonEmptyCells[cell.ID]; return true; } toWorld(cx: number, cy: number): [number, number] { return grid_toWorld(this.cellSize, cx, cy); } toCell(x: number, y: number): [number, number] { return grid_toCell(this.cellSize, x, y); } queryRect( x: number, y: number, w: number, h: number, filter?: (other?: string) => boolean ): string[] { assertIsRect(x, y, w, h); const cellRect = grid_toCellRect(this.cellSize, { x, y, w, h }); const itemsInCellRect = this.getItemsInCellRect(cellRect); const items: string[] = []; for (const itemID of itemsInCellRect) if ( (!filter || filter(itemID)) && rect_isIntersecting(x, y, w, h, this.getRect(itemID)) ) items.push(itemID); return [...new Set(items)]; } queryPoint( x: number, y: number, filter?: (other?: string) => boolean ): string[] { const [cx, cy] = this.toCell(x, y); const itemsInCellRect = this.getItemsInCellPoint(cx, cy); const items: string[] = []; for (let i = 0; i < itemsInCellRect.length; i++) { const itemID = itemsInCellRect[i]; if ( (!filter || filter(itemID)) && rect_containsPoint(this.getRect(itemID), x, y) ) items.push(itemID); } return items; } querySegment( x1: number, y1: number, x2: number, y2: number, filter?: (other?: string) => boolean ): string[] { const itemsInfo = getInfoAboutItemsTouchedBySegment( this, x1, y1, x2, y2, filter ); const items: string[] = []; if (itemsInfo) for (let i = 0; i < itemsInfo.length; i++) items.push(itemsInfo[i].item); return items; } querySegmentWithCoords( x1: number, y1: number, x2: number, y2: number, filter?: (other?: string) => boolean ): { item: string; ti1: number; ti2: number; weight: number | null; x1: number; x2: number; y1: number; y2: number; }[] { let itemInfo: any = getInfoAboutItemsTouchedBySegment( this, x1, y1, x2, y2, filter ); let dx: number = x2 - x1; let dy: number = y2 - y1; let info: any; let ti1: number; let ti2: number; for (const item of itemInfo) { info = item; ti1 = info.ti1; ti2 = info.ti2; info.weight = null; info.x1 = x1 + dx * ti1; info.y1 = y1 + dy * ti1; info.x2 = x1 + dx * ti2; info.y2 = y1 + dy * ti2; } return itemInfo; } public add( itemID: string, x: number, y: number, w: number, h: number ): string { const rect: IRect | undefined = this.rects.get(itemID); if (rect) throw new Error(`Item "${itemID}" added to the world twice.`); assertIsRect(x, y, w, h); const rectToAdd = { x, y, w, h }; this.rects.set(itemID, rectToAdd); const cellRect = grid_toCellRect(this.cellSize, rectToAdd); for (let cy = cellRect.y; cy < cellRect.y + cellRect.h; cy++) for (let cx = cellRect.x; cx < cellRect.x + cellRect.w; cx++) this.addItemToCell(itemID, cx, cy); return itemID; } remove(itemID: string): void { const _itemRect = this.getRect(itemID); const itemRect: IRect = { x: _itemRect.x, y: _itemRect.y, w: _itemRect.w, h: _itemRect.h, }; this.rects.delete(itemID); let cellRect = grid_toCellRect(this.cellSize, itemRect); for (let cy = cellRect.y; cy < cellRect.y + cellRect.h; cy++) for (let cx = cellRect.x; cx < cellRect.x + cellRect.w; cx++) this.removeItemFromCell(itemID, cx, cy); } update( itemID: string, x2: number, y2: number, w2?: number, h2?: number ): void { let itemRect = this.getRect(itemID); w2 = isNaN(w2!) ? itemRect.w : w2; h2 = isNaN(h2!) ? itemRect.h : h2; assertIsRect(x2, y2, w2!, h2!); if ( itemRect.x != x2 || itemRect.y != y2 || itemRect.w != w2 || itemRect.h != h2 ) { const cellRect1 = grid_toCellRect(this.cellSize, itemRect); const cellRect2 = grid_toCellRect(this.cellSize, { x: x2, y: y2, w: w2!, h: h2!, }); if ( cellRect1.x != cellRect2.x || cellRect1.y != cellRect2.y || cellRect1.w != cellRect2.w || cellRect1.h != cellRect2.h ) { const cr1: number = cellRect1.x + cellRect1.w - 1; const cb1: number = cellRect1.y + cellRect1.h - 1; const cr2: number = cellRect2.x + cellRect2.w - 1; const cb2: number = cellRect2.y + cellRect2.h - 1; let cyOut: boolean; for (let cy = cellRect1.y; cy <= cb1; cy++) { cyOut = Number(cy) < cellRect2.y || cy > cb2; for (let cx = cellRect1.x; cx <= cr1; cx++) if (cyOut || cx < cellRect2.x || cx > cr2) this.removeItemFromCell(itemID, cx, cy); } for (let cy = cellRect2.y; cy <= cb2; cy++) { cyOut = cy < cellRect1.y || cy > cb1; for (let cx = cellRect2.x; cx <= cr2; cx++) if (cyOut || cx < cellRect1.x || cx > cr1) this.addItemToCell(itemID, cx, cy); } } this.rects.set(itemID, { x: x2, y: y2, w: w2!, h: h2!, }); } } public move( itemID: string, goalX: number, goalY: number, filter?: Filter ): ReturnType<Response> { const { x, y, collisions } = this.check(itemID, goalX, goalY, filter); this.update(itemID, x, y); return { x, y, collisions }; } check( itemID: string, goalX: number, goalY: number, filter?: Filter ): ReturnType<Response> { const checkFilter: Filter = filter || defaultFilter; let visited: Record<string, boolean> = {}; visited[itemID] = true; const visitedFilter = (item: string, other: string) => !!visited[other] ? false : checkFilter(item, other); let detectedCollisions: Collision[] = []; const itemRect: IRect = this.getRect(itemID); let projectedCollisions: Collision[] = this.project( itemID, itemRect, goalX, goalY, visitedFilter ); let collisionsCounter: number = projectedCollisions?.length || 0; while (collisionsCounter > 0) { const collision: Collision = projectedCollisions[0]; detectedCollisions.push(collision); visited[collision.other] = true; let response = this.getResponseByName(collision.type!); const { x, y, collisions } = response( this, collision, itemRect, goalX, goalY, visitedFilter ); goalX = x; goalY = y; projectedCollisions = collisions; collisionsCounter = collisions?.length || 0; } return { x: goalX, y: goalY, collisions: detectedCollisions }; } } // Public library functions const bump = { newWorld: function(cellSize: number): World { cellSize = cellSize || 64; assertIsPositiveNumber(cellSize, 'cellSize'); const world: World = new World({ cellSize: cellSize, rects: {}, rows: [], nonEmptyCells: {}, responses: {} as Record<string, Response>, }); world.addResponse('touch', touch); world.addResponse('cross', cross); world.addResponse('slide', slide); world.addResponse('bounce', bounce); return world; }, rect: { getNearestCorner: rect_getNearestCorner, getSegmentIntersectionIndices: rect_getSegmentIntersectionIndices, getDiff: rect_getDiff, containsPoint: rect_containsPoint, isIntersecting: rect_isIntersecting, getSquareDistance: rect_getSquareDistance, detectCollision: rect_detectCollision, }, responses: { touch, cross, slide, bounce, }, }; export default Object.freeze(bump);