diff options
Diffstat (limited to 'public/bluebey-studio/src')
| -rw-r--r-- | public/bluebey-studio/src/face.js | 14 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 107 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 27 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 19 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 2 |
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" |
