创意预览页:首页头图被宝宝从里面顶起来(demo/bump,不进正式首页)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:12:21 +00:00
parent cec4be248e
commit b017cccfae
3 changed files with 240 additions and 0 deletions

BIN
demo/bump/below.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

BIN
demo/bump/hero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

240
demo/bump/index.html Normal file
View File

@@ -0,0 +1,240 @@
<!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>