Files
mama-box/js/baby-limbs.js

274 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ================= 首页头图:宝宝时不时动一动小手小脚(2026-10-05) =================
用户:「右上角羊水袋里的小宝宝时不时稍微动一动小腿、小脚、小胳膊,缓缓地动,不要太激烈」。
⚠️ 之前试过 AI 生成的 GIF,整张一起扭,脸型、腿型都变(见 hero-fx.js show() 里的注释),所以这里不用动图:
在静图上盖一张 WebGL 画布,同一张 PNG 原样画上去,只在「关节 → 手脚末端」这一小段做小角度旋转扭动,
脸周围是保护圈(权重强制为 0),头和身子一像素都不动。
· 每个阶段的关节位置手工标在 LIMBS 里(坐标 = 480×480 原图像素)。1、2 阶段是胚芽,没有手脚,不动。
· 节奏(10-12 起只在孕 20 周起):每隔 4~9 秒随机挑一处(小腿 / 小脚 / 小胳膊),用 1.8~2.8 秒缓缓抬起再放回;
偶尔小腿和小脚一起「蹬」一下。角度只有 3~9 度。不动的时候不重画(不占电)。
· 跟 hero-fx.js 解耦:不改它,只盯 #heroStill 的 src / class —— 换阶段、淡入淡出都自动跟上。
· 没 WebGL / 系统「减少动态效果」→ 什么都不做,静图照常。
· 调试:URL 带 limbdebug=1 → 角度 ×4、动作更频繁,并把会动的区域染成蓝色,方便核对关节标得准不准。 */
(function () {
'use strict';
const still = document.getElementById('heroStill');
const box = document.getElementById('heroBaby');
if (!still || !box || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const DEBUG = /[?&]limbdebug=1/.test(location.search);
// 每处:[关节 x, y, 末端 x, y, 影响半径, 最大角度(度), 类型] 类型 leg / foot / arm(foot 会被 leg 带着一起蹬)
// face:[圆心 x, y, 半径] —— 圈内完全不动,1.5 倍半径处才恢复
const LIMBS = {
'450581c2f05f': { face: [228, 222, 24], l: [[268, 272, 300, 286, 14, 6, 'leg'], [242, 258, 266, 240, 9, 5, 'arm']] },
'dc4c7eea1c50': { face: [240, 207, 30], l: [[288, 272, 322, 320, 18, 5, 'leg'], [306, 318, 326, 310, 10, 9, 'foot'], [228, 272, 256, 236, 10, 3, 'arm']] },
'7bf792b04123': { face: [217, 205, 32], l: [[252, 262, 292, 302, 15, 6, 'leg'], [278, 305, 296, 298, 10, 10, 'foot'], [197, 283, 236, 244, 12, 5, 'arm']] },
'f140f4367b48': { face: [220, 208, 33], l: [[262, 262, 306, 303, 15, 6, 'leg'], [288, 305, 308, 300, 10, 10, 'foot'], [200, 282, 236, 244, 12, 5, 'arm']] },
'51e99b3060e2': { face: [222, 205, 34], l: [[276, 266, 335, 310, 17, 6, 'leg'], [322, 313, 338, 305, 11, 10, 'foot'], [232, 268, 278, 240, 12, 5, 'arm']] },
'a5dbb24c1ef2': { face: [225, 215, 40], l: [[276, 252, 322, 296, 16, 5, 'leg'], [310, 305, 326, 293, 11, 10, 'foot']] },
'610c4d92c16d': { face: [250, 205, 42], l: [[292, 272, 335, 305, 16, 4, 'leg'], [325, 312, 338, 300, 12, 9, 'foot']] }
};
const N = 4; // shader 里最多 4 处
const cv = document.createElement('canvas');
cv.className = 'hero-kid hero-limb';
cv.setAttribute('aria-hidden', 'true');
still.after(cv);
const gl = cv.getContext('webgl', { premultipliedAlpha: true, alpha: true }) || cv.getContext('experimental-webgl');
if (!gl) { cv.remove(); return; }
const VS = 'attribute vec2 a;void main(){gl_Position=vec4(a,0.,1.);}';
const FS = [
'precision mediump float;',
'uniform vec2 uRes;uniform sampler2D uTex;uniform vec4 uL[' + N + '];uniform vec2 uR[' + N + '];uniform vec3 uF;uniform float uDbg;',
'void main(){',
' vec2 q=vec2(gl_FragCoord.x/uRes.x,1.-gl_FragCoord.y/uRes.y)*480.;',
' vec2 off=vec2(0.);float wsum=0.;',
' for(int i=0;i<' + N + ';i++){',
' float ang=uR[i].y; if(ang==0.) continue;',
' vec2 p=uL[i].xy,d=uL[i].zw-p; float s=dot(q-p,d)/dot(d,d);',
' float dist=length(q-(p+d*clamp(s,0.,1.))); float r=uR[i].x;',
// 关节处 0 → 段长一半处 1(关节不撕开);过了末端再渐隐;离开肢体 r 以外渐隐
' float w=smoothstep(.05,.55,s)*(1.-smoothstep(1.25,1.7,s))*(1.-smoothstep(r*.6,r*1.25,dist));',
' float a=-ang*w; vec2 v=q-p;',
' off+=vec2(cos(a)*v.x-sin(a)*v.y,sin(a)*v.x+cos(a)*v.y)-v; wsum+=w;',
' }',
' float fw=smoothstep(uF.z,uF.z*1.5,length(q-uF.xy));', // 脸保护圈
' vec4 c=texture2D(uTex,(q+off*fw)/480.);',
' if(uDbg>.5) c.rgb=mix(c.rgb,vec3(.2,.5,1.)*c.a,min(wsum*fw,1.)*.6);',
' gl_FragColor=c;',
'}'
].join('\n');
function sh(t, src) { const s = gl.createShader(t); gl.shaderSource(s, src); gl.compileShader(s); return s; }
const pr = gl.createProgram();
gl.attachShader(pr, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FS)); gl.linkProgram(pr);
if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) { cv.remove(); return; }
gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const aLoc = gl.getAttribLocation(pr, 'a');
gl.enableVertexAttribArray(aLoc); gl.vertexAttribPointer(aLoc, 2, gl.FLOAT, false, 0, 0);
const U = {}; ['uRes', 'uTex', 'uL', 'uR', 'uF', 'uDbg'].forEach(k => { U[k] = gl.getUniformLocation(pr, k); });
gl.uniform1i(U.uTex, 0); gl.uniform1f(U.uDbg, DEBUG && !/limbdebug=2/.test(location.search) ? 1 : 0); // limbdebug=2:放大角度但不染色
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
/* 2026-10-12 用户:「20 周以后的宝宝,偶尔让他稍微动一动,微微动一动脚和手,幅度不要太大」。
原来 11 周起都动、角度 3~9°,实测 10 秒里只有两三百个像素在变,基本看不出来。
改成:只有孕 20 周起才动;手 3°→5°、小腿 5°→6°、小脚 9°→10°(仍是「微微」),间隔放宽到 4~9 秒(「偶尔」)。
孕周来自 hero-fx.js 同一个 mb-preg 事件(没建档 / 产后不动);kidweek=NN 调试参数照样认。 */
const FORCE = (location.search.match(/[?&]kidweek=(\d+)/) || [])[1];
let week = FORCE != null ? +FORCE : (window.__mbPreg && !window.__mbPreg.postpartum ? window.__mbPreg.week : null);
addEventListener('mb-preg', e => { if (FORCE == null) { const d = e.detail || {}; week = d.postpartum ? null : d.week; } });
const old = () => week >= 20;
let cfg = null, ready = false, raf = 0, timer = 0, seen = true, lastPick = -1;
const moves = []; // 正在进行的动作:{ i, t0, dur, amp }
const home = document.getElementById('screen-home');
const active = () => seen && !document.hidden && (!home || home.classList.contains('is-active'));
function size() {
const d = Math.min(3, devicePixelRatio || 1), w = Math.round(Math.min(720, box.clientWidth * d)) || 480;
if (cv.width !== w) { cv.width = cv.height = w; gl.viewport(0, 0, w, w); }
gl.uniform2f(U.uRes, w, w);
}
function draw(now) {
const L = new Float32Array(N * 4), R = new Float32Array(N * 2);
let busy = false;
for (let k = moves.length - 1; k >= 0; k--) {
const m = moves[k], u = (now - m.t0) / m.dur;
if (u >= 1) { moves.splice(k, 1); continue; }
if (u < 0) { busy = true; continue; }
busy = true;
const e = Math.sin(Math.PI * u); R[m.i * 2 + 1] += m.amp * e * e; // 缓起缓落:0 → 最大 → 0
}
cfg.l.forEach((l, i) => { if (i < N) { L.set(l.slice(0, 4), i * 4); R[i * 2] = l[4]; } });
gl.uniform4fv(U.uL, L); gl.uniform2fv(U.uR, R); gl.uniform3fv(U.uF, cfg.face);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
return busy;
}
function loop(now) {
raf = 0;
if (!ready || !active()) return;
if (draw(now)) raf = requestAnimationFrame(loop);
}
const kick = () => { if (!raf && ready) raf = requestAnimationFrame(loop); };
/* 11-05 用户:「踢的时候给一个小音效,不要持续,只有踢的时候给一点点」
→ 只在小腿 / 小脚动(=踢)时响一声,小胳膊动不响;WebAudio 现场合成一声很轻的「噗」(隔着水的低沉闷响 + 一个极轻的小气泡),约 0.2 秒、音量很小,不加载音频文件。
· 浏览器规定用户碰过页面后才能出声 → 第一次点 / 摸屏幕时才建 AudioContext,之前的踢腿不响。
· 页面在后台 / 首页不在前台时本来就不动,也就不响;系统「减少动态效果」整个文件不跑。
· URL 带 kicksound=0 关掉(调试用)。 */
/* 11-05 用户:「音效我打开了,但是没有声音」→ 三处原因一起修:
① 原来是 60~150Hz 的低音,手机外放喇叭基本放不出 → 改到 180~420Hz 的「咚」+ 一点低通噪声当「闷擦」,音量略加;
② iPhone 静音键开着时 WebAudio 会被静音 → 有 navigator.audioSession 就设成 playback,没有就在手势里放一段极短的静音 <audio> 把会话切到「播放」类;
③ 原来只在第一次触摸建一次 AudioContext,没起来或切后台被挂起后就再也不响 → 每次触摸都检查,挂起了就 resume。 */
let ac = null, nb = null, primed = false;
const soundOff = /[?&#]kicksound=0/.test(location.href);
function silentWav() { // 0.05 秒静音 wav(8kHz 单声道 8bit)
const n = 400, buf = new ArrayBuffer(44 + n), v = new DataView(buf), w = (o, str) => { for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i)); };
w(0, 'RIFF'); v.setUint32(4, 36 + n, true); w(8, 'WAVEfmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, 8000, true); v.setUint32(28, 8000, true); v.setUint16(32, 1, true); v.setUint16(34, 8, true); w(36, 'data'); v.setUint32(40, n, true);
for (let i = 0; i < n; i++) v.setUint8(44 + i, 128);
return URL.createObjectURL(new Blob([buf], { type: 'audio/wav' }));
}
const unlock = () => {
if (soundOff) return;
try {
if (!primed) {
primed = true;
if (navigator.audioSession) navigator.audioSession.type = 'playback';
else { const au = new Audio(silentWav()); au.setAttribute('playsinline', ''); au.volume = 0.01; const pr = au.play(); if (pr && pr.catch) pr.catch(() => {}); }
}
if (!ac) { const A = window.AudioContext || window.webkitAudioContext; if (A) ac = new A(); }
if (ac && ac.state !== 'running') ac.resume();
if (ac && !nb) { // 0.25 秒白噪声,做「闷擦」用
nb = ac.createBuffer(1, Math.floor(ac.sampleRate * .25), ac.sampleRate);
const d = nb.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
} catch (e) {}
};
['touchstart', 'touchend', 'pointerdown', 'click'].forEach(t => addEventListener(t, unlock, { passive: true, capture: true }));
/* 11-05 用户:「不是每一次都出现,随机、偶尔出现一次就行」→ 每次踢只有 2 成概率响,且离上一声至少 40 秒(大约一两分钟才响一次) */
let lastSnd = 0;
function kickSound(delay) {
if (!ac || ac.state !== 'running') return;
const nowMs = Date.now();
if (!DEBUG && (nowMs - lastSnd < 40000 || Math.random() > .2)) return;
lastSnd = nowMs;
try { if (localStorage.getItem('mb_kick_snd') === '0') return; } catch (e) {} // 「我的 → 隐私与权限 → 宝宝踢腿音效」关了就不响
const t = ac.currentTime + delay / 1000, out = ac.createGain();
out.gain.value = 0.9; out.connect(ac.destination);
// 咚:正弦 420→180Hz 快速下滑(手机喇叭放得出来),带一点二次谐波让它更「实」
[[1, .32], [2, .08]].forEach(([m, v]) => {
const o = ac.createOscillator(), g = ac.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(420 * m, t); o.frequency.exponentialRampToValueAtTime(180 * m, t + .14);
g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(v, t + .008); g.gain.exponentialRampToValueAtTime(0.0001, t + .18);
o.connect(g); g.connect(out); o.start(t); o.stop(t + .2);
});
// 闷擦:低通噪声一小下,像隔着肚皮蹬了一脚
if (nb) {
const n = ac.createBufferSource(), lp = ac.createBiquadFilter(), ng = ac.createGain();
n.buffer = nb; lp.type = 'lowpass'; lp.frequency.value = 900;
ng.gain.setValueAtTime(0.0001, t); ng.gain.exponentialRampToValueAtTime(0.12, t + .006); ng.gain.exponentialRampToValueAtTime(0.0001, t + .09);
n.connect(lp); lp.connect(ng); ng.connect(out); n.start(t); n.stop(t + .1);
}
}
// 安排下一次「动一动」
function plan() {
clearTimeout(timer);
timer = setTimeout(() => {
if (ready && active() && cfg.l.length && (old() || DEBUG)) {
let i = Math.floor(Math.random() * cfg.l.length);
if (i === lastPick && cfg.l.length > 1) i = (i + 1) % cfg.l.length;
lastPick = i;
const now = performance.now(), l = cfg.l[i], dir = Math.random() < .5 ? -1 : 1;
const dur = 1800 + Math.random() * 1000, deg = l[5] * (.6 + Math.random() * .4) * (DEBUG ? 4 : 1);
moves.push({ i, t0: now, dur, amp: dir * deg * Math.PI / 180 });
// 小腿动时 4 成概率小脚也跟着轻轻翘一下(像蹬了一下),稍晚 0.3 秒起
const f = cfg.l.findIndex(x => x[6] === 'foot');
if (l[6] === 'leg' && f >= 0 && Math.random() < .4)
moves.push({ i: f, t0: now + 300, dur: dur - 200, amp: -dir * cfg.l[f][5] * .7 * (DEBUG ? 4 : 1) * Math.PI / 180 });
if (l[6] === 'leg' || l[6] === 'foot') {
kickSound(dur * .3);
kickUntil = now + dur + 800; // 踢的这段时间(+0.8 秒宽限)点脚弹红心
kickIdx = [i]; if (f >= 0 && f !== i && moves.some(m => m.i === f)) kickIdx.push(f);
} // 踢到最快那一下时响(缓起缓落,约 3 成处)
kick();
}
plan();
}, DEBUG ? 900 + Math.random() * 600 : 4000 + Math.random() * 5000);
}
/* 11-05 用户:「当婴儿踢的时候,点击这个踢的位置,弹出一个实心的红心」
→ 小腿 / 小脚正在动(含动完后 0.8 秒宽限)时,点到那只脚附近(以末端为中心、原图 70px 半径,手指粗所以放宽)就在点的位置弹一颗实心红心:
放大弹出 → 往上飘 → 淡出,约 1 秒。小胳膊动、或没在踢的时候点,不弹。
· 头图宝宝那层是 pointer-events:none(不挡下面的点击),所以这里在 document 上听 pointerdown 再按坐标判断,不拦截、不影响原有点击。
· 调试 limbdebug=1 时任何时候点脚附近都弹,方便看效果。 */
const hs = document.createElement('style');
hs.textContent = '.kick-heart{position:fixed;z-index:9999;width:34px;height:34px;margin:-17px 0 0 -17px;pointer-events:none;' +
'animation:khPop 1.1s cubic-bezier(.2,.8,.3,1) forwards;filter:drop-shadow(0 3px 6px rgba(220,30,60,.35))}' +
'@keyframes khPop{0%{transform:scale(.2);opacity:0}18%{transform:scale(1.25);opacity:1}32%{transform:scale(.95)}45%{transform:scale(1.05) translateY(-6px);opacity:1}65%{opacity:1}100%{transform:scale(1) translateY(-46px);opacity:0}}';
document.head.appendChild(hs);
let kickUntil = 0, kickIdx = [];
function heartAt(x, y) {
const h = document.createElement('div');
h.className = 'kick-heart';
h.style.left = x + 'px'; h.style.top = y + 'px';
h.innerHTML = '<svg viewBox="0 0 24 24" width="34" height="34"><path d="M12 21.2l-1.4-1.3C5.4 15.3 2 12.2 2 8.4 2 5.3 4.4 3 7.4 3c1.8 0 3.5.8 4.6 2.2C13.1 3.8 14.8 3 16.6 3 19.6 3 22 5.3 22 8.4c0 3.8-3.4 6.9-8.6 11.5z" fill="#F0284A"/></svg>';
document.body.appendChild(h);
setTimeout(() => h.remove(), 1100);
}
addEventListener('pointerdown', e => {
if (!ready || !cfg || !cv.classList.contains('is-on')) return;
if (!DEBUG && performance.now() > kickUntil) return;
const r = cv.getBoundingClientRect();
if (!r.width || e.clientX < r.left - 30 || e.clientX > r.right + 30 || e.clientY < r.top - 30 || e.clientY > r.bottom + 30) return;
const k = 480 / r.width, qx = (e.clientX - r.left) * k, qy = (e.clientY - r.top) * k;
const idx = DEBUG ? cfg.l.map((l, i) => i).filter(i => cfg.l[i][6] !== 'arm') : kickIdx;
if (idx.some(i => { const l = cfg.l[i]; return Math.hypot(qx - l[2], qy - l[3]) < 70 || Math.hypot(qx - (l[0] + l[2]) / 2, qy - (l[1] + l[3]) / 2) < 60; }))
heartAt(e.clientX, e.clientY);
}, { passive: true, capture: true });
// 跟着 #heroStill:换图时重新载贴图,静图淡出时画布一起淡出
function sync() {
const m = (still.getAttribute('src') || '').match(/([0-9a-f]{12})\.png/);
const next = m && LIMBS[m[1]];
if (!next) { cfg = null; ready = false; cv.classList.remove('is-on'); return; }
if (next !== cfg) {
cfg = next; ready = false; moves.length = 0;
cv.classList.remove('is-on');
const img = new Image();
img.onload = () => {
if (cfg !== next) return;
size();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
ready = true; draw(performance.now());
if (still.classList.contains('is-on')) cv.classList.add('is-on');
};
img.src = still.src;
return;
}
if (ready) cv.classList.toggle('is-on', still.classList.contains('is-on'));
}
// 调试:__limbPose(k) 把所有手脚定格在 k × 最大角度(k=0 原图),方便截图核对
if (DEBUG) window.__limbPose = k => {
clearTimeout(timer); moves.length = 0;
cfg.l.forEach((l, i) => moves.push({ i, t0: performance.now() - 500, dur: 1000, amp: k * l[5] * Math.PI / 180 }));
draw(performance.now()); moves.length = 0;
};
new MutationObserver(sync).observe(still, { attributes: true, attributeFilter: ['src', 'class'] });
sync();
plan();
addEventListener('resize', () => { if (ready) { size(); draw(performance.now()); } });
if ('IntersectionObserver' in window) new IntersectionObserver(es => { seen = es[0].isIntersecting; }).observe(box);
cv.addEventListener('webglcontextlost', () => { ready = false; cv.classList.remove('is-on'); });
})();