@idle-screens/saver-metaquarium 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,12 +58,15 @@ frame loop (rAF or renderFrame(t)):
58
58
  | swimSpeed | number | 1 | Swim time-scale multiplier |
59
59
  | fogColor | color | #030009 | Atmosphere / background |
60
60
  | fishUrl | string | ipfs://…/fish_257_….glb | GLB model URL, single-breed mode (playground can override to a local asset) |
61
- | fishMix | string | "" | Mixed population DSL: `id[:count]` comma-separated, catalog ids or breed aliases (`"257:2,100:1"`). Non-empty overrides fishUrl + fishCount; counts absolute, tier-capped |
61
+ | fishMix | string | "" | Mixed population DSL: `id[:count][@style]` comma-separated, catalog ids or breed aliases (`"257:2,100:1"`, `"457:3@hover,257:6@school"` — per-token swim style; untagged tokens follow `swimStyle`). Non-empty overrides fishUrl + fishCount; counts absolute, tier-capped |
62
62
  | fogNear | number | 60 | Fog start distance (the old hardcoded Fog near) |
63
63
  | fogFar | number | 500 | Fog full-opacity distance; tank enforces > near + 20 |
64
64
  | moteDensity | number | 0 | Plankton motes, 0–1 of the tier budget (400/250/120). 0 = off |
65
65
  | moteColor | color | #7fd6ff | Mote tint |
66
66
  | floorColor | color | #0a1d33 | Floor disc color (the old hardcoded navy) |
67
+ | swimStyle | enum | loop | `loop` (pre-style), `school`, `drift`, `hover`, `patrol`, `bottom`, `surface`; relationship styles `follow` / `pair` / `chase` bond a fish to the nearest preceding unbonded fish in the mix (and swim in its depth band); `auto` gives each untagged token its breed's default |
68
+ | lightSeek | number | 0 | Free fish drawn toward the room's light shafts, each to its own pool (needs rays) |
69
+ | formationBreathe | number | 0 | The school relaxes outward and back on a ~15 s cycle; only ever expands |
67
70
 
68
71
  ## File map
69
72
 
@@ -1,55 +1,69 @@
1
1
  // src/environments.ts
2
2
  var FLOOR_KINDS = ["flat", "dunes", "ridges", "basin"];
3
3
  var ENVIRONMENTS = [
4
- { name: "void", label: "Void", water: null, floor: "flat", rays: null },
4
+ { name: "void", label: "Void", water: null, floor: "flat", rays: null, seedSalt: 0 },
5
5
  {
6
6
  name: "abyss",
7
7
  label: "Abyss",
8
8
  water: null,
9
9
  floor: "basin",
10
- rays: { strength: 0.35, color: "#0e4a6e", y: -620 }
10
+ seedSalt: 161,
11
+ rays: { strength: 0.35, color: "#0e4a6e", y: -620 },
12
+ palette: { fog: "#02030a", floor: "#05070f", mote: "#4fd4a8" }
11
13
  },
12
14
  {
13
15
  name: "reef",
14
16
  label: "Reef",
15
17
  water: { y: 150, color: "#2ad4ff", opacity: 0.18 },
16
18
  floor: "dunes",
17
- rays: { strength: 0.5, color: "#bfefff", y: 900 }
19
+ rays: { strength: 0.5, color: "#bfefff", y: 900 },
20
+ seedSalt: 162,
21
+ palette: { fog: "#0a3d5c", floor: "#123c50", mote: "#bfe8ff" }
18
22
  },
19
23
  {
20
24
  name: "kelp",
21
25
  label: "Kelp forest",
22
26
  water: { y: 170, color: "#1e6f5c", opacity: 0.2 },
23
27
  floor: "ridges",
24
- rays: { strength: 0.4, color: "#9ef5d0", y: 820 }
28
+ rays: { strength: 0.4, color: "#9ef5d0", y: 820 },
29
+ seedSalt: 163,
30
+ palette: { fog: "#07271e", floor: "#0c2f22", mote: "#a5f2c6" }
25
31
  },
26
32
  {
27
33
  name: "ice",
28
34
  label: "Under ice",
29
35
  water: { y: 132, color: "#cfefff", opacity: 0.32 },
30
36
  floor: "flat",
31
- rays: { strength: 0.6, color: "#eaf8ff", y: 1e3 }
37
+ rays: { strength: 0.6, color: "#eaf8ff", y: 1e3 },
38
+ seedSalt: 164,
39
+ palette: { fog: "#12283c", floor: "#1b3d59", mote: "#d8f2ff" }
32
40
  },
33
41
  {
34
42
  name: "vent",
35
43
  label: "Hydrothermal vent",
36
44
  water: null,
37
45
  floor: "ridges",
38
- rays: { strength: 0.45, color: "#ff7a3c", y: -520 }
46
+ seedSalt: 165,
47
+ rays: { strength: 0.45, color: "#ff7a3c", y: -520 },
48
+ palette: { fog: "#170502", floor: "#4a1a0a", mote: "#ff8a3c" }
39
49
  },
40
50
  {
41
51
  name: "lagoon",
42
52
  label: "Lagoon",
43
53
  water: { y: 118, color: "#7ff3d0", opacity: 0.22 },
44
54
  floor: "dunes",
45
- rays: { strength: 0.55, color: "#fff3b0", y: 950 }
55
+ rays: { strength: 0.55, color: "#fff3b0", y: 950 },
56
+ seedSalt: 166,
57
+ palette: { fog: "#0d453f", floor: "#6e4457", mote: "#eafff4" }
46
58
  },
47
59
  {
48
60
  name: "universe",
49
61
  label: "Universe",
50
62
  water: null,
51
63
  floor: "ridges",
52
- rays: { strength: 0.3, color: "#c39bff", y: 1200 }
64
+ seedSalt: 167,
65
+ rays: { strength: 0.3, color: "#c39bff", y: 1200 },
66
+ palette: { fog: "#0d0618", floor: "#171030", mote: "#cfa8ff" }
53
67
  }
54
68
  ];
