@craftedstudios/avatars 0.0.0-stage → 1.0.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.
@@ -0,0 +1,403 @@
1
+ // One WebGL context for the whole page, shared by every avatar.
2
+ //
3
+ // Browsers allow roughly 16 WebGL contexts per page, so avatars can't each
4
+ // have one. Each avatar is instead a small 2D canvas in the DOM, so it
5
+ // scrolls, clips and layers like an image. Each frame the renderer draws every
6
+ // avatar that needs drawing into tiles of one offscreen WebGL canvas, then
7
+ // copies each tile into its avatar's canvas in the same task.
8
+ //
9
+ // What gets drawn:
10
+ // - avatars on screen whose effect is animating, up to 32; past that the
11
+ // rest hold a still frame
12
+ // - any avatar mid-lean or mid-entrance, at full frame rate
13
+ // - any avatar that changed (new, updated, resized) once
14
+ // Animating avatars redraw at about 20fps while nothing is leaning; the
15
+ // effects drift slowly enough that it doesn't show. The loop stops entirely
16
+ // when the tab is hidden or nothing on screen needs drawing.
17
+
18
+ import { VERT, FRAG } from './shader.js';
19
+ import { parseColor as parseCss, shaderColors } from './color.js';
20
+ import * as goo from './goo.js';
21
+
22
+ export const MAX_LIVE = 32;
23
+ const ATLAS = 2048;
24
+ const IDLE_MS = 50;
25
+ const ENTER_S = 0.4;
26
+ const SHAPE = { circle: 0, squircle: 1, square: 2 };
27
+ const MODE = { follow: 1, repel: 2, distort: 3, pulse: 4, orbit: 5, turbulence: 6 };
28
+ // Pattern interactions that move with time keep redrawing at full rate while
29
+ // the pointer is acting on them.
30
+ const TIMED = new Set(['distort', 'pulse', 'turbulence']);
31
+ // Scenery's interactions reach about half the pattern from the pointer, so
32
+ // past 1.4 radii from the center there's nothing left to move.
33
+ const WARP_RANGE = 1.4;
34
+ // The shader's branch for each effect. Kept apart from the order in seed.js,
35
+ // which decides what `auto` picks and must not move after 1.0.
36
+ const FX = { linear: 0, thermal: 1, topography: 2, plasma: 3, radial: 4 };
37
+
38
+ let shared;
39
+
40
+ // The page's renderer, created on first use. Null without WebGL or a DOM.
41
+ export function getRenderer() {
42
+ if (shared !== undefined) return shared;
43
+ shared = typeof document === 'undefined' ? null : new Renderer();
44
+ if (shared && !shared.gl) shared = null;
45
+ return shared;
46
+ }
47
+
48
+ class Renderer {
49
+ constructor() {
50
+ this.canvas = document.createElement('canvas');
51
+ this.items = new Set();
52
+ this.visible = new Set();
53
+ this.raf = 0;
54
+ this.last = 0;
55
+ this.lastIdle = -Infinity;
56
+ this.mouse = null;
57
+ this.lost = false;
58
+ this.stats = { contexts: 0, frameMs: 0, drawn: 0, live: 0 };
59
+ if (!this.setup()) { this.gl = null; return; }
60
+ this.stats.contexts = 1;
61
+
62
+ this.canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); this.lost = true; });
63
+ this.canvas.addEventListener('webglcontextrestored', () => {
64
+ this.lost = false;
65
+ if (!this.setup()) return;
66
+ for (const it of this.items) it.dirty = true;
67
+ this.wake();
68
+ });
69
+
70
+ this.reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
71
+ this.reduced = this.reducedQuery.matches;
72
+ this.reducedQuery.addEventListener?.('change', (e) => {
73
+ this.reduced = e.matches;
74
+ for (const it of this.items) { it.dirty = true; it.enter = 1; Object.assign(it.drop, { x: 0, y: 0, vx: 0, vy: 0 }); }
75
+ this.wake();
76
+ });
77
+
78
+ this.io = new IntersectionObserver((entries) => {
79
+ for (const e of entries) {
80
+ const it = e.target.__craftedAvatar;
81
+ if (!it) continue;
82
+ if (e.isIntersecting) this.visible.add(it); else this.visible.delete(it);
83
+ }
84
+ this.wake();
85
+ }, { rootMargin: '64px' });
86
+
87
+ addEventListener('pointermove', (e) => {
88
+ if (e.pointerType !== 'mouse') { this.mouse = null; return; }
89
+ this.mouse = [e.clientX, e.clientY];
90
+ this.wake();
91
+ }, { passive: true });
92
+ document.documentElement.addEventListener('mouseleave', () => { this.mouse = null; this.wake(); });
93
+ document.addEventListener('visibilitychange', () => this.wake());
94
+ }
95
+
96
+ setup() {
97
+ const gl = this.canvas.getContext('webgl', {
98
+ alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false,
99
+ preserveDrawingBuffer: false, powerPreference: 'low-power',
100
+ });
101
+ if (!gl) return false;
102
+ const sh = (type, src) => {
103
+ const s = gl.createShader(type);
104
+ gl.shaderSource(s, src);
105
+ gl.compileShader(s);
106
+ return gl.getShaderParameter(s, gl.COMPILE_STATUS) || gl.isContextLost() ? s : null;
107
+ };
108
+ const vs = sh(gl.VERTEX_SHADER, VERT), fs = sh(gl.FRAGMENT_SHADER, FRAG);
109
+ if (!vs || !fs) return false;
110
+ const prog = gl.createProgram();
111
+ gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog);
112
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS) && !gl.isContextLost()) return false;
113
+ gl.useProgram(prog);
114
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
115
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
116
+ const loc = gl.getAttribLocation(prog, 'p');
117
+ gl.enableVertexAttribArray(loc);
118
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
119
+ gl.disable(gl.BLEND);
120
+ this.max = Math.min(ATLAS, ...gl.getParameter(gl.MAX_VIEWPORT_DIMS));
121
+ this.U = {};
122
+ for (const n of ['uTile', 'uC', 'uE', 'uR', 'uDropR', 'uGoo', 'uBulge', 'uShape', 'uRing', 'uRingColor', 'uIn', 'uFx', 'uCol1', 'uCol2', 'uCol3', 'uCol4', 'uT', 'uGrain', 'uGrainCell', 'uAngle', 'uLayout', 'uWarp', 'uScale', 'uMouse', 'uMouseAct', 'uMode'])
123
+ this.U[n] = gl.getUniformLocation(prog, n);
124
+ this.gl = gl;
125
+ return true;
126
+ }
127
+
128
+ add(it) {
129
+ this.items.add(it);
130
+ it.host.__craftedAvatar = it;
131
+ this.io.observe(it.host);
132
+ this.wake();
133
+ }
134
+
135
+ remove(it) {
136
+ this.items.delete(it);
137
+ this.visible.delete(it);
138
+ this.io.unobserve(it.host);
139
+ delete it.host.__craftedAvatar;
140
+ }
141
+
142
+ wake() {
143
+ if (!this.raf && document.visibilityState === 'visible') this.raf = requestAnimationFrame((t) => this.frame(t));
144
+ }
145
+
146
+ frame(now) {
147
+ this.raf = 0;
148
+ const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 1 / 60;
149
+ this.last = now;
150
+ if (this.lost || document.visibilityState !== 'visible') { this.last = 0; return; }
151
+ const start = performance.now();
152
+ const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
153
+ const animate = !this.reduced;
154
+
155
+ // Leans and entrances first: they decide whether this is a full-rate frame.
156
+ const draw = new Set();
157
+ const live = [];
158
+ let busy = false;
159
+ // Only the avatar nearest the pointer reaches for it. When several are in
160
+ // range, as on a wall or in a stack, all of them dripping toward one
161
+ // cursor reads as noise.
162
+ let nearest = null, nearestK = Infinity, ndx = 0, ndy = 0;
163
+ if (this.mouse && animate) {
164
+ for (const it of this.visible) {
165
+ if (it.o.interaction !== 'goo' || !it.host.isConnected) continue;
166
+ const b = it.host.getBoundingClientRect();
167
+ const dx = this.mouse[0] - (b.left + b.width / 2), dy = this.mouse[1] - (b.top + b.height / 2);
168
+ const k = Math.hypot(dx, dy) / (it.o.size / 2);
169
+ if (k < it.goo.range && k < nearestK) { nearest = it; nearestK = k; ndx = dx; ndy = dy; }
170
+ }
171
+ }
172
+ for (const it of this.visible) {
173
+ if (!it.host.isConnected) continue;
174
+ if (it.dpr !== dpr) it.resize(dpr);
175
+ const o = it.o, r = o.size / 2;
176
+ const [tx, ty] = it === nearest ? goo.target(ndx, ndy, r, it.goo) : [0, 0];
177
+ const d = it.drop;
178
+ if (!goo.settled(d, tx, ty, r)) { goo.step(d, tx, ty, dt, it.goo); it.moving = true; }
179
+ else if (it.moving) { d.x = tx; d.y = ty; d.vx = d.vy = 0; it.moving = false; draw.add(it); }
180
+ if (MODE[o.interaction] && this.warp(it, dt, animate)) it.moving = true;
181
+ if (it.enter < 1) { it.enter = animate ? Math.min(1, it.enter + dt / ENTER_S) : 1; it.moving = true; }
182
+ if (it.moving) { busy = true; draw.add(it); }
183
+ if (it.dirty) draw.add(it);
184
+ if (animate && o.motion !== 'still') live.push(it);
185
+ }
186
+
187
+ // Past the cap, the largest keep moving and the rest hold their frame.
188
+ if (live.length > MAX_LIVE) live.sort((a, b) => (b.moving - a.moving) || (b.o.size - a.o.size) || (a.order - b.order));
189
+ live.length = Math.min(live.length, MAX_LIVE);
190
+ // The ones leaning or entering are already drawn every frame; the rest
191
+ // keep to the idle rate.
192
+ if (live.length && now - this.lastIdle >= IDLE_MS) {
193
+ this.lastIdle = now;
194
+ for (const it of live) draw.add(it);
195
+ }
196
+
197
+ if (draw.size) this.render([...draw], now / 1000);
198
+ this.stats.frameMs = performance.now() - start;
199
+ this.stats.drawn = draw.size;
200
+ this.stats.live = live.length;
201
+
202
+ if (busy || live.length || [...this.visible].some((it) => it.dirty)) this.wake();
203
+ else this.last = 0;
204
+ }
205
+
206
+ // Eases a pattern interaction in while the pointer is over or near the
207
+ // avatar and out when it leaves, and follows the pointer smoothly rather
208
+ // than snapping to it. Returns whether it still needs drawing.
209
+ warp(it, dt, animate) {
210
+ const w = it.warp, r = it.o.size / 2;
211
+ let target = 0, dx = w.x, dy = w.y;
212
+ if (this.mouse && animate) {
213
+ const b = it.host.getBoundingClientRect();
214
+ const x = this.mouse[0] - (b.left + b.width / 2), y = this.mouse[1] - (b.top + b.height / 2);
215
+ if (Math.hypot(x, y) < WARP_RANGE * r) { target = 1; dx = x; dy = y; }
216
+ }
217
+ const follow = w.act < 0.01 ? 1 : 1 - Math.exp(-dt * 14);
218
+ const mx = dx - w.x, my = dy - w.y;
219
+ w.x += mx * follow;
220
+ w.y += my * follow;
221
+ w.act += (target - w.act) * (1 - Math.exp(-dt * (target ? 8 : 4)));
222
+ if (!target && w.act < 0.003) w.act = 0;
223
+ const easing = Math.abs(target - w.act) > 0.003 || Math.abs(mx) + Math.abs(my) > 0.05;
224
+ return easing || (w.act > 0 && TIMED.has(it.o.interaction));
225
+ }
226
+
227
+ // Draws items into tiles of the shared canvas, a shelf at a time, and copies
228
+ // each tile out before the canvas is reused.
229
+ render(items, seconds) {
230
+ items.sort((a, b) => b.dev - a.dev);
231
+ let batch = [], x = 0, y = 0, shelf = 0, width = 0;
232
+ const flush = () => {
233
+ if (!batch.length) return;
234
+ this.drawBatch(batch, width, y + shelf, seconds);
235
+ batch = []; x = 0; y = 0; shelf = 0; width = 0;
236
+ };
237
+ for (const it of items) {
238
+ const s = Math.min(it.dev, this.max);
239
+ if (x + s > this.max) { x = 0; y += shelf; shelf = 0; }
240
+ if (y + s > this.max) flush();
241
+ batch.push({ it, x, y, s });
242
+ x += s; width = Math.max(width, x); shelf = Math.max(shelf, s);
243
+ }
244
+ flush();
245
+ }
246
+
247
+ ensure(w, h) {
248
+ const c = this.canvas, step = 256;
249
+ const W = Math.min(this.max, Math.max(c.width, Math.ceil(w / step) * step));
250
+ const H = Math.min(this.max, Math.max(c.height, Math.ceil(h / step) * step));
251
+ if (c.width !== W || c.height !== H) { c.width = W; c.height = H; }
252
+ }
253
+
254
+ drawBatch(batch, w, h, seconds) {
255
+ const { gl, U } = this;
256
+ this.ensure(w, h);
257
+ const H = this.canvas.height;
258
+ for (const { it, x, y, s } of batch) {
259
+ const p = it.params(seconds, s);
260
+ gl.viewport(x, H - y - s, s, s);
261
+ gl.uniform4f(U.uTile, x, H - y - s, s, s);
262
+ gl.uniform2f(U.uC, p.cx, p.cy);
263
+ gl.uniform2f(U.uE, p.ex, p.ey);
264
+ gl.uniform1f(U.uR, p.r);
265
+ gl.uniform1f(U.uDropR, p.dropR);
266
+ gl.uniform1f(U.uGoo, p.goo);
267
+ gl.uniform1f(U.uBulge, p.bulge);
268
+ gl.uniform1f(U.uWarp, p.warp);
269
+ gl.uniform2f(U.uMouse, p.mx, p.my);
270
+ gl.uniform1f(U.uMouseAct, p.mouseAct);
271
+ gl.uniform1f(U.uMode, p.mode);
272
+ gl.uniform1f(U.uScale, p.scale);
273
+ gl.uniform1f(U.uShape, SHAPE[p.shape]);
274
+ gl.uniform1f(U.uRing, p.ring);
275
+ gl.uniform3fv(U.uRingColor, p.ringColor);
276
+ gl.uniform1f(U.uIn, p.enter);
277
+ gl.uniform1f(U.uFx, FX[p.effect]);
278
+ gl.uniform1f(U.uAngle, p.angle);
279
+ gl.uniform4fv(U.uLayout, p.layout);
280
+ p.colors.forEach((c, i) => gl.uniform3fv(U[`uCol${i + 1}`], c));
281
+ gl.uniform1f(U.uT, p.t);
282
+ gl.uniform1f(U.uGrain, p.grain);
283
+ gl.uniform1f(U.uGrainCell, Math.max(1, (2 * p.r) / 540));
284
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
285
+ }
286
+ for (const { it, x, y, s } of batch) it.blit(this.canvas, x, y, s);
287
+ }
288
+ }
289
+
290
+ // What an avatar needs from the renderer: its draw parameters, and somewhere
291
+ // to put the pixels. `Item` is the live one in the DOM; still images build a
292
+ // throwaway target of their own.
293
+ export class Item {
294
+ constructor(host, o, { enter = false } = {}) {
295
+ this.host = host;
296
+ this.order = Item.count = (Item.count || 0) + 1;
297
+ this.drop = { x: 0, y: 0, vx: 0, vy: 0 };
298
+ this.warp = { x: 0, y: 0, act: 0 };
299
+ this.enter = enter ? 0 : 1;
300
+ this.moving = false;
301
+ this.dirty = true;
302
+ this.canvas = document.createElement('canvas');
303
+ this.canvas.setAttribute('aria-hidden', 'true');
304
+ this.ctx = this.canvas.getContext('2d');
305
+ this.set(o);
306
+ }
307
+
308
+ set(o) {
309
+ const prev = this.o;
310
+ this.o = o;
311
+ this.colors = shaderColors(o.palette);
312
+ this.goo = goo.params(o.stretch, o.thickness);
313
+ this.ringColor = parseColor(o.ringColor);
314
+ // A new interaction starts from rest, not from wherever the last one was.
315
+ if (prev && prev.interaction !== o.interaction) {
316
+ Object.assign(this.drop, { x: 0, y: 0, vx: 0, vy: 0 });
317
+ Object.assign(this.warp, { x: 0, y: 0, act: 0 });
318
+ }
319
+ if (!prev || prev.size !== o.size || (prev.interaction === 'goo') !== (o.interaction === 'goo')) this.resize(this.dpr || Math.min(globalThis.devicePixelRatio || 1, 2));
320
+ this.dirty = true;
321
+ }
322
+
323
+ // With goo, the canvas reaches past the box on every side to leave room for
324
+ // the lean. Every other interaction stays inside the outline.
325
+ resize(dpr) {
326
+ this.dpr = dpr;
327
+ const pad = this.o.interaction === 'goo' ? goo.PAD * this.o.size / 2 : 0;
328
+ const css = this.o.size + 2 * pad;
329
+ this.pad = pad;
330
+ this.dev = Math.max(1, Math.round(css * dpr));
331
+ this.canvas.width = this.canvas.height = this.dev;
332
+ Object.assign(this.canvas.style, {
333
+ position: 'absolute', left: `${-pad}px`, top: `${-pad}px`, width: `${css}px`, height: `${css}px`,
334
+ pointerEvents: 'none', display: 'block',
335
+ });
336
+ this.dirty = true;
337
+ }
338
+
339
+ // Each avatar keeps its own clock, starting at zero, so a speed change picks
340
+ // up from where the animation is rather than jumping to where the new speed
341
+ // would have put it. Paused, the clock stops where it is, so turning motion
342
+ // off holds the frame on screen and turning it on resumes from there.
343
+ clock(seconds, running) {
344
+ if (running && this.running && this.lastSeconds !== undefined) this.time += Math.max(0, seconds - this.lastSeconds) * this.o.speed;
345
+ this.time ??= 0;
346
+ this.running = running;
347
+ this.lastSeconds = seconds;
348
+ return this.time;
349
+ }
350
+
351
+ params(seconds, s) {
352
+ const o = this.o, k = s / this.dev;
353
+ const r = (o.size / 2) * this.dpr * k;
354
+ const c = (this.pad + o.size / 2) * this.dpr * k;
355
+ const ex = this.drop.x * this.dpr * k, ey = this.drop.y * this.dpr * k;
356
+ const still = o.motion === 'still' || shared?.reduced;
357
+ return {
358
+ cx: c, cy: c, ex: c + ex, ey: c + ey, r,
359
+ dropR: goo.dropRadius(this.drop, o.size / 2, this.goo) * this.dpr * k,
360
+ goo: this.goo.goo, bulge: this.goo.bulge, warp: o.distortion * 2, scale: o.scale,
361
+ mx: c + this.warp.x * this.dpr * k, my: c + this.warp.y * this.dpr * k,
362
+ mouseAct: MODE[o.interaction] ? this.warp.act : 0, mode: MODE[o.interaction] || 0,
363
+ shape: o.shape, ring: o.ring * this.dpr * k, ringColor: this.ringColor,
364
+ enter: easeOut(this.enter), effect: o.traits.effect, colors: this.colors,
365
+ t: this.clock(seconds, !still) + o.traits.phase, grain: o.grain, angle: angleOf(o.traits), layout: o.traits.layout,
366
+ };
367
+ }
368
+
369
+ blit(src, x, y, s) {
370
+ const ctx = this.ctx;
371
+ ctx.globalCompositeOperation = 'copy';
372
+ ctx.drawImage(src, x, y, s, s, 0, 0, this.dev, this.dev);
373
+ this.dirty = false;
374
+ // The live avatar has its first frame; the gradient underneath is no
375
+ // longer needed and would show through during the lean.
376
+ if (!this.shown) { this.shown = true; this.hideFallback(); }
377
+ }
378
+
379
+ hideFallback() {
380
+ this.host.style.background = 'none';
381
+ this.host.style.boxShadow = 'none';
382
+ }
383
+ }
384
+
385
+ // A direction per seed, from its phase, spread by the golden angle.
386
+ export const angleOf = (traits) => (traits.phase * 2.39996) % (Math.PI * 2);
387
+
388
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
389
+
390
+ let probe;
391
+ export function parseColor(c) {
392
+ const known = parseCss(c);
393
+ if (known) return known;
394
+ // Anything else CSS understands, such as a named color, read back through a
395
+ // 1px canvas.
396
+ probe ||= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
397
+ probe.clearRect(0, 0, 1, 1);
398
+ probe.fillStyle = '#fff';
399
+ probe.fillStyle = c;
400
+ probe.fillRect(0, 0, 1, 1);
401
+ const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
402
+ return [r / 255, g / 255, b / 255];
403
+ }
package/src/seed.js ADDED
@@ -0,0 +1,67 @@
1
+ // Everything an avatar looks like comes from one 32-bit hash of its seed.
2
+ // Once 1.0 ships this mapping is frozen: changing any line here changes every
3
+ // user's avatar, so it can only change in a new major version. The snapshot in
4
+ // test/seed.test.js fails if it moves.
5
+
6
+ // `auto` picks from these five, and only ever these five. Effects added later
7
+ // are reachable only as an explicit `effect`, so they never change an existing
8
+ // avatar.
9
+ export const AUTO_EFFECTS = ['linear', 'thermal', 'topography', 'plasma', 'radial'];
10
+ export const EFFECTS = [...AUTO_EFFECTS];
11
+
12
+ import { effectColors, hex, scramble } from './color.js';
13
+
14
+ const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
15
+
16
+ export function fnv1a32(str) {
17
+ const bytes = encoder ? encoder.encode(str) : utf8(str);
18
+ let h = 0x811c9dc5;
19
+ for (let i = 0; i < bytes.length; i++) {
20
+ h ^= bytes[i];
21
+ h = Math.imul(h, 0x01000193);
22
+ }
23
+ return h >>> 0;
24
+ }
25
+
26
+ // For runtimes without TextEncoder. Same bytes, so the same hash.
27
+ function utf8(str) {
28
+ const out = [];
29
+ for (const ch of str) {
30
+ let c = ch.codePointAt(0);
31
+ if (c < 0x80) out.push(c);
32
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
33
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
34
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // Where in its pattern a seed sits: an offset of up to a fifth of the avatar
40
+ // each way, a turn, and a zoom of 0.85 to 1.2, so two seeds of one effect
41
+ // don't share a layout.
42
+ function layoutOf(w) {
43
+ const unit = (at) => ((w >>> at) & 255) / 255;
44
+ return [(unit(0) - 0.5) * 0.4, (unit(8) - 0.5) * 0.4, unit(16) * Math.PI * 2, 0.85 + unit(24) * 0.35];
45
+ }
46
+
47
+ // The hash is scrambled first: FNV alone barely moves its upper bits for
48
+ // seeds that differ only near the start, so emails on the same domain landed
49
+ // on nearly the same colors.
50
+ //
51
+ // What an avatar is: its effect, its hue, its tone (the rest of its colors,
52
+ // see family() in color.js), its layout, and its phase (seconds added to the
53
+ // clock so no two avatars move in step), and the colors the seed gives that
54
+ // effect, in the effect's own roles. An `effect` override wins.
55
+ export function traits(seed, { effect = 'auto' } = {}) {
56
+ const h = scramble(fnv1a32(String(seed ?? '')));
57
+ return {
58
+ effect: EFFECTS.includes(effect) ? effect : AUTO_EFFECTS[h % 5],
59
+ hue: (h >>> 3) % 360,
60
+ tone: scramble(h ^ 0x9e3779b9),
61
+ layout: layoutOf(scramble(h ^ 0x27d4eb2f)),
62
+ phase: ((h >>> 12) % 997) * 0.1,
63
+ get colors() {
64
+ return effectColors(this.effect, this.hue, undefined, this.tone).map(hex);
65
+ },
66
+ };
67
+ }