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