Files
mama-box/demo/bump/index.html

397 lines
24 KiB
HTML
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>宝宝在肚子里动 · 创意预览</title>
<!-- 2026-10-03 创意预览(不进正式首页):「整块头图是妈妈的肚皮」v3。
头图纹理改为 JS 现场合成(canvas):肚皮底 belly_bg.jpg + LOGO + 倒计时 + 周数 + 进度条 + 右侧「这周的宝宝」玻璃卡。
右侧原来是胎儿插画,现在放「这周宝宝学会了 · 睁眼看光」+ 宝宝的一句悄悄话,顶出来/被摁回去时换话;
卡片也画在纹理里,所以被顶到时会和字一起鼓起来变形。
WebGL 把纹理当一层肉肉的肚皮:
· 宝宝时不时用不同部位往外顶:小脚丫(尖一点的小帐篷,顶上隐约几个脚趾)、小拳头(小而圆)、
小膝盖(长条)、小屁股(又大又缓,还会慢慢拱着挪一段);常常专挑字和卡片顶;
· 手指戳上去 = 肚皮被按下去一个窝,松手后 duang duang 回弹(欠阻尼弹簧)+ 周围一起晃;
· 戳中鼓起处 → 被摁回去,往远离手指的方向溜走,过一两秒换个部位、换个地方再顶出来;
· 头图下方「找找宝宝」收集条:戳回过哪个部位就点亮哪个。
v4:宝宝活在整个页面底下——头图 + 下面所有模块(首页截图)合成一张整页纹理,WebGL 画布固定铺满屏幕,
按滚动位置取当前这一屏来画;滑到别的模块停下来,宝宝会在这一屏里慢慢顶一下,把卡片撑成小帐篷。
?rec=1:录屏模式,由 __adv / __tap / __press 逐帧驱动(生成动图用)。 -->
<style>
html,body{margin:0;background:#F8F3EF;-webkit-tap-highlight-color:transparent;font-family:-apple-system,"PingFang SC","Noto Sans SC",sans-serif}
.page{position:relative;max-width:480px;margin:0 auto;overflow:hidden}
.page img{display:block;width:100%} /* 真实排版用的静态图:撑出页面高度,WebGL 起不来时直接看它 */
/* 整页的「肚皮」:固定铺满一屏,按滚动位置画当前这一屏 */
#cv{position:fixed;top:0;left:50%;width:min(100vw,480px);height:100%;transform:translateX(-50%);pointer-events:none;z-index:2;opacity:0}
#cv.on{opacity:1}
/* 头图下缘的「找找宝宝」收集条:和首页「记一下」同一套白色玻璃胶囊语言 */
.hunt{position:absolute;left:0;right:0;top:0;display:flex;justify-content:center;pointer-events:none;z-index:3}
.hunt .bar{margin-top:calc(min(100vw,480px) * 216 / 375);display:flex;align-items:center;gap:6px;padding:6px 7px 6px 13px;border-radius:999px;
background:rgba(255,255,255,.84);-webkit-backdrop-filter:blur(12px) saturate(160%);backdrop-filter:blur(12px) saturate(160%);
box-shadow:0 8px 20px rgba(180,80,50,.16),inset 0 0 0 1px rgba(255,255,255,.95);white-space:nowrap;
transition:transform .35s cubic-bezier(.3,1.6,.5,1)}
.hunt .bar.pop{transform:scale(1.06)}
.hunt .t{font-size:12px;font-weight:700;color:#C2563B;letter-spacing:.3px;margin-right:2px}
.hunt .c{display:flex;align-items:center;gap:3px;padding:4px 8px;border-radius:999px;font-size:11.5px;font-weight:600;
color:#D99A86;background:#FBEDE6;transition:all .3s}
.hunt .c i{width:6px;height:6px;border-radius:50%;background:#EBC3B4;transition:background .3s}
.hunt .c.on{color:#fff;background:linear-gradient(135deg,#FF9A72,#F06A45);box-shadow:0 3px 8px rgba(240,106,69,.35)}
.hunt .c.on i{background:#FFF3C4}
/* 录屏用的手指 */
.finger{position:absolute;width:38px;height:38px;margin:-19px 0 0 -19px;border-radius:50%;pointer-events:none;z-index:5;
background:radial-gradient(circle,rgba(255,255,255,.85) 0 30%,rgba(255,255,255,.35) 55%,rgba(255,255,255,0) 72%);
box-shadow:0 0 0 2px rgba(255,255,255,.7);opacity:0;transform:scale(1.25);transition:opacity .15s,transform .15s}
.finger.on{opacity:1;transform:scale(1)}
</style>
</head>
<body>
<div class="page">
<img src="belly.jpg" alt="">
<img src="below.jpg" alt="">
<i class="finger" id="finger"></i>
<div class="hunt"><div class="bar" id="hunt"><span class="t">找找宝宝</span>
<span class="c" data-k="foot"><i></i>小脚丫</span><span class="c" data-k="fist"><i></i>小拳头</span>
<span class="c" data-k="knee"><i></i>小膝盖</span><span class="c" data-k="butt"><i></i>小屁股</span></div></div>
</div>
<canvas id="cv"></canvas>
<script>
(function () {
const W = 375, H = 270, BH = 2551, HP = H + BH; // 头图 375x270(与首页一致)+ 下面模块 375x2551 = 整页
const REC = /[?&]rec=1/.test(location.search);
const page = document.querySelector('.page'), cv = document.getElementById('cv');
const hunt = document.getElementById('hunt'), finger = document.getElementById('finger');
const gl = cv.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: REC, antialias: true });
if (!gl) return;
// 整页纹理尽量按 3 倍画,但不超过显卡纹理上限和 canvas 面积上限(iOS 约 1600 万像素)
const SC = Math.min(3, gl.getParameter(gl.MAX_TEXTURE_SIZE) / HP, Math.sqrt(16e6 / (W * HP)));
const VS = 'attribute vec2 a;varying vec2 v;void main(){v=a*.5+.5;v.y=1.-v.y;gl_Position=vec4(a,0.,1.);}';
const FS = `
precision highp float;
varying vec2 v;
uniform sampler2D T;
uniform vec2 S; // 整页逻辑尺寸 375xHP
uniform vec2 O; // 当前这一屏:滚动位置 y、屏幕高度(逻辑 px)
uniform vec4 F; // 鼓起处 x,y,角度,高度(px)
uniform vec4 K; // 形状 半径x,半径y,尖度(指数),脚趾显隐(0-1)
uniform vec4 G; // 左右脚(±1),脚趾蜷曲(0-1),拉长量,0
uniform vec4 P; // 手指 x,y,按压深度(负=凹下去,正=回弹鼓起),0
uniform vec2 J; // 肚皮整体晃动的位移(px)
uniform float Tm;
float sdE(vec2 p,vec2 c,vec2 r){return (length((p-c)/r)-1.)*min(r.x,r.y);}
vec2 loc(vec2 p){vec2 d=p-F.xy; float c=cos(F.z),s=sin(F.z); return vec2(c*d.x+s*d.y,-s*d.x+c*d.y);}
// 脚趾:只在小脚丫时隐约露一点,圆乎乎、很淡
float toes(vec2 p){
if(K.w<.01||F.w<.01) return 0.;
vec2 d=loc(p)/1.2; d.x*=G.x; d.y/=1.+G.z;
float cu=G.y, ht=0.;
vec3 toe[5];
toe[0]=vec3(-6.5,-22.,5.2); toe[1]=vec3(.5,-24.5,4.2); toe[2]=vec3(6.,-23.,3.8); toe[3]=vec3(10.3,-20.,3.5); toe[4]=vec3(13.4,-15.8,3.2);
for(int i=0;i<5;i++){
vec3 t=toe[i]; float w=sin(Tm*7.+float(i)*1.3)*.5+.5;
float sd=length(d-t.xy-vec2(0.,(3.+1.5*w)*cu))-t.z;
ht=max(ht,smoothstep(5.,-5.,sd)*(.5-.15*cu*w));
}
return F.w*K.w*ht;
}
// 鼓起:椭圆的软帐篷,尖度 K.z 越大顶越尖(小脚丫尖、小屁股缓)
float tent(vec2 p){
if(F.w<.01) return 0.;
vec2 d=loc(p)/K.xy; d.y/=1.+G.z*.5;
float r=sqrt(dot(d,d)+.014);
float u=clamp((1.-r)/(1.-.118),0.,1.);
return F.w*2.3*pow(u,K.z);
}
float dentR=33.;
float dent(vec2 p){vec2 d=p-P.xy;return P.z*exp(-dot(d,d)/(dentR*dentR));}
float hgt(vec2 p){return tent(p)+toes(p)*.12+dent(p);}
void main(){
vec2 p=vec2(v.x*S.x,O.x+v.y*O.y);
if(p.y<0.||p.y>S.y){gl_FragColor=vec4(.973,.953,.937,1.);return;} // iOS 橡皮筋拉出页面外
float m=1.; // 整页都是肚皮
float e=1.2;
vec2 g=vec2(hgt(p+vec2(e,0.))-hgt(p-vec2(e,0.)),hgt(p+vec2(0.,e))-hgt(p-vec2(0.,e)))/(2.*e)*m;
float h=hgt(p)*m;
// 肉感:按下去时周围的皮肤被拽向手指,回弹时被推开;整块肚皮再跟着晃
vec2 dp=p-P.xy;
float gp=exp(-dot(dp,dp)/(dentR*dentR*2.6));
float gw=exp(-dot(dp,dp)/(130.*130.));
vec2 q=p+dp*(-P.z*.03)*gp - J*gw + g*2.2;
float th=clamp(tent(p)/16.,0.,1.2);
q-=(p-F.xy)*.36*th; // 鼓起处把字往外撑开、放大:越靠近顶越被撑大
q=mix(p,q,m);
vec3 c=texture2D(T,clamp(q/S,0.,1.)).rgb;
vec3 n=normalize(vec3(-g*.85,1.));
vec3 L=normalize(vec3(-.45,-.75,.9));
float dif=dot(n,L)-dot(vec3(0.,0.,1.),L);
c+=vec3(1.,.95,.9)*max(dif,0.)*.30;
c*=1.+min(dif,0.)*vec3(.28,.36,.38); // 背光侧偏暖红,像皮肤
// 鼓起处往右下投一层淡影,让它「立」起来
vec2 d0=loc(p)/K.xy*68.-vec2(7.,11.); c*=1.-.10*exp(-dot(d0,d0)/(48.*48.))*smoothstep(1.,9.,F.w)*(1.-th)*m;
float dn=clamp(-dent(p)/10.,0.,1.)*m;
c*=1.-dn*vec3(.13,.21,.2);
vec3 Hh=normalize(L+vec3(0.,0.,1.));
c+=vec3(1.,.97,.95)*pow(max(dot(n,Hh),0.),24.)*.12*smoothstep(.5,4.,abs(h));
c+=vec3(1.,.88,.8)*smoothstep(2.,12.,h)*.05; // 顶起处微微透亮
gl_FragColor=vec4(c,1.);
}`;
function sh(t, s) { const o = gl.createShader(t); gl.shaderSource(o, s); gl.compileShader(o); if (!gl.getShaderParameter(o, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(o); return o; }
const pr = gl.createProgram();
gl.attachShader(pr, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FS)); gl.linkProgram(pr); gl.useProgram(pr);
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const al = gl.getAttribLocation(pr, 'a'); gl.enableVertexAttribArray(al); gl.vertexAttribPointer(al, 2, gl.FLOAT, false, 0, 0);
const U = n => gl.getUniformLocation(pr, n);
const uO = U('O'), uS = U('S'), uF = U('F'), uK = U('K'), uG = U('G'), uP = U('P'), uJ = U('J'), uT = U('Tm');
gl.uniform2f(uS, W, HP);
/* ---------- 纹理:canvas 现场合成,宝宝卡的悄悄话会变 ---------- */
const tc = document.createElement('canvas'); tc.width = Math.floor(W * SC); tc.height = Math.floor(HP * SC);
const hc = document.createElement('canvas'); hc.width = tc.width; hc.height = Math.ceil(H * SC); // 只换头图那一截时用
const cx = tc.getContext('2d');
const SRC = { bg: 'belly_bg.jpg', top: 'top.png', pill: '../_hero/pill.png', week: '../_hero/week.png', prog: '../_hero/prog.png', below: 'below.jpg' };
const IMG = {}; let loaded = 0;
const tex = gl.createTexture(); let ready = false;
const FONT = '-apple-system,"PingFang SC","Noto Sans SC",sans-serif';
const card = { line: '我认得妈妈的声音' };
function rr(x, y, w, h, r) { cx.beginPath(); cx.moveTo(x + r, y); cx.arcTo(x + w, y, x + w, y + h, r); cx.arcTo(x + w, y + h, x, y + h, r); cx.arcTo(x, y + h, x, y, r); cx.arcTo(x, y, x + w, y, r); cx.closePath(); }
function compose(full) {
cx.setTransform(SC, 0, 0, SC, 0, 0);
if (full) cx.drawImage(IMG.below, 0, H, W, BH);
cx.drawImage(IMG.bg, 0, 0, W, H);
// 右侧原来是宝宝插画的位置:一大团很淡的暖光 + 几颗小光点,让肚皮不空
const gr = cx.createRadialGradient(292, 118, 6, 292, 118, 120);
gr.addColorStop(0, 'rgba(255,236,214,.42)'); gr.addColorStop(.55, 'rgba(255,226,200,.14)'); gr.addColorStop(1, 'rgba(255,226,200,0)');
cx.fillStyle = gr; cx.fillRect(150, 0, 225, 250);
[[226, 52, 1.6, .55], [352, 44, 1.2, .5], [214, 196, 1.3, .45], [362, 168, 1.8, .4], [246, 210, 1, .5]].forEach(([x, y, r, a]) => {
const g2 = cx.createRadialGradient(x, y, 0, x, y, r * 4); g2.addColorStop(0, 'rgba(255,255,255,' + a + ')'); g2.addColorStop(1, 'rgba(255,255,255,0)');
cx.fillStyle = g2; cx.beginPath(); cx.arc(x, y, r * 4, 0, 7); cx.fill();
});
cx.drawImage(IMG.top, 0, 0, W, 50);
cx.drawImage(IMG.pill, 14, 52, 159.69, 42);
cx.font = '900 13.5px ' + FONT; cx.textBaseline = 'middle';
cx.shadowColor = 'rgba(120,30,10,.28)'; cx.shadowBlur = 3; cx.shadowOffsetY = 1;
cx.fillStyle = '#FFF3C4'; cx.fillText('67', 120.6, 73.5);
cx.shadowColor = 'transparent';
cx.drawImage(IMG.week, 14, 86, 110.9, 70);
cx.drawImage(IMG.prog, 14, 150, 192, 36);
// 「这周宝宝学会了」玻璃卡:30周的发育小变化 + 宝宝的一句悄悄话(被顶/被摁时换话)
const X = 228, Y = 60, CW = 134, CH = 122;
cx.save();
cx.shadowColor = 'rgba(150,55,30,.16)'; cx.shadowBlur = 18; cx.shadowOffsetY = 8;
rr(X, Y, CW, CH, 20); cx.fillStyle = 'rgba(255,255,255,.17)'; cx.fill();
cx.restore();
const hl = cx.createLinearGradient(X, Y, X, Y + CH); hl.addColorStop(0, 'rgba(255,255,255,.24)'); hl.addColorStop(1, 'rgba(255,255,255,.05)');
rr(X, Y, CW, CH, 20); cx.fillStyle = hl; cx.fill();
cx.lineWidth = 1; cx.strokeStyle = 'rgba(255,255,255,.55)'; rr(X + .5, Y + .5, CW - 1, CH - 1, 19.5); cx.stroke();
cx.shadowColor = 'rgba(120,30,10,.25)'; cx.shadowBlur = 4; cx.shadowOffsetY = 1;
cx.fillStyle = 'rgba(255,255,255,.9)'; cx.font = '600 11px ' + FONT; cx.fillText('这周宝宝学会了', X + 14, Y + 19);
cx.font = '900 25px ' + FONT; cx.fillStyle = '#fff'; cx.fillText('睁眼看光', X + 13, Y + 45);
cx.font = '600 10.5px ' + FONT; cx.fillStyle = 'rgba(255,255,255,.9)'; cx.fillText('晒太阳时,我能感觉到亮', X + 14, Y + 68);
cx.shadowColor = 'transparent';
cx.fillStyle = 'rgba(255,255,255,.28)'; cx.fillRect(X + 14, Y + 81, CW - 28, 1);
// 悄悄话:小气泡尾巴 + 一句话
cx.fillStyle = 'rgba(255,255,255,.92)'; cx.beginPath(); cx.arc(X + 18, Y + 101, 2.6, 0, 7); cx.fill();
cx.shadowColor = 'rgba(120,30,10,.22)'; cx.shadowBlur = 3; cx.shadowOffsetY = 1;
cx.font = '600 11px ' + FONT; cx.fillStyle = 'rgba(255,255,255,.95)';
let ln = card.line; while (cx.measureText(ln).width > CW - 40 && ln.length > 2) ln = ln.slice(0, -1);
cx.fillText(ln, X + 26, Y + 101.5);
cx.shadowColor = 'transparent';
gl.bindTexture(gl.TEXTURE_2D, tex);
if (full) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, tc); return; }
// 悄悄话变了:只把头图那一截重新传上去,整页纹理很大,别每次全传
const hx = hc.getContext('2d'); hx.drawImage(tc, 0, 0);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGB, gl.UNSIGNED_BYTE, hc);
}
function setCard(line) { card.line = line; if (ready) compose(); }
Object.keys(SRC).forEach(k => {
const im = new Image(); im.onload = () => {
IMG[k] = im;
if (++loaded === Object.keys(SRC).length) {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
ready = true; compose(true); cv.classList.add('on');
(document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { compose(true); window.__ready = true; });
}
};
im.src = SRC[k];
});
// 页面 px ↔ 逻辑 px;当前这一屏在整页里的位置
let K = 1;
const view = () => { const r = page.getBoundingClientRect(); K = r.width / W; return { y: -r.top / K, h: cv.clientHeight / K }; };
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2); // 整屏都要算,最多 2 倍
cv.width = Math.round(cv.clientWidth * dpr); cv.height = Math.round(cv.clientHeight * dpr);
gl.viewport(0, 0, cv.width, cv.height);
}
resize(); addEventListener('resize', resize);
/* ---------- 宝宝的各个部位:形状不同,动法不同 ---------- */
// rx/ry 椭圆半径,sharp 尖度,toe 脚趾显隐,amp 高度,dur 停留秒数,roll 是否慢慢拱着挪
const PARTS = {
foot: { name: '小脚丫', say: ['妈妈,我在伸腿', '踢一脚打个招呼'], caught: '脚丫被你抓到啦', rx: 64, ry: 72, sharp: 1.7, toe: 1, amp: 9.5, dur: [2.4, 3.4] },
fist: { name: '小拳头', say: ['我在挥小拳头', '妈妈看我力气大'], caught: '小拳头被你握住啦', rx: 46, ry: 46, sharp: 1.9, toe: 0, amp: 8, dur: [1.8, 2.6] },
knee: { name: '小膝盖', say: ['我在翻个身', '这里有点挤哦'], caught: '膝盖被摁回去啦', rx: 50, ry: 86, sharp: 1.6, toe: 0, amp: 8.5, dur: [2.2, 3] },
butt: { name: '小屁股', say: ['我在伸懒腰~', '换个姿势睡觉'], caught: '屁股被你拍到了', rx: 100, ry: 84, sharp: 1.25, toe: 0, amp: 7, dur: [3, 3.8], roll: true },
};
const ORDER = ['foot', 'fist', 'butt', 'knee'];
const found = {};
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: 290, y: 120, ang: -.3, side: 1, part: PARTS.foot, h: { v: 0, vel: 0, to: 0, k: 140, c: 11 }, curl: 0, stretch: 0, life: 0, dur: 3, ox: 0, oy: 0 };
// 手指按压:欠阻尼,松手后过冲成一个小鼓包再回来 → duang duang
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 = .5, t = 0, seq = 0;
// 一半以上的概率专挑字和卡片顶(大字「30」、倒计时胶囊、进度条、slogan、宝宝卡),字会被撑起来变形
const TEXT_SPOTS = [[62, 120], [100, 74], [104, 124], [96, 166], [230, 26], [292, 104], [300, 80], [150, 168]];
// 只在当前这一屏里挑位置:头图露着就多半挑头图上的字,滑到下面就在下面的模块上顶
function pickSpot() {
const vw = view(), y0 = Math.max(vw.y + 60, 20), y1 = Math.min(vw.y + vw.h - 70, HP - 40);
const spots = TEXT_SPOTS.filter(s => s[1] > y0 && s[1] < y1);
for (let i = 0; i < 40; i++) {
let x, y;
if (spots.length && Math.random() < .7) { const s = spots[Math.floor(Math.random() * spots.length)]; x = s[0] + (Math.random() - .5) * 16; y = s[1] + (Math.random() - .5) * 10; }
else { x = 50 + Math.random() * 275; y = y0 + Math.random() * Math.max(y1 - y0, 1); }
if (Math.hypot(x - ft.x, y - ft.y) > 80) return { x, y };
}
return { x: 190, y: (y0 + y1) / 2 };
}
function appear() {
const key = ORDER[seq++ % ORDER.length], P = PARTS[key];
const s = pickSpot();
ft.key = key; ft.part = P;
ft.x = s.x; ft.y = s.y; ft.ox = ft.oy = 0;
ft.ang = (Math.random() - .5) * 1.4 + (s.x > 190 ? .25 : -.25);
ft.side = Math.random() < .5 ? 1 : -1;
ft.rollDir = s.x > 200 ? -1 : 1;
ft.h.to = P.amp; ft.h.k = P.roll ? 70 : 140; ft.h.c = P.roll ? 9 : 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;
mode = 'show';
setCard(P.say[Math.floor(Math.random() * P.say.length)]);
}
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';
}
function press(x, y, on) {
if (on) {
pk.x = x; pk.y = y; pk.held = true; pk.d.to = -14; pk.d.k = 380; pk.d.c = 22;
const fx = ft.x + ft.ox, fy = ft.y + ft.oy, R = Math.max(ft.part.rx, ft.part.ry) * .8;
if ((mode === 'show' || (mode === 'hide' && ft.h.v > 3)) && Math.hypot(x - fx, y - fy) < R) {
// 摁中:被摁回去,往远离手指的方向溜
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); mode = 'flee'; wait = 1.1 + Math.random() * .8;
if (navigator.vibrate) navigator.vibrate(15);
setCard(ft.part.caught);
found[ft.key] = true;
const chip = hunt.querySelector('[data-k="' + ft.key + '"]'); if (chip) chip.classList.add('on');
hunt.classList.add('pop'); setTimeout(() => hunt.classList.remove('pop'), 300);
if (Object.keys(found).length === ORDER.length) hunt.querySelector('.t').textContent = '全找到啦!';
}
} else if (pk.held) {
pk.held = false;
// 松手:弹簧变软、阻尼变小,皮肉弹回来并过冲 → duang duang
pk.d.to = 0; pk.d.k = 360; pk.d.c = 4.6; pk.d.vel += 170;
jg.y.vel += 150; jg.x.vel += (Math.random() - .5) * 90;
}
}
page.addEventListener('pointerdown', e => {
const r = page.getBoundingClientRect();
press((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.width * W, true);
});
page.addEventListener('pointerup', () => press(0, 0, false));
// 手指一滑变成滚动:浏览器会发 pointercancel,这时窝悄悄平回去,不弹
page.addEventListener('pointercancel', () => { if (pk.held) { pk.held = false; pk.d.to = 0; pk.d.k = 300; pk.d.c = 34; } });
let lastY = 0, still = 0;
function step(dt) {
t += dt;
// 滑动停下来 0.35 秒,看看宝宝在不在这一屏里;不在就让它挪过来,在这一屏里慢慢顶一下
const vw = view();
if (Math.abs(vw.y - lastY) > 1.5) { still = 0; lastY = vw.y; }
else if (still < .35 && (still += dt) >= .35) {
const fy = ft.y + ft.oy, seen = fy > vw.y + 30 && fy < vw.y + vw.h - 30;
if (!seen) {
if (mode === 'show' || mode === 'hide') { ft.h.v = 0; ft.h.vel = 0; mode = 'wait'; wait = .5 + Math.random() * .6; }
else if (mode === 'wait') wait = Math.min(wait, .5 + Math.random() * .6);
}
}
if (mode === 'wait') { wait -= dt; if (wait <= 0) appear(); }
else if (mode === 'show') {
ft.life += dt;
const u = ft.life, P = ft.part;
if (P.roll) {
// 小屁股:慢慢拱着往一边挪
ft.ox = ft.rollDir * 30 * Math.min(u / ft.dur, 1); ft.oy = -6 * Math.sin(Math.min(u / ft.dur, 1) * Math.PI);
ft.wob = Math.sin(u * 1.2) * .08; ft.curl = 0; ft.stretch = .05 * Math.sin(u * 1.6);
} else {
ft.curl = P.toe ? Math.max(0, Math.sin(u * 3.2 - .6)) * .9 : 0;
ft.stretch = .06 * Math.sin(u * 2.4);
ft.wob = Math.sin(u * 2.1) * .12;
if (u > ft.dur * .45 && !ft.kicked) { 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 = .9 + Math.random() * 1.2; }
} else if (mode === 'flee') {
const f = ft.flee; f.t += dt;
const s = 26 * (1 - Math.exp(-f.t * 7));
ft.ox = f.dx * s; ft.oy = f.dy * s;
ft.stretch = .35 * Math.exp(-f.t * 5);
ft.curl = 1;
wait -= dt;
if (wait <= 0) appear();
}
if (mode !== 'show' && mode !== 'flee') { ft.stretch *= Math.exp(-dt * 6); }
spring(ft.h, dt); if (ft.h.v < 0) { ft.h.v = 0; ft.h.vel *= -.2; }
spring(pk.d, dt); spring(jg.x, dt); spring(jg.y, dt);
}
function draw() {
if (!ready) return;
const P = ft.part, wob = mode === 'show' ? (ft.wob || 0) : 0;
gl.uniform4f(uF, ft.x + ft.ox, ft.y + ft.oy, ft.ang + wob, ft.h.v);
gl.uniform4f(uK, P.rx, P.ry, P.sharp, P.toe);
gl.uniform4f(uG, ft.side, ft.curl, ft.stretch, 0);
gl.uniform4f(uP, pk.x, pk.y, pk.d.v, 0);
gl.uniform2f(uJ, jg.x.v, jg.y.v);
gl.uniform1f(uT, t);
const vw = view(); gl.uniform2f(uO, vw.y, vw.h);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
if (REC) {
// 录屏:由外部按帧推进
window.__adv = ms => { const n = Math.round(ms / (1000 / 120)); for (let i = 0; i < n; i++) step(1 / 120); draw(); };
window.__press = (x, y, on) => press(x, y, on);
window.__tap = (x, y) => press(x, y, true);
window.__foot = () => ({ x: ft.x + ft.ox, y: ft.y + ft.oy, h: ft.h.v, mode, key: ft.key });
window.__finger = (x, y, on) => {
finger.style.left = x * K + 'px'; finger.style.top = y * K + 'px';
finger.classList.toggle('on', !!on);
};
window.__seed = s => { let z = s; Math.random = () => ((z = (z * 16807) % 2147483647) / 2147483647); };
} else {
let last = performance.now();
(function loop(now) {
const dt = Math.min((now - last) / 1000, 1 / 20); last = now;
const n = Math.max(1, Math.round(dt * 120));
for (let i = 0; i < n; i++) step(dt / n);
draw(); requestAnimationFrame(loop);
})(last);
}
})();
</script>
</body>
</html>