diff options
Diffstat (limited to 'bluebey-studio/src/presets.js')
| -rw-r--r-- | bluebey-studio/src/presets.js | 892 |
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 } }, +]; |
