yummy.cricket/rainbow-quox/canvas.ts

304 lines
7.6 KiB
TypeScript

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 type Image = ImageData;
export type Images = Record<Layer, Image>;
export type ComposedImages = Images & {comp?: ImageData};
export type Side = 'front' | 'back';
export function flip(s: Side): Side {
return s == 'front' ? 'back' : 'front';
}
let buffer = new OffscreenCanvas(WIDTH, HEIGHT);
let bufferCtx = buffer.getContext('2d')!;
type Positions = Record<Layer, [number, number]>;
const FRONT_POS: Positions = {
belly1: [187, 105],
belly2: [186, 91],
claws: [3, 168],
cuffs: [42, 160],
eyes: [223, 52],
eyeshine: [223, 52],
fins1: [381, 31],
fins2: [387, 35],
fins3: [495, 140],
lines: [1, 0],
masks: [173, 3],
outer: [28, 43],
spines: [372, 23],
static: [50, 52],
stripes: [50, 168],
vitiligo1: [34, 23],
vitiligo2: [198, 92],
vitiligo3: [214, 312],
vitiligo4: [647, 71],
};
const BACK_POS: Positions = {
belly1: [39, 67],
belly2: [92, 95],
claws: [191, 334],
cuffs: [221, 215],
eyes: [685, 42],
eyeshine: [685, 42],
fins1: [227, 60],
fins2: [226, 61],
fins3: [229, 195],
lines: [0, 0],
masks: [643, 1],
outer: [2, 22],
spines: [337, 50],
static: [219, 41],
stripes: [219, 221],
vitiligo1: [4, 22],
vitiligo2: [46, 48],
vitiligo3: [101, 134],
vitiligo4: [221, 56],
};
type Rgb = [number, number, number];
type Rgbs = Record<Color.Layer, Rgb>;
function toRgb(col: Color.Oklch): Rgb {
// :)
const prev = bufferCtx.getImageData(0, 0, 1, 1);
bufferCtx.save();
bufferCtx.fillStyle = Color.oklch(col);
bufferCtx.fillRect(0, 0, 1, 1);
bufferCtx.restore();
const rgb = bufferCtx.getImageData(0, 0, 1, 1).data;
bufferCtx.putImageData(prev, 0, 0);
return [rgb[0]!, rgb[1]!, rgb[2]!];
}
function toRgbs(col: Color.Colors): Rgbs {
return Color.makeColorInfo(l => toRgb(col[l]));
}
function setImageDataRgb([r, g, b]: Rgb, img: Image): void {
let data = img.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = r; data[i+1] = g; data[i+2] = b;
}
}
async function wait(img: HTMLImageElement): Promise<HTMLImageElement> {
if (img.complete) {
return new Promise((r, _) => r(img));
} else {
return new Promise((r, _) => img.addEventListener('load', () => r(img)));
}
}
async function load(side: Side, layer: Layer): Promise<Image> {
if (layer == 'eyeshine') layer = 'eyes';
let img = new Image; img.src = `./${side}/${layer}.png`;
let bmp = await createImageBitmap(await wait(img));
bufferCtx.clearRect(0, 0, WIDTH, HEIGHT); // ?
bufferCtx.drawImage(bmp, 0, 0);
return bufferCtx.getImageData(0, 0, WIDTH, HEIGHT);
}
async function loadSide(side: Side): Promise<ComposedImages> {
const res: Partial<ComposedImages> = { };
for (const l of allLayers) { res[l] = await load(side, l); }
return res as ComposedImages;
}
function setColors(cols: Rgbs, layers: ComposedImages): void {
bufferCtx.save();
for (const l of allLayers) {
if (l == 'static' || l == 'eyeshine') continue;
setImageDataRgb(cols[l], layers[l]);
}
delete layers.comp;
bufferCtx.restore();
}
async function compose(layers: Images, pos: Positions): Promise<ImageData> {
bufferCtx.save();
bufferCtx.clearRect(0, 0, WIDTH, HEIGHT);
for (const l of allLayers) {
const [x, y] = pos[l];
bufferCtx.globalCompositeOperation =
l == 'eyeshine' ? 'luminosity' : 'source-over';
bufferCtx.drawImage(await createImageBitmap(layers[l]), x, y);
}
let res = bufferCtx.getImageData(0, 0, WIDTH, HEIGHT);
bufferCtx.restore();
return res;
}
async function redraw(ctx: CanvasRenderingContext2D,
layers: ComposedImages, pos: Positions) {
let data = layers.comp ??= await compose(layers, pos);
ctx.putImageData(data, 0, 0);
}
async function loadingMessage(): Promise<ImageBitmap> {
bufferCtx.save();
bufferCtx.clearRect(0, 0, WIDTH, HEIGHT);
bufferCtx.font = 'bold 100px Muller, sans-serif';
bufferCtx.textAlign = 'center';
bufferCtx.fillText('loading layers…', WIDTH/2, HEIGHT/2);
let res = createImageBitmap(buffer);
bufferCtx.restore();
return await res;
}
let pic: HTMLCanvasElement;
let picCtx: CanvasRenderingContext2D;
let pic2: HTMLCanvasElement;
let pic2Ctx: CanvasRenderingContext2D;
let fronts: Images;
let backs: Images;
let side: Side = 'front';
function startAnim(name: string) {
document.documentElement.dataset.running = name;
}
function isRunning(): boolean {
return !!document.documentElement.dataset.running;
}
function finishAnim() {
delete document.documentElement.dataset.running;
}
function layers(): Images { return side == 'front' ? fronts : backs; }
function pos(): Positions { return side == 'front' ? FRONT_POS : BACK_POS; }
async function recolorOn(ctx: CanvasRenderingContext2D) {
const cols = Color.colors();
const rgbs = toRgbs(cols);
setColors(rgbs, fronts);
setColors(rgbs, backs);
await redraw(ctx, layers(), pos());
return cols.outer.h;
}
function setBg(hue: number) {
document.documentElement.style.setProperty('--hue', `${hue}`);
}
async function animateReroll(_e: Event, done: () => void) {
const duration = 400;
const hue = await recolorOn(pic2Ctx);
pic2.style.animation = `${duration}ms ease fade-in`;
setBg(hue);
setTimeout(finish, duration);
async function finish() {
await redraw(picCtx, layers(), pos()).then(() => {
pic2.style.removeProperty('animation');
pic2.style.removeProperty('opacity');
done();
});
}
}
function animateSwap(_e: Event, done: () => void) {
const duration = 1000;
pic.style.animation = `${duration}ms ease swap`;
setTimeout(swapImage, duration/2);
setTimeout(finish, duration);
async function swapImage() {
side = flip(side);
await redraw(picCtx, layers(), pos());
}
function finish() {
pic.style.removeProperty('animation');
done();
}
}
document.addEventListener('DOMContentLoaded', async function() {
pic = document.getElementById('pic') as HTMLCanvasElement;
picCtx = pic.getContext('2d')!;
pic2 = document.getElementById('pic2') as HTMLCanvasElement;
pic2Ctx = pic2.getContext('2d')!;
picCtx.drawImage(await loadingMessage(), 0, 0);
fronts = await loadSide('front');
backs = await loadSide('back');
side = 'front';
let hue = await recolorOn(picCtx);
setBg(hue);
await redraw(picCtx, layers(), pos());
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, animateReroll, 'reroll', e); }
function handleSwap(e: Event) { wrap(swap, animateSwap, 'swap', e); }
type HandlerWithFinish = (e: Event, finish: () => void) => void;
function wrap(elem: Element, f: HandlerWithFinish,
name: string, e: Event) {
if (elem != e.target) return;
e.stopPropagation();
if (isRunning()) return;
removeListeners();
startAnim(name);
f(e, () => { finishAnim(); addListeners(); });
}
});
export { load, loadSide };