@idle-screens/saver-metaquarium 0.20.0 → 0.21.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,7 +107,10 @@ var SWIM_STYLES = [
107
107
  // is — the follower samples the SAME closed form at `d - lag`.
108
108
  { name: "follow", label: "Follower", speedMul: 1, band: "free", bobAmp: 1.2, bobHz: 0.5, formation: false, travel: 1, bond: "follow" },
109
109
  { name: "pair", label: "Pair", speedMul: 0.8, band: "free", bobAmp: 2.5, bobHz: 0.35, formation: false, travel: 1, bond: "pair" },
110
- { name: "chase", label: "Chaser", speedMul: 1, band: "free", bobAmp: 1, bobHz: 0.6, formation: false, travel: 1, bond: "chase" }
110
+ { name: "chase", label: "Chaser", speedMul: 1, band: "free", bobAmp: 1, bobHz: 0.6, formation: false, travel: 1, bond: "chase" },
111
+ // A seat in a vignette's audience (vignette.ts crowdPose): the script places it, out front facing the
112
+ // stage. With no vignette playing there is no show to watch, and it hovers.
113
+ { name: "audience", label: "Audience", speedMul: 0.55, band: "free", bobAmp: 4, bobHz: 0.3, formation: false, travel: 0.12 }
111
114
  ];
112
115
  var AUTO_STYLE_BY_BREED = {
113
116
  angelfish: "school",
@@ -206,12 +209,12 @@ function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH,
206
209
  const FILE = 6;
207
210
  const files = Math.ceil(Math.max(1, count) / FILE);
208
211
  const col2 = Math.floor(index / FILE);
209
- const within = index % FILE;
212
+ const within2 = index % FILE;
210
213
  const fileLen = Math.min(FILE, count - col2 * FILE);
211
214
  return {
212
215
  side: (col2 - (files - 1) / 2) * L * 1.6 + (fishHash(index, 4) - 0.5) * L * 0.4 * j,
213
216
  up: (fishHash(index, 5) - 0.5) * L * 0.5 * j,
214
- back: (within - (fileLen - 1) / 2) * L * 1.7
217
+ back: (within2 - (fileLen - 1) / 2) * L * 1.7
215
218
  };
216
219
  }
