demo/bump v4:宝宝活在整页底下,滑到哪个模块就在哪一屏顶出小帐篷

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 21:08:44 +00:00
parent ec97586afc
commit ec7b6dbe7f

View File

@@ -14,16 +14,16 @@
· 手指戳上去 = 肚皮被按下去一个窝,松手后 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}
.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}
.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;
@@ -46,31 +46,32 @@
</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>
<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>
<img class="below" src="below.jpg" alt="">
</div>
<canvas id="cv"></canvas>
<script>
(function () {
const W = 375, H = 270, SC = 3; // 头图逻辑尺寸(与首页一致),纹理按 3 倍画
const W = 375, H = 270, BH = 2551, HP = H + BH; // 头图 375x270(与首页一致)+ 下面模块 375x2551 = 整页
const REC = /[?&]rec=1/.test(location.search);
const hero = document.getElementById('hero'), cv = document.getElementById('cv');
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) { hero.classList.add('nogl'); return; }
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; // 逻辑尺寸 375x270
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
@@ -105,10 +106,9 @@ 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)));
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;
@@ -142,20 +142,22 @@ 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'), uF = U('F'), uK = U('K'), uG = U('G'), uP = U('P'), uJ = U('J'), uT = U('Tm');
gl.uniform2f(uS, W, H);
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 = W * SC; tc.height = H * SC;
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' };
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() {
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);
@@ -196,7 +198,10 @@ void main(){
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);
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 => {
@@ -208,16 +213,19 @@ void main(){
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; });
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, 3), r = hero.getBoundingClientRect();
cv.width = Math.round(r.width * dpr); cv.height = Math.round(r.height * dpr);
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);
@@ -243,14 +251,17 @@ void main(){
// 一半以上的概率专挑字和卡片顶(大字「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 (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 (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: 300, y: 120 };
return { x: 190, y: (y0 + y1) / 2 };
}
function appear() {
const key = ORDER[seq++ % ORDER.length], P = PARTS[key];
@@ -295,16 +306,27 @@ void main(){
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);
page.addEventListener('pointerdown', e => {
const r = page.getBoundingClientRect();
press((e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.width * W, true);
});
const up = () => press(0, 0, false);
hero.addEventListener('pointerup', up); hero.addEventListener('pointercancel', up);
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;
@@ -344,6 +366,7 @@ void main(){
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);
}
@@ -354,7 +377,7 @@ void main(){
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.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); };