incanto 0.1.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 (138) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.md +88 -0
  4. package/assets/audio/attacked.mp3 +0 -0
  5. package/assets/audio/explosion.mp3 +0 -0
  6. package/assets/audio/gold_loot.mp3 +0 -0
  7. package/assets/audio/heal.mp3 +0 -0
  8. package/assets/audio/hit_metal_bang.mp3 +0 -0
  9. package/assets/audio/ice_spear.mp3 +0 -0
  10. package/assets/audio/monster_died.mp3 +0 -0
  11. package/assets/audio/slash.mp3 +0 -0
  12. package/assets/audio/smite.mp3 +0 -0
  13. package/assets/audio/spells_cast.mp3 +0 -0
  14. package/assets/audio/ui_click.wav +0 -0
  15. package/assets/audio/walk.mp3 +0 -0
  16. package/assets/catalog.json +390 -0
  17. package/assets/characters/2dbasic.json +41 -0
  18. package/assets/characters/2dbasic.png +0 -0
  19. package/assets/characters/ghost.json +46 -0
  20. package/assets/characters/ghost.png +0 -0
  21. package/assets/characters/goblin.json +40 -0
  22. package/assets/characters/goblin.png +0 -0
  23. package/assets/characters/medieval-knight.json +41 -0
  24. package/assets/characters/medieval-knight.png +0 -0
  25. package/assets/effects/swoosh.png +0 -0
  26. package/assets/items/box.png +0 -0
  27. package/assets/items/buff_potion.png +0 -0
  28. package/assets/items/coin.png +0 -0
  29. package/assets/items/gem.png +0 -0
  30. package/assets/items/gold.png +0 -0
  31. package/assets/items/hp_potion.png +0 -0
  32. package/assets/items/locked_item_box.png +0 -0
  33. package/assets/items/map.png +0 -0
  34. package/assets/items/resurrection_potion.png +0 -0
  35. package/assets/items/super_box.png +0 -0
  36. package/assets/items/trap.png +0 -0
  37. package/assets/tiles/floor00.jpg +0 -0
  38. package/assets/tiles/minecraft-tiles.png +0 -0
  39. package/assets/tiles/wall00.jpg +0 -0
  40. package/assets/vegetation/ash_color.png +0 -0
  41. package/assets/vegetation/aspen_color.png +0 -0
  42. package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
  43. package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
  44. package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
  45. package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
  46. package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
  47. package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
  48. package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
  49. package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
  50. package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
  51. package/assets/vegetation/ground/dirt_color.jpg +0 -0
  52. package/assets/vegetation/ground/dirt_normal.jpg +0 -0
  53. package/assets/vegetation/ground/grass.jpg +0 -0
  54. package/assets/vegetation/oak_color.png +0 -0
  55. package/assets/vegetation/pine_color.png +0 -0
  56. package/bin/incanto-assets.mjs +107 -0
  57. package/bin/incanto-check.mjs +107 -0
  58. package/bin/incanto-editor.mjs +343 -0
  59. package/bin/incanto-env.mjs +144 -0
  60. package/bin/incanto-model.mjs +296 -0
  61. package/bin/incanto-play.mjs +219 -0
  62. package/bin/incanto-skills.mjs +71 -0
  63. package/dist/2d.d.ts +642 -0
  64. package/dist/2d.js +44 -0
  65. package/dist/3d.d.ts +1860 -0
  66. package/dist/3d.js +5 -0
  67. package/dist/agent8-DzU2fFyH.js +129 -0
  68. package/dist/audio-player-DqUR3XFs.d.ts +110 -0
  69. package/dist/behavior-BAQq7HGM.d.ts +851 -0
  70. package/dist/create-game-BdjpTHrW.js +1725 -0
  71. package/dist/create-game-CZHROKcT.js +527 -0
  72. package/dist/debug-draw-CZmOYjL2.js +13 -0
  73. package/dist/debug.d.ts +66 -0
  74. package/dist/debug.js +658 -0
  75. package/dist/duplicate-DP2WPYom.js +22 -0
  76. package/dist/env.d.ts +430 -0
  77. package/dist/env.js +3152 -0
  78. package/dist/errors-BMFaY68Q.d.ts +33 -0
  79. package/dist/errors-BpWbnbb_.js +13 -0
  80. package/dist/gameplay-Ccruc3Wd.js +1501 -0
  81. package/dist/gameplay.d.ts +543 -0
  82. package/dist/gameplay.js +2 -0
  83. package/dist/heightmap-CroQPEER.js +185 -0
  84. package/dist/index.d.ts +305 -0
  85. package/dist/index.js +62 -0
  86. package/dist/json-BLk7H2Qa.js +30 -0
  87. package/dist/loader-CGs_G-r0.js +919 -0
  88. package/dist/loader-Mo0KghCv.d.ts +41 -0
  89. package/dist/net.d.ts +427 -0
  90. package/dist/net.js +772 -0
  91. package/dist/noise-CGUMx44x.js +82 -0
  92. package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
  93. package/dist/particle-sim-DYuSUxvK.js +1319 -0
  94. package/dist/physics-2d-KuMWPTf6.js +288 -0
  95. package/dist/physics-3d-Dl67vOLT.js +434 -0
  96. package/dist/react.d.ts +65 -0
  97. package/dist/react.js +209 -0
  98. package/dist/register-BuUV1_KB.js +561 -0
  99. package/dist/register-CNlYAS1_.js +10634 -0
  100. package/dist/register-DPEV9_9t.js +851 -0
  101. package/dist/register-Dasmnurl.js +374 -0
  102. package/dist/registry-BVJ2HbCn.js +132 -0
  103. package/dist/rng-DP-SR7eg.js +38 -0
  104. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  105. package/dist/schema-CcoWb32N.d.ts +104 -0
  106. package/dist/test.d.ts +158 -0
  107. package/dist/test.js +275 -0
  108. package/dist/touch-031PxtCR.js +208 -0
  109. package/dist/vite.d.ts +26 -0
  110. package/dist/vite.js +57 -0
  111. package/editor/assets/GameServer-C56iOUgF.js +1 -0
  112. package/editor/assets/agent8-Bp7QFI7v.js +1 -0
  113. package/editor/assets/index-DF3tMeKJ.css +1 -0
  114. package/editor/assets/index-Dl2pjA8e.js +7365 -0
  115. package/editor/assets/rapier-CEuLKeCu.js +1 -0
  116. package/editor/assets/rapier-DE6a0vmv.js +1 -0
  117. package/editor/index.html +169 -0
  118. package/package.json +97 -0
  119. package/schemas/scene.schema.json +4254 -0
  120. package/skills/README.md +9 -0
  121. package/skills/incanto-3d-character.md +229 -0
  122. package/skills/incanto-3d-models.md +151 -0
  123. package/skills/incanto-assets.md +118 -0
  124. package/skills/incanto-audio.md +309 -0
  125. package/skills/incanto-behaviors-and-scripts.md +169 -0
  126. package/skills/incanto-building-2d-games.md +242 -0
  127. package/skills/incanto-building-3d-games.md +245 -0
  128. package/skills/incanto-editor.md +163 -0
  129. package/skills/incanto-environment.md +743 -0
  130. package/skills/incanto-gameplay-behaviors.md +707 -0
  131. package/skills/incanto-multiplayer.md +264 -0
  132. package/skills/incanto-node-reference.md +797 -0
  133. package/skills/incanto-physics-and-input.md +164 -0
  134. package/skills/incanto-scene-json-authoring.md +325 -0
  135. package/skills/incanto-verifying-your-game.md +191 -0
  136. package/skills/incanto-web-integration.md +96 -0
  137. package/templates/agent8-server.js +84 -0
  138. package/templates/agent8-server.ts +138 -0
