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

374 lines
21 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 回弹(欠阻尼弹簧)+ 周围一起晃;
· 戳中鼓起处 → 被摁回去,往远离手指的方向溜走,过一两秒换个部位、换个地方再顶出来;
· 头图下方「找找宝宝」收集条:戳回过哪个部位就点亮哪个。
下面的卡片是首页截图(静态),只为给头图一个真实语境。
?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}
/* 头图下缘的「找找宝宝」收集条:和首页「记一下」同一套白色玻璃胶囊语言 */
.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">
<div class="hero" id="hero">
<img src="belly.jpg" alt="">
<canvas id="cv"></canvas>
<i class="finger" id="finger"></i>
</div>
<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>
<img class="below" src="below.jpg" alt="">
</div>
<script>
(function () {
const W = 375, H = 270, SC = 3; // 头图逻辑尺寸(与首页一致),纹理按 3 倍画
const REC = /[?&]rec=1/.test(location.search);
const hero = document.getElementById('hero'), 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) { 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 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=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 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 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, H);
/* ---------- 纹理:canvas 现场合成,宝宝卡的悄悄话会变 ---------- */
const tc = document.createElement('canvas'); tc.width = W * SC; tc.height = 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' };
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() {
cx.setTransform(SC, 0, 0, SC, 0, 0);
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);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, tc);
}
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();
(document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { compose(); window.__ready = true; });
}
};
im.src = SRC[k];
});
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);
/* ---------- 宝宝的各个部位:形状不同,动法不同 ---------- */
// 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() {
for (let i = 0; i < 40; i++) {
let x, y;
if (Math.random() < .7) { 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 = 220 + Math.random() * 130; y = 70 + Math.random() * 120; }
if (Math.hypot(x - ft.x, y - ft.y) > 80) return { x, y };
}
return { x: 300, y: 120 };
}
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;
}
}
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, 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);
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 / 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>