首页宝宝时不时缓缓动一动小手小脚(WebGL 局部扭动,脸不动)(v26100439)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 01:04:27 +00:00
parent c957be3a77
commit b87afd1b27
3 changed files with 173 additions and 2 deletions

View File

@@ -7,7 +7,7 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<title>兜爸心选 · 孕育陪伴</title> <title>兜爸心选 · 孕育陪伴</title>
<!-- V:26100438 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 --> <!-- V:26100439 缓存版本标记:改 css/js 时同步 bump 该值(与 js/app.js 的 VER 同源),静态资源按 mtime 走 ETag 自动刷新 -->
<link rel="stylesheet" href="mama-box.css"> <link rel="stylesheet" href="mama-box.css">
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script> <script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<!-- 共享助手:授权/跳商城/位置上报(分享卡片同步,见 docs/h5-auth-standard.md 第四节) --> <!-- 共享助手:授权/跳商城/位置上报(分享卡片同步,见 docs/h5-auth-standard.md 第四节) -->
@@ -2039,6 +2039,8 @@
<script src="js/bump.js"></script> <script src="js/bump.js"></script>
<!-- 2026-10-04 首页头图:按孕周换宝宝 + 阳光水波(独立文件) --> <!-- 2026-10-04 首页头图:按孕周换宝宝 + 阳光水波(独立文件) -->
<script src="js/hero-fx.js"></script> <script src="js/hero-fx.js"></script>
<!-- 2026-10-05 首页头图:宝宝时不时动一动小手小脚(独立文件,删掉这一行即下线) -->
<script src="js/baby-limbs.js"></script>
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@
(function () { (function () {
'use strict'; 'use strict';
/* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */ /* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */
var VER = '26100438'; var VER = '26100439';
const $ = (s, c) => (c || document).querySelector(s); const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s)); const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));

169
js/baby-limbs.js Normal file
View File

@@ -0,0 +1,169 @@
/* ================= 首页头图:宝宝时不时动一动小手小脚(2026-10-05) =================
用户:「右上角羊水袋里的小宝宝时不时稍微动一动小腿、小脚、小胳膊,缓缓地动,不要太激烈」。
⚠️ 之前试过 AI 生成的 GIF,整张一起扭,脸型、腿型都变(见 hero-fx.js show() 里的注释),所以这里不用动图:
在静图上盖一张 WebGL 画布,同一张 PNG 原样画上去,只在「关节 → 手脚末端」这一小段做小角度旋转扭动,
脸周围是保护圈(权重强制为 0),头和身子一像素都不动。
· 每个阶段的关节位置手工标在 LIMBS 里(坐标 = 480×480 原图像素)。1、2 阶段是胚芽,没有手脚,不动。
· 节奏:每隔 2.5~6 秒随机挑一处(小腿 / 小脚 / 小胳膊),用 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, 5, 'leg'], [278, 305, 296, 298, 10, 9, 'foot'], [197, 283, 236, 244, 12, 3, 'arm']] },
'f140f4367b48': { face: [220, 208, 33], l: [[262, 262, 306, 303, 15, 5, 'leg'], [288, 305, 308, 300, 10, 9, 'foot'], [200, 282, 236, 244, 12, 3, 'arm']] },
'51e99b3060e2': { face: [222, 205, 34], l: [[276, 266, 335, 310, 17, 5, 'leg'], [322, 313, 338, 305, 11, 9, 'foot'], [232, 268, 278, 240, 12, 4, 'arm']] },
'a5dbb24c1ef2': { face: [225, 215, 40], l: [[276, 252, 322, 296, 16, 4, 'leg'], [310, 305, 326, 293, 11, 9, 'foot']] },
'610c4d92c16d': { face: [250, 205, 42], l: [[292, 272, 335, 305, 16, 3, 'leg'], [325, 312, 338, 300, 12, 8, '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);
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); };
// 安排下一次「动一动」
function plan() {
clearTimeout(timer);
timer = setTimeout(() => {
if (ready && active() && cfg.l.length) {
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 });
kick();
}
plan();
}, DEBUG ? 900 + Math.random() * 600 : 2500 + Math.random() * 3500);
}
// 跟着 #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'); });
})();