@idle-screens/saver-metaquarium 0.6.0 → 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
 
@@ -94,8 +94,44 @@ var SWIM_STYLES = [
94
94
  { name: "hover", label: "Hover", speedMul: 0.55, band: "free", bobAmp: 7, bobHz: 0.3, formation: false, travel: 0.12 },
95
95
  { name: "patrol", label: "Patrol", speedMul: 0.55, band: "mid", bobAmp: 1, bobHz: 0.09, formation: false, travel: 1 },
96
96
  { name: "bottom", label: "Bottom-hugger", speedMul: 0.7, band: "floor", bobAmp: 2, bobHz: 0.4, formation: false, travel: 1 },
97
- { 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" }
98
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
+ }
99
135
  var SWIM_STYLE_NAMES = SWIM_STYLES.map((s) => s.name);
100
136
  function swimStyleOf(name) {
101
137
  return SWIM_STYLES.find((s) => s.name === name) ?? SWIM_STYLES[0];
@@ -128,7 +164,7 @@ function anchorFraction(style, index, variance) {
128
164
  }
129
165
  var FISH_LENGTH = 18;
130
166
  var FORMATION_HALF_WIDTH = 62;
131
- var FORMATION_SHAPES = ["phalanx", "line", "ring", "wedge", "ball"];
167
+ var FORMATION_SHAPES = ["phalanx", "line", "ring", "wedge", "ball", "wheel"];
132
168
  function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH, shape = "phalanx") {
133
169
  const j = 0.35 + 0.65 * Math.max(0, Math.min(1, variance));
134
170
  if (shape === "line") {
@@ -152,6 +188,15 @@ function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH,
152
188
  back: Math.sin(a) * r
153
189
  };
154
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
+ }
155
200
  if (shape === "wedge") {
156
201
  const VSIZE = 9;
157
202
  const layers = Math.ceil(Math.max(1, count) / VSIZE);
@@ -210,6 +255,12 @@ function formationExtent(count, variance, shape = "phalanx") {
210
255
  }
211
256
  return { side, up, back, reach };
212
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
+ }
213
264
  function bandRange(band) {
214
265
  switch (band) {
215
266
  // Tight, because 0..0.3 of a 57-unit volume is nearly two body lengths of
@@ -247,10 +298,32 @@ function splineAt(points, g) {
247
298
  catmullRom(p0[2], p1[2], p2[2], p3[2], u)
248
299
  ];
249
300
  }
250
- var PATH_SHAPES = ["wander", "orbit", "eight", "helix", "canyon"];
251
- function shapeWaypoints(shape, rng, bounds) {
301
+ var PATH_SHAPES = ["wander", "orbit", "eight", "helix", "canyon", "crossing"];
302
+ function shapeWaypoints(shape, rng, bounds, opts = {}) {
252
303
  const points = [];
253
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
+ }
254
327
  if (shape === "orbit") {
255
328
  const n2 = 14 + rng.int(0, 4);
256
329
  const r0 = bounds.radius * rng.range(0.45, 0.8);
@@ -325,8 +398,8 @@ function shapeWaypoints(shape, rng, bounds) {
325
398
  }
326
399
  return points;
327
400
  }
328
- function compileSwimPlan(rng, bounds, shape = "wander") {
329
- const points = shapeWaypoints(shape, rng, bounds);
401
+ function compileSwimPlan(rng, bounds, shape = "wander", opts = {}) {
402
+ const points = shapeWaypoints(shape, rng, bounds, opts);
330
403
  const n = points.length;
331
404
  const arc = new Float32Array(ARC_SAMPLES + 1);
332
405
  let prev = splineAt(points, 0);
@@ -1126,6 +1199,20 @@ function parseFishMix(mix, catalog = FISH_CATALOG) {
1126
1199
  const problems = [];
1127
1200
  const unique = catalog === FISH_CATALOG;
1128
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
+ };
1129
1216
  const allocate = (breed, want) => {
1130
1217
  const b = BREEDS.find((x) => x.breed === breed);
1131
1218
  if (!b?.range) return null;
@@ -1147,7 +1234,22 @@ function parseFishMix(mix, catalog = FISH_CATALOG) {
1147
1234
  for (const rawToken of mix.split(",")) {
1148
1235
  const token = rawToken.trim();
1149
1236
  if (token === "") continue;
1150
- 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(":");
1151
1253
  if (extra.length > 0) {
1152
1254
  problems.push(`"${token}": too many ':' \u2014 expected id[:count]`);
1153
1255
  continue;
@@ -1187,12 +1289,12 @@ function parseFishMix(mix, catalog = FISH_CATALOG) {
1187
1289
  }
1188
1290
  }
1189
1291
  if (!unique || fish.id > TOTAL_SUPPLY) {
1190
- entries.push({ id: fish.id, url, count });
1292
+ entries.push({ id: fish.id, breed: fish.breed, url, count, ...style ? { style } : {} });
1191
1293
  continue;
1192
1294
  }
1193
1295
  const breed = breedOf(fish.id);
1194
1296
  if (!breed) {
1195
- entries.push({ id: fish.id, url, count });
1297
+ entries.push({ id: fish.id, breed: fish.breed, url, count, ...style ? { style } : {} });
1196
1298
  continue;
1197
1299
  }
1198
1300
  const isAlias = !/^\d+$/.test(key);
@@ -1212,17 +1314,22 @@ function parseFishMix(mix, catalog = FISH_CATALOG) {
1212
1314
  problems.push(`fish ${got}: no asset URL`);
1213
1315
  continue;
1214
1316
  }
1215
- entries.push({ id: got, url: gotUrl, count: 1 });
1317
+ entries.push({ id: got, breed: fish.breed, url: gotUrl, count: 1, ...style ? { style } : {} });
1216
1318
  }
1217
1319
  }
1218
1320
  return { entries, problems };
1219
1321
  }
1220
1322
  function expandFishMix(entries, cap) {
1221
- const urls = [];
1323
+ return expandFishMixSlots(entries, cap).map((s) => s.url);
1324
+ }
1325
+ function expandFishMixSlots(entries, cap) {
1326
+ const slots = [];
1222
1327
  for (const e of entries) {
1223
- 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
+ }
1224
1331
  }
1225
- return urls;
1332
+ return slots;
1226
1333
  }
1227
1334
 
1228
1335
  // src/manifest.ts
@@ -1254,7 +1361,10 @@ var METAQUARIUM_PARAMS = {
1254
1361
  * different angelfish). A custom catalog is a closed world (no farm/IPFS
1255
1362
  * fallback, no uniqueness — NPC entries are species). Counts are
1256
1363
  * absolute; the expanded total clamps to the device tier's fish cap.
1257
- * 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-
1258
1368
  * breed mode. Unknown ids/bad counts degrade (good tokens still parse);
1259
1369
  * raw URLs are not accepted here — use fishUrl for a custom single-breed GLB. */
1260
1370
  fishMix: { type: "string", default: "", ease: "step" },
@@ -1296,15 +1406,23 @@ var METAQUARIUM_PARAMS = {
1296
1406
  rayStrength: { type: "number", default: -1, min: -1, max: 1, ease: "step" },
1297
1407
  /** How the fish move. `loop` is exactly the pre-style behaviour, so the
1298
1408
  * default changes nothing. A small named set on purpose: a silhouette of
1299
- * movement you can name is one you can choose from. */
1300
- 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" },
1301
1416
  /** The shape a fish's loop is drawn on. `wander` is the original roaming
1302
1417
  * itinerary, so the default changes nothing. `orbit` laps, `eight` crosses
1303
- * the middle, `helix` tours the water column, `canyon` sweeps low — same
1304
- * 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. */
1305
1422
  pathShape: { type: "enum", default: "wander", options: [...PATH_SHAPES], ease: "step" },
1306
1423
  /** How a `school` holds together: the original lattice, single file, a
1307
- * 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
1308
1426
  * non-formation style; the same no-pair-inside-a-body-length law holds
1309
1427
  * for all of them. */
1310
1428
  formationShape: { type: "enum", default: "phalanx", options: [...FORMATION_SHAPES], ease: "step" },
@@ -1333,7 +1451,18 @@ var METAQUARIUM_PARAMS = {
1333
1451
  * behaviour, and a scene already on someone's wall should not start moving
1334
1452
  * differently because a dependency was bumped. 0.3–0.4 is the recommended
1335
1453
  * value for a clip-less cast — the studio swim variants all set it. */
1336
- 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" }
1337
1466
  };
1338
1467
  var MIAMI_VICE_COLORS = [
1339
1468
  "#ff6ad5",
@@ -1431,6 +1560,10 @@ export {
1431
1560
  RAY_COUNT,
1432
1561
  affordableLayers,
1433
1562
  SWIM_STYLES,
1563
+ AUTO_STYLE_BY_BREED,
1564
+ autoStyleFor,
1565
+ formationBreathe,
1566
+ idleSway,
1434
1567
  SWIM_STYLE_NAMES,
1435
1568
  swimStyleOf,
1436
1569
  fishHash,
@@ -1440,6 +1573,7 @@ export {
1440
1573
  FORMATION_SHAPES,
1441
1574
  formationSlot,
1442
1575
  formationExtent,
1576
+ fitBreath,
1443
1577
  bandRange,
1444
1578
  PATH_SHAPES,
1445
1579
  compileSwimPlan,
@@ -1467,6 +1601,7 @@ export {
1467
1601
  NPC_CATALOG,
1468
1602
  parseFishMix,
1469
1603
  expandFishMix,
1604
+ expandFishMixSlots,
1470
1605
  METAQUARIUM_PARAMS,
1471
1606
  MIAMI_VICE_COLORS,
1472
1607
  BLOOM_COLORS,
@@ -1475,4 +1610,4 @@ export {
1475
1610
  paramSpaceWith,
1476
1611
  coerceNum
1477
1612
  };
1478
- //# sourceMappingURL=chunk-O676ILL6.js.map
1613
+ //# sourceMappingURL=chunk-RTUYWDDZ.js.map