Skip to content
53 changes: 31 additions & 22 deletions packages/core-editor/src/common/context/event-emitter.type.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/**
* Events that the NanoForge editor can emit to the running engine.
*/
export enum EventTypeEnum {
/** Reload modules without restarting the engine (live-patch). */
HOT_RELOAD = "hot-reload",
/** Fully restart the engine with the latest changes. */
HARD_RELOAD = "hard-reload",
}

/** Callback signature for event listeners. */
export type ListenerType = (...args: any[]) => void;
export type ListenerType<
Events extends string,
EventsMap extends Record<Events, unknown[]>,
K extends keyof EventsMap,
> = (...args: EventsMap[K]) => void;

/** Signature of the waiting for execution events. */
export type QueuedEvent<EventsMap, K extends keyof EventsMap = keyof EventsMap> = {
event: K;
args: EventsMap[K];
};

/**
* Simple event emitter interface used for communication between the NanoForge
Expand All @@ -19,49 +19,58 @@ export type ListenerType = (...args: any[]) => void;
* Listeners are queued and processed via `runEvents` to keep the engine
* loop deterministic.
*/
export interface IEventEmitter {
export interface IEventEmitter<Events extends string, EventsMap extends Record<Events, unknown[]>> {
/** Map of event names to their registered listeners. */
listeners: Record<EventTypeEnum | string, ListenerType[]>;
listeners: {
[K in keyof EventsMap]?: ListenerType<Events, EventsMap, K>[];
};

/** Queue of events waiting to be dispatched by `runEvents`. */
eventQueue: { event: EventTypeEnum | string; args: any[] }[];
eventQueue: QueuedEvent<EventsMap>[];

/** Drain the event queue and invoke all matching listeners. */
runEvents: () => void;
runEvents(): void;

/**
* Enqueue an event for dispatching on the next `runEvents` call.
*
* @param event - Event name or EventTypeEnum value.
* @param args - Optional arguments forwarded to listeners.
*/
emitEvent: (event: EventTypeEnum, ...args: any) => void;
emitEvent<K extends keyof EventsMap>(event: K, ...args: EventsMap[K]): void;

/**
* Register a listener for an event. Alias: `on`.
*
* @param event - Event name to subscribe to.
* @param listener - Callback invoked when the event fires.
*/
addListener: (event: EventTypeEnum | string, listener: ListenerType) => void;
addListener<K extends keyof EventsMap>(
event: K,
listener: ListenerType<Events, EventsMap, K>,
): void;
/** Alias for `addListener`. */
on: (event: EventTypeEnum | string, listener: ListenerType) => void;
on<K extends keyof EventsMap>(event: K, listener: ListenerType<Events, EventsMap, K>): void;

/**
* Remove a previously registered listener. Alias: `off`.
*
* @param event - Event name to unsubscribe from.
* @param listener - The exact listener function to remove.
*/
removeListener: (event: EventTypeEnum | string, listener: ListenerType) => void;
removeListener<K extends keyof EventsMap>(
event: K,
listener: ListenerType<Events, EventsMap, K>,
): void;
/** Alias for `removeListener`. */
off: (event: EventTypeEnum | string, listener: ListenerType) => void;
off<K extends keyof EventsMap>(event: K, listener: ListenerType<Events, EventsMap, K>): void;

/**
* Remove all listeners registered for a specific event.
*
* @param event - Event name whose listeners should be cleared.
*/
removeListenersForEvent: (event: EventTypeEnum | string) => void;
removeListenersForEvent(event: keyof EventsMap): void;
/** Remove every registered listener across all events. */
removeAllListeners: () => void;
removeAllListeners(): void;
}
25 changes: 25 additions & 0 deletions packages/core-editor/src/common/context/events/core-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type Save } from "../save.type";

/**
* Events that the NanoForge editor can emit to the running engine.
*/
export enum CoreEvents {
/** Reload only changed entity components params (live-patch) */
HOT_RELOAD = "hot-reload",
/** Reload all entities component params */
HARD_RELOAD = "hard-reload",
/** Don't execute the run function until UNPAUSE_GAME */
PAUSE_GAME = "pause-game",
/** End main loop and clear */
STOP_GAME = "stop-game",
/** resume executing the run function */
UNPAUSE_GAME = "unpause-game",
}

