clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / web / src / render / terrain-style.ts
terrain-style.ts 9.3K
1/**2 * DOM-free terrain rendering config for the Urizen map view: per-biome3 * background palette, weighted glyph variants, sparse decor sets, and the4 * deterministic per-cell hash that picks between them (Caves-of-Qud-style5 * per-cell variation so painted regions don't read as repeated wallpaper).6 *7 * Kept free of browser APIs on purpose so node scripts can import it directly8 * (node's TS type-stripping) to mock-render the map for visual verification.9 *10 * Determinism: everything a cell draws is a pure function of (x, y) and the11 * terrain char, so the cached terrain layer renders identically on every pass.12 * Tint quantization: each variant exposes at most 3 tint strings (dim / base /13 * bright), which bounds the atlas tint cache to <= 3 entries per variant.14 */15import type { UrizenName } from './urizen-manifest';1617/** Three quantized brightness steps (dim / base / bright) of one color. */18export type Steps3 = readonly [string, string, string];1920export interface TerrainVariant {21 /** Manifest glyph; undefined draws only the background (a calm/bare cell). */22 glyph?: UrizenName;23 /** Quantized tint steps; undefined draws the tile in its natural colors. */24 tints?: Steps3;25 /** Selection weight; weights in a variant list sum to 100. */26 w: number;27}2829export interface TerrainDecor {30 glyph: UrizenName;31 /** Fixed tint (never jittered, to bound the tint cache); undefined = natural. */32 tint?: string;33}3435export interface TerrainStyle {36 label: string;37 /** Background fill steps (dim/base/bright), hash-picked per cell. */38 bg: Steps3;39 /** Weighted glyph variants; the first entry is the dominant "base" look. */40 variants: readonly TerrainVariant[];41 /** Sparse accent set, hash-picked on decor cells in place of the variant. */42 decor?: readonly TerrainDecor[];43 /** Decor appears on roughly 1-in-decorEvery cells. */44 decorEvery?: number;45}4647/** Multiply a #rrggbb color's brightness by `f` (clamped). */48export function shade(hex: string, f: number): string {49 const n = Number.parseInt(hex.slice(1), 16);50 const ch = (v: number): string =>51 Math.max(0, Math.min(255, Math.round(v * f)))52 .toString(16)53 .padStart(2, '0');54 return `#${ch(n >> 16)}${ch((n >> 8) & 0xff)}${ch(n & 0xff)}`;55}5657// Deliberately subtle spreads: enough to break up flat fields, not enough to58// read as a checkerboard.59const bg3 = (base: string): Steps3 => [shade(base, 0.88), base, shade(base, 1.12)];60const tint3 = (base: string): Steps3 => [shade(base, 0.84), base, shade(base, 1.16)];6162/** Fog fallback for unknown chars (and '?'). Solid black, nothing drawn. */63export const FOG: TerrainStyle = {64 label: 'unexplored',65 bg: ['#000000', '#000000', '#000000'],66 variants: [{ w: 100 }],67};6869/**70 * Per-TerrainChar render styles. Busy full-map biomes (grass/hills/steppe/71 * arctic) stay tint-muted so settlements/warbands remain the brightest things72 * on screen; decor tints are likewise kept dusty.73 */74export const TERRAIN: Record<string, TerrainStyle> = {75 '?': FOG,76 '.': {77 label: 'plains',78 bg: bg3('#101a0e'),79 variants: [80 { glyph: 'terrain.plains', tints: tint3('#3d5c31'), w: 55 }, // grass tufts81 { glyph: 'terrain.plains.tufts', tints: tint3('#3d5c31'), w: 18 }, // sparser tufts82 { glyph: 'terrain.plains.blades', tints: tint3('#3d5c31'), w: 15 }, // thin blades83 { glyph: 'terrain.plains.bare', tints: tint3('#33452c'), w: 12 }, // bare speckled patch84 ],85 decor: [86 { glyph: 'decor.flower', tint: '#a89a4e' }, // straw-yellow bloom87 { glyph: 'decor.shrub', tint: '#4a6b3a' },88 { glyph: 'decor.boulder', tint: '#54585f' },89 ],90 decorEvery: 20,91 },92 f: {93 label: 'forest',94 bg: bg3('#0b140a'),95 variants: [96 { glyph: 'terrain.forest', w: 45 }, // round deciduous pair97 { glyph: 'terrain.forest.checker', w: 20 }, // checker-foliage pair98 { glyph: 'tree.pine', w: 20 }, // pine clump99 { glyph: 'terrain.forest.oak', w: 15 }, // single broad oak100 ],101 decor: [102 { glyph: 'decor.mushroom' }, // red-cap mushroom (natural colors)103 { glyph: 'decor.stump' },104 { glyph: 'decor.deadtree' },105 ],106 decorEvery: 18,107 },108 h: {109 label: 'hills',110 bg: bg3('#131a0f'),111 variants: [112 { glyph: 'terrain.hills', tints: tint3('#5b7d43'), w: 60 }, // twin mounds113 { glyph: 'terrain.hills.triple', tints: tint3('#5b7d43'), w: 25 }, // jagged triple114 { glyph: 'terrain.hills.dune', tints: tint3('#54713f'), w: 15 }, // low single mound115 ],116 decor: [117 { glyph: 'decor.boulder', tint: '#62666d' },118 { glyph: 'decor.shrub', tint: '#4a6b3a' },119 ],120 decorEvery: 24,121 },122 m: {123 label: 'mountain',124 bg: bg3('#15151a'),125 variants: [126 { glyph: 'terrain.mountain', w: 62 }, // gray twin peaks (natural)127 { glyph: 'terrain.mountain.snowcap', w: 22 }, // snow-capped peak (natural)128 { glyph: 'terrain.hills.triple', tints: tint3('#565b64'), w: 16 }, // rocky crags129 ],130 decor: [{ glyph: 'decor.boulder', tint: '#8a8f99' }],131 decorEvery: 18,132 },133 w: {134 label: 'water',135 bg: bg3('#081831'),136 variants: [137 { glyph: 'terrain.water', tints: tint3('#4c86b8'), w: 50 }, // mid-blue waves138 { w: 27 }, // calm open water (background only)139 { glyph: 'terrain.water', tints: tint3('#33587e'), w: 23 }, // dim deep swell140 ],141 decor: [{ glyph: 'decor.swirl' }], // teal eddy (natural colors)142 decorEvery: 26,143 },144 r: {145 label: 'river',146 bg: bg3('#0c2547'),147 variants: [148 { glyph: 'terrain.river', tints: tint3('#7ec8ff'), w: 70 }, // bright ripples149 { w: 30 }, // calm stretch150 ],151 decor: [{ glyph: 'decor.swirl', tint: '#7ec8ff' }],152 decorEvery: 28,153 },154 s: {155 label: 'swamp',156 bg: bg3('#0e1710'),157 variants: [158 { glyph: 'terrain.swamp', w: 50 }, // green marsh mottling (natural)159 { glyph: 'terrain.swamp.mud', w: 20 }, // brown mud mottling (natural)160 { glyph: 'terrain.swamp.lilypad', tints: tint3('#3f6b3f'), w: 15 },161 { w: 15 }, // still murk (background only)162 ],163 decor: [164 { glyph: 'decor.reeds', tint: '#5d7a4a' },165 { glyph: 'decor.mushroom' },166 ],167 decorEvery: 12,168 },169 t: {170 label: 'steppe',171 bg: bg3('#1a1409'),172 variants: [173 { glyph: 'terrain.steppe', tints: tint3('#8f6c30'), w: 55 }, // dry tufts174 { glyph: 'terrain.steppe.tall', tints: tint3('#8f6c30'), w: 20 }, // tall grass175 { glyph: 'terrain.steppe.dense', tints: tint3('#7d5e2a'), w: 10 }, // dense stand176 { glyph: 'terrain.plains.bare', tints: tint3('#6b532a'), w: 15 }, // bare dust177 ],178 decor: [179 { glyph: 'decor.deadtree' }, // gnarled dead tree (natural brown)180 { glyph: 'decor.boulder', tint: '#7a715f' },181 { glyph: 'decor.cactus', tint: '#6b7a3f' },182 ],183 decorEvery: 16,184 },185 a: {186 label: 'arctic',187 bg: bg3('#161b20'),188 variants: [189 { glyph: 'terrain.arctic', tints: tint3('#aebfcc'), w: 50 }, // snow drifts190 { glyph: 'terrain.arctic.sparkle', tints: tint3('#93a5b4'), w: 15 }, // wind-blown speckle191 { w: 35 }, // featureless snowfield (background only)192 ],193 decor: [194 { glyph: 'decor.bones' }, // scattered bones (natural white)195 { glyph: 'decor.deadtree', tint: '#8795a3' },196 ],197 decorEvery: 18,198 },199 v: {200 label: 'volcano',201 bg: bg3('#1d0d08'),202 variants: [{ glyph: 'terrain.volcano', w: 100 }],203 },204 O: {205 label: 'ore vein',206 bg: bg3('#15151a'),207 variants: [{ glyph: 'terrain.ore', tints: tint3('#aeb9c9'), w: 100 }],208 },209 G: {210 label: 'gold vein',211 bg: bg3('#15151a'),212 variants: [{ glyph: 'terrain.gold', tints: tint3('#e8bf45'), w: 100 }],213 },214};215216/** Shore water (a 'w' cell 4-adjacent to land): lighter tone + foam ripples. */217export const SHORE: { bg: Steps3; tints: Steps3 } = {218 bg: bg3('#0d2748'),219 tints: tint3('#8ec7dc'),220};221222/** True when `ch` is a solid-ground TerrainChar (for shoreline detection). */223export function isLandChar(ch: string): boolean {224 return ch !== 'w' && ch !== 'r' && ch !== '?' && ch in TERRAIN;225}226227/**228 * splitmix32-style integer mix of cell coordinates. Pure and cheap; the same229 * (x, y) always yields the same 32-bit hash, so the terrain layer is230 * deterministic across redraws. Distinct bit ranges of the result drive231 * independent choices: bits 0-1 background step, 2-8 variant roll,232 * 9-15 decor gate, 16-23 decor index, 24-31 tint step.233 */234export function cellHash(x: number, y: number): number {235 let h = (Math.imul(x, 0x9e3779b1) ^ Math.imul(y ^ 0x6d2b79f5, 0x85ebca77)) >>> 0;236 h ^= h >>> 16;237 h = Math.imul(h, 0x21f0aaad);238 h ^= h >>> 15;239 h = Math.imul(h, 0x735a2d97);240 h ^= h >>> 15;241 return h >>> 0;242}243244/** Hash-pick one of 3 quantized steps (background or tint). */245export function stepOf(steps: Steps3, h: number, bits: number): string {246 return steps[(h >>> bits) % 3] ?? steps[1];247}248249/** Weighted variant choice; the first (dominant) entry wins ties/overflow. */250export function pickVariant(t: TerrainStyle, h: number): TerrainVariant {251 let roll = (h >>> 2) % 100;252 for (const v of t.variants) {253 roll -= v.w;254 if (roll < 0) return v;255 }256 return t.variants[0] ?? { w: 100 };257}258259/** Decor for this cell, or null for the vast majority of cells. */260export function pickDecor(t: TerrainStyle, h: number): TerrainDecor | null {261 if (!t.decor || !t.decorEvery) return null;262 if ((h >>> 9) % t.decorEvery !== 0) return null;263 return t.decor[(h >>> 16) % t.decor.length] ?? null;264}