import * as Color from './color.js'; export const WIDTH = 1000; export const HEIGHT = 673; export type Layer = 'static' | 'eyeshine' | Color.Layer; // in compositing order export const allLayers: Layer[] = ['static', 'outer', 'spines', 'stripes', 'cuffs', 'fins1', 'fins2', 'fins3', 'belly1', 'belly2', 'masks', 'claws', 'vitiligo1', 'vitiligo2', 'vitiligo3', 'vitiligo4', 'eyes', 'eyeshine', 'lines']; export function makeLayerInfo(f: (l: Layer) => A): Record { return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record; } export type Position = [x: number, y: number]; export type Positions = Record; export async function loadPos(side: SideName): Promise { let req = new Request(`./${side}/pos.json`); return fetch(req).then(resp => resp.json()); } export type LayerInfo1 = {data: ImageData, pos: Position}; export type LayerInfo = { layers: Record, comp?: ImageData, }; type Rgb = [number, number, number]; type Rgbs = Record; let rgbBuf = new OffscreenCanvas(1, 1).getContext('2d')!; function toRgb(col: Color.Oklch): Rgb { // :) rgbBuf.fillStyle = col.css(); rgbBuf.fillRect(0, 0, 1, 1); const rgb = rgbBuf.getImageData(0, 0, 1, 1).data; return [rgb[0]!, rgb[1]!, rgb[2]!]; } function toRgbs(col: Color.Colors): Rgbs { return Color.makeColorInfo(l => toRgb(col[l])); } function setImageDataRgb({ data }: ImageData, [r, g, b]: Rgb, a: number = -1) { for (let i = 0; i < data.length; i += 4) { data[i] = r; data[i+1] = g; data[i+2] = b; if (a >= 0) data[i+3]! *= a; } } async function loadImage(url: string): Promise { const img0 = new Image; img0.src = url; const img: ImageBitmapSource = img0.complete ? img0 : await new Promise(r => img0.addEventListener('load', () => r(img0))); return createImageBitmap(img); } async function load(buf: OffscreenCanvasRenderingContext2D, side: SideName, layer: Layer): Promise { if (layer == 'eyeshine') layer = 'eyes'; let bmp = await loadImage(`./${side}/${layer}.webp`); return navigator.locks.request('buf', async () => { buf.clearRect(0, 0, WIDTH, HEIGHT); // ? buf.drawImage(bmp, 0, 0); return buf.getImageData(0, 0, WIDTH, HEIGHT) }); } async function loadSide(buf: OffscreenCanvasRenderingContext2D, side: SideName): Promise { const layers: Partial> = { }; const pos = await loadPos(side); const images = Object.fromEntries( await Promise.all( allLayers.map(l => load(buf, side, l).then(res => [l, res]))) ) as Record; console.log(images); for (const l of allLayers) { layers[l] = { data: images[l], pos: pos[l] }; } return { layers: layers as Record }; } function setColors(cols: Rgbs, info: LayerInfo): void { for (const l of allLayers) { if (l == 'static' || l == 'eyeshine') continue; setImageDataRgb(info.layers[l].data, cols[l]); } delete info.comp; } async function compose(buf: OffscreenCanvasRenderingContext2D, { layers }: LayerInfo): Promise { return navigator.locks.request('buf', async () => { buf.save(); buf.clearRect(0, 0, WIDTH, HEIGHT); for (const l of allLayers) { const [x, y] = layers[l].pos; buf.globalCompositeOperation = l == 'eyeshine' ? 'luminosity' : 'source-over'; buf.drawImage(await createImageBitmap(layers[l].data), x, y); } buf.restore(); return buf.getImageData(0, 0, WIDTH, HEIGHT); }); } async function redraw(ctx: CanvasRenderingContext2D, buf: OffscreenCanvasRenderingContext2D, info: LayerInfo) { let data = info.comp ??= await compose(buf, info); ctx.putImageData(data, 0, 0); } function message(msg: string, ctx: CanvasRenderingContext2D) { ctx.save(); ctx.clearRect(0, 0, WIDTH, HEIGHT); ctx.font = 'bold 100px Muller, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(msg, WIDTH/2, HEIGHT/2); ctx.restore(); } function startAnim(name: string) { document.documentElement.dataset.running = name; } function isRunning(): boolean { return !!document.documentElement.dataset.running; } function finishAnim() { delete document.documentElement.dataset.running; } type SideName = 'front' | 'back'; class Side { cur: SideName = 'front'; fronts: LayerInfo; backs: LayerInfo; private constructor(fronts: LayerInfo, backs: LayerInfo) { this.fronts = fronts; this.backs = backs; } static async init(buf: OffscreenCanvasRenderingContext2D) { return new Side(await loadSide(buf, 'front'), await loadSide(buf, 'back')); } flip() { this.cur = (this.cur == 'front' ? 'back' : 'front'); } layers() { return this.cur == 'front' ? this.fronts : this.backs; } async recolorOn(ctx: CanvasRenderingContext2D, buf: OffscreenCanvasRenderingContext2D) { const cols = Color.colors(); const rgbs = toRgbs(cols); setColors(rgbs, this.fronts); setColors(rgbs, this.backs); await redraw(ctx, buf, this.layers()); return cols.outer.h; } async ensureComposed(buf: OffscreenCanvasRenderingContext2D) { this.fronts.comp ??= await compose(buf, this.fronts); this.backs.comp ??= await compose(buf, this.backs); } } function setBg(hue: number) { document.documentElement.style.setProperty('--hue', `${hue}`); } async function animateReroll(ctx1: CanvasRenderingContext2D, ctx2: CanvasRenderingContext2D, buf: OffscreenCanvasRenderingContext2D, side: Side, done: () => void) { const duration = 200; const hue = await side.recolorOn(ctx2, buf); ctx2.canvas.style.animation = `${duration}ms ease fade-in`; setBg(hue); setTimeout(finish, duration); async function finish() { await redraw(ctx1, buf, side.layers()).then(() => { ctx2.canvas.style.removeProperty('animation'); ctx2.canvas.style.removeProperty('opacity'); done(); }); } } async function animateSwap(picCtx: CanvasRenderingContext2D, pic2Ctx: CanvasRenderingContext2D, side: Side, buf: OffscreenCanvasRenderingContext2D, done: () => void) { const duration = 400; side.flip(); await Promise.all([ side.ensureComposed(buf), redraw(pic2Ctx, buf, side.layers()), ]); picCtx.canvas.style.animation = `${duration}ms ease swap1`; pic2Ctx.canvas.style.animation = `${duration}ms ease swap2`; setTimeout(swap, duration/2); setTimeout(finish, duration); function swap() { let data = pic2Ctx.getImageData(0, 0, WIDTH, HEIGHT); picCtx.putImageData(data, 0, 0); } function finish() { picCtx.canvas.style.removeProperty('animation'); pic2Ctx.canvas.style.removeProperty('animation'); done(); } } document.addEventListener('DOMContentLoaded', async function() { let pic = document.getElementById('pic') as HTMLCanvasElement; let picCtx = pic.getContext('2d')!; let pic2 = document.getElementById('pic2') as HTMLCanvasElement; let pic2Ctx = pic2.getContext('2d')!; let buf = new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!; message('loading layers…', picCtx); let side: Side = await Side.init(buf).catch(err => { message(err, picCtx); throw err; }); let hue = await side.recolorOn(picCtx, buf); setBg(hue); await redraw(picCtx, buf, side.layers()); const reroll = document.getElementById('reroll')!; const swap = document.getElementById('swap')!; addListeners(); function addListeners() { reroll.addEventListener('click', handleReroll); swap.addEventListener('click', handleSwap); } function removeListeners() { reroll.removeEventListener('click', handleReroll); swap.removeEventListener('click', handleSwap); } function handleReroll(e: Event) { wrap(reroll, done => animateReroll(picCtx, pic2Ctx, buf, side, done), 'reroll', e); } function handleSwap(e: Event) { wrap(swap, done => animateSwap(picCtx, pic2Ctx, side, buf, done), 'swap', e); } type Handler = (finish: () => void) => void; function wrap(elem: Element, f: Handler, name: string, e: Event) { if (elem != e.target) return; e.stopPropagation(); if (isRunning()) return; removeListeners(); startAnim(name); f(() => { finishAnim(); addListeners(); }); } }); export { load, loadSide };