incanto 0.69.0 → 0.71.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/_behaviors-loader.mjs +22 -0
- package/bin/_read-json.mjs +28 -0
- package/bin/incanto-assets.mjs +5 -4
- package/bin/incanto-check.mjs +58 -31
- package/bin/incanto-editor.mjs +128 -5
- package/bin/incanto-env.mjs +3 -2
- package/bin/incanto-feel.mjs +24 -13
- package/bin/incanto-frame.mjs +8 -1
- package/bin/incanto-multiplay.mjs +11 -9
- package/bin/incanto-new.mjs +105 -10
- package/bin/incanto-play.mjs +158 -16
- package/bin/incanto-playtest.mjs +66 -23
- package/bin/incanto-skills.mjs +14 -2
- package/bin/incanto-verify.mjs +121 -102
- package/bin/incanto.mjs +3 -2
- package/dist/2d.d.ts +296 -34
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +157 -9
- package/dist/3d.js +8 -8
- package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
- package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
- package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
- package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
- package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +2 -3
- package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
- package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
- package/dist/editor.js +1088 -882
- package/dist/env.d.ts +1 -1
- package/dist/env.js +5 -3
- package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
- package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
- package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
- package/dist/gameplay.d.ts +254 -10
- package/dist/gameplay.js +2 -2
- package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
- package/dist/index.d.ts +179 -13
- package/dist/index.js +10 -12
- package/dist/json-CfTjpvW8.js +67 -0
- package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
- package/dist/net.d.ts +27 -3
- package/dist/net.js +2 -2
- package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
- package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
- package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
- package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
- package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
- package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
- package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
- package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
- package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
- package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
- package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
- package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
- package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
- package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
- package/dist/test-CmN54kWv.js +4264 -0
- package/dist/test.d.ts +639 -30
- package/dist/test.js +3 -3
- package/dist/touch-BnCyPA0G.js +519 -0
- package/dist/vite.d.ts +20 -37
- package/dist/vite.js +177 -7
- package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
- package/editor/assets/agent8-yLIEYHbd.js +1 -0
- package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
- package/editor/assets/index-DK9xMGpW.js +11046 -0
- package/editor/index.html +1 -1
- package/package.json +4 -14
- package/schemas/scene.schema.json +34 -3
- package/skills/incanto-3d-character.md +14 -1
- package/skills/incanto-3d-models.md +12 -0
- package/skills/incanto-assets.md +9 -1
- package/skills/incanto-audio.md +8 -2
- package/skills/incanto-behaviors-and-scripts.md +105 -7
- package/skills/incanto-building-2d-games.md +97 -10
- package/skills/incanto-building-3d-games.md +58 -4
- package/skills/incanto-editor.md +46 -7
- package/skills/incanto-environment.md +19 -1
- package/skills/incanto-game-feel.md +70 -0
- package/skills/incanto-gameplay-behaviors.md +172 -20
- package/skills/incanto-hud.md +142 -7
- package/skills/incanto-localization.md +13 -5
- package/skills/incanto-multiplayer.md +83 -3
- package/skills/incanto-node-reference.md +239 -59
- package/skills/incanto-performance.md +52 -0
- package/skills/incanto-physics-and-input.md +123 -24
- package/skills/incanto-playtesting.md +112 -2
- package/skills/incanto-save-slots.md +217 -7
- package/skills/incanto-scene-json-authoring.md +69 -12
- package/skills/incanto-verifying-your-game.md +207 -10
- package/skills/incanto-web-integration.md +28 -0
- package/skills/incanto-your-first-game.md +3 -1
- package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
- package/templates-app/beacon-isle-3d/package.json +2 -2
- package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
- package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
- package/templates-app/beacon-isle-3d/src/main.ts +11 -1
- package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
- package/templates-app/beacon-isle-3d/verify.ts +3 -1
- package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
- package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
- package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
- package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
- package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
- package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
- package/templates-app/molehill-2d/index.html +73 -0
- package/templates-app/molehill-2d/package.json +23 -0
- package/templates-app/molehill-2d/src/behaviors.ts +198 -0
- package/templates-app/molehill-2d/src/game.scene.json +1205 -0
- package/templates-app/molehill-2d/src/main.ts +41 -0
- package/templates-app/molehill-2d/tsconfig.json +13 -0
- package/templates-app/molehill-2d/verify.ts +247 -0
- package/templates-app/molehill-2d/vite.config.ts +12 -0
- package/templates-app/platformer-2d/package.json +2 -2
- package/templates-app/platformer-2d/src/game.scene.json +178 -986
- package/templates-app/platformer-2d/src/main.ts +11 -1
- package/templates-app/platformer-2d/tsconfig.json +1 -1
- package/templates-app/star-survivor/package.json +2 -2
- package/templates-app/star-survivor/src/game.scene.json +43 -216
- package/templates-app/star-survivor/src/main.ts +11 -1
- package/templates-app/star-survivor/tsconfig.json +1 -1
- package/templates-app/tps-3d/PROJECT/Context.md +1 -1
- package/templates-app/tps-3d/package.json +2 -2
- package/templates-app/tps-3d/src/behaviors.ts +19 -1
- package/templates-app/tps-3d/src/game.scene.json +66 -263
- package/templates-app/tps-3d/src/main.ts +13 -2
- package/templates-app/tps-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
- package/templates-app/village-quest-3d/package.json +2 -2
- package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
- package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
- package/templates-app/village-quest-3d/src/main.ts +11 -1
- package/templates-app/village-quest-3d/src/village.scene.json +202 -863
- package/templates-app/village-quest-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/verify.ts +14 -1
- package/dist/duplicate-Dj4L-5AA.js +0 -22
- package/dist/json-CwwhxQgb.js +0 -36
- package/dist/registry-CF70EArN.js +0 -212
- package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
- package/dist/test-D6ISmuFE.js +0 -2383
- package/dist/touch-DEAmqGdf.js +0 -225
- package/editor/assets/agent8-BDvaZMQJ.js +0 -1
- package/editor/assets/index-CZIRr5EL.js +0 -10958
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
* reconnect that never re-joins, a throttle that discards a payload, a batch
|
|
18
18
|
* dropped on a closed socket.
|
|
19
19
|
*/
|
|
20
|
-
import { existsSync
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
21
|
import { dirname, join, resolve } from 'node:path';
|
|
22
22
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
23
|
-
import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
|
|
23
|
+
import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
|
|
24
|
+
import { readJson } from './_read-json.mjs';
|
|
24
25
|
|
|
25
26
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
27
|
|
|
@@ -37,6 +38,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
|
|
|
37
38
|
--seconds N simulated seconds (default 5)
|
|
38
39
|
--seed N make the match reproducible (default 1)
|
|
39
40
|
--behaviors FILE your Behavior subclasses (.ts works on bun / node >= 23.6)
|
|
41
|
+
comma-separate or repeat for several files
|
|
40
42
|
--server FILE your Server class (default: server/src/server.ts if present)
|
|
41
43
|
--scenes a=b.json a scene a NetworkSpawner names, repeatable
|
|
42
44
|
--no-late-join skip the late-joining client
|
|
@@ -65,7 +67,7 @@ const { playMultiplayer, multiplayText, registerAllNodes } = await import(
|
|
|
65
67
|
);
|
|
66
68
|
registerAllNodes();
|
|
67
69
|
|
|
68
|
-
const sceneJson =
|
|
70
|
+
const sceneJson = readJson(scenePath);
|
|
69
71
|
const sceneDir = dirname(scenePath);
|
|
70
72
|
|
|
71
73
|
// Without the game's own behaviours a scene that names one throws
|
|
@@ -73,9 +75,9 @@ const sceneDir = dirname(scenePath);
|
|
|
73
75
|
// given them, and stub what is left. A stubbed behaviour still lets the
|
|
74
76
|
// SCENE's replication run, which is what this command measures.
|
|
75
77
|
const behaviorsFile = flag('--behaviors', null);
|
|
76
|
-
|
|
78
|
+
for (const file of behaviorFiles(behaviorsFile)) {
|
|
77
79
|
try {
|
|
78
|
-
const mod = await import(pathToFileURL(resolve(
|
|
80
|
+
const mod = await import(pathToFileURL(resolve(file)).href);
|
|
79
81
|
const { registerBehavior, Behavior } = await import(
|
|
80
82
|
pathToFileURL(join(PKG, 'dist', 'index.js')).href
|
|
81
83
|
);
|
|
@@ -87,7 +89,7 @@ if (behaviorsFile) {
|
|
|
87
89
|
} catch (e) {
|
|
88
90
|
const why = e?.message ?? String(e);
|
|
89
91
|
reexecUnderBun(import.meta.url, why);
|
|
90
|
-
console.error(behaviorsHelp(
|
|
92
|
+
console.error(behaviorsHelp(file, why));
|
|
91
93
|
process.exit(1);
|
|
92
94
|
}
|
|
93
95
|
}
|
|
@@ -105,7 +107,7 @@ const scenes = {};
|
|
|
105
107
|
for (let i = 0; i < argv.length; i++) {
|
|
106
108
|
if (argv[i] !== '--scenes') continue;
|
|
107
109
|
const [key, file] = String(argv[i + 1] ?? '').split('=');
|
|
108
|
-
if (key && file) scenes[key] =
|
|
110
|
+
if (key && file) scenes[key] = readJson(resolve(sceneDir, file));
|
|
109
111
|
}
|
|
110
112
|
const spawnerScenes = new Set();
|
|
111
113
|
const walk = (node) => {
|
|
@@ -120,7 +122,7 @@ for (const name of spawnerScenes) {
|
|
|
120
122
|
for (const guess of [`${name}.scene.json`, `${name}.json`]) {
|
|
121
123
|
const at = resolve(sceneDir, guess);
|
|
122
124
|
if (existsSync(at)) {
|
|
123
|
-
scenes[name] =
|
|
125
|
+
scenes[name] = readJson(at);
|
|
124
126
|
break;
|
|
125
127
|
}
|
|
126
128
|
}
|
|
@@ -182,7 +184,7 @@ try {
|
|
|
182
184
|
lateJoin: !argv.includes('--no-late-join'),
|
|
183
185
|
load: {
|
|
184
186
|
stubMissingBehaviors: true,
|
|
185
|
-
resolveScene: (p) =>
|
|
187
|
+
resolveScene: (p) => readJson(resolve(sceneDir, p)),
|
|
186
188
|
},
|
|
187
189
|
// Move the owner, or every replicated key holds its authored value and two
|
|
188
190
|
// motionless clients agree about nothing in particular.
|
package/bin/incanto-new.mjs
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* three commands that get you playing. Templates are real npm consumers —
|
|
11
11
|
* no monorepo, no aliases.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { cpSync, existsSync, readdirSync, writeFileSync } from 'node:fs';
|
|
14
15
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
15
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { readJson } from './_read-json.mjs';
|
|
16
18
|
|
|
17
19
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
20
|
const TEMPLATES_DIR = join(PKG, 'templates-app');
|
|
@@ -26,6 +28,7 @@ const DESCRIPTIONS = {
|
|
|
26
28
|
'side-scrolling platformer (the 2D flagship): tilemap level, coyote-time jump, follow cam, coins',
|
|
27
29
|
'star-survivor':
|
|
28
30
|
'survivors-like: waves that never stop, auto-attack, upgrades, a clock to outlast',
|
|
31
|
+
'molehill-2d': 'played with the MOUSE: whack-a-mole — clickable nodes, no character, no keyboard',
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
/**
|
|
@@ -64,17 +67,46 @@ if (argv.includes('--list') || argv.includes('-l')) {
|
|
|
64
67
|
|
|
65
68
|
let target;
|
|
66
69
|
let template = 'beacon-isle-3d';
|
|
70
|
+
/*
|
|
71
|
+
* An unknown flag is a TYPO, not a request.
|
|
72
|
+
*
|
|
73
|
+
* This loop dropped anything it did not recognise, so `--tempalte platformer-2d`
|
|
74
|
+
* — and `--template=platformer-2d`, which is the form an agent types —
|
|
75
|
+
* scaffolded the default 3D game and exited 0. The funnel command silently
|
|
76
|
+
* building the wrong game is the most expensive silence in the package.
|
|
77
|
+
*/
|
|
78
|
+
const usage =
|
|
79
|
+
'usage: incanto-new <dir> [--template <name>] [--local] | --list\n' +
|
|
80
|
+
' --local pin `file:` to the incanto this command is running from,\n' +
|
|
81
|
+
' instead of the registry — for verifying a release candidate.\n' +
|
|
82
|
+
' From a source checkout it PACKS first, because a `file:` pin at\n' +
|
|
83
|
+
' a workspace package is not installable.';
|
|
84
|
+
const KNOWN_FLAGS = new Set(['--template', '-t', '--local', '--list', '-l', '--help', '-h']);
|
|
67
85
|
for (let i = 0; i < argv.length; i++) {
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
const a = argv[i];
|
|
87
|
+
if (a === '--template' || a === '-t') template = argv[++i];
|
|
88
|
+
else if (a.startsWith('--template=')) template = a.slice('--template='.length);
|
|
89
|
+
else if (a === '--help' || a === '-h') {
|
|
90
|
+
console.log(usage);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
} else if (a.startsWith('-') && !KNOWN_FLAGS.has(a)) {
|
|
93
|
+
console.error(`unknown flag '${a}'.\n${usage}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
} else if (!a.startsWith('-')) {
|
|
96
|
+
if (target) {
|
|
97
|
+
console.error(`unexpected argument '${a}' — the template goes after --template.\n${usage}`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
target = a;
|
|
101
|
+
}
|
|
70
102
|
}
|
|
71
103
|
|
|
72
104
|
if (!target) {
|
|
73
|
-
console.error(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
105
|
+
console.error(usage);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
if (template === undefined || template === '') {
|
|
109
|
+
console.error(`--template needs a name.\n${usage}`);
|
|
78
110
|
process.exit(1);
|
|
79
111
|
}
|
|
80
112
|
|
|
@@ -93,11 +125,52 @@ if (existsSync(dest)) {
|
|
|
93
125
|
cpSync(join(TEMPLATES_DIR, template), dest, { recursive: true });
|
|
94
126
|
|
|
95
127
|
const pkgPath = join(dest, 'package.json');
|
|
96
|
-
const pkg =
|
|
128
|
+
const pkg = readJson(pkgPath);
|
|
97
129
|
pkg.name = basename(dest)
|
|
98
130
|
.toLowerCase()
|
|
99
131
|
.replace(/[^a-z0-9-]/g, '-');
|
|
100
132
|
|
|
133
|
+
/**
|
|
134
|
+
* What `--local` should pin: the directory itself, or a tarball packed from it.
|
|
135
|
+
*
|
|
136
|
+
* Only a source checkout needs the pack — that is the one whose `package.json`
|
|
137
|
+
* still carries the monorepo's `catalog:`/`workspace:` devDependencies, which
|
|
138
|
+
* bun resolves through a `file:` dependency and cannot find outside the
|
|
139
|
+
* workspace.
|
|
140
|
+
*/
|
|
141
|
+
function localPin(engineRoot, into) {
|
|
142
|
+
let manifest;
|
|
143
|
+
try {
|
|
144
|
+
manifest = readJson(join(engineRoot, 'package.json'));
|
|
145
|
+
} catch {
|
|
146
|
+
return engineRoot;
|
|
147
|
+
}
|
|
148
|
+
const unresolvable = Object.values(manifest.devDependencies ?? {}).some(
|
|
149
|
+
(v) => typeof v === 'string' && (v.startsWith('catalog:') || v.startsWith('workspace:')),
|
|
150
|
+
);
|
|
151
|
+
if (!unresolvable) return engineRoot;
|
|
152
|
+
// `npm pack` runs this package's `prepack`, which strips those devDeps —
|
|
153
|
+
// exactly the artifact `verify:tarball` installs and proves.
|
|
154
|
+
const out = spawnSync('npm', ['pack', '--pack-destination', into, '--silent'], {
|
|
155
|
+
cwd: engineRoot,
|
|
156
|
+
encoding: 'utf-8',
|
|
157
|
+
});
|
|
158
|
+
const name = String(out.stdout ?? '')
|
|
159
|
+
.trim()
|
|
160
|
+
.split('\n')
|
|
161
|
+
.pop();
|
|
162
|
+
if (out.status !== 0 || !name || !existsSync(join(into, name))) {
|
|
163
|
+
console.error(
|
|
164
|
+
`--local: could not pack the engine at ${engineRoot}, and pinning it directly would ` +
|
|
165
|
+
`produce a project bun cannot install (its devDependencies use the workspace-only ` +
|
|
166
|
+
`\`catalog:\` protocol).\n${(out.stderr ?? '').trim()}`,
|
|
167
|
+
);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
console.log(` packed the engine → ${name}`);
|
|
171
|
+
return `./${name}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
101
174
|
/*
|
|
102
175
|
* WHICH incanto this scaffold will install.
|
|
103
176
|
*
|
|
@@ -113,7 +186,29 @@ pkg.name = basename(dest)
|
|
|
113
186
|
*/
|
|
114
187
|
const ENGINE_ROOT = PKG;
|
|
115
188
|
if (argv.includes('--local')) {
|
|
116
|
-
|
|
189
|
+
/*
|
|
190
|
+
* A `file:` pin at a SOURCE CHECKOUT is not installable, and the flag's whole
|
|
191
|
+
* purpose is to be installed.
|
|
192
|
+
*
|
|
193
|
+
* bun resolves a `file:` dependency's devDependencies, and this package's are
|
|
194
|
+
* monorepo declarations — four of them in bun's workspace-only `catalog:`
|
|
195
|
+
* protocol. So `bun install` on a `--local` scaffold pinned at
|
|
196
|
+
* `packages/engine` fails outright and leaves NO node_modules:
|
|
197
|
+
*
|
|
198
|
+
* error: @types/three@catalog: failed to resolve
|
|
199
|
+
* error: three@catalog: failed to resolve … and three more
|
|
200
|
+
*
|
|
201
|
+
* `prepack` already strips them for the published artifact, and
|
|
202
|
+
* `verify:tarball` proves THAT installs — so the fix is to pin what that
|
|
203
|
+
* step proves rather than the directory it is packed from. Six independent
|
|
204
|
+
* agents hit this before writing a line of game code; every one of them was
|
|
205
|
+
* running the bin out of a checkout, which is what an agent testing the
|
|
206
|
+
* engine does.
|
|
207
|
+
*
|
|
208
|
+
* An INSTALLED copy has no such devDeps, so it is pinned directly and nothing
|
|
209
|
+
* is packed.
|
|
210
|
+
*/
|
|
211
|
+
pkg.dependencies = { ...pkg.dependencies, incanto: `file:${localPin(ENGINE_ROOT, dest)}` };
|
|
117
212
|
}
|
|
118
213
|
const pin = pkg.dependencies?.incanto ?? '(none)';
|
|
119
214
|
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
package/bin/incanto-play.mjs
CHANGED
|
@@ -15,6 +15,9 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
15
15
|
* vector <action> <x> <y> analog direction ((0 0) clears)
|
|
16
16
|
* key <code> down|up raw KeyboardEvent.code mouse <0|1|2> down|up
|
|
17
17
|
* pointer <dx> <dy> look deltas wheel <dy>
|
|
18
|
+
* at <x> <y> | at <path> put the CURSOR somewhere (a node, or a point)
|
|
19
|
+
* click [left|middle|right] press and release there — a mouse game's whole
|
|
20
|
+
* input, and there was no way to say it
|
|
18
21
|
* capture [file] current state as scene JSON (stdout or file)
|
|
19
22
|
* describe one line per node, non-default props only
|
|
20
23
|
* framing what the camera SEES: on/off screen, lights, overlaps
|
|
@@ -26,7 +29,8 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
26
29
|
*/
|
|
27
30
|
import { createInterface } from 'node:readline';
|
|
28
31
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
29
|
-
import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
|
|
32
|
+
import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
|
|
33
|
+
import { readJson } from './_read-json.mjs';
|
|
30
34
|
|
|
31
35
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
36
|
|
|
@@ -40,13 +44,19 @@ function parseArgs(argv) {
|
|
|
40
44
|
};
|
|
41
45
|
for (let i = 0; i < argv.length; i++) {
|
|
42
46
|
const a = argv[i];
|
|
43
|
-
|
|
47
|
+
// Repeatable: behaviours legitimately live in more than one file.
|
|
48
|
+
if (a === '--behaviors') args.behaviors = [...(args.behaviors ?? []), argv[++i]];
|
|
44
49
|
else if (a === '--seed') args.seed = Number(argv[++i]);
|
|
45
50
|
else if (a === '--fixed-hz') args.fixedHz = Number(argv[++i]);
|
|
46
51
|
else if (a === '--commands') args.commands = argv[++i];
|
|
47
52
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
48
53
|
else if (a.startsWith('--')) {
|
|
54
|
+
// `invalid`, not `help`: printing usage and exiting 0 told an agent its
|
|
55
|
+
// typo was a request. `--runs=4`, `--behaviours`, `--no-replay` all landed
|
|
56
|
+
// here and looked like success — and with `--json` the machine path was
|
|
57
|
+
// literally silent (0 bytes on stdout).
|
|
49
58
|
console.error(`unknown flag: ${a}`);
|
|
59
|
+
args.invalid = true;
|
|
50
60
|
args.help = true;
|
|
51
61
|
} else if (!args.scene) args.scene = a;
|
|
52
62
|
}
|
|
@@ -59,7 +69,7 @@ if (args.help || !args.scene) {
|
|
|
59
69
|
|
|
60
70
|
Headless play: stdin commands (step/press/release/vector/key/mouse/pointer/
|
|
61
71
|
wheel/capture/describe/framing/logs/quit), JSON-line responses on stdout.`);
|
|
62
|
-
process.exit(args.help && args.scene !== undefined ? 0 : 1);
|
|
72
|
+
process.exit(!args.invalid && args.help && args.scene !== undefined ? 0 : 1);
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
const out = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
@@ -69,20 +79,47 @@ const fail = (cmd, e) => {
|
|
|
69
79
|
out({ ok: false, cmd, error: { code: e?.code ?? 'ERROR', message: e?.message ?? String(e) } });
|
|
70
80
|
};
|
|
71
81
|
|
|
72
|
-
const { createPlaySession } = await import(
|
|
82
|
+
const { createPlaySession, geometricPicker, geometricView, pointAtNode } = await import(
|
|
83
|
+
pathToFileURL(join(PKG, 'dist', 'test.js')).href
|
|
84
|
+
);
|
|
73
85
|
const incanto = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
|
|
74
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Let a behaviour ask where the cursor IS, not just what is under it.
|
|
89
|
+
*
|
|
90
|
+
* The renderer installs `toWorld`/`toScreen` so game logic can aim; there is no
|
|
91
|
+
* renderer here, so a game driven from this REPL could be clicked and never
|
|
92
|
+
* aimed. Same terms as the picker: only if the game brought none.
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* Has anything put the cursor somewhere yet?
|
|
96
|
+
*
|
|
97
|
+
* `click` used to fire regardless, so a plan whose `at` failed reported the aim
|
|
98
|
+
* false and the click true — and the click landed wherever the cursor had been
|
|
99
|
+
* left, which is (0, 0) at the start of a session.
|
|
100
|
+
*/
|
|
101
|
+
let aimed = false;
|
|
102
|
+
|
|
103
|
+
const aimFrom = (session) => {
|
|
104
|
+
if (session.engine.toWorld) return;
|
|
105
|
+
const view = geometricView(() => session.scene);
|
|
106
|
+
session.engine.toWorld = view.toWorld;
|
|
107
|
+
session.engine.toScreen ??= view.toScreen;
|
|
108
|
+
session.engine.screenRay ??= view.screenRay;
|
|
109
|
+
};
|
|
110
|
+
|
|
75
111
|
// --behaviors: import the consumer module (TS works on node >= 23.6 / bun)
|
|
76
112
|
// and register every exported Behavior subclass under its export name.
|
|
77
113
|
const behaviors = {};
|
|
78
|
-
|
|
114
|
+
const behaviorSources = behaviorFiles(args.behaviors);
|
|
115
|
+
for (const file of behaviorSources) {
|
|
79
116
|
let mod;
|
|
80
117
|
try {
|
|
81
|
-
mod = await import(pathToFileURL(resolve(
|
|
118
|
+
mod = await import(pathToFileURL(resolve(file)).href);
|
|
82
119
|
} catch (e) {
|
|
83
120
|
reexecUnderBun(import.meta.url, e?.message ?? String(e));
|
|
84
121
|
const reason =
|
|
85
|
-
e?.code === 'ERR_MODULE_NOT_FOUND' && String(e.message).includes(resolve(
|
|
122
|
+
e?.code === 'ERR_MODULE_NOT_FOUND' && String(e.message).includes(resolve(file))
|
|
86
123
|
? 'file not found'
|
|
87
124
|
: (e?.message ?? String(e));
|
|
88
125
|
out({
|
|
@@ -90,7 +127,7 @@ if (args.behaviors) {
|
|
|
90
127
|
cmd: 'behaviors',
|
|
91
128
|
error: {
|
|
92
129
|
code: e?.code ?? 'BEHAVIORS_LOAD_FAILED',
|
|
93
|
-
message: behaviorsHelp(
|
|
130
|
+
message: behaviorsHelp(file, reason),
|
|
94
131
|
},
|
|
95
132
|
});
|
|
96
133
|
process.exit(1);
|
|
@@ -100,21 +137,20 @@ if (args.behaviors) {
|
|
|
100
137
|
behaviors[name] = value;
|
|
101
138
|
}
|
|
102
139
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
140
|
+
}
|
|
141
|
+
if (behaviorSources.length > 0 && Object.keys(behaviors).length === 0) {
|
|
142
|
+
console.error(`warning: no Behavior subclasses exported from ${behaviorSources.join(', ')}`);
|
|
106
143
|
}
|
|
107
144
|
|
|
108
|
-
const sceneJson =
|
|
145
|
+
const sceneJson = readJson(resolve(args.scene));
|
|
109
146
|
let session;
|
|
110
147
|
try {
|
|
111
148
|
session = await createPlaySession(sceneJson, {
|
|
112
149
|
behaviors,
|
|
113
|
-
stubMissingBehaviors:
|
|
150
|
+
stubMissingBehaviors: behaviorSources.length === 0, // structure-only play without code
|
|
114
151
|
seed: args.seed,
|
|
115
152
|
fixedHz: args.fixedHz,
|
|
116
|
-
resolveScene: (p) =>
|
|
117
|
-
JSON.parse(readFileSync(resolve(dirname(resolve(args.scene)), p), 'utf-8')),
|
|
153
|
+
resolveScene: (p) => readJson(resolve(dirname(resolve(args.scene)), p)),
|
|
118
154
|
});
|
|
119
155
|
} catch (e) {
|
|
120
156
|
fail('load', e);
|
|
@@ -127,7 +163,7 @@ out({
|
|
|
127
163
|
scene: session.scene.name,
|
|
128
164
|
dimension: session.scene.dimension ?? null,
|
|
129
165
|
behaviors: Object.keys(behaviors),
|
|
130
|
-
stubbed:
|
|
166
|
+
stubbed: behaviorSources.length === 0,
|
|
131
167
|
});
|
|
132
168
|
|
|
133
169
|
function handle(line) {
|
|
@@ -164,6 +200,81 @@ function handle(line) {
|
|
|
164
200
|
session.engine.input.handlePointerMove(Number(parts[0]), Number(parts[1]));
|
|
165
201
|
out({ ok: true, cmd });
|
|
166
202
|
break;
|
|
203
|
+
/*
|
|
204
|
+
* Where the CURSOR is, and clicking it — the whole input of a match-3, a
|
|
205
|
+
* tower defense, a card game or a point-and-click.
|
|
206
|
+
*
|
|
207
|
+
* `pointer` is a look DELTA, and `mouse` presses a button at wherever the
|
|
208
|
+
* cursor is not. Headless there is also no renderer to raycast with, so
|
|
209
|
+
* `engine.picker` is null and `Clickable` is inert: this installs the
|
|
210
|
+
* geometric one on first use, the same picker `runScript`'s `click` step
|
|
211
|
+
* uses, so the two drivers answer alike.
|
|
212
|
+
*/
|
|
213
|
+
case 'at': {
|
|
214
|
+
const where =
|
|
215
|
+
parts.length === 1
|
|
216
|
+
? pointAtNode(session.scene, parts[0])
|
|
217
|
+
: [Number(parts[0]), Number(parts[1])];
|
|
218
|
+
if (!where || !Number.isFinite(where[0]) || !Number.isFinite(where[1])) {
|
|
219
|
+
out({
|
|
220
|
+
ok: false,
|
|
221
|
+
cmd,
|
|
222
|
+
error:
|
|
223
|
+
parts.length === 1
|
|
224
|
+
? `at '${parts[0]}' — no such node, or it is not on screen`
|
|
225
|
+
: 'at needs two numbers (scene px) or one node path',
|
|
226
|
+
});
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
session.engine.picker ??= geometricPicker(() => session.scene);
|
|
230
|
+
aimFrom(session);
|
|
231
|
+
session.engine.input.setPointerPosition(where[0], where[1]);
|
|
232
|
+
aimed = true;
|
|
233
|
+
out({ ok: true, cmd, at: where });
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'click': {
|
|
237
|
+
const button = { left: 0, middle: 1, right: 2 }[parts[0] ?? 'left'];
|
|
238
|
+
if (button === undefined) {
|
|
239
|
+
out({ ok: false, cmd, error: 'click takes nothing, or left|middle|right' });
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
/*
|
|
243
|
+
* A click before anything aimed reported `ok: true`.
|
|
244
|
+
*
|
|
245
|
+
* The documented plan is `at %Mole; click; step 300`, and when the
|
|
246
|
+
* `at` fails — an ambiguous `%name`, a node off screen — the click that
|
|
247
|
+
* follows it clicked wherever the cursor had been left and said so with
|
|
248
|
+
* a green line. A caller reading `ok` per command sees one false and
|
|
249
|
+
* one true and concludes the click landed.
|
|
250
|
+
*/
|
|
251
|
+
if (!aimed) {
|
|
252
|
+
out({
|
|
253
|
+
ok: false,
|
|
254
|
+
cmd,
|
|
255
|
+
error:
|
|
256
|
+
'nothing has aimed the cursor yet — `at <node path>` or `at X Y` first. ' +
|
|
257
|
+
'A click with no aim would land wherever the cursor was left.',
|
|
258
|
+
});
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
session.engine.picker ??= geometricPicker(() => session.scene);
|
|
262
|
+
aimFrom(session);
|
|
263
|
+
const at = session.engine.input.pointerPosition();
|
|
264
|
+
const under = at ? session.engine.pickAt(at.x, at.y) : null;
|
|
265
|
+
const hit = under ? under.getPath() : null;
|
|
266
|
+
// Press, a step, release, a step: `Clickable` fires on the RELEASE over
|
|
267
|
+
// the node the press started on, and the frame between is where a game
|
|
268
|
+
// gets to show that it is being pressed.
|
|
269
|
+
session.engine.input.handleMouseButton(button, true);
|
|
270
|
+
session.step(16.7);
|
|
271
|
+
session.engine.input.handleMouseButton(button, false);
|
|
272
|
+
session.step(16.7);
|
|
273
|
+
// WHAT it landed on, not merely that it happened: the whole reason to
|
|
274
|
+
// aim is to hit something, and a caller cannot pick from out here.
|
|
275
|
+
out({ ok: true, cmd, t: Math.round(session.timeMs), hit });
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
167
278
|
case 'wheel':
|
|
168
279
|
session.engine.input.handleWheel(Number(parts[0]));
|
|
169
280
|
out({ ok: true, cmd });
|
|
@@ -217,6 +328,37 @@ if (args.commands) {
|
|
|
217
328
|
// did nothing.
|
|
218
329
|
const recording = text.trimStart().startsWith('{') ? JSON.parse(text) : null;
|
|
219
330
|
if (recording?.replay) {
|
|
331
|
+
/*
|
|
332
|
+
* Is this a recording of THIS game?
|
|
333
|
+
*
|
|
334
|
+
* `.incanto/playtest` is keyed by `(outcome, seed)` and never cleaned, so
|
|
335
|
+
* the second playtest in a project leaves a stale `stuck-seed3.json` from a
|
|
336
|
+
* scene that no longer exists sitting beside the current one — and this
|
|
337
|
+
* replayed it against whatever scene it was pointed at and reported
|
|
338
|
+
* success. A replay of the wrong game is worse than no replay: it is a
|
|
339
|
+
* failure you cannot reproduce and an explanation that is not true.
|
|
340
|
+
*
|
|
341
|
+
* Recordings written before this carry no stamp and still play, which is
|
|
342
|
+
* the point of stamping rather than gating: an old file is not a wrong one.
|
|
343
|
+
*/
|
|
344
|
+
const stamped = recording.scene;
|
|
345
|
+
const mine = sceneJson?.name;
|
|
346
|
+
if (stamped?.name && mine && stamped.name !== mine) {
|
|
347
|
+
out({
|
|
348
|
+
ok: false,
|
|
349
|
+
cmd: 'replay',
|
|
350
|
+
error: {
|
|
351
|
+
code: 'REPLAY_SCENE_MISMATCH',
|
|
352
|
+
message:
|
|
353
|
+
`this recording is of '${stamped.name}'${stamped.path ? ` (${stamped.path})` : ''} ` +
|
|
354
|
+
`and you pointed it at '${mine}'. Replays live in .incanto/playtest keyed by ` +
|
|
355
|
+
`outcome and seed, so an old one from another scene sits right beside the ` +
|
|
356
|
+
`current ones — check the file, or delete the directory and playtest again.`,
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
session.dispose();
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
220
362
|
incanto.replay(session.engine, recording);
|
|
221
363
|
out({
|
|
222
364
|
ok: true,
|