diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 00:21:36 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 00:21:36 +0900 |
| commit | 8e02615f8e3a8401c4300cb7c14362251bda57c4 (patch) | |
| tree | 956fa75bac229d74ce571f1e14d3577b9f2a468f /public/bluebey-studio/src/panel.js | |
| parent | 629dfd09777d1abb3cdecd8fc58d18234d1063cf (diff) | |
bluebey-studio: 目のメニューを両目/左右のグループ構成に整理
Diffstat (limited to 'public/bluebey-studio/src/panel.js')
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 306 |
1 files changed, 145 insertions, 161 deletions
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index b2c3519..6cf8c40 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -440,7 +440,6 @@ export function buildPanel(app, root) { // --- eyes faceSection = faceTabSections.eye; - const eyeWidgets = {}; /** Tear sliders, which are added to the こまかい設定 box once it exists. */ const tearWidgets = []; // The link switch sits directly above the *left* eye's open slider, because that @@ -459,6 +458,7 @@ export function buildPanel(app, root) { state.face.eyes.right.closedLines = state.face.eyes.left.closedLines; state.face.eyes.right.irisShape = state.face.eyes.left.irisShape; state.face.eyes.right.white = state.face.eyes.left.white ?? null; + state.face.eyes.right.highlight = state.face.eyes.left.highlight ?? null; state.face.eyes.right.lookX = state.face.eyes.left.lookX; state.face.eyes.right.lookY = state.face.eyes.left.lookY; // The tears mirror as a pair: the size and the height match, while the @@ -499,22 +499,36 @@ export function buildPanel(app, root) { // The usual: an open eye shows its white; a shut eye or a heart does not. return eyeShapeOf(eye) === 'open'; }; + const eyeHighlightOf = (eye) => (eye.highlight != null + ? eye.highlight === true + : state.face.eyes.highlight !== false); + + // Each eye group carries the same controls. Linked, one group ("両目") drives + // both eyes; unlinked, a group per eye appears and the gaze pad doubles. + const eyeGroup = (title) => { + const body = h('div', { class: 'eye-group-body' }); + const el = h('div', { class: 'eye-group' }, h('div', { class: 'eye-group-head', text: title }), body); + return { + el, + add: (child) => (body.append(child?.el ?? child), child), + setVisible: (visible) => { el.style.display = visible ? '' : 'none'; }, + }; + }; + + const makeEyeControls = (mode) => { + const keys = mode === 'both' ? ['left', 'right'] : [mode]; + const primary = keys[0]; + const write = (fn) => { for (const k of keys) fn(state.face.eyes[k]); }; + const touched = () => { app.actions.refresh('face'); sync(); }; + const group = eyeGroup(mode === 'both' ? '両目' : (mode === 'left' ? '左の目' : '右の目')); - for (const key of ['left', 'right']) { - const label = key === 'left' ? '左の目' : '右の目'; const open = slider({ - label: `${label}の開き`, - min: 0, max: 1, step: 0.01, value: 1, + label: '目の開き', min: 0, max: 1, step: 0.01, value: 1, format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`), - onInput: (value) => { - state.face.eyes[key].open = value; - if (state.face.eyes.linked) state.face.eyes[key === 'left' ? 'right' : 'left'].open = value; - app.actions.refresh('face'); - sync(); - }, + onInput: (v) => { write((e) => { e.open = v; }); touched(); }, }); const shape = segmented({ - label: `${label}の形`, + label: '目の形', options: [ { value: 'open', label: 'ふつう' }, { value: 'line', label: '線' }, @@ -523,15 +537,10 @@ export function buildPanel(app, root) { { value: 'heart', label: 'ハート' }, ], value: 'open', - onChange: (value) => { - applyEyeShape(state.face.eyes[key], value); - if (state.face.eyes.linked) applyEyeShape(state.face.eyes[key === 'left' ? 'right' : 'left'], value); - app.actions.refresh('face'); - sync(); - }, + onChange: (v) => { write((e) => applyEyeShape(e, v)); touched(); }, }); const closedLines = segmented({ - label: `${label}の線の数`, + label: '線の数', options: [ { value: '1', label: '1本' }, { value: '2', label: '2本' }, @@ -539,45 +548,78 @@ export function buildPanel(app, root) { ], value: '1', hint: '2本以上は端点がつながった形になります', - onChange: (value) => { - state.face.eyes[key].closedLines = Number(value); - applyEyeShape(state.face.eyes[key], 'line'); - if (state.face.eyes.linked) { - const other = state.face.eyes[key === 'left' ? 'right' : 'left']; - other.closedLines = Number(value); - applyEyeShape(other, 'line'); - } - app.actions.refresh('face'); - sync(); - }, + onChange: (v) => { write((e) => { e.closedLines = Number(v); applyEyeShape(e, 'line'); }); touched(); }, }); - // The direction of a "3" eye. It used to be tied to the nose side, which made - // the pair a mirror image; now each eye says which way its 3 faces. const threeFlip = segmented({ - label: `${label}の3の向き`, + label: '3の向き', options: [ { value: 'normal', label: '3' }, { value: 'flip', label: '3(逆)' }, ], value: 'normal', - hint: '「3の目」のときの向きです。左右で別々に選べます', - onChange: (value) => { - state.face.eyes[key].threeFlip = value === 'flip'; - applyEyeShape(state.face.eyes[key], 'three'); - app.actions.refresh('face'); - sync(); - }, + onChange: (v) => { write((e) => { e.threeFlip = v === 'flip'; applyEyeShape(e, 'three'); }); touched(); }, + }); + const white = check({ + label: '白目を出す', + value: true, + onChange: (v) => { write((e) => { e.white = v; }); touched(); }, + }); + const highlight = check({ + label: '光彩(瞳の白い丸)を出す', + value: true, + onChange: (v) => { write((e) => { e.highlight = v; }); touched(); }, }); - // Where this eye sits: per eye, so a lopsided face (one eye slid across) is - // possible. const eyeX = slider({ - label: `${label}のよこ位置`, min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (value) => { state.face.eyes[key].eyeX = value; app.actions.refresh('face'); }, + label: '横位置', min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { write((e) => { e.eyeX = v; }); touched(); }, + }); + const pad = xyPad({ + value: { x: 0, y: 0 }, + center: 'eye', + onChange: ({ x, y }) => { write((e) => { e.lookX = x; e.lookY = y; }); touched(); }, }); - // Tears are per eye, and so are where they hang and which way they lean. - // When the eyes are linked, a tear edit mirrors onto the other eye: the size - // and the height are copied, but the sideways position and the tilt flip - // sign. Two drops leaning the same way read as a tilted face, not as crying. + + group.add(open); + group.add(shape); + group.add(closedLines); + group.add(threeFlip); + group.add(controlRow(null, white.el, { wide: true })); + group.add(controlRow(null, highlight.el, { wide: true })); + group.add(eyeX); + group.add(controlRow('目線', h('div', { class: 'pad-wrap' }, pad.el, + h('p', { class: 'hint', text: 'ドラッグで目線。ダブルクリックで正面に戻ります。' })))); + + const syncOne = () => { + const e = state.face.eyes[primary]; + const shapeValue = eyeShapeOf(e); + open.set(e.open); + shape.set(shapeValue); + closedLines.set(String(e.closedLines ?? 1)); + closedLines.el.style.display = shapeValue === 'line' ? '' : 'none'; + threeFlip.set(e.threeFlip ? 'flip' : 'normal'); + threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none'; + white.set(eyeWhiteOf(e)); + highlight.set(eyeHighlightOf(e)); + eyeX.set(e.eyeX ?? 0); + pad.set({ x: e.lookX ?? 0, y: e.lookY ?? 0 }); + }; + return { group, sync: syncOne }; + }; + + const bothControls = makeEyeControls('both'); + const leftControls = makeEyeControls('left'); + const rightControls = makeEyeControls('right'); + + faceSection.add(controlRow(null, linkedToggle.el, { wide: true })); + faceSection.add(bothControls.group); + faceSection.add(leftControls.group); + faceSection.add(rightControls.group); + + // 下まぶた・涙・目線スライダー (per eye) live in こまかい設定; the tears mirror + // onto the other eye while the two are linked. + const eyeAdvancedWidgets = {}; + for (const key of ['left', 'right']) { + const label = key === 'left' ? '左の目' : '右の目'; const otherKey = key === 'left' ? 'right' : 'left'; const setTear = (patch) => { Object.assign(state.face.eyes[key], patch); @@ -606,7 +648,7 @@ export function buildPanel(app, root) { label: `${label}の涙の傾き`, min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (value) => setTear({ tearTilt: value }), }); - const lowerLidEach = slider({ + const lowerLid = slider({ label: `${label}の下まぶた`, min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (value) => { state.face.eyes[key].lowerLid = value; app.actions.refresh('face'); }, }); @@ -618,29 +660,8 @@ export function buildPanel(app, root) { label: `${label}の目線(たて)`, min: -1, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (value) => { state.face.eyes[key].lookY = value; app.actions.refresh('face'); }, }); - // 白目: whether this eye shows the white of the eyeball. Stored tri-state (a - // null means "the usual" - open eyes show it, shut ones do not), shown as a - // plain on/off. - const white = check({ - label: `${label}の白目`, - value: true, - onChange: (value) => { - state.face.eyes[key].white = value; - if (state.face.eyes.linked) state.face.eyes[key === 'left' ? 'right' : 'left'].white = value; - app.actions.refresh('face'); - sync(); - }, - }); - eyeWidgets[key] = { open, shape, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt, lowerLid: lowerLidEach, lookX, lookY, white }; - if (key === 'left') faceSection.add(controlRow(null, linkedToggle.el, { wide: true })); - faceSection.add(open.el); - faceSection.add(shape.el); - faceSection.add(closedLines.el); - faceSection.add(threeFlip.el); - faceSection.add(controlRow(null, white.el, { wide: true })); - faceSection.add(eyeX.el); - // 目線 and 下まぶた (per eye) and the tears go into the こまかい設定 box later. - tearWidgets.push(lowerLidEach.el, lookX.el, lookY.el, tear.el, tearY.el, tearX.el, tearTilt.el); + eyeAdvancedWidgets[key] = { tear, tearY, tearX, tearTilt, lowerLid, lookX, lookY }; + tearWidgets.push(lowerLid.el, lookX.el, lookY.el, tear.el, tearY.el, tearX.el, tearTilt.el); } // One button to put every eye setting back to the artwork's defaults. The @@ -659,75 +680,41 @@ export function buildPanel(app, root) { }], })); - const linkedNote = hint('「左右を連動させる」を入れると、開き・形・線の数・白目・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。'); - faceSection.add(linkedNote); + faceSection.add(hint('「左右を連動させる」を入れると「両目」の設定が1つだけ出て、開き・形・白目・光彩・目線が両目でそろいます。' + + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。' + + '涙のよこ位置と傾きは左右で反転し、線対称になります。')); - // 光彩 (the white glint in the iris). Kept here beside the eye shape, because it - // is part of the eye's look rather than an advanced extra. - const highlightToggle = check({ - label: '光彩(瞳の白い丸)', - value: true, - onChange: (value) => { state.face.eyes.highlight = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, highlightToggle.el, { wide: true })); - - // Drag the gaze of one eye at a time. `linked` copies the movement onto the - // other eye, so a linked pair still moves together. - let lookTarget = 'left'; - const lookEyeSegment = segmented({ - label: '動かす目', - options: [ - { value: 'left', label: '左の目' }, - { value: 'right', label: '右の目' }, - ], - value: 'left', - onChange: (value) => { - lookTarget = value; - const eye = state.face.eyes[value]; - lookPad.set({ x: eye.lookX ?? 0, y: eye.lookY ?? 0 }); - }, - }); - faceSection.add(lookEyeSegment.el); - - const lookPad = xyPad({ - value: { x: 0, y: 0 }, - center: 'eye', - onChange: ({ x, y }) => { - const other = lookTarget === 'left' ? 'right' : 'left'; - state.face.eyes[lookTarget].lookX = x; - state.face.eyes[lookTarget].lookY = y; - if (state.face.eyes.linked) { - state.face.eyes[other].lookX = x; - state.face.eyes[other].lookY = y; - } - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(controlRow('目線', h('div', { class: 'pad-wrap' }, - lookPad.el, - h('p', { class: 'hint', text: 'ドラッグで「動かす目」を動かします。ダブルクリックで正面に戻ります。' })))); - - // 目を細める: a one-tap マンガの細目, kept out of the こまかい設定 box so it is - // easy to find. - faceSection.add(buttons({ - items: [{ - id: 'squint', - label: '目を細める', - onClick: () => { - // The upper lid comes partway down and the lower lid rises to meet it. - // `open` is per-eye (kept in step here); `lowerLid` is shared. - state.face.eyes.left.open = 0.5; - state.face.eyes.right.open = 0.5; - state.face.eyes.lowerLid = 0.5; - app.actions.refresh('face'); - sync(); + // 目を細める (and its undo) grouped with the lid shape and tilt that shape the + // squint, so the whole narrowed-eye look sits together. + const squintGroup = eyeGroup('目を細める'); + squintGroup.add(buttons({ + items: [ + { + id: 'squint', + label: '目を細める', + onClick: () => { + // The upper lid comes partway down and the lower lid rises to meet it. + // `open` is per eye (kept in step here); `lowerLid` is shared. + state.face.eyes.left.open = 0.5; + state.face.eyes.right.open = 0.5; + state.face.eyes.lowerLid = 0.5; + app.actions.refresh('face'); + sync(); + }, }, - }], + { + id: 'unsquint', + label: '目を細めない', + onClick: () => { + state.face.eyes.left.open = 1; + state.face.eyes.right.open = 1; + state.face.eyes.lowerLid = 0; + app.actions.refresh('face'); + sync(); + }, + }, + ], })); - - // まぶたの形 and 目の傾き sit right next to 目を細める, so the narrowed eye can be - // tuned without hunting inside こまかい設定. const lidShapeSegment = segmented({ label: 'まぶたの形', options: [ @@ -737,14 +724,15 @@ export function buildPanel(app, root) { value: 'curve', onChange: (value) => { state.face.eyes.lidShape = value; app.actions.refresh('face'); }, }); - faceSection.add(lidShapeSegment.el); const lidTiltSlider = slider({ label: 'まぶたの傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (value) => { state.face.eyes.lidTilt = value; app.actions.refresh('face'); }, }); - faceSection.add(lidTiltSlider.el); - faceSection.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。' + squintGroup.add(lidShapeSegment.el); + squintGroup.add(lidTiltSlider.el); + squintGroup.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。' + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。')); + faceSection.add(squintGroup); const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・色・まぶたの太さ・左右べつの設定・手描き)'); const irisScaleSlider = slider({ @@ -2749,30 +2737,26 @@ export function buildPanel(app, root) { syncers.push(...boneSyncers); syncers.push(() => { + const linked = state.face.eyes.linked !== false; + bothControls.group.setVisible(linked); + leftControls.group.setVisible(!linked); + rightControls.group.setVisible(!linked); + bothControls.sync(); + leftControls.sync(); + rightControls.sync(); + for (const key of ['left', 'right']) { const eye = state.face.eyes[key]; - const shapeValue = eyeShapeOf(eye); - eyeWidgets[key].open.set(eye.open); - eyeWidgets[key].shape.set(shapeValue); - eyeWidgets[key].closedLines.set(String(eye.closedLines ?? 1)); - eyeWidgets[key].closedLines.el.style.display = shapeValue === 'line' ? '' : 'none'; - eyeWidgets[key].threeFlip.set(eye.threeFlip ? 'flip' : 'normal'); - // The direction only means anything for the "3" eye; the line count only - // for the plain line. - eyeWidgets[key].threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none'; - eyeWidgets[key].white.set(eyeWhiteOf(eye)); - eyeWidgets[key].tear.set(eye.tear ?? 0); - eyeWidgets[key].tearY.set(eye.tearY ?? 0); - eyeWidgets[key].tearX.set(eye.tearX ?? 0); - eyeWidgets[key].tearTilt.set(eye.tearTilt ?? 0); - eyeWidgets[key].eyeX.set(eye.eyeX ?? 0); - eyeWidgets[key].lookX.set(eye.lookX ?? 0); - eyeWidgets[key].lookY.set(eye.lookY ?? 0); - eyeWidgets[key].lowerLid.set(eye.lowerLid ?? state.face.eyes.lowerLid ?? 0); + const w = eyeAdvancedWidgets[key]; + w.tear.set(eye.tear ?? 0); + w.tearY.set(eye.tearY ?? 0); + w.tearX.set(eye.tearX ?? 0); + w.tearTilt.set(eye.tearTilt ?? 0); + w.lowerLid.set(eye.lowerLid ?? state.face.eyes.lowerLid ?? 0); + w.lookX.set(eye.lookX ?? 0); + w.lookY.set(eye.lookY ?? 0); } - lookPad.set({ x: state.face.eyes[lookTarget].lookX ?? 0, y: state.face.eyes[lookTarget].lookY ?? 0 }); - highlightToggle.set(state.face.eyes.highlight); irisScaleSlider.set(state.face.eyes.irisScale); heartColorField.set(state.face.eyes.heartColor ?? '#e0344f'); lidWidthSlider.set(state.face.eyes.lidWidth ?? 14); |
