aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 18:42:20 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 18:42:20 +0900
commite530565430622f7d1e824d27b6dc26d95024eb6f (patch)
tree272901323bc3fa37f951793d4e41a600d9b802e2
parentbb680c0e7c0a8b47115e067d769f18407f07a988 (diff)
public/bluebey-studio: 戻す/やり直すをタブの上へ、口パクをうごきへ
元に戻す・やり直すをパネル上部の常時表示バー(アイコン)に移し、どのタブでも使えるようにした。同じバーに「すべてをランダムに」(スタイル・ポーズ・顔・テーマ)を追加。 「口パク」を顔タブから「うごき」の中へ統合した。 「顔をランダムに」が口の高さ・傾き・はば・頭の模様も変えるようにした。
-rw-r--r--public/bluebey-studio/src/panel.js159
-rw-r--r--public/bluebey-studio/src/style.css23
-rw-r--r--public/bluebey-studio/src/ui.js11
3 files changed, 133 insertions, 60 deletions
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index e4ef0b6..f82d6bf 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -1,6 +1,6 @@
import {
h, section as uiSection, subhead, hint, slider, check, segmented, buttons,
- colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast,
+ colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, icon,
} from './ui.js';
import {
FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS,
@@ -152,6 +152,71 @@ export function buildPanel(app, root) {
root.replaceChildren();
+ // --- randomising ----------------------------------------------------------
+ // Shared by 「顔をランダムに」 (face tab) and 「すべてをランダムに」 (the top bar).
+ const rnd = (min, max) => min + (max - min) * Math.random();
+ const pick = (list) => list[Math.floor(Math.random() * list.length)];
+ const randomEye = () => ({
+ open: +rnd(0.4, 1).toFixed(2),
+ lookX: +rnd(-0.35, 0.35).toFixed(2),
+ lookY: +rnd(-0.25, 0.25).toFixed(2),
+ });
+ const randomFacePatch = () => ({
+ eyes: {
+ left: randomEye(),
+ right: randomEye(),
+ cheeks: { enabled: Math.random() < 0.5 },
+ beard: { shape: pick(['off', 'scotch', 'kaiser', 'apron', 'cat']) },
+ headMark: { shape: pick(['off', 'cap', 'fringe', 'fringe-up', 'wave', 'side']) },
+ 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),
+ },
+ },
+ // The mouth takes the height and the tilt too, so the face really changes.
+ mouth: {
+ smile: +rnd(-1, 1.2).toFixed(2),
+ open: +rnd(0, 0.5).toFixed(2),
+ width: +rnd(0.7, 1.3).toFixed(2),
+ tilt: Math.round(rnd(-12, 12)),
+ offsetY: Math.round(rnd(-16, 16)),
+ },
+ });
+ const randomFace = () => replace({ face: randomFacePatch() }, 'face');
+ const randomAll = () => {
+ const pose = pick(POSE_PRESETS);
+ const theme = pick(THEMES);
+ app.actions.applyPosePreset?.(pose.id);
+ app.actions.applyTheme?.(theme.id);
+ replace({
+ face: randomFacePatch(),
+ render: { style: pick(STYLE_DEFS.map((style) => style.value)), mirror: Math.random() < 0.5 },
+ }, 'all');
+ sync();
+ };
+
+ // A small action bar above the tabs: undo/redo apply to every tab, so they must
+ // not be buried inside one of them. 「すべてをランダムに」 sits here too.
+ const topBar = h('div', { class: 'panel-topbar' });
+ const iconButton = (label, d, onClick) => {
+ const button = h('button', { type: 'button', class: 'icon-btn', title: label, 'aria-label': label });
+ button.append(icon(d));
+ button.addEventListener('click', onClick);
+ return button;
+ };
+ topBar.append(
+ iconButton('元に戻す', 'M4.8 11.5h9.7a5.2 5.2 0 0 1 0 10.4h-3.6M4.8 11.5 9.6 6.7M4.8 11.5 9.6 16.3', () => app.actions.undo()),
+ iconButton('やり直す', 'M19.2 11.5h-9.7a5.2 5.2 0 0 0 0 10.4h3.6M19.2 11.5 14.4 6.7M19.2 11.5 14.4 16.3', () => app.actions.redo()),
+ iconButton('すべてをランダムに', 'M5 4.5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-13a1 1 0 0 1 1-1ZM9 8.5h.01M15 8.5h.01M12 12h.01M9 15.5h.01M15 15.5h.01', () => randomAll()),
+ );
+ root.append(topBar);
+
// Tabs instead of one long scroll: the panel used to be a wall of controls,
// and everything is easier to find this way. Each tab is a small stroke icon
// (its name is the tooltip and the accessible label) sitting on the top edge
@@ -274,11 +339,9 @@ export function buildPanel(app, root) {
{ id: 'resetBone', label: 'このボーンを戻す', onClick: () => { rig.reset(rig.selected); app.actions.capturePose(); syncBones(); app.needsRender = true; } },
{ id: 'resetAll', label: 'ポーズを全部戻す', onClick: () => app.actions.resetPose() },
{ id: 'random', label: '少しランダムに', onClick: () => randomPose() },
- { id: 'undo', label: '元に戻す', onClick: () => app.actions.undo() },
- { id: 'redo', label: 'やり直す', onClick: () => app.actions.redo() },
],
}).el);
- tabPose.append(hint('「元に戻す」「やり直す」は、ポーズだけでなく色や背景など、すべての操作に効きます。'));
+ tabPose.append(hint('「元に戻す」「やり直す」は、パネル上部のアイコンにあります(どのタブでも使えます)。'));
/** Accepts a preset object (from the buttons) or a preset id (from tools). */
function applyPosePreset(presetOrId) {
@@ -1322,34 +1385,8 @@ export function buildPanel(app, root) {
/* -------------------------------------------------------------- 口パク */
- // 口パク only: the mouth moves as if talking, and no sound is made at all.
- // (The studio used to read the line with the device's own speech voices, but
- // those belong to the device, so a recording of them is not ours to hand out.)
- const flapSection = section(tabFace, '口パク(声は出ません)');
- const flapText = textArea({
- label: '口パクするセリフ',
- value: '',
- rows: 3,
- onChange: (value) => { state.mouthFlap.text = value; },
- });
- flapSection.add(flapText.el);
- flapSection.add(buttons({
- items: [{ id: 'flap', label: '口パク/止める', primary: true, onClick: () => app.actions.toggleMouthFlap() }],
- }));
-
- const flapSliders = {
- rate: slider({
- label: 'はやさ', min: 0.5, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`,
- onInput: (v) => { state.mouthFlap.rate = v; },
- }),
- mouthGain: slider({
- label: '口の開き', min: 0.2, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
- onInput: (v) => { state.mouthFlap.mouthGain = v; },
- }),
- };
- for (const widget of Object.values(flapSliders)) flapSection.add(widget.el);
- flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。'
- + '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。'));
+ // 口パク lives inside うごき on the pose tab (see below): talking is something
+ // the character *does*, and it is usually recorded together with the motion.
/* ------------------------------------------------------ movement (pose tab) */
@@ -1403,6 +1440,35 @@ export function buildPanel(app, root) {
moveSection.add(recordButton.el);
moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。'));
+ // 口パク: the mouth moves as if talking, and no sound is made at all. It lives
+ // in うごき because talking is motion - and it is usually recorded with it.
+ // (The studio used to read the line with the device's own speech voices, but
+ // those belong to the device, so a recording of them is not ours to hand out.)
+ const flapSection = details(moveSection.body, '口パク(声は出ません)');
+ const flapText = textArea({
+ label: '口パクするセリフ',
+ value: '',
+ rows: 3,
+ onChange: (value) => { state.mouthFlap.text = value; },
+ });
+ flapSection.add(flapText.el);
+ flapSection.add(buttons({
+ items: [{ id: 'flap', label: '口パク/止める', primary: true, onClick: () => app.actions.toggleMouthFlap() }],
+ }));
+ const flapSliders = {
+ rate: slider({
+ label: 'はやさ', min: 0.5, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`,
+ onInput: (v) => { state.mouthFlap.rate = v; },
+ }),
+ mouthGain: slider({
+ label: '口の開き', min: 0.2, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.mouthFlap.mouthGain = v; },
+ }),
+ };
+ for (const widget of Object.values(flapSliders)) flapSection.add(widget.el);
+ flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。'
+ + '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。'));
+
// --- 見る先 (moved here, next to the face controls, from the pose tab) ----
const lookAtSection = section(tabFace, '見る先');
// `lookAt` moves the eyes and the face only, so the face has to be rebuilt too.
@@ -1446,34 +1512,7 @@ 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');
- };
+ // The face tab's bottom line: a quick way out of whatever has been built up.
tabFace.append(buttons({
items: [
{ id: 'faceReset', label: '顔をリセット', onClick: () => replace({ face: defaultState().face }, 'face') },
diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css
index 7caa6f3..57d5403 100644
--- a/public/bluebey-studio/src/style.css
+++ b/public/bluebey-studio/src/style.css
@@ -783,3 +783,26 @@ kbd {
font-size: 11px;
text-align: center;
}
+
+/* The always-visible action bar above the tabs: 元に戻す / やり直す / すべてランダム. */
+.panel-topbar {
+ display: flex;
+ gap: 4px;
+ justify-content: flex-end;
+ padding: 4px 8px 2px;
+}
+.panel-topbar .icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #fff;
+ color: #2b2433;
+ cursor: pointer;
+}
+.panel-topbar .icon-btn:hover { background: #f4f0fb; }
+.panel-topbar .icon-btn:active { transform: translateY(1px); }
diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js
index 46c5adb..b4c7d24 100644
--- a/public/bluebey-studio/src/ui.js
+++ b/public/bluebey-studio/src/ui.js
@@ -95,6 +95,17 @@ function secIcon(d) {
}
/**
+ * A standalone 24x24 stroke icon (the same drawing as a tab's), for a plain
+ * button that has no label of its own.
+ */
+export function icon(d, size = 18) {
+ const svg = tabIcon(d);
+ svg.setAttribute('width', String(size));
+ svg.setAttribute('height', String(size));
+ 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
* label becomes its tooltip and accessible name; without one the label shows.