From 8f39a174d6c4415c7a4df418187090fb5e061983 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:56:21 +0900 Subject: bluebey-studio: ボーンのたて回転・縦書きと吹き出しの形・線画の帽子小物・光の色 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ボーンのたてスライダーが±180°で暴れる問題を修正。セリフの縦書きを禁則込みで直し、「ささやき」「思考」の吹き出しを追加。帽子・小物の輪郭線を線画のみにし、線画で帯や芯の境目が読めるよう調整。眼鏡の左右レンズ間隔、光と環境光の色を追加。ハートの下まぶた、レンズが濃いときのまばたきを修正。口メニューの項目名を整理。「顔/すべてをランダム」に眼鏡・Oの口・ひげの大きさ・目の左右位置などを追加。 --- public/bluebey-studio/src/caption.js | 239 ++++++++++++++++++++++++++++++++--- 1 file changed, 222 insertions(+), 17 deletions(-) (limited to 'public/bluebey-studio/src/caption.js') diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js index a315971..fd418da 100644 --- a/public/bluebey-studio/src/caption.js +++ b/public/bluebey-studio/src/caption.js @@ -70,6 +70,23 @@ 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. * @@ -270,6 +287,10 @@ export function drawCaption(ctx, caption, { width, height, scale = 1, font = nul 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(); } } @@ -332,6 +353,9 @@ export function captionToSvg(caption, { width, height, scale = 1, font = null, r 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(``); } @@ -528,6 +552,10 @@ function verticalOffsetOf(ch) { * 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 @@ -540,23 +568,36 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { columns.push({ text: '', count: 0 }); continue; } - let current = ''; - let count = 0; - for (const ch of chars) { - if (count >= limit) { - // A closing mark that would start a column hangs off the previous one. - if (HANGING_PUNCTUATION.has(ch)) { - current += ch; - continue; - } - columns.push({ text: current, count }); - current = ''; - count = 0; + 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; } - current += ch; - count += 1; + columns.push({ text: cells.join('') + hanging.join(''), count: cells.length }); } - columns.push({ text: current, count }); } const longest = columns.reduce((max, column) => Math.max(max, column.count), 0); return { @@ -565,7 +606,12 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { width: fontSize, hang: [...column.text].length - column.count, })), - width: columns.length * fontSize * lineHeight, + // 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, }; @@ -644,6 +690,8 @@ function bubbleCommands(box, state, scale) { }; 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) @@ -728,6 +776,162 @@ function shoutEdges(box, scale) { 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); + const pitch = Math.max(scale * 8, Math.min(w, h) * 0.42); + const count = Math.max(6, Math.round(perimeter / pitch)); + const points = sampleRing(ring, count); + const amp = Math.min((perimeter / count) * 0.5, Math.min(w, h) * 0.18); + + const edges = []; + 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 }; + const nx = mid.x - cx; + const ny = mid.y - cy; + const length = Math.hypot(nx, ny) || 1; + edges.push(scallopEdge(a, b, { x: nx / length, y: ny / length }, amp)); + } + return { edges, amp }; +} + +/** 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, + }; + const span = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5); + const room = span - outer; + if (!(room > 0)) return []; + + const r1 = room * 0.22; + const r2 = room * 0.13; + const r3 = room * 0.07; + const gap = room * 0.05; + const d1 = outer + r1; + const d2 = d1 + r1 + gap + r2; + const d3 = d2 + r2 + gap + r3; + 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 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. * @@ -923,7 +1127,8 @@ function resolveFontSize(caption, scale) { function bubbleOf(caption) { const value = caption?.bubble; - if (value === 'round' || value === 'rect' || value === 'shout' || value === 'none') return value; + if (value === 'round' || value === 'rect' || value === 'shout' + || value === 'whisper' || value === 'think' || value === 'none') return value; return 'round'; } -- cgit v1.3.1