aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/gacha.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/gacha.js')
-rw-r--r--public/bluebey-studio/src/gacha.js481
1 files changed, 0 insertions, 481 deletions
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,
- },
- };
-}