217
220
  if (shape === "ring") {
@@ -236,9 +239,9 @@ function formationSlot(index, count, variance, halfWidth = FORMATION_HALF_WIDTH,
236
239
  const VSIZE = 9;
237
240
  const layers = Math.ceil(Math.max(1, count) / VSIZE);
238
241
  const layer = Math.floor(index / VSIZE);
239
- const within = index % VSIZE;
240
- const rank = within + 1 >> 1;
241
- const wing = within === 0 ? 0 : within % 2 === 1 ? -1 : 1;
242
+ const within2 = index % VSIZE;
243
+ const rank = within2 + 1 >> 1;
244
+ const wing = within2 === 0 ? 0 : within2 % 2 === 1 ? -1 : 1;
242
245
  const droop = (rank - 2) * L * (layers === 1 ? 0.24 : 0.12);
243
246
  const pitch2 = layers === 1 ? 1.5 : 1.2;
244
247
  const jit2 = layers === 1 ? 0.2 : 0.15;
@@ -1415,24 +1418,26 @@ var DEFAULT_COLORS = ["#fff2cf", "#ff8ad0", "#7fdcff"];
1415
1418
  function parseSpotRig(value) {
1416
1419
  const spots = [], problems = [];
1417
1420
  for (const raw of value.split(",").map((s) => s.trim()).filter(Boolean)) {
1418
- const m = /^(\d{1,2})(?:\/(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?(?:\*(\d+(?:\.\d+)?))?$/.exec(raw);
1421
+ const m = /^(?:(\d{1,2})|@([a-z][a-z0-9]{0,15}))(?:\/(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?(?:\*(\d+(?:\.\d+)?))?$/.exec(raw);
1419
1422
  if (!m) {
1420
- problems.push(`"${raw}" is not slot[/color][*radius]`);
1423
+ problems.push(`"${raw}" is not slot[/color][*radius] or @mark[/color][*radius]`);
1421
1424
  continue;
1422
1425
  }
1423
1426
  if (spots.length >= MAX_SPOTS) {
1424
1427
  problems.push(`more than ${MAX_SPOTS} spots \u2014 "${raw}" dropped`);
1425
1428
  continue;
1426
1429
  }
1427
- const slot = Math.min(23, Number(m[1]));
1428
- if (spots.some((spot2) => spot2.slot === slot)) {
1429
- problems.push(`slot ${slot} is already spotted \u2014 "${raw}" dropped`);
1430
+ const mark = m[2];
1431
+ const slot = mark ? -1 : Math.min(23, Number(m[1]));
1432
+ if (mark ? spots.some((spot2) => spot2.mark === mark) : spots.some((spot2) => spot2.slot === slot)) {
1433
+ problems.push(`${mark ? `mark ${mark}` : `slot ${slot}`} is already spotted \u2014 "${raw}" dropped`);
1430
1434
  continue;
1431
1435
  }
1432
1436
  spots.push({
1433
1437
  slot,
1434
- color: m[2] ?? DEFAULT_COLORS[spots.length],
1435
- radius: Math.min(60, Math.max(12, m[3] ? Number(m[3]) : 28))
1438
+ ...mark ? { mark } : {},
1439
+ color: m[3] ?? DEFAULT_COLORS[spots.length],
1440
+ radius: Math.min(60, Math.max(12, m[4] ? Number(m[4]) : 28))
1436
1441
  });
1437
1442
  }
1438
1443
  return { spots, problems };
@@ -2506,30 +2511,32 @@ var OPEN_MARKS = {
2506
2511
  front: { x: 0, y: 30, z: 60 },
2507
2512
  back: { x: 0, y: 40, z: -70 },
2508
2513
  high: { x: 10, y: 62, z: -10 },
2509
- low: { x: -10, y: 18, z: 10 }
2514
+ low: { x: -10, y: 18, z: 10 },
2515
+ // Behind the mic stand (`stageSet: club` stands it here, facing front).
2516
+ mic: { x: 0, y: 36, z: -4, solo: true }
2510
2517
  };
2511
- var ACTOR = "abc";
2518
+ var ACTOR = "abcd";
2512
2519
  var CRUISE = 17;
2513
2520
  var STANDOFF = 20;
2514
2521
  function spot(mark, actor, actors) {
2515
- if (actors === 1) return mark;
2522
+ if (actors === 1 || mark.solo) return mark;
2516
2523
  const a = actor / actors * Math.PI * 2 + 0.6;
2517
2524
  return { x: mark.x + Math.cos(a) * STANDOFF, y: mark.y + (actor % 2 ? 2.5 : 0), z: mark.z + Math.sin(a) * STANDOFF };
2518
2525
  }
2519
- function parseVignette(script, marks) {
2526
+ function parseVignette(script, marks, audience = []) {
2520
2527
  const problems = [];
2521
2528
  const raw = String(script ?? "").split(/[|\n]/).map((b) => b.trim()).filter(Boolean);
2522
2529
  let actors = 0;
2523
2530
  for (const beat of raw) {
2524
2531
  for (const cue of beat.replace(/^\d+(?:\.\d+)?s\s{0,16}:/, "").split(",")) {
2525
2532
  for (const word of cue.trim().split(/\s+/)) {
2526
- if (!/^[abc]$/.test(word)) break;
2533
+ if (!/^[abcd]$/.test(word)) break;
2527
2534
  actors = Math.max(actors, ACTOR.indexOf(word) + 1);
2528
2535
  }
2529
2536
  }
2530
2537
  }
2531
- if (!raw.length || !actors) return { actors: 0, beats: [], duration: 0, problems: raw.length ? ["no actor (a, b, c) named"] : [] };
2532
2538
  const centre = marks.centre ?? marks.rug ?? OPEN_MARKS.centre;
2539
+ if (!raw.length || !actors) return { actors: 0, beats: [], duration: 0, problems: raw.length ? ["no actor (a\u2013d) named"] : [], seats: /* @__PURE__ */ new Map(), centre };
2533
2540
  let at = Array.from({ length: actors }, (_, i) => spot(centre, i, actors));
2534
2541
  const start = [];
2535
2542
  const beats = [];
@@ -2547,7 +2554,7 @@ function parseVignette(script, marks) {
2547
2554
  for (const cue of body.split(",").map((c) => c.trim()).filter(Boolean)) {
2548
2555
  const words = cue.split(/\s+/);
2549
2556
  const who = [];
2550
- while (words.length && /^[abc]$/.test(words[0])) who.push(ACTOR.indexOf(words.shift()));
2557
+ while (words.length && /^[abcd]$/.test(words[0])) who.push(ACTOR.indexOf(words.shift()));
2551
2558
  if (!who.length) {
2552
2559
  problems.push(`beat ${bi + 1}: "${cue}" names no actor`);
2553
2560
  continue;
@@ -2555,7 +2562,7 @@ function parseVignette(script, marks) {
2555
2562
  for (let w = 0; w < words.length; w += 1) {
2556
2563
  const word = words[w];
2557
2564
  const target = (name) => {
2558
- if (/^[abc]$/.test(name) && ACTOR.indexOf(name) < actors) return { actor: ACTOR.indexOf(name), point: null };
2565
+ if (/^[abcd]$/.test(name) && ACTOR.indexOf(name) < actors) return { actor: ACTOR.indexOf(name), point: null };
2559
2566
  const mk = marks[name];
2560
2567
  if (mk) return { actor: null, point: mk };
2561
2568
  problems.push(`beat ${bi + 1}: no mark or actor "${name}" (marks: ${Object.keys(marks).join(", ")})`);
@@ -2627,7 +2634,9 @@ function parseVignette(script, marks) {
2627
2634
  beats.push({ t0, dur: length, actors: start.map((p) => ({ to: p, moved: true, faceActor: null, facePoint: null, gesture: null, circle: null, follow: null })) });
2628
2635
  t0 += length;
2629
2636
  }
2630
- return { actors, beats, duration: t0, problems };
2637
+ const seats = /* @__PURE__ */ new Map();
2638
+ for (const slot of audience) if (slot >= actors && !seats.has(slot)) seats.set(slot, seats.size);
2639
+ return { actors, beats, duration: t0, problems, seats, centre };
2631
2640
  }
2632
2641
  var smoother = (u) => {
2633
2642
  const c = Math.min(1, Math.max(0, u));
@@ -2682,7 +2691,10 @@ function poseAcross(v, actor, tSec, dt, own) {
2682
2691
  return poseOf(v, actor, snapBetween(v, actor, tSec, tSec + dt) ? tSec : tSec + dt, own);
2683
2692
  }
2684
2693
  function poseOf(v, actor, tSec, own) {
2685
- if (actor >= v.actors || !v.duration) return null;
2694
+ if (!v.duration) return null;
2695
+ const seat = v.seats.get(actor);
2696
+ if (seat !== void 0) return crowdPose(v, seat, tSec, own);
2697
+ if (actor >= v.actors) return null;
2686
2698
  const t = (tSec % v.duration + v.duration) % v.duration;
2687
2699
  let bi = v.beats.findIndex((b) => t < b.t0 + b.dur);
2688
2700
  if (bi < 0) bi = v.beats.length - 1;
@@ -2819,6 +2831,152 @@ function poseOf(v, actor, tSec, own) {
2819
2831
  }
2820
2832
  return { x, y: Math.max(9, y), z, fx, fy, fz, roll, doing, lookAt: target ?? null, ...gesture ? { gesture } : {} };
2821
2833
  }
2834
+ var CROWD = {
2835
+ /** Rows out from the stage's middle, front (nearest the camera) first: distance, height, seats.
2836
+ * Raked: each row higher than the one before, so the back rows see over the front. */
2837
+ rows: [{ r: 48, y: 15, n: 6 }, { r: 64, y: 21, n: 8 }, { r: 80, y: 27, n: 10 }],
2838
+ /** How far round each row reaches, radians either side of straight out front. */
2839
+ arc: 0.7,
2840
+ /** A laugh: it breaks this long before the talk ends, and rolls on this long. */
2841
+ lead: 0.45,
2842
+ laugh: 2.8,
2843
+ /** Seconds a seat can be late to a laugh or the applause. */
2844
+ late: 0.55,
2845
+ /** One seat in this many is a tough crowd: it chuckles where the rest laugh. */
2846
+ tough: 5,
2847
+ /** Seconds the room's heads take to follow the stage: they look where it was, a moment ago. */
2848
+ follow: 1.05
2849
+ };
2850
+ var seatHash = (seat, k = 0) => {
2851
+ const s = Math.sin(seat * 127.1 + k * 311.7 + 74.7) * 43758.5453;
2852
+ return s - Math.floor(s);
2853
+ };
2854
+ function crowdSeat(v, seat) {
2855
+ let row = 0, first = 0;
2856
+ while (row < CROWD.rows.length - 1 && seat >= first + CROWD.rows[row].n) {
2857
+ first += CROWD.rows[row].n;
2858
+ row += 1;
2859
+ }
2860
+ const r = CROWD.rows[row];
2861
+ const inRow = Math.min(r.n, v.seats.size - first);
2862
+ const mid = (inRow - 1) / 2;
2863
+ const order = Array.from({ length: inRow }, (_, p) => p).sort((p, q) => Math.abs(p - mid) - Math.abs(q - mid) || q - p);
2864
+ const i = order[seat - first] ?? seat - first;
2865
+ const step = CROWD.arc * 2 / Math.max(1, r.n - 1);
2866
+ const a = (i - (inRow - 1) / 2) * step + (seatHash(seat, 1) - 0.5) * step * 0.3;
2867
+ const d = r.r + (seatHash(seat, 2) - 0.5) * 5;
2868
+ return { x: v.centre.x + Math.sin(a) * d, y: r.y + (seatHash(seat, 3) - 0.5) * 3, z: v.centre.z + Math.cos(a) * d };
2869
+ }
2870
+ var REACTIONS = /* @__PURE__ */ new WeakMap();
2871
+ function reactionsOf(v) {
2872
+ let r = REACTIONS.get(v);
2873
+ if (r) return r;
2874
+ r = { laughs: [], claps: [] };
2875
+ for (const b of v.beats) {
2876
+ if (b.actors.some((a) => a.gesture === "talk")) r.laughs.push(b.t0 + b.dur);
2877
+ if (b.actors.some((a) => a.gesture === "bow")) r.claps.push([b.t0, b.t0 + b.dur]);
2878
+ }
2879
+ REACTIONS.set(v, r);
2880
+ return r;
2881
+ }
2882
+ function holderAt(v, tSec) {
2883
+ const t = (tSec % v.duration + v.duration) % v.duration;
2884
+ const beat = v.beats.find((b) => t < b.t0 + b.dur) ?? v.beats[v.beats.length - 1];
2885
+ const talking = beat.actors.findIndex((a) => a.gesture === "talk");
2886
+ if (talking >= 0) return talking;
2887
+ let best = 0, bestD = Infinity;
2888
+ beat.actors.forEach((a, i) => {
2889
+ const d = Math.hypot(a.to.x - v.centre.x, a.to.z - v.centre.z);
2890
+ if (d < bestD) {
2891
+ bestD = d;
2892
+ best = i;
2893
+ }
2894
+ });
2895
+ return best;
2896
+ }
2897
+ function within(v, t, start, len, out) {
2898
+ for (const shift of [-1, 0, 1]) {
2899
+ const u = (t - start - shift * v.duration) / len;
2900
+ if (u > 0 && u < 1) {
2901
+ if (out) out.shift = shift;
2902
+ return u;
2903
+ }
2904
+ }
2905
+ return -1;
2906
+ }
2907
+ function crowdPose(v, seat, tSec, own) {
2908
+ const p = crowdSeat(v, seat);
2909
+ const t = (tSec % v.duration + v.duration) % v.duration;
2910
+ let gx = 0, gy = 0, gz = 0;
2911
+ const taps = 4;
2912
+ for (let k = 0; k < taps; k++) {
2913
+ const tk = tSec - k / (taps - 1) * CROWD.follow;
2914
+ const m = placeOf(v, holderAt(v, tk), tk);
2915
+ gx += m.x / taps;
2916
+ gy += m.y / taps;
2917
+ gz += m.z / taps;
2918
+ }
2919
+ const look = { x: gx, y: gy, z: gz };
2920
+ let fx = gx - p.x, fy = (gy - p.y) * 0.4, fz = gz - p.z;
2921
+ let fl = Math.hypot(fx, fy, fz) || 1;
2922
+ fx /= fl;
2923
+ fy /= fl;
2924
+ fz /= fl;
2925
+ const { x, z } = p;
2926
+ let y = p.y + Math.sin(tSec * 1.1 + seat * 2.3) * 0.7;
2927
+ let roll = Math.sin(tSec * 0.7 + seat) * 0.03;
2928
+ let pitch = 0;
2929
+ let doing = "watching";
2930
+ let gesture;
2931
+ const { laughs, claps } = reactionsOf(v);
2932
+ const late = seatHash(seat, 4) * CROWD.late;
2933
+ let lu = -1, lat = 0, lk = 0;
2934
+ const loop = { shift: 0 }, lap = Math.floor(tSec / v.duration);
2935
+ let burst;
2936
+ for (let k = 0; k < laughs.length; k++) {
2937
+ const at = laughs[k];
2938
+ const len = CROWD.laugh * (0.8 + 0.45 * seatHash(seat, 10 + k));
2939
+ const u = within(v, t, at - CROWD.lead + late, len, loop);
2940
+ if (u > lu) {
2941
+ lu = u;
2942
+ lat = u * len;
2943
+ lk = k;
2944
+ }
2945
+ }
2946
+ if (lu > 0) {
2947
+ const e = Math.min(1, lu * 6) * (1 - smoother((lu - 0.35) / 0.65));
2948
+ const tough = seatHash(seat, 5) < 1 / CROWD.tough;
2949
+ const amp = tough ? 0.4 : 0.75 + 0.5 * seatHash(seat, 6);
2950
+ const hz = 5.5 + 2.5 * seatHash(seat, 7);
2951
+ doing = "laughing";
2952
+ pitch = (0.3 + Math.sin(lat * hz * 2) * 0.1) * e * amp;
2953
+ y += Math.abs(Math.sin(lat * hz)) * 3.4 * e * amp;
2954
+ roll += Math.sin(lat * hz) * 0.16 * e * amp;
2955
+ if (own?.("talk")) gesture = { name: "talk", at: lat, bar: lat, weight: e * amp };
2956
+ if (!tough) {
2957
+ const start = laughs[lk] - CROWD.lead + late + (lap + loop.shift) * v.duration;
2958
+ burst = { key: `crowd:${seat}:${lk}:${lap + loop.shift}`, t: start + 0.25 };
2959
+ }
2960
+ }
2961
+ for (const [a, b] of claps) {
2962
+ const u = within(v, t, a + late, b - a);
2963
+ if (u <= 0) continue;
2964
+ const e = bell(u) ** 0.6, ct = u * (b - a);
2965
+ doing = "applauding";
2966
+ y += Math.abs(Math.sin(ct * 6.5)) * 1.3 * e;
2967
+ roll += Math.sin(ct * 13) * 0.22 * e;
2968
+ if (own?.("wiggle")) gesture = { name: "wiggle", at: ct, bar: ct, weight: e };
2969
+ break;
2970
+ }
2971
+ if (pitch) {
2972
+ fy += Math.tan(pitch) * Math.hypot(fx, fz);
2973
+ fl = Math.hypot(fx, fy, fz) || 1;
2974
+ fx /= fl;
2975
+ fy /= fl;
2976
+ fz /= fl;
2977
+ }
2978
+ return { x, y: Math.max(9, y), z, fx, fy, fz, roll, doing, lookAt: look, ...gesture ? { gesture } : {}, ...burst ? { burst } : {} };
2979
+ }
2822
2980
  var polar = (deg, r, y) => ({ x: Math.cos(deg * Math.PI / 180) * r, y, z: Math.sin(deg * Math.PI / 180) * r });
2823
2981
  var INTERIOR_MARK_POLAR = {
2824
2982
  door: { deg: 28, r: 124, y: 22 },
@@ -2909,11 +3067,48 @@ var VIGNETTES = {
2909
3067
  "6s: a @c bow, b @c bow, c >front bow",
2910
3068
  "5s: a >left, b >right, c @front shake",
2911
3069
  "5s: c >back"
3070
+ ].join(" | "),
3071
+ // Open mic: a host (d) brings on three comics (a b c) in turn — each two bits and a bow — and sends them off;
3072
+ // the two waiting stand in the wings and watch whoever has the mic, nodding along when the room laughs. Then
3073
+ // a goodnight from the host, and the company bows from where it stands. Written for an `@audience` out front (it laughs as each talk
3074
+ // ends), `stageSet: club` and one spot standing on the mic (`spotRig: @mic`): whoever steps up is lit.
3075
+ // openmic cues: 4s:-, 5s:a, 5s:a, 3s:a, 5s:a, 6s:a, 3s:a, 6s:a, 3s:a, 5s:a, 4s:a, 3s:a, 5s:a, 6s:a, 3s:a, 6s:a, 3s:a, 5s:a, 4s:a, 3s:a, 5s:a, 6s:a, 3s:a, 6s:a, 3s:a, 5s:a, 5s:a, 4s:a, 5s:-
3076
+ openmic: [
3077
+ "4s: a =left, b =right, c =left, d =right",
3078
+ "5s: d >mic @front, a @mic nod, b @mic nod, c @mic nod",
3079
+ "5s: d @front talk, a @mic, b @mic, c @mic",
3080
+ "3s: d @a wiggle, a @mic nod, b @mic nod, c @mic nod",
3081
+ "5s: d >right, a >mic @front, b @mic, c @mic",
3082
+ "6s: a @front talk, b @mic, c @mic, d @mic nod",
3083
+ "3s: a @front nod, b @mic nod, c @mic nod, d @mic nod",
3084
+ "6s: a @front talk, b @mic, c @mic, d @mic",
3085
+ "3s: a @front bow, b @mic nod, c @mic nod, d @mic nod",
3086
+ "5s: a >left, d >mic @front, b @mic, c @mic",
3087
+ "4s: d @front talk, a @mic nod, b @mic, c @mic",
3088
+ "3s: d @b wiggle, a @mic, b @mic nod, c @mic",
3089
+ "5s: d >right, b >mic @front, a @mic, c @mic",
3090
+ "6s: b @front talk, a @mic, c @mic, d @mic nod",
3091
+ "3s: b @front rest, a @mic nod, c @mic nod, d @mic nod",
3092
+ "6s: b @front talk, a @mic, c @mic, d @mic",
3093
+ "3s: b @front bow, a @mic nod, c @mic nod, d @mic nod",
3094
+ "5s: b >right, d >mic @front, a @mic, c @mic",
3095
+ "4s: d @front talk, a @mic, b @mic nod, c @mic",
3096
+ "3s: d @c wiggle, a @mic, b @mic, c @mic nod",
3097
+ "5s: d >right, c >mic @front, a @mic, b @mic",
3098
+ "6s: c @front talk, a @mic, b @mic, d @mic nod",
3099
+ "3s: c @front shake, a @mic nod, b @mic nod, d @mic nod",
3100
+ "6s: c @front talk, a @mic, b @mic, d @mic",
3101
+ "3s: c @front bow, a @mic nod, b @mic nod, d @mic nod",
3102
+ "5s: c >left, d >mic @front, a @mic, b @mic",
3103
+ "5s: d @front talk, a @mic nod, b @mic, c @mic nod",
3104
+ "4s: a @front bow, b @front bow, c @front bow, d @front bow",
3105
+ "5s: d >right, a @mic, b @mic, c @mic"
2912
3106
  ].join(" | ")
2913
3107
  };
2914
3108
  var VIGNETTE_CUES = {
2915
3109
  duet: "4s:-, 7s:a, 5s:a, 7s:b, 5s:b, 6s:a+b, 8s:a+b, 6s:a+b, 5s:a+b",
2916
- trio: "4s:-, 6s:a, 6s:b, 6s:c, 7s:a+b, 7s:b+c, 9s:a+b+c, 6s:a+b+c, 5s:c, 5s:-"
3110
+ trio: "4s:-, 6s:a, 6s:b, 6s:c, 7s:a+b, 7s:b+c, 9s:a+b+c, 6s:a+b+c, 5s:c, 5s:-",
3111
+ openmic: "4s:-, 5s:a, 5s:a, 3s:a, 5s:a, 6s:a, 3s:a, 6s:a, 3s:a, 5s:a, 4s:a, 3s:a, 5s:a, 6s:a, 3s:a, 6s:a, 3s:a, 5s:a, 4s:a, 3s:a, 5s:a, 6s:a, 3s:a, 6s:a, 3s:a, 5s:a, 5s:a, 4s:a, 5s:-"
2917
3112
  };
2918
3113
  function resolveVignette(value) {
2919
3114
  const v = String(value ?? "").trim();
@@ -2926,11 +3121,11 @@ var PARAM_DOCS = {
2926
3121
  cameraAzimuth: "Orbit bearing in degrees. 0 looks at the village from the front.",
2927
3122
  cameraElevation: "Degrees above the floor. 8\u201315 is eye level with the fish; 40+ looks down on a castle.",
2928
3123
  cameraDistance: "Distance from the tank centre. 80 is a close-up, 170\u2013220 a village, 300+ a landmark.",
2929
- shot: "A named framing: `orbit` (default), `hero` (three-quarter), `front` (level, long lens), `low` (among the plants, looking up), `top` (the floor as a map), `surface` (the water's underside), `macro` (close on the nearest landmark), `cast` (frames the mix's first three wherever they swim). Azimuth and autoRotate turn it; distance scales it.",
3124
+ shot: "A framing: `orbit` (default), `hero` (three-quarter), `front` (level, long lens), `low` (looking up), `top` (a map), `surface` (the underside), `macro` (nearest landmark), `cast` (the mix's first three). Azimuth and autoRotate turn it, distance scales it.",
2930
3125
  shotBlend: "Seconds a change of `shot` takes (0 = a cut): the camera moves to the new framing, eased.",
2931
- cameraFollow: "Ride with one fish (cast slot, 0 = first); -1 is the orbit camera. Orbit params are ignored while following. Pair with `followSpot` on the same slot for a spotlit chase.",
3126
+ cameraFollow: "Ride with one fish (cast slot); -1 is the orbit camera, whose params are ignored while following. With `followSpot` on the slot, a spotlit chase.",
2932
3127
  followAngle: "Where round the followed fish the chase camera stands, degrees: 0 behind (default), 90 beside, 180 in front looking back at its face.",
2933
- followDistance: "How far behind the followed fish the camera rides (40\u201370 is a chase). Under ~18 it is the fish's eye and the fish is hidden; a floor walker (Dot, crab, octopus, snail, starfish) sees from its own head height.",
3128
+ followDistance: "How far behind the followed fish the camera rides (40\u201370 is a chase). Under ~18 it is the fish's eye; a floor walker sees from its own head height.",
2934
3129
  autoRotate: "Turntable speed, degrees/second. 0 holds the shot (stages, vignettes); 1\u20132 is a slow reveal. frame.state.camera.bearing: where it stands now.",
2935
3130
  followAvoid: "Keep the followed fish in view, 0\u20131: the chase camera rises over low clutter or pulls in, eased. For walks through a village.",
2936
3131
  nearFade: "A creature at the lens dissolves instead of being sliced, 0\u20131. The followed fish never fades.",
@@ -2942,7 +3137,7 @@ var PARAM_DOCS = {
2942
3137
  ambientOcclusion: "Ambient occlusion, 0\u20131: crevices darken. High tier.",
2943
3138
  highlights: "Bright light rolls off instead of clipping, 0\u20131: a fish in a shaft stays a fish. Bright rooms want it. Mid/high tiers.",
2944
3139
  fishUrl: "The single model used when `fishMix` is empty: ipfs://, https://assets.idlescreens.com/, or a URL on the page's own origin. Any other host is refused and the default fish swims.",
2945
- fishMix: "The cast: `id[:count][@style]`, comma-separated: `100:2@drift, 257:1, seahorse:3, shark:1@patrol`. Minted breeds plus bundled creatures: shark crab dori glowfish babyfish hackerfish blowfish starfish octopus snail dot dolphin. Floor walkers keep their own pace (swimSpeed does not hurry them): crab (climbs rocks, forages, pinches, waves), starfish (walks upright too; dances with `starfishDance`), snail (very slow; hides in its shell). `dot` (experimental): Dot the courier, a box on legs with a screen face; `dolphin` (experimental) chatters, flips, blows bubbles, breaches where there is open surface, and in a scene stands on its tail for character beats (talk, nod, bow...). Both act out vignette gestures. Overrides fishCount/fishUrl.",
3140
+ fishMix: "The cast: `id[:count][@style]`, comma-separated: `100:2@drift, 257:1, seahorse:3, shark:1@patrol`. Minted breeds plus bundled: shark crab dori glowfish babyfish hackerfish blowfish starfish octopus snail dot dolphin. Floor walkers keep their own pace: crab, starfish (dances with `starfishDance`), snail. `dot` (a courier box on legs) and `dolphin` (chatters, flips, breaches; stands on its tail to act) play vignette gestures. Overrides fishCount/fishUrl.",
2946
3141
  dracoPath: "Where the Draco decoder lives; leave empty on a channel. Only the page's own origin or the shipped copy is used.",
2947
3142
  swimSpeed: "Global swim speed multiplier. 0.4 is contemplative, 1 lively.",
2948
3143
  swimStyle: "How the cast swims unless a fishMix entry says otherwise (`@style`). `auto` picks by breed.",
@@ -2965,15 +3160,16 @@ var PARAM_DOCS = {
2965
3160
  fishAmbient: "The water's light on the fish in lit mode (backs, flanks, bellies, metal plates). Fixes fish reading dark; 0.6\u20131 in bright tanks. Dims with a follow-spot's house lights.",
2966
3161
  fishGlow: "How strongly GLOW- parts of a fish bloom and light their neighbours and the floor.",
2967
3162
  fishMetal: "`on` (default) gives METAL- parts a polished, reflective finish.",
2968
- snailShell: "The snails' shells: `banded` (as drawn), `pearl` (iridescent nacre), `gold`, `glow` (a spiral that breathes light; best at night); `mix` (default), each its own. Lit fish only. frame.state.fish[i].shell says which.",
2969
- starfishDance: "Starfish dance: `aerobics`, a class facing the camera with an instructor, in unison; `freestyle`, each its own move every eight counts; `duet`, couples face to face, best with `spotRig: 0/#ffd27a*26, 1/#ff8ad0*26`. `off` (default): they crawl and wave on their own. Cast a few (`fishMix: starfish:7`); best with `shot: front`, `autoRotate: 0`, a bright room.",
3163
+ snailShell: "Snail shells: `banded`, `pearl` (iridescent), `gold`, `glow` (a spiral breathing light; best at night); `mix` (default), each its own. Lit fish only. frame.state.fish[i].shell says which.",
3164
+ starfishDance: "Starfish dance: `aerobics`, a class in unison facing the camera; `freestyle`, each its own move every eight counts; `duet`, couples face to face (with `spotRig: 0/#ffd27a*26, 1/#ff8ad0*26`); `off` (default). Cast a few (`starfish:7`), `shot: front`, `autoRotate: 0`, a bright room.",
2970
3165
  danceTempo: "Beats per minute for the starfish dance (60\u2013180, default 128). Glides on the beat.",
2971
- fishLook: "`neon` dresses the bundled creatures (crab, glowfish, shark\u2026) for blacklight: near-black coats, eyes and a crab's mouth glowing a neon of their own. Best in a dark room (abyss, void) with `finish`. `natural` is the default.",
3166
+ fishLook: "`neon` dresses the bundled creatures for blacklight: near-black coats, eyes (and a crab's mouth) glowing a neon of their own. Best in a dark room with `finish`. Default `natural`.",
2972
3167
  fishRim: "A thin edge of the water's light round each fish, 0\u20131 (lit mode): dark fish on dark water. 0.6\u20131 in dark rooms.",
2973
3168
  fishShadows: "Soft shadows on the ground under the swimmers, 0\u20131: dark near the sand, faint high up.",
2974
3169
  fishFadeIn: "Seconds an arriving fish takes to dissolve in (0 = it pops in).",
2975
3170
  sceneryShadows: "Soft shadows under rocks, homes, castle, pyramids, geodes and lamps, 0\u20131, so nothing floats.",
2976
- swimWave: "Fish bend as they swim: a wave runs nose to tail and the body curls into turns (0 = rigid wiggle). Skips the turtle, crab and fish that already have a skeleton; the seahorse gets its own motion instead \u2014 fin, tail and nod.",
3171
+ voxelAO: "Plant cubes darken where they touch each other and the sand, 0\u20131: solid coral. All tiers.",
3172
+ swimWave: "Fish bend as they swim: a wave nose to tail, the body curling into turns (0 = rigid wiggle). Skips the turtle, crab and fish with a skeleton; the seahorse gets fin, tail and nod instead.",
2977
3173
  fishAvoid: "Fish make way for each other: a pair about to meet parts a second ahead, the smaller giving more, over or under before round. 1 (default) on, 0 pass-through. `inspect().crowding` counts pairs still touching.",
2978
3174
  eyeLife: "Eyes blink, look, and emote (0 = painted on). Redraws each token's own pixel-grid eye; black and white only.",
2979
3175
  // water and room
@@ -2983,21 +3179,21 @@ var PARAM_DOCS = {
2983
3179
  floorColor: "Sea-floor colour.",
2984
3180
  water: "Water instead of fog: red fades with distance first, then green, blue last \u2014 a red fish goes blue-green before it goes into the murk. 0.6\u20131 for most scenes; 0 is plain fog.",
2985
3181
  waterClarity: "With `water` on: 0 murky (colour lost fast, short reach), 0.5 as before, 1 clear (colour kept, reach \xD71.5). Bright tanks want 0.7\u20131.",
2986
- waterTint: "The sunlit water, `#rrggbb`: distant things fade into it looking up, half of it on the level, none looking down, and the background becomes that gradient. Empty = off; with `water` on, reef, kelp, ice and lagoon bring their own.",
3182
+ waterTint: "The sunlit water, `#rrggbb`: distance fades into it looking up, half on the level, none looking down; the background takes the gradient. Empty = off; with `water` on, reef, kelp, ice and lagoon bring their own.",
2987
3183
  dither: "`on` adds an invisible dither that removes colour banding in dark fogged gradients on TVs. Recommended with `water`; `off` (default) is byte-exact with older scenes.",
2988
3184
  moteDensity: "Suspended motes.",
2989
3185
  moteColor: "Their colour.",
2990
3186
  environment: "A room preset (floor, ceiling, light shafts). `void` for the mineral-world scenes.",
2991
3187
  floorKind: "Floor shape: `auto` follows the environment, `flat`, or terrain `dunes`, `ridges`, `basin`, `shelf` (the town on a terraced plateau), `trench` (a deep channel across the front), `terraces` (tiers behind the town).",
2992
3188
  waterY: "Height of the water ceiling; -1 = the environment's own.",
2993
- rayStrength: "Light shafts from the surface, within device budget: 5 on the high tier, 2 on standard, none on the lowest tier in a room with a water surface (reef, kelp, ice, lagoon; abyss, vent and universe keep 2). Where dropped, perceiveChannel reads room.rayStrength 0.",
3189
+ rayStrength: "Surface light shafts, within device budget: 5 high tier, 2 standard; none on the lowest tier with a surface (abyss, vent, universe keep 2; perceiveChannel then reads 0).",
2994
3190
  raySoftness: "Shafts as soft volume, not flat sheets, 0\u20131. 1 for anything cinematic.",
2995
3191
  floorDetail: "A seabed, not a coloured plane, 0\u20131: ripples, silt, grain; the floor's far edge fades into the fog.",
2996
3192
  caustics: "The dancing net of light from the surface on the floor, rocks, plants and fish (0 = none). Sharp near the top, soft and dim deep down.",
2997
3193
  causticScale: "Size of the caustic cells: below 1 a fine shimmer, above 1 broad slow bands.",
2998
- surfaceMirror: "The surface from below: a window up to the light and a rippling mirror of the tank around it (0 = the plain sheet). Needs reef, kelp, ice or lagoon; pays off on the `surface` and `low` shots. High tier reflects the real tank, mid tier the deep water.",
3194
+ surfaceMirror: "The surface from below: a window to the light in a rippling mirror of the tank (0 = plain). Needs reef, kelp, ice or lagoon; for the `surface` and `low` shots. High tier mirrors the tank, mid the deep water.",
2999
3195
  // world
3000
- propMix: "Crystals: `crystal[:count][@habit][/palette][*size]`. Habits lotus\xB7spire\xB7druse\xB7scatter\xB7coral; palettes env\xB7rainbow or a colour (blue hotpink purple seafoam yellow orange cyan white glass); `*6` is tower-sized. `crystal:3@druse/rainbow`.",
3196
+ propMix: "Crystals: `crystal[:count][@habit][/palette][*size]`. Habits lotus spire druse scatter coral; palettes env rainbow blue hotpink purple seafoam yellow orange cyan white glass; `*6` is tower-sized.",
3001
3197
  envProps: "`on` lets the environment preset plant its own crystals when `propMix` is empty.",
3002
3198
  crystalScale: "Size of the whole mineral world (crystals, rocks, homes, flora).",
3003
3199
  crystalWild: "Organic individuality: lean, bald sides, branching. 0 is a catalogue crystal, 1 is coral.",
@@ -3006,30 +3202,32 @@ var PARAM_DOCS = {
3006
3202
  crystalTint: "How much nearby light sources colour a passing fish. 0.5\u20130.7 ties the cast to the place.",
3007
3203
  rockDensity: "Faceted boulders, an arch and a back ridge around the crystals.",
3008
3204
  rockVeins: "Crystals bursting out of the rocks: each crown broken open, chips on the rim, a colony of the same crystal growing from the breach. 0 is plain stone.",
3009
- geodeHomes: "Geode houses (0\u20133) in a crescent: a village. Their doors are vignette marks `home1`\u2026, `home1in`\u2026. Capped at 2 on basic/minimal tiers (3 on standard/high); a dropped house takes its homeN/homeNin marks, and indoors there are none.",
3205
+ geodeHomes: "Geode houses (0\u20133) in a crescent: a village. Doors are vignette marks `home1`\u2026, `home1in`\u2026. Capped at 2 on basic/minimal tiers; a dropped house takes its marks, and indoors there are none.",
3010
3206
  stoneStyle: "`weathered`: river-grey rocks and homes with silt on top, not dark holes in a lit room. `classic` (default): dark slate.",
3011
3207
  interior: "`geode` sets the whole scene INSIDE a geode home: furniture, chandelier, marks like `table`, `bed`, `door`.",
3012
3208
  floraDensity: "Voxel plants: one tank-wide budget (\xD7 36/72/108 by tier) shared among crystals and geodes, so more crystals means sparser gardens. What grows is `floraMix`; empty, the room picks.",
3013
- geodes: "Wild geodes on the floor (0 = none): split geodes, thunder eggs, cathedrals, clusters, one cavern. They glow and pulse with the crystals, take the nearest crystal's mineral, and wake when a fish comes close.",
3209
+ geodes: "Wild geodes on the floor (0 = none): split geodes, thunder eggs, cathedrals, clusters, one cavern. They pulse with the crystals, take the nearest one's mineral, and wake when a fish comes close.",
3014
3210
  geodeMix: "Which geodes: `kind[:weight]`, comma-separated. geode cathedral cluster cavern (the cavern is one at most).",
3015
3211
  geodeMineral: "What they are made of: amethyst agate celestine citrine carnelian rose emerald quartz smoky. Empty = the mineral nearest in colour to the nearest crystal; `world` = two from the seed; or a list.",
3016
- fountain: "The town square's fountain where the paths meet: `vent` (a hot-vent chimney crusted with crystal, glowing at the mouth) or `geode` (a great geode basin on a plinth). A bubble column, a pebble plaza, a light of its own, and a vignette mark `fountain`.",
3017
- signs: 'Signs, `kind[@place][>target][/#color][*size][:text]`: `plank` on a post, `arrow` to another place, life-`ring`, lit `porthole`, `neon` lettering, scrolling `led` board, each beside its place. Original words only. `plank@gate:"Welcome to the reef", neon@home1/#ff4fa0:OPEN, arrow@hub>gate:Castle`.',
3018
- signFlicker: "How unstable the lit signs' power is (neon and LED), 0\u20131, default 0.4: hums, stutters, a dead row for a moment, now and then a reboot. Each sign has its own supply; 0 is steady. Flash-safe at any setting.",
3019
- streetLamps: "Crystal streetlamps along the paths (0 = none, 1 = about 24): slate posts with a crystal crown in their nearest crystal's colour, a halo and light on the floor. Needs paths.",
3212
+ fountain: "The town square's fountain: `vent` (a crystal-crusted chimney glowing at the mouth) or `geode` (a geode basin on a plinth). A bubble column, a plaza, its own light, and a vignette mark `fountain`.",
3213
+ signs: "Signs, `kind[@place][>target][/#color][*size][:text]`: `plank` on a post, `arrow` to a place, life-`ring`, lit `porthole`, `neon` lettering, scrolling `led`, each beside its place. Original words only. `neon@home1/#ff4fa0:OPEN, arrow@hub>gate:Castle`.",
3214
+ stageSet: "`club`: a brick back wall, a mic on a stand at the `mic` mark, a stool; `mic`: the stand alone. For the `openmic` vignette.",
3215
+ dolphinCast: "Comic dolphins, in fishMix order: `showman` (navy suit, red tie, quick), `drifter` (tan, flannel, trucker cap, slow slouch), `bigguy` (big, slate, dark tee, deadpan), `host` (a bow tie); else `classic`. Types, no likeness.",
3216
+ signFlicker: "How unstable the lit signs' power is, 0\u20131 (default 0.4): hums, stutters, a dead row, now and then a reboot, each sign on its own supply; 0 is steady. Flash-safe.",
3217
+ streetLamps: "Crystal streetlamps along the paths (0\u20131, about 24 at 1): slate posts crowned in the nearest crystal's colour, a halo, light on the floor. Needs paths.",
3020
3218
  townProps: "Village props, kind[:count][/style]: fence (a yard per home; driftwood coral stone crystal), mailbox (by each gate: post clam bottle; marks mailbox1\u2026), bench[:n] (shell stone), sock[:n]. Fences and mailboxes need geodeHomes.",
3021
- floorCover: "What the village lays on its floor, kind[/style]: lawn (yards; stripes checks), mosaic (a compass rose at the hub; stone shell glass), mat (at doors; kelp stripe), steps (down every walk; slate sand moss), ripples (open sand; fine broad). Lawns and mats need geodeHomes, steps need paths. Flat ground only.",
3022
- pyramids: "Pyramids built of cubes like the fish (0 = none, 1 = about a dozen complexes): symmetric stacks, a great pyramid and its queens, the great ones at the back. Flat ground only: use floorKind: flat (none on dunes, ridges or a basin).",
3219
+ floorCover: "The village's floor, kind[/style]: lawn (yards; stripes checks), mosaic (at the hub; stone shell glass), mat (at doors; kelp stripe), steps (down walks; slate sand moss), ripples (fine broad). Lawns and mats need geodeHomes, steps paths. Flat ground.",
3220
+ pyramids: "Pyramids of cubes (0 = none, 1 = about a dozen complexes): a great pyramid and its queens, the great ones at the back. Flat ground only (floorKind: flat).",
3023
3221
  pyramidMix: "Shapes and stones, `shape|stone[:weight], \u2026`: giza step bent djoser mayan frame; sandstone limestone obsidian jade gold lapis coral crystal nacre basalt, and lit led and neon (every cube a lamp). Empty = every shape in the room's stone.",
3024
- pyramidLayout: "field = complexes over the floor; stage = one monumental pyramid in the middle of the camera's orbit (lit if the mix has led or neon, else stone with lit trim), a small one at each side, the field behind. Needs flat ground in the middle.",
3222
+ pyramidLayout: "field = complexes over the floor; stage = one monumental pyramid mid-orbit (lit with led or neon, else stone with lit trim), a small one each side, the field behind. Needs flat ground in the middle.",
3025
3223
  pyramidShow: "The lit pyramids' show (led, neon or trim): auto = one wave through every routine; solo = each its own; a routine (rings beam edges equalizer rainbow twinkle faces rain spiral breathe ripple); or cues `8s:rings, 16s:equalizer`. Live, no rebuild.",
3026
3224
  pyramidBpm: "The light show's tempo, 0\u2013140 (0 = free-running): the motion steps on the beat and each beat lifts the lamps a little \u2014 never a flash.",
3027
3225
  geodeLayout: "`field` (default) scatters them; `gallery` lines them up, a row per kind and a column per mineral, the mega geode behind. Frame with cameraDistance 400, elevation ~26, nothing else in the scene.",
3028
- floraLayout: "`garden` (default) grows plants round the crystals; `gallery` plants one of each species in two rows across the front (low before tall). With `floraMix` set, only its species. Frame with `shot: front` or `cameraDistance` ~260.",
3029
- floraPalette: "One colour scheme for the garden: `world` picks a hero hue, a neighbour and an accent from the seed; or give up to six `#rrggbb`. Each colony is pulled most of the way to one of them. Empty = every species its own colours.",
3030
- floraMix: "Which plants: `species[:weight]`, comma-separated: grass tube bulb fan kelp anemone staghorn brain whip barrel shelf seapen clam bubble elder curl pod (clam: two at most; elder: one). `kelp:3, whip, seapen` is a kelp forest; `staghorn:2, brain, anemone:2, clam` a reef. Empty = the room's garden. Needs floraDensity.",
3226
+ floraLayout: "`garden` (default) grows plants round the crystals; `gallery` plants one of each species in two rows across the front (only floraMix's, if set). Frame with `shot: front` or `cameraDistance` ~260.",
3227
+ floraPalette: "One colour scheme for the garden: `world` (a hero hue, a neighbour and an accent from the seed) or up to six `#rrggbb`; each colony is pulled most of the way to one. Empty = each species its own.",
3228
+ floraMix: "Which plants, `species[:weight]`: grass tube bulb fan kelp anemone staghorn brain whip barrel shelf seapen clam bubble elder curl pod (clam \u22642, elder 1). `kelp:3, whip, seapen` a kelp forest; `staghorn:2, brain, anemone:2, clam` a reef. Needs floraDensity.",
3031
3229
  bubbleVents: "Bubbles from chimneys, fissures and crystals.",
3032
- bubbleStyle: "How vent bubbles live: `classic` loops puffs; `live` grows each bubble at the vent, rises it to the surface (if there is one) and pops it, resting the vent now and then. Distant bubbles dim instead of fattening.",
3230
+ bubbleStyle: "Vent bubbles: `classic` loops puffs; `live` grows each at the vent, rises it to the surface and pops it, resting the vent now and then. Distant bubbles dim instead of fattening.",
3033
3231
  pearling: "Oxygen pearls on the plants: beads grow on the leaves over half a minute or more, sway with them and let go (0 = none). Needs floraDensity > 0; with no flora there is nothing to pearl on.",
3034
3232
  co2Mist: "A fine haze of tiny bubbles from the vents, drifting on a slow current (0 = none).",
3035
3233
  marineSnow: "Drifting lit particles; gives the water depth.",
@@ -3041,19 +3239,19 @@ var PARAM_DOCS = {
3041
3239
  landmark: "One thing bigger than everything else. `castle`: voxel walls, crystal-spired towers, gate, road, plaza. `citadel`: two storeys. Marks: `gate`, `plaza`, `courtyard`.",
3042
3240
  // stage
3043
3241
  followSpot: "A follow-spot on one fish slot (0 = first of the cast); -1 off. House lights drop with it.",
3044
- spotStrength: "How hard the spots are on and how far the house lights fall. House lights dim only lit fish's key/fill (to 40% at full) and fishAmbient; reflections, glow, flat fish and the unlit floor/scenery keep theirs, and a `-` blackout only puts out the spots.",
3242
+ spotStrength: "How hard the spots are and how far the house lights fall: lit fish's key/fill (to 40%) and fishAmbient dim; glow, reflections and scenery keep theirs. A `-` blackout puts out only the spots.",
3045
3243
  spotColor: "Colour of the single `followSpot`.",
3046
3244
  spotShadow: "The spotted fish's shadow in its pool. 0 none, 1 true size.",
3047
- spotRig: "Up to three spots: `slot[/color][*radius]`. `0/#ff8ad0*26, 1/#7fdcff`. They are a, b, c in order. Overrides followSpot.",
3245
+ spotRig: "Up to three spots: `slot[/color][*radius]` follows a fish, `@mark` stands on a mark (`@mic` lights whoever steps up). They are a, b, c in order. Overrides followSpot.",
3048
3246
  spotCues: "A looping cue sheet for the rig: `8s:a, 8s:b, 12s:a+b, 4s:-` (`-` = blackout). Spots cross-fade at each cue.",
3049
- vignette: "A small scene for the first 2\u20133 fish: a preset (tea\xB7bedtime\xB7seek indoors; duet\xB7trio on the open stage) or a script of beats. A floor creature cast as an actor stays on the floor. Grammar below."
3247
+ vignette: "A small scene for the first 2\u20133 fish: a preset (tea\xB7bedtime\xB7seek indoors; duet\xB7trio\xB7openmic on the open stage) or a script of beats. A floor creature cast as an actor stays on the floor. Grammar below."
3050
3248
  };
3051
3249
  var GRAMMAR = `
3052
3250
  fishMix id[:count][@style][*size], \u2026 100:2@drift, 257:1, seahorse:3, shark:1@patrol*1.5
3053
3251
  id = a minted token (1\u2013512) or a breed (betafish angelfish seahorse seaturtle). Minted fish are
3054
3252
  individuals: a count casts DISTINCT neighbours. Bundled creatures (a count is copies): shark
3055
3253
  crab dori glowfish babyfish hackerfish blowfish starfish octopus snail dot. *size scales that token over its
3056
- breed's own size. Styles: loop school drift hover patrol bottom surface follow pair chase.
3254
+ breed's own size. Styles: loop school drift hover patrol bottom surface follow pair chase audience.
3057
3255
  propMix crystal[#id][:count][@habit][/palette][*size], \u2026 crystal:3@druse/rainbow, crystal:1@spire/cyan*6
3058
3256
  habits lotus spire druse scatter coral \xB7 palettes env rainbow blue hotpink purple seafoam yellow orange cyan white glass
3059
3257
  floraMix species[:weight], \u2026 kelp:3, whip, seapen \xB7 staghorn:2, brain, anemone:2, clam
@@ -3069,17 +3267,18 @@ pyramidShow auto | solo | routine | <sec>s:<routine>, \u2026 8s:rings, 8s:bea
3069
3267
  geodeMix kind[:weight], \u2026 geode cathedral cluster cavern geode:3, cathedral, cluster
3070
3268
  geodeMineral world | mineral, \u2026 amethyst agate celestine citrine carnelian rose emerald quartz smoky
3071
3269
  floraPalette world | #rrggbb, \u2026 (\u22646) world \xB7 #ff4fa0, #ffb347, #4fd1ff
3072
- spotRig slot[/color][*radius], \u2026 (\u22643; they become a, b, c) 0/#ffd27a*24, 1/#ff8ad0*24, 2/#7fdcff*24
3270
+ spotRig slot|@mark[/color][*radius], \u2026 (\u22643; they become a, b, c) 0/#ffd27a*24, 1/#ff8ad0*24, 2/#7fdcff*24
3073
3271
  spotCues <sec>s:<spots>, \u2026 loops 4s:-, 7s:a, 7s:b, 12s:a+b
3074
3272
  signs kind[@place][>target][/#color][*size][:text], \u2026 plank@gate:"Welcome to the reef", neon@home1/#ff4fa0:OPEN, led@plaza:TIDE 4.2M
3075
3273
  kinds plank arrow ring porthole neon led \xB7 places gate plaza courtyard hub fountain home1 home2 home3 centre left right front back
3076
3274
  an arrow points at >target \xB7 text \u2264 plank 24, arrow 16, ring 10, porthole 4 (empty = a fish), neon 14, led 64 (scrolls) \xB7 \u2264 8 signs
3077
- vignette beats separated by |, cues by comma; actors a b c are fish slots 0 1 2
3275
+ vignette beats separated by |, cues by comma; actors a b c d are fish slots 0 1 2 3
3078
3276
  [<sec>s:] actor =mark be there, no travel (a later beat's = snaps at its start) actor >mark swim there
3079
3277
  actor @mark | @actor face it a b circle mark each named actor rings it, back to its spot by the beat's end; others stay put
3080
3278
  actor follow actor gestures: talk nod shake hop spin wiggle rest peek bow \xB7 ink (octopus only: a cloud of ink)
3081
3279
  dances: toprock footwork windmill headspin freeze drop (a betafish dances them with its own body; 2 s beats keep them on a 120 bpm bar)
3082
3280
  Give every beat a duration and end where you began and a spotCues sheet stays in step forever.
3281
+ Fish cast @audience sit out front facing the stage: they laugh as each talk ends and applaud each bow.
3083
3282
  Marks outdoors: ${Object.keys(OPEN_MARKS).join(" ")} \u2014 plus the world's own:
3084
3283
  home1 home1in \u2026 (geodeHomes), gate plaza courtyard (landmark), hub (paths), fountain (fountain).
3085
3284
  Marks indoors (interior: geode): ${Object.keys(INTERIOR_MARKS).join(" ")}
@@ -3273,6 +3472,28 @@ var RECIPES = [
3273
3472
  cameraAzimuth: 0
3274
3473
  }
3275
3474
  },
3475
+ {
3476
+ id: "open-mic",
3477
+ label: "Open mic",
3478
+ what: "A comedy club: a host brings three comic dolphins to the mic in turn; a seated crowd laughs and applauds.",
3479
+ params: {
3480
+ ...ALIVE,
3481
+ ...STAGE,
3482
+ floorColor: "#1a1210",
3483
+ vignette: "openmic",
3484
+ stageSet: "club",
3485
+ spotRig: "@mic/#ffc890*26",
3486
+ spotCues: VIGNETTE_CUES.openmic,
3487
+ spotStrength: 0.3,
3488
+ fishMix: "dolphin:4, dori:2@audience*0.7, crab:2@audience*0.6, octopus:1@audience*0.7, babyfish:5@audience",
3489
+ dolphinCast: "showman, drifter, bigguy, host",
3490
+ signs: "neon@back/#ff4fa0:OPEN MIC",
3491
+ finish: 0.6,
3492
+ cameraDistance: 125,
3493
+ cameraElevation: 12,
3494
+ cameraAzimuth: 0
3495
+ }
3496
+ },
3276
3497
  {
3277
3498
  id: "follow-spot",
3278
3499
  label: "Follow-spot",
@@ -3327,7 +3548,7 @@ var RECIPES = [
3327
3548
  {
3328
3549
  id: "reef-characters",
3329
3550
  label: "Reef characters",
3330
- what: "The bundled characters on a reef: tangs that look back, blowfish that puff and flirt, an octopus that changes colour and inks, a crab. A slow orbit.",
3551
+ what: "The bundled characters on a reef: tangs that look back, blowfish that puff and flirt, an octopus that inks, a crab.",
3331
3552
  params: {
3332
3553
  ...ALIVE,
3333
3554
  environment: "reef",
@@ -3370,7 +3591,7 @@ var RECIPES = [
3370
3591
  {
3371
3592
  id: "dot-delivers",
3372
3593
  label: "Dot delivers",
3373
- what: "Dot the courier walks to a neighbour's door and knocks; a blowfish comes out, they bow and chat, Dot spins for joy and walks home. The camera rides beside it.",
3594
+ what: "Dot the courier knocks at a neighbour's door; a blowfish comes out, they bow and chat, Dot spins for joy and walks home.",
3374
3595
  params: {
3375
3596
  ...ALIVE,
3376
3597
  geodeHomes: 3,
@@ -3513,6 +3734,9 @@ function validateMetaquariumParams(params) {
3513
3734
  else push("spotCues", parseSpotCues(str("spotCues"), spots).problems);
3514
3735
  }
3515
3736
  const world = worldMarks(params);
3737
+ for (const s of rig.spots) {
3738
+ if (s.mark && !(s.mark in OPEN_MARKS) && !(s.mark in world)) out.push({ path: "spotRig", message: `no "${s.mark}" mark in this scene \u2014 that spot is dark` });
3739
+ }
3516
3740
  if (str("vignette")) {
3517
3741
  const indoors = params.interior === "geode";
3518
3742
  push("vignette", parseVignette(resolveVignette(str("vignette")), indoors ? INTERIOR_MARKS : { ...OPEN_MARKS, ...world }).problems);
@@ -3598,17 +3822,17 @@ function dependencyProblems(params) {
3598
3822
  const spot2 = Math.round(num("followSpot", -1));
3599
3823
  if (rig.length && set("followSpot")) out.push({ path: "followSpot", also: ["spotRig"], message: "ignored: a spotRig replaces the single followSpot" });
3600
3824
  else if (spot2 >= cast) out.push({ path: "followSpot", also: castParams, message: noFish(spot2) });
3601
- for (const s of rig) if (s.slot >= cast) out.push({ path: "spotRig", also: castParams, message: noFish(s.slot) });
3825
+ for (const s of rig) if (!s.mark && s.slot >= cast) out.push({ path: "spotRig", also: castParams, message: noFish(s.slot) });
3602
3826
  if (str("vignette")) {
3603
3827
  const v = parseVignette(resolveVignette(str("vignette")), { ...OPEN_MARKS, ...INTERIOR_MARKS });
3604
3828
  const { actors } = v;
3605
- if (actors > cast) out.push({ path: "vignette", also: castParams, message: `needs ${actors} fish (actors a\u2013${"abc"[actors - 1]} are slots 0\u2013${actors - 1}), but ${castWhy}` });
3829
+ if (actors > cast) out.push({ path: "vignette", also: castParams, message: `needs ${actors} fish (actors a\u2013${"abcd"[actors - 1]} are slots 0\u2013${actors - 1}), but ${castWhy}` });
3606
3830
  const slotBreeds = expandFishMixSlots(mix, 3).map((sl) => sl.breed);
3607
3831
  for (const [g, breeds] of Object.entries(BREED_GESTURES)) {
3608
3832
  for (let a = 0; a < actors; a++) {
3609
3833
  if (!v.beats.some((b) => b.actors[a]?.gesture === g)) continue;
3610
3834
  const breed = slotBreeds[a] ?? "fish";
3611
- if (!breeds.includes(breed)) out.push({ path: "vignette", also: ["fishMix"], message: `"${"abc"[a]} ${g}" does nothing: ${g} is the ${breeds.join("/")}'s own, and actor ${"abc"[a]} (slot ${a}) is a ${breed}` });
3835
+ if (!breeds.includes(breed)) out.push({ path: "vignette", also: ["fishMix"], message: `"${"abcd"[a]} ${g}" does nothing: ${g} is the ${breeds.join("/")}'s own, and actor ${"abcd"[a]} (slot ${a}) is a ${breed}` });
3612
3836
  }
3613
3837
  }
3614
3838
  }
@@ -3760,7 +3984,7 @@ function describeMetaquarium(params, timed = []) {
3760
3984
  if (Math.round(num("shoal") * CAST_CAP2 * 2.5) >= 3) world.push(`a school of ${str("shoalKind")}`);
3761
3985
  const stage = [];
3762
3986
  const rig = parseSpotRig(str("spotRig")).spots;
3763
- if (rig.length) stage.push(`${rig.length} spotlight${rig.length === 1 ? "" : "s"} on ${rig.map((s) => slotName(s.slot)).join(", ")}`);
3987
+ if (rig.length) stage.push(`${rig.length} spotlight${rig.length === 1 ? "" : "s"} on ${rig.map((s) => s.mark ? `the ${s.mark} mark` : slotName(s.slot)).join(", ")}`);
3764
3988
  else if (num("followSpot") >= 0) stage.push(`follow-spot on ${slotName(num("followSpot"))}`);
3765
3989
  if (has("spotCues") && (rig.length || num("followSpot") >= 0)) {
3766
3990
  const sheet = parseSpotCues(str("spotCues"), Math.max(1, rig.length));
@@ -4073,6 +4297,10 @@ var METAQUARIUM_PARAMS = {
4073
4297
  * homes, the castle, pyramids, wild geodes, lamps — so it stands on the
4074
4298
  * sand instead of floating over it. 0 is none. */
4075
4299
  sceneryShadows: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
4300
+ /** The plants' cubes darken where they meet each other and the sand — the
4301
+ * corner shading every voxel renderer has — so a coral reads solid instead
4302
+ * of as flat cut-outs. Baked once, the first frame it is on; every tier. 0 is off. */
4303
+ voxelAO: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
4076
4304
  /** A creature that comes too close to the camera dissolves (an ordered
4077
4305
  * dither by depth) instead of being cut by the lens: at 1 it is whole
4078
4306
  * beyond about a fish and a half and gone inside about half that. 0 is off. */
@@ -4250,6 +4478,13 @@ var METAQUARIUM_PARAMS = {
4250
4478
  * fountain home1…, or centre left right front back), facing the front; an LED board
4251
4479
  * scrolls a long line. Empty = none. */
4252
4480
  signs: { type: "string", default: "", ease: "step" },
4481
+ /** The open-mic set (club.ts): `club` is a brick back wall, a mic on a stand at the `mic` mark and a
4482
+ * stool; `mic` the stand alone. The `openmic` vignette is written for it, with an `@audience` out front. */
4483
+ stageSet: { type: "enum", default: "none", options: ["none", "club", "mic"], ease: "step" },
4484
+ /** EXPERIMENTAL. The dolphins' cast — comic personas, in the order the dolphins come in fishMix:
4485
+ * `showman, drifter, bigguy` (dolphin-comics.ts). Each its own look (skin, finish, shirt, prop) and
4486
+ * act (tempo, size of gesture, slouch, lean, lids). Unknown names, and dolphins past the list: classic. */
4487
+ dolphinCast: { type: "string", default: "", ease: "step" },
4253
4488
  /** How unstable the lit signs' power is (neon and LED boards): 0 steady; higher, more
4254
4489
  * hum, stutters, a dead row or tube, a slipped row, now and then a reboot. Each sign
4255
4490
  * has its own supply — most steady, some dodgy. Flash-safe at any setting. */
@@ -4542,4 +4777,4 @@ export {
4542
4777
  paramSpaceWith,
4543
4778
  coerceNum
4544
4779
  };
4545
- //# sourceMappingURL=chunk-IIYEE6GT.js.map
4780
+ //# sourceMappingURL=chunk-5DJ5WBVN.js.map