feat(首页彩蛋): 宝宝胎动按孕周分阶段,贴近真实 (v26100403)

- <20 周 / 未建档 / 产后:不出现
- 20~25 周:小鱼吐泡泡(flick/tap),小浅快、常连两下,隔 10~25 秒,不追屏
- 26~33 周:完整版拳打脚踢,最勤
- 34 周起:多为大片慢拱(roll/butt),猛踢减少、节奏放慢
- app.js syncPregnancy 结束后广播 mb-preg;调试参数 bumpweek=N

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 00:36:59 +00:00
parent cc51237dff
commit f737d1f18d
3 changed files with 68 additions and 15 deletions

View File

@@ -20,6 +20,12 @@
松手 窝 duang duang 回弹,周围的内容跟着颤几下
600ms 三次里有一次:旁边轻轻反踢一下(宝宝不服气)
⚠️ iOS 的网页没有震动接口(微信 JS-SDK 也没有),苹果用户只有画面 + 声音。
按孕周分阶段,尽量贴近真实胎动(2026-10-04 用户:「尽量模拟真实的」):
· 20 周前 / 没填预产期 / 已产后:不出现(这时候肚皮外面看不到胎动;没孕周就不猜)
· 20~25 周「小鱼吐泡泡」:小、浅、一闪就没,常常连着两下;隔 10~25 秒才一次,周数越小越稀;滑过来也不追
· 26~33 周「拳打脚踢」:完整版——小脚丫 / 小拳头 / 膝盖 / 小屁股,最勤(隔 4~8 秒),中途还会再顶一下
· 34 周起「地方小了」:很少猛踢,多是一大片慢慢拱过去(翻身、屁股),节奏放慢(隔 7~12 秒),次数不减
孕周来自 app.js 的 mb-preg 事件(syncPregnancy 跑完后广播)。调试:URL 带 bumpweek=24 可强制按 24 周演示。
开关:「我的 → 隐私权限 → 宝宝在肚皮下动」,存 localStorage 'mb_bump'('0' = 关);系统开了「减弱动态效果」也不出来。 */
(function () {
'use strict';
@@ -58,10 +64,34 @@
fist: { rx: 44, ry: 44, sharp: 1.9, amp: 8, dur: [3, 3.8] },
knee: { rx: 48, ry: 80, sharp: 1.6, amp: 8.5, dur: [3.4, 4.2] },
butt: { rx: 92, ry: 78, sharp: 1.25, amp: 7, dur: [4, 5], roll: true },
kick: { rx: 34, ry: 36, sharp: 1.8, amp: 4.5, dur: [.35, .45] } // 被摁后不服气的反踢:小、快、摁不到
kick: { rx: 34, ry: 36, sharp: 1.8, amp: 4.5, dur: [.35, .45] }, // 被摁后不服气的反踢:小、快、摁不到
// 20~25 周:宝宝还小,只有很轻的一下(flick),偶尔稍大一点的轻碰(tap)
flick: { rx: 32, ry: 34, sharp: 1.7, amp: 5, dur: [.8, 1.2], soft: true },
tap: { rx: 42, ry: 42, sharp: 1.8, amp: 6.5, dur: [1.4, 2], soft: true },
// 34 周起:一大片慢慢拱过去(翻身),长条形,边走边起伏
roll: { rx: 118, ry: 72, sharp: 1.15, amp: 6.5, dur: [5.5, 7], roll: true, slide: 64 }
};
const ORDER = ['foot', 'fist', 'butt', 'knee'];
/* ---------- 孕周 → 阶段 ---------- */
const STAGES = {
flutter: { order: ['flick', 'flick', 'tap', 'flick'], gap: [10, 18], kick: false, again: 0, double: .45, follow: false },
active: { order: ORDER, gap: [4, 8], kick: true, again: .34, double: 0, follow: true },
late: { order: ['roll', 'butt', 'foot', 'roll', 'knee'], gap: [7, 12], kick: false, again: .15, double: 0, follow: true, slow: true }
};
const dbgWeek = (location.href.match(/bumpweek=(\d+)/) || [])[1];
let week = dbgWeek ? +dbgWeek : (window.__mbPreg ? window.__mbPreg.week : null);
const stageOf = w => w == null || w < 20 ? null : w < 26 ? STAGES.flutter : w < 34 ? STAGES.active : STAGES.late;
let stage = stageOf(week);
// 两次之间隔多久:20~22 周最稀(再 +40%),越往后越接近本阶段的基准
const gap = () => { const g = stage ? stage.gap : [6, 10], m = week != null && week < 23 ? 1.4 : 1; return (g[0] + Math.random() * (g[1] - g[0])) * m; };
addEventListener('mb-preg', e => {
if (dbgWeek) return;
week = e.detail ? e.detail.week : null; stage = stageOf(week);
if (!stage && mode !== 'wait') { mode = 'wait'; ft.h.v = pk.d.v = 0; release(); cx.clearRect(0, 0, cv.width, cv.height); }
});
let near = null; // 「连着两下」时第二下的位置
const spring = (o, dt) => { const a = -o.k * (o.v - o.to) - o.c * o.vel; o.vel += a * dt; o.v += o.vel * dt; };
const ft = { x: 0, y: 0, ang: 0, part: PARTS.foot, h: { v: 0, vel: 0, to: 0, k: 140, c: 11 },
spread: { v: 1, vel: 0, to: 1, k: 260, c: 18 }, life: 0, dur: 3, ox: 0, oy: 0, wob: 0 };
@@ -157,11 +187,13 @@
return null;
}
const active = () => !isOff() && !document.hidden && scr.classList.contains('is-active');
const active = () => !!stage && !isOff() && !document.hidden && scr.classList.contains('is-active');
const idle = () => performance.now() - lastTouch > 1500;
function appear(key, at) {
const P = PARTS[key || ORDER[seq % ORDER.length]];
const ord = stage ? stage.order : ORDER;
const P = PARTS[key || ord[seq % ord.length]];
if (!at && near) { const r0 = scrRect(); if (hitOK(r0.left + near.x, r0.top + near.y - scr.scrollTop)) at = near; near = null; }
const s = at || pickSpot(P);
if (!s) { wait = 1.5; return; }
if (!key) seq++;
@@ -170,8 +202,13 @@
ft.ang = (Math.random() - .5) * 1.4;
ft.rollDir = s.x > scr.clientWidth / 2 ? -1 : 1;
ft.spread.v = ft.spread.to = 1; ft.spread.vel = 0;
ft.h.to = P.amp; ft.h.k = P.roll ? 70 : 140; ft.h.c = 9; ft.h.vel += P.roll ? 30 : 120;
// 晚期地方小:同样的部位也顶得慢、顶得闷;早期的小鱼泡泡快起快落
const slow = stage && stage.slow && !P.roll;
ft.h.to = P.amp * (slow ? .8 : 1);
ft.h.k = P.roll ? 70 : slow ? 75 : P.soft ? 220 : 140; ft.h.c = P.soft ? 16 : 9;
ft.h.vel += P.roll ? 30 : slow ? 45 : P.soft ? 50 : 120;
ft.life = 0; ft.dur = P.dur[0] + Math.random() * (P.dur[1] - P.dur[0]); ft.kicked = false;
ft.chained = !!ft.chainNext; ft.chainNext = false;
ft.catchable = P !== PARTS.kick;
if (!skin) skin = findSkin(ft.x, ft.y);
gather(ft.x, ft.y, Math.max(P.rx, P.ry) * 1.25 + 20);
@@ -226,17 +263,17 @@
if (l < 6) { dx = -Math.sin(ft.ang); dy = Math.cos(ft.ang); l = 1; }
ft.flee = { dx: dx / l, dy: dy / l, t: 0 };
hide(true); ft.h.k = 260; ft.h.c = 26;
mode = 'flee'; wait = 4 + Math.random() * 3;
mode = 'flee'; wait = gap();
setTimeout(() => heart(fx + ft.flee.dx * 18, fy + ft.flee.dy * 18 - 10), 80);
// 三次里有一次:0.6 秒后在旁边轻轻反踢一下
clearTimeout(kickTimer);
if (Math.random() < .34) kickTimer = setTimeout(() => {
if (stage && Math.random() < stage.again) kickTimer = setTimeout(() => {
if (!active()) return;
const k = { x: fx + ft.flee.dx * 55, y: fy + ft.flee.dy * 55 };
const r0 = scrRect();
if (!hitOK(r0.left + k.x, r0.top + k.y - scr.scrollTop)) return;
buzz(10); blip(170, 90, .07, .12);
appear('kick', k); wait = 4 + Math.random() * 3;
appear('kick', k); wait = gap();
}, 600);
run();
}
@@ -278,14 +315,21 @@
const u = ft.life, P = ft.part;
if (P.roll) { // 小屁股:慢慢拱着往一边挪
const k = Math.min(u / ft.dur, 1);
ft.ox = ft.rollDir * 30 * k; ft.oy = -6 * Math.sin(k * Math.PI); ft.wob = Math.sin(u * 1.2) * .08;
ft.ox = ft.rollDir * (P.slide || 30) * k; ft.oy = -6 * Math.sin(k * Math.PI); ft.wob = Math.sin(u * 1.2) * .08;
} else {
ft.wob = Math.sin(u * 2.1) * .12;
if (u > ft.dur * .4 && !ft.kicked && P !== PARTS.kick) { ft.kicked = true; ft.h.vel += 70; } // 中途再顶一下
if (u > ft.dur * .4 && !ft.kicked && P !== PARTS.kick && !P.soft && stage && stage.kick) { ft.kicked = true; ft.h.vel += 70; } // 中途再顶一下
}
if (u > ft.dur) hide(false);
} else if (mode === 'hide') {
if (ft.h.v < .3) { mode = 'wait'; wait = 4 + Math.random() * 4; }
if (ft.h.v < .3) {
mode = 'wait'; wait = gap();
// 小鱼吐泡泡常常是连着的:一闪没了,旁边紧跟着又一下
if (stage && stage.double && ft.part.soft && !ft.chained && Math.random() < stage.double) {
const a = Math.random() * Math.PI * 2;
near = { x: ft.x + Math.cos(a) * 42, y: ft.y + Math.sin(a) * 42 }; wait = .35 + Math.random() * .3; ft.chainNext = true;
}
}
} else if (mode === 'flee') {
const f = ft.flee; f.t += dt;
const s = 24 * (1 - Math.exp(-f.t * 7));
@@ -297,7 +341,7 @@
if (Math.abs(top - lastTop) > 1.5) { still = 0; lastTop = top; }
else if (still < .4 && (still += dt) >= .4) {
const fy = ft.y + ft.oy - top, seen = fy > 30 && fy < scr.clientHeight - 30;
if (!seen) {
if (!seen && stage && stage.follow) {
if (mode === 'show' || mode === 'hide') { ft.h.v = 0; ft.h.vel = 0; mode = 'wait'; release(); }
if (mode === 'wait') wait = Math.min(wait, 1 + Math.random() * .6);
}
@@ -427,6 +471,6 @@
else { wait = 1.2; tick(); }
});
// 调试 / 截图用:URL 带 bumpdebug 才挂
if (/bumpdebug/.test(location.href)) window.__bump = { ft, pk, atoms: () => atoms.map(a => [a.el.className || a.el.tagName, Math.round(a.x), Math.round(a.y), a.el.style.transform.slice(0, 90)]), mode: () => mode, now: () => { wait = 0; lastTouch = 0; }, at: (k, x, y) => { lastTouch = 0; appear(k, { x, y: y + scr.scrollTop }); } };
if (/bumpdebug/.test(location.href)) window.__bump = { ft, pk, atoms: () => atoms.map(a => [a.el.className || a.el.tagName, Math.round(a.x), Math.round(a.y), a.el.style.transform.slice(0, 90)]), mode: () => mode, now: () => { wait = 0; lastTouch = 0; }, week: () => week, at: (k, x, y) => { lastTouch = 0; appear(k, { x, y: y + scr.scrollTop }); } };
wait = 3; tick();
})();