diff options
Diffstat (limited to 'public/bluebey-studio')
| -rw-r--r-- | public/bluebey-studio/src/face.js | 1 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 73 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 134 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 13 |
4 files changed, 176 insertions, 45 deletions
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index 6898faf..c1a0c72 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -192,6 +192,7 @@ export class Face { highlight: p.eyes.highlight, lidWidth: p.eyes.lidWidth, lowerLid: p.eyes.lowerLid, + lidShape: p.eyes.lidShape, brow: p.eyes.brow, // ほっぺ and 頭の模様 are shared by the whole face, so they travel with // the other shared eye fields (see `drawEyes` in src/faceArt.js). diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 8d20cda..4442bc6 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -470,7 +470,7 @@ 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, lower) { +function clipEye(ctx, eye, open, lower, flat = false) { circlePath(ctx, eye.cx, eye.cy, eye.r); ctx.clip(); @@ -478,14 +478,19 @@ function clipEye(ctx, eye, open, lower) { if (upper > 0.0005) { const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; const lowest = eye.cy - eye.r + 2 * eye.r * upper; - const cy = lowest - rl; ctx.beginPath(); - ctx.moveTo(eye.cx - HUGE, cy); - ctx.lineTo(eye.cx - rl, cy); - ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down - ctx.lineTo(eye.cx + HUGE, cy); - ctx.lineTo(eye.cx + HUGE, cy + HUGE); - ctx.lineTo(eye.cx - HUGE, cy + HUGE); + if (flat) { + // A flat lid: a straight edge straight across the eye. + ctx.rect(eye.cx - HUGE, lowest, HUGE * 2, HUGE); + } else { + const cy = lowest - rl; + ctx.moveTo(eye.cx - HUGE, cy); + ctx.lineTo(eye.cx - rl, cy); + ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down + ctx.lineTo(eye.cx + HUGE, cy); + ctx.lineTo(eye.cx + HUGE, cy + HUGE); + ctx.lineTo(eye.cx - HUGE, cy + HUGE); + } ctx.closePath(); ctx.clip(); } @@ -494,28 +499,40 @@ function clipEye(ctx, eye, open, lower) { if (bottom > 0.0005) { const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; const highest = eye.cy + eye.r - 2 * eye.r * bottom; - const cy = highest + rl; ctx.beginPath(); - ctx.moveTo(eye.cx - HUGE, cy); - ctx.lineTo(eye.cx - rl, cy); - ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up - ctx.lineTo(eye.cx + HUGE, cy); - ctx.lineTo(eye.cx + HUGE, cy - HUGE); - ctx.lineTo(eye.cx - HUGE, cy - HUGE); + if (flat) { + ctx.rect(eye.cx - HUGE, highest - HUGE, HUGE * 2, HUGE); + } else { + const cy = highest + rl; + ctx.moveTo(eye.cx - HUGE, cy); + ctx.lineTo(eye.cx - rl, cy); + ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up + ctx.lineTo(eye.cx + HUGE, cy); + ctx.lineTo(eye.cx + HUGE, cy - HUGE); + ctx.lineTo(eye.cx - HUGE, cy - HUGE); + } ctx.closePath(); ctx.clip(); } } -function lidPath(ctx, eye, amount, lower) { +function lidPath(ctx, eye, amount, lower, flat = false) { + ctx.beginPath(); + if (flat) { + // A straight lid edge; the caller clips it to the eyeball. + const y = lower + ? eye.cy + eye.r - 2 * eye.r * amount + : eye.cy - eye.r + 2 * eye.r * (1 - amount); + ctx.moveTo(eye.cx - eye.r * 2, y); + ctx.lineTo(eye.cx + eye.r * 2, y); + return; + } const rl = eye.r * EYE_LAYOUT.lidRadiusFactor; if (!lower) { const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount); - ctx.beginPath(); ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true); } else { const highest = eye.cy + eye.r - 2 * eye.r * amount; - ctx.beginPath(); ctx.arc(eye.cx, highest + rl, rl, Math.PI, 0, false); } } @@ -939,13 +956,18 @@ function drawBeard(ctx, spec, lineOnly = false) { // longer and wider-spread without making them fatter, and `spacing` opens the // gap between the left and right. const whisker = 8; - const inner = w * 0.30 + clamp(spec.spacing ?? 0, 0, 800); - const outer = Math.max(inner + w * 0.2, w * 0.68); + const spacing = clamp(spec.spacing ?? 0, 0, 800); + const inner = w * 0.30 + spacing; + // `length` scales how far each whisker reaches out; `lineGap` opens the spacing + // between the three lines of one side. Neither changes the line's thickness. + const length = clamp(spec.length ?? 1, 0.2, 3); + const gap = w * 0.16 * clamp(spec.lineGap ?? 1, 0.2, 3); + const outer = inner + w * 0.38 * length; for (const dir of [-1, 1]) { for (let i = 0; i < 3; i += 1) { stroke([ - { x: cx + dir * inner, y: cy + (i - 1) * w * 0.16 }, - { x: cx + dir * outer, y: cy + (i - 1) * w * 0.24 }, + { x: cx + dir * inner, y: cy + (i - 1) * gap }, + { x: cx + dir * outer, y: cy + (i - 1) * gap * 1.5 }, ], whisker); } } @@ -1134,6 +1156,7 @@ export function drawEyes(ctx, p) { const travel = Math.max(0, EYE_LAYOUT.radius - irisRadius); const heartTravel = EYE_LAYOUT.radius * 0.9; const lowerLid = clamp(style.lowerLid ?? 0, 0, 1); + const lidFlat = (style.lidShape ?? 'curve') === 'flat'; // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here // rather than inside the per-eye loop below. 鼻ちょうちん comes along too. @@ -1200,7 +1223,7 @@ export function drawEyes(ctx, p) { // 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, lowerLid * (1 - open)); + if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid * (1 - open), lidFlat); const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax; let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel; @@ -1266,12 +1289,12 @@ export function drawEyes(ctx, p) { ctx.strokeStyle = lineColor; ctx.lineWidth = lidWidth; if (lidFade > 0.01) { - lidPath(ctx, eye, open, false); + lidPath(ctx, eye, open, false, lidFlat); ctx.stroke(); } if (lowerLid > 0.01) { ctx.globalAlpha = line ? 1 : lowerLid; - lidPath(ctx, eye, lowerLid, true); + lidPath(ctx, eye, lowerLid, true, lidFlat); ctx.stroke(); } ctx.restore(); diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 4b2a4c0..e4ef0b6 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -266,8 +266,10 @@ export function buildPanel(app, root) { poseBox.add(controlRow(null, gizmoToggle.el, { wide: true })); // --- 操作 ----------------------------------------------------------------- - const poseActions = section(tabPose, '操作'); - poseActions.add(buttons({ + // Deliberately NOT wrapped in a collapsible section: 「ポーズを全部戻す」 and the + // undo/redo pair are used often enough that they should be on screen without + // opening anything first. + tabPose.append(buttons({ items: [ { id: 'resetBone', label: 'このボーンを戻す', onClick: () => { rig.reset(rig.selected); app.actions.capturePose(); syncBones(); app.needsRender = true; } }, { id: 'resetAll', label: 'ポーズを全部戻す', onClick: () => app.actions.resetPose() }, @@ -275,8 +277,8 @@ export function buildPanel(app, root) { { id: 'undo', label: '元に戻す', onClick: () => app.actions.undo() }, { id: 'redo', label: 'やり直す', onClick: () => app.actions.redo() }, ], - })); - poseActions.add(hint('「元に戻す」「やり直す」は、ポーズだけでなく色や背景など、すべての操作に効きます。')); + }).el); + tabPose.append(hint('「元に戻す」「やり直す」は、ポーズだけでなく色や背景など、すべての操作に効きます。')); /** Accepts a preset object (from the buttons) or a preset id (from tools). */ function applyPosePreset(presetOrId) { @@ -572,6 +574,15 @@ export function buildPanel(app, root) { label: '下まぶた', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (value) => { state.face.eyes.lowerLid = value; app.actions.refresh('face'); }, }); + const lidShapeSegment = segmented({ + label: 'まぶたの形', + options: [ + { value: 'curve', label: '曲線' }, + { value: 'flat', label: '直線(平ら)' }, + ], + value: 'curve', + onChange: (value) => { state.face.eyes.lidShape = value; app.actions.refresh('face'); }, + }); eyeAdvanced.add(irisScaleSlider.el); eyeAdvanced.add(heartScaleSlider.el); eyeAdvanced.add(controlRow(null, heartWhiteToggle.el, { wide: true })); @@ -579,6 +590,25 @@ export function buildPanel(app, root) { eyeAdvanced.add(lookMaxSlider.el); eyeAdvanced.add(lidWidthSlider.el); eyeAdvanced.add(lowerLidSlider.el); + eyeAdvanced.add(lidShapeSegment.el); + eyeAdvanced.add(buttons({ + items: [{ + id: 'squint', + label: '目を細める', + onClick: () => { + // マンガの細目: the upper lid comes partway down and the lower lid rises to + // meet it. `open` is per-eye (kept in step here); `lowerLid` is shared. + state.face.eyes.left.open = 0.5; + state.face.eyes.right.open = 0.5; + state.face.eyes.lowerLid = 0.5; + app.actions.refresh('face'); + sync(); + }, + }], + })); + eyeAdvanced.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。' + + '「目を細める」で、上まぶたを下げつつ下まぶたを上げた細目になります。' + + '「下まぶた」を上げると、下からもまぶたが持ち上がります。')); const highlightToggle = check({ label: '瞳のハイライト', @@ -721,13 +751,13 @@ export function buildPanel(app, root) { // it, with the two brows sitting close together. Object.assign(state.face.eyes.brow, { enabled: true, - angle: 36, - height: 6, - length: 1.1, - thickness: 3.5, + angle: 34, + height: 2, + length: 1.7, + thickness: 3.6, curve: 0, taper: 0, - spacing: -10, + spacing: -30, color: '#12101a', }); app.actions.refresh('face'); @@ -745,11 +775,11 @@ export function buildPanel(app, root) { enabled: true, angle: 10, height: 4, - length: 0.6, - thickness: 3.5, - curve: 0.1, + length: 2.86, + thickness: 3.29, + curve: 0.4, taper: 1, - spacing: -205, + spacing: -50, color: '#181220', }); app.actions.refresh('face'); @@ -1025,8 +1055,8 @@ export function buildPanel(app, root) { options: [ { value: 'off', label: 'なし' }, { value: 'scotch', label: 'ちょびひげ' }, - { value: 'kaiser', label: 'カイゼル' }, - { value: 'apron', label: 'エプロン' }, + { value: 'kaiser', label: 'ダリ' }, + { value: 'apron', label: 'ルパン' }, { value: 'cat', label: 'ねこ' }, ], value: 'off', @@ -1046,19 +1076,29 @@ export function buildPanel(app, root) { label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); }, }), + length: slider({ + label: 'ひげの長さ(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.beard.length = v; app.actions.refresh('face'); }, + }), + lineGap: slider({ + label: '線の間隔(ねこ)', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.beard.lineGap = v; app.actions.refresh('face'); }, + }), }; faceSection.add(beardSliders.size.el); const beardBox = details(faceSection.body, 'こまかい設定(位置・色)'); beardBox.add(beardSliders.spacing.el); beardBox.add(beardSliders.offsetY.el); + beardBox.add(beardSliders.length.el); + beardBox.add(beardSliders.lineGap.el); const beardColor = colorField({ label: 'ひげの色', value: '#3a2a4a', swatches: ['#3a2a4a', '#1c1624', '#6b4a2a', '#8a8a94'], onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); }, }); beardBox.add(beardColor.el); - beardBox.add(hint('鼻の下に、ちょびひげ・カイゼル・エプロン・ねこ、の髭を描きます。' - + '「左右の間隔」は「ねこ」の左右の髭の間を広げます。')); + beardBox.add(hint('鼻の下に、ちょびひげ・ダリ・ルパン・ねこ、の髭を描きます。' + + '「左右の間隔」「ひげの長さ」「線の間隔」は「ねこ」で使います。')); // --- mouth faceSection = section(tabFace, '口'); @@ -1406,6 +1446,41 @@ export function buildPanel(app, root) { /* ---------------------------------------------------------------- display */ + // The face tab's bottom line: a quick way out of whatever has been built up, + // plus a one-press random face for fun. + const randomFace = () => { + const rnd = (min, max) => min + (max - min) * Math.random(); + const pick = (list) => list[Math.floor(Math.random() * list.length)]; + const eye = () => ({ open: +rnd(0.45, 1).toFixed(2), lookX: +rnd(-0.3, 0.3).toFixed(2), lookY: +rnd(-0.2, 0.2).toFixed(2) }); + replace({ + face: { + eyes: { + left: eye(), + right: eye(), + cheeks: { enabled: Math.random() < 0.5 }, + beard: { shape: pick(['off', 'scotch', 'kaiser', 'apron', 'cat']) }, + brow: { + enabled: true, + angle: Math.round(rnd(-30, 40)), + height: Math.round(rnd(-10, 50)), + length: +rnd(0.6, 2.6).toFixed(2), + thickness: +rnd(1.2, 3.4).toFixed(2), + curve: +rnd(0, 0.3).toFixed(2), + spacing: Math.round(rnd(-120, 30)), + taper: +rnd(0, 1).toFixed(2), + }, + }, + mouth: { smile: +rnd(-1, 1.2).toFixed(2), open: +rnd(0, 0.5).toFixed(2) }, + }, + }, 'face'); + }; + tabFace.append(buttons({ + items: [ + { id: 'faceReset', label: '顔をリセット', onClick: () => replace({ face: defaultState().face }, 'face') }, + { id: 'faceRandom', label: '顔をランダムに', onClick: () => randomFace() }, + ], + }).el); + const displaySection = section(tabView, 'スタイル'); const styleSegment = segmented({ @@ -1451,6 +1526,18 @@ export function buildPanel(app, root) { }); displaySection.add(controlRow(null, leafBodyLineCheck.el, { wide: true })); displaySection.add(hint('「葉っぱと体の境目に線を出す」を切ると、葉は体の後ろに回り込むだけになります(すっきりしますが、線画では葉と体が同じ白になって見分けにくくなります)。')); + displaySection.add(buttons({ + items: [{ + id: 'resetStyle', + label: 'プリセットのスタイルに戻す', + onClick: () => replace({ + render: { + style: 'real', outline: true, outlineWidth: 0.022, outlinePixels: 2, + outlineColor: '#2a1e33', paper: '#ffffff', leafBodyLine: true, + }, + }, 'render'), + }], + })); /* ------------------------------------------------------------- body colour */ @@ -1543,6 +1630,18 @@ export function buildPanel(app, root) { onChange: (value) => { state.render.environment = value; app.actions.refresh('render'); }, }); lookSection.add(environmentSegment.el); + lookSection.add(buttons({ + items: [{ + id: 'resetLook', + label: 'プリセットに戻す', + onClick: () => replace({ + render: { + mirror: false, shadow: true, shadowOpacity: 0.22, shadowSoftness: 1.6, + contactShadow: false, contactBlob: false, environment: 'gradient', envIntensity: 1, + }, + }, 'render'), + }], + })); /* -------------------------------------------------------------- background */ @@ -2507,6 +2606,7 @@ export function buildPanel(app, root) { lookMaxSlider.set(state.face.eyes.lookMax ?? 1); lidWidthSlider.set(state.face.eyes.lidWidth ?? 14); lowerLidSlider.set(state.face.eyes.lowerLid ?? 0); + lidShapeSegment.set(state.face.eyes.lidShape ?? 'curve'); browToggle.set(state.face.eyes.brow.enabled); browSliders.angle.set(state.face.eyes.brow.angle); browSliders.height.set(state.face.eyes.brow.height); diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index aee3a5f..c2c7054 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -193,6 +193,9 @@ export function defaultState() { highlight: true, lidWidth: 14, lowerLid: 0, + // How the closing eyelid is drawn: `curve` is the soft, rounded lid; `flat` + // is a straight lid edge (the hard, narrowed look). See faceArt.js. + lidShape: 'curve', heartScale: 2, heartColor: '#e0344f', heartHideWhite: true, @@ -226,10 +229,10 @@ export function defaultState() { // plate as the eyes, out to the side of the nose. Starts off. snot: { enabled: false, - size: 1, - offsetX: 0, + size: 1.23, + offsetX: -23, offsetY: 0, - offsetZ: 0, + offsetZ: 16, color: '#e3ecff', }, // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`, @@ -240,6 +243,10 @@ export function defaultState() { size: 1, offsetY: 0, spacing: 0, + // ねこ only: how far each whisker reaches (`length`) and how far apart the + // three lines of one side sit (`lineGap`). Neither changes the thickness. + length: 1, + lineGap: 1, color: '#3a2a4a', }, // Eyebrows are drawn into the eye texture. Off by default, because the |
