1/**2 * Urizen 1-bit tileset loader + draw API.3 *4 * The sheet (web/public/urizen/urizen_onebit_tileset__v2d0.png, fetched and5 * sha-256-verified by scripts/import-urizen.mjs) packs 12x12 opaque tiles on a6 * 13px stride. Tiles sit on solid near-black backgrounds, so we alpha-key the7 * whole sheet once at load with a luminance threshold rather than keying on8 * exact black. Tinting draws the keyed glyph to a small offscreen canvas and9 * applies a 'source-in' fill; tinted variants are cached per (tile, tint).10 */11import { log } from '../log';12import { URIZEN } from './urizen-manifest';1314export const SHEET_URL = '/urizen/urizen_onebit_tileset__v2d0.png';15export const TILE = 12;16const STRIDE = 13;17const MARGIN = 1;18/** Pixels with luminance <= this become transparent (background key). */19const LUMA_KEY = 45;2021export interface UrizenAtlas {22 /** Sheet grid size (includes ruler columns — see usableTile). */23 grid: { cols: number; rows: number };24 /** Fraction of opaque pixels in a tile, 0..1 (cached). */25 inkRatio(col: number, row: number): number;26 /** Draw a tile by raw grid position (identicons; no manifest involved). */27 drawRaw(28 ctx: CanvasRenderingContext2D,29 col: number,30 row: number,31 x: number,32 y: number,33 size: number,34 tint?: string,35 ): void;36 /**37 * Draw manifest tile `name` with its top-left at (x, y), scaled to38 * size x size px. Unknown names draw a magenta placeholder box and warn once.39 * `tint` (any canvas fillStyle color string) recolors the whole silhouette.40 */41 draw(42 ctx: CanvasRenderingContext2D,43 name: string,44 x: number,45 y: number,46 size: number,47 tint?: string,48 ): void;49 /** True when `name` exists in the manifest. */50 has(name: string): boolean;51}5253function makeCanvas(w: number, h: number): HTMLCanvasElement {54 const c = document.createElement('canvas');55 c.width = w;56 c.height = h;57 return c;58}5960function ctx2d(c: HTMLCanvasElement): CanvasRenderingContext2D {61 const ctx = c.getContext('2d');62 if (!ctx) throw new Error('2d canvas context unavailable');63 return ctx;64}6566export async function loadUrizen(): Promise<UrizenAtlas> {67 const res = await fetch(SHEET_URL);68 if (!res.ok) throw new Error(`failed to fetch urizen sheet: HTTP ${res.status}`);69 const bitmap = await createImageBitmap(await res.blob());7071 // Alpha-key the full sheet once.72 const sheet = makeCanvas(bitmap.width, bitmap.height);73 const sctx = ctx2d(sheet);74 sctx.drawImage(bitmap, 0, 0);75 bitmap.close();76 const img = sctx.getImageData(0, 0, sheet.width, sheet.height);77 const d = img.data;78 for (let i = 0; i < d.length; i += 4) {79 const luma = 0.299 * (d[i] ?? 0) + 0.587 * (d[i + 1] ?? 0) + 0.114 * (d[i + 2] ?? 0);80 if (luma <= LUMA_KEY) d[i + 3] = 0;81 }82 sctx.putImageData(img, 0, 0);8384 /** Cache of tinted 12x12 tiles, keyed `${col},${row}|${tint}`. */85 const tintCache = new Map<string, HTMLCanvasElement>();86 const warned = new Set<string>();8788 function tintedTile(col: number, row: number, tint: string): HTMLCanvasElement {89 const key = `${col},${row}|${tint}`;90 let tile = tintCache.get(key);91 if (!tile) {92 tile = makeCanvas(TILE, TILE);93 const tctx = ctx2d(tile);94 tctx.drawImage(95 sheet,96 MARGIN + STRIDE * col,97 MARGIN + STRIDE * row,98 TILE,99 TILE,100 0,101 0,102 TILE,103 TILE,104 );105 tctx.globalCompositeOperation = 'source-in';106 tctx.fillStyle = tint;107 tctx.fillRect(0, 0, TILE, TILE);108 tintCache.set(key, tile);109 }110 return tile;111 }112113 const cols = Math.floor((sheet.width - MARGIN + (STRIDE - TILE)) / STRIDE);114 const rows = Math.floor((sheet.height - MARGIN + (STRIDE - TILE)) / STRIDE);115 const inkCache = new Map<number, number>();116117 function inkRatio(col: number, row: number): number {118 const key = row * cols + col;119 let ratio = inkCache.get(key);120 if (ratio === undefined) {121 const data = sctx.getImageData(MARGIN + STRIDE * col, MARGIN + STRIDE * row, TILE, TILE).data;122 let opaque = 0;123 for (let i = 3; i < data.length; i += 4) if (data[i]! > 0) opaque++;124 ratio = opaque / (TILE * TILE);125 inkCache.set(key, ratio);126 }127 return ratio;128 }129130 return {131 has: (name: string) => name in URIZEN,132 grid: { cols, rows },133 inkRatio,134135 drawRaw(ctx, col, row, x, y, size, tint) {136 const prev = ctx.imageSmoothingEnabled;137 ctx.imageSmoothingEnabled = false;138 if (tint) {139 ctx.drawImage(tintedTile(col, row, tint), 0, 0, TILE, TILE, x, y, size, size);140 } else {141 ctx.drawImage(sheet, MARGIN + STRIDE * col, MARGIN + STRIDE * row, TILE, TILE, x, y, size, size);142 }143 ctx.imageSmoothingEnabled = prev;144 },145146 draw(ctx, name, x, y, size, tint) {147 const entry = URIZEN[name];148 if (!entry) {149 if (!warned.has(name)) {150 warned.add(name);151 log.warn(`urizen: unknown tile name "${name}"`);152 }153 ctx.save();154 ctx.strokeStyle = '#f0f';155 ctx.lineWidth = 1;156 ctx.strokeRect(x + 0.5, y + 0.5, size - 1, size - 1);157 ctx.restore();158 return;159 }160 const [col, row] = entry;161 const prev = ctx.imageSmoothingEnabled;162 ctx.imageSmoothingEnabled = false;163 if (tint) {164 ctx.drawImage(tintedTile(col, row, tint), 0, 0, TILE, TILE, x, y, size, size);165 } else {166 ctx.drawImage(167 sheet,168 MARGIN + STRIDE * col,169 MARGIN + STRIDE * row,170 TILE,171 TILE,172 x,173 y,174 size,175 size,176 );177 }178 ctx.imageSmoothingEnabled = prev;179 },180 };181}