UNPKG

vertecs

Version:

A typescript entity-component-system framework

1,529 lines (1,507 loc) 467 kB
import { v4 } from 'uuid'; import { Vec3 as Vec3$1, Mat4, Quat as Quat$1, mat4, vec3, quat } from 'ts-gl-matrix'; import { Material, Camera, Quaternion, PerspectiveCamera, Scene, StaticDrawUsage, InstancedMesh, WebGLRenderer, PCFSoftShadowMap, ColorManagement, SRGBColorSpace, Matrix4, Vector3, AnimationMixer, LineBasicMaterial, BufferGeometry, Line, Mesh, SphereGeometry, MeshBasicMaterial } from 'three'; import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { CSS3DObject, CSS3DRenderer } from 'three/addons/renderers/CSS3DRenderer.js'; import { WebSocketServer } from 'ws'; /** * A component is a piece of data that is attached to an entity */ class Component { /** * The component id * @private */ #id; /** * The entity this component is attached to * @protected */ $entity; /** * Create a new component */ constructor(id) { this.#id = id ?? v4(); } /** * Called when the component is added to the entity */ onAddedToEntity(entity) { } /** * Called when the component is removed from the entity */ onRemovedFromEntity(entity) { } /** * Called when the attached entity has a new parent * This method is called before the parent is updated * @param entity The new parent entity */ onEntityNewParent(entity) { } /** * Called when another component is added to the attached entity * @param component */ onComponentAddedToAttachedEntity(component) { } /** * This method is called when the {@see destroy} method is called */ onDestroyed() { } /** * Return a new clone of this component, by default, it returns the same component */ clone() { return this; } get id() { return this.#id; } get entity() { return this.$entity; } set entity(value) { this.$entity = value; } } /** * An entity is a general purpose object which contains components */ class Entity { #ecsManager; #id; #components; #parent; #children; #name; #root; #tags; constructor(options) { this.#id = options?.id ?? v4(); this.#children = []; this.#components = []; this.#ecsManager = options?.ecsManager; options?.parent?.addChild(this); this.#name = options?.name; this.#root = this; this.#tags = []; if (this.#ecsManager) { this.#ecsManager.addEntity(this); } options?.children?.forEach((child) => this.addChild(child)); // Add components one by one to trigger events options?.components?.forEach((component) => this.addComponent(component)); } /** * Find an entity by it's id * @param ecsManager * @param id The entity id to find */ static findById(ecsManager, id) { return ecsManager.entities.find((entity) => entity.id === id); } /** * Find an entity by a component * @param ecsManager * @param component The component class */ static findByComponent(ecsManager, component) { return ecsManager.entities.find((entity) => entity.getComponent(component)); } /** * Find an entity by a component * @param ecsManager The ecs manager * @param component The component class */ static findAllByComponent(ecsManager, component) { return ecsManager.entities.filter((entity) => entity.getComponent(component)); } /** * Find an entity by a tag * @param ecsManager * @param tag The tag */ static findAllByTag(ecsManager, tag) { return ecsManager.entities.filter((entity) => entity.tags.includes(tag)); } /** * Return the first child found with the specified name * @param name The child name */ findChildByName(name) { for (let i = 0; i < this.children.length; i++) { const child = this.children[i]; if (child.name === name) { return child; } } for (let i = 0; i < this.children.length; i++) { const child = this.children[i]; const found = child.findChildByName(name); if (found) { return found; } } return undefined; } /** * Find the first entity in the entity hierarchy with the specified component * @param component */ findWithComponent(component) { if (this.getComponent(component)) { return this; } return this.children.find((child) => child.findWithComponent(component)); } /** * Return a component by its class * @param componentClass The component's class or subclass constructor */ getComponent(componentClass) { return this.components.find((component) => component instanceof componentClass); } /** * Return the first component found in an entity hierarchy * @param componentConstructor The component's class or subclass constructor */ findComponent(componentConstructor) { return this.findWithComponent(componentConstructor)?.getComponent(componentConstructor); } /** * Return all components present in the entity * @param filter */ getComponents(filter) { if (!filter) { // Return all components when no filter is given return Array.from(this.#components.values()); } return filter.map((filteredComponentClass) => this.getComponent(filteredComponentClass)); } /** * Add a component to this entity * @param newComponent The component to add */ addComponent(newComponent) { if (!this.getComponent(newComponent.constructor)) { newComponent.entity = this; this.components.push(newComponent); this.#ecsManager?.onComponentAddedToEntity(this, newComponent); newComponent.onAddedToEntity(this); this.#components.forEach((component) => { if (component !== newComponent) { component.onComponentAddedToAttachedEntity(component); } }); } } addComponents(...newComponents) { newComponents.forEach((component) => this.addComponent(component)); } /** * Add a child to this entity * @param entity The child */ addChild(entity) { this.#children.push(entity); entity.parent = this; entity.root = this.root; if (!entity.ecsManager) { this.ecsManager?.addEntity(entity); } } /** * Add a tag to an entity * @param tag The tag to add */ addTag(tag) { this.#tags.push(tag); } /** * Remove a components from this entity * @param componentClass The component's class to remove */ removeComponent(componentClass) { const component = this.getComponent(componentClass); if (component) { this.#components.splice(this.#components.indexOf(component), 1); this.#ecsManager?.onComponentRemovedFromEntity(this, component); component.onRemovedFromEntity(this); return component; } return undefined; } /** * Clone an entity's name, components, recursively */ clone(id) { const clone = new Entity({ name: this.#name, components: Array.from(this.#components.values()).map((component) => component.clone()), ecsManager: this.#ecsManager, id, }); this.children.forEach((child) => { clone.addChild(child.clone()); }); return clone; } /** * Destroy this entity, remove and destroy all added components */ destroy() { this.children.forEach((child) => child.destroy()); for (let i = this.components.length - 1; i >= 0; i--) { this.removeComponent(this.components[i].constructor); } this.parent?.children.splice(this.parent.children.indexOf(this), 1); this.#ecsManager?.removeEntity(this); } get ecsManager() { return this.#ecsManager; } set ecsManager(value) { this.#ecsManager = value; } get tags() { return this.#tags; } get root() { return this.#root; } set root(value) { this.#root = value; } get components() { return this.#components; } get parent() { return this.#parent; } set parent(entity) { this.components.forEach((component) => component.onEntityNewParent(entity)); this.#parent = entity; this.#root = entity?.root ?? this; } get name() { return this.#name; } set name(value) { this.#name = value; } get children() { return this.#children; } get id() { return this.#id; } } /** * A system loops over all entities and uses the components of the entities to perform logic. */ class System { ecsManager; #hasStarted; filter; #lastUpdateTime; #tps; #loopTime; $dependencies; #sleepTime; /** * Create a new system with the given component group filter and the given tps */ constructor(filter, tps, dependencies) { this.filter = filter; this.#lastUpdateTime = -1; this.#tps = tps ?? 60; this.#hasStarted = false; this.#loopTime = 0; this.$dependencies = dependencies ?? []; this.#sleepTime = 0; } /** * Called every frame, you should not call this method directly but instead use the {@see onLoop} method */ loop(components, entities) { this.#sleepTime -= this.getDeltaTime(); if (this.#sleepTime > 0) { return; } const startLoopTime = performance.now(); this.onLoop(components, entities, this.getDeltaTime()); this.#loopTime = performance.now() - startLoopTime; this.#lastUpdateTime = performance.now(); } async start(ecsManager) { this.ecsManager = ecsManager; this.#hasStarted = true; await this.onStart(); } async stop() { this.#hasStarted = false; await this.onStop(); } sleep(milliseconds) { this.#sleepTime = milliseconds; } /** * Called when the system is added to an ecs manager * @param ecsManager */ onAddedToEcsManager(ecsManager) { } /** * Called when the system is added to an ecs manager */ async initialize() { } /** * Called when the system is ready to start */ async onStart() { } /** * Called when the system is stopped */ async onStop() { } /** * Called whenever an entity becomes eligible to a system * An entity becomes eligible when a component is added to an entity making it eligible to a group, * or when a new system is added and an entity was already eligible to the new system's group */ onEntityEligible(entity, components) { } /** * Called when an entity becomes ineligible to a system, and before it is removed from the system */ onEntityNoLongerEligible(entity, components) { } /** * Return the time since the last update * @private */ getDeltaTime() { return performance.now() - this.#lastUpdateTime || 0; } /** * Return true if enough time has passed since the last update, false otherwise */ hasEnoughTimePassed() { return (this.#lastUpdateTime === -1 || performance.now() - this.#lastUpdateTime >= 1000 / this.#tps); } get dependencies() { return this.$dependencies; } get lastUpdateTime() { return this.#lastUpdateTime; } set lastUpdateTime(value) { this.#lastUpdateTime = value; } get loopTime() { return this.#loopTime; } get tps() { return this.#tps; } set tps(value) { this.#tps = value; } get hasStarted() { return this.#hasStarted; } set hasStarted(value) { this.#hasStarted = value; } } class DependencyGraph { static getOrderedSystems(systems) { const resolved = []; const unresolved = []; systems.forEach((system) => { if (systems.includes(system) && !resolved.includes(system)) { this.resolveSystem(systems, system, resolved, unresolved); } }); return resolved; } static resolveSystem(systems, systemToResolve, resolvedSystems, unresolved) { if (!systemToResolve.hasEnoughTimePassed()) { return false; } unresolved.push(systemToResolve); const dependencies = systemToResolve.dependencies.map((dependencyClass) => systems.find((system) => system instanceof dependencyClass)); for (let i = 0; i < dependencies.length; i++) { const dependency = dependencies[i]; if (!dependency || !dependency.hasEnoughTimePassed()) { const index = unresolved.indexOf(systemToResolve); if (index > -1) { unresolved.splice(index, 1); } return false; } if (!resolvedSystems.includes(dependency)) { if (!this.resolveSystem(systems, dependency, resolvedSystems, unresolved)) { // If we can't resolve the dependency, we can't resolve the system. const index = unresolved.indexOf(systemToResolve); if (index > -1) { unresolved.splice(index, 1); } return false; } } } resolvedSystems.push(systemToResolve); const index = unresolved.indexOf(systemToResolve); if (index > -1) { unresolved.splice(index, 1); } return true; } } /** * The system manager is responsible for managing all the systems */ class EcsManager { #entities = []; ecsGroups; isStarted; #loopTime; constructor() { this.ecsGroups = new Map(); this.isStarted = false; this.#loopTime = 0; } /** * Create a new entity and add it to this ecs manager * @param options */ createEntity(options) { return new Entity({ ...options, ecsManager: this }); } /** * Add a system to the system manager */ async addSystem(system) { system.onAddedToEcsManager(this); let ecsGroup = this.ecsGroups.get(system.filter); if (!system.hasStarted) { await system.start(this); } if (!ecsGroup) { ecsGroup = { entities: [], components: [], systems: [system], }; this.ecsGroups.set(system.filter, ecsGroup); } this.#entities?.forEach((entity) => { if (ecsGroup && this.isEntityEligibleToGroup(system.filter, entity)) { const components = entity.getComponents(system.filter); system.onEntityEligible(entity, components); ecsGroup.entities.push(entity); ecsGroup.components.push(components); } }); await system.initialize(); } removeSystem(SystemConstructor) { const system = this.findSystem(SystemConstructor); if (!system) { return; } const ecsGroup = this.ecsGroups.get(system.filter); if (!ecsGroup) { return; } const systemIndex = ecsGroup.systems.indexOf(system); if (systemIndex === -1) { return; } ecsGroup.systems.splice(systemIndex, 1); if (ecsGroup.systems.length === 0) { this.ecsGroups.delete(system.filter); } ecsGroup.entities.forEach((entity, i) => { const components = ecsGroup.components[i]; system.onEntityNoLongerEligible(entity, components); }); system.stop(); } /** * The entry point of the ECS engine */ async start() { this.isStarted = true; Array.from(this.ecsGroups.values()).forEach((ecsGroup) => { ecsGroup.systems.forEach(async (system) => { if (!system.hasStarted) { await system.start(this); } }); }); setTimeout(this.loop.bind(this)); } async stop() { Array.from(this.ecsGroups.values()).forEach((ecsGroup) => { ecsGroup.systems.forEach(async (system) => { if (system.hasStarted) { await system.stop(); } }); }); this.isStarted = false; } /** * Add multiple entities to the system manager */ addEntities(entities) { entities.forEach((entity) => { if (entity.ecsManager !== this) { this.addEntity(entity); } }); } /** * Add an entity to the system manager */ addEntity(newEntity) { if (this.#entities.find((entity) => entity.id === newEntity.id)) { throw new Error(`Entity found with same ID: ${newEntity.id}`); } newEntity.ecsManager = this; Array.from(this.ecsGroups.entries()).forEach(([filter, ecsGroup]) => { if (this.isEntityEligibleToGroup(filter, newEntity) && !ecsGroup.entities.includes(newEntity)) { ecsGroup.entities.push(newEntity); const components = newEntity.getComponents(filter); ecsGroup.systems.forEach((system) => system.onEntityEligible(newEntity, components)); ecsGroup.components.push(components); } }); if (newEntity.children.length > 0) { this.addEntities(newEntity.children); } this.#entities.push(newEntity); } removeEntity(entity) { const entityIndex = this.#entities.indexOf(entity); if (entityIndex === -1) { return; } this.#entities.splice(entityIndex, 1); Array.from(this.ecsGroups.entries()).forEach(([filter, ecsGroup]) => { const entityIndex = ecsGroup.entities.indexOf(entity); if (entityIndex !== -1) { const components = ecsGroup.components[entityIndex]; ecsGroup.systems.forEach((system) => system.onEntityNoLongerEligible(entity, components)); ecsGroup.entities.splice(entityIndex, 1); ecsGroup.components.splice(entityIndex, 1); } }); } destroyEntity(entityId) { const entityToDestroy = this.#entities.find((entity) => entity.id === entityId); entityToDestroy?.destroy(); } /** * Loop through all the systems and call their loop method, this method should not be called manually, * see {@link start} */ loop() { const start = performance.now(); if (this.ecsGroups.size === 0) { throw new Error("No system found"); } if (!this.isStarted) { return; } // Make a tree of systems based on their dependencies const systemsToUpdate = []; this.ecsGroups.forEach((ecsGroup, group) => { ecsGroup.systems .filter((system) => system.hasEnoughTimePassed()) .forEach((system) => systemsToUpdate.push(system)); }); DependencyGraph.getOrderedSystems(systemsToUpdate).forEach((system) => { const ecsGroup = this.ecsGroups.get(system.filter); if (!ecsGroup) { return; } system.loop(ecsGroup.components, ecsGroup.entities); }); this.#loopTime = performance.now() - start; if (typeof requestAnimationFrame === "undefined") { setImmediate(this.loop.bind(this)); } else { requestAnimationFrame(this.loop.bind(this)); } } /** * Check if an entity components is eligible to a group filter */ isEntityEligibleToGroup(group, entity) { return group.every((systemComponentClass) => entity.getComponent(systemComponentClass)); } /** * Called after a component is added to an entity * @param entity * @param component */ onComponentAddedToEntity(entity, component) { Array.from(this.ecsGroups.keys()).forEach((group) => { if (this.isEntityEligibleToGroup(group, entity)) { const ecsGroup = this.ecsGroups.get(group); if (ecsGroup && !ecsGroup.entities.includes(entity)) { const components = entity.getComponents(group); ecsGroup.entities.push(entity); ecsGroup.systems.forEach((system) => system.onEntityEligible(entity, components)); ecsGroup.components.push(components); } } }); } findSystem(SystemConstructor) { return Array.from(this.ecsGroups.values()) .flatMap((ecsGroup) => ecsGroup.systems) .find((system) => system instanceof SystemConstructor); } /** * Called after a component is removed from an entity * This method will check if the entity is still eligible to the groups and flag it for deletion if not * @param entity * @param component */ onComponentRemovedFromEntity(entity, component) { Array.from(this.ecsGroups.entries()).forEach(([filter, ecsGroup]) => { if (!this.isEntityEligibleToGroup(filter, entity) && ecsGroup.entities?.includes(entity)) { const entityToDeleteIndex = ecsGroup.entities.indexOf(entity); const components = ecsGroup.components[entityToDeleteIndex]; ecsGroup.systems.forEach((system) => system.onEntityNoLongerEligible(entity, components)); ecsGroup.entities.splice(entityToDeleteIndex, 1); ecsGroup.components.splice(entityToDeleteIndex, 1); } }); } get entities() { return this.#entities; } get loopTime() { return this.#loopTime; } } class IsPrefab extends Component { #prefabName; constructor(prefabName) { super(); this.#prefabName = prefabName; } get prefabName() { return this.#prefabName; } set prefabName(prefabName) { this.#prefabName = prefabName; } } class PrefabManager { static #prefabs = new Map(); constructor() { } static add(name, prefab) { if (!prefab.getComponent(IsPrefab)) { prefab.addComponent(new IsPrefab(name)); } this.#prefabs.set(name, prefab); } static get(name, id) { return this.#prefabs.get(name)?.clone(id); } } /** * A serializable component is a component that can be serialized and deserialized, * it is used to send components over network or to save them to a file for example */ class SerializableComponent extends Component { constructor(options) { super(options?.id); } serialize(addMetadata = false) { if (!addMetadata) { return { className: this.constructor.name, data: this.write(), }; } return { id: this.id, className: this.constructor.name, data: this.write(), }; } deserialize(serializedComponent) { return this.read(serializedComponent.data); } } /** * The json representation of an entity */ class SerializedEntity { $id; $components; $name; $destroyed; $parent; $prefabName; constructor(id, components, name, destroyed, parent, prefabName) { this.$id = id; this.$components = components; this.$name = name; this.$destroyed = destroyed; this.$parent = parent; this.$prefabName = prefabName; } toJSON() { return { id: this.$id, name: this.$name, components: Array.from(this.$components.entries()), destroyed: this.$destroyed, parent: this.$parent, prefabName: this.$prefabName, }; } static reviver(key, value) { if (key === "components") { return new Map(value); } return value; } // TODO: Move to network entity get destroyed() { return this.$destroyed; } set destroyed(value) { this.$destroyed = value; } get parent() { return this.$parent; } set parent(value) { this.$parent = value; } get prefabName() { return this.$prefabName; } get id() { return this.$id; } get name() { return this.$name; } get components() { return this.$components; } } class IoUtils { /** * Imports an entity from a json string * @param ComponentClasses The list of component classes to import * @param serializedEntityJson */ static import(ComponentClasses, serializedEntityJson) { const serializedEntity = JSON.parse(serializedEntityJson, SerializedEntity.reviver); const targetEntity = new Entity({ id: serializedEntity.id, name: serializedEntity.name, }); serializedEntity.components.forEach((serializedComponent) => { const TargetComponentClass = ComponentClasses.find((ComponentClass) => ComponentClass.name === serializedComponent.className); if (!TargetComponentClass) { console.warn(`Unknown component found in import ${serializedComponent.className}`); return; } const component = new TargetComponentClass(); targetEntity.addComponent(component); component.deserialize(serializedComponent); }); return targetEntity; } /** * Exports an entity to a json string * @param entity */ static export(entity) { const serializedEntity = new SerializedEntity(entity.id, new Map(), entity.name); entity.components.forEach((component) => { if (component instanceof SerializableComponent) { serializedEntity.components.set(component.constructor.name, component.serialize(false)); } }); return JSON.stringify(serializedEntity); } } class ThreeCamera extends Component { #camera; #lookAt; #lookAtOffset; #orbitControls; constructor(camera, lookAt, lookAtOffset, id, update) { super(id); this.#camera = camera; this.#lookAt = lookAt; this.#lookAtOffset = lookAtOffset || new Vec3$1(); this.#orbitControls = update ?? true; } get orbitControls() { return this.#orbitControls; } get lookAtOffset() { return this.#lookAtOffset; } set lookAtOffset(value) { this.#lookAtOffset = value; } get lookAt() { return this.#lookAt; } set lookAt(value) { this.#lookAt = value; } get camera() { return this.#camera; } set camera(value) { this.#camera = value; } } class ThreeObject3D extends Component { #isVisible; #object3D; constructor(object3D, id) { super(id); this.#isVisible = true; this.#object3D = object3D; } get object3D() { return this.#object3D; } set object3D(value) { this.#object3D = value; } get isVisible() { return this.#isVisible; } set isVisible(value) { this.#isVisible = value; } clone() { const mesh = this.object3D; const clonedMesh = SkeletonUtils.clone(mesh); const { material } = mesh; if (material instanceof Material) { clonedMesh.material = material.clone(); } else if (Array.isArray(material)) { const materials = material; clonedMesh.material = materials.map((material) => material.clone()); } return new ThreeObject3D(clonedMesh); } } /** * A transform represents a position, rotation and a scale, it may have a parent Transform, * * Global position, rotation and scale are only updated when dirty and queried, * parents are updated from the current transform up to the root transform. */ class Transform extends Component { /** * The result of the post-multiplication of all the parents of this transform * This matrix is only updated when queried and dirty * @private */ modelToWorldMatrix; /** * The inverse of {@see modelToWorldMatrix} * This matrix is only updated when queried and dirty * @private */ worldToModelMatrix; /** * The result of Translation * Rotation * Scale * This matrix is only updated when queried and dirty * @private */ modelMatrix; /** * The current position * @private */ position; /** * The current rotation * @private */ rotation; /** * The current scale * @private */ scaling; dirty; forward; /** * The parent transform * @private */ parent; #worldPosition; #worldRotation; #worldScale; /** * Creates a new transform * @param translation Specifies the translation, will be copied using {@link Vec3.copy} * @param rotation Specifies the rotation, will be copied using {@link Quat.copy} * @param scaling Specifies the scale, will be copied using {@link Vec3.copy} * @param forward Specifies the forward vector, will be copied using {@link Vec3.copy} */ constructor(translation, rotation, scaling, forward) { super(); this.modelMatrix = Mat4.create(); this.modelToWorldMatrix = Mat4.create(); this.worldToModelMatrix = Mat4.create(); this.position = Vec3$1.create(); this.rotation = Quat$1.create(); this.scaling = Vec3$1.fromValues(1, 1, 1); this.forward = forward ?? Vec3$1.fromValues(0, 0, 1); this.#worldPosition = Vec3$1.create(); this.#worldRotation = Quat$1.create(); this.#worldScale = Vec3$1.create(); if (translation) Vec3$1.copy(this.position, translation); if (rotation) Quat$1.copy(this.rotation, rotation); if (scaling) Vec3$1.copy(this.scaling, scaling); this.dirty = true; } /** * Set this transform's parent to the entity's parent * @param entity The entity this transform is attached to */ onAddedToEntity(entity) { if (entity.parent) { this.setNewParent(entity.parent); } } /** * Remove this transform's parent * @param entity The entity this transform was attached to */ onRemovedFromEntity(entity) { this.parent = undefined; } /** * Called whenever the attached entity parent change * @param parent The new parent entity */ onEntityNewParent(parent) { this.setNewParent(parent); } setNewParent(parent) { if (!parent.getComponent(Transform)) { parent.addComponent(new Transform()); } this.parent = parent.getComponent(Transform); this.dirty = true; } onDestroyed() { } static fromMat4(matrix) { const transform = new Transform(); transform.copy(matrix); return transform; } /** * Copy the translation, rotation and scaling of the transform * @param transform The transform to copy from */ copy(transform) { Mat4.getTranslation(this.position, transform); Mat4.getRotation(this.rotation, transform); Mat4.getScaling(this.scaling, transform); this.dirty = true; } /** * Translate this transform using a translation vector * @param translation The translation vector */ translate(translation) { Vec3$1.add(this.position, this.position, translation); this.dirty = true; } setWorldRotation(rotation) { const inverseWorldRotation = Quat$1.invert(Quat$1.create(), this.getWorldRotation()); Quat$1.multiply(this.rotation, this.rotation, inverseWorldRotation); Quat$1.mul(this.rotation, this.rotation, rotation); this.dirty = true; } /** * Reset the position, rotation, and scale to the default values */ reset() { Vec3$1.set(this.position, 0, 0, 0); this.resetRotation(); Vec3$1.set(this.scaling, 1, 1, 1); this.dirty = true; } /** * Reset the rotation to the default values */ resetRotation() { Quat$1.set(this.rotation, 0, 0, 0, 1); this.dirty = true; } /** * Rotate this transform in the x axis * @param x An angle in radians */ rotateX(x) { Quat$1.rotateX(this.rotation, this.rotation, x); this.dirty = true; } /** * Rotate this transform in the y axis * @param y An angle in radians */ rotateY(y) { Quat$1.rotateY(this.rotation, this.rotation, y); this.dirty = true; } /** * Rotate this transform in the y axis * @param z An angle in radians */ rotateZ(z) { Quat$1.rotateZ(this.rotation, this.rotation, z); this.dirty = true; } rotate(rotation) { Quat$1.mul(this.rotation, this.rotation, rotation); this.dirty = true; } setWorldScale(scale) { const inverseScale = Vec3$1.inverse(Vec3$1.create(), this.getWorldScale()); Vec3$1.mul(this.scaling, this.scaling, inverseScale); Vec3$1.mul(this.scaling, this.scaling, scale); this.dirty = true; } scale(scale) { Vec3$1.multiply(this.scaling, this.scaling, scale); this.dirty = true; } setScale(scale) { Vec3$1.copy(this.scaling, scale); this.dirty = true; } setRotationQuat(rotation) { Quat$1.copy(this.rotation, rotation); this.dirty = true; } /** * Updates the model to world matrix of this transform and returns it * It update all the parents until no one is dirty */ updateModelToWorldMatrix() { if (!this.dirty && !this.parent?.dirty) { return this.modelToWorldMatrix; } // Update the model matrix Mat4.fromRotationTranslationScale(this.modelMatrix, this.rotation, this.position, this.scaling); // If the object has a parent, multiply its matrix with the parent's if (this.parent) { const parentMatrix = this.parent.updateModelToWorldMatrix(); Mat4.mul(this.modelToWorldMatrix, parentMatrix, this.modelMatrix); } else { Mat4.copy(this.modelToWorldMatrix, this.modelMatrix); } Mat4.getTranslation(this.#worldPosition, this.modelToWorldMatrix); Mat4.getRotation(this.#worldRotation, this.modelToWorldMatrix); Mat4.getScaling(this.#worldScale, this.modelToWorldMatrix); return this.modelToWorldMatrix; } getWorldToModelMatrix() { return Mat4.invert(this.worldToModelMatrix, this.updateModelToWorldMatrix()); } getWorldPosition() { if (this.dirty) { this.updateModelToWorldMatrix(); } return this.#worldPosition; } /** * Get the world scale of this transform */ getWorldScale() { if (this.dirty) { this.updateModelToWorldMatrix(); } return this.#worldScale; } /** * Get the world rotation of this transform */ getWorldRotation() { if (this.dirty) { this.updateModelToWorldMatrix(); } return this.#worldRotation; } getWorldForwardVector(out) { const worldRotation = this.getWorldRotation(); Vec3$1.normalize(out, Vec3$1.transformQuat(Vec3$1.create(), this.forward, worldRotation)); return out; } getVectorInModelSpace(out, vector) { this.updateModelToWorldMatrix(); Mat4.invert(this.worldToModelMatrix, this.modelToWorldMatrix); Vec3$1.transformMat4(out, vector, this.worldToModelMatrix); return out; } getVectorInWorldSpace(out, vector) { Vec3$1.transformMat4(out, vector, this.updateModelToWorldMatrix()); return out; } toWorldScale(out, scale) { Vec3$1.mul(out, scale, Vec3$1.inverse(Vec3$1.create(), this.getWorldScale())); return out; } /** * Make this transform look at the specified position * @param x * @param y * @param z */ lookAtXyz(x, y, z) { const lookAtMatrix = Mat4.create(); mat4.targetTo(lookAtMatrix, [x, y, z], this.getWorldPosition(), [0, 1, 0]); Mat4.getRotation(this.rotation, lookAtMatrix); this.dirty = true; } lookAt(position) { this.lookAtXyz(position[0], position[1], position[2]); } setWorldUnitScale() { const modelToWorldMatrix = this.updateModelToWorldMatrix(); const scale = Vec3$1.create(); // TODO: Cache this const currentScale = Vec3$1.create(); // TODO: Cache this Vec3$1.div(scale, [1, 1, 1], Mat4.getScaling(currentScale, modelToWorldMatrix)); this.scale(scale); } setWorldPosition(position) { const worldPosition = this.getWorldPosition(); Vec3$1.sub(position, position, worldPosition); Vec3$1.add(this.position, this.position, position); this.dirty = true; } /** * Set the current position * @param position Specifies the new position, will be copied using {@link Vec3.copy} */ setPosition(position) { Vec3$1.copy(this.position, position); this.dirty = true; } setForward(forward) { Vec3$1.copy(this.forward, forward); this.dirty = true; } /** * Return a new Transform with the same position, rotation, scaling, but no parent */ clone() { return new Transform(this.position, this.rotation, this.scaling); } } class MathUtils { static getEulerToDegrees(radians) { return radians * (180 / Math.PI); } /** * Convert a quaternion to euler angles. * Missing function from gl-matrix * @param quat * @param out */ static getEulerFromQuat(out, quat) { const sinRCosP = 2 * (quat[3] * quat[0] + quat[1] * quat[2]); const cosRCosP = 1 - 2 * (quat[0] * quat[0] + quat[1] * quat[1]); const roll = Math.atan2(sinRCosP, cosRCosP); const sinP = 2 * (quat[3] * quat[1] - quat[2] * quat[0]); let pitch; if (Math.abs(sinP) >= 1) pitch = (Math.sign(sinP) * Math.PI) / 2; // use 90 degrees if out of range else pitch = Math.asin(sinP); const sinYCosP = 2 * (quat[3] * quat[2] + quat[0] * quat[1]); const cosYCosP = 1 - 2 * (quat[1] * quat[1] + quat[2] * quat[2]); const yaw = Math.atan2(sinYCosP, cosYCosP); vec3.set(out, MathUtils.getEulerToDegrees(roll), MathUtils.getEulerToDegrees(pitch), MathUtils.getEulerToDegrees(yaw)); return out; } } class ThreeCameraSystem extends System { #cameraEntity; #renderer; #controls; constructor(renderer, tps) { super([ThreeCamera, Transform], tps); this.#renderer = renderer; } async onStart() { this.#cameraEntity = this.ecsManager?.createEntity(); const originEntity = this.ecsManager?.createEntity(); originEntity?.addComponent(new Transform()); this.cameraEntity?.addComponent(new ThreeCamera(new Camera(), originEntity)); this.cameraEntity?.addComponent(new Transform()); } onEntityEligible(entity, components) { const cameraComponent = entity.getComponent(ThreeCamera); const transform = entity.getComponent(Transform); if (!cameraComponent || !transform) { throw new Error("Camera or transform component not found"); } if (this.#cameraEntity) { this.#cameraEntity.removeComponent(ThreeCamera); } this.#cameraEntity = entity; if (cameraComponent.orbitControls) { this.#controls = new OrbitControls(cameraComponent.camera, this.#renderer.domElement); } const lookAtWorldPosition = cameraComponent.lookAt ?.getComponent(Transform) ?.getWorldPosition(); const worldPosition = transform.getWorldPosition(); cameraComponent.camera.position.set(worldPosition[0], worldPosition[1], worldPosition[2]); if (lookAtWorldPosition) { cameraComponent.camera.lookAt(lookAtWorldPosition[0], lookAtWorldPosition[1], lookAtWorldPosition[2]); } this.#controls?.update(); } onLoop(components, entities, deltaTime) { const cameraComponent = this.#cameraEntity?.getComponent(ThreeCamera); const camera = cameraComponent?.camera; const transform = this.#cameraEntity?.getComponent(Transform); const lookAtTransform = cameraComponent?.lookAt?.getComponent(Transform); if (!cameraComponent || !camera || !transform) { console.warn("Camera or transform not found"); return; } if (cameraComponent.orbitControls) { this.#controls?.update(); return; } const worldPosition = transform.getWorldPosition(); const worldRotation = transform.getWorldRotation(); camera.position.set(worldPosition[0], worldPosition[1], worldPosition[2]); camera.rotation.setFromQuaternion(new Quaternion(worldRotation[0], worldRotation[1], worldRotation[2], worldRotation[3])); if (lookAtTransform) { const worldPosition = lookAtTransform.getWorldPosition(); camera.position.set(worldPosition[0] + cameraComponent.lookAtOffset[0], worldPosition[1] + cameraComponent.lookAtOffset[1], worldPosition[2] + cameraComponent.lookAtOffset[2]); const lookAtWorldPosition = lookAtTransform?.getWorldPosition(); if (lookAtWorldPosition) { camera.lookAt(lookAtWorldPosition[0], lookAtWorldPosition[1], lookAtWorldPosition[2]); } } } get cameraEntity() { return this.#cameraEntity; } } class ThreeLightComponent extends Component { #light; #target; constructor(light, target, castShadow) { super(); this.#light = light; this.#target = target; if (castShadow && this.#light.shadow) { this.#light.castShadow = true; this.#light.shadow.mapSize.width = 512; this.#light.shadow.mapSize.height = 512; if (!this.#light.shadow.camera) { throw new Error("Light does not have a shadow camera"); } // @ts-ignore this.#light.shadow.camera.near = 0.5; // @ts-ignore this.#light.shadow.camera.far = 500; } } get target() { return this.#target; } get light() { return this.#light; } } class ThreeLightSystem extends System { #scene; constructor(scene, tps) { super([ThreeLightComponent, Transform], tps); this.#scene = scene; } onEntityEligible(entity, components) { const lightComponent = entity.getComponent(ThreeLightComponent); if (!lightComponent) { throw new Error("Entity is missing a ThreeLightComponent"); } this.#scene.add(lightComponent.light); if (lightComponent.target) { // @ts-ignore lightComponent.light.target = lightComponent.target.getComponent(ThreeObject3D)?.object3D; } } onEntityNoLongerEligible(entity, components) { const [threeLightComponent] = components; if (!threeLightComponent) { throw new Error("Entity is missing a ThreeLightComponent"); } this.#scene.remove(threeLightComponent.light); } onLoop(components, entities, deltaTime) { for (let i = 0; i < components.length; i++) { const [lightComponent, transform] = components[i]; const worldPosition = transform.getWorldPosition(); lightComponent.light.position.set(worldPosition[0], worldPosition[1], worldPosition[2]); } } } // @ts-ignore class ThreeCss3dComponent extends Component { #css3dObject; constructor(htmlElement, id) { super(); if (id) { htmlElement.setAttribute("id", `${htmlElement.tagName.toLowerCase()}-${id}`); } this.#css3dObject = new CSS3DObject(htmlElement); } get css3dObject() { return this.#css3dObject; } } class ThreeCss3dSystem extends System { #renderer; #scene; #threeSystem; #camera; constructor(threeSystem, tps) { super([ThreeCss3dComponent, Transform], tps, [ThreeSystem]); this.#threeSystem = threeSystem; this.#camera = new PerspectiveCamera(90, window.innerWidth / window.innerHeight, 1, 2000); this.#renderer = new CSS3DRenderer(); this.#renderer.setSize(window.innerWidth, window.innerHeight); this.#scene = new Scene(); // For some reason, the css renderer is 100x bigger than the webgl renderer so we need to scale it down this.#scene.scale.set(0.01, 0.01, 0.01); const container = document.getElementById("hud"); if (!container) { throw new Error("No element with id 'app' found"); } container.appendChild(this.#renderer.domElement); } onEntityEligible(entity, components) { const { css3dObject } = entity.getComponent(ThreeCss3dComponent); this.#scene.add(css3dObject); } onEntityNoLongerEligible(entity, components) { const [threeCss3dComponent] = components; this.#scene.remove(threeCss3dComponent.css3dObject); } onLoop(components, entities, deltaTime) { this.#camera.position.copy(this.#threeSystem.camera.position); this.#camera.quaternion.copy(this.#threeSystem.camera.quaternion); for (let i = 0; i < components.length; i++) { const [css3dComponent, transform] = components[i]; const worldPosition = transform.getWorldPosition(); // Position is multiplied by 100 because the css renderer is 100x bigger than the webgl renderer css3dComponent.css3dObject.position.x = worldPosition[0] * 100; css3dComponent.css3dObject.position.y = worldPosition[1] * 100; css3dComponent.css3dObject.position.z = worldPosition[2] * 100; css3dComponent.css3dObject.lookAt(this.#camera.position); } this.#renderer.render(this.#scene, this.#camera); } } class ThreeInstancedMesh extends ThreeObject3D { #entities; constructor(instancedMesh, id) { super(instancedMesh, id); this.#entities = []; instancedMesh.instanceMatrix.setUsage(StaticDrawUsage); } onAddedToEntity(entity) { this.#entities.push(entity.id); } onRemovedFromEntity(entity) { const index = this.getEntityIndex(entity.id); if (index !== -1) { this.#entities.splice(index, 1); } } getEntityIndex(entityId) { return this.#entities.indexOf(entityId); } get entities() { return this.#entities; } clone() { if (this.entities.length > 1024) { return new ThreeInstancedMesh(new InstancedMesh(super.object3D.geometry, super.object3D.material, this.entities.length), this.id); } return this; } } class ThreeSystem extends System { #scene; #renderer; #cameraSystem; #lightSystem; #css3dSystem; constructor(tps) { super([Transform, ThreeObject3D], tps); this.#scene = new Scene(); this.#renderer = new WebGLRenderer({ antialias: true }); this.#renderer.setPixelRatio(window.devicePixelRatio); this.#renderer.setSize(window.innerWidth, window.innerHeight); this.#renderer.setClearColor(0x323336); this.#renderer.shadowMap.enabled = true; this.#renderer.shadowMap.type = PCFSoftShadowMap; ColorManagement.enabled = true; this.#renderer.outputColorSpace = SRGBColorSpace; document.body.appendChild(this.#renderer.domElement); } onAddedToEcsManager(ecsManager) { this.#lightSystem = new ThreeLightSystem(this.#scene, this.tps); ecsManager.addSystem(this.#lightSystem); this.#cameraSystem = new ThreeCameraSystem(this.renderer, this.tps); ecsManager.addSystem(this.#cameraSystem); this.$dependencies = [ThreeCameraSystem, ThreeLightSystem]; if (document.getElementById("hud")) { this.#css3dSystem = new ThreeCss3dSystem(this, this.tps); ecsManager.addSystem(this.#css3dSystem); } } onEntityEligible(entity, components) { const threeMesh = entity.getComponent(ThreeObject3D); if (!threeMesh) { throw new Error("ThreeMesh not found on eligible entity"); } if (threeMesh instanceof ThreeInstancedMesh) { const instancedMesh = threeMesh.object3D; for (let i = 0; i < instancedMesh.count; i++) { const matrix = new Matrix4();