diff --git "a/web/src/engine/art.ts" "b/web/src/engine/art.ts" --- "a/web/src/engine/art.ts" +++ "b/web/src/engine/art.ts" @@ -1,1432 +1,1448 @@ -// Pixel art — cohesive sprite system, one density throughout. Ported from art.jsx. -// All visuals are procedural canvas, rendered client-side, so the server spends ~0 CPU on -// art and the game stays fully local — no image models, no external services. -import { BAYER4, ditherGrad } from './draw' -import type { Pal } from './draw' -import type { ScenePainter } from './pixel' - -type Grid = string[][] -interface Sprite { - pal: Pal - frames: string[][] - px: number -} -interface PortraitOpts { - skin: string - skinS: string - hair: string - hairHl: string - style?: string - cloth: string - clothHl: string - accent: string - lip?: string - tie?: string - beard?: string - stubble?: boolean - glasses?: string - hat?: string - hatBrim?: string - hatHl?: string - skinHi?: string - legs?: string -} - -export const SPAL: Record = { - k: '#080c11', K: '#04070b', - '1': '#e7b48d', '2': '#c4895f', '3': '#945d40', - '4': '#ead0b8', '5': '#c19b80', - '6': '#a76b44', '7': '#7c4d2e', - '8': '#74492e', '9': '#543521', - p: '#a8554c', - h: '#16191f', H: '#2a2f39', r: '#43301f', R: '#65492b', y: '#8d8c82', Y: '#b8b7ad', - u: '#6d3920', U: '#92542f', b: '#c19341', B: '#e3c06d', w0: '#3a3a40', - e: '#0e1119', o: '#ded7c3', - t: '#284149', T: '#37636b', m: '#1c222a', M: '#2d3640', x: '#5f1f1f', X: '#87292a', - w: '#b7b09a', W: '#d4cdb7', n: '#b3742d', N: '#dba04a', i: '#cfc8b2', I: '#9d977f', - g: '#39474f', G: '#536a72', c: '#d2d8dc', -} - -function blank(W: number, H: number): Grid { - return Array.from({ length: H }, () => Array(W).fill('.')) -} -function setpx(g: Grid, x: number, y: number, c: string): void { - if (y >= 0 && y < g.length && x >= 0 && x < g[0].length) g[y][x] = c -} -function fillrect(g: Grid, x: number, y: number, w: number, h: number, c: string): void { - for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) setpx(g, x + i, y + j, c) -} - -const HEAD: Record = { - 4: [7, 14], 5: [6, 15], 6: [5, 16], 7: [5, 16], 8: [5, 16], 9: [5, 16], 10: [5, 16], - 11: [5, 16], 12: [5, 16], 13: [6, 15], 14: [6, 15], 15: [6, 15], 16: [7, 14], 17: [8, 13], -} - -export function makePortrait(o: PortraitOpts): Sprite { - const sk = o.skin - const skS = o.skinS - const build = (blink: boolean, talk = false): string[] => { - const g = blank(22, 24) - fillrect(g, 9, 17, 4, 3, sk) - fillrect(g, 9, 18, 4, 1, skS) - fillrect(g, 2, 20, 18, 4, o.cloth) - for (let x = 2; x < 20; x++) setpx(g, x, 20, o.clothHl) - setpx(g, 9, 20, o.accent) - setpx(g, 12, 20, o.accent) - setpx(g, 10, 21, o.accent) - setpx(g, 11, 21, o.accent) - if (o.tie) { - setpx(g, 10, 21, o.tie); setpx(g, 11, 21, o.tie); setpx(g, 10, 22, o.tie); setpx(g, 11, 22, o.tie) - } - for (const y in HEAD) { - const [a, bx] = HEAD[+y] - for (let x = a; x <= bx; x++) setpx(g, x, +y, sk) - } - for (const y in HEAD) { - const [, bx] = HEAD[+y] - setpx(g, bx, +y, skS); setpx(g, bx - 1, +y, skS) - } - setpx(g, 7, 16, skS); setpx(g, 13, 16, skS) - if (o.beard) { - for (let x = 6; x <= 15; x++) setpx(g, x, 15, o.beard) - fillrect(g, 7, 16, 7, 2, o.beard) - setpx(g, 9, 15, sk); setpx(g, 12, 15, sk) - } - if (o.stubble) { - for (let x = 6; x <= 15; x++) if ((x + 1) % 2) setpx(g, x, 16, skS) - } - fillrect(g, 7, 9, 3, 1, o.hair) - fillrect(g, 12, 9, 3, 1, o.hair) - if (blink) { - setpx(g, 8, 11, skS); setpx(g, 9, 11, skS); setpx(g, 13, 11, skS); setpx(g, 14, 11, skS) - } else { - setpx(g, 8, 11, 'o'); setpx(g, 9, 11, 'e'); setpx(g, 13, 11, 'e'); setpx(g, 14, 11, 'o') - setpx(g, 8, 10, skS); setpx(g, 14, 10, skS) - } - setpx(g, 11, 12, skS); setpx(g, 11, 13, skS); setpx(g, 10, 13, skS) - fillrect(g, 9, 15, 4, 1, o.lip || 'p') - if (o.beard) fillrect(g, 9, 15, 4, 1, '#7a3f3a') - // Talking frame: the jaw drops into a small dark opening (synced to the voice). - if (talk) { setpx(g, 10, 15, 'k'); setpx(g, 11, 15, 'k'); setpx(g, 10, 16, 'k'); setpx(g, 11, 16, 'k') } - setpx(g, 7, 12, o.skinHi || sk) - const H = o.hair - const Hl = o.hairHl - const top = () => { - fillrect(g, 5, 3, 12, 3, H) - for (let x = 6; x <= 15; x++) setpx(g, x, 3, Hl) - } - const sides = (to: number) => { - for (let y = 4; y <= to; y++) { - setpx(g, 5, y, H); setpx(g, 16, y, H) - } - } - if (o.style === 'short') { - top(); fillrect(g, 5, 5, 12, 2, H); sides(8); setpx(g, 5, 4, H); setpx(g, 16, 4, H) - } else if (o.style === 'slick') { - fillrect(g, 6, 3, 10, 3, H); for (let x = 6; x < 13; x++) setpx(g, x, 3, Hl); sides(7) - } else if (o.style === 'long') { - top(); fillrect(g, 5, 5, 12, 2, H); sides(17); fillrect(g, 4, 8, 2, 9, H); fillrect(g, 16, 8, 2, 9, H) - } else if (o.style === 'bun') { - top(); fillrect(g, 5, 5, 12, 1, H); sides(7); fillrect(g, 8, 1, 6, 3, H); for (let x = 9; x < 13; x++) setpx(g, x, 1, Hl) - } else if (o.style === 'curly') { - for (let x = 5; x <= 16; x++) { const yy = 3 + (x % 2); setpx(g, x, yy, H); setpx(g, x, 3, H) } - fillrect(g, 5, 4, 12, 3, H); for (let x = 6; x < 16; x += 2) setpx(g, x, 3, Hl); sides(9); setpx(g, 4, 6, H); setpx(g, 17, 6, H) - } else if (o.style === 'bald') { - for (let y = 7; y <= 10; y++) { setpx(g, 5, y, H); setpx(g, 16, y, H) } - setpx(g, 6, 12, skS) - } else if (o.style === 'wave') { - top(); fillrect(g, 5, 5, 12, 2, H); for (let x = 6; x < 16; x += 3) setpx(g, x, 5, Hl); sides(8) - } - if (o.glasses) { - const gl = o.glasses - setpx(g, 7, 10, gl); setpx(g, 9, 10, gl); setpx(g, 7, 11, gl); setpx(g, 7, 12, gl); setpx(g, 9, 11, gl); setpx(g, 9, 12, gl); setpx(g, 8, 12, gl) - setpx(g, 12, 10, gl); setpx(g, 14, 10, gl); setpx(g, 14, 11, gl); setpx(g, 14, 12, gl); setpx(g, 12, 11, gl); setpx(g, 12, 12, gl); setpx(g, 13, 12, gl) - setpx(g, 10, 11, gl); setpx(g, 11, 11, gl) - if (!blink) { setpx(g, 8, 11, 'o'); setpx(g, 13, 11, 'o') } - } - if (o.hat) { - fillrect(g, 4, 2, 14, 2, o.hat) - for (let x = 4; x < 18; x++) setpx(g, x, 4, o.hatBrim || o.hat) - fillrect(g, 3, 4, 16, 1, o.hatBrim || o.hat) - for (let x = 5; x < 17; x++) setpx(g, x, 2, o.hatHl || o.hat) - } - for (const y in HEAD) { - const [a] = HEAD[+y] - setpx(g, a - 1, +y, 'k') - } - // Keep as a 2D grid of cell strings (cells are full hex/key values, NOT single - // chars), so multi-character colors survive. Joining rows would shred them. - return g - } - // frame 0: neutral · frame 1: blink · frame 2: mouth open (talking). - return { pal: SPAL, frames: [build(false), build(true), build(false, true)], px: 6 } -} - -function shade(c: string): string { - const m: Record = { - [SPAL.t]: '#1d3138', [SPAL.m]: '#141a20', [SPAL.x]: '#481818', - [SPAL.w]: '#9a937e', [SPAL.n]: '#8a5a22', [SPAL.g]: '#2a363d', - } - return m[c] || '#0c0f14' -} - -export function makeBody(o: PortraitOpts): Sprite { - const sk = o.skin - const skS = o.skinS - const build = (dy: number): string[] => { - const g = blank(24, 44) - fillrect(g, 7, 33, 4, 11, o.legs || o.cloth) - fillrect(g, 13, 33, 4, 11, o.legs || o.cloth) - setpx(g, 7, 33, 'k'); setpx(g, 16, 33, 'k') - fillrect(g, 6, 42, 5, 2, '#15181d'); fillrect(g, 13, 42, 5, 2, '#15181d') - const ty = 14 + dy - fillrect(g, 5, ty, 14, 20, o.cloth) - for (let x = 5; x < 19; x++) setpx(g, x, ty, o.clothHl) - for (let y = ty; y < ty + 20; y++) { - setpx(g, 17, y, shade(o.cloth)); setpx(g, 18, y, shade(o.cloth)) - } - setpx(g, 11, ty, o.accent); setpx(g, 12, ty, o.accent) - fillrect(g, 11, ty, 2, 8, o.tie || o.accent) - fillrect(g, 3, ty, 2, 16, shade(o.cloth)); fillrect(g, 19, ty, 2, 16, shade(o.cloth)) - fillrect(g, 3, ty + 16, 3, 2, sk); fillrect(g, 18, ty + 16, 3, 2, sk) - fillrect(g, 10, ty - 2, 4, 3, sk); setpx(g, 13, ty - 1, skS) - const hx = 7 - const hy = ty - 15 - fillrect(g, hx + 1, hy + 1, 9, 11, sk) - for (let y = hy + 1; y < hy + 12; y++) setpx(g, hx + 9, y, skS) - fillrect(g, hx, hy, 11, 3, o.hair) - for (let x = hx + 1; x < hx + 10; x++) setpx(g, x, hy, o.hairHl) - if (o.style === 'long') { - fillrect(g, hx, hy + 2, 2, 9, o.hair); fillrect(g, hx + 9, hy + 2, 2, 9, o.hair) - } else { - setpx(g, hx, hy + 3, o.hair); setpx(g, hx + 10, hy + 3, o.hair) - } - if (o.hat) { - fillrect(g, hx - 1, hy - 1, 13, 2, o.hat); fillrect(g, hx - 2, hy + 1, 15, 1, o.hatBrim || o.hat) - } - setpx(g, hx + 3, hy + 5, 'e'); setpx(g, hx + 7, hy + 5, 'e') - if (o.glasses) { - setpx(g, hx + 2, hy + 5, o.glasses); setpx(g, hx + 4, hy + 5, o.glasses); setpx(g, hx + 6, hy + 5, o.glasses); setpx(g, hx + 8, hy + 5, o.glasses) - } - setpx(g, hx + 5, hy + 7, skS) - fillrect(g, hx + 4, hy + 9, 3, 1, o.lip || 'p') - if (o.beard) { - fillrect(g, hx + 2, hy + 9, 7, 3, o.beard); fillrect(g, hx + 4, hy + 9, 3, 1, o.lip || 'p') - } - // 2D grid of cell strings (cells are full hex/key values) - joining rows would shred them. - return g - } - return { pal: SPAL, frames: [build(0), build(1)], px: 6 } -} - -export const PORTRAITS: Record = { - victim: makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.h, hairHl: SPAL.H, style: 'bun', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.n, lip: SPAL.p }), - wexler: makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'slick', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.i, tie: SPAL.x, beard: '#7d7c72', stubble: true }), - iris: makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p }), - teo: makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, stubble: true }), - frost: makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'wave', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, glasses: SPAL.g, lip: SPAL.p }), - detective: makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.n, hat: '#23292f', hatBrim: '#171c21', hatHl: '#2d353d', stubble: true }), -} - -export const BODIES: Record = { - wexler: makeBody({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'slick', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.i, tie: SPAL.x, beard: '#7d7c72' }), - iris: makeBody({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p }), - teo: makeBody({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, legs: SPAL.m }), - frost: makeBody({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'wave', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, glasses: SPAL.g, lip: SPAL.p }), -} - -// Gender-matched fallback casts for generated suspects (named portraits are golden-only). -// A female suspect always draws a female portrait/body; a male suspect a male one. -const _M_PORTRAITS: Sprite[] = [ - PORTRAITS.wexler, PORTRAITS.teo, - makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'curly', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i }), - makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'bald', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, beard: '#5a4030' }), - makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.y, hairHl: SPAL.Y, style: 'short', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.n, stubble: true }), -] -const _F_PORTRAITS: Sprite[] = [ - PORTRAITS.iris, PORTRAITS.frost, - makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'bun', cloth: SPAL.x, clothHl: SPAL.X, accent: SPAL.i, lip: SPAL.p }), - makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'curly', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.n, lip: SPAL.p }), - makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'wave', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p }), -] -const _M_BODIES: Sprite[] = [ - BODIES.wexler, BODIES.teo, - makeBody({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, legs: SPAL.m }), - makeBody({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, legs: SPAL.m, beard: '#5a4030' }), -] -const _F_BODIES: Sprite[] = [ - BODIES.iris, BODIES.frost, - makeBody({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'long', cloth: SPAL.x, clothHl: SPAL.X, accent: SPAL.i, lip: SPAL.p }), - makeBody({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.n, lip: SPAL.p }), -] -function _hash(s: string): number { - let h = 0 - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0 - return h -} -function _isFemale(gender?: string): boolean { - return (gender || '').toLowerCase().startsWith('f') -} -export function portraitFor(id: string, gender?: string): Sprite { - if (PORTRAITS[id]) return PORTRAITS[id] - const pool = _isFemale(gender) ? _F_PORTRAITS : _M_PORTRAITS - return pool[_hash(id) % pool.length] -} -export function bodyFor(id: string, gender?: string): Sprite { - if (BODIES[id]) return BODIES[id] - const pool = _isFemale(gender) ? _F_BODIES : _M_BODIES - return pool[_hash(id) % pool.length] -} - -export const IPAL: Record = { - k: '#080c11', d: '#1c222a', g: '#37636b', G: '#5d8a8a', a: '#e0a44c', A: '#f5d08a', - x: '#87292a', w: '#d4cdb7', W: '#f5f1e6', s: '#2d4a52', b: '#3a6b6b', e: '#0e1119', m: '#9d977f', -} -export const EV_ICONS: Record = { - phone: ['..kkkkkkkk..', '..kddddddk..', '..kdwwwwdk..', '..kdwGGwdk..', '..kdwWWwdk..', '..kdwwwwdk..', '..kdaaaadk..', '..kdwwwwdk..', '..kdGGGGdk..', '..kdwwwwdk..', '..kdwwwwdk..', '..kddddddk..', '..kdd ddk..', '..kdaaaadk..', '..kddddddk..', '..kkkkkkkk..'], - receipt: ['...wwwwwww..', '..wWWWWWWw..', '..wkkkkkkw..', '..wWWWWWWw..', '..wkkk.kkw..', '..wWWWWWWw..', '..wkk.kkkw..', '..wWWWWWWw..', '..wkkkk..w..', '..wWWWWWWw..', '..wxxxxxxw..', '..wWWWWWWw..', '..wkk.kkkw..', '..wWWWWWWw..', '..wwvwvwvw..', '...wvwvwv...'], - cctv: ['.kkkkkk.....', '.kddddkk....', '.kdGGGdkk...', '.kdGAAGdkkk.', '.kdGAAGddek.', '.kdGGGdkek..', '.kdddddkk...', '..kkkkk.....', '....kk......', '...kddk.....', '..kddddk....', '..kdsssdk...', '..kdsbsdk...', '..kddddk....', '...kkkk.....', '............'], - voicemail: ['..kkkkkkkk..', '.kddddddddk.', '.kdwwwwwwdk.', '.kdwGbGbwdk.', '.kdwbGbGwdk.', '.kdwGbGbwdk.', '.kdwwwwwwdk.', '.kdaWaWaWdk.', '.kd.a.a.adk.', '.kdaWaWaWdk.', '.kdwwwwwwdk.', '.kddddddddk.', '..kkkkkkkk..', '...k....k...', '..kk....kk..', '............'], - keycard: ['............', '.kkkkkkkkkk.', '.kssssssssk.', '.ksbbbbbbsk.', '.ksbaaaabsk.', '.ksbaWWabsk.', '.ksbaaaabsk.', '.ksbbbbbbsk.', '.ksssssssk..', '.kswwwwsssk.', '.kswwwwsssk.', '.kssssssssk.', '.kkkkkkkkkk.', '......kk....', '.....kgggk..', '............'], - photoEv: ['.wwwwwwwwww.', '.wkkkkkkkkw.', '.wkdddddd kw', '.wkdaa..d.kw', '.wkd.aa..dkw', '.wkd..aa.dkw', '.wkd...aadkw', '.wkdGG..adkw', '.wkdddddddkw', '.wkdGGGGGdkw', '.wkddddddd kw', '.wkkkkkkkkw.', '.wwwwwwwwww.', '...ka.ak....', '..k.aa.k....', '............'], - compass: ['......a.....', '.....aAa....', '....a.a.a...', '...a..a..a..', '..a...a...a.', '.a....a....a', 'a....aAa... a', '.aaaaaAaaaaa', 'a....aAa....a', '.a...a.a...a.', '..a..a.a..a..', '...a.a.a.a...', '....aa.aa....', '.....aAa.....', '......a......', '............'], -} - -// ---- scene painters ---- -const C = { - sky0: '#0c1622', sky1: '#13202b', sky2: '#1b2d38', water: '#0e1a22', - bldg: '#0a1118', bldgL: '#13202b', win: '#e0a44c', winDim: '#7a5a2a', - amber: '#e0a44c', amberD: '#b9772f', bone: '#e0d9c4', ox: '#87292a', - slate: '#2d4a52', slateL: '#3a6b6b', shadow: '#070b0f', lamp: '#f5d08a', -} -function lampCone(ctx: CanvasRenderingContext2D, cx: number, cy: number, w: number, h: number): void { - // A soft pool of lamplight directly under the bulb - dense near the source, fading out - // quickly - NOT a hard full-height pyramid. Narrower + shorter + quadratic falloff. - const reach = Math.max(8, Math.floor(h * 0.5)) - for (let y = 0; y < reach; y++) { - const f = y / reach - const ww = Math.floor(w * 0.45 * f) - const t = 0.3 * (1 - f) * (1 - f) - for (let x = -ww; x < ww; x++) { - const thr = (BAYER4[(cy + y) & 3][(cx + x) & 3] + 0.5) / 16 - if (thr < t) { - ctx.fillStyle = C.amber - ctx.globalAlpha = 0.32 - ctx.fillRect(cx + x, cy + y, 1, 1) - } - } - } - ctx.globalAlpha = 1 -} -function rainStreaks(ctx: CanvasRenderingContext2D, w: number, h: number, t: number): void { - ctx.fillStyle = 'rgba(170,190,200,0.22)' - for (let i = 0; i < 40; i++) { - const x = (i * 37 + t * 4) % w - const y = (i * 53 + t * 7) % h - ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3) - } -} - -const paintSkyline: ScenePainter = (ctx, w, h) => { - const horizon = Math.floor(h * 0.5) - ditherGrad(ctx, 0, 0, w, horizon, C.sky2, C.sky1) - for (let y = horizon - 22; y < horizon; y++) { - const tt = (y - (horizon - 22)) / 22 - for (let x = 0; x < w; x++) { - const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16 - if (thr < tt * 0.5) { ctx.fillStyle = C.amberD; ctx.fillRect(x, y, 1, 1) } - } - } - ditherGrad(ctx, 0, horizon, w, h - horizon, '#0c1620', '#0a121a') - const dist = [[0.0, 0.16, 0.09], [0.08, 0.28, 0.07], [0.14, 0.18, 0.1], [0.23, 0.34, 0.08], [0.31, 0.22, 0.1], [0.4, 0.4, 0.09], [0.49, 0.2, 0.11], [0.58, 0.3, 0.09], [0.66, 0.24, 0.1], [0.75, 0.38, 0.08], [0.83, 0.2, 0.1], [0.91, 0.28, 0.1]] - for (const [bx, bh, bw] of dist) { - const x = Math.floor(bx * w) - const bht = Math.floor(bh * h * 0.44) - const wd = Math.ceil(bw * w) - ctx.fillStyle = '#0a121a'; ctx.fillRect(x, horizon - bht, wd, bht) - ctx.fillStyle = C.bldgL; ctx.fillRect(x, horizon - bht, 1, bht) - for (let wy = horizon - bht + 3; wy < horizon - 2; wy += 5) - for (let wx = x + 2; wx < x + wd - 2; wx += 4) { ctx.fillStyle = (wx + wy) % 3 ? C.winDim : C.win; ctx.fillRect(wx, wy, 2, 2) } - } - const fg = [[0.0, 0.4, 0.16], [0.15, 0.3, 0.13], [0.27, 0.52, 0.15], [0.41, 0.34, 0.12], [0.52, 0.46, 0.16], [0.67, 0.3, 0.14], [0.8, 0.5, 0.2]] - for (const [bx, bh, bw] of fg) { - const x = Math.floor(bx * w) - const wd = Math.ceil(bw * w) - const topY = Math.floor(h * (0.5 + (1 - bh) * 0.22)) - ctx.fillStyle = '#070d13'; ctx.fillRect(x, topY, wd, h - topY) - ctx.fillStyle = '#0e1a24'; ctx.fillRect(x, topY, wd, 2) - ctx.fillStyle = '#10202b'; ctx.fillRect(x, topY, 1, h - topY) - for (let wy = topY + 5; wy < h - 3; wy += 8) - for (let wx = x + 3; wx < x + wd - 4; wx += 8) { const lit = (wx * 3 + wy) % 5; ctx.fillStyle = lit === 0 ? C.amber : lit === 1 ? C.win : '#0c151d'; ctx.fillRect(wx, wy, 3, 4) } - } -} - -const paintDesk: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - ctx.fillStyle = C.sky2; ctx.fillRect(w - 70, 8, 60, h - 40) - for (let y = 10; y < h - 34; y += 4) { ctx.fillStyle = C.shadow; ctx.fillRect(w - 70, y, 60, 2) } - ctx.fillStyle = C.winDim; for (let i = 0; i < 10; i++) ctx.fillRect(w - 66 + i * 6, h - 40, 2, 2) - ctx.fillStyle = '#241a12'; ctx.fillRect(0, h - 26, w, 26) - ctx.fillStyle = '#3a2a1a'; ctx.fillRect(0, h - 26, w, 2) - const lx = 42 - const ly = h - 26 - ctx.fillStyle = C.shadow; ctx.fillRect(lx - 2, ly - 22, 4, 22) - ctx.fillStyle = C.amberD; ctx.fillRect(lx - 9, ly - 30, 18, 9) - ctx.fillStyle = C.amber; ctx.fillRect(lx - 9, ly - 30, 18, 2) - ctx.fillStyle = C.lamp; ctx.fillRect(lx - 7, ly - 22, 14, 3) - lampCone(ctx, lx, ly - 21, 30, 40) - ctx.fillStyle = C.bone; ctx.fillRect(lx - 14, h - 16, 40, 12) - ctx.fillStyle = C.amberD; ctx.fillRect(lx - 14, h - 16, 40, 2) - ctx.fillStyle = C.ox; ctx.fillRect(lx - 10, h - 12, 14, 2) - ctx.fillStyle = C.slate; ctx.fillRect(w - 50, h - 16, 10, 11); ctx.fillRect(w - 40, h - 13, 3, 4) -} - -const paintAtrium: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) - for (let cx = 10; cx < w; cx += 34) { ctx.fillStyle = C.bldg; ctx.fillRect(cx, 10, 8, h - 40); ctx.fillStyle = C.bldgL; ctx.fillRect(cx, 10, 2, h - 40) } - for (let cx = 22; cx < w; cx += 34) { ditherGrad(ctx, cx, 14, 16, h - 50, C.slate, C.sky1); for (let y = 18; y < h - 40; y += 6) { ctx.fillStyle = C.shadow; ctx.fillRect(cx, y, 16, 1) } } - const my = Math.floor(h * 0.34) - ctx.fillStyle = C.bldgL; ctx.fillRect(0, my, w, 4) - for (let x = 4; x < w; x += 6) { ctx.fillStyle = C.slate; ctx.fillRect(x, my + 4, 1, 8) } - ctx.fillStyle = C.shadow; ctx.fillRect(0, my + 12, w, 2) - ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 22, w, 22) - for (let x = 0; x < w; x += 10) { ctx.fillStyle = C.sky1; ctx.fillRect(x, h - 22, 1, 22) } - const lx = Math.floor(w / 2) - ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 14) - ctx.fillStyle = C.amberD; ctx.fillRect(lx - 5, 14, 11, 4) - ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, 18, 9, 2) - lampCone(ctx, lx, 19, 46, h - 30) - ctx.fillStyle = C.bone - const bx = lx + 10 - const by = h - 12 - ctx.fillRect(bx, by, 2, 1); ctx.fillRect(bx + 2, by - 1, 1, 1); ctx.fillRect(bx + 5, by - 2, 1, 1); ctx.fillRect(bx + 8, by, 2, 1); ctx.fillRect(bx - 2, by + 2, 1, 1); ctx.fillRect(bx + 11, by + 2, 1, 1); ctx.fillRect(bx + 3, by + 4, 5, 1) - ctx.fillStyle = C.amber; ctx.fillRect(bx + 14, by, 2, 3) - rainStreaks(ctx, w, h * 0.5, t) -} - -const paintInterro: ScenePainter = (ctx, w, h) => { - // Crisp, full-height interrogation room - flat paneled walls (not a smooth gradient), - // a one-way mirror, a hanging lamp, and a table along the bottom. Designed to fill the - // whole stage so there is no hard crop seam. - ctx.fillStyle = C.sky1; ctx.fillRect(0, 0, w, h) - ctx.fillStyle = C.sky0; ctx.fillRect(0, 0, w, Math.floor(h * 0.3)) // darker upper wall - ctx.fillStyle = '#0a121a' - const step = Math.max(8, Math.floor(w / 6)) - for (let x = step; x < w; x += step) ctx.fillRect(x, 0, 2, Math.floor(h * 0.74)) // panel seams - ctx.fillStyle = C.sky2; ctx.fillRect(0, Math.floor(h * 0.5), w, 3) // wainscot - ctx.fillStyle = C.shadow; ctx.fillRect(0, Math.floor(h * 0.5) + 3, w, 2) - // one-way mirror (upper right) - const mw = Math.floor(w * 0.24) - const mh = Math.floor(h * 0.15) - const mx = w - mw - Math.floor(w * 0.06) - const my = Math.floor(h * 0.12) - ctx.fillStyle = C.shadow; ctx.fillRect(mx - 2, my - 2, mw + 4, mh + 4) - ditherGrad(ctx, mx, my, mw, mh, C.slateL, C.sky1) - // floor - const fy = Math.floor(h * 0.78) - ctx.fillStyle = '#0a1015'; ctx.fillRect(0, fy, w, h - fy) - ctx.fillStyle = C.sky2; ctx.fillRect(0, fy, w, 2) - // hanging lamp + soft glow - const lx = Math.floor(w * 0.5) - ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, Math.floor(h * 0.05)) - ctx.fillStyle = C.amberD; ctx.fillRect(lx - 7, Math.floor(h * 0.05), 15, 5) - ctx.fillStyle = C.lamp; ctx.fillRect(lx - 5, Math.floor(h * 0.05) + 5, 11, 2) - lampCone(ctx, lx, Math.floor(h * 0.05) + 6, Math.floor(w * 0.55), Math.floor(h * 0.55)) - // interrogation table along the bottom - const ty = Math.floor(h * 0.85) - ctx.fillStyle = '#1a2228'; ctx.fillRect(0, ty, w, h - ty) - ctx.fillStyle = C.slate; ctx.fillRect(0, ty, w, 3) -} - -const paintSeawall: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.6), C.sky2, C.sky0) - ctx.fillStyle = C.bldg; for (let x = 0; x < w; x += 18) { const bh = 8 + ((x * 7) % 18); ctx.fillRect(x, Math.floor(h * 0.5) - bh, 16, bh) } - ditherGrad(ctx, 0, Math.floor(h * 0.5), w, Math.floor(h * 0.5), C.water, C.sky0) - ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 20, w, 20) - ctx.fillStyle = C.slate; ctx.fillRect(0, h - 22, w, 2) - for (let x = 6; x < w; x += 14) { ctx.fillStyle = C.slate; ctx.fillRect(x, h - 30, 2, 10) } - ctx.fillStyle = C.slate; ctx.fillRect(0, h - 30, w, 2) - const fx = Math.floor(w * 0.62) - ctx.fillStyle = C.bone; ctx.fillRect(fx, h - 40, 6, 14); ctx.fillStyle = '#9a937e'; ctx.fillRect(fx + 4, h - 40, 2, 14) - ctx.fillStyle = C.sky1; ctx.fillRect(fx + 1, h - 44, 4, 4) - rainStreaks(ctx, w, h, t) -} - -const paintMezzanine: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - ctx.fillStyle = C.bldgL; ctx.fillRect(0, Math.floor(h * 0.5), w, 4) - for (let x = 4; x < w; x += 8) { ctx.fillStyle = C.slate; ctx.fillRect(x, Math.floor(h * 0.5) + 4, 2, 14) } - ditherGrad(ctx, 0, Math.floor(h * 0.5) + 18, w, Math.floor(h * 0.5), C.shadow, '#020406') - lampCone(ctx, w - 14, 4, 40, h - 10) - const fx = Math.floor(w * 0.4) - ctx.fillStyle = C.bone; ctx.fillRect(fx, Math.floor(h * 0.5) - 18, 7, 18) - ctx.fillStyle = '#9a937e'; ctx.fillRect(fx + 5, Math.floor(h * 0.5) - 18, 2, 18) - ctx.fillStyle = C.sky2; ctx.fillRect(fx + 1, Math.floor(h * 0.5) - 23, 5, 5) - ctx.fillStyle = C.ox; ctx.fillRect(fx + 7, Math.floor(h * 0.5) - 6, 2, 2) - rainStreaks(ctx, w, h * 0.5, t) -} - -const paintMap: ScenePainter = (ctx, w, h, t) => { - ctx.fillStyle = C.sky0; ctx.fillRect(0, 0, w, h) - ctx.fillStyle = C.slate - for (let x = 0; x < w; x += 12) ctx.fillRect(x, 0, 1, h) - for (let y = 0; y < h; y += 12) ctx.fillRect(0, y, w, 1) - ctx.fillStyle = C.water; for (let i = 0; i < w + h; i++) ctx.fillRect(i, Math.floor(h * 0.6) - Math.floor(i * 0.4), 3, 3) - const n = Math.min(Math.floor(t / 2), 60) - for (let i = 0; i < n; i++) { const x = (i * 53) % w; const y = (i * 31) % h; ctx.fillStyle = i % 4 ? C.winDim : C.amber; ctx.fillRect(x, y, 3, 3) } - const px = Math.floor(w * 0.56) - const py = Math.floor(h * 0.42) - ctx.fillStyle = C.ox; ctx.fillRect(px - 2, py - 6, 5, 6); ctx.fillRect(px - 1, py, 3, 4) - ctx.fillStyle = C.bone; ctx.fillRect(px, py - 4, 1, 1) -} - -// ---- room interiors: recognizable furniture so a generated location reads as itself ---- -const _WOOD = '#3a2c1c' -const _WOOD_L = '#55402a' -const _METAL = '#2d3640' -const _BOOK = ['#6b3a2e', '#3a5a52', '#5a4a2a', '#46506b', '#5e1c1c', '#37636b'] - -const paintKitchen: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) - const cy = Math.floor(h * 0.16) - for (let x = 6; x < w - 6; x += 26) { - ctx.fillStyle = _WOOD; ctx.fillRect(x, cy, 22, 20) - ctx.fillStyle = _WOOD_L; ctx.fillRect(x, cy, 22, 2) - ctx.fillStyle = C.amber; ctx.fillRect(x + 18, cy + 9, 2, 3) - } - ctx.fillStyle = C.slate; ctx.fillRect(w - 52, cy + 26, 40, 26) - ditherGrad(ctx, w - 50, cy + 28, 36, 22, C.slateL, C.sky1) - ctx.fillStyle = C.shadow; ctx.fillRect(w - 33, cy + 26, 2, 26) - const ly = Math.floor(h * 0.62) - ctx.fillStyle = '#cfc8b2'; ctx.fillRect(0, ly, w, 5) - ctx.fillStyle = _WOOD; ctx.fillRect(0, ly + 5, w, h - ly - 5) - for (let x = 4; x < w; x += 24) { ctx.fillStyle = C.shadow; ctx.fillRect(x, ly + 5, 1, h - ly - 5); ctx.fillStyle = C.amber; ctx.fillRect(x + 18, ly + 12, 2, 3) } - ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.2), ly - 8, 10, 8); ctx.fillRect(Math.floor(w * 0.2) + 10, ly - 5, 3, 3) - const lx = Math.floor(w * 0.5) - ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 12) - ctx.fillStyle = C.amberD; ctx.fillRect(lx - 6, 12, 13, 4) - ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, 16, 9, 2) - lampCone(ctx, lx, 18, 44, ly - 14) - ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.62), ly - 3, 3, 3) - rainStreaks(ctx, w, h * 0.4, t) -} - -const paintStudy: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) - const shelf = (sx: number, sw: number) => { - ctx.fillStyle = _WOOD; ctx.fillRect(sx, 12, sw, h - 40) - for (let sy = 18; sy < h - 34; sy += 16) { - ctx.fillStyle = '#241a10'; ctx.fillRect(sx + 2, sy + 12, sw - 4, 3) - for (let bx = sx + 3; bx < sx + sw - 3; bx += 3) { ctx.fillStyle = _BOOK[(bx + sy) % _BOOK.length]; ctx.fillRect(bx, sy, 2, 12) } - } - } - shelf(8, Math.floor(w * 0.34)) - shelf(Math.floor(w * 0.58), Math.floor(w * 0.34)) - const dy = h - 24 - ctx.fillStyle = '#241a12'; ctx.fillRect(0, dy, w, 24) - ctx.fillStyle = '#3a2a1a'; ctx.fillRect(0, dy, w, 2) - const lx = Math.floor(w * 0.5) - ctx.fillStyle = C.amberD; ctx.fillRect(lx - 6, dy - 12, 12, 5) - ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, dy - 7, 9, 2) - lampCone(ctx, lx, dy - 6, 36, 30) - ctx.fillStyle = C.bone; ctx.fillRect(lx - 14, dy - 4, 20, 4) - ctx.fillStyle = C.ox; ctx.fillRect(lx + 12, dy - 3, 3, 3) -} - -const paintParlor: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) - ctx.fillStyle = C.bldgL; ctx.fillRect(0, Math.floor(h * 0.55), w, 2) - const fx = 12 - const fy = Math.floor(h * 0.32) - ctx.fillStyle = '#1a1410'; ctx.fillRect(fx, fy, 40, h - fy - 22) - ctx.fillStyle = _WOOD; ctx.fillRect(fx - 3, fy - 4, 46, 5) - ctx.fillStyle = '#0a0805'; ctx.fillRect(fx + 8, fy + 10, 24, h - fy - 40) - for (let i = 0; i < 30; i++) { const ex = fx + 10 + ((i * 7) % 20); const ey = h - 36 - ((i * 5) % 16); ctx.fillStyle = i % 3 ? C.amberD : C.amber; ctx.fillRect(ex, ey, 2, 2) } - ctx.fillStyle = _WOOD_L; ctx.fillRect(w - 46, fy, 30, 22); ctx.fillStyle = C.slate; ctx.fillRect(w - 43, fy + 3, 24, 16) - const sy = h - 30 - const sofX = Math.floor(w * 0.34) - const sofW = Math.floor(w * 0.4) - ctx.fillStyle = C.slate; ctx.fillRect(sofX, sy, sofW, 18) - ctx.fillStyle = C.slateL; ctx.fillRect(sofX, sy, sofW, 4) - ctx.fillStyle = C.slate; ctx.fillRect(sofX - 4, sy - 6, 6, 24); ctx.fillRect(sofX + sofW, sy - 6, 6, 24) - ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 12, w, 12) - ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.52), h - 8, 3, 3) - lampCone(ctx, Math.floor(w * 0.5), 8, 50, h - 16) - rainStreaks(ctx, w, h * 0.4, t) -} - -const paintBedroom: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - ctx.fillStyle = C.slate; ctx.fillRect(w - 56, 14, 42, 34); ditherGrad(ctx, w - 54, 16, 38, 30, C.slateL, C.sky1) - ctx.fillStyle = C.shadow; ctx.fillRect(w - 35, 14, 2, 34); ctx.fillRect(w - 56, 30, 42, 2) - const bx = Math.floor(w * 0.22) - const by = h - 34 - const bw = Math.floor(w * 0.5) - ctx.fillStyle = _WOOD; ctx.fillRect(bx - 4, by - 12, 6, 28) - ctx.fillStyle = C.bone; ctx.fillRect(bx, by, bw, 6) - ctx.fillStyle = C.slate; ctx.fillRect(bx, by + 6, bw, 14) - ctx.fillStyle = C.slateL; ctx.fillRect(bx, by + 6, bw, 3) - ctx.fillStyle = C.bone; ctx.fillRect(bx + 3, by + 2, 12, 5) - const nx = bx + bw + 6 - ctx.fillStyle = _WOOD; ctx.fillRect(nx, by + 4, 14, 16) - ctx.fillStyle = C.amberD; ctx.fillRect(nx + 3, by - 2, 8, 6); ctx.fillStyle = C.lamp; ctx.fillRect(nx + 4, by + 3, 6, 1) - lampCone(ctx, nx + 7, by, 28, 24) - ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 10, w, 10) -} - -const paintAlley: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - // facing walls funneling to a lit back street - ctx.fillStyle = '#0a121a'; ctx.fillRect(0, 0, Math.floor(w * 0.3), h) - ctx.fillStyle = '#0c141c'; ctx.fillRect(Math.floor(w * 0.7), 0, Math.ceil(w * 0.3), h) - for (let y = 8; y < h - 30; y += 14) { - ctx.fillStyle = C.bldgL; ctx.fillRect(8, y, 14, 8); ctx.fillRect(w - 24, y + 5, 14, 8) - ctx.fillStyle = (y % 28) ? C.winDim : C.win; ctx.fillRect(11, y + 2, 3, 3); ctx.fillRect(w - 21, y + 7, 3, 3) - } - // fire escape zig-zag on the left wall - ctx.fillStyle = _METAL - for (let i = 0; i < 4; i++) { const y = 16 + i * 22; ctx.fillRect(26, y, 26, 2); ctx.fillRect(i % 2 ? 26 : 50, y - 12, 2, 14) } - // wet cobbled lane - ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 26, w, 26) - for (let x = 2; x < w; x += 9) { ctx.fillStyle = (x % 18) ? '#101a22' : C.slate; ctx.fillRect(x, h - 24 + (x % 3), 5, 2) } - // dumpster + crates - ctx.fillStyle = C.slate; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 18) - ctx.fillStyle = C.slateL; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 3) - ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.32) + 40, h - 34, 12, 10); ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.32) + 40, h - 34, 12, 2) - // buzzing sign at the alley mouth - const sx = Math.floor(w * 0.62) - ctx.fillStyle = C.shadow; ctx.fillRect(sx, 18, 3, 16) - ctx.fillStyle = C.ox; ctx.fillRect(sx - 12, 20, 12, 12) - ctx.fillStyle = '#b8443f'; ctx.fillRect(sx - 10, 22, 8, 8) - lampCone(ctx, sx - 6, 32, 30, h - 60) - rainStreaks(ctx, w, h, t) -} - -const paintBar: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - // back shelf with bottles - ctx.fillStyle = _WOOD; ctx.fillRect(10, 12, w - 20, 34) - ctx.fillStyle = '#241a10'; ctx.fillRect(10, 28, w - 20, 3); ctx.fillRect(10, 43, w - 20, 3) - for (let x = 16; x < w - 18; x += 7) { - ctx.fillStyle = _BOOK[(x >> 2) % _BOOK.length]; ctx.fillRect(x, 18, 4, 10); ctx.fillRect(x + 1, 15, 2, 3) - if ((x >> 3) % 2) { ctx.fillStyle = _BOOK[(x >> 1) % _BOOK.length]; ctx.fillRect(x, 33, 4, 10); ctx.fillRect(x + 1, 31, 2, 2) } - } - // mirror strip behind the top row - ctx.fillStyle = C.slateL; ctx.fillRect(12, 13, w - 24, 2) - // counter - const cy = Math.floor(h * 0.62) - ctx.fillStyle = _WOOD_L; ctx.fillRect(0, cy, w, 5) - ctx.fillStyle = _WOOD; ctx.fillRect(0, cy + 5, w, h - cy - 5) - for (let x = 6; x < w; x += 22) { ctx.fillStyle = C.shadow; ctx.fillRect(x, cy + 5, 1, h - cy - 5) } - // stools - for (let i = 0; i < 4; i++) { - const x = 24 + i * Math.floor(w * 0.22) - ctx.fillStyle = C.ox; ctx.fillRect(x, h - 22, 14, 4) - ctx.fillStyle = _METAL; ctx.fillRect(x + 6, h - 18, 2, 18) - } - // glass + bottle left on the counter - ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.3), cy - 6, 4, 6) - ctx.fillStyle = C.amberD; ctx.fillRect(Math.floor(w * 0.3) + 10, cy - 10, 4, 10); ctx.fillStyle = C.amber; ctx.fillRect(Math.floor(w * 0.3) + 11, cy - 12, 2, 2) - // low pendant lamps - for (const fx of [0.25, 0.65]) { - const lx = Math.floor(w * fx) - ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 8) - ctx.fillStyle = C.amberD; ctx.fillRect(lx - 5, 8, 11, 4); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 3, 12, 7, 2) - lampCone(ctx, lx, 13, 34, cy - 16) - } -} - -const paintCasino: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, '#131a14', '#0a0f0a') - // wall pattern + framed rules board - for (let x = 0; x < w; x += 16) { ctx.fillStyle = '#0d1410'; ctx.fillRect(x, 0, 2, Math.floor(h * 0.5)) } - ctx.fillStyle = _WOOD; ctx.fillRect(w - 58, 10, 40, 24); ctx.fillStyle = C.bone; ctx.fillRect(w - 54, 13, 32, 18) - ctx.fillStyle = C.shadow; for (let i = 0; i < 4; i++) ctx.fillRect(w - 50, 16 + i * 4, 24, 1) - // felt table, elliptical feel via stacked rects - const ty = Math.floor(h * 0.55) - ctx.fillStyle = '#14401f'; ctx.fillRect(Math.floor(w * 0.1), ty, Math.floor(w * 0.8), 26) - ctx.fillStyle = '#1d5a2c'; ctx.fillRect(Math.floor(w * 0.13), ty, Math.floor(w * 0.74), 4) - ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.08), ty + 26, Math.floor(w * 0.84), 5) - // cards + chip stacks - ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.3), ty + 8, 7, 9); ctx.fillRect(Math.floor(w * 0.34), ty + 6, 7, 9) - ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.36), ty + 8, 2, 2) - for (let i = 0; i < 3; i++) { - const x = Math.floor(w * 0.55) + i * 9 - ctx.fillStyle = [C.ox, C.bone, C.amber][i]; ctx.fillRect(x, ty + 10 - (i % 2) * 2, 6, 6 + (i % 2) * 2) - ctx.fillStyle = C.shadow; ctx.fillRect(x, ty + 14, 6, 1) - } - // dice - ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.46), ty + 12, 4, 4); ctx.fillStyle = C.shadow; ctx.fillRect(Math.floor(w * 0.47), ty + 13, 1, 1) - // green-shaded pendant - const lx = Math.floor(w * 0.5) - ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 10) - ctx.fillStyle = '#1d5a2c'; ctx.fillRect(lx - 8, 10, 17, 5); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 5, 15, 11, 2) - lampCone(ctx, lx, 16, 50, ty - 16) - ctx.fillStyle = '#0a0f0a'; ctx.fillRect(0, h - 10, w, 10) -} - -const paintTheater: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, '#1a0e10', '#0c0608') - // stage floor + proscenium - const sy = Math.floor(h * 0.58) - ctx.fillStyle = _WOOD; ctx.fillRect(0, sy, w, 8) - ctx.fillStyle = _WOOD_L; ctx.fillRect(0, sy, w, 2) - // heavy curtains left/right with fold lines - for (const [x0, dir] of [[0, 1], [w - Math.floor(w * 0.18), -1]] as [number, number][]) { - const cw = Math.floor(w * 0.18) - ctx.fillStyle = '#5e1c1c'; ctx.fillRect(x0, 0, cw, sy) - for (let x = x0 + 2; x < x0 + cw; x += 5) { ctx.fillStyle = '#481414'; ctx.fillRect(x, 0, 2, sy) } - ctx.fillStyle = '#87292a'; ctx.fillRect(dir > 0 ? x0 : x0 + cw - 3, 0, 3, sy) - } - // curtain valance - ctx.fillStyle = '#5e1c1c'; ctx.fillRect(0, 0, w, 14) - for (let x = 3; x < w; x += 9) { ctx.fillStyle = '#481414'; ctx.fillRect(x, 10, 5, 6) } - ctx.fillStyle = C.amberD; ctx.fillRect(0, 14, w, 2) - // backdrop + lone prop chair - ditherGrad(ctx, Math.floor(w * 0.2), 18, Math.floor(w * 0.6), sy - 20, C.sky2, C.sky0) - ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.46), sy - 18, 3, 18); ctx.fillRect(Math.floor(w * 0.55), sy - 18, 3, 18); ctx.fillRect(Math.floor(w * 0.46), sy - 18, 12, 3); ctx.fillRect(Math.floor(w * 0.46), sy - 30, 3, 12) - // footlights - for (let x = 10; x < w - 8; x += 18) { ctx.fillStyle = C.lamp; ctx.fillRect(x, sy - 2, 4, 2); lampCone(ctx, x + 2, sy - 2, 10, 12) } - // audience rows in the dark - ctx.fillStyle = '#070b0f'; ctx.fillRect(0, sy + 8, w, h - sy - 8) - for (let y = sy + 12; y < h - 4; y += 9) for (let x = 8 + (y % 2) * 6; x < w - 8; x += 14) { ctx.fillStyle = '#10161c'; ctx.fillRect(x, y, 9, 5) } - lampCone(ctx, Math.floor(w * 0.5), 16, 60, sy - 18) -} - -const paintWarehouse: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - // high ribbon windows letting moonlight in - for (let x = 8; x < w - 8; x += 34) { - ctx.fillStyle = C.slate; ctx.fillRect(x, 6, 26, 12) - ditherGrad(ctx, x + 2, 8, 22, 8, C.slateL, C.sky1) - ctx.fillStyle = C.shadow; ctx.fillRect(x + 12, 6, 2, 12) - } - // roof trusses - ctx.fillStyle = _METAL - for (let x = 0; x < w; x += 40) { ctx.fillRect(x, 20, 2, 10); ctx.fillRect(x, 28, 40, 2) } - // crate stacks - const crate = (x: number, y: number, s: number) => { - ctx.fillStyle = _WOOD; ctx.fillRect(x, y, s, s) - ctx.fillStyle = _WOOD_L; ctx.fillRect(x, y, s, 2); ctx.fillRect(x, y, 2, s) - ctx.fillStyle = C.shadow; ctx.fillRect(x + 2, y + Math.floor(s / 2), s - 4, 1) - } - crate(14, h - 50, 24); crate(40, h - 50, 24); crate(26, h - 74, 24) - crate(w - 44, h - 44, 18); crate(w - 70, h - 44, 18) - // hanging chain + hook - ctx.fillStyle = _METAL; for (let y = 0; y < 36; y += 4) ctx.fillRect(Math.floor(w * 0.58), y, 2, 3) - ctx.fillRect(Math.floor(w * 0.58) - 2, 36, 6, 3) - // floor - ctx.fillStyle = '#0c1014'; ctx.fillRect(0, h - 26, w, 26) - for (let x = 0; x < w; x += 26) { ctx.fillStyle = '#11161b'; ctx.fillRect(x, h - 26, 1, 26) } - lampCone(ctx, Math.floor(w * 0.4), 30, 44, h - 60) - rainStreaks(ctx, w, 18, t) -} - -const paintRooftop: ScenePainter = (ctx, w, h, t) => { - // city far below beyond the parapet - ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.55), C.sky2, C.sky0) - for (let x = 0; x < w; x += 14) { - const bh = 6 + ((x * 13) % 22) - ctx.fillStyle = C.bldg; ctx.fillRect(x, Math.floor(h * 0.55) - bh, 12, bh) - ctx.fillStyle = (x % 28) ? C.winDim : C.win; ctx.fillRect(x + 3, Math.floor(h * 0.55) - bh + 3, 2, 2) - } - // parapet ledge - ctx.fillStyle = '#10181f'; ctx.fillRect(0, Math.floor(h * 0.55), w, 6) - ctx.fillStyle = C.slate; ctx.fillRect(0, Math.floor(h * 0.55), w, 2) - // tar roof - ditherGrad(ctx, 0, Math.floor(h * 0.55) + 6, w, h, '#0c1117', '#070b0f') - // vents, pipe, water tank - ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.18), h - 34, 18, 14) - ctx.fillStyle = '#3a444e'; ctx.fillRect(Math.floor(w * 0.18), h - 34, 18, 3) - ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.24), h - 40, 4, 6) - const wx = Math.floor(w * 0.72) - ctx.fillStyle = _WOOD; ctx.fillRect(wx, h - 64, 30, 26) - ctx.fillStyle = _WOOD_L; ctx.fillRect(wx, h - 64, 30, 3) - ctx.fillStyle = _METAL; ctx.fillRect(wx + 4, h - 38, 2, 12); ctx.fillRect(wx + 24, h - 38, 2, 12) - ctx.fillStyle = '#241c12'; ctx.fillRect(wx + 6, h - 70, 18, 6) - // door bulkhead with a weak lamp - ctx.fillStyle = '#0e151c'; ctx.fillRect(8, h - 56, 26, 30) - ctx.fillStyle = C.slate; ctx.fillRect(8, h - 56, 26, 2) - ctx.fillStyle = '#1a232c'; ctx.fillRect(14, h - 48, 12, 22) - ctx.fillStyle = C.lamp; ctx.fillRect(19, h - 60, 4, 2) - lampCone(ctx, 21, h - 58, 22, 28) - rainStreaks(ctx, w, h, t) -} - -const paintOffice: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - // blinds with light slicing through - const bw = Math.floor(w * 0.3) - ctx.fillStyle = C.sky2; ctx.fillRect(w - bw - 10, 8, bw, 44) - for (let y = 10; y < 50; y += 4) { ctx.fillStyle = (y % 8) ? C.shadow : C.winDim; ctx.fillRect(w - bw - 10, y, bw, 2) } - // filing cabinets - for (let i = 0; i < 2; i++) { - const x = 10 + i * 22 - ctx.fillStyle = _METAL; ctx.fillRect(x, h - 64, 18, 40) - for (let d = 0; d < 3; d++) { ctx.fillStyle = '#3a444e'; ctx.fillRect(x + 2, h - 60 + d * 12, 14, 2); ctx.fillStyle = C.amberD; ctx.fillRect(x + 7, h - 57 + d * 12, 4, 1) } - } - // desk with papers, phone, banker's lamp - const dy = h - 30 - ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.3), dy, Math.floor(w * 0.52), 6) - ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.3), dy, Math.floor(w * 0.52), 2) - ctx.fillStyle = '#241a12'; ctx.fillRect(Math.floor(w * 0.33), dy + 6, 8, h - dy - 6); ctx.fillRect(Math.floor(w * 0.72), dy + 6, 8, h - dy - 6) - ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.38), dy - 4, 16, 4); ctx.fillRect(Math.floor(w * 0.42), dy - 6, 14, 2) - ctx.fillStyle = '#15181d'; ctx.fillRect(Math.floor(w * 0.6), dy - 6, 12, 6) - const lx = Math.floor(w * 0.5) - ctx.fillStyle = C.amberD; ctx.fillRect(lx - 6, dy - 14, 13, 4); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, dy - 10, 9, 2) - lampCone(ctx, lx, dy - 9, 30, 26) - // coat stand - ctx.fillStyle = _WOOD; ctx.fillRect(w - 16, h - 58, 2, 34) - ctx.fillStyle = C.sky2; ctx.fillRect(w - 22, h - 54, 10, 16) - ctx.fillStyle = '#0c1117'; ctx.fillRect(0, h - 12, w, 12) - rainStreaks(ctx, w - bw - 10 + 2, 50, t) -} - -const paintLobby: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) - // key wall behind the front desk - ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.28), 10, Math.floor(w * 0.44), 30) - for (let y = 14; y < 36; y += 7) for (let x = Math.floor(w * 0.3); x < Math.floor(w * 0.7); x += 8) { - ctx.fillStyle = '#241a10'; ctx.fillRect(x, y, 5, 5) - if ((x + y) % 3) { ctx.fillStyle = C.amber; ctx.fillRect(x + 2, y + 1, 1, 3) } - } - // big lobby clock - ctx.fillStyle = _WOOD; ctx.fillRect(w - 36, 8, 22, 26) - ctx.fillStyle = C.bone; ctx.fillRect(w - 33, 11, 16, 16) - ctx.fillStyle = C.shadow; ctx.fillRect(w - 26, 14, 2, 6); ctx.fillRect(w - 26, 19, 5, 2) - // front desk - const dy = Math.floor(h * 0.56) - ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.2), dy, Math.floor(w * 0.6), 5) - ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.2), dy + 5, Math.floor(w * 0.6), 22) - for (let x = Math.floor(w * 0.24); x < Math.floor(w * 0.78); x += 16) { ctx.fillStyle = '#241a10'; ctx.fillRect(x, dy + 8, 10, 14) } - // service bell + register book - ctx.fillStyle = C.amber; ctx.fillRect(Math.floor(w * 0.44), dy - 4, 6, 3); ctx.fillStyle = C.amberD; ctx.fillRect(Math.floor(w * 0.46), dy - 6, 2, 2) - ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.56), dy - 3, 14, 3) - // checkered marble floor - for (let y = dy + 27; y < h; y += 6) for (let x = ((y / 6) % 2) * 6; x < w; x += 12) { ctx.fillStyle = '#11181f'; ctx.fillRect(x, y, 6, 6) } - ctx.fillStyle = '#0b1016'; ctx.fillRect(0, dy + 27, w, 1) - // twin sconces - for (const fx of [0.12, 0.88]) { - const sx = Math.floor(w * fx) - ctx.fillStyle = C.amberD; ctx.fillRect(sx - 2, 22, 5, 6); ctx.fillStyle = C.lamp; ctx.fillRect(sx - 1, 20, 3, 2) - lampCone(ctx, sx, 28, 20, 30) - } - lampCone(ctx, Math.floor(w * 0.5), 6, 54, dy - 8) -} - -const paintStation: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.5), C.sky1, C.sky0) - // canopy + supports - ctx.fillStyle = '#10181f'; ctx.fillRect(0, 0, w, 12) - ctx.fillStyle = C.slate; ctx.fillRect(0, 12, w, 2) - for (let x = 20; x < w; x += 60) { ctx.fillStyle = _METAL; ctx.fillRect(x, 14, 3, Math.floor(h * 0.62) - 14) } - // hanging station clock - const cx = Math.floor(w * 0.5) - ctx.fillStyle = _METAL; ctx.fillRect(cx - 1, 14, 2, 8) - ctx.fillStyle = '#10181f'; ctx.fillRect(cx - 8, 22, 16, 16) - ctx.fillStyle = C.bone; ctx.fillRect(cx - 6, 24, 12, 12) - ctx.fillStyle = C.shadow; ctx.fillRect(cx - 1, 27, 2, 4); ctx.fillRect(cx - 1, 29, 4, 2) - // platform edge - const py = Math.floor(h * 0.62) - ctx.fillStyle = '#11181f'; ctx.fillRect(0, py, w, 8) - ctx.fillStyle = C.bone; for (let x = 0; x < w; x += 10) ctx.fillRect(x, py, 6, 2) - // tracks below - ctx.fillStyle = '#070b0f'; ctx.fillRect(0, py + 8, w, h - py - 8) - for (let x = 0; x < w; x += 12) { ctx.fillStyle = _WOOD; ctx.fillRect(x, py + 14, 8, 3) } - ctx.fillStyle = _METAL; ctx.fillRect(0, py + 12, w, 2); ctx.fillRect(0, py + 22, w, 2) - // bench + abandoned suitcase - ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.14), py - 14, 30, 4); ctx.fillRect(Math.floor(w * 0.14) + 2, py - 10, 3, 10); ctx.fillRect(Math.floor(w * 0.14) + 25, py - 10, 3, 10) - ctx.fillStyle = '#5a4a2a'; ctx.fillRect(Math.floor(w * 0.55), py - 10, 14, 10); ctx.fillStyle = '#3e331d'; ctx.fillRect(Math.floor(w * 0.55) + 6, py - 10, 2, 10) - // departures board - ctx.fillStyle = '#0a0f14'; ctx.fillRect(w - 64, 18, 48, 22) - for (let i = 0; i < 3; i++) { ctx.fillStyle = i === 1 ? C.amber : C.winDim; ctx.fillRect(w - 60, 22 + i * 6, 28 - i * 6, 2) } - lampCone(ctx, Math.floor(w * 0.3), 14, 40, py - 20) - rainStreaks(ctx, w, 12, t) -} - -const paintGarage: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - // segmented rolling door - const dw = Math.floor(w * 0.4) - ctx.fillStyle = '#10181f'; ctx.fillRect(w - dw - 8, 8, dw, Math.floor(h * 0.6)) - for (let y = 10; y < Math.floor(h * 0.6); y += 8) { ctx.fillStyle = C.slate; ctx.fillRect(w - dw - 8, y, dw, 2) } - // pegboard of tools - ctx.fillStyle = _WOOD; ctx.fillRect(10, 10, Math.floor(w * 0.3), 34) - for (let x = 14; x < Math.floor(w * 0.3); x += 8) { ctx.fillStyle = _METAL; ctx.fillRect(x, 14, 2, 10 + (x % 3) * 4) } - ctx.fillStyle = '#3a444e'; ctx.fillRect(14, 36, Math.floor(w * 0.26), 2) - // car under tarp - const cy = h - 40 - ctx.fillStyle = C.slate; ctx.fillRect(Math.floor(w * 0.16), cy, Math.floor(w * 0.5), 18) - ctx.fillStyle = C.slateL; ctx.fillRect(Math.floor(w * 0.2), cy - 8, Math.floor(w * 0.34), 8) - ctx.fillStyle = C.shadow; ctx.fillRect(Math.floor(w * 0.22), cy + 18, 10, 5); ctx.fillRect(Math.floor(w * 0.52), cy + 18, 10, 5) - // oil stain + dropped wrench - ctx.fillStyle = '#05080b'; ctx.fillRect(Math.floor(w * 0.72), h - 16, 22, 6) - ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.78), h - 22, 10, 2); ctx.fillRect(Math.floor(w * 0.86), h - 24, 3, 6) - ctx.fillStyle = '#0c1014'; ctx.fillRect(0, h - 12, w, 12) - // caged work light - const lx = Math.floor(w * 0.38) - ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 10) - ctx.fillStyle = _METAL; ctx.fillRect(lx - 4, 10, 9, 6) - ctx.fillStyle = C.lamp; ctx.fillRect(lx - 2, 12, 5, 3) - lampCone(ctx, lx, 16, 40, cy - 14) -} - -const paintChapel: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, '#100c14', '#080610') - // tall stained-glass window - const gx = Math.floor(w * 0.42) - const gw = Math.floor(w * 0.16) - ctx.fillStyle = C.shadow; ctx.fillRect(gx - 2, 6, gw + 4, 52) - const tints = ['#5e1c1c', '#284149', '#5a4a2a', '#46506b'] - for (let y = 8; y < 54; y += 6) for (let x = gx; x < gx + gw; x += 5) { ctx.fillStyle = tints[((x + y) >> 2) % tints.length]; ctx.fillRect(x, y, 4, 5) } - ctx.fillStyle = C.shadow; ctx.fillRect(gx + Math.floor(gw / 2), 6, 2, 52); ctx.fillRect(gx - 2, 28, gw + 4, 2) - // moonlight pooling beneath the glass - lampCone(ctx, gx + Math.floor(gw / 2), 58, 40, 40) - // altar with candles - const ay = Math.floor(h * 0.6) - ctx.fillStyle = _WOOD; ctx.fillRect(gx - 10, ay, gw + 20, 6) - ctx.fillStyle = C.bone; ctx.fillRect(gx - 8, ay - 2, gw + 16, 2) - for (let i = 0; i < 4; i++) { - const x = gx - 6 + i * Math.floor((gw + 12) / 3) - ctx.fillStyle = C.bone; ctx.fillRect(x, ay - 8, 2, 6) - ctx.fillStyle = C.amber; ctx.fillRect(x, ay - 10, 2, 2) - } - // pews receding - for (let i = 0; i < 3; i++) { - const y = ay + 14 + i * 12 - ctx.fillStyle = _WOOD; ctx.fillRect(14 - i * 2, y, w - 28 + i * 4, 5) - ctx.fillStyle = '#241a10'; ctx.fillRect(14 - i * 2, y + 5, w - 28 + i * 4, 2) - } - ctx.fillStyle = '#06040a'; ctx.fillRect(0, h - 8, w, 8) -} - -const paintGallery: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) - // hung canvases, one conspicuously missing - const art = [[14, 0.26, '#284149'], [Math.floor(w * 0.3), 0.2, '#5a4a2a'], [Math.floor(w * 0.62), 0.24, '#46506b']] as [number, number, string][] - for (const [x, fw, tint] of art) { - const aw = Math.floor(w * fw) - ctx.fillStyle = _WOOD_L; ctx.fillRect(x - 2, 12, aw + 4, 34) - ditherGrad(ctx, x, 14, aw, 30, tint, C.sky0) - ctx.fillStyle = C.shadow; ctx.fillRect(x, 44, aw, 2) - } - // the empty frame - pale rectangle where a painting was - const ex = Math.floor(w * 0.48) - ctx.fillStyle = _WOOD_L; ctx.fillRect(ex - 2, 12, 24, 34) - ctx.fillStyle = '#1d2832'; ctx.fillRect(ex, 14, 20, 30) - ctx.fillStyle = C.bone; ctx.fillRect(ex + 2, 16, 16, 26) - ctx.fillStyle = C.ox; ctx.fillRect(ex + 6, 26, 8, 2) - // velvet rope line - for (let x = 10; x < w - 10; x += 36) { ctx.fillStyle = _METAL; ctx.fillRect(x, h - 38, 2, 16) } - ctx.fillStyle = C.ox; for (let x = 12; x < w - 12; x += 4) ctx.fillRect(x, h - 34 + ((x >> 2) % 2), 3, 2) - // pedestal with small bust - const px2 = Math.floor(w * 0.82) - ctx.fillStyle = C.slate; ctx.fillRect(px2, h - 44, 12, 22) - ctx.fillStyle = C.slateL; ctx.fillRect(px2, h - 44, 12, 2) - ctx.fillStyle = C.bone; ctx.fillRect(px2 + 3, h - 52, 6, 8) - // polished floor - ctx.fillStyle = '#0d141b'; ctx.fillRect(0, h - 20, w, 20) - for (let x = 0; x < w; x += 18) { ctx.fillStyle = '#121a22'; ctx.fillRect(x, h - 20, 1, 20) } - for (const [x, fw] of art) lampCone(ctx, x + Math.floor((w * fw) / 2), 8, Math.floor(w * fw), 30) - lampCone(ctx, ex + 10, 8, 26, 30) -} - -const paintCellar: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, '#120e0a', '#080604') - // stone arch ribs - for (let x = 8; x < w; x += 44) { - ctx.fillStyle = '#1d1812'; ctx.fillRect(x, 8, 6, h - 28) - ctx.fillStyle = '#28201666'; ctx.fillRect(x, 8, 2, h - 28) - } - ctx.fillStyle = '#1d1812'; ctx.fillRect(0, 6, w, 4) - // wine racks - for (let y = 22; y < h - 40; y += 14) for (let x = 18; x < Math.floor(w * 0.4); x += 7) { - ctx.fillStyle = '#241a10'; ctx.fillRect(x, y, 6, 10) - ctx.fillStyle = '#0f2418'; ctx.fillRect(x + 1, y + 2, 4, 4) - } - // barrels on their sides - for (let i = 0; i < 2; i++) { - const x = Math.floor(w * 0.55) + i * 34 - ctx.fillStyle = _WOOD; ctx.fillRect(x, h - 48, 26, 22) - ctx.fillStyle = _WOOD_L; ctx.fillRect(x, h - 48, 26, 3) - ctx.fillStyle = _METAL; ctx.fillRect(x, h - 44, 26, 2); ctx.fillRect(x, h - 32, 26, 2) - ctx.fillStyle = '#241a10'; ctx.fillRect(x + 11, h - 40, 4, 6) - } - // candle lantern on a crate - const cx = Math.floor(w * 0.34) - ctx.fillStyle = _WOOD; ctx.fillRect(cx, h - 38, 16, 14) - ctx.fillStyle = _METAL; ctx.fillRect(cx + 4, h - 50, 8, 12) - ctx.fillStyle = C.amber; ctx.fillRect(cx + 6, h - 46, 4, 6) - lampCone(ctx, cx + 8, h - 44, 26, 22) - // dirt floor - ctx.fillStyle = '#0c0906'; ctx.fillRect(0, h - 18, w, 18) - for (let x = 4; x < w; x += 11) { ctx.fillStyle = '#141009'; ctx.fillRect(x, h - 14 + (x % 3), 4, 2) } -} - -const paintGreenhouse: ScenePainter = (ctx, w, h, t) => { - // glass panes against the night sky - ditherGrad(ctx, 0, 0, w, h, '#0e1d1a', '#081210') - ctx.fillStyle = _METAL - for (let x = 0; x < w; x += 24) ctx.fillRect(x, 0, 2, Math.floor(h * 0.6)) - for (let y = 0; y < Math.floor(h * 0.6); y += 16) ctx.fillRect(0, y, w, 2) - // slanted roof line - for (let x = 0; x < w; x += 8) { ctx.fillStyle = '#10202b44'; ctx.fillRect(x, Math.floor(x / 12), 6, 1) } - // planting benches with foliage - const by = Math.floor(h * 0.62) - ctx.fillStyle = _WOOD; ctx.fillRect(6, by, Math.floor(w * 0.38), 4); ctx.fillRect(Math.floor(w * 0.56), by, Math.floor(w * 0.38), 4) - ctx.fillStyle = '#241a10'; ctx.fillRect(10, by + 4, 3, h - by - 14); ctx.fillRect(Math.floor(w * 0.38) - 6, by + 4, 3, h - by - 14) - ctx.fillRect(Math.floor(w * 0.56) + 4, by + 4, 3, h - by - 14); ctx.fillRect(Math.floor(w * 0.92), by + 4, 3, h - by - 14) - const leaf = ['#1d4a2c', '#14401f', '#2c5e38'] - for (let x = 8; x < Math.floor(w * 0.42); x += 6) { ctx.fillStyle = leaf[(x >> 1) % 3]; ctx.fillRect(x, by - 6 - (x % 5), 4, 6 + (x % 5)) } - for (let x = Math.floor(w * 0.58); x < Math.floor(w * 0.92); x += 6) { ctx.fillStyle = leaf[(x >> 2) % 3]; ctx.fillRect(x, by - 5 - (x % 4), 4, 5 + (x % 4)) } - // overturned pot + trowel on the path - ctx.fillStyle = '#6b3a2e'; ctx.fillRect(Math.floor(w * 0.46), h - 16, 10, 6) - ctx.fillStyle = '#0c0906'; ctx.fillRect(Math.floor(w * 0.44), h - 11, 8, 3) - ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.5), h - 9, 8, 2) - // stone path - ctx.fillStyle = '#10161c'; ctx.fillRect(Math.floor(w * 0.42), by + 4, Math.floor(w * 0.14), h - by - 4) - lampCone(ctx, Math.floor(w * 0.5), 4, 50, by - 6) - rainStreaks(ctx, w, Math.floor(h * 0.55), t) -} - -const paintDiner: ScenePainter = (ctx, w, h, t) => { - ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) - // big window with the street outside - const wx = Math.floor(w * 0.55) - ctx.fillStyle = C.shadow; ctx.fillRect(wx - 2, 8, w - wx - 8, 40) - ditherGrad(ctx, wx, 10, w - wx - 12, 36, C.sky2, C.sky0) - for (let x = wx + 4; x < w - 16; x += 12) { ctx.fillStyle = C.bldg; ctx.fillRect(x, 26, 8, 20) ; ctx.fillStyle = C.winDim; ctx.fillRect(x + 2, 30, 2, 2) } - // reversed window lettering - ctx.fillStyle = C.amberD; for (let i = 0; i < 5; i++) ctx.fillRect(wx + 6 + i * 9, 14, 6, 2) - // counter with pie stand and coffee pot - const cy = Math.floor(h * 0.58) - ctx.fillStyle = '#3a6b6b'; ctx.fillRect(0, cy, Math.floor(w * 0.5), 5) - ctx.fillStyle = '#284149'; ctx.fillRect(0, cy + 5, Math.floor(w * 0.5), h - cy - 17) - ctx.fillStyle = C.bone; ctx.fillRect(14, cy - 10, 12, 8); ctx.fillStyle = C.slateL; ctx.fillRect(12, cy - 12, 16, 2) - ctx.fillStyle = _METAL; ctx.fillRect(38, cy - 9, 8, 9); ctx.fillStyle = C.amberD; ctx.fillRect(40, cy - 11, 4, 2) - // stools - for (let i = 0; i < 3; i++) { const x = 10 + i * 22; ctx.fillStyle = C.ox; ctx.fillRect(x, h - 20, 12, 3); ctx.fillStyle = _METAL; ctx.fillRect(x + 5, h - 17, 2, 17) } - // booth by the window - ctx.fillStyle = C.ox; ctx.fillRect(wx + 2, h - 36, 8, 24); ctx.fillRect(w - 22, h - 36, 8, 24) - ctx.fillStyle = '#6b2222'; ctx.fillRect(wx + 2, h - 36, 8, 3); ctx.fillRect(w - 22, h - 36, 8, 3) - ctx.fillStyle = _WOOD; ctx.fillRect(wx + 12, h - 26, w - wx - 36, 4) - ctx.fillStyle = C.bone; ctx.fillRect(wx + 18, h - 30, 8, 4) - // checkered floor - for (let y = h - 12; y < h; y += 6) for (let x = ((y / 6) % 2) * 6; x < w; x += 12) { ctx.fillStyle = '#11181f'; ctx.fillRect(x, y, 6, 6) } - lampCone(ctx, Math.floor(w * 0.25), 6, 40, cy - 10) - rainStreaks(ctx, w - wx, 38, t) -} - -const paintVault: ScenePainter = (ctx, w, h) => { - ditherGrad(ctx, 0, 0, w, h, '#11141c', '#080a10') - // wall of deposit boxes - for (let y = 10; y < h - 40; y += 10) for (let x = 8; x < Math.floor(w * 0.38); x += 12) { - ctx.fillStyle = _METAL; ctx.fillRect(x, y, 10, 8) - ctx.fillStyle = '#3a444e'; ctx.fillRect(x, y, 10, 1) - ctx.fillStyle = C.amberD; ctx.fillRect(x + 4, y + 3, 2, 2) - } - // one box pulled open - ctx.fillStyle = '#05080b'; ctx.fillRect(32, 40, 10, 8) - ctx.fillStyle = _METAL; ctx.fillRect(30, 46, 14, 3) - // massive round vault door ajar on the right - const cx = Math.floor(w * 0.72) - const cy2 = Math.floor(h * 0.45) - for (let r = 26; r > 4; r -= 4) { - ctx.fillStyle = r % 8 ? '#2d3640' : '#3a444e' - ctx.fillRect(cx - r, cy2 - r, r * 2, r * 2) - } - ctx.fillStyle = '#10141a'; ctx.fillRect(cx - 4, cy2 - 4, 8, 8) - // spoke handle - ctx.fillStyle = C.bone - ctx.fillRect(cx - 14, cy2 - 1, 28, 2); ctx.fillRect(cx - 1, cy2 - 14, 2, 28) - // hinge gap of darkness - the door stands open - ctx.fillStyle = '#020304'; ctx.fillRect(cx + 24, cy2 - 28, 10, 56) - // scattered bills on the floor - ctx.fillStyle = '#0b0e14'; ctx.fillRect(0, h - 24, w, 24) - for (let i = 0; i < 6; i++) { const x = 20 + i * Math.floor(w * 0.13); ctx.fillStyle = i % 2 ? '#1d5a2c' : C.bone; ctx.fillRect(x, h - 18 + (i % 3) * 3, 8, 4) } - lampCone(ctx, Math.floor(w * 0.4), 2, 50, h - 30) -} - -export const SCENES: Record = { - skyline: paintSkyline, desk: paintDesk, atrium: paintAtrium, interro: paintInterro, - seawall: paintSeawall, mezzanine: paintMezzanine, map: paintMap, - kitchen: paintKitchen, study: paintStudy, parlor: paintParlor, bedroom: paintBedroom, - alley: paintAlley, bar: paintBar, casino: paintCasino, theater: paintTheater, - warehouse: paintWarehouse, rooftop: paintRooftop, office: paintOffice, lobby: paintLobby, - station: paintStation, garage: paintGarage, chapel: paintChapel, gallery: paintGallery, - cellar: paintCellar, greenhouse: paintGreenhouse, diner: paintDiner, vault: paintVault, -} - -// Map a free-text location name (generated cases invent rooms) to the closest set. -// First match wins - keep the specific venues above the generic interiors. -const _ROOM_MAP: [RegExp, string][] = [ - [/alley|backstreet|side\s*street|passage(way)?\b/i, 'alley'], - [/\bbar\b|tavern|pub\b|saloon|speakeas|taproom|cantina|nightclub|club\s*floor/i, 'bar'], - [/casino|card\s*room|gambling|poker|roulette|betting/i, 'casino'], - [/theat|stage|auditorium|opera|cabaret|backstage|dressing\s*room|rehearsal/i, 'theater'], - [/warehouse|store\s*room|storage|loading|depot|freight|cargo/i, 'warehouse'], - [/rooftop|roof\b|widow'?s\s*walk/i, 'rooftop'], - [/office|bureau|headquarters|newsroom|press\s*room|precinct/i, 'office'], - [/lobby|reception|front\s*desk|concierge|check[-\s]?in/i, 'lobby'], - [/station|platform|train|railway|terminus|tram/i, 'station'], - [/garage|workshop|mechanic|motor|boathouse|shed|barn|stable/i, 'garage'], - [/chapel|church|cathedral|shrine|sanctuary|cloister|vestry/i, 'chapel'], - [/gallery|museum|exhibit|auction|studio|atelier/i, 'gallery'], - [/cellar|basement|wine\b|crypt|undercroft|catacomb|tunnel/i, 'cellar'], - [/vault|safe\s*room|strong\s*room|\bbank\b|deposit|counting\s*house/i, 'vault'], - [/greenhouse|conservatory|garden|orchard|arboretum|nursery\s*garden/i, 'greenhouse'], - [/diner|caf[eé]|coffee|canteen|tea\s*room|bistro|restaurant/i, 'diner'], - [/kitchen|pantry|galley|scullery|bakery/i, 'kitchen'], - [/librar|study|den\b|archive|records|reading\s*room/i, 'study'], - [/bed|chamber|boudoir|suite|nursery|dormitor/i, 'bedroom'], - [/mezzanine|\brail\b|balcon|landing|stairwell/i, 'mezzanine'], - [/dock|harbou?r|pier|seawall|wharf|seaside|waterfront|quay|marina|lighthouse/i, 'seawall'], - [/parlou?r|lounge|living|sitting|drawing|salon|terrace|veranda|smoking\s*room/i, 'parlor'], - [/hall|foyer|ballroom|atrium|dining|entrance|stair|court/i, 'atrium'], -] - -export function sceneForRoom(name: string): ScenePainter { - for (const [re, key] of _ROOM_MAP) if (re.test(name || '')) return SCENES[key] - return SCENES.parlor // generic interior -} - -// ---- exhibit illustrations ---- -// Each exhibit gets a procedural "evidence photo": the object, large, on a forensic -// table under a spot, with a measuring strip. The kind is read from the exhibit's -// own words so a letter looks like a letter and a vial looks like a vial. -export type ExhibitKind = - | 'letter' | 'photo' | 'ledger' | 'key' | 'blade' | 'bottle' | 'watch' | 'fabric' - | 'flame' | 'ticket' | 'jewel' | 'phone' | 'recorder' | 'print' | 'cash' | 'rope' - | 'boot' | 'bag' - -// ORDER MATTERS - specific kinds sit above broad word-matches so "letter opener" is a -// blade (not a letter), "matchbook" is a flame (not a book), "footprint" is a boot -// (not a fingerprint), and "recording" is a tape (not a ledger record). -const _EX_RULES: [RegExp, ExhibitKind][] = [ - [/photo|polaroid|portrait|snapshot|negative|film/i, 'photo'], - [/tape|recorder|recording|reel\b|voicemail|dictaphone|cylinder/i, 'recorder'], - [/knife|blade|dagger|letter\s*opener|razor|scissor|shard|glass\s+fragment/i, 'blade'], - [/vial|poison|bottle|flask|tonic|medicine|pill|powder|arsenic|cyanide|decanter|wine|whisky/i, 'bottle'], - [/watch|clock|timepiece/i, 'watch'], - [/match|lighter|accelerant|kerosene|petrol|gasoline|\bash(es)?\b|burn|scorch|cigar|cigarette/i, 'flame'], - [/footprint|boot|shoe|heel|track\b|tread/i, 'boot'], - [/fingerprint|\bprints?\b|smudge|handprint/i, 'print'], - [/keycard|access|badge|pass\b|\bkeys?\b|lockpick|latch/i, 'key'], - [/ring\b|necklace|brooch|jewel|diamond|pearl|locket|pendant|bracelet|gem/i, 'jewel'], - [/phone|telephone|pager|wire\s*tap/i, 'phone'], - [/ticket|stub|pawn|receipt|invoice|bill\b|cheque|check\b|voucher|claim/i, 'ticket'], - [/cash|money|bills|banknote|currency|wad\b|payment|bribe|coin|wallet/i, 'cash'], - [/rope|cord|wire\b|cable|twine|strap|belt/i, 'rope'], - [/glove|scarf|fabric|fibre|fiber|cloth|thread|handkerchief|button|cufflink|coat|shawl|silk/i, 'fabric'], - [/ledger|\bbook\b|journal|diary|register|contract|deed|will\b|manifest|statement|\brecords\b/i, 'ledger'], - [/letter|note\b|envelope|telegram|correspondence|memo|page|paper/i, 'letter'], -] - -export function exhibitKindFor(name: string, summary = ''): ExhibitKind { - const hay = `${name} ${summary}` - for (const [re, kind] of _EX_RULES) if (re.test(hay)) return kind - return 'bag' -} - -function exBase(ctx: CanvasRenderingContext2D, w: number, h: number): void { - ditherGrad(ctx, 0, 0, w, h, '#141a21', '#0a0e13') - lampCone(ctx, Math.floor(w / 2), 0, Math.floor(w * 0.9), h) - // forensic measuring strip - ctx.fillStyle = C.bone; ctx.fillRect(6, h - 7, Math.floor(w * 0.4), 3) - ctx.fillStyle = '#0a0e13' - for (let x = 8; x < 6 + Math.floor(w * 0.4); x += 5) ctx.fillRect(x, h - 7, 1, 3) - // evidence tag in the corner - ctx.fillStyle = C.amberD; ctx.fillRect(w - 20, h - 12, 14, 8) - ctx.fillStyle = '#3a2a10'; ctx.fillRect(w - 18, h - 9, 10, 1); ctx.fillRect(w - 18, h - 7, 7, 1) -} - -type ExPainter = (ctx: CanvasRenderingContext2D, w: number, h: number, s: number) => void - -const _EX_PAINT: Record = { - letter: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 22 - const cy = Math.floor(h / 2) - 14 - // open envelope behind - ctx.fillStyle = '#b3ac96'; ctx.fillRect(cx - 6, cy + 12, 34, 18) - ctx.fillStyle = '#9d977f'; ctx.fillRect(cx - 6, cy + 12, 34, 2) - // folded letter, slightly rotated feel via offset slabs - ctx.fillStyle = C.bone; ctx.fillRect(cx + 8, cy - 6, 36, 26) - ctx.fillStyle = '#fdf9ec'; ctx.fillRect(cx + 8, cy - 6, 36, 3) - ctx.fillStyle = '#3a3428' - for (let i = 0; i < 5; i++) ctx.fillRect(cx + 12, cy + (i * 4), 24 - ((s + i) % 3) * 5, 1) - // wax seal or ink blot - ctx.fillStyle = s % 2 ? C.ox : '#1c222a'; ctx.fillRect(cx + 34, cy + 12, 5, 5) - }, - photo: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 20 - const cy = Math.floor(h / 2) - 17 - ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 44, 38) - ctx.fillStyle = '#10161d'; ctx.fillRect(cx + 4, cy + 4, 36, 24) - // two silhouettes caught in frame - ctx.fillStyle = '#222b35' - ctx.fillRect(cx + 10 + (s % 4), cy + 12, 7, 16); ctx.fillRect(cx + 12 + (s % 4), cy + 8, 4, 5) - ctx.fillRect(cx + 24, cy + 14, 7, 14); ctx.fillRect(cx + 26, cy + 10, 4, 5) - ctx.fillStyle = C.winDim; ctx.fillRect(cx + 34, cy + 6, 3, 3) - // bent corner - ctx.fillStyle = '#9d977f'; ctx.fillRect(cx + 40, cy + 34, 4, 4) - }, - ledger: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 26 - const cy = Math.floor(h / 2) - 14 - ctx.fillStyle = '#241a10'; ctx.fillRect(cx - 2, cy - 2, 56, 32) - ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 26, 28); ctx.fillRect(cx + 27, cy, 25, 28) - ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 26, cy, 1, 28) - ctx.fillStyle = '#3a3428' - for (let i = 0; i < 6; i++) { ctx.fillRect(cx + 3, cy + 4 + i * 4, 20, 1); ctx.fillRect(cx + 30, cy + 4 + i * 4, 19, 1) } - // the flagged entry - ctx.fillStyle = C.ox; ctx.fillRect(cx + 30, cy + 4 + ((s % 4) + 1) * 4 - 1, 19, 3) - }, - key: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 18 - const cy = Math.floor(h / 2) - 2 - if (s % 2) { - // hotel key with fob - ctx.fillStyle = '#b8b7ad'; ctx.fillRect(cx, cy - 3, 26, 4) - ctx.fillRect(cx + 24, cy + 1, 3, 6); ctx.fillRect(cx + 18, cy + 1, 3, 4) - ctx.fillStyle = '#8d8c82'; ctx.fillRect(cx - 8, cy - 7, 10, 10) - ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - 5, cy - 4, 4, 4) - ctx.fillStyle = C.ox; ctx.fillRect(cx + 32, cy - 8, 14, 9) - ctx.fillStyle = C.bone; ctx.fillRect(cx + 35, cy - 5, 8, 3) - } else { - // access card - ctx.fillStyle = '#2d4a52'; ctx.fillRect(cx - 4, cy - 12, 42, 26) - ctx.fillStyle = '#3a6b6b'; ctx.fillRect(cx - 4, cy - 12, 42, 5) - ctx.fillStyle = C.amber; ctx.fillRect(cx, cy - 2, 10, 8) - ctx.fillStyle = C.bone; ctx.fillRect(cx + 14, cy, 18, 2); ctx.fillRect(cx + 14, cy + 4, 12, 2) - } - }, - blade: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 24 - const cy = Math.floor(h / 2) - // blade with taper - ctx.fillStyle = '#c2c8cc' - for (let i = 0; i < 30; i++) ctx.fillRect(cx + i, cy - 3 + (i >> 4), 1, 5 - (i >> 3)) - ctx.fillStyle = '#8a9298'; ctx.fillRect(cx, cy + 1, 28, 1) - // guard + grip - ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 30, cy - 5, 3, 9) - ctx.fillStyle = '#55402a'; ctx.fillRect(cx + 33, cy - 3, 14, 6) - ctx.fillStyle = '#241a10'; for (let i = 0; i < 3; i++) ctx.fillRect(cx + 35 + i * 4, cy - 3, 1, 6) - // a dark stain near the tip when the story calls for it - if (s % 3 !== 1) { ctx.fillStyle = '#4a1414'; ctx.fillRect(cx + 4, cy - 1, 6, 3) } - }, - bottle: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 6 - const cy = Math.floor(h / 2) - 16 - const tint = s % 2 ? '#1d4a2c' : '#2d3a5e' - ctx.fillStyle = tint; ctx.fillRect(cx, cy + 10, 14, 22) - ctx.fillRect(cx + 4, cy + 2, 6, 9) - ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 4, cy - 2, 6, 4) - // liquid line + skull label - ctx.fillStyle = s % 2 ? '#2c5e38' : '#46506b'; ctx.fillRect(cx + 2, cy + 18, 10, 12) - ctx.fillStyle = C.bone; ctx.fillRect(cx + 3, cy + 14, 8, 8) - ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 5, cy + 16, 4, 3); ctx.fillRect(cx + 5, cy + 20, 1, 1); ctx.fillRect(cx + 8, cy + 20, 1, 1) - // spilled drops - ctx.fillStyle = tint; ctx.fillRect(cx + 18, cy + 30, 4, 2); ctx.fillRect(cx + 24, cy + 32, 2, 1) - }, - watch: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 2 - const cy = Math.floor(h / 2) - 2 - // chain - ctx.fillStyle = '#8d8c82' - for (let i = 0; i < 7; i++) ctx.fillRect(cx + 12 + i * 3, cy - 14 + i * 2, 2, 2) - // case rings - for (let r = 14; r > 2; r -= 3) { ctx.fillStyle = r % 6 ? '#b8943e' : '#e3c06d'; ctx.fillRect(cx - r, cy - r, r * 2, r * 2) } - ctx.fillStyle = C.bone; ctx.fillRect(cx - 8, cy - 8, 16, 16) - // hands stopped at the hour of the crime - ctx.fillStyle = '#0a0e13' - ctx.fillRect(cx - 1, cy - 7 + (s % 3), 2, 8); ctx.fillRect(cx - 1, cy, 6 - (s % 3), 2) - // cracked glass - ctx.fillStyle = '#8a9298'; ctx.fillRect(cx + 2, cy - 6, 1, 5); ctx.fillRect(cx + 3, cy - 3, 3, 1) - }, - fabric: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 20 - const cy = Math.floor(h / 2) - 10 - const tint = ['#5e1c1c', '#284149', '#46506b'][s % 3] - // a torn swatch, ragged edges - ctx.fillStyle = tint - for (let y = 0; y < 22; y++) { - const ragL = (y * 7 + s) % 4 - const ragR = (y * 5 + s) % 5 - ctx.fillRect(cx + ragL, cy + y, 38 - ragL - ragR, 1) - } - // weave lines + a loose thread - ctx.fillStyle = '#00000033' - for (let y = 2; y < 20; y += 3) ctx.fillRect(cx + 3, cy + y, 32, 1) - ctx.fillStyle = tint - ctx.fillRect(cx + 38, cy + 22, 2, 2); ctx.fillRect(cx + 41, cy + 25, 2, 1); ctx.fillRect(cx + 44, cy + 26, 3, 1) - // monogram patch - ctx.fillStyle = C.amber; ctx.fillRect(cx + 14, cy + 8, 8, 6) - ctx.fillStyle = '#3a2a10'; ctx.fillRect(cx + 16, cy + 10, 4, 2) - }, - flame: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 16 - const cy = Math.floor(h / 2) - 8 - if (s % 2) { - // matchbook, one match torn out - ctx.fillStyle = C.ox; ctx.fillRect(cx, cy, 26, 18) - ctx.fillStyle = '#b8443f'; ctx.fillRect(cx, cy, 26, 4) - ctx.fillStyle = C.bone; ctx.fillRect(cx + 4, cy + 7, 18, 7) - ctx.fillStyle = '#3a2c1c'; for (let i = 0; i < 5; i++) ctx.fillRect(cx + 5 + i * 4, cy + 8, 2, 5) - ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 32, cy + 6, 2, 10); ctx.fillStyle = C.amber; ctx.fillRect(cx + 31, cy + 3, 4, 4) - } else { - // scorched tin of accelerant - ctx.fillStyle = '#8a4a1e'; ctx.fillRect(cx + 2, cy - 4, 20, 26) - ctx.fillStyle = '#a8602a'; ctx.fillRect(cx + 2, cy - 4, 20, 3) - ctx.fillStyle = '#241208'; ctx.fillRect(cx + 6, cy + 2, 12, 12) - ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - 4, cy + 20, 34, 4) - ctx.fillStyle = '#1c1209'; ctx.fillRect(cx + 24, cy + 16, 10, 6) - } - }, - ticket: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 24 - const cy = Math.floor(h / 2) - 9 - ctx.fillStyle = '#cfc0a0'; ctx.fillRect(cx, cy, 48, 20) - ctx.fillStyle = '#b3a585'; ctx.fillRect(cx, cy, 48, 3) - // perforation + torn half - ctx.fillStyle = '#0a0e13'; for (let y = cy; y < cy + 20; y += 3) ctx.fillRect(cx + 33, y, 1, 2) - ctx.fillStyle = '#3a3428' - ctx.fillRect(cx + 4, cy + 7, 24, 2); ctx.fillRect(cx + 4, cy + 12, 16 + (s % 3) * 3, 2) - ctx.fillStyle = C.ox; ctx.fillRect(cx + 37, cy + 5, 8, 8) - }, - jewel: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - const cy = Math.floor(h / 2) - 4 - // necklace chain pooled on the table - ctx.fillStyle = '#e3c06d' - for (let i = 0; i < 14; i++) ctx.fillRect(cx - 20 + i * 3, cy + 10 + Math.floor(Math.sin(i * 0.9 + s) * 3), 2, 2) - // the stone - const gem = ['#7ec8d8', '#c86a7a', '#8ad89a'][s % 3] - ctx.fillStyle = '#b8943e'; ctx.fillRect(cx - 6, cy - 8, 12, 12) - ctx.fillStyle = gem; ctx.fillRect(cx - 4, cy - 6, 8, 8) - ctx.fillStyle = '#ffffff'; ctx.fillRect(cx - 3, cy - 5, 2, 2) - // glints - ctx.fillStyle = C.lamp; ctx.fillRect(cx + 8, cy - 12, 1, 3); ctx.fillRect(cx + 7, cy - 11, 3, 1) - }, - phone: (ctx, w, h) => { - const cx = Math.floor(w / 2) - 14 - const cy = Math.floor(h / 2) - 12 - // candlestick telephone - bright enough to read against the dark table - ctx.fillStyle = '#3a444e'; ctx.fillRect(cx + 6, cy + 22, 20, 5); ctx.fillRect(cx + 12, cy + 6, 8, 17) - ctx.fillStyle = '#5a6470'; ctx.fillRect(cx + 13, cy + 6, 2, 17); ctx.fillRect(cx + 7, cy + 22, 18, 2) - // mouthpiece horn - ctx.fillStyle = '#3a444e'; ctx.fillRect(cx + 6, cy - 2, 20, 8) - ctx.fillStyle = '#5a6470'; ctx.fillRect(cx + 8, cy - 1, 16, 2) - ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 12, cy + 1, 8, 4) - // rotary dial face - ctx.fillStyle = C.bone; ctx.fillRect(cx + 12, cy + 14, 8, 6) - ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 15, cy + 16, 2, 2) - // earpiece on its side hook + coiled cord - ctx.fillStyle = '#3a444e'; ctx.fillRect(cx - 6, cy + 2, 8, 5); ctx.fillRect(cx - 4, cy + 7, 5, 10) - ctx.fillStyle = '#5a6470'; ctx.fillRect(cx - 5, cy + 3, 6, 2) - ctx.fillStyle = '#8a9298' - for (let i = 0; i < 6; i++) ctx.fillRect(cx - 2 + ((i % 2) * 3), cy + 18 + i * 2, 2, 2) - }, - recorder: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 22 - const cy = Math.floor(h / 2) - 12 - ctx.fillStyle = '#2a2f39'; ctx.fillRect(cx, cy, 44, 26) - ctx.fillStyle = '#3a414e'; ctx.fillRect(cx, cy, 44, 4) - // twin reels - for (const rx of [cx + 11, cx + 31]) { - ctx.fillStyle = '#0a0e13'; ctx.fillRect(rx - 7, cy + 8, 14, 14) - ctx.fillStyle = '#43301f'; ctx.fillRect(rx - 5, cy + 10, 10, 10) - ctx.fillStyle = C.bone; ctx.fillRect(rx - 1, cy + 14, 2, 2) - } - // tape strand + rec light - ctx.fillStyle = '#43301f'; ctx.fillRect(cx + 13, cy + 9, 18, 1) - ctx.fillStyle = s % 2 ? C.ox : '#4a1414'; ctx.fillRect(cx + 40, cy + 2, 2, 2) - }, - print: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 18 - const cy = Math.floor(h / 2) - 14 - // print card - ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 28, 30) - ctx.fillStyle = '#0a0e13' - // whorl - for (let r = 9; r > 1; r -= 2) ctx.fillRect(cx + 14 - r, cy + 14 - r + (s % 2), r * 2, 1) - for (let r = 8; r > 1; r -= 2) ctx.fillRect(cx + 14 - r, cy + 14 + r, r * 2, 1) - ctx.fillRect(cx + 6, cy + 10, 1, 9); ctx.fillRect(cx + 22, cy + 9, 1, 9) - // magnifier over the corner - ctx.fillStyle = '#8d8c82'; ctx.fillRect(cx + 22, cy + 18, 14, 2) - for (let r = 8; r > 5; r--) { ctx.fillStyle = '#b8b7ad'; ctx.fillRect(cx + 30 - r, cy + 12 - r, r * 2, r * 2) } - ctx.fillStyle = '#1d2832aa'; ctx.fillRect(cx + 25, cy + 7, 10, 10) - }, - cash: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 22 - const cy = Math.floor(h / 2) - 8 - // stacked banded bills - for (let i = 0; i < 3; i++) { - const y = cy + 12 - i * 6 - ctx.fillStyle = i % 2 ? '#1d5a2c' : '#14401f'; ctx.fillRect(cx + i * 3, y, 36, 6) - ctx.fillStyle = '#2c5e38'; ctx.fillRect(cx + i * 3, y, 36, 1) - ctx.fillStyle = C.bone; ctx.fillRect(cx + i * 3 + 14, y, 8, 6) - } - // loose bill + coins - ctx.fillStyle = '#1d5a2c'; ctx.fillRect(cx + 40, cy + 14, 14, 7) - ctx.fillStyle = C.bone; ctx.fillRect(cx + 45, cy + 16, 4, 3) - ctx.fillStyle = '#e3c06d'; ctx.fillRect(cx - 8, cy + 18, 4, 4); ctx.fillRect(cx - 12, cy + 20 + (s % 2), 4, 4) - }, - rope: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - const cy = Math.floor(h / 2) + 2 - // coiled loops - for (let r = 16; r > 4; r -= 4) { - ctx.fillStyle = r % 8 ? '#8a6a3a' : '#a8854a' - ctx.fillRect(cx - r, cy - Math.floor(r / 2), r * 2, Math.floor(r / 1.2)) - ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - r + 3, cy - Math.floor(r / 2) + 3, (r - 3) * 2, Math.floor(r / 1.4) - 4) - } - ctx.fillStyle = '#8a6a3a'; ctx.fillRect(cx - 4, cy - 2, 8, 6) - // frayed end trailing off - ctx.fillStyle = '#a8854a' - for (let i = 0; i < 10; i++) ctx.fillRect(cx + 14 + i * 2, cy + 8 + ((i + s) % 3), 2, 2) - ctx.fillStyle = '#6b4f2a'; ctx.fillRect(cx + 33, cy + 8, 1, 4); ctx.fillRect(cx + 35, cy + 9, 1, 3) - }, - boot: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 12 - const cy = Math.floor(h / 2) - 14 - // plaster cast slab - ctx.fillStyle = '#3a3022'; ctx.fillRect(cx - 8, cy - 2, 42, 34) - ctx.fillStyle = '#4a3e2c'; ctx.fillRect(cx - 8, cy - 2, 42, 3) - // boot tread pressed in - ctx.fillStyle = '#241c12' - ctx.fillRect(cx + 4, cy + 2, 16, 18); ctx.fillRect(cx + 6, cy + 22, 12, 6) - ctx.fillStyle = '#15100a' - for (let i = 0; i < 4; i++) ctx.fillRect(cx + 6, cy + 4 + i * 4, 12, 2) - ctx.fillRect(cx + 7, cy + 23, 10, 2) - // the worn-heel notch that gives it away - ctx.fillStyle = '#4a3e2c'; ctx.fillRect(cx + 6 + (s % 3) * 3, cy + 24, 4, 3) - }, - bag: (ctx, w, h, s) => { - const cx = Math.floor(w / 2) - 18 - const cy = Math.floor(h / 2) - 12 - // sealed evidence bag with a small dark object inside - ctx.fillStyle = '#c8d0d4'; ctx.fillRect(cx, cy, 36, 28) - ctx.fillStyle = '#e2e8ea'; ctx.fillRect(cx, cy, 36, 5) - ctx.fillStyle = C.ox; ctx.fillRect(cx, cy + 4, 36, 2) - ctx.fillStyle = '#2a2f39'; ctx.fillRect(cx + 10 + (s % 3) * 2, cy + 12, 14, 9) - ctx.fillStyle = '#15181d'; ctx.fillRect(cx + 12 + (s % 3) * 2, cy + 14, 10, 5) - ctx.fillStyle = '#8a9298'; ctx.fillRect(cx + 4, cy + 24, 28, 1) - }, -} - -/** Procedural painter for one exhibit, seeded by its id so each looks distinct. */ -export function exhibitPainter(name: string, summary: string, seedKey: string): ScenePainter { - const kind = exhibitKindFor(name, summary) - const s = _hash(seedKey || name) - return (ctx, w, h) => { - exBase(ctx, w, h) - _EX_PAINT[kind](ctx, w, h, s) - } -} - -export function sceneFor(name: string): ScenePainter { - if (SCENES[name]) return SCENES[name] - // "Building — Room" -> key the painter off the room (the part after the last dash). - const room = /[—–]/.test(name) ? name.split(/[—–]/).pop()!.trim() : name - return sceneForRoom(room) -} +// Pixel art — cohesive sprite system, one density throughout. Ported from art.jsx. +// All visuals are procedural canvas, rendered client-side, so the server spends ~0 CPU on +// art and the game stays fully local — no image models, no external services. +import { BAYER4, ditherGrad } from './draw' +import type { Pal } from './draw' +import type { ScenePainter } from './pixel' + +type Grid = string[][] +interface Sprite { + pal: Pal + frames: Grid[] + px: number +} +interface PortraitOpts { + skin: string + skinS: string + hair: string + hairHl: string + style?: string + cloth: string + clothHl: string + accent: string + lip?: string + tie?: string + beard?: string + stubble?: boolean + glasses?: string + hat?: string + hatBrim?: string + hatHl?: string + skinHi?: string + legs?: string +} + +export const SPAL: Record = { + k: '#080c11', K: '#04070b', + '1': '#e7b48d', '2': '#c4895f', '3': '#945d40', + '4': '#ead0b8', '5': '#c19b80', + '6': '#a76b44', '7': '#7c4d2e', + '8': '#74492e', '9': '#543521', + p: '#a8554c', + h: '#16191f', H: '#2a2f39', r: '#43301f', R: '#65492b', y: '#8d8c82', Y: '#b8b7ad', + u: '#6d3920', U: '#92542f', b: '#c19341', B: '#e3c06d', w0: '#3a3a40', + e: '#0e1119', o: '#ded7c3', + t: '#284149', T: '#37636b', m: '#1c222a', M: '#2d3640', x: '#5f1f1f', X: '#87292a', + w: '#b7b09a', W: '#d4cdb7', n: '#b3742d', N: '#dba04a', i: '#cfc8b2', I: '#9d977f', + g: '#39474f', G: '#536a72', c: '#d2d8dc', +} + +function blank(W: number, H: number): Grid { + return Array.from({ length: H }, () => Array(W).fill('.')) +} +function setpx(g: Grid, x: number, y: number, c: string): void { + if (y >= 0 && y < g.length && x >= 0 && x < g[0].length) g[y][x] = c +} +function fillrect(g: Grid, x: number, y: number, w: number, h: number, c: string): void { + for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) setpx(g, x + i, y + j, c) +} + +const HEAD: Record = { + 4: [7, 14], 5: [6, 15], 6: [5, 16], 7: [5, 16], 8: [5, 16], 9: [5, 16], 10: [5, 16], + 11: [5, 16], 12: [5, 16], 13: [6, 15], 14: [6, 15], 15: [6, 15], 16: [7, 14], 17: [8, 13], +} + +export function makePortrait(o: PortraitOpts): Sprite { + const sk = o.skin + const skS = o.skinS + const build = (blink: boolean, talk = false): Grid => { + const g = blank(22, 24) + fillrect(g, 9, 17, 4, 3, sk) + fillrect(g, 9, 18, 4, 1, skS) + fillrect(g, 2, 20, 18, 4, o.cloth) + for (let x = 2; x < 20; x++) setpx(g, x, 20, o.clothHl) + setpx(g, 9, 20, o.accent) + setpx(g, 12, 20, o.accent) + setpx(g, 10, 21, o.accent) + setpx(g, 11, 21, o.accent) + if (o.tie) { + setpx(g, 10, 21, o.tie); setpx(g, 11, 21, o.tie); setpx(g, 10, 22, o.tie); setpx(g, 11, 22, o.tie) + } + for (const y in HEAD) { + const [a, bx] = HEAD[+y] + for (let x = a; x <= bx; x++) setpx(g, x, +y, sk) + } + for (const y in HEAD) { + const [, bx] = HEAD[+y] + setpx(g, bx, +y, skS); setpx(g, bx - 1, +y, skS) + } + setpx(g, 7, 16, skS); setpx(g, 13, 16, skS) + if (o.beard) { + for (let x = 6; x <= 15; x++) setpx(g, x, 15, o.beard) + fillrect(g, 7, 16, 7, 2, o.beard) + setpx(g, 9, 15, sk); setpx(g, 12, 15, sk) + } + if (o.stubble) { + for (let x = 6; x <= 15; x++) if ((x + 1) % 2) setpx(g, x, 16, skS) + } + fillrect(g, 7, 9, 3, 1, o.hair) + fillrect(g, 12, 9, 3, 1, o.hair) + if (blink) { + setpx(g, 8, 11, skS); setpx(g, 9, 11, skS); setpx(g, 13, 11, skS); setpx(g, 14, 11, skS) + } else { + setpx(g, 8, 11, 'o'); setpx(g, 9, 11, 'e'); setpx(g, 13, 11, 'e'); setpx(g, 14, 11, 'o') + setpx(g, 8, 10, skS); setpx(g, 14, 10, skS) + } + setpx(g, 11, 12, skS); setpx(g, 11, 13, skS); setpx(g, 10, 13, skS) + fillrect(g, 9, 15, 4, 1, o.lip || 'p') + if (o.beard) fillrect(g, 9, 15, 4, 1, '#7a3f3a') + // Talking frame: the jaw drops into a small dark opening (synced to the voice). + if (talk) { setpx(g, 10, 15, 'k'); setpx(g, 11, 15, 'k'); setpx(g, 10, 16, 'k'); setpx(g, 11, 16, 'k') } + setpx(g, 7, 12, o.skinHi || sk) + const H = o.hair + const Hl = o.hairHl + const top = () => { + fillrect(g, 5, 3, 12, 3, H) + for (let x = 6; x <= 15; x++) setpx(g, x, 3, Hl) + } + const sides = (to: number) => { + for (let y = 4; y <= to; y++) { + setpx(g, 5, y, H); setpx(g, 16, y, H) + } + } + if (o.style === 'short') { + top(); fillrect(g, 5, 5, 12, 2, H); sides(8); setpx(g, 5, 4, H); setpx(g, 16, 4, H) + } else if (o.style === 'slick') { + fillrect(g, 6, 3, 10, 3, H); for (let x = 6; x < 13; x++) setpx(g, x, 3, Hl); sides(7) + } else if (o.style === 'long') { + top(); fillrect(g, 5, 5, 12, 2, H); sides(17); fillrect(g, 4, 8, 2, 9, H); fillrect(g, 16, 8, 2, 9, H) + } else if (o.style === 'bun') { + top(); fillrect(g, 5, 5, 12, 1, H); sides(7); fillrect(g, 8, 1, 6, 3, H); for (let x = 9; x < 13; x++) setpx(g, x, 1, Hl) + } else if (o.style === 'curly') { + for (let x = 5; x <= 16; x++) { const yy = 3 + (x % 2); setpx(g, x, yy, H); setpx(g, x, 3, H) } + fillrect(g, 5, 4, 12, 3, H); for (let x = 6; x < 16; x += 2) setpx(g, x, 3, Hl); sides(9); setpx(g, 4, 6, H); setpx(g, 17, 6, H) + } else if (o.style === 'bald') { + for (let y = 7; y <= 10; y++) { setpx(g, 5, y, H); setpx(g, 16, y, H) } + setpx(g, 6, 12, skS) + } else if (o.style === 'wave') { + top(); fillrect(g, 5, 5, 12, 2, H); for (let x = 6; x < 16; x += 3) setpx(g, x, 5, Hl); sides(8) + } + if (o.glasses) { + const gl = o.glasses + setpx(g, 7, 10, gl); setpx(g, 9, 10, gl); setpx(g, 7, 11, gl); setpx(g, 7, 12, gl); setpx(g, 9, 11, gl); setpx(g, 9, 12, gl); setpx(g, 8, 12, gl) + setpx(g, 12, 10, gl); setpx(g, 14, 10, gl); setpx(g, 14, 11, gl); setpx(g, 14, 12, gl); setpx(g, 12, 11, gl); setpx(g, 12, 12, gl); setpx(g, 13, 12, gl) + setpx(g, 10, 11, gl); setpx(g, 11, 11, gl) + if (!blink) { setpx(g, 8, 11, 'o'); setpx(g, 13, 11, 'o') } + } + if (o.hat) { + fillrect(g, 4, 2, 14, 2, o.hat) + for (let x = 4; x < 18; x++) setpx(g, x, 4, o.hatBrim || o.hat) + fillrect(g, 3, 4, 16, 1, o.hatBrim || o.hat) + for (let x = 5; x < 17; x++) setpx(g, x, 2, o.hatHl || o.hat) + } + for (const y in HEAD) { + const [a] = HEAD[+y] + setpx(g, a - 1, +y, 'k') + } + // Keep as a 2D grid of cell strings (cells are full hex/key values, NOT single + // chars), so multi-character colors survive. Joining rows would shred them. + return g + } + // frame 0: neutral · frame 1: blink · frame 2: mouth open (talking). + return { pal: SPAL, frames: [build(false), build(true), build(false, true)], px: 6 } +} + +function shade(c: string): string { + const m: Record = { + [SPAL.t]: '#1d3138', [SPAL.m]: '#141a20', [SPAL.x]: '#481818', + [SPAL.w]: '#9a937e', [SPAL.n]: '#8a5a22', [SPAL.g]: '#2a363d', + } + return m[c] || '#0c0f14' +} + +export function makeBody(o: PortraitOpts): Sprite { + const sk = o.skin + const skS = o.skinS + const build = (dy: number): Grid => { + const g = blank(24, 44) + fillrect(g, 7, 33, 4, 11, o.legs || o.cloth) + fillrect(g, 13, 33, 4, 11, o.legs || o.cloth) + setpx(g, 7, 33, 'k'); setpx(g, 16, 33, 'k') + fillrect(g, 6, 42, 5, 2, '#15181d'); fillrect(g, 13, 42, 5, 2, '#15181d') + const ty = 14 + dy + fillrect(g, 5, ty, 14, 20, o.cloth) + for (let x = 5; x < 19; x++) setpx(g, x, ty, o.clothHl) + for (let y = ty; y < ty + 20; y++) { + setpx(g, 17, y, shade(o.cloth)); setpx(g, 18, y, shade(o.cloth)) + } + setpx(g, 11, ty, o.accent); setpx(g, 12, ty, o.accent) + fillrect(g, 11, ty, 2, 8, o.tie || o.accent) + fillrect(g, 3, ty, 2, 16, shade(o.cloth)); fillrect(g, 19, ty, 2, 16, shade(o.cloth)) + fillrect(g, 3, ty + 16, 3, 2, sk); fillrect(g, 18, ty + 16, 3, 2, sk) + fillrect(g, 10, ty - 2, 4, 3, sk); setpx(g, 13, ty - 1, skS) + const hx = 7 + const hy = ty - 15 + fillrect(g, hx + 1, hy + 1, 9, 11, sk) + for (let y = hy + 1; y < hy + 12; y++) setpx(g, hx + 9, y, skS) + fillrect(g, hx, hy, 11, 3, o.hair) + for (let x = hx + 1; x < hx + 10; x++) setpx(g, x, hy, o.hairHl) + if (o.style === 'long') { + fillrect(g, hx, hy + 2, 2, 9, o.hair); fillrect(g, hx + 9, hy + 2, 2, 9, o.hair) + } else { + setpx(g, hx, hy + 3, o.hair); setpx(g, hx + 10, hy + 3, o.hair) + } + if (o.hat) { + fillrect(g, hx - 1, hy - 1, 13, 2, o.hat); fillrect(g, hx - 2, hy + 1, 15, 1, o.hatBrim || o.hat) + } + setpx(g, hx + 3, hy + 5, 'e'); setpx(g, hx + 7, hy + 5, 'e') + if (o.glasses) { + setpx(g, hx + 2, hy + 5, o.glasses); setpx(g, hx + 4, hy + 5, o.glasses); setpx(g, hx + 6, hy + 5, o.glasses); setpx(g, hx + 8, hy + 5, o.glasses) + } + setpx(g, hx + 5, hy + 7, skS) + fillrect(g, hx + 4, hy + 9, 3, 1, o.lip || 'p') + if (o.beard) { + fillrect(g, hx + 2, hy + 9, 7, 3, o.beard); fillrect(g, hx + 4, hy + 9, 3, 1, o.lip || 'p') + } + // 2D grid of cell strings (cells are full hex/key values) - joining rows would shred them. + return g + } + return { pal: SPAL, frames: [build(0), build(1)], px: 6 } +} + +export const PORTRAITS: Record = { + victim: makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.h, hairHl: SPAL.H, style: 'bun', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.n, lip: SPAL.p }), + wexler: makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'slick', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.i, tie: SPAL.x, beard: '#7d7c72', stubble: true }), + iris: makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p }), + teo: makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, stubble: true }), + frost: makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'wave', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, glasses: SPAL.g, lip: SPAL.p }), + detective: makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.n, hat: '#23292f', hatBrim: '#171c21', hatHl: '#2d353d', stubble: true }), +} + +export const BODIES: Record = { + wexler: makeBody({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'slick', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.i, tie: SPAL.x, beard: '#7d7c72' }), + iris: makeBody({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p }), + teo: makeBody({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, legs: SPAL.m }), + frost: makeBody({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.y, hairHl: SPAL.Y, style: 'wave', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, glasses: SPAL.g, lip: SPAL.p }), +} + +// Gender-matched fallback casts for generated suspects (named portraits are golden-only). +// A female suspect always draws a female portrait/body; a male suspect a male one. +const _M_PORTRAITS: Sprite[] = [ + PORTRAITS.wexler, PORTRAITS.teo, + makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'curly', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i }), + makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'bald', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, beard: '#5a4030' }), + makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.y, hairHl: SPAL.Y, style: 'short', cloth: SPAL.m, clothHl: SPAL.M, accent: SPAL.n, stubble: true }), +] +const _F_PORTRAITS: Sprite[] = [ + PORTRAITS.iris, PORTRAITS.frost, + makePortrait({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'bun', cloth: SPAL.x, clothHl: SPAL.X, accent: SPAL.i, lip: SPAL.p }), + makePortrait({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'curly', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.n, lip: SPAL.p }), + makePortrait({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'wave', cloth: SPAL.w, clothHl: SPAL.W, accent: SPAL.t, lip: SPAL.p }), +] +const _M_BODIES: Sprite[] = [ + BODIES.wexler, BODIES.teo, + makeBody({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'short', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.i, legs: SPAL.m }), + makeBody({ skin: SPAL['1'], skinS: SPAL['2'], hair: SPAL.r, hairHl: SPAL.R, style: 'short', cloth: SPAL.g, clothHl: SPAL.G, accent: SPAL.M, legs: SPAL.m, beard: '#5a4030' }), +] +const _F_BODIES: Sprite[] = [ + BODIES.iris, BODIES.frost, + makeBody({ skin: SPAL['6'], skinS: SPAL['7'], hair: SPAL.h, hairHl: SPAL.H, style: 'long', cloth: SPAL.x, clothHl: SPAL.X, accent: SPAL.i, lip: SPAL.p }), + makeBody({ skin: SPAL['4'], skinS: SPAL['5'], hair: SPAL.u, hairHl: SPAL.U, style: 'long', cloth: SPAL.t, clothHl: SPAL.T, accent: SPAL.n, lip: SPAL.p }), +] +function _hash(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0 + return h +} +function _isFemale(gender?: string): boolean { + return (gender || '').toLowerCase().startsWith('f') +} +export function portraitFor(id: string, gender?: string): Sprite { + if (PORTRAITS[id]) return PORTRAITS[id] + const pool = _isFemale(gender) ? _F_PORTRAITS : _M_PORTRAITS + return pool[_hash(id) % pool.length] +} +export function bodyFor(id: string, gender?: string): Sprite { + if (BODIES[id]) return BODIES[id] + const pool = _isFemale(gender) ? _F_BODIES : _M_BODIES + return pool[_hash(id) % pool.length] +} + +export const IPAL: Record = { + k: '#080c11', d: '#1c222a', g: '#37636b', G: '#5d8a8a', a: '#e0a44c', A: '#f5d08a', + x: '#87292a', w: '#d4cdb7', W: '#f5f1e6', s: '#2d4a52', b: '#3a6b6b', e: '#0e1119', m: '#9d977f', +} +export const EV_ICONS: Record = { + phone: ['..kkkkkkkk..', '..kddddddk..', '..kdwwwwdk..', '..kdwGGwdk..', '..kdwWWwdk..', '..kdwwwwdk..', '..kdaaaadk..', '..kdwwwwdk..', '..kdGGGGdk..', '..kdwwwwdk..', '..kdwwwwdk..', '..kddddddk..', '..kdd ddk..', '..kdaaaadk..', '..kddddddk..', '..kkkkkkkk..'], + receipt: ['...wwwwwww..', '..wWWWWWWw..', '..wkkkkkkw..', '..wWWWWWWw..', '..wkkk.kkw..', '..wWWWWWWw..', '..wkk.kkkw..', '..wWWWWWWw..', '..wkkkk..w..', '..wWWWWWWw..', '..wxxxxxxw..', '..wWWWWWWw..', '..wkk.kkkw..', '..wWWWWWWw..', '..wwvwvwvw..', '...wvwvwv...'], + cctv: ['.kkkkkk.....', '.kddddkk....', '.kdGGGdkk...', '.kdGAAGdkkk.', '.kdGAAGddek.', '.kdGGGdkek..', '.kdddddkk...', '..kkkkk.....', '....kk......', '...kddk.....', '..kddddk....', '..kdsssdk...', '..kdsbsdk...', '..kddddk....', '...kkkk.....', '............'], + voicemail: ['..kkkkkkkk..', '.kddddddddk.', '.kdwwwwwwdk.', '.kdwGbGbwdk.', '.kdwbGbGwdk.', '.kdwGbGbwdk.', '.kdwwwwwwdk.', '.kdaWaWaWdk.', '.kd.a.a.adk.', '.kdaWaWaWdk.', '.kdwwwwwwdk.', '.kddddddddk.', '..kkkkkkkk..', '...k....k...', '..kk....kk..', '............'], + keycard: ['............', '.kkkkkkkkkk.', '.kssssssssk.', '.ksbbbbbbsk.', '.ksbaaaabsk.', '.ksbaWWabsk.', '.ksbaaaabsk.', '.ksbbbbbbsk.', '.ksssssssk..', '.kswwwwsssk.', '.kswwwwsssk.', '.kssssssssk.', '.kkkkkkkkkk.', '......kk....', '.....kgggk..', '............'], + photoEv: ['.wwwwwwwwww.', '.wkkkkkkkkw.', '.wkdddddd kw', '.wkdaa..d.kw', '.wkd.aa..dkw', '.wkd..aa.dkw', '.wkd...aadkw', '.wkdGG..adkw', '.wkdddddddkw', '.wkdGGGGGdkw', '.wkddddddd kw', '.wkkkkkkkkw.', '.wwwwwwwwww.', '...ka.ak....', '..k.aa.k....', '............'], + compass: ['......a.....', '.....aAa....', '....a.a.a...', '...a..a..a..', '..a...a...a.', '.a....a....a', 'a....aAa... a', '.aaaaaAaaaaa', 'a....aAa....a', '.a...a.a...a.', '..a..a.a..a..', '...a.a.a.a...', '....aa.aa....', '.....aAa.....', '......a......', '............'], + fingerprint: ['............', '...kkkkkk...', '..kGGGGGGk..', '.kGkkkkkkGk.', '.kGkGGGGkGk.', '.kGkGkkGkGk.', '.kGkGkGkkGk.', '.kGkGkGkGGk.', '.kGkGkkGkGk.', '.kGkGGGGkGk.', '.kGkkkkkkGk.', '..kGGGGGGk..', '...kkkkkk...', '............', '............', '............'], + tumbler: ['............', '.kkkkkkkkkk.', '.kwWWWWWWwk.', '.kw......wk.', '.kw....xxwk.', '.kw...x..wk.', '.kw......wk.', '.kwAAAAAAwk.', '.kwaaaaaawk.', '.kwaaaaaawk.', '.kwaaaaaawk.', '.kwwwwwwwwk.', '.kkkkkkkkkk.', '............', '............', '............'], + thread: ['............', '..kkkkkkkk..', '.kmmmmmmmmk.', '..kkkkkkkk..', '..kGgGgGgk..', '..kgGgGgGk..', '..kGgGgGgk..', '..kgGgGgGk..', '..kGgGgGgk..', '..kkkkkkkk..', '.kmmmmmmmmk.', '..kkkkkkkk..', '.......G....', '......G.....', '.......G....', '............'], + envelope: ['............', '............', '.kkkkkkkkkk.', '.kWkWWWWkWk.', '.kWWkWWkWWk.', '.kWWWkkWWWk.', '.kWWWWWWWWk.', '.kWWWxxWWWk.', '.kWWWxxWWWk.', '.kWWWWWWWWk.', '.kkkkkkkkkk.', '............', '............', '............', '............', '............'], + bottle: ['....kkkk....', '....kmmk....', '....kwwk....', '....kwwk....', '...kkwwkk...', '..kwggggwk..', '.kwggggggwk.', '.kwgWWWWgwk.', '.kwgWxxWgwk.', '.kwgWWWWgwk.', '.kwggggggwk.', '.kwggggggwk.', '..kwggggwk..', '...kkkkkk...', '............', '............'], + knife: ['.....kk.....', '....kWWk....', '....kWwk....', '....kWwk....', '....kWwk....', '....kWwk....', '....kWwk....', '....kWwk....', '...kkkkkk...', '..kmmmmmmk..', '...kkddkk...', '....kddk....', '....kddk....', '....kddk....', '....kkkk....', '............'], + clock: ['.....kk.....', '....kaak....', '..kkkkkkkk..', '.kaWWWWWWak.', 'kaWWWkWWWWak', 'kaWWWkWWWWak', 'kaWWWkkkWWak', 'kaWWWWWWWWak', 'kaWWWWWWWWak', '.kaWWWWWWak.', '..kkkkkkkk..', '............', '............', '............', '............', '............'], + jewel: ['............', '.....kk.....', '....kGGk....', '...kGWWGk...', '...kGGGGk...', '....kkkk....', '...kaaaak...', '..kak..kak..', '..ka....ak..', '..kak..kak..', '...kaaaak...', '....kkkk....', '............', '............', '............', '............'], + cigarette: ['.......g....', '......g.....', '.......g....', '......g.....', '.......g....', '............', '.kkkkkkkkkk.', '.kmmWWWWaAk.', '.kmmWWWWaxk.', '.kkkkkkkkkk.', '............', '............', '............', '............', '............', '............'], + bootprint: ['............', '...kkkkk....', '..kdddddk...', '..kdkdkdk...', '..kdddddk...', '..kdkdkdk...', '..kdddddk...', '...kkkkk....', '............', '...kkkk.....', '..kddddk....', '..kdkkdk....', '..kddddk....', '...kkkk.....', '............', '............'], + book: ['............', '..kkkkkkkk..', '.kexxxxxxWk.', '.kexxxxxxWk.', '.kexaaaaxWk.', '.kexaaaaxWk.', '.kexxxxxxWk.', '.kexxxxxxWk.', '.kexxxxxxWk.', '.kexxxxxxWk.', '.kexxxxxxWk.', '..kkkkkkkk..', '............', '............', '............', '............'], + key: ['............', '...kkkk.....', '..ka..ak....', '..ka..ak....', '...kaak.....', '....kak.....', '....kak.....', '....kak.....', '....kak.....', '....kaak....', '....kak.....', '....kaak....', '....kkk.....', '............', '............', '............'], + flame: ['....kaak....', '...kaAAak...', '...kaAAak...', '...kaAAak...', '....kaak....', '.....xx.....', '.....mm.....', '.....mm.....', '.....mm.....', '.....mm.....', '.....mm.....', '.....mm.....', '.....kk.....', '............', '............', '............'], + pen: ['....kkkk....', '....kxxk....', '....kxxk....', '....kxxk....', '....kxxk....', '....kxxk....', '....kxxk....', '....kaak....', '....kWWk....', '....kWWk....', '.....kWk....', '.....kk.....', '............', '............', '............', '............'], + cash: ['............', '............', '.kkkkkkkkkk.', '.kgaggggggk.', '.kgagGGgggk.', '.kgagGGgggk.', '.kgaggggggk.', '.kkkkkkkkkk.', '..kggggggk..', '..kkkkkkkk..', '............', '............', '............', '............', '............', '............'], + magnifier: ['...kkkk.....', '..kGGGGk....', '.kGWGGGGk...', '.kGGGGGGk...', '.kGGGGGGk...', '..kGGGGk....', '...kkkkk....', '......kkk...', '.......kmk..', '........kmk.', '.........kk.', '............', '............', '............', '............', '............'], +} + +// ---- scene painters ---- +const C = { + sky0: '#0c1622', sky1: '#13202b', sky2: '#1b2d38', water: '#0e1a22', + bldg: '#0a1118', bldgL: '#13202b', win: '#e0a44c', winDim: '#7a5a2a', + amber: '#e0a44c', amberD: '#b9772f', bone: '#e0d9c4', ox: '#87292a', + slate: '#2d4a52', slateL: '#3a6b6b', shadow: '#070b0f', lamp: '#f5d08a', +} +function lampCone(ctx: CanvasRenderingContext2D, cx: number, cy: number, w: number, h: number): void { + // A soft pool of lamplight directly under the bulb - dense near the source, fading out + // quickly - NOT a hard full-height pyramid. Narrower + shorter + quadratic falloff. + const reach = Math.max(8, Math.floor(h * 0.5)) + for (let y = 0; y < reach; y++) { + const f = y / reach + const ww = Math.floor(w * 0.45 * f) + const t = 0.3 * (1 - f) * (1 - f) + for (let x = -ww; x < ww; x++) { + const thr = (BAYER4[(cy + y) & 3][(cx + x) & 3] + 0.5) / 16 + if (thr < t) { + ctx.fillStyle = C.amber + ctx.globalAlpha = 0.32 + ctx.fillRect(cx + x, cy + y, 1, 1) + } + } + } + ctx.globalAlpha = 1 +} +function rainStreaks(ctx: CanvasRenderingContext2D, w: number, h: number, t: number): void { + ctx.fillStyle = 'rgba(170,190,200,0.22)' + for (let i = 0; i < 40; i++) { + const x = (i * 37 + t * 4) % w + const y = (i * 53 + t * 7) % h + ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3) + } +} + +const paintSkyline: ScenePainter = (ctx, w, h) => { + const horizon = Math.floor(h * 0.5) + ditherGrad(ctx, 0, 0, w, horizon, C.sky2, C.sky1) + for (let y = horizon - 22; y < horizon; y++) { + const tt = (y - (horizon - 22)) / 22 + for (let x = 0; x < w; x++) { + const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16 + if (thr < tt * 0.5) { ctx.fillStyle = C.amberD; ctx.fillRect(x, y, 1, 1) } + } + } + ditherGrad(ctx, 0, horizon, w, h - horizon, '#0c1620', '#0a121a') + const dist = [[0.0, 0.16, 0.09], [0.08, 0.28, 0.07], [0.14, 0.18, 0.1], [0.23, 0.34, 0.08], [0.31, 0.22, 0.1], [0.4, 0.4, 0.09], [0.49, 0.2, 0.11], [0.58, 0.3, 0.09], [0.66, 0.24, 0.1], [0.75, 0.38, 0.08], [0.83, 0.2, 0.1], [0.91, 0.28, 0.1]] + for (const [bx, bh, bw] of dist) { + const x = Math.floor(bx * w) + const bht = Math.floor(bh * h * 0.44) + const wd = Math.ceil(bw * w) + ctx.fillStyle = '#0a121a'; ctx.fillRect(x, horizon - bht, wd, bht) + ctx.fillStyle = C.bldgL; ctx.fillRect(x, horizon - bht, 1, bht) + for (let wy = horizon - bht + 3; wy < horizon - 2; wy += 5) + for (let wx = x + 2; wx < x + wd - 2; wx += 4) { ctx.fillStyle = (wx + wy) % 3 ? C.winDim : C.win; ctx.fillRect(wx, wy, 2, 2) } + } + const fg = [[0.0, 0.4, 0.16], [0.15, 0.3, 0.13], [0.27, 0.52, 0.15], [0.41, 0.34, 0.12], [0.52, 0.46, 0.16], [0.67, 0.3, 0.14], [0.8, 0.5, 0.2]] + for (const [bx, bh, bw] of fg) { + const x = Math.floor(bx * w) + const wd = Math.ceil(bw * w) + const topY = Math.floor(h * (0.5 + (1 - bh) * 0.22)) + ctx.fillStyle = '#070d13'; ctx.fillRect(x, topY, wd, h - topY) + ctx.fillStyle = '#0e1a24'; ctx.fillRect(x, topY, wd, 2) + ctx.fillStyle = '#10202b'; ctx.fillRect(x, topY, 1, h - topY) + for (let wy = topY + 5; wy < h - 3; wy += 8) + for (let wx = x + 3; wx < x + wd - 4; wx += 8) { const lit = (wx * 3 + wy) % 5; ctx.fillStyle = lit === 0 ? C.amber : lit === 1 ? C.win : '#0c151d'; ctx.fillRect(wx, wy, 3, 4) } + } +} + +const paintDesk: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + ctx.fillStyle = C.sky2; ctx.fillRect(w - 70, 8, 60, h - 40) + for (let y = 10; y < h - 34; y += 4) { ctx.fillStyle = C.shadow; ctx.fillRect(w - 70, y, 60, 2) } + ctx.fillStyle = C.winDim; for (let i = 0; i < 10; i++) ctx.fillRect(w - 66 + i * 6, h - 40, 2, 2) + ctx.fillStyle = '#241a12'; ctx.fillRect(0, h - 26, w, 26) + ctx.fillStyle = '#3a2a1a'; ctx.fillRect(0, h - 26, w, 2) + const lx = 42 + const ly = h - 26 + ctx.fillStyle = C.shadow; ctx.fillRect(lx - 2, ly - 22, 4, 22) + ctx.fillStyle = C.amberD; ctx.fillRect(lx - 9, ly - 30, 18, 9) + ctx.fillStyle = C.amber; ctx.fillRect(lx - 9, ly - 30, 18, 2) + ctx.fillStyle = C.lamp; ctx.fillRect(lx - 7, ly - 22, 14, 3) + lampCone(ctx, lx, ly - 21, 30, 40) + ctx.fillStyle = C.bone; ctx.fillRect(lx - 14, h - 16, 40, 12) + ctx.fillStyle = C.amberD; ctx.fillRect(lx - 14, h - 16, 40, 2) + ctx.fillStyle = C.ox; ctx.fillRect(lx - 10, h - 12, 14, 2) + ctx.fillStyle = C.slate; ctx.fillRect(w - 50, h - 16, 10, 11); ctx.fillRect(w - 40, h - 13, 3, 4) +} + +const paintAtrium: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) + for (let cx = 10; cx < w; cx += 34) { ctx.fillStyle = C.bldg; ctx.fillRect(cx, 10, 8, h - 40); ctx.fillStyle = C.bldgL; ctx.fillRect(cx, 10, 2, h - 40) } + for (let cx = 22; cx < w; cx += 34) { ditherGrad(ctx, cx, 14, 16, h - 50, C.slate, C.sky1); for (let y = 18; y < h - 40; y += 6) { ctx.fillStyle = C.shadow; ctx.fillRect(cx, y, 16, 1) } } + const my = Math.floor(h * 0.34) + ctx.fillStyle = C.bldgL; ctx.fillRect(0, my, w, 4) + for (let x = 4; x < w; x += 6) { ctx.fillStyle = C.slate; ctx.fillRect(x, my + 4, 1, 8) } + ctx.fillStyle = C.shadow; ctx.fillRect(0, my + 12, w, 2) + ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 22, w, 22) + for (let x = 0; x < w; x += 10) { ctx.fillStyle = C.sky1; ctx.fillRect(x, h - 22, 1, 22) } + const lx = Math.floor(w / 2) + ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 14) + ctx.fillStyle = C.amberD; ctx.fillRect(lx - 5, 14, 11, 4) + ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, 18, 9, 2) + lampCone(ctx, lx, 19, 46, h - 30) + ctx.fillStyle = C.bone + const bx = lx + 10 + const by = h - 12 + ctx.fillRect(bx, by, 2, 1); ctx.fillRect(bx + 2, by - 1, 1, 1); ctx.fillRect(bx + 5, by - 2, 1, 1); ctx.fillRect(bx + 8, by, 2, 1); ctx.fillRect(bx - 2, by + 2, 1, 1); ctx.fillRect(bx + 11, by + 2, 1, 1); ctx.fillRect(bx + 3, by + 4, 5, 1) + ctx.fillStyle = C.amber; ctx.fillRect(bx + 14, by, 2, 3) + rainStreaks(ctx, w, h * 0.5, t) +} + +const paintInterro: ScenePainter = (ctx, w, h) => { + // Crisp, full-height interrogation room - flat paneled walls (not a smooth gradient), + // a one-way mirror, a hanging lamp, and a table along the bottom. Designed to fill the + // whole stage so there is no hard crop seam. + ctx.fillStyle = C.sky1; ctx.fillRect(0, 0, w, h) + ctx.fillStyle = C.sky0; ctx.fillRect(0, 0, w, Math.floor(h * 0.3)) // darker upper wall + ctx.fillStyle = '#0a121a' + const step = Math.max(8, Math.floor(w / 6)) + for (let x = step; x < w; x += step) ctx.fillRect(x, 0, 2, Math.floor(h * 0.74)) // panel seams + ctx.fillStyle = C.sky2; ctx.fillRect(0, Math.floor(h * 0.5), w, 3) // wainscot + ctx.fillStyle = C.shadow; ctx.fillRect(0, Math.floor(h * 0.5) + 3, w, 2) + // one-way mirror (upper right) + const mw = Math.floor(w * 0.24) + const mh = Math.floor(h * 0.15) + const mx = w - mw - Math.floor(w * 0.06) + const my = Math.floor(h * 0.12) + ctx.fillStyle = C.shadow; ctx.fillRect(mx - 2, my - 2, mw + 4, mh + 4) + ditherGrad(ctx, mx, my, mw, mh, C.slateL, C.sky1) + // floor + const fy = Math.floor(h * 0.78) + ctx.fillStyle = '#0a1015'; ctx.fillRect(0, fy, w, h - fy) + ctx.fillStyle = C.sky2; ctx.fillRect(0, fy, w, 2) + // hanging lamp + soft glow + const lx = Math.floor(w * 0.5) + ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, Math.floor(h * 0.05)) + ctx.fillStyle = C.amberD; ctx.fillRect(lx - 7, Math.floor(h * 0.05), 15, 5) + ctx.fillStyle = C.lamp; ctx.fillRect(lx - 5, Math.floor(h * 0.05) + 5, 11, 2) + lampCone(ctx, lx, Math.floor(h * 0.05) + 6, Math.floor(w * 0.55), Math.floor(h * 0.55)) + // interrogation table along the bottom + const ty = Math.floor(h * 0.85) + ctx.fillStyle = '#1a2228'; ctx.fillRect(0, ty, w, h - ty) + ctx.fillStyle = C.slate; ctx.fillRect(0, ty, w, 3) +} + +const paintSeawall: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.6), C.sky2, C.sky0) + ctx.fillStyle = C.bldg; for (let x = 0; x < w; x += 18) { const bh = 8 + ((x * 7) % 18); ctx.fillRect(x, Math.floor(h * 0.5) - bh, 16, bh) } + ditherGrad(ctx, 0, Math.floor(h * 0.5), w, Math.floor(h * 0.5), C.water, C.sky0) + ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 20, w, 20) + ctx.fillStyle = C.slate; ctx.fillRect(0, h - 22, w, 2) + for (let x = 6; x < w; x += 14) { ctx.fillStyle = C.slate; ctx.fillRect(x, h - 30, 2, 10) } + ctx.fillStyle = C.slate; ctx.fillRect(0, h - 30, w, 2) + const fx = Math.floor(w * 0.62) + ctx.fillStyle = C.bone; ctx.fillRect(fx, h - 40, 6, 14); ctx.fillStyle = '#9a937e'; ctx.fillRect(fx + 4, h - 40, 2, 14) + ctx.fillStyle = C.sky1; ctx.fillRect(fx + 1, h - 44, 4, 4) + rainStreaks(ctx, w, h, t) +} + +const paintMezzanine: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + ctx.fillStyle = C.bldgL; ctx.fillRect(0, Math.floor(h * 0.5), w, 4) + for (let x = 4; x < w; x += 8) { ctx.fillStyle = C.slate; ctx.fillRect(x, Math.floor(h * 0.5) + 4, 2, 14) } + ditherGrad(ctx, 0, Math.floor(h * 0.5) + 18, w, Math.floor(h * 0.5), C.shadow, '#020406') + lampCone(ctx, w - 14, 4, 40, h - 10) + const fx = Math.floor(w * 0.4) + ctx.fillStyle = C.bone; ctx.fillRect(fx, Math.floor(h * 0.5) - 18, 7, 18) + ctx.fillStyle = '#9a937e'; ctx.fillRect(fx + 5, Math.floor(h * 0.5) - 18, 2, 18) + ctx.fillStyle = C.sky2; ctx.fillRect(fx + 1, Math.floor(h * 0.5) - 23, 5, 5) + ctx.fillStyle = C.ox; ctx.fillRect(fx + 7, Math.floor(h * 0.5) - 6, 2, 2) + rainStreaks(ctx, w, h * 0.5, t) +} + +const paintMap: ScenePainter = (ctx, w, h, t) => { + ctx.fillStyle = C.sky0; ctx.fillRect(0, 0, w, h) + ctx.fillStyle = C.slate + for (let x = 0; x < w; x += 12) ctx.fillRect(x, 0, 1, h) + for (let y = 0; y < h; y += 12) ctx.fillRect(0, y, w, 1) + ctx.fillStyle = C.water; for (let i = 0; i < w + h; i++) ctx.fillRect(i, Math.floor(h * 0.6) - Math.floor(i * 0.4), 3, 3) + const n = Math.min(Math.floor(t / 2), 60) + for (let i = 0; i < n; i++) { const x = (i * 53) % w; const y = (i * 31) % h; ctx.fillStyle = i % 4 ? C.winDim : C.amber; ctx.fillRect(x, y, 3, 3) } + const px = Math.floor(w * 0.56) + const py = Math.floor(h * 0.42) + ctx.fillStyle = C.ox; ctx.fillRect(px - 2, py - 6, 5, 6); ctx.fillRect(px - 1, py, 3, 4) + ctx.fillStyle = C.bone; ctx.fillRect(px, py - 4, 1, 1) +} + +// ---- room interiors: recognizable furniture so a generated location reads as itself ---- +const _WOOD = '#3a2c1c' +const _WOOD_L = '#55402a' +const _METAL = '#2d3640' +const _BOOK = ['#6b3a2e', '#3a5a52', '#5a4a2a', '#46506b', '#5e1c1c', '#37636b'] + +const paintKitchen: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) + const cy = Math.floor(h * 0.16) + for (let x = 6; x < w - 6; x += 26) { + ctx.fillStyle = _WOOD; ctx.fillRect(x, cy, 22, 20) + ctx.fillStyle = _WOOD_L; ctx.fillRect(x, cy, 22, 2) + ctx.fillStyle = C.amber; ctx.fillRect(x + 18, cy + 9, 2, 3) + } + ctx.fillStyle = C.slate; ctx.fillRect(w - 52, cy + 26, 40, 26) + ditherGrad(ctx, w - 50, cy + 28, 36, 22, C.slateL, C.sky1) + ctx.fillStyle = C.shadow; ctx.fillRect(w - 33, cy + 26, 2, 26) + const ly = Math.floor(h * 0.62) + ctx.fillStyle = '#cfc8b2'; ctx.fillRect(0, ly, w, 5) + ctx.fillStyle = _WOOD; ctx.fillRect(0, ly + 5, w, h - ly - 5) + for (let x = 4; x < w; x += 24) { ctx.fillStyle = C.shadow; ctx.fillRect(x, ly + 5, 1, h - ly - 5); ctx.fillStyle = C.amber; ctx.fillRect(x + 18, ly + 12, 2, 3) } + ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.2), ly - 8, 10, 8); ctx.fillRect(Math.floor(w * 0.2) + 10, ly - 5, 3, 3) + const lx = Math.floor(w * 0.5) + ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 12) + ctx.fillStyle = C.amberD; ctx.fillRect(lx - 6, 12, 13, 4) + ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, 16, 9, 2) + lampCone(ctx, lx, 18, 44, ly - 14) + ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.62), ly - 3, 3, 3) + rainStreaks(ctx, w, h * 0.4, t) +} + +const paintStudy: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) + const shelf = (sx: number, sw: number) => { + ctx.fillStyle = _WOOD; ctx.fillRect(sx, 12, sw, h - 40) + for (let sy = 18; sy < h - 34; sy += 16) { + ctx.fillStyle = '#241a10'; ctx.fillRect(sx + 2, sy + 12, sw - 4, 3) + for (let bx = sx + 3; bx < sx + sw - 3; bx += 3) { ctx.fillStyle = _BOOK[(bx + sy) % _BOOK.length]; ctx.fillRect(bx, sy, 2, 12) } + } + } + shelf(8, Math.floor(w * 0.34)) + shelf(Math.floor(w * 0.58), Math.floor(w * 0.34)) + const dy = h - 24 + ctx.fillStyle = '#241a12'; ctx.fillRect(0, dy, w, 24) + ctx.fillStyle = '#3a2a1a'; ctx.fillRect(0, dy, w, 2) + const lx = Math.floor(w * 0.5) + ctx.fillStyle = C.amberD; ctx.fillRect(lx - 6, dy - 12, 12, 5) + ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, dy - 7, 9, 2) + lampCone(ctx, lx, dy - 6, 36, 30) + ctx.fillStyle = C.bone; ctx.fillRect(lx - 14, dy - 4, 20, 4) + ctx.fillStyle = C.ox; ctx.fillRect(lx + 12, dy - 3, 3, 3) +} + +const paintParlor: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) + ctx.fillStyle = C.bldgL; ctx.fillRect(0, Math.floor(h * 0.55), w, 2) + const fx = 12 + const fy = Math.floor(h * 0.32) + ctx.fillStyle = '#1a1410'; ctx.fillRect(fx, fy, 40, h - fy - 22) + ctx.fillStyle = _WOOD; ctx.fillRect(fx - 3, fy - 4, 46, 5) + ctx.fillStyle = '#0a0805'; ctx.fillRect(fx + 8, fy + 10, 24, h - fy - 40) + for (let i = 0; i < 30; i++) { const ex = fx + 10 + ((i * 7) % 20); const ey = h - 36 - ((i * 5) % 16); ctx.fillStyle = i % 3 ? C.amberD : C.amber; ctx.fillRect(ex, ey, 2, 2) } + ctx.fillStyle = _WOOD_L; ctx.fillRect(w - 46, fy, 30, 22); ctx.fillStyle = C.slate; ctx.fillRect(w - 43, fy + 3, 24, 16) + const sy = h - 30 + const sofX = Math.floor(w * 0.34) + const sofW = Math.floor(w * 0.4) + ctx.fillStyle = C.slate; ctx.fillRect(sofX, sy, sofW, 18) + ctx.fillStyle = C.slateL; ctx.fillRect(sofX, sy, sofW, 4) + ctx.fillStyle = C.slate; ctx.fillRect(sofX - 4, sy - 6, 6, 24); ctx.fillRect(sofX + sofW, sy - 6, 6, 24) + ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 12, w, 12) + ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.52), h - 8, 3, 3) + lampCone(ctx, Math.floor(w * 0.5), 8, 50, h - 16) + rainStreaks(ctx, w, h * 0.4, t) +} + +const paintBedroom: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + ctx.fillStyle = C.slate; ctx.fillRect(w - 56, 14, 42, 34); ditherGrad(ctx, w - 54, 16, 38, 30, C.slateL, C.sky1) + ctx.fillStyle = C.shadow; ctx.fillRect(w - 35, 14, 2, 34); ctx.fillRect(w - 56, 30, 42, 2) + const bx = Math.floor(w * 0.22) + const by = h - 34 + const bw = Math.floor(w * 0.5) + ctx.fillStyle = _WOOD; ctx.fillRect(bx - 4, by - 12, 6, 28) + ctx.fillStyle = C.bone; ctx.fillRect(bx, by, bw, 6) + ctx.fillStyle = C.slate; ctx.fillRect(bx, by + 6, bw, 14) + ctx.fillStyle = C.slateL; ctx.fillRect(bx, by + 6, bw, 3) + ctx.fillStyle = C.bone; ctx.fillRect(bx + 3, by + 2, 12, 5) + const nx = bx + bw + 6 + ctx.fillStyle = _WOOD; ctx.fillRect(nx, by + 4, 14, 16) + ctx.fillStyle = C.amberD; ctx.fillRect(nx + 3, by - 2, 8, 6); ctx.fillStyle = C.lamp; ctx.fillRect(nx + 4, by + 3, 6, 1) + lampCone(ctx, nx + 7, by, 28, 24) + ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 10, w, 10) +} + +const paintAlley: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + // facing walls funneling to a lit back street + ctx.fillStyle = '#0a121a'; ctx.fillRect(0, 0, Math.floor(w * 0.3), h) + ctx.fillStyle = '#0c141c'; ctx.fillRect(Math.floor(w * 0.7), 0, Math.ceil(w * 0.3), h) + for (let y = 8; y < h - 30; y += 14) { + ctx.fillStyle = C.bldgL; ctx.fillRect(8, y, 14, 8); ctx.fillRect(w - 24, y + 5, 14, 8) + ctx.fillStyle = (y % 28) ? C.winDim : C.win; ctx.fillRect(11, y + 2, 3, 3); ctx.fillRect(w - 21, y + 7, 3, 3) + } + // fire escape zig-zag on the left wall + ctx.fillStyle = _METAL + for (let i = 0; i < 4; i++) { const y = 16 + i * 22; ctx.fillRect(26, y, 26, 2); ctx.fillRect(i % 2 ? 26 : 50, y - 12, 2, 14) } + // wet cobbled lane + ctx.fillStyle = '#0c151b'; ctx.fillRect(0, h - 26, w, 26) + for (let x = 2; x < w; x += 9) { ctx.fillStyle = (x % 18) ? '#101a22' : C.slate; ctx.fillRect(x, h - 24 + (x % 3), 5, 2) } + // dumpster + crates + ctx.fillStyle = C.slate; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 18) + ctx.fillStyle = C.slateL; ctx.fillRect(Math.floor(w * 0.32), h - 42, 34, 3) + ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.32) + 40, h - 34, 12, 10); ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.32) + 40, h - 34, 12, 2) + // buzzing sign at the alley mouth + const sx = Math.floor(w * 0.62) + ctx.fillStyle = C.shadow; ctx.fillRect(sx, 18, 3, 16) + ctx.fillStyle = C.ox; ctx.fillRect(sx - 12, 20, 12, 12) + ctx.fillStyle = '#b8443f'; ctx.fillRect(sx - 10, 22, 8, 8) + lampCone(ctx, sx - 6, 32, 30, h - 60) + rainStreaks(ctx, w, h, t) +} + +const paintBar: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + // back shelf with bottles + ctx.fillStyle = _WOOD; ctx.fillRect(10, 12, w - 20, 34) + ctx.fillStyle = '#241a10'; ctx.fillRect(10, 28, w - 20, 3); ctx.fillRect(10, 43, w - 20, 3) + for (let x = 16; x < w - 18; x += 7) { + ctx.fillStyle = _BOOK[(x >> 2) % _BOOK.length]; ctx.fillRect(x, 18, 4, 10); ctx.fillRect(x + 1, 15, 2, 3) + if ((x >> 3) % 2) { ctx.fillStyle = _BOOK[(x >> 1) % _BOOK.length]; ctx.fillRect(x, 33, 4, 10); ctx.fillRect(x + 1, 31, 2, 2) } + } + // mirror strip behind the top row + ctx.fillStyle = C.slateL; ctx.fillRect(12, 13, w - 24, 2) + // counter + const cy = Math.floor(h * 0.62) + ctx.fillStyle = _WOOD_L; ctx.fillRect(0, cy, w, 5) + ctx.fillStyle = _WOOD; ctx.fillRect(0, cy + 5, w, h - cy - 5) + for (let x = 6; x < w; x += 22) { ctx.fillStyle = C.shadow; ctx.fillRect(x, cy + 5, 1, h - cy - 5) } + // stools + for (let i = 0; i < 4; i++) { + const x = 24 + i * Math.floor(w * 0.22) + ctx.fillStyle = C.ox; ctx.fillRect(x, h - 22, 14, 4) + ctx.fillStyle = _METAL; ctx.fillRect(x + 6, h - 18, 2, 18) + } + // glass + bottle left on the counter + ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.3), cy - 6, 4, 6) + ctx.fillStyle = C.amberD; ctx.fillRect(Math.floor(w * 0.3) + 10, cy - 10, 4, 10); ctx.fillStyle = C.amber; ctx.fillRect(Math.floor(w * 0.3) + 11, cy - 12, 2, 2) + // low pendant lamps + for (const fx of [0.25, 0.65]) { + const lx = Math.floor(w * fx) + ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 8) + ctx.fillStyle = C.amberD; ctx.fillRect(lx - 5, 8, 11, 4); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 3, 12, 7, 2) + lampCone(ctx, lx, 13, 34, cy - 16) + } +} + +const paintCasino: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, '#131a14', '#0a0f0a') + // wall pattern + framed rules board + for (let x = 0; x < w; x += 16) { ctx.fillStyle = '#0d1410'; ctx.fillRect(x, 0, 2, Math.floor(h * 0.5)) } + ctx.fillStyle = _WOOD; ctx.fillRect(w - 58, 10, 40, 24); ctx.fillStyle = C.bone; ctx.fillRect(w - 54, 13, 32, 18) + ctx.fillStyle = C.shadow; for (let i = 0; i < 4; i++) ctx.fillRect(w - 50, 16 + i * 4, 24, 1) + // felt table, elliptical feel via stacked rects + const ty = Math.floor(h * 0.55) + ctx.fillStyle = '#14401f'; ctx.fillRect(Math.floor(w * 0.1), ty, Math.floor(w * 0.8), 26) + ctx.fillStyle = '#1d5a2c'; ctx.fillRect(Math.floor(w * 0.13), ty, Math.floor(w * 0.74), 4) + ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.08), ty + 26, Math.floor(w * 0.84), 5) + // cards + chip stacks + ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.3), ty + 8, 7, 9); ctx.fillRect(Math.floor(w * 0.34), ty + 6, 7, 9) + ctx.fillStyle = C.ox; ctx.fillRect(Math.floor(w * 0.36), ty + 8, 2, 2) + for (let i = 0; i < 3; i++) { + const x = Math.floor(w * 0.55) + i * 9 + ctx.fillStyle = [C.ox, C.bone, C.amber][i]; ctx.fillRect(x, ty + 10 - (i % 2) * 2, 6, 6 + (i % 2) * 2) + ctx.fillStyle = C.shadow; ctx.fillRect(x, ty + 14, 6, 1) + } + // dice + ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.46), ty + 12, 4, 4); ctx.fillStyle = C.shadow; ctx.fillRect(Math.floor(w * 0.47), ty + 13, 1, 1) + // green-shaded pendant + const lx = Math.floor(w * 0.5) + ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 10) + ctx.fillStyle = '#1d5a2c'; ctx.fillRect(lx - 8, 10, 17, 5); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 5, 15, 11, 2) + lampCone(ctx, lx, 16, 50, ty - 16) + ctx.fillStyle = '#0a0f0a'; ctx.fillRect(0, h - 10, w, 10) +} + +const paintTheater: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, '#1a0e10', '#0c0608') + // stage floor + proscenium + const sy = Math.floor(h * 0.58) + ctx.fillStyle = _WOOD; ctx.fillRect(0, sy, w, 8) + ctx.fillStyle = _WOOD_L; ctx.fillRect(0, sy, w, 2) + // heavy curtains left/right with fold lines + for (const [x0, dir] of [[0, 1], [w - Math.floor(w * 0.18), -1]] as [number, number][]) { + const cw = Math.floor(w * 0.18) + ctx.fillStyle = '#5e1c1c'; ctx.fillRect(x0, 0, cw, sy) + for (let x = x0 + 2; x < x0 + cw; x += 5) { ctx.fillStyle = '#481414'; ctx.fillRect(x, 0, 2, sy) } + ctx.fillStyle = '#87292a'; ctx.fillRect(dir > 0 ? x0 : x0 + cw - 3, 0, 3, sy) + } + // curtain valance + ctx.fillStyle = '#5e1c1c'; ctx.fillRect(0, 0, w, 14) + for (let x = 3; x < w; x += 9) { ctx.fillStyle = '#481414'; ctx.fillRect(x, 10, 5, 6) } + ctx.fillStyle = C.amberD; ctx.fillRect(0, 14, w, 2) + // backdrop + lone prop chair + ditherGrad(ctx, Math.floor(w * 0.2), 18, Math.floor(w * 0.6), sy - 20, C.sky2, C.sky0) + ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.46), sy - 18, 3, 18); ctx.fillRect(Math.floor(w * 0.55), sy - 18, 3, 18); ctx.fillRect(Math.floor(w * 0.46), sy - 18, 12, 3); ctx.fillRect(Math.floor(w * 0.46), sy - 30, 3, 12) + // footlights + for (let x = 10; x < w - 8; x += 18) { ctx.fillStyle = C.lamp; ctx.fillRect(x, sy - 2, 4, 2); lampCone(ctx, x + 2, sy - 2, 10, 12) } + // audience rows in the dark + ctx.fillStyle = '#070b0f'; ctx.fillRect(0, sy + 8, w, h - sy - 8) + for (let y = sy + 12; y < h - 4; y += 9) for (let x = 8 + (y % 2) * 6; x < w - 8; x += 14) { ctx.fillStyle = '#10161c'; ctx.fillRect(x, y, 9, 5) } + lampCone(ctx, Math.floor(w * 0.5), 16, 60, sy - 18) +} + +const paintWarehouse: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + // high ribbon windows letting moonlight in + for (let x = 8; x < w - 8; x += 34) { + ctx.fillStyle = C.slate; ctx.fillRect(x, 6, 26, 12) + ditherGrad(ctx, x + 2, 8, 22, 8, C.slateL, C.sky1) + ctx.fillStyle = C.shadow; ctx.fillRect(x + 12, 6, 2, 12) + } + // roof trusses + ctx.fillStyle = _METAL + for (let x = 0; x < w; x += 40) { ctx.fillRect(x, 20, 2, 10); ctx.fillRect(x, 28, 40, 2) } + // crate stacks + const crate = (x: number, y: number, s: number) => { + ctx.fillStyle = _WOOD; ctx.fillRect(x, y, s, s) + ctx.fillStyle = _WOOD_L; ctx.fillRect(x, y, s, 2); ctx.fillRect(x, y, 2, s) + ctx.fillStyle = C.shadow; ctx.fillRect(x + 2, y + Math.floor(s / 2), s - 4, 1) + } + crate(14, h - 50, 24); crate(40, h - 50, 24); crate(26, h - 74, 24) + crate(w - 44, h - 44, 18); crate(w - 70, h - 44, 18) + // hanging chain + hook + ctx.fillStyle = _METAL; for (let y = 0; y < 36; y += 4) ctx.fillRect(Math.floor(w * 0.58), y, 2, 3) + ctx.fillRect(Math.floor(w * 0.58) - 2, 36, 6, 3) + // floor + ctx.fillStyle = '#0c1014'; ctx.fillRect(0, h - 26, w, 26) + for (let x = 0; x < w; x += 26) { ctx.fillStyle = '#11161b'; ctx.fillRect(x, h - 26, 1, 26) } + lampCone(ctx, Math.floor(w * 0.4), 30, 44, h - 60) + rainStreaks(ctx, w, 18, t) +} + +const paintRooftop: ScenePainter = (ctx, w, h, t) => { + // city far below beyond the parapet + ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.55), C.sky2, C.sky0) + for (let x = 0; x < w; x += 14) { + const bh = 6 + ((x * 13) % 22) + ctx.fillStyle = C.bldg; ctx.fillRect(x, Math.floor(h * 0.55) - bh, 12, bh) + ctx.fillStyle = (x % 28) ? C.winDim : C.win; ctx.fillRect(x + 3, Math.floor(h * 0.55) - bh + 3, 2, 2) + } + // parapet ledge + ctx.fillStyle = '#10181f'; ctx.fillRect(0, Math.floor(h * 0.55), w, 6) + ctx.fillStyle = C.slate; ctx.fillRect(0, Math.floor(h * 0.55), w, 2) + // tar roof + ditherGrad(ctx, 0, Math.floor(h * 0.55) + 6, w, h, '#0c1117', '#070b0f') + // vents, pipe, water tank + ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.18), h - 34, 18, 14) + ctx.fillStyle = '#3a444e'; ctx.fillRect(Math.floor(w * 0.18), h - 34, 18, 3) + ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.24), h - 40, 4, 6) + const wx = Math.floor(w * 0.72) + ctx.fillStyle = _WOOD; ctx.fillRect(wx, h - 64, 30, 26) + ctx.fillStyle = _WOOD_L; ctx.fillRect(wx, h - 64, 30, 3) + ctx.fillStyle = _METAL; ctx.fillRect(wx + 4, h - 38, 2, 12); ctx.fillRect(wx + 24, h - 38, 2, 12) + ctx.fillStyle = '#241c12'; ctx.fillRect(wx + 6, h - 70, 18, 6) + // door bulkhead with a weak lamp + ctx.fillStyle = '#0e151c'; ctx.fillRect(8, h - 56, 26, 30) + ctx.fillStyle = C.slate; ctx.fillRect(8, h - 56, 26, 2) + ctx.fillStyle = '#1a232c'; ctx.fillRect(14, h - 48, 12, 22) + ctx.fillStyle = C.lamp; ctx.fillRect(19, h - 60, 4, 2) + lampCone(ctx, 21, h - 58, 22, 28) + rainStreaks(ctx, w, h, t) +} + +const paintOffice: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + // blinds with light slicing through + const bw = Math.floor(w * 0.3) + ctx.fillStyle = C.sky2; ctx.fillRect(w - bw - 10, 8, bw, 44) + for (let y = 10; y < 50; y += 4) { ctx.fillStyle = (y % 8) ? C.shadow : C.winDim; ctx.fillRect(w - bw - 10, y, bw, 2) } + // filing cabinets + for (let i = 0; i < 2; i++) { + const x = 10 + i * 22 + ctx.fillStyle = _METAL; ctx.fillRect(x, h - 64, 18, 40) + for (let d = 0; d < 3; d++) { ctx.fillStyle = '#3a444e'; ctx.fillRect(x + 2, h - 60 + d * 12, 14, 2); ctx.fillStyle = C.amberD; ctx.fillRect(x + 7, h - 57 + d * 12, 4, 1) } + } + // desk with papers, phone, banker's lamp + const dy = h - 30 + ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.3), dy, Math.floor(w * 0.52), 6) + ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.3), dy, Math.floor(w * 0.52), 2) + ctx.fillStyle = '#241a12'; ctx.fillRect(Math.floor(w * 0.33), dy + 6, 8, h - dy - 6); ctx.fillRect(Math.floor(w * 0.72), dy + 6, 8, h - dy - 6) + ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.38), dy - 4, 16, 4); ctx.fillRect(Math.floor(w * 0.42), dy - 6, 14, 2) + ctx.fillStyle = '#15181d'; ctx.fillRect(Math.floor(w * 0.6), dy - 6, 12, 6) + const lx = Math.floor(w * 0.5) + ctx.fillStyle = C.amberD; ctx.fillRect(lx - 6, dy - 14, 13, 4); ctx.fillStyle = C.lamp; ctx.fillRect(lx - 4, dy - 10, 9, 2) + lampCone(ctx, lx, dy - 9, 30, 26) + // coat stand + ctx.fillStyle = _WOOD; ctx.fillRect(w - 16, h - 58, 2, 34) + ctx.fillStyle = C.sky2; ctx.fillRect(w - 22, h - 54, 10, 16) + ctx.fillStyle = '#0c1117'; ctx.fillRect(0, h - 12, w, 12) + rainStreaks(ctx, w - bw - 10 + 2, 50, t) +} + +const paintLobby: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) + // key wall behind the front desk + ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.28), 10, Math.floor(w * 0.44), 30) + for (let y = 14; y < 36; y += 7) for (let x = Math.floor(w * 0.3); x < Math.floor(w * 0.7); x += 8) { + ctx.fillStyle = '#241a10'; ctx.fillRect(x, y, 5, 5) + if ((x + y) % 3) { ctx.fillStyle = C.amber; ctx.fillRect(x + 2, y + 1, 1, 3) } + } + // big lobby clock + ctx.fillStyle = _WOOD; ctx.fillRect(w - 36, 8, 22, 26) + ctx.fillStyle = C.bone; ctx.fillRect(w - 33, 11, 16, 16) + ctx.fillStyle = C.shadow; ctx.fillRect(w - 26, 14, 2, 6); ctx.fillRect(w - 26, 19, 5, 2) + // front desk + const dy = Math.floor(h * 0.56) + ctx.fillStyle = _WOOD_L; ctx.fillRect(Math.floor(w * 0.2), dy, Math.floor(w * 0.6), 5) + ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.2), dy + 5, Math.floor(w * 0.6), 22) + for (let x = Math.floor(w * 0.24); x < Math.floor(w * 0.78); x += 16) { ctx.fillStyle = '#241a10'; ctx.fillRect(x, dy + 8, 10, 14) } + // service bell + register book + ctx.fillStyle = C.amber; ctx.fillRect(Math.floor(w * 0.44), dy - 4, 6, 3); ctx.fillStyle = C.amberD; ctx.fillRect(Math.floor(w * 0.46), dy - 6, 2, 2) + ctx.fillStyle = C.bone; ctx.fillRect(Math.floor(w * 0.56), dy - 3, 14, 3) + // checkered marble floor + for (let y = dy + 27; y < h; y += 6) for (let x = ((y / 6) % 2) * 6; x < w; x += 12) { ctx.fillStyle = '#11181f'; ctx.fillRect(x, y, 6, 6) } + ctx.fillStyle = '#0b1016'; ctx.fillRect(0, dy + 27, w, 1) + // twin sconces + for (const fx of [0.12, 0.88]) { + const sx = Math.floor(w * fx) + ctx.fillStyle = C.amberD; ctx.fillRect(sx - 2, 22, 5, 6); ctx.fillStyle = C.lamp; ctx.fillRect(sx - 1, 20, 3, 2) + lampCone(ctx, sx, 28, 20, 30) + } + lampCone(ctx, Math.floor(w * 0.5), 6, 54, dy - 8) +} + +const paintStation: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, Math.floor(h * 0.5), C.sky1, C.sky0) + // canopy + supports + ctx.fillStyle = '#10181f'; ctx.fillRect(0, 0, w, 12) + ctx.fillStyle = C.slate; ctx.fillRect(0, 12, w, 2) + for (let x = 20; x < w; x += 60) { ctx.fillStyle = _METAL; ctx.fillRect(x, 14, 3, Math.floor(h * 0.62) - 14) } + // hanging station clock + const cx = Math.floor(w * 0.5) + ctx.fillStyle = _METAL; ctx.fillRect(cx - 1, 14, 2, 8) + ctx.fillStyle = '#10181f'; ctx.fillRect(cx - 8, 22, 16, 16) + ctx.fillStyle = C.bone; ctx.fillRect(cx - 6, 24, 12, 12) + ctx.fillStyle = C.shadow; ctx.fillRect(cx - 1, 27, 2, 4); ctx.fillRect(cx - 1, 29, 4, 2) + // platform edge + const py = Math.floor(h * 0.62) + ctx.fillStyle = '#11181f'; ctx.fillRect(0, py, w, 8) + ctx.fillStyle = C.bone; for (let x = 0; x < w; x += 10) ctx.fillRect(x, py, 6, 2) + // tracks below + ctx.fillStyle = '#070b0f'; ctx.fillRect(0, py + 8, w, h - py - 8) + for (let x = 0; x < w; x += 12) { ctx.fillStyle = _WOOD; ctx.fillRect(x, py + 14, 8, 3) } + ctx.fillStyle = _METAL; ctx.fillRect(0, py + 12, w, 2); ctx.fillRect(0, py + 22, w, 2) + // bench + abandoned suitcase + ctx.fillStyle = _WOOD; ctx.fillRect(Math.floor(w * 0.14), py - 14, 30, 4); ctx.fillRect(Math.floor(w * 0.14) + 2, py - 10, 3, 10); ctx.fillRect(Math.floor(w * 0.14) + 25, py - 10, 3, 10) + ctx.fillStyle = '#5a4a2a'; ctx.fillRect(Math.floor(w * 0.55), py - 10, 14, 10); ctx.fillStyle = '#3e331d'; ctx.fillRect(Math.floor(w * 0.55) + 6, py - 10, 2, 10) + // departures board + ctx.fillStyle = '#0a0f14'; ctx.fillRect(w - 64, 18, 48, 22) + for (let i = 0; i < 3; i++) { ctx.fillStyle = i === 1 ? C.amber : C.winDim; ctx.fillRect(w - 60, 22 + i * 6, 28 - i * 6, 2) } + lampCone(ctx, Math.floor(w * 0.3), 14, 40, py - 20) + rainStreaks(ctx, w, 12, t) +} + +const paintGarage: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + // segmented rolling door + const dw = Math.floor(w * 0.4) + ctx.fillStyle = '#10181f'; ctx.fillRect(w - dw - 8, 8, dw, Math.floor(h * 0.6)) + for (let y = 10; y < Math.floor(h * 0.6); y += 8) { ctx.fillStyle = C.slate; ctx.fillRect(w - dw - 8, y, dw, 2) } + // pegboard of tools + ctx.fillStyle = _WOOD; ctx.fillRect(10, 10, Math.floor(w * 0.3), 34) + for (let x = 14; x < Math.floor(w * 0.3); x += 8) { ctx.fillStyle = _METAL; ctx.fillRect(x, 14, 2, 10 + (x % 3) * 4) } + ctx.fillStyle = '#3a444e'; ctx.fillRect(14, 36, Math.floor(w * 0.26), 2) + // car under tarp + const cy = h - 40 + ctx.fillStyle = C.slate; ctx.fillRect(Math.floor(w * 0.16), cy, Math.floor(w * 0.5), 18) + ctx.fillStyle = C.slateL; ctx.fillRect(Math.floor(w * 0.2), cy - 8, Math.floor(w * 0.34), 8) + ctx.fillStyle = C.shadow; ctx.fillRect(Math.floor(w * 0.22), cy + 18, 10, 5); ctx.fillRect(Math.floor(w * 0.52), cy + 18, 10, 5) + // oil stain + dropped wrench + ctx.fillStyle = '#05080b'; ctx.fillRect(Math.floor(w * 0.72), h - 16, 22, 6) + ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.78), h - 22, 10, 2); ctx.fillRect(Math.floor(w * 0.86), h - 24, 3, 6) + ctx.fillStyle = '#0c1014'; ctx.fillRect(0, h - 12, w, 12) + // caged work light + const lx = Math.floor(w * 0.38) + ctx.fillStyle = C.shadow; ctx.fillRect(lx, 0, 1, 10) + ctx.fillStyle = _METAL; ctx.fillRect(lx - 4, 10, 9, 6) + ctx.fillStyle = C.lamp; ctx.fillRect(lx - 2, 12, 5, 3) + lampCone(ctx, lx, 16, 40, cy - 14) +} + +const paintChapel: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, '#100c14', '#080610') + // tall stained-glass window + const gx = Math.floor(w * 0.42) + const gw = Math.floor(w * 0.16) + ctx.fillStyle = C.shadow; ctx.fillRect(gx - 2, 6, gw + 4, 52) + const tints = ['#5e1c1c', '#284149', '#5a4a2a', '#46506b'] + for (let y = 8; y < 54; y += 6) for (let x = gx; x < gx + gw; x += 5) { ctx.fillStyle = tints[((x + y) >> 2) % tints.length]; ctx.fillRect(x, y, 4, 5) } + ctx.fillStyle = C.shadow; ctx.fillRect(gx + Math.floor(gw / 2), 6, 2, 52); ctx.fillRect(gx - 2, 28, gw + 4, 2) + // moonlight pooling beneath the glass + lampCone(ctx, gx + Math.floor(gw / 2), 58, 40, 40) + // altar with candles + const ay = Math.floor(h * 0.6) + ctx.fillStyle = _WOOD; ctx.fillRect(gx - 10, ay, gw + 20, 6) + ctx.fillStyle = C.bone; ctx.fillRect(gx - 8, ay - 2, gw + 16, 2) + for (let i = 0; i < 4; i++) { + const x = gx - 6 + i * Math.floor((gw + 12) / 3) + ctx.fillStyle = C.bone; ctx.fillRect(x, ay - 8, 2, 6) + ctx.fillStyle = C.amber; ctx.fillRect(x, ay - 10, 2, 2) + } + // pews receding + for (let i = 0; i < 3; i++) { + const y = ay + 14 + i * 12 + ctx.fillStyle = _WOOD; ctx.fillRect(14 - i * 2, y, w - 28 + i * 4, 5) + ctx.fillStyle = '#241a10'; ctx.fillRect(14 - i * 2, y + 5, w - 28 + i * 4, 2) + } + ctx.fillStyle = '#06040a'; ctx.fillRect(0, h - 8, w, 8) +} + +const paintGallery: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, C.sky2, C.sky0) + // hung canvases, one conspicuously missing + const art = [[14, 0.26, '#284149'], [Math.floor(w * 0.3), 0.2, '#5a4a2a'], [Math.floor(w * 0.62), 0.24, '#46506b']] as [number, number, string][] + for (const [x, fw, tint] of art) { + const aw = Math.floor(w * fw) + ctx.fillStyle = _WOOD_L; ctx.fillRect(x - 2, 12, aw + 4, 34) + ditherGrad(ctx, x, 14, aw, 30, tint, C.sky0) + ctx.fillStyle = C.shadow; ctx.fillRect(x, 44, aw, 2) + } + // the empty frame - pale rectangle where a painting was + const ex = Math.floor(w * 0.48) + ctx.fillStyle = _WOOD_L; ctx.fillRect(ex - 2, 12, 24, 34) + ctx.fillStyle = '#1d2832'; ctx.fillRect(ex, 14, 20, 30) + ctx.fillStyle = C.bone; ctx.fillRect(ex + 2, 16, 16, 26) + ctx.fillStyle = C.ox; ctx.fillRect(ex + 6, 26, 8, 2) + // velvet rope line + for (let x = 10; x < w - 10; x += 36) { ctx.fillStyle = _METAL; ctx.fillRect(x, h - 38, 2, 16) } + ctx.fillStyle = C.ox; for (let x = 12; x < w - 12; x += 4) ctx.fillRect(x, h - 34 + ((x >> 2) % 2), 3, 2) + // pedestal with small bust + const px2 = Math.floor(w * 0.82) + ctx.fillStyle = C.slate; ctx.fillRect(px2, h - 44, 12, 22) + ctx.fillStyle = C.slateL; ctx.fillRect(px2, h - 44, 12, 2) + ctx.fillStyle = C.bone; ctx.fillRect(px2 + 3, h - 52, 6, 8) + // polished floor + ctx.fillStyle = '#0d141b'; ctx.fillRect(0, h - 20, w, 20) + for (let x = 0; x < w; x += 18) { ctx.fillStyle = '#121a22'; ctx.fillRect(x, h - 20, 1, 20) } + for (const [x, fw] of art) lampCone(ctx, x + Math.floor((w * fw) / 2), 8, Math.floor(w * fw), 30) + lampCone(ctx, ex + 10, 8, 26, 30) +} + +const paintCellar: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, '#120e0a', '#080604') + // stone arch ribs + for (let x = 8; x < w; x += 44) { + ctx.fillStyle = '#1d1812'; ctx.fillRect(x, 8, 6, h - 28) + ctx.fillStyle = '#28201666'; ctx.fillRect(x, 8, 2, h - 28) + } + ctx.fillStyle = '#1d1812'; ctx.fillRect(0, 6, w, 4) + // wine racks + for (let y = 22; y < h - 40; y += 14) for (let x = 18; x < Math.floor(w * 0.4); x += 7) { + ctx.fillStyle = '#241a10'; ctx.fillRect(x, y, 6, 10) + ctx.fillStyle = '#0f2418'; ctx.fillRect(x + 1, y + 2, 4, 4) + } + // barrels on their sides + for (let i = 0; i < 2; i++) { + const x = Math.floor(w * 0.55) + i * 34 + ctx.fillStyle = _WOOD; ctx.fillRect(x, h - 48, 26, 22) + ctx.fillStyle = _WOOD_L; ctx.fillRect(x, h - 48, 26, 3) + ctx.fillStyle = _METAL; ctx.fillRect(x, h - 44, 26, 2); ctx.fillRect(x, h - 32, 26, 2) + ctx.fillStyle = '#241a10'; ctx.fillRect(x + 11, h - 40, 4, 6) + } + // candle lantern on a crate + const cx = Math.floor(w * 0.34) + ctx.fillStyle = _WOOD; ctx.fillRect(cx, h - 38, 16, 14) + ctx.fillStyle = _METAL; ctx.fillRect(cx + 4, h - 50, 8, 12) + ctx.fillStyle = C.amber; ctx.fillRect(cx + 6, h - 46, 4, 6) + lampCone(ctx, cx + 8, h - 44, 26, 22) + // dirt floor + ctx.fillStyle = '#0c0906'; ctx.fillRect(0, h - 18, w, 18) + for (let x = 4; x < w; x += 11) { ctx.fillStyle = '#141009'; ctx.fillRect(x, h - 14 + (x % 3), 4, 2) } +} + +const paintGreenhouse: ScenePainter = (ctx, w, h, t) => { + // glass panes against the night sky + ditherGrad(ctx, 0, 0, w, h, '#0e1d1a', '#081210') + ctx.fillStyle = _METAL + for (let x = 0; x < w; x += 24) ctx.fillRect(x, 0, 2, Math.floor(h * 0.6)) + for (let y = 0; y < Math.floor(h * 0.6); y += 16) ctx.fillRect(0, y, w, 2) + // slanted roof line + for (let x = 0; x < w; x += 8) { ctx.fillStyle = '#10202b44'; ctx.fillRect(x, Math.floor(x / 12), 6, 1) } + // planting benches with foliage + const by = Math.floor(h * 0.62) + ctx.fillStyle = _WOOD; ctx.fillRect(6, by, Math.floor(w * 0.38), 4); ctx.fillRect(Math.floor(w * 0.56), by, Math.floor(w * 0.38), 4) + ctx.fillStyle = '#241a10'; ctx.fillRect(10, by + 4, 3, h - by - 14); ctx.fillRect(Math.floor(w * 0.38) - 6, by + 4, 3, h - by - 14) + ctx.fillRect(Math.floor(w * 0.56) + 4, by + 4, 3, h - by - 14); ctx.fillRect(Math.floor(w * 0.92), by + 4, 3, h - by - 14) + const leaf = ['#1d4a2c', '#14401f', '#2c5e38'] + for (let x = 8; x < Math.floor(w * 0.42); x += 6) { ctx.fillStyle = leaf[(x >> 1) % 3]; ctx.fillRect(x, by - 6 - (x % 5), 4, 6 + (x % 5)) } + for (let x = Math.floor(w * 0.58); x < Math.floor(w * 0.92); x += 6) { ctx.fillStyle = leaf[(x >> 2) % 3]; ctx.fillRect(x, by - 5 - (x % 4), 4, 5 + (x % 4)) } + // overturned pot + trowel on the path + ctx.fillStyle = '#6b3a2e'; ctx.fillRect(Math.floor(w * 0.46), h - 16, 10, 6) + ctx.fillStyle = '#0c0906'; ctx.fillRect(Math.floor(w * 0.44), h - 11, 8, 3) + ctx.fillStyle = _METAL; ctx.fillRect(Math.floor(w * 0.5), h - 9, 8, 2) + // stone path + ctx.fillStyle = '#10161c'; ctx.fillRect(Math.floor(w * 0.42), by + 4, Math.floor(w * 0.14), h - by - 4) + lampCone(ctx, Math.floor(w * 0.5), 4, 50, by - 6) + rainStreaks(ctx, w, Math.floor(h * 0.55), t) +} + +const paintDiner: ScenePainter = (ctx, w, h, t) => { + ditherGrad(ctx, 0, 0, w, h, C.sky1, C.sky0) + // big window with the street outside + const wx = Math.floor(w * 0.55) + ctx.fillStyle = C.shadow; ctx.fillRect(wx - 2, 8, w - wx - 8, 40) + ditherGrad(ctx, wx, 10, w - wx - 12, 36, C.sky2, C.sky0) + for (let x = wx + 4; x < w - 16; x += 12) { ctx.fillStyle = C.bldg; ctx.fillRect(x, 26, 8, 20) ; ctx.fillStyle = C.winDim; ctx.fillRect(x + 2, 30, 2, 2) } + // reversed window lettering + ctx.fillStyle = C.amberD; for (let i = 0; i < 5; i++) ctx.fillRect(wx + 6 + i * 9, 14, 6, 2) + // counter with pie stand and coffee pot + const cy = Math.floor(h * 0.58) + ctx.fillStyle = '#3a6b6b'; ctx.fillRect(0, cy, Math.floor(w * 0.5), 5) + ctx.fillStyle = '#284149'; ctx.fillRect(0, cy + 5, Math.floor(w * 0.5), h - cy - 17) + ctx.fillStyle = C.bone; ctx.fillRect(14, cy - 10, 12, 8); ctx.fillStyle = C.slateL; ctx.fillRect(12, cy - 12, 16, 2) + ctx.fillStyle = _METAL; ctx.fillRect(38, cy - 9, 8, 9); ctx.fillStyle = C.amberD; ctx.fillRect(40, cy - 11, 4, 2) + // stools + for (let i = 0; i < 3; i++) { const x = 10 + i * 22; ctx.fillStyle = C.ox; ctx.fillRect(x, h - 20, 12, 3); ctx.fillStyle = _METAL; ctx.fillRect(x + 5, h - 17, 2, 17) } + // booth by the window + ctx.fillStyle = C.ox; ctx.fillRect(wx + 2, h - 36, 8, 24); ctx.fillRect(w - 22, h - 36, 8, 24) + ctx.fillStyle = '#6b2222'; ctx.fillRect(wx + 2, h - 36, 8, 3); ctx.fillRect(w - 22, h - 36, 8, 3) + ctx.fillStyle = _WOOD; ctx.fillRect(wx + 12, h - 26, w - wx - 36, 4) + ctx.fillStyle = C.bone; ctx.fillRect(wx + 18, h - 30, 8, 4) + // checkered floor + for (let y = h - 12; y < h; y += 6) for (let x = ((y / 6) % 2) * 6; x < w; x += 12) { ctx.fillStyle = '#11181f'; ctx.fillRect(x, y, 6, 6) } + lampCone(ctx, Math.floor(w * 0.25), 6, 40, cy - 10) + rainStreaks(ctx, w - wx, 38, t) +} + +const paintVault: ScenePainter = (ctx, w, h) => { + ditherGrad(ctx, 0, 0, w, h, '#11141c', '#080a10') + // wall of deposit boxes + for (let y = 10; y < h - 40; y += 10) for (let x = 8; x < Math.floor(w * 0.38); x += 12) { + ctx.fillStyle = _METAL; ctx.fillRect(x, y, 10, 8) + ctx.fillStyle = '#3a444e'; ctx.fillRect(x, y, 10, 1) + ctx.fillStyle = C.amberD; ctx.fillRect(x + 4, y + 3, 2, 2) + } + // one box pulled open + ctx.fillStyle = '#05080b'; ctx.fillRect(32, 40, 10, 8) + ctx.fillStyle = _METAL; ctx.fillRect(30, 46, 14, 3) + // massive round vault door ajar on the right + const cx = Math.floor(w * 0.72) + const cy2 = Math.floor(h * 0.45) + for (let r = 26; r > 4; r -= 4) { + ctx.fillStyle = r % 8 ? '#2d3640' : '#3a444e' + ctx.fillRect(cx - r, cy2 - r, r * 2, r * 2) + } + ctx.fillStyle = '#10141a'; ctx.fillRect(cx - 4, cy2 - 4, 8, 8) + // spoke handle + ctx.fillStyle = C.bone + ctx.fillRect(cx - 14, cy2 - 1, 28, 2); ctx.fillRect(cx - 1, cy2 - 14, 2, 28) + // hinge gap of darkness - the door stands open + ctx.fillStyle = '#020304'; ctx.fillRect(cx + 24, cy2 - 28, 10, 56) + // scattered bills on the floor + ctx.fillStyle = '#0b0e14'; ctx.fillRect(0, h - 24, w, 24) + for (let i = 0; i < 6; i++) { const x = 20 + i * Math.floor(w * 0.13); ctx.fillStyle = i % 2 ? '#1d5a2c' : C.bone; ctx.fillRect(x, h - 18 + (i % 3) * 3, 8, 4) } + lampCone(ctx, Math.floor(w * 0.4), 2, 50, h - 30) +} + +export const SCENES: Record = { + skyline: paintSkyline, desk: paintDesk, atrium: paintAtrium, interro: paintInterro, + seawall: paintSeawall, mezzanine: paintMezzanine, map: paintMap, + kitchen: paintKitchen, study: paintStudy, parlor: paintParlor, bedroom: paintBedroom, + alley: paintAlley, bar: paintBar, casino: paintCasino, theater: paintTheater, + warehouse: paintWarehouse, rooftop: paintRooftop, office: paintOffice, lobby: paintLobby, + station: paintStation, garage: paintGarage, chapel: paintChapel, gallery: paintGallery, + cellar: paintCellar, greenhouse: paintGreenhouse, diner: paintDiner, vault: paintVault, +} + +// Map a free-text location name (generated cases invent rooms) to the closest set. +// First match wins - keep the specific venues above the generic interiors. +const _ROOM_MAP: [RegExp, string][] = [ + [/alley|backstreet|side\s*street|passage(way)?\b/i, 'alley'], + [/\bbar\b|tavern|pub\b|saloon|speakeas|taproom|cantina|nightclub|club\s*floor/i, 'bar'], + [/casino|card\s*room|gambling|poker|roulette|betting/i, 'casino'], + [/theat|stage|auditorium|opera|cabaret|backstage|dressing\s*room|rehearsal/i, 'theater'], + [/warehouse|store\s*room|storage|loading|depot|freight|cargo/i, 'warehouse'], + [/rooftop|roof\b|widow'?s\s*walk/i, 'rooftop'], + [/office|bureau|headquarters|newsroom|press\s*room|precinct/i, 'office'], + [/lobby|reception|front\s*desk|concierge|check[-\s]?in/i, 'lobby'], + [/station|platform|train|railway|terminus|tram/i, 'station'], + [/garage|workshop|mechanic|motor|boathouse|shed|barn|stable/i, 'garage'], + [/chapel|church|cathedral|shrine|sanctuary|cloister|vestry/i, 'chapel'], + [/gallery|museum|exhibit|auction|studio|atelier/i, 'gallery'], + [/cellar|basement|wine\b|crypt|undercroft|catacomb|tunnel/i, 'cellar'], + [/vault|safe\s*room|strong\s*room|\bbank\b|deposit|counting\s*house/i, 'vault'], + [/greenhouse|conservatory|garden|orchard|arboretum|nursery\s*garden/i, 'greenhouse'], + [/diner|caf[eé]|coffee|canteen|tea\s*room|bistro|restaurant/i, 'diner'], + [/kitchen|pantry|galley|scullery|bakery/i, 'kitchen'], + [/librar|study|den\b|archive|records|reading\s*room/i, 'study'], + [/bed|chamber|boudoir|suite|nursery|dormitor/i, 'bedroom'], + [/mezzanine|\brail\b|balcon|landing|stairwell/i, 'mezzanine'], + [/dock|harbou?r|pier|seawall|wharf|seaside|waterfront|quay|marina|lighthouse/i, 'seawall'], + [/parlou?r|lounge|living|sitting|drawing|salon|terrace|veranda|smoking\s*room/i, 'parlor'], + [/hall|foyer|ballroom|atrium|dining|entrance|stair|court/i, 'atrium'], +] + +export function sceneForRoom(name: string): ScenePainter { + for (const [re, key] of _ROOM_MAP) if (re.test(name || '')) return SCENES[key] + return SCENES.parlor // generic interior +} + +// ---- exhibit illustrations ---- +// Each exhibit gets a procedural "evidence photo": the object, large, on a forensic +// table under a spot, with a measuring strip. The kind is read from the exhibit's +// own words so a letter looks like a letter and a vial looks like a vial. +export type ExhibitKind = + | 'letter' | 'photo' | 'ledger' | 'key' | 'blade' | 'bottle' | 'watch' | 'fabric' + | 'flame' | 'ticket' | 'jewel' | 'phone' | 'recorder' | 'print' | 'cash' | 'rope' + | 'boot' | 'bag' + +// ORDER MATTERS - specific kinds sit above broad word-matches so "letter opener" is a +// blade (not a letter), "matchbook" is a flame (not a book), "footprint" is a boot +// (not a fingerprint), and "recording" is a tape (not a ledger record). +const _EX_RULES: [RegExp, ExhibitKind][] = [ + [/photo|polaroid|portrait|snapshot|negative|film/i, 'photo'], + [/tape|recorder|recording|reel\b|voicemail|dictaphone|cylinder/i, 'recorder'], + [/knife|blade|dagger|letter\s*opener|razor|scissor|shard|glass\s+fragment/i, 'blade'], + [/vial|poison|bottle|flask|tonic|medicine|pill|powder|arsenic|cyanide|decanter|wine|whisky/i, 'bottle'], + [/watch|clock|timepiece/i, 'watch'], + [/match|lighter|accelerant|kerosene|petrol|gasoline|\bash(es)?\b|burn|scorch|cigar|cigarette/i, 'flame'], + [/footprint|boot|shoe|heel|track\b|tread/i, 'boot'], + [/fingerprint|\bprints?\b|smudge|handprint/i, 'print'], + [/keycard|access|badge|pass\b|\bkeys?\b|lockpick|latch/i, 'key'], + [/ring\b|necklace|brooch|jewel|diamond|pearl|locket|pendant|bracelet|gem/i, 'jewel'], + [/phone|telephone|pager|wire\s*tap/i, 'phone'], + [/ticket|stub|pawn|receipt|invoice|bill\b|cheque|check\b|voucher|claim/i, 'ticket'], + [/cash|money|bills|banknote|currency|wad\b|payment|bribe|coin|wallet/i, 'cash'], + [/rope|cord|wire\b|cable|twine|strap|belt/i, 'rope'], + [/glove|scarf|fabric|fibre|fiber|cloth|thread|handkerchief|button|cufflink|coat|shawl|silk/i, 'fabric'], + [/ledger|\bbook\b|journal|diary|register|contract|deed|will\b|manifest|statement|\brecords\b/i, 'ledger'], + [/letter|note\b|envelope|telegram|correspondence|memo|page|paper/i, 'letter'], +] + +export function exhibitKindFor(name: string, summary = ''): ExhibitKind { + const hay = `${name} ${summary}` + for (const [re, kind] of _EX_RULES) if (re.test(hay)) return kind + return 'bag' +} + +function exBase(ctx: CanvasRenderingContext2D, w: number, h: number): void { + ditherGrad(ctx, 0, 0, w, h, '#141a21', '#0a0e13') + lampCone(ctx, Math.floor(w / 2), 0, Math.floor(w * 0.9), h) + // forensic measuring strip + ctx.fillStyle = C.bone; ctx.fillRect(6, h - 7, Math.floor(w * 0.4), 3) + ctx.fillStyle = '#0a0e13' + for (let x = 8; x < 6 + Math.floor(w * 0.4); x += 5) ctx.fillRect(x, h - 7, 1, 3) + // evidence tag in the corner + ctx.fillStyle = C.amberD; ctx.fillRect(w - 20, h - 12, 14, 8) + ctx.fillStyle = '#3a2a10'; ctx.fillRect(w - 18, h - 9, 10, 1); ctx.fillRect(w - 18, h - 7, 7, 1) +} + +type ExPainter = (ctx: CanvasRenderingContext2D, w: number, h: number, s: number) => void + +const _EX_PAINT: Record = { + letter: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 22 + const cy = Math.floor(h / 2) - 14 + // open envelope behind + ctx.fillStyle = '#b3ac96'; ctx.fillRect(cx - 6, cy + 12, 34, 18) + ctx.fillStyle = '#9d977f'; ctx.fillRect(cx - 6, cy + 12, 34, 2) + // folded letter, slightly rotated feel via offset slabs + ctx.fillStyle = C.bone; ctx.fillRect(cx + 8, cy - 6, 36, 26) + ctx.fillStyle = '#fdf9ec'; ctx.fillRect(cx + 8, cy - 6, 36, 3) + ctx.fillStyle = '#3a3428' + for (let i = 0; i < 5; i++) ctx.fillRect(cx + 12, cy + (i * 4), 24 - ((s + i) % 3) * 5, 1) + // wax seal or ink blot + ctx.fillStyle = s % 2 ? C.ox : '#1c222a'; ctx.fillRect(cx + 34, cy + 12, 5, 5) + }, + photo: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 20 + const cy = Math.floor(h / 2) - 17 + ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 44, 38) + ctx.fillStyle = '#10161d'; ctx.fillRect(cx + 4, cy + 4, 36, 24) + // two silhouettes caught in frame + ctx.fillStyle = '#222b35' + ctx.fillRect(cx + 10 + (s % 4), cy + 12, 7, 16); ctx.fillRect(cx + 12 + (s % 4), cy + 8, 4, 5) + ctx.fillRect(cx + 24, cy + 14, 7, 14); ctx.fillRect(cx + 26, cy + 10, 4, 5) + ctx.fillStyle = C.winDim; ctx.fillRect(cx + 34, cy + 6, 3, 3) + // bent corner + ctx.fillStyle = '#9d977f'; ctx.fillRect(cx + 40, cy + 34, 4, 4) + }, + ledger: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 26 + const cy = Math.floor(h / 2) - 14 + ctx.fillStyle = '#241a10'; ctx.fillRect(cx - 2, cy - 2, 56, 32) + ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 26, 28); ctx.fillRect(cx + 27, cy, 25, 28) + ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 26, cy, 1, 28) + ctx.fillStyle = '#3a3428' + for (let i = 0; i < 6; i++) { ctx.fillRect(cx + 3, cy + 4 + i * 4, 20, 1); ctx.fillRect(cx + 30, cy + 4 + i * 4, 19, 1) } + // the flagged entry + ctx.fillStyle = C.ox; ctx.fillRect(cx + 30, cy + 4 + ((s % 4) + 1) * 4 - 1, 19, 3) + }, + key: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 18 + const cy = Math.floor(h / 2) - 2 + if (s % 2) { + // hotel key with fob + ctx.fillStyle = '#b8b7ad'; ctx.fillRect(cx, cy - 3, 26, 4) + ctx.fillRect(cx + 24, cy + 1, 3, 6); ctx.fillRect(cx + 18, cy + 1, 3, 4) + ctx.fillStyle = '#8d8c82'; ctx.fillRect(cx - 8, cy - 7, 10, 10) + ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - 5, cy - 4, 4, 4) + ctx.fillStyle = C.ox; ctx.fillRect(cx + 32, cy - 8, 14, 9) + ctx.fillStyle = C.bone; ctx.fillRect(cx + 35, cy - 5, 8, 3) + } else { + // access card + ctx.fillStyle = '#2d4a52'; ctx.fillRect(cx - 4, cy - 12, 42, 26) + ctx.fillStyle = '#3a6b6b'; ctx.fillRect(cx - 4, cy - 12, 42, 5) + ctx.fillStyle = C.amber; ctx.fillRect(cx, cy - 2, 10, 8) + ctx.fillStyle = C.bone; ctx.fillRect(cx + 14, cy, 18, 2); ctx.fillRect(cx + 14, cy + 4, 12, 2) + } + }, + blade: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 24 + const cy = Math.floor(h / 2) + // blade with taper + ctx.fillStyle = '#c2c8cc' + for (let i = 0; i < 30; i++) ctx.fillRect(cx + i, cy - 3 + (i >> 4), 1, 5 - (i >> 3)) + ctx.fillStyle = '#8a9298'; ctx.fillRect(cx, cy + 1, 28, 1) + // guard + grip + ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 30, cy - 5, 3, 9) + ctx.fillStyle = '#55402a'; ctx.fillRect(cx + 33, cy - 3, 14, 6) + ctx.fillStyle = '#241a10'; for (let i = 0; i < 3; i++) ctx.fillRect(cx + 35 + i * 4, cy - 3, 1, 6) + // a dark stain near the tip when the story calls for it + if (s % 3 !== 1) { ctx.fillStyle = '#4a1414'; ctx.fillRect(cx + 4, cy - 1, 6, 3) } + }, + bottle: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 6 + const cy = Math.floor(h / 2) - 16 + const tint = s % 2 ? '#1d4a2c' : '#2d3a5e' + ctx.fillStyle = tint; ctx.fillRect(cx, cy + 10, 14, 22) + ctx.fillRect(cx + 4, cy + 2, 6, 9) + ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 4, cy - 2, 6, 4) + // liquid line + skull label + ctx.fillStyle = s % 2 ? '#2c5e38' : '#46506b'; ctx.fillRect(cx + 2, cy + 18, 10, 12) + ctx.fillStyle = C.bone; ctx.fillRect(cx + 3, cy + 14, 8, 8) + ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 5, cy + 16, 4, 3); ctx.fillRect(cx + 5, cy + 20, 1, 1); ctx.fillRect(cx + 8, cy + 20, 1, 1) + // spilled drops + ctx.fillStyle = tint; ctx.fillRect(cx + 18, cy + 30, 4, 2); ctx.fillRect(cx + 24, cy + 32, 2, 1) + }, + watch: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 2 + const cy = Math.floor(h / 2) - 2 + // chain + ctx.fillStyle = '#8d8c82' + for (let i = 0; i < 7; i++) ctx.fillRect(cx + 12 + i * 3, cy - 14 + i * 2, 2, 2) + // case rings + for (let r = 14; r > 2; r -= 3) { ctx.fillStyle = r % 6 ? '#b8943e' : '#e3c06d'; ctx.fillRect(cx - r, cy - r, r * 2, r * 2) } + ctx.fillStyle = C.bone; ctx.fillRect(cx - 8, cy - 8, 16, 16) + // hands stopped at the hour of the crime + ctx.fillStyle = '#0a0e13' + ctx.fillRect(cx - 1, cy - 7 + (s % 3), 2, 8); ctx.fillRect(cx - 1, cy, 6 - (s % 3), 2) + // cracked glass + ctx.fillStyle = '#8a9298'; ctx.fillRect(cx + 2, cy - 6, 1, 5); ctx.fillRect(cx + 3, cy - 3, 3, 1) + }, + fabric: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 20 + const cy = Math.floor(h / 2) - 10 + const tint = ['#5e1c1c', '#284149', '#46506b'][s % 3] + // a torn swatch, ragged edges + ctx.fillStyle = tint + for (let y = 0; y < 22; y++) { + const ragL = (y * 7 + s) % 4 + const ragR = (y * 5 + s) % 5 + ctx.fillRect(cx + ragL, cy + y, 38 - ragL - ragR, 1) + } + // weave lines + a loose thread + ctx.fillStyle = '#00000033' + for (let y = 2; y < 20; y += 3) ctx.fillRect(cx + 3, cy + y, 32, 1) + ctx.fillStyle = tint + ctx.fillRect(cx + 38, cy + 22, 2, 2); ctx.fillRect(cx + 41, cy + 25, 2, 1); ctx.fillRect(cx + 44, cy + 26, 3, 1) + // monogram patch + ctx.fillStyle = C.amber; ctx.fillRect(cx + 14, cy + 8, 8, 6) + ctx.fillStyle = '#3a2a10'; ctx.fillRect(cx + 16, cy + 10, 4, 2) + }, + flame: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 16 + const cy = Math.floor(h / 2) - 8 + if (s % 2) { + // matchbook, one match torn out + ctx.fillStyle = C.ox; ctx.fillRect(cx, cy, 26, 18) + ctx.fillStyle = '#b8443f'; ctx.fillRect(cx, cy, 26, 4) + ctx.fillStyle = C.bone; ctx.fillRect(cx + 4, cy + 7, 18, 7) + ctx.fillStyle = '#3a2c1c'; for (let i = 0; i < 5; i++) ctx.fillRect(cx + 5 + i * 4, cy + 8, 2, 5) + ctx.fillStyle = '#3a2c1c'; ctx.fillRect(cx + 32, cy + 6, 2, 10); ctx.fillStyle = C.amber; ctx.fillRect(cx + 31, cy + 3, 4, 4) + } else { + // scorched tin of accelerant + ctx.fillStyle = '#8a4a1e'; ctx.fillRect(cx + 2, cy - 4, 20, 26) + ctx.fillStyle = '#a8602a'; ctx.fillRect(cx + 2, cy - 4, 20, 3) + ctx.fillStyle = '#241208'; ctx.fillRect(cx + 6, cy + 2, 12, 12) + ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - 4, cy + 20, 34, 4) + ctx.fillStyle = '#1c1209'; ctx.fillRect(cx + 24, cy + 16, 10, 6) + } + }, + ticket: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 24 + const cy = Math.floor(h / 2) - 9 + ctx.fillStyle = '#cfc0a0'; ctx.fillRect(cx, cy, 48, 20) + ctx.fillStyle = '#b3a585'; ctx.fillRect(cx, cy, 48, 3) + // perforation + torn half + ctx.fillStyle = '#0a0e13'; for (let y = cy; y < cy + 20; y += 3) ctx.fillRect(cx + 33, y, 1, 2) + ctx.fillStyle = '#3a3428' + ctx.fillRect(cx + 4, cy + 7, 24, 2); ctx.fillRect(cx + 4, cy + 12, 16 + (s % 3) * 3, 2) + ctx.fillStyle = C.ox; ctx.fillRect(cx + 37, cy + 5, 8, 8) + }, + jewel: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) + const cy = Math.floor(h / 2) - 4 + // necklace chain pooled on the table + ctx.fillStyle = '#e3c06d' + for (let i = 0; i < 14; i++) ctx.fillRect(cx - 20 + i * 3, cy + 10 + Math.floor(Math.sin(i * 0.9 + s) * 3), 2, 2) + // the stone + const gem = ['#7ec8d8', '#c86a7a', '#8ad89a'][s % 3] + ctx.fillStyle = '#b8943e'; ctx.fillRect(cx - 6, cy - 8, 12, 12) + ctx.fillStyle = gem; ctx.fillRect(cx - 4, cy - 6, 8, 8) + ctx.fillStyle = '#ffffff'; ctx.fillRect(cx - 3, cy - 5, 2, 2) + // glints + ctx.fillStyle = C.lamp; ctx.fillRect(cx + 8, cy - 12, 1, 3); ctx.fillRect(cx + 7, cy - 11, 3, 1) + }, + phone: (ctx, w, h) => { + const cx = Math.floor(w / 2) - 14 + const cy = Math.floor(h / 2) - 12 + // candlestick telephone - bright enough to read against the dark table + ctx.fillStyle = '#3a444e'; ctx.fillRect(cx + 6, cy + 22, 20, 5); ctx.fillRect(cx + 12, cy + 6, 8, 17) + ctx.fillStyle = '#5a6470'; ctx.fillRect(cx + 13, cy + 6, 2, 17); ctx.fillRect(cx + 7, cy + 22, 18, 2) + // mouthpiece horn + ctx.fillStyle = '#3a444e'; ctx.fillRect(cx + 6, cy - 2, 20, 8) + ctx.fillStyle = '#5a6470'; ctx.fillRect(cx + 8, cy - 1, 16, 2) + ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 12, cy + 1, 8, 4) + // rotary dial face + ctx.fillStyle = C.bone; ctx.fillRect(cx + 12, cy + 14, 8, 6) + ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx + 15, cy + 16, 2, 2) + // earpiece on its side hook + coiled cord + ctx.fillStyle = '#3a444e'; ctx.fillRect(cx - 6, cy + 2, 8, 5); ctx.fillRect(cx - 4, cy + 7, 5, 10) + ctx.fillStyle = '#5a6470'; ctx.fillRect(cx - 5, cy + 3, 6, 2) + ctx.fillStyle = '#8a9298' + for (let i = 0; i < 6; i++) ctx.fillRect(cx - 2 + ((i % 2) * 3), cy + 18 + i * 2, 2, 2) + }, + recorder: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 22 + const cy = Math.floor(h / 2) - 12 + ctx.fillStyle = '#2a2f39'; ctx.fillRect(cx, cy, 44, 26) + ctx.fillStyle = '#3a414e'; ctx.fillRect(cx, cy, 44, 4) + // twin reels + for (const rx of [cx + 11, cx + 31]) { + ctx.fillStyle = '#0a0e13'; ctx.fillRect(rx - 7, cy + 8, 14, 14) + ctx.fillStyle = '#43301f'; ctx.fillRect(rx - 5, cy + 10, 10, 10) + ctx.fillStyle = C.bone; ctx.fillRect(rx - 1, cy + 14, 2, 2) + } + // tape strand + rec light + ctx.fillStyle = '#43301f'; ctx.fillRect(cx + 13, cy + 9, 18, 1) + ctx.fillStyle = s % 2 ? C.ox : '#4a1414'; ctx.fillRect(cx + 40, cy + 2, 2, 2) + }, + print: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 18 + const cy = Math.floor(h / 2) - 14 + // print card + ctx.fillStyle = C.bone; ctx.fillRect(cx, cy, 28, 30) + ctx.fillStyle = '#0a0e13' + // whorl + for (let r = 9; r > 1; r -= 2) ctx.fillRect(cx + 14 - r, cy + 14 - r + (s % 2), r * 2, 1) + for (let r = 8; r > 1; r -= 2) ctx.fillRect(cx + 14 - r, cy + 14 + r, r * 2, 1) + ctx.fillRect(cx + 6, cy + 10, 1, 9); ctx.fillRect(cx + 22, cy + 9, 1, 9) + // magnifier over the corner + ctx.fillStyle = '#8d8c82'; ctx.fillRect(cx + 22, cy + 18, 14, 2) + for (let r = 8; r > 5; r--) { ctx.fillStyle = '#b8b7ad'; ctx.fillRect(cx + 30 - r, cy + 12 - r, r * 2, r * 2) } + ctx.fillStyle = '#1d2832aa'; ctx.fillRect(cx + 25, cy + 7, 10, 10) + }, + cash: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 22 + const cy = Math.floor(h / 2) - 8 + // stacked banded bills + for (let i = 0; i < 3; i++) { + const y = cy + 12 - i * 6 + ctx.fillStyle = i % 2 ? '#1d5a2c' : '#14401f'; ctx.fillRect(cx + i * 3, y, 36, 6) + ctx.fillStyle = '#2c5e38'; ctx.fillRect(cx + i * 3, y, 36, 1) + ctx.fillStyle = C.bone; ctx.fillRect(cx + i * 3 + 14, y, 8, 6) + } + // loose bill + coins + ctx.fillStyle = '#1d5a2c'; ctx.fillRect(cx + 40, cy + 14, 14, 7) + ctx.fillStyle = C.bone; ctx.fillRect(cx + 45, cy + 16, 4, 3) + ctx.fillStyle = '#e3c06d'; ctx.fillRect(cx - 8, cy + 18, 4, 4); ctx.fillRect(cx - 12, cy + 20 + (s % 2), 4, 4) + }, + rope: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) + const cy = Math.floor(h / 2) + 2 + // coiled loops + for (let r = 16; r > 4; r -= 4) { + ctx.fillStyle = r % 8 ? '#8a6a3a' : '#a8854a' + ctx.fillRect(cx - r, cy - Math.floor(r / 2), r * 2, Math.floor(r / 1.2)) + ctx.fillStyle = '#0a0e13'; ctx.fillRect(cx - r + 3, cy - Math.floor(r / 2) + 3, (r - 3) * 2, Math.floor(r / 1.4) - 4) + } + ctx.fillStyle = '#8a6a3a'; ctx.fillRect(cx - 4, cy - 2, 8, 6) + // frayed end trailing off + ctx.fillStyle = '#a8854a' + for (let i = 0; i < 10; i++) ctx.fillRect(cx + 14 + i * 2, cy + 8 + ((i + s) % 3), 2, 2) + ctx.fillStyle = '#6b4f2a'; ctx.fillRect(cx + 33, cy + 8, 1, 4); ctx.fillRect(cx + 35, cy + 9, 1, 3) + }, + boot: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 12 + const cy = Math.floor(h / 2) - 14 + // plaster cast slab + ctx.fillStyle = '#3a3022'; ctx.fillRect(cx - 8, cy - 2, 42, 34) + ctx.fillStyle = '#4a3e2c'; ctx.fillRect(cx - 8, cy - 2, 42, 3) + // boot tread pressed in + ctx.fillStyle = '#241c12' + ctx.fillRect(cx + 4, cy + 2, 16, 18); ctx.fillRect(cx + 6, cy + 22, 12, 6) + ctx.fillStyle = '#15100a' + for (let i = 0; i < 4; i++) ctx.fillRect(cx + 6, cy + 4 + i * 4, 12, 2) + ctx.fillRect(cx + 7, cy + 23, 10, 2) + // the worn-heel notch that gives it away + ctx.fillStyle = '#4a3e2c'; ctx.fillRect(cx + 6 + (s % 3) * 3, cy + 24, 4, 3) + }, + bag: (ctx, w, h, s) => { + const cx = Math.floor(w / 2) - 18 + const cy = Math.floor(h / 2) - 12 + // sealed evidence bag with a small dark object inside + ctx.fillStyle = '#c8d0d4'; ctx.fillRect(cx, cy, 36, 28) + ctx.fillStyle = '#e2e8ea'; ctx.fillRect(cx, cy, 36, 5) + ctx.fillStyle = C.ox; ctx.fillRect(cx, cy + 4, 36, 2) + ctx.fillStyle = '#2a2f39'; ctx.fillRect(cx + 10 + (s % 3) * 2, cy + 12, 14, 9) + ctx.fillStyle = '#15181d'; ctx.fillRect(cx + 12 + (s % 3) * 2, cy + 14, 10, 5) + ctx.fillStyle = '#8a9298'; ctx.fillRect(cx + 4, cy + 24, 28, 1) + }, +} + +/** Procedural painter for one exhibit, seeded by its id so each looks distinct. */ +export function exhibitPainter(name: string, summary: string, seedKey: string): ScenePainter { + const kind = exhibitKindFor(name, summary) + const s = _hash(seedKey || name) + return (ctx, w, h) => { + exBase(ctx, w, h) + _EX_PAINT[kind](ctx, w, h, s) + } +} + +export function sceneFor(name: string): ScenePainter { + if (SCENES[name]) return SCENES[name] + // "Building — Room" -> key the painter off the room (the part after the last dash). + const room = /[—–]/.test(name) ? name.split(/[—–]/).pop()!.trim() : name + return sceneForRoom(room) +}