aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/caption.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-06 09:56:21 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-06 09:56:21 +0900
commit8f39a174d6c4415c7a4df418187090fb5e061983 (patch)
treedbd3cf1e4f67393f029a455c4226479fbefe4804 /public/bluebey-studio/src/caption.js
parent63c1a9dfa8b04ab9725a9a49ef34cd3fc6ade56c (diff)
bluebey-studio: ボーンのたて回転・縦書きと吹き出しの形・線画の帽子小物・光の色
ボーンのたてスライダーが±180°で暴れる問題を修正。セリフの縦書きを禁則込みで直し、「ささやき」「思考」の吹き出しを追加。帽子・小物の輪郭線を線画のみにし、線画で帯や芯の境目が読めるよう調整。眼鏡の左右レンズ間隔、光と環境光の色を追加。ハートの下まぶた、レンズが濃いときのまばたきを修正。口メニューの項目名を整理。「顔/すべてをランダム」に眼鏡・Oの口・ひげの大きさ・目の左右位置などを追加。
Diffstat (limited to 'public/bluebey-studio/src/caption.js')
-rw-r--r--public/bluebey-studio/src/caption.js239
1 files changed, 222 insertions, 17 deletions
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
@@ -71,6 +71,23 @@ const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']);
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
@@ -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(`<path d="${bubble}" fill="${fill}"${stroke}/>`);
}
@@ -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)
@@ -729,6 +777,162 @@ function shoutEdges(box, scale) {
}
/**
+ * 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.
*
* The spike is inserted *into* the outline, not appended to it: the two sides
@@ -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';
}