397 lines
24 KiB
HTML
397 lines
24 KiB
HTML
<!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 回弹(欠阻尼弹簧)+ 周围一起晃;
|
||
· 戳中鼓起处 → 被摁回去,往远离手指的方向溜走,过一两秒换个部位、换个地方再顶出来;
|
||
· 头图下方「找找宝宝」收集条:戳回过哪个部位就点亮哪个。
|
||
v4:宝宝活在整个页面底下——头图 + 下面所有模块(首页截图)合成一张整页纹理,WebGL 画布固定铺满屏幕,
|
||
按滚动位置取当前这一屏来画;滑到别的模块停下来,宝宝会在这一屏里慢慢顶一下,把卡片撑成小帐篷。
|
||
?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}
|
||
.page img{display:block;width:100%} /* 真实排版用的静态图:撑出页面高度,WebGL 起不来时直接看它 */
|
||
/* 整页的「肚皮」:固定铺满一屏,按滚动位置画当前这一屏 */
|
||
#cv{position:fixed;top:0;left:50%;width:min(100vw,480px);height:100%;transform:translateX(-50%);pointer-events:none;z-index:2;opacity:0}
|
||
#cv.on{opacity:1}
|
||
/* 头图下缘的「找找宝宝」收集条:和首页「记一下」同一套白色玻璃胶囊语言 */
|
||
.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">
|
||
<img src="belly.jpg" alt="">
|
||
<img src="below.jpg" alt="">
|
||
<i class="finger" id="finger"></i>
|
||
<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>
|
||
</div>
|
||
<canvas id="cv"></canvas>
|
||
<script>
|
||
(function () {
|
||
const W = 375, H = 270, BH = 2551, HP = H + BH; // 头图 375x270(与首页一致)+ 下面模块 375x2551 = 整页
|
||
const REC = /[?&]rec=1/.test(location.search);
|
||
const page = document.querySelector('.page'), 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) return;
|
||
// 整页纹理尽量按 3 倍画,但不超过显卡纹理上限和 canvas 面积上限(iOS 约 1600 万像素)
|
||
const SC = Math.min(3, gl.getParameter(gl.MAX_TEXTURE_SIZE) / HP, Math.sqrt(16e6 / (W * HP)));
|
||
|
||
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; // 整页逻辑尺寸 375xHP
|
||
uniform vec2 O; // 当前这一屏:滚动位置 y、屏幕高度(逻辑 px)
|
||
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=vec2(v.x*S.x,O.x+v.y*O.y);
|
||
if(p.y<0.||p.y>S.y){gl_FragColor=vec4(.973,.953,.937,1.);return;} // iOS 橡皮筋拉出页面外
|
||
float m=1.; // 整页都是肚皮
|
||
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 uO = U('O'), 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, HP);
|
||
|
||
/* ---------- 纹理:canvas 现场合成,宝宝卡的悄悄话会变 ---------- */
|
||
const tc = document.createElement('canvas'); tc.width = Math.floor(W * SC); tc.height = Math.floor(HP * SC);
|
||
const hc = document.createElement('canvas'); hc.width = tc.width; hc.height = Math.ceil(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', below: 'below.jpg' };
|
||
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(full) {
|
||
cx.setTransform(SC, 0, 0, SC, 0, 0);
|
||
if (full) cx.drawImage(IMG.below, 0, H, W, BH);
|
||
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);
|
||
if (full) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, tc); return; }
|
||
// 悄悄话变了:只把头图那一截重新传上去,整页纹理很大,别每次全传
|
||
const hx = hc.getContext('2d'); hx.drawImage(tc, 0, 0);
|
||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGB, gl.UNSIGNED_BYTE, hc);
|
||
}
|
||
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(true); cv.classList.add('on');
|
||
(document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { compose(true); window.__ready = true; });
|
||
}
|
||
};
|
||
im.src = SRC[k];
|
||
});
|
||
|
||
// 页面 px ↔ 逻辑 px;当前这一屏在整页里的位置
|
||
let K = 1;
|
||
const view = () => { const r = page.getBoundingClientRect(); K = r.width / W; return { y: -r.top / K, h: cv.clientHeight / K }; };
|
||
function resize() {
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2); // 整屏都要算,最多 2 倍
|
||
cv.width = Math.round(cv.clientWidth * dpr); cv.height = Math.round(cv.clientHeight * 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() {
|
||
const vw = view(), y0 = Math.max(vw.y + 60, 20), y1 = Math.min(vw.y + vw.h - 70, HP - 40);
|
||
const spots = TEXT_SPOTS.filter(s => s[1] > y0 && s[1] < y1);
|
||
for (let i = 0; i < 40; i++) {
|
||
let x, y;
|
||
if (spots.length && Math.random() < .7) { const s = spots[Math.floor(Math.random() * spots.length)]; x = s[0] + (Math.random() - .5) * 16; y = s[1] + (Math.random() - .5) * 10; }
|
||
else { x = 50 + Math.random() * 275; y = y0 + Math.random() * Math.max(y1 - y0, 1); }
|
||
if (Math.hypot(x - ft.x, y - ft.y) > 80) return { x, y };
|
||
}
|
||
return { x: 190, y: (y0 + y1) / 2 };
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
page.addEventListener('pointerdown', e => {
|
||
const r = page.getBoundingClientRect();
|
||
press((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.width * W, true);
|
||
});
|
||
page.addEventListener('pointerup', () => press(0, 0, false));
|
||
// 手指一滑变成滚动:浏览器会发 pointercancel,这时窝悄悄平回去,不弹
|
||
page.addEventListener('pointercancel', () => { if (pk.held) { pk.held = false; pk.d.to = 0; pk.d.k = 300; pk.d.c = 34; } });
|
||
|
||
let lastY = 0, still = 0;
|
||
function step(dt) {
|
||
t += dt;
|
||
// 滑动停下来 0.35 秒,看看宝宝在不在这一屏里;不在就让它挪过来,在这一屏里慢慢顶一下
|
||
const vw = view();
|
||
if (Math.abs(vw.y - lastY) > 1.5) { still = 0; lastY = vw.y; }
|
||
else if (still < .35 && (still += dt) >= .35) {
|
||
const fy = ft.y + ft.oy, seen = fy > vw.y + 30 && fy < vw.y + vw.h - 30;
|
||
if (!seen) {
|
||
if (mode === 'show' || mode === 'hide') { ft.h.v = 0; ft.h.vel = 0; mode = 'wait'; wait = .5 + Math.random() * .6; }
|
||
else if (mode === 'wait') wait = Math.min(wait, .5 + Math.random() * .6);
|
||
}
|
||
}
|
||
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);
|
||
const vw = view(); gl.uniform2f(uO, vw.y, vw.h);
|
||
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 * K + 'px'; finger.style.top = y * K + 'px';
|
||
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>
|