vertecs
Version:
A typescript entity-component-system framework
1,529 lines (1,507 loc) • 467 kB
JavaScript
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();