incanto 0.60.0 → 0.61.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/bin/incanto-check.mjs +13 -0
- package/dist/2d.d.ts +22 -3
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +5 -5
- package/dist/3d.js +5 -4
- package/dist/{pathfinding-BqWBb0kh.d.ts → audio-player-D5GJgb_x.d.ts} +103 -38
- package/dist/{behavior-DWKTUzKI.d.ts → behavior-DsgayMsH.d.ts} +79 -1
- package/dist/{create-game-ClnIb_M5.js → create-game-DH7JI5xx.js} +76 -11
- package/dist/{create-game-BCm38FJV.js → create-game-IX5lEH0P.js} +21 -294
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +1 -1
- package/dist/{duplicate-DJQd44CD.js → duplicate-CGqAmK2h.js} +1 -1
- package/dist/editor.js +2 -1
- package/dist/{environment-presets-8cjF3t6w.js → environment-presets-CNxCuhZF.js} +48 -13
- package/dist/{gameplay-BBEjPFsR.js → gameplay-Dtzd2itW.js} +53 -11
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +7 -7
- package/dist/{loader-D8n7TU8W.js → loader-D7jTvDQv.js} +797 -2
- package/dist/{loader-TvkRFbyL.d.ts → loader-DolLJWJn.d.ts} +13 -1
- package/dist/net.d.ts +2 -2
- package/dist/net.js +1 -1
- package/dist/pathfinding-_fGrCFmH.d.ts +28 -0
- package/dist/{physics-2d-DqdVp1bt.js → physics-2d-CBnor8Zf.js} +2 -2
- package/dist/{physics-3d-BP0DZb_1.js → physics-3d-BTUfUSWO.js} +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-BSu2dWGC.js → register-DL3izw8j.js} +46 -9
- package/dist/{register-Da3hXh2H.js → register-xuSRyD6b.js} +72 -16
- package/dist/{registry-WWcQcfMr.js → registry-CF70EArN.js} +55 -3
- package/dist/{replay-BCMK_VRP.d.ts → replay-C5x2vPF5.d.ts} +2 -2
- package/dist/{replay-BlNuIDdg.js → replay-D7-yle3s.js} +44 -197
- package/dist/{split-screen-CL5Yvxse.js → split-screen-5Ban4q4n.js} +3 -3
- package/dist/{split-screen-BQ3tAsf-.d.ts → split-screen-D7OopelJ.d.ts} +2 -2
- package/dist/{src-DFpXBMJN.js → src-DozXvyZS.js} +1 -1
- package/dist/{teardown-CTTwhWSe.js → teardown-B6rwJOyS.js} +1 -1
- package/dist/{test-BeZ95pqw.js → test-Dch_7VQD.js} +15 -14
- package/dist/test.d.ts +8 -4
- package/dist/test.js +2 -2
- package/dist/vite.js +2 -2
- package/editor/assets/{agent8-DSJries_.js → agent8-CF1JL2tR.js} +1 -1
- package/editor/assets/{debug-D15Wi5TO.js → debug-3QzYhOPA.js} +1 -1
- package/editor/assets/{index-BjC88k97.js → index-CAD2c5ug.js} +92 -92
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +4 -0
- package/skills/incanto-3d-models.md +1 -1
- package/skills/incanto-assets.md +10 -1
- package/skills/incanto-audio.md +21 -13
- package/skills/incanto-behaviors-and-scripts.md +7 -3
- package/skills/incanto-building-2d-games.md +12 -3
- package/skills/incanto-editor.md +1 -1
- package/skills/incanto-gameplay-behaviors.md +24 -1
- package/skills/incanto-hud.md +5 -3
- package/skills/incanto-localization.md +7 -0
- package/skills/incanto-node-reference.md +1 -0
- package/skills/incanto-scene-json-authoring.md +6 -1
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/dist/particle-sim-C5OfBbmU.d.ts +0 -77
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
2
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
3
3
|
import { n as jsonEquals, t as jsonClone } from "./json-CwwhxQgb.js";
|
|
4
|
-
import { c as mergeStaticSignals, i as getNodeSchema, r as createNode, s as mergeStaticProps, t as applySchemaProps } from "./registry-
|
|
4
|
+
import { c as mergeStaticSignals, i as getNodeSchema, o as getNodeType, r as createNode, s as mergeStaticProps, t as applySchemaProps } from "./registry-CF70EArN.js";
|
|
5
5
|
//#region src/core/signal.ts
|
|
6
6
|
var Signal = class {
|
|
7
7
|
connections = [];
|
|
@@ -166,6 +166,196 @@ function parseNodePath(path) {
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
//#endregion
|
|
169
|
+
//#region src/core/node-refs.ts
|
|
170
|
+
/**
|
|
171
|
+
* Node-path PROPS, resolved against scene JSON.
|
|
172
|
+
*
|
|
173
|
+
* `Chase.target`, `Camera2D.follow`, `Spawner.prefab`, `Joint3D.target`,
|
|
174
|
+
* `CharacterController3D.skinPath`, `Foliage3D.terrain` … each holds a path to
|
|
175
|
+
* another node, and nothing checked any of them. A connection whose end does not
|
|
176
|
+
* resolve is a hard `DANGLING_CONNECTION` at load; a PROP that does not resolve
|
|
177
|
+
* is nothing at all — `getNodeOrNull` returns null, the scene loads, the enemy
|
|
178
|
+
* never chases, and no error is ever printed.
|
|
179
|
+
*
|
|
180
|
+
* That makes it exactly the class `auditScene` exists for: loads fine, plays
|
|
181
|
+
* wrong. The editor uses the same resolver so the two cannot drift.
|
|
182
|
+
*
|
|
183
|
+
* The grammar comes from `parseNodePath` — the loader's own parser — so a path
|
|
184
|
+
* this module accepts is one the engine accepts.
|
|
185
|
+
*/
|
|
186
|
+
function nodeAt(root, path) {
|
|
187
|
+
let node = root;
|
|
188
|
+
for (const index of path) {
|
|
189
|
+
node = node?.children?.[index];
|
|
190
|
+
if (!node) return null;
|
|
191
|
+
}
|
|
192
|
+
return node ?? null;
|
|
193
|
+
}
|
|
194
|
+
function byName(root, name) {
|
|
195
|
+
const found = [];
|
|
196
|
+
const walk = (node, at) => {
|
|
197
|
+
if (node.name === name) found.push(at);
|
|
198
|
+
(node.children ?? []).forEach((child, i) => {
|
|
199
|
+
walk(child, [...at, i]);
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
walk(root, []);
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Resolve `value` from the node at `owner`, the way the engine would.
|
|
207
|
+
*
|
|
208
|
+
* Note the asymmetry with connections, whose paths resolve from the SCENE ROOT:
|
|
209
|
+
* a prop's path resolves from the node holding it, which is why `'../Skin'` is
|
|
210
|
+
* the documented default of `CharacterController3D.skinPath` and would mean
|
|
211
|
+
* nothing in a connection.
|
|
212
|
+
*/
|
|
213
|
+
function resolveRefInJson(root, owner, value) {
|
|
214
|
+
if (!root) return {
|
|
215
|
+
ok: false,
|
|
216
|
+
problem: {
|
|
217
|
+
kind: "noMatch",
|
|
218
|
+
name: value
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
let parsed;
|
|
222
|
+
try {
|
|
223
|
+
parsed = parseNodePath(value);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
problem: {
|
|
228
|
+
kind: "grammar",
|
|
229
|
+
message: error instanceof Error ? error.message : String(error)
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (parsed.kind === "unique") {
|
|
234
|
+
const found = byName(root, parsed.name);
|
|
235
|
+
if (found.length === 1) return {
|
|
236
|
+
ok: true,
|
|
237
|
+
path: found[0]
|
|
238
|
+
};
|
|
239
|
+
if (found.length === 0) return {
|
|
240
|
+
ok: false,
|
|
241
|
+
problem: {
|
|
242
|
+
kind: "noMatch",
|
|
243
|
+
name: parsed.name
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
problem: {
|
|
249
|
+
kind: "ambiguous",
|
|
250
|
+
name: parsed.name,
|
|
251
|
+
count: found.length
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
let at;
|
|
256
|
+
let segments = parsed.segments;
|
|
257
|
+
if (parsed.kind === "absolute") {
|
|
258
|
+
const first = segments[0] ?? "";
|
|
259
|
+
if (first !== root.name && first !== "root") return {
|
|
260
|
+
ok: false,
|
|
261
|
+
problem: {
|
|
262
|
+
kind: "wrongRoot",
|
|
263
|
+
first,
|
|
264
|
+
root: String(root.name ?? "")
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
at = [];
|
|
268
|
+
segments = segments.slice(1);
|
|
269
|
+
} else at = [...owner];
|
|
270
|
+
for (const segment of segments) {
|
|
271
|
+
if (segment === "..") {
|
|
272
|
+
if (at.length === 0) return {
|
|
273
|
+
ok: false,
|
|
274
|
+
problem: { kind: "aboveRoot" }
|
|
275
|
+
};
|
|
276
|
+
at.pop();
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const here = nodeAt(root, at);
|
|
280
|
+
const index = (here?.children ?? []).findIndex((child) => child.name === segment);
|
|
281
|
+
if (index < 0) return {
|
|
282
|
+
ok: false,
|
|
283
|
+
problem: {
|
|
284
|
+
kind: "noChild",
|
|
285
|
+
parent: String(here?.name ?? "?"),
|
|
286
|
+
segment
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
at.push(index);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
path: at
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** The problem in English, for `incanto-check` and as the editor's base text. */
|
|
297
|
+
function describeRefProblem(problem, value) {
|
|
298
|
+
switch (problem.kind) {
|
|
299
|
+
case "grammar": return problem.message;
|
|
300
|
+
case "noMatch": return `'%${problem.name}' matches no node in this scene.`;
|
|
301
|
+
case "ambiguous": return `'%${problem.name}' matches ${problem.count} nodes — a %name must be unique.`;
|
|
302
|
+
case "wrongRoot": return `'${value}' starts at '${problem.first}', but the scene root is '${problem.root}' (or write '/root/…').`;
|
|
303
|
+
case "noChild": return `'${value}' leads nowhere — '${problem.parent}' has no child named '${problem.segment}'.`;
|
|
304
|
+
case "aboveRoot": return `'${value}' walks above the scene root.`;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** Every prop on this node declared `nodePath: true`, per bag. */
|
|
308
|
+
function refProps(node) {
|
|
309
|
+
const collect = (schema) => Object.entries(schema).filter(([, def]) => def?.nodePath).map(([key]) => key);
|
|
310
|
+
let props = [];
|
|
311
|
+
if (node.type) try {
|
|
312
|
+
props = collect(getNodeSchema(node.type));
|
|
313
|
+
} catch {}
|
|
314
|
+
const name = node.script?.name;
|
|
315
|
+
return {
|
|
316
|
+
props,
|
|
317
|
+
script: name ? collect(behaviorSchema(name)) : []
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Scene lints for node-path props that point at nothing.
|
|
322
|
+
*
|
|
323
|
+
* An EMPTY value is not reported: `''` is the default of most of these props and
|
|
324
|
+
* means "not set", which is a choice rather than a typo.
|
|
325
|
+
*/
|
|
326
|
+
function nodeRefWarnings(scene) {
|
|
327
|
+
const root = scene.root;
|
|
328
|
+
if (!root) return [];
|
|
329
|
+
const warnings = [];
|
|
330
|
+
const walk = (node, at, path) => {
|
|
331
|
+
const keys = refProps(node);
|
|
332
|
+
const bags = [[
|
|
333
|
+
node.props,
|
|
334
|
+
keys.props,
|
|
335
|
+
""
|
|
336
|
+
], [
|
|
337
|
+
node.script?.props,
|
|
338
|
+
keys.script,
|
|
339
|
+
`${node.script?.name ?? "script"}.`
|
|
340
|
+
]];
|
|
341
|
+
for (const [bag, propKeys, prefix] of bags) {
|
|
342
|
+
if (!bag) continue;
|
|
343
|
+
for (const key of propKeys) {
|
|
344
|
+
const value = bag[key];
|
|
345
|
+
if (typeof value !== "string" || value === "") continue;
|
|
346
|
+
const result = resolveRefInJson(root, at, value);
|
|
347
|
+
if (result.ok) continue;
|
|
348
|
+
warnings.push(`${path}: ${prefix}${key} — ${describeRefProblem(result.problem, value)} The scene loads and the prop does nothing.`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
(node.children ?? []).forEach((child, i) => {
|
|
352
|
+
walk(child, [...at, i], `${path}/${child.name ?? "?"}`);
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
walk(root, [], String(root.name ?? "root"));
|
|
356
|
+
return warnings;
|
|
357
|
+
}
|
|
358
|
+
//#endregion
|
|
169
359
|
//#region src/core/diagnostics.ts
|
|
170
360
|
function diagnose(engine, level, ...parts) {
|
|
171
361
|
engine?.log[level](...parts);
|
|
@@ -559,6 +749,112 @@ function uniqueSiblingName(requested, siblings, self) {
|
|
|
559
749
|
return `${stem}${counter}`;
|
|
560
750
|
}
|
|
561
751
|
//#endregion
|
|
752
|
+
//#region src/core/scene/handler-collision.ts
|
|
753
|
+
/**
|
|
754
|
+
* A handler name that means two different things, resolved silently.
|
|
755
|
+
*
|
|
756
|
+
* `wireConnections` looks the handler up on the node FIRST and on the behavior
|
|
757
|
+
* second — "Node methods win", which is right when only one of them has it and
|
|
758
|
+
* a coin flip when both do. A node carries 62 public methods before any adapter
|
|
759
|
+
* registers more (`stop`, `play`, `show`, `clear`, `say`, `advance`, `focus`,
|
|
760
|
+
* `next`, `resolve`, `start`, `free`…), and every one of those is a name an
|
|
761
|
+
* author would give a handler.
|
|
762
|
+
*
|
|
763
|
+
* Measured — an `Enemy` script on an `AnimatedSprite2D` with a `stop()` that
|
|
764
|
+
* means "stop chasing", wired from a trigger:
|
|
765
|
+
*
|
|
766
|
+
* ```
|
|
767
|
+
* loaded with NO error
|
|
768
|
+
* behavior.chasing after the signal = true
|
|
769
|
+
* ```
|
|
770
|
+
*
|
|
771
|
+
* `AnimatedSprite2D.stop()` ran instead: the enemy froze mid-animation and kept
|
|
772
|
+
* coming. The wire fired, the handler existed, and the wrong one was called.
|
|
773
|
+
*
|
|
774
|
+
* Only methods the author WROTE count. Every behavior inherits `update`,
|
|
775
|
+
* `onReady` and friends from the base class, and those collide with the node's
|
|
776
|
+
* by construction — flagging them would be a false alarm on a name nobody would
|
|
777
|
+
* wire a signal to anyway.
|
|
778
|
+
*/
|
|
779
|
+
function ambiguousHandler(node, handler) {
|
|
780
|
+
const behavior = node.behavior;
|
|
781
|
+
if (!behavior) return false;
|
|
782
|
+
if (typeof node[handler] !== "function") return false;
|
|
783
|
+
return declaredOnSubclass(behavior, handler);
|
|
784
|
+
}
|
|
785
|
+
/** Is `name` a method this behavior's own class defines, not one it inherits? */
|
|
786
|
+
function declaredOnSubclass(behavior, name) {
|
|
787
|
+
let proto = Object.getPrototypeOf(behavior);
|
|
788
|
+
while (proto && proto !== Behavior.prototype) {
|
|
789
|
+
const own = Object.getOwnPropertyDescriptor(proto, name);
|
|
790
|
+
if (own && typeof own.value === "function") return true;
|
|
791
|
+
proto = Object.getPrototypeOf(proto);
|
|
792
|
+
}
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
/** The error `wireConnections` throws when a handler names both. */
|
|
796
|
+
function ambiguousHandlerError(node, handler, script, to) {
|
|
797
|
+
return new IncantoError("AMBIGUOUS_HANDLER", `Connection → '${to}' names handler '${handler}', which is BOTH a method of '${node.constructor.typeName}' and one your behavior '${typeof script === "string" ? script : "?"}' defines. The node's wins, so the behavior's '${handler}' would never run and nothing would say so. Rename the behavior's method (or drop it, if the node's is the one you meant).`, {
|
|
798
|
+
path: node.getPath(),
|
|
799
|
+
nodeType: node.constructor.typeName,
|
|
800
|
+
uid: node.uid ?? void 0
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* The same collision, as far as a scene FILE can see it.
|
|
805
|
+
*
|
|
806
|
+
* The load-time error needs the real behavior class, which only the game has —
|
|
807
|
+
* `incanto check` loads scripts as stubs and cannot know what methods they
|
|
808
|
+
* declare. But it CAN see that the handler is a method of the node's own type
|
|
809
|
+
* and that the node carries a script, and that pair is the whole hazard: if the
|
|
810
|
+
* script also defines that name, the node's wins and the script's never runs.
|
|
811
|
+
*
|
|
812
|
+
* Deliberately conditional on the script. `"handler": "play"` on a scriptless
|
|
813
|
+
* `AudioPlayer` is the intended wiring and appears 53 times in the shipped
|
|
814
|
+
* corpus; the pair this warns about appears zero times.
|
|
815
|
+
*/
|
|
816
|
+
function handlerShadowWarnings(scene) {
|
|
817
|
+
const root = scene.root;
|
|
818
|
+
if (!root) return [];
|
|
819
|
+
const out = [];
|
|
820
|
+
for (const raw of scene.connections ?? []) {
|
|
821
|
+
const conn = raw;
|
|
822
|
+
if (!conn || typeof conn.to !== "string" || typeof conn.handler !== "string") continue;
|
|
823
|
+
const found = resolveRefInJson(root, [], conn.to);
|
|
824
|
+
if (!found.ok) continue;
|
|
825
|
+
let node = root;
|
|
826
|
+
for (const i of found.path) node = node?.children?.[i];
|
|
827
|
+
const script = node?.script?.name;
|
|
828
|
+
if (!node?.type || !script) continue;
|
|
829
|
+
if (!nodeTypeMethods(node.type).has(conn.handler)) continue;
|
|
830
|
+
out.push(`${conn.to}: handler '${conn.handler}' is a method of ${node.type} itself, and this node also runs the script '${script}'. The node's method WINS — if '${script}' defines '${conn.handler}' too, that one never runs and nothing says so.`);
|
|
831
|
+
}
|
|
832
|
+
return out;
|
|
833
|
+
}
|
|
834
|
+
/** Every public method a registered node type answers to, including inherited. */
|
|
835
|
+
function nodeTypeMethods(type) {
|
|
836
|
+
const cached = methodCache.get(type);
|
|
837
|
+
if (cached) return cached;
|
|
838
|
+
const out = /* @__PURE__ */ new Set();
|
|
839
|
+
let proto;
|
|
840
|
+
try {
|
|
841
|
+
proto = getNodeType(type).prototype;
|
|
842
|
+
} catch {
|
|
843
|
+
methodCache.set(type, out);
|
|
844
|
+
return out;
|
|
845
|
+
}
|
|
846
|
+
while (proto && proto !== Object.prototype) {
|
|
847
|
+
for (const key of Object.getOwnPropertyNames(proto)) {
|
|
848
|
+
if (key.startsWith("_") || key === "constructor") continue;
|
|
849
|
+
if (typeof Object.getOwnPropertyDescriptor(proto, key)?.value === "function") out.add(key);
|
|
850
|
+
}
|
|
851
|
+
proto = Object.getPrototypeOf(proto);
|
|
852
|
+
}
|
|
853
|
+
methodCache.set(type, out);
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
const methodCache = /* @__PURE__ */ new Map();
|
|
857
|
+
//#endregion
|
|
562
858
|
//#region src/core/input.ts
|
|
563
859
|
/** Keys typed into editable elements belong to the page UI, not the game. */
|
|
564
860
|
function isEditableTarget(target) {
|
|
@@ -1729,6 +2025,484 @@ function computeViewport(canvasW, canvasH, viewport) {
|
|
|
1729
2025
|
};
|
|
1730
2026
|
}
|
|
1731
2027
|
//#endregion
|
|
2028
|
+
//#region src/core/scene/asset-refs.ts
|
|
2029
|
+
/**
|
|
2030
|
+
* Hard-check every `$assetKey` a scene names, at LOAD.
|
|
2031
|
+
*
|
|
2032
|
+
* The check already existed and nothing routed to it at author time:
|
|
2033
|
+
* `AssetStore2D.resolve` throws `UNKNOWN_ASSET` for an undeclared key AND for a
|
|
2034
|
+
* value that is not a `$ref`, and it runs inside the renderer, in a browser,
|
|
2035
|
+
* when the texture is first needed. Headless the scene loaded clean.
|
|
2036
|
+
*
|
|
2037
|
+
* Measured before this existed, on a scene with four sprites — one good, one
|
|
2038
|
+
* with a typo'd key, one with a raw URL, one with a bare word:
|
|
2039
|
+
*
|
|
2040
|
+
* ```
|
|
2041
|
+
* ok g.scene.json
|
|
2042
|
+
* warn: $hero → /hero.png is not in the project …
|
|
2043
|
+
* 1/1 scene(s) valid
|
|
2044
|
+
* ```
|
|
2045
|
+
*
|
|
2046
|
+
* The only one it mentioned is the one that IS declared. The other three draw
|
|
2047
|
+
* nothing, and `incanto-assets.md` promises that a raw URL here "hard-fails".
|
|
2048
|
+
*/
|
|
2049
|
+
/** The `assets{}` entry types a prop may point at, and what to call them. */
|
|
2050
|
+
const KIND_LABEL = {
|
|
2051
|
+
texture: "an image",
|
|
2052
|
+
spritesheet: "a spritesheet (with frameWidth/frameHeight)",
|
|
2053
|
+
model: "a GLB/glTF/VRM model",
|
|
2054
|
+
animation: "a GLB's animation clips"
|
|
2055
|
+
};
|
|
2056
|
+
/**
|
|
2057
|
+
* Throws `UNKNOWN_ASSET` for any asset prop this node cannot resolve.
|
|
2058
|
+
*
|
|
2059
|
+
* @param props the node's own prop values
|
|
2060
|
+
* @param schema the merged prop schema for its type
|
|
2061
|
+
* @param assets the scene's `assets{}` block
|
|
2062
|
+
* @param nodeName for the message — a scene has many nodes
|
|
2063
|
+
*/
|
|
2064
|
+
function validateAssetRefs(props, schema, assets, nodeName) {
|
|
2065
|
+
for (const [key, def] of Object.entries(schema)) {
|
|
2066
|
+
const spec = def.asset;
|
|
2067
|
+
if (!spec) continue;
|
|
2068
|
+
const value = props[key];
|
|
2069
|
+
if (typeof value !== "string" || value === "") continue;
|
|
2070
|
+
if (!value.startsWith("$")) {
|
|
2071
|
+
if (!spec.refOnly) continue;
|
|
2072
|
+
throw new IncantoError("UNKNOWN_ASSET", `'${nodeName}.${key}' takes a "$assetKey" reference, not ${/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//") ? "a URL" : `'${value}'`}. Declare the asset — "assets": { "${suggestKey(value)}": { "type": "${spec.kind}", "url": "…" } } — and point the prop at "$${suggestKey(value)}".`, { prop: key });
|
|
2073
|
+
}
|
|
2074
|
+
const name = value.slice(1);
|
|
2075
|
+
const entry = assets?.[name];
|
|
2076
|
+
if (!entry) {
|
|
2077
|
+
const declared = Object.keys(assets ?? {});
|
|
2078
|
+
throw new IncantoError("UNKNOWN_ASSET", `'${nodeName}.${key}' names asset '${value}', which the scene does not declare. ` + (declared.length > 0 ? `Declared: [${declared.join(", ")}].` : "The scene has no \"assets\" block."), {
|
|
2079
|
+
prop: key,
|
|
2080
|
+
validOptions: declared
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
if (entry.type !== spec.kind) throw new IncantoError("UNKNOWN_ASSET", `'${nodeName}.${key}' needs ${KIND_LABEL[spec.kind] ?? spec.kind} — asset '${name}' is declared as "${String(entry.type)}". Change the entry's "type" to "${spec.kind}".`, {
|
|
2084
|
+
prop: key,
|
|
2085
|
+
validOptions: [spec.kind]
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
/** A plausible asset key for the message, from whatever they wrote. */
|
|
2090
|
+
function suggestKey(value) {
|
|
2091
|
+
const last = value.split(/[/\\]/).pop() ?? value;
|
|
2092
|
+
const stem = last.split("?")[0]?.replace(/\.[a-z0-9]+$/i, "") ?? last;
|
|
2093
|
+
return stem !== "" ? stem : "myAsset";
|
|
2094
|
+
}
|
|
2095
|
+
//#endregion
|
|
2096
|
+
//#region src/core/scene/environment.ts
|
|
2097
|
+
const SKY_KEYS = [
|
|
2098
|
+
"type",
|
|
2099
|
+
"sunPosition",
|
|
2100
|
+
"elevationDeg",
|
|
2101
|
+
"azimuthDeg",
|
|
2102
|
+
"turbidity",
|
|
2103
|
+
"rayleigh"
|
|
2104
|
+
];
|
|
2105
|
+
const FOG_KEYS = [
|
|
2106
|
+
"color",
|
|
2107
|
+
"near",
|
|
2108
|
+
"far"
|
|
2109
|
+
];
|
|
2110
|
+
const CLOUD_KEYS = [
|
|
2111
|
+
"coverage",
|
|
2112
|
+
"density",
|
|
2113
|
+
"base",
|
|
2114
|
+
"top",
|
|
2115
|
+
"color",
|
|
2116
|
+
"shadeColor",
|
|
2117
|
+
"speed",
|
|
2118
|
+
"scale"
|
|
2119
|
+
];
|
|
2120
|
+
const BLOOM_KEYS = ["threshold", "strength"];
|
|
2121
|
+
const POST_KEYS = [
|
|
2122
|
+
"vignette",
|
|
2123
|
+
"saturation",
|
|
2124
|
+
"contrast"
|
|
2125
|
+
];
|
|
2126
|
+
const SHADOW_KEYS = [
|
|
2127
|
+
"mapSize",
|
|
2128
|
+
"radius",
|
|
2129
|
+
"static"
|
|
2130
|
+
];
|
|
2131
|
+
const SHADOW_MAP_SIZES = [1024, 2048];
|
|
2132
|
+
const DEFAULT_TURBIDITY = 2;
|
|
2133
|
+
const DEFAULT_RAYLEIGH = 1;
|
|
2134
|
+
const DEFAULT_FOG_NEAR = 50;
|
|
2135
|
+
const DEFAULT_FOG_FAR = 800;
|
|
2136
|
+
/** Fallback haze when fog is declared without a sky to derive a horizon from. */
|
|
2137
|
+
const DEFAULT_FOG_COLOR = "#cfd8e0";
|
|
2138
|
+
const DEG2RAD = Math.PI / 180;
|
|
2139
|
+
/** Parse + hard-validate the 3D slice of a scene's `environment` header. */
|
|
2140
|
+
function parseEnvironment3D(env) {
|
|
2141
|
+
return {
|
|
2142
|
+
exposure: parseExposure(env?.exposure),
|
|
2143
|
+
sky: parseSky(env?.sky),
|
|
2144
|
+
fog: parseFog(env?.fog, env?.sky !== void 0),
|
|
2145
|
+
clouds: parseClouds(env?.clouds),
|
|
2146
|
+
bloom: parseBloom(env?.bloom),
|
|
2147
|
+
post: parsePost(env?.post),
|
|
2148
|
+
shadows: parseShadows(env?.shadows)
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* elevation/azimuth (degrees) → unit direction. Matches three's Sky example
|
|
2153
|
+
* (`Vector3.setFromSphericalCoords`): azimuth 0 = +Z, 90 = +X; elevation 90 =
|
|
2154
|
+
* straight up.
|
|
2155
|
+
*/
|
|
2156
|
+
function sunDirectionFromElevationAzimuth(elevationDeg, azimuthDeg) {
|
|
2157
|
+
const phi = (90 - elevationDeg) * DEG2RAD;
|
|
2158
|
+
const theta = azimuthDeg * DEG2RAD;
|
|
2159
|
+
return [
|
|
2160
|
+
Math.sin(phi) * Math.sin(theta),
|
|
2161
|
+
Math.cos(phi),
|
|
2162
|
+
Math.sin(phi) * Math.cos(theta)
|
|
2163
|
+
];
|
|
2164
|
+
}
|
|
2165
|
+
/** The sky's sun as a UNIT vector — what Water3D/Foliage3D uniforms consume. */
|
|
2166
|
+
function sunDirectionFromSky(sky) {
|
|
2167
|
+
const [x, y, z] = sky.sunPosition;
|
|
2168
|
+
const len = Math.hypot(x, y, z) || 1;
|
|
2169
|
+
return [
|
|
2170
|
+
x / len,
|
|
2171
|
+
y / len,
|
|
2172
|
+
z / len
|
|
2173
|
+
];
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* A horizon-ish haze color derived from the sky config — the default fog
|
|
2177
|
+
* color. Cheap model, judged by eye: clear skies haze blue-grey, turbid skies
|
|
2178
|
+
* whiten, and a low sun warms the band toward amber.
|
|
2179
|
+
*/
|
|
2180
|
+
function horizonColorFromSky(sky) {
|
|
2181
|
+
const clear = [
|
|
2182
|
+
191,
|
|
2183
|
+
213,
|
|
2184
|
+
232
|
|
2185
|
+
];
|
|
2186
|
+
const hazy = [
|
|
2187
|
+
233,
|
|
2188
|
+
228,
|
|
2189
|
+
217
|
|
2190
|
+
];
|
|
2191
|
+
const warm = [
|
|
2192
|
+
242,
|
|
2193
|
+
201,
|
|
2194
|
+
150
|
|
2195
|
+
];
|
|
2196
|
+
const t = clamp01((sky.turbidity - DEFAULT_TURBIDITY) / 8);
|
|
2197
|
+
const dir = sunDirectionFromSky(sky);
|
|
2198
|
+
const w = clamp01((18 - Math.asin(clamp(dir[1], -1, 1)) / DEG2RAD) / 18) * .8;
|
|
2199
|
+
const mix = (i) => Math.round(lerp(lerp(clear[i], hazy[i], t), warm[i], w));
|
|
2200
|
+
return `#${[
|
|
2201
|
+
mix(0),
|
|
2202
|
+
mix(1),
|
|
2203
|
+
mix(2)
|
|
2204
|
+
].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
|
2205
|
+
}
|
|
2206
|
+
function parseExposure(value) {
|
|
2207
|
+
if (value === void 0) return 1;
|
|
2208
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new IncantoError("BAD_FORMAT", `environment.exposure must be a finite number > 0 (tone-mapping exposure, default 1), got ${JSON.stringify(value)}.`, { prop: "exposure" });
|
|
2209
|
+
return value;
|
|
2210
|
+
}
|
|
2211
|
+
function parseSky(value) {
|
|
2212
|
+
if (value === void 0) return null;
|
|
2213
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.sky must be an object ({ type?: "atmosphere", sunPosition? | elevationDeg?+azimuthDeg?, turbidity?, rayleigh? }), got ${JSON.stringify(value)}.`, { prop: "sky" });
|
|
2214
|
+
const sky = value;
|
|
2215
|
+
for (const key of Object.keys(sky)) if (!SKY_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.sky has unknown key '${key}'. Valid keys: [${SKY_KEYS.join(", ")}].`, {
|
|
2216
|
+
prop: "sky",
|
|
2217
|
+
validOptions: SKY_KEYS
|
|
2218
|
+
});
|
|
2219
|
+
if (sky.type !== void 0 && sky.type !== "atmosphere") throw new IncantoError("BAD_FORMAT", `environment.sky.type must be 'atmosphere' (the only sky type so far), got ${JSON.stringify(sky.type)}.`, {
|
|
2220
|
+
prop: "sky",
|
|
2221
|
+
validOptions: ["atmosphere"]
|
|
2222
|
+
});
|
|
2223
|
+
const hasAngles = sky.elevationDeg !== void 0 || sky.azimuthDeg !== void 0;
|
|
2224
|
+
if (sky.sunPosition !== void 0 && hasAngles) throw new IncantoError("BAD_FORMAT", `environment.sky takes sunPosition OR elevationDeg/azimuthDeg, not both.`, {
|
|
2225
|
+
prop: "sky",
|
|
2226
|
+
validOptions: ["sunPosition", "elevationDeg+azimuthDeg"]
|
|
2227
|
+
});
|
|
2228
|
+
let sunPosition;
|
|
2229
|
+
if (sky.sunPosition !== void 0) {
|
|
2230
|
+
const sp = sky.sunPosition;
|
|
2231
|
+
if (!Array.isArray(sp) || sp.length !== 3 || !sp.every((v) => typeof v === "number" && Number.isFinite(v)) || Math.hypot(sp[0], sp[1], sp[2]) === 0) throw new IncantoError("BAD_FORMAT", `environment.sky.sunPosition must be a non-zero [x, y, z] vector, got ${JSON.stringify(sp)}.`, { prop: "sky" });
|
|
2232
|
+
sunPosition = [
|
|
2233
|
+
sp[0],
|
|
2234
|
+
sp[1],
|
|
2235
|
+
sp[2]
|
|
2236
|
+
];
|
|
2237
|
+
} else {
|
|
2238
|
+
const elevation = numberOr(sky.elevationDeg, 32, "sky.elevationDeg");
|
|
2239
|
+
const azimuth = numberOr(sky.azimuthDeg, 135, "sky.azimuthDeg");
|
|
2240
|
+
if (elevation < -90 || elevation > 90) throw new IncantoError("BAD_FORMAT", `environment.sky.elevationDeg must be in [-90, 90] (degrees above the horizon), got ${elevation}.`, { prop: "sky" });
|
|
2241
|
+
sunPosition = sunDirectionFromElevationAzimuth(elevation, azimuth);
|
|
2242
|
+
}
|
|
2243
|
+
const turbidity = numberOr(sky.turbidity, DEFAULT_TURBIDITY, "sky.turbidity");
|
|
2244
|
+
if (turbidity <= 0) throw new IncantoError("BAD_FORMAT", `environment.sky.turbidity must be > 0 (atmospheric haze; 2 ≈ clear day), got ${turbidity}.`, { prop: "sky" });
|
|
2245
|
+
const rayleigh = numberOr(sky.rayleigh, DEFAULT_RAYLEIGH, "sky.rayleigh");
|
|
2246
|
+
if (rayleigh < 0) throw new IncantoError("BAD_FORMAT", `environment.sky.rayleigh must be >= 0 (Rayleigh scattering; 1 ≈ earth-like), got ${rayleigh}.`, { prop: "sky" });
|
|
2247
|
+
return {
|
|
2248
|
+
sunPosition,
|
|
2249
|
+
turbidity,
|
|
2250
|
+
rayleigh
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
function parseFog(value, hasSky) {
|
|
2254
|
+
if (value === void 0) return null;
|
|
2255
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.fog must be an object ({ color?, near?, far? }), got ${JSON.stringify(value)}.`, { prop: "fog" });
|
|
2256
|
+
const fog = value;
|
|
2257
|
+
for (const key of Object.keys(fog)) if (!FOG_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.fog has unknown key '${key}'. Valid keys: [${FOG_KEYS.join(", ")}].`, {
|
|
2258
|
+
prop: "fog",
|
|
2259
|
+
validOptions: FOG_KEYS
|
|
2260
|
+
});
|
|
2261
|
+
if (fog.color !== void 0 && typeof fog.color !== "string") throw new IncantoError("BAD_FORMAT", `environment.fog.color must be a hex color string, got ${JSON.stringify(fog.color)}.`, { prop: "fog" });
|
|
2262
|
+
const near = numberOr(fog.near, DEFAULT_FOG_NEAR, "fog.near");
|
|
2263
|
+
const far = numberOr(fog.far, DEFAULT_FOG_FAR, "fog.far");
|
|
2264
|
+
if (near < 0) throw new IncantoError("BAD_FORMAT", `environment.fog.near must be >= 0 meters, got ${near}.`, { prop: "fog" });
|
|
2265
|
+
if (far <= near) throw new IncantoError("BAD_FORMAT", `environment.fog.far must be > near (got near ${near}, far ${far}).`, { prop: "fog" });
|
|
2266
|
+
return {
|
|
2267
|
+
color: fog.color ?? (hasSky ? "" : DEFAULT_FOG_COLOR),
|
|
2268
|
+
near,
|
|
2269
|
+
far
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
function parseClouds(value) {
|
|
2273
|
+
if (value === void 0) return null;
|
|
2274
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.clouds must be an object ({ coverage?, density?, base?, top?, color?, shadeColor?, speed?, scale? }), got ${JSON.stringify(value)}.`, { prop: "clouds" });
|
|
2275
|
+
const c = value;
|
|
2276
|
+
for (const key of Object.keys(c)) if (!CLOUD_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.clouds has unknown key '${key}'. Valid keys: [${CLOUD_KEYS.join(", ")}].`, {
|
|
2277
|
+
prop: "clouds",
|
|
2278
|
+
validOptions: CLOUD_KEYS
|
|
2279
|
+
});
|
|
2280
|
+
for (const k of ["color", "shadeColor"]) if (c[k] !== void 0 && typeof c[k] !== "string") throw new IncantoError("BAD_FORMAT", `environment.clouds.${k} must be a hex color string, got ${JSON.stringify(c[k])}.`, { prop: "clouds" });
|
|
2281
|
+
const coverage = numberOr(c.coverage, .5, "clouds.coverage");
|
|
2282
|
+
if (coverage < 0 || coverage > 1) throw new IncantoError("BAD_FORMAT", `environment.clouds.coverage must be in [0, 1] (how much sky is cloudy), got ${coverage}.`, { prop: "clouds" });
|
|
2283
|
+
const density = numberOr(c.density, 1, "clouds.density");
|
|
2284
|
+
if (density < 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.density must be >= 0 (optical thickness), got ${density}.`, { prop: "clouds" });
|
|
2285
|
+
const base = numberOr(c.base, 120, "clouds.base");
|
|
2286
|
+
const top = numberOr(c.top, 320, "clouds.top");
|
|
2287
|
+
if (top <= base) throw new IncantoError("BAD_FORMAT", `environment.clouds.top must be > base (got base ${base}, top ${top}).`, { prop: "clouds" });
|
|
2288
|
+
const speed = numberOr(c.speed, 1, "clouds.speed");
|
|
2289
|
+
const scale = numberOr(c.scale, 240, "clouds.scale");
|
|
2290
|
+
if (scale <= 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.scale must be > 0 (feature size in world units), got ${scale}.`, { prop: "clouds" });
|
|
2291
|
+
return {
|
|
2292
|
+
coverage,
|
|
2293
|
+
density,
|
|
2294
|
+
base,
|
|
2295
|
+
top,
|
|
2296
|
+
color: c.color ?? "#ffffff",
|
|
2297
|
+
shadeColor: c.shadeColor ?? "#9fb0c8",
|
|
2298
|
+
speed,
|
|
2299
|
+
scale
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
function parseBloom(value) {
|
|
2303
|
+
if (value === void 0) return null;
|
|
2304
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.bloom must be an object ({ threshold?, strength? }), got ${JSON.stringify(value)}.`, { prop: "bloom" });
|
|
2305
|
+
const b = value;
|
|
2306
|
+
for (const key of Object.keys(b)) if (!BLOOM_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.bloom has unknown key '${key}'. Valid keys: [${BLOOM_KEYS.join(", ")}].`, {
|
|
2307
|
+
prop: "bloom",
|
|
2308
|
+
validOptions: BLOOM_KEYS
|
|
2309
|
+
});
|
|
2310
|
+
const threshold = numberOr(b.threshold, 1, "bloom.threshold");
|
|
2311
|
+
if (threshold < 0 || threshold > 8) throw new IncantoError("BAD_FORMAT", `environment.bloom.threshold must be in [0, 8] (linear-HDR luminance; 1 = white), got ${threshold}.`, { prop: "bloom" });
|
|
2312
|
+
const strength = numberOr(b.strength, .8, "bloom.strength");
|
|
2313
|
+
if (strength < 0) throw new IncantoError("BAD_FORMAT", `environment.bloom.strength must be >= 0, got ${strength}.`, { prop: "bloom" });
|
|
2314
|
+
return {
|
|
2315
|
+
threshold,
|
|
2316
|
+
strength
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
function parsePost(value) {
|
|
2320
|
+
if (value === void 0) return null;
|
|
2321
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.post must be an object ({ vignette?, saturation?, contrast? }), got ${JSON.stringify(value)}.`, { prop: "post" });
|
|
2322
|
+
const p = value;
|
|
2323
|
+
for (const key of Object.keys(p)) if (!POST_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.post has unknown key '${key}'. Valid keys: [${POST_KEYS.join(", ")}].`, {
|
|
2324
|
+
prop: "post",
|
|
2325
|
+
validOptions: POST_KEYS
|
|
2326
|
+
});
|
|
2327
|
+
const vignette = numberOr(p.vignette, 0, "post.vignette");
|
|
2328
|
+
if (vignette < 0 || vignette > 1) throw new IncantoError("BAD_FORMAT", `environment.post.vignette must be in [0, 1], got ${vignette}.`, { prop: "post" });
|
|
2329
|
+
const saturation = numberOr(p.saturation, 1, "post.saturation");
|
|
2330
|
+
if (saturation < 0 || saturation > 4) throw new IncantoError("BAD_FORMAT", `environment.post.saturation must be in [0, 4] (1 = neutral), got ${saturation}.`, { prop: "post" });
|
|
2331
|
+
const contrast = numberOr(p.contrast, 1, "post.contrast");
|
|
2332
|
+
if (contrast < .2 || contrast > 3) throw new IncantoError("BAD_FORMAT", `environment.post.contrast must be in [0.2, 3] (1 = neutral), got ${contrast}.`, { prop: "post" });
|
|
2333
|
+
return {
|
|
2334
|
+
vignette,
|
|
2335
|
+
saturation,
|
|
2336
|
+
contrast
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
function parseShadows(value) {
|
|
2340
|
+
if (value === void 0) return null;
|
|
2341
|
+
if (value === false) return false;
|
|
2342
|
+
if (value === true) return {
|
|
2343
|
+
mapSize: 2048,
|
|
2344
|
+
radius: 1,
|
|
2345
|
+
static: false
|
|
2346
|
+
};
|
|
2347
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.shadows must be true, false or an object ({ mapSize?, radius? }), got ${JSON.stringify(value)}.`, { prop: "shadows" });
|
|
2348
|
+
const shadows = value;
|
|
2349
|
+
for (const key of Object.keys(shadows)) if (!SHADOW_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.shadows has unknown key '${key}'. Valid keys: [${SHADOW_KEYS.join(", ")}].`, {
|
|
2350
|
+
prop: "shadows",
|
|
2351
|
+
validOptions: SHADOW_KEYS
|
|
2352
|
+
});
|
|
2353
|
+
const mapSize = shadows.mapSize === void 0 ? 2048 : shadows.mapSize;
|
|
2354
|
+
if (!SHADOW_MAP_SIZES.includes(mapSize)) throw new IncantoError("BAD_FORMAT", `environment.shadows.mapSize must be one of [${SHADOW_MAP_SIZES.join(", ")}], got ${JSON.stringify(shadows.mapSize)}.`, {
|
|
2355
|
+
prop: "shadows",
|
|
2356
|
+
validOptions: SHADOW_MAP_SIZES.map(String)
|
|
2357
|
+
});
|
|
2358
|
+
const radius = numberOr(shadows.radius, 1, "shadows.radius");
|
|
2359
|
+
if (radius < 0) throw new IncantoError("BAD_FORMAT", `environment.shadows.radius must be >= 0, got ${radius}.`, { prop: "shadows" });
|
|
2360
|
+
const staticShadows = shadows.static === void 0 ? false : shadows.static;
|
|
2361
|
+
if (typeof staticShadows !== "boolean") throw new IncantoError("BAD_FORMAT", `environment.shadows.static must be a boolean, got ${JSON.stringify(shadows.static)}.`, { prop: "shadows" });
|
|
2362
|
+
return {
|
|
2363
|
+
mapSize,
|
|
2364
|
+
radius,
|
|
2365
|
+
static: staticShadows
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
function numberOr(value, fallback, at) {
|
|
2369
|
+
if (value === void 0) return fallback;
|
|
2370
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new IncantoError("BAD_FORMAT", `environment.${at} must be a finite number, got ${JSON.stringify(value)}.`, { prop: at });
|
|
2371
|
+
return value;
|
|
2372
|
+
}
|
|
2373
|
+
function clamp(v, min, max) {
|
|
2374
|
+
return Math.min(Math.max(v, min), max);
|
|
2375
|
+
}
|
|
2376
|
+
function clamp01(v) {
|
|
2377
|
+
return clamp(v, 0, 1);
|
|
2378
|
+
}
|
|
2379
|
+
function lerp(a, b, t) {
|
|
2380
|
+
return a + (b - a) * t;
|
|
2381
|
+
}
|
|
2382
|
+
//#endregion
|
|
2383
|
+
//#region src/core/scene/environment-validate.ts
|
|
2384
|
+
/**
|
|
2385
|
+
* Check a scene's `environment` header at LOAD.
|
|
2386
|
+
*
|
|
2387
|
+
* `parseEnvironment3D` has always thrown good errors — and its only caller was
|
|
2388
|
+
* `Environment3D.apply()`, inside a live renderer, in a browser. So the scene
|
|
2389
|
+
* header's biggest block was the one thing `loadScene` and `incanto-check`
|
|
2390
|
+
* could not check. Measured, every one of these loading clean and reported as
|
|
2391
|
+
* `1/1 scene(s) valid`:
|
|
2392
|
+
*
|
|
2393
|
+
* ```
|
|
2394
|
+
* sky.type = "gradient" (only atmosphere exists) NO ERROR
|
|
2395
|
+
* shadows = "soft" NO ERROR
|
|
2396
|
+
* exposure = "bright" NO ERROR
|
|
2397
|
+
* rendering.pixelRatio = "auto" NO ERROR
|
|
2398
|
+
* a typo'd top-level key: skye NO ERROR
|
|
2399
|
+
* ```
|
|
2400
|
+
*
|
|
2401
|
+
* The last two are the ones that cost a whole session. A typo'd top-level key
|
|
2402
|
+
* means the sky you thought you declared does not exist — no backdrop, no
|
|
2403
|
+
* image-based light, no sun, no message. And `pixelRatio: "auto"` (the
|
|
2404
|
+
* plausible typo for `"device"`) reaches `setPixelRatio("auto")`, three
|
|
2405
|
+
* computes `Math.floor(width * NaN)`, and the canvas becomes 0×0: the game
|
|
2406
|
+
* renders nothing at all.
|
|
2407
|
+
*
|
|
2408
|
+
* Same shape as the asset refs and as `nodePath`/`required` before them — the
|
|
2409
|
+
* rule existed and nothing routed to it at author time.
|
|
2410
|
+
*/
|
|
2411
|
+
/**
|
|
2412
|
+
* Top-level blocks the renderer reads.
|
|
2413
|
+
*
|
|
2414
|
+
* Kept in step with the consumers by a test, not by discipline: the first cut
|
|
2415
|
+
* of this list was written by hand and left out `iblIntensity` and `preset`,
|
|
2416
|
+
* which 17 shipped scenes use — so the check rejected the engine's own
|
|
2417
|
+
* examples. A whitelist nobody can verify is a new way to be wrong.
|
|
2418
|
+
*/
|
|
2419
|
+
const ENVIRONMENT_KEYS = [
|
|
2420
|
+
"ambient",
|
|
2421
|
+
"background",
|
|
2422
|
+
"bloom",
|
|
2423
|
+
"clouds",
|
|
2424
|
+
"exposure",
|
|
2425
|
+
"fog",
|
|
2426
|
+
"hdri",
|
|
2427
|
+
"iblIntensity",
|
|
2428
|
+
"post",
|
|
2429
|
+
"preset",
|
|
2430
|
+
"rendering",
|
|
2431
|
+
"shadows",
|
|
2432
|
+
"sky",
|
|
2433
|
+
"skybox"
|
|
2434
|
+
];
|
|
2435
|
+
/** Closest declared key by a cheap edit distance, for "did you mean". */
|
|
2436
|
+
function nearest(key) {
|
|
2437
|
+
const lower = key.toLowerCase();
|
|
2438
|
+
let best = null;
|
|
2439
|
+
let bestScore = 3;
|
|
2440
|
+
for (const known of ENVIRONMENT_KEYS) {
|
|
2441
|
+
const d = distance(lower, known);
|
|
2442
|
+
if (d < bestScore) {
|
|
2443
|
+
bestScore = d;
|
|
2444
|
+
best = known;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
return best;
|
|
2448
|
+
}
|
|
2449
|
+
function distance(a, b) {
|
|
2450
|
+
const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
2451
|
+
for (let j = 0; j <= b.length; j++) rows[0][j] = j;
|
|
2452
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
|
|
2453
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
2454
|
+
rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
|
|
2455
|
+
}
|
|
2456
|
+
return rows[a.length][b.length];
|
|
2457
|
+
}
|
|
2458
|
+
/** `rendering` is read by the renderer, not by `parseEnvironment3D`. */
|
|
2459
|
+
function validateRendering(rendering) {
|
|
2460
|
+
if (rendering === void 0) return;
|
|
2461
|
+
if (typeof rendering !== "object" || rendering === null || Array.isArray(rendering)) throw new IncantoError("BAD_FORMAT", `environment.rendering must be an object ({ antialias?, pixelRatio? }), got ${JSON.stringify(rendering)}.`);
|
|
2462
|
+
const r = rendering;
|
|
2463
|
+
for (const key of Object.keys(r)) {
|
|
2464
|
+
if (key === "antialias" || key === "pixelRatio" || key === "preserveDrawingBuffer") continue;
|
|
2465
|
+
throw new IncantoError("BAD_FORMAT", `environment.rendering has unknown key '${key}'. Valid keys: [antialias, pixelRatio, preserveDrawingBuffer].`, {
|
|
2466
|
+
prop: key,
|
|
2467
|
+
validOptions: [
|
|
2468
|
+
"antialias",
|
|
2469
|
+
"pixelRatio",
|
|
2470
|
+
"preserveDrawingBuffer"
|
|
2471
|
+
]
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
const ratio = r.pixelRatio;
|
|
2475
|
+
if (ratio !== void 0 && ratio !== "device" && typeof ratio !== "number") throw new IncantoError("BAD_FORMAT", `environment.rendering.pixelRatio must be a number or "device", got ${JSON.stringify(ratio)}. Anything else reaches setPixelRatio() as-is and gives the game a 0×0 canvas.`, {
|
|
2476
|
+
prop: "pixelRatio",
|
|
2477
|
+
validOptions: ["device"]
|
|
2478
|
+
});
|
|
2479
|
+
if (typeof ratio === "number" && !(ratio > 0 && Number.isFinite(ratio))) throw new IncantoError("BAD_FORMAT", `environment.rendering.pixelRatio must be greater than 0, got ${JSON.stringify(ratio)}.`, { prop: "pixelRatio" });
|
|
2480
|
+
if (r.antialias !== void 0 && typeof r.antialias !== "boolean") throw new IncantoError("BAD_FORMAT", `environment.rendering.antialias must be true or false, got ${JSON.stringify(r.antialias)}.`, { prop: "antialias" });
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Throws `BAD_FORMAT` for anything in `environment` the renderer would reject
|
|
2484
|
+
* or silently drop.
|
|
2485
|
+
*
|
|
2486
|
+
* @param environment the scene's `environment` block
|
|
2487
|
+
* @param dimension only a 3D scene has this contract
|
|
2488
|
+
*/
|
|
2489
|
+
function validateEnvironment(environment, dimension) {
|
|
2490
|
+
if (environment === void 0 || environment === null) return;
|
|
2491
|
+
if (typeof environment !== "object" || Array.isArray(environment)) throw new IncantoError("BAD_FORMAT", `Scene "environment" must be an object, got ${JSON.stringify(environment)}.`);
|
|
2492
|
+
const env = environment;
|
|
2493
|
+
validateRendering(env.rendering);
|
|
2494
|
+
if (dimension !== "3d") return;
|
|
2495
|
+
for (const key of Object.keys(env)) {
|
|
2496
|
+
if (ENVIRONMENT_KEYS.includes(key)) continue;
|
|
2497
|
+
const guess = nearest(key);
|
|
2498
|
+
throw new IncantoError("BAD_FORMAT", `environment has unknown key '${key}'${guess ? ` — did you mean "${guess}"?` : ""}. Valid keys: [${ENVIRONMENT_KEYS.join(", ")}].`, {
|
|
2499
|
+
prop: key,
|
|
2500
|
+
validOptions: [...ENVIRONMENT_KEYS]
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
parseEnvironment3D(env);
|
|
2504
|
+
}
|
|
2505
|
+
//#endregion
|
|
1732
2506
|
//#region src/core/scene/network-validate.ts
|
|
1733
2507
|
/**
|
|
1734
2508
|
* Hard-check a node's `network` block at LOAD.
|
|
@@ -1958,7 +2732,12 @@ function loadScene(json, opts) {
|
|
|
1958
2732
|
const sceneJson = json;
|
|
1959
2733
|
validateHeader(sceneJson);
|
|
1960
2734
|
validateStrings(sceneJson.strings);
|
|
2735
|
+
validateEnvironment(sceneJson.environment, sceneJson.dimension);
|
|
1961
2736
|
inheritedStrings = {};
|
|
2737
|
+
loadingAssets = {
|
|
2738
|
+
...opts?.declaredAssets ?? {},
|
|
2739
|
+
...sceneJson.assets ?? {}
|
|
2740
|
+
};
|
|
1962
2741
|
const root = buildNode(sceneJson.root, opts, [], "", sceneJson.constants, Object.keys(sceneJson.orderGroups ?? {}));
|
|
1963
2742
|
validateUniqueUids(root);
|
|
1964
2743
|
const tree = new SceneTree();
|
|
@@ -1967,6 +2746,7 @@ function loadScene(json, opts) {
|
|
|
1967
2746
|
wireConnections(root, sceneJson.connections ?? [], opts?.declareConnectionSignals);
|
|
1968
2747
|
const collected = inheritedStrings;
|
|
1969
2748
|
inheritedStrings = null;
|
|
2749
|
+
loadingAssets = null;
|
|
1970
2750
|
return new Scene(withInheritedStrings(sceneJson, collected), root, tree);
|
|
1971
2751
|
}
|
|
1972
2752
|
/**
|
|
@@ -1978,6 +2758,15 @@ function loadScene(json, opts) {
|
|
|
1978
2758
|
* constants table is passed the way it is.
|
|
1979
2759
|
*/
|
|
1980
2760
|
let inheritedStrings = null;
|
|
2761
|
+
/**
|
|
2762
|
+
* The `assets{}` table to validate `$ref`s against, or null outside a load.
|
|
2763
|
+
*
|
|
2764
|
+
* NULL, not empty: `buildNodeJson` rebuilds a detached subtree for
|
|
2765
|
+
* `duplicateNode` and for `NetworkSpawner`, and there is no scene header in
|
|
2766
|
+
* either — checking against "no assets" there would reject every sprite in a
|
|
2767
|
+
* duplicated node.
|
|
2768
|
+
*/
|
|
2769
|
+
let loadingAssets = null;
|
|
1981
2770
|
/** Fold one sub-scene's table into the accumulator. */
|
|
1982
2771
|
function inheritStrings(strings) {
|
|
1983
2772
|
if (!inheritedStrings || strings === void 0) return;
|
|
@@ -2089,6 +2878,10 @@ function parentOf(path) {
|
|
|
2089
2878
|
const i = path.lastIndexOf("/");
|
|
2090
2879
|
return i <= 0 ? "" : path.slice(0, i);
|
|
2091
2880
|
}
|
|
2881
|
+
/** The merged prop schema of whatever type this node is. */
|
|
2882
|
+
function propsFor(node) {
|
|
2883
|
+
return mergeStaticProps(node.constructor);
|
|
2884
|
+
}
|
|
2092
2885
|
function applyCommon(node, nj, opts) {
|
|
2093
2886
|
if (typeof nj.uid === "string" && nj.uid !== "") node.uid = nj.uid;
|
|
2094
2887
|
node.name = nj.name;
|
|
@@ -2103,6 +2896,7 @@ function applyCommon(node, nj, opts) {
|
|
|
2103
2896
|
node.behavior = behavior;
|
|
2104
2897
|
for (const s of mergeStaticSignals(behavior.constructor)) node.declareSignal(s);
|
|
2105
2898
|
}
|
|
2899
|
+
if (loadingAssets) validateAssetRefs(nj.props ?? {}, propsFor(node), loadingAssets, node.name);
|
|
2106
2900
|
if (nj.network) {
|
|
2107
2901
|
validateNetworkBlock(nj.network, node.name);
|
|
2108
2902
|
node.network = jsonClone(nj.network);
|
|
@@ -2125,6 +2919,7 @@ function wireConnections(root, connections, declareSignals = false) {
|
|
|
2125
2919
|
const nodeFn = toNode[conn.handler];
|
|
2126
2920
|
const behaviorFn = toNode.behavior ? toNode.behavior[conn.handler] : void 0;
|
|
2127
2921
|
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}')` : "") + `.`);
|
|
2922
|
+
if (ambiguousHandler(toNode, conn.handler)) throw ambiguousHandlerError(toNode, conn.handler, toNode.script?.name, conn.to);
|
|
2128
2923
|
const listener = (...args) => {
|
|
2129
2924
|
if (conn.filter && !matchesFilter(args[0], conn.filter)) return;
|
|
2130
2925
|
if (conn.once) fromNode.off(conn.signal, listener);
|
|
@@ -2203,4 +2998,4 @@ function validateUniqueUids(root) {
|
|
|
2203
2998
|
walk(root);
|
|
2204
2999
|
}
|
|
2205
3000
|
//#endregion
|
|
2206
|
-
export {
|
|
3001
|
+
export { describeRefProblem as A, registeredBehaviors as B, ORDER_GROUP_BASE as C, handlerShadowWarnings as D, InputMap as E, behaviorSchema as F, behaviorSignals as I, clearBehaviors as L, resolveRefInJson as M, parseNodePath as N, Node as O, Behavior as P, getBehavior as R, createSaveStore as S, resolveOrderGroups as T, Signal as V, SaveSlots as _, serializeNode as a, restoreBehaviors as b, parseEnvironment3D as c, computeViewport as d, resolveViewport as f, resolveConstants as g, isConstRef as h, Scene as i, nodeRefWarnings as j, diagnose as k, sunDirectionFromElevationAzimuth as l, CONST_REF_KEY as m, loadScene as n, SCENE_FORMAT as o, SceneTree as p, loader_exports as r, horizonColorFromSky as s, buildNodeJson as t, sunDirectionFromSky as u, behaviorsWithoutSave as v, effectiveOrder as w, savesWithoutUid as x, captureBehaviors as y, registerBehavior as z };
|