export interface CoreEventsMap {
[CoreEvents.HOT_RELOAD]: [save: Save];
[CoreEvents.HARD_RELOAD]: [save: Save];
[CoreEvents.PAUSE_GAME]: [];
[CoreEvents.STOP_GAME]: [];
[CoreEvents.UNPAUSE_GAME]: [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// ! Please do not remove this event unless a new one replaces it, it causes types issues

export enum EditorEvents {
EMPTY = "empty",
}

export interface EditorEventsMap {
[EditorEvents.EMPTY]: [];
}
10 changes: 6 additions & 4 deletions packages/core-editor/src/common/context/options.type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type IEventEmitter } from "./event-emitter.type";
import { type CoreEvents, type CoreEventsMap } from "./events/core-events";
import { type EditorEvents, type EditorEventsMap } from "./events/editor-events";
import { type Save } from "./save.type";

/**
Expand All @@ -25,9 +27,9 @@ export interface IEditorRunClientOptions {
/** Serialised scene state loaded or saved by the editor. */
save: Save;
/** Event emitter for core-to-editor communication. */
coreEvents: IEventEmitter;
coreEvents: IEventEmitter<CoreEvents, CoreEventsMap>;
/** Event emitter for editor-to-core communication. */
editorEvents: IEventEmitter;
editorEvents: IEventEmitter<EditorEvents, EditorEventsMap>;
};
}

Expand All @@ -47,8 +49,8 @@ export interface IEditorRunServerOptions {
/** Serialised scene state loaded or saved by the editor. */
save: Save;
/** Event emitter for core-to-editor communication. */
coreEvents: IEventEmitter;
coreEvents: IEventEmitter<CoreEvents, CoreEventsMap>;
/** Event emitter for editor-to-core communication. */
editorEvents: IEventEmitter;
editorEvents: IEventEmitter<EditorEvents, EditorEventsMap>;
};
}
2 changes: 1 addition & 1 deletion packages/core-editor/src/common/context/save.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export interface SaveEntity {
}

