scena3d 0.3.0 → 0.4.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/README.md CHANGED
@@ -56,6 +56,27 @@ game.onUpdate(() => { // ← SC
56
56
 
57
57
  Neither library imports the other — the obstacle shape (`{ center, radius }`) is structural. Run the full demo (`npm run dev` — gama3d comes from npm): a day-night cycle over terrain, lakes, a windblown forest, a seeded hamlet whose windows ignite at dusk, and a dirt road whose single authored curve is at once the visual ribbon, the scatter keep-out, and the wardens' patrol route. Append `?t=0.85` to freeze the time of day.
58
58
 
59
+ ## A world as JSON
60
+
61
+ Everything above can also be declared instead of coded — a `SceneManifest` is plain data (store it, diff it, send it over the network), and `buildScene` applies all the cross-feature wiring automatically: scatters stay ashore, off roads and out of the village; the village avoids water and paths; its lamps and windows feed the day cycle.
62
+
63
+ ```ts
64
+ const world = buildScene({
65
+ seed: 18,
66
+ palette: 'autumn',
67
+ terrain: { size: 90, amplitude: 5 },
68
+ water: { level: 0.25 },
69
+ dayCycle: { dayLength: 120 },
70
+ paths: [{ points: roadPoints, loop: true }],
71
+ village: { radius: 9, houses: 5 },
72
+ scatters: [{ density: 0.05, items: [{ type: 'tree', weight: 4 }, { type: 'rock' }],
73
+ lod: { distance: 34 } }], // far tiles collapse to cones
74
+ }, scene);
75
+ game.onUpdate((t) => world.update(t.delta));
76
+ ```
77
+
78
+ Run it: `npm run dev:manifest`. For handcrafted interiors there are kits — `assembleKit(['####', '#.S#', '#..D', '####'])` turns an ASCII map into snapped walls, floors, doorways, torches, spawn points and obstacles. And for levels authored in Blender/glTF, `extractMarkers(gltf.scene)` reads `spawn_*` / `route_*_0` / `obstacle_*` / `keepout_*` empties into spawns, ordered patrol routes and steering/scatter metadata.
79
+
59
80
  ## API
60
81
 
61
82
  | Area | Exports |
@@ -64,9 +85,11 @@ Neither library imports the other — the obstacle shape (`{ center, radius }`)
64
85
  | Environment | `createTerrain` (with `heightAt(x, z)` and `waterLevel` sand bands), `createSky`, `createLightingRig(...)`, `applyFog(...)`, `createWater` + `aboveWater` mask, `createDayCycle` (one `timeOfDay` drives sun/sky/fog/lamps), `applyWind` (vegetation sway), `createPath` (ribbon + patrol route + keep-out from one curve) |
65
86
  | Scattering | `scatter({ seed, area, surface, density \| count, items, mask, minSpacing, keepOut })` → `{ group, placements, obstacles, count }` |
66
87
  | Generators | `createVillage({ seed, center, radius, houses, surface, mask })` → `{ group, props, obstacles, lamps, keepOut }` — a hamlet whose windows and lamps hand straight to `createDayCycle`, buildings to `ObstacleAvoidance`, clearing to `scatter` |
88
+ | Kits | `KIT_UNIT`, `assembleKit(asciiRows)` → `{ group, obstacles, spawns, torches, floorAt }` — grid-snapped walls/floors/doorways as two InstancedMeshes |
89
+ | Scene assembly | `buildScene(manifest, scene?)` → a whole wired world from plain JSON; `extractMarkers(root)` → `{ spawns, routes, obstacles, keepOut }` from naming conventions |
67
90
  | Core | `Rng` (seeded), `valueNoise2`/`fractalNoise2`, `PALETTES`, `collectObstacles` |
68
91
 
69
- Scatter placement uses density noise for natural clumping and clearings, a spatial hash for minimum spacing, and per-item visual variants; rendering merges everything into `InstancedMesh`es (one draw call per prop part).
92
+ Scatter placement uses density noise for natural clumping and clearings, a spatial hash for minimum spacing, and per-item visual variants; rendering merges everything into `InstancedMesh`es (one draw call per prop part). Opt into `lod: { distance, tileSize }` and placements bucket into tiles that swap to each item's `createFar` variant beyond the distance (10% hysteresis; call `result.update(camera)` each frame).
70
93
 
71
94
  ## Roadmap
72
95
 
@@ -80,9 +103,9 @@ Scatter placement uses density noise for natural clumping and clearings, a spati
80
103
  - [x] Paths: one curve = visual ribbon + scatter keep-out + GAMA patrol route
81
104
  - [x] Seed-stability snapshot tests (output frozen within a minor version)
82
105
  - [x] Buildings (house, watchtower, well) and ruins; `createVillage` hamlet generator with the full gameplay handshake
83
- - [ ] Kits: dungeon/village pieces with shared snap dimensions
84
- - [ ] Declarative scene manifests (JSON → scene), Blender marker conventions (`spawn_*`, `route_*`, `nav_*`)
85
- - [ ] LOD tiers for scatter
106
+ - [x] Kits: ASCII maps → grid-snapped dungeon/compound pieces (`assembleKit`)
107
+ - [x] Declarative scene manifests (JSON → scene) and Blender marker conventions (`spawn_*`, `route_*`, `obstacle_*`, `keepout_*`)
108
+ - [x] LOD tiles for scatter (`createFar` variants, hysteresis)
86
109
  - [ ] CC0 asset-pack adapters (Kenney/Quaternius) and a KTX2/Draco pipeline
87
110
  - [ ] Docs site with live playground (reusing GAMA's runner)
88
111
 
@@ -90,10 +113,11 @@ Scatter placement uses density noise for natural clumping and clearings, a spati
90
113
 
91
114
  ```bash
92
115
  npm install
93
- npm test # 49 vitest unit tests (determinism, metadata, scatter rules, snapshots)
116
+ npm test # 62 vitest unit tests (determinism, metadata, scatter rules, snapshots)
94
117
  npm run typecheck
95
118
  npm run build # tsup → dist (ESM + CJS + d.ts)
96
119
  npm run dev # the SCENA × GAMA living-forest demo
120
+ npm run dev:manifest # the same kind of world, built from one JSON manifest
97
121
  ```
98
122
 
99
123
  ## License
package/dist/index.cjs CHANGED
@@ -21,11 +21,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DEFAULT_PALETTE: () => DEFAULT_PALETTE,
24
+ KIT_UNIT: () => KIT_UNIT,
24
25
  PALETTES: () => PALETTES,
25
26
  Rng: () => Rng,
26
27
  aboveWater: () => aboveWater,
27
28
  applyFog: () => applyFog,
28
29
  applyWind: () => applyWind,
30
+ assembleKit: () => assembleKit,
31
+ buildScene: () => buildScene,
29
32
  collectObstacles: () => collectObstacles,
30
33
  createBush: () => createBush,
31
34
  createCrate: () => createCrate,
@@ -45,6 +48,7 @@ __export(index_exports, {
45
48
  createVillage: () => createVillage,
46
49
  createWater: () => createWater,
47
50
  createWell: () => createWell,
51
+ extractMarkers: () => extractMarkers,
48
52
  fractalNoise2: () => fractalNoise2,
49
53
  hash2: () => hash2,
50
54
  scatter: () => scatter,
@@ -1262,8 +1266,139 @@ function createVillage(options = {}) {
1262
1266
  };
1263
1267
  }
1264
1268
 
1265
- // src/scatter/scatter.ts
1269
+ // src/kits/kit.ts
1266
1270
  var import_three17 = require("three");
1271
+ var KIT_UNIT = 2;
1272
+ function assembleKit(rows, options = {}) {
1273
+ const rng = new Rng(options.seed ?? 1);
1274
+ const palette = options.palette ?? DEFAULT_PALETTE;
1275
+ const unit = options.unit ?? KIT_UNIT;
1276
+ const wallHeight = options.wallHeight ?? 2.6;
1277
+ let torchLights = options.torchLights ?? 4;
1278
+ const height = rows.length;
1279
+ const width = Math.max(...rows.map((r) => r.length), 0);
1280
+ const originX = (-width / 2 + 0.5) * unit;
1281
+ const originZ = (-height / 2 + 0.5) * unit;
1282
+ const worldOf = (col, row) => ({
1283
+ x: originX + col * unit,
1284
+ z: originZ + row * unit
1285
+ });
1286
+ const group = new import_three17.Group();
1287
+ group.name = "kit";
1288
+ const obstacles = [];
1289
+ const spawns = [];
1290
+ const torches = [];
1291
+ const floorCells = /* @__PURE__ */ new Set();
1292
+ const wallCells = [];
1293
+ const floorTiles = [];
1294
+ for (let row = 0; row < height; row++) {
1295
+ for (let col = 0; col < (rows[row] ?? "").length; col++) {
1296
+ const cell = rows[row][col];
1297
+ if (cell === " " || cell === void 0) continue;
1298
+ const { x, z } = worldOf(col, row);
1299
+ if (cell === "#") {
1300
+ wallCells.push({ x, z });
1301
+ obstacles.push({ center: new import_three17.Vector3(x, wallHeight / 2, z), radius: unit * 0.71 });
1302
+ continue;
1303
+ }
1304
+ floorTiles.push({ x, z });
1305
+ floorCells.add(`${col},${row}`);
1306
+ if (cell === "D") {
1307
+ const horizontalRun = rows[row][col - 1] === "#" || rows[row][col + 1] === "#";
1308
+ const lintel = new import_three17.Mesh(
1309
+ new import_three17.BoxGeometry(
1310
+ horizontalRun ? unit : unit * 0.4,
1311
+ wallHeight * 0.25,
1312
+ horizontalRun ? unit * 0.4 : unit
1313
+ ),
1314
+ new import_three17.MeshStandardMaterial({ color: palette.woodDark, flatShading: true })
1315
+ );
1316
+ lintel.position.set(x, wallHeight * 0.875, z);
1317
+ group.add(lintel);
1318
+ } else if (cell === "T") {
1319
+ group.add(createTorch(x, z, palette, rng, torchLights > 0));
1320
+ torchLights--;
1321
+ torches.push(new import_three17.Vector3(x, 0, z));
1322
+ obstacles.push({ center: new import_three17.Vector3(x, 0, z), radius: 0.3 });
1323
+ } else if (cell === "S") {
1324
+ spawns.push(new import_three17.Vector3(x, 0, z));
1325
+ }
1326
+ }
1327
+ }
1328
+ const stone = new import_three17.MeshStandardMaterial({ color: rng.pick(palette.rock), flatShading: true });
1329
+ const stoneDark = new import_three17.MeshStandardMaterial({ color: palette.cliff, flatShading: true });
1330
+ const matrix = new import_three17.Matrix4();
1331
+ if (wallCells.length > 0) {
1332
+ const walls = new import_three17.InstancedMesh(
1333
+ new import_three17.BoxGeometry(unit, wallHeight, unit),
1334
+ stone,
1335
+ wallCells.length
1336
+ );
1337
+ wallCells.forEach((cell, i) => {
1338
+ walls.setMatrixAt(i, matrix.makeTranslation(cell.x, wallHeight / 2, cell.z));
1339
+ });
1340
+ walls.instanceMatrix.needsUpdate = true;
1341
+ group.add(walls);
1342
+ }
1343
+ if (floorTiles.length > 0) {
1344
+ const floors = new import_three17.InstancedMesh(
1345
+ new import_three17.BoxGeometry(unit, 0.2, unit),
1346
+ stoneDark,
1347
+ floorTiles.length
1348
+ );
1349
+ floorTiles.forEach((cell, i) => {
1350
+ floors.setMatrixAt(i, matrix.makeTranslation(cell.x, -0.1, cell.z));
1351
+ });
1352
+ floors.instanceMatrix.needsUpdate = true;
1353
+ group.add(floors);
1354
+ }
1355
+ return {
1356
+ group,
1357
+ obstacles,
1358
+ spawns,
1359
+ torches,
1360
+ floorAt(x, z) {
1361
+ const col = Math.round((x - originX) / unit);
1362
+ const row = Math.round((z - originZ) / unit);
1363
+ return floorCells.has(`${col},${row}`);
1364
+ },
1365
+ size: { width: width * unit, depth: height * unit }
1366
+ };
1367
+ }
1368
+ function createTorch(x, z, palette, rng, light) {
1369
+ const torch = new import_three17.Group();
1370
+ torch.name = "torch";
1371
+ const pole = new import_three17.Mesh(
1372
+ new import_three17.CylinderGeometry(0.04, 0.06, 1.5, 5),
1373
+ new import_three17.MeshStandardMaterial({ color: palette.woodDark, flatShading: true })
1374
+ );
1375
+ pole.position.y = 0.75;
1376
+ torch.add(pole);
1377
+ const flame = new import_three17.Mesh(
1378
+ new import_three17.SphereGeometry(0.1, 6, 5),
1379
+ new import_three17.MeshStandardMaterial({
1380
+ color: palette.lampGlow,
1381
+ emissive: palette.lampGlow,
1382
+ emissiveIntensity: 1.8
1383
+ })
1384
+ );
1385
+ flame.position.y = 1.58;
1386
+ flame.scale.y = rng.range(1.2, 1.5);
1387
+ torch.add(flame);
1388
+ if (light) {
1389
+ const point = new import_three17.PointLight(palette.lampGlow, 5, 9, 1.9);
1390
+ point.position.y = 1.6;
1391
+ torch.add(point);
1392
+ }
1393
+ torch.position.set(x, 0, z);
1394
+ return torch;
1395
+ }
1396
+
1397
+ // src/scene/manifest.ts
1398
+ var import_three19 = require("three");
1399
+
1400
+ // src/scatter/scatter.ts
1401
+ var import_three18 = require("three");
1267
1402
  function scatter(options) {
1268
1403
  const seed = options.seed ?? 1;
1269
1404
  const rng = new Rng(seed);
@@ -1318,7 +1453,7 @@ function scatter(options) {
1318
1453
  const itemIndex = pickItem();
1319
1454
  const [minScale, maxScale2] = options.items[itemIndex].scale ?? [0.8, 1.25];
1320
1455
  placements.push({
1321
- position: new import_three17.Vector3(x, y, z),
1456
+ position: new import_three18.Vector3(x, y, z),
1322
1457
  rotationY: rng.range(0, Math.PI * 2),
1323
1458
  scale: rng.range(minScale, maxScale2),
1324
1459
  itemIndex
@@ -1328,18 +1463,59 @@ function scatter(options) {
1328
1463
  if (!bucket) occupied.set(key, bucket = []);
1329
1464
  bucket.push({ x, z });
1330
1465
  }
1331
- const group = new import_three17.Group();
1466
+ const group = new import_three18.Group();
1332
1467
  group.name = "scatter";
1333
1468
  const obstacles = [];
1334
- const placementMatrix = new import_three17.Matrix4();
1335
- const composed = new import_three17.Matrix4();
1336
- const quaternion = new import_three17.Quaternion();
1337
- const up = new import_three17.Vector3(0, 1, 0);
1338
- const scaleVector = new import_three17.Vector3();
1469
+ const placementMatrix = new import_three18.Matrix4();
1470
+ const composed = new import_three18.Matrix4();
1471
+ const quaternion = new import_three18.Quaternion();
1472
+ const up = new import_three18.Vector3(0, 1, 0);
1473
+ const scaleVector = new import_three18.Vector3();
1474
+ const emit = (variant, list, parent) => {
1475
+ variant.object.updateMatrixWorld(true);
1476
+ variant.object.traverse((child) => {
1477
+ if (!(child instanceof import_three18.Mesh)) return;
1478
+ const instanced = new import_three18.InstancedMesh(child.geometry, child.material, list.length);
1479
+ instanced.instanceMatrix.setUsage(import_three18.DynamicDrawUsage);
1480
+ list.forEach((placement, index) => {
1481
+ quaternion.setFromAxisAngle(up, placement.rotationY);
1482
+ scaleVector.setScalar(placement.scale);
1483
+ placementMatrix.compose(placement.position, quaternion, scaleVector);
1484
+ composed.multiplyMatrices(placementMatrix, child.matrixWorld);
1485
+ instanced.setMatrixAt(index, composed);
1486
+ });
1487
+ instanced.instanceMatrix.needsUpdate = true;
1488
+ parent.add(instanced);
1489
+ });
1490
+ };
1491
+ const tileSize = options.lod?.tileSize ?? 16;
1492
+ const tileMap = /* @__PURE__ */ new Map();
1493
+ const tileFor = (x, z) => {
1494
+ const tx = Math.floor(x / tileSize);
1495
+ const tz = Math.floor(z / tileSize);
1496
+ const key = `${tx},${tz}`;
1497
+ let tile = tileMap.get(key);
1498
+ if (!tile) {
1499
+ tile = {
1500
+ center: { x: (tx + 0.5) * tileSize, z: (tz + 0.5) * tileSize },
1501
+ near: new import_three18.Group(),
1502
+ far: new import_three18.Group()
1503
+ };
1504
+ tile.far.visible = false;
1505
+ group.add(tile.near, tile.far);
1506
+ tileMap.set(key, tile);
1507
+ }
1508
+ return tile;
1509
+ };
1339
1510
  options.items.forEach((item, itemIndex) => {
1340
1511
  const variantCount = item.variants ?? 4;
1341
1512
  const variants = [];
1342
1513
  for (let v = 0; v < variantCount; v++) variants.push(item.create(rng.fork()));
1514
+ const useLod = options.lod !== void 0 && item.createFar !== void 0;
1515
+ const farVariants = [];
1516
+ if (useLod) {
1517
+ for (let v = 0; v < variantCount; v++) farVariants.push(item.createFar(rng.fork()));
1518
+ }
1343
1519
  const byVariant = variants.map(() => []);
1344
1520
  for (const placement of placements) {
1345
1521
  if (placement.itemIndex !== itemIndex) continue;
@@ -1349,21 +1525,21 @@ function scatter(options) {
1349
1525
  variants.forEach((variant, v) => {
1350
1526
  const list = byVariant[v];
1351
1527
  if (list.length === 0) return;
1352
- variant.object.updateMatrixWorld(true);
1353
- variant.object.traverse((child) => {
1354
- if (!(child instanceof import_three17.Mesh)) return;
1355
- const instanced = new import_three17.InstancedMesh(child.geometry, child.material, list.length);
1356
- instanced.instanceMatrix.setUsage(import_three17.DynamicDrawUsage);
1357
- list.forEach((placement, index) => {
1358
- quaternion.setFromAxisAngle(up, placement.rotationY);
1359
- scaleVector.setScalar(placement.scale);
1360
- placementMatrix.compose(placement.position, quaternion, scaleVector);
1361
- composed.multiplyMatrices(placementMatrix, child.matrixWorld);
1362
- instanced.setMatrixAt(index, composed);
1363
- });
1364
- instanced.instanceMatrix.needsUpdate = true;
1365
- group.add(instanced);
1366
- });
1528
+ if (useLod) {
1529
+ const byTile = /* @__PURE__ */ new Map();
1530
+ for (const placement of list) {
1531
+ const tile = tileFor(placement.position.x, placement.position.z);
1532
+ let bucket = byTile.get(tile);
1533
+ if (!bucket) byTile.set(tile, bucket = []);
1534
+ bucket.push(placement);
1535
+ }
1536
+ for (const [tile, bucket] of byTile) {
1537
+ emit(variant, bucket, tile.near);
1538
+ emit(farVariants[v], bucket, tile.far);
1539
+ }
1540
+ } else {
1541
+ emit(variant, list, group);
1542
+ }
1367
1543
  if (variant.obstacleRadius > 0) {
1368
1544
  for (const placement of list) {
1369
1545
  obstacles.push({
@@ -1374,16 +1550,243 @@ function scatter(options) {
1374
1550
  }
1375
1551
  });
1376
1552
  });
1377
- return { group, placements, obstacles, count: placements.length };
1553
+ const result = { group, placements, obstacles, count: placements.length };
1554
+ if (options.lod) {
1555
+ const tiles = [...tileMap.values()];
1556
+ const swapOut = options.lod.distance * 1.1;
1557
+ const swapIn = options.lod.distance * 0.9;
1558
+ result.tiles = tiles;
1559
+ result.update = (camera) => {
1560
+ for (const tile of tiles) {
1561
+ const dx = camera.position.x - tile.center.x;
1562
+ const dz = camera.position.z - tile.center.z;
1563
+ const distance = Math.hypot(dx, dz);
1564
+ if (tile.near.visible && distance > swapOut) {
1565
+ tile.near.visible = false;
1566
+ tile.far.visible = true;
1567
+ } else if (!tile.near.visible && distance < swapIn) {
1568
+ tile.near.visible = true;
1569
+ tile.far.visible = false;
1570
+ }
1571
+ }
1572
+ };
1573
+ }
1574
+ return result;
1575
+ }
1576
+
1577
+ // src/scene/manifest.ts
1578
+ var PROP_FACTORIES = {
1579
+ tree: (seed, palette) => createTree({ seed, palette }),
1580
+ rock: (seed, palette) => createRock({ seed, palette }),
1581
+ bush: (seed, palette) => createBush({ seed, palette }),
1582
+ grass: (seed, palette) => createGrassTuft({ seed, palette }),
1583
+ crate: (seed, palette) => createCrate({ seed, palette }),
1584
+ fence: (seed, palette) => createFence({ seed, palette }),
1585
+ lamp: (seed, palette) => createLamp({ seed, palette })
1586
+ };
1587
+ var FAR_FACTORIES = {
1588
+ tree: (seed, palette) => {
1589
+ const rng = new Rng(seed);
1590
+ const group = new import_three19.Group();
1591
+ const cone = new import_three19.Mesh(
1592
+ new import_three19.CylinderGeometry(0, rng.range(1, 1.4), rng.range(2.6, 3.8), 5),
1593
+ new import_three19.MeshStandardMaterial({ color: rng.pick(palette.foliage), flatShading: true })
1594
+ );
1595
+ cone.position.y = cone.geometry.parameters.height / 2 + 0.3;
1596
+ group.add(cone);
1597
+ return { object: group, obstacleRadius: 0 };
1598
+ },
1599
+ bush: (seed, palette) => {
1600
+ const rng = new Rng(seed);
1601
+ const group = new import_three19.Group();
1602
+ const cone = new import_three19.Mesh(
1603
+ new import_three19.CylinderGeometry(0.1, rng.range(0.5, 0.7), rng.range(0.5, 0.8), 5),
1604
+ new import_three19.MeshStandardMaterial({ color: rng.pick(palette.foliage), flatShading: true })
1605
+ );
1606
+ cone.position.y = 0.3;
1607
+ group.add(cone);
1608
+ return { object: group, obstacleRadius: 0 };
1609
+ },
1610
+ grass: () => ({ object: new import_three19.Group(), obstacleRadius: 0 })
1611
+ };
1612
+ function buildScene(manifest, scene) {
1613
+ const seed = manifest.seed ?? 1;
1614
+ const palette = manifest.palette ? PALETTES[manifest.palette] : DEFAULT_PALETTE;
1615
+ const group = new import_three19.Group();
1616
+ group.name = "scena-scene";
1617
+ const updates = [];
1618
+ let terrain;
1619
+ if (manifest.terrain) {
1620
+ terrain = createTerrain({
1621
+ ...manifest.terrain,
1622
+ seed,
1623
+ waterLevel: manifest.water?.level,
1624
+ palette
1625
+ });
1626
+ group.add(terrain.mesh);
1627
+ }
1628
+ const heightAt = terrain ? terrain.heightAt : () => 0;
1629
+ let water;
1630
+ if (manifest.water) {
1631
+ water = createWater({
1632
+ level: manifest.water.level,
1633
+ size: manifest.water.size ?? (terrain ? terrain.size * 1.4 : 200),
1634
+ palette
1635
+ });
1636
+ group.add(water.mesh);
1637
+ updates.push((dt) => water.update(dt));
1638
+ }
1639
+ const dryLand = terrain && water ? aboveWater(terrain, water, 0.3) : () => true;
1640
+ let sky;
1641
+ if (manifest.sky ?? true) {
1642
+ sky = createSky({ palette });
1643
+ group.add(sky.mesh);
1644
+ }
1645
+ const rig = createLightingRig(manifest.lighting ?? "day");
1646
+ group.add(rig.group);
1647
+ if (scene && manifest.fog !== false) applyFog(scene, manifest.fog ?? "haze", palette);
1648
+ const paths = (manifest.paths ?? []).map((spec) => {
1649
+ const path = createPath(spec.points, {
1650
+ surface: heightAt,
1651
+ width: spec.width,
1652
+ loop: spec.loop,
1653
+ palette
1654
+ });
1655
+ group.add(path.mesh);
1656
+ return path;
1657
+ });
1658
+ const onAnyPath = (x, z) => paths.some((p) => p.contains(x, z));
1659
+ let village;
1660
+ if (manifest.village) {
1661
+ village = createVillage({
1662
+ ...manifest.village,
1663
+ seed: seed + 1,
1664
+ surface: heightAt,
1665
+ mask: (x, z) => dryLand(x, z) && !onAnyPath(x, z),
1666
+ palette
1667
+ });
1668
+ group.add(village.group);
1669
+ }
1670
+ const wind = manifest.wind ?? true;
1671
+ const scatters = (manifest.scatters ?? []).map((spec, index) => {
1672
+ const inset = terrain ? terrain.size * 0.45 : 40;
1673
+ const avoidWater = spec.avoidWater ?? true;
1674
+ const avoidPaths = spec.avoidPaths ?? true;
1675
+ const result = scatter({
1676
+ seed: seed + 10 + index,
1677
+ area: spec.area ?? { min: { x: -inset, z: -inset }, max: { x: inset, z: inset } },
1678
+ surface: heightAt,
1679
+ density: spec.density,
1680
+ count: spec.count,
1681
+ minSpacing: spec.minSpacing,
1682
+ clumpScale: spec.clumpScale,
1683
+ lod: spec.lod,
1684
+ items: spec.items.map((item) => {
1685
+ const far = spec.lod ? FAR_FACTORIES[item.type] : void 0;
1686
+ return {
1687
+ create: (rng) => PROP_FACTORIES[item.type](rng.int(1, 1e9), palette),
1688
+ createFar: far && ((rng) => far(rng.int(1, 1e9), palette)),
1689
+ weight: item.weight,
1690
+ variants: item.variants,
1691
+ scale: item.scale
1692
+ };
1693
+ }),
1694
+ mask: (x, z, y) => (spec.maxHeight === void 0 || y < spec.maxHeight) && (!avoidWater || dryLand(x, z)) && (!avoidPaths || !onAnyPath(x, z)),
1695
+ keepOut: [
1696
+ ...avoidPaths ? paths.flatMap((p) => p.keepOut) : [],
1697
+ ...village ? village.keepOut : []
1698
+ ]
1699
+ });
1700
+ group.add(result.group);
1701
+ if (wind !== false) {
1702
+ const sway = applyWind(result.group, {
1703
+ strength: typeof wind === "object" ? wind.strength ?? 0.05 : 0.05
1704
+ });
1705
+ updates.push((dt) => sway.update(dt));
1706
+ }
1707
+ return result;
1708
+ });
1709
+ let cycle;
1710
+ if (manifest.dayCycle) {
1711
+ cycle = createDayCycle({
1712
+ sky,
1713
+ rig,
1714
+ scene,
1715
+ lamps: village?.lamps,
1716
+ palette,
1717
+ dayLength: manifest.dayCycle.dayLength,
1718
+ timeOfDay: manifest.dayCycle.timeOfDay
1719
+ });
1720
+ updates.push((dt) => cycle.update(dt));
1721
+ }
1722
+ scene?.add(group);
1723
+ return {
1724
+ group,
1725
+ palette,
1726
+ terrain,
1727
+ water,
1728
+ sky,
1729
+ rig,
1730
+ cycle,
1731
+ paths,
1732
+ village,
1733
+ scatters,
1734
+ obstacles: [
1735
+ ...village ? village.obstacles : [],
1736
+ ...scatters.flatMap((s) => s.obstacles)
1737
+ ],
1738
+ heightAt,
1739
+ update(dt) {
1740
+ for (const fn of updates) fn(dt);
1741
+ }
1742
+ };
1743
+ }
1744
+
1745
+ // src/scene/markers.ts
1746
+ var import_three20 = require("three");
1747
+ function extractMarkers(root) {
1748
+ root.updateWorldMatrix(true, true);
1749
+ const spawns = {};
1750
+ const routePoints = {};
1751
+ const obstacles = [];
1752
+ const keepOut = [];
1753
+ root.traverse((node) => {
1754
+ const name = node.name.replace(/\.\d+$/, "");
1755
+ const match = /^(spawn|route|obstacle|keepout)_(.+)$/.exec(name);
1756
+ if (!match) return;
1757
+ const [, kind, rest] = match;
1758
+ const position = node.getWorldPosition(new import_three20.Vector3());
1759
+ const radius = Math.max(node.scale.x, node.scale.z);
1760
+ if (kind === "spawn") {
1761
+ spawns[rest] = position;
1762
+ } else if (kind === "route") {
1763
+ const step = /^(.*)_(\d+)$/.exec(rest);
1764
+ const routeName = step ? step[1] : rest;
1765
+ const index = step ? parseInt(step[2], 10) : 0;
1766
+ (routePoints[routeName] ?? (routePoints[routeName] = [])).push({ index, position });
1767
+ } else if (kind === "obstacle") {
1768
+ obstacles.push({ center: position, radius });
1769
+ } else {
1770
+ keepOut.push({ center: { x: position.x, z: position.z }, radius });
1771
+ }
1772
+ });
1773
+ const routes = {};
1774
+ for (const [name, points] of Object.entries(routePoints)) {
1775
+ routes[name] = points.sort((a, b) => a.index - b.index).map((p) => p.position);
1776
+ }
1777
+ return { spawns, routes, obstacles, keepOut };
1378
1778
  }
1379
1779
  // Annotate the CommonJS export names for ESM import in node:
1380
1780
  0 && (module.exports = {
1381
1781
  DEFAULT_PALETTE,
1782
+ KIT_UNIT,
1382
1783
  PALETTES,
1383
1784
  Rng,
1384
1785
  aboveWater,
1385
1786
  applyFog,
1386
1787
  applyWind,
1788
+ assembleKit,
1789
+ buildScene,
1387
1790
  collectObstacles,
1388
1791
  createBush,
1389
1792
  createCrate,
@@ -1403,6 +1806,7 @@ function scatter(options) {
1403
1806
  createVillage,
1404
1807
  createWater,
1405
1808
  createWell,
1809
+ extractMarkers,
1406
1810
  fractalNoise2,
1407
1811
  hash2,
1408
1812
  scatter,