diff options
Diffstat (limited to 'public')
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 159 | ||||
| -rw-r--r-- | public/bluebey-studio/src/style.css | 23 | ||||
| -rw-r--r-- | public/bluebey-studio/src/ui.js | 11 |
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. |
