557 lines
36 KiB
JavaScript
557 lines
36 KiB
JavaScript
/* ================= 首页彩蛋:宝宝在肚皮底下动(2026-10-03,预览) =================
|
||
整个首页当成妈妈的肚皮:宝宝时不时用小脚丫 / 小拳头 / 小膝盖 / 小屁股从底下往外顶,
|
||
把顶到的那片内容撑成一个软软的小帐篷;滑到哪个模块,宝宝就跟到哪一屏。
|
||
原型见 demo/bump(那边是把整页截图当纹理扭,这里是真实页面,做法不同):
|
||
· 鼓包 = 附近真实元素(叶子级的文字块、图标、图片)按离鼓包中心的远近往外撑开 + 放大 + 往外倾斜,
|
||
再在上面盖一层只画光影的 canvas(亮面 + 投影)。元素还是原来的元素,照样能点。
|
||
· 只动 transform,不碰布局;动完把元素的行内 transform / transition 原样还回去。
|
||
|
||
和点击的关系(产品口径 2026-10-03):
|
||
1. 宝宝尽量不顶按钮:挑位置时,鼓包范围里只要压到小按钮 / 链接 / 输入框就换地方;
|
||
整张能点的大卡片(工具格、知识卡)允许顶。
|
||
2. 真顶到了能点的地方:第一下算「摁宝宝」—— 这次 click 在捕获阶段被拦掉,不跳转;
|
||
宝宝缩回去后再点就正常跳。只拦鼓包中心那一圈,点在边上照常跳。
|
||
3. 宝宝冒头不到 0.3 秒就点下去 = 用户本来就是要点按钮,碰巧冒出来:不拦,照常跳,宝宝顺便缩回去。
|
||
4. 用户在滑 / 在点的时候宝宝不出来:页面静止一会儿才顶;弹层、详情页盖住时不出来。
|
||
|
||
摁中时的反馈(一次约 1.2 秒):
|
||
0ms 轻震(安卓)+ 一声很轻的「噗」+ 按出一个窝,鼓包被压扁、往四周摊开
|
||
80ms 稍重一震 + 更轻的「啵」,鼓包往远离手指的方向滑开、沉下去,冒一颗实心小红心(11-05 起,原来是空心 ♡)
|
||
松手 窝 duang duang 回弹,周围的内容跟着颤几下
|
||
600ms 三次里有一次:旁边轻轻反踢一下(宝宝不服气)
|
||
⚠️ iOS 的网页没有震动接口(微信 JS-SDK 也没有),苹果用户只有画面 + 声音。
|
||
按孕周分阶段,尽量贴近真实胎动(2026-10-04 用户:「尽量模拟真实的」):
|
||
· 20 周前 / 没填预产期 / 已产后:不出现(这时候肚皮外面看不到胎动;没孕周就不猜)
|
||
· 20~25 周「小鱼吐泡泡」:小、浅、一闪就没,常常连着两下;隔 10~25 秒才一次,周数越小越稀;滑过来也不追
|
||
· 26~33 周「拳打脚踢」:完整版——小脚丫 / 小拳头 / 膝盖 / 小屁股,中途还会再顶一下(11-05 用户:间隔改为 14~25 秒,原 4~8 秒)
|
||
· 34 周起「地方小了」:很少猛踢,多是一大片慢慢拱过去(翻身、屁股),节奏放慢(11-05 用户:间隔改为 14~25 秒,原 7~12 秒)
|
||
11-07 用户:「改成 15~25 秒一次,20 周之后都踢,力度大一点,能看出小脚印」→ 间隔统一 15~25 秒;三个阶段都以小脚丫为主(约 2/3),脚 amp 9.5→13,脚印加深放大。
|
||
孕周来自 app.js 的 mb-preg 事件(syncPregnancy 跑完后广播)。调试:URL 带 bumpweek=24 可强制按 24 周演示。
|
||
· 节奏(26 周起):有时原地「哒哒哒」带节奏连踢几拍;有时踢完不歇,旁边紧接着再踢 1~2 下
|
||
开关:「我的 → 隐私权限 → 宝宝在肚皮下动」,存 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%}' +
|
||
/* 11-05 用户:「点击小孩踢的那个位置,弹出的那个小心,让它变成一个实心的红心」→ 空心 ♡ 字符换成实心红心 SVG(同头图踢腿红心),稍放大、停留稍久 */
|
||
'.bump-heart{position:absolute;width:26px;height:26px;margin:-13px 0 0 -13px;' +
|
||
'filter:drop-shadow(0 2px 4px rgba(220,30,60,.3));animation:bumpHeart 1.1s cubic-bezier(.2,.9,.3,1) forwards}' +
|
||
'.bump-heart svg{display:block;width:100%;height:100%}' +
|
||
'@keyframes bumpHeart{0%{opacity:0;transform:translateY(4px) scale(.4)}25%{opacity:1;transform:translateY(-6px) scale(1.2)}60%{opacity:1}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: 2.5, amp: 13, dur: [3.6, 4.6] }, // 11-07 用户:「力度大一点,能看出小脚印」→ amp 9.5 → 13
|
||
fist: { rx: 44, ry: 44, sharp: 2.7, amp: 8, dur: [3, 3.8] },
|
||
knee: { rx: 48, ry: 80, sharp: 2.3, 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: 2.6, amp: 4.5, dur: [.35, .45] }, // 被摁后不服气的反踢:小、快、摁不到
|
||
// 20~25 周:宝宝还小,只有很轻的一下(flick),偶尔稍大一点的轻碰(tap)
|
||
flick: { rx: 32, ry: 34, sharp: 2.2, amp: 6.5, dur: [.8, 1.2], soft: true },
|
||
tap: { rx: 42, ry: 42, sharp: 2.3, 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 的力度也一起加了)
|
||
// 2026-10-04 用户:「踢到的液面鼓得尖一点,更明显」—— 手脚膝的 sharp 加大约 0.6~0.8(越大越往中心收、顶越尖),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 = {
|
||
// drum:原地带节奏连踢的概率;chain:踢完不歇、旁边紧接着再踢的概率,最多连 chainMax 下(2026-10-04 用户:「有时会带点节奏、可以连续踢」)
|
||
// 11-05 起间隔统一 25~35 秒,11-07 改 15~25 秒(gap() 读 GAP,这里各阶段的 gap 只作记录)
|
||
/* 11-07 用户:「20 周之后都踢」→ 三个阶段都以小脚丫为主(约 2/3 是脚),20~25 周也会真踢、带脚印 */
|
||
flutter: { order: ['foot', 'tap', 'foot', 'flick', 'foot'], gap: [15, 25], kick: false, again: 0, double: .45, follow: false, drum: 0, chain: 0 },
|
||
active: { order: ['foot', 'fist', 'foot', 'knee', 'foot', 'butt'], gap: [15, 25], kick: true, again: .34, double: 0, follow: true, drum: .4, chain: .3, chainMax: 3 },
|
||
late: { order: ['foot', 'roll', 'foot', 'butt', 'foot', 'knee'], gap: [15, 25], kick: false, again: .15, double: 0, follow: true, slow: true, drum: .22, chain: .15, chainMax: 2 }
|
||
};
|
||
// 原地连踢的节奏型(每拍间隔,秒):匀速「哒哒哒」、「哒哒—哒」、「哒—哒哒」、两下一组
|
||
const RHYTHMS = [[.42, .42, .42], [.3, .3, .75, .3], [.6, .28, .28], [.32, .9, .32], [.38, .38, .38, .38]];
|
||
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%),越往后越接近本阶段的基准
|
||
/* 11-05 用户:「用户来到首页静止不动,至少停 25 秒、25~35 秒之间随机才踢一次,所有孕周都这样」→ 各阶段统一 25~35 秒,不再按阶段 / 20~22 周加长;
|
||
而且要「一直没动」:点屏幕、滑动都会把倒计时重新从 25~35 秒数起(见 restart() 调用处),回到首页、开关打开也从头数 */
|
||
/* 11-07 用户:「改成 15~25 秒一次」 */
|
||
const GAP = [15, 25];
|
||
const gap = () => GAP[0] + Math.random() * (GAP[1] - GAP[0]);
|
||
let lastInput = -1e9; /* 最近一次真正的手指 / 滚轮操作(图片加载、布局变化引起的 scroll 不算用户动了) */
|
||
const restart = () => { if (mode === 'wait') wait = gap(); };
|
||
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.chainN = ft.chained ? (ft.chainN || 0) + 1 : 0;
|
||
// 连续踢:第一下就定好后面还跟几下;连踢的每一下都短促(1 秒出头),不像单独那一下要停好几秒
|
||
if (ft.chained) ft.chainLeft--;
|
||
else ft.chainLeft = stage && stage.chain && !P.soft && !P.roll && P !== PARTS.kick && Math.random() < stage.chain
|
||
? 1 + (Math.random() * (stage.chainMax - 1) | 0) : 0;
|
||
const inChain = ft.chained || ft.chainLeft > 0;
|
||
if (inChain && !P.roll) ft.dur = (1.1 + Math.random() * .6) * (stage && stage.slow ? 1.3 : 1);
|
||
// 原地连踢:脚丫 / 拳头 / 膝盖才有;晚期节奏放慢 1.4 倍。拍子排在第一下顶起来之后,整段时长跟着拉长
|
||
ft.beats = null; ft.beat = 0; ft.pop = 0;
|
||
if (!inChain && stage && stage.drum && !P.roll && !P.soft && P !== PARTS.kick && Math.random() < stage.drum) {
|
||
const r = RHYTHMS[Math.random() * RHYTHMS.length | 0], m = stage.slow ? 1.4 : 1;
|
||
let t = .7; ft.beats = r.map(d => (t += d * m));
|
||
ft.dur = Math.max(ft.dur, t + .9); ft.kicked = true; // 有节奏就不再「中途再顶一下」
|
||
}
|
||
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.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 21.2l-1.4-1.3C5.4 15.3 2 12.2 2 8.4 2 5.3 4.4 3 7.4 3c1.8 0 3.5.8 4.6 2.2C13.1 3.8 14.8 3 16.6 3 19.6 3 22 5.3 22 8.4c0 3.8-3.4 6.9-8.6 11.5z" fill="#F0284A"/></svg>';
|
||
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(), 1150);
|
||
}
|
||
|
||
/* ---------- 摁宝宝 ---------- */
|
||
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 = lastInput = performance.now(); restart();
|
||
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);
|
||
/* 手指松开后的惯性滑动(3 秒内)也算在动;上面模块高度变化引起的滚动校正(浏览器 scroll anchoring)不算,否则停在页面中间时一直数不到 */
|
||
scr.addEventListener('scroll', () => { const t = performance.now(); if (t - lastInput < 3000) { lastTouch = t; restart(); } run(); }, { passive: true });
|
||
['touchstart', 'wheel'].forEach(t => scr.addEventListener(t, () => { lastInput = performance.now(); restart(); }, { 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; } // 中途再顶一下
|
||
// 一拍:先往回缩一下(鼓包塌一点),0.12 秒后再顶上来,位置跟着微微挪(脚尖 / 脚跟轮流使劲)
|
||
if (ft.beats && ft.beat < ft.beats.length && u >= ft.beats[ft.beat]) {
|
||
ft.beat++; ft.h.vel -= 85; ft.pop = .12;
|
||
ft.ox += (Math.random() - .5) * 10; ft.oy += (Math.random() - .5) * 10;
|
||
}
|
||
if (ft.pop > 0 && (ft.pop -= dt) <= 0) ft.h.vel += stage && stage.slow ? 95 : 150;
|
||
}
|
||
if (u > ft.dur) {
|
||
// 连续踢:这一下收得快一点,旁边紧接着再来,不等下一个间隔
|
||
ft.chainNext = ft.chainLeft > 0;
|
||
if (ft.chainNext) { ft.h.to = 0; ft.h.k = 200; ft.h.c = 24; mode = 'hide'; } else hide(false);
|
||
}
|
||
} else if (mode === 'hide') {
|
||
if (ft.h.v < .3) {
|
||
mode = 'wait'; wait = gap();
|
||
if (ft.chainNext) {
|
||
const a = Math.random() * Math.PI * 2, d = 50 + Math.random() * 40;
|
||
near = { x: ft.x + ft.ox + Math.cos(a) * d, y: ft.y + ft.oy + Math.sin(a) * d }; wait = .2 + Math.random() * .35;
|
||
}
|
||
// 小鱼吐泡泡常常是连着的:一闪没了,旁边紧跟着又一下
|
||
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(); wait = gap(); ft.chainNext = false; } /* 11-05 正踢着被滑走:重新等 25~35 秒 */
|
||
/* 11-05 起不再把等待缩到 1 秒多(要静止 25~35 秒才踢),只把宝宝挪到这一屏 */
|
||
}
|
||
}
|
||
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();
|
||
}
|
||
// 脚印:脚跟 / 足弓外沿 / 前掌 / 五个脚趾,单位坐标(脚尖朝 -y),整体缩到鼓包的 0.62
|
||
const FOOT = [[0, .42, .2], [.1, .12, .15], [-.02, -.18, .24], [-.17, -.5, .1], [-.04, -.57, .08], [.07, -.55, .07], [.16, -.5, .06], [.23, -.41, .055]];
|
||
function footprint(x, y, rx, ry, ang, k) {
|
||
/* 11-07 用户:「能看出小脚印」→ 原来是全软边的极淡阴影(0.075),实际看不出来。
|
||
改成实心中间 + 软边的压痕(暖灰,峰值约 0.38),左上错开一层白边当受光沿,顶到一小半就开始显出来 */
|
||
if (k < .15) return;
|
||
const a = Math.min(1, (k - .15) / .6), f = .72;
|
||
cx.save(); cx.translate(x, y); cx.rotate(ang); cx.scale(rx, ry);
|
||
const dot = (ox, oy, r, rgb, al) => {
|
||
const g = cx.createRadialGradient(ox, oy, 0, ox, oy, r);
|
||
g.addColorStop(0, 'rgba(' + rgb + ',' + al.toFixed(3) + ')');
|
||
g.addColorStop(.55, 'rgba(' + rgb + ',' + (al * .8).toFixed(3) + ')');
|
||
g.addColorStop(1, 'rgba(' + rgb + ',0)');
|
||
cx.fillStyle = g; cx.beginPath(); cx.arc(ox, oy, r, 0, 7); cx.fill();
|
||
};
|
||
for (const [px, py, r] of FOOT) dot(px * f - .025, py * f - .03, r * f * 1.25, '255,255,255', .32 * a);
|
||
for (const [px, py, r] of FOOT) dot(px * f, py * f, r * f * 1.05, '120,72,58', .38 * a);
|
||
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), [
|
||
// 2026-10-04 用户:「凸起就行,不要变颜色(之前踢完泛浅红)」—— 只留中性灰的背光阴影 + 白高光,不带任何暖色
|
||
[.16, .22, 1.02, 'rgba(70,62,58,' + (.1 * k).toFixed(3) + ')'],
|
||
[.34, .42, .66, 'rgba(70,62,58,' + (.08 * k).toFixed(3) + ')'],
|
||
[-.26, -.32, .55, 'rgba(255,255,255,' + (.38 * k).toFixed(3) + ')'],
|
||
// 尖顶:顶点处一小团亮光 + 紧贴着的背光,鼓包看起来是「被顶出一个尖」而不是一片圆丘(翻身/屁股那种大片的不加)
|
||
...(P.roll ? [] : [[.07, .09, .3, 'rgba(70,62,58,' + (.09 * k).toFixed(3) + ')'],
|
||
[-.07, -.09, .2, 'rgba(255,255,255,' + (.5 * k).toFixed(3) + ')']])
|
||
]);
|
||
// 小脚丫顶上来时,隐隐约约带出一个脚印:极淡、全是软边,峰值时才看得出,凑近看像有像无
|
||
if (P === PARTS.foot || P === PARTS.kick) footprint(ft.x + ft.ox + ox, ft.y + ft.oy + oy, P.rx * sp, P.ry * sp, ft.ang + (mode === 'show' ? ft.wob : 0), Math.min(H / P.amp, 1));
|
||
}
|
||
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(70,62,58,' + (.14 * k).toFixed(3) + ')'],
|
||
[.32, .36, .55, 'rgba(255,255,255,' + (.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(70,62,58,' + (.06 * k).toFixed(3) + ')'],
|
||
[-.22, -.28, .65, 'rgba(255,255,255,' + (.3 * k).toFixed(3) + ')']
|
||
]);
|
||
}
|
||
}
|
||
|
||
/* ---------- 循环:有东西在动才跑 rAF,静止时只用定时器数「下一次什么时候顶」 ---------- */
|
||
let running = false, last = 0, painted = false;
|
||
function frame(now) {
|
||
const wall = Math.min((now - last) / 1000, 1), dt = Math.min(wall, 1 / 20); last = now;
|
||
/* 物理按 dt 封顶防跳帧;等待倒计时按真实时间补齐(掉帧 / 省电模式下 rAF 慢,按封顶的 dt 数会数成一倍长) */
|
||
if (mode === 'wait' && active() && idle() && wall > dt) wait -= wall - dt;
|
||
if (!active()) {
|
||
if (mode !== 'wait') mode = 'wait';
|
||
wait = gap(); /* 不在首页时一直重置:回到首页后从头数 25~35 秒 */
|
||
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);
|
||
const t0 = performance.now();
|
||
tickT = setTimeout(() => {
|
||
if (running) return;
|
||
const el = Math.min((performance.now() - t0) / 1000, 1); /* 按真实经过的时间减(定时器会拖慢,按 .25 数会越数越长) */
|
||
if (!active()) { if (mode === 'wait') wait = gap(); } /* 11-05 不在首页 / 切后台:回来从头数 25~35 秒 */
|
||
else if (idle() && mode === 'wait') { wait -= el; 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 = gap(); 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, w: () => [Math.round(wait * 10) / 10, Math.round(performance.now() - lastTouch), active(), running], now: () => { wait = 0; lastTouch = 0; }, week: () => week, at: (k, x, y) => { lastTouch = 0; appear(k, { x, y: y + scr.scrollTop }); } };
|
||
wait = gap(); tick();
|
||
})();
|