70c06224fe
horizon-theme gains the Blocks palettes (light and dark, WCAG AA on every panel and on the stone), Pixelify Sans (OFL) and Pixelarticons (MIT); tokens.css now carries the Blocks colours, the pixel font and the block edges. The site sets headings, navigation and buttons in Pixelify Sans, turns the index lists into stacks of panels with an outline, a bevel and a lip, puts a stone texture behind the page and Horizon's pixel landscape (a dawn or a sunset) behind the hero, both drawn in code by scripts/art.mjs. Light and dark still follow the system, with the toggle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
6.2 KiB
JavaScript
152 lines
6.2 KiB
JavaScript
// Horizon's pixel art for the website, drawn in code like the launcher's (launcher/src/art.rs):
|
|
// the stone tile behind the pages and the landscape behind the hero, in both appearances.
|
|
// Each texel is one pixel of the PNG; the CSS scales them up with `image-rendering: pixelated`.
|
|
// Written by `npm run assets` (or `node scripts/art.mjs`) into public/media/.
|
|
import { readFile } from "node:fs/promises";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import sharp from "sharp";
|
|
|
|
const site = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const repo = join(site, "..");
|
|
|
|
// The same small fixed random sequence as art.rs (xorshift), so the art is the same every time.
|
|
function rng(seed) {
|
|
let x = seed >>> 0;
|
|
return () => {
|
|
x = (x ^ (x << 13)) >>> 0;
|
|
x = (x ^ (x >>> 17)) >>> 0;
|
|
x = (x ^ (x << 5)) >>> 0;
|
|
return Math.fround((x >>> 8) / (1 << 24));
|
|
};
|
|
}
|
|
|
|
const channels = (c) => [(c >> 16) & 255, (c >> 8) & 255, c & 255];
|
|
const shade = (c, by) => channels(c).reduce((out, v) => (out << 8) | Math.min(255, Math.max(0, v + by)), 0);
|
|
const mix = (a, b, t) => {
|
|
const [x, y] = [channels(a), channels(b)];
|
|
return x.reduce((out, v, i) => (out << 8) | Math.round(v + (y[i] - v) * t), 0);
|
|
};
|
|
|
|
// `n` by `n` texels of stone: the page colour in a few close shades, with darker flecks.
|
|
export function stone(page, shades, n) {
|
|
const s = shades.map((by) => shade(page, by));
|
|
const next = rng(0x9e3779b9);
|
|
const out = Array.from({ length: n * n }, () => {
|
|
const v = next();
|
|
return s[v < 0.5 ? 0 : v < 0.78 ? 1 : v < 0.93 ? 2 : 3];
|
|
});
|
|
for (let k = 0; k < Math.floor((n * n) / 50); k++) {
|
|
const x = Math.floor(next() * n);
|
|
const y = Math.floor(next() * n);
|
|
const len = 1 + Math.floor(next() * 3);
|
|
for (let i = 0; i < len; i++) {
|
|
out[y * n + ((x + i) % n)] = s[4];
|
|
out[((y + 1) % n) * n + ((x + i + 1) % n)] = s[4];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export const SUNSET = {
|
|
sky: [0x0a0f2c, 0x26206a, 0x4b2a7e, 0x7c3a88, 0xc4527a, 0xee6e5a, 0xffb25e],
|
|
sun: [0xfff6cf, 0xffd272, 0xff9a44],
|
|
far: 0x4a2f7e, mid: 0x2b1f5e, near: 0x151a45, top: 0x3d2f80, trees: 0x0e1236,
|
|
};
|
|
export const DAWN = {
|
|
sky: [0x8d7fd8, 0xa596e2, 0xbcaeea, 0xd2c4f0, 0xe7d2ee, 0xf6dce4, 0xffe6d6],
|
|
sun: [0xffffff, 0xfff1d0, 0xffd9a8],
|
|
far: 0xa898dc, mid: 0x8676c8, near: 0x6a5ab4, top: 0x9a8ae0, trees: 0x4c3e94,
|
|
};
|
|
|
|
// A blocky landscape, `w` by `h` texels: banded sky, a sun half set behind the far hills and cut
|
|
// into slices like Horizon's mark, flat clouds, three ridges of hills and a few square trees.
|
|
export function scene(p, seed, w, h) {
|
|
const next = rng((Math.imul(seed, 0x2545f491) | 1) >>> 0);
|
|
const out = new Array(w * h).fill(0);
|
|
const horizon = Math.floor((h * 62) / 100);
|
|
const band = horizon / p.sky.length;
|
|
for (let y = 0; y < h; y++) {
|
|
const f = y / band;
|
|
const i = Math.min(Math.floor(f), p.sky.length - 1);
|
|
const dither = f - i > 0.88 && i + 1 < p.sky.length;
|
|
for (let x = 0; x < w; x++) out[y * w + x] = dither && (x + y) % 2 === 1 ? p.sky[i + 1] : p.sky[i];
|
|
}
|
|
const rect = (x, y, rw, rh, c) => {
|
|
for (let yy = Math.max(y, 0); yy < Math.min(y + rh, h); yy++)
|
|
for (let xx = Math.max(x, 0); xx < Math.min(x + rw, w); xx++) out[yy * w + xx] = c;
|
|
};
|
|
for (let k = 0; k < 4; k++) {
|
|
const cx = Math.floor(next() * w);
|
|
const cy = Math.floor(horizon * (0.12 + next() * 0.4));
|
|
const cw = 10 + Math.floor(next() * 14);
|
|
const under = p.sky[Math.min(Math.floor(cy / band) + 1, p.sky.length - 1)];
|
|
const c = mix(under, 0xffffff, 0.12);
|
|
rect(cx, cy, cw, 2, c);
|
|
rect(cx + 3, cy - 2, Math.max(cw - 6, 0), 2, c);
|
|
}
|
|
const r = Math.floor(h * 0.2);
|
|
const [cx, cy] = [Math.floor(w * 0.68), horizon - Math.floor(h * 0.11)];
|
|
for (let dy = -r; dy <= 0; dy++) {
|
|
const half = Math.round(Math.sqrt(r * r - dy * dy));
|
|
const t = (dy + r) / r;
|
|
const c = p.sun[t < 0.4 ? 0 : t < 0.75 ? 1 : 2];
|
|
if ((cy + dy) % 5 !== 0 || dy < -r * 0.6) rect(cx - half, cy + dy, half * 2, 1, c);
|
|
}
|
|
const ridge = (base, amp, rough) => {
|
|
let [y, v] = [base, 0];
|
|
return Array.from({ length: w }, (_, x) => {
|
|
if (x % 2 === 0) {
|
|
v = (v + (next() - 0.5) * rough) * 0.86;
|
|
y += v + (base - y) * 0.06;
|
|
}
|
|
return Math.max(Math.round(Math.min(Math.max(y, base - amp), base + amp)), 0);
|
|
});
|
|
};
|
|
const layers = [
|
|
[p.far, horizon - h * 0.1, h * 0.12, 3.2, null],
|
|
[p.mid, horizon + h * 0.02, h * 0.09, 2.6, null],
|
|
[p.near, horizon + h * 0.18, h * 0.07, 2.2, p.top],
|
|
];
|
|
let near = [];
|
|
for (const [c, base, amp, rough, top] of layers) {
|
|
const hs = ridge(base, amp, rough);
|
|
hs.forEach((y, x) => {
|
|
for (let yy = Math.min(y, h); yy < h; yy++) out[yy * w + x] = c;
|
|
if (top !== null && y < h) out[y * w + x] = top;
|
|
});
|
|
near = hs;
|
|
}
|
|
for (let k = 0; k < Math.floor(w / 26); k++) {
|
|
const tx = 4 + Math.floor(next() * (w - 10));
|
|
const ty = near[tx];
|
|
rect(tx - 2, ty - 8, 5, 5, p.trees);
|
|
rect(tx - 1, ty - 10, 3, 2, p.trees);
|
|
rect(tx, ty - 3, 1, 3, p.trees);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const png = (texels, w, h, file) =>
|
|
sharp(Buffer.from(texels.flatMap(channels)), { raw: { width: w, height: h, channels: 3 } })
|
|
.png({ compressionLevel: 9, palette: true })
|
|
.toFile(file);
|
|
|
|
// The page colours and the stone's shades come from crates/horizon-theme, like tokens.css.
|
|
export async function writeArt(media) {
|
|
const theme = await readFile(join(repo, "crates/horizon-theme/src/lib.rs"), "utf8");
|
|
const page = (name) => parseInt(theme.match(new RegExp(`pub const ${name}: Blocks = Blocks \\{\\s*page: 0x([0-9a-f]{6})`))[1], 16);
|
|
const shades = JSON.parse(`[${theme.match(/TILE_SHADES: \[i16; 5\] = \[([^\]]*)\]/)[1]}]`);
|
|
const texels = Number(theme.match(/TILE_TEXELS: usize = (\d+)/)[1]) * 4;
|
|
await png(stone(page("BLOCKS_DARK"), shades, texels), texels, texels, join(media, "stone-dark.png"));
|
|
await png(stone(page("BLOCKS_LIGHT"), shades, texels), texels, texels, join(media, "stone-light.png"));
|
|
const [w, h] = [240, 72];
|
|
await png(scene(SUNSET, 3, w, h), w, h, join(media, "scene-dark.png"));
|
|
await png(scene(DAWN, 3, w, h), w, h, join(media, "scene-light.png"));
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
await writeArt(join(site, "public", "media"));
|
|
console.log("pixel art written to public/media/");
|
|
}
|