incanto 0.14.0 → 0.15.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 (29) hide show
  1. package/dist/3d.d.ts +15 -0
  2. package/dist/3d.js +3 -3
  3. package/dist/{create-game-OSntcIiM.js → create-game-BS-K-GY1.js} +2 -2
  4. package/dist/index.js +1 -1
  5. package/dist/{physics-3d-D_kUdOjr.js → physics-3d-TmOuCy7J.js} +1 -1
  6. package/dist/react.js +1 -1
  7. package/dist/{register-C9-16x_t.js → register-Bh_3Z39N.js} +110 -5
  8. package/dist/test.js +3 -3
  9. package/editor/assets/{agent8-BDC4LhjC.js → agent8-DqQ2CQ5A.js} +1 -1
  10. package/editor/assets/{index-7qOYg7t8.js → index-CofH0yGY.js} +77 -5
  11. package/editor/index.html +1 -1
  12. package/package.json +1 -1
  13. package/schemas/scene.schema.json +16 -0
  14. package/skills/incanto-environment.md +7 -1
  15. package/skills/incanto-node-reference.md +4 -0
  16. package/templates-app/beacon-isle-3d/PROJECT/Status.md +1 -1
  17. package/templates-app/beacon-isle-3d/generate-world.ts +23 -18
  18. package/templates-app/beacon-isle-3d/package.json +1 -1
  19. package/templates-app/beacon-isle-3d/src/behaviors.ts +70 -4
  20. package/templates-app/beacon-isle-3d/src/game.scene.json +20 -21
  21. package/templates-app/tps-3d/package.json +1 -1
  22. package/templates-app/village-quest-3d/PROJECT/Context.md +1 -1
  23. package/templates-app/village-quest-3d/PROJECT/Requirements.md +1 -1
  24. package/templates-app/village-quest-3d/PROJECT/Structure.md +6 -1
  25. package/templates-app/village-quest-3d/generate-dressing.ts +769 -0
  26. package/templates-app/village-quest-3d/package.json +1 -1
  27. package/templates-app/village-quest-3d/src/behaviors.ts +74 -8
  28. package/templates-app/village-quest-3d/src/grove.scene.json +1 -13
  29. package/templates-app/village-quest-3d/src/village.scene.json +2989 -162
