Skip to content

Commit 8787a0c

Browse files
authored
Merge pull request #173 from JollyPixel/minimal-scene-implementation
Minimal scene implementation
2 parents 4f65d0d + 0d47b70 commit 8787a0c

File tree

11 files changed

+1228
-27
lines changed

11 files changed

+1228
-27
lines changed

.changeset/yellow-camels-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@jolly-pixel/engine": minor
3+
---
4+
5+
Refactor SceneManager and implement minimal scene management

.claude/settings.local.json

Lines changed: 0 additions & 20 deletions
This file was deleted.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,5 @@ dist
135135
.yarn/build-state.yml
136136
.yarn/install-state.gz
137137
.pnp.*
138+
139+
.claude
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* eslint-disable no-empty-function */
2+
// Import Third-party Dependencies
3+
import { EventEmitter } from "@posva/event-emitter";
4+
5+
// Import Internal Dependencies
6+
import type { World, WorldDefaultContext } from "./World.ts";
7+
import { IntegerIncrement } from "./generators/IntegerIncrement.ts";
8+
9+
export type SceneLifecycleEvents = {
10+
awake: [];
11+
start: [];
12+
destroy: [];
13+
};
14+
15+
export abstract class Scene<
16+
TContext = WorldDefaultContext
17+
> extends EventEmitter<SceneLifecycleEvents> {
18+
static readonly Id = new IntegerIncrement();
19+
20+
readonly id: number;
21+
readonly name: string;
22+
23+
/** Set by SceneManager when the scene is activated. */
24+
world!: World<any, TContext>;
25+
26+
constructor(name: string) {
27+
super();
28+
this.id = Scene.Id.incr();
29+
this.name = name;
30+
}
31+
32+
/**
33+
* Called once when the scene is first activated (before the first start/update).
34+
* Populate actors here.
35+
*/
36+
awake(): void {}
37+
38+
/**
39+
* Called once at the beginning of the first frame after awake.
40+
* Useful for cross-actor initialization that requires all awake() calls to have run.
41+
*/
42+
start(): void {}
43+
44+
/**
45+
* Called every frame (variable rate).
46+
*/
47+
update(_deltaTime: number): void {}
48+
49+
/**
50+
* Called every fixed step (deterministic rate).
51+
*/
52+
fixedUpdate(_deltaTime: number): void {}
53+
54+
/**
55+
* Called when the scene is being replaced or explicitly unloaded.
56+
* Clean up timers, subscriptions, etc. Actor destruction is handled
57+
* automatically by SceneManager.
58+
*/
59+
destroy(): void {}
60+
}

packages/engine/src/systems/SceneManager.ts

Lines changed: 204 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,30 @@ import {
88
ActorComponent,
99
ActorTree
1010
} from "../actor/index.ts";
11-
import type { WorldDefaultContext } from "./World.ts";
11+
import type { World, WorldDefaultContext } from "./World.ts";
1212
import type { Component } from "../components/types.ts";
13+
import type { Scene } from "./Scene.ts";
14+
15+
export type AppendedSceneEntry<TContext> = {
16+
scene: Scene<TContext>;
17+
/**
18+
* All actors created during the scene's awake(),
19+
* tracked for cleanup on removeScene.
20+
**/
21+
ownedActors: ReadonlySet<Actor<TContext>>;
22+
};
1323

