@game-vir/entity
Version:
Entity system for game development.
384 lines (383 loc) • 14 kB
JavaScript
import { assert, assertWrap, check } from '@augment-vir/assert';
import { arrayToObject, getObjectTypedEntries, log, makeWritable, wrapInTry, } from '@augment-vir/common';
import { System } from 'detect-collisions';
import { assertValidShape, defineShape } from 'object-shape-tester';
import { ParticleContainer } from 'pixi.js';
import { defineTypedEvent, ListenTarget } from 'typed-event-target';
import { ConstructorMap } from '../constructor-map.js';
/**
* The top level storage class of all entities. Add entities with {@link EntityStore.addEntity}.
*
* @category Internal
*/
export class EntityStore {
/**
* All current child entities.
*
* Instead of modifying this set, use {@link EntityStore.addEntity} or
* {@link EntityStore.removeEntity}. If you must manually modify this set directly, you'll also
* need to modify {@link EntityStore.entityInstanceMap}.
*/
currentEntityInstances = new Set();
/** If true, this entity store should no longer be used or operated upon. */
isDestroyed = false;
/** An internal mapping of all entity constructors to their current instances. */
entityInstanceMap = new ConstructorMap();
/** Original pixi app. */
pixi;
/** Context given to all entities. This can be undefined. */
context;
/** Collision detection system. */
hitboxSystem;
/** A map of all entity keys to their registered Entity constructors. */
entityKeyConstructorMap;
/** Events emitted from any child entities. */
events = new ListenTarget();
constructor(args) {
this.pixi = args.pixi;
this.context = args.context;
this.hitboxSystem = args.hitboxSystem || new System();
this.entityKeyConstructorMap = arrayToObject(args.registeredEntities, (entityConstructor) => {
return {
key: entityConstructor.entityKey,
value: entityConstructor,
};
});
}
/**
* Runs `.update()` on all current entities and runs collision detection for all hitboxes. If
* any entities get marked as destroyed during their update, then they will be removed from the
* set of entities.
*
* @returns All detected hitbox collisions (if any).
*/
updateAllEntities() {
if (this.isDestroyed) {
throw new Error('Cannot operate on a destroyed entity store.');
}
this.currentEntityInstances.forEach((entity) => {
/** Check if the entity was destroyed outside of an update cycle. */
if (entity.isDestroyed) {
entity.immediatelyDestroy();
return;
}
entity.update();
/** Check if the entity was destroyed while updating. */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (entity.isDestroyed) {
entity.immediatelyDestroy();
}
});
this.hitboxSystem.update();
/**
* This `checkAll` method is synchronous, so even though its using a callback it'll still
* finish before this `updateAllEntities` method exits.
*/
this.hitboxSystem.checkAll((response) => {
try {
const primaryEntity = wrapInTry(() => {
return assertWrap.instanceOf(response.a.userData, BaseEntity);
}, {
fallbackValue: undefined,
});
const secondaryEntity = wrapInTry(() => {
return assertWrap.instanceOf(response.b.userData, BaseEntity);
}, {
fallbackValue: undefined,
});
/* node:coverage ignore next 3 */
if (!primaryEntity || !secondaryEntity) {
return;
}
primaryEntity.collide(secondaryEntity, response);
/* node:coverage ignore next 3: catch all edge cases just in case */
}
catch (error) {
log.error(error);
}
});
}
/** Get all current instances of the given entity class constructor. */
getEntities(entityClassConstructor) {
return this.entityInstanceMap.getInstances(entityClassConstructor);
}
/** Remove an entity from the store. */
removeEntity(entity) {
if (this.isDestroyed) {
throw new Error('Cannot operate on a destroyed entity store.');
}
this.currentEntityInstances.delete(entity);
this.entityInstanceMap.remove(entity);
if (entity instanceof ViewEntity && !entity.isDestroyed) {
// eslint-disable-next-line unicorn/prefer-dom-node-remove
this.pixi.stage.removeChild(entity.view);
if (entity.hitbox) {
this.hitboxSystem.remove(entity.hitbox);
}
}
}
/**
* Create an entity instance by finding the registered constructor with the given `entityKey`
* and then deserializing and passing the given `serializedParams` to that constructor.
*/
deserializeEntity(entityKey, serializedParams) {
if (this.isDestroyed) {
throw new Error('Cannot operate on a destroyed entity store.');
}
const entityConstructor = this.entityKeyConstructorMap[entityKey];
if (!entityConstructor) {
throw new Error(`No entity registered for key '${entityKey}'`);
}
return this.addEntity(entityConstructor, entityConstructor.deserialize(serializedParams));
}
/** Create a new instance of the given entity class and add it to this entity store. */
addEntity(entityClass, ...params) {
if (this.isDestroyed) {
throw new Error('Cannot operate on a destroyed entity store.');
}
const entityConstructor = this.entityKeyConstructorMap[entityClass.entityKey];
if (!entityConstructor) {
throw new Error(`No entity registered for key '${entityClass.entityKey}'`);
}
const child = new entityClass({
entityStore: this,
pixi: this.pixi,
context: this.context,
params: params[0],
hitboxSystem: this.hitboxSystem,
});
this.currentEntityInstances.add(child);
this.entityInstanceMap.add(child);
return child;
}
/** Destroys the entity store and all entities contained inside it. */
destroy() {
if (this.isDestroyed) {
throw new Error('Entity store is already destroyed.');
}
this.currentEntityInstances.forEach((entity) => entity.destroy());
makeWritable(this).isDestroyed = true;
this.events.destroy();
this.currentEntityInstances.clear();
this.entityInstanceMap.destroy();
delete this.pixi;
delete this.hitboxSystem;
delete this.context;
delete this.events;
}
}
/**
* Shape definition for {@link EntityPositionParams}.
*
* @category Util
*/
export const entityPositionParamsShape = defineShape({
x: -1,
y: -1,
});
/**
* Event emitted by all entities when they are destroyed.
*
* @category Internal
*/
export class EntityDestroyEvent extends defineTypedEvent('entity-destroy-event') {
}
/**
* Default value for the optional {@link ParamsMap}.
*
* @category Internal
*/
export const defaultParamsMap = {
hitbox: {
x: true,
y: true,
},
view: {
x: true,
y: true,
},
};
/**
* Base entity class, types, and functionality.
*
* @category Internal
*/
export class BaseEntity {
/**
* This key is used for deserialization of entities to track which class needs to be
* constructed. You cannot have duplicate keys loaded at the same time.
*/
static entityKey = 'BaseEntity';
/** Shape definition of this entity's parameters. */
static paramsShape;
/**
* Defines which properties from {@link BaseEntity.params} will be mapped to hitbox and/or view
* properties.
*/
static paramsMap;
/**
* A mapping from params properties to hitbox or view properties, making it easy to map params
* values.
*/
static reverseParamsMap;
/** Parses the serialized params generated by {@link BaseEntity.serialize}. */
static deserialize(serialized) {
const deserialized = serialized ? JSON.parse(serialized) : undefined;
if (this.paramsShape) {
assertValidShape(deserialized, this.paramsShape);
}
else {
assert.isUndefined(deserialized);
}
return deserialized;
}
/**
* Dispatch an event. This will be dispatched through this entity's entity store (so listen for
* the event on the store).
*/
dispatch(event) {
/** Cast to optionally undefined to account for destruction. */
this.entityStore?.events.dispatch(event);
}
/** If true, this entity should no longer be used or operated upon. */
isDestroyed = false;
hitbox;
/** The entity store to add all entities to. */
entityStore;
context;
/** Writable entity params. These should be serializable. */
params;
/** Original pixi app. */
pixi;
/** Collision detection system. */
hitboxSystem;
constructor(args) {
this.entityStore = args.entityStore;
this.context = args.context;
this.params = args.params;
this.pixi = args.pixi;
this.hitboxSystem = args.hitboxSystem;
}
/** Add a new entity to the entity store. */
addEntity(entityClass, ...params) {
if (this.isDestroyed) {
throw new Error('Cannot add entity through destroyed entity.');
}
return this.entityStore.addEntity(entityClass, ...params);
}
/** Marks the entity for destruction in the next entity store update. */
destroy() {
makeWritable(this).isDestroyed = true;
}
/**
* Immediately destroy the current entity, stop its updates.
*
* This is probably not what you want to use! See {@link BaseEntity.destroy} instead.
*/
immediatelyDestroy() {
makeWritable(this).isDestroyed = true;
this.entityStore?.removeEntity(this);
this.dispatch(new EntityDestroyEvent());
delete this.entityStore;
delete this.context;
delete this.hitboxSystem;
delete this.params;
}
/**
* This method is call whenever this entity's hitbox (if it has one) collides with another
* hitbox. It will be called for each individual collision. Override this to do something about
* it.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
collide(otherEntity, collision) { }
/**
* Serialize the entity params for sharing across the network (for multiplayer play). By default
* this simply calls `JSON.stringify` on `this.params`. This method must be overridden if your
* entity has params that are not JSON compatible.
*/
serialize() {
return this.params ? JSON.stringify(this.params) : undefined;
}
}
/**
* Base view entity class, types, and functionality.
*
* @category Internal
*/
export class ViewEntity extends BaseEntity {
/** The entity's PixiJS view. */
view;
constructor(args) {
super(args);
const { view, hitbox } = this.createView();
if (view) {
this.view = view;
this.pixi.stage.addChild(this.view);
}
else {
this.view = new ParticleContainer();
this.view.visible = false;
}
if (hitbox) {
this.hitbox = hitbox;
this.hitbox.userData = this;
this.hitboxSystem.insert(this.hitbox);
}
this.wrapParamsInProxy();
}
wrapParamsInProxy() {
const paramsMap = this.constructor.paramsMap;
const reverseParamsMap = this.constructor.reverseParamsMap;
const params = this.params;
if (!params || !paramsMap || !reverseParamsMap) {
return;
}
makeWritable(this).params = new Proxy(params, {
set: (target, propertyKey, value, receiver) => {
if (propertyKey in params && check.hasKey(reverseParamsMap, propertyKey)) {
const mappings = reverseParamsMap[propertyKey];
if (this.hitbox && mappings?.hitbox) {
mappings.hitbox.forEach((mapToKey) => {
this.hitbox[mapToKey] = value;
});
}
if (mappings?.view) {
mappings.view.forEach((mapToKey) => {
this.view[mapToKey] = value;
});
}
}
return Reflect.set(target, propertyKey, value, receiver);
},
});
/** Propagate initial params. */
getObjectTypedEntries(this.params).forEach(([key, value,]) => {
this.params[key] = value;
});
}
/** Detects if the current entity is still within the bounds of the render canvas. */
isInBounds(options = {}) {
if (this.isDestroyed) {
throw new Error('Cannot check bounds on destroyed entity.');
}
else if (options.entirely) {
return this.pixi.screen.containsRect(this.view.getBounds().rectangle);
}
else {
return this.pixi.screen.intersects(this.view.getBounds().rectangle);
}
}
/**
* Immediately destroy the current entity, stop its updates, and remove it from the view.
*
* This is probably not what you want to use! See {@link BaseEntity.destroy} instead.
*/
immediatelyDestroy() {
this.view?.destroy({ children: true });
if (this.hitbox) {
this.hitboxSystem.remove(this.hitbox);
}
super.immediatelyDestroy();
delete this.view;
}
}