aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--public/bluebey-studio/src/face.js3
-rw-r--r--public/bluebey-studio/src/faceArt.js46
-rw-r--r--public/bluebey-studio/src/panel.js73
-rw-r--r--public/bluebey-studio/src/presets.js8
4 files changed, 79 insertions, 51 deletions
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js
index 273468a..e3063f2 100644
--- a/public/bluebey-studio/src/face.js
+++ b/public/bluebey-studio/src/face.js
@@ -259,6 +259,9 @@ export class Face {
}
if (this.hairMesh) {
+ // `overEyes` decides the draw order: the hair plate sits in front of the
+ // eyes (bangs over the face) or behind them.
+ this.hairMesh.renderOrder = p.eyes.headMark?.overEyes === false ? 0 : 3;
const layout = this.hairLayout;
const ctx = this.hairCtx;
ctx.clearRect(0, 0, layout.width, layout.height);
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index c4940d4..7b5f7de 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -796,7 +796,7 @@ function drawCheeks(ctx, spec) {
*/
function drawHeadCover(ctx, spec, limits) {
const shape = spec?.shape ?? 'off';
- if (shape === 'off' || shape === 'none') return;
+ if (shape === 'off' || shape === 'none' || shape === 'wave') return;
const size = clamp(spec.size ?? 1, 0.02, 3);
const left = limits.left;
@@ -828,23 +828,14 @@ function drawHeadCover(ctx, spec, limits) {
const halfSpan = width / 2;
const boundary = (x) => {
const t = clamp((x - cx) / halfSpan, -1, 1);
- if (shape === 'fringe') {
- // Bangs: a soft point dipping in the middle, with side lobes.
- return base + 150 * (1 - Math.abs(t)) - 55 * Math.abs(Math.sin(t * Math.PI));
- }
- if (shape === 'fringe-up') {
- // The mirror image: a fringe that peaks *up* in the middle (a widow's peak
- // or centre part), so more forehead shows in the middle than at the sides.
- return base - 150 * (1 - Math.abs(t)) + 55 * Math.abs(Math.sin(t * Math.PI));
- }
- if (shape === 'wave') {
- // A single smooth sweep across the forehead.
- return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5);
- }
- if (shape === 'side') {
- // A side part: high on one side, low on the other.
- return base + 260 * t;
- }
+ // まえがみ: a narrow, deep V pointing down the middle (Atom / Mickey).
+ if (shape === 'fringe') return base + 175 * Math.max(0, 1 - Math.abs(t) * 1.6);
+ // はちわれ: the same V upside down - a widow's peak.
+ if (shape === 'fringe-up') return base - 320 * Math.max(0, 1 - Math.abs(t) * 1.5);
+ // カーブ: a plain shallow curve, higher (more hair) round the sides.
+ if (shape === 'curve') return base + 160 * t * t;
+ // わけめ: a 7:3 parting - a step at 30% from the left.
+ if (shape === 'side') return base + 60 - 320 / (1 + Math.exp(-(t + 0.4) * 10));
// `cap`: a rounded helmet, lowest in the middle.
return base - 100 * (1 - t * t);
};
@@ -864,7 +855,7 @@ function drawHeadCover(ctx, spec, limits) {
if (shape === 'side') {
// わけめ: cut a real parting in the hair, so the two halves read as combed
// apart rather than as one smooth sweep.
- const partX = cx - halfSpan * 0.45;
+ const partX = cx - halfSpan * 0.4;
const gw = Math.max(5, width * 0.02) * (0.6 + size);
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
@@ -894,8 +885,8 @@ function drawHeadCover(ctx, spec, limits) {
*/
export function drawHeadCoverWrap(ctx, spec, layout) {
const shape = spec?.shape ?? 'off';
- // `split` is a retired front-plate shape; it has no wrap equivalent.
- if (shape === 'off' || shape === 'none' || shape === 'split') return;
+ // `split` and `wave` are retired front-plate shapes; they have no wrap equal.
+ if (shape === 'off' || shape === 'none' || shape === 'split' || shape === 'wave') return;
const size = clamp(spec.size ?? 1, 0.02, 3);
const W = layout.width;
@@ -924,10 +915,15 @@ export function drawHeadCoverWrap(ctx, spec, layout) {
const cx = layout.offsetX + span * 0.5 + clamp(spec.offsetX ?? 0, -600, 600);
const vBound = (x) => {
const t = clamp((x - cx) / half, -1, 1);
- if (shape === 'fringe') return base + 0.14 * (1 - Math.abs(t)) - 0.05 * Math.abs(Math.sin(t * Math.PI));
- if (shape === 'fringe-up') return base - 0.14 * (1 - Math.abs(t)) + 0.05 * Math.abs(Math.sin(t * Math.PI));
- if (shape === 'wave') return base + 0.10 * Math.sin(t * Math.PI * 0.9 + 0.5);
- if (shape === 'side') return base + 0.20 * t;
+ // まえがみ: a narrow, deep V pointing down the middle (Atom / Mickey).
+ if (shape === 'fringe') return base + 0.15 * Math.max(0, 1 - Math.abs(t) * 1.6);
+ // はちわれ: 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);
+ // カーブ: a plain shallow curve, lower (more hair) round the sides.
+ if (shape === 'curve') return base + 0.10 * t * t;
+ // わけめ: a 7:3 parting - a step at 30% from the left, so the larger side
+ // sits lower (more hair).
+ if (shape === 'side') return base + 0.08 - 0.16 / (1 + Math.exp(-(t + 0.4) * 10));
return base - 0.07 * (1 - t * t); // `cap`
};
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 63e501b..237d6c8 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -28,7 +28,8 @@ const SECTION_ICONS = {
'目': 'M2.5 12s3.6-6 9.5-6 9.5 6 9.5 6-3.6 6-9.5 6-9.5-6-9.5-6ZM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z',
'眉': 'M4 11q3.2-3.2 7-2M13 9q3.8-1.2 7 2',
'眼鏡・サングラス': 'M8 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM16 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM11.2 13h1.6M4.8 11.7 3 10.3M19.2 11.7 21 10.3',
- 'ほっぺ・頭の模様': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM9.4 10.2h.01M14.6 10.2h.01M9.4 14.2h5.2',
+ '髪': 'M4 14c0-4.8 3.6-7.6 8-7.6s8 2.8 8 7.6M4 14c2.6-1.4 5.3-1.9 8-1.8M20 14c-2.6-1.4-5.3-1.9-8-1.8',
+ 'ほっぺ': 'M6.4 14.6a2 1.4 0 1 0 0 .01ZM17.6 14.6a2 1.4 0 1 0 0 .01ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z',
'鼻ちょうちん': 'M15 4.6a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2ZM10.6 9.6 4.8 11.4',
'ひげ': 'M4.4 11.2c2.6-1.2 5.2-.6 7.6 2 2.4-2.6 5-3.2 7.6-2',
'口': 'M4.6 10c3 4.8 11.8 4.8 14.8 0',
@@ -169,7 +170,7 @@ export function buildPanel(app, root) {
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', 'wave', 'side']) },
+ headMark: { shape: pick(['off', 'cap', 'fringe', 'fringe-up', 'curve', 'side']) },
brow: {
enabled: true,
angle: Math.round(rnd(-30, 40)),
@@ -390,6 +391,21 @@ export function buildPanel(app, root) {
let faceSection = section(tabFace, 'プリセット');
+ // The face tab's sections are created here in the order they should read on
+ // screen (プリセット -> 髪 -> 眉 -> 目 -> 見る先 -> ...), but filled further
+ // down where the widgets are built: the DOM order is fixed at creation.
+ const faceTabSections = {
+ hair: section(tabFace, '髪'),
+ brow: section(tabFace, '眉'),
+ eye: section(tabFace, '目'),
+ look: section(tabFace, '見る先'),
+ glasses: section(tabFace, '眼鏡・サングラス'),
+ cheeks: section(tabFace, 'ほっぺ'),
+ snot: section(tabFace, '鼻ちょうちん'),
+ beard: section(tabFace, 'ひげ'),
+ mouth: section(tabFace, '口'),
+ };
+
faceSection.add(buttons({
items: FACE_PRESETS.map((preset) => ({
id: preset.id,
@@ -408,7 +424,7 @@ export function buildPanel(app, root) {
}
// --- eyes
- faceSection = section(tabFace, '目');
+ faceSection = faceTabSections.eye;
const eyeWidgets = {};
// The link switch sits directly above the *left* eye's open slider, because that
@@ -748,7 +764,7 @@ export function buildPanel(app, root) {
}));
// --- eyebrows (the original character has none; this is extra range)
- faceSection = section(tabFace, '眉');
+ faceSection = faceTabSections.brow;
faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。'));
const browToggle = check({
@@ -859,7 +875,7 @@ export function buildPanel(app, root) {
}));
// --- glasses / sunglasses (drawn into the same texture as the eyes and brows)
- faceSection = section(tabFace, '眼鏡・サングラス');
+ faceSection = faceTabSections.glasses;
faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。'
+ '線画のときはレンズを塗らず、フレームの線だけになります。'));
@@ -988,11 +1004,11 @@ export function buildPanel(app, root) {
],
}));
- // --- ほっぺ (a manga blush) and 頭の模様 (a mark on the head)
- faceSection = section(tabFace, 'ほっぺ・頭の模様');
- faceSection.add(hint('ほっぺ(マンガの赤らみ)と、頭の模様を、目や眉と同じ絵にかけます。'
- + 'どちらも決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。'
- + 'はじめは両方とも出ていません。'));
+ // --- ほっぺ (a manga blush)
+ faceSection = faceTabSections.cheeks;
+ faceSection.add(hint('ほっぺ(マンガの赤らみ)を、目や眉と同じ絵にかけます。'
+ + '決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。'
+ + 'はじめは出ていません。'));
const cheeksToggle = check({
label: 'ほっぺを出す',
@@ -1028,14 +1044,19 @@ export function buildPanel(app, root) {
cheekBox.add(hint('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、'
+ '「たかさ」は目からの下がり具合です。'));
+ // --- 髪 (a hair-like covering over the whole head; its own section, above 眉)
+ faceSection = faceTabSections.hair;
+ faceSection.add(hint('髪を頭にかぶせます。決まったキャラクターの髪ではなく、'
+ + 'よくあるマンガの形として用意しました。はじめは出ていません。'));
+
const headMarkShape = segmented({
- label: '頭の模様',
+ label: '髪',
options: [
{ value: 'off', label: 'なし' },
{ value: 'cap', label: 'かぶせ' },
{ value: 'fringe', label: 'まえがみ' },
- { value: 'fringe-up', label: 'まえがみ(逆)' },
- { value: 'wave', label: 'なみ' },
+ { value: 'fringe-up', label: 'はちわれ' },
+ { value: 'curve', label: 'カーブ' },
{ value: 'side', label: 'わけめ' },
],
value: 'off',
@@ -1043,6 +1064,13 @@ export function buildPanel(app, root) {
});
faceSection.add(headMarkShape.el);
+ const headMarkOverEyes = check({
+ label: '髪を目の前に出す',
+ value: true,
+ onChange: (value) => { state.face.eyes.headMark.overEyes = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, headMarkOverEyes.el, { wide: true }));
+
const headMarkSliders = {
size: slider({
label: '大きさ', min: 0.05, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
@@ -1062,7 +1090,7 @@ export function buildPanel(app, root) {
headMarkBox.add(headMarkSliders.offsetX.el);
headMarkBox.add(headMarkSliders.offsetY.el);
const headMarkColor = colorField({
- label: '模様の色', value: '#8a4fe0',
+ label: '髪の色', value: '#8a4fe0',
swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'],
onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); },
});
@@ -1073,13 +1101,13 @@ export function buildPanel(app, root) {
});
headMarkBox.add(headMarkColor.el);
headMarkBox.add(headMarkColor2.el);
- headMarkBox.add(hint('「頭の模様」は、頭にかぶせる髪のような塗りです。'
- + '「まえがみ(逆)」は、まえがみを上下さかさまにした形です。'
- + '「わけめ」は、髪を分けた筋が入ります。'
- + '決まったキャラクターの髪ではなく、よくあるマンガの記号として用意しました。'));
+ headMarkBox.add(hint('「かぶせ」は頭にかぶせる丸い髪、「まえがみ」は中央がとがった前髪、'
+ + '「はちわれ」は中央が逆に持ち上がった生え際、「カーブ」はドラえもんのように単純にカーブした髪、'
+ + '「わけめ」は7:3に分けた髪です。「たかさ」で生え際を目の中心あたりまで下げられます。'
+ + '「髪を目の前に出す」を切ると、髪が目の後ろに回ります。'));
// --- 鼻ちょうちん: the snot bubble of a sleeping face.
- faceSection = section(tabFace, '鼻ちょうちん');
+ faceSection = faceTabSections.snot;
const snotToggle = check({
label: '鼻ちょうちんを出す',
value: false,
@@ -1118,7 +1146,7 @@ export function buildPanel(app, root) {
+ '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。'));
// --- ひげ: mustaches and beards, hung under the nose.
- faceSection = section(tabFace, 'ひげ');
+ faceSection = faceTabSections.beard;
const beardShape = segmented({
label: 'ひげ',
options: [
@@ -1179,7 +1207,7 @@ export function buildPanel(app, root) {
+ '「左右の間隔」「瓢の長さ」「線の間隔」は「ねこ」で使います。'));
// --- mouth
- faceSection = section(tabFace, '口');
+ faceSection = faceTabSections.mouth;
const mouthToggle = check({
label: '口を表示',
@@ -1483,7 +1511,7 @@ export function buildPanel(app, root) {
+ '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。'));
// --- 見る先 (moved here, next to the face controls, from the pose tab) ----
- const lookAtSection = section(tabFace, '見る先');
+ const lookAtSection = faceTabSections.look;
// `lookAt` moves the eyes and the face only, so the face has to be rebuilt too.
const refreshLookAt = () => {
app.actions.refresh('lookAt');
@@ -2684,6 +2712,7 @@ export function buildPanel(app, root) {
for (const [key, widget] of Object.entries(cheekSliders)) widget.set(state.face.eyes.cheeks[key]);
cheekColor.set(state.face.eyes.cheeks.color);
headMarkShape.set(state.face.eyes.headMark.shape ?? 'off');
+ headMarkOverEyes.set(state.face.eyes.headMark.overEyes !== false);
for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]);
headMarkColor.set(state.face.eyes.headMark.color);
headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff');
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 3a79d9b..40dc377 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -223,15 +223,15 @@ export function defaultState() {
offsetY: 0, // extra drop below the eyes
color: '#f6a6b8',
},
- // The head covering's `shape` is one of `off`, `cap`, `fringe`, `wave`,
- // `side` (the boundary style) or `split` (a two-tone head divided by a
- // vertical curve). `color` is the covering / left half; `color2` is the
- // second colour of the two-tone split.
+ // The hair covering's `shape` is one of `off`, `cap`, `fringe`,
+ // `fringe-up`, `curve` or `side`. `overEyes` puts the hair in front of
+ // the eyes (bangs over the face); off leaves the eyes on top.
headMark: {
shape: 'off',
size: 1,
offsetX: 0,
offsetY: 0,
+ overEyes: true,
color: '#8a4fe0',
color2: '#ffffff',
},