From e85782618437b94f6d4b098add6b0f54937e4b27 Mon Sep 17 00:00:00 2001 From: Karlcarlo Date: Sat, 3 Oct 2026 23:48:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(=E9=A6=96=E9=A1=B5=E5=BD=A9=E8=9B=8B):=20?= =?UTF-8?q?=E5=AE=9D=E5=AE=9D=E5=9C=A8=E8=82=9A=E7=9A=AE=E5=BA=95=E4=B8=8B?= =?UTF-8?q?=E5=8A=A8=E2=80=94=E2=80=94=E6=95=B4=E9=A1=B5=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E9=A1=B6=E5=87=BA=E5=B0=8F=E5=B8=90=E7=AF=B7=EF=BC=8C=E6=91=81?= =?UTF-8?q?=E4=B8=AD=E6=9C=89=E9=9C=87=E5=8A=A8/=E5=A3=B0=E9=9F=B3/?= =?UTF-8?q?=E5=8E=8B=E6=89=81=E6=BB=91=E8=B5=B0/=E5=9B=9E=E5=BC=B9?= =?UTF-8?q?=E4=BD=99=E6=B3=A2=E5=8F=8D=E9=A6=88=20(v26100301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 js/bump.js(独立文件,删 index.html 里一行即下线) - 第一下摁鼓包不触发卡片跳转;宝宝冒头<0.3s内点击照常跳转;不顶小按钮 - 「隐私权限」新增开关「宝宝在肚皮下动」(localStorage mb_bump) Co-Authored-By: Claude Opus 5.5 --- index.html | 4 +- js/app.js | 13 +- js/bump.js | 432 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 js/bump.js diff --git a/index.html b/index.html index 07e27a4..c95e771 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ 兜爸心选 · 孕育陪伴 - + @@ -1967,6 +1967,8 @@ 不影响应用其余功能。 --> + + diff --git a/js/app.js b/js/app.js index 25c278f..e0f0344 100644 --- a/js/app.js +++ b/js/app.js @@ -3,7 +3,7 @@ (function () { 'use strict'; /* 页面版本号(YYMMDD+序号):显示在「关于兜爸心选」页底部;bump 时同步 head 注释与 ?v= */ - var VER = '26100233'; + var VER = '26100301'; const $ = (s, c) => (c || document).querySelector(s); const $$ = (s, c) => Array.from((c || document).querySelectorAll(s)); @@ -1569,6 +1569,10 @@ ['sw', '档案仅自己可见', '关掉后可生成分享链接', 1], ['sw', '允许家人查看', '爸爸可看孕周与产检日程', 1] ] }, + /* 2026-10-03 首页彩蛋开关(js/bump.js):第 5 项是 localStorage 键,开关状态真的存下来,不是摆设 */ + { t: '首页小彩蛋', rows: [ + ['sw', '宝宝在肚皮下动', '首页偶尔被宝宝顶起来,摁一下会躲开', 1, 'mb_bump'] + ] }, { t: '', rows: [ ['txt', '我们不会把你的孕期数据用于广告投放,也不会卖给第三方。数据加密存储,你可以随时导出或删除。'] ] } @@ -1634,9 +1638,11 @@ '' + r[1] + '' + (r[2] ? '' + r[2] + '' : '') + '›'; - /* 开关行:aria-checked 让读屏也能拿到状态,样式只认 .on */ + /* 开关行:aria-checked 让读屏也能拿到状态,样式只认 .on。 + 带第 5 项(localStorage 键)的是真开关:状态从本地读,'0' = 关 */ + if (r[4]) r = [r[0], r[1], r[2], localStorage.getItem(r[4]) === '0' ? 0 : 1, r[4]]; return ''; }).join(''); @@ -1660,6 +1666,7 @@ const on = !b.classList.contains('on'); b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); + if (b.dataset.key) { localStorage.setItem(b.dataset.key, on ? '1' : '0'); window.dispatchEvent(new Event('mb-bump')); } haptic(); toast(b.dataset.sw + (on ? ' 已开启' : ' 已关闭')); }); diff --git a/js/bump.js b/js/bump.js new file mode 100644 index 0000000..743463d --- /dev/null +++ b/js/bump.js @@ -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(); +})();