55
69
  var ENVIRONMENT_NAMES = ENVIRONMENTS.map((e) => e.name);
@@ -74,11 +88,50 @@ var SWIM_STYLES = [
74
88
  { name: "loop", label: "Loop", speedMul: 1, band: "free", bobAmp: 0, bobHz: 0, formation: false, travel: 1 },
75
89
  { name: "school", label: "School", speedMul: 1, band: "free", bobAmp: 1.5, bobHz: 0.5, formation: true, travel: 1 },
76
90
  { name: "drift", label: "Drift", speedMul: 0.35, band: "mid", bobAmp: 5, bobHz: 0.13, formation: false, travel: 0.6 },
77
- { name: "hover", label: "Hover", speedMul: 0.2, band: "free", bobAmp: 7, bobHz: 0.3, formation: false, travel: 0.12 },
91
+ // speedMul 0.55, not 0.2: QA measured hover at swimSpeed 0.5 pinned to a
92
+ // fixed screen x for 8+ seconds — frozen, not station-keeping. The two
93
+ // multiplied slowdowns (speedMul x travel) were the freeze.
94
+ { name: "hover", label: "Hover", speedMul: 0.55, band: "free", bobAmp: 7, bobHz: 0.3, formation: false, travel: 0.12 },
78
95
  { name: "patrol", label: "Patrol", speedMul: 0.55, band: "mid", bobAmp: 1, bobHz: 0.09, formation: false, travel: 1 },
79
96
  { name: "bottom", label: "Bottom-hugger", speedMul: 0.7, band: "floor", bobAmp: 2, bobHz: 0.4, formation: false, travel: 1 },
80
- { name: "surface", label: "Surface-skimmer", speedMul: 0.9, band: "ceiling", bobAmp: 3, bobHz: 0.55, formation: false, travel: 1 }
97
+ { name: "surface", label: "Surface-skimmer", speedMul: 0.9, band: "ceiling", bobAmp: 3, bobHz: 0.55, formation: false, travel: 1 },
98
+ // Relationships (MQ31). A bonded fish has no route of its own: it rides
99
+ // its leader's plan at a lag, so a `@follow` trio behind a turtle is a
100
+ // file, a `@pair` couple orbits a shared point, and a `@chase` closes on
101
+ // its leader and falls back. All three stay pure in t because the leader
102
+ // is — the follower samples the SAME closed form at `d - lag`.
103
+ { name: "follow", label: "Follower", speedMul: 1, band: "free", bobAmp: 1.2, bobHz: 0.5, formation: false, travel: 1, bond: "follow" },
104
+ { name: "pair", label: "Pair", speedMul: 0.8, band: "free", bobAmp: 2.5, bobHz: 0.35, formation: false, travel: 1, bond: "pair" },
105
+ { name: "chase", label: "Chaser", speedMul: 1, band: "free", bobAmp: 1, bobHz: 0.6, formation: false, travel: 1, bond: "chase" }
81
106
  ];
