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 +29 -5
- package/dist/index.cjs +428 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +256 -2
- package/dist/index.d.ts +256 -2
- package/dist/index.js +436 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
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
|
-
- [
|
|
84
|
-
- [
|
|
85
|
-
- [
|
|
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 #
|
|
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/
|
|
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
|
|
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
|
|
1466
|
+
const group = new import_three18.Group();
|
|
1332
1467
|
group.name = "scatter";
|
|
1333
1468
|
const obstacles = [];
|
|
1334
|
-
const placementMatrix = new
|
|
1335
|
-
const composed = new
|
|
1336
|
-
const quaternion = new
|
|
1337
|
-
const up = new
|
|
1338
|
-
const scaleVector = new
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
group
|
|
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
|
-
|
|
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,
|