case0 / web /src /engine /art.ts
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
raw
history blame
32.2 kB
// 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<string, string> = {
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<number, [number, number]> = {
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<string, string> = {
[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<string, Sprite> = {
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<string, Sprite> = {
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<string, string> = {
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<string, string[]> = {
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)
}
export const SCENES: Record<string, ScenePainter> = {
skyline: paintSkyline, desk: paintDesk, atrium: paintAtrium, interro: paintInterro,
seawall: paintSeawall, mezzanine: paintMezzanine, map: paintMap,
kitchen: paintKitchen, study: paintStudy, parlor: paintParlor, bedroom: paintBedroom,
}
// Map a free-text location name (generated cases invent rooms) to the closest interior.
const _ROOM_MAP: [RegExp, string][] = [
[/kitchen|pantry|galley/i, 'kitchen'],
[/librar|study|office|den|archive/i, 'study'],
[/cellar|basement|wine|vault/i, 'study'],
[/bed|chamber|boudoir|suite|nursery/i, 'bedroom'],
[/mezzanine|\brail\b|balcon/i, 'mezzanine'],
[/dock|harbou?r|pier|seawall|wharf|seaside|waterfront/i, 'seawall'],
[/parlou?r|lounge|living|sitting|drawing|salon|conservatory|garden|terrace|greenhouse/i, 'parlor'],
[/hall|foyer|ballroom|atrium|gallery|lobby|dining|entrance|stair|landing|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
}
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)
}