@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.
- package/AGENTS.md +127 -4
- package/ATTRIBUTION.md +41 -3
- package/README.md +234 -34
- package/docs/characters.md +144 -0
- package/docs/debug-panel.md +126 -0
- package/docs/docs.css +589 -0
- package/docs/environment.md +186 -0
- package/docs/getting-started.md +180 -0
- package/docs/index.html +14 -0
- package/docs/lab-architecture.md +100 -0
- package/docs/lighting.md +825 -0
- package/docs/main.jsx +739 -0
- package/docs/mcp.md +97 -0
- package/docs/post-processing.md +98 -0
- package/docs/settings-reference.md +1878 -0
- package/docs/shader-constants.md +77 -0
- package/docs/sky.md +182 -0
- package/docs/style-labs.md +309 -0
- package/docs/texture-lab.md +135 -0
- package/docs/toon-shading.md +183 -0
- package/docs/tsl-conventions.md +167 -0
- package/docs/vegetation-sky.md +275 -0
- package/docs/water.md +430 -0
- package/docs/weather.md +200 -0
- package/docs/world-scale.md +111 -0
- package/mcp/server.mjs +610 -0
- package/mcp/style-lab-tools.mjs +371 -0
- package/mcp/vite-plugin.mjs +174 -0
- package/mcp/workspace.mjs +397 -0
- package/package.json +64 -5
- package/src/ambientfx/INTEGRATION.md +164 -0
- package/src/ambientfx/ambientFxPresets.js +83 -0
- package/src/ambientfx/ambientFxSettings.js +368 -0
- package/src/ambientfx/emitters.js +169 -0
- package/src/ambientfx/index.js +5 -0
- package/src/ambientfx/particleBackbone.js +493 -0
- package/src/ambientfx/stylizedAmbientFx.js +450 -0
- package/src/assetlib/ambientcg.js +117 -0
- package/src/assetlib/assetRef.js +91 -0
- package/src/assetlib/importedEntry.js +40 -0
- package/src/assetlib/index.js +23 -0
- package/src/assetlib/kaykit.js +192 -0
- package/src/assetlib/kaykitStaticIndex.js +700 -0
- package/src/assetlib/loadImported.js +143 -0
- package/src/assetlib/opensource3d.js +113 -0
- package/src/assetlib/polyhaven.js +182 -0
- package/src/assetlib/polypizza.js +115 -0
- package/src/assetlib/smithsonian.js +214 -0
- package/src/assetlib/sources.js +279 -0
- package/src/assetlib/zip.js +58 -0
- package/src/biome/biomeGenerator.js +385 -0
- package/src/biome/biomeRuntime.js +299 -0
- package/src/biome/index.js +2 -0
- package/src/buildinggen/buildingAsset.js +44 -0
- package/src/buildinggen/buildingGrammar.js +311 -0
- package/src/buildinggen/buildingMesh.js +451 -0
- package/src/buildinggen/buildingPresets.js +46 -0
- package/src/buildinggen/buildingRecipe.js +100 -0
- package/src/buildinggen/buildingSettings.js +238 -0
- package/src/buildinggen/index.js +6 -0
- package/src/camera/cameraDirector.js +157 -0
- package/src/camera/cameraGenerator.js +367 -0
- package/src/camera/cameraRig.js +570 -0
- package/src/camera/cameraSettings.js +236 -0
- package/src/camera/index.js +7 -0
- package/src/catalog/builtinEntries.js +245 -0
- package/src/catalog/catalog.js +210 -0
- package/src/catalog/index.js +3 -0
- package/src/catalog/manifest.js +84 -0
- package/src/core/generation.js +529 -0
- package/src/environment/environmentRigs.js +21 -1
- package/src/environment/environmentSettings.js +4 -0
- package/src/environment/environmentSunShadowPass.js +8 -0
- package/src/fauna/INTEGRATION.md +174 -0
- package/src/fauna/boids.js +861 -0
- package/src/fauna/faunaBodies.js +492 -0
- package/src/fauna/faunaPresets.js +52 -0
- package/src/fauna/faunaSettings.js +525 -0
- package/src/fauna/index.js +5 -0
- package/src/fauna/stylizedFauna.js +395 -0
- package/src/game-feel/gameFeelGenerator.js +402 -0
- package/src/game-feel/gameFeelRuntime.js +549 -0
- package/src/game-feel/index.js +2 -0
- package/src/index.js +17 -6
- package/src/lighting/colorIntensity.js +177 -0
- package/src/lighting/index.js +161 -0
- package/src/lighting/lightDescriptors.js +249 -0
- package/src/lighting/lightingCapabilities.js +79 -0
- package/src/lighting/lightingDocuments.js +247 -0
- package/src/lighting/lightingFixtures.js +446 -0
- package/src/lighting/lightingGenerator.js +449 -0
- package/src/lighting/lightingPresets.js +319 -0
- package/src/lighting/lightingRuntime.js +723 -0
- package/src/lighting/lightingStyle.js +386 -0
- package/src/lighting/lightingSystem.js +774 -0
- package/src/lighting/unrealExport.js +186 -0
- package/src/lighting/utils.js +87 -0
- package/src/motion/index.js +5 -0
- package/src/motion/motionClip.js +441 -0
- package/src/motion/motionController.js +628 -0
- package/src/motion/motionDocuments.js +225 -0
- package/src/motion/motionGraph.js +307 -0
- package/src/motion/motionSettings.js +222 -0
- package/src/pathgen/index.js +7 -0
- package/src/pathgen/pathBridge.js +232 -0
- package/src/pathgen/pathPresets.js +35 -0
- package/src/pathgen/pathRibbon.js +410 -0
- package/src/pathgen/pathRouter.js +380 -0
- package/src/pathgen/pathSettings.js +335 -0
- package/src/pathgen/pathTextures.js +123 -0
- package/src/pathgen/stylizedPaths.js +453 -0
- package/src/post/index.js +1 -0
- package/src/post/postGenerator.js +177 -0
- package/src/post/postProcessing.js +41 -0
- package/src/propgen/generatorsWave1.js +379 -0
- package/src/propgen/generatorsWave2.js +462 -0
- package/src/propgen/index.js +5 -0
- package/src/propgen/propAsset.js +323 -0
- package/src/propgen/propParts.js +170 -0
- package/src/propgen/propPlacement.js +459 -0
- package/src/propgen/propPresets.js +82 -0
- package/src/propgen/propSettings.js +395 -0
- package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
- package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
- package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
- package/src/shaders-tsl/chunks/water-waves.js +90 -11
- package/src/shaders-tsl/environment.js +15 -1
- package/src/shaders-tsl/flower.js +279 -30
- package/src/shaders-tsl/grass.js +60 -33
- package/src/shaders-tsl/sky.js +125 -31
- package/src/shaders-tsl/tree-leaf.js +61 -25
- package/src/shaders-tsl/water-breaker.js +7 -4
- package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
- package/src/shaders-tsl/water.js +439 -49
- package/src/shaders-tsl/woody-surface.js +154 -0
- package/src/sky/sceneOverrideLayers.js +10 -0
- package/src/sky/skyQuality.js +26 -0
- package/src/sky/stylizedSky.js +753 -45
- package/src/soundscape/index.js +4 -0
- package/src/soundscape/soundscapeGenerator.js +179 -0
- package/src/soundscape/soundscapeRuntime.js +806 -0
- package/src/soundscape/soundscapeSettings.js +292 -0
- package/src/styles/index.js +13 -0
- package/src/styles/styleBundle.js +325 -0
- package/src/stylizedTerrain.js +32 -2
- package/src/stylizedWorld.js +423 -20
- package/src/texgen/evaluateTexture.js +675 -0
- package/src/texgen/index.js +60 -0
- package/src/texgen/noise2.js +210 -0
- package/src/texgen/textureAi.js +436 -0
- package/src/texgen/textureGenerators.js +516 -0
- package/src/texgen/texturePresets.js +490 -0
- package/src/texgen/textureSettings.js +342 -0
- package/src/texgen/textureThree.js +59 -0
- package/src/vegetation/flowerSpecies.js +15 -3
- package/src/vegetation/grassPalettes.js +153 -0
- package/src/vegetation/index.js +6 -0
- package/src/vegetation/stylizedBush.js +2 -0
- package/src/vegetation/stylizedFlower.js +82 -0
- package/src/vegetation/stylizedFlowers.js +48 -7
- package/src/vegetation/stylizedForest.js +29 -1
- package/src/vegetation/stylizedGrass.js +291 -56
- package/src/vegetation/stylizedTree.js +38 -2
- package/src/vegetation/stylizedTreeFoliage.js +2 -1
- package/src/vegetation/vegetationShaders.js +1110 -0
- package/src/vfxgen/INTEGRATION.md +145 -0
- package/src/vfxgen/core/burstBackbone.js +380 -0
- package/src/vfxgen/core/projectileCore.js +92 -0
- package/src/vfxgen/core/spriteShapes.js +98 -0
- package/src/vfxgen/core/trailRibbon.js +272 -0
- package/src/vfxgen/core/vfxRandom.js +29 -0
- package/src/vfxgen/effects/emitHelpers.js +37 -0
- package/src/vfxgen/effects/magicEffects.js +162 -0
- package/src/vfxgen/effects/movementEffects.js +87 -0
- package/src/vfxgen/effects/weaponEffects.js +118 -0
- package/src/vfxgen/index.js +18 -0
- package/src/vfxgen/moves/moveController.js +146 -0
- package/src/vfxgen/moves/moveLibrary.js +335 -0
- package/src/vfxgen/vfxPresets.js +98 -0
- package/src/vfxgen/vfxSettings.js +384 -0
- package/src/vfxgen/vfxSystem.js +449 -0
- package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
- package/src/villagegen/index.js +4 -0
- package/src/villagegen/stylizedVillage.js +490 -0
- package/src/villagegen/villageArchetypes.js +160 -0
- package/src/villagegen/villageNames.js +40 -0
- package/src/villagegen/villageSites.js +105 -0
- package/src/water/sceneOverrideLayers.js +23 -0
- package/src/water/water.js +5 -0
- package/src/water/waterBreakerSystem.js +15 -1
- package/src/water/waterCurrentField.js +447 -0
- package/src/water/waterMaterial.js +12 -0
- package/src/water/waterNearshorePhase.js +320 -0
- package/src/water/waterScenePasses.js +83 -30
- package/src/water/waterSettings.js +325 -13
- package/src/water/waterShoreMaterial.js +322 -0
- package/src/water/waterShoreStateField.js +605 -0
- package/src/water/waterSurface.js +797 -28
- package/src/weather/index.js +6 -0
- package/src/weather/weatherPrecipitation.js +221 -0
- package/src/weather/weatherPresets.js +258 -0
- package/src/weather/weatherSettings.js +269 -0
- package/src/weather/weatherSystem.js +871 -0
- package/src/worldMinimap.js +62 -0
- package/src/worldPresets.js +4 -1
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
// Steering core for the fauna layer — classic boids with cheap XZ spatial
|
|
2
|
+
// hashing, staggered ticks, and hard budgets. Pure JS with no THREE
|
|
3
|
+
// dependency so scripts/verify-fauna.mjs can run the whole simulation in
|
|
4
|
+
// Node and assert the invariants (fish depth clamps, mask compliance, tick
|
|
5
|
+
// budgets, determinism).
|
|
6
|
+
//
|
|
7
|
+
// The split of work is the whole performance story:
|
|
8
|
+
// - a full steering TICK (neighbor search, forces, rand-gated decisions,
|
|
9
|
+
// heightAt cache refresh) touches at most `tickShare` (default 1/4) of
|
|
10
|
+
// all agents per update, walked round-robin;
|
|
11
|
+
// - INTEGRATION (pos += v·dt, clamps, heading smoothing) runs for every
|
|
12
|
+
// agent every frame — a few flops each, so motion stays 60 Hz smooth
|
|
13
|
+
// while steering runs at ~15 Hz;
|
|
14
|
+
// - agents farther than `farDistance` from the follow target skip steering
|
|
15
|
+
// entirely and fly scripted circles (their tick only refreshes terrain
|
|
16
|
+
// caches), so a distant flock costs integration alone.
|
|
17
|
+
//
|
|
18
|
+
// Determinism: one mulberry32 stream per agent (seeded from the world seed),
|
|
19
|
+
// advanced only inside that agent's own ticks; the walk order is fixed; no
|
|
20
|
+
// Math.random anywhere. Same seed + same update(dt) sequence → the same sky.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
createFaunaSettings,
|
|
24
|
+
FAUNA_SPECIES,
|
|
25
|
+
normalizeFaunaPopulations,
|
|
26
|
+
} from './faunaSettings.js';
|
|
27
|
+
|
|
28
|
+
export const FAUNA_STATE = Object.freeze({
|
|
29
|
+
FLY: 0, // also HOVER for dragonflies
|
|
30
|
+
APPROACH: 1, // bird gliding to a claimed perch
|
|
31
|
+
PERCHED: 2,
|
|
32
|
+
DART: 3, // dragonfly relocating
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function mulberry32(seed) {
|
|
36
|
+
let state = (Math.trunc(seed) || 1) >>> 0;
|
|
37
|
+
return () => {
|
|
38
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
39
|
+
let t = state;
|
|
40
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
41
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
42
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hashCombine(a, b) {
|
|
47
|
+
let h = (Math.imul(Math.trunc(a) | 0, 0x9e3779b1) ^ Math.imul(Math.trunc(b) | 0, 0x85ebca77)) | 0;
|
|
48
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
49
|
+
return (h ^ (h >>> 16)) >>> 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Per-agent rng: mulberry32 with the state array as backing store.
|
|
53
|
+
function nextRand(states, i) {
|
|
54
|
+
const s = (states[i] + 0x6d2b79f5) >>> 0;
|
|
55
|
+
states[i] = s;
|
|
56
|
+
let t = s;
|
|
57
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
58
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
59
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sampleHeight(heightAt, x, z) {
|
|
63
|
+
if (typeof heightAt !== 'function') return 0;
|
|
64
|
+
const y = Number(heightAt(x, z));
|
|
65
|
+
return Number.isFinite(y) ? y : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeBounds(bounds) {
|
|
69
|
+
if (typeof bounds === 'number' && Number.isFinite(bounds)) {
|
|
70
|
+
const half = Math.max(Math.abs(bounds), 10);
|
|
71
|
+
return { minX: -half, maxX: half, minZ: -half, maxZ: half };
|
|
72
|
+
}
|
|
73
|
+
const source = bounds && typeof bounds === 'object' ? bounds : {};
|
|
74
|
+
if (source.min && source.max) {
|
|
75
|
+
return {
|
|
76
|
+
minX: Number(source.min.x) || -200, maxX: Number(source.max.x) || 200,
|
|
77
|
+
minZ: Number(source.min.z) || -200, maxZ: Number(source.max.z) || 200,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const hx = Math.max(Math.abs(Number(source.x) || 200), 10);
|
|
81
|
+
const hz = Math.max(Math.abs(Number(source.z) || Number(source.x) || 200), 10);
|
|
82
|
+
return { minX: -hx, maxX: hx, minZ: -hz, maxZ: hz };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeArrays(count) {
|
|
86
|
+
return {
|
|
87
|
+
px: new Float32Array(count), py: new Float32Array(count), pz: new Float32Array(count),
|
|
88
|
+
vx: new Float32Array(count), vy: new Float32Array(count), vz: new Float32Array(count),
|
|
89
|
+
heading: new Float32Array(count), bank: new Float32Array(count),
|
|
90
|
+
phase: new Float32Array(count), speedMul: new Float32Array(count), scale: new Float32Array(count),
|
|
91
|
+
variant: new Uint8Array(count), state: new Uint8Array(count), far: new Uint8Array(count),
|
|
92
|
+
amp: new Float32Array(count), ampTarget: new Float32Array(count),
|
|
93
|
+
fold: new Float32Array(count), foldTarget: new Float32Array(count),
|
|
94
|
+
timer: new Float32Array(count), cooldown: new Float32Array(count),
|
|
95
|
+
groundY: new Float32Array(count), // terrain cache (bed for fish)
|
|
96
|
+
homeX: new Float32Array(count), homeY: new Float32Array(count), homeZ: new Float32Array(count),
|
|
97
|
+
target: new Int32Array(count).fill(-1), // perch index for birds
|
|
98
|
+
turn: new Float32Array(count), // scripted-loop yaw rate (rad/s)
|
|
99
|
+
lastTick: new Float32Array(count),
|
|
100
|
+
hoverBias: new Float32Array(count), // per-agent band position 0..1
|
|
101
|
+
wf: new Float32Array(count * 4), // wander sinusoid freqs/phases
|
|
102
|
+
rng: new Uint32Array(count),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Rejection-samples `count` points passing `accept(x, z)`; degrades to fewer
|
|
107
|
+
// points instead of hanging when the mask is starved.
|
|
108
|
+
function samplePool(rng, bounds, count, accept) {
|
|
109
|
+
const pool = [];
|
|
110
|
+
const spanX = bounds.maxX - bounds.minX;
|
|
111
|
+
const spanZ = bounds.maxZ - bounds.minZ;
|
|
112
|
+
let attempts = Math.max(count, 1) * 60;
|
|
113
|
+
while (pool.length < count && attempts > 0) {
|
|
114
|
+
attempts -= 1;
|
|
115
|
+
const x = bounds.minX + rng() * spanX;
|
|
116
|
+
const z = bounds.minZ + rng() * spanZ;
|
|
117
|
+
if (accept(x, z)) pool.push({ x, z });
|
|
118
|
+
}
|
|
119
|
+
return pool;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates the CPU fauna simulation. All coordinates are world meters.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object} options
|
|
126
|
+
* @param {number} [options.seed]
|
|
127
|
+
* @param {Function} [options.heightAt] `(x, z) => y` terrain sampler.
|
|
128
|
+
* @param {number} [options.waterLevel]
|
|
129
|
+
* @param {number|{x,z}|{min,max}} [options.bounds] Roaming rectangle.
|
|
130
|
+
* @param {Object} [options.species] Populations `{ birds, butterflies, dragonflies, fish }`.
|
|
131
|
+
* @param {Object} [options.masks] `{ flowers?: (x, z) => boolean }` butterfly anchor mask.
|
|
132
|
+
* @param {Object} [options.settings] Grouped overrides (see createFaunaSettings).
|
|
133
|
+
* @param {Array} [options.perchPoints] Initial `{ x, y, z }` roosts.
|
|
134
|
+
* @param {Object} [options.variantCounts] Geometry variants per species (the
|
|
135
|
+
* renderer passes the palette's real scheme count; variant assignment
|
|
136
|
+
* happens here so it is deterministic and Node-testable).
|
|
137
|
+
*/
|
|
138
|
+
export function createFaunaSimulation({
|
|
139
|
+
seed = 1,
|
|
140
|
+
heightAt = null,
|
|
141
|
+
waterLevel = 0,
|
|
142
|
+
bounds = 240,
|
|
143
|
+
species = {},
|
|
144
|
+
masks = {},
|
|
145
|
+
settings = {},
|
|
146
|
+
perchPoints = [],
|
|
147
|
+
variantCounts: variantCountsOption = {},
|
|
148
|
+
} = {}) {
|
|
149
|
+
const cfg = createFaunaSettings(settings);
|
|
150
|
+
const populations = normalizeFaunaPopulations(species);
|
|
151
|
+
const box = normalizeBounds(bounds);
|
|
152
|
+
const rootRng = mulberry32(hashCombine(Math.round(Number(seed) || 1), 0xfa07a));
|
|
153
|
+
const ground = (x, z) => sampleHeight(heightAt, x, z);
|
|
154
|
+
const flowerMask = typeof masks?.flowers === 'function' ? masks.flowers : null;
|
|
155
|
+
|
|
156
|
+
// --- shared pools ---------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
// Terrain roosts used when no host cluster has registered perch points yet.
|
|
159
|
+
const perches = [];
|
|
160
|
+
const addPerchPoints = (points) => {
|
|
161
|
+
for (const p of points ?? []) {
|
|
162
|
+
if (!Number.isFinite(p?.x) || !Number.isFinite(p?.z)) continue;
|
|
163
|
+
perches.push({
|
|
164
|
+
occupant: -1,
|
|
165
|
+
x: Number(p.x),
|
|
166
|
+
y: Number.isFinite(p.y) ? Number(p.y) : ground(p.x, p.z),
|
|
167
|
+
z: Number(p.z),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
if (Array.isArray(perchPoints) && perchPoints.length > 0) addPerchPoints(perchPoints);
|
|
172
|
+
if (perches.length === 0 && populations.birds > 0) {
|
|
173
|
+
addPerchPoints(samplePool(rootRng, box, 24, (x, z) => ground(x, z) > waterLevel + 0.5)
|
|
174
|
+
.map((p) => ({ x: p.x, y: ground(p.x, p.z) + 0.15, z: p.z })));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Butterfly anchors come FROM the mask, so wandering around an anchor
|
|
178
|
+
// (radius-capped) keeps agents over the flower field by construction.
|
|
179
|
+
// Fewer anchors than agents on purpose: shared anchors read as the flower
|
|
180
|
+
// clumps butterflies gather on, instead of a uniform sprinkle.
|
|
181
|
+
const dryLand = (x, z) => ground(x, z) > waterLevel + 0.2;
|
|
182
|
+
const flowerPool = populations.butterflies > 0
|
|
183
|
+
? samplePool(rootRng, box, Math.max(8, Math.ceil(populations.butterflies / 5)),
|
|
184
|
+
flowerMask ? (x, z) => flowerMask(x, z) && dryLand(x, z) : dryLand)
|
|
185
|
+
: [];
|
|
186
|
+
|
|
187
|
+
// Dragonfly anchors hover the shallow-water margin (bed just below the
|
|
188
|
+
// waterline). Falls back to any water, then to the butterfly pool.
|
|
189
|
+
const shoreWater = (x, z) => {
|
|
190
|
+
const bed = ground(x, z);
|
|
191
|
+
return bed < waterLevel - 0.15 && bed > waterLevel - 3.5;
|
|
192
|
+
};
|
|
193
|
+
let dragonPool = populations.dragonflies > 0
|
|
194
|
+
? samplePool(rootRng, box, Math.max(6, Math.ceil(populations.dragonflies / 2)), shoreWater)
|
|
195
|
+
: [];
|
|
196
|
+
if (populations.dragonflies > 0 && dragonPool.length === 0) {
|
|
197
|
+
dragonPool = samplePool(rootRng, box, Math.max(12, populations.dragonflies),
|
|
198
|
+
(x, z) => ground(x, z) < waterLevel - 0.15);
|
|
199
|
+
}
|
|
200
|
+
if (populations.dragonflies > 0 && dragonPool.length === 0) dragonPool = flowerPool;
|
|
201
|
+
|
|
202
|
+
// --- species construction -------------------------------------------------
|
|
203
|
+
|
|
204
|
+
const speciesStates = {};
|
|
205
|
+
|
|
206
|
+
function initCommon(state, i, x, y, z, s, headingAngle, speed) {
|
|
207
|
+
const a = state.arrays;
|
|
208
|
+
a.px[i] = x; a.py[i] = y; a.pz[i] = z;
|
|
209
|
+
a.vx[i] = Math.sin(headingAngle) * speed;
|
|
210
|
+
a.vy[i] = 0;
|
|
211
|
+
a.vz[i] = Math.cos(headingAngle) * speed;
|
|
212
|
+
a.heading[i] = headingAngle;
|
|
213
|
+
a.rng[i] = hashCombine(hashCombine(seed, state.speciesIndex * 0x51ed), i + 1);
|
|
214
|
+
const r = () => nextRand(a.rng, i);
|
|
215
|
+
a.variant[i] = Math.floor(r() * state.variantCount) % state.variantCount;
|
|
216
|
+
a.phase[i] = r() * Math.PI * 2;
|
|
217
|
+
a.speedMul[i] = 0.85 + r() * 0.3;
|
|
218
|
+
a.scale[i] = s * (1 - state.scaleJitter / 2 + r() * state.scaleJitter);
|
|
219
|
+
a.amp[i] = 1; a.ampTarget[i] = 1;
|
|
220
|
+
a.turn[i] = (r() < 0.5 ? -1 : 1) * (0.25 + r() * 0.3);
|
|
221
|
+
a.hoverBias[i] = r();
|
|
222
|
+
a.wf[i * 4] = 0.25 + r() * 0.5;
|
|
223
|
+
a.wf[i * 4 + 1] = r() * Math.PI * 2;
|
|
224
|
+
a.wf[i * 4 + 2] = 0.3 + r() * 0.55;
|
|
225
|
+
a.wf[i * 4 + 3] = r() * Math.PI * 2;
|
|
226
|
+
a.groundY[i] = ground(x, z);
|
|
227
|
+
a.lastTick[i] = -r() * 0.25; // desync first ticks
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function makeSpecies(name, index, count, variantCount, scaleJitter, cellSize) {
|
|
231
|
+
const state = {
|
|
232
|
+
arrays: makeArrays(count),
|
|
233
|
+
cell: Math.max(cellSize, 1),
|
|
234
|
+
count,
|
|
235
|
+
grid: new Map(),
|
|
236
|
+
gridStamp: -1,
|
|
237
|
+
name,
|
|
238
|
+
scaleJitter,
|
|
239
|
+
speciesIndex: index,
|
|
240
|
+
variantCount: Math.max(1, variantCount),
|
|
241
|
+
};
|
|
242
|
+
speciesStates[name] = state;
|
|
243
|
+
return state;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const variantCounts = {
|
|
247
|
+
birds: 3, butterflies: 4, dragonflies: 3, fish: 4,
|
|
248
|
+
...(variantCountsOption && typeof variantCountsOption === 'object' ? variantCountsOption : {}),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Birds spawn in a few flock clusters so the first frame already reads as
|
|
252
|
+
// flocks, not confetti.
|
|
253
|
+
{
|
|
254
|
+
const s = makeSpecies('birds', 0, populations.birds, variantCounts.birds, 0.24, cfg.birds.neighborRadius);
|
|
255
|
+
const clusters = samplePool(rootRng, box, Math.max(1, Math.ceil(s.count / 16)), () => true);
|
|
256
|
+
for (let i = 0; i < s.count; i += 1) {
|
|
257
|
+
const c = clusters[i % Math.max(clusters.length, 1)] ?? { x: 0, z: 0 };
|
|
258
|
+
const x = c.x + (rootRng() - 0.5) * 24;
|
|
259
|
+
const z = c.z + (rootRng() - 0.5) * 24;
|
|
260
|
+
const g = ground(x, z);
|
|
261
|
+
const y = g + cfg.birds.altitudeMin
|
|
262
|
+
+ rootRng() * Math.max(cfg.birds.altitudeMax - cfg.birds.altitudeMin, 1);
|
|
263
|
+
initCommon(s, i, x, y, z, cfg.birds.scale, rootRng() * Math.PI * 2, cfg.birds.cruiseSpeed);
|
|
264
|
+
s.arrays.timer[i] = 2 + rootRng() * 6; // perch-decision cooldown
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
const s = makeSpecies('butterflies', 1, populations.butterflies, variantCounts.butterflies, 0.4, 4);
|
|
270
|
+
for (let i = 0; i < s.count; i += 1) {
|
|
271
|
+
const anchor = flowerPool.length > 0 ? flowerPool[i % flowerPool.length] : { x: 0, z: 0 };
|
|
272
|
+
const x = anchor.x + (rootRng() - 0.5) * 3;
|
|
273
|
+
const z = anchor.z + (rootRng() - 0.5) * 3;
|
|
274
|
+
const g = ground(x, z);
|
|
275
|
+
const y = g + cfg.butterflies.hoverMin
|
|
276
|
+
+ rootRng() * Math.max(cfg.butterflies.hoverMax - cfg.butterflies.hoverMin, 0.1);
|
|
277
|
+
initCommon(s, i, x, y, z, cfg.butterflies.scale, rootRng() * Math.PI * 2, cfg.butterflies.speed * 0.6);
|
|
278
|
+
s.arrays.homeX[i] = anchor.x;
|
|
279
|
+
s.arrays.homeZ[i] = anchor.z;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
{
|
|
284
|
+
const s = makeSpecies('dragonflies', 2, populations.dragonflies, variantCounts.dragonflies, 0.3, 5);
|
|
285
|
+
for (let i = 0; i < s.count; i += 1) {
|
|
286
|
+
const anchor = dragonPool.length > 0 ? dragonPool[i % dragonPool.length] : { x: 0, z: 0 };
|
|
287
|
+
const x = anchor.x + (rootRng() - 0.5) * 2;
|
|
288
|
+
const z = anchor.z + (rootRng() - 0.5) * 2;
|
|
289
|
+
initCommon(s, i, x, waterLevel + cfg.dragonflies.hoverHeight, z,
|
|
290
|
+
cfg.dragonflies.scale, rootRng() * Math.PI * 2, 0.4);
|
|
291
|
+
s.arrays.homeX[i] = anchor.x;
|
|
292
|
+
s.arrays.homeZ[i] = anchor.z;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Fish spawn in schools at points with enough water column; a world with
|
|
297
|
+
// no qualifying water simply holds zero fish (population budget, not error).
|
|
298
|
+
{
|
|
299
|
+
const deepEnough = (x, z) => waterLevel - ground(x, z) >= cfg.fish.minSpawnDepth;
|
|
300
|
+
const schools = populations.fish > 0
|
|
301
|
+
? samplePool(rootRng, box, Math.max(1, Math.ceil(populations.fish / 14)), deepEnough)
|
|
302
|
+
: [];
|
|
303
|
+
const count = schools.length > 0 ? populations.fish : 0;
|
|
304
|
+
const s = makeSpecies('fish', 3, count, variantCounts.fish, 0.5, cfg.fish.neighborRadius);
|
|
305
|
+
for (let i = 0; i < s.count; i += 1) {
|
|
306
|
+
const c = schools[i % schools.length];
|
|
307
|
+
let x = c.x;
|
|
308
|
+
let z = c.z;
|
|
309
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
310
|
+
const cx = c.x + (rootRng() - 0.5) * 8;
|
|
311
|
+
const cz = c.z + (rootRng() - 0.5) * 8;
|
|
312
|
+
if (deepEnough(cx, cz)) { x = cx; z = cz; break; }
|
|
313
|
+
}
|
|
314
|
+
const bed = ground(x, z);
|
|
315
|
+
const lo = bed + cfg.fish.bedMargin;
|
|
316
|
+
const hi = waterLevel - cfg.fish.surfaceMargin;
|
|
317
|
+
const y = Math.max(hi - (0.4 + rootRng() * 1.6), lo + 0.2);
|
|
318
|
+
initCommon(s, i, x, y, z, cfg.fish.scale, rootRng() * Math.PI * 2, cfg.fish.cruiseSpeed);
|
|
319
|
+
s.arrays.py[i] = Math.min(Math.max(y, lo), Math.max(hi, lo));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Fixed walk order for the staggered tick budget.
|
|
324
|
+
const walk = [];
|
|
325
|
+
for (const name of FAUNA_SPECIES) {
|
|
326
|
+
const s = speciesStates[name];
|
|
327
|
+
for (let i = 0; i < s.count; i += 1) walk.push({ i, s });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const stats = {
|
|
331
|
+
agents: Object.fromEntries(FAUNA_SPECIES.map((name) => [name, speciesStates[name].count])),
|
|
332
|
+
steeredLastUpdate: 0,
|
|
333
|
+
tickBudget: Math.max(1, Math.ceil(walk.length * cfg.shared.tickShare)),
|
|
334
|
+
total: walk.length,
|
|
335
|
+
updates: 0,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const sim = {
|
|
339
|
+
addPerchPoints,
|
|
340
|
+
bounds: box,
|
|
341
|
+
perches,
|
|
342
|
+
settings: cfg,
|
|
343
|
+
species: speciesStates,
|
|
344
|
+
stats,
|
|
345
|
+
time: 0,
|
|
346
|
+
waterLevel,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// --- spatial hash ----------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
function rebuildGrid(state, stamp) {
|
|
352
|
+
if (state.gridStamp === stamp) return;
|
|
353
|
+
state.gridStamp = stamp;
|
|
354
|
+
state.grid.clear();
|
|
355
|
+
const { px, pz } = state.arrays;
|
|
356
|
+
const inv = 1 / state.cell;
|
|
357
|
+
for (let i = 0; i < state.count; i += 1) {
|
|
358
|
+
const key = ((Math.floor(px[i] * inv) + 1024) << 12) | ((Math.floor(pz[i] * inv) + 1024) & 0xfff);
|
|
359
|
+
const bucket = state.grid.get(key);
|
|
360
|
+
if (bucket) bucket.push(i);
|
|
361
|
+
else state.grid.set(key, [i]);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const flock = { ax: 0, ay: 0, az: 0 };
|
|
366
|
+
function accumulateFlock(state, i, radius, sepRadius, wCoh, wAli, wSep) {
|
|
367
|
+
const a = state.arrays;
|
|
368
|
+
const inv = 1 / state.cell;
|
|
369
|
+
const cx = Math.floor(a.px[i] * inv);
|
|
370
|
+
const cz = Math.floor(a.pz[i] * inv);
|
|
371
|
+
const radiusSq = radius * radius;
|
|
372
|
+
const sepSq = sepRadius * sepRadius;
|
|
373
|
+
let n = 0;
|
|
374
|
+
let sumX = 0; let sumY = 0; let sumZ = 0;
|
|
375
|
+
let velX = 0; let velY = 0; let velZ = 0;
|
|
376
|
+
let sepX = 0; let sepY = 0; let sepZ = 0;
|
|
377
|
+
for (let gx = cx - 1; gx <= cx + 1; gx += 1) {
|
|
378
|
+
for (let gz = cz - 1; gz <= cz + 1; gz += 1) {
|
|
379
|
+
const bucket = state.grid.get(((gx + 1024) << 12) | ((gz + 1024) & 0xfff));
|
|
380
|
+
if (!bucket) continue;
|
|
381
|
+
for (let b = 0; b < bucket.length; b += 1) {
|
|
382
|
+
const j = bucket[b];
|
|
383
|
+
if (j === i) continue;
|
|
384
|
+
const dx = a.px[j] - a.px[i];
|
|
385
|
+
const dy = a.py[j] - a.py[i];
|
|
386
|
+
const dz = a.pz[j] - a.pz[i];
|
|
387
|
+
const d2 = dx * dx + dy * dy + dz * dz;
|
|
388
|
+
if (d2 > radiusSq) continue;
|
|
389
|
+
n += 1;
|
|
390
|
+
sumX += a.px[j]; sumY += a.py[j]; sumZ += a.pz[j];
|
|
391
|
+
velX += a.vx[j]; velY += a.vy[j]; velZ += a.vz[j];
|
|
392
|
+
if (d2 < sepSq && d2 > 1e-6) {
|
|
393
|
+
const w = 1 / d2;
|
|
394
|
+
sepX -= dx * w; sepY -= dy * w; sepZ -= dz * w;
|
|
395
|
+
}
|
|
396
|
+
if (n >= 10) break; // cap: flocking quality saturates fast
|
|
397
|
+
}
|
|
398
|
+
if (n >= 10) break;
|
|
399
|
+
}
|
|
400
|
+
if (n >= 10) break;
|
|
401
|
+
}
|
|
402
|
+
if (n === 0) {
|
|
403
|
+
flock.ax = 0; flock.ay = 0; flock.az = 0;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const invN = 1 / n;
|
|
407
|
+
flock.ax = (sumX * invN - a.px[i]) * wCoh + velX * invN * wAli + sepX * wSep;
|
|
408
|
+
flock.ay = (sumY * invN - a.py[i]) * wCoh * 0.5 + velY * invN * wAli * 0.5 + sepY * wSep;
|
|
409
|
+
flock.az = (sumZ * invN - a.pz[i]) * wCoh + velZ * invN * wAli + sepZ * wSep;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function clampSpeed(a, i, min, max) {
|
|
413
|
+
const speed = Math.hypot(a.vx[i], a.vy[i], a.vz[i]);
|
|
414
|
+
if (speed < 1e-5) return;
|
|
415
|
+
const clamped = Math.min(Math.max(speed, min), max);
|
|
416
|
+
if (clamped !== speed) {
|
|
417
|
+
const k = clamped / speed;
|
|
418
|
+
a.vx[i] *= k; a.vy[i] *= k; a.vz[i] *= k;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function boundsForce(a, i, dt, strength = 3) {
|
|
423
|
+
const margin = 10;
|
|
424
|
+
if (a.px[i] < box.minX + margin) a.vx[i] += strength * dt * (box.minX + margin - a.px[i]);
|
|
425
|
+
if (a.px[i] > box.maxX - margin) a.vx[i] += strength * dt * (box.maxX - margin - a.px[i]);
|
|
426
|
+
if (a.pz[i] < box.minZ + margin) a.vz[i] += strength * dt * (box.minZ + margin - a.pz[i]);
|
|
427
|
+
if (a.pz[i] > box.maxZ - margin) a.vz[i] += strength * dt * (box.maxZ - margin - a.pz[i]);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- species ticks ---------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
function birdTick(state, i, dt, follow, stamp) {
|
|
433
|
+
const a = state.arrays;
|
|
434
|
+
const b = cfg.birds;
|
|
435
|
+
a.groundY[i] = ground(a.px[i], a.pz[i]);
|
|
436
|
+
a.cooldown[i] = Math.max(0, a.cooldown[i] - dt);
|
|
437
|
+
|
|
438
|
+
// TRUE 3D flee distance: a camera or player well above the flock is not
|
|
439
|
+
// a threat; a walker at perch height is.
|
|
440
|
+
const fx = follow ? a.px[i] - follow.x : 0;
|
|
441
|
+
const fy = follow ? a.py[i] - (follow.y ?? 0) : 0;
|
|
442
|
+
const fz = follow ? a.pz[i] - follow.z : 0;
|
|
443
|
+
const followD2 = follow ? fx * fx + fy * fy + fz * fz : Infinity;
|
|
444
|
+
|
|
445
|
+
if (a.state[i] === FAUNA_STATE.PERCHED) {
|
|
446
|
+
a.timer[i] -= dt;
|
|
447
|
+
if (a.timer[i] <= 0 || followD2 < b.fleeRadius * b.fleeRadius) {
|
|
448
|
+
// Flush: launch up and away, wings out.
|
|
449
|
+
const away = Math.max(Math.sqrt(followD2), 1e-3);
|
|
450
|
+
const dirX = follow && followD2 < b.fleeRadius * b.fleeRadius ? fx / away : Math.sin(a.heading[i]);
|
|
451
|
+
const dirZ = follow && followD2 < b.fleeRadius * b.fleeRadius ? fz / away : Math.cos(a.heading[i]);
|
|
452
|
+
if (a.target[i] >= 0 && perches[a.target[i]]) perches[a.target[i]].occupant = -1;
|
|
453
|
+
a.target[i] = -1;
|
|
454
|
+
a.state[i] = FAUNA_STATE.FLY;
|
|
455
|
+
a.foldTarget[i] = 0;
|
|
456
|
+
a.ampTarget[i] = 1;
|
|
457
|
+
a.vx[i] = dirX * b.cruiseSpeed * 1.2;
|
|
458
|
+
a.vy[i] = b.cruiseSpeed * 0.55;
|
|
459
|
+
a.vz[i] = dirZ * b.cruiseSpeed * 1.2;
|
|
460
|
+
a.cooldown[i] = 6 + nextRand(a.rng, i) * 8;
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (a.state[i] === FAUNA_STATE.APPROACH) {
|
|
466
|
+
const perch = perches[a.target[i]];
|
|
467
|
+
if (!perch || perch.occupant !== i) { a.state[i] = FAUNA_STATE.FLY; a.target[i] = -1; return; }
|
|
468
|
+
const dx = perch.x - a.px[i];
|
|
469
|
+
const dy = perch.y - a.py[i];
|
|
470
|
+
const dz = perch.z - a.pz[i];
|
|
471
|
+
const dist = Math.hypot(dx, dy, dz);
|
|
472
|
+
if (dist < 0.6) {
|
|
473
|
+
a.px[i] = perch.x; a.py[i] = perch.y + 0.1; a.pz[i] = perch.z;
|
|
474
|
+
a.vx[i] = 0; a.vy[i] = 0; a.vz[i] = 0;
|
|
475
|
+
a.state[i] = FAUNA_STATE.PERCHED;
|
|
476
|
+
a.foldTarget[i] = 1;
|
|
477
|
+
a.ampTarget[i] = 0;
|
|
478
|
+
a.timer[i] = b.perchDuration * (0.6 + nextRand(a.rng, i) * 0.8);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// Arrive: decelerating approach, gliding (low flap amplitude).
|
|
482
|
+
const arrive = Math.min(dist / 6, 1) * b.cruiseSpeed + 1.2;
|
|
483
|
+
const inv = arrive / Math.max(dist, 1e-4);
|
|
484
|
+
a.vx[i] += (dx * inv - a.vx[i]) * Math.min(1, 2.5 * dt);
|
|
485
|
+
a.vy[i] += (dy * inv - a.vy[i]) * Math.min(1, 2.5 * dt);
|
|
486
|
+
a.vz[i] += (dz * inv - a.vz[i]) * Math.min(1, 2.5 * dt);
|
|
487
|
+
a.ampTarget[i] = 0.25;
|
|
488
|
+
a.timer[i] -= dt;
|
|
489
|
+
if (a.timer[i] < -20) { // stuck approach: give up
|
|
490
|
+
if (perch) perch.occupant = -1;
|
|
491
|
+
a.target[i] = -1;
|
|
492
|
+
a.state[i] = FAUNA_STATE.FLY;
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// FLY. Far agents circle; only their caches refresh (above).
|
|
498
|
+
if (a.far[i]) {
|
|
499
|
+
clampSpeed(a, i, b.cruiseSpeed * 0.9, b.cruiseSpeed * 1.1);
|
|
500
|
+
a.vy[i] *= 0.6;
|
|
501
|
+
a.ampTarget[i] = 0.7;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
rebuildGrid(state, stamp);
|
|
506
|
+
accumulateFlock(state, i, b.neighborRadius, b.separationRadius,
|
|
507
|
+
b.cohesion * 0.4, b.alignment * 0.55, b.separation * 1.6);
|
|
508
|
+
a.vx[i] += flock.ax * dt;
|
|
509
|
+
a.vy[i] += flock.ay * dt;
|
|
510
|
+
a.vz[i] += flock.az * dt;
|
|
511
|
+
|
|
512
|
+
// Wander + altitude band spring.
|
|
513
|
+
const w = a.wf;
|
|
514
|
+
a.vx[i] += Math.sin(sim.time * w[i * 4] + w[i * 4 + 1]) * b.wander * 1.6 * dt;
|
|
515
|
+
a.vz[i] += Math.cos(sim.time * w[i * 4 + 2] + w[i * 4 + 3]) * b.wander * 1.6 * dt;
|
|
516
|
+
const bandY = a.groundY[i] + b.altitudeMin
|
|
517
|
+
+ a.hoverBias[i] * Math.max(b.altitudeMax - b.altitudeMin, 1);
|
|
518
|
+
a.vy[i] += (bandY - a.py[i]) * 0.35 * dt;
|
|
519
|
+
a.vy[i] *= 1 - Math.min(1, 0.6 * dt);
|
|
520
|
+
boundsForce(a, i, dt);
|
|
521
|
+
|
|
522
|
+
if (followD2 < b.fleeRadius * b.fleeRadius) {
|
|
523
|
+
const d = Math.max(Math.sqrt(followD2), 1e-3);
|
|
524
|
+
a.vx[i] += (fx / d) * 26 * dt;
|
|
525
|
+
a.vy[i] += 9 * dt;
|
|
526
|
+
a.vz[i] += (fz / d) * 26 * dt;
|
|
527
|
+
a.ampTarget[i] = 1;
|
|
528
|
+
a.cooldown[i] = Math.max(a.cooldown[i], 4);
|
|
529
|
+
} else if (a.cooldown[i] <= 0 && perches.length > 0
|
|
530
|
+
&& nextRand(a.rng, i) < b.perchChance * dt * 0.1) {
|
|
531
|
+
// Claim the nearest free perch within gliding range.
|
|
532
|
+
let best = -1;
|
|
533
|
+
let bestD2 = 30 * 30;
|
|
534
|
+
for (let p = 0; p < perches.length; p += 1) {
|
|
535
|
+
if (perches[p].occupant >= 0) continue;
|
|
536
|
+
const dx = perches[p].x - a.px[i];
|
|
537
|
+
const dz = perches[p].z - a.pz[i];
|
|
538
|
+
const d2 = dx * dx + dz * dz;
|
|
539
|
+
if (d2 < bestD2) { bestD2 = d2; best = p; }
|
|
540
|
+
}
|
|
541
|
+
if (best >= 0) {
|
|
542
|
+
perches[best].occupant = i;
|
|
543
|
+
a.target[i] = best;
|
|
544
|
+
a.state[i] = FAUNA_STATE.APPROACH;
|
|
545
|
+
a.timer[i] = 0;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
a.cooldown[i] = 3;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
clampSpeed(a, i, b.cruiseSpeed * 0.55, b.maxSpeed);
|
|
552
|
+
// Flap hard when climbing or slow, glide when descending.
|
|
553
|
+
const speed = Math.hypot(a.vx[i], a.vy[i], a.vz[i]);
|
|
554
|
+
a.ampTarget[i] = Math.min(1, Math.max(0.12,
|
|
555
|
+
0.45 + a.vy[i] * 0.35 + (speed < b.cruiseSpeed ? 0.25 : 0)));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function butterflyTick(state, i, dt, follow) {
|
|
559
|
+
const a = state.arrays;
|
|
560
|
+
const b = cfg.butterflies;
|
|
561
|
+
a.groundY[i] = ground(a.px[i], a.pz[i]);
|
|
562
|
+
|
|
563
|
+
const hx = a.homeX[i] - a.px[i];
|
|
564
|
+
const hz = a.homeZ[i] - a.pz[i];
|
|
565
|
+
const homeD = Math.hypot(hx, hz);
|
|
566
|
+
|
|
567
|
+
if (a.far[i]) {
|
|
568
|
+
// Scripted loop around the anchor: pull home + keep the circling turn.
|
|
569
|
+
if (homeD > 2) {
|
|
570
|
+
a.vx[i] += hx / homeD * 0.8 * dt;
|
|
571
|
+
a.vz[i] += hz / homeD * 0.8 * dt;
|
|
572
|
+
}
|
|
573
|
+
clampSpeed(a, i, b.speed * 0.4, b.speed);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Anchor spring, ramping in over the last 30% of the wander radius.
|
|
578
|
+
const over = homeD - b.wanderRadius * 0.7;
|
|
579
|
+
if (over > 0) {
|
|
580
|
+
const k = Math.min(over / Math.max(b.wanderRadius * 0.3, 0.5), 2) * 2.4;
|
|
581
|
+
a.vx[i] += hx / Math.max(homeD, 1e-3) * k * dt;
|
|
582
|
+
a.vz[i] += hz / Math.max(homeD, 1e-3) * k * dt;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// The flutter: sinusoid wander in XZ + a bobbing hover band.
|
|
586
|
+
const w = a.wf;
|
|
587
|
+
a.vx[i] += Math.sin(sim.time * (1.1 + w[i * 4]) + w[i * 4 + 1]) * b.speed * 2.6 * dt;
|
|
588
|
+
a.vz[i] += Math.cos(sim.time * (1.2 + w[i * 4 + 2]) + w[i * 4 + 3]) * b.speed * 2.6 * dt;
|
|
589
|
+
const hoverY = a.groundY[i] + b.hoverMin
|
|
590
|
+
+ a.hoverBias[i] * Math.max(b.hoverMax - b.hoverMin, 0.1);
|
|
591
|
+
a.vy[i] += (hoverY - a.py[i]) * 2.2 * dt;
|
|
592
|
+
a.vy[i] *= 1 - Math.min(1, 1.5 * dt);
|
|
593
|
+
|
|
594
|
+
if (follow) {
|
|
595
|
+
const fx = a.px[i] - follow.x;
|
|
596
|
+
const fy = a.py[i] - (follow.y ?? 0);
|
|
597
|
+
const fz = a.pz[i] - follow.z;
|
|
598
|
+
const d2 = fx * fx + fy * fy + fz * fz; // 3D: an aerial camera is far
|
|
599
|
+
if (d2 < b.fleeRadius * b.fleeRadius) {
|
|
600
|
+
const d = Math.max(Math.sqrt(d2), 1e-3);
|
|
601
|
+
a.vx[i] += (fx / d) * 8 * dt;
|
|
602
|
+
a.vy[i] += 2.5 * dt;
|
|
603
|
+
a.vz[i] += (fz / d) * 8 * dt;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Occasional anchor hop — only to NEARBY flower points, so butterflies
|
|
608
|
+
// drift around their patch instead of migrating across the map (and the
|
|
609
|
+
// flower-mask invariant holds through the transit).
|
|
610
|
+
if (flowerPool.length > 1 && nextRand(a.rng, i) < 0.06 * dt) {
|
|
611
|
+
const reach = b.wanderRadius * 3;
|
|
612
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
613
|
+
const pick = flowerPool[Math.floor(nextRand(a.rng, i) * flowerPool.length) % flowerPool.length];
|
|
614
|
+
const dx = pick.x - a.px[i];
|
|
615
|
+
const dz = pick.z - a.pz[i];
|
|
616
|
+
if (dx * dx + dz * dz < reach * reach) {
|
|
617
|
+
a.homeX[i] = pick.x;
|
|
618
|
+
a.homeZ[i] = pick.z;
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
boundsForce(a, i, dt);
|
|
624
|
+
clampSpeed(a, i, b.speed * 0.3, b.speed * 1.8);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function dragonflyTick(state, i, dt, follow) {
|
|
628
|
+
const a = state.arrays;
|
|
629
|
+
const b = cfg.dragonflies;
|
|
630
|
+
a.groundY[i] = ground(a.px[i], a.pz[i]);
|
|
631
|
+
const hoverY = waterLevel + b.hoverHeight * (0.7 + a.hoverBias[i] * 0.7);
|
|
632
|
+
|
|
633
|
+
if (a.state[i] === FAUNA_STATE.DART) {
|
|
634
|
+
const dx = a.homeX[i] - a.px[i];
|
|
635
|
+
const dz = a.homeZ[i] - a.pz[i];
|
|
636
|
+
const d = Math.hypot(dx, dz);
|
|
637
|
+
if (d < 1) {
|
|
638
|
+
a.state[i] = FAUNA_STATE.FLY;
|
|
639
|
+
a.vx[i] *= 0.25; a.vz[i] *= 0.25;
|
|
640
|
+
} else {
|
|
641
|
+
a.vx[i] = dx / d * b.dartSpeed;
|
|
642
|
+
a.vz[i] = dz / d * b.dartSpeed;
|
|
643
|
+
}
|
|
644
|
+
a.vy[i] += (hoverY - a.py[i]) * 4 * dt;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (!a.far[i]) {
|
|
649
|
+
// Hover jitter: tight, twitchy sinusoids — the dragonfly read.
|
|
650
|
+
const w = a.wf;
|
|
651
|
+
a.vx[i] += Math.sin(sim.time * (3 + w[i * 4] * 4) + w[i * 4 + 1]) * 3.2 * dt;
|
|
652
|
+
a.vz[i] += Math.cos(sim.time * (3 + w[i * 4 + 2] * 4) + w[i * 4 + 3]) * 3.2 * dt;
|
|
653
|
+
const hxD = a.homeX[i] - a.px[i];
|
|
654
|
+
const hzD = a.homeZ[i] - a.pz[i];
|
|
655
|
+
const dh = Math.hypot(hxD, hzD);
|
|
656
|
+
if (dh > b.hoverRadius * 0.6) {
|
|
657
|
+
a.vx[i] += hxD / Math.max(dh, 1e-3) * 3 * dt;
|
|
658
|
+
a.vz[i] += hzD / Math.max(dh, 1e-3) * 3 * dt;
|
|
659
|
+
}
|
|
660
|
+
if (follow) {
|
|
661
|
+
const fx = a.px[i] - follow.x;
|
|
662
|
+
const fy = a.py[i] - (follow.y ?? 0);
|
|
663
|
+
const fz = a.pz[i] - follow.z;
|
|
664
|
+
const d2 = fx * fx + fy * fy + fz * fz;
|
|
665
|
+
if (d2 < 6.25) { // dragonflies tolerate ~2.5 m before darting
|
|
666
|
+
const d = Math.max(Math.sqrt(d2), 1e-3);
|
|
667
|
+
a.vx[i] += (fx / d) * 14 * dt;
|
|
668
|
+
a.vz[i] += (fz / d) * 14 * dt;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (dragonPool.length > 1 && nextRand(a.rng, i) < b.dartChance * dt * 0.125) {
|
|
672
|
+
const pick = dragonPool[Math.floor(nextRand(a.rng, i) * dragonPool.length) % dragonPool.length];
|
|
673
|
+
a.homeX[i] = pick.x;
|
|
674
|
+
a.homeZ[i] = pick.z;
|
|
675
|
+
a.state[i] = FAUNA_STATE.DART;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
a.vy[i] += (hoverY - a.py[i]) * 5 * dt;
|
|
679
|
+
a.vy[i] *= 1 - Math.min(1, 3 * dt);
|
|
680
|
+
boundsForce(a, i, dt);
|
|
681
|
+
clampSpeed(a, i, 0, b.dartSpeed);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function fishTick(state, i, dt, follow, stamp) {
|
|
685
|
+
const a = state.arrays;
|
|
686
|
+
const f = cfg.fish;
|
|
687
|
+
const bed = ground(a.px[i], a.pz[i]);
|
|
688
|
+
a.groundY[i] = bed;
|
|
689
|
+
const lo = bed + f.bedMargin;
|
|
690
|
+
const hi = waterLevel - f.surfaceMargin;
|
|
691
|
+
|
|
692
|
+
// Shallow-water escape dominates everything: swim down the bed gradient
|
|
693
|
+
// before the depth clamps can ever pinch (see the invariant analysis in
|
|
694
|
+
// scripts/verify-fauna.mjs).
|
|
695
|
+
const room = hi - lo;
|
|
696
|
+
if (room < 0.5) {
|
|
697
|
+
const e = 1.5;
|
|
698
|
+
const gx = (ground(a.px[i] + e, a.pz[i]) - ground(a.px[i] - e, a.pz[i])) / (2 * e);
|
|
699
|
+
const gz = (ground(a.px[i], a.pz[i] + e) - ground(a.px[i], a.pz[i] - e)) / (2 * e);
|
|
700
|
+
const g = Math.max(Math.hypot(gx, gz), 1e-4);
|
|
701
|
+
const urgency = room < 0.2 ? 3 : 1;
|
|
702
|
+
a.vx[i] += (-gx / g) * f.cruiseSpeed * 4 * urgency * dt;
|
|
703
|
+
a.vz[i] += (-gz / g) * f.cruiseSpeed * 4 * urgency * dt;
|
|
704
|
+
a.vy[i] -= 0.8 * dt;
|
|
705
|
+
clampSpeed(a, i, f.cruiseSpeed * 0.6, f.maxSpeed);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (a.far[i]) {
|
|
710
|
+
clampSpeed(a, i, f.cruiseSpeed * 0.9, f.cruiseSpeed * 1.1);
|
|
711
|
+
a.vy[i] *= 0.5;
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
rebuildGrid(state, stamp);
|
|
716
|
+
accumulateFlock(state, i, f.neighborRadius, f.separationRadius,
|
|
717
|
+
f.cohesion * 1.2, f.alignment * 0.7, f.separation * 0.35);
|
|
718
|
+
a.vx[i] += flock.ax * dt;
|
|
719
|
+
a.vy[i] += flock.ay * dt;
|
|
720
|
+
a.vz[i] += flock.az * dt;
|
|
721
|
+
|
|
722
|
+
const w = a.wf;
|
|
723
|
+
a.vx[i] += Math.sin(sim.time * w[i * 4] * 0.7 + w[i * 4 + 1]) * f.wander * 0.8 * dt;
|
|
724
|
+
a.vz[i] += Math.cos(sim.time * w[i * 4 + 2] * 0.7 + w[i * 4 + 3]) * f.wander * 0.8 * dt;
|
|
725
|
+
// Preferred depth: a lane in the top of the water column — koi live where
|
|
726
|
+
// the refraction pass can show them, not on the bed.
|
|
727
|
+
const laneY = Math.max(hi - (0.2 + a.hoverBias[i] * 0.7), lo + Math.min(room, 2) * 0.15);
|
|
728
|
+
a.vy[i] += (laneY - a.py[i]) * 0.8 * dt;
|
|
729
|
+
a.vy[i] *= 1 - Math.min(1, 1.2 * dt);
|
|
730
|
+
|
|
731
|
+
if (follow) {
|
|
732
|
+
const fx = a.px[i] - follow.x;
|
|
733
|
+
const fy = a.py[i] - (follow.y ?? 0);
|
|
734
|
+
const fz = a.pz[i] - follow.z;
|
|
735
|
+
// 3D: a swimmer or a bridge-walker close above scares fish; an aerial
|
|
736
|
+
// camera does not empty the shot.
|
|
737
|
+
const d2 = fx * fx + fy * fy + fz * fz;
|
|
738
|
+
if (d2 < f.fleeRadius * f.fleeRadius) {
|
|
739
|
+
const d = Math.max(Math.sqrt(d2), 1e-3);
|
|
740
|
+
a.vx[i] += (fx / d) * 10 * dt;
|
|
741
|
+
a.vz[i] += (fz / d) * 10 * dt;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
boundsForce(a, i, dt);
|
|
745
|
+
clampSpeed(a, i, f.cruiseSpeed * 0.45, f.maxSpeed);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const TICKS = {
|
|
749
|
+
birds: birdTick,
|
|
750
|
+
butterflies: butterflyTick,
|
|
751
|
+
dragonflies: dragonflyTick,
|
|
752
|
+
fish: fishTick,
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// --- integration (every agent, every frame) --------------------------------
|
|
756
|
+
|
|
757
|
+
function integrate(dt, follow) {
|
|
758
|
+
const far2 = cfg.shared.farDistance * cfg.shared.farDistance;
|
|
759
|
+
for (const name of FAUNA_SPECIES) {
|
|
760
|
+
const state = speciesStates[name];
|
|
761
|
+
const a = state.arrays;
|
|
762
|
+
const isFish = name === 'fish';
|
|
763
|
+
const isBird = name === 'birds';
|
|
764
|
+
for (let i = 0; i < state.count; i += 1) {
|
|
765
|
+
// Far flag (cheap: one distance per agent per frame keeps the
|
|
766
|
+
// degrade responsive even between that agent's steering ticks).
|
|
767
|
+
if (follow) {
|
|
768
|
+
const dx = a.px[i] - follow.x;
|
|
769
|
+
const dz = a.pz[i] - follow.z;
|
|
770
|
+
a.far[i] = dx * dx + dz * dz > far2 ? 1 : 0;
|
|
771
|
+
} else {
|
|
772
|
+
a.far[i] = 0;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const k = Math.min(1, 5 * dt);
|
|
776
|
+
a.amp[i] += (a.ampTarget[i] - a.amp[i]) * k;
|
|
777
|
+
a.fold[i] += (a.foldTarget[i] - a.fold[i]) * Math.min(1, 3.5 * dt);
|
|
778
|
+
|
|
779
|
+
if (a.state[i] === FAUNA_STATE.PERCHED) continue;
|
|
780
|
+
|
|
781
|
+
if (a.far[i] && a.state[i] === FAUNA_STATE.FLY) {
|
|
782
|
+
// Scripted loop: constant-rate yaw — perfect circles, no steering.
|
|
783
|
+
const wd = a.turn[i] * dt;
|
|
784
|
+
const nvx = a.vx[i] - a.vz[i] * wd;
|
|
785
|
+
a.vz[i] += a.vx[i] * wd;
|
|
786
|
+
a.vx[i] = nvx;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
a.px[i] += a.vx[i] * dt;
|
|
790
|
+
a.py[i] += a.vy[i] * dt;
|
|
791
|
+
a.pz[i] += a.vz[i] * dt;
|
|
792
|
+
|
|
793
|
+
// Hard bounds (steering springs should make these no-ops).
|
|
794
|
+
if (a.px[i] < box.minX) { a.px[i] = box.minX; a.vx[i] *= -0.5; }
|
|
795
|
+
else if (a.px[i] > box.maxX) { a.px[i] = box.maxX; a.vx[i] *= -0.5; }
|
|
796
|
+
if (a.pz[i] < box.minZ) { a.pz[i] = box.minZ; a.vz[i] *= -0.5; }
|
|
797
|
+
else if (a.pz[i] > box.maxZ) { a.pz[i] = box.maxZ; a.vz[i] *= -0.5; }
|
|
798
|
+
|
|
799
|
+
if (isFish) {
|
|
800
|
+
const lo = a.groundY[i] + cfg.fish.bedMargin;
|
|
801
|
+
const hi = waterLevel - cfg.fish.surfaceMargin;
|
|
802
|
+
if (a.py[i] > hi) { a.py[i] = hi; if (a.vy[i] > 0) a.vy[i] = 0; }
|
|
803
|
+
if (a.py[i] < lo) { a.py[i] = Math.min(lo, hi); if (a.vy[i] < 0) a.vy[i] = 0; }
|
|
804
|
+
} else if (isBird) {
|
|
805
|
+
const floor = a.groundY[i] + (a.state[i] === FAUNA_STATE.APPROACH ? 0.1 : 1.0);
|
|
806
|
+
if (a.py[i] < floor) { a.py[i] = floor; if (a.vy[i] < 0) a.vy[i] = 0; }
|
|
807
|
+
} else {
|
|
808
|
+
const floor = name === 'dragonflies'
|
|
809
|
+
? Math.max(waterLevel + 0.15, a.groundY[i] + 0.2)
|
|
810
|
+
: a.groundY[i] + cfg.butterflies.hoverMin * 0.5;
|
|
811
|
+
if (a.py[i] < floor) { a.py[i] = floor; if (a.vy[i] < 0) a.vy[i] = 0; }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Heading smoothing + banking from the applied turn.
|
|
815
|
+
const speed2 = a.vx[i] * a.vx[i] + a.vz[i] * a.vz[i];
|
|
816
|
+
if (speed2 > 1e-6) {
|
|
817
|
+
const target = Math.atan2(a.vx[i], a.vz[i]);
|
|
818
|
+
let delta = target - a.heading[i];
|
|
819
|
+
delta -= Math.round(delta / (Math.PI * 2)) * Math.PI * 2;
|
|
820
|
+
const maxTurn = 7 * dt;
|
|
821
|
+
const applied = Math.min(Math.max(delta, -maxTurn), maxTurn);
|
|
822
|
+
a.heading[i] += applied;
|
|
823
|
+
const targetBank = Math.min(Math.max(applied / Math.max(dt, 1e-4) * -0.12, -0.6), 0.6);
|
|
824
|
+
a.bank[i] += (targetBank - a.bank[i]) * Math.min(1, 6 * dt);
|
|
825
|
+
} else {
|
|
826
|
+
a.bank[i] *= 1 - Math.min(1, 4 * dt);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// --- public update ----------------------------------------------------------
|
|
833
|
+
|
|
834
|
+
let cursor = 0;
|
|
835
|
+
sim.update = (delta, follow = null) => {
|
|
836
|
+
const dt = Math.min(Math.max(Number(delta) || 0.016, 0.0005), 0.1);
|
|
837
|
+
sim.time += dt;
|
|
838
|
+
stats.updates += 1;
|
|
839
|
+
|
|
840
|
+
integrate(dt, follow);
|
|
841
|
+
|
|
842
|
+
// Staggered steering slice.
|
|
843
|
+
let steered = 0;
|
|
844
|
+
const budget = Math.min(stats.tickBudget, walk.length);
|
|
845
|
+
for (let n = 0; n < budget; n += 1) {
|
|
846
|
+
const entry = walk[cursor];
|
|
847
|
+
cursor = (cursor + 1) % Math.max(walk.length, 1);
|
|
848
|
+
const state = entry.s;
|
|
849
|
+
const i = entry.i;
|
|
850
|
+
const a = state.arrays;
|
|
851
|
+
const dtTick = Math.min(Math.max(sim.time - a.lastTick[i], 1 / 240), 0.6);
|
|
852
|
+
a.lastTick[i] = sim.time;
|
|
853
|
+
TICKS[state.name](state, i, dtTick, follow, stats.updates);
|
|
854
|
+
steered += 1;
|
|
855
|
+
}
|
|
856
|
+
stats.steeredLastUpdate = steered;
|
|
857
|
+
return sim;
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
return sim;
|
|
861
|
+
}
|