107
+ var AUTO_STYLE_BY_BREED = {
108
+ angelfish: "school",
109
+ betafish: "drift",
110
+ seahorse: "hover",
111
+ seaturtle: "surface",
112
+ // The unminted NPC set.
113
+ blowfish: "hover",
114
+ hackerfish: "loop",
115
+ glowfish: "drift",
116
+ babyfish: "school",
117
+ shark: "patrol",
118
+ crab: "bottom",
119
+ jellyfish: "drift",
120
+ dori: "school"
121
+ };
122
+ function autoStyleFor(breed) {
123
+ const name = breed ? AUTO_STYLE_BY_BREED[breed.toLowerCase()] : void 0;
124
+ return swimStyleOf(name ?? "loop");
125
+ }
126
+ function formationBreathe(tSec, amount) {
127
+ const a = Math.max(0, Math.min(1, amount));
128
+ if (a <= 0) return 1;
129
+ return 1 + a * 0.22 * (0.5 + 0.5 * Math.sin(tSec * 0.42));
130
+ }
131
+ function idleSway(style, tSec, phase) {
132
+ if (style.travel >= 1 || style.formation) return 0;
133
+ return (1 - style.travel) * 0.5 * Math.sin(tSec * 0.45 + phase);
134
+ }
82
135
  var SWIM_STYLE_NAMES = SWIM_STYLES.map((s) => s.name);
83
136
  function swimStyleOf(name) {
84
137
  return SWIM_STYLES.find((s) => s.name === name) ?? SWIM_STYLES[0];
@@ -105,9 +158,13 @@ function fishVariation(index, variance) {
105
158
  anchor: fishHash(index, 7)
106
159
  };
107
160
  }
161
+ function anchorFraction(style, index, variance) {
162
+ if (style.name === "loop") return 0;
163
+ return fishVariation(index, variance).anchor;
164
+ }
108
165
  var FISH_LENGTH = 18;
109
166
  var FORMATION_HALF_WIDTH = 62;
110
- var FORMATION_SHAPES = ["phalanx", "line", "ring", "wedge", "ball"];
167
+ var FORMATION_SHAPES = ["phalanx", "line", "ring", "wedge", "ball", "wheel"];
111
168
  function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH, shape = "phalanx") {
112
169
  const j = 0.35 + 0.65 * Math.max(0, Math.min(1, variance));
113
170
  if (shape === "line") {
@@ -131,6 +188,15 @@ function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH,
131
188
  back: Math.sin(a) * r
132
189
  };
133
190
  }
191
+ if (shape === "wheel") {
192
+ const r = Math.max(FISH_LENGTH * 1.6, count * FISH_LENGTH * 1.35 / (Math.PI * 2));
193
+ const a = index / Math.max(1, count) * Math.PI * 2;
194
+ return {
195
+ side: Math.cos(a) * r,
196
+ up: Math.sin(a) * Math.min(r * 0.55, 24) + (fishHash(index, 5) - 0.5) * FISH_LENGTH * 0.3 * j,
197
+ back: Math.sin(a) * r
198
+ };
199
+ }
134
200
  if (shape === "wedge") {
135
201
  const VSIZE = 9;
136
202
  const layers = Math.ceil(Math.max(1, count) / VSIZE);
@@ -138,12 +204,12 @@ function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH,
138
204
  const within = index % VSIZE;
139
205
  const rank = within + 1 >> 1;
140
206
  const wing = within === 0 ? 0 : within % 2 === 1 ? -1 : 1;
207
+ const droop = (rank - 2) * FISH_LENGTH * (layers === 1 ? 0.24 : 0.12);
208
+ const pitch2 = layers === 1 ? 1.5 : 1.2;
209
+ const jit2 = layers === 1 ? 0.2 : 0.15;
141
210
  return {
142
211
  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,
212
+ up: (layer - (layers - 1) / 2) * FISH_LENGTH * pitch2 + (fishHash(index, 5) - 0.5) * FISH_LENGTH * jit2 * j - droop,
147
213
  back: (rank - 2) * FISH_LENGTH * 1.35
148
214
  };
149
215
  }
@@ -189,6 +255,12 @@ function formationExtent(count, variance, shape = "phalanx") {
189
255
  }
190
256
  return { side, up, back, reach };
191
257
  }
