feat(首页头图): 宝宝按孕周 9 阶段换形态、孕囊随之缩放;花瓣换成阳光水波 (v26100407)

- 背景换成去掉羊水囊的空背景,羊水囊+宝宝整幅按阶段换(4/8/11/14/18/24/28/32/36 周)
- 每阶段一张本地程序化形变 GIF(呼吸、轻浮、小手、蹬脚;脸不动,闭眼闭嘴)+ 同帧静图打底
- 孕囊直径按阶段 .42~1.03 倍,围绕原圆心缩放
- 去掉花瓣飘落;新增 #heroFx 画布:斜照光束 + 水波焦散,离开首页/不可见时停画
- 逻辑独立在 js/hero-fx.js,调试 ?kidweek=N

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 02:14:41 +00:00
parent 51b1e392aa
commit e718e8298d
23 changed files with 174 additions and 62 deletions

138
js/hero-fx.js Normal file
View File

@@ -0,0 +1,138 @@
/* ================= 首页头图:按孕周换宝宝 + 阳光水波(2026-10-04) =================
用户:「宝宝按孕周换、孕囊跟着宝宝大小变;顶部往下落的花瓣去掉,换成阳光和水波纹」。
① 换宝宝:孕周来自 app.js 的 mb-preg 事件(syncPregnancy 跑完广播,首屏一次、远程档案回来再一次)。
9 个阶段各一张「羊水囊 + 宝宝」整幅图:静图(首帧)先出,GIF 加载完再盖上淡入。
没建档 / 产后 / 事件迟迟不来 → 用第 6 阶段(原来那个 25 周的宝宝)。
孕囊大小 = .hero-baby 的 --k(相对原 25 周囊的倍数),CSS 里围绕同一圆心缩放、带过渡。
调试:URL 带 kidweek=12 强制按 12 周显示。
② 阳光水波:#heroFx 一张画布,screen 混合叠在宝宝上、文字下。
光束 = 右上角斜照下来的几道软光,慢慢摆;水波 = 低分辨率算一张焦散网再放大(糊一点正好像水底光斑)。
不在首页 / 页面隐藏 / 头图滚出视口时停画;系统「减少动态效果」下整块不画(CSS 也隐藏)。 */
(function () {
'use strict';
// [起始孕周, 静图, GIF, 孕囊倍数 k]
const STAGES = [
[0, '6fff51c80fe6.jpg', '2ad895e978db.gif', .42], // 1 4–7 周 胚芽 + 卵黄囊
[8, 'a75f4970478c.jpg', '90bd965c9c20.gif', .52], // 2 8–10 周 小豆芽胚胎
[11, '42d00e242622.jpg', 'd7eda80e7d45.gif', .62], // 3 11–13 周 刚有人形
[14, '23302f9bfb42.jpg', 'f8252c0c0c22.gif', .72], // 4 14–17 周 身子拉长、吮手
[18, '7e6c3509097a.jpg', '993c4174c867.gif', .86], // 5 18–23 周 比例接近新生儿、偏瘦
[24, 'c0cbdd749ab0.jpg', '9c682de32bc3.gif', 1], // 6 24–27 周 原来那个宝宝
[28, 'bbaebc03aaf7.jpg', '01529c30ecc6.gif', 1.01], // 7 28–31 周 大一圈、有胎发
[32, '086e4e001a89.jpg', '11061c1c24c1.gif', 1.02], // 8 32–35 周 肉嘟嘟、抱腿
[36, 'f6d3a8d56d5b.jpg', '2262d424d7e2.gif', 1.03] // 9 36 周起 头朝下入盆
];
const DEFAULT = 5;
const IMG = 'assets/img/';
const box = document.getElementById('heroBaby');
const still = document.getElementById('heroStill');
const kid = document.getElementById('heroKid');
if (!box || !still || !kid) return;
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const force = (location.search.match(/[?&]kidweek=(\d+)/) || [])[1];
function stageOf(week) {
if (week == null || isNaN(week)) return DEFAULT;
let i = 0;
while (i + 1 < STAGES.length && week >= STAGES[i + 1][0]) i++;
return i;
}
let cur = -1, seq = 0;
function show(i) {
if (i === cur) return;
cur = i;
const [, png, gif, k] = STAGES[i], my = ++seq;
box.style.setProperty('--k', k);
// 先把旧图淡掉再换,避免新旧宝宝叠在一起闪一下
kid.classList.remove('is-on'); still.classList.remove('is-on');
const s = new Image();
s.onload = () => { if (my !== seq) return; still.src = s.src; still.classList.add('is-on'); };
s.src = IMG + png;
if (reduce) return;
const g = new Image();
g.onload = () => { if (my !== seq) return; kid.src = g.src; kid.classList.add('is-on'); };
g.src = IMG + gif;
}
function onPreg(d) { show(stageOf(force != null ? +force : (d && d.week))); }
addEventListener('mb-preg', e => onPreg(e.detail));
if (force != null || window.__mbPreg) onPreg(window.__mbPreg);
else setTimeout(() => { if (cur < 0) show(DEFAULT); }, 2500);
/* ---------------- 阳光 + 水波 ---------------- */
const cv = document.getElementById('heroFx');
if (!cv || reduce) return;
const hero = cv.parentNode, home = document.getElementById('screen-home');
const ctx = cv.getContext('2d');
const CW = 72; // 焦散低分辨率宽
const off = document.createElement('canvas'), oc = off.getContext('2d');
let W = 0, H = 0, dpr = 1, ch = 0, img = null, seen = true, raf = 0, last = 0;
function size() {
const r = hero.getBoundingClientRect();
dpr = Math.min(devicePixelRatio || 1, 2);
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ch = Math.max(8, Math.round(CW * H / W));
off.width = CW; off.height = ch; img = oc.createImageData(CW, ch);
}
// 光束:从右上角画外斜着照进来,每道各自慢慢摆、忽明忽暗
const RAYS = [[-.10, .07, .55], [.02, .045, .4], [.12, .09, .5], [.24, .05, .35], [.34, .07, .3]];
function rays(t) {
const ox = W * .92, oy = -H * .55, L = H * 2.1;
ctx.globalCompositeOperation = 'lighter';
RAYS.forEach(([a0, w, al], i) => {
const a = Math.PI * .62 + a0 + Math.sin(t * .13 + i * 1.7) * .035;
const ww = w * (1 + .25 * Math.sin(t * .21 + i));
const k = al * (.65 + .35 * Math.sin(t * .37 + i * 2.3));
const x1 = ox + Math.cos(a - ww) * L, y1 = oy + Math.sin(a - ww) * L;
const x2 = ox + Math.cos(a + ww) * L, y2 = oy + Math.sin(a + ww) * L;
const gr = ctx.createLinearGradient(ox, oy, ox + Math.cos(a) * L * .8, oy + Math.sin(a) * L * .8);
gr.addColorStop(0, 'rgba(255,248,225,0)');
gr.addColorStop(.3, 'rgba(255,244,215,' + (.16 * k).toFixed(3) + ')');
gr.addColorStop(.62, 'rgba(255,236,205,' + (.07 * k).toFixed(3) + ')');
gr.addColorStop(1, 'rgba(255,230,200,0)');
ctx.fillStyle = gr;
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(x1, y1); ctx.lineTo(x2, y2); ctx.closePath(); ctx.fill();
});
}
// 水波焦散:几层正弦叠出起伏,取「接近 0 的那条线」做亮线,越往下越淡
function caustics(t) {
const d = img.data, cx = CW * .7, cy = ch * .1;
for (let y = 0; y < ch; y++) {
const fall = 1 - y / ch * .55;
for (let x = 0; x < CW; x++) {
const v = Math.sin(x * .23 + t * .8) + Math.sin(y * .31 - t * .6 + x * .05)
+ Math.sin((x + y) * .14 + t * .45) + Math.sin(Math.hypot(x - cx, y - cy) * .27 - t * 1.05);
let c = 1 - Math.abs(Math.sin(v * 1.15));
c = c * c; c = c * c * c; // 六次方:只留细细的亮网
const o = (y * CW + x) * 4;
d[o] = 255; d[o + 1] = 242; d[o + 2] = 222; d[o + 3] = c * 120 * fall;
}
}
oc.putImageData(img, 0, 0);
ctx.globalCompositeOperation = 'lighter';
ctx.imageSmoothingEnabled = true;
ctx.globalAlpha = .55;
ctx.drawImage(off, 0, 0, W, H);
ctx.globalAlpha = 1;
}
function frame(now) {
raf = 0;
if (!(seen && !document.hidden && (!home || home.classList.contains('is-active')))) return;
raf = requestAnimationFrame(frame);
if (now - last < 33) return; // ≈30fps 足够,省电
last = now;
const t = now / 1000;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
caustics(t); rays(t);
}
function kick() { if (!raf) raf = requestAnimationFrame(frame); }
size();
addEventListener('resize', () => { size(); kick(); });
document.addEventListener('visibilitychange', kick);
if (home) new MutationObserver(kick).observe(home, { attributes: true, attributeFilter: ['class'] });
if ('IntersectionObserver' in window) new IntersectionObserver(es => { seen = es[0].isIntersecting; kick(); }).observe(hero);
cv.classList.add('is-on');
kick();
})();