aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src')
-rw-r--r--public/bluebey-studio/src/face.js14
-rw-r--r--public/bluebey-studio/src/faceArt.js107
-rw-r--r--public/bluebey-studio/src/main.js27
-rw-r--r--public/bluebey-studio/src/panel.js19
-rw-r--r--public/bluebey-studio/src/presets.js2
5 files changed, 73 insertions, 96 deletions
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js
index c1a0c72..0813c54 100644
--- a/public/bluebey-studio/src/face.js
+++ b/public/bluebey-studio/src/face.js
@@ -38,6 +38,9 @@ export class Face {
};
this.customNames = new Map();
+ // Drawings supplied for a beard kind (assets/beards/<shape>.png). Loaded by the
+ // app; a kind without one falls back to the built-in drawing (see drawBeard).
+ this.beardImages = {};
this.eyeCanvas = makeCanvas(this.eyeLayout);
this.mouthCanvas = makeCanvas(this.mouthLayout);
this.eyeCtx = this.eyeCanvas.getContext('2d');
@@ -131,6 +134,16 @@ export class Face {
return 'original';
}
+ /**
+ * Set the drawings used for the beards, keyed by beard kind (`scotch`, `kaiser`,
+ * `apron`, `cat`). Called by the app once the files are loaded; the next redraw
+ * picks them up.
+ */
+ setBeardImages(map) {
+ this.beardImages = map ?? {};
+ this.dirty = true;
+ }
+
/** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */
setSource(kind, source) {
if (kind === 'eyes') {
@@ -201,6 +214,7 @@ export class Face {
// ひげ is shared by the whole face as well. (鼻ちょうちん is a real 3D
// object hung off the nose - see src/main.js - so it is not drawn here.)
beard: p.eyes.beard,
+ beards: this.beardImages,
// 眼鏡 / サングラス are shared by both eyes and drawn into this same
// texture, so they travel with the other shared eye fields.
glasses: p.eyes.glasses,
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 4442bc6..2e1572a 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -286,11 +286,6 @@ export const MOUTH_LAYOUT = {
// `rise = height * (1 - |dx/half|^2.5)` - so it is steep-sided with a smooth,
// almost flat top. It is convex upwards, but it is **not** a point.
tongue: { pos: 0.84, width: 126, height: 115, crown: 2.5 },
- // The tongue *lolling out* of the mouth (舌を出す). The same super-ellipse as
- // the tongue above, but the crown hangs below its foot instead of rising over
- // it, so the tip droops past the lip. `pos` is its own, because the in-mouth
- // tongue sits off to one side and a lolling tongue reads better centred.
- tongueOut: { pos: 0.5, width: 104, height: 118, crown: 2.2 },
corner: { fromX: 26, fromY: 3, toX: 32, toY: 42, width: 11, curve: 26 },
openRise: 64,
// Was 0.25: opening the mouth used to flatten the smile to stay inside the
@@ -873,7 +868,7 @@ 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);
- drawBeard(ctx, style.beard, lineOnly);
+ drawBeard(ctx, style.beard, lineOnly, style.beards);
}
/**
@@ -882,13 +877,27 @@ function drawFaceExtras(ctx, style, lineOnly) {
* thick waving band, and `apron` is a filled bib. In a line drawing nothing is
* filled and only the outlines are inked.
*/
-function drawBeard(ctx, spec, lineOnly = false) {
+function drawBeard(ctx, spec, lineOnly = false, textures = null) {
const shape = spec?.shape ?? 'off';
if (shape === 'off' || shape === 'none') return;
const size = clamp(spec.size ?? 1, 0.2, 3);
const cx = BEARD_LAYOUT.cx;
const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600);
const w = BEARD_LAYOUT.width * size;
+
+ // A supplied drawing for this kind (assets/beards/<shape>.png) replaces the
+ // built-in strokes. The drawings are trimmed to their ink, so `w` is the
+ // beard's real width; it is centred on the nose and scaled by `size`.
+ const texture = textures?.[shape];
+ if (texture && texture.width) {
+ // Slightly wider than the built-in base: a moustache drawn to the same width
+ // as a beard's stroke sat mostly behind the nose.
+ const drawW = w * 1.4;
+ const dh = (drawW * (texture.height || 1)) / (texture.width || 1);
+ ctx.drawImage(texture, cx - drawW / 2, cy - dh / 2, drawW, dh);
+ return;
+ }
+
const color = spec.color ?? '#3a2a4a';
const line = spec.line ?? '#55386e';
const lw = Math.max(2, 9 * size);
@@ -1372,9 +1381,6 @@ export function drawMouth(ctx, p) {
const arc = cubicPoints(lip.p0, lip.p1, lip.p2, lip.p3, 96);
const tongueAmount = clamp(p.tongue ?? 1, 0, 1.6);
- // How far the tongue hangs *out* of the mouth (below the lip). Separate from
- // `tongue`, which is the one that pokes up over the lip of a closed mouth.
- const tongueOutAmount = clamp(p.tongueOut ?? 0, 0, 1);
// --- a round "O" mouth (surprised, singing) ---------------------------
const roundAmount = clamp(p.round ?? 0, 0, 1);
@@ -1402,24 +1408,6 @@ export function drawMouth(ctx, p) {
ctx.fillStyle = tongueColor;
ctx.fill();
}
- if (tongueOutAmount > 0.01) {
- // A tongue hanging out below the round mouth. Its foot is a flat line
- // inside the lower lip; the tip is clamped to the plate's lower edge.
- const drop = Math.min(L.tongueOut.height * tongueOutAmount, Math.max(0, edgeBottom - (centreY + ry * 0.5)));
- if (drop > 4) {
- const half = (L.tongueOut.width * tongueOutAmount) / 2;
- const footY = centreY + ry * 0.5;
- ctx.beginPath();
- ctx.moveTo(L.centreX - half, footY);
- for (let i = 0; i <= 26; i++) {
- const dx = 1 - 2 * (i / 26);
- ctx.lineTo(L.centreX + dx * half, footY + drop * (1 - Math.pow(Math.abs(dx), L.tongueOut.crown)));
- }
- ctx.closePath();
- ctx.fillStyle = tongueColor;
- ctx.fill();
- }
- }
}
ctx.strokeStyle = strokeColor;
ctx.lineWidth = line ? thickness * 0.8 : thickness;
@@ -1489,61 +1477,13 @@ export function drawMouth(ctx, p) {
ctx.fill();
}
- /**
- * The tongue lolling *out* of the mouth, hanging below `curve`.
- *
- * The mirror of `drawTongue`: the foot sits along the lip (or the lower jaw
- * when the mouth is open) and the crown drops *below* it. The tip is clamped
- * against the plate's real lower edge, so a tongue on a low mouth gets shorter
- * instead of being smeared off the chin by the texture's clamp.
- */
- function drawTongueOut(pointAt, curve, height) {
- const half = (L.tongueOut.width * tongueOutAmount) / 2;
- const pos = clamp(p.tongueOutPos ?? L.tongueOut.pos, 0.05, 0.95);
- const arcLength = Math.max(1, chordLength(curve));
- const dt = clamp(half / arcLength, 0.01, 0.45);
- const t0 = clamp(pos - dt, 0, 1);
- const t1 = clamp(pos + dt, 0, 1);
- const base = [];
- for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16));
- const mid = pointAt(clamp(pos, 0, 1));
- const drop = Math.min(height, Math.max(0, edgeBottom - mid.y));
- if (drop < 2) return;
- const footRight = base[base.length - 1];
- const footLeft = base[0];
- const crown = [];
- const steps = 26;
- for (let i = 0; i <= steps; i++) {
- const f = i / steps;
- const dx = 1 - 2 * f;
- const footY = footRight.y + (footLeft.y - footRight.y) * f;
- crown.push({
- x: mid.x + dx * half,
- y: footY + drop * (1 - Math.pow(Math.abs(dx), L.tongueOut.crown)),
- });
- }
- const shaped = rotate([...base, ...crown], pivot, tilt);
- if (line) {
- strokePolyline(ctx, shaped, { color: strokeColor, width: thickness * 0.8, closed: true });
- return;
- }
- tracePolyline(ctx, shaped, true);
- ctx.fillStyle = tongueColor;
- ctx.fill();
- }
-
if (openAmount > 0.004) {
if (!line) fillPolygon(ctx, openShape, innerColor);
// The tongue rises from the lower lip *into* the mouth, so it has to arrive
// with the opening: at a hair's width the jaw curve is a sliver, and the
// tongue used to burst straight out of it and sit on the chin.
const tongueOpen = clamp((openAmount - 0.06) / 0.24, 0, 1);
- if (tongueOutAmount > 0.01 && tongueOpen > 0.01) {
- // A tongue lolling *out* over the lower jaw is not clipped to the mouth:
- // it hangs in front of the chin. Drawn before the outline, which then
- // crosses its root.
- drawTongueOut(jawAt, jaw, L.tongueOut.height * tongueOutAmount * tongueOpen);
- } else if (tongueAmount > 0.01 && tongueOpen > 0.01) {
+ if (tongueAmount > 0.01 && tongueOpen > 0.01) {
ctx.save();
tracePolyline(ctx, openShape, true);
ctx.clip();
@@ -1555,14 +1495,11 @@ export function drawMouth(ctx, p) {
// would leave the lower edge as a bare colour change.
strokePolyline(ctx, openShape, { color: strokeColor, width: thickness, closed: true });
} else {
- // Closed lips - but the tongue still pokes out. That is what the original
- // artwork does (the mouth reads as a smile with the tongue showing, like
- // ペコちゃん), and it is why a plain line looked wrong there. `tongueOut`
- // hangs the tongue the other way, *below* the lip line. The lip line goes on
- // afterwards, so the tongue comes out from under it.
- if (tongueOutAmount > 0.01) {
- drawTongueOut(lipAt, arc, L.tongueOut.height * tongueOutAmount);
- } else if (tongueAmount > 0.01) {
+ // Closed lips - but the tongue still pokes over the lip. That is what the
+ // original artwork does (the mouth reads as a smile with the tongue showing,
+ // like ペコちゃん), and it is why a plain line looked wrong there. The lip
+ // line goes on afterwards, so the tongue comes out from under it.
+ if (tongueAmount > 0.01) {
drawTongue(lipAt, arc, tongueHeight);
}
strokePolyline(ctx, rotate(arc, pivot, tilt), { color: strokeColor, width: thickness });
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 051554d..b94fca0 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -50,6 +50,10 @@ function assetUrl(file, kind) {
const backdrops = globalThis.__BLUEBEY_BACKGROUNDS__ ?? {};
return backdrops[file] ?? `assets/backgrounds/${file}.webp`;
}
+ if (kind === 'beard') {
+ const beards = globalThis.__BLUEBEY_BEARDS__ ?? {};
+ return beards[file] ?? `assets/beards/${file}`;
+ }
return inlined[file] ?? `assets/fonts/${file}`;
}
@@ -385,6 +389,29 @@ async function init() {
});
const animator = new Animator({ state });
+ // Beard drawings supplied as files (`assets/beards/<kind>.png`): a kind that has
+ // one is drawn from the file instead of the built-in strokes. A missing file is
+ // skipped, so the app works before any are added.
+ void (async () => {
+ // Only the kinds that actually ship a drawing. Add a name here when its file is
+ // added to assets/beards/, so no 404s are logged for the ones without one.
+ const kinds = ['scotch'];
+ const loaded = {};
+ await Promise.all(kinds.map(async (kind) => {
+ try {
+ const response = await fetch(assetUrl(`${kind}.png`, 'beard'));
+ if (!response.ok) return;
+ loaded[kind] = await createImageBitmap(await response.blob());
+ } catch {
+ /* no drawing for this kind: the built-in one is used */
+ }
+ }));
+ if (Object.keys(loaded).length) {
+ face.setBeardImages(loaded);
+ app.needsRender = true;
+ }
+ })();
+
// ------------------------------------------------------------------- look
const look = createLook({
renderer,
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index f82d6bf..3312e9c 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -190,14 +190,17 @@ export function buildPanel(app, root) {
});
const randomFace = () => replace({ face: randomFacePatch() }, 'face');
const randomAll = () => {
- const pose = pick(POSE_PRESETS);
+ // 後ろを向く(180°)や大きく傾くポーズは避ける。見える顔が変わる範囲だけ。
+ const poses = POSE_PRESETS.filter((preset) => {
+ const m = preset.pose?.bones?.master ?? [0, 0, 0];
+ return Math.abs(m[0]) <= 45 && Math.abs(m[1]) <= 40 && Math.abs(m[2]) <= 40;
+ });
+ const pose = pick(poses.length ? poses : POSE_PRESETS);
const theme = pick(THEMES);
app.actions.applyPosePreset?.(pose.id);
app.actions.applyTheme?.(theme.id);
- replace({
- face: randomFacePatch(),
- render: { style: pick(STYLE_DEFS.map((style) => style.value)), mirror: Math.random() < 0.5 },
- }, 'all');
+ // The viewer style (実写/フラット/線画) and 左右反転 are deliberately left alone.
+ replace({ face: randomFacePatch() }, 'face');
sync();
};
@@ -213,7 +216,7 @@ export function buildPanel(app, root) {
topBar.append(
iconButton('元に戻す', 'M4.8 11.5h9.7a5.2 5.2 0 0 1 0 10.4h-3.6M4.8 11.5 9.6 6.7M4.8 11.5 9.6 16.3', () => app.actions.undo()),
iconButton('やり直す', 'M19.2 11.5h-9.7a5.2 5.2 0 0 0 0 10.4h3.6M19.2 11.5 14.4 6.7M19.2 11.5 14.4 16.3', () => app.actions.redo()),
- iconButton('すべてをランダムに', 'M5 4.5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-13a1 1 0 0 1 1-1ZM9 8.5h.01M15 8.5h.01M12 12h.01M9 15.5h.01M15 15.5h.01', () => randomAll()),
+ iconButton('すべてをランダムに', 'M7.2 9.4a2.6 2.6 0 1 1 3.9 2.3c-.9.5-1.3 1.2-1.3 2.1v.4M9.2 17.4h.01M16.8 6.8v6.1M16.8 15.6h.01', () => randomAll()),
);
root.append(topBar);
@@ -1188,15 +1191,13 @@ export function buildPanel(app, root) {
offsetY: slider({ label: '口の高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }),
tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }),
tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }),
- tongueOut: slider({ label: '舌を外に出す', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongueOut = v; app.actions.refresh('face'); } }),
- tongueOutPos: slider({ label: '外に出す舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.5, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongueOutPos = v; app.actions.refresh('face'); } }),
corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }),
cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }),
};
const mouthVisible = ['smile', 'round', 'width', 'thickness', 'tilt', 'offsetY'];
for (const key of mouthVisible) faceSection.add(mouthSliders[key].el);
const mouthBox = details(faceSection.body, 'こまかい設定(開き・舌・口角)');
- for (const key of ['open', 'tongue', 'tonguePos', 'tongueOut', 'tongueOutPos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el);
+ for (const key of ['open', 'tongue', 'tonguePos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el);
mouthBox.add(buttons({
items: [{
id: 'mouthreset',
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index c2c7054..5492307 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -295,8 +295,6 @@ export function defaultState() {
offsetY: 0, // texture pixels
tongue: 0.96, // 0 hides it; scales the whole tongue
tonguePos: 0.84, // 0..1 along the smile line
- tongueOut: 0, // 0..1; >0 hangs the tongue *out*, below the lip
- tongueOutPos: 0.5, // 0..1 along the lip, for the tongue that hangs out
corners: 0.71, // length of the corner marks, 0 hides them
cornerAngle: -8, // degrees, tilts the corner marks
round: 0, // >0 turns the mouth into a round "O"