incanto 0.36.2 → 0.38.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 (57) hide show
  1. package/bin/incanto-feel.mjs +93 -0
  2. package/bin/incanto-playtest.mjs +136 -0
  3. package/dist/2d.d.ts +2 -2
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +44 -3
  6. package/dist/3d.js +4 -32
  7. package/dist/{behavior-DibCwrW7.d.ts → behavior-B5PJ32eu.d.ts} +106 -6
  8. package/dist/{create-game-D-StzrTj.js → create-game-C4t5gVhc.js} +5 -5
  9. package/dist/{create-game-DnOu2aAa.js → create-game-DqY2lMBc.js} +783 -689
  10. package/dist/debug.d.ts +1 -1
  11. package/dist/{duplicate-Cvb1BSca.js → duplicate-CRtihGmC.js} +1 -1
  12. package/dist/editor.js +21 -0
  13. package/dist/{environment-presets-fK9oyrrR.js → environment-presets-C0Axpfth.js} +75 -9
  14. package/dist/{gameplay-C0MUMSak.js → gameplay-CsJDUQh_.js} +2 -2
  15. package/dist/gameplay.d.ts +1 -1
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +4 -50
  18. package/dist/index.js +5 -75
  19. package/dist/{loader-BqigZsfF.d.ts → loader-DSMdqasu.d.ts} +1 -1
  20. package/dist/{loader-DAZlgqeC.js → loader-r49nDwB4.js} +15 -0
  21. package/dist/net.d.ts +1 -1
  22. package/dist/net.js +3 -3
  23. package/dist/{pathfinding-B3QtdhvZ.d.ts → pathfinding-BrE5Uec6.d.ts} +1 -1
  24. package/dist/{physics-2d-B4v39hca.js → physics-2d-OfKb0PV_.js} +2 -2
  25. package/dist/{physics-3d-DFJVMYET.js → physics-3d-C8242nXr.js} +3 -3
  26. package/dist/react.d.ts +1 -1
  27. package/dist/react.js +1 -1
  28. package/dist/{register-DXwtIVqP.js → register-B4RRQbyq.js} +2 -2
  29. package/dist/{register-BHAwM4bK.js → register-Dm7nrd-E.js} +322 -34
  30. package/dist/{register-COGCNitr.js → register-DnB9m7Rl.js} +2 -2
  31. package/dist/replay-Cxrr-ApH.d.ts +86 -0
  32. package/dist/{audit-D7F3n3Nt.js → replay-DH0UOuZ-.js} +72 -2
  33. package/dist/test-CoDPWx-m.js +1404 -0
  34. package/dist/test.d.ts +113 -4
  35. package/dist/test.js +3 -3
  36. package/dist/vite.js +1 -1
  37. package/editor/assets/{agent8-DMvBVhe4.js → agent8-BtIlblPm.js} +1 -1
  38. package/editor/assets/{debug-BgAq55oC.js → debug-BvTmHXI7.js} +1 -1
  39. package/editor/assets/{index-Cy_QsKbh.js → index-BS01WWgM.js} +91 -91
  40. package/editor/index.html +1 -1
  41. package/package.json +3 -1
  42. package/schemas/scene.schema.json +318 -0
  43. package/skills/incanto-3d-character.md +35 -0
  44. package/skills/incanto-building-2d-games.md +6 -0
  45. package/skills/incanto-building-3d-games.md +6 -0
  46. package/skills/incanto-game-feel.md +76 -0
  47. package/skills/incanto-node-reference.md +51 -0
  48. package/skills/incanto-performance.md +212 -0
  49. package/skills/incanto-playtesting.md +143 -0
  50. package/skills/incanto-verifying-your-game.md +41 -0
  51. package/templates-app/beacon-isle-3d/package.json +1 -1
  52. package/templates-app/tps-3d/package.json +1 -1
  53. package/templates-app/village-quest-3d/package.json +1 -1
  54. package/templates-app/village-quest-3d/src/village.scene.json +3 -1
  55. package/templates-app/village-quest-3d/verify.ts +55 -5
  56. package/dist/errors-DGRtWlSx.d.ts +0 -39
  57. package/dist/test-X_xE6Yay.js +0 -642
