aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 14:36:10 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 14:36:10 +0900
commitc23c8fb5c547540c0705bef900340c9a6e358126 (patch)
treeabe4787077265e9d32690183900126fadbc81d4e /public/bluebey-studio
parenta1337fab526a9babd5c492537bd7aee4cc5e7d26 (diff)
public/bluebey-studio: 鼻ちょうちんを球に、寝ている動きを追加、見出しにアイコン
- 鼻ちょうちんを単純な球にし、奥行き(おくゆき)も設定できるように - 「ねている」の動きを追加。呼吸に合わせて鼻ちょうちんが膨らみ・縮む - 各タブの見出し(メニュー一覧)にアイコンを追加 - 操作案内の文言を実態(ぶるべーをドラッグで移動)に合わせて更新
Diffstat (limited to 'public/bluebey-studio')
-rw-r--r--public/bluebey-studio/index.html4
-rw-r--r--public/bluebey-studio/src/animation.js26
-rw-r--r--public/bluebey-studio/src/main.js39
-rw-r--r--public/bluebey-studio/src/panel.js44
-rw-r--r--public/bluebey-studio/src/presets.js2
-rw-r--r--public/bluebey-studio/src/style.css3
-rw-r--r--public/bluebey-studio/src/ui.js15
7 files changed, 110 insertions, 23 deletions
diff --git a/public/bluebey-studio/index.html b/public/bluebey-studio/index.html
index 0711549..db44f3c 100644
--- a/public/bluebey-studio/index.html
+++ b/public/bluebey-studio/index.html
@@ -104,10 +104,10 @@ if (window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coa
<aside id="panel" aria-label="コントロールパネル"></aside>
<p id="viewport-hint" class="viewport-hint">
- ドラッグ:回転 / ホイール:ズーム / 右ドラッグ:移動 / モデルをクリック:ボーン選択
+ ドラッグ:回転 / ホイール:ズーム / 右ドラッグ:移動 / ぶるべーをドラッグ:移動
</p>
<p id="viewport-hint-touch" class="viewport-hint">
- 1本指でドラッグ:回転 / 2本指:ズーム・移動 / タップ:ボーン選択
+ 1本指でドラッグ:回転 / 2本指:ズーム・移動 / ぶるべーをドラッグ:移動
</p>
<div id="toast" role="status" aria-live="polite"></div>
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js
index 3233e43..e569d12 100644
--- a/public/bluebey-studio/src/animation.js
+++ b/public/bluebey-studio/src/animation.js
@@ -17,6 +17,7 @@ export class Animator {
this.eyeOpen = 1;
this.look = { x: 0, y: 0 };
this.idleWeight = 0;
+ this.snotScale = 1;
}
reset() {
@@ -25,6 +26,7 @@ export class Animator {
this.blinkPhase = -1;
this.blinkTimer = 2;
this.time = 0;
+ this.snotScale = 1;
}
update(dt) {
@@ -35,11 +37,24 @@ export class Animator {
const moving = (this.state.anim.mode ?? 'off') !== 'off';
this.idleWeight += ((moving ? 1 : 0) - this.idleWeight) * Math.min(1, step * 4);
+ // 鼻ちょうちん: while sleeping, the bubble swells on the out-breath and shrinks
+ // again. It is a real object (see src/main.js), so the motion is published here
+ // as a plain multiplier the render loop applies to the bubble's scale.
+ this.snotScale = (this.state.anim.mode ?? 'off') === 'sleep'
+ ? 0.45 + 0.55 * (0.5 - 0.5 * Math.cos(this.time * 1.3))
+ : 1;
+
this.updateBlink(step);
this.updateLook(step);
}
updateBlink(dt) {
+ if ((this.state.anim.mode ?? 'off') === 'sleep') {
+ // Asleep: the eyes stay shut, whatever the blink setting says.
+ this.eyeOpen = 0;
+ this.blinkPhase = -1;
+ return;
+ }
if (!this.state.anim.blink) {
this.eyeOpen = 1;
this.blinkPhase = -1;
@@ -128,6 +143,17 @@ export class Animator {
return { bones, root };
}
+ if (mode === 'sleep') {
+ // Slow breathing: the body rises and settles, the flippers drift, and the
+ // bubble swells and shrinks (that swell is `snotScale`, read by the loop).
+ const breath = 0.5 - 0.5 * Math.cos(t * 1.3);
+ add('master', Math.sin(t * 0.65) * 1.1, 0, 0);
+ add('arm.l', 0, 0, Math.sin(t * 0.9) * 2.5);
+ add('arm.r', 0, 0, -Math.sin(t * 0.9) * 2.5);
+ root[1] += breath * 0.03;
+ return { bones, root };
+ }
+
// Default: gentle breathing, as if standing there alive.
add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6);
add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5);
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 68df32c..1f6ad6e 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -275,14 +275,15 @@ async function init() {
if (!group) return;
group.visible = snot?.enabled === true;
if (!group.visible) return;
- group.scale.setScalar(Number.isFinite(snot.size) ? snot.size : 1);
- const base = group.userData.base;
const k = 0.01; // artwork px -> world units, enough to nudge the bubble
- group.position.set(
- base.x + (snot.offsetX ?? 0) * k,
- base.y + (snot.offsetY ?? 0) * k,
- base.z,
- );
+ 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));
+ group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1;
+ group.scale.setScalar(group.userData.baseScale);
group.userData.material.color.set(snot.color ?? '#dfe8ff');
}
@@ -321,16 +322,12 @@ async function init() {
});
const group = new THREE.Group();
group.name = 'snot-bubble';
- const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.05, 28, 20), material);
- const neck = new THREE.Mesh(new THREE.SphereGeometry(radius * 0.42, 20, 14), material);
- group.add(bubble, neck);
+ const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.15, 28, 20), material);
+ group.add(bubble);
const bone = master.bone;
- const bubbleLocal = bone.worldToLocal(centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7)));
- const noseLocal = bone.worldToLocal(centre.clone());
- group.position.copy(bubbleLocal);
- // The neck lump sits partway back towards the nose, so the two read as joined.
- neck.position.copy(noseLocal).sub(bubbleLocal).multiplyScalar(0.45);
- group.userData = { bubble, neck, material, base: bubbleLocal.clone() };
+ 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 };
group.visible = false;
bone.add(group);
return group;
@@ -1244,12 +1241,20 @@ async function init() {
if (snotBubble) {
const snot = state.face.eyes.snot;
- const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.color}`;
+ const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.offsetZ}|${snot.color}`;
if (sig !== snotSignature) {
snotSignature = sig;
applySnotBubble(snotBubble, snot);
app.needsRender = true;
}
+ // The sleeping breath swells the bubble; `snotScale` is 1 otherwise.
+ if (snotBubble.visible) {
+ const scale = (snotBubble.userData.baseScale ?? 1) * (animator.snotScale ?? 1);
+ if (snotBubble.scale.x !== scale) {
+ snotBubble.scale.setScalar(scale);
+ app.needsRender = true;
+ }
+ }
}
const params = buildFaceParams();
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index c26c88c..255e6cf 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -1,5 +1,5 @@
import {
- h, section, subhead, hint, slider, check, segmented, buttons,
+ h, section as uiSection, subhead, hint, slider, check, segmented, buttons,
colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast,
} from './ui.js';
import {
@@ -15,6 +15,43 @@ import {
GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH,
} from './gion.js';
+/**
+ * A small icon for each section heading, so the list a tab shows can be scanned
+ * at a glance. Keyed by the section title; a title with no icon just shows text.
+ */
+const SECTION_ICONS = {
+ 'ポーズ例': 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2',
+ '全身とボーンのスライダー': 'M4 7h16M4 12h16M4 17h16M8 7h.01M15 12h.01M11 17h.01',
+ '操作': 'M7 4l10 6-4.3 1.3L10.8 16 7 4Z',
+ 'うごき': 'M8 5l10 7-10 7V5Z',
+ 'プリセット': 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0',
+ '目': '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',
+ '鼻ちょうちん': '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',
+ '口パク(声は出ません)': 'M4 9v6h3l4 3V6L7 9H4ZM15.5 9.5l4 5M19.5 9.5l-4 5',
+ '見る先': 'M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6Z',
+ 'スタイル': 'M4 20l3.4-.8L20 6.6 17.4 4 4.8 16.6 4 20Z',
+ '体の色': 'M12 3.4s6 6.2 6 10.1a6 6 0 0 1-12 0C6 9.6 12 3.4 12 3.4Z',
+ '見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01',
+ '背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01',
+ '舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5',
+ 'セリフ(吹き出し)': 'M4 5h16v10H9l-4 4v-4H4V5Z',
+ '画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8',
+ '画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01',
+ '画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01',
+ 'まんが': 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
+ '共有': 'M8.2 10.6a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 6.4a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 20.8a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM10 10.4l5-2.6M10 13.6l5 2.6',
+ 'テクスチャの下地を書き出す': 'M4 4h16v16H4zM4 9.3h16M4 14.7h16M9.3 4v16M14.7 4v16',
+ '設定': 'M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8ZM12 2.6v2.4M12 19v2.4M2.6 12H5M19 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7',
+};
+
+/** `section`, with the heading icon looked up from its title. */
+const section = (parent, title, options) => uiSection(parent, title, { icon: SECTION_ICONS[title], ...options });
+
/** Fallbacks for the fields an older settings file may predate. */
const BODY_COLOR_FALLBACKS = {
body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0',
@@ -965,6 +1002,10 @@ export function buildPanel(app, root) {
label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`,
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)}`,
+ onInput: (v) => { state.face.eyes.snot.offsetZ = v; app.actions.refresh('face'); },
+ }),
};
for (const widget of Object.values(snotSliders)) snotBox.add(widget.el);
const snotColor = colorField({
@@ -1287,6 +1328,7 @@ export function buildPanel(app, root) {
{ value: 'idle', label: 'ゆれる' },
{ value: 'walk', label: '歩く' },
{ value: 'wave', label: 'てをふる' },
+ { value: 'sleep', label: 'ねている' },
],
value: 'off',
onChange: (value) => { state.anim.mode = value; app.animator.reset(); app.needsRender = true; },
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index a450eb2..6487066 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -216,8 +216,8 @@ export function defaultState() {
size: 1,
offsetX: 0,
offsetY: 0,
+ offsetZ: 0,
color: '#e3ecff',
- line: '#55386e',
},
// ひげ: a mustache or beard worn under the nose. `shape` is one of `off`,
// `scotch`, `kaiser`, `apron` or `cat`. Starts off.
diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css
index e5287a2..7caa6f3 100644
--- a/public/bluebey-studio/src/style.css
+++ b/public/bluebey-studio/src/style.css
@@ -316,6 +316,9 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); }
.sec-head .chev { color: var(--muted); font-size: 10px; transition: transform 0.18s; }
.sec.closed .chev { transform: rotate(-90deg); }
+.sec-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.sec-title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sec-icon { flex: 0 0 auto; width: 16px; height: 16px; color: var(--accent); }
.sec-body { padding: 2px 12px 12px; display: grid; gap: 9px; }
.sec.closed .sec-body { display: none; }
diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js
index e184f5e..46c5adb 100644
--- a/public/bluebey-studio/src/ui.js
+++ b/public/bluebey-studio/src/ui.js
@@ -36,10 +36,12 @@ export function toast(message) {
toastTimer = setTimeout(() => node.classList.remove('show'), 2400);
}
-export function section(parent, title, { open = false } = {}) {
+export function section(parent, title, { open = false, icon = null } = {}) {
const body = h('div', { class: 'sec-body' });
const head = h('button', { type: 'button', class: 'sec-head' },
- h('span', { text: title }),
+ h('span', { class: 'sec-title' },
+ icon ? secIcon(icon) : null,
+ h('span', { text: title })),
h('span', { class: 'chev', text: '▾' }));
const el = h('section', { class: `sec${open ? '' : ' closed'}` }, head, body);
head.addEventListener('click', () => el.classList.toggle('closed'));
@@ -83,6 +85,15 @@ function tabIcon(d) {
return svg;
}
+function secIcon(d) {
+ const svg = tabIcon(d);
+ svg.setAttribute('class', 'sec-icon');
+ svg.setAttribute('width', '16');
+ svg.setAttribute('height', '16');
+ svg.style.verticalAlign = 'middle';
+ return svg;
+}
+
/**
* Tabbed container: returns the per-tab panels to fill in. When a definition
* carries an `icon` (an SVG path `d`) the tab shows only that icon, and the