incanto 0.1.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.
- package/LICENSE +30 -0
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES.md +88 -0
- package/assets/audio/attacked.mp3 +0 -0
- package/assets/audio/explosion.mp3 +0 -0
- package/assets/audio/gold_loot.mp3 +0 -0
- package/assets/audio/heal.mp3 +0 -0
- package/assets/audio/hit_metal_bang.mp3 +0 -0
- package/assets/audio/ice_spear.mp3 +0 -0
- package/assets/audio/monster_died.mp3 +0 -0
- package/assets/audio/slash.mp3 +0 -0
- package/assets/audio/smite.mp3 +0 -0
- package/assets/audio/spells_cast.mp3 +0 -0
- package/assets/audio/ui_click.wav +0 -0
- package/assets/audio/walk.mp3 +0 -0
- package/assets/catalog.json +390 -0
- package/assets/characters/2dbasic.json +41 -0
- package/assets/characters/2dbasic.png +0 -0
- package/assets/characters/ghost.json +46 -0
- package/assets/characters/ghost.png +0 -0
- package/assets/characters/goblin.json +40 -0
- package/assets/characters/goblin.png +0 -0
- package/assets/characters/medieval-knight.json +41 -0
- package/assets/characters/medieval-knight.png +0 -0
- package/assets/effects/swoosh.png +0 -0
- package/assets/items/box.png +0 -0
- package/assets/items/buff_potion.png +0 -0
- package/assets/items/coin.png +0 -0
- package/assets/items/gem.png +0 -0
- package/assets/items/gold.png +0 -0
- package/assets/items/hp_potion.png +0 -0
- package/assets/items/locked_item_box.png +0 -0
- package/assets/items/map.png +0 -0
- package/assets/items/resurrection_potion.png +0 -0
- package/assets/items/super_box.png +0 -0
- package/assets/items/trap.png +0 -0
- package/assets/tiles/floor00.jpg +0 -0
- package/assets/tiles/minecraft-tiles.png +0 -0
- package/assets/tiles/wall00.jpg +0 -0
- package/assets/vegetation/ash_color.png +0 -0
- package/assets/vegetation/aspen_color.png +0 -0
- package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
- package/assets/vegetation/ground/dirt_color.jpg +0 -0
- package/assets/vegetation/ground/dirt_normal.jpg +0 -0
- package/assets/vegetation/ground/grass.jpg +0 -0
- package/assets/vegetation/oak_color.png +0 -0
- package/assets/vegetation/pine_color.png +0 -0
- package/bin/incanto-assets.mjs +107 -0
- package/bin/incanto-check.mjs +107 -0
- package/bin/incanto-editor.mjs +343 -0
- package/bin/incanto-env.mjs +144 -0
- package/bin/incanto-model.mjs +296 -0
- package/bin/incanto-play.mjs +219 -0
- package/bin/incanto-skills.mjs +71 -0
- package/dist/2d.d.ts +642 -0
- package/dist/2d.js +44 -0
- package/dist/3d.d.ts +1860 -0
- package/dist/3d.js +5 -0
- package/dist/agent8-DzU2fFyH.js +129 -0
- package/dist/audio-player-DqUR3XFs.d.ts +110 -0
- package/dist/behavior-BAQq7HGM.d.ts +851 -0
- package/dist/create-game-BdjpTHrW.js +1725 -0
- package/dist/create-game-CZHROKcT.js +527 -0
- package/dist/debug-draw-CZmOYjL2.js +13 -0
- package/dist/debug.d.ts +66 -0
- package/dist/debug.js +658 -0
- package/dist/duplicate-DP2WPYom.js +22 -0
- package/dist/env.d.ts +430 -0
- package/dist/env.js +3152 -0
- package/dist/errors-BMFaY68Q.d.ts +33 -0
- package/dist/errors-BpWbnbb_.js +13 -0
- package/dist/gameplay-Ccruc3Wd.js +1501 -0
- package/dist/gameplay.d.ts +543 -0
- package/dist/gameplay.js +2 -0
- package/dist/heightmap-CroQPEER.js +185 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +62 -0
- package/dist/json-BLk7H2Qa.js +30 -0
- package/dist/loader-CGs_G-r0.js +919 -0
- package/dist/loader-Mo0KghCv.d.ts +41 -0
- package/dist/net.d.ts +427 -0
- package/dist/net.js +772 -0
- package/dist/noise-CGUMx44x.js +82 -0
- package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
- package/dist/particle-sim-DYuSUxvK.js +1319 -0
- package/dist/physics-2d-KuMWPTf6.js +288 -0
- package/dist/physics-3d-Dl67vOLT.js +434 -0
- package/dist/react.d.ts +65 -0
- package/dist/react.js +209 -0
- package/dist/register-BuUV1_KB.js +561 -0
- package/dist/register-CNlYAS1_.js +10634 -0
- package/dist/register-DPEV9_9t.js +851 -0
- package/dist/register-Dasmnurl.js +374 -0
- package/dist/registry-BVJ2HbCn.js +132 -0
- package/dist/rng-DP-SR7eg.js +38 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-CcoWb32N.d.ts +104 -0
- package/dist/test.d.ts +158 -0
- package/dist/test.js +275 -0
- package/dist/touch-031PxtCR.js +208 -0
- package/dist/vite.d.ts +26 -0
- package/dist/vite.js +57 -0
- package/editor/assets/GameServer-C56iOUgF.js +1 -0
- package/editor/assets/agent8-Bp7QFI7v.js +1 -0
- package/editor/assets/index-DF3tMeKJ.css +1 -0
- package/editor/assets/index-Dl2pjA8e.js +7365 -0
- package/editor/assets/rapier-CEuLKeCu.js +1 -0
- package/editor/assets/rapier-DE6a0vmv.js +1 -0
- package/editor/index.html +169 -0
- package/package.json +97 -0
- package/schemas/scene.schema.json +4254 -0
- package/skills/README.md +9 -0
- package/skills/incanto-3d-character.md +229 -0
- package/skills/incanto-3d-models.md +151 -0
- package/skills/incanto-assets.md +118 -0
- package/skills/incanto-audio.md +309 -0
- package/skills/incanto-behaviors-and-scripts.md +169 -0
- package/skills/incanto-building-2d-games.md +242 -0
- package/skills/incanto-building-3d-games.md +245 -0
- package/skills/incanto-editor.md +163 -0
- package/skills/incanto-environment.md +743 -0
- package/skills/incanto-gameplay-behaviors.md +707 -0
- package/skills/incanto-multiplayer.md +264 -0
- package/skills/incanto-node-reference.md +797 -0
- package/skills/incanto-physics-and-input.md +164 -0
- package/skills/incanto-scene-json-authoring.md +325 -0
- package/skills/incanto-verifying-your-game.md +191 -0
- package/skills/incanto-web-integration.md +96 -0
- package/templates/agent8-server.js +84 -0
- package/templates/agent8-server.ts +138 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
2
|
+
import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
|
|
3
|
+
import { c as mergeStaticSignals, i as getNodeSchema, r as createNode, s as mergeStaticProps, t as applySchemaProps } from "./registry-BVJ2HbCn.js";
|
|
4
|
+
//#region src/core/signal.ts
|
|
5
|
+
var Signal = class {
|
|
6
|
+
connections = [];
|
|
7
|
+
/** Connect a listener. Returns a disposer. Connecting the same fn twice is a no-op. */
|
|
8
|
+
connect(fn, opts) {
|
|
9
|
+
if (!this.connections.some((c) => c.fn === fn)) this.connections.push({
|
|
10
|
+
fn,
|
|
11
|
+
once: opts?.once ?? false
|
|
12
|
+
});
|
|
13
|
+
return () => this.disconnect(fn);
|
|
14
|
+
}
|
|
15
|
+
disconnect(fn) {
|
|
16
|
+
const i = this.connections.findIndex((c) => c.fn === fn);
|
|
17
|
+
if (i !== -1) this.connections.splice(i, 1);
|
|
18
|
+
}
|
|
19
|
+
disconnectAll() {
|
|
20
|
+
this.connections.length = 0;
|
|
21
|
+
}
|
|
22
|
+
emit(...args) {
|
|
23
|
+
const snapshot = [...this.connections];
|
|
24
|
+
for (const conn of snapshot) {
|
|
25
|
+
if (!this.connections.includes(conn)) continue;
|
|
26
|
+
if (conn.once) this.disconnect(conn.fn);
|
|
27
|
+
conn.fn(...args);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
get connectionCount() {
|
|
31
|
+
return this.connections.length;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/core/behavior.ts
|
|
36
|
+
/**
|
|
37
|
+
* Vibe-coded logic attached to a JSON-declared node — the ONE script a node
|
|
38
|
+
* may carry (`"script": {"name": "PlayerController", "props": {...}}`).
|
|
39
|
+
*
|
|
40
|
+
* Lifecycle hooks run AFTER the node's own. Props follow the same
|
|
41
|
+
* schema/defaults/delta model as node props.
|
|
42
|
+
*/
|
|
43
|
+
var Behavior = class {
|
|
44
|
+
static props;
|
|
45
|
+
/**
|
|
46
|
+
* Custom signals this behavior emits via `this.emit(...)` — declared onto
|
|
47
|
+
* its node at load. Undeclared emits are hard errors (typo safety).
|
|
48
|
+
*/
|
|
49
|
+
static signals;
|
|
50
|
+
/** The node this behavior is attached to (assigned by the loader). */
|
|
51
|
+
node;
|
|
52
|
+
/** The running engine, reached through the scene tree. */
|
|
53
|
+
get engine() {
|
|
54
|
+
const engine = this.node?.tree?.engine;
|
|
55
|
+
if (!engine) throw new IncantoError("TREE_VIOLATION", `Behavior on '${this.node?.name ?? "?"}' is not attached to a running engine (node must be in a scene set on an Engine).`);
|
|
56
|
+
return engine;
|
|
57
|
+
}
|
|
58
|
+
/** Shortcut for `this.engine.input`. */
|
|
59
|
+
get input() {
|
|
60
|
+
return this.engine.input;
|
|
61
|
+
}
|
|
62
|
+
/** Seeded engine randomness — use this, not Math.random(), for replayability. */
|
|
63
|
+
get rng() {
|
|
64
|
+
return this.engine.rng;
|
|
65
|
+
}
|
|
66
|
+
/** The engine log channel (visible in the debug overlay and test harness). */
|
|
67
|
+
get log() {
|
|
68
|
+
return this.engine.log;
|
|
69
|
+
}
|
|
70
|
+
getNode(path) {
|
|
71
|
+
return this.node.getNode(path);
|
|
72
|
+
}
|
|
73
|
+
emit(signal, ...args) {
|
|
74
|
+
this.node.emit(signal, ...args);
|
|
75
|
+
}
|
|
76
|
+
on(signal, fn, opts) {
|
|
77
|
+
return this.node.on(signal, fn, opts);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* @internal No-op stand-in attached when a scene's script names an
|
|
82
|
+
* unregistered behavior under `stubMissingBehaviors` (validation/tool mode):
|
|
83
|
+
* props skip validation, connection handlers resolve to nothing, emits are
|
|
84
|
+
* harmless. Games never see this — the strict default still hard-fails.
|
|
85
|
+
*/
|
|
86
|
+
var StubBehavior = class extends Behavior {};
|
|
87
|
+
const behaviors = /* @__PURE__ */ new Map();
|
|
88
|
+
/** Whether a behavior name is registered (no throw). */
|
|
89
|
+
function hasBehavior(name) {
|
|
90
|
+
return behaviors.has(name);
|
|
91
|
+
}
|
|
92
|
+
/** Explicit registration — never an import side effect (tree-shaking safety). */
|
|
93
|
+
function registerBehavior(name, ctor, opts) {
|
|
94
|
+
const existing = behaviors.get(name);
|
|
95
|
+
if (existing && existing !== ctor && !opts?.replace) throw new IncantoError("DUPLICATE_BEHAVIOR", `Behavior '${name}' is already registered by a different class. Replacing an implementation (e.g. under hot reload) needs registerBehavior(name, ctor, { replace: true }).`);
|
|
96
|
+
behaviors.set(name, ctor);
|
|
97
|
+
}
|
|
98
|
+
function getBehavior(name) {
|
|
99
|
+
const ctor = behaviors.get(name);
|
|
100
|
+
if (!ctor) {
|
|
101
|
+
const registered = [...behaviors.keys()];
|
|
102
|
+
throw new IncantoError("UNKNOWN_BEHAVIOR", `Unknown behavior '${name}'. Registered behaviors: [${registered.join(", ")}]. Did you forget registerBehavior('${name}', ${name})?`, { validOptions: registered });
|
|
103
|
+
}
|
|
104
|
+
return ctor;
|
|
105
|
+
}
|
|
106
|
+
function registeredBehaviors() {
|
|
107
|
+
return [...behaviors.keys()];
|
|
108
|
+
}
|
|
109
|
+
/** Test isolation helper. */
|
|
110
|
+
function clearBehaviors() {
|
|
111
|
+
behaviors.clear();
|
|
112
|
+
}
|
|
113
|
+
/** @internal Instantiate + validate props (used by the scene loader). */
|
|
114
|
+
function createBehavior(name, props) {
|
|
115
|
+
const ctor = getBehavior(name);
|
|
116
|
+
const behavior = new ctor();
|
|
117
|
+
applySchemaProps(behavior, mergeStaticProps(ctor), props, `script:${name}`);
|
|
118
|
+
return behavior;
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/core/node-path.ts
|
|
122
|
+
function parseNodePath(path) {
|
|
123
|
+
if (path === "") throw new IncantoError("BAD_NODE_PATH", "Node path must not be empty.");
|
|
124
|
+
if (path.startsWith("%")) {
|
|
125
|
+
const name = path.slice(1);
|
|
126
|
+
if (name === "" || name.includes("/") || name.includes("%")) throw new IncantoError("BAD_NODE_PATH", `Invalid unique-name path '${path}'. Expected '%NodeName'.`);
|
|
127
|
+
return {
|
|
128
|
+
kind: "unique",
|
|
129
|
+
name
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (path === ".") return {
|
|
133
|
+
kind: "relative",
|
|
134
|
+
segments: []
|
|
135
|
+
};
|
|
136
|
+
const absolute = path.startsWith("/");
|
|
137
|
+
const body = absolute ? path.slice(1) : path;
|
|
138
|
+
if (body === "" || body.split("/").some((s) => s === "")) throw new IncantoError("BAD_NODE_PATH", `Invalid node path '${path}'. Segments must be non-empty (no leading/trailing/double '/').`);
|
|
139
|
+
const segments = body.split("/");
|
|
140
|
+
for (const seg of segments) {
|
|
141
|
+
if (seg.includes("%")) throw new IncantoError("BAD_NODE_PATH", `Invalid node path '${path}'. '%' is only allowed as a whole-path prefix ('%Name').`);
|
|
142
|
+
if (seg === ".") throw new IncantoError("BAD_NODE_PATH", `Invalid node path '${path}'. '.' is only valid as the whole path — drop the './' prefix.`);
|
|
143
|
+
}
|
|
144
|
+
if (absolute && segments.includes("..")) throw new IncantoError("BAD_NODE_PATH", `Invalid node path '${path}'. Absolute paths must not contain '..'.`);
|
|
145
|
+
return {
|
|
146
|
+
kind: absolute ? "absolute" : "relative",
|
|
147
|
+
segments
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/core/node.ts
|
|
152
|
+
function validateName(name) {
|
|
153
|
+
if (name === "" || name.includes("/") || name.includes("%")) throw new IncantoError("TREE_VIOLATION", `Invalid node name '${name}'. Names must be non-empty and must not contain '/' or '%'.`);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Base class of everything in an Incanto scene (Godot's Node model).
|
|
157
|
+
*
|
|
158
|
+
* Pure data + tree structure: no rendering, no DOM, no three.js — renderer
|
|
159
|
+
* adapters subscribe from the outside.
|
|
160
|
+
*/
|
|
161
|
+
var Node = class {
|
|
162
|
+
static typeName = "Node";
|
|
163
|
+
_name;
|
|
164
|
+
_parent = null;
|
|
165
|
+
_children = [];
|
|
166
|
+
_groups = /* @__PURE__ */ new Set();
|
|
167
|
+
_signals = /* @__PURE__ */ new Map();
|
|
168
|
+
_declared = null;
|
|
169
|
+
_tree = null;
|
|
170
|
+
_ready = false;
|
|
171
|
+
/**
|
|
172
|
+
* Optional STABLE identifier (scene JSON `uid`) — unlike names (unique only
|
|
173
|
+
* among siblings) a uid is unique across the whole scene, so scripts and
|
|
174
|
+
* tools can address a node no matter where it moves.
|
|
175
|
+
*/
|
|
176
|
+
uid = null;
|
|
177
|
+
/** Free-form JSON identity for game logic (e.g. `{kind: 'ITEM', value: 10}`). */
|
|
178
|
+
tags = {};
|
|
179
|
+
/** Behavior attachment blob from scene JSON (resolved by the loader). */
|
|
180
|
+
script = null;
|
|
181
|
+
/** The resolved behavior instance (set by the loader from `script`). */
|
|
182
|
+
behavior = null;
|
|
183
|
+
/** Replication config blob from scene JSON (interpreted in M6; preserved until then). */
|
|
184
|
+
network = null;
|
|
185
|
+
constructor(name) {
|
|
186
|
+
const ctor = this.constructor;
|
|
187
|
+
this._name = name ?? ctor.typeName;
|
|
188
|
+
validateName(this._name);
|
|
189
|
+
}
|
|
190
|
+
get name() {
|
|
191
|
+
return this._name;
|
|
192
|
+
}
|
|
193
|
+
set name(value) {
|
|
194
|
+
validateName(value);
|
|
195
|
+
if (this._parent) this._name = uniqueSiblingName(value, this._parent._children, this);
|
|
196
|
+
else this._name = value;
|
|
197
|
+
}
|
|
198
|
+
get parent() {
|
|
199
|
+
return this._parent;
|
|
200
|
+
}
|
|
201
|
+
get children() {
|
|
202
|
+
return this._children;
|
|
203
|
+
}
|
|
204
|
+
get groups() {
|
|
205
|
+
return this._groups;
|
|
206
|
+
}
|
|
207
|
+
/** The SceneTree this node is attached to, or null while detached. */
|
|
208
|
+
get tree() {
|
|
209
|
+
return this._tree;
|
|
210
|
+
}
|
|
211
|
+
/** Whether onReady has run (it runs at most once per instance). */
|
|
212
|
+
get isReady() {
|
|
213
|
+
return this._ready;
|
|
214
|
+
}
|
|
215
|
+
addChild(child) {
|
|
216
|
+
if (child._parent !== null) throw new IncantoError("TREE_VIOLATION", `Cannot add '${child.name}': it already has parent '${child._parent.name}'. removeChild/reparent first.`);
|
|
217
|
+
for (let n = this; n; n = n._parent) if (n === child) throw new IncantoError("TREE_VIOLATION", `Cannot add '${child.name}' to '${this.name}': it is this node or an ancestor (cycle).`);
|
|
218
|
+
child._name = uniqueSiblingName(child._name, this._children, child);
|
|
219
|
+
child._parent = this;
|
|
220
|
+
this._children.push(child);
|
|
221
|
+
if (this._tree) {
|
|
222
|
+
child._propagateEnterTree(this._tree);
|
|
223
|
+
child._propagateReady();
|
|
224
|
+
}
|
|
225
|
+
return child;
|
|
226
|
+
}
|
|
227
|
+
removeChild(child) {
|
|
228
|
+
const i = this._children.indexOf(child);
|
|
229
|
+
if (i === -1) throw new IncantoError("TREE_VIOLATION", `'${child.name}' is not a child of '${this.name}'.`);
|
|
230
|
+
if (child._tree) child._propagateExitTree();
|
|
231
|
+
this._children.splice(i, 1);
|
|
232
|
+
child._parent = null;
|
|
233
|
+
}
|
|
234
|
+
reparent(newParent) {
|
|
235
|
+
this._parent?.removeChild(this);
|
|
236
|
+
newParent.addChild(this);
|
|
237
|
+
}
|
|
238
|
+
findChild(name, recursive = true) {
|
|
239
|
+
for (const c of this._children) if (c._name === name) return c;
|
|
240
|
+
if (recursive) for (const c of this._children) {
|
|
241
|
+
const found = c.findChild(name, true);
|
|
242
|
+
if (found) return found;
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
/** Topmost ancestor (the node itself when detached). */
|
|
247
|
+
getRoot() {
|
|
248
|
+
let n = this;
|
|
249
|
+
while (n._parent) n = n._parent;
|
|
250
|
+
return n;
|
|
251
|
+
}
|
|
252
|
+
/** Depth-first search of THIS subtree for the node carrying `uid`. */
|
|
253
|
+
getNodeByUid(uid) {
|
|
254
|
+
if (this.uid === uid) return this;
|
|
255
|
+
for (const child of this.children) {
|
|
256
|
+
const found = child.getNodeByUid(uid);
|
|
257
|
+
if (found) return found;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
/** Every node in THIS subtree named `name` — names repeat, so a list. */
|
|
262
|
+
getNodesByName(name) {
|
|
263
|
+
const out = [];
|
|
264
|
+
const walk = (node) => {
|
|
265
|
+
if (node.name === name) out.push(node);
|
|
266
|
+
for (const child of node.children) walk(child);
|
|
267
|
+
};
|
|
268
|
+
walk(this);
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
getPath() {
|
|
272
|
+
const parts = [];
|
|
273
|
+
for (let n = this; n; n = n._parent) parts.unshift(n._name);
|
|
274
|
+
return `/${parts.join("/")}`;
|
|
275
|
+
}
|
|
276
|
+
getNode(path) {
|
|
277
|
+
const found = this.resolve(path);
|
|
278
|
+
if (found) return found;
|
|
279
|
+
throw new IncantoError("NODE_NOT_FOUND", `No node at '${path}' from '${this.getPath()}'. Children here: [${this._children.map((c) => c._name).join(", ")}].`);
|
|
280
|
+
}
|
|
281
|
+
getNodeOrNull(path) {
|
|
282
|
+
return this.resolve(path);
|
|
283
|
+
}
|
|
284
|
+
resolve(path) {
|
|
285
|
+
const parsed = parseNodePath(path);
|
|
286
|
+
if (parsed.kind === "unique") {
|
|
287
|
+
const matches = [];
|
|
288
|
+
collectByName(this.getRoot(), parsed.name, matches);
|
|
289
|
+
if (matches.length > 1) throw new IncantoError("DUPLICATE_UNIQUE_NAME", `'%${parsed.name}' is ambiguous: ${matches.length} nodes named '${parsed.name}' (${matches.map((m) => m.getPath()).join(", ")}).`);
|
|
290
|
+
return matches[0] ?? null;
|
|
291
|
+
}
|
|
292
|
+
let current;
|
|
293
|
+
let segments = parsed.segments;
|
|
294
|
+
if (parsed.kind === "absolute") {
|
|
295
|
+
const root = this.getRoot();
|
|
296
|
+
if (segments[0] !== root._name && segments[0] !== "root") return null;
|
|
297
|
+
current = root;
|
|
298
|
+
segments = segments.slice(1);
|
|
299
|
+
} else current = this;
|
|
300
|
+
for (const seg of segments) {
|
|
301
|
+
if (!current) return null;
|
|
302
|
+
if (seg === "..") {
|
|
303
|
+
current = current._parent;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
current = current._children.find((c) => c._name === seg) ?? null;
|
|
307
|
+
}
|
|
308
|
+
return current;
|
|
309
|
+
}
|
|
310
|
+
addToGroup(group) {
|
|
311
|
+
this._groups.add(group);
|
|
312
|
+
}
|
|
313
|
+
removeFromGroup(group) {
|
|
314
|
+
this._groups.delete(group);
|
|
315
|
+
}
|
|
316
|
+
isInGroup(group) {
|
|
317
|
+
return this._groups.has(group);
|
|
318
|
+
}
|
|
319
|
+
declaredSignals() {
|
|
320
|
+
if (!this._declared) {
|
|
321
|
+
this._declared = /* @__PURE__ */ new Set();
|
|
322
|
+
let ctor = this.constructor;
|
|
323
|
+
while (typeof ctor === "function") {
|
|
324
|
+
const own = ctor;
|
|
325
|
+
if (Object.hasOwn(ctor, "signals") && own.signals) for (const s of own.signals) this._declared.add(s);
|
|
326
|
+
ctor = Object.getPrototypeOf(ctor);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return this._declared;
|
|
330
|
+
}
|
|
331
|
+
/** Declare an ad-hoc signal on THIS instance (static `signals` covers types). */
|
|
332
|
+
declareSignal(name) {
|
|
333
|
+
this.declaredSignals().add(name);
|
|
334
|
+
}
|
|
335
|
+
/** Every signal this instance may emit (static + behavior + ad-hoc). */
|
|
336
|
+
declaredSignalNames() {
|
|
337
|
+
return [...this.declaredSignals()];
|
|
338
|
+
}
|
|
339
|
+
assertDeclared(name) {
|
|
340
|
+
if (this.declaredSignals().has(name)) return;
|
|
341
|
+
const ctor = this.constructor;
|
|
342
|
+
const declared = [...this.declaredSignals()];
|
|
343
|
+
throw new IncantoError("UNKNOWN_SIGNAL", `Unknown signal '${name}' on '${this.getPath()}' (${ctor.typeName}). Declared signals: [${declared.join(", ")}]. Declare it with "static signals = ['${name}']" on the node class or its behavior, or call node.declareSignal('${name}').`, {
|
|
344
|
+
signal: name,
|
|
345
|
+
path: this.getPath(),
|
|
346
|
+
nodeType: ctor.typeName,
|
|
347
|
+
validOptions: declared
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** Get the named DECLARED signal (creating its Signal object on demand). */
|
|
351
|
+
signal(name) {
|
|
352
|
+
this.assertDeclared(name);
|
|
353
|
+
let sig = this._signals.get(name);
|
|
354
|
+
if (!sig) {
|
|
355
|
+
sig = new Signal();
|
|
356
|
+
this._signals.set(name, sig);
|
|
357
|
+
}
|
|
358
|
+
return sig;
|
|
359
|
+
}
|
|
360
|
+
on(signal, fn, opts) {
|
|
361
|
+
return this.signal(signal).connect(fn, opts);
|
|
362
|
+
}
|
|
363
|
+
off(signal, fn) {
|
|
364
|
+
this._signals.get(signal)?.disconnect(fn);
|
|
365
|
+
}
|
|
366
|
+
emit(signal, ...args) {
|
|
367
|
+
this.assertDeclared(signal);
|
|
368
|
+
this._signals.get(signal)?.emit(...args);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Defer destruction to the end of the current update pass (flushed by the
|
|
372
|
+
* SceneTree). Frees immediately when detached from any tree.
|
|
373
|
+
*/
|
|
374
|
+
queueFree() {
|
|
375
|
+
if (this._tree) this._tree._queueFree(this);
|
|
376
|
+
else this.free();
|
|
377
|
+
}
|
|
378
|
+
/** Immediately detach and tear down this node and its children. */
|
|
379
|
+
free() {
|
|
380
|
+
const tree = this._tree;
|
|
381
|
+
for (const child of [...this._children]) child.free();
|
|
382
|
+
this._parent?.removeChild(this);
|
|
383
|
+
if (this._tree) this._propagateExitTree();
|
|
384
|
+
tree?._detachRoot(this);
|
|
385
|
+
for (const sig of this._signals.values()) sig.disconnectAll();
|
|
386
|
+
this._signals.clear();
|
|
387
|
+
}
|
|
388
|
+
/** @internal */
|
|
389
|
+
_propagateEnterTree(tree) {
|
|
390
|
+
this._tree = tree;
|
|
391
|
+
this.onEnterTree();
|
|
392
|
+
this.behavior?.onEnterTree?.();
|
|
393
|
+
for (const c of [...this._children]) if (c._tree !== tree) c._propagateEnterTree(tree);
|
|
394
|
+
}
|
|
395
|
+
/** @internal */
|
|
396
|
+
_propagateReady() {
|
|
397
|
+
for (const c of [...this._children]) if (c._tree) c._propagateReady();
|
|
398
|
+
if (!this._ready) {
|
|
399
|
+
this._ready = true;
|
|
400
|
+
this.onReady();
|
|
401
|
+
this.behavior?.onReady?.();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** @internal */
|
|
405
|
+
_propagateExitTree() {
|
|
406
|
+
for (const c of [...this._children]) c._propagateExitTree();
|
|
407
|
+
this.onExitTree();
|
|
408
|
+
this.behavior?.onExitTree?.();
|
|
409
|
+
this._tree = null;
|
|
410
|
+
}
|
|
411
|
+
/** @internal */
|
|
412
|
+
_propagateUpdate(dt) {
|
|
413
|
+
this.update(dt);
|
|
414
|
+
this.behavior?.update?.(dt);
|
|
415
|
+
const kids = this._children;
|
|
416
|
+
for (let i = 0, n = kids.length; i < n; i++) kids[i]?._propagateUpdate(dt);
|
|
417
|
+
}
|
|
418
|
+
/** @internal */
|
|
419
|
+
_propagateFixedUpdate(dt) {
|
|
420
|
+
this.fixedUpdate(dt);
|
|
421
|
+
this.behavior?.fixedUpdate?.(dt);
|
|
422
|
+
const kids = this._children;
|
|
423
|
+
for (let i = 0, n = kids.length; i < n; i++) kids[i]?._propagateFixedUpdate(dt);
|
|
424
|
+
}
|
|
425
|
+
onEnterTree() {}
|
|
426
|
+
onReady() {}
|
|
427
|
+
onExitTree() {}
|
|
428
|
+
update(_dt) {}
|
|
429
|
+
fixedUpdate(_dt) {}
|
|
430
|
+
};
|
|
431
|
+
function collectByName(node, name, out) {
|
|
432
|
+
if (node.name === name) out.push(node);
|
|
433
|
+
for (const c of node.children) collectByName(c, name, out);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Deterministic sibling auto-rename: keep the requested name when free,
|
|
437
|
+
* otherwise increment its trailing number ('Enemy' → 'Enemy2', 'Enemy2' → 'Enemy3').
|
|
438
|
+
*/
|
|
439
|
+
function uniqueSiblingName(requested, siblings, self) {
|
|
440
|
+
const taken = new Set(siblings.filter((s) => s !== self).map((s) => s.name));
|
|
441
|
+
if (!taken.has(requested)) return requested;
|
|
442
|
+
const m = requested.match(/^(.*?)(\d+)$/);
|
|
443
|
+
const stem = m ? m[1] : requested;
|
|
444
|
+
let counter = m ? Number(m[2]) + 1 : 2;
|
|
445
|
+
while (taken.has(`${stem}${counter}`)) counter += 1;
|
|
446
|
+
return `${stem}${counter}`;
|
|
447
|
+
}
|
|
448
|
+
//#endregion
|
|
449
|
+
//#region src/core/scene/constants.ts
|
|
450
|
+
/**
|
|
451
|
+
* Named scene CONSTANTS — a scene-level table of reusable typed values
|
|
452
|
+
* (`scene.constants`) that node props may reference instead of hard-coding a
|
|
453
|
+
* literal. A prop value of `{"@const": "NAME"}` is replaced, AT LOAD, with the
|
|
454
|
+
* constant's literal value before the node is built — so nodes never see a
|
|
455
|
+
* reference, only the resolved value (mirrors how assets stay simple, but
|
|
456
|
+
* resolved eagerly because a prop's value must match its declared type).
|
|
457
|
+
*
|
|
458
|
+
* Authoring tools keep the `{"@const": …}` reference in their own JSON; only the
|
|
459
|
+
* runtime resolves it. Constant values are FINAL literals — a constant must not
|
|
460
|
+
* reference another constant (keep the table flat).
|
|
461
|
+
*
|
|
462
|
+
* Pure + three-free; lives in core so the loader and any tool can share it.
|
|
463
|
+
*/
|
|
464
|
+
/** The single reserved key marking a prop value as a constant reference. */
|
|
465
|
+
const CONST_REF_KEY = "@const";
|
|
466
|
+
/** A `{"@const": "NAME"}` reference — exactly that one string key, nothing else. */
|
|
467
|
+
function isConstRef(value) {
|
|
468
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
469
|
+
const keys = Object.keys(value);
|
|
470
|
+
return keys.length === 1 && keys[0] === "@const" && typeof value["@const"] === "string";
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Deep-resolve every `{"@const": "NAME"}` reference in `value` to its literal
|
|
474
|
+
* from `constants` (cloned, so the table is never aliased into node state).
|
|
475
|
+
* Unknown names hard-fail — agents self-correct on hard errors.
|
|
476
|
+
*/
|
|
477
|
+
function resolveConstants(value, constants) {
|
|
478
|
+
if (value === void 0 || value === null) return value;
|
|
479
|
+
if (isConstRef(value)) {
|
|
480
|
+
const name = value[CONST_REF_KEY];
|
|
481
|
+
if (!constants || !(name in constants)) {
|
|
482
|
+
const declared = constants ? Object.keys(constants) : [];
|
|
483
|
+
throw new IncantoError("UNKNOWN_CONSTANT", `Unknown constant '@const: ${name}'. Declared constants: ${declared.length ? declared.join(", ") : "(none — add a \"constants\" section)"}.`);
|
|
484
|
+
}
|
|
485
|
+
return jsonClone(constants[name]);
|
|
486
|
+
}
|
|
487
|
+
if (Array.isArray(value)) return value.map((item) => resolveConstants(item, constants));
|
|
488
|
+
if (typeof value === "object") {
|
|
489
|
+
const out = {};
|
|
490
|
+
for (const [k, v] of Object.entries(value)) out[k] = resolveConstants(v, constants);
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
return value;
|
|
494
|
+
}
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/core/scene-tree.ts
|
|
497
|
+
/**
|
|
498
|
+
* Owns a node tree and drives its lifecycle:
|
|
499
|
+
*
|
|
500
|
+
* - `setRoot` → onEnterTree parent-first, then onReady children-first (once per instance)
|
|
501
|
+
* - `update`/`fixedUpdate` → parent-first traversal, then flush of queued frees
|
|
502
|
+
* - group queries across attached nodes
|
|
503
|
+
*
|
|
504
|
+
* Headless by design — tests step it manually; the render loop (M2) calls it.
|
|
505
|
+
*/
|
|
506
|
+
var SceneTree = class {
|
|
507
|
+
_root = null;
|
|
508
|
+
_engine = null;
|
|
509
|
+
freeQueue = /* @__PURE__ */ new Set();
|
|
510
|
+
_frameId = 0;
|
|
511
|
+
/**
|
|
512
|
+
* Monotonic update-frame counter — bumped once per `update()`. Per-frame
|
|
513
|
+
* caches (e.g. the 3d grass-bender body gather) key off this so they rebuild
|
|
514
|
+
* at most once a frame regardless of node visitation order.
|
|
515
|
+
*/
|
|
516
|
+
get frameId() {
|
|
517
|
+
return this._frameId;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* @internal Opaque per-frame scratch the 3d layer parks its shared moving-body
|
|
521
|
+
* snapshot on (kept here so it is per-tree-instance, never a module global —
|
|
522
|
+
* core stays three-free, the 3d resolver owns the shape).
|
|
523
|
+
*/
|
|
524
|
+
_frameScratch = null;
|
|
525
|
+
get root() {
|
|
526
|
+
return this._root;
|
|
527
|
+
}
|
|
528
|
+
/** The engine driving this tree (set by Engine.setScene), or null. */
|
|
529
|
+
get engine() {
|
|
530
|
+
return this._engine;
|
|
531
|
+
}
|
|
532
|
+
/** @internal */
|
|
533
|
+
_setEngine(engine) {
|
|
534
|
+
this._engine = engine;
|
|
535
|
+
}
|
|
536
|
+
setRoot(node) {
|
|
537
|
+
if (this._root) throw new IncantoError("TREE_VIOLATION", "SceneTree already has a root.");
|
|
538
|
+
if (node.parent) throw new IncantoError("TREE_VIOLATION", `Root must not have a parent ('${node.name}' is a child of '${node.parent.name}').`);
|
|
539
|
+
this._root = node;
|
|
540
|
+
node._propagateEnterTree(this);
|
|
541
|
+
node._propagateReady();
|
|
542
|
+
}
|
|
543
|
+
update(dt) {
|
|
544
|
+
this._frameId++;
|
|
545
|
+
this._root?._propagateUpdate(dt);
|
|
546
|
+
this.flushFreeQueue();
|
|
547
|
+
}
|
|
548
|
+
fixedUpdate(dt) {
|
|
549
|
+
this._root?._propagateFixedUpdate(dt);
|
|
550
|
+
this.flushFreeQueue();
|
|
551
|
+
}
|
|
552
|
+
getNodesInGroup(group) {
|
|
553
|
+
const out = [];
|
|
554
|
+
if (this._root) collectGroup(this._root, group, out);
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
/** Call `method(...args)` on every group member that implements it. */
|
|
558
|
+
callGroup(group, method, ...args) {
|
|
559
|
+
for (const node of this.getNodesInGroup(group)) {
|
|
560
|
+
const fn = node[method];
|
|
561
|
+
if (typeof fn === "function") fn.apply(node, args);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** @internal */
|
|
565
|
+
_queueFree(node) {
|
|
566
|
+
this.freeQueue.add(node);
|
|
567
|
+
}
|
|
568
|
+
/** @internal Called by Node.free(): a freed root must not be re-drivable. */
|
|
569
|
+
_detachRoot(node) {
|
|
570
|
+
if (this._root === node) this._root = null;
|
|
571
|
+
}
|
|
572
|
+
flushFreeQueue() {
|
|
573
|
+
if (this.freeQueue.size === 0) return;
|
|
574
|
+
const items = [...this.freeQueue];
|
|
575
|
+
this.freeQueue.clear();
|
|
576
|
+
for (const node of items) node.free();
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
function collectGroup(node, group, out) {
|
|
580
|
+
if (node.isInGroup(group)) out.push(node);
|
|
581
|
+
for (const c of node.children) collectGroup(c, group, out);
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region src/core/viewport.ts
|
|
585
|
+
const FITS = [
|
|
586
|
+
"expand",
|
|
587
|
+
"letterbox",
|
|
588
|
+
"integer"
|
|
589
|
+
];
|
|
590
|
+
/**
|
|
591
|
+
* Parse + hard-validate a scene's `viewport` header. With a design resolution,
|
|
592
|
+
* scene JSON owns layout again: the game is authored in fixed design pixels
|
|
593
|
+
* and the renderer maps them onto whatever canvas size the page provides.
|
|
594
|
+
*/
|
|
595
|
+
function resolveViewport(viewport) {
|
|
596
|
+
if (viewport === void 0) return null;
|
|
597
|
+
const design = viewport.design;
|
|
598
|
+
if (!Array.isArray(design) || design.length !== 2 || typeof design[0] !== "number" || typeof design[1] !== "number" || design[0] <= 0 || design[1] <= 0) throw new IncantoError("BAD_FORMAT", `Scene "viewport.design" must be [width, height] with positive numbers, got ${JSON.stringify(design)}.`);
|
|
599
|
+
const fit = viewport.fit ?? "expand";
|
|
600
|
+
if (typeof fit !== "string" || !FITS.includes(fit)) throw new IncantoError("BAD_FORMAT", `Scene "viewport.fit" must be one of [${FITS.join(", ")}], got ${JSON.stringify(fit)}.`);
|
|
601
|
+
return {
|
|
602
|
+
design: [design[0], design[1]],
|
|
603
|
+
fit
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Pure viewport math (headless-testable):
|
|
608
|
+
* - expand: the design rect is always fully visible; extra world shows beyond it
|
|
609
|
+
* - letterbox: EXACTLY the design rect, centered, bars elsewhere
|
|
610
|
+
* - integer: expand with whole-number scaling (pixel art), floored, min 1
|
|
611
|
+
*/
|
|
612
|
+
function computeViewport(canvasW, canvasH, viewport) {
|
|
613
|
+
const dw = viewport.design[0];
|
|
614
|
+
const dh = viewport.design[1];
|
|
615
|
+
const raw = Math.min(canvasW / dw, canvasH / dh);
|
|
616
|
+
const scale = viewport.fit === "integer" ? Math.max(1, Math.floor(raw)) : raw;
|
|
617
|
+
if (viewport.fit === "letterbox") {
|
|
618
|
+
const contentW = dw * scale;
|
|
619
|
+
const contentH = dh * scale;
|
|
620
|
+
return {
|
|
621
|
+
scale,
|
|
622
|
+
viewW: dw,
|
|
623
|
+
viewH: dh,
|
|
624
|
+
offsetX: (canvasW - contentW) / 2,
|
|
625
|
+
offsetY: (canvasH - contentH) / 2,
|
|
626
|
+
contentW,
|
|
627
|
+
contentH
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
scale,
|
|
632
|
+
viewW: canvasW / scale,
|
|
633
|
+
viewH: canvasH / scale,
|
|
634
|
+
offsetX: 0,
|
|
635
|
+
offsetY: 0,
|
|
636
|
+
contentW: canvasW,
|
|
637
|
+
contentH: canvasH
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/core/scene/schema.ts
|
|
642
|
+
/** Current scene file format version. Defaults are frozen per format version. */
|
|
643
|
+
const SCENE_FORMAT = 1;
|
|
644
|
+
//#endregion
|
|
645
|
+
//#region src/core/scene/serializer.ts
|
|
646
|
+
/**
|
|
647
|
+
* Serialize a node subtree to scene JSON with delta-only props: values equal
|
|
648
|
+
* to the type's schema defaults are omitted (Godot PackedScene behavior —
|
|
649
|
+
* files stay tiny and AI-readable).
|
|
650
|
+
*/
|
|
651
|
+
function serializeNode(node) {
|
|
652
|
+
const ctor = node.constructor;
|
|
653
|
+
const json = {
|
|
654
|
+
name: node.name,
|
|
655
|
+
type: ctor.typeName
|
|
656
|
+
};
|
|
657
|
+
if (node.uid !== null) json.uid = node.uid;
|
|
658
|
+
if (node.groups.size > 0) json.groups = [...node.groups];
|
|
659
|
+
if (Object.keys(node.tags).length > 0) json.tags = jsonClone(node.tags);
|
|
660
|
+
const schema = getNodeSchema(ctor.typeName);
|
|
661
|
+
const props = {};
|
|
662
|
+
for (const [key, def] of Object.entries(schema)) {
|
|
663
|
+
const value = node[key];
|
|
664
|
+
if (value === void 0) continue;
|
|
665
|
+
assertJsonSafe(value, `${node.name}.${key}`);
|
|
666
|
+
if (!jsonEquals(value, def.default)) props[key] = jsonClone(value);
|
|
667
|
+
}
|
|
668
|
+
if (Object.keys(props).length > 0) json.props = props;
|
|
669
|
+
if (node.script) json.script = jsonClone(node.script);
|
|
670
|
+
if (node.network) json.network = jsonClone(node.network);
|
|
671
|
+
if (node.children.length > 0) json.children = node.children.map((c) => serializeNode(c));
|
|
672
|
+
return json;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* NaN/Infinity silently become `null` in JSON.stringify — corrupting saved
|
|
676
|
+
* scenes. Fail loudly at serialize time instead.
|
|
677
|
+
*/
|
|
678
|
+
function assertJsonSafe(value, at) {
|
|
679
|
+
if (typeof value === "number" && !Number.isFinite(value)) throw new IncantoError("PROP_TYPE_MISMATCH", `Prop '${at}' is ${value} — scene props must be finite numbers (NaN/Infinity become null in JSON).`);
|
|
680
|
+
if (Array.isArray(value)) for (let i = 0; i < value.length; i++) assertJsonSafe(value[i], `${at}[${i}]`);
|
|
681
|
+
else if (value !== null && typeof value === "object") for (const [k, v] of Object.entries(value)) assertJsonSafe(v, `${at}.${k}`);
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/core/scene/scene.ts
|
|
685
|
+
/**
|
|
686
|
+
* A loaded, live scene: the node tree plus everything needed to round-trip
|
|
687
|
+
* back to scene JSON (declared connections, asset/input/multiplayer blobs).
|
|
688
|
+
*
|
|
689
|
+
* JSON is the source of truth for STRUCTURE; imperative listeners added via
|
|
690
|
+
* `node.on(...)` are code, not data, and intentionally do not serialize.
|
|
691
|
+
*/
|
|
692
|
+
var Scene = class {
|
|
693
|
+
name;
|
|
694
|
+
dimension;
|
|
695
|
+
root;
|
|
696
|
+
tree;
|
|
697
|
+
/** Scene-level rendering environment (read by renderer adapters). */
|
|
698
|
+
environment;
|
|
699
|
+
/** Asset declarations (consumed by renderer/asset layers, `$key` references). */
|
|
700
|
+
assets;
|
|
701
|
+
/** Named constant values (`{"@const": "NAME"}` prop references resolve to these). */
|
|
702
|
+
constants;
|
|
703
|
+
/** Input action declarations (loaded into `Engine.input` on setScene). */
|
|
704
|
+
input;
|
|
705
|
+
/** Physics config (gravity etc. — interpreted by the physics modules). */
|
|
706
|
+
physics;
|
|
707
|
+
/** Multiplayer config (room etc. — interpreted by incanto/net). */
|
|
708
|
+
multiplayer;
|
|
709
|
+
/** Design-resolution viewport (consumed by renderers via resolveViewport). */
|
|
710
|
+
viewport;
|
|
711
|
+
connections;
|
|
712
|
+
constructor(source, root, tree) {
|
|
713
|
+
this.name = source.name;
|
|
714
|
+
this.dimension = source.dimension;
|
|
715
|
+
this.root = root;
|
|
716
|
+
this.tree = tree;
|
|
717
|
+
this.assets = source.assets ? jsonClone(source.assets) : void 0;
|
|
718
|
+
this.constants = source.constants ? jsonClone(source.constants) : void 0;
|
|
719
|
+
this.input = source.input ? jsonClone(source.input) : void 0;
|
|
720
|
+
this.physics = source.physics ? jsonClone(source.physics) : void 0;
|
|
721
|
+
this.multiplayer = source.multiplayer ? jsonClone(source.multiplayer) : void 0;
|
|
722
|
+
this.environment = source.environment ? jsonClone(source.environment) : void 0;
|
|
723
|
+
this.viewport = source.viewport ? jsonClone(source.viewport) : void 0;
|
|
724
|
+
this.connections = source.connections ? jsonClone(source.connections) : [];
|
|
725
|
+
}
|
|
726
|
+
/** Lossless export. Note: expanded sub-scene instances serialize as full trees in M1. */
|
|
727
|
+
toJSON() {
|
|
728
|
+
return {
|
|
729
|
+
format: 1,
|
|
730
|
+
type: "scene",
|
|
731
|
+
name: this.name,
|
|
732
|
+
...this.dimension ? { dimension: this.dimension } : {},
|
|
733
|
+
...this.assets ? { assets: jsonClone(this.assets) } : {},
|
|
734
|
+
...this.constants ? { constants: jsonClone(this.constants) } : {},
|
|
735
|
+
...this.input ? { input: jsonClone(this.input) } : {},
|
|
736
|
+
...this.physics ? { physics: jsonClone(this.physics) } : {},
|
|
737
|
+
...this.multiplayer ? { multiplayer: jsonClone(this.multiplayer) } : {},
|
|
738
|
+
...this.environment ? { environment: jsonClone(this.environment) } : {},
|
|
739
|
+
...this.viewport ? { viewport: jsonClone(this.viewport) } : {},
|
|
740
|
+
root: serializeNode(this.root),
|
|
741
|
+
...this.connections.length > 0 ? { connections: jsonClone(this.connections) } : {}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region src/core/scene/loader.ts
|
|
747
|
+
/**
|
|
748
|
+
* Build a live Scene from scene JSON.
|
|
749
|
+
*
|
|
750
|
+
* Takes `unknown` on purpose: the loader hard-validates everything at runtime,
|
|
751
|
+
* so consumers can pass an imported JSON module without TypeScript casts.
|
|
752
|
+
*
|
|
753
|
+
* Order: validate header → instantiate nodes top-down → attach to a SceneTree
|
|
754
|
+
* (enterTree/ready fire) → wire connections LAST. Every failure is a hard
|
|
755
|
+
* IncantoError naming valid alternatives — agents self-correct on hard errors.
|
|
756
|
+
*/
|
|
757
|
+
function loadScene(json, opts) {
|
|
758
|
+
if (typeof json !== "object" || json === null) throw new IncantoError("BAD_FORMAT", `Scene JSON must be an object, got ${json === null ? "null" : typeof json}.`);
|
|
759
|
+
const sceneJson = json;
|
|
760
|
+
validateHeader(sceneJson);
|
|
761
|
+
const root = buildNode(sceneJson.root, opts, [], "", sceneJson.constants);
|
|
762
|
+
validateUniqueUids(root);
|
|
763
|
+
const tree = new SceneTree();
|
|
764
|
+
if (opts?.engine) tree._setEngine(opts.engine);
|
|
765
|
+
tree.setRoot(root);
|
|
766
|
+
wireConnections(root, sceneJson.connections ?? [], opts?.declareConnectionSignals);
|
|
767
|
+
return new Scene(sceneJson, root, tree);
|
|
768
|
+
}
|
|
769
|
+
/** @internal Rebuild a detached node subtree from node JSON (used by duplicateNode).
|
|
770
|
+
* The source comes from a live node's serialized props, which are already resolved
|
|
771
|
+
* literals — no constants table needed. */
|
|
772
|
+
function buildNodeJson(json, opts) {
|
|
773
|
+
return buildNode(json, opts, [], "", void 0);
|
|
774
|
+
}
|
|
775
|
+
function validateHeader(json) {
|
|
776
|
+
if (json.format !== 1) throw new IncantoError("BAD_FORMAT", `Unsupported scene format ${JSON.stringify(json.format)}; this engine reads format 1.`);
|
|
777
|
+
if (json.type !== "scene") throw new IncantoError("BAD_FORMAT", `Expected "type": "scene", got ${JSON.stringify(json.type)}.`);
|
|
778
|
+
if (typeof json.name !== "string" || json.name === "") throw new IncantoError("BAD_FORMAT", "Scene \"name\" must be a non-empty string.");
|
|
779
|
+
if (json.dimension !== void 0 && json.dimension !== "2d" && json.dimension !== "3d") throw new IncantoError("BAD_FORMAT", `Scene "dimension" must be "2d" or "3d", got ${JSON.stringify(json.dimension)}.`);
|
|
780
|
+
if (typeof json.root !== "object" || json.root === null) throw new IncantoError("BAD_FORMAT", "Scene \"root\" must be a node object.");
|
|
781
|
+
resolveViewport(json.viewport);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Re-raise a node-build error with the node's absolute scene path attached —
|
|
785
|
+
* "Unknown prop 'x' on 'Sprite2D'" alone cannot locate the node among forty
|
|
786
|
+
* Sprite2Ds; "(at '/Level/Enemies/Slime3')" can. Inner (deeper) contexts win.
|
|
787
|
+
*/
|
|
788
|
+
function withNodeContext(e, path, nj) {
|
|
789
|
+
if (e instanceof IncantoError && !e.details.path) return new IncantoError(e.code, `${e.message} (at '${path}')`, {
|
|
790
|
+
...e.details,
|
|
791
|
+
path,
|
|
792
|
+
...typeof nj.uid === "string" && nj.uid !== "" ? { uid: nj.uid } : {},
|
|
793
|
+
...e.details.nodeType === void 0 && nj.type ? { nodeType: nj.type } : {}
|
|
794
|
+
});
|
|
795
|
+
return e;
|
|
796
|
+
}
|
|
797
|
+
function buildNode(nj, opts, stack, parentPath, constants) {
|
|
798
|
+
const path = `${parentPath}/${nj.name}`;
|
|
799
|
+
try {
|
|
800
|
+
if (nj.instance !== void 0 && nj.type !== void 0) throw new IncantoError("BAD_FORMAT", `Node '${nj.name}' declares both "type" and "instance" — they are mutually exclusive.`);
|
|
801
|
+
if (nj.instance !== void 0) return buildInstance(nj, opts, stack, path, constants);
|
|
802
|
+
if (nj.type === void 0) throw new IncantoError("BAD_FORMAT", `Node '${nj.name}' must declare either "type" or "instance".`);
|
|
803
|
+
const node = createNode(nj.type, resolveConstants(nj.props, constants));
|
|
804
|
+
applyCommon(node, nj, opts);
|
|
805
|
+
node.constructor.validateJson?.(node);
|
|
806
|
+
for (const childJson of nj.children ?? []) node.addChild(buildNode(childJson, opts, stack, path, constants));
|
|
807
|
+
return node;
|
|
808
|
+
} catch (e) {
|
|
809
|
+
throw withNodeContext(e, path, nj);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
function buildInstance(nj, opts, stack, path, constants) {
|
|
813
|
+
const instancePath = nj.instance;
|
|
814
|
+
if (!opts?.resolveScene) throw new IncantoError("UNRESOLVED_INSTANCE", `Node '${nj.name}' references instance '${instancePath}' but no resolveScene option was provided.`);
|
|
815
|
+
if (stack.includes(instancePath)) throw new IncantoError("UNRESOLVED_INSTANCE", `Instance cycle detected: ${[...stack, instancePath].join(" → ")}.`);
|
|
816
|
+
const sub = opts.resolveScene(instancePath);
|
|
817
|
+
if (!sub) throw new IncantoError("UNRESOLVED_INSTANCE", `resolveScene returned nothing for instance '${instancePath}' (node '${nj.name}').`);
|
|
818
|
+
validateHeader(sub);
|
|
819
|
+
const overrides = resolveConstants(nj.overrides ?? {}, constants);
|
|
820
|
+
const node = buildNode({
|
|
821
|
+
...sub.root,
|
|
822
|
+
name: nj.name,
|
|
823
|
+
props: deepMerge(sub.root.props ?? {}, overrides)
|
|
824
|
+
}, opts, [...stack, instancePath], parentOf(path), sub.constants);
|
|
825
|
+
applyCommon(node, {
|
|
826
|
+
...nj,
|
|
827
|
+
type: void 0,
|
|
828
|
+
props: void 0
|
|
829
|
+
}, opts);
|
|
830
|
+
for (const childJson of nj.children ?? []) node.addChild(buildNode(childJson, opts, stack, path, constants));
|
|
831
|
+
wireConnections(node, sub.connections ?? [], opts?.declareConnectionSignals);
|
|
832
|
+
return node;
|
|
833
|
+
}
|
|
834
|
+
function parentOf(path) {
|
|
835
|
+
const i = path.lastIndexOf("/");
|
|
836
|
+
return i <= 0 ? "" : path.slice(0, i);
|
|
837
|
+
}
|
|
838
|
+
function applyCommon(node, nj, opts) {
|
|
839
|
+
if (typeof nj.uid === "string" && nj.uid !== "") node.uid = nj.uid;
|
|
840
|
+
node.name = nj.name;
|
|
841
|
+
for (const g of nj.groups ?? []) node.addToGroup(g);
|
|
842
|
+
if (nj.tags) node.tags = jsonClone(nj.tags);
|
|
843
|
+
if (nj.script) {
|
|
844
|
+
node.script = jsonClone(nj.script);
|
|
845
|
+
const name = nj.script.name;
|
|
846
|
+
if (typeof name !== "string" || name === "") throw new IncantoError("BAD_FORMAT", `Node '${nj.name}': "script" needs a non-empty "name" (a registered behavior).`);
|
|
847
|
+
const behavior = opts?.stubMissingBehaviors && !hasBehavior(name) ? new class extends StubBehavior {}() : createBehavior(name, nj.script.props);
|
|
848
|
+
behavior.node = node;
|
|
849
|
+
node.behavior = behavior;
|
|
850
|
+
for (const s of mergeStaticSignals(behavior.constructor)) node.declareSignal(s);
|
|
851
|
+
}
|
|
852
|
+
if (nj.network) node.network = jsonClone(nj.network);
|
|
853
|
+
}
|
|
854
|
+
function deepMerge(base, override) {
|
|
855
|
+
const out = { ...base };
|
|
856
|
+
for (const [key, value] of Object.entries(override)) {
|
|
857
|
+
const prev = out[key];
|
|
858
|
+
if (prev !== null && value !== null && typeof prev === "object" && typeof value === "object" && !Array.isArray(prev) && !Array.isArray(value)) out[key] = deepMerge(prev, value);
|
|
859
|
+
else out[key] = value;
|
|
860
|
+
}
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
function wireConnections(root, connections, declareSignals = false) {
|
|
864
|
+
for (const conn of connections) {
|
|
865
|
+
const fromNode = resolveEndpoint(root, conn.from, conn, "from");
|
|
866
|
+
if (declareSignals || fromNode.behavior instanceof StubBehavior) fromNode.declareSignal(conn.signal);
|
|
867
|
+
const toNode = resolveEndpoint(root, conn.to, conn, "to");
|
|
868
|
+
const nodeFn = toNode[conn.handler];
|
|
869
|
+
const behaviorFn = toNode.behavior ? toNode.behavior[conn.handler] : void 0;
|
|
870
|
+
if (typeof nodeFn !== "function" && typeof behaviorFn !== "function" && !(toNode.behavior instanceof StubBehavior)) throw new IncantoError("UNKNOWN_HANDLER", `Connection '${conn.signal}' → '${conn.to}' names handler '${conn.handler}', but '${toNode.name}' has no such method` + (toNode.behavior ? ` (nor does its behavior '${toNode.script?.name}')` : "") + `.`);
|
|
871
|
+
const listener = (...args) => {
|
|
872
|
+
if (conn.filter && !matchesFilter(args[0], conn.filter)) return;
|
|
873
|
+
if (conn.once) fromNode.off(conn.signal, listener);
|
|
874
|
+
const fn = toNode[conn.handler];
|
|
875
|
+
if (typeof fn === "function") {
|
|
876
|
+
fn.apply(toNode, args);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const bfn = toNode.behavior ? toNode.behavior[conn.handler] : void 0;
|
|
880
|
+
if (typeof bfn === "function") bfn.apply(toNode.behavior, args);
|
|
881
|
+
};
|
|
882
|
+
try {
|
|
883
|
+
fromNode.on(conn.signal, listener);
|
|
884
|
+
} catch (e) {
|
|
885
|
+
if (e instanceof IncantoError && e.code === "UNKNOWN_SIGNAL") throw new IncantoError("UNKNOWN_SIGNAL", `Connection from '${conn.from}': ${e.message}`, e.details);
|
|
886
|
+
throw e;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function resolveEndpoint(root, path, conn, side) {
|
|
891
|
+
try {
|
|
892
|
+
return root.getNode(path);
|
|
893
|
+
} catch (e) {
|
|
894
|
+
if (e instanceof IncantoError) throw new IncantoError("DANGLING_CONNECTION", `Connection '${conn.signal}' has a dangling '${side}' path '${path}': ${e.message}`);
|
|
895
|
+
throw e;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
function matchesFilter(arg, filter) {
|
|
899
|
+
if (!(arg instanceof Node)) return false;
|
|
900
|
+
if (filter.group && !arg.isInGroup(filter.group)) return false;
|
|
901
|
+
if (filter.tag) {
|
|
902
|
+
for (const [key, value] of Object.entries(filter.tag)) if (JSON.stringify(arg.tags[key]) !== JSON.stringify(value)) return false;
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
function validateUniqueUids(root) {
|
|
907
|
+
const seen = /* @__PURE__ */ new Map();
|
|
908
|
+
const walk = (node) => {
|
|
909
|
+
if (node.uid !== null) {
|
|
910
|
+
const other = seen.get(node.uid);
|
|
911
|
+
if (other) throw new IncantoError("DUPLICATE_UID", `duplicate uid '${node.uid}' on ${other.getPath()} and ${node.getPath()} — uids must be unique across the scene.`);
|
|
912
|
+
seen.set(node.uid, node);
|
|
913
|
+
}
|
|
914
|
+
for (const child of node.children) walk(child);
|
|
915
|
+
};
|
|
916
|
+
walk(root);
|
|
917
|
+
}
|
|
918
|
+
//#endregion
|
|
919
|
+
export { registerBehavior as _, SCENE_FORMAT as a, SceneTree as c, resolveConstants as d, Node as f, getBehavior as g, clearBehaviors as h, serializeNode as i, CONST_REF_KEY as l, Behavior as m, loadScene as n, computeViewport as o, parseNodePath as p, Scene as r, resolveViewport as s, buildNodeJson as t, isConstRef as u, registeredBehaviors as v, Signal as y };
|