Compare commits
No commits in common. "dff263856c9e8a547b7e8af438caaeca3b9ca34c" and "898c7753a42d183d7e2db91bc3d108639160fd47" have entirely different histories.
dff263856c
...
898c7753a4
71 changed files with 402 additions and 844 deletions
7
Makefile
7
Makefile
|
@ -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)
|
||||
|
|
38
dnd/bio.css
38
dnd/bio.css
|
@ -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; }
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<title>d&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>&</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.
|
||||
(2020–24)
|
||||
</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)
BIN
dnd/kezda.webp
(Stored with Git LFS)
Binary file not shown.
|
@ -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)
BIN
dnd/kezda/kezda.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/kezda/kezda2x.webp
(Stored with Git LFS)
BIN
dnd/kezda/kezda2x.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/kezda/src/kezda-full.kra
(Stored with Git LFS)
BIN
dnd/kezda/src/kezda-full.kra
(Stored with Git LFS)
Binary file not shown.
|
@ -1,3 +0,0 @@
|
|||
@import url(../bio.css);
|
||||
|
||||
|
BIN
dnd/kezda2x.webp
(Stored with Git LFS)
BIN
dnd/kezda2x.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/marigold.webp
(Stored with Git LFS)
BIN
dnd/marigold.webp
(Stored with Git LFS)
Binary file not shown.
|
@ -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>
|
||||
let’s get it out of the way. her actual name is, um, let’s 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>
|
|
@ -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)
BIN
dnd/marigold2x.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/nex.kra-autosave.kra
(Stored with Git LFS)
BIN
dnd/nex.kra-autosave.kra
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/nex.webp
(Stored with Git LFS)
BIN
dnd/nex.webp
(Stored with Git LFS)
Binary file not shown.
|
@ -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)
BIN
dnd/nex2x.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/src/kezda.kra
(Stored with Git LFS)
BIN
dnd/src/kezda.kra
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/src/marigold.kra
(Stored with Git LFS)
BIN
dnd/src/marigold.kra
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/src/nex.kra
(Stored with Git LFS)
BIN
dnd/src/nex.kra
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/src/velzek.kra
(Stored with Git LFS)
BIN
dnd/src/velzek.kra
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/velzek.webp
(Stored with Git LFS)
BIN
dnd/velzek.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/velzek/armour.s.webp
(Stored with Git LFS)
BIN
dnd/velzek/armour.s.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/velzek/armour.s2x.webp
(Stored with Git LFS)
BIN
dnd/velzek/armour.s2x.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/velzek/clothes.s.webp
(Stored with Git LFS)
BIN
dnd/velzek/clothes.s.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/velzek/clothes.s2x.webp
(Stored with Git LFS)
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)
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)
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)
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)
BIN
dnd/velzek/src/suveesha.kra
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/velzek/src/velzek.kra
(Stored with Git LFS)
BIN
dnd/velzek/src/velzek.kra
(Stored with Git LFS)
Binary file not shown.
|
@ -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)
BIN
dnd/velzek/suveesha.s.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/velzek/suveesha.s2x.webp
(Stored with Git LFS)
BIN
dnd/velzek/suveesha.s2x.webp
(Stored with Git LFS)
Binary file not shown.
BIN
dnd/velzek2x.webp
(Stored with Git LFS)
BIN
dnd/velzek2x.webp
(Stored with Git LFS)
Binary file not shown.
|
@ -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 |
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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' });
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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
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
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
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
BIN
velzek/clothes_small2x.webp
(Stored with Git LFS)
Normal file
Binary file not shown.
|
@ -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> velzek’s 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 they’ve 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 avaro’s 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 don’t 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 berei’s more familiar symbols: a bundle of wheat, a sickle, and a
|
||||
adopt berei’s 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
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
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
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
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
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
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
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
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
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
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
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
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
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
BIN
velzek/src/velzek icon.kra
(Stored with Git LFS)
Normal file
Binary file not shown.
|
@ -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;
|
Loading…
Add table
Add a link
Reference in a new issue