@waica/engine 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/aabb.d.ts +2 -0
  3. package/dist/aabb.js +4 -0
  4. package/dist/animation/clip-player.d.ts +20 -0
  5. package/dist/animation/clip-player.js +24 -0
  6. package/dist/animation/contract.d.ts +21 -0
  7. package/dist/animation/contract.js +23 -0
  8. package/dist/animation/sheet.d.ts +65 -0
  9. package/dist/animation/sheet.js +44 -0
  10. package/dist/archetype.d.ts +37 -0
  11. package/dist/archetype.js +1 -0
  12. package/dist/camera.d.ts +71 -0
  13. package/dist/camera.js +59 -0
  14. package/dist/collision-shape.d.ts +26 -0
  15. package/dist/collision-shape.js +136 -0
  16. package/dist/component-registry.d.ts +16 -0
  17. package/dist/component-registry.js +44 -0
  18. package/dist/component.d.ts +58 -0
  19. package/dist/component.js +11 -0
  20. package/dist/components/animated-sprite.d.ts +80 -0
  21. package/dist/components/animated-sprite.js +210 -0
  22. package/dist/components/dynamic-body.d.ts +68 -0
  23. package/dist/components/dynamic-body.js +189 -0
  24. package/dist/components/hitbox.d.ts +25 -0
  25. package/dist/components/hitbox.js +21 -0
  26. package/dist/components/solid.d.ts +32 -0
  27. package/dist/components/solid.js +45 -0
  28. package/dist/components/sprite.d.ts +51 -0
  29. package/dist/components/sprite.js +112 -0
  30. package/dist/entity.d.ts +22 -0
  31. package/dist/entity.js +52 -0
  32. package/dist/events.d.ts +8 -0
  33. package/dist/events.js +20 -0
  34. package/dist/game.d.ts +95 -0
  35. package/dist/game.js +263 -0
  36. package/dist/index.d.ts +39 -0
  37. package/dist/index.js +26 -0
  38. package/dist/input.d.ts +40 -0
  39. package/dist/input.js +84 -0
  40. package/dist/scene.d.ts +80 -0
  41. package/dist/scene.js +93 -0
  42. package/dist/solid-axis.d.ts +21 -0
  43. package/dist/solid-axis.js +77 -0
  44. package/dist/state/hooks.d.ts +90 -0
  45. package/dist/state/hooks.js +81 -0
  46. package/dist/state/state-machine.d.ts +83 -0
  47. package/dist/state/state-machine.js +173 -0
  48. package/dist/stats.d.ts +27 -0
  49. package/dist/stats.js +54 -0
  50. package/dist/ui.d.ts +47 -0
  51. package/dist/ui.js +175 -0
  52. package/package.json +32 -0