14-
export type SceneEvents = {
24+
export type SceneEvents<TContext = WorldDefaultContext> = {
1525
awake: [];
26+
sceneChanged: [scene: Scene<TContext>];
27+
sceneDestroyed: [scene: Scene<TContext>];
28+
sceneAppended: [scene: Scene<TContext>];
29+
sceneRemoved: [scene: Scene<TContext>];
1630
};
1731

1832
export class SceneManager<
1933
TContext = WorldDefaultContext
20-
> extends EventEmitter<SceneEvents> {
34+
> extends EventEmitter<SceneEvents<TContext>> {
2135
default: THREE.Scene;
2236

2337
componentsToBeStarted: Component[] = [];
@@ -27,6 +41,14 @@ export class SceneManager<
2741
#actorsByName: Map<string, Actor<TContext>[]> = new Map();
2842
#cachedActors: Actor<TContext>[] = [];
2943

44+
#currentScene: Scene<TContext> | null = null;
45+
#pendingScene: Scene<TContext> | null = null;
46+
#sceneStartPending = false;
47+
#world: World<any, TContext> | null = null;
48+
49+
#appendedScenes: Map<number, AppendedSceneEntry<TContext>> = new Map();
50+
#appendedScenesPendingStart: Set<number> = new Set();
51+
3052
readonly tree = new ActorTree<TContext>({
3153
addCallback: (actor) => this.default.add(actor.object3D),
3254
removeCallback: (actor) => this.default.remove(actor.object3D)
@@ -39,10 +61,24 @@ export class SceneManager<
3961
this.default = scene ?? new THREE.Scene();
4062
}
4163

64+
get currentScene(): Scene<TContext> | null {
65+
return this.#currentScene;
66+
}
67+
68+
get hasPendingScene(): boolean {
69+
return this.#pendingScene !== null;
70+
}
71+
4272
getSource() {
4373
return this.default;
4474
}
4575

76+
bindWorld(
77+
world: World<any, TContext>
78+
): void {
79+
this.#world = world;
80+
}
81+
4682
awake() {
4783
for (const { actor } of this.tree.walk()) {
4884
if (!actor.awoken) {
@@ -53,7 +89,158 @@ export class SceneManager<
5389
this.emit("awake");
5490
}
5591

92+
setScene(
93+
scene: Scene<TContext>
94+
): void {
95+
if (this.#currentScene !== null) {
96+
for (const entry of this.#appendedScenes.values()) {
97+
this.emit("sceneRemoved", entry.scene);
98+
entry.scene.destroy();
99+
}
100+
this.#appendedScenes.clear();
101+
this.#appendedScenesPendingStart.clear();
102+
103+
this.emit("sceneDestroyed", this.#currentScene);
104+
this.#currentScene.destroy();
105+
106+
const allActors = Array.from(this.#registeredActors);
107+
for (const actor of allActors) {
108+
this.destroyActor(actor);
109+
}
110+
111+
this.componentsToBeStarted.length = 0;
112+
this.componentsToBeDestroyed.length = 0;
113+
114+
this.default.clear();
115+
this.#registeredActors.clear();
116+
this.#actorsByName.clear();
117+
}
118+
119+
scene.world = this.#world!;
120+
this.#currentScene = scene;
121+
122+
scene.awake();
123+
this.awake();
124+
125+
this.#sceneStartPending = true;
126+
this.emit("sceneChanged", scene);
127+
}
128+
129+
loadScene(
130+
scene: Scene<TContext>
131+
): void {
132+
this.#pendingScene = scene;
133+
}
134+
135+
/**
136+
* Inserts a scene as a prefab into the current scene.
137+
* The scene's awake() is called immediately; start() is deferred to the next beginFrame.
138+
* All actors created during awake() are tracked and will be destroyed on removeScene().
139+
*/
140+
appendScene(
141+
scene: Scene<TContext>
142+
): void {
143+
const snapshot = new Set(this.#registeredActors);
144+
145+
scene.world = this.#world!;
146+
scene.awake();
147+
148+
const ownedActors = new Set<Actor<TContext>>();
149+
for (const actor of this.#registeredActors) {
150+
if (!snapshot.has(actor)) {
151+
ownedActors.add(actor);
152+
}
153+
}
154+
155+
// Awaken any actors created during awake() that haven't been woken yet
156+
this.awake();
157+
158+
this.#appendedScenes.set(scene.id, { scene, ownedActors });
159+
this.#appendedScenesPendingStart.add(scene.id);
160+
161+
this.emit("sceneAppended", scene);
162+
}
163+
164+
removeScene(scene: Scene<TContext>): void;
165+
removeScene(name: string): void;
166+
removeScene(
167+
target: Scene<TContext> | string
168+
): void {
169+
if (typeof target === "string") {
170+
for (const [id, entry] of this.#appendedScenes) {
171+
if (entry.scene.name === target) {
172+
this.#teardownAppendedScene(id, entry);
173+
}
174+
}
175+
}
176+
else {
177+
const entry = this.#appendedScenes.get(target.id);
178+
if (entry !== undefined) {
179+
this.#teardownAppendedScene(target.id, entry);
180+
}
181+
}
182+
}
183+
184+
#teardownAppendedScene(
185+
id: number,
186+
entry: AppendedSceneEntry<TContext>
187+
): void {
188+
this.emit("sceneRemoved", entry.scene);
189+
entry.scene.destroy();
190+
191+
// Destroy only root-level owned actors; destroyActor cascades to children
192+
for (const actor of entry.ownedActors) {
193+
if (actor.parent === null || !entry.ownedActors.has(actor.parent)) {
194+
this.destroyActor(actor);
195+
}
196+
}
197+
198+
this.#appendedScenes.delete(id);
199+
this.#appendedScenesPendingStart.delete(id);
200+
}
201+
202+
getScene(): Scene<TContext> | null;
203+
getScene(id: number): Scene<TContext> | null;
204+
getScene(name: string): Scene<TContext>[];
205+
getScene(
206+
target?: number | string
207+
): Scene<TContext> | null | Scene<TContext>[] {
208+
if (target === undefined) {
209+
return this.#currentScene;
210+
}
211+
212+
if (typeof target === "number") {
213+
return this.#appendedScenes.get(target)?.scene ?? null;
214+
}
215+
216+
const result: Scene<TContext>[] = [];
217+
for (const entry of this.#appendedScenes.values()) {
218+
if (entry.scene.name === target) {
219+
result.push(entry.scene);
220+
}
221+
}
222+
223+
return result;
224+
}
225+
56226
beginFrame() {
227+
if (this.#pendingScene !== null) {
228+
this.setScene(this.#pendingScene);
229+
this.#pendingScene = null;
230+
}
231+
232+
if (this.#sceneStartPending) {
233+
this.#currentScene?.start();
234+
this.#sceneStartPending = false;
235+
}
236+
237+
if (this.#appendedScenesPendingStart.size > 0) {
238+
for (const id of this.#appendedScenesPendingStart) {
239+
this.#appendedScenes.get(id)?.scene.start();
240+
}
241+
this.#appendedScenesPendingStart.clear();
242+
}
243+
57244
this.#cachedActors = Array.from(this.#registeredActors);
58245

59246
let i = 0;
@@ -78,6 +265,11 @@ export class SceneManager<
78265
this.#cachedActors.forEach((actor) => {
79266
actor.fixedUpdate(deltaTime);
80267
});
268+
this.#currentScene?.fixedUpdate(deltaTime);
269+
270+
for (const { scene } of this.#appendedScenes.values()) {
271+
scene.fixedUpdate(deltaTime);
272+
}
81273
}
82274

83275
update(
@@ -86,6 +278,11 @@ export class SceneManager<
86278
this.#cachedActors.forEach((actor) => {
87279
actor.update(deltaTime);
88280
});
281+
this.#currentScene?.update(deltaTime);
282+
283+
for (const { scene } of this.#appendedScenes.values()) {
284+
scene.update(deltaTime);
285+
}
89286
}
90287

91288
endFrame() {
@@ -117,7 +314,10 @@ export class SceneManager<
117314

118315
this.unregisterActor(actor);
119316

120-
// NOTE: make sure to remove deeply into the tree
317+
// For root actors (parent === null): removes from tree.children and fires
318+
// the removeCallback that detaches actor.object3D from the THREE.Scene.
319+
// For non-root actors this is a no-op; actor.destroy() handles removal
320+
// from the parent's children list via parent.remove(this).
121321
this.tree.remove(actor);
122322
actor.destroy();
123323
}

packages/engine/src/systems/World.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class World<
7878
this.context = context;
7979
this.loop = new FixedTimeStep();
8080

81+
sceneManager.bindWorld(this);
8182
globalsAdapter.setGame(this);
8283
}
8384

packages/engine/src/systems/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type {
1212
} from "./asset/Registry.ts";
1313

1414
export * from "./World.ts";
15+
export * from "./Scene.ts";
1516
export * from "./FixedTimeStep.ts";
1617
export * from "./rendering/index.ts";
1718
export * from "./SceneManager.ts";

0 commit comments

Comments
 (0)