@@ -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-DAZlgqeC.js";
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-r49nDwB4.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";
@@ -1403,7 +1403,10 @@ const DEFAULTS = {
1403
1403
  sensitivity: 1,
1404
1404
  invertY: false,
1405
1405
  reduceMotion: false,
1406
- language: "en"
1406
+ language: "en",
1407
+ qualityChosen: false,
1408
+ maxFps: 0,
1409
+ renderScale: 1
1407
1410
  };
1408
1411
  const TIERS = [
1409
1412
  "low",
@@ -1469,6 +1472,64 @@ var Settings = class {
1469
1472
  for (const key of Object.keys(DEFAULTS)) this.set(key, DEFAULTS[key]);
1470
1473
  }
1471
1474
  /**
1475
+ * Choose a starting tier from the device, ONCE.
1476
+ *
1477
+ * Only on a first visit: after someone has picked a tier, re-detecting on
1478
+ * every launch would silently undo their choice — which is the behaviour every
1479
+ * player experiences as "the settings do not save".
1480
+ */
1481
+ autoQuality(hints = readDeviceHints()) {
1482
+ if (!this.values.qualityChosen) this.values.quality = suggestQuality(hints);
1483
+ return this.values.quality;
1484
+ }
1485
+ /** Record that the tier came from a person, not from the device probe. */
1486
+ chooseQuality(tier) {
1487
+ this.set("quality", tier);
1488
+ this.set("qualityChosen", true);
1489
+ }
1490
+ /**
1491
+ * Apply the saved quality tier to a live scene, and keep applying it.
1492
+ *
1493
+ * The same shape as `bindAudio` and `bindLocale`. Until 0.38 this did not
1494
+ * exist: `settings.quality` was written to disk and read by NOBODY, and
1495
+ * `qualityEnvironment` — the function that turns a tier into something the
1496
+ * renderer understands — emitted patches the environment parser rejects, for
1497
+ * all three tiers. Both were public, documented and never once called.
1498
+ */
1499
+ bindQuality(apply) {
1500
+ apply(qualityEnvironment(this.values.quality));
1501
+ return this.changed.connect((key) => {
1502
+ if (key === "quality") apply(qualityEnvironment(this.values.quality));
1503
+ });
1504
+ }
1505
+ /**
1506
+ * Apply the saved frame cap, and again whenever it changes.
1507
+ *
1508
+ * Separate from `bindQuality` because it is not a tier: a player caps the
1509
+ * frame rate to keep a phone cool or a battery alive, on the highest visual
1510
+ * settings the device can hold, and those two choices do not belong on one
1511
+ * slider. 0 means uncapped. Returns the unsubscribe.
1512
+ */
1513
+ bindFrameCap(apply) {
1514
+ apply(this.values.maxFps);
1515
+ return this.changed.connect((key) => {
1516
+ if (key === "maxFps") apply(this.values.maxFps);
1517
+ });
1518
+ }
1519
+ /**
1520
+ * Apply the saved resolution multiplier, and again whenever it changes.
1521
+ *
1522
+ * The lever that buys the most frames per unit of ugliness on a weak GPU —
1523
+ * shading 44% of the pixels at 0.66 costs a soft image and nothing else,
1524
+ * while dropping a quality tier costs shadows entirely.
1525
+ */
1526
+ bindRenderScale(apply) {
1527
+ apply(this.values.renderScale);
1528
+ return this.changed.connect((key) => {
1529
+ if (key === "renderScale") apply(this.values.renderScale);
1530
+ });
1531
+ }
1532
+ /**
1472
1533
  * Apply the saved language, and keep it in step both ways.
1473
1534
  *
1474
1535
  * The same shape as `bindAudio`: a game that writes
@@ -1502,45 +1563,67 @@ var Settings = class {
1502
1563
  }
1503
1564
  };
1504
1565
  /**
1505
- * What a quality tier MEANS, as an environment patch.
1566
+ * What a quality tier MEANS, as a LIVE environment patch.
1567
+ *
1568
+ * Only the levers that actually cost: shadows, post, bloom and clouds.
1569
+ * Deliberately an `environment` patch, because `setEnvironment3D(engine, patch)`
1570
+ * already applies one live and validated — this is not a second rendering
1571
+ * pipeline, it is three presets for the one that exists.
1506
1572
  *
1507
- * Only the levers that actually cost: shadows, post, clouds, and the resolution
1508
- * the renderer draws at. Deliberately an `environment` patch, because
1509
- * `setEnvironment3D(engine, patch)` already applies one live and validated
1510
- * this is not a second rendering pipeline, it is three presets for the one that
1511
- * exists.
1573
+ * The `off` spelling matters and got this wrong for three releases: every tier
1574
+ * emitted `{ enabled: false }`, which the environment parser rejects
1575
+ * (`environment.bloom has unknown key 'enabled'`). Nothing called this function,
1576
+ * so nothing found out. In a live patch `null` DELETES a key that is how a
1577
+ * stage is turned off — and `shadows` alone takes a real `false`.
1578
+ *
1579
+ * Antialiasing and pixel ratio are NOT here: `antialias` is a WebGL context
1580
+ * attribute and cannot change without rebuilding the renderer. See
1581
+ * `qualityRendering`.
1512
1582
  */
1513
1583
  function qualityEnvironment(tier) {
1514
1584
  if (tier === "low") return {
1515
- shadows: { enabled: false },
1516
- bloom: { enabled: false },
1517
- post: { enabled: false },
1518
- clouds: { enabled: false },
1519
- rendering: {
1520
- antialias: false,
1521
- pixelRatio: 1
1522
- }
1585
+ shadows: false,
1586
+ bloom: null,
1587
+ post: null,
1588
+ clouds: null
1523
1589
  };
1524
1590
  if (tier === "medium") return {
1525
- shadows: {
1526
- enabled: true,
1527
- static: true
1528
- },
1529
- bloom: { enabled: true },
1530
- post: { enabled: false },
1531
- rendering: {
1532
- antialias: true,
1533
- pixelRatio: 1
1534
- }
1591
+ shadows: { static: true },
1592
+ bloom: {},
1593
+ post: null
1535
1594
  };
1536
1595
  return {
1537
- shadows: { enabled: true },
1538
- bloom: { enabled: true },
1539
- post: { enabled: true },
1540
- rendering: {
1541
- antialias: true,
1542
- pixelRatio: "device"
1543
- }
1596
+ shadows: true,
1597
+ bloom: {},
1598
+ post: {}
1599
+ };
1600
+ }
1601
+ /**
1602
+ * What a quality tier means for the levers that can only be set at BOOT.
1603
+ *
1604
+ * `antialias` is a context attribute: three fixes it when the WebGL context is
1605
+ * created, so a settings menu that changes it is choosing what the NEXT launch
1606
+ * gets. Every engine has this constraint and every game's options screen says
1607
+ * "requires restart" next to it — `createGame3D` reads the saved tier at boot,
1608
+ * so the restart is all it takes.
1609
+ *
1610
+ * `pixelRatio` CAN change live (adaptive resolution already does it), but it
1611
+ * belongs with antialias as "how many pixels do we draw" rather than "what is in
1612
+ * the scene".
1613
+ */
1614
+ function qualityRendering(tier) {
1615
+ const device = Math.min(globalThis.devicePixelRatio ?? 1, 2);
1616
+ if (tier === "low") return {
1617
+ antialias: false,
1618
+ pixelRatio: 1
1619
+ };
1620
+ if (tier === "medium") return {
1621
+ antialias: true,
1622
+ pixelRatio: Math.min(device, 1.5)
1623
+ };
1624
+ return {
1625
+ antialias: true,
1626
+ pixelRatio: device
1544
1627
  };
1545
1628
  }
1546
1629
  //#endregion
@@ -1671,6 +1754,7 @@ var Engine = class {
1671
1754
  this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
1672
1755
  this.scheduler = opts.scheduler ?? rafScheduler;
1673
1756
  this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
1757
+ this.maxFps = opts.maxFps ?? 0;
1674
1758
  }
1675
1759
  get scene() {
1676
1760
  return this._scene;
@@ -1693,6 +1777,7 @@ var Engine = class {
1693
1777
  start() {
1694
1778
  if (this.disposeScheduler) return;
1695
1779
  this.disposeScheduler = this.scheduler((now) => {
1780
+ if (!this.frameIsDue(now)) return;
1696
1781
  try {
1697
1782
  this.tick(now);
1698
1783
  } catch (error) {
@@ -1701,6 +1786,51 @@ var Engine = class {
1701
1786
  });
1702
1787
  }
1703
1788
  /**
1789
+ * The frame cap: fps, or 0 for "as fast as the display goes" (the default).
1790
+ *
1791
+ * Live — a settings menu writes it mid-game. It gates the whole frame, not
1792
+ * just the render: an uncapped loop on a 144 Hz phone burns battery running
1793
+ * game logic nobody sees.
1794
+ *
1795
+ * `tick()` is deliberately NOT gated. A headless harness driving its own
1796
+ * clock asked for that frame; the cap is about the real-time loop.
1797
+ */
1798
+ maxFps = 0;
1799
+ nextFrameAt = 0;
1800
+ lastOfferedAt = null;
1801
+ displayPeriodMs = 0;
1802
+ /**
1803
+ * Should this display frame run?
1804
+ *
1805
+ * The obvious `if (now - last < interval) return` is wrong in a way that only
1806
+ * shows up between the round numbers: a 45 fps cap on a 60 Hz display misses
1807
+ * every other deadline by a fraction of a millisecond, waits a whole display
1808
+ * frame more, and delivers 30 — asking for 45 makes the game slower than not
1809
+ * capping at all. The fix is to allow a frame that is within half a display
1810
+ * period of its deadline, and to advance the deadline by the interval rather
1811
+ * than from `now`, so the error does not accumulate.
1812
+ */
1813
+ frameIsDue(now) {
1814
+ const prev = this.lastOfferedAt;
1815
+ this.lastOfferedAt = now;
1816
+ if (prev !== null) {
1817
+ const delta = now - prev;
1818
+ if (delta > 0 && delta < 500) this.displayPeriodMs = this.displayPeriodMs === 0 ? delta : this.displayPeriodMs * .9 + delta * .1;
1819
+ }
1820
+ if (!(this.maxFps > 0)) {
1821
+ this.nextFrameAt = 0;
1822
+ return true;
1823
+ }
1824
+ const interval = 1e3 / this.maxFps;
1825
+ if (this.nextFrameAt === 0) {
1826
+ this.nextFrameAt = now + interval;
1827
+ return true;
1828
+ }
1829
+ if (now < this.nextFrameAt - this.displayPeriodMs * .5) return false;
1830
+ this.nextFrameAt = Math.max(now, this.nextFrameAt + interval);
1831
+ return true;
1832
+ }
1833
+ /**
1704
1834
  * A frame threw somewhere outside a single node (a renderer, physics, a
1705
1835
  * signal handler). Report the first one and count the rest: a broken frame
1706
1836
  * repeats sixty times a second, and sixty identical traces a second is how
@@ -3241,6 +3371,161 @@ var UiLanguageSelect = class extends UiSelect {
3241
3371
  return ENDONYMS[value] ?? value;
3242
3372
  }
3243
3373
  };
3374
+ /**
3375
+ * A select bound to one persisted setting — the shape all three graphics
3376
+ * controls share.
3377
+ *
3378
+ * The engine owns quality tiers, the frame cap and the resolution scale, and
3379
+ * every one of them was reachable only from TypeScript. A vibe-coded game whose
3380
+ * whole world is JSON could not put a graphics menu on screen without a
3381
+ * behavior, a signal connection, and the knowledge that `Settings` persists —
3382
+ * which is how "supports low-end devices" quietly becomes "does not".
3383
+ *
3384
+ * Subclasses say which setting, what the options are, and what they read as.
3385
+ */
3386
+ var UiSettingSelect = class extends UiSelect {
3387
+ /** Last value we PUSHED into the select, to tell an engine change from a player one. */
3388
+ lastPushed = null;
3389
+ _engine() {
3390
+ return this.tree?.engine ?? null;
3391
+ }
3392
+ /**
3393
+ * The translated option label, or the English one.
3394
+ *
3395
+ * `locale.t` returns the KEY when nothing declares it, and a menu reading
3396
+ * `settings.quality.high` is worse than one reading `High`. English is the
3397
+ * base language and the fallback, always.
3398
+ */
3399
+ _tOr(key, fallback) {
3400
+ const locale = this._engine()?.locale;
3401
+ return locale?.has(key) ? locale.t(key) : fallback;
3402
+ }
3403
+ _sync() {
3404
+ const engine = this._engine();
3405
+ if (engine) {
3406
+ const stored = this._read(engine);
3407
+ if (stored !== this.lastPushed) {
3408
+ this.lastPushed = stored;
3409
+ this.value = stored;
3410
+ } else if (this.value !== stored) {
3411
+ this._write(engine, this.value);
3412
+ this.lastPushed = this._read(engine);
3413
+ }
3414
+ }
3415
+ super._sync();
3416
+ }
3417
+ };
3418
+ /**
3419
+ * The graphics quality picker.
3420
+ *
3421
+ * ```json
3422
+ * { "name": "Quality", "type": "UiQualitySelect" }
3423
+ * ```
3424
+ *
3425
+ * Writes through `chooseQuality`, NOT `set('quality')`: a tier a person picked
3426
+ * has to survive the next launch's device detection, and the two are
3427
+ * indistinguishable afterwards unless the choice is recorded as one.
3428
+ *
3429
+ * Shadows, bloom, post and clouds follow immediately. Antialiasing follows on
3430
+ * the next launch — it is a WebGL context attribute and cannot change while a
3431
+ * context exists.
3432
+ */
3433
+ var UiQualitySelect = class extends UiSettingSelect {
3434
+ static typeName = "UiQualitySelect";
3435
+ static props = {
3436
+ ...UiSelect.props,
3437
+ label: { default: "Quality" },
3438
+ options: { default: "low,medium,high" }
3439
+ };
3440
+ label = "Quality";
3441
+ options = "low,medium,high";
3442
+ _key() {
3443
+ return "quality";
3444
+ }
3445
+ _read(engine) {
3446
+ return String(engine.settings.get("quality"));
3447
+ }
3448
+ _write(engine, value) {
3449
+ if (value === "low" || value === "medium" || value === "high") engine.settings.chooseQuality(value);
3450
+ }
3451
+ _optionLabel(value) {
3452
+ const english = value.charAt(0).toUpperCase() + value.slice(1);
3453
+ return this._tOr(`settings.quality.${value}`, english);
3454
+ }
3455
+ };
3456
+ /**
3457
+ * The frame-rate cap: fewer frames, a cooler phone, a longer battery.
3458
+ *
3459
+ * ```json
3460
+ * { "name": "FrameCap", "type": "UiFrameCapSelect" }
3461
+ * ```
3462
+ *
3463
+ * `0` is uncapped and is the default — a cap nobody asked for is a downgrade.
3464
+ * The cap gates the whole frame, so it saves the CPU as well as the GPU.
3465
+ */
3466
+ var UiFrameCapSelect = class extends UiSettingSelect {
3467
+ static typeName = "UiFrameCapSelect";
3468
+ static props = {
3469
+ ...UiSelect.props,
3470
+ label: { default: "Frame rate" },
3471
+ /** `0` means uncapped and reads as a word, not as a number. */
3472
+ options: { default: "30,60,120,0" }
3473
+ };
3474
+ label = "Frame rate";
3475
+ options = "30,60,120,0";
3476
+ _key() {
3477
+ return "maxFps";
3478
+ }
3479
+ _read(engine) {
3480
+ return String(engine.settings.get("maxFps"));
3481
+ }
3482
+ _write(engine, value) {
3483
+ const fps = Number(value);
3484
+ if (!Number.isFinite(fps) || fps < 0) return;
3485
+ engine.settings.set("maxFps", fps);
3486
+ engine.maxFps = fps;
3487
+ }
3488
+ _optionLabel(value) {
3489
+ if (value === "0") return this._tOr("settings.frameCap.unlimited", "Unlimited");
3490
+ return value;
3491
+ }
3492
+ };
3493
+ /**
3494
+ * The resolution scale — the cheapest frames on a weak GPU.
3495
+ *
3496
+ * ```json
3497
+ * { "name": "Resolution", "type": "UiRenderScaleSelect" }
3498
+ * ```
3499
+ *
3500
+ * At 75% the fragment shaders run over 56% of the pixels for a slightly softer
3501
+ * image and nothing else, where dropping a quality tier costs the shadows
3502
+ * outright. Reads as a percentage, because `0.75` means nothing to a player.
3503
+ */
3504
+ var UiRenderScaleSelect = class extends UiSettingSelect {
3505
+ static typeName = "UiRenderScaleSelect";
3506
+ static props = {
3507
+ ...UiSelect.props,
3508
+ label: { default: "Resolution" },
3509
+ options: { default: "0.5,0.75,1" }
3510
+ };
3511
+ label = "Resolution";
3512
+ options = "0.5,0.75,1";
3513
+ _key() {
3514
+ return "renderScale";
3515
+ }
3516
+ _read(engine) {
3517
+ return String(engine.settings.get("renderScale"));
3518
+ }
3519
+ _write(engine, value) {
3520
+ const scale = Number(value);
3521
+ if (!Number.isFinite(scale) || scale <= 0) return;
3522
+ engine.settings.set("renderScale", scale);
3523
+ }
3524
+ _optionLabel(value) {
3525
+ const pct = Math.round(Number(value) * 100);
3526
+ return Number.isFinite(pct) ? `${pct}%` : value;
3527
+ }
3528
+ };
3244
3529
  //#endregion
3245
3530
  //#region src/core/register.ts
3246
3531
  /**
@@ -3264,6 +3549,9 @@ function registerCoreNodes() {
3264
3549
  registerNode(UiToggle);
3265
3550
  registerNode(UiSelect);
3266
3551
  registerNode(UiLanguageSelect);
3552
+ registerNode(UiQualitySelect);
3553
+ registerNode(UiFrameCapSelect);
3554
+ registerNode(UiRenderScaleSelect);
3267
3555
  }
3268
3556
  //#endregion
3269
- export { translationKey as A, WebAudioMusicBackend as B, restoreBehaviors as C, Localization as D, BASE_LOCALE as E, spatialPan as F, fadeGain as H, SFX_PRESETS as I, SFX_PRESET_NAMES as L, isAudioContextAvailable as M, ROLLOFF_MODELS as N, T_PREFIX as O, spatialGain as P, synthSfx as R, captureBehaviors as S, LogManager as T, AudioBuses as U, crossfadeGains as V, qualityEnvironment as _, UiSlider as a, SaveSlots as b, UiButton as c, UiBanner as d, UiBar as f, Settings as g, Engine as h, UiSelect as i, SfxEngine as j, suggestLocale 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, readDeviceHints as v, createSaveStore as w, behaviorsWithoutSave as x, suggestQuality as y, MusicManager as z };
3557
+ export { suggestLocale as A, MusicManager as B, captureBehaviors as C, BASE_LOCALE as D, LogManager as E, spatialGain as F, crossfadeGains as H, spatialPan as I, SFX_PRESETS as L, SfxEngine as M, isAudioContextAvailable as N, Localization as O, ROLLOFF_MODELS as P, SFX_PRESET_NAMES as R, behaviorsWithoutSave as S, createSaveStore as T, fadeGain as U, WebAudioMusicBackend as V, AudioBuses as W, qualityEnvironment as _, UiSlider as a, suggestQuality as b, UiButton as c, UiBanner as d, UiBar as f, Settings as g, Engine as h, UiSelect as i, translationKey as j, T_PREFIX 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, qualityRendering as v, restoreBehaviors as w, SaveSlots as x, readDeviceHints as y, synthSfx as z };
@@ -1,5 +1,5 @@
1
- import { f as Node, h as effectiveOrder } from "./loader-DAZlgqeC.js";
2
- import { t as registerCoreNodes } from "./register-BHAwM4bK.js";
1
+ import { f as Node, h as effectiveOrder } from "./loader-r49nDwB4.js";
2
+ import { t as registerCoreNodes } from "./register-Dm7nrd-E.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
5
5
  import { i as getNodeSchema, l as registerNode } from "./registry-IyWCGe4q.js";
@@ -0,0 +1,86 @@
1
+ import { b as Engine } from "./behavior-B5PJ32eu.js";
2
+ import { s as JsonObject } from "./schema-CFeioQRE.js";
3
+
4
+ //#region src/core/audit.d.ts
5
+ /** Human-readable warnings (empty = clean). Pure JSON walk, no registry. */
6
+ declare function auditScene(scene: JsonObject): string[];
7
+ //#endregion
8
+ //#region src/core/errors.d.ts
9
+ /**
10
+ * Stable error codes for every hard failure in the engine.
11
+ *
12
+ * Incanto fails loudly with machine-readable codes and messages that list valid
13
+ * alternatives — AI agents self-correct on hard failures, not warnings.
14
+ */
15
+ type IncantoErrorCode = "BAD_FORMAT" | "BAD_NODE_PATH" | "DANGLING_CONNECTION" | "DUPLICATE_BEHAVIOR" | "DUPLICATE_NODE_TYPE" | "DUPLICATE_UNIQUE_NAME" | "DUPLICATE_UID" | "NODE_NOT_FOUND" | "PROP_TYPE_MISMATCH" | "TREE_VIOLATION" | "UNKNOWN_ANIMATION" | "UNKNOWN_ASSET" | "UNKNOWN_CONSTANT" | "UNKNOWN_BEHAVIOR" | "UNKNOWN_HANDLER" | "UNKNOWN_NODE_TYPE" | "UNKNOWN_PROP" | "UNKNOWN_SIGNAL" | "UNRESOLVED_INSTANCE";
16
+ /**
17
+ * Structured context mirrored from the prose message — tools and agents parse
18
+ * THESE instead of regexing the message (which is for humans and may reword).
19
+ */
20
+ interface IncantoErrorDetails {
21
+ /** Absolute path of the offending node in the scene being loaded ('/Root/Child'). */
22
+ readonly path?: string;
23
+ /** The offending node's uid, when the scene JSON declared one. */
24
+ readonly uid?: string;
25
+ /** The offending node's type name. */
26
+ readonly nodeType?: string;
27
+ /** The offending prop key. */
28
+ readonly prop?: string;
29
+ /** The offending signal name. */
30
+ readonly signal?: string;
31
+ /** The valid alternatives the message also lists — for programmatic self-correction. */
32
+ readonly validOptions?: readonly string[];
33
+ }
34
+ declare class IncantoError extends Error {
35
+ readonly code: IncantoErrorCode;
36
+ readonly details: IncantoErrorDetails;
37
+ constructor(code: IncantoErrorCode, message: string, details?: IncantoErrorDetails);
38
+ }
39
+ //#endregion
40
+ //#region src/core/replay.d.ts
41
+ /**
42
+ * Deterministic input replay — the engine already runs on a seeded RNG and
43
+ * an injectable clock, so a recording of (tick timestamps + input events)
44
+ * replays BIT-IDENTICALLY into a fresh engine with the same seed and scene.
45
+ * That turns "does my game still work?" into an assertion an agent can run
46
+ * headlessly: record once while playing, replay forever as a regression.
47
+ *
48
+ * const rec = startRecording(engine); // play the game...
49
+ * const replayJson = rec.stop(); // ...save this JSON
50
+ *
51
+ * const fresh = new Engine({ seed: SAME_SEED }); // same seed + scene JSON
52
+ * fresh.setScene(loadScene(sceneJson));
53
+ * replay(fresh, replayJson); // now assert game state
54
+ *
55
+ * Caveats (documented, by design): game code must use `engine.rng` (not
56
+ * Math.random) and dt/engine.time (not Date.now) — the engine's own rules.
57
+ * Gamepad polling is not recorded (bind pads to actions; actions replay).
58
+ */
59
+ /** [tickIndex, methodName, ...primitive args] */
60
+ type ReplayEvent = [number, string, ...(string | number | boolean)[]];
61
+ interface ReplayJson {
62
+ /** File-format marker for forward compatibility. */
63
+ replay: 1;
64
+ /** Tick timestamps (ms) exactly as the loop delivered them. */
65
+ ticks: number[];
66
+ events: ReplayEvent[];
67
+ }
68
+ interface Recorder {
69
+ /** Detach the taps and return the recording. */
70
+ stop(): ReplayJson;
71
+ }
72
+ /**
73
+ * Tap the engine's input entry points and tick clock. Recording starts at
74
+ * the CURRENT moment — start it before the first tick for full-run replays.
75
+ */
76
+ declare function startRecording(engine: Engine): Recorder;
77
+ /**
78
+ * Drive a fresh engine through a recording: for each recorded tick, feed
79
+ * that tick's input events first, then tick with the recorded timestamp.
80
+ * `onTick(index)` lets tests assert mid-run state.
81
+ */
82
+ declare function replay(engine: Engine, recording: ReplayJson, opts?: {
83
+ onTick?: (index: number) => void;
84
+ }): void;
85
+ //#endregion
86
+ export { startRecording as a, IncantoErrorDetails as c, replay as i, auditScene as l, ReplayEvent as n, IncantoError as o, ReplayJson as r, IncantoErrorCode as s, Recorder as t };
@@ -1,4 +1,4 @@
1
- import { A as translationKey } from "./register-BHAwM4bK.js";
1
+ import { j as translationKey } from "./register-Dm7nrd-E.js";
2
2
  //#region src/core/audit.ts
3
3
  const ANIMATED_TYPES = new Set([
4
4
  "Particles2D",
@@ -108,4 +108,74 @@ function auditStrings(scene) {
108
108
  return [`${listed.length} translation key${listed.length === 1 ? "" : "s"} nothing declares: ${listed.join(", ")}. The key itself renders on screen. Add them under "strings": { "en": { ... } } — other locales may omit any of them and fall back to English.`];
109
109
  }
110
110
  //#endregion
111
- export { auditScene as t };
111
+ //#region src/core/replay.ts
112
+ const RECORDED_METHODS = [
113
+ "handleKey",
114
+ "handleMouseButton",
115
+ "handlePointerMove",
116
+ "handleWheel",
117
+ "pressAction",
118
+ "releaseAction"
119
+ ];
120
+ /**
121
+ * Tap the engine's input entry points and tick clock. Recording starts at
122
+ * the CURRENT moment — start it before the first tick for full-run replays.
123
+ */
124
+ function startRecording(engine) {
125
+ const ticks = [];
126
+ const events = [];
127
+ const input = engine.input;
128
+ const originals = /* @__PURE__ */ new Map();
129
+ for (const name of RECORDED_METHODS) {
130
+ const original = input[name];
131
+ originals.set(name, original);
132
+ input[name] = (...args) => {
133
+ events.push([
134
+ ticks.length,
135
+ name,
136
+ ...args
137
+ ]);
138
+ return original.apply(engine.input, args);
139
+ };
140
+ }
141
+ const originalTick = engine.tick.bind(engine);
142
+ const engineAny = engine;
143
+ engineAny.tick = (nowMs) => {
144
+ ticks.push(nowMs);
145
+ originalTick(nowMs);
146
+ };
147
+ let stopped = false;
148
+ return { stop() {
149
+ if (!stopped) {
150
+ stopped = true;
151
+ for (const [name, fn] of originals) input[name] = fn;
152
+ engineAny.tick = originalTick;
153
+ }
154
+ return {
155
+ replay: 1,
156
+ ticks,
157
+ events
158
+ };
159
+ } };
160
+ }
161
+ /**
162
+ * Drive a fresh engine through a recording: for each recorded tick, feed
163
+ * that tick's input events first, then tick with the recorded timestamp.
164
+ * `onTick(index)` lets tests assert mid-run state.
165
+ */
166
+ function replay(engine, recording, opts) {
167
+ const input = engine.input;
168
+ let cursor = 0;
169
+ for (let i = 0; i < recording.ticks.length; i++) {
170
+ while (cursor < recording.events.length && recording.events[cursor][0] === i) {
171
+ const [, method, ...args] = recording.events[cursor];
172
+ const fn = input[method];
173
+ if (typeof fn === "function") fn.apply(engine.input, args);
174
+ cursor += 1;
175
+ }
176
+ engine.tick(recording.ticks[i]);
177
+ opts?.onTick?.(i);
178
+ }
179
+ }
180
+ //#endregion
181
+ export { startRecording as n, auditScene as r, replay as t };