aboutsummaryrefslogtreecommitdiffhomepage
path: root/public
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 17:17:51 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 17:17:51 +0900
commitbb680c0e7c0a8b47115e067d769f18407f07a988 (patch)
treec770993fd172958801c7cb1648971de45530d0d4 /public
parentfbaf2cc671fed23b555e218499301af0f50327b0 (diff)
public/bluebey-studio: 鼻ちょうちんの既定値・まぶた・眉・ひげ・メニュー
鼻ちょうちんの既定値を 大きさ1.23 / よこ-23 / たかさ0 / おくゆき16 にした。 ひげのラベルを ダリ・ルパン に変更。ねこひげに「長さ」「線の間隔」を追加。 まぶたを「曲線/直線」で選べるようにし、「目を細める」ボタンを追加(下まぶたも上がる)。 眉の「ゴル風」を参考画像に寄せ、「両風」を指定値に変更。 ポーズタブの「操作」をくくらずに常時表示。顔タブの最下部に「顔をリセット」「顔をランダムに」を追加。スタイルと見た目の調整にプリセットに戻すボタンを追加。
Diffstat (limited to 'public')
-rw-r--r--public/bluebey-studio/src/face.js1
-rw-r--r--public/bluebey-studio/src/faceArt.js73
-rw-r--r--public/bluebey-studio/src/panel.js134
-rw-r--r--public/bluebey-studio/src/presets.js13
4 files changed, 176 insertions, 45 deletions
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js
index 6898faf..c1a0c72 100644
--- a/public/bluebey-studio/src/face.js
+++ b/public/bluebey-studio/src/face.js
@@ -192,6 +192,7 @@ export class Face {
highlight: p.eyes.highlight,
lidWidth: p.eyes.lidWidth,
lowerLid: p.eyes.lowerLid,
+ lidShape: p.eyes.lidShape,
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 8d20cda..4442bc6 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -470,7 +470,7 @@ function rotate(points, pivot, degrees) {
* shaded face behind it. That is what makes the eyelid blend perfectly without
* baking a face-coloured background into the texture.
*/
-function clipEye(ctx, eye, open, lower) {
+function clipEye(ctx, eye, open, lower, flat = false) {
circlePath(ctx, eye.cx, eye.cy, eye.r);
ctx.clip();
@@ -478,14 +478,19 @@ function clipEye(ctx, eye, open, lower) {
if (upper > 0.0005) {
const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
const lowest = eye.cy - eye.r + 2 * eye.r * upper;
- const cy = lowest - rl;
ctx.beginPath();
- ctx.moveTo(eye.cx - HUGE, cy);
- ctx.lineTo(eye.cx - rl, cy);
- ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down
- ctx.lineTo(eye.cx + HUGE, cy);
- ctx.lineTo(eye.cx + HUGE, cy + HUGE);
- ctx.lineTo(eye.cx - HUGE, cy + HUGE);
+ if (flat) {
+ // A flat lid: a straight edge straight across the eye.
+ ctx.rect(eye.cx - HUGE, lowest, HUGE * 2, HUGE);
+ } else {
+ const cy = lowest - rl;
+ ctx.moveTo(eye.cx - HUGE, cy);
+ ctx.lineTo(eye.cx - rl, cy);
+ ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down
+ ctx.lineTo(eye.cx + HUGE, cy);
+ ctx.lineTo(eye.cx + HUGE, cy + HUGE);
+ ctx.lineTo(eye.cx - HUGE, cy + HUGE);
+ }
ctx.closePath();
ctx.clip();
}
@@ -494,28 +499,40 @@ function clipEye(ctx, eye, open, lower) {
if (bottom > 0.0005) {
const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
const highest = eye.cy + eye.r - 2 * eye.r * bottom;
- const cy = highest + rl;
ctx.beginPath();
- ctx.moveTo(eye.cx - HUGE, cy);
- ctx.lineTo(eye.cx - rl, cy);
- ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up
- ctx.lineTo(eye.cx + HUGE, cy);
- ctx.lineTo(eye.cx + HUGE, cy - HUGE);
- ctx.lineTo(eye.cx - HUGE, cy - HUGE);
+ if (flat) {
+ ctx.rect(eye.cx - HUGE, highest - HUGE, HUGE * 2, HUGE);
+ } else {
+ const cy = highest + rl;
+ ctx.moveTo(eye.cx - HUGE, cy);
+ ctx.lineTo(eye.cx - rl, cy);
+ ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up
+ ctx.lineTo(eye.cx + HUGE, cy);
+ ctx.lineTo(eye.cx + HUGE, cy - HUGE);
+ ctx.lineTo(eye.cx - HUGE, cy - HUGE);
+ }
ctx.closePath();
ctx.clip();
}
}
-function lidPath(ctx, eye, amount, lower) {
+function lidPath(ctx, eye, amount, lower, flat = false) {
+ ctx.beginPath();
+ if (flat) {
+ // A straight lid edge; the caller clips it to the eyeball.
+ const y = lower
+ ? eye.cy + eye.r - 2 * eye.r * amount
+ : eye.cy - eye.r + 2 * eye.r * (1 - amount);
+ ctx.moveTo(eye.cx - eye.r * 2, y);
+ ctx.lineTo(eye.cx + eye.r * 2, y);
+ return;
+ }
const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
if (!lower) {
const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount);
- ctx.beginPath();
ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true);
} else {
const highest = eye.cy + eye.r - 2 * eye.r * amount;
- ctx.beginPath();
ctx.arc(eye.cx, highest + rl, rl, Math.PI, 0, false);
}
}
@@ -939,13 +956,18 @@ function drawBeard(ctx, spec, lineOnly = false) {
// longer and wider-spread without making them fatter, and `spacing` opens the
// gap between the left and right.
const whisker = 8;
- const inner = w * 0.30 + clamp(spec.spacing ?? 0, 0, 800);
- const outer = Math.max(inner + w * 0.2, w * 0.68);
+ const spacing = clamp(spec.spacing ?? 0, 0, 800);
+ const inner = w * 0.30 + spacing;
+ // `length` scales how far each whisker reaches out; `lineGap` opens the spacing
+ // between the three lines of one side. Neither changes the line's thickness.
+ const length = clamp(spec.length ?? 1, 0.2, 3);
+ const gap = w * 0.16 * clamp(spec.lineGap ?? 1, 0.2, 3);
+ const outer = inner + w * 0.38 * length;
for (const dir of [-1, 1]) {
for (let i = 0; i < 3; i += 1) {
stroke([
- { x: cx + dir * inner, y: cy + (i - 1) * w * 0.16 },
- { x: cx + dir * outer, y: cy + (i - 1) * w * 0.24 },
+ { x: cx + dir * inner, y: cy + (i - 1) * gap },
+ { x: cx + dir * outer, y: cy + (i - 1) * gap * 1.5 },
], whisker);
}
}
@@ -1134,6 +1156,7 @@ export function drawEyes(ctx, p) {
const travel = Math.max(0, EYE_LAYOUT.radius - irisRadius);
const heartTravel = EYE_LAYOUT.radius * 0.9;
const lowerLid = clamp(style.lowerLid ?? 0, 0, 1);
+ const lidFlat = (style.lidShape ?? 'curve') === 'flat';
// ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here
// rather than inside the per-eye loop below. 鼻ちょうちん comes along too.
@@ -1200,7 +1223,7 @@ export function drawEyes(ctx, p) {
// A heart is not an eyeball, so once the eye is fully open it is not clipped
// to the eyeball circle - that is what lets a heart be much larger than the
// iris. While the lid is partly closed the clip is still needed.
- if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid * (1 - open));
+ if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid * (1 - open), lidFlat);
const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax;
let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel;
@@ -1266,12 +1289,12 @@ export function drawEyes(ctx, p) {
ctx.strokeStyle = lineColor;
ctx.lineWidth = lidWidth;
if (lidFade > 0.01) {
- lidPath(ctx, eye, open, false);
+ lidPath(ctx, eye, open, false, lidFlat);
ctx.stroke();
}
if (lowerLid > 0.01) {
ctx.globalAlpha = line ? 1 : lowerLid;
- lidPath(ctx, eye, lowerLid, true);
+ lidPath(ctx, eye, lowerLid, true, lidFlat);
ctx.stroke();
}
ctx.restore();
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 4b2a4c0..e4ef0b6 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -266,8 +266,10 @@ export function buildPanel(app, root) {
poseBox.add(controlRow(null, gizmoToggle.el, { wide: true }));
// --- 操作 -----------------------------------------------------------------
- const poseActions = section(tabPose, '操作');
- poseActions.add(buttons({
+ // Deliberately NOT wrapped in a collapsible section: 「ポーズを全部戻す」 and the
+ // undo/redo pair are used often enough that they should be on screen without
+ // opening anything first.
+ tabPose.append(buttons({
items: [
{ id: 'resetBone', label: 'このボーンを戻す', onClick: () => { rig.reset(rig.selected); app.actions.capturePose(); syncBones(); app.needsRender = true; } },
{ id: 'resetAll', label: 'ポーズを全部戻す', onClick: () => app.actions.resetPose() },
@@ -275,8 +277,8 @@ export function buildPanel(app, root) {
{ id: 'undo', label: '元に戻す', onClick: () => app.actions.undo() },
{ id: 'redo', label: 'やり直す', onClick: () => app.actions.redo() },
],
- }));
- poseActions.add(hint('「元に戻す」「やり直す」は、ポーズだけでなく色や背景など、すべての操作に効きます。'));
+ }).el);
+ tabPose.append(hint('「元に戻す」「やり直す」は、ポーズだけでなく色や背景など、すべての操作に効きます。'));
/** Accepts a preset object (from the buttons) or a preset id (from tools). */
function applyPosePreset(presetOrId) {
@@ -572,6 +574,15 @@ export function buildPanel(app, root) {
label: '下まぶた', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2),
onInput: (value) => { state.face.eyes.lowerLid = value; app.actions.refresh('face'); },
});
+ const lidShapeSegment = segmented({
+ label: 'まぶたの形',
+ options: [
+ { value: 'curve', label: '曲線' },
+ { value: 'flat', label: '直線(平ら)' },
+ ],
+ value: 'curve',
+ onChange: (value) => { state.face.eyes.lidShape = value; app.actions.refresh('face'); },
+ });
eyeAdvanced.add(irisScaleSlider.el);
eyeAdvanced.add(heartScaleSlider.el);
eyeAdvanced.add(controlRow(null, heartWhiteToggle.el, { wide: true }));
@@ -579,6 +590,25 @@ export function buildPanel(app, root) {
eyeAdvanced.add(lookMaxSlider.el);
eyeAdvanced.add(lidWidthSlider.el);
eyeAdvanced.add(lowerLidSlider.el);
+ eyeAdvanced.add(lidShapeSegment.el);
+ eyeAdvanced.add(buttons({
+ items: [{
+ id: 'squint',
+ label: '目を細める',
+ onClick: () => {
+ // マンガの細目: the upper lid comes partway down and the lower lid rises to
+ // meet it. `open` is per-eye (kept in step here); `lowerLid` is shared.
+ state.face.eyes.left.open = 0.5;
+ state.face.eyes.right.open = 0.5;
+ state.face.eyes.lowerLid = 0.5;
+ app.actions.refresh('face');
+ sync();
+ },
+ }],
+ }));
+ eyeAdvanced.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。'
+ + '「目を細める」で、上まぶたを下げつつ下まぶたを上げた細目になります。'
+ + '「下まぶた」を上げると、下からもまぶたが持ち上がります。'));
const highlightToggle = check({
label: '瞳のハイライト',
@@ -721,13 +751,13 @@ export function buildPanel(app, root) {
// it, with the two brows sitting close together.
Object.assign(state.face.eyes.brow, {
enabled: true,
- angle: 36,
- height: 6,
- length: 1.1,
- thickness: 3.5,
+ angle: 34,
+ height: 2,
+ length: 1.7,
+ thickness: 3.6,
curve: 0,
taper: 0,
- spacing: -10,
+ spacing: -30,
color: '#12101a',
});
app.actions.refresh('face');
@@ -745,11 +775,11 @@ export function buildPanel(app, root) {
enabled: true,
angle: 10,
height: 4,
- length: 0.6,
- thickness: 3.5,
- curve: 0.1,
+ length: 2.86,
+ thickness: 3.29,
+ curve: 0.4,
taper: 1,
- spacing: -205,
+ spacing: -50,
color: '#181220',
});
app.actions.refresh('face');
@@ -1025,8 +1055,8 @@ export function buildPanel(app, root) {
options: [
{ value: 'off', label: 'なし' },
{ value: 'scotch', label: 'ちょびひげ' },
- { value: 'kaiser', label: 'カイゼル' },
- { value: 'apron', label: 'エプロン' },
+ { value: 'kaiser', label: 'ダリ' },
+ { value: 'apron', label: 'ルパン' },
{ value: 'cat', label: 'ねこ' },
],
value: 'off',
@@ -1046,19 +1076,29 @@ export function buildPanel(app, root) {
label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); },
}),
+ length: slider({
+ label: 'ひげの長さ(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.beard.length = v; app.actions.refresh('face'); },
+ }),
+ lineGap: slider({
+ label: '線の間隔(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.eyes.beard.lineGap = v; app.actions.refresh('face'); },
+ }),
};
faceSection.add(beardSliders.size.el);
const beardBox = details(faceSection.body, 'こまかい設定(位置・色)');
beardBox.add(beardSliders.spacing.el);
beardBox.add(beardSliders.offsetY.el);
+ beardBox.add(beardSliders.length.el);
+ beardBox.add(beardSliders.lineGap.el);
const beardColor = colorField({
label: 'ひげの色', value: '#3a2a4a',
swatches: ['#3a2a4a', '#1c1624', '#6b4a2a', '#8a8a94'],
onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); },
});
beardBox.add(beardColor.el);
- beardBox.add(hint('鼻の下に、ちょびひげ・カイゼル・エプロン・ねこ、の髭を描きます。'
- + '「左右の間隔」は「ねこ」の左右の髭の間を広げます。'));
+ beardBox.add(hint('鼻の下に、ちょびひげ・ダリ・ルパン・ねこ、の髭を描きます。'
+ + '「左右の間隔」「ひげの長さ」「線の間隔」は「ねこ」で使います。'));
// --- mouth
faceSection = section(tabFace, '口');
@@ -1406,6 +1446,41 @@ export function buildPanel(app, root) {
/* ---------------------------------------------------------------- display */
+ // The face tab's bottom line: a quick way out of whatever has been built up,
+ // plus a one-press random face for fun.
+ const randomFace = () => {
+ const rnd = (min, max) => min + (max - min) * Math.random();
+ const pick = (list) => list[Math.floor(Math.random() * list.length)];
+ const eye = () => ({ open: +rnd(0.45, 1).toFixed(2), lookX: +rnd(-0.3, 0.3).toFixed(2), lookY: +rnd(-0.2, 0.2).toFixed(2) });
+ replace({
+ face: {
+ eyes: {
+ left: eye(),
+ right: eye(),
+ cheeks: { enabled: Math.random() < 0.5 },
+ beard: { shape: pick(['off', 'scotch', 'kaiser', 'apron', 'cat']) },
+ brow: {
+ enabled: true,
+ angle: Math.round(rnd(-30, 40)),
+ height: Math.round(rnd(-10, 50)),
+ length: +rnd(0.6, 2.6).toFixed(2),
+ thickness: +rnd(1.2, 3.4).toFixed(2),
+ curve: +rnd(0, 0.3).toFixed(2),
+ spacing: Math.round(rnd(-120, 30)),
+ taper: +rnd(0, 1).toFixed(2),
+ },
+ },
+ mouth: { smile: +rnd(-1, 1.2).toFixed(2), open: +rnd(0, 0.5).toFixed(2) },
+ },
+ }, 'face');
+ };
+ tabFace.append(buttons({
+ items: [
+ { id: 'faceReset', label: '顔をリセット', onClick: () => replace({ face: defaultState().face }, 'face') },
+ { id: 'faceRandom', label: '顔をランダムに', onClick: () => randomFace() },
+ ],
+ }).el);
+
const displaySection = section(tabView, 'スタイル');
const styleSegment = segmented({
@@ -1451,6 +1526,18 @@ export function buildPanel(app, root) {
});
displaySection.add(controlRow(null, leafBodyLineCheck.el, { wide: true }));
displaySection.add(hint('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。'));
+ displaySection.add(buttons({
+ items: [{
+ id: 'resetStyle',
+ label: 'プリセットのスタイルに戻す',
+ onClick: () => replace({
+ render: {
+ style: 'real', outline: true, outlineWidth: 0.022, outlinePixels: 2,
+ outlineColor: '#2a1e33', paper: '#ffffff', leafBodyLine: true,
+ },
+ }, 'render'),
+ }],
+ }));
/* ------------------------------------------------------------- body colour */
@@ -1543,6 +1630,18 @@ export function buildPanel(app, root) {
onChange: (value) => { state.render.environment = value; app.actions.refresh('render'); },
});
lookSection.add(environmentSegment.el);
+ lookSection.add(buttons({
+ items: [{
+ id: 'resetLook',
+ label: 'プリセットに戻す',
+ onClick: () => replace({
+ render: {
+ mirror: false, shadow: true, shadowOpacity: 0.22, shadowSoftness: 1.6,
+ contactShadow: false, contactBlob: false, environment: 'gradient', envIntensity: 1,
+ },
+ }, 'render'),
+ }],
+ }));
/* -------------------------------------------------------------- background */
@@ -2507,6 +2606,7 @@ export function buildPanel(app, root) {
lookMaxSlider.set(state.face.eyes.lookMax ?? 1);
lidWidthSlider.set(state.face.eyes.lidWidth ?? 14);
lowerLidSlider.set(state.face.eyes.lowerLid ?? 0);
+ lidShapeSegment.set(state.face.eyes.lidShape ?? 'curve');
browToggle.set(state.face.eyes.brow.enabled);
browSliders.angle.set(state.face.eyes.brow.angle);
browSliders.height.set(state.face.eyes.brow.height);
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index aee3a5f..c2c7054 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -193,6 +193,9 @@ export function defaultState() {
highlight: true,
lidWidth: 14,
lowerLid: 0,
+ // How the closing eyelid is drawn: `curve` is the soft, rounded lid; `flat`
+ // is a straight lid edge (the hard, narrowed look). See faceArt.js.
+ lidShape: 'curve',
heartScale: 2,
heartColor: '#e0344f',
heartHideWhite: true,
@@ -226,10 +229,10 @@ export function defaultState() {
// plate as the eyes, out to the side of the nose. Starts off.
snot: {
enabled: false,
- size: 1,
- offsetX: 0,
+ size: 1.23,
+ offsetX: -23,
offsetY: 0,
- offsetZ: 0,
+ offsetZ: 16,
color: '#e3ecff',
},
// ひげ: a mustache or beard worn under the nose. `shape` is one of `off`,
@@ -240,6 +243,10 @@ export function defaultState() {
size: 1,
offsetY: 0,
spacing: 0,
+ // ねこ only: how far each whisker reaches (`length`) and how far apart the
+ // three lines of one side sit (`lineGap`). Neither changes the thickness.
+ length: 1,
+ lineGap: 1,
color: '#3a2a4a',
},
// Eyebrows are drawn into the eye texture. Off by default, because the