diff options
Diffstat (limited to 'public/bluebey-studio/src')
| -rw-r--r-- | public/bluebey-studio/src/animation.js | 26 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 39 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 44 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 2 | ||||
| -rw-r--r-- | public/bluebey-studio/src/style.css | 3 | ||||
| -rw-r--r-- | public/bluebey-studio/src/ui.js | 15 |
6 files changed, 108 insertions, 21 deletions
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js index 3233e43..e569d12 100644 --- a/public/bluebey-studio/src/animation.js +++ b/public/bluebey-studio/src/animation.js @@ -17,6 +17,7 @@ export class Animator { this.eyeOpen = 1; this.look = { x: 0, y: 0 }; this.idleWeight = 0; + this.snotScale = 1; } reset() { @@ -25,6 +26,7 @@ export class Animator { this.blinkPhase = -1; this.blinkTimer = 2; this.time = 0; + this.snotScale = 1; } update(dt) { @@ -35,11 +37,24 @@ export class Animator { const moving = (this.state.anim.mode ?? 'off') !== 'off'; this.idleWeight += ((moving ? 1 : 0) - this.idleWeight) * Math.min(1, step * 4); + // 鼻ちょうちん: while sleeping, the bubble swells on the out-breath and shrinks + // again. It is a real object (see src/main.js), so the motion is published here + // as a plain multiplier the render loop applies to the bubble's scale. + this.snotScale = (this.state.anim.mode ?? 'off') === 'sleep' + ? 0.45 + 0.55 * (0.5 - 0.5 * Math.cos(this.time * 1.3)) + : 1; + this.updateBlink(step); this.updateLook(step); } updateBlink(dt) { + if ((this.state.anim.mode ?? 'off') === 'sleep') { + // Asleep: the eyes stay shut, whatever the blink setting says. + this.eyeOpen = 0; + this.blinkPhase = -1; + return; + } if (!this.state.anim.blink) { this.eyeOpen = 1; this.blinkPhase = -1; @@ -128,6 +143,17 @@ export class Animator { return { bones, root }; } + if (mode === 'sleep') { + // Slow breathing: the body rises and settles, the flippers drift, and the + // bubble swells and shrinks (that swell is `snotScale`, read by the loop). + const breath = 0.5 - 0.5 * Math.cos(t * 1.3); + add('master', Math.sin(t * 0.65) * 1.1, 0, 0); + add('arm.l', 0, 0, Math.sin(t * 0.9) * 2.5); + add('arm.r', 0, 0, -Math.sin(t * 0.9) * 2.5); + root[1] += breath * 0.03; + return { bones, root }; + } + // Default: gentle breathing, as if standing there alive. add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6); add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5); diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 68df32c..1f6ad6e 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -275,14 +275,15 @@ async function init() { if (!group) return; group.visible = snot?.enabled === true; if (!group.visible) return; - group.scale.setScalar(Number.isFinite(snot.size) ? snot.size : 1); - const base = group.userData.base; const k = 0.01; // artwork px -> world units, enough to nudge the bubble - group.position.set( - base.x + (snot.offsetX ?? 0) * k, - base.y + (snot.offsetY ?? 0) * k, - base.z, - ); + const target = group.userData.baseWorld.clone().add(new THREE.Vector3( + (snot.offsetX ?? 0) * k, + (snot.offsetY ?? 0) * k, + (snot.offsetZ ?? 0) * k, + )); + group.position.copy(group.userData.bone.worldToLocal(target)); + group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1; + group.scale.setScalar(group.userData.baseScale); group.userData.material.color.set(snot.color ?? '#dfe8ff'); } @@ -321,16 +322,12 @@ async function init() { }); const group = new THREE.Group(); group.name = 'snot-bubble'; - const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.05, 28, 20), material); - const neck = new THREE.Mesh(new THREE.SphereGeometry(radius * 0.42, 20, 14), material); - group.add(bubble, neck); + const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.15, 28, 20), material); + group.add(bubble); const bone = master.bone; - const bubbleLocal = bone.worldToLocal(centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7))); - const noseLocal = bone.worldToLocal(centre.clone()); - group.position.copy(bubbleLocal); - // The neck lump sits partway back towards the nose, so the two read as joined. - neck.position.copy(noseLocal).sub(bubbleLocal).multiplyScalar(0.45); - group.userData = { bubble, neck, material, base: bubbleLocal.clone() }; + const baseWorld = centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7)); + group.position.copy(bone.worldToLocal(baseWorld.clone())); + group.userData = { bubble, material, bone, baseWorld, baseScale: 1 }; group.visible = false; bone.add(group); return group; @@ -1244,12 +1241,20 @@ async function init() { if (snotBubble) { const snot = state.face.eyes.snot; - const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.color}`; + const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.offsetZ}|${snot.color}`; if (sig !== snotSignature) { snotSignature = sig; applySnotBubble(snotBubble, snot); app.needsRender = true; } + // The sleeping breath swells the bubble; `snotScale` is 1 otherwise. + if (snotBubble.visible) { + const scale = (snotBubble.userData.baseScale ?? 1) * (animator.snotScale ?? 1); + if (snotBubble.scale.x !== scale) { + snotBubble.scale.setScalar(scale); + app.needsRender = true; + } + } } const params = buildFaceParams(); diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index c26c88c..255e6cf 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -1,5 +1,5 @@ import { - h, section, subhead, hint, slider, check, segmented, buttons, + h, section as uiSection, subhead, hint, slider, check, segmented, buttons, colorField, xyPad, tailPad, selectField, controlRow, tabs, details, toast, } from './ui.js'; import { @@ -15,6 +15,43 @@ import { GION_SHEETS, gionSheetUrl, cropFromMarquee, fitSheet, normalizeRect, clamp, DEFAULT_STAMP_WIDTH, } from './gion.js'; +/** + * A small icon for each section heading, so the list a tab shows can be scanned + * at a glance. Keyed by the section title; a title with no icon just shows text. + */ +const SECTION_ICONS = { + 'ポーズ例': 'M12 3.6a2.1 2.1 0 1 1 0 4.2 2.1 2.1 0 1 1 0-4.2ZM12 7.8v6.4M12 10.3 8.4 12.5M12 10.3l3.6 2.2M12 14.2 9 20.4M12 14.2l3 6.2', + '全身とボーンのスライダー': 'M4 7h16M4 12h16M4 17h16M8 7h.01M15 12h.01M11 17h.01', + '操作': 'M7 4l10 6-4.3 1.3L10.8 16 7 4Z', + 'うごき': 'M8 5l10 7-10 7V5Z', + 'プリセット': 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0', + '目': 'M2.5 12s3.6-6 9.5-6 9.5 6 9.5 6-3.6 6-9.5 6-9.5-6-9.5-6ZM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2Z', + '眉': 'M4 11q3.2-3.2 7-2M13 9q3.8-1.2 7 2', + '眼鏡・サングラス': 'M8 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM16 10.2a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4ZM11.2 13h1.6M4.8 11.7 3 10.3M19.2 11.7 21 10.3', + 'ほっぺ・頭の模様': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM9.4 10.2h.01M14.6 10.2h.01M9.4 14.2h5.2', + '鼻ちょうちん': 'M15 4.6a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2ZM10.6 9.6 4.8 11.4', + 'ひげ': 'M4.4 11.2c2.6-1.2 5.2-.6 7.6 2 2.4-2.6 5-3.2 7.6-2', + '口': 'M4.6 10c3 4.8 11.8 4.8 14.8 0', + '口パク(声は出ません)': 'M4 9v6h3l4 3V6L7 9H4ZM15.5 9.5l4 5M19.5 9.5l-4 5', + '見る先': 'M12 3.5v3M12 17.5v3M3.5 12h3M17.5 12h3M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6Z', + 'スタイル': 'M4 20l3.4-.8L20 6.6 17.4 4 4.8 16.6 4 20Z', + '体の色': 'M12 3.4s6 6.2 6 10.1a6 6 0 0 1-12 0C6 9.6 12 3.4 12 3.4Z', + '見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01', + '背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01', + '舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5', + 'セリフ(吹き出し)': 'M4 5h16v10H9l-4 4v-4H4V5Z', + '画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8', + '画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01', + '画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01', + 'まんが': 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', + '共有': 'M8.2 10.6a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 6.4a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM17 20.8a2 2 0 1 0 0-3.2 2 2 0 0 0 0 3.2ZM10 10.4l5-2.6M10 13.6l5 2.6', + 'テクスチャの下地を書き出す': 'M4 4h16v16H4zM4 9.3h16M4 14.7h16M9.3 4v16M14.7 4v16', + '設定': 'M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8ZM12 2.6v2.4M12 19v2.4M2.6 12H5M19 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7', +}; + +/** `section`, with the heading icon looked up from its title. */ +const section = (parent, title, options) => uiSection(parent, title, { icon: SECTION_ICONS[title], ...options }); + /** Fallbacks for the fields an older settings file may predate. */ const BODY_COLOR_FALLBACKS = { body: '#c8b0f0', accent: '#8a4fe0', nose: '#7a4fb0', @@ -965,6 +1002,10 @@ export function buildPanel(app, root) { 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'); }, }), + offsetZ: slider({ + label: 'おくゆき', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.snot.offsetZ = v; app.actions.refresh('face'); }, + }), }; for (const widget of Object.values(snotSliders)) snotBox.add(widget.el); const snotColor = colorField({ @@ -1287,6 +1328,7 @@ export function buildPanel(app, root) { { value: 'idle', label: 'ゆれる' }, { value: 'walk', label: '歩く' }, { value: 'wave', label: 'てをふる' }, + { value: 'sleep', label: 'ねている' }, ], value: 'off', onChange: (value) => { state.anim.mode = value; app.animator.reset(); app.needsRender = true; }, diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index a450eb2..6487066 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -216,8 +216,8 @@ export function defaultState() { size: 1, offsetX: 0, offsetY: 0, + offsetZ: 0, color: '#e3ecff', - line: '#55386e', }, // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`, // `scotch`, `kaiser`, `apron` or `cat`. Starts off. diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css index e5287a2..7caa6f3 100644 --- a/public/bluebey-studio/src/style.css +++ b/public/bluebey-studio/src/style.css @@ -316,6 +316,9 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); } .sec-head .chev { color: var(--muted); font-size: 10px; transition: transform 0.18s; } .sec.closed .chev { transform: rotate(-90deg); } +.sec-title { display: flex; align-items: center; gap: 8px; min-width: 0; } +.sec-title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sec-icon { flex: 0 0 auto; width: 16px; height: 16px; color: var(--accent); } .sec-body { padding: 2px 12px 12px; display: grid; gap: 9px; } .sec.closed .sec-body { display: none; } diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js index e184f5e..46c5adb 100644 --- a/public/bluebey-studio/src/ui.js +++ b/public/bluebey-studio/src/ui.js @@ -36,10 +36,12 @@ export function toast(message) { toastTimer = setTimeout(() => node.classList.remove('show'), 2400); } -export function section(parent, title, { open = false } = {}) { +export function section(parent, title, { open = false, icon = null } = {}) { const body = h('div', { class: 'sec-body' }); const head = h('button', { type: 'button', class: 'sec-head' }, - h('span', { text: title }), + h('span', { class: 'sec-title' }, + icon ? secIcon(icon) : null, + h('span', { text: title })), h('span', { class: 'chev', text: '▾' })); const el = h('section', { class: `sec${open ? '' : ' closed'}` }, head, body); head.addEventListener('click', () => el.classList.toggle('closed')); @@ -83,6 +85,15 @@ function tabIcon(d) { return svg; } +function secIcon(d) { + const svg = tabIcon(d); + svg.setAttribute('class', 'sec-icon'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.style.verticalAlign = 'middle'; + return svg; +} + /** * Tabbed container: returns the per-tab panels to fill in. When a definition * carries an `icon` (an SVG path `d`) the tab shows only that icon, and the |
