274 lines
18 KiB
JavaScript
274 lines
18 KiB
JavaScript
/* ================= 首页头图:宝宝时不时动一动小手小脚(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'); });
|
||
})();
|