incanto 0.58.0 → 0.59.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 (59) hide show
  1. package/bin/incanto-multiplay.mjs +170 -0
  2. package/bin/incanto-verify.mjs +68 -4
  3. package/bin/incanto.mjs +1 -0
  4. package/dist/2d.d.ts +8 -2
  5. package/dist/2d.js +3 -3
  6. package/dist/3d.d.ts +9 -3
  7. package/dist/3d.js +4 -4
  8. package/dist/{behavior-l08AEbq9.d.ts → behavior-DWKTUzKI.d.ts} +10 -0
  9. package/dist/{create-game-D10bU5_J.js → create-game-BiW8Men_.js} +6 -6
  10. package/dist/{create-game-BZwWJIns.js → create-game-CHDLDQsQ.js} +6 -6
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/debug.js +2 -2
  13. package/dist/{duplicate-BPLZDZpd.js → duplicate-DJQd44CD.js} +1 -1
  14. package/dist/{environment-presets-CvvQr_bJ.js → environment-presets-DRAz5EV9.js} +12 -10
  15. package/dist/{gameplay-BVphcxmE.js → gameplay-BBEjPFsR.js} +62 -34
  16. package/dist/gameplay.d.ts +1 -1
  17. package/dist/gameplay.js +1 -1
  18. package/dist/index.d.ts +36 -8
  19. package/dist/index.js +8 -8
  20. package/dist/{json-BLk7H2Qa.js → json-CwwhxQgb.js} +7 -1
  21. package/dist/{loader-BcrRSjxB.js → loader-D8n7TU8W.js} +142 -5
  22. package/dist/{loader-BbEMTuWg.d.ts → loader-TvkRFbyL.d.ts} +1 -1
  23. package/dist/net.d.ts +2 -2
  24. package/dist/net.js +1 -1
  25. package/dist/{pathfinding-mEN4V1CU.d.ts → pathfinding-BqWBb0kh.d.ts} +1 -1
  26. package/dist/{physics-2d-EqA-jddf.js → physics-2d-BaRSRrrZ.js} +12 -2
  27. package/dist/{physics-3d-Dnz4fsXX.js → physics-3d-CYxjh-HW.js} +13 -3
  28. package/dist/react.d.ts +1 -1
  29. package/dist/react.js +2 -2
  30. package/dist/{register-C6ZBFRjd.js → register-BpFcgdcL.js} +57 -27
  31. package/dist/{register-Ch70uByv.js → register-CDrAQqPp.js} +90 -41
  32. package/dist/{registry-C7u42TID.js → registry-WWcQcfMr.js} +1 -1
  33. package/dist/{replay-s7I2GstT.js → replay-CEPyQtF_.js} +31 -7
  34. package/dist/{replay-Dw6gMlYA.d.ts → replay-O-yAGM76.d.ts} +1 -1
  35. package/dist/{split-screen-B0baBwxI.d.ts → split-screen-BQ3tAsf-.d.ts} +38 -1
  36. package/dist/{split-screen-DLsUrleX.js → split-screen-DDMZutQ6.js} +56 -13
  37. package/dist/{sprite-animation-C0wXLBZJ.js → sprite-animation-CY-mrr1L.js} +1 -1
  38. package/dist/{src-C1J09Op6.js → src-CY21B462.js} +1 -1
  39. package/dist/{teardown-Cs113S9F.js → teardown-RApWnM1G.js} +1 -1
  40. package/dist/{test-ZBga8kQ9.js → test-DHYuFyAu.js} +260 -25
  41. package/dist/test.d.ts +103 -5
  42. package/dist/test.js +3 -3
  43. package/dist/vite.js +2 -2
  44. package/editor/assets/{agent8-BQQjE9UQ.js → agent8-BoRGtVxK.js} +1 -1
  45. package/editor/assets/{debug-C9UCsXBS.js → debug-CzdyCg75.js} +1 -1
  46. package/editor/assets/{index-CBgfM3WD.js → index-VesuVEhe.js} +91 -91
  47. package/editor/index.html +1 -1
  48. package/package.json +2 -1
  49. package/schemas/scene.schema.json +1174 -70
  50. package/skills/incanto-localization.md +40 -8
  51. package/skills/incanto-multiplayer.md +57 -4
  52. package/skills/incanto-node-reference.md +18 -0
  53. package/skills/incanto-physics-and-input.md +26 -0
  54. package/skills/incanto-verifying-your-game.md +22 -0
  55. package/templates-app/beacon-isle-3d/package.json +1 -1
  56. package/templates-app/platformer-2d/package.json +1 -1
  57. package/templates-app/star-survivor/package.json +1 -1
  58. package/templates-app/tps-3d/package.json +1 -1
  59. package/templates-app/village-quest-3d/package.json +1 -1
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * incanto-multiplay — run a real match headlessly and check the clients AGREE.
4
+ *
5
+ * bunx incanto multiplay src/game.scene.json
6
+ * bunx incanto multiplay src/game.scene.json --players 3 --seconds 8 --json
7
+ *
8
+ * A desync is the purest silent failure this engine can have: both clients
9
+ * run, neither errors, and they are playing different games. Nothing checked
10
+ * for one — the harness measured room MEMBERSHIP, so "saw p2" meant "p2 is in
11
+ * the room", not "p2's data arrived", and a game whose replication was
12
+ * completely dead reported exactly what a working one did.
13
+ *
14
+ * WHAT THIS PROVES, AND WHAT IT DOES NOT. Every client runs against one
15
+ * in-memory server: no latency, no loss, no reordering. So this measures the
16
+ * APPLY path and the protocol shape, and says nothing about a live wire — a
17
+ * reconnect that never re-joins, a throttle that discards a payload, a batch
18
+ * dropped on a closed socket.
19
+ */
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { fileURLToPath, pathToFileURL } from 'node:url';
23
+
24
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
25
+
26
+ const argv = process.argv.slice(2);
27
+ const flag = (name, fallback) => {
28
+ const i = argv.indexOf(name);
29
+ return i >= 0 ? argv[i + 1] : fallback;
30
+ };
31
+
32
+ if (argv.includes('--help') || argv.includes('-h')) {
33
+ console.log(`Usage: incanto multiplay [scene.json] [options]
34
+
35
+ --players N clients in the match (default 2)
36
+ --seconds N simulated seconds (default 5)
37
+ --seed N make the match reproducible (default 1)
38
+ --behaviors FILE your Behavior subclasses (.ts works on bun / node >= 23.6)
39
+ --scenes a=b.json a scene a NetworkSpawner names, repeatable
40
+ --no-late-join skip the late-joining client
41
+ --json machine-readable report
42
+
43
+ Checks that every client holds the SAME value for every replicated key, plus
44
+ one client that joins AFTER the match settles — the only observer that reads
45
+ the authoritative snapshot fresh, and so the only one that can see a value
46
+ erased from it.`);
47
+ process.exit(0);
48
+ }
49
+
50
+ const named = argv.find((a) => !a.startsWith('-') && a.endsWith('.json'));
51
+ if (!named) {
52
+ console.error('incanto-multiplay: name the scene, e.g. src/game.scene.json');
53
+ process.exit(2);
54
+ }
55
+ const scenePath = resolve(named);
56
+ if (!existsSync(scenePath)) {
57
+ console.error(`no such scene: ${scenePath}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ const { playMultiplayer, multiplayText, registerAllNodes } = await import(
62
+ pathToFileURL(join(PKG, 'dist', 'test.js')).href
63
+ );
64
+ registerAllNodes();
65
+
66
+ const sceneJson = JSON.parse(readFileSync(scenePath, 'utf-8'));
67
+ const sceneDir = dirname(scenePath);
68
+
69
+ // Without the game's own behaviours a scene that names one throws
70
+ // `Unknown behavior` before a single frame runs — so load them when we are
71
+ // given them, and stub what is left. A stubbed behaviour still lets the
72
+ // SCENE's replication run, which is what this command measures.
73
+ const behaviorsFile = flag('--behaviors', null);
74
+ if (behaviorsFile) {
75
+ try {
76
+ const mod = await import(pathToFileURL(resolve(behaviorsFile)).href);
77
+ const { registerBehavior, Behavior } = await import(
78
+ pathToFileURL(join(PKG, 'dist', 'index.js')).href
79
+ );
80
+ for (const [name, value] of Object.entries(mod)) {
81
+ if (typeof value === 'function' && value.prototype instanceof Behavior) {
82
+ registerBehavior(name, value, { replace: true });
83
+ }
84
+ }
85
+ } catch (e) {
86
+ console.error(`could not load --behaviors '${behaviorsFile}' (${e?.message ?? e}).`);
87
+ process.exit(1);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Scenes a `NetworkSpawner` names.
93
+ *
94
+ * `--scenes remote-player=src/remote.scene.json` is explicit; otherwise every
95
+ * spawner's `scene` prop is looked for as a sibling file, which is where the
96
+ * templates put them. A spawner whose scene cannot be found materialises
97
+ * nothing, and the agreement check reports that as `missing` rather than
98
+ * letting it read as "this game has no remote players".
99
+ */
100
+ const scenes = {};
101
+ for (let i = 0; i < argv.length; i++) {
102
+ if (argv[i] !== '--scenes') continue;
103
+ const [key, file] = String(argv[i + 1] ?? '').split('=');
104
+ if (key && file) scenes[key] = JSON.parse(readFileSync(resolve(sceneDir, file), 'utf-8'));
105
+ }
106
+ const spawnerScenes = new Set();
107
+ const walk = (node) => {
108
+ if (node?.type === 'NetworkSpawner' && typeof node.props?.scene === 'string') {
109
+ spawnerScenes.add(node.props.scene);
110
+ }
111
+ for (const child of node?.children ?? []) walk(child);
112
+ };
113
+ walk(sceneJson.root);
114
+ for (const name of spawnerScenes) {
115
+ if (scenes[name]) continue;
116
+ for (const guess of [`${name}.scene.json`, `${name}.json`]) {
117
+ const at = resolve(sceneDir, guess);
118
+ if (existsSync(at)) {
119
+ scenes[name] = JSON.parse(readFileSync(at, 'utf-8'));
120
+ break;
121
+ }
122
+ }
123
+ }
124
+
125
+ let report;
126
+ try {
127
+ report = await playMultiplayer({
128
+ scene: sceneJson,
129
+ scenes,
130
+ players: Number(flag('--players', 2)) || 2,
131
+ seconds: Number(flag('--seconds', 5)) || 5,
132
+ seed: Number(flag('--seed', 1)) || 1,
133
+ lateJoin: !argv.includes('--no-late-join'),
134
+ load: {
135
+ stubMissingBehaviors: true,
136
+ resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
137
+ },
138
+ // Move the owner, or every replicated key holds its authored value and two
139
+ // motionless clients agree about nothing in particular.
140
+ drive: (player, frame) => {
141
+ const root = player.engine.scene?.root;
142
+ const owner = root && findOwner(root);
143
+ if (!owner || !Array.isArray(owner.position)) return;
144
+ const spin = (player.account.charCodeAt(1) || 1) * 0.7;
145
+ owner.position = owner.position.map((v, i) =>
146
+ i === 1 && owner.position.length === 3 ? v : Math.sin(frame / 20 + spin + i) * 4,
147
+ );
148
+ },
149
+ });
150
+ } catch (e) {
151
+ console.error(`multiplay failed: ${e?.message ?? e}`);
152
+ process.exit(1);
153
+ }
154
+
155
+ /** The single `network.mode === 'owner'` node, the way the manager finds it. */
156
+ function findOwner(node) {
157
+ if (node.network?.mode === 'owner') return node;
158
+ for (const child of node.children ?? []) {
159
+ const found = findOwner(child);
160
+ if (found) return found;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ if (argv.includes('--json')) {
166
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
167
+ } else {
168
+ process.stdout.write(`${multiplayText(report)}\n`);
169
+ }
170
+ process.exit(report.ok ? 0 : 1);
@@ -19,7 +19,7 @@
19
19
  * what to arrange, and says so in the summary.
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
- import { existsSync, readdirSync, statSync } from 'node:fs';
22
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
23
23
  import { dirname, join, relative } from 'node:path';
24
24
  import { fileURLToPath, pathToFileURL } from 'node:url';
25
25
 
@@ -35,18 +35,21 @@ const flag = (name) => {
35
35
  return i >= 0 ? argv[i + 1] : null;
36
36
  };
37
37
  if (argv.includes('--help') || argv.includes('-h')) {
38
- console.log(`Usage: incanto-verify [scene.json] [--behaviors FILE] [--json]
38
+ console.log(`Usage: incanto verify [scene.json] [--behaviors FILE] [--json]
39
39
 
40
40
  Walks the whole ladder and tells you the ONE thing to do next:
41
41
 
42
42
  loads the scene is legal and every asset resolves (incanto-check)
43
43
  plays a seeded run can actually finish it (incanto-playtest)
44
+ feels the sounds and effects it declares actually fired (engine.audio/effects)
45
+ agrees two clients hold the same replicated values (incanto-multiplay)
44
46
  draws the GPU drew something and the subject is in shot (incanto-frame)
45
47
  says the running game logs no errors and lost no assets (incanto-logs)
46
48
 
47
49
  Without a scene it looks for one *.scene.json under the current directory.
48
- "draws" needs a dev server with the page open; without one it is reported as
49
- NOT MEASURED rather than failed a missing measurement is not a broken game.`);
50
+ "draws" needs a dev server with the page open, and "agrees" needs a scene with
51
+ a "multiplayer" header; without either they are reported as NOT MEASURED rather
52
+ than failed — a missing measurement is not a broken game.`);
50
53
  process.exit(0);
51
54
  }
52
55
 
@@ -274,6 +277,67 @@ if (rungs[0].status === 'pass') {
274
277
  }
275
278
  }
276
279
 
280
+ // ---- agrees --------------------------------------------------------------
281
+ // A desync is the purest silent failure this engine has: both clients run,
282
+ // neither errors, and they are playing different games. Skipped outright for a
283
+ // single-player game, because a rung that always passes is decoration.
284
+ {
285
+ const isMultiplayer = (() => {
286
+ try {
287
+ const doc = JSON.parse(readFileSync(scene, 'utf-8'));
288
+ return Boolean(doc?.multiplayer);
289
+ } catch {
290
+ return false;
291
+ }
292
+ })();
293
+ if (!isMultiplayer) {
294
+ rungs.push({
295
+ name: 'agrees',
296
+ status: 'skipped',
297
+ summary: 'not run — this scene has no `multiplayer` header',
298
+ });
299
+ } else {
300
+ const args = [scene, '--json', '--seconds', '4'];
301
+ if (behaviors) args.push('--behaviors', behaviors);
302
+ const r = run('multiplay', args);
303
+ const out = safeJson(r.stdout);
304
+ if (!out) {
305
+ rungs.push({
306
+ name: 'agrees',
307
+ status: 'unmeasured',
308
+ summary: `the match could not run — ${firstLine(r.stderr) || `exit ${r.status}`}`,
309
+ fix: `see it in full: \`incanto multiplay ${scene}\``,
310
+ });
311
+ } else if (out.keysCompared === 0) {
312
+ // Nothing moved, so nothing was compared. Saying "pass" here is how a
313
+ // rung becomes decorative.
314
+ rungs.push({
315
+ name: 'agrees',
316
+ status: 'unmeasured',
317
+ summary: 'no owner node declared any sync keys, so agreement was not tested',
318
+ fix: 'give the player node `network: { mode: "owner", sync: ["position"] }`',
319
+ });
320
+ } else if (out.ok) {
321
+ rungs.push({
322
+ name: 'agrees',
323
+ status: 'pass',
324
+ summary:
325
+ `${out.keysCompared} replicated value(s) match across clients` +
326
+ `${out.lateJoinChecked ? ' and a late joiner' : ''}` +
327
+ ' — over one in-memory server, not a live wire',
328
+ });
329
+ } else {
330
+ const first = out.disagreements?.[0];
331
+ rungs.push({
332
+ name: 'agrees',
333
+ status: 'fail',
334
+ summary: first ? first.summary : (out.problems?.[0] ?? 'the clients disagreed'),
335
+ fix: `every disagreement: \`incanto multiplay ${scene}\``,
336
+ });
337
+ }
338
+ }
339
+ }
340
+
277
341
  // ---- draws ---------------------------------------------------------------
