diff --git a/src/lib/ecs/system.spec.ts b/src/lib/ecs/system.spec.ts index 8677481..fe510e5 100644 --- a/src/lib/ecs/system.spec.ts +++ b/src/lib/ecs/system.spec.ts @@ -1,9 +1,10 @@ /// import { ScriptContext } from "@rbxts/services"; + import { Query } from "./query"; import { createEvent } from "./storage/event"; -import { System, SystemManager } from "./system"; +import { StorageObject, System, SystemManager } from "./system"; import { World, WorldOptions } from "./world"; function shallowEquals(a: Array, b: Array): boolean { @@ -19,20 +20,21 @@ world.flush = (): void => {}; let manager = {} as SystemManager; -/** - * Normally these would be initialized by the abstract class, but as we are - * using a mock here, we need to initialize them ourselves. - */ -function createSystem(): System { - const system = {} as System; - system.dt = 0; - system.enabled = true; - system.priority = 0; - system.storage = []; - system.onUpdate = (): void => {}; - return system; +class MockSystem extends System { + public dt = 0; + public enabled = true; + public priority = 0; + public storage: Array = []; + + public onUpdate(): void {} } +class MockSystem1 extends MockSystem {} +class MockSystem2 extends MockSystem {} +class MockSystem3 extends MockSystem {} +class MockSystem4 extends MockSystem {} +class MockSystem5 extends MockSystem {} + export = (): void => { beforeEach(() => { manager = new SystemManager(world); @@ -50,19 +52,18 @@ export = (): void => { })(); expect(system).to.be.ok(); - expect(tostring(getmetatable(system))).to.equal("ASystemWithAName"); expect(system.priority).to.equal(1000); }); it("be able to be scheduled", () => { let callCount = 0; - const system = createSystem(); + const system = new MockSystem(); system.onUpdate = (): void => { callCount += 1; }; - void manager.scheduleSystem(system); + void manager.scheduleSystem(system as System); void manager.start(); expect(callCount).to.equal(0); @@ -74,12 +75,12 @@ export = (): void => { it("be able to schedule multiple systems", () => { let callCount = 0; - const system1 = createSystem(); + const system1 = new MockSystem(); system1.onUpdate = (): void => { callCount += 1; }; - const system2 = createSystem(); + const system2 = new MockSystem1(); system2.onUpdate = (): void => { callCount += 1; }; @@ -94,7 +95,7 @@ export = (): void => { }); it("be able to configure queries", () => { - const system = createSystem(); + const system = new MockSystem(); let query; system.configureQueries = (world: World): void => { query = new Query(world).mask; @@ -110,26 +111,26 @@ export = (): void => { it("call systems in correct priority order", () => { const systemOrder: Array = []; - const system1 = createSystem(); + const system1 = new MockSystem(); system1.priority = 1; system1.onUpdate = (): void => { systemOrder.push(4); }; - const system2 = createSystem(); + const system2 = new MockSystem1(); system2.priority = 1000; system2.onUpdate = (): void => { systemOrder.push(1); }; - const system3 = createSystem(); + const system3 = new MockSystem2(); system3.priority = 25; system3.onUpdate = (): void => { systemOrder.push(3); }; - const system4 = createSystem(); + const system4 = new MockSystem3(); system4.priority = 100; system4.onUpdate = (): void => { systemOrder.push(2); @@ -149,7 +150,7 @@ export = (): void => { let callCount = 0; - const system = createSystem(); + const system = new MockSystem(); system.executionGroup = tempBindableEvent.Event; system.onUpdate = (): void => { callCount += 1; @@ -169,12 +170,12 @@ export = (): void => { let callCount = 0; - const system1 = createSystem(); + const system1 = new MockSystem(); system1.onUpdate = (): void => { callCount += 1; }; - const system2 = createSystem(); + const system2 = new MockSystem1(); system2.onUpdate = (): void => { callCount += 10; }; @@ -203,14 +204,14 @@ export = (): void => { const event = tempBindableEvent.Event; - const system1 = createSystem(); + const system1 = new MockSystem(); system1.priority = 1; system1.executionGroup = event; system1.onUpdate = (): void => { systemOrder.push(3); }; - const system2 = createSystem(); + const system2 = new MockSystem1(); system2.priority = 100; system2.executionGroup = event; @@ -218,26 +219,26 @@ export = (): void => { systemOrder.push(1); }; - const system3 = createSystem(); + const system3 = new MockSystem2(); system3.priority = 5; system3.executionGroup = event; system3.onUpdate = (): void => { systemOrder.push(2); }; - const system4 = createSystem(); + const system4 = new MockSystem3(); system4.priority = 1; system4.onUpdate = (): void => { systemOrder.push(6); }; - const system5 = createSystem(); + const system5 = new MockSystem4(); system5.priority = 5; system5.onUpdate = (): void => { systemOrder.push(5); }; - const system6 = createSystem(); + const system6 = new MockSystem5(); system6.priority = 100; system6.onUpdate = (): void => { systemOrder.push(4); @@ -259,20 +260,20 @@ export = (): void => { it("be able to call systems in order", () => { const systemOrder: Array = []; - const system1 = createSystem(); + const system1 = new MockSystem(); system1.onUpdate = (): void => { systemOrder.push(3); }; - const system2 = createSystem(); - system2.after = [system1]; + const system2 = new MockSystem1(); + system2.after = [MockSystem]; system2.onUpdate = (): void => { systemOrder.push(2); }; - const system3 = createSystem(); - system3.after = [system2]; + const system3 = new MockSystem2(); + system3.after = [MockSystem1]; system3.onUpdate = (): void => { systemOrder.push(1); }; @@ -288,19 +289,19 @@ export = (): void => { const systemOrder1: Array = []; - const system4 = createSystem(); + const system4 = new MockSystem3(); system4.onUpdate = (): void => { systemOrder1.push(3); }; - const system5 = createSystem(); - system5.after = [system4]; + const system5 = new MockSystem4(); + system5.after = [MockSystem3]; system5.onUpdate = (): void => { systemOrder1.push(2); }; - const system6 = createSystem(); - system6.after = [system4, system5]; + const system6 = new MockSystem5(); + system6.after = [MockSystem3, MockSystem4]; system6.onUpdate = (): void => { systemOrder1.push(1); }; @@ -321,14 +322,14 @@ export = (): void => { const event = tempBindableEvent.Event; - const system1 = createSystem(); + const system1 = new MockSystem(); system1.priority = 1; system1.onUpdate = (): void => {}; - const system2 = createSystem(); + const system2 = new MockSystem1(); system2.executionGroup = event; system2.priority = 100; - system2.after = [system1]; + system2.after = [MockSystem]; system2.onUpdate = (): void => {}; let errored = false; @@ -348,7 +349,7 @@ export = (): void => { it("be able to disable and enable a system", () => { let callCount = 0; - const system = createSystem(); + const system = new MockSystem(); system.onUpdate = (): void => { callCount += 1; }; @@ -361,11 +362,11 @@ export = (): void => { bindableEvent.Fire(); expect(callCount).to.equal(1); - manager.disableSystem(system); + manager.disableSystem(MockSystem); bindableEvent.Fire(); expect(callCount).to.equal(1); - manager.enableSystem(system); + manager.enableSystem(MockSystem); bindableEvent.Fire(); expect(callCount).to.equal(2); }); @@ -373,7 +374,7 @@ export = (): void => { it("be able to unschedule a system", () => { let callCount = 0; - const system = createSystem(); + const system = new MockSystem(); system.onUpdate = (): void => { callCount += 1; }; @@ -394,7 +395,7 @@ export = (): void => { it("not allow yielding in a system", () => { let callCount = 0; - const system = createSystem(); + const system = new MockSystem(); system.onUpdate = (): void => { callCount += 1; task.wait(); @@ -412,12 +413,12 @@ export = (): void => { it("should not be called more than once if a system is scheduled later", () => { let callCount = 0; - const system = createSystem(); + const system = new MockSystem(); system.onUpdate = (): void => { callCount += 1; }; - const system2 = createSystem(); + const system2 = new MockSystem1(); system2.onUpdate = (): void => { callCount += 1; }; @@ -435,7 +436,7 @@ export = (): void => { const tempBindableEvent = new Instance("BindableEvent"); const event = createEvent(tempBindableEvent.Event); - const system = createSystem(); + const system = new MockSystem(); system.storage.push(event); let callCount = 0; @@ -460,14 +461,14 @@ export = (): void => { expect(callCount).to.equal(1); - manager.disableSystem(system); + manager.disableSystem(MockSystem); tempBindableEvent.Fire(); bindableEvent.Fire(); expect(callCount).to.equal(1); - manager.enableSystem(system); + manager.enableSystem(MockSystem); tempBindableEvent.Fire(); bindableEvent.Fire(); @@ -485,13 +486,13 @@ export = (): void => { }); it("should not send multiple duplicate errors to the console", () => { - const system = createSystem(); + const system = new MockSystem(); system.onUpdate = (): void => { - throw ("test"); + throw "test"; }; - manager.scheduleSystem(system); - manager.start(); + void manager.scheduleSystem(system); + void manager.start(); let errorCount = 0; diff --git a/src/lib/ecs/system.ts b/src/lib/ecs/system.ts index eb4229a..622c150 100644 --- a/src/lib/ecs/system.ts +++ b/src/lib/ecs/system.ts @@ -11,6 +11,9 @@ export type StorageObject = { setup: () => void; }; +export type SystemConstructor = new () => System; +export type SystemName = string; + interface SystemInternal extends System { /** * Storage for errors that have occurred recently in the system. This is @@ -73,7 +76,7 @@ export interface System { */ export abstract class System { /** An optional set of systems that must be executed before this system */ - public after?: Array; + public after?: Array; /** The time since the system was last called. */ public dt = 0; /** @@ -142,9 +145,10 @@ export class SystemManager { private executionDefault: ExecutionGroup; private executionGroupSignals: Map = new Map(); private executionGroups: Set = new Set(); + // private systemArgs?: Array; + private nameToSystem: Map = new Map(); /** Whether or not the system manager has began execution. */ private started = false; - // private systemArgs?: Array; private systems: Array = []; private systemsByExecutionGroup: Map> = new Map(); private world: World; @@ -163,7 +167,8 @@ export class SystemManager { * * @param system The system that should be disabled. */ - public disableSystem(system: System): void { + public disableSystem(ctor: SystemConstructor): void { + const system = this.getSystem(ctor); system.enabled = false; for (const storage of system.storage) { @@ -181,7 +186,8 @@ export class SystemManager { * * @param system The system that should be enabled. */ - public enableSystem(system: System): void { + public enableSystem(ctor: SystemConstructor): void { + const system = this.getSystem(ctor); system.enabled = true; for (const storage of system.storage) { @@ -193,6 +199,49 @@ export class SystemManager { // do we include dev-only logging for things like this? } + /** + * Replaces a system with a new system. This is useful for hot-reloading. + * This will not work in non-studio environments. Storage will not persist + * between the old and new system, and instead will be cleaned up and set + * up again. + * + * @param oldSystem The system to replace + * @param newSystem The system to replace it with + */ + public replaceSystem(oldSystem: System, newSystem: System): void { + assert(RunService.IsStudio(), "replaceSystem can only be called in Studio"); + + const index = this.systems.indexOf(oldSystem); + if (index === -1) { + throw `System ${tostring(getmetatable(oldSystem))} not found`; + } + + for (const storage of oldSystem.storage) { + storage.cleanup(); + } + + this.nameToSystem.set(tostring(getmetatable(oldSystem)), newSystem); + this.systems[index] = newSystem; + + newSystem.configureQueries(this.world); + this.setupSystemStorage(newSystem); + + const executionIndex = this.systemsByExecutionGroup + .get(oldSystem.executionGroup ?? this.executionDefault) + ?.indexOf(oldSystem); + if (executionIndex === undefined) { + throw `System ${tostring( + getmetatable(oldSystem), + )} not found in execution group ${tostring(oldSystem.executionGroup)}`; + } + + this.systemsByExecutionGroup + .get(oldSystem.executionGroup ?? this.executionDefault) + ?.remove(executionIndex); + + this.sortSystems(this.systems); + } + /** * Schedules an individual system. * @@ -234,6 +283,12 @@ export class SystemManager { } this.systems.push(system); + + if (this.nameToSystem.has(tostring(getmetatable(system)))) { + throw `System ${tostring(getmetatable(system))} has already been scheduled!`; + } + + this.nameToSystem.set(tostring(getmetatable(system)), system); } this.sortSystems(systems); @@ -346,6 +401,7 @@ export class SystemManager { public unscheduleSystems(systems: Array): void { for (const system of systems) { this.systems.remove(this.systems.indexOf(system)); + this.nameToSystem.delete(tostring(getmetatable(system))); const systemInExecutionGroup = this.systemsByExecutionGroup.get( system.executionGroup ?? this.executionDefault, @@ -429,6 +485,22 @@ export class SystemManager { } } + /** + * Given a system constructor, returns the system instance if it exists. + * @param ctor The system constructor. + * @returns The system instance, or undefined if it doesn't exist. + */ + private getSystem(ctor: SystemConstructor): System { + const systemName = tostring(ctor); + print(systemName); + const systemInstance = this.nameToSystem.get(systemName); + if (!systemInstance) { + throw `System ${systemName} does not exist!`; + } + + return systemInstance; + } + /** * Initializes a given system. * @@ -459,8 +531,11 @@ export class SystemManager { this.validateSystems(unscheduledSystems); unscheduledSystems.sort((a, b) => { - if (a.after !== undefined && a.after.includes(b)) { - if (b.after !== undefined && b.after.includes(a)) { + if (a.after !== undefined && a.after.includes(getmetatable(b) as SystemConstructor)) { + if ( + b.after !== undefined && + b.after.includes(getmetatable(a) as SystemConstructor) + ) { throw error( `Systems ${tostring(getmetatable(a))} and ${tostring( getmetatable(b), @@ -470,7 +545,7 @@ export class SystemManager { return false; } - if (b.after !== undefined && b.after.includes(a)) { + if (b.after !== undefined && b.after.includes(getmetatable(a) as SystemConstructor)) { return true; } @@ -587,9 +662,10 @@ export class SystemManager { } for (const afterSystem of system.after) { - if (system.executionGroup !== afterSystem.executionGroup) { + const afterSystemInstance = this.getSystem(afterSystem); + if (system.executionGroup !== afterSystemInstance.executionGroup) { const msg = `System ${tostring(getmetatable(system))} and ${tostring( - getmetatable(afterSystem), + afterSystem, )} are in different execution groups`; throw msg; } diff --git a/src/lib/ecs/world.ts b/src/lib/ecs/world.ts index 5ef54aa..a34973f 100644 --- a/src/lib/ecs/world.ts +++ b/src/lib/ecs/world.ts @@ -21,7 +21,7 @@ import { Observer } from "./observer"; // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { ANY, NOT } from "./query"; import { ALL, Query, RawQuery } from "./query"; -import { ExecutionGroup, System, SystemManager } from "./system"; +import { ExecutionGroup, System, SystemConstructor, SystemManager } from "./system"; export interface WorldOptions { /** @@ -237,12 +237,12 @@ export class World { * this can be used for systems that are expected to be reenabled at a * later point. * - * @param system The system that should be disabled. + * @param ctor The constructor of the system that should be enabled. * * @returns The world instance to allow for method chaining. */ - public disableSystem(system: System): this { - this.scheduler.disableSystem(system); + public disableSystem(ctor: SystemConstructor): this { + this.scheduler.disableSystem(ctor); return this; } @@ -253,12 +253,12 @@ export class World { * This will not error if a system that is already enabled is enabled * again. * - * @param system The system that should be enabled. + * @param ctor The constructor of the system that should be enabled. * * @returns The world instance to allow for method chaining. */ - public enableSystem(system: System): this { - this.scheduler.enableSystem(system); + public enableSystem(ctor: SystemConstructor): this { + this.scheduler.enableSystem(ctor); return this; } @@ -469,6 +469,19 @@ export class World { return this.removeComponent(entityId, tag as unknown as AnyComponent); } + /** + * Replaces a system with a new system. This is useful for hot-reloading. + * This will not work in non-studio environments. Storage will not persist + * between the old and new system, and instead will be cleaned up and set + * up again. + * + * @param oldSystem The system to replace + * @param newSystem The system to replace it with + */ + public replaceSystem(oldSystem: System, newSystem: System): void { + this.scheduler.replaceSystem(oldSystem, newSystem); + } + /** * Schedules an individual system to be executed in the world. *