incanto 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/bin/incanto-play.mjs +42 -17
  2. package/dist/2d.d.ts +3 -2
  3. package/dist/2d.js +3 -3
  4. package/dist/3d.d.ts +4 -3
  5. package/dist/3d.js +4 -4
  6. package/dist/{behavior-62q0HWBO.d.ts → behavior-rZNfzVbH.d.ts} +37 -3
  7. package/dist/{create-game-DFBjMetZ.js → create-game-BNaOC3Px.js} +5 -5
  8. package/dist/{create-game-BgV6UbVA.js → create-game-CkzKZ4v5.js} +5 -5
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-CI9WF_bg.js → duplicate-EybyYeyL.js} +1 -1
  11. package/dist/{environment-presets-CZOH5TY5.js → environment-presets-C08pOC6H.js} +2 -2
  12. package/dist/{gameplay-02Btmmjn.js → gameplay-bStgtZBV.js} +175 -6
  13. package/dist/gameplay.d.ts +79 -2
  14. package/dist/gameplay.js +2 -2
  15. package/dist/index.d.ts +14 -5
  16. package/dist/index.js +6 -6
  17. package/dist/{loader-DwazzlQb.js → loader-B2asghWa.js} +350 -1
  18. package/dist/{loader-CeyU_bm1.d.ts → loader-Dkbn56KC.d.ts} +1 -1
  19. package/dist/net.d.ts +1 -1
  20. package/dist/net.js +3 -3
  21. package/dist/{pathfinding-C49JSNNq.d.ts → pathfinding-Bz34pvQD.d.ts} +1 -1
  22. package/dist/{physics-2d-vyCBfACH.js → physics-2d-Ccq2L9R0.js} +8 -3
  23. package/dist/{physics-3d-DpRqw8Mz.js → physics-3d-B2c5KNYh.js} +13 -8
  24. package/dist/react.d.ts +1 -1
  25. package/dist/react.js +1 -1
  26. package/dist/{register-BNPZYJmd.js → register-B_BaaGx-.js} +2 -2
  27. package/dist/{register-uvaZj1KX.js → register-CGq-Hee8.js} +19 -265
  28. package/dist/{register-CB11yp21.js → register-CLVhzWcI.js} +2 -2
  29. package/dist/{replay-C0XJIsO7.js → replay-BgKxGcXH.js} +42 -3
  30. package/dist/{replay-CAphXMyM.d.ts → replay-DIP2_as4.d.ts} +6 -2
  31. package/dist/{src-DF4gCsqO.js → src-C3UwzYXl.js} +1 -1
  32. package/dist/{test-DRna_BQU.js → test-D2gRpk5V.js} +48 -21
  33. package/dist/test.d.ts +3 -3
  34. package/dist/test.js +2 -2
  35. package/dist/vite.js +2 -2
  36. package/editor/assets/{agent8-DCW4TgDt.js → agent8-DbX_msaO.js} +1 -1
  37. package/editor/assets/{debug-RC6qts6S.js → debug-C-kMxdl1.js} +1 -1
  38. package/editor/assets/{index-5dEIhvsf.js → index-CUc1U7wm.js} +91 -91
  39. package/editor/index.html +1 -1
  40. package/package.json +1 -1
  41. package/skills/incanto-building-2d-games.md +6 -1
  42. package/skills/incanto-gameplay-behaviors.md +11 -0
  43. package/skills/incanto-node-reference.md +13 -0
  44. package/skills/incanto-playtesting.md +13 -2
  45. package/skills/incanto-save-slots.md +134 -12
  46. package/templates-app/beacon-isle-3d/package.json +1 -1
  47. package/templates-app/tps-3d/package.json +1 -1
  48. package/templates-app/village-quest-3d/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { E as Signal, _ as InputMap, b as behaviorSchema, f as Node, g as resolveOrderGroups, m as ORDER_GROUP_BASE, v as diagnose } from "./loader-DwazzlQb.js";
1
+ import { C as InputMap, M as Signal, S as resolveOrderGroups, _ as restoreBehaviors, b as ORDER_GROUP_BASE, f as Node, g as captureBehaviors, w as diagnose, y as createSaveStore } from "./loader-B2asghWa.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
3
  import { t as Rng } from "./rng-DP-SR7eg.js";
4
4
  import { l as registerNode } from "./registry-IyWCGe4q.js";
@@ -1134,267 +1134,6 @@ var LogManager = class {
1134
1134
  }
1135
1135
  };
1136
1136
  //#endregion
