UNPKG

@game-vir/entity

Version:

Entity system for game development.

384 lines (383 loc) 14 kB
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; } }