diff options
Diffstat (limited to 'bluebey-studio/src/panel.js')
| -rw-r--r-- | bluebey-studio/src/panel.js | 722 |
1 files changed, 42 insertions, 680 deletions
diff --git a/bluebey-studio/src/panel.js b/bluebey-studio/src/panel.js index 0ee94ac..a6e971d 100644 --- a/bluebey-studio/src/panel.js +++ b/bluebey-studio/src/panel.js @@ -1,10 +1,9 @@ import { h, section as uiSection, subhead, hint, slider, check, segmented, buttons, - colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, icon, svgIcon, + colorField, xyPad, selectField, controlRow, tabs, details, toast, icon, svgIcon, } from './ui.js'; import { - FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS, - defaultState, applyPatch, BEARD_KIND_DEFAULTS, HEAD_MARK_KIND_DEFAULTS, + FACE_PRESETS, POSE_PRESETS, defaultState, applyPatch, } from './presets.js'; import { STYLE_DEFS } from './styles.js'; import { ENVIRONMENTS } from './look.js'; @@ -26,21 +25,14 @@ const SECTION_ICONS = { 'うごき': 'M8 5l10 7-10 7V5Z', 'プリセット': 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0', '目': 'M2.5 12s3.6-6 9.5-6 9.5 6 9.5 6-3.6 6-9.5 6-9.5-6-9.5-6ZM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z', - '眉': 'M4 11q3.2-3.2 7-2M13 9q3.8-1.2 7 2', '眼鏡・サングラス': 'M8 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM16 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM11.2 13h1.6M4.8 11.7 3 10.3M19.2 11.7 21 10.3', - '髪': 'M4 14c0-4.8 3.6-7.6 8-7.6s8 2.8 8 7.6M4 14c2.6-1.4 5.3-1.9 8-1.8M20 14c-2.6-1.4-5.3-1.9-8-1.8', - 'ほっぺ': 'M6.4 14.6a2 1.4 0 1 0 0 .01ZM17.6 14.6a2 1.4 0 1 0 0 .01ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z', '鼻ちょうちん': 'M15 4.6a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2ZM10.6 9.6 4.8 11.4', - 'ひげ': 'M4.4 11.2c2.6-1.2 5.2-.6 7.6 2 2.4-2.6 5-3.2 7.6-2', '口': 'M4.6 10c3 4.8 11.8 4.8 14.8 0', - '口パク(声は出ません)': 'M4 9v6h3l4 3V6L7 9H4ZM15.5 9.5l4 5M19.5 9.5l-4 5', '見る先': 'M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6Z', 'スタイル': 'M4 20l3.4-.8L20 6.6 17.4 4 4.8 16.6 4 20Z', - '体の色': 'M12 3.4s6 6.2 6 10.1a6 6 0 0 1-12 0C6 9.6 12 3.4 12 3.4Z', '見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01', '背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01', '舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5', - 'セリフ(フキダシ)': 'M4 5h16v10H9l-4 4v-4H4V5Z', '画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8', '画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01', '画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01', @@ -54,15 +46,6 @@ const SECTION_ICONS = { const section = (parent, title, options) => uiSection(parent, title, { icon: SECTION_ICONS[title], ...options }); /** Fallbacks for the fields an older settings file may predate. */ -const BODY_COLOR_FALLBACKS = { - body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', - leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0', -}; -const CAPTION_SLIDER_FALLBACKS = { - fontSize: 34, lineHeight: 1.42, padding: 18, radius: 24, borderWidth: 4, -}; -const CAPTION_COLOR_FALLBACKS = { textColor: '#3f2b52', bubbleColor: '#ffffff', borderColor: '#55386e' }; -const MOUTH_FLAP_FALLBACKS = { rate: 1, mouthGain: 1 }; const LOOK_AT_FALLBACKS = { x: 0, y: 2.6, z: 3, amount: 1 }; /** @@ -78,9 +61,8 @@ function confirmImageLicense() { } /** - * The panel had no multi-line field, and the two features that take free prose - * (the caption and the spoken text) want the same shape, so it is built once - * here rather than growing the widget kit for a single caller. + * The panel's multi-line field. The 看板 prop's lettering is the only caller, but + * the shape (a control row wrapping a textarea) is worth keeping in one place. */ function textArea({ label, value = '', rows = 3, onChange }) { const input = h('textarea', { @@ -147,18 +129,6 @@ export function buildPanel(app, root) { const sync = () => { for (const fn of syncers) fn(); }; const syncBones = () => { for (const fn of boneSyncers) fn(); }; - /** - * Grey out (and disable) a control whose setting has no effect in the current - * mode - e.g. the brow's angle while the brow is a loaded picture. - */ - const setRowEnabled = (widget, on) => { - const row = widget.el.closest?.('.row') ?? widget.el; - const input = row.querySelector?.('input, select, button'); - if (input) input.disabled = !on; - row.classList?.toggle('is-off', !on); - }; - - /** Patch one scope of the state without re-reading the whole UI. */ /** Replace the state and re-read every widget (used by presets and loads). */ const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true }); @@ -190,55 +160,25 @@ export function buildPanel(app, root) { tilt: Math.round(rnd(-12, 12)), }; }; - const randomBeard = (shape) => { - const base = BEARD_KIND_DEFAULTS[shape] ?? { size: 1, offsetY: 0, spacing: 0 }; - if (shape === 'off') return { shape }; - return { - shape, - ...base, - size: +((base.size ?? 1) * rnd(0.8, 1.25)).toFixed(2), - offsetY: Math.round((base.offsetY ?? 0) + rnd(-16, 16)), - }; - }; - const randomHeadMark = () => { - const shape = pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']); - return { shape, ...(HEAD_MARK_KIND_DEFAULTS[shape] ?? {}) }; - }; const randomFacePatch = () => { - const beardShape = pick(['off', 'scotch', 'kaiser', 'cat']); return { eyes: { left: randomEye(), right: randomEye(), - cheeks: { enabled: Math.random() < 0.5 }, // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。 glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(), - beard: randomBeard(beardShape), - headMark: randomHeadMark(), - brow: { - enabled: true, - // Random brows are line drawings, never ゴル風's loaded picture. - image: false, - angle: Math.round(rnd(-30, 40)), - height: Math.round(rnd(-10, 50)), - length: +rnd(0.6, 2.6).toFixed(2), - thickness: +rnd(1.2, 3.4).toFixed(2), - curve: +rnd(0, 0.3).toFixed(2), - spacing: Math.round(rnd(-120, 30)), - taper: +rnd(0, 1).toFixed(2), }, - }, - // The mouth takes the height and the tilt too, so the face really changes. - mouth: { - smile: +rnd(-1, 1.2).toFixed(2), - // ときどき丸く開く口(O)にする。 - round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0, - open: +rnd(0, 0.5).toFixed(2), - width: +rnd(0.7, 1.3).toFixed(2), - thickness: +rnd(0.7, 1.6).toFixed(2), - tilt: Math.round(rnd(-12, 12)), - offsetY: Math.round(rnd(-16, 16)), - }, + // The mouth takes the height and the tilt too, so the face really changes. + mouth: { + smile: +rnd(-1, 1.2).toFixed(2), + // ときどき丸く開く口(O)にする。 + round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0, + open: +rnd(0, 0.5).toFixed(2), + width: +rnd(0.7, 1.3).toFixed(2), + thickness: +rnd(0.7, 1.6).toFixed(2), + tilt: Math.round(rnd(-12, 12)), + offsetY: Math.round(rnd(-16, 16)), + }, }; }; const randomFace = () => replace({ face: randomFacePatch() }, 'face'); @@ -249,10 +189,9 @@ export function buildPanel(app, root) { return Math.abs(m[0]) <= 45 && Math.abs(m[1]) <= 40 && Math.abs(m[2]) <= 40; }); const pose = pick(poses.length ? poses : POSE_PRESETS); - const theme = pick(THEMES); app.actions.applyPosePreset?.(pose.id); - app.actions.applyTheme?.(theme.id); - // The viewer style (実写/フラット/線画) and 左右反転 are deliberately left alone. + // The viewer style (実写/フラット/線画), the body colours, and 左右反転 are + // deliberately left alone. replace({ face: randomFacePatch() }, 'face'); sync(); }; @@ -441,18 +380,16 @@ export function buildPanel(app, root) { let faceSection = section(tabFace, 'プリセット'); // The face tab's sections are created here in the order they should read on - // screen (プリセット -> 髪 -> 眉 -> 目 -> 見る先 -> ...), but filled further - // down where the widgets are built: the DOM order is fixed at creation. + // screen (プリセット -> 目 -> 見る先 -> 口 -> 眼鏡 -> 鼻ちょうちん -> 帽子), + // but filled further down where the widgets are built: the DOM order is fixed + // at creation. const faceTabSections = { - hair: section(tabFace, '髪'), - brow: section(tabFace, '眉'), eye: section(tabFace, '目'), look: section(tabFace, '見る先'), + mouth: section(tabFace, '口'), glasses: section(tabFace, '眼鏡・サングラス'), - cheeks: section(tabFace, 'ほっぺ'), snot: section(tabFace, '鼻ちょうちん'), - beard: section(tabFace, 'ひげ'), - mouth: section(tabFace, '口'), + hat: section(tabFace, '帽子'), }; faceSection.add(buttons({ @@ -860,128 +797,9 @@ export function buildPanel(app, root) { const tearBox = details(faceSection.body, '横位置(左右べつ)'); for (const el of tearWidgets) tearBox.add(el); - // --- eyebrows (the original character has none; this is extra range) - faceSection = faceTabSections.brow; - faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。')); - - const browToggle = check({ - label: '眉を表示', - value: false, - onChange: (value) => { state.face.eyes.brow.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, browToggle.el, { wide: true })); - - const browSliders = { - angle: slider({ - label: '角度', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { state.face.eyes.brow.angle = v; app.actions.refresh('face'); }, - }), - height: slider({ - label: '高さ', min: -40, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.brow.height = v; app.actions.refresh('face'); }, - }), - length: slider({ - label: '長さ', min: 0, max: 4, step: 0.01, value: 1, - format: (v) => (v < 0.02 ? '点' : v.toFixed(2)), - onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); }, - }), - thickness: slider({ - label: '太さ', min: 0.4, max: 3.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.brow.thickness = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - label: '間隔', min: -220, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.brow.spacing = v; app.actions.refresh('face'); }, - }), - curve: slider({ - label: '曲がり', min: 0, max: 0.4, step: 0.01, value: 0.14, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.brow.curve = v; app.actions.refresh('face'); }, - }), - taper: slider({ - label: '鼻側の細さ', min: 0, max: 1, step: 0.01, value: 1, - format: (v) => (v >= 0.995 ? 'そのまま' : (v <= 0.005 ? 'とがる' : v.toFixed(2))), - onInput: (v) => { state.face.eyes.brow.taper = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(hint('+の角度で内側が下がり、怒った感じになります。-で困り顔。' - + '高さは + で上がり、- にすると目にかくれて見えなくなります。')); - faceSection.add(browSliders.angle.el); - const browBox = details(faceSection.body, 'こまかい設定(高さ・長さ・太さ・色)'); - browBox.add(browSliders.height.el); - browBox.add(browSliders.length.el); - browBox.add(browSliders.thickness.el); - browBox.add(browSliders.spacing.el); - browBox.add(browSliders.curve.el); - browBox.add(browSliders.taper.el); - const browColor = colorField({ - label: '眉の色', value: '#55386e', - swatches: ['#55386e', '#2a1e33', '#7a5c2e', '#8a4a5e'], - onChange: (v) => { state.face.eyes.brow.color = v; app.actions.refresh('face'); }, - }); - browBox.add(browColor.el); - browBox.add(hint('「間隔」は左右の眉のへだたりです(+で広がり、-で内側に寄って、' - + '大きく-にすると左右がくっつきます)。「長さ」を 0 にすると点になります。' - + '「鼻側の細さ」を 0 にすると鼻側がとがります。')); - browBox.add(buttons({ - items: [ - { - id: 'golgo', - label: 'ゴル風(画像)', - onClick: () => { - // ゴルゴ13: a very thick, almost straight black wedge, wide at the outer - // end and tapering to a point at the nose, angled steeply down towards - // it, with the two brows sitting close together. - Object.assign(state.face.eyes.brow, { - enabled: true, - image: true, - angle: 34, - height: 33, - length: 2.82, - thickness: 3.6, - curve: 0, - taper: 0, - spacing: -59, - color: '#2a1e33', - }); - app.actions.refresh('face'); - sync(); - }, - }, - { - id: 'ryotsu', - label: '両風(線の眉)', - onClick: () => { - // 両津勘吉: thick げじげじ brows that are joined in the middle, sitting - // low over the eyes. `spacing` is what closes the gap - at this length - // the two inner ends meet over the nose (see the panel's note). Turning - // `image` off is what makes the line drawing take over from ゴル風's - // loaded picture. - Object.assign(state.face.eyes.brow, { - enabled: true, - image: false, - angle: 10, - height: 4, - length: 2.86, - thickness: 3.29, - curve: 0.4, - taper: 1, - spacing: -50, - color: '#181220', - }); - app.actions.refresh('face'); - sync(); - }, - }, - ], - })); - - faceSection.add(hint('「ゴル風(画像)」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。' - + '画像のときは角度・太さ・曲がり・鼻側の細さが効かないので、灰色になります。' - + '「両風(線の眉)」など線で描く眉に切り替えると、この画像は使われません。')); - - // --- glasses / sunglasses (drawn into the same texture as the eyes and brows) + // --- glasses / sunglasses (drawn into the same texture as the eyes) faceSection = faceTabSections.glasses; - faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。' + faceSection.add(hint('眼鏡やサングラスを、目と同じ絵にかけます。' + '線画のときはレンズを塗らず、フレームの線だけになります。')); // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明 @@ -1114,121 +932,6 @@ export function buildPanel(app, root) { ], })); - // --- ほっぺ (a manga blush) - faceSection = faceTabSections.cheeks; - faceSection.add(hint('ほっぺ(マンガの赤らみ)を、目や眉と同じ絵にかけます。' - + '決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。' - + 'はじめは出ていません。')); - - const cheeksToggle = check({ - label: 'ほっぺを出す', - value: false, - onChange: (value) => { state.face.eyes.cheeks.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, cheeksToggle.el, { wide: true })); - - const cheekSliders = { - size: slider({ - label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.cheeks.size = v; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: 'たかさ', min: -80, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.cheeks.offsetY = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(cheekSliders.size.el); - const cheekBox = details(faceSection.body, 'こまかい設定(位置・色)'); - cheekBox.add(cheekSliders.offsetY.el); - cheekBox.add(cheekSliders.spacing.el); - const cheekColor = colorField({ - label: 'ほっぺの色', value: '#f6a6b8', - swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'], - onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); }, - }); - cheekBox.add(cheekColor.el); - cheekBox.add(hint('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、' - + '「たかさ」は目からの下がり具合です。')); - - // --- 髪 (a hair-like covering over the whole head; its own section, above 眉) - faceSection = faceTabSections.hair; - faceSection.add(hint('髪を頭にかぶせます。決まったキャラクターの髪ではなく、' - + 'よくあるマンガの形として用意しました。はじめは出ていません。')); - - const headMarkShape = segmented({ - label: '髪', - options: [ - { value: 'off', label: 'なし' }, - { value: 'cap', label: 'かぶせ' }, - { value: 'fringe', label: 'ぎざぎざ' }, - { value: 'fringe-up', label: 'はちわれ' }, - { value: 'curve', label: 'カーブ' }, - ], - value: 'off', - onChange: (value) => { - const mark = state.face.eyes.headMark; - mark.shape = value; - // Entering a shape applies its own starting size (ぎざぎざ is smaller and - // shallower than the shared defaults). Other shapes just keep the values. - Object.assign(mark, HEAD_MARK_KIND_DEFAULTS[value] ?? {}); - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(headMarkShape.el); - - const headMarkOverEyes = check({ - label: '髪を目の前に出す', - value: true, - onChange: (value) => { state.face.eyes.headMark.overEyes = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, headMarkOverEyes.el, { wide: true })); - - const headMarkSliders = { - size: slider({ - label: '大きさ', min: 0.05, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.headMark.size = v; app.actions.refresh('face'); }, - }), - teeth: slider({ - label: '歯の深さ', min: 0.02, max: 0.4, step: 0.01, value: 0.12, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.headMark.teeth = v; app.actions.refresh('face'); }, - }), - offsetX: slider({ - label: 'よこの位置', min: -260, max: 260, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: 'たかさ', min: -300, max: 300, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(headMarkSliders.size.el); - faceSection.add(headMarkSliders.teeth.el); - //「歯の深さ」はぎざぎざのときだけ意味があるので、それ以外では隠します。 - const headMarkBox = details(faceSection.body, 'こまかい設定(位置・色)'); - headMarkBox.add(headMarkSliders.offsetX.el); - headMarkBox.add(headMarkSliders.offsetY.el); - const headMarkColor = colorField({ - label: '髪の色', value: '#8a4fe0', - swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'], - onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); }, - }); - const headMarkColor2 = colorField({ - label: '2色目', value: '#ffffff', - swatches: ['#ffffff', '#f3ead9', '#ffe08a', '#7fd8ff'], - onChange: (v) => { state.face.eyes.headMark.color2 = v; app.actions.refresh('face'); }, - }); - headMarkBox.add(headMarkColor.el); - headMarkBox.add(headMarkColor2.el); - headMarkBox.add(hint('「かぶせ」は頭にかぶせる丸い髪、「ぎざぎざ」は生え際がギザギザにとがった髪(「歯の深さ」でギザギザの深さを変えられます)、' - + '「はちわれ」は中央が鋭く持ち上がった生え際、「カーブ」はドラえもんのように単純にカーブした髪です。' - + '「たかさ」で生え際を目の中心あたりまで下げられます。' - + '「髪を目の前に出す」を切ると、髪が目の後ろに回ります。')); - // --- 鼻ちょうちん: the snot bubble of a sleeping face. faceSection = faceTabSections.snot; const snotToggle = check({ @@ -1268,67 +971,6 @@ export function buildPanel(app, root) { + '「ねている」ポーズと組み合わせると、いねむりしているように見えます。' + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。')); - // --- ひげ: mustaches and beards, hung under the nose. - faceSection = faceTabSections.beard; - const beardShape = segmented({ - label: 'ひげ', - options: [ - { value: 'off', label: 'なし' }, - { value: 'scotch', label: 'ちょびひげ' }, - { value: 'kaiser', label: 'ダリ' }, - { value: 'cat', label: 'ねこ' }, - ], - value: 'off', - onChange: (value) => { - const beard = state.face.eyes.beard; - beard.shape = value; - // Each kind starts at its own size and height (a textured kind is drawn at a - // different scale from the built-in strokes). A kind with no entry resets to - // the neutral values, so switching kinds cannot leave a stray size behind. - Object.assign(beard, BEARD_KIND_DEFAULTS[value] ?? { size: 1, offsetY: 0, spacing: 0 }); - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(beardShape.el); - const beardSliders = { - size: slider({ - label: '大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.size = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - label: '左右の間隔', min: 0, max: 250, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - // Shown 10 higher than the value stored, so the default (stored -10) reads 0. - onInput: (v) => { state.face.eyes.beard.spacing = v - 10; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); }, - }), - length: slider({ - label: 'ひげの長さ(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.length = v; app.actions.refresh('face'); }, - }), - lineGap: slider({ - label: '線の間隔(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.lineGap = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(beardSliders.size.el); - const beardBox = details(faceSection.body, 'こまかい設定(位置・色)'); - beardBox.add(beardSliders.spacing.el); - beardBox.add(beardSliders.offsetY.el); - beardBox.add(beardSliders.length.el); - beardBox.add(beardSliders.lineGap.el); - const beardColor = colorField({ - label: 'ひげの色', value: '#1c1624', - swatches: ['#1c1624', '#000000', '#3a2a4a', '#6b4a2a', '#8a8a94'], - onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); }, - }); - beardBox.add(beardColor.el); - beardBox.add(hint('鼻の下に、ちょびひげ・ダリ・ねこ、の瓢を描きます。' - + '「左右の間隔」「瓢の長さ」「線の間隔」は「ねこ」で使います。')); - // --- mouth faceSection = faceTabSections.mouth; @@ -1408,153 +1050,6 @@ export function buildPanel(app, root) { const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)'); for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el); - /* ---------------------------------------------------------------- caption */ - - const captionSection = section(tabScene, 'セリフ(フキダシ)'); - - // Two bubbles share this one set of controls: the selector says which bubble - // they are editing, and every handler below reads the active one when it runs, - // so switching does not have to rewire anything. - let activeCaption = 'caption'; - const cap = () => state[activeCaption]; - const captionBubbleSegment = segmented({ - label: 'どの吹き出し', - options: [ - { value: 'caption', label: '①' }, - { value: 'caption2', label: '②' }, - { value: 'narration', label: 'ナレーション' }, - ], - value: 'caption', - onChange: (value) => { activeCaption = value; sync(); }, - }); - captionSection.add(captionBubbleSegment.el); - captionSection.add(hint('吹き出しは①と②の2つ、ナレーション枠が1つ出せます。切り替えて、それぞれ別の位置・形・色・縦書きにできます。')); - - const captionToggle = check({ - label: 'この吹き出しを出す', - value: false, - onChange: (value) => { cap().enabled = value; app.actions.refresh('caption'); sync(); }, - }); - captionSection.add(controlRow(null, captionToggle.el, { wide: true })); - - const captionText = textArea({ - label: 'セリフ', - value: '', - rows: 3, - onChange: (value) => { cap().text = value; app.actions.refresh('caption'); }, - }); - captionSection.add(captionText.el); - - const verticalToggle = check({ - label: '縦書きにする', - value: false, - onChange: (v) => { cap().vertical = v; app.actions.refresh('caption'); sync(); }, - }); - captionSection.add(controlRow(null, verticalToggle.el, { wide: true })); - - captionSection.add(buttons({ - label: '例', - items: CAPTION_PRESETS.map((preset) => ({ - id: preset.id, - label: preset.label, - onClick: () => { Object.assign(cap(), preset.caption); app.actions.refresh('caption'); sync(); }, - })), - })); - - const bubbleSegment = segmented({ - label: '形', - options: BUBBLE_STYLES, - value: 'round', - onChange: (value) => { cap().bubble = value; app.actions.refresh('caption'); }, - }); - const tailSegment = tailPad({ - label: 'しっぽ', - value: 'left', - onChange: (value) => { cap().tail = value; app.actions.refresh('caption'); }, - }); - captionSection.add(bubbleSegment.el); - captionSection.add(tailSegment.el); - - const captionSliders = { - fontSize: slider({ - label: '文字の大きさ', min: 16, max: 72, step: 1, value: 34, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().fontSize = v; app.actions.refresh('caption'); }, - }), - lineHeight: slider({ - label: '行の高さ', min: 1, max: 2, step: 0.01, value: 1.42, format: (v) => v.toFixed(2), - onInput: (v) => { cap().lineHeight = v; app.actions.refresh('caption'); }, - }), - padding: slider({ - label: '内側の余白', min: 4, max: 40, step: 1, value: 18, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().padding = v; app.actions.refresh('caption'); }, - }), - radius: slider({ - label: '角の丸み', min: 0, max: 60, step: 1, value: 24, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().radius = v; app.actions.refresh('caption'); }, - }), - borderWidth: slider({ - label: '線の太さ', min: 0, max: 12, step: 0.5, value: 4, format: (v) => `${v.toFixed(1)}px`, - onInput: (v) => { cap().borderWidth = v; app.actions.refresh('caption'); }, - }), - }; - for (const widget of Object.values(captionSliders)) captionSection.add(widget.el); - - const captionPosition = { - x: slider({ - label: '横位置', min: 0, max: 1, step: 0.01, value: 0.58, format: (v) => v.toFixed(2), - onInput: (v) => { cap().x = v; app.actions.refresh('caption'); }, - }), - y: slider({ - label: '縦位置', min: 0, max: 1, step: 0.01, value: 0.16, format: (v) => v.toFixed(2), - onInput: (v) => { cap().y = v; app.actions.refresh('caption'); }, - }), - }; - captionSection.add(captionPosition.x.el); - captionSection.add(captionPosition.y.el); - - const captionMaxWidthSlider = slider({ - label: 'はば', min: 0.15, max: 0.8, step: 0.01, value: 0.36, format: (v) => v.toFixed(2), - onInput: (v) => { cap().maxWidth = v; app.actions.refresh('caption'); }, - }); - captionSection.add(captionMaxWidthSlider.el); - - const alignSegment = segmented({ - label: 'そろえ', - options: [ - { value: 'left', label: '左' }, - { value: 'center', label: '中' }, - { value: 'right', label: '右' }, - ], - value: 'left', - onChange: (value) => { cap().align = value; app.actions.refresh('caption'); }, - }); - captionSection.add(alignSegment.el); - - const boldToggle = check({ - label: '太字', value: false, - onChange: (value) => { cap().bold = value; app.actions.refresh('caption'); }, - }); - const systemFontToggle = check({ - label: '標準フォントを使う', value: false, - onChange: (value) => { cap().font = value ? 'system' : 'rounded'; app.actions.refresh('caption'); }, - }); - captionSection.add(controlRow(null, boldToggle.el, { wide: true })); - captionSection.add(controlRow(null, systemFontToggle.el, { wide: true })); - - const captionColors = { - textColor: colorField({ label: '文字の色', value: '#3f2b52', onChange: (v) => { cap().textColor = v; app.actions.refresh('caption'); } }), - bubbleColor: colorField({ label: '吹き出しの色', value: '#ffffff', onChange: (v) => { cap().bubbleColor = v; app.actions.refresh('caption'); } }), - borderColor: colorField({ label: '線の色', value: '#55386e', onChange: (v) => { cap().borderColor = v; app.actions.refresh('caption'); } }), - }; - for (const widget of Object.values(captionColors)) captionSection.add(widget.el); - captionSection.add(hint('セリフはプレビューと書き出し画像の両方に描きこまれます。位置は画像全体を0〜1とした割合です。' - + 'ビューポートで吹き出しをドラッグしても動かせます。')); - - /* -------------------------------------------------------------- 口パク */ - - // 口パク lives inside うごき on the pose tab (see below): talking is something - // the character *does*, and it is usually recorded together with the motion. - /* ------------------------------------------------------ movement (pose tab) */ // This is the pose tab's うごき section; the widgets sit here because they are @@ -1607,35 +1102,6 @@ export function buildPanel(app, root) { moveSection.add(recordButton.el); moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。')); - // 口パク: the mouth moves as if talking, and no sound is made at all. It lives - // in うごき because talking is motion - and it is usually recorded with it. - // (The studio used to read the line with the device's own speech voices, but - // those belong to the device, so a recording of them is not ours to hand out.) - const flapSection = details(moveSection.body, '口パク(声は出ません)'); - const flapText = textArea({ - label: '口パクするセリフ', - value: '', - rows: 3, - onChange: (value) => { state.mouthFlap.text = value; }, - }); - flapSection.add(flapText.el); - flapSection.add(buttons({ - items: [{ id: 'flap', label: '口パク/止める', primary: true, onClick: () => app.actions.toggleMouthFlap() }], - })); - const flapSliders = { - rate: slider({ - label: 'はやさ', min: 0.5, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`, - onInput: (v) => { state.mouthFlap.rate = v; }, - }), - mouthGain: slider({ - label: '口の開き', min: 0.2, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.mouthFlap.mouthGain = v; }, - }), - }; - for (const widget of Object.values(flapSliders)) flapSection.add(widget.el); - flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。' - + '「シーン」タブの「セリフ(フキダシ)」の文とは別で、こちらは長さにだけ使います。')); - // --- 見る先 (moved here, next to the face controls, from the pose tab) ---- const lookAtSection = faceTabSections.look; // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too. @@ -1675,7 +1141,7 @@ export function buildPanel(app, root) { onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); }, }); lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true })); - lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目と髪が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。')); + lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。')); /* ---------------------------------------------------------------- display */ @@ -1745,9 +1211,9 @@ export function buildPanel(app, root) { }], })); - /* ------------------------------------------------------------- 帽子 */ + /* ------------------------------------------------------------- 帽子 (顔タブ) */ - const hatSection = section(tabView, '帽子'); + const hatSection = faceTabSections.hat; hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。「なし」を選ぶと外れます。')); const hatSegment = segmented({ label: '帽子', @@ -1817,46 +1283,15 @@ export function buildPanel(app, root) { hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0); }); - /* ------------------------------------------------------------- body colour */ - - const colourSection = section(tabView, '体の色'); - const themeSegment = segmented({ - label: 'テーマ', - options: THEMES.map((theme) => ({ value: theme.id, label: theme.label })), - value: 'original', - onChange: (id) => { app.actions.applyTheme(id); sync(); }, - }); - colourSection.add(themeSegment.el); - colourSection.add(hint('テーマは出発点です。選んだあとでパーツごとに色を変えられます。')); - - /** Write one part colour, creating `colors` if an old settings file lacked it. */ - const setBodyColor = (part, value) => { - state.render.colors = state.render.colors ?? {}; - state.render.colors[part] = value; - app.actions.refresh('render'); - }; - const bodyColors = { - body: colorField({ label: '体', value: '#c8b0f0', onChange: (v) => setBodyColor('body', v) }), - accent: colorField({ label: '手', value: '#8a4fe0', onChange: (v) => setBodyColor('accent', v) }), - nose: colorField({ label: '鼻', value: '#7a4fb0', onChange: (v) => setBodyColor('nose', v) }), - leaf: colorField({ label: '葉', value: '#a6dd6a', onChange: (v) => setBodyColor('leaf', v) }), - vein: colorField({ label: '葉脈', value: '#7fbf3f', onChange: (v) => setBodyColor('vein', v) }), - feet: colorField({ label: '足', value: '#7a4fb0', onChange: (v) => setBodyColor('feet', v) }), - }; - for (const widget of Object.values(bodyColors)) colourSection.add(widget.el); + /* ---------------------------------------------------------- 環境の映りこみ */ + // The body colours are fixed (see `render.colors` in presets.js), so the 体の色 + // section is gone. The strength of the environment reflection is still a look + // setting, so it lives in 見た目の調整 below. const envIntensitySlider = slider({ label: '環境の映りこみ', min: 0, max: 2, step: 0.05, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.render.envIntensity = v; app.actions.refresh('render'); }, }); - colourSection.add(envIntensitySlider.el); - colourSection.add(buttons({ - items: [{ - id: 'resetColors', - label: 'プリセットの色に戻す', - onClick: () => { app.actions.applyTheme(state.render.theme ?? 'original'); sync(); }, - }], - })); /* -------------------------------------------------------------------- look */ @@ -1900,6 +1335,9 @@ export function buildPanel(app, root) { lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。' + '「接地影だけ」は足もとの丸い影だけにします。' + '「足もとの丸い影」は、足もとに薄く敷く丸い影です(はじめは出ていません)。')); + // 環境の映りこみ: the strength of the environment reflection, moved here from + // the old 体の色 section (the body colours themselves are fixed now). + lookSection.add(envIntensitySlider.el); const environmentSegment = segmented({ label: '照明の種類', @@ -2709,7 +2147,7 @@ export function buildPanel(app, root) { comicSection.add(controlRow(null, storyPanelBox, { wide: true })); let storyShape = ''; - /** Bring back everything a panel saved: pose, face, both bubbles and the camera. */ + /** Bring back everything a panel saved: pose, face, 擬音 and the camera. */ const recallStoryPanel = (index) => { const panel = state.story?.panels?.[index]; if (!panel) return; @@ -2718,8 +2156,6 @@ export function buildPanel(app, root) { app.actions.applyState({ pose: panel.pose ?? {}, face: panel.face ?? {}, - caption: panel.caption ?? {}, - caption2: panel.caption2 ?? {}, // The 擬音 on the shot. A panel saved before this existed clears them, so // recalling it shows what it actually recorded rather than a leftover. gion: panel.gion ?? { items: [] }, @@ -2734,7 +2170,7 @@ export function buildPanel(app, root) { state.story.panels = (state.story.panels ?? []).filter((_, i) => i !== index); // The empty patch is only here to route the edit through the app's own apply // path: that is what records an undo step and re-reads the panel. - app.actions.applyState({}, { scope: 'caption', sync: true }); + app.actions.applyState({}, { scope: 'all', sync: true }); }; /** @@ -2813,16 +2249,11 @@ export function buildPanel(app, root) { items: [ { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') }, { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') }, - { id: 'facemap-hair', label: '髪の下地を書き出す', onClick: () => app.actions.saveFaceMap('hair') }, ], })); faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。' - + 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や' - + '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。' - + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。' - + '「髪の下地」は、頭をぐるりと巻いたテクスチャを開いたもので、横が頭の角度、' - + 'たてが高さです。左右の端が頭の後ろ(合わせ目)、青いたて線が顔の正面、' - + '青いよこ線が既定の生え際です。')); + + 'そのまま絵を描いて「目の画像を読み込む(PNG)」や「口の画像を読み込む(PNG)」で読み込むと、' + + '描いた場所にそのまま入ります。青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。')); /* --------------------------------------------------------------- settings */ @@ -2859,13 +2290,12 @@ export function buildPanel(app, root) { infoSection.add(statsLine); infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。')); - // シーン: the scene controls are built wherever they belong in the code (the - // speech bubble sits with the face controls), so put them on the tab in the - // order the user meets them: what is behind the character, what is beside it, - // the speech bubble, then the light and the read-out. + // シーン: the scene controls are built wherever they belong in the code, so put + // them on the tab in the order the user meets them: what is behind the + // character, what is beside it, then the light and the read-out. tabScene.append( backdropSection.el, propSection.el, gionSection.el, wallSection.el, - captionSection.el, screenLightSection.el, infoSection.el, + screenLightSection.el, infoSection.el, ); boneSyncers.push(() => { @@ -2896,21 +2326,6 @@ export function buildPanel(app, root) { w.eyeX.set(eye.eyeX ?? 0); } - browToggle.set(state.face.eyes.brow.enabled); - browSliders.angle.set(state.face.eyes.brow.angle); - browSliders.height.set(state.face.eyes.brow.height); - browSliders.length.set(state.face.eyes.brow.length); - browSliders.thickness.set(state.face.eyes.brow.thickness); - browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0); - browSliders.curve.set(state.face.eyes.brow.curve); - browSliders.taper.set(state.face.eyes.brow.taper ?? 1); - // A loaded ゴル風 picture ignores the shape sliders, so grey those out rather - // than leave them looking like they should do something. - const browIsImage = state.face.eyes.brow.image === true; - for (const key of ['angle', 'thickness', 'curve', 'taper']) { - setRowEnabled(browSliders[key], !browIsImage); - } - browColor.set(state.face.eyes.brow.color); glassesToggle.set(state.face.eyes.glasses.enabled === true); glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses'); for (const [key, widget] of Object.entries(glassesSliders)) widget.set(state.face.eyes.glasses[key]); @@ -2922,24 +2337,10 @@ export function buildPanel(app, root) { }); syncers.push(() => { - cheeksToggle.set(state.face.eyes.cheeks.enabled === true); - for (const [key, widget] of Object.entries(cheekSliders)) widget.set(state.face.eyes.cheeks[key]); - cheekColor.set(state.face.eyes.cheeks.color); - headMarkShape.set(state.face.eyes.headMark.shape ?? 'off'); - headMarkSliders.teeth.el.style.display = (state.face.eyes.headMark.shape === 'fringe') ? '' : 'none'; - headMarkOverEyes.set(state.face.eyes.headMark.overEyes !== false); - for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]); - headMarkColor.set(state.face.eyes.headMark.color); - headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff'); snotToggle.set(state.face.eyes.snot.enabled === true); snotSize.set(state.face.eyes.snot.size); for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]); snotColor.set(state.face.eyes.snot.color); - beardShape.set(state.face.eyes.beard.shape ?? 'off'); - for (const [key, widget] of Object.entries(beardSliders)) widget.set(state.face.eyes.beard[key]); - // 「左右の間隔」は内部値より+10して表示する(既定の -10 が 0 に見える)。 - beardSliders.spacing.set((state.face.eyes.beard.spacing ?? 0) + 10); - beardColor.set(state.face.eyes.beard.color); }); syncers.push(() => { @@ -2996,10 +2397,6 @@ export function buildPanel(app, root) { }); syncers.push(() => { - themeSegment.set(state.render?.theme ?? 'original'); - for (const [key, widget] of Object.entries(bodyColors)) { - widget.set(state.render?.colors?.[key] ?? BODY_COLOR_FALLBACKS[key]); - } envIntensitySlider.set(state.render?.envIntensity ?? 1); }); @@ -3073,35 +2470,6 @@ export function buildPanel(app, root) { }); syncers.push(() => { - captionBubbleSegment.set(activeCaption); - const bubble = cap() ?? {}; - captionToggle.set(bubble.enabled === true); - captionText.set(bubble.text ?? ''); - verticalToggle.set(bubble.vertical === true); - bubbleSegment.set(bubble.bubble ?? 'round'); - tailSegment.set(bubble.tail ?? 'left'); - for (const [key, widget] of Object.entries(captionSliders)) { - widget.set(bubble[key] ?? CAPTION_SLIDER_FALLBACKS[key]); - } - captionPosition.x.set(bubble.x ?? 0.58); - captionPosition.y.set(bubble.y ?? 0.16); - captionMaxWidthSlider.set(bubble.maxWidth ?? 0.36); - alignSegment.set(bubble.align ?? 'left'); - boldToggle.set(bubble.bold === true); - systemFontToggle.set(bubble.font === 'system'); - for (const [key, widget] of Object.entries(captionColors)) { - widget.set(bubble[key] ?? CAPTION_COLOR_FALLBACKS[key]); - } - }); - - syncers.push(() => { - flapText.set(state.mouthFlap?.text ?? ''); - for (const [key, widget] of Object.entries(flapSliders)) { - widget.set(state.mouthFlap?.[key] ?? MOUTH_FLAP_FALLBACKS[key]); - } - }); - - syncers.push(() => { storyScaleSegment.set(String(state.story?.scale ?? 1.5)); storyColumnsSlider.set(state.story?.columns ?? 1); storyGapSlider.set(state.story?.gap ?? 12); @@ -3121,12 +2489,6 @@ export function buildPanel(app, root) { return { sync, onRigChanged: syncBones, - /** Which bubble the caption controls are editing (a drag on it calls this). */ - selectCaption(key) { - if (key !== 'caption' && key !== 'caption2' && key !== 'narration') return; - activeCaption = key; - sync(); - }, /** Which 擬音 stamp the viewport should outline (a drag on it calls this). */ selectGion(id) { app.gionSelected = id ?? null; |
