rainbow quox
This commit is contained in:
parent
cad73b232d
commit
dff263856c
6 changed files with 371 additions and 234 deletions
25
rainbow-quox/edit.svg
Normal file
25
rainbow-quox/edit.svg
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="15" height="15" viewBox="0 0 100 91.21">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="gradient" y1="100%" y2="0%">
|
||||
<stop offset="20%" stop-color="hsl(60 90% 95%)" />
|
||||
<stop offset="100%" stop-color="hsl(60 80% 90%)" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="fade">
|
||||
<stop offset="25%" stop-color="white" />
|
||||
<stop offset="87%" stop-color="black" />
|
||||
</linearGradient>
|
||||
|
||||
<mask id="mask">
|
||||
<rect fill="url(#fade)" x="30" y="80" width="70" height="20" />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<g fill="url(#gradient)">
|
||||
<path id="pencil" d="M 70,0 l -70,70 v 21.21 h 21.21 l 70,-70 z" />
|
||||
<path id="line" mask="url(#mask)" d="M 30,91.21 h 70 v -10 h -60 z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 784 B |
|
@ -13,11 +13,11 @@ export class HistoryItem {
|
|||
}
|
||||
|
||||
asHtml(): HTMLButtonElement {
|
||||
const { lines: bg, outer, belly1: belly, fins1: fins } = this.rgb;
|
||||
const { lines, outer, belly1: belly, fins1: fins } = this.rgb;
|
||||
|
||||
const content = `
|
||||
<svg class=history-colors width=30 height=25 viewBox="-10 -10 140 120">
|
||||
<rect x=-10 y=-10 width=140 height=120 fill="${bg.css()}" />
|
||||
<rect x=-10 y=-10 width=140 height=120 fill="${lines.css()}" />
|
||||
<path fill="${fins.css()}" d="M 60,0 h -57.73 v 100 z">
|
||||
<title>fin colour: ${fins.css()}</title>
|
||||
</path>
|
||||
|
@ -53,21 +53,23 @@ export class History {
|
|||
|
||||
add(name: string): void { this.items.push(name); }
|
||||
|
||||
*iterNames(maxLength?: number | null): Iterable<string> {
|
||||
*iterNames(maxLength: number = 100): Iterable<string> {
|
||||
let seen = new Set<string>;
|
||||
let done = 0;
|
||||
if (maxLength === undefined) maxLength = 100;
|
||||
|
||||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||||
if (maxLength !== null && done > maxLength) break;
|
||||
if (maxLength >= 0 && done > maxLength) break;
|
||||
const name = this.items[i]!;
|
||||
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name); done++;
|
||||
yield name;
|
||||
seen.add(name);
|
||||
done++;
|
||||
}
|
||||
}
|
||||
|
||||
*iterItems(maxLength?: number | null): Iterable<HistoryItem> {
|
||||
// pass a negative number to iterate over all
|
||||
*iterItems(maxLength?: number): Iterable<HistoryItem> {
|
||||
for (const name of this.iterNames(maxLength)) {
|
||||
const oklch = Color.colors(new Color.Rand(name), Color.KNOWN[name]);
|
||||
const rgbs = Color.toRgbs(oklch);
|
||||
|
@ -77,37 +79,26 @@ export class History {
|
|||
}
|
||||
|
||||
static validate(x: unknown): History | undefined {
|
||||
if (!Array.isArray(x)) return;
|
||||
if (!x.every(i => typeof i === 'string')) return;
|
||||
return new History(x);
|
||||
if (Array.isArray(x) && x.every(i => typeof i == 'string'))
|
||||
return new History(x);
|
||||
}
|
||||
|
||||
toJSON() { return this.items; }
|
||||
toJSON(): unknown { return this.items; }
|
||||
|
||||
save(persist = true) {
|
||||
save(persist = true): void {
|
||||
const storage = persist ? localStorage : sessionStorage;
|
||||
storage.setItem('history', JSON.stringify(this));
|
||||
}
|
||||
|
||||
// if the json was invalid, return it
|
||||
// if no history exists just start a new one
|
||||
static load(): History | string {
|
||||
// if no history exists, or it's invalid, just start a new one
|
||||
static load(): History {
|
||||
const json =
|
||||
sessionStorage.getItem('history') ??
|
||||
localStorage.getItem('history');
|
||||
if (json != null) {
|
||||
let h = History.validate(JSON.parse(json));
|
||||
if (h) { h.prune(); return h; }
|
||||
else return json;
|
||||
} else {
|
||||
return new History;
|
||||
}
|
||||
}
|
||||
|
||||
// if the json is invalid, discard it
|
||||
static loadOrClear(): History {
|
||||
const h = History.load();
|
||||
return h instanceof History ? h : new History;
|
||||
if (json === null) return new History;
|
||||
|
||||
return History.validate(JSON.parse(json)) ?? new History;
|
||||
}
|
||||
|
||||
addSave(name: string, persist = true): void {
|
||||
|
@ -115,14 +106,9 @@ export class History {
|
|||
this.save(persist);
|
||||
}
|
||||
|
||||
prune(maxLength?: number | null) {
|
||||
prune(maxLength?: number): void {
|
||||
let keep = [];
|
||||
for (let name of this.iterNames(maxLength)) keep.push(name);
|
||||
this.items = keep.reverse();
|
||||
}
|
||||
|
||||
pruneSave(maxLength?: number | null, persist = true) {
|
||||
this.prune(maxLength);
|
||||
this.save(persist);
|
||||
}
|
||||
}
|
||||
|
|
161
rainbow-quox/script/layer.ts
Normal file
161
rainbow-quox/script/layer.ts
Normal file
|
@ -0,0 +1,161 @@
|
|||
import * as Color from './color.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);
|
||||
}
|
||||
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function loadImageData(url: string, buf?: Buffer): Promise<ImageData> {
|
||||
if (buf && navigator.locks) return loadDataLocking(url, buf);
|
||||
else return loadDataFresh(url);
|
||||
}
|
||||
|
||||
|
||||
export const WIDTH = 1040;
|
||||
export const HEIGHT = 713;
|
||||
|
||||
export 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) return makeBuffer(width, height);
|
||||
}
|
||||
|
||||
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'];
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
|
||||
export function loadLayers(dir: string): Promise<Record<Layer, ImageData>> {
|
||||
let buf = makeBufferIfLocks(WIDTH, HEIGHT);
|
||||
return makeLayerInfoAsync(l => loadImageData(`./${dir}/${l}.webp`, buf));
|
||||
}
|
||||
|
||||
|
||||
export type Position = [x: number, y: number];
|
||||
export type Positions = Record<Layer, Position>;
|
||||
|
||||
export async function loadPos(dir: string): Promise<Positions> {
|
||||
return (await fetch(`./${dir}/pos.json`)).json();
|
||||
}
|
||||
|
||||
|
||||
export type Side = 'front' | 'back';
|
||||
|
||||
export function swapSide(s: Side): Side {
|
||||
return s == 'front' ? 'back' : 'front';
|
||||
}
|
||||
|
||||
export type SideData = Record<Layer, [ImageData, Position]>;
|
||||
|
||||
export type Data = {
|
||||
front: SideData, back: SideData,
|
||||
frontImage?: ImageData, backImage?: ImageData,
|
||||
};
|
||||
|
||||
export type ComposedData = Required<Data>;
|
||||
|
||||
export async function loadData(): Promise<Data> {
|
||||
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 recolor({ 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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recolorAll(layers: Data, cols: Color.Rgbs) {
|
||||
await Promise.all(Color.allLayers.map(l => {
|
||||
recolor(layers.front[l][0], cols[l]);
|
||||
recolor(layers.back[l][0], cols[l]);
|
||||
}));
|
||||
delete layers.frontImage; delete layers.backImage;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export async function
|
||||
ensureComposed(buf: Buffer, data: Data): 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'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function redraw(ctx: CanvasRenderingContext2D,
|
||||
buf: Buffer, data: ComposedData, side: Side) {
|
||||
await ensureComposed(buf, data);
|
||||
ctx.putImageData(data[`${side}Image`], 0, 0);
|
||||
}
|
64
rainbow-quox/script/palette.ts
Normal file
64
rainbow-quox/script/palette.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { Rgb, Rgbs, rgb } from './color.js';
|
||||
import { Layer } from './layer.js';
|
||||
|
||||
export type Color =
|
||||
Exclude<Layer, 'eyeshine' | 'stroke' | 'static'>
|
||||
| 'collars' | 'bells' | 'tongues' | 'socks' | 'sclera';
|
||||
|
||||
// in palette order
|
||||
export const COLORS: Color[] =
|
||||
['lines', 'outer', 'vitiligo1', 'spines', 'fins1', 'fins2', 'fins3',
|
||||
'vitiligo4', 'belly1', 'vitiligo3', 'belly2', 'vitiligo2', 'sclera',
|
||||
'eyes', 'tongues', 'masks', 'claws', 'socks', 'stripes', 'cuffs',
|
||||
'collars', 'bells'];
|
||||
|
||||
export const NAMES: Partial<Record<Color, string>> = {
|
||||
outer: 'outer body',
|
||||
stripes: 'sock stripes',
|
||||
cuffs: 'sock cuffs',
|
||||
fins1: 'fins (outer)',
|
||||
fins2: 'fins (mid)',
|
||||
fins3: 'fins (inner)',
|
||||
belly1: 'belly 1',
|
||||
belly2: 'belly 2',
|
||||
vitiligo1: 'outer body vitiligo',
|
||||
vitiligo2: 'belly 2 vitiligo',
|
||||
vitiligo3: 'belly 1 vitiligo',
|
||||
vitiligo4: 'fins vitiligo',
|
||||
};
|
||||
|
||||
export function name(l: Color): string {
|
||||
return NAMES[l] ?? l;
|
||||
}
|
||||
|
||||
export type StaticColor = Exclude<Color, Layer>;
|
||||
|
||||
export const STATIC_COLS: Record<StaticColor, Rgb> = {
|
||||
collars: rgb(206, 75, 101),
|
||||
bells: rgb(235, 178, 79),
|
||||
tongues: rgb(222, 165, 184),
|
||||
socks: rgb(238, 239, 228),
|
||||
sclera: rgb(238, 239, 228),
|
||||
};
|
||||
|
||||
export function get(col: Color, palette: Rgbs): Rgb {
|
||||
type PPalette = Partial<Record<Color, Rgb>>;
|
||||
let p = palette as PPalette;
|
||||
let s = STATIC_COLS as PPalette;
|
||||
return (p[col] ?? s[col])!;
|
||||
}
|
||||
|
||||
export function make(seed: string, palette: Rgbs): Blob {
|
||||
let lines = [
|
||||
"GIMP Palette\n",
|
||||
`Name: quox ${seed}\n`,
|
||||
"Columns: 6\n\n",
|
||||
];
|
||||
|
||||
for (const col of COLORS) {
|
||||
let { r, g, b } = get(col, palette);
|
||||
lines.push(`${r} ${g} ${b} ${name(col)}\n`);
|
||||
}
|
||||
|
||||
return new Blob(lines, { type: 'application/x-gimp-palette' });
|
||||
}
|
|
@ -1,177 +1,16 @@
|
|||
import * as Color from './color.js';
|
||||
import { History } from './history.js';
|
||||
import * as Layer from './layer.js';
|
||||
import * as Palette from './palette.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) {
|
||||
function message(msg: string, size = 100) {
|
||||
const ctx = getCanvasCtx('main');
|
||||
const size = error ? 30 : 100;
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
ctx.clearRect(0, 0, Layer.WIDTH, Layer.HEIGHT);
|
||||
ctx.font = `bold ${size}px Muller, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(msg, WIDTH/2, HEIGHT/2, WIDTH-10);
|
||||
ctx.fillText(msg, Layer.WIDTH/2, Layer.HEIGHT/2, Layer.WIDTH-10);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
@ -187,19 +26,19 @@ function updateUrl(seed: string): void {
|
|||
|
||||
type ApplyStateOpts = {
|
||||
seed: string,
|
||||
side?: Side,
|
||||
side?: Layer.Side,
|
||||
firstLoad?: boolean,
|
||||
buf?: Buffer,
|
||||
buf?: Layer.Buffer,
|
||||
history?: History,
|
||||
done?: Done,
|
||||
};
|
||||
|
||||
async function
|
||||
applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
|
||||
applyState(data: Layer.Data, opts: ApplyStateOpts): Promise<string> {
|
||||
let { side, seed, firstLoad, buf, history, done } = opts;
|
||||
side ??= 'front';
|
||||
firstLoad ??= false;
|
||||
buf ??= new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
|
||||
buf ??= Layer.makeBuffer();
|
||||
done ??= () => {};
|
||||
|
||||
let rand = new Color.Rand(seed);
|
||||
|
@ -208,7 +47,7 @@ applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
|
|||
const rgb = Color.toRgbs(oklch);
|
||||
const newSeed = rand.alphaNum();
|
||||
|
||||
await recolorLayers(data, rgb);
|
||||
await Layer.recolorAll(data, rgb);
|
||||
|
||||
updateBg(oklch);
|
||||
updateSvgs(oklch, rgb);
|
||||
|
@ -216,7 +55,7 @@ applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
|
|||
updateUrl(seed);
|
||||
|
||||
if (firstLoad) {
|
||||
await instantUpdateImage(side, await ensureComposed(buf, data));
|
||||
await instantUpdateImage(side, await Layer.ensureComposed(buf, data));
|
||||
done();
|
||||
} else {
|
||||
await animateUpdateImage(buf, side, data, done);
|
||||
|
@ -235,7 +74,7 @@ function getCanvasCtx(id: CanvasId) {
|
|||
}
|
||||
|
||||
async function
|
||||
instantUpdateImage(side: Side, data: ComposedData) {
|
||||
instantUpdateImage(side: Layer.Side, data: Layer.ComposedData) {
|
||||
getCanvasCtx('main').putImageData(data[`${side}Image`], 0, 0);
|
||||
}
|
||||
|
||||
|
@ -244,9 +83,10 @@ type Done = () => void;
|
|||
const noAnim = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
async function
|
||||
animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
|
||||
animateUpdateImage(buf: Layer.Buffer, side: Layer.Side,
|
||||
data: Layer.Data, done: Done) {
|
||||
if (noAnim.matches) {
|
||||
instantUpdateImage(side, await ensureComposed(buf, data));
|
||||
instantUpdateImage(side, await Layer.ensureComposed(buf, data));
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
@ -257,11 +97,11 @@ animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
|
|||
const aux = getCanvasCtx('aux');
|
||||
|
||||
document.documentElement.dataset.running = 'reroll';
|
||||
const cdata = await ensureComposed(buf, data);
|
||||
redraw(aux, buf, cdata, side);
|
||||
const cdata = await Layer.ensureComposed(buf, data);
|
||||
Layer.redraw(aux, buf, cdata, side);
|
||||
|
||||
aux.canvas.addEventListener('animationend', async () => {
|
||||
await redraw(main, buf, cdata, side);
|
||||
await Layer.redraw(main, buf, cdata, side);
|
||||
aux.canvas.style.removeProperty('animation');
|
||||
delete document.documentElement.dataset.running;
|
||||
done();
|
||||
|
@ -273,7 +113,8 @@ animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
|
|||
}
|
||||
|
||||
async function
|
||||
animateSwapImage(buf: Buffer, newSide: Side, data: ComposedData, done: Done) {
|
||||
animateSwapImage(buf: Layer.Buffer, newSide: Layer.Side,
|
||||
data: Layer.ComposedData, done: Done) {
|
||||
if (noAnim.matches) {
|
||||
instantUpdateImage(newSide, data);
|
||||
done();
|
||||
|
@ -286,10 +127,10 @@ animateSwapImage(buf: Buffer, newSide: Side, data: ComposedData, done: Done) {
|
|||
const aux = getCanvasCtx('aux');
|
||||
|
||||
document.documentElement.dataset.running = 'swap';
|
||||
await redraw(aux, buf, data, newSide);
|
||||
await Layer.redraw(aux, buf, data, newSide);
|
||||
|
||||
aux.canvas.addEventListener('animationend', async () => {
|
||||
const image = aux.getImageData(0, 0, WIDTH, HEIGHT);
|
||||
const image = aux.getImageData(0, 0, Layer.WIDTH, Layer.HEIGHT);
|
||||
main.putImageData(image, 0, 0);
|
||||
|
||||
main.canvas.style.removeProperty('animation');
|
||||
|
@ -342,7 +183,7 @@ function updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) {
|
|||
}
|
||||
|
||||
|
||||
function showHistory(history: History, data: LayerData,
|
||||
function showHistory(history: History, data: Layer.Data,
|
||||
opts: Omit<ApplyStateOpts, 'seed' | 'history'>) {
|
||||
const list = document.getElementById('history-items');
|
||||
if (!list) return;
|
||||
|
@ -369,7 +210,7 @@ function showHistory(history: History, data: LayerData,
|
|||
|
||||
function closeHistory() {
|
||||
document.getElementById('history-items')?.
|
||||
scroll({top: 0, left: 0, behavior: 'smooth'});
|
||||
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';
|
||||
|
@ -377,18 +218,7 @@ function closeHistory() {
|
|||
|
||||
function download(seed: string) {
|
||||
const colors = Color.toRgbs(Color.colors(new Color.Rand(seed)));
|
||||
|
||||
let lines = [
|
||||
"GIMP Palette\n",
|
||||
`Name: quox ${seed}\n\n`,
|
||||
];
|
||||
|
||||
for (const name of Color.allLayers) {
|
||||
let { r, g, b } = colors[name];
|
||||
lines.push(`${r} ${g} ${b} ${name}\n`);
|
||||
}
|
||||
|
||||
const blob = new Blob(lines, { type: 'application/x-gimp-palette' });
|
||||
const blob = Palette.make(seed, colors);
|
||||
|
||||
// there must be a better way to push out a file than
|
||||
// this autohotkey-ass nonsense
|
||||
|
@ -403,15 +233,15 @@ function download(seed: string) {
|
|||
async function setup() {
|
||||
message('loading layers…');
|
||||
|
||||
let data = await loadData().catch(e => { message(e, true); throw e });
|
||||
let history = History.loadOrClear();
|
||||
let data = await Layer.loadData().catch(e => { message(e, 30); throw e });
|
||||
let history = History.load();
|
||||
|
||||
let buf = new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
|
||||
let buf = Layer.makeBuffer();
|
||||
|
||||
let prevSeed = urlState() ?? new Color.Rand().alphaNum();
|
||||
let seed =
|
||||
await applyState(data, { seed: prevSeed, buf, history, firstLoad: true });
|
||||
let side: Side = 'front';
|
||||
let side: Layer.Side = 'front';
|
||||
|
||||
const reroll = document.getElementById('reroll')!;
|
||||
const swap = document.getElementById('swap')!;
|
||||
|
@ -473,8 +303,8 @@ async function setup() {
|
|||
}
|
||||
function runSwap() {
|
||||
run(async k => {
|
||||
side = swapSide(side);
|
||||
const cdata = await ensureComposed(buf, data);
|
||||
side = Layer.swapSide(side);
|
||||
const cdata = await Layer.ensureComposed(buf, data);
|
||||
await animateSwapImage(buf, side, cdata, k);
|
||||
});
|
||||
}
|
||||
|
|
71
rainbow-quox/style/defs.scss
Normal file
71
rainbow-quox/style/defs.scss
Normal file
|
@ -0,0 +1,71 @@
|
|||
$box-texture: url(3px-tile.png);
|
||||
$box-bg: oklch(0.3 0.2 var(--hue));
|
||||
$box-fg: oklch(0.95 0.075 var(--c-hue));
|
||||
|
||||
$button-bg: oklch(0.5 0.25 var(--hue));
|
||||
$button-fg: oklch(0.98 0.1 var(--c-hue));
|
||||
|
||||
|
||||
// https://oakreef.ie/transy :)
|
||||
$transition-duration: 250ms;
|
||||
$transition-curve: cubic-bezier(.47,.74,.61,1.2);
|
||||
|
||||
@mixin transy($prop: transform, $duration: $transition-duration) {
|
||||
transition: $prop $duration $transition-curve;
|
||||
}
|
||||
|
||||
@mixin shadow {
|
||||
filter: drop-shadow(6px 6px 0 oklch(0.4 0.2 var(--hue) / 0.45));
|
||||
@media (prefers-color-scheme: dark) {
|
||||
filter: drop-shadow(6px 6px 0 oklch(0.1 0.125 var(--hue) / 0.45));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@mixin box-base {
|
||||
@include shadow;
|
||||
// respecify font family for <button>
|
||||
font: 700 20pt var(--font);
|
||||
padding: 0.2rem 0.5rem;
|
||||
background-blend-mode: hard-light;
|
||||
border: 3px solid oklch(0.2 0.05 var(--hue));
|
||||
}
|
||||
|
||||
@mixin button {
|
||||
@include box-base;
|
||||
background: $box-texture, $button-bg;
|
||||
color: $button-fg;
|
||||
}
|
||||
|
||||
@mixin box {
|
||||
@include box-base;
|
||||
background: $box-texture, $box-bg;
|
||||
color: $box-fg;
|
||||
}
|
||||
|
||||
@mixin image-button {
|
||||
@include button;
|
||||
padding: 5px;
|
||||
> * { display: block; }
|
||||
}
|
||||
|
||||
@mixin nested-image-button {
|
||||
@include image-button;
|
||||
border: 2px solid $button-fg;
|
||||
}
|
||||
|
||||
$arrowhead:
|
||||
conic-gradient(from -124deg at 100% 50%,
|
||||
currentcolor, currentcolor 68deg, transparent 68deg);
|
||||
|
||||
@mixin arrow-button {
|
||||
@include button;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 1.25ex; aspect-ratio: 2/3;
|
||||
background: $arrowhead;
|
||||
margin-right: 0.5ex;
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue