@idle-screens/saver-metaquarium 0.4.0 → 0.5.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.
@@ -107,29 +107,87 @@ function fishVariation(index, variance) {
107
107
  }
108
108
  var FISH_LENGTH = 18;
109
109
  var FORMATION_HALF_WIDTH = 62;
110
- function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH) {
110
+ var FORMATION_SHAPES = ["phalanx", "line", "ring", "wedge", "ball"];
111
+ function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH, shape = "phalanx") {
112
+ const j = 0.35 + 0.65 * Math.max(0, Math.min(1, variance));
113
+ if (shape === "line") {
114
+ const FILE = 6;
115
+ const files = Math.ceil(Math.max(1, count) / FILE);
116
+ const col2 = Math.floor(index / FILE);
117
+ const within = index % FILE;
118
+ const fileLen = Math.min(FILE, count - col2 * FILE);
119
+ return {
120
+ side: (col2 - (files - 1) / 2) * FISH_LENGTH * 1.6 + (fishHash(index, 4) - 0.5) * FISH_LENGTH * 0.4 * j,
121
+ up: (fishHash(index, 5) - 0.5) * FISH_LENGTH * 0.5 * j,
122
+ back: (within - (fileLen - 1) / 2) * FISH_LENGTH * 1.7
123
+ };
124
+ }
125
+ if (shape === "ring") {
126
+ const r = Math.max(FISH_LENGTH * 1.6, count * FISH_LENGTH * 1.35 / (Math.PI * 2));
127
+ const a = index / Math.max(1, count) * Math.PI * 2;
128
+ return {
129
+ side: Math.cos(a) * r,
130
+ up: (fishHash(index, 5) - 0.5) * FISH_LENGTH * 0.6 * j,
131
+ back: Math.sin(a) * r
132
+ };
133
+ }
134
+ if (shape === "wedge") {
135
+ const VSIZE = 9;
136
+ const layers = Math.ceil(Math.max(1, count) / VSIZE);
137
+ const layer = Math.floor(index / VSIZE);
138
+ const within = index % VSIZE;
139
+ const rank = within + 1 >> 1;
140
+ const wing = within === 0 ? 0 : within % 2 === 1 ? -1 : 1;
141
+ return {
142
+ side: wing * rank * FISH_LENGTH * 1.25 + (fishHash(index, 4) - 0.5) * FISH_LENGTH * 0.3 * j,
143
+ // 1.5 body lengths per layer with a small jitter: adjacent layers seat
144
+ // the same rank directly above each other, so pitch minus worst-case
145
+ // jitter closure must clear one body length.
146
+ up: (layer - (layers - 1) / 2) * FISH_LENGTH * 1.5 + (fishHash(index, 5) - 0.5) * FISH_LENGTH * 0.2 * j,
147
+ back: (rank - 2) * FISH_LENGTH * 1.35
148
+ };
149
+ }
150
+ if (shape === "ball") {
151
+ const r = FISH_LENGTH * (1.1 + 0.34 * Math.sqrt(count));
152
+ const g = (1 + Math.sqrt(5)) / 2;
153
+ const u = count <= 1 ? 0 : index / (count - 1);
154
+ const incl = Math.acos(1 - 2 * u);
155
+ const az = 2 * Math.PI * index / (g * g);
156
+ return {
157
+ side: r * Math.sin(incl) * Math.cos(az),
158
+ // Vertically squashed, hard-capped at 28: the water column is 57 units
159
+ // and the carrier's y-clamp needs the shell's top inside it whatever
160
+ // the cast size makes of the radius.
161
+ up: Math.cos(incl) * Math.min(r * 0.62, 28),
162
+ back: r * Math.sin(incl) * Math.sin(az)
163
+ };
164
+ }
111
165
  const cols = Math.max(1, Math.ceil(Math.sqrt(Math.max(1, count))));
112
166
  const col = index % cols;
113
167
  const row = Math.floor(index / cols);
114
- const j = 0.35 + 0.65 * Math.max(0, Math.min(1, variance));
115
168
  const span = Math.max(1, cols - 1);
116
169
  const pitch = Math.min(FISH_LENGTH * 1.9, halfWidth * 2 / span);
117
170
  const jit = pitch * 0.35;
171
+ const rows = Math.ceil(Math.max(1, count) / cols);
118
172
  return {
119
173
  side: (col - (cols - 1) / 2) * pitch + (fishHash(index, 4) - 0.5) * jit * j,
120
174
  up: (fishHash(index, 5) - 0.5) * FISH_LENGTH * j,
121
- back: row * FISH_LENGTH * 1.6 + fishHash(index, 6) * FISH_LENGTH * 0.4 * j
175
+ // Centred fore-aft like every other shape — a 24-fish lattice trailing
176
+ // its whole depth behind the carrier pushed the extent past what the
177
+ // centre-clamp could absorb.
178
+ back: (row - (rows - 1) / 2) * FISH_LENGTH * 1.6 + fishHash(index, 6) * FISH_LENGTH * 0.4 * j
122
179
  };
123
180
  }
