aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/presets.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/presets.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/presets.js')
-rw-r--r--bluebey-studio/src/presets.js892
1 files changed, 892 insertions, 0 deletions
diff --git a/bluebey-studio/src/presets.js b/bluebey-studio/src/presets.js
new file mode 100644
index 0000000..e4fe49f
--- /dev/null
+++ b/bluebey-studio/src/presets.js
@@ -0,0 +1,892 @@
+/**
+ * The complete, serialisable studio state plus the preset libraries.
+ *
+ * Everything here is plain JSON: "設定を保存" writes exactly this object, and
+ * "設定を読み込む" merges it back in, so a look can be archived and shared.
+ *
+ * Eye parameters are named after the character's own left/right, NOT the
+ * viewer's: `left` is the eye on the model's +x side, which is the half of the
+ * eye texture with u > 0.5. In a front view that eye appears on the right of
+ * the screen.
+ */
+
+/**
+ * One speech bubble's defaults.
+ *
+ * A factory rather than a constant, because there are two bubbles (see
+ * `caption2`) and they must not share one object - editing one would edit both.
+ */
+function captionDefaults() {
+ return {
+ enabled: false,
+ text: 'こんにちは、ぶるべーです。',
+ // Anchor of the bubble's box, as a fraction of the image (0..1).
+ x: 0.58,
+ y: 0.16,
+ maxWidth: 0.36, // fraction of the image width for the text column
+ bubble: 'round', // round | rect | shout | none
+ tail: 'left', // left | right | top | bottom | topLeft | topRight | bottomLeft | bottomRight | none
+ fontSize: 34, // pixels at 1x; scales with the export
+ lineHeight: 1.42,
+ padding: 18,
+ radius: 24,
+ textColor: '#3f2b52',
+ bubbleColor: '#ffffff',
+ borderColor: '#55386e',
+ borderWidth: 4,
+ bold: false,
+ align: 'left', // left | center | right
+ vertical: false, // 縦書き (columns run right to left)
+ font: 'rounded', // rounded (vendored) | system
+ };
+}
+
+/**
+ * ナレーション (a narration box): a plain rectangular panel of vertical text, the
+ * manga narration style. Same shape as a bubble, with a rect box, no tail, and
+ * 縦書き on by default.
+ */
+function narrationDefaults() {
+ return {
+ enabled: false,
+ text: 'ここで、ぶるべーは考えた。',
+ x: 0.06,
+ y: 0.08,
+ maxWidth: 0.5,
+ bubble: 'rect',
+ tail: 'none',
+ fontSize: 30,
+ lineHeight: 1.35,
+ padding: 16,
+ radius: 8,
+ textColor: '#2b2433',
+ bubbleColor: '#ffffff',
+ borderColor: '#2b2433',
+ borderWidth: 3,
+ bold: false,
+ align: 'left',
+ vertical: true,
+ font: 'rounded',
+ };
+}
+
+/**
+ * Per-kind starting size and height for ひげ, applied when a kind is picked. The
+ * textured kinds are drawings, so they are drawn at a different scale from the
+ * built-in strokes; a kind with no entry resets to the neutral values.
+ */
+export const BEARD_KIND_DEFAULTS = {
+ scotch: { size: 0.46, offsetY: 74 },
+ kaiser: { size: 1, offsetY: 80, spacing: -10 },
+};
+
+/**
+ * Per-shape starting values for 髪 (headMark), applied when a shape is picked.
+ * `teeth` only matters for ぎざぎざ, so its default is set here rather than on
+ * the shared `size`/`teeth` fields.
+ */
+export const HEAD_MARK_KIND_DEFAULTS = {
+ fringe: { size: 0.83, teeth: 0.06 },
+};
+
+/**
+ * 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or
+ * two opposite cuts) can be made. A wall's position is kept *relative to the
+ * character*, so moving ぶるべー carries its walls along (see src/clip.js).
+ */
+function wallDefaults() {
+ return {
+ on: false,
+ x: 0, y: 0, z: 0, // a point the plane passes through, relative to the character
+ yaw: 0, // turn about Y, degrees
+ tilt: 0, // lean about X, degrees
+ // The guide is the only part of the wall that is ever *drawn* (the wall
+ // itself writes depth but no colour, so it hides what is behind it
+ // without being visible). `guide` shows that rectangle while placing,
+ // which is also when it can be dragged in the viewport.
+ guide: false,
+ size: 1, // the wall's size (a real width and height, not a clip)
+ };
+}
+
+export function defaultState() {
+ return {
+ version: 1,
+ view: {
+ projection: 'persp', // persp | ortho
+ fov: 30,
+ azimuth: 0, // degrees, 0 = looking at the face
+ polar: 76, // degrees from +y, 90 = level with the target
+ distance: 0, // 0 = fit to the model automatically
+ orthoHeight: 0, // 0 = fit to the model automatically
+ targetY: 0, // 0 = centre of the model
+ // The mouse orbit and pan write their own target back here (see
+ // ViewRig.captureInto), so refreshing the view - or sharing a link - keeps
+ // the camera the user actually framed.
+ targetX: 0,
+ targetZ: 0,
+ autoRotate: false,
+ autoRotateSpeed: 0.8,
+ // solid | transparent | preset | image | effect | camera
+ // preset = one of the CC0 backdrops in assets/backgrounds
+ // image = something the user loaded (data URL)
+ // effect = a procedurally drawn manga effect line (no file at all)
+ // camera = the phone's camera, for the AR mode
+ background: 'solid',
+ backgroundColor: '#ffffff',
+ backgroundPreset: 'autumn_park',
+ backgroundEffect: 'focus', // focus | fall | speed | ellipse
+ backgroundImage: null, // data URL, set by 「画像を読み込む」
+ backgroundFit: 'cover', // cover | contain | stretch | tile
+ backgroundBlur: 0, // px
+ backgroundDarken: 0, // 0..1 dark veil, helps the character read
+ backgroundScale: 1,
+ backgroundOffset: { x: 0, y: 0 },
+ // AR: which camera, and whether to mirror (front camera feels mirrored)
+ cameraFacing: 'environment', // environment | user
+ cameraMirror: false,
+ gyro: false, // look around by turning the phone
+ },
+ render: {
+ style: 'real', // real | flat | lineart | outline
+ outline: true,
+ outlineWidth: 0.022,
+ outlineColor: '#2a1e33',
+ paper: '#ffffff',
+ shadow: true,
+ // The soft round blob at the character's feet. Off by default: it sat at the
+ // origin and stayed behind when the character was moved. See look.js.
+ contactBlob: false,
+ pngScale: 2,
+ pngTransparent: false,
+ svgWidth: 2048,
+ lightAzimuth: -38,
+ lightElevation: 40,
+ lightIntensity: 2.1,
+ ambient: 0.9,
+ // The colour of the main light and of the ambient (sky) light.
+ lightColor: '#ffffff',
+ ambientColor: '#ffffff',
+ exposure: 1,
+ // Body colours. A theme fills these in; the colour pickers then adjust
+ // them one part at a time, so a theme is a starting point, not a mode.
+ theme: 'original',
+ colors: {
+ body: '#c8b0f0',
+ accent: '#8a4fe0',
+ nose: '#7a4fb0',
+ leaf: '#a6dd6a',
+ vein: '#7fbf3f',
+ feet: '#7a4fb0',
+ },
+ // Mirror the whole character, for laying out a panel that faces its text.
+ mirror: false,
+ // Shadows: the soft blob under the feet, plus the cast shadow.
+ shadowOpacity: 0.22,
+ shadowSoftness: 1.6,
+ shadowOffset: 0,
+ contactShadow: false, // blob only, no cast shadow
+ // Lighting environment. 'room' is three's RoomEnvironment; 'gradient' is
+ // the tiny procedural sky the studio always had.
+ environment: 'gradient', // gradient | room | none
+ envIntensity: 1,
+ // Hand-drawn line work (SVG export and the outline pass).
+ handDrawn: 0, // 0 = off, 1 = a lot
+ handDrawnSeed: 7,
+ handDrawnScale: 40,
+ handDrawnPasses: 1,
+ // How the outline is drawn - the two methods take the parts they are good
+ // at. See MODEL-GUIDE.md §5 and src/outline.js.
+ //
+ // 'screen' (the default) hands the leaves to the screen-space edge pass,
+ // because a hull cannot outline a shell that thin: the expanded
+ // front and back cross inside it and the line breaks up. Every
+ // other part keeps its hull, whose line is computed from the
+ // geometry and so comes out smooth, where the pixel grid would
+ // make it stepped.
+ // 'hull' the inverted-hull copy everywhere - cheap, and the way it was
+ // done before, but the leaves then come out as a tangle.
+ outlineMethod: 'screen',
+ outlinePixels: 2.4, // 'screen' only: line width in screen pixels
+ // Whether a leaf gets a line where it meets the *body*. There is no right
+ // answer here, so it is a setting:
+ // on - the body counts as paper, so the leaves are outlined where they
+ // emerge from it. Without this a line drawing cannot tell the
+ // leaves and the body apart at all (both are paper).
+ // off - the body blocks the line, so the leaf simply passes behind it and
+ // nothing is drawn along the meeting. Cleaner, but the leaves and
+ // the body merge into one white shape.
+ leafBodyLine: true,
+ // 見えない壁 (the invisible wall). Up to two placeable rectangles that hide
+ // whatever is behind them, so the character can be buried in a wall and
+ // only the rest of the body shows. See src/clip.js.
+ wall: wallDefaults(),
+ wall2: wallDefaults(),
+ },
+ pose: {
+ bones: {}, // { boneName: [x, y, z] } in degrees, rest = 0,0,0
+ root: [0, 0, 0], // whole-body offset in model units
+ },
+ anim: {
+ mode: 'off', // off | idle | walk
+ blink: true,
+ blinkInterval: 3.4, // seconds between blinks
+ lookAround: false,
+ speed: 1,
+ },
+ face: {
+ // 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js.
+ // `x`/`z` slide it sideways / forwards, `height` lifts it off the head;
+ // `tiltX` leans it forward/back and `tiltZ` leans it sideways.
+ hat: { kind: 'none', custom: false, x: 0, z: 0, height: -0.7, tiltX: 0, tiltZ: 0 },
+ eyes: {
+ source: 'parametric', // parametric | opened | closed | ... (hand-drawn)
+ linked: true, // move both eyes together
+ left: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null },
+ right: { shape: 'open', open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0.3, tearOn: false, tearY: 0, tearX: 0, tearTilt: 0, white: null, highlight: null, shapeScale: 1, lowerLid: null, lidShape: null, lidWidth: null, lidTilt: null, lashes: null, lashAngle: null, lashPos: null },
+ irisScale: 1,
+ lookMax: 1.2,
+ highlight: true,
+ lidWidth: 14,
+ lowerLid: 0,
+ lashes: false,
+ lashAngle: 0,
+ lashPos: 0,
+ // How the closing eyelid is drawn: `curve` is the soft, rounded lid; `flat`
+ // is a straight lid edge (the hard, narrowed look). `lidTilt` rotates the
+ // narrowed eye. See faceArt.js.
+ lidShape: 'curve',
+ lidTilt: 0,
+ heartScale: 2,
+ heartColor: '#e0344f',
+ // The tears live on each eye (see `eyes.left.tear`), so one eye can cry
+ // on its own; only the colour is shared.
+ tearColor: '#8fd8ff',
+ // ほっぺ (a manga blush) and 頭の模様 (a hair-like head covering) are drawn
+ // into the same plate as the eyes and brows. Both are generic devices,
+ // not a copy of any one character, and both start off. The blush is always
+ // four horizontal strokes, so there is no line-count field.
+ cheeks: {
+ enabled: false,
+ size: 1,
+ spacing: 0, // extra gap outwards from the eyes
+ offsetY: 0, // extra drop below the eyes
+ color: '#f6a6b8',
+ },
+ // The hair covering's `shape` is one of `off`, `cap`, `fringe`,
+ // `fringe-up` or `curve`. `overEyes` puts the hair in front of the eyes
+ // (bangs over the face); off leaves the eyes on top.
+ headMark: {
+ shape: 'off',
+ size: 1,
+ // ぎざぎざ only: how far the saw teeth reach down.
+ teeth: 0.12,
+ offsetX: 0,
+ offsetY: 0,
+ overEyes: true,
+ color: '#8a4fe0',
+ color2: '#ffffff',
+ },
+ // 鼻ちょうちん: the snot bubble someone sleeps with. Drawn into the same
+ // plate as the eyes, out to the side of the nose. Starts off.
+ snot: {
+ enabled: false,
+ size: 1.23,
+ offsetX: -23,
+ offsetY: 0,
+ offsetZ: 16,
+ color: '#e3ecff',
+ },
+ // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`,
+ // `scotch`, `kaiser`, `apron` or `cat`. `spacing` widens the gap between
+ // the cat's left and right whiskers. Starts off.
+ beard: {
+ shape: 'off',
+ size: 1,
+ offsetY: 0,
+ spacing: 0,
+ // ねこ only: how far each whisker reaches (`length`) and how far apart the
+ // three lines of one side sit (`lineGap`). Neither changes the thickness.
+ length: 1,
+ lineGap: 1,
+ color: '#1c1624',
+ },
+ // Eyebrows are drawn into the eye texture. Off by default, because the
+ // original character has no eyebrows.
+ brow: {
+ enabled: false,
+ // When true and a drawing (assets/brows/gol-right.png) was loaded, the
+ // brow is painted from that file (mirrored) instead of the strokes.
+ image: false,
+ angle: 0,
+ height: 0,
+ length: 1,
+ thickness: 1,
+ curve: 0.14,
+ // How wide the nose-side end is: 1 = a plain stroke, 0 = a wedge that
+ // comes to a point (the ゴル風 look). `spacing` widens the gap between
+ // the two brows.
+ taper: 1,
+ spacing: 0,
+ color: '#55386e',
+ },
+ // 眼鏡 / サングラス are drawn into the same texture as the eyes and brows,
+ // so they travel with the head instead of being a separate 3D mesh. Off
+ // by default. `kind` only picks which lens defaults the panel offers; the
+ // drawing itself reads the colours and opacities below.
+ glasses: {
+ enabled: false,
+ kind: 'glasses', // glasses | sunglasses
+ frameColor: '#2a1e33',
+ frameWidth: 1, // multiplier on the frame stroke (EYE_LAYOUT.lidStroke)
+ lensColor: '#2b2433',
+ lensOpacity: 0.22, // 0..1; 眼鏡 is nearly clear, サングラス dark
+ lensGap: 0, // px, + moves the two lenses further apart
+ scale: 1, // lens size multiplier
+ offsetY: 0, // px, artwork units (positive = down)
+ tilt: 0, // degrees, both lenses turn together
+ },
+ white: '#ffffff',
+ iris: '#150e1b',
+ line: '#55386e',
+ },
+ mouth: {
+ source: 'parametric', // parametric | original
+ visible: true,
+ smile: 1, // 0 = straight line, 1 = the original smile, <0 = frown
+ width: 1, // the smile line's length (the chord); also the O's width
+ thickness: 1,
+ open: 0, // 0 = closed lips, 1 = wide open
+ tilt: 0, // degrees
+ offsetY: 0, // texture pixels
+ tongue: 0.96, // 0 hides it; scales the whole tongue
+ tonguePos: 0.84, // 0..1 along the smile line
+ corners: 0.71, // length of the corner marks, 0 hides them
+ cornerAngle: -8, // degrees, tilts the corner marks
+ round: 0, // >0 turns the mouth into a round "O"
+ color: '#ff1a44',
+ innerColor: '#4a0f1e',
+ tongueColor: '#ff2d2d',
+ cornerColor: '#725497',
+ line: '#55386e',
+ },
+ },
+ // ---------------------------------------------------------------- caption
+ // The speech bubbles drawn next to the character when exporting. They live in
+ // the state so they are saved, shared and undone like everything else. There
+ // are two of them, so a line and a reply (or a narrator and a speaker) fit.
+ caption: captionDefaults(),
+ // The second bubble starts on the other side, so the two do not overlap.
+ caption2: { ...captionDefaults(), text: '', x: 0.08, y: 0.44 },
+ // A ナレーション box (vertical text, rect, no tail).
+ narration: narrationDefaults(),
+ // -------------------------------------------------------- 口パク (mouth flap)
+ // The mouth moves as if talking, with no sound at all: the device's own
+ // speech voices belong to the device, so a recording of them is not ours to
+ // hand out. Only the *length* of the text matters here.
+ mouthFlap: {
+ text: 'こんにちは。小平市のぶるべーです。',
+ rate: 1, // how fast the syllables come
+ mouthGain: 1, // how wide the mouth opens
+ },
+ // ----------------------------------------------------------------- look at
+ // Where the character should look (world units).
+ lookAt: {
+ enabled: false,
+ x: 0,
+ y: 2.6,
+ z: 3,
+ turnBody: false, // also rotate the whole body a little
+ amount: 1,
+ // The turn and the gaze are *remembered* rather than derived, so switching
+ // the mode (or just 体も向ける) off leaves the character where it is instead
+ // of snapping it back to the front. See `applyLookAt` in main.js.
+ bodyYawDeg: 0,
+ freeze: null,
+ },
+ // ------------------------------------------------------------------- props
+ // 小物. Each entry is a procedural prop from src/props.js.
+ props: {
+ items: [],
+ },
+ // -------------------------------------------------------------------- 擬音
+ // マンガのオノマトペ (擬音) as stickers over the picture. Each item is a crop
+ // of a sheet - `{ id, sheet, sx, sy, sw, sh, x, y, w, rot, flip }` - and only
+ // the sheet *name* is stored, never the bitmap, so a shared link stays small
+ // (see src/gion.js for the geometry).
+ gion: {
+ items: [],
+ },
+ // ------------------------------------------------------------------- story
+ // 4コマ / 紙芝居. Each panel is a small state patch (pose, face, caption).
+ story: {
+ columns: 1,
+ gap: 12,
+ padding: 20,
+ sheetBackground: '#ffffff',
+ // How big each captured panel is, as a multiple of the viewport. 2x doubles
+ // each side, so the sheet PNG gets four times the pixels per panel.
+ scale: 1.5,
+ panels: [],
+ },
+ };
+}
+
+/** Expression presets. Each patches a copy of the default state. */
+export const FACE_PRESETS = [
+ {
+ id: 'normal',
+ label: 'ふつう',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ },
+ // Depth 1 = the hand-drawn original; the corners and tongue below are also
+ // the settings that reproduce it (measured against the artwork).
+ mouth: { smile: 1, width: 1, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0.96, tonguePos: 0.84, corners: 0.71, cornerAngle: -8 },
+ },
+ },
+ {
+ id: 'laugh',
+ label: 'にっこり',
+ face: {
+ eyes: {
+ left: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' },
+ right: { shape: 'arch', open: 1, lookX: 0, lookY: 0, closed: 'arch' },
+ highlight: false,
+ },
+ // A laugh is a wide smile with squeezed-shut eyes. An *open* mouth on a
+ // round mascot reads as a shout, so only びっくり keeps the round O.
+ mouth: { smile: 1.15, width: 1.14, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, round: 0, tongue: 0.96, corners: 1.25 },
+ },
+ },
+ {
+ id: 'eating',
+ label: 'たべている',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0.15, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ },
+ // A small, cute round mouth (the other round mouth, with びっくり).
+ mouth: { smile: 0.2, width: 0.8, thickness: 1.1, open: 0, tilt: 0, offsetY: 6, round: 0.58, tongue: 0.7, corners: 0 },
+ },
+ },
+ {
+ id: 'wink',
+ label: 'ウインク',
+ face: {
+ eyes: {
+ // The shut eye is the original `eyes-close-tight` artwork: three strokes
+ // sharing one vertex, pointing at the nose.
+ left: { shape: 'line3', open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 3 },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line', closedLines: 1 },
+ irisScale: 1,
+ highlight: true,
+ },
+ mouth: { smile: 1.0, width: 1.02, thickness: 1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 1 },
+ },
+ },
+ {
+ id: 'surprised',
+ label: 'びっくり',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ right: { open: 1, lookX: 0, lookY: 0, closed: 'line' },
+ irisScale: 0.74,
+ lookMax: 0.35,
+ highlight: true,
+ },
+ mouth: { smile: 0.2, width: 0.85, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, round: 0.62, tongue: 0.25, corners: 0 },
+ },
+ },
+ {
+ id: 'sleepy',
+ label: 'ねぼけ',
+ face: {
+ eyes: {
+ left: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' },
+ right: { open: 0.42, lookX: 0, lookY: -0.22, closed: 'line' },
+ irisScale: 1,
+ highlight: true,
+ // A sleeper's snot bubble, with the defaults from the panel.
+ snot: { enabled: true },
+ },
+ mouth: { smile: 0.62, width: 0.88, thickness: 1, open: 0, tilt: -3, offsetY: 6, tongue: 0, corners: 0.8 },
+ },
+ },
+ {
+ id: 'shy',
+ label: 'てれ',
+ face: {
+ eyes: {
+ // Blushing, eyes half shut and looking away.
+ left: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' },
+ right: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' },
+ cheeks: { enabled: true },
+ irisScale: 1,
+ highlight: true,
+ },
+ mouth: { smile: 0.5, width: 0.82, thickness: 1.05, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0.5 },
+ },
+ },
+ {
+ id: 'sidelong',
+ label: 'じと目',
+ face: {
+ eyes: {
+ // Flat, narrowed lids tilted inwards - a sideways, unimpressed look.
+ // 上まぶたの高さ is now independent of the lower lid, so `open` carries the
+ // whole slit (it used to be measured from the raised lower lid).
+ left: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' },
+ right: { open: 0.74, lookX: 0.4, lookY: -0.12, closed: 'line' },
+ lowerLid: 0.42,
+ lidShape: 'flat',
+ lidTilt: 12,
+ irisScale: 0.92,
+ highlight: true,
+ },
+ mouth: { smile: 0.12, width: 0.78, thickness: 1.1, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+ {
+ id: 'love',
+ label: 'すき',
+ face: {
+ eyes: {
+ left: { shape: 'heart', open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false },
+ right: { shape: 'heart', open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false },
+ irisScale: 1,
+ // The white behind a heart would poke out around its lobes, so hide it.
+ heartColor: '#e0344f',
+ highlight: false,
+ },
+ mouth: { smile: 1.15, width: 1, thickness: 1.05, open: 0.16, tilt: 4, offsetY: 0, tongue: 0.9, corners: 1.3 },
+ },
+ },
+ {
+ id: 'grumpy',
+ label: 'むっと',
+ face: {
+ eyes: {
+ left: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 },
+ right: { open: 0.6, lookX: 0, lookY: -0.12, closed: 'line', closedLines: 2 },
+ irisScale: 0.86,
+ highlight: true,
+ },
+ mouth: { smile: -0.62, width: 0.84, thickness: 1.15, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+ {
+ id: 'cry',
+ label: 'ないてる',
+ face: {
+ eyes: {
+ // Big tears that hang low and lean outwards - the classic "crying hard"
+ // look. The sideways position and the tilt are mirrored between the eyes,
+ // so the pair stays symmetric.
+ left: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: -20 },
+ right: { open: 0.84, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearOn: true, tearY: 26, tearTilt: 20 },
+ irisScale: 1.02,
+ lowerLid: 0.42,
+ highlight: true,
+ },
+ mouth: { smile: -0.5, width: 0.72, thickness: 1.25, open: 0, tilt: 0, offsetY: 0, tongue: 0, corners: 0 },
+ },
+ },
+];
+
+/**
+ * Poses. `bones` holds per-bone XYZ degrees relative to the rest pose.
+ *
+ * The axes were measured (turn one bone 40° and watch the hand's centre move),
+ * and the two sides are NOT the same. That is what used to send the left hand
+ * behind while the right one came forward:
+ *
+ * arm / hand
+ * X lift. Up on both sides, so the SAME value is symmetric.
+ * Y roll about the flipper - which way the palm faces.
+ * Z forward/back. +Z is BACK on the left and FORWARD on the right, so a
+ * symmetric pose writes the right-hand Z with the opposite sign, while
+ * a walk (opposite arms) writes the SAME sign on both.
+ * legsupport
+ * X out to the side and up (same value on both sides is symmetric)
+ * Y twist (which way the toe points)
+ * Z forward/back, mirrored like `arm`
+ * It pivots at the middle of the body, so the feet travel a long way for
+ * a small angle - keep it under about 15 degrees.
+ * master
+ * X lean forward Y spin around Z roll sideways
+ *
+ * `armsupport` still exists in the rig but it is a control bone that pivots at
+ * the body's centre, so it swings a hand in a wide arc across the face. Every
+ * preset below drives `arm`/`hand` instead.
+ */
+export const POSE_PRESETS = [
+ { id: 'stand', label: '立ち', pose: { bones: {} } },
+ {
+ id: 'attention',
+ label: 'きおつけ',
+ pose: {
+ bones: {
+ // Both flippers dropped to the sides. A negative X lowers the arm, and
+ // the value is the same on both sides (only Z mirrors between them).
+ 'arm.l': [-62, 0, 0],
+ 'arm.r': [-62, 0, 0],
+ },
+ },
+ },
+ {
+ id: 'bow',
+ label: 'おじぎ',
+ pose: { bones: { master: [22, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } },
+ },
+ {
+ id: 'deep-bow',
+ label: 'ふかくおじぎ',
+ pose: { bones: { master: [42, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } },
+ },
+ {
+ id: 'tilt',
+ label: 'くびかしげ',
+ pose: {
+ bones: {
+ master: [0, 0, 16],
+ // The roll turns the whole body about its base, which swings the feet up
+ // and down, so the legs take the difference. The right-hand leg (on the
+ // left of the screen) is the one that is planted: it opens outwards a
+ // little and keeps the sole down. The left-hand leg bends back behind it.
+ 'legsupport.r': [8, 0, 0],
+ 'legsupport.l': [-6, 0, 14],
+ },
+ },
+ },
+ {
+ id: 'wave',
+ label: 'てをふる',
+ pose: {
+ bones: {
+ master: [0, 0, -4],
+ // The left flipper goes up and slightly forward. Y is the roll about the
+ // flipper's own length, which is what turns the palm to face outwards.
+ 'arm.l': [84, 0, -6],
+ 'hand.l': [0, -30, 0],
+ },
+ },
+ },
+ {
+ id: 'banzai',
+ label: 'ばんざい',
+ pose: {
+ bones: {
+ master: [-8, 0, 0],
+ // Both flippers as far up as the shoulder allows (the pivot sits at the
+ // middle of the body, so they cannot reach above the head).
+ 'arm.l': [106, 0, -12],
+ 'arm.r': [106, 0, 12],
+ },
+ root: [0, 0.2, 0],
+ },
+ },
+ {
+ id: 'stretch',
+ label: 'のびをする',
+ pose: {
+ bones: {
+ master: [-16, 0, 0],
+ 'arm.l': [112, 0, -8],
+ 'arm.r': [112, 0, 8],
+ },
+ root: [0, 0.08, 0],
+ },
+ },
+ {
+ id: 'jump',
+ label: 'ジャンプ',
+ pose: {
+ bones: {
+ master: [-8, 0, 0],
+ 'arm.l': [68, 0, -10],
+ 'arm.r': [68, 0, 10],
+ 'legsupport.l': [10, 0, 0],
+ 'legsupport.r': [10, 0, 0],
+ },
+ root: [0, 0.62, 0],
+ },
+ },
+ {
+ id: 'leap',
+ label: '大ジャンプ',
+ pose: {
+ bones: {
+ master: [-18, 0, 0],
+ 'arm.l': [92, 0, -12],
+ 'arm.r': [92, 0, 12],
+ 'legsupport.l': [14, 0, 0],
+ 'legsupport.r': [14, 0, 0],
+ },
+ root: [0, 1.0, 0],
+ },
+ },
+ {
+ id: 'dance',
+ label: 'おどる',
+ pose: {
+ bones: {
+ master: [-4, 0, -12],
+ // Deliberately uneven: the left flipper is up, the right one is out in
+ // front, and the feet step the other way.
+ 'arm.l': [78, 0, -14],
+ 'arm.r': [14, 0, 34],
+ 'legsupport.l': [0, 0, 8],
+ 'legsupport.r': [0, 0, 8],
+ },
+ root: [0, 0.14, 0],
+ },
+ },
+ {
+ id: 'tip-over',
+ label: 'こてん',
+ pose: {
+ bones: {
+ master: [0, 0, 68],
+ 'arm.l': [26, 0, -18],
+ 'arm.r': [10, 0, 18],
+ },
+ root: [0, 0.25, 0],
+ },
+ },
+ {
+ id: 'lie',
+ label: 'ねている',
+ pose: {
+ bones: {
+ // Flat on its back: X tips the body backwards all the way over, so the
+ // face looks up. The root lift keeps the back resting on the ground
+ // rather than sinking through it (the body's depth becomes its height).
+ master: [-88, 0, 0],
+ // Both flippers rest against the body, loose.
+ 'arm.l': [-18, 0, 0],
+ 'arm.r': [-18, 0, 0],
+ },
+ root: [0, 1.5, 0],
+ },
+ },
+ { id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } },
+ { id: 'turn', label: 'うしろむき', pose: { bones: { master: [0, 180, 0] } } },
+ {
+ id: 'present',
+ label: '右を紹介',
+ pose: {
+ bones: {
+ master: [6, -26, 0],
+ // The right flipper sweeps forward to point at whatever is on its right
+ // (+Z is forwards on the right); the left one just steps aside.
+ 'arm.r': [16, 0, 52],
+ 'arm.l': [10, 0, -6],
+ },
+ },
+ },
+ {
+ id: 'present-left',
+ label: '左を紹介',
+ pose: {
+ bones: {
+ master: [6, 26, 0],
+ 'arm.l': [16, 0, -52],
+ 'arm.r': [10, 0, 6],
+ },
+ },
+ },
+];
+
+/** Deep-merge `patch` into `target` (plain objects only). */
+export function applyPatch(target, patch) {
+ for (const [key, value] of Object.entries(patch ?? {})) {
+ if (value && typeof value === 'object' && !Array.isArray(value)
+ && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
+ applyPatch(target[key], value);
+ } else {
+ target[key] = Array.isArray(value) ? value.slice() : value;
+ }
+ }
+ return target;
+}
+
+export function cloneState(state) {
+ return JSON.parse(JSON.stringify(state));
+}
+
+/**
+ * Body colour themes (C-1). Each is a full set of part colours, so picking one
+ * is a single assignment; the individual pickers in the panel then let you drift
+ * away from it without losing the rest.
+ *
+ * ぶるべー is a leaf-sprout mascot, so the themes are named after plants and
+ * weather rather than after colours.
+ */
+export const THEMES = [
+ {
+ id: 'original',
+ label: 'オリジナル',
+ colors: { body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0' },
+ },
+ {
+ id: 'sakura',
+ label: 'さくら',
+ colors: { body: '#f7c6d9', accent: '#e0709b', nose: '#c25b7e', leaf: '#b8e08a', vein: '#8dbb54', feet: '#c25b7e' },
+ },
+ {
+ id: 'matcha',
+ label: 'まっちゃ',
+ colors: { body: '#cfe3ac', accent: '#6f9c3a', nose: '#5c8030', leaf: '#9fd463', vein: '#79ab3c', feet: '#5c8030' },
+ },
+ {
+ id: 'sora',
+ label: 'そら',
+ colors: { body: '#bcd9f7', accent: '#4a7fd4', nose: '#3f6cae', leaf: '#a8e08a', vein: '#7cbb58', feet: '#3f6cae' },
+ },
+ {
+ id: 'lemon',
+ label: 'レモン',
+ colors: { body: '#f8e9a6', accent: '#d8a520', nose: '#b3871a', leaf: '#b9de74', vein: '#8fbb45', feet: '#b3871a' },
+ },
+ {
+ id: 'momo',
+ label: 'もも',
+ colors: { body: '#f9cbae', accent: '#e07a4a', nose: '#bd6238', leaf: '#aede7c', vein: '#84b84e', feet: '#bd6238' },
+ },
+ {
+ id: 'sumi',
+ label: 'すみ',
+ colors: { body: '#9aa2b8', accent: '#4b5468', nose: '#3c4356', leaf: '#8fbf7a', vein: '#6d9a58', feet: '#3c4356' },
+ },
+ {
+ id: 'yozora',
+ label: 'よぞら',
+ colors: { body: '#8f8fd0', accent: '#403f8f', nose: '#34336f', leaf: '#7fc7b0', vein: '#5aa08c', feet: '#34336f' },
+ },
+];
+
+/** Bubble shapes offered for captions. */
+export const BUBBLE_STYLES = [
+ { value: 'round', label: 'まる' },
+ { value: 'rect', label: 'しかく' },
+ { value: 'shout', label: 'さけぶ' },
+ { value: 'whisper', label: 'ささやき' },
+ { value: 'think', label: '思考' },
+ { value: 'none', label: 'なし' },
+];
+
+/** A couple of ready-made captions, so the feature is discoverable. */
+export const CAPTION_PRESETS = [
+ { id: 'greeting', label: 'あいさつ', caption: { enabled: true, text: 'こんにちは、ぶるべーです。' } },
+ { id: 'explain', label: '説明', caption: { enabled: true, text: 'ここがポイントです。', bubble: 'rect', tail: 'left' } },
+ { id: 'surprise', label: 'びっくり', caption: { enabled: true, text: 'えっ!?', bubble: 'shout', tail: 'left', fontSize: 40, bold: true } },
+];