From bc2821adb11a30244fc4663f4fafb756877d9508 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:09:40 +0900 Subject: bluebey-studio: public/ の外へ移動し非公開化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bluebey-studio/src/gacha.js | 481 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 bluebey-studio/src/gacha.js (limited to 'bluebey-studio/src/gacha.js') diff --git a/bluebey-studio/src/gacha.js b/bluebey-studio/src/gacha.js new file mode 100644 index 0000000..c44f80f --- /dev/null +++ b/bluebey-studio/src/gacha.js @@ -0,0 +1,481 @@ +/** + * 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.16, 0.28], + 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.08, 0.2], + }, + { + 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.1, 0.24], + 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 leftShape = 'open'; + let rightShape = 'open'; + 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]); + // A shut eye is the artwork with the lids open now; the lid height is its own + // slider, so `open` is 1 and the shape says which line it is. + if (side === 'left') { + leftOpen = 1; + leftLines = lines; + leftShape = `line${lines}`; + } else { + rightOpen = 1; + rightLines = lines; + rightShape = `line${lines}`; + } + } else if (spec.shutBoth && chance(random, spec.shutBoth.chance)) { + leftOpen = 1; + rightOpen = 1; + leftClosed = spec.shutBoth.closed; + rightClosed = spec.shutBoth.closed; + leftLines = spec.shutBoth.closedLines; + rightLines = spec.shutBoth.closedLines; + const shape = spec.shutBoth.closed === 'line' + ? `line${spec.shutBoth.closedLines}` + : spec.shutBoth.closed; + leftShape = shape; + rightShape = shape; + } + + const lookX = round2(rand(random, spec.lookX[0], spec.lookX[1])); + const lookY = round2(rand(random, spec.lookY[0], spec.lookY[1])); + + return { + left: { shape: leftShape, open: leftOpen, lookX, lookY, closed: leftClosed, closedLines: leftLines, irisShape: 'circle' }, + right: { shape: rightShape, 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; + eyes.left.tearOn = true; + eyes.right.tearOn = true; + } + + 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.left.shape = 'heart'; + eyes.right.shape = '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, + }, + }; +} -- cgit v1.3.1