aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 21:46:21 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 21:46:21 +0900
commit7329ba8c8901ee095ad7316b8554c5e44431eebc (patch)
tree471d3994df66e38a3bd12b38d2899193985c9f2f
parent27220fe6aa19f8e63be3984be1bc3288002252f7 (diff)
bluebey-studio: 縦書きのセリフとナレーション枠、コック帽改善、小物に鉛筆とコップなど
セリフを縦書き可能にし、縦書きのナレーションボックスを追加。ハートでも下まぶたを閉じられるようにし、ふつうの目の瞳を少し鼻側へ。HairPlateは髪が無いとき非表示(iPhoneの色ずれ対策)。コック帽をトック帽に、鉛筆とコップを小物に追加。
-rw-r--r--public/bluebey-studio/src/caption.js84
-rw-r--r--public/bluebey-studio/src/face.js35
-rw-r--r--public/bluebey-studio/src/faceArt.js13
-rw-r--r--public/bluebey-studio/src/hats.js20
-rw-r--r--public/bluebey-studio/src/main.js2
-rw-r--r--public/bluebey-studio/src/panel.js13
-rw-r--r--public/bluebey-studio/src/presets.js32
-rw-r--r--public/bluebey-studio/src/props.js22
8 files changed, 187 insertions, 34 deletions
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js
index 15f5bf3..1543f0e 100644
--- a/public/bluebey-studio/src/caption.js
+++ b/public/bluebey-studio/src/caption.js
@@ -118,12 +118,19 @@ export function layoutCaption(caption, { width, height, scale = 1, font = null }
const requested = toNonNegative(state.padding, DEFAULT_PADDING) * resolvedScale;
const text = typeof state.text === 'string' ? state.text : '';
- const block = layoutBlock(font, text, {
- fontSize,
- maxWidth: resolveColumn(state, outWidth, requested),
- lineHeight,
- align: alignOf(state),
- });
+ 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.
@@ -169,6 +176,7 @@ export function layoutCaption(caption, { width, height, scale = 1, font = null }
lineHeight,
padding,
scale: resolvedScale,
+ vertical,
usedOutlines: Boolean(font),
};
}
@@ -275,6 +283,9 @@ export function captionToSvg(caption, { width, height, scale = 1, font = null, r
const text = typeof state.text === 'string' ? state.text : '';
let usedOutlines = Boolean(font) && text.trim() !== '' && hasGlyphs(font, text);
+ // Vertical text falls back to `<text>`: the outline path lays glyphs out
+ // horizontally, and a column of glyphs reads fine as live text.
+ if (layout.vertical) usedOutlines = false;
const parts = [
'<svg xmlns="http://www.w3.org/2000/svg"'
@@ -339,6 +350,19 @@ function drawText(ctx, layout, state, font) {
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
+ if (layout.vertical) {
+ // Columns run right to left; each glyph sits on its own line, upright.
+ 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 y = top + j * fontSize + metrics.ascent;
+ ctx.fillText(chars[j], x, y);
+ }
+ }
+ return;
+ }
+
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const x = left + alignOffset(align, blockWidth, line.width);
@@ -371,6 +395,26 @@ function liveText(state, layout, font, fill, places) {
const halfLeading = (step - (metrics.ascent + metrics.descent)) / 2;
const top = box.y + padding;
+ if (layout.vertical) {
+ const attributes = [
+ `font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
+ `font-size="${formatNumber(fontSize, places)}"`,
+ state.bold ? 'font-weight="bold"' : '',
+ `fill="${escapeAttribute(fill)}"`,
+ 'text-anchor="start"',
+ ].filter(Boolean).join(' ');
+ const tspans = [];
+ 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 y = top + j * fontSize + metrics.ascent;
+ tspans.push(`<tspan x="${formatNumber(x, places)}" y="${formatNumber(y, places)}">${escapeText(chars[j])}</tspan>`);
+ }
+ }
+ return `<text ${attributes}>${tspans.join('')}</text>`;
+ }
+
const attributes = [
`font-family="${escapeAttribute(fontFamilyStack(state, Boolean(font)))}"`,
`font-size="${formatNumber(fontSize, places)}"`,
@@ -430,6 +474,34 @@ function layoutBlock(font, text, options) {
return wrapByCount(text, options);
}
+/**
+ * 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.
+ */
+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('');
+ continue;
+ }
+ for (let i = 0; i < chars.length; i += limit) {
+ columns.push(chars.slice(i, i + limit).join(''));
+ }
+ }
+ const longest = columns.reduce((max, column) => Math.max(max, [...column].length), 0);
+ return {
+ lines: columns.map((column) => ({ text: column, width: fontSize })),
+ width: columns.length * fontSize * lineHeight,
+ height: longest * fontSize,
+ };
+}
+
/** 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);
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js
index ac48527..3d21022 100644
--- a/public/bluebey-studio/src/face.js
+++ b/public/bluebey-studio/src/face.js
@@ -278,19 +278,28 @@ export class Face {
}
if (this.hairMesh) {
- // `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;
- 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;
+ 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;
+ 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/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 829defc..e63f197 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -131,7 +131,10 @@ export const EYE_LAYOUT = {
},
arch: { edgesUp: 8, apexUp: 44 },
// まつげ: a few strokes flicking out from the upper-outer lid (see drawEyes).
- lash: { length: 30, width: 8, angles: [46, 66, 86] },
+ // `raise` lifts them a touch above the lid edge (2% of the lid slider).
+ lash: { length: 30, width: 8, angles: [46, 66, 86], raise: 0.04 },
+ // "ふつう"の目の瞳を、両目とも少し鼻側に寄せてかわいく見せる(px)。
+ irisInward: 7,
};
/**
@@ -1481,7 +1484,7 @@ export function drawEyes(ctx, p) {
if (isShutShape && !showWhite) {
clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
} else if (!isHeart || open < 0.999 || showWhite) {
- clipEye(ctx, eye, open, isHeart ? 0 : lowerLid, lidFlat, lidTilt * eye.towardNose);
+ clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
}
if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
if (isShutShape) {
@@ -1490,7 +1493,7 @@ export function drawEyes(ctx, p) {
} else if (isHeart) {
fillHeart(ctx, eye.cx + dx, eye.cy + dy, heartRadius, line ? lineColor : heartColor);
} else {
- const irisX = eye.cx + dx;
+ const irisX = eye.cx + dx + eye.towardNose * EYE_LAYOUT.irisInward;
const irisY = eye.cy + dy;
fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor);
// 光彩 (the white glint) is per eye, falling back to the shared switch.
@@ -1532,7 +1535,7 @@ export function drawEyes(ctx, p) {
const upperAmt = clamp(open, 0, 1);
// A heart is a replacement for the eyeball, not an eye behind a lid: it keeps
// no lower-lid line, which used to cut across the heart.
- const showLowerLid = lowerLid > 0.01 && !isHeart;
+ const showLowerLid = lowerLid > 0.01;
if (lidFade > 0.01 || showLowerLid) {
ctx.save();
if (eyeTilt) {
@@ -1586,7 +1589,7 @@ export function drawEyes(ctx, p) {
const dy = ux * sa + uy * ca;
const r0 = eye.r * 0.96;
const bx = eye.cx + ux * r0;
- const by = eye.cy + uy * r0 + lidDrop;
+ const by = eye.cy + uy * r0 + lidDrop - eye.r * EYE_LAYOUT.lash.raise;
strokePolyline(ctx, [
{ x: bx, y: by },
{ x: bx + dx * EYE_LAYOUT.lash.length, y: by + dy * EYE_LAYOUT.lash.length },
diff --git a/public/bluebey-studio/src/hats.js b/public/bluebey-studio/src/hats.js
index c639548..3fb65f3 100644
--- a/public/bluebey-studio/src/hats.js
+++ b/public/bluebey-studio/src/hats.js
@@ -60,19 +60,25 @@ function buildCap() {
return group;
}
-/** コック帽: a tall white band under a puffy crown. */
+/** コック帽: a tall pleated toque - a cylindrical band under a big puffy crown. */
function buildChef() {
const group = new THREE.Group();
const white = material('#f7f6f2');
const shade = material('#e6e3da');
- add(group, cylinder(0.94, 0.98, 0.62, 28), white, 0, 0.31, 0);
- const puff = add(group, sphere(1.22, 24, 18), white, 0, 0.86, 0);
+ // The band: a tall cylinder that sits on the head.
+ add(group, cylinder(0.86, 0.92, 0.95, 28), white, 0, 0.475, 0);
+ add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0);
+ // The crown: a big squashed sphere ringed with lobes, so the top reads as the
+ // classic pleated toque rather than a plain dome.
+ const puff = add(group, sphere(1.24, 26, 20), white, 0, 1.30, 0);
puff.scale.set(1, 0.82, 1);
- for (let i = 0; i < 6; i += 1) {
- const a = (i / 6) * Math.PI * 2;
- const lobe = add(group, sphere(0.44, 14, 12), shade, Math.cos(a) * 0.86, 1.22, Math.sin(a) * 0.86);
- lobe.scale.set(1, 0.85, 1);
+ for (let i = 0; i < 8; i += 1) {
+ const a = (i / 8) * Math.PI * 2;
+ const lobe = add(group, sphere(0.5, 14, 12), white, Math.cos(a) * 0.92, 1.52, Math.sin(a) * 0.92);
+ lobe.scale.set(1, 0.9, 1);
}
+ const top = add(group, sphere(0.62, 16, 12), white, 0, 1.92, 0);
+ top.scale.set(1, 0.78, 1);
return group;
}
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index b2437b3..cecdce5 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -659,7 +659,7 @@ async function init() {
let captionFont = null;
let captionFontPromise = null;
/** Where each bubble was last drawn, in CSS pixels (in state order). */
- const CAPTION_KEYS = ['caption', 'caption2'];
+ const CAPTION_KEYS = ['caption', 'caption2', 'narration'];
const captionBoxes = new Map();
const captionHandles = new Map();
/** Set while a bubble is being dragged. */
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 9e7a9ac..6d8e8ba 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -1373,12 +1373,13 @@ export function buildPanel(app, root) {
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 つまで出せます。①と②を切り替えて、それぞれ別の位置・形・色にできます。'));
+ captionSection.add(hint('吹き出しは①と②の2つ、ナレーション枠が1つ出せます。切り替えて、それぞれ別の位置・形・色・縦書きにできます。'));
const captionToggle = check({
label: 'この吹き出しを出す',
@@ -1395,6 +1396,13 @@ export function buildPanel(app, root) {
});
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) => ({
@@ -2998,6 +3006,7 @@ export function buildPanel(app, root) {
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)) {
@@ -3043,7 +3052,7 @@ export function buildPanel(app, root) {
onRigChanged: syncBones,
/** Which bubble the caption controls are editing (a drag on it calls this). */
selectCaption(key) {
- if (key !== 'caption' && key !== 'caption2') return;
+ if (key !== 'caption' && key !== 'caption2' && key !== 'narration') return;
activeCaption = key;
sync();
},
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 9baa3a4..6bcf724 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -36,11 +36,41 @@ function captionDefaults() {
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.
@@ -336,6 +366,8 @@ export function defaultState() {
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
diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js
index 3f09651..70a258d 100644
--- a/public/bluebey-studio/src/props.js
+++ b/public/bluebey-studio/src/props.js
@@ -353,6 +353,24 @@ const buildFuton = prop((group, material) => {
* default variant: an open cardboard box stands about a third taller again, so
* the offered scale stays sensible either way.
*/
+/** 鉛筆: an oversized pencil standing on its eraser, point up. */
+const buildPencil = prop((group, material) => {
+ const r = 0.13;
+ addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6);
+ addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6);
+ addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6);
+ addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6);
+ addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.94, 0], 6);
+});
+
+/** コップ: a mug with a rim, a handle and a little drink inside. */
+const buildCup = prop((group, material) => {
+ addCylinder(group, material('paper'), 0.34, 0.30, 0.66, [0, 0.33, 0], 18);
+ addCylinder(group, material('paper', 0.86), 0.30, 0.30, 0.05, [0, 0.645, 0], 18);
+ addCylinder(group, material('wood', 0.5), 0.28, 0.28, 0.02, [0, 0.61, 0], 18);
+ addMesh(group, new THREE.TorusGeometry(0.16, 0.045, 8, 16), material('paper'), 0.36, 0.36, 0);
+});
+
export const PROP_LIBRARY = [
{ id: 'podium', label: '演台', height: 2.2, build: buildPodium },
{ id: 'desk', label: '机', height: 1.5, build: buildDesk },
@@ -363,6 +381,8 @@ export const PROP_LIBRARY = [
{ id: 'can', label: '空いた缶詰', height: 0.65, build: buildCan },
{ id: 'bed', label: 'ベッド', height: 2.0, build: buildBed },
{ id: 'futon', label: '布団', height: 0.65, build: buildFuton },
+ { id: 'pencil', label: '鉛筆', height: 2.0, build: buildPencil },
+ { id: 'cup', label: 'コップ', height: 0.66, build: buildCup },
];
/**
@@ -385,6 +405,8 @@ export const PROP_DEFAULTS = {
can: { x: 1.7, y: 0, z: 1.5, rotY: -0.5, scale: 1 },
bed: { x: 0, y: 0, z: -4.7, rotY: 0, scale: 1 },
futon: { x: 4.2, y: 0, z: -0.6, rotY: -0.35, scale: 1 },
+ pencil: { x: -1.4, y: 0, z: 2.6, rotY: 0.3, scale: 1 },
+ cup: { x: 1.5, y: 0, z: 2.5, rotY: -0.4, scale: 1 },
};
/** The palette entry each part reads, derived from the app's colours. */