case0 / web /src /engine /theme.ts
HusseinEid's picture
Per-case visuals, incident vignettes, guided play, no-repeat dealing, four new crime kinds (#4)
16ff49b
Raw
History Blame Contribute Delete
4.31 kB
// 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)
}
}
}