incanto 0.12.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.
Files changed (82) hide show
  1. package/README.md +22 -0
  2. package/bin/incanto-new.mjs +87 -0
  3. package/dist/2d.d.ts +2 -2
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +194 -71
  6. package/dist/3d.js +74 -4
  7. package/dist/{behavior-Bod1AyJS.d.ts → behavior-CPibUfnH.d.ts} +4 -2
  8. package/dist/{create-game-C_xf4gm9.js → create-game-Ct86zczq.js} +5 -5
  9. package/dist/{create-game-B2fewJUy.js → create-game-J6g4QInv.js} +5 -5
  10. package/dist/debug.d.ts +1 -1
  11. package/dist/{duplicate-BEvGBtb_.js → duplicate-BINwP0WG.js} +1 -1
  12. package/dist/{gameplay-Dn3yidJw.js → gameplay-sSqrUcnz.js} +75 -3
  13. package/dist/gameplay.d.ts +37 -2
  14. package/dist/gameplay.js +2 -2
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +4 -4
  17. package/dist/{loader-B4OEXDZ8.js → loader-BZqOKfI2.js} +3 -1
  18. package/dist/{loader-BcUDfNHn.d.ts → loader-DILt9PGC.d.ts} +1 -1
  19. package/dist/net.d.ts +1 -1
  20. package/dist/net.js +3 -3
  21. package/dist/{pathfinding-BSiXjd6W.d.ts → pathfinding-RWYkNKx9.d.ts} +1 -1
  22. package/dist/{physics-2d-B6D864oZ.js → physics-2d-BiIdl51r.js} +2 -2
  23. package/dist/{physics-3d-RLNaxRyw.js → physics-3d-DhaAaKJo.js} +2 -2
  24. package/dist/react.d.ts +1 -1
  25. package/dist/react.js +1 -1
  26. package/dist/{register-Cwc-yCYx.js → register-BFFE1Mh1.js} +2 -2
  27. package/dist/{register-ysLR3WQS.js → register-BFw4c83z.js} +508 -123
  28. package/dist/{register-BD6zgrv_.js → register-Djwckzgx.js} +2 -2
  29. package/dist/{register-DJZXxwAn.js → register-nObreUQR.js} +1 -1
  30. package/dist/test.d.ts +2 -2
  31. package/dist/test.js +10 -10
  32. package/editor/assets/{agent8-D_4QY8Aq.js → agent8-BF8Jfbkl.js} +1 -1
  33. package/editor/assets/{index-s0DZuroe.js → index-DSq0gdhE.js} +74 -74
  34. package/editor/index.html +1 -1
  35. package/package.json +4 -2
  36. package/schemas/scene.schema.json +263 -0
  37. package/skills/incanto-3d-character.md +35 -0
  38. package/skills/incanto-building-3d-games.md +48 -0
  39. package/skills/incanto-gameplay-behaviors.md +23 -1
  40. package/skills/incanto-node-reference.md +51 -0
  41. package/templates-app/beacon-isle-3d/PROJECT/Context.md +44 -0
  42. package/templates-app/beacon-isle-3d/PROJECT/Requirements.md +16 -0
  43. package/templates-app/beacon-isle-3d/PROJECT/Status.md +38 -0
  44. package/templates-app/beacon-isle-3d/PROJECT/Structure.md +24 -0
  45. package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +38 -0
  46. package/templates-app/beacon-isle-3d/generate-world.ts +814 -0
  47. package/templates-app/beacon-isle-3d/index.html +68 -0
  48. package/templates-app/beacon-isle-3d/package.json +26 -0
  49. package/templates-app/beacon-isle-3d/src/behaviors.ts +570 -0
  50. package/templates-app/beacon-isle-3d/src/game.scene.json +2992 -0
  51. package/templates-app/beacon-isle-3d/src/main.ts +33 -0
  52. package/templates-app/beacon-isle-3d/tsconfig.json +13 -0
  53. package/templates-app/beacon-isle-3d/verify.ts +285 -0
  54. package/templates-app/beacon-isle-3d/vite.config.ts +5 -0
  55. package/templates-app/tps-3d/PROJECT/Context.md +49 -0
  56. package/templates-app/tps-3d/PROJECT/Requirements.md +38 -0
  57. package/templates-app/tps-3d/PROJECT/Status.md +41 -0
  58. package/templates-app/tps-3d/PROJECT/Structure.md +44 -0
  59. package/templates-app/tps-3d/docs/project-3d-rules.md +38 -0
  60. package/templates-app/tps-3d/index.html +244 -0
  61. package/templates-app/tps-3d/package.json +25 -0
  62. package/templates-app/tps-3d/src/behaviors.ts +405 -0
  63. package/templates-app/tps-3d/src/game.scene.json +677 -0
  64. package/templates-app/tps-3d/src/main.ts +64 -0
  65. package/templates-app/tps-3d/tsconfig.json +13 -0
  66. package/templates-app/tps-3d/verify.ts +167 -0
  67. package/templates-app/tps-3d/vite.config.ts +5 -0
  68. package/templates-app/village-quest-3d/PROJECT/Context.md +39 -0
  69. package/templates-app/village-quest-3d/PROJECT/Requirements.md +36 -0
  70. package/templates-app/village-quest-3d/PROJECT/Status.md +26 -0
  71. package/templates-app/village-quest-3d/PROJECT/Structure.md +30 -0
  72. package/templates-app/village-quest-3d/docs/project-3d-rules.md +38 -0
  73. package/templates-app/village-quest-3d/index.html +68 -0
  74. package/templates-app/village-quest-3d/package.json +25 -0
  75. package/templates-app/village-quest-3d/src/behaviors.ts +529 -0
  76. package/templates-app/village-quest-3d/src/grove.scene.json +826 -0
  77. package/templates-app/village-quest-3d/src/main.ts +35 -0
  78. package/templates-app/village-quest-3d/src/quest.ts +18 -0
  79. package/templates-app/village-quest-3d/src/village.scene.json +680 -0
  80. package/templates-app/village-quest-3d/tsconfig.json +13 -0
  81. package/templates-app/village-quest-3d/verify.ts +254 -0
  82. package/templates-app/village-quest-3d/vite.config.ts +5 -0
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Beacon Isle world builder — run `bun generate-world.ts` to (re)emit
3
+ * `src/game.scene.json`. Deterministic: same seeds, same island. This is the
4
+ * template's authoring story: the WORLD is generated (terrain, sea, groves,
5
+ * grass, flowers, ward sites picked on walkable ground), the GAME is authored
6
+ * (quest, enemies, HUD) — regenerate the world without touching the game.
7
+ */
8
+
9
+ import { buildTerrainNav, Terrain3D } from 'incanto/3d';
10
+ import { generateTerrain } from 'incanto/env';
11
+
12
+ const SEED = 7411;
13
+ const SIZE = 240;
14
+ const MAX_HEIGHT = 16;
15
+
16
+ // ---- 1. the island ----------------------------------------------------------------
17
+ const terrain = generateTerrain({ seed: SEED, theme: 'island', size: SIZE, maxHeight: MAX_HEIGHT });
18
+ const surfaceProps = (terrain.children ?? []).find((c) => c.name === 'Ground')?.children?.[0]
19
+ ?.props as Record<string, unknown>;
20
+ const seaY =
21
+ ((terrain.children ?? []).find((c) => c.name === 'Sea')?.props?.position as number[])?.[1] ?? 0;
22
+
23
+ // headless height sampler — the SAME surface the game will load
24
+ const sampler = new Terrain3D('S');
25
+ Object.assign(sampler, surfaceProps);
26
+ const heightAt = (x: number, z: number): number => sampler.heightAt(x, z);
27
+ const nav = buildTerrainNav(sampler, { cellSize: 3, maxSlopeDeg: 38, minHeight: seaY + 0.6 });
28
+
29
+ /** Deterministic LCG so placement never shifts between runs. */
30
+ let rngState = SEED;
31
+ const rand = (): number => {
32
+ rngState = (rngState * 1664525 + 1013904223) >>> 0;
33
+ return rngState / 0xffffffff;
34
+ };
35
+
36
+ /** Find a walkable spot near (x, z), spiralling outward on the nav grid. */
37
+ function walkableNear(x: number, z: number): [number, number, number] {
38
+ const [cx, cy] = nav.toCell(x, z);
39
+ for (let r = 0; r < 25; r++) {
40
+ for (let dy = -r; dy <= r; dy++) {
41
+ for (let dx = -r; dx <= r; dx++) {
42
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
43
+ if (!nav.grid.solid(cx + dx, cy + dy)) return nav.toWorld(cx + dx, cy + dy);
44
+ }
45
+ }
46
+ }
47
+ throw new Error(`no walkable ground near ${x},${z}`);
48
+ }
49
+
50
+ // ---- 2. key sites ------------------------------------------------------------------
51
+ const keeperSite = walkableNear(-18, 62); // south coast bluff
52
+ const playerSpawn = walkableNear(keeperSite[0] + 12, keeperSite[2] + 10);
53
+ const wardSites = [walkableNear(-62, -38), walkableNear(58, -52), walkableNear(66, 44)];
54
+
55
+ // ---- 3. dressing -------------------------------------------------------------------
56
+ const dressing: unknown[] = [];
57
+ const CANOPIES = ['#4a7c3f', '#56883c', '#b8862f', '#2c5740', '#86a346'];
58
+ const TRUNKS = ['#7a5a3a', '#806044', '#5f4530', '#a39c8e'];
59
+ let placed = 0;
60
+ for (let i = 0; i < 600 && placed < 22; i++) {
61
+ const x = (rand() - 0.5) * (SIZE - 40);
62
+ const z = (rand() - 0.5) * (SIZE - 40);
63
+ const y = heightAt(x, z);
64
+ if (y < seaY + 1.2) continue;
65
+ const [wcx, wcy] = nav.toCell(x, z);
66
+ if (nav.grid.solid(wcx, wcy)) continue;
67
+ // keep groves clear of the quest sites
68
+ const sites = [keeperSite, ...wardSites];
69
+ if (sites.some(([sx, , sz]) => Math.hypot(sx - x, sz - z) < 16)) continue;
70
+ placed++;
71
+ dressing.push({
72
+ name: `Grove${placed}`,
73
+ type: 'Tree3D',
74
+ props: {
75
+ type: rand() > 0.4 ? 'broadleaf' : 'conifer',
76
+ seed: Math.floor(rand() * 1e9),
77
+ height: 5 + rand() * 3,
78
+ position: [round2(x), round2(y), round2(z)],
79
+ count: 7,
80
+ area: [14, 14],
81
+ canopyColor: CANOPIES[Math.floor(rand() * CANOPIES.length)],
82
+ trunkColor: TRUNKS[Math.floor(rand() * TRUNKS.length)],
83
+ leafFadeStart: 160,
84
+ leafFadeEnd: 420,
85
+ },
86
+ });
87
+ }
88
+ let grassPlaced = 0;
89
+ for (let i = 0; i < 400 && grassPlaced < 14; i++) {
90
+ const x = (rand() - 0.5) * (SIZE - 60);
91
+ const z = (rand() - 0.5) * (SIZE - 60);
92
+ const y = heightAt(x, z);
93
+ if (y < seaY + 1) continue;
94
+ grassPlaced++;
95
+ dressing.push({
96
+ name: `Grass${grassPlaced}`,
97
+ type: 'Foliage3D',
98
+ props: {
99
+ kind: 'grass',
100
+ style: 'mesh',
101
+ position: [round2(x), round2(y), round2(z)],
102
+ area: [34, 34],
103
+ density: 10,
104
+ terrain: '../../Ground/Surface',
105
+ },
106
+ });
107
+ }
108
+ let flowersPlaced = 0;
109
+ for (let i = 0; i < 300 && flowersPlaced < 9; i++) {
110
+ const x = (rand() - 0.5) * (SIZE - 70);
111
+ const z = (rand() - 0.5) * (SIZE - 70);
112
+ const y = heightAt(x, z);
113
+ if (y < seaY + 1.4) continue;
114
+ flowersPlaced++;
115
+ dressing.push({
116
+ name: `Flowers${flowersPlaced}`,
117
+ type: 'Flowers3D',
118
+ props: {
119
+ position: [round2(x), round2(y), round2(z)],
120
+ area: [12, 12],
121
+ seed: Math.floor(rand() * 1e9),
122
+ terrain: '../../Ground/Surface',
123
+ },
124
+ });
125
+ }
126
+
127
+ function round2(n: number): number {
128
+ return Math.round(n * 100) / 100;
129
+ }
130
+
131
+ // shoreline rocks: a band just above the waterline, ONE draw call
132
+ const rockRows: number[][] = [];
133
+ for (let i = 0; i < 900 && rockRows.length < 70; i++) {
134
+ const x = (rand() - 0.5) * (SIZE - 10);
135
+ const z = (rand() - 0.5) * (SIZE - 10);
136
+ const y = heightAt(x, z);
137
+ if (y < seaY + 0.15 || y > seaY + 1.1) continue; // the wet band
138
+ rockRows.push([
139
+ round2(x),
140
+ round2(y - 0.15),
141
+ round2(z),
142
+ Math.floor(rand() * 360),
143
+ round2(0.5 + rand() * 1.1),
144
+ ]);
145
+ }
146
+ dressing.push({
147
+ name: 'ShoreRocks',
148
+ type: 'InstancedMesh3D',
149
+ props: {
150
+ mesh: 'gem',
151
+ size: [0.6, 0.45, 0.6],
152
+ material: { color: '#7e8590', roughness: 0.95, flatShading: true },
153
+ transforms: rockRows,
154
+ castShadow: true,
155
+ receiveShadow: true,
156
+ },
157
+ });
158
+
159
+ // shade spawn cells: two per ward, on walkable ground nearby
160
+ const shadePacks = wardSites.map(([wx, , wz], i) => {
161
+ const a = walkableNear(wx + 9, wz + 4);
162
+ const b = walkableNear(wx - 7, wz - 8);
163
+ return { ward: i + 1, spots: [a, b] };
164
+ });
165
+
166
+ // ---- 4. game nodes -----------------------------------------------------------------
167
+ const ward = (i: number, [x, y, z]: [number, number, number]): unknown => ({
168
+ name: `Ward${i}`,
169
+ type: 'Node3D',
170
+ groups: ['ward'],
171
+ props: { position: [round2(x), round2(y), round2(z)] },
172
+ children: [
173
+ {
174
+ name: 'Plinth',
175
+ type: 'StaticBody3D',
176
+ props: { collider: { shape: 'box', size: [1.6, 1.2, 1.6] }, position: [0, 0.6, 0] },
177
+ children: [
178
+ {
179
+ name: 'Stone',
180
+ type: 'MeshInstance3D',
181
+ props: {
182
+ mesh: 'cylinder',
183
+ size: [1.6, 1.2, 1.6],
184
+ material: { color: '#7d8494', roughness: 0.9 },
185
+ castShadow: true,
186
+ receiveShadow: true,
187
+ },
188
+ },
189
+ ],
190
+ },
191
+ {
192
+ name: 'Crystal',
193
+ type: 'MeshInstance3D',
194
+ props: {
195
+ mesh: 'gem',
196
+ size: [0.55, 1.1, 0.55],
197
+ position: [0, 1.75, 0],
198
+ material: { color: '#243044', emissive: '#66ddff', emissiveIntensity: 0, roughness: 0.2 },
199
+ castShadow: true,
200
+ },
201
+ },
202
+ {
203
+ name: 'Glow',
204
+ type: 'OmniLight3D',
205
+ props: { position: [0, 2, 0], color: '#7fe3ff', intensity: 0, range: 16 },
206
+ },
207
+ {
208
+ name: 'LitFx',
209
+ type: 'Particles3D',
210
+ props: {
211
+ position: [0, 2, 0],
212
+ preset: 'magic',
213
+ rate: 0,
214
+ burst: 40,
215
+ blend: 'add',
216
+ sizeStart: 80,
217
+ sizeEnd: 16,
218
+ renderOrder: 100,
219
+ },
220
+ },
221
+ {
222
+ name: 'ChimeSfx',
223
+ type: 'AudioPlayer',
224
+ props: { preset: 'powerup', volume: 0.7, spatial: true, refDistance: 6, maxDistance: 60 },
225
+ },
226
+ ],
227
+ });
228
+
229
+ const shade = (wardIdx: number, n: number, [x, y, z]: [number, number, number]): unknown => ({
230
+ name: `Shade${wardIdx}_${n}`,
231
+ type: 'Node3D',
232
+ groups: ['shade', `shade-ward${wardIdx}`],
233
+ props: { position: [round2(x), round2(y + 0.9), round2(z)] },
234
+ script: { name: 'PathFollow', props: { speed: 2.6, loop: true } },
235
+ children: [
236
+ {
237
+ name: 'Body',
238
+ type: 'MeshInstance3D',
239
+ props: {
240
+ mesh: 'capsule',
241
+ size: [0.28, 0.62, 0.28],
242
+ material: { color: '#141428', emissive: '#3b2d8a', emissiveIntensity: 0.5, roughness: 0.6 },
243
+ castShadow: true,
244
+ },
245
+ },
246
+ {
247
+ name: 'Hood',
248
+ type: 'MeshInstance3D',
249
+ props: {
250
+ mesh: 'gem',
251
+ size: [0.3, 0.46, 0.3],
252
+ position: [0, 0.58, 0],
253
+ material: { color: '#0e0e1e', emissive: '#241a5e', emissiveIntensity: 0.4 },
254
+ },
255
+ },
256
+ {
257
+ name: 'EyeL',
258
+ type: 'MeshInstance3D',
259
+ props: {
260
+ mesh: 'sphere',
261
+ size: [0.05, 0.05, 0.05],
262
+ position: [-0.09, 0.6, 0.24],
263
+ material: { color: '#ff4466', emissive: '#ff4466', emissiveIntensity: 2.4 },
264
+ },
265
+ },
266
+ {
267
+ name: 'EyeR',
268
+ type: 'MeshInstance3D',
269
+ props: {
270
+ mesh: 'sphere',
271
+ size: [0.05, 0.05, 0.05],
272
+ position: [0.09, 0.6, 0.24],
273
+ material: { color: '#ff4466', emissive: '#ff4466', emissiveIntensity: 2.4 },
274
+ },
275
+ },
276
+ {
277
+ name: 'Wisp',
278
+ type: 'Trail3D',
279
+ props: {
280
+ width: 0.3,
281
+ color: '#5a48c8',
282
+ opacity: 0.5,
283
+ seconds: 0.5,
284
+ additive: true,
285
+ position: [0, 0.2, 0],
286
+ },
287
+ },
288
+ {
289
+ name: 'Fang',
290
+ type: 'Area3D',
291
+ props: { position: [0, 0, 0.4], collider: { shape: 'box', size: [0.7, 1.2, 0.8] } },
292
+ script: {
293
+ name: 'DamageOnContact',
294
+ props: { amount: 20, targetGroup: 'player', oncePerTarget: false, repeatEvery: 1.2 },
295
+ },
296
+ },
297
+ {
298
+ name: 'HissSfx',
299
+ type: 'AudioPlayer',
300
+ props: {
301
+ preset: 'hurt',
302
+ volume: 0.3,
303
+ pitch: 0.4,
304
+ spatial: true,
305
+ refDistance: 4,
306
+ maxDistance: 30,
307
+ },
308
+ },
309
+ ],
310
+ });
311
+
312
+ const scene = {
313
+ format: 1,
314
+ type: 'scene',
315
+ dimension: '3d',
316
+ name: 'BeaconIsle',
317
+ environment: {
318
+ sky: { type: 'atmosphere', elevationDeg: 17, azimuthDeg: 224, turbidity: 3.2, rayleigh: 2.1 },
319
+ fog: { near: 110, far: 560 },
320
+ shadows: { mapSize: 2048, radius: 1.2 },
321
+ exposure: 1.12,
322
+ ambient: { color: '#ffe8d4', intensity: 0.66 },
323
+ iblIntensity: 0.9,
324
+ bloom: { threshold: 1.2, strength: 0.55 },
325
+ post: { vignette: 0.28, saturation: 1.12, contrast: 1.02 },
326
+ },
327
+ physics: { gravity: [0, -9.81, 0] },
328
+ input: {
329
+ move: {
330
+ type: 'vector2',
331
+ keys: {
332
+ up: ['KeyW', 'ArrowUp'],
333
+ down: ['KeyS', 'ArrowDown'],
334
+ left: ['KeyA', 'ArrowLeft'],
335
+ right: ['KeyD', 'ArrowRight'],
336
+ },
337
+ touch: 'joystick',
338
+ },
339
+ jump: { type: 'button', keys: ['Space'], touch: 'button' },
340
+ sprint: { type: 'button', keys: ['ShiftLeft', 'ShiftRight'] },
341
+ interact: { type: 'button', keys: ['KeyE'], touch: 'button' },
342
+ attack: { type: 'button', keys: ['Mouse0', 'KeyF'], touch: 'button' },
343
+ },
344
+ assets: {
345
+ 'characters/base': {
346
+ type: 'model',
347
+ url: 'https://agent8-games.verse8.io/assets/3d/characters/realistic style/base-model.glb',
348
+ },
349
+ 'anims/idle': {
350
+ type: 'animation',
351
+ url: 'https://agent8-games.verse8.io/assets/3d/animations/mixamorig/idle-00.glb',
352
+ },
353
+ 'anims/walk': {
354
+ type: 'animation',
355
+ url: 'https://agent8-games.verse8.io/assets/3d/animations/mixamorig/walk.glb',
356
+ },
357
+ 'anims/run': {
358
+ type: 'animation',
359
+ url: 'https://agent8-games.verse8.io/assets/3d/animations/mixamorig/run-medium.glb',
360
+ },
361
+ 'anims/runFast': {
362
+ type: 'animation',
363
+ url: 'https://agent8-games.verse8.io/assets/3d/animations/mixamorig/run-fast.glb',
364
+ },
365
+ 'anims/jump': {
366
+ type: 'animation',
367
+ url: 'https://agent8-games.verse8.io/assets/3d/animations/mixamorig/jump.glb',
368
+ },
369
+ 'anims/attack': {
370
+ type: 'animation',
371
+ url: 'https://agent8-games.verse8.io/assets/3d/animations/mixamorig/melee-attack.glb',
372
+ },
373
+ },
374
+ root: {
375
+ name: 'Game',
376
+ type: 'Node',
377
+ script: { name: 'IsleDirector' },
378
+ children: [
379
+ {
380
+ ...terrain,
381
+ // static per-child, NOT the whole subtree: Sea (Water3D) and Clouds
382
+ // must keep animating — auditScene flags exactly this mistake.
383
+ children: (terrain.children ?? []).map((c) =>
384
+ c.type === 'Water3D' || c.name === 'Clouds'
385
+ ? c
386
+ : { ...c, props: { ...(c.props ?? {}), static: true } },
387
+ ),
388
+ },
389
+ { name: 'Dressing', type: 'Node3D', props: { static: true }, children: dressing },
390
+ {
391
+ name: 'Lighthouse',
392
+ type: 'Node3D',
393
+ props: {
394
+ position: [
395
+ round2(keeperSite[0] - 5),
396
+ round2(heightAt(keeperSite[0] - 5, keeperSite[2] - 4)),
397
+ round2(keeperSite[2] - 4),
398
+ ],
399
+ static: true,
400
+ },
401
+ children: [
402
+ {
403
+ name: 'Tower',
404
+ type: 'StaticBody3D',
405
+ props: { collider: { shape: 'box', size: [3, 11, 3] }, position: [0, 5.5, 0] },
406
+ children: [
407
+ {
408
+ name: 'Shaft',
409
+ type: 'MeshInstance3D',
410
+ props: {
411
+ mesh: 'cylinder',
412
+ size: [3, 11, 3],
413
+ material: { color: '#e8e2d4', roughness: 0.8 },
414
+ castShadow: true,
415
+ receiveShadow: true,
416
+ },
417
+ },
418
+ {
419
+ name: 'Band',
420
+ type: 'MeshInstance3D',
421
+ props: {
422
+ mesh: 'cylinder',
423
+ size: [3.06, 1.6, 3.06],
424
+ position: [0, 1.2, 0],
425
+ material: { color: '#b8452e', roughness: 0.8 },
426
+ },
427
+ },
428
+ ],
429
+ },
430
+ {
431
+ name: 'Lamp',
432
+ type: 'MeshInstance3D',
433
+ props: {
434
+ mesh: 'gem',
435
+ size: [0.9, 1.4, 0.9],
436
+ position: [0, 11.6, 0],
437
+ material: { color: '#fff2c4', emissive: '#ffd982', emissiveIntensity: 2.2 },
438
+ },
439
+ },
440
+ {
441
+ name: 'LampLight',
442
+ type: 'OmniLight3D',
443
+ props: { position: [0, 11.6, 0], color: '#ffd982', intensity: 2.2, range: 40 },
444
+ },
445
+ ],
446
+ },
447
+ {
448
+ name: 'Keeper',
449
+ type: 'Node3D',
450
+ props: { position: keeperSite.map(round2) },
451
+ script: { name: 'Interactable', props: { action: 'interact', range: 3 } },
452
+ children: [
453
+ {
454
+ name: 'Body',
455
+ type: 'ModelInstance3D',
456
+ props: {
457
+ model: '$characters/base',
458
+ targetHeight: 1.68,
459
+ animation: '$anims/idle',
460
+ tint: '#d8b46a',
461
+ castShadow: true,
462
+ },
463
+ },
464
+ {
465
+ name: 'Marker',
466
+ type: 'Billboard3D',
467
+ props: { position: [0, 2.2, 0], mode: 'y' },
468
+ children: [
469
+ {
470
+ name: 'Bang',
471
+ type: 'MeshInstance3D',
472
+ props: {
473
+ mesh: 'box',
474
+ size: [0.13, 0.46, 0.13],
475
+ material: { color: '#ffd166', emissive: '#ffd166', emissiveIntensity: 1.8 },
476
+ },
477
+ },
478
+ ],
479
+ },
480
+ {
481
+ name: 'HeadTrack',
482
+ type: 'BoneLookAt3D',
483
+ props: { target: '../Body', bone: 'Head', lookAt: '../../Player', maxAngleDeg: 80 },
484
+ },
485
+ {
486
+ name: 'TalkSfx',
487
+ type: 'AudioPlayer',
488
+ props: { preset: 'blip', volume: 0.5, pitch: 0.9 },
489
+ },
490
+ { name: 'AcceptSfx', type: 'AudioPlayer', props: { preset: 'coin', volume: 0.5 } },
491
+ ],
492
+ },
493
+ ...wardSites.map((site, i) => ward(i + 1, site)),
494
+ ...shadePacks.flatMap((p) => p.spots.map((s, j) => shade(p.ward, j + 1, s))),
495
+ {
496
+ name: 'Player',
497
+ type: 'RigidBody3D',
498
+ groups: ['player'],
499
+ script: { name: 'Health', props: { max: 100, invulnerableFor: 0.8 } },
500
+ props: {
501
+ position: [round2(playerSpawn[0]), round2(playerSpawn[1] + 1.2), round2(playerSpawn[2])],
502
+ fixedRotation: true,
503
+ friction: 0,
504
+ collider: { shape: 'capsule', radius: 0.34, height: 1 },
505
+ },
506
+ children: [
507
+ {
508
+ name: 'Controller',
509
+ type: 'CharacterController3D',
510
+ props: {
511
+ view: 'free',
512
+ camDistance: 5.2,
513
+ eyeHeight: 1.1,
514
+ maxSpeed: 4.4,
515
+ sprintMultiplier: 1.8,
516
+ pitchMin: -0.5,
517
+ pitchMax: 1.1,
518
+ animations: {
519
+ idle: '$anims/idle',
520
+ walk: '$anims/walk',
521
+ run: '$anims/run',
522
+ fastRun: '$anims/runFast',
523
+ airborne: '$anims/jump',
524
+ },
525
+ },
526
+ script: { name: 'SwordStrike' },
527
+ },
528
+ {
529
+ name: 'Skin',
530
+ type: 'ModelInstance3D',
531
+ props: {
532
+ model: '$characters/base',
533
+ targetHeight: 1.7,
534
+ animation: '$anims/idle',
535
+ castShadow: true,
536
+ position: [0, -0.85, 0],
537
+ },
538
+ },
539
+ {
540
+ name: 'SwordMount',
541
+ type: 'BoneAttachment3D',
542
+ props: { target: '../Skin', bone: 'RightHand' },
543
+ children: [
544
+ {
545
+ name: 'Blade',
546
+ type: 'MeshInstance3D',
547
+ props: {
548
+ mesh: 'box',
549
+ size: [0.045, 0.7, 0.045],
550
+ position: [0, 0.42, 0],
551
+ material: { color: '#d8dde8', metalness: 0.9, roughness: 0.25 },
552
+ castShadow: true,
553
+ },
554
+ },
555
+ {
556
+ name: 'Guard',
557
+ type: 'MeshInstance3D',
558
+ props: {
559
+ mesh: 'box',
560
+ size: [0.16, 0.04, 0.06],
561
+ position: [0, 0.09, 0],
562
+ material: { color: '#8a6d3a', metalness: 0.7, roughness: 0.4 },
563
+ },
564
+ },
565
+ {
566
+ name: 'SwordTip',
567
+ type: 'Node3D',
568
+ props: { position: [0, 0.78, 0] },
569
+ children: [
570
+ {
571
+ name: 'SwingTrail',
572
+ type: 'Trail3D',
573
+ props: {
574
+ width: 0.26,
575
+ color: '#ffe9b0',
576
+ opacity: 0.9,
577
+ seconds: 0.25,
578
+ additive: true,
579
+ emitting: false,
580
+ minDistance: 0.02,
581
+ },
582
+ },
583
+ ],
584
+ },
585
+ ],
586
+ },
587
+ {
588
+ name: 'DashTrail',
589
+ type: 'Trail3D',
590
+ props: {
591
+ width: 0.5,
592
+ color: '#ffffff',
593
+ opacity: 0.28,
594
+ seconds: 0.45,
595
+ emitting: false,
596
+ position: [0, -0.4, 0],
597
+ },
598
+ },
599
+ {
600
+ name: 'SwingSfx',
601
+ type: 'AudioPlayer',
602
+ props: { preset: 'shoot', volume: 0.35, pitch: 0.55 },
603
+ },
604
+ { name: 'HitSfx', type: 'AudioPlayer', props: { preset: 'hit', volume: 0.6 } },
605
+ ],
606
+ },
607
+ {
608
+ name: 'Sky',
609
+ type: 'Node',
610
+ script: {
611
+ name: 'DayNight',
612
+ props: { paused: true, startHour: 17.5, nightDarkness: 0.42, maxSunElevationDeg: 48 },
613
+ },
614
+ },
615
+ {
616
+ name: 'Boss',
617
+ type: 'Node3D',
618
+ groups: ['boss'],
619
+ // PathFollow drives node.position in WORLD coords — the boss must be a
620
+ // ROOT child (a positioned parent would double the offset on screen)
621
+ props: {
622
+ visible: false,
623
+ position: [
624
+ round2(keeperSite[0] + 16),
625
+ round2(heightAt(keeperSite[0] + 16, keeperSite[2] - 14)),
626
+ round2(keeperSite[2] - 14),
627
+ ],
628
+ },
629
+ script: { name: 'PathFollow', props: { speed: 2.0, loop: false } },
630
+ children: [
631
+ {
632
+ name: 'Body',
633
+ type: 'MeshInstance3D',
634
+ props: {
635
+ mesh: 'capsule',
636
+ size: [1.1, 2.6, 1.1],
637
+ position: [0, 2.4, 0],
638
+ material: {
639
+ color: '#171130',
640
+ emissive: '#41288f',
641
+ emissiveIntensity: 0.7,
642
+ roughness: 0.55,
643
+ },
644
+ castShadow: true,
645
+ },
646
+ },
647
+ {
648
+ name: 'Crown',
649
+ type: 'MeshInstance3D',
650
+ props: {
651
+ mesh: 'gem',
652
+ size: [1.0, 1.2, 1.0],
653
+ position: [0, 4.6, 0],
654
+ material: {
655
+ color: '#0c0920',
656
+ emissive: '#6b46c8',
657
+ emissiveIntensity: 1.1,
658
+ flatShading: true,
659
+ },
660
+ castShadow: true,
661
+ },
662
+ },
663
+ {
664
+ name: 'EyeL',
665
+ type: 'MeshInstance3D',
666
+ props: {
667
+ mesh: 'sphere',
668
+ size: [0.16, 0.16, 0.16],
669
+ position: [-0.34, 4.4, 0.8],
670
+ material: { color: '#ff2f6b', emissive: '#ff2f6b', emissiveIntensity: 4 },
671
+ },
672
+ },
673
+ {
674
+ name: 'EyeR',
675
+ type: 'MeshInstance3D',
676
+ props: {
677
+ mesh: 'sphere',
678
+ size: [0.16, 0.16, 0.16],
679
+ position: [0.34, 4.4, 0.8],
680
+ material: { color: '#ff2f6b', emissive: '#ff2f6b', emissiveIntensity: 4 },
681
+ },
682
+ },
683
+ {
684
+ name: 'ArmL',
685
+ type: 'MeshInstance3D',
686
+ props: {
687
+ mesh: 'capsule',
688
+ size: [0.4, 2.0, 0.4],
689
+ position: [-1.5, 2.6, 0],
690
+ rotation: [0, 0, 18],
691
+ material: { color: '#171130', emissive: '#41288f', emissiveIntensity: 0.5 },
692
+ castShadow: true,
693
+ },
694
+ },
695
+ {
696
+ name: 'ArmR',
697
+ type: 'MeshInstance3D',
698
+ props: {
699
+ mesh: 'capsule',
700
+ size: [0.4, 2.0, 0.4],
701
+ position: [1.5, 2.6, 0],
702
+ rotation: [0, 0, -18],
703
+ material: { color: '#171130', emissive: '#41288f', emissiveIntensity: 0.5 },
704
+ castShadow: true,
705
+ },
706
+ },
707
+ {
708
+ name: 'EyeGlow',
709
+ type: 'OmniLight3D',
710
+ props: { position: [0, 4.2, 0.6], color: '#b46bff', intensity: 0, range: 26 },
711
+ },
712
+ {
713
+ name: 'Dread',
714
+ type: 'Trail3D',
715
+ props: {
716
+ width: 1.2,
717
+ color: '#6b46c8',
718
+ opacity: 0.35,
719
+ seconds: 0.8,
720
+ additive: true,
721
+ position: [0, 1.2, 0],
722
+ emitting: false,
723
+ },
724
+ },
725
+ {
726
+ name: 'Fang',
727
+ type: 'Area3D',
728
+ props: {
729
+ position: [0, 1.2, 0.8],
730
+ collider: { shape: 'box', size: [2.4, 3.2, 2.2] },
731
+ },
732
+ script: {
733
+ name: 'DamageOnContact',
734
+ props: { amount: 35, targetGroup: 'player', oncePerTarget: false, repeatEvery: 1.5 },
735
+ },
736
+ },
737
+ {
738
+ name: 'RoarSfx',
739
+ type: 'AudioPlayer',
740
+ props: {
741
+ preset: 'explosion',
742
+ volume: 0.7,
743
+ pitch: 0.35,
744
+ spatial: true,
745
+ refDistance: 10,
746
+ maxDistance: 90,
747
+ },
748
+ },
749
+ ],
750
+ },
751
+ {
752
+ name: 'Camera',
753
+ type: 'Camera3D',
754
+ props: {
755
+ current: true,
756
+ far: 900,
757
+ position: [keeperSite[0], keeperSite[1] + 6, keeperSite[2] + 14],
758
+ },
759
+ },
760
+ { name: 'WinSfx', type: 'AudioPlayer', props: { preset: 'win', volume: 0.7 } },
761
+ {
762
+ name: 'HUD',
763
+ type: 'HudLayer',
764
+ children: [
765
+ {
766
+ name: 'Quest',
767
+ type: 'UiText',
768
+ uid: 'n_bi_quest',
769
+ props: {
770
+ text: 'Find the lighthouse keeper [E]',
771
+ size: 16,
772
+ anchor: 'topLeft',
773
+ shadow: true,
774
+ },
775
+ },
776
+ {
777
+ name: 'HP',
778
+ type: 'UiBar',
779
+ uid: 'n_bi_hp',
780
+ props: { anchor: 'topRight', value: 100, max: 100, width: 170 },
781
+ },
782
+ { name: 'Banner', type: 'UiBanner', uid: 'n_bi_banner', props: { anchor: 'center' } },
783
+ {
784
+ name: 'Dialogue',
785
+ type: 'UiDialogue',
786
+ uid: 'n_bi_dialogue',
787
+ props: { anchor: 'bottom', charsPerSecond: 45 },
788
+ },
789
+ {
790
+ name: 'Hint',
791
+ type: 'UiText',
792
+ uid: 'n_bi_hint',
793
+ props: {
794
+ text: 'WASD move · Shift sprint · E interact · click/F strike',
795
+ size: 12,
796
+ color: '#dddddd',
797
+ anchor: 'bottomRight',
798
+ shadow: true,
799
+ },
800
+ },
801
+ ],
802
+ },
803
+ ],
804
+ },
805
+ connections: [
806
+ { signal: 'interacted', from: 'Keeper', to: '/root', handler: 'onTalk' },
807
+ { signal: 'died', from: 'Player', to: '/root', handler: 'onPlayerDown' },
808
+ ],
809
+ };
810
+
811
+ await Bun.write(`${import.meta.dir}/src/game.scene.json`, `${JSON.stringify(scene, null, 2)}\n`);
812
+ console.log(
813
+ `beacon isle generated: keeper ${keeperSite.map(round2)}, wards ${wardSites.map((w) => w.map(round2)).join(' | ')}, ${placed} groves, ${grassPlaced} grass, ${flowersPlaced} flowers`,
814
+ );