| 1 | // Acting: presence → acting state → pose over time. |
| 2 | // |
| 3 | // pose(t) = spring(base pose of the state) state blends, follow-through |
| 4 | // + Σ one-shot clips (enter, exit, beats) authored, exact frame timing |
| 5 | // + loop clip of the state the ongoing performance |
| 6 | // + ambient (breath, bob, blinks, puffs) alive, never dancing |
| 7 | // + drag (flukes and fins trail the body) secondary motion |
| 8 | // |
| 9 | // Clips are data — keyframes in frames at 30 fps — so a GPUI port reads the |
| 10 | // same tables. Nothing here invents activity: every acting state comes from |
| 11 | // presence, every work action from an Engine activity kind, every beat from an |
| 12 | // Engine event. |
| 13 | (function (root) { |
| 14 | 'use strict'; |
| 15 | const R = root.WhaleRig, PR = root.WhaleProps; |
| 16 | const { lerp, clamp, D2R, TAU } = R; |
| 17 | const FPS = 30; |
| 18 | |
| 19 | // --------------------------------------------------------------------------- |
| 20 | // Easing and clips. |
| 21 | // --------------------------------------------------------------------------- |
| 22 | const EASE = { |
| 23 | l: t => t, |
| 24 | i: t => t * t * t, |
| 25 | o: t => 1 - Math.pow(1 - t, 3), |
| 26 | io: t => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2, |
| 27 | b: t => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }, |
| 28 | s: t => (t < 1 ? 0 : 1), |
| 29 | }; |
| 30 | function track(keys, f) { |
| 31 | if (f <= keys[0][0]) return keys[0][1]; |
| 32 | for (let i = 1; i < keys.length; i++) { |
| 33 | const [f1, v1, e = 'io'] = keys[i]; |
| 34 | if (f <= f1) { |
| 35 | const [f0, v0] = keys[i - 1]; |
| 36 | const t = f1 === f0 ? 1 : (f - f0) / (f1 - f0); |
| 37 | return lerp(v0, v1, EASE[e](t)); |
| 38 | } |
| 39 | } |
| 40 | return keys[keys.length - 1][1]; |
| 41 | } |
| 42 | function evalClip(clip, f, out, w = 1) { |
| 43 | for (const k in clip.keys) out[k] = (out[k] || 0) + track(clip.keys[k], f) * w; |
| 44 | return out; |
| 45 | } |
| 46 | // Clip builder: `k(dur, {param: [[frame, value, ease], ...]}, events)`. |
| 47 | const k = (dur, keys, events = [], extra = {}) => ({ dur, keys, events, ...extra }); |
| 48 | |
| 49 | // --------------------------------------------------------------------------- |
| 50 | // Presence and Engine activity → acting state. One-to-one with presence; |
| 51 | // Working picks its action from the owner's activity kind (the canonical |
| 52 | // classifier in the shared pet owner) and falls back to plain work. |
| 53 | // --------------------------------------------------------------------------- |
| 54 | const KIND_TO_ACT = { |
| 55 | reading: 'read', files: 'read', memory: 'read', searching: 'search', editing: 'write', |
| 56 | executing: 'run', testing: 'run', browsing: 'browse', network: 'connect', computer: 'computer', |
| 57 | responding: 'talk', communicating: 'talk', delegating: 'pod', thinking: 'think', |
| 58 | tool: 'busy', unknown: 'busy', waiting: 'busy', error: 'busy', |
| 59 | }; |
| 60 | function actingFor(presence, activity, context = {}) { |
| 61 | switch (presence) { |
| 62 | case 'Offline': return 'sleep'; |
| 63 | case 'Idle': return 'rest'; |
| 64 | case 'Listening': return 'listen'; |
| 65 | case 'Thinking': return 'think'; |
| 66 | case 'NeedsYou': return 'needs'; |
| 67 | case 'Done': return 'done'; |
| 68 | case 'Stuck': { |
| 69 | const age = context.nowMs - context.failedAtMs; |
| 70 | return ['failed', 'error'].includes(context.status) && age >= 0 && age < 3000 ? 'hmm' : 'rest'; |
| 71 | } |
| 72 | case 'Working': return activity?.observed === true && context.freshness === 'Live' |
| 73 | ? KIND_TO_ACT[activity.kind] || 'busy' : 'busy'; |
| 74 | default: return 'rest'; |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // --------------------------------------------------------------------------- |
| 79 | // Props live in the pose so springs and clips drive them like anything else. |
| 80 | // --------------------------------------------------------------------------- |
| 81 | const PROP_POSE = { |
| 82 | page: 0, pageFlip: 0, pageRot: 0, pageDX: 0, pageDY: 0, |
| 83 | pad: 0, padLines: 0, pencil: 0, scribX: 0, scribY: 0, |
| 84 | lens: 0, wrench: 0, wrenchSpin: 0, wrenchY: 0, glass: 0, glassExt: 0, pod: 0, |
| 85 | spout: 0, splash: 0, cursor: 0, cursorX: 0, cursorY: 0, link: 0, linkTilt: 0, |
| 86 | }; |
| 87 | const REST = { head: 0, fluke: 6, fin: 0, lid: 0.04, smile: 0.42, lookX: 0.15, lookY: 0.05 }; |
| 88 | |
| 89 | // --------------------------------------------------------------------------- |
| 90 | // The acting catalogue. Frames at 30 fps. Poses are absolute; clips are offsets. |
| 91 | // --------------------------------------------------------------------------- |
| 92 | const ACTS = { |
| 93 | rest: { |
| 94 | base: { ...REST }, |
| 95 | enter: k(18, { squash: [[0, 0], [6, 0.03], [18, 0, 'o']], y: [[0, 0], [6, 0.8], [18, 0, 'o']] }), |
| 96 | loop: k(1, {}), |
| 97 | ambient: { breath: 1, bob: 1, blink: 1, dart: 1, puff: 1, period: 150 }, |
| 98 | poster: {}, |
| 99 | }, |
| 100 | listen: { |
| 101 | base: { ...REST, yaw: 0.5, head: 6, tilt: 8, fin: 10, lid: 0, eyeScale: 1.06, brow: 0.4, smile: 0.55, lookX: -0.2, lookY: -0.05, scale: 1.04, x: 2 }, |
| 102 | enter: k(14, { squash: [[0, 0], [4, 0.05], [14, 0, 'o']], y: [[0, 0], [4, 1.2], [14, 0, 'o']] }), |
| 103 | // Small receptive nods; the fin stays cupped. |
| 104 | loop: k(96, { head: [[0, 0], [64, 0], [70, -4, 'o'], [78, 1], [86, 0, 'io']], tilt: [[0, 0], [48, 0], [60, 3], [80, 0]] }), |
| 105 | ambient: { breath: 0.8, bob: 0.6, blink: 1, dart: 0, puff: 0, period: 130 }, |
| 106 | poster: {}, |
| 107 | }, |
| 108 | think: { |
| 109 | base: { ...REST, head: 10, curl: 0.3, fin: 112, lookX: 0.62, lookY: -0.88, lid: 0.1, brow: 0.55, browTilt: 0.45, smile: 0.22, fluke: 14 }, |
| 110 | // The thought beat: a slow blink, then the eyes go up and away. |
| 111 | enter: k(20, { lid: [[0, 0], [3, 0.95, 'i'], [6, 0.95], [11, 0, 'o']], lookX: [[0, 0], [12, 0]], lookY: [[0, 0], [12, 0]] }, [[16, 'thought']]), |
| 112 | // Fin taps the chin; eyes drift between two thinking points; a thought rises. |
| 113 | loop: k(72, { |
| 114 | fin: [[0, 0], [40, 0], [44, 7, 'o'], [48, 0], [52, 7, 'o'], [56, 0]], |
| 115 | lookX: [[0, 0], [24, 0], [28, -0.5, 'o'], [52, -0.5], [56, 0, 'o']], |
| 116 | fluke: [[0, 0], [36, 5], [72, 0]], |
| 117 | }, [[30, 'thought']]), |
| 118 | ambient: { breath: 0.7, bob: 0.5, blink: 0.6, dart: 0, puff: 0, period: 130 }, |
| 119 | poster: { particles: [['thought-cloud']] }, |
| 120 | }, |
| 121 | busy: { |
| 122 | // Presence says Working and nothing says what: swim in place, on it. |
| 123 | base: { ...REST, head: -2, rot: -12, curl: -0.38, fin: 24, lid: 0.16, lookX: 0.5, lookY: 0.15, smile: 0.3 }, |
| 124 | enter: k(10, { squash: [[0, 0], [3, 0.05], [10, 0, 'o']] }), |
| 125 | loop: k(24, { fluke: [[0, -12], [12, 12], [24, -12]], fin: [[0, 8], [12, -8], [24, 8]], y: [[0, 0], [6, -0.6], [18, 0.6], [24, 0]] }), |
| 126 | ambient: { breath: 0.5, bob: 0.4, blink: 0.8, dart: 0, puff: 0, period: 120 }, |
| 127 | poster: {}, |
| 128 | }, |
| 129 | read: { |
| 130 | base: { ...REST, head: -5, fin: 132, lookX: 0.62, lookY: 0.45, lid: 0.2, lidLow: 0.06, smile: 0.25, page: 1 }, |
| 131 | enter: k(14, { fin: [[0, 0], [4, -18, 'o'], [12, 0, 'o']], squash: [[0, 0], [3, 0.03], [10, 0, 'o']] }), |
| 132 | // Three lines per cycle: scan across, snap back. |
| 133 | loop: k(54, { |
| 134 | lookX: [[0, -0.45], [14, 0.45, 'l'], [18, -0.45, 'o'], [32, 0.45, 'l'], [36, -0.45, 'o'], [50, 0.45, 'l'], [54, -0.45, 'o']], |
| 135 | lookY: [[0, -0.3], [16, -0.3], [18, 0, 's'], [34, 0], [36, 0.3, 's'], [52, 0.3], [54, -0.3, 's']], |
| 136 | head: [[0, 0.8], [27, -0.8], [54, 0.8]], |
| 137 | }), |
| 138 | // One real read event → one page turn (the nose nudges it along). |
| 139 | beat: k(12, { pageFlip: [[0, 0], [6, 1, 'io'], [12, 0, 'io']], head: [[0, 0], [3, -3, 'o'], [8, 1], [12, 0]] }), |
| 140 | exit: k(10, { pageDX: [[0, 0], [10, -6, 'i']], pageDY: [[0, 0], [10, 4, 'i']], fin: [[0, 0], [5, -20, 'o'], [10, 0]] }), |
| 141 | ambient: { breath: 0.6, bob: 0.5, blink: 1, dart: 0, puff: 0, period: 130 }, |
| 142 | poster: {}, |
| 143 | }, |
| 144 | search: { |
| 145 | base: { ...REST, head: -2, fin: 168, lookX: 0.85, lookY: 0.05, lid: 0.04, brow: 0.3, smile: 0.3, lens: 1 }, |
| 146 | enter: k(14, { fin: [[0, 0], [4, -20, 'o'], [12, 0, 'o']], squash: [[0, 0], [3, 0.03], [10, 0, 'o']] }), |
| 147 | // Sweep the lens across; the eyes follow it. |
| 148 | loop: k(64, { |
| 149 | fin: [[0, -16], [32, 12], [64, -16]], |
| 150 | lookX: [[0, 0.1], [32, 0.2], [64, 0.1]], lookY: [[0, 0.45], [32, -0.35], [64, 0.45]], |
| 151 | head: [[0, -2], [32, 2], [64, -2]], |
| 152 | }), |
| 153 | beat: k(10, { fin: [[0, 0], [3, 8, 'o'], [10, 0]], eyeScale: [[0, 0], [3, 0.08], [10, 0]] }), |
| 154 | exit: k(10, { fin: [[0, 0], [6, -26, 'o'], [10, 0]] }), |
| 155 | ambient: { breath: 0.6, bob: 0.5, blink: 0.8, dart: 0, puff: 0, period: 130 }, |
| 156 | poster: {}, |
| 157 | }, |
| 158 | write: { |
| 159 | base: { ...REST, head: -9, fin: 118, lookX: 0.8, lookY: 0.85, lid: 0.3, lidLow: 0.08, smile: 0.15, mouthSide: 0.45, pad: 1, pencil: 1 }, |
| 160 | enter: k(14, { fin: [[0, 0], [4, -16, 'o'], [12, 0, 'o']], squash: [[0, 0], [3, 0.03], [10, 0, 'o']] }), |
| 161 | // Scribble: quick zigzags, the head bobbing with each stroke, flukes keeping time. |
| 162 | loop: k(18, { |
| 163 | scribX: [[0, -3], [3, 3, 'l'], [6, -2, 'l'], [9, 3.5, 'l'], [12, -1, 'l'], [15, 2.5, 'l'], [18, -3, 'l']], |
| 164 | scribY: [[0, 0], [3, -0.8, 'l'], [6, 0.6, 'l'], [9, -0.7, 'l'], [12, 0.5, 'l'], [15, -0.4, 'l'], [18, 0, 'l']], |
| 165 | head: [[0, 0], [9, -1.5], [18, 0]], fluke: [[0, -5], [9, 5], [18, -5]], |
| 166 | }), |
| 167 | // One real edit → one more written line. |
| 168 | beat: k(12, { squash: [[0, 0], [2, 0.03], [8, 0]] }), |
| 169 | exit: k(10, { fin: [[0, 0], [5, -18, 'o'], [10, 0]] }), |
| 170 | ambient: { breath: 0.5, bob: 0.4, blink: 0.8, dart: 0, puff: 0, period: 120 }, |
| 171 | poster: {}, |
| 172 | }, |
| 173 | run: { |
| 174 | // The tail curls up over the back — the mark's C — to work the wrench. |
| 175 | base: { ...REST, curl: 0.64, head: 7, fluke: -24, lookX: -0.35, lookY: -0.95, lid: 0.1, brow: 0.15, smile: 0.35, wrench: 1 }, |
| 176 | enter: k(20, { curl: [[0, 0], [6, -0.12, 'o'], [20, 0, 'o']], fluke: [[0, 0], [6, -12], [20, 0]], squash: [[0, 0], [5, 0.05], [14, 0, 'o']] }), |
| 177 | // Ratchet: a firm turn, a quick reset; the body leans into each turn. |
| 178 | loop: k(30, { |
| 179 | wrenchSpin: [[0, 0], [10, 42, 'io'], [16, 42], [26, 0, 'o'], [30, 0]], |
| 180 | rot: [[0, 0], [10, 2], [26, 0]], fluke: [[0, 0], [10, 6], [26, 0]], |
| 181 | }), |
| 182 | // A new command: toss the wrench, spin, catch. |
| 183 | beat: k(12, { |
| 184 | wrenchY: [[0, 0], [6, -8, 'o'], [11, 0, 'i'], [12, 0]], |
| 185 | wrenchSpin: [[0, 0], [11, 360, 'io'], [12, 360]], |
| 186 | fluke: [[0, 0], [2, -7], [5, 5], [12, 0]], |
| 187 | squash: [[0, 0], [2, 0.04], [5, -0.025], [10, 0.03], [12, 0]], |
| 188 | }), |
| 189 | exit: k(12, { wrenchY: [[0, 0], [12, -6, 'o']] }), |
| 190 | ambient: { breath: 0.5, bob: 0.4, blink: 0.9, dart: 0, puff: 0, period: 120 }, |
| 191 | poster: {}, |
| 192 | }, |
| 193 | browse: { |
| 194 | base: { ...REST, head: 7, y: -3, fin: 122, lookX: 0.9, lookY: -0.1, lid: 0.06, smile: 0.3, glass: 1, glassExt: 1 }, |
| 195 | enter: k(20, { y: [[0, 0], [5, 1.5, 'o'], [14, 0, 'o']], glassExt: [[0, 0], [10, 0], [18, 0]] }), |
| 196 | // A slow look across the horizon. |
| 197 | loop: k(80, { rot: [[0, -3], [40, 3], [80, -3]], head: [[0, -1], [40, 1.5], [80, -1]] }), |
| 198 | // A new page: refocus. |
| 199 | beat: k(10, { glassExt: [[0, 0], [4, 0.18, 'o'], [10, 0]] }), |
| 200 | exit: k(10, { glassExt: [[0, 0], [8, -0.9, 'i']] }), |
| 201 | ambient: { breath: 0.6, bob: 0.5, blink: 0.7, dart: 0, puff: 0, period: 130 }, |
| 202 | poster: {}, |
| 203 | }, |
| 204 | talk: { |
| 205 | // Writing the response: a three-quarter turn toward you, talking. |
| 206 | base: { ...REST, yaw: 0.3, head: 3, fin: 36, lookX: -0.3, lookY: 0, lid: 0, smile: 0.5, eyeScale: 1.02 }, |
| 207 | enter: k(12, { squash: [[0, 0], [3, 0.04], [12, 0, 'o']] }), |
| 208 | loop: k(84, { |
| 209 | mouth: [[0, 0.05], [4, 0.62], [8, 0.18], [13, 0.72], [17, 0.14], [21, 0.52], [26, 0.08], [30, 0.66], [36, 0.12], [40, 0.46], [44, 0.05], [52, 0.05], [56, 0.64], [61, 0.16], [66, 0.58], [72, 0.1], [78, 0.44], [84, 0.05]], |
| 210 | fin: [[0, 0], [44, 0], [52, 32, 'o'], [64, 20], [74, 0]], |
| 211 | head: [[0, 0], [8, 1.5], [22, -1], [36, 1], [50, 0], [84, 0]], |
| 212 | }, [[18, 'speech'], [60, 'speech']]), |
| 213 | ambient: { breath: 0.6, bob: 0.5, blink: 1, dart: 0, puff: 0, period: 120 }, |
| 214 | poster: { particles: [['speech']] }, |
| 215 | }, |
| 216 | pod: { |
| 217 | // Coordinating agents: calves swim alongside; the whale keeps an eye on them. |
| 218 | base: { ...REST, head: 2, lookX: -0.55, lookY: -0.1, smile: 0.45, pod: 1 }, |
| 219 | enter: k(16, { squash: [[0, 0], [4, 0.04], [14, 0, 'o']] }), |
| 220 | loop: k(120, { lookX: [[0, 0], [50, 0], [56, 0.9, 'o'], [90, 0.9], [96, 0, 'o']], head: [[0, 0], [60, 0], [66, -3, 'o'], [72, 0]] }), |
| 221 | ambient: { breath: 0.8, bob: 0.8, blink: 1, dart: 0, puff: 0, period: 140 }, |
| 222 | poster: {}, |
| 223 | }, |
| 224 | needs: { |
| 225 | // A clear nose-up posture asks for attention. The whole whale acts; |
| 226 | // the flipper remains tucked and the familiar profile stays intact. |
| 227 | base: { ...REST, yaw: 1, rot: -24, head: 4, x: 2, y: -3, scale: 1.06, fluke: 22, fin: 0, finFar: 0, lid: 0, eyeScale: 1.06, brow: 0.3, smile: 0.42, lookX: 0.1, lookY: -0.25 }, |
| 228 | enter: k(42, { |
| 229 | rot: [[0, 0], [4, 6, 'o'], [12, -4, 'o'], [22, 1], [36, 0, 'io']], |
| 230 | y: [[0, 0], [5, 1.5], [12, -3, 'o'], [24, 0.6], [40, 0, 'io']], |
| 231 | squash: [[0, 0], [4, 0.045], [10, -0.045, 'o'], [22, 0.012], [36, 0]], |
| 232 | fluke: [[0, 0], [8, -8], [16, 26, 'o'], [24, -8], [34, 5], [42, 0, 'io']], |
| 233 | eyeScale: [[0, 0], [8, 0], [13, 0.05, 'o'], [28, 0, 'io']], |
| 234 | }), |
| 235 | // Hold the readable posture. One slow settling rock, never a repeated alarm. |
| 236 | loop: k(360, { |
| 237 | rot: [[0, 0], [200, 0], [230, 3, 'io'], [260, 3], [300, 0, 'io']], |
| 238 | fluke: [[0, 0], [210, 0], [240, 8, 'io'], [300, 0, 'io']], |
| 239 | lid: [[0, 0], [140, 0], [144, 0.9, 'i'], [152, 0, 'o']], |
| 240 | }), |
| 241 | exit: k(12, { squash: [[0, 0], [4, 0.025], [12, 0, 'o']] }), |
| 242 | ambient: { breath: 0.65, bob: 0.25, blink: 0.4, dart: 0, puff: 0, period: 150 }, |
| 243 | poster: {}, |
| 244 | }, |
| 245 | done: { |
| 246 | base: { ...REST, head: 5, fluke: 10, lidLow: 0.62, smile: 0.9, fin: 22, lid: 0 }, |
| 247 | // Rise from a visible water surface and exhale one broad fountain. |
| 248 | enter: k(80, { |
| 249 | squash: [[0, 0], [6, 0.1, 'o'], [10, -0.08, 'o'], [22, 0.02], [30, 0, 'o']], |
| 250 | y: [[0, 0], [6, 3], [16, -7, 'o'], [32, -5], [70, 0, 'io']], |
| 251 | rot: [[0, 0], [6, 3], [16, -10, 'o'], [32, -8], [70, 0, 'io']], |
| 252 | spout: [[0, 0], [10, 0], [18, 1, 'o'], [34, 1], [62, 0, 'io']], |
| 253 | splash: [[0, 0], [6, 0.5], [14, 1, 'o'], [38, 0.8], [70, 0, 'io']], |
| 254 | fluke: [[0, 0], [8, -14], [14, 30, 'o'], [24, -8], [34, 0, 'o']], |
| 255 | curl: [[0, 0], [30, 0], [48, 0.5, 'io'], [60, 0.5], [78, 0, 'io']], |
| 256 | head: [[0, 0], [30, 0], [48, -6, 'io'], [60, -6], [78, 0, 'io']], |
| 257 | lidLow: [[0, 0], [8, 0], [12, 0.25, 'o'], [30, 0.2], [48, 0.3], [78, 0]], |
| 258 | }, [[18, 'spout', { n: 5, big: true }]]), |
| 259 | loop: k(1, {}), |
| 260 | ambient: { breath: 0.9, bob: 0.8, blink: 0, dart: 0, puff: 0, period: 150 }, |
| 261 | poster: { rot: -10, y: -4, head: 7, spout: 1, splash: 1 }, |
| 262 | }, |
| 263 | hmm: { |
| 264 | // A visible recoil and nose-down pause; no distressed face or thought cloud. |
| 265 | base: { ...REST, rot: 22, x: -3, y: 2, scale: 0.96, head: -10, yaw: 0.45, curl: 0.7, fluke: 34, fin: 0, lookX: -0.4, lookY: 0.5, lid: 0.46, mouthSide: 0.45, smile: 0.1 }, |
| 266 | enter: k(30, { x: [[0, 0], [7, -4, 'o'], [24, 0, 'io']], rot: [[0, 0], [8, 5, 'o'], [30, 0, 'io']], head: [[0, 0], [3, 4, 'o'], [10, 0, 'io']], lid: [[0, 0], [2, 0.7], [5, 0.7], [9, 0, 'o']] }), |
| 267 | loop: k(120, { head: [[0, 0], [60, 0], [76, 3, 'io'], [96, 0, 'io']] }), |
| 268 | ambient: { breath: 0.4, bob: 0.1, blink: 0.6, dart: 0, puff: 0, period: 140 }, |
| 269 | poster: {}, |
| 270 | }, |
| 271 | computer: { |
| 272 | base: { ...REST, head: 4, lookX: 0.8, lookY: -0.8, lid: 0.12, cursor: 1 }, |
| 273 | enter: k(18, { head: [[0, 0], [5, -3], [18, 0]], squash: [[0, 0], [4, 0.025], [14, 0]] }), |
| 274 | loop: k(96, { |
| 275 | cursorX: [[0, -1], [28, 1.5], [56, 1.5], [76, -1], [96, -1]], |
| 276 | cursorY: [[0, 0], [28, -2], [56, 1], [76, 0], [96, 0]], |
| 277 | head: [[0, 0], [28, 2], [56, -1], [96, 0]], |
| 278 | }), |
| 279 | beat: k(10, { cursorY: [[0, 0], [3, 1.5, 'o'], [10, 0]], head: [[0, 0], [3, -2], [10, 0]] }), |
| 280 | exit: k(10, { cursorY: [[0, 0], [10, -4]] }), |
| 281 | ambient: { breath: 0.6, bob: 0.3, blink: 0.8, dart: 0, puff: 0, period: 130 }, |
| 282 | poster: {}, |
| 283 | }, |
| 284 | connect: { |
| 285 | base: { ...REST, head: 3, lookX: 0.8, lookY: -0.55, lid: 0.1, link: 1 }, |
| 286 | enter: k(16, { head: [[0, 0], [5, -2], [16, 0]] }), |
| 287 | loop: k(80, { linkTilt: [[0, -4], [40, 4], [80, -4]], head: [[0, 0], [40, 1.5], [80, 0]] }), |
| 288 | beat: k(10, { linkTilt: [[0, 0], [4, 10], [10, 0]] }), |
| 289 | exit: k(10, {}), |
| 290 | ambient: { breath: 0.6, bob: 0.3, blink: 0.8, dart: 0, puff: 0, period: 130 }, |
| 291 | poster: {}, |
| 292 | }, |
| 293 | sleep: { |
| 294 | // Asleep: curled into the mark, breathing slow and deep. |
| 295 | base: { ...REST, curl: 0.92, head: -8, lid: 1, smile: 0.28, fin: -12, fluke: 6, lookX: 0, lookY: 0 }, |
| 296 | enter: k(40, { |
| 297 | mouth: [[0, 0], [8, 0.85, 'o'], [16, 0.85], [22, 0, 'io']], |
| 298 | lid: [[0, 0], [6, 0], [24, 0]], |
| 299 | fin: [[0, 0], [8, 44, 'o'], [18, 44], [26, 0]], |
| 300 | squash: [[0, 0], [8, -0.07, 'o'], [18, -0.07], [26, 0]], |
| 301 | curl: [[0, 0], [18, 0], [40, 0]], |
| 302 | }), |
| 303 | loop: k(1, {}), |
| 304 | ambient: { breath: 1.7, bob: 0.5, blink: 0, dart: 0, puff: 0, sleepy: 1, period: 210 }, |
| 305 | poster: {}, |
| 306 | }, |
| 307 | }; |
| 308 | // Waking up plays before whatever comes after sleep. |
| 309 | const WAKE = k(26, { |
| 310 | lid: [[0, 0], [5, 0], [8, 0.45], [12, 0], [16, 0]], |
| 311 | squash: [[0, 0], [10, -0.09, 'o'], [18, 0.03], [26, 0, 'o']], |
| 312 | lookX: [[0, 0], [14, -0.6], [20, 0.6], [26, 0]], |
| 313 | }); |
| 314 | // Error beat inside work: a short, honest hmm — then back to the job. |
| 315 | const HMM_BEAT = k(12, { head: [[0, 0], [4, -4, 'o'], [8, -4], [12, 0]], tilt: [[0, 0], [5, -6], [8, -6], [12, 0]], mouthSide: [[0, 0], [5, 1], [8, 1], [12, 0]] }); |
| 316 | // Resting puff from the blowhole. |
| 317 | const PUFF = k(34, { y: [[0, 0], [6, 0.7], [10, -0.6, 'o'], [34, 0, 'io']], squash: [[0, 0], [6, 0.03], [10, -0.02], [20, 0]] }, [[9, 'spout', { n: 3 }]]); |
| 318 | const BLINK = k(7, { lid: [[0, 0], [2, 1, 'i'], [3, 1], [7, 0, 'o']] }); |
| 319 | |
| 320 | // --------------------------------------------------------------------------- |
| 321 | // Springs: per-parameter frequency (rad/s) and damping ratio. |
| 322 | // --------------------------------------------------------------------------- |
| 323 | const SPRING = { |
| 324 | _: [9, 0.8], yaw: [8, 0.72], x: [7, 0.8], y: [7, 0.72], rot: [8, 0.65], scale: [10, 0.7], squash: [14, 0.45], |
| 325 | curl: [6.5, 0.8], arch: [7, 0.8], head: [9, 0.7], tilt: [8, 0.65], fluke: [6, 0.45], flukeSpread: [8, 0.7], |
| 326 | fin: [9, 0.55], finFar: [9, 0.55], lookX: [22, 0.9], lookY: [22, 0.9], lid: [26, 1], lidLow: [16, 0.9], |
| 327 | eyeScale: [12, 0.5], brow: [12, 0.8], browTilt: [12, 0.8], mouth: [22, 1], smile: [10, 0.85], mouthSide: [10, 0.85], |
| 328 | page: [12, 0.5], pad: [12, 0.55], pencil: [12, 0.5], lens: [12, 0.5], wrench: [12, 0.5], glass: [12, 0.55], pod: [6, 0.8], |
| 329 | glassExt: [10, 0.6], cursor: [12, 0.55], link: [12, 0.55], |
| 330 | }; |
| 331 | const PARAMS = Object.keys({ ...R.POSE, ...PROP_POSE }); |
| 332 | |
| 333 | function mulberry32(a) { return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } |
| 334 | |
| 335 | // Presentation rates, per 30-fps frame. The final envelope bounds interruption |
| 336 | // discontinuities as well as springs; it never queues a superseded performance. |
| 337 | const RATE = { _: 0.16, x:2, y:2, rot:5, tilt:5, head:5, fin:12, finFar:12, |
| 338 | fluke:10, arch:5, wrenchSpin:100, wrenchY:4, pageRot:6, pageDX:2, pageDY:2, |
| 339 | scribX:4, scribY:2, padLines:.3, lid:.6, lidLow:.35, lookX:.5, lookY:.5, |
| 340 | squash:.08, scale:.06, yaw:.11, curl:.10, mouth:.4, cursorX:2, cursorY:2, linkTilt:6 }; |
| 341 | const angleDelta = (a,b) => ((a-b+180)%360+360)%360-180; |
| 342 | class Director { |
| 343 | constructor(opts = {}) { |
| 344 | this.rng=mulberry32(opts.seed || 7); this.f=0; |
| 345 | this.presence='Idle'; this.activity=null; this.context={}; this.acting='rest'; |
| 346 | this.reduced=!!opts.reduced; this.ambientOn=opts.ambient!==false; |
| 347 | this.direction='mark'; this.x=this.targetFor('rest'); |
| 348 | this.v=Object.fromEntries(PARAMS.map(p=>[p,0])); |
| 349 | this.shots=[]; this.loops=[{clip:ACTS.rest.loop,at:0,owner:'rest'}]; |
| 350 | this.particles=[]; this.lines=1; this.events=[]; this.emissions=[]; |
| 351 | this.amb={blinkAt:40,dartAt:120,puffAt:360,look:{x:0,y:0}}; |
| 352 | this.poseOut={...this.x}; this.departure=null; this.changedAt=0; |
| 353 | this.doneTurns=new Set(); this.spans=new Set(); this.errorSpan=null; |
| 354 | this.calves=Array.from({length:3},()=>({value:0,velocity:0,target:0})); |
| 355 | } |
| 356 | targetFor(acting){return {...R.POSE,...PROP_POSE,...ACTS[acting].base,padLines:this.lines||1};} |
| 357 | set(presence,activity=null,context={}){ |
| 358 | this.presence=presence; this.activity=activity; this.context={...context}; |
| 359 | const next=actingFor(presence,activity,context); |
| 360 | const doneKey=presence==='Done' && context.status==='completed' && typeof context.turnId==='string' && context.turnId; |
| 361 | const celebrate=!!doneKey && !this.doneTurns.has(doneKey); |
| 362 | if(celebrate)this.doneTurns.add(doneKey); |
| 363 | this.go(next,{celebrate,force:celebrate&&next==='done'}); |
| 364 | const n=next==='pod' && Number.isInteger(activity?.parallel) && activity.parallel>0 ? Math.min(3,activity.parallel) : 0; |
| 365 | this.calves.forEach((c,i)=>{c.target=+(i<n);if(this.reduced){c.value=c.target;c.velocity=0;}}); |
| 366 | // Observe every onset, including those hidden by owner priority: never queue. |
| 367 | const spans=activity?.observed===true&&context.freshness==='Live'&&Array.isArray(activity.active)?activity.active:[]; |
| 368 | for(const span of spans){if(!span||typeof span.kind!=='string'||!Number.isFinite(span.sinceMs))continue; |
| 369 | const key=`${context.turnId||''}|${span.kind}|${span.sinceMs}`; |
| 370 | if(!this.spans.has(key)){this.spans.add(key);this.event({type:'tool',kind:span.kind,sinceMs:span.sinceMs,source:'active'});}} |
| 371 | if(this.spans.size>512)this.spans=new Set([...this.spans].slice(-256)); |
| 372 | this.poseOut=this.reduced?this.poster():this.poseOut; |
| 373 | } |
| 374 | go(next,{celebrate=false,force=false}={}){ |
| 375 | if(next===this.acting&&!force)return; |
| 376 | const prev=this.acting,old=ACTS[prev],current=this.pose(); |
| 377 | // Freeze the visible offset, not the old clip's future movement or events. |
| 378 | this.departure={at:this.f,offset:Object.fromEntries(PARAMS.map(p=>[p,current[p]-this.x[p]]))}; |
| 379 | this.shots=this.shots.filter(s=>s.wake&&this.f-s.at<WAKE.dur&&next!=='sleep'); |
| 380 | this.loops=[];this.particles=[];this.acting=next;this.changedAt=this.f; |
| 381 | this.fromTarget={...this.x}; |
| 382 | this.delays={fin:3,finFar:4,fluke:4,curl:prev==='sleep'?2:3, |
| 383 | lookX:next==='think'?6:0,lookY:next==='think'?6:0,lid:next==='sleep'?6:0, |
| 384 | glassExt:next==='browse'?8:0}; |
| 385 | if(next==='write')this.lines=1; |
| 386 | if(this.reduced){this.x=this.targetFor(next);this.v=Object.fromEntries(PARAMS.map(p=>[p,0]));this.shots=[];this.departure=null;this.poseOut=this.poster();return;} |
| 387 | if(old.exit)this.shots.push({clip:old.exit,at:this.f,owner:prev,exit:true}); |
| 388 | if(prev==='sleep'&&next!=='sleep')this.shots.push({clip:WAKE,at:this.f,owner:next,wake:true}); |
| 389 | if(next!=='done'||celebrate)this.shots.push({clip:ACTS[next].enter,at:this.f,owner:next}); |
| 390 | this.loops=[{clip:ACTS[next].loop,at:this.f+Math.min(10,ACTS[next].enter.dur*.6),owner:next}]; |
| 391 | } |
| 392 | setReduced(value){ |
| 393 | if(this.reduced===!!value)return; |
| 394 | this.reduced=!!value;this.shots=[];this.loops=[];this.particles=[];this.departure=null; |
| 395 | this.acting=actingFor(this.presence,this.activity,this.context); |
| 396 | this.x=this.targetFor(this.acting);this.v=Object.fromEntries(PARAMS.map(p=>[p,0])); |
| 397 | this.calves.forEach(c=>{c.value=c.target;c.velocity=0;});this.poseOut=this.poster(); |
| 398 | if(!this.reduced)this.loops=[{clip:ACTS[this.acting].loop,at:this.f,owner:this.acting}]; |
| 399 | } |
| 400 | event(e){ |
| 401 | this.events.push({...e,f:this.f});if(this.events.length>40)this.events.shift(); |
| 402 | if(this.presence!=='Working'||this.context.freshness!=='Live'||this.activity?.observed!==true)return false; |
| 403 | if(KIND_TO_ACT[e.kind]!==this.acting)return false; |
| 404 | const act=ACTS[this.acting];let clip; |
| 405 | if(e.type==='tool')clip=act.beat; |
| 406 | if(e.type==='failed'&&['failed','error'].includes(e.status)&&!['rest','sleep','needs','done','hmm','listen','think'].includes(this.acting))clip=HMM_BEAT; |
| 407 | if(!clip)return false; |
| 408 | if(e.type==='tool'&&this.acting==='write')this.lines=Math.min(4,this.lines+1); |
| 409 | if(this.reduced){this.poseOut=this.poster();return true;} |
| 410 | this.shots=this.shots.filter(s=>s.clip!==clip); |
| 411 | this.shots.push({clip,at:this.f,owner:this.acting,beat:true});return true; |
| 412 | } |
| 413 | step(dt){ |
| 414 | if(this.reduced){this.poseOut=this.poster();return;} |
| 415 | dt=clamp(dt,0,1/10);const df=dt*FPS,f0=this.f;this.f+=df; |
| 416 | const target=this.targetFor(this.acting),sub=Math.max(1,Math.ceil(dt*120)),h=dt/sub; |
| 417 | for(let s=0;s<sub;s++){ |
| 418 | for(const p of PARAMS){const [w,z]=SPRING[p]||SPRING._; |
| 419 | const t=this.f-this.changedAt<(this.delays?.[p]||0)?this.fromTarget[p]:target[p]; |
| 420 | this.v[p]+=(w*w*(t-this.x[p])-2*z*w*this.v[p])*h;this.x[p]+=this.v[p]*h;} |
| 421 | for(const c of this.calves){c.velocity+=(144*(c.target-c.value)-24*c.velocity)*h;c.value=clamp(c.value+c.velocity*h,0,1);} |
| 422 | } |
| 423 | const out={}; |
| 424 | if(this.departure){const w=1-clamp((this.f-this.departure.at)/8,0,1);for(const p of PARAMS)out[p]=this.departure.offset[p]*w;if(!w)this.departure=null;} |
| 425 | this.anchors=R.build(R.DIRECTIONS[this.direction],this.poseOut,0).anchors; |
| 426 | this.shots=this.shots.filter(s=>this.f-s.at<=s.clip.dur); |
| 427 | for(const s of this.shots){if(this.f<s.at)continue;evalClip(s.clip,this.f-s.at,out); |
| 428 | if(!s.exit&&(s.wake||s.owner===this.acting))this.fire(s.clip,f0-s.at,this.f-s.at,s.owner||this.acting);} |
| 429 | for(const l of this.loops){if(this.f<l.at)continue;const lf=(this.f-l.at)%l.clip.dur,w=clamp((this.f-l.at)/8,0,1); |
| 430 | evalClip(l.clip,lf,out,w);const pf=(f0-l.at)%l.clip.dur;this.fire(l.clip,pf<=lf?pf:-1,lf,l.owner);} |
| 431 | this.ambient(out,f0); |
| 432 | out.fluke=(out.fluke||0)+clamp(-this.v.y*2.2-this.v.rot*.6,-22,22); |
| 433 | out.fin=(out.fin||0)+clamp(-this.v.y*1.2,-12,12); |
| 434 | const pose={}; |
| 435 | for(const p of PARAMS){const desired=this.x[p]+(out[p]||0),prior=this.poseOut?.[p]??desired; |
| 436 | const delta=p==='wrenchSpin'?angleDelta(desired,prior):desired-prior; |
| 437 | pose[p]=prior+clamp(delta,-(RATE[p]||RATE._)*df,(RATE[p]||RATE._)*df);} |
| 438 | for(const p of ['lid','lidLow','mouth','yaw','pageFlip'])pose[p]=clamp(pose[p],0,1); |
| 439 | for(const p of ['page','pad','pencil','lens','wrench','glass','pod','cursor','link'])pose[p]=clamp(pose[p],0,1); |
| 440 | this.poseOut=pose; |
| 441 | for(const p of this.particles){p.age+=df;p.vy+=(p.g||0)*df;p.vx*=Math.pow(p.drag||.98,df);p.vy*=Math.pow(p.drag||.98,df);p.x+=p.vx*df;p.y+=p.vy*df;} |
| 442 | this.particles=this.particles.filter(p=>p.age<p.life&&p.owner===this.acting); |
| 443 | } |
| 444 | fire(clip,a,b,owner=this.acting){if(owner!==this.acting)return;for(const [fr,kind,opt] of clip.events||[])if(fr>a&&fr<=b)this.emit(kind,opt||{});} |
| 445 | |
| 446 | ambient(out, f0) { |
| 447 | if (!this.ambientOn) return; |
| 448 | const A = ACTS[this.acting].ambient, f = this.f; |
| 449 | // Breath: a slow inhale, a short hold, a longer exhale. |
| 450 | const ph = (f % A.period) / A.period; |
| 451 | const b = ph < 0.4 ? Math.sin(ph / 0.4 * Math.PI / 2) : ph < 0.5 ? 1 : Math.cos((ph - 0.5) / 0.5 * Math.PI / 2); |
| 452 | const breath = A.breath; |
| 453 | out.squash = (out.squash || 0) - 0.016 * b * breath; |
| 454 | out.y = (out.y || 0) - 0.55 * b * breath + Math.sin(f / 210 * TAU) * 1.1 * A.bob; |
| 455 | out.fluke = (out.fluke || 0) + 3.5 * Math.sin((f - 10) / A.period * TAU) * breath; |
| 456 | out.fin = (out.fin || 0) + 2.2 * Math.sin((f - 6) / A.period * TAU) * breath; |
| 457 | if(this.acting==='rest'){ |
| 458 | out.rot=(out.rot||0)+2.3*Math.sin(f/240*TAU); |
| 459 | out.x=(out.x||0)+1.8*Math.sin(f/330*TAU); |
| 460 | out.y+=Math.sin((f+33)/210*TAU); |
| 461 | out.fluke+=8*Math.sin((f-24)/180*TAU); |
| 462 | out.head=(out.head||0)+1.5*Math.sin((f-12)/250*TAU); |
| 463 | } |
| 464 | // Blinks, eye darts, blowhole puffs — at irregular, natural intervals. |
| 465 | const r = this.rng; |
| 466 | if (A.blink > 0 && f >= this.amb.blinkAt) { |
| 467 | this.shots.push({ clip: BLINK, at: f, owner: this.acting, ambient:true }); |
| 468 | if (r() < 0.2) this.shots.push({ clip: BLINK, at: f + 9, owner: this.acting, ambient:true }); |
| 469 | this.amb.blinkAt = f + (90 + r() * 150) / A.blink; |
| 470 | } |
| 471 | if (A.dart > 0) { |
| 472 | if (f >= this.amb.dartAt) { this.amb.look = { x: (r() - 0.5) * 0.9, y: (r() - 0.5) * 0.5 }; this.amb.dartAt = f + 150 + r() * 240; this.amb.lookAt = f; } |
| 473 | const t = clamp((f - (this.amb.lookAt || 0)) / 4, 0, 1); |
| 474 | this.amb.cur = this.amb.cur || { x: 0, y: 0 }; |
| 475 | this.amb.cur.x = lerp(this.amb.cur.x, this.amb.look.x, t); this.amb.cur.y = lerp(this.amb.cur.y, this.amb.look.y, t); |
| 476 | out.lookX = (out.lookX || 0) + this.amb.cur.x; out.lookY = (out.lookY || 0) + this.amb.cur.y; |
| 477 | } |
| 478 | if (A.puff > 0 && f >= this.amb.puffAt) { this.shots.push({ clip: PUFF, at: f, owner: this.acting, ambient:true }); this.amb.puffAt = f + 600 + r() * 540; } |
| 479 | if (A.sleepy && f >= (this.amb.sleepAt || 0)) { this.emit('sleep'); this.amb.sleepAt = f + 170 + r() * 140; } |
| 480 | } |
| 481 | |
| 482 | // Particles are born at rig anchors, in world design units. |
| 483 | emit(kind, opt = {}) { |
| 484 | const start = this.particles.length; |
| 485 | const a = this.anchors; |
| 486 | if (!a) return; |
| 487 | this.emissions.push({kind,owner:this.acting,f:this.f}); |
| 488 | if(this.emissions.length>100)this.emissions.shift(); |
| 489 | const r = this.rng, seed = r(); |
| 490 | if (kind === 'spout') { |
| 491 | const n = opt.n || 4, big = !!opt.big; |
| 492 | const source=big?a.spout:a.blowhole; |
| 493 | for (let i = 0; i < n; i++) { |
| 494 | const ang = -Math.PI / 2 + (r() - 0.5) * (big ? 1.3 : 0.8); |
| 495 | const sp = (big ? 1.55 : 0.9) * (0.75 + r() * 0.5); |
| 496 | this.particles.push({ kind: 'drop', x: source.x+(big?(r()-.5)*26:0), y: source.y-(big?24+r()*3:1), vx: Math.cos(ang)*(big?.3:sp), vy: big?-.35:Math.sin(ang)*sp, g: big?.055:.075, drag: 0.985, r: big ? 1.6 : 1.3, age: 0, life: big ? 35 : 26, seed }); |
| 497 | } |
| 498 | for (let i = 0; i < (big ? 3 : 2); i++) this.particles.push({ kind: 'mist', x: a.blowhole.x + (r() - 0.5) * 3, y: a.blowhole.y - 5 - r() * 4, vx: (r() - 0.5) * 0.1, vy: -0.12, r: big ? 4.5 : 3.2, age: 0, life: 24, drag: 0.95, seed }); |
| 499 | } else if (kind === 'thought') { |
| 500 | // Three rising bubbles; the last grows into a cloud, drifts, then pops. |
| 501 | const bx = a.blowhole.x + 2, by = a.blowhole.y - 3; |
| 502 | [[0, 1.3, 0], [5, 2.1, 0], [10, 5.2, 1]].forEach(([delay, rad, cloud], i) => this.particles.push({ kind: 'thought', x: bx + [0,6,14][i], y: by - i * 5.5, vx: 0.06, vy: -0.05, r: rad, cloud: !!cloud, age: -delay, life: 64 - delay, popAt: 54 - delay, drag: 0.99, seed, wob: cloud ? 0 : 0.02 })); |
| 503 | } else if (kind === 'speech') { |
| 504 | const m = a.mouth; |
| 505 | for (let i = 0; i < 3; i++) this.particles.push({ kind: 'bubble', x: m.x + 4 + r() * 2, y: m.y - 1, vx: 0.26 + r() * 0.15, vy: -0.3 - r() * 0.2, r: 1.7 + r() * 1.6, age: -i * 6, life: 50, popAt: 42, drag: 0.985, wob: 0.03, seed: r() }); |
| 506 | } else if (kind === 'blub') { |
| 507 | this.particles.push({ kind: 'bubble', x: a.blowhole.x, y: a.blowhole.y - 2, vx: 0.02, vy: -0.16, r: 2.2, age: 0, life: 64, popAt: 56, drag: 0.995, wob: 0.025, seed }); |
| 508 | } else if (kind === 'sleep') { |
| 509 | this.particles.push({ kind: 'bubble', x: a.blowhole.x, y: a.blowhole.y - 1.5, vx: 0.03, vy: -0.1, r: 1.6, age: 0, life: 90, popAt: 82, drag: 0.997, wob: 0.02, seed, alpha: 0.85 }); |
| 510 | } |
| 511 | for (const p of this.particles.slice(start)) p.owner=this.acting; |
| 512 | } |
| 513 | |
| 514 | // Reduced motion: the state's poster pose — no clock, no ambient, no transitions. |
| 515 | poster() { |
| 516 | const act = ACTS[this.acting]; |
| 517 | const pose = { ...this.targetFor(this.acting), ...(act.poster || {}) }; |
| 518 | delete pose.particles; |
| 519 | pose.padLines = this.lines; |
| 520 | return pose; |
| 521 | } |
| 522 | posterParticles(anchors) { |
| 523 | const list = (ACTS[this.acting].poster || {}).particles || []; |
| 524 | const out = []; |
| 525 | for (const [kind] of list) { |
| 526 | if (kind === 'thought-cloud') { |
| 527 | const bx = anchors.blowhole.x + 2, by = anchors.blowhole.y - 3; |
| 528 | out.push({ kind: 'thought', x: bx, y: by, r: 1.3, age: 10, life: 100 }, { kind: 'thought', x: bx + 6, y: by - 5.5, r: 2.1, age: 10, life: 100 }, { kind: 'thought', x: bx + 14, y: by - 13, r: 5.2, cloud: true, age: 10, life: 100 }); |
| 529 | } else if (kind === 'speech') { |
| 530 | out.push({ kind: 'bubble', x: anchors.mouth.x + 6, y: anchors.mouth.y - 5, r: 1.9, age: 10, life: 100 }, { kind: 'bubble', x: anchors.mouth.x + 9, y: anchors.mouth.y - 10, r: 1.3, age: 10, life: 100 }); |
| 531 | } else if (kind === 'blub') { |
| 532 | out.push({ kind: 'bubble', x: anchors.blowhole.x + 1, y: anchors.blowhole.y - 8, r: 2.2, age: 10, life: 100 }); |
| 533 | } else if (kind === 'spout-still') { |
| 534 | const b = anchors.blowhole; |
| 535 | [[-3, -7, -0.6, -0.5], [0, -9, 0, -1], [3, -7, 0.6, -0.5], [-5, -4, -0.8, 0.3], [5, -4, 0.8, 0.3]].forEach(([dx, dy, vx, vy]) => out.push({ kind: 'drop', x: b.x + dx, y: b.y + dy, vx, vy, r: 1.4, age: 5, life: 100 })); |
| 536 | } |
| 537 | } |
| 538 | return out; |
| 539 | } |
| 540 | |
| 541 | pose() { return this.poseOut || this.poster(); } |
| 542 | } |
| 543 | |
| 544 | // --------------------------------------------------------------------------- |
| 545 | // Render one view of the director: whale, props, particles. |
| 546 | // --------------------------------------------------------------------------- |
| 547 | function scene(director,view={}){ |
| 548 | const dir=R.DIRECTIONS[view.dir||'mark']||R.DIRECTIONS.mark,pose={...director.pose(),...view.pose}; |
| 549 | const podVisibility=Math.max(...director.calves.map(c=>c.value));pose.scale*=1-.16*podVisibility;pose.x+=10*podVisibility; |
| 550 | const parts=R.build(dir,pose,view.lod||0,{size:view.size||512,dpr:view.dpr||1}); |
| 551 | const calves=[]; |
| 552 | for(let i=0;i<3;i++){ |
| 553 | const vis=director.calves[i].value;if(vis<.002)continue; |
| 554 | const phase=director.reduced?0:director.f/30*(.8+i*.13)+i*2.1; |
| 555 | const cp=R.build(dir,{x:-44-(1-vis)*10,y:-30+i*30,scale:.18*vis, |
| 556 | fluke:6+Math.sin(phase)*8,lid:0},view.lod>=2?2:0,{size:view.size||512,dpr:view.dpr||1}); |
| 557 | calves.push(...cp.shapes.map(s=>({...s,id:`calf-${i+1}-${s.id}`,opacity:s.opacity*vis}))); |
| 558 | } |
| 559 | parts.shapes=[...calves,...parts.shapes,...PR.shapes(pose,parts,view)]; |
| 560 | const particles=director.reduced?director.posterParticles(parts.anchors):director.particles; |
| 561 | parts.shapes.push(...PR.particles(particles,view)); |
| 562 | return parts; |
| 563 | } |
| 564 | function render(ctx,director,view){const parts=scene(director,view);R.draw(ctx,parts,R.resolveLook(view.theme,view.px,view.dpr,view.lod));return parts;} |
| 565 | root.WhaleActing = { ACTS, KIND_TO_ACT, actingFor, Director, render, scene, evalClip, track, FPS, PROP_POSE, PARAMS, RATE, SPRING, WAKE, angleDelta }; |
| 566 | })(typeof window !== 'undefined' ? window : globalThis); |
| 567 |