346 lines
9.7 KiB
TypeScript
346 lines
9.7 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 function makeLayerInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
|
|
return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record<Layer, A>;
|
|
}
|
|
|
|
export type Position = [x: number, y: number];
|
|
export type Positions = Record<Layer, Position>;
|
|
|
|
export async function loadPos(side: SideName): Promise<Positions> {
|
|
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<Layer, LayerInfo1>,
|
|
comp?: ImageData,
|
|
};
|
|
|
|
|
|
type Rgb = [number, number, number];
|
|
type Rgbs = Record<Color.Layer, Rgb>;
|
|
|
|
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 toHex([r, g, b]: Rgb): string {
|
|
function chan(n: number) {
|
|
let a = Math.floor(n).toString(16);
|
|
return a.length == 1 ? `0${a}` : a;
|
|
}
|
|
return `#${chan(r)}${chan(g)}${chan(b)}`;
|
|
}
|
|
|
|
|
|
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<ImageBitmap> {
|
|
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<ImageData> {
|
|
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<LayerInfo> {
|
|
const layers: Partial<Record<Layer, LayerInfo1>> = { };
|
|
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<Layer, ImageData>;
|
|
for (const l of allLayers) {
|
|
layers[l] = { data: images[l], pos: pos[l] };
|
|
}
|
|
return { layers: layers as Record<Layer, LayerInfo1> };
|
|
}
|
|
|
|
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<ImageData> {
|
|
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, WIDTH-10);
|
|
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):
|
|
Promise<[Color.Colors, Rgbs]> {
|
|
const cols = Color.colors();
|
|
const rgbs = toRgbs(cols);
|
|
setColors(rgbs, this.fronts);
|
|
setColors(rgbs, this.backs);
|
|
await redraw(ctx, buf, this.layers());
|
|
return [cols, rgbs];
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
|
|
function getPalette(): XMLDocument | null {
|
|
const palette = document.getElementById('palette') as HTMLObjectElement;
|
|
return palette.contentDocument;
|
|
}
|
|
|
|
function setPalette(oklch: Color.Colors, rgb: Rgbs) {
|
|
const palette = getPalette();
|
|
|
|
if (!palette) {
|
|
setTimeout(() => setPalette(oklch, rgb), 500);
|
|
return;
|
|
}
|
|
|
|
palette.documentElement.style.setProperty('--hue', `${oklch.outer.h}`);
|
|
console.log(palette);
|
|
|
|
for (const l of Color.allLayers) {
|
|
let col = toHex(rgb[l]);
|
|
|
|
const swatch = palette.getElementById(`s-${l}`);
|
|
if (swatch) swatch.style.fill = col;
|
|
|
|
const text = palette.getElementById(`c-${l}`);
|
|
if (text) text.innerHTML = col;
|
|
|
|
const bg = palette.getElementById(`p-${l}`);
|
|
if (bg) bg.style.fill = col;
|
|
|
|
const item = palette.getElementById(`i-${l}`);
|
|
if (item) {
|
|
const textCol = oklch[l].l < 0.6 ? 'white' : 'black';
|
|
item.style.setProperty('--text', textCol);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
|
|
async function animateReroll(ctx1: CanvasRenderingContext2D,
|
|
ctx2: CanvasRenderingContext2D,
|
|
buf: OffscreenCanvasRenderingContext2D,
|
|
side: Side, done: () => void) {
|
|
const duration = 200;
|
|
const [oklch, rgb] = await side.recolorOn(ctx2, buf);
|
|
ctx2.canvas.style.animation = `${duration}ms ease fade-in`;
|
|
setBg(oklch.outer.h);
|
|
setPalette(oklch, rgb);
|
|
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 [oklch, rgb] = await side.recolorOn(picCtx, buf);
|
|
setBg(oklch.outer.h);
|
|
setPalette(oklch, rgb);
|
|
await redraw(picCtx, buf, side.layers());
|
|
|
|
const reroll = document.getElementById('reroll')!;
|
|
const swap = document.getElementById('swap')!;
|
|
|
|
addListeners();
|
|
|
|
type Done = (finish: () => void) => void;
|
|
|
|
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);
|
|
}
|
|
|
|
function addListeners() {
|
|
reroll.addEventListener('click', handleReroll);
|
|
swap.addEventListener('click', handleSwap);
|
|
}
|
|
|
|
function removeListeners() {
|
|
reroll.removeEventListener('click', handleReroll);
|
|
swap.removeEventListener('click', handleSwap);
|
|
}
|
|
|
|
function wrap(elem: Element, f: Done, name: string, e: Event) {
|
|
if (elem != e.target) return;
|
|
e.stopPropagation();
|
|
if (isRunning()) return;
|
|
removeListeners();
|
|
startAnim(name);
|
|
f(() => { finishAnim(); addListeners(); });
|
|
}
|
|
});
|
|
|
|
export { load, loadSide };
|