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