diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 14:13:21 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-05 14:13:21 +0900 |
| commit | 174886a483d90756b1320c12aef0e6f296c6d425 (patch) | |
| tree | 9683ef2b818cbb13293f0fab6d3096c76e541d49 /public/bluebey-studio/src/faceArt.js | |
| parent | 4808fc1cd44737200cfd51747a628585185f1f9f (diff) | |
bluebey-studio: 目メニューを再編し、閉じ目にもまぶたを効かせる
閉じ目を眼球の中身として上まぶた・下まぶたで隠せるようにした。まぶた設定を「まぶた」グループに、ハートの色を目の形の直下に、瞳と形の大きさを「大きさ」に統合。こまかい設定は解体し、横位置・手描きは個別の箱に分けた。
Diffstat (limited to 'public/bluebey-studio/src/faceArt.js')
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 169 |
1 files changed, 97 insertions, 72 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 3eb106f..04dd6e3 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -489,10 +489,8 @@ function rotate(points, pivot, degrees) { * shaded face behind it. That is what makes the eyelid blend perfectly without * baking a face-coloured background into the texture. */ -function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { - circlePath(ctx, eye.cx, eye.cy, eye.r); - ctx.clip(); - +/** Clip to the upper and lower lids only, without the eyeball circle. */ +function clipLids(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { const tilt = (clamp(tiltDeg, -45, 45) * Math.PI) / 180; const rotated = Math.abs(tilt) > 1e-4; const spin = () => { @@ -557,6 +555,13 @@ function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { } } +/** Clip to the eyeball circle as well (the iris and heart are eyeball content). */ +function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { + circlePath(ctx, eye.cx, eye.cy, eye.r); + ctx.clip(); + clipLids(ctx, eye, open, lowerLid, flat, tiltDeg); +} + function lidPath(ctx, eye, amount, lower, flat = false) { ctx.beginPath(); if (flat) { @@ -1316,6 +1321,23 @@ function drawGlasses(ctx, eye, line, style, limits) { * @param {object} p.eyes `{ left, right }`, each `{ open, lookX, lookY, closed }` * @param {object} [p.style] colours and shared shaping parameters */ +/** + * Which artwork an eye shows: 'open', 'heart', 'line1'..'line3', 'three' or + * 'arch'. `shape` is the explicit choice; older saved states and the おまかせ + * rolls only set `open`/`closed`/`irisShape`, so this falls back to those. + */ +function eyeShapeKey(spec) { + // A fully lowered upper lid always means a shut artwork, even if a stale + // `shape` says otherwise - this is what keeps older saved links working. + if ((spec.open ?? 1) <= 0.02) { + if (spec.closed === 'three') return 'three'; + if (spec.closed === 'arch') return 'arch'; + return `line${clamp(Math.round(spec.closedLines ?? 1), 1, 3)}`; + } + if (spec.shape) return spec.shape; + return (spec.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open'; +} + export function drawEyes(ctx, p) { const mode = p.mode ?? 'paint'; const line = mode === 'line'; @@ -1325,12 +1347,9 @@ export function drawEyes(ctx, p) { const irisColor = style.iris ?? '#150e1b'; const lineColor = style.line ?? '#55386e'; const lidWidth = style.lidWidth ?? EYE_LAYOUT.lidStroke; - const irisScale = style.irisScale ?? 1; const lookMax = style.lookMax ?? 1; const heartScale = 2; const heartColor = style.heartColor ?? '#e0344f'; - const irisRadius = EYE_LAYOUT.irisRadius * irisScale; - const travel = Math.max(0, EYE_LAYOUT.radius - irisRadius); const heartTravel = EYE_LAYOUT.radius * 0.9; const lidFlat = (style.lidShape ?? 'curve') === 'flat'; // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint). @@ -1342,7 +1361,6 @@ export function drawEyes(ctx, p) { for (const layout of EYE_LAYOUT.eyes) { const spec = (p.eyes ?? {})[layout.key] ?? {}; - const open = clamp(spec.open ?? 1, 0, 1); const eye = { // `eyeX` slides this eye - and its lid, brow and tear - sideways on its own. cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400), @@ -1351,14 +1369,35 @@ export function drawEyes(ctx, p) { towardNose: layout.towardNose, }; const lineStyle = { line: lineColor, width: lidWidth }; - const isHeart = (spec.irisShape ?? 'circle') === 'heart'; - // The lower lid is per eye (falling back to the shared value). + // Which artwork this eye shows. `shape` is the explicit choice; older saved + // states and the おまかせ rolls only set `open`/`closed`, so fall back. + const shapeKey = eyeShapeKey(spec); + const explicitShut = typeof spec.shape === 'string' + && (spec.shape === 'three' || spec.shape === 'arch' || spec.shape.startsWith('line')); + const isHeart = shapeKey === 'heart'; + const isShutShape = shapeKey === 'three' || shapeKey === 'arch' || shapeKey.startsWith('line'); + // The shut artwork reads `closed`/`closedLines`; build them from `shape` so a + // line, the 3 and the arch share one path with the older fields. + const shapeSpec = isShutShape + ? { + ...spec, + closed: shapeKey === 'three' ? 'three' : (shapeKey === 'arch' ? 'arch' : 'line'), + closedLines: shapeKey.startsWith('line') ? Number(shapeKey.slice(4)) : (spec.closedLines ?? 1), + } + : spec; + // The upper lid (`open`) and the lower lid are independent, and both shape + // every eye now: the shut artwork is drawn as the eye's content and is + // trimmed by them. A shut eye saved the old way stored open = 0 meaning + // "shut"; lift it or the artwork would be clipped away. An explicit shut + // `shape` keeps its own lid height, so the lid can be lowered onto it. + const rawOpen = spec.open ?? 1; + const open = clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1); const lowerLid = clamp(spec.lowerLid ?? style.lowerLid ?? 0, 0, 1); - // 形の大きさ: scales the drawn shape (shut lines, the 3, the arch, the heart). - const shapeScale = clamp(spec.shapeScale ?? 1, 0.4, 2); - // `white` is tri-state: an open eye shows its white, a shut eye or a heart - // does not, unless the switch forces it one way. - const showWhite = spec.white != null ? spec.white === true : (open > 0.02 && !isHeart); + // 大きさ: one knob for the iris, the heart and the shut artwork. + const shapeScale = clamp(style.irisScale ?? 1, 0.4, 2); + const irisRadius = EYE_LAYOUT.irisRadius * shapeScale; + // `white` is tri-state: only the plain open eye shows it by default. + const showWhite = spec.white != null ? spec.white === true : shapeKey === 'open'; // While the white is shown, a shape that would poke out of the eyeball is // scaled back inside it; `shapeHalfLimit` is the half-size it may reach. const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity; @@ -1399,9 +1438,11 @@ export function drawEyes(ctx, p) { ctx.restore(); }; - // Where the pupil is looking. Every eye shape shares it: a shut eye's line, a - // 3 or an arch shift with the gaze too, not just the round eyeball. - const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax; + // Where the pupil is looking. Every eye shape shares it: the shut artwork + // shifts with the gaze too, not just the round eyeball. + const eyeTravel = isHeart + ? heartTravel * lookMax + : Math.max(0, EYE_LAYOUT.radius - irisRadius) * lookMax; let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel; let dy = -clamp(spec.lookY ?? 0, -1, 1) * eyeTravel; const dist = Math.hypot(dx, dy); @@ -1410,64 +1451,48 @@ export function drawEyes(ctx, p) { dy = (dy / dist) * eyeTravel; } - if (open <= 0.02 || open <= lowerLid + 0.02) { - // A shut eye can still be given a white backing (the "白目" switch), which - // reads as a line drawn across an eyeball rather than a bare line. This also - // catches a lower lid raised past the upper one, which reads as shut too. - if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); - // 目線 moves the drawn shape as well, so a shut eye can glance sideways. - ctx.save(); - ctx.translate(dx, dy); - drawShutEye(ctx, eye, spec, lineStyle, shapeScale, shapeHalfLimit); - ctx.restore(); - // The glasses sit over the shut-eye artwork too, but under the tear below. - drawGlasses(ctx, eye, line, style, limits); - drawTear(); - continue; - } - - // --- eyeball ------------------------------------------------------- + // --- the eye's content, clipped by the lids ------------------------- + // Every shape is drawn the same way now: a white backing, then the artwork + // (iris, heart or the shut line/3/arch), all trimmed by the upper and lower + // lids. The shut artwork reaches past the eyeball, so it is only lid-clipped. ctx.save(); - // A heart is not an eyeball, so once the eye is fully open it is not clipped - // to the eyeball circle - that is what lets a heart be much larger than the - // iris. While the lid is partly closed the clip is still needed. - if (!isHeart || open < 0.999) clipEye(ctx, eye, open, isHeart ? 0 : lowerLid, lidFlat, lidTilt * eye.towardNose); - - // The white of the eye (see `showWhite` above). + if (isShutShape) { + clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose); + } else if (!isHeart || open < 0.999) { + clipEye(ctx, eye, open, isHeart ? 0 : lowerLid, lidFlat, lidTilt * eye.towardNose); + } if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); - const irisX = eye.cx + dx; - const irisY = eye.cy + dy; - if (isHeart) { - // A heart is wider than a circle, so cap it at the plane rather than the - // eyeball; a red heart has no pupil highlight (it would sit on top of it). - // A heart is not an eyeball, so it may grow well past the eye: the plate - // keeps the whole canvas, and the eyes plate is drawn over the mouth, so a - // big heart has room. The cap only stops it from running off the canvas. - // With the white shown, the default size is the one that just fits inside - // the eyeball; 形の大きさ can still grow it past the white. - const heartFit = showWhite ? Math.min(1, shapeHalfLimit / (irisRadius * heartScale)) : 1; - const heartRadius = Math.min(irisRadius * heartScale * shapeScale * heartFit, EYE_LAYOUT.radius * 2.1); - fillHeart(ctx, irisX, irisY, heartRadius, line ? lineColor : heartColor); + if (isShutShape) { + ctx.translate(dx, dy); + drawShutEye(ctx, eye, shapeSpec, lineStyle, shapeScale, shapeHalfLimit); + } else if (isHeart) { + // A heart is not an eyeball, so it may grow past the eye up to the plate. + // With the white shown, its default size is what just fits inside it; the + // single 大きさ knob can still grow it past the white. + const heartFit = showWhite ? Math.min(1, shapeHalfLimit / (EYE_LAYOUT.irisRadius * heartScale)) : 1; + const heartRadius = Math.min(EYE_LAYOUT.irisRadius * heartScale * shapeScale * heartFit, EYE_LAYOUT.radius * 2.1); + fillHeart(ctx, eye.cx + dx, eye.cy + dy, heartRadius, line ? lineColor : heartColor); } else { + const irisX = eye.cx + dx; + 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. - const highlightOn = spec.highlight != null ? spec.highlight === true : style.highlight !== false; - if (highlightOn && !isHeart) { - const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * irisScale; - const hy = irisY + EYE_LAYOUT.highlightOffset.y * irisScale; - const hr = EYE_LAYOUT.highlightRadius * irisScale; - if (line) { - // Punch the sparkle out of the iris rather than painting it white. On - // screen the paper behind shows through, and in the SVG export (which - // only sees alpha) it stays a hole in the pupil - both of which read as - // the white glint of the original artwork. - ctx.save(); - ctx.globalCompositeOperation = 'destination-out'; - fillCircle(ctx, hx, hy, hr, '#000000'); - ctx.restore(); - } else { - fillCircle(ctx, hx, hy, hr, white); + // 光彩 (the white glint) is per eye, falling back to the shared switch. + const highlightOn = spec.highlight != null ? spec.highlight === true : style.highlight !== false; + if (highlightOn) { + const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * shapeScale; + const hy = irisY + EYE_LAYOUT.highlightOffset.y * shapeScale; + const hr = EYE_LAYOUT.highlightRadius * shapeScale; + if (line) { + // Punch the sparkle out of the iris rather than painting it white. On + // screen the paper behind shows through, and in the SVG export (which + // only sees alpha) it stays a hole in the pupil. + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + fillCircle(ctx, hx, hy, hr, '#000000'); + ctx.restore(); + } else { + fillCircle(ctx, hx, hy, hr, white); + } } } ctx.restore(); |
