File size: 4,307 Bytes
16ff49b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// 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)
    }
  }
}