frame.js
3.8 kB · javascript · 124 lines
1import { palette } from '../palette.js';2import { dark as NIGHT, light as DAY } from '../theme.js';34/* RANDOM */56export function rng(seed) {7 if (seed === undefined || seed === null) return Math.random;8 let s = (seed >>> 0) || 0x9e3779b9;9 return () => {10 s ^= s << 13;11 s ^= s >>> 17;12 s ^= s << 5;13 s >>>= 0;14 return s / 4294967296;15 };16}1718export function pick(rand, list) {19 return list[Math.floor(rand() * list.length)];20}2122/* COLOUR */2324export function rgb(value) {25 const text = String(value).trim();26 if (text.startsWith('#')) {27 const hex = text.length < 7 ? [...text.slice(1, 4)].map((c) => c + c).join('') : text.slice(1, 7);28 const n = Number.parseInt(hex, 16);29 return [(n >> 16) & 255, (n >> 8) & 255, n & 255];30 }31 const parts = text.match(/-?\d+(\.\d+)?/g);32 return parts ? parts.slice(0, 3).map(Number) : [0, 0, 0];33}3435export function veil(color, alpha) {36 const [r, g, b] = rgb(color);37 return `rgba(${r}, ${g}, ${b}, ${alpha})`;38}3940export function paints(canvas) {41 const night = typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;42 const base = night ? NIGHT : DAY;43 const css = typeof getComputedStyle === 'function' && canvas ? getComputedStyle(canvas) : null;44 const read = (name, fallback) => (css && css.getPropertyValue(name).trim()) || fallback;45 const paper = read('--art', read('--ground', base.ground ?? palette.white));46 const accent = read('--accent', base.accent ?? palette.blue);47 return { paper, accent };48}4950/* LIFE */5152export function run(canvas, make, opts = {}) {53 const rand = rng(opts.seed);54 const still = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;55 let skin = paints(canvas);56 const view = { rand, look: () => skin, still, w: 1, h: 1, dpr: 1 };57 const size = () => {58 const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);59 const w = Math.max(1, Math.round((canvas.clientWidth || 300) * dpr));60 const h = Math.max(1, Math.round((canvas.clientHeight || 150) * dpr));61 if (w === view.w && h === view.h && dpr === view.dpr) return false;62 view.dpr = dpr;63 view.w = w;64 view.h = h;65 canvas.width = w;66 canvas.height = h;67 return true;68 };69 size();70 const saver = make(canvas, view, opts);71 const watched = typeof IntersectionObserver === 'function';72 let timer = 0;73 let frame = 0;74 let seen = !watched;75 const loop = () => {76 saver.draw();77 frame = requestAnimationFrame(loop);78 };79 const play = () => {80 if (still || timer || frame) return;81 if (saver.every) timer = setInterval(() => saver.draw(), saver.every);82 else frame = requestAnimationFrame(loop);83 };84 const halt = () => {85 if (timer) clearInterval(timer);86 if (frame) cancelAnimationFrame(frame);87 timer = 0;88 frame = 0;89 };90 const wake = () => (seen && !document.hidden ? play() : halt());91 const paint = () => {92 skin = paints(canvas);93 saver.theme?.();94 if (still) saver.draw();95 };96 const grow = () => {97 if (!size()) return;98 saver.size?.();99 if (still) saver.draw();100 };101 const spy = ([entry]) => {102 seen = entry.isIntersecting;103 wake();104 };105 const eye = watched ? new IntersectionObserver(spy) : null;106 const tape = typeof ResizeObserver === 'function' ? new ResizeObserver(grow) : null;107 const shade = typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null;108 eye?.observe(canvas);109 tape?.observe(canvas);110 shade?.addEventListener('change', paint);111 window.addEventListener('theme', paint);112 document.addEventListener('visibilitychange', wake);113 if (still) saver.draw();114 else wake();115 return () => {116 halt();117 eye?.disconnect();118 tape?.disconnect();119 shade?.removeEventListener('change', paint);120 window.removeEventListener('theme', paint);121 document.removeEventListener('visibilitychange', wake);122 saver.stop?.();123 };124}