@@ -0,0 +1,769 @@
1
+ /**
2
+ * Emberwood VILLAGE dressing generator — run `bun generate-dressing.ts` after
3
+ * editing, output goes into src/village.scene.json (formatted by biome).
4
+ *
5
+ * The gameplay layer (Elder, Player, HUD, GroveGate, connections, input) is
6
+ * PRESERVED verbatim from the existing scene file; only `environment` and the
7
+ * purely-visual `Village` subtree are rebuilt. Behaviors never reach into
8
+ * `Village/**`, so the whole subtree is free to regenerate — that's the
9
+ * template's dressing workflow: tweak a number here, re-run, reload.
10
+ *
11
+ * Everything is engine-primitive built (MeshInstance3D boxes/cylinders/gems,
12
+ * Tree3D, Foliage3D, Flowers3D, InstancedMesh3D) — no external assets, so the
13
+ * whole look survives `incanto-new` scaffolding offline.
14
+ */
15
+ import { readFileSync, writeFileSync } from 'node:fs';
16
+
17
+ type Json = Record<string, unknown>;
18
+ type NodeJson = {
19
+ name: string;
20
+ type: string;
21
+ props?: Json;
22
+ children?: NodeJson[];
23
+ groups?: string[];
24
+ };
25
+
26
+ // ---- tiny builders ----------------------------------------------------------------
27
+
28
+ const round2 = (n: number): number => Math.round(n * 100) / 100;
29
+
30
+ function mesh(
31
+ name: string,
32
+ shape: 'box' | 'cylinder' | 'gem' | 'sphere',
33
+ size: number[],
34
+ material: Json,
35
+ props: Json = {},
36
+ ): NodeJson {
37
+ return {
38
+ name,
39
+ type: 'MeshInstance3D',
40
+ props: {
41
+ mesh: shape,
42
+ size: size.map(round2),
43
+ material,
44
+ castShadow: true,
45
+ receiveShadow: true,
46
+ ...props,
47
+ },
48
+ };
49
+ }
50
+
51
+ // palette — warm late-afternoon timber village
52
+ const TIMBER = { color: '#54402c', roughness: 0.85 };
53
+ const TIMBER_LIGHT = { color: '#6b533a', roughness: 0.85 };
54
+ const STONE = { color: '#8d939c', roughness: 0.95, flatShading: true };
55
+ const PATH_DIRT = '#9b8459';
56
+ const WINDOW_GLOW = {
57
+ color: '#3a2c1c',
58
+ emissive: '#ffc46e',
59
+ emissiveIntensity: 1.7,
60
+ roughness: 0.4,
61
+ };
62
+ const LANTERN_GLOW = {
63
+ color: '#8a5a20',
64
+ emissive: '#ffb84f',
65
+ emissiveIntensity: 2.8,
66
+ roughness: 0.3,
67
+ };
68
+
69
+ /**
70
+ * A timber-framed cottage: plaster walls, corner posts + cross beams, a door
71
+ * with a step, warm emissive windows, two-slab gabled roof with ridge beam,
72
+ * chimney with drifting smoke. `door` picks which face looks at the path.
73
+ */
74
+ function house(
75
+ name: string,
76
+ x: number,
77
+ z: number,
78
+ w: number,
79
+ h: number,
80
+ d: number,
81
+ opts: {
82
+ yaw?: number;
83
+ plaster?: string;
84
+ roof?: string;
85
+ door?: 'north' | 'south' | 'east' | 'west';
86
+ },
87
+ ): NodeJson {
88
+ const plaster = opts.plaster ?? '#d8c9a8';
89
+ const roofColor = opts.roof ?? '#b06a45';
90
+ const yaw = opts.yaw ?? 0;
91
+ const rise = Math.min(1.7, d * 0.34);
92
+ const overhang = 0.45;
93
+ const halfSpan = d / 2 + overhang;
94
+ const slabLen = Math.hypot(halfSpan, rise);
95
+ const pitchDeg = (Math.atan2(rise, halfSpan) * 180) / Math.PI;
96
+
97
+ const children: NodeJson[] = [
98
+ mesh('Walls', 'box', [w, h, d], { color: plaster, roughness: 0.9 }),
99
+ // stepped gable fill under the ridge (the slab overhang hides the steps)
100
+ mesh(
101
+ 'Gable',
102
+ 'box',
103
+ [w, rise, d * 0.56],
104
+ { color: plaster, roughness: 0.9 },
105
+ {
106
+ position: [0, h / 2 + rise / 2, 0],
107
+ },
108
+ ),
109
+ ];
110
+
111
+ // corner posts + top beams (the timber frame that sells the silhouette)
112
+ for (const [sx, sz] of [
113
+ [-1, -1],
114
+ [-1, 1],
115
+ [1, -1],
116
+ [1, 1],
117
+ ] as const) {
118
+ children.push(
119
+ mesh(`Post${sx > 0 ? 'E' : 'W'}${sz > 0 ? 'S' : 'N'}`, 'box', [0.24, h, 0.24], TIMBER, {
120
+ position: [sx * (w / 2 - 0.01), 0, sz * (d / 2 - 0.01)],
121
+ }),
122
+ );
123
+ }
124
+ for (const sz of [-1, 1] as const) {
125
+ children.push(
126
+ mesh(`Beam${sz > 0 ? 'S' : 'N'}`, 'box', [w + 0.06, 0.22, 0.26], TIMBER, {
127
+ position: [0, h / 2 - 0.11, sz * (d / 2 - 0.02)],
128
+ }),
129
+ );
130
+ }
131
+
132
+ // roof: two tilted slabs + ridge, gables on ±x
133
+ for (const s of [-1, 1] as const) {
134
+ children.push(
135
+ mesh(
136
+ s > 0 ? 'RoofS' : 'RoofN',
137
+ 'box',
138
+ [w + 0.9, 0.16, slabLen + 0.15],
139
+ {
140
+ color: roofColor,
141
+ roughness: 0.85,
142
+ flatShading: true,
143
+ },
144
+ {
145
+ position: [0, h / 2 + rise / 2, (s * halfSpan) / 2],
146
+ rotation: [-s * pitchDeg, 0, 0],
147
+ },
148
+ ),
149
+ );
150
+ }
151
+ children.push(
152
+ mesh('Ridge', 'box', [w + 1.0, 0.2, 0.3], TIMBER, { position: [0, h / 2 + rise + 0.02, 0] }),
153
+ );
154
+
155
+ // chimney + smoke — the village is ALIVE
156
+ children.push(
157
+ mesh('Chimney', 'box', [0.55, rise + 1.1, 0.55], STONE, {
158
+ position: [w / 2 - 0.8, h / 2 + (rise + 1.1) / 2 - 0.2, d * 0.18],
159
+ }),
160
+ {
161
+ name: 'Smoke',
162
+ type: 'Particles3D',
163
+ props: {
164
+ position: [w / 2 - 0.8, h / 2 + rise + 1.05, d * 0.18],
165
+ rate: 3.5,
166
+ lifetime: [1.6, 2.6],
167
+ speed: [6, 14],
168
+ directionDeg: -90,
169
+ spreadDeg: 16,
170
+ gravity: [4, -6], // drift with the wind, keep rising
171
+ sizeStart: 12,
172
+ sizeEnd: 34,
173
+ colorStart: '#c9c4bc',
174
+ colorEnd: '#9aa0a8',
175
+ alphaStart: 0.3,
176
+ alphaEnd: 0,
177
+ blend: 'normal',
178
+ maxParticles: 32,
179
+ },
180
+ },
181
+ );
182
+
183
+ // door + step + windows on the path-facing side
184
+ const face = opts.door ?? 'south';
185
+ const axis = face === 'north' || face === 'south' ? 'z' : 'x';
186
+ const sign = face === 'south' || face === 'east' ? 1 : -1;
187
+ const facePos = (off: number, lateral: number, y: number): number[] =>
188
+ axis === 'z' ? [lateral, y, sign * (d / 2 + off)] : [sign * (w / 2 + off), y, lateral];
189
+ const faceRot = axis === 'z' ? [0, 0, 0] : [0, 90, 0];
190
+ const doorY = -h / 2 + 1.05;
191
+ children.push(
192
+ mesh('DoorFrame', 'box', [1.16, 2.26, 0.1], TIMBER, {
193
+ position: facePos(0.02, 0.9, doorY + 0.05),
194
+ rotation: faceRot,
195
+ }),
196
+ mesh('Door', 'box', [0.92, 2.05, 0.12], TIMBER_LIGHT, {
197
+ position: facePos(0.06, 0.9, doorY),
198
+ rotation: faceRot,
199
+ }),
200
+ mesh('Step', 'box', [1.3, 0.16, 0.7], STONE, {
201
+ position: facePos(0.36, 0.9, -h / 2 + 0.08),
202
+ rotation: faceRot,
203
+ }),
204
+ );
205
+ for (const [i, lat] of [-0.95, -2.0].entries()) {
206
+ children.push(
207
+ mesh(`WindowTrim${i + 1}`, 'box', [0.94, 1.04, 0.08], TIMBER, {
208
+ position: facePos(0.02, lat, 0.28),
209
+ rotation: faceRot,
210
+ }),
211
+ mesh(`Window${i + 1}`, 'box', [0.74, 0.84, 0.1], WINDOW_GLOW, {
212
+ position: facePos(0.05, lat, 0.28),
213
+ rotation: faceRot,
214
+ }),
215
+ );
216
+ }
217
+
218
+ return {
219
+ name,
220
+ type: 'StaticBody3D',
221
+ props: {
222
+ collider: { shape: 'box', size: [w, h, d] },
223
+ position: [x, h / 2, z],
224
+ ...(yaw ? { rotation: [0, yaw, 0] } : {}),
225
+ },
226
+ children,
227
+ };
228
+ }
229
+
230
+ /** A lamp post with a hanging glow-gem — pairs with bloom + one OmniLight3D. */
231
+ function lantern(name: string, x: number, z: number, yaw = 0): NodeJson {
232
+ return {
233
+ name,
234
+ type: 'Node3D',
235
+ props: { position: [x, 0, z], rotation: [0, yaw, 0] },
236
+ children: [
237
+ mesh('Post', 'box', [0.16, 2.3, 0.16], TIMBER, { position: [0, 1.15, 0] }),
238
+ mesh('Arm', 'box', [0.62, 0.12, 0.12], TIMBER, { position: [0.26, 2.24, 0] }),
239
+ mesh(
240
+ 'Cage',
241
+ 'box',
242
+ [0.3, 0.4, 0.3],
243
+ { color: '#3c3228', roughness: 0.6 },
244
+ {
245
+ position: [0.5, 2.0, 0],
246
+ },
247
+ ),
248
+ mesh('Glow', 'gem', [0.16, 0.22, 0.16], LANTERN_GLOW, {
249
+ position: [0.5, 2.0, 0],
250
+ props: undefined,
251
+ castShadow: false,
252
+ }),
253
+ ],
254
+ };
255
+ }
256
+
257
+ /** Barrel: staved cylinder + two dark hoops. */
258
+ function barrel(name: string, x: number, z: number): NodeJson {
259
+ return {
260
+ name,
261
+ type: 'Node3D',
262
+ props: { position: [x, 0, z] },
263
+ children: [
264
+ mesh(
265
+ 'Body',
266
+ 'cylinder',
267
+ [0.34, 0.78, 0.34],
268
+ { color: '#7a5c3c', roughness: 0.9 },
269
+ {
270
+ position: [0, 0.39, 0],
271
+ },
272
+ ),
273
+ mesh(
274
+ 'HoopTop',
275
+ 'cylinder',
276
+ [0.36, 0.06, 0.36],
277
+ { color: '#3a332c', roughness: 0.5, metalness: 0.6 },
278
+ {
279
+ position: [0, 0.62, 0],
280
+ castShadow: false,
281
+ },
282
+ ),
283
+ mesh(
284
+ 'HoopBottom',
285
+ 'cylinder',
286
+ [0.36, 0.06, 0.36],
287
+ { color: '#3a332c', roughness: 0.5, metalness: 0.6 },
288
+ {
289
+ position: [0, 0.18, 0],
290
+ castShadow: false,
291
+ },
292
+ ),
293
+ ],
294
+ };
295
+ }
296
+
297
+ function crate(name: string, x: number, z: number, s: number, yaw: number): NodeJson {
298
+ return mesh(
299
+ name,
300
+ 'box',
301
+ [s, s, s],
302
+ { color: '#9c7c50', roughness: 0.9, flatShading: true },
303
+ {
304
+ position: [x, s / 2, z],
305
+ rotation: [0, yaw, 0],
306
+ },
307
+ );
308
+ }
309
+
310
+ // ---- assemble the Village subtree ---------------------------------------------------
311
+
312
+ const villageChildren: NodeJson[] = [];
313
+
314
+ // ground: meadow green with a receiving floor collider
315
+ villageChildren.push({
316
+ name: 'Floor',
317
+ type: 'StaticBody3D',
318
+ props: { collider: { shape: 'box', size: [64, 0.1, 64] }, position: [0, -0.05, 0] },
319
+ children: [
320
+ mesh('Grass', 'box', [64, 0.1, 64], { color: '#57713f', roughness: 1 }, { castShadow: false }),
321
+ ],
322
+ });
323
+
324
+ // packed-dirt path + plaza + edge pebbles
325
+ villageChildren.push(
326
+ mesh(
327
+ 'Path',
328
+ 'box',
329
+ [3, 0.12, 40],
330
+ { color: PATH_DIRT, roughness: 1 },
331
+ {
332
+ position: [0, 0.01, -4],
333
+ castShadow: false,
334
+ },
335
+ ),
336
+ mesh(
337
+ 'Plaza',
338
+ 'cylinder',
339
+ [5.4, 0.12, 5.4],
340
+ { color: '#a68d63', roughness: 1 },
341
+ {
342
+ position: [1.5, 0.015, 6],
343
+ castShadow: false,
344
+ },
345
+ ),
346
+ );
347
+ {
348
+ const pebbles: number[][] = [];
349
+ let n = 0;
350
+ for (let z = -24; z <= -2; z += 1.6) {
351
+ for (const side of [-1, 1]) {
352
+ const jitter = Math.sin(z * 12.9898 + side * 78.233) * 0.35;
353
+ pebbles.push([
354
+ side * (1.75 + Math.abs(jitter) * 0.4),
355
+ 0.05,
356
+ z + jitter,
357
+ (z * 37 + n * 61) % 360,
358
+ 0.8 + ((n * 7) % 5) * 0.12,
359
+ ]);
360
+ n++;
361
+ }
362
+ }
363
+ villageChildren.push({
364
+ name: 'PathStones',
365
+ type: 'InstancedMesh3D',
366
+ props: {
367
+ mesh: 'gem',
368
+ size: [0.11, 0.07, 0.09],
369
+ material: { color: '#8d939c', roughness: 0.95, flatShading: true },
370
+ transforms: pebbles.map((r) => r.map(round2)),
371
+ receiveShadow: true,
372
+ },
373
+ });
374
+ }
375
+
376
+ // cottages — two collidable originals + three background ones closing the square
377
+ villageChildren.push(
378
+ house('House1', -10, 2, 6, 3.4, 5, { plaster: '#d8c9a8', roof: '#b06a45', door: 'east', yaw: 8 }),
379
+ house('House2', 11, -2, 5, 3, 6, { plaster: '#cfc2a2', roof: '#8a4f38', door: 'west', yaw: -6 }),
380
+ house('House3', -13, -13, 5.4, 3.1, 5, {
381
+ plaster: '#e0d2b4',
382
+ roof: '#6a6f7a',
383
+ door: 'east',
384
+ yaw: 24,
385
+ }),
386
+ house('House4', 13, -14, 4.6, 2.9, 5.2, {
387
+ plaster: '#d3c5a5',
388
+ roof: '#a05a3c',
389
+ door: 'west',
390
+ yaw: -18,
391
+ }),
392
+ house('House5', -3, 17, 6.2, 3.4, 5.4, {
393
+ plaster: '#dccdae',
394
+ roof: '#7a4534',
395
+ door: 'north',
396
+ yaw: 3,
397
+ }),
398
+ );
399
+
400
+ // the well — stone ring, twin posts, tiny gabled cap, crossbar + bucket
401
+ villageChildren.push({
402
+ name: 'Well',
403
+ type: 'StaticBody3D',
404
+ props: { collider: { shape: 'box', size: [2.2, 1, 2.2] }, position: [5, 0.5, 8] },
405
+ children: [
406
+ mesh('Ring', 'cylinder', [0.95, 1, 0.95], STONE),
407
+ mesh(
408
+ 'Rim',
409
+ 'cylinder',
410
+ [1.05, 0.14, 1.05],
411
+ { color: '#7a808a', roughness: 0.95, flatShading: true },
412
+ {
413
+ position: [0, 0.55, 0],
414
+ },
415
+ ),
416
+ mesh('PostW', 'box', [0.14, 1.7, 0.14], TIMBER, { position: [-0.95, 0.85, 0] }),
417
+ mesh('PostE', 'box', [0.14, 1.7, 0.14], TIMBER, { position: [0.95, 0.85, 0] }),
418
+ mesh(
419
+ 'CapN',
420
+ 'box',
421
+ [2.5, 0.1, 0.95],
422
+ { color: '#8a4f38', roughness: 0.85, flatShading: true },
423
+ {
424
+ position: [0, 1.85, -0.38],
425
+ rotation: [32, 0, 0],
426
+ },
427
+ ),
428
+ mesh(
429
+ 'CapS',
430
+ 'box',
431
+ [2.5, 0.1, 0.95],
432
+ { color: '#8a4f38', roughness: 0.85, flatShading: true },
433
+ {
434
+ position: [0, 1.85, 0.38],
435
+ rotation: [-32, 0, 0],
436
+ },
437
+ ),
438
+ mesh('Crossbar', 'cylinder', [0.06, 2.0, 0.06], TIMBER_LIGHT, {
439
+ position: [0, 1.45, 0],
440
+ rotation: [0, 0, 90],
441
+ }),
442
+ mesh('Bucket', 'cylinder', [0.18, 0.26, 0.18], TIMBER_LIGHT, { position: [0.3, 0.95, 0] }),
443
+ ],
444
+ });
445
+
446
+ // gate — posts, beam, braces, a lantern each side
447
+ villageChildren.push(
448
+ {
449
+ name: 'GatePostL',
450
+ type: 'StaticBody3D',
451
+ props: { collider: { shape: 'box', size: [0.8, 4, 0.8] }, position: [-2.4, 2, -26] },
452
+ children: [
453
+ mesh('Post', 'box', [0.8, 4, 0.8], TIMBER),
454
+ mesh('Brace', 'box', [0.24, 1.5, 0.24], TIMBER_LIGHT, {
455
+ position: [0.55, 1.45, 0],
456
+ rotation: [0, 0, -38],
457
+ }),
458
+ ],
459
+ },
460
+ {
461
+ name: 'GatePostR',
462
+ type: 'StaticBody3D',
463
+ props: { collider: { shape: 'box', size: [0.8, 4, 0.8] }, position: [2.4, 2, -26] },
464
+ children: [
465
+ mesh('Post', 'box', [0.8, 4, 0.8], TIMBER),
466
+ mesh('Brace', 'box', [0.24, 1.5, 0.24], TIMBER_LIGHT, {
467
+ position: [-0.55, 1.45, 0],
468
+ rotation: [0, 0, 38],
469
+ }),
470
+ ],
471
+ },
472
+ mesh('GateBeam', 'box', [6.4, 0.7, 0.9], TIMBER, { position: [0, 4.1, -26] }),
473
+ mesh(
474
+ 'GateCap',
475
+ 'box',
476
+ [7.2, 0.22, 1.2],
477
+ { color: '#8a4f38', roughness: 0.85, flatShading: true },
478
+ {
479
+ position: [0, 4.55, -26],
480
+ },
481
+ ),
482
+ lantern('GateLampL', -3.2, -25.2, 40),
483
+ lantern('GateLampR', 3.2, -25.2, 140),
484
+ );
485
+
486
+ // fence lines flanking the path (posts instanced, rails as two long boxes each side)
487
+ {
488
+ const posts: number[][] = [];
489
+ for (const side of [-1, 1]) {
490
+ for (let z = -23; z <= -8; z += 1.7) {
491
+ posts.push([side * 2.6, 0.5, z, side * 4, 1]);
492
+ }
493
+ }
494
+ villageChildren.push({
495
+ name: 'FencePosts',
496
+ type: 'InstancedMesh3D',
497
+ props: {
498
+ mesh: 'box',
499
+ size: [0.14, 1.0, 0.14],
500
+ material: { color: '#6b533a', roughness: 0.9 },
501
+ transforms: posts.map((r) => r.map(round2)),
502
+ castShadow: true,
503
+ },
504
+ });
505
+ for (const side of [-1, 1]) {
506
+ for (const [i, y] of [0.42, 0.78].entries()) {
507
+ villageChildren.push(
508
+ mesh(
509
+ `FenceRail${side > 0 ? 'E' : 'W'}${i + 1}`,
510
+ 'box',
511
+ [0.07, 0.1, 15.4],
512
+ {
513
+ color: '#755c40',
514
+ roughness: 0.9,
515
+ },
516
+ { position: [side * 2.6, y, -15.5] },
517
+ ),
518
+ );
519
+ }
520
+ }
521
+ }
522
+
523
+ // square dressing: lamp posts, market stall, barrels, crates
524
+ villageChildren.push(
525
+ lantern('Lamp1', -2.6, 3.4, -30),
526
+ lantern('Lamp2', 4.2, 12.6, 150),
527
+ lantern('Lamp3', -3.4, -7.5, -90),
528
+ {
529
+ name: 'Stall',
530
+ type: 'Node3D',
531
+ props: { position: [-4.6, 0, 8.6], rotation: [0, 18, 0] },
532
+ children: [
533
+ mesh('Counter', 'box', [2.6, 0.9, 1.1], TIMBER_LIGHT, { position: [0, 0.45, 0] }),
534
+ mesh(
535
+ 'Top',
536
+ 'box',
537
+ [2.7, 0.08, 1.2],
538
+ { color: '#8a6a44', roughness: 0.85 },
539
+ {
540
+ position: [0, 0.93, 0],
541
+ },
542
+ ),
543
+ mesh('PoleW', 'box', [0.1, 2.2, 0.1], TIMBER, { position: [-1.2, 1.1, -0.45] }),
544
+ mesh('PoleE', 'box', [0.1, 2.2, 0.1], TIMBER, { position: [1.2, 1.1, -0.45] }),
545
+ mesh(
546
+ 'Canopy',
547
+ 'box',
548
+ [2.9, 0.06, 1.9],
549
+ { color: '#a33d34', roughness: 0.8 },
550
+ {
551
+ position: [0, 2.18, 0.25],
552
+ rotation: [-12, 0, 0],
553
+ castShadow: true,
554
+ },
555
+ ),
556
+ mesh(
557
+ 'CanopyStripe',
558
+ 'box',
559
+ [2.92, 0.061, 0.5],
560
+ { color: '#e8ddc4', roughness: 0.8 },
561
+ {
562
+ position: [0, 2.181, 0.25],
563
+ rotation: [-12, 0, 0],
564
+ castShadow: false,
565
+ },
566
+ ),
567
+ mesh(
568
+ 'Goods1',
569
+ 'sphere',
570
+ [0.16, 0.16, 0.16],
571
+ { color: '#c8542e', roughness: 0.6 },
572
+ {
573
+ position: [-0.5, 1.06, 0.1],
574
+ castShadow: false,
575
+ },
576
+ ),
577
+ mesh(
578
+ 'Goods2',
579
+ 'sphere',
580
+ [0.14, 0.14, 0.14],
581
+ { color: '#d9a032', roughness: 0.6 },
582
+ {
583
+ position: [-0.16, 1.04, -0.2],
584
+ castShadow: false,
585
+ },
586
+ ),
587
+ mesh(
588
+ 'Goods3',
589
+ 'sphere',
590
+ [0.15, 0.15, 0.15],
591
+ { color: '#7ba03a', roughness: 0.6 },
592
+ {
593
+ position: [0.2, 1.05, 0.14],
594
+ castShadow: false,
595
+ },
596
+ ),
597
+ ],
598
+ },
599
+ barrel('Barrel1', -7.2, 4.2),
600
+ barrel('Barrel2', -6.6, 4.9),
601
+ barrel('Barrel3', 8.9, -4.6),
602
+ crate('Crate1', 8.2, -5.4, 0.62, 14),
603
+ crate('Crate2', 8.5, -4.5, 0.5, -8),
604
+ crate('Crate3', -7.0, 5.8, 0.55, 32),
605
+ );
606
+
607
+ // meadow grass + flower beds (flat ground: fields sit at y = 0 — no terrain, no drape)
608
+ const grassField = (
609
+ name: string,
610
+ x: number,
611
+ z: number,
612
+ ax: number,
613
+ az: number,
614
+ seed: number,
615
+ ): NodeJson => ({
616
+ name,
617
+ type: 'Foliage3D',
618
+ props: {
619
+ kind: 'grass',
620
+ style: 'mesh',
621
+ position: [x, 0, z],
622
+ area: [ax, az],
623
+ density: 9,
624
+ height: 0.22,
625
+ coverage: 0.78,
626
+ seed,
627
+ },
628
+ });
629
+ villageChildren.push(
630
+ grassField('MeadowW', -17, -2, 26, 34, 101),
631
+ grassField('MeadowE', 18, -1, 24, 34, 202),
632
+ grassField('MeadowN', 2, 21, 42, 16, 303),
633
+ grassField('MeadowGate', 0, -21.5, 30, 12, 404),
634
+ {
635
+ name: 'FlowersWell',
636
+ type: 'Flowers3D',
637
+ props: {
638
+ position: [6.8, 0, 9.6],
639
+ area: [2.6, 2.2],
640
+ density: 'lush',
641
+ seed: 7,
642
+ palette: ['#e4572e', '#f3a712', '#ffffff'],
643
+ },
644
+ },
645
+ {
646
+ name: 'FlowersH1',
647
+ type: 'Flowers3D',
648
+ props: {
649
+ position: [-9.4, 0, 5.6],
650
+ area: [3.4, 1.4],
651
+ density: 'lush',
652
+ seed: 17,
653
+ palette: ['#a26bd4', '#ffffff', '#f3a712'],
654
+ },
655
+ },
656
+ {
657
+ name: 'FlowersH2',
658
+ type: 'Flowers3D',
659
+ props: {
660
+ position: [8.2, -0, -0.4],
661
+ area: [3, 1.3],
662
+ density: 'lush',
663
+ seed: 27,
664
+ palette: ['#e4572e', '#ffffff'],
665
+ },
666
+ },
667
+ {
668
+ name: 'FlowersGate',
669
+ type: 'Flowers3D',
670
+ props: {
671
+ position: [-4.4, 0, -24.4],
672
+ area: [2.4, 1.6],
673
+ density: 'lush',
674
+ seed: 37,
675
+ palette: ['#f3a712', '#a26bd4'],
676
+ },
677
+ },
678
+ );
679
+
680
+ // trees — broadleaf oaks by the square, mixed perimeter treeline, bushes at houses
681
+ villageChildren.push(
682
+ {
683
+ name: 'Oak1',
684
+ type: 'Tree3D',
685
+ props: { position: [-16, 0, -10], type: 'broadleaf', seed: 11, scale: [1.2, 1.2, 1.2] },
686
+ },
687
+ { name: 'Oak2', type: 'Tree3D', props: { position: [17, 0, 6], type: 'broadleaf', seed: 23 } },
688
+ { name: 'Oak3', type: 'Tree3D', props: { position: [-14, 0, 14], type: 'broadleaf', seed: 31 } },
689
+ {
690
+ name: 'Oak4',
691
+ type: 'Tree3D',
692
+ props: { position: [14, 0, -14], type: 'broadleaf', seed: 47, scale: [1.35, 1.35, 1.35] },
693
+ },
694
+ {
695
+ name: 'TreelineW',
696
+ type: 'Tree3D',
697
+ props: { position: [-27, 0, -4], type: 'conifer', count: 7, area: [8, 40], seed: 61 },
698
+ },
699
+ {
700
+ name: 'TreelineE',
701
+ type: 'Tree3D',
702
+ props: { position: [27, 0, -2], type: 'conifer', count: 7, area: [8, 40], seed: 71 },
703
+ },
704
+ {
705
+ name: 'TreelineN',
706
+ type: 'Tree3D',
707
+ props: { position: [4, 0, 27], type: 'broadleaf', count: 6, area: [40, 7], seed: 81 },
708
+ },
709
+ {
710
+ name: 'TreelineGate',
711
+ type: 'Tree3D',
712
+ props: { position: [-14, 0, -27], type: 'conifer', count: 4, area: [22, 6], seed: 91 },
713
+ },
714
+ {
715
+ name: 'TreelineGateE',
716
+ type: 'Tree3D',
717
+ props: { position: [14, 0, -27], type: 'conifer', count: 4, area: [20, 6], seed: 95 },
718
+ },
719
+ { name: 'Bush1', type: 'Tree3D', props: { position: [-7.4, 0, 0.4], type: 'bush', seed: 5 } },
720
+ { name: 'Bush2', type: 'Tree3D', props: { position: [9.2, 0, 1.8], type: 'bush', seed: 15 } },
721
+ { name: 'Bush3', type: 'Tree3D', props: { position: [1.6, 0, -24.2], type: 'bush', seed: 25 } },
722
+ );
723
+
724
+ // light rig: the warm sun + one omni fill at the square (lantern glow anchor)
725
+ villageChildren.push(
726
+ {
727
+ name: 'Sun',
728
+ type: 'DirectionalLight3D',
729
+ props: {
730
+ position: [7, 30, 22],
731
+ intensity: 2.4,
732
+ color: '#ffe2b8',
733
+ castShadow: true,
734
+ shadowArea: 56,
735
+ },
736
+ },
737
+ {
738
+ name: 'SquareGlow',
739
+ type: 'OmniLight3D',
740
+ props: { position: [1.5, 2.6, 6], color: '#ffc98a', intensity: 14, range: 16 },
741
+ },
742
+ );
743
+
744
+ // ---- splice into the hand-authored scene -------------------------------------------
745
+
746
+ const scenePath = new URL('src/village.scene.json', import.meta.url).pathname;
747
+ const scene = JSON.parse(readFileSync(scenePath, 'utf8'));
748
+
749
+ scene.environment = {
750
+ sky: { type: 'atmosphere', elevationDeg: 38, azimuthDeg: 8, turbidity: 2.0, rayleigh: 1.2 },
751
+ fog: { near: 55, far: 190 },
752
+ shadows: { mapSize: 2048, radius: 1.1 },
753
+ exposure: 1.0,
754
+ ambient: { color: '#e8d5c0', intensity: 0.52 },
755
+ iblIntensity: 0.65,
756
+ bloom: { threshold: 1.35, strength: 0.4 },
757
+ post: { vignette: 0.22, saturation: 1.1, contrast: 1.03 },
758
+ };
759
+
760
+ const game = scene.root;
761
+ const village = (game.children as NodeJson[]).find((c) => c.name === 'Village');
762
+ if (!village) throw new Error('Village node not found');
763
+ village.children = villageChildren;
764
+ // chimney smoke is a render-hook Particles3D — a static wrapper would freeze
765
+ // it after one frame (auditScene flags exactly this), so the village stays live
766
+ if (village.props) delete (village.props as Record<string, unknown>).static;
767
+
768
+ writeFileSync(scenePath, `${JSON.stringify(scene, null, 2)}\n`);
769
+ console.log(`village dressed: ${villageChildren.length} top-level nodes`);