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

298 lines
15 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 创意预览(不进正式首页):「整块头图是妈妈的肚皮」v2。
头图去掉了胎儿插画(belly.jpg:原头图渐变拟合 + 一团圆鼓鼓的肚皮柔光,再叠回 LOGO/倒计时/周数/进度条)。
WebGL 把它当一层肉肉的肚皮:
· 宝宝时不时往外蹬一脚,把肚皮连同上面的字撑成一个软软的小帐篷(顶上隐约一只圆乎乎的小脚丫),扭一扭再缩回去,隔会儿换个地方,常常专挑字踢;
· 手指戳上去 = 肚皮被按下去一个窝,松手后像肉一样 duang duang 回弹(欠阻尼弹簧,过冲时反鼓起来)+ 周围一起晃;
· 戳中小脚印 → 脚印「嗖」地缩回去并往远离手指的方向溜走,过一两秒在别处冒出来。
不再有水波纹。下面的卡片是首页截图(静态),只为给头图一个真实语境。
?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}
.hero{position:relative;width:100%;aspect-ratio:375/270;touch-action:manipulation;cursor:pointer}
.hero canvas,.hero img{position:absolute;inset:0;width:100%;height:100%;display:block}
.hero img{opacity:0} /* WebGL 起不来时退回静态头图 */
.hero.nogl img{opacity:1}
.below{display:block;width:100%;margin-top:0}
/* 头图下方的小提示:和首页「记一下」同一套白色胶囊语言 */
.hint{position:absolute;left:0;right:0;top:0;display:flex;justify-content:center;pointer-events:none;z-index:3}
.hint span{margin-top:calc(min(100vw,480px) * 222 / 375);padding:6px 14px 6px 12px;border-radius:999px;
font-size:12.5px;color:#C2563B;letter-spacing:.3px;font-weight:600;
background:rgba(255,255,255,.82);-webkit-backdrop-filter:blur(12px) saturate(160%);backdrop-filter:blur(12px) saturate(160%);
box-shadow:0 6px 16px rgba(180,80,50,.16),inset 0 0 0 1px rgba(255,255,255,.9);transition:transform .35s cubic-bezier(.3,1.6,.5,1)}
.hint span b{color:#F06A45}
.hint span.pop{transform:scale(1.08)}
.hint span{white-space:nowrap}
/* 录屏用的手指 */
.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">
<div class="hero" id="hero">
<img src="belly.jpg" alt="">
<canvas id="cv"></canvas>
<i class="finger" id="finger"></i>
</div>
<div class="hint"><span id="hint">宝宝在里面踢你呢,<b>戳一下小脚丫</b></span></div>
<img class="below" src="below.jpg" alt="">
</div>
<script>
(function () {
const W = 375, H = 270; // 头图逻辑尺寸(与首页一致)
const REC = /[?&]rec=1/.test(location.search);
const hero = document.getElementById('hero'), cv = document.getElementById('cv');
const hint = document.getElementById('hint'), finger = document.getElementById('finger');
const gl = cv.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: REC, antialias: true });
if (!gl) { hero.classList.add('nogl'); return; }
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; // 逻辑尺寸 375x270
uniform vec4 F; // 脚印 x,y,角度,高度(px)
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);}
float smin(float a,float b,float k){float h=clamp(.5+.5*(b-a)/k,0.,1.);return mix(b,a,h)-k*h*(1.-h);}
// 小脚丫:脚尖朝 -y,长约 52px
float foot(vec2 p){
if(F.w<.01) return 0.;
vec2 d=p-F.xy; float c=cos(F.z),s=sin(F.z);
d=vec2(c*d.x+s*d.y,-s*d.x+c*d.y);
d/=1.2; d.x*=G.x; d.y/=1.+G.z;
float sole=sdE(d,vec2(1.,-6.),vec2(11.5,11.));
sole=smin(sole,sdE(d,vec2(-1.5,6.),vec2(8.,11.)),10.);
sole=smin(sole,sdE(d,vec2(-1.,15.),vec2(8.5,9.)),10.);
float hs=smoothstep(8.,-14.,sole);
// 五个脚趾,从大到小;蜷曲时往脚掌收、变矮
float cu=G.y, ht=0.;
vec3 toe[5];
toe[0]=vec3(-6.5,-22.,4.8); toe[1]=vec3(.5,-24.5,3.7); toe[2]=vec3(6.,-23.,3.3); toe[3]=vec3(10.3,-20.,3.); toe[4]=vec3(13.4,-15.8,2.7);
for(int i=0;i<5;i++){
vec3 t=toe[i]; float w=sin(Tm*9.+float(i)*1.3)*.5+.5;
vec2 tc=t.xy+vec2(0.,(3.5+1.5*w)*cu);
float sd=length(d-tc)-t.z;
ht=max(ht,smoothstep(4.,-5.,sd)*(.62-.18*cu*w));
}
return F.w*max(hs,ht);
}
float dentR=33.;
float dent(vec2 p){vec2 d=p-P.xy;return P.z*exp(-dot(d,d)/(dentR*dentR));}
// 小帐篷:脚丫往外蹬,把肚皮连同上面的字一起撑成一个尖顶软帐篷
float tent(vec2 p){
if(F.w<.01) return 0.;
vec2 d=p-F.xy; float c=cos(F.z),s=sin(F.z);
d=vec2(c*d.x+s*d.y,-s*d.x+c*d.y); d.y*=.85;
float u=clamp(1.-sqrt(dot(d,d)+64.)/68.,0.,1.)/(1.-8./68.); // 顶上磨圆,不出尖折痕
return F.w*2.3*pow(u,1.7);
}
float hgt(vec2 p){return tent(p)+foot(p)*.3+dent(p);}
void main(){
vec2 p=v*S;
vec3 c0=texture2D(T,v).rgb;
// 只在橙色肚皮上生效:下缘弧线外是页面底色
// 白字也接近页面底色,所以只在下缘弧线那一带(y>185)按颜色判断
float m=p.y<185.?1.:smoothstep(.03,.12,distance(c0,vec3(.973,.953,.937)));
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 fh=clamp(foot(p)/12.,0.,1.);
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); // 背光侧偏暖红,像皮肤
// 脚印四周一圈很淡的凹陷(皮被撑起,边上被拉紧)
float rim=smoothstep(.0,.25,fh)*(1.-smoothstep(.25,.7,fh));
// 按下去的窝:窝底暗一点、偏红一点(像压着肉)
// 帐篷往右下投一层淡影,让它「立」起来
vec2 d0=p-F.xy-vec2(7.,11.); c*=1.-.10*exp(-dot(d0,d0)/(48.*48.))*smoothstep(1.,9.,F.w)*(1.-th);
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 uS = U('S'), uF = U('F'), uG = U('G'), uP = U('P'), uJ = U('J'), uT = U('Tm');
gl.uniform2f(uS, W, H);
const tex = gl.createTexture(); let ready = false;
const img = new Image();
img.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
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; window.__ready = true;
};
img.src = 'belly.jpg';
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 3), r = hero.getBoundingClientRect();
cv.width = Math.round(r.width * dpr); cv.height = Math.round(r.height * dpr);
gl.viewport(0, 0, cv.width, cv.height);
}
resize(); addEventListener('resize', resize);
/* ---------- 物理:都是阻尼弹簧 ---------- */
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, 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, count = 0;
const TEXT_SPOTS = [[62, 120], [100, 74], [104, 124], [96, 166], [230, 26], [160, 168]];
function pickSpot() {
for (let i = 0; i < 40; i++) {
// 一半概率专挑字踢(大字「30」、倒计时胶囊、进度条、slogan),字会被撑起来变形;一半落在右边空白肚皮
let x, y;
if (Math.random() < .55) { const s = TEXT_SPOTS[Math.floor(Math.random() * TEXT_SPOTS.length)]; x = s[0] + (Math.random() - .5) * 16; y = s[1] + (Math.random() - .5) * 10; }
else { x = 240 + Math.random() * 100; y = 80 + Math.random() * 100; }
if (Math.hypot(x - ft.x, y - ft.y) > 80) return { x, y };
}
return { x: 300, y: 120 };
}
function appear() {
const s = pickSpot();
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.h.to = 9.5; ft.h.k = 140; ft.h.c = 9; ft.h.vel += 120; // 蹬一下冒出来
ft.life = 0; ft.dur = 2.6 + Math.random() * 1.4;
mode = 'show';
}
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;
if ((mode === 'show' || (mode === 'hide' && ft.h.v > 3)) && Math.hypot(x - fx, y - fy) < 58) {
// 戳中小脚丫:嗖地缩回去,往远离手指的方向溜
count++;
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);
hint.innerHTML = count < 2 ? '被你戳到啦!小脚丫缩回去了' : '又溜啦~已经戳到宝宝 <b>' + count + '</b> 次';
hint.classList.add('pop'); setTimeout(() => hint.classList.remove('pop'), 300);
}
} 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;
}
}
hero.addEventListener('pointerdown', e => {
const r = hero.getBoundingClientRect();
hero.setPointerCapture && hero.setPointerCapture(e.pointerId);
press((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H, true);
});
const up = () => press(0, 0, false);
hero.addEventListener('pointerup', up); hero.addEventListener('pointercancel', up);
function step(dt) {
t += dt;
if (mode === 'wait') { wait -= dt; if (wait <= 0) appear(); }
else if (mode === 'show') {
ft.life += dt;
const u = ft.life;
// 扭一扭:轻轻转、往脚尖方向蹭一点、脚趾蜷一蜷;中途再蹬一下
ft.curl = Math.max(0, Math.sin(u * 3.2 - .6)) * .9;
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); ft.kicked = false; }
} else if (mode === 'hide') {
if (ft.h.v < .3) { mode = 'wait'; wait = .9 + Math.random() * 1.2; ft.kicked = false; }
} 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) { ft.kicked = false; 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 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(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);
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 });
window.__finger = (x, y, on) => {
finger.style.left = x / W * 100 + '%'; finger.style.top = y / H * 100 + '%';
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>