@idle-screens/saver-metaquarium 0.20.0 → 0.22.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 };
@@ -1615,7 +1620,7 @@ function shardGeometry(rng) {
1615
1620
  for (let i = 0; i < n; i += 1) tris.push([top[i], tip, top[(i + 1) % n]]);
1616
1621
  const positions = new Float32Array(tris.length * 9);
1617
1622
  const normals = new Float32Array(tris.length * 9);
1618
- const smooth3 = new Float32Array(tris.length * 9);
1623
+ const smooth4 = new Float32Array(tris.length * 9);
1619
1624
  tris.forEach((tri, t) => {
1620
1625
  const [a, b, c] = tri;
1621
1626
  const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
@@ -1643,12 +1648,12 @@ function shardGeometry(rng) {
1643
1648
  normals[o + 2] = nz;
1644
1649
  const sy = (p[1] - 0.55) * 0.25;
1645
1650
  const sl = Math.hypot(p[0], sy, p[2]) || 1;
1646
- smooth3[o] = p[0] / sl;
1647
- smooth3[o + 1] = sy / sl;
1648
- smooth3[o + 2] = p[2] / sl;
1651
+ smooth4[o] = p[0] / sl;
1652
+ smooth4[o + 1] = sy / sl;
1653
+ smooth4[o + 2] = p[2] / sl;
1649
1654
  });
1650
1655
  });
1651
- return { positions, normals, smooth: smooth3, triangles: tris.length };
1656
+ return { positions, normals, smooth: smooth4, triangles: tris.length };
1652
1657
  }
1653
1658
  var HABIT_RINGS = {
1654
1659
  lotus: [[1, 0], [6, 30], [9, 55], [16, 85]],
@@ -2495,10 +2500,190 @@ function parseSignMix(value) {
2495
2500
  return { entries, problems };
2496
2501
  }
2497
2502
 
2503
+ // src/choreo.ts
2504
+ var STARFISH_MOVE_NAMES = [
2505
+ "march",
2506
+ "jacks",
2507
+ "reach",
2508
+ "kick",
2509
+ "twist",
2510
+ "circles",
2511
+ "disco",
2512
+ "spin",
2513
+ "mambo",
2514
+ "sway",
2515
+ "twirl",
2516
+ "dip",
2517
+ "hold",
2518
+ "walk",
2519
+ "lie",
2520
+ "crawl",
2521
+ "curl",
2522
+ "wave",
2523
+ "pop",
2524
+ "rise",
2525
+ "sit"
2526
+ ];
2527
+ var CHOREO_MARKS = {
2528
+ home: [0, 0],
2529
+ left: [-2, 0],
2530
+ right: [2, 0],
2531
+ front: [0, 1.2],
2532
+ back: [0, -1.5],
2533
+ centre: "centre"
2534
+ };
2535
+ var MAX_CUES = 256;
2536
+ function parseChoreography(value, moves) {
2537
+ const lines = /* @__PURE__ */ new Map(), problems = [];
2538
+ const known = new Set(moves);
2539
+ for (const rawLine of value.split("|").map((s) => s.trim()).filter(Boolean)) {
2540
+ const head = /^([a-z]|\*)\s*:\s*(?=\d|\.)/.exec(rawLine);
2541
+ const actor = head ? head[1] === "*" ? "*" : head[1].charCodeAt(0) - 97 : "*";
2542
+ const body = head ? rawLine.slice(head[0].length) : rawLine;
2543
+ if (lines.has(actor)) problems.push(`dancer ${actor === "*" ? "*" : String.fromCharCode(97 + actor)} has two lines; the later one wins`);
2544
+ const cues = [];
2545
+ let b0 = 0;
2546
+ for (const raw of body.split(/,\s*(?=(?:\d+(?:\.\d+)?|\.\d+)\s*:)/).map((s) => s.trim()).filter(Boolean)) {
2547
+ if (cues.length >= MAX_CUES) {
2548
+ problems.push(`more than ${MAX_CUES} cues on one line; the rest are dropped`);
2549
+ break;
2550
+ }
2551
+ const m = /^(\d+(?:\.\d+)?|\.\d+)\s*:\s*([a-z][a-z_]*)([^a-z_].*)?$/.exec(raw);
2552
+ if (!m) {
2553
+ problems.push(`"${raw}" is not <beats>:<move>`);
2554
+ continue;
2555
+ }
2556
+ const beats = Number(m[1]);
2557
+ if (!(beats > 0)) {
2558
+ problems.push(`"${raw}": a cue needs some beats`);
2559
+ continue;
2560
+ }
2561
+ if (!known.has(m[2])) {
2562
+ problems.push(`"${raw}": no move "${m[2]}" (moves: ${moves.join(" ")})`);
2563
+ continue;
2564
+ }
2565
+ const cue = { b0, beats: Math.min(beats, 512), move: m[2], turn: 0, lift: 0 };
2566
+ for (const tok of (m[3] ?? "").replace(/^[,\s]+/, "").split(/\s+/).filter(Boolean)) {
2567
+ const mod = /^([>@~^/%*])(.+)$/.exec(tok);
2568
+ if (!mod) {
2569
+ problems.push(`"${raw}": "${tok}" is not >to, @face, ~turn, ^leap, /lean, %crouch or *groove`);
2570
+ continue;
2571
+ }
2572
+ const [, op, arg] = mod;
2573
+ if (op === ">") {
2574
+ const mark = CHOREO_MARKS[arg];
2575
+ const xy = /^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)$/.exec(arg);
2576
+ if (mark) cue.to = mark;
2577
+ else if (xy) cue.to = [clamp(Number(xy[1]), -6, 6), clamp(Number(xy[2]), -6, 6)];
2578
+ else problems.push(`"${raw}": travel to "${arg}" \u2014 a mark (${Object.keys(CHOREO_MARKS).join(" ")}) or side,depth`);
2579
+ } else if (op === "@") {
2580
+ const named = { cam: "cam", room: 0, front: 0, back: 180, left: -90, right: 90 };
2581
+ if (arg in named) cue.face = named[arg];
2582
+ else if (Number.isFinite(Number(arg))) cue.face = Number(arg);
2583
+ else problems.push(`"${raw}": face "${arg}" \u2014 degrees, cam, room, left, right or back`);
2584
+ } else if (op === "/") {
2585
+ if (Number.isFinite(Number(arg))) cue.lean = clamp(Number(arg), -60, 60);
2586
+ else problems.push(`"${raw}": lean "${arg}" is not degrees`);
2587
+ } else if (op === "%") {
2588
+ if (Number.isFinite(Number(arg)) && Number(arg) > 0) cue.squash = clamp(Number(arg), 0.4, 1.5);
2589
+ else problems.push(`"${raw}": crouch "${arg}" is not a height scale`);
2590
+ } else if (op === "*") {
2591
+ if (Number.isFinite(Number(arg)) && Number(arg) > 0) cue.groove = clamp(Number(arg), 0.02, 0.3);
2592
+ else problems.push(`"${raw}": groove "${arg}" is not a depth`);
2593
+ } else if (op === "~") {
2594
+ if (Number.isFinite(Number(arg))) cue.turn = clamp(Number(arg), -1440, 1440);
2595
+ else problems.push(`"${raw}": turn "${arg}" is not degrees`);
2596
+ } else {
2597
+ if (Number.isFinite(Number(arg)) && Number(arg) >= 0) cue.lift = Math.min(Number(arg), 3);
2598
+ else problems.push(`"${raw}": leap "${arg}" is not a height`);
2599
+ }
2600
+ }
2601
+ cues.push(cue);
2602
+ b0 += cue.beats;
2603
+ }
2604
+ if (cues.length) lines.set(actor, { cues, beats: b0 });
2605
+ else problems.push(`line "${rawLine}" has no cues`);
2606
+ }
2607
+ return { lines, problems };
2608
+ }
2609
+ var CHOREO_HANDOVER = 0.5;
2610
+ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
2611
+ var smooth3 = (u) => {
2612
+ const c = clamp(u, 0, 1);
2613
+ return c * c * (3 - 2 * c);
2614
+ };
2615
+ var posMod = (v, m) => (v % m + m) % m;
2616
+ function choreoLine(sheet, slot) {
2617
+ return sheet?.lines.get(slot) ?? sheet?.lines.get("*") ?? null;
2618
+ }
2619
+ function choreoAt(line, beats) {
2620
+ const b = posMod(beats, line.beats);
2621
+ const n = line.cues.length;
2622
+ let i = 0;
2623
+ while (i < n - 1 && b >= line.cues[i].b0 + line.cues[i].beats) i++;
2624
+ const cue = line.cues[i], next = line.cues[(i + 1) % n], prev = line.cues[(i + n - 1) % n];
2625
+ let side = 0, depth = 0, centre = 0, face = 0, faceCam = 0, turn = 0;
2626
+ const end = (c) => {
2627
+ if (c.to === "centre") centre = 1;
2628
+ else if (c.to) {
2629
+ side = c.to[0];
2630
+ depth = c.to[1];
2631
+ centre = 0;
2632
+ }
2633
+ turn += c.turn;
2634
+ };
2635
+ const facing = (c) => {
2636
+ if (c.face === "cam") faceCam = 1;
2637
+ else if (c.face !== void 0) {
2638
+ face = c.face;
2639
+ faceCam = 0;
2640
+ }
2641
+ };
2642
+ for (let k = 0; k < i; k++) {
2643
+ end(line.cues[k]);
2644
+ facing(line.cues[k]);
2645
+ }
2646
+ facing(cue);
2647
+ const beat = b - cue.b0, u = clamp(beat / cue.beats, 0, 1);
2648
+ const s = smooth3(u);
2649
+ if (cue.to === "centre") centre += (1 - centre) * s;
2650
+ else if (cue.to) {
2651
+ side += (cue.to[0] - side) * s;
2652
+ depth += (cue.to[1] - depth) * s;
2653
+ centre *= 1 - s;
2654
+ }
2655
+ turn += cue.turn * s;
2656
+ const lift = cue.lift > 0 ? cue.lift * Math.sin(Math.PI * u) : 0;
2657
+ const last = i === n - 1;
2658
+ const h = Math.min(CHOREO_HANDOVER, cue.beats / 2, next.beats / 2);
2659
+ const ahead = smooth3((beat - (cue.beats - h)) / h);
2660
+ if (ahead > 0) {
2661
+ if (next.face === "cam") faceCam += (1 - faceCam) * ahead;
2662
+ else if (next.face !== void 0 && !last) {
2663
+ face += angleTo(face, next.face) * ahead;
2664
+ faceCam *= 1 - ahead;
2665
+ } else if (last) {
2666
+ face += angleTo(face, next.face === void 0 ? 0 : next.face) * ahead;
2667
+ faceCam *= 1 - ahead;
2668
+ }
2669
+ }
2670
+ const lean = (cue.lean ?? 0) + ((next.lean ?? 0) - (cue.lean ?? 0)) * ahead;
2671
+ let squash = (cue.squash ?? 1) + ((next.squash ?? 1) - (cue.squash ?? 1)) * ahead;
2672
+ if (next.lift > 0) squash *= 1 - 0.16 * ahead;
2673
+ if (cue.lift > 0) squash *= u < 0.15 ? 0.84 + 0.26 * smooth3(u / 0.15) : 1 + 0.1 * Math.sin(Math.PI * u) - 0.16 * smooth3((u - 0.85) / 0.15);
2674
+ if (prev.lift > 0 && n > 1) squash *= 0.84 + 0.16 * smooth3(beat / Math.min(0.75, cue.beats));
2675
+ const groove = cue.groove ?? 0;
2676
+ if (groove > 0 && cue.lift === 0) squash *= 1 - groove * (1 + Math.cos(2 * Math.PI * posMod(beats, 1))) / 2;
2677
+ return { cue, beat, u, next, ahead, side, depth, centre, face, faceCam, turn, lift, lean, squash };
2678
+ }
2679
+ function angleTo(a, b) {
2680
+ return posMod(b - a + 180, 360) - 180;
2681
+ }
2682
+
2498
2683
  // src/vignette.ts
