aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/panel.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 00:08:30 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 00:08:30 +0900
commit629dfd09777d1abb3cdecd8fc58d18234d1063cf (patch)
tree6f0f01befc1a4283d716a24187e0fc570a7a905e /public/bluebey-studio/src/panel.js
parent793bfd720525f7f1f14a507e1c962101db5ef283 (diff)
bluebey-studio: 帽子の高さ・傾き、髪の下地書き出し、目の形メニューの再構成
Diffstat (limited to 'public/bluebey-studio/src/panel.js')
-rw-r--r--public/bluebey-studio/src/panel.js236
1 files changed, 162 insertions, 74 deletions
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index ed7f766..b2c3519 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -148,6 +148,17 @@ export function buildPanel(app, root) {
const sync = () => { for (const fn of syncers) fn(); };
const syncBones = () => { for (const fn of boneSyncers) fn(); };
+ /**
+ * Grey out (and disable) a control whose setting has no effect in the current
+ * mode - e.g. the brow's angle while the brow is a loaded picture.
+ */
+ const setRowEnabled = (widget, on) => {
+ const row = widget.el.closest?.('.row') ?? widget.el;
+ const input = row.querySelector?.('input, select, button');
+ if (input) input.disabled = !on;
+ row.classList?.toggle('is-off', !on);
+ };
+
/** Patch one scope of the state without re-reading the whole UI. */
/** Replace the state and re-read every widget (used by presets and loads). */
const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true });
@@ -446,6 +457,10 @@ export function buildPanel(app, root) {
state.face.eyes.right.open = state.face.eyes.left.open;
state.face.eyes.right.closed = state.face.eyes.left.closed;
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.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
// sideways position and the tilt flip sign.
state.face.eyes.right.tear = state.face.eyes.left.tear ?? 0;
@@ -457,6 +472,34 @@ export function buildPanel(app, root) {
sync();
},
});
+ // The eye "shape" folds the old 閉じ方 (line/3/わらう) and the heart pupil into
+ // one menu. `open`/`closed`/`irisShape` stay the stored fields; this reads and
+ // writes them together so the menu always shows the current look.
+ const eyeShapeOf = (eye) => {
+ // A shut eye wins over a heart: dragging the 開き slider down leaves a shut
+ // line (or 3/わらう), even if the heart pupil is still selected underneath.
+ if ((eye.open ?? 1) <= 0.02) {
+ if (eye.closed === 'three') return 'three';
+ if (eye.closed === 'arch') return 'arch';
+ return 'line';
+ }
+ return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
+ };
+ const applyEyeShape = (eye, value) => {
+ eye.irisShape = value === 'heart' ? 'heart' : 'circle';
+ if (value === 'open' || value === 'heart') {
+ eye.open = 1;
+ return;
+ }
+ eye.open = 0;
+ eye.closed = value;
+ };
+ const eyeWhiteOf = (eye) => {
+ if (eye.white != null) return eye.white === true;
+ // The usual: an open eye shows its white; a shut eye or a heart does not.
+ return eyeShapeOf(eye) === 'open';
+ };
+
for (const key of ['left', 'right']) {
const label = key === 'left' ? '左の目' : '右の目';
const open = slider({
@@ -470,23 +513,19 @@ export function buildPanel(app, root) {
sync();
},
});
- const closed = segmented({
- label: `${label}の閉じ方`,
+ const shape = segmented({
+ label: `${label}の形`,
options: [
+ { value: 'open', label: 'ふつう' },
{ value: 'line', label: '線' },
{ value: 'three', label: '3の目' },
{ value: 'arch', label: 'わらう' },
+ { value: 'heart', label: 'ハート' },
],
- value: 'line',
+ value: 'open',
onChange: (value) => {
- state.face.eyes[key].closed = value;
- // 線 and わらう are the shut-eye artwork, so shut the eye for the user.
- state.face.eyes[key].open = 0;
- if (state.face.eyes.linked) {
- const other = state.face.eyes[key === 'left' ? 'right' : 'left'];
- other.open = 0;
- other.closed = 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();
},
@@ -502,13 +541,11 @@ export function buildPanel(app, root) {
hint: '2本以上は端点がつながった形になります',
onChange: (value) => {
state.face.eyes[key].closedLines = Number(value);
- state.face.eyes[key].closed = 'line';
- state.face.eyes[key].open = 0;
+ applyEyeShape(state.face.eyes[key], 'line');
if (state.face.eyes.linked) {
const other = state.face.eyes[key === 'left' ? 'right' : 'left'];
- other.open = 0;
- other.closed = 'line';
other.closedLines = Number(value);
+ applyEyeShape(other, 'line');
}
app.actions.refresh('face');
sync();
@@ -526,8 +563,7 @@ export function buildPanel(app, root) {
hint: '「3の目」のときの向きです。左右で別々に選べます',
onChange: (value) => {
state.face.eyes[key].threeFlip = value === 'flip';
- state.face.eyes[key].closed = 'three';
- state.face.eyes[key].open = 0;
+ applyEyeShape(state.face.eyes[key], 'three');
app.actions.refresh('face');
sync();
},
@@ -582,12 +618,26 @@ 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'); },
});
- eyeWidgets[key] = { open, closed, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt, lowerLid: lowerLidEach, lookX, lookY };
+ // 白目: 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(closed.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);
@@ -609,39 +659,54 @@ export function buildPanel(app, root) {
}],
}));
- const irisShapeSegment = segmented({
- label: '瞳の形',
+ const linkedNote = hint('「左右を連動させる」を入れると、開き・形・線の数・白目・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。');
+ faceSection.add(linkedNote);
+
+ // 光彩 (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: 'circle', label: '丸' },
- { value: 'heart', label: 'ハート' },
+ { value: 'left', label: '左の目' },
+ { value: 'right', label: '右の目' },
],
- value: 'circle',
+ value: 'left',
onChange: (value) => {
- state.face.eyes.left.irisShape = value;
- state.face.eyes.right.irisShape = value;
- app.actions.refresh('face');
+ lookTarget = value;
+ const eye = state.face.eyes[value];
+ lookPad.set({ x: eye.lookX ?? 0, y: eye.lookY ?? 0 });
},
});
- faceSection.add(irisShapeSegment.el);
-
- const linkedNote = hint('「左右を連動させる」を入れると、開き・閉じ方・線の数・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。');
- faceSection.add(linkedNote);
+ faceSection.add(lookEyeSegment.el);
const lookPad = xyPad({
value: { x: 0, y: 0 },
center: 'eye',
onChange: ({ x, y }) => {
- state.face.eyes.left.lookX = x;
- state.face.eyes.left.lookY = y;
- state.face.eyes.right.lookX = x;
- state.face.eyes.right.lookY = 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: 'ドラッグで両目が動きます。ダブルクリックで正面に戻ります。' }))));
+ h('p', { class: 'hint', text: 'ドラッグで「動かす目」を動かします。ダブルクリックで正面に戻ります。' }))));
// 目を細める: a one-tap マンガの細目, kept out of the こまかい設定 box so it is
// easy to find.
@@ -678,15 +743,8 @@ 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 irisScaleSlider = slider({
@@ -705,16 +763,9 @@ export function buildPanel(app, root) {
eyeAdvanced.add(irisScaleSlider.el);
eyeAdvanced.add(heartColorField.el);
eyeAdvanced.add(lidWidthSlider.el);
- eyeAdvanced.add(hint('「まぶたの形」「まぶたの傾き」「目を細める」「白目を消す」は、この上の「目」にあります。'
+ eyeAdvanced.add(hint('「まぶたの形」「まぶたの傾き」「目を細める」「白目」「光彩」は、この上の「目」にあります。'
+ 'ハートの大きさは「瞳の大きさ」で変わります。'));
- const highlightToggle = check({
- label: '瞳のハイライト',
- value: true,
- onChange: (value) => { state.face.eyes.highlight = value; app.actions.refresh('face'); },
- });
- eyeAdvanced.add(controlRow(null, highlightToggle.el, { wide: true }));
-
const eyeColors = {
white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }),
iris: colorField({ label: '瞳', value: '#150e1b', onChange: (v) => { state.face.eyes.iris = v; app.actions.refresh('face'); } }),
@@ -846,7 +897,7 @@ export function buildPanel(app, root) {
items: [
{
id: 'golgo',
- label: 'ゴル風',
+ label: 'ゴル風(画像)',
onClick: () => {
// ゴルゴ13: a very thick, almost straight black wedge, wide at the outer
// end and tapering to a point at the nose, angled steeply down towards
@@ -869,7 +920,7 @@ export function buildPanel(app, root) {
},
{
id: 'ryotsu',
- label: '両風',
+ label: '両風(線の眉)',
onClick: () => {
// 両津勘吉: thick げじげじ brows that are joined in the middle, sitting
// low over the eyes. `spacing` is what closes the gap - at this length
@@ -895,8 +946,9 @@ export function buildPanel(app, root) {
],
}));
- faceSection.add(hint('「ゴル風」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。'
- + '「両風」など線で描く眉に切り替えると、この画像は使われません。'));
+ faceSection.add(hint('「ゴル風(画像)」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。'
+ + '画像のときは角度・太さ・曲がり・鼻側の細さが効かないので、灰色になります。'
+ + '「両風(線の眉)」など線で描く眉に切り替えると、この画像は使われません。'));
// --- glasses / sunglasses (drawn into the same texture as the eyes and brows)
faceSection = faceTabSections.glasses;
@@ -1627,6 +1679,29 @@ export function buildPanel(app, root) {
hatSection.add(hatSegment.el);
syncers.push(() => hatSegment.set(state.face.hat?.kind ?? 'none'));
+ const hatBox = details(hatSection.body, '位置と傾き');
+ const hatSliders = {
+ height: slider({
+ label: '高さ', min: -0.6, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.height = v; app.actions.refresh('all'); },
+ }),
+ tiltX: slider({
+ label: '前後の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.hat.tiltX = v; app.actions.refresh('all'); },
+ }),
+ tiltZ: slider({
+ label: '左右の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
+ onInput: (v) => { state.face.hat.tiltZ = v; app.actions.refresh('all'); },
+ }),
+ };
+ for (const widget of Object.values(hatSliders)) hatBox.add(widget.el);
+ hatBox.add(hint('「高さ」で帽子を浮かせたり深くかぶせたり、「前後の傾き」「左右の傾き」で斜めにかぶせられます。'));
+ syncers.push(() => {
+ hatSliders.height.set(state.face.hat?.height ?? 0);
+ hatSliders.tiltX.set(state.face.hat?.tiltX ?? 0);
+ hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0);
+ });
+
/* ------------------------------------------------------------- body colour */
const colourSection = section(tabView, '体の色');
@@ -2605,12 +2680,16 @@ export function buildPanel(app, root) {
items: [
{ id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') },
{ id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') },
+ { id: 'facemap-hair', label: '髪の下地を書き出す', onClick: () => app.actions.saveFaceMap('hair') },
],
}));
faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。'
+ 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や'
+ '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。'
- + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'));
+ + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。'
+ + '「髪の下地」は、頭をぐるりと巻いたテクスチャを開いたもので、横が頭の角度、'
+ + 'たてが高さです。左右の端が頭の後ろ(合わせ目)、青いたて線が顔の正面、'
+ + '青いよこ線が既定の生え際です。'));
/* --------------------------------------------------------------- settings */
@@ -2671,31 +2750,34 @@ export function buildPanel(app, root) {
syncers.push(() => {
for (const key of ['left', 'right']) {
- eyeWidgets[key].open.set(state.face.eyes[key].open);
- eyeWidgets[key].closed.set(state.face.eyes[key].closed);
- eyeWidgets[key].closedLines.set(String(state.face.eyes[key].closedLines ?? 1));
- eyeWidgets[key].threeFlip.set(state.face.eyes[key].threeFlip ? 'flip' : 'normal');
- // The direction only means anything for the "3" eye.
- eyeWidgets[key].threeFlip.el.style.display = state.face.eyes[key].closed === 'three' ? '' : 'none';
- eyeWidgets[key].tear.set(state.face.eyes[key].tear ?? 0);
- eyeWidgets[key].tearY.set(state.face.eyes[key].tearY ?? 0);
- eyeWidgets[key].tearX.set(state.face.eyes[key].tearX ?? 0);
- eyeWidgets[key].tearTilt.set(state.face.eyes[key].tearTilt ?? 0);
- eyeWidgets[key].eyeX.set(state.face.eyes[key].eyeX ?? 0);
- eyeWidgets[key].lookX.set(state.face.eyes[key].lookX ?? 0);
- eyeWidgets[key].lookY.set(state.face.eyes[key].lookY ?? 0);
- eyeWidgets[key].lowerLid.set(state.face.eyes[key].lowerLid ?? state.face.eyes.lowerLid ?? 0);
+ 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);
}
- irisShapeSegment.set(state.face.eyes.left.irisShape ?? 'circle');
- lookPad.set({ x: state.face.eyes.left.lookX, y: state.face.eyes.left.lookY });
+ 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);
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);
@@ -2704,6 +2786,12 @@ export function buildPanel(app, root) {
browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0);
browSliders.curve.set(state.face.eyes.brow.curve);
browSliders.taper.set(state.face.eyes.brow.taper ?? 1);
+ // A loaded ゴル風 picture ignores the shape sliders, so grey those out rather
+ // than leave them looking like they should do something.
+ const browIsImage = state.face.eyes.brow.image === true;
+ for (const key of ['angle', 'thickness', 'curve', 'taper']) {
+ setRowEnabled(browSliders[key], !browIsImage);
+ }
browColor.set(state.face.eyes.brow.color);
glassesToggle.set(state.face.eyes.glasses.enabled === true);
glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses');