/* ================= 首页头图:宝宝时不时动一动小手小脚(2026-10-05) ================= 用户:「右上角羊水袋里的小宝宝时不时稍微动一动小腿、小脚、小胳膊,缓缓地动,不要太激烈」。 ⚠️ 之前试过 AI 生成的 GIF,整张一起扭,脸型、腿型都变(见 hero-fx.js show() 里的注释),所以这里不用动图: 在静图上盖一张 WebGL 画布,同一张 PNG 原样画上去,只在「关节 → 手脚末端」这一小段做小角度旋转扭动, 脸周围是保护圈(权重强制为 0),头和身子一像素都不动。 · 每个阶段的关节位置手工标在 LIMBS 里(坐标 = 480×480 原图像素)。1、2 阶段是胚芽,没有手脚,不动。 · 节奏(11-05 起改为:只小脚踢,每 10~28 秒一下,见 plan())旧:(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,没有就在手势里放一段极短的静音