// Pure half of the paper-doll renderer: Patron -> list of coloured rects. // No Phaser imports โ€” fully unit-testable, deterministic per (patron, pose). // doll.ts paints these plans into textures; real pixel art later replaces the // painter while THIS data contract (and renderDoll's signature) stays put. import { PALETTE } from '../data/outfits'; import { drunkStage } from '../rules/drunk'; import type { Patron } from '../data/types'; export type DollPose = 'queue' | 'idPhoto' | 'floorTopDown'; export interface PlanRect { x: number; y: number; w: number; h: number; colour: number; } export interface DollPlan { width: number; height: number; rects: PlanRect[]; /** render-time tell: sway amplitude in px (0 = steady) */ swayPx: number; } export const DOLL_SIZES: Record = { queue: { width: 32, height: 48 }, idPhoto: { width: 24, height: 24 }, floorTopDown: { width: 16, height: 16 }, }; const SKIN_TONES = [0xf1c9a5, 0xd9a066, 0xa5673f, 0x6b4423, 0x8d5524, 0xe8b088] as const; // A tiny deterministic hash so body build/skin derive from dollSeed without // consuming RNG stream state (plans must be reproducible in isolation). function mix(seed: number, salt: number): number { let h = (seed ^ (salt * 0x9e3779b9)) >>> 0; h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0; h = Math.imul(h ^ (h >>> 13), 0x45d9f3b) >>> 0; return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } const colourOf = (name: string): number => PALETTE[name] ?? 0xff00ff; function layerColour(p: Patron, slot: string): number | undefined { const layer = p.outfit.find((l) => l.slot === slot); if (!layer || layer.type === 'none') return undefined; return colourOf(layer.colour); } export function dollPlan(p: Patron, pose: DollPose): DollPlan { const { width, height } = DOLL_SIZES[pose]; const skin = SKIN_TONES[Math.floor(mix(p.dollSeed, 1) * SKIN_TONES.length)]!; const build = 0.8 + mix(p.dollSeed, 2) * 0.4; // 0.8..1.2 body width factor const stage = drunkStage(p.intoxication); const swayPx = stage === 'loose' ? 1 : stage === 'messy' ? 2 : stage === 'maggot' ? 3 : 0; const rects: PlanRect[] = []; const seedForPhoto = pose === 'idPhoto' ? p.idCard.photoSeed : p.dollSeed; const photoSkin = SKIN_TONES[Math.floor(mix(seedForPhoto, 1) * SKIN_TONES.length)]!; const hair = layerColour(p, 'hair') ?? 0x14141c; const photoHair = pose === 'idPhoto' && p.idCard.photoSeed !== p.dollSeed ? (Object.values(PALETTE)[Math.floor(mix(seedForPhoto, 3) * 10)] ?? hair) : hair; if (pose === 'idPhoto') { // Head-and-shoulders on a flat card background. rects.push({ x: 0, y: 0, w: width, h: height, colour: 0xdad4c4 }); rects.push({ x: 7, y: 6, w: 10, h: 10, colour: photoSkin }); // face rects.push({ x: 6, y: 3, w: 12, h: 4, colour: photoHair }); // hair rects.push({ x: 5, y: 16, w: 14, h: 8, colour: layerColour(p, 'top') ?? 0x555555 }); // shoulders return { width, height, rects, swayPx: 0 }; } if (pose === 'floorTopDown') { // Seen from above: hair blob over shoulders (top colour). const top = layerColour(p, 'outer') ?? layerColour(p, 'top') ?? 0x555555; rects.push({ x: 3, y: 3, w: 10, h: 10, colour: top }); // shoulders rects.push({ x: 5, y: 5, w: 6, h: 6, colour: hair }); // head/hair return { width, height, rects, swayPx }; } // queue pose โ€” full front-facing figure, 32x48. const bw = Math.round(12 * build); // body width const cx = Math.floor(width / 2); const shoes = layerColour(p, 'shoes') ?? 0x14141c; const legs = layerColour(p, 'legs') ?? 0x333344; const top = layerColour(p, 'top') ?? 0x555566; const outer = layerColour(p, 'outer'); const acc = p.outfit.find((l) => l.slot === 'accessory'); rects.push({ x: cx - 4, y: 44, w: 3, h: 4, colour: shoes }); // L shoe rects.push({ x: cx + 1, y: 44, w: 3, h: 4, colour: shoes }); // R shoe rects.push({ x: cx - 4, y: 30, w: 8, h: 14, colour: legs }); // legs rects.push({ x: cx - Math.floor(bw / 2), y: 16, w: bw, h: 14, colour: top }); // torso if (outer !== undefined) { rects.push({ x: cx - Math.floor(bw / 2) - 1, y: 16, w: 3, h: 13, colour: outer }); // jacket L rects.push({ x: cx + Math.floor(bw / 2) - 2, y: 16, w: 3, h: 13, colour: outer }); // jacket R } const topLayer = p.outfit.find((l) => l.slot === 'top'); if (topLayer?.logo) rects.push({ x: cx - 2, y: 20, w: 4, h: 3, colour: 0xffffff }); // visible logo rects.push({ x: cx - 3, y: 6, w: 6, h: 9, colour: skin }); // face rects.push({ x: cx - 4, y: 3, w: 8, h: 4, colour: hair }); // hair const hairLayer = p.outfit.find((l) => l.slot === 'hair'); if (hairLayer?.type === 'long') rects.push({ x: cx - 5, y: 6, w: 2, h: 8, colour: hair }); if (hairLayer?.type === 'mullet') rects.push({ x: cx - 4, y: 12, w: 8, h: 3, colour: hair }); if (acc && acc.type !== 'none') { const accColour = colourOf(acc.colour); if (acc.type === 'bucketHat' || acc.type === 'cap') rects.push({ x: cx - 5, y: 2, w: 10, h: 3, colour: accColour }); if (acc.type === 'sunnies') rects.push({ x: cx - 3, y: 8, w: 6, h: 2, colour: 0x000000 }); if (acc.type === 'bumbag') rects.push({ x: cx - 5, y: 28, w: 10, h: 3, colour: accColour }); if (acc.type === 'chain') rects.push({ x: cx - 2, y: 16, w: 4, h: 1, colour: 0xf0d060 }); } // drunk tells: bloodshot eye pixels from 'loose' up if (swayPx > 0) { rects.push({ x: cx - 2, y: 9, w: 1, h: 1, colour: 0xcc3333 }); rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 }); } // The disguise (design ยง4.1): a grudged regular's third appearance wears a // fake moustache and a big pair of NOT-sunnies. Deliberately absent from the // idPhoto pose โ€” his card still shows the clean-shaven bloke you knocked // back on Thursday. Same walk. Same name. "Different" man. if (p.flags.disguised === true) { rects.push({ x: cx - 2, y: 11, w: 5, h: 1, colour: 0x2a1c10 }); // moustache rects.push({ x: cx - 3, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses L rects.push({ x: cx + 1, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses R rects.push({ x: cx - 1, y: 8, w: 2, h: 1, colour: 0x101018 }); // bridge } return { width, height, rects, swayPx }; }