Files
mama-box/js/bump.js
Karlcarlo 3043e37fb5 style(首页彩蛋): 胎动幅度整体加大 (v26100404)
部位尺寸 +20%;元素撑开/放大/上抬/斜切约 1.5~2 倍;卡片鼓起约 2 倍;光影加深;早期小鱼泡泡振幅加大。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:49:19 +00:00

480 lines
28 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 也没有),苹果用户只有画面 + 声音。
按孕周分阶段,尽量贴近真实胎动(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';
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] }, // 被摁后不服气的反踢:小、快、摁不到
// 20~25 周:宝宝还小,只有很轻的一下(flick),偶尔稍大一点的轻碰(tap)
flick: { rx: 32, ry: 34, sharp: 1.7, amp: 6.5, dur: [.8, 1.2], soft: true },
tap: { rx: 42, ry: 42, sharp: 1.8, 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 的力度也一起加了)
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 = {
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 };
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.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 = 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 = 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 * (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; } // 中途再顶一下
}
if (u > ft.dur) hide(false);
} else if (mode === 'hide') {
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));
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(); }
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(' + (-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();
}
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,' + (.24 * k).toFixed(3) + ')'],
[.34, .42, .7, 'rgba(150,55,32,' + (.24 * k).toFixed(3) + ')'],
[-.06, -.08, .62, 'rgba(255,196,170,' + (.2 * k).toFixed(3) + ')'],
[-.26, -.32, .5, 'rgba(255,250,244,' + (.32 * 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; }, week: () => week, at: (k, x, y) => { lastTouch = 0; appear(k, { x, y: y + scr.scrollTop }); } };
wait = 3; tick();
})();