Files
mama-box/js/bump.js
2026-11-07 10:14:19 +00:00

557 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ================= 首页彩蛋:宝宝在肚皮底下动(2026-10-03,预览) =================
整个首页当成妈妈的肚皮:宝宝时不时用小脚丫 / 小拳头 / 小膝盖 / 小屁股从底下往外顶,
把顶到的那片内容撑成一个软软的小帐篷;滑到哪个模块,宝宝就跟到哪一屏。
原型见 demo/bump(那边是把整页截图当纹理扭,这里是真实页面,做法不同):
· 鼓包 = 附近真实元素(叶子级的文字块、图标、图片)按离鼓包中心的远近往外撑开 + 放大 + 往外倾斜,
再在上面盖一层只画光影的 canvas(亮面 + 投影)。元素还是原来的元素,照样能点。
· 只动 transform,不碰布局;动完把元素的行内 transform / transition 原样还回去。
和点击的关系(产品口径 2026-10-03):
1. 宝宝尽量不顶按钮:挑位置时,鼓包范围里只要压到小按钮 / 链接 / 输入框就换地方;
整张能点的大卡片(工具格、知识卡)允许顶。
2. 真顶到了能点的地方:第一下算「摁宝宝」—— 这次 click 在捕获阶段被拦掉,不跳转;
宝宝缩回去后再点就正常跳。只拦鼓包中心那一圈,点在边上照常跳。
3. 宝宝冒头不到 0.3 秒就点下去 = 用户本来就是要点按钮,碰巧冒出来:不拦,照常跳,宝宝顺便缩回去。
4. 用户在滑 / 在点的时候宝宝不出来:页面静止一会儿才顶;弹层、详情页盖住时不出来。
摁中时的反馈(一次约 1.2 秒):
0ms 轻震(安卓)+ 一声很轻的「噗」+ 按出一个窝,鼓包被压扁、往四周摊开
80ms 稍重一震 + 更轻的「啵」,鼓包往远离手指的方向滑开、沉下去,冒一颗实心小红心(11-05 起,原来是空心 ♡)
松手 窝 duang duang 回弹,周围的内容跟着颤几下
600ms 三次里有一次:旁边轻轻反踢一下(宝宝不服气)
⚠️ iOS 的网页没有震动接口(微信 JS-SDK 也没有),苹果用户只有画面 + 声音。
按孕周分阶段,尽量贴近真实胎动(2026-10-04 用户:「尽量模拟真实的」):
· 20 周前 / 没填预产期 / 已产后:不出现(这时候肚皮外面看不到胎动;没孕周就不猜)
· 20~25 周「小鱼吐泡泡」:小、浅、一闪就没,常常连着两下;隔 10~25 秒才一次,周数越小越稀;滑过来也不追
· 26~33 周「拳打脚踢」:完整版——小脚丫 / 小拳头 / 膝盖 / 小屁股,中途还会再顶一下(11-05 用户:间隔改为 14~25 秒,原 4~8 秒)
· 34 周起「地方小了」:很少猛踢,多是一大片慢慢拱过去(翻身、屁股),节奏放慢(11-05 用户:间隔改为 14~25 秒,原 7~12 秒)
11-07 用户:「改成 15~25 秒一次,20 周之后都踢,力度大一点,能看出小脚印」→ 间隔统一 15~25 秒;三个阶段都以小脚丫为主(约 2/3),脚 amp 9.5→13,脚印加深放大。
孕周来自 app.js 的 mb-preg 事件(syncPregnancy 跑完后广播)。调试:URL 带 bumpweek=24 可强制按 24 周演示。
· 节奏(26 周起):有时原地「哒哒哒」带节奏连踢几拍;有时踢完不歇,旁边紧接着再踢 1~2 下
开关:「我的 → 隐私权限 → 宝宝在肚皮下动」,存 localStorage 'mb_bump'('0' = 关);系统开了「减弱动态效果」也不出来。 */
(function () {
'use strict';
const scr = document.getElementById('screen-home'), phone = document.getElementById('phone');
if (!scr || !phone || !window.requestAnimationFrame) return;
const reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
const isOff = () => reduce || localStorage.getItem('mb_bump') === '0';
/* ---------- 光影层:盖在首页上、在底部导航和各种弹层下面,不吃点击 ---------- */
const st = document.createElement('style');
st.textContent =
'.bump-layer{position:absolute;inset:0;z-index:3;pointer-events:none;overflow:hidden}' +
'.bump-layer canvas{position:absolute;inset:0;width:100%;height:100%}' +
/* 11-05 用户:「点击小孩踢的那个位置,弹出的那个小心,让它变成一个实心的红心」→ 空心 ♡ 字符换成实心红心 SVG(同头图踢腿红心),稍放大、停留稍久 */
'.bump-heart{position:absolute;width:26px;height:26px;margin:-13px 0 0 -13px;' +
'filter:drop-shadow(0 2px 4px rgba(220,30,60,.3));animation:bumpHeart 1.1s cubic-bezier(.2,.9,.3,1) forwards}' +
'.bump-heart svg{display:block;width:100%;height:100%}' +
'@keyframes bumpHeart{0%{opacity:0;transform:translateY(4px) scale(.4)}25%{opacity:1;transform:translateY(-6px) scale(1.2)}60%{opacity:1}100%{opacity:0;transform:translateY(-30px) scale(.9)}}';
document.head.appendChild(st);
const layer = document.createElement('div'); layer.className = 'bump-layer'; layer.setAttribute('aria-hidden', 'true');
const cv = document.createElement('canvas'); layer.appendChild(cv);
phone.appendChild(layer);
const cx = cv.getContext('2d');
let dpr = 1;
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(layer.clientWidth * dpr); cv.height = Math.round(layer.clientHeight * dpr);
}
resize(); addEventListener('resize', resize);
/* ---------- 坐标:一律用「首页内容坐标」(随内容滚动),画的时候再换成屏幕坐标 ---------- */
const scrRect = () => scr.getBoundingClientRect();
const toContent = (x, y) => { const r = scrRect(); return { x: x - r.left, y: y - r.top + scr.scrollTop }; };
/* ---------- 宝宝的各个部位:形状不同,动法不同(形状参数和 demo/bump 一致,脚趾去掉了;真实页面上停留时间加长到 3~5 秒,留够看见再伸手的时间) ---------- */
const PARTS = {
foot: { rx: 60, ry: 68, sharp: 2.5, amp: 13, dur: [3.6, 4.6] }, // 11-07 用户:「力度大一点,能看出小脚印」→ amp 9.5 → 13
fist: { rx: 44, ry: 44, sharp: 2.7, amp: 8, dur: [3, 3.8] },
knee: { rx: 48, ry: 80, sharp: 2.3, 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: 2.6, amp: 4.5, dur: [.35, .45] }, // 被摁后不服气的反踢:小、快、摁不到
// 20~25 周:宝宝还小,只有很轻的一下(flick),偶尔稍大一点的轻碰(tap)
flick: { rx: 32, ry: 34, sharp: 2.2, amp: 6.5, dur: [.8, 1.2], soft: true },
tap: { rx: 42, ry: 42, sharp: 2.3, amp: 8, 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 }
};
// 2026-10-04 用户:「整体幅度要大一点,不然看不清、感受不到」—— 所有部位尺寸统一放大 20%(下面 apply / paint 的力度也一起加了)
// 2026-10-04 用户:「踢到的液面鼓得尖一点,更明显」—— 手脚膝的 sharp 加大约 0.6~0.8(越大越往中心收、顶越尖),paint 里顶点再加一点集中高光
const SIZE = 1.2;
for (const k in PARTS) { PARTS[k].rx *= SIZE; PARTS[k].ry *= SIZE; }
const ORDER = ['foot', 'fist', 'butt', 'knee'];
/* ---------- 孕周 → 阶段 ---------- */
const STAGES = {
// drum:原地带节奏连踢的概率;chain:踢完不歇、旁边紧接着再踢的概率,最多连 chainMax 下(2026-10-04 用户:「有时会带点节奏、可以连续踢」)
// 11-05 起间隔统一 25~35 秒,11-07 改 15~25 秒(gap() 读 GAP,这里各阶段的 gap 只作记录)
/* 11-07 用户:「20 周之后都踢」→ 三个阶段都以小脚丫为主(约 2/3 是脚),20~25 周也会真踢、带脚印 */
flutter: { order: ['foot', 'tap', 'foot', 'flick', 'foot'], gap: [15, 25], kick: false, again: 0, double: .45, follow: false, drum: 0, chain: 0 },
active: { order: ['foot', 'fist', 'foot', 'knee', 'foot', 'butt'], gap: [15, 25], kick: true, again: .34, double: 0, follow: true, drum: .4, chain: .3, chainMax: 3 },
late: { order: ['foot', 'roll', 'foot', 'butt', 'foot', 'knee'], gap: [15, 25], kick: false, again: .15, double: 0, follow: true, slow: true, drum: .22, chain: .15, chainMax: 2 }
};
// 原地连踢的节奏型(每拍间隔,秒):匀速「哒哒哒」、「哒哒—哒」、「哒—哒哒」、两下一组
const RHYTHMS = [[.42, .42, .42], [.3, .3, .75, .3], [.6, .28, .28], [.32, .9, .32], [.38, .38, .38, .38]];
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%),越往后越接近本阶段的基准
/* 11-05 用户:「用户来到首页静止不动,至少停 25 秒、25~35 秒之间随机才踢一次,所有孕周都这样」→ 各阶段统一 25~35 秒,不再按阶段 / 20~22 周加长;
而且要「一直没动」:点屏幕、滑动都会把倒计时重新从 25~35 秒数起(见 restart() 调用处),回到首页、开关打开也从头数 */
/* 11-07 用户:「改成 15~25 秒一次」 */
const GAP = [15, 25];
const gap = () => GAP[0] + Math.random() * (GAP[1] - GAP[0]);
let lastInput = -1e9; /* 最近一次真正的手指 / 滚轮操作(图片加载、布局变化引起的 scroll 不算用户动了) */
const restart = () => { if (mode === 'wait') wait = gap(); };
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 };
const pk = { x: 0, y: 0, d: { v: 0, vel: 0, to: 0, k: 520, c: 6.5 }, held: false };
const jg = { x: { v: 0, vel: 0, to: 0, k: 210, c: 4.2 }, y: { v: 0, vel: 0, to: 0, k: 210, c: 4.2 } };
let mode = 'wait', wait = 3, seq = 0, lastTouch = 0, lastTop = 0, still = 1, blockUntil = 0, kickTimer = 0;
/* ---------- 被撑起来的元素 ---------- */
// 叶子级元素:图片 / 图标,或者里面只有行内内容的块(文字块、小色块)。动画中的元素跳过(行内 transform 盖不过动画)
const CLICK = 'button,a,input,textarea,select,label,[role="button"],[role="switch"],[data-app],[data-go],[data-open],[data-sheet],[data-toast]';
let atoms = [];
const saved = new Map();
function isAtom(el, cs) {
if (el.ownerSVGElement) return false;
if (cs.display === 'none' || cs.display === 'inline' || cs.display === 'contents') return false;
if (cs.animationName && cs.animationName !== 'none') return false;
if (cs.position === 'fixed' || cs.position === 'sticky') return false;
const tag = el.tagName;
if (tag === 'IMG' || tag === 'svg' || tag === 'CANVAS' || tag === 'VIDEO') return true;
for (const c of el.children) { const d = getComputedStyle(c).display; if (d !== 'inline' && d !== 'none') return false; }
if (el.textContent.trim()) return true;
return cs.backgroundImage !== 'none' || !/rgba\(.*,\s*0\)|transparent/.test(cs.backgroundColor);
}
// 把鼓包 / 窝附近 R 以内的叶子元素收进来(每次冒头、每次摁时收一遍,页面数据会变)
function gather(cxp, cyp, R) {
const r0 = scrRect(), top = scr.scrollTop;
for (const el of scr.querySelectorAll('*')) {
if (saved.has(el)) continue;
const r = el.getBoundingClientRect();
if (!r.width || !r.height) continue;
const ex = r.left - r0.left + r.width / 2, ey = r.top - r0.top + top + r.height / 2;
if (Math.abs(ex - cxp) > R + r.width / 2 || Math.abs(ey - cyp) > R + r.height / 2) continue;
if (r.width * r.height > 4 * R * R) continue;
const cs = getComputedStyle(el);
if (!isAtom(el, cs)) continue;
// 大块元素整体动会很怪,按尺寸减弱
const damp = Math.min(1, (R * 1.1) / Math.max(r.width, r.height));
const a = { el, x: ex, y: ey, damp, base: cs.transform !== 'none' ? ' ' + cs.transform : '' };
saved.set(el, { t: el.style.transform, tr: el.style.transition, wc: el.style.willChange });
el.style.transition = 'none'; // 不设 will-change:设了会按原尺寸缓存图层,放大后字发糊
atoms.push(a);
}
}
// 「皮」:鼓包正下方那张卡片(有圆角或底色的容器)整体也微微鼓起来——卡片边缘一动,鼓包才看得出来
let skin = null;
function findSkin(cxp, cyp) {
const r0 = scrRect(), el = document.elementFromPoint(r0.left + cxp, r0.top + cyp - scr.scrollTop);
for (let e = el; e && e !== scr; e = e.parentElement) {
const r = e.getBoundingClientRect();
if (r.width > scr.clientWidth * 1.02 || r.height > 560) break;
if (r.width < 90 || r.height < 60) continue;
const cs = getComputedStyle(e);
if (cs.animationName !== 'none' || cs.position === 'fixed' || cs.position === 'sticky') continue;
const solid = !/rgba\(.*,\s*0\)|transparent/.test(cs.backgroundColor) || cs.backgroundImage !== 'none' || cs.boxShadow !== 'none';
if (!solid || parseFloat(cs.borderTopLeftRadius) < 6) continue;
saved.set(e, { t: e.style.transform, tr: e.style.transition, wc: e.style.willChange, to: e.style.transformOrigin });
e.style.transition = 'none';
return { el: e, x: r.left - r0.left, y: r.top - r0.top + scr.scrollTop, w: r.width, h: r.height, base: cs.transform !== 'none' ? ' ' + cs.transform : '' };
}
return null;
}
function release() {
saved.forEach((s, el) => { el.style.transform = s.t; el.style.transition = s.tr; el.style.willChange = s.wc; if ('to' in s) el.style.transformOrigin = s.to; });
saved.clear(); atoms = []; skin = null;
}
/* ---------- 挑位置:只在当前这一屏里,躲开小按钮 ---------- */
function hitOK(px, py) { // 屏幕坐标
const el = document.elementFromPoint(px, py);
if (!el || !scr.contains(el)) return null; // 被弹层 / 导航盖住,或者出了首页
const c = el.closest(CLICK);
if (c && scr.contains(c)) { const r = c.getBoundingClientRect(); if (r.width * r.height < 14000) return null; }
return el;
}
function pickSpot(P) {
const r0 = scrRect(), vh = scr.clientHeight, vw = scr.clientWidth;
const y0 = 80, y1 = vh - 130; // 不贴顶,不压底部导航
if (y1 - y0 < 120) return null;
for (let i = 0; i < 30; i++) {
const x = 60 + Math.random() * (vw - 120), y = y0 + Math.random() * (y1 - y0);
const px = r0.left + x, py = r0.top + y;
const c = hitOK(px, py); if (!c) continue;
if (i < 20 && !c.textContent.trim() && c.tagName !== 'IMG') continue; // 前 20 次只挑压着字 / 图的地方
let ok = true;
for (let k = 0; k < 8 && ok; k++) {
const a = k / 8 * Math.PI * 2;
if (!hitOK(px + Math.cos(a) * P.rx * .55, py + Math.sin(a) * P.ry * .55)) ok = false;
}
if (!ok) continue;
const p = { x, y: y + scr.scrollTop };
if (Math.hypot(p.x - ft.x, p.y - ft.y) > 80) return p;
}
return null;
}
const active = () => !!stage && !isOff() && !document.hidden && scr.classList.contains('is-active');
const idle = () => performance.now() - lastTouch > 1500;
function appear(key, at) {
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++;
if (!pk.held && Math.abs(pk.d.v) < .05) release(); // 反踢时手指的窝还在回弹,别把那几个元素放掉
ft.part = P; ft.x = s.x; ft.y = s.y; ft.ox = ft.oy = 0;
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;
// 晚期地方小:同样的部位也顶得慢、顶得闷;早期的小鱼泡泡快起快落
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.chainN = ft.chained ? (ft.chainN || 0) + 1 : 0;
// 连续踢:第一下就定好后面还跟几下;连踢的每一下都短促(1 秒出头),不像单独那一下要停好几秒
if (ft.chained) ft.chainLeft--;
else ft.chainLeft = stage && stage.chain && !P.soft && !P.roll && P !== PARTS.kick && Math.random() < stage.chain
? 1 + (Math.random() * (stage.chainMax - 1) | 0) : 0;
const inChain = ft.chained || ft.chainLeft > 0;
if (inChain && !P.roll) ft.dur = (1.1 + Math.random() * .6) * (stage && stage.slow ? 1.3 : 1);
// 原地连踢:脚丫 / 拳头 / 膝盖才有;晚期节奏放慢 1.4 倍。拍子排在第一下顶起来之后,整段时长跟着拉长
ft.beats = null; ft.beat = 0; ft.pop = 0;
if (!inChain && stage && stage.drum && !P.roll && !P.soft && P !== PARTS.kick && Math.random() < stage.drum) {
const r = RHYTHMS[Math.random() * RHYTHMS.length | 0], m = stage.slow ? 1.4 : 1;
let t = .7; ft.beats = r.map(d => (t += d * m));
ft.dur = Math.max(ft.dur, t + .9); ft.kicked = true; // 有节奏就不再「中途再顶一下」
}
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);
mode = 'show'; run();
}
function hide(fast) {
ft.h.to = 0;
if (fast) { ft.h.k = 700; ft.h.c = 45; } else { ft.h.k = 60; ft.h.c = 14; }
mode = 'hide';
}
/* ---------- 声音:WebAudio 现场合成,不带音频文件;第一次摁的时候才建(要用户手势) ---------- */
let ac = null;
function audio() {
try {
if (!ac) {
if (navigator.audioSession) navigator.audioSession.type = 'ambient'; // 跟随静音键
const A = window.AudioContext || window.webkitAudioContext; if (!A) return null;
ac = new A();
}
if (ac.state === 'suspended') ac.resume();
} catch (e) { ac = null; }
return ac;
}
function blip(f0, f1, vol, len, delay) {
const a = audio(); if (!a) return;
const t = a.currentTime + (delay || 0), o = a.createOscillator(), g = a.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + len * .8);
g.gain.setValueAtTime(.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + .008); g.gain.exponentialRampToValueAtTime(.0001, t + len);
o.connect(g); g.connect(a.destination); o.start(t); o.stop(t + len + .02);
}
const buzz = p => { try { navigator.vibrate && navigator.vibrate(p); } catch (e) {} };
function heart(x, y) {
const r0 = scrRect(), lr = layer.getBoundingClientRect();
const h = document.createElement('i'); h.className = 'bump-heart';
h.innerHTML = '<svg viewBox="0 0 24 24"><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>';
h.style.left = (x + r0.left - lr.left) + 'px'; h.style.top = (y - scr.scrollTop + r0.top - lr.top) + 'px';
layer.appendChild(h); setTimeout(() => h.remove(), 1150);
}
/* ---------- 摁宝宝 ---------- */
function poke(x, y) {
const fx = ft.x + ft.ox, fy = ft.y + ft.oy;
buzz([12, 70, 22]);
blip(380, 120, .2, .16); blip(560, 260, .08, .12, .08);
pk.x = x; pk.y = y; pk.held = true; pk.d.to = -12; pk.d.k = 380; pk.d.c = 22;
gather(x, y, 80);
// 鼓包先被压扁、往四周摊开,再往远离手指的方向滑走、沉下去
ft.spread.to = 1.35; ft.spread.vel += 2;
let dx = fx - x, dy = fy - y, l = Math.hypot(dx, dy);
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 = gap();
setTimeout(() => heart(fx + ft.flee.dx * 18, fy + ft.flee.dy * 18 - 10), 80);
// 三次里有一次:0.6 秒后在旁边轻轻反踢一下
clearTimeout(kickTimer);
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 = gap();
}, 600);
run();
}
function letGo(soft) {
if (!pk.held) return;
pk.held = false;
if (soft) { pk.d.to = 0; pk.d.k = 300; pk.d.c = 34; return; } // 变成滚动了:窝悄悄平回去,不弹
// 松手:弹簧变软、阻尼变小,皮肉弹回来并过冲 → duang duang,周围一起颤
pk.d.to = 0; pk.d.k = 360; pk.d.c = 4.6; pk.d.vel += 150;
jg.y.vel += 120; jg.x.vel += (Math.random() - .5) * 70;
run();
}
scr.addEventListener('pointerdown', e => {
lastTouch = lastInput = performance.now(); restart();
if (isOff() || (mode !== 'show' && !(mode === 'hide' && ft.h.v > 3)) || !ft.catchable) return;
const p = toContent(e.clientX, e.clientY), fx = ft.x + ft.ox, fy = ft.y + ft.oy;
// 只认鼓包中心那一圈;点在边上照常走原来的点击
const P = ft.part, d = Math.hypot((p.x - fx) / (P.rx * .62), (p.y - fy) / (P.ry * .62));
if (d > 1) return;
if (ft.life < .3 && mode === 'show') { hide(true); return; } // 刚冒头就点 = 本来要点按钮,不拦
blockUntil = Infinity; // 这一整次手势(摁多久都算)之后的那个 click 都拦掉,松手时再收口
poke(p.x, p.y);
}, { capture: true, passive: true });
scr.addEventListener('pointerup', () => { if (blockUntil === Infinity) blockUntil = performance.now() + 400; letGo(false); }, { passive: true });
scr.addEventListener('pointercancel', () => { blockUntil = 0; letGo(true); }, { passive: true });
// 摁宝宝的那一下不算点击:捕获阶段拦掉,下面的卡片不跳转
window.addEventListener('click', e => {
if (performance.now() < blockUntil) { blockUntil = 0; e.preventDefault(); e.stopImmediatePropagation(); }
}, true);
/* 手指松开后的惯性滑动(3 秒内)也算在动;上面模块高度变化引起的滚动校正(浏览器 scroll anchoring)不算,否则停在页面中间时一直数不到 */
scr.addEventListener('scroll', () => { const t = performance.now(); if (t - lastInput < 3000) { lastTouch = t; restart(); } run(); }, { passive: true });
['touchstart', 'wheel'].forEach(t => scr.addEventListener(t, () => { lastInput = performance.now(); restart(); }, { passive: true }));
/* ---------- 物理 ---------- */
function step(dt) {
if (mode === 'wait') {
if (active() && idle()) { wait -= dt; if (wait <= 0) appear(); }
} else if (mode === 'show') {
ft.life += dt;
const u = ft.life, P = ft.part;
if (P.roll) { // 小屁股:慢慢拱着往一边挪
const k = Math.min(u / ft.dur, 1);
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 && !P.soft && stage && stage.kick) { ft.kicked = true; ft.h.vel += 70; } // 中途再顶一下
// 一拍:先往回缩一下(鼓包塌一点),0.12 秒后再顶上来,位置跟着微微挪(脚尖 / 脚跟轮流使劲)
if (ft.beats && ft.beat < ft.beats.length && u >= ft.beats[ft.beat]) {
ft.beat++; ft.h.vel -= 85; ft.pop = .12;
ft.ox += (Math.random() - .5) * 10; ft.oy += (Math.random() - .5) * 10;
}
if (ft.pop > 0 && (ft.pop -= dt) <= 0) ft.h.vel += stage && stage.slow ? 95 : 150;
}
if (u > ft.dur) {
// 连续踢:这一下收得快一点,旁边紧接着再来,不等下一个间隔
ft.chainNext = ft.chainLeft > 0;
if (ft.chainNext) { ft.h.to = 0; ft.h.k = 200; ft.h.c = 24; mode = 'hide'; } else hide(false);
}
} else if (mode === 'hide') {
if (ft.h.v < .3) {
mode = 'wait'; wait = gap();
if (ft.chainNext) {
const a = Math.random() * Math.PI * 2, d = 50 + Math.random() * 40;
near = { x: ft.x + ft.ox + Math.cos(a) * d, y: ft.y + ft.oy + Math.sin(a) * d }; wait = .2 + Math.random() * .35;
}
// 小鱼吐泡泡常常是连着的:一闪没了,旁边紧跟着又一下
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));
ft.ox = f.dx * s; ft.oy = f.dy * s;
if (ft.h.v < .3) mode = 'wait';
}
// 滑动停下 0.4 秒:宝宝不在这一屏就挪过来,1 秒多后在这一屏里慢慢顶一下
const top = scr.scrollTop;
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 && stage && stage.follow) {
if (mode === 'show' || mode === 'hide') { ft.h.v = 0; ft.h.vel = 0; mode = 'wait'; release(); wait = gap(); ft.chainNext = false; } /* 11-05 正踢着被滑走:重新等 25~35 秒 */
/* 11-05 起不再把等待缩到 1 秒多(要静止 25~35 秒才踢),只把宝宝挪到这一屏 */
}
}
spring(ft.h, dt); if (ft.h.v < 0) { ft.h.v = 0; ft.h.vel *= -.2; }
spring(ft.spread, dt);
if (mode === 'wait' && ft.spread.to !== 1 && ft.h.v < .3) { ft.spread.to = 1; ft.spread.v = 1; ft.spread.vel = 0; }
spring(pk.d, dt); spring(jg.x, dt); spring(jg.y, dt);
}
const resting = () => ft.h.v < .05 && Math.abs(ft.h.vel) < .5 && Math.abs(pk.d.v) < .05 && Math.abs(pk.d.vel) < .5 && !pk.held &&
Math.abs(jg.x.v) + Math.abs(jg.y.v) < .03 && Math.abs(jg.x.vel) + Math.abs(jg.y.vel) < .3;
/* ---------- 把物理量落到元素上 ---------- */
function tentAt(x, y) {
const P = ft.part, H = ft.h.v; if (H < .02) return 0;
let dx = x - (ft.x + ft.ox), dy = y - (ft.y + ft.oy);
const a = ft.ang + (mode === 'show' ? ft.wob : 0), c = Math.cos(a), s = Math.sin(a), sp = ft.spread.v;
const lx = (c * dx + s * dy) / (P.rx * sp), ly = (-s * dx + c * dy) / (P.ry * sp);
const r = Math.sqrt(lx * lx + ly * ly + .014), u = Math.max(0, Math.min(1, (1 - r) / (1 - .118)));
return H * 2.3 * Math.pow(u, P.sharp) / sp;
}
function apply() {
const fx = ft.x + ft.ox, fy = ft.y + ft.oy, R = Math.max(ft.part.rx, ft.part.ry) * ft.spread.v, D = pk.d.v;
if (skin) {
// 卡片以鼓包为原点轻轻放大、往上抬一点,手指按下去时反过来
const k = Math.min(ft.h.v / 9, 1.3) + Math.max(-1, Math.min(1, D / 12)) * .6;
const ox = Math.max(0, Math.min(skin.w, fx - skin.x)), oy = Math.max(0, Math.min(skin.h, fy - skin.y));
skin.el.style.transformOrigin = ox.toFixed(1) + 'px ' + oy.toFixed(1) + 'px';
skin.el.style.transform = Math.abs(k) < .005 ? skin.base.trim() :
'translateY(' + (-3.2 * k).toFixed(2) + 'px) scale(' + (1 + .045 * k).toFixed(4) + ')' + skin.base;
}
for (const a of atoms) {
let tx = 0, ty = 0, s = 1, ry = 0, rx = 0;
// 鼓包:越靠近顶越被撑开、放大,坡上的微微斜切
const th = Math.min(tentAt(a.x, a.y) / 14, 1.25) * a.damp;
if (th > .002) {
const dx = a.x - fx, dy = a.y - fy;
tx += dx * .42 * th; ty += dy * .42 * th - th * 8; s += .4 * th;
ry += 14 * Math.max(-1, Math.min(1, dx / R)) * th; rx -= 14 * Math.max(-1, Math.min(1, dy / R)) * th;
}
// 手指的窝:按下去往手指那边拽、缩小;回弹过冲时反过来
if (Math.abs(D) > .02) {
const dx = a.x - pk.x, dy = a.y - pk.y, g = Math.exp(-(dx * dx + dy * dy) / (46 * 46)) * a.damp;
tx += dx * D * .03 * g; ty += dy * D * .03 * g; s += D * .015 * g;
ry += 7 * Math.max(-1, Math.min(1, dx / 46)) * (D / 12) * g; rx -= 7 * Math.max(-1, Math.min(1, dy / 46)) * (D / 12) * g;
}
// 余波:周围一圈跟着颤
const gx = a.x - pk.x, gy = a.y - pk.y, gw = Math.exp(-(gx * gx + gy * gy) / (140 * 140));
tx -= jg.x.v * gw; ty -= jg.y.v * gw;
a.el.style.transform = (Math.abs(tx) + Math.abs(ty) + Math.abs(s - 1) * 50 + Math.abs(rx) + Math.abs(ry) < .02) ? a.base.trim() :
// 只用平面变换:带 perspective / rotateX 的 3D 变换会让文字走合成层,发糊。坡度改用轻微的斜切来暗示
'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) skew(' + (ry * .5).toFixed(2) + 'deg,' + (-rx * .3).toFixed(2) + 'deg) scale(' + s.toFixed(4) + ')' + a.base;
}
}
// 光影:鼓包 = 左上亮面 + 右下投影;窝 = 左上内阴影 + 右下反光
function blob(x, y, rx, ry, ang, stops) {
cx.save(); cx.translate(x, y); cx.rotate(ang); cx.scale(rx, ry);
for (const [ox, oy, r, col] of stops) {
const g = cx.createRadialGradient(ox, oy, 0, ox, oy, r);
g.addColorStop(0, col); g.addColorStop(1, col.replace(/[\d.]+\)$/, '0)'));
cx.fillStyle = g; cx.beginPath(); cx.arc(ox, oy, r, 0, 7); cx.fill();
}
cx.restore();
}
// 脚印:脚跟 / 足弓外沿 / 前掌 / 五个脚趾,单位坐标(脚尖朝 -y),整体缩到鼓包的 0.62
const FOOT = [[0, .42, .2], [.1, .12, .15], [-.02, -.18, .24], [-.17, -.5, .1], [-.04, -.57, .08], [.07, -.55, .07], [.16, -.5, .06], [.23, -.41, .055]];
function footprint(x, y, rx, ry, ang, k) {
/* 11-07 用户:「能看出小脚印」→ 原来是全软边的极淡阴影(0.075),实际看不出来。
改成实心中间 + 软边的压痕(暖灰,峰值约 0.38),左上错开一层白边当受光沿,顶到一小半就开始显出来 */
if (k < .15) return;
const a = Math.min(1, (k - .15) / .6), f = .72;
cx.save(); cx.translate(x, y); cx.rotate(ang); cx.scale(rx, ry);
const dot = (ox, oy, r, rgb, al) => {
const g = cx.createRadialGradient(ox, oy, 0, ox, oy, r);
g.addColorStop(0, 'rgba(' + rgb + ',' + al.toFixed(3) + ')');
g.addColorStop(.55, 'rgba(' + rgb + ',' + (al * .8).toFixed(3) + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
cx.fillStyle = g; cx.beginPath(); cx.arc(ox, oy, r, 0, 7); cx.fill();
};
for (const [px, py, r] of FOOT) dot(px * f - .025, py * f - .03, r * f * 1.25, '255,255,255', .32 * a);
for (const [px, py, r] of FOOT) dot(px * f, py * f, r * f * 1.05, '120,72,58', .38 * a);
cx.restore();
}
function paint() {
cx.setTransform(dpr, 0, 0, dpr, 0, 0);
cx.clearRect(0, 0, cv.width, cv.height);
const r0 = scrRect(), lr = layer.getBoundingClientRect(), ox = r0.left - lr.left, oy = r0.top - lr.top - scr.scrollTop;
const H = ft.h.v;
if (H > .1) {
const P = ft.part, sp = ft.spread.v, k = Math.min(H / 9, 1.3) / sp;
blob(ft.x + ft.ox + ox, ft.y + ft.oy + oy, P.rx * sp, P.ry * sp, ft.ang + (mode === 'show' ? ft.wob : 0), [
// 2026-10-04 用户:「凸起就行,不要变颜色(之前踢完泛浅红)」—— 只留中性灰的背光阴影 + 白高光,不带任何暖色
[.16, .22, 1.02, 'rgba(70,62,58,' + (.1 * k).toFixed(3) + ')'],
[.34, .42, .66, 'rgba(70,62,58,' + (.08 * k).toFixed(3) + ')'],
[-.26, -.32, .55, 'rgba(255,255,255,' + (.38 * k).toFixed(3) + ')'],
// 尖顶:顶点处一小团亮光 + 紧贴着的背光,鼓包看起来是「被顶出一个尖」而不是一片圆丘(翻身/屁股那种大片的不加)
...(P.roll ? [] : [[.07, .09, .3, 'rgba(70,62,58,' + (.09 * k).toFixed(3) + ')'],
[-.07, -.09, .2, 'rgba(255,255,255,' + (.5 * k).toFixed(3) + ')']])
]);
// 小脚丫顶上来时,隐隐约约带出一个脚印:极淡、全是软边,峰值时才看得出,凑近看像有像无
if (P === PARTS.foot || P === PARTS.kick) footprint(ft.x + ft.ox + ox, ft.y + ft.oy + oy, P.rx * sp, P.ry * sp, ft.ang + (mode === 'show' ? ft.wob : 0), Math.min(H / P.amp, 1));
}
const D = pk.d.v;
if (D < -.2) {
const k = Math.min(-D / 12, 1.2);
blob(pk.x + ox, pk.y + oy, 40, 40, 0, [
[-.16, -.2, .9, 'rgba(70,62,58,' + (.14 * k).toFixed(3) + ')'],
[.32, .36, .55, 'rgba(255,255,255,' + (.45 * k).toFixed(3) + ')']
]);
} else if (D > .2) {
const k = Math.min(D / 10, 1);
blob(pk.x + ox, pk.y + oy, 40, 40, 0, [
[.14, .2, .95, 'rgba(70,62,58,' + (.06 * k).toFixed(3) + ')'],
[-.22, -.28, .65, 'rgba(255,255,255,' + (.3 * k).toFixed(3) + ')']
]);
}
}
/* ---------- 循环:有东西在动才跑 rAF,静止时只用定时器数「下一次什么时候顶」 ---------- */
let running = false, last = 0, painted = false;
function frame(now) {
const wall = Math.min((now - last) / 1000, 1), dt = Math.min(wall, 1 / 20); last = now;
/* 物理按 dt 封顶防跳帧;等待倒计时按真实时间补齐(掉帧 / 省电模式下 rAF 慢,按封顶的 dt 数会数成一倍长) */
if (mode === 'wait' && active() && idle() && wall > dt) wait -= wall - dt;
if (!active()) {
if (mode !== 'wait') mode = 'wait';
wait = gap(); /* 不在首页时一直重置:回到首页后从头数 25~35 秒 */
ft.h.v = ft.h.vel = 0; pk.d.v = pk.d.vel = 0; pk.held = false; jg.x.v = jg.y.v = jg.x.vel = jg.y.vel = 0;
}
const n = Math.max(1, Math.round(dt * 120));
for (let i = 0; i < n; i++) step(dt / n);
if (mode === 'wait' && resting()) {
if (atoms.length) release();
if (painted) { cx.clearRect(0, 0, cv.width, cv.height); painted = false; }
running = false; tick(); return;
}
apply(); paint(); painted = true;
requestAnimationFrame(frame);
}
function run() { if (running || isOff()) return; running = true; last = performance.now(); requestAnimationFrame(frame); }
// 等下一次冒头:粗粒度定时器,到点再交给 rAF
let tickT = 0;
function tick() {
clearTimeout(tickT);
const t0 = performance.now();
tickT = setTimeout(() => {
if (running) return;
const el = Math.min((performance.now() - t0) / 1000, 1); /* 按真实经过的时间减(定时器会拖慢,按 .25 数会越数越长) */
if (!active()) { if (mode === 'wait') wait = gap(); } /* 11-05 不在首页 / 切后台:回来从头数 25~35 秒 */
else if (idle() && mode === 'wait') { wait -= el; if (wait <= 0) { appear(); if (running) return; } }
tick();
}, 250);
}
// 开关切换(「隐私权限」里那一行)
addEventListener('mb-bump', () => {
if (isOff()) { mode = 'wait'; ft.h.v = pk.d.v = 0; release(); cx.clearRect(0, 0, cv.width, cv.height); }
else { wait = gap(); 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, w: () => [Math.round(wait * 10) / 10, Math.round(performance.now() - lastTouch), active(), running], now: () => { wait = 0; lastTouch = 0; }, week: () => week, at: (k, x, y) => { lastTouch = 0; appear(k, { x, y: y + scr.scrollTop }); } };
wait = gap(); tick();
})();