Spaces:
Running
Running
| // 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<string, [string, string]> = { | |
| 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) | |
| } | |
| } | |
| } | |