From a1337fab526a9babd5c492537bd7aee4cc5e7d26 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:27:24 +0900 Subject: public/bluebey-studio: 立体の鼻ちょうちん・仰向けポーズ・ひげなどを追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 鼻ちょうちんをテクスチャ描画から3Dオブジェクトに変更。鼻の下に泡を 出し、master ボーンに親子付けて体の傾きに追従する - 寝ているポーズを仰向けにし、背中が地面につく高さに調整 - ポーズを変えたら「てをふる」の動きを「とまる」に戻す - スマホでメニュー表示中に画面外をタップするとメニューを閉じる - 眉の長さを、左右がつながるくらいまで伸ばせるように - 眼鏡・サングラスの「種類」を選ぶと「眼鏡をかける」を自動でオンに - マウス操作からボーン選択を外し、ドラッグで移動・Shift+ドラッグで 向き変更にした(ボーンの操作はメニューの一覧から) - 頭の模様: 大きさをより小さくでき、ツートンを廃止、「わけめ」に分け目 を入れ、「まえがみ(逆)」(上に凸)を追加 - ひげ(ちょびひげ・カイゼル・エプロン・ねこ)を追加 --- public/bluebey-studio/src/faceArt.js | 187 ++++++++++++++++++----------------- 1 file changed, 94 insertions(+), 93 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 1beedad..a92482a 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -178,19 +178,14 @@ export const HEAD_MARK_LAYOUT = { }; /** - * 鼻ちょうちん: the little snot bubble that grows and shrinks while someone sleeps. - * - * It hangs off the nose, which is a *separate mesh* in front of the plate, so the - * bubble sits out to the side of it (a bubble drawn straight below would run off - * the plate's bottom edge). The neck starts at the nose and reaches out to the - * bubble, one thin wisp, the way manga draws it. + * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate + * at the nose, the same spot the snot bubble uses. `shape` picks a well-known + * kind; `size` scales it about the nose, and the colours are adjustable. */ -export const SNOT_LAYOUT = { - cx: 511.5, // the face midline, the same centre the mouth uses - cy: 336, // just below the eye row, level with the nose - offset: 122, // how far out to the side the bubble hangs, clear of the nose - radius: 48, // bubble radius at size 1 - lineWidth: 6, +export const BEARD_LAYOUT = { + cx: 511.5, + cy: 352, // just under the nose + width: 240, // base width of the widest beard at size 1 }; /** @@ -771,7 +766,7 @@ 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 size = clamp(spec.size ?? 1, 0.02, 3); const left = limits.left; const right = limits.right; const top = limits.top; @@ -788,28 +783,8 @@ function drawHeadCover(ctx, spec, limits) { ctx.clip(); 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(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(); + // Retired: the two-tone head was dropped, but an old saved state may still + // carry the shape, so treat it as off rather than drawing a two-tone head. ctx.restore(); return; } @@ -825,6 +800,11 @@ function drawHeadCover(ctx, spec, limits) { // 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)); } + if (shape === 'fringe-up') { + // The mirror image: a fringe that peaks *up* in the middle (a widow's peak + // or centre part), so more forehead shows in the middle than at the sides. + return base - 150 * (1 - Math.abs(t)) + 55 * Math.abs(Math.sin(t * Math.PI)); + } if (shape === 'wave') { // A single smooth sweep across the forehead. return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5); @@ -848,6 +828,23 @@ function drawHeadCover(ctx, spec, limits) { ctx.closePath(); ctx.fillStyle = color; ctx.fill(); + + if (shape === 'side') { + // わけめ: cut a real parting in the hair, so the two halves read as combed + // apart rather than as one smooth sweep. + const partX = cx - halfSpan * 0.45; + const gw = Math.max(5, width * 0.02) * (0.6 + size); + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + ctx.moveTo(partX - gw * 0.16, top); + ctx.lineTo(partX + gw * 0.16, top); + ctx.lineTo(partX + gw * 0.7, boundary(partX) + 4); + ctx.lineTo(partX - gw * 0.7, boundary(partX) + 4); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } ctx.restore(); } @@ -859,73 +856,77 @@ function drawFaceExtras(ctx, style, lineOnly) { drawCheeks(ctx, style.cheeks); // The head covering fills out to the plate's real edges, so it needs `limits`. drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS); - drawSnotBubble(ctx, style.snot, lineOnly); + drawBeard(ctx, style.beard, lineOnly); } /** - * 鼻ちょうちん: a bubble hanging off the nose, joined to it by a thin neck. The - * neck starts at the nose (which the nose mesh hides, so it reads as growing out - * from under it) and swells into the round bubble out to the side. In a line - * drawing the bubble is left empty and only its outline is inked. + * ひげ: a mustache or beard under the nose. Each kind is a small shape built on + * the spot; `scotch` and `cat` are drawn with round-capped strokes, `kaiser` is a + * thick waving band, and `apron` is a filled bib. In a line drawing nothing is + * filled and only the outlines are inked. */ -function drawSnotBubble(ctx, spec, lineOnly) { - if (!spec?.enabled) return; +function drawBeard(ctx, spec, lineOnly = false) { + const shape = spec?.shape ?? 'off'; + if (shape === 'off' || shape === 'none') return; const size = clamp(spec.size ?? 1, 0.2, 3); - const dx = spec.offsetX ?? 0; - const dy = spec.offsetY ?? 0; - const r = SNOT_LAYOUT.radius * size; - const cx = SNOT_LAYOUT.cx + SNOT_LAYOUT.offset + dx; - const cy = SNOT_LAYOUT.cy + dy; - const color = spec.color ?? '#e3ecff'; + const cx = BEARD_LAYOUT.cx + clamp(spec.offsetX ?? 0, -600, 600); + const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600); + const w = BEARD_LAYOUT.width * size; + const color = spec.color ?? '#3a2a4a'; const line = spec.line ?? '#55386e'; - const lw = Math.max(1, SNOT_LAYOUT.lineWidth * size); - - // The nose end sits on the midline; the bubble hangs out to one side. - const nx = SNOT_LAYOUT.cx + dx; - const ny = SNOT_LAYOUT.cy + dy - r * 0.1; - const dist = Math.hypot(cx - nx, cy - ny) || 1; - const ux = (cx - nx) / dist; - const uy = (cy - ny) / dist; - // Where the neck meets the bubble: a little inside its edge, so the two merge. - const reach = Math.max(0, dist - r * 0.72); - const ex = nx + ux * reach; - const ey = ny + uy * reach; + const lw = Math.max(2, 9 * size); + const stroke = (points, width) => strokePolyline(ctx, points, { + color: lineOnly ? line : color, width, + }); - ctx.save(); - ctx.lineJoin = 'round'; - ctx.lineCap = 'round'; - // The neck: a wedge from a narrow end at the nose to a wide end at the bubble. - const px = -uy; - const py = ux; - const nW = lw * 0.55; - const bW = r * 0.5; - ctx.beginPath(); - ctx.moveTo(nx + px * nW, ny + py * nW); - ctx.lineTo(ex + px * bW, ey + py * bW); - ctx.lineTo(ex - px * bW, ey - py * bW); - ctx.lineTo(nx - px * nW, ny - py * nW); - ctx.closePath(); - if (lineOnly) { - ctx.strokeStyle = line; - ctx.lineWidth = lw; - ctx.stroke(); - } else { - ctx.fillStyle = color; - ctx.fill(); - ctx.strokeStyle = line; - ctx.lineWidth = lw * 0.7; - ctx.stroke(); + if (shape === 'scotch') { + // ちょび髭: two short dashes under the nose, angled down and out so their tips + // clear the nose mesh (which hides anything drawn straight behind it). + stroke([{ x: cx - w * 0.07, y: cy + w * 0.02 }, { x: cx - w * 0.5, y: cy + w * 0.17 }], lw * 1.8); + stroke([{ x: cx + w * 0.07, y: cy + w * 0.02 }, { x: cx + w * 0.5, y: cy + w * 0.17 }], lw * 1.8); + return; } - // The bubble itself, over the neck's wide end. - circlePath(ctx, cx, cy, r); - if (!lineOnly) { - ctx.fillStyle = color; - ctx.fill(); + if (shape === 'kaiser') { + // カイゼル: a full, thick mustache whose ends turn up. + stroke([ + { x: cx - w * 0.5, y: cy - w * 0.02 }, + { x: cx - w * 0.28, y: cy + w * 0.07 }, + { x: cx, y: cy + w * 0.09 }, + { x: cx + w * 0.28, y: cy + w * 0.07 }, + { x: cx + w * 0.5, y: cy - w * 0.02 }, + ], lw * 2.6); + stroke([{ x: cx - w * 0.5, y: cy - w * 0.02 }, { x: cx - w * 0.62, y: cy - w * 0.16 }], lw * 1.4); + stroke([{ x: cx + w * 0.5, y: cy - w * 0.02 }, { x: cx + w * 0.62, y: cy - w * 0.16 }], lw * 1.4); + return; + } + if (shape === 'apron') { + // エプロン: a bib-shaped beard hanging from the cheeks to a rounded point. + const pts = [ + { x: cx - w * 0.6, y: cy - w * 0.04 }, + { x: cx - w * 0.5, y: cy + w * 0.35 }, + { x: cx - w * 0.22, y: cy + w * 0.62 }, + { x: cx, y: cy + w * 0.7 }, + { x: cx + w * 0.22, y: cy + w * 0.62 }, + { x: cx + w * 0.5, y: cy + w * 0.35 }, + { x: cx + w * 0.6, y: cy - w * 0.04 }, + ]; + if (lineOnly) { + strokePolyline(ctx, pts, { color: line, width: lw }); + } else { + fillPolygon(ctx, pts, color); + strokePolyline(ctx, pts, { color: line, width: lw * 0.5 }); + } + return; + } + // ねこ: three whiskers fanned out on each side of the muzzle. + for (const dir of [-1, 1]) { + for (let i = 0; i < 3; i += 1) { + stroke([ + { x: cx + dir * w * 0.08, y: cy + w * 0.04 }, + { x: cx + dir * w * 0.62, y: cy + w * 0.04 + (i - 1) * w * 0.18 }, + ], lw * 0.9); + } } - ctx.strokeStyle = line; - ctx.lineWidth = lw; - ctx.stroke(); - ctx.restore(); } /** -- cgit v1.3.1