2499
2684
  var DANCES = ["toprock", "footwork", "windmill", "headspin", "freeze", "drop"];
2500
- var GESTURES = ["talk", "nod", "shake", "hop", "spin", "wiggle", "rest", "peek", "bow", "ink", ...DANCES];
2501
- var BREED_GESTURES = { ink: ["octopus"] };
2685
+ var GESTURES = ["talk", "nod", "shake", "hop", "spin", "wiggle", "rest", "peek", "bow", "ink", "puff", ...DANCES];
2686
+ var BREED_GESTURES = { ink: ["octopus"], puff: ["blowfish"] };
2502
2687
  var OPEN_MARKS = {
2503
2688
  centre: { x: 0, y: 36, z: 0 },
2504
2689
  left: { x: -70, y: 34, z: 0 },
@@ -2506,30 +2691,32 @@ var OPEN_MARKS = {
2506
2691
  front: { x: 0, y: 30, z: 60 },
2507
2692
  back: { x: 0, y: 40, z: -70 },
2508
2693
  high: { x: 10, y: 62, z: -10 },
2509
- low: { x: -10, y: 18, z: 10 }
2694
+ low: { x: -10, y: 18, z: 10 },
2695
+ // Behind the mic stand (`stageSet: club` stands it here, facing front).
2696
+ mic: { x: 0, y: 36, z: -4, solo: true }
2510
2697
  };
2511
- var ACTOR = "abc";
2698
+ var ACTOR = "abcd";
2512
2699
  var CRUISE = 17;
2513
2700
  var STANDOFF = 20;
2514
2701
  function spot(mark, actor, actors) {
2515
- if (actors === 1) return mark;
2702
+ if (actors === 1 || mark.solo) return mark;
2516
2703
  const a = actor / actors * Math.PI * 2 + 0.6;
2517
2704
  return { x: mark.x + Math.cos(a) * STANDOFF, y: mark.y + (actor % 2 ? 2.5 : 0), z: mark.z + Math.sin(a) * STANDOFF };
2518
2705
  }
2519
- function parseVignette(script, marks) {
2706
+ function parseVignette(script, marks, audience = []) {
2520
2707
  const problems = [];
2521
2708
  const raw = String(script ?? "").split(/[|\n]/).map((b) => b.trim()).filter(Boolean);
2522
2709
  let actors = 0;
2523
2710
  for (const beat of raw) {
2524
2711
  for (const cue of beat.replace(/^\d+(?:\.\d+)?s\s{0,16}:/, "").split(",")) {
2525
2712
  for (const word of cue.trim().split(/\s+/)) {
2526
- if (!/^[abc]$/.test(word)) break;
2713
+ if (!/^[abcd]$/.test(word)) break;
2527
2714
  actors = Math.max(actors, ACTOR.indexOf(word) + 1);
2528
2715
  }
2529
2716
  }
2530
2717
  }
2531
- if (!raw.length || !actors) return { actors: 0, beats: [], duration: 0, problems: raw.length ? ["no actor (a, b, c) named"] : [] };
2532
2718
  const centre = marks.centre ?? marks.rug ?? OPEN_MARKS.centre;
2719
+ if (!raw.length || !actors) return { actors: 0, beats: [], duration: 0, problems: raw.length ? ["no actor (a\u2013d) named"] : [], seats: /* @__PURE__ */ new Map(), centre };
2533
2720
  let at = Array.from({ length: actors }, (_, i) => spot(centre, i, actors));
2534
2721
  const start = [];
2535
2722
  const beats = [];
@@ -2547,7 +2734,7 @@ function parseVignette(script, marks) {
2547
2734
  for (const cue of body.split(",").map((c) => c.trim()).filter(Boolean)) {
2548
2735
  const words = cue.split(/\s+/);
2549
2736
  const who = [];
2550
- while (words.length && /^[abc]$/.test(words[0])) who.push(ACTOR.indexOf(words.shift()));
2737
+ while (words.length && /^[abcd]$/.test(words[0])) who.push(ACTOR.indexOf(words.shift()));
2551
2738
  if (!who.length) {
2552
2739
  problems.push(`beat ${bi + 1}: "${cue}" names no actor`);
2553
2740
  continue;
@@ -2555,7 +2742,7 @@ function parseVignette(script, marks) {
2555
2742
  for (let w = 0; w < words.length; w += 1) {
2556
2743
  const word = words[w];
2557
2744
  const target = (name) => {
2558
- if (/^[abc]$/.test(name) && ACTOR.indexOf(name) < actors) return { actor: ACTOR.indexOf(name), point: null };
2745
+ if (/^[abcd]$/.test(name) && ACTOR.indexOf(name) < actors) return { actor: ACTOR.indexOf(name), point: null };
2559
2746
  const mk = marks[name];
2560
2747
  if (mk) return { actor: null, point: mk };
2561
2748
  problems.push(`beat ${bi + 1}: no mark or actor "${name}" (marks: ${Object.keys(marks).join(", ")})`);
@@ -2627,7 +2814,9 @@ function parseVignette(script, marks) {
2627
2814
  beats.push({ t0, dur: length, actors: start.map((p) => ({ to: p, moved: true, faceActor: null, facePoint: null, gesture: null, circle: null, follow: null })) });
2628
2815
  t0 += length;
2629
2816
  }
2630
- return { actors, beats, duration: t0, problems };
2817
+ const seats = /* @__PURE__ */ new Map();
2818
+ for (const slot of audience) if (slot >= actors && !seats.has(slot)) seats.set(slot, seats.size);
2819
+ return { actors, beats, duration: t0, problems, seats, centre };
2631
2820
  }
2632
2821
  var smoother = (u) => {
2633
2822
  const c = Math.min(1, Math.max(0, u));
@@ -2682,7 +2871,10 @@ function poseAcross(v, actor, tSec, dt, own) {
2682
2871
  return poseOf(v, actor, snapBetween(v, actor, tSec, tSec + dt) ? tSec : tSec + dt, own);
2683
2872
  }
2684
2873
  function poseOf(v, actor, tSec, own) {
2685
- if (actor >= v.actors || !v.duration) return null;
2874
+ if (!v.duration) return null;
2875
+ const seat = v.seats.get(actor);
2876
+ if (seat !== void 0) return crowdPose(v, seat, tSec, own);
2877
+ if (actor >= v.actors) return null;
2686
2878
  const t = (tSec % v.duration + v.duration) % v.duration;
2687
2879
  let bi = v.beats.findIndex((b) => t < b.t0 + b.dur);
2688
2880
  if (bi < 0) bi = v.beats.length - 1;
@@ -2776,6 +2968,7 @@ function poseOf(v, actor, tSec, own) {
2776
2968
  pitch = -0.08 * e;
2777
2969
  break;
2778
2970
  // ink: the body holds still; the octopus squirts its cloud (the tank's InkLayer) and pales.
2971
+ // puff: the body holds still; the blowfish gulps itself round (puffer.ts PufferInput.puffCue).
2779
2972
  // The dances, for a fish with no clips for them: the whole body does what it can.
2780
2973
  case "toprock":
2781
2974
  roll += Math.sin(bt * Math.PI * 2) * 0.3 * e;
@@ -2819,6 +3012,152 @@ function poseOf(v, actor, tSec, own) {
2819
3012
  }
2820
3013
  return { x, y: Math.max(9, y), z, fx, fy, fz, roll, doing, lookAt: target ?? null, ...gesture ? { gesture } : {} };
2821
3014
  }
3015
+ var CROWD = {
3016
+ /** Rows out from the stage's middle, front (nearest the camera) first: distance, height, seats.
3017
+ * Raked: each row higher than the one before, so the back rows see over the front. */
3018
+ rows: [{ r: 48, y: 15, n: 6 }, { r: 64, y: 21, n: 8 }, { r: 80, y: 27, n: 10 }],
3019
+ /** How far round each row reaches, radians either side of straight out front. */
3020
+ arc: 0.7,
3021
+ /** A laugh: it breaks this long before the talk ends, and rolls on this long. */
3022
+ lead: 0.45,
3023
+ laugh: 2.8,
3024
+ /** Seconds a seat can be late to a laugh or the applause. */
3025
+ late: 0.55,
3026
+ /** One seat in this many is a tough crowd: it chuckles where the rest laugh. */
3027
+ tough: 5,
3028
+ /** Seconds the room's heads take to follow the stage: they look where it was, a moment ago. */
3029
+ follow: 1.05
3030
+ };
3031
+ var seatHash = (seat, k = 0) => {
3032
+ const s = Math.sin(seat * 127.1 + k * 311.7 + 74.7) * 43758.5453;
3033
+ return s - Math.floor(s);
3034
+ };
3035
+ function crowdSeat(v, seat) {
3036
+ let row = 0, first = 0;
3037
+ while (row < CROWD.rows.length - 1 && seat >= first + CROWD.rows[row].n) {
3038
+ first += CROWD.rows[row].n;
3039
+ row += 1;
3040
+ }
3041
+ const r = CROWD.rows[row];
3042
+ const inRow = Math.min(r.n, v.seats.size - first);
3043
+ const mid = (inRow - 1) / 2;
3044
+ const order = Array.from({ length: inRow }, (_, p) => p).sort((p, q) => Math.abs(p - mid) - Math.abs(q - mid) || q - p);
3045
+ const i = order[seat - first] ?? seat - first;
3046
+ const step = CROWD.arc * 2 / Math.max(1, r.n - 1);
3047
+ const a = (i - (inRow - 1) / 2) * step + (seatHash(seat, 1) - 0.5) * step * 0.3;
3048
+ const d = r.r + (seatHash(seat, 2) - 0.5) * 5;
3049
+ 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 };
3050
+ }
3051
+ var REACTIONS = /* @__PURE__ */ new WeakMap();
3052
+ function reactionsOf(v) {
3053
+ let r = REACTIONS.get(v);
3054
+ if (r) return r;
3055
+ r = { laughs: [], claps: [] };
3056
+ for (const b of v.beats) {
3057
+ if (b.actors.some((a) => a.gesture === "talk")) r.laughs.push(b.t0 + b.dur);
3058
+ if (b.actors.some((a) => a.gesture === "bow")) r.claps.push([b.t0, b.t0 + b.dur]);
3059
+ }
3060
+ REACTIONS.set(v, r);
3061
+ return r;
3062
+ }
3063
+ function holderAt(v, tSec) {
3064
+ const t = (tSec % v.duration + v.duration) % v.duration;
3065
+ const beat = v.beats.find((b) => t < b.t0 + b.dur) ?? v.beats[v.beats.length - 1];
3066
+ const talking = beat.actors.findIndex((a) => a.gesture === "talk");
3067
+ if (talking >= 0) return talking;
3068
+ let best = 0, bestD = Infinity;
3069
+ beat.actors.forEach((a, i) => {
3070
+ const d = Math.hypot(a.to.x - v.centre.x, a.to.z - v.centre.z);
3071
+ if (d < bestD) {
3072
+ bestD = d;
3073
+ best = i;
3074
+ }
3075
+ });
3076
+ return best;
3077
+ }
3078
+ function within(v, t, start, len, out) {
3079
+ for (const shift of [-1, 0, 1]) {
3080
+ const u = (t - start - shift * v.duration) / len;
3081
+ if (u > 0 && u < 1) {
3082
+ if (out) out.shift = shift;
3083
+ return u;
3084
+ }
3085
+ }
3086
+ return -1;
3087
+ }
3088
+ function crowdPose(v, seat, tSec, own) {
3089
+ const p = crowdSeat(v, seat);
3090
+ const t = (tSec % v.duration + v.duration) % v.duration;
3091
+ let gx = 0, gy = 0, gz = 0;
3092
+ const taps = 4;
3093
+ for (let k = 0; k < taps; k++) {
3094
+ const tk = tSec - k / (taps - 1) * CROWD.follow;
3095
+ const m = placeOf(v, holderAt(v, tk), tk);
3096
+ gx += m.x / taps;
3097
+ gy += m.y / taps;
3098
+ gz += m.z / taps;
3099
+ }
3100
+ const look = { x: gx, y: gy, z: gz };
3101
+ let fx = gx - p.x, fy = (gy - p.y) * 0.4, fz = gz - p.z;
3102
+ let fl = Math.hypot(fx, fy, fz) || 1;
3103
+ fx /= fl;
3104
+ fy /= fl;
3105
+ fz /= fl;
3106
+ const { x, z } = p;
3107
+ let y = p.y + Math.sin(tSec * 1.1 + seat * 2.3) * 0.7;
3108
+ let roll = Math.sin(tSec * 0.7 + seat) * 0.03;
3109
+ let pitch = 0;
3110
+ let doing = "watching";
3111
+ let gesture;
3112
+ const { laughs, claps } = reactionsOf(v);
3113
+ const late = seatHash(seat, 4) * CROWD.late;
3114
+ let lu = -1, lat = 0, lk = 0;
3115
+ const loop = { shift: 0 }, lap = Math.floor(tSec / v.duration);
3116
+ let burst;
3117
+ for (let k = 0; k < laughs.length; k++) {
3118
+ const at = laughs[k];
3119
+ const len = CROWD.laugh * (0.8 + 0.45 * seatHash(seat, 10 + k));
3120
+ const u = within(v, t, at - CROWD.lead + late, len, loop);
3121
+ if (u > lu) {
3122
+ lu = u;
3123
+ lat = u * len;
3124
+ lk = k;
3125
+ }
3126
+ }
3127
+ if (lu > 0) {
3128
+ const e = Math.min(1, lu * 6) * (1 - smoother((lu - 0.35) / 0.65));
3129
+ const tough = seatHash(seat, 5) < 1 / CROWD.tough;
3130
+ const amp = tough ? 0.4 : 0.75 + 0.5 * seatHash(seat, 6);
3131
+ const hz = 5.5 + 2.5 * seatHash(seat, 7);
3132
+ doing = "laughing";
3133
+ pitch = (0.3 + Math.sin(lat * hz * 2) * 0.1) * e * amp;
3134
+ y += Math.abs(Math.sin(lat * hz)) * 3.4 * e * amp;
3135
+ roll += Math.sin(lat * hz) * 0.16 * e * amp;
3136
+ if (own?.("talk")) gesture = { name: "talk", at: lat, bar: lat, weight: e * amp };
3137
+ if (!tough) {
3138
+ const start = laughs[lk] - CROWD.lead + late + (lap + loop.shift) * v.duration;
3139
+ burst = { key: `crowd:${seat}:${lk}:${lap + loop.shift}`, t: start + 0.25 };
3140
+ }
3141
+ }
3142
+ for (const [a, b] of claps) {
3143
+ const u = within(v, t, a + late, b - a);
3144
+ if (u <= 0) continue;
3145
+ const e = bell(u) ** 0.6, ct = u * (b - a);
3146
+ doing = "applauding";
3147
+ y += Math.abs(Math.sin(ct * 6.5)) * 1.3 * e;
3148
+ roll += Math.sin(ct * 13) * 0.22 * e;
3149
+ if (own?.("wiggle")) gesture = { name: "wiggle", at: ct, bar: ct, weight: e };
3150
+ break;
3151
+ }
3152
+ if (pitch) {
3153
+ fy += Math.tan(pitch) * Math.hypot(fx, fz);
3154
+ fl = Math.hypot(fx, fy, fz) || 1;
3155
+ fx /= fl;
3156
+ fy /= fl;
3157
+ fz /= fl;
3158
+ }
3159
+ return { x, y: Math.max(9, y), z, fx, fy, fz, roll, doing, lookAt: look, ...gesture ? { gesture } : {}, ...burst ? { burst } : {} };
3160
+ }
2822
3161
  var polar = (deg, r, y) => ({ x: Math.cos(deg * Math.PI / 180) * r, y, z: Math.sin(deg * Math.PI / 180) * r });
2823
3162
  var INTERIOR_MARK_POLAR = {
2824
3163
  door: { deg: 28, r: 124, y: 22 },
@@ -2909,11 +3248,48 @@ var VIGNETTES = {
2909
3248
  "6s: a @c bow, b @c bow, c >front bow",
2910
3249
  "5s: a >left, b >right, c @front shake",
2911
3250
  "5s: c >back"
3251
+ ].join(" | "),
3252
+ // Open mic: a host (d) brings on three comics (a b c) in turn — each two bits and a bow — and sends them off;
3253
+ // the two waiting stand in the wings and watch whoever has the mic, nodding along when the room laughs. Then
3254
+ // a goodnight from the host, and the company bows from where it stands. Written for an `@audience` out front (it laughs as each talk
3255
+ // ends), `stageSet: club` and one spot standing on the mic (`spotRig: @mic`): whoever steps up is lit.
3256
+ // 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:-
3257
+ openmic: [
3258
+ "4s: a =left, b =right, c =left, d =right",
3259
+ "5s: d >mic @front, a @mic nod, b @mic nod, c @mic nod",
3260
+ "5s: d @front talk, a @mic, b @mic, c @mic",
3261
+ "3s: d @a wiggle, a @mic nod, b @mic nod, c @mic nod",
3262
+ "5s: d >right, a >mic @front, b @mic, c @mic",
3263
+ "6s: a @front talk, b @mic, c @mic, d @mic nod",
3264
+ "3s: a @front nod, b @mic nod, c @mic nod, d @mic nod",
3265
+ "6s: a @front talk, b @mic, c @mic, d @mic",
3266
+ "3s: a @front bow, b @mic nod, c @mic nod, d @mic nod",
3267
+ "5s: a >left, d >mic @front, b @mic, c @mic",
3268
+ "4s: d @front talk, a @mic nod, b @mic, c @mic",
3269
+ "3s: d @b wiggle, a @mic, b @mic nod, c @mic",
3270
+ "5s: d >right, b >mic @front, a @mic, c @mic",
3271
+ "6s: b @front talk, a @mic, c @mic, d @mic nod",
3272
+ "3s: b @front rest, a @mic nod, c @mic nod, d @mic nod",
3273
+ "6s: b @front talk, a @mic, c @mic, d @mic",
3274
+ "3s: b @front bow, a @mic nod, c @mic nod, d @mic nod",
3275
+ "5s: b >right, d >mic @front, a @mic, c @mic",
3276
+ "4s: d @front talk, a @mic, b @mic nod, c @mic",
3277
+ "3s: d @c wiggle, a @mic, b @mic, c @mic nod",
3278
+ "5s: d >right, c >mic @front, a @mic, b @mic",
3279
+ "6s: c @front talk, a @mic, b @mic, d @mic nod",
3280
+ "3s: c @front shake, a @mic nod, b @mic nod, d @mic nod",
3281
+ "6s: c @front talk, a @mic, b @mic, d @mic",
3282
+ "3s: c @front bow, a @mic nod, b @mic nod, d @mic nod",
3283
+ "5s: c >left, d >mic @front, a @mic, b @mic",
3284
+ "5s: d @front talk, a @mic nod, b @mic, c @mic nod",
3285
+ "4s: a @front bow, b @front bow, c @front bow, d @front bow",
3286
+ "5s: d >right, a @mic, b @mic, c @mic"
2912
3287
  ].join(" | ")
2913
3288
  };
2914
3289
  var VIGNETTE_CUES = {
2915
3290
  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:-"
3291
+ 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:-",
3292
+ 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
3293
  };
2918
3294
  function resolveVignette(value) {
2919
3295
  const v = String(value ?? "").trim();
@@ -2926,23 +3302,23 @@ var PARAM_DOCS = {
2926
3302
  cameraAzimuth: "Orbit bearing in degrees. 0 looks at the village from the front.",
2927
3303
  cameraElevation: "Degrees above the floor. 8\u201315 is eye level with the fish; 40+ looks down on a castle.",
2928
3304
  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.",
3305
+ shot: "A framing: `orbit`, `hero` (three-quarter), `front` (level, long lens), `low`, `top` (a map), `surface`, `macro` (nearest landmark), `cast` (the mix's first three). Azimuth and autoRotate turn it, distance scales it.",
2930
3306
  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.",
3307
+ 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
3308
  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.",
3309
+ 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
3310
  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
3311
  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
- nearFade: "A creature at the lens dissolves instead of being sliced, 0\u20131. The followed fish never fades.",
3312
+ nearFade: "A creature at the lens dissolves, not sliced, 0\u20131. The followed fish never fades.",
2937
3313
  // cast
2938
3314
  fishCount: "How many fish when `fishMix` is empty (all are `fishUrl`).",
2939
3315
  finish: "A final full-screen pass: gentle grade, soft vignette, dither against banding, and restrained bloom on high-end devices (0 = none; never on the low tier).",
2940
3316
  tiltShift: "Tilt-shift, 0\u20131: sharp through the middle, soft above and below, like a model. Mid/high tiers.",
2941
- depthOfField: "Depth of field, 0\u20131: the subject sharp (followed fish, `cast` leads), the rest soft. High tier.",
3317
+ depthOfField: "Depth of field, 0\u20131: the subject (followed fish, `cast` leads) sharp, the rest soft. High tier.",
2942
3318
  ambientOcclusion: "Ambient occlusion, 0\u20131: crevices darken. High tier.",
2943
- 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.",
3319
+ highlights: "Bright light rolls off instead of clipping, 0\u20131: a fish in a shaft stays a fish. Mid/high tiers.",
2944
3320
  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.",
3321
+ fishMix: "The cast: `id[:count][@style]`, comma-separated: `100:2@drift, 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: `starfishDance`, `choreography`), snail. `dot` (a box on legs), `dolphin` (flips, breaches) play vignette gestures. Overrides fishCount/fishUrl.",
2946
3322
  dracoPath: "Where the Draco decoder lives; leave empty on a channel. Only the page's own origin or the shipped copy is used.",
2947
3323
  swimSpeed: "Global swim speed multiplier. 0.4 is contemplative, 1 lively.",
2948
3324
  swimStyle: "How the cast swims unless a fishMix entry says otherwise (`@style`). `auto` picks by breed.",
@@ -2965,15 +3341,18 @@ var PARAM_DOCS = {
2965
3341
  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
3342
  fishGlow: "How strongly GLOW- parts of a fish bloom and light their neighbours and the floor.",
2967
3343
  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.",
2970
- 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.",
3344
+ 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.",
3345
+ starfishDance: "Starfish dance: `aerobics` (a class in unison), `freestyle` (a move each per eight counts), `duet` (couples), `off`. Cast a few: `starfish:7`.",
3346
+ danceTempo: "Dance BPM; glides on the beat.",
3347
+ choreography: "Starfish dance cues, on the beat (see grammar).",
3348
+ 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
3349
  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
- fishShadows: "Soft shadows on the ground under the swimmers, 0\u20131: dark near the sand, faint high up.",
2974
- fishFadeIn: "Seconds an arriving fish takes to dissolve in (0 = it pops in).",
2975
- 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.",
3350
+ fishShadows: "Soft shadows under the swimmers, 0\u20131: dark near the sand, faint high up.",
3351
+ fishAO: "A fish's own corners darken, 0\u20131: solid fish. All tiers.",
3352
+ fishFadeIn: "Seconds an arriving fish takes to fade in (0 pops in).",
3353
+ sceneryShadows: "Soft shadows under rocks, homes, castle, pyramids, geodes, lamps, 0\u20131: nothing floats.",
3354
+ voxelAO: "Plant cubes darken where they touch each other and the sand, 0\u20131. All tiers.",
3355
+ 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
3356
  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
3357
  eyeLife: "Eyes blink, look, and emote (0 = painted on). Redraws each token's own pixel-grid eye; black and white only.",
2979
3358
  // water and room
@@ -2983,21 +3362,21 @@ var PARAM_DOCS = {
2983
3362
  floorColor: "Sea-floor colour.",
2984
3363
  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
3364
  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.",
3365
+ 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
3366
  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
3367
  moteDensity: "Suspended motes.",
2989
3368
  moteColor: "Their colour.",
2990
3369
  environment: "A room preset (floor, ceiling, light shafts). `void` for the mineral-world scenes.",
2991
3370
  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
3371
  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.",
3372
+ 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
3373
  raySoftness: "Shafts as soft volume, not flat sheets, 0\u20131. 1 for anything cinematic.",
2995
- floorDetail: "A seabed, not a coloured plane, 0\u20131: ripples, silt, grain; the floor's far edge fades into the fog.",
3374
+ floorDetail: "A seabed, not a coloured plane, 0\u20131: ripples, silt, grain; its far edge fades into the fog.",
2996
3375
  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
3376
  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.",
3377
+ 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
3378
  // 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`.",
3379
+ 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
3380
  envProps: "`on` lets the environment preset plant its own crystals when `propMix` is empty.",
3002
3381
  crystalScale: "Size of the whole mineral world (crystals, rocks, homes, flora).",
3003
3382
  crystalWild: "Organic individuality: lean, bald sides, branching. 0 is a catalogue crystal, 1 is coral.",
@@ -3006,30 +3385,32 @@ var PARAM_DOCS = {
3006
3385
  crystalTint: "How much nearby light sources colour a passing fish. 0.5\u20130.7 ties the cast to the place.",
3007
3386
  rockDensity: "Faceted boulders, an arch and a back ridge around the crystals.",
3008
3387
  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.",
3388
+ 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
3389
  stoneStyle: "`weathered`: river-grey rocks and homes with silt on top, not dark holes in a lit room. `classic` (default): dark slate.",
3011
3390
  interior: "`geode` sets the whole scene INSIDE a geode home: furniture, chandelier, marks like `table`, `bed`, `door`.",
3012
3391
  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.",
3392
+ 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
3393
  geodeMix: "Which geodes: `kind[:weight]`, comma-separated. geode cathedral cluster cavern (the cavern is one at most).",
3015
3394
  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.",
3395
+ 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`.",
3396
+ 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`.",
3397
+ 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.",
3398
+ 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.",
3399
+ 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.",
3400
+ 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
3401
  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).",
3402
+ 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.",
3403
+ 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
3404
  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.",
3405
+ 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
3406
  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
3407
  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
3408
  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.",
3409
+ 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.",
3410
+ 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.",
3411
+ 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
3412
  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.",
3413
+ 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
3414
  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
3415
  co2Mist: "A fine haze of tiny bubbles from the vents, drifting on a slow current (0 = none).",
3035
3416
  marineSnow: "Drifting lit particles; gives the water depth.",
@@ -3041,19 +3422,19 @@ var PARAM_DOCS = {
3041
3422
  landmark: "One thing bigger than everything else. `castle`: voxel walls, crystal-spired towers, gate, road, plaza. `citadel`: two storeys. Marks: `gate`, `plaza`, `courtyard`.",
3042
3423
  // stage
3043
3424
  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.",
3425
+ 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
3426
  spotColor: "Colour of the single `followSpot`.",
3046
3427
  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.",
3048
- 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."
3428
+ 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.",
3429
+ spotCues: "Looping cues for the rig: `8s:a, 12s:a+b, 4s:-` (`-` blackout, cues \u2265 2 s). Spots cross-fade.",
3430
+ 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
3431
  };
3051
3432
  var GRAMMAR = `
3052
3433
  fishMix id[:count][@style][*size], \u2026 100:2@drift, 257:1, seahorse:3, shark:1@patrol*1.5
3053
3434
  id = a minted token (1\u2013512) or a breed (betafish angelfish seahorse seaturtle). Minted fish are
3054
3435
  individuals: a count casts DISTINCT neighbours. Bundled creatures (a count is copies): shark
3055
3436
  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.
3437
+ breed's own size. Styles: loop school drift hover patrol bottom surface follow pair chase audience.
3057
3438
  propMix crystal[#id][:count][@habit][/palette][*size], \u2026 crystal:3@druse/rainbow, crystal:1@spire/cyan*6
3058
3439
  habits lotus spire druse scatter coral \xB7 palettes env rainbow blue hotpink purple seafoam yellow orange cyan white glass
3059
3440
  floraMix species[:weight], \u2026 kelp:3, whip, seapen \xB7 staghorn:2, brain, anemone:2, clam
@@ -3069,17 +3450,19 @@ pyramidShow auto | solo | routine | <sec>s:<routine>, \u2026 8s:rings, 8s:bea
3069
3450
  geodeMix kind[:weight], \u2026 geode cathedral cluster cavern geode:3, cathedral, cluster
3070
3451
  geodeMineral world | mineral, \u2026 amethyst agate celestine citrine carnelian rose emerald quartz smoky
3071
3452
  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
3453
+ spotRig slot|@mark[/color][*radius], \u2026 (\u22643; they become a, b, c) 0/#ffd27a*24, 1/#ff8ad0*24, 2/#7fdcff*24
3073
3454
  spotCues <sec>s:<spots>, \u2026 loops 4s:-, 7s:a, 7s:b, 12s:a+b
3455
+ choreography [a|*:] <beats>:<move> [>to @face ~turn ^leap /lean %crouch *groove],\u2026|\u2026 moves ${STARFISH_MOVE_NAMES.join(" ")}
3074
3456
  signs kind[@place][>target][/#color][*size][:text], \u2026 plank@gate:"Welcome to the reef", neon@home1/#ff4fa0:OPEN, led@plaza:TIDE 4.2M
3075
3457
  kinds plank arrow ring porthole neon led \xB7 places gate plaza courtyard hub fountain home1 home2 home3 centre left right front back
3076
3458
  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
3459
+ vignette beats separated by |, cues by comma; actors a b c d are fish slots 0 1 2 3
3078
3460
  [<sec>s:] actor =mark be there, no travel (a later beat's = snaps at its start) actor >mark swim there
3079
3461
  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
- actor follow actor gestures: talk nod shake hop spin wiggle rest peek bow \xB7 ink (octopus only: a cloud of ink)
3462
+ actor follow actor gestures: talk nod shake hop spin wiggle rest peek bow \xB7 ink (octopus) \xB7 puff (blowfish)
3081
3463
  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
3464
  Give every beat a duration and end where you began and a spotCues sheet stays in step forever.
3465
+ Fish cast @audience sit out front facing the stage: they laugh as each talk ends and applaud each bow.
3083
3466
  Marks outdoors: ${Object.keys(OPEN_MARKS).join(" ")} \u2014 plus the world's own:
3084
3467
  home1 home1in \u2026 (geodeHomes), gate plaza courtyard (landmark), hub (paths), fountain (fountain).
3085
3468
  Marks indoors (interior: geode): ${Object.keys(INTERIOR_MARKS).join(" ")}
@@ -3089,7 +3472,7 @@ vignette beats separated by |, cues by comma; actors a b c are fish slots 0 1 2
3089
3472
  var NIGHT = { fogColor: "#0a0a1a", fogNear: 120, fogFar: 700 };
3090
3473
  var STAGE = { fogColor: "#01030a", floorColor: "#0a1322", fogNear: 120, fogFar: 650, autoRotate: 0 };
3091
3474
  var ALIVE = { eyeLife: 1 };
3092
- var CINEMATIC = { highlights: 1, raySoftness: 1, nearFade: 1, finish: 0.6 };
3475
+ var CINEMATIC = { highlights: 1, raySoftness: 1, nearFade: 1, voxelAO: 1, fishAO: 1, finish: 0.6 };
3093
3476
  var RECIPES = [
3094
3477
  {
3095
3478
  id: "geode-harbor",
@@ -3273,6 +3656,28 @@ var RECIPES = [
3273
3656
  cameraAzimuth: 0
3274
3657
  }
3275
3658
  },
3659
+ {
3660
+ id: "open-mic",
3661
+ label: "Open mic",
3662
+ what: "A comedy club: a host brings three comic dolphins to the mic in turn; a seated crowd laughs and applauds.",
3663
+ params: {
3664
+ ...ALIVE,
3665
+ ...STAGE,
3666
+ floorColor: "#1a1210",
3667
+ vignette: "openmic",
3668
+ stageSet: "club",
3669
+ spotRig: "@mic/#ffc890*26",
3670
+ spotCues: VIGNETTE_CUES.openmic,
3671
+ spotStrength: 0.3,
3672
+ fishMix: "dolphin:4, dori:2@audience*0.7, crab:2@audience*0.6, octopus:1@audience*0.7, babyfish:5@audience",
3673
+ dolphinCast: "showman, drifter, bigguy, host",
3674
+ signs: "neon@back/#ff4fa0:OPEN MIC",
3675
+ finish: 0.6,
3676
+ cameraDistance: 125,
3677
+ cameraElevation: 12,
3678
+ cameraAzimuth: 0
3679
+ }
3680
+ },
3276
3681
  {
3277
3682
  id: "follow-spot",
3278
3683
  label: "Follow-spot",
@@ -3327,7 +3732,7 @@ var RECIPES = [
3327
3732
  {
3328
3733
  id: "reef-characters",
3329
3734
  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.",
3735
+ what: "The bundled characters on a reef: tangs that look back, blowfish that puff and flirt, an octopus that inks, a crab.",
3331
3736
  params: {
3332
3737
  ...ALIVE,
3333
3738
  environment: "reef",
@@ -3346,7 +3751,7 @@ var RECIPES = [
3346
3751
  {
3347
3752
  id: "starfish-class",
3348
3753
  label: "Starfish aerobics",
3349
- what: "Seven starfish stand up on a pale ice floor, face the camera and dance an aerobics routine in unison, an instructor in front.",
3754
+ what: "Seven starfish stand up on a pale ice floor, face the camera and dance aerobics in unison, an instructor in front.",
3350
3755
  params: { environment: "ice", fishMix: "starfish:7", starfishDance: "aerobics", danceTempo: 120, shot: "front", autoRotate: 0 }
3351
3756
  },
3352
3757
  {
@@ -3370,7 +3775,7 @@ var RECIPES = [
3370
3775
  {
3371
3776
  id: "dot-delivers",
3372
3777
  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.",
3778
+ 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
3779
  params: {
3375
3780
  ...ALIVE,
3376
3781
  geodeHomes: 3,
@@ -3388,7 +3793,7 @@ var RECIPES = [
3388
3793
  {
3389
3794
  id: "snail-night",
3390
3795
  label: "Snails by night",
3391
- what: "Glowing snails on a dark floor: each spiral breathes light, the feelers pool green on the sand, and the camera rides beside one.",
3796
+ what: "Glowing snails on a dark floor: each spiral breathes light, feelers pool green on the sand, the camera beside one.",
3392
3797
  params: {
3393
3798
  ...ALIVE,
3394
3799
  ...NIGHT,
@@ -3407,7 +3812,7 @@ var RECIPES = [
3407
3812
  {
3408
3813
  id: "reef-cinematic",
3409
3814
  label: "Reef, cinematic",
3410
- what: "A small reef cast framed close wherever it swims, in soft shafts, each fish over its shadow. The polished look.",
3815
+ what: "A small reef cast framed close wherever it swims, in soft shafts, each fish over its shadow.",
3411
3816
  params: {
3412
3817
  ...ALIVE,
3413
3818
  ...CINEMATIC,
@@ -3432,7 +3837,7 @@ var RECIPES = [
3432
3837
  {
3433
3838
  id: "night-cinematic",
3434
3839
  label: "Deep night, cinematic",
3435
- what: "The deep at night: glowfish under jellyfish lanterns, a shark sliding through, every outline rimmed in light.",
3840
+ what: "The deep at night: glowfish under jellyfish lanterns, a shark sliding through, outlines rimmed in light.",
3436
3841
  params: {
3437
3842
  ...ALIVE,
3438
3843
  ...CINEMATIC,
@@ -3456,7 +3861,7 @@ var RECIPES = [
3456
3861
  {
3457
3862
  id: "village-cinematic",
3458
3863
  label: "Village, cinematic",
3459
- what: "Dot, Puff and Hex meet in a sunlit village of stone homes, seen high through a tilt-shift lens: a model village.",
3864
+ what: "Dot, Puff and Hex meet in a sunlit village of stone homes, seen through a tilt-shift lens.",
3460
3865
  params: {
3461
3866
  ...ALIVE,
3462
3867
  ...CINEMATIC,
@@ -3504,6 +3909,7 @@ function validateMetaquariumParams(params) {
3504
3909
  if (str("townProps")) push("townProps", parseTownProps(str("townProps")).problems);
3505
3910
  if (str("floorCover")) push("floorCover", parseFloorCover(str("floorCover")).problems);
3506
3911
  if (str("pyramidShow")) push("pyramidShow", parsePyramidShow(str("pyramidShow")).problems);
3912
+ if (str("choreography")) push("choreography", parseChoreography(str("choreography"), STARFISH_MOVE_NAMES).problems);
3507
3913
  if (str("geodeMineral")) push("geodeMineral", parseGeodeMineral(str("geodeMineral")).problems);
3508
3914
  const rig = parseSpotRig(str("spotRig"));
3509
3915
  push("spotRig", rig.problems);
@@ -3513,6 +3919,9 @@ function validateMetaquariumParams(params) {
3513
3919
  else push("spotCues", parseSpotCues(str("spotCues"), spots).problems);
3514
3920
  }
3515
3921
  const world = worldMarks(params);
3922
+ for (const s of rig.spots) {
3923
+ 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` });
3924
+ }
3516
3925
  if (str("vignette")) {
3517
3926
  const indoors = params.interior === "geode";
3518
3927
  push("vignette", parseVignette(resolveVignette(str("vignette")), indoors ? INTERIOR_MARKS : { ...OPEN_MARKS, ...world }).problems);
@@ -3598,17 +4007,17 @@ function dependencyProblems(params) {
3598
4007
  const spot2 = Math.round(num("followSpot", -1));
3599
4008
  if (rig.length && set("followSpot")) out.push({ path: "followSpot", also: ["spotRig"], message: "ignored: a spotRig replaces the single followSpot" });
3600
4009
  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) });
4010
+ for (const s of rig) if (!s.mark && s.slot >= cast) out.push({ path: "spotRig", also: castParams, message: noFish(s.slot) });
3602
4011
  if (str("vignette")) {
3603
4012
  const v = parseVignette(resolveVignette(str("vignette")), { ...OPEN_MARKS, ...INTERIOR_MARKS });
3604
4013
  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}` });
4014
+ 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
4015
  const slotBreeds = expandFishMixSlots(mix, 3).map((sl) => sl.breed);
3607
4016
  for (const [g, breeds] of Object.entries(BREED_GESTURES)) {
3608
4017
  for (let a = 0; a < actors; a++) {
3609
4018
  if (!v.beats.some((b) => b.actors[a]?.gesture === g)) continue;
3610
4019
  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}` });
4020
+ 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
4021
  }
3613
4022
  }
3614
4023
  }
@@ -3617,7 +4026,9 @@ function dependencyProblems(params) {
3617
4026
  if (dance !== "off" && !starfishSwims) {
3618
4027
  out.push({ path: "starfishDance", also: ["fishMix"], message: "no starfish in the cast to dance \u2014 add e.g. starfish:7 (slots past the cap never swim)" });
3619
4028
  }
3620
- if (set("danceTempo") && dance === "off") out.push({ path: "danceTempo", also: ["starfishDance"], message: "does nothing while starfishDance is off" });
4029
+ const choreographed = !!str("choreography").trim();
4030
+ if (set("danceTempo") && dance === "off" && !choreographed) out.push({ path: "danceTempo", also: ["starfishDance"], message: "does nothing while starfishDance is off" });
4031
+ if (choreographed && !starfishSwims) out.push({ path: "choreography", also: ["fishMix"], message: "no starfish in the cast to dance it \u2014 add e.g. starfish:5" });
3621
4032
  if (follow < 0) {
3622
4033
  for (const k of ["followDistance", "followAngle", "followAvoid"]) if (set(k)) out.push({ path: k, also: ["cameraFollow"], message: "does nothing while cameraFollow is -1 (the orbit camera)" });
3623
4034
  } else if (follow < cast) {
@@ -3760,7 +4171,7 @@ function describeMetaquarium(params, timed = []) {
3760
4171
  if (Math.round(num("shoal") * CAST_CAP2 * 2.5) >= 3) world.push(`a school of ${str("shoalKind")}`);
3761
4172
  const stage = [];
3762
4173
  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(", ")}`);
4174
+ 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
4175
  else if (num("followSpot") >= 0) stage.push(`follow-spot on ${slotName(num("followSpot"))}`);
3765
4176
  if (has("spotCues") && (rig.length || num("followSpot") >= 0)) {
3766
4177
  const sheet = parseSpotCues(str("spotCues"), Math.max(1, rig.length));
@@ -3771,6 +4182,7 @@ function describeMetaquarium(params, timed = []) {
3771
4182
  stage.push(VIGNETTES[v] ? `vignette: ${v}` : `vignette: ${v.split(/[|\n]/).filter((b) => b.trim()).length} beats`);
3772
4183
  }
3773
4184
  if (str("starfishDance") !== "off") stage.push(`starfish ${str("starfishDance")}${has("danceTempo") ? ` at ${num("danceTempo")} bpm` : ""}`);
4185
+ if (str("choreography").trim()) stage.push(`a starfish choreography${has("danceTempo") ? ` at ${num("danceTempo")} bpm` : ""}`);
3774
4186
  const camera = [];
3775
4187
  const follow = Math.round(num("cameraFollow"));
3776
4188
  if (follow >= 0) {
@@ -4060,6 +4472,10 @@ var METAQUARIUM_PARAMS = {
4060
4472
  * mode), brighter on top: what separates a dark fish from dark water.
4061
4473
  * 0 is none. */
4062
4474
  fishRim: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
4475
+ /** The fish's own corners darken — a fin's root, the step at the head, the
4476
+ * gap under a shell — read off the voxels they are built from. Baked once
4477
+ * per breed, the first frame it is on; every tier. 0 is off. */
4478
+ fishAO: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
4063
4479
  /** A soft shadow on the ground under every swimmer: small and dark near
4064
4480
  * the sand, wide and faint high up — what puts a fish at a height instead
4065
4481
  * of floating in front of the floor. 0 is none (floor walkers always
@@ -4073,6 +4489,10 @@ var METAQUARIUM_PARAMS = {
4073
4489
  * homes, the castle, pyramids, wild geodes, lamps — so it stands on the
4074
4490
  * sand instead of floating over it. 0 is none. */
4075
4491
  sceneryShadows: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
4492
+ /** The plants' cubes darken where they meet each other and the sand — the
4493
+ * corner shading every voxel renderer has — so a coral reads solid instead
4494
+ * of as flat cut-outs. Baked once, the first frame it is on; every tier. 0 is off. */
4495
+ voxelAO: { type: "number", default: 0, min: 0, max: 1, ease: "smooth" },
4076
4496
  /** A creature that comes too close to the camera dissolves (an ordered
4077
4497
  * dither by depth) instead of being cut by the lens: at 1 it is whole
4078
4498
  * beyond about a fish and a half and gone inside about half that. 0 is off. */
@@ -4124,6 +4544,13 @@ var METAQUARIUM_PARAMS = {
4124
4544
  /** The dance's tempo, beats per minute. Every move is a bar of four beats.
4125
4545
  * Integrated when steered, so a glide speeds the class up on the beat. */
4126
4546
  danceTempo: { type: "number", default: 128, min: 60, max: 180, ease: "smooth" },
4547
+ /** An authored dance on the beat (choreo.ts): a cue sheet per dancer —
4548
+ * `a: 8:lie, 4:rise, 4:reach >right @cam | *: 4:march ~360, 4:jacks ^0.5`.
4549
+ * Set, every starfish dances it (in the class's places, or the couples'
4550
+ * under `starfishDance: duet`), facing `cameraAzimuth`; a dancer it leaves
4551
+ * out keeps `starfishDance`'s routine, or stands and waits. Beats on the
4552
+ * `danceTempo` clock from the start, so a sheet lines up with music. */
4553
+ choreography: { type: "string", default: "", ease: "step" },
4127
4554
  /** Eye life, 0..1: blinks on a personal clock, idle saccades, pupils that
4128
4555
  * lead a turn or a climb, eyes on whoever a vignette has it talking to, a
4129
4556
  * glance at the camera now and then, wide for a hop and shut for a rest.
@@ -4250,6 +4677,13 @@ var METAQUARIUM_PARAMS = {
4250
4677
  * fountain home1…, or centre left right front back), facing the front; an LED board
4251
4678
  * scrolls a long line. Empty = none. */
4252
4679
  signs: { type: "string", default: "", ease: "step" },
4680
+ /** The open-mic set (club.ts): `club` is a brick back wall, a mic on a stand at the `mic` mark and a
4681
+ * stool; `mic` the stand alone. The `openmic` vignette is written for it, with an `@audience` out front. */
4682
+ stageSet: { type: "enum", default: "none", options: ["none", "club", "mic"], ease: "step" },
4683
+ /** EXPERIMENTAL. The dolphins' cast — comic personas, in the order the dolphins come in fishMix:
4684
+ * `showman, drifter, bigguy` (dolphin-comics.ts). Each its own look (skin, finish, shirt, prop) and
4685
+ * act (tempo, size of gesture, slouch, lean, lids). Unknown names, and dolphins past the list: classic. */
4686
+ dolphinCast: { type: "string", default: "", ease: "step" },
4253
4687
  /** How unstable the lit signs' power is (neon and LED boards): 0 steady; higher, more
4254
4688
  * hum, stutters, a dead row or tube, a slipped row, now and then a reboot. Each sign
4255
4689
  * has its own supply — most steady, some dodgy. Flash-safe at any setting. */
@@ -4516,6 +4950,10 @@ export {
4516
4950
  SIGN_TEXT_MAX,
4517
4951
  MAX_SIGNS,
4518
4952
  parseSignMix,
4953
+ STARFISH_MOVE_NAMES,
4954
+ parseChoreography,
4955
+ choreoLine,
4956
+ choreoAt,
4519
4957
  GESTURES,
4520
4958
  OPEN_MARKS,
4521
4959
  parseVignette,
@@ -4542,4 +4980,4 @@ export {
4542
4980
  paramSpaceWith,
4543
4981
  coerceNum
4544
4982
  };
4545
- //# sourceMappingURL=chunk-IIYEE6GT.js.map
4983
+ //# sourceMappingURL=chunk-42RIVUT4.js.map