demo/bump v2:头图改成妈妈肚皮,小脚印时隐时现,戳中就溜,按压肉感回弹

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:59:27 +00:00
parent c692d25576
commit 6f42ee1499
4 changed files with 154 additions and 109 deletions

BIN
demo/bump/belly.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
demo/bump/belly_bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -3,13 +3,15 @@
<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 逐帧驱动(生成动图用)。 -->
<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}
@@ -26,6 +28,7 @@
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%);
@@ -36,11 +39,11 @@
<body>
<div class="page">
<div class="hero" id="hero">
<img src="hero.jpg" alt="">
<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>
<div class="hint"><span id="hint">宝宝在里面踢你呢,<b>戳一下小脚丫</b></span></div>
<img class="below" src="below.jpg" alt="">
</div>
<script>
@@ -58,52 +61,70 @@ 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 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 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);
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(6.,-12.,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(2.5,-3.5,sd)*(.8-.25*cu*w));
}
return h;
return F.w*max(hs,ht);
}
float hgt(vec2 p){return bump(p)+wave(p);}
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 foot(p)+dent(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 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.);
q-=(p-F.xy)*.10*fh; // 脚印顶起处微微放大
q=mix(p,q,m);
vec3 c=texture2D(T,clamp(q/S,0.,1.)).rgb;
// 只在橙色头图区域生效(下缘弧线外是页面底色,不该被打光)
vec3 n=normalize(vec3(-g*1.1,1.));
vec3 n=normalize(vec3(-g*.9,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);
c+=vec3(1.,.93,.88)*max(dif,0.)*.34;
c*=1.+min(dif,0.)*vec3(.42,.55,.6); // 背光侧偏暖红,像皮肤
// 脚印四周一圈很淡的凹陷(皮被撑起,边上被拉紧)
float rim=smoothstep(.0,.25,fh)*(1.-smoothstep(.25,.7,fh));
c*=1.-.05*rim*smoothstep(2.,8.,F.w);
// 按下去的窝:窝底暗一点、偏红一点(像压着肉)
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.)*pow(max(dot(n,Hh),0.),60.)*.35*smoothstep(.5,4.,h); // 果冻感高光
c+=vec3(1.,.9,.85)*smoothstep(2.,22.,h)*.06; // 顶部微微透亮
c+=vec3(1.,.97,.95)*pow(max(dot(n,Hh),0.),36.)*.22*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; }
@@ -113,7 +134,7 @@ void main(){
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');
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;
@@ -127,7 +148,7 @@ void main(){
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
ready = true; window.__ready = true;
};
img.src = 'hero.jpg';
img.src = 'belly.jpg';
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 3), r = hero.getBoundingClientRect();
@@ -136,81 +157,104 @@ void main(){
}
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 = [];
/* ---------- 物理:都是阻尼弹簧 ---------- */
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;
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 };
for (let i = 0; i < 40; i++) {
// 落在右半边空白肚皮上(原来画宝宝的地方),偶尔窜到左下角;不压在大字和胶囊上
const x = 40 + Math.random() * 300, y = 78 + Math.random() * 108;
if (x < 225 && y < 188) continue;
if (Math.hypot(x - ft.x, y - ft.y) > 70) 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';
}
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);
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) < 46) {
// 戳中小脚丫:嗖地缩回去,往远离手指的方向溜
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();
tap((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H);
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 === '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 === '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 !== '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();
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;
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);
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);
}
@@ -218,8 +262,9 @@ void main(){
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.__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);