diff options
Diffstat (limited to 'public/bluebey-studio/src')
| -rw-r--r-- | public/bluebey-studio/src/face.js | 12 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 21 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 28 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 14 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 4 |
5 files changed, 77 insertions, 2 deletions
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index f2440d3..0a00399 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -52,6 +52,8 @@ export class Face { // 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 = {}; + // Drawings for the brows (assets/brows/<kind>.png), loaded the same way. + this.browImages = {}; this.eyeCanvas = makeCanvas(this.eyeLayout); this.mouthCanvas = makeCanvas(this.mouthLayout); this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout, HAIR_SCALE) : null; @@ -163,6 +165,15 @@ export class Face { this.dirty = true; } + /** + * Set the drawings used for the brows, keyed by brow kind (`gol`). Called by the + * app once the files are loaded; the next redraw picks them up. + */ + setBrowImages(map) { + this.browImages = map ?? {}; + this.dirty = true; + } + /** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */ setSource(kind, source) { if (kind === 'eyes') { @@ -237,6 +248,7 @@ export class Face { // object hung off the nose - see src/main.js - so it is not drawn here.) beard: p.eyes.beard, beards: this.beardImages, + brows: this.browImages, // 眼鏡 / サングラス 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 9d5e20d..671ed1d 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -679,6 +679,25 @@ function drawBrow(ctx, eye, spec, style, limits = WINDOW_LIMITS) { // one away from the nose. `layout.offsetX` is the resting inset from the // artwork (which the old code read off the wrong object, so it never applied). const cx = eye.cx + eye.towardNose * (layout.offsetX - (brow.spacing ?? 0)); + + // A supplied drawing (assets/brows/gol-right.png, the model's right brow only) + // replaces the strokes: like the textured ひげ it is drawn twice, mirrored, at + // `cx`, and tinted with the brow colour. + const entry = style.brows?.gol; + if (brow.image && entry?.image) { + const sprite = tintedSprite(entry.image, brow.color ?? '#2a1e33'); + const sw = sprite.width || 1; + const sh = sprite.height || 1; + const drawW = Math.max(4, length); + const drawH = drawW * (sh / sw); + ctx.save(); + ctx.translate(cx, cy); + // The drawing is the model's right brow, so the other eye gets it mirrored. + ctx.scale(eye.towardNose >= 0 ? 1 : -1, 1); + ctx.drawImage(sprite, -drawW / 2, -drawH / 2, drawW, drawH); + ctx.restore(); + return; + } const angle = (((brow.angle ?? 0) + (spec.browAngle ?? 0)) * eye.towardNose * Math.PI) / 180; const half = length / 2; const from = { x: cx - Math.cos(angle) * half, y: cy - Math.sin(angle) * half }; @@ -1486,7 +1505,7 @@ export function drawMouth(ctx, p) { // use the whole canvas: a very wide mouth used to smear sideways off the plate. const cornerReach = L.corner.toX * cornerAmount; const halfChord = Math.min( - L.halfChord * clamp(p.width ?? 1, 0.2, 1.6), + L.halfChord * clamp(p.width ?? 1, 0.2, 1.6) * clamp(p.length ?? 1, 0.2, 2.5), Math.min(L.centreX - (edgeLeft + cornerReach), (edgeRight - cornerReach) - L.centreX), ); diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 548be76..3f61ad5 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -54,6 +54,10 @@ function assetUrl(file, kind) { const beards = globalThis.__BLUEBEY_BEARDS__ ?? {}; return beards[file] ?? `assets/beards/${file}`; } + if (kind === 'brow') { + const brows = globalThis.__BLUEBEY_BROWS__ ?? {}; + return brows[file] ?? `assets/brows/${file}`; + } return inlined[file] ?? `assets/fonts/${file}`; } @@ -420,6 +424,30 @@ async function init() { } })(); + // Brow drawings supplied as files (`assets/brows/<kind>.png`). A file ending + // `-right.png` is one side; it is drawn twice (mirrored) at each eye. Missing + // files are skipped, so the built-in ゴル風 strokes are the fallback. + void (async () => { + const files = [{ kind: 'gol', file: 'gol-right.png' }]; + const loaded = {}; + await Promise.all(files.map(async ({ kind, file }) => { + try { + const response = await fetch(assetUrl(file, 'brow')); + if (!response.ok) return; + loaded[kind] = { + image: await createImageBitmap(await response.blob()), + half: file.endsWith('-right.png'), + }; + } catch { + /* a missing drawing falls back to the built-in strokes */ + } + })); + if (Object.keys(loaded).length) { + face.setBrowImages(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 237d6c8..406bbfb 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -836,6 +836,7 @@ export function buildPanel(app, root) { // it, with the two brows sitting close together. Object.assign(state.face.eyes.brow, { enabled: true, + image: true, angle: 34, height: 2, length: 1.7, @@ -874,6 +875,15 @@ export function buildPanel(app, root) { ], })); + const browImageToggle = check({ + label: 'ゴル風の画像を使う', + value: false, + onChange: (value) => { state.face.eyes.brow.image = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, browImageToggle.el, { wide: true })); + faceSection.add(hint('読み込んだ画像(assets/brows/gol-right.png)を眉として左右に描きます。' + + '「ゴル風」を押すと自動で入ります。画像が無いときは今までの線で描きます。')); + // --- glasses / sunglasses (drawn into the same texture as the eyes and brows) faceSection = faceTabSections.glasses; faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。' @@ -1226,6 +1236,7 @@ export function buildPanel(app, root) { open: slider({ label: '口の開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), round: slider({ label: '丸く開く(Oの口)', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }), width: slider({ label: '口の幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), + length: slider({ label: '口の長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }), thickness: slider({ label: '口の太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), tilt: slider({ label: '口の傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }), 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'); } }), @@ -1234,7 +1245,7 @@ export function buildPanel(app, root) { 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']; + const mouthVisible = ['smile', 'round', 'width', 'length', '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', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el); @@ -2695,6 +2706,7 @@ export function buildPanel(app, root) { browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0); browSliders.curve.set(state.face.eyes.brow.curve); browSliders.taper.set(state.face.eyes.brow.taper ?? 1); + browImageToggle.set(state.face.eyes.brow.image === true); browColor.set(state.face.eyes.brow.color); glassesToggle.set(state.face.eyes.glasses.enabled === true); glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses'); diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index 40dc377..4ae17b3 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -263,6 +263,9 @@ export function defaultState() { // original character has no eyebrows. brow: { enabled: false, + // When true and a drawing (assets/brows/gol-right.png) was loaded, the + // brow is painted from that file (mirrored) instead of the strokes. + image: false, angle: 0, height: 0, length: 1, @@ -299,6 +302,7 @@ export function defaultState() { visible: true, smile: 1, // 0 = straight line, 1 = the original smile, <0 = frown width: 1, + length: 1, // extra multiplier on the smile line's length thickness: 1, open: 0, // 0 = closed lips, 1 = wide open tilt: 0, // degrees |
