469 lines
14 KiB
TypeScript
469 lines
14 KiB
TypeScript
import * as Color from './color.js';
|
|
import { History } from './history.js';
|
|
|
|
|
|
async function loadBitmap(url: string): Promise<ImageBitmap> {
|
|
const img0 = new Image;
|
|
const img: Promise<ImageBitmapSource> = new Promise((ok, err) => {
|
|
img0.addEventListener('load', () => ok(img0));
|
|
img0.addEventListener('error', () => err(`couldn't load file: ${url}`));
|
|
});
|
|
img0.src = url;
|
|
return createImageBitmap(await img);
|
|
}
|
|
|
|
|
|
type Buffer = OffscreenCanvasRenderingContext2D;
|
|
|
|
function dataViaBuffer(bmp: ImageBitmap, buf: Buffer): ImageData {
|
|
buf.clearRect(0, 0, bmp.width, bmp.height);
|
|
buf.drawImage(bmp, 0, 0);
|
|
return buf.getImageData(0, 0, bmp.width, bmp.height);
|
|
}
|
|
|
|
async function loadDataLocking(url: string, buf: Buffer): Promise<ImageData> {
|
|
return loadBitmap(url).then(i =>
|
|
navigator.locks.request('imagebuf', () => dataViaBuffer(i, buf)));
|
|
}
|
|
|
|
async function loadDataFresh(url: string): Promise<ImageData> {
|
|
const img = await loadBitmap(url);
|
|
let buf = new OffscreenCanvas(img.width, img.height).getContext('2d')!;
|
|
return dataViaBuffer(img, buf);
|
|
}
|
|
|
|
function loadImageData(url: string, buf?: Buffer): Promise<ImageData> {
|
|
if (buf && navigator.locks) return loadDataLocking(url, buf);
|
|
else return loadDataFresh(url);
|
|
}
|
|
|
|
const WIDTH = 1040;
|
|
const HEIGHT = 713;
|
|
|
|
function makeBuffer(width = WIDTH, height = HEIGHT): Buffer {
|
|
return new OffscreenCanvas(width, height).getContext('2d')!;
|
|
}
|
|
|
|
function makeBufferIfLocks(width?: number, height?: number): Buffer | undefined {
|
|
if (navigator.locks) makeBuffer(width, height);
|
|
else return undefined;
|
|
}
|
|
|
|
export type Layer = 'stroke' | 'static' | 'eyeshine' | Color.Layer;
|
|
|
|
// in compositing order
|
|
export const allLayers: Layer[] =
|
|
['stroke', 'static', 'outer', 'spines', 'stripes', 'cuffs', 'fins1', 'fins2',
|
|
'fins3', 'belly1', 'belly2', 'masks', 'claws', 'vitiligo1', 'vitiligo2',
|
|
'vitiligo3', 'vitiligo4', 'eyes', 'eyeshine', 'lines'];
|
|
|
|
function makeLayerInfo<A>(f: (l: Layer) => A): Record<Layer, A> {
|
|
return Object.fromEntries(allLayers.map(l => [l, f(l)])) as Record<Layer, A>;
|
|
}
|
|
|
|
async function makeLayerInfoAsync<A>(f: (l: Layer) => Promise<A>):
|
|
Promise<Record<Layer, A>> {
|
|
let list = await Promise.all(allLayers.map(l => f(l).then(res => [l, res])));
|
|
return Object.fromEntries(list);
|
|
}
|
|
|
|
|
|
function loadLayers(dir: string): Promise<Record<Layer, ImageData>> {
|
|
let buf = makeBufferIfLocks(WIDTH, HEIGHT);
|
|
return makeLayerInfoAsync(l => loadImageData(`./${dir}/${l}.webp`, buf));
|
|
}
|
|
|
|
|
|
|
|
type Position = [x: number, y: number];
|
|
type Positions = Record<Layer, Position>;
|
|
|
|
async function loadPos(dir: string): Promise<Positions> {
|
|
return (await fetch(`./${dir}/pos.json`)).json();
|
|
}
|
|
|
|
|
|
type Side = 'front' | 'back';
|
|
|
|
function swapSide(s: Side): Side {
|
|
return s == 'front' ? 'back' : 'front';
|
|
}
|
|
|
|
type SideData = Record<Layer, [ImageData, Position]>;
|
|
|
|
type LayerData = {
|
|
front: SideData, back: SideData,
|
|
frontImage?: ImageData, backImage?: ImageData,
|
|
};
|
|
|
|
type ComposedData = Required<LayerData>;
|
|
|
|
async function loadData(): Promise<LayerData> {
|
|
let [fl, fp, bl, bp] = await Promise.all([
|
|
loadLayers('front'), loadPos('front'),
|
|
loadLayers('back'), loadPos('back')
|
|
]);
|
|
return {
|
|
front: makeLayerInfo(l => [fl[l], fp[l]]),
|
|
back: makeLayerInfo(l => [bl[l], bp[l]]),
|
|
}
|
|
}
|
|
|
|
|
|
function singleColor({ data }: ImageData, { r, g, b }: Color.Rgb) {
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
data[i] = r; data[i+1] = g; data[i+2] = b;
|
|
}
|
|
}
|
|
|
|
async function recolorLayers(layers: LayerData, cols: Color.Rgbs) {
|
|
await Promise.all(Color.allLayers.map(l => {
|
|
singleColor(layers.front[l][0], cols[l]);
|
|
singleColor(layers.back[l][0], cols[l]);
|
|
}));
|
|
delete layers.frontImage; delete layers.backImage;
|
|
}
|
|
|
|
type ComposeLayer = [ImageData, Position, GlobalCompositeOperation];
|
|
|
|
async function compose(buf: Buffer, layers: ComposeLayer[],
|
|
width: number, height: number): Promise<ImageData> {
|
|
buf.save();
|
|
buf.clearRect(0, 0, width, height);
|
|
const bmps = await Promise.all(layers.map(async ([l, [x, y], m]) =>
|
|
[await createImageBitmap(l), x, y, m] as const));
|
|
for (const [bmp, x, y, m] of bmps) {
|
|
buf.globalCompositeOperation = m;
|
|
buf.drawImage(bmp, x, y);
|
|
}
|
|
buf.restore();
|
|
return buf.getImageData(0, 0, width, height);
|
|
}
|
|
|
|
async function
|
|
ensureComposed(buf: Buffer, data: LayerData): Promise<ComposedData> {
|
|
let { front, back } = data;
|
|
data.frontImage ??= await composeLayers(front);
|
|
data.backImage ??= await composeLayers(back);
|
|
return data as ComposedData;
|
|
|
|
function composeLayers(sdata: SideData): Promise<ImageData> {
|
|
return compose(buf, allLayers.map(l => makeLayer(l, sdata)), WIDTH, HEIGHT);
|
|
}
|
|
function makeLayer(l: Layer, sdata: SideData): ComposeLayer {
|
|
let [i, p] = sdata[l];
|
|
return [i, p, l == 'eyeshine' ? 'luminosity' : 'source-over'];
|
|
}
|
|
}
|
|
|
|
|
|
async function redraw(ctx: CanvasRenderingContext2D,
|
|
buf: Buffer, data: ComposedData, side: Side) {
|
|
await ensureComposed(buf, data);
|
|
ctx.putImageData(data[`${side}Image`], 0, 0);
|
|
}
|
|
|
|
|
|
function message(msg: string, error = false) {
|
|
const ctx = getCanvasCtx('main');
|
|
const size = error ? 30 : 100;
|
|
ctx.save();
|
|
ctx.clearRect(0, 0, WIDTH, HEIGHT);
|
|
ctx.font = `bold ${size}px Muller, sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(msg, WIDTH/2, HEIGHT/2, WIDTH-10);
|
|
ctx.restore();
|
|
}
|
|
|
|
|
|
function urlState(): string | undefined {
|
|
let hash = document.location.hash?.substring(1);
|
|
if (hash != '' && hash !== undefined) return decodeURI(hash);
|
|
}
|
|
|
|
function updateUrl(seed: string): void {
|
|
history.replaceState({}, '', `#${encodeURI(seed)}`);
|
|
}
|
|
|
|
type ApplyStateOpts = {
|
|
seed: string,
|
|
side?: Side,
|
|
firstLoad?: boolean,
|
|
buf?: Buffer,
|
|
history?: History,
|
|
done?: Done,
|
|
};
|
|
|
|
async function
|
|
applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
|
|
let { side, seed, firstLoad, buf, history, done } = opts;
|
|
side ??= 'front';
|
|
firstLoad ??= false;
|
|
buf ??= new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
|
|
done ??= () => {};
|
|
|
|
let rand = new Color.Rand(seed);
|
|
|
|
const oklch = Color.colors(rand, Color.KNOWN[seed]);
|
|
const rgb = Color.toRgbs(oklch);
|
|
const newSeed = rand.alphaNum();
|
|
|
|
await recolorLayers(data, rgb);
|
|
|
|
updateBg(oklch);
|
|
updateSvgs(oklch, rgb);
|
|
updateLabel(seed);
|
|
updateUrl(seed);
|
|
|
|
if (firstLoad) {
|
|
await instantUpdateImage(side, await ensureComposed(buf, data));
|
|
done();
|
|
} else {
|
|
await animateUpdateImage(buf, side, data, done);
|
|
}
|
|
|
|
if (history) history.addSave(seed);
|
|
|
|
return newSeed;
|
|
}
|
|
|
|
type CanvasId = 'main' | 'aux';
|
|
|
|
function getCanvasCtx(id: CanvasId) {
|
|
const canvas = document.getElementById(id) as HTMLCanvasElement;
|
|
return canvas.getContext('2d')!;
|
|
}
|
|
|
|
async function
|
|
instantUpdateImage(side: Side, data: ComposedData) {
|
|
getCanvasCtx('main').putImageData(data[`${side}Image`], 0, 0);
|
|
}
|
|
|
|
type Done = () => void;
|
|
|
|
const noAnim = matchMedia('(prefers-reduced-motion: reduce)');
|
|
|
|
async function
|
|
animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
|
|
if (noAnim.matches) {
|
|
instantUpdateImage(side, await ensureComposed(buf, data));
|
|
done();
|
|
return;
|
|
}
|
|
|
|
const duration = 200;
|
|
|
|
const main = getCanvasCtx('main');
|
|
const aux = getCanvasCtx('aux');
|
|
|
|
document.documentElement.dataset.running = 'reroll';
|
|
const cdata = await ensureComposed(buf, data);
|
|
redraw(aux, buf, cdata, side);
|
|
|
|
aux.canvas.addEventListener('animationend', async () => {
|
|
await redraw(main, buf, cdata, side);
|
|
aux.canvas.style.removeProperty('animation');
|
|
delete document.documentElement.dataset.running;
|
|
done();
|
|
});
|
|
|
|
setTimeout(() =>
|
|
aux.canvas.style.animation = `${duration}ms ease forwards fade-in`
|
|
);
|
|
}
|
|
|
|
async function
|
|
animateSwapImage(buf: Buffer, newSide: Side, data: ComposedData, done: Done) {
|
|
if (noAnim.matches) {
|
|
instantUpdateImage(newSide, data);
|
|
done();
|
|
return;
|
|
}
|
|
|
|
const duration = 400;
|
|
|
|
const main = getCanvasCtx('main');
|
|
const aux = getCanvasCtx('aux');
|
|
|
|
document.documentElement.dataset.running = 'swap';
|
|
await redraw(aux, buf, data, newSide);
|
|
|
|
aux.canvas.addEventListener('animationend', async () => {
|
|
const image = aux.getImageData(0, 0, WIDTH, HEIGHT);
|
|
main.putImageData(image, 0, 0);
|
|
|
|
main.canvas.style.removeProperty('animation');
|
|
aux.canvas.style.removeProperty('animation');
|
|
delete document.documentElement.dataset.running;
|
|
done();
|
|
});
|
|
|
|
main.canvas.style.animation = `${duration}ms ease swap1`;
|
|
aux.canvas.style.animation = `${duration}ms ease swap2`;
|
|
}
|
|
|
|
function updateBg(cols: Color.Colors) {
|
|
document.documentElement.style.setProperty('--hue', `${cols.outer.h}`);
|
|
}
|
|
|
|
function updateLabel(seed: string) {
|
|
const stateLabel = document.getElementById('current-name');
|
|
if (stateLabel) stateLabel.innerHTML = seed;
|
|
}
|
|
|
|
function updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) {
|
|
const paletteObj = document.getElementById('palette') as HTMLObjectElement;
|
|
const palette = paletteObj.contentDocument as XMLDocument | null;
|
|
|
|
if (palette) {
|
|
palette.documentElement.style.setProperty('--hue', `${oklch.outer.h}`);
|
|
const get = (id: string) => palette.getElementById(id);
|
|
|
|
for (const layer of Color.allLayers) {
|
|
let col = rgb[layer].css();
|
|
let elem;
|
|
|
|
// main group
|
|
if (elem = get(`i-${layer}`)) {
|
|
if (oklch[layer].l < 0.6) {
|
|
elem.classList.add('light'); elem.classList.remove('dark');
|
|
} else {
|
|
elem.classList.add('dark'); elem.classList.remove('light');
|
|
}
|
|
elem.style.setProperty('--col', col);
|
|
}
|
|
|
|
// label
|
|
if (elem = get(`c-${layer}`)) elem.innerHTML = col;
|
|
// minor swatch, if applicable
|
|
if (elem = get(`s-${layer}`)) elem.style.setProperty('--col', col);
|
|
}
|
|
}
|
|
|
|
const showuiObj = document.getElementById('showui') as HTMLObjectElement;
|
|
const showui = showuiObj.contentDocument as XMLDocument | null;
|
|
|
|
if (showui) {
|
|
showui.documentElement.style.setProperty('--hue', `${oklch.outer.h}`);
|
|
}
|
|
}
|
|
|
|
|
|
function showHistory(history: History, data: LayerData,
|
|
opts: Omit<ApplyStateOpts, 'seed' | 'history'>) {
|
|
const list = document.getElementById('history-items');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
let { side, firstLoad, buf, done } = opts;
|
|
|
|
for (const item of history.iterItems()) {
|
|
const elem = item.asHtml();
|
|
let allOpts = { side, firstLoad, buf, done, seed: item.name, history };
|
|
elem.addEventListener('click', () => {
|
|
applyState(data, allOpts);
|
|
});
|
|
list.appendChild(elem);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
document.documentElement.dataset.state = 'history';
|
|
const elem = document.createElement('div');
|
|
elem.id = 'history-close-target';
|
|
elem.addEventListener('click', closeHistory);
|
|
document.body.appendChild(elem);
|
|
});
|
|
}
|
|
|
|
function closeHistory() {
|
|
document.getElementById('history-items')?.
|
|
scroll({top: 0, left: 0, behavior: 'smooth'});
|
|
let field = document.getElementById('history-close-target');
|
|
if (field) field.parentElement?.removeChild(field);
|
|
document.documentElement.dataset.state = 'ready';
|
|
}
|
|
|
|
async function setup() {
|
|
message('loading layers…');
|
|
|
|
let data = await loadData().catch(e => { message(e, true); throw e });
|
|
let history = History.loadOrClear();
|
|
|
|
let buf = new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
|
|
|
|
let seed = urlState() ?? new Color.Rand().alphaNum();
|
|
let side: Side = 'front';
|
|
seed = await applyState(data, { seed, buf, history, firstLoad: true });
|
|
|
|
const reroll = document.getElementById('reroll')!;
|
|
const swap = document.getElementById('swap')!;
|
|
|
|
addListeners();
|
|
|
|
// these ones don't need to be toggled
|
|
document.getElementById('hideui')?.addEventListener('click', () => {
|
|
document.documentElement.dataset.state = 'fullquox';
|
|
});
|
|
document.getElementById('showui')?.addEventListener('click', () => {
|
|
document.documentElement.dataset.state = 'ready';
|
|
});
|
|
document.getElementById('history-button')?.addEventListener('click', () => {
|
|
// does this need the add/remove listeners dance
|
|
// actually does anything any more?
|
|
showHistory(history, data, { side, buf });
|
|
});
|
|
document.getElementById('close-history')?.addEventListener('click', closeHistory);
|
|
document.getElementById('current-name')?.addEventListener('focusout', async e => {
|
|
const space = String.raw`(\n|\s|<br>| )`;
|
|
const re = new RegExp(`^${space}+|${space}+$`, 'msgu');
|
|
|
|
let elem = e.target as HTMLElement;
|
|
let str = elem.innerText.replaceAll(re, '');
|
|
if (!str) str = new Color.Rand().alphaNum();
|
|
elem.innerText = str;
|
|
// todo allow images cos it's funny
|
|
|
|
seed = await applyState(data, { side, seed: str, buf, history });
|
|
});
|
|
|
|
document.documentElement.dataset.state = 'ready';
|
|
|
|
async function run(task: (k: Done) => Promise<void>): Promise<void> {
|
|
removeListeners();
|
|
await task(addListeners);
|
|
}
|
|
|
|
function updateFromUrl() {
|
|
run(async k => {
|
|
const newSeed = urlState();
|
|
if (newSeed) {
|
|
const opts = { history, side, seed: newSeed, buf, done: k };
|
|
seed = await applyState(data, opts);
|
|
}
|
|
});
|
|
}
|
|
function runReroll() {
|
|
run(async k => {
|
|
seed = await applyState(data, { side, seed, buf, history, done: k });
|
|
});
|
|
}
|
|
function runSwap() {
|
|
run(async k => {
|
|
side = swapSide(side);
|
|
const cdata = await ensureComposed(buf, data);
|
|
await animateSwapImage(buf, side, cdata, k);
|
|
});
|
|
}
|
|
|
|
function addListeners() {
|
|
window.addEventListener('hashchange', updateFromUrl);
|
|
reroll.addEventListener('click', runReroll);
|
|
swap.addEventListener('click', runSwap);
|
|
}
|
|
function removeListeners() {
|
|
window.removeEventListener('hashchange', updateFromUrl);
|
|
reroll.removeEventListener('click', runReroll);
|
|
swap.removeEventListener('click', runSwap);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', setup);
|