aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/main.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/main.js')
-rw-r--r--public/bluebey-studio/src/main.js169
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() {