aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/presets.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/presets.js')
-rw-r--r--public/bluebey-studio/src/presets.js698
1 files changed, 698 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
new file mode 100644
index 0000000..3146e0b
--- /dev/null
+++ b/public/bluebey-studio/src/presets.js
@@ -0,0 +1,698 @@
+/**
+ * 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
+ font: 'rounded', // rounded (vendored) | system
+ };
+}
+
+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,
+ pngScale: 2,
+ pngTransparent: false,
+ svgWidth: 2048,
+ lightAzimuth: -38,
+ lightElevation: 40,
+ lightIntensity: 2.1,
+ ambient: 0.9,
+ 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). A placeable clipping plane that hides
+ // whatever is behind it, so the character can be buried in the wall and
+ // only the rest of the body shows. See src/clip.js.
+ wall: {
+ on: false,
+ x: 0, y: 0, z: 0, // a point the plane passes through (world units)
+ 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)
+ },
+ },
+ 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: {
+ eyes: {
+ source: 'parametric', // parametric | opened | closed | ... (hand-drawn)
+ linked: true, // move both eyes together
+ left: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0 },
+ right: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0 },
+ irisScale: 1,
+ lookMax: 1,
+ highlight: true,
+ lidWidth: 14,
+ lowerLid: 0,
+ heartScale: 2,
+ heartColor: '#e0344f',
+ heartHideWhite: true,
+ // 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 mark on the head) are drawn into
+ // the same plate as the eyes and brows. Both are generic manga devices,
+ // not a copy of any one character, and both start off. The mark's shape
+ // is one of `off`, `hook`, `spiral`, `strokes`, `dots`.
+ cheeks: {
+ enabled: false,
+ size: 1,
+ spacing: 0, // extra gap outwards from the eyes
+ offsetY: 0, // extra drop below the eyes
+ lines: 4, // hatch strokes; 0 leaves a plain patch
+ color: '#f6a6b8',
+ },
+ headMark: {
+ shape: 'off',
+ size: 1,
+ offsetX: 0,
+ offsetY: 0,
+ lineWidth: 1,
+ color: '#8a4fe0',
+ },
+ // Eyebrows are drawn into the eye texture. Off by default, because the
+ // original character has no eyebrows.
+ brow: {
+ enabled: 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
+ 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,
+ 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
+ tongueOut: 0, // 0..1; >0 hangs the tongue *out*, below the lip
+ tongueOutPos: 0.5, // 0..1 along the lip, for the tongue that hangs out
+ 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 },
+ // -------------------------------------------------------- 口パク (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: { open: 0, lookX: 0, lookY: 0, closed: 'arch' },
+ right: { open: 0, 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: { open: 0, 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,
+ },
+ mouth: { smile: 0.62, width: 0.88, thickness: 1, open: 0, tilt: -3, offsetY: 6, tongue: 0, corners: 0.8 },
+ },
+ },
+ {
+ id: 'love',
+ label: 'すき',
+ face: {
+ eyes: {
+ left: { open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' },
+ right: { open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' },
+ irisScale: 1,
+ // Size 2 with the eyeball's white hidden is the size that reads best:
+ // any white behind the heart pokes out around its lobes.
+ heartScale: 2,
+ heartColor: '#e0344f',
+ heartHideWhite: true,
+ 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.72, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, tearY: 26, tearTilt: -20 },
+ right: { open: 0.72, lookX: 0, lookY: -0.3, closed: 'line', tear: 1.35, 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: 'bow', label: 'おじぎ', pose: { bones: { master: [22, 0, 0] } } },
+ { id: 'deep-bow', label: 'ふかくおじぎ', pose: { bones: { master: [42, 0, 0] } } },
+ { id: 'tilt', label: 'くびかしげ', pose: { bones: { master: [0, 0, 16] } } },
+ {
+ 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: '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: '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 } },
+];