aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 14:56:41 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 14:56:41 +0900
commitc6d1df09c66bcfe8388651866d26ed5d05b4d2d2 (patch)
tree8f55d24a31ec22c1c904157e41ba13cdd88ab420 /public/bluebey-studio/src
parent27483644d7ba9e819dbb5f64844270dd499f45ff (diff)
public/bluebey-studio: 鼻ちょうちんの追従とひげを修正
鼻ちょうちんの位置が、カメラ移動やポーズ変更のあとに飛ぶ問題を修正。位置はボーン局所座標で持ち、スライダーのオフセットは体の向きに追従させた。寝ている動きでは鼻ちょうちんを自動で表示する。 ひげは「猫」に左右の間隔スライダーを追加し、太さを変えずに大きくできるようにした。カイゼルは先をダリ風のカールにした。
Diffstat (limited to 'public/bluebey-studio/src')
-rw-r--r--public/bluebey-studio/src/faceArt.js36
-rw-r--r--public/bluebey-studio/src/main.js30
-rw-r--r--public/bluebey-studio/src/panel.js26
-rw-r--r--public/bluebey-studio/src/presets.js5
4 files changed, 73 insertions, 24 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index a92482a..8d20cda 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -869,7 +869,7 @@ function drawBeard(ctx, spec, lineOnly = false) {
const shape = spec?.shape ?? 'off';
if (shape === 'off' || shape === 'none') return;
const size = clamp(spec.size ?? 1, 0.2, 3);
- const cx = BEARD_LAYOUT.cx + clamp(spec.offsetX ?? 0, -600, 600);
+ const cx = BEARD_LAYOUT.cx;
const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600);
const w = BEARD_LAYOUT.width * size;
const color = spec.color ?? '#3a2a4a';
@@ -895,8 +895,24 @@ function drawBeard(ctx, spec, lineOnly = false) {
{ x: cx + w * 0.28, y: cy + w * 0.07 },
{ x: cx + w * 0.5, y: cy - w * 0.02 },
], lw * 2.6);
- stroke([{ x: cx - w * 0.5, y: cy - w * 0.02 }, { x: cx - w * 0.62, y: cy - w * 0.16 }], lw * 1.4);
- stroke([{ x: cx + w * 0.5, y: cy - w * 0.02 }, { x: cx + w * 0.62, y: cy - w * 0.16 }], lw * 1.4);
+ const curlAt = (dir) => {
+ // A little inward-turning spiral at the tip: the Dalí curl.
+ const ex = cx + dir * w * 0.5;
+ const ey = cy - w * 0.02;
+ const r0 = lw * 0.5;
+ const r1 = w * 0.17;
+ const steps = 22;
+ const points = [];
+ for (let i = 0; i <= steps; i += 1) {
+ const t = i / steps;
+ const a = -Math.PI / 2 + dir * Math.PI * 2 * 1.4 * t;
+ const r = r0 + (r1 - r0) * t;
+ points.push({ x: ex + dir * Math.cos(a) * r, y: ey + Math.sin(a) * r });
+ }
+ stroke(points, lw * 1.25);
+ };
+ curlAt(-1);
+ curlAt(1);
return;
}
if (shape === 'apron') {
@@ -918,13 +934,19 @@ function drawBeard(ctx, spec, lineOnly = false) {
}
return;
}
- // ねこ: three whiskers fanned out on each side of the muzzle.
+ // ねこ: three whiskers a side, growing out of the cheeks with a gap left in the
+ // middle (like a cat's). The thickness is fixed, so `size` makes the whiskers
+ // 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);
for (const dir of [-1, 1]) {
for (let i = 0; i < 3; i += 1) {
stroke([
- { x: cx + dir * w * 0.08, y: cy + w * 0.04 },
- { x: cx + dir * w * 0.62, y: cy + w * 0.04 + (i - 1) * w * 0.18 },
- ], lw * 0.9);
+ { x: cx + dir * inner, y: cy + (i - 1) * w * 0.16 },
+ { x: cx + dir * outer, y: cy + (i - 1) * w * 0.24 },
+ ], whisker);
}
}
}
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 1f6ad6e..c7e6501 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -275,13 +275,15 @@ async function init() {
if (!group) return;
group.visible = snot?.enabled === true;
if (!group.visible) return;
+ // The offsets are applied in the *bone's* frame, along the world axes measured
+ // once at rest, so the bubble travels with the body. Recomputing them against
+ // the live world matrix made it snap back whenever the body had moved.
const k = 0.01; // artwork px -> world units, enough to nudge the bubble
- const target = group.userData.baseWorld.clone().add(new THREE.Vector3(
- (snot.offsetX ?? 0) * k,
- (snot.offsetY ?? 0) * k,
- (snot.offsetZ ?? 0) * k,
- ));
- group.position.copy(group.userData.bone.worldToLocal(target));
+ const { localBase, axes } = group.userData;
+ group.position.copy(localBase)
+ .addScaledVector(axes.x, (snot.offsetX ?? 0) * k)
+ .addScaledVector(axes.y, (snot.offsetY ?? 0) * k)
+ .addScaledVector(axes.z, (snot.offsetZ ?? 0) * k);
group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1;
group.scale.setScalar(group.userData.baseScale);
group.userData.material.color.set(snot.color ?? '#dfe8ff');
@@ -326,8 +328,20 @@ async function init() {
group.add(bubble);
const bone = master.bone;
const baseWorld = centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7));
- group.position.copy(bone.worldToLocal(baseWorld.clone()));
- group.userData = { bubble, material, bone, baseWorld, baseScale: 1 };
+ const localBase = bone.worldToLocal(baseWorld.clone());
+ // World-axis directions expressed in the bone's frame, captured at rest: the
+ // sliders keep meaning "right / up / towards the viewer" while the bubble rides
+ // with the body.
+ const boneQuat = new THREE.Quaternion();
+ bone.getWorldQuaternion(boneQuat);
+ const invQuat = boneQuat.clone().invert();
+ const axes = {
+ x: new THREE.Vector3(1, 0, 0).applyQuaternion(invQuat),
+ y: new THREE.Vector3(0, 1, 0).applyQuaternion(invQuat),
+ z: new THREE.Vector3(0, 0, 1).applyQuaternion(invQuat),
+ };
+ group.position.copy(localBase);
+ group.userData = { bubble, material, bone, localBase, axes, baseScale: 1 };
group.visible = false;
bone.add(group);
return group;
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 255e6cf..72718bc 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -1003,7 +1003,7 @@ export function buildPanel(app, root) {
onInput: (v) => { state.face.eyes.snot.offsetY = v; app.actions.refresh('face'); },
}),
offsetZ: slider({
- label: 'おくゆき', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ label: 'おくゆき', min: -10, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
onInput: (v) => { state.face.eyes.snot.offsetZ = v; app.actions.refresh('face'); },
}),
};
@@ -1038,9 +1038,9 @@ export function buildPanel(app, root) {
label: '大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, format: (v) => v.toFixed(2),
onInput: (v) => { state.face.eyes.beard.size = v; app.actions.refresh('face'); },
}),
- offsetX: slider({
- label: 'よこの位置', min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
- onInput: (v) => { state.face.eyes.beard.offsetX = v; app.actions.refresh('face'); },
+ spacing: slider({
+ label: '左右の間隔', min: 0, max: 240, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
+ onInput: (v) => { state.face.eyes.beard.spacing = v; app.actions.refresh('face'); },
}),
offsetY: slider({
label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
@@ -1049,7 +1049,7 @@ export function buildPanel(app, root) {
};
faceSection.add(beardSliders.size.el);
const beardBox = details(faceSection.body, 'こまかい設定(位置・色)');
- beardBox.add(beardSliders.offsetX.el);
+ beardBox.add(beardSliders.spacing.el);
beardBox.add(beardSliders.offsetY.el);
const beardColor = colorField({
label: 'ひげの色', value: '#3a2a4a',
@@ -1057,7 +1057,8 @@ export function buildPanel(app, root) {
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, '口');
@@ -1331,7 +1332,18 @@ export function buildPanel(app, root) {
{ value: 'sleep', label: 'ねている' },
],
value: 'off',
- onChange: (value) => { state.anim.mode = value; app.animator.reset(); app.needsRender = true; },
+ onChange: (value) => {
+ state.anim.mode = value;
+ // Sleeping always shows the snot bubble, so the motion reads without having
+ // to switch the bubble on as well.
+ if (value === 'sleep') {
+ state.face.eyes.snot.enabled = true;
+ snotToggle.set(true);
+ app.actions.refresh('face');
+ }
+ app.animator.reset();
+ app.needsRender = true;
+ },
});
moveSection.add(motionSegment.el);
for (const widget of Object.values(animWidgets)) moveSection.add(controlRow(null, widget.el, { wide: true }));
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 6487066..1130a67 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -220,12 +220,13 @@ export function defaultState() {
color: '#e3ecff',
},
// ひげ: a mustache or beard worn under the nose. `shape` is one of `off`,
- // `scotch`, `kaiser`, `apron` or `cat`. Starts off.
+ // `scotch`, `kaiser`, `apron` or `cat`. `spacing` widens the gap between
+ // the cat's left and right whiskers. Starts off.
beard: {
shape: 'off',
size: 1,
- offsetX: 0,
offsetY: 0,
+ spacing: 0,
color: '#3a2a4a',
},
// Eyebrows are drawn into the eye texture. Off by default, because the