124
- function formationExtent(count, variance) {
125
- let side = 0, up = 0, back = 0;
181
+ function formationExtent(count, variance, shape = "phalanx") {
182
+ let side = 0, up = 0, back = 0, reach = 0;
126
183
  for (let i = 0; i < count; i += 1) {
127
- const s = formationSlot(i, count, variance);
184
+ const s = formationSlot(i, count, variance, void 0, shape);
128
185
  side = Math.max(side, Math.abs(s.side));
129
186
  up = Math.max(up, Math.abs(s.up));
130
187
  back = Math.max(back, Math.abs(s.back));
188
+ reach = Math.max(reach, Math.hypot(s.side, s.back));
131
189
  }
132
- return { side, up, back };
190
+ return { side, up, back, reach };
133
191
  }
134
192
  function bandRange(band) {
135
193
  switch (band) {
@@ -147,6 +205,225 @@ function bandRange(band) {
147
205
  }
148
206
  }
149
207
 
208
+ // src/plan.ts
209
+ var ARC_SAMPLES = 1024;
210
+ function catmullRom(p0, p1, p2, p3, u) {
211
+ const u2 = u * u;
212
+ const u3 = u2 * u;
213
+ return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
214
+ }
215
+ function splineAt(points, g) {
216
+ const n = points.length;
217
+ const i = Math.floor(g) % n;
218
+ const u = g - Math.floor(g);
219
+ const p0 = points[(i - 1 + n) % n];
220
+ const p1 = points[i];
221
+ const p2 = points[(i + 1) % n];
222
+ const p3 = points[(i + 2) % n];
223
+ return [
224
+ catmullRom(p0[0], p1[0], p2[0], p3[0], u),
225
+ catmullRom(p0[1], p1[1], p2[1], p3[1], u),
226
+ catmullRom(p0[2], p1[2], p2[2], p3[2], u)
227
+ ];
228
+ }
229
+ var PATH_SHAPES = ["wander", "orbit", "eight", "helix", "canyon"];
230
+ function shapeWaypoints(shape, rng, bounds) {
231
+ const points = [];
232
+ const ySpan = bounds.yMax - bounds.yMin;
233
+ if (shape === "orbit") {
234
+ const n2 = 14 + rng.int(0, 4);
235
+ const r0 = bounds.radius * rng.range(0.45, 0.8);
236
+ const y0 = bounds.yMin + ySpan * rng.range(0.25, 0.75);
237
+ const dir = rng.next() < 0.5 ? 1 : -1;
238
+ for (let i = 0; i < n2; i++) {
239
+ const a = dir * (i / n2) * Math.PI * 2;
240
+ const r = r0 * (1 + rng.range(-0.08, 0.08));
241
+ points.push([Math.cos(a) * r, y0 + ySpan * rng.range(-0.08, 0.08), Math.sin(a) * r]);
242
+ }
243
+ return points;
244
+ }
245
+ if (shape === "eight") {
246
+ const n2 = 20 + rng.int(0, 4);
247
+ const rx = bounds.radius * rng.range(0.6, 0.85);
248
+ const rz = bounds.radius * rng.range(0.35, 0.55);
249
+ const y0 = bounds.yMin + ySpan * rng.range(0.3, 0.7);
250
+ const phase = rng.range(0, Math.PI * 2);
251
+ for (let i = 0; i < n2; i++) {
252
+ const u = i / n2 * Math.PI * 2;
253
+ points.push([
254
+ Math.sin(u + phase) * rx,
255
+ y0 + Math.sin(u * 2 + phase) * ySpan * 0.12,
256
+ Math.sin(u * 2 + phase * 2) * rz
257
+ ]);
258
+ }
259
+ return points;
260
+ }
261
+ if (shape === "helix") {
262
+ const n2 = 24;
263
+ const r0 = bounds.radius * rng.range(0.35, 0.6);
264
+ const phase = rng.range(0, Math.PI * 2);
265
+ for (let i = 0; i < n2; i++) {
266
+ const u = i / n2;
267
+ const a = phase + u * Math.PI * 4;
268
+ const climb = 1 - Math.abs(1 - 2 * u);
269
+ points.push([
270
+ Math.cos(a) * r0,
271
+ bounds.yMin + ySpan * (0.1 + climb * 0.8),
272
+ Math.sin(a) * r0
273
+ ]);
274
+ }
275
+ return points;
276
+ }
277
+ if (shape === "canyon") {
278
+ const n2 = 16;
279
+ const axis = rng.range(0, Math.PI);
280
+ const ax = Math.cos(axis), az = Math.sin(axis);
281
+ const y0 = bounds.yMin + ySpan * rng.range(0.08, 0.28);
282
+ const len = bounds.radius * 0.85;
283
+ const width = bounds.radius * rng.range(0.12, 0.3);
284
+ for (let i = 0; i < n2; i++) {
285
+ const u = i / n2 * Math.PI * 2;
286
+ const along = Math.sin(u) * len;
287
+ const across = Math.cos(u) * width;
288
+ points.push([
289
+ ax * along - az * across,
290
+ y0 + ySpan * rng.range(0, 0.08),
291
+ az * along + ax * across
292
+ ]);
293
+ }
294
+ return points;
295
+ }
296
+ const n = 16 + rng.int(0, 4);
297
+ for (let i = 0; i < n; i++) {
298
+ const baseAngle = i / n * Math.PI * 2;
299
+ const angle = baseAngle + rng.range(-0.25, 0.25);
300
+ const inner = i % 2 === 0;
301
+ const radius = inner ? bounds.radius * rng.range(0.15, 0.45) : bounds.radius * rng.range(0.55, 0.85);
302
+ const y = bounds.yMin + ySpan * rng.range(0.2, 0.8);
303
+ points.push([Math.cos(angle) * radius, y, Math.sin(angle) * radius]);
304
+ }
305
+ return points;
306
+ }
307
+ function compileSwimPlan(rng, bounds, shape = "wander") {
308
+ const points = shapeWaypoints(shape, rng, bounds);
309
+ const n = points.length;
310
+ const arc = new Float32Array(ARC_SAMPLES + 1);
311
+ let prev = splineAt(points, 0);
312
+ let total = 0;
313
+ for (let s = 1; s <= ARC_SAMPLES; s++) {
314
+ const g = s / ARC_SAMPLES * n;
315
+ const p = splineAt(points, g);
316
+ total += Math.hypot(p[0] - prev[0], p[1] - prev[1], p[2] - prev[2]);
317
+ arc[s] = total;
318
+ prev = p;
319
+ }
320
+ const cruise = 8 + rng.range(0, 4);
321
+ const wobble = [
322
+ { amp: cruise * 0.18, w: 0.11 + rng.range(0, 0.05), phase: rng.range(0, Math.PI * 2) },
323
+ { amp: cruise * 0.1, w: 0.043 + rng.range(0, 0.02), phase: rng.range(0, Math.PI * 2) }
324
+ ];
325
+ return { points, arc, totalLength: total, cruise, wobble };
326
+ }
327
+ function distanceAt(plan, tSec, speed) {
328
+ let d = plan.cruise * tSec;
329
+ for (const h of plan.wobble) {
330
+ d += h.amp / h.w * (Math.cos(h.phase) - Math.cos(h.w * tSec + h.phase));
331
+ }
332
+ return d * speed;
333
+ }
334
+ function paramForDistance(plan, dist) {
335
+ const n = plan.points.length;
336
+ const d = (dist % plan.totalLength + plan.totalLength) % plan.totalLength;
337
+ const arc = plan.arc;
338
+ let lo = 0;
339
+ let hi = ARC_SAMPLES;
340
+ while (lo + 1 < hi) {
341
+ const mid = lo + hi >> 1;
342
+ if (arc[mid] <= d) lo = mid;
343
+ else hi = mid;
344
+ }
345
+ const seg = arc[hi] - arc[lo] || 1;
346
+ const frac = (d - arc[lo]) / seg;
347
+ return (lo + frac) / ARC_SAMPLES * n;
348
+ }
349
+ function swimPoseAtDistance(plan, dist) {
350
+ const g = paramForDistance(plan, dist);
351
+ const [x, y, z] = splineAt(plan.points, g);
352
+ const step = plan.points.length / ARC_SAMPLES * 8;
353
+ const ahead = splineAt(plan.points, g + step);
354
+ const behind = splineAt(plan.points, g - step + plan.points.length);
355
+ let fx = ahead[0] - behind[0];
356
+ let fy = ahead[1] - behind[1];
357
+ let fz = ahead[2] - behind[2];
358
+ const m = Math.hypot(fx, fy, fz) || 1;
359
+ fx /= m;
360
+ fy /= m;
361
+ fz /= m;
362
+ const h0 = Math.atan2(z - behind[2], x - behind[0]);
363
+ const h1 = Math.atan2(ahead[2] - z, ahead[0] - x);
364
+ let turn = h1 - h0;
365
+ if (turn > Math.PI) turn -= Math.PI * 2;
366
+ if (turn < -Math.PI) turn += Math.PI * 2;
367
+ const roll = Math.max(-0.35, Math.min(0.35, turn * 3.5));
368
+ return { x, y, z, fx, fy, fz, roll, dist };
369
+ }
370
+
371
+ // src/maneuver.ts
372
+ var MANEUVERS = ["none", "dart", "startle", "graze", "curious", "zoomies"];
373
+ var SPECS = {
374
+ dart: { name: "dart", dur: 2.4, interval: 14, advance: 4.5, kick: 0.5, lift: 0, flurry: 1.1, bursts: 1 },
375
+ startle: { name: "startle", dur: 1.8, interval: 18, advance: 1.2, kick: 2.8, lift: 0.4, flurry: 1.3, bursts: 1 },
376
+ graze: { name: "graze", dur: 5.5, interval: 16, advance: -3.2, kick: 0.6, lift: -0.7, flurry: 0.3, bursts: 1 },
377
+ curious: { name: "curious", dur: 4, interval: 15, advance: 0.8, kick: 2, lift: 0.2, flurry: 0.5, bursts: 1 },
378
+ zoomies: { name: "zoomies", dur: 3.2, interval: 20, advance: 7.5, kick: 0.9, lift: 0.15, flurry: 1.5, bursts: 3 }
379
+ };
380
+ function maneuverSpecOf(name) {
381
+ return Object.hasOwn(SPECS, name) ? SPECS[name] : null;
382
+ }
383
+ var smooth = (u) => u <= 0 ? 0 : u >= 1 ? 1 : u * u * (3 - 2 * u);
384
+ var bump = (u) => {
385
+ if (u <= 0 || u >= 1) return 0;
386
+ const b = 4 * u * (1 - u);
387
+ return b * b;
388
+ };
389
+ function surges(u, bursts) {
390
+ if (u <= 0) return 0;
391
+ if (u >= 1) return 1;
392
+ const x = u * bursts;
393
+ return (Math.floor(x) + smooth(x - Math.floor(x))) / bursts;
394
+ }
395
+ var IDLE = { along: 0, alongBump: 0, side: 0, up: 0, flurry: 0 };
396
+ function maneuverAt(spec, index, tSec, rate, intensity) {
397
+ if (!spec || rate <= 0 || intensity <= 0 || tSec < 0) return IDLE;
398
+ const interval = spec.interval * (3 - 2 * Math.min(1, rate));
399
+ const k0 = Math.floor(tSec / interval);
400
+ let along = 0;
401
+ let side = 0;
402
+ let up = 0;
403
+ let flurry = 0;
404
+ let alongBump = 0;
405
+ const done = Math.max(0, k0 - 1);
406
+ along += done * spec.advance * intensity;
407
+ for (const k of [k0 - 1, k0, k0 + 1]) {
408
+ if (k < 0) continue;
409
+ const start = (k + fishHash(index, 11 + k)) * interval;
410
+ const u = (tSec - start) / spec.dur;
411
+ if (u <= 0) continue;
412
+ if (u >= 1) {
413
+ if (k >= done) along += spec.advance * intensity;
414
+ continue;
415
+ }
416
+ const sign = fishHash(index, 23 + k) < 0.5 ? -1 : 1;
417
+ const env = bump(u) * intensity;
418
+ along += spec.advance * surges(u, spec.bursts) * intensity;
419
+ alongBump += spec.advance * bump(u) * intensity;
420
+ side += sign * spec.kick * env;
421
+ up += spec.lift * spec.kick * env;
422
+ flurry += spec.flurry * env;
423
+ }
424
+ return { along, alongBump, side, up, flurry };
425
+ }
426
+
150
427
  // src/asset-cids.ts
151
428
  var ASSET_CIDS = [
152
429
  "QmZCzKVLJ1MYRzaZEFyuMCVmrPR3urQYMFR9RWerJYpCEr",
@@ -702,14 +979,19 @@ var BREEDS = [
702
979
  metadataCid: "QmZTntNpvwxe8Mii7s1XTpTrbaci4ZgYUXs9sEbsBodtaF",
703
980
  model: "sea-turtle"
704
981
  },
705
- { breed: "blowfish", minted: false, count: 0, model: "blow-fish" },
706
- { breed: "hackerfish", minted: false, count: 0, model: "hacker-fish" },
707
- { breed: "glowfish", minted: false, count: 0, model: "glow-fish" },
708
- { breed: "babyfish", minted: false, count: 0, model: "baby-fish" },
709
- { breed: "shark", minted: false, count: 0, model: "shark" },
710
- { breed: "crab", minted: false, count: 0, model: "crab" },
982
+ // Unminted: `model` names the CURRENT basename in the original aquarium's
983
+ // NPC set (versioned there; these are the ones its entity classes load,
984
+ // except glow-fish, where the app loads the 503 KB glow-fish5 while an
985
+ // identical-material 30 KB Draco glow-fish4 sits beside it — we take the
986
+ // small one). All are clip-less and unrigged, so they swim on `bodyWiggle`.
987
+ { breed: "blowfish", minted: false, count: 0, model: "blow-fish4" },
988
+ { breed: "hackerfish", minted: false, count: 0, model: "hacker-fish3" },
989
+ { breed: "glowfish", minted: false, count: 0, model: "glow-fish4" },
990
+ { breed: "babyfish", minted: false, count: 0, model: "baby-fish2" },
991
+ { breed: "shark", minted: false, count: 0, model: "shark3" },
992
+ { breed: "crab", minted: false, count: 0, model: "crab3" },
711
993
  { breed: "jellyfish", minted: false, count: 0, model: "jellyfish" },
712
- { breed: "dori", minted: false, count: 0, model: "dori" }
994
+ { breed: "dori", minted: false, count: 0, model: "dori3" }
713
995
  ];
714
996
  function breedOf(id) {
715
997
  if (!isMintedId(id)) return null;
@@ -800,6 +1082,16 @@ var FISH_CATALOG = [
800
1082
  { id: 497, name: "Fish 497", breed: "seaturtle", ipfs3d: "ipfs://QmTBNvoUiwPw9HSUmy1qKCWPBKkBRAensgooYVqMmsviaE/fish_497_of_the_metaquarium_3d.glb", localGlb: "" }
801
1083
  ];
802
1084
  var DEFAULT_FISH = FISH_CATALOG[0];
1085
+ var NPC_CATALOG = [
1086
+ { id: 601, name: "Blowfish", breed: "blowfish", ipfs3d: "", localGlb: "/assets/metaquarium/npc-blowfish.glb" },
1087
+ { id: 602, name: "Hackerfish", breed: "hackerfish", ipfs3d: "", localGlb: "/assets/metaquarium/npc-hackerfish.glb" },
1088
+ { id: 603, name: "Glowfish", breed: "glowfish", ipfs3d: "", localGlb: "/assets/metaquarium/npc-glowfish.glb" },
1089
+ { id: 604, name: "Babyfish", breed: "babyfish", ipfs3d: "", localGlb: "/assets/metaquarium/npc-babyfish.glb" },
1090
+ { id: 605, name: "Shark", breed: "shark", ipfs3d: "", localGlb: "/assets/metaquarium/shark3.glb" },
1091
+ { id: 606, name: "Crab", breed: "crab", ipfs3d: "", localGlb: "/assets/metaquarium/npc-crab.glb" },
1092
+ { id: 607, name: "Jellyfish", breed: "jellyfish", ipfs3d: "", localGlb: "/assets/metaquarium/npc-jellyfish.glb" },
1093
+ { id: 608, name: "Dori", breed: "dori", ipfs3d: "", localGlb: "/assets/metaquarium/npc-dori.glb" }
1094
+ ];
803
1095
  function parseFishMix(mix, catalog = FISH_CATALOG) {
804
1096
  const entries = [];
805
1097
  const problems = [];
@@ -815,26 +1107,37 @@ function parseFishMix(mix, catalog = FISH_CATALOG) {
815
1107
  let fish = /^\d+$/.test(key) ? catalog.find((f) => f.id === Number(key)) : catalog.find((f) => f.breed.toLowerCase() === key);
816
1108
  if (!fish && catalog === FISH_CATALOG && /^\d+$/.test(key)) {
817
1109
  const n = Number(key);
818
- const url = fishAsset(n, "3d");
1110
+ const url2 = fishAsset(n, "3d");
819
1111
  const breed = breedOf(n);
820
- if (url && breed) fish = { id: n, name: `Fish ${n}`, breed, ipfs3d: url, localGlb: "" };
1112
+ if (url2 && breed) fish = { id: n, name: `Fish ${n}`, breed, ipfs3d: url2, localGlb: "" };
821
1113
  }
822
1114
  if (!fish) {
1115
+ const breeds = [...new Set(catalog.map((f) => f.breed))].join(", ");
823
1116
  problems.push(
824
- /^\d+$/.test(key) ? catalog === FISH_CATALOG ? `"${key}": not a minted token id (1-${TOTAL_SUPPLY})` : `"${key}": not a catalog id` : `"${key}": not a breed (${BREEDS.filter((b) => b.minted).map((b) => b.breed).join(", ")})`
1117
+ /^\d+$/.test(key) ? catalog === FISH_CATALOG ? `"${key}": not a minted token id (1-${TOTAL_SUPPLY})` : `"${key}": not a catalog id` : `"${key}": not a breed (${breeds})`
825
1118
  );
826
1119
  continue;
827
1120
  }
1121
+ const url = fish.ipfs3d;
1122
+ if (!url) {
1123
+ problems.push(`"${key}": model not hosted here (catalog carries no URL for it)`);
1124
+ continue;
1125
+ }
828
1126
  let count = 1;
829
1127
  if (countRaw !== void 0) {
830
1128
  const n = Number(countRaw.trim());
831
- if (!Number.isInteger(n) || n < 1 || n > 24) {
1129
+ if (!Number.isInteger(n) || n < 1) {
832
1130
  problems.push(`"${token}": count must be an integer 1-24`);
833
1131
  continue;
834
1132
  }
835
- count = n;
1133
+ if (n > 24) {
1134
+ problems.push(`"${token}": count clamped to 24`);
1135
+ count = 24;
1136
+ } else {
1137
+ count = n;
1138
+ }
836
1139
  }
837
- entries.push({ id: fish.id, url: fish.ipfs3d, count });
1140
+ entries.push({ id: fish.id, url, count });
838
1141
  }
839
1142
  return { entries, problems };
840
1143
  }
@@ -916,10 +1219,30 @@ var METAQUARIUM_PARAMS = {
916
1219
  * default changes nothing. A small named set on purpose: a silhouette of
917
1220
  * movement you can name is one you can choose from. */
918
1221
  swimStyle: { type: "enum", default: "loop", options: [...SWIM_STYLE_NAMES], ease: "step" },
1222
+ /** The shape a fish's loop is drawn on. `wander` is the original roaming
1223
+ * itinerary, so the default changes nothing. `orbit` laps, `eight` crosses
1224
+ * the middle, `helix` tours the water column, `canyon` sweeps low — same
1225
+ * spline engine, different itinerary. */
1226
+ pathShape: { type: "enum", default: "wander", options: [...PATH_SHAPES], ease: "step" },
1227
+ /** How a `school` holds together: the original lattice, single file, a
1228
+ * carousel ring, the migratory V, or a bait-ball. Ignored by every
1229
+ * non-formation style; the same no-pair-inside-a-body-length law holds
1230
+ * for all of them. */
1231
+ formationShape: { type: "enum", default: "phalanx", options: [...FORMATION_SHAPES], ease: "step" },
919
1232
  /** Per-fish spread: 0 a uniform shoal, 1 every fish visibly its own animal
920
1233
  * (±40% speed, ±25% size, own phase). The uniqueness dial — one number
921
1234
  * instead of per-fish values nobody wants to author. */
922
1235
  swimVariance: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
1236
+ /** Named event layered over the swim style — the thing that turns uniform
1237
+ * cruising into behaviour a viewer recognizes. Each fish runs its own
1238
+ * seeded schedule of the chosen event; displacement-based, so frames stay
1239
+ * addressable. `none` (default) changes nothing. */
1240
+ maneuver: { type: "enum", default: "none", options: [...MANEUVERS], ease: "step" },
1241
+ /** How often events fire: 0 never, 1 the maneuver's own tempo (roughly
1242
+ * every 14-20s per fish, desynchronised). */
1243
+ maneuverRate: { type: "number", default: 0.5, min: 0, max: 1, ease: "smooth" },
1244
+ /** How hard: scales the surge, the kick, and the tail flurry together. */
1245
+ maneuverIntensity: { type: "number", default: 0.7, min: 0, max: 1, ease: "smooth" },
923
1246
  /** Procedural body yaw for models that carry NO animation clip — most of
924
1247
  * the breed library. Distance-driven like the tail beat, so it speeds up
925
1248
  * with the fish and stays frame-addressable. Clipped models ignore it:
@@ -1033,9 +1356,16 @@ export {
1033
1356
  fishHash,
1034
1357
  fishVariation,
1035
1358
  FISH_LENGTH,
1359
+ FORMATION_SHAPES,
1036
1360
  formationSlot,
1037
1361
  formationExtent,
1038
1362
  bandRange,
1363
+ PATH_SHAPES,
1364
+ compileSwimPlan,
1365
+ distanceAt,
1366
+ swimPoseAtDistance,
1367
+ maneuverSpecOf,
1368
+ maneuverAt,
1039
1369
  ASSET_CIDS,
1040
1370
  TOTAL_SUPPLY,
1041
1371
  isMintedId,
@@ -1053,6 +1383,7 @@ export {
1053
1383
  resolveIpfsUrls,
1054
1384
  FISH_CATALOG,
1055
1385
  DEFAULT_FISH,
1386
+ NPC_CATALOG,
1056
1387
  parseFishMix,
1057
1388
  expandFishMix,
1058
1389
  METAQUARIUM_PARAMS,
@@ -1063,4 +1394,4 @@ export {
1063
1394
  paramSpaceWith,
1064
1395
  coerceNum
1065
1396
  };
1066
- //# sourceMappingURL=chunk-BWIJDJPQ.js.map
1397
+ //# sourceMappingURL=chunk-23P73MYY.js.map