/** * Seeded "おまかせ" rolls for the face and the pose. * * The studio is a hundred sliders, and that is exactly the problem: when you sit * down to make an expression you reach for the same three settings every time. * This module is the antidote - one press and a stranger picks the numbers for * you, from ranges a person would actually dial in, so the result is plausible * rather than a mash of the full slider span. * * Two ideas hold it together: * * - A roll picks an *emotion* first (ふつう, うれしい, びっくり, ...), then samples * every part inside that emotion's window. Correlated parts read as one face; * an independent draw per slider reads as noise. * * - The randomness is seeded, and the seed is a short base36 string that fits in * a URL. `rollAll(seed)` is a pure function of that string, so a look the user * liked can be reproduced and shared (`?seed=...`) instead of lost. A "seed" * is either a number or text; text that is already a base36 number is read as * one, so `decodeSeed(seed)` is always accepted back by `makeRandom`. * * The face and pose vocabulary, and every bound quoted below, follows * `src/presets.js` and the slider ranges in `src/panel.js`. Nothing here touches * the DOM or the app state: the functions return patches for `applyPatch`. */ /** * FNV-1a over UTF-16 code units, for text that is not already a seed. */ function fnv1a(text) { let hash = 0x811c9dc5; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return hash >>> 0; } /** * Normalise any seed to a 32-bit integer. Text that is already a base36 number * is read as one - that is what `decodeSeed` emits, so it has to come back in * unchanged - and anything else is hashed. `hashSeed`, `makeRandom` and * `decodeSeed` all go through this, so the four entry points never disagree. */ function toSeedInt(seed) { if (typeof seed === 'number' && Number.isFinite(seed)) return Math.trunc(seed) >>> 0; const text = String(seed ?? '').trim(); if (/^[0-9a-z]+$/.test(text)) { const parsed = parseInt(text, 36); if (Number.isFinite(parsed)) return parsed >>> 0; } return fnv1a(text); } /** * Turn text into a stable 32-bit seed (for `?seed=` in a URL). * * A word becomes an FNV-1a hash; text that already looks like a base36 seed is * passed through, so `decodeSeed`'s output round-trips. */ export function hashSeed(text) { return toSeedInt(text); } /** * A seeded PRNG (mulberry32). Returns `() => number` in `[0, 1)`. * Accepts a number or a string; strings go through the same rule as `decodeSeed`. */ export function makeRandom(seed) { let state = toSeedInt(seed); return function random() { state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** * The short, readable form of a seed (base36), for the "現在のシード" field. * Idempotent: `decodeSeed(decodeSeed(x)) === decodeSeed(x)`, and the result is * accepted straight back by `makeRandom`. */ export function decodeSeed(seed) { return (toSeedInt(seed) >>> 0).toString(36); } function rand(random, min, max) { return min + random() * (max - min); } /** An angle in whole degrees, so the pose matches the step-1 sliders. */ function deg(random, min, max) { return Math.round(rand(random, min, max)); } function round2(value) { return Math.round(value * 100) / 100; } function pick(random, list) { return list[Math.floor(random() * list.length)]; } function chance(random, probability) { return random() < probability; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } /** * The mood table. Each entry samples only inside a range that reads as that * emotion, which is what keeps the parts agreeing with each other. * * Fields: `eyes` (open/irisScale/lookMax/lookX/lookY, all in slider units), * `mouth` (smile/open/round/corners/cornerAngle/tilt/offsetY/tongue/width/ * thickness), plus optional `tear`, `lowerLid`, `heart`, `wink` and `shutBoth`. */ const MOODS = [ { id: 'normal', eyes: { open: [0.92, 1], irisScale: [0.95, 1.06], lookMax: [0.55, 1], lookX: [-0.28, 0.28], lookY: [-0.16, 0.16], }, mouth: { smile: [0.62, 0.95], open: [0, 0.05], width: [0.9, 1.1], thickness: [0.9, 1.15], corners: [0.8, 1.2], cornerAngle: [-6, 6], tilt: [-4, 4], offsetY: [-5, 6], tongue: [0, 1.1], }, }, { id: 'happy', eyes: { open: [0.88, 1], irisScale: [0.96, 1.08], lookMax: [0.5, 0.95], lookX: [-0.3, 0.3], lookY: [-0.2, 0.05], }, mouth: { smile: [1.05, 1.3], open: [0.02, 0.22], width: [0.95, 1.2], thickness: [0.9, 1.15], corners: [1.05, 1.4], cornerAngle: [0, 12], tilt: [-4, 6], offsetY: [-8, 2], tongue: [0.9, 1.35], }, }, { id: 'surprised', eyes: { open: [0.95, 1], irisScale: [0.8, 0.9], lookMax: [0.3, 0.5], lookX: [-0.15, 0.15], lookY: [-0.1, 0.1], }, mouth: { smile: [0, 0.2], open: [0.1, 0.32], round: [0.55, 0.8], width: [0.82, 0.98], thickness: [1, 1.2], corners: [0, 0.15], cornerAngle: [-3, 3], tilt: [-3, 3], offsetY: [-6, 6], tongue: [0, 0.4], }, }, { id: 'sad', eyes: { open: [0.55, 0.75], irisScale: [0.95, 1.05], lookMax: [0.45, 0.85], lookX: [-0.2, 0.2], lookY: [-0.35, -0.12], }, mouth: { smile: [-0.7, -0.4], open: [0, 0.05], width: [0.68, 0.88], thickness: [1.05, 1.3], corners: [0, 0.1], cornerAngle: [-4, 4], tilt: [-5, 5], offsetY: [6, 16], tongue: [0, 0.1], }, lowerLid: [0.3, 0.5], tear: [0.6, 1.2], tearChance: 1, }, { id: 'angry', eyes: { open: [0.55, 0.7], irisScale: [0.82, 0.92], lookMax: [0.5, 0.9], lookX: [-0.15, 0.15], lookY: [-0.16, 0.02], }, mouth: { smile: [-0.85, -0.5], open: [0, 0.04], width: [0.78, 0.94], thickness: [1.05, 1.3], corners: [0, 0.05], cornerAngle: [-6, 6], tilt: [-6, 6], offsetY: [8, 18], tongue: [0, 0.05], }, lowerLid: [0.15, 0.35], }, { id: 'love', eyes: { open: [1, 1], irisScale: [0.9, 1.02], lookMax: [0.4, 0.8], lookX: [-0.16, 0.16], lookY: [-0.36, -0.2], }, mouth: { smile: [1.15, 1.3], open: [0.08, 0.3], width: [0.98, 1.18], thickness: [0.98, 1.2], corners: [1.2, 1.4], cornerAngle: [4, 14], tilt: [2, 8], offsetY: [-2, 6], tongue: [1.1, 1.4], }, heart: true, }, { id: 'sleepy', eyes: { open: [0.32, 0.5], irisScale: [0.95, 1.05], lookMax: [0.4, 0.7], lookX: [-0.18, 0.18], lookY: [-0.26, -0.08], }, mouth: { smile: [0.5, 0.82], open: [0, 0.08], width: [0.82, 1], thickness: [0.92, 1.1], corners: [0.5, 0.9], cornerAngle: [-4, 4], tilt: [-7, -1], offsetY: [4, 13], tongue: [0.3, 0.8], }, lowerLid: [0.2, 0.45], tear: [0.2, 0.5], tearChance: 0.5, shutBoth: { chance: 0.4, closed: 'line', closedLines: 1 }, }, { id: 'wink', eyes: { open: [0.95, 1], irisScale: [0.95, 1.05], lookMax: [0.5, 0.9], lookX: [-0.2, 0.2], lookY: [-0.15, 0.1], }, mouth: { smile: [1, 1.3], open: [0.02, 0.18], width: [0.95, 1.15], thickness: [0.92, 1.12], corners: [0.9, 1.2], cornerAngle: [4, 12], tilt: [-5, 5], offsetY: [-6, 4], tongue: [0.8, 1.25], }, wink: true, }, ]; /** * Sample the eyes. Both eyes normally agree (a tiny jitter keeps the face from * looking printed); a wink shuts exactly one, and ねむい sometimes shuts both. * A shut-eye style below 1 line is only ever picked when that eye is closed. */ function rollEyes(random, spec) { const [openMin, openMax] = spec.open; let leftOpen = round2(rand(random, openMin, openMax)); let rightOpen = round2(clamp(leftOpen + rand(random, -0.06, 0.06), 0, 1)); let leftClosed = 'line'; let rightClosed = 'line'; let leftLines = 1; let rightLines = 1; if (spec.wink) { const side = chance(random, 0.5) ? 'left' : 'right'; const lines = pick(random, [2, 3]); if (side === 'left') { leftOpen = 0; leftLines = lines; } else { rightOpen = 0; rightLines = lines; } } else if (spec.shutBoth && chance(random, spec.shutBoth.chance)) { leftOpen = 0; rightOpen = 0; leftClosed = spec.shutBoth.closed; rightClosed = spec.shutBoth.closed; leftLines = spec.shutBoth.closedLines; rightLines = spec.shutBoth.closedLines; } const lookX = round2(rand(random, spec.lookX[0], spec.lookX[1])); const lookY = round2(rand(random, spec.lookY[0], spec.lookY[1])); return { left: { open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' }, right: { open: rightOpen, lookX, lookY, closed: rightClosed, closedLines: rightLines, irisShape: 'circle' }, irisScale: round2(rand(random, spec.irisScale[0], spec.irisScale[1])), lookMax: round2(rand(random, spec.lookMax[0], spec.lookMax[1])), highlight: true, }; } /** Sample the mouth. A round "O" and a smile are mutually exclusive by design. */ function rollMouth(random, spec) { return { visible: true, smile: round2(rand(random, spec.smile[0], spec.smile[1])), open: round2(rand(random, spec.open[0], spec.open[1])), round: spec.round ? round2(rand(random, spec.round[0], spec.round[1])) : 0, width: round2(rand(random, spec.width[0], spec.width[1])), thickness: round2(rand(random, spec.thickness[0], spec.thickness[1])), tilt: Math.round(rand(random, spec.tilt[0], spec.tilt[1])), offsetY: Math.round(rand(random, spec.offsetY[0], spec.offsetY[1])), corners: round2(rand(random, spec.corners[0], spec.corners[1])), cornerAngle: Math.round(rand(random, spec.cornerAngle[0], spec.cornerAngle[1])), tongue: round2(rand(random, spec.tongue[0], spec.tongue[1])), }; } /** * A patch for the `face` section: `{ eyes, mouth }`, ready for * `applyPatch(state.face, patch)`. * * Bounds, in case you are reading this from the tests: * eyes.open 0..1, irisScale 0.8..1.1, lookMax 0.3..1, * lookX -0.35..0.35, lookY -0.45..0.2, * mouth.smile -1..1.4 (negative = frown, only when the eyes agree), * mouth.open 0..0.35, mouth.round 0 or 0.4..0.8, * corners 0..1.4, cornerAngle -30..30, tilt -8..8, offsetY -20..30, * width 0.6..1.3, thickness 0.7..1.4, tongue 0..1.6, * eyes.left/right.tear 0..1.6. */ export function randomFace(random) { const mood = pick(random, MOODS); const eyes = rollEyes(random, mood.eyes); const mouth = rollMouth(random, mood.mouth); // Tears only read on a sad or sleepy face: a frown, or eyes that are half shut. // A fully shut eye never cries. const halfShut = eyes.left.open > 0 && eyes.left.open < 0.78; if (mood.tear && eyes.left.open > 0 && (mouth.smile < 0 || halfShut) && chance(random, mood.tearChance ?? 1)) { // The tears are per eye, so both have to be set. (A crying face wants both; // the shape still allows one eye to cry on its own.) const amount = round2(rand(random, mood.tear[0], mood.tear[1])); eyes.left.tear = amount; eyes.right.tear = amount; } if (mood.lowerLid) { eyes.lowerLid = round2(rand(random, mood.lowerLid[0], mood.lowerLid[1])); } // A heart pupil needs both eyes open, or it reads as a broken iris. if (mood.heart && eyes.left.open >= 0.9 && eyes.right.open >= 0.9) { eyes.left.irisShape = 'heart'; eyes.right.irisShape = 'heart'; eyes.heartScale = round2(rand(random, 0.9, 1.15)); eyes.heartColor = '#e0344f'; eyes.highlight = false; } return { eyes, mouth }; } /** * The pose moves. Each composes one to three bones (never the whole rig) inside * safe limits: arms up to ~74°, master lean up to 24°, root offsets small. * * Bounds: master [-20..24, -28..28, -14..14], armsupport [-18..76, -12..12, * -24..24], arm [-14..20, -8..8, -12..12], legsupport [-8..24, -8..8, -12..12], * root x/z -0.15..0.15 and y 0..0.45. */ const POSE_MOVES = [ // A lean or a nod - one bone, the safest thing in the table. (random) => ({ bones: { master: [deg(random, 6, 24), deg(random, -10, 10), deg(random, -6, 6)] }, }), // A curious tilt to one side. (random) => { const sign = chance(random, 0.5) ? 1 : -1; return { bones: { master: [deg(random, -6, 8), 0, sign * deg(random, 8, 14)] } }; }, // A wave from one arm. (random) => { const side = pick(random, ['l', 'r']); return { bones: { [`armsupport.${side}`]: [deg(random, 42, 74), deg(random, -8, 8), deg(random, -14, 14)], [`arm.${side}`]: [deg(random, 6, 18), 0, 0], }, }; }, // A two-armed cheer, with a small hop. (random) => ({ bones: { master: [deg(random, -14, -2), 0, 0], 'armsupport.l': [deg(random, 52, 74), 0, deg(random, 4, 18)], 'armsupport.r': [deg(random, 52, 74), 0, -deg(random, 4, 18)], }, root: [0, round2(rand(random, 0.05, 0.3)), 0], }), // Introducing something off to one side. (random) => { const side = pick(random, ['l', 'r']); const sign = side === 'l' ? 1 : -1; const other = side === 'l' ? 'r' : 'l'; return { bones: { master: [deg(random, 2, 8), sign * deg(random, 16, 26), 0], [`armsupport.${side}`]: [deg(random, 24, 40), 0, sign * deg(random, 10, 20)], [`armsupport.${other}`]: [deg(random, 2, 14), 0, 0], }, }; }, // A little hop on the spot. (random) => ({ bones: { master: [deg(random, -12, -2), 0, 0], 'legsupport.l': [deg(random, 8, 22), 0, deg(random, -8, 8)], 'legsupport.r': [deg(random, 8, 22), 0, deg(random, -8, 8)], }, root: [0, round2(rand(random, 0.18, 0.42)), 0], }), // Both arms up in a simple stretch. (random) => ({ bones: { 'armsupport.l': [deg(random, 46, 74), 0, deg(random, 4, 16)], 'armsupport.r': [deg(random, 46, 74), 0, -deg(random, 4, 16)], }, }), // A dance sway: one arm leads, the other follows. (random) => { const sign = chance(random, 0.5) ? 1 : -1; return { bones: { master: [deg(random, -4, 4), 0, sign * deg(random, 4, 12)], 'armsupport.l': [deg(random, 30, 46), 0, sign * deg(random, 8, 18)], 'armsupport.r': [deg(random, 12, 26), 0, -sign * deg(random, 8, 18)], }, root: [round2(rand(random, -0.08, 0.08)), 0, 0], }; }, ]; /** A patch for the `pose` section: `{ bones, root }`. */ export function randomPose(random) { const move = pick(random, POSE_MOVES)(random); return { bones: move.bones, root: move.root ?? [0, 0, 0] }; } /** * A patch for the `lookAt` section. About half the time it enables a target off * to one side, so the character glances away instead of staring at the camera. * Bounds: |x| 1.2..3.2 when enabled, y 1.6..3.4, z 2..4.2, amount 0.5..1. */ export function randomLook(random) { if (chance(random, 0.45)) { const sign = chance(random, 0.5) ? 1 : -1; return { lookAt: { enabled: true, x: round2(sign * rand(random, 1.2, 3.2)), y: round2(rand(random, 1.6, 3.4)), z: round2(rand(random, 2, 4.2)), turnBody: chance(random, 0.35), amount: round2(rand(random, 0.5, 1)), }, }; } return { lookAt: { enabled: false, x: 0, y: 2.6, z: 3, turnBody: false, amount: round2(rand(random, 0.6, 1)) }, }; } /** * One press of おまかせ: face, pose and look, all from the same seed. * * @param {number | string} seed a number, or text that `decodeSeed` also accepts * @returns {{ seed: string, patch: { face: object, pose: object, lookAt: object } }} * `seed` is the readable base36 form, for the URL and the seed field. */ export function rollAll(seed) { const random = makeRandom(seed); return { seed: decodeSeed(seed), patch: { face: randomFace(random), pose: randomPose(random), lookAt: randomLook(random).lookAt, }, }; }