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 --- public/bluebey-studio/src/gacha.js | 481 ------------------------------------- 1 file changed, 481 deletions(-) delete mode 100644 public/bluebey-studio/src/gacha.js (limited to 'public/bluebey-studio/src/gacha.js') diff --git a/public/bluebey-studio/src/gacha.js b/public/bluebey-studio/src/gacha.js deleted file mode 100644 index c44f80f..0000000 --- a/public/bluebey-studio/src/gacha.js +++ /dev/null @@ -1,481 +0,0 @@ -/** - * 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