clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

main
mosaic / site / lib / colour.ts
colour.ts 9.1K
1// Tile ownership as background blend. Every colour is one opaque sRGB fill:2// the circular mean of the owners' hues at a fixed OKLCH lightness and3// chroma, mixed with the panel grey by a strength that encodes status.4// No alpha is ever stacked, so seams and corners never go lighter.56import { HUES, type TileStatus } from "./fold.ts";78/**9 * Theme: the chrome greys and the glyph ink. Glyphs are drawn in the theme's10 * non-colour opposite to the page (black on dark, white on light), which11 * keeps their intensity low; the tile hues are the same in both.12 */13export type Theme = {14 name: "dark" | "light";15 bg: string; // the page: printed paper (light) or the night page (dark)16 panel: string; // the board ground under a tile: flat kaya, no grain17 rule: string; // hairlines18 ink: string; // stone black, text19 inkGiven: string; // the mark on a given stone20 muted: string;21 unknownShift: number; // OKLab lightness shift for cells not yet known22 stone1: string; // a 1: the black stone23 stone0: string; // a 0: the white stone24 line: string; // board lines and stone outlines25 danger: string; // wrong, dead, byo-yomi26 chrome: string; // the dock27};28/** Kifu: the printed page and a flat kaya board. */29export const LIGHT: Theme = {30 name: "light",31 bg: "#f4f1e8",32 panel: "#e6c98a",33 rule: "#1a1a1a",34 ink: "#1a1a1a",35 inkGiven: "#f4f1e8",36 muted: "#6f6a5e",37 unknownShift: 0.04,38 stone1: "#1a1a1a",39 stone0: "#fbfaf6",40 line: "#1a1a1a",41 danger: "#b0231c",42 chrome: "#f4f1e8",43};44/** Kifu at night: the same structure on an ink page, the board darkened. */45export const DARK: Theme = {46 name: "dark",47 bg: "#171613",48 panel: "#4a3d27",49 rule: "#d8d2c2",50 ink: "#e9e4d6",51 inkGiven: "#171613",52 muted: "#9a9384",53 unknownShift: -0.05,54 stone1: "#101010",55 stone0: "#efeade",56 line: "#d8d2c2",57 danger: "#d94a3d",58 chrome: "#1c1b17",59};60export const PANEL = DARK.panel;61export const RULE = DARK.rule;62export const INK = DARK.ink;63export const MUTED = DARK.muted;6465const L = 0.58;66const C = 0.13;6768/**69 * Status strength of the wash, for a cell whose value is known. A cell70 * that is not yet known sits lower (see cellFill): the field reads as a71 * surface that fills in, not as a grid that gets stamped.72 */73export const STRENGTH: Record<TileStatus, number> = {74 solved: 0.6,75 revealed: 0.4,76 hidden: 0.12,77};78const UNKNOWN_STRENGTH = 0.55; // share of the status strength an unknown cell keeps79const RANK: Record<TileStatus, number> = { hidden: 0, revealed: 1, solved: 2 };8081export function hueAngle(i: number): number {82 return (360 / HUES) * i + 20;83}8485function oklabToSrgb(86 l: number,87 a: number,88 b: number,89): [number, number, number] {90 const l_ = l + 0.3963377774 * a + 0.2158037573 * b;91 const m_ = l - 0.1055613458 * a - 0.0638541728 * b;92 const s_ = l - 0.0894841775 * a - 1.2914855480 * b;93 const l3 = l_ * l_ * l_, m3 = m_ * m_ * m_, s3 = s_ * s_ * s_;94 const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;95 const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;96 const bb = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;97 const gamma = (c: number) => {98 const x = Math.max(0, Math.min(1, c));99 return x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;100 };101 return [gamma(r), gamma(g), gamma(bb)];102}103104function srgbToOklab(105 r: number,106 g: number,107 b: number,108): [number, number, number] {109 const lin = (110 c: number,111 ) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));112 const rl = lin(r), gl = lin(g), bl = lin(b);113 const l = Math.cbrt(114 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl,115 );116 const m = Math.cbrt(117 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl,118 );119 const s = Math.cbrt(120 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl,121 );122 return [123 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,124 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,125 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,126 ];127}128129function hexToRgb(hex: string): [number, number, number] {130 const v = parseInt(hex.slice(1), 16);131 return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255];132}133134function toHex(rgb: [number, number, number]): string {135 return "#" +136 rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join("");137}138139/** Mean of a set of hue indexes, in OKLCH at fixed L and C, as OKLab. */140export function meanHueLab(hues: number[]): [number, number, number] {141 let sa = 0, sb = 0;142 for (const hi of hues) {143 const h = (hueAngle(hi) * Math.PI) / 180;144 sa += Math.cos(h);145 sb += Math.sin(h);146 }147 const ang = Math.atan2(sb, sa);148 // constant chroma: the mean direction at the shared C, never a shorter vector149 return [L, C * Math.cos(ang), C * Math.sin(ang)];150}151152const panelLabCache = new Map<string, [number, number, number]>();153function panelLabOf(panel: string): [number, number, number] {154 let v = panelLabCache.get(panel);155 if (!v) {156 v = srgbToOklab(...hexToRgb(panel));157 panelLabCache.set(panel, v);158 }159 return v;160}161162/**163 * One opaque fill: panel grey mixed toward the owners' mean hue by status164 * strength. The overview is value-blind: nothing here depends on 0 or 1.165 * `known` lifts a cell to the full status wash; unknown cells keep a share166 * of it and shift toward the background. `recency` in 0..1 is a short167 * lightness lift for cells that just became known, so a batch lands like168 * a ripple and settles.169 */170export function cellFill(171 hues: number[],172 status: TileStatus,173 known = true,174 theme: Theme = DARK,175 recency = 0,176 chroma = 1,177): string {178 const panelLab = panelLabOf(theme.panel);179 if (hues.length === 0) {180 // an ownerless known cell (a v2 filler zone): flat panel grey181 return toHex(oklabToSrgb(panelLab[0], panelLab[1], panelLab[2]));182 }183 let k = STRENGTH[status];184 let shift = 0;185 if (!known) {186 k *= UNKNOWN_STRENGTH;187 shift = theme.unknownShift;188 }189 const [l, a0, b0] = meanHueLab(hues);190 // chroma < 1 wilts the hue toward grey (an agent's health)191 const a = a0 * chroma, b = b0 * chroma;192 void recency; // placement is a motion on the board, not a lift of the wash193 return toHex(oklabToSrgb(194 panelLab[0] + (l - panelLab[0]) * k + shift,195 panelLab[1] + (a - panelLab[1]) * k,196 panelLab[2] + (b - panelLab[2]) * k,197 ));198}199200/** Solid tile colour, for outlines; `soft` lowers it toward the wash. */201export function tileStroke(hue: number, soft = false): string {202 const [l, a, b] = meanHueLab([hue]);203 return soft204 ? toHex(oklabToSrgb(l + 0.02, a * 0.7, b * 0.7))205 : toHex(oklabToSrgb(l + 0.12, a, b));206}207208export function highestStatus(statuses: TileStatus[]): TileStatus {209 let best: TileStatus = "hidden";210 for (const s of statuses) if (RANK[s] > RANK[best]) best = s;211 return best;212}213214// ---------------------------------------------------------------- ink215216const rgbCache = new Map<string, [number, number, number]>();217218/** sRGB bytes of a hex fill, cached (for ImageData rasters). */219export function rgbOf(hex: string): [number, number, number] {220 let v = rgbCache.get(hex);221 if (!v) {222 const [r, g, b] = hexToRgb(hex);223 v = [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];224 rgbCache.set(hex, v);225 }226 return v;227}228229const inkCache = new Map<string, string>();230231/**232 * Ink as a tone of the wash it sits on, not black or white: the wash233 * shifted in OKLab lightness away from the page (lighter on dark, darker234 * on light) by `delta`, chroma kept. Values then read as marks in the235 * surface rather than stamps on it.236 */237export function inkTone(wash: string, theme: Theme, delta: number): string {238 const key = `${wash}|${theme.name}|${delta}`;239 let v = inkCache.get(key);240 if (!v) {241 const [l, a, b] = srgbToOklab(...hexToRgb(wash));242 const dir = theme.name === "dark" ? 1 : -1;243 v = toHex(oklabToSrgb(l + dir * delta, a * 0.9, b * 0.9));244 inkCache.set(key, v);245 }246 return v;247}248249/** The neutral wash for a tile nobody has claimed yet, by status. */250export function neutralFill(251 status: TileStatus,252 known: boolean,253 theme: Theme,254 recency = 0,255): string {256 const panelLab = panelLabOf(theme.panel);257 const dir = theme.name === "dark" ? 1 : -1;258 const lift =259 (status === "solved" ? 0.05 : status === "revealed" ? 0.035 : 0.012) *260 (known ? 1 : 0.5) + recency * 0.05;261 return toHex(oklabToSrgb(panelLab[0] + dir * lift, panelLab[1], panelLab[2]));262}263264/**265 * The fog wash: a tile the field has generated but no territory has266 * reached. Barely above the void, below every other wash, so the day's267 * reach reads at a glance and the unexplored field is still a field.268 */269export function fogFill(theme: Theme): string {270 const panelLab = panelLabOf(theme.panel);271 const dir = theme.name === "dark" ? 1 : -1;272 return toHex(273 oklabToSrgb(panelLab[0] + dir * 0.006, panelLab[1], panelLab[2]),274 );275}276277/** Solid agent colour for swatches, labels and outlines. */278export function agentColour(279 hue: number,280 theme: Theme,281 strong = false,282 chroma = 1,283): string {284 const [l, a0, b0] = meanHueLab([hue]);285 const a = a0 * chroma, b = b0 * chroma;286 const dl = theme.name === "dark"287 ? (strong ? 0.18 : 0.1)288 : (strong ? -0.12 : -0.06);289 return toHex(oklabToSrgb(l + dl, a, b));290}