incanto 0.74.0 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/incanto-multiplay.mjs +52 -4
- package/dist/2d.d.ts +83 -7
- package/dist/2d.js +4 -3
- package/dist/3d.d.ts +861 -167
- package/dist/3d.js +6 -6
- package/dist/{agent8-CHTT4unP.js → agent8-B-bh3n0X.js} +1 -1
- package/dist/{audio-player-BOrk4eQx.d.ts → audio-player-BRo2uvG6.d.ts} +23 -2
- package/dist/{behavior-J1cmVah0.d.ts → behavior-B_245qRy.d.ts} +15 -1
- package/dist/{create-game-ssvGvP7W.js → create-game-BMIPKrQW.js} +84 -8
- package/dist/{create-game-BAiA-FjP.js → create-game-Dai5vfVu.js} +80 -11
- package/dist/debug.d.ts +1 -1
- package/dist/{editor-switch-CFU9mCec.d.ts → editor-switch-Bzt0GzVp.d.ts} +13 -3
- package/dist/editor.js +1600 -1529
- package/dist/env.d.ts +1 -1
- package/dist/{environment-presets-BkqlWewf.js → environment-presets-e_9YCnGu.js} +2361 -104
- package/dist/{gameplay-Bxe1sMVT.js → gameplay-Bfff_beb.js} +10919 -6798
- package/dist/gameplay.d.ts +573 -31
- package/dist/gameplay.js +3 -2
- package/dist/index.d.ts +150 -6
- package/dist/index.js +6 -6
- package/dist/{loader-BAeWMYfF.d.ts → loader-CbkVdXL8.d.ts} +2 -2
- package/dist/net.d.ts +2 -2
- package/dist/net.js +2 -2
- package/dist/{physics-2d-C-SfvjzH.js → physics-2d-Tuf5LAyq.js} +250 -31
- package/dist/{physics-3d-rOzeg890.js → physics-3d-C5MOujfQ.js} +673 -51
- package/dist/{picking-BMV34Pjl.js → picking-DIwE48sU.js} +10 -7
- package/dist/{save-slots-CEUGeLuB.js → pose-ByFB_J3O.js} +217 -11
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/{register-DRQyZKGU.js → register-7E3P1gz6.js} +191 -430
- package/dist/{register-CjociOtt.js → register-B768Qedj.js} +596 -2
- package/dist/{replay-B6BbgGM_.js → replay-DrqQ6dZj.js} +133 -18
- package/dist/{replay-wZK5Ok5c.d.ts → replay-W5nCw_cU.d.ts} +21 -2
- package/dist/{rng-Bb-IutXB.d.ts → rng-BsXZg3D6.d.ts} +7 -0
- package/dist/{split-screen-BxZ5rBe7.js → split-screen-B-dIcQsQ.js} +10 -5
- package/dist/{split-screen-D6sx8z3Z.d.ts → split-screen-CiLJf2-C.d.ts} +3 -3
- package/dist/{sprite-animation-Bz3DHD_4.js → sprite-animation-SQa5gIu2.js} +8 -6
- package/dist/{src-CqtvYtSN.js → src-D823V07k.js} +1 -1
- package/dist/{test-CEl0y2Sw.js → test-CVbxnXlv.js} +487 -82
- package/dist/test.d.ts +90 -12
- package/dist/test.js +2 -2
- package/dist/vite.js +34 -5
- package/editor/assets/{agent8-BlzturGi.js → agent8-DA-I5qhv.js} +1 -1
- package/editor/assets/{debug-BMUi8usj.js → debug-99tEPoGm.js} +1 -1
- package/editor/assets/index-Db74LZY-.js +11579 -0
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +1639 -280
- package/skills/incanto-3d-character.md +383 -20
- package/skills/incanto-audio.md +24 -2
- package/skills/incanto-building-2d-games.md +21 -2
- package/skills/incanto-building-3d-games.md +38 -3
- package/skills/incanto-editor.md +29 -8
- package/skills/incanto-environment.md +257 -14
- package/skills/incanto-game-feel.md +60 -1
- package/skills/incanto-gameplay-behaviors.md +668 -20
- package/skills/incanto-hud.md +116 -0
- package/skills/incanto-multiplayer.md +141 -2
- package/skills/incanto-node-reference.md +398 -16
- package/skills/incanto-performance.md +32 -0
- package/skills/incanto-physics-and-input.md +241 -13
- package/skills/incanto-playtesting.md +2 -2
- package/skills/incanto-save-slots.md +61 -2
- package/skills/incanto-scene-json-authoring.md +13 -0
- package/skills/incanto-verifying-your-game.md +86 -3
- package/templates-app/beacon-isle-3d/PROJECT/Status.md +12 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/game.scene.json +157 -3
- package/templates-app/molehill-2d/PROJECT/Status.md +7 -0
- package/templates-app/molehill-2d/package.json +1 -1
- package/templates-app/molehill-2d/src/game.scene.json +129 -2
- package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
- package/templates-app/platformer-2d/docs/project-2d-rules.md +3 -2
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/platformer-2d/src/behaviors.ts +40 -26
- package/templates-app/platformer-2d/src/game.scene.json +144 -2
- package/templates-app/platformer-2d/verify.ts +18 -1
- package/templates-app/star-survivor/PROJECT/Status.md +7 -0
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/star-survivor/src/behaviors.ts +9 -8
- package/templates-app/star-survivor/src/game.scene.json +142 -4
- package/templates-app/star-survivor/verify.ts +13 -0
- package/templates-app/tps-3d/PROJECT/Status.md +6 -0
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/game.scene.json +71 -10
- package/templates-app/village-quest-3d/PROJECT/Status.md +7 -0
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/behaviors.ts +8 -16
- package/templates-app/village-quest-3d/src/grove.scene.json +121 -5
- package/templates-app/village-quest-3d/src/village.scene.json +126 -2
- package/templates-app/village-quest-3d/verify.ts +52 -2
- package/editor/assets/index-CrUCQoaB.js +0 -11046
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { n as jsonEquals, t as jsonClone } from "./json-CfTjpvW8.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { G as HudLayer, K as HudWidgetBase, L as resetMemorySaves, N as resolveViewport, b as loadScene, dt as registerBehavior, f as isCloned, i as worldPosition, ot as StubBehavior } from "./pose-ByFB_J3O.js";
|
|
3
|
+
import { b as Engine } from "./register-B768Qedj.js";
|
|
4
4
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
5
5
|
import { t as Rng } from "./rng-CDOMybym.js";
|
|
6
|
-
import { a as LIGHT_NODE_TYPES, n as startRecording } from "./replay-
|
|
6
|
+
import { a as LIGHT_NODE_TYPES, n as startRecording } from "./replay-DrqQ6dZj.js";
|
|
7
7
|
import { c as getNodeSchema, d as mergeStaticProps, u as getNodeType } from "./touch-CioTZB-y.js";
|
|
8
|
-
import {
|
|
9
|
-
import { t as registerNodes2D } from "./register-
|
|
10
|
-
import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-
|
|
11
|
-
import { a as NetworkManager, c as readSyncKey, i as sanitizeName, n as registerNodesNet, r as NetworkSpawner, s as findOwnerNode, t as createSplitScreen } from "./split-screen-
|
|
8
|
+
import { n as registerGameplayBehaviors } from "./gameplay-Bfff_beb.js";
|
|
9
|
+
import { t as registerNodes2D } from "./register-7E3P1gz6.js";
|
|
10
|
+
import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-e_9YCnGu.js";
|
|
11
|
+
import { a as NetworkManager, c as readSyncKey, i as sanitizeName, n as registerNodesNet, r as NetworkSpawner, s as findOwnerNode, t as createSplitScreen } from "./split-screen-B-dIcQsQ.js";
|
|
12
12
|
import { Box3, Euler, Frustum, Matrix4, PerspectiveCamera, Quaternion, Ray, Vector3 } from "three";
|
|
13
13
|
//#region src/core/asset-urls.ts
|
|
14
14
|
/** Keys inside a `material` object that hold a texture URL. */
|
|
@@ -59,6 +59,32 @@ function sceneAssetUrls(json) {
|
|
|
59
59
|
url: value
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
+
const seen = new Set(out.filter((u) => u.path === path).map((u) => u.url));
|
|
63
|
+
const deep = (value, where) => {
|
|
64
|
+
if (typeof value === "string") {
|
|
65
|
+
if (/^https?:\/\//i.test(value) && !seen.has(value)) {
|
|
66
|
+
seen.add(value);
|
|
67
|
+
out.push({
|
|
68
|
+
path,
|
|
69
|
+
prop: where,
|
|
70
|
+
kind: "",
|
|
71
|
+
url: value
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
value.forEach((item, i) => {
|
|
78
|
+
deep(item, `${where}[${i}]`);
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (value && typeof value === "object") for (const [key, inner] of Object.entries(value)) deep(inner, `${where}.${key}`);
|
|
83
|
+
};
|
|
84
|
+
for (const [key, value] of Object.entries(props)) {
|
|
85
|
+
if (typeof value === "string") continue;
|
|
86
|
+
deep(value, key);
|
|
87
|
+
}
|
|
62
88
|
}
|
|
63
89
|
if (Array.isArray(node.children)) for (const c of node.children) visit(c, path);
|
|
64
90
|
};
|
|
@@ -1050,7 +1076,9 @@ function pointAtNode(scene, path, opts = {}) {
|
|
|
1050
1076
|
if (!node) return null;
|
|
1051
1077
|
const at = worldPosition(node);
|
|
1052
1078
|
if (at.length < 2) return null;
|
|
1053
|
-
const
|
|
1079
|
+
const entries = describeFraming(scene, opts.viewport ? { viewport: opts.viewport } : {}).entries;
|
|
1080
|
+
const here = node.getPath();
|
|
1081
|
+
const seen = entries.find((e) => e.path === here) ?? entries.find((e) => e.path.startsWith(`${here}/`));
|
|
1054
1082
|
if (seen && seen.where === "offscreen") return null;
|
|
1055
1083
|
if (scene.dimension === "2d") return [at[0] ?? 0, at[1] ?? 0];
|
|
1056
1084
|
if (!seen?.screen) return null;
|
|
@@ -1186,6 +1214,71 @@ function worldOf(node) {
|
|
|
1186
1214
|
/** Trigger volumes are destinations even without a script — that IS their job. */
|
|
1187
1215
|
const TRIGGERS = new Set(["Area2D", "Area3D"]);
|
|
1188
1216
|
/**
|
|
1217
|
+
* How big is this node, for the purpose of "did the player reach it"?
|
|
1218
|
+
*
|
|
1219
|
+
* Reach was measured against a node's ORIGIN, which is right for a coin and
|
|
1220
|
+
* wrong for anything with size: the origin of a 30×16 m quay is under its
|
|
1221
|
+
* middle, so a player who walked every plank of it was reported as never having
|
|
1222
|
+
* reached it. And a node earns its place on that list by carrying a GROUP,
|
|
1223
|
+
* which is exactly how every example here tags its ground.
|
|
1224
|
+
*
|
|
1225
|
+
* The half-diagonal of whatever the node declares — its collider first (that is
|
|
1226
|
+
* the thing you can stand on), then the mesh it draws — scaled by the node's
|
|
1227
|
+
* own scale. A node that declares no size gets 0 and behaves exactly as before.
|
|
1228
|
+
* Rotation is ignored on purpose: the diagonal is already the generous case.
|
|
1229
|
+
*/
|
|
1230
|
+
function reachRadiusOf(node) {
|
|
1231
|
+
const num = (n) => typeof n === "number" && Number.isFinite(n) ? n : NaN;
|
|
1232
|
+
/** Half-extents plus how to turn them into ONE radius for this shape. */
|
|
1233
|
+
let half = null;
|
|
1234
|
+
let round = false;
|
|
1235
|
+
const collider = node.collider;
|
|
1236
|
+
if (collider && typeof collider === "object") {
|
|
1237
|
+
const shape = collider.shape;
|
|
1238
|
+
if (shape === "box" && Array.isArray(collider.size)) {
|
|
1239
|
+
const [x, y, z] = collider.size;
|
|
1240
|
+
half = [
|
|
1241
|
+
num(x) / 2,
|
|
1242
|
+
num(y) / 2,
|
|
1243
|
+
num(z) / 2
|
|
1244
|
+
];
|
|
1245
|
+
} else if (shape === "sphere") {
|
|
1246
|
+
const r = num(collider.radius);
|
|
1247
|
+
half = [
|
|
1248
|
+
r,
|
|
1249
|
+
r,
|
|
1250
|
+
r
|
|
1251
|
+
];
|
|
1252
|
+
round = true;
|
|
1253
|
+
} else if (shape === "capsule" || shape === "cylinder") {
|
|
1254
|
+
const r = num(collider.radius);
|
|
1255
|
+
half = [
|
|
1256
|
+
r,
|
|
1257
|
+
num(collider.height) / 2 + (shape === "capsule" ? r : 0),
|
|
1258
|
+
r
|
|
1259
|
+
];
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (!half && Array.isArray(node.size) && node.size.length >= 3) {
|
|
1263
|
+
const [x, y, z] = node.size;
|
|
1264
|
+
half = [
|
|
1265
|
+
num(x) / 2,
|
|
1266
|
+
num(y) / 2,
|
|
1267
|
+
num(z) / 2
|
|
1268
|
+
];
|
|
1269
|
+
}
|
|
1270
|
+
if (!half) return 0;
|
|
1271
|
+
const s = Array.isArray(node.scale) ? node.scale : [];
|
|
1272
|
+
const at = (i) => Number.isFinite(num(s[i])) ? num(s[i]) : 1;
|
|
1273
|
+
const [hx, hy, hz] = [
|
|
1274
|
+
half[0] * at(0),
|
|
1275
|
+
half[1] * at(1),
|
|
1276
|
+
half[2] * at(2)
|
|
1277
|
+
];
|
|
1278
|
+
const r = round ? Math.max(hx, hy, hz) : Math.hypot(hx, hy, hz);
|
|
1279
|
+
return Number.isFinite(r) ? r : 0;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1189
1282
|
* Is this node somewhere the player is meant to GO?
|
|
1190
1283
|
*
|
|
1191
1284
|
* Author intent, not geometry. Keying off the tree alone reported 150 unreached
|
|
@@ -1211,6 +1304,7 @@ function isTarget(node) {
|
|
|
1211
1304
|
function makeOracle(engine, player) {
|
|
1212
1305
|
let outcome = null;
|
|
1213
1306
|
let damage = 0;
|
|
1307
|
+
let playerHealth = false;
|
|
1214
1308
|
const offs = [];
|
|
1215
1309
|
let declaresEnd = false;
|
|
1216
1310
|
/** First verdict wins — a run ends at its first conclusion, not its last. */
|
|
@@ -1227,9 +1321,12 @@ function makeOracle(engine, player) {
|
|
|
1227
1321
|
if (signal === "won") offs.push(node.on(signal, () => settle("won")));
|
|
1228
1322
|
if (signal === "lost") offs.push(node.on(signal, () => settle("lost")));
|
|
1229
1323
|
if (signal === "died" && isPlayerSide(node)) offs.push(node.on(signal, () => settle("lost")));
|
|
1230
|
-
if (signal === "damaged" && isPlayerSide(node))
|
|
1231
|
-
|
|
1232
|
-
|
|
1324
|
+
if (signal === "damaged" && isPlayerSide(node)) {
|
|
1325
|
+
playerHealth = true;
|
|
1326
|
+
offs.push(node.on(signal, () => {
|
|
1327
|
+
damage += 1;
|
|
1328
|
+
}));
|
|
1329
|
+
}
|
|
1233
1330
|
if (signal === "flowChanged") offs.push(node.on(signal, (state) => {
|
|
1234
1331
|
if (state === "won") settle("won");
|
|
1235
1332
|
if (state === "gameover") settle("lost");
|
|
@@ -1243,6 +1340,7 @@ function makeOracle(engine, player) {
|
|
|
1243
1340
|
outcome: () => outcome,
|
|
1244
1341
|
declaresEnd: () => declaresEnd,
|
|
1245
1342
|
damage: () => damage,
|
|
1343
|
+
playerHasHealth: () => playerHealth,
|
|
1246
1344
|
dispose: () => {
|
|
1247
1345
|
for (const off of offs) off();
|
|
1248
1346
|
}
|
|
@@ -1291,8 +1389,21 @@ var Driver = class {
|
|
|
1291
1389
|
/** What was pressed most recently, so a freeze can be attributed to it. */
|
|
1292
1390
|
lastPressed = null;
|
|
1293
1391
|
moveAction;
|
|
1392
|
+
/**
|
|
1393
|
+
* The OTHER characters' move actions — a couch co-op game declares one action
|
|
1394
|
+
* set per player and tells each controller which to read (`moveAction`), and
|
|
1395
|
+
* this bot drove the first vector2 action it found. Measured on a two-player
|
|
1396
|
+
* scene: player one walked 17.3 m and player two walked 0.0 m for the whole
|
|
1397
|
+
* run, and the report said the game could never be won — about a game that is
|
|
1398
|
+
* won by two people walking.
|
|
1399
|
+
*/
|
|
1400
|
+
extraMoves = [];
|
|
1294
1401
|
/** The button the scene's controller calls its jump — `jump` unless it says otherwise. */
|
|
1295
1402
|
jumpButton = "jump";
|
|
1403
|
+
/** Every controller's jump, so each player's jump gets the jump's odds. */
|
|
1404
|
+
jumpButtons = new Set(["jump"]);
|
|
1405
|
+
/** What the scene's controllers asked to read, in tree order. */
|
|
1406
|
+
controllerActions = [];
|
|
1296
1407
|
/**
|
|
1297
1408
|
* The action vocabulary this driver was bound to, so it can notice a swap.
|
|
1298
1409
|
*
|
|
@@ -1407,16 +1518,24 @@ var Driver = class {
|
|
|
1407
1518
|
}
|
|
1408
1519
|
}
|
|
1409
1520
|
if (root) {
|
|
1410
|
-
const
|
|
1411
|
-
while (stack.length > 0) {
|
|
1412
|
-
const node = stack.pop();
|
|
1521
|
+
const walk = (node) => {
|
|
1413
1522
|
if (CONTROLLERS.has(node.constructor.typeName ?? "")) {
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1523
|
+
const jump = node.jumpAction;
|
|
1524
|
+
const move = node.moveAction;
|
|
1525
|
+
this.controllerActions.push({
|
|
1526
|
+
move: typeof move === "string" && move !== "" ? move : "move",
|
|
1527
|
+
jump: typeof jump === "string" && jump !== "" ? jump : "jump"
|
|
1528
|
+
});
|
|
1417
1529
|
}
|
|
1418
|
-
for (const child of node.children)
|
|
1419
|
-
}
|
|
1530
|
+
for (const child of node.children) walk(child);
|
|
1531
|
+
};
|
|
1532
|
+
walk(root);
|
|
1533
|
+
const first = this.controllerActions[0];
|
|
1534
|
+
if (first) this.jumpButton = first.jump;
|
|
1535
|
+
this.jumpButtons = new Set(this.controllerActions.map((c) => c.jump));
|
|
1536
|
+
if (this.jumpButtons.size === 0) this.jumpButtons = new Set([this.jumpButton]);
|
|
1537
|
+
this.vocabulary = "";
|
|
1538
|
+
this.rebind();
|
|
1420
1539
|
}
|
|
1421
1540
|
this.readsMouse = asks;
|
|
1422
1541
|
const design = engine.scene?.viewport?.design;
|
|
@@ -1500,6 +1619,14 @@ var Driver = class {
|
|
|
1500
1619
|
this.headingLeft = .4 + this.rand() * 1.2;
|
|
1501
1620
|
}
|
|
1502
1621
|
if (this.moveAction) this.engine.input.setActionVector(this.moveAction, Math.cos(this.heading), Math.sin(this.heading));
|
|
1622
|
+
for (const other of this.extraMoves) {
|
|
1623
|
+
other.left -= dt;
|
|
1624
|
+
if (other.left <= 0) {
|
|
1625
|
+
other.heading = this.rand() * Math.PI * 2;
|
|
1626
|
+
other.left = .5 + this.rand() * 1.5;
|
|
1627
|
+
}
|
|
1628
|
+
this.engine.input.setActionVector(other.action, Math.cos(other.heading), Math.sin(other.heading));
|
|
1629
|
+
}
|
|
1503
1630
|
for (const button of this.buttons) {
|
|
1504
1631
|
if (this.frozeTheGame.has(button) || this.avoid.has(button)) continue;
|
|
1505
1632
|
const left = (this.held.get(button) ?? 0) - dt;
|
|
@@ -1512,7 +1639,7 @@ var Driver = class {
|
|
|
1512
1639
|
this.held.set(button, -1);
|
|
1513
1640
|
continue;
|
|
1514
1641
|
}
|
|
1515
|
-
const chance =
|
|
1642
|
+
const chance = this.jumpButtons.has(button) ? .06 : .02;
|
|
1516
1643
|
if (this.rand() < chance) {
|
|
1517
1644
|
this.engine.input.pressAction(button);
|
|
1518
1645
|
this.actionPresses += 1;
|
|
@@ -1707,7 +1834,16 @@ var Driver = class {
|
|
|
1707
1834
|
const signature = declared.map((a) => `${a.type}:${a.name}`).join(",");
|
|
1708
1835
|
if (signature === this.vocabulary) return;
|
|
1709
1836
|
this.vocabulary = signature;
|
|
1710
|
-
|
|
1837
|
+
const vectors = declared.filter((a) => a.type === "vector2").map((a) => a.name);
|
|
1838
|
+
const wanted = this.controllerActions[0]?.move;
|
|
1839
|
+
this.moveAction = (wanted && vectors.includes(wanted) ? wanted : vectors[0]) ?? null;
|
|
1840
|
+
const others = new Set(this.controllerActions.slice(1).map((c) => c.move).filter((name) => name !== this.moveAction && vectors.includes(name)));
|
|
1841
|
+
this.extraMoves = this.extraMoves.filter((m) => others.has(m.action));
|
|
1842
|
+
for (const action of others) if (!this.extraMoves.some((m) => m.action === action)) this.extraMoves.push({
|
|
1843
|
+
action,
|
|
1844
|
+
heading: this.rand() * Math.PI * 2,
|
|
1845
|
+
left: 0
|
|
1846
|
+
});
|
|
1711
1847
|
this.buttons = declared.filter((a) => a.type === "button").map((a) => a.name);
|
|
1712
1848
|
const still = new Set(this.buttons);
|
|
1713
1849
|
for (const button of [...this.held.keys()]) if (!still.has(button)) this.held.delete(button);
|
|
@@ -1718,6 +1854,7 @@ var Driver = class {
|
|
|
1718
1854
|
this.rebind();
|
|
1719
1855
|
for (const button of this.buttons) this.engine.input.releaseAction(button);
|
|
1720
1856
|
if (this.moveAction) this.engine.input.setActionVector(this.moveAction, 0, 0);
|
|
1857
|
+
for (const other of this.extraMoves) this.engine.input.setActionVector(other.action, 0, 0);
|
|
1721
1858
|
}
|
|
1722
1859
|
};
|
|
1723
1860
|
async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
|
|
@@ -1776,7 +1913,11 @@ async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
|
|
|
1776
1913
|
const targetNodes = [];
|
|
1777
1914
|
const collect = (node) => {
|
|
1778
1915
|
if (SCREEN_SPACE.has(node.constructor.typeName ?? "")) return;
|
|
1779
|
-
if (node !== player && isTarget(node)) targetNodes.push([
|
|
1916
|
+
if (node !== player && isTarget(node)) targetNodes.push([
|
|
1917
|
+
node.getPath(),
|
|
1918
|
+
node,
|
|
1919
|
+
reachRadiusOf(node)
|
|
1920
|
+
]);
|
|
1780
1921
|
for (const child of node.children) collect(child);
|
|
1781
1922
|
};
|
|
1782
1923
|
collect(scene.root);
|
|
@@ -1834,10 +1975,10 @@ async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
|
|
|
1834
1975
|
for (const path of engine.audio.sources()) heardSoFar.add(path);
|
|
1835
1976
|
for (const path of engine.effects.sources()) shownSoFar.add(path);
|
|
1836
1977
|
session.step(stepMs);
|
|
1837
|
-
if (player) for (const [path, node] of targetNodes) {
|
|
1978
|
+
if (player) for (const [path, node, extent] of targetNodes) {
|
|
1838
1979
|
if (reached.has(path)) continue;
|
|
1839
1980
|
const p = worldOf(node);
|
|
1840
|
-
if (Math.hypot(at[0] - p[0], at[1] - p[1], at[2] - p[2]) <= radius) reached.add(path);
|
|
1981
|
+
if (Math.hypot(at[0] - p[0], at[1] - p[1], at[2] - p[2]) <= radius + extent) reached.add(path);
|
|
1841
1982
|
}
|
|
1842
1983
|
const verdict = oracle.outcome();
|
|
1843
1984
|
if (verdict) {
|
|
@@ -1904,6 +2045,7 @@ async function runOnce(json, seed, opts, avoid = /* @__PURE__ */ new Set()) {
|
|
|
1904
2045
|
declaresEnd: oracle.declaresEnd(),
|
|
1905
2046
|
hazards,
|
|
1906
2047
|
hasPlayer: player !== null,
|
|
2048
|
+
playerHasHealth: oracle.playerHasHealth(),
|
|
1907
2049
|
froze: driver.paused(),
|
|
1908
2050
|
restartedBy,
|
|
1909
2051
|
unread: unreadNow
|
|
@@ -1935,6 +2077,7 @@ async function playtest(json, opts = {}) {
|
|
|
1935
2077
|
let signals = [];
|
|
1936
2078
|
let declaresWin = false;
|
|
1937
2079
|
let hasPlayer = false;
|
|
2080
|
+
let playerHasHealth = false;
|
|
1938
2081
|
let hazards = 0;
|
|
1939
2082
|
const froze = /* @__PURE__ */ new Set();
|
|
1940
2083
|
const restarted = /* @__PURE__ */ new Set();
|
|
@@ -1951,6 +2094,7 @@ async function playtest(json, opts = {}) {
|
|
|
1951
2094
|
signals = one.signals;
|
|
1952
2095
|
declaresWin = declaresWin || one.declaresEnd;
|
|
1953
2096
|
hasPlayer = hasPlayer || one.hasPlayer;
|
|
2097
|
+
playerHasHealth = playerHasHealth || one.playerHasHealth;
|
|
1954
2098
|
hazards = one.hazards;
|
|
1955
2099
|
for (const action of one.froze) froze.add(action);
|
|
1956
2100
|
if (one.restartedBy) restarted.add(one.restartedBy);
|
|
@@ -2000,6 +2144,7 @@ async function playtest(json, opts = {}) {
|
|
|
2000
2144
|
warnings: [...new Set(runs.flatMap((r) => r.warnings))],
|
|
2001
2145
|
declaresWin,
|
|
2002
2146
|
hasPlayer,
|
|
2147
|
+
playerHasHealth,
|
|
2003
2148
|
hazards,
|
|
2004
2149
|
froze: [...froze],
|
|
2005
2150
|
restarted: [...restarted],
|
|
@@ -2183,7 +2328,8 @@ function playtestText(report) {
|
|
|
2183
2328
|
lines.push("");
|
|
2184
2329
|
if (damage > 0) lines.push(` danger: ${damage} hits across ${total} runs (${(damage / total).toFixed(1)} per run)`);
|
|
2185
2330
|
else if (hazards === 0 && lost > 0) lines.push(` danger: nothing DEALT DAMAGE — no DamageOnContact, no hazard group — and yet ${lost} of ${total} runs were lost. The loss comes from something else: a fall, a timer, a trigger, or running out of lives.`);
|
|
2186
|
-
else if (hazards === 0) lines.push(" danger:
|
|
2331
|
+
else if (hazards === 0 && report.playerHasHealth) lines.push(" danger: no DamageOnContact is wired and no run took a hit — but the player HAS a Health, so either nothing reached it, or the damage comes from a behavior of your own calling `Health.damage()`, which this cannot see. A scripted run is the check.");
|
|
2332
|
+
else if (hazards === 0) lines.push(" danger: nothing in this scene can hurt the player — no DamageOnContact, and no player-side Health to lose. A game with no way to lose is a walkabout.");
|
|
2187
2333
|
else lines.push(` danger: ${hazards} hazard(s) are wired and none of them landed a hit in ${total} runs. They may be unreachable — or the bot may simply have outrun them, which a real player would not. Judge it with a scripted run.`);
|
|
2188
2334
|
return lines.join("\n");
|
|
2189
2335
|
}
|
|
@@ -2232,11 +2378,73 @@ function failingReplays(report) {
|
|
|
2232
2378
|
}));
|
|
2233
2379
|
}
|
|
2234
2380
|
//#endregion
|
|
2381
|
+
//#region src/test/start-the-game.ts
|
|
2382
|
+
/** Enough for a title screen's worth of buttons, not enough to be a bot. */
|
|
2383
|
+
const MAX_PRESSES = 8;
|
|
2384
|
+
const START = /play|start|run|begin|resume|continue|new|go\b/i;
|
|
2385
|
+
/** What a probe is allowed to sit through before deciding the game is stuck. */
|
|
2386
|
+
const MAX_WAIT_FRAMES = 900;
|
|
2387
|
+
/**
|
|
2388
|
+
* @returns what state the world reached — see `StartResult`.
|
|
2389
|
+
*/
|
|
2390
|
+
function startTheGame(ctx) {
|
|
2391
|
+
const started = ctx.engine.timeScale !== 0 ? true : pressStart(ctx);
|
|
2392
|
+
return {
|
|
2393
|
+
running: started,
|
|
2394
|
+
controllable: started && waitForControl(ctx)
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
/**
|
|
2398
|
+
* Sit out an opening cutscene.
|
|
2399
|
+
*
|
|
2400
|
+
* A cutscene takes control by switching the controller off, not by stopping the
|
|
2401
|
+
* clock, so nothing above can see it. The wait is BOUNDED and it only ever
|
|
2402
|
+
* happens to a game that actually switched the controller off — a game that is
|
|
2403
|
+
* playable from frame one pays four frames, which every probe in `feel.ts`
|
|
2404
|
+
* already spends settling.
|
|
2405
|
+
*/
|
|
2406
|
+
function waitForControl(ctx) {
|
|
2407
|
+
ctx.step(4);
|
|
2408
|
+
const controller = findController(ctx.root);
|
|
2409
|
+
if (!controller) return true;
|
|
2410
|
+
for (let waited = 0; waited < MAX_WAIT_FRAMES && controller.enabled === false; waited += 30) ctx.step(30);
|
|
2411
|
+
return controller.enabled !== false;
|
|
2412
|
+
}
|
|
2413
|
+
function findController(root) {
|
|
2414
|
+
const type = root.constructor.typeName ?? "";
|
|
2415
|
+
if (type === "CharacterController2D" || type === "CharacterController3D") return root;
|
|
2416
|
+
for (const child of root.children) {
|
|
2417
|
+
const found = findController(child);
|
|
2418
|
+
if (found) return found;
|
|
2419
|
+
}
|
|
2420
|
+
return null;
|
|
2421
|
+
}
|
|
2422
|
+
function pressStart(ctx) {
|
|
2423
|
+
const pressable = [];
|
|
2424
|
+
const walk = (node) => {
|
|
2425
|
+
const layer = node;
|
|
2426
|
+
if (typeof layer.focusables === "function") for (const widget of layer.focusables()) {
|
|
2427
|
+
const w = widget;
|
|
2428
|
+
if (typeof w.press === "function") pressable.push(w);
|
|
2429
|
+
}
|
|
2430
|
+
for (const child of node.children) walk(child);
|
|
2431
|
+
};
|
|
2432
|
+
walk(ctx.root);
|
|
2433
|
+
const label = (w) => typeof w.text === "string" ? w.text : "";
|
|
2434
|
+
const order = [...pressable.filter((w) => START.test(label(w))), ...pressable.filter((w) => !START.test(label(w)))];
|
|
2435
|
+
for (const widget of order.slice(0, MAX_PRESSES)) {
|
|
2436
|
+
widget.press();
|
|
2437
|
+
ctx.step(4);
|
|
2438
|
+
if (ctx.engine.timeScale !== 0) return true;
|
|
2439
|
+
}
|
|
2440
|
+
return ctx.engine.timeScale !== 0;
|
|
2441
|
+
}
|
|
2442
|
+
//#endregion
|
|
2235
2443
|
//#region src/test/facing.ts
|
|
2236
|
-
const DEG2RAD = Math.PI / 180;
|
|
2444
|
+
const DEG2RAD$1 = Math.PI / 180;
|
|
2237
2445
|
/** The forward vector of a yaw (degrees) for a given art convention. */
|
|
2238
2446
|
function forwardOf(yawDeg, axis) {
|
|
2239
|
-
const yaw = yawDeg * DEG2RAD;
|
|
2447
|
+
const yaw = yawDeg * DEG2RAD$1;
|
|
2240
2448
|
const base = [Math.sin(yaw), Math.cos(yaw)];
|
|
2241
2449
|
switch (axis) {
|
|
2242
2450
|
case "-z": return [-base[0], -base[1]];
|
|
@@ -2306,6 +2514,11 @@ async function facingReport(json, opts = {}) {
|
|
|
2306
2514
|
const p = player.position;
|
|
2307
2515
|
return [p[0] ?? 0, (is2d ? p[1] : p[2]) ?? 0];
|
|
2308
2516
|
};
|
|
2517
|
+
const { running } = startTheGame({
|
|
2518
|
+
engine: session.engine,
|
|
2519
|
+
root: session.scene.root,
|
|
2520
|
+
step: (frames) => session.step(frames * 1e3 / 60)
|
|
2521
|
+
});
|
|
2309
2522
|
session.step(500);
|
|
2310
2523
|
const from = posOf();
|
|
2311
2524
|
if (move) session.engine.input.setActionVector(move, 1, 0);
|
|
@@ -2317,7 +2530,9 @@ async function facingReport(json, opts = {}) {
|
|
|
2317
2530
|
const travelled = Math.hypot(dx, dz);
|
|
2318
2531
|
const { skin, declaredOffset } = findSkin(player, opts.skinPath);
|
|
2319
2532
|
const skinPath = skin ? skin.getPath() : null;
|
|
2320
|
-
|
|
2533
|
+
const stillness = is2d ? 2 : .05;
|
|
2534
|
+
const lockedOn = player.children.some((c) => c.lockTarget instanceof Object);
|
|
2535
|
+
if (skin && lockedOn) {
|
|
2321
2536
|
session.dispose();
|
|
2322
2537
|
return {
|
|
2323
2538
|
player: player.getPath(),
|
|
@@ -2326,7 +2541,19 @@ async function facingReport(json, opts = {}) {
|
|
|
2326
2541
|
backwards: false,
|
|
2327
2542
|
travelled: Math.round(travelled * 1e3) / 1e3,
|
|
2328
2543
|
basis: null,
|
|
2329
|
-
noAnswer:
|
|
2544
|
+
noAnswer: "locked-on"
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
if (!skin || travelled < stillness) {
|
|
2548
|
+
session.dispose();
|
|
2549
|
+
return {
|
|
2550
|
+
player: player.getPath(),
|
|
2551
|
+
skin: skinPath,
|
|
2552
|
+
dot: null,
|
|
2553
|
+
backwards: false,
|
|
2554
|
+
travelled: Math.round(travelled * 1e3) / 1e3,
|
|
2555
|
+
basis: null,
|
|
2556
|
+
noAnswer: running ? skin ? "did-not-move" : "no-skin" : "paused"
|
|
2330
2557
|
};
|
|
2331
2558
|
}
|
|
2332
2559
|
const heading = is2d ? forward2D(skin, dx, dz, declaredOffset) : {
|
|
@@ -2381,7 +2608,7 @@ function forward2D(skin, dx, dy, declaredOffset) {
|
|
|
2381
2608
|
};
|
|
2382
2609
|
}
|
|
2383
2610
|
if (typeof s.rotation === "number") {
|
|
2384
|
-
const yaw = (s.rotation - declaredOffset) * DEG2RAD;
|
|
2611
|
+
const yaw = (s.rotation - declaredOffset) * DEG2RAD$1;
|
|
2385
2612
|
return {
|
|
2386
2613
|
forward: [Math.cos(yaw), Math.sin(yaw)],
|
|
2387
2614
|
basis: "yaw"
|
|
@@ -2396,7 +2623,9 @@ function forward2D(skin, dx, dy, declaredOffset) {
|
|
|
2396
2623
|
function facingText(report) {
|
|
2397
2624
|
if (!report.player) return "facing: no player to drive";
|
|
2398
2625
|
if (report.skin === null) return `facing: ${report.player} has no skin to check`;
|
|
2626
|
+
if (report.noAnswer === "locked-on") return `facing: ${report.skin} is LOCKED ON (the controller's \`lockTarget\`), so it faces its target rather than its travel — a strafe is the intended reading, not a defect.`;
|
|
2399
2627
|
if (report.noAnswer === "no-heading") return `facing: ${report.skin} declares no heading (no flipX, no rotation) — nothing to compare its travel with`;
|
|
2628
|
+
if (report.noAnswer === "paused") return "facing: this game was PAUSED for the whole probe (`engine.timeScale` 0) and stayed that way after pressing every button on screen — a title screen holds the world until its START is pressed, so nothing moved and nothing was compared.";
|
|
2400
2629
|
if (report.dot === null) return `facing: ${report.player} did not move — nothing to compare`;
|
|
2401
2630
|
if (report.backwards) return `facing: ${report.skin} RUNS BACKWARDS (dot ${report.dot.toFixed(2)}) — the skin points away from the direction of travel. The rule is +Z forward, yaw = atan2(dx, dz), with NO +180; for art that faces another way set skinYawOffset rather than adding to the yaw.`;
|
|
2402
2631
|
return `facing: ${report.skin} faces its travel (dot ${report.dot.toFixed(2)})`;
|
|
@@ -2410,6 +2639,56 @@ const REL_TOL = .005;
|
|
|
2410
2639
|
function closeEnough(a, b, absTol, relTol) {
|
|
2411
2640
|
return Math.abs(a - b) <= Math.max(absTol, relTol * Math.abs(a));
|
|
2412
2641
|
}
|
|
2642
|
+
const RAD2DEG = 180 / Math.PI;
|
|
2643
|
+
/**
|
|
2644
|
+
* The slack a moving owner earns on one key: speed × window for a position,
|
|
2645
|
+
* spin × window (in degrees) for a rotation, nothing for anything else. Keyed
|
|
2646
|
+
* by the LAST path segment, so `Wheel0.rotation` is a rotation too.
|
|
2647
|
+
*/
|
|
2648
|
+
function motionSlack(key, motion) {
|
|
2649
|
+
if (!motion) return 0;
|
|
2650
|
+
const prop = key.slice(key.lastIndexOf(".") + 1);
|
|
2651
|
+
if (prop === "position") return motion.speed * motion.windowSeconds;
|
|
2652
|
+
if (prop === "rotation") return motion.spin * motion.windowSeconds * RAD2DEG;
|
|
2653
|
+
return 0;
|
|
2654
|
+
}
|
|
2655
|
+
/** Degrees two copies of one orientation may differ by at rest (a float's worth). */
|
|
2656
|
+
const ROTATION_TOL_DEG = .01;
|
|
2657
|
+
const DEG2RAD = Math.PI / 180;
|
|
2658
|
+
const isRotationKey = (key) => key.slice(key.lastIndexOf(".") + 1) === "rotation";
|
|
2659
|
+
/** Euler XYZ degrees → unit quaternion [x, y, z, w] (three.js order). */
|
|
2660
|
+
function quatOf(e) {
|
|
2661
|
+
const c1 = Math.cos((e[0] ?? 0) * DEG2RAD / 2);
|
|
2662
|
+
const c2 = Math.cos((e[1] ?? 0) * DEG2RAD / 2);
|
|
2663
|
+
const c3 = Math.cos((e[2] ?? 0) * DEG2RAD / 2);
|
|
2664
|
+
const s1 = Math.sin((e[0] ?? 0) * DEG2RAD / 2);
|
|
2665
|
+
const s2 = Math.sin((e[1] ?? 0) * DEG2RAD / 2);
|
|
2666
|
+
const s3 = Math.sin((e[2] ?? 0) * DEG2RAD / 2);
|
|
2667
|
+
return [
|
|
2668
|
+
s1 * c2 * c3 + c1 * s2 * s3,
|
|
2669
|
+
c1 * s2 * c3 - s1 * c2 * s3,
|
|
2670
|
+
c1 * c2 * s3 + s1 * s2 * c3,
|
|
2671
|
+
c1 * c2 * c3 - s1 * s2 * s3
|
|
2672
|
+
];
|
|
2673
|
+
}
|
|
2674
|
+
/**
|
|
2675
|
+
* The angle, in degrees, between two rotations written as the engine writes
|
|
2676
|
+
* them: one number (2D, degrees) or an Euler XYZ triple (3D, degrees). Any
|
|
2677
|
+
* other shape falls back to the plain component gap.
|
|
2678
|
+
*/
|
|
2679
|
+
function orientationGapDeg(a, b) {
|
|
2680
|
+
if (a.length === 1 && b.length === 1) {
|
|
2681
|
+
const d = Math.abs(((a[0] ?? 0) - (b[0] ?? 0)) % 360);
|
|
2682
|
+
return Math.min(d, 360 - d);
|
|
2683
|
+
}
|
|
2684
|
+
if (a.length === 3 && b.length === 3) {
|
|
2685
|
+
const p = quatOf(a);
|
|
2686
|
+
const q = quatOf(b);
|
|
2687
|
+
const dot = Math.min(1, Math.abs(p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]));
|
|
2688
|
+
return 2 * Math.acos(dot) / DEG2RAD;
|
|
2689
|
+
}
|
|
2690
|
+
return Math.max(...a.map((v, i) => Math.abs(v - (b[i] ?? 0))));
|
|
2691
|
+
}
|
|
2413
2692
|
function numbers(v) {
|
|
2414
2693
|
if (typeof v === "number") return [v];
|
|
2415
2694
|
if (Array.isArray(v) && v.every((x) => typeof x === "number")) return v;
|
|
@@ -2474,7 +2753,8 @@ function agreementProblems(live, late = [], opts = {}) {
|
|
|
2474
2753
|
});
|
|
2475
2754
|
continue;
|
|
2476
2755
|
}
|
|
2477
|
-
|
|
2756
|
+
const slack = motionSlack(key, s.motion);
|
|
2757
|
+
if (isRotationKey(key) ? orientationGapDeg(a, b) <= ROTATION_TOL_DEG + slack : a.every((v, i) => closeEnough(v, b[i], absTol + slack, relTol))) continue;
|
|
2478
2758
|
found.push({
|
|
2479
2759
|
kind: "mismatch",
|
|
2480
2760
|
account: s.account,
|
|
@@ -2632,6 +2912,21 @@ function instancesByName(engine) {
|
|
|
2632
2912
|
* @param clients every client in the match
|
|
2633
2913
|
* @param observers the clients whose beliefs to record (defaults to `clients`)
|
|
2634
2914
|
*/
|
|
2915
|
+
/** The server's queue turns every six frames in the harness — that is latency too. */
|
|
2916
|
+
const HARNESS_TURN_SECONDS = .1;
|
|
2917
|
+
const DEFAULT_THROTTLE_MS = 50;
|
|
2918
|
+
const magnitude = (v) => Array.isArray(v) && v.every((n) => typeof n === "number") ? Math.hypot(...v) : 0;
|
|
2919
|
+
/** How fast the owner is moving right now, and how stale its copies may be. */
|
|
2920
|
+
function motionOf(node) {
|
|
2921
|
+
const n = node;
|
|
2922
|
+
const net = node.network ?? {};
|
|
2923
|
+
const throttleMs = typeof net.throttleMs === "number" ? Math.max(30, net.throttleMs) : DEFAULT_THROTTLE_MS;
|
|
2924
|
+
return {
|
|
2925
|
+
speed: magnitude(n.linearVelocity),
|
|
2926
|
+
spin: magnitude(n.angularVelocity),
|
|
2927
|
+
windowSeconds: throttleMs / 1e3 + HARNESS_TURN_SECONDS
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2635
2930
|
function takeAgreementSnapshots(clients, observers = clients) {
|
|
2636
2931
|
const snapshots = [];
|
|
2637
2932
|
for (const owner of clients) {
|
|
@@ -2668,7 +2963,8 @@ function takeAgreementSnapshots(clients, observers = clients) {
|
|
|
2668
2963
|
account: owner.account,
|
|
2669
2964
|
truth,
|
|
2670
2965
|
seen,
|
|
2671
|
-
...unreadable.length > 0 ? { unreadable } : {}
|
|
2966
|
+
...unreadable.length > 0 ? { unreadable } : {},
|
|
2967
|
+
motion: motionOf(node)
|
|
2672
2968
|
});
|
|
2673
2969
|
}
|
|
2674
2970
|
return snapshots;
|
|
@@ -2754,7 +3050,7 @@ async function makeProbe(json, opts) {
|
|
|
2754
3050
|
const declared = session.engine.input.declaredActions();
|
|
2755
3051
|
const moveOptions = declared.filter((a) => a.type === "vector2").map((a) => a.name);
|
|
2756
3052
|
const named = (prop, type) => {
|
|
2757
|
-
const want = controllerProp(session.scene.root, prop);
|
|
3053
|
+
const want = controllerProp(session.scene.root, prop, player);
|
|
2758
3054
|
return want && declared.some((a) => a.name === want && a.type === type) ? want : null;
|
|
2759
3055
|
};
|
|
2760
3056
|
const move = opts.moveAction ?? named("moveAction", "vector2") ?? moveOptions[0] ?? null;
|
|
@@ -2767,34 +3063,44 @@ async function makeProbe(json, opts) {
|
|
|
2767
3063
|
move,
|
|
2768
3064
|
jump,
|
|
2769
3065
|
sprint,
|
|
2770
|
-
moveOptions
|
|
3066
|
+
moveOptions,
|
|
3067
|
+
paused: false,
|
|
3068
|
+
controllerOff: false
|
|
2771
3069
|
};
|
|
2772
3070
|
}
|
|
2773
3071
|
const is2d = session.scene.dimension === "2d";
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
return Math.hypot(v[0], v[2]);
|
|
2788
|
-
},
|
|
2789
|
-
onFloor: () => {
|
|
2790
|
-
const body = player;
|
|
2791
|
-
return typeof body.isOnFloor === "function" ? body.isOnFloor() : null;
|
|
2792
|
-
}
|
|
3072
|
+
const probe = {
|
|
3073
|
+
session,
|
|
3074
|
+
engine: session.engine,
|
|
3075
|
+
player,
|
|
3076
|
+
is2d,
|
|
3077
|
+
up: is2d ? -1 : 1,
|
|
3078
|
+
lift: is2d ? PX_LIFT : UNIT_LIFT,
|
|
3079
|
+
step: (frames = 1) => session.step(frames * STEP_MS),
|
|
3080
|
+
pos: () => positionOf(player),
|
|
3081
|
+
vel: () => velocityOf(player),
|
|
3082
|
+
speed: () => {
|
|
3083
|
+
const v = velocityOf(player);
|
|
3084
|
+
return Math.hypot(v[0], v[2]);
|
|
2793
3085
|
},
|
|
3086
|
+
onFloor: () => {
|
|
3087
|
+
const body = player;
|
|
3088
|
+
return typeof body.isOnFloor === "function" ? body.isOnFloor() : null;
|
|
3089
|
+
}
|
|
3090
|
+
};
|
|
3091
|
+
const state = startTheGame({
|
|
3092
|
+
engine: session.engine,
|
|
3093
|
+
root: session.scene.root,
|
|
3094
|
+
step: (frames) => probe.step(frames)
|
|
3095
|
+
});
|
|
3096
|
+
return {
|
|
3097
|
+
probe,
|
|
2794
3098
|
move,
|
|
2795
3099
|
jump,
|
|
2796
3100
|
sprint,
|
|
2797
|
-
moveOptions
|
|
3101
|
+
moveOptions,
|
|
3102
|
+
paused: !state.running,
|
|
3103
|
+
controllerOff: state.running && !state.controllable
|
|
2798
3104
|
};
|
|
2799
3105
|
}
|
|
2800
3106
|
/** Let the character settle onto whatever it is standing on. */
|
|
@@ -3044,20 +3350,20 @@ async function measureCoyote(json, opts, jump, jumps = 1) {
|
|
|
3044
3350
|
return Math.round((lo + 1) * STEP_MS);
|
|
3045
3351
|
}
|
|
3046
3352
|
/**
|
|
3047
|
-
*
|
|
3353
|
+
* A controller's declared window as written in the scene, in ms — for CONTRAST.
|
|
3048
3354
|
*
|
|
3049
3355
|
* The whole point of this module is that a number is measured rather than read
|
|
3050
3356
|
* off the schema. This one is read, and it earns its place by disagreeing: a
|
|
3051
3357
|
* controller that declares 0.12 and measures "always" is telling you something
|
|
3052
3358
|
* neither number says alone.
|
|
3053
3359
|
*/
|
|
3054
|
-
function
|
|
3360
|
+
function declaredControllerMs(json, prop) {
|
|
3055
3361
|
let found = null;
|
|
3056
3362
|
const walk = (node) => {
|
|
3057
3363
|
if (!node || typeof node !== "object") return;
|
|
3058
3364
|
const n = node;
|
|
3059
3365
|
if (typeof n.type === "string" && /^CharacterController[23]D$/.test(n.type)) {
|
|
3060
|
-
const value = n.props?.
|
|
3366
|
+
const value = n.props?.[prop];
|
|
3061
3367
|
if (typeof value === "number" && found === null) found = Math.round(value * 1e3);
|
|
3062
3368
|
}
|
|
3063
3369
|
for (const child of n.children ?? []) walk(child);
|
|
@@ -3170,7 +3476,11 @@ async function feelReport(json, opts = {}) {
|
|
|
3170
3476
|
airtimeMs: null,
|
|
3171
3477
|
coyoteMs: null,
|
|
3172
3478
|
jumpsInMidair: false,
|
|
3479
|
+
neverJumped: false,
|
|
3480
|
+
controllerOff: false,
|
|
3173
3481
|
declaredCoyoteMs: null,
|
|
3482
|
+
declaredJumpBufferMs: null,
|
|
3483
|
+
timeStood: false,
|
|
3174
3484
|
declaredMaxJumps: 1,
|
|
3175
3485
|
doubleJump: null,
|
|
3176
3486
|
jumpBufferMs: null,
|
|
@@ -3336,8 +3646,12 @@ async function feelReport(json, opts = {}) {
|
|
|
3336
3646
|
airtimeMs: shape.airtimeMs,
|
|
3337
3647
|
coyoteMs: coyote,
|
|
3338
3648
|
jumpsInMidair: coyote === null && jump !== null,
|
|
3339
|
-
declaredCoyoteMs:
|
|
3649
|
+
declaredCoyoteMs: declaredControllerMs(json, "coyoteSeconds"),
|
|
3650
|
+
declaredJumpBufferMs: declaredControllerMs(json, "jumpBufferSeconds"),
|
|
3340
3651
|
jumpBufferMs: buffer,
|
|
3652
|
+
timeStood: first.paused && topSpeed <= .05 && shape.apex === null,
|
|
3653
|
+
controllerOff: first.controllerOff && topSpeed <= .05,
|
|
3654
|
+
neverJumped: jump !== null && shape.apex === null && held.apex === null && walking.apex === null && sprinting.apex === null,
|
|
3341
3655
|
inputToMotionFrames: latency,
|
|
3342
3656
|
declaredMaxJumps: jumps,
|
|
3343
3657
|
doubleJump,
|
|
@@ -3362,7 +3676,14 @@ function feelText(report) {
|
|
|
3362
3676
|
const names = report.moveTried.map((n) => `'${n}'`).join(", ");
|
|
3363
3677
|
lines.push(` pushed ${names} every way (±x, ±y) — the player did not move`);
|
|
3364
3678
|
lines.push("");
|
|
3365
|
-
|
|
3679
|
+
if (report.controllerOff) {
|
|
3680
|
+
lines.push(" the player's CONTROLLER was switched off for the whole measurement, and stayed off through fifteen seconds of waiting. A cutscene does this — the clock runs, the world");
|
|
3681
|
+
lines.push(" moves, and only the character is held. Nothing below was measured. Measure the scene the player PLAYS, or end the cutscene sooner than fifteen seconds.");
|
|
3682
|
+
} else if (report.timeStood) {
|
|
3683
|
+
lines.push(" the clock never ran: this game was PAUSED for the whole measurement (`engine.timeScale` 0), and it stayed that way after pressing every button on");
|
|
3684
|
+
lines.push(" screen. A title screen holds the world like this until its START is pressed. NOTHING below was measured — every number is the stopped world, not the game.");
|
|
3685
|
+
lines.push(" Point this at the scene the game PLAYS, or give the start button a label the probe can find (PLAY / START / RESUME).");
|
|
3686
|
+
} else lines.push(` could not move ${report.player}: no motion in 300 frames with ${names} pushed ±x and ±y. Either the movement is not wired to a vector action, or it is driven by buttons. Nothing below was measured.`);
|
|
3366
3687
|
} else lines.push(` pushed '${report.moveAction}' [${report.moveAxis.join(", ")}]`);
|
|
3367
3688
|
if (report.moveAxis) {
|
|
3368
3689
|
lines.push(` top speed ${report.topSpeed} u/s`);
|
|
@@ -3384,8 +3705,16 @@ function feelText(report) {
|
|
|
3384
3705
|
if (report.doubleJump) lines.push(` ${report.declaredMaxJumps === 2 ? "double jump" : `${report.declaredMaxJumps} jumps`} ${report.doubleJump.apex} u (${report.declaredMaxJumps} TAPS, maxJumps ${report.declaredMaxJumps}${report.doubleJump.airtimeMs !== null ? `, ${ms(report.doubleJump.airtimeMs)} airtime` : ""})`);
|
|
3385
3706
|
lines.push(` time to apex ${ms(report.timeToApexMs)}`);
|
|
3386
3707
|
lines.push(` airtime ${ms(report.airtimeMs)}${cut && report.heldAirtimeMs !== null ? ` (${ms(report.heldAirtimeMs)} held)` : ""}`);
|
|
3387
|
-
|
|
3388
|
-
lines.push(`
|
|
3708
|
+
const declared = (v) => v === null ? "" : ` (the scene declares ${ms(v)})`;
|
|
3709
|
+
lines.push(` coyote window ${report.timeStood ? `not measured — the clock was stopped${declared(report.declaredCoyoteMs)}` : report.neverJumped ? `not measured — nothing ever jumped${declared(report.declaredCoyoteMs)}` : report.jumpsInMidair ? "not measurable — this character jumps in MID-AIR" : report.coyoteMs === null ? "always (no window)" : ms(report.coyoteMs)}`);
|
|
3710
|
+
lines.push(` jump buffer ${report.timeStood ? `not measured — the clock was stopped${declared(report.declaredJumpBufferMs)}` : report.neverJumped ? `not measured — nothing ever jumped${declared(report.declaredJumpBufferMs)}` : report.jumpBufferMs === null ? "always (no window)" : ms(report.jumpBufferMs)}${report.jumpsInMidair && !report.timeStood && !report.neverJumped ? " (suspect — see above)" : ""}`);
|
|
3711
|
+
if (report.neverJumped && !report.timeStood) {
|
|
3712
|
+
lines.push("");
|
|
3713
|
+
lines.push(` note: this scene declares '${report.jumpAction}' and NOTHING left the ground in any`);
|
|
3714
|
+
lines.push(" of the four jump probes — standing, held, walking, sprinting. Check that the");
|
|
3715
|
+
lines.push(` controller reads '${report.jumpAction}', that its \`jumpVelocity\` is not 0, and that it is \`enabled\`.`);
|
|
3716
|
+
lines.push(" Both windows above are searched by jumping, so neither was measured.");
|
|
3717
|
+
}
|
|
3389
3718
|
if (report.jumpsInMidair) {
|
|
3390
3719
|
lines.push("");
|
|
3391
3720
|
lines.push(" note: a jump still fires a full second after leaving the ground, so");
|
|
@@ -3394,12 +3723,12 @@ function feelText(report) {
|
|
|
3394
3723
|
lines.push(" while `jump` is held — a glide, a jetpack, an updraft.");
|
|
3395
3724
|
if (report.declaredCoyoteMs !== null) lines.push(` The controller DECLARES coyoteSeconds ${ms(report.declaredCoyoteMs)}; this probe cannot confirm it.`);
|
|
3396
3725
|
}
|
|
3397
|
-
if (report.coyoteMs === 0) {
|
|
3726
|
+
if (report.coyoteMs === 0 && !report.timeStood && !report.neverJumped) {
|
|
3398
3727
|
lines.push("");
|
|
3399
3728
|
lines.push(" note: coyote window is 0 — a jump that only fires while the ground check");
|
|
3400
3729
|
lines.push(" is true reads to players as unresponsive controls. Try coyoteSeconds: 0.12.");
|
|
3401
3730
|
}
|
|
3402
|
-
if (report.jumpBufferMs === 0) {
|
|
3731
|
+
if (report.jumpBufferMs === 0 && !report.timeStood && !report.neverJumped) {
|
|
3403
3732
|
lines.push(" note: jump buffer is 0 — a press a few frames before landing is thrown away.");
|
|
3404
3733
|
lines.push(" Try jumpBufferSeconds: 0.15.");
|
|
3405
3734
|
}
|
|
@@ -3413,19 +3742,35 @@ function feelText(report) {
|
|
|
3413
3742
|
}
|
|
3414
3743
|
return lines.join("\n");
|
|
3415
3744
|
}
|
|
3416
|
-
/**
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3745
|
+
/**
|
|
3746
|
+
* The action name the CHOSEN player's controller says it reads.
|
|
3747
|
+
*
|
|
3748
|
+
* This took the first controller a stack-walk found anywhere in the tree, which
|
|
3749
|
+
* in a two-player scene is the LAST one in the file. Measured on
|
|
3750
|
+
* `examples/coop-3d`, a local co-op forge: the report chose `Smith1` and then
|
|
3751
|
+
* drove it with `jump2`, Smith2's button, so a character that jumps 0.563 m
|
|
3752
|
+
* reported `jump apex — (nothing left the ground)` and a coyote window of 0 at
|
|
3753
|
+
* a controller declaring `coyoteSeconds: 0.12`. Move survived only because the
|
|
3754
|
+
* move probe has a fallback SEARCH; jump has none.
|
|
3755
|
+
*
|
|
3756
|
+
* Two rules, and `coop-3d` needed both to be wrong at once: ask the player that
|
|
3757
|
+
* was chosen FIRST, and walk in document order so "the first controller" means
|
|
3758
|
+
* the first one in the file.
|
|
3759
|
+
*/
|
|
3760
|
+
function controllerProp(root, prop, player = null) {
|
|
3761
|
+
const find = (from) => {
|
|
3762
|
+
const type = from.constructor.typeName ?? "";
|
|
3422
3763
|
if (type === "CharacterController2D" || type === "CharacterController3D") {
|
|
3423
|
-
const value =
|
|
3764
|
+
const value = from[prop];
|
|
3424
3765
|
return typeof value === "string" && value !== "" ? value : null;
|
|
3425
3766
|
}
|
|
3426
|
-
for (const child of
|
|
3427
|
-
|
|
3428
|
-
|
|
3767
|
+
for (const child of from.children) {
|
|
3768
|
+
const found = find(child);
|
|
3769
|
+
if (found !== null) return found;
|
|
3770
|
+
}
|
|
3771
|
+
return null;
|
|
3772
|
+
};
|
|
3773
|
+
return (player ? find(player) : null) ?? find(root);
|
|
3429
3774
|
}
|
|
3430
3775
|
//#endregion
|
|
3431
3776
|
//#region src/test/multiplay.ts
|
|
@@ -3457,6 +3802,7 @@ async function playMultiplayer(opts) {
|
|
|
3457
3802
|
if (frame % 6 === 0) await settle();
|
|
3458
3803
|
}
|
|
3459
3804
|
await settle();
|
|
3805
|
+
for (const player of players) player.engine.input.clearInjected();
|
|
3460
3806
|
for (let frame = 0; frame < 60; frame++) {
|
|
3461
3807
|
now += stepMs;
|
|
3462
3808
|
for (const player of players) player.engine.tick(now);
|
|
@@ -3464,6 +3810,7 @@ async function playMultiplayer(opts) {
|
|
|
3464
3810
|
}
|
|
3465
3811
|
await settle();
|
|
3466
3812
|
const live = takeAgreementSnapshots(players);
|
|
3813
|
+
const moving = live.filter((s) => (s.motion?.speed ?? 0) > .05 || (s.motion?.spin ?? 0) > .05).map((s) => s.account);
|
|
3467
3814
|
const matchDetail = players.map((p) => ({
|
|
3468
3815
|
account: p.account,
|
|
3469
3816
|
room: p.manager.roomId,
|
|
@@ -3496,7 +3843,8 @@ async function playMultiplayer(opts) {
|
|
|
3496
3843
|
disagreements: [...agreementProblems(live, lateSnapshots), ...collectionProblems(collections)],
|
|
3497
3844
|
keysCompared: live.reduce((n, s) => n + Object.keys(s.truth).length * Object.keys(s.seen).length, 0) + collections.reduce((n, c) => n + c.received.length, 0),
|
|
3498
3845
|
lateJoinChecked,
|
|
3499
|
-
...lateJoinRefused ? { lateJoinRefused } : {}
|
|
3846
|
+
...lateJoinRefused ? { lateJoinRefused } : {},
|
|
3847
|
+
moving
|
|
3500
3848
|
});
|
|
3501
3849
|
dispose();
|
|
3502
3850
|
return report;
|
|
@@ -3510,11 +3858,14 @@ async function playMultiplayer(opts) {
|
|
|
3510
3858
|
*/
|
|
3511
3859
|
async function joinLate(server, first, opts) {
|
|
3512
3860
|
try {
|
|
3513
|
-
const { Engine } = await import("./register-
|
|
3514
|
-
const { loadScene } = await import("./
|
|
3861
|
+
const { Engine } = await import("./register-B768Qedj.js").then((n) => n.x);
|
|
3862
|
+
const { loadScene } = await import("./pose-ByFB_J3O.js").then((n) => n.x);
|
|
3515
3863
|
const { jsonClone } = await import("./json-CfTjpvW8.js").then((n) => n.i);
|
|
3516
3864
|
const engine = new Engine(opts.seed !== void 0 ? { seed: opts.seed + 999 } : {});
|
|
3517
|
-
engine.setScene(loadScene(jsonClone(opts.scene),
|
|
3865
|
+
engine.setScene(loadScene(jsonClone(opts.scene), {
|
|
3866
|
+
...opts.load,
|
|
3867
|
+
engine
|
|
3868
|
+
}));
|
|
3518
3869
|
const manager = await NetworkManager.create(engine, {
|
|
3519
3870
|
transport: server.createClient("late"),
|
|
3520
3871
|
room: first.manager.roomId
|
|
@@ -3551,6 +3902,7 @@ function summarize(players, seconds, frames, matchDetail, agreement) {
|
|
|
3551
3902
|
disagreements: agreement.disagreements,
|
|
3552
3903
|
keysCompared: agreement.keysCompared,
|
|
3553
3904
|
lateJoinChecked: agreement.lateJoinChecked,
|
|
3905
|
+
moving: agreement.moving,
|
|
3554
3906
|
seconds,
|
|
3555
3907
|
frames,
|
|
3556
3908
|
rooms,
|
|
@@ -3606,6 +3958,7 @@ function multiplayText(report) {
|
|
|
3606
3958
|
const spawned = Object.entries(p.spawned).map(([name, n]) => `${name}=${n}`).join(" ");
|
|
3607
3959
|
lines.push(` ${p.account}: sees [${p.sawAccounts.join(", ") || "—"}]` + (spawned ? ` spawned ${spawned}` : "") + (p.errors ? ` · ${p.errors} error(s)` : ""));
|
|
3608
3960
|
}
|
|
3961
|
+
if (report.moving.length > 0) lines.push(` · ${report.moving.join(", ")} still moving when compared — a copy may trail by one send window, and that is not a disagreement`);
|
|
3609
3962
|
for (const problem of report.problems) lines.push(` ✗ ${problem}`);
|
|
3610
3963
|
if (report.ok) lines.push(" ✓ every client in one room, seeing the others");
|
|
3611
3964
|
return lines.join("\n");
|
|
@@ -4070,6 +4423,11 @@ function validateScene(json, opts = {}) {
|
|
|
4070
4423
|
throw e;
|
|
4071
4424
|
}
|
|
4072
4425
|
}
|
|
4426
|
+
/** Does anything in this tree want a mouse? */
|
|
4427
|
+
function hasClickable(node) {
|
|
4428
|
+
if (node.behavior?.constructor.name === "Clickable") return true;
|
|
4429
|
+
return node.children.some(hasClickable);
|
|
4430
|
+
}
|
|
4073
4431
|
/**
|
|
4074
4432
|
* What an assert's return value MEANS: a failure message, or null for a pass.
|
|
4075
4433
|
*
|
|
@@ -4109,10 +4467,10 @@ async function runScript(json, opts) {
|
|
|
4109
4467
|
if (opts.locale) engine.locale.locale = opts.locale;
|
|
4110
4468
|
const physics = opts.physics ?? "auto";
|
|
4111
4469
|
if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
|
|
4112
|
-
const { enablePhysics2D } = await import("./physics-2d-
|
|
4470
|
+
const { enablePhysics2D } = await import("./physics-2d-Tuf5LAyq.js").then((n) => n.r);
|
|
4113
4471
|
await enablePhysics2D(engine);
|
|
4114
4472
|
} else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
|
|
4115
|
-
const { enablePhysics3D } = await import("./physics-3d-
|
|
4473
|
+
const { enablePhysics3D } = await import("./physics-3d-C5MOujfQ.js").then((n) => n.r);
|
|
4116
4474
|
await enablePhysics3D(engine);
|
|
4117
4475
|
}
|
|
4118
4476
|
const failures = [];
|
|
@@ -4134,6 +4492,25 @@ async function runScript(json, opts) {
|
|
|
4134
4492
|
for (let at = hit; at; at = at.parent) if (at === wanted) return true;
|
|
4135
4493
|
return false;
|
|
4136
4494
|
};
|
|
4495
|
+
/**
|
|
4496
|
+
* Did this click reach the same HANDLER the author asked for?
|
|
4497
|
+
*
|
|
4498
|
+
* Two meshes of one object overlap on screen, and which one the ray meets
|
|
4499
|
+
* first is not something an author chose: `click: '…/Ballista/Frame'` was
|
|
4500
|
+
* reported as a miss because the picker found `…/Ballista/Bolt`, while the
|
|
4501
|
+
* click had gone through the real `Clickable` on their shared parent and
|
|
4502
|
+
* fired it. A click is wrong when it lands on a different THING, not on a
|
|
4503
|
+
* different triangle of the same one.
|
|
4504
|
+
*/
|
|
4505
|
+
const clickOwner = (node) => {
|
|
4506
|
+
for (let at = node; at; at = at.parent) if (at.behavior?.constructor.name === "Clickable") return at;
|
|
4507
|
+
return null;
|
|
4508
|
+
};
|
|
4509
|
+
const sameClick = (wanted, hit) => {
|
|
4510
|
+
if (isSelfOrAncestor(wanted, hit)) return true;
|
|
4511
|
+
const owner = clickOwner(wanted);
|
|
4512
|
+
return owner !== null && owner === clickOwner(hit);
|
|
4513
|
+
};
|
|
4137
4514
|
/** Frames on which a queued mouse release is due — see `ScriptStep.click`. */
|
|
4138
4515
|
let releaseAt = -1;
|
|
4139
4516
|
/** The button `click` pressed, so the queued release lets go of the same one. */
|
|
@@ -4144,6 +4521,13 @@ async function runScript(json, opts) {
|
|
|
4144
4521
|
* it — a behaviour reading `engine.pointerWorld()` must get an answer for the
|
|
4145
4522
|
* same cursor the click is at.
|
|
4146
4523
|
*/
|
|
4524
|
+
{
|
|
4525
|
+
const view = geometricView(live);
|
|
4526
|
+
engine.toWorld ??= view.toWorld;
|
|
4527
|
+
engine.toScreen ??= view.toScreen;
|
|
4528
|
+
engine.screenRay ??= view.screenRay;
|
|
4529
|
+
}
|
|
4530
|
+
if (hasClickable(live().root)) engine.input.setPointerPosition(0, 0);
|
|
4147
4531
|
const armPointer = () => {
|
|
4148
4532
|
engine.picker ??= geometricPicker(live);
|
|
4149
4533
|
if (!engine.toWorld) {
|
|
@@ -4224,7 +4608,7 @@ async function runScript(json, opts) {
|
|
|
4224
4608
|
const at = engine.input.pointerPosition();
|
|
4225
4609
|
const under = at ? engine.pickAt(at.x, at.y) : null;
|
|
4226
4610
|
const wanted = live().root.getNodeOrNull(named);
|
|
4227
|
-
if (wanted && (!under || !
|
|
4611
|
+
if (wanted && (!under || !sameClick(wanted, under))) failures.push({
|
|
4228
4612
|
atMs: step.atMs,
|
|
4229
4613
|
label: step.label,
|
|
4230
4614
|
message: `clicked '${named}' and the picker found ${under ? `'${under.getPath()}'` : "nothing"} there. A node is pickable when something can measure it: a \`size\` prop, a collider, or a spritesheet asset with \`frameWidth\`/\`frameHeight\`. A \`Sprite2D\` takes its size from the decoded texture, which headless never exists — give it a collider, or click the node that has the shape.`
|
|
@@ -4273,6 +4657,15 @@ async function runScript(json, opts) {
|
|
|
4273
4657
|
releaseAt = -1;
|
|
4274
4658
|
}
|
|
4275
4659
|
engine.step();
|
|
4660
|
+
if (opts.onFrame) try {
|
|
4661
|
+
opts.onFrame(ctx);
|
|
4662
|
+
} catch (error) {
|
|
4663
|
+
failures.push({
|
|
4664
|
+
atMs: Math.round(t),
|
|
4665
|
+
label: "onFrame",
|
|
4666
|
+
message: error instanceof Error ? error.message : String(error)
|
|
4667
|
+
});
|
|
4668
|
+
}
|
|
4276
4669
|
if (t >= nextSnapshot - 1e-6) {
|
|
4277
4670
|
snapshots.push({
|
|
4278
4671
|
atMs: Math.round(t),
|
|
@@ -4302,6 +4695,18 @@ async function runScript(json, opts) {
|
|
|
4302
4695
|
label: "engine",
|
|
4303
4696
|
message: `${swallowed} error(s) were swallowed to keep the loop alive — the logs above name the node, the behavior and the phase`
|
|
4304
4697
|
});
|
|
4698
|
+
const said = /* @__PURE__ */ new Set();
|
|
4699
|
+
for (const entry of logs) {
|
|
4700
|
+
if (entry.level !== "error") continue;
|
|
4701
|
+
const line = entry.parts.map((part) => part instanceof Error ? `${part.name}: ${part.message}` : String(part)).join(" ").replace(/\s+/g, " ").trim();
|
|
4702
|
+
if (line === "" || said.has(line)) continue;
|
|
4703
|
+
said.add(line);
|
|
4704
|
+
failures.push({
|
|
4705
|
+
atMs: opts.durationMs,
|
|
4706
|
+
label: "engine said",
|
|
4707
|
+
message: line
|
|
4708
|
+
});
|
|
4709
|
+
}
|
|
4305
4710
|
engine.dispose();
|
|
4306
4711
|
const ok = failures.length === 0;
|
|
4307
4712
|
return {
|
|
@@ -4350,10 +4755,10 @@ async function createPlaySession(json, opts = {}) {
|
|
|
4350
4755
|
engine.setScene(scene);
|
|
4351
4756
|
const physics = opts.physics ?? "auto";
|
|
4352
4757
|
if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
|
|
4353
|
-
const { enablePhysics2D } = await import("./physics-2d-
|
|
4758
|
+
const { enablePhysics2D } = await import("./physics-2d-Tuf5LAyq.js").then((n) => n.r);
|
|
4354
4759
|
await enablePhysics2D(engine);
|
|
4355
4760
|
} else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
|
|
4356
|
-
const { enablePhysics3D } = await import("./physics-3d-
|
|
4761
|
+
const { enablePhysics3D } = await import("./physics-3d-C5MOujfQ.js").then((n) => n.r);
|
|
4357
4762
|
await enablePhysics3D(engine);
|
|
4358
4763
|
}
|
|
4359
4764
|
const stepMs = 1e3 / (opts.fixedHz ?? 60);
|