diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-04 00:43:33 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-04 00:43:33 +0900 |
| commit | 8c430aefc24545c100b34acea2824d837f5e0337 (patch) | |
| tree | 6e6c823b57936511438379cfed0ce3fbffe90ef3 | |
| parent | 3c7fc9271cdaf7b8ee11609d3c4e73df7ba9e0d9 (diff) | |
bluebey-studio: 顔タブの並び替えと髪の形を追加
顔タブを プリセット→髪→眉→目→見る先→…の順に。頭の模様を「髪」としてほっぺから分離。見る先を目の直下へ。なみを削除し、わけめを7:3分けに、まえがみをとがった形に、まえがみ(逆)を「はちわれ」に改名し、単純なカーブの形を追加。髪を目の前に出すか後ろに回すかを選べるようにした。
| -rw-r--r-- | public/bluebey-studio/src/face.js | 3 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 46 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 73 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 8 |
4 files changed, 79 insertions, 51 deletions
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index 273468a..e3063f2 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -259,6 +259,9 @@ export class Face { } if (this.hairMesh) { + // `overEyes` decides the draw order: the hair plate sits in front of the + // eyes (bangs over the face) or behind them. + this.hairMesh.renderOrder = p.eyes.headMark?.overEyes === false ? 0 : 3; const layout = this.hairLayout; const ctx = this.hairCtx; ctx.clearRect(0, 0, layout.width, layout.height); diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index c4940d4..7b5f7de 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -796,7 +796,7 @@ function drawCheeks(ctx, spec) { */ function drawHeadCover(ctx, spec, limits) { const shape = spec?.shape ?? 'off'; - if (shape === 'off' || shape === 'none') return; + if (shape === 'off' || shape === 'none' || shape === 'wave') return; const size = clamp(spec.size ?? 1, 0.02, 3); const left = limits.left; @@ -828,23 +828,14 @@ function drawHeadCover(ctx, spec, limits) { const halfSpan = width / 2; const boundary = (x) => { const t = clamp((x - cx) / halfSpan, -1, 1); - if (shape === 'fringe') { - // Bangs: a soft point dipping in the middle, with side lobes. - return base + 150 * (1 - Math.abs(t)) - 55 * Math.abs(Math.sin(t * Math.PI)); - } - if (shape === 'fringe-up') { - // The mirror image: a fringe that peaks *up* in the middle (a widow's peak - // or centre part), so more forehead shows in the middle than at the sides. - return base - 150 * (1 - Math.abs(t)) + 55 * Math.abs(Math.sin(t * Math.PI)); - } - if (shape === 'wave') { - // A single smooth sweep across the forehead. - return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5); - } - if (shape === 'side') { - // A side part: high on one side, low on the other. - return base + 260 * t; - } + // まえがみ: a narrow, deep V pointing down the middle (Atom / Mickey). + if (shape === 'fringe') return base + 175 * Math.max(0, 1 - Math.abs(t) * 1.6); + // はちわれ: the same V upside down - a widow's peak. + if (shape === 'fringe-up') return base - 320 * Math.max(0, 1 - Math.abs(t) * 1.5); + // カーブ: a plain shallow curve, higher (more hair) round the sides. + if (shape === 'curve') return base + 160 * t * t; + // わけめ: a 7:3 parting - a step at 30% from the left. + if (shape === 'side') return base + 60 - 320 / (1 + Math.exp(-(t + 0.4) * 10)); // `cap`: a rounded helmet, lowest in the middle. return base - 100 * (1 - t * t); }; @@ -864,7 +855,7 @@ function drawHeadCover(ctx, spec, limits) { if (shape === 'side') { // わけめ: cut a real parting in the hair, so the two halves read as combed // apart rather than as one smooth sweep. - const partX = cx - halfSpan * 0.45; + const partX = cx - halfSpan * 0.4; const gw = Math.max(5, width * 0.02) * (0.6 + size); ctx.save(); ctx.globalCompositeOperation = 'destination-out'; @@ -894,8 +885,8 @@ function drawHeadCover(ctx, spec, limits) { */ export function drawHeadCoverWrap(ctx, spec, layout) { const shape = spec?.shape ?? 'off'; - // `split` is a retired front-plate shape; it has no wrap equivalent. - if (shape === 'off' || shape === 'none' || shape === 'split') return; + // `split` and `wave` are retired front-plate shapes; they have no wrap equal. + if (shape === 'off' || shape === 'none' || shape === 'split' || shape === 'wave') return; const size = clamp(spec.size ?? 1, 0.02, 3); const W = layout.width; @@ -924,10 +915,15 @@ export function drawHeadCoverWrap(ctx, spec, layout) { const cx = layout.offsetX + span * 0.5 + clamp(spec.offsetX ?? 0, -600, 600); const vBound = (x) => { const t = clamp((x - cx) / half, -1, 1); - if (shape === 'fringe') return base + 0.14 * (1 - Math.abs(t)) - 0.05 * Math.abs(Math.sin(t * Math.PI)); - if (shape === 'fringe-up') return base - 0.14 * (1 - Math.abs(t)) + 0.05 * Math.abs(Math.sin(t * Math.PI)); - if (shape === 'wave') return base + 0.10 * Math.sin(t * Math.PI * 0.9 + 0.5); - if (shape === 'side') return base + 0.20 * t; + // まえがみ: a narrow, deep V pointing down the middle (Atom / Mickey). + if (shape === 'fringe') return base + 0.15 * Math.max(0, 1 - Math.abs(t) * 1.6); + // はちわれ: 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); + // カーブ: a plain shallow curve, lower (more hair) round the sides. + if (shape === 'curve') return base + 0.10 * t * t; + // わけめ: a 7:3 parting - a step at 30% from the left, so the larger side + // sits lower (more hair). + if (shape === 'side') return base + 0.08 - 0.16 / (1 + Math.exp(-(t + 0.4) * 10)); return base - 0.07 * (1 - t * t); // `cap` }; diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 63e501b..237d6c8 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -28,7 +28,8 @@ const SECTION_ICONS = { '目': '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', - 'ほっぺ・頭の模様': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM9.4 10.2h.01M14.6 10.2h.01M9.4 14.2h5.2', + '髪': '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', @@ -169,7 +170,7 @@ export function buildPanel(app, root) { right: randomEye(), cheeks: { enabled: Math.random() < 0.5 }, beard: { shape: beardShape, ...(BEARD_KIND_DEFAULTS[beardShape] ?? { size: 1, offsetY: 0, spacing: 0 }) }, - headMark: { shape: pick(['off', 'cap', 'fringe', 'fringe-up', 'wave', 'side']) }, + headMark: { shape: pick(['off', 'cap', 'fringe', 'fringe-up', 'curve', 'side']) }, brow: { enabled: true, angle: Math.round(rnd(-30, 40)), @@ -390,6 +391,21 @@ 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. + const faceTabSections = { + hair: section(tabFace, '髪'), + brow: section(tabFace, '眉'), + eye: section(tabFace, '目'), + look: section(tabFace, '見る先'), + glasses: section(tabFace, '眼鏡・サングラス'), + cheeks: section(tabFace, 'ほっぺ'), + snot: section(tabFace, '鼻ちょうちん'), + beard: section(tabFace, 'ひげ'), + mouth: section(tabFace, '口'), + }; + faceSection.add(buttons({ items: FACE_PRESETS.map((preset) => ({ id: preset.id, @@ -408,7 +424,7 @@ export function buildPanel(app, root) { } // --- eyes - faceSection = section(tabFace, '目'); + faceSection = faceTabSections.eye; const eyeWidgets = {}; // The link switch sits directly above the *left* eye's open slider, because that @@ -748,7 +764,7 @@ export function buildPanel(app, root) { })); // --- eyebrows (the original character has none; this is extra range) - faceSection = section(tabFace, '眉'); + faceSection = faceTabSections.brow; faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。')); const browToggle = check({ @@ -859,7 +875,7 @@ export function buildPanel(app, root) { })); // --- glasses / sunglasses (drawn into the same texture as the eyes and brows) - faceSection = section(tabFace, '眼鏡・サングラス'); + faceSection = faceTabSections.glasses; faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。' + '線画のときはレンズを塗らず、フレームの線だけになります。')); @@ -988,11 +1004,11 @@ export function buildPanel(app, root) { ], })); - // --- ほっぺ (a manga blush) and 頭の模様 (a mark on the head) - faceSection = section(tabFace, 'ほっぺ・頭の模様'); - faceSection.add(hint('ほっぺ(マンガの赤らみ)と、頭の模様を、目や眉と同じ絵にかけます。' - + 'どちらも決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。' - + 'はじめは両方とも出ていません。')); + // --- ほっぺ (a manga blush) + faceSection = faceTabSections.cheeks; + faceSection.add(hint('ほっぺ(マンガの赤らみ)を、目や眉と同じ絵にかけます。' + + '決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。' + + 'はじめは出ていません。')); const cheeksToggle = check({ label: 'ほっぺを出す', @@ -1028,14 +1044,19 @@ export function buildPanel(app, root) { 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: '頭の模様', + label: '髪', options: [ { value: 'off', label: 'なし' }, { value: 'cap', label: 'かぶせ' }, { value: 'fringe', label: 'まえがみ' }, - { value: 'fringe-up', label: 'まえがみ(逆)' }, - { value: 'wave', label: 'なみ' }, + { value: 'fringe-up', label: 'はちわれ' }, + { value: 'curve', label: 'カーブ' }, { value: 'side', label: 'わけめ' }, ], value: 'off', @@ -1043,6 +1064,13 @@ export function buildPanel(app, root) { }); 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), @@ -1062,7 +1090,7 @@ export function buildPanel(app, root) { headMarkBox.add(headMarkSliders.offsetX.el); headMarkBox.add(headMarkSliders.offsetY.el); const headMarkColor = colorField({ - label: '模様の色', value: '#8a4fe0', + label: '髪の色', value: '#8a4fe0', swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'], onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); }, }); @@ -1073,13 +1101,13 @@ export function buildPanel(app, root) { }); headMarkBox.add(headMarkColor.el); headMarkBox.add(headMarkColor2.el); - headMarkBox.add(hint('「頭の模様」は、頭にかぶせる髪のような塗りです。' - + '「まえがみ(逆)」は、まえがみを上下さかさまにした形です。' - + '「わけめ」は、髪を分けた筋が入ります。' - + '決まったキャラクターの髪ではなく、よくあるマンガの記号として用意しました。')); + headMarkBox.add(hint('「かぶせ」は頭にかぶせる丸い髪、「まえがみ」は中央がとがった前髪、' + + '「はちわれ」は中央が逆に持ち上がった生え際、「カーブ」はドラえもんのように単純にカーブした髪、' + + '「わけめ」は7:3に分けた髪です。「たかさ」で生え際を目の中心あたりまで下げられます。' + + '「髪を目の前に出す」を切ると、髪が目の後ろに回ります。')); // --- 鼻ちょうちん: the snot bubble of a sleeping face. - faceSection = section(tabFace, '鼻ちょうちん'); + faceSection = faceTabSections.snot; const snotToggle = check({ label: '鼻ちょうちんを出す', value: false, @@ -1118,7 +1146,7 @@ export function buildPanel(app, root) { + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。')); // --- ひげ: mustaches and beards, hung under the nose. - faceSection = section(tabFace, 'ひげ'); + faceSection = faceTabSections.beard; const beardShape = segmented({ label: 'ひげ', options: [ @@ -1179,7 +1207,7 @@ export function buildPanel(app, root) { + '「左右の間隔」「瓢の長さ」「線の間隔」は「ねこ」で使います。')); // --- mouth - faceSection = section(tabFace, '口'); + faceSection = faceTabSections.mouth; const mouthToggle = check({ label: '口を表示', @@ -1483,7 +1511,7 @@ export function buildPanel(app, root) { + '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。')); // --- 見る先 (moved here, next to the face controls, from the pose tab) ---- - const lookAtSection = section(tabFace, '見る先'); + const lookAtSection = faceTabSections.look; // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too. const refreshLookAt = () => { app.actions.refresh('lookAt'); @@ -2684,6 +2712,7 @@ export function buildPanel(app, root) { 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'); + 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'); diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index 3a79d9b..40dc377 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -223,15 +223,15 @@ export function defaultState() { offsetY: 0, // extra drop below the eyes color: '#f6a6b8', }, - // The head covering's `shape` is one of `off`, `cap`, `fringe`, `wave`, - // `side` (the boundary style) or `split` (a two-tone head divided by a - // vertical curve). `color` is the covering / left half; `color2` is the - // second colour of the two-tone split. + // The hair covering's `shape` is one of `off`, `cap`, `fringe`, + // `fringe-up`, `curve` or `side`. `overEyes` puts the hair in front of + // the eyes (bangs over the face); off leaves the eyes on top. headMark: { shape: 'off', size: 1, offsetX: 0, offsetY: 0, + overEyes: true, color: '#8a4fe0', color2: '#ffffff', }, |
