feat: "scene" (i.e. world) management

- Add tests to ensure predictable start/stop world behavior.
- Update README to include example usages.
This commit is contained in:
nvms 2025-03-27 19:13:05 -04:00
parent 6e063101cc
commit 48d1205505
3 changed files with 170 additions and 0 deletions

View File

@ -11,6 +11,7 @@ An ECS framework (and robust input system) for the web.
* [Entities](#entities) * [Entities](#entities)
* [Components](#components) * [Components](#components)
* [Extending components](#extending-components) * [Extending components](#extending-components)
* [Scene Management](#scene-management)
* [Extras](#extras) * [Extras](#extras)
* [Keyboard, mouse and gamepad input](#keyboard-mouse-and-gamepad-input) * [Keyboard, mouse and gamepad input](#keyboard-mouse-and-gamepad-input)
* [Input system](#input-system) * [Input system](#input-system)
@ -485,6 +486,37 @@ mortals((results) => {
stop(); stop();
``` ```
### Scene Management
NGN doesn't enforce any specific scene management pattern, giving you the freedom to implement what works best for your game. The simplest approach is to use separate worlds as scenes:
```typescript
// Create different worlds for different scenes
const menuScene = createWorld();
const gameScene = createWorld();
const pauseScene = createWorld();
// Configure each scene
menuScene.defineMain(() => { /* menu logic */ });
gameScene.defineMain(() => { /* game logic */ });
// Track current scene
let currentScene = menuScene;
// Switch scenes
function switchToScene(newScene) {
currentScene.stop();
currentScene = newScene;
currentScene.start();
}
// Start with menu
menuScene.start();
// Later, switch to game
switchToScene(gameScene);
```
- **`World > step`** - **`World > step`**
Calls all systems once. Passes the `WorldState` to each system. You should do this in your main program loop, e.g.: Calls all systems once. Passes the `WorldState` to each system. You should do this in your main program loop, e.g.:

View File

@ -2,4 +2,5 @@ import { describe } from "manten";
await describe("ngn", async ({ runTestSuite }) => { await describe("ngn", async ({ runTestSuite }) => {
runTestSuite(import("./ngn")); runTestSuite(import("./ngn"));
runTestSuite(import("./ngn/scenes.test"));
}); });

View File

@ -0,0 +1,137 @@
import { expect, test, testSuite } from "manten";
import { createWorld, WorldState } from "../../ngn";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default testSuite(async () => {
test("can switch between worlds (scenes)", async () => {
// Create two separate worlds (scenes)
const sceneA = createWorld();
const sceneB = createWorld();
// Track execution counts for each scene
let sceneAExecutions = 0;
let sceneBExecutions = 0;
// Set up main loops for each scene
sceneA.defineMain((state: WorldState) => {
sceneAExecutions++;
if (sceneAExecutions >= 3) sceneA.stop();
});
sceneB.defineMain((state: WorldState) => {
sceneBExecutions++;
if (sceneBExecutions >= 3) sceneB.stop();
});
// Start sceneA and let it run for a bit
sceneA.start();
await sleep(500);
// Verify sceneA ran and sceneB didn't
expect(sceneAExecutions).toBe(3);
expect(sceneBExecutions).toBe(0);
// Start sceneB and let it run
sceneB.start();
await sleep(500);
// Verify sceneB ran
expect(sceneBExecutions).toBe(3);
});
test("worlds maintain separate entity collections", () => {
const sceneA = createWorld();
const sceneB = createWorld();
// Create entities in each scene
const entityA = sceneA.createEntity({ name: "EntityA" });
const entityB = sceneB.createEntity({ name: "EntityB" });
// Verify entities exist in their respective scenes
expect(sceneA.getEntity(entityA.id)).toBeDefined();
expect(sceneA.getEntity(entityB.id)).toBeUndefined();
expect(sceneB.getEntity(entityB.id)).toBeDefined();
expect(sceneB.getEntity(entityA.id)).toBeUndefined();
});
test("worlds maintain separate system collections", () => {
const sceneA = createWorld();
const sceneB = createWorld();
// Track system executions
let systemAExecutions = 0;
let systemBExecutions = 0;
// Create systems for each scene
const systemA = () => {
systemAExecutions++;
};
const systemB = () => {
systemBExecutions++;
};
// Add systems to their respective scenes
sceneA.addSystem(systemA);
sceneB.addSystem(systemB);
// Step each scene
sceneA.step();
sceneB.step();
// Verify systems ran in their respective scenes
expect(systemAExecutions).toBe(1);
expect(systemBExecutions).toBe(1);
// Remove system from sceneA
sceneA.removeSystem(systemA);
// Step each scene again
sceneA.step();
sceneB.step();
// Verify systemA didn't run but systemB did
expect(systemAExecutions).toBe(1);
expect(systemBExecutions).toBe(2);
});
test("worlds maintain separate time tracking", async () => {
const sceneA = createWorld();
const sceneB = createWorld();
// Set different time scales
sceneA.state.time.scale = 0.5;
sceneB.state.time.scale = 2.0;
let sceneATime = 0;
let sceneBTime = 0;
// Set up main loops to capture time values
sceneA.defineMain((state: WorldState) => {
sceneATime = state.time.delta;
sceneA.stop();
});
sceneB.defineMain((state: WorldState) => {
sceneBTime = state.time.delta;
sceneB.stop();
});
// Run both scenes
sceneA.start();
await sleep(200);
sceneB.start();
await sleep(200);
// Verify time scales were applied correctly
expect(sceneATime).toBeGreaterThan(0);
expect(sceneBTime).toBeGreaterThan(0);
expect(sceneBTime).toBeGreaterThan(sceneATime);
// Verify the ratio is approximately 4:1 (2.0 vs 0.5)
const ratio = sceneBTime / sceneATime;
expect(ratio).toBeGreaterThan(3); // Allow some flexibility in timing
});
});