258
+ function fitBreath(extent, lattice, bounds, margin = FISH_LENGTH * 0.5) {
259
+ let fit = lattice;
260
+ if (extent.up > 0) fit = Math.min(fit, Math.max(1, (bounds.yRange / 2 - margin) / extent.up));
261
+ if (extent.reach > 0) fit = Math.min(fit, Math.max(1, (bounds.radius - margin) / extent.reach));
262
+ return fit;
263
+ }
192
264
  function bandRange(band) {
193
265
  switch (band) {
194
266
  // Tight, because 0..0.3 of a 57-unit volume is nearly two body lengths of
@@ -226,10 +298,32 @@ function splineAt(points, g) {
226
298
  catmullRom(p0[2], p1[2], p2[2], p3[2], u)
227
299
  ];
228
300
  }
229
- var PATH_SHAPES = ["wander", "orbit", "eight", "helix", "canyon"];
230
- function shapeWaypoints(shape, rng, bounds) {
301
+ var PATH_SHAPES = ["wander", "orbit", "eight", "helix", "canyon", "crossing"];
302
+ function shapeWaypoints(shape, rng, bounds, opts = {}) {
231
303
  const points = [];
232
304
  const ySpan = bounds.yMax - bounds.yMin;
305
+ if (shape === "crossing") {
306
+ const n2 = 16;
307
+ const az = (opts.cameraAzimuthDeg ?? 0) * Math.PI / 180;
308
+ const rx = Math.cos(az), rz = -Math.sin(az);
309
+ const dx = Math.sin(az), dz = Math.cos(az);
310
+ const len = bounds.radius * 0.9;
311
+ const depth = bounds.radius * rng.range(0.18, 0.3);
312
+ const y0 = bounds.yMin + ySpan * rng.range(0.35, 0.6);
313
+ const lift = ySpan * rng.range(0.04, 0.1);
314
+ for (let i = 0; i < n2; i++) {
315
+ const u = i / n2 * Math.PI * 2;
316
+ const along = Math.sin(u) * len;
317
+ const toward = Math.cos(u) * depth;
318
+ points.push([
319
+ rx * along + dx * toward,
320
+ // The far leg rides a little higher so it never hides behind the near one.
321
+ y0 + (toward < 0 ? lift : 0) + ySpan * rng.range(0, 0.04),
322
+ rz * along + dz * toward
323
+ ]);
324
+ }
325
+ return points;
326
+ }
233
327
  if (shape === "orbit") {
234
328
  const n2 = 14 + rng.int(0, 4);
235
329
  const r0 = bounds.radius * rng.range(0.45, 0.8);
@@ -304,8 +398,8 @@ function shapeWaypoints(shape, rng, bounds) {
304
398
  }
305
399
  return points;
306
400
  }
307
- function compileSwimPlan(rng, bounds, shape = "wander") {
308
- const points = shapeWaypoints(shape, rng, bounds);
401
+ function compileSwimPlan(rng, bounds, shape = "wander", opts = {}) {
402
+ const points = shapeWaypoints(shape, rng, bounds, opts);
309
403
  const n = points.length;
310
404
  const arc = new Float32Array(ARC_SAMPLES + 1);
311
405
  let prev = splineAt(points, 0);
@@ -371,11 +465,13 @@ function swimPoseAtDistance(plan, dist) {
371
465
  // src/maneuver.ts
372
466
  var MANEUVERS = ["none", "dart", "startle", "graze", "curious", "zoomies"];
373
467
  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 }
468
+ dart: { name: "dart", dur: 2.4, interval: 14, advance: 4.5, kick: 0.5, lift: 0, flurry: 1.1, bursts: 1, pitch: 0, contagious: false },
469
+ startle: { name: "startle", dur: 1.8, interval: 18, advance: 1.2, kick: 2.8, lift: 0.4, flurry: 1.3, bursts: 1, pitch: 0.18, contagious: true },
470
+ // Graze sinks for real now: lift beyond ±1 is deliberate — QA measured the
471
+ // old -0.7 × 0.6 at 7.6u of drop, indistinguishable from hover's idle bob.
472
+ graze: { name: "graze", dur: 5.5, interval: 16, advance: -3.2, kick: 0.6, lift: -2.1, flurry: 0.3, bursts: 1, pitch: -0.55, contagious: false },
473
+ curious: { name: "curious", dur: 4, interval: 15, advance: 0.8, kick: 2, lift: 0.2, flurry: 0.5, bursts: 1, pitch: 0.12, contagious: false },
474
+ zoomies: { name: "zoomies", dur: 3.2, interval: 20, advance: 7.5, kick: 0.9, lift: 0.15, flurry: 1.5, bursts: 3, pitch: 0, contagious: false }
379
475
  };
380
476
  function maneuverSpecOf(name) {
381
477
  return Object.hasOwn(SPECS, name) ? SPECS[name] : null;
@@ -390,38 +486,44 @@ function surges(u, bursts) {
390
486
  if (u <= 0) return 0;
391
487
  if (u >= 1) return 1;
392
488
  const x = u * bursts;
393
- return (Math.floor(x) + smooth(x - Math.floor(x))) / bursts;
489
+ const frac = x - Math.floor(x);
490
+ return (Math.floor(x) + smooth(frac / 0.55)) / bursts;
394
491
  }
395
- var IDLE = { along: 0, alongBump: 0, side: 0, up: 0, flurry: 0 };
396
- function maneuverAt(spec, index, tSec, rate, intensity) {
492
+ var IDLE = { along: 0, alongBump: 0, side: 0, up: 0, flurry: 0, pitch: 0 };
493
+ function maneuverAt(spec, index, tSec, rate, intensity, seatDelay = null) {
397
494
  if (!spec || rate <= 0 || intensity <= 0 || tSec < 0) return IDLE;
398
- const interval = spec.interval * (3 - 2 * Math.min(1, rate));
495
+ const interval = rate <= 1 ? spec.interval * (3 - 2 * rate) : Math.max(spec.dur * 1.15, spec.interval / rate);
399
496
  const k0 = Math.floor(tSec / interval);
400
497
  let along = 0;
401
498
  let side = 0;
402
499
  let up = 0;
403
500
  let flurry = 0;
501
+ let pitch = 0;
404
502
  let alongBump = 0;
405
503
  const done = Math.max(0, k0 - 1);
406
- along += done * spec.advance * intensity;
504
+ const perm = 0.6 + 0.8 * fishHash(index, 37);
505
+ along += done * spec.advance * intensity * perm;
407
506
  for (const k of [k0 - 1, k0, k0 + 1]) {
408
507
  if (k < 0) continue;
409
- const start = (k + fishHash(index, 11 + k)) * interval;
508
+ const wave = spec.contagious && seatDelay !== null;
509
+ const jitter = wave ? fishHash(1013, 11 + k) : fishHash(index, 11 + k);
510
+ const start = (k + jitter) * interval + (wave ? seatDelay : 0);
410
511
  const u = (tSec - start) / spec.dur;
411
512
  if (u <= 0) continue;
412
513
  if (u >= 1) {
413
- if (k >= done) along += spec.advance * intensity;
514
+ if (k >= done) along += spec.advance * intensity * perm;
414
515
  continue;
415
516
  }
416
517
  const sign = fishHash(index, 23 + k) < 0.5 ? -1 : 1;
417
518
  const env = bump(u) * intensity;
418
- along += spec.advance * surges(u, spec.bursts) * intensity;
519
+ along += spec.advance * surges(u, spec.bursts) * intensity * perm;
419
520
  alongBump += spec.advance * bump(u) * intensity;
420
521
  side += sign * spec.kick * env;
421
522
  up += spec.lift * spec.kick * env;
422
523
  flurry += spec.flurry * env;
524
+ pitch += spec.pitch * env;
423
525
  }
424
- return { along, alongBump, side, up, flurry };
526
+ return { along, alongBump, side, up, flurry, pitch };
425
527
  }
426
528
 
427
529
  // src/asset-cids.ts
@@ -1095,10 +1197,59 @@ var NPC_CATALOG = [
1095
1197
  function parseFishMix(mix, catalog = FISH_CATALOG) {
1096
1198
  const entries = [];
1097
1199
  const problems = [];
1200
+ const unique = catalog === FISH_CATALOG;
1201
+ const used = /* @__PURE__ */ new Set();
1202
+ const resolvesAsToken = (core) => {
1203
+ const [idRaw, countRaw, ...extra] = core.split(":");
1204
+ if (extra.length > 0) return false;
1205
+ if (countRaw !== void 0) {
1206
+ const count = Number(countRaw.trim());
1207
+ if (!Number.isInteger(count) || count < 1) return false;
1208
+ }
1209
+ const key = (idRaw ?? "").trim().toLowerCase();
1210
+ if (/^\d+$/.test(key)) {
1211
+ if (catalog.some((fish) => fish.id === Number(key))) return true;
1212
+ return catalog === FISH_CATALOG && fishAsset(Number(key), "3d") !== null && breedOf(Number(key)) !== null;
1213
+ }
1214
+ return catalog.some((fish) => fish.breed.toLowerCase() === key);
1215
+ };
1216
+ const allocate = (breed, want) => {
1217
+ const b = BREEDS.find((x) => x.breed === breed);
1218
+ if (!b?.range) return null;
1219
+ const [lo, hi] = b.range;
1220
+ if (!used.has(want) && want >= lo && want <= hi) {
1221
+ used.add(want);
1222
+ return want;
1223
+ }
1224
+ for (let d = 1; d <= hi - lo; d += 1) {
1225
+ for (const cand of [want + d, want - d]) {
1226
+ if (cand >= lo && cand <= hi && !used.has(cand)) {
1227
+ used.add(cand);
1228
+ return cand;
1229
+ }
1230
+ }
1231
+ }
1232
+ return null;
1233
+ };
1098
1234
  for (const rawToken of mix.split(",")) {
1099
1235
  const token = rawToken.trim();
1100
1236
  if (token === "") continue;
1101
- const [idRaw, countRaw, ...extra] = token.split(":");
1237
+ let style;
1238
+ let core = token;
1239
+ const at = token.lastIndexOf("@");
1240
+ if (at >= 0) {
1241
+ const styleRaw = token.slice(at + 1).trim().toLowerCase();
1242
+ const styledCore = token.slice(0, at).trim();
1243
+ if (resolvesAsToken(token)) {
1244
+ } else if (SWIM_STYLE_NAMES.includes(styleRaw)) {
1245
+ core = styledCore;
1246
+ style = styleRaw;
1247
+ } else if (resolvesAsToken(styledCore)) {
1248
+ core = styledCore;
1249
+ problems.push(`"${token}": unknown style "${styleRaw}" (${SWIM_STYLE_NAMES.join(", ")}) \u2014 swimming with the scene's swimStyle`);
1250
+ }
1251
+ }
1252
+ const [idRaw, countRaw, ...extra] = core.split(":");
1102
1253
  if (extra.length > 0) {
1103
1254
  problems.push(`"${token}": too many ':' \u2014 expected id[:count]`);
1104
1255
  continue;
@@ -1108,8 +1259,8 @@ function parseFishMix(mix, catalog = FISH_CATALOG) {
1108
1259
  if (!fish && catalog === FISH_CATALOG && /^\d+$/.test(key)) {
1109
1260
  const n = Number(key);
1110
1261
  const url2 = fishAsset(n, "3d");
1111
- const breed = breedOf(n);
1112
- if (url2 && breed) fish = { id: n, name: `Fish ${n}`, breed, ipfs3d: url2, localGlb: "" };
1262
+ const breed2 = breedOf(n);
1263
+ if (url2 && breed2) fish = { id: n, name: `Fish ${n}`, breed: breed2, ipfs3d: url2, localGlb: "" };
1113
1264
  }
1114
1265
  if (!fish) {
1115
1266
  const breeds = [...new Set(catalog.map((f) => f.breed))].join(", ");
@@ -1137,16 +1288,48 @@ function parseFishMix(mix, catalog = FISH_CATALOG) {
1137
1288
  count = n;
1138
1289
  }
1139
1290
  }
1140
- entries.push({ id: fish.id, url, count });
1291
+ if (!unique || fish.id > TOTAL_SUPPLY) {
1292
+ entries.push({ id: fish.id, breed: fish.breed, url, count, ...style ? { style } : {} });
1293
+ continue;
1294
+ }
1295
+ const breed = breedOf(fish.id);
1296
+ if (!breed) {
1297
+ entries.push({ id: fish.id, breed: fish.breed, url, count, ...style ? { style } : {} });
1298
+ continue;
1299
+ }
1300
+ const isAlias = !/^\d+$/.test(key);
1301
+ const b = BREEDS.find((x) => x.breed === breed);
1302
+ for (let i = 0; i < count; i += 1) {
1303
+ const want = isAlias && b?.range ? b.range[0] + Math.floor((b.range[1] - b.range[0]) * i / Math.max(1, count)) : fish.id;
1304
+ const got = allocate(breed, want);
1305
+ if (got === null) {
1306
+ problems.push(`"${token}": only ${i} distinct ${breed} left \u2014 a minted fish appears once per scene`);
1307
+ break;
1308
+ }
1309
+ if (!isAlias && i === 0 && got !== fish.id) {
1310
+ problems.push(`fish ${fish.id} already cast \u2014 fish ${got} swims instead`);
1311
+ }
1312
+ const gotUrl = fishAsset(got, "3d");
1313
+ if (!gotUrl) {
1314
+ problems.push(`fish ${got}: no asset URL`);
1315
+ continue;
1316
+ }
1317
+ entries.push({ id: got, breed: fish.breed, url: gotUrl, count: 1, ...style ? { style } : {} });
1318
+ }
1141
1319
  }
1142
1320
  return { entries, problems };
1143
1321
  }
1144
1322
  function expandFishMix(entries, cap) {
1145
- const urls = [];
1323
+ return expandFishMixSlots(entries, cap).map((s) => s.url);
1324
+ }
1325
+ function expandFishMixSlots(entries, cap) {
1326
+ const slots = [];
1146
1327
  for (const e of entries) {
1147
- for (let i = 0; i < e.count && urls.length < cap; i++) urls.push(e.url);
1328
+ for (let i = 0; i < e.count && slots.length < cap; i++) {
1329
+ slots.push(e.style ? { url: e.url, breed: e.breed, style: e.style } : { url: e.url, breed: e.breed });
1330
+ }
1148
1331
  }
1149
- return urls;
1332
+ return slots;
1150
1333
  }
1151
1334
 
1152
1335
  // src/manifest.ts
@@ -1173,9 +1356,15 @@ var METAQUARIUM_PARAMS = {
1173
1356
  fishUrl: { type: "string", default: "ipfs://QmaHbEQAP6k2zopJHJBzyaK62zNX5yH8yASDjkaG4DY9Dp/fish_257_of_the_metaquarium_3d.glb" },
1174
1357
  /** Mixed population DSL: comma-separated `id[:count]` of a minted token id
1175
1358
  * (1-512, default catalog) or breed name — e.g. "257:3,100:2,seaturtle:1".
1176
- * A custom catalog is a closed world (no farm/IPFS fallback). Counts are
1359
+ * A minted fish is an INDIVIDUAL: no id appears twice in a scene, and a
1360
+ * count casts that many DISTINCT fish of the breed ("300:12" is twelve
1361
+ * different angelfish). A custom catalog is a closed world (no farm/IPFS
1362
+ * fallback, no uniqueness — NPC entries are species). Counts are
1177
1363
  * absolute; the expanded total clamps to the device tier's fish cap.
1178
- * Non-empty mix OVERRIDES fishUrl and fishCount; empty string = single-
1364
+ * A token may carry its own swim style — `id[:count]@style`, e.g.
1365
+ * "457:3@hover,257:6@school,497:1@surface" — so one tank holds several
1366
+ * behaviours; untagged tokens follow `swimStyle`. Non-empty mix
1367
+ * OVERRIDES fishUrl and fishCount; empty string = single-
1179
1368
  * breed mode. Unknown ids/bad counts degrade (good tokens still parse);
1180
1369
  * raw URLs are not accepted here — use fishUrl for a custom single-breed GLB. */
1181
1370
  fishMix: { type: "string", default: "", ease: "step" },
@@ -1217,15 +1406,23 @@ var METAQUARIUM_PARAMS = {
1217
1406
  rayStrength: { type: "number", default: -1, min: -1, max: 1, ease: "step" },
1218
1407
  /** How the fish move. `loop` is exactly the pre-style behaviour, so the
1219
1408
  * default changes nothing. A small named set on purpose: a silhouette of
1220
- * movement you can name is one you can choose from. */
1221
- swimStyle: { type: "enum", default: "loop", options: [...SWIM_STYLE_NAMES], ease: "step" },
1409
+ * movement you can name is one you can choose from. `auto` lets each
1410
+ * untagged fishMix token swim the way its breed does (seahorse hover,
1411
+ * turtle skim the surface, angelfish school, betafish drift); a token's
1412
+ * `@style` still wins. The relationship styles — `follow`, `pair`,
1413
+ * `chase` — bond a fish to the nearest preceding unbonded fish in the mix
1414
+ * (`seaturtle:1, 257:3@follow` is a turtle with an escort). */
1415
+ swimStyle: { type: "enum", default: "loop", options: [...SWIM_STYLE_NAMES, "auto"], ease: "step" },
1222
1416
  /** The shape a fish's loop is drawn on. `wander` is the original roaming
1223
1417
  * 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. */
1418
+ * the middle, `helix` tours the water column, `canyon` sweeps low,
1419
+ * `crossing` is a camera-relative parade lane (across the frame in front,
1420
+ * back the other way behind; laid against cameraAzimuth when chosen) —
1421
+ * same spline engine, different itinerary. */
1226
1422
  pathShape: { type: "enum", default: "wander", options: [...PATH_SHAPES], ease: "step" },
1227
1423
  /** How a `school` holds together: the original lattice, single file, a
1228
- * carousel ring, the migratory V, or a bait-ball. Ignored by every
1424
+ * carousel ring, the migratory V, a bait-ball, or the ring tilted into a
1425
+ * `wheel` that reads from a side camera. Ignored by every
1229
1426
  * non-formation style; the same no-pair-inside-a-body-length law holds
1230
1427
  * for all of them. */
1231
1428
  formationShape: { type: "enum", default: "phalanx", options: [...FORMATION_SHAPES], ease: "step" },
@@ -1239,8 +1436,9 @@ var METAQUARIUM_PARAMS = {
1239
1436
  * addressable. `none` (default) changes nothing. */
1240
1437
  maneuver: { type: "enum", default: "none", options: [...MANEUVERS], ease: "step" },
1241
1438
  /** 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" },
1439
+ * every 14-20s per fish, desynchronised), above 1 faster — 3 packs events
1440
+ * nearly back to back. Values <=1 mean exactly what they always did. */
1441
+ maneuverRate: { type: "number", default: 0.5, min: 0, max: 3, ease: "smooth" },
1244
1442
  /** How hard: scales the surge, the kick, and the tail flurry together. */
1245
1443
  maneuverIntensity: { type: "number", default: 0.7, min: 0, max: 1, ease: "smooth" },
1246
1444
  /** Procedural body yaw for models that carry NO animation clip — most of
@@ -1253,7 +1451,18 @@ var METAQUARIUM_PARAMS = {
1253
1451
  * behaviour, and a scene already on someone's wall should not start moving
1254
1452
  * differently because a dependency was bumped. 0.3–0.4 is the recommended
1255
1453
  * value for a clip-less cast — the studio swim variants all set it. */
1256
- bodyWiggle: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" }
1454
+ bodyWiggle: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
1455
+ /** Light-seeking: free-swimming fish are drawn toward the room's light
1456
+ * shafts, each fish to its own pool. 0 (default) leaves every route
1457
+ * where it was; 1 pulls a fish most of the way into its shaft, so the
1458
+ * cast gathers in the light and a `rayStrength` dial becomes a staging
1459
+ * dial too. Needs a room with rays; formations are steered by their
1460
+ * carrier and ignore it. */
1461
+ lightSeek: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
1462
+ /** Formation breathing: the school relaxes outward and draws back in on a
1463
+ * slow (~15 s) cycle. 0 (default) is the rigid lattice; 1 opens it by up
1464
+ * to a fifth. Only ever expands, so the spacing guarantee holds. */
1465
+ formationBreathe: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" }
1257
1466
  };
1258
1467
  var MIAMI_VICE_COLORS = [
1259
1468
  "#ff6ad5",
@@ -1351,14 +1560,20 @@ export {
1351
1560
  RAY_COUNT,
1352
1561
  affordableLayers,
1353
1562
  SWIM_STYLES,
1563
+ AUTO_STYLE_BY_BREED,
1564
+ autoStyleFor,
1565
+ formationBreathe,
1566
+ idleSway,
1354
1567
  SWIM_STYLE_NAMES,
1355
1568
  swimStyleOf,
1356
1569
  fishHash,
1357
1570
  fishVariation,
1571
+ anchorFraction,
1358
1572
  FISH_LENGTH,
1359
1573
  FORMATION_SHAPES,
1360
1574
  formationSlot,
1361
1575
  formationExtent,
1576
+ fitBreath,
1362
1577
  bandRange,
1363
1578
  PATH_SHAPES,
1364
1579
  compileSwimPlan,
@@ -1386,6 +1601,7 @@ export {
1386
1601
  NPC_CATALOG,
1387
1602
  parseFishMix,
1388
1603
  expandFishMix,
1604
+ expandFishMixSlots,
1389
1605
  METAQUARIUM_PARAMS,
1390
1606
  MIAMI_VICE_COLORS,
1391
1607
  BLOOM_COLORS,
@@ -1394,4 +1610,4 @@ export {
1394
1610
  paramSpaceWith,
1395
1611
  coerceNum
1396
1612
  };
1397
- //# sourceMappingURL=chunk-23P73MYY.js.map
1613
+ //# sourceMappingURL=chunk-RTUYWDDZ.js.map