From 34032981f60848940640e5741c9ced41a550c8f1 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:39:29 +0900 Subject: public/bluebey-studio: ポーズ追加・シーンタブ新設・タブのアイコン化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ポーズ例に「きおつけ」を追加し、おじぎ/ふかくおじぎを両手下げに、 くびかしげは左足を接地させたままにするよう補正、「てをふる」の動き を追加、全身の移動スライダーの範囲を拡大 - 「表情」タブを「顔」に改名し、「背景」「舞台」「セリフ」「画面・光」 「画面の情報」をまとめた「シーン」タブを新設 - 各タブをアイコンのみの表示にし、タブらしい見た目に変更(名前はツール チップと読み上げラベルに) - 起動時の注意書きと /bluebey/ ページの禁止事項を「イメージを損なう ような利用(たとえば、攻撃的・差別的・性的・過激な利用、誹謗中傷 など)」に変更 - これまでの未コミット分(背景の追加とWebP化、眼鏡・サングラス、ほっぺ ・頭の模様、缶詰、看板、集中線、輪郭線のズーム追従、スマホ対応、 共有など)を反映 - タッチ用の操作案内の
の閉じ位置を修正 --- public/bluebey-studio/src/faceArt.js | 245 ++++++++++++++++++++++------------- 1 file changed, 154 insertions(+), 91 deletions(-) (limited to 'public/bluebey-studio/src/faceArt.js') diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 65205fa..61bfa0d 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -147,17 +147,34 @@ 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.62, // half-height, as a fraction of the half-width - lines: 4, // hatch strokes across the patch - lineWidth: 5, - slant: 58, // hatching angle, degrees + 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 = { - cx: (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2, // the face midline - cy: 92, // up on the forehead, above the brows - size: 46, // nominal radius of a shape - lineWidth: 9, + // 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` }; /** @@ -212,8 +229,9 @@ const SUNGLASSES_LAYOUT = { bridgeLift: 0.25, // the bar sits this far above the lens centre (x half-height) templeLength: 1.15, // a short stub, like the round pair's but a little shorter templeRise: 24, - frameFactor: 0.6, // a thin rim, slimmer than the round pair's stroke - highlightWidth: 0.12, // glass highlight thickness, as a fraction of the half-width + // A heavy rim. It used to be slimmer than the round pair's stroke, which read + // as a thin pair of shades; the reference look is a chunky frame. + frameFactor: 1.6, }; /** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole @@ -666,31 +684,50 @@ function drawCheek(ctx, cx, cy, spec) { ctx.ellipse(cx, cy, r, ry, 0, 0, TAU); ctx.fillStyle = spec.color ?? '#f6a6b8'; ctx.fill(); - // Clip the hatching to the patch, so the strokes keep a clean edge. + // Clip the hatching to the patch, so a wobbly stroke keeps a clean edge. ctx.clip(); - const lines = Math.max(0, Math.round(spec.lines ?? CHEEK_LAYOUT.lines)); - if (lines > 0) { - const angle = (CHEEK_LAYOUT.slant * Math.PI) / 180; - const dx = Math.cos(angle); - const dy = -Math.sin(angle); - const reach = r * 1.3; - ctx.strokeStyle = spec.hatchColor ?? '#e0708f'; - ctx.lineWidth = Math.max(1, CHEEK_LAYOUT.lineWidth * size); - ctx.lineCap = 'round'; - for (let i = 0; i < lines; i += 1) { - // Spread the strokes evenly across the patch. - const t = lines === 1 ? 0 : i / (lines - 1) - 0.5; - const px = cx + t * r * 1.2; - ctx.beginPath(); - ctx.moveTo(px - dx * reach, cy - dy * reach); - ctx.lineTo(px + dx * reach, cy + dy * reach); - ctx.stroke(); - } + // 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; @@ -704,60 +741,97 @@ function drawCheeks(ctx, spec) { } /** - * 頭の模様: one of a few simple marks drawn on the forehead. Kept to a handful of - * shapes on purpose - the point is a generic manga device, not a particular - * character's artwork - and it starts off. + * 頭の模様: 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 drawHeadMark(ctx, spec) { - const shape = spec.shape ?? 'off'; +function drawHeadCover(ctx, spec, limits) { + const shape = spec?.shape ?? 'off'; if (shape === 'off' || shape === 'none') return; + const size = clamp(spec.size ?? 1, 0.2, 3); - const s = HEAD_MARK_LAYOUT.size * size; - const cx = HEAD_MARK_LAYOUT.cx + clamp(spec.offsetX ?? 0, -400, 400); - const cy = HEAD_MARK_LAYOUT.cy + clamp(spec.offsetY ?? 0, -400, 400); + 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(); - ctx.strokeStyle = spec.color ?? '#8a4fe0'; - ctx.fillStyle = spec.color ?? '#8a4fe0'; - ctx.lineWidth = Math.max(1, HEAD_MARK_LAYOUT.lineWidth * size); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; + // 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 === 'hook') { - // A short curved hook, like a comma. - ctx.beginPath(); - ctx.moveTo(cx - s * 1.05, cy + s * 0.45); - ctx.quadraticCurveTo(cx - s * 0.15, cy - s * 1.05, cx + s * 0.85, cy + s * 0.15); - ctx.stroke(); - } else if (shape === 'spiral') { - const steps = 60; - ctx.beginPath(); - for (let i = 0; i <= steps; i += 1) { - const t = i / steps; - const angle = t * TAU * 2.2; - const radius = s * (0.14 + 0.86 * t); - const x = cx + Math.cos(angle) * radius; - const y = cy + Math.sin(angle) * radius; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - } else if (shape === 'strokes') { - // Three short strokes standing side by side. - for (let i = -1; i <= 1; i += 1) { + if (shape === 'split') { + // Two-tone head: a vertical curve divides the head into two colours. `size` + // bows the seam, `offsetY` bows it the other way, `offsetX` slides it. + const bow = (size - 1) * 220 + clamp(spec.offsetY ?? 0, -600, 600); + const seam = (y) => cx + Math.sin(clamp((y - top) / (bottom - top), 0, 1) * Math.PI) * bow; + const steps = 32; + const trace = (dir) => { ctx.beginPath(); - ctx.moveTo(cx + i * s * 0.62, cy - s * 0.6); - ctx.lineTo(cx + i * s * 0.62, cy + s * 0.6); - ctx.stroke(); + ctx.moveTo(seam(top), top); + for (let i = 1; i <= steps; i += 1) { + const y = top + ((bottom - top) * i) / steps; + ctx.lineTo(seam(y), y); + } + ctx.lineTo(dir > 0 ? right : left, bottom); + ctx.lineTo(dir > 0 ? right : left, top); + ctx.closePath(); + }; + trace(-1); + ctx.fillStyle = color; + ctx.fill(); + trace(1); + ctx.fillStyle = color2; + ctx.fill(); + 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); + if (shape === 'fringe') { + // Bangs: a soft point dipping in the middle, with side lobes. + return base + 150 * (1 - Math.abs(t)) - 55 * Math.abs(Math.sin(t * Math.PI)); } - } else if (shape === 'dots') { - const radius = Math.max(1.5, ctx.lineWidth * 0.8); - for (let i = -1; i <= 1; i += 1) { - ctx.beginPath(); - ctx.arc(cx + i * s * 0.62, cy, radius, 0, TAU); - ctx.fill(); + if (shape === 'wave') { + // A single smooth sweep across the forehead. + return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5); + } + if (shape === 'side') { + // A side part: high on one side, low on the other. + return base + 260 * 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(); } @@ -767,7 +841,8 @@ function drawHeadMark(ctx, spec) { */ function drawFaceExtras(ctx, style) { drawCheeks(ctx, style.cheeks); - drawHeadMark(ctx, style.headMark); + // The head covering fills out to the plate's real edges, so it needs `limits`. + drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS); } /** @@ -861,18 +936,6 @@ function drawGlasses(ctx, eye, line, style, limits) { ctx.strokeStyle = colour; ctx.lineWidth = frame; ctx.stroke(); - // A light streak inside the glass, clipped to the lens so it can never spill - // over the frame. Shaded drawings only: 線画's lens is empty paper. - if (!line) { - ctx.save(); - traceRoundedPolygon(ctx, lens, ry * L.corner); - ctx.clip(); - strokePolyline(ctx, [at(-rx * 0.6, ry * 0.15), at(rx * 0.4, -ry * 0.85)], { - color: 'rgba(255, 255, 255, 0.5)', - width: Math.max(2, rx * L.highlightWidth), - }); - ctx.restore(); - } } else { ctx.beginPath(); ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU); @@ -915,13 +978,13 @@ function drawGlasses(ctx, eye, line, style, limits) { // --- temple ---------------------------------------------------------- // A stub outwards from the outer edge of the lens. It stays short on purpose: // the plate only reaches so far, and a temple that ran off it would smear. The - // round pair's outer edge is a vertical line at the lens centre height; the - // shades' outer end sits low, so their stub starts down there instead of - // floating below the lens. + // round pair's outer edge is a vertical line at the lens centre height. The + // shades' outer end drops low, so their arm leaves from the *top* edge instead + // - from the pointed outer corner - which is where a cat-eye's arm attaches. const outerX = shades - ? lensCx - eye.towardNose * rx * L.outerEndX + ? lensCx - eye.towardNose * rx * L.tipX : lensCx - eye.towardNose * rx; - const outerY = shades ? lensCy + ry * L.outerBottom * 0.7 : lensCy; + const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy; const reach = clamp( outerX - eye.towardNose * r * L.templeLength, limits.left + EDGE_MARGIN, -- cgit v1.3.1