diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-06 09:56:21 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-06 09:56:21 +0900 |
| commit | 8f39a174d6c4415c7a4df418187090fb5e061983 (patch) | |
| tree | dbd3cf1e4f67393f029a455c4226479fbefe4804 /public/bluebey-studio/src/panel.js | |
| parent | 63c1a9dfa8b04ab9725a9a49ef34cd3fc6ade56c (diff) | |
bluebey-studio: ボーンのたて回転・縦書きと吹き出しの形・線画の帽子小物・光の色
ボーンのたてスライダーが±180°で暴れる問題を修正。セリフの縦書きを禁則込みで直し、「ささやき」「思考」の吹き出しを追加。帽子・小物の輪郭線を線画のみにし、線画で帯や芯の境目が読めるよう調整。眼鏡の左右レンズ間隔、光と環境光の色を追加。ハートの下まぶた、レンズが濃いときのまばたきを修正。口メニューの項目名を整理。「顔/すべてをランダム」に眼鏡・Oの口・ひげの大きさ・目の左右位置などを追加。
Diffstat (limited to 'public/bluebey-studio/src/panel.js')
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 104 |
1 files changed, 85 insertions, 19 deletions
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index f7053cb..bb409ef 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -4,7 +4,7 @@ import { } from './ui.js'; import { FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS, - defaultState, applyPatch, BEARD_KIND_DEFAULTS, + defaultState, applyPatch, BEARD_KIND_DEFAULTS, HEAD_MARK_KIND_DEFAULTS, } from './presets.js'; import { STYLE_DEFS } from './styles.js'; import { ENVIRONMENTS } from './look.js'; @@ -40,7 +40,7 @@ const SECTION_ICONS = { '見た目の調整': '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', + 'セリフ(フキダシ)': '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', @@ -172,7 +172,38 @@ export function buildPanel(app, root) { open: +rnd(0.4, 1).toFixed(2), lookX: +rnd(-0.35, 0.35).toFixed(2), lookY: +rnd(-0.25, 0.25).toFixed(2), + eyeX: Math.round(rnd(-16, 16)), }); + const randomGlasses = () => { + const kind = pick(['glasses', 'sunglasses']); + const dark = kind === 'sunglasses'; + return { + enabled: true, + kind, + frameColor: pick(['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6']), + lensColor: pick(['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e']), + lensOpacity: dark ? +rnd(0.85, 1).toFixed(2) : +rnd(0, 0.35).toFixed(2), + lensGap: Math.round(rnd(-24, 56)), + frameWidth: +rnd(0.6, 1.8).toFixed(2), + scale: +rnd(0.9, 1.15).toFixed(2), + offsetY: Math.round(rnd(-18, 18)), + 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 { @@ -180,8 +211,10 @@ export function buildPanel(app, root) { left: randomEye(), 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', 'curve']) }, + // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。 + 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. @@ -198,8 +231,11 @@ export function buildPanel(app, root) { // 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)), }, @@ -532,8 +568,8 @@ export function buildPanel(app, root) { const EYE_ICONS = { both: '<circle cx="7" cy="12" r="3.8"/><circle cx="17" cy="12" r="3.8"/><circle cx="7" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="17" cy="12" r="1.3" fill="currentColor" stroke="none"/>', - left: '<path d="M21 12h-4"/><path d="M19 9.5l-2.5 2.5 2.5 2.5"/><circle cx="9" cy="12" r="3.8"/><circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none"/>', - right: '<path d="M3 12h4"/><path d="M5 9.5l2.5 2.5-2.5 2.5"/><circle cx="15" cy="12" r="3.8"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + left: '<path d="M3 12h4"/><path d="M5 9.5l2.5 2.5-2.5 2.5"/><circle cx="15" cy="12" r="3.8"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + right: '<path d="M21 12h-4"/><path d="M19 9.5l-2.5 2.5 2.5 2.5"/><circle cx="9" cy="12" r="3.8"/><circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none"/>', lid: '<path d="M3 14c2.5-6.5 15.5-6.5 18 0"/><path d="M6 16v3M12 16v3M18 16v3"/>', }; // Each eye group carries the same controls. Linked, one group ("両目") drives @@ -809,7 +845,8 @@ export function buildPanel(app, root) { faceSection.add(hint('「まぶた」は目のグループごとに設定できます。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、' + 'まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。' - + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。')); + + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。' + + '「左の目」「右の目」は、ぶるべー自身から見た左右です(正面から見ると、画面では左右が入れ替わって見えます)。')); const eyeColors = { white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }), @@ -1013,6 +1050,10 @@ export function buildPanel(app, root) { format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)), onInput: (v) => { state.face.eyes.glasses.lensOpacity = v; app.actions.refresh('face'); }, }), + lensGap: slider({ + label: '左右レンズの間隔', min: -40, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.glasses.lensGap = v; app.actions.refresh('face'); }, + }), frameWidth: slider({ label: 'フレームの太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.eyes.glasses.frameWidth = v; app.actions.refresh('face'); }, @@ -1032,6 +1073,7 @@ export function buildPanel(app, root) { glassesBox.add(glassesSliders.frameWidth.el); glassesBox.add(glassesSliders.offsetY.el); glassesBox.add(glassesSliders.tilt.el); + glassesBox.add(glassesSliders.lensGap.el); const glassesFrameColor = colorField({ label: 'フレームの色', value: '#2a1e33', swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'], @@ -1127,7 +1169,15 @@ export function buildPanel(app, root) { { value: 'curve', label: 'カーブ' }, ], value: 'off', - onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); sync(); }, + 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); @@ -1295,14 +1345,14 @@ export function buildPanel(app, root) { const cornerAngleFor = (smile) => Math.round(31 - 39 * Math.max(-1, Math.min(1.4, smile))); const mouthSliders = { - smile: slider({ label: '笑いの深さ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }), - open: slider({ label: '口の開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), + smile: slider({ label: '曲げ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }), + open: slider({ label: '開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), round: slider({ label: 'Oの大きさ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }), - width: slider({ label: '口の幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), - length: slider({ label: '口の長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }), - thickness: slider({ label: '口の太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), - tilt: slider({ label: '口の傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }), - offsetY: slider({ label: '口の高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }), + width: slider({ label: '幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), + length: slider({ label: '長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }), + thickness: slider({ label: '太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), + tilt: slider({ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }), + offsetY: slider({ label: '高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }), tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }), tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }), 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'); } }), @@ -1316,7 +1366,7 @@ export function buildPanel(app, root) { round: ['round', 'width', 'thickness', 'tongue', 'tilt', 'offsetY'], }; // DOM order is fixed here; the mode only shows or hides rows. - const mouthRowOrder = ['round', 'smile', 'length', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY']; + const mouthRowOrder = ['round', 'smile', 'length', 'width', 'thickness', 'open', 'tilt', 'offsetY', 'tongue', 'tonguePos', 'corners', 'cornerAngle']; const mouthMode = segmented({ label: '口の形', options: [ @@ -1337,7 +1387,7 @@ export function buildPanel(app, root) { faceSection.add(mouthMode); for (const key of mouthRowOrder) faceSection.add(mouthSliders[key].el); faceSection.add(hint('「口の形」を切り替えると、その形で使うスライダーだけが出ます。' - + 'ふつうの口は笑いの深さと口の開き、丸く開く口(O)はOの大きさで形が決まります。')); + + 'ふつうの口は「曲げ」と「開き」、丸く開く口(O)は「Oの大きさ」で形が決まります。')); faceSection.add(buttons({ items: [{ id: 'mouthreset', @@ -1361,7 +1411,7 @@ export function buildPanel(app, root) { /* ---------------------------------------------------------------- caption */ - const captionSection = section(tabScene, 'セリフ(吹き出し)'); + 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, @@ -1585,7 +1635,7 @@ export function buildPanel(app, root) { }; 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; @@ -2510,9 +2560,23 @@ export function buildPanel(app, root) { label: '露光', min: 0.4, max: 2, step: 0.02, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.render.exposure = v; app.actions.refresh('render'); }, }); + const lightColorField = colorField({ + label: '光の色', value: '#ffffff', + swatches: ['#ffffff', '#fff3d6', '#d6e8ff', '#ffd9c9', '#e6dcff'], + onChange: (v) => { state.render.lightColor = v; app.actions.refresh('render'); }, + }); + const ambientColorField = colorField({ + label: '環境光の色', value: '#ffffff', + swatches: ['#ffffff', '#d9d0f2', '#cfe6ff', '#ffe6cc', '#e8ffe0'], + onChange: (v) => { state.render.ambientColor = v; app.actions.refresh('render'); }, + }); lightSection.add(lightIntensitySlider.el); lightSection.add(ambientSlider.el); + lightSection.add(lightColorField.el); + lightSection.add(ambientColorField.el); lightSection.add(exposureSlider.el); + lightSection.add(hint('「光の色」でライトの色、「環境光の色」で影側の色を変えられます。' + + 'テクスチャ(環境)の映りこみは「見た目」タブの「環境の映りこみ」で調整できます。')); /* ----------------------------------------------------------------- camera */ @@ -2905,6 +2969,8 @@ export function buildPanel(app, root) { }); lightIntensitySlider.set(state.render.lightIntensity ?? 2.1); ambientSlider.set(state.render.ambient ?? 0.9); + lightColorField.set(state.render.lightColor ?? '#ffffff'); + ambientColorField.set(state.render.ambientColor ?? '#ffffff'); exposureSlider.set(state.render.exposure ?? 1); const line = ['lineart', 'outline'].includes(state.render.style); outlineToggle.el.querySelector('input').disabled = line; |
