aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/panel.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-06 09:56:21 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-06 09:56:21 +0900
commit8f39a174d6c4415c7a4df418187090fb5e061983 (patch)
treedbd3cf1e4f67393f029a455c4226479fbefe4804 /public/bluebey-studio/src/panel.js
parent63c1a9dfa8b04ab9725a9a49ef34cd3fc6ade56c (diff)
bluebey-studio: ボーンのたて回転・縦書きと吹き出しの形・線画の帽子小物・光の色
ボーンのたてスライダーが±180°で暴れる問題を修正。セリフの縦書きを禁則込みで直し、「ささやき」「思考」の吹き出しを追加。帽子・小物の輪郭線を線画のみにし、線画で帯や芯の境目が読めるよう調整。眼鏡の左右レンズ間隔、光と環境光の色を追加。ハートの下まぶた、レンズが濃いときのまばたきを修正。口メニューの項目名を整理。「顔/すべてをランダム」に眼鏡・Oの口・ひげの大きさ・目の左右位置などを追加。
Diffstat (limited to 'public/bluebey-studio/src/panel.js')
-rw-r--r--public/bluebey-studio/src/panel.js104
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;