feat(首页彩蛋): 宝宝在肚皮底下动——整页随机顶出小帐篷,摁中有震动/声音/压扁滑走/回弹余波反馈 (v26100301)

- 新增 js/bump.js(独立文件,删 index.html 里一行即下线)
- 第一下摁鼓包不触发卡片跳转;宝宝冒头<0.3s内点击照常跳转;不顶小按钮
- 「隐私权限」新增开关「宝宝在肚皮下动」(localStorage mb_bump)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 23:48:55 +00:00
parent ec7b6dbe7f
commit e857826184
3 changed files with 445 additions and 4 deletions

432
js/bump.js Normal file
View File

@@ -0,0 +1,432 @@
/* ================= 首页彩蛋:宝宝在肚皮底下动(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();
})();