@alexaegis/ecs
Version:
A basic ECS library
1,416 lines (1,415 loc) • 43 kB
JavaScript
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
};