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
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ /**
5
+ * incanto-feel — what your controls actually FEEL like, as numbers.
6
+ *
7
+ * bunx incanto-feel src/game.scene.json
8
+ *
9
+ * `capture()` and `framing()` are snapshots. Game feel lives on the time axis:
10
+ * how high the jump really goes, how many frames a turn costs, how wide the
11
+ * coyote window really is. This drives the player through scripted probes and
12
+ * reports the measurements.
13
+ *
14
+ * The windows are MEASURED by probing, not read off the schema — so a
15
+ * `coyoteSeconds` the controller never consults reports 0 ms. That was a real
16
+ * bug in this engine's own 3D controller, and the JSON looked correct
17
+ * throughout.
18
+ *
19
+ * It reports numbers and does not grade them: "good" jump feel is a genre
20
+ * decision, and a PASS/FAIL table against invented bands would be confidently
21
+ * wrong for most games.
22
+ */
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+
25
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
26
+
27
+ function parseArgs(argv) {
28
+ const args = {};
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ if (a === '--behaviors') args.behaviors = argv[++i];
32
+ else if (a === '--move') args.move = argv[++i];
33
+ else if (a === '--jump') args.jump = argv[++i];
34
+ else if (a === '--json') args.json = true;
35
+ else if (a === '--help' || a === '-h') args.help = true;
36
+ else if (a.startsWith('--')) {
37
+ console.error(`unknown flag: ${a}`);
38
+ args.help = true;
39
+ } else if (!args.scene) args.scene = a;
40
+ }
41
+ return args;
42
+ }
43
+
44
+ const args = parseArgs(process.argv.slice(2));
45
+ if (args.help || !args.scene) {
46
+ console.error(`Usage: incanto-feel <scene.json> [options]
47
+
48
+ --behaviors FILE your Behavior subclasses (.ts works on node >= 23.6 / bun)
49
+ --move ACTION the vector action to move with (default: the first vector2)
50
+ --jump ACTION the button to jump with (default: "jump")
51
+ --json the report as JSON instead of prose`);
52
+ process.exit(args.help && args.scene !== undefined ? 0 : 1);
53
+ }
54
+
55
+ const { feelReport, feelText } = await import(pathToFileURL(join(PKG, 'dist', 'test.js')).href);
56
+ const incanto = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
57
+
58
+ const behaviors = {};
59
+ if (args.behaviors) {
60
+ let mod;
61
+ try {
62
+ mod = await import(pathToFileURL(resolve(args.behaviors)).href);
63
+ } catch (e) {
64
+ console.error(`could not load --behaviors '${args.behaviors}' (${e?.message ?? e}).`);
65
+ process.exit(1);
66
+ }
67
+ for (const [name, value] of Object.entries(mod)) {
68
+ if (typeof value === 'function' && value.prototype instanceof incanto.Behavior) {
69
+ behaviors[name] = value;
70
+ }
71
+ }
72
+ }
73
+
74
+ const scenePath = resolve(args.scene);
75
+ const sceneDir = dirname(scenePath);
76
+ const sceneJson = JSON.parse(readFileSync(scenePath, 'utf-8'));
77
+
78
+ let report;
79
+ try {
80
+ report = await feelReport(sceneJson, {
81
+ behaviors,
82
+ stubMissingBehaviors: !args.behaviors,
83
+ ...(args.move ? { moveAction: args.move } : {}),
84
+ ...(args.jump ? { jumpAction: args.jump } : {}),
85
+ resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
86
+ });
87
+ } catch (e) {
88
+ console.error(`feel failed: ${e?.message ?? e}`);
89
+ process.exit(1);
90
+ }
91
+
92
+ process.stdout.write(args.json ? `${JSON.stringify(report, null, 2)}\n` : `${feelText(report)}\n`);
93
+ process.exit(report.player ? 0 : 1);
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ /**
5
+ * incanto-playtest — the engine plays your game and tells you whether it works.
6
+ *
7
+ * bunx incanto-playtest src/game.scene.json --runs 20 --seconds 60
8
+ *
9
+ * `incanto-check` answers "will it load". This answers "is it a game": can it
10
+ * be finished, which destinations are unreachable, which wires never fire, and
11
+ * whether anything in it can hurt the player.
12
+ *
13
+ * Runs are seeded and headless — no renderer, no browser — so twenty minutes of
14
+ * simulated play costs a second or two. A run that did NOT win is written out
15
+ * as a replay you can watch:
16
+ *
17
+ * bunx incanto-play src/game.scene.json --commands .incanto/playtest/fell-seed7.txt
18
+ *
19
+ * Without --behaviors, unregistered scripts are stubbed: the structure plays,
20
+ * your game logic does not. Pass the file that exports your Behavior subclasses
21
+ * to test the real thing.
22
+ *
23
+ * Exit code 1 when no run reached a win, so CI can gate on it.
24
+ */
25
+ import { fileURLToPath, pathToFileURL } from 'node:url';
26
+
27
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
28
+
29
+ function parseArgs(argv) {
30
+ const args = { runs: 20, seconds: 60, seed: 1, out: '.incanto/playtest' };
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === '--behaviors') args.behaviors = argv[++i];
34
+ else if (a === '--runs') args.runs = Number(argv[++i]);
35
+ else if (a === '--seconds') args.seconds = Number(argv[++i]);
36
+ else if (a === '--seed') args.seed = Number(argv[++i]);
37
+ else if (a === '--out') args.out = argv[++i];
38
+ else if (a === '--no-replays') args.noReplays = true;
39
+ else if (a === '--json') args.json = true;
40
+ else if (a === '--help' || a === '-h') args.help = true;
41
+ else if (a.startsWith('--')) {
42
+ console.error(`unknown flag: ${a}`);
43
+ args.help = true;
44
+ } else if (!args.scene) args.scene = a;
45
+ }
46
+ return args;
47
+ }
48
+
49
+ const args = parseArgs(process.argv.slice(2));
50
+ if (args.help || !args.scene) {
51
+ console.error(`Usage: incanto-playtest <scene.json> [options]
52
+
53
+ --runs N independent seeded runs (default 20)
54
+ --seconds N simulated seconds per run before calling it stuck (default 60)
55
+ --seed N first seed; run i uses seed+i (default 1)
56
+ --behaviors FILE your Behavior subclasses (.ts works on node >= 23.6 / bun)
57
+ --out DIR where failing replays go (default .incanto/playtest)
58
+ --no-replays report only, write nothing
59
+ --json the full report as JSON instead of prose
60
+
61
+ Exits 1 when no run reached a win.`);
62
+ process.exit(args.help && args.scene !== undefined ? 0 : 1);
63
+ }
64
+
65
+ const { playtest, playtestText, failingReplays } = await import(
66
+ pathToFileURL(join(PKG, 'dist', 'test.js')).href
67
+ );
68
+ const incanto = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
69
+
70
+ const behaviors = {};
71
+ if (args.behaviors) {
72
+ let mod;
73
+ try {
74
+ mod = await import(pathToFileURL(resolve(args.behaviors)).href);
75
+ } catch (e) {
76
+ console.error(
77
+ `could not load --behaviors '${args.behaviors}' (${e?.message ?? e}). ` +
78
+ 'Pass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.',
79
+ );
80
+ process.exit(1);
81
+ }
82
+ for (const [name, value] of Object.entries(mod)) {
83
+ if (typeof value === 'function' && value.prototype instanceof incanto.Behavior) {
84
+ behaviors[name] = value;
85
+ }
86
+ }
87
+ if (Object.keys(behaviors).length === 0) {
88
+ console.error(`warning: no Behavior subclasses exported from ${args.behaviors}`);
89
+ }
90
+ }
91
+
92
+ const scenePath = resolve(args.scene);
93
+ const sceneJson = JSON.parse(readFileSync(scenePath, 'utf-8'));
94
+ const sceneDir = dirname(scenePath);
95
+
96
+ let report;
97
+ try {
98
+ report = await playtest(sceneJson, {
99
+ runs: args.runs,
100
+ seconds: args.seconds,
101
+ seed: args.seed,
102
+ behaviors,
103
+ stubMissingBehaviors: !args.behaviors,
104
+ // sub-scenes resolve relative to the scene file, as the loader does
105
+ resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
106
+ });
107
+ } catch (e) {
108
+ console.error(`playtest failed: ${e?.message ?? e}`);
109
+ process.exit(1);
110
+ }
111
+
112
+ if (args.json) {
113
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
114
+ } else {
115
+ process.stdout.write(`${playtestText(report)}\n`);
116
+ }
117
+
118
+ // A failing run is only useful if you can watch it. One file per outcome kind —
119
+ // twenty identical "stuck" replays teach nothing that the first one does not.
120
+ if (!args.noReplays && !args.json) {
121
+ const seen = new Set();
122
+ const written = [];
123
+ for (const { seed, outcome, replay } of failingReplays(report)) {
124
+ if (seen.has(outcome)) continue;
125
+ seen.add(outcome);
126
+ const file = join(resolve(args.out), `${outcome}-seed${seed}.json`);
127
+ mkdirSync(dirname(file), { recursive: true });
128
+ writeFileSync(file, JSON.stringify(replay, null, 2));
129
+ written.push(file);
130
+ }
131
+ if (written.length > 0) {
132
+ process.stdout.write(`\n replays: ${written.join('\n ')}\n`);
133
+ }
134
+ }
135
+
136
+ process.exit(report.runs.some((r) => r.outcome === 'won') ? 0 : 1);
package/dist/2d.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { N as Scene$1, S as Scheduler, T as RendererStats, Tt as Node, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-DibCwrW7.js";
1
+ import { N as Scene$1, S as Scheduler, T as RendererStats, Tt as Node, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-B5PJ32eu.js";
2
2
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DVwIGZdK.js";
3
3
  import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
4
- import { t as LoadSceneOptions } from "./loader-BqigZsfF.js";
4
+ import { t as LoadSceneOptions } from "./loader-DSMdqasu.js";
5
5
  import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
6
6
  import { Group, Mesh, Object3D, Scene, Texture } from "three";
7
7
  import * as RapierNs from "@dimforge/rapier2d-compat";
package/dist/2d.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-D-StzrTj.js";
3
- import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-COGCNitr.js";
4
- import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-B4v39hca.js";
2
+ import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-C4t5gVhc.js";
3
+ import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-DnB9m7Rl.js";
4
+ import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-OfKb0PV_.js";
5
5
  //#region src/2d/library-sprite.ts
6
6
  /**
7
7
  * Turn a library sprite-animation JSON into a scene-ready spritesheet asset
package/dist/3d.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { N as Scene$1, S as Scheduler, T as RendererStats, Tt as Node, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-DibCwrW7.js";
1
+ import { N as Scene$1, S as Scheduler, T as RendererStats, Tt as Node, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-B5PJ32eu.js";
2
2
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DVwIGZdK.js";
3
3
  import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
4
- import { t as LoadSceneOptions } from "./loader-BqigZsfF.js";
4
+ import { t as LoadSceneOptions } from "./loader-DSMdqasu.js";
5
5
  import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
6
- import { n as PathGrid, s as SpatialPose } from "./pathfinding-B3QtdhvZ.js";
6
+ import { n as PathGrid, s as SpatialPose } from "./pathfinding-BrE5Uec6.js";
7
7
  import { AnimationClip, AnimationMixer, BufferGeometry, Color, DirectionalLight, Group, InstancedMesh, Mesh, MeshPhysicalMaterial, Object3D, PerspectiveCamera, Scene, ShaderMaterial, Texture, Vector3, WebGLRenderer } from "three";
8
8
  import { VRM } from "@pixiv/three-vrm";
9
9
  import { Sky } from "three/examples/jsm/objects/Sky.js";
@@ -1058,6 +1058,12 @@ declare class CharacterController3D extends Node3D {
1058
1058
  sprintMultiplier: number;
1059
1059
  jumpVelocity: number;
1060
1060
  sprintJumpMultiplier: number;
1061
+ coyoteSeconds: number;
1062
+ jumpBufferSeconds: number;
1063
+ jumpCutMultiplier: number;
1064
+ maxJumps: number;
1065
+ airControl: number;
1066
+ fallGravity: number;
1061
1067
  floatHeight: number;
1062
1068
  mouseLook: boolean;
1063
1069
  zoomMin: number;
@@ -1093,6 +1099,11 @@ declare class CharacterController3D extends Node3D {
1093
1099
  /** idle | walk | run | fastRun | airborne — drive animations from this. */
1094
1100
  state: "idle" | "walk" | "run" | "fastRun" | "airborne";
1095
1101
  grounded: boolean;
1102
+ /** @internal Feel timers — see the jump block for what each one buys. */
1103
+ private coyoteLeft;
1104
+ private bufferLeft;
1105
+ private jumpsUsed;
1106
+ private rising;
1096
1107
  private get body();
1097
1108
  override onEnterTree(): void;
1098
1109
  override fixedUpdate(dt: number): void;
@@ -3114,6 +3125,19 @@ interface Renderer3DOptions {
3114
3125
  /** Defaults to `window.devicePixelRatio` capped at 2. */
3115
3126
  pixelRatio?: number;
3116
3127
  /**
3128
+ * Starting point for the rendering knobs, from the player's quality tier.
3129
+ *
3130
+ * DEFAULTS, not overrides: a scene's own `environment.rendering` is a decision
3131
+ * about that scene and still wins, and an explicit `pixelRatio` option wins
3132
+ * over both. This only decides what a device that has said nothing gets —
3133
+ * which for `antialias` is the only chance there is, since it is a WebGL
3134
+ * context attribute and cannot change after the context exists.
3135
+ */
3136
+ qualityDefaults?: {
3137
+ antialias: boolean;
3138
+ pixelRatio: number;
3139
+ };
3140
+ /**
3117
3141
  * Keep the frame rate by rendering at fewer pixels when frames run slow, and
3118
3142
  * hand them back when they recover (default ON). A retina display asks for
3119
3143
  * 4× the pixels; shaded water is fragment-heavy; the honest answer is fewer
@@ -3211,6 +3235,8 @@ declare class Renderer3D {
3211
3235
  /** The pixel ratio the scene ASKED for — the governor scales this, never replaces it. */
3212
3236
  private readonly basePixelRatio;
3213
3237
  private readonly adaptive;
3238
+ private renderScale;
3239
+ private adaptiveScale;
3214
3240
  private lastFrameAt;
3215
3241
  private readonly debugLines;
3216
3242
  private readonly selectionLines;
@@ -3225,6 +3251,21 @@ declare class Renderer3D {
3225
3251
  */
3226
3252
  private governResolution;
3227
3253
  /**
3254
+ * The player's resolution multiplier, on top of whatever the scene and the
3255
+ * quality tier resolved to.
3256
+ *
3257
+ * The cheapest lever there is on a weak GPU: at 0.66 the fragment shaders run
3258
+ * over 44% of the pixels for a soft image and nothing else, where dropping a
3259
+ * quality tier costs the shadows outright. Live — a settings menu writes it
3260
+ * mid-game — and it multiplies with the adaptive governor rather than
3261
+ * fighting it, so a player who has already scaled down does not get scaled
3262
+ * down twice as hard.
3263
+ */
3264
+ setRenderScale(scale: number): void;
3265
+ /** Judge frames against `fps` — see AdaptiveResolution.setTarget. */
3266
+ setFrameTarget(fps: number): void;
3267
+ private applyPixelRatio;
3268
+ /**
3228
3269
  * Compile the scene's GPU programs off the critical path.
3229
3270
  *
3230
3271
  * The compile itself is unavoidable — a dressed outdoor scene is 40+ programs
package/dist/3d.js CHANGED
@@ -1,36 +1,8 @@
1
- import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { A as Water3D, F as PhysicsBody3D, I as RigidBody3D, L as StaticBody3D, M as WaterCutout3D, N as Area3D, P as CharacterBody3D, R as Node3D, V as WATER_MAX_RIPPLES, j as WATER_CUTOUT_MAX } from "./gameplay-C0MUMSak.js";
3
- import { A as Terrain3D, B as keyboardIntensity, C as resolveFlowerDensity, D as BoneLookAt3D, E as Camera3D, F as InstancedMesh3D, G as acquireTexture, H as rigPose, I as MeshInstance3D, M as TERRAIN_THEMES, N as terrainThemeLayers, O as BoneAttachment3D, P as Joint3D, R as QUARTER_PITCH, S as Flowers3D, T as CharacterController3D, U as TextureCache3D, V as movementState, W as acquireOwnTexture, _ as LoftMesh3D, a as Tree3D, b as Foliage3D, c as buildRiverRings, d as riverCarveChannels, f as riverStepFor, g as ModelInstance3D, h as Particles3D, i as VoxelGrid3D, j as DEFAULT_TERRAIN_TEXTURE_BASE, k as Billboard3D, l as findRiverCoverageGaps, m as traceDownhillPath, n as registerNodes3D, o as Trail3D, p as smoothCourse, r as VOXEL_PALETTE, s as River3D, u as projectToRiver, v as DirectionalLight3D, w as FLOWER_VARIETIES, x as DENSITY_PRESETS, y as OmniLight3D, z as cameraRelative } from "./environment-presets-fK9oyrrR.js";
4
- import { a as Environment3D, c as sunDirectionFromElevationAzimuth, i as syncTree, l as sunDirectionFromSky, o as horizonColorFromSky, r as Renderer3D, s as parseEnvironment3D, t as createGame3D, u as AssetStore3D } from "./create-game-DnOu2aAa.js";
1
+ import { A as Water3D, F as PhysicsBody3D, I as RigidBody3D, L as StaticBody3D, M as WaterCutout3D, N as Area3D, P as CharacterBody3D, R as Node3D, V as WATER_MAX_RIPPLES, j as WATER_CUTOUT_MAX } from "./gameplay-CsJDUQh_.js";
2
+ import { A as Terrain3D, B as keyboardIntensity, C as resolveFlowerDensity, D as BoneLookAt3D, E as Camera3D, F as InstancedMesh3D, G as acquireTexture, H as rigPose, I as MeshInstance3D, M as TERRAIN_THEMES, N as terrainThemeLayers, O as BoneAttachment3D, P as Joint3D, R as QUARTER_PITCH, S as Flowers3D, T as CharacterController3D, U as TextureCache3D, V as movementState, W as acquireOwnTexture, _ as LoftMesh3D, a as Tree3D, b as Foliage3D, c as buildRiverRings, d as riverCarveChannels, f as riverStepFor, g as ModelInstance3D, h as Particles3D, i as VoxelGrid3D, j as DEFAULT_TERRAIN_TEXTURE_BASE, k as Billboard3D, l as findRiverCoverageGaps, m as traceDownhillPath, n as registerNodes3D, o as Trail3D, p as smoothCourse, r as VOXEL_PALETTE, s as River3D, u as projectToRiver, v as DirectionalLight3D, w as FLOWER_VARIETIES, x as DENSITY_PRESETS, y as OmniLight3D, z as cameraRelative } from "./environment-presets-C0Axpfth.js";
3
+ import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-DqY2lMBc.js";
5
4
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
6
- import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-DFJVMYET.js";
7
- //#region src/3d/environment-runtime.ts
8
- /**
9
- * Live environment editing — the renderer re-applies `scene.environment`
10
- * every frame (cheap when unchanged), so changing the LOOK at runtime is
11
- * just writing that object. This helper does it safely:
12
- *
13
- * setEnvironment3D(engine, { sky: { elevationDeg: 4 }, exposure: 0.8 });
14
- * setEnvironment3D(engine, { fog: null }); // null DELETES a key
15
- *
16
- * - one-level deep merge (patching `sky.elevationDeg` keeps the other sky keys)
17
- * - hard-validates the merged result NOW (in your call stack), so a bad patch
18
- * throws where you wrote it — never inside the render loop
19
- */
20
- function setEnvironment3D(engine, patch) {
21
- const scene = engine.scene;
22
- if (!scene) throw new IncantoError("BAD_FORMAT", "setEnvironment3D: the engine has no scene.");
23
- const merged = { ...scene.environment ?? {} };
24
- for (const [key, value] of Object.entries(patch)) if (value === null) delete merged[key];
25
- else if (typeof value === "object" && !Array.isArray(value) && typeof merged[key] === "object" && merged[key] !== null && !Array.isArray(merged[key])) merged[key] = {
26
- ...merged[key],
27
- ...value
28
- };
29
- else merged[key] = value;
30
- parseEnvironment3D(merged);
31
- scene.environment = merged;
32
- }
33
- //#endregion
5
+ import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-C8242nXr.js";
34
6
  //#region src/3d/terrain-nav.ts
35
7
  function buildTerrainNav(terrain, opts) {
36
8
  const cellSize = opts?.cellSize ?? 2;
@@ -670,6 +670,17 @@ declare class InputMap {
670
670
  * scripted gameplay tests and touch buttons drive the game by intent
671
671
  * (`press('jump')`) instead of reverse-engineering keybinds.
672
672
  */
673
+ /**
674
+ * Every action this scene declared, with its kind.
675
+ *
676
+ * A scene states its own control vocabulary in `input{}`, which means a tool
677
+ * can drive a game it has never seen — the automated playtester's entire
678
+ * premise. Nothing else could ask: `actions` was private.
679
+ */
680
+ declaredActions(): Array<{
681
+ name: string;
682
+ type: "button" | "vector2";
683
+ }>;
673
684
  pressAction(action: string): void;
674
685
  /** Release an injected button action (yields one justReleased frame). */
675
686
  releaseAction(action: string): void;
@@ -1080,6 +1091,21 @@ interface SettingsValues {
1080
1091
  * does not translate falls back to English, silently and on purpose.
1081
1092
  */
1082
1093
  language: string;
1094
+ /**
1095
+ * Has a HUMAN chosen the quality tier?
1096
+ *
1097
+ * Without this, a first boot cannot tell "the default happens to be high" from
1098
+ * "the player picked high", so device auto-detection would either never run or
1099
+ * would overwrite a deliberate choice on every launch.
1100
+ */
1101
+ qualityChosen: boolean;
1102
+ /** Frames per second to draw at most. 0 = uncapped (draw every rAF). */
1103
+ maxFps: number;
1104
+ /**
1105
+ * Extra multiplier on the render resolution, on top of the tier's pixel ratio.
1106
+ * 1 = as the tier says. Below 1 trades sharpness for frame rate.
1107
+ */
1108
+ renderScale: number;
1083
1109
  }
1084
1110
  /** What a device probe can see without a GPU context. */
1085
1111
  interface DeviceHints {
@@ -1111,6 +1137,43 @@ declare class Settings {
1111
1137
  /** Back to defaults, on disk too. */
1112
1138
  reset(): void;
1113
1139
  /**
1140
+ * Choose a starting tier from the device, ONCE.
1141
+ *
1142
+ * Only on a first visit: after someone has picked a tier, re-detecting on
1143
+ * every launch would silently undo their choice — which is the behaviour every
1144
+ * player experiences as "the settings do not save".
1145
+ */
1146
+ autoQuality(hints?: DeviceHints): QualityTier;
1147
+ /** Record that the tier came from a person, not from the device probe. */
1148
+ chooseQuality(tier: QualityTier): void;
1149
+ /**
1150
+ * Apply the saved quality tier to a live scene, and keep applying it.
1151
+ *
1152
+ * The same shape as `bindAudio` and `bindLocale`. Until 0.38 this did not
1153
+ * exist: `settings.quality` was written to disk and read by NOBODY, and
1154
+ * `qualityEnvironment` — the function that turns a tier into something the
1155
+ * renderer understands — emitted patches the environment parser rejects, for
1156
+ * all three tiers. Both were public, documented and never once called.
1157
+ */
1158
+ bindQuality(apply: (patch: Record<string, JsonValue>) => void): () => void;
1159
+ /**
1160
+ * Apply the saved frame cap, and again whenever it changes.
1161
+ *
1162
+ * Separate from `bindQuality` because it is not a tier: a player caps the
1163
+ * frame rate to keep a phone cool or a battery alive, on the highest visual
1164
+ * settings the device can hold, and those two choices do not belong on one
1165
+ * slider. 0 means uncapped. Returns the unsubscribe.
1166
+ */
1167
+ bindFrameCap(apply: (fps: number) => void): () => void;
1168
+ /**
1169
+ * Apply the saved resolution multiplier, and again whenever it changes.
1170
+ *
1171
+ * The lever that buys the most frames per unit of ugliness on a weak GPU —
1172
+ * shading 44% of the pixels at 0.66 costs a soft image and nothing else,
1173
+ * while dropping a quality tier costs shadows entirely.
1174
+ */
1175
+ bindRenderScale(apply: (scale: number) => void): () => void;
1176
+ /**
1114
1177
  * Apply the saved language, and keep it in step both ways.
1115
1178
  *
1116
1179
  * The same shape as `bindAudio`: a game that writes
@@ -1128,13 +1191,22 @@ declare class Settings {
1128
1191
  bindAudio(audio: AudioBuses): () => void;
1129
1192
  }
1130
1193
  /**
1131
- * What a quality tier MEANS, as an environment patch.
1194
+ * What a quality tier MEANS, as a LIVE environment patch.
1195
+ *
1196
+ * Only the levers that actually cost: shadows, post, bloom and clouds.
1197
+ * Deliberately an `environment` patch, because `setEnvironment3D(engine, patch)`
1198
+ * already applies one live and validated — this is not a second rendering
1199
+ * pipeline, it is three presets for the one that exists.
1200
+ *
1201
+ * The `off` spelling matters and got this wrong for three releases: every tier
1202
+ * emitted `{ enabled: false }`, which the environment parser rejects
1203
+ * (`environment.bloom has unknown key 'enabled'`). Nothing called this function,
1204
+ * so nothing found out. In a live patch `null` DELETES a key — that is how a
1205
+ * stage is turned off — and `shadows` alone takes a real `false`.
1132
1206
  *
1133
- * Only the levers that actually cost: shadows, post, clouds, and the resolution
1134
- * the renderer draws at. Deliberately an `environment` patch, because
1135
- * `setEnvironment3D(engine, patch)` already applies one live and validated —
1136
- * this is not a second rendering pipeline, it is three presets for the one that
1137
- * exists.
1207
+ * Antialiasing and pixel ratio are NOT here: `antialias` is a WebGL context
1208
+ * attribute and cannot change without rebuilding the renderer. See
1209
+ * `qualityRendering`.
1138
1210
  */
1139
1211
  declare function qualityEnvironment(tier: QualityTier): Record<string, JsonValue>;
1140
1212
  //#endregion
@@ -1184,6 +1256,8 @@ interface EngineOptions {
1184
1256
  scheduler?: Scheduler;
1185
1257
  /** Seed for `engine.rng` — set it to make a run reproducible (default: random). */
1186
1258
  seed?: number;
1259
+ /** Frame cap in fps; 0 (default) runs at whatever the display offers. */
1260
+ maxFps?: number;
1187
1261
  }
1188
1262
  /**
1189
1263
  * The game loop: drives a Scene's tree with fixed-timestep `fixedUpdate`
@@ -1295,6 +1369,32 @@ declare class Engine {
1295
1369
  setScene(scene: Scene): void;
1296
1370
  start(): void;
1297
1371
  /**
1372
+ * The frame cap: fps, or 0 for "as fast as the display goes" (the default).
1373
+ *
1374
+ * Live — a settings menu writes it mid-game. It gates the whole frame, not
1375
+ * just the render: an uncapped loop on a 144 Hz phone burns battery running
1376
+ * game logic nobody sees.
1377
+ *
1378
+ * `tick()` is deliberately NOT gated. A headless harness driving its own
1379
+ * clock asked for that frame; the cap is about the real-time loop.
1380
+ */
1381
+ maxFps: number;
1382
+ private nextFrameAt;
1383
+ private lastOfferedAt;
1384
+ private displayPeriodMs;
1385
+ /**
1386
+ * Should this display frame run?
1387
+ *
1388
+ * The obvious `if (now - last < interval) return` is wrong in a way that only
1389
+ * shows up between the round numbers: a 45 fps cap on a 60 Hz display misses
1390
+ * every other deadline by a fraction of a millisecond, waits a whole display
1391
+ * frame more, and delivers 30 — asking for 45 makes the game slower than not
1392
+ * capping at all. The fix is to allow a frame that is within half a display
1393
+ * period of its deadline, and to advance the deadline by the interval rather
1394
+ * than from `now`, so the error does not accumulate.
1395
+ */
1396
+ private frameIsDue;
1397
+ /**
1298
1398
  * A frame threw somewhere outside a single node (a renderer, physics, a
1299
1399
  * signal handler). Report the first one and count the rest: a broken frame
1300
1400
  * repeats sixty times a second, and sixty identical traces a second is how
@@ -1,13 +1,13 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { n as loadScene, o as computeViewport, s as resolveViewport, v as diagnose, w as registerBehavior } from "./loader-DAZlgqeC.js";
3
- import { h as Engine, m as AudioPlayer } from "./register-BHAwM4bK.js";
2
+ import { n as loadScene, o as computeViewport, s as resolveViewport, v as diagnose, w as registerBehavior } from "./loader-r49nDwB4.js";
3
+ import { h as Engine, m as AudioPlayer } from "./register-Dm7nrd-E.js";
4
4
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
5
  import { i as resolveRendering, n as attachTouchControls } from "./touch-BoNg_MnF.js";
6
6
  import { i as openBundledEditor, n as crossFade, r as devServerLibrary, t as teardown } from "./teardown-CCtAMDLB.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-C0MUMSak.js";
8
- import { g as PhysicsBody2D, n as UILayer, t as registerNodes2D, u as Camera2D, y as Node2D } from "./register-COGCNitr.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-CsJDUQh_.js";
8
+ import { g as PhysicsBody2D, n as UILayer, t as registerNodes2D, u as Camera2D, y as Node2D } from "./register-DnB9m7Rl.js";
9
9
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
10
- import { n as enablePhysics2D } from "./physics-2d-B4v39hca.js";
10
+ import { n as enablePhysics2D } from "./physics-2d-OfKb0PV_.js";
11
11
  import { Box3, BufferAttribute, BufferGeometry, Color, LineBasicMaterial, LineSegments, LinearFilter, NearestFilter, OrthographicCamera, Raycaster, SRGBColorSpace, Scene, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three";
12
12
  //#region src/2d/assets.ts
13
13
  /**