@call-me-sensei/toonlab 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,490 @@
1
+ // Seeded settlements: pure composition of 01 (streets ride the path
2
+ // system), 02 (props by role), and 03 (building recipes). Villages never
3
+ // flatten terrain — buildings rely on their foundation skirts, streets on
4
+ // the ribbon overlay — so sites are chosen flat enough that this holds
5
+ // (invariant, tested in verify-villagegen).
6
+
7
+ import * as THREE from 'three';
8
+
9
+ import { buildingAsset } from '../buildinggen/buildingAsset.js';
10
+ import { mergePainted } from '../propgen/propParts.js';
11
+ import {
12
+ createPropAsset,
13
+ placeAlongSpline,
14
+ placeProps,
15
+ propAssetFromObject,
16
+ } from '../propgen/index.js';
17
+ import { createDebrisAsset } from '../debrisgen/debrisGenerator.js';
18
+ import { resamplePolyline } from '../pathgen/pathRibbon.js';
19
+ import { POI_ARCHETYPES } from './villageArchetypes.js';
20
+ import { generatePlaceName } from './villageNames.js';
21
+
22
+ function mulberry32(seed) {
23
+ let state = (Math.trunc(seed) || 1) >>> 0;
24
+ return () => {
25
+ state = (state + 0x6d2b79f5) >>> 0;
26
+ let t = state;
27
+ t = Math.imul(t ^ (t >>> 15), t | 1);
28
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
29
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
30
+ };
31
+ }
32
+
33
+ const STREET_HALF = 1.7; // matches the default ribbon width + shoulder
34
+
35
+ function slopeOf(heightAt, x, z, step = 1.2) {
36
+ const dx = (heightAt(x + step, z) - heightAt(x - step, z)) / (2 * step);
37
+ const dz = (heightAt(x, z + step) - heightAt(x, z - step)) / (2 * step);
38
+ return Math.hypot(dx, dz);
39
+ }
40
+
41
+ function distanceToPolyline(samples, x, z) {
42
+ let best = Infinity;
43
+ for (let index = 0; index < samples.length - 1; index += 1) {
44
+ const a = samples[index];
45
+ const b = samples[index + 1];
46
+ const abx = b.x - a.x;
47
+ const abz = b.z - a.z;
48
+ const lengthSq = abx * abx + abz * abz;
49
+ const t = lengthSq > 1e-9
50
+ ? Math.min(Math.max(((x - a.x) * abx + (z - a.z) * abz) / lengthSq, 0), 1)
51
+ : 0;
52
+ best = Math.min(best, Math.hypot(x - (a.x + abx * t), z - (a.z + abz * t)));
53
+ }
54
+ return best;
55
+ }
56
+
57
+ /**
58
+ * Builds one settlement. See plans/04-villages.md — this is the composable
59
+ * entry; `createStylizedWorld({ pois })` calls it per site and merges the
60
+ * returned street routes into the world path network.
61
+ *
62
+ * @returns {{ root, name, archetype, center, radius, entries, streetRoutes,
63
+ * blockers, buildings, update, dispose, stats }}
64
+ */
65
+ export function createStylizedVillage({
66
+ seed = 1,
67
+ center = { x: 0, z: 0 },
68
+ radius = 34,
69
+ heightAt,
70
+ waterLevel = 0,
71
+ archetype = 'village',
72
+ collision = null,
73
+ parent = null,
74
+ } = {}) {
75
+ if (typeof heightAt !== 'function') throw new Error('createStylizedVillage needs heightAt(x, z).');
76
+ const spec = POI_ARCHETYPES[archetype] ?? POI_ARCHETYPES.village;
77
+ const random = mulberry32(seed * 747796405 + 21);
78
+ const cx = Number(center.x) || 0;
79
+ const cz = Number(center.z) || 0;
80
+ const name = generatePlaceName(seed, archetype);
81
+
82
+ const root = new THREE.Group();
83
+ root.name = `POI ${archetype} ${name}`;
84
+ const blockers = [];
85
+ const updaters = [];
86
+
87
+ // --- street ---------------------------------------------------------------
88
+ // A gentle bent main street through the center, plus an optional branch.
89
+ const heading = random() * Math.PI * 2;
90
+ const dirX = Math.sin(heading);
91
+ const dirZ = Math.cos(heading);
92
+ const perpX = -dirZ;
93
+ const perpZ = dirX;
94
+ const streetLength = Math.max(radius * 1.6, spec.minRadius * 1.4);
95
+ const bend = (random() - 0.5) * radius * 0.4;
96
+ const streetPoints = [-0.5, -0.17, 0.17, 0.5].map((t) => [
97
+ cx + dirX * streetLength * t + perpX * bend * Math.sin(t * Math.PI),
98
+ cz + dirZ * streetLength * t + perpZ * bend * Math.sin(t * Math.PI),
99
+ ]);
100
+ // Shrink ends onto dry land so entries never start underwater.
101
+ const dry = (point) => heightAt(point[0], point[1]) > waterLevel + 0.7;
102
+ while (streetPoints.length > 2 && !dry(streetPoints[0])) streetPoints.shift();
103
+ while (streetPoints.length > 2 && !dry(streetPoints[streetPoints.length - 1])) streetPoints.pop();
104
+
105
+ const streetRoutes = [{ points: streetPoints, style: spec.layout.streetStyle, wander: false }];
106
+ const branchRoll = random();
107
+ const branchSide = random() < 0.5 ? -1 : 1;
108
+ if (branchRoll < spec.layout.branchChance) {
109
+ const mid = streetPoints[Math.floor(streetPoints.length / 2)];
110
+ const branchEnd = [
111
+ mid[0] + perpX * branchSide * radius * 0.9,
112
+ mid[1] + perpZ * branchSide * radius * 0.9,
113
+ ];
114
+ if (dry(branchEnd)) {
115
+ streetRoutes.push({
116
+ points: [mid, [
117
+ mid[0] + perpX * branchSide * radius * 0.45 + dirX * radius * 0.12,
118
+ mid[1] + perpZ * branchSide * radius * 0.45 + dirZ * radius * 0.12,
119
+ ], branchEnd],
120
+ style: spec.layout.streetStyle,
121
+ wander: false,
122
+ });
123
+ }
124
+ }
125
+ const streetSamples = streetRoutes.flatMap((route) => resamplePolyline(
126
+ route.points.map(([x, z]) => ({ x, z })), 3,
127
+ ));
128
+ const streetCurve = new THREE.CatmullRomCurve3(
129
+ resamplePolyline(streetPoints.map(([x, z]) => ({ x, z })), 4)
130
+ .map((point) => new THREE.Vector3(point.x, heightAt(point.x, point.z), point.z)),
131
+ false, 'catmullrom', 0.1,
132
+ );
133
+
134
+ // --- buildings ---------------------------------------------------------------
135
+ const [minBuildings, maxBuildings] = spec.layout.buildingCount;
136
+ const targetBuildings = minBuildings + Math.round(random() * (maxBuildings - minBuildings));
137
+ const buildings = [];
138
+ const acceptedCircles = [];
139
+ const roleBuckets = { hi: new Map(), lo: new Map() };
140
+ const roleMaterials = new Map();
141
+ const matrix = new THREE.Matrix4();
142
+ const quaternion = new THREE.Quaternion();
143
+ const scratch = new THREE.Vector3();
144
+
145
+ const pickType = () => {
146
+ const roll = random();
147
+ let sum = 0;
148
+ for (const entry of spec.buildings) {
149
+ sum += entry.weight;
150
+ if (roll <= sum) return entry.type;
151
+ }
152
+ return spec.buildings[spec.buildings.length - 1]?.type ?? 'cottage';
153
+ };
154
+
155
+ const tryPlaceBuilding = (parcelCenter, facePoint) => {
156
+ const type = pickType();
157
+ const buildSeed = Math.floor(random() * 0xffffffff);
158
+ const asset = buildingAsset({ seed: buildSeed, type });
159
+ const built = asset.build(buildSeed);
160
+ const y = heightAt(parcelCenter.x, parcelCenter.z);
161
+ if (y < waterLevel + 0.6) return false; // never in water (invariant)
162
+ if (slopeOf(heightAt, parcelCenter.x, parcelCenter.z) > 0.24) return false;
163
+ // face the street: door normal rotates onto the direction to facePoint
164
+ const toStreetX = facePoint.x - parcelCenter.x;
165
+ const toStreetZ = facePoint.z - parcelCenter.z;
166
+ const yaw = Math.atan2(toStreetX, toStreetZ) - Math.atan2(built.door.nx, built.door.nz);
167
+ // world-frame footprint circles
168
+ const sin = Math.sin(yaw);
169
+ const cos = Math.cos(yaw);
170
+ const circles = built.footprint.circles.map((circle) => ({
171
+ radius: circle.radius,
172
+ x: parcelCenter.x + circle.x * cos + circle.z * sin,
173
+ z: parcelCenter.z - circle.x * sin + circle.z * cos,
174
+ }));
175
+ // invariants at layout time: clear of the street ribbon, other
176
+ // footprints, and the water — checked, not eyeballed
177
+ for (const circle of circles) {
178
+ if (distanceToPolyline(streetSamples, circle.x, circle.z) < circle.radius + STREET_HALF + 0.3) return false;
179
+ if (heightAt(circle.x, circle.z) < waterLevel + 0.5) return false;
180
+ for (const other of acceptedCircles) {
181
+ const minDistance = circle.radius + other.radius + 0.4;
182
+ if ((circle.x - other.x) ** 2 + (circle.z - other.z) ** 2 < minDistance ** 2) return false;
183
+ }
184
+ }
185
+ acceptedCircles.push(...circles);
186
+ blockers.push(...circles);
187
+ buildings.push({ built, circles, type, x: parcelCenter.x, y, yaw, z: parcelCenter.z });
188
+
189
+ // merge role geometries (hi and lo) with the placement transform baked
190
+ for (const [level, object] of [['hi', built.object3D], ['lo', built.lod.far]]) {
191
+ object.updateWorldMatrix(true, true);
192
+ object.traverse((child) => {
193
+ if (!child.isMesh) return;
194
+ const role = child.name.replace('Building-', '') || 'wall';
195
+ if (!roleMaterials.has(role)) roleMaterials.set(role, child.material);
196
+ const geometry = child.geometry;
197
+ quaternion.setFromAxisAngle(scratch.set(0, 1, 0), yaw);
198
+ matrix.compose(
199
+ new THREE.Vector3(parcelCenter.x, y, parcelCenter.z),
200
+ quaternion,
201
+ new THREE.Vector3(1, 1, 1),
202
+ );
203
+ geometry.applyMatrix4(child.matrixWorld);
204
+ geometry.applyMatrix4(matrix);
205
+ const bucket = roleBuckets[level].get(role);
206
+ if (bucket) bucket.push(geometry);
207
+ else roleBuckets[level].set(role, [geometry]);
208
+ });
209
+ }
210
+ return true;
211
+ };
212
+
213
+ if (spec.buildings.length > 0) {
214
+ if (archetype === 'shrine') {
215
+ // one shrine BEYOND the head of the approach — far enough that its
216
+ // footprint clears the street ribbon (the veranda and steps fill the
217
+ // gap), facing back down the approach.
218
+ const head = streetPoints[streetPoints.length - 1];
219
+ const prev = streetPoints[streetPoints.length - 2];
220
+ const forwardX = head[0] - prev[0];
221
+ const forwardZ = head[1] - prev[1];
222
+ const norm = Math.hypot(forwardX, forwardZ) || 1;
223
+ for (const reach of [6.5, 8, 10]) {
224
+ const shrineCenter = {
225
+ x: head[0] + (forwardX / norm) * reach,
226
+ z: head[1] + (forwardZ / norm) * reach,
227
+ };
228
+ if (tryPlaceBuilding(shrineCenter, { x: head[0], z: head[1] })) break;
229
+ }
230
+ } else {
231
+ // parcels pace the street, alternating sides with jittered setbacks
232
+ const strideBase = 8.5;
233
+ let placedCount = 0;
234
+ let guard = 0;
235
+ const totalLength = streetCurve.getLength();
236
+ while (placedCount < targetBuildings && guard < targetBuildings * 6) {
237
+ guard += 1;
238
+ const t = 0.08 + random() * 0.84;
239
+ const at = streetCurve.getPointAt(t);
240
+ const tangent = streetCurve.getTangentAt(t);
241
+ const side = guard % 2 === 0 ? 1 : -1;
242
+ const setback = spec.layout.parcelSetback[0]
243
+ + random() * (spec.layout.parcelSetback[1] - spec.layout.parcelSetback[0]);
244
+ const offset = STREET_HALF + setback + 3.4;
245
+ const px = -tangent.z;
246
+ const pz = tangent.x;
247
+ const parcelCenter = {
248
+ x: at.x + px * offset * side + tangent.x * (random() - 0.5) * strideBase * 0.4,
249
+ z: at.z + pz * offset * side + tangent.z * (random() - 0.5) * strideBase * 0.4,
250
+ };
251
+ if (Math.hypot(parcelCenter.x - cx, parcelCenter.z - cz) > radius * 1.15) continue;
252
+ if (tryPlaceBuilding(parcelCenter, { x: at.x, z: at.z })) placedCount += 1;
253
+ }
254
+ }
255
+ }
256
+
257
+ // one mesh per role per LOD level → a whole village stays ≤ 6 draw calls
258
+ // for buildings; swap hi/lo by camera distance to the village center
259
+ const lodGroups = { hi: new THREE.Group(), lo: new THREE.Group() };
260
+ for (const level of ['hi', 'lo']) {
261
+ lodGroups[level].name = `VillageBuildings-${level}`;
262
+ for (const [role, geometries] of roleBuckets[level]) {
263
+ const merged = mergePainted(geometries);
264
+ const mesh = new THREE.Mesh(merged, roleMaterials.get(role));
265
+ mesh.name = `Village-${role}`;
266
+ mesh.castShadow = true;
267
+ mesh.receiveShadow = true;
268
+ lodGroups[level].add(mesh);
269
+ }
270
+ root.add(lodGroups[level]);
271
+ }
272
+ lodGroups.lo.visible = false;
273
+ const lodDistanceSq = (radius * 6) ** 2;
274
+
275
+ // --- props by role --------------------------------------------------------------
276
+ const propSeedBase = seed * 31 + 7;
277
+ const addPropUpdate = (placed) => {
278
+ if (placed?.update) updaters.push(placed.update);
279
+ if (placed?.blockers) blockers.push(...placed.blockers);
280
+ };
281
+
282
+ if (spec.props.lanternSpacing > 0) {
283
+ const lantern = createPropAsset({
284
+ asset: { seed: propSeedBase, type: 'lantern', variant: archetype === 'shrine' ? 'stoneToro' : 'woodPost' },
285
+ });
286
+ addPropUpdate(placeAlongSpline({
287
+ asset: lantern,
288
+ heightAt,
289
+ mask: (x, z) => heightAt(x, z) > waterLevel + 0.4,
290
+ offset: STREET_HALF + 0.5,
291
+ parent: root,
292
+ seed: propSeedBase + 1,
293
+ sides: archetype === 'shrine' ? 2 : 1,
294
+ spacing: spec.props.lanternSpacing,
295
+ spline: streetCurve,
296
+ }));
297
+ }
298
+
299
+ if (spec.props.torii) {
300
+ const torii = createPropAsset({ asset: { seed: propSeedBase + 2, type: 'torii' } });
301
+ const at = streetCurve.getPointAt(0.12);
302
+ const tangent = streetCurve.getTangentAt(0.12);
303
+ addPropUpdate(placeProps({
304
+ asset: torii,
305
+ heightAt,
306
+ parent: root,
307
+ positions: [{ x: at.x, yaw: Math.atan2(tangent.x, tangent.z) + Math.PI / 2, z: at.z }],
308
+ seed: propSeedBase + 2,
309
+ yaw: 'positions',
310
+ }));
311
+ }
312
+
313
+ if (spec.props.well && buildings.length > 2) {
314
+ const at = streetCurve.getPointAt(0.5);
315
+ const tangent = streetCurve.getTangentAt(0.5);
316
+ const well = createPropAsset({ asset: { seed: propSeedBase + 3, type: 'well' } });
317
+ addPropUpdate(placeProps({
318
+ asset: well,
319
+ heightAt,
320
+ parent: root,
321
+ positions: [{
322
+ x: at.x + -tangent.z * (STREET_HALF + 2.6) * branchSide * -1,
323
+ z: at.z + tangent.x * (STREET_HALF + 2.6) * branchSide * -1,
324
+ }],
325
+ seed: propSeedBase + 3,
326
+ }));
327
+ }
328
+
329
+ if (spec.props.pier) {
330
+ // walk downhill from the center to the waterline, plant the pier there
331
+ let px = cx;
332
+ let pz = cz;
333
+ for (let step = 0; step < 60 && heightAt(px, pz) > waterLevel + 0.35; step += 1) {
334
+ const gx = (heightAt(px + 1, pz) - heightAt(px - 1, pz)) / 2;
335
+ const gz = (heightAt(px, pz + 1) - heightAt(px, pz - 1)) / 2;
336
+ const norm = Math.hypot(gx, gz) || 1;
337
+ px -= (gx / norm) * 2.2;
338
+ pz -= (gz / norm) * 2.2;
339
+ }
340
+ if (heightAt(px, pz) <= waterLevel + 0.6) {
341
+ const gx = (heightAt(px + 1, pz) - heightAt(px - 1, pz)) / 2;
342
+ const gz = (heightAt(px, pz + 1) - heightAt(px, pz - 1)) / 2;
343
+ const yaw = Math.atan2(-gx, -gz);
344
+ const pier = createPropAsset({ asset: { seed: propSeedBase + 4, type: 'pier' } });
345
+ addPropUpdate(placeProps({
346
+ asset: pier,
347
+ heightAt: () => waterLevel + 0.1, // deck rides the waterline, not the bed
348
+ parent: root,
349
+ positions: [{ x: px, yaw, z: pz }],
350
+ seed: propSeedBase + 4,
351
+ yaw: 'positions',
352
+ }));
353
+ }
354
+ }
355
+
356
+ // rear-yard clutter: crates, firewood, benches behind houses
357
+ const clutter = [
358
+ ['crateStack', spec.props.crates],
359
+ ['firewood', spec.props.firewood],
360
+ ['bench', spec.props.benches],
361
+ ];
362
+ for (const [type, count] of clutter) {
363
+ if (!count || buildings.length === 0) continue;
364
+ const positions = [];
365
+ for (let index = 0; index < count; index += 1) {
366
+ const building = buildings[Math.floor(random() * buildings.length)];
367
+ const angle = random() * Math.PI * 2;
368
+ const reach = 3.6 + random() * 2.4;
369
+ const x = building.x + Math.sin(angle) * reach;
370
+ const z = building.z + Math.cos(angle) * reach;
371
+ if (heightAt(x, z) < waterLevel + 0.5) continue;
372
+ if (distanceToPolyline(streetSamples, x, z) < STREET_HALF + 0.8) continue;
373
+ if (acceptedCircles.some((circle) => (circle.x - x) ** 2 + (circle.z - z) ** 2 < (circle.radius + 0.8) ** 2)) continue;
374
+ positions.push({ x, z });
375
+ }
376
+ if (positions.length > 0) {
377
+ addPropUpdate(placeProps({
378
+ asset: createPropAsset({ asset: { seed: propSeedBase + 5, type } }),
379
+ heightAt,
380
+ parent: root,
381
+ positions,
382
+ seed: propSeedBase + 6,
383
+ }));
384
+ }
385
+ }
386
+
387
+ // parcel fences: three-sided yard ring, open to the street
388
+ if (spec.props.fenceParcelChance > 0) {
389
+ const fence = createPropAsset({ asset: { seed: propSeedBase + 8, type: 'fence', variant: 'picket' } });
390
+ for (const building of buildings) {
391
+ if (random() >= spec.props.fenceParcelChance) continue;
392
+ const half = Math.max(...building.circles.map((circle) => circle.radius)) + 1.6;
393
+ const sin = Math.sin(building.yaw);
394
+ const cos = Math.cos(building.yaw);
395
+ const corner = (lx, lz) => ({
396
+ x: building.x + lx * cos + lz * sin,
397
+ y: heightAt(building.x + lx * cos + lz * sin, building.z - lx * sin + lz * cos),
398
+ z: building.z - lx * sin + lz * cos,
399
+ });
400
+ // door faces +local door normal; leave that side open
401
+ const points = [
402
+ corner(-half, half), corner(-half, -half),
403
+ corner(half, -half), corner(half, half),
404
+ ];
405
+ const run = fence.buildAlong(points, { seed: propSeedBase + 9 });
406
+ root.add(run.object3D);
407
+ blockers.push(...run.footprints);
408
+ }
409
+ }
410
+
411
+ // campsite / ruin dressing via debrisgen — the cross-cluster seam at work
412
+ if (spec.props.campfire) {
413
+ const fire = createDebrisAsset({ asset: { seed: propSeedBase + 10, type: 'ash', variant: 'campfire' } });
414
+ const asset = propAssetFromObject(fire, { footprint: { radius: 1 } });
415
+ addPropUpdate(placeProps({
416
+ asset, heightAt, parent: root, positions: [{ x: cx, z: cz }], seed: propSeedBase + 10,
417
+ }));
418
+ }
419
+ if (spec.props.brokenWalls) {
420
+ const wall = createPropAsset({ asset: { seed: propSeedBase + 11, type: 'stoneWall' } });
421
+ for (let arc = 0; arc < 3; arc += 1) {
422
+ const angle = random() * Math.PI * 2;
423
+ const reach = 4 + random() * radius * 0.4;
424
+ const arcLength = 4 + random() * 6;
425
+ const points = [0, 1, 2].map((step) => {
426
+ const a = angle + (step - 1) * (arcLength / reach) * 0.5;
427
+ const x = cx + Math.sin(a) * reach;
428
+ const z = cz + Math.cos(a) * reach;
429
+ return { x, y: heightAt(x, z), z };
430
+ });
431
+ if (points.some((point) => point.y < waterLevel + 0.5)) continue;
432
+ const run = wall.buildAlong(points, { seed: propSeedBase + 12 + arc });
433
+ root.add(run.object3D);
434
+ blockers.push(...run.footprints);
435
+ }
436
+ }
437
+ if (spec.props.milestone) {
438
+ addPropUpdate(placeProps({
439
+ asset: createPropAsset({ asset: { seed: propSeedBase + 13, type: 'milestone' } }),
440
+ heightAt,
441
+ parent: root,
442
+ positions: [{ x: cx + 2, z: cz - 2 }],
443
+ seed: propSeedBase + 13,
444
+ }));
445
+ }
446
+
447
+ collision?.addCircles?.(blockers);
448
+ parent?.add?.(root);
449
+
450
+ const entries = [
451
+ { x: streetPoints[0][0], z: streetPoints[0][1] },
452
+ { x: streetPoints[streetPoints.length - 1][0], z: streetPoints[streetPoints.length - 1][1] },
453
+ ];
454
+
455
+ let disposed = false;
456
+ return {
457
+ archetype,
458
+ blockers,
459
+ buildings,
460
+ center: { x: cx, z: cz },
461
+ dispose() {
462
+ if (disposed) return;
463
+ disposed = true;
464
+ root.parent?.remove(root);
465
+ root.traverse((object) => { if (object.isMesh) object.geometry?.dispose(); });
466
+ },
467
+ entries,
468
+ name,
469
+ radius,
470
+ root,
471
+ stats: {
472
+ blockers: blockers.length,
473
+ buildings: buildings.length,
474
+ routes: streetRoutes.length,
475
+ },
476
+ streetRoutes,
477
+ /** Per frame: prop LOD + whole-village building LOD swap. */
478
+ update(delta, camera) {
479
+ for (const update of updaters) update(delta, camera);
480
+ if (!camera) return;
481
+ const dx = cx - camera.position.x;
482
+ const dz = cz - camera.position.z;
483
+ const far = dx * dx + dz * dz > lodDistanceSq;
484
+ if (lodGroups.lo.visible !== far) {
485
+ lodGroups.lo.visible = far;
486
+ lodGroups.hi.visible = !far;
487
+ }
488
+ },
489
+ };
490
+ }
@@ -0,0 +1,160 @@
1
+ // POI archetypes as data, not code: each entry describes how a place picks
2
+ // its site, what it builds, and what dresses it. Hosts can register custom
3
+ // archetypes with `registerPoiArchetype` — villagegen only ever reads this
4
+ // table.
5
+
6
+ // siteScore(x, z, context) → 0..∞ (higher = better site; ≤ 0 rejects).
7
+ // context: { heightAt, waterLevel, slopeAt(x, z), shoreDistance(x, z) }.
8
+
9
+ const flatness = (slope) => Math.max(0, 1 - slope / 0.22);
10
+
11
+ export const POI_ARCHETYPES = {
12
+ village: {
13
+ buildings: [
14
+ { type: 'cottage', weight: 0.62 },
15
+ { type: 'farmhouse', weight: 0.18 },
16
+ { type: 'shed', weight: 0.2 },
17
+ ],
18
+ id: 'village',
19
+ label: 'Village',
20
+ layout: {
21
+ buildingCount: [9, 15],
22
+ branchChance: 0.7,
23
+ parcelSetback: [0.8, 2.2],
24
+ streetStyle: 'dirt',
25
+ },
26
+ minRadius: 26,
27
+ props: {
28
+ benches: 2,
29
+ crates: 3,
30
+ fenceParcelChance: 0.45,
31
+ firewood: 2,
32
+ lanternSpacing: 16,
33
+ well: true,
34
+ },
35
+ siteScore(x, z, { heightAt, waterLevel, slopeAt }) {
36
+ const y = heightAt(x, z);
37
+ if (y < waterLevel + 1) return 0;
38
+ return flatness(slopeAt(x, z)) * (y < waterLevel + 14 ? 1.2 : 1);
39
+ },
40
+ },
41
+ pierHamlet: {
42
+ buildings: [
43
+ { type: 'cottage', weight: 0.55 },
44
+ { type: 'shed', weight: 0.45 },
45
+ ],
46
+ id: 'pierHamlet',
47
+ label: 'Fishing hamlet',
48
+ layout: {
49
+ buildingCount: [4, 7],
50
+ branchChance: 0.2,
51
+ parcelSetback: [0.7, 1.6],
52
+ streetStyle: 'planks',
53
+ },
54
+ minRadius: 18,
55
+ props: {
56
+ benches: 1,
57
+ crates: 4,
58
+ fenceParcelChance: 0.15,
59
+ firewood: 1,
60
+ lanternSpacing: 14,
61
+ pier: true,
62
+ well: false,
63
+ },
64
+ siteScore(x, z, { heightAt, waterLevel, slopeAt, shoreDistance }) {
65
+ const y = heightAt(x, z);
66
+ if (y < waterLevel + 0.8 || y > waterLevel + 7) return 0;
67
+ const shore = shoreDistance(x, z);
68
+ if (shore > 26) return 0; // hamlets live on the waterline
69
+ return flatness(slopeAt(x, z)) * (1.6 - shore / 26);
70
+ },
71
+ },
72
+ shrine: {
73
+ buildings: [{ type: 'shrine', weight: 1 }],
74
+ id: 'shrine',
75
+ label: 'Shrine',
76
+ layout: {
77
+ buildingCount: [1, 1],
78
+ branchChance: 0,
79
+ parcelSetback: [0, 0],
80
+ streetStyle: 'stone',
81
+ },
82
+ minRadius: 12,
83
+ props: {
84
+ approachLanterns: true,
85
+ benches: 0,
86
+ crates: 0,
87
+ fenceParcelChance: 0,
88
+ firewood: 0,
89
+ lanternSpacing: 7,
90
+ torii: true,
91
+ well: false,
92
+ },
93
+ siteScore(x, z, { heightAt, waterLevel, slopeAt }) {
94
+ const y = heightAt(x, z);
95
+ if (y < waterLevel + 2) return 0;
96
+ // hilltop bonus: shrines overlook
97
+ return flatness(slopeAt(x, z)) * (0.6 + Math.min((y - waterLevel) / 45, 1.4));
98
+ },
99
+ },
100
+ campsite: {
101
+ buildings: [],
102
+ id: 'campsite',
103
+ label: 'Campsite',
104
+ layout: {
105
+ buildingCount: [0, 0],
106
+ branchChance: 0,
107
+ parcelSetback: [0, 0],
108
+ streetStyle: 'dirt',
109
+ },
110
+ minRadius: 8,
111
+ props: {
112
+ benches: 1,
113
+ campfire: true,
114
+ crates: 1,
115
+ fenceParcelChance: 0,
116
+ firewood: 1,
117
+ lanternSpacing: 0,
118
+ well: false,
119
+ },
120
+ siteScore(x, z, { heightAt, waterLevel, slopeAt }) {
121
+ const y = heightAt(x, z);
122
+ if (y < waterLevel + 1) return 0;
123
+ return flatness(slopeAt(x, z));
124
+ },
125
+ },
126
+ ruin: {
127
+ buildings: [],
128
+ id: 'ruin',
129
+ label: 'Ruin',
130
+ layout: {
131
+ buildingCount: [0, 0],
132
+ branchChance: 0,
133
+ parcelSetback: [0, 0],
134
+ streetStyle: 'stone',
135
+ },
136
+ minRadius: 10,
137
+ props: {
138
+ benches: 0,
139
+ brokenWalls: true,
140
+ crates: 0,
141
+ fenceParcelChance: 0,
142
+ firewood: 0,
143
+ lanternSpacing: 0,
144
+ milestone: true,
145
+ well: false,
146
+ },
147
+ siteScore(x, z, { heightAt, waterLevel, slopeAt }) {
148
+ const y = heightAt(x, z);
149
+ if (y < waterLevel + 1.5) return 0;
150
+ return flatness(slopeAt(x, z)) * 0.9;
151
+ },
152
+ },
153
+ };
154
+
155
+ /** Hosts register custom archetypes; id collisions replace (deliberately). */
156
+ export function registerPoiArchetype(archetype) {
157
+ if (!archetype?.id) throw new Error('registerPoiArchetype needs an { id }.');
158
+ POI_ARCHETYPES[archetype.id] = archetype;
159
+ return archetype;
160
+ }
@@ -0,0 +1,40 @@
1
+ // Tiny seeded syllable name generator. Pure flavor — and the thing everyone
2
+ // screenshots, which is why it ships in v1. Names lean soft-Japanese
3
+ // romaji to match the shrine/torii dressing, with a per-archetype suffix.
4
+
5
+ function mulberry32(seed) {
6
+ let state = (Math.trunc(seed) || 1) >>> 0;
7
+ return () => {
8
+ state = (state + 0x6d2b79f5) >>> 0;
9
+ let t = state;
10
+ t = Math.imul(t ^ (t >>> 15), t | 1);
11
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
12
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
13
+ };
14
+ }
15
+
16
+ const OPENERS = ['ka', 'ki', 'ku', 'ha', 'hi', 'ho', 'na', 'no', 'mi', 'mo', 'sa', 'shi', 'so', 'ta', 'to', 'ya', 'yu', 'o', 'a', 'i'];
17
+ const MIDDLES = ['ra', 'ri', 'ru', 'wa', 'ne', 'ki', 'sa', 'shi', 'chi', 'ba', 'ga', 'zu', 'mi', 'no', 'ta', 'ma', 'ke', 'ko'];
18
+ const SUFFIXES = Object.freeze({
19
+ campsite: [' Camp', ' Rest', ' Hollow'],
20
+ pierHamlet: ['hama', 'ura', 'minato'],
21
+ ruin: [' Ruins', ' Stones', ' Remnant'],
22
+ shrine: [' Shrine', '-jinja', ' Hokora'],
23
+ village: ['mura', 'no', 'sato', 'machi'],
24
+ });
25
+
26
+ /** Deterministic per seed; `archetype` picks the suffix family. */
27
+ export function generatePlaceName(seed, archetype = 'village') {
28
+ const random = mulberry32(seed * 1597334677 + 89);
29
+ const syllables = 2 + (random() < 0.4 ? 1 : 0);
30
+ let stem = OPENERS[Math.floor(random() * OPENERS.length)];
31
+ for (let index = 1; index < syllables; index += 1) {
32
+ stem += MIDDLES[Math.floor(random() * MIDDLES.length)];
33
+ }
34
+ const suffixes = SUFFIXES[archetype] ?? SUFFIXES.village;
35
+ const suffix = suffixes[Math.floor(random() * suffixes.length)];
36
+ const name = suffix.startsWith(' ') || suffix.startsWith('-')
37
+ ? stem[0].toUpperCase() + stem.slice(1) + suffix
38
+ : stem[0].toUpperCase() + stem.slice(1) + suffix;
39
+ return name;
40
+ }