aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/faceArt.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/faceArt.js')
-rw-r--r--public/bluebey-studio/src/faceArt.js187
1 files changed, 94 insertions, 93 deletions
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();
}
/**