1137
- //#region src/core/save.ts
1138
- const memoryStores = /* @__PURE__ */ new Map();
1139
- function createSaveStore(namespace) {
1140
- const prefix = `incanto:${namespace}:`;
1141
- const local = storageOrNull();
1142
- if (!local) {
1143
- let mem = memoryStores.get(namespace);
1144
- if (!mem) {
1145
- mem = /* @__PURE__ */ new Map();
1146
- memoryStores.set(namespace, mem);
1147
- }
1148
- const backing = mem;
1149
- return {
1150
- get: (key, fallback) => parse(backing.get(key), fallback),
1151
- set: (key, value) => void backing.set(key, JSON.stringify(value)),
1152
- remove: (key) => void backing.delete(key),
1153
- clear: () => backing.clear()
1154
- };
1155
- }
1156
- return {
1157
- get: (key, fallback) => parse(local.getItem(prefix + key) ?? void 0, fallback),
1158
- set: (key, value) => local.setItem(prefix + key, JSON.stringify(value)),
1159
- remove: (key) => local.removeItem(prefix + key),
1160
- clear: () => {
1161
- const doomed = [];
1162
- for (let i = 0; i < local.length; i++) {
1163
- const k = local.key(i);
1164
- if (k?.startsWith(prefix)) doomed.push(k);
1165
- }
1166
- for (const k of doomed) local.removeItem(k);
1167
- }
1168
- };
1169
- }
1170
- function parse(raw, fallback) {
1171
- if (raw === void 0 || raw === null) return fallback;
1172
- try {
1173
- return JSON.parse(raw);
1174
- } catch {
1175
- return fallback;
1176
- }
1177
- }
1178
- function storageOrNull() {
1179
- try {
1180
- if (typeof localStorage === "undefined") return null;
1181
- const probe = "__incanto_probe__";
1182
- localStorage.setItem(probe, "1");
1183
- localStorage.removeItem(probe);
1184
- return localStorage;
1185
- } catch {
1186
- return null;
1187
- }
1188
- }
1189
- //#endregion
1190
- //#region src/core/save-slots.ts
1191
- /**
1192
- * Save slots — "continue where you left off", which no game on this engine could
1193
- * do.
1194
- *
1195
- * `createSaveStore` has been here a long time and `Scene.toJSON()` round-trips a
1196
- * live tree, so two thirds of the work existed. The missing third was that
1197
- * BEHAVIOR state had nowhere to go: `Behavior` declared `onEnterTree`, `onReady`,
1198
- * `onExitTree`, `update` and `fixedUpdate` and nothing for "write yourself down",
1199
- * so `Health.current`, `ScoreKeeper.score` and every quest flag were
1200
- * unrecoverable. Both quest templates this repo ships were RPGs you could not
1201
- * resume.
1202
- *
1203
- * ## What a save is, and what it deliberately is NOT
1204
- *
1205
- * A save is **which scene, plus every behavior's state, keyed by node uid**. It
1206
- * is not a snapshot of the live tree.
1207
- *
1208
- * That is a design decision, not a shortcut. Restoring a live tree by walking
1209
- * paths breaks on this engine specifically:
1210
- *
1211
- * - `Spawner.onReady` DETACHES its prefab template from the tree (see
1212
- * `SpawnSource.resolveTemplate`), so a captured tree and a freshly booted one
1213
- * legitimately disagree about which nodes exist, every time.
1214
- * Every scene with a spawner would report phantom removals.
1215
- * - A spawned enemy carrying its own spawner has already lost ITS template, so
1216
- * re-adding that subtree runs `onReady` against a node that is not there and
1217
- * throws — and `onReady` is not quarantined, so it kills the whole load.
1218
- * - Prop deltas cannot be applied with `applySchemaProps`, which resets every
1219
- * prop to its default before writing.
1220
- *
1221
- * Reloading the scene from source sidesteps all three: the structure comes from
1222
- * the file (which is authoritative and already validated), and the save carries
1223
- * only what the file cannot know. A fresh boot detaches the same templates the
1224
- * captured one did, so there is nothing to reconcile.
1225
- *
1226
- * The cost, stated plainly so nobody discovers it: **spawned enemies and
1227
- * mid-level positions are not restored.** You resume at the scene's start with
1228
- * your stats, inventory, unlocks and quest flags intact. That is what a
1229
- * checkpoint save is, and it is what both quest templates need. A game that
1230
- * wants a position saves it — `serialize()` can return anything.
1231
- *
1232
- * Node uids are the join key because they are the one identifier the engine
1233
- * already promises survives a rename or a reparent.
1234
- */
1235
- /**
1236
- * Every behavior in the tree that has something to say, by uid.
1237
- *
1238
- * A behavior with no `serialize` contributes nothing — the hook is optional in
1239
- * exactly the way the other five are, so adding save support to a game is
1240
- * additive and a game that never saves pays nothing.
1241
- *
1242
- * A node with no `uid` is skipped and reported, because state keyed by nothing
1243
- * cannot be given back. The editor assigns a uid to every node it touches; a
1244
- * hand-written scene may not have.
1245
- */
1246
- function captureBehaviors(root) {
1247
- const state = {};
1248
- const unaddressable = [];
1249
- const walk = (node) => {
1250
- const behavior = node.behavior;
1251
- if (behavior && typeof behavior.serialize === "function") {
1252
- const value = behavior.serialize();
1253
- if (value !== void 0) if (node.uid) state[node.uid] = value;
1254
- else unaddressable.push(node.getPath());
1255
- }
1256
- for (const child of node.children) walk(child);
1257
- };
1258
- walk(root);
1259
- return {
1260
- state,
1261
- unaddressable
1262
- };
1263
- }
1264
- /**
1265
- * Hand each behavior its state back.
1266
- *
1267
- * Call this AFTER the scene has loaded and `onReady` has run: `onReady` is where
1268
- * a behavior sets its starting values, so restoring first would be overwritten
1269
- * by the fresh start.
1270
- *
1271
- * Never throws on a mismatch. A save from an older build of the game will name
1272
- * uids that no longer exist, and refusing to load it would mean a patch that
1273
- * moves one node deletes everyone's progress. It reports instead.
1274
- */
1275
- function restoreBehaviors(root, state) {
1276
- const report = {
1277
- missing: [],
1278
- skipped: [],
1279
- restored: 0
1280
- };
1281
- for (const [uid, value] of Object.entries(state)) {
1282
- const node = root.getNodeByUid(uid);
1283
- if (!node) {
1284
- report.missing.push(uid);
1285
- continue;
1286
- }
1287
- const behavior = node.behavior;
1288
- if (!behavior || typeof behavior.deserialize !== "function") {
1289
- report.skipped.push(uid);
1290
- continue;
1291
- }
1292
- try {
1293
- behavior.deserialize(value);
1294
- report.restored += 1;
1295
- } catch {
1296
- report.skipped.push(uid);
1297
- }
1298
- }
1299
- return report;
1300
- }
1301
- /** Slot ids, so a "3 save slots" menu is a list and not a naming convention. */
1302
- const INDEX_KEY = "slots";
1303
- /**
1304
- * The slot layer: a named list of saves over the existing store.
1305
- *
1306
- * Slot ids are yours (`'auto'`, `'1'`, `'2'`, `'3'`) — the engine does not
1307
- * decide how many you have or whether one of them autosaves.
1308
- */
1309
- var SaveSlots = class {
1310
- store;
1311
- constructor(namespace = "saves", store) {
1312
- this.store = store ?? createSaveStore(namespace);
1313
- }
1314
- /** Slot ids that have a save, newest first. */
1315
- list() {
1316
- const rows = this.store.get(INDEX_KEY, []).map((id) => ({
1317
- id,
1318
- slot: this.read(id)
1319
- })).filter((row) => row.slot !== null);
1320
- rows.sort((a, b) => b.slot.savedAt - a.slot.savedAt);
1321
- return rows.map((row) => row.id);
1322
- }
1323
- /** Every slot with its contents — what a load menu actually renders. */
1324
- all() {
1325
- return this.list().map((id) => ({
1326
- id,
1327
- ...this.read(id)
1328
- }));
1329
- }
1330
- read(id) {
1331
- const raw = this.store.get(key(id), null);
1332
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
1333
- const slot = raw;
1334
- if (typeof slot.scene !== "string" || typeof slot.state !== "object" || slot.state === null) return null;
1335
- return {
1336
- scene: slot.scene,
1337
- state: slot.state,
1338
- label: typeof slot.label === "string" ? slot.label : "",
1339
- savedAt: typeof slot.savedAt === "number" ? slot.savedAt : 0,
1340
- playtime: typeof slot.playtime === "number" ? slot.playtime : 0,
1341
- data: slot.data ?? {}
1342
- };
1343
- }
1344
- /**
1345
- * Write a slot.
1346
- *
1347
- * `savedAt` is a parameter rather than `Date.now()` so a test can assert an
1348
- * ordering without sleeping, and so a game that wants server time can use it.
1349
- */
1350
- write(id, slot) {
1351
- const full = {
1352
- scene: slot.scene,
1353
- state: slot.state,
1354
- label: slot.label ?? "",
1355
- savedAt: slot.savedAt ?? 0,
1356
- playtime: slot.playtime ?? 0,
1357
- data: slot.data ?? {}
1358
- };
1359
- this.store.set(key(id), full);
1360
- const ids = this.store.get(INDEX_KEY, []);
1361
- if (!ids.includes(id)) this.store.set(INDEX_KEY, [...ids, id]);
1362
- }
1363
- remove(id) {
1364
- this.store.remove(key(id));
1365
- this.store.set(INDEX_KEY, this.store.get(INDEX_KEY, []).filter((other) => other !== id));
1366
- }
1367
- /** Wipe every slot — a "delete all data" button, and test isolation. */
1368
- clear() {
1369
- for (const id of this.store.get(INDEX_KEY, [])) this.store.remove(key(id));
1370
- this.store.set(INDEX_KEY, []);
1371
- }
1372
- };
1373
- function key(id) {
1374
- return `slot:${id}`;
1375
- }
1376
- /**
1377
- * Behaviors in this tree that hold state and cannot save it — the audit's
1378
- * question, answerable before a player loses an hour.
1379
- *
1380
- * A behavior with props but no `serialize` is not necessarily wrong (an
1381
- * `Oscillate` derives everything from time), so this reports rather than warns,
1382
- * and the caller decides.
1383
- */
1384
- function behaviorsWithoutSave(root) {
1385
- const out = [];
1386
- const walk = (node) => {
1387
- const behavior = node.behavior;
1388
- const name = node.script?.name;
1389
- if (behavior && typeof name === "string" && typeof behavior.serialize !== "function") {
1390
- if (Object.keys(behaviorSchema(name)).length > 0) out.push(`${node.getPath()} (${name})`);
1391
- }
1392
- for (const child of node.children) walk(child);
1393
- };
1394
- walk(root);
1395
- return out;
1396
- }
1397
- //#endregion
1398
1137
  //#region src/core/settings.ts
