// Per-case visual theme: a tint keyed to the crime kind, weather parsed from the case's // own weather line, and a stable seed for painter variation. Held in a module singleton // set when a case loads (GameProvider remounts per run, so every canvas repaints fresh). // DEFAULT_THEME reproduces the un-themed output exactly, so any canvas painted before a // case arrives looks like it always did. import { BAYER4 } from './draw' export type WeatherKind = 'rain' | 'fog' | 'sleet' | 'dry' export interface CaseTheme { seed: number weather: WeatherKind tint: string tintStrength: number } export const DEFAULT_THEME: CaseTheme = { seed: 0, weather: 'rain', tint: '', tintStrength: 0 } let _theme: CaseTheme = DEFAULT_THEME export function setCaseTheme(t: CaseTheme): void { _theme = t } export function getCaseTheme(): CaseTheme { return _theme } function hashStr(s: string): number { let h = 0 for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0 return h } // Two noir-muted shades per crime kind; the seed picks one so two homicides // in a row still read slightly differently. const TINTS: Record = { homicide: ['#87292a', '#5e1c1c'], theft: ['#37636b', '#284149'], fraud: ['#1d5a2c', '#14401f'], blackmail: ['#46506b', '#3a3a5e'], arson: ['#b9772f', '#8a4a1e'], missing_person: ['#2d3a5e', '#1d2832'], con: ['#8a6a1e', '#6b521a'], poisoning: ['#3f6b2a', '#2c4a1e'], ransom: ['#3a4a5e', '#28323e'], sabotage: ['#8a4a2a', '#6b3a1e'], } export function weatherFromText(text: string): WeatherKind { const t = (text || '').toLowerCase() if (/sleet/.test(t)) return 'sleet' if (/fog|mist/.test(t)) return 'fog' if (/rain|storm|drizzle|downpour/.test(t)) return 'rain' return 'dry' } export function themeFromCase(c: { id: string; weather?: string; kind?: string }): CaseTheme { const seed = hashStr(c.id || '') const pair = TINTS[c.kind || 'homicide'] || TINTS.homicide return { seed, weather: weatherFromText(c.weather || ''), tint: pair[seed & 1], tintStrength: 0.16 } } /** Post-paint pass over a freshly painted (static) scene buffer. Composite ops are * per-pixel, so the pixel-art stays crisp. 'tint' skips the weather treatment * (exhibits sit on an indoor forensic table). */ export function applySceneTheme(ctx: CanvasRenderingContext2D, w: number, h: number, mode: 'full' | 'tint'): void { const t = _theme if (t.tint && t.tintStrength > 0) { ctx.globalCompositeOperation = 'overlay' ctx.globalAlpha = t.tintStrength ctx.fillStyle = t.tint ctx.fillRect(0, 0, w, h) ctx.globalAlpha = 1 ctx.globalCompositeOperation = 'source-over' } if (mode === 'full' && t.weather === 'fog') { // Wash the color out, then lay dithered haze bands that thicken toward the ground. ctx.globalCompositeOperation = 'saturation' ctx.globalAlpha = 0.45 ctx.fillStyle = '#8a949c' ctx.fillRect(0, 0, w, h) ctx.globalAlpha = 1 ctx.globalCompositeOperation = 'source-over' ctx.fillStyle = 'rgba(170,185,195,0.32)' for (let b = 0; b < 4; b++) { const y0 = Math.floor(h * (0.34 + b * 0.17)) const bh = 3 + b * 2 const cover = 0.22 + b * 0.13 for (let y = y0; y < Math.min(h, y0 + bh); y++) { for (let x = 0; x < w; x++) { const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16 if (thr < cover) ctx.fillRect(x, y, 1, 1) } } } } } /** Per-frame precipitation for animated scenes. Rain matches the legacy overlay * pixel-for-pixel; sleet falls in slanted two-pixel steps; fog and dry add nothing. */ export function weatherOverlay(ctx: CanvasRenderingContext2D, w: number, h: number, t: number): void { const wk = _theme.weather if (wk === 'rain') { ctx.fillStyle = 'rgba(176,196,206,0.26)' for (let i = 0; i < 36; i++) { const x = (i * 41 + t * 5) % w const y = (i * 57 + t * 9) % h ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3) } } else if (wk === 'sleet') { ctx.fillStyle = 'rgba(200,210,220,0.30)' for (let i = 0; i < 28; i++) { const x = (i * 47 + t * 6) % w const y = (i * 53 + t * 8) % h ctx.fillRect(Math.floor(x), Math.floor(y), 1, 2) ctx.fillRect(Math.floor(x) + 1, Math.floor(y) + 2, 1, 2) } } }