返回 CodeWhale
scene.js
根目录 / crates / tui / src / integrations / dsh / scene.js
1 /* codewhale-ocean: ambient ocean scene behind the DSH web UI.
2 *
3 * Plain script (no module syntax) so it can be spliced verbatim into the
4 * bundle's lib/client.js — dsh-client-modules serves exactly one file per
5 * client plugin (`/plugins/<id>/client.js`), so a sibling scene.js would
6 * never be fetched. Exposes `createOcean(palette)`; the caller mounts it.
7 *
8 * Contract: one full-viewport <canvas> (fixed, inset 0, z-index -1,
9 * pointer-events none) painted below the app root and above the body
10 * background. ~30 fps, paused while the document is hidden, one static
11 * frame under prefers-reduced-motion, no per-frame allocations, DPR-aware.
12 * Off switch: body class `codewhale-ocean-off` or
13 * localStorage["codewhale.ocean"] === "off". `palette` carries
14 * { light: {base, accent, ink, dim}, dark: {...} } CSS hex colors taken from
15 * the skin token table.
16 */
17 function createOcean(palette) {
18 var STORAGE_KEY = "codewhale.ocean";
19 var OFF_CLASS = "codewhale-ocean-off";
20 var FRAME_MS = 1000 / 30;
21 var FISH_COUNT = 16;
22 var BUBBLE_COUNT = 26;
23 var TAU = Math.PI * 2;
24 var MONO = '14px "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace';
25
26 function isOff() {
27 try {
28 if (typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY) === "off") return true;
29 } catch (_) {}
30 return !!(document.body && document.body.classList.contains(OFF_CLASS));
31 }
32
33 function hexToRgb(hex) {
34 var h = String(hex).replace("#", "");
35 if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
36 var n = parseInt(h, 16);
37 if (isNaN(n)) return [128, 128, 128];
38 return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
39 }
40 function mix(a, b, t) {
41 return [
42 Math.round(a[0] + (b[0] - a[0]) * t),
43 Math.round(a[1] + (b[1] - a[1]) * t),
44 Math.round(a[2] + (b[2] - a[2]) * t),
45 ];
46 }
47 function rgba(c, a) {
48 return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
49 }
50
51 // Per-scheme colour set, precomputed once per scheme switch (no strings
52 // are built inside the frame loop).
53 function buildColors(scheme) {
54 var p = palette[scheme] || palette.dark;
55 var base = hexToRgb(p.base);
56 var accent = hexToRgb(p.accent);
57 var ink = hexToRgb(p.ink);
58 var dim = hexToRgb(p.dim);
59 var dark = scheme === "dark";
60 return {
61 dark: dark,
62 // Depth gradient: light water reads darker/bluer with depth; dark
63 // water is lit faintly from above and falls to the base at the floor.
64 top: dark ? rgba(mix(base, accent, 0.16), 1) : rgba(base, 1),
65 bottom: dark ? rgba(base, 1) : rgba(mix(base, accent, 0.17), 1),
66 whaleNear: dark ? mix(dim, accent, 0.58) : mix(ink, accent, 0.36),
67 whaleFar: dark ? mix(dim, base, 0.12) : mix(dim, accent, 0.3),
68 fish: dark ? mix(accent, dim, 0.15) : mix(accent, ink, 0.1),
69 bubble: dark ? mix(dim, accent, 0.4) : mix(accent, dim, 0.3),
70 };
71 }
72
73 function detectScheme() {
74 var cs = document.documentElement.style.colorScheme;
75 if (cs === "dark" || cs === "light") return cs;
76 if (document.body && document.body.hasAttribute("data-ds-dark-theme")) return "dark";
77 if (typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
78 return "light";
79 }
80
81 function reducedMotion() {
82 return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
83 }
84
85 // Deterministic PRNG so two frames of the same seed are comparable and
86 // the scene does not depend on Math.random ordering.
87 var seed = 0x2f6e2b1;
88 function rand() {
89 seed = (seed * 1664525 + 1013904223) >>> 0;
90 return seed / 4294967296;
91 }
92
93 // ---- Whale --------------------------------------------------------------
94 // Unit-space silhouette facing +x, ~5:1 length:height: fluke tip near x=0,
95 // snout at x=1, spine on y=0. Drawn with the CTM scaled to the whale's
96 // length, so every number here is a proportion. Blunt rounded head, long
97 // flat back with a low soft dorsal hump about two thirds back, slightly
98 // convex belly, thin tail stock, and a HORIZONTAL fluke seen from the
99 // side: a thin swept blade with a slight downward curl (never a vertical
100 // fish tail). The fluke is added under a rotation about the tail stock
101 // (0.11, 0) — canvas paths capture the CTM per segment, so the flex needs
102 // no point math.
103 function traceWhale(g, flex) {
104 g.moveTo(1.0, 0.02);
105 // forehead and back
106 g.bezierCurveTo(1.0, -0.035, 0.975, -0.085, 0.9, -0.095);
107 g.bezierCurveTo(0.8, -0.105, 0.68, -0.1, 0.55, -0.095);
108 // low dorsal hump, soft
109 g.bezierCurveTo(0.47, -0.095, 0.4, -0.105, 0.34, -0.118);
110 g.bezierCurveTo(0.3, -0.105, 0.26, -0.085, 0.22, -0.06);
111 // peduncle tapers thin into the tail stock
112 g.bezierCurveTo(0.18, -0.04, 0.14, -0.024, 0.11, -0.02);
113 // horizontal fluke: a wide, low, notched T seen with a hint of
114 // perspective — lobes sweep BACK (width > height), the lower lobe a
115 // touch longer for the downward curl. Never a vertical fish tail.
116 g.save();
117 g.translate(0.11, 0);
118 g.rotate(flex);
119 g.translate(-0.11, 0);
120 g.bezierCurveTo(0.07, -0.04, 0.0, -0.05, -0.08, -0.065);
121 g.bezierCurveTo(-0.03, -0.04, -0.01, -0.012, 0.005, 0.0);
122 g.bezierCurveTo(-0.01, 0.012, -0.03, 0.045, -0.085, 0.075);
123 g.bezierCurveTo(0.0, 0.055, 0.07, 0.04, 0.11, 0.02);
124 g.restore();
125 // belly, slightly convex, up to the jaw
126 g.bezierCurveTo(0.2, 0.05, 0.35, 0.09, 0.52, 0.1);
127 g.bezierCurveTo(0.72, 0.11, 0.9, 0.09, 0.98, 0.05);
128 g.bezierCurveTo(1.0, 0.04, 1.0, 0.03, 1.0, 0.02);
129 g.closePath();
130 }
131 // One long pectoral flipper (~1/3 body length) sweeping down and back from
132 // a third of the way along the body — the humpback cue.
133 function tracePectoral(g) {
134 g.moveTo(0.72, 0.09);
135 g.bezierCurveTo(0.66, 0.135, 0.5, 0.205, 0.38, 0.225);
136 g.bezierCurveTo(0.47, 0.18, 0.58, 0.115, 0.65, 0.075);
137 g.closePath();
138 }
139
140 // One crossing = a slow linear glide with a gentle sine in Y. Fields in
141 // a flat Float64Array: [x0, x1, yBase, yAmp, yFreq, dur, start, len,
142 // phase, spoutAt]. Paths are biased to the lower half (near) and the top
143 // edge (far) so neither whale swims through the composer card.
144 var SPOUT_N = 10, SB = 4; // spout bubbles: [x, y, vy, life]
145 function Whale(near) {
146 this.near = near;
147 this.p = new Float64Array(10);
148 this.dir = 1;
149 this.spout = new Float64Array(SPOUT_N * SB);
150 this.x = 0; this.y = 0;
151 }
152 Whale.prototype.reset = function (W, H, t, first) {
153 var p = this.p;
154 var near = this.near;
155 var len = near ? Math.max(240, Math.min(0.34 * W, 560)) : Math.max(130, Math.min(0.18 * W, 300));
156 this.dir = rand() < 0.5 ? -1 : 1;
157 var band0 = near ? 0.66 : 0.07;
158 var band1 = near ? 0.9 : 0.24;
159 p[2] = H * (band0 + rand() * (band1 - band0));
160 p[3] = H * (0.02 + rand() * 0.025);
161 p[4] = 0.12 + rand() * 0.08;
162 var margin = len * 1.15;
163 p[0] = this.dir > 0 ? -margin : W + margin;
164 p[1] = this.dir > 0 ? W + margin : -margin;
165 p[5] = (near ? 75 : 110) * (0.85 + rand() * 0.3); // seconds per crossing
166 // First whale starts mid-crossing so the scene is never empty on load.
167 p[6] = first ? t - p[5] * (0.3 + rand() * 0.25) : t + (near ? 4 : 6) + rand() * 10;
168 p[7] = len;
169 p[8] = rand() * TAU;
170 p[9] = t + 6 + rand() * 12;
171 for (var i = 0; i < SPOUT_N; i++) this.spout[i * SB + 3] = 0;
172 };
173 Whale.prototype.step = function (t, dt, W, H) {
174 var p = this.p;
175 var u = (t - p[6]) / p[5];
176 if (u < 0) return;
177 if (u > 1) { this.reset(W, H, t, false); return; }
178 // ease the ends a little so entry/exit are calm
179 var e = u * u * (3 - 2 * u) * 0.35 + u * 0.65;
180 this.x = p[0] + (p[1] - p[0]) * e;
181 this.y = p[2] + Math.sin(t * p[4] + p[8]) * p[3];
182 // spout: a short bubble stream from the head when in the top third
183 var sp = this.spout;
184 if (this.y < H / 3 && t > p[9] && t < p[9] + 1.6) {
185 for (var i = 0; i < SPOUT_N; i++) {
186 var o = i * SB;
187 if (sp[o + 3] > 0) continue;
188 if (rand() < dt * 6) {
189 sp[o] = this.x + this.dir * p[7] * 0.9;
190 sp[o + 1] = this.y - p[7] * 0.09;
191 sp[o + 2] = 18 + rand() * 14;
192 sp[o + 3] = 1.2 + rand() * 0.8;
193 }
194 }
195 } else if (t >= p[9] + 1.6) {
196 p[9] = t + 14 + rand() * 16;
197 }
198 for (var j = 0; j < SPOUT_N; j++) {
199 var q = j * SB;
200 if (sp[q + 3] <= 0) continue;
201 sp[q + 3] -= dt;
202 sp[q + 1] -= sp[q + 2] * dt;
203 sp[q] += Math.sin(t * 2 + j) * 4 * dt;
204 }
205 };
206 Whale.prototype.draw = function (g, t, alpha) {
207 var p = this.p;
208 var u = (t - p[6]) / p[5];
209 if (u < 0 || u > 1) return;
210 // pitch follows the sine's slope, kept gentle
211 var slope = Math.cos(t * p[4] + p[8]) * p[3] * p[4];
212 var ang = Math.max(-0.12, Math.min(0.12, slope / (p[7] * 0.4))) * this.dir;
213 // tail flex: ±10° around the stock, slow, with a softer second harmonic
214 var flex = 0.14 * Math.sin(t * 0.9 + p[8]) + 0.035 * Math.sin(t * 1.8 + p[8]);
215 var L = p[7];
216 g.save();
217 g.translate(this.x, this.y);
218 g.rotate(ang);
219 g.scale(this.dir * L, L);
220 g.globalAlpha = alpha;
221 g.beginPath();
222 traceWhale(g, flex);
223 tracePectoral(g);
224 g.fill();
225 g.restore();
226 };
227 Whale.prototype.drawSpout = function (g, alpha) {
228 var sp = this.spout;
229 g.lineWidth = 1;
230 for (var i = 0; i < SPOUT_N; i++) {
231 var o = i * SB;
232 if (sp[o + 3] <= 0) continue;
233 g.globalAlpha = alpha * Math.min(1, sp[o + 3]);
234 g.beginPath();
235 g.arc(sp[o], sp[o + 1], 1.4, 0, TAU);
236 g.stroke();
237 }
238 };
239
240 // ---- Fish school (flocking-lite) -----------------------------------------
241 // Layout: [x, y, vx, vy, ox, oy, size, phase, facing]
242 var FS = 9;
243 function initFish(fish, W, H) {
244 for (var i = 0; i < FISH_COUNT; i++) {
245 var o = i * FS;
246 var a = rand() * TAU, r = 30 + rand() * 100;
247 fish[o + 4] = Math.cos(a) * r * 1.6;
248 fish[o + 5] = Math.sin(a) * r * 0.55;
249 fish[o + 0] = W * 0.5 + fish[o + 4];
250 fish[o + 1] = H * 0.55 + fish[o + 5];
251 fish[o + 2] = 0; fish[o + 3] = 0;
252 fish[o + 6] = 0.85 + rand() * 0.4;
253 fish[o + 7] = rand() * TAU;
254 fish[o + 8] = 1;
255 }
256 }
257 function stepFish(fish, t, dt, W, H) {
258 // leader on a slow lissajous inside the middle band
259 var lx = W * (0.5 + 0.36 * Math.sin(t * 0.055));
260 var ly = H * (0.5 + 0.2 * Math.sin(t * 0.09 + 1.3));
261 for (var i = 0; i < FISH_COUNT; i++) {
262 var o = i * FS;
263 var wob = fish[o + 7];
264 var tx = lx + fish[o + 4] + Math.sin(t * 0.7 + wob) * 6;
265 var ty = ly + fish[o + 5] + Math.cos(t * 0.9 + wob) * 4;
266 var ax = (tx - fish[o]) * 0.9, ay = (ty - fish[o + 1]) * 0.9;
267 fish[o + 2] += ax * dt; fish[o + 3] += ay * dt;
268 // damping + speed cap
269 fish[o + 2] *= 0.985; fish[o + 3] *= 0.985;
270 var sp = Math.sqrt(fish[o + 2] * fish[o + 2] + fish[o + 3] * fish[o + 3]);
271 var cap = 55;
272 if (sp > cap) { fish[o + 2] *= cap / sp; fish[o + 3] *= cap / sp; }
273 fish[o] += fish[o + 2] * dt; fish[o + 1] += fish[o + 3] * dt;
274 // facing with hysteresis so glyphs don't flip-flop
275 if (fish[o + 2] > 6) fish[o + 8] = 1; else if (fish[o + 2] < -6) fish[o + 8] = -1;
276 }
277 }
278 function drawFish(g, fish, alpha) {
279 g.font = MONO;
280 g.textBaseline = "middle";
281 g.textAlign = "center";
282 for (var i = 0; i < FISH_COUNT; i++) {
283 var o = i * FS;
284 var big = (i % 5) === 0;
285 var right = fish[o + 8] > 0;
286 var glyph = big ? (right ? "><o>" : "<o><") : (right ? "><>" : "<><");
287 g.globalAlpha = alpha * (0.55 + 0.45 * (fish[o + 6] - 0.85) / 0.4);
288 g.save();
289 g.translate(fish[o], fish[o + 1]);
290 g.scale(fish[o + 6], fish[o + 6]);
291 g.fillText(glyph, 0, 0);
292 g.restore();
293 }
294 }
295
296 // ---- Bubbles ---------------------------------------------------------------
297 // Layout: [x, y, r, speed, phase]
298 var BS = 5;
299 function resetBubble(b, o, W, H, fresh) {
300 b[o] = rand() * W;
301 b[o + 1] = fresh ? rand() * H : H + 10 + rand() * 40;
302 b[o + 2] = 0.8 + rand() * 1.9;
303 b[o + 3] = 9 + rand() * 14;
304 b[o + 4] = rand() * TAU;
305 }
306 function initBubbles(b, W, H) {
307 for (var i = 0; i < BUBBLE_COUNT; i++) resetBubble(b, i * BS, W, H, true);
308 }
309 function stepBubbles(b, t, dt, W, H) {
310 for (var i = 0; i < BUBBLE_COUNT; i++) {
311 var o = i * BS;
312 b[o + 1] -= b[o + 3] * dt;
313 b[o] += Math.sin(t * 0.8 + b[o + 4]) * 6 * dt;
314 if (b[o + 1] < -12) resetBubble(b, o, W, H, false);
315 }
316 }
317 function drawBubbles(g, b, alpha) {
318 g.lineWidth = 1;
319 for (var i = 0; i < BUBBLE_COUNT; i++) {
320 var o = i * BS;
321 g.globalAlpha = alpha * (0.35 + 0.4 * (b[o + 2] - 0.8) / 1.9);
322 g.beginPath();
323 g.arc(b[o], b[o + 1], b[o + 2], 0, TAU);
324 g.stroke();
325 }
326 }
327
328 // ---- Scene ------------------------------------------------------------------
329 var canvas = null, g = null;
330 var W = 0, H = 0, dpr = 1;
331 var colors = null, scheme = null;
332 var whales = [new Whale(false), new Whale(true)];
333 var fish = new Float64Array(FISH_COUNT * FS);
334 var bubbles = new Float64Array(BUBBLE_COUNT * BS);
335 var gradient = null;
336 var raf = 0, running = false, mounted = false, lastFrame = 0, lastT = 0, t0 = 0;
337 var intensity = 1;
338 var staticOnly = false;
339
340 function resize() {
341 var w = window.innerWidth, h = window.innerHeight;
342 var d = Math.min(2, window.devicePixelRatio || 1);
343 if (w === W && h === H && d === dpr && gradient) return;
344 var first = W === 0;
345 W = w; H = h; dpr = d;
346 canvas.width = Math.max(1, Math.round(W * dpr));
347 canvas.height = Math.max(1, Math.round(H * dpr));
348 g.setTransform(dpr, 0, 0, dpr, 0, 0);
349 rebuildGradient();
350 if (first) {
351 var t = (performance.now() - t0) / 1000;
352 whales[0].reset(W, H, t, false);
353 whales[1].reset(W, H, t, true);
354 initFish(fish, W, H);
355 initBubbles(bubbles, W, H);
356 }
357 }
358 function rebuildGradient() {
359 if (!g || !colors) return;
360 gradient = g.createLinearGradient(0, 0, 0, H);
361 gradient.addColorStop(0, colors.top);
362 gradient.addColorStop(1, colors.bottom);
363 }
364 function applyScheme(next) {
365 if (next !== "dark") next = "light";
366 if (next === scheme && colors) return;
367 scheme = next;
368 colors = buildColors(next);
369 colors.whaleNearStyle = rgba(colors.whaleNear, 1);
370 colors.whaleFarStyle = rgba(colors.whaleFar, 1);
371 colors.fishStyle = rgba(colors.fish, 1);
372 colors.bubbleStyle = rgba(colors.bubble, 1);
373 rebuildGradient();
374 }
375
376 function frame(now) {
377 raf = 0;
378 if (!running) return;
379 if (now - lastFrame < FRAME_MS - 1) { raf = requestAnimationFrame(frame); return; }
380 lastFrame = now;
381 var t = (now - t0) / 1000;
382 var dt = Math.min(0.1, t - lastT);
383 lastT = t;
384 step(t, dt);
385 paint(t);
386 if (!staticOnly) raf = requestAnimationFrame(frame);
387 }
388 function step(t, dt) {
389 whales[0].step(t, dt, W, H);
390 whales[1].step(t, dt, W, H);
391 stepFish(fish, t, dt, W, H);
392 stepBubbles(bubbles, t, dt, W, H);
393 }
394 function paint(t) {
395 g.globalAlpha = 1;
396 g.fillStyle = gradient;
397 g.fillRect(0, 0, W, H);
398 var a = intensity;
399 g.fillStyle = colors.whaleFarStyle;
400 whales[0].draw(g, t, a * (colors.dark ? 0.46 : 0.4));
401 g.strokeStyle = colors.bubbleStyle;
402 drawBubbles(g, bubbles, a * (colors.dark ? 0.66 : 0.58));
403 whales[0].drawSpout(g, a * 0.6);
404 whales[1].drawSpout(g, a * 0.6);
405 g.fillStyle = colors.fishStyle;
406 drawFish(g, fish, a * (colors.dark ? 0.96 : 0.95));
407 g.fillStyle = colors.whaleNearStyle;
408 whales[1].draw(g, t, a * (colors.dark ? 0.78 : 0.64));
409 g.globalAlpha = 1;
410 }
411
412 function onVisibility() {
413 if (document.hidden) { if (raf) { cancelAnimationFrame(raf); raf = 0; } }
414 else if (running && !raf && !staticOnly) { lastFrame = 0; lastT = (performance.now() - t0) / 1000; raf = requestAnimationFrame(frame); }
415 }
416
417 function mount() {
418 if (mounted) return true;
419 if (isOff()) return false;
420 canvas = document.createElement("canvas");
421 canvas.setAttribute("aria-hidden", "true");
422 canvas.setAttribute("data-codewhale-ocean", "");
423 canvas.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;z-index:-1;pointer-events:none;display:block;";
424 g = canvas.getContext("2d", { alpha: false });
425 if (!g) return false;
426 document.body.insertBefore(canvas, document.body.firstChild);
427 t0 = performance.now();
428 applyScheme(detectScheme());
429 resize();
430 window.addEventListener("resize", resize);
431 document.addEventListener("visibilitychange", onVisibility);
432 mounted = true;
433 return true;
434 }
435
436 function start() {
437 if (!document.body) {
438 document.addEventListener("DOMContentLoaded", function () { start(); }, { once: true });
439 return true;
440 }
441 if (!mount()) return false;
442 staticOnly = reducedMotion();
443 running = true;
444 if (staticOnly) {
445 // one calm frame, then nothing moves: settle the school first so
446 // the still image reads as a school, not a spawn point
447 var t = 0;
448 for (var i = 0; i < 90; i++) { t += 1 / 30; step(t, 1 / 30); }
449 paint(t);
450 return true;
451 }
452 if (!raf) { lastFrame = 0; lastT = (performance.now() - t0) / 1000; raf = requestAnimationFrame(frame); }
453 return true;
454 }
455 function stop() {
456 running = false;
457 if (raf) { cancelAnimationFrame(raf); raf = 0; }
458 if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
459 window.removeEventListener("resize", resize);
460 document.removeEventListener("visibilitychange", onVisibility);
461 mounted = false; W = 0; H = 0; gradient = null;
462 }
463 function setIntensity(v) {
464 intensity = Math.max(0, Math.min(1, Number(v) || 0));
465 if (staticOnly && running) paint(3);
466 }
467 function setScheme(next) {
468 applyScheme(next);
469 if (staticOnly && running) paint(3);
470 }
471
472 var api = { start: start, stop: stop, setIntensity: setIntensity, setScheme: setScheme, isOff: isOff, get running() { return running; } };
473 window.__codewhaleOcean = api;
474 return api;
475 }
476
476 lines JAVASCRIPT