From 33557794b50815ff43204a81dfce71d3d95fa1c3 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:20:51 +0900 Subject: bluebey-studio: まぶたの形と目の傾きを追加し、細目時のまばたきを修正 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「直線(平ら)」と「目の傾き」を「目を細める」の隣に移動・追加(左右で反転)。上まぶたが下まぶたの位置まで下りて閉じるようにし、細目でもまばたきで最後まで閉じるようにした。 --- public/bluebey-studio/src/faceArt.js | 54 +++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 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 c5eca6e..6a870fa 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -485,11 +485,24 @@ 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, lower, flat = false) { +function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) { circlePath(ctx, eye.cx, eye.cy, eye.r); ctx.clip(); - const upper = 1 - clamp(open, 0, 1); + const tilt = (clamp(tiltDeg, -45, 45) * Math.PI) / 180; + const rotated = Math.abs(tilt) > 1e-4; + const spin = () => { + ctx.translate(eye.cx, eye.cy); + ctx.rotate(tilt); + ctx.translate(-eye.cx, -eye.cy); + }; + if (rotated) spin(); + + const lower = clamp(lowerLid, 0, 1); + // The upper lid travels all the way down to the lower lid's resting line, so + // the eye shuts at open = 0 whatever the lower lid is doing: a blink closes + // fully even on a squint. + const upper = (1 - lower) * (1 - clamp(open, 0, 1)); if (upper > 0.0005) { const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; const lowest = eye.cy - eye.r + 2 * eye.r * upper; @@ -510,7 +523,7 @@ function clipEye(ctx, eye, open, lower, flat = false) { ctx.clip(); } - const bottom = clamp(lower, 0, 1); + const bottom = lower; if (bottom > 0.0005) { const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; const highest = eye.cy + eye.r - 2 * eye.r * bottom; @@ -529,6 +542,13 @@ function clipEye(ctx, eye, open, lower, flat = false) { ctx.closePath(); ctx.clip(); } + + if (rotated) { + // Undo the rotation for the caller, but keep the clip we just set. + ctx.translate(eye.cx, eye.cy); + ctx.rotate(-tilt); + ctx.translate(-eye.cx, -eye.cy); + } } function lidPath(ctx, eye, amount, lower, flat = false) { @@ -1337,6 +1357,8 @@ export function drawEyes(ctx, p) { const heartTravel = EYE_LAYOUT.radius * 0.9; const lowerLid = clamp(style.lowerLid ?? 0, 0, 1); const lidFlat = (style.lidShape ?? 'curve') === 'flat'; + // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint). + const lidTilt = clamp(style.lidTilt ?? 0, -45, 45); // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here // rather than inside the per-eye loop below. 鼻ちょうちん comes along too. @@ -1403,7 +1425,7 @@ export function drawEyes(ctx, p) { // 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, lowerLid * (1 - open), lidFlat); + if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose); const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax; let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel; @@ -1461,25 +1483,31 @@ export function drawEyes(ctx, p) { // Faded in as the lid starts to cover the eye, so opening the eye all the // way leaves the clean original artwork with no extra line. const lidFade = clamp((0.95 - open) / 0.1, 0, 1); + // The tilt is mirrored between the eyes, so a positive value reads the same + // way on both (an inward, angry-looking squint). + const eyeTilt = lidTilt * eye.towardNose; + // The upper lid line has to sit where the clip put it: it travels from the + // top of the eye down to the lower lid's resting line. + const upperAmt = lowerLid + open * (1 - lowerLid); if (lidFade > 0.01 || lowerLid > 0.01) { ctx.save(); + if (eyeTilt) { + ctx.translate(eye.cx, eye.cy); + ctx.rotate((eyeTilt * Math.PI) / 180); + ctx.translate(-eye.cx, -eye.cy); + } circlePath(ctx, eye.cx, eye.cy, eye.r); ctx.clip(); ctx.globalAlpha = line ? 1 : lidFade; ctx.strokeStyle = lineColor; ctx.lineWidth = lidWidth; if (lidFade > 0.01) { - lidPath(ctx, eye, open, false, lidFlat); + lidPath(ctx, eye, upperAmt, false, lidFlat); ctx.stroke(); } - // The lower lid fades out as the eye closes (the upper lid does the work), - // and the *line* has to use the same amount as the clip above - using the - // raw `lowerLid` left the line floating inside the eyeball, which read as a - // second (doubled) lid. - const lower = lowerLid * (1 - open); - if (lower > 0.01) { - ctx.globalAlpha = line ? 1 : lower; - lidPath(ctx, eye, lower, true, lidFlat); + if (lowerLid > 0.01) { + ctx.globalAlpha = line ? 1 : lowerLid; + lidPath(ctx, eye, lowerLid, true, lidFlat); ctx.stroke(); } ctx.restore(); -- cgit v1.3.1