From c6d1df09c66bcfe8388651866d26ed5d05b4d2d2 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:56:41 +0900 Subject: public/bluebey-studio: 鼻ちょうちんの追従とひげを修正 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 鼻ちょうちんの位置が、カメラ移動やポーズ変更のあとに飛ぶ問題を修正。位置はボーン局所座標で持ち、スライダーのオフセットは体の向きに追従させた。寝ている動きでは鼻ちょうちんを自動で表示する。 ひげは「猫」に左右の間隔スライダーを追加し、太さを変えずに大きくできるようにした。カイゼルは先をダリ風のカールにした。 --- public/bluebey-studio/src/faceArt.js | 36 +++++++++++++++++++++++++++++------- public/bluebey-studio/src/main.js | 30 ++++++++++++++++++++++-------- public/bluebey-studio/src/panel.js | 26 +++++++++++++++++++------- public/bluebey-studio/src/presets.js | 5 +++-- 4 files changed, 73 insertions(+), 24 deletions(-) (limited to 'public/bluebey-studio/src') 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 -- cgit v1.3.1