diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-03 13:51:53 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-03 13:51:53 +0900 |
| commit | 7892cc6f46cdfcd6dfbbdc94d34cbf0c68f29fba (patch) | |
| tree | cc353614365d2a2b56055b46e8b3c9e8d930ad12 | |
| parent | c3046436182754c550d447d689c28af86d06b316 (diff) | |
public/bluebey-studio: 寝ているポーズと鼻ちょうちんを追加し、動きを修正
- ポーズ例に「ねている」を追加(体を横に倒した姿勢)
- 「顔」タブに「鼻ちょうちん」を追加。鼻の横に泡を描き、大きさ・位置
・色を調整できる。寝ている顔に使う
- 「歩く」の腕と脚の位相を逆にし、右手が前に出るときは左足が前に出る
ように修正
- 「てをふる」の腕の上げ幅を抑え、手が顔にめり込まないように修正
| -rw-r--r-- | public/bluebey-studio/src/animation.js | 17 | ||||
| -rw-r--r-- | public/bluebey-studio/src/face.js | 2 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 89 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 39 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 24 |
5 files changed, 162 insertions, 9 deletions
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js index 47f1cfb..3233e43 100644 --- a/public/bluebey-studio/src/animation.js +++ b/public/bluebey-studio/src/animation.js @@ -97,7 +97,9 @@ export class Animator { // wants. X (which the first version used) is the *lift*, so opposite signs // there just raised one flipper and dropped the other one. // `legsupport` pivots at the middle of the body, so the legs take a much - // smaller angle than the arms. + // smaller angle than the arms. The legs swing on the OPPOSITE phase to the + // arms, so when a hand comes forward it is the opposite foot that steps out + // (the same value as the arms put them in step, i.e. a "same-side" waddle). const phase = Math.sin(t * 2.4); const bob = Math.abs(Math.sin(t * 2.4)); add('master', -2, 0, phase * 4); @@ -105,8 +107,8 @@ export class Animator { add('arm.r', 6, 0, phase * 26); add('hand.l', 0, 0, phase * 10); add('hand.r', 0, 0, phase * 10); - add('legsupport.l', 0, 0, phase * 9); - add('legsupport.r', 0, 0, phase * 9); + add('legsupport.l', 0, 0, -phase * 9); + add('legsupport.r', 0, 0, -phase * 9); root[1] += bob * 0.05; return { bones, root }; } @@ -114,10 +116,13 @@ export class Animator { if (mode === 'wave') { // One flipper is held up and sweeps back and forth, with the hand lagging a // little behind it; the body and the other flipper rock along gently. The - // +84 X holds the arm up on its own, so the wave reads from a plain stance. + // lift is kept modest: raising the arm much further folds the flipper over + // the top of the head, where its inner edge cuts into the face, so the X + // here (and its swing) stop well short of that. The Z swing is what reads + // as the wave, and it is biased slightly *back* for the same reason. const wave = Math.sin(t * 3.2); - add('arm.l', 84 + wave * 14, 0, wave * 12); - add('hand.l', 0, 0, Math.sin(t * 3.2 + 0.6) * 22); + add('arm.l', 66 + wave * 8, 0, 6 + wave * 10); + add('hand.l', 0, 0, Math.sin(t * 3.2 + 0.6) * 14); add('arm.r', 0, 0, Math.sin(t * 1.6 + 1) * 4); add('master', Math.sin(t * 1.6) * 1.2, 0, Math.sin(t * 1.6 + 0.4) * 3); return { bones, root }; diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index 18f1436..196ff84 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -197,6 +197,8 @@ export class Face { // the other shared eye fields (see `drawEyes` in src/faceArt.js). cheeks: p.eyes.cheeks, headMark: p.eyes.headMark, + // 鼻ちょうちん is shared by the whole face as well. + snot: p.eyes.snot, // 眼鏡 / サングラス 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 61bfa0d..1beedad 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -178,6 +178,22 @@ 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. + */ +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, +}; + +/** * 眼鏡 / サングラス, drawn into the same texture as the eyes and brows. * * Not measured off any artwork - the character has no glasses - but the numbers @@ -839,10 +855,77 @@ function drawHeadCover(ctx, spec, limits) { * The shared extras (ほっぺ and 頭の模様) are drawn once per face, before the eyes, * so the eyes, brows and glasses win any overlap and the cheeks are not doubled. */ -function drawFaceExtras(ctx, style) { +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); +} + +/** + * 鼻ちょうちん: 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. + */ +function drawSnotBubble(ctx, spec, lineOnly) { + if (!spec?.enabled) 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 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; + + 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(); + } + // The bubble itself, over the neck's wide end. + circlePath(ctx, cx, cy, r); + if (!lineOnly) { + ctx.fillStyle = color; + ctx.fill(); + } + ctx.strokeStyle = line; + ctx.lineWidth = lw; + ctx.stroke(); + ctx.restore(); } /** @@ -1030,8 +1113,8 @@ export function drawEyes(ctx, p) { const lowerLid = clamp(style.lowerLid ?? 0, 0, 1); // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here - // rather than inside the per-eye loop below. - drawFaceExtras(ctx, style); + // rather than inside the per-eye loop below. 鼻ちょうちん comes along too. + drawFaceExtras(ctx, style, line); for (const layout of EYE_LAYOUT.eyes) { const spec = (p.eyes ?? {})[layout.key] ?? {}; diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index babeb59..bad4059 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -927,6 +927,41 @@ export function buildPanel(app, root) { + '「ツートン」は頭を左右2色に分けます(2色目を使います)。' + '決まったキャラクターの髪ではなく、よくあるマンガの記号として用意しました。')); + // --- 鼻ちょうちん: the snot bubble of a sleeping face. + faceSection = section(tabFace, '鼻ちょうちん'); + const snotToggle = check({ + label: '鼻ちょうちんを出す', + value: false, + onChange: (value) => { state.face.eyes.snot.enabled = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, snotToggle.el, { wide: true })); + const snotSize = slider({ + label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.snot.size = v; app.actions.refresh('face'); }, + }); + faceSection.add(snotSize.el); + const snotBox = details(faceSection.body, 'こまかい設定(位置・色)'); + const snotSliders = { + offsetX: slider({ + label: 'よこの位置', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.snot.offsetX = v; app.actions.refresh('face'); }, + }), + offsetY: slider({ + label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.snot.offsetY = v; app.actions.refresh('face'); }, + }), + }; + for (const widget of Object.values(snotSliders)) snotBox.add(widget.el); + const snotColor = colorField({ + label: '泡の色', value: '#e3ecff', + swatches: ['#e3ecff', '#ffffff', '#cfe0ff', '#bfe8d0'], + onChange: (v) => { state.face.eyes.snot.color = v; app.actions.refresh('face'); }, + }); + snotBox.add(snotColor.el); + snotBox.add(hint('寝ている顔の横に、鼻から出る泡を描きます。' + + '「ねている」ポーズと組み合わせると、いねむりしているように見えます。' + + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。')); + // --- mouth faceSection = section(tabFace, '口'); @@ -2355,6 +2390,10 @@ export function buildPanel(app, root) { for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]); headMarkColor.set(state.face.eyes.headMark.color); headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff'); + snotToggle.set(state.face.eyes.snot.enabled === true); + snotSize.set(state.face.eyes.snot.size); + for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]); + snotColor.set(state.face.eyes.snot.color); }); syncers.push(() => { diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index b14a6e4..d967af2 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -209,6 +209,16 @@ export function defaultState() { color: '#8a4fe0', color2: '#ffffff', }, + // 鼻ちょうちん: the snot bubble someone sleeps with. Drawn into the same + // plate as the eyes, out to the side of the nose. Starts off. + snot: { + enabled: false, + size: 1, + offsetX: 0, + offsetY: 0, + color: '#e3ecff', + line: '#55386e', + }, // Eyebrows are drawn into the eye texture. Off by default, because the // original character has no eyebrows. brow: { @@ -623,6 +633,20 @@ export const POSE_PRESETS = [ root: [0, 0.25, 0], }, }, + { + id: 'lie', + label: 'ねている', + pose: { + bones: { + // Rolled right over onto its side (Z is the sideways roll) - the body lies + // along the ground with the face turned up towards the camera. + master: [0, 0, 88], + // Both flippers rest against the body, loose. + 'arm.l': [-30, 0, 0], + 'arm.r': [-30, 0, 0], + }, + }, + }, { id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } }, { id: 'turn', label: 'うしろむき', pose: { bones: { master: [0, 180, 0] } } }, { |
