diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
| commit | bc2821adb11a30244fc4663f4fafb756877d9508 (patch) | |
| tree | fea8d30d2eb1e67f895097dbf7ec4d152361243e /public/bluebey-studio/src/panel.js | |
| parent | 0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff) | |
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'public/bluebey-studio/src/panel.js')
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 3144 |
1 files changed, 0 insertions, 3144 deletions
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js deleted file mode 100644 index 0ee94ac..0000000 --- a/public/bluebey-studio/src/panel.js +++ /dev/null @@ -1,3144 +0,0 @@ -import { - h, section as uiSection, subhead, hint, slider, check, segmented, buttons, - colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, icon, svgIcon, -} from './ui.js'; -import { - FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS, - defaultState, applyPatch, BEARD_KIND_DEFAULTS, HEAD_MARK_KIND_DEFAULTS, -} from './presets.js'; -import { STYLE_DEFS } from './styles.js'; -import { ENVIRONMENTS } from './look.js'; -import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js'; -import { PROP_LIBRARY, applyPropFaceScale, applyPropText } from './props.js'; -import { HAT_LIBRARY } from './hats.js'; -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', - '髪': 'M4 14c0-4.8 3.6-7.6 8-7.6s8 2.8 8 7.6M4 14c2.6-1.4 5.3-1.9 8-1.8M20 14c-2.6-1.4-5.3-1.9-8-1.8', - 'ほっぺ': 'M6.4 14.6a2 1.4 0 1 0 0 .01ZM17.6 14.6a2 1.4 0 1 0 0 .01ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z', - '鼻ちょうちん': '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', - leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0', -}; -const CAPTION_SLIDER_FALLBACKS = { - fontSize: 34, lineHeight: 1.42, padding: 18, radius: 24, borderWidth: 4, -}; -const CAPTION_COLOR_FALLBACKS = { textColor: '#3f2b52', bubbleColor: '#ffffff', borderColor: '#55386e' }; -const MOUTH_FLAP_FALLBACKS = { rate: 1, mouthGain: 1 }; -const LOOK_AT_FALLBACKS = { x: 0, y: 2.6, z: 3, amount: 1 }; - -/** - * The licence reminder the three image-import buttons show before they open the - * file picker. Kept in one place so the wording can change without hunting for - * each button. - */ -const IMAGE_LICENSE_NOTICE = '読み込む画像は、ご自身で権利をお持ちか、利用許諾のあるものに限ります。ライセンスをご確認ください。'; - -/** True when the user confirmed they have the right to use the image. */ -function confirmImageLicense() { - return window.confirm(IMAGE_LICENSE_NOTICE); -} - -/** - * The panel had no multi-line field, and the two features that take free prose - * (the caption and the spoken text) want the same shape, so it is built once - * here rather than growing the widget kit for a single caller. - */ -function textArea({ label, value = '', rows = 3, onChange }) { - const input = h('textarea', { - rows, - style: { - width: '100%', - boxSizing: 'border-box', - resize: 'vertical', - font: 'inherit', - fontFamily: 'ui-monospace, "Hiragino Kaku Gothic ProN", "Noto Sans JP", monospace', - fontSize: '11.5px', - lineHeight: '1.5', - padding: '6px 8px', - border: '1px solid #e4dff0', - borderRadius: '8px', - background: '#fff', - color: '#2b2433', - }, - }); - input.value = value ?? ''; - input.addEventListener('input', () => onChange?.(input.value)); - return { - el: controlRow(label, input, { wide: true }), - // Assigning the same text again would jump the caret to the end, so only - // write when the value actually differs. - set(v) { const next = v ?? ''; if (input.value !== next) input.value = next; }, - get: () => input.value, - }; -} - -/** - * The placed prop groups, in state order. - * - * WHY this climbs the scene: the app owns the prop rebuild (see `applyProps` in - * src/main.js) and hands `props.js` only the whole-prop scale, so a sign's - * per-item `faceScale` has to be re-applied to the freshly built mesh by the one - * place that knows that value - this panel. There is no direct scene reference - * here, so this walks up from the model's container and collects the props: they - * are the only objects tagged `userData.propId`, and the app adds them in order. - */ -function placedPropGroups(app) { - const scene = app.container?.parent; - if (!scene) return []; - const groups = []; - scene.traverse((object) => { - if (object.userData?.propId) groups.push(object); - }); - return groups; -} - -/** - * Builds the whole control panel and wires it to the app state. - * - * Widgets never call the heavy "apply everything" path while they are being - * dragged; they patch one scope and ask the app to refresh just that part. - */ -export function buildPanel(app, root) { - const state = app.state; - const model = app.model; - const rig = app.rig; - - const syncers = []; - const boneSyncers = []; - const sync = () => { for (const fn of syncers) fn(); }; - const syncBones = () => { for (const fn of boneSyncers) fn(); }; - - /** - * Grey out (and disable) a control whose setting has no effect in the current - * mode - e.g. the brow's angle while the brow is a loaded picture. - */ - const setRowEnabled = (widget, on) => { - const row = widget.el.closest?.('.row') ?? widget.el; - const input = row.querySelector?.('input, select, button'); - if (input) input.disabled = !on; - row.classList?.toggle('is-off', !on); - }; - - /** Patch one scope of the state without re-reading the whole UI. */ - /** Replace the state and re-read every widget (used by presets and loads). */ - const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true }); - - 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), - eyeX: Math.round(rnd(-16, 16)), - }); - const randomGlasses = () => { - const kind = pick(['glasses', 'sunglasses']); - const dark = kind === 'sunglasses'; - return { - enabled: true, - kind, - frameColor: pick(['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6']), - lensColor: pick(['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e']), - lensOpacity: dark ? +rnd(0.85, 1).toFixed(2) : +rnd(0, 0.35).toFixed(2), - lensGap: Math.round(rnd(-24, 56)), - frameWidth: +rnd(0.6, 1.8).toFixed(2), - scale: +rnd(0.9, 1.15).toFixed(2), - offsetY: Math.round(rnd(-18, 18)), - tilt: Math.round(rnd(-12, 12)), - }; - }; - const randomBeard = (shape) => { - const base = BEARD_KIND_DEFAULTS[shape] ?? { size: 1, offsetY: 0, spacing: 0 }; - if (shape === 'off') return { shape }; - return { - shape, - ...base, - size: +((base.size ?? 1) * rnd(0.8, 1.25)).toFixed(2), - offsetY: Math.round((base.offsetY ?? 0) + rnd(-16, 16)), - }; - }; - const randomHeadMark = () => { - const shape = pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']); - return { shape, ...(HEAD_MARK_KIND_DEFAULTS[shape] ?? {}) }; - }; - const randomFacePatch = () => { - const beardShape = pick(['off', 'scotch', 'kaiser', 'cat']); - return { - eyes: { - left: randomEye(), - right: randomEye(), - cheeks: { enabled: Math.random() < 0.5 }, - // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。 - glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(), - beard: randomBeard(beardShape), - headMark: randomHeadMark(), - brow: { - enabled: true, - // Random brows are line drawings, never ゴル風's loaded picture. - image: false, - 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), - // ときどき丸く開く口(O)にする。 - round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0, - open: +rnd(0, 0.5).toFixed(2), - width: +rnd(0.7, 1.3).toFixed(2), - thickness: +rnd(0.7, 1.6).toFixed(2), - tilt: Math.round(rnd(-12, 12)), - offsetY: Math.round(rnd(-16, 16)), - }, - }; - }; - const randomFace = () => replace({ face: randomFacePatch() }, 'face'); - const randomAll = () => { - // 後ろを向く(180°)や大きく傾くポーズは避ける。見える顔が変わる範囲だけ。 - const poses = POSE_PRESETS.filter((preset) => { - const m = preset.pose?.bones?.master ?? [0, 0, 0]; - return Math.abs(m[0]) <= 45 && Math.abs(m[1]) <= 40 && Math.abs(m[2]) <= 40; - }); - const pose = pick(poses.length ? poses : POSE_PRESETS); - const theme = pick(THEMES); - app.actions.applyPosePreset?.(pose.id); - app.actions.applyTheme?.(theme.id); - // The viewer style (実写/フラット/線画) and 左右反転 are deliberately left alone. - replace({ face: randomFacePatch() }, 'face'); - 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('すべてをランダムに', 'M7.2 9.4a2.6 2.6 0 1 1 3.9 2.3c-.9.5-1.3 1.2-1.3 2.1v.4M9.2 17.4h.01M16.8 6.8v6.1M16.8 15.6h.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 - // of the content, so the row reads as tabs rather than buttons. - const tabBar = tabs(root, [ - { - id: 'pose', - label: 'ポーズ', - // A standing figure: head, body, arms and legs. - icon: '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', - }, - { - id: 'face', - 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', - }, - { - id: 'view', - label: '見た目', - // A painter's palette: the outline, the thumb hole and four colour dots. - 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. - icon: 'M12 3.8v9.6M8.2 9.8 12 13.6l3.8-3.8M4.5 16.5v1.6a1.8 1.8 0 0 0 1.8 1.8h11.4a1.8 1.8 0 0 0 1.8-1.8v-1.6', - }, - ]); - 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 */ - - // ポーズ例: ready-made poses, because most people start by picking one. - const posePresetSection = section(tabPose, 'ポーズ例'); - posePresetSection.add(buttons({ - items: POSE_PRESETS.map((preset) => ({ - id: preset.id, - label: preset.label, - onClick: () => applyPosePreset(preset), - })), - })); - - // 全身とボーンのスライダー: pick a bone, then turn it on the three axes. - const poseSection = section(tabPose, '全身とボーンのスライダー'); - - const boneList = h('div', { class: 'bone-list' }); - const boneButtons = new Map(); - for (const bone of model.bones) { - const button = h('button', { type: 'button', class: 'bone-item' }, - h('span', { text: bone.label }), - h('small', { text: bone.name })); - button.addEventListener('click', () => rig.select(bone.name)); - boneButtons.set(bone.name, button); - boneList.append(button); - } - poseSection.add(controlRow(null, boneList, { wide: true })); - - const axisSliders = { - x: slider({ label: 'よこ(X)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('x', v) }), - y: slider({ label: 'たて(Y)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('y', v) }), - z: slider({ label: 'ねじり(Z)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => setAxis('z', v) }), - }; - for (const widget of Object.values(axisSliders)) poseSection.add(widget.el); - - function setAxis(axis, value) { - const name = rig.selected; - if (!name) return; - const delta = rig.getDelta(name); - delta[axis] = value; - rig.setDelta(name, delta); - app.actions.capturePose(); - app.needsRender = true; - } - - poseSection.add(hint('ボーンは上の一覧から選びます。スライダーは「休めの姿勢からの差」です。' - + 'ビューポートのぶるべーは、ドラッグで移動、Shift+ドラッグで向きを変えられます。')); - - // こまかい設定: 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 = [ - { label: 'よこ移動', min: -20, max: 20 }, - { label: 'たて移動', min: -10, max: 20 }, - { label: 'おくゆき', min: -20, max: 20 }, - ].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; }, - })); - const poseBox = details(poseSection.body, 'こまかい設定(ぜんたいをずらす・回転ギズモ)'); - for (const widget of rootSliders) poseBox.add(widget.el); - - const gizmoToggle = check({ - label: '回転ギズモを表示', - // Off by default: the rings belong to the model's surface, so having them - // drawn on top of the character is not what you want while composing a - // picture. The 'g' key toggles it too. - value: false, - onChange: (value) => { rig.setGizmoVisible(value); app.needsRender = true; }, - }); - poseBox.add(controlRow(null, gizmoToggle.el, { wide: true })); - - // --- 操作 ----------------------------------------------------------------- - // Deliberately NOT wrapped in a collapsible section: 「ポーズを全部戻す」 and the - // undo/redo pair are used often enough that they should be on screen without - // opening anything first. - tabPose.append(buttons({ - items: [ - { 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() }, - ], - }).el); - tabPose.append(hint('「元に戻す」「やり直す」は、パネル上部のアイコンにあります(どのタブでも使えます)。')); - - /** Accepts a preset object (from the buttons) or a preset id (from tools). */ - function applyPosePreset(presetOrId) { - const preset = typeof presetOrId === 'string' - ? POSE_PRESETS.find((item) => item.id === presetOrId) - : presetOrId; - if (!preset) return; - // Some poses already raise the arms; leaving the てをふる motion running would - // add to the arm the pose just set and fold the flipper over the face, so - // switching to any pose stops that motion. - if (state.anim.mode === 'wave') { - state.anim.mode = 'off'; - app.animator.reset(); - app.needsRender = true; - } - state.pose = { bones: {}, root: [0, 0, 0] }; - applyPatch(state.pose, preset.pose ?? {}); - if (!state.pose.root) state.pose.root = [0, 0, 0]; - replace({}, 'pose'); - sync(); - } - - function randomPose() { - const names = model.bones.map((bone) => bone.name); - for (const name of names) { - if (name === 'master') continue; - const scale = name.startsWith('arm') ? 22 : 12; - rig.setDelta(name, { - x: (Math.random() - 0.5) * scale, - y: (Math.random() - 0.5) * scale, - z: (Math.random() - 0.5) * scale, - }); - } - app.actions.capturePose(); - syncBones(); - app.needsRender = true; - } - - /* ------------------------------------------------------------------ face */ - - let faceSection = section(tabFace, 'プリセット'); - - // The face tab's sections are created here in the order they should read on - // screen (プリセット -> 髪 -> 眉 -> 目 -> 見る先 -> ...), but filled further - // down where the widgets are built: the DOM order is fixed at creation. - const faceTabSections = { - hair: section(tabFace, '髪'), - brow: section(tabFace, '眉'), - eye: section(tabFace, '目'), - look: section(tabFace, '見る先'), - glasses: section(tabFace, '眼鏡・サングラス'), - cheeks: section(tabFace, 'ほっぺ'), - snot: section(tabFace, '鼻ちょうちん'), - beard: section(tabFace, 'ひげ'), - mouth: section(tabFace, '口'), - }; - - faceSection.add(buttons({ - items: FACE_PRESETS.map((preset) => ({ - id: preset.id, - label: preset.label, - onClick: () => applyFacePreset(preset.id), - })), - })); - - function applyFacePreset(id) { - const preset = FACE_PRESETS.find((item) => item.id === id); - if (!preset) return; - state.face = defaultState().face; - applyPatch(state.face, preset.face); - app.actions.refresh('face'); - sync(); - } - - // --- eyes - faceSection = faceTabSections.eye; - - /** Tear sliders, which are added to the こまかい設定 box once it exists. */ - const tearWidgets = []; - // The link switch sits directly above the *left* eye's open slider, because that - // is the slider it drives - it was previously below both eyes, where it looked - // like it belonged to whatever came next. - const linkedToggle = check({ - label: '左右を連動させる', - value: true, - onChange: (value) => { - state.face.eyes.linked = value; - if (value) { - // Catch the two eyes up with each other, so switching the link on does - // something visible even if they had drifted apart. - state.face.eyes.right.open = state.face.eyes.left.open; - state.face.eyes.right.closed = state.face.eyes.left.closed; - state.face.eyes.right.closedLines = state.face.eyes.left.closedLines; - state.face.eyes.right.irisShape = state.face.eyes.left.irisShape; - state.face.eyes.right.shape = state.face.eyes.left.shape; - // The lids are per eye now too, so link them along with the rest. - state.face.eyes.right.lowerLid = state.face.eyes.left.lowerLid; - state.face.eyes.right.lidShape = state.face.eyes.left.lidShape; - state.face.eyes.right.lidWidth = state.face.eyes.left.lidWidth; - state.face.eyes.right.lidTilt = state.face.eyes.left.lidTilt; - state.face.eyes.right.lashes = state.face.eyes.left.lashes; - state.face.eyes.right.lashAngle = state.face.eyes.left.lashAngle; - state.face.eyes.right.lashPos = state.face.eyes.left.lashPos; - state.face.eyes.right.white = state.face.eyes.left.white ?? null; - state.face.eyes.right.highlight = state.face.eyes.left.highlight ?? null; - state.face.eyes.right.lookX = state.face.eyes.left.lookX; - state.face.eyes.right.lookY = state.face.eyes.left.lookY; - // The tears mirror as a pair: the size and the height match, while the - // sideways position and the tilt flip sign. - state.face.eyes.right.tear = state.face.eyes.left.tear ?? 0; - state.face.eyes.right.tearY = state.face.eyes.left.tearY ?? 0; - state.face.eyes.right.tearX = -(state.face.eyes.left.tearX ?? 0); - state.face.eyes.right.tearTilt = -(state.face.eyes.left.tearTilt ?? 0); - } - app.actions.refresh('face'); - sync(); - }, - }); - // The eye "shape" folds the old 閉じ方 (line/3/わらう) and the heart pupil into - // one menu. `open`/`closed`/`irisShape` stay the stored fields; this reads and - // writes them together so the menu always shows the current look. - const eyeShapeOf = (eye) => { - const shape = eye.shape; - const explicitShut = shape === 'three' || shape === 'arch' - || (typeof shape === 'string' && shape.startsWith('line')); - if (explicitShut) return shape; - if ((eye.open ?? 1) <= 0.02) { - if (!shape) { - if (eye.closed === 'three') return 'three'; - if (eye.closed === 'arch') return 'arch'; - return `line${Math.min(3, Math.max(1, Math.round(eye.closedLines ?? 1)))}`; - } - // An open/heart eye whose lid is all the way down is just a blink. - return 'line1'; - } - if (shape) return shape; - return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open'; - }; - const applyEyeShape = (eye, value) => { - eye.shape = value; - eye.irisShape = value === 'heart' ? 'heart' : 'circle'; - if (value === 'open' || value === 'heart') { - eye.open = 1; - // Clear any stale shut fields so a blink (open -> 0) shows a plain line and - // never the 3 or the arch that was picked before. - eye.closed = 'line'; - eye.closedLines = 1; - return; - } - // A shut artwork is drawn as the eye's content with the lids open; the lid - // height is its own slider (上まぶたの高さ), so selecting one opens the lid. - eye.open = 1; - if (value.startsWith('line')) { - eye.closed = 'line'; - eye.closedLines = Number(value.slice(4)) || 1; - return; - } - eye.closed = value; - }; - const eyeWhiteOf = (eye) => { - if (eye.white != null) return eye.white === true; - // The usual: an open eye shows its white; a shut eye or a heart does not. - return eyeShapeOf(eye) === 'open'; - }; - const eyeHighlightOf = (eye) => (eye.highlight != null - ? eye.highlight === true - : state.face.eyes.highlight !== false); - - const EYE_ICONS = { - both: '<circle cx="7" cy="12" r="3.8"/><circle cx="17" cy="12" r="3.8"/><circle cx="7" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="17" cy="12" r="1.3" fill="currentColor" stroke="none"/>', - left: '<path d="M3 12h4"/><path d="M5 9.5l2.5 2.5-2.5 2.5"/><circle cx="15" cy="12" r="3.8"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/>', - right: '<path d="M21 12h-4"/><path d="M19 9.5l-2.5 2.5 2.5 2.5"/><circle cx="9" cy="12" r="3.8"/><circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none"/>', - lid: '<path d="M3 14c2.5-6.5 15.5-6.5 18 0"/><path d="M6 16v3M12 16v3M18 16v3"/>', - }; - // Each eye group carries the same controls. Linked, one group ("両目") drives - // both eyes; unlinked, a group per eye appears and the gaze pad doubles. - const eyeGroup = (title, icon = null) => { - const body = h('div', { class: 'eye-group-body' }); - const head = h('div', { class: 'eye-group-head' }, - icon ? svgIcon(EYE_ICONS[icon] ?? '') : null, - h('span', { text: title })); - const el = h('div', { class: 'eye-group' }, head, body); - return { - el, - add: (child) => (body.append(child?.el ?? child), child), - setVisible: (visible) => { el.style.display = visible ? '' : 'none'; }, - }; - }; - - const makeEyeControls = (mode) => { - const keys = mode === 'both' ? ['left', 'right'] : [mode]; - const primary = keys[0]; - const write = (fn) => { for (const k of keys) fn(state.face.eyes[k]); }; - const touched = () => { app.actions.refresh('face'); sync(); }; - const group = eyeGroup(mode === 'both' ? '両目' : (mode === 'left' ? '左目' : '右目'), mode); - - // --- まぶた (per eye) ------------------------------------------------ - // 上まぶたの高さ and 下まぶたの高さ share one opening, so each is held to the - // other: the lower lid cannot rise past the upper, and the upper cannot drop - // below the lower. The sliders' own limits are kept in step in `syncOne`. - const lidSub = eyeGroup('まぶた', 'lid'); - const open = slider({ - label: '上まぶたの高さ', min: 0, max: 1, step: 0.01, value: 1, - format: (v) => (v < 0.02 ? '閉じ' : v > 0.98 ? '開き' : `${Math.round(v * 100)}%`), - onInput: (v) => { - const floor = state.face.eyes[primary].lowerLid ?? state.face.eyes.lowerLid ?? 0; - const vv = Math.max(v, floor); - write((e) => { e.open = vv; }); - if (vv !== v) open.set(vv); - touched(); - }, - }); - const lowerLid = slider({ - label: '下まぶたの高さ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), - onInput: (v) => { - const ceil = state.face.eyes[primary].open ?? 1; - const vv = Math.min(v, ceil); - write((e) => { e.lowerLid = vv; }); - if (vv !== v) lowerLid.set(vv); - touched(); - }, - }); - const lidShape = segmented({ - label: 'まぶたの形', - options: [ - { value: 'curve', label: '曲線' }, - { value: 'flat', label: '直線(平ら)' }, - ], - value: 'curve', - onChange: (v) => { write((e) => { e.lidShape = v; }); touched(); }, - }); - const lidWidth = slider({ - label: 'まぶたの太さ', min: 0, max: 30, step: 1, value: 14, format: (v) => `${Math.round(v)}`, - onInput: (v) => { write((e) => { e.lidWidth = v; }); touched(); }, - }); - const lidTilt = slider({ - label: 'まぶたの傾き', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { write((e) => { e.lidTilt = v; }); touched(); }, - }); - const lashes = check({ - label: 'まつげをつける', - value: false, - onChange: (v) => { write((e) => { e.lashes = v; }); touched(); }, - }); - const lashAngle = slider({ - label: 'まつげの角度', min: -80, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { write((e) => { e.lashAngle = v; }); touched(); }, - }); - const lashPos = slider({ - label: 'まつげの位置', min: -80, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { write((e) => { e.lashPos = v; }); touched(); }, - }); - lidSub.add(lidShape); - lidSub.add(open); - lidSub.add(lowerLid); - lidSub.add(lidWidth); - lidSub.add(lidTilt); - lidSub.add(controlRow(null, lashes.el, { wide: true })); - lidSub.add(lashAngle); - lidSub.add(lashPos); - group.add(lidSub); - const openInput = open.el.querySelector('input'); - const lowerLidInput = lowerLid.el.querySelector('input'); - - // 目の形: the artwork the eye shows. A shut shape is drawn with the lids open, - // so 上まぶたの高さ can then be lowered onto it. - const shape = segmented({ - label: '目の形', - options: [ - { value: 'open', label: 'ふつう' }, - { value: 'line1', label: '1線' }, - { value: 'line2', label: '2線' }, - { value: 'line3', label: '3線' }, - { value: 'three', label: '3の目' }, - { value: 'arch', label: 'わらう' }, - { value: 'heart', label: 'ハート' }, - ], - value: 'open', - onChange: (v) => { write((e) => applyEyeShape(e, v)); touched(); }, - }); - // The heart colour only matters for the heart, so it sits right under 目の形 - // and shows only then. - const heartColor = colorField({ - label: 'ハートの色', value: '#e0344f', - swatches: ['#e0344f', '#ff5f8f', '#c2185b', '#150e1b'], - onChange: (v) => { state.face.eyes.heartColor = v; touched(); }, - }); - // 大きさ: one knob for the iris, the heart and the shut artwork. - const size = slider({ - label: '大きさ', min: 0.4, max: 1.8, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.irisScale = v; touched(); }, - }); - const threeFlip = segmented({ - label: '3の向き', - options: [ - { value: 'normal', label: '3' }, - { value: 'flip', label: '3(逆)' }, - ], - value: 'normal', - onChange: (v) => { write((e) => { e.threeFlip = v === 'flip'; applyEyeShape(e, 'three'); }); touched(); }, - }); - const white = check({ - label: '白目を出す', - value: true, - onChange: (v) => { write((e) => { e.white = v; }); touched(); }, - }); - const highlight = check({ - label: '光彩(瞳の白い丸)を出す', - value: true, - onChange: (v) => { write((e) => { e.highlight = v; }); touched(); }, - }); - const pad = xyPad({ - value: { x: 0, y: 0 }, - center: 'eye', - onChange: ({ x, y }) => { write((e) => { e.lookX = x; e.lookY = y; }); touched(); }, - }); - // 涙: per eye. Linked, one set drives both - size and height match, while the - // sideways position and the tilt mirror so the pair stays symmetric. - const tearOn = check({ - label: '涙を出す', - value: false, - onChange: (v) => { write((e) => { e.tearOn = v; }); touched(); }, - }); - const writeTear = (patch) => { - Object.assign(state.face.eyes[primary], patch); - if (mode === 'both') { - const mirrored = { ...patch }; - if (patch.tearX != null) mirrored.tearX = -patch.tearX; - if (patch.tearTilt != null) mirrored.tearTilt = -patch.tearTilt; - Object.assign(state.face.eyes[primary === 'left' ? 'right' : 'left'], mirrored); - } - touched(); - }; - const tear = slider({ - label: '涙の大きさ', min: 0.3, max: 1.6, step: 0.01, value: 0.3, format: (v) => v.toFixed(2), - onInput: (v) => writeTear({ tear: v }), - }); - const tearY = slider({ - label: '涙の高さ', min: -80, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => writeTear({ tearY: v }), - }); - const tearX = slider({ - label: '涙のよこ位置', min: -150, max: 150, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => writeTear({ tearX: v }), - }); - const tearTilt = slider({ - label: '涙の傾き', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => writeTear({ tearTilt: v }), - }); - - group.add(shape); - group.add(heartColor); - group.add(size); - group.add(threeFlip); - group.add(controlRow(null, white.el, { wide: true })); - group.add(controlRow(null, highlight.el, { wide: true })); - group.add(controlRow(null, tearOn.el, { wide: true })); - group.add(tear); - group.add(tearY); - group.add(tearX); - group.add(tearTilt); - group.add(controlRow('目線', h('div', { class: 'pad-wrap' }, pad.el, - h('p', { class: 'hint', text: 'ドラッグで目線。ダブルクリックで正面に戻ります。' })))); - - const syncOne = () => { - const e = state.face.eyes[primary]; - const shapeValue = eyeShapeOf(e); - const lower = e.lowerLid ?? state.face.eyes.lowerLid ?? 0; - const upper = e.open ?? 1; - // Keep each lid's slider range from crossing the other before applying the - // values, so the displayed thumb cannot sit past the other lid either. - if (openInput) openInput.min = String(Math.min(lower, upper)); - if (lowerLidInput) lowerLidInput.max = String(Math.max(lower, upper)); - open.set(upper); - lowerLid.set(lower); - lidShape.set(e.lidShape ?? state.face.eyes.lidShape ?? 'curve'); - lidWidth.set(e.lidWidth ?? state.face.eyes.lidWidth ?? 14); - lidTilt.set(e.lidTilt ?? state.face.eyes.lidTilt ?? 0); - lashes.set((e.lashes ?? state.face.eyes.lashes) === true); - const lashesOn = (e.lashes ?? state.face.eyes.lashes) === true; - lashAngle.set(e.lashAngle ?? state.face.eyes.lashAngle ?? 0); - lashPos.set(e.lashPos ?? state.face.eyes.lashPos ?? 0); - lashAngle.el.style.display = lashesOn ? '' : 'none'; - lashPos.el.style.display = lashesOn ? '' : 'none'; - shape.set(shapeValue); - heartColor.set(state.face.eyes.heartColor ?? '#e0344f'); - heartColor.el.style.display = shapeValue === 'heart' ? '' : 'none'; - size.set(state.face.eyes.irisScale ?? 1); - threeFlip.set(e.threeFlip ? 'flip' : 'normal'); - threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none'; - white.set(eyeWhiteOf(e)); - highlight.set(eyeHighlightOf(e)); - const tearShown = e.tearOn === true; - tearOn.set(tearShown); - for (const widget of [tear, tearY, tearX, tearTilt]) { - widget.el.style.display = tearShown ? '' : 'none'; - } - tear.set(e.tear ?? 0.3); - tearY.set(e.tearY ?? 0); - tearX.set(e.tearX ?? 0); - tearTilt.set(e.tearTilt ?? 0); - pad.set({ x: e.lookX ?? 0, y: e.lookY ?? 0 }); - }; - return { group, sync: syncOne }; - }; - - const bothControls = makeEyeControls('both'); - const leftControls = makeEyeControls('left'); - const rightControls = makeEyeControls('right'); - - faceSection.add(controlRow(null, linkedToggle.el, { wide: true })); - faceSection.add(bothControls.group); - faceSection.add(leftControls.group); - faceSection.add(rightControls.group); - faceSection.add(hint('「目の形」の「2線」「3線」は端点がつながった形になります。' - + '閉じ目の形も、上まぶた・下まぶたで上下から隠せます。')); - - // 横位置 (per eye) rarely changes, so it gets its own small box. - const eyeAdvancedWidgets = {}; - for (const key of ['left', 'right']) { - const label = key === 'left' ? '左目' : '右目'; - const eyeX = slider({ - label: `${label}の横位置`, min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (value) => { state.face.eyes[key].eyeX = value; app.actions.refresh('face'); }, - }); - eyeAdvancedWidgets[key] = { eyeX }; - tearWidgets.push(eyeX.el); - } - - // One button to put every eye setting back to the artwork's defaults. The - // artwork *source* is kept: resetting the numbers should not throw away a - // hand-drawn image the user loaded. - faceSection.add(buttons({ - items: [{ - id: 'eyesReset', - label: '目の設定をリセット', - onClick: () => { - const source = state.face.eyes.source; - state.face.eyes = { ...defaultState().face.eyes, source }; - app.actions.refresh('face'); - sync(); - }, - }], - })); - - faceSection.add(hint('「まぶた」は目のグループごとに設定できます。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、' - + 'まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。' - + 'はずすと「左目」「右目」の設定がそれぞれに出て、目線のパッドも2つになります。' - + '「左目」「右目」は、ぶるべー自身から見た左右です(正面から見ると、画面では左右が入れ替わって見えます)。')); - - const eyeColors = { - white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }), - iris: colorField({ label: '瞳', value: '#150e1b', onChange: (v) => { state.face.eyes.iris = v; app.actions.refresh('face'); } }), - line: colorField({ label: 'まぶたの線', value: '#55386e', onChange: (v) => { state.face.eyes.line = v; app.actions.refresh('face'); } }), - }; - const eyeColorBox = details(faceSection.body, '色(白目・瞳・まぶたの線)'); - for (const widget of Object.values(eyeColors)) eyeColorBox.add(widget.el); - - // 横位置 (per eye) is rarely used, so it stays in its own small box. - const tearBox = details(faceSection.body, '横位置(左右べつ)'); - for (const el of tearWidgets) tearBox.add(el); - - // --- eyebrows (the original character has none; this is extra range) - faceSection = faceTabSections.brow; - faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。')); - - const browToggle = check({ - label: '眉を表示', - value: false, - onChange: (value) => { state.face.eyes.brow.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, browToggle.el, { wide: true })); - - const browSliders = { - angle: slider({ - label: '角度', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { state.face.eyes.brow.angle = v; app.actions.refresh('face'); }, - }), - height: slider({ - label: '高さ', min: -40, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.brow.height = v; app.actions.refresh('face'); }, - }), - length: slider({ - label: '長さ', min: 0, max: 4, step: 0.01, value: 1, - format: (v) => (v < 0.02 ? '点' : v.toFixed(2)), - onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); }, - }), - thickness: slider({ - label: '太さ', min: 0.4, max: 3.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.brow.thickness = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - label: '間隔', min: -220, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.brow.spacing = v; app.actions.refresh('face'); }, - }), - curve: slider({ - label: '曲がり', min: 0, max: 0.4, step: 0.01, value: 0.14, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.brow.curve = v; app.actions.refresh('face'); }, - }), - taper: slider({ - label: '鼻側の細さ', min: 0, max: 1, step: 0.01, value: 1, - format: (v) => (v >= 0.995 ? 'そのまま' : (v <= 0.005 ? 'とがる' : v.toFixed(2))), - onInput: (v) => { state.face.eyes.brow.taper = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(hint('+の角度で内側が下がり、怒った感じになります。-で困り顔。' - + '高さは + で上がり、- にすると目にかくれて見えなくなります。')); - faceSection.add(browSliders.angle.el); - const browBox = details(faceSection.body, 'こまかい設定(高さ・長さ・太さ・色)'); - browBox.add(browSliders.height.el); - browBox.add(browSliders.length.el); - browBox.add(browSliders.thickness.el); - browBox.add(browSliders.spacing.el); - browBox.add(browSliders.curve.el); - browBox.add(browSliders.taper.el); - const browColor = colorField({ - label: '眉の色', value: '#55386e', - swatches: ['#55386e', '#2a1e33', '#7a5c2e', '#8a4a5e'], - onChange: (v) => { state.face.eyes.brow.color = v; app.actions.refresh('face'); }, - }); - browBox.add(browColor.el); - browBox.add(hint('「間隔」は左右の眉のへだたりです(+で広がり、-で内側に寄って、' - + '大きく-にすると左右がくっつきます)。「長さ」を 0 にすると点になります。' - + '「鼻側の細さ」を 0 にすると鼻側がとがります。')); - browBox.add(buttons({ - items: [ - { - id: 'golgo', - label: 'ゴル風(画像)', - onClick: () => { - // ゴルゴ13: a very thick, almost straight black wedge, wide at the outer - // end and tapering to a point at the nose, angled steeply down towards - // it, with the two brows sitting close together. - Object.assign(state.face.eyes.brow, { - enabled: true, - image: true, - angle: 34, - height: 33, - length: 2.82, - thickness: 3.6, - curve: 0, - taper: 0, - spacing: -59, - color: '#2a1e33', - }); - app.actions.refresh('face'); - sync(); - }, - }, - { - id: 'ryotsu', - label: '両風(線の眉)', - onClick: () => { - // 両津勘吉: thick げじげじ brows that are joined in the middle, sitting - // low over the eyes. `spacing` is what closes the gap - at this length - // the two inner ends meet over the nose (see the panel's note). Turning - // `image` off is what makes the line drawing take over from ゴル風's - // loaded picture. - Object.assign(state.face.eyes.brow, { - enabled: true, - image: false, - angle: 10, - height: 4, - length: 2.86, - thickness: 3.29, - curve: 0.4, - taper: 1, - spacing: -50, - color: '#181220', - }); - app.actions.refresh('face'); - sync(); - }, - }, - ], - })); - - faceSection.add(hint('「ゴル風(画像)」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。' - + '画像のときは角度・太さ・曲がり・鼻側の細さが効かないので、灰色になります。' - + '「両風(線の眉)」など線で描く眉に切り替えると、この画像は使われません。')); - - // --- glasses / sunglasses (drawn into the same texture as the eyes and brows) - faceSection = faceTabSections.glasses; - faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。' - + '線画のときはレンズを塗らず、フレームの線だけになります。')); - - // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明 - // なので、目を開けたままだと瞳が透けて見えてしまう。種類を変えたらその種類の - // 既定値(レンズの色・濃さ・高さ)を一緒に持ってきて、サングラスのときは両目を - // 閉じる。フレームや大きさには触らないので、作りこんだフレームは種類を変えても - // 失われない。 - const GLASSES_KINDS = { - 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: [ - { value: 'glasses', label: '眼鏡' }, - { value: 'sunglasses', label: 'サングラス' }, - ], - value: 'glasses', - onChange: (value) => { - setGlassesKind(value); - // Picking a kind means you mean to wear it, so put the glasses on rather - // than leaving the checkbox off and nothing showing on the face. - state.face.eyes.glasses.enabled = true; - glassesToggle.set(true); - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(glassesKindSegment.el); - - const glassesSliders = { - scale: slider({ - label: '大きさ', min: 0.7, max: 1.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.glasses.scale = v; app.actions.refresh('face'); }, - }), - lensOpacity: slider({ - label: 'レンズの濃さ', min: 0, max: 1, step: 0.01, value: 0.22, - format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)), - onInput: (v) => { state.face.eyes.glasses.lensOpacity = v; app.actions.refresh('face'); }, - }), - lensGap: slider({ - label: '左右レンズの間隔', min: -40, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.glasses.lensGap = v; app.actions.refresh('face'); }, - }), - frameWidth: slider({ - label: 'フレームの太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.glasses.frameWidth = v; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: '高さ', min: -60, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.glasses.offsetY = v; app.actions.refresh('face'); }, - }), - tilt: slider({ - label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { state.face.eyes.glasses.tilt = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(glassesSliders.scale.el); - faceSection.add(glassesSliders.lensOpacity.el); - const glassesBox = details(faceSection.body, 'こまかい設定(フレーム・位置・色)'); - glassesBox.add(glassesSliders.frameWidth.el); - glassesBox.add(glassesSliders.offsetY.el); - glassesBox.add(glassesSliders.tilt.el); - glassesBox.add(glassesSliders.lensGap.el); - const glassesFrameColor = colorField({ - label: 'フレームの色', value: '#2a1e33', - swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'], - onChange: (v) => { state.face.eyes.glasses.frameColor = v; app.actions.refresh('face'); }, - }); - const glassesLensColor = colorField({ - label: 'レンズの色', value: '#2b2433', - swatches: ['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e'], - onChange: (v) => { state.face.eyes.glasses.lensColor = v; app.actions.refresh('face'); }, - }); - glassesBox.add(glassesFrameColor.el); - glassesBox.add(glassesLensColor.el); - glassesBox.add(hint('「レンズの濃さ」を 0 にするとレンズは透明になり、フレームだけの眼鏡になります。' - + '「高さ」は眼鏡全体を上下に動かします(+で下がります)。' - + '「傾き」は左右のレンズが一緒に傾きます。')); - glassesBox.add(buttons({ - items: [ - { - id: 'glasses', - label: '眼鏡', - onClick: () => { - setGlassesKind('glasses'); - state.face.eyes.glasses.enabled = true; - app.actions.refresh('face'); - sync(); - }, - }, - { - id: 'sunglasses', - label: 'サングラス', - onClick: () => { - setGlassesKind('sunglasses'); - state.face.eyes.glasses.enabled = true; - app.actions.refresh('face'); - sync(); - }, - }, - ], - })); - - // --- ほっぺ (a manga blush) - faceSection = faceTabSections.cheeks; - faceSection.add(hint('ほっぺ(マンガの赤らみ)を、目や眉と同じ絵にかけます。' - + '決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。' - + 'はじめは出ていません。')); - - const cheeksToggle = check({ - label: 'ほっぺを出す', - value: false, - onChange: (value) => { state.face.eyes.cheeks.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, cheeksToggle.el, { wide: true })); - - const cheekSliders = { - size: slider({ - label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.cheeks.size = v; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: 'たかさ', min: -80, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.cheeks.offsetY = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - 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'); }, - }), - }; - faceSection.add(cheekSliders.size.el); - const cheekBox = details(faceSection.body, 'こまかい設定(位置・色)'); - cheekBox.add(cheekSliders.offsetY.el); - cheekBox.add(cheekSliders.spacing.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('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、' - + '「たかさ」は目からの下がり具合です。')); - - // --- 髪 (a hair-like covering over the whole head; its own section, above 眉) - faceSection = faceTabSections.hair; - faceSection.add(hint('髪を頭にかぶせます。決まったキャラクターの髪ではなく、' - + 'よくあるマンガの形として用意しました。はじめは出ていません。')); - - const headMarkShape = segmented({ - label: '髪', - options: [ - { value: 'off', label: 'なし' }, - { value: 'cap', label: 'かぶせ' }, - { value: 'fringe', label: 'ぎざぎざ' }, - { value: 'fringe-up', label: 'はちわれ' }, - { value: 'curve', label: 'カーブ' }, - ], - value: 'off', - onChange: (value) => { - const mark = state.face.eyes.headMark; - mark.shape = value; - // Entering a shape applies its own starting size (ぎざぎざ is smaller and - // shallower than the shared defaults). Other shapes just keep the values. - Object.assign(mark, HEAD_MARK_KIND_DEFAULTS[value] ?? {}); - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(headMarkShape.el); - - const headMarkOverEyes = check({ - label: '髪を目の前に出す', - value: true, - onChange: (value) => { state.face.eyes.headMark.overEyes = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, headMarkOverEyes.el, { wide: true })); - - const headMarkSliders = { - size: slider({ - label: '大きさ', min: 0.05, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.headMark.size = v; app.actions.refresh('face'); }, - }), - teeth: slider({ - label: '歯の深さ', min: 0.02, max: 0.4, step: 0.01, value: 0.12, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.headMark.teeth = v; app.actions.refresh('face'); }, - }), - offsetX: slider({ - label: 'よこの位置', min: -260, max: 260, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); }, - }), - offsetY: slider({ - 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'); }, - }), - }; - faceSection.add(headMarkSliders.size.el); - faceSection.add(headMarkSliders.teeth.el); - //「歯の深さ」はぎざぎざのときだけ意味があるので、それ以外では隠します。 - const headMarkBox = details(faceSection.body, 'こまかい設定(位置・色)'); - headMarkBox.add(headMarkSliders.offsetX.el); - headMarkBox.add(headMarkSliders.offsetY.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(headMarkColor2.el); - headMarkBox.add(hint('「かぶせ」は頭にかぶせる丸い髪、「ぎざぎざ」は生え際がギザギザにとがった髪(「歯の深さ」でギザギザの深さを変えられます)、' - + '「はちわれ」は中央が鋭く持ち上がった生え際、「カーブ」はドラえもんのように単純にカーブした髪です。' - + '「たかさ」で生え際を目の中心あたりまで下げられます。' - + '「髪を目の前に出す」を切ると、髪が目の後ろに回ります。')); - - // --- 鼻ちょうちん: the snot bubble of a sleeping face. - faceSection = faceTabSections.snot; - const snotToggle = check({ - label: '鼻ちょうちんを出す', - value: false, - onChange: (value) => { state.face.eyes.snot.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, snotToggle.el, { wide: true })); - const snotSize = slider({ - label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.snot.size = v; app.actions.refresh('face'); }, - }); - faceSection.add(snotSize.el); - const snotBox = details(faceSection.body, 'こまかい設定(位置・色)'); - const snotSliders = { - offsetX: slider({ - label: 'よこの位置', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.snot.offsetX = v; app.actions.refresh('face'); }, - }), - offsetY: slider({ - 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: -10, 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({ - label: '泡の色', value: '#e3ecff', - swatches: ['#e3ecff', '#ffffff', '#cfe0ff', '#bfe8d0'], - onChange: (v) => { state.face.eyes.snot.color = v; app.actions.refresh('face'); }, - }); - snotBox.add(snotColor.el); - snotBox.add(hint('寝ている顔の横に、鼻から出る泡を描きます。' - + '「ねている」ポーズと組み合わせると、いねむりしているように見えます。' - + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。')); - - // --- ひげ: mustaches and beards, hung under the nose. - faceSection = faceTabSections.beard; - const beardShape = segmented({ - label: 'ひげ', - options: [ - { value: 'off', label: 'なし' }, - { value: 'scotch', label: 'ちょびひげ' }, - { value: 'kaiser', label: 'ダリ' }, - { value: 'cat', label: 'ねこ' }, - ], - value: 'off', - onChange: (value) => { - const beard = state.face.eyes.beard; - beard.shape = value; - // Each kind starts at its own size and height (a textured kind is drawn at a - // different scale from the built-in strokes). A kind with no entry resets to - // the neutral values, so switching kinds cannot leave a stray size behind. - Object.assign(beard, BEARD_KIND_DEFAULTS[value] ?? { size: 1, offsetY: 0, spacing: 0 }); - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(beardShape.el); - const beardSliders = { - size: slider({ - label: '大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.size = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - label: '左右の間隔', min: 0, max: 250, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - // Shown 10 higher than the value stored, so the default (stored -10) reads 0. - onInput: (v) => { state.face.eyes.beard.spacing = v - 10; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); }, - }), - length: slider({ - label: 'ひげの長さ(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.length = v; app.actions.refresh('face'); }, - }), - lineGap: slider({ - label: '線の間隔(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.lineGap = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(beardSliders.size.el); - const beardBox = details(faceSection.body, 'こまかい設定(位置・色)'); - beardBox.add(beardSliders.spacing.el); - beardBox.add(beardSliders.offsetY.el); - beardBox.add(beardSliders.length.el); - beardBox.add(beardSliders.lineGap.el); - const beardColor = colorField({ - label: 'ひげの色', value: '#1c1624', - swatches: ['#1c1624', '#000000', '#3a2a4a', '#6b4a2a', '#8a8a94'], - onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); }, - }); - beardBox.add(beardColor.el); - beardBox.add(hint('鼻の下に、ちょびひげ・ダリ・ねこ、の瓢を描きます。' - + '「左右の間隔」「瓢の長さ」「線の間隔」は「ねこ」で使います。')); - - // --- mouth - faceSection = faceTabSections.mouth; - - const mouthToggle = check({ - label: '口を表示', - value: true, - onChange: (value) => { state.face.mouth.visible = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, mouthToggle.el, { wide: true })); - - // 口角の角度は「笑いの深さ」に比例して動かす(笑いの深さ 1 で -8°、-1 で 70°、 - // その間は直線)。笑いの深さを動かすと追従し、手で微調整したいときのために - // スライダー自体は残してある。 - const cornerAngleFor = (smile) => Math.round(31 - 39 * Math.max(-1, Math.min(1.4, smile))); - - const mouthSliders = { - smile: slider({ label: '曲げ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }), - open: slider({ label: '開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), - round: slider({ label: 'Oの大きさ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }), - width: slider({ label: '幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), - thickness: slider({ label: '太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), - tilt: slider({ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }), - offsetY: slider({ label: '高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }), - tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }), - tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }), - corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }), - cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }), - }; - // 口には「ふつうの口」と「丸く開く(O)」の2つの形があり、使うスライダーが - // まったく違う。形を切り替えると、その形で使うものだけを出す。 - const mouthModeOf = (mouth) => (mouth.round > 0.004 ? 'round' : 'curve'); - const mouthRowKeys = { - curve: ['smile', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY'], - round: ['round', 'width', 'thickness', 'tongue', 'tilt', 'offsetY'], - }; - // DOM order is fixed here; the mode only shows or hides rows. - const mouthRowOrder = ['round', 'smile', 'width', 'thickness', 'open', 'tilt', 'offsetY', 'tongue', 'tonguePos', 'corners', 'cornerAngle']; - const mouthMode = segmented({ - label: '口の形', - options: [ - { value: 'curve', label: 'ふつうの口' }, - { value: 'round', label: '丸く開く(O)' }, - ], - value: 'curve', - onChange: (shape) => { - if (shape === 'round') { - if (!(state.face.mouth.round > 0.004)) state.face.mouth.round = 0.6; - } else { - state.face.mouth.round = 0; - } - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(mouthMode); - for (const key of mouthRowOrder) faceSection.add(mouthSliders[key].el); - faceSection.add(hint('「口の形」を切り替えると、その形で使うスライダーだけが出ます。' - + 'ふつうの口は「曲げ」と「開き」、丸く開く口(O)は「Oの大きさ」で形が決まります。')); - faceSection.add(buttons({ - items: [{ - id: 'mouthreset', - label: '口を初期値に戻す', - onClick: () => { - state.face.mouth = defaultState().face.mouth; - app.actions.refresh('face'); - sync(); - }, - }], - })); - - const mouthColors = { - color: colorField({ label: '口の線', value: '#ff1a44', onChange: (v) => { state.face.mouth.color = v; app.actions.refresh('face'); } }), - innerColor: colorField({ label: '口の中', value: '#4a0f1e', onChange: (v) => { state.face.mouth.innerColor = v; app.actions.refresh('face'); } }), - tongueColor: colorField({ label: '舌', value: '#ff2d2d', onChange: (v) => { state.face.mouth.tongueColor = v; app.actions.refresh('face'); } }), - cornerColor: colorField({ label: '口角', value: '#725497', onChange: (v) => { state.face.mouth.cornerColor = v; app.actions.refresh('face'); } }), - }; - const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)'); - for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el); - - /* ---------------------------------------------------------------- caption */ - - 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, - // so switching does not have to rewire anything. - let activeCaption = 'caption'; - const cap = () => state[activeCaption]; - const captionBubbleSegment = segmented({ - label: 'どの吹き出し', - options: [ - { value: 'caption', label: '①' }, - { value: 'caption2', label: '②' }, - { value: 'narration', label: 'ナレーション' }, - ], - value: 'caption', - onChange: (value) => { activeCaption = value; sync(); }, - }); - captionSection.add(captionBubbleSegment.el); - captionSection.add(hint('吹き出しは①と②の2つ、ナレーション枠が1つ出せます。切り替えて、それぞれ別の位置・形・色・縦書きにできます。')); - - const captionToggle = check({ - label: 'この吹き出しを出す', - value: false, - onChange: (value) => { cap().enabled = value; app.actions.refresh('caption'); sync(); }, - }); - captionSection.add(controlRow(null, captionToggle.el, { wide: true })); - - const captionText = textArea({ - label: 'セリフ', - value: '', - rows: 3, - onChange: (value) => { cap().text = value; app.actions.refresh('caption'); }, - }); - captionSection.add(captionText.el); - - const verticalToggle = check({ - label: '縦書きにする', - value: false, - onChange: (v) => { cap().vertical = v; app.actions.refresh('caption'); sync(); }, - }); - captionSection.add(controlRow(null, verticalToggle.el, { wide: true })); - - captionSection.add(buttons({ - label: '例', - items: CAPTION_PRESETS.map((preset) => ({ - id: preset.id, - label: preset.label, - onClick: () => { Object.assign(cap(), preset.caption); app.actions.refresh('caption'); sync(); }, - })), - })); - - const bubbleSegment = segmented({ - label: '形', - options: BUBBLE_STYLES, - value: 'round', - onChange: (value) => { cap().bubble = value; app.actions.refresh('caption'); }, - }); - const tailSegment = tailPad({ - label: 'しっぽ', - value: 'left', - onChange: (value) => { cap().tail = value; app.actions.refresh('caption'); }, - }); - captionSection.add(bubbleSegment.el); - captionSection.add(tailSegment.el); - - const captionSliders = { - fontSize: slider({ - label: '文字の大きさ', min: 16, max: 72, step: 1, value: 34, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().fontSize = v; app.actions.refresh('caption'); }, - }), - lineHeight: slider({ - label: '行の高さ', min: 1, max: 2, step: 0.01, value: 1.42, format: (v) => v.toFixed(2), - onInput: (v) => { cap().lineHeight = v; app.actions.refresh('caption'); }, - }), - padding: slider({ - label: '内側の余白', min: 4, max: 40, step: 1, value: 18, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().padding = v; app.actions.refresh('caption'); }, - }), - radius: slider({ - label: '角の丸み', min: 0, max: 60, step: 1, value: 24, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().radius = v; app.actions.refresh('caption'); }, - }), - borderWidth: slider({ - label: '線の太さ', min: 0, max: 12, step: 0.5, value: 4, format: (v) => `${v.toFixed(1)}px`, - onInput: (v) => { cap().borderWidth = v; app.actions.refresh('caption'); }, - }), - }; - for (const widget of Object.values(captionSliders)) captionSection.add(widget.el); - - const captionPosition = { - x: slider({ - label: '横位置', min: 0, max: 1, step: 0.01, value: 0.58, format: (v) => v.toFixed(2), - onInput: (v) => { cap().x = v; app.actions.refresh('caption'); }, - }), - y: slider({ - label: '縦位置', min: 0, max: 1, step: 0.01, value: 0.16, format: (v) => v.toFixed(2), - onInput: (v) => { cap().y = v; app.actions.refresh('caption'); }, - }), - }; - captionSection.add(captionPosition.x.el); - captionSection.add(captionPosition.y.el); - - const captionMaxWidthSlider = slider({ - label: 'はば', min: 0.15, max: 0.8, step: 0.01, value: 0.36, format: (v) => v.toFixed(2), - onInput: (v) => { cap().maxWidth = v; app.actions.refresh('caption'); }, - }); - captionSection.add(captionMaxWidthSlider.el); - - const alignSegment = segmented({ - label: 'そろえ', - options: [ - { value: 'left', label: '左' }, - { value: 'center', label: '中' }, - { value: 'right', label: '右' }, - ], - value: 'left', - onChange: (value) => { cap().align = value; app.actions.refresh('caption'); }, - }); - captionSection.add(alignSegment.el); - - const boldToggle = check({ - label: '太字', value: false, - onChange: (value) => { cap().bold = value; app.actions.refresh('caption'); }, - }); - const systemFontToggle = check({ - label: '標準フォントを使う', value: false, - onChange: (value) => { cap().font = value ? 'system' : 'rounded'; app.actions.refresh('caption'); }, - }); - captionSection.add(controlRow(null, boldToggle.el, { wide: true })); - captionSection.add(controlRow(null, systemFontToggle.el, { wide: true })); - - const captionColors = { - textColor: colorField({ label: '文字の色', value: '#3f2b52', onChange: (v) => { cap().textColor = v; app.actions.refresh('caption'); } }), - bubbleColor: colorField({ label: '吹き出しの色', value: '#ffffff', onChange: (v) => { cap().bubbleColor = v; app.actions.refresh('caption'); } }), - borderColor: colorField({ label: '線の色', value: '#55386e', onChange: (v) => { cap().borderColor = v; app.actions.refresh('caption'); } }), - }; - for (const widget of Object.values(captionColors)) captionSection.add(widget.el); - captionSection.add(hint('セリフはプレビューと書き出し画像の両方に描きこまれます。位置は画像全体を0〜1とした割合です。' - + 'ビューポートで吹き出しをドラッグしても動かせます。')); - - /* -------------------------------------------------------------- 口パク */ - - // 口パク 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) */ - - // This is the pose tab's うごき section; the widgets sit here because they are - // driven by the animator alongside the pose state. - const moveSection = section(tabPose, 'うごき'); - - const animWidgets = { - blink: check({ label: 'まばたきする', value: true, onChange: (v) => { state.anim.blink = v; app.animator.reset(); } }), - lookAround: check({ label: '目線がうごく', value: false, onChange: (v) => { state.anim.lookAround = v; app.animator.reset(); } }), - }; - - const motionSegment = segmented({ - label: 'からだのうごき', - options: [ - { value: 'off', label: 'とまる' }, - { value: 'idle', label: 'ゆれる' }, - { value: 'walk', label: '歩く' }, - { value: 'wave', label: 'てをふる' }, - { value: 'sleep', label: 'ねている' }, - ], - value: 'off', - onChange: (value) => { - state.anim.mode = value; - // Sleeping always shows the snot bubble, so the motion reads without having - // to switch the bubble on as well. - if (value === 'sleep') { - state.face.eyes.snot.enabled = true; - snotToggle.set(true); - app.actions.refresh('face'); - } - app.animator.reset(); - app.needsRender = true; - }, - }); - moveSection.add(motionSegment.el); - for (const widget of Object.values(animWidgets)) moveSection.add(controlRow(null, widget.el, { wide: true })); - - moveSection.add(slider({ - label: 'まばたきの間隔', min: 1, max: 8, step: 0.1, value: 3.4, format: (v) => `${v.toFixed(1)}秒`, - onInput: (v) => { state.anim.blinkInterval = v; }, - })); - moveSection.add(slider({ - label: '動きの速さ', min: 0.2, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`, - onInput: (v) => { state.anim.speed = v; }, - })); - - const recordButton = buttons({ - items: [{ id: 'record', label: '録画を開始', primary: true, onClick: () => app.actions.toggleRecording() }], - }); - 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 = faceTabSections.look; - // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too. - const refreshLookAt = () => { - app.actions.refresh('lookAt'); - app.actions.refresh('face'); - sync(); - }; - const lookAtToggle = check({ - label: '指定した場所を見る', - value: false, - onChange: (value) => { state.lookAt.enabled = value; refreshLookAt(); }, - }); - lookAtSection.add(controlRow(null, lookAtToggle.el, { wide: true })); - const lookAtSliders = { - x: slider({ - label: 'よこ(X)', min: -6, max: 6, step: 0.05, value: 0, format: (v) => v.toFixed(2), - onInput: (v) => { state.lookAt.x = v; refreshLookAt(); }, - }), - y: slider({ - label: 'たかさ(Y)', min: 0, max: 5, step: 0.05, value: 2.6, format: (v) => v.toFixed(2), - onInput: (v) => { state.lookAt.y = v; refreshLookAt(); }, - }), - z: slider({ - label: 'おくゆき(Z)', min: -6, max: 6, step: 0.05, value: 3, format: (v) => v.toFixed(2), - onInput: (v) => { state.lookAt.z = v; refreshLookAt(); }, - }), - amount: slider({ - label: '強さ', min: 0, max: 1, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.lookAt.amount = v; refreshLookAt(); }, - }), - }; - for (const widget of Object.values(lookAtSliders)) lookAtSection.add(widget.el); - const turnBodyToggle = check({ - label: '体も向ける', - value: false, - onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); }, - }); - lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true })); - lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目と髪が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。')); - - /* ---------------------------------------------------------------- display */ - - // 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') }, - { id: 'faceRandom', label: '顔をランダムに', onClick: () => randomFace() }, - ], - }).el); - - const displaySection = section(tabView, 'スタイル'); - - const styleSegment = segmented({ - label: 'スタイル', - options: STYLE_DEFS.map((style) => ({ value: style.value, label: style.label })), - value: 'real', - onChange: (value) => replace({ render: { style: value } }, 'render'), - }); - displaySection.add(styleSegment.el); - displaySection.add(hint('線画は輪郭線+白ぬりです(体に色がつきません)。線だけを他の絵に重ねたいときは、「書き出し」タブで「PNGの背景を透明にする」を入れてください。')); - - const outlineToggle = check({ - label: '輪郭線を出す', value: true, - onChange: (value) => { state.render.outline = value; app.actions.refresh('render'); }, - }); - displaySection.add(controlRow(null, outlineToggle.el, { wide: true })); - const outlineWidthSlider = slider({ - label: '線の太さ(体・手足)', min: 0, max: 0.08, step: 0.001, value: 0.022, format: (v) => v.toFixed(3), - onInput: (v) => { state.render.outlineWidth = v; app.actions.refresh('render'); }, - }); - displaySection.add(outlineWidthSlider.el); - const outlinePixelsSlider = slider({ - label: '線の太さ(葉っぱ・鼻)', min: 1, max: 5, step: 0.1, value: 2, - format: (v) => `${v.toFixed(1)} px`, - onInput: (v) => { state.render.outlinePixels = v; app.actions.refresh('render'); }, - }); - displaySection.add(outlinePixelsSlider.el); - displaySection.add(colorField({ - label: '線の色', value: '#2a1e33', - swatches: ['#2a1e33', '#111111', '#55386e', '#0f5c8c', '#8c4a0f'], - onChange: (v) => { state.render.outlineColor = v; app.actions.refresh('render'); }, - })); - displaySection.add(colorField({ - label: '紙の色', value: '#ffffff', - swatches: ['#ffffff', '#fdf8ee', '#f4f0fb'], - onChange: (v) => { state.render.paper = v; app.actions.refresh('render'); }, - })); - const leafBodyLineCheck = check({ - label: '葉っぱと体の境目に線を出す', value: true, - onChange: (v) => { state.render.leafBodyLine = v; app.actions.refresh('render'); }, - title: '線画では体も葉っぱも紙なので、切ると葉っぱと体の区別がつかなくなります。' - + '「葉が体にめり込んで見える」のが気になるときに切ってください', - }); - displaySection.add(controlRow(null, leafBodyLineCheck.el, { wide: true })); - displaySection.add(hint('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。')); - displaySection.add(buttons({ - items: [{ - id: 'resetStyle', - label: 'プリセットのスタイルに戻す', - onClick: () => replace({ - render: { - style: 'real', outline: true, outlineWidth: 0.022, outlinePixels: 2, - outlineColor: '#2a1e33', paper: '#ffffff', leafBodyLine: true, - }, - }, 'render'), - }], - })); - - /* ------------------------------------------------------------- 帽子 */ - - const hatSection = section(tabView, '帽子'); - hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。「なし」を選ぶと外れます。')); - const hatSegment = segmented({ - label: '帽子', - options: HAT_LIBRARY.map((hat) => ({ value: hat.id, label: hat.label })), - value: 'none', - onChange: (value) => { - state.face.hat.kind = value; - // Picking one of the built-in hats (including なし) also clears any GLB hat - // the user imported, so なし really removes the hat. - state.face.hat.custom = false; - app.actions.refresh('all'); - }, - }); - hatSection.add(hatSegment.el); - syncers.push(() => hatSegment.set(state.face.hat?.custom === true ? 'none' : (state.face.hat?.kind ?? 'none'))); - - // 帽子モデル(GLB)の取り込み。頭に乗るよう自動で位置と大きさを合わせる。 - const hatFilePicker = h('input', { type: 'file', accept: '.glb,.gltf,model/gltf-binary,model/gltf+json', style: { display: 'none' } }); - hatFilePicker.addEventListener('change', async () => { - const file = hatFilePicker.files?.[0]; - hatFilePicker.value = ''; - if (!file) return; - try { - await app.actions.loadHatModel(file); - toast(`帽子「${file.name}」を読み込みました`); - } catch (error) { - console.error(error); - toast('帽子モデルを読み込めませんでした(GLBをお使いください)'); - } - }); - hatSection.add(hatFilePicker); - hatSection.add(buttons({ - items: [{ id: 'hatfile', label: '帽子モデルを読み込む(GLB)', onClick: () => hatFilePicker.click() }], - })); - hatSection.add(hint('GLBの帽子を頭に乗せます。大きさと位置は自動で合わせます。')); - - const hatBox = details(hatSection.body, '位置と傾き'); - const hatSliders = { - height: slider({ - label: '高さ', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.hat.height = v; app.actions.refresh('all'); }, - }), - x: slider({ - label: 'よこ位置', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.hat.x = v; app.actions.refresh('all'); }, - }), - z: slider({ - label: 'おくゆき', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.hat.z = v; app.actions.refresh('all'); }, - }), - tiltX: slider({ - label: '前後の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { state.face.hat.tiltX = v; app.actions.refresh('all'); }, - }), - tiltZ: slider({ - label: '左右の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { state.face.hat.tiltZ = v; app.actions.refresh('all'); }, - }), - }; - for (const widget of Object.values(hatSliders)) hatBox.add(widget.el); - hatBox.add(hint('「高さ」「よこ位置」「おくゆき」で帽子を動かし、「前後の傾き」「左右の傾き」で斜めにかぶせられます。')); - syncers.push(() => { - hatSliders.height.set(state.face.hat?.height ?? 0); - hatSliders.x.set(state.face.hat?.x ?? 0); - hatSliders.z.set(state.face.hat?.z ?? 0); - hatSliders.tiltX.set(state.face.hat?.tiltX ?? 0); - hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0); - }); - - /* ------------------------------------------------------------- body colour */ - - const colourSection = section(tabView, '体の色'); - const themeSegment = segmented({ - label: 'テーマ', - options: THEMES.map((theme) => ({ value: theme.id, label: theme.label })), - value: 'original', - onChange: (id) => { app.actions.applyTheme(id); sync(); }, - }); - colourSection.add(themeSegment.el); - colourSection.add(hint('テーマは出発点です。選んだあとでパーツごとに色を変えられます。')); - - /** Write one part colour, creating `colors` if an old settings file lacked it. */ - const setBodyColor = (part, value) => { - state.render.colors = state.render.colors ?? {}; - state.render.colors[part] = value; - app.actions.refresh('render'); - }; - const bodyColors = { - body: colorField({ label: '体', value: '#c8b0f0', onChange: (v) => setBodyColor('body', v) }), - accent: colorField({ label: '手', value: '#8a4fe0', onChange: (v) => setBodyColor('accent', v) }), - nose: colorField({ label: '鼻', value: '#7a4fb0', onChange: (v) => setBodyColor('nose', v) }), - leaf: colorField({ label: '葉', value: '#a6dd6a', onChange: (v) => setBodyColor('leaf', v) }), - vein: colorField({ label: '葉脈', value: '#7fbf3f', onChange: (v) => setBodyColor('vein', v) }), - feet: colorField({ label: '足', value: '#7a4fb0', onChange: (v) => setBodyColor('feet', v) }), - }; - for (const widget of Object.values(bodyColors)) colourSection.add(widget.el); - - const envIntensitySlider = slider({ - label: '環境の映りこみ', min: 0, max: 2, step: 0.05, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.render.envIntensity = v; app.actions.refresh('render'); }, - }); - colourSection.add(envIntensitySlider.el); - colourSection.add(buttons({ - items: [{ - id: 'resetColors', - label: 'プリセットの色に戻す', - onClick: () => { app.actions.applyTheme(state.render.theme ?? 'original'); sync(); }, - }], - })); - - /* -------------------------------------------------------------------- look */ - - const lookSection = section(tabView, '見た目の調整'); - const mirrorToggle = check({ - label: '左右反転', - value: false, - onChange: (value) => { state.render.mirror = value; app.actions.refresh('render'); sync(); }, - }); - lookSection.add(controlRow(null, mirrorToggle.el, { wide: true })); - lookSection.add(hint('体だけでなく、目や口の位置までふくめて反転します。文字と並べるときの向きを決めるときに使います。')); - - const shadowCheck = check({ - label: '影を落とす', - value: true, - onChange: (value) => { state.render.shadow = value; app.actions.refresh('render'); }, - }); - lookSection.add(controlRow(null, shadowCheck.el, { wide: true })); - const shadowOpacitySlider = slider({ - label: '濃さ', min: 0, max: 0.6, step: 0.01, value: 0.22, format: (v) => v.toFixed(2), - onInput: (v) => { state.render.shadowOpacity = v; app.actions.refresh('render'); }, - }); - const shadowSoftnessSlider = slider({ - label: 'ぼかし', min: 0, max: 3, step: 0.05, value: 1.6, format: (v) => v.toFixed(2), - onInput: (v) => { state.render.shadowSoftness = v; app.actions.refresh('render'); }, - }); - lookSection.add(shadowOpacitySlider.el); - lookSection.add(shadowSoftnessSlider.el); - const contactShadowToggle = check({ - label: '接地影だけ', - value: false, - onChange: (value) => { state.render.contactShadow = value; app.actions.refresh('render'); }, - }); - lookSection.add(controlRow(null, contactShadowToggle.el, { wide: true })); - const contactBlobToggle = check({ - label: '足もとの丸い影', - value: false, - onChange: (value) => { state.render.contactBlob = value; app.actions.refresh('render'); }, - }); - lookSection.add(controlRow(null, contactBlobToggle.el, { wide: true })); - lookSection.add(hint('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。' - + '「接地影だけ」は足もとの丸い影だけにします。' - + '「足もとの丸い影」は、足もとに薄く敷く丸い影です(はじめは出ていません)。')); - - const environmentSegment = segmented({ - label: '照明の種類', - options: ENVIRONMENTS.map((env) => ({ value: env.value, label: env.label })), - value: 'gradient', - onChange: (value) => { state.render.environment = value; app.actions.refresh('render'); }, - }); - // 照明の種類は「シーン」タブの「画面・光」に置く(環境の映りこみの元を選ぶものなので)。 - lookSection.add(buttons({ - items: [{ - id: 'resetLook', - label: 'プリセットに戻す', - onClick: () => replace({ - render: { - mirror: false, shadow: true, shadowOpacity: 0.22, shadowSoftness: 1.6, - contactShadow: false, contactBlob: false, environment: 'gradient', envIntensity: 1, - }, - }, 'render'), - }], - })); - - /* -------------------------------------------------------------- background */ - - 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. - let lastMaterial = 'preset'; - const backgroundModeSegment = segmented({ - label: '背景', - options: [ - { value: 'solid', label: '単色' }, - { value: 'transparent', label: '透明' }, - { value: 'material', label: '素材' }, - { value: 'image', label: '自分の画像' }, - { value: 'camera', label: 'カメラ=AR' }, - ], - value: 'solid', - onChange: (value) => { - if (value === 'camera') { - // The action owns the stream, so let it decide and re-read the state after. - Promise.resolve(app.actions.toggleCamera(true)).then(() => sync()); - return; - } - if (state.view.background === 'camera') app.actions.toggleCamera(false); - state.view.background = value === 'material' ? lastMaterial : value; - app.actions.refresh('view'); - sync(); - }, - }); - backdropSection.add(backgroundModeSegment.el); - // 背景の色 only shows in the 単色 mode; it used to live in the スタイル section. - const backgroundColour = colorField({ - label: '背景の色', value: '#ffffff', - swatches: ['#ffffff', '#f4f0fb', '#d9f0ff', '#fff0f4', '#1b1230'], - onChange: (v) => { state.view.backgroundColor = v; app.actions.refresh('view'); }, - }); - backdropSection.add(backgroundColour.el); - - const photoButtons = buttons({ - label: '写真', - items: BACKGROUND_PRESETS.map((preset) => ({ - id: preset.name, - label: preset.label, - onClick: () => { - state.view.backgroundPreset = preset.name; - lastMaterial = 'preset'; - state.view.background = 'preset'; - app.actions.refresh('view'); - sync(); - }, - })), - }); - - const effectButtons = buttons({ - label: '効果線(まんが)', - items: EFFECT_PRESETS.map((effect) => ({ - id: `effect-${effect.name}`, - label: effect.label, - onClick: () => { - state.view.backgroundEffect = effect.name; - lastMaterial = 'effect'; - state.view.background = 'effect'; - app.actions.refresh('view'); - sync(); - }, - })), - }); - - // The two rows are shown only while 素材 is the active mode (see the syncer - // below); the border groups them so they read as one choice, not two. - const materialGroup = h('div', { - class: 'material-group', - style: { display: 'none', paddingLeft: '8px', borderLeft: '2px solid #e4dff0' }, - }, photoButtons.el, effectButtons.el, hint('効果線はアプリが描いています(画像ファイルなし・ネット不要)。集中線=放射、落ち込み線=上から下、疾走線=左右です。')); - backdropSection.add(materialGroup); - - const backgroundImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } }); - backgroundImagePicker.addEventListener('change', async () => { - const file = backgroundImagePicker.files?.[0]; - backgroundImagePicker.value = ''; - if (!file) return; - try { - const dataUrl = await app.backdrop.loadImageFile(file); - state.view.backgroundImage = dataUrl; - state.view.background = 'image'; - app.actions.refresh('view'); - sync(); - toast(`背景の画像「${file.name}」を読み込みました`); - } catch (error) { - console.error(error); - toast('画像を読み込めませんでした(PNG / JPG をお使いください)'); - } - }); - backdropSection.add(backgroundImagePicker); - backdropSection.add(buttons({ - items: [{ id: 'bgimg', label: '画像を読み込む', onClick: () => { if (confirmImageLicense()) backgroundImagePicker.click(); } }], - })); - - const backgroundFitSegment = segmented({ - label: '合わせ方', - options: [ - { value: 'cover', label: '全体' }, - { value: 'contain', label: '収める' }, - { value: 'stretch', label: '伸ばす' }, - { value: 'tile', label: '並べる' }, - ], - value: 'cover', - onChange: (value) => { state.view.backgroundFit = value; app.actions.refresh('view'); }, - }); - const backgroundBlurSlider = slider({ - label: 'ぼかし', min: 0, max: 20, step: 0.5, value: 0, format: (v) => `${v.toFixed(1)}px`, - onInput: (v) => { state.view.backgroundBlur = v; app.actions.refresh('view'); }, - }); - const backgroundDarkenSlider = slider({ - label: '暗さ', min: 0, max: 0.8, step: 0.01, value: 0, format: (v) => v.toFixed(2), - onInput: (v) => { state.view.backgroundDarken = v; app.actions.refresh('view'); }, - }); - const backgroundScaleSlider = slider({ - label: '大きさ', min: 0.5, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.view.backgroundScale = v; app.actions.refresh('view'); }, - }); - backdropSection.add(backgroundFitSegment.el); - backdropSection.add(backgroundBlurSlider.el); - backdropSection.add(backgroundDarkenSlider.el); - backdropSection.add(backgroundScaleSlider.el); - - const cameraFacingSegment = segmented({ - label: 'カメラ', - options: [ - { value: 'user', label: '前' }, - { value: 'environment', label: '後ろ' }, - ], - value: 'environment', - onChange: (value) => { - state.view.cameraFacing = value; - // Only a live stream has to be restarted on the other lens. - if (state.view.background === 'camera') { - Promise.resolve(app.actions.toggleCamera(true)).then(() => sync()); - return; - } - app.actions.refresh('view'); - }, - }); - const cameraMirrorToggle = check({ - label: '左右反転', - value: false, - onChange: (value) => { state.view.cameraMirror = value; app.actions.refresh('view'); }, - }); - backdropSection.add(cameraFacingSegment.el); - backdropSection.add(controlRow(null, cameraMirrorToggle.el, { wide: true })); - - // A gyro reading is a delta from the pose the device held when the switch was - // turned on, so the camera does not jump when it starts listening. - let gyroBase = null; - let gyroOff = null; - const gyroToggle = check({ - label: '端末を傾けて見る', - value: false, - onChange: (value) => { - state.view.gyro = value; - if (!value) { - gyroBase = null; - if (gyroOff) { gyroOff(); gyroOff = null; } - return; - } - gyroBase = { azimuth: Number(state.view.azimuth) || 0, polar: Number(state.view.polar) || 76 }; - app.backdrop?.requestGyro?.(); - gyroOff = gyroOff ?? app.backdrop?.onGyro?.((reading) => { - if (!gyroBase) return; - const yaw = reading?.yaw ?? 0; - const pitch = reading?.pitch ?? 0; - state.view.azimuth = Math.max(-180, Math.min(180, gyroBase.azimuth - yaw)); - state.view.polar = Math.max(1, Math.min(179, gyroBase.polar - pitch)); - app.actions.refresh('view'); - }) ?? null; - }, - }); - backdropSection.add(controlRow(null, gyroToggle.el, { wide: true })); - backdropSection.add(hint('カメラ(AR)を使うには、https のサイトか localhost で開いてください。http のLANアドレスでは、どのブラウザでもカメラは使えません。使えるときは、URLバーのアイコンを「許可」にし、端末の設定でもアプリにカメラを許可してください。')); - backdropSection.add(buttons({ - items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }], - })); - - /* ------------------------------------------------------------------- props */ - - // 小物: the things you put the character *among*. Each stage feature gets its - // own top-level section rather than one shared 舞台 drawer, so they are easier - // to find and open independently. - const propSection = section(tabScene, '小物'); - propSection.add(buttons({ - label: '追加', - items: PROP_LIBRARY.map((entry) => ({ - id: entry.id, - label: entry.label, - onClick: () => { app.actions.addProp(entry.id); sync(); }, - })), - })); - propSection.add(hint('小物はキャラクターのまわりに置かれ、背景と同じくそのまま写真に写ります。' - + 'ビューポートで小物をドラッグすると移動できます(数値でも動かせます)。')); - const propListBox = h('div', { class: 'prop-list' }); - propSection.add(controlRow(null, propListBox, { wide: true })); - - const propWidgets = []; - let propShape = ''; - - /** - * Rebuild the list only when the *set* of props changes: a rebuild mid-drag - * would replace the slider the user is holding. The handlers read the item by - * index rather than keeping a reference, so a state swap (undo, load) is safe. - */ - const renderProps = () => { - const items = state.props?.items ?? []; - const shape = items.map((item) => item.kind).join('|'); - if (shape === propShape && propWidgets.length === items.length) return; - propShape = shape; - propWidgets.length = 0; - propListBox.replaceChildren(); - items.forEach((item, index) => { - const label = PROP_LIBRARY.find((entry) => entry.id === item.kind)?.label ?? item.kind; - const block = h('div', { - class: 'prop-item', - style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' }, - }, subhead(items.length > 1 ? `${label} ${index + 1}` : label)); - const patch = (key) => (value) => { - const target = state.props?.items?.[index]; - if (target) target[key] = value; - app.actions.refresh('render'); - sync(); - }; - const sliders = { - x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('x') }), - y: slider({ label: 'たかさ(Y)', min: 0, max: 2, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('y') }), - z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: patch('z') }), - rotX: slider({ label: '前後の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotX') }), - rotY: slider({ label: '回転(よこ)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotY') }), - rotZ: slider({ label: '左右の傾き', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: patch('rotZ') }), - scale: slider({ label: '大きさ', min: 0.4, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: patch('scale') }), - }; - // 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 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; - let signText = null; - if (item.kind === 'sign') { - signText = textArea({ - label: '看板の文字(改行できます)', - value: '', - onChange: (value) => { - const target = state.props?.items?.[index]; - if (target) target.text = value; - // Paint straight onto the live face rather than rebuilding every prop - // on each keystroke; a later rebuild re-applies it (see main.js). - applyPropText(placedPropGroups(app)[index], value); - app.needsRender = true; - }, - }); - block.insertBefore(signText.el, numbers.el); - faceScale = slider({ - label: '文字部分の大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, - format: (v) => v.toFixed(2), onInput: patch('faceScale'), - }); - block.insertBefore(faceScale.el, numbers.el); - } - block.append(buttons({ - items: [{ - id: 'remove', - label: '削除', - onClick: () => { - state.props = state.props ?? { items: [] }; - state.props.items = (state.props.items ?? []).filter((_, i) => i !== index); - app.actions.refresh('render'); - renderProps(); - sync(); - }, - }], - }).el); - propListBox.append(block); - propWidgets.push({ index, sliders, faceScale, signText }); - }); - }; - renderProps(); - - /* ------------------------------------------------------------- 擬音 */ - - // 擬音: manga onomatopoeia as stickers over the picture. The sheets are dense, - // so the user picks the crop with a marquee instead of the app slicing them - // (see src/gion.js for why). Only the sheet name and the crop rectangle live in - // the state - never the bitmap - so a shared link stays small. - const gionSection = section(tabScene, '擬音(マンガのオノマトペ)'); - gionSection.add(hint('擬音(ドドド、バン!など)の画像から、四角く切り出したスタンプを画面に貼れます。' - + '「擬音をえらぶ」で画像を開き、ドラッグで四角を描いて「追加」を押してください。' - + '貼ったあとはビューポートでドラッグして動かせます。')); - - const gionSheetSelect = selectField({ - label: 'シート', - options: GION_SHEETS.map((sheet) => ({ value: sheet.name, label: sheet.label })), - value: GION_SHEETS[0]?.name ?? '', - onChange: () => {}, - }); - if (GION_SHEETS.length) { - gionSection.add(gionSheetSelect.el); - gionSection.add(buttons({ - items: [{ - id: 'pick', - label: '擬音をえらぶ', - primary: true, - onClick: () => openGionPicker(gionSheetSelect.get()), - }], - })); - } else { - // No sheet ships with the studio (see src/gion.js), so there is nothing to - // pick yet - the section stays, but only with a note on how to add one. - gionSection.add(hint('擬音の画像がまだありません。assets/manga-gion/ に画像を置き、' - + 'src/gion.js の GION_SHEETS に登録すると使えます。')); - } - - const gionListBox = h('div', { class: 'gion-list' }); - gionSection.add(controlRow(null, gionListBox, { wide: true })); - - let gionShape = ''; - const gionWidgets = []; - - /** Select a stamp: the viewport outlines it and the list highlights it. */ - const selectGion = (id) => { - app.gionSelected = id ?? null; - app.actions.refresh('gion'); - sync(); - }; - - /** - * A little canvas preview of a stamp's crop, drawn from the sheet once it has - * loaded. The sheet itself is only fetched when a stamp first needs it. - */ - const gionPreviews = new Map(); - function gionPreview(item) { - const canvas = h('canvas', { class: 'gion-thumb', width: 84, height: 56 }); - const draw = (image) => { - if (!image?.naturalWidth) return; - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); - const scale = Math.min(canvas.width / item.sw, canvas.height / item.sh); - const w = item.sw * scale; - const h = item.sh * scale; - ctx.drawImage(image, item.sx, item.sy, item.sw, item.sh, - (canvas.width - w) / 2, (canvas.height - h) / 2, w, h); - }; - const known = gionPreviews.get(item.sheet); - if (known) { - draw(known); - } else { - const image = new Image(); - image.onload = () => draw(image); - image.src = gionSheetUrl(item.sheet); - gionPreviews.set(item.sheet, image); - } - return canvas; - } - - /** Rebuild the list only when the *set* of stamps changes (see `renderProps`). */ - const renderGion = () => { - const items = state.gion?.items ?? []; - const shape = items - .map((item) => `${item.id}:${item.sheet}:${item.sx},${item.sy},${item.sw},${item.sh}`) - .join('|'); - if (shape === gionShape && gionWidgets.length === items.length) return; - gionShape = shape; - gionWidgets.length = 0; - gionListBox.replaceChildren(); - if (!items.some((item) => item.id === app.gionSelected)) app.gionSelected = null; - if (items.length === 0) { - gionListBox.append(h('p', { class: 'hint', text: 'まだ擬音は貼られていません。' })); - return; - } - items.forEach((item, index) => { - const label = GION_SHEETS.find((sheet) => sheet.name === item.sheet)?.label ?? item.sheet; - const block = h('div', { - class: 'gion-item', - style: { borderTop: '1px dashed #e4dff0', paddingTop: '8px', display: 'grid', gap: '6px' }, - }); - const preview = gionPreview(item); - preview.title = 'クリックで選択'; - preview.addEventListener('click', () => selectGion(item.id)); - const head = h('div', { class: 'gion-item-head' }, preview, - subhead(`擬音 ${index + 1}(${label})`)); - block.append(head); - - const patch = (key) => (value) => { - const target = state.gion?.items?.[index]; - if (target) target[key] = value; - app.actions.refresh('gion'); - sync(); - }; - const sliders = { - w: slider({ - label: '大きさ', min: 40, max: 900, step: 1, value: DEFAULT_STAMP_WIDTH, - format: (v) => `${Math.round(v)}px`, onInput: patch('w'), - }), - rot: slider({ - label: '回転', min: -180, max: 180, step: 1, value: 0, - format: (v) => `${Math.round(v)}°`, onInput: patch('rot'), - }), - }; - const flip = check({ label: '左右反転', value: false, onChange: patch('flip') }); - block.append(sliders.w.el, sliders.rot.el, controlRow(null, flip.el, { wide: true })); - block.append(buttons({ - items: [ - { id: 'select', label: 'この擬音を選ぶ', onClick: () => selectGion(item.id) }, - { - id: 'remove', - label: '削除', - onClick: () => { - state.gion = state.gion ?? { items: [] }; - state.gion.items = (state.gion.items ?? []).filter((_, i) => i !== index); - if (app.gionSelected === item.id) app.gionSelected = null; - app.actions.refresh('gion'); - renderGion(); - sync(); - }, - }, - ], - }).el); - gionListBox.append(block); - gionWidgets.push({ id: item.id, sliders, flip, block }); - }); - }; - renderGion(); - - /** - * The picker: a full-screen overlay showing the whole sheet, on which the user - * drags a rectangle. "追加" turns that rectangle into a stamp in the middle of - * the picture; Esc or キャンセル closes without adding anything. - */ - const openGionPicker = (sheetName) => { - const sheet = GION_SHEETS.find((entry) => entry.name === sheetName) ?? GION_SHEETS[0]; - if (!sheet) { toast('擬音のシートがありません'); return; } - - const image = h('img', { class: 'gion-picker-sheet', alt: sheet.label, src: gionSheetUrl(sheet.name) }); - const marquee = h('div', { class: 'gion-picker-marquee' }); - const board = h('div', { class: 'gion-picker-board' }, image, marquee); - const addButton = h('button', { type: 'button', class: 'btn primary', text: '追加', disabled: true }); - const cancelButton = h('button', { type: 'button', class: 'btn', text: 'キャンセル' }); - const overlay = h('div', { class: 'gion-picker' }, - h('div', { class: 'gion-picker-bar' }, - h('div', { class: 'gion-picker-title', text: `「${sheet.label}」から擬音をえらぶ` }), - h('div', { class: 'gion-picker-actions' }, - h('span', { class: 'hint', text: 'ドラッグで四角を描く/Escで閉じる' }), - addButton, - cancelButton)), - board); - - /** Where the sheet is currently displayed, in board pixels. */ - let sheetRect = null; - /** The marquee the user is drawing, or the last one drawn. */ - let current = null; - let start = null; - - const layoutSheet = () => { - if (!image.naturalWidth) return; - const rect = fitSheet(image, { width: board.clientWidth, height: board.clientHeight }, { padding: 12 }); - image.style.left = `${rect.x}px`; - image.style.top = `${rect.y}px`; - image.style.width = `${rect.w}px`; - image.style.height = `${rect.h}px`; - sheetRect = rect; - }; - - const updateAdd = () => { - addButton.disabled = !(current && current.w >= 6 && current.h >= 6 && sheetRect); - }; - - const drawMarquee = () => { - if (!current) { marquee.style.display = 'none'; return; } - marquee.style.display = 'block'; - marquee.style.left = `${current.x}px`; - marquee.style.top = `${current.y}px`; - marquee.style.width = `${current.w}px`; - marquee.style.height = `${current.h}px`; - }; - - const pointIn = (event, bounds) => ({ - x: clamp(event.clientX - bounds.left, 0, bounds.width), - y: clamp(event.clientY - bounds.top, 0, bounds.height), - }); - - board.addEventListener('pointerdown', (event) => { - if (event.pointerType === 'mouse' && event.button !== 0) return; - start = pointIn(event, board.getBoundingClientRect()); - current = null; - drawMarquee(); - updateAdd(); - try { board.setPointerCapture(event.pointerId); } catch { /* a nicety, not a need */ } - }); - board.addEventListener('pointermove', (event) => { - if (!start) return; - current = normalizeRect(start, pointIn(event, board.getBoundingClientRect())); - drawMarquee(); - updateAdd(); - }); - const endMarquee = (event) => { - if (!start) return; - start = null; - try { if (board.hasPointerCapture(event.pointerId)) board.releasePointerCapture(event.pointerId); } catch { /* done anyway */ } - updateAdd(); - }; - board.addEventListener('pointerup', endMarquee); - board.addEventListener('pointercancel', endMarquee); - image.addEventListener('load', () => { layoutSheet(); updateAdd(); }); - - addButton.addEventListener('click', () => { - if (!current || !sheetRect) return; - const crop = cropFromMarquee(current, sheetRect, image); - app.actions.addGionStamp({ sheet: sheet.name, ...crop }); - close(); - }); - cancelButton.addEventListener('click', () => close()); - overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); }); - - const onKey = (event) => { - if (event.key !== 'Escape') return; - event.preventDefault(); - close(); - }; - const onResize = () => layoutSheet(); - - let closed = false; - const close = () => { - if (closed) return; - closed = true; - document.removeEventListener('keydown', onKey); - window.removeEventListener('resize', onResize); - overlay.remove(); - }; - - document.addEventListener('keydown', onKey); - window.addEventListener('resize', onResize); - document.body.append(overlay); - // A cached sheet is already loaded, so the `load` event never fires for it. - if (image.complete && image.naturalWidth) { layoutSheet(); updateAdd(); } - }; - - /* ------------------------------------------------------------- 見えない壁 */ - - // 見えない壁: place up to two finite "boards" and bury the character in them, so - // only the part in front still shows. A wall's position is kept *relative to the - // character*, so moving ぶるべー carries its walls along (see src/clip.js). - const wallSection = section(tabScene, '見えない壁'); - wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。' - + 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。' - + '板は2枚まで置けます。板の位置はぶるべーからの相対なので、ぶるべーを動かすと壁も一緒に動きます。' - + '板そのものは透明です(下の「板を表示」をオンにしたときだけ見えます)。')); - - const WALL_SLOTS = [ - { key: 'wall', label: '壁 1' }, - { key: 'wall2', label: '壁 2' }, - ]; - - const wallGroups = WALL_SLOTS.map(({ key, label }) => { - const box = details(wallSection.body, label); - const cfg = () => (state.render[key] = state.render[key] ?? defaultState().render[key]); - const toggle = check({ - label: `${label}で一部を隠す`, - value: false, - onChange: (value) => { cfg().on = value; app.actions.refresh('render'); sync(); }, - }); - box.add(controlRow(null, toggle.el, { wide: true })); - const sliders = { - x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().x = v; app.actions.refresh('render'); sync(); } }), - y: slider({ label: 'たかさ(Y)', min: -2, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().y = v; app.actions.refresh('render'); sync(); } }), - z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().z = v; app.actions.refresh('render'); sync(); } }), - yaw: slider({ label: '向き(左右)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().yaw = v; app.actions.refresh('render'); sync(); } }), - tilt: slider({ label: '傾き(前後)', min: -90, max: 90, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().tilt = v; app.actions.refresh('render'); sync(); } }), - size: slider({ label: '大きさ', min: 0.2, max: 8, step: 0.1, value: 1, format: (v) => v.toFixed(1), onInput: (v) => { cfg().size = v; app.actions.refresh('render'); sync(); } }), - }; - for (const widget of Object.values(sliders)) box.add(widget.el); - - // The wall writes depth but no colour, so it hides what is behind it without - // being visible. The guide is the tinted copy of that same rectangle, shown - // only while placing - and it is also the switch that says "I am placing the - // wall now", so with it off the wall cannot swallow an orbit drag. - const guide = check({ - label: '板を表示(ビューポートでドラッグして動かせます)', - value: false, - onChange: (value) => { cfg().guide = value; app.actions.refresh('render'); sync(); }, - }); - box.add(controlRow(null, guide.el, { wide: true })); - return { key, label, toggle, sliders, guide }; - }); - - wallSection.add(hint('板は「大きさ」の四角い壁そのもので、**その後ろ側が隠れます**' - + '(カメラから見て板の後ろになる部分が隠れるので、カメラを回すと見え方も変わります)。' - + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。' - + '「板を表示」した板は、ビューポートでドラッグしても動かせます。')); - wallSection.add(buttons({ - items: [{ - id: 'wallReset', - label: '見えない壁をリセット', - onClick: () => { - state.render.wall = defaultState().render.wall; - state.render.wall2 = defaultState().render.wall2; - app.actions.refresh('render'); - sync(); - }, - }], - })); - - /* ------------------------------------------------------------------ light */ - - // 画面・光: the light rig and the camera, which together decide how the picture - // is lit and framed. - const screenLightSection = section(tabScene, '画面・光'); - const lightSection = details(screenLightSection.body, 'ひかり'); - const lightPad = xyPad({ - value: { x: -0.2, y: 0.45 }, - onChange: ({ x, y }) => { - state.render.lightAzimuth = x * 180; - state.render.lightElevation = 5 + Math.max(0, y) * 80; - app.actions.refresh('render'); - }, - }); - lightSection.add(controlRow('光の向き', h('div', { class: 'pad-wrap' }, - lightPad.el, - h('p', { class: 'hint', text: 'ドラッグで光源の向きを変えられます。' })))); - const lightIntensitySlider = slider({ - label: '明るさ', min: 0, max: 5, step: 0.05, value: 2.1, format: (v) => v.toFixed(2), - onInput: (v) => { state.render.lightIntensity = v; app.actions.refresh('render'); }, - }); - const ambientSlider = slider({ - label: '環境光', min: 0, max: 3, step: 0.05, value: 0.9, format: (v) => v.toFixed(2), - onInput: (v) => { state.render.ambient = v; app.actions.refresh('render'); }, - }); - const exposureSlider = slider({ - label: '露光', min: 0.4, max: 2, step: 0.02, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.render.exposure = v; app.actions.refresh('render'); }, - }); - const lightColorField = colorField({ - label: '光の色', value: '#ffffff', - swatches: ['#ffffff', '#fff3d6', '#d6e8ff', '#ffd9c9', '#e6dcff'], - onChange: (v) => { state.render.lightColor = v; app.actions.refresh('render'); }, - }); - const ambientColorField = colorField({ - label: '環境光の色', value: '#ffffff', - swatches: ['#ffffff', '#d9d0f2', '#cfe6ff', '#ffe6cc', '#e8ffe0'], - onChange: (v) => { state.render.ambientColor = v; app.actions.refresh('render'); }, - }); - lightSection.add(environmentSegment.el); - lightSection.add(lightIntensitySlider.el); - lightSection.add(ambientSlider.el); - lightSection.add(lightColorField.el); - lightSection.add(ambientColorField.el); - lightSection.add(exposureSlider.el); - lightSection.add(hint('「照明の種類」は環境の映りこみの元(グラデーション/部屋/なし)です。' - + '「光の色」でライトの色、「環境光の色」で影側の色を変えられます。' - + 'テクスチャ(環境)の映りこみの強さは「見た目」タブの「環境の映りこみ」で調整できます。')); - - /* ----------------------------------------------------------------- camera */ - - const cameraSection = details(screenLightSection.body, 'カメラ'); - - const projectionSegment = segmented({ - label: '映し方', - options: [ - { value: 'persp', label: '遠近あり' }, - { value: 'ortho', label: '正投影' }, - ], - value: 'persp', - onChange: (value) => replace({ view: { projection: value } }, 'view'), - }); - cameraSection.add(projectionSegment.el); - cameraSection.add(hint('正投影にすると遠近のゆがみが消え、図面のような絵になります。')); - - cameraSection.add(buttons({ - label: '向き', - items: [ - { id: 'front', label: '正面', onClick: () => app.actions.setCameraPreset('front') }, - { id: 'threeQuarter', label: '斜め', onClick: () => app.actions.setCameraPreset('threeQuarter') }, - { id: 'side', label: '横', onClick: () => app.actions.setCameraPreset('side') }, - { id: 'back', label: '後ろ', onClick: () => app.actions.setCameraPreset('back') }, - { id: 'top', label: '見下ろし', onClick: () => app.actions.setCameraPreset('top') }, - ], - })); - - const cameraSliders = { - azimuth: slider({ - label: '回転', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { state.view.azimuth = v; app.actions.refresh('view'); }, - }), - polar: slider({ - label: '高さ', min: 1, max: 179, step: 1, value: 76, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { state.view.polar = v; app.actions.refresh('view'); }, - }), - size: slider({ - label: '大きさ', min: 2, max: 200, step: 0.1, value: 12, format: (v) => v.toFixed(1), - onInput: (v) => { - if (state.view.projection === 'ortho') state.view.orthoHeight = v; - else state.view.distance = v; - app.actions.refresh('view'); - }, - }), - targetY: slider({ - label: '見る高さ', min: 0, max: Math.max(2, model.size.y * 1.2), step: 0.05, value: model.size.y * 0.5, format: (v) => v.toFixed(2), - onInput: (v) => { state.view.targetY = v; app.actions.refresh('view'); }, - }), - }; - for (const widget of Object.values(cameraSliders)) cameraSection.add(widget.el); - - const autoRotateToggle = check({ - label: '自動で回す', value: false, - onChange: (v) => { state.view.autoRotate = v; app.actions.refresh('view'); }, - }); - cameraSection.add(controlRow(null, autoRotateToggle.el, { wide: true })); - cameraSection.add(slider({ - label: '回す速さ', min: 0.2, max: 4, step: 0.1, value: 0.8, format: (v) => v.toFixed(1), - onInput: (v) => { state.view.autoRotateSpeed = v; app.actions.refresh('view'); }, - })); - - /* ------------------------------------------------------------------ export */ - - const exportSection = section(tabExport, '画像'); - - exportSection.add(segmented({ - label: 'PNGの大きさ', - options: [ - { value: '1', label: '1倍' }, - { value: '2', label: '2倍' }, - { value: '3', label: '3倍' }, - { value: '4', label: '4倍' }, - ], - value: '2', - onChange: (value) => { state.render.pngScale = Number(value); }, - })); - exportSection.add(buttons({ - items: [ - { id: 'png', label: 'PNGを保存', primary: true, onClick: () => app.actions.savePNG() }, - { id: 'copy', label: 'クリップボードにコピー', onClick: () => app.actions.copyPNG() }, - ], - })); - const transparentCheck = check({ - label: 'PNGの背景を透明にする', value: false, - onChange: (v) => { state.render.pngTransparent = v; }, - }); - exportSection.add(transparentCheck.el); - exportSection.add(hint('線画のときに透明にすると、体の白ぬりも透明になって線だけが残ります(他の絵に重ねられます)。ふつう・フラットでは体の色はそのまま残ります。')); - - // まんが(コマ)gets its own section: it is a different kind of output. - const comicSection = section(tabExport, 'まんが'); - const storyScaleSegment = segmented({ - label: '書き出しの大きさ', - options: [ - { value: '1', label: '1×(軽い)' }, - { value: '1.5', label: '1.5×' }, - { value: '2', label: '2×(きれい)' }, - ], - value: '1.5', - hint: 'コマ1枚の画素数は「いまの画面の大きさ × この倍率」です。' - + '2× だと台紙のPNGがとても大きくなります(重いときは 1× か 1.5×)', - onChange: (value) => { state.story.scale = Number(value); }, - }); - comicSection.add(storyScaleSegment.el); - const storyColumnsSlider = slider({ - label: '列数', min: 1, max: 4, step: 1, value: 1, format: (v) => `${Math.round(v)}列`, - onInput: (v) => { state.story.columns = Math.round(v); }, - }); - const storyGapSlider = slider({ - label: '間隔', min: 0, max: 40, step: 1, value: 12, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { state.story.gap = v; }, - }); - const storyBackgroundField = colorField({ - label: '台紙の色', value: '#ffffff', - onChange: (v) => { state.story.sheetBackground = v; }, - }); - comicSection.add(storyColumnsSlider.el); - comicSection.add(storyGapSlider.el); - comicSection.add(storyBackgroundField.el); - comicSection.add(buttons({ - items: [ - { id: 'addPanel', label: '今の状態をコマに追加', onClick: () => { app.actions.addStoryPanel(); sync(); } }, - { id: 'saveStory', label: 'まんがを書き出す', primary: true, onClick: () => app.actions.saveStory({ scale: state.story?.scale ?? 1.5 }) }, - ], - })); - comicSection.add(hint('2列以上で3コマ以上のときは、読む順にコーナーへ番号を付けます。')); - const storyPanelBox = h('div', { class: 'story-panels', style: { display: 'grid', gap: '6px' } }); - comicSection.add(controlRow(null, storyPanelBox, { wide: true })); - let storyShape = ''; - - /** Bring back everything a panel saved: pose, face, both bubbles and the camera. */ - const recallStoryPanel = (index) => { - const panel = state.story?.panels?.[index]; - if (!panel) return; - // Replace the bones outright: a merged pose would keep bones the panel never had. - state.pose = { bones: {}, root: [0, 0, 0] }; - app.actions.applyState({ - pose: panel.pose ?? {}, - face: panel.face ?? {}, - caption: panel.caption ?? {}, - caption2: panel.caption2 ?? {}, - // The 擬音 on the shot. A panel saved before this existed clears them, so - // recalling it shows what it actually recorded rather than a leftover. - gion: panel.gion ?? { items: [] }, - // The camera it was recorded with, so the view comes back too. A panel - // saved before this existed simply keeps the current camera. - ...(panel.view ? { view: panel.view } : {}), - }, { scope: 'all', sync: true }); - }; - - const removeStoryPanel = (index) => { - state.story = state.story ?? {}; - state.story.panels = (state.story.panels ?? []).filter((_, i) => i !== index); - // The empty patch is only here to route the edit through the app's own apply - // path: that is what records an undo step and re-reads the panel. - app.actions.applyState({}, { scope: 'caption', sync: true }); - }; - - /** - * Same rebuild policy as the prop list: only when the set of panels changes. - * - * A preview arrives a moment *after* its panel does (it needs a render), so its - * version is part of the identity: when one turns up, the row is drawn again - * with it. - */ - const renderStoryPanels = () => { - const panels = state.story?.panels ?? []; - const thumbs = app.actions.storyThumbs?.() ?? null; - const shape = `${panels.length}:${panels - .map((panel) => `${panel.id ?? ''}:${thumbs?.get?.(panel.id)?.version ?? 0}`) - .join('|')}`; - if (shape === storyShape) return; - storyShape = shape; - storyPanelBox.replaceChildren(); - panels.forEach((panel, index) => { - const thumb = thumbs?.get?.(panel.id)?.url; - const preview = thumb - ? h('img', { - class: 'story-thumb', - alt: panel.label ?? `コマ${index + 1}`, - src: thumb, - title: 'クリックでこのコマを呼び出す', - style: { - width: '100%', height: '76px', objectFit: 'contain', cursor: 'pointer', - background: '#f3f0fa', border: '1px solid #e4dff0', borderRadius: '8px', - }, - onClick: () => recallStoryPanel(index), - }) - : h('div', { - text: '(プレビューなし)', - style: { - padding: '6px 8px', borderRadius: '8px', background: '#f7f5fd', - color: '#8a80a0', fontSize: '11px', - }, - }); - const block = h('div', { - class: 'story-panel', - style: { borderTop: '1px dashed #e4dff0', paddingTop: '6px', display: 'grid', gap: '4px' }, - }, subhead(panel.label ?? `コマ${index + 1}`), preview); - block.append(buttons({ - items: [ - { id: 'recall', label: 'このコマを呼び出す', onClick: () => recallStoryPanel(index) }, - { id: 'remove', label: '削除', onClick: () => removeStoryPanel(index) }, - ], - }).el); - storyPanelBox.append(block); - }); - }; - renderStoryPanels(); - - // The share section comes after まんが, so the reading order is 画像 → まんが → - // 共有 (the order the outputs are usually wanted in). - /* ------------------------------------------------------------ extra export */ - - const extraExportSection = section(tabExport, '共有'); - extraExportSection.add(buttons({ - items: [ - { id: 'share-image', label: '画像をシェア', primary: true, onClick: () => app.actions.shareImage() }, - { id: 'share-x', label: 'Xで投稿', onClick: () => app.actions.shareImage('x') }, - { id: 'share-fb', label: 'Facebookでシェア', onClick: () => app.actions.shareImage('facebook') }, - { id: 'share-link', label: 'この見た目のリンクをコピー', onClick: () => app.actions.copyShareLink() }, - ], - })); - extraExportSection.add(hint('いまの画面を画像にして「#ぶるべースタジオ」を付けて投稿します。' - + 'スマートフォンでは端末の共有画面が開きます。パソコンでは画像をコピーして投稿画面を開くので、貼り付けて投稿してください(Ctrl+V)。')); - - // 手描きの顔パーツを描くための下地。画面を切り取る他の書き出しと違って、これは - // 「スタジオが読み込む大きさ・位置そのまま」の素材なので、別のセクションにして - // 共有のあとに置く(素材を作る → 見せる、の順)。 - const faceMapSection = section(tabExport, 'テクスチャの下地を書き出す'); - faceMapSection.add(buttons({ - items: [ - { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') }, - { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') }, - { id: 'facemap-hair', label: '髪の下地を書き出す', onClick: () => app.actions.saveFaceMap('hair') }, - ], - })); - faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。' - + 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や' - + '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。' - + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。' - + '「髪の下地」は、頭をぐるりと巻いたテクスチャを開いたもので、横が頭の角度、' - + 'たてが高さです。左右の端が頭の後ろ(合わせ目)、青いたて線が顔の正面、' - + '青いよこ線が既定の生え際です。')); - - /* --------------------------------------------------------------- settings */ - - const settingsSection = section(tabExport, '設定'); - - settingsSection.add(buttons({ - items: [ - { id: 'save', label: '設定を保存(JSON)', onClick: () => app.actions.saveSettings() }, - { id: 'load', label: '設定を読み込む', onClick: () => settingsPicker.click() }, - ], - })); - - const settingsPicker = h('input', { type: 'file', accept: '.json,application/json', style: { display: 'none' } }); - settingsPicker.addEventListener('change', async () => { - const file = settingsPicker.files?.[0]; - settingsPicker.value = ''; - if (!file) return; - try { - const text = await file.text(); - app.actions.loadSettings(text); - } catch (error) { - console.error(error); - toast('設定ファイルを読み込めませんでした'); - } - }); - settingsSection.add(settingsPicker); - - /* -------------------------------------------------------------------- sync */ - - // 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(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, propSection.el, gionSection.el, wallSection.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); - const delta = name ? rig.getDelta(name) : { x: 0, y: 0, z: 0 }; - axisSliders.x.set(delta.x); - axisSliders.y.set(delta.y); - axisSliders.z.set(delta.z); - const root = state.pose.root ?? [0, 0, 0]; - rootSliders.forEach((widget, index) => widget.set(root[index] ?? 0)); - }); - - syncers.push(...boneSyncers); - - syncers.push(() => { - const linked = state.face.eyes.linked !== false; - bothControls.group.setVisible(linked); - leftControls.group.setVisible(!linked); - rightControls.group.setVisible(!linked); - bothControls.sync(); - leftControls.sync(); - rightControls.sync(); - - for (const key of ['left', 'right']) { - const eye = state.face.eyes[key]; - const w = eyeAdvancedWidgets[key]; - w.eyeX.set(eye.eyeX ?? 0); - } - - browToggle.set(state.face.eyes.brow.enabled); - browSliders.angle.set(state.face.eyes.brow.angle); - browSliders.height.set(state.face.eyes.brow.height); - browSliders.length.set(state.face.eyes.brow.length); - browSliders.thickness.set(state.face.eyes.brow.thickness); - browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0); - browSliders.curve.set(state.face.eyes.brow.curve); - browSliders.taper.set(state.face.eyes.brow.taper ?? 1); - // A loaded ゴル風 picture ignores the shape sliders, so grey those out rather - // than leave them looking like they should do something. - const browIsImage = state.face.eyes.brow.image === true; - for (const key of ['angle', 'thickness', 'curve', 'taper']) { - setRowEnabled(browSliders[key], !browIsImage); - } - browColor.set(state.face.eyes.brow.color); - glassesToggle.set(state.face.eyes.glasses.enabled === true); - glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses'); - for (const [key, widget] of Object.entries(glassesSliders)) widget.set(state.face.eyes.glasses[key]); - glassesFrameColor.set(state.face.eyes.glasses.frameColor); - glassesLensColor.set(state.face.eyes.glasses.lensColor); - eyeColors.white.set(state.face.eyes.white); - eyeColors.iris.set(state.face.eyes.iris); - eyeColors.line.set(state.face.eyes.line); - }); - - syncers.push(() => { - cheeksToggle.set(state.face.eyes.cheeks.enabled === true); - for (const [key, widget] of Object.entries(cheekSliders)) widget.set(state.face.eyes.cheeks[key]); - cheekColor.set(state.face.eyes.cheeks.color); - headMarkShape.set(state.face.eyes.headMark.shape ?? 'off'); - headMarkSliders.teeth.el.style.display = (state.face.eyes.headMark.shape === 'fringe') ? '' : 'none'; - headMarkOverEyes.set(state.face.eyes.headMark.overEyes !== false); - 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'); - snotToggle.set(state.face.eyes.snot.enabled === true); - snotSize.set(state.face.eyes.snot.size); - for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]); - snotColor.set(state.face.eyes.snot.color); - beardShape.set(state.face.eyes.beard.shape ?? 'off'); - for (const [key, widget] of Object.entries(beardSliders)) widget.set(state.face.eyes.beard[key]); - // 「左右の間隔」は内部値より+10して表示する(既定の -10 が 0 に見える)。 - beardSliders.spacing.set((state.face.eyes.beard.spacing ?? 0) + 10); - beardColor.set(state.face.eyes.beard.color); - }); - - syncers.push(() => { - mouthToggle.set(state.face.mouth.visible); - for (const [key, widget] of Object.entries(mouthSliders)) widget.set(state.face.mouth[key]); - for (const [key, widget] of Object.entries(mouthColors)) widget.set(state.face.mouth[key]); - const mouthShape = mouthModeOf(state.face.mouth); - mouthMode.set(mouthShape); - const shown = new Set(mouthRowKeys[mouthShape]); - for (const key of mouthRowOrder) mouthSliders[key].el.style.display = shown.has(key) ? '' : 'none'; - }); - - syncers.push(() => { - motionSegment.set(state.anim.mode ?? 'off'); - animWidgets.blink.set(state.anim.blink); - animWidgets.lookAround.set(state.anim.lookAround); - }); - - syncers.push(() => { - styleSegment.set(state.render.style); - outlineToggle.set(state.render.outline); - outlinePixelsSlider.set(state.render.outlinePixels ?? 2); - leafBodyLineCheck.set(state.render.leafBodyLine !== false); - transparentCheck.set(state.render.pngTransparent); - lightPad.set({ - x: state.render.lightAzimuth / 180, - y: Math.max(0, (state.render.lightElevation - 5) / 80), - }); - lightIntensitySlider.set(state.render.lightIntensity ?? 2.1); - ambientSlider.set(state.render.ambient ?? 0.9); - lightColorField.set(state.render.lightColor ?? '#ffffff'); - ambientColorField.set(state.render.ambientColor ?? '#ffffff'); - exposureSlider.set(state.render.exposure ?? 1); - const line = ['lineart', 'outline'].includes(state.render.style); - outlineToggle.el.querySelector('input').disabled = line; - }); - - syncers.push(() => { - projectionSegment.set(state.view.projection); - cameraSliders.azimuth.set(state.view.azimuth); - cameraSliders.polar.set(state.view.polar); - const ortho = state.view.projection === 'ortho'; - cameraSliders.size.set(ortho ? (state.view.orthoHeight || 7.5) : (state.view.distance || 12)); - cameraSliders.targetY.set(state.view.targetY || model.size.y * 0.5); - autoRotateToggle.set(state.view.autoRotate); - }); - - syncers.push(() => { - lookAtToggle.set(state.lookAt?.enabled === true); - for (const [key, widget] of Object.entries(lookAtSliders)) { - widget.set(state.lookAt?.[key] ?? LOOK_AT_FALLBACKS[key]); - } - turnBodyToggle.set(state.lookAt?.turnBody === true); - }); - - syncers.push(() => { - themeSegment.set(state.render?.theme ?? 'original'); - for (const [key, widget] of Object.entries(bodyColors)) { - widget.set(state.render?.colors?.[key] ?? BODY_COLOR_FALLBACKS[key]); - } - envIntensitySlider.set(state.render?.envIntensity ?? 1); - }); - - syncers.push(() => { - mirrorToggle.set(state.render?.mirror === true); - shadowCheck.set(state.render?.shadow !== false); - shadowOpacitySlider.set(state.render?.shadowOpacity ?? 0.22); - shadowSoftnessSlider.set(state.render?.shadowSoftness ?? 1.6); - contactShadowToggle.set(state.render?.contactShadow === true); - contactBlobToggle.set(state.render?.contactBlob === true); - environmentSegment.set(state.render?.environment ?? 'gradient'); - }); - - syncers.push(() => { - const background = state.view?.background ?? 'solid'; - // 素材 is active for either half of it, and its rows show only then. - const isMaterial = background === 'preset' || background === 'effect'; - backgroundModeSegment.set(isMaterial ? 'material' : background); - materialGroup.style.display = isMaterial ? '' : 'none'; - backgroundColour.set(state.view?.backgroundColor ?? '#ffffff'); - backgroundFitSegment.set(state.view?.backgroundFit ?? 'cover'); - backgroundBlurSlider.set(state.view?.backgroundBlur ?? 0); - backgroundDarkenSlider.set(state.view?.backgroundDarken ?? 0); - backgroundScaleSlider.set(state.view?.backgroundScale ?? 1); - cameraFacingSegment.set(state.view?.cameraFacing ?? 'environment'); - cameraMirrorToggle.set(state.view?.cameraMirror === true); - gyroToggle.set(state.view?.gyro === true); - }); - - syncers.push(() => { - renderProps(); - const items = state.props?.items ?? []; - // A rebuild only carries the whole-prop scale, so re-apply each sign's face - // size to the freshly built mesh here (see `placedPropGroups`). - const groups = placedPropGroups(app); - for (const entry of propWidgets) { - const item = items[entry.index] ?? {}; - entry.sliders.x.set(item.x ?? 0); - entry.sliders.y.set(item.y ?? 0); - entry.sliders.z.set(item.z ?? 0); - entry.sliders.rotX.set(item.rotX ?? 0); - entry.sliders.rotY.set(item.rotY ?? 0); - entry.sliders.rotZ.set(item.rotZ ?? 0); - entry.sliders.scale.set(item.scale ?? 1); - entry.faceScale?.set(item.faceScale ?? 1); - applyPropFaceScale(groups[entry.index], item.faceScale); - entry.signText?.set(item.text ?? ''); - } - }); - - syncers.push(() => { - renderGion(); - const items = state.gion?.items ?? []; - for (const entry of gionWidgets) { - const item = items.find((candidate) => candidate.id === entry.id); - if (!item) continue; - entry.sliders.w.set(item.w ?? DEFAULT_STAMP_WIDTH); - entry.sliders.rot.set(item.rot ?? 0); - entry.flip.set(item.flip === true); - entry.block.classList.toggle('active', item.id === app.gionSelected); - } - }); - - syncers.push(() => { - for (const group of wallGroups) { - const wall = state.render?.[group.key] ?? defaultState().render[group.key]; - group.toggle.set(wall.on === true); - for (const [key, widget] of Object.entries(group.sliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0)); - group.guide.set(wall.guide === true); - } - }); - - syncers.push(() => { - captionBubbleSegment.set(activeCaption); - const bubble = cap() ?? {}; - captionToggle.set(bubble.enabled === true); - captionText.set(bubble.text ?? ''); - verticalToggle.set(bubble.vertical === true); - bubbleSegment.set(bubble.bubble ?? 'round'); - tailSegment.set(bubble.tail ?? 'left'); - for (const [key, widget] of Object.entries(captionSliders)) { - widget.set(bubble[key] ?? CAPTION_SLIDER_FALLBACKS[key]); - } - captionPosition.x.set(bubble.x ?? 0.58); - captionPosition.y.set(bubble.y ?? 0.16); - captionMaxWidthSlider.set(bubble.maxWidth ?? 0.36); - alignSegment.set(bubble.align ?? 'left'); - boldToggle.set(bubble.bold === true); - systemFontToggle.set(bubble.font === 'system'); - for (const [key, widget] of Object.entries(captionColors)) { - widget.set(bubble[key] ?? CAPTION_COLOR_FALLBACKS[key]); - } - }); - - syncers.push(() => { - flapText.set(state.mouthFlap?.text ?? ''); - for (const [key, widget] of Object.entries(flapSliders)) { - widget.set(state.mouthFlap?.[key] ?? MOUTH_FLAP_FALLBACKS[key]); - } - }); - - syncers.push(() => { - storyScaleSegment.set(String(state.story?.scale ?? 1.5)); - storyColumnsSlider.set(state.story?.columns ?? 1); - storyGapSlider.set(state.story?.gap ?? 12); - storyBackgroundField.set(state.story?.sheetBackground ?? '#ffffff'); - renderStoryPanels(); - }); - - app.actions.applyFacePreset = applyFacePreset; - app.actions.applyPosePreset = applyPosePreset; - - sync(); - - // The 'g' key toggles the gizmo behind the panel's back, so the checkbox - // follows the rig rather than the other way round. - syncers.push(() => gizmoToggle.set(rig.helper.visible === true)); - - return { - sync, - onRigChanged: syncBones, - /** Which bubble the caption controls are editing (a drag on it calls this). */ - selectCaption(key) { - if (key !== 'caption' && key !== 'caption2' && key !== 'narration') return; - activeCaption = key; - sync(); - }, - /** Which 擬音 stamp the viewport should outline (a drag on it calls this). */ - selectGion(id) { - app.gionSelected = id ?? null; - sync(); - }, - /** Open the crop picker for a sheet (the 擬音 section's button calls this). */ - openGionPicker(sheet) { - openGionPicker(sheet ?? gionSheetSelect.get()); - }, - setStats(text) { statsLine.textContent = text; }, - setRecording(recording) { - recordButton.setLabel('record', recording ? '録画を停止' : '録画を開始'); - }, - }; -} |
