Dynamically Loading Systems in Your Babylon.js Game
Let's talk about dynamically loading systems in your Babylon.js games. This approach offers a ton of benefits, making your game code more modular, scalable, and flexible.
My Approach: Entity Component System (ECS) & Scene Management
We'll be building a SceneManager
class that handles everything related to loading, switching, and unloading game scenes. The key to our system is an Entity Component System (ECS) architecture. This pattern separates your game data and logic into distinct components. Think of it like this:
- Entities: These are the core objects in your game, like characters, items, or enemies.
- Components: They represent the properties and behaviors of your entities, such as position, health, or movement abilities.
- Systems: These are the logic that acts on entities based on their components. For instance, a "MovementSystem" could update the position of entities with a "PositionComponent."
The Scene Manager
Let's start by defining our SceneManager
class. This class will be responsible for managing our scenes, loading their data, and handling transitions between them. You can think of a Scene as a single .json file for a level in a game, so switching scenes would happen at the end of a level.
import { Scene } from "@babylonjs/core";
import { EntityManager } from "./Entity";
export class SceneManager {
public currentScene: Scene; // The currently active scene.
entityManager: EntityManager; // Our entity manager, responsible for entities and components.
scenes: Map<Scene>; // A map to store loaded scenes for easy access.
sceneCode = { systems: [], componentTypes: [], entities: [] }; // Data about the loaded scene: its components, systems, and entities.
constructor(entityManager: EntityManager) {
this.scenes = {};
this.entityManager = entityManager;
this.currentScene = entityManager.scene;
return this;
}
async loadSceneData(
sceneName: string,
gameName: string,
onSceneLoaded = null,
) {
// ... (Implementation follows)
}
switchToScene(sceneName) {
// ... (Implementation follows)
}
unloadScene(sceneName: string) {
// ... (Implementation follows)
}
async loadSceneCode(entityManager: EntityManager, data: any) {
// ... (Implementation follows)
}
async importComponent(componentType) {
// ... (Implementation follows)
}
updateSystems(deltaTime) {
// ... (Implementation follows)
}
deriveRequiredSystems(componentTypes) {
// ... (Implementation follows)
}
}
Loading Scene Data
The loadSceneData
function fetches the scene's data from a JSON file. This data includes information about the entities in the scene, their components, and the systems that manage them.
async loadSceneData(
sceneName: string,
gameName: string,
onSceneLoaded = null,
) {
// If the scene is already loaded, just switch to it.
if (this.scenes[sceneName]) {
this.switchToScene(sceneName);
return;
}
const response = await fetch(
`/gamedata/${gameName}/scenes/${sceneName}.json`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
); // Replace "" with path to scenes
const sceneData = await response.json();
// Load scene resources (e.g., meshes, textures, sounds)
if (sceneData)
this.sceneCode = await this.loadSceneCode(this.entityManager, sceneData);
this.scenes[sceneName] = this.currentScene;
// Execute the scene's ready logic.
this.currentScene.executeWhenReady(() => {
if (onSceneLoaded) {
onSceneLoaded(scene);
}
});
}
Switching Scenes
The switchToScene
function handles the transition from one scene to another. It cleans up the current scene, detaching controls and resources, and then switches to the new scene, attaching the necessary resources and controls.
switchToScene(sceneName) {
// Make sure the scene is actually loaded.
if (!this.scenes[sceneName]) {
console.error(`Scene '${sceneName}' not loaded yet.`);
return;
}
// Clean up the previous scene.
if (this.currentScene) {
this.currentScene.detachControl();
// Cleanup other resources as needed
}
// Switch to the new scene.
this.currentScene = this.scenes[sceneName];
// Attach input and other resources to the new scene.
this.currentScene.attachControl(this.engine.getRenderingCanvas());
// Handle other resource attachments as needed (e.g., cameras)
// Implement transition logic here (e.g., fade effects)
return this.currentScene;
}
Unloading Scenes
When we're done with a scene, we can unload it from memory using the unloadScene
function. This helps to free up resources and keep our game running smoothly.
unloadScene(sceneName: string) {
// Make sure the scene exists.
if (!this.scenes[sceneName]) {
return;
}
// Detach resources before disposal.
this.scenes[sceneName].detachControl();
// Dispose of other resources as needed
this.scenes[sceneName].dispose();
delete this.scenes[sceneName];
}
Processing Scene Code
The loadSceneCode
function takes the raw scene data, processes it, and dynamically imports and instantiates the necessary components and systems.
async loadSceneCode(entityManager: EntityManager, data: any) {
// Process entities and components
const entities: any = {};
const componentTypes = new Set();
const systems = [];
for (const entityName in data.entities) {
const entityData = data.entities[entityName];
if (entityData)
entities[entityName] = entityManager.createEntity(entityName);
for (const componentType in entityData.components) {
const componentData = entityData.components[componentType];
// Dynamically import and create component
const componentClass = await this.importComponent(componentType);
if (componentClass) {
entities[entityName].addComponent(new componentClass(componentData));
componentTypes.add(componentType);
} else {
console.error(`Failed to load component: ${componentType}`);
}
}
}
// Derive required systems based on component types
const requiredSystems = this.deriveRequiredSystems(componentTypes);
// Import and create required systems
for (const systemName of requiredSystems) {
const systemModule = await import(`../systems/${systemName}System.ts`);
const systemClass = systemModule[systemName + "System"];
const system = new systemClass(entityManager);
systems.push(system);
}
return { entities, componentTypes, systems }; // Return processed scene data
}
Importing Components
The importComponent
function handles the dynamic import of component classes based on their names.
async importComponent(componentType) {
try {
const module = await import(`../components/${componentType}Component.ts`);
return module[componentType + `Component`]; // Assuming components are exported as default
} catch (error) {
console.error(`Failed to import component: ${componentType}`, error);
return null;
}
}
Deriving Required Systems
Once we have loaded the components for our scene, we need to figure out which systems are required to manage those components. The deriveRequiredSystems
function takes care of this.
deriveRequiredSystems(componentTypes) {
const requiredSystems = new Set();
for (const componentType of componentTypes) {
const systemName = componentType.replace("Component", "System");
requiredSystems.add(systemName);
}
return Array.from(requiredSystems);
}
Updating Systems
Finally, the updateSystems
function iterates through all loaded systems and calls their update
methods, allowing them to perform their logic on the entities in the scene.
updateSystems(deltaTime) {
if (!this.sceneCode.systems) return;
this.sceneCode.systems.forEach((system) => {
if (system.update) system.update(deltaTime);
});
}
In Sum
Now, you have a system that can handle scene loading, switching, and unloading, and dynamically load the necessary components and systems for each scene. Now what happens when you create a new scene file, say, scene2.json
and change the data. We have a new game, from the same code. And that's what's next.