138 lines
6.2 KiB
TypeScript
138 lines
6.2 KiB
TypeScript
// 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<DollPose, { width: number; height: number }> = {
|
|
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 };
|
|
}
|