diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-04 23:10:56 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-04 23:10:56 +0900 |
| commit | bbefd07f238dd0cecb82fa75d36ffcffe9613ef8 (patch) | |
| tree | 63721d3e787e096695d826db06a6f9a461db8476 | |
| parent | adc9bc266ca9795cd153c8425bf8f31f6ad31ac7 (diff) | |
bluebey-studio: まえがみ・わけめ・白目の調整と目/口メニュー整理など
まえがみを大きさ0でもM字に保つよう調整。わけめの切り込みを浅くした。白目の表示/非表示を線まぶたでも選べるように。目の涙と手描きパーツをこまかい設定へ、口の幅をこまかい設定へ移動し口のパーツ選択を削除。Undo/?!バーを固定表示に。プリセットに「てれ」「じと目」を追加し、ねぼけに鼻ちょうちんを表示。
| -rw-r--r-- | public/bluebey-studio/src/face.js | 1 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 40 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 70 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 37 | ||||
| -rw-r--r-- | public/bluebey-studio/src/style.css | 15 |
5 files changed, 102 insertions, 61 deletions
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index 65c16d9..78b2b8d 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -237,6 +237,7 @@ export class Face { lowerLid: p.eyes.lowerLid, lidShape: p.eyes.lidShape, lidTilt: p.eyes.lidTilt, + hideWhite: p.eyes.hideWhite === true, brow: p.eyes.brow, // ほっぺ and 頭の模様 are shared by the whole face, so they travel with // the other shared eye fields (see `drawEyes` in src/faceArt.js). diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index fab975a..48ffd67 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -962,9 +962,16 @@ export function drawHeadCoverWrap(ctx, spec, layout) { // centre (the peak) and at the temples, with the corners between receded - // the three peaks and two valleys of an "M". `t` is -1..1 across the front. if (shape === 'fringe') { - const m = (1 + Math.cos(2 * Math.PI * t)) / 2; // peaks at t = 0, +-1 - const peak = Math.max(0, 1 - Math.abs(t) * 3); // sharper central point - return base + 0.26 * m + 0.12 * peak; + const m = (1 + Math.cos(2 * Math.PI * t)) / 2; // wide peaks at t = 0, +-1 + const peak = Math.max(0, 1 - Math.abs(t) * 1.6); // the central point + const shape01 = 0.55 * m + 0.45 * peak; // 0..1 across the M + // The recessed corners keep a hairline, and the peaks scale with `size`, so + // a small まえがみ stays an M instead of collapsing into a spindle. + const valley = Math.max(base, 0.10); + // The peaks deepen with `size`, so a small まえがみ is a small M rather + // than a tall spike on a thin band. + const amp = 0.36 * (0.55 + 0.45 * Math.min(clamp(size, 0.05, 3), 1)); + return valley + amp * shape01; } // はちわれ: the same V upside down - a widow's peak. if (shape === 'fringe-up') return base - 0.18 * Math.max(0, 1 - Math.abs(t) * 1.5); @@ -995,17 +1002,20 @@ export function drawHeadCoverWrap(ctx, spec, layout) { ctx.fill(); if (shape === 'side') { - // The parting: a thin wedge cut out at 30% (a 7:3 split), running from the - // top of the head down to the hairline. + // The parting: a shallow notch cut up from the hairline at 30% (a 7:3 split). + // Cutting all the way to the top of the head made it look bald, so only the + // lower part of the covering is cut. const partX = span * 0.4; - const gw = Math.max(8, span * 0.016) * (0.6 + size); + const gw = Math.max(6, span * 0.012) * (0.6 + size); + const bY = clamp(vBound(partX), 0, 1) * H; + const top = Math.max(0, bY - Math.min(110, bY * 0.6)); ctx.save(); ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); - ctx.moveTo(partX - gw * 0.5, 0); - ctx.lineTo(partX + gw * 0.5, 0); - ctx.lineTo(partX + gw * 1.2, clamp(vBound(partX), 0, 1) * H + 2); - ctx.lineTo(partX - gw * 1.2, clamp(vBound(partX), 0, 1) * H + 2); + ctx.moveTo(partX - gw * 0.35, top); + ctx.lineTo(partX + gw * 0.35, top); + ctx.lineTo(partX + gw * 1.1, bY + 2); + ctx.lineTo(partX - gw * 1.1, bY + 2); ctx.closePath(); ctx.fill(); ctx.restore(); @@ -1437,11 +1447,11 @@ export function drawEyes(ctx, p) { dy = (dy / dist) * eyeTravel; } - // The eyeball's white is only painted in the shaded styles: in line art the - // paper behind shows through, which is what a line drawing wants. (The SVG - // export traces the alpha mask, so a white fill would close the eye up - // completely.) - if (!line && !(isHeart && style.heartHideWhite)) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); + // The eyeball's white can be hidden: for a heart it stops the white showing + // round the lobes, and `hideWhite` does the same for any style (e.g. a line + // squint) when the user asks for it. + const hideWhite = style.hideWhite === true || (isHeart && style.heartHideWhite); + if (!line && !hideWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); const irisX = eye.cx + dx; const irisY = eye.cy + dy; if (isHeart) { diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 5ef2b9c..b8f1d63 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -427,6 +427,8 @@ export function buildPanel(app, root) { 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 // is the slider it drives - it was previously below both eyes, where it looked // like it belonged to whatever came next. @@ -572,10 +574,8 @@ export function buildPanel(app, root) { faceSection.add(closedLines.el); faceSection.add(threeFlip.el); faceSection.add(eyeX.el); - faceSection.add(tear.el); - faceSection.add(tearY.el); - faceSection.add(tearX.el); - faceSection.add(tearTilt.el); + // The tears go into the こまかい設定 box later, with the other extras. + tearWidgets.push(tear.el, tearY.el, tearX.el, tearTilt.el); } // One button to put every eye setting back to the artwork's defaults. The @@ -663,10 +663,17 @@ export function buildPanel(app, root) { onInput: (value) => { state.face.eyes.lidTilt = value; app.actions.refresh('face'); }, }); faceSection.add(lidTiltSlider.el); + const hideWhiteToggle = check({ + label: '白目を消す', + value: false, + onChange: (value) => { state.face.eyes.hideWhite = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, hideWhiteToggle.el, { wide: true })); faceSection.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。' - + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。')); + + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。' + + '「白目を消す」は、細めた目やハートのときに白目を出しません。')); - const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・ハート・涙・まぶた)'); + const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・色・ハート・まぶた・涙・手描き)'); const irisScaleSlider = slider({ label: '瞳の大きさ', min: 0.4, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (value) => { state.face.eyes.irisScale = value; app.actions.refresh('face'); }, @@ -722,6 +729,10 @@ export function buildPanel(app, root) { const eyeColorBox = details(faceSection.body, '色(白目・瞳・まぶたの線)'); for (const widget of Object.values(eyeColors)) eyeColorBox.add(widget.el); + // Tears (per eye) live here with the other extras. + const tearBox = details(eyeAdvanced.body, '涙(左右べつ)'); + for (const el of tearWidgets) tearBox.add(el); + const eyeSource = selectField({ label: '目のパーツ', // 実際にモデルに入っている手描きパーツだけを一覧に出す(消したものが残ると混乱するため) @@ -733,7 +744,7 @@ export function buildPanel(app, root) { value: 'parametric', onChange: (value) => { state.face.eyes.source = value; app.actions.refresh('face'); }, }); - faceSection.add(eyeSource.el); + eyeAdvanced.add(eyeSource.el); // 手描き画像の読み込み(GIMP などで描いた PNG をそのまま使えます) const rebuildEyeOptions = () => { @@ -770,8 +781,8 @@ export function buildPanel(app, root) { toast('画像を読み込めませんでした(PNG / JPG をお使いください)'); } }); - faceSection.add(eyeImagePicker); - faceSection.add(buttons({ + eyeAdvanced.add(eyeImagePicker); + eyeAdvanced.add(buttons({ items: [{ id: 'eyeimg', label: '目の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) eyeImagePicker.click(); } }], })); @@ -1257,10 +1268,12 @@ export function buildPanel(app, root) { corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }), cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }), }; - const mouthVisible = ['smile', 'round', 'width', 'length', 'thickness', 'tilt', 'offsetY']; + const mouthVisible = ['smile', 'round', 'length', 'thickness', 'tilt', 'offsetY']; for (const key of mouthVisible) faceSection.add(mouthSliders[key].el); - const mouthBox = details(faceSection.body, 'こまかい設定(開き・舌・口角)'); - for (const key of ['open', 'tongue', 'tonguePos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el); + faceSection.add(hint('「口の長さ」は口の横の長さです。「口の幅」は丸い口(O)の横の広がりで、' + + 'こまかい設定にあります。')); + const mouthBox = details(faceSection.body, 'こまかい設定(幅・開き・舌・口角)'); + for (const key of ['width', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el); mouthBox.add(buttons({ items: [{ id: 'mouthreset', @@ -1282,37 +1295,6 @@ export function buildPanel(app, root) { const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)'); for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el); - const mouthSource = selectField({ - label: '口のパーツ', - options: [ - { value: 'parametric', label: 'パラメータで作る(おすすめ)' }, - { value: 'original', label: '手描き(原本)' }, - ], - value: 'parametric', - onChange: (value) => { state.face.mouth.source = value; app.actions.refresh('face'); }, - }); - faceSection.add(mouthSource.el); - - const mouthImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } }); - mouthImagePicker.addEventListener('change', async () => { - const file = mouthImagePicker.files?.[0]; - mouthImagePicker.value = ''; - if (!file) return; - try { - await app.face.loadImage('mouth', file); - state.face.mouth.source = 'original'; - sync(); - toast(`口の画像「${file.name}」を読み込みました`); - } catch (error) { - console.error(error); - toast('画像を読み込めませんでした(PNG / JPG をお使いください)'); - } - }); - faceSection.add(mouthImagePicker); - faceSection.add(buttons({ - items: [{ id: 'mouthimg', label: '口の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) mouthImagePicker.click(); } }], - })); - /* ---------------------------------------------------------------- caption */ const captionSection = section(tabScene, 'セリフ(吹き出し)'); @@ -2711,6 +2693,7 @@ export function buildPanel(app, root) { lowerLidSlider.set(state.face.eyes.lowerLid ?? 0); lidShapeSegment.set(state.face.eyes.lidShape ?? 'curve'); lidTiltSlider.set(state.face.eyes.lidTilt ?? 0); + hideWhiteToggle.set(state.face.eyes.hideWhite === true); browToggle.set(state.face.eyes.brow.enabled); browSliders.angle.set(state.face.eyes.brow.angle); browSliders.height.set(state.face.eyes.brow.height); @@ -2756,7 +2739,6 @@ export function buildPanel(app, root) { mouthToggle.set(state.face.mouth.visible); for (const [key, widget] of Object.entries(mouthSliders)) widget.set(state.face.mouth[key]); for (const [key, widget] of Object.entries(mouthColors)) widget.set(state.face.mouth[key]); - mouthSource.set(state.face.mouth.source); }); syncers.push(() => { diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index 0862fcd..9578db4 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -208,6 +208,9 @@ export function defaultState() { // narrowed eye. See faceArt.js. lidShape: 'curve', lidTilt: 0, + // Hide the eyeball's white everywhere (not only for a heart): a line + // squint then reads as a bare line. + hideWhite: false, heartScale: 2, heartColor: '#e0344f', heartHideWhite: true, @@ -463,11 +466,45 @@ export const FACE_PRESETS = [ 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. + left: { open: 0.55, lookX: 0.4, lookY: -0.12, closed: 'line' }, + right: { open: 0.55, 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: { diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css index 57d5403..bb76265 100644 --- a/public/bluebey-studio/src/style.css +++ b/public/bluebey-studio/src/style.css @@ -225,13 +225,19 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); } } body.panel-hidden #topbar { max-width: none; } + + /* The grab handle is sticky at the top of the sheet, so the action bar and the + tabs start below it. */ + .panel-topbar { top: 17px; } + .tabs { top: 51px; } } /* ----------------------------------------------------------------- sections */ .tabs { position: sticky; - top: 0; + /* Sits just below the sticky .panel-topbar (which is 34px tall). */ + top: 34px; z-index: 3; display: flex; align-items: flex-end; @@ -784,8 +790,13 @@ kbd { text-align: center; } -/* The always-visible action bar above the tabs: 元に戻す / やり直す / すべてランダム. */ +/* The always-visible action bar above the tabs: 元に戻す / やり直す / すべてランダム. + Sticky, so it (and the tabs below it) stay on screen while the panel scrolls. */ .panel-topbar { + position: sticky; + top: 0; + z-index: 5; + background: #fdfcff; display: flex; gap: 4px; justify-content: flex-end; |