/**
* Root serialised scene state exchanged between the editor and the engine.
* Root serialized scene state exchanged between the editor and the engine.
*/
export interface Save {
/** All libraries registered in the scene. */
Expand Down
11 changes: 6 additions & 5 deletions packages/core-editor/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class Core {
this._configRegistry = new ConfigRegistry(options.env);
await this.runInit(this.getInitContext(options));
this.editor = new CoreEditor(
this,
options.editor,
this.config.getComponentSystemLibrary<ECSClientLibrary>().library,
);
Expand Down Expand Up @@ -63,7 +64,7 @@ export class Core {
return;
}
const tickStart = Date.now();
await runner(tickStart - previousTick);
if (this.editor?.isPaused) await runner(tickStart - previousTick);
previousTick = tickStart;
setTimeout(render, tickLengthMs + tickStart - Date.now());
};
Expand All @@ -72,16 +73,16 @@ export class Core {
setTimeout(render);
}

public getExecutionContext(): EditableExecutionContext {
return new EditableExecutionContext(this.context, this.config.libraryManager);
}

private getInitContext(options: IEditorRunOptions): InitContext {
if (!this._configRegistry) throw new NfNotInitializedException("Core");

return new InitContext(this.context, this.config.libraryManager, this._configRegistry, options);
}

private getExecutionContext(): EditableExecutionContext {
return new EditableExecutionContext(this.context, this.config.libraryManager);
}

private getClearContext(): ClearContext {
return new ClearContext(this.context, this.config.libraryManager);
}
Expand Down
66 changes: 56 additions & 10 deletions packages/core-editor/src/editor/core-editor.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,68 @@
import { NfNotFound } from "@nanoforge-dev/common";
import { type ECSClientLibrary, type Entity } from "@nanoforge-dev/ecs-client";

import { EventTypeEnum } from "../common/context/event-emitter.type";
import { CoreEvents } from "../common/context/events/core-events";
import { type IEditorRunOptions } from "../common/context/options.type";
import { type Save } from "../common/context/save.type";
import { type Core } from "../core/core";

export class CoreEditor {
private editor: IEditorRunOptions["editor"];
private ecsLibrary: ECSClientLibrary;
constructor(editor: IEditorRunOptions["editor"], ecsLibrary: ECSClientLibrary) {
private lastLoadedSave: Save;
private core: Core;
private _isPaused: boolean = false;

constructor(core: Core, editor: IEditorRunOptions["editor"], ecsLibrary: ECSClientLibrary) {
this.editor = editor;
this.lastLoadedSave = JSON.parse(JSON.stringify(this.editor.save));
this.ecsLibrary = ecsLibrary;
this.editor.coreEvents?.addListener(
EventTypeEnum.HOT_RELOAD,
this.askEntitiesHotReload.bind(this),
);
this.editor.coreEvents?.addListener(CoreEvents.HOT_RELOAD, this.hotReloadEvent.bind(this));
this.editor.coreEvents?.addListener(CoreEvents.HARD_RELOAD, this.hardReloadEvent.bind(this));
this.editor.coreEvents?.addListener(CoreEvents.PAUSE_GAME, this.pauseGameEvent.bind(this));
this.editor.coreEvents?.addListener(CoreEvents.STOP_GAME, this.stopGameEvent.bind(this));
this.editor.coreEvents?.addListener(CoreEvents.UNPAUSE_GAME, this.unpauseGameEvent.bind(this));
Comment thread
Exeloo marked this conversation as resolved.
this.core = core;
}

get isPaused(): boolean {
return this._isPaused;
}

public runEvents() {
this.editor.coreEvents?.runEvents();
}

public askEntitiesHotReload(): void {
public hotReloadEvent(save: Save): void {
const reg = this.ecsLibrary.registry;
const save = this.editor.save;
this.lastLoadedSave = JSON.parse(JSON.stringify(save));
save.entities.forEach(({ id, components }) => {
Object.entries(components).forEach(([componentName, params]) => {
const ogComponent = save.components.find(({ name: paramName }) => {
return paramName == componentName;
const ogComponent = save.components.find(({ name }) => name === componentName);
if (!ogComponent) {
throw new NfNotFound("Component: " + componentName + " not found in saved components");
}
const ecsEntity: Entity = this.getEntityFromEntityId(id);
const ecsComponent = reg.getEntityComponent(ecsEntity, {
name: componentName,
});
Object.entries(params).forEach(([paramName, paramValue]) => {
const lastLoadedParam = this.lastLoadedSave.entities.find((e) => e.id === id)?.components[
componentName
]?.[paramName];
if (lastLoadedParam !== paramValue) ecsComponent[paramName] = paramValue;
});
reg.addComponent(ecsEntity, ecsComponent);
});
});
}

public hardReloadEvent(save: Save): void {
const reg = this.ecsLibrary.registry;
this.lastLoadedSave = JSON.parse(JSON.stringify(save));
save.entities.forEach(({ id, components }) => {
Object.entries(components).forEach(([componentName, params]) => {
const ogComponent = save.components.find(({ name }) => name === componentName);
if (!ogComponent) {
throw new NfNotFound("Component: " + componentName + " not found in saved components");
}
Expand All @@ -43,6 +78,17 @@ export class CoreEditor {
});
}

public pauseGameEvent(): void {
this._isPaused = true;
}
public unpauseGameEvent(): void {
this._isPaused = false;
}

public stopGameEvent(): void {
this.core.getExecutionContext().application.setIsRunning(false);
}

private getEntityFromEntityId(entityId: string): Entity {
const reg = this.ecsLibrary.registry;
return reg.entityFromIndex(
Expand Down
21 changes: 12 additions & 9 deletions packages/core-editor/test/editor-feature.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { type ECSClientLibrary } from "@nanoforge-dev/ecs-client";
import { afterEach, describe, expect, it, vi } from "vitest";

import { EventTypeEnum } from "../src/common/context/event-emitter.type";
import { CoreEvents } from "../src/common/context/events/core-events";
import type { IEditorRunOptions } from "../src/common/context/options.type";
import { type Save, type SaveComponent, type SaveEntity } from "../src/common/context/save.type";
import { type Core } from "../src/core/core";
import { CoreEditor } from "../src/editor/core-editor";
import { EventEmitter } from "./helpers/event-emitter";

Expand All @@ -15,13 +16,14 @@ describe("EditorFeatures", () => {
describe("eventEmitter", () => {
it("should execute eventQueue once", async () => {
const events = new EventEmitter();
events.emitEvent(EventTypeEnum.HOT_RELOAD);
events.emitEvent(EventTypeEnum.HOT_RELOAD);
events.emitEvent(CoreEvents.HOT_RELOAD);
events.emitEvent(CoreEvents.HOT_RELOAD);
const spyHotReload = vi
.spyOn(CoreEditor.prototype, "askEntitiesHotReload")
.spyOn(CoreEditor.prototype, "hotReloadEvent")
.mockImplementation(() => {});
new CoreEditor(
{ coreEvents: events } as IEditorRunOptions["editor"],
{} as unknown as Core,
{ coreEvents: events, save: { libraries: [] } } as unknown as IEditorRunOptions["editor"],
{} as ECSClientLibrary,
).runEvents();
expect(spyHotReload).toHaveBeenCalledTimes(2);
Expand Down Expand Up @@ -113,14 +115,15 @@ describe("EditorFeatures", () => {
];
const fakeReg = new FakeRegistry();
new CoreEditor(
{} as unknown as Core,
{
save: {
components,
entities,
} as any as Save,
} as any as IEditorRunOptions["editor"],
{ registry: fakeReg } as any as ECSClientLibrary,
).askEntitiesHotReload();
).hotReloadEvent({ components, entities } as any as Save);
expect(fakeReg.getComponents).toHaveBeenCalledWith({ name: "__RESERVED_ENTITY_ID" });
expect(getIndex).toHaveBeenNthCalledWith(1, {
entityId: "ent2",
Expand All @@ -137,13 +140,13 @@ describe("EditorFeatures", () => {
expect(fakeReg.getEntityComponent).toHaveBeenNthCalledWith(1, 2, { name: "Position" });
expect(fakeReg.getEntityComponent).toHaveBeenNthCalledWith(2, 2, { name: "Bullets" });
expect(fakeReg.getEntityComponent).toHaveBeenNthCalledWith(3, 3, { name: "Position" });
expect(fakeReg.addComponent).toHaveBeenNthCalledWith(1, 2, { name: "Position", x: 1, y: 2 });
expect(fakeReg.addComponent).toHaveBeenNthCalledWith(1, 2, { name: "Position", x: 3, y: 4 });
expect(fakeReg.addComponent).toHaveBeenNthCalledWith(2, 2, {
name: "Bullets",
bulletTypes: ["fire", "water", "rocket"],
number: 1000,
number: 4,
});
expect(fakeReg.addComponent).toHaveBeenNthCalledWith(3, 3, { name: "Position", x: 5, y: 6 });
expect(fakeReg.addComponent).toHaveBeenNthCalledWith(3, 3, { name: "Position", x: 7, y: 8 });
});
});
});
2 changes: 1 addition & 1 deletion packages/ecs-lib/lib/libecs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface ClassHandle {
[Symbol.dispose](): void;
clone(): this;
}
export interface container extends ClassHandle {
export interface container extends ClassHandle, Iterable<any | undefined> {
size(): number;
get(_0: number): any | undefined | undefined;
push_back(_0?: any): void;
Expand Down
2 changes: 1 addition & 1 deletion packages/ecs-lib/wasm/Registry.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ namespace nfo {
SparseArray<emscripten::val> &register_component(const Component &component)
{
std::string component_type(get_js_class_name(component));
if (component_type == "entity" || component_type == "id")
if (component_type == "entity" || component_type == "id" || component_type == UNKNOWN_COMPONENT_TYPE)
throw std::runtime_error("Component type '" + component_type + "' not supported : you can't use : id, entity, " + UNKNOWN_COMPONENT_TYPE);
if (!_components_arrays.contains(component_type))
_components_arrays.emplace(component_type, SparseArray<emscripten::val>());
Expand Down
Loading