278
342
  {
279
343
  const r = run('frame', ['--json']);
package/bin/incanto.mjs CHANGED
@@ -35,6 +35,7 @@ const COMMANDS = {
35
35
  check: ['incanto-check.mjs', 'validate scene JSON and the assets it names'],
36
36
  verify: ['incanto-verify.mjs', 'the whole ladder: loads · plays · feels · draws · says'],
37
37
  playtest: ['incanto-playtest.mjs', 'drive the game headlessly and report what happened'],
38
+ multiplay: ['incanto-multiplay.mjs', 'run a real match and check the clients agree'],
38
39
  play: ['incanto-play.mjs', 'run a scene headlessly for a fixed span'],
39
40
  feel: ['incanto-feel.mjs', 'measure jump height, run speed, facing'],
40
41
  frame: ['incanto-frame.mjs', 'what the running page is drawing right now'],
package/dist/2d.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { At as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-l08AEbq9.js";
1
+ import { At as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-DWKTUzKI.js";
2
2
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
3
  import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
4
4
  import { r as FrameStats } from "./frame-report-DCnHFmto.js";
5
- import { t as LoadSceneOptions } from "./loader-BbEMTuWg.js";
5
+ import { t as LoadSceneOptions } from "./loader-TvkRFbyL.js";
6
6
  import { n as AnimationEntry } from "./sprite-animation-CMr6f1K2.js";
7
7
  import { r as ParticleView, t as ParticleSim } from "./particle-sim-C5OfBbmU.js";
8
8
  import { Group, Mesh, Object3D, Scene, Texture } from "three";
@@ -107,6 +107,9 @@ declare class PhysicsBody2D extends Node2D {
107
107
  private _collider;
108
108
  /** @internal Physics resync flag — raised when `collider` is REPLACED. */
109
109
  _colliderChanged: boolean;
110
+ private _enabled;
111
+ /** @internal Physics resync flag — raised when `enabled` is written. */
112
+ _enabledChanged: boolean;
110
113
  /**
111
114
  * Collider shape prop. Change it by REPLACING the object
112
115
  * (`body.collider = { shape: 'box', size: [...] }`) — physics rebuilds the
@@ -114,6 +117,9 @@ declare class PhysicsBody2D extends Node2D {
114
117
  */
115
118
  get collider(): JsonObject;
116
119
  set collider(value: JsonObject);
120
+ /** See the `enabled` prop. Writing it arms or disarms the existing collider. */
121
+ get enabled(): boolean;
122
+ set enabled(value: boolean);
117
123
  /** Loader hook: bad collider shapes fail at LOAD, not at physics start. */
118
124
  static validateJson(node: Node): void;
119
125
  /** @internal Set by Physics2D when the body is created. */
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-BZwWJIns.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-C6ZBFRjd.js";
4
- import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-EqA-jddf.js";
2
+ import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-CHDLDQsQ.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-BpFcgdcL.js";
4
+ import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-BaRSRrrZ.js";
5
5
  //#region src/2d/library-sprite.ts
6
6
  /**
7
7
  * What a `CharacterController2D`/`3D` will ask a skin to play, and the clip in
package/dist/3d.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { At as Node, D as QualityCaps, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-l08AEbq9.js";
1
+ import { At as Node, D as QualityCaps, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-DWKTUzKI.js";
2
2
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
3
  import { i as SceneJson$1, s as JsonObject } from "./schema-CFeioQRE.js";
4
4
  import { a as GridCell, c as diffText, d as frameText, i as FrameStatsOptions, l as frameSignature, n as FrameSignature, o as SIGNATURE_GRID, r as FrameStats, s as diffSignatures, t as FrameDiff, u as frameStats } from "./frame-report-DCnHFmto.js";
5
- import { t as LoadSceneOptions } from "./loader-BbEMTuWg.js";
5
+ import { t as LoadSceneOptions } from "./loader-TvkRFbyL.js";
6
6
  import { r as ParticleView, t as ParticleSim } from "./particle-sim-C5OfBbmU.js";
7
- import { n as PathGrid, s as SpatialPose } from "./pathfinding-mEN4V1CU.js";
7
+ import { n as PathGrid, s as SpatialPose } from "./pathfinding-BqWBb0kh.js";
8
8
  import { AnimationClip, AnimationMixer, BufferGeometry, Color, DirectionalLight, Group, InstancedMesh, Mesh, MeshPhysicalMaterial, Object3D, PerspectiveCamera, Scene, ShaderMaterial, Texture, Vector3, WebGLRenderer } from "three";
9
9
  import { VRM } from "@pixiv/three-vrm";
10
10
  import { Sky } from "three/examples/jsm/objects/Sky.js";
@@ -333,6 +333,9 @@ declare class PhysicsBody3D extends Node3D {
333
333
  private _collider;
334
334
  /** @internal Physics resync flag — raised when `collider` is REPLACED. */
335
335
  _colliderChanged: boolean;
336
+ private _enabled;
337
+ /** @internal Physics resync flag — raised when `enabled` is written. */
338
+ _enabledChanged: boolean;
336
339
  /**
337
340
  * Collider shape prop. Change it by REPLACING the object
338
341
  * (`body.collider = { shape: 'box', size: [...] }`) — physics rebuilds the
@@ -340,6 +343,9 @@ declare class PhysicsBody3D extends Node3D {
340
343
  */
341
344
  get collider(): JsonObject;
342
345
  set collider(value: JsonObject);
346
+ /** See the `enabled` prop. Writing it arms or disarms the existing collider. */
347
+ get enabled(): boolean;
348
+ set enabled(value: boolean);
343
349
  /** Loader hook: bad collider shapes fail at LOAD, not at physics start. */
344
350
  static validateJson(node: Node): void;
345
351
  /** @internal Set by Physics3D when the body is created. */
package/dist/3d.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { a as frameSignature, n as diffSignatures, o as frameStats, r as diffText, s as frameText, t as SIGNATURE_GRID } from "./frame-report-BSMny7oe.js";
2
- import { B as StaticBody3D, F as WaterCutout3D, I as Area3D, L as CharacterBody3D, N as Water3D, P as WATER_CUTOUT_MAX, R as PhysicsBody3D, V as Node3D, W as WATER_MAX_RIPPLES, z as RigidBody3D } from "./gameplay-BVphcxmE.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-CvvQr_bJ.js";
4
- 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-D10bU5_J.js";
2
+ import { B as StaticBody3D, F as WaterCutout3D, I as Area3D, L as CharacterBody3D, N as Water3D, P as WATER_CUTOUT_MAX, R as PhysicsBody3D, V as Node3D, W as WATER_MAX_RIPPLES, z as RigidBody3D } from "./gameplay-BBEjPFsR.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-DRAz5EV9.js";
4
+ 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-BiW8Men_.js";
5
5
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
6
- import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-Dnz4fsXX.js";
6
+ import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-CYxjh-HW.js";
7
7
  //#region src/3d/model-verdict.ts
8
8
  /** Mixamo exports every bone as `mixamorigX`; the retargeter binds by that name. */
9
9
  const MIXAMO = /^mixamorig[:_]?/i;
@@ -1033,6 +1033,16 @@ declare class Localization {
1033
1033
  * `startsWith` per widget per frame and nothing else.
1034
1034
  */
1035
1035
  resolve(value: string, params?: Record<string, JsonValue>): string;
1036
+ /**
1037
+ * Whether the ACTIVE locale or the base declares this key.
1038
+ *
1039
+ * This is the runtime question, and it is not the same as `has`: a key only
1040
+ * `ko` declares is "declared", and an English player still gets the raw key
1041
+ * back from `t()`, because the fallback chain is current → base → the key.
1042
+ * Widgets that ask before translating asked the wrong one and painted
1043
+ * `settings.quality.low` into an English menu.
1044
+ */
1045
+ declares(key: string): boolean;
1036
1046
  /** Whether any locale declares this key — for the audit, not for the runtime. */
1037
1047
  has(key: string): boolean;
1038
1048
  }
@@ -1,14 +1,14 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { A as registerBehavior, C as diagnose, n as loadScene } from "./loader-BcrRSjxB.js";
3
- import { _ as Engine, g as AudioPlayer, x as qualityRendering, y as qualityCaps } from "./register-Ch70uByv.js";
2
+ import { j as registerBehavior, n as loadScene, w as diagnose } from "./loader-D8n7TU8W.js";
3
+ import { S as qualityRendering, _ as Engine, b as qualityCaps, g as AudioPlayer } from "./register-CDrAQqPp.js";
4
4
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
5
  import { i as resolveRendering, n as attachTouchControls } from "./touch-DESwnpOc.js";
6
- import { a as openBundledEditor, c as claimCanvasGestures, i as devServerLibrary, l as audioErrors, n as pauseWhenHidden, o as poseFromRenderer, r as crossFade, s as wireDevChannel, t as teardown } from "./teardown-Cs113S9F.js";
6
+ import { a as openBundledEditor, c as claimCanvasGestures, i as devServerLibrary, l as audioErrors, n as pauseWhenHidden, o as poseFromRenderer, r as crossFade, s as wireDevChannel, t as teardown } from "./teardown-RApWnM1G.js";
7
7
  import { o as frameStats } from "./frame-report-BSMny7oe.js";
8
- import { R as PhysicsBody3D, U as createCausticsQuad, V as Node3D, n as registerGameplayBehaviors } from "./gameplay-BVphcxmE.js";
8
+ import { R as PhysicsBody3D, U as createCausticsQuad, V as Node3D, n as registerGameplayBehaviors } from "./gameplay-BBEjPFsR.js";
9
9
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
10
- import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-CvvQr_bJ.js";
11
- import { n as enablePhysics3D } from "./physics-3d-Dnz4fsXX.js";
10
+ import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-DRAz5EV9.js";
11
+ import { n as enablePhysics3D } from "./physics-3d-CYxjh-HW.js";
12
12
  import { ACESFilmicToneMapping, AmbientLight, Box3, BufferAttribute, BufferGeometry, Color, DepthTexture, EquirectangularReflectionMapping, FloatType, Fog, HalfFloatType, LineBasicMaterial, LineSegments, Matrix4, Mesh, PCFShadowMap, PMREMGenerator, PerspectiveCamera, PlaneGeometry, Quaternion, Raycaster, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
13
13
  import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
14
14
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
@@ -1,14 +1,14 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { A as registerBehavior, C as diagnose, n as loadScene, o as computeViewport, s as resolveViewport } from "./loader-BcrRSjxB.js";
3
- import { _ as Engine, g as AudioPlayer } from "./register-Ch70uByv.js";
2
+ import { c as resolveViewport, j as registerBehavior, n as loadScene, s as computeViewport, w as diagnose } from "./loader-D8n7TU8W.js";
3
+ import { _ as Engine, g as AudioPlayer } from "./register-CDrAQqPp.js";
4
4
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
5
5
  import { i as resolveRendering, n as attachTouchControls } from "./touch-DESwnpOc.js";
6
- import { a as openBundledEditor, c as claimCanvasGestures, i as devServerLibrary, l as audioErrors, n as pauseWhenHidden, r as crossFade, s as wireDevChannel, t as teardown } from "./teardown-Cs113S9F.js";
6
+ import { a as openBundledEditor, c as claimCanvasGestures, i as devServerLibrary, l as audioErrors, n as pauseWhenHidden, r as crossFade, s as wireDevChannel, t as teardown } from "./teardown-RApWnM1G.js";
7
7
  import { o as frameStats } from "./frame-report-BSMny7oe.js";
8
- import { n as registerGameplayBehaviors } from "./gameplay-BVphcxmE.js";
9
- import { g as PhysicsBody2D, n as UILayer, t as registerNodes2D, u as Camera2D, y as Node2D } from "./register-C6ZBFRjd.js";
8
+ import { n as registerGameplayBehaviors } from "./gameplay-BBEjPFsR.js";
9
+ import { g as PhysicsBody2D, n as UILayer, t as registerNodes2D, u as Camera2D, y as Node2D } from "./register-BpFcgdcL.js";
10
10
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
11
- import { n as enablePhysics2D } from "./physics-2d-EqA-jddf.js";
11
+ import { n as enablePhysics2D } from "./physics-2d-BaRSRrrZ.js";
12
12
  import { Box3, BufferAttribute, BufferGeometry, Color, LineBasicMaterial, LineSegments, LinearFilter, NearestFilter, OrthographicCamera, Raycaster, SRGBColorSpace, Scene, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three";
13
13
  //#region src/2d/assets.ts
14
14
  /**
package/dist/debug.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Pt as LogLevel, T as RendererStats, b as Engine } from "./behavior-l08AEbq9.js";
1
+ import { Pt as LogLevel, T as RendererStats, b as Engine } from "./behavior-DWKTUzKI.js";
2
2
 
3
3
  //#region src/debug/panel.d.ts
4
4
  /** Minimal document surface the overlay needs (injectable for tests). */
package/dist/debug.js CHANGED
@@ -1,5 +1,5 @@
1
- import { t as jsonClone } from "./json-BLk7H2Qa.js";
2
- import { s as mergeStaticProps } from "./registry-C7u42TID.js";
1
+ import { t as jsonClone } from "./json-CwwhxQgb.js";
2
+ import { s as mergeStaticProps } from "./registry-WWcQcfMr.js";
3
3
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
4
4
  //#region src/debug/capture.ts
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { i as serializeNode, t as buildNodeJson } from "./loader-BcrRSjxB.js";
1
+ import { a as serializeNode, t as buildNodeJson } from "./loader-D8n7TU8W.js";
2
2
  //#region src/core/scene/duplicate.ts
3
3
  function stripUids(json) {
4
4
  const { uid: _uid, ...rest } = json;
@@ -1,11 +1,11 @@
1
- import { C as diagnose } from "./loader-BcrRSjxB.js";
2
- import { t as registerCoreNodes } from "./register-Ch70uByv.js";
1
+ import { w as diagnose } from "./loader-D8n7TU8W.js";
2
+ import { I as translateOn, t as registerCoreNodes } from "./register-CDrAQqPp.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
5
- import { i as getNodeSchema, l as registerNode } from "./registry-C7u42TID.js";
5
+ import { i as getNodeSchema, l as registerNode } from "./registry-WWcQcfMr.js";
6
6
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
7
- import { i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, r as validateAnimationAliases, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-C0wXLBZJ.js";
8
- import { B as StaticBody3D, F as WaterCutout3D, G as colliderFootDrop, I as Area3D, L as CharacterBody3D, N as Water3D, R as PhysicsBody3D, V as Node3D, w as tolerateUnknownAction, z as RigidBody3D } from "./gameplay-BVphcxmE.js";
7
+ import { i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, r as validateAnimationAliases, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-CY-mrr1L.js";
8
+ import { B as StaticBody3D, F as WaterCutout3D, G as colliderFootDrop, I as Area3D, L as CharacterBody3D, N as Water3D, R as PhysicsBody3D, V as Node3D, w as tolerateUnknownAction, z as RigidBody3D } from "./gameplay-BBEjPFsR.js";
9
9
  import { t as checkSheetGrid } from "./sheet-grid-BT6N_Bjs.js";
10
10
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
11
11
  import { AdditiveBlending, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CapsuleGeometry, ClampToEdgeWrapping, Color, ConeGeometry, CylinderGeometry, DataTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, Group, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LinearMipmapLinearFilter, LoopOnce, LoopRepeat, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NoColorSpace, NormalBlending, PerspectiveCamera, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, SphereGeometry, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector3, Vector4, VectorKeyframeTrack } from "three";
@@ -5837,8 +5837,9 @@ var Label3D = class extends Sprite3D {
5837
5837
  canvasTexture = null;
5838
5838
  lastKey = "";
5839
5839
  resolveTexture(_assets) {
5840
- if (this.text === "" || typeof document === "undefined") return null;
5841
- const key = `${this.text}\0${this.height}\0${this.color}\0${this.font}\0${this.outline}`;
5840
+ const text = translateOn(this, this.text);
5841
+ if (text === "" || typeof document === "undefined") return null;
5842
+ const key = `${text}\0${this.height}\0${this.color}\0${this.font}\0${this.outline}`;
5842
5843
  if (key !== this.lastKey) {
5843
5844
  this.lastKey = key;
5844
5845
  this.rasterize();
@@ -5847,6 +5848,7 @@ var Label3D = class extends Sprite3D {
5847
5848
  }
5848
5849
  /** Draw the text once, and size the quad to what it measured. */
5849
5850
  rasterize() {
5851
+ const text = translateOn(this, this.text);
5850
5852
  const fontPx = Math.max(8, Math.round(this.height * 256));
5851
5853
  this.canvas ??= document.createElement("canvas");
5852
5854
  const ctx = this.canvas.getContext("2d");
@@ -5854,7 +5856,7 @@ var Label3D = class extends Sprite3D {
5854
5856
  const stroke = Math.max(1, Math.round(fontPx * .14));
5855
5857
  const fontSpec = `bold ${fontPx}px ${this.font}`;
5856
5858
  ctx.font = fontSpec;
5857
- const width = Math.max(1, Math.ceil(ctx.measureText(this.text).width) + stroke * 2);
5859
+ const width = Math.max(1, Math.ceil(ctx.measureText(text).width) + stroke * 2);
5858
5860
  const lineHeight = Math.ceil(fontPx * 1.35) + stroke * 2;
5859
5861
  this.canvas.width = width;
5860
5862
  this.canvas.height = lineHeight;
@@ -5865,10 +5867,10 @@ var Label3D = class extends Sprite3D {
5865
5867
  ctx.lineWidth = stroke;
5866
5868
  ctx.lineJoin = "round";
5867
5869
  ctx.strokeStyle = this.outline;
5868
- ctx.strokeText(this.text, width / 2, lineHeight / 2);
5870
+ ctx.strokeText(text, width / 2, lineHeight / 2);
5869
5871
  }
5870
5872
  ctx.fillStyle = this.color;
5871
- ctx.fillText(this.text, width / 2, lineHeight / 2);
5873
+ ctx.fillText(text, width / 2, lineHeight / 2);
5872
5874
  this.canvasTexture?.dispose();
5873
5875
  const texture = new CanvasTexture(this.canvas);
5874
5876
  texture.colorSpace = SRGBColorSpace;
@@ -1,7 +1,7 @@
1
- import { A as registerBehavior, C as diagnose, S as Node, T as Behavior, f as SaveSlots, n as loadScene, y as effectiveOrder } from "./loader-BcrRSjxB.js";
1
+ import { C as Node, E as Behavior, b as effectiveOrder, j as registerBehavior, n as loadScene, p as SaveSlots, w as diagnose } from "./loader-D8n7TU8W.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
- import { t as jsonClone } from "./json-BLk7H2Qa.js";
4
- import { t as duplicateNode } from "./duplicate-BPLZDZpd.js";
3
+ import { t as jsonClone } from "./json-CwwhxQgb.js";
4
+ import { t as duplicateNode } from "./duplicate-DJQd44CD.js";
5
5
  import { Color, CubeCamera, DepthTexture, DoubleSide, Euler, FloatType, Frustum, HalfFloatType, LinearMipmapLinearFilter, MathUtils, Matrix4, Mesh, MeshDepthMaterial, Object3D, PerspectiveCamera, Plane, PlaneGeometry, Quaternion, ShaderMaterial, Sphere, Vector2, Vector3, Vector4, WebGLCubeRenderTarget, WebGLRenderTarget } from "three";
6
6
  //#region src/3d/frustum.ts
7
7
  const scratchFrustum = new Frustum();
@@ -2441,43 +2441,62 @@ var Node3D = class Node3D extends Node {
2441
2441
  * `{shape:'capsule', radius, height}` (meters, y-up).
2442
2442
  */
2443
2443
  var PhysicsBody3D = class extends Node3D {
2444
- static props = { collider: {
2445
- default: {},
2446
- variants: {
2447
- tag: "shape",
2448
- byTag: {
2449
- auto: { shape: "auto" },
2450
- box: {
2451
- shape: "box",
2452
- size: [
2453
- 1,
2454
- 1,
2455
- 1
2456
- ]
2457
- },
2458
- sphere: {
2459
- shape: "sphere",
2460
- radius: .5
2461
- },
2462
- capsule: {
2463
- shape: "capsule",
2464
- radius: .35,
2465
- height: 1
2466
- },
2467
- trimesh: {
2468
- shape: "trimesh",
2469
- vertices: [],
2470
- indices: []
2471
- },
2472
- heightfield: { shape: "heightfield" }
2444
+ static props = {
2445
+ collider: {
2446
+ default: {},
2447
+ variants: {
2448
+ tag: "shape",
2449
+ byTag: {
2450
+ auto: { shape: "auto" },
2451
+ box: {
2452
+ shape: "box",
2453
+ size: [
2454
+ 1,
2455
+ 1,
2456
+ 1
2457
+ ]
2458
+ },
2459
+ sphere: {
2460
+ shape: "sphere",
2461
+ radius: .5
2462
+ },
2463
+ capsule: {
2464
+ shape: "capsule",
2465
+ radius: .35,
2466
+ height: 1
2467
+ },
2468
+ trimesh: {
2469
+ shape: "trimesh",
2470
+ vertices: [],
2471
+ indices: []
2472
+ },
2473
+ heightfield: { shape: "heightfield" }
2474
+ }
2473
2475
  }
2474
- }
2475
- } };
2476
+ },
2477
+ /**
2478
+ * Whether this body's collider takes part in physics right now.
2479
+ *
2480
+ * A melee hitbox is off most of the time and live for a few frames of a
2481
+ * swing, and there was no way to say so: games expressed it by REPLACING
2482
+ * `collider` with `{}`, which tears the rigid body down and builds a new
2483
+ * one twice per swing, and makes the engine warn `has no collider —
2484
+ * physics skips it` about a scene that is correct. Keep the real collider
2485
+ * and turn it off.
2486
+ *
2487
+ * Off means no contacts and no `triggerEnter`/`triggerExit`. The body
2488
+ * itself stays, so nothing is rebuilt and re-arming is one call.
2489
+ */
2490
+ enabled: { default: true }
2491
+ };
2476
2492
  /** The unified collision model: every collider participant can emit these. */
2477
2493
  static signals = ["triggerEnter", "triggerExit"];
2478
2494
  _collider = {};
2479
2495
  /** @internal Physics resync flag — raised when `collider` is REPLACED. */
2480
2496
  _colliderChanged = false;
2497
+ _enabled = true;
2498
+ /** @internal Physics resync flag — raised when `enabled` is written. */
2499
+ _enabledChanged = false;
2481
2500
  /**
2482
2501
  * Collider shape prop. Change it by REPLACING the object
2483
2502
  * (`body.collider = { shape: 'box', size: [...] }`) — physics rebuilds the
@@ -2490,6 +2509,15 @@ var PhysicsBody3D = class extends Node3D {
2490
2509
  this._collider = value;
2491
2510
  this._colliderChanged = true;
2492
2511
  }
2512
+ /** See the `enabled` prop. Writing it arms or disarms the existing collider. */
2513
+ get enabled() {
2514
+ return this._enabled;
2515
+ }
2516
+ set enabled(value) {
2517
+ if (value === this._enabled) return;
2518
+ this._enabled = value;
2519
+ this._enabledChanged = true;
2520
+ }
2493
2521
  /** Loader hook: bad collider shapes fail at LOAD, not at physics start. */
2494
2522
  static validateJson(node) {
2495
2523
  const body = node;