import {
h, section as uiSection, subhead, hint, slider, check, segmented, buttons,
colorField, xyPad, selectField, controlRow, tabs, details, toast, icon, svgIcon,
} from './ui.js';
import {
FACE_PRESETS, POSE_PRESETS, defaultState, applyPatch,
} 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',
'眼鏡・サングラス': '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',
'鼻ちょうちん': '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.6 10c3 4.8 11.8 4.8 14.8 0',
'見る先': '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',
'見た目の調整': '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',
'画面・光': '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',
'設定': '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 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's multi-line field. The 看板 prop's lettering is the only caller, but
* the shape (a control row wrapping a textarea) is worth keeping in one place.
*/
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(); };
/** 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 randomFacePatch = () => {
return {
eyes: {
left: randomEye(),
right: randomEye(),
// 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。
glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(),
},
// 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);
app.actions.applyPosePreset?.(pose.id);
// The viewer style (実写/フラット/線画), the body colours, 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 = {
eye: section(tabFace, '目'),
look: section(tabFace, '見る先'),
mouth: section(tabFace, '口'),
glasses: section(tabFace, '眼鏡・サングラス'),
snot: section(tabFace, '鼻ちょうちん'),
hat: 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: '',
left: '',
right: '',
lid: '',
};
// 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);
// --- glasses / sunglasses (drawn into the same texture as the eyes)
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();
},
},
],
}));
// --- 鼻ちょうちん: 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('寝ている顔の横に、鼻から出る泡を描きます。'
+ '「ねている」ポーズと組み合わせると、いねむりしているように見えます。'
+ '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。'));
// --- 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);
/* ------------------------------------------------------ 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(動画)として保存されます。'));
// --- 見る先 (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 = faceTabSections.hat;
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);
});
/* ---------------------------------------------------------- 環境の映りこみ */
// The body colours are fixed (see `render.colors` in presets.js), so the 体の色
// section is gone. The strength of the environment reflection is still a look
// setting, so it lives in 見た目の調整 below.
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'); },
});
/* -------------------------------------------------------------------- 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('影の向きは「ひかり」の光の向き(パッド)にしたがって変わります。'
+ '「接地影だけ」は足もとの丸い影だけにします。'
+ '「足もとの丸い影」は、足もとに薄く敷く丸い影です(はじめは出ていません)。'));
// 環境の映りこみ: the strength of the environment reflection, moved here from
// the old 体の色 section (the body colours themselves are fixed now).
lookSection.add(envIntensitySlider.el);
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, 擬音 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 ?? {},
// 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: 'all', 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)。'));
/* --------------------------------------------------------------- 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, so put
// them on the tab in the order the user meets them: what is behind the
// character, what is beside it, then the light and the read-out.
tabScene.append(
backdropSection.el, propSection.el, gionSection.el, wallSection.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);
}
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(() => {
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);
});
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(() => {
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(() => {
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 擬音 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 ? '録画を停止' : '録画を開始');
},
};
}