aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/faceArt.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 14:13:21 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 14:13:21 +0900
commit174886a483d90756b1320c12aef0e6f296c6d425 (patch)
tree9683ef2b818cbb13293f0fab6d3096c76e541d49 /public/bluebey-studio/src/faceArt.js
parent4808fc1cd44737200cfd51747a628585185f1f9f (diff)
bluebey-studio: 目メニューを再編し、閉じ目にもまぶたを効かせる
閉じ目を眼球の中身として上まぶた・下まぶたで隠せるようにした。まぶた設定を「まぶた」グループに、ハートの色を目の形の直下に、瞳と形の大きさを「大きさ」に統合。こまかい設定は解体し、横位置・手描きは個別の箱に分けた。
Diffstat (limited to 'public/bluebey-studio/src/faceArt.js')
-rw-r--r--public/bluebey-studio/src/faceArt.js169
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();