In any game world, it's helpful to have a World class, which orchistrates the Entities, Components and Systems in the game. Sometimes coders name this the EntityManager, but I'd rather not use the OOP Manager nomenclature.
You should already have the following directory structure within your src folder that we setup last article:
src/
├── lib/
│ └─ ECS/
│ ├── index.ts
│ ├── Component.ts
│ ├── Entity.ts
│ └── System.ts
│ └── World.ts
Now that we've setup ECS base classes we can write the World class. What does a World do in the ECS pattern? Basically we're using it as the holder of all the entities and everything else associated with the loaded data.
import { AbstractEngine, Engine, Scene } from "@babylonjs/core";
import { System } from "./System";
import { Entity } from "./Entity";
import { Component } from "./Component";
const BASE_COMPONENT_DIR = "../../Components/";
export class World {
canvas: HTMLCanvasElement;
entities: Map<String, Entity>;
engine: AbstractEngine;
currentScene: Scene;
originalSceneData: any;
sceneCode: any | undefined;
isPaused: boolean = false;
worldEntity: Entity | undefined;
worldName: string = "World";
private componentCache: Map<string, { component: Component; system: any }> =
new Map();
constructor(canvasId: string) {
this.canvas = document.getElementById(canvasId) as HTMLCanvasElement;
this.engine = new Engine(this.canvas, true);
this.entities = new Map();
this.currentScene = new Scene(this.engine);
}
...
The World constructor takes the canvasId and takes it from there. In Babylon.js we need to create an Engine and give the canvas to it. Then we create a new Scene and pass it the engine.
Here we defined a couple of functions that are imported into main.ts and run first thing the browser does.
async loadSystems() {
return this.sceneCode.systems.forEach((system: System) => {
system.load && system.load();
system.loadEntities && system.loadEntities();
});
}
async start() {
await this.loadSystems();
this.engine.runRenderLoop(() => {
const scene = this.currentScene;
if (!scene || !scene.isReady()) return;
const deltaTime = this.engine.getDeltaTime();
this.updateSystems(deltaTime);
if (scene.activeCamera) scene.render();
});
window.addEventListener("resize", () => {
this.engine.resize();
});
}
In the first article of this series we setup the main.ts file. We're now ready to add the world starting to it.
import { World } from "./lib/ECS";
document.addEventListener("DOMContentLoaded", async () => {
const params = new URLSearchParams(location.search);
const gameName = params.get("game") || "FirstGame";
const level = params.get("scene") || params.get("level") || "0";
const canvasId = "renderCanvas";
const world = new World(canvasId);
await world.loadSceneData(level, gameName);
world.start();
});
We'll load scene data from the JSON files we made in A Previous Article. Why the JSON format? Because it's human-readable (mostly), and it's easier to work with by non-technical people while in development. But also because it enforces data only. Functions can't be defined in JSON, so we'll be using it as a way to keep the code and data seperate throughout the process.
Now, of course, one doesn't have to import data thought a fetch. That's just one way. This game could have a hard coded reference to the data/FirstGame/scenes/0.ts that just is the data bundled into the game code.
What we're trying to build. We don't just want the one scene. We want unlimited scenes that we can jump between to make the levels and other distinct moments of the game.
In src/lib/ECS/World.ts:
async loadSceneData(sceneName: string, gameName: string) {
const scenePath = `/GameData/${gameName}/scenes/${sceneName}.json`;
const response = await fetch(scenePath, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const sceneData = await response.json();
if (!sceneData) {
throw "Data is not in JSON Format";
return;
}
if (!sceneData.entities) {
throw "No entities property in sceneData";
return;
}
this.originalSceneData = sceneData;
this.sceneCode = await this.loadSceneCode(sceneData);
document.title = `${gameName} - ${sceneName}`;
}
- The path to a scene json file can be anywhere on the internet, so long as CORS isn't an issue. Here we're simply looking in the
public/GameData folder for the gameName and sceneName.
We only need one System class for each unique Component in the scene. The System classes process all entities for that System at the same time. So the first thing we do is iterate over the entities listed in the data file and grab all of their components. The Set data type will make sure it's entries are unique for us.
Remember, one of our design constraints is, there's always Component and System pairs of the same name.
async loadSceneCode(data: any) {
const entities: any = {};
const componentTypes = new Set<string>();
const systems: System[] = [];
const components: Map<string, Component> = new Map();
const uniqueComponents = new Set<string>(
Object.keys(data.entities).flatMap((entityName) =>
Object.keys(data.entities[entityName].components),
),
);
await this.loadUniqueComponents(uniqueComponents);
this.processEntitiesAndComponents(
data,
entities,
componentTypes,
components,
);
this.instantiateSystems(systems);
return { entities, componentTypes, systems, components };
}
This is where we load components and system code dynamically based on the data from the .json file we just fetched.
In vite this is easy enough using import().
private async loadUniqueComponents(uniqueComponents: Set<string>) {
for (let componentType of uniqueComponents) {
try {
const { component, system } =
await this.importComponentAndSystem(componentType);
if (component && system) {
this.componentCache.set(componentType, {
component,
system,
});
}
} catch (error) {
console.error(
`Failed to import component/system: ${componentType}`,
error,
);
}
}
}
async importComponentAndSystem(componentType: string): any {
try {
const module = await import(
`${BASE_COMPONENT_DIR}${componentType}.ts`
);
const component = module[`${componentType}Component`] as Component;
const system = module[`${`${componentType}System`}`] as System;
return { component, system };
} catch (error) {
console.error(
`Failed to import component/system: ${componentType}`,
error,
);
return { component: null, system: null };
}
}
The World class needs methods for adding and removing entities from the entities map. So let's add those quickly.
createEntity(name: string) {
const entity = new Entity(name);
this.entities.set(entity.name, entity);
return entity;
}
removeEntity(entity: Entity) {
this.entities.delete(entity.name);
}
Now comes the part where we loop over the data file and construct actual the entities in code. It can look a bit intemidating.
private processEntitiesAndComponents(
data: any,
entities: any,
componentTypes: Set<string>,
components: Map<string, Component>,
) {
for (const entityName in data.entities) {
const entityData = data.entities[entityName];
if (entityData) {
entities[entityName] = this.createEntity(entityName);
if (entityName == data.worldEntity)
this.worldEntity = entities[entityName];
}
for (const componentType in entityData.components) {
const componentData = entityData.components[componentType];
const cachedComponent = this.componentCache.get(componentType);
if (cachedComponent && cachedComponent.component) {
components.set(componentType, cachedComponent.component);
const c = new (cachedComponent.component as any)(componentData);
entities[entityName].addComponent(c);
componentTypes.add(componentType);
} else {
console.error(`Failed to load component: ${componentType}`);
}
}
}
}
Finally.
private instantiateSystems(systems: System[]) {
for (const systemName of this.componentCache) {
const cachedComponent = this.componentCache.get(systemName[0]);
if (cachedComponent && cachedComponent.system) {
const system = new cachedComponent.system(this, [
cachedComponent.component,
]);
systems.push(system);
} else {
console.warn(`System not found in cache for: ${systemName}`);
}
}
}
Now we need a way to update our systems. This is done inside the game loop. Remember this in the start method?
this.engine.runRenderLoop(() => {
const scene = this.currentScene;
if (!scene || !scene.isReady()) return;
const deltaTime = this.engine.getDeltaTime();
this.updateSystems(deltaTime);
if (scene.activeCamera) scene.render();
});
Earlier in loadSceneCode We returned an object full of every system object and saved it to this.sceneCode. Now we can loop through each and if it has an update method, it runs it.
updateSystems(deltaTime: number) {
if (!this.sceneCode || !this.currentScene.isReady()) return;
this.sceneCode.systems.forEach((system: System) => {
if (system.update) system.update(deltaTime);
});
}
Let's add some helper methods to the World Class to make our lives easier when grabbing entities from our world.
search(name: string[] | string): Entity | Entity[] | undefined {
if (Array.isArray(name)) {
return name
.map((n) => {
return this.entities.get(n);
})
.filter((e) => !!e);
}
return this.entities.get(name);
}
entitiesWith(componentClasses: Component[]): Entity[] {
const entities = this.entities.values();
const e = Array.from(entities).filter((entity: Entity) =>
componentClasses.every((comp) => entity.hasComponent(comp)),
);
const m = new Map();
e.forEach((entity) => m.set(entity.name, entity));
return e;
}
The World is a big place. In ECS a World class tends to add and remove Entities and Components. Another name for it might be an EntityManager but I'm not into managers too much. It's overused.