/** * 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 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 head covering's `shape` is one of `off`, `cap`, `fringe`, `wave`, // `side` (the boundary style) or `split` (a two-tone head divided by a // vertical curve). `color` is the covering / left half; `color2` is the // second colour of the two-tone split. headMark: { shape: 'off', size: 1, offsetX: 0, offsetY: 0, 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, offsetX: 0, offsetY: 0, offsetZ: 0, color: '#e3ecff', }, // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`, // `scotch`, `kaiser`, `apron` or `cat`. Starts off. beard: { shape: 'off', size: 1, offsetX: 0, offsetY: 0, color: '#3a2a4a', }, // 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: '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: '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 } }, ];