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

241 lines
12 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 创意预览(不进正式首页):「首页被宝宝从里面顶起来」。
头图当成一层软布/果冻:WebGL 把首页头图截图当纹理,按一个高斯鼓包的高度场做「放大折射 + 受光高光 + 背光阴影」,
宝宝隔几秒往上顶一下(弹簧冲量,带回弹);点中鼓包 → 原地塌下去、荡开一圈波纹,
鼓包贴着布面「钻」到另一个位置(顺着跑动方向被拉长),再「啵」地顶起来。
下面的卡片是首页截图(静态),只为给头图一个真实语境。
?rec=1:录屏模式,关掉自动循环,由 __adv / __tap 逐帧驱动(生成动图用)。 -->
<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)}
/* 录屏用的手指 */
.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="hero.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 B; // 鼓包 x,y,半径,高度(px)
uniform vec3 D; // 拉伸方向 dx,dy,拉伸量
uniform vec4 R[4]; // 波纹 x,y,年龄(s),强度
uniform float Tm;
float bump(vec2 p){
vec2 d=p-B.xy;
float a=dot(d,D.xy), b=dot(d,vec2(-D.y,D.x));
a/= (1.+D.z); b*= (1.+D.z*.35);
float r=B.z*(1.+.035*sin(Tm*2.1)); // 布面轻轻呼吸
return B.w*exp(-(a*a+b*b)/(r*r));
}
float wave(vec2 p){
float h=0.;
for(int i=0;i<4;i++){
vec4 w=R[i]; if(w.w<=0.) continue;
float dist=length(p-w.xy), front=w.z*210.;
float e=exp(-pow((dist-front)/34.,2.));
h+=w.w*5.5*exp(-w.z*2.6)*e*sin((dist-front)*.16);
}
return h;
}
float hgt(vec2 p){return bump(p)+wave(p);}
void main(){
vec2 p=v*S;
float e=1.;
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);
float h=hgt(p);
// 折射:往鼓包中心取样 → 鼓起来的地方被放大,像隔着一层果冻
// 透镜:越靠近鼓包顶越被放大(顶部约 1.5 倍),文字、图案会跟着「鼓」起来
float hn=clamp(bump(p)/22.,0.,1.3);
vec2 q=p-(p-B.xy)*.34*hn+g*4.;
vec3 c=texture2D(T,clamp(q/S,0.,1.)).rgb;
// 只在橙色头图区域生效(下缘弧线外是页面底色,不该被打光)
vec3 n=normalize(vec3(-g*1.1,1.));
vec3 L=normalize(vec3(-.45,-.75,.9));
float dif=dot(n,L)-dot(vec3(0.,0.,1.),L);
c+=vec3(1.,.97,.94)*max(dif,0.)*.42;
c*=1.+min(dif,0.)*vec3(.38,.46,.5); // 背光侧偏暖的阴影
// 鼓包根部一圈很淡的接触阴影:布被顶起后四周微微下陷
float ring=bump(p)/max(B.w,.001); c*=1.-.06*smoothstep(.02,.25,ring)*(1.-smoothstep(.25,.6,ring))*smoothstep(2.,10.,B.w);
// 鼓包往右下投一层很淡的影子,让它「站」起来
vec2 d0=p-B.xy-vec2(6.,10.); float sh=exp(-dot(d0,d0)/(B.z*B.z*1.1))*smoothstep(1.,12.,B.w);
c*=1.-.09*sh*(1.-ring);
vec3 Hh=normalize(L+vec3(0.,0.,1.));
c+=vec3(1.)*pow(max(dot(n,Hh),0.),60.)*.35*smoothstep(.5,4.,h); // 果冻感高光
c+=vec3(1.,.9,.85)*smoothstep(2.,22.,h)*.06; // 顶部微微透亮
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'), uB = U('B'), uD = U('D'), uR = U('R'), 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 = 'hero.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 BASE = 21, RAD = 42;
const b = { x: 96, y: 84, amp: 0, vel: 0, target: BASE, k: 170, c: 8.5, sx: 0, sy: 1, st: 0 };
let mode = 'idle', trip = null, nextKick = 0.6, t = 0, count = 0;
const ripples = [];
function kick(power) { b.vel += power; }
function ripple(x, y, s) { ripples.push({ x, y, age: 0, s }); if (ripples.length > 4) ripples.shift(); }
/* 鼓包落点:挑头图里「有东西」的地方(大字、胶囊、进度条、slogan、羊水囊边),顶起来时字和图案会被撑变形,
「界面本身被顶起来」才看得出来;落在大片纯色上只像一团光。每次加一点随机抖动,且离上一个点足够远。 */
const SPOTS = [[96, 84], [150, 50], [82, 112], [214, 24], [120, 22], [300, 176], [330, 70], [176, 112]];
function pickSpot() {
const pool = SPOTS.filter(s => Math.hypot(s[0] - b.x, s[1] - b.y) > 110);
const s = pool[Math.floor(Math.random() * pool.length)] || SPOTS[0];
return { x: s[0] + (Math.random() - .5) * 14, y: s[1] + (Math.random() - .5) * 10 };
}
const ease = u => u < .5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
function tap(x, y) {
if (mode !== 'idle') return;
if (Math.hypot(x - b.x, y - b.y) < RAD * 1.35) {
// 点中:原地塌下去 + 荡一圈波纹,贴着布面钻到别处
count++;
ripple(b.x, b.y, 1);
if (navigator.vibrate) navigator.vibrate(12);
const to = pickSpot();
trip = { fx: b.x, fy: b.y, tx: to.x, ty: to.y, t: 0, dur: .62 };
mode = 'sink'; b.target = 4.5; b.k = 420; b.c = 20; trip.wait = .12;
hint.innerHTML = count < 2 ? '抓到啦!它跑到别处去了' : '又跑啦~已经逗了宝宝 <b>' + count + '</b> 次';
hint.classList.add('pop'); setTimeout(() => hint.classList.remove('pop'), 300);
} else {
// 没点中:布面轻轻一荡,宝宝在原地回应一下
ripple(x, y, .45); kick(90);
}
}
hero.addEventListener('pointerdown', e => {
const r = hero.getBoundingClientRect();
tap((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H);
});
function step(dt) {
t += dt;
if (mode === 'idle') {
nextKick -= dt;
if (nextKick <= 0) { kick(150 + Math.random() * 120); nextKick = 2 + Math.random() * 1.6; }
} else if (mode === 'sink') {
trip.wait -= dt;
if (trip.wait <= 0) mode = 'travel';
} else if (mode === 'travel') {
trip.t += dt;
const u = Math.min(trip.t / trip.dur, 1), e = ease(u);
const nx = trip.fx + (trip.tx - trip.fx) * e, ny = trip.fy + (trip.ty - trip.fy) * e + Math.sin(u * Math.PI) * -22;
const vx = nx - b.x, vy = ny - b.y, sp = Math.hypot(vx, vy) / Math.max(dt, 1e-3);
if (sp > 1) { b.sx = vx / Math.hypot(vx, vy); b.sy = vy / Math.hypot(vx, vy); }
b.st = Math.min(sp / 520, 1.1);
b.x = nx; b.y = ny;
if (u >= 1) { // 到了:啵地顶起来
mode = 'idle'; b.target = BASE; b.k = 170; b.c = 8.5; b.st = 0;
kick(420); ripple(b.x, b.y, .7); nextKick = 1.8 + Math.random();
}
}
if (mode !== 'travel') b.st *= Math.exp(-dt * 8);
const a = -b.k * (b.amp - b.target) - b.c * b.vel;
b.vel += a * dt; b.amp += b.vel * dt;
if (b.amp < 0) { b.amp = 0; b.vel *= -.3; }
for (const r of ripples) r.age += dt;
while (ripples.length && ripples[0].age > 2) ripples.shift();
}
function draw() {
if (!ready) return;
gl.uniform4f(uB, b.x, b.y, RAD * (1 + b.amp / 120), b.amp);
gl.uniform3f(uD, b.sx, b.sy, b.st);
const arr = new Float32Array(16);
ripples.forEach((r, i) => arr.set([r.x, r.y, r.age, r.s], i * 4));
gl.uniform4fv(uR, arr);
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.__tap = (x, y) => tap(x, y);
window.__bump = () => ({ x: b.x, y: b.y });
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>