UNPKG

@alexaegis/ecs

Version:
1,416 lines (1,415 loc) 43 kB
import { stringToMatrix } from "@alexaegis/advent-of-code-lib/string"; import { BoundingBox, mapMatrix, Vec2, arrayDiff, mapGetOrSet, partition, nonNullish, arrayContains, Direction, renderMatrix, hzToMs, filterMap } from "@alexaegis/advent-of-code-lib"; import { BehaviorSubject, switchMap, interval, takeWhile, tap, lastValueFrom } from "rxjs"; import blessed from "blessed"; import terminalKit from "terminal-kit"; import { noopAsync } from "@alexaegis/common"; class Component { belongsTo = []; _world; is(component) { return this instanceof component; } set world(world) { this._world = world; } get world() { if (this._world) { return this._world; } else { throw new Error("Component is not attached to any world"); } } componentType() { return Object.getPrototypeOf(this).constructor; } componentName() { return this.componentType().name; } onSpawn() { return; } } const normalizeSpriteOptions = (options) => { return { ...options }; }; class Sprite { options; render; _renderBox; _box; /** * * @param render a matrix that will represent the look of this Sprite * @param boundingBox when not set, it will be calculated from the size * of the render. When set it will let the render to be repeated within * the box. * TODO: repeat anchor */ constructor(render = [[]], rawOptions) { this.options = normalizeSpriteOptions(rawOptions); this.render = render; this._renderBox = BoundingBox.fromMatrix(render); this._box = this.options.size ?? this._renderBox; } static fromString(render, rawOptions) { const matrix = stringToMatrix(render); return Sprite.fromMatrix(matrix, rawOptions); } static fromMatrix(matrix, rawOptions) { const options = normalizeSpriteOptions(rawOptions); const tileMatrix = mapMatrix( matrix, (char) => Sprite.intoDefaultTile(char, options) ); return new Sprite(tileMatrix, rawOptions); } getTileAt(x, y) { return this._box.contains(x, y) ? this.render[y % this._renderBox.height]?.[x % this._renderBox.width] : void 0; } intoDefaultTile(tile) { return Sprite.intoDefaultTile(tile, this.options); } static intoDefaultTile(char, options) { return { char: char ?? " ", bg: options.defaultBackgroundColor, fg: options.defaultForegroundColor }; } /** * Resets the frame to a sized matrix */ blank(size) { this.render = size.createBlankMatrix(() => this.intoDefaultTile()); } get boundingBox() { return this._box; } forEach(callback) { for (let y = 0; y < this.render.length; y++) { const row = this.render[y]; if (row) { for (let x = 0; x < row.length; x++) { const tile = row[x]; if (tile) { callback(new Vec2(x, y), tile); } } } } } put(x, y, tile) { const row = this.render[y]; if (row) { row[x] = typeof tile === "string" ? this.intoDefaultTile(tile) : tile; } } merge(x, y, tileLike) { const tile = typeof tileLike === "string" ? this.intoDefaultTile(tileLike) : tileLike; const renderTile = this.render[y]?.[x]; if (renderTile) { if (tile.char) { renderTile.char = tile.char; } if (tile.fg) { renderTile.fg = tile.fg; } if (tile.bg) { renderTile.bg = tile.bg; } } } asStringMatrix() { return mapMatrix(this.render, (tile) => tile.char ?? " "); } } class SpatialCache { constructor(world) { this.world = world; } /** * A fast lookup for entities at given positions, entities covering * infinite area are not stored here */ positionTable = /* @__PURE__ */ new Map(); /** * A collection of entities covering infinite areas, used sparingly */ infiniteBoxes = /* @__PURE__ */ new Map(); get(position) { const fromTable = this.positionTable.get(Vec2.toString(position)) ?? []; const fromInfiniteBoxes = [...this.infiniteBoxes.entries()].filter(([, boxes]) => boxes.some((box) => box.contains(position))).map(([entityId]) => this.world.entities.get(entityId)); return [...fromTable, ...fromInfiniteBoxes]; } move(entity, from, to) { const { onlyInFirst, onlyInSecond } = arrayDiff(from, to); for (const noLongerAt of onlyInFirst) { const entitiesThere = this.positionTable.get(noLongerAt); if (entitiesThere) { const next = entitiesThere.filter((entityThere) => entityThere !== entity); if (next.length > 0) { this.positionTable.set(noLongerAt, next); } else { this.positionTable.delete(noLongerAt); } } } for (const nowAt of onlyInSecond) { const entitiesThere = this.positionTable.get(nowAt) ?? []; entitiesThere.push(entity); this.positionTable.set(nowAt, entitiesThere); } } areaOfAllNonInfiniteEntities() { return BoundingBox.fromVectors(this.positionTable.keyArray().map((v) => new Vec2(v))); } } class SpatialComponent extends Component { /** * The last area this component occupied based on the PositionComponent, * used to calculate movement difference on move and is replaced after * move. */ lastPositionsString; lastPositions; getLastPositions(defaultPosition) { if (this.lastPositions) { return this.lastPositions; } else if (defaultPosition) { const areas = this.area(defaultPosition); return areas.filter((area) => area.isFinite()).flatMap((area) => area.renderIntoVectors()); } else { return void 0; } } getLastPositionsString(defaultPosition) { if (this.lastPositionsString) { return this.lastPositionsString; } else if (defaultPosition) { return this.getLastPositions(defaultPosition).map((v) => v.toString()); } else { return void 0; } } getBoundingBoxOfAll() { return BoundingBox.fromVectors( this.getSpatialCache().positionTable.keyArray().map((v) => new Vec2(v)) ); } getSpatialCache() { return SpatialComponent.getSpatialCache(this.world, this.componentType()); } static getSpatialCache(world, type) { return mapGetOrSet(world.componentSpatialCache, type, () => new SpatialCache(world)); } static isSpatialComponent(component) { return typeof component.area === "function"; } } class AsciiDisplayComponent extends SpatialComponent { sprite; constructor(sprite) { super(); this.sprite = sprite; } area(at) { return [this.sprite.boundingBox.clone().moveAnchorTo(at)]; } static fromSprite(sprite) { return new AsciiDisplayComponent(sprite); } static fromString(char, spriteOptions) { return new AsciiDisplayComponent(Sprite.fromMatrix(stringToMatrix(char), spriteOptions)); } static fromMatrix(matrix, spriteOptions) { return new AsciiDisplayComponent(Sprite.fromMatrix(matrix, spriteOptions)); } } class ColliderComponent extends SpatialComponent { constructor(colliders) { super(); this.colliders = colliders; } static unit = ColliderComponent.fromBoundingBoxes(BoundingBox.fromLength(0)); static fromRender(sprite, tileCollides = (char) => char !== " " && char !== ".") { const boundingBoxes = []; if (sprite.boundingBox.every((x, y) => tileCollides(sprite.getTileAt(x, y)?.char ?? " "))) { boundingBoxes.push(sprite.boundingBox); } else { for (let y = 0; y < sprite.render.length; y++) { const renderRow = sprite.render[y]; if (renderRow) { if (renderRow.every((tile) => tileCollides(tile.char ?? " "))) { boundingBoxes.push( BoundingBox.fromVectors([ new Vec2(0, y), new Vec2(renderRow.length - 1, y) ]) ); } else { for (let x = 0; x < renderRow.length; x++) { const tile = renderRow[x]; if (tileCollides(tile?.char ?? " ")) { boundingBoxes.push(BoundingBox.fromVectors([new Vec2(x, y)])); } } } } } } return new ColliderComponent(boundingBoxes); } static fromBoundingBoxes(...colliders) { return new ColliderComponent(colliders); } area(position) { return this.colliders.map((collider) => collider.clone().offset(position)); } intersectsWithPoint(point) { return this.colliders.some((collider) => collider.contains(point)); } intersectsWithBoundingBox(box) { return this.colliders.some((collider) => collider.intersects(box)); } } class AnyPositionComponent extends Component { constructor(_position, z = 0) { super(); this._position = _position; this.z = z; } onSpawn() { this.indexEntityMove(void 0, this.position); } get position() { return this._position; } indexEntityMove(from, to) { for (const entity of this.belongsTo) { for (const component of entity.components.values()) { if (SpatialComponent.isSpatialComponent(component)) { const cache = component.getSpatialCache(); const lastPositions = from ? component.getLastPositions(from) : []; const toArea = component.area(to); const [finiteArea, infiniteArea] = partition(toArea, (area) => area.isFinite()); const nextPositions = finiteArea.flatMap((area) => area.renderIntoVectors()); const nextPositionsString = nextPositions.map((p) => p.toString()); component.lastPositions = nextPositions; cache.move( entity, lastPositions.map((p) => p.toString()), nextPositionsString ); if (infiniteArea.length > 0) { for (const entity2 of this.belongsTo) { cache.infiniteBoxes.set(entity2.entityId, infiniteArea); } } } } } } } class PositionComponent extends AnyPositionComponent { onMoveCallbacks = []; moveTo(to) { return this.move(to.sub(this.position)); } move(offset) { if (this.canMove(offset)) { const oldPosition = this.position.clone(); this._position.addMut(offset); this.indexEntityMove(oldPosition, this.position); for (const callback of this.onMoveCallbacks) callback(this.position); return true; } else { return false; } } /** * Can only move if all the colliders ofattached entities are free to move. * ? Self collider */ canMove(offset) { return this.belongsTo.map((entity) => entity.getComponent(ColliderComponent)).filter(nonNullish).flatMap( (collider) => collider.getLastPositions(this.position).map((p) => p.add(offset)) ).every( (p) => this.world.entitiesCollidingAt(p).filter( (collidingEntity) => !arrayContains(this.belongsTo, collidingEntity) ).length === 0 ); } onMove(callback) { this.onMoveCallbacks.push(callback); } getWorldBox() { return this.belongsTo.reduce( (box, entity) => { const displayComponent = entity.getComponent(AsciiDisplayComponent); if (displayComponent) { for (const area of displayComponent.area(this.position)) { box.extend([area.topLeft, area.bottomRight]); } } return box; }, BoundingBox.fromVectors([this.position]) ); } } class StaticPositionComponent extends AnyPositionComponent { } class CameraComponent extends Component { screenViewport = BoundingBox.fromSize(new Vec2(10, 10)); worldViewport = BoundingBox.fromSize(new Vec2(10, 10)); positionComponent; options; constructor(rawOptions) { super(); this.options = { movable: true, ...rawOptions }; } onSpawn() { const positionComponent = this.belongsTo[0]?.getComponent(PositionComponent); if (positionComponent) { this.positionComponent = positionComponent; } else { throw new Error("Trying to spawn a camera without a PositionComponent"); } this.positionComponent.onMove((position) => { this.worldViewport.moveTopLeftTo(position); }); } setFollowOptions(followOptions) { this.options = { movable: this.options.movable, ...followOptions }; } updateFollowOptions(followOptions) { this.options = { ...this.options, ...followOptions }; } followEntity(entity) { this.options.entity = entity; } get worldAnchor() { return this.worldViewport.topLeft; } get size() { return this.screenViewport.size.clone(); } get position() { return this.positionComponent.position; } resize(size) { this.worldViewport.resizeFromTopleft(size); this.screenViewport.resizeFromTopleft(size); } getScreenBoxFromWorldBox(worldBox) { return BoundingBox.fromVectors([ this.getScreenPositionFromWorldPosition(worldBox.topLeft), this.getScreenPositionFromWorldPosition(worldBox.bottomRight) ]); } getWorldBoxFromScreenBox(screenBox) { return BoundingBox.fromVectors([ this.getWorldPositionFromScreenPosition(screenBox.topLeft), this.getWorldPositionFromScreenPosition(screenBox.bottomRight) ]); } getScreenPositionFromWorldPosition(gamePosition) { return gamePosition.sub(this.worldAnchor); } getWorldPositionFromScreenPosition(screenPosition) { return screenPosition.add(this.worldAnchor); } screenCenter() { return this.screenViewport.center; } lookingAt() { return this.worldViewport.center; } getScreenspaceBoundingBox() { return this.screenViewport; } getGamespaceBoundingBox() { return this.worldViewport; } centerOn(on) { this.positionComponent.moveTo(on.sub(this.screenCenter())); } move(offset) { this.positionComponent.move(offset); } } class NameComponent extends Component { constructor(name) { super(); this.name = name; } } class Entity { constructor(world, entityId) { this.world = world; this.entityId = entityId; } spawned = false; components = /* @__PURE__ */ new Map(); getComponent(componentType) { return this.components.get(componentType); } getComponentOrThrow(componentType) { const component = this.components.get(componentType); if (!component) { throw new Error("Component was not found on entity!"); } return component; } despawn() { this.world.despawn(this); } freezePosition() { const positionComponent = this.getComponent(PositionComponent); if (positionComponent) { this.world.deattachComponent(this, positionComponent); this.world.attachComponent( this, new StaticPositionComponent(positionComponent.position.clone()) ); } } /** * Takes the displayComponent into account when available. */ getCenterPosition() { const positionComponent = this.getComponent(PositionComponent) ?? this.getComponent(StaticPositionComponent); if (!positionComponent) { return void 0; } let entityPosition = positionComponent.position; const displayComponent = this.getComponent(AsciiDisplayComponent); if (displayComponent?.sprite.boundingBox) { entityPosition = entityPosition.add(displayComponent.sprite.boundingBox.center); } return entityPosition; } /** * Wrapping as in combining multiple boxes into one if there are multiple * Vision as in it tracks what the AsciiDisplayComponent displays * and WorldBox as it's a box whichs coordinates are world coordinates */ getWrappingVisionWorldBox() { const positionComponent = this.getComponent(PositionComponent) ?? this.getComponent(StaticPositionComponent); if (!positionComponent) { return void 0; } const displayComponent = this.getComponent(AsciiDisplayComponent); return displayComponent?.sprite.boundingBox ? displayComponent.sprite.boundingBox.clone().moveAnchorTo(positionComponent.position) : BoundingBox.fromVectors([positionComponent.position]); } /** * Wrapping as in combining multiple boxes into one if there are multiple * Collision as in it tracks what the ColliderComponent displays * and WorldBox as it's a box whichs coordinates are world coordinates */ getWrappingCollisionWorldBox() { const positionComponent = this.getComponent(PositionComponent) ?? this.getComponent(StaticPositionComponent); if (!positionComponent) { return void 0; } const collider = this.getComponent(ColliderComponent); return collider?.colliders ? BoundingBox.combine(collider.colliders).moveAnchorTo(positionComponent.position) : BoundingBox.fromVectors([positionComponent.position]); } } const addCameraFollowSystem = (gridWorld) => { gridWorld.addSystem((world) => { const camera = world.query(CameraComponent)[0]?.[1]; if (!camera) { return; } const entityPosition = camera.options.entity?.getCenterPosition(); if (!entityPosition) { return; } const paddedWorldViewport = camera.worldViewport.clone(); let horizontalMargin = 0; let verticalMargin = 0; if (camera.options.followArea?.marginRatio !== void 0) { const marginRatio = camera.options.followArea.marginRatio; horizontalMargin = Math.floor(paddedWorldViewport.horizontal.length / marginRatio); verticalMargin = Math.floor(paddedWorldViewport.vertical.length / marginRatio); if (camera.options.followArea.kind === "responsive") { paddedWorldViewport.pad(-horizontalMargin, -verticalMargin); } else if (camera.options.followArea.kind === "responsiveSquare") { paddedWorldViewport.pad(-Math.min(horizontalMargin, verticalMargin)); } else { paddedWorldViewport.pad(-marginRatio); } } if (camera.options.followMode === "focus") { camera.centerOn(entityPosition); } else if (!paddedWorldViewport.contains(entityPosition)) { const maxEdge = paddedWorldViewport.clampInto(entityPosition); const offset = entityPosition.sub(maxEdge); switch (camera.options.followMode) { case "edge": { camera.move(offset); break; } case "jumpToCenter": { const horizontalJump = Math.floor(paddedWorldViewport.width / 2); const verticalJump = Math.floor(paddedWorldViewport.height / 2); if (entityPosition.x !== maxEdge.x) { offset.x = offset.x < 0 ? -horizontalJump : horizontalJump; } if (entityPosition.y !== maxEdge.y) { offset.y = offset.y < 0 ? -verticalJump : verticalJump; } camera.move(offset); break; } case "jump": { if (entityPosition.x !== maxEdge.x) { offset.x = offset.x < 0 ? -horizontalMargin : horizontalMargin; } if (entityPosition.y !== maxEdge.y) { offset.y = offset.y < 0 ? -verticalMargin : verticalMargin; } camera.move(offset); break; } } } return void 0; }); }; const spawnCamera = (world, cameraOptions) => { const cameraComponent = new CameraComponent(cameraOptions); const cameraEntity = world.spawn(cameraComponent, new PositionComponent(Vec2.ORIGIN.clone())); if (cameraOptions?.movable) { addArrowMoveSystem(world, cameraEntity); addCameraFollowSystem(world); } return cameraEntity; }; const compassString = ` -Y -X╳+X +Y`; const spawnCompass = (world) => { const compassDisplayComponent = AsciiDisplayComponent.fromString(compassString); return world.spawn( new StaticPositionComponent(new Vec2(-2, -1), Number.NEGATIVE_INFINITY), compassDisplayComponent ); }; class FloorMarkerComponent extends Component { } const spawnFloor = (world, from, to) => { const worldBox = BoundingBox.fromVectors([from, to]); const localBox = worldBox.clone().normalize(); return world.spawn( new FloorMarkerComponent(), new StaticPositionComponent(worldBox.anchor, -1), AsciiDisplayComponent.fromMatrix( localBox.createBlankMatrix(() => "░"), { defaultBackgroundColor: "green", defaultForegroundColor: "brightGreen" } ) ); }; class PlayerMarkerComponent extends Component { } const spawnPlayer = (world, startingPosition = Vec2.ORIGIN.clone()) => { const playerEntity = world.spawn( new PlayerMarkerComponent(), new PositionComponent(startingPosition), AsciiDisplayComponent.fromString("ඞ", { defaultForegroundColor: "white" }), ColliderComponent.unit ); addWasdMoveSystem(world, playerEntity); addCameraFollowSystem(world); return playerEntity; }; class WallMarkerComponent extends Component { } const spawnWall = (world, from, to) => { const worldBox = BoundingBox.fromVectors([from, to]); const localBox = worldBox.clone().normalize(); return world.spawn( new WallMarkerComponent(), new StaticPositionComponent(worldBox.anchor, 0), AsciiDisplayComponent.fromString("#", { size: localBox, defaultBackgroundColor: "black", defaultForegroundColor: "gray" }), ColliderComponent.fromBoundingBoxes(localBox) ); }; class Executor { constructor(world, haltCondition) { this.world = world; this.haltCondition = haltCondition; } manualHalt = false; halt() { this.manualHalt = true; } isHalting() { if (this.manualHalt) { return true; } else if (this.haltCondition === "none") { return false; } else if (this.haltCondition === "untilSettled") { return this.world.systemsSettled; } else if (typeof this.haltCondition === "number") { return this.world.timeData.tick >= this.haltCondition; } else { return this.haltCondition(this.world); } } } class UntilHaltExecutor extends Executor { async run() { await this.world.initializeSystems(); while (!this.isHalting()) { this.world.tick(); } return this.world.timeData.tick; } } const addAutocirclingSystem = (world, entity) => { let currentDirection = Direction.EAST; world.addSystem((_world, time) => { const position = entity.getComponent(PositionComponent); position.move(currentDirection); if (time.tick % 3 === 0) { currentDirection = currentDirection.right(); } return void 0; }); }; class System { } class ControllerBuffer { constructor(backend) { this.backend = backend; } mousePosition = Vec2.ORIGIN; keyBuffer = {}; init() { this.backend.onKeyPress((event, _modifier) => { this.keyBuffer[event] = 1; switch (event) { case "UP": case "w": { this.keyBuffer["DOWN"] = 0; this.keyBuffer["s"] = 0; break; } case "DOWN": case "s": { this.keyBuffer["UP"] = 0; this.keyBuffer["w"] = 0; break; } case "LEFT": case "a": { this.keyBuffer["RIGHT"] = 0; this.keyBuffer["d"] = 0; break; } case "RIGHT": case "d": { this.keyBuffer["LEFT"] = 0; this.keyBuffer["a"] = 0; break; } } }); this.backend.onMouseMove((event) => { this.mousePosition = event; }); } } class BasicController extends System { constructor(backend, targetEntity, eventMap) { super(); this.backend = backend; this.targetEntity = targetEntity; this.eventMap = eventMap; this.buffer = new ControllerBuffer(this.backend); } order = -1; buffer; init() { this.buffer.init(); return void 0; } tick(world, timeData) { for (const key in this.eventMap) { if (this.buffer.keyBuffer[key]) { this.eventMap[key]?.(this.targetEntity, world, timeData); delete this.buffer.keyBuffer[key]; } } return void 0; } } const addArrowMoveSystem = (world, entity) => { if (world.io) { const controller = new BasicController(world.io, entity, { UP: (target) => { const position = target.getComponent(PositionComponent); position?.move(Direction.NORTH); }, LEFT: (target) => { const position = target.getComponent(PositionComponent); position?.move(Direction.WEST); }, DOWN: (target) => { const position = target.getComponent(PositionComponent); position?.move(Direction.SOUTH); }, RIGHT: (target) => { const position = target.getComponent(PositionComponent); position?.move(Direction.EAST); } }); world.addSystem(controller); } }; const addWasdMoveSystem = (world, entity) => { if (world.io) { const controller = new BasicController(world.io, entity, { w: (target) => { const position = target.getComponent(PositionComponent); position?.move(Direction.NORTH); }, a: (target) => { const position = target.getComponent(PositionComponent); position?.move(Direction.WEST); }, s: (target) => { const position = target.getComponent(PositionComponent); position?.move(Direction.SOUTH); }, d: (target) => { const position = target.getComponent(PositionComponent); position?.move(Direction.EAST); } }); world.addSystem(controller); } }; class IntervalExecutor extends Executor { timeInterval$; onTickCallbacks = []; tick$; constructor(world, haltCondition, timeInterval) { super(world, haltCondition); this.timeInterval$ = new BehaviorSubject(timeInterval); this.tick$ = this.timeInterval$.pipe( switchMap((timeInterval2) => interval(timeInterval2)), takeWhile(() => !this.isHalting()), tap(() => { this.world.tick(); for (const callback of this.onTickCallbacks) callback(this.world); }) ); } async run() { await this.world.initializeSystems(); return lastValueFrom(this.tick$); } setTimeInterval(timeInterval) { this.timeInterval$.next(timeInterval); } onTick(callback) { this.onTickCallbacks.push(callback); } } class StepperExecutor extends Executor { async run() { await this.world.initializeSystems(); return this.world.timeData.tick; } tick(maxTick = Number.POSITIVE_INFINITY) { return !this.world.systemsSettled && this.world.timeData.tick < maxTick; } } class BlessedIOBackend { program; setTitle(title) { this.program.setTitle(title); } // eslint-disable-next-line @typescript-eslint/require-await async init(_resizeNotifier) { this.program = blessed.program({ smartCSR: true }); this.program.alternateBuffer(); this.program.enableMouse(); this.program.hideCursor(); this.program.clear(); this.program.addListener("resize", (_e) => { this.program.getWindowSize((a) => { console.log("getWindowSize", a); }); }); this.program.key("q", async () => { await this.close(); console.log("PANIC"); process.exit(0); }); } pushFrame(frame) { frame.forEach(({ x, y }, cell) => { this.program.move(x, y); this.program.bg("black"); this.program.write(cell.char ?? " "); }); this.program.flush(); } onKeyPress(_callback) { return; } onMouseMove(_callback) { return; } onTerminateRequest(_callback) { return; } // eslint-disable-next-line @typescript-eslint/require-await async close() { this.program.clear(); this.program.disableMouse(); this.program.showCursor(); this.program.normalBuffer(); process.exit(0); } } const detectTerminal = () => new Promise((resolve, reject) => { terminalKit.getDetectedTerminal((error, terminal) => { if (error) { reject(error); } else { resolve(terminal); } }); }); class TerminalKitIOBackend { terminal; buffer; terminateRequests = []; keyEmitters = []; mouseEmitters = []; setTitle(title) { this.terminal.windowTitle(title); } async init(resizeNotifier) { this.terminal = await detectTerminal(); this.terminal.fullscreen(true); this.terminal.hideCursor(); this.terminal.grabInput({ mouse: "motion" }, false); this.buffer = new terminalKit.ScreenBuffer({ dst: this.terminal }); resizeNotifier({ x: this.terminal.width, y: this.terminal.height }); this.terminal.on("resize", (width, height) => { this.buffer.resize(new terminalKit.Rect(0, width, 0, height)); resizeNotifier({ x: width, y: height }); }); this.terminal.on( "key", async (name, _matches, _data) => { let modifier; let event = name; if (name.includes("_")) { const [mod, key] = name.splitIntoStringPair("_"); modifier = mod; event = key; } if (/^[A-Z]$/.test(name)) { event = name.toLowerCase(); modifier = "SHIFT"; } for (const callback of this.keyEmitters) callback(event, modifier); if (name === "CTRL_C" || name === "ESCAPE") { for (const callback of this.terminateRequests) callback(); await this.close(); process.exit(0); } } ); this.terminal.on("mouse", (_eventName, data) => { for (const callback of this.mouseEmitters) { callback(new Vec2(data.x, data.y)); } }); } pushFrame(frame) { frame.forEach(({ x, y }, tile) => { this.buffer.put( { x, y, dx: 0, dy: 0, wrap: false, attr: { color: tile.fg ?? "white", bgTransparency: !tile.bg, bgColor: tile.bg } }, tile.char ?? " " ); }); this.buffer.draw({ delta: true }); } onMouseMove(callback) { this.mouseEmitters.push(callback); } onKeyPress(callback) { this.keyEmitters.push(callback); } onTerminateRequest(callback) { this.terminateRequests.push(callback); } async close() { this.terminal.reset(); this.terminal.clear(); await this.terminal.asyncCleanup(); } } class ConsoleLogIOBackend { setTitle(_title) { return; } async init(_resize) { await noopAsync(); return; } close() { return; } pushFrame(frame) { console.log(renderMatrix(frame.asStringMatrix())); } onKeyPress(_callback) { return; } onMouseMove(_callback) { return; } onTerminateRequest(_callback) { return; } } const normalizeRendererSystemOptions = (options) => { return { renderColliders: false, ...options }; }; class RendererSystem extends System { order = Number.POSITIVE_INFINITY; camera; currentFrame; options; constructor(options) { super(); this.options = normalizeRendererSystemOptions(options); this.camera = this.options.cameraEntity.getComponent(CameraComponent); } async init() { await this.options.backend.init((size) => { this.camera?.resize(size); }); } tick(world) { this.currentFrame = this.render(world); this.options.backend.pushFrame(this.currentFrame); return false; } render(world) { const frame = new Sprite(); if (!this.camera) { return frame; } frame.blank(this.camera.screenViewport); for (const [, positionComponent, displayComponent] of [ ...world.query(StaticPositionComponent, AsciiDisplayComponent), ...world.query(PositionComponent, AsciiDisplayComponent) ].sort((a, b) => a[1].z - b[1].z)) { const screenPosition = this.camera.getScreenPositionFromWorldPosition( positionComponent.position ); const entityScreenBox = displayComponent.sprite.boundingBox.clone().moveAnchorTo(screenPosition); const screenIntersection = this.camera.screenViewport.intersection(entityScreenBox); screenIntersection?.forEach((screenX, screenY) => { const worldX = this.camera.worldAnchor.x + screenX; const worldY = this.camera.worldAnchor.y + screenY; const localX = worldX - positionComponent.position.x; const localY = worldY - positionComponent.position.y; const tile = displayComponent.sprite.getTileAt(localX, localY); if (tile) { frame.merge(screenX, screenY, tile); } }); } if (this.options.renderColliders) { this.renderCollidersOntoSprite(frame); } return frame; } renderCollidersOntoSprite(frame) { this.camera?.screenViewport.forEach((x, y) => { const sp = new Vec2(x, y); const wp = this.camera?.getWorldPositionFromScreenPosition(sp) ?? Vec2.ORIGIN; const collidingEntities = this.camera?.world.entitiesCollidingAt(wp).length; if (collidingEntities !== void 0) { frame.merge(x, y, collidingEntities.toString()); } }); } printCurrentFrame() { if (this.currentFrame) { const render = renderMatrix(this.currentFrame.asStringMatrix()); console.log(render); } } } const normalizeGridWorldOptions = (options) => { return { io: options?.io, executorSpeed: options?.executorSpeed ?? 60, executorHaltCondition: options?.executorHaltCondition ?? "untilSettled", rendererOptions: { renderColliders: options?.rendererOptions?.renderColliders ?? false }, cameraOptions: options?.cameraOptions }; }; const defaultGridWorldAocOptions = { executorSpeed: process.env["RUN"] ? 60 : "instant", executorHaltCondition: "untilSettled", io: process.env["RUN"] ? "terminalKit" : void 0 }; class GridWorld { options; entities = /* @__PURE__ */ new Map(); components = /* @__PURE__ */ new Map(); systems = []; /** * Any component that can be cached spacially will be cached here with relation * to the PositionComponent. Any other component that defines an Area * will be cached here and updated as the PositionComponent is updated. */ componentSpatialCache = /* @__PURE__ */ new Map(); /** * If one was added, it's reference is made easily accessible */ rendererSystem; _io; _systemsInitialized = false; _lastFrameTime = 0; _timeData = { time: 0, tick: 0, deltaTime: 0 }; _ticksDoingNothing = 0; _systemsSettledAtTick = void 0; _executor; nextEntityId = 0; constructor(options = defaultGridWorldAocOptions) { this.options = normalizeGridWorldOptions(options); if (this.options.executorSpeed === "stepper") { this._executor = new StepperExecutor(this, this.options.executorHaltCondition); } else if (this.options.executorSpeed === "instant") { this._executor = new UntilHaltExecutor(this, this.options.executorHaltCondition); } else { this._executor = new IntervalExecutor( this, this.options.executorHaltCondition, hzToMs(this.options.executorSpeed) ); } switch (this.options.io) { case "terminalKit": { this._io = new TerminalKitIOBackend(); break; } case "blessed": { this._io = new BlessedIOBackend(); break; } case "console": { this._io = new ConsoleLogIOBackend(); break; } } if (this._io) { const cameraEntity = spawnCamera(this, this.options.cameraOptions); if (!this.rendererSystem) { this.addSystem( new RendererSystem({ ...this.options.rendererOptions, cameraEntity, backend: this._io }) ); } } } halt() { this.executor?.halt(); } getVisibleEntityBoundingBox() { return AsciiDisplayComponent.getSpatialCache( this, AsciiDisplayComponent ).areaOfAllNonInfiniteEntities(); } getCollidingEntityBoundingBox() { return ColliderComponent.getSpatialCache( this, AsciiDisplayComponent ).areaOfAllNonInfiniteEntities(); } centerCameraOnEntity(entity) { try { const [, camera] = this.queryOne(CameraComponent); const entityCenter = entity.getCenterPosition(); if (entityCenter) { camera.centerOn(entityCenter); } } catch { return; } } centerCameraOnEntities() { try { const [, camera] = this.queryOne(CameraComponent); const entityBoundingBox = this.getVisibleEntityBoundingBox(); camera.centerOn(entityBoundingBox.center); } catch { return; } } entitiesVisibleAt(position) { return AsciiDisplayComponent.getSpatialCache(this, AsciiDisplayComponent).get(position); } entitiesCollidingAt(position) { return ColliderComponent.getSpatialCache(this, ColliderComponent).get(position); } addSystem(systemLike) { if (systemLike instanceof RendererSystem) { this.rendererSystem = systemLike; } const system = typeof systemLike === "function" ? { init: () => void 0, tick: systemLike } : systemLike; this.systems.push(system); } async run() { return this._executor ? await this._executor.run() : this.timeData.tick; } async initializeSystems() { if (!this._systemsInitialized) { await Promise.all( this.systems.filter((system) => system instanceof System).map((system) => system.init(this)) ); this._systemsInitialized = true; this._timeData.time = performance.now(); } } tick() { if (!this._systemsInitialized) { throw new Error("Trying to advance world state when systems are not initialized!"); } const now = performance.now(); this._timeData.deltaTime = now - this._timeData.time; this._timeData.time = now; this._timeData.tick += 1; let mutated = false; for (const system of this.systems) { const systemMutated = system.tick(this, this._timeData) ?? false; mutated = mutated || systemMutated; } this._ticksDoingNothing = mutated ? 0 : this._ticksDoingNothing + 1; if (this._systemsSettledAtTick === void 0 && this._ticksDoingNothing >= 2) { this._systemsSettledAtTick = this._timeData.tick; } this._lastFrameTime = performance.now() - now; return this._timeData.tick; } get timeData() { return this._timeData; } get lastFrameTime() { return this._lastFrameTime; } get systemsSettled() { return this._systemsSettledAtTick === void 0 ? false : this._systemsSettledAtTick <= this.timeData.tick; } get io() { return this._io; } get executor() { return this._executor; } spawn(...components) { const entity = new Entity(this, this.nextEntityId); for (const component of components) { this.attachComponent(entity, component); } this.entities.set(entity.entityId, entity); for (const component of entity.components.values()) { component.onSpawn(); } entity.spawned = true; this.nextEntityId += 1; return entity; } attachComponent(entity, component) { component.world = this; entity.components.set(component.componentType(), component); if (!arrayContains(component.belongsTo, entity)) { component.belongsTo.push(entity); } const comps = this.components.get(component.componentType()) ?? []; comps.push(component); this.components.set(component.componentType(), comps); } deattachComponent(entity, component) { entity.components.delete(component.componentType()); component.belongsTo.removeItem(entity); if (component.belongsTo.length === 0) { this.components.get(component.componentType())?.removeItem(component); } } despawn(entity) { this.entities.delete(entity.entityId); for (const component of entity.components.values()) { this.deattachComponent(entity, component); } entity.spawned = false; } getSingletonComponent(componentType) { const component = this.getComponentsByType(componentType); if (component.length === 1) { return component[0]; } else if (component.length === 0) { throw new Error( `[getSingletonComponent] no entity exists with component ${String(componentType)}` ); } else { throw new Error( `[getSingletonComponent] more than one entity exists with component ${String( componentType )}` ); } } getComponentsByType(componentType) { return this.components.get(componentType) ?? []; } getCamera() { return this.queryOne(CameraComponent)[1]; } followWithCamera(entity) { const camera = this.getCamera(); camera.followEntity(entity); } /** * ? Type is not correctly inferred within filter objects * @param componentFilters * @returns */ filter(...componentFilters) { return filterMap(this.entities.values(), (entity) => { const matchingComponents = filterMap(componentFilters, (componentFilter) => { const component = entity.components.get(componentFilter.componentType); return component ? componentFilter.filter(component) : false; }); return matchingComponents.length > 0 ? [entity, ...matchingComponents] : void 0; }); } query(...componentTypes) { const firstComponentType = componentTypes[0]; if (firstComponentType) { const entities = this.getComponentsByType(firstComponentType).reduce( (acc, component) => { for (const belongingEntity of component.belongsTo) { acc.add(belongingEntity); } return acc; }, /* @__PURE__ */ new Set() ); return filterMap(entities, (entity) => { const matchingComponents = componentTypes.map( (componentType) => entity.components.get(componentType) ); return matchingComponents.every((component) => !!component) ? [entity, ...matchingComponents] : void 0; }); } else { return []; } } queryOne(...componentTypes) { const firstComponentType = componentTypes[0]; if (firstComponentType) { const entities = this.getComponentsByType(firstComponentType).reduce( (acc, component) => { for (const belongingEntity of component.belongsTo) { acc.add(belongingEntity); } return acc; }, /* @__PURE__ */ new Set() ); for (const entity of entities) { const matchingComponents = componentTypes.map( (componentType) => entity.components.get(componentType) ); if (matchingComponents.every((component) => !!component)) { return [entity, ...matchingComponents]; } } } throw new Error( `Cannot find an entity with components: ${componentTypes.map((t) => t.name).join(", ")}` ); } print() { this.rendererSystem?.printCurrentFrame(); } } export { AnyPositionComponent, AsciiDisplayComponent, CameraComponent, ColliderComponent, Component, Entity, Executor, FloorMarkerComponent, GridWorld, NameComponent, PlayerMarkerComponent, PositionComponent, SpatialComponent, StaticPositionComponent, System, UntilHaltExecutor, WallMarkerComponent, addArrowMoveSystem, addAutocirclingSystem, addWasdMoveSystem, defaultGridWorldAocOptions, spawnCamera, spawnCompass, spawnFloor, spawnPlayer, spawnWall };