aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/gacha.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/gacha.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/gacha.js')
-rw-r--r--bluebey-studio/src/gacha.js481
1 files changed, 481 insertions, 0 deletions
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,
+ },
+ };
+}