incanto 0.11.0 → 0.13.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/README.md +22 -0
- package/bin/incanto-new.mjs +87 -0
- package/dist/2d.d.ts +2 -2
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +246 -71
- package/dist/3d.js +143 -4
- package/dist/{behavior-Bod1AyJS.d.ts → behavior-CPibUfnH.d.ts} +4 -2
- package/dist/{create-game-CdWhP6ve.js → create-game-Ct86zczq.js} +5 -5
- package/dist/{create-game-DZ_4Nfq8.js → create-game-J6g4QInv.js} +75 -24
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-BEvGBtb_.js → duplicate-BINwP0WG.js} +1 -1
- package/dist/{gameplay-Dn3yidJw.js → gameplay-sSqrUcnz.js} +75 -3
- package/dist/gameplay.d.ts +37 -2
- package/dist/gameplay.js +2 -2
- package/dist/index.d.ts +3 -30
- package/dist/index.js +4 -4
- package/dist/{loader-B4OEXDZ8.js → loader-BZqOKfI2.js} +3 -1
- package/dist/{loader-BcUDfNHn.d.ts → loader-DILt9PGC.d.ts} +1 -1
- package/dist/net.d.ts +1 -1
- package/dist/net.js +3 -3
- package/dist/{audio-player-Dyjg5k92.d.ts → pathfinding-RWYkNKx9.d.ts} +29 -2
- package/dist/{physics-2d-DweTURFt.js → physics-2d-BiIdl51r.js} +8 -4
- package/dist/{physics-3d-Br97Ans2.js → physics-3d-DhaAaKJo.js} +8 -4
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-Cwc-yCYx.js → register-BFFE1Mh1.js} +2 -2
- package/dist/{register-ysLR3WQS.js → register-BFw4c83z.js} +508 -123
- package/dist/{register-BD6zgrv_.js → register-Djwckzgx.js} +2 -2
- package/dist/{register-DJZXxwAn.js → register-nObreUQR.js} +1 -1
- package/dist/test.d.ts +2 -2
- package/dist/test.js +10 -10
- package/editor/assets/{agent8-BxNvAcLA.js → agent8-BF8Jfbkl.js} +1 -1
- package/editor/assets/{index-DMCQXowF.js → index-DSq0gdhE.js} +53 -41
- package/editor/index.html +1 -1
- package/package.json +4 -2
- package/schemas/scene.schema.json +263 -0
- package/skills/incanto-3d-character.md +37 -1
- package/skills/incanto-building-3d-games.md +83 -0
- package/skills/incanto-editor.md +3 -3
- package/skills/incanto-gameplay-behaviors.md +23 -1
- package/skills/incanto-node-reference.md +51 -0
- package/templates-app/beacon-isle-3d/PROJECT/Context.md +44 -0
- package/templates-app/beacon-isle-3d/PROJECT/Requirements.md +16 -0
- package/templates-app/beacon-isle-3d/PROJECT/Status.md +38 -0
- package/templates-app/beacon-isle-3d/PROJECT/Structure.md +24 -0
- package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +38 -0
- package/templates-app/beacon-isle-3d/generate-world.ts +814 -0
- package/templates-app/beacon-isle-3d/index.html +68 -0
- package/templates-app/beacon-isle-3d/package.json +26 -0
- package/templates-app/beacon-isle-3d/src/behaviors.ts +570 -0
- package/templates-app/beacon-isle-3d/src/game.scene.json +2992 -0
- package/templates-app/beacon-isle-3d/src/main.ts +33 -0
- package/templates-app/beacon-isle-3d/tsconfig.json +13 -0
- package/templates-app/beacon-isle-3d/verify.ts +285 -0
- package/templates-app/beacon-isle-3d/vite.config.ts +5 -0
- package/templates-app/tps-3d/PROJECT/Context.md +49 -0
- package/templates-app/tps-3d/PROJECT/Requirements.md +38 -0
- package/templates-app/tps-3d/PROJECT/Status.md +41 -0
- package/templates-app/tps-3d/PROJECT/Structure.md +44 -0
- package/templates-app/tps-3d/docs/project-3d-rules.md +38 -0
- package/templates-app/tps-3d/index.html +244 -0
- package/templates-app/tps-3d/package.json +25 -0
- package/templates-app/tps-3d/src/behaviors.ts +405 -0
- package/templates-app/tps-3d/src/game.scene.json +677 -0
- package/templates-app/tps-3d/src/main.ts +64 -0
- package/templates-app/tps-3d/tsconfig.json +13 -0
- package/templates-app/tps-3d/verify.ts +167 -0
- package/templates-app/tps-3d/vite.config.ts +5 -0
- package/templates-app/village-quest-3d/PROJECT/Context.md +39 -0
- package/templates-app/village-quest-3d/PROJECT/Requirements.md +36 -0
- package/templates-app/village-quest-3d/PROJECT/Status.md +26 -0
- package/templates-app/village-quest-3d/PROJECT/Structure.md +30 -0
- package/templates-app/village-quest-3d/docs/project-3d-rules.md +38 -0
- package/templates-app/village-quest-3d/index.html +68 -0
- package/templates-app/village-quest-3d/package.json +25 -0
- package/templates-app/village-quest-3d/src/behaviors.ts +529 -0
- package/templates-app/village-quest-3d/src/grove.scene.json +826 -0
- package/templates-app/village-quest-3d/src/main.ts +35 -0
- package/templates-app/village-quest-3d/src/quest.ts +18 -0
- package/templates-app/village-quest-3d/src/village.scene.json +680 -0
- package/templates-app/village-quest-3d/tsconfig.json +13 -0
- package/templates-app/village-quest-3d/verify.ts +254 -0
- package/templates-app/village-quest-3d/vite.config.ts +5 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beacon Isle — the incanto 3D template: a generated island (terrain, sea,
|
|
3
|
+
* groves, grass, flowers), a quest with a real NPC, shades that HUNT you
|
|
4
|
+
* across the surface via terrain navigation, melee with a bone-mounted sword,
|
|
5
|
+
* golden-hour grade + bloom, touch controls on phones — regenerate the whole
|
|
6
|
+
* world with `bun run world`, the game logic never changes.
|
|
7
|
+
*/
|
|
8
|
+
import { assetUrls, preloadUrls } from 'incanto';
|
|
9
|
+
import { createGame3D } from 'incanto/3d';
|
|
10
|
+
import { BEHAVIORS } from './behaviors';
|
|
11
|
+
import gameJson from './game.scene.json';
|
|
12
|
+
|
|
13
|
+
const fill = document.querySelector('#progress') as HTMLElement | null;
|
|
14
|
+
const text = document.querySelector('#progress-text') as HTMLElement | null;
|
|
15
|
+
await preloadUrls(assetUrls(gameJson.assets), (done, total) => {
|
|
16
|
+
const pct = total > 0 ? Math.round((done / total) * 100) : 100;
|
|
17
|
+
if (fill) fill.style.width = `${pct}%`;
|
|
18
|
+
if (text) text.textContent = `${pct}%`;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
|
22
|
+
const game = await createGame3D({
|
|
23
|
+
debug: import.meta.env.VITE_INCANTO_DEBUG === '1',
|
|
24
|
+
canvas,
|
|
25
|
+
scene: gameJson,
|
|
26
|
+
behaviors: BEHAVIORS,
|
|
27
|
+
pointer: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
document.querySelector('#loading')?.remove();
|
|
31
|
+
|
|
32
|
+
// console-poking handle for playtesting
|
|
33
|
+
(window as unknown as { game: typeof game }).game = game;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"types": ["vite/client", "node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "verify.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beacon Isle plays headlessly: audit → full quest E2E (talk → accept → clear
|
|
3
|
+
* shades → relight 3 wards → report → win) → shades hunt on the terrain nav →
|
|
4
|
+
* death respawns with quest progress kept → record/replay bit-identical.
|
|
5
|
+
* Run with `bun verify.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import type { UiDialogue, UiText } from 'incanto';
|
|
8
|
+
import { Engine, loadScene, registerBehavior, replay, startRecording } from 'incanto';
|
|
9
|
+
import { enablePhysics3D } from 'incanto/3d';
|
|
10
|
+
import { auditScene, registerAllNodes, runScript } from 'incanto/test';
|
|
11
|
+
import { BEHAVIORS, quest, resetQuest } from './src/behaviors';
|
|
12
|
+
import gameJson from './src/game.scene.json';
|
|
13
|
+
|
|
14
|
+
interface Vec {
|
|
15
|
+
position: number[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ok = (label: string, cond: boolean): void => {
|
|
19
|
+
if (!cond) {
|
|
20
|
+
console.error(`FAIL: ${label}`);
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
} else {
|
|
23
|
+
console.log(`pass: ${label}`);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ---- AUDIT -------------------------------------------------------------------------
|
|
28
|
+
{
|
|
29
|
+
registerAllNodes();
|
|
30
|
+
for (const [name, ctor] of Object.entries(BEHAVIORS)) {
|
|
31
|
+
registerBehavior(name, ctor, { replace: true });
|
|
32
|
+
}
|
|
33
|
+
const warnings = auditScene(gameJson as unknown as Parameters<typeof auditScene>[0]);
|
|
34
|
+
ok(`auditScene is clean (${warnings.length} warnings)`, warnings.length === 0);
|
|
35
|
+
for (const w of warnings) console.error(` warn: ${w}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---- QUEST E2E ---------------------------------------------------------------------
|
|
39
|
+
{
|
|
40
|
+
resetQuest();
|
|
41
|
+
const keeper = { x: 0, z: 0 };
|
|
42
|
+
|
|
43
|
+
const result = await runScript(gameJson, {
|
|
44
|
+
durationMs: 90000,
|
|
45
|
+
seed: 11,
|
|
46
|
+
behaviors: BEHAVIORS,
|
|
47
|
+
steps: [
|
|
48
|
+
{
|
|
49
|
+
atMs: 200,
|
|
50
|
+
label: 'talk to the keeper, accept the quest',
|
|
51
|
+
do: (ctx) => {
|
|
52
|
+
const k = ctx.getNode('/root/Keeper') as unknown as Vec;
|
|
53
|
+
keeper.x = k.position[0] ?? 0;
|
|
54
|
+
keeper.z = k.position[2] ?? 0;
|
|
55
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
56
|
+
player.position = [keeper.x + 1.5, (k.position[1] ?? 0) + 1.2, keeper.z + 1.5];
|
|
57
|
+
for (let i = 0; i < 5; i++) ctx.engine.step();
|
|
58
|
+
ctx.engine.input.pressAction('interact');
|
|
59
|
+
ctx.engine.step();
|
|
60
|
+
ctx.engine.input.releaseAction('interact');
|
|
61
|
+
const talk = ctx.getNode('/root/HUD/Dialogue') as UiDialogue;
|
|
62
|
+
if (!talk.active) throw new Error('dialogue did not open');
|
|
63
|
+
for (let i = 0; i < 200; i++) ctx.engine.step();
|
|
64
|
+
talk.advance();
|
|
65
|
+
for (let i = 0; i < 260; i++) ctx.engine.step();
|
|
66
|
+
talk.choose(0);
|
|
67
|
+
ctx.engine.step();
|
|
68
|
+
if (!quest.accepted) throw new Error('quest not accepted');
|
|
69
|
+
// the E2E teleports through shade packs — don't die mid-script
|
|
70
|
+
(ctx.getNode('/root/Player').behavior as unknown as { current: number }).current = 99999;
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
atMs: 5000,
|
|
75
|
+
label: 'shades hunt the player across the terrain nav',
|
|
76
|
+
do: (ctx) => {
|
|
77
|
+
const shade = ctx.getNode('/root/Shade1_1') as unknown as Vec;
|
|
78
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
79
|
+
// stand 6m from the shade (inside aggro)
|
|
80
|
+
player.position = [
|
|
81
|
+
(shade.position[0] ?? 0) + 6,
|
|
82
|
+
(shade.position[1] ?? 0) + 0.4,
|
|
83
|
+
shade.position[2] ?? 0,
|
|
84
|
+
];
|
|
85
|
+
const gap = () =>
|
|
86
|
+
Math.hypot(
|
|
87
|
+
(shade.position[0] ?? 0) - (player.position[0] ?? 0),
|
|
88
|
+
(shade.position[2] ?? 0) - (player.position[2] ?? 0),
|
|
89
|
+
);
|
|
90
|
+
let reached = false;
|
|
91
|
+
for (let i = 0; i < 700 && !reached; i++) {
|
|
92
|
+
player.position = [
|
|
93
|
+
player.position[0] ?? 0,
|
|
94
|
+
player.position[1] ?? 0,
|
|
95
|
+
player.position[2] ?? 0,
|
|
96
|
+
];
|
|
97
|
+
ctx.engine.step();
|
|
98
|
+
reached = gap() < 1.6;
|
|
99
|
+
}
|
|
100
|
+
if (!reached) throw new Error(`shade never closed in (gap ${gap().toFixed(1)})`);
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
atMs: 12000,
|
|
105
|
+
label: 'clear the guards and relight all three wards',
|
|
106
|
+
do: (ctx) => {
|
|
107
|
+
const engine = ctx.engine;
|
|
108
|
+
const live = () => {
|
|
109
|
+
const root = engine.scene?.tree.root;
|
|
110
|
+
if (!root) throw new Error('no scene');
|
|
111
|
+
return root;
|
|
112
|
+
};
|
|
113
|
+
for (let w = 1; w <= 3; w++) {
|
|
114
|
+
const ward = live().getNode(`Ward${w}`) as unknown as Vec;
|
|
115
|
+
// kill this ward's guards (one strike each)
|
|
116
|
+
for (let round = 0; round < 4; round++) {
|
|
117
|
+
const guards = engine.scene?.tree.getNodesInGroup(`shade-ward${w}`) ?? [];
|
|
118
|
+
const target = guards[0] as unknown as Vec | undefined;
|
|
119
|
+
if (!target) break;
|
|
120
|
+
const player = live().getNode('Player') as unknown as Vec;
|
|
121
|
+
player.position = [
|
|
122
|
+
(target.position[0] ?? 0) + 0.9,
|
|
123
|
+
(target.position[1] ?? 0) + 0.3,
|
|
124
|
+
(target.position[2] ?? 0) + 0.9,
|
|
125
|
+
];
|
|
126
|
+
engine.step();
|
|
127
|
+
engine.input.pressAction('attack');
|
|
128
|
+
engine.step();
|
|
129
|
+
engine.input.releaseAction('attack');
|
|
130
|
+
for (let i = 0; i < 42; i++) engine.step();
|
|
131
|
+
}
|
|
132
|
+
const left = engine.scene?.tree.getNodesInGroup(`shade-ward${w}`).length ?? -1;
|
|
133
|
+
if (left !== 0) throw new Error(`ward ${w}: ${left} shades survived`);
|
|
134
|
+
// stand at the ward and relight
|
|
135
|
+
const player = live().getNode('Player') as unknown as Vec;
|
|
136
|
+
player.position = [
|
|
137
|
+
(ward.position[0] ?? 0) + 1,
|
|
138
|
+
(ward.position[1] ?? 0) + 1,
|
|
139
|
+
ward.position[2] ?? 0,
|
|
140
|
+
];
|
|
141
|
+
for (let i = 0; i < 4; i++) engine.step();
|
|
142
|
+
engine.input.pressAction('interact');
|
|
143
|
+
engine.step();
|
|
144
|
+
engine.input.releaseAction('interact');
|
|
145
|
+
for (let i = 0; i < 5; i++) engine.step();
|
|
146
|
+
if (!quest.lit.has(w)) throw new Error(`ward ${w} did not light`);
|
|
147
|
+
}
|
|
148
|
+
if (quest.lit.size !== 3) throw new Error('not all wards lit');
|
|
149
|
+
if (!quest.bossRisen) throw new Error('the Shadeheart never rose');
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
atMs: 25000,
|
|
154
|
+
label: 'five strikes fell the Shadeheart',
|
|
155
|
+
do: (ctx) => {
|
|
156
|
+
const engine = ctx.engine;
|
|
157
|
+
const root = engine.scene?.tree.root;
|
|
158
|
+
if (!root) throw new Error('no scene');
|
|
159
|
+
for (let i = 0; i < 60; i++) engine.step(); // boss wakes + starts hunting
|
|
160
|
+
const player = root.getNode('Player') as unknown as Vec;
|
|
161
|
+
for (let round = 0; round < 7 && !quest.bossDown; round++) {
|
|
162
|
+
const boss = engine.scene?.tree.getNodesInGroup('boss')[0] as unknown as
|
|
163
|
+
| Vec
|
|
164
|
+
| undefined;
|
|
165
|
+
if (!boss) break;
|
|
166
|
+
player.position = [
|
|
167
|
+
(boss.position[0] ?? 0) + 1.2,
|
|
168
|
+
(boss.position[1] ?? 0) + 0.3,
|
|
169
|
+
(boss.position[2] ?? 0) + 1.2,
|
|
170
|
+
];
|
|
171
|
+
engine.step();
|
|
172
|
+
engine.input.pressAction('attack');
|
|
173
|
+
engine.step();
|
|
174
|
+
engine.input.releaseAction('attack');
|
|
175
|
+
for (let i = 0; i < 42; i++) engine.step();
|
|
176
|
+
}
|
|
177
|
+
if (!quest.bossDown) throw new Error('the Shadeheart survived 7 rounds');
|
|
178
|
+
if ((engine.scene?.tree.getNodesInGroup('boss').length ?? -1) !== 0)
|
|
179
|
+
throw new Error('boss node not freed');
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
atMs: 30000,
|
|
184
|
+
label: 'report back — quest complete + win banner',
|
|
185
|
+
do: (ctx) => {
|
|
186
|
+
const engine = ctx.engine;
|
|
187
|
+
const root = engine.scene?.tree.root;
|
|
188
|
+
if (!root) throw new Error('no scene');
|
|
189
|
+
const player = root.getNode('Player') as unknown as Vec;
|
|
190
|
+
const k = root.getNode('Keeper') as unknown as Vec;
|
|
191
|
+
player.position = [
|
|
192
|
+
(k.position[0] ?? 0) + 1.5,
|
|
193
|
+
(k.position[1] ?? 0) + 1.2,
|
|
194
|
+
(k.position[2] ?? 0) + 1.5,
|
|
195
|
+
];
|
|
196
|
+
for (let i = 0; i < 5; i++) engine.step();
|
|
197
|
+
engine.input.pressAction('interact');
|
|
198
|
+
engine.step();
|
|
199
|
+
engine.input.releaseAction('interact');
|
|
200
|
+
const talk = root.getNode('HUD/Dialogue') as UiDialogue;
|
|
201
|
+
for (let i = 0; i < 400; i++) engine.step();
|
|
202
|
+
talk.advance();
|
|
203
|
+
for (let i = 0; i < 500; i++) engine.step();
|
|
204
|
+
talk.advance();
|
|
205
|
+
for (let i = 0; i < 80; i++) engine.step(); // dialogueFinished + fireworks timer
|
|
206
|
+
if (!quest.done) throw new Error('quest never completed');
|
|
207
|
+
const line = (root.getNode('HUD/Quest') as UiText).text;
|
|
208
|
+
if (!line.includes('safe')) throw new Error(`quest line: ${line}`);
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
ok('QUEST: full loop (keeper → hunt → 3 wards → win)', result.ok);
|
|
214
|
+
for (const f of result.failures) console.error(` ${f.label}: ${f.message}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- DEATH keeps progress ------------------------------------------------------------
|
|
218
|
+
{
|
|
219
|
+
resetQuest();
|
|
220
|
+
quest.accepted = true;
|
|
221
|
+
quest.lit.add(1); // pretend ward 1 was lit before dying
|
|
222
|
+
const result = await runScript(gameJson, {
|
|
223
|
+
durationMs: 60000,
|
|
224
|
+
seed: 5,
|
|
225
|
+
behaviors: BEHAVIORS,
|
|
226
|
+
steps: [
|
|
227
|
+
{
|
|
228
|
+
atMs: 300,
|
|
229
|
+
label: 'death → respawn keeps lit wards',
|
|
230
|
+
do: (ctx) => {
|
|
231
|
+
const engine = ctx.engine;
|
|
232
|
+
const health = () =>
|
|
233
|
+
engine.scene?.tree.root?.getNode('Player')?.behavior as unknown as { current: number };
|
|
234
|
+
for (let i = 0; i < 4000 && (health()?.current ?? 0) > 0; i++) {
|
|
235
|
+
const shades = engine.scene?.tree.getNodesInGroup('shade') ?? [];
|
|
236
|
+
const s = shades[0] as unknown as Vec | undefined;
|
|
237
|
+
if (!s) throw new Error('no shades');
|
|
238
|
+
const p = engine.scene?.tree.root?.getNode('Player') as unknown as Vec;
|
|
239
|
+
p.position = [s.position[0] ?? 0, (s.position[1] ?? 0) + 0.1, s.position[2] ?? 0];
|
|
240
|
+
engine.step();
|
|
241
|
+
}
|
|
242
|
+
if ((health()?.current ?? 1) > 0) throw new Error('shades never killed the player');
|
|
243
|
+
for (let i = 0; i < 130; i++) engine.step(); // respawn countdown
|
|
244
|
+
if ((health()?.current ?? 0) < 100) throw new Error('respawn did not reset HP');
|
|
245
|
+
if (!quest.lit.has(1)) throw new Error('respawn lost the lit ward');
|
|
246
|
+
const shadeCount = engine.scene?.tree.getNodesInGroup('shade').length ?? 0;
|
|
247
|
+
if (shadeCount !== 6) throw new Error(`respawn spawned ${shadeCount} shades`);
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
ok('DEATH: respawn keeps quest progress', result.ok);
|
|
253
|
+
for (const f of result.failures) console.error(` ${f.label}: ${f.message}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- REPLAY determinism --------------------------------------------------------------
|
|
257
|
+
{
|
|
258
|
+
resetQuest();
|
|
259
|
+
const boot = async (): Promise<Engine> => {
|
|
260
|
+
const engine = new Engine({ seed: 42, scheduler: () => () => {} });
|
|
261
|
+
engine.setScene(loadScene(structuredClone(gameJson) as Parameters<typeof loadScene>[0]));
|
|
262
|
+
await enablePhysics3D(engine);
|
|
263
|
+
return engine;
|
|
264
|
+
};
|
|
265
|
+
const a = await boot();
|
|
266
|
+
const rec = startRecording(a);
|
|
267
|
+
for (let frame = 0; frame <= 240; frame++) {
|
|
268
|
+
if (frame === 20) a.input.handleKey('KeyW', true);
|
|
269
|
+
if (frame === 140) a.input.handleKey('KeyA', true);
|
|
270
|
+
if (frame === 200) a.input.handleKey('KeyW', false);
|
|
271
|
+
a.tick(frame * (1000 / 60));
|
|
272
|
+
}
|
|
273
|
+
const recording = rec.stop();
|
|
274
|
+
const posA = (a.scene?.tree.root?.getNode('Player') as unknown as Vec).position;
|
|
275
|
+
resetQuest();
|
|
276
|
+
const b = await boot();
|
|
277
|
+
replay(b, recording);
|
|
278
|
+
const posB = (b.scene?.tree.root?.getNode('Player') as unknown as Vec).position;
|
|
279
|
+
ok(
|
|
280
|
+
`REPLAY: bit-identical after 240 replayed frames (${posA.map((v) => v.toFixed(3)).join(', ')})`,
|
|
281
|
+
posA.length === 3 && posA.every((v, i) => v === posB[i]),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
console.log(process.exitCode ? '\nVERIFY FAILED' : '\nVERIFY OK');
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Context — tps-3d (Incanto)
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
"Vanguard" — a 3D THIRD-PERSON shooter. The camera rides over your soldier's
|
|
6
|
+
shoulder (you SEE your character); the crosshair is where the hitscan rifle
|
|
7
|
+
fires. Move with WASD, look with the mouse (click locks the pointer), Shift to
|
|
8
|
+
sprint, Space to jump, click / F to fire, R to reload. Survive and clear three
|
|
9
|
+
escalating waves of chasing enemies to **SECURE THE AREA**; let them drain your
|
|
10
|
+
HP to 0 and the **MISSION FAILS**.
|
|
11
|
+
|
|
12
|
+
The entire loop is composed from `incanto/gameplay` built-ins wired in
|
|
13
|
+
`src/game.scene.json` — the same gameplay spine as the `fps-3d` example, but with
|
|
14
|
+
`CharacterController3D`'s `view: 'free'` third-person rig and a VISIBLE character.
|
|
15
|
+
The only custom TypeScript is `Shoot` (hitscan + face-aim) and `HudUpdater`.
|
|
16
|
+
|
|
17
|
+
## Tech Stack
|
|
18
|
+
|
|
19
|
+
_Exact versions are in `package.json`._
|
|
20
|
+
|
|
21
|
+
- **Game engine**: `incanto` + `incanto/3d` (CharacterController3D free-view rig,
|
|
22
|
+
MeshInstance3D, Terrain/Tree3D, environment sky/fog/shadows, spatial audio) +
|
|
23
|
+
`incanto/gameplay` (Health, Chase, WaveSpawner, DamageOnContact, ScoreKeeper).
|
|
24
|
+
- **Build / Lang**: Vite, TypeScript. No React — a single canvas + HTML HUD.
|
|
25
|
+
|
|
26
|
+
## Critical Memory
|
|
27
|
+
|
|
28
|
+
- READ THE SKILLS FIRST: `node_modules/incanto/skills/incanto-3d-character.md`
|
|
29
|
+
(the controller + camera rigs), `incanto-building-3d-games.md`,
|
|
30
|
+
`incanto-gameplay-behaviors.md`, `incanto-environment.md`, `incanto-audio.md`.
|
|
31
|
+
- **THIRD-PERSON = `view: 'free'`.** The controller OWNS the scene's current
|
|
32
|
+
Camera3D and orbits it behind+above the player (`camDistance` 4.2, `eyeHeight`
|
|
33
|
+
1.1), looking at the pivot. Do NOT add a FollowCamera — it would fight the rig.
|
|
34
|
+
- **The character is a real GLB model.** The `Player/Skin` is a `ModelInstance3D`
|
|
35
|
+
(`base-model.glb` + mixamo idle/walk/run/jump clips, declared in scene `assets`).
|
|
36
|
+
The controller mounts it at 180° and yaw-rotates it toward the MOVE direction;
|
|
37
|
+
`Shoot.onReady` maps `movementStateChanged` → the matching clip, and
|
|
38
|
+
`Shoot.faceAim()` snaps the model to the AIM direction on each shot so the
|
|
39
|
+
soldier turns to shoot where you look. (No held-weapon bone attachment yet — the
|
|
40
|
+
shot reads via the crosshair + tracer + muzzle flash.)
|
|
41
|
+
- Aim is the CAMERA look direction: `Shoot` reads `controller.yaw/pitch`, computes
|
|
42
|
+
a forward vector, and hitscans the nearest enemy in the crosshair cone. The
|
|
43
|
+
shot originates at the player + `eyeHeight` (chest).
|
|
44
|
+
- ONE owner of authority per concern: enemies are spawned clones — `Health` has
|
|
45
|
+
`freeOnDeath: true` (scene `connections` are NOT cloned, so death cleanup must
|
|
46
|
+
be node-local). Friendly fire is gated by `DamageOnContact.targetGroup`.
|
|
47
|
+
- The HUD + crosshair + banner are plain DOM (`index.html`); `HudUpdater` writes
|
|
48
|
+
the numbers (guarded so headless runs are a no-op).
|
|
49
|
+
- `window.game` is exposed for console poking.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Requirements — tps-3d (Incanto)
|
|
2
|
+
|
|
3
|
+
## Coding Patterns
|
|
4
|
+
|
|
5
|
+
- Game STRUCTURE belongs in `src/game.scene.json` (nodes, props, input map,
|
|
6
|
+
environment, the player + enemy template, waves). Add nodes there or with
|
|
7
|
+
`npx incanto-editor`, NOT ad-hoc in code.
|
|
8
|
+
- Game LOGIC: reach for a BUILT-IN gameplay behavior first (Health, Chase,
|
|
9
|
+
WaveSpawner, DamageOnContact, ScoreKeeper). This game's ONLY custom behaviors
|
|
10
|
+
are `Shoot` (the click-to-fire hitscan + face-aim, which no built-in can be —
|
|
11
|
+
it reads the camera angle and the mouse) and `HudUpdater` (DOM presentation).
|
|
12
|
+
- THIRD-PERSON camera is declarative: `CharacterController3D` `view: 'free'` +
|
|
13
|
+
`camDistance`/`eyeHeight`. The controller drives the scene's `current` Camera3D
|
|
14
|
+
— never also attach a FollowCamera to that camera.
|
|
15
|
+
- The VISIBLE character is a `ModelInstance3D` named `Skin`, sibling of the
|
|
16
|
+
Controller (the controller rotates `../Skin`). It loads a GLB (`$characters/base`)
|
|
17
|
+
sized by `targetHeight` and animated by mixamo `$anims/*` clips (declared in
|
|
18
|
+
scene `assets`, fetched from the CDN; mixamo retargets onto the model
|
|
19
|
+
automatically). `Shoot.onReady` drives the clip from `movementStateChanged`.
|
|
20
|
+
- Enemies are a hidden `EnemyTemplate` the `WaveSpawner` clones: `Health`
|
|
21
|
+
(`freeOnDeath: true`) on the root, `Chase` (`moveParent: true`) on a child,
|
|
22
|
+
`DamageOnContact` (`targetGroup: 'player'`) on an Area3D hitbox.
|
|
23
|
+
- Generate every node uid with the engine's `newUid()` (or let the editor assign
|
|
24
|
+
them) — NEVER hand-write readable uid strings.
|
|
25
|
+
|
|
26
|
+
## Known Issues / Constraints
|
|
27
|
+
|
|
28
|
+
- Movement-facing vs aim-facing: the controller turns the soldier toward the MOVE
|
|
29
|
+
direction; `Shoot.faceAim()` overrides that toward the AIM on each shot, so the
|
|
30
|
+
character reads as "aim while standing, run-face while moving". A full
|
|
31
|
+
strafe-aim rig (always face the camera) would be a custom controller tweak.
|
|
32
|
+
- Mouse look needs POINTER LOCK (click the canvas). A free cursor never spins the
|
|
33
|
+
camera (deltas only accumulate while locked or a button is held).
|
|
34
|
+
- HUD is DOM, not nodes: 3D scenes have no 2D `UILayer`/`Label`. The crosshair,
|
|
35
|
+
HUD and banner live in `index.html`; `HudUpdater` fills them (guarded for
|
|
36
|
+
headless). The crosshair marks the camera aim point, not the gun barrel.
|
|
37
|
+
- Zero-asset: arena, soldier and enemies are primitives; SFX are procedural
|
|
38
|
+
presets; the music is a bundled clip. Nothing is fetched at boot.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Status — tps-3d (Incanto)
|
|
2
|
+
|
|
3
|
+
## Implemented
|
|
4
|
+
|
|
5
|
+
- `src/game.scene.json`: third-person arena — atmosphere sky/fog/shadows, a
|
|
6
|
+
48×48 walled floor with cover blocks + pillars + a ramp + a conifer `Grove`,
|
|
7
|
+
sun + fill light.
|
|
8
|
+
- **Player** (RigidBody3D, group `player`, `Health` max 100 / 0.6s i-frames):
|
|
9
|
+
`Controller` (CharacterController3D `view: 'free'`, camDistance 4.2, eyeHeight
|
|
10
|
+
1.1, maxSpeed 4.2, `Shoot` script); a real GLB `Skin` (ModelInstance3D
|
|
11
|
+
`base-model.glb` + mixamo idle/walk/run/jump clips); a `Muzzle` Node3D
|
|
12
|
+
(shot/kill/empty SFX + flash light).
|
|
13
|
+
- **Spawner** (WaveSpawner, 3 waves of 4/6/8) cloning a hidden `EnemyTemplate`
|
|
14
|
+
(CharacterBody3D, group `enemy`, `Health` 30 freeOnDeath, red capsule + glow
|
|
15
|
+
eye, `Chase` moveParent AI, `DamageOnContact` hitbox, spatial growl).
|
|
16
|
+
- Camera3D (current), HurtSfx, `Hud` (HudUpdater). Connections: Player.died →
|
|
17
|
+
`/root.loseLife`, Player.damaged → HurtSfx, Spawner.allCleared → Hud.
|
|
18
|
+
- `src/behaviors.ts`: `Shoot` (18-round clip, hitscan within a ~9° crosshair cone
|
|
19
|
+
to 70 m, tracer + muzzle flash, face-aim on fire, reload) and `HudUpdater`
|
|
20
|
+
(HP/Kills/Wave/Ammo + health bar + SECURE/FAILED banner).
|
|
21
|
+
- `src/main.ts`: preloads the GLB + animation assets with a progress bar
|
|
22
|
+
(`preloadUrls(assetUrls(scene.assets))`), then `createGame3D` with
|
|
23
|
+
`pointer: true`, looping music, on-screen FIRE/JUMP buttons, `window.game`.
|
|
24
|
+
- `index.html`: canvas + center crosshair + HUD overlay + banner + mobile buttons.
|
|
25
|
+
|
|
26
|
+
## Works (verified)
|
|
27
|
+
|
|
28
|
+
- `bun run check` — scene valid. `bun run verify` — 9/9 headless checks (move,
|
|
29
|
+
waves spawn, Chase closes in, hitscan scores, all-clear WIN, contact-damage LOSE).
|
|
30
|
+
- **Verified by playing in the browser (Playwright)**: over-the-shoulder camera
|
|
31
|
+
frames the soldier (back to camera, `skinRotationY` 180); enemies spawn + chase
|
|
32
|
+
+ reach the player; holding fire cleared a wave (Kills 0→4) with visible tracers;
|
|
33
|
+
HP drained to 0 under a swarm → "MISSION FAILED" banner. HUD updates live.
|
|
34
|
+
|
|
35
|
+
## Not Included (on purpose)
|
|
36
|
+
|
|
37
|
+
- No held-weapon bone attachment (the model has no rifle in-hand — the shot reads
|
|
38
|
+
via crosshair + tracer + muzzle flash); no dedicated shoot/reload/aim clips
|
|
39
|
+
(the CDN set is idle/walk/run/jump).
|
|
40
|
+
- No cover/peek mechanic, no projectile-drop weapons (hitscan only). Co-op
|
|
41
|
+
multiplayer lives in the sibling `examples/tps-3d-multiplay`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Structure — tps-3d (Incanto)
|
|
2
|
+
|
|
3
|
+
## `index.html`
|
|
4
|
+
|
|
5
|
+
Full-window canvas + the DOM overlays a 3D scene can't host as nodes: the center
|
|
6
|
+
crosshair (camera aim point), the HUD (wave / HP + bar / kills / ammo), the
|
|
7
|
+
win-lose banner, on-screen FIRE/JUMP buttons, the loading overlay, and the
|
|
8
|
+
iframe-embed GAME_SIZE harness.
|
|
9
|
+
|
|
10
|
+
## `src/game.scene.json`
|
|
11
|
+
|
|
12
|
+
THE game. Header: atmosphere `environment` (sky/fog/shadows/exposure/ambient),
|
|
13
|
+
physics gravity, and the input map (move/jump/sprint/fire/reload). Tree:
|
|
14
|
+
- `Arena` — walled floor, cover blocks, pillars, a ramp, sun + fill light, a
|
|
15
|
+
conifer `Grove` (Tree3D).
|
|
16
|
+
- `Player` (RigidBody3D, `Health`) → `Controller` (CharacterController3D
|
|
17
|
+
`view: 'free'` + `Shoot`), the visible `Skin` soldier (primitive parts), and a
|
|
18
|
+
`Muzzle` (SFX + flash).
|
|
19
|
+
- `Camera` (Camera3D, current — driven by the controller each frame).
|
|
20
|
+
- `Spawner` (WaveSpawner) → hidden `EnemyTemplate` (Health/Chase/DamageOnContact/
|
|
21
|
+
growl + red skin).
|
|
22
|
+
- `Hud` (HudUpdater). Plus top-level `connections` for death/damage/all-clear.
|
|
23
|
+
Open it with `npx incanto-editor`.
|
|
24
|
+
|
|
25
|
+
## `src/behaviors.ts`
|
|
26
|
+
|
|
27
|
+
The only custom code. `Shoot` — hitscan rifle on the CharacterController3D: reads
|
|
28
|
+
its yaw/pitch for aim, raycasts the nearest enemy in the crosshair cone, applies
|
|
29
|
+
`Health.damage`, scores kills via ScoreKeeper, spawns a tracer + muzzle flash,
|
|
30
|
+
owns the ammo clip/reload, and `faceAim()`s the soldier to the shot direction.
|
|
31
|
+
`HudUpdater` — reads ScoreKeeper/Health/Shoot/wave each frame into the HTML HUD
|
|
32
|
+
and flips the banner (guarded so headless runs are a no-op).
|
|
33
|
+
|
|
34
|
+
## `src/main.ts`
|
|
35
|
+
|
|
36
|
+
Boot: `createGame3D({ scene, behaviors: { Shoot, HudUpdater }, pointer: true })`,
|
|
37
|
+
loop the bundled music, wire the on-screen buttons, expose `window.game`.
|
|
38
|
+
|
|
39
|
+
## `verify.ts`
|
|
40
|
+
|
|
41
|
+
Headless proof (`bun run verify`) that the loop plays end to end — move, waves,
|
|
42
|
+
Chase closing in, the autopilot aiming + firing the hitscan to clear all waves
|
|
43
|
+
(WIN), and an enemy hitbox draining the player to 0 (LOSE) — via `incanto/test`'s
|
|
44
|
+
`runScript` at a seeded fixed timestep.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<userRequest>
|
|
2
|
+
YOU MUST follow these rules EXACTLY when vibe-coding on this Incanto template:
|
|
3
|
+
|
|
4
|
+
1. 📖 READ THE SKILLS FIRST: `node_modules/incanto/skills/` is the engine
|
|
5
|
+
manual, version-pinned to what is installed. Start with
|
|
6
|
+
`incanto-building-3d-games.md`, `incanto-3d-character.md`,
|
|
7
|
+
`incanto-gameplay-behaviors.md`, `incanto-environment.md` and
|
|
8
|
+
`incanto-audio.md` — this template composes all five.
|
|
9
|
+
2. 🧱 STRUCTURE IS JSON: scenes, nodes, props, assets, input maps and
|
|
10
|
+
replication all live in `src/game.scene.json`. Add nodes there (or with
|
|
11
|
+
`npx incanto-editor`), NOT by constructing them ad-hoc in code.
|
|
12
|
+
3. 🧠 LOGIC IS BEHAVIORS: gameplay code = small TypeScript Behavior classes,
|
|
13
|
+
registered with `registerBehavior('Name', Class)` and attached in JSON via
|
|
14
|
+
`"script": { "name": "Name" }`. Reach for a BUILT-IN gameplay behavior
|
|
15
|
+
(Health, Chase, WaveSpawner, DamageOnContact, ScoreKeeper…) before writing
|
|
16
|
+
your own — this game's only custom behaviors are `Shoot` and `HudUpdater`.
|
|
17
|
+
4. 🔑 UIDS ARE GENERATED: every node uid comes from
|
|
18
|
+
`import { newUid } from 'incanto'`. NEVER invent readable uid strings.
|
|
19
|
+
5. 📦 ASSETS ARE DECLARED: `assets` entries need `type` + `url`; reference
|
|
20
|
+
them as `"$key"`. Free extra key-values are preserved — use them to leave
|
|
21
|
+
notes for yourself (license, palette, source prompt).
|
|
22
|
+
6. ⬆️ Y IS UP: 3D space is y-UP meters, +Z is "south", rotations are degrees
|
|
23
|
+
(Euler XYZ). Negative gravity falls.
|
|
24
|
+
7. 🧲 COLLIDERS ARE PROPS: `"collider": { "shape": "box"|"sphere"|"capsule", … }`
|
|
25
|
+
on a body node — never child shape nodes. Wrong shapes hard-fail at load.
|
|
26
|
+
8. 🖥️ THE HUD IS DOM, NOT NODES: 3D scenes have no 2D `UILayer`/`Label` nodes
|
|
27
|
+
(those are `incanto/2d`). The HUD + crosshair + banner are plain HTML in
|
|
28
|
+
`index.html`; `HudUpdater` writes the live numbers into them (guarded so
|
|
29
|
+
headless runs are a no-op).
|
|
30
|
+
9. 🐛 DEBUG WHEN UNSURE: `physics.debugDraw = true` (the object
|
|
31
|
+
`enablePhysics3D` returns) draws every collider in the running game; or boot
|
|
32
|
+
with `?incanto_debug=1` for the live Explorer/Inspector/Stats overlay.
|
|
33
|
+
10. 🎬 DELTA DISCIPLINE: only write props that differ from defaults — the
|
|
34
|
+
loader treats unknown/garbage props as hard errors, which is your friend.
|
|
35
|
+
11. ✅ VERIFY LIKE A USER: `bun run check` validates the scene, `bun run verify`
|
|
36
|
+
proves the loop headlessly, then `bun run dev` and actually PLAY what you
|
|
37
|
+
changed (move, shoot, clear a wave) before declaring it done.
|
|
38
|
+
</userRequest>
|