package/dist/game.js ADDED
@@ -0,0 +1,263 @@
1
+ import * as THREE from 'three';
2
+ import { collisionOverlap } from './collision-shape.js';
3
+ import { resolveSceneCamera, stepSceneCamera } from './camera.js';
4
+ import { Hitbox } from './components/hitbox.js';
5
+ import { Entity } from './entity.js';
6
+ import { Emitter } from './events.js';
7
+ import { Input } from './input.js';
8
+ import { registryEntry, spawnFromJson } from './scene.js';
9
+ import { Stats } from './stats.js';
10
+ import { GameUi } from './ui.js';
11
+ /**
12
+ * Engine core: loop, unified 2D/3D three scene, orthographic camera,
13
+ * entities with components, and input. See DESIGN.md.
14
+ */
15
+ export class Game {
16
+ scene = new THREE.Scene();
17
+ camera;
18
+ input;
19
+ entities = [];
20
+ events = new Emitter();
21
+ stats;
22
+ /** The HTML UI layer: presentation-only pieces toggled from code. */
23
+ ui;
24
+ /** Registry retained by loadScene for runtime prefab spawning. */
25
+ registry = null;
26
+ paramOverrides = {};
27
+ /**
28
+ * With false, the loop keeps rendering but runs no component updates
29
+ * or collisions — the editor's edit mode.
30
+ */
31
+ simulate = true;
32
+ // TODO(H1): migrate to WebGPURenderer (three/webgpu) with automatic WebGL2 fallback.
33
+ renderer;
34
+ resizeObserver;
35
+ updateFns = new Set();
36
+ resolution;
37
+ viewHeight;
38
+ sceneCamera = null;
39
+ lastTime = 0;
40
+ constructor(options) {
41
+ const { canvas, background = 0x1a1a2e, viewHeight = 10 } = options;
42
+ this.viewHeight = viewHeight;
43
+ this.resolution = options.resolution ?? null;
44
+ this.input = new Input(options.bindings);
45
+ this.stats = new Stats(options.stats);
46
+ this.ui = new GameUi(this.stats, () => canvas.parentElement ?? document.body);
47
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
48
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
49
+ this.scene.background = new THREE.Color(background);
50
+ this.camera = new THREE.OrthographicCamera();
51
+ this.camera.position.z = 10;
52
+ this.resize();
53
+ this.resizeObserver = new ResizeObserver(() => this.resize());
54
+ this.resizeObserver.observe(canvas);
55
+ }
56
+ /** Creates a live entity in the scene. */
57
+ spawn(name) {
58
+ const entity = new Entity(this, name);
59
+ this.entities.push(entity);
60
+ this.scene.add(entity.node);
61
+ return entity;
62
+ }
63
+ /** Instantiates a registered prefab after a scene has supplied the registry. */
64
+ spawnPrefab(prefab, options = {}) {
65
+ const registry = this.registry;
66
+ if (!registry) {
67
+ console.warn(`[waica] cannot spawn prefab before loadScene: "${prefab}"`);
68
+ return null;
69
+ }
70
+ if (!registryEntry(registry.prefabs, prefab)) {
71
+ console.warn(`[waica] unknown runtime prefab: "${prefab}"`);
72
+ return null;
73
+ }
74
+ const base = prefab.slice(prefab.lastIndexOf('/') + 1) || 'Entity';
75
+ const name = options.name ?? base.charAt(0).toUpperCase() + base.slice(1);
76
+ return spawnFromJson(this, { name, prefab, ...(options.position ? { position: options.position } : {}) }, registry);
77
+ }
78
+ /** Finds an entity by name. */
79
+ find(name) {
80
+ return this.entities.find((e) => e.name === name);
81
+ }
82
+ /** Loads persisted parameter overrides (waica.params.json). */
83
+ async loadParams(url) {
84
+ try {
85
+ const res = await fetch(url);
86
+ if (res.ok)
87
+ this.paramOverrides = (await res.json());
88
+ }
89
+ catch {
90
+ // no params file: the archetype defaults apply
91
+ }
92
+ }
93
+ /** Applies persisted overrides to a freshly added component. */
94
+ applyParamOverrides(entity, component) {
95
+ const Class = component.constructor;
96
+ const override = this.paramOverrides[entity.name]?.[Class.componentName];
97
+ if (override)
98
+ Object.assign(component, override);
99
+ }
100
+ /** Registers a function that runs once per frame. Returns the unsubscribe. */
101
+ onUpdate(fn) {
102
+ this.updateFns.add(fn);
103
+ return () => this.updateFns.delete(fn);
104
+ }
105
+ /**
106
+ * Adopts a scene's camera block: jumps to its framing and, while
107
+ * simulating, follows/clamps per its settings. Called by loadScene.
108
+ */
109
+ setSceneCamera(json) {
110
+ // No camera block: leave the camera to the host (constructor viewHeight).
111
+ if (!json) {
112
+ this.sceneCamera = null;
113
+ return;
114
+ }
115
+ this.sceneCamera = resolveSceneCamera(json);
116
+ // With a follow target the declared position is moot: start centered on
117
+ // the target so play begins framed like the editor shows it.
118
+ const followed = this.sceneCamera.follow ? this.find(this.sceneCamera.follow) : undefined;
119
+ const [x, y] = followed
120
+ ? [followed.position.x, followed.position.y]
121
+ : this.sceneCamera.position;
122
+ this.camera.position.x = x;
123
+ this.camera.position.y = y;
124
+ this.setViewHeight(this.sceneCamera.zoom);
125
+ }
126
+ start() {
127
+ this.renderer.setAnimationLoop((time) => this.tick(time));
128
+ }
129
+ stop() {
130
+ this.renderer.setAnimationLoop(null);
131
+ }
132
+ /** Internal: called by Entity.destroy(). */
133
+ removeEntity(entity) {
134
+ const i = this.entities.indexOf(entity);
135
+ if (i !== -1)
136
+ this.entities.splice(i, 1);
137
+ }
138
+ /** Visible world height (2D camera zoom). */
139
+ get view() {
140
+ return this.viewHeight;
141
+ }
142
+ setViewHeight(value) {
143
+ this.viewHeight = Math.min(Math.max(value, 2), 80);
144
+ this.resize();
145
+ }
146
+ /** Shuts the game down completely (loop, input, GPU). */
147
+ dispose() {
148
+ this.stop();
149
+ this.input.dispose();
150
+ this.resizeObserver.disconnect();
151
+ this.ui.dispose();
152
+ for (const entity of [...this.entities])
153
+ entity.destroy();
154
+ this.renderer.dispose();
155
+ }
156
+ tick(time) {
157
+ // Clamp dt: switching tabs or pausing doesn't fast-forward the simulation.
158
+ const dt = Math.min((time - this.lastTime) / 1000, 0.1);
159
+ this.lastTime = time;
160
+ if (this.simulate) {
161
+ for (const entity of [...this.entities]) {
162
+ for (const component of [...entity.components])
163
+ component.onUpdate?.(dt);
164
+ }
165
+ this.dispatchCollisions();
166
+ this.updateSceneCamera(dt);
167
+ }
168
+ // The UI must react to the pause itself (hide until resumed).
169
+ this.ui.setActive(this.simulate);
170
+ for (const fn of this.updateFns)
171
+ fn(dt);
172
+ this.input.endFrame();
173
+ if (this.resolution) {
174
+ // Letterbox bars: clear the whole canvas, then render inside the scissor.
175
+ this.renderer.setScissorTest(false);
176
+ this.renderer.setClearColor(0x000000, 1);
177
+ this.renderer.clear(true, false, false);
178
+ this.renderer.setScissorTest(true);
179
+ }
180
+ this.renderer.render(this.scene, this.camera);
181
+ }
182
+ updateSceneCamera(dt) {
183
+ const cam = this.sceneCamera;
184
+ if (!cam)
185
+ return;
186
+ const followed = cam.follow ? this.find(cam.follow) : undefined;
187
+ const mover = followed?.components.find((c) => typeof c.vx === 'number');
188
+ const next = stepSceneCamera(cam, {
189
+ x: this.camera.position.x,
190
+ y: this.camera.position.y,
191
+ halfW: (this.camera.right - this.camera.left) / 2,
192
+ halfH: this.viewHeight / 2,
193
+ target: followed ? { x: followed.position.x, y: followed.position.y } : null,
194
+ vx: mover?.vx ?? 0,
195
+ dt,
196
+ });
197
+ this.camera.position.x = next.x;
198
+ this.camera.position.y = next.y;
199
+ }
200
+ dispatchCollisions() {
201
+ const boxed = this.entities.filter((e) => e.has(Hitbox));
202
+ for (let i = 0; i < boxed.length; i++) {
203
+ for (let j = i + 1; j < boxed.length; j++) {
204
+ const a = boxed[i];
205
+ const b = boxed[j];
206
+ if (!a?.alive || !b?.alive)
207
+ continue;
208
+ const ha = a.get(Hitbox);
209
+ const hb = b.get(Hitbox);
210
+ if (!ha || !hb)
211
+ continue;
212
+ const hit = collisionOverlap({
213
+ x: a.position.x + ha.offsetX,
214
+ y: a.position.y + ha.offsetY,
215
+ width: ha.width,
216
+ height: ha.height,
217
+ shape: ha.shape,
218
+ points: ha.points,
219
+ }, {
220
+ x: b.position.x + hb.offsetX,
221
+ y: b.position.y + hb.offsetY,
222
+ width: hb.width,
223
+ height: hb.height,
224
+ shape: hb.shape,
225
+ points: hb.points,
226
+ });
227
+ if (!hit)
228
+ continue;
229
+ for (const c of [...a.components])
230
+ c.onCollide?.(b);
231
+ if (!a.alive || !b.alive)
232
+ continue;
233
+ for (const c of [...b.components])
234
+ c.onCollide?.(a);
235
+ }
236
+ }
237
+ }
238
+ resize() {
239
+ const canvas = this.renderer.domElement;
240
+ const { clientWidth: w, clientHeight: h } = canvas;
241
+ if (w === 0 || h === 0)
242
+ return;
243
+ this.renderer.setSize(w, h, false);
244
+ let aspect = w / h;
245
+ if (this.resolution) {
246
+ // Fixed resolution: the largest rect with its aspect, centered (letterbox).
247
+ aspect = this.resolution.width / this.resolution.height;
248
+ const vw = Math.min(w, h * aspect);
249
+ const vh = vw / aspect;
250
+ const vx = (w - vw) / 2;
251
+ const vy = (h - vh) / 2;
252
+ this.renderer.setViewport(vx, vy, vw, vh);
253
+ this.renderer.setScissor(vx, vy, vw, vh);
254
+ }
255
+ const halfH = this.viewHeight / 2;
256
+ const halfW = halfH * aspect;
257
+ this.camera.left = -halfW;
258
+ this.camera.right = halfW;
259
+ this.camera.top = halfH;
260
+ this.camera.bottom = -halfH;
261
+ this.camera.updateProjectionMatrix();
262
+ }
263
+ }
@@ -0,0 +1,39 @@
1
+ export { Game } from './game.js';
2
+ export type { GameOptions, GameResolution, SpawnPrefabOptions, UpdateFn, ParamOverrides, } from './game.js';
3
+ export { CAMERA_DEFAULTS, resolveSceneCamera, stepSceneCamera } from './camera.js';
4
+ export type { SceneCameraJson, CameraLimitsJson, ResolvedSceneCamera } from './camera.js';
5
+ export { Entity } from './entity.js';
6
+ export { Component } from './component.js';
7
+ export type { ComponentClass, ContactNormal, ParamSpec, SolidContact, } from './component.js';
8
+ export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
9
+ export type { ComponentModule } from './component-registry.js';
10
+ export { Input, DEFAULT_BINDINGS } from './input.js';
11
+ export type { ActionName, InputBindings } from './input.js';
12
+ export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
13
+ export { Stats } from './stats.js';
14
+ export type { StatValue } from './stats.js';
15
+ export { GameUi } from './ui.js';
16
+ export { Sprite } from './components/sprite.js';
17
+ export { Solid } from './components/solid.js';
18
+ export { Hitbox } from './components/hitbox.js';
19
+ export { DynamicBody } from './components/dynamic-body.js';
20
+ export { AnimatedSprite } from './components/animated-sprite.js';
21
+ export { aabbOverlap } from './aabb.js';
22
+ export { resolveSolidAxis } from './solid-axis.js';
23
+ export type { CollisionAxis, SolidAxisOptions } from './solid-axis.js';
24
+ export { COLLISION_SHAPES, DEFAULT_COLLISION_POLYGON, collisionBounds, collisionOverlap, collisionVertices, resolveCollisionPoints, } from './collision-shape.js';
25
+ export type { CollisionBody, CollisionBounds, CollisionPoint, CollisionShape, } from './collision-shape.js';
26
+ export { Emitter } from './events.js';
27
+ export { loadScene, spawnFromJson, resolveEntityComponents, resolveProps } from './scene.js';
28
+ export type { SceneJson, SceneEntityJson, SceneComponentJson, SceneRegistry, PrefabJson } from './scene.js';
29
+ export { ClipPlayer } from './animation/clip-player.js';
30
+ export type { ClipDef } from './animation/clip-player.js';
31
+ export { sheetCell, sheetFrameCount, locateFrame } from './animation/sheet.js';
32
+ export type { SheetGridParams, SheetCell, SheetDef } from './animation/sheet.js';
33
+ export { resolveClip, missingClips } from './animation/contract.js';
34
+ export type { AnimationContract } from './animation/contract.js';
35
+ export { StateMachine, evaluateTrigger, nextTransition } from './state/state-machine.js';
36
+ export type { StateJson, StateTransitionJson, TriggerEnv } from './state/state-machine.js';
37
+ export { defineRole, defineStates, installArchetype, logicSet, registeredLogicSets, registeredRoles, resetRegistries, roleDefinition, } from './state/hooks.js';
38
+ export type { ArchetypeBundle, RoleDefinition, RoleGraph, StateContext, StateHooks, StateLogic, } from './state/hooks.js';
39
+ export * as THREE from 'three';
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ export { Game } from './game.js';
2
+ export { CAMERA_DEFAULTS, resolveSceneCamera, stepSceneCamera } from './camera.js';
3
+ export { Entity } from './entity.js';
4
+ export { Component } from './component.js';
5
+ export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
6
+ export { Input, DEFAULT_BINDINGS } from './input.js';
7
+ export { Stats } from './stats.js';
8
+ export { GameUi } from './ui.js';
9
+ export { Sprite } from './components/sprite.js';
10
+ export { Solid } from './components/solid.js';
11
+ export { Hitbox } from './components/hitbox.js';
12
+ export { DynamicBody } from './components/dynamic-body.js';
13
+ export { AnimatedSprite } from './components/animated-sprite.js';
14
+ export { aabbOverlap } from './aabb.js';
15
+ export { resolveSolidAxis } from './solid-axis.js';
16
+ export { COLLISION_SHAPES, DEFAULT_COLLISION_POLYGON, collisionBounds, collisionOverlap, collisionVertices, resolveCollisionPoints, } from './collision-shape.js';
17
+ export { Emitter } from './events.js';
18
+ export { loadScene, spawnFromJson, resolveEntityComponents, resolveProps } from './scene.js';
19
+ export { ClipPlayer } from './animation/clip-player.js';
20
+ export { sheetCell, sheetFrameCount, locateFrame } from './animation/sheet.js';
21
+ export { resolveClip, missingClips } from './animation/contract.js';
22
+ export { StateMachine, evaluateTrigger, nextTransition } from './state/state-machine.js';
23
+ export { defineRole, defineStates, installArchetype, logicSet, registeredLogicSets, registeredRoles, resetRegistries, roleDefinition, } from './state/hooks.js';
24
+ // Explicit escape hatch while our own API grows: a single source of three
25
+ // for the whole workspace. The thesis is that three stays an implementation detail.
26
+ export * as THREE from 'three';
@@ -0,0 +1,40 @@
1
+ export type ActionName = string;
2
+ /** Action → KeyboardEvent.code list. */
3
+ export type InputBindings = Record<string, string[]>;
4
+ /** Neutral engine baseline; archetypes own their action vocabulary. */
5
+ export declare const DEFAULT_BINDINGS: Readonly<InputBindings>;
6
+ /**
7
+ * Action-based input with archetype default bindings.
8
+ * v0: keyboard. TODO(H1): gamepad and touch.
9
+ */
10
+ export declare class Input {
11
+ private readonly bindings;
12
+ private readonly down;
13
+ private readonly justDown;
14
+ private readonly used;
15
+ /** Installs exactly the action map supplied by the active archetype/project. */
16
+ constructor(bindings?: Readonly<InputBindings>);
17
+ /** Is the action held this frame? */
18
+ held(action: ActionName): boolean;
19
+ /** Was the action pressed exactly this frame? */
20
+ justPressed(action: ActionName): boolean;
21
+ /** -1..1 axis from two actions (left/right by default). */
22
+ axis(negative?: ActionName, positive?: ActionName): number;
23
+ /**
24
+ * Marks this frame's press of the action as spent, so consumers that
25
+ * honor consumption (state-machine 'input:' triggers) ignore it. One
26
+ * press does one thing: the press that launches a ground jump can't
27
+ * also fire a "key press jump" transition on the very same frame.
28
+ */
29
+ consume(action: ActionName): void;
30
+ /** Was this frame's press already spent by someone? */
31
+ consumed(action: ActionName): boolean;
32
+ /** Called by the Game at the end of each frame. */
33
+ endFrame(): void;
34
+ dispose(): void;
35
+ private isActive;
36
+ private releaseAll;
37
+ private onVisibilityChange;
38
+ private onKeyDown;
39
+ private onKeyUp;
40
+ }
package/dist/input.js ADDED
@@ -0,0 +1,84 @@
1
+ /** Neutral engine baseline; archetypes own their action vocabulary. */
2
+ export const DEFAULT_BINDINGS = {};
3
+ /**
4
+ * Action-based input with archetype default bindings.
5
+ * v0: keyboard. TODO(H1): gamepad and touch.
6
+ */
7
+ export class Input {
8
+ bindings = new Map();
9
+ down = new Set();
10
+ justDown = new Set();
11
+ used = new Set();
12
+ /** Installs exactly the action map supplied by the active archetype/project. */
13
+ constructor(bindings = DEFAULT_BINDINGS) {
14
+ for (const [action, codes] of Object.entries(bindings)) {
15
+ this.bindings.set(action, new Set(codes));
16
+ }
17
+ window.addEventListener('keydown', this.onKeyDown);
18
+ window.addEventListener('keyup', this.onKeyUp);
19
+ window.addEventListener('blur', this.releaseAll);
20
+ document.addEventListener('visibilitychange', this.onVisibilityChange);
21
+ }
22
+ /** Is the action held this frame? */
23
+ held(action) {
24
+ return this.isActive(action, this.down);
25
+ }
26
+ /** Was the action pressed exactly this frame? */
27
+ justPressed(action) {
28
+ return this.isActive(action, this.justDown);
29
+ }
30
+ /** -1..1 axis from two actions (left/right by default). */
31
+ axis(negative = 'left', positive = 'right') {
32
+ return (this.held(positive) ? 1 : 0) - (this.held(negative) ? 1 : 0);
33
+ }
34
+ /**
35
+ * Marks this frame's press of the action as spent, so consumers that
36
+ * honor consumption (state-machine 'input:' triggers) ignore it. One
37
+ * press does one thing: the press that launches a ground jump can't
38
+ * also fire a "key press jump" transition on the very same frame.
39
+ */
40
+ consume(action) {
41
+ this.used.add(action);
42
+ }
43
+ /** Was this frame's press already spent by someone? */
44
+ consumed(action) {
45
+ return this.used.has(action);
46
+ }
47
+ /** Called by the Game at the end of each frame. */
48
+ endFrame() {
49
+ this.justDown.clear();
50
+ this.used.clear();
51
+ }
52
+ dispose() {
53
+ window.removeEventListener('keydown', this.onKeyDown);
54
+ window.removeEventListener('keyup', this.onKeyUp);
55
+ window.removeEventListener('blur', this.releaseAll);
56
+ document.removeEventListener('visibilitychange', this.onVisibilityChange);
57
+ }
58
+ isActive(action, set) {
59
+ const codes = this.bindings.get(action);
60
+ if (!codes)
61
+ return false;
62
+ for (const code of codes)
63
+ if (set.has(code))
64
+ return true;
65
+ return false;
66
+ }
67
+ releaseAll = () => {
68
+ this.down.clear();
69
+ this.justDown.clear();
70
+ };
71
+ onVisibilityChange = () => {
72
+ if (document.visibilityState === 'hidden')
73
+ this.releaseAll();
74
+ };
75
+ onKeyDown = (e) => {
76
+ if (e.repeat)
77
+ return;
78
+ this.down.add(e.code);
79
+ this.justDown.add(e.code);
80
+ };
81
+ onKeyUp = (e) => {
82
+ this.down.delete(e.code);
83
+ };
84
+ }
@@ -0,0 +1,80 @@
1
+ import type { SceneCameraJson } from './camera.js';
2
+ import type { ComponentClass } from './component.js';
3
+ import type { Entity } from './entity.js';
4
+ import type { Game } from './game.js';
5
+ /**
6
+ * Waica's scene format: declarative, git-friendly data, editable by the
7
+ * visual editor. The scene is the source of truth; the Game is its live
8
+ * projection.
9
+ */
10
+ export interface SceneComponentJson {
11
+ type: string;
12
+ props?: Record<string, unknown>;
13
+ }
14
+ export interface SceneEntityJson {
15
+ name: string;
16
+ position?: [number, number];
17
+ /** Prefab ref like "characters/slime", resolved against SceneRegistry.prefabs. */
18
+ prefab?: string;
19
+ /** Per-component prop overrides on top of the prefab: componentType -> propName -> value. */
20
+ overrides?: Record<string, Record<string, unknown>>;
21
+ components?: SceneComponentJson[];
22
+ /** Editor-only grouping label; spawning ignores it. */
23
+ folder?: string;
24
+ }
25
+ /** A reusable entity template: a typed bag of components a scene can reference. */
26
+ export interface PrefabJson {
27
+ waicaPrefab: 1;
28
+ type: 'character' | 'object' | 'tile';
29
+ components: SceneComponentJson[];
30
+ }
31
+ export interface SceneJson {
32
+ waicaScene: 1 | 2 | 3;
33
+ /** The scene's built-in camera (v3); absent = the host keeps control. */
34
+ camera?: SceneCameraJson;
35
+ entities: SceneEntityJson[];
36
+ /** UI pieces (src/ui/*.html) mounted visible when the scene loads. */
37
+ ui?: string[];
38
+ /**
39
+ * Editor-only folder registry: keeps empty folders alive and ordered.
40
+ * Folders group entities in the editor's explorer; the runtime ignores them.
41
+ */
42
+ folders?: string[];
43
+ }
44
+ /** Which components exist and how to resolve archetype assets (waica:*). */
45
+ export interface SceneRegistry {
46
+ components: Record<string, ComponentClass>;
47
+ /**
48
+ * Resolves asset URIs — "waica:dog" registry keys or project paths like
49
+ * "src/art/hero.png" — to loadable URLs. MUST return unknown strings
50
+ * unchanged: every string prop goes through it.
51
+ */
52
+ resolveAsset?: (uri: string) => string;
53
+ /** Prefab definitions keyed by ref ("characters/slime"). */
54
+ prefabs?: Record<string, PrefabJson>;
55
+ /** UI piece sources keyed by name ("coin-counter" → its HTML). */
56
+ ui?: Record<string, string>;
57
+ }
58
+ /**
59
+ * Own-property lookup. Scene JSON is data — a component or prefab named
60
+ * "constructor" or "toString" must read as missing, not as whatever sits
61
+ * on Object.prototype.
62
+ */
63
+ export declare function registryEntry<T>(record: Record<string, T> | undefined, key: string): T | undefined;
64
+ /**
65
+ * Runs every string prop through the registry's asset resolver (if any),
66
+ * recursing into arrays and plain objects — nested textures (e.g. an
67
+ * AnimatedSprite's extraSheets) resolve like top-level ones. Safe because
68
+ * resolvers return unknown strings unchanged.
69
+ */
70
+ export declare function resolveProps(props: Record<string, unknown> | undefined, registry: SceneRegistry): Record<string, unknown>;
71
+ /**
72
+ * Expands an entity's prefab (with its overrides) into the final component
73
+ * list: prefab components first, inline extras appended after. Pure — inputs
74
+ * are never mutated; merged components are fresh objects.
75
+ */
76
+ export declare function resolveEntityComponents(entity: SceneEntityJson, prefabs?: Record<string, PrefabJson>): SceneComponentJson[];
77
+ /** Instantiates a scene entity into the game. */
78
+ export declare function spawnFromJson(game: Game, json: SceneEntityJson, registry: SceneRegistry): Entity;
79
+ /** Loads a full scene into the game. */
80
+ export declare function loadScene(game: Game, scene: SceneJson, registry: SceneRegistry): void;
package/dist/scene.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Own-property lookup. Scene JSON is data — a component or prefab named
3
+ * "constructor" or "toString" must read as missing, not as whatever sits
4
+ * on Object.prototype.
5
+ */
6
+ export function registryEntry(record, key) {
7
+ if (!record || !Object.prototype.hasOwnProperty.call(record, key))
8
+ return undefined;
9
+ return record[key];
10
+ }
11
+ /**
12
+ * Runs every string prop through the registry's asset resolver (if any),
13
+ * recursing into arrays and plain objects — nested textures (e.g. an
14
+ * AnimatedSprite's extraSheets) resolve like top-level ones. Safe because
15
+ * resolvers return unknown strings unchanged.
16
+ */
17
+ export function resolveProps(props, registry) {
18
+ if (!props)
19
+ return {};
20
+ const resolve = registry.resolveAsset;
21
+ if (!resolve)
22
+ return { ...props };
23
+ const walk = (value) => {
24
+ if (typeof value === 'string')
25
+ return resolve(value);
26
+ if (Array.isArray(value))
27
+ return value.map(walk);
28
+ if (value && typeof value === 'object' && value.constructor === Object) {
29
+ const out = {};
30
+ for (const [key, entry] of Object.entries(value))
31
+ out[key] = walk(entry);
32
+ return out;
33
+ }
34
+ return value;
35
+ };
36
+ return walk(props);
37
+ }
38
+ /**
39
+ * Expands an entity's prefab (with its overrides) into the final component
40
+ * list: prefab components first, inline extras appended after. Pure — inputs
41
+ * are never mutated; merged components are fresh objects.
42
+ */
43
+ export function resolveEntityComponents(entity, prefabs) {
44
+ const inline = entity.components ?? [];
45
+ if (!entity.prefab)
46
+ return inline;
47
+ const prefab = registryEntry(prefabs, entity.prefab);
48
+ if (!prefab) {
49
+ console.warn(`[waica] unknown prefab in scene: "${entity.prefab}" (${entity.name})`);
50
+ return inline;
51
+ }
52
+ const fromPrefab = prefab.components.map((comp) => ({
53
+ type: comp.type,
54
+ props: { ...comp.props, ...entity.overrides?.[comp.type] },
55
+ }));
56
+ return [...fromPrefab, ...inline];
57
+ }
58
+ /** Instantiates a scene entity into the game. */
59
+ export function spawnFromJson(game, json, registry) {
60
+ const entity = game.spawn(json.name);
61
+ if (json.position)
62
+ entity.position.set(json.position[0], json.position[1], 0);
63
+ for (const comp of resolveEntityComponents(json, registry.prefabs)) {
64
+ const Class = registryEntry(registry.components, comp.type);
65
+ if (!Class) {
66
+ console.warn(`[waica] unknown component in scene: "${comp.type}" (${json.name})`);
67
+ continue;
68
+ }
69
+ // The registry now carries project-owned classes, i.e. arbitrary user
70
+ // code. A throwing constructor or onReady costs that one component —
71
+ // never the rest of the scene, and never the editor hosting it.
72
+ try {
73
+ entity.add(Class, resolveProps(comp.props, registry));
74
+ }
75
+ catch (error) {
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ console.error(`[waica] component "${comp.type}" failed on "${json.name}": ${message}`);
78
+ }
79
+ }
80
+ return entity;
81
+ }
82
+ /** Loads a full scene into the game. */
83
+ export function loadScene(game, scene, registry) {
84
+ game.registry = registry;
85
+ for (const entityJson of scene.entities)
86
+ spawnFromJson(game, entityJson, registry);
87
+ // After the spawns: with a follow target, the camera starts centered on it.
88
+ game.setSceneCamera(scene.camera);
89
+ if (registry.ui)
90
+ game.ui.defineAll(registry.ui);
91
+ for (const name of scene.ui ?? [])
92
+ game.ui.show(name);
93
+ }
@@ -0,0 +1,21 @@
1
+ import { type CollisionBody } from './collision-shape.js';
2
+ import { Solid } from './components/solid.js';
3
+ import type { Entity } from './entity.js';
4
+ export type CollisionAxis = 'x' | 'y';
5
+ export interface SolidAxisOptions {
6
+ /** Dynamic entity whose axis position has already been moved to its target. */
7
+ entity: Entity;
8
+ axis: CollisionAxis;
9
+ /** Known pre-move position on this axis. */
10
+ previous: number;
11
+ /** Current world-space collision body; read again as the axis position changes. */
12
+ body(): CollisionBody;
13
+ /** Receives each nearest Solid that blocks this axis move. */
14
+ onContact?(solid: Solid): void;
15
+ }
16
+ /**
17
+ * Resolves one axis against scene Solids. Large displacements are split into
18
+ * body-sized steps so a thin wall cannot sit entirely between two samples.
19
+ * Returns whether a new contact blocked the move.
20
+ */
21
+ export declare function resolveSolidAxis({ entity, axis, previous, body, onContact, }: SolidAxisOptions): boolean;