diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 21:46:21 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 21:46:21 +0900 |
| commit | 7329ba8c8901ee095ad7316b8554c5e44431eebc (patch) | |
| tree | 471d3994df66e38a3bd12b38d2899193985c9f2f /public/bluebey-studio | |
| parent | 27220fe6aa19f8e63be3984be1bc3288002252f7 (diff) | |
bluebey-studio: 縦書きのセリフとナレーション枠、コック帽改善、小物に鉛筆とコップなど
セリフを縦書き可能にし、縦書きのナレーションボックスを追加。ハートでも下まぶたを閉じられるようにし、ふつうの目の瞳を少し鼻側へ。HairPlateは髪が無いとき非表示(iPhoneの色ずれ対策)。コック帽をトック帽に、鉛筆とコップを小物に追加。
Diffstat (limited to 'public/bluebey-studio')
| -rw-r--r-- | public/bluebey-studio/src/caption.js | 84 | ||||
| -rw-r--r-- | public/bluebey-studio/src/face.js | 35 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 13 | ||||
| -rw-r--r-- | public/bluebey-studio/src/hats.js | 20 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 2 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 13 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 32 | ||||
| -rw-r--r-- | public/bluebey-studio/src/props.js | 22 |
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. */ |
