Compare commits

..

No commits in common. "dff263856c9e8a547b7e8af438caaeca3b9ca34c" and "898c7753a42d183d7e2db91bc3d108639160fd47" have entirely different histories.

71 changed files with 402 additions and 844 deletions

View file

@ -1,16 +1,15 @@
PAGES = index.html pubkey.txt rainbow-quox/index.html \
dnd/index.html $(wildcard dnd/*/index.html)
PAGES = index.html pubkey.txt rainbow-quox/index.html velzek/index.html
MEDIA = \
$(wildcard media/*.png) $(wildcard media/*.gif) $(wildcard media/*.webp) \
$(wildcard media/flags/*) $(wildcard media/buttons/*) \
$(wildcard media/icons/*) $(wildcard media/bg/*) 8831.png 8831-quox.png \
$(wildcard rainbow-quox/front/*) $(wildcard rainbow-quox/back/*) \
$(wildcard rainbow-quox/*.svg) rainbow-quox/palette.svg \
$(wildcard dnd/*.png) $(wildcard dnd/*.webp) $(wildcard dnd/*/*.webp)
$(wildcard velzek/*.webp) $(wildcard velzek/*.png)
CSS = $(shell find fonts -type f) \
$(patsubst %.scss,%.css, \
$(wildcard rainbow-quox/style/*) $(wildcard style/*)) \
dnd/base.css dnd/bio.css dnd/index.css $(wildcard dnd/*/style.css)
velzek/style.css
SCRIPTS = $(patsubst %.ts,%.js,$(wildcard script/*.ts rainbow-quox/script/*.ts))
MISC = $(shell find .well-known -type f)
ALL = $(CSS) $(PAGES) $(MEDIA) $(SCRIPTS) $(MISC)

View file

@ -1,38 +0,0 @@
@import url(base.css);
@layer base.headings {
h2 {
margin: 2rem 3rem;
position: relative;
border-bottom: 3px double currentcolor;
&::before {
font-feature-settings: "ornm" 5;
position: absolute;
left: -1.15em;
bottom: 7%;
rotate: -5deg;
}
}
}
@layer base.other {
table {
min-width: 20rem;
margin: 0 3rem;
border-bottom: 2px solid currentcolor;
}
caption {
border-top: 2px solid currentcolor;
border-bottom: 1px solid currentcolor;
font-weight: 700;
text-align: center;
.and { font-weight: 450; }
}
th, td, caption { padding: 0.125lh 0.5em 0.0625lh; }
tbody th { text-align: right; }
}

View file

@ -1,71 +0,0 @@
@import url(base.css);
@layer {
header { row-gap: 0; }
header p {
grid-area: 2/1/2/4;
margin: 0;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
font-stretch: 90%;
}
}
@layer {
.portrait {
margin: 0;
border: 4px solid currentcolor;
box-shadow: var(--shadow);
img {
display: block;
width: 200px;
height: 200px;
}
}
.char {
width: 80%;
margin-left: auto;
margin-right: auto;
padding-left: 1em;
padding-right: 1em;
display: grid;
gap: 0.5em 2em;
&:nth-of-type(odd) {
grid-template:
"portrait name" auto
"portrait desc" 1fr / min-content auto;
}
&:nth-of-type(even) {
grid-template:
"name portrait" auto
"desc portrait" 1fr / auto min-content;
}
.portrait { grid-area: portrait; }
h2 { grid-area: name; margin: 0; }
> div, p { grid-area: desc; align-self: start; }
}
}
@layer {
.char {
margin-top: 2em;
padding-top: 2em;
border-top: 3px double currentcolor;
}
.char:last-of-type {
padding-bottom: 2em;
border-bottom: 3px double currentcolor;
}
h2 small {
font-size: 70%;
font-weight: 700;
}
}

View file

@ -1,83 +0,0 @@
<!doctype html>
<html lang=en>
<meta charset=utf-8>
<title>d&amp;d chars</title>
<link rel=stylesheet href=index.css>
<meta name=viewport content='width=device-width; initial-scale=0.75'>
<header>
<h1>d<span class=amp>&amp;</span>d chars</h1>
<p> blorbos from my tabletop
</header>
<section class=char id=velzek>
<figure class=portrait>
<a href=velzek>
<img src=velzek.webp srcset='velzek2x.webp 2x'>
</a>
</figure>
<h2>
<a href=velzek>velzek hawthorne</a>
</h2>
<p>
anthropologist observing the humanoid society in marikest, when a suspicious
sequence of events plunge the city into chaos.
(2024)
</section>
<section class=char id=marigold>
<figure class=portrait>
<a href=marigold>
<img src=marigold.webp srcset='marigold2x.webp 2x'>
</a>
</figure>
<h2>
<a href=marigold><small>(call me)</small> marigold</a>
</h2>
<p>
disgraced acolyte of bahamut sent out into the world to atone for her
mistakes.
(2024)
</section>
<section class=char id=nex>
<figure class=portrait>
<a href=nex> <img src=nex.webp srcset='nex2x.webp 2x'> </a>
</figure>
<h2>
<a href=nex>nex</a>
</h2>
<p>
went to hell and back to stop the world from disintegrating. you know,
normal stuff.
(202024)
</section>
<section class=char id=kezda>
<figure class=portrait>
<a href=kezda> <img src=kezda.webp srcset='kezda2x.webp 2x'> </a>
</figure>
<h2>
<a href=kezda>kezda</a>
</h2>
<p>
little lizard who saw magic one time and never stopped thinking about it
<br>
(2019; <small>game abandoned but i still like this lil gremlin</small>)
</section>
<footer>
<a href=.. aria-label=back></a>
</footer>

BIN
dnd/kezda.webp (Stored with Git LFS)

Binary file not shown.

View file

@ -1,28 +0,0 @@
<!doctype html>
<html lang=en>
<meta charset=utf-8>
<title>kezda</title>
<link rel=stylesheet href=style.css>
<link rel=icon href=icon.webp>
<meta name=viewport content='width=device-width; initial-scale=0.75'>
<header>
<h1 id=top>kezda</h1>
</header>
<section id=info>
<h2>basic info</h2>
<figure id=char-pic class=mainfig>
<img src=kezda.webp srcset='kezda2x.webp 2x'
alt='kezda with their familar poking out of a hole in their hat'>
</figure>
a lil creature
</section>
<footer>
<a href=.. aria-label=back></a>
</footer>

BIN
dnd/kezda/kezda.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/kezda/kezda2x.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/kezda/src/kezda-full.kra (Stored with Git LFS)

Binary file not shown.

View file

@ -1,3 +0,0 @@
@import url(../bio.css);

BIN
dnd/kezda2x.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/marigold.webp (Stored with Git LFS)

Binary file not shown.

View file

@ -1,35 +0,0 @@
<!doctype html>
<html lang=en>
<meta charset=utf-8>
<title>marigold</title>
<link rel=stylesheet href=style.css>
<link rel=icon href=icon.webp>
<meta name=viewport content='width=device-width; initial-scale=0.75'>
<header>
<h1 id=top>marigold</h1>
</header>
<section id=info>
<h2>basic info</h2>
<table class=stats>
<caption> warlock of bahamut
<tbody>
<tr> <th> height <td> 198 cm (6ʹ 6ʺ)
<tr> <th> weight <td> 121 kg (267 ℔)
<tr> <th> age <td> 37
</table>
<p>
lets get it out of the way. her actual name is, um, lets see here…
<span class=ipa>[ˈqχḁʂx̩kɬ̩ːχ]</span>. which is why she goes by marigold
among the humanoids.
</section>
<footer>
<a href=.. aria-label=back></a>
</footer>

View file

@ -1,6 +0,0 @@
@import url(../bio.css);
.ipa {
font-feature-settings: "ss03" 1;
font-variation-settings: "ENLA" 0;
}

BIN
dnd/marigold2x.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/nex.kra-autosave.kra (Stored with Git LFS)

Binary file not shown.

BIN
dnd/nex.webp (Stored with Git LFS)

Binary file not shown.

View file

@ -1,22 +0,0 @@
<!doctype html>
<html lang=en>
<meta charset=utf-8>
<title>nex</title>
<link rel=stylesheet href=../bio.css>
<link rel=icon href=icon.webp>
<meta name=viewport content='width=device-width; initial-scale=0.75'>
<header>
<h1 id=top>nex</h1>
</header>
<section id=info>
<h2>basic info</h2>
</section>
<footer>
<a href=.. aria-label=back></a>
</footer>

BIN
dnd/nex2x.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/src/kezda.kra (Stored with Git LFS)

Binary file not shown.

BIN
dnd/src/marigold.kra (Stored with Git LFS)

Binary file not shown.

BIN
dnd/src/nex.kra (Stored with Git LFS)

Binary file not shown.

BIN
dnd/src/velzek.kra (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/armour.s.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/armour.s2x.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/clothes.s.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/clothes.s2x.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/map_k.s.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/map_k.s2x.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/src/map_k_full.kra (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/src/suveesha.kra (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/src/velzek.kra (Stored with Git LFS)

Binary file not shown.

View file

@ -1,8 +0,0 @@
@import url(../bio.css);
@layer {
#char-pic {
shape-outside: polygon(100% 0%, 13% 0%, 13% 25%, 0% 27%,
0% 51%, 18% 60%, 21% 100%, 100% 100%);
}
}

BIN
dnd/velzek/suveesha.s.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek/suveesha.s2x.webp (Stored with Git LFS)

Binary file not shown.

BIN
dnd/velzek2x.webp (Stored with Git LFS)

Binary file not shown.

View file

@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 784 B

View file

@ -13,11 +13,11 @@ export class HistoryItem {
}
asHtml(): HTMLButtonElement {
const { lines, outer, belly1: belly, fins1: fins } = this.rgb;
const { lines: bg, 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="${lines.css()}" />
<rect x=-10 y=-10 width=140 height=120 fill="${bg.css()}" />
<path fill="${fins.css()}" d="M 60,0 h -57.73 v 100 z">
<title>fin colour: ${fins.css()}</title>
</path>
@ -53,23 +53,21 @@ export class History {
add(name: string): void { this.items.push(name); }
*iterNames(maxLength: number = 100): Iterable<string> {
*iterNames(maxLength?: number | null): 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 >= 0 && done > maxLength) break;
if (maxLength !== null && done > maxLength) break;
const name = this.items[i]!;
if (!name || seen.has(name)) continue;
seen.add(name); done++;
yield name;
seen.add(name);
done++;
}
}
// pass a negative number to iterate over all
*iterItems(maxLength?: number): Iterable<HistoryItem> {
*iterItems(maxLength?: number | null): 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);
@ -79,26 +77,37 @@ export class History {
}
static validate(x: unknown): History | undefined {
if (Array.isArray(x) && x.every(i => typeof i == 'string'))
return new History(x);
if (!Array.isArray(x)) return;
if (!x.every(i => typeof i === 'string')) return;
return new History(x);
}
toJSON(): unknown { return this.items; }
toJSON() { return this.items; }
save(persist = true): void {
save(persist = true) {
const storage = persist ? localStorage : sessionStorage;
storage.setItem('history', JSON.stringify(this));
}
// if no history exists, or it's invalid, just start a new one
static load(): History {
// if the json was invalid, return it
// if no history exists just start a new one
static load(): History | string {
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 (json === null) return new History;
return History.validate(JSON.parse(json)) ?? new History;
// if the json is invalid, discard it
static loadOrClear(): History {
const h = History.load();
return h instanceof History ? h : new History;
}
addSave(name: string, persist = true): void {
@ -106,9 +115,14 @@ export class History {
this.save(persist);
}
prune(maxLength?: number): void {
prune(maxLength?: number | null) {
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);
}
}

View file

@ -1,161 +0,0 @@
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);
}

View file

@ -1,64 +0,0 @@
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' });
}

View file

@ -1,16 +1,177 @@
import * as Color from './color.js';
import { History } from './history.js';
import * as Layer from './layer.js';
import * as Palette from './palette.js';
function message(msg: string, size = 100) {
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, Layer.WIDTH, Layer.HEIGHT);
ctx.clearRect(0, 0, WIDTH, HEIGHT);
ctx.font = `bold ${size}px Muller, sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(msg, Layer.WIDTH/2, Layer.HEIGHT/2, Layer.WIDTH-10);
ctx.fillText(msg, WIDTH/2, HEIGHT/2, WIDTH-10);
ctx.restore();
}
@ -26,19 +187,19 @@ function updateUrl(seed: string): void {
type ApplyStateOpts = {
seed: string,
side?: Layer.Side,
side?: Side,
firstLoad?: boolean,
buf?: Layer.Buffer,
buf?: Buffer,
history?: History,
done?: Done,
};
async function
applyState(data: Layer.Data, opts: ApplyStateOpts): Promise<string> {
applyState(data: LayerData, opts: ApplyStateOpts): Promise<string> {
let { side, seed, firstLoad, buf, history, done } = opts;
side ??= 'front';
firstLoad ??= false;
buf ??= Layer.makeBuffer();
buf ??= new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
done ??= () => {};
let rand = new Color.Rand(seed);
@ -47,7 +208,7 @@ applyState(data: Layer.Data, opts: ApplyStateOpts): Promise<string> {
const rgb = Color.toRgbs(oklch);
const newSeed = rand.alphaNum();
await Layer.recolorAll(data, rgb);
await recolorLayers(data, rgb);
updateBg(oklch);
updateSvgs(oklch, rgb);
@ -55,7 +216,7 @@ applyState(data: Layer.Data, opts: ApplyStateOpts): Promise<string> {
updateUrl(seed);
if (firstLoad) {
await instantUpdateImage(side, await Layer.ensureComposed(buf, data));
await instantUpdateImage(side, await ensureComposed(buf, data));
done();
} else {
await animateUpdateImage(buf, side, data, done);
@ -74,7 +235,7 @@ function getCanvasCtx(id: CanvasId) {
}
async function
instantUpdateImage(side: Layer.Side, data: Layer.ComposedData) {
instantUpdateImage(side: Side, data: ComposedData) {
getCanvasCtx('main').putImageData(data[`${side}Image`], 0, 0);
}
@ -83,10 +244,9 @@ type Done = () => void;
const noAnim = matchMedia('(prefers-reduced-motion: reduce)');
async function
animateUpdateImage(buf: Layer.Buffer, side: Layer.Side,
data: Layer.Data, done: Done) {
animateUpdateImage(buf: Buffer, side: Side, data: LayerData, done: Done) {
if (noAnim.matches) {
instantUpdateImage(side, await Layer.ensureComposed(buf, data));
instantUpdateImage(side, await ensureComposed(buf, data));
done();
return;
}
@ -97,11 +257,11 @@ animateUpdateImage(buf: Layer.Buffer, side: Layer.Side,
const aux = getCanvasCtx('aux');
document.documentElement.dataset.running = 'reroll';
const cdata = await Layer.ensureComposed(buf, data);
Layer.redraw(aux, buf, cdata, side);
const cdata = await ensureComposed(buf, data);
redraw(aux, buf, cdata, side);
aux.canvas.addEventListener('animationend', async () => {
await Layer.redraw(main, buf, cdata, side);
await redraw(main, buf, cdata, side);
aux.canvas.style.removeProperty('animation');
delete document.documentElement.dataset.running;
done();
@ -113,8 +273,7 @@ animateUpdateImage(buf: Layer.Buffer, side: Layer.Side,
}
async function
animateSwapImage(buf: Layer.Buffer, newSide: Layer.Side,
data: Layer.ComposedData, done: Done) {
animateSwapImage(buf: Buffer, newSide: Side, data: ComposedData, done: Done) {
if (noAnim.matches) {
instantUpdateImage(newSide, data);
done();
@ -127,10 +286,10 @@ animateSwapImage(buf: Layer.Buffer, newSide: Layer.Side,
const aux = getCanvasCtx('aux');
document.documentElement.dataset.running = 'swap';
await Layer.redraw(aux, buf, data, newSide);
await redraw(aux, buf, data, newSide);
aux.canvas.addEventListener('animationend', async () => {
const image = aux.getImageData(0, 0, Layer.WIDTH, Layer.HEIGHT);
const image = aux.getImageData(0, 0, WIDTH, HEIGHT);
main.putImageData(image, 0, 0);
main.canvas.style.removeProperty('animation');
@ -183,7 +342,7 @@ function updateSvgs(oklch: Color.Colors, rgb: Color.Rgbs) {
}
function showHistory(history: History, data: Layer.Data,
function showHistory(history: History, data: LayerData,
opts: Omit<ApplyStateOpts, 'seed' | 'history'>) {
const list = document.getElementById('history-items');
if (!list) return;
@ -210,7 +369,7 @@ function showHistory(history: History, data: Layer.Data,
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';
@ -218,7 +377,18 @@ function closeHistory() {
function download(seed: string) {
const colors = Color.toRgbs(Color.colors(new Color.Rand(seed)));
const blob = Palette.make(seed, colors);
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' });
// there must be a better way to push out a file than
// this autohotkey-ass nonsense
@ -233,15 +403,15 @@ function download(seed: string) {
async function setup() {
message('loading layers…');
let data = await Layer.loadData().catch(e => { message(e, 30); throw e });
let history = History.load();
let data = await loadData().catch(e => { message(e, true); throw e });
let history = History.loadOrClear();
let buf = Layer.makeBuffer();
let buf = new OffscreenCanvas(WIDTH, HEIGHT).getContext('2d')!;
let prevSeed = urlState() ?? new Color.Rand().alphaNum();
let seed =
await applyState(data, { seed: prevSeed, buf, history, firstLoad: true });
let side: Layer.Side = 'front';
let side: Side = 'front';
const reroll = document.getElementById('reroll')!;
const swap = document.getElementById('swap')!;
@ -303,8 +473,8 @@ async function setup() {
}
function runSwap() {
run(async k => {
side = Layer.swapSide(side);
const cdata = await Layer.ensureComposed(buf, data);
side = swapSide(side);
const cdata = await ensureComposed(buf, data);
await animateSwapImage(buf, side, cdata, k);
});
}

View file

@ -1,71 +0,0 @@
$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;
}
}

BIN
velzek/armour_small.webp (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/armour_small2x.webp (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/clothes_small.webp (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/clothes_small2x.webp (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -8,6 +8,14 @@
<meta name=viewport content='width=device-width; initial-scale=0.75'>
<meta property=og:type content=article>
<meta property=og:title content="velzek hawthorne">
<meta property=og:description content="kobold cleric and anthropologist">
<meta property=og:url content="https://niss.website/velzek/">
<meta property=og:image content="https://niss.website/velzek/icon.png">
<meta name=twitter:card content=summary_large_image>
<meta name=twitter:image content="https://niss.website/velzek/clothes.webp">
<header>
<h1 id=top>velzek hawthorne</h1>
</header>
@ -15,33 +23,36 @@
<section id=info>
<h2>basic info</h2>
<figure id=char-pic class=mainfig>
<figure id=velzek-pic>
<input type=checkbox id=switch>
<label for=switch> switch </label>
<div id=char-img-holder>
<a href=clothes.webp id=pic1>
<img src=clothes.s.webp srcset='clothes.s2x.webp 2x'
<div id=velzek-img-holder>
<a href=clothes.webp>
<img id=clothes-pic
src=clothes_small.webp srcset='clothes_small2x.webp 2x'
alt='velzek the kobold in green priest garments'>
</a>
<a href=armour.webp id=pic2>
<img src=armour.s.webp srcset='armour.s2x.webp 2x'
<a href=armour.webp>
<img id=armour-pic
src=armour_small.webp srcset='armour_small2x.webp 2x'
alt='velzek the kobold in metal armour holding a mace'>
</a>
</div>
</figure>
<table class=stats>
<caption>
cleric of <a href=#suveesha>suveesha</a>
<span class=and>and anthropologist</span>
<tbody>
<tr> <th> height <td> 74 cm (2ʹ 5ʺ)
<tr> <th> weight <td> 18 kg (40 ℔)
<tr> <th> age <td> 32
<tr> <th> year of birth <td> 1187
</table>
<p>
<strong>cleric of <a href=#suveesha>suveesha</a></strong>
and anthropologist
<dl>
<dt>height <dd> 74 cm (2ʹ 5ʺ)
<dt>weight <dd> 18 kg (40 ℔)
<dt>age <dd> 32
<dt>year of birth <dd> 1187
<dt>alignment <dd> neutral good
</dl>
<p>
<dfn>velzek</dfn> is a kobold from a community called
@ -49,26 +60,24 @@
eight years ago, she and four other kobolds arrived at the temple of berei,
next to the green on the south border of marikest.
<table>
<caption> velzeks companions
<tbody>
<tr> <th> yarva bitterbrush <td> 26, he/him
<tr> <th> keshku æstivæ <td> 28, she/her
<tr> <th> volek ruba <td> 23, she/her
<tr> <th> tokil arceuthus <td> 25, he/him
</table>
<!--
purshia tridentata
æstīvus: summer, but also wheat is triticum aestivum
rubus: blackberry/raspberry
ἄρκευθος: juniper
-->
<p>
her companions are:
<dl>
<dt> yarva bitterbrush <dd> 26, he/him
<!-- purshia tridentata -->
<dt> keshku aestivae <dd> 28, she/her
<!-- æstīvus: summer, but also wheat is triticum aestivum -->
<dt> volek ruba <dd> 23, she/her
<!-- rubus: blackberry/raspberry -->
<dt> tokil arceuthus <dd> 25, he/him
<!-- ἄρκευθος: juniper -->
</dl>
<p>
until arriving in marikest, the concept of a surname was totally unknown to
the kobolds, so they all invented names based on plants for themselves once
they became needed. in the city theyve been under the mentorship of a
halfling named <dfn>bobbie fairchild</dfn>.
they became needed.
<p>
the purpose of the kobolds arrival in marikest is to study outside
@ -82,9 +91,9 @@
<section id=ekkel>
<h2>ekkel</h2>
<figure id=ekkel-pic class=mainfig>
<figure id=ekkel-pic>
<a href=map_k_full.webp>
<img src=map_k.s.webp srcset='map_k.s2x.webp 2x' class=bordered
<img src=map_k.webp class=bordered
alt="ekkel is north of the ruins of avaros dungeon"
title="location of ekkel burrow">
</a>
@ -95,7 +104,7 @@
the windswept wall. despite the historical friction between kobolds and
humanoids, ekkel has enjoyed peace for decades, due to its location far away
from any major surface roads. like all burrows, ekkel is considered to be a
single huge family; kobolds dont consciously keep track of closer kinship
single huge family; kobolds don't consciously keep track of closer kinship
bonds.
<p>
@ -117,10 +126,8 @@
<section id=suveesha>
<h2>suveesha</h2>
<figure id=suveesha-pic class=mainfig>
<a href=suveesha.webp>
<img src=suveesha.s.webp srcset='suveesha.s2x.webp 2x' class=bordered>
</a>
<figure id=suveesha-pic>
<a href=suveesha.webp><img src=suveesha.webp class=bordered></a>
</figure>
<p>
@ -139,10 +146,10 @@
<p>
while amongst the humanoids, velzek and the others have been instructed to
adopt bereis more familiar symbols: a bundle of wheat, a sickle, and a
adopt bereis symbols for familiarity: a bundle of wheat, a sickle, and a
rising sun.
</section>
<footer>
<a href=.. aria-label=back></a>
<a href=/index.html aria-label=back></a>
</footer>

BIN
velzek/map_k.webp (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/armour.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/armour_small.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/armour_small2x.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/clothes.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/clothes_small.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/clothes_small2x.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/icon.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/map_k.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/map_k_full.kra (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/map_k_full.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/suveesha.kra (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/suveesha.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
velzek/src/velzek icon.kra (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -1,5 +1,5 @@
@layer fonts, base, header, footer, images, switcher, phone;
@import url(../fonts/junicodevf/junicodevf.css) layer(fonts);
@layer base, header, footer, images, switcher, phone;
@import url(../fonts/junicodevf/junicodevf.css) layer(base);
@layer base {
* { box-sizing: border-box; }
@ -10,8 +10,8 @@
--text-color: hsl(350deg 40% 15%);
--accent-color: hsl(80deg 50% 35%);
--shadow-color: hsl(from var(--text-color) h 20% 3% / 20%);
--shadow: 3px 2px 0 var(--shadow-color);
--shadow2: -2px 0 0 hsl(from var(--text-color) h 20% 7% / 15%);
--shadow: drop-shadow(3px 2px 0 var(--shadow-color));
--shadow2: drop-shadow(-2px 0 0 hsl(from var(--text-color) h 20% 7% / 15%));
}
:root {
@ -34,18 +34,17 @@
background: url(beige-paper.png), hsl(40deg 80% 80%);
background-blend-mode: multiply;
border: 10px solid currentcolor;
box-shadow: var(--shadow), var(--shadow2);
filter: var(--shadow) var(--shadow2);
}
figure { filter: var(--shadow2); }
}
@layer base.fonts {
:root {
font-family: JunicodeVF, serif;
--base-features:
"ccmp", "calt", "liga", "loca", "rlig", "kern", "mark", "mkmk",
"ss09", "cv69" 6; /* nice */
font-feature-settings: var(--base-features);
font-feature-settings:
"ccmp", "calt", "liga", "loca", "rlig", "kern", "mark", "mkmk";
font-variation-settings: "ENLA" 25;
font-stretch: 125%;
font-weight: 450;
@ -73,6 +72,32 @@
}
}
@layer base.headings {
h1, h2, h3, h4, h5, h6 {
font-stretch: 75%;
font-variation-settings: "ENLA" 0;
}
h2 {
margin: 1em;
position: relative;
border-bottom: 3px double currentcolor;
font-size: 225%;
font-weight: 500;
&::before {
content: '•';
font-size: 80%;
font-feature-settings: "ornm" 5;
position: absolute;
left: -1.15em;
bottom: 7%;
rotate: -5deg;
}
}
}
@layer base.other {
a {
color: inherit;
@ -86,35 +111,19 @@
font-style: italic;
}
.amp {
font-size: 75%;
font-weight: 550;
}
dl { margin: 0 1em; }
dt { font-weight: bold; }
dd { margin: 0; }
small { font-stretch: 100%; }
}
@layer base.headings {
h1, h2, h3, h4, h5, h6 {
font-stretch: 75%;
font-feature-settings: var(--base-features),
"cv02" 1, "cv08" 1, "cv10" 1, "cv12" 10, "cv14" 6, "cv15" 4, "cv16" 1,
"cv24" 5, "cv38" 2, "cv48" 1;
font-variation-settings: "ENLA" 0;
small { font-stretch: 65%; }
}
h2 {
margin: 0 0 0.5rem;
font-size: 225%;
font-weight: 500;
&::before {
content: '•';
font-size: 80%;
font-feature-settings: "ornm" 2;
@media (width >= 70rem) {
dl {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 1em;
}
dt { grid-column-start: 1; }
dd { grid-column-start: 2; }
}
}
@ -164,7 +173,7 @@
}
@layer images {
#char-img-holder {
#velzek-img-holder {
display: grid;
grid-template: "i";
align-items: center;
@ -175,36 +184,37 @@
margin: auto;
}
.mainfig {
img {
filter: var(--shadow2);
max-width: 100%;
&.bordered { border: 10px solid currentcolor; }
}
.bordered {
box-shadow: var(--shadow), var(--shadow2);
border: 10px solid currentcolor;
}
#char-pic { filter: drop-shadow(var(--shadow)); }
figure img { width: 100%; }
@media (width >= 70rem) {
.mainfig {
figure {
width: 480px;
float: right;
margin: 0 calc(0px - var(--protrude)) 1em 1em;
img { width: 100%; }
}
section:nth-of-type(even) .mainfig { rotate: -1deg; }
section:nth-of-type(odd) .mainfig { rotate: 1.5deg; }
#char-pic { rotate: 0deg; }
#velzek-pic {
shape-outside: polygon(100% 0%, 13% 0%, 13% 25%, 0% 27%,
0% 51%, 18% 60%, 21% 100%, 100% 100%);
}
#ekkel-pic { rotate: -2deg; }
#suveesha-pic { rotate: 3deg; }
}
}
@layer switcher {
#pic1, #pic2 { transition: all ease 200ms 175ms; }
#velzek-pic img {
transition: all ease 200ms 175ms;
}
:root:has(#switch:checked) #pic1,
:root:not(:has(#switch:checked)) #pic2 {
:root:has(#switch:checked) #clothes-pic,
:root:not(:has(#switch:checked)) #armour-pic {
opacity: 0;
transition-delay: 0ms;
pointer-events: none;
@ -215,7 +225,7 @@
position: absolute;
}
#char-pic label {
#velzek-pic label {
display: block;
width: min-content;
margin: 0 auto 1em;