aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-04 23:10:56 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-04 23:10:56 +0900
commitbbefd07f238dd0cecb82fa75d36ffcffe9613ef8 (patch)
tree63721d3e787e096695d826db06a6f9a461db8476 /public/bluebey-studio/src
parentadc9bc266ca9795cd153c8425bf8f31f6ad31ac7 (diff)
bluebey-studio: まえがみ・わけめ・白目の調整と目/口メニュー整理など
まえがみを大きさ0でもM字に保つよう調整。わけめの切り込みを浅くした。白目の表示/非表示を線まぶたでも選べるように。目の涙と手描きパーツをこまかい設定へ、口の幅をこまかい設定へ移動し口のパーツ選択を削除。Undo/?!バーを固定表示に。プリセットに「てれ」「じと目」を追加し、ねぼけに鼻ちょうちんを表示。
Diffstat (limited to 'public/bluebey-studio/src')
-rw-r--r--public/bluebey-studio/src/face.js1
-rw-r--r--public/bluebey-studio/src/faceArt.js40
-rw-r--r--public/bluebey-studio/src/panel.js70
-rw-r--r--public/bluebey-studio/src/presets.js37
-rw-r--r--public/bluebey-studio/src/style.css15
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;