aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/panel.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/panel.js')
-rw-r--r--public/bluebey-studio/src/panel.js141
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(() => {