incanto 0.72.0 → 0.74.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-check.mjs +114 -42
- package/bin/incanto-verify.mjs +9 -2
- package/dist/2d.d.ts +29 -4
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +137 -15
- package/dist/3d.js +6 -6
- package/dist/{agent8-BqZ0_O1u.js → agent8-CHTT4unP.js} +1 -1
- package/dist/{audio-player-ioZ7VKC6.d.ts → audio-player-BOrk4eQx.d.ts} +1 -1
- package/dist/{behavior-Bf9P9oB2.d.ts → behavior-J1cmVah0.d.ts} +8 -0
- package/dist/{create-game-CmFAtLZp.js → create-game-BAiA-FjP.js} +7 -7
- package/dist/{create-game-C9rWqqAC.js → create-game-ssvGvP7W.js} +7 -7
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +1 -1
- package/dist/editor.js +965 -893
- package/dist/{environment-presets-BeHoaGhy.js → environment-presets-BkqlWewf.js} +277 -60
- package/dist/{gameplay-BlQe-M07.js → gameplay-Bxe1sMVT.js} +611 -39
- package/dist/gameplay.d.ts +141 -7
- package/dist/gameplay.js +2 -2
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -6
- package/dist/{loader-CBfQzB6S.d.ts → loader-BAeWMYfF.d.ts} +1 -1
- package/dist/net.d.ts +2 -2
- package/dist/net.js +2 -2
- package/dist/{physics-2d-DrIgTlLk.js → physics-2d-C-SfvjzH.js} +94 -11
- package/dist/{physics-3d-BzOCw1VS.js → physics-3d-rOzeg890.js} +202 -15
- package/dist/{picking-C9McVJrO.js → picking-BMV34Pjl.js} +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-5IYfooAN.js → register-CjociOtt.js} +19 -4
- package/dist/{register-C8HuRkOf.js → register-DRQyZKGU.js} +45 -9
- package/dist/{replay-datiAE-b.js → replay-B6BbgGM_.js} +42 -6
- package/dist/{replay-CBEChqXq.d.ts → replay-wZK5Ok5c.d.ts} +1 -1
- package/dist/{save-slots-CEuJPUle.js → save-slots-CEUGeLuB.js} +11 -2
- package/dist/{split-screen-Bm5DTElG.js → split-screen-BxZ5rBe7.js} +4 -4
- package/dist/{split-screen-DkqLng6q.d.ts → split-screen-D6sx8z3Z.d.ts} +2 -2
- package/dist/{sprite-animation-CqR2o3SA.js → sprite-animation-Bz3DHD_4.js} +1 -1
- package/dist/{src-DRSsRleD.js → src-CqtvYtSN.js} +1 -1
- package/dist/{test-Djuq3Vhq.js → test-CEl0y2Sw.js} +162 -30
- package/dist/test.d.ts +56 -5
- package/dist/test.js +2 -2
- package/dist/{touch-BnCyPA0G.js → touch-CioTZB-y.js} +1 -1
- package/dist/vite.js +3 -3
- package/editor/assets/{agent8-CMKz2cax.js → agent8-BlzturGi.js} +1 -1
- package/editor/assets/{debug-DLrQm-az.js → debug-BMUi8usj.js} +1 -1
- package/editor/assets/{index-BEKuxYcE.js → index-CrUCQoaB.js} +53 -53
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +634 -10
- package/skills/incanto-3d-character.md +113 -5
- package/skills/incanto-assets.md +9 -3
- package/skills/incanto-behaviors-and-scripts.md +26 -2
- package/skills/incanto-building-2d-games.md +9 -0
- package/skills/incanto-building-3d-games.md +45 -0
- package/skills/incanto-editor.md +6 -1
- package/skills/incanto-environment.md +7 -2
- package/skills/incanto-game-feel.md +16 -11
- package/skills/incanto-gameplay-behaviors.md +179 -16
- package/skills/incanto-node-reference.md +124 -45
- package/skills/incanto-physics-and-input.md +67 -5
- package/skills/incanto-scene-json-authoring.md +9 -3
- package/skills/incanto-verifying-your-game.md +19 -2
- package/templates-app/beacon-isle-3d/PROJECT/Status.md +8 -0
- package/templates-app/beacon-isle-3d/PROJECT/Structure.md +3 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/game.scene.json +136 -0
- package/templates-app/beacon-isle-3d/verify.ts +144 -0
- package/templates-app/molehill-2d/package.json +1 -1
- package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
- package/templates-app/platformer-2d/PROJECT/Structure.md +3 -0
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/platformer-2d/src/game.scene.json +126 -0
- package/templates-app/platformer-2d/verify.ts +54 -0
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/tps-3d/PROJECT/Context.md +1 -1
- package/templates-app/tps-3d/PROJECT/Status.md +40 -1
- package/templates-app/tps-3d/PROJECT/Structure.md +19 -0
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/behaviors.ts +9 -0
- package/templates-app/tps-3d/src/cover.scene.json +26 -0
- package/templates-app/tps-3d/src/game.scene.json +331 -75
- package/templates-app/tps-3d/src/main.ts +14 -0
- package/templates-app/tps-3d/src/pillar.scene.json +25 -0
- package/templates-app/tps-3d/verify.ts +582 -3
- package/templates-app/village-quest-3d/package.json +1 -1
|
@@ -18,9 +18,15 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { Node } from 'incanto';
|
|
20
20
|
import { registerBehavior } from 'incanto';
|
|
21
|
-
import { auditScene, registerAllNodes, runScript } from 'incanto/test';
|
|
21
|
+
import { auditScene, feelReport, registerAllNodes, runScript } from 'incanto/test';
|
|
22
22
|
import { HudUpdater, Shoot } from './src/behaviors';
|
|
23
|
+
import coverJson from './src/cover.scene.json';
|
|
23
24
|
import sceneJson from './src/game.scene.json';
|
|
25
|
+
import pillarJson from './src/pillar.scene.json';
|
|
26
|
+
|
|
27
|
+
/** The prefabs `game.scene.json` places — bun has no `import.meta.glob`, so by name. */
|
|
28
|
+
const resolveScene = (path: string): never =>
|
|
29
|
+
(({ 'cover.scene.json': coverJson, 'pillar.scene.json': pillarJson })[path] ?? null) as never;
|
|
24
30
|
|
|
25
31
|
const behaviors = { Shoot, HudUpdater };
|
|
26
32
|
|
|
@@ -65,11 +71,13 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
65
71
|
{
|
|
66
72
|
let won = 0;
|
|
67
73
|
let moved = false;
|
|
74
|
+
let upperArmed = false;
|
|
68
75
|
let sawEnemy = false;
|
|
69
76
|
let chaseClosedIn = false;
|
|
70
77
|
let maxScore = 0;
|
|
71
78
|
|
|
72
79
|
const result = await runScript(sceneJson, {
|
|
80
|
+
resolveScene,
|
|
73
81
|
durationMs: 60000,
|
|
74
82
|
seed: 1,
|
|
75
83
|
behaviors,
|
|
@@ -128,6 +136,13 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
128
136
|
ctx.engine.input.pressAction('fire');
|
|
129
137
|
ctx.engine.step();
|
|
130
138
|
ctx.engine.input.releaseAction('fire');
|
|
139
|
+
// The shot rides the UPPER-BODY layer, so the legs keep running.
|
|
140
|
+
// Headless there is no mixer to clear it, which is exactly what
|
|
141
|
+
// lets this read that the shot ARMED it.
|
|
142
|
+
const skin = ctx.getNode('/root/Player/Skin') as unknown as {
|
|
143
|
+
animationUpper: string;
|
|
144
|
+
};
|
|
145
|
+
if (skin.animationUpper === '$anims/shoot') upperArmed = true;
|
|
131
146
|
}
|
|
132
147
|
ctx.engine.step();
|
|
133
148
|
const score = (ctx.getNode('/root').behavior as unknown as { score: number }).score;
|
|
@@ -139,6 +154,10 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
139
154
|
});
|
|
140
155
|
|
|
141
156
|
ok('holding move walked the player', moved);
|
|
157
|
+
ok(
|
|
158
|
+
'a shot plays on the upper body (animationUpper = $anims/shoot) while the legs keep their clip',
|
|
159
|
+
upperArmed,
|
|
160
|
+
);
|
|
142
161
|
ok('a wave produced enemies', sawEnemy);
|
|
143
162
|
ok('an enemy closed in on the player (Chase.moveParent)', chaseClosedIn);
|
|
144
163
|
ok('hitscan kills scored points', maxScore >= 5);
|
|
@@ -152,6 +171,7 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
152
171
|
let damaged = 0;
|
|
153
172
|
|
|
154
173
|
const result = await runScript(sceneJson, {
|
|
174
|
+
resolveScene,
|
|
155
175
|
durationMs: 8000,
|
|
156
176
|
seed: 2,
|
|
157
177
|
behaviors,
|
|
@@ -193,7 +213,12 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
193
213
|
let hp = 100;
|
|
194
214
|
let hits = 0;
|
|
195
215
|
const result = await runScript(sceneJson, {
|
|
196
|
-
|
|
216
|
+
resolveScene,
|
|
217
|
+
// 26 s, not 20: the first enemy arrives at ~17 s (a spawn pile at z −20,
|
|
218
|
+
// a detour round the 0.6 m Ramp), so a 20 s window made "keeps hurting"
|
|
219
|
+
// an arrival-time test that any extra collider in the arena could tip —
|
|
220
|
+
// adding two slopes at the far corner took it from 3 hits to 2.
|
|
221
|
+
durationMs: 26000,
|
|
197
222
|
seed: 5,
|
|
198
223
|
behaviors,
|
|
199
224
|
steps: [
|
|
@@ -205,7 +230,7 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
205
230
|
},
|
|
206
231
|
},
|
|
207
232
|
{
|
|
208
|
-
atMs:
|
|
233
|
+
atMs: 25000,
|
|
209
234
|
label: 'read the health the contact actually took',
|
|
210
235
|
do: (ctx) => {
|
|
211
236
|
hp = (ctx.getNode('/root/Player').behavior as unknown as { current: number }).current;
|
|
@@ -218,4 +243,558 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
218
243
|
ok('runScript reported no failures (CONTACT path)', result.ok);
|
|
219
244
|
}
|
|
220
245
|
|
|
246
|
+
// ---- FEEL: the controller's game-feel table, MEASURED -----------------------------
|
|
247
|
+
// `jumpCutMultiplier`, `maxJumps`, `airControl` and `fallGravity` were documented
|
|
248
|
+
// in the 3D character skill and set by no 3D example; this starter carries them
|
|
249
|
+
// now. Each is asserted by its EFFECT — the shape of the jump — never by reading
|
|
250
|
+
// the prop back, which is the difference between a scene that declares feel and
|
|
251
|
+
// a character that has it.
|
|
252
|
+
{
|
|
253
|
+
const feel = await feelReport(sceneJson, { behaviors, resolveScene });
|
|
254
|
+
const tapped = feel.jumpApex ?? 0;
|
|
255
|
+
const held = feel.heldJumpApex ?? tapped;
|
|
256
|
+
ok(
|
|
257
|
+
`jumpCutMultiplier: a tap (${tapped} u) is a shorter hop than a hold (${held} u)`,
|
|
258
|
+
tapped > 0.05 && held > tapped * 1.5,
|
|
259
|
+
);
|
|
260
|
+
ok(
|
|
261
|
+
`maxJumps 2: a second press in the air climbs higher (${feel.doubleJump?.apex ?? '—'} u vs ${tapped} u)`,
|
|
262
|
+
(feel.doubleJump?.apex ?? 0) > tapped * 1.4,
|
|
263
|
+
);
|
|
264
|
+
ok(
|
|
265
|
+
`coyote window is measured with a double jump declared (${feel.coyoteMs} ms)`,
|
|
266
|
+
(feel.coyoteMs ?? 0) >= 50,
|
|
267
|
+
);
|
|
268
|
+
ok(
|
|
269
|
+
`jump buffer is measured with a double jump declared (${feel.jumpBufferMs} ms)`,
|
|
270
|
+
(feel.jumpBufferMs ?? 0) >= 50,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---- STEP + JUMP: the two controller numbers a level is built against ---------
|
|
275
|
+
// `stepHeight` and `jumpVelocity` were set by no example. The arena now has a
|
|
276
|
+
// 0.3 m curb and a 0.9 m ledge at z −12, and each is asserted by what the player
|
|
277
|
+
// can DO about it — walk over the one, be stopped by the other — and the jump
|
|
278
|
+
// by how much higher a faster launch reaches, never by reading a prop back.
|
|
279
|
+
{
|
|
280
|
+
type Vec = { position: number[] };
|
|
281
|
+
let curbX = 0;
|
|
282
|
+
let floorY = 0;
|
|
283
|
+
let curbY = 0;
|
|
284
|
+
let ledgeX = 0;
|
|
285
|
+
const result = await runScript(sceneJson, {
|
|
286
|
+
resolveScene: resolveScene as never,
|
|
287
|
+
behaviors,
|
|
288
|
+
durationMs: 6000,
|
|
289
|
+
steps: [
|
|
290
|
+
{
|
|
291
|
+
atMs: 100,
|
|
292
|
+
do: (ctx) => {
|
|
293
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
294
|
+
player.position = [0, 1.4, -12];
|
|
295
|
+
ctx.engine.input.setActionVector('move', 1, 0);
|
|
296
|
+
for (let i = 0; i < 180; i++) {
|
|
297
|
+
ctx.engine.step();
|
|
298
|
+
const [x = 0, y = 0] = player.position;
|
|
299
|
+
if (i === 40) floorY = y; // settled, still short of the curb
|
|
300
|
+
if (x > 5.7 && x < 6.3) curbY = Math.max(curbY, y); // on top of it
|
|
301
|
+
}
|
|
302
|
+
curbX = player.position[0] ?? 0;
|
|
303
|
+
ctx.engine.input.setActionVector('move', 0, 0);
|
|
304
|
+
player.position = [0, 1.4, -12];
|
|
305
|
+
ctx.engine.input.setActionVector('move', -1, 0);
|
|
306
|
+
for (let i = 0; i < 180; i++) ctx.engine.step();
|
|
307
|
+
ledgeX = player.position[0] ?? 0;
|
|
308
|
+
ctx.engine.input.setActionVector('move', 0, 0);
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
ok('runScript reported no failures (STEP path)', result.ok);
|
|
314
|
+
ok(
|
|
315
|
+
`stepHeight 0.4: the player walks OVER the 0.3 m curb at x 6 (reached x ${curbX.toFixed(1)}; floor y ${floorY.toFixed(2)} → curb y ${curbY.toFixed(2)})`,
|
|
316
|
+
curbX > 7 && curbY - floorY > 0.2,
|
|
317
|
+
);
|
|
318
|
+
ok(
|
|
319
|
+
`…and the 0.9 m ledge at x −6 STOPS it (x ${ledgeX.toFixed(1)})`,
|
|
320
|
+
ledgeX > -5.7 && ledgeX < -4.5,
|
|
321
|
+
);
|
|
322
|
+
// The same scene with the launch back at the default: the prop is asserted
|
|
323
|
+
// by the DIFFERENCE it makes, which is the only thing a level cares about.
|
|
324
|
+
const text = JSON.stringify(sceneJson);
|
|
325
|
+
ok(
|
|
326
|
+
'the scene declares jumpVelocity 5 (the comparison needs it)',
|
|
327
|
+
text.includes('"jumpVelocity":5'),
|
|
328
|
+
);
|
|
329
|
+
const slower = JSON.parse(text.replace('"jumpVelocity":5', '"jumpVelocity":4'));
|
|
330
|
+
const fast = await feelReport(sceneJson, { behaviors, resolveScene });
|
|
331
|
+
const slow = await feelReport(slower, { behaviors, resolveScene });
|
|
332
|
+
ok(
|
|
333
|
+
`jumpVelocity 5 vs 4: a faster launch reaches higher (${fast.heldJumpApex ?? fast.jumpApex} u vs ${slow.heldJumpApex ?? slow.jumpApex} u)`,
|
|
334
|
+
(fast.heldJumpApex ?? 0) > (slow.heldJumpApex ?? 0) * 1.3,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- SLOPE: what an enemy can climb -----------------------------------------------
|
|
339
|
+
// `CharacterBody3D.slopeLimitDeg` was set by no example. The enemy prefab now
|
|
340
|
+
// declares 50°, and the arena has a 40° hill and a 70° cliff (at z −20, away from
|
|
341
|
+
// the fight), each rising 4 m of slab onto a plateau. One enemy is put at the
|
|
342
|
+
// foot of each with the player on top: it must climb the hill and reach, and
|
|
343
|
+
// must NOT climb the cliff. Asserted by where the enemy GOT, never by the prop.
|
|
344
|
+
{
|
|
345
|
+
type Vec = { position: number[] };
|
|
346
|
+
const climb = (
|
|
347
|
+
ctx: { getNode(p: string): unknown; engine: { step(): void } },
|
|
348
|
+
enemy: Vec,
|
|
349
|
+
foot: [number, number],
|
|
350
|
+
top: [number, number, number],
|
|
351
|
+
): { rose: number; gap: number } => {
|
|
352
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
353
|
+
enemy.position = [foot[0], 0.95, foot[1]];
|
|
354
|
+
player.position = [top[0], top[1] + 0.85, top[2]];
|
|
355
|
+
let rose = 0;
|
|
356
|
+
for (let i = 0; i < 240; i++) {
|
|
357
|
+
ctx.engine.step();
|
|
358
|
+
rose = Math.max(rose, (enemy.position[1] ?? 0) - 0.95);
|
|
359
|
+
}
|
|
360
|
+
const gap = Math.hypot((enemy.position[0] ?? 0) - top[0], (enemy.position[2] ?? 0) - top[2]);
|
|
361
|
+
player.position = [0, 1.4, 18]; // off the plateau before the contact damage adds up
|
|
362
|
+
return { rose, gap };
|
|
363
|
+
};
|
|
364
|
+
let hill = { rose: 0, gap: 99 };
|
|
365
|
+
let cliff = { rose: 0, gap: 99 };
|
|
366
|
+
const result = await runScript(sceneJson, {
|
|
367
|
+
resolveScene: resolveScene as never,
|
|
368
|
+
behaviors,
|
|
369
|
+
durationMs: 6000,
|
|
370
|
+
steps: [
|
|
371
|
+
{
|
|
372
|
+
atMs: 3500,
|
|
373
|
+
label: 'one enemy at the foot of the hill, then of the cliff',
|
|
374
|
+
do: (ctx) => {
|
|
375
|
+
const enemy = liveEnemies(ctx)[0] as unknown as Vec | undefined;
|
|
376
|
+
if (!enemy) return;
|
|
377
|
+
hill = climb(ctx, enemy, [6.97, -20], [13.03, 2.571, -20]);
|
|
378
|
+
cliff = climb(ctx, enemy, [15.82, -20], [20.18, 3.759, -20]);
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
});
|
|
383
|
+
ok('runScript reported no failures (SLOPE path)', result.ok);
|
|
384
|
+
ok(
|
|
385
|
+
`slopeLimitDeg 50: the enemy climbs the 40° hill and reaches the player on top (rose ${hill.rose.toFixed(2)} m, ${hill.gap.toFixed(1)} m short)`,
|
|
386
|
+
hill.rose > 2 && hill.gap < 3,
|
|
387
|
+
);
|
|
388
|
+
ok(
|
|
389
|
+
`…and cannot climb the 70° cliff (rose ${cliff.rose.toFixed(2)} m, ${cliff.gap.toFixed(1)} m short)`,
|
|
390
|
+
cliff.rose < 1 && cliff.gap > 3,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---- RIG: what the controller does about the world around it ---------------------
|
|
395
|
+
// `cameraCollision` and `platformCarry` were set by no example. The scene now
|
|
396
|
+
// declares both, the arena has a moving `Lift` (a StaticBody3D walked by
|
|
397
|
+
// Patrol), and each is asserted by an effect: how close the camera comes when
|
|
398
|
+
// a wall is behind you, and whether standing still on a moving floor keeps you
|
|
399
|
+
// on it. `floatHeight` was composed too and taken back out: the hover spring
|
|
400
|
+
// sags ~0.13 m under gravity, so below that the capsule rests on contact and
|
|
401
|
+
// the prop changes nothing (0.01 → y 0.839, 0.05 → 0.840, 0.3 → 1.008) — the
|
|
402
|
+
// rest height is reported here, not asserted, and the skill says why.
|
|
403
|
+
{
|
|
404
|
+
type Vec = { position: number[] };
|
|
405
|
+
let restY = 0;
|
|
406
|
+
let camGap = 99;
|
|
407
|
+
let liftTravel = 0;
|
|
408
|
+
let riderDrift = 99;
|
|
409
|
+
const result = await runScript(sceneJson, {
|
|
410
|
+
resolveScene: resolveScene as never,
|
|
411
|
+
behaviors,
|
|
412
|
+
durationMs: 8000,
|
|
413
|
+
steps: [
|
|
414
|
+
{
|
|
415
|
+
atMs: 100,
|
|
416
|
+
do: (ctx) => {
|
|
417
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
418
|
+
const cam = ctx.getNode('/root/Camera') as unknown as Vec;
|
|
419
|
+
const lift = ctx.getNode('/root/Arena/Lift') as unknown as Vec;
|
|
420
|
+
// where the capsule rests on open floor (half 0.5 + radius 0.34)
|
|
421
|
+
player.position = [0, 1.4, 12];
|
|
422
|
+
for (let i = 0; i < 120; i++) ctx.engine.step();
|
|
423
|
+
restY = player.position[1] ?? 0;
|
|
424
|
+
// cameraCollision: the boom points +z; 1.25 m from Wall2 (z 24.25) the
|
|
425
|
+
// camera cannot sit 4.2 m back
|
|
426
|
+
player.position = [10, 1.4, 23];
|
|
427
|
+
for (let i = 0; i < 120; i++) ctx.engine.step();
|
|
428
|
+
camGap = Math.hypot(
|
|
429
|
+
(cam.position[0] ?? 0) - (player.position[0] ?? 0),
|
|
430
|
+
(cam.position[2] ?? 0) - (player.position[2] ?? 0),
|
|
431
|
+
);
|
|
432
|
+
// platformCarry: stand on the Lift and do nothing for two seconds
|
|
433
|
+
const [lx = 0, , lz = 0] = lift.position;
|
|
434
|
+
player.position = [lx, 0.6 + 0.9, lz];
|
|
435
|
+
for (let i = 0; i < 30; i++) ctx.engine.step(); // settle
|
|
436
|
+
const x0 = lift.position[0] ?? 0;
|
|
437
|
+
const p0 = player.position[0] ?? 0;
|
|
438
|
+
for (let i = 0; i < 120; i++) ctx.engine.step();
|
|
439
|
+
liftTravel = (lift.position[0] ?? 0) - x0;
|
|
440
|
+
riderDrift = Math.abs((player.position[0] ?? 0) - p0 - liftTravel);
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
});
|
|
445
|
+
ok('runScript reported no failures (RIG path)', result.ok);
|
|
446
|
+
ok(
|
|
447
|
+
`the capsule rests at y ${restY.toFixed(3)} on open floor (0.84 = half 0.5 + radius 0.34)`,
|
|
448
|
+
restY > 0.8 && restY < 0.9,
|
|
449
|
+
);
|
|
450
|
+
ok(
|
|
451
|
+
`cameraCollision: with a wall behind, the camera closes to ${camGap.toFixed(2)} m (4.2 in the open)`,
|
|
452
|
+
camGap < 2.2,
|
|
453
|
+
);
|
|
454
|
+
ok(
|
|
455
|
+
`platformCarry: the Lift travelled ${liftTravel.toFixed(2)} m and the rider drifted only ${riderDrift.toFixed(2)} m off it`,
|
|
456
|
+
Math.abs(liftTravel) > 2.5 && riderDrift < 0.5,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---- CATCH: an arena you cannot leave for good --------------------------------
|
|
461
|
+
// The Hill and Cliff plateaus, the Lift and a double jump at jumpVelocity 5 —
|
|
462
|
+
// three compositions of this round — add up to a route over the 3 m walls:
|
|
463
|
+
// 24 seeded playtests fell out of the world 3 times. The walls stay; a
|
|
464
|
+
// `Respawn` under the player catches anything that gets over them.
|
|
465
|
+
{
|
|
466
|
+
type Vec = { position: number[] };
|
|
467
|
+
let caughtAt: number[] = [];
|
|
468
|
+
const result = await runScript(sceneJson, {
|
|
469
|
+
resolveScene: resolveScene as never,
|
|
470
|
+
behaviors,
|
|
471
|
+
durationMs: 4000,
|
|
472
|
+
steps: [
|
|
473
|
+
{
|
|
474
|
+
atMs: 100,
|
|
475
|
+
do: (ctx) => {
|
|
476
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
477
|
+
player.position = [-14, 4.8, -26]; // where the trace put it: over Wall1
|
|
478
|
+
for (let i = 0; i < 240; i++) ctx.engine.step();
|
|
479
|
+
caughtAt = [...player.position];
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
ok('runScript reported no failures (CATCH path)', result.ok);
|
|
485
|
+
ok(
|
|
486
|
+
`a player over the wall is caught and put back in the arena (y ${caughtAt[1]?.toFixed(2)}, z ${caughtAt[2]?.toFixed(1)})`,
|
|
487
|
+
(caughtAt[1] ?? -99) > 0 &&
|
|
488
|
+
Math.abs(caughtAt[2] ?? 99) < 24 &&
|
|
489
|
+
Math.abs(caughtAt[0] ?? 99) < 24,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---- RANGE: a mortar that lobs shells ---------------------------------------------
|
|
494
|
+
// `Spawner.at`/`autoStart`, `Projectile.direction`/`gravity` and a `Lifetime`
|
|
495
|
+
// riding a clone were set by no example. The arena's far corner has a Mortar
|
|
496
|
+
// (autoStart false, at [0,1,0]) whose Shell flies [0.8,0.6,0] at 8 u/s under
|
|
497
|
+
// `gravity: 9.81`. A POSITIVE gravity pulls DOWN in the scene's own convention
|
|
498
|
+
// now (+y in 2D, −y in 3D); it used to add to +y regardless, and 9.81 in this
|
|
499
|
+
// y-up scene LIFTED the shell — measured: y 19.36 at 1.5 s and still climbing
|
|
500
|
+
// at 59.8 m when the Lifetime freed it. Asserted by
|
|
501
|
+
// where the clone appears, which way its y goes and when it is freed — never
|
|
502
|
+
// by reading a prop back.
|
|
503
|
+
{
|
|
504
|
+
type Vec = { position: number[] };
|
|
505
|
+
type Sp = { liveCount: number; spawn(): Node | null };
|
|
506
|
+
let idleLive = -1;
|
|
507
|
+
let born: number[] = [];
|
|
508
|
+
const ys: number[] = [];
|
|
509
|
+
const xs: number[] = [];
|
|
510
|
+
let freedAtFrame = -1;
|
|
511
|
+
const result = await runScript(sceneJson, {
|
|
512
|
+
resolveScene: resolveScene as never,
|
|
513
|
+
behaviors,
|
|
514
|
+
durationMs: 8000,
|
|
515
|
+
steps: [
|
|
516
|
+
{
|
|
517
|
+
atMs: 100,
|
|
518
|
+
do: (ctx) => {
|
|
519
|
+
const sp = ctx.getNode('/root/Arena/Mortar').behavior as unknown as Sp;
|
|
520
|
+
for (let i = 0; i < 180; i++) ctx.engine.step(); // 3 s: autoStart false ticks nothing
|
|
521
|
+
idleLive = sp.liveCount;
|
|
522
|
+
const shell = sp.spawn();
|
|
523
|
+
if (!shell) return;
|
|
524
|
+
const s = shell as unknown as Vec;
|
|
525
|
+
born = [...s.position];
|
|
526
|
+
for (let i = 0; i < 240; i++) {
|
|
527
|
+
ctx.engine.step();
|
|
528
|
+
if (shell.parent === null) {
|
|
529
|
+
freedAtFrame = i + 1;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
xs.push(s.position[0] ?? 0);
|
|
533
|
+
ys.push(s.position[1] ?? 0);
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
});
|
|
539
|
+
ok('runScript reported no failures (RANGE path)', result.ok);
|
|
540
|
+
ok(
|
|
541
|
+
`autoStart false: after 3 s the mortar has spawned ${idleLive} shells on its own`,
|
|
542
|
+
idleLive === 0,
|
|
543
|
+
);
|
|
544
|
+
ok(
|
|
545
|
+
`at [0,1,0]: the shell appears 1 m over the mortar (local ${born.map((v) => v.toFixed(2)).join(', ')})`,
|
|
546
|
+
Math.abs(born[0] ?? 9) < 0.01 &&
|
|
547
|
+
Math.abs((born[1] ?? 9) - 1) < 0.01 &&
|
|
548
|
+
Math.abs(born[2] ?? 9) < 0.01,
|
|
549
|
+
);
|
|
550
|
+
const apex = Math.max(...ys);
|
|
551
|
+
const apexAt = ys.indexOf(apex) / 60;
|
|
552
|
+
const y15 = ys[89] ?? Number.NaN;
|
|
553
|
+
const x15 = xs[89] ?? Number.NaN;
|
|
554
|
+
ok(
|
|
555
|
+
`gravity 9.81 pulls the shell DOWN in a y-up scene: apex ${apex.toFixed(2)} m at ${apexAt.toFixed(2)} s, y ${y15.toFixed(2)} at 1.5 s, x ${x15.toFixed(2)} (a lob at vy 4.8 peaks at 2.17 m at 0.49 s and is under the floor by 1.5 s; +9.81 climbed to 19.4 m)`,
|
|
556
|
+
apex < 2.5 && apexAt < 0.7 && y15 < 0,
|
|
557
|
+
);
|
|
558
|
+
ok(
|
|
559
|
+
`Lifetime 3 s + freeParent frees the shell at frame ${freedAtFrame} (180 = 3 s)`,
|
|
560
|
+
freedAtFrame >= 178 && freedAtFrame <= 184,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---- FACING: an enemy that turns to look where it walks ---------------------------
|
|
565
|
+
// `Chase.facePath`/`turnSpeed` were set by no example. The enemy's AI now points
|
|
566
|
+
// `facePath` at its Skin (the Eye rides the Skin, so the turn is visible) at
|
|
567
|
+
// 3 rad/s. Asserted by the Skin's yaw: settled on one heading, then how far it
|
|
568
|
+
// has turned 0.1 s after the target moves 90° round — a snap would be there already.
|
|
569
|
+
{
|
|
570
|
+
type Vec = { position: number[] };
|
|
571
|
+
type Rot = { rotation: number[] };
|
|
572
|
+
let yawSettled = 99;
|
|
573
|
+
let yawAfter100ms = 99;
|
|
574
|
+
let yawAfter1s = 99;
|
|
575
|
+
const result = await runScript(sceneJson, {
|
|
576
|
+
resolveScene: resolveScene as never,
|
|
577
|
+
behaviors,
|
|
578
|
+
durationMs: 6000,
|
|
579
|
+
steps: [
|
|
580
|
+
{
|
|
581
|
+
atMs: 3500,
|
|
582
|
+
do: (ctx) => {
|
|
583
|
+
const enemy = liveEnemies(ctx)[0] as (Node & Vec) | undefined;
|
|
584
|
+
if (!enemy) return;
|
|
585
|
+
const skin = enemy.findChild('Skin') as unknown as Rot;
|
|
586
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
587
|
+
enemy.position = [-16, 0.95, 14];
|
|
588
|
+
player.position = [-16, 1.4, 4]; // travel −z → yaw 180
|
|
589
|
+
for (let i = 0; i < 90; i++) ctx.engine.step();
|
|
590
|
+
yawSettled = skin.rotation[1] ?? 99;
|
|
591
|
+
player.position = [-6, 1.4, enemy.position[2] ?? 10]; // travel +x → yaw 90
|
|
592
|
+
for (let i = 0; i < 6; i++) ctx.engine.step();
|
|
593
|
+
yawAfter100ms = skin.rotation[1] ?? 99;
|
|
594
|
+
for (let i = 0; i < 54; i++) ctx.engine.step();
|
|
595
|
+
yawAfter1s = skin.rotation[1] ?? 99;
|
|
596
|
+
player.position = [0, 1.4, 18];
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
});
|
|
601
|
+
ok('runScript reported no failures (FACING path)', result.ok);
|
|
602
|
+
ok(
|
|
603
|
+
`facePath ../Skin: walking −z the skin settles at yaw ${yawSettled.toFixed(1)}° (180 = +Z-forward turned about)`,
|
|
604
|
+
Math.abs(Math.abs(yawSettled) - 180) < 3,
|
|
605
|
+
);
|
|
606
|
+
ok(
|
|
607
|
+
`turnSpeed 3 rad/s: 0.1 s after the target moves round to +x the skin has turned to ${yawAfter100ms.toFixed(1)}° (≈163 expected — 17° of the 90; 90 would be a snap)`,
|
|
608
|
+
yawAfter100ms > 150 && yawAfter100ms < 175,
|
|
609
|
+
);
|
|
610
|
+
ok(`…and after 1 s it faces +x at ${yawAfter1s.toFixed(1)}°`, Math.abs(yawAfter1s - 90) < 5);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---- LIFT: a deck that turns to face where it travels (Patrol.facePath/turnSpeed) ---
|
|
614
|
+
// The same pair on `Patrol`, set by no example either. The Lift's Skin now turns
|
|
615
|
+
// toward its travel at 2 rad/s; a pingpong reversal is a 180° turn that should
|
|
616
|
+
// take π/2 s. Asserted by how far the deck has turned 0.25 s and 2 s after the
|
|
617
|
+
// reversal the harness watches for.
|
|
618
|
+
{
|
|
619
|
+
type Vec = { position: number[] };
|
|
620
|
+
type Rot = { rotation: number[] };
|
|
621
|
+
let flipAtFrame = -1;
|
|
622
|
+
let yawBefore = 99;
|
|
623
|
+
let yawQuarter = 99;
|
|
624
|
+
let yawLater = 99;
|
|
625
|
+
const result = await runScript(sceneJson, {
|
|
626
|
+
resolveScene: resolveScene as never,
|
|
627
|
+
behaviors,
|
|
628
|
+
durationMs: 12000,
|
|
629
|
+
steps: [
|
|
630
|
+
{
|
|
631
|
+
atMs: 100,
|
|
632
|
+
do: (ctx) => {
|
|
633
|
+
const lift = ctx.getNode('/root/Arena/Lift') as Node & Vec;
|
|
634
|
+
const skin = lift.findChild('Skin') as unknown as Rot;
|
|
635
|
+
let prev = lift.position[0] ?? 0;
|
|
636
|
+
let dir = 0;
|
|
637
|
+
for (let i = 0; i < 600; i++) {
|
|
638
|
+
ctx.engine.step();
|
|
639
|
+
const x = lift.position[0] ?? 0;
|
|
640
|
+
const d = Math.sign(x - prev);
|
|
641
|
+
prev = x;
|
|
642
|
+
if (d !== 0 && dir !== 0 && d !== dir) {
|
|
643
|
+
flipAtFrame = i;
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
if (d !== 0) dir = d;
|
|
647
|
+
}
|
|
648
|
+
yawBefore = skin.rotation[1] ?? 99;
|
|
649
|
+
for (let i = 0; i < 15; i++) ctx.engine.step();
|
|
650
|
+
yawQuarter = skin.rotation[1] ?? 99;
|
|
651
|
+
for (let i = 0; i < 105; i++) ctx.engine.step();
|
|
652
|
+
yawLater = skin.rotation[1] ?? 99;
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
});
|
|
657
|
+
const turned = (a: number, b: number): number => Math.abs(((b - a + 540) % 360) - 180);
|
|
658
|
+
ok('runScript reported no failures (LIFT path)', result.ok);
|
|
659
|
+
ok(
|
|
660
|
+
`the Lift reversed at frame ${flipAtFrame} (8 m at 2 m/s ≈ 4 s = 240)`,
|
|
661
|
+
flipAtFrame > 200 && flipAtFrame < 280,
|
|
662
|
+
);
|
|
663
|
+
ok(
|
|
664
|
+
`turnSpeed 2 rad/s: 0.25 s after the reversal the deck has turned ${turned(yawBefore, yawQuarter).toFixed(1)}° of 180 (≈29 expected)`,
|
|
665
|
+
turned(yawBefore, yawQuarter) > 20 && turned(yawBefore, yawQuarter) < 40,
|
|
666
|
+
);
|
|
667
|
+
ok(
|
|
668
|
+
`…and 2 s after it has turned the full ${turned(yawBefore, yawLater).toFixed(1)}°`,
|
|
669
|
+
turned(yawBefore, yawLater) > 170,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---- MINE: a fuse that starts when stepped on (Lifetime.startOnSignal) -------------
|
|
674
|
+
// A 1.5 s `Lifetime` with `startOnSignal` sits at the far corner with a Trigger
|
|
675
|
+
// area wired `triggerEnter → startTimer` under `filter: { group: "player" }` —
|
|
676
|
+
// the 1 m sphere overlaps the floor, and without the filter the FLOOR lit the
|
|
677
|
+
// fuse on frame 1. Asserted by the node still being there after 3 s, then going
|
|
678
|
+
// 1.5 s after the player steps on it.
|
|
679
|
+
{
|
|
680
|
+
type Vec = { position: number[] };
|
|
681
|
+
let expired = 0;
|
|
682
|
+
let aliveAfter3s = false;
|
|
683
|
+
let framesToExpire = -1;
|
|
684
|
+
const result = await runScript(sceneJson, {
|
|
685
|
+
resolveScene: resolveScene as never,
|
|
686
|
+
behaviors,
|
|
687
|
+
durationMs: 8000,
|
|
688
|
+
steps: [
|
|
689
|
+
{
|
|
690
|
+
atMs: 100,
|
|
691
|
+
do: (ctx) => {
|
|
692
|
+
const mine = ctx.getNode('/root/Arena/Mine');
|
|
693
|
+
mine.on('expired', () => expired++);
|
|
694
|
+
for (let i = 0; i < 180; i++) ctx.engine.step();
|
|
695
|
+
aliveAfter3s = mine.parent !== null && expired === 0;
|
|
696
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
697
|
+
player.position = [20, 1.4, 20];
|
|
698
|
+
for (let i = 0; i < 240; i++) {
|
|
699
|
+
ctx.engine.step();
|
|
700
|
+
if (expired > 0) {
|
|
701
|
+
framesToExpire = i + 1;
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
player.position = [0, 1.4, 18];
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
],
|
|
709
|
+
});
|
|
710
|
+
ok('runScript reported no failures (MINE path)', result.ok);
|
|
711
|
+
ok(
|
|
712
|
+
`startOnSignal: a 1.5 s Lifetime is still in the tree after 3 s (${aliveAfter3s})`,
|
|
713
|
+
aliveAfter3s,
|
|
714
|
+
);
|
|
715
|
+
ok(
|
|
716
|
+
`…and stepping on its trigger starts the fuse: expired ${framesToExpire} frames after (90 = 1.5 s, plus the contact frame or two)`,
|
|
717
|
+
framesToExpire >= 89 && framesToExpire <= 100,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ---- BAIT: a pickup only the enemies take (Pickup.collectorGroup) ---------------------
|
|
722
|
+
// `collectorGroup` was set by no example — every pickup in the repo is the
|
|
723
|
+
// player's. A Bait area near Wall4 says `collectorGroup: "enemy"`. Asserted by who
|
|
724
|
+
// collects it: the player standing on it must not, an enemy walked onto it must.
|
|
725
|
+
{
|
|
726
|
+
type Vec = { position: number[] };
|
|
727
|
+
let collected = 0;
|
|
728
|
+
let playerTook = -1;
|
|
729
|
+
let enemyTook = -1;
|
|
730
|
+
let baitGone = false;
|
|
731
|
+
const result = await runScript(sceneJson, {
|
|
732
|
+
resolveScene: resolveScene as never,
|
|
733
|
+
behaviors,
|
|
734
|
+
durationMs: 6000,
|
|
735
|
+
steps: [
|
|
736
|
+
{
|
|
737
|
+
atMs: 3500,
|
|
738
|
+
do: (ctx) => {
|
|
739
|
+
const bait = ctx.getNode('/root/Arena/Bait');
|
|
740
|
+
bait.on('collected', () => collected++);
|
|
741
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
742
|
+
player.position = [21, 1.4, 12];
|
|
743
|
+
for (let i = 0; i < 30; i++) ctx.engine.step();
|
|
744
|
+
playerTook = collected;
|
|
745
|
+
player.position = [0, 1.4, 18];
|
|
746
|
+
const enemy = liveEnemies(ctx)[0] as unknown as Vec | undefined;
|
|
747
|
+
if (!enemy) return;
|
|
748
|
+
enemy.position = [21, 0.95, 12];
|
|
749
|
+
for (let i = 0; i < 30; i++) ctx.engine.step();
|
|
750
|
+
enemyTook = collected;
|
|
751
|
+
baitGone = bait.parent === null;
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
],
|
|
755
|
+
});
|
|
756
|
+
ok('runScript reported no failures (BAIT path)', result.ok);
|
|
757
|
+
ok(
|
|
758
|
+
`collectorGroup enemy: the player standing on the bait collected ${playerTook} of it`,
|
|
759
|
+
playerTook === 0,
|
|
760
|
+
);
|
|
761
|
+
ok(
|
|
762
|
+
`…and an enemy walked onto it collected ${enemyTook} (bait freed: ${baitGone})`,
|
|
763
|
+
enemyTook === 1 && baitGone,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ---- WAVES ON A SIGNAL: `WaveSpawner.autoStart` (measured, not composed) ----------
|
|
768
|
+
// Set by no example — every wave in the repo starts at ready. This starter's
|
|
769
|
+
// waves ARE the game, so the prop is measured on a copy of the JSON with
|
|
770
|
+
// `autoStart: false` rather than composed into it: five seconds must produce no
|
|
771
|
+
// enemy, and `start()` must then produce the first wave on its own schedule.
|
|
772
|
+
{
|
|
773
|
+
const text = JSON.stringify(sceneJson);
|
|
774
|
+
const key = '"name":"WaveSpawner","props":{';
|
|
775
|
+
ok('the scene declares its waves (the comparison needs them)', text.includes(key));
|
|
776
|
+
const held = JSON.parse(text.replace(key, `${key}"autoStart":false,`));
|
|
777
|
+
let idle = -1;
|
|
778
|
+
let after = -1;
|
|
779
|
+
const result = await runScript(held, {
|
|
780
|
+
resolveScene: resolveScene as never,
|
|
781
|
+
behaviors,
|
|
782
|
+
durationMs: 12000,
|
|
783
|
+
steps: [
|
|
784
|
+
{
|
|
785
|
+
atMs: 5000,
|
|
786
|
+
do: (ctx) => {
|
|
787
|
+
idle = liveEnemies(ctx).length;
|
|
788
|
+
(ctx.getNode('/root/Spawner').behavior as unknown as { start(): void }).start();
|
|
789
|
+
for (let i = 0; i < 330; i++) ctx.engine.step(); // delayBefore 2 + 4 × 1.1 = 6.4 s
|
|
790
|
+
after = liveEnemies(ctx).length;
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
],
|
|
794
|
+
});
|
|
795
|
+
ok('runScript reported no failures (WAVES path)', result.ok);
|
|
796
|
+
ok(`autoStart false: five seconds in, ${idle} enemies`, idle === 0);
|
|
797
|
+
ok(`…and start() brings the first wave: ${after} enemies 5.5 s later (4 declared)`, after === 4);
|
|
798
|
+
}
|
|
799
|
+
|
|
221
800
|
console.log(process.exitCode ? '\nVERIFY FAILED' : '\nVERIFY OK — Vanguard plays end to end');
|