1399
1138
  const DEFAULTS = {
1400
1139
  master: 1,
@@ -1890,7 +1629,7 @@ var Engine = class {
1890
1629
  captureState() {
1891
1630
  const root = this.scene?.root;
1892
1631
  if (!root) return {};
1893
- const { state, unaddressable } = captureBehaviors(root);
1632
+ const { state, unaddressable } = captureBehaviors(root, this.scene?.source);
1894
1633
  for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
1895
1634
  return state;
1896
1635
  }
@@ -1900,7 +1639,9 @@ var Engine = class {
1900
1639
  if (!root) return {
1901
1640
  missing: Object.keys(state),
1902
1641
  skipped: [],
1903
- restored: 0
1642
+ restored: 0,
1643
+ expected: 0,
1644
+ freed: 0
1904
1645
  };
1905
1646
  return restoreBehaviors(root, state);
1906
1647
  }
@@ -2399,6 +2140,19 @@ function widgetFromElement(el) {
2399
2140
  let dragging = null;
2400
2141
  /** Shared plumbing: mount into the parent HudLayer's anchor slot. */
2401
2142
  var HudWidgetBase = class extends Node {
2143
+ /**
2144
+ * Wireable visibility: `noSave → Continue.hide`.
2145
+ *
2146
+ * `visible` is a prop, and a `connections[]` handler has to be a METHOD — the
2147
+ * same wall that kept a score off the screen until widgets grew `setText`. A
2148
+ * title screen greying out its own Continue button is the case that asked.
2149
+ */
2150
+ show(..._args) {
2151
+ this.visible = true;
2152
+ }
2153
+ hide() {
2154
+ this.visible = false;
2155
+ }
2402
2156
  static props = {
2403
2157
  anchor: {
2404
2158
  default: "topLeft",
@@ -3630,4 +3384,4 @@ function registerCoreNodes() {
3630
3384
  registerNode(UiRenderScaleSelect);
3631
3385
  }
3632
3386
  //#endregion
3633
- export { T_PREFIX as A, synthSfx as B, behaviorsWithoutSave as C, LogManager as D, createSaveStore as E, ROLLOFF_MODELS as F, AudioBuses as G, WebAudioMusicBackend as H, spatialGain as I, spatialPan as L, translationKey as M, SfxEngine as N, BASE_LOCALE as O, isAudioContextAvailable as P, SFX_PRESETS as R, SaveSlots as S, restoreBehaviors as T, crossfadeGains as U, MusicManager as V, fadeGain as W, qualityCaps as _, UiSlider as a, readDeviceHints as b, UiButton as c, UiBanner as d, UiBar as f, Settings as g, Engine as h, UiSelect as i, suggestLocale as j, Localization as k, UiDialogue as l, AudioPlayer as m, UiImage as n, UiToggle as o, UiText as p, UiPanel as r, Timer as s, registerCoreNodes as t, HudLayer as u, qualityEnvironment as v, captureBehaviors as w, suggestQuality as x, qualityRendering as y, SFX_PRESET_NAMES as z };
3387
+ export { ROLLOFF_MODELS as A, AudioBuses as B, BASE_LOCALE as C, translationKey as D, suggestLocale as E, synthSfx as F, MusicManager as I, WebAudioMusicBackend as L, spatialPan as M, SFX_PRESETS as N, SfxEngine as O, SFX_PRESET_NAMES as P, crossfadeGains as R, LogManager as S, T_PREFIX as T, qualityCaps as _, UiSlider as a, readDeviceHints as b, UiButton as c, UiBanner as d, UiBar as f, Settings as g, Engine as h, UiSelect as i, spatialGain as j, isAudioContextAvailable as k, UiDialogue as l, AudioPlayer as m, UiImage as n, UiToggle as o, UiText as p, UiPanel as r, Timer as s, registerCoreNodes as t, HudLayer as u, qualityEnvironment as v, Localization as w, suggestQuality as x, qualityRendering as y, fadeGain as z };
@@ -1,5 +1,5 @@
1
- import { E as Signal, f as Node, t as buildNodeJson } from "./loader-DwazzlQb.js";
2
- import { t as registerCoreNodes } from "./register-uvaZj1KX.js";
1
+ import { M as Signal, f as Node, t as buildNodeJson } from "./loader-B2asghWa.js";
2
+ import { t as registerCoreNodes } from "./register-CGq-Hee8.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
5
  import { l as registerNode } from "./registry-IyWCGe4q.js";
@@ -1,4 +1,4 @@
1
- import { M as translationKey } from "./register-uvaZj1KX.js";
1
+ import { D as translationKey } from "./register-CGq-Hee8.js";
2
2
  //#region src/core/audit.ts
3
3
  const ANIMATED_TYPES = new Set([
4
4
  "Particles2D",
@@ -31,6 +31,21 @@ const LIGHT_TYPES = new Set([
31
31
  "OmniLight3D",
32
32
  "SpotLight3D"
33
33
  ]);
34
+ /**
35
+ * Built-ins that put something in a save. A save is keyed by node uid, so one
36
+ * of these on a node without one is state that silently goes nowhere: the save
37
+ * comes back `{}` and the load reports a clean bill, because "nothing was
38
+ * missing" is true of an empty save.
39
+ *
40
+ * A JSON walk cannot ask a behavior whether it serializes, so this is the list
41
+ * of the ones that do. Your own behavior with a `serialize()` is caught at
42
+ * runtime instead, by `captureState`.
43
+ */
44
+ const SAVEABLE_SCRIPTS = new Set([
45
+ "Health",
46
+ "ScoreKeeper",
47
+ "Collector"
48
+ ]);
34
49
  /** Does the scene header light anything by itself (sky, hdri, ambient, preset)? */
35
50
  function isLit(environment) {
36
51
  if (!environment) return false;
@@ -47,9 +62,12 @@ function auditScene(scene) {
47
62
  let currentCameras = 0;
48
63
  let cameras = 0;
49
64
  let lights = 0;
65
+ const needUid = [];
50
66
  const walk = (node, path, insideHud, staticRoot) => {
51
67
  const type = node.type ?? "";
52
68
  const props = node.props ?? {};
69
+ const script = node.script?.name;
70
+ if (script && SAVEABLE_SCRIPTS.has(script) && !node.uid) needUid.push(`${path} (${script})`);
53
71
  if (type === "Camera2D" || type === "Camera3D") {
54
72
  cameras += 1;
55
73
  if (props.current === true) currentCameras += 1;
@@ -66,6 +84,7 @@ function auditScene(scene) {
66
84
  for (const child of node.children ?? []) walk(child, `${path}/${child.name ?? "?"}`, nextHud, nextStatic);
67
85
  };
68
86
  walk(root, root.name ?? "root", false, root.props?.static === true ? root.name ?? "root" : null);
87
+ if (needUid.length > 0) warnings.push(`these carry state a save keeps and have no uid to key it under, so the save comes back EMPTY and the load reports no problem: ${needUid.join(", ")}. Give each one a "uid" from newUid().`);
69
88
  if (cameras > 0 && currentCameras === 0) warnings.push("no camera has \"current\": true — the screen renders from a default view, probably not what you framed.");
70
89
  if (currentCameras > 1) warnings.push(`${currentCameras} cameras claim "current": true — only the first found wins.`);
71
90
  if (scene.dimension === "3d" && lights === 0 && !isLit(scene.environment)) warnings.push("nothing lights this 3D scene — no light node, no environment sky/hdri, and no ambient intensity. It will render black. Add a DirectionalLight3D, or an \"environment\": { \"sky\": { \"type\": \"atmosphere\" } } / { \"preset\": \"...\" }.");
@@ -109,13 +128,25 @@ function auditStrings(scene) {
109
128
  }
110
129
  //#endregion
111
130
  //#region src/core/replay.ts
131
+ /**
132
+ * Every entry point that can change what the game is being told, because one
133
+ * left out is a replay that reproduces a character standing still.
134
+ *
135
+ * `setActionVector` was the omission that mattered: the playtest driver moves
136
+ * with it and presses buttons with `pressAction`, so its replays recorded all
137
+ * the jumping and none of the walking, and played back as a character hopping
138
+ * on the spot where the real run had walked off a ledge.
139
+ */
112
140
  const RECORDED_METHODS = [
113
141
  "handleKey",
114
142
  "handleMouseButton",
115
143
  "handlePointerMove",
116
144
  "handleWheel",
117
145
  "pressAction",
118
- "releaseAction"
146
+ "releaseAction",
147
+ "setActionVector",
148
+ "setPointerPosition",
149
+ "clearInjected"
119
150
  ];
120
151
  /**
121
152
  * Tap the engine's input entry points and tick clock. Recording starts at
@@ -139,17 +170,23 @@ function startRecording(engine) {
139
170
  };
140
171
  }
141
172
  const originalTick = engine.tick.bind(engine);
173
+ const originalStep = engine.step.bind(engine);
142
174
  const engineAny = engine;
143
175
  engineAny.tick = (nowMs) => {
144
176
  ticks.push(nowMs);
145
177
  originalTick(nowMs);
146
178
  };
179
+ engineAny.step = () => {
180
+ ticks.push(null);
181
+ originalStep();
182
+ };
147
183
  let stopped = false;
148
184
  return { stop() {
149
185
  if (!stopped) {
150
186
  stopped = true;
151
187
  for (const [name, fn] of originals) input[name] = fn;
152
188
  engineAny.tick = originalTick;
189
+ engineAny.step = originalStep;
153
190
  }
154
191
  return {
155
192
  replay: 1,
@@ -173,7 +210,9 @@ function replay(engine, recording, opts) {
173
210
  if (typeof fn === "function") fn.apply(engine.input, args);
174
211
  cursor += 1;
175
212
  }
176
- engine.tick(recording.ticks[i]);
213
+ const at = recording.ticks[i];
214
+ if (at === null) engine.step();
215
+ else engine.tick(at);
177
216
  opts?.onTick?.(i);
178
217
  }
179
218
  }
@@ -1,4 +1,4 @@
1
- import { b as Engine } from "./behavior-62q0HWBO.js";
1
+ import { b as Engine } from "./behavior-rZNfzVbH.js";
2
2
  import { s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/core/audit.d.ts
@@ -62,7 +62,11 @@ interface ReplayJson {
62
62
  /** File-format marker for forward compatibility. */
63
63
  replay: 1;
64
64
  /** Tick timestamps (ms) exactly as the loop delivered them. */
65
- ticks: number[];
65
+ /**
66
+ * One entry per recorded FRAME: the `nowMs` it was ticked with, or `null` for
67
+ * a fixed `step()`. Older recordings are all numbers and still replay.
68
+ */
69
+ ticks: Array<number | null>;
66
70
  events: ReplayEvent[];
67
71
  }
68
72
  interface Recorder {
@@ -161,6 +161,6 @@ function newUid() {
161
161
  //#endregion
162
162
  //#region src/index.ts
163
163
  /** Engine version. Kept in sync with package.json by the release pipeline. */
164
- const VERSION = "0.49.0";
164
+ const VERSION = "0.51.0";
165
165
  //#endregion
166
166
  export { findPath as a, preloadUrls as i, newUid as n, gridFromRows as o, assetUrls as r, VERSION as t };
@@ -1,13 +1,13 @@
1
- import { n as loadScene, s as resolveViewport, w as registerBehavior } from "./loader-DwazzlQb.js";
2
- import { h as Engine } from "./register-uvaZj1KX.js";
1
+ import { A as registerBehavior, n as loadScene, s as resolveViewport } from "./loader-B2asghWa.js";
2
+ import { h as Engine } from "./register-CGq-Hee8.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { n as startRecording } from "./replay-C0XJIsO7.js";
4
+ import { n as startRecording } from "./replay-BgKxGcXH.js";
5
5
  import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
6
6
  import { i as getNodeSchema, s as mergeStaticProps } from "./registry-IyWCGe4q.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-02Btmmjn.js";
8
- import { t as registerNodes2D } from "./register-BNPZYJmd.js";
9
- import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-CZOH5TY5.js";
10
- import { t as registerNodesNet } from "./register-CB11yp21.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-bStgtZBV.js";
8
+ import { t as registerNodes2D } from "./register-B_BaaGx-.js";
9
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-C08pOC6H.js";
10
+ import { t as registerNodesNet } from "./register-CLVhzWcI.js";
11
11
  import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
12
12
  //#region src/test/framing.ts
13
13
  /**
@@ -40,6 +40,8 @@ const LIGHT_TYPES = new Set([
40
40
  ]);
41
41
  /** Screen-space subtrees: they ignore the camera, so framing has nothing to say. */
42
42
  const SCREEN_SPACE = new Set(["UILayer", "HudLayer"]);
43
+ /** Depth a 2D collider is given on the axis it does not have. */
44
+ const FLAT_DEPTH = 1e3;
43
45
  /** How far two colliders must actually interpenetrate to be a finding. */
44
46
  const TOUCH_EPS_2D = 1;
45
47
  const TOUCH_EPS_3D = .01;
@@ -369,31 +371,56 @@ function cornersOnScreen(box, cam) {
369
371
  * trying to see anyway.
370
372
  */
371
373
  function findOverlaps(placed, epsilon) {
372
- const boxes = [];
374
+ const shapes = [];
373
375
  for (const p of placed) {
374
- const half = halfExtents(p.node);
375
- if (!half) continue;
376
- const shrunk = half.clone().multiplyScalar(2).addScalar(-2 * epsilon);
377
- boxes.push({
376
+ const shape = shapeOf(p.node, p.position);
377
+ if (!shape) continue;
378
+ shapes.push({
378
379
  path: p.node.getPath(),
379
380
  internal: p.node.name.startsWith("__"),
380
- box: new Box3().setFromCenterAndSize(p.position, shrunk.max(new Vector3(0, 0, 0)))
381
+ shape
381
382
  });
382
383
  }
383
384
  const out = [];
384
- for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
385
- const a = boxes[i];
386
- const b = boxes[j];
385
+ for (let i = 0; i < shapes.length; i++) for (let j = i + 1; j < shapes.length; j++) {
386
+ const a = shapes[i];
387
+ const b = shapes[j];
387
388
  if (!a || !b) continue;
388
389
  if (a.path.startsWith(`${b.path}/`) || b.path.startsWith(`${a.path}/`)) continue;
389
390
  if (a.internal && b.internal) continue;
390
- if (a.box.intersectsBox(b.box)) out.push({
391
+ if (penetrates(a.shape, b.shape, epsilon)) out.push({
391
392
  a: a.path,
392
393
  b: b.path
393
394
  });
394
395
  }
395
396
  return out;
396
397
  }
398
+ function shapeOf(node, centre) {
399
+ const collider = node.collider;
400
+ const kind = collider?.shape;
401
+ if (kind === "sphere" || kind === "circle") return {
402
+ kind: "ball",
403
+ centre,
404
+ radius: collider?.radius ?? .5
405
+ };
406
+ const half = halfExtents(node);
407
+ if (!half) return null;
408
+ return {
409
+ kind: "box",
410
+ centre,
411
+ half: kind === "rect" ? new Vector3(half.x, half.y, FLAT_DEPTH) : half
412
+ };
413
+ }
414
+ /** True when the two shapes share more than `eps` of space, in every axis. */
415
+ function penetrates(a, b, eps) {
416
+ if (a.kind === "ball" && b.kind === "ball") return a.centre.distanceTo(b.centre) < a.radius + b.radius - eps;
417
+ if (a.kind === "ball" || b.kind === "ball") {
418
+ const ball = a.kind === "ball" ? a : b;
419
+ const box = a.kind === "ball" ? b : a;
420
+ return new Vector3(Math.max(0, Math.abs(ball.centre.x - box.centre.x) - box.half.x), Math.max(0, Math.abs(ball.centre.y - box.centre.y) - box.half.y), Math.max(0, Math.abs(ball.centre.z - box.centre.z) - box.half.z)).length() < ball.radius - eps;
421
+ }
422
+ return Math.abs(a.centre.x - b.centre.x) < a.half.x + b.half.x - eps && Math.abs(a.centre.y - b.centre.y) < a.half.y + b.half.y - eps && Math.abs(a.centre.z - b.centre.z) < a.half.z + b.half.z - eps;
423
+ }
397
424
  function round(n) {
398
425
  return Math.round(n * 1e3) / 1e3;
399
426
  }
@@ -1594,10 +1621,10 @@ async function runScript(json, opts) {
1594
1621
  engine.setScene(scene);
1595
1622
  const physics = opts.physics ?? "auto";
1596
1623
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1597
- const { enablePhysics2D } = await import("./physics-2d-vyCBfACH.js").then((n) => n.r);
1624
+ const { enablePhysics2D } = await import("./physics-2d-Ccq2L9R0.js").then((n) => n.r);
1598
1625
  await enablePhysics2D(engine);
1599
1626
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1600
- const { enablePhysics3D } = await import("./physics-3d-DpRqw8Mz.js").then((n) => n.r);
1627
+ const { enablePhysics3D } = await import("./physics-3d-B2c5KNYh.js").then((n) => n.r);
1601
1628
  await enablePhysics3D(engine);
1602
1629
  }
1603
1630
  const failures = [];
@@ -1710,10 +1737,10 @@ async function createPlaySession(json, opts = {}) {
1710
1737
  engine.setScene(scene);
1711
1738
  const physics = opts.physics ?? "auto";
1712
1739
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1713
- const { enablePhysics2D } = await import("./physics-2d-vyCBfACH.js").then((n) => n.r);
1740
+ const { enablePhysics2D } = await import("./physics-2d-Ccq2L9R0.js").then((n) => n.r);
1714
1741
  await enablePhysics2D(engine);
1715
1742
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1716
- const { enablePhysics3D } = await import("./physics-3d-DpRqw8Mz.js").then((n) => n.r);
1743
+ const { enablePhysics3D } = await import("./physics-3d-B2c5KNYh.js").then((n) => n.r);
1717
1744
  await enablePhysics3D(engine);
1718
1745
  }
1719
1746
  const stepMs = 1e3 / (opts.fixedHz ?? 60);
package/dist/test.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { Et as Node, P as Scene, b as Engine, kt as LogEntry, n as BehaviorCtor } from "./behavior-62q0HWBO.js";
1
+ import { At as LogEntry, Dt as Node, P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-rZNfzVbH.js";
2
2
  import { c as JsonValue, i as SceneJson } from "./schema-CFeioQRE.js";
3
- import { t as LoadSceneOptions } from "./loader-CeyU_bm1.js";
4
- import { l as auditScene, o as IncantoError, r as ReplayJson } from "./replay-CAphXMyM.js";
3
+ import { t as LoadSceneOptions } from "./loader-Dkbn56KC.js";
4
+ import { l as auditScene, o as IncantoError, r as ReplayJson } from "./replay-DIP2_as4.js";
5
5
 
6
6
  //#region src/test/facing.d.ts
7
7
  interface FacingOptions {
package/dist/test.js CHANGED
@@ -1,3 +1,3 @@
1
- import { r as auditScene } from "./replay-C0XJIsO7.js";
2
- import { _ as playtestText, a as registerAllNodes, c as ladderText, d as feelText, f as facingReport, g as playtest, h as findPlayer, i as findFloatingProps, l as ladderVerdict, m as failingReplays, n as createPlaySession, o as runScript, p as facingText, r as describeCapture, s as validateScene, t as captureScene, u as feelReport, v as describeFraming, y as framingText } from "./test-DRna_BQU.js";
1
+ import { r as auditScene } from "./replay-BgKxGcXH.js";
2
+ import { _ as playtestText, a as registerAllNodes, c as ladderText, d as feelText, f as facingReport, g as playtest, h as findPlayer, i as findFloatingProps, l as ladderVerdict, m as failingReplays, n as createPlaySession, o as runScript, p as facingText, r as describeCapture, s as validateScene, t as captureScene, u as feelReport, v as describeFraming, y as framingText } from "./test-D2gRpk5V.js";
3
3
  export { auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, ladderText, ladderVerdict, playtest, playtestText, registerAllNodes, runScript, validateScene };
package/dist/vite.js CHANGED
@@ -1,6 +1,6 @@
1
- import { t as VERSION } from "./src-DF4gCsqO.js";
1
+ import { t as VERSION } from "./src-C3UwzYXl.js";
2
2
  import { n as diffSignatures } from "./frame-report-njybhZon.js";
3
- import { s as validateScene } from "./test-DRna_BQU.js";
3
+ import { s as validateScene } from "./test-D2gRpk5V.js";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
6
6
  //#region src/vite/frame-endpoint.ts
@@ -1 +1 @@
1
- import{n as e}from"./index-5dEIhvsf.js";async function t(t){return new n((await e(()=>import(`./GameServer-C56iOUgF.js`),[],import.meta.url)).GameServer,t)}var n=class{raw;active=new Map;reconnecting=!1;disposed=!1;constructor(e,t){this.raw=new e({...t})}get account(){return this.raw.account}get connected(){return this.raw.connected}connect(){return this.raw.connected?Promise.resolve(!0):(this.disposed=!1,this.rawConnect())}rawConnect(){return this.raw.connect({onDisconnect:()=>void this.reconnect()})}disconnect(){this.disposed=!0;for(let e of this.active.values())e.off();return this.active.clear(),this.raw.disconnect()}remoteFunction(e,t,n){return this.raw.remoteFunction(e,t,n)}track(e){let t=Symbol(`sub`),n={make:e,off:e()};return this.active.set(t,n),()=>{n.off(),this.active.delete(t)}}async reconnect(){if(!this.disposed&&!this.reconnecting){this.reconnecting=!0;try{await this.rawConnect();for(let e of this.active.values())e.off(),e.off=e.make()}finally{this.reconnecting=!1}}}subscribeRoomState(e,t){return this.track(()=>this.raw.subscribeRoomState(e,t))}subscribeRoomMyState(e,t){return this.track(()=>this.raw.subscribeRoomMyState(e,t))}subscribeRoomAllUserStates(e,t){return this.track(()=>this.raw.subscribeRoomAllUserStates(e,e=>{let n={};for(let t of e??[]){if(!t||typeof t.account!=`string`||t.__leaved)continue;let{account:e,__updated:r,__leaved:i,...a}=t;n[e]=a}t(n)}))}subscribeRoomCollection(e,t,n){return this.track(()=>this.raw.subscribeRoomCollection(e,t,({items:e})=>{let t={};for(let n of e??[])n&&typeof n.__id==`string`&&(t[n.__id]=n);n(t)}))}onRoomMessage(e,t,n){return this.track(()=>this.raw.onRoomMessage(e,t,n))}onRoomUserJoin(e,t){return this.track(()=>this.raw.onRoomUserJoin(e,t))}onRoomUserLeave(e,t){return this.track(()=>this.raw.onRoomUserLeave(e,t))}subscribeGlobalState(e){return this.track(()=>this.raw.subscribeGlobalState(e))}subscribeGlobalMyState(e){return this.track(()=>this.raw.subscribeGlobalMyState(e))}subscribeGlobalUserState(e,t){return this.track(()=>this.raw.subscribeGlobalUserState(e,t))}subscribeGlobalCollection(e,t){return this.track(()=>this.raw.subscribeGlobalCollection(e,({items:e})=>{let n={};for(let t of e??[])t&&typeof t.__id==`string`&&(n[t.__id]=t);t(n)}))}subscribeAsset(e,t){return this.track(()=>this.raw.subscribeAsset(e,t))}onGlobalMessage(e,t){return this.track(()=>this.raw.onGlobalMessage(e,t))}};export{t as createAgent8Server};
1
+ import{n as e}from"./index-CUc1U7wm.js";async function t(t){return new n((await e(()=>import(`./GameServer-C56iOUgF.js`),[],import.meta.url)).GameServer,t)}var n=class{raw;active=new Map;reconnecting=!1;disposed=!1;constructor(e,t){this.raw=new e({...t})}get account(){return this.raw.account}get connected(){return this.raw.connected}connect(){return this.raw.connected?Promise.resolve(!0):(this.disposed=!1,this.rawConnect())}rawConnect(){return this.raw.connect({onDisconnect:()=>void this.reconnect()})}disconnect(){this.disposed=!0;for(let e of this.active.values())e.off();return this.active.clear(),this.raw.disconnect()}remoteFunction(e,t,n){return this.raw.remoteFunction(e,t,n)}track(e){let t=Symbol(`sub`),n={make:e,off:e()};return this.active.set(t,n),()=>{n.off(),this.active.delete(t)}}async reconnect(){if(!this.disposed&&!this.reconnecting){this.reconnecting=!0;try{await this.rawConnect();for(let e of this.active.values())e.off(),e.off=e.make()}finally{this.reconnecting=!1}}}subscribeRoomState(e,t){return this.track(()=>this.raw.subscribeRoomState(e,t))}subscribeRoomMyState(e,t){return this.track(()=>this.raw.subscribeRoomMyState(e,t))}subscribeRoomAllUserStates(e,t){return this.track(()=>this.raw.subscribeRoomAllUserStates(e,e=>{let n={};for(let t of e??[]){if(!t||typeof t.account!=`string`||t.__leaved)continue;let{account:e,__updated:r,__leaved:i,...a}=t;n[e]=a}t(n)}))}subscribeRoomCollection(e,t,n){return this.track(()=>this.raw.subscribeRoomCollection(e,t,({items:e})=>{let t={};for(let n of e??[])n&&typeof n.__id==`string`&&(t[n.__id]=n);n(t)}))}onRoomMessage(e,t,n){return this.track(()=>this.raw.onRoomMessage(e,t,n))}onRoomUserJoin(e,t){return this.track(()=>this.raw.onRoomUserJoin(e,t))}onRoomUserLeave(e,t){return this.track(()=>this.raw.onRoomUserLeave(e,t))}subscribeGlobalState(e){return this.track(()=>this.raw.subscribeGlobalState(e))}subscribeGlobalMyState(e){return this.track(()=>this.raw.subscribeGlobalMyState(e))}subscribeGlobalUserState(e,t){return this.track(()=>this.raw.subscribeGlobalUserState(e,t))}subscribeGlobalCollection(e,t){return this.track(()=>this.raw.subscribeGlobalCollection(e,({items:e})=>{let n={};for(let t of e??[])t&&typeof t.__id==`string`&&(n[t.__id]=t);t(n)}))}subscribeAsset(e,t){return this.track(()=>this.raw.subscribeAsset(e,t))}onGlobalMessage(e,t){return this.track(()=>this.raw.onGlobalMessage(e,t))}};export{t as createAgent8Server};