package/dist/env.js ADDED
@@ -0,0 +1,3152 @@
1
+ import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
+ import { t as Rng } from "./rng-DP-SR7eg.js";
3
+ import { t as createNoise2D } from "./noise-CGUMx44x.js";
4
+ import { t as buildHeightmap } from "./heightmap-CroQPEER.js";
5
+ //#region src/env/round.ts
6
+ /** 2-decimal rounding — keeps generated JSON readable without hurting determinism. */
7
+ function round2(value) {
8
+ return Math.round(value * 100) / 100;
9
+ }
10
+ //#endregion
11
+ //#region src/env/pieces.ts
12
+ /**
13
+ * Shared building blocks for the environment generators — the canonical
14
+ * visible+collidable compositions and the recurring primitive props (trees,
15
+ * boulders, sky lights). Internal to `incanto/env`; not exported from the
16
+ * package surface.
17
+ */
18
+ /** StaticBody3D box collider + MeshInstance3D 'Skin' child of the same size. */
19
+ function staticBox(name, size, position, skin, rotation) {
20
+ return {
21
+ name,
22
+ type: "StaticBody3D",
23
+ props: {
24
+ collider: {
25
+ shape: "box",
26
+ size
27
+ },
28
+ position,
29
+ ...rotation ? { rotation } : {}
30
+ },
31
+ children: [{
32
+ name: "Skin",
33
+ type: "MeshInstance3D",
34
+ props: {
35
+ mesh: "box",
36
+ size,
37
+ ...skin
38
+ }
39
+ }]
40
+ };
41
+ }
42
+ const TRUNK_COLORS = [
43
+ "#6b4a2b",
44
+ "#7a5230",
45
+ "#5d4023"
46
+ ];
47
+ const CANOPY_COLORS = [
48
+ "#3f6b2f",
49
+ "#4f8a3d",
50
+ "#2f5d28",
51
+ "#5b8266"
52
+ ];
53
+ /**
54
+ * A primitive tree: a StaticBody3D trunk collider with a cylinder 'Trunk' and
55
+ * sphere 'Canopy' MeshInstance3D skin (no cone primitive exists — the sphere
56
+ * canopy IS the house look).
57
+ */
58
+ function tree(index, x, z, rng) {
59
+ const trunkHeight = round2(rng.range(1.6, 2.8));
60
+ const trunkRadius = round2(rng.range(.12, .22));
61
+ const canopyRadius = round2(rng.range(.9, 1.6));
62
+ return {
63
+ name: `Tree${index}`,
64
+ type: "StaticBody3D",
65
+ props: {
66
+ position: [
67
+ x,
68
+ 0,
69
+ z
70
+ ],
71
+ collider: {
72
+ shape: "box",
73
+ size: [
74
+ round2(trunkRadius * 2),
75
+ trunkHeight,
76
+ round2(trunkRadius * 2)
77
+ ],
78
+ offset: [
79
+ 0,
80
+ round2(trunkHeight / 2),
81
+ 0
82
+ ]
83
+ }
84
+ },
85
+ children: [{
86
+ name: "Trunk",
87
+ type: "MeshInstance3D",
88
+ props: {
89
+ mesh: "cylinder",
90
+ size: [
91
+ trunkRadius,
92
+ trunkHeight,
93
+ trunkRadius
94
+ ],
95
+ position: [
96
+ 0,
97
+ round2(trunkHeight / 2),
98
+ 0
99
+ ],
100
+ material: {
101
+ color: rng.pick(TRUNK_COLORS),
102
+ roughness: 1
103
+ },
104
+ castShadow: true
105
+ }
106
+ }, {
107
+ name: "Canopy",
108
+ type: "MeshInstance3D",
109
+ props: {
110
+ mesh: "sphere",
111
+ size: [
112
+ canopyRadius,
113
+ canopyRadius,
114
+ canopyRadius
115
+ ],
116
+ position: [
117
+ 0,
118
+ round2(trunkHeight + canopyRadius * .6),
119
+ 0
120
+ ],
121
+ material: {
122
+ color: rng.pick(CANOPY_COLORS),
123
+ roughness: 1
124
+ },
125
+ castShadow: true
126
+ }
127
+ }]
128
+ };
129
+ }
130
+ /** A boulder: a sphere mesh squashed on y, slightly sunken, gray-jittered —
131
+ * or tinted by an explicit `color` (the forest's mossy variant). */
132
+ function rock(index, x, z, rng, sizeRange, color) {
133
+ const radius = round2(rng.range(sizeRange[0], sizeRange[1]));
134
+ const squash = round2(rng.range(.5, .7));
135
+ return {
136
+ name: `Rock${index}`,
137
+ type: "MeshInstance3D",
138
+ props: {
139
+ mesh: "sphere",
140
+ size: [
141
+ 1,
142
+ 1,
143
+ 1
144
+ ],
145
+ position: [
146
+ x,
147
+ round2(radius * squash * .6),
148
+ z
149
+ ],
150
+ rotation: [
151
+ 0,
152
+ rng.int(0, 359),
153
+ 0
154
+ ],
155
+ scale: [
156
+ radius,
157
+ round2(radius * squash),
158
+ round2(radius * rng.range(.8, 1.1))
159
+ ],
160
+ material: {
161
+ color: color ?? grayTone(rng),
162
+ roughness: 1
163
+ },
164
+ castShadow: true
165
+ }
166
+ };
167
+ }
168
+ /** A deterministic stony gray — the "slight color jitter" for boulders. */
169
+ function grayTone(rng) {
170
+ const hex = Math.round(rng.range(110, 160)).toString(16).padStart(2, "0");
171
+ return `#${hex}${hex}${hex}`;
172
+ }
173
+ /** Sun + cool fill — the standard outdoor key/fill pair (same recipe as arena). */
174
+ function skyLights(span) {
175
+ return [{
176
+ name: "Sun",
177
+ type: "DirectionalLight3D",
178
+ props: {
179
+ position: [
180
+ round2(span * .8),
181
+ round2(span * 1.5),
182
+ round2(span * .6)
183
+ ],
184
+ intensity: 1,
185
+ castShadow: true,
186
+ shadowArea: round2(span * 1.2)
187
+ }
188
+ }, {
189
+ name: "FillLight",
190
+ type: "DirectionalLight3D",
191
+ props: {
192
+ position: [
193
+ round2(-span * .8),
194
+ round2(span * .8),
195
+ round2(-span * .6)
196
+ ],
197
+ intensity: .4,
198
+ color: "#b9d4ff"
199
+ }
200
+ }];
201
+ }
202
+ /** Normalize a square-number-or-[x, z] extent (the CLI passes a single number). */
203
+ function extent(area, fallback) {
204
+ if (area === void 0) return fallback;
205
+ return typeof area === "number" ? [area, area] : area;
206
+ }
207
+ function clamp(value, min, max) {
208
+ return Math.min(Math.max(value, min), max);
209
+ }
210
+ //#endregion
211
+ //#region src/env/arena.ts
212
+ /** Valid `generateArena` themes — drives the catalog options AND validation. */
213
+ const ARENA_THEMES = [
214
+ "boxes",
215
+ "ruins",
216
+ "garden"
217
+ ];
218
+ const WALL_THICKNESS = .5;
219
+ const FLOOR_THICKNESS$1 = .1;
220
+ const OBSTACLE_MARGIN = 2;
221
+ const PALETTES = {
222
+ boxes: {
223
+ floor: "#3f3f3f",
224
+ wall: "#55504a",
225
+ obstacles: [
226
+ "#b0413e",
227
+ "#5b8266",
228
+ "#3e6990",
229
+ "#a26b38",
230
+ "#6d5a96",
231
+ "#878787"
232
+ ]
233
+ },
234
+ ruins: {
235
+ floor: "#7d766b",
236
+ wall: "#8a8378",
237
+ obstacles: [
238
+ "#8a8378",
239
+ "#979085",
240
+ "#a39a8d",
241
+ "#7b746a"
242
+ ]
243
+ },
244
+ garden: {
245
+ floor: "#4d7c3a",
246
+ wall: "#2f6b2f",
247
+ obstacles: [
248
+ "#2f6b2f",
249
+ "#3a7a38",
250
+ "#356e33"
251
+ ]
252
+ }
253
+ };
254
+ /**
255
+ * A 3D FPS stage: floor + 4 perimeter walls + N obstacles (every body a
256
+ * StaticBody3D box collider with a MeshInstance3D 'Skin' child — the
257
+ * canonical visible+collidable composition) + sun/fill/lamp lighting, dressed
258
+ * by `theme`: 'boxes' strews colorful crates, 'ruins' lays broken stone
259
+ * colonnade rows, 'garden' grows hedge blocks plus Foliage3D grass patches
260
+ * and a Water3D pool at the center.
261
+ */
262
+ function generateArena(opts) {
263
+ const { seed, width = 30, depth = 30, wallHeight = 3, obstacles = 8, theme = "boxes" } = opts;
264
+ if (!ARENA_THEMES.includes(theme)) throw new IncantoError("BAD_FORMAT", `generateArena theme must be one of [${ARENA_THEMES.join(", ")}], got '${theme}'.`, {
265
+ prop: "theme",
266
+ validOptions: [...ARENA_THEMES]
267
+ });
268
+ const palette = PALETTES[theme];
269
+ const rng = new Rng(seed);
270
+ const children = [staticBox("Floor", [
271
+ width,
272
+ FLOOR_THICKNESS$1,
273
+ depth
274
+ ], [
275
+ 0,
276
+ -.1 / 2,
277
+ 0
278
+ ], {
279
+ material: {
280
+ color: palette.floor,
281
+ roughness: 1
282
+ },
283
+ receiveShadow: true
284
+ })];
285
+ const wallY = wallHeight / 2;
286
+ const nsSize = [
287
+ width + WALL_THICKNESS * 2,
288
+ wallHeight,
289
+ WALL_THICKNESS
290
+ ];
291
+ const ewSize = [
292
+ WALL_THICKNESS,
293
+ wallHeight,
294
+ depth
295
+ ];
296
+ const wallSkin = {
297
+ material: {
298
+ color: palette.wall,
299
+ roughness: .9
300
+ },
301
+ receiveShadow: true
302
+ };
303
+ children.push(staticBox("Wall1", nsSize, [
304
+ 0,
305
+ wallY,
306
+ -(depth + WALL_THICKNESS) / 2
307
+ ], wallSkin), staticBox("Wall2", nsSize, [
308
+ 0,
309
+ wallY,
310
+ (depth + WALL_THICKNESS) / 2
311
+ ], wallSkin), staticBox("Wall3", ewSize, [
312
+ -(width + WALL_THICKNESS) / 2,
313
+ wallY,
314
+ 0
315
+ ], wallSkin), staticBox("Wall4", ewSize, [
316
+ (width + WALL_THICKNESS) / 2,
317
+ wallY,
318
+ 0
319
+ ], wallSkin));
320
+ if (theme === "ruins") children.push(...ruinRows(rng, palette, width, depth, wallHeight, obstacles));
321
+ else children.push(...scatteredObstacles(rng, palette, width, depth, wallHeight, obstacles, theme));
322
+ if (theme === "garden") children.push(...gardenDressing(rng, width, depth));
323
+ const span = Math.max(width, depth);
324
+ children.push({
325
+ name: "Sun",
326
+ type: "DirectionalLight3D",
327
+ props: {
328
+ position: [
329
+ round2(span * .8),
330
+ round2(span * 1.5),
331
+ round2(span * .6)
332
+ ],
333
+ intensity: 1,
334
+ castShadow: true,
335
+ shadowArea: round2(span * 1.2)
336
+ }
337
+ }, {
338
+ name: "FillLight",
339
+ type: "DirectionalLight3D",
340
+ props: {
341
+ position: [
342
+ round2(-span * .8),
343
+ round2(span * .8),
344
+ round2(-span * .6)
345
+ ],
346
+ intensity: .4,
347
+ color: "#b9d4ff"
348
+ }
349
+ }, {
350
+ name: "Lamp",
351
+ type: "OmniLight3D",
352
+ props: {
353
+ position: [
354
+ 0,
355
+ round2(wallHeight + 2),
356
+ 0
357
+ ],
358
+ intensity: .5,
359
+ color: "#fff3d6",
360
+ range: round2(span)
361
+ }
362
+ });
363
+ return {
364
+ name: "Arena",
365
+ type: "Node3D",
366
+ children
367
+ };
368
+ }
369
+ /** The classic random-crate scatter ('boxes'), reused green for 'garden' hedges. */
370
+ function scatteredObstacles(rng, palette, width, depth, wallHeight, obstacles, theme) {
371
+ const out = [];
372
+ const poolClearance = theme === "garden" ? Math.min(width, depth) * .16 : 0;
373
+ for (let i = 1; i <= obstacles; i++) {
374
+ const size = [
375
+ round2(rng.range(.8, 2.6)),
376
+ round2(rng.range(.8, Math.max(1.2, wallHeight * .8))),
377
+ round2(rng.range(.8, 2.6))
378
+ ];
379
+ let x = round2(rng.range(-(width / 2 - OBSTACLE_MARGIN), width / 2 - OBSTACLE_MARGIN));
380
+ let z = round2(rng.range(-(depth / 2 - OBSTACLE_MARGIN), depth / 2 - OBSTACLE_MARGIN));
381
+ if (poolClearance > 0 && Math.hypot(x, z) < poolClearance + 1.5) {
382
+ const away = Math.max(Math.hypot(x, z), .001);
383
+ x = round2(x / away * (poolClearance + 1.5 + rng.range(0, 2)));
384
+ z = round2(z / away * (poolClearance + 1.5 + rng.range(0, 2)));
385
+ }
386
+ const yaw = rng.int(0, 359);
387
+ out.push(staticBox(`Obstacle${i}`, size, [
388
+ x,
389
+ round2(size[1] / 2),
390
+ z
391
+ ], {
392
+ material: {
393
+ color: rng.pick(palette.obstacles),
394
+ roughness: .8
395
+ },
396
+ castShadow: true
397
+ }, [
398
+ 0,
399
+ yaw,
400
+ 0
401
+ ]));
402
+ }
403
+ return out;
404
+ }
405
+ /**
406
+ * Broken colonnade rows: obstacles become stone pillars laid out along
407
+ * z-rows, with rng-varied heights (toppled stumps between standing columns)
408
+ * and a slight per-pillar drift so the ruin reads weathered, not minted.
409
+ */
410
+ function ruinRows(rng, palette, width, depth, wallHeight, obstacles) {
411
+ const out = [];
412
+ if (obstacles <= 0) return out;
413
+ const rows = Math.max(1, Math.round(Math.sqrt(obstacles / 2)));
414
+ const perRow = Math.ceil(obstacles / rows);
415
+ const usableW = width - OBSTACLE_MARGIN * 2;
416
+ const usableD = depth - OBSTACLE_MARGIN * 2;
417
+ let placed = 0;
418
+ for (let r = 0; r < rows && placed < obstacles; r++) {
419
+ const z = round2(rows === 1 ? 0 : -usableD / 2 + r / (rows - 1) * usableD);
420
+ for (let c = 0; c < perRow && placed < obstacles; c++) {
421
+ placed++;
422
+ const x = round2((perRow === 1 ? 0 : -usableW / 2 + c / (perRow - 1) * usableW) + rng.range(-.4, .4));
423
+ const height = round2(rng.next() > .35 ? rng.range(wallHeight * .7, wallHeight * 1.2) : rng.range(.4, .9));
424
+ const girth = round2(rng.range(.8, 1.2));
425
+ out.push(staticBox(`Obstacle${placed}`, [
426
+ girth,
427
+ height,
428
+ girth
429
+ ], [
430
+ x,
431
+ round2(height / 2),
432
+ round2(z + rng.range(-.4, .4))
433
+ ], {
434
+ material: {
435
+ color: rng.pick(palette.obstacles),
436
+ roughness: .95
437
+ },
438
+ castShadow: true
439
+ }, [
440
+ 0,
441
+ rng.int(-8, 8),
442
+ 0
443
+ ]));
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+ /** Garden extras: Foliage3D grass + LUSH Flowers3D beds around a center pool. */
449
+ function gardenDressing(rng, width, depth) {
450
+ const out = [];
451
+ const poolRadius = Math.min(width, depth) * .16;
452
+ /** A spot whose whole patch clears the center pool. */
453
+ const clearOfPool = (patch) => {
454
+ const reach = Math.min(width, depth) / 2 - patch / 2 - 1;
455
+ let x = round2(rng.range(-reach, reach));
456
+ let z = round2(rng.range(-reach, reach));
457
+ const away = Math.max(Math.hypot(x, z), .001);
458
+ if (away < poolRadius + patch / 2) {
459
+ x = round2(x / away * (poolRadius + patch / 2 + .5));
460
+ z = round2(z / away * (poolRadius + patch / 2 + .5));
461
+ }
462
+ return [x, z];
463
+ };
464
+ for (let i = 1; i <= 3; i++) {
465
+ const patch = round2(Math.min(width, depth) * rng.range(.18, .26));
466
+ const [x, z] = clearOfPool(patch);
467
+ out.push({
468
+ name: `Grass${i}`,
469
+ type: "Foliage3D",
470
+ props: {
471
+ kind: "grass",
472
+ area: [patch, patch],
473
+ density: 10,
474
+ seed: rng.int(1, 1e9),
475
+ position: [
476
+ x,
477
+ 0,
478
+ z
479
+ ]
480
+ }
481
+ });
482
+ }
483
+ for (let i = 1; i <= 2; i++) {
484
+ const patch = round2(Math.min(width, depth) * rng.range(.12, .18));
485
+ const [x, z] = clearOfPool(patch);
486
+ out.push({
487
+ name: `FlowerBed${i}`,
488
+ type: "Flowers3D",
489
+ props: {
490
+ density: "lush",
491
+ clustering: .3,
492
+ area: [patch, patch],
493
+ seed: rng.int(1, 1e9),
494
+ position: [
495
+ x,
496
+ 0,
497
+ z
498
+ ]
499
+ }
500
+ });
501
+ }
502
+ out.push({
503
+ name: "Pool",
504
+ type: "Water3D",
505
+ props: {
506
+ size: [round2(poolRadius * 2), round2(poolRadius * 2)],
507
+ position: [
508
+ 0,
509
+ .3,
510
+ 0
511
+ ],
512
+ waveHeight: .04
513
+ }
514
+ });
515
+ return out;
516
+ }
517
+ //#endregion
518
+ //#region src/env/dungeon-2d.ts
519
+ /** Tile size in px — rooms, corridors and walls snap to this grid. */
520
+ const TILE = 32;
521
+ const ROOM_TILES = [4, 8];
522
+ const FLOOR_COLOR$1 = "#332f3a";
523
+ const WALL_COLOR$1 = "#6b6357";
524
+ /**
525
+ * A roguelike 2D dungeon: rectangular rooms connected by 1-tile L-corridors
526
+ * (horizontal leg, then vertical), rasterized onto a 32px tile grid. Floors
527
+ * are ColorRect2D rects ('Room1'…, 'Corridor1H'/'Corridor1V'…); every
528
+ * non-floor tile touching a floor tile becomes a StaticBody2D wall segment
529
+ * (merged into runs along x) with a ColorRect2D skin. Centered on the origin.
530
+ */
531
+ function generateDungeon2D(opts) {
532
+ const { seed, rooms = 5 } = opts;
533
+ const [width, height] = extent(opts.size, [960, 720]);
534
+ const cols = Math.max(8, Math.floor(width / TILE));
535
+ const rows = Math.max(8, Math.floor(height / TILE));
536
+ const rng = new Rng(seed);
537
+ const placed = [];
538
+ for (let attempt = 0; attempt < rooms * 12 && placed.length < rooms; attempt++) {
539
+ const w = rng.int(ROOM_TILES[0], ROOM_TILES[1]);
540
+ const h = rng.int(ROOM_TILES[0], ROOM_TILES[1]);
541
+ const candidate = {
542
+ x: rng.int(1, Math.max(1, cols - w - 1)),
543
+ y: rng.int(1, Math.max(1, rows - h - 1)),
544
+ w,
545
+ h
546
+ };
547
+ if (placed.some((room) => overlaps(room, candidate, 1))) continue;
548
+ placed.push(candidate);
549
+ }
550
+ const floor = /* @__PURE__ */ new Set();
551
+ const markFloor = (rect) => {
552
+ for (let ty = rect.y; ty < rect.y + rect.h; ty++) for (let tx = rect.x; tx < rect.x + rect.w; tx++) floor.add(`${tx},${ty}`);
553
+ };
554
+ for (const room of placed) markFloor(room);
555
+ const corridors = [];
556
+ for (let i = 1; i < placed.length; i++) {
557
+ const [ax, ay] = center(placed[i - 1]);
558
+ const [bx, by] = center(placed[i]);
559
+ const legH = {
560
+ x: Math.min(ax, bx),
561
+ y: ay,
562
+ w: Math.abs(ax - bx) + 1,
563
+ h: 1
564
+ };
565
+ const legV = {
566
+ x: bx,
567
+ y: Math.min(ay, by),
568
+ w: 1,
569
+ h: Math.abs(ay - by) + 1
570
+ };
571
+ for (const [suffix, rect] of [["H", legH], ["V", legV]]) {
572
+ markFloor(rect);
573
+ if (rect.w > 1 || rect.h > 1) corridors.push({
574
+ name: `Corridor${i}${suffix}`,
575
+ rect
576
+ });
577
+ }
578
+ }
579
+ const wallTiles = /* @__PURE__ */ new Set();
580
+ for (const key of floor) {
581
+ const [tx, ty] = key.split(",").map(Number);
582
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
583
+ const neighbor = `${tx + dx},${ty + dy}`;
584
+ if (!floor.has(neighbor)) wallTiles.add(neighbor);
585
+ }
586
+ }
587
+ const offsetX = -(cols * TILE) / 2;
588
+ const offsetY = -(rows * TILE) / 2;
589
+ const rectNode = (name, rect, color) => ({
590
+ name,
591
+ type: "ColorRect2D",
592
+ props: {
593
+ position: [offsetX + (rect.x + rect.w / 2) * TILE, offsetY + (rect.y + rect.h / 2) * TILE],
594
+ size: [rect.w * TILE, rect.h * TILE],
595
+ color
596
+ }
597
+ });
598
+ const children = placed.map((room, i) => rectNode(`Room${i + 1}`, room, FLOOR_COLOR$1));
599
+ for (const corridor of corridors) children.push(rectNode(corridor.name, corridor.rect, FLOOR_COLOR$1));
600
+ let wallIndex = 0;
601
+ for (let ty = -1; ty <= rows; ty++) {
602
+ let tx = -1;
603
+ while (tx <= cols) {
604
+ if (!wallTiles.has(`${tx},${ty}`)) {
605
+ tx++;
606
+ continue;
607
+ }
608
+ let run = 1;
609
+ while (tx + run <= cols && wallTiles.has(`${tx + run},${ty}`)) run++;
610
+ wallIndex++;
611
+ const size = [run * TILE, TILE];
612
+ children.push({
613
+ name: `Wall${wallIndex}`,
614
+ type: "StaticBody2D",
615
+ props: {
616
+ position: [offsetX + (tx + run / 2) * TILE, offsetY + (ty + .5) * TILE],
617
+ collider: {
618
+ shape: "rect",
619
+ size
620
+ }
621
+ },
622
+ children: [{
623
+ name: "Skin",
624
+ type: "ColorRect2D",
625
+ props: {
626
+ size,
627
+ color: WALL_COLOR$1
628
+ }
629
+ }]
630
+ });
631
+ tx += run;
632
+ }
633
+ }
634
+ return {
635
+ name: "Dungeon",
636
+ type: "Node2D",
637
+ children
638
+ };
639
+ }
640
+ function overlaps(a, b, gap) {
641
+ return a.x - gap < b.x + b.w && a.x + a.w + gap > b.x && a.y - gap < b.y + b.h && a.y + a.h + gap > b.y;
642
+ }
643
+ function center(rect) {
644
+ return [Math.floor(rect.x + rect.w / 2), Math.floor(rect.y + rect.h / 2)];
645
+ }
646
+ //#endregion
647
+ //#region src/env/maze-grid.ts
648
+ const DIRECTIONS = [
649
+ [0, -1],
650
+ [1, 0],
651
+ [0, 1],
652
+ [-1, 0]
653
+ ];
654
+ /**
655
+ * Recursive-backtracker maze (the classic): carve from cell (0,0), always
656
+ * advancing to a random unvisited neighbor and knocking down the wall
657
+ * between, backtracking when stuck — every cell ends up reachable. Then the
658
+ * entrance (west wall of the first cell) and exit (east wall of the last)
659
+ * are opened. Shared by `generateMaze` (3D) and `generateMaze2D`; games can
660
+ * BFS over `cells` for pathing or item placement.
661
+ */
662
+ function carveMaze(rng, cols, rows) {
663
+ const width = 2 * cols + 1;
664
+ const height = 2 * rows + 1;
665
+ const cells = Array.from({ length: height }, () => new Array(width).fill(false));
666
+ const open = (x, z) => {
667
+ cells[z][x] = true;
668
+ };
669
+ open(1, 1);
670
+ const visited = new Set(["0,0"]);
671
+ const stack = [[0, 0]];
672
+ while (stack.length > 0) {
673
+ const [c, r] = stack[stack.length - 1];
674
+ const neighbors = [];
675
+ for (const [dc, dr] of DIRECTIONS) {
676
+ const nc = c + dc;
677
+ const nr = r + dr;
678
+ if (nc >= 0 && nc < cols && nr >= 0 && nr < rows && !visited.has(`${nc},${nr}`)) neighbors.push([nc, nr]);
679
+ }
680
+ if (neighbors.length === 0) {
681
+ stack.pop();
682
+ continue;
683
+ }
684
+ const [nc, nr] = rng.pick(neighbors);
685
+ visited.add(`${nc},${nr}`);
686
+ open(2 * nc + 1, 2 * nr + 1);
687
+ open(c + nc + 1, r + nr + 1);
688
+ stack.push([nc, nr]);
689
+ }
690
+ open(0, 1);
691
+ open(2 * cols, 2 * rows - 1);
692
+ return {
693
+ cols,
694
+ rows,
695
+ cells
696
+ };
697
+ }
698
+ //#endregion
699
+ //#region src/env/maze.ts
700
+ /** Valid `generateMaze` themes — drives the catalog options AND validation. */
701
+ const MAZE_THEMES = [
702
+ "stone",
703
+ "hedge",
704
+ "canyon"
705
+ ];
706
+ const FLOOR_THICKNESS = .1;
707
+ /** Live CDNs the textures stream from (same hosts as Terrain3D's splats and
708
+ * the arena dressing — zero-setup contract). */
709
+ const WALL_TEXTURE_BASE = "https://agent8-games.verse8.io/assets/3D/default/textures/wall";
710
+ const TERRAIN_TEXTURE_BASE = "https://agent8-games.verse8.io/assets/3D/default/textures/terrain";
711
+ /**
712
+ * Theme materials. Tiling rule of thumb (verified at eye level in the
713
+ * playground maze demo): bricks read right at one repeat per 2 m, the terrain
714
+ * sets per 2.5–3.5 m. Box UVs span 0..1 per face, so emit
715
+ * `repeat: [length/tile, height/tile]` per box — run ends compress a little,
716
+ * but they abut other walls/pillars and never read.
717
+ */
718
+ const THEMES$1 = {
719
+ stone: {
720
+ wall: {
721
+ color: "#e8e2d8",
722
+ map: `${WALL_TEXTURE_BASE}/blocks.png`,
723
+ normalMap: `${WALL_TEXTURE_BASE}/blocks_normal.png`,
724
+ tile: 2,
725
+ roughness: .95
726
+ },
727
+ floor: {
728
+ color: "#99938a",
729
+ map: `${TERRAIN_TEXTURE_BASE}/stone.png`,
730
+ normalMap: `${TERRAIN_TEXTURE_BASE}/stone_normal.png`,
731
+ tile: 3,
732
+ roughness: 1
733
+ },
734
+ cap: "#6b5848",
735
+ pillar: "#cfc8bb",
736
+ path: "#665f55",
737
+ sun: {
738
+ color: "#c9d6ea",
739
+ intensity: .75,
740
+ height: .5
741
+ },
742
+ fill: "#9fb4cc",
743
+ mood: {
744
+ sky: {
745
+ elevationDeg: 10,
746
+ azimuthDeg: 150,
747
+ turbidity: 16,
748
+ rayleigh: 3.2
749
+ },
750
+ fog: {
751
+ near: .5,
752
+ far: 3.5,
753
+ color: "#86909c"
754
+ },
755
+ exposure: .82,
756
+ ambient: {
757
+ color: "#c9d4e2",
758
+ intensity: .12
759
+ }
760
+ }
761
+ },
762
+ hedge: {
763
+ wall: {
764
+ color: "#55a83e",
765
+ map: `${TERRAIN_TEXTURE_BASE}/grass.png`,
766
+ normalMap: `${TERRAIN_TEXTURE_BASE}/grass_normal.png`,
767
+ tile: 1.4,
768
+ roughness: 1
769
+ },
770
+ floor: {
771
+ color: "#86b06d",
772
+ map: `${TERRAIN_TEXTURE_BASE}/grass.png`,
773
+ normalMap: `${TERRAIN_TEXTURE_BASE}/grass_normal.png`,
774
+ tile: 3,
775
+ roughness: 1
776
+ },
777
+ pillar: "#3d7531",
778
+ path: "#7d6845",
779
+ sun: {
780
+ color: "#e9eee6",
781
+ intensity: .7,
782
+ height: 1
783
+ },
784
+ fill: "#b9c8b4",
785
+ mood: {
786
+ sky: {
787
+ elevationDeg: 35,
788
+ azimuthDeg: 150,
789
+ turbidity: 18,
790
+ rayleigh: 4.2
791
+ },
792
+ fog: {
793
+ near: .8,
794
+ far: 5,
795
+ color: "#aab8a6"
796
+ },
797
+ exposure: .88,
798
+ ambient: {
799
+ color: "#dde5d8",
800
+ intensity: .15
801
+ }
802
+ }
803
+ },
804
+ canyon: {
805
+ wall: {
806
+ color: "#f0b070",
807
+ map: `${TERRAIN_TEXTURE_BASE}/stone.png`,
808
+ normalMap: `${TERRAIN_TEXTURE_BASE}/stone_normal.png`,
809
+ tile: 2.4,
810
+ roughness: 1
811
+ },
812
+ floor: {
813
+ color: "#e3c193",
814
+ map: `${TERRAIN_TEXTURE_BASE}/sand.png`,
815
+ normalMap: `${TERRAIN_TEXTURE_BASE}/sand_normal.png`,
816
+ tile: 3.5,
817
+ roughness: 1
818
+ },
819
+ pillar: "#d8a868",
820
+ path: "#a98a58",
821
+ sun: {
822
+ color: "#ffb572",
823
+ intensity: 1.15,
824
+ height: .35
825
+ },
826
+ fill: "#caa37e",
827
+ mood: {
828
+ sky: {
829
+ elevationDeg: 9,
830
+ azimuthDeg: 230,
831
+ turbidity: 9,
832
+ rayleigh: 3.5
833
+ },
834
+ fog: {
835
+ near: .7,
836
+ far: 4.5,
837
+ color: "#c79c6e"
838
+ },
839
+ exposure: .92,
840
+ ambient: {
841
+ color: "#ffdcb6",
842
+ intensity: .13
843
+ }
844
+ }
845
+ }
846
+ };
847
+ /** Span of the default 8×8/cellSize-2 maze — fog multiples are tuned vs it. */
848
+ const DEFAULT_SPAN = 34;
849
+ /**
850
+ * The environment header that matches a maze theme — the terrainEnvironment
851
+ * contract (spread into the scene's `environment`; scene keys layer on top),
852
+ * tuned MOODY at corridor level: low hazy sun, sub-1 exposure, dim ambient
853
+ * and a fog window that closes in on the walls. 'stone' is the coolest and
854
+ * foggiest (dungeon courtyard), 'hedge' an overcast garden, 'canyon' a warm
855
+ * dusk. `span` is the maze's world extent ((2·width+1)·cellSize, default 34).
856
+ */
857
+ function mazeEnvironment(theme, span = DEFAULT_SPAN) {
858
+ const spec = THEMES$1[theme];
859
+ if (!spec) throw new IncantoError("BAD_FORMAT", `mazeEnvironment theme must be one of [${MAZE_THEMES.join(", ")}], got '${theme}'.`, {
860
+ prop: "theme",
861
+ validOptions: [...MAZE_THEMES]
862
+ });
863
+ const { mood } = spec;
864
+ return {
865
+ sky: {
866
+ type: "atmosphere",
867
+ ...mood.sky
868
+ },
869
+ fog: {
870
+ near: round2(span * mood.fog.near),
871
+ far: round2(span * mood.fog.far),
872
+ color: mood.fog.color
873
+ },
874
+ shadows: true,
875
+ exposure: mood.exposure,
876
+ ambient: {
877
+ color: mood.ambient.color,
878
+ intensity: mood.ambient.intensity
879
+ }
880
+ };
881
+ }
882
+ /** Material JSON for a textured surface over a box of [length × height]. */
883
+ function surfaceMaterial(surface, length, height) {
884
+ return {
885
+ color: surface.color,
886
+ roughness: surface.roughness,
887
+ map: surface.map,
888
+ ...surface.normalMap ? { normalMap: surface.normalMap } : {},
889
+ repeat: [round2(length / surface.tile), round2(height / surface.tile)]
890
+ };
891
+ }
892
+ /** Junction pillars per maze (caps keep VRAM sane: every textured box loads
893
+ * its own GL textures — see docs/ax/LEARNINGS.md). */
894
+ const MAX_PILLARS = 10;
895
+ /**
896
+ * A 3D maze: recursive-backtracker corridors (entrance on the west edge,
897
+ * exit on the east) as chunky one-tile-thick StaticBody3D walls over a
898
+ * textured floor slab, plus sun/fill lights. Consecutive wall tiles merge
899
+ * into single boxes along x to keep the node count down. Same algorithm as
900
+ * `generateMaze2D`; `theme` swaps the materials and dressing over the
901
+ * IDENTICAL carved layout — every theme gets textured walls/floor (worldspace
902
+ * brick/grass/sandstone tiling), junction pillars, and a pillar-framed
903
+ * entrance/exit with a tinted path tile; 'stone' adds coping caps on every
904
+ * run, 'hedge' grows grass strips along the wall tops + corridor patches,
905
+ * 'canyon' perches boulders on the rim. Deterministic from `seed`.
906
+ */
907
+ function generateMaze(opts) {
908
+ const { seed, width = 8, depth = 8, cellSize = 2, wallHeight = 2.5, theme = "stone" } = opts;
909
+ if (!MAZE_THEMES.includes(theme)) throw new IncantoError("BAD_FORMAT", `generateMaze theme must be one of [${MAZE_THEMES.join(", ")}], got '${theme}'.`, {
910
+ prop: "theme",
911
+ validOptions: [...MAZE_THEMES]
912
+ });
913
+ const spec = THEMES$1[theme];
914
+ const rng = new Rng(seed);
915
+ const grid = carveMaze(rng, width, depth);
916
+ const gridWidth = 2 * width + 1;
917
+ const gridDepth = 2 * depth + 1;
918
+ const spanX = round2(gridWidth * cellSize);
919
+ const spanZ = round2(gridDepth * cellSize);
920
+ const tileCenter = (gx, gz) => [round2((gx + .5) * cellSize - spanX / 2), round2((gz + .5) * cellSize - spanZ / 2)];
921
+ const children = [staticBox("Floor", [
922
+ spanX,
923
+ FLOOR_THICKNESS,
924
+ spanZ
925
+ ], [
926
+ 0,
927
+ -.1 / 2,
928
+ 0
929
+ ], {
930
+ material: surfaceMaterial(spec.floor, spanX, spanZ),
931
+ receiveShadow: true
932
+ })];
933
+ const wallRuns = [];
934
+ let wallIndex = 0;
935
+ for (let gz = 0; gz < gridDepth; gz++) {
936
+ let gx = 0;
937
+ while (gx < gridWidth) {
938
+ if (grid.cells[gz]?.[gx]) {
939
+ gx++;
940
+ continue;
941
+ }
942
+ let run = 1;
943
+ while (gx + run < gridWidth && !grid.cells[gz]?.[gx + run]) run++;
944
+ wallRuns.push({
945
+ gx,
946
+ gz,
947
+ run
948
+ });
949
+ wallIndex++;
950
+ const length = round2(run * cellSize);
951
+ children.push(staticBox(`Wall${wallIndex}`, [
952
+ length,
953
+ wallHeight,
954
+ cellSize
955
+ ], [
956
+ round2((gx + run / 2) * cellSize - spanX / 2),
957
+ round2(wallHeight / 2),
958
+ round2((gz + .5) * cellSize - spanZ / 2)
959
+ ], {
960
+ material: surfaceMaterial(spec.wall, length, wallHeight),
961
+ castShadow: true,
962
+ receiveShadow: true
963
+ }));
964
+ gx += run;
965
+ }
966
+ }
967
+ if (spec.cap) children.push(...wallCaps(wallRuns, cellSize, wallHeight, spanX, spanZ, spec.cap));
968
+ children.push(...junctionPillars(rng, grid, cellSize, wallHeight, tileCenter, spec));
969
+ if (theme === "hedge") {
970
+ children.push(...hedgeTops(rng, wallRuns, cellSize, wallHeight, tileCenter));
971
+ children.push(...hedgeGrass(rng, grid, cellSize, tileCenter, width, depth));
972
+ } else if (theme === "canyon") children.push(...rimRocks(rng, wallRuns, cellSize, wallHeight, tileCenter));
973
+ children.push(...gates(grid, cellSize, wallHeight, tileCenter, spec));
974
+ children.push(...skyLights(Math.max(spanX, spanZ)).map((light, i) => {
975
+ if (i !== 0) return {
976
+ ...light,
977
+ props: {
978
+ ...light.props,
979
+ color: spec.fill
980
+ }
981
+ };
982
+ const position = light.props?.position;
983
+ return {
984
+ ...light,
985
+ props: {
986
+ ...light.props,
987
+ color: spec.sun.color,
988
+ intensity: spec.sun.intensity,
989
+ position: [
990
+ position[0] ?? 0,
991
+ round2((position[1] ?? 0) * spec.sun.height),
992
+ position[2] ?? 0
993
+ ]
994
+ }
995
+ };
996
+ }));
997
+ return {
998
+ name: "Maze",
999
+ type: "Node3D",
1000
+ children
1001
+ };
1002
+ }
1003
+ /** Decorative coping trim atop each wall run (slight overhang, no collider —
1004
+ * dressing never changes the collision layout between themes). */
1005
+ function wallCaps(wallRuns, cellSize, wallHeight, spanX, spanZ, color) {
1006
+ return wallRuns.map((wall, i) => ({
1007
+ name: `Cap${i + 1}`,
1008
+ type: "MeshInstance3D",
1009
+ props: {
1010
+ mesh: "box",
1011
+ size: [
1012
+ round2(wall.run * cellSize + .16),
1013
+ .12,
1014
+ round2(cellSize + .16)
1015
+ ],
1016
+ position: [
1017
+ round2((wall.gx + wall.run / 2) * cellSize - spanX / 2),
1018
+ round2(wallHeight + .06),
1019
+ round2((wall.gz + .5) * cellSize - spanZ / 2)
1020
+ ],
1021
+ material: {
1022
+ color,
1023
+ roughness: 1
1024
+ },
1025
+ castShadow: true,
1026
+ receiveShadow: true
1027
+ }
1028
+ }));
1029
+ }
1030
+ /**
1031
+ * Slightly wider/taller columns on wall-junction posts (grid tiles with even
1032
+ * coordinates and ≥3 wall neighbors — where runs meet in a T or X). Up to
1033
+ * MAX_PILLARS, chosen deterministically.
1034
+ */
1035
+ function junctionPillars(rng, grid, cellSize, wallHeight, tileCenter, spec) {
1036
+ const candidates = [];
1037
+ const isWall = (gx, gz) => grid.cells[gz]?.[gx] === false;
1038
+ for (let gz = 2; gz < grid.cells.length - 1; gz += 2) for (let gx = 2; gx < (grid.cells[gz]?.length ?? 0) - 1; gx += 2) {
1039
+ if (!isWall(gx, gz)) continue;
1040
+ if (Number(isWall(gx - 1, gz)) + Number(isWall(gx + 1, gz)) + Number(isWall(gx, gz - 1)) + Number(isWall(gx, gz + 1)) >= 3) candidates.push([gx, gz]);
1041
+ }
1042
+ const count = Math.min(MAX_PILLARS, candidates.length);
1043
+ const out = [];
1044
+ const used = /* @__PURE__ */ new Set();
1045
+ const side = round2(cellSize * 1.2);
1046
+ const height = round2(wallHeight * 1.12);
1047
+ for (let i = 1; i <= count; i++) {
1048
+ let idx = rng.int(0, candidates.length - 1);
1049
+ while (used.has(idx)) idx = (idx + 1) % candidates.length;
1050
+ used.add(idx);
1051
+ const [gx, gz] = candidates[idx];
1052
+ const [x, z] = tileCenter(gx, gz);
1053
+ out.push(pillar(`Pillar${i}`, x, z, side, height, spec));
1054
+ }
1055
+ return out;
1056
+ }
1057
+ /** One decorative column over the wall texture (caps walls visually). */
1058
+ function pillar(name, x, z, side, height, spec) {
1059
+ return {
1060
+ name,
1061
+ type: "MeshInstance3D",
1062
+ props: {
1063
+ mesh: "box",
1064
+ size: [
1065
+ side,
1066
+ height,
1067
+ side
1068
+ ],
1069
+ position: [
1070
+ x,
1071
+ round2(height / 2),
1072
+ z
1073
+ ],
1074
+ material: {
1075
+ ...surfaceMaterial(spec.wall, side, height),
1076
+ color: spec.pillar
1077
+ },
1078
+ castShadow: true,
1079
+ receiveShadow: true
1080
+ }
1081
+ };
1082
+ }
1083
+ /** Gate posts flanking the entrance (west, tile (0,1)) and exit (east) plus
1084
+ * a tinted path tile in each opening — arrivals read the way in/out. */
1085
+ function gates(grid, cellSize, wallHeight, tileCenter, spec) {
1086
+ const lastX = 2 * grid.cols;
1087
+ const lastZ = 2 * grid.rows - 1;
1088
+ const side = round2(cellSize * 1.1);
1089
+ const height = round2(wallHeight * 1.25);
1090
+ const out = [
1091
+ [0, 0],
1092
+ [0, 2],
1093
+ [lastX, lastZ - 1],
1094
+ [lastX, lastZ + 1]
1095
+ ].map(([gx, gz], i) => {
1096
+ const [x, z] = tileCenter(gx, gz);
1097
+ return pillar(`Gate${i + 1}`, x, z, side, height, spec);
1098
+ });
1099
+ for (const [name, gx, gz] of [[
1100
+ "EntrancePath",
1101
+ 0,
1102
+ 1
1103
+ ], [
1104
+ "ExitPath",
1105
+ lastX,
1106
+ lastZ
1107
+ ]]) {
1108
+ const [x, z] = tileCenter(gx, gz);
1109
+ out.push({
1110
+ name,
1111
+ type: "MeshInstance3D",
1112
+ props: {
1113
+ mesh: "box",
1114
+ size: [
1115
+ round2(cellSize * .96),
1116
+ .04,
1117
+ round2(cellSize * .96)
1118
+ ],
1119
+ position: [
1120
+ x,
1121
+ .02,
1122
+ z
1123
+ ],
1124
+ material: {
1125
+ ...surfaceMaterial(spec.floor, cellSize, cellSize),
1126
+ color: spec.path
1127
+ },
1128
+ receiveShadow: true
1129
+ }
1130
+ });
1131
+ }
1132
+ return out;
1133
+ }
1134
+ /** How many of the longest runs grow a trimmed-grass strip on top. */
1135
+ const MAX_HEDGE_TOPS = 10;
1136
+ /** Foliage3D strips along the rim of the longest hedge runs — the trimmed
1137
+ * hedge silhouette (every strip is one cheap instanced tuft field). */
1138
+ function hedgeTops(rng, wallRuns, cellSize, wallHeight, tileCenter) {
1139
+ return [...wallRuns].filter((wall) => wall.run >= 2).sort((a, b) => b.run - a.run || a.gz - b.gz || a.gx - b.gx).slice(0, MAX_HEDGE_TOPS).map((wall, i) => {
1140
+ const [, z] = tileCenter(wall.gx, wall.gz);
1141
+ const [xStart] = tileCenter(wall.gx, wall.gz);
1142
+ const [xEnd] = tileCenter(wall.gx + wall.run - 1, wall.gz);
1143
+ return {
1144
+ name: `HedgeTop${i + 1}`,
1145
+ type: "Foliage3D",
1146
+ props: {
1147
+ kind: "grass",
1148
+ style: "tufts",
1149
+ area: [round2(wall.run * cellSize * .92), round2(cellSize * .7)],
1150
+ density: 14,
1151
+ height: .35,
1152
+ sway: .4,
1153
+ colorA: "#2f5e26",
1154
+ colorB: "#5d8a3c",
1155
+ seed: rng.int(1, 1e9),
1156
+ position: [
1157
+ round2((xStart + xEnd) / 2),
1158
+ wallHeight,
1159
+ z
1160
+ ]
1161
+ }
1162
+ };
1163
+ });
1164
+ }
1165
+ /** Foliage3D grass patches on a handful of open corridor cells. */
1166
+ function hedgeGrass(rng, grid, cellSize, tileCenter, width, depth) {
1167
+ const open = [];
1168
+ for (let gz = 0; gz < grid.cells.length; gz++) for (let gx = 0; gx < (grid.cells[gz]?.length ?? 0); gx++) if (grid.cells[gz]?.[gx]) open.push([gx, gz]);
1169
+ const patches = Math.min(open.length, Math.max(3, Math.floor(width * depth / 12)));
1170
+ const out = [];
1171
+ const used = /* @__PURE__ */ new Set();
1172
+ for (let i = 1; i <= patches && used.size < open.length; i++) {
1173
+ let idx = rng.int(0, open.length - 1);
1174
+ while (used.has(idx)) idx = (idx + 1) % open.length;
1175
+ used.add(idx);
1176
+ const [gx, gz] = open[idx];
1177
+ const [x, z] = tileCenter(gx, gz);
1178
+ const patch = round2(cellSize * .8);
1179
+ out.push({
1180
+ name: `Grass${i}`,
1181
+ type: "Foliage3D",
1182
+ props: {
1183
+ kind: "grass",
1184
+ area: [patch, patch],
1185
+ density: 8,
1186
+ seed: rng.int(1, 1e9),
1187
+ position: [
1188
+ x,
1189
+ 0,
1190
+ z
1191
+ ]
1192
+ }
1193
+ });
1194
+ }
1195
+ return out;
1196
+ }
1197
+ /** Boulders perched ON TOP of random wall runs — the canyon rim. */
1198
+ function rimRocks(rng, wallRuns, cellSize, wallHeight, tileCenter) {
1199
+ const out = [];
1200
+ if (wallRuns.length === 0) return out;
1201
+ const count = Math.min(8, wallRuns.length);
1202
+ for (let i = 1; i <= count; i++) {
1203
+ const run = rng.pick(wallRuns);
1204
+ const [x, z] = tileCenter(run.gx + rng.int(0, run.run - 1), run.gz);
1205
+ const node = rock(i, x, z, rng, [.3, round2(cellSize * .35)], "#8f7355");
1206
+ const position = node.props?.position;
1207
+ position[1] = round2(position[1] + wallHeight);
1208
+ out.push(node);
1209
+ }
1210
+ return out;
1211
+ }
1212
+ //#endregion
1213
+ //#region src/env/maze-2d.ts
1214
+ const FLOOR_COLOR = "#23222b";
1215
+ const WALL_COLOR = "#5f6672";
1216
+ /**
1217
+ * The 2D twin of `generateMaze` — the SAME recursive-backtracker grid
1218
+ * (entrance west, exit east) emitted as a ColorRect2D floor backdrop plus
1219
+ * StaticBody2D wall bodies with ColorRect2D skins (px, y-down, centered on
1220
+ * the origin). Consecutive wall tiles merge into single bodies along x.
1221
+ */
1222
+ function generateMaze2D(opts) {
1223
+ const { seed, cols = 10, rows = 8, cellPx = 64 } = opts;
1224
+ const grid = carveMaze(new Rng(seed), cols, rows);
1225
+ const gridWidth = 2 * cols + 1;
1226
+ const gridHeight = 2 * rows + 1;
1227
+ const spanX = round2(gridWidth * cellPx);
1228
+ const spanY = round2(gridHeight * cellPx);
1229
+ const children = [{
1230
+ name: "Floor",
1231
+ type: "ColorRect2D",
1232
+ props: {
1233
+ size: [spanX, spanY],
1234
+ color: FLOOR_COLOR
1235
+ }
1236
+ }];
1237
+ let wallIndex = 0;
1238
+ for (let gy = 0; gy < gridHeight; gy++) {
1239
+ let gx = 0;
1240
+ while (gx < gridWidth) {
1241
+ if (grid.cells[gy]?.[gx]) {
1242
+ gx++;
1243
+ continue;
1244
+ }
1245
+ let run = 1;
1246
+ while (gx + run < gridWidth && !grid.cells[gy]?.[gx + run]) run++;
1247
+ wallIndex++;
1248
+ const size = [round2(run * cellPx), cellPx];
1249
+ children.push({
1250
+ name: `Wall${wallIndex}`,
1251
+ type: "StaticBody2D",
1252
+ props: {
1253
+ position: [round2((gx + run / 2) * cellPx - spanX / 2), round2((gy + .5) * cellPx - spanY / 2)],
1254
+ collider: {
1255
+ shape: "rect",
1256
+ size
1257
+ }
1258
+ },
1259
+ children: [{
1260
+ name: "Skin",
1261
+ type: "ColorRect2D",
1262
+ props: {
1263
+ size,
1264
+ color: WALL_COLOR
1265
+ }
1266
+ }]
1267
+ });
1268
+ gx += run;
1269
+ }
1270
+ }
1271
+ return {
1272
+ name: "Maze2D",
1273
+ type: "Node2D",
1274
+ children
1275
+ };
1276
+ }
1277
+ //#endregion
1278
+ //#region src/env/platforms-2d.ts
1279
+ const THICKNESS = 16;
1280
+ const COLORS = [
1281
+ "#5b8266",
1282
+ "#3e6990",
1283
+ "#a26b38",
1284
+ "#6d5a96",
1285
+ "#b0413e"
1286
+ ];
1287
+ /**
1288
+ * A left-to-right 2D platform course: StaticBody2D rect colliders with
1289
+ * ColorRect2D 'Skin' children. Spacing is caller-tunable — keep `gapX` and
1290
+ * `stepY` inside your character's jump arc for a reachable course (px,
1291
+ * y-down: world gravity pulls +y, so negative `stepY` steps UP).
1292
+ */
1293
+ function generatePlatforms2D(opts) {
1294
+ const { seed, count = 10, width = [80, 160], gapX = [40, 120], stepY = [-80, 40], start = [0, 300] } = opts;
1295
+ const rng = new Rng(seed);
1296
+ const children = [];
1297
+ let w = round2(rng.range(width[0], width[1]));
1298
+ let x = start[0];
1299
+ let y = start[1];
1300
+ for (let i = 1; i <= count; i++) {
1301
+ children.push(platform(i, x, y, w, rng.pick(COLORS)));
1302
+ if (i === count) break;
1303
+ const nextW = round2(rng.range(width[0], width[1]));
1304
+ const gap = round2(rng.range(gapX[0], gapX[1]));
1305
+ x = round2(x + w / 2 + gap + nextW / 2);
1306
+ y = round2(y + rng.range(stepY[0], stepY[1]));
1307
+ w = nextW;
1308
+ }
1309
+ return {
1310
+ name: "Platforms",
1311
+ type: "Node2D",
1312
+ children
1313
+ };
1314
+ }
1315
+ function platform(index, x, y, w, color) {
1316
+ return {
1317
+ name: `Platform${index}`,
1318
+ type: "StaticBody2D",
1319
+ props: {
1320
+ position: [x, y],
1321
+ collider: {
1322
+ shape: "rect",
1323
+ size: [w, THICKNESS]
1324
+ }
1325
+ },
1326
+ children: [{
1327
+ name: "Skin",
1328
+ type: "ColorRect2D",
1329
+ props: {
1330
+ size: [w, THICKNESS],
1331
+ color
1332
+ }
1333
+ }]
1334
+ };
1335
+ }
1336
+ //#endregion
1337
+ //#region src/env/clouds.ts
1338
+ const PUFFS = [3, 5];
1339
+ const ALTITUDE_JITTER = 3;
1340
+ const CLOUD_MATERIAL = {
1341
+ color: "#ffffff",
1342
+ roughness: 1,
1343
+ emissive: "#ffffff",
1344
+ emissiveIntensity: .25
1345
+ };
1346
+ /**
1347
+ * Soft sky clouds: each a Node3D group of 3–5 overlapping white sphere puffs
1348
+ * stretched wide and squashed flat, floating around `altitude`. Pure visuals
1349
+ * — no colliders, no physics.
1350
+ *
1351
+ * @deprecated The island terrain theme ships its own cloud layer — use
1352
+ * `runGenerator('terrain', { seed, theme: 'island' })` for whole worlds.
1353
+ * Kept for custom library compositions (other themes, custom skies).
1354
+ */
1355
+ function generateClouds(opts) {
1356
+ const { seed, count = 8, altitude = 18 } = opts;
1357
+ const [areaX, areaZ] = extent(opts.area, [60, 60]);
1358
+ const rng = new Rng(seed);
1359
+ const children = [];
1360
+ for (let i = 1; i <= count; i++) {
1361
+ const puffCount = rng.int(PUFFS[0], PUFFS[1]);
1362
+ const puffs = [];
1363
+ for (let p = 1; p <= puffCount; p++) {
1364
+ const radius = round2(rng.range(1, 2.2));
1365
+ puffs.push({
1366
+ name: `Puff${p}`,
1367
+ type: "MeshInstance3D",
1368
+ props: {
1369
+ mesh: "sphere",
1370
+ size: [
1371
+ 1,
1372
+ 1,
1373
+ 1
1374
+ ],
1375
+ position: [
1376
+ round2((p - (puffCount + 1) / 2) * rng.range(1, 1.6)),
1377
+ round2(rng.range(-.3, .3)),
1378
+ round2(rng.range(-.6, .6))
1379
+ ],
1380
+ scale: [
1381
+ round2(radius * rng.range(1.1, 1.6)),
1382
+ round2(radius * .55),
1383
+ radius
1384
+ ],
1385
+ material: CLOUD_MATERIAL
1386
+ }
1387
+ });
1388
+ }
1389
+ children.push({
1390
+ name: `Cloud${i}`,
1391
+ type: "Node3D",
1392
+ props: { position: [
1393
+ round2(rng.range(-areaX / 2, areaX / 2)),
1394
+ round2(altitude + rng.range(-3, ALTITUDE_JITTER)),
1395
+ round2(rng.range(-areaZ / 2, areaZ / 2))
1396
+ ] },
1397
+ children: puffs
1398
+ });
1399
+ }
1400
+ return {
1401
+ name: "Clouds",
1402
+ type: "Node3D",
1403
+ children
1404
+ };
1405
+ }
1406
+ //#endregion
1407
+ //#region src/env/terrain.ts
1408
+ /** Valid `generateTerrain` themes — drives the catalog options AND validation. */
1409
+ const TERRAIN_GENERATOR_THEMES = [
1410
+ "island",
1411
+ "alpine",
1412
+ "plains",
1413
+ "desert",
1414
+ "meadow",
1415
+ "forest",
1416
+ "savanna",
1417
+ "snow",
1418
+ "wetland",
1419
+ "volcanic"
1420
+ ];
1421
+ /** Terrain3D's default grid segments — the probe MUST match the runtime grid. */
1422
+ const TERRAIN_RESOLUTION = 128;
1423
+ /** Terrain3D edge wrap: the rim drops this many meters before the −50 m skirt. */
1424
+ const EDGE_WRAP_DROP = 20;
1425
+ /** Sea margin above the highest rim cliff (> the ~0.52 m wave-trough dip). */
1426
+ const SEA_MARGIN = .8;
1427
+ /** Island sand splat band tops out at 12% of the realized height span. */
1428
+ const SAND_BAND = .12;
1429
+ /** Default IBL mood cut for tree-bearing themes (the eztree stand's lit form). */
1430
+ const DEFAULT_IBL_INTENSITY = .6;
1431
+ const THEMES = {
1432
+ island: {
1433
+ splat: "island",
1434
+ maxHeight: 4.5,
1435
+ sun: "#fff4d6",
1436
+ fill: "#b9d4ff",
1437
+ sky: {
1438
+ elevationDeg: 38,
1439
+ azimuthDeg: 145,
1440
+ turbidity: 2.6,
1441
+ rayleigh: 1.1
1442
+ },
1443
+ fog: {
1444
+ near: 1,
1445
+ far: 4
1446
+ },
1447
+ iblIntensity: .72
1448
+ },
1449
+ alpine: {
1450
+ splat: "alpine",
1451
+ maxHeight: 8,
1452
+ roughness: .65,
1453
+ detail: 5,
1454
+ sun: "#f4f7ff",
1455
+ fill: "#c9d8f2",
1456
+ sky: {
1457
+ elevationDeg: 45,
1458
+ azimuthDeg: 35,
1459
+ turbidity: 1.4,
1460
+ rayleigh: 1.3
1461
+ },
1462
+ fog: {
1463
+ near: 1.8,
1464
+ far: 6.5
1465
+ },
1466
+ sunIntensity: 1.1,
1467
+ exposure: .92,
1468
+ iblIntensity: .72
1469
+ },
1470
+ plains: {
1471
+ splat: "plains",
1472
+ maxHeight: 4,
1473
+ sun: "#fff2cf",
1474
+ fill: "#bcd3ef",
1475
+ sky: {
1476
+ elevationDeg: 36,
1477
+ azimuthDeg: 140,
1478
+ turbidity: 2.4,
1479
+ rayleigh: 1
1480
+ },
1481
+ fog: {
1482
+ near: .9,
1483
+ far: 4
1484
+ },
1485
+ iblIntensity: .58
1486
+ },
1487
+ desert: {
1488
+ splat: "desert",
1489
+ maxHeight: 5,
1490
+ sun: "#ffe3b3",
1491
+ fill: "#e8c9a6",
1492
+ sky: {
1493
+ elevationDeg: 42,
1494
+ azimuthDeg: 160,
1495
+ turbidity: 7,
1496
+ rayleigh: .6
1497
+ },
1498
+ fog: {
1499
+ near: .8,
1500
+ far: 3.2,
1501
+ color: "#e8d3ae"
1502
+ },
1503
+ sunIntensity: 1.35,
1504
+ iblIntensity: .75
1505
+ },
1506
+ meadow: {
1507
+ splat: "grassland",
1508
+ maxHeight: 1.2,
1509
+ sun: "#fff8e2",
1510
+ fill: "#bfe0c9",
1511
+ sky: {
1512
+ elevationDeg: 55,
1513
+ azimuthDeg: 125,
1514
+ turbidity: 2.4,
1515
+ rayleigh: .95
1516
+ },
1517
+ fog: {
1518
+ near: 1,
1519
+ far: 4.4
1520
+ },
1521
+ sunIntensity: 2.6,
1522
+ exposure: 1.12,
1523
+ iblIntensity: .62
1524
+ },
1525
+ forest: {
1526
+ splat: "forest",
1527
+ maxHeight: 2.5,
1528
+ sun: "#ffdca8",
1529
+ fill: "#a9c8b4",
1530
+ sky: {
1531
+ elevationDeg: 29,
1532
+ azimuthDeg: 120,
1533
+ turbidity: 5.5,
1534
+ rayleigh: 1
1535
+ },
1536
+ fog: {
1537
+ near: .22,
1538
+ far: 1.8,
1539
+ color: "#9fb494"
1540
+ },
1541
+ sunIntensity: 2.6,
1542
+ ambient: .26,
1543
+ iblIntensity: .55
1544
+ },
1545
+ savanna: {
1546
+ splat: "savanna",
1547
+ maxHeight: 3,
1548
+ sun: "#ffdca0",
1549
+ fill: "#e6d2a4",
1550
+ sky: {
1551
+ elevationDeg: 34,
1552
+ azimuthDeg: 150,
1553
+ turbidity: 5,
1554
+ rayleigh: .7
1555
+ },
1556
+ fog: {
1557
+ near: 1,
1558
+ far: 4.2,
1559
+ color: "#e3cf9f"
1560
+ },
1561
+ sunIntensity: 2,
1562
+ exposure: 1.05,
1563
+ iblIntensity: .6
1564
+ },
1565
+ snow: {
1566
+ splat: "snow",
1567
+ maxHeight: 2.2,
1568
+ sun: "#dfe9ff",
1569
+ fill: "#c2d2f0",
1570
+ sky: {
1571
+ elevationDeg: 22,
1572
+ azimuthDeg: 35,
1573
+ turbidity: 1.6,
1574
+ rayleigh: 1.6
1575
+ },
1576
+ fog: {
1577
+ near: 1.2,
1578
+ far: 5,
1579
+ color: "#dbe6f5"
1580
+ },
1581
+ sunIntensity: 1,
1582
+ exposure: .9,
1583
+ iblIntensity: .72
1584
+ },
1585
+ wetland: {
1586
+ splat: "wetland",
1587
+ maxHeight: 1.4,
1588
+ sun: "#d6ddc8",
1589
+ fill: "#9fb29a",
1590
+ sky: {
1591
+ elevationDeg: 24,
1592
+ azimuthDeg: 115,
1593
+ turbidity: 9,
1594
+ rayleigh: 1.1
1595
+ },
1596
+ fog: {
1597
+ near: .5,
1598
+ far: 2,
1599
+ color: "#92a288"
1600
+ },
1601
+ sunIntensity: 1.5,
1602
+ exposure: .94,
1603
+ ambient: .24,
1604
+ iblIntensity: .66
1605
+ },
1606
+ volcanic: {
1607
+ splat: "volcanic",
1608
+ maxHeight: 4,
1609
+ roughness: .7,
1610
+ sun: "#ff8a4a",
1611
+ fill: "#7a4a3a",
1612
+ sky: {
1613
+ elevationDeg: 14,
1614
+ azimuthDeg: 135,
1615
+ turbidity: 10,
1616
+ rayleigh: .3
1617
+ },
1618
+ fog: {
1619
+ near: .18,
1620
+ far: 1.4,
1621
+ color: "#2a211c"
1622
+ },
1623
+ sunIntensity: 1.7,
1624
+ exposure: .86,
1625
+ ambient: .22,
1626
+ iblIntensity: .5
1627
+ }
1628
+ };
1629
+ /**
1630
+ * The environment header that matches a generator theme — sky, horizon fog
1631
+ * and shadows composed for the same world `generateTerrain` emits (fog
1632
+ * distances scale with `size`). Spread it into the scene's `environment`;
1633
+ * scene-specific keys (ambient, background fallback) layer on top.
1634
+ */
1635
+ function terrainEnvironment(theme, size = 200) {
1636
+ const spec = THEMES[theme];
1637
+ if (!spec) throw new IncantoError("BAD_FORMAT", `terrainEnvironment theme must be one of [${TERRAIN_GENERATOR_THEMES.join(", ")}], got '${theme}'.`, {
1638
+ prop: "theme",
1639
+ validOptions: [...TERRAIN_GENERATOR_THEMES]
1640
+ });
1641
+ return {
1642
+ sky: {
1643
+ type: "atmosphere",
1644
+ ...spec.sky
1645
+ },
1646
+ fog: {
1647
+ near: round2(size * spec.fog.near),
1648
+ far: round2(size * spec.fog.far),
1649
+ ...spec.fog.color ? { color: spec.fog.color } : {}
1650
+ },
1651
+ shadows: true,
1652
+ ...spec.exposure !== void 0 ? { exposure: spec.exposure } : {},
1653
+ iblIntensity: spec.iblIntensity ?? DEFAULT_IBL_INTENSITY,
1654
+ ambient: {
1655
+ color: "#ffffff",
1656
+ intensity: spec.ambient ?? .18
1657
+ }
1658
+ };
1659
+ }
1660
+ /**
1661
+ * A complete heightfield world from one seed + one theme: the canonical
1662
+ * physics recipe (`StaticBody3D{heightfield}` with a `Terrain3D` child) plus
1663
+ * theme dressing placed ON the surface via the same heightmap the node will
1664
+ * build at runtime — trees probe `heightAt` and reject steep/sand/snow cells,
1665
+ * the island sea level is COMPUTED from border probes (two-sided constraint:
1666
+ * above every rim cliff top, inside the sand band), rocks/foliage sit at
1667
+ * their sampled ground height. Sun/fill lights round out every theme.
1668
+ *
1669
+ * Replaces the old voxel terrain in the catalog — that lives on as the
1670
+ * library-only `generateVoxelTerrain` for minecraft-style block worlds.
1671
+ */
1672
+ function generateTerrain(opts) {
1673
+ const { seed, theme = "island", size = 200, water = false } = opts;
1674
+ const spec = THEMES[theme];
1675
+ if (!spec) throw new IncantoError("BAD_FORMAT", `generateTerrain theme must be one of [${TERRAIN_GENERATOR_THEMES.join(", ")}], got '${theme}'.`, {
1676
+ prop: "theme",
1677
+ validOptions: [...TERRAIN_GENERATOR_THEMES]
1678
+ });
1679
+ let maxHeight = opts.maxHeight || spec.maxHeight;
1680
+ const rng = new Rng(seed);
1681
+ const isIsland = theme === "island";
1682
+ const probe = (h) => buildHeightmap({
1683
+ width: size,
1684
+ depth: size,
1685
+ segsX: TERRAIN_RESOLUTION,
1686
+ segsZ: TERRAIN_RESOLUTION,
1687
+ maxHeight: h,
1688
+ seed,
1689
+ ...spec.roughness !== void 0 ? { roughness: spec.roughness } : {},
1690
+ ...spec.detail !== void 0 ? { detail: spec.detail } : {},
1691
+ islandEdge: isIsland
1692
+ });
1693
+ let hm = probe(maxHeight);
1694
+ if (isIsland) for (let i = 0; i < 3; i++) {
1695
+ const cliffTop = borderMaxHeight(hm) - EDGE_WRAP_DROP;
1696
+ const span = hm.maxHeight - hm.minHeight;
1697
+ const sandTop = hm.minHeight + SAND_BAND * span;
1698
+ if (cliffTop + SEA_MARGIN <= sandTop) break;
1699
+ const budget = EDGE_WRAP_DROP - SEA_MARGIN;
1700
+ const needed = borderMaxHeight(hm) - hm.minHeight - SAND_BAND * span;
1701
+ maxHeight = round2(maxHeight * Math.min(budget / needed * .95, .9));
1702
+ hm = probe(maxHeight);
1703
+ }
1704
+ const children = [{
1705
+ name: "Ground",
1706
+ type: "StaticBody3D",
1707
+ props: { collider: { shape: "heightfield" } },
1708
+ children: [{
1709
+ name: "Surface",
1710
+ type: "Terrain3D",
1711
+ props: {
1712
+ size: [size, size],
1713
+ maxHeight,
1714
+ seed,
1715
+ theme: spec.splat,
1716
+ ...spec.roughness !== void 0 ? { roughness: spec.roughness } : {},
1717
+ ...spec.detail !== void 0 ? { detail: spec.detail } : {}
1718
+ }
1719
+ }]
1720
+ }];
1721
+ const isWetland = theme === "wetland";
1722
+ if (isIsland) children.push({
1723
+ name: "Sea",
1724
+ type: "Water3D",
1725
+ props: {
1726
+ size: [size * 8, size * 8],
1727
+ position: [
1728
+ 0,
1729
+ islandSeaLevel(hm),
1730
+ 0
1731
+ ],
1732
+ opacity: 1
1733
+ }
1734
+ });
1735
+ else if (isWetland) {
1736
+ const span = hm.maxHeight - hm.minHeight || 1;
1737
+ const waterLevel = round2(hm.minHeight + .22 * span);
1738
+ children.push({
1739
+ name: "Swamp",
1740
+ type: "Water3D",
1741
+ props: {
1742
+ size: [size, size],
1743
+ position: [
1744
+ 0,
1745
+ waterLevel,
1746
+ 0
1747
+ ],
1748
+ waveHeight: .02,
1749
+ quality: "simple",
1750
+ color: "#3a4a30",
1751
+ opacity: .95
1752
+ }
1753
+ });
1754
+ } else if (water) {
1755
+ const lakeLevel = round2(hm.minHeight + .1 * (hm.maxHeight - hm.minHeight));
1756
+ children.push({
1757
+ name: "Lake",
1758
+ type: "Water3D",
1759
+ props: {
1760
+ size: [size, size],
1761
+ position: [
1762
+ 0,
1763
+ lakeLevel,
1764
+ 0
1765
+ ],
1766
+ waveHeight: .04
1767
+ }
1768
+ });
1769
+ }
1770
+ children.push(...dressing(theme, rng, hm, size));
1771
+ if (isIsland) children.push(generateClouds({
1772
+ seed: rng.int(1, 1e9),
1773
+ count: 6,
1774
+ area: size,
1775
+ altitude: Math.round(hm.maxHeight + 12)
1776
+ }));
1777
+ children.push(...skyLights(size).map((light, i) => tintLight(light, i === 0 ? spec.sun : spec.fill, i === 0 ? spec.sunIntensity ?? 1.7 : void 0)));
1778
+ return {
1779
+ name: "Terrain",
1780
+ type: "Node3D",
1781
+ children
1782
+ };
1783
+ }
1784
+ /**
1785
+ * The documented two-sided island sea-level constraint, computed instead of
1786
+ * guessed: the edge wrap drops the rim only 20 m before the vertical skirt,
1787
+ * so the sea must sit ABOVE (highest border height − 20 m) — probe the
1788
+ * border with `baseHeight` — while staying inside the sand band (bottom 12%
1789
+ * of the realized span) so the visible shore reads as beach. Drownability
1790
+ * wins when a tall island can't satisfy both.
1791
+ */
1792
+ function islandSeaLevel(hm) {
1793
+ const cliffTop = borderMaxHeight(hm) - EDGE_WRAP_DROP;
1794
+ const sandTop = hm.minHeight + SAND_BAND * (hm.maxHeight - hm.minHeight);
1795
+ return round2(Math.max(Math.min(cliffTop + SEA_MARGIN, sandTop), cliffTop + .55));
1796
+ }
1797
+ /** Highest no-wrap height along the terrain border (probed like the docs say). */
1798
+ function borderMaxHeight(hm) {
1799
+ let borderMax = Number.NEGATIVE_INFINITY;
1800
+ const hw = hm.width / 2;
1801
+ const hd = hm.depth / 2;
1802
+ for (let i = 0; i <= TERRAIN_RESOLUTION; i++) {
1803
+ const x = -hw + i / TERRAIN_RESOLUTION * hm.width;
1804
+ const z = -hd + i / TERRAIN_RESOLUTION * hm.depth;
1805
+ borderMax = Math.max(borderMax, hm.baseHeight(x, -hd), hm.baseHeight(x, hd), hm.baseHeight(-hw, z), hm.baseHeight(hw, z));
1806
+ }
1807
+ return borderMax;
1808
+ }
1809
+ /** Rejection-sample terrain cells matching a height band + slope budget. */
1810
+ function sampleSpots(rng, hm, spec) {
1811
+ const limit = Math.min(hm.width / 2 - spec.margin, spec.within ?? Number.POSITIVE_INFINITY);
1812
+ const span = hm.maxHeight - hm.minHeight || 1;
1813
+ const spots = [];
1814
+ for (let attempt = 0; attempt < spec.count * 30 && spots.length < spec.count; attempt++) {
1815
+ const x = round2(rng.range(-limit, limit));
1816
+ const z = round2(rng.range(-limit, limit));
1817
+ if (spec.clearing && Math.hypot(x, z) < spec.clearing) continue;
1818
+ if (spec.within && Math.hypot(x, z) > spec.within) continue;
1819
+ const y = hm.heightAt(x, z);
1820
+ const h01 = (y - hm.minHeight) / span;
1821
+ if (h01 < spec.band[0] || h01 > spec.band[1]) continue;
1822
+ if (hm.slopeAt(x, z) > spec.maxSlope) continue;
1823
+ spots.push({
1824
+ x,
1825
+ z,
1826
+ y
1827
+ });
1828
+ }
1829
+ return spots;
1830
+ }
1831
+ const CONIFER_PALETTE = [
1832
+ {
1833
+ canopy: "#2f5d44",
1834
+ trunk: "#6e4a32"
1835
+ },
1836
+ {
1837
+ canopy: "#356a4c",
1838
+ trunk: "#71503a"
1839
+ },
1840
+ {
1841
+ canopy: "#2c5740",
1842
+ trunk: "#5f4530"
1843
+ },
1844
+ {
1845
+ canopy: "#3a6b4a",
1846
+ trunk: "#6a4c34"
1847
+ }
1848
+ ];
1849
+ const BROADLEAF_PALETTE = [
1850
+ {
1851
+ canopy: "#4a7c3f",
1852
+ trunk: "#7a5a3a"
1853
+ },
1854
+ {
1855
+ canopy: "#56883c",
1856
+ trunk: "#806044"
1857
+ },
1858
+ {
1859
+ canopy: "#7a9d3e",
1860
+ trunk: "#9a9488"
1861
+ },
1862
+ {
1863
+ canopy: "#86a346",
1864
+ trunk: "#a39c8e"
1865
+ },
1866
+ {
1867
+ canopy: "#b8862f",
1868
+ trunk: "#7e5e38"
1869
+ },
1870
+ {
1871
+ canopy: "#a8702c",
1872
+ trunk: "#74552f"
1873
+ }
1874
+ ];
1875
+ /** Savanna acacia: a dry golden-green flat canopy over warm tan bark. */
1876
+ const ACACIA_PALETTE = {
1877
+ canopy: "#9aa052",
1878
+ trunk: "#8a6a44"
1879
+ };
1880
+ /** Snow tundra conifer: a cool DARK blue-green, frosted — reads dusted. */
1881
+ const SNOW_CONIFER_PALETTE = {
1882
+ canopy: "#39513f",
1883
+ trunk: "#5a5650"
1884
+ };
1885
+ /** A species canopy/trunk variant for a grove — drawn from the rng in a fixed
1886
+ * order so determinism holds. Conifers/broadleaf get their palettes; dead and
1887
+ * bush keep the node defaults (snags are barkless grey, bushes their own). */
1888
+ function speciesColors(type, rng) {
1889
+ if (type === "conifer") return rng.pick(CONIFER_PALETTE);
1890
+ if (type === "broadleaf") return rng.pick(BROADLEAF_PALETTE);
1891
+ return null;
1892
+ }
1893
+ /** A Tree3D at a sampled spot — slightly sunk so the roots never float. The
1894
+ * grove draws a per-species canopy/trunk variant from the rng (fixed order →
1895
+ * deterministic), giving the world its tree-color variety; dead/bush keep the
1896
+ * node defaults. The rng draw happens for EVERY tree so the seed contract is
1897
+ * stable regardless of species. */
1898
+ function treeAt(name, spot, rng, type, grove) {
1899
+ const heightRange = grove?.height ?? [4.5, 7];
1900
+ const sink = grove?.sink ?? (grove?.count !== void 0 ? .3 : .05);
1901
+ const extras = {};
1902
+ if (grove?.tier !== void 0) extras.tier = grove.tier;
1903
+ if (grove?.count !== void 0 && grove.area !== void 0) {
1904
+ extras.count = grove.count;
1905
+ extras.area = [grove.area, grove.area];
1906
+ }
1907
+ const colors = speciesColors(type, rng);
1908
+ const tint = grove?.palette ?? colors;
1909
+ if (tint) {
1910
+ extras.canopyColor = tint.canopy;
1911
+ extras.trunkColor = tint.trunk;
1912
+ }
1913
+ return {
1914
+ name,
1915
+ type: "Tree3D",
1916
+ props: {
1917
+ type,
1918
+ seed: rng.int(1, 1e9),
1919
+ height: round2(rng.range(heightRange[0], heightRange[1])),
1920
+ position: [
1921
+ spot.x,
1922
+ round2(spot.y - sink),
1923
+ spot.z
1924
+ ],
1925
+ ...extras
1926
+ }
1927
+ };
1928
+ }
1929
+ /** A Foliage3D grass patch hugging the surface at a sampled flat spot.
1930
+ * eztree-parity pass: carpets render the `tufts` style — recon showed the
1931
+ * reference 잔디밭 is instanced multi-blade tuft CARDS, not per-blade
1932
+ * geometry — with a taller stand (the reference grass is knee-high).
1933
+ * `flowers` sprinkles white/yellow/violet heads — a meadow thing (the node
1934
+ * default stays 0 for back-compat; themes opt in here). */
1935
+ function grassAt(name, spot, rng, patch, density, flowers = 0, look) {
1936
+ return {
1937
+ name,
1938
+ type: "Foliage3D",
1939
+ props: {
1940
+ kind: "grass",
1941
+ style: "tufts",
1942
+ area: [patch, patch],
1943
+ density,
1944
+ height: look?.height ?? .3,
1945
+ ...look?.colors ? {
1946
+ colorA: look.colors[0],
1947
+ colorB: look.colors[1]
1948
+ } : {},
1949
+ seed: rng.int(1, 1e9),
1950
+ position: [
1951
+ spot.x,
1952
+ round2(spot.y + .02),
1953
+ spot.z
1954
+ ],
1955
+ ...flowers > 0 ? { flowers } : {}
1956
+ }
1957
+ };
1958
+ }
1959
+ /** A Foliage3D REED bed hugging the surface at a sampled flat spot — the
1960
+ * swamp's lush dark-green reeds (kind 'reeds' only has the 'simple' look:
1961
+ * tall crossed quads). Rooted at the water margin so they read as marsh. */
1962
+ function reedsAt(name, spot, rng, patch, density) {
1963
+ return {
1964
+ name,
1965
+ type: "Foliage3D",
1966
+ props: {
1967
+ kind: "reeds",
1968
+ style: "simple",
1969
+ area: [patch, patch],
1970
+ density,
1971
+ height: .9,
1972
+ colorA: "#2f4a26",
1973
+ colorB: "#5a6f33",
1974
+ seed: rng.int(1, 1e9),
1975
+ position: [
1976
+ spot.x,
1977
+ round2(spot.y + .02),
1978
+ spot.z
1979
+ ]
1980
+ }
1981
+ };
1982
+ }
1983
+ /** A Flowers3D patch hugging the surface at a sampled flat spot — real
1984
+ * flower plants (stems + petal heads), density preset per theme. */
1985
+ function flowersAt(name, spot, rng, patch, density) {
1986
+ return {
1987
+ name,
1988
+ type: "Flowers3D",
1989
+ props: {
1990
+ density,
1991
+ area: [patch, patch],
1992
+ seed: rng.int(1, 1e9),
1993
+ position: [
1994
+ spot.x,
1995
+ round2(spot.y + .02),
1996
+ spot.z
1997
+ ]
1998
+ }
1999
+ };
2000
+ }
2001
+ /** Rocks ride their sampled ground height (rock() y is the boulder sink).
2002
+ * `tone` overrides the stony gray per boulder (the forest's moss tint). */
2003
+ function rocksAt(spots, rng, sizeRange, tone) {
2004
+ return spots.map((spot, i) => {
2005
+ const node = rock(i + 1, spot.x, spot.z, rng, sizeRange, tone?.(rng));
2006
+ const position = node.props?.position;
2007
+ position[1] = round2(position[1] + spot.y);
2008
+ return node;
2009
+ });
2010
+ }
2011
+ /** A deterministic moss green-grey — boulders under a canopy grow a coat.
2012
+ * Kept close to gray (green channel leads by a whisker): a saturated green
2013
+ * sphere reads as a glowing egg from the treeline, not a mossy rock. */
2014
+ function mossTone(rng) {
2015
+ const g = Math.round(rng.range(104, 128));
2016
+ const r = Math.round(g - rng.range(10, 18));
2017
+ const b = Math.round(g - rng.range(20, 30));
2018
+ const hex = (v) => v.toString(16).padStart(2, "0");
2019
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
2020
+ }
2021
+ /** A deterministic near-white grey — snow-dusted tundra boulders (cool, very
2022
+ * light, a faint blue cast so they read as snow-capped stone, not white eggs). */
2023
+ function snowTone(rng) {
2024
+ const v = Math.round(rng.range(196, 224));
2025
+ const b = Math.min(255, v + Math.round(rng.range(4, 12)));
2026
+ const hex = (x) => x.toString(16).padStart(2, "0");
2027
+ return `#${hex(v)}${hex(v)}${hex(b)}`;
2028
+ }
2029
+ /** A deterministic dark basalt grey — charred volcanic boulders (near-black,
2030
+ * a hair warm so they don't read as pure void under the ember light). */
2031
+ function basaltTone(rng) {
2032
+ const v = Math.round(rng.range(34, 56));
2033
+ const r = Math.min(255, v + Math.round(rng.range(2, 8)));
2034
+ const hex = (x) => x.toString(16).padStart(2, "0");
2035
+ return `#${hex(r)}${hex(v)}${hex(v)}`;
2036
+ }
2037
+ /** Weathered dead-wood browns for stumps (bark long gone, heartwood left). */
2038
+ const STUMP_COLORS = [
2039
+ "#6f5b41",
2040
+ "#7a644a",
2041
+ "#665439"
2042
+ ];
2043
+ /** Theme dressing — every spot is probed against the SAME runtime heightmap. */
2044
+ function dressing(theme, rng, hm, size) {
2045
+ const out = [];
2046
+ switch (theme) {
2047
+ case "island": {
2048
+ const spots = sampleSpots(rng, hm, {
2049
+ count: 7,
2050
+ band: [.18, .6],
2051
+ maxSlope: .5,
2052
+ margin: 26
2053
+ });
2054
+ out.push(...spots.map((s, i) => treeAt(`Tree${i + 1}`, s, rng, "conifer")));
2055
+ break;
2056
+ }
2057
+ case "alpine": {
2058
+ const treeline = sampleSpots(rng, hm, {
2059
+ count: 10,
2060
+ band: [.2, .5],
2061
+ maxSlope: .55,
2062
+ margin: 6
2063
+ });
2064
+ out.push(...treeline.map((s, i) => treeAt(`Tree${i + 1}`, s, rng, "conifer")));
2065
+ const snowBand = sampleSpots(rng, hm, {
2066
+ count: 8,
2067
+ band: [.55, 1],
2068
+ maxSlope: .9,
2069
+ margin: 6
2070
+ });
2071
+ out.push(...rocksAt(snowBand, rng, [.6, 1.8]));
2072
+ break;
2073
+ }
2074
+ case "plains": {
2075
+ const trees = sampleSpots(rng, hm, {
2076
+ count: 8,
2077
+ band: [0, 1],
2078
+ maxSlope: .4,
2079
+ margin: 6
2080
+ });
2081
+ out.push(...trees.map((s, i) => treeAt(`Tree${i + 1}`, s, rng, "broadleaf")));
2082
+ const boulders = sampleSpots(rng, hm, {
2083
+ count: 6,
2084
+ band: [0, 1],
2085
+ maxSlope: .5,
2086
+ margin: 6
2087
+ });
2088
+ out.push(...rocksAt(boulders, rng, [.4, 1.2]));
2089
+ break;
2090
+ }
2091
+ case "desert": {
2092
+ const trees = sampleSpots(rng, hm, {
2093
+ count: 6,
2094
+ band: [0, 1],
2095
+ maxSlope: .45,
2096
+ margin: 6
2097
+ });
2098
+ out.push(...trees.map((s, i) => treeAt(`Tree${i + 1}`, s, rng, "dead")));
2099
+ const boulders = sampleSpots(rng, hm, {
2100
+ count: 8,
2101
+ band: [0, 1],
2102
+ maxSlope: .6,
2103
+ margin: 6
2104
+ });
2105
+ out.push(...rocksAt(boulders, rng, [.5, 1.6]));
2106
+ break;
2107
+ }
2108
+ case "meadow": {
2109
+ const patch = round2(size * .09);
2110
+ const carpets = sampleSpots(rng, hm, {
2111
+ count: 7,
2112
+ band: [0, 1],
2113
+ maxSlope: .05,
2114
+ margin: patch / 2 + 8
2115
+ });
2116
+ out.push(...carpets.map((s, i) => grassAt(`Carpet${i + 1}`, s, rng, patch, 30)));
2117
+ const bed = round2(size * .07);
2118
+ const beds = sampleSpots(rng, hm, {
2119
+ count: 3,
2120
+ band: [0, 1],
2121
+ maxSlope: .05,
2122
+ margin: bed / 2 + 8
2123
+ });
2124
+ out.push(...beds.map((s, i) => flowersAt(`Flowers${i + 1}`, s, rng, bed, "sparse")));
2125
+ const groves = sampleSpots(rng, hm, {
2126
+ count: 4,
2127
+ band: [0, 1],
2128
+ maxSlope: .1,
2129
+ margin: 12
2130
+ });
2131
+ out.push(...groves.map((s, i) => treeAt(`Grove${i + 1}`, s, rng, "broadleaf", {
2132
+ count: 3,
2133
+ area: 6
2134
+ })));
2135
+ const boulders = sampleSpots(rng, hm, {
2136
+ count: 6,
2137
+ band: [0, 1],
2138
+ maxSlope: .2,
2139
+ margin: 8
2140
+ });
2141
+ out.push(...rocksAt(boulders, rng, [.3, .9]));
2142
+ break;
2143
+ }
2144
+ case "forest": {
2145
+ const clearing = round2(size * .12);
2146
+ const limit = size / 2 - 8;
2147
+ const species = createNoise2D(rng.int(1, 1e9));
2148
+ const speciesAt = (x, z, i) => {
2149
+ const n = species(x / 70, z / 70);
2150
+ if (n > .12) return "conifer";
2151
+ if (n < -.12) return "broadleaf";
2152
+ return i % 2 === 0 ? "conifer" : "broadleaf";
2153
+ };
2154
+ const clusters = sampleSpots(rng, hm, {
2155
+ count: 40,
2156
+ band: [0, 1],
2157
+ maxSlope: .18,
2158
+ margin: 8,
2159
+ clearing: clearing + 6
2160
+ });
2161
+ const groveTypes = clusters.map((s, i) => speciesAt(s.x, s.z, i));
2162
+ out.push(...clusters.map((s, i) => treeAt(`Grove${i + 1}`, s, rng, groveTypes[i], {
2163
+ count: 12,
2164
+ area: 13,
2165
+ height: [5.5, 8]
2166
+ })));
2167
+ clusters.forEach((s, i) => {
2168
+ if (i % 3 !== 0) return;
2169
+ out.push(treeAt(`Sapling${i + 1}`, s, rng, groveTypes[i], {
2170
+ count: 4,
2171
+ area: 19,
2172
+ height: [2.5, 3.4],
2173
+ sink: .12
2174
+ }));
2175
+ });
2176
+ const elderSpots = sampleSpots(rng, hm, {
2177
+ count: 6,
2178
+ band: [0, 1],
2179
+ maxSlope: .2,
2180
+ margin: 12,
2181
+ clearing: clearing + 4
2182
+ });
2183
+ out.push(...elderSpots.map((s, i) => treeAt(`Elder${i + 1}`, s, rng, speciesAt(s.x, s.z, i), {
2184
+ tier: "high",
2185
+ height: [8.4, 9.8]
2186
+ })));
2187
+ const accentSpots = sampleSpots(rng, hm, {
2188
+ count: 3,
2189
+ band: [0, 1],
2190
+ maxSlope: .18,
2191
+ margin: 10,
2192
+ clearing: clearing + 6
2193
+ });
2194
+ out.push(...accentSpots.map((s, i) => treeAt(`Accent${i + 1}`, s, rng, "broadleaf", {
2195
+ tier: "high",
2196
+ count: 3,
2197
+ area: 7,
2198
+ height: [5.6, 6.8]
2199
+ })));
2200
+ const bushSpots = sampleSpots(rng, hm, {
2201
+ count: 10,
2202
+ band: [0, 1],
2203
+ maxSlope: .14,
2204
+ margin: 9,
2205
+ clearing: clearing + 2
2206
+ });
2207
+ out.push(...bushSpots.map((s, i) => treeAt(`Bush${i + 1}`, s, rng, "bush", {
2208
+ count: 4,
2209
+ area: 10,
2210
+ height: [2.1, 3.1],
2211
+ sink: .12
2212
+ })));
2213
+ let fernIndex = 0;
2214
+ clusters.forEach((s, i) => {
2215
+ if (i % 3 === 2) return;
2216
+ const angle = rng.range(0, Math.PI * 2);
2217
+ const dist = rng.range(2, 4.5);
2218
+ const fernSeed = rng.int(1, 1e9);
2219
+ const fx = round2(clamp(s.x + Math.cos(angle) * dist, -limit, limit));
2220
+ const fz = round2(clamp(s.z + Math.sin(angle) * dist, -limit, limit));
2221
+ if (hm.slopeAt(fx, fz) > .09) return;
2222
+ fernIndex++;
2223
+ out.push({
2224
+ name: `Fern${fernIndex}`,
2225
+ type: "Foliage3D",
2226
+ props: {
2227
+ kind: "grass",
2228
+ style: "tufts",
2229
+ tuftStyle: "fern",
2230
+ area: [8, 8],
2231
+ density: 9,
2232
+ height: .4,
2233
+ colorA: "#4a6b34",
2234
+ colorB: "#82a258",
2235
+ seed: fernSeed,
2236
+ position: [
2237
+ fx,
2238
+ round2(hm.heightAt(fx, fz) + .02),
2239
+ fz
2240
+ ]
2241
+ }
2242
+ });
2243
+ });
2244
+ const patch = round2(size * .08);
2245
+ const patches = sampleSpots(rng, hm, {
2246
+ count: 5,
2247
+ band: [0, 1],
2248
+ maxSlope: .06,
2249
+ margin: patch / 2 + 8
2250
+ });
2251
+ out.push(...patches.map((s, i) => grassAt(`Grass${i + 1}`, s, rng, patch, 20, 0, {
2252
+ height: .24,
2253
+ colors: ["#4f7034", "#85a154"]
2254
+ })));
2255
+ const logCount = elderSpots.length > 0 ? rng.int(3, 5) : 0;
2256
+ for (let i = 0; i < logCount; i++) {
2257
+ const elder = elderSpots[i % elderSpots.length];
2258
+ const angle = rng.range(0, Math.PI * 2);
2259
+ const dist = rng.range(2.5, 4.5);
2260
+ const lx = round2(clamp(elder.x + Math.cos(angle) * dist, -limit, limit));
2261
+ const lz = round2(clamp(elder.z + Math.sin(angle) * dist, -limit, limit));
2262
+ out.push({
2263
+ name: `Log${i + 1}`,
2264
+ type: "Tree3D",
2265
+ props: {
2266
+ type: "dead",
2267
+ seed: rng.int(1, 1e9),
2268
+ height: round2(rng.range(4.2, 5.6)),
2269
+ trunkColor: "#4a4236",
2270
+ position: [
2271
+ lx,
2272
+ round2(hm.heightAt(lx, lz) + .12),
2273
+ lz
2274
+ ],
2275
+ rotation: [
2276
+ 0,
2277
+ rng.int(0, 359),
2278
+ round2(rng.range(81, 97))
2279
+ ]
2280
+ }
2281
+ });
2282
+ }
2283
+ sampleSpots(rng, hm, {
2284
+ count: 5,
2285
+ band: [0, 1],
2286
+ maxSlope: .25,
2287
+ margin: 9,
2288
+ clearing
2289
+ }).forEach((s, i) => {
2290
+ const radius = round2(rng.range(.16, .28));
2291
+ const height = round2(rng.range(.3, .55));
2292
+ out.push({
2293
+ name: `Stump${i + 1}`,
2294
+ type: "MeshInstance3D",
2295
+ props: {
2296
+ mesh: "cylinder",
2297
+ size: [
2298
+ radius,
2299
+ height,
2300
+ radius
2301
+ ],
2302
+ position: [
2303
+ s.x,
2304
+ round2(s.y + height / 2 - .06),
2305
+ s.z
2306
+ ],
2307
+ rotation: [
2308
+ 0,
2309
+ rng.int(0, 359),
2310
+ 0
2311
+ ],
2312
+ material: {
2313
+ color: rng.pick(STUMP_COLORS),
2314
+ roughness: 1
2315
+ },
2316
+ castShadow: true
2317
+ }
2318
+ });
2319
+ });
2320
+ const boulders = sampleSpots(rng, hm, {
2321
+ count: 8,
2322
+ band: [0, 1],
2323
+ maxSlope: .2,
2324
+ margin: 8
2325
+ });
2326
+ out.push(...rocksAt(boulders, rng, [.3, 1], mossTone));
2327
+ const carpet = round2(clearing * .75);
2328
+ const carpets = sampleSpots(rng, hm, {
2329
+ count: 3,
2330
+ band: [0, 1],
2331
+ maxSlope: .06,
2332
+ margin: 8,
2333
+ within: clearing - round2(carpet / Math.SQRT2)
2334
+ });
2335
+ out.push(...carpets.map((s, i) => grassAt(`Clearing${i + 1}`, s, rng, carpet, 30, 0, { colors: ["#5d8438", "#a8bc60"] })));
2336
+ const bed = round2(clearing * .45);
2337
+ const beds = sampleSpots(rng, hm, {
2338
+ count: 2,
2339
+ band: [0, 1],
2340
+ maxSlope: .06,
2341
+ margin: 8,
2342
+ within: clearing - round2(bed / Math.SQRT2)
2343
+ });
2344
+ out.push(...beds.map((s, i) => flowersAt(`Flowers${i + 1}`, s, rng, bed, "sparse")));
2345
+ break;
2346
+ }
2347
+ case "savanna": {
2348
+ const trees = sampleSpots(rng, hm, {
2349
+ count: 7,
2350
+ band: [0, .85],
2351
+ maxSlope: .35,
2352
+ margin: 8
2353
+ });
2354
+ out.push(...trees.map((s, i) => treeAt(`Tree${i + 1}`, s, rng, "broadleaf", {
2355
+ height: [3.5, 5],
2356
+ palette: ACACIA_PALETTE
2357
+ })));
2358
+ const patch = round2(size * .09);
2359
+ const carpets = sampleSpots(rng, hm, {
2360
+ count: 6,
2361
+ band: [0, 1],
2362
+ maxSlope: .05,
2363
+ margin: patch / 2 + 8
2364
+ });
2365
+ out.push(...carpets.map((s, i) => grassAt(`Carpet${i + 1}`, s, rng, patch, 26, 0, {
2366
+ height: .34,
2367
+ colors: ["#9a8f43", "#c9bd6a"]
2368
+ })));
2369
+ const boulders = sampleSpots(rng, hm, {
2370
+ count: 7,
2371
+ band: [0, 1],
2372
+ maxSlope: .5,
2373
+ margin: 8
2374
+ });
2375
+ out.push(...rocksAt(boulders, rng, [.4, 1.4]));
2376
+ const scrub = sampleSpots(rng, hm, {
2377
+ count: 4,
2378
+ band: [0, 1],
2379
+ maxSlope: .2,
2380
+ margin: 9
2381
+ });
2382
+ out.push(...scrub.map((s, i) => treeAt(`Scrub${i + 1}`, s, rng, "bush", {
2383
+ count: 3,
2384
+ area: 8,
2385
+ height: [1.4, 2.2]
2386
+ })));
2387
+ break;
2388
+ }
2389
+ case "snow": {
2390
+ const trees = sampleSpots(rng, hm, {
2391
+ count: 8,
2392
+ band: [0, .9],
2393
+ maxSlope: .4,
2394
+ margin: 8
2395
+ });
2396
+ out.push(...trees.map((s, i) => treeAt(`Tree${i + 1}`, s, rng, "conifer", {
2397
+ height: [4, 6.5],
2398
+ palette: SNOW_CONIFER_PALETTE
2399
+ })));
2400
+ const boulders = sampleSpots(rng, hm, {
2401
+ count: 10,
2402
+ band: [0, 1],
2403
+ maxSlope: .7,
2404
+ margin: 8
2405
+ });
2406
+ out.push(...rocksAt(boulders, rng, [.5, 1.8], snowTone));
2407
+ break;
2408
+ }
2409
+ case "wetland": {
2410
+ const bed = round2(size * .08);
2411
+ const reedBeds = sampleSpots(rng, hm, {
2412
+ count: 6,
2413
+ band: [0, .5],
2414
+ maxSlope: .06,
2415
+ margin: bed / 2 + 8
2416
+ });
2417
+ out.push(...reedBeds.map((s, i) => reedsAt(`Reeds${i + 1}`, s, rng, bed, 4)));
2418
+ const patch = round2(size * .08);
2419
+ const moss = sampleSpots(rng, hm, {
2420
+ count: 4,
2421
+ band: [.2, 1],
2422
+ maxSlope: .05,
2423
+ margin: patch / 2 + 8
2424
+ });
2425
+ out.push(...moss.map((s, i) => grassAt(`Moss${i + 1}`, s, rng, patch, 22, 0, {
2426
+ height: .22,
2427
+ colors: ["#3a5a2c", "#6f8a48"]
2428
+ })));
2429
+ const snags = sampleSpots(rng, hm, {
2430
+ count: 6,
2431
+ band: [.25, 1],
2432
+ maxSlope: .35,
2433
+ margin: 9
2434
+ });
2435
+ out.push(...snags.map((s, i) => treeAt(`Snag${i + 1}`, s, rng, "dead", { height: [4, 6] })));
2436
+ const bushes = sampleSpots(rng, hm, {
2437
+ count: 6,
2438
+ band: [.2, 1],
2439
+ maxSlope: .2,
2440
+ margin: 9
2441
+ });
2442
+ out.push(...bushes.map((s, i) => treeAt(`Bush${i + 1}`, s, rng, "bush", {
2443
+ count: 3,
2444
+ area: 7,
2445
+ height: [1.6, 2.6]
2446
+ })));
2447
+ const boulders = sampleSpots(rng, hm, {
2448
+ count: 5,
2449
+ band: [.2, 1],
2450
+ maxSlope: .3,
2451
+ margin: 8
2452
+ });
2453
+ out.push(...rocksAt(boulders, rng, [.3, 1], mossTone));
2454
+ break;
2455
+ }
2456
+ case "volcanic": {
2457
+ const trees = sampleSpots(rng, hm, {
2458
+ count: 7,
2459
+ band: [0, 1],
2460
+ maxSlope: .45,
2461
+ margin: 8
2462
+ });
2463
+ out.push(...trees.map((s, i) => treeAt(`Tree${i + 1}`, s, rng, "dead", { height: [4, 6.5] })));
2464
+ const boulders = sampleSpots(rng, hm, {
2465
+ count: 12,
2466
+ band: [0, 1],
2467
+ maxSlope: .7,
2468
+ margin: 8
2469
+ });
2470
+ out.push(...rocksAt(boulders, rng, [.4, 1.8], basaltTone));
2471
+ sampleSpots(rng, hm, {
2472
+ count: 3,
2473
+ band: [0, .6],
2474
+ maxSlope: .18,
2475
+ margin: 12
2476
+ }).forEach((s, i) => {
2477
+ rng.int(1, 1e9);
2478
+ out.push({
2479
+ name: `Smoke${i + 1}`,
2480
+ type: "Particles3D",
2481
+ props: {
2482
+ preset: "smoke",
2483
+ position: [
2484
+ s.x,
2485
+ round2(s.y + .2),
2486
+ s.z
2487
+ ],
2488
+ rate: 8,
2489
+ maxParticles: 64,
2490
+ colorStart: "#5a5048",
2491
+ colorEnd: "#2a2422",
2492
+ sizeStart: 18,
2493
+ sizeEnd: 44
2494
+ }
2495
+ });
2496
+ out.push({
2497
+ name: `Embers${i + 1}`,
2498
+ type: "Particles3D",
2499
+ props: {
2500
+ position: [
2501
+ s.x,
2502
+ round2(s.y + .1),
2503
+ s.z
2504
+ ],
2505
+ rate: 10,
2506
+ maxParticles: 48,
2507
+ lifetime: [.8, 1.8],
2508
+ speed: [12, 34],
2509
+ directionDeg: -90,
2510
+ spreadDeg: 40,
2511
+ gravity: [0, -18],
2512
+ sizeStart: 4,
2513
+ sizeEnd: 1,
2514
+ colorStart: "#ffce6a",
2515
+ colorEnd: "#d83a14",
2516
+ blend: "add"
2517
+ }
2518
+ });
2519
+ });
2520
+ break;
2521
+ }
2522
+ }
2523
+ return out;
2524
+ }
2525
+ /** Tint a skyLights() light toward the theme palette (fog-ready colors). */
2526
+ function tintLight(light, color, intensity) {
2527
+ return {
2528
+ ...light,
2529
+ props: {
2530
+ ...light.props,
2531
+ color,
2532
+ ...intensity !== void 0 ? { intensity } : {}
2533
+ }
2534
+ };
2535
+ }
2536
+ //#endregion
2537
+ //#region src/env/catalog.ts
2538
+ /**
2539
+ * The generator catalog: name → description, dimension and CLI-typed params.
2540
+ * Drives the `incanto-env` CLI (`--list`, generic `--<param>` flags) and any
2541
+ * editor UI, so help text can never drift from the code. `seed` is implicit
2542
+ * — every generator requires it.
2543
+ *
2544
+ * The 3D set is EXACTLY arena/terrain/maze — themes do the heavy lifting
2545
+ * (the old meadow/forest/island/rocks/clouds generators became terrain/arena
2546
+ * themes; their functions live on in the library, deprecated). Tuple-typed
2547
+ * library options (scatter items, platforms2d ranges) and the voxel-world
2548
+ * `generateVoxelTerrain` stay library-only and are not listed.
2549
+ */
2550
+ const GENERATORS = {
2551
+ arena: {
2552
+ description: "FPS stage: floor, 4 perimeter walls, obstacles, lights — themes: boxes (crates), ruins (broken stone rows), garden (hedges, grass, pool)",
2553
+ dimension: "3d",
2554
+ params: {
2555
+ theme: {
2556
+ type: "string",
2557
+ default: "boxes",
2558
+ options: [...ARENA_THEMES]
2559
+ },
2560
+ width: {
2561
+ type: "number",
2562
+ default: 30,
2563
+ min: 4
2564
+ },
2565
+ depth: {
2566
+ type: "number",
2567
+ default: 30,
2568
+ min: 4
2569
+ },
2570
+ wallHeight: {
2571
+ type: "number",
2572
+ default: 3,
2573
+ min: .5
2574
+ },
2575
+ obstacles: {
2576
+ type: "number",
2577
+ default: 8,
2578
+ min: 0
2579
+ }
2580
+ }
2581
+ },
2582
+ terrain: {
2583
+ description: "Heightfield world: StaticBody3D{heightfield} + Terrain3D + theme dressing (trees, rocks, grass, sea/clouds on island, broad swamp water on wetland, smoke/ember emitters on volcanic; maxHeight 0 = theme default; water adds a lake to non-island themes)",
2584
+ dimension: "3d",
2585
+ params: {
2586
+ theme: {
2587
+ type: "string",
2588
+ default: "island",
2589
+ options: [...TERRAIN_GENERATOR_THEMES]
2590
+ },
2591
+ size: {
2592
+ type: "number",
2593
+ default: 200,
2594
+ min: 40,
2595
+ max: 400
2596
+ },
2597
+ maxHeight: {
2598
+ type: "number",
2599
+ default: 0,
2600
+ min: 0
2601
+ },
2602
+ water: {
2603
+ type: "boolean",
2604
+ default: false
2605
+ }
2606
+ }
2607
+ },
2608
+ maze: {
2609
+ description: "Recursive-backtracker 3D maze, west→east — themes: stone, hedge (green + grass), canyon (sandstone + rim rocks)",
2610
+ dimension: "3d",
2611
+ params: {
2612
+ theme: {
2613
+ type: "string",
2614
+ default: "stone",
2615
+ options: [...MAZE_THEMES]
2616
+ },
2617
+ width: {
2618
+ type: "number",
2619
+ default: 8,
2620
+ min: 2,
2621
+ max: 40
2622
+ },
2623
+ depth: {
2624
+ type: "number",
2625
+ default: 8,
2626
+ min: 2,
2627
+ max: 40
2628
+ },
2629
+ cellSize: {
2630
+ type: "number",
2631
+ default: 2,
2632
+ min: .5
2633
+ },
2634
+ wallHeight: {
2635
+ type: "number",
2636
+ default: 2.5,
2637
+ min: .5
2638
+ }
2639
+ }
2640
+ },
2641
+ maze2d: {
2642
+ description: "The same maze algorithm as 2D ColorRect2D + StaticBody2D tiles",
2643
+ dimension: "2d",
2644
+ params: {
2645
+ cols: {
2646
+ type: "number",
2647
+ default: 10,
2648
+ min: 2,
2649
+ max: 40
2650
+ },
2651
+ rows: {
2652
+ type: "number",
2653
+ default: 8,
2654
+ min: 2,
2655
+ max: 40
2656
+ },
2657
+ cellPx: {
2658
+ type: "number",
2659
+ default: 64,
2660
+ min: 8
2661
+ }
2662
+ }
2663
+ },
2664
+ dungeon2d: {
2665
+ description: "Roguelike rooms + L-corridors: floor rects, wall bodies (32px tiles)",
2666
+ dimension: "2d",
2667
+ params: {
2668
+ rooms: {
2669
+ type: "number",
2670
+ default: 5,
2671
+ min: 1,
2672
+ max: 20
2673
+ },
2674
+ size: {
2675
+ type: "number",
2676
+ default: 960,
2677
+ min: 256
2678
+ }
2679
+ }
2680
+ },
2681
+ platforms2d: {
2682
+ description: "Left-to-right 2D platform course (tune ranges via the library)",
2683
+ dimension: "2d",
2684
+ params: { count: {
2685
+ type: "number",
2686
+ default: 10,
2687
+ min: 1
2688
+ } }
2689
+ }
2690
+ };
2691
+ const RUNNERS = {
2692
+ arena: generateArena,
2693
+ terrain: generateTerrain,
2694
+ maze: generateMaze,
2695
+ maze2d: generateMaze2D,
2696
+ dungeon2d: generateDungeon2D,
2697
+ platforms2d: generatePlatforms2D
2698
+ };
2699
+ /**
2700
+ * Invoke any catalog generator uniformly by name (what the CLI and editor
2701
+ * call). Unknown names are a hard error listing the valid ones; option
2702
+ * validation stays with each generator.
2703
+ */
2704
+ function runGenerator(name, opts) {
2705
+ const fn = RUNNERS[name];
2706
+ if (!fn) {
2707
+ const valid = Object.keys(RUNNERS);
2708
+ throw new IncantoError("BAD_FORMAT", `Unknown generator '${name}'. Valid: [${valid.join(", ")}] — the old meadow/forest/island/rocks/clouds generators became themes (e.g. terrain theme: 'meadow', arena theme: 'garden'); scatter is library-only (needs item templates).`, { validOptions: valid });
2709
+ }
2710
+ return fn(opts);
2711
+ }
2712
+ //#endregion
2713
+ //#region src/env/forest.ts
2714
+ const GROUND_COLOR$1 = "#3c5e2e";
2715
+ const GRASS_PATCHES = 3;
2716
+ const EDGE_MARGIN$1 = 1.5;
2717
+ /**
2718
+ * A primitive-tree forest: a ground slab planted with StaticBody3D
2719
+ * trunk+canopy trees whose density falls off into an optional central
2720
+ * clearing (hard-empty inside `clearing`, ramping to full density at 1.5×),
2721
+ * softened by a few Foliage3D grass patches, lit by sun/fill.
2722
+ *
2723
+ * @deprecated Use `runGenerator('terrain', { seed, theme: 'forest' })` — the
2724
+ * Terrain3D forest (dense mixed Tree3D groves, central clearing, grass
2725
+ * patches, heightfield collider) replaced this flat-slab version in the
2726
+ * catalog.
2727
+ */
2728
+ function generateForest(opts) {
2729
+ const { seed, size = 48, trees = 40, clearing = 6 } = opts;
2730
+ const rng = new Rng(seed);
2731
+ const children = [staticBox("Ground", [
2732
+ size,
2733
+ .2,
2734
+ size
2735
+ ], [
2736
+ 0,
2737
+ -.1,
2738
+ 0
2739
+ ], {
2740
+ material: {
2741
+ color: GROUND_COLOR$1,
2742
+ roughness: 1
2743
+ },
2744
+ receiveShadow: true
2745
+ })];
2746
+ const limit = size / 2 - EDGE_MARGIN$1;
2747
+ const rampEdge = clearing * 1.5;
2748
+ let planted = 0;
2749
+ for (let attempt = 0; attempt < trees * 8 && planted < trees; attempt++) {
2750
+ const x = round2(rng.range(-limit, limit));
2751
+ const z = round2(rng.range(-limit, limit));
2752
+ const distance = Math.hypot(x, z);
2753
+ if (clearing > 0) {
2754
+ if (distance < clearing) continue;
2755
+ if (distance < rampEdge && rng.next() > (distance - clearing) / (rampEdge - clearing)) continue;
2756
+ }
2757
+ planted++;
2758
+ children.push(tree(planted, x, z, rng));
2759
+ }
2760
+ for (let i = 1; i <= GRASS_PATCHES; i++) {
2761
+ const patch = round2(size * rng.range(.18, .3));
2762
+ const reach = size / 2 - patch / 2;
2763
+ children.push({
2764
+ name: `Grass${i}`,
2765
+ type: "Foliage3D",
2766
+ props: {
2767
+ kind: "grass",
2768
+ area: [patch, patch],
2769
+ density: 8,
2770
+ seed: rng.int(1, 1e9),
2771
+ position: [
2772
+ round2(rng.range(-reach, reach)),
2773
+ 0,
2774
+ round2(rng.range(-reach, reach))
2775
+ ]
2776
+ }
2777
+ });
2778
+ }
2779
+ children.push(...skyLights(size));
2780
+ return {
2781
+ name: "Forest",
2782
+ type: "Node3D",
2783
+ children
2784
+ };
2785
+ }
2786
+ //#endregion
2787
+ //#region src/env/insert.ts
2788
+ /**
2789
+ * PURE insertion: a NEW scene JSON with `node` appended under the node at
2790
+ * `at` (default: the scene root). Neither input is mutated.
2791
+ *
2792
+ * `at` is the '/'-joined chain of node NAMES from the root, ROOT INCLUDED —
2793
+ * 'Root/Level' targets the root's child 'Level' (NodePath semantics applied
2794
+ * to the JSON tree). A missing path is a hard NODE_NOT_FOUND listing what IS
2795
+ * there. Sibling name clashes are fine — the loader uniquifies on load.
2796
+ */
2797
+ function insertIntoScene(scene, node, at) {
2798
+ const out = structuredClone(scene);
2799
+ const target = resolveJsonPath(out.root, at);
2800
+ target.children = [...target.children ?? [], structuredClone(node)];
2801
+ return out;
2802
+ }
2803
+ function resolveJsonPath(root, at) {
2804
+ if (at === void 0 || at === "" || at === ".") return root;
2805
+ const segments = (at.startsWith("/") ? at.slice(1) : at).split("/");
2806
+ if (segments[0] !== root.name) throw new IncantoError("NODE_NOT_FOUND", `Scene path '${at}' must start at the root, which is named '${root.name}' (e.g. '${root.name}/Child').`, {
2807
+ path: at,
2808
+ validOptions: [root.name]
2809
+ });
2810
+ let current = root;
2811
+ for (const segment of segments.slice(1)) {
2812
+ const next = (current.children ?? []).find((child) => child.name === segment);
2813
+ if (!next) {
2814
+ const names = (current.children ?? []).map((child) => child.name);
2815
+ throw new IncantoError("NODE_NOT_FOUND", `No node at '${at}': '${current.name}' has no child '${segment}'. Children: [${names.join(", ")}].`, {
2816
+ path: at,
2817
+ validOptions: names
2818
+ });
2819
+ }
2820
+ current = next;
2821
+ }
2822
+ return current;
2823
+ }
2824
+ //#endregion
2825
+ //#region src/env/voxel-terrain.ts
2826
+ /** VOXEL_PALETTE tiles (see voxel-grid-3d.ts): grass top, dirt fill, bedrock base. */
2827
+ const GRASS$1 = 1;
2828
+ const DIRT$1 = 2;
2829
+ const BEDROCK$1 = 5;
2830
+ /** Lattice spacing for the value noise — bigger = smoother hills. */
2831
+ const LATTICE_STEP = 8;
2832
+ /**
2833
+ * A voxel heightfield for minecraft-style worlds: smooth-ish value noise (a
2834
+ * seeded lattice, bilinearly interpolated with a smoothstep fade) baked into
2835
+ * a VoxelGrid3D `voxels` prop as [x,y,z,tile] tuples — solid columns of
2836
+ * bedrock/dirt/grass centered on the origin. Colliders stay the game's job
2837
+ * (chunk trimeshes near the player — see the minecraft template).
2838
+ *
2839
+ * Library-only by design: the `terrain` catalog generator emits a smooth
2840
+ * Terrain3D heightfield instead — reach for THIS when blocks are the point
2841
+ * (digging, building, VoxelGrid3D worlds).
2842
+ */
2843
+ function generateVoxelTerrain(opts) {
2844
+ const { seed, size = 32, height = 8, water = false } = opts;
2845
+ const noise = makeValueNoise(new Rng(seed), size);
2846
+ const half = Math.floor(size / 2);
2847
+ const voxels = [];
2848
+ for (let x = 0; x < size; x++) for (let z = 0; z < size; z++) {
2849
+ const columnHeight = 1 + Math.round(noise(x, z) * (height - 1));
2850
+ for (let y = 0; y <= columnHeight; y++) {
2851
+ const tile = y === 0 ? BEDROCK$1 : y === columnHeight ? GRASS$1 : DIRT$1;
2852
+ voxels.push([
2853
+ x - half,
2854
+ y,
2855
+ z - half,
2856
+ tile
2857
+ ]);
2858
+ }
2859
+ }
2860
+ const children = [{
2861
+ name: "Voxels",
2862
+ type: "VoxelGrid3D",
2863
+ props: { voxels }
2864
+ }];
2865
+ if (water) {
2866
+ const waterLevel = round2(Math.max(1, height * .35) + .4);
2867
+ children.push({
2868
+ name: "Water",
2869
+ type: "Water3D",
2870
+ props: {
2871
+ size: [size, size],
2872
+ position: [
2873
+ 0,
2874
+ waterLevel,
2875
+ 0
2876
+ ]
2877
+ }
2878
+ });
2879
+ }
2880
+ return {
2881
+ name: "VoxelTerrain",
2882
+ type: "Node3D",
2883
+ children
2884
+ };
2885
+ }
2886
+ /**
2887
+ * Value noise over [0,size)² in [0,1] — random lattice + smooth interpolation.
2888
+ * Shared with `generateIsland` (which shapes it with a radial falloff).
2889
+ */
2890
+ function makeValueNoise(rng, size) {
2891
+ const cells = Math.ceil(size / LATTICE_STEP) + 1;
2892
+ const lattice = [];
2893
+ for (let i = 0; i < cells * cells; i++) lattice.push(rng.next());
2894
+ const at = (ix, iz) => lattice[Math.min(iz, cells - 1) * cells + Math.min(ix, cells - 1)];
2895
+ return (x, z) => {
2896
+ const gx = x / LATTICE_STEP;
2897
+ const gz = z / LATTICE_STEP;
2898
+ const x0 = Math.floor(gx);
2899
+ const z0 = Math.floor(gz);
2900
+ const tx = fade(gx - x0);
2901
+ const tz = fade(gz - z0);
2902
+ return lerp(lerp(at(x0, z0), at(x0 + 1, z0), tx), lerp(at(x0, z0 + 1), at(x0 + 1, z0 + 1), tx), tz);
2903
+ };
2904
+ }
2905
+ function fade(t) {
2906
+ return t * t * (3 - 2 * t);
2907
+ }
2908
+ function lerp(a, b, t) {
2909
+ return a + (b - a) * t;
2910
+ }
2911
+ //#endregion
2912
+ //#region src/env/island.ts
2913
+ /** VOXEL_PALETTE tiles (see voxel-grid-3d.ts): grass top, dirt fill, bedrock base, sandy shore. */
2914
+ const GRASS = 1;
2915
+ const DIRT = 2;
2916
+ const BEDROCK = 5;
2917
+ const SAND = 11;
2918
+ /**
2919
+ * A voxel island: the terrain value noise shaped by a radial dome falloff —
2920
+ * tall grassy center, sandy shore where columns dip to the waterline, bedrock
2921
+ * base — with an optional Water3D ring lapping the beach and sun/fill lights.
2922
+ * Colliders stay the game's job (chunk trimeshes near the player — see the
2923
+ * minecraft template).
2924
+ *
2925
+ * @deprecated Use `runGenerator('terrain', { seed, theme: 'island' })` — the
2926
+ * Terrain3D island (smooth heightfield, splatted beach/cliffs/snow, computed
2927
+ * drownable sea level, heightfield collider) replaced this in the catalog.
2928
+ * Kept for voxel-world library users.
2929
+ */
2930
+ function generateIsland(opts) {
2931
+ const { seed, radius = 16, height = 8, water = true } = opts;
2932
+ const rng = new Rng(seed);
2933
+ const size = radius * 2 + 1;
2934
+ const noise = makeValueNoise(rng, size);
2935
+ const waterLevel = Math.max(1, Math.round(height * .3));
2936
+ const voxels = [];
2937
+ for (let x = -radius; x <= radius; x++) for (let z = -radius; z <= radius; z++) {
2938
+ const distance = Math.hypot(x, z);
2939
+ if (distance > radius) continue;
2940
+ const falloff = 1 - (distance / radius) ** 2;
2941
+ const columnHeight = Math.round(height * falloff * (.55 + .45 * noise(x + radius, z + radius)));
2942
+ for (let y = 0; y <= columnHeight; y++) {
2943
+ const top = columnHeight <= waterLevel + 1 ? SAND : GRASS;
2944
+ const tile = y === 0 ? BEDROCK : y === columnHeight ? top : DIRT;
2945
+ voxels.push([
2946
+ x,
2947
+ y,
2948
+ z,
2949
+ tile
2950
+ ]);
2951
+ }
2952
+ }
2953
+ const children = [{
2954
+ name: "Voxels",
2955
+ type: "VoxelGrid3D",
2956
+ props: { voxels }
2957
+ }];
2958
+ if (water) children.push({
2959
+ name: "Water",
2960
+ type: "Water3D",
2961
+ props: {
2962
+ size: [round2(size * 1.6), round2(size * 1.6)],
2963
+ position: [
2964
+ 0,
2965
+ round2(waterLevel + .4),
2966
+ 0
2967
+ ]
2968
+ }
2969
+ });
2970
+ children.push(...skyLights(size));
2971
+ return {
2972
+ name: "Island",
2973
+ type: "Node3D",
2974
+ children
2975
+ };
2976
+ }
2977
+ //#endregion
2978
+ //#region src/env/meadow.ts
2979
+ const FOLIAGE_OPTIONS = [
2980
+ "grass",
2981
+ "flowers",
2982
+ "mixed"
2983
+ ];
2984
+ const GROUND_COLOR = "#4d7c3a";
2985
+ const FLOWER_COLOR_A = "#c95b8e";
2986
+ const FLOWER_COLOR_B = "#e8d36b";
2987
+ const ROCK_SIZE = [.3, .9];
2988
+ const EDGE_MARGIN = 2;
2989
+ /**
2990
+ * A grass field (잔디밭): a StaticBody3D ground slab carpeted with an
2991
+ * instanced Foliage3D field, plus scattered boulders, primitive trees and
2992
+ * sun/fill lighting. 'mixed' lays the full grass field with a smaller flower
2993
+ * patch on top.
2994
+ *
2995
+ * @deprecated Use `runGenerator('terrain', { seed, theme: 'meadow' })` — the
2996
+ * rolling Terrain3D meadow (blade carpets, broadleaf groves, rocks, a real
2997
+ * heightfield collider) replaced this flat-slab version in the catalog.
2998
+ */
2999
+ function generateMeadow(opts) {
3000
+ const { seed, size = 40, foliage = "grass", rocks = 6, trees = 4 } = opts;
3001
+ if (!FOLIAGE_OPTIONS.includes(foliage)) throw new IncantoError("BAD_FORMAT", `generateMeadow foliage must be one of [${FOLIAGE_OPTIONS.join(", ")}], got '${foliage}'.`, { validOptions: FOLIAGE_OPTIONS });
3002
+ const rng = new Rng(seed);
3003
+ const children = [staticBox("Ground", [
3004
+ size,
3005
+ .2,
3006
+ size
3007
+ ], [
3008
+ 0,
3009
+ -.1,
3010
+ 0
3011
+ ], {
3012
+ material: {
3013
+ color: GROUND_COLOR,
3014
+ roughness: 1
3015
+ },
3016
+ receiveShadow: true
3017
+ })];
3018
+ const field = round2(size * .92);
3019
+ if (foliage === "grass" || foliage === "mixed") children.push(foliageField("Grass", "grass", [field, field], rng));
3020
+ if (foliage === "flowers" || foliage === "mixed") {
3021
+ const patch = foliage === "mixed" ? round2(size * .4) : field;
3022
+ const offset = foliage === "mixed" ? [
3023
+ round2(rng.range(-size / 6, size / 6)),
3024
+ 0,
3025
+ round2(rng.range(-size / 6, size / 6))
3026
+ ] : void 0;
3027
+ children.push(foliageField("Flowers", "flowers", [patch, patch], rng, {
3028
+ density: 4,
3029
+ height: .32,
3030
+ colorA: FLOWER_COLOR_A,
3031
+ colorB: FLOWER_COLOR_B,
3032
+ ...offset ? { position: offset } : {}
3033
+ }));
3034
+ }
3035
+ const limit = size / 2 - EDGE_MARGIN;
3036
+ for (let i = 1; i <= trees; i++) children.push(tree(i, round2(rng.range(-limit, limit)), round2(rng.range(-limit, limit)), rng));
3037
+ for (let i = 1; i <= rocks; i++) children.push(rock(i, round2(rng.range(-limit, limit)), round2(rng.range(-limit, limit)), rng, ROCK_SIZE));
3038
+ children.push(...skyLights(size));
3039
+ return {
3040
+ name: "Meadow",
3041
+ type: "Node3D",
3042
+ children
3043
+ };
3044
+ }
3045
+ /** A Foliage3D field with a derived placement seed (deterministic from the rng). */
3046
+ function foliageField(name, kind, area, rng, extra = {}) {
3047
+ return {
3048
+ name,
3049
+ type: "Foliage3D",
3050
+ props: {
3051
+ kind,
3052
+ area,
3053
+ seed: rng.int(1, 1e9),
3054
+ ...extra
3055
+ }
3056
+ };
3057
+ }
3058
+ //#endregion
3059
+ //#region src/env/rocks.ts
3060
+ /**
3061
+ * Clustered boulders: a few cluster anchors with rocks scattered tightly
3062
+ * around them — squashed, gray-jittered MeshInstance3D spheres (visual props,
3063
+ * no colliders; wrap one in a StaticBody3D yourself if it must block).
3064
+ *
3065
+ * @deprecated The terrain themes scatter their own rocks (alpine snow-band
3066
+ * boulders, desert/plains/meadow clusters) — use `runGenerator('terrain',
3067
+ * { seed, theme })` for whole worlds. Kept for custom library compositions.
3068
+ */
3069
+ function generateRocks(opts) {
3070
+ const { seed, count = 12, sizeRange = [.4, 1.6] } = opts;
3071
+ const [areaX, areaZ] = extent(opts.area, [24, 24]);
3072
+ const rng = new Rng(seed);
3073
+ const clusters = [];
3074
+ const clusterCount = clamp(Math.round(count / 4), 1, 4);
3075
+ for (let i = 0; i < clusterCount; i++) clusters.push([rng.range(-areaX * .3, areaX * .3), rng.range(-areaZ * .3, areaZ * .3)]);
3076
+ const spread = Math.min(areaX, areaZ) / 5;
3077
+ const children = [];
3078
+ for (let i = 1; i <= count; i++) {
3079
+ const [cx, cz] = rng.pick(clusters);
3080
+ const x = round2(clamp(cx + rng.range(-spread, spread), -areaX / 2, areaX / 2));
3081
+ const z = round2(clamp(cz + rng.range(-spread, spread), -areaZ / 2, areaZ / 2));
3082
+ children.push(rock(i, x, z, rng, sizeRange));
3083
+ }
3084
+ return {
3085
+ name: "Rocks",
3086
+ type: "Node3D",
3087
+ children
3088
+ };
3089
+ }
3090
+ //#endregion
3091
+ //#region src/env/scatter.ts
3092
+ const SCALE_JITTER = [.8, 1.2];
3093
+ /**
3094
+ * Scatter N instances of caller-provided node templates over an XZ area:
3095
+ * random position, y-rotation, and uniform scale jitter (multiplied onto the
3096
+ * template's own scale). The template's `position[1]` survives as the ground
3097
+ * offset. Library-only — the items make it too open-ended for CLI flags.
3098
+ */
3099
+ function generateScatter(opts) {
3100
+ const { seed, count = 20, area = [20, 20], items } = opts;
3101
+ if (items.length === 0) throw new IncantoError("BAD_FORMAT", "generateScatter needs at least one item template, e.g. {type: 'MeshInstance3D', props: {mesh: 'box'}}.");
3102
+ const rng = new Rng(seed);
3103
+ const totalWeight = items.reduce((sum, item) => sum + (item.weight ?? 1), 0);
3104
+ const children = [];
3105
+ for (let i = 1; i <= count; i++) {
3106
+ const item = pickWeighted(items, rng.next() * totalWeight);
3107
+ const template = structuredClone(item.props ?? {});
3108
+ const x = round2(rng.range(-area[0] / 2, area[0] / 2));
3109
+ const z = round2(rng.range(-area[1] / 2, area[1] / 2));
3110
+ const yaw = rng.int(0, 359);
3111
+ const jitter = round2(rng.range(SCALE_JITTER[0], SCALE_JITTER[1]));
3112
+ const baseScale = Array.isArray(template.scale) ? template.scale : [
3113
+ 1,
3114
+ 1,
3115
+ 1
3116
+ ];
3117
+ const y = Array.isArray(template.position) ? template.position[1] ?? 0 : 0;
3118
+ children.push({
3119
+ name: `${item.type}${i}`,
3120
+ type: item.type,
3121
+ props: {
3122
+ ...template,
3123
+ position: [
3124
+ x,
3125
+ y,
3126
+ z
3127
+ ],
3128
+ rotation: [
3129
+ 0,
3130
+ yaw,
3131
+ 0
3132
+ ],
3133
+ scale: baseScale.map((s) => round2((typeof s === "number" ? s : 1) * jitter))
3134
+ }
3135
+ });
3136
+ }
3137
+ return {
3138
+ name: "Scatter",
3139
+ type: "Node3D",
3140
+ children
3141
+ };
3142
+ }
3143
+ function pickWeighted(items, roll) {
3144
+ let cursor = roll;
3145
+ for (const item of items) {
3146
+ cursor -= item.weight ?? 1;
3147
+ if (cursor < 0) return item;
3148
+ }
3149
+ return items[items.length - 1];
3150
+ }
3151
+ //#endregion
3152
+ export { ARENA_THEMES, GENERATORS, MAZE_THEMES, TERRAIN_GENERATOR_THEMES, carveMaze, generateArena, generateClouds, generateDungeon2D, generateForest, generateIsland, generateMaze, generateMaze2D, generateMeadow, generatePlatforms2D, generateRocks, generateScatter, generateTerrain, generateVoxelTerrain, insertIntoScene, makeValueNoise, mazeEnvironment, runGenerator, terrainEnvironment };