Files
mama-box/js/bump.js
Karlcarlo e857826184 feat(首页彩蛋): 宝宝在肚皮底下动——整页随机顶出小帐篷,摁中有震动/声音/压扁滑走/回弹余波反馈 (v26100301)
- 新增 js/bump.js(独立文件,删 index.html 里一行即下线)
- 第一下摁鼓包不触发卡片跳转;宝宝冒头<0.3s内点击照常跳转;不顶小按钮
- 「隐私权限」新增开关「宝宝在肚皮下动」(localStorage mb_bump)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:48:55 +00:00

433 lines
24 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 稍重一震 + 更轻的「啵」,鼓包往远离手指的方向滑开、沉下去,冒一颗小 ♡
松手 窝 duang duang 回弹,周围的内容跟着颤几下
600ms 三次里有一次:旁边轻轻反踢一下(宝宝不服气)
⚠️ iOS 的网页没有震动接口(微信 JS-SDK 也没有),苹果用户只有画面 + 声音。
开关:「我的 → 隐私权限 → 宝宝在肚皮下动」,存 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%}' +
'.bump-heart{position:absolute;margin:-10px 0 0 -8px;font-size:16px;line-height:20px;color:#F4593C;font-weight:700;' +
'text-shadow:0 1px 3px rgba(255,255,255,.9);animation:bumpHeart .9s cubic-bezier(.2,.9,.3,1) forwards}' +
'@keyframes bumpHeart{0%{opacity:0;transform:translateY(4px) scale(.4)}25%{opacity:1;transform:translateY(-6px) scale(1.15)}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: 1.7, amp: 9.5, dur: [3.6, 4.6] },
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] } // 被摁后不服气的反踢:小、快、摁不到
};
const ORDER = ['foot', 'fist', 'butt', 'knee'];
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 = () => !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 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;
ft.h.to = P.amp; ft.h.k = P.roll ? 70 : 140; ft.h.c = 9; ft.h.vel += P.roll ? 30 : 120;
ft.life = 0; ft.dur = P.dur[0] + Math.random() * (P.dur[1] - P.dur[0]); ft.kicked = 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);
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.textContent = '♡';
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(), 950);
}
/* ---------- 摁宝宝 ---------- */
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 = 4 + Math.random() * 3;
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 (!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;
}, 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 = performance.now();
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);
scr.addEventListener('scroll', () => { lastTouch = performance.now(); run(); }, { 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 * 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) hide(false);
} else if (mode === 'hide') {
if (ft.h.v < .3) { mode = 'wait'; wait = 4 + Math.random() * 4; }
} 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) {
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);
}
}
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(' + (-1.5 * k).toFixed(2) + 'px) scale(' + (1 + .022 * 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) / 16, 1.2) * a.damp;
if (th > .002) {
const dx = a.x - fx, dy = a.y - fy;
tx += dx * .3 * th; ty += dy * .3 * th - th * 4; s += .26 * th;
ry += 10 * Math.max(-1, Math.min(1, dx / R)) * th; rx -= 10 * 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 * .022 * g; ty += dy * D * .022 * g; s += D * .011 * 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();
}
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), [
[.14, .2, 1.05, 'rgba(160,64,40,' + (.16 * k).toFixed(3) + ')'],
[.34, .42, .7, 'rgba(150,55,32,' + (.16 * k).toFixed(3) + ')'],
[-.06, -.08, .62, 'rgba(255,196,170,' + (.16 * k).toFixed(3) + ')'],
[-.26, -.32, .5, 'rgba(255,250,244,' + (.24 * k).toFixed(3) + ')']
]);
}
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(140,55,35,' + (.24 * k).toFixed(3) + ')'],
[.32, .36, .55, 'rgba(255,250,244,' + (.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(150,62,38,' + (.08 * k).toFixed(3) + ')'],
[-.22, -.28, .65, 'rgba(255,250,244,' + (.3 * k).toFixed(3) + ')']
]);
}
}
/* ---------- 循环:有东西在动才跑 rAF,静止时只用定时器数「下一次什么时候顶」 ---------- */
let running = false, last = 0, painted = false;
function frame(now) {
const dt = Math.min((now - last) / 1000, 1 / 20); last = now;
if (!active()) {
if (mode !== 'wait') { mode = 'wait'; wait = 2; }
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);
tickT = setTimeout(() => {
if (running) return;
if (active() && idle() && mode === 'wait') { wait -= .25; 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 = 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 }); } };
wait = 3; tick();
})();