diff options
Diffstat (limited to 'public/bluebey-studio/src/main.js')
| -rw-r--r-- | public/bluebey-studio/src/main.js | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 9c0b98d..68df32c 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -265,6 +265,79 @@ async function init() { const propRoot = new THREE.Group(); scene.add(propRoot); + // --- 鼻ちょうちん: a modelled bubble hung under the nose -------------------- + // The nose is a *skinned* part of the body, so a child of the nose mesh would + // sit at the origin instead of on the face. The whole body (nose included) + // rides the `master` bone, so the bubble is parented there, placed at the nose's + // rest position in that bone's local frame, and read from the face state each + // frame in the render loop. + function applySnotBubble(group, snot) { + 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, + ); + group.userData.material.color.set(snot.color ?? '#dfe8ff'); + } + + const snotBubble = (() => { + const nose = model.parts.noseMesh; + const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master'); + if (!nose || !master || !nose.isSkinnedMesh) return null; + model.root.updateMatrixWorld(true); + // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the + // back of the model, in fact) - sample the skinned positions instead, which is + // where the nose actually ends up. + const attr = nose.geometry.attributes.position; + const step = Math.max(1, Math.floor(attr.count / 400)); + const point = new THREE.Vector3(); + const centre = new THREE.Vector3(); + let samples = 0; + for (let i = 0; i < attr.count; i += step) { + point.fromBufferAttribute(attr, i); + nose.applyBoneTransform(i, point); + centre.add(point); + samples += 1; + } + if (!samples) return null; + centre.multiplyScalar(1 / samples); + nose.localToWorld(centre); + let radius = 0; + for (let i = 0; i < attr.count; i += step) { + point.fromBufferAttribute(attr, i); + nose.applyBoneTransform(i, point); + nose.localToWorld(point); + radius = Math.max(radius, point.distanceTo(centre)); + } + if (!Number.isFinite(radius) || radius <= 0) radius = 0.25; + const material = new THREE.MeshStandardMaterial({ + color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92, + }); + 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 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() }; + group.visible = false; + bone.add(group); + return group; + })(); + const snotMeshes = snotBubble ? [...snotBubble.children] : []; + applySnotBubble(snotBubble, state.face.eyes.snot); + const halfHeight = model.size.y * 0.5; view.frame(model.size, halfHeight); @@ -975,6 +1048,7 @@ async function init() { rig.helper, model.parts.eyeMesh, model.parts.mouthMesh, + ...snotMeshes, ...styles.outlineMeshes, ]; } @@ -1156,6 +1230,7 @@ async function init() { let fpsAt = performance.now(); const warmupUntil = performance.now() + 2500; + let snotSignature = null; const loop = (now) => { const rawDelta = now - last; const dt = Math.min(0.05, rawDelta / 1000); @@ -1167,6 +1242,16 @@ async function init() { if (!rig.controls.dragging) rig.applyPose(posed); container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0); + if (snotBubble) { + const snot = state.face.eyes.snot; + const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.color}`; + if (sig !== snotSignature) { + snotSignature = sig; + applySnotBubble(snotBubble, snot); + app.needsRender = true; + } + } + const params = buildFaceParams(); const signature = JSON.stringify(params); if (signature !== faceSignature) { @@ -2069,6 +2154,90 @@ async function init() { document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); }); const help = document.getElementById('help'); help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); }); + + // --- the mouse on the character ---------------------------------------- + // Dragging the model slides it across the view; holding Shift and dragging + // turns it around. Bone posing lives in the panel, so the mouse never grabs a + // bone (see `Rig` in src/rig.js). On a small screen the panel is a sheet over + // the view, so a tap on the view first just dismisses the sheet. + const dragRay = new THREE.Raycaster(); + const dragNdc = new THREE.Vector2(); + const dragHitAt = (event) => { + const rect = canvas.getBoundingClientRect(); + dragNdc.set( + ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1, + -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1, + ); + dragRay.setFromCamera(dragNdc, view.camera); + const targets = [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body] + .filter((mesh) => mesh?.visible); + return dragRay.intersectObjects(targets, false)[0] ?? null; + }; + // World units per screen pixel, so the model keeps up with the cursor. + const dragScale = () => { + const camera = view.camera; + const height = Math.max(1, canvas.clientHeight); + if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / height; + const distance = camera.position.distanceTo(view.controls.target); + return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height; + }; + let modelDrag = null; + + canvas.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || event.target !== canvas) return; + if (narrowScreenLayout.matches && !document.body.classList.contains('panel-hidden')) { + togglePanel(); + return; + } + if (!dragHitAt(event)) return; + const root = state.pose.root ?? [0, 0, 0]; + const master = state.pose.bones?.master ?? [0, 0, 0]; + modelDrag = { + pointerId: event.pointerId, + spin: event.shiftKey, + x: event.clientX, + y: event.clientY, + root: [...root], + masterY: master[1] ?? 0, + }; + view.controls.enabled = false; + stage.style.cursor = 'grabbing'; + try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ } + }); + + canvas.addEventListener('pointermove', (event) => { + if (!modelDrag || event.pointerId !== modelDrag.pointerId) return; + const dx = event.clientX - modelDrag.x; + const dy = event.clientY - modelDrag.y; + if (modelDrag.spin) { + const bones = { ...(state.pose.bones ?? {}) }; + const master = bones.master ?? [0, 0, 0]; + bones.master = [master[0], round2(modelDrag.masterY + dx * 0.7), master[2]]; + state.pose.bones = bones; + } else { + const k = dragScale(); + state.pose.root = [ + round2(modelDrag.root[0] + dx * k), + round2(modelDrag.root[1] - dy * k), + modelDrag.root[2], + ]; + } + app.panel?.onRigChanged?.(); + app.needsRender = true; + }); + + const endModelDrag = (event) => { + if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return; + modelDrag = null; + view.controls.enabled = true; + stage.style.cursor = ''; + if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } } + capturePose(); + app.panel?.onRigChanged?.(); + app.needsRender = true; + }; + canvas.addEventListener('pointerup', endModelDrag); + canvas.addEventListener('pointercancel', endModelDrag); } function togglePanel() { |
