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.
- package/LICENSE +30 -0
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES.md +88 -0
- package/assets/audio/attacked.mp3 +0 -0
- package/assets/audio/explosion.mp3 +0 -0
- package/assets/audio/gold_loot.mp3 +0 -0
- package/assets/audio/heal.mp3 +0 -0
- package/assets/audio/hit_metal_bang.mp3 +0 -0
- package/assets/audio/ice_spear.mp3 +0 -0
- package/assets/audio/monster_died.mp3 +0 -0
- package/assets/audio/slash.mp3 +0 -0
- package/assets/audio/smite.mp3 +0 -0
- package/assets/audio/spells_cast.mp3 +0 -0
- package/assets/audio/ui_click.wav +0 -0
- package/assets/audio/walk.mp3 +0 -0
- package/assets/catalog.json +390 -0
- package/assets/characters/2dbasic.json +41 -0
- package/assets/characters/2dbasic.png +0 -0
- package/assets/characters/ghost.json +46 -0
- package/assets/characters/ghost.png +0 -0
- package/assets/characters/goblin.json +40 -0
- package/assets/characters/goblin.png +0 -0
- package/assets/characters/medieval-knight.json +41 -0
- package/assets/characters/medieval-knight.png +0 -0
- package/assets/effects/swoosh.png +0 -0
- package/assets/items/box.png +0 -0
- package/assets/items/buff_potion.png +0 -0
- package/assets/items/coin.png +0 -0
- package/assets/items/gem.png +0 -0
- package/assets/items/gold.png +0 -0
- package/assets/items/hp_potion.png +0 -0
- package/assets/items/locked_item_box.png +0 -0
- package/assets/items/map.png +0 -0
- package/assets/items/resurrection_potion.png +0 -0
- package/assets/items/super_box.png +0 -0
- package/assets/items/trap.png +0 -0
- package/assets/tiles/floor00.jpg +0 -0
- package/assets/tiles/minecraft-tiles.png +0 -0
- package/assets/tiles/wall00.jpg +0 -0
- package/assets/vegetation/ash_color.png +0 -0
- package/assets/vegetation/aspen_color.png +0 -0
- package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
- package/assets/vegetation/ground/dirt_color.jpg +0 -0
- package/assets/vegetation/ground/dirt_normal.jpg +0 -0
- package/assets/vegetation/ground/grass.jpg +0 -0
- package/assets/vegetation/oak_color.png +0 -0
- package/assets/vegetation/pine_color.png +0 -0
- package/bin/incanto-assets.mjs +107 -0
- package/bin/incanto-check.mjs +107 -0
- package/bin/incanto-editor.mjs +343 -0
- package/bin/incanto-env.mjs +144 -0
- package/bin/incanto-model.mjs +296 -0
- package/bin/incanto-play.mjs +219 -0
- package/bin/incanto-skills.mjs +71 -0
- package/dist/2d.d.ts +642 -0
- package/dist/2d.js +44 -0
- package/dist/3d.d.ts +1860 -0
- package/dist/3d.js +5 -0
- package/dist/agent8-DzU2fFyH.js +129 -0
- package/dist/audio-player-DqUR3XFs.d.ts +110 -0
- package/dist/behavior-BAQq7HGM.d.ts +851 -0
- package/dist/create-game-BdjpTHrW.js +1725 -0
- package/dist/create-game-CZHROKcT.js +527 -0
- package/dist/debug-draw-CZmOYjL2.js +13 -0
- package/dist/debug.d.ts +66 -0
- package/dist/debug.js +658 -0
- package/dist/duplicate-DP2WPYom.js +22 -0
- package/dist/env.d.ts +430 -0
- package/dist/env.js +3152 -0
- package/dist/errors-BMFaY68Q.d.ts +33 -0
- package/dist/errors-BpWbnbb_.js +13 -0
- package/dist/gameplay-Ccruc3Wd.js +1501 -0
- package/dist/gameplay.d.ts +543 -0
- package/dist/gameplay.js +2 -0
- package/dist/heightmap-CroQPEER.js +185 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +62 -0
- package/dist/json-BLk7H2Qa.js +30 -0
- package/dist/loader-CGs_G-r0.js +919 -0
- package/dist/loader-Mo0KghCv.d.ts +41 -0
- package/dist/net.d.ts +427 -0
- package/dist/net.js +772 -0
- package/dist/noise-CGUMx44x.js +82 -0
- package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
- package/dist/particle-sim-DYuSUxvK.js +1319 -0
- package/dist/physics-2d-KuMWPTf6.js +288 -0
- package/dist/physics-3d-Dl67vOLT.js +434 -0
- package/dist/react.d.ts +65 -0
- package/dist/react.js +209 -0
- package/dist/register-BuUV1_KB.js +561 -0
- package/dist/register-CNlYAS1_.js +10634 -0
- package/dist/register-DPEV9_9t.js +851 -0
- package/dist/register-Dasmnurl.js +374 -0
- package/dist/registry-BVJ2HbCn.js +132 -0
- package/dist/rng-DP-SR7eg.js +38 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-CcoWb32N.d.ts +104 -0
- package/dist/test.d.ts +158 -0
- package/dist/test.js +275 -0
- package/dist/touch-031PxtCR.js +208 -0
- package/dist/vite.d.ts +26 -0
- package/dist/vite.js +57 -0
- package/editor/assets/GameServer-C56iOUgF.js +1 -0
- package/editor/assets/agent8-Bp7QFI7v.js +1 -0
- package/editor/assets/index-DF3tMeKJ.css +1 -0
- package/editor/assets/index-Dl2pjA8e.js +7365 -0
- package/editor/assets/rapier-CEuLKeCu.js +1 -0
- package/editor/assets/rapier-DE6a0vmv.js +1 -0
- package/editor/index.html +169 -0
- package/package.json +97 -0
- package/schemas/scene.schema.json +4254 -0
- package/skills/README.md +9 -0
- package/skills/incanto-3d-character.md +229 -0
- package/skills/incanto-3d-models.md +151 -0
- package/skills/incanto-assets.md +118 -0
- package/skills/incanto-audio.md +309 -0
- package/skills/incanto-behaviors-and-scripts.md +169 -0
- package/skills/incanto-building-2d-games.md +242 -0
- package/skills/incanto-building-3d-games.md +245 -0
- package/skills/incanto-editor.md +163 -0
- package/skills/incanto-environment.md +743 -0
- package/skills/incanto-gameplay-behaviors.md +707 -0
- package/skills/incanto-multiplayer.md +264 -0
- package/skills/incanto-node-reference.md +797 -0
- package/skills/incanto-physics-and-input.md +164 -0
- package/skills/incanto-scene-json-authoring.md +325 -0
- package/skills/incanto-verifying-your-game.md +191 -0
- package/skills/incanto-web-integration.md +96 -0
- package/templates/agent8-server.js +84 -0
- 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 };
|