diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-03 13:39:29 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-03 13:39:29 +0900 |
| commit | 34032981f60848940640e5741c9ced41a550c8f1 (patch) | |
| tree | 907e76722f9a62cd24c3093047554417ae76b64c /public/bluebey-studio/src/panel.js | |
| parent | 58a00c403ccf4fc114a9b46d89925ea54d574e2a (diff) | |
public/bluebey-studio: ポーズ追加・シーンタブ新設・タブのアイコン化
- ポーズ例に「きおつけ」を追加し、おじぎ/ふかくおじぎを両手下げに、
くびかしげは左足を接地させたままにするよう補正、「てをふる」の動き
を追加、全身の移動スライダーの範囲を拡大
- 「表情」タブを「顔」に改名し、「背景」「舞台」「セリフ」「画面・光」
「画面の情報」をまとめた「シーン」タブを新設
- 各タブをアイコンのみの表示にし、タブらしい見た目に変更(名前はツール
チップと読み上げラベルに)
- 起動時の注意書きと /bluebey/ ページの禁止事項を「イメージを損なう
ような利用(たとえば、攻撃的・差別的・性的・過激な利用、誹謗中傷
など)」に変更
- これまでの未コミット分(背景の追加とWebP化、眼鏡・サングラス、ほっぺ
・頭の模様、缶詰、看板、集中線、輪郭線のズーム追従、スマホ対応、
共有など)を反映
- タッチ用の操作案内の <p> の閉じ位置を修正
Diffstat (limited to 'public/bluebey-studio/src/panel.js')
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 141 |
1 files changed, 90 insertions, 51 deletions
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 28ea8f9..babeb59 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -115,9 +115,10 @@ export function buildPanel(app, root) { root.replaceChildren(); - // Four 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 now carries a - // small stroke icon so the row can be scanned at a glance. + // 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 + // of the content, so the row reads as tabs rather than buttons. const tabBar = tabs(root, [ { id: 'pose', @@ -127,7 +128,7 @@ export function buildPanel(app, root) { }, { id: 'face', - label: '表情', + label: '顔', // A smiley face: an outline, two eyes and a smile. icon: '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', }, @@ -138,6 +139,12 @@ export function buildPanel(app, root) { icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.7 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.7-1.7h2c3 0 5.5-2.5 5.5-5.6C22 6 17.5 2 12 2ZM13.5 6.5h.01M17.5 10.5h.01M8.5 7.5h.01M6.5 12.5h.01', }, { + id: 'scene', + label: 'シーン', + // A framed picture: background, stage and light all sit inside the frame. + icon: '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', + }, + { id: 'export', label: '書き出し', // A download arrow dropping into a tray. @@ -147,6 +154,7 @@ export function buildPanel(app, root) { const tabPose = tabBar.panels.pose; const tabFace = tabBar.panels.face; const tabView = tabBar.panels.view; + const tabScene = tabBar.panels.scene; const tabExport = tabBar.panels.export; /* ------------------------------------------------------------------ pose */ @@ -197,8 +205,12 @@ export function buildPanel(app, root) { // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the // main way in, so they stay folded away inside the bone section. - const rootSliders = ['よこ移動', 'たて移動', 'おくゆき'].map((label, index) => slider({ - label, min: -2, max: 2, step: 0.01, value: 0, + const rootSliders = [ + { label: 'よこ移動', min: -5, max: 5 }, + { label: 'たて移動', min: -2, max: 6 }, + { label: 'おくゆき', min: -5, max: 5 }, + ].map(({ label, min, max }, index) => slider({ + label, min, max, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.pose.root[index] = v; app.needsRender = true; }, })); @@ -705,24 +717,43 @@ export function buildPanel(app, root) { faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。' + '線画のときはレンズを塗らず、フレームの線だけになります。')); - const glassesToggle = check({ - label: '眼鏡をかける', - value: false, - onChange: (value) => { state.face.eyes.glasses.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, glassesToggle.el, { wide: true })); - - // 眼鏡とサングラスの違いは、おもにレンズの濃さ。切り替えたらその種類のレンズの - // 既定値(色と濃さ)を一緒に持ってくる。フレームや大きさには触らないので、 - // 作りこんだフレームは種類を変えても失われない。 + // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明 + // なので、目を開けたままだと瞳が透けて見えてしまう。種類を変えたらその種類の + // 既定値(レンズの色・濃さ・高さ)を一緒に持ってきて、サングラスのときは両目を + // 閉じる。フレームや大きさには触らないので、作りこんだフレームは種類を変えても + // 失われない。 const GLASSES_KINDS = { - glasses: { lensColor: '#2b2433', lensOpacity: 0.22 }, - sunglasses: { lensColor: '#1a1620', lensOpacity: 0.75 }, + glasses: { lensColor: '#2b2433', lensOpacity: 0.22, offsetY: 0 }, + sunglasses: { lensColor: '#1a1620', lensOpacity: 1, offsetY: -40 }, + }; + const wearSunglasses = () => { + state.face.eyes.glasses.lensOpacity = 1; + state.face.eyes.left.open = 0; + state.face.eyes.right.open = 0; }; const setGlassesKind = (kind) => { Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] }); + // サングラス hides the eye behind an opaque lens, so both eyes shut with it; + // 眼鏡's lens is clear, so switching back opens them again. + const open = kind === 'sunglasses' ? 0 : 1; + state.face.eyes.left.open = open; + state.face.eyes.right.open = open; }; + const glassesToggle = check({ + label: '眼鏡をかける', + value: false, + onChange: (value) => { + state.face.eyes.glasses.enabled = value; + // A loaded state can already be on サングラス, so enabling the shades also + // shuts the eyes rather than showing them through the opaque lens. + if (value && state.face.eyes.glasses.kind === 'sunglasses') wearSunglasses(); + app.actions.refresh('face'); + sync(); + }, + }); + faceSection.add(controlRow(null, glassesToggle.el, { wide: true })); + const glassesKindSegment = segmented({ label: '種類', options: [ @@ -833,34 +864,29 @@ export function buildPanel(app, root) { label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); }, }), - lines: slider({ - label: '線の数', min: 0, max: 7, step: 1, value: 4, - format: (v) => (v < 0.5 ? 'なし' : `${Math.round(v)}本`), - onInput: (v) => { state.face.eyes.cheeks.lines = Math.round(v); app.actions.refresh('face'); }, - }), }; faceSection.add(cheekSliders.size.el); - const cheekBox = details(faceSection.body, 'こまかい設定(位置・線・色)'); + const cheekBox = details(faceSection.body, 'こまかい設定(位置・色)'); cheekBox.add(cheekSliders.offsetY.el); cheekBox.add(cheekSliders.spacing.el); - cheekBox.add(cheekSliders.lines.el); const cheekColor = colorField({ label: 'ほっぺの色', value: '#f6a6b8', swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'], onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); }, }); cheekBox.add(cheekColor.el); - cheekBox.add(hint('「線の数」を 0 にすると、線のないほっぺになります。' - + '「よこの間隔」は左右のほっぺのへだたり、「たかさ」は目からの下がり具合です。')); + cheekBox.add(hint('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、' + + '「たかさ」は目からの下がり具合です。')); const headMarkShape = segmented({ label: '頭の模様', options: [ { value: 'off', label: 'なし' }, - { value: 'hook', label: 'かぎ' }, - { value: 'spiral', label: 'うずまき' }, - { value: 'strokes', label: '三本線' }, - { value: 'dots', label: '点' }, + { value: 'cap', label: 'かぶせ' }, + { value: 'fringe', label: 'まえがみ' }, + { value: 'wave', label: 'なみ' }, + { value: 'side', label: 'わけめ' }, + { value: 'split', label: 'ツートン' }, ], value: 'off', onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); }, @@ -877,27 +903,29 @@ export function buildPanel(app, root) { onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); }, }), offsetY: slider({ - label: 'たかさ', min: -60, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + label: 'たかさ', min: -300, max: 300, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); }, }), - lineWidth: slider({ - label: '太さ', min: 0.4, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.headMark.lineWidth = v; app.actions.refresh('face'); }, - }), }; faceSection.add(headMarkSliders.size.el); - const headMarkBox = details(faceSection.body, 'こまかい設定(位置・太さ・色)'); + const headMarkBox = details(faceSection.body, 'こまかい設定(位置・色)'); headMarkBox.add(headMarkSliders.offsetX.el); headMarkBox.add(headMarkSliders.offsetY.el); - headMarkBox.add(headMarkSliders.lineWidth.el); const headMarkColor = colorField({ label: '模様の色', value: '#8a4fe0', swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'], onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); }, }); + const headMarkColor2 = colorField({ + label: '2色目', value: '#ffffff', + swatches: ['#ffffff', '#f3ead9', '#ffe08a', '#7fd8ff'], + onChange: (v) => { state.face.eyes.headMark.color2 = v; app.actions.refresh('face'); }, + }); headMarkBox.add(headMarkColor.el); - headMarkBox.add(hint('「頭の模様」を選ぶと出ます。決まったキャラクターの絵ではなく、' - + 'よくあるマンガの記号です。色・大きさ・位置は自由に変えられます。')); + headMarkBox.add(headMarkColor2.el); + headMarkBox.add(hint('「頭の模様」は、頭にかぶせる髪のような塗りです。' + + '「ツートン」は頭を左右2色に分けます(2色目を使います)。' + + '決まったキャラクターの髪ではなく、よくあるマンガの記号として用意しました。')); // --- mouth faceSection = section(tabFace, '口'); @@ -987,7 +1015,7 @@ export function buildPanel(app, root) { /* ---------------------------------------------------------------- caption */ - const captionSection = section(tabFace, 'セリフ(吹き出し)'); + const captionSection = section(tabScene, 'セリフ(吹き出し)'); // Two bubbles share this one set of controls: the selector says which bubble // they are editing, and every handler below reads the active one when it runs, @@ -1148,7 +1176,7 @@ export function buildPanel(app, root) { }; for (const widget of Object.values(flapSliders)) flapSection.add(widget.el); flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。' - + '上の「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。')); + + '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。')); /* ------------------------------------------------------ movement (pose tab) */ @@ -1167,6 +1195,7 @@ export function buildPanel(app, root) { { value: 'off', label: 'とまる' }, { value: 'idle', label: 'ゆれる' }, { value: 'walk', label: '歩く' }, + { value: 'wave', label: 'てをふる' }, ], value: 'off', onChange: (value) => { state.anim.mode = value; app.animator.reset(); app.needsRender = true; }, @@ -1364,7 +1393,7 @@ export function buildPanel(app, root) { /* -------------------------------------------------------------- background */ - const backdropSection = section(tabView, '背景'); + const backdropSection = section(tabScene, '背景'); // 素材 is one button for two state values (a photo or an effect line). This // remembers which of the two was picked last, so leaving 素材 and coming back // returns to it instead of always jumping to the photos. @@ -1540,7 +1569,7 @@ export function buildPanel(app, root) { }, }); backdropSection.add(controlRow(null, gyroToggle.el, { wide: true })); - backdropSection.add(hint('カメラを使うには https か localhost で開いてください(カメラの使用の許可も必要です)。')); + backdropSection.add(hint('カメラ(AR)を使うには、https のサイトか localhost で開いてください。http のLANアドレスでは、どのブラウザでもカメラは使えません。使えるときは、URLバーのアイコンを「許可」にし、端末の設定でもアプリにカメラを許可してください。')); backdropSection.add(buttons({ items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }], })); @@ -1549,7 +1578,7 @@ export function buildPanel(app, root) { // 舞台: the things you put the character *among* - the props and the wall - in // one place, since they are set up together. - const stageSection = section(tabView, '舞台'); + const stageSection = section(tabScene, '舞台'); const propSection = details(stageSection.body, '小物'); propSection.add(buttons({ label: '追加', @@ -1601,10 +1630,10 @@ export function buildPanel(app, root) { // The numbers are only a fallback for the drag, so they stay folded away. const numbers = details(block, '数値で調整'); for (const widget of Object.values(sliders)) numbers.add(widget.el); - // 看板 only: the writing surface can be enlarged on its own, so the frame - // and posts stay put. The app rebuilds the props from the state and carries - // only the whole-prop scale, so the props syncer re-applies this to the - // fresh mesh (see `applyPropFaceScale`). + // 看板 only: the writing area (face *and* frame) can be enlarged on its + // own, so the posts and base stay put. The app rebuilds the props from the + // state and carries only the whole-prop scale, so the props syncer + // re-applies this to the fresh mesh (see `applyPropFaceScale`). let faceScale = null; if (item.kind === 'sign') { faceScale = slider({ @@ -1946,7 +1975,7 @@ export function buildPanel(app, root) { // 画面・光: the light rig and the camera, which together decide how the picture // is lit and framed. - const screenLightSection = section(tabView, '画面・光'); + const screenLightSection = section(tabScene, '画面・光'); const lightSection = details(screenLightSection.body, 'ひかり'); const lightPad = xyPad({ value: { x: -0.2, y: 0.45 }, @@ -2246,11 +2275,20 @@ export function buildPanel(app, root) { // A tiny read-out of what the renderer is actually doing: if the picture ever // looks wrong or never appears, this says why (buffer size, quality, fps). - const infoSection = section(tabView, '画面の情報'); + const infoSection = section(tabScene, '画面の情報'); const statsLine = h('p', { class: 'hint', text: '計測中…' }); infoSection.add(statsLine); infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。')); + // シーン: the scene controls are built wherever they belong in the code (the + // speech bubble sits with the face controls), so put them on the tab in the + // order the user meets them: what is behind the character, what is beside it, + // the speech bubble, then the light and the read-out. + tabScene.append( + backdropSection.el, stageSection.el, captionSection.el, + screenLightSection.el, infoSection.el, + ); + boneSyncers.push(() => { const name = rig.selected; for (const [key, button] of boneButtons) button.classList.toggle('active', key === name); @@ -2316,6 +2354,7 @@ export function buildPanel(app, root) { headMarkShape.set(state.face.eyes.headMark.shape ?? 'off'); for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]); headMarkColor.set(state.face.eyes.headMark.color); + headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff'); }); syncers.push(() => { |
