From 9deb6762ef5794b340943e2d2845da79c437dce5 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:33:42 +0900 Subject: bluebey-studio: 準拠版(フキダシ・眉毛・髪・ひげ・ほっぺ・体の色を削除) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ぶるべーはしゃべらない・姿を変えない方針に合わせた準拠版。セリフ/ナレーション/吹き出し・口パク・眉毛(ゴルゴ等)・髪(頭の模様)・ひげ・ほっぺ・体の色/テーマを削除。目・口・眼鏡・帽子・鼻ちょうちん・ポーズ・背景・小物・光・書き出しは維持。caption.js / mouthFlap.js は削除。 --- bluebey-studio/src/caption.js | 1321 --------------------------------------- bluebey-studio/src/exporter.js | 72 +-- bluebey-studio/src/face.js | 92 +-- bluebey-studio/src/faceArt.js | 576 +---------------- bluebey-studio/src/main.js | 340 ++-------- bluebey-studio/src/mouthFlap.js | 267 -------- bluebey-studio/src/panel.js | 722 ++------------------- bluebey-studio/src/presets.js | 238 +------ 8 files changed, 103 insertions(+), 3525 deletions(-) delete mode 100644 bluebey-studio/src/caption.js delete mode 100644 bluebey-studio/src/mouthFlap.js diff --git a/bluebey-studio/src/caption.js b/bluebey-studio/src/caption.js deleted file mode 100644 index 71759ea..0000000 --- a/bluebey-studio/src/caption.js +++ /dev/null @@ -1,1321 +0,0 @@ -import { - captionFontStack, - hasGlyphs, - layoutText, - textToPathData, -} from './textOutlines.js'; - -/** - * The speech bubble that sits next to the character. - * - * The same caption is drawn three times: into the 2D canvas that is overlaid on - * the 3D view, into the canvas that gets composited into an exported PNG, and - * into the vector SVG export. Drawing a shape three times by hand is how a - * preview starts to disagree with the file it produced, so the outline is built - * exactly once, here, as a short list of path commands. `drawCaption` replays - * that list through a 2D context and `captionToSvg` prints the same list as SVG - * path data, which is what keeps the two renderers together. - * - * The module never measures or wraps text itself: `textOutlines.js` owns line - * breaking and glyph outlines. This file only decides how large the bubble has - * to be for the block it is handed, padding included, and guarantees that block - * fits inside. - * - * It is pure in the same sense as `trace.js`: no DOM, no globals and no - * asynchronous work, so the tests can drive it in Node with a stub context. - */ - -/** Defaults mirroring the `caption` slice of `presets.js`. */ -const DEFAULT_FONT_SIZE = 34; -const DEFAULT_LINE_HEIGHT = 1.42; -const DEFAULT_PADDING = 18; -const DEFAULT_RADIUS = 24; -const DEFAULT_BORDER_WIDTH = 4; -const DEFAULT_MAX_WIDTH = 0.36; -const DEFAULT_TEXT_COLOR = '#3f2b52'; -const DEFAULT_BUBBLE_COLOR = '#ffffff'; -const DEFAULT_BORDER_COLOR = '#55386e'; - -/** Advance widths used before the vendored font has loaded, as a fraction of em. */ -const FALLBACK_WIDE_EM = 1; -const FALLBACK_NARROW_EM = 0.55; - -/** - * Vertical metrics of the fallback stack, as a fraction of the font size. - * - * A typical Japanese family sits close to these numbers, so the baseline of the - * pre-load preview barely moves when the real font arrives. - */ -const FALLBACK_ASCENT = 0.88; -const FALLBACK_DESCENT = 0.12; - -/** Control-point distance that turns a corner into a quarter circle. */ -const KAPPA = 0.5522847498307936; - -/** Code-point ranges that deserve a full em in the fallback measurement. */ -const WIDE_RANGES = [ - [0x1100, 0x11ff], [0x2e80, 0x30ff], [0x3130, 0x318f], [0x3400, 0x4dbf], - [0x4e00, 0x9fff], [0xac00, 0xd7ff], [0xf900, 0xfaff], [0xff00, 0xffef], -]; - -/** - * Punctuation that may hang past the bottom of a vertical column. - * - * Japanese line breaking (禁則処理) forbids starting a line with a closing - * mark, so when one of these would land at the top of a new column it is kept - * on the previous column and drawn past that column's last cell instead. - */ -const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']); - -/** Small kana, which sit toward the upper-right of their cell when stacked. */ -const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ'; - -/** - * Characters that may not open a column (行頭禁則). - * - * A closing mark such as `、` simply hangs off the previous column; `ー`, small - * kana and closing brackets are kept company by handing the preceding - * character over to the next column as well, so a column never starts with - * something that has nothing to attach to. - */ -const COLUMN_START_FORBIDDEN = new Set([ - '」', '』', ')', '〕', '】', '〉', '》', '”', '’', '・', 'ー', - ...SMALL_KANA, - ...HANGING_PUNCTUATION, -]); - -/** Characters that may not close a column (行末禁則). */ -const COLUMN_END_FORBIDDEN = new Set(['「', '『', '(', '〔', '【', '〈', '《', '“', '‘']); - -/** - * Per-character offset for vertical (縦書き) text, in em. - * - * A column draws one glyph per cell, so a few characters need a nudge to read - * the way a Japanese typesetter would place them: the prolonged sound mark - * becomes an upright tick, small kana tuck into the upper-right, and the - * brackets lean toward the ends of the span they enclose. Everything else - * stays dead-centre. Sharing this table is what keeps the canvas and the SVG - * export in step. - */ -const VERTICAL_OFFSETS = new Map(); -for (const ch of SMALL_KANA) VERTICAL_OFFSETS.set(ch, { dx: 0.12, dy: -0.12 }); -// 縦書きの句読点は、横書きの左下ではなく、字枠の右上に置く。 -for (const ch of HANGING_PUNCTUATION) VERTICAL_OFFSETS.set(ch, { dx: 0.68, dy: -0.55 }); - -/** - * Characters drawn turned a quarter turn when stacked, so they read the way - * 縦書き sets them: the prolonged sound mark `ー` becomes an upright bar, and the - * brackets `「」『』[]()` turn so they open down the column. - */ -const VERTICAL_ROTATED = new Set(['ー', '~', '〜', '―']); -const VERTICAL_BRACKETS = new Set([ - '「', '」', '『', '』', '(', ')', '[', ']', '【', '】', '〔', '〕', '〈', '〉', '《', '》', -]); - -/** - * How far a rotated glyph is nudged down inside its cell, in em. The rotated `ー` - * is centred otherwise, which leaves it hugging the character above it; the - * brackets stay centred. - */ -const VERTICAL_ROTATED_DROP = 0.14; - -/** - * The downward nudge (em) for a glyph that is turned a quarter turn in 縦書き, or - * `null` when the glyph is drawn the ordinary way. - */ -function verticalRotation(ch) { - if (VERTICAL_ROTATED.has(ch)) return VERTICAL_ROTATED_DROP; - if (VERTICAL_BRACKETS.has(ch)) return 0; - return null; -} - -/** Offset used for the characters that need no special placement. */ -const NO_OFFSET = Object.freeze({ dx: 0, dy: 0 }); - -/** - * The `ctx.font` string for a caption. - * - * Canvas wants size, weight and family in one string, and the size has to carry - * the export scale because the same caption is drawn at 1x for the preview and - * at 2x or more into a PNG. `loaded` is the app's answer to "is the vendored - * subset ready?": until it is, the vendored family is dropped from the stack so - * the preview does not ask for a font that is still downloading. - * - * @param {object} caption the `caption` slice of the state - * @param {number} [scale=1] - * @param {boolean} [loaded=false] - * @returns {string} - */ -export function fontSpec(caption, scale = 1, loaded = false) { - const state = caption ?? {}; - const size = resolveFontSize(state, positiveScale(scale)); - const weight = state.bold ? 'bold ' : ''; - return `${weight}${formatNumber(size, 3)}px ${fontFamilyStack(state, loaded)}`; -} - -/** - * Resolve a caption to pixel geometry at the output size. - * - * `x`/`y` are fractions of the image and land on the bubble's top-left corner; - * `maxWidth` is a fraction of the image width and caps the text column. The box - * is always the bubble itself, padding included and the tail excluded, and it - * always fits inside the image: padding gives way first, then the position is - * clamped (leaving room for the tail), and only a block larger than the image - * is clipped. Every returned number is finite, even for an empty caption or a - * zero-sized image. - * - * `font` is an opentype font from `textOutlines.js`. Without it the text is - * wrapped by a rough character count, so the preview still shows a bubble while - * the font is loading. - * - * @param {object} caption - * @param {object} [options] - * @param {number} [options.width=0] - * @param {number} [options.height=0] - * @param {number} [options.scale=1] - * @param {import('opentype.js').Font|null} [options.font=null] - * @returns {{ - * box: {x: number, y: number, w: number, h: number}, - * lines: Array<{text: string, width: number}>, - * fontSize: number, lineHeight: number, padding: number, scale: number, - * usedOutlines: boolean, - * }} - */ -export function layoutCaption(caption, { width, height, scale = 1, font = null } = {}) { - const state = caption ?? {}; - const resolvedScale = positiveScale(scale); - const outWidth = toNonNegative(width, 0); - const outHeight = toNonNegative(height, 0); - - const fontSize = resolveFontSize(state, resolvedScale); - const lineHeight = toPositive(state.lineHeight, DEFAULT_LINE_HEIGHT); - const requested = toNonNegative(state.padding, DEFAULT_PADDING) * resolvedScale; - const text = typeof state.text === 'string' ? state.text : ''; - - const vertical = state.vertical === true; - const block = vertical - ? layoutVertical(text, { - fontSize, - lineHeight, - maxHeight: resolveColumn(state, outHeight, requested), - }) - : layoutBlock(font, text, { - fontSize, - maxWidth: resolveColumn(state, outWidth, requested), - lineHeight, - align: alignOf(state), - }); - - // Padding gives way before the bubble does, so a caption that cannot fit with - // its usual breathing room still fits inside the image. - const padding = Math.max( - 0, - Math.min(requested, (outWidth - block.width) / 2, (outHeight - block.height) / 2), - ); - const w = Math.min(block.width + 2 * padding, outWidth); - const h = Math.min(block.height + 2 * padding, outHeight); - - // The tail sticks out of the box, so the box has to keep that much clear of - // the edge it points at. - const tail = tailOf(state); - const tailLength = tail === 'none' ? 0 : Math.max(0, Math.min(fontSize * 0.9, h * 0.5)); - const roomX = Math.max(0, outWidth - w); - const roomY = Math.max(0, outHeight - h); - - // Which way the tail leaves the box, as a direction where each axis is -1, - // 0 or 1. A diagonal tail needs room on *both* axes, so the clearance is - // driven by the direction rather than by which name the tail has. - const dir = tailDirection(tail); - let lowX = 0; - let highX = roomX; - if (dir.x < 0) lowX = Math.min(tailLength, roomX); - else if (dir.x > 0) highX = Math.max(0, roomX - tailLength); - - let lowY = 0; - let highY = roomY; - if (dir.y < 0) lowY = Math.min(tailLength, roomY); - else if (dir.y > 0) highY = Math.max(0, roomY - tailLength); - - const box = { - x: clamp(toFinite(state.x, 0) * outWidth, lowX, highX), - y: clamp(toFinite(state.y, 0) * outHeight, lowY, highY), - w, - h, - }; - - return { - box, - lines: block.lines, - fontSize, - lineHeight, - padding, - scale: resolvedScale, - vertical, - usedOutlines: Boolean(font), - }; -} - -/** - * Lay the bubble down in a 2D context, ready to be filled and stroked. - * - * The tail is part of the same path as the bubble, so the border runs along it - * and joins the outline cleanly instead of meeting it at a seam. The caller - * owns the colours; this only traces the shape. - * - * @param {CanvasRenderingContext2D} ctx - * @param {{x: number, y: number, w: number, h: number}} box bubble box, tail excluded - * @param {object} caption - * @param {number} [scale=1] - * @returns {boolean} `true` when there was a shape to trace - */ -export function bubblePath(ctx, box, caption, scale = 1) { - const commands = bubbleCommands(box, caption ?? {}, positiveScale(scale)); - applyCommands(ctx, commands); - return commands.length > 0; -} - -/** - * Draw a caption: bubble first, then the text line by line. - * - * The 2D canvas is overlaid exactly on the 3D view for the preview and is - * composited into the PNG on export, which is why the very same function runs - * in both places: what the user sees is what the file contains. - * - * The bubble box comes back even when the caption is switched off, so the app - * can show a placeholder and hit-test a drag without a second layout. - * - * @param {CanvasRenderingContext2D} ctx - * @param {object} caption - * @param {object} [options] - * @param {number} [options.width=0] - * @param {number} [options.height=0] - * @param {number} [options.scale=1] - * @param {import('opentype.js').Font|null} [options.font=null] - * @returns {{x: number, y: number, w: number, h: number}} - */ -export function drawCaption(ctx, caption, { width, height, scale = 1, font = null } = {}) { - const state = caption ?? {}; - const layout = layoutCaption(state, { width, height, scale, font }); - const { box } = layout; - if (!state.enabled || !ctx) return box; - - ctx.save(); - - const commands = bubbleCommands(box, state, layout.scale); - if (commands.length > 0) { - applyCommands(ctx, commands); - ctx.fillStyle = colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR); - ctx.fill(); - - const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale; - if (borderWidth > 0) { - ctx.strokeStyle = colorOf(state.borderColor, DEFAULT_BORDER_COLOR); - ctx.lineWidth = borderWidth; - ctx.lineJoin = 'round'; - ctx.lineCap = 'round'; - // A whisper is the rounded bubble with a broken outline. - if (bubbleOf(state) === 'whisper' && typeof ctx.setLineDash === 'function') { - ctx.setLineDash([borderWidth * 3, borderWidth * 2]); - } - ctx.stroke(); - } - } - - if (layout.lines.length > 0) drawText(ctx, layout, state, font); - - ctx.restore(); - return box; -} - -/** - * The caption as an SVG fragment, plus whether the text is outlined. - * - * Text becomes glyph outlines whenever the font covers it, because a `` - * element is redrawn with whatever font the viewer happens to have and a - * caption that reflows is worse than one that cannot be selected. When a glyph - * is missing the whole caption falls back to `` and `usedOutlines` is - * `false`, which is the caller's signal to warn that the file now depends on - * the viewer's fonts. - * - * The bubble and its tail are one ``, traced from the same command list - * the canvas uses. `enabled` is not consulted: the export path only runs for an - * enabled caption, and drawing whatever is handed over keeps this usable for a - * thumbnail. - * - * @param {object} caption - * @param {object} [options] - * @param {number} [options.width=0] - * @param {number} [options.height=0] - * @param {number} [options.scale=1] - * @param {import('opentype.js').Font|null} [options.font=null] - * @param {number} [options.round=2] decimal places in the output - * @returns {{svg: string, usedOutlines: boolean}} - */ -export function captionToSvg(caption, { width, height, scale = 1, font = null, round = 2 } = {}) { - const state = caption ?? {}; - const places = Math.max(0, Math.min(8, Math.floor(Number.isFinite(round) ? round : 2))); - const layout = layoutCaption(state, { width, height, scale, font }); - const { box, lines, fontSize, lineHeight, padding } = layout; - const outWidth = toNonNegative(width, 0); - const outHeight = toNonNegative(height, 0); - const text = typeof state.text === 'string' ? state.text : ''; - - let usedOutlines = Boolean(font) && text.trim() !== '' && hasGlyphs(font, text); - // Vertical text falls back to ``: the outline path lays glyphs out - // horizontally, and a column of glyphs reads fine as live text. - if (layout.vertical) usedOutlines = false; - - const parts = [ - '`, - ]; - - const bubble = commandsToPathData(bubbleCommands(box, state, layout.scale), places); - if (bubble) { - const fill = escapeAttribute(colorOf(state.bubbleColor, DEFAULT_BUBBLE_COLOR)); - const borderWidth = toNonNegative(state.borderWidth, DEFAULT_BORDER_WIDTH) * layout.scale; - const stroke = borderWidth > 0 - ? ` stroke="${escapeAttribute(colorOf(state.borderColor, DEFAULT_BORDER_COLOR))}"` - + ` stroke-width="${formatNumber(borderWidth, places)}" stroke-linejoin="round"` - + (bubbleOf(state) === 'whisper' - ? ` stroke-dasharray="${formatNumber(borderWidth * 3, places)} ${formatNumber(borderWidth * 2, places)}"` - : '') - : ''; - parts.push(``); - } - - if (lines.length > 0) { - const textColor = colorOf(state.textColor, DEFAULT_TEXT_COLOR); - let outlined = ''; - if (usedOutlines) { - // `textToPathData` re-applies the alignment against the widest line, so - // the block it needs is the one `layoutText` measured. - outlined = textToPathData(font, { - lines, - width: blockWidthOf(lines), - height: lines.length * fontSize * lineHeight, - lineHeight, - fontSize, - align: alignOf(state), - }, { x: box.x + padding, y: box.y + padding, round: places }); - usedOutlines = outlined !== ''; - } - if (usedOutlines) { - parts.push(``); - } else { - parts.push(liveText(state, layout, font, textColor, places)); - } - } - - parts.push(''); - return { svg: parts.join('\n') + '\n', usedOutlines }; -} - -// --- text -------------------------------------------------------------------- - -/** Draw every line at its own baseline, shifted sideways by the alignment. */ -function drawText(ctx, layout, state, font) { - const { box, lines, fontSize, lineHeight, padding, scale } = layout; - const metrics = baselineMetrics(font, fontSize); - const step = fontSize * lineHeight; - const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2; - const blockWidth = blockWidthOf(lines); - const left = box.x + padding; - const top = box.y + padding; - const align = alignOf(state); - - ctx.font = fontSpec(state, scale, Boolean(font)); - ctx.fillStyle = colorOf(state.textColor, DEFAULT_TEXT_COLOR); - ctx.textAlign = 'left'; - ctx.textBaseline = 'alphabetic'; - - if (layout.vertical) { - // Columns run right to left; each glyph sits in its own cell. `ー` and its - // like are turned a quarter turn so they read as vertical strokes. - for (let i = 0; i < lines.length; i++) { - const chars = [...lines[i].text]; - const x = left + (lines.length - 1 - i) * step; - for (let j = 0; j < chars.length; j++) { - const ch = chars[j]; - const drop = verticalRotation(ch); - if (drop !== null) { - ctx.save(); - ctx.translate(x + fontSize / 2, top + j * fontSize + fontSize / 2 + drop * fontSize); - ctx.rotate(Math.PI / 2); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(ch, 0, 0); - ctx.restore(); - continue; - } - const offset = verticalOffsetOf(ch); - const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize; - ctx.fillText(ch, x + offset.dx * fontSize, y); - } - } - return; - } - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const x = left + alignOffset(align, blockWidth, line.width); - const y = top + i * step + halfLeading + metrics.ascent; - ctx.fillText(line.text, x, y); - } -} - -/** - * The `` fallback. - * - * The family comes from the font stack alone, not from `fontSpec`: size and - * weight have attributes of their own, and a `font-family` that carried them - * would be ignored by every viewer. - */ -function liveText(state, layout, font, fill, places) { - const { box, lines, fontSize, lineHeight, padding } = layout; - const blockWidth = blockWidthOf(lines); - const align = alignOf(state); - const left = box.x + padding; - const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start'; - const x = align === 'center' - ? left + blockWidth / 2 - : align === 'right' - ? left + blockWidth - : left; - - const metrics = baselineMetrics(font, fontSize); - const step = fontSize * lineHeight; - const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2; - const top = box.y + padding; - - if (layout.vertical) { - // The stacked glyphs share one left-anchored . The rotated ones (`ー`) - // get a of their own, centred on the cell and turned a quarter turn. - const base = [ - `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`, - `font-size="${formatNumber(fontSize, places)}"`, - state.bold ? 'font-weight="bold"' : '', - `fill="${escapeAttribute(fill)}"`, - ].filter(Boolean).join(' '); - const tspans = []; - const rotated = []; - for (let i = 0; i < lines.length; i++) { - const chars = [...lines[i].text]; - const x = left + (lines.length - 1 - i) * step; - for (let j = 0; j < chars.length; j++) { - const ch = chars[j]; - const drop = verticalRotation(ch); - if (drop !== null) { - const cx = x + fontSize / 2; - const cy = top + j * fontSize + fontSize / 2 + drop * fontSize; - rotated.push(`${escapeText(ch)}`); - continue; - } - const offset = verticalOffsetOf(ch); - const y = top + j * fontSize + metrics.ascent + offset.dy * fontSize; - tspans.push(`${escapeText(ch)}`); - } - } - const main = tspans.length ? `${tspans.join('')}` : ''; - return `${main}${rotated.join('')}`; - } - - const attributes = [ - `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`, - `font-size="${formatNumber(fontSize, places)}"`, - state.bold ? 'font-weight="bold"' : '', - `fill="${escapeAttribute(fill)}"`, - `text-anchor="${anchor}"`, - ].filter(Boolean).join(' '); - - const tspans = lines.map((line, i) => { - const y = top + i * step + halfLeading + metrics.ascent; - return `` - + `${escapeText(line.text)}`; - }).join(''); - - return `${tspans}`; -} - -/** - * Wrap text without a font, from a character count. - * - * The preview has to show something while the vendored font downloads, and this - * is a deliberately crude stand-in: wide (CJK) characters count as one em and - * everything else as half an em. Once `layoutText` can run its metrics replace - * these numbers, so the rough version only ever decides a preview, never a file. - */ -function wrapByCount(text, { fontSize, maxWidth, lineHeight }) { - const limit = Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : Infinity; - const lines = []; - - for (const paragraph of String(text).split(/\r\n|\r|\n/)) { - let current = ''; - for (const ch of paragraph) { - const candidate = current + ch; - if (current.trimEnd() !== '' && measureFallback(candidate, fontSize) > limit) { - lines.push(current.trimEnd()); - current = /\s/.test(ch) ? '' : ch; // the break swallows the space - } else { - current = candidate; - } - } - lines.push(current.trimEnd()); - } - - const measured = lines.map((line) => ({ text: line, width: measureFallback(line, fontSize) })); - let width = 0; - for (const line of measured) width = Math.max(width, line.width); - return { lines: measured, width, height: measured.length * fontSize * lineHeight }; -} - -/** Wrap with `layoutText`, or with the character count when there is no font. */ -function layoutBlock(font, text, options) { - if (text === '') return { lines: [], width: 0, height: 0 }; - if (font) { - const layout = layoutText(font, text, options); - return { lines: layout.lines, width: layout.width, height: layout.height }; - } - return wrapByCount(text, options); -} - -/** - * Offset of one character inside its vertical cell, in em. - * - * Shared by `drawText` and `liveText` so a glyph that needs a nudge lands in - * the same place on screen and in the export. - */ -function verticalOffsetOf(ch) { - return VERTICAL_OFFSETS.get(ch) ?? NO_OFFSET; -} - -/** - * Vertical (縦書き) layout: each paragraph becomes one column, characters stacked - * top to bottom, columns running right to left. A paragraph longer than the - * available height is split into more columns. - * - * Closing punctuation hangs: when `、` or `。` (or `,`/`.`) would fall at the top - * of a new column, it stays on the previous one and is drawn just below that - * column's last cell instead. Such a hanging character does not count toward - * the column's length, so it never makes the block a cell taller or adds a - * column of its own. `lines[i].hang` reports how many characters on that line - * are hanging. - * - * The other 禁則 cases are repaired too: ー, small kana and closing brackets are - * kept off the top of a column by handing the preceding character down with - * them (追い出し), and an opening bracket is never left at the bottom of one. - */ -function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { - const limit = Number.isFinite(maxHeight) && maxHeight > 0 - ? Math.max(1, Math.floor(maxHeight / fontSize)) - : Infinity; - const columns = []; - for (const paragraph of String(text).split(/\r\n|\r|\n/)) { - const chars = [...paragraph]; - if (chars.length === 0) { - columns.push({ text: '', count: 0 }); - continue; - } - let i = 0; - while (i < chars.length) { - // Fill the column cell by cell, then repair its two ends for 禁則処理. - const cells = []; - const hanging = []; - while (i < chars.length && cells.length < limit) { - cells.push(chars[i]); - i += 1; - } - // 行末禁則: an opening bracket may not close a column, so hand it over to - // the next one (it will open that column instead). - while (cells.length > 1 && COLUMN_END_FORBIDDEN.has(cells[cells.length - 1])) { - cells.pop(); - i -= 1; - } - // 行頭禁則 (追い出し): ー, small kana and closing brackets may not open a - // column; move the preceding character down to keep them company. - if (i < chars.length && cells.length > 1 - && COLUMN_START_FORBIDDEN.has(chars[i]) && !HANGING_PUNCTUATION.has(chars[i])) { - cells.pop(); - i -= 1; - } - // 行頭禁則 (ぶら下げ): a closing mark that would open the next column - // stays on this one, drawn just below its last cell. - while (i < chars.length && HANGING_PUNCTUATION.has(chars[i])) { - hanging.push(chars[i]); - i += 1; - } - columns.push({ text: cells.join('') + hanging.join(''), count: cells.length }); - } - } - const longest = columns.reduce((max, column) => Math.max(max, column.count), 0); - return { - lines: columns.map((column) => ({ - text: column.text, - width: fontSize, - hang: [...column.text].length - column.count, - })), - // The block is exactly as wide as the columns that are drawn: `step` apart, - // each cell one em wide (not one whole `step`, which would leave a leading - // of dead space on the right and push the text off-centre). - width: columns.length === 0 - ? 0 - : (columns.length - 1) * fontSize * lineHeight + fontSize, - height: longest * fontSize, - vertical: true, - }; -} - -/** Text column in pixels: a fraction of the image, never wider than the image. */ -function resolveColumn(state, outWidth, padding) { - const fraction = toNonNegative(state.maxWidth, DEFAULT_MAX_WIDTH); - if (!(fraction > 0)) return 0; // 0 means "do not wrap", as in `layoutText` - return Math.min(fraction * outWidth, Math.max(0, outWidth - 2 * padding)); -} - -/** Ascent above and descent below the baseline, in pixels. */ -function baselineMetrics(font, fontSize) { - const unitsPerEm = font?.unitsPerEm; - if (Number.isFinite(font?.ascender) && Number.isFinite(font?.descender) && unitsPerEm > 0) { - return { - ascent: (font.ascender * fontSize) / unitsPerEm, - descent: (-font.descender * fontSize) / unitsPerEm, - }; - } - return { ascent: fontSize * FALLBACK_ASCENT, descent: fontSize * FALLBACK_DESCENT }; -} - -/** Widest line of the block, which is the width the alignment works against. */ -function blockWidthOf(lines) { - let width = 0; - for (const line of lines) width = Math.max(width, toFinite(line?.width, 0)); - return width; -} - -/** Sideways shift of one line inside the block. */ -function alignOffset(align, blockWidth, lineWidth) { - const slack = Math.max(0, blockWidth - lineWidth); - if (align === 'center') return slack / 2; - if (align === 'right') return slack; - return 0; -} - -/** Width of a string by character count, in pixels. */ -function measureFallback(text, fontSize) { - let width = 0; - for (const ch of String(text ?? '')) { - width += isWideChar(ch) ? fontSize * FALLBACK_WIDE_EM : fontSize * FALLBACK_NARROW_EM; - } - return width; -} - -/** Characters that usually take a full em in a Japanese font. */ -function isWideChar(ch) { - const code = ch.codePointAt(0); - for (const [start, end] of WIDE_RANGES) { - if (code >= start && code <= end) return true; - } - return false; -} - -// --- geometry ---------------------------------------------------------------- - -/** - * The bubble outline as path commands. - * - * Both renderers go through here, which is the point: the canvas preview, the - * PNG and the SVG are three views of one geometry rather than three - * implementations of it. - * - * @returns {Array} empty when there is nothing to draw - */ -function bubbleCommands(box, state, scale) { - const shape = bubbleOf(state); - const rect = { - x: toFinite(box?.x, 0), - y: toFinite(box?.y, 0), - w: toNonNegative(box?.w, 0), - h: toNonNegative(box?.h, 0), - }; - if (shape === 'none' || !(rect.w > 0) || !(rect.h > 0)) return []; - - if (shape === 'think') return thinkCommands(rect, state, scale); - - const radius = toNonNegative(state.radius, DEFAULT_RADIUS) * scale; - const edges = shape === 'shout' - ? shoutEdges(rect, scale) - : shape === 'rect' - ? rectEdges(rect) - : roundEdges(rect, radius); - - return edgesToCommands(withTail(edges, rect, state, scale)); -} - -/** Clockwise rectangle, starting at the top-left corner. */ -function rectEdges(box) { - const { x, y, w, h } = box; - const tl = { x, y }; - const tr = { x: x + w, y }; - const br = { x: x + w, y: y + h }; - const bl = { x, y: y + h }; - return [ - lineEdge(tl, tr), - lineEdge(tr, br), - lineEdge(br, bl), - lineEdge(bl, tl), - ]; -} - -/** - * Rounded rectangle as straight sides plus quarter-circle corners. - * - * Keeping the sides straight (rather than approximating the whole outline with - * a polyline) is what lets the tail attach to a real straight edge; the radius - * is clamped so the corners can never cross each other. - */ -function roundEdges(box, radius) { - const { x, y, w, h } = box; - const r = clamp(radius, 0, Math.min(w, h) / 2); - if (!(r > 0.5)) return rectEdges(box); - - const k = r * KAPPA; - const p = (px, py) => ({ x: px, y: py }); - const corner = (from, c1, c2, to) => cubicEdge(p(...from), p(...c1), p(...c2), p(...to)); - return [ - lineEdge(p(x + r, y), p(x + w - r, y)), - corner([x + w - r, y], [x + w - r + k, y], [x + w, y + r - k], [x + w, y + r]), - lineEdge(p(x + w, y + r), p(x + w, y + h - r)), - corner([x + w, y + h - r], [x + w, y + h - r + k], [x + w - r + k, y + h], [x + w - r, y + h]), - lineEdge(p(x + w - r, y + h), p(x + r, y + h)), - corner([x + r, y + h], [x + r - k, y + h], [x, y + h - r + k], [x, y + h - r]), - lineEdge(p(x, y + h - r), p(x, y + r)), - corner([x, y + r], [x, y + r - k], [x + r - k, y], [x + r, y]), - ]; -} - -/** - * Star-burst: a rectangle whose four sides zig-zag outwards. - * - * The tooth depth and pitch are fractions of the bubble, so a big "shout" and a - * small one have the same number of spikes instead of the big one looking like - * a saw. - */ -function shoutEdges(box, scale) { - const { x, y, w, h } = box; - const amp = Math.min(w, h) * 0.07; - const pitch = Math.max(scale, Math.min(w, h) * 0.22); - const corners = [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }]; - const normals = [{ x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }]; - - const points = []; - for (let side = 0; side < 4; side++) { - const a = corners[side]; - const b = corners[(side + 1) % 4]; - const normal = normals[side]; - const length = Math.hypot(b.x - a.x, b.y - a.y); - const teeth = Math.max(2, Math.round(length / pitch)); - points.push(a); - for (let i = 0; i < teeth; i++) { - if (i > 0) points.push(lerp(a, b, i / teeth)); - const out = lerp(a, b, (i + 0.5) / teeth); - points.push({ x: out.x + normal.x * amp, y: out.y + normal.y * amp }); - } - } - - return points.map((from, i) => lineEdge(from, points[(i + 1) % points.length])); -} - -/** - * Cloud outline for a thought bubble, plus how far its scallops bulge out. - * - * The four sides of the box are replaced by a ring of outward scallops: a - * rounded rectangle is sampled evenly by arc length and each pair of samples is - * joined by a half-ellipse bulging away from the centre. - */ -function thinkEdges(box, scale) { - const { x, y, w, h } = box; - const cx = x + w / 2; - const cy = y + h / 2; - const ring = roundedRectRing(box, Math.min(w, h) * 0.28); - const perimeter = ringLength(ring); - // Fluffier: more scallops, each bulging further, so it reads as a soft cloud. - const pitch = Math.max(scale * 6, Math.min(w, h) * 0.30); - const count = Math.max(6, Math.round(perimeter / pitch)); - const points = sampleRing(ring, count); - const amp = Math.min((perimeter / count) * 0.55, Math.min(w, h) * 0.26); - - const edges = []; - let outer = amp; - for (let i = 0; i < points.length; i++) { - const a = points[i]; - const b = points[(i + 1) % points.length]; - const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; - // Bulge perpendicular to the edge, not away from the centre: on a wide box - // the centre direction points sideways along the top and bottom edges, which - // is what tilted those scallops. The sign is flipped so it always faces out, - // which makes every edge scallop the same way the left and right ones do. - const dx = b.x - a.x; - const dy = b.y - a.y; - const length = Math.hypot(dx, dy) || 1; - let nx = -dy / length; - let ny = dx / length; - if (nx * (mid.x - cx) + ny * (mid.y - cy) < 0) { - nx = -nx; - ny = -ny; - } - // A small, *deterministic* wobble so the scallops are not all identical - the - // even size read as too regular. Same index, same bubble, every render. - const scallopAmp = amp * (0.72 + 0.56 * scallopJitter(i)); - if (scallopAmp > outer) outer = scallopAmp; - edges.push(scallopEdge(a, b, { x: nx, y: ny }, scallopAmp)); - } - return { edges, amp: outer }; -} - -/** A closed cloud, followed by the tail: two or three shrinking puffs. */ -function thinkCommands(box, state, scale) { - const cloud = thinkEdges(box, scale); - const commands = edgesToCommands(cloud.edges); - for (const puff of thoughtTrail(box, state, scale, cloud.amp)) { - commands.push(...circleCommands(puff.x, puff.y, puff.r)); - } - return commands; -} - -/** - * The circles that trail away from a thought bubble instead of a pointed tail. - * - * They start just outside the cloud (`outer`) and shrink as they go, ending - * inside the room `layoutCaption` reserves for the tail. - */ -function thoughtTrail(box, state, scale, outer) { - const tail = tailOf(state); - if (tail === 'none') return []; - const dir = tailDirection(tail); - const diagonal = dir.x !== 0 && dir.y !== 0; - const nx = dir.x * (diagonal ? Math.SQRT1_2 : 1); - const ny = dir.y * (diagonal ? Math.SQRT1_2 : 1); - const anchor = { - x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2, - y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2, - }; - // Sized from the font, not the leftover room, so the puffs stay visible at any - // bubble size; they start just outside the cloud (`outer`) and shrink away. - const unit = resolveFontSize(state, scale); - // 80% of the first cut, which read a touch large. - const r1 = unit * 0.48; - const r2 = unit * 0.34; - const r3 = unit * 0.21; - const gap = unit * 0.5; - const d1 = outer + r1; - const d2 = d1 + r1 + gap; - const d3 = d2 + r2 + gap; - return [ - { x: anchor.x + nx * d1, y: anchor.y + ny * d1, r: r1 }, - { x: anchor.x + nx * d2, y: anchor.y + ny * d2, r: r2 }, - { x: anchor.x + nx * d3, y: anchor.y + ny * d3, r: r3 }, - ]; -} - -/** One outward scallop: a half-ellipse from `from` to `to` bulging along `normal`. */ -function scallopEdge(from, to, normal, amp) { - const k = (4 / 3) * amp; - return cubicEdge( - from, - { x: from.x + normal.x * k, y: from.y + normal.y * k }, - { x: to.x + normal.x * k, y: to.y + normal.y * k }, - to, - ); -} - -/** - * A small deterministic wobble in 0..1 for the i-th scallop of a cloud. - * - * It is derived from the index alone, so a bubble draws its scallops exactly - * the same in the live preview, a screenshot, and the SVG export. - */ -function scallopJitter(i) { - const x = Math.sin((i + 1) * 12.9898) * 43758.5453; - return x - Math.floor(x); -} - -/** A circle as `M`/`C`/`Z` commands, so it can join the bubble's one path. */ -function circleCommands(cx, cy, r) { - const k = r * KAPPA; - return [ - { type: 'M', x: cx + r, y: cy }, - { type: 'C', x1: cx + r, y1: cy + k, x2: cx + k, y2: cy + r, x: cx, y: cy + r }, - { type: 'C', x1: cx - k, y1: cy + r, x2: cx - r, y2: cy + k, x: cx - r, y: cy }, - { type: 'C', x1: cx - r, y1: cy - k, x2: cx - k, y2: cy - r, x: cx, y: cy - r }, - { type: 'C', x1: cx + k, y1: cy - r, x2: cx + r, y2: cy - k, x: cx + r, y: cy }, - { type: 'Z' }, - ]; -} - -/** Points around a rounded rectangle, clockwise from the top-left arc. */ -function roundedRectRing(box, radius) { - const { x, y, w, h } = box; - const r = clamp(radius, 0, Math.min(w, h) / 2); - const points = []; - const arc = (cx, cy, from, to) => { - const steps = 6; - for (let i = 1; i <= steps; i++) { - const t = from + (to - from) * (i / steps); - points.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r }); - } - }; - - points.push({ x: x + r, y }); - points.push({ x: x + w - r, y }); - arc(x + w - r, y + r, -Math.PI / 2, 0); - points.push({ x: x + w, y: y + h - r }); - arc(x + w - r, y + h - r, 0, Math.PI / 2); - points.push({ x: x + r, y: y + h }); - arc(x + r, y + h - r, Math.PI / 2, Math.PI); - points.push({ x, y: y + r }); - arc(x + r, y + r, Math.PI, Math.PI * 1.5); - return points; -} - -/** Total length of a closed polyline's perimeter. */ -function ringLength(points) { - let total = 0; - for (let i = 0; i < points.length; i++) { - total += distance(points[i], points[(i + 1) % points.length]); - } - return total; -} - -/** `count` points spaced evenly by arc length around a closed polyline. */ -function sampleRing(points, count) { - const n = points.length; - const total = ringLength(points); - if (!(total > 0) || !(count > 0)) return []; - const out = []; - let seg = 0; - let start = 0; - let length = n > 1 ? distance(points[0], points[1 % n]) : 0; - for (let i = 0; i < count; i++) { - const target = (total * i) / count; - while (seg < n - 1 && start + length < target) { - start += length; - seg += 1; - length = distance(points[seg], points[(seg + 1) % n]); - } - const t = length > 0 ? clamp((target - start) / length, 0, 1) : 0; - out.push(lerp(points[seg], points[(seg + 1) % n], t)); - } - return out; -} - -/** - * Replace part of the edge the tail points at with a spike. - * - * The spike is inserted *into* the outline, not appended to it: the two sides - * of the tail and the bubble become one continuous border. The straight edge - * nearest the tail's anchor is used, which for every shape except the shout is - * the obvious side and for the shout is the nearest zig-zag segment. - */ -function withTail(edges, box, state, scale) { - const tail = tailOf(state); - if (tail === 'none' || edges.length === 0) return edges; - - const tailLength = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5); - if (!(tailLength > 0)) return edges; - - const dir = tailDirection(tail); - // A diagonal tail leaves from a corner; an axis-aligned one leaves from the - // middle of the side it points at. - const anchor = { - x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2, - y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2, - }; - const diagonal = dir.x !== 0 && dir.y !== 0; - const normal = { - x: dir.x * (diagonal ? Math.SQRT1_2 : 1), - y: dir.y * (diagonal ? Math.SQRT1_2 : 1), - }; - - const index = nearestEdge(edges, anchor, box, tail); - if (index < 0) return edges; - - const edge = edges[index]; - const length = distance(edge.from, edge.to); - const base = Math.min(tailLength * 0.7, length * 0.45); - if (!(base > 0) || !(length > 0)) return edges; - - // The spike sits at the point of the edge nearest the anchor - the middle for - // a side, the corner for a diagonal - kept far enough in that both feet land - // on the edge itself. - const t = clamp(projectionT(edge, anchor), base / length, 1 - base / length); - const middle = lerp(edge.from, edge.to, t); - // A diagonal spike advances `tailLength` on *each* axis, so it reaches as far - // as a side tail does and reads as a proper corner. - const reach = tailLength * (diagonal ? Math.SQRT2 : 1); - const tip = { x: middle.x + normal.x * reach, y: middle.y + normal.y * reach }; - const leftFoot = lerp(edge.from, edge.to, t - base / length); - const rightFoot = lerp(edge.from, edge.to, t + base / length); - const replacement = [ - lineEdge(edge.from, leftFoot), - lineEdge(leftFoot, tip), - lineEdge(tip, rightFoot), - lineEdge(rightFoot, edge.to), - ]; - return edges.slice(0, index).concat(replacement, edges.slice(index + 1)); -} - -/** Index of the straight edge closest to the tail's anchor, or `-1`. */ -function nearestEdge(edges, anchor, box, tail) { - let best = -1; - let bestDistance = Infinity; - for (let i = 0; i < edges.length; i++) { - const edge = edges[i]; - if (edge.kind !== 'line') continue; - const middle = { x: (edge.from.x + edge.to.x) / 2, y: (edge.from.y + edge.to.y) / 2 }; - if (!onSide(middle, box, tail)) continue; - const d = distance(middle, anchor); - if (d < bestDistance) { - bestDistance = d; - best = i; - } - } - return best; -} - -/** Is `point` on the side of the box the tail points at? */ -function onSide(point, box, tail) { - const dir = tailDirection(tail); - const midX = box.x + box.w * 0.5; - const midY = box.y + box.h * 0.5; - if (dir.x < 0 && point.x > midX) return false; - if (dir.x > 0 && point.x < midX) return false; - if (dir.y < 0 && point.y > midY) return false; - if (dir.y > 0 && point.y < midY) return false; - return true; -} - -/** How far along `edge` (0..1) the point nearest `point` falls. */ -function projectionT(edge, point) { - const dx = edge.to.x - edge.from.x; - const dy = edge.to.y - edge.from.y; - const lengthSq = dx * dx + dy * dy; - if (!(lengthSq > 0)) return 0.5; - return ((point.x - edge.from.x) * dx + (point.y - edge.from.y) * dy) / lengthSq; -} - -/** One straight edge of a closed outline. */ -function lineEdge(from, to) { - return { kind: 'line', from, to }; -} - -/** One cubic edge of a closed outline. */ -function cubicEdge(from, c1, c2, to) { - return { kind: 'cubic', from, c1, c2, to }; -} - -/** Walk the edges into `M`/`L`/`C`/`Z` commands. */ -function edgesToCommands(edges) { - if (edges.length === 0) return []; - const commands = [{ type: 'M', x: edges[0].from.x, y: edges[0].from.y }]; - for (const edge of edges) { - if (edge.kind === 'line') { - commands.push({ type: 'L', x: edge.to.x, y: edge.to.y }); - } else { - commands.push({ - type: 'C', - x1: edge.c1.x, y1: edge.c1.y, - x2: edge.c2.x, y2: edge.c2.y, - x: edge.to.x, y: edge.to.y, - }); - } - } - commands.push({ type: 'Z' }); - return commands; -} - -/** Replay commands into a 2D context. */ -function applyCommands(ctx, commands) { - ctx.beginPath(); - for (const command of commands) { - if (command.type === 'M') ctx.moveTo(command.x, command.y); - else if (command.type === 'L') ctx.lineTo(command.x, command.y); - else if (command.type === 'C') { - ctx.bezierCurveTo(command.x1, command.y1, command.x2, command.y2, command.x, command.y); - } else if (command.type === 'Z') { - ctx.closePath(); - } - } -} - -/** Print commands as SVG path data. */ -function commandsToPathData(commands, places) { - const parts = []; - for (const command of commands) { - if (command.type === 'M') { - parts.push(`M${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`); - } else if (command.type === 'L') { - parts.push(`L${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`); - } else if (command.type === 'C') { - parts.push( - `C${formatNumber(command.x1, places)} ${formatNumber(command.y1, places)}` - + ` ${formatNumber(command.x2, places)} ${formatNumber(command.y2, places)}` - + ` ${formatNumber(command.x, places)} ${formatNumber(command.y, places)}`, - ); - } else if (command.type === 'Z') { - parts.push('Z'); - } - } - return parts.join(' '); -} - -// --- shared helpers ---------------------------------------------------------- - -/** The family list for a caption, or the system-only list before the font loads. */ -function fontFamilyStack(caption, loaded) { - const stack = captionFontStack(); - if (loaded && caption?.font !== 'system') return stack; - return dropLeadingFamily(stack); -} - -/** `captionFontStack()` minus its first family, for a plain system fallback. */ -function dropLeadingFamily(stack) { - let quote = null; - for (let i = 0; i < stack.length; i++) { - const ch = stack[i]; - if (quote) { - if (ch === '\\') i++; - else if (ch === quote) quote = null; - } else if (ch === "'" || ch === '"') { - quote = ch; - } else if (ch === ',') { - const rest = stack.slice(i + 1).trim(); - return rest === '' ? 'sans-serif' : rest; - } - } - return 'sans-serif'; -} - -/** Font size in output pixels, always positive and finite. */ -function resolveFontSize(caption, scale) { - const base = toPositive(caption?.fontSize, DEFAULT_FONT_SIZE); - return toPositive(base * positiveScale(scale), DEFAULT_FONT_SIZE); -} - -function bubbleOf(caption) { - const value = caption?.bubble; - if (value === 'round' || value === 'rect' || value === 'shout' - || value === 'whisper' || value === 'think' || value === 'none') return value; - return 'round'; -} - -function tailOf(caption) { - const value = caption?.tail; - return TAIL_VALUES.has(value) ? value : 'left'; -} - -const TAIL_VALUES = new Set([ - 'left', 'right', 'top', 'bottom', - 'topLeft', 'topRight', 'bottomLeft', 'bottomRight', - 'none', -]); - -/** - * Which way a tail leaves the box, as a direction where each axis is -1, 0 or - * 1 (0 for `none` or anything unknown). A diagonal tail carries a sign on both - * axes, so `bottomLeft` is `{ x: -1, y: 1 }`. - */ -function tailDirection(tail) { - switch (tail) { - case 'left': return { x: -1, y: 0 }; - case 'right': return { x: 1, y: 0 }; - case 'top': return { x: 0, y: -1 }; - case 'bottom': return { x: 0, y: 1 }; - case 'topLeft': return { x: -1, y: -1 }; - case 'topRight': return { x: 1, y: -1 }; - case 'bottomLeft': return { x: -1, y: 1 }; - case 'bottomRight': return { x: 1, y: 1 }; - default: return { x: 0, y: 0 }; - } -} - -function alignOf(caption) { - const value = caption?.align; - return value === 'center' || value === 'right' ? value : 'left'; -} - -function colorOf(value, fallback) { - return typeof value === 'string' && value !== '' ? value : fallback; -} - -/** Scale is a multiplier: anything that is not a positive number means 1. */ -function positiveScale(value) { - return Number.isFinite(value) && value > 0 ? value : 1; -} - -function toFinite(value, fallback) { - return Number.isFinite(value) ? value : fallback; -} - -function toPositive(value, fallback) { - return Number.isFinite(value) && value > 0 ? value : fallback; -} - -function toNonNegative(value, fallback) { - return Number.isFinite(value) && value >= 0 ? value : fallback; -} - -function clamp(value, low, high) { - if (high < low) return low; - return Math.min(Math.max(value, low), high); -} - -/** Decimal string for both `ctx.font` and SVG, never `NaN` and never `-0`. */ -function formatNumber(value, places = 2) { - if (!Number.isFinite(value)) return '0'; - const decimals = Number.isFinite(places) ? Math.max(0, Math.min(8, Math.floor(places))) : 2; - const factor = Math.pow(10, decimals); - const rounded = Math.round(value * factor) / factor; - return Object.is(rounded, -0) ? '0' : String(rounded); -} - -/** - * Escape character data. - * - * Quotes are escaped too even though element content allows them: the caption - * is user text, and the extra entities cost nothing next to never emitting a - * document a stricter parser could object to. - */ -function escapeText(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * Escape an attribute value. - * - * Only the double quote matters inside a double-quoted attribute, so single - * quotes survive and a family stack such as `'Hiragino Maru Gothic ProN', - * sans-serif` stays readable in the file. - */ -function escapeAttribute(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function distance(a, b) { - return Math.hypot(b.x - a.x, b.y - a.y); -} - -function lerp(a, b, t) { - return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; -} diff --git a/bluebey-studio/src/exporter.js b/bluebey-studio/src/exporter.js index b34b6f6..ea9356e 100644 --- a/bluebey-studio/src/exporter.js +++ b/bluebey-studio/src/exporter.js @@ -1,7 +1,6 @@ import { traceAlphaContours, contoursToPathData } from './trace.js'; import { roughenContours } from './handDrawn.js'; -import { captionToSvg } from './caption.js'; -import { EYE_LAYOUT, MOUTH_LAYOUT, HAIR_WRAP_LAYOUT } from './faceArt.js'; +import { EYE_LAYOUT, MOUTH_LAYOUT } from './faceArt.js'; /** * Export helpers. @@ -67,8 +66,8 @@ export async function capturePNG(view, { scale = 2, background, width, height }) /** * Encode a canvas as a PNG blob. Exported because the caller composites the - * backdrop and the caption onto the rendered canvas afterwards, and then has to - * re-encode it. + * backdrop and the 擬音 stamps onto the rendered canvas afterwards, and then has + * to re-encode it. */ export function canvasToBlob(canvas) { return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); @@ -97,8 +96,6 @@ export function buildSVG(view, { lineWidth = null, background = null, handDrawn = null, - captions = [], - captionFont = null, outlinePixels = 2, } = {}) { const previousStyle = styles.style; @@ -167,27 +164,13 @@ export function buildSVG(view, { : ''; const widthAttr = lineWidth ? ` stroke="${lineColor}" stroke-width="${lineWidth}"` : ''; - // The caption is authored against the viewport size, so it scales with the - // requested SVG width. Its bubble and text are emitted as real vector shapes. - // Every bubble is emitted as its own real vector shapes, one after the other. - let captionSvg = ''; - let captionUsedOutlines = captions.some(Boolean); - for (const caption of captions) { - if (!caption) continue; - const scale = width / Math.max(1, view.width || 1280); - const result = captionToSvg(caption, { width, height, scale, font: captionFont }); - captionSvg += result.svg; - if (result.usedOutlines === false) captionUsedOutlines = false; - } - return { contours: contours.length, - captionUsedOutlines, svg: ` ぶるべー 線画 ${rect} -${captionSvg} + `, }; } @@ -275,53 +258,6 @@ export function buildFaceMap(face, kind) { return canvas; } -/** - * The 髪の下地 (a base to draw hair on). - * - * The hair plate is a cylinder unrolled: `x` is the angle round the head, with - * the face at the middle of the artwork window and the seam at the back at the - * two edges, and `y` is the height, top of the head first. The current hair is - * copied in as a faint reference, and guides mark the window, the front centre - * and the height the default まえがみ hairline reaches. - * - * @param {import('./face.js').Face} face - * @returns {HTMLCanvasElement|null} - */ -export function buildHairMap(face) { - const layout = face.hairLayout; - const plate = face.hairCanvas; - if (!layout || !plate) return null; - // The plate's UVs were rewritten to fill 0..1, so the whole canvas is the - // texture (unlike the eye/mouth maps, which keep a window inside a wider - // canvas). Every guide is therefore measured straight against the canvas. - const canvas = document.createElement('canvas'); - canvas.width = plate.width; - canvas.height = plate.height; - const ctx = canvas.getContext('2d'); - ctx.drawImage(plate, 0, 0); - - // White paper under the copy, so a redraw starts from an opaque base. - ctx.globalCompositeOperation = 'destination-over'; - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.globalCompositeOperation = 'source-over'; - - ctx.strokeStyle = FACE_MAP_GUIDE; - ctx.fillStyle = FACE_MAP_GUIDE; - ctx.lineWidth = 3; - - // The front of the head sits at the middle of the artwork window, which is - // where `drawHeadCoverWrap` puts the face, so this vertical line is the - // nose-to-backbone plane. The canvas's own left/right edges are the seam at - // the back of the head. - const fx = (layout.offsetX + layout.window.width * 0.5) * (canvas.width / layout.width); - guideLine(ctx, fx, 0, fx, canvas.height); - // The default hairline (大きさ 1) runs across the whole wrap at this height. - guideLine(ctx, 0, HAIR_WRAP_LAYOUT.base * canvas.height, canvas.width, HAIR_WRAP_LAYOUT.base * canvas.height); - - return canvas; -} - /** One guide segment, kept out of the map builders so the placements stay readable. */ function guideLine(ctx, x0, y0, x1, y1) { ctx.beginPath(); diff --git a/bluebey-studio/src/face.js b/bluebey-studio/src/face.js index cec7619..f42b823 100644 --- a/bluebey-studio/src/face.js +++ b/bluebey-studio/src/face.js @@ -1,11 +1,5 @@ import * as THREE from 'three'; -import { drawEyes, drawMouth, drawHeadCoverWrap, canvasLayout } from './faceArt.js'; - -// The hair plate's texture is drawn at this multiple of the plate's natural UV -// size. The hairline crosses the head over only a fraction of that height, so at -// 1x its edge comes out visibly jagged; doubling it (with the finer curve -// subdivision in `drawHeadCoverWrap`) keeps the edge smooth. -const HAIR_SCALE = 2; +import { drawEyes, drawMouth, canvasLayout } from './faceArt.js'; /** * Owns the two procedural textures (eyes and mouth) and keeps them in sync with @@ -15,8 +9,8 @@ const HAIR_SCALE = 2; * of the *whole* front half of the body, so their UVs cover far more than the * 0..1 window the drawing lives in. `remapPlate` rewrites each plate's UVs onto * 0..1 and the canvas is enlarged to match, which is what removes the - * clamp-to-edge smear at the border and what gives a moved tear, brow or mouth - * room to move (see `canvasLayout` in src/faceArt.js). + * clamp-to-edge smear at the border and what gives a moved tear or mouth room to + * move (see `canvasLayout` in src/faceArt.js). * * Both parts can independently fall back to the original hand-drawn texture * that ships inside the GLB. @@ -25,16 +19,16 @@ export class Face { constructor({ eyeMesh, mouthMesh, hairMesh = null, originals }) { this.eyeMesh = eyeMesh; this.mouthMesh = mouthMesh; - // 頭の模様 can be carried by a plate that wraps right round the head (the - // `hair-plate`), so hair is visible from behind as well. Older models have no - // such plate and draw the covering on the front one instead. + // The `hair-plate`, if the model has one, is never drawn: ぶるべー has no + // hair. Hiding the mesh outright (rather than showing an empty texture) keeps + // it from tinting the head on platforms where transparent textures do. this.hairMesh = hairMesh; + if (hairMesh) hairMesh.visible = false; // Rewrite each plate's UVs so its own range fills 0..1, and work out the // canvas that keeps the artwork exactly where the 2022 textures put it. this.eyeLayout = remapPlate(eyeMesh); this.mouthLayout = remapPlate(mouthMesh); - this.hairLayout = hairMesh ? remapPlate(hairMesh) : null; // The hand-drawn eye textures bake the face colour into their background, // which is invisible in the lit "real" style but shows up as a flat purple @@ -49,21 +43,13 @@ export class Face { }; this.customNames = new Map(); - // Drawings supplied for a beard kind (assets/beards/.png). Loaded by the - // app; a kind without one falls back to the built-in drawing (see drawBeard). - this.beardImages = {}; - // Drawings for the brows (assets/brows/.png), loaded the same way. - this.browImages = {}; this.eyeCanvas = makeCanvas(this.eyeLayout); this.mouthCanvas = makeCanvas(this.mouthLayout); - this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout, HAIR_SCALE) : null; this.eyeCtx = this.eyeCanvas.getContext('2d'); this.mouthCtx = this.mouthCanvas.getContext('2d'); - this.hairCtx = this.hairCanvas ? this.hairCanvas.getContext('2d') : null; this.eyeTexture = makeTexture(this.eyeCanvas); this.mouthTexture = makeTexture(this.mouthCanvas); - this.hairTexture = this.hairCanvas ? makeTexture(this.hairCanvas) : null; this.params = null; this.styleMode = 'paint'; @@ -123,10 +109,6 @@ export class Face { this.mouthMesh.material.map = mouthMap; this.mouthMesh.material.needsUpdate = true; } - if (this.hairMesh && this.hairMesh.material.map !== this.hairTexture) { - this.hairMesh.material.map = this.hairTexture; - this.hairMesh.material.needsUpdate = true; - } } /** @@ -155,25 +137,6 @@ export class Face { return 'original'; } - /** - * Set the drawings used for the beards, keyed by beard kind (`scotch`, `kaiser`, - * `apron`, `cat`). Called by the app once the files are loaded; the next redraw - * picks them up. - */ - setBeardImages(map) { - this.beardImages = map ?? {}; - this.dirty = true; - } - - /** - * Set the drawings used for the brows, keyed by brow kind (`gol`). Called by the - * app once the files are loaded; the next redraw picks them up. - */ - setBrowImages(map) { - this.browImages = map ?? {}; - this.dirty = true; - } - /** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */ setSource(kind, source) { if (kind === 'eyes') { @@ -248,19 +211,6 @@ export class Face { lidShape: p.eyes.lidShape, lidTilt: p.eyes.lidTilt, lashes: p.eyes.lashes, - brow: p.eyes.brow, - // ほっぺ and 頭の模様 are shared by the whole face, so they travel with - // the other shared eye fields (see `drawEyes` in src/faceArt.js). - cheeks: p.eyes.cheeks, - // When the model has a wrapping hair plate the covering is drawn there - // instead (below), so it is switched off on the front plate to keep it - // from being drawn twice. - headMark: this.hairMesh ? { shape: 'off' } : p.eyes.headMark, - // ひげ is shared by the whole face as well. (鼻ちょうちん is a real 3D - // object hung off the nose - see src/main.js - so it is not drawn here.) - beard: p.eyes.beard, - beards: this.beardImages, - brows: this.browImages, // 眼鏡 / サングラス are shared by both eyes and drawn into this same // texture, so they travel with the other shared eye fields. glasses: p.eyes.glasses, @@ -287,34 +237,6 @@ export class Face { this.mouthTexture.needsUpdate = true; } - if (this.hairMesh) { - const hairShape = p.eyes.headMark?.shape ?? 'off'; - // Shapes that draw nothing (the retired front-plate ones, or なし). A fully - // transparent texture still tinted the head on iOS, so the plate is hidden - // outright when there is no hair rather than left showing an empty texture. - const hairShown = !(hairShape === 'off' || hairShape === 'none' - || hairShape === 'split' || hairShape === 'wave' || hairShape === 'side'); - this.hairMesh.visible = hairShown; - if (hairShown) { - // `overEyes` decides the draw order: the hair plate sits in front of the - // eyes (bangs over the face) or behind them. - this.hairMesh.renderOrder = p.eyes.headMark?.overEyes === false ? 0 : 3; - // The colour lives on the material (the texture is a white mask), so it - // follows the same colour management as the body on every platform. - this.hairMesh.material.color.set(p.eyes.headMark?.color ?? '#8a4fe0'); - const layout = this.hairLayout; - const ctx = this.hairCtx; - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, layout.width * HAIR_SCALE, layout.height * HAIR_SCALE); - ctx.scale(HAIR_SCALE, HAIR_SCALE); - // `drawHeadCoverWrap` works in canvas coordinates (its x axis is the UV - // angle already normalised), so it is not shifted by the window offset. - drawHeadCoverWrap(ctx, p.eyes.headMark ?? {}, layout); - ctx.setTransform(1, 0, 0, 1, 0, 0); - this.hairTexture.needsUpdate = true; - } - } - this.dirty = false; } } diff --git a/bluebey-studio/src/faceArt.js b/bluebey-studio/src/faceArt.js index 8537fd8..4aaa2d6 100644 --- a/bluebey-studio/src/faceArt.js +++ b/bluebey-studio/src/faceArt.js @@ -98,16 +98,6 @@ export const EYE_LAYOUT = { ], lidRadiusFactor: 1.35, lidStroke: 14, - // Eyebrows are painted into the same texture as the eyes. The eye plane is a - // flat-ish patch on a round head, so only its lower part is really outside - // the head: a brow high on the forehead would be swallowed by it. The brow - // therefore sits just above the eyeball (row 156) and the eyeball is drawn - // over its lower edge afterwards. - // The brow sits just above the eyeball, and the eyeball is drawn over its - // lower edge. `lift` is that resting height, so `height: 0` is the brow's - // natural place and a POSITIVE height raises it (a negative one sinks it - // towards the eye, where the eyeball will cover it). - brow: { lift: 114, length: 134, thickness: 15, curve: 0.14, offsetX: 6 }, // A teardrop hangs well below the eye. The plate keeps the whole canvas, so // the drop can sit low without being clipped (measured with a ruler grid). tear: { size: 34, offsetX: 66, offsetY: 88 }, @@ -137,90 +127,13 @@ export const EYE_LAYOUT = { irisInward: 7, }; -/** - * ほっぺ (a manga blush) and 頭の模様 (a mark on the head), both drawn into the eye - * plate next to the eyes and brows. - * - * They are deliberately *generic* manga devices, not a copy of any one character: - * a pink patch crossed by short diagonal strokes is a stock convention, and the - * head mark is one of a few simple shapes (a hook, a spiral, three strokes, a dot - * row) that starts off. Both stay editable through the panel. The cheek positions - * are tied to `EYE_LAYOUT` so they sit beside the eyes; the head mark sits up in - * the plate's upper area, i.e. on the forehead above the brows. - */ -export const CHEEK_LAYOUT = { - drop: 64, // how far below the eye centre the patch sits (artwork px) - outward: 66, // how far outwards, away from the midline, it sits - radius: 50, // half-width of the patch - squash: 0.55, // half-height, as a fraction of the half-width (a flat ellipse) - // The blush is *always* four strokes - the reference artwork has four - so - // there is no line-count control any more. The strokes are horizontal and - // hand-drawn: each wobbles a little and tapers to a point at both ends. - lines: 4, - lineWidth: 10, // the stroke thickness at its widest (artwork px) - spread: 0.62, // how far the outermost stroke sits from the centre (x half-height) - margin: 0.18, // clear gap left between a stroke's end and the patch outline - wobble: 0.32, // sideways wander, as a fraction of the stroke thickness -}; - -/** - * 頭の模様: a *hair-like covering* over the whole head, not a small forehead symbol. - * - * The face plate is a shell of the *whole* front half of the body, so the plate's - * own edge is the head's front silhouette. Filling the plate from its top edge - * down to an adjustable boundary therefore makes the covering's outline follow - * the silhouette for free - there is no separate ellipse to keep in step. - * - * It is a generic device: a colour cap, a fringe, a side part, or a left/right - * two-tone split. It is deliberately *not* a copy of any one character's hair. - * It starts off. - */ -export const HEAD_MARK_LAYOUT = { - // Where the boundary sits by default: on the forehead, just above the brows - // (EYE_LAYOUT.eyes[0].cy is the eye row and the brows reach ~114px above it). - cy: 120, - reach: 190, // artwork px the boundary moves per unit of `size` -}; - -/** - * 頭の模様 on the *hair plate*: the head's cover when the model carries a plate - * that wraps all the way round (material `hair-plate`, cylindrical UV). - * - * The front plate only reaches the head's silhouette, so hair drawn on it stops - * at the front half. The hair plate's UV is `u` = angle round the head (front at - * 0.5, back at the seam 0/1) and `v` = height (0 at the model's feet, 1 at the - * top of the head), so the covering is everything *above* a boundary line: a cap - * that is visible from behind as well. - */ -export const HAIR_WRAP_LAYOUT = { - // The wrap's v runs top-of-head (0) to the model's feet (1) after the GLB's - // V-flip, so `base` is how far down from the top the hairline sits at size 1. - // `reach` is how much a unit of `size` moves it; `offsetScale` converts the - // `offsetY` slider (artwork px) to the same units, so one slider drives both. - base: 0.30, - reach: 0.30, - offsetScale: 0.30 / 190, -}; - -/** - * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate - * at the nose, the same spot the snot bubble uses. `shape` picks a well-known - * kind; `size` scales it about the nose, and the colours are adjustable. - */ -export const BEARD_LAYOUT = { - cx: 511.5, - cy: 352, // just under the nose - width: 240, // base width of the widest beard at size 1 -}; /** * 眼鏡 / サングラス, drawn into the same texture as the eyes and brows. * * Not measured off any artwork - the character has no glasses - but the numbers * are tied to the eyeball so a lens is always a little wider than the eye it - * covers. The lens centre sits *below* the eye centre because the brow is drawn - * first, just above the eyeball (see `EYE_LAYOUT.brow`): a lens wide enough to - * read as glasses would otherwise cut straight through it. + * covers. * * The two kinds get separate shapes on purpose. They used to share one ellipse * and differ only in how dark the lens was filled, which made them hard to tell @@ -679,489 +592,10 @@ function drawShutEye(ctx, eye, spec, style, shapeScale = 1, maxHalf = Infinity) strokePolyline(ctx, points, { color: line, width }); } -/** - * A stroke whose width runs from `w0` at the first point to `w1` at the last. - * - * Used for a ゴルゴ13-style brow: a filled wedge that comes to a point at one end - * reads as a heavy eyebrow, where a constant-width stroke reads as a soft arc. - */ -function taperedStroke(ctx, points, w0, w1, color) { - const count = points.length; - if (count < 2) return; - const side = [[], []]; - for (let i = 0; i < count; i++) { - const t = i / (count - 1); - const w = (w0 + (w1 - w0) * t) / 2; - const before = points[Math.max(0, i - 1)]; - const after = points[Math.min(count - 1, i + 1)]; - const dx = after.x - before.x; - const dy = after.y - before.y; - const len = Math.hypot(dx, dy) || 1; - const nx = -dy / len; - const ny = dx / len; - side[0].push({ x: points[i].x + nx * w, y: points[i].y + ny * w }); - side[1].push({ x: points[i].x - nx * w, y: points[i].y - ny * w }); - } - ctx.save(); - tracePolyline(ctx, [...side[0], ...side[1].reverse()], true); - ctx.fillStyle = color; - ctx.fill(); - ctx.restore(); -} - -/** An eyebrow: a short arc above the eye, mirrored between the two eyes so a - * positive `angle` always means "inner end down" (an angry brow). */ -function drawBrow(ctx, eye, spec, style, limits = WINDOW_LIMITS) { - const brow = style.brow; - if (!brow?.enabled) return; - const layout = EYE_LAYOUT.brow; - // 0 is allowed: a zero-length stroke with round caps is a dot, which is what a - // length of 0 is asking for. - const length = Math.max(0, (brow.length ?? 1) * layout.length); - const thickness = Math.max(1, (brow.thickness ?? 1) * layout.thickness); - const cy = eye.cy - layout.lift - (brow.height ?? 0) - (spec.browHeight ?? 0); - // `spacing` widens the gap between the two brows: a positive value moves each - // one away from the nose. `layout.offsetX` is the resting inset from the - // artwork (which the old code read off the wrong object, so it never applied). - const cx = eye.cx + eye.towardNose * (layout.offsetX - (brow.spacing ?? 0)); - - // A supplied drawing (assets/brows/gol-right.png, the model's right brow only) - // replaces the strokes: like the textured ひげ it is drawn twice, mirrored, at - // `cx`, and tinted with the brow colour. - const entry = style.brows?.gol; - if (brow.image && entry?.image) { - const sprite = tintedSprite(entry.image, brow.color ?? '#2a1e33'); - const sw = sprite.width || 1; - const sh = sprite.height || 1; - const drawW = Math.max(4, length); - const drawH = drawW * (sh / sw); - ctx.save(); - ctx.translate(cx, cy); - // The drawing is the model's right brow, so the other eye gets it mirrored. - ctx.scale(eye.towardNose >= 0 ? 1 : -1, 1); - ctx.drawImage(sprite, -drawW / 2, -drawH / 2, drawW, drawH); - ctx.restore(); - return; - } - const angle = (((brow.angle ?? 0) + (spec.browAngle ?? 0)) * eye.towardNose * Math.PI) / 180; - const half = length / 2; - const from = { x: cx - Math.cos(angle) * half, y: cy - Math.sin(angle) * half }; - const to = { x: cx + Math.cos(angle) * half, y: cy + Math.sin(angle) * half }; - const bow = (brow.curve ?? layout.curve) * length; - const points = quadraticPoints(from, { x: cx, y: cy - bow }, to, 16); - // A brow raised with the height slider used to run off the top of the canvas, - // where the clamped edge row was copied across the whole plate and the brow - // smeared upwards. Keep the whole stroke inside the artwork instead. - const top = limits.top + EDGE_MARGIN; - const highest = Math.min(...points.map((q) => q.y)) - thickness / 2; - if (highest < top) for (const q of points) q.y += top - highest; - // `taper` is how wide the *inner* (nose-side) end is: 1 = a plain stroke, and - // lower values turn the brow into a wedge that comes to a point at the nose. - const taper = clamp(brow.taper ?? 1, 0, 1); - const color = brow.color ?? style.line ?? '#55386e'; - // A tapered wedge has no shape at zero length, so a dot always takes the plain - // round-capped stroke. - if (taper >= 0.999 || length < 2) { - strokePolyline(ctx, points, { color, width: thickness }); - return; - } - const innerIsTo = eye.towardNose > 0; - taperedStroke( - ctx, - points, - thickness * (innerIsTo ? 1 : taper), - thickness * (innerIsTo ? taper : 1), - color, - ); -} - -/* ------------------------------------------------------- ほっぺ / 頭の模様 */ - -/** - * ほっぺ: a soft pink patch crossed by short diagonal hatch strokes, the classic - * manga blush. Drawn once per cheek; the caller draws these before the eyes. - */ -function drawCheek(ctx, cx, cy, spec) { - const size = clamp(spec.size ?? 1, 0.2, 3); - const r = CHEEK_LAYOUT.radius * size; - const ry = r * CHEEK_LAYOUT.squash; - - ctx.save(); - ctx.beginPath(); - ctx.ellipse(cx, cy, r, ry, 0, 0, TAU); - ctx.fillStyle = spec.color ?? '#f6a6b8'; - ctx.fill(); - // Clip the hatching to the patch, so a wobbly stroke keeps a clean edge. - ctx.clip(); - - // Four horizontal strokes, hand-drawn rather than ruled: each tapers to a - // point at both ends and wanders a little. Each one stops short of the patch - // outline - the margin is measured against the patch's own width at that - // height, so a stroke on the narrow top or bottom cannot reach the edge. - const thickness = Math.max(1.5, CHEEK_LAYOUT.lineWidth * size); - ctx.fillStyle = spec.hatchColor ?? '#e0708f'; - for (let i = 0; i < CHEEK_LAYOUT.lines; i += 1) { - const t = CHEEK_LAYOUT.lines === 1 ? 0 : (i / (CHEEK_LAYOUT.lines - 1)) * 2 - 1; // -1..1 - const dy = t * ry * CHEEK_LAYOUT.spread; - const chord = r * Math.sqrt(Math.max(0, 1 - (dy / ry) ** 2)); - const half = chord - r * CHEEK_LAYOUT.margin; - if (half < 4) continue; - traceHandHatch(ctx, cx, cy + dy, half, thickness, i * 1.9); - } - ctx.restore(); -} - -/** - * One hand-drawn hatch stroke: a lens-shaped mark that tapers to a point at both - * ends, with a gentle wobble so it reads as drawn rather than printed. - */ -function traceHandHatch(ctx, cx, cy, half, thickness, seed) { - const steps = 12; - const top = []; - const bottom = []; - for (let i = 0; i <= steps; i += 1) { - const u = i / steps; - const x = cx + (u - 0.5) * 2 * half; - const wobble = Math.sin(u * Math.PI * 1.6 + seed) * thickness * CHEEK_LAYOUT.wobble; - const w = (thickness / 2) * Math.sin(u * Math.PI); // 0 at both ends - top.push({ x, y: cy + wobble - w }); - bottom.push({ x, y: cy + wobble + w }); - } - ctx.beginPath(); - ctx.moveTo(top[0].x, top[0].y); - for (const point of top.slice(1)) ctx.lineTo(point.x, point.y); - for (let i = bottom.length - 1; i >= 0; i -= 1) ctx.lineTo(bottom[i].x, bottom[i].y); - ctx.closePath(); - ctx.fill(); -} - -/** The pair of cheeks: symmetric about the face's midline (see `EYE_LAYOUT`). */ -function drawCheeks(ctx, spec) { - if (!spec?.enabled) return; - const drop = CHEEK_LAYOUT.drop + clamp(spec.offsetY ?? 0, -400, 400); - const outward = CHEEK_LAYOUT.outward + clamp(spec.spacing ?? 0, -400, 400); - for (const layout of EYE_LAYOUT.eyes) { - // Outwards is away from the midline, i.e. the opposite of `towardNose`. - const cx = layout.cx - layout.towardNose * outward; - drawCheek(ctx, cx, layout.cy + drop, spec); - } -} - -/** - * 頭の模様: a hair-like covering over the whole head. - * - * The face plate is a shell of the front half of the body, so its own edge is - * the head's silhouette. This fills the plate from its top edge down to an - * adjustable boundary, which is what makes the covering follow the head outline - * without knowing the head's shape ahead of time. The boundary style, colour and - * size are all adjustable, and one style (`split`) divides the head into two - * colours along a vertical curve - a generic "two-tone head" device. It is not a - * copy of any particular character's hair. - */ -function drawHeadCover(ctx, spec, limits) { - const shape = spec?.shape ?? 'off'; - if (shape === 'off' || shape === 'none' || shape === 'wave' || shape === 'side') return; - - const size = clamp(spec.size ?? 1, 0.02, 3); - const left = limits.left; - const right = limits.right; - const top = limits.top; - const bottom = limits.bottom; - const width = right - left; - const cx = (left + right) / 2 + clamp(spec.offsetX ?? 0, -600, 600); - const color = spec.color ?? '#8a4fe0'; - const color2 = spec.color2 ?? '#ffffff'; - - ctx.save(); - // Stay on the plate: nothing may spill past the artwork's real edges. - ctx.beginPath(); - ctx.rect(left, top, width, bottom - top); - ctx.clip(); - - if (shape === 'split') { - // Retired: the two-tone head was dropped, but an old saved state may still - // carry the shape, so treat it as off rather than drawing a two-tone head. - ctx.restore(); - return; - } - - // The boundary curve. `t` runs -1..1 across the plate, so each style is a - // small shape function; the covering is everything above it. - const base = HEAD_MARK_LAYOUT.cy + (size - 1) * HEAD_MARK_LAYOUT.reach - + clamp(spec.offsetY ?? 0, -600, 600); - const halfSpan = width / 2; - const boundary = (x) => { - const t = clamp((x - cx) / halfSpan, -1, 1); - // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front - // gives the teeth; `teeth` is how far they reach down (its own slider), so - // the 大きさ slider only moves the hairline up and down. - if (shape === 'fringe') { - const teeth = 8; - const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle - return base + (spec.teeth ?? 0.12) * 600 * saw; - } - // はちわれ: the same V upside down - a widow's peak. - if (shape === 'fringe-up') return base - 320 * Math.max(0, 1 - Math.abs(t) * 1.5); - // カーブ: a plain shallow curve, higher (more hair) round the sides. - if (shape === 'curve') return base + 160 * t * t; - // `cap`: a rounded helmet, lowest in the middle. - return base - 100 * (1 - t * t); - }; - - const steps = 48; - ctx.beginPath(); - ctx.moveTo(left, top); - ctx.lineTo(right, top); - for (let i = steps; i >= 0; i -= 1) { - const x = left + (width * i) / steps; - ctx.lineTo(x, boundary(x)); - } - ctx.closePath(); - ctx.fillStyle = color; - ctx.fill(); - ctx.restore(); -} - -/** - * 頭の模様 drawn onto the hair plate, which wraps right round the head. - * - * The plate's canvas is the cylindrical UV unrolled: `x` is the angle (front at - * the middle, the seam at the back at both ends) and `y` is the height. The GLB - * flips V on export, so the top of the head lands at the top of the canvas and - * the covering is everything above the boundary line - the same direction as - * `drawHeadCover`, which fills down from the top edge of the front plate. - * - * The same `shape` names are reused, with their front-to-back modulation tied to - * how far the column leans towards the front (`t`). - */ -export function drawHeadCoverWrap(ctx, spec, layout) { - const shape = spec?.shape ?? 'off'; - // `split`, `wave` and `side` are retired front-plate shapes; they have no wrap - // equal. An old saved state may still carry one, so it simply draws no hair. - if (shape === 'off' || shape === 'none' || shape === 'split' || shape === 'wave' || shape === 'side') return; - - const size = clamp(spec.size ?? 1, 0.02, 3); - const W = layout.width; - const H = layout.height; - // The horizontal axis is the angle. The *front* sits at u = 0.5, which is the - // middle of the artwork window (1024), not the middle of the canvas: after the - // seam heal the canvas is wider than the window, so using W/2 put the pattern - // a good way off centre. - const span = (layout.window ?? ART_WINDOW).width; - // After the GLB's V-flip the wrap's v is 0 at the top of the head and 1 at the - // model's feet, so a *larger* boundary means more hair (it reaches further - // down) - the same direction as `size` and `offsetY` on the front plate. - const base = HAIR_WRAP_LAYOUT.base - + (size - 1) * HAIR_WRAP_LAYOUT.reach - + clamp(spec.offsetY ?? 0, -600, 600) * HAIR_WRAP_LAYOUT.offsetScale; - ctx.save(); - ctx.beginPath(); - ctx.rect(0, 0, W, H); - ctx.clip(); - // `t` is -1..1 across the front (+-90 deg around the front); everything behind - // is pinned to +-1, so the hairline runs flat round the back of the head. - const half = span * 0.25; - const cx = layout.offsetX + span * 0.5 + clamp(spec.offsetX ?? 0, -600, 600); - const vBound = (x) => { - const t = clamp((x - cx) / half, -1, 1); - // まえがみ: 富士額 / M字 (a widow's peak). The hairline comes down at the - // centre (the peak) and at the temples, with the corners between receded - - // the three peaks and two valleys of an "M". `t` is -1..1 across the front. - if (shape === 'fringe') { - // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front - // gives the teeth; `teeth` is how far they reach down (its own slider), so - // the 大きさ slider only moves the hairline up and down. - const teeth = 8; - const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle - return base + (spec.teeth ?? 0.12) * saw; - } - // はちわれ: the same V upside down - a widow's peak. - if (shape === 'fringe-up') return base - 0.18 * Math.max(0, 1 - Math.abs(t) * 1.5); - // カーブ: a deep round cover - low at the sides, high in the middle, like the - // blue of a certain robot cat's head. - if (shape === 'curve') return base + 0.42 * t * t; - return base - 0.07 * (1 - t * t); // `cap` - }; - const steps = Math.max(96, Math.ceil(W / 2)); - ctx.beginPath(); - ctx.moveTo(0, 0); - for (let i = 0; i <= steps; i += 1) { - const x = (W * i) / steps; - ctx.lineTo(x, clamp(vBound(x), 0, 1) * H); - } - ctx.lineTo(W, 0); - ctx.closePath(); - // A white mask: the colour is applied by the hair plate's material, so it goes - // through the same colour path as the body instead of being baked into the - // canvas (which shaded differently on iOS). - ctx.fillStyle = '#ffffff'; - ctx.fill(); - ctx.restore(); -} -/** - * The shared extras (ほっぺ and 頭の模様) are drawn once per face, before the eyes, - * so the eyes, brows and glasses win any overlap and the cheeks are not doubled. - */ -function drawFaceExtras(ctx, style, lineOnly) { - drawCheeks(ctx, style.cheeks); - // The head covering fills out to the plate's real edges, so it needs `limits`. - drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS); - drawBeard(ctx, style.beard, lineOnly, style.beards); -} - -const BEARD_TINT_IDS = new WeakMap(); -const BEARD_TINT_CACHE = new Map(); -let beardTintSeq = 0; - -/** - * A copy of a beard drawing in the chosen colour. - * - * The supplied files are drawn in near-black, so repainting the ink with the - * beard colour (`source-in` keeps the anti-aliased alpha) is what lets the colour - * picker work on a drawing. Cached per (sprite, colour). - */ -function tintedSprite(image, color) { - let id = BEARD_TINT_IDS.get(image); - if (!id) { - beardTintSeq += 1; - id = beardTintSeq; - BEARD_TINT_IDS.set(image, id); - } - const key = `${id}|${color}`; - const cached = BEARD_TINT_CACHE.get(key); - if (cached) return cached; - const canvas = document.createElement('canvas'); - canvas.width = image.width; - canvas.height = image.height; - const ctx = canvas.getContext('2d'); - ctx.drawImage(image, 0, 0); - ctx.globalCompositeOperation = 'source-in'; - ctx.fillStyle = color; - ctx.fillRect(0, 0, canvas.width, canvas.height); - BEARD_TINT_CACHE.set(key, canvas); - return canvas; -} - -/** - * ひげ: a mustache or beard under the nose. Each kind is a small shape built on - * the spot; `scotch` and `cat` are drawn with round-capped strokes, and `kaiser` - * is a thick waving band with a curl at each tip. A kind that ships a drawing in - * `assets/beards/` is drawn from that instead (see `textures`), tinted with the - * beard colour. In a line drawing nothing is filled and only the outlines are inked. - */ -function drawBeard(ctx, spec, lineOnly = false, textures = null) { - const shape = spec?.shape ?? 'off'; - if (shape === 'off' || shape === 'none') return; - const size = clamp(spec.size ?? 1, 0.2, 3); - const cx = BEARD_LAYOUT.cx; - const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600); - const w = BEARD_LAYOUT.width * size; - const color = spec.color ?? '#3a2a4a'; - - // A supplied drawing for this kind (assets/beards/.png) replaces the - // built-in strokes. The drawings are trimmed to their ink, so `w` is the - // beard's real width; it is centred on the nose and scaled by `size`, and it is - // tinted with the beard colour (the files are drawn in near-black). - const entry = textures?.[shape]; - if (entry?.image) { - const sprite = tintedSprite(entry.image, color); - // Slightly wider than the built-in base: a moustache drawn to the same width - // as a beard's stroke sat mostly behind the nose. - const drawW = w * 1.4; - if (entry.half) { - // One side, mirrored to make the pair. The drawing is the model's own right - // side, which sits in the artwork's left half; `spacing` opens the middle - // (a negative value brings the two halves together). - const halfW = drawW / 2; - const dh = (halfW * (sprite.height || 1)) / (sprite.width || 1); - const gap = clamp(spec.spacing ?? 0, -400, 800); - const y = cy - dh / 2; - ctx.drawImage(sprite, cx - gap - halfW, y, halfW, dh); - ctx.save(); - ctx.translate(cx + gap, cy); - ctx.scale(-1, 1); - // Draw into [-halfW, 0] so that, mirrored, it lands in [cx+gap, cx+gap+halfW]. - ctx.drawImage(sprite, -halfW, -dh / 2, halfW, dh); - ctx.restore(); - return; - } - const dh = (drawW * (sprite.height || 1)) / (sprite.width || 1); - ctx.drawImage(sprite, cx - drawW / 2, cy - dh / 2, drawW, dh); - return; - } - - const line = spec.line ?? '#55386e'; - const lw = Math.max(2, 9 * size); - const stroke = (points, width) => strokePolyline(ctx, points, { - color: lineOnly ? line : color, width, - }); - - if (shape === 'scotch') { - // ちょび髭: two short dashes under the nose, angled down and out so their tips - // clear the nose mesh (which hides anything drawn straight behind it). - stroke([{ x: cx - w * 0.07, y: cy + w * 0.02 }, { x: cx - w * 0.5, y: cy + w * 0.17 }], lw * 1.8); - stroke([{ x: cx + w * 0.07, y: cy + w * 0.02 }, { x: cx + w * 0.5, y: cy + w * 0.17 }], lw * 1.8); - return; - } - if (shape === 'kaiser') { - // カイゼル: a full, thick mustache whose ends turn up. - stroke([ - { x: cx - w * 0.5, y: cy - w * 0.02 }, - { x: cx - w * 0.28, y: cy + w * 0.07 }, - { x: cx, y: cy + w * 0.09 }, - { x: cx + w * 0.28, y: cy + w * 0.07 }, - { x: cx + w * 0.5, y: cy - w * 0.02 }, - ], lw * 2.6); - const curlAt = (dir) => { - // A little inward-turning spiral at the tip: the Dalí curl. - const ex = cx + dir * w * 0.5; - const ey = cy - w * 0.02; - const r0 = lw * 0.5; - const r1 = w * 0.17; - const steps = 22; - const points = []; - for (let i = 0; i <= steps; i += 1) { - const t = i / steps; - const a = -Math.PI / 2 + dir * Math.PI * 2 * 1.4 * t; - const r = r0 + (r1 - r0) * t; - points.push({ x: ex + dir * Math.cos(a) * r, y: ey + Math.sin(a) * r }); - } - stroke(points, lw * 1.25); - }; - curlAt(-1); - curlAt(1); - return; - } - if (shape === 'apron') { - // A filled bib was once a kind here; kept as a no-op so a saved state that - // still names it simply shows nothing rather than throwing. - return; - } - // ねこ: three whiskers a side, growing out of the cheeks with a gap left in the - // middle (like a cat's). The thickness is fixed, so `size` makes the whiskers - // longer and wider-spread without making them fatter, and `spacing` opens the - // gap between the left and right. - const whisker = 8; - const spacing = clamp(spec.spacing ?? 0, 0, 800); - const inner = w * 0.30 + spacing; - // `length` scales how far each whisker reaches out; `lineGap` opens the spacing - // between the three lines of one side. Neither changes the line's thickness. - const length = clamp(spec.length ?? 1, 0.2, 3); - const gap = w * 0.16 * clamp(spec.lineGap ?? 1, 0.2, 3); - const outer = inner + w * 0.38 * length; - for (const dir of [-1, 1]) { - for (let i = 0; i < 3; i += 1) { - stroke([ - { x: cx + dir * inner, y: cy + (i - 1) * gap }, - { x: cx + dir * outer, y: cy + (i - 1) * gap * 1.5 }, - ], whisker); - } - } -} /** * One lens of a pair of 眼鏡 / サングラス, plus this eye's half of the bridge and its @@ -1382,14 +816,10 @@ export function drawEyes(ctx, p) { const lensOpaque = style.glasses?.enabled === true && clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99; - // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here - // rather than inside the per-eye loop below. 鼻ちょうちん comes along too. - drawFaceExtras(ctx, style, line); - for (const layout of EYE_LAYOUT.eyes) { const spec = (p.eyes ?? {})[layout.key] ?? {}; const eye = { - // `eyeX` slides this eye - and its lid, brow and tear - sideways on its own. + // `eyeX` slides this eye - and its lid and tear - sideways on its own. cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400), cy: layout.cy, r: EYE_LAYOUT.radius, @@ -1436,8 +866,6 @@ export function drawEyes(ctx, p) { // scaled back inside it; `shapeHalfLimit` is the half-size it may reach. const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity; - drawBrow(ctx, eye, spec, style, limits); - // --- tear ---------------------------------------------------------- // A teardrop hangs below the eye, so it is drawn on top of everything else // (the opaque eyeball would otherwise cover it) and for a shut eye too. The diff --git a/bluebey-studio/src/main.js b/bluebey-studio/src/main.js index 20c6c7d..f861ade 100644 --- a/bluebey-studio/src/main.js +++ b/bluebey-studio/src/main.js @@ -6,7 +6,7 @@ import { Face } from './face.js'; import { Styles, isLineStyle } from './styles.js'; import { Rig } from './rig.js'; import { Animator, createRecorder, startRecording, stopRecording } from './animation.js'; -import { defaultState, applyPatch, THEMES } from './presets.js'; +import { defaultState, applyPatch } from './presets.js'; import { buildPanel } from './panel.js'; import * as exporter from './exporter.js'; import { toast } from './ui.js'; @@ -15,12 +15,10 @@ import { createLook } from './look.js'; import { createClipper } from './clip.js'; import { buildHat } from './hats.js'; import { createBackdrop, backdropStyle } from './background.js'; -import { drawCaption, layoutCaption } from './caption.js'; -import { loadFont, captionFontStack } from './textOutlines.js'; +import { captionFontStack } from './textOutlines.js'; import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js'; import { rollAll, decodeSeed } from './gacha.js'; import { encodeState, decodeState } from './urlState.js'; -import { createMouthFlap, levelToMouth } from './mouthFlap.js'; import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js'; import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js'; @@ -29,16 +27,13 @@ const DEG = Math.PI / 180; const RAD_TO_DEG = 180 / Math.PI; const MODEL_URL = 'assets/bluebey.glb'; -/** The caption font is only fetched when a bubble is first used. */ -const CAPTION_FONT_TTF = 'assets/fonts/bluebey-caption.ttf'; -const CAPTION_FONT_WOFF2 = 'assets/fonts/bluebey-caption.woff2'; /** How far the eyes can swing for a look-at target, in radians. */ const MAX_LOOK_YAW = 0.5; const MAX_LOOK_PITCH = 0.34; /** Undo steps are labelled by the part of the state that changed. */ const SCOPE_LABELS = { - all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', caption: 'セリフ', gion: '擬音', + all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', gion: '擬音', }; /** @@ -55,10 +50,6 @@ function assetUrl(file, kind) { const beards = globalThis.__BLUEBEY_BEARDS__ ?? {}; return beards[file] ?? `assets/beards/${file}`; } - if (kind === 'brow') { - const brows = globalThis.__BLUEBEY_BROWS__ ?? {}; - return brows[file] ?? `assets/brows/${file}`; - } return inlined[file] ?? `assets/fonts/${file}`; } @@ -201,15 +192,14 @@ async function init() { let resizeBurst = 0; /** Declared early: the viewport can resize before the model has loaded. */ - let captionCanvas = null; + let overlayCanvas = null; /** Set while undo/redo replays a snapshot, so it is not recorded again. */ let suspendHistory = false; // ------------------------------------------------------------------- 擬音 - // `redrawCaption` runs from the very first `resizeViewport`, so the sheet cache + // `redrawOverlay` runs from the very first `resizeViewport`, so the sheet cache // and the drag handles have to exist before it does (a `const` cannot be read - // before it is evaluated). The drawing itself lives further down, next to the - // caption overlay it is layered under. + // before it is evaluated). The drawing itself lives further down. const gionImages = new Map(); // sheet name -> loaded Image const gionLoading = new Map(); // sheet name -> in-flight Promise const gionHandles = new Map(); // stamp id -> transparent drag div @@ -241,7 +231,7 @@ async function init() { renderer.setSize(width, height, false); outline.setSize(width * quality, height * quality); view.resize(); - redrawCaption(); + redrawOverlay(); app.needsRender = true; } resizeViewport(); @@ -532,60 +522,6 @@ async function init() { }); const animator = new Animator({ state }); - // Beard drawings supplied as files (`assets/beards/.png`): a kind that has - // one is drawn from the file instead of the built-in strokes. A missing file is - // skipped, so the app works before any are added. - void (async () => { - // Each entry: a beard kind and the drawing in `assets/beards/`. A file ending - // `-right.png` is one side of a moustache; it is drawn twice (mirrored) with a - // `spacing` gap between them. A kind with no entry uses the built-in strokes. - const files = [ - { kind: 'scotch', file: 'scotch.png' }, - { kind: 'kaiser', file: 'kaiser-right.png' }, - ]; - const loaded = {}; - await Promise.all(files.map(async ({ kind, file }) => { - try { - const response = await fetch(assetUrl(file, 'beard')); - if (!response.ok) return; - loaded[kind] = { - image: await createImageBitmap(await response.blob()), - half: file.endsWith('-right.png'), - }; - } catch { - /* a missing drawing falls back to the built-in strokes */ - } - })); - if (Object.keys(loaded).length) { - face.setBeardImages(loaded); - app.needsRender = true; - } - })(); - - // Brow drawings supplied as files (`assets/brows/.png`). A file ending - // `-right.png` is one side; it is drawn twice (mirrored) at each eye. Missing - // files are skipped, so the built-in ゴル風 strokes are the fallback. - void (async () => { - const files = [{ kind: 'gol', file: 'gol-right.png' }]; - const loaded = {}; - await Promise.all(files.map(async ({ kind, file }) => { - try { - const response = await fetch(assetUrl(file, 'brow')); - if (!response.ok) return; - loaded[kind] = { - image: await createImageBitmap(await response.blob()), - half: file.endsWith('-right.png'), - }; - } catch { - /* a missing drawing falls back to the built-in strokes */ - } - })); - if (Object.keys(loaded).length) { - face.setBrowImages(loaded); - app.needsRender = true; - } - })(); - // ------------------------------------------------------------------- look const look = createLook({ renderer, @@ -645,170 +581,39 @@ async function init() { const storyThumbs = new Map(); let storySeq = 0; - // ------------------------------------------------------- caption overlay - // The bubble is drawn into its own 2D canvas laid over the WebGL one, with the - // very same routine the PNG export uses - so the preview cannot lie. - captionCanvas = document.createElement('canvas'); - captionCanvas.id = 'caption-layer'; - captionCanvas.style.cssText = + // ------------------------------------------------------- 擬音 overlay + // The 擬音 stamps are drawn into their own 2D canvas laid over the WebGL one. + overlayCanvas = document.createElement('canvas'); + overlayCanvas.id = 'overlay-layer'; + overlayCanvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2'; - stage.append(captionCanvas); - - /** opentype font for outline export and text layout (lazily fetched). */ - let captionFont = null; - let captionFontPromise = null; - /** Where each bubble was last drawn, in CSS pixels (in state order). */ - const CAPTION_KEYS = ['caption', 'caption2', 'narration']; - const captionBoxes = new Map(); - const captionHandles = new Map(); - /** Set while a bubble is being dragged. */ - let captionDrag = null; - - // The bubbles are draggable, but the caption canvas has to stay - // `pointer-events: none`: it covers the whole viewport, and the orbit controls - // and the bone picking live underneath it. So each bubble gets a separate - // transparent drag box, parked exactly over it by `positionCaptionHandles`. - for (const key of CAPTION_KEYS) { - const handle = document.createElement('div'); - handle.id = `caption-handle-${key}`; - handle.style.cssText = 'position:absolute;display:none;cursor:move;' - + 'touch-action:none;pointer-events:auto;z-index:3'; - handle.addEventListener('pointerdown', (event) => startCaptionDrag(event, key)); - handle.addEventListener('pointermove', moveCaptionDrag); - handle.addEventListener('pointerup', endCaptionDrag); - handle.addEventListener('pointercancel', endCaptionDrag); - handle.dataset.captionHandle = '1'; - stage.append(handle); - captionHandles.set(key, handle); - } - - /** Park each drag target over its bubble (or hide it when it is not shown). */ - function positionCaptionHandles() { - for (const key of CAPTION_KEYS) { - const handle = captionHandles.get(key); - const box = captionBoxes.get(key); - if (!handle) continue; - if (!box) { - handle.style.display = 'none'; - continue; - } - // A few pixels of slack, so the rounded corners are still easy to grab. - handle.style.display = 'block'; - handle.style.left = `${box.x - 4}px`; - handle.style.top = `${box.y - 4}px`; - handle.style.width = `${box.w + 8}px`; - handle.style.height = `${box.h + 8}px`; - } - } + stage.append(overlayCanvas); - function startCaptionDrag(event, key) { - const caption = state[key]; - const box = captionBoxes.get(key); - if (caption?.enabled !== true || !box) return; - // Touching a bubble also selects it in the panel, so the sliders edit the one - // you just grabbed. - app.panel?.selectCaption?.(key); - event.preventDefault(); - event.stopPropagation(); - // Not all pointers can be captured (and a synthetic one cannot), so a - // failure here must not stop the drag from starting. - try { - captionHandles.get(key)?.setPointerCapture(event.pointerId); - } catch { - /* capture is a nicety, not a requirement */ - } - captionDrag = { - key, - pointerId: event.pointerId, - fromX: event.clientX, - fromY: event.clientY, - boxX: box.x, - boxY: box.y, - }; - } - - function moveCaptionDrag(event) { - if (!captionDrag || event.pointerId !== captionDrag.pointerId) return; - const caption = state[captionDrag.key]; - if (!caption) return; - event.preventDefault(); - const width = stage.clientWidth || 1280; - const height = stage.clientHeight || 800; - caption.x = (captionDrag.boxX + (event.clientX - captionDrag.fromX)) / width; - caption.y = (captionDrag.boxY + (event.clientY - captionDrag.fromY)) / height; - // Store back what the layout will actually use, so pushing past an edge does - // not pile up an out-of-range value that has to be undone before the bubble - // moves again. `refresh` redraws the bubble and records one undo step (the - // history coalesces a run of the same label). - const layout = layoutCaption(caption, { width, height, scale: 1, font: captionFont }); - if (width > 0) caption.x = layout.box.x / width; - if (height > 0) caption.y = layout.box.y / height; - refresh('caption'); - } - - function endCaptionDrag(event) { - if (!captionDrag || (event && event.pointerId !== captionDrag.pointerId)) return; - captionDrag = null; - // The 横位置 / 縦位置 sliders catch up when the drag ends, not every pixel. - app.actions.syncPanel?.(); - } - - async function ensureCaptionFont() { - if (captionFontPromise) return captionFontPromise; - captionFontPromise = (async () => { - try { - const face = new FontFace('M PLUS Rounded 1c', `url(${assetUrl('bluebey-caption.woff2')})`); - document.fonts.add(await face.load()); - } catch (error) { - console.warn('[bluebey] caption font face failed', error); - } - try { - captionFont = await loadFont(assetUrl('bluebey-caption.ttf')); - } catch (error) { - console.warn('[bluebey] caption outlines unavailable', error); - } - redrawCaption(); - return captionFont; - })(); - return captionFontPromise; - } - - function redrawCaption() { - if (!captionCanvas) return; + function redrawOverlay() { + if (!overlayCanvas) return; const width = stage.clientWidth || 1280; const height = stage.clientHeight || 800; const ratio = Math.min(2, window.devicePixelRatio || 1); const wantW = Math.round(width * ratio); const wantH = Math.round(height * ratio); - if (captionCanvas.width !== wantW || captionCanvas.height !== wantH) { - captionCanvas.width = wantW; - captionCanvas.height = wantH; + if (overlayCanvas.width !== wantW || overlayCanvas.height !== wantH) { + overlayCanvas.width = wantW; + overlayCanvas.height = wantH; } - const ctx = captionCanvas.getContext('2d'); + const ctx = overlayCanvas.getContext('2d'); ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.clearRect(0, 0, width, height); - // 擬音はセリフより背面に置く: the bubbles stay readable on top of them. const gionItems = state.gion?.items ?? []; for (const item of gionItems) ensureGionSheet(item.sheet); drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 }); - for (const key of CAPTION_KEYS) { - const caption = state[key]; - captionBoxes.delete(key); - if (!caption?.enabled) continue; - // The vendored font is only fetched once a bubble is actually used; when it - // arrives `ensureCaptionFont` redraws this canvas. - if (!captionFontPromise) void ensureCaptionFont(); - captionBoxes.set(key, drawCaption(ctx, caption, { width, height, scale: 1, font: captionFont })); - } - positionCaptionHandles(); ensureGionHandles(gionItems); positionGionHandles(gionItems); } // ----------------------------------------------------------------- 擬音 - // A stamp is a crop of a sheet, drawn on the caption canvas *under* the bubbles. - // The canvas is `pointer-events: none`, so - exactly like a caption - each stamp - // gets its own transparent drag box, parked over it by `positionGionHandles`. + // A stamp is a crop of a sheet, drawn on the overlay canvas. + // The canvas is `pointer-events: none`, so each stamp gets its own transparent + // drag box, parked over it by `positionGionHandles`. /** Load a sheet once, on first use, and redraw when it arrives. */ function ensureGionSheet(name) { @@ -822,7 +627,7 @@ async function init() { gionLoading.delete(name); if (loaded) { gionImages.set(name, loaded); - redrawCaption(); + redrawOverlay(); } return loaded; }); @@ -1062,7 +867,6 @@ async function init() { function onPropPointerDown(event) { if (event.button !== 0) return; - if (event.target?.dataset?.captionHandle) return; // a bubble drags itself if (rig?.controls?.dragging) return; // the rotation gizmo is in charge const hit = propAt(event); if (hit) { @@ -1289,17 +1093,8 @@ async function init() { return aim; } - // -------------------------------------------- history, 口パク, gacha + // ---------------------------------------------------- history, gacha const history = new History({ limit: 120 }); - /** How far the mouth is open right now because of the 口パク animation. */ - let flapMouth = 0; - const mouthFlap = createMouthFlap({ - onLevel: (level) => { - flapMouth = levelToMouth(level, state.mouthFlap?.mouthGain ?? 1); - app.needsRender = true; - }, - onEnd: () => { flapMouth = 0; app.needsRender = true; }, - }); let rig = null; // The gizmo always belongs to whichever camera is currently active. @@ -1436,8 +1231,7 @@ async function init() { Object.assign(app, { renderer, scene, camera: view.camera, view, styles, face, rig, animator, key, ambient, ground, environment, halfHeight, outline, clip, - look, backdrop, history, mouthFlap, propRoot, mirrorGroup, container, - get captionFont() { return captionFont; }, + look, backdrop, history, propRoot, mirrorGroup, container, get backdropImage() { return backdropImage; }, }); @@ -1526,11 +1320,9 @@ async function init() { setCameraPreset: (id) => setCameraPreset(id), undo: () => restoreFromHistory('undo'), redo: () => restoreFromHistory('redo'), - applyTheme: (id) => applyTheme(id), rollGacha: (seed) => rollGacha(seed), copyShareLink: () => copyShareLink(), shareImage: (network) => shareImage(network), - toggleMouthFlap: () => toggleMouthFlap(), storyThumbs: () => storyThumbs, toggleCamera: (on) => toggleCamera(on), addProp: (kind) => addProp(kind), @@ -1761,20 +1553,14 @@ async function init() { }; }; - // Lip-sync and a look-at target both drive the mouth and the eyes without - // being part of the saved expression. + // A look-at target drives the eyes without being part of the saved + // expression; the mouth is used as-is. const mouth = { ...state.face.mouth }; - if (flapMouth > 0.001) mouth.open = Math.max(mouth.open ?? 0, flapMouth); - // A talking mouth has no tongue sticking out. This follows the whole flap - // session rather than the opening: the envelope dips through zero between - // syllables, and keying it off the opening made the tongue flicker back into - // view on every closed frame. - if (mouthFlap.running) mouth.tongue = 0; return { eyes: { - // Everything shared by both eyes (colours, iris scale, brows, glasses, - // ...) has to travel with the per-eye values, because the face renderer + // Everything shared by both eyes (colours, iris scale, glasses, ...) has + // to travel with the per-eye values, because the face renderer // reads them from here. Spreading `...eyes` is also what makes the face // redraw when one of them (e.g. `glasses`) changes: the loop compares the // JSON of this object against the last one it drew. @@ -1857,7 +1643,7 @@ async function init() { // after some other change (adding a prop) re-ran this. applyBackground(); } - if (scope === 'all' || scope === 'view' || scope === 'caption' || scope === 'gion') redrawCaption(); + if (scope === 'all' || scope === 'view' || scope === 'gion') redrawOverlay(); // A style change flips the face between paint and ink (`faceMode`), so the // 'render' scope has to rebuild the face too - otherwise the old drawing @@ -2091,13 +1877,13 @@ async function init() { if (drawn !== onScreen) styles.setStyle(onScreen); } - // The backdrop, the 擬音 stamps and the bubbles live in DOM layers behind - // and above the WebGL canvas, so an export has to paint them in itself - in - // the same order as the screen: backdrop, then stamps, then bubbles. + // The backdrop and the 擬音 stamps live in DOM layers behind the WebGL + // canvas, so an export has to paint them in itself - in the same order as + // the screen: backdrop, then stamps. const ctx = result.canvas.getContext('2d'); - // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps and the - // bubbles are drawn at below. The backdrop's tiled fit needs it too, so its - // tiles come out the size the preview's CSS paints them. + // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps are + // drawn at below. The backdrop's tiled fit needs it too, so its tiles come + // out the size the preview's CSS paints them. const base = canvas.clientWidth || 1280; const outputScale = result.width / Math.max(1, base); let changed = false; @@ -2112,19 +1898,6 @@ async function init() { }); changed = true; } - const bubbles = CAPTION_KEYS.map((key) => state[key]).filter((caption) => caption?.enabled); - if (bubbles.length) { - await ensureCaptionFont(); - for (const caption of bubbles) { - drawCaption(ctx, caption, { - width: result.width, - height: result.height, - scale: outputScale, - font: captionFont, - }); - } - changed = true; - } if (changed) result.blob = await exporter.canvasToBlob(result.canvas); return result; }); @@ -2153,23 +1926,18 @@ async function init() { // `face.flush(now, 26)`), so force any pending one through first or the map // would show the expression before last. face.flush(performance.now(), 0); - return kind === 'hair' ? exporter.buildHairMap(face) : exporter.buildFaceMap(face, kind); + return exporter.buildFaceMap(face, kind); } async function saveFaceMap(kind) { const canvas = faceMapCanvas(kind); if (!canvas) { - toast('このモデルには髪のプレートがありません'); + toast('この下地は書き出せませんでした'); return; } const blob = await exporter.canvasToBlob(canvas); - const label = kind === 'eyes' ? '目' : kind === 'mouth' ? '口' : '髪'; + const label = kind === 'eyes' ? '目' : '口'; exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`); - if (kind === 'hair') { - toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` - + 'この画像に前髪を描いて渡してください'); - return; - } toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`); } @@ -2273,14 +2041,6 @@ async function init() { toast(kind === 'undo' ? '1つ戻しました' : 'やり直しました'); } - function applyTheme(id) { - const theme = THEMES.find((item) => item.id === id); - if (!theme) return; - state.render.theme = id; - state.render.colors = { ...theme.colors }; - applyState({}, { scope: 'render', sync: true }); - } - /** おまかせ: a seeded random expression, so it can be reproduced and shared. */ function rollGacha(seedText) { const seed = seedText || String(Math.floor(Math.random() * 1e9)); @@ -2370,18 +2130,6 @@ async function init() { : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)'); } - function toggleMouthFlap() { - if (mouthFlap.running) { - mouthFlap.stop(); - toast('口パクを止めました'); - return; - } - const seconds = mouthFlap.start(state.mouthFlap?.text ?? '', { - rate: state.mouthFlap?.rate ?? 1, - }); - toast(`口パクを始めました(音は出ません・約${seconds.toFixed(1)}秒)`); - } - async function toggleCamera(on) { if (!on) { backdrop.stopCamera(); @@ -2423,15 +2171,13 @@ async function init() { label: `コマ${index}`, pose: JSON.parse(JSON.stringify(state.pose)), face: JSON.parse(JSON.stringify(state.face)), - caption: JSON.parse(JSON.stringify(state.caption)), - caption2: JSON.parse(JSON.stringify(state.caption2)), // The 擬音 stamps as well, so a panel comes back with everything it showed. gion: JSON.parse(JSON.stringify(state.gion)), // The camera as well as the pose: recalling a panel should put you back // where you were looking when you recorded it. view: JSON.parse(JSON.stringify(state.view)), }]; - applyState({}, { scope: 'caption', sync: true }); + applyState({}, { scope: 'gion', sync: true }); // Then photograph what was just recorded, so the list shows the shot rather // than only its name. @@ -2463,8 +2209,7 @@ async function init() { /** * A number in the corner of a comic panel, for sheets whose reading order is - * not obvious. Drawn with the vendored caption font, so the sheet does not - * depend on whatever fonts the viewer happens to have. + * not obvious. */ function drawPanelNumber(ctx, number, x, y, cellW, cellH) { const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075)); @@ -2505,8 +2250,6 @@ async function init() { state.pose = { bones: pose.bones ?? {}, root: pose.root ?? [0, 0, 0] }; state.face = JSON.parse(JSON.stringify(saved.face)); applyPatch(state.face, panel.face ?? {}); - state.caption = { ...saved.caption, ...(panel.caption ?? {}) }; - state.caption2 = { ...saved.caption2, ...(panel.caption2 ?? {}) }; // The camera the panel was framed with, so a comic keeps its angles. if (panel.view) state.view = JSON.parse(JSON.stringify(panel.view)); refresh('all'); @@ -2530,7 +2273,6 @@ async function init() { // obvious from the layout alone, so the panels are numbered. A single column // (or two panels) reads top to bottom without help. const numbered = columns >= 2 && captures.length >= 3; - if (numbered) await ensureCaptionFont(); // A manga page reads by its panel frames. They are drawn inside each cell so // the artwork keeps its full size, and are thick enough to survive the 1× diff --git a/bluebey-studio/src/mouthFlap.js b/bluebey-studio/src/mouthFlap.js deleted file mode 100644 index 1109398..0000000 --- a/bluebey-studio/src/mouthFlap.js +++ /dev/null @@ -1,267 +0,0 @@ -/** - * 口パク (mouth flap): open and close the mouth as if talking, with no sound. - * - * WHY no speech: the studio used to read the caption out with the Web Speech - * API, but that voice belongs to the device (it is the OS's own speech engine), - * so a recording of it is not something we are free to hand out. What a clip - * actually needs is the *mouth motion*, and that needs no voice at all: - * `createMouthEnvelope` builds a smooth, seeded signal in the 3-6 - * syllable-per-second band, and `createMouthFlap` ticks it at about 30 Hz for - * as long as the line would take to say. - * - * Nothing here touches `speechSynthesis`, so there is no permission prompt, no - * device dependency, and a recording of the animation is the studio's own work. - */ - -const TAU = Math.PI * 2; -/** The mouth is sampled at ~30 Hz: enough for an animation, cheap to run. */ -const LEVEL_HZ = 30; -/** How long the envelope takes to close after `stop()`, in seconds. */ -const CLOSE_SECONDS = 0.3; - -const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); -const clamp01 = (value) => clamp(value, 0, 1); - -/** - * mulberry32, the same tiny generator `handDrawn.js` uses. The mouth must be - * reproducible for a seed, so `Math.random` is not an option. - * - * @param {number} seed - * @returns {() => number} values in [0, 1) - */ -function mulberry32(seed) { - let a = seed >>> 0; - return function next() { - a = (a + 0x6d2b79f5) >>> 0; - let t = a; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** - * The mouth source: a deterministic, smooth mouth opening in 0..1. - * - * A syllable is one closed -> open -> closed cycle, so the signal is a cosine - * pulse whose frequency is slowly modulated between 3 and 6 Hz and shaped by a - * slower amplitude wobble. The syllable range and the modulation constants come - * from the seed, so two envelopes with the same seed are exactly identical, and - * the signal is continuous with a bounded slope: it never jumps, which is what - * keeps the mouth from flickering. - * - * @param {{ seed?: number }} [options] - * @returns {{ value: (t: number) => number, start: (at?: number) => void, stop: () => void }} - */ -export function createMouthEnvelope({ seed = 1 } = {}) { - const random = mulberry32(seed); - const phase0 = random() * TAU; - const phase1 = random() * TAU; - // 4.2..4.8 syllables/s on average, swung by up to 1.2 either way, then kept - // inside 3..6 so the read stays in the range of human speech. - const rateMean = 4.2 + random() * 0.6; - const rateSwing = Math.min(rateMean - 3, 6 - rateMean, 0.7 + random() * 0.8); - const rateMod = 0.21 + random() * 0.25; - const ampMod = 0.4 + random() * 0.3; - - let startAt = 0; - let stopAt = Infinity; - let lastAt = 0; - - /** The envelope at `u` seconds after the start, ignoring start and stop. */ - function raw(u) { - // The integral of the modulated rate, so theta stays continuous (a - // modulated sine would kink whenever the rate changed). - const theta = TAU * (rateMean * u - + (rateSwing / (TAU * rateMod)) * (Math.cos(phase0) - Math.cos(TAU * rateMod * u + phase0))); - const pulse = 0.5 - 0.5 * Math.cos(theta); - const amp = 0.55 + 0.2 * Math.sin(TAU * ampMod * u + phase1); - return clamp01(amp * pulse); - } - - return { - /** - * The mouth opening at `t`, in the caller's own time base. Before `start` - * it is 0, and after `stop()` it fades to 0 within `CLOSE_SECONDS`. - * - * @param {number} t - * @returns {number} 0..1 - */ - value(t) { - const time = Number.isFinite(t) ? t : 0; - if (time > lastAt) lastAt = time; - if (time < startAt) return 0; - const fading = time - stopAt; - if (fading > 0) { - const fade = 1 - fading / CLOSE_SECONDS; - if (fade <= 0) return 0; - return raw(time - startAt) * fade; - } - return raw(time - startAt); - }, - - /** Begin (or restart) the envelope at `at`, opening from closed. */ - start(at = 0) { - startAt = Number.isFinite(at) ? at : 0; - stopAt = Infinity; - lastAt = startAt; - }, - - /** Stop: the envelope decays to 0 from wherever it is. */ - stop() { - stopAt = lastAt; - }, - }; -} - -/** - * Map a 0..1 level to the app's mouth-opening range. - * - * WHY not `level` straight through: a half-open mouth is the readable one, and - * a full gape looks like a shout. 0 stays 0 (a closed mouth must stay closed) - * and 1 lands at 0.55. `gain` lets a caller push the mouth wider for a loud - * passage, but the result is clamped to 0..0.9 so it is never a full gape. - * - * @param {number} level - * @param {number} [gain=1] - * @returns {number} 0..0.9 - */ -export function levelToMouth(level, gain = 1) { - if (!Number.isFinite(level)) return 0; - const scale = Number.isFinite(gain) ? gain : 1; - return clamp(clamp01(level) * 0.55 * scale, 0, 0.9); -} - -/** - * Roughly how long a line takes to say, in seconds **at rate 1**. - * - * A Japanese syllable is about 0.155 s, so the count of characters is a good - * enough clock - and it is the only clock available once there is no voice to - * listen to. An empty line still gets a few seconds, so the button never looks - * like it did nothing. - * - * @param {string} text - * @returns {number} seconds, 1.1..40 (or 3 for an empty line) - */ -export function speakingSeconds(text) { - const chars = [...String(text ?? '').trim()].length; - if (chars === 0) return 3; - return clamp(0.55 + chars * 0.155, 1.1, 40); -} - -/** - * The mouth-flap animation. - * - * `start(text, { rate })` opens the mouth in the seeded syllable rhythm and - * stops itself once the line would have been said, then reports `onEnd`. A - * second `start` restarts it, and `stop()` fades the mouth shut early - which is - * what the panel's button does while it is running. - * - * @param {{ - * onLevel?: (level: number) => void, - * onEnd?: () => void, - * }} [options] - */ -export function createMouthFlap({ onLevel, onEnd } = {}) { - const envelope = createMouthEnvelope(); - let handlers = { onLevel, onEnd }; - let timer = null; - let closing = false; - let running = false; - let startedAt = 0; - let lastEmit = 0; - let currentRate = 1; - /** The envelope's own time at which the line is over. */ - let limitAt = Infinity; - - const nowSeconds = () => (globalThis.performance?.now?.() ?? Date.now()) / 1000; - const rafSupported = typeof globalThis.requestAnimationFrame === 'function'; - const schedule = (fn) => (rafSupported - ? globalThis.requestAnimationFrame(fn) - : globalThis.setTimeout(fn, 1000 / LEVEL_HZ)); - const unschedule = (id) => { - if (id == null) return; - if (rafSupported) globalThis.cancelAnimationFrame(id); - else globalThis.clearTimeout(id); - }; - - /** - * One level sample, about 30 times a second. Once the line has run its - * length the loop stops itself, and while the moth is closing it keeps going - * until the mouth is shut - so no timer is ever left behind. - */ - function tick() { - timer = null; - const stamp = nowSeconds(); - const elapsed = (stamp - startedAt) * currentRate; - if (!closing && elapsed >= limitAt) { - closing = true; - envelope.stop(); - } - const level = envelope.value(elapsed); - if (closing || stamp - lastEmit >= 1 / LEVEL_HZ - 0.001) { - lastEmit = stamp; - handlers.onLevel?.(level); - } - if (closing && level <= 0) { - running = false; - handlers.onEnd?.(); - return; - } - timer = schedule(tick); - } - - /** - * Start flapping. `seconds` overrides the length guessed from the text. - * - * @param {string} text - * @param {{ rate?: number, seconds?: number }} [options] - * @returns {number} how long the flap will run, in seconds - */ - function start(text, { rate = 1, seconds = null } = {}) { - stopNow(); - currentRate = clamp(Number.isFinite(rate) ? rate : 1, 0.2, 4); - limitAt = Number.isFinite(seconds) && seconds > 0 ? seconds : speakingSeconds(text); - startedAt = nowSeconds(); - lastEmit = 0; - closing = false; - running = true; - envelope.start(0); - timer = schedule(tick); - return limitAt / currentRate; - } - - /** Clear the timer immediately (no fade). */ - function stopNow() { - unschedule(timer); - timer = null; - closing = false; - running = false; - envelope.stop(); - } - - /** Fade the mouth shut and let the loop clean itself up. */ - function stop() { - if (timer == null) { - running = false; - envelope.stop(); - return; - } - closing = true; - envelope.stop(); - } - - function dispose() { - stopNow(); - handlers = { onLevel: null, onEnd: null }; - } - - return { - get running() { - return running; - }, - start, - stop, - dispose, - }; -} diff --git a/bluebey-studio/src/panel.js b/bluebey-studio/src/panel.js index 0ee94ac..a6e971d 100644 --- a/bluebey-studio/src/panel.js +++ b/bluebey-studio/src/panel.js @@ -1,10 +1,9 @@ import { h, section as uiSection, subhead, hint, slider, check, segmented, buttons, - colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, icon, svgIcon, + colorField, xyPad, selectField, controlRow, tabs, details, toast, icon, svgIcon, } from './ui.js'; import { - FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS, - defaultState, applyPatch, BEARD_KIND_DEFAULTS, HEAD_MARK_KIND_DEFAULTS, + FACE_PRESETS, POSE_PRESETS, defaultState, applyPatch, } from './presets.js'; import { STYLE_DEFS } from './styles.js'; import { ENVIRONMENTS } from './look.js'; @@ -26,21 +25,14 @@ const SECTION_ICONS = { 'うごき': 'M8 5l10 7-10 7V5Z', 'プリセット': 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0', '目': 'M2.5 12s3.6-6 9.5-6 9.5 6 9.5 6-3.6 6-9.5 6-9.5-6-9.5-6ZM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z', - '眉': 'M4 11q3.2-3.2 7-2M13 9q3.8-1.2 7 2', '眼鏡・サングラス': 'M8 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM16 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM11.2 13h1.6M4.8 11.7 3 10.3M19.2 11.7 21 10.3', - '髪': 'M4 14c0-4.8 3.6-7.6 8-7.6s8 2.8 8 7.6M4 14c2.6-1.4 5.3-1.9 8-1.8M20 14c-2.6-1.4-5.3-1.9-8-1.8', - 'ほっぺ': 'M6.4 14.6a2 1.4 0 1 0 0 .01ZM17.6 14.6a2 1.4 0 1 0 0 .01ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z', '鼻ちょうちん': 'M15 4.6a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2ZM10.6 9.6 4.8 11.4', - 'ひげ': 'M4.4 11.2c2.6-1.2 5.2-.6 7.6 2 2.4-2.6 5-3.2 7.6-2', '口': 'M4.6 10c3 4.8 11.8 4.8 14.8 0', - '口パク(声は出ません)': 'M4 9v6h3l4 3V6L7 9H4ZM15.5 9.5l4 5M19.5 9.5l-4 5', '見る先': 'M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6Z', 'スタイル': 'M4 20l3.4-.8L20 6.6 17.4 4 4.8 16.6 4 20Z', - '体の色': 'M12 3.4s6 6.2 6 10.1a6 6 0 0 1-12 0C6 9.6 12 3.4 12 3.4Z', '見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01', '背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01', '舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5', - 'セリフ(フキダシ)': 'M4 5h16v10H9l-4 4v-4H4V5Z', '画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8', '画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01', '画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01', @@ -54,15 +46,6 @@ const SECTION_ICONS = { const section = (parent, title, options) => uiSection(parent, title, { icon: SECTION_ICONS[title], ...options }); /** Fallbacks for the fields an older settings file may predate. */ -const BODY_COLOR_FALLBACKS = { - body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', - leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0', -}; -const CAPTION_SLIDER_FALLBACKS = { - fontSize: 34, lineHeight: 1.42, padding: 18, radius: 24, borderWidth: 4, -}; -const CAPTION_COLOR_FALLBACKS = { textColor: '#3f2b52', bubbleColor: '#ffffff', borderColor: '#55386e' }; -const MOUTH_FLAP_FALLBACKS = { rate: 1, mouthGain: 1 }; const LOOK_AT_FALLBACKS = { x: 0, y: 2.6, z: 3, amount: 1 }; /** @@ -78,9 +61,8 @@ function confirmImageLicense() { } /** - * The panel had no multi-line field, and the two features that take free prose - * (the caption and the spoken text) want the same shape, so it is built once - * here rather than growing the widget kit for a single caller. + * 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', { @@ -147,18 +129,6 @@ export function buildPanel(app, root) { const sync = () => { for (const fn of syncers) fn(); }; const syncBones = () => { for (const fn of boneSyncers) fn(); }; - /** - * Grey out (and disable) a control whose setting has no effect in the current - * mode - e.g. the brow's angle while the brow is a loaded picture. - */ - const setRowEnabled = (widget, on) => { - const row = widget.el.closest?.('.row') ?? widget.el; - const input = row.querySelector?.('input, select, button'); - if (input) input.disabled = !on; - row.classList?.toggle('is-off', !on); - }; - - /** Patch one scope of the state without re-reading the whole UI. */ /** Replace the state and re-read every widget (used by presets and loads). */ const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true }); @@ -190,55 +160,25 @@ export function buildPanel(app, root) { tilt: Math.round(rnd(-12, 12)), }; }; - const randomBeard = (shape) => { - const base = BEARD_KIND_DEFAULTS[shape] ?? { size: 1, offsetY: 0, spacing: 0 }; - if (shape === 'off') return { shape }; - return { - shape, - ...base, - size: +((base.size ?? 1) * rnd(0.8, 1.25)).toFixed(2), - offsetY: Math.round((base.offsetY ?? 0) + rnd(-16, 16)), - }; - }; - const randomHeadMark = () => { - const shape = pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']); - return { shape, ...(HEAD_MARK_KIND_DEFAULTS[shape] ?? {}) }; - }; const randomFacePatch = () => { - const beardShape = pick(['off', 'scotch', 'kaiser', 'cat']); return { eyes: { left: randomEye(), right: randomEye(), - cheeks: { enabled: Math.random() < 0.5 }, // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。 glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(), - beard: randomBeard(beardShape), - headMark: randomHeadMark(), - brow: { - enabled: true, - // Random brows are line drawings, never ゴル風's loaded picture. - image: false, - angle: Math.round(rnd(-30, 40)), - height: Math.round(rnd(-10, 50)), - length: +rnd(0.6, 2.6).toFixed(2), - thickness: +rnd(1.2, 3.4).toFixed(2), - curve: +rnd(0, 0.3).toFixed(2), - spacing: Math.round(rnd(-120, 30)), - taper: +rnd(0, 1).toFixed(2), }, - }, - // The mouth takes the height and the tilt too, so the face really changes. - mouth: { - smile: +rnd(-1, 1.2).toFixed(2), - // ときどき丸く開く口(O)にする。 - round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0, - open: +rnd(0, 0.5).toFixed(2), - width: +rnd(0.7, 1.3).toFixed(2), - thickness: +rnd(0.7, 1.6).toFixed(2), - tilt: Math.round(rnd(-12, 12)), - offsetY: Math.round(rnd(-16, 16)), - }, + // 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'); @@ -249,10 +189,9 @@ export function buildPanel(app, root) { return Math.abs(m[0]) <= 45 && Math.abs(m[1]) <= 40 && Math.abs(m[2]) <= 40; }); const pose = pick(poses.length ? poses : POSE_PRESETS); - const theme = pick(THEMES); app.actions.applyPosePreset?.(pose.id); - app.actions.applyTheme?.(theme.id); - // The viewer style (実写/フラット/線画) and 左右反転 are deliberately left alone. + // The viewer style (実写/フラット/線画), the body colours, and 左右反転 are + // deliberately left alone. replace({ face: randomFacePatch() }, 'face'); sync(); }; @@ -441,18 +380,16 @@ export function buildPanel(app, root) { 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. + // screen (プリセット -> 目 -> 見る先 -> 口 -> 眼鏡 -> 鼻ちょうちん -> 帽子), + // but filled further down where the widgets are built: the DOM order is fixed + // at creation. const faceTabSections = { - hair: section(tabFace, '髪'), - brow: section(tabFace, '眉'), eye: section(tabFace, '目'), look: section(tabFace, '見る先'), + mouth: section(tabFace, '口'), glasses: section(tabFace, '眼鏡・サングラス'), - cheeks: section(tabFace, 'ほっぺ'), snot: section(tabFace, '鼻ちょうちん'), - beard: section(tabFace, 'ひげ'), - mouth: section(tabFace, '口'), + hat: section(tabFace, '帽子'), }; faceSection.add(buttons({ @@ -860,128 +797,9 @@ export function buildPanel(app, root) { const tearBox = details(faceSection.body, '横位置(左右べつ)'); for (const el of tearWidgets) tearBox.add(el); - // --- eyebrows (the original character has none; this is extra range) - faceSection = faceTabSections.brow; - faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。')); - - const browToggle = check({ - label: '眉を表示', - value: false, - onChange: (value) => { state.face.eyes.brow.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, browToggle.el, { wide: true })); - - const browSliders = { - angle: slider({ - label: '角度', min: -40, max: 40, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, - onInput: (v) => { state.face.eyes.brow.angle = v; app.actions.refresh('face'); }, - }), - height: slider({ - label: '高さ', min: -40, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.brow.height = v; app.actions.refresh('face'); }, - }), - length: slider({ - label: '長さ', min: 0, max: 4, step: 0.01, value: 1, - format: (v) => (v < 0.02 ? '点' : v.toFixed(2)), - onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); }, - }), - thickness: slider({ - label: '太さ', min: 0.4, max: 3.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.brow.thickness = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - label: '間隔', min: -220, max: 60, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.brow.spacing = v; app.actions.refresh('face'); }, - }), - curve: slider({ - label: '曲がり', min: 0, max: 0.4, step: 0.01, value: 0.14, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.brow.curve = v; app.actions.refresh('face'); }, - }), - taper: slider({ - label: '鼻側の細さ', min: 0, max: 1, step: 0.01, value: 1, - format: (v) => (v >= 0.995 ? 'そのまま' : (v <= 0.005 ? 'とがる' : v.toFixed(2))), - onInput: (v) => { state.face.eyes.brow.taper = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(hint('+の角度で内側が下がり、怒った感じになります。-で困り顔。' - + '高さは + で上がり、- にすると目にかくれて見えなくなります。')); - faceSection.add(browSliders.angle.el); - const browBox = details(faceSection.body, 'こまかい設定(高さ・長さ・太さ・色)'); - browBox.add(browSliders.height.el); - browBox.add(browSliders.length.el); - browBox.add(browSliders.thickness.el); - browBox.add(browSliders.spacing.el); - browBox.add(browSliders.curve.el); - browBox.add(browSliders.taper.el); - const browColor = colorField({ - label: '眉の色', value: '#55386e', - swatches: ['#55386e', '#2a1e33', '#7a5c2e', '#8a4a5e'], - onChange: (v) => { state.face.eyes.brow.color = v; app.actions.refresh('face'); }, - }); - browBox.add(browColor.el); - browBox.add(hint('「間隔」は左右の眉のへだたりです(+で広がり、-で内側に寄って、' - + '大きく-にすると左右がくっつきます)。「長さ」を 0 にすると点になります。' - + '「鼻側の細さ」を 0 にすると鼻側がとがります。')); - browBox.add(buttons({ - items: [ - { - id: 'golgo', - label: 'ゴル風(画像)', - onClick: () => { - // ゴルゴ13: a very thick, almost straight black wedge, wide at the outer - // end and tapering to a point at the nose, angled steeply down towards - // it, with the two brows sitting close together. - Object.assign(state.face.eyes.brow, { - enabled: true, - image: true, - angle: 34, - height: 33, - length: 2.82, - thickness: 3.6, - curve: 0, - taper: 0, - spacing: -59, - color: '#2a1e33', - }); - app.actions.refresh('face'); - sync(); - }, - }, - { - id: 'ryotsu', - label: '両風(線の眉)', - onClick: () => { - // 両津勘吉: thick げじげじ brows that are joined in the middle, sitting - // low over the eyes. `spacing` is what closes the gap - at this length - // the two inner ends meet over the nose (see the panel's note). Turning - // `image` off is what makes the line drawing take over from ゴル風's - // loaded picture. - Object.assign(state.face.eyes.brow, { - enabled: true, - image: false, - angle: 10, - height: 4, - length: 2.86, - thickness: 3.29, - curve: 0.4, - taper: 1, - spacing: -50, - color: '#181220', - }); - app.actions.refresh('face'); - sync(); - }, - }, - ], - })); - - faceSection.add(hint('「ゴル風(画像)」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。' - + '画像のときは角度・太さ・曲がり・鼻側の細さが効かないので、灰色になります。' - + '「両風(線の眉)」など線で描く眉に切り替えると、この画像は使われません。')); - - // --- glasses / sunglasses (drawn into the same texture as the eyes and brows) + // --- glasses / sunglasses (drawn into the same texture as the eyes) faceSection = faceTabSections.glasses; - faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。' + faceSection.add(hint('眼鏡やサングラスを、目と同じ絵にかけます。' + '線画のときはレンズを塗らず、フレームの線だけになります。')); // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明 @@ -1114,121 +932,6 @@ export function buildPanel(app, root) { ], })); - // --- ほっぺ (a manga blush) - faceSection = faceTabSections.cheeks; - faceSection.add(hint('ほっぺ(マンガの赤らみ)を、目や眉と同じ絵にかけます。' - + '決まったキャラクターの絵ではなく、よくあるマンガの記号として用意しました。' - + 'はじめは出ていません。')); - - const cheeksToggle = check({ - label: 'ほっぺを出す', - value: false, - onChange: (value) => { state.face.eyes.cheeks.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, cheeksToggle.el, { wide: true })); - - const cheekSliders = { - size: slider({ - label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.cheeks.size = v; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: 'たかさ', min: -80, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.cheeks.offsetY = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(cheekSliders.size.el); - const cheekBox = details(faceSection.body, 'こまかい設定(位置・色)'); - cheekBox.add(cheekSliders.offsetY.el); - cheekBox.add(cheekSliders.spacing.el); - const cheekColor = colorField({ - label: 'ほっぺの色', value: '#f6a6b8', - swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'], - onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); }, - }); - cheekBox.add(cheekColor.el); - cheekBox.add(hint('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、' - + '「たかさ」は目からの下がり具合です。')); - - // --- 髪 (a hair-like covering over the whole head; its own section, above 眉) - faceSection = faceTabSections.hair; - faceSection.add(hint('髪を頭にかぶせます。決まったキャラクターの髪ではなく、' - + 'よくあるマンガの形として用意しました。はじめは出ていません。')); - - const headMarkShape = segmented({ - label: '髪', - options: [ - { value: 'off', label: 'なし' }, - { value: 'cap', label: 'かぶせ' }, - { value: 'fringe', label: 'ぎざぎざ' }, - { value: 'fringe-up', label: 'はちわれ' }, - { value: 'curve', label: 'カーブ' }, - ], - value: 'off', - onChange: (value) => { - const mark = state.face.eyes.headMark; - mark.shape = value; - // Entering a shape applies its own starting size (ぎざぎざ is smaller and - // shallower than the shared defaults). Other shapes just keep the values. - Object.assign(mark, HEAD_MARK_KIND_DEFAULTS[value] ?? {}); - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(headMarkShape.el); - - const headMarkOverEyes = check({ - label: '髪を目の前に出す', - value: true, - onChange: (value) => { state.face.eyes.headMark.overEyes = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, headMarkOverEyes.el, { wide: true })); - - const headMarkSliders = { - size: slider({ - label: '大きさ', min: 0.05, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.headMark.size = v; app.actions.refresh('face'); }, - }), - teeth: slider({ - label: '歯の深さ', min: 0.02, max: 0.4, step: 0.01, value: 0.12, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.headMark.teeth = v; app.actions.refresh('face'); }, - }), - offsetX: slider({ - label: 'よこの位置', min: -260, max: 260, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: 'たかさ', min: -300, max: 300, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(headMarkSliders.size.el); - faceSection.add(headMarkSliders.teeth.el); - //「歯の深さ」はぎざぎざのときだけ意味があるので、それ以外では隠します。 - const headMarkBox = details(faceSection.body, 'こまかい設定(位置・色)'); - headMarkBox.add(headMarkSliders.offsetX.el); - headMarkBox.add(headMarkSliders.offsetY.el); - const headMarkColor = colorField({ - label: '髪の色', value: '#8a4fe0', - swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'], - onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); }, - }); - const headMarkColor2 = colorField({ - label: '2色目', value: '#ffffff', - swatches: ['#ffffff', '#f3ead9', '#ffe08a', '#7fd8ff'], - onChange: (v) => { state.face.eyes.headMark.color2 = v; app.actions.refresh('face'); }, - }); - headMarkBox.add(headMarkColor.el); - headMarkBox.add(headMarkColor2.el); - headMarkBox.add(hint('「かぶせ」は頭にかぶせる丸い髪、「ぎざぎざ」は生え際がギザギザにとがった髪(「歯の深さ」でギザギザの深さを変えられます)、' - + '「はちわれ」は中央が鋭く持ち上がった生え際、「カーブ」はドラえもんのように単純にカーブした髪です。' - + '「たかさ」で生え際を目の中心あたりまで下げられます。' - + '「髪を目の前に出す」を切ると、髪が目の後ろに回ります。')); - // --- 鼻ちょうちん: the snot bubble of a sleeping face. faceSection = faceTabSections.snot; const snotToggle = check({ @@ -1268,67 +971,6 @@ export function buildPanel(app, root) { + '「ねている」ポーズと組み合わせると、いねむりしているように見えます。' + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。')); - // --- ひげ: mustaches and beards, hung under the nose. - faceSection = faceTabSections.beard; - const beardShape = segmented({ - label: 'ひげ', - options: [ - { value: 'off', label: 'なし' }, - { value: 'scotch', label: 'ちょびひげ' }, - { value: 'kaiser', label: 'ダリ' }, - { value: 'cat', label: 'ねこ' }, - ], - value: 'off', - onChange: (value) => { - const beard = state.face.eyes.beard; - beard.shape = value; - // Each kind starts at its own size and height (a textured kind is drawn at a - // different scale from the built-in strokes). A kind with no entry resets to - // the neutral values, so switching kinds cannot leave a stray size behind. - Object.assign(beard, BEARD_KIND_DEFAULTS[value] ?? { size: 1, offsetY: 0, spacing: 0 }); - app.actions.refresh('face'); - sync(); - }, - }); - faceSection.add(beardShape.el); - const beardSliders = { - size: slider({ - label: '大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.size = v; app.actions.refresh('face'); }, - }), - spacing: slider({ - label: '左右の間隔', min: 0, max: 250, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - // Shown 10 higher than the value stored, so the default (stored -10) reads 0. - onInput: (v) => { state.face.eyes.beard.spacing = v - 10; app.actions.refresh('face'); }, - }), - offsetY: slider({ - label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, - onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); }, - }), - length: slider({ - label: 'ひげの長さ(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.length = v; app.actions.refresh('face'); }, - }), - lineGap: slider({ - label: '線の間隔(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.beard.lineGap = v; app.actions.refresh('face'); }, - }), - }; - faceSection.add(beardSliders.size.el); - const beardBox = details(faceSection.body, 'こまかい設定(位置・色)'); - beardBox.add(beardSliders.spacing.el); - beardBox.add(beardSliders.offsetY.el); - beardBox.add(beardSliders.length.el); - beardBox.add(beardSliders.lineGap.el); - const beardColor = colorField({ - label: 'ひげの色', value: '#1c1624', - swatches: ['#1c1624', '#000000', '#3a2a4a', '#6b4a2a', '#8a8a94'], - onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); }, - }); - beardBox.add(beardColor.el); - beardBox.add(hint('鼻の下に、ちょびひげ・ダリ・ねこ、の瓢を描きます。' - + '「左右の間隔」「瓢の長さ」「線の間隔」は「ねこ」で使います。')); - // --- mouth faceSection = faceTabSections.mouth; @@ -1408,153 +1050,6 @@ export function buildPanel(app, root) { const mouthColorBox = details(faceSection.body, '色(口の線・中・舌・口角)'); for (const widget of Object.values(mouthColors)) mouthColorBox.add(widget.el); - /* ---------------------------------------------------------------- caption */ - - const captionSection = section(tabScene, 'セリフ(フキダシ)'); - - // Two bubbles share this one set of controls: the selector says which bubble - // they are editing, and every handler below reads the active one when it runs, - // so switching does not have to rewire anything. - let activeCaption = 'caption'; - const cap = () => state[activeCaption]; - const captionBubbleSegment = segmented({ - label: 'どの吹き出し', - options: [ - { value: 'caption', label: '①' }, - { value: 'caption2', label: '②' }, - { value: 'narration', label: 'ナレーション' }, - ], - value: 'caption', - onChange: (value) => { activeCaption = value; sync(); }, - }); - captionSection.add(captionBubbleSegment.el); - captionSection.add(hint('吹き出しは①と②の2つ、ナレーション枠が1つ出せます。切り替えて、それぞれ別の位置・形・色・縦書きにできます。')); - - const captionToggle = check({ - label: 'この吹き出しを出す', - value: false, - onChange: (value) => { cap().enabled = value; app.actions.refresh('caption'); sync(); }, - }); - captionSection.add(controlRow(null, captionToggle.el, { wide: true })); - - const captionText = textArea({ - label: 'セリフ', - value: '', - rows: 3, - onChange: (value) => { cap().text = value; app.actions.refresh('caption'); }, - }); - captionSection.add(captionText.el); - - const verticalToggle = check({ - label: '縦書きにする', - value: false, - onChange: (v) => { cap().vertical = v; app.actions.refresh('caption'); sync(); }, - }); - captionSection.add(controlRow(null, verticalToggle.el, { wide: true })); - - captionSection.add(buttons({ - label: '例', - items: CAPTION_PRESETS.map((preset) => ({ - id: preset.id, - label: preset.label, - onClick: () => { Object.assign(cap(), preset.caption); app.actions.refresh('caption'); sync(); }, - })), - })); - - const bubbleSegment = segmented({ - label: '形', - options: BUBBLE_STYLES, - value: 'round', - onChange: (value) => { cap().bubble = value; app.actions.refresh('caption'); }, - }); - const tailSegment = tailPad({ - label: 'しっぽ', - value: 'left', - onChange: (value) => { cap().tail = value; app.actions.refresh('caption'); }, - }); - captionSection.add(bubbleSegment.el); - captionSection.add(tailSegment.el); - - const captionSliders = { - fontSize: slider({ - label: '文字の大きさ', min: 16, max: 72, step: 1, value: 34, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().fontSize = v; app.actions.refresh('caption'); }, - }), - lineHeight: slider({ - label: '行の高さ', min: 1, max: 2, step: 0.01, value: 1.42, format: (v) => v.toFixed(2), - onInput: (v) => { cap().lineHeight = v; app.actions.refresh('caption'); }, - }), - padding: slider({ - label: '内側の余白', min: 4, max: 40, step: 1, value: 18, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().padding = v; app.actions.refresh('caption'); }, - }), - radius: slider({ - label: '角の丸み', min: 0, max: 60, step: 1, value: 24, format: (v) => `${Math.round(v)}px`, - onInput: (v) => { cap().radius = v; app.actions.refresh('caption'); }, - }), - borderWidth: slider({ - label: '線の太さ', min: 0, max: 12, step: 0.5, value: 4, format: (v) => `${v.toFixed(1)}px`, - onInput: (v) => { cap().borderWidth = v; app.actions.refresh('caption'); }, - }), - }; - for (const widget of Object.values(captionSliders)) captionSection.add(widget.el); - - const captionPosition = { - x: slider({ - label: '横位置', min: 0, max: 1, step: 0.01, value: 0.58, format: (v) => v.toFixed(2), - onInput: (v) => { cap().x = v; app.actions.refresh('caption'); }, - }), - y: slider({ - label: '縦位置', min: 0, max: 1, step: 0.01, value: 0.16, format: (v) => v.toFixed(2), - onInput: (v) => { cap().y = v; app.actions.refresh('caption'); }, - }), - }; - captionSection.add(captionPosition.x.el); - captionSection.add(captionPosition.y.el); - - const captionMaxWidthSlider = slider({ - label: 'はば', min: 0.15, max: 0.8, step: 0.01, value: 0.36, format: (v) => v.toFixed(2), - onInput: (v) => { cap().maxWidth = v; app.actions.refresh('caption'); }, - }); - captionSection.add(captionMaxWidthSlider.el); - - const alignSegment = segmented({ - label: 'そろえ', - options: [ - { value: 'left', label: '左' }, - { value: 'center', label: '中' }, - { value: 'right', label: '右' }, - ], - value: 'left', - onChange: (value) => { cap().align = value; app.actions.refresh('caption'); }, - }); - captionSection.add(alignSegment.el); - - const boldToggle = check({ - label: '太字', value: false, - onChange: (value) => { cap().bold = value; app.actions.refresh('caption'); }, - }); - const systemFontToggle = check({ - label: '標準フォントを使う', value: false, - onChange: (value) => { cap().font = value ? 'system' : 'rounded'; app.actions.refresh('caption'); }, - }); - captionSection.add(controlRow(null, boldToggle.el, { wide: true })); - captionSection.add(controlRow(null, systemFontToggle.el, { wide: true })); - - const captionColors = { - textColor: colorField({ label: '文字の色', value: '#3f2b52', onChange: (v) => { cap().textColor = v; app.actions.refresh('caption'); } }), - bubbleColor: colorField({ label: '吹き出しの色', value: '#ffffff', onChange: (v) => { cap().bubbleColor = v; app.actions.refresh('caption'); } }), - borderColor: colorField({ label: '線の色', value: '#55386e', onChange: (v) => { cap().borderColor = v; app.actions.refresh('caption'); } }), - }; - for (const widget of Object.values(captionColors)) captionSection.add(widget.el); - captionSection.add(hint('セリフはプレビューと書き出し画像の両方に描きこまれます。位置は画像全体を0〜1とした割合です。' - + 'ビューポートで吹き出しをドラッグしても動かせます。')); - - /* -------------------------------------------------------------- 口パク */ - - // 口パク lives inside うごき on the pose tab (see below): talking is something - // the character *does*, and it is usually recorded together with the motion. - /* ------------------------------------------------------ movement (pose tab) */ // This is the pose tab's うごき section; the widgets sit here because they are @@ -1607,35 +1102,6 @@ export function buildPanel(app, root) { moveSection.add(recordButton.el); moveSection.add(hint('スペースキーで演出の開始・停止。録画はWebM(動画)として保存されます。')); - // 口パク: the mouth moves as if talking, and no sound is made at all. It lives - // in うごき because talking is motion - and it is usually recorded with it. - // (The studio used to read the line with the device's own speech voices, but - // those belong to the device, so a recording of them is not ours to hand out.) - const flapSection = details(moveSection.body, '口パク(声は出ません)'); - const flapText = textArea({ - label: '口パクするセリフ', - value: '', - rows: 3, - onChange: (value) => { state.mouthFlap.text = value; }, - }); - flapSection.add(flapText.el); - flapSection.add(buttons({ - items: [{ id: 'flap', label: '口パク/止める', primary: true, onClick: () => app.actions.toggleMouthFlap() }], - })); - const flapSliders = { - rate: slider({ - label: 'はやさ', min: 0.5, max: 2, step: 0.05, value: 1, format: (v) => `${v.toFixed(2)}倍`, - onInput: (v) => { state.mouthFlap.rate = v; }, - }), - mouthGain: slider({ - label: '口の開き', min: 0.2, max: 2, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.mouthFlap.mouthGain = v; }, - }), - }; - for (const widget of Object.values(flapSliders)) flapSection.add(widget.el); - flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。' - + '「シーン」タブの「セリフ(フキダシ)」の文とは別で、こちらは長さにだけ使います。')); - // --- 見る先 (moved here, next to the face controls, from the pose tab) ---- const lookAtSection = faceTabSections.look; // `lookAt` moves the eyes and the face only, so the face has to be rebuilt too. @@ -1675,7 +1141,7 @@ export function buildPanel(app, root) { onChange: (value) => { state.lookAt.turnBody = value; refreshLookAt(); }, }); lookAtSection.add(controlRow(null, turnBodyToggle.el, { wide: true })); - lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目と髪が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。')); + lookAtSection.add(hint('ビューポートを**クリック**すると、その場所を見るように目が向きます(ドラッグと区別するため、動かさずに押して離してください)。数値でも同じ値を動かせます。')); /* ---------------------------------------------------------------- display */ @@ -1745,9 +1211,9 @@ export function buildPanel(app, root) { }], })); - /* ------------------------------------------------------------- 帽子 */ + /* ------------------------------------------------------------- 帽子 (顔タブ) */ - const hatSection = section(tabView, '帽子'); + const hatSection = faceTabSections.hat; hatSection.add(hint('ぶるべーの頭に帽子をかぶせます。「なし」を選ぶと外れます。')); const hatSegment = segmented({ label: '帽子', @@ -1817,46 +1283,15 @@ export function buildPanel(app, root) { hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0); }); - /* ------------------------------------------------------------- body colour */ - - const colourSection = section(tabView, '体の色'); - const themeSegment = segmented({ - label: 'テーマ', - options: THEMES.map((theme) => ({ value: theme.id, label: theme.label })), - value: 'original', - onChange: (id) => { app.actions.applyTheme(id); sync(); }, - }); - colourSection.add(themeSegment.el); - colourSection.add(hint('テーマは出発点です。選んだあとでパーツごとに色を変えられます。')); - - /** Write one part colour, creating `colors` if an old settings file lacked it. */ - const setBodyColor = (part, value) => { - state.render.colors = state.render.colors ?? {}; - state.render.colors[part] = value; - app.actions.refresh('render'); - }; - const bodyColors = { - body: colorField({ label: '体', value: '#c8b0f0', onChange: (v) => setBodyColor('body', v) }), - accent: colorField({ label: '手', value: '#8a4fe0', onChange: (v) => setBodyColor('accent', v) }), - nose: colorField({ label: '鼻', value: '#7a4fb0', onChange: (v) => setBodyColor('nose', v) }), - leaf: colorField({ label: '葉', value: '#a6dd6a', onChange: (v) => setBodyColor('leaf', v) }), - vein: colorField({ label: '葉脈', value: '#7fbf3f', onChange: (v) => setBodyColor('vein', v) }), - feet: colorField({ label: '足', value: '#7a4fb0', onChange: (v) => setBodyColor('feet', v) }), - }; - for (const widget of Object.values(bodyColors)) colourSection.add(widget.el); + /* ---------------------------------------------------------- 環境の映りこみ */ + // 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'); }, }); - colourSection.add(envIntensitySlider.el); - colourSection.add(buttons({ - items: [{ - id: 'resetColors', - label: 'プリセットの色に戻す', - onClick: () => { app.actions.applyTheme(state.render.theme ?? 'original'); sync(); }, - }], - })); /* -------------------------------------------------------------------- look */ @@ -1900,6 +1335,9 @@ export function buildPanel(app, root) { 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: '照明の種類', @@ -2709,7 +2147,7 @@ export function buildPanel(app, root) { comicSection.add(controlRow(null, storyPanelBox, { wide: true })); let storyShape = ''; - /** Bring back everything a panel saved: pose, face, both bubbles and the camera. */ + /** Bring back everything a panel saved: pose, face, 擬音 and the camera. */ const recallStoryPanel = (index) => { const panel = state.story?.panels?.[index]; if (!panel) return; @@ -2718,8 +2156,6 @@ export function buildPanel(app, root) { app.actions.applyState({ pose: panel.pose ?? {}, face: panel.face ?? {}, - caption: panel.caption ?? {}, - caption2: panel.caption2 ?? {}, // The 擬音 on the shot. A panel saved before this existed clears them, so // recalling it shows what it actually recorded rather than a leftover. gion: panel.gion ?? { items: [] }, @@ -2734,7 +2170,7 @@ export function buildPanel(app, root) { state.story.panels = (state.story.panels ?? []).filter((_, i) => i !== index); // The empty patch is only here to route the edit through the app's own apply // path: that is what records an undo step and re-reads the panel. - app.actions.applyState({}, { scope: 'caption', sync: true }); + app.actions.applyState({}, { scope: 'all', sync: true }); }; /** @@ -2813,16 +2249,11 @@ export function buildPanel(app, root) { items: [ { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') }, { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') }, - { id: 'facemap-hair', label: '髪の下地を書き出す', onClick: () => app.actions.saveFaceMap('hair') }, ], })); faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。' - + 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や' - + '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。' - + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。' - + '「髪の下地」は、頭をぐるりと巻いたテクスチャを開いたもので、横が頭の角度、' - + 'たてが高さです。左右の端が頭の後ろ(合わせ目)、青いたて線が顔の正面、' - + '青いよこ線が既定の生え際です。')); + + 'そのまま絵を描いて「目の画像を読み込む(PNG)」や「口の画像を読み込む(PNG)」で読み込むと、' + + '描いた場所にそのまま入ります。青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。')); /* --------------------------------------------------------------- settings */ @@ -2859,13 +2290,12 @@ export function buildPanel(app, root) { infoSection.add(statsLine); infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。')); - // シーン: the scene controls are built wherever they belong in the code (the - // speech bubble sits with the face controls), so put them on the tab in the - // order the user meets them: what is behind the character, what is beside it, - // the speech bubble, then the light and the read-out. + // シーン: 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, - captionSection.el, screenLightSection.el, infoSection.el, + screenLightSection.el, infoSection.el, ); boneSyncers.push(() => { @@ -2896,21 +2326,6 @@ export function buildPanel(app, root) { w.eyeX.set(eye.eyeX ?? 0); } - browToggle.set(state.face.eyes.brow.enabled); - browSliders.angle.set(state.face.eyes.brow.angle); - browSliders.height.set(state.face.eyes.brow.height); - browSliders.length.set(state.face.eyes.brow.length); - browSliders.thickness.set(state.face.eyes.brow.thickness); - browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0); - browSliders.curve.set(state.face.eyes.brow.curve); - browSliders.taper.set(state.face.eyes.brow.taper ?? 1); - // A loaded ゴル風 picture ignores the shape sliders, so grey those out rather - // than leave them looking like they should do something. - const browIsImage = state.face.eyes.brow.image === true; - for (const key of ['angle', 'thickness', 'curve', 'taper']) { - setRowEnabled(browSliders[key], !browIsImage); - } - browColor.set(state.face.eyes.brow.color); glassesToggle.set(state.face.eyes.glasses.enabled === true); glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses'); for (const [key, widget] of Object.entries(glassesSliders)) widget.set(state.face.eyes.glasses[key]); @@ -2922,24 +2337,10 @@ export function buildPanel(app, root) { }); syncers.push(() => { - cheeksToggle.set(state.face.eyes.cheeks.enabled === true); - for (const [key, widget] of Object.entries(cheekSliders)) widget.set(state.face.eyes.cheeks[key]); - cheekColor.set(state.face.eyes.cheeks.color); - headMarkShape.set(state.face.eyes.headMark.shape ?? 'off'); - headMarkSliders.teeth.el.style.display = (state.face.eyes.headMark.shape === 'fringe') ? '' : 'none'; - headMarkOverEyes.set(state.face.eyes.headMark.overEyes !== false); - for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]); - headMarkColor.set(state.face.eyes.headMark.color); - headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff'); snotToggle.set(state.face.eyes.snot.enabled === true); snotSize.set(state.face.eyes.snot.size); for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]); snotColor.set(state.face.eyes.snot.color); - beardShape.set(state.face.eyes.beard.shape ?? 'off'); - for (const [key, widget] of Object.entries(beardSliders)) widget.set(state.face.eyes.beard[key]); - // 「左右の間隔」は内部値より+10して表示する(既定の -10 が 0 に見える)。 - beardSliders.spacing.set((state.face.eyes.beard.spacing ?? 0) + 10); - beardColor.set(state.face.eyes.beard.color); }); syncers.push(() => { @@ -2996,10 +2397,6 @@ export function buildPanel(app, root) { }); syncers.push(() => { - themeSegment.set(state.render?.theme ?? 'original'); - for (const [key, widget] of Object.entries(bodyColors)) { - widget.set(state.render?.colors?.[key] ?? BODY_COLOR_FALLBACKS[key]); - } envIntensitySlider.set(state.render?.envIntensity ?? 1); }); @@ -3072,35 +2469,6 @@ export function buildPanel(app, root) { } }); - syncers.push(() => { - captionBubbleSegment.set(activeCaption); - const bubble = cap() ?? {}; - captionToggle.set(bubble.enabled === true); - captionText.set(bubble.text ?? ''); - verticalToggle.set(bubble.vertical === true); - bubbleSegment.set(bubble.bubble ?? 'round'); - tailSegment.set(bubble.tail ?? 'left'); - for (const [key, widget] of Object.entries(captionSliders)) { - widget.set(bubble[key] ?? CAPTION_SLIDER_FALLBACKS[key]); - } - captionPosition.x.set(bubble.x ?? 0.58); - captionPosition.y.set(bubble.y ?? 0.16); - captionMaxWidthSlider.set(bubble.maxWidth ?? 0.36); - alignSegment.set(bubble.align ?? 'left'); - boldToggle.set(bubble.bold === true); - systemFontToggle.set(bubble.font === 'system'); - for (const [key, widget] of Object.entries(captionColors)) { - widget.set(bubble[key] ?? CAPTION_COLOR_FALLBACKS[key]); - } - }); - - syncers.push(() => { - flapText.set(state.mouthFlap?.text ?? ''); - for (const [key, widget] of Object.entries(flapSliders)) { - widget.set(state.mouthFlap?.[key] ?? MOUTH_FLAP_FALLBACKS[key]); - } - }); - syncers.push(() => { storyScaleSegment.set(String(state.story?.scale ?? 1.5)); storyColumnsSlider.set(state.story?.columns ?? 1); @@ -3121,12 +2489,6 @@ export function buildPanel(app, root) { return { sync, onRigChanged: syncBones, - /** Which bubble the caption controls are editing (a drag on it calls this). */ - selectCaption(key) { - if (key !== 'caption' && key !== 'caption2' && key !== 'narration') return; - activeCaption = key; - sync(); - }, /** Which 擬音 stamp the viewport should outline (a drag on it calls this). */ selectGion(id) { app.gionSelected = id ?? null; diff --git a/bluebey-studio/src/presets.js b/bluebey-studio/src/presets.js index e4fe49f..a31c952 100644 --- a/bluebey-studio/src/presets.js +++ b/bluebey-studio/src/presets.js @@ -10,85 +10,6 @@ * the screen. */ -/** - * One speech bubble's defaults. - * - * A factory rather than a constant, because there are two bubbles (see - * `caption2`) and they must not share one object - editing one would edit both. - */ -function captionDefaults() { - return { - enabled: false, - text: 'こんにちは、ぶるべーです。', - // Anchor of the bubble's box, as a fraction of the image (0..1). - x: 0.58, - y: 0.16, - maxWidth: 0.36, // fraction of the image width for the text column - bubble: 'round', // round | rect | shout | none - tail: 'left', // left | right | top | bottom | topLeft | topRight | bottomLeft | bottomRight | none - fontSize: 34, // pixels at 1x; scales with the export - lineHeight: 1.42, - padding: 18, - radius: 24, - textColor: '#3f2b52', - bubbleColor: '#ffffff', - borderColor: '#55386e', - borderWidth: 4, - bold: false, - align: 'left', // left | center | right - vertical: false, // 縦書き (columns run right to left) - font: 'rounded', // rounded (vendored) | system - }; -} - -/** - * ナレーション (a narration box): a plain rectangular panel of vertical text, the - * manga narration style. Same shape as a bubble, with a rect box, no tail, and - * 縦書き on by default. - */ -function narrationDefaults() { - return { - enabled: false, - text: 'ここで、ぶるべーは考えた。', - x: 0.06, - y: 0.08, - maxWidth: 0.5, - bubble: 'rect', - tail: 'none', - fontSize: 30, - lineHeight: 1.35, - padding: 16, - radius: 8, - textColor: '#2b2433', - bubbleColor: '#ffffff', - borderColor: '#2b2433', - borderWidth: 3, - bold: false, - align: 'left', - vertical: true, - font: 'rounded', - }; -} - -/** - * Per-kind starting size and height for ひげ, applied when a kind is picked. The - * textured kinds are drawings, so they are drawn at a different scale from the - * built-in strokes; a kind with no entry resets to the neutral values. - */ -export const BEARD_KIND_DEFAULTS = { - scotch: { size: 0.46, offsetY: 74 }, - kaiser: { size: 1, offsetY: 80, spacing: -10 }, -}; - -/** - * Per-shape starting values for 髪 (headMark), applied when a shape is picked. - * `teeth` only matters for ぎざぎざ, so its default is set here rather than on - * the shared `size`/`teeth` fields. - */ -export const HEAD_MARK_KIND_DEFAULTS = { - fringe: { size: 0.83, teeth: 0.06 }, -}; - /** * 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or * two opposite cuts) can be made. A wall's position is kept *relative to the @@ -168,9 +89,9 @@ export function defaultState() { lightColor: '#ffffff', ambientColor: '#ffffff', exposure: 1, - // Body colours. A theme fills these in; the colour pickers then adjust - // them one part at a time, so a theme is a starting point, not a mode. - theme: 'original', + // The fixed body palette. ぶるべー keeps its colours, so there is no theme + // picker and no per-part editor; the parts live here so the styles and the + // model have one place to read them from. colors: { body: '#c8b0f0', accent: '#8a4fe0', @@ -262,31 +183,6 @@ export function defaultState() { // The tears live on each eye (see `eyes.left.tear`), so one eye can cry // on its own; only the colour is shared. tearColor: '#8fd8ff', - // ほっぺ (a manga blush) and 頭の模様 (a hair-like head covering) are drawn - // into the same plate as the eyes and brows. Both are generic devices, - // not a copy of any one character, and both start off. The blush is always - // four horizontal strokes, so there is no line-count field. - cheeks: { - enabled: false, - size: 1, - spacing: 0, // extra gap outwards from the eyes - offsetY: 0, // extra drop below the eyes - color: '#f6a6b8', - }, - // The hair covering's `shape` is one of `off`, `cap`, `fringe`, - // `fringe-up` or `curve`. `overEyes` puts the hair in front of the eyes - // (bangs over the face); off leaves the eyes on top. - headMark: { - shape: 'off', - size: 1, - // ぎざぎざ only: how far the saw teeth reach down. - teeth: 0.12, - offsetX: 0, - offsetY: 0, - overEyes: true, - color: '#8a4fe0', - color2: '#ffffff', - }, // 鼻ちょうちん: the snot bubble someone sleeps with. Drawn into the same // plate as the eyes, out to the side of the nose. Starts off. snot: { @@ -297,42 +193,9 @@ export function defaultState() { offsetZ: 16, color: '#e3ecff', }, - // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`, - // `scotch`, `kaiser`, `apron` or `cat`. `spacing` widens the gap between - // the cat's left and right whiskers. Starts off. - beard: { - shape: 'off', - size: 1, - offsetY: 0, - spacing: 0, - // ねこ only: how far each whisker reaches (`length`) and how far apart the - // three lines of one side sit (`lineGap`). Neither changes the thickness. - length: 1, - lineGap: 1, - color: '#1c1624', - }, - // Eyebrows are drawn into the eye texture. Off by default, because the - // original character has no eyebrows. - brow: { - enabled: false, - // When true and a drawing (assets/brows/gol-right.png) was loaded, the - // brow is painted from that file (mirrored) instead of the strokes. - image: false, - angle: 0, - height: 0, - length: 1, - thickness: 1, - curve: 0.14, - // How wide the nose-side end is: 1 = a plain stroke, 0 = a wedge that - // comes to a point (the ゴル風 look). `spacing` widens the gap between - // the two brows. - taper: 1, - spacing: 0, - color: '#55386e', - }, - // 眼鏡 / サングラス are drawn into the same texture as the eyes and brows, - // so they travel with the head instead of being a separate 3D mesh. Off - // by default. `kind` only picks which lens defaults the panel offers; the + // 眼鏡 / サングラス are drawn into the same texture as the eyes, so they + // travel with the head instead of being a separate 3D mesh. Off by + // default. `kind` only picks which lens defaults the panel offers; the // drawing itself reads the colours and opacities below. glasses: { enabled: false, @@ -371,24 +234,6 @@ export function defaultState() { line: '#55386e', }, }, - // ---------------------------------------------------------------- caption - // The speech bubbles drawn next to the character when exporting. They live in - // the state so they are saved, shared and undone like everything else. There - // are two of them, so a line and a reply (or a narrator and a speaker) fit. - caption: captionDefaults(), - // The second bubble starts on the other side, so the two do not overlap. - caption2: { ...captionDefaults(), text: '', x: 0.08, y: 0.44 }, - // A ナレーション box (vertical text, rect, no tail). - narration: narrationDefaults(), - // -------------------------------------------------------- 口パク (mouth flap) - // The mouth moves as if talking, with no sound at all: the device's own - // speech voices belong to the device, so a recording of them is not ours to - // hand out. Only the *length* of the text matters here. - mouthFlap: { - text: 'こんにちは。小平市のぶるべーです。', - rate: 1, // how fast the syllables come - mouthGain: 1, // how wide the mouth opens - }, // ----------------------------------------------------------------- look at // Where the character should look (world units). lookAt: { @@ -418,7 +263,7 @@ export function defaultState() { items: [], }, // ------------------------------------------------------------------- story - // 4コマ / 紙芝居. Each panel is a small state patch (pose, face, caption). + // 4コマ / 紙芝居. Each panel is a small state patch (pose, face). story: { columns: 1, gap: 12, @@ -529,7 +374,6 @@ export const FACE_PRESETS = [ // Blushing, eyes half shut and looking away. left: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' }, right: { open: 0.72, lookX: -0.4, lookY: 0.08, closed: 'line' }, - cheeks: { enabled: true }, irisScale: 1, highlight: true, }, @@ -822,71 +666,3 @@ export function applyPatch(target, patch) { export function cloneState(state) { return JSON.parse(JSON.stringify(state)); } - -/** - * Body colour themes (C-1). Each is a full set of part colours, so picking one - * is a single assignment; the individual pickers in the panel then let you drift - * away from it without losing the rest. - * - * ぶるべー is a leaf-sprout mascot, so the themes are named after plants and - * weather rather than after colours. - */ -export const THEMES = [ - { - id: 'original', - label: 'オリジナル', - colors: { body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', leaf: '#a6dd6a', vein: '#7fbf3f', feet: '#7a4fb0' }, - }, - { - id: 'sakura', - label: 'さくら', - colors: { body: '#f7c6d9', accent: '#e0709b', nose: '#c25b7e', leaf: '#b8e08a', vein: '#8dbb54', feet: '#c25b7e' }, - }, - { - id: 'matcha', - label: 'まっちゃ', - colors: { body: '#cfe3ac', accent: '#6f9c3a', nose: '#5c8030', leaf: '#9fd463', vein: '#79ab3c', feet: '#5c8030' }, - }, - { - id: 'sora', - label: 'そら', - colors: { body: '#bcd9f7', accent: '#4a7fd4', nose: '#3f6cae', leaf: '#a8e08a', vein: '#7cbb58', feet: '#3f6cae' }, - }, - { - id: 'lemon', - label: 'レモン', - colors: { body: '#f8e9a6', accent: '#d8a520', nose: '#b3871a', leaf: '#b9de74', vein: '#8fbb45', feet: '#b3871a' }, - }, - { - id: 'momo', - label: 'もも', - colors: { body: '#f9cbae', accent: '#e07a4a', nose: '#bd6238', leaf: '#aede7c', vein: '#84b84e', feet: '#bd6238' }, - }, - { - id: 'sumi', - label: 'すみ', - colors: { body: '#9aa2b8', accent: '#4b5468', nose: '#3c4356', leaf: '#8fbf7a', vein: '#6d9a58', feet: '#3c4356' }, - }, - { - id: 'yozora', - label: 'よぞら', - colors: { body: '#8f8fd0', accent: '#403f8f', nose: '#34336f', leaf: '#7fc7b0', vein: '#5aa08c', feet: '#34336f' }, - }, -]; - -/** Bubble shapes offered for captions. */ -export const BUBBLE_STYLES = [ - { value: 'round', label: 'まる' }, - { value: 'rect', label: 'しかく' }, - { value: 'shout', label: 'さけぶ' }, - { value: 'whisper', label: 'ささやき' }, - { value: 'think', label: '思考' }, - { value: 'none', label: 'なし' }, -]; - -/** A couple of ready-made captions, so the feature is discoverable. */ -export const CAPTION_PRESETS = [ - { id: 'greeting', label: 'あいさつ', caption: { enabled: true, text: 'こんにちは、ぶるべーです。' } }, - { id: 'explain', label: '説明', caption: { enabled: true, text: 'ここがポイントです。', bubble: 'rect', tail: 'left' } }, - { id: 'surprise', label: 'びっくり', caption: { enabled: true, text: 'えっ!?', bubble: 'shout', tail: 'left', fontSize: 40, bold: true } }, -]; -- cgit v1.3.1