Compare commits
241 Commits
bf43084afe
...
48773baa42
| Author | SHA1 | Date | |
|---|---|---|---|
| 48773baa42 | |||
| 495c6a3dd2 | |||
| ae8324467c | |||
| c911f63ee5 | |||
| 835bd39a2d | |||
| d6904f364a | |||
| 15c2fb7142 | |||
| ca4e22a729 | |||
| 28fc5ff9aa | |||
| 65f1603082 | |||
| dc156b7a6a | |||
| ce544f1d8a | |||
| 26bff164c3 | |||
| 323acc3dc3 | |||
| 45bacdd1c2 | |||
| 57e845eb2b | |||
| e9cfa10fca | |||
| 4c2bba9dc5 | |||
| 4cb6a4a9b9 | |||
| 20317c94ab | |||
| 62db1a1463 | |||
| f900c018bc | |||
| 6074193f7c | |||
| 531e406f14 | |||
| f733855095 | |||
| 8f645186a1 | |||
| 5f62d32ba2 | |||
| 14cd1e7ad1 | |||
| de68ae20d4 | |||
| 19525a6f68 | |||
| 3caee8e94b | |||
| 8993a86490 | |||
| bc4f5b38e6 | |||
| e35218690e | |||
| 4c31cb1c17 | |||
| 86a5b608bd | |||
| b87afd1b27 | |||
| c957be3a77 | |||
| da4560ce0e | |||
| 3969aecdc0 | |||
| 914c3e1bed | |||
| 98a948db39 | |||
| 2b15f78706 | |||
| 7e1381f70c | |||
| 89567e3649 | |||
| e3774522ce | |||
| 6ab8c1fa17 | |||
| 26f6e90eaa | |||
| 080d0481e4 | |||
| 4d412f0e60 | |||
| 038207d585 | |||
| d01a617ec5 | |||
| 62d885ab72 | |||
| 72a7e4b935 | |||
| dca1b69eff | |||
| 0f73b85f10 | |||
| 55e293874f | |||
| a031686ec0 | |||
| f3af6bb839 | |||
| 0ddd6348fb | |||
| bb53087f7d | |||
| 72a190fe71 | |||
| 1637529e3d | |||
| e4d0c7c5ae | |||
| 2a95a5f3b9 | |||
| 6c656ecc28 | |||
| 3a3ea0a393 | |||
| ca164bc2f8 | |||
| e718e8298d | |||
| 51b1e392aa | |||
| 966efb17a1 | |||
| 3043e37fb5 | |||
| f737d1f18d | |||
| cc51237dff | |||
| 5be1c15154 | |||
| e857826184 | |||
| ec7b6dbe7f | |||
| ec97586afc | |||
| 83611d82c5 | |||
| cd76d36531 | |||
| 6f42ee1499 | |||
| c692d25576 | |||
| b017cccfae | |||
| cec4be248e | |||
| 8256fed48b | |||
| 504ea8eabd | |||
| e4df5dbdcc | |||
| c248cb8122 | |||
| 5608b389be | |||
| f4932d3d06 | |||
| fd7ce776cc | |||
| 65412829ed | |||
| cb399c15a6 | |||
| 2ed5ee279f | |||
| c4882a7c56 | |||
| d414c32c50 | |||
| ca9e2c1d07 | |||
| ebaee6b834 | |||
| 2033809ff7 | |||
| e2497c40d6 | |||
| 0d1c62c37e | |||
| 3db0df5199 | |||
| 3ae3b673e3 | |||
| 931c3215e0 | |||
| 7a55dc9423 | |||
| 2eafc08c74 | |||
| 26a13076fd | |||
| abf5145e36 | |||
| 84720fcee0 | |||
| 572cb996ed | |||
| fd538eb6fb | |||
| f5f597ed1d | |||
| 25f7b76057 | |||
| 7f20aef305 | |||
| 420b297e7e | |||
| ffd0887840 | |||
| b05faea2a6 | |||
| 8f01f24e24 | |||
| 0994781f1e | |||
| 8f4f77acee | |||
| d22c18089c | |||
| 710cd83529 | |||
| 3aa972fd6a | |||
| 3021a2821f | |||
| 05db23eba1 | |||
| 914f6ea785 | |||
| 26779c207e | |||
| 7a22948e29 | |||
| 00a2df52cc | |||
| 7373930f3f | |||
| e8f31c65f0 | |||
| 6b26d5ccbe | |||
| 6e55245919 | |||
| d62337953f | |||
| 2e2986dc5c | |||
| 0f02c56433 | |||
| cfb87f27de | |||
| 16f3b77841 | |||
| 6af3d5e80c | |||
| bb87127fa8 | |||
| a68f39040b | |||
| 534ccc3ca6 | |||
| 911cd0338b | |||
| 885657fe2b | |||
| 3cff549288 | |||
| 7b174623d3 | |||
| e45306f0dd | |||
| e70e4cb91b | |||
| a9581b64a1 | |||
| d75d7b7023 | |||
| acc8301b20 | |||
| c4c4d8ceac | |||
| fb116ddb5e | |||
| 9095ce6600 | |||
| 5df6d7638c | |||
| 22b2d1e058 | |||
| e065a47ccb | |||
| 8b749633d6 | |||
| a748d2ed03 | |||
| 9279fd921b | |||
| d72b40f0d7 | |||
| 44f0cfeec1 | |||
| fcac03ea59 | |||
| 39c27e4bf2 | |||
| 18feb14b4b | |||
| 5dc647af3c | |||
| 53bb0f6bcb | |||
| 32ddb04b71 | |||
| add92309b2 | |||
| 7ae14d6df9 | |||
| ca2d69f6a5 | |||
| 5601f44e29 | |||
| 7934b91865 | |||
| 9a1ff38d94 | |||
| a688ce3a81 | |||
| 5da526fc5c | |||
| 49cc7973d2 | |||
| 3f115bd37f | |||
| da995ae540 | |||
| 96b04877de | |||
| f1ab738865 | |||
| ac72585ecb | |||
| 782f0b8c58 | |||
| 8ca26162ce | |||
| df7c071d3c | |||
| b3b768722a | |||
| 19e0167df0 | |||
| eb1c1b51f1 | |||
| f0428fea99 | |||
| 0e0c23c806 | |||
| 6bf7fa4058 | |||
| ed05acdf55 | |||
| 9ca25d05de | |||
| e7624ca484 | |||
| 906da99a6e | |||
| b05862496d | |||
| ff633c3ad5 | |||
| ae16994bcc | |||
| e6ac933393 | |||
| b83faa77c2 | |||
| fcabec28dd | |||
| 3eea304818 | |||
| 3de3bbdccc | |||
| a90f348061 | |||
| 3394a1f6b0 | |||
| df95d1fae0 | |||
| 4637412e26 | |||
| d270f09bf7 | |||
| 555012f248 | |||
| ec87d36db2 | |||
| 1fa565ff2f | |||
| 15dabcea5f | |||
| 9013a84dea | |||
| 70c9786f15 | |||
| c414a7ff8f | |||
| 4c550882fe | |||
| da740ba07e | |||
| 555befc08b | |||
| 53b208e3a5 | |||
| a7e71fda2d | |||
| d164688743 | |||
| 37bdfa6f24 | |||
| 4a7eb92864 | |||
| 18eecdb3a2 | |||
| c73a1419ec | |||
| 8c25d5fc74 | |||
| 488989d82c | |||
| 05ce51300e | |||
| 9a1ad1fb93 | |||
| 9ea9f3c50f | |||
| 62b23af323 | |||
| bca92312f8 | |||
| 983191a752 | |||
| d5ee5c8654 | |||
| 9bb3c5a533 | |||
| f21af1e25b | |||
| b69a02e570 | |||
| d2c04194fa | |||
| a0119760ed | |||
| 44e20cb0c3 | |||
| 12fcc037dc |
42
AGENTS.md
@@ -7,8 +7,9 @@
|
||||
|
||||
## 1. 这是什么
|
||||
|
||||
微信小程序里的**首页 H5**:小程序首页壳(`crmeb/pages/index/index.vue`)用 web-view 硬编码加载
|
||||
`https://mp.yizhezhe.com/mama-box/`,用户看到的就是本站。它同时是 **H5 应用宫格**入口(30+ 第三方应用的门面)。
|
||||
微信小程序里的**首页 H5**:小程序首页壳(`crmeb/pages/index/index.vue`)用 web-view 加载本站。
|
||||
⚠️ 2026-10-09 起首页地址**由 box_app 后台动态配置**(`GET /api/mp/public/home/config` → `result.url`),
|
||||
不再硬编码;当前指向 `https://mp.yizhezhe.com/mama-box-preview/`(用户确认),默认/兜底仍是 `/mama-box/`。它同时是 **H5 应用宫格**入口(30+ 第三方应用的门面)。
|
||||
|
||||
- 纯静态站点:**无框架、无构建、无测试**,`index.html` 一个页面用 `.screen` 切换多个屏。
|
||||
- 仓库 `jone/mama-box`;本地 `/home/jone/workspace/mama-box`;生产 `/opt/app/mama-box`(nginx alias 托管,非 box_app 挂载)。
|
||||
@@ -80,6 +81,43 @@ nginx:`root /opt/app` + `Cache-Control: no-cache` + `etag on` —— 缓存靠
|
||||
⚠️ 卡是固定高度 bento,只有 kick / weight 有位置放第二行(`.mc-extra`);给 fweight/cal/curve 加第二行前
|
||||
先重排那笔高度账(`158+10+64=232`,见 `mama-box.css`)。
|
||||
|
||||
12. **⚠️ 第 2 条「不造假数据」的唯一登记例外:首页「此刻,孕妈们在做什么」卡(2026-10-01 起)**
|
||||
- 内容:该卡的总人数 / 今日三项 / 匿名动态在**模拟阶段**为 box_app 按规则生成的模拟数字(`GET /pulse`,确定性哈希、不落库、不进任何报表)。
|
||||
- 决策人:产品负责人(用户本人),2026-10-01 拍板「前期先用模拟数据,要显示人多;正式页先用模拟,真实数据跟上后替换」;已书面告知虚假宣传风险(《反不正当竞争法》第 8 条)。
|
||||
- 期限:**最晚 2026-12-31** 复核(≤ 3 个月);真实数据接通后在后台把 `MamaBoxConfig.pulseMode` 改成 `real`(或 `off`),无需发版。
|
||||
- 2026-10-02 扩展:卡内新增「同周悄悄话」(`GET/POST /whisper`、`POST /whisper/hug`)。mock 模式下真实悄悄话与 box_app 生成的**模拟悄悄话**(id 以 `m_` 开头,确定性哈希、不落库)混排,跟随同一个 `pulseMode` 开关;real 只出真实的。⚠️ 模拟句是「假装有人说过的话」,比模拟数字更敏感,切 real 时务必一并去掉。
|
||||
- ⚠️ 悄悄话后端(`models/mamaBoxWhisper.py`、`services/mama_box_whisper_serv.py`、`mama_box_router.py` 与 `models/__init__.py` 的改动)是 2026-10-02 **直接改在服务器 `/opt/app/box_app` 上的,没进 git**(当时的账号无 box_app 推送权限;改前备份在 `/opt/app/box_app_bak_202610021000`)。服务器下次 `git reset --hard` 前,必须由有权限的人先把这几个文件提交进仓库,否则接口会丢。
|
||||
- 边界:**只限这张卡**。孕期盲盒的数字(已收集、本周开盒人数)一律真实;任何别处都不得引用这里的模拟数字,前端也不得在接口失败时自造数字兜底。
|
||||
|
||||
13. **跳其它 H5 应用 / 调第三方接口一律同源:只写相对路径 `/<slug>/`,不写域名**(2026-10-09 用户定)
|
||||
- 背景:小程序 web-view 只放行业务域名 `mp.yizhezhe.com`,直连 `taidong.douba.top`(或任何 `*.douba.top`)会被拦;
|
||||
首页直接 `fetch` 别的域名还会跨域。所有第三方 H5 都由 mp 前置机(8.145.39.61)nginx 反代过去,路径不变。
|
||||
- 写法:`const XXX_URL = '/<slug>/';`、接口 `'/<slug>/api/...'`。确实需要完整地址的地方(如 `return_url`、上报 last_app)
|
||||
用 `location.origin` 或 `new URL(path, location.origin)` 拼,**也不要手写域名**;万不得已写死时只能是 `https://mp.yizhezhe.com/...`。
|
||||
- **新接一个应用前,先 `curl -s -o /dev/null -w '%{http_code}' https://mp.yizhezhe.com/<slug>/` 测:**
|
||||
- 200 → 直接用相对路径;
|
||||
- **404 → 说明 mp 还没反代这个目录**(不是「小程序白名单没加」,别等别人)。去 mp 的 `/etc/nginx/conf.d/default.conf`
|
||||
把 slug 加进「第三方 H5 应用统一反代」那条 `location ~ ^/(...)/`(页面和 `/api` 都在目录下时一条就够),
|
||||
再加一行 `location = /<slug> { return 301 /<slug>/; }`。改前 `cp -a` 备份,`nginx -t` 通过才 `nginx -s reload`,
|
||||
之后对比 `mp` 和 `taidong` 两边同一路径的内容应一致,并复查 `/mama-box/`、`/mama-box-preview/`、`/api/mp/...` 仍 200。
|
||||
⚠️ 这台机同时跑正式站,改 nginx 前先告诉用户。
|
||||
- 应用的页面或接口不在 `/<slug>/` 目录下(比如接口在根路径 `/api/xxx`)→ 别在 mp 上加兜底,先找该应用的会话改成目录内。
|
||||
- 历史:2026-10-09 一次补了 `chengzhang-dangan`、`muying-xiaoxiao`、`yinger-fang`、`doudou-duihuan` 四个(备份 `default.conf.bak.4proxy-20261009222036`),
|
||||
首页这 4 个入口同日改为相对路径(v26100906)。例外:微信 JS SDK `res.wx.qq.com` 是官方脚本,不在此列。
|
||||
|
||||
14. **任何 H5 应用「返回首页」一律调 `wxAuth.goHome()`,禁止写死首页地址**(2026-10-09 用户定)
|
||||
- 原因:首页地址是后台动态配置的(见第 1 节),写死 `https://mp.yizhezhe.com/mama-box/` 会把用户送回旧首页。
|
||||
`goHome()`(`js/wx-auth.js` ⑦)先拉 `/api/mp/public/home/config` 再跳,自动续接 openid/unionid;1.5s 超时或接口坏 → 回退 `/mama-box/`。
|
||||
- 写法:`if (window.wxAuth && typeof wxAuth.goHome === 'function') { wxAuth.goHome(); return; }`,
|
||||
原来拼身份的写死地址只允许留作 **wxAuth 没加载时的兜底**。
|
||||
⚠️ `goHome()` 的身份只认 URL + sessionStorage(`identity()`)。应用若把身份从 URL 抹掉、存在 localStorage 等别处,
|
||||
站内跳几次后回首页会带空身份 → 调 `goHome()` 前先 `wxAuth.rememberIdentity(openid, unionid)`(数胎动 2026-10-10 首个这么做)。`wx-auth.js` 用绝对地址 `https://mp.yizhezhe.com/mama-box/js/wx-auth.js` 引入。
|
||||
- 首页带 `return_url` 跳过去的应用(档案 / 婴儿房 / 商城)返回时回 `return_url` 即可(它就是当时的首页地址);**没带 `return_url` 时的兜底也要走 `goHome()`**,别 `history.back()`。
|
||||
- 本仓库自己:首页内部不要用 `goHome()`(那是给别的应用回来用的);`wx-auth.js` 里 `DEFAULT_HOME` 是唯一允许写死首页的地方。
|
||||
- 2026-10-09 普查(48 个目录):主返回写死 `/mama-box/`、已通知各自会话改的有 数胎动 taidong(预览版已改,正式待批)、孕检日历 chanjian-rili(10-10 已上线,复扫通过)、待产清单 daichan-qingdan、
|
||||
体重 yunqi-tizhong、血糖 yunqi-xuetang、数宫缩 gongsuo-pinlv;laixin、laixin/youju、yunqi-nengbuchi、shengao-tizhong 已先调 goHome(写死仅兜底)。
|
||||
新接应用前照此检查:`curl -s https://mp.yizhezhe.com/<slug>/ | grep -n "mama-box/'\|goHome"`。
|
||||
|
||||
## 5. 踩坑
|
||||
|
||||
- **不要给别的应用追加 `#openid`**:部分应用自带 hash 路由(如 `/baobao-yingji`),追加 hash 会被当非法路由 → 页面未找到。身份一律走 query(`withIdentity()` 已封装)。
|
||||
|
||||
1
assets/data/preg-tips.json
Normal file
BIN
assets/fonts/slogan-xiaowei.d5f456a3.woff2
Normal file
BIN
assets/img/0022b97f8cc0.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
assets/img/0780b4698253.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/img/0dcddb83b038.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
assets/img/0fa71babe9a2.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
assets/img/2314f334e4d0.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/img/250bb167b0f1.gif
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/img/251ec778dc15.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/img/2a88a5a7c1c3.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 21 KiB |
1
assets/img/3c4cf0da356c.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="8 8 32 32"><circle cx="24" cy="24" r="13.5" fill="none" stroke="#fff" stroke-width="3.2"/><path d="M24 24L30.5 17.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/><circle cx="24" cy="24" r="3" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 272 B |
1
assets/img/429ffcba0d6e.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#FFA8C5"/><stop offset="1" stop-color="#EE6A98"/></linearGradient></defs><rect width="512" height="512" rx="120" fill="url(#g)"/><g fill="#fff"><rect x="96" y="96" width="140" height="140" rx="38"/><rect x="276" y="96" width="140" height="140" rx="38" opacity=".55"/><rect x="96" y="276" width="140" height="140" rx="38" opacity=".55"/><rect x="276" y="276" width="140" height="140" rx="38"/></g><g fill="#EE6A98"><rect x="148" y="122" width="36" height="22" rx="8"/><rect x="138" y="140" width="56" height="74" rx="20"/></g><path d="M346 300c-30 0-44 28-44 48 0 32 26 48 44 48s44-16 44-48c0-20-14-48-44-48z" fill="#EE6A98"/><circle cx="330" cy="292" r="14" fill="#EE6A98"/><circle cx="362" cy="292" r="14" fill="#EE6A98"/></svg>
|
||||
|
After Width: | Height: | Size: 878 B |
BIN
assets/img/450581c2f05f.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
assets/img/494179795c67.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/img/507e24e95ef0.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
assets/img/51e99b3060e2.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
assets/img/5ae2e7f617e2.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/img/5aec5c71b009.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
assets/img/5b39ca21a42e.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
assets/img/610c4d92c16d.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
assets/img/681b0bdb885e.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
assets/img/6a17a98c52b8.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/img/6a268e7269b7.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
1
assets/img/6add0c9e5856.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="8.5 8 35 35" fill="#fff"><ellipse cx="26" cy="30" rx="9" ry="11.5" transform="rotate(10 26 30)"/><circle cx="14.5" cy="17" r="3.2"/><circle cx="21.5" cy="13.5" r="2.8"/><circle cx="28" cy="12.5" r="2.5"/><circle cx="34" cy="14" r="2.2"/><circle cx="38.5" cy="17.5" r="1.9"/></svg>
|
||||
|
After Width: | Height: | Size: 329 B |
BIN
assets/img/6bdabd9eecc5.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/img/6c5d82c9b093.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 45 KiB |
BIN
assets/img/7bf792b04123.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
assets/img/83f0ab30cf36.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/img/85da5aaebb4f.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/img/91f1472ee58a.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/img/a5dbb24c1ef2.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
1
assets/img/a693b92f3239.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="12 8 76 80"><defs><mask id="m"><rect x="0" y="0" width="100" height="100" fill="#fff"/><path d="M16 42H84" stroke="#000" stroke-width="4"/><path d="M37 60l8 8 17-17" fill="none" stroke="#000" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round"/><rect x="27" y="10" width="15" height="24" rx="7.5" fill="#000"/><rect x="58" y="10" width="15" height="24" rx="7.5" fill="#000"/></mask></defs><rect x="16" y="22" width="68" height="62" rx="14" fill="#fff" mask="url(#m)"/><rect x="30" y="12" width="9" height="18" rx="4.5" fill="#fff"/><rect x="61" y="12" width="9" height="18" rx="4.5" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 665 B |
BIN
assets/img/a6f52893ad1e.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
1
assets/img/ad7487f2e445.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 11 35 35"><path d="M9 31c3.6 0 4.2-13 7.6-13s4 13 7.6 13 4.2-13 7.6-13 4 13 7.6 13" fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/><circle cx="24" cy="38.5" r="2.3" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
BIN
assets/img/affcfc61f21f.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
assets/img/b0087c3c012a.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#FFC56B"/><stop offset="1" stop-color="#FF7E5C"/></linearGradient></defs><rect width="512" height="512" rx="120" fill="url(#g)"/><rect x="126" y="236" width="260" height="170" rx="26" fill="#fff"/><rect x="106" y="180" width="300" height="76" rx="24" fill="#fff"/><rect x="236" y="180" width="40" height="226" fill="#FFB25E"/><path d="M256 182c-28-62-92-66-92-30 0 26 46 32 92 30zM256 182c28-62 92-66 92-30 0 26-46 32-92 30z" fill="#fff"/><circle cx="388" cy="132" r="14" fill="#fff" opacity=".8"/><circle cx="132" cy="120" r="9" fill="#fff" opacity=".6"/></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
BIN
assets/img/b0e24b10aaee.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 48 KiB |
BIN
assets/img/b743ed8646d5.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/img/bddd81b7315d.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/img/c685f94d437c.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
assets/img/c6e9c5612d8e.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/img/cceeb3b3f4ef.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/img/d41305de1db9.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/img/d63b80896e8e.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/img/d75b2a87e805.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
assets/img/d953571ad48d.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/img/db2f99232b39.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/img/dc4c7eea1c50.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
assets/img/dcf2ae48e0ec.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/img/dd756c516072.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
5
assets/img/dou-coin.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
assets/img/f140f4367b48.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
1
assets/img/fc0ab3d2e21a.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 6 88 88"><defs><mask id="m"><rect x="0" y="0" width="100" height="100" fill="#fff"/><circle cx="50" cy="34" r="18" fill="#000"/><path d="M26 68H74" stroke="#000" stroke-width="3" stroke-linecap="round"/></mask></defs><rect x="10" y="10" width="80" height="80" rx="22" fill="#fff" mask="url(#m)"/><line x1="38.67" y1="36.00" x2="36.21" y2="36.43" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><line x1="38.89" y1="31.02" x2="36.48" y2="30.38" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><line x1="41.19" y1="26.61" x2="39.28" y2="25.00" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><line x1="45.14" y1="23.58" x2="44.08" y2="21.31" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><line x1="50.00" y1="22.50" x2="50.00" y2="20.00" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><line x1="54.86" y1="23.58" x2="55.92" y2="21.31" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><line x1="58.81" y1="26.61" x2="60.72" y2="25.00" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><line x1="61.11" y1="31.02" x2="63.52" y2="30.38" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><line x1="61.33" y1="36.00" x2="63.79" y2="36.43" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><path d="M50 38L50 24" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/><circle cx="50" cy="38" r="4" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/img/fe00c2bddf9b.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/img/ff112c222b1e.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/img/glass-noise.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/img/nursery-day-sky.jpg
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/img/nursery-day.jpg
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
assets/img/nursery-night-sky.jpg
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
assets/img/nursery-night.jpg
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
assets/img/qifu-tree.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
demo/_hero/below.jpg
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
demo/_hero/bg.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
demo/_hero/pill.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
demo/_hero/prog.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
demo/_hero/week.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
demo/bump/belly.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
demo/bump/belly_bg.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
demo/bump/below.jpg
Normal file
|
After Width: | Height: | Size: 372 KiB |
396
demo/bump/index.html
Normal file
@@ -0,0 +1,396 @@
|
||||
<!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>
|
||||
BIN
demo/bump/top.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
142
demo/countdown/index.html
Normal file
@@ -0,0 +1,142 @@
|
||||
<!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 创意预览(不进正式首页):「宝宝把倒计时改了」—— 设计为每天一次的小彩蛋。
|
||||
头图拆层同 demo/occupy(胶囊里的「67」单独用 HTML 渲染,才能做翻牌)。
|
||||
时间线:1s 后一只小胖手从羊水囊那边伸过来 → 手指按住「67」往下拨,个位 7 滚成 6(66)→
|
||||
手停住、冒个问号(发现不对)→ 偷偷往上拨回 67 → 手飞快缩回去 → 胶囊旁冒一句「我有点着急。」带一滴汗。
|
||||
正式上线时按北京时间「每天首次打开首页」播一次(localStorage 记日期);本预览页每次打开都播,并给「再看一次」。
|
||||
?rec=1:由 __set(t) 逐帧驱动(生成动图用)。 -->
|
||||
<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-wrap{position:relative;width:100%;overflow:hidden}
|
||||
.stage{position:absolute;left:0;top:0;width:375px;height:270px;transform-origin:0 0;overflow:hidden}
|
||||
.stage>*{position:absolute}
|
||||
.bg{left:0;top:0;width:375px;height:270px}
|
||||
.pill{left:14px;top:52px;width:159.69px;height:42px}
|
||||
.week{left:14px;top:86px;width:110.9px;height:70px}
|
||||
.prog{left:14px;top:150px;width:192px;height:36px}
|
||||
/* 「67」:十位固定,个位是一条竖排数字带,translateY 滚动 */
|
||||
.num{left:120.6px;top:63px;height:20px;transform-origin:50% 60%;display:flex;font:900 13.5px/20px -apple-system,"PingFang SC","Noto Sans SC",sans-serif;
|
||||
color:#FFF3C4;text-shadow:0 1px 3px rgba(120,30,10,.28)}
|
||||
.num .win{display:inline-block;height:20px;overflow:hidden;position:relative;width:.62em}
|
||||
.num .reel{display:flex;flex-direction:column;will-change:transform}
|
||||
.num .reel i{font-style:normal;height:20px;text-align:center}
|
||||
.num.hl{color:#fff}
|
||||
.hand{left:0;top:0;width:150px;height:46px;transform-origin:9px 14px;will-change:transform;
|
||||
filter:drop-shadow(0 0 .8px #fff) drop-shadow(0 0 .6px #fff) drop-shadow(0 3px 3px rgba(120,40,20,.28))}
|
||||
.q{left:0;top:0;font:900 16px/1 -apple-system,"PingFang SC",sans-serif;color:#fff;text-shadow:0 1px 3px rgba(120,30,10,.35);opacity:0}
|
||||
.bubble{left:152px;top:90px;padding:7px 12px 8px 12px;border-radius:4px 14px 14px 14px;background:#fff;white-space:nowrap;
|
||||
font-size:13px;font-weight:800;color:#D9583A;letter-spacing:.3px;box-shadow:0 6px 16px rgba(150,60,30,.22);
|
||||
transform-origin:0 0;opacity:0;display:flex;align-items:center;gap:5px}
|
||||
.bubble::after{content:"";position:absolute;left:8px;top:-5px;border:6px solid transparent;border-bottom-color:#fff;border-top:0}
|
||||
.bubble svg{width:12px;height:14px}
|
||||
.below{display:block;width:100%}
|
||||
.replay{position:absolute;left:50%;transform:translateX(-50%);z-index:5;border:0;padding:7px 16px;border-radius:999px;
|
||||
font:600 12.5px -apple-system,"PingFang SC",sans-serif;color:#C2563B;background:rgba(255,255,255,.85);
|
||||
-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 6px 16px rgba(180,80,50,.16),inset 0 0 0 1px #fff;
|
||||
opacity:0;pointer-events:none;transition:opacity .3s}
|
||||
.replay.on{opacity:1;pointer-events:auto}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="hero-wrap" id="wrap">
|
||||
<div class="stage" id="stage">
|
||||
<img class="bg" src="../_hero/bg.jpg" alt="">
|
||||
<img class="pill" src="../_hero/pill.png" alt="">
|
||||
<div class="num" id="num"><span>6</span><span class="win"><span class="reel" id="reel"><i>8</i><i>7</i><i>6</i></span></span></div>
|
||||
<img class="week" src="../_hero/week.png" alt="">
|
||||
<img class="prog" src="../_hero/prog.png" alt="">
|
||||
|
||||
<!-- 小胖手(手指朝左):前臂从右边伸进来,食指伸直,其余手指蜷着 -->
|
||||
<svg class="hand" id="hand" viewBox="0 0 150 46" aria-hidden="true">
|
||||
<path d="M150 14 L58 17 Q50 18 50 23 Q50 28 58 29 L150 33 Z" fill="#FFD6BF"/>
|
||||
<path d="M86 18.5 q2 4.5 0 9 M96 18.2 q2 4.8 0 9.6" stroke="#F4BCA0" stroke-width="1.8" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="44" cy="25" rx="16" ry="13" fill="#FFDCC8"/>
|
||||
<ellipse cx="38" cy="34" rx="7" ry="5" fill="#FFD2BA"/>
|
||||
<ellipse cx="47" cy="36" rx="6.5" ry="4.6" fill="#FFD2BA"/>
|
||||
<path d="M32 31 q-3 3 1 6" stroke="#F4BCA0" stroke-width="1.6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M33 19 L10 15" stroke="#FFDCC8" stroke-width="9" stroke-linecap="round"/>
|
||||
<ellipse cx="9.5" cy="14.5" rx="2.6" ry="2.2" fill="#FFF1E8"/>
|
||||
<path d="M44 13 q6 -4 10 2" stroke="#FFDCC8" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="q" id="q">?</div>
|
||||
|
||||
<div class="bubble" id="bubble">
|
||||
<span>我有点着急。</span>
|
||||
<svg viewBox="0 0 12 14" aria-hidden="true"><path d="M6 1 C6 1 1.5 7 1.5 9.3 A4.5 4.5 0 0 0 10.5 9.3 C10.5 7 6 1 6 1z" fill="#8FD0F2"/><ellipse cx="4.6" cy="9.4" rx="1.1" ry="1.6" fill="#fff" opacity=".8"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<button class="replay" id="replay">再看一次</button>
|
||||
</div>
|
||||
<img class="below" src="../_hero/below.jpg" alt="">
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const REC = /[?&]rec=1/.test(location.search);
|
||||
const $ = id => document.getElementById(id);
|
||||
const wrap = $('wrap'), stage = $('stage'), hand = $('hand'), reel = $('reel'), num = $('num'), q = $('q'), bubble = $('bubble'), replay = $('replay');
|
||||
function fit() {
|
||||
const w = Math.min(wrap.parentNode.clientWidth, 480), s = w / 375;
|
||||
stage.style.transform = 'scale(' + s + ')'; wrap.style.height = 250 * s + 'px';
|
||||
replay.style.top = 214 * s + 'px';
|
||||
}
|
||||
fit(); addEventListener('resize', fit);
|
||||
|
||||
const cl = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
|
||||
const seg = (t, a, b) => cl((t - a) / (b - a));
|
||||
const lerp = (a, b, u) => a + (b - a) * u;
|
||||
const eio = u => u < .5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
|
||||
const eo = u => 1 - Math.pow(1 - u, 3);
|
||||
const pop = u => u <= 0 ? 0 : 1 + Math.exp(-6 * u) * Math.sin(14 * u) * .35 - Math.exp(-14 * u);
|
||||
const T = 8.4;
|
||||
|
||||
// 指尖坐标(头图坐标系)。手的 SVG 指尖在 (9,14)
|
||||
const TIP_HOME = [236, 74], TIP_UP = [135, 61], TIP_DN = [135, 71];
|
||||
function frame(t) {
|
||||
let tip = TIP_HOME.slice(), ang = 0, digit = 1; // reel 位置:0=8,1=7,2=6
|
||||
// 1.0–1.25 从羊水囊边探出一点,停一下;1.25–1.9 伸到「67」上方
|
||||
if (t < 1.0) tip = [400, 74];
|
||||
else if (t < 1.25) tip = [lerp(400, 250, eo(seg(t, 1.0, 1.25))), 74];
|
||||
else if (t < 1.4) tip = [250, 74];
|
||||
else if (t < 1.95) { const u = eio(seg(t, 1.4, 1.95)); tip = [lerp(250, TIP_UP[0], u), lerp(74, TIP_UP[1], u) - Math.sin(u * Math.PI) * 6]; }
|
||||
// 1.95–2.35 往下拨:7 → 6
|
||||
else if (t < 2.35) { const u = eio(seg(t, 1.95, 2.35)); tip = [TIP_UP[0], lerp(TIP_UP[1], TIP_DN[1], u)]; digit = 1 + u; ang = -8 * u; }
|
||||
// 2.35–3.3 停住,看一眼……发现不对(问号、手抖两下)
|
||||
else if (t < 3.3) { tip = TIP_DN.slice(); digit = 2; ang = -8; const w = seg(t, 2.8, 3.25); tip[0] += Math.sin(w * Math.PI * 6) * 1.6 * (1 - w); }
|
||||
// 3.3–3.55 手指绕到下面,3.55–3.85 偷偷往上拨回 7
|
||||
else if (t < 3.55) { const u = eio(seg(t, 3.3, 3.55)); tip = [lerp(TIP_DN[0], TIP_DN[0] + 2, u), lerp(TIP_DN[1], TIP_DN[1] + 3, u)]; digit = 2; ang = lerp(-8, 6, u); }
|
||||
else if (t < 3.85) { const u = eio(seg(t, 3.55, 3.85)); tip = [TIP_DN[0] + 2, lerp(TIP_DN[1] + 3, TIP_UP[1], u)]; digit = 2 - u; ang = lerp(6, 10, u); }
|
||||
// 3.85–4.25 飞快缩回去
|
||||
else if (t < 4.3) { const u = seg(t, 3.9, 4.25); tip = [lerp(TIP_UP[0], 420, u * u), lerp(TIP_UP[1], 70, u)]; ang = lerp(10, 0, u); }
|
||||
else tip = [420, 70];
|
||||
|
||||
hand.style.transform = 'translate(' + (tip[0] - 9).toFixed(1) + 'px,' + (tip[1] - 14).toFixed(1) + 'px) rotate(' + ang.toFixed(1) + 'deg)';
|
||||
reel.style.transform = 'translateY(' + (-digit * 20).toFixed(2) + 'px)';
|
||||
num.classList.toggle('hl', t > 1.95 && t < 3.85);
|
||||
const hk = seg(t, 1.85, 2.05) * (1 - seg(t, 3.9, 4.15)); // 被拨的时候数字微微放大,好让人看见
|
||||
num.style.transform = 'scale(' + (1 + .22 * hk).toFixed(3) + ')';
|
||||
// 问号
|
||||
const qi = seg(t, 2.55, 2.75), qo = seg(t, 3.25, 3.4);
|
||||
q.style.opacity = Math.min(qi, 1 - qo);
|
||||
q.style.transform = 'translate(' + (TIP_DN[0] + 16) + 'px,' + (38 - qi * 4) + 'px) scale(' + (.6 + .4 * pop(qi)).toFixed(3) + ')';
|
||||
// 气泡 4.35 → 7.4
|
||||
const bIn = seg(t, 4.35, 4.85), bOut = seg(t, 7.2, 7.45);
|
||||
bubble.style.opacity = Math.min(cl(bIn * 3), 1 - bOut);
|
||||
bubble.style.transform = 'scale(' + (bIn > 0 ? Math.max(0, pop(bIn * 1.5)) * (1 - bOut * .2) : 0).toFixed(3) + ')';
|
||||
replay.classList.toggle('on', !REC && t > T - .4);
|
||||
}
|
||||
|
||||
if (REC) { window.__set = frame; window.__T = T; frame(0); window.__ready = true; return; }
|
||||
let start = performance.now();
|
||||
(function loop(now) { frame(Math.min((now - start) / 1000, T)); requestAnimationFrame(loop); })(start);
|
||||
replay.addEventListener('click', () => { start = performance.now() - 300; });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
211
demo/occupy/index.html
Normal file
@@ -0,0 +1,211 @@
|
||||
<!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 创意预览(不进正式首页):「宝宝今天占领首页」。
|
||||
头图拆成几层(背景 / 倒计时胶囊 / 「30周+3天」/ 进度条,均为首页预览版截图抠出来的透明层),
|
||||
卡通宝宝用代码画(SVG)。时间线:1s 后宝宝从右下角爬出来 → 把「30周+3天」顶歪 → 跳上去坐着、戴上小皇冠 →
|
||||
冒一句「今天这里归我管。」→ 几秒后跳下来爬走,数字弹回原位。
|
||||
整段由 frame(t) 一个纯函数按时间算出全部位置,?rec=1 时由 __set(t) 逐帧驱动(生成动图用)。 -->
|
||||
<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-wrap{position:relative;width:100%;overflow:hidden}
|
||||
.stage{position:absolute;left:0;top:0;width:375px;height:270px;transform-origin:0 0;overflow:hidden}
|
||||
.stage>*{position:absolute}
|
||||
.bg{left:0;top:0;width:375px;height:270px}
|
||||
.pill{left:14px;top:52px;width:159.69px;height:42px}
|
||||
.num{left:121.6px;top:63px;width:16px;height:20px;line-height:20px;font:900 13.5px/20px -apple-system,"PingFang SC","Noto Sans SC",sans-serif;
|
||||
color:#FFF3C4;text-shadow:0 1px 3px rgba(120,30,10,.28);text-align:center}
|
||||
.week{left:14px;top:86px;width:110.9px;height:70px;transform-origin:14px 60px;will-change:transform}
|
||||
.prog{left:14px;top:150px;width:192px;height:36px}
|
||||
.baby{filter:drop-shadow(0 0 .8px #fff) drop-shadow(0 0 .8px #fff) drop-shadow(0 0 .6px #fff) drop-shadow(0 3px 3px rgba(120,40,20,.3));will-change:transform;opacity:0}
|
||||
#crawl{width:104px;height:80px;left:0;top:0}
|
||||
#sit{width:74px;height:81px;left:0;top:0;transform-origin:50% 80%}
|
||||
.bubble{left:104px;top:30px;padding:7px 12px 8px;border-radius:14px 14px 14px 4px;background:#fff;white-space:nowrap;
|
||||
font-size:13px;font-weight:800;color:#D9583A;letter-spacing:.3px;box-shadow:0 6px 16px rgba(150,60,30,.22);
|
||||
transform-origin:0 100%;opacity:0}
|
||||
.bubble::after{content:"";position:absolute;left:-5px;bottom:4px;border:6px solid transparent;border-right-color:#fff;border-left:0}
|
||||
.below{display:block;width:100%}
|
||||
.replay{position:absolute;left:50%;transform:translateX(-50%);z-index:5;border:0;padding:7px 16px;border-radius:999px;
|
||||
font:600 12.5px -apple-system,"PingFang SC",sans-serif;color:#C2563B;background:rgba(255,255,255,.85);
|
||||
-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 6px 16px rgba(180,80,50,.16),inset 0 0 0 1px #fff;
|
||||
opacity:0;pointer-events:none;transition:opacity .3s}
|
||||
.replay.on{opacity:1;pointer-events:auto}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="hero-wrap" id="wrap">
|
||||
<div class="stage" id="stage">
|
||||
<img class="bg" src="../_hero/bg.jpg" alt="">
|
||||
<img class="pill" src="../_hero/pill.png" alt="">
|
||||
<div class="num">67</div>
|
||||
<img class="prog" src="../_hero/prog.png" alt="">
|
||||
<img class="week" id="week" src="../_hero/week.png" alt="">
|
||||
|
||||
<!-- 爬行宝宝(侧面,头朝左)。四肢用粗圆头线段画,好绕肩/髋转动 -->
|
||||
<svg class="baby" id="crawl" viewBox="0 0 130 100" aria-hidden="true">
|
||||
<g stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<g id="cLegB"><path d="M90 66 L97 88 L113 89" stroke="#EDB293" stroke-width="14"/></g>
|
||||
<g id="cArmB"><path d="M47 60 L43 87" stroke="#EDB293" stroke-width="12"/></g>
|
||||
</g>
|
||||
<ellipse cx="72" cy="58" rx="29" ry="20" fill="#FFD9C4"/>
|
||||
<path d="M58 44 q14 -6 28 0" stroke="#F7C4A9" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="95" cy="59" rx="15" ry="18" fill="#fff"/>
|
||||
<path d="M85 44 q-6 15 0 30" stroke="#F3D7CB" stroke-width="2.4" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="99" cy="52" r="2.2" fill="#FFC2B4"/><circle cx="104" cy="62" r="2.2" fill="#FFC2B4"/><circle cx="96" cy="67" r="2.2" fill="#FFC2B4"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<g id="cLegF"><path d="M86 68 L91 91 L108 93" stroke="#FFD2BA" stroke-width="15"/></g>
|
||||
<g id="cArmF"><path d="M54 62 L51 90" stroke="#FFD2BA" stroke-width="13"/></g>
|
||||
</g>
|
||||
<g id="cHead">
|
||||
<circle cx="34" cy="40" r="25" fill="#FFDCC8"/>
|
||||
<circle cx="50" cy="42" r="5.5" fill="#FFCDB4"/>
|
||||
<path d="M28 16 q4 -9 12 -5 q-7 1 -6 7" fill="#8E5A44"/>
|
||||
<path d="M37 15.5 q6 -5 11 1" stroke="#8E5A44" stroke-width="2.4" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="21" cy="38" r="3.4" fill="#4A2A20"/><circle cx="22.2" cy="36.8" r="1.2" fill="#fff"/>
|
||||
<ellipse cx="25" cy="48" rx="5.5" ry="3.8" fill="#FF9C93" opacity=".55"/>
|
||||
<path d="M11 47 q4 4 8 1" stroke="#B45A42" stroke-width="2.2" fill="none" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- 坐着的宝宝(正面),一手叉腰一手举拳,头上小皇冠 -->
|
||||
<svg class="baby" id="sit" viewBox="0 0 110 120" aria-hidden="true">
|
||||
<g stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<g id="sLegL"><path d="M43 90 L38 110" stroke="#FFD2BA" stroke-width="15"/></g>
|
||||
<g id="sLegR"><path d="M67 90 L72 110" stroke="#FFD2BA" stroke-width="15"/></g>
|
||||
</g>
|
||||
<ellipse cx="37" cy="113" rx="9" ry="5.5" fill="#FFCDB4"/><ellipse cx="73" cy="113" rx="9" ry="5.5" fill="#FFCDB4"/>
|
||||
<ellipse cx="55" cy="80" rx="24" ry="21" fill="#FFD9C4"/>
|
||||
<path d="M31 84 q24 22 48 0 q2 14 -24 16 q-26 -2 -24 -16z" fill="#fff"/>
|
||||
<path d="M32 84 q23 12 46 0" stroke="#F3D7CB" stroke-width="2.4" fill="none" stroke-linecap="round"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M35 70 L22 79 L33 89" stroke="#FFD2BA" stroke-width="11"/>
|
||||
<g id="sArmR"><path d="M75 70 L88 58 L90 44" stroke="#FFD2BA" stroke-width="11"/></g>
|
||||
</g>
|
||||
<g id="sFist"><circle cx="90" cy="41" r="7" fill="#FFCDB4"/></g>
|
||||
<circle cx="55" cy="40" r="27" fill="#FFDCC8"/>
|
||||
<circle cx="29" cy="42" r="5" fill="#FFCDB4"/><circle cx="81" cy="42" r="5" fill="#FFCDB4"/>
|
||||
<path d="M50 14 q4 -9 12 -5 q-7 1 -6 7" fill="#8E5A44"/>
|
||||
<path d="M44 41 q2.5 -4 5 0" stroke="#4A2A20" stroke-width="2.6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M61 41 q2.5 -4 5 0" stroke="#4A2A20" stroke-width="2.6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M42 33 l7 2 M68 33 l-7 2" stroke="#8E5A44" stroke-width="2" stroke-linecap="round"/>
|
||||
<ellipse cx="40" cy="49" rx="5.5" ry="3.6" fill="#FF9C93" opacity=".55"/><ellipse cx="70" cy="49" rx="5.5" ry="3.6" fill="#FF9C93" opacity=".55"/>
|
||||
<path d="M49 50 q6 7 12 0 z" fill="#C2563B" stroke="#C2563B" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<g id="crown" transform="rotate(-10 55 12)">
|
||||
<path d="M42 18 L40 4 L48 11 L55 1 L62 11 L70 4 L68 18 Z" fill="#FFD25A" stroke="#F0A93A" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<circle cx="55" cy="13" r="2" fill="#F06A45"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="bubble" id="bubble">今天这里归我管。</div>
|
||||
</div>
|
||||
<button class="replay" id="replay">再看一次</button>
|
||||
</div>
|
||||
<img class="below" src="../_hero/below.jpg" alt="">
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const REC = /[?&]rec=1/.test(location.search);
|
||||
const $ = id => document.getElementById(id);
|
||||
const wrap = $('wrap'), stage = $('stage'), week = $('week'), crawl = $('crawl'), sit = $('sit'), bubble = $('bubble'), replay = $('replay');
|
||||
const crown = $('crown');
|
||||
function fit() {
|
||||
const w = Math.min(wrap.parentNode.clientWidth, 480), s = w / 375;
|
||||
stage.style.transform = 'scale(' + s + ')'; wrap.style.height = 250 * s + 'px';
|
||||
replay.style.top = 214 * s + 'px';
|
||||
}
|
||||
fit(); addEventListener('resize', fit);
|
||||
|
||||
const cl = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
|
||||
const seg = (t, a, b) => cl((t - a) / (b - a));
|
||||
const lerp = (a, b, u) => a + (b - a) * u;
|
||||
const eio = u => u < .5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
|
||||
const eo = u => 1 - Math.pow(1 - u, 3);
|
||||
const spring = u => u <= 0 ? 0 : 1 - Math.exp(-7 * u) * Math.cos(13 * u); // 带回弹的 0→1
|
||||
const rot = (id, a, cx, cy) => $(id).setAttribute('transform', 'rotate(' + a.toFixed(2) + ' ' + cx + ' ' + cy + ')');
|
||||
const pop = (u) => u <= 0 ? 0 : 1 + Math.exp(-6 * u) * Math.sin(14 * u) * .35 - Math.exp(-14 * u) * 1;
|
||||
|
||||
// 关键位置(逻辑像素,375 宽头图坐标)
|
||||
const C0 = [384, 152], C1 = [114, 84]; // 爬行起点(右下角外)→ 顶到「30周」右侧
|
||||
const SIT = [26, 33]; // 坐姿宝宝左上角(坐在「30」顶上)
|
||||
const T = 9.6; // 一轮总时长
|
||||
|
||||
function frame(t) {
|
||||
// ---- 爬进来 1.0 → 2.7 ----
|
||||
let cx = C0[0], cy = C0[1], cOp = 0, flip = 1, gait = 0;
|
||||
const uIn = seg(t, 1.0, 2.7);
|
||||
if (t >= 1.0 && t < 3.05) {
|
||||
const u = eo(uIn);
|
||||
cx = lerp(C0[0], C1[0], u); cy = lerp(C0[1], C1[1], u);
|
||||
cOp = 1; gait = t * 9;
|
||||
if (t > 2.7) { const p = seg(t, 2.7, 2.95); cx = C1[0] - 9 * Math.sin(p * Math.PI * .5); gait = 2.7 * 9 + p * 3; } // 用头顶一下
|
||||
}
|
||||
// ---- 被顶歪:2.75 起数字块向左倒 -9°,带回弹 ----
|
||||
let wa = 0, wx = 0;
|
||||
if (t >= 2.75) {
|
||||
const s = spring(seg(t, 2.75, 3.6) * 1.15);
|
||||
wa = -9 * s; wx = -5 * s;
|
||||
}
|
||||
// ---- 跳上去 3.05 → 3.45(爬姿换坐姿,抛物线)----
|
||||
let sx = SIT[0], sy = SIT[1], sOp = 0, sRot = -6, sSq = 1;
|
||||
if (t >= 3.05 && t < 7.0) {
|
||||
const u = seg(t, 3.05, 3.45);
|
||||
const fx = C1[0] - 9 - 4, fy = C1[1] - 14;
|
||||
sx = lerp(fx, SIT[0], eio(u)); sy = lerp(fy, SIT[1], eio(u)) - Math.sin(u * Math.PI) * 34;
|
||||
sOp = 1;
|
||||
if (u >= 1) { const q = seg(t, 3.45, 3.85); sSq = 1 - Math.exp(-8 * q) * Math.sin(q * 18) * .12; } // 落座压扁回弹
|
||||
}
|
||||
// ---- 坐着:晃腿、举拳 ----
|
||||
const sit_t = t - 3.45;
|
||||
rot('sLegL', Math.sin(t * 7) * 14, 43, 90); rot('sLegR', Math.sin(t * 7 + 2.2) * 14, 67, 90);
|
||||
const fist = sit_t > 0 ? Math.sin(sit_t * 9) * 8 * Math.exp(-Math.max(0, sit_t - 1.4)) : 0;
|
||||
rot('sArmR', fist, 75, 70); rot('sFist', fist, 75, 70);
|
||||
const cr = seg(t, 3.55, 3.85);
|
||||
crown.setAttribute('transform', 'translate(0 ' + ((1 - eo(cr)) * -16).toFixed(1) + ') rotate(-10 55 12)');
|
||||
crown.style.opacity = cr;
|
||||
// ---- 气泡 3.75 → 6.6 ----
|
||||
const bIn = seg(t, 3.75, 4.25), bOut = seg(t, 6.45, 6.7);
|
||||
bubble.style.opacity = Math.min(cl(bIn * 3), 1 - bOut);
|
||||
bubble.style.transform = 'scale(' + (bIn > 0 ? Math.max(0, pop(bIn * 1.5)) * (1 - bOut * .2) : 0).toFixed(3) + ')';
|
||||
// ---- 跳下来 6.75 → 7.15,再爬走 7.15 → 8.5,数字块弹回 ----
|
||||
if (t >= 6.75 && t < 7.15) {
|
||||
const u = seg(t, 6.75, 7.15);
|
||||
sx = lerp(SIT[0], 132, eio(u)); sy = lerp(SIT[1], 112, eio(u)) - Math.sin(u * Math.PI) * 30;
|
||||
sOp = 1; sRot = lerp(-6, 8, u);
|
||||
}
|
||||
if (t >= 7.15 && t < 8.6) {
|
||||
const u = seg(t, 7.15, 8.5);
|
||||
cx = lerp(128, 396, eio(u)); cy = lerp(118, 158, eio(u)); cOp = 1; flip = -1; gait = t * 10; sOp = 0;
|
||||
}
|
||||
if (t >= 7.0 && t < 7.15) sOp = 1;
|
||||
if (t >= 6.8) {
|
||||
const s = spring(seg(t, 6.8, 7.8) * 1.15);
|
||||
wa = -9 * (1 - s); wx = -5 * (1 - s);
|
||||
}
|
||||
if (t < 3.05 || t >= 7.15) sOp = 0;
|
||||
if (t >= 3.05 && t < 7.15) cOp = 0;
|
||||
|
||||
week.style.transform = 'translateX(' + wx.toFixed(2) + 'px) rotate(' + wa.toFixed(2) + 'deg)';
|
||||
crawl.style.opacity = cOp;
|
||||
const bob = Math.abs(Math.sin(gait)) * -2.4;
|
||||
crawl.style.transform = 'translate(' + cx.toFixed(1) + 'px,' + (cy + bob).toFixed(1) + 'px) scaleX(' + flip + ')';
|
||||
rot('cArmF', Math.sin(gait) * 20, 54, 62); rot('cArmB', -Math.sin(gait) * 20, 47, 60);
|
||||
rot('cLegF', -Math.sin(gait) * 16, 86, 68); rot('cLegB', Math.sin(gait) * 16, 90, 66);
|
||||
rot('cHead', Math.sin(gait * 2) * 2.5, 40, 55);
|
||||
sit.style.opacity = sOp;
|
||||
sit.style.transform = 'translate(' + sx.toFixed(1) + 'px,' + sy.toFixed(1) + 'px) rotate(' + sRot + 'deg) scale(' + (1 / sSq).toFixed(3) + ',' + sSq.toFixed(3) + ')';
|
||||
replay.classList.toggle('on', !REC && t > T - .4);
|
||||
}
|
||||
|
||||
if (REC) { window.__set = frame; window.__T = T; frame(0); window.__ready = true; return; }
|
||||
let start = performance.now() - 0;
|
||||
(function loop(now) { const t = (now - start) / 1000; frame(Math.min(t, T)); requestAnimationFrame(loop); })(start);
|
||||
replay.addEventListener('click', () => { start = performance.now() - 300; });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
935
index.html
169
js/baby-limbs.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/* ================= 首页头图:宝宝时不时动一动小手小脚(2026-10-05) =================
|
||||
用户:「右上角羊水袋里的小宝宝时不时稍微动一动小腿、小脚、小胳膊,缓缓地动,不要太激烈」。
|
||||
|
||||
⚠️ 之前试过 AI 生成的 GIF,整张一起扭,脸型、腿型都变(见 hero-fx.js show() 里的注释),所以这里不用动图:
|
||||
在静图上盖一张 WebGL 画布,同一张 PNG 原样画上去,只在「关节 → 手脚末端」这一小段做小角度旋转扭动,
|
||||
脸周围是保护圈(权重强制为 0),头和身子一像素都不动。
|
||||
· 每个阶段的关节位置手工标在 LIMBS 里(坐标 = 480×480 原图像素)。1、2 阶段是胚芽,没有手脚,不动。
|
||||
· 节奏:每隔 2.5~6 秒随机挑一处(小腿 / 小脚 / 小胳膊),用 1.8~2.8 秒缓缓抬起再放回;
|
||||
偶尔小腿和小脚一起「蹬」一下。角度只有 3~9 度。不动的时候不重画(不占电)。
|
||||
· 跟 hero-fx.js 解耦:不改它,只盯 #heroStill 的 src / class —— 换阶段、淡入淡出都自动跟上。
|
||||
· 没 WebGL / 系统「减少动态效果」→ 什么都不做,静图照常。
|
||||
· 调试:URL 带 limbdebug=1 → 角度 ×4、动作更频繁,并把会动的区域染成蓝色,方便核对关节标得准不准。 */
|
||||
(function () {
|
||||
'use strict';
|
||||
const still = document.getElementById('heroStill');
|
||||
const box = document.getElementById('heroBaby');
|
||||
if (!still || !box || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
const DEBUG = /[?&]limbdebug=1/.test(location.search);
|
||||
|
||||
// 每处:[关节 x, y, 末端 x, y, 影响半径, 最大角度(度), 类型] 类型 leg / foot / arm(foot 会被 leg 带着一起蹬)
|
||||
// face:[圆心 x, y, 半径] —— 圈内完全不动,1.5 倍半径处才恢复
|
||||
const LIMBS = {
|
||||
'450581c2f05f': { face: [228, 222, 24], l: [[268, 272, 300, 286, 14, 6, 'leg'], [242, 258, 266, 240, 9, 5, 'arm']] },
|
||||
'dc4c7eea1c50': { face: [240, 207, 30], l: [[288, 272, 322, 320, 18, 5, 'leg'], [306, 318, 326, 310, 10, 9, 'foot'], [228, 272, 256, 236, 10, 3, 'arm']] },
|
||||
'7bf792b04123': { face: [217, 205, 32], l: [[252, 262, 292, 302, 15, 5, 'leg'], [278, 305, 296, 298, 10, 9, 'foot'], [197, 283, 236, 244, 12, 3, 'arm']] },
|
||||
'f140f4367b48': { face: [220, 208, 33], l: [[262, 262, 306, 303, 15, 5, 'leg'], [288, 305, 308, 300, 10, 9, 'foot'], [200, 282, 236, 244, 12, 3, 'arm']] },
|
||||
'51e99b3060e2': { face: [222, 205, 34], l: [[276, 266, 335, 310, 17, 5, 'leg'], [322, 313, 338, 305, 11, 9, 'foot'], [232, 268, 278, 240, 12, 4, 'arm']] },
|
||||
'a5dbb24c1ef2': { face: [225, 215, 40], l: [[276, 252, 322, 296, 16, 4, 'leg'], [310, 305, 326, 293, 11, 9, 'foot']] },
|
||||
'610c4d92c16d': { face: [250, 205, 42], l: [[292, 272, 335, 305, 16, 3, 'leg'], [325, 312, 338, 300, 12, 8, 'foot']] }
|
||||
};
|
||||
const N = 4; // shader 里最多 4 处
|
||||
|
||||
const cv = document.createElement('canvas');
|
||||
cv.className = 'hero-kid hero-limb';
|
||||
cv.setAttribute('aria-hidden', 'true');
|
||||
still.after(cv);
|
||||
const gl = cv.getContext('webgl', { premultipliedAlpha: true, alpha: true }) || cv.getContext('experimental-webgl');
|
||||
if (!gl) { cv.remove(); return; }
|
||||
|
||||
const VS = 'attribute vec2 a;void main(){gl_Position=vec4(a,0.,1.);}';
|
||||
const FS = [
|
||||
'precision mediump float;',
|
||||
'uniform vec2 uRes;uniform sampler2D uTex;uniform vec4 uL[' + N + '];uniform vec2 uR[' + N + '];uniform vec3 uF;uniform float uDbg;',
|
||||
'void main(){',
|
||||
' vec2 q=vec2(gl_FragCoord.x/uRes.x,1.-gl_FragCoord.y/uRes.y)*480.;',
|
||||
' vec2 off=vec2(0.);float wsum=0.;',
|
||||
' for(int i=0;i<' + N + ';i++){',
|
||||
' float ang=uR[i].y; if(ang==0.) continue;',
|
||||
' vec2 p=uL[i].xy,d=uL[i].zw-p; float s=dot(q-p,d)/dot(d,d);',
|
||||
' float dist=length(q-(p+d*clamp(s,0.,1.))); float r=uR[i].x;',
|
||||
// 关节处 0 → 段长一半处 1(关节不撕开);过了末端再渐隐;离开肢体 r 以外渐隐
|
||||
' float w=smoothstep(.05,.55,s)*(1.-smoothstep(1.25,1.7,s))*(1.-smoothstep(r*.6,r*1.25,dist));',
|
||||
' float a=-ang*w; vec2 v=q-p;',
|
||||
' off+=vec2(cos(a)*v.x-sin(a)*v.y,sin(a)*v.x+cos(a)*v.y)-v; wsum+=w;',
|
||||
' }',
|
||||
' float fw=smoothstep(uF.z,uF.z*1.5,length(q-uF.xy));', // 脸保护圈
|
||||
' vec4 c=texture2D(uTex,(q+off*fw)/480.);',
|
||||
' if(uDbg>.5) c.rgb=mix(c.rgb,vec3(.2,.5,1.)*c.a,min(wsum*fw,1.)*.6);',
|
||||
' gl_FragColor=c;',
|
||||
'}'
|
||||
].join('\n');
|
||||
function sh(t, src) { const s = gl.createShader(t); gl.shaderSource(s, src); gl.compileShader(s); return s; }
|
||||
const pr = gl.createProgram();
|
||||
gl.attachShader(pr, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FS)); gl.linkProgram(pr);
|
||||
if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) { cv.remove(); return; }
|
||||
gl.useProgram(pr);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
const aLoc = gl.getAttribLocation(pr, 'a');
|
||||
gl.enableVertexAttribArray(aLoc); gl.vertexAttribPointer(aLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
const U = {}; ['uRes', 'uTex', 'uL', 'uR', 'uF', 'uDbg'].forEach(k => { U[k] = gl.getUniformLocation(pr, k); });
|
||||
gl.uniform1i(U.uTex, 0); gl.uniform1f(U.uDbg, DEBUG && !/limbdebug=2/.test(location.search) ? 1 : 0); // limbdebug=2:放大角度但不染色
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
||||
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);
|
||||
|
||||
let cfg = null, ready = false, raf = 0, timer = 0, seen = true, lastPick = -1;
|
||||
const moves = []; // 正在进行的动作:{ i, t0, dur, amp }
|
||||
const home = document.getElementById('screen-home');
|
||||
const active = () => seen && !document.hidden && (!home || home.classList.contains('is-active'));
|
||||
|
||||
function size() {
|
||||
const d = Math.min(3, devicePixelRatio || 1), w = Math.round(Math.min(720, box.clientWidth * d)) || 480;
|
||||
if (cv.width !== w) { cv.width = cv.height = w; gl.viewport(0, 0, w, w); }
|
||||
gl.uniform2f(U.uRes, w, w);
|
||||
}
|
||||
function draw(now) {
|
||||
const L = new Float32Array(N * 4), R = new Float32Array(N * 2);
|
||||
let busy = false;
|
||||
for (let k = moves.length - 1; k >= 0; k--) {
|
||||
const m = moves[k], u = (now - m.t0) / m.dur;
|
||||
if (u >= 1) { moves.splice(k, 1); continue; }
|
||||
if (u < 0) { busy = true; continue; }
|
||||
busy = true;
|
||||
const e = Math.sin(Math.PI * u); R[m.i * 2 + 1] += m.amp * e * e; // 缓起缓落:0 → 最大 → 0
|
||||
}
|
||||
cfg.l.forEach((l, i) => { if (i < N) { L.set(l.slice(0, 4), i * 4); R[i * 2] = l[4]; } });
|
||||
gl.uniform4fv(U.uL, L); gl.uniform2fv(U.uR, R); gl.uniform3fv(U.uF, cfg.face);
|
||||
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
return busy;
|
||||
}
|
||||
function loop(now) {
|
||||
raf = 0;
|
||||
if (!ready || !active()) return;
|
||||
if (draw(now)) raf = requestAnimationFrame(loop);
|
||||
}
|
||||
const kick = () => { if (!raf && ready) raf = requestAnimationFrame(loop); };
|
||||
|
||||
// 安排下一次「动一动」
|
||||
function plan() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (ready && active() && cfg.l.length) {
|
||||
let i = Math.floor(Math.random() * cfg.l.length);
|
||||
if (i === lastPick && cfg.l.length > 1) i = (i + 1) % cfg.l.length;
|
||||
lastPick = i;
|
||||
const now = performance.now(), l = cfg.l[i], dir = Math.random() < .5 ? -1 : 1;
|
||||
const dur = 1800 + Math.random() * 1000, deg = l[5] * (.6 + Math.random() * .4) * (DEBUG ? 4 : 1);
|
||||
moves.push({ i, t0: now, dur, amp: dir * deg * Math.PI / 180 });
|
||||
// 小腿动时 4 成概率小脚也跟着轻轻翘一下(像蹬了一下),稍晚 0.3 秒起
|
||||
const f = cfg.l.findIndex(x => x[6] === 'foot');
|
||||
if (l[6] === 'leg' && f >= 0 && Math.random() < .4)
|
||||
moves.push({ i: f, t0: now + 300, dur: dur - 200, amp: -dir * cfg.l[f][5] * .7 * (DEBUG ? 4 : 1) * Math.PI / 180 });
|
||||
kick();
|
||||
}
|
||||
plan();
|
||||
}, DEBUG ? 900 + Math.random() * 600 : 2500 + Math.random() * 3500);
|
||||
}
|
||||
|
||||
// 跟着 #heroStill:换图时重新载贴图,静图淡出时画布一起淡出
|
||||
function sync() {
|
||||
const m = (still.getAttribute('src') || '').match(/([0-9a-f]{12})\.png/);
|
||||
const next = m && LIMBS[m[1]];
|
||||
if (!next) { cfg = null; ready = false; cv.classList.remove('is-on'); return; }
|
||||
if (next !== cfg) {
|
||||
cfg = next; ready = false; moves.length = 0;
|
||||
cv.classList.remove('is-on');
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cfg !== next) return;
|
||||
size();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
ready = true; draw(performance.now());
|
||||
if (still.classList.contains('is-on')) cv.classList.add('is-on');
|
||||
};
|
||||
img.src = still.src;
|
||||
return;
|
||||
}
|
||||
if (ready) cv.classList.toggle('is-on', still.classList.contains('is-on'));
|
||||
}
|
||||
// 调试:__limbPose(k) 把所有手脚定格在 k × 最大角度(k=0 原图),方便截图核对
|
||||
if (DEBUG) window.__limbPose = k => {
|
||||
clearTimeout(timer); moves.length = 0;
|
||||
cfg.l.forEach((l, i) => moves.push({ i, t0: performance.now() - 500, dur: 1000, amp: k * l[5] * Math.PI / 180 }));
|
||||
draw(performance.now()); moves.length = 0;
|
||||
};
|
||||
new MutationObserver(sync).observe(still, { attributes: true, attributeFilter: ['src', 'class'] });
|
||||
sync();
|
||||
plan();
|
||||
addEventListener('resize', () => { if (ready) { size(); draw(performance.now()); } });
|
||||
if ('IntersectionObserver' in window) new IntersectionObserver(es => { seen = es[0].isIntersecting; }).observe(box);
|
||||
cv.addEventListener('webglcontextlost', () => { ready = false; cv.classList.remove('is-on'); });
|
||||
})();
|
||||
529
js/bump.js
Normal file
@@ -0,0 +1,529 @@
|
||||
/* ================= 首页彩蛋:宝宝在肚皮底下动(2026-10-03,预览) =================
|
||||
整个首页当成妈妈的肚皮:宝宝时不时用小脚丫 / 小拳头 / 小膝盖 / 小屁股从底下往外顶,
|
||||
把顶到的那片内容撑成一个软软的小帐篷;滑到哪个模块,宝宝就跟到哪一屏。
|
||||
原型见 demo/bump(那边是把整页截图当纹理扭,这里是真实页面,做法不同):
|
||||
· 鼓包 = 附近真实元素(叶子级的文字块、图标、图片)按离鼓包中心的远近往外撑开 + 放大 + 往外倾斜,
|
||||
再在上面盖一层只画光影的 canvas(亮面 + 投影)。元素还是原来的元素,照样能点。
|
||||
· 只动 transform,不碰布局;动完把元素的行内 transform / transition 原样还回去。
|
||||
|
||||
和点击的关系(产品口径 2026-10-03):
|
||||
1. 宝宝尽量不顶按钮:挑位置时,鼓包范围里只要压到小按钮 / 链接 / 输入框就换地方;
|
||||
整张能点的大卡片(工具格、知识卡)允许顶。
|
||||
2. 真顶到了能点的地方:第一下算「摁宝宝」—— 这次 click 在捕获阶段被拦掉,不跳转;
|
||||
宝宝缩回去后再点就正常跳。只拦鼓包中心那一圈,点在边上照常跳。
|
||||
3. 宝宝冒头不到 0.3 秒就点下去 = 用户本来就是要点按钮,碰巧冒出来:不拦,照常跳,宝宝顺便缩回去。
|
||||
4. 用户在滑 / 在点的时候宝宝不出来:页面静止一会儿才顶;弹层、详情页盖住时不出来。
|
||||
|
||||
摁中时的反馈(一次约 1.2 秒):
|
||||
0ms 轻震(安卓)+ 一声很轻的「噗」+ 按出一个窝,鼓包被压扁、往四周摊开
|
||||
80ms 稍重一震 + 更轻的「啵」,鼓包往远离手指的方向滑开、沉下去,冒一颗小 ♡
|
||||
松手 窝 duang duang 回弹,周围的内容跟着颤几下
|
||||
600ms 三次里有一次:旁边轻轻反踢一下(宝宝不服气)
|
||||
⚠️ iOS 的网页没有震动接口(微信 JS-SDK 也没有),苹果用户只有画面 + 声音。
|
||||
按孕周分阶段,尽量贴近真实胎动(2026-10-04 用户:「尽量模拟真实的」):
|
||||
· 20 周前 / 没填预产期 / 已产后:不出现(这时候肚皮外面看不到胎动;没孕周就不猜)
|
||||
· 20~25 周「小鱼吐泡泡」:小、浅、一闪就没,常常连着两下;隔 10~25 秒才一次,周数越小越稀;滑过来也不追
|
||||
· 26~33 周「拳打脚踢」:完整版——小脚丫 / 小拳头 / 膝盖 / 小屁股,最勤(隔 4~8 秒),中途还会再顶一下
|
||||
· 34 周起「地方小了」:很少猛踢,多是一大片慢慢拱过去(翻身、屁股),节奏放慢(隔 7~12 秒),次数不减
|
||||
孕周来自 app.js 的 mb-preg 事件(syncPregnancy 跑完后广播)。调试:URL 带 bumpweek=24 可强制按 24 周演示。
|
||||
· 节奏(26 周起):有时原地「哒哒哒」带节奏连踢几拍;有时踢完不歇,旁边紧接着再踢 1~2 下
|
||||
开关:「我的 → 隐私权限 → 宝宝在肚皮下动」,存 localStorage 'mb_bump'('0' = 关);系统开了「减弱动态效果」也不出来。 */
|
||||
(function () {
|
||||
'use strict';
|
||||
const scr = document.getElementById('screen-home'), phone = document.getElementById('phone');
|
||||
if (!scr || !phone || !window.requestAnimationFrame) return;
|
||||
const reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const isOff = () => reduce || localStorage.getItem('mb_bump') === '0';
|
||||
|
||||
/* ---------- 光影层:盖在首页上、在底部导航和各种弹层下面,不吃点击 ---------- */
|
||||
const st = document.createElement('style');
|
||||
st.textContent =
|
||||
'.bump-layer{position:absolute;inset:0;z-index:3;pointer-events:none;overflow:hidden}' +
|
||||
'.bump-layer canvas{position:absolute;inset:0;width:100%;height:100%}' +
|
||||
'.bump-heart{position:absolute;margin:-10px 0 0 -8px;font-size:16px;line-height:20px;color:#F4593C;font-weight:700;' +
|
||||
'text-shadow:0 1px 3px rgba(255,255,255,.9);animation:bumpHeart .9s cubic-bezier(.2,.9,.3,1) forwards}' +
|
||||
'@keyframes bumpHeart{0%{opacity:0;transform:translateY(4px) scale(.4)}25%{opacity:1;transform:translateY(-6px) scale(1.15)}100%{opacity:0;transform:translateY(-30px) scale(.9)}}';
|
||||
document.head.appendChild(st);
|
||||
const layer = document.createElement('div'); layer.className = 'bump-layer'; layer.setAttribute('aria-hidden', 'true');
|
||||
const cv = document.createElement('canvas'); layer.appendChild(cv);
|
||||
phone.appendChild(layer);
|
||||
const cx = cv.getContext('2d');
|
||||
let dpr = 1;
|
||||
function resize() {
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
cv.width = Math.round(layer.clientWidth * dpr); cv.height = Math.round(layer.clientHeight * dpr);
|
||||
}
|
||||
resize(); addEventListener('resize', resize);
|
||||
|
||||
/* ---------- 坐标:一律用「首页内容坐标」(随内容滚动),画的时候再换成屏幕坐标 ---------- */
|
||||
const scrRect = () => scr.getBoundingClientRect();
|
||||
const toContent = (x, y) => { const r = scrRect(); return { x: x - r.left, y: y - r.top + scr.scrollTop }; };
|
||||
|
||||
/* ---------- 宝宝的各个部位:形状不同,动法不同(形状参数和 demo/bump 一致,脚趾去掉了;真实页面上停留时间加长到 3~5 秒,留够看见再伸手的时间) ---------- */
|
||||
const PARTS = {
|
||||
foot: { rx: 60, ry: 68, sharp: 2.5, amp: 9.5, dur: [3.6, 4.6] },
|
||||
fist: { rx: 44, ry: 44, sharp: 2.7, amp: 8, dur: [3, 3.8] },
|
||||
knee: { rx: 48, ry: 80, sharp: 2.3, amp: 8.5, dur: [3.4, 4.2] },
|
||||
butt: { rx: 92, ry: 78, sharp: 1.25, amp: 7, dur: [4, 5], roll: true },
|
||||
kick: { rx: 34, ry: 36, sharp: 2.6, amp: 4.5, dur: [.35, .45] }, // 被摁后不服气的反踢:小、快、摁不到
|
||||
// 20~25 周:宝宝还小,只有很轻的一下(flick),偶尔稍大一点的轻碰(tap)
|
||||
flick: { rx: 32, ry: 34, sharp: 2.2, amp: 6.5, dur: [.8, 1.2], soft: true },
|
||||
tap: { rx: 42, ry: 42, sharp: 2.3, amp: 8, dur: [1.4, 2], soft: true },
|
||||
// 34 周起:一大片慢慢拱过去(翻身),长条形,边走边起伏
|
||||
roll: { rx: 118, ry: 72, sharp: 1.15, amp: 6.5, dur: [5.5, 7], roll: true, slide: 64 }
|
||||
};
|
||||
// 2026-10-04 用户:「整体幅度要大一点,不然看不清、感受不到」—— 所有部位尺寸统一放大 20%(下面 apply / paint 的力度也一起加了)
|
||||
// 2026-10-04 用户:「踢到的液面鼓得尖一点,更明显」—— 手脚膝的 sharp 加大约 0.6~0.8(越大越往中心收、顶越尖),paint 里顶点再加一点集中高光
|
||||
const SIZE = 1.2;
|
||||
for (const k in PARTS) { PARTS[k].rx *= SIZE; PARTS[k].ry *= SIZE; }
|
||||
const ORDER = ['foot', 'fist', 'butt', 'knee'];
|
||||
|
||||
/* ---------- 孕周 → 阶段 ---------- */
|
||||
const STAGES = {
|
||||
// drum:原地带节奏连踢的概率;chain:踢完不歇、旁边紧接着再踢的概率,最多连 chainMax 下(2026-10-04 用户:「有时会带点节奏、可以连续踢」)
|
||||
flutter: { order: ['flick', 'flick', 'tap', 'flick'], gap: [10, 18], kick: false, again: 0, double: .45, follow: false, drum: 0, chain: 0 },
|
||||
active: { order: ORDER, gap: [4, 8], kick: true, again: .34, double: 0, follow: true, drum: .4, chain: .3, chainMax: 3 },
|
||||
late: { order: ['roll', 'butt', 'foot', 'roll', 'knee'], gap: [7, 12], kick: false, again: .15, double: 0, follow: true, slow: true, drum: .22, chain: .15, chainMax: 2 }
|
||||
};
|
||||
// 原地连踢的节奏型(每拍间隔,秒):匀速「哒哒哒」、「哒哒—哒」、「哒—哒哒」、两下一组
|
||||
const RHYTHMS = [[.42, .42, .42], [.3, .3, .75, .3], [.6, .28, .28], [.32, .9, .32], [.38, .38, .38, .38]];
|
||||
const dbgWeek = (location.href.match(/bumpweek=(\d+)/) || [])[1];
|
||||
let week = dbgWeek ? +dbgWeek : (window.__mbPreg ? window.__mbPreg.week : null);
|
||||
const stageOf = w => w == null || w < 20 ? null : w < 26 ? STAGES.flutter : w < 34 ? STAGES.active : STAGES.late;
|
||||
let stage = stageOf(week);
|
||||
// 两次之间隔多久:20~22 周最稀(再 +40%),越往后越接近本阶段的基准
|
||||
const gap = () => { const g = stage ? stage.gap : [6, 10], m = week != null && week < 23 ? 1.4 : 1; return (g[0] + Math.random() * (g[1] - g[0])) * m; };
|
||||
addEventListener('mb-preg', e => {
|
||||
if (dbgWeek) return;
|
||||
week = e.detail ? e.detail.week : null; stage = stageOf(week);
|
||||
if (!stage && mode !== 'wait') { mode = 'wait'; ft.h.v = pk.d.v = 0; release(); cx.clearRect(0, 0, cv.width, cv.height); }
|
||||
});
|
||||
let near = null; // 「连着两下」时第二下的位置
|
||||
|
||||
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: 0, y: 0, ang: 0, part: PARTS.foot, h: { v: 0, vel: 0, to: 0, k: 140, c: 11 },
|
||||
spread: { v: 1, vel: 0, to: 1, k: 260, c: 18 }, life: 0, dur: 3, ox: 0, oy: 0, wob: 0 };
|
||||
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 = 3, seq = 0, lastTouch = 0, lastTop = 0, still = 1, blockUntil = 0, kickTimer = 0;
|
||||
|
||||
/* ---------- 被撑起来的元素 ---------- */
|
||||
// 叶子级元素:图片 / 图标,或者里面只有行内内容的块(文字块、小色块)。动画中的元素跳过(行内 transform 盖不过动画)
|
||||
const CLICK = 'button,a,input,textarea,select,label,[role="button"],[role="switch"],[data-app],[data-go],[data-open],[data-sheet],[data-toast]';
|
||||
let atoms = [];
|
||||
const saved = new Map();
|
||||
function isAtom(el, cs) {
|
||||
if (el.ownerSVGElement) return false;
|
||||
if (cs.display === 'none' || cs.display === 'inline' || cs.display === 'contents') return false;
|
||||
if (cs.animationName && cs.animationName !== 'none') return false;
|
||||
if (cs.position === 'fixed' || cs.position === 'sticky') return false;
|
||||
const tag = el.tagName;
|
||||
if (tag === 'IMG' || tag === 'svg' || tag === 'CANVAS' || tag === 'VIDEO') return true;
|
||||
for (const c of el.children) { const d = getComputedStyle(c).display; if (d !== 'inline' && d !== 'none') return false; }
|
||||
if (el.textContent.trim()) return true;
|
||||
return cs.backgroundImage !== 'none' || !/rgba\(.*,\s*0\)|transparent/.test(cs.backgroundColor);
|
||||
}
|
||||
// 把鼓包 / 窝附近 R 以内的叶子元素收进来(每次冒头、每次摁时收一遍,页面数据会变)
|
||||
function gather(cxp, cyp, R) {
|
||||
const r0 = scrRect(), top = scr.scrollTop;
|
||||
for (const el of scr.querySelectorAll('*')) {
|
||||
if (saved.has(el)) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.width || !r.height) continue;
|
||||
const ex = r.left - r0.left + r.width / 2, ey = r.top - r0.top + top + r.height / 2;
|
||||
if (Math.abs(ex - cxp) > R + r.width / 2 || Math.abs(ey - cyp) > R + r.height / 2) continue;
|
||||
if (r.width * r.height > 4 * R * R) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (!isAtom(el, cs)) continue;
|
||||
// 大块元素整体动会很怪,按尺寸减弱
|
||||
const damp = Math.min(1, (R * 1.1) / Math.max(r.width, r.height));
|
||||
const a = { el, x: ex, y: ey, damp, base: cs.transform !== 'none' ? ' ' + cs.transform : '' };
|
||||
saved.set(el, { t: el.style.transform, tr: el.style.transition, wc: el.style.willChange });
|
||||
el.style.transition = 'none'; // 不设 will-change:设了会按原尺寸缓存图层,放大后字发糊
|
||||
atoms.push(a);
|
||||
}
|
||||
}
|
||||
// 「皮」:鼓包正下方那张卡片(有圆角或底色的容器)整体也微微鼓起来——卡片边缘一动,鼓包才看得出来
|
||||
let skin = null;
|
||||
function findSkin(cxp, cyp) {
|
||||
const r0 = scrRect(), el = document.elementFromPoint(r0.left + cxp, r0.top + cyp - scr.scrollTop);
|
||||
for (let e = el; e && e !== scr; e = e.parentElement) {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.width > scr.clientWidth * 1.02 || r.height > 560) break;
|
||||
if (r.width < 90 || r.height < 60) continue;
|
||||
const cs = getComputedStyle(e);
|
||||
if (cs.animationName !== 'none' || cs.position === 'fixed' || cs.position === 'sticky') continue;
|
||||
const solid = !/rgba\(.*,\s*0\)|transparent/.test(cs.backgroundColor) || cs.backgroundImage !== 'none' || cs.boxShadow !== 'none';
|
||||
if (!solid || parseFloat(cs.borderTopLeftRadius) < 6) continue;
|
||||
saved.set(e, { t: e.style.transform, tr: e.style.transition, wc: e.style.willChange, to: e.style.transformOrigin });
|
||||
e.style.transition = 'none';
|
||||
return { el: e, x: r.left - r0.left, y: r.top - r0.top + scr.scrollTop, w: r.width, h: r.height, base: cs.transform !== 'none' ? ' ' + cs.transform : '' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function release() {
|
||||
saved.forEach((s, el) => { el.style.transform = s.t; el.style.transition = s.tr; el.style.willChange = s.wc; if ('to' in s) el.style.transformOrigin = s.to; });
|
||||
saved.clear(); atoms = []; skin = null;
|
||||
}
|
||||
|
||||
/* ---------- 挑位置:只在当前这一屏里,躲开小按钮 ---------- */
|
||||
function hitOK(px, py) { // 屏幕坐标
|
||||
const el = document.elementFromPoint(px, py);
|
||||
if (!el || !scr.contains(el)) return null; // 被弹层 / 导航盖住,或者出了首页
|
||||
const c = el.closest(CLICK);
|
||||
if (c && scr.contains(c)) { const r = c.getBoundingClientRect(); if (r.width * r.height < 14000) return null; }
|
||||
return el;
|
||||
}
|
||||
function pickSpot(P) {
|
||||
const r0 = scrRect(), vh = scr.clientHeight, vw = scr.clientWidth;
|
||||
const y0 = 80, y1 = vh - 130; // 不贴顶,不压底部导航
|
||||
if (y1 - y0 < 120) return null;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const x = 60 + Math.random() * (vw - 120), y = y0 + Math.random() * (y1 - y0);
|
||||
const px = r0.left + x, py = r0.top + y;
|
||||
const c = hitOK(px, py); if (!c) continue;
|
||||
if (i < 20 && !c.textContent.trim() && c.tagName !== 'IMG') continue; // 前 20 次只挑压着字 / 图的地方
|
||||
let ok = true;
|
||||
for (let k = 0; k < 8 && ok; k++) {
|
||||
const a = k / 8 * Math.PI * 2;
|
||||
if (!hitOK(px + Math.cos(a) * P.rx * .55, py + Math.sin(a) * P.ry * .55)) ok = false;
|
||||
}
|
||||
if (!ok) continue;
|
||||
const p = { x, y: y + scr.scrollTop };
|
||||
if (Math.hypot(p.x - ft.x, p.y - ft.y) > 80) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const active = () => !!stage && !isOff() && !document.hidden && scr.classList.contains('is-active');
|
||||
const idle = () => performance.now() - lastTouch > 1500;
|
||||
|
||||
function appear(key, at) {
|
||||
const ord = stage ? stage.order : ORDER;
|
||||
const P = PARTS[key || ord[seq % ord.length]];
|
||||
if (!at && near) { const r0 = scrRect(); if (hitOK(r0.left + near.x, r0.top + near.y - scr.scrollTop)) at = near; near = null; }
|
||||
const s = at || pickSpot(P);
|
||||
if (!s) { wait = 1.5; return; }
|
||||
if (!key) seq++;
|
||||
if (!pk.held && Math.abs(pk.d.v) < .05) release(); // 反踢时手指的窝还在回弹,别把那几个元素放掉
|
||||
ft.part = P; ft.x = s.x; ft.y = s.y; ft.ox = ft.oy = 0;
|
||||
ft.ang = (Math.random() - .5) * 1.4;
|
||||
ft.rollDir = s.x > scr.clientWidth / 2 ? -1 : 1;
|
||||
ft.spread.v = ft.spread.to = 1; ft.spread.vel = 0;
|
||||
// 晚期地方小:同样的部位也顶得慢、顶得闷;早期的小鱼泡泡快起快落
|
||||
const slow = stage && stage.slow && !P.roll;
|
||||
ft.h.to = P.amp * (slow ? .8 : 1);
|
||||
ft.h.k = P.roll ? 70 : slow ? 75 : P.soft ? 220 : 140; ft.h.c = P.soft ? 16 : 9;
|
||||
ft.h.vel += P.roll ? 30 : slow ? 45 : P.soft ? 50 : 120;
|
||||
ft.life = 0; ft.dur = P.dur[0] + Math.random() * (P.dur[1] - P.dur[0]); ft.kicked = false;
|
||||
ft.chained = !!ft.chainNext; ft.chainNext = false;
|
||||
ft.chainN = ft.chained ? (ft.chainN || 0) + 1 : 0;
|
||||
// 连续踢:第一下就定好后面还跟几下;连踢的每一下都短促(1 秒出头),不像单独那一下要停好几秒
|
||||
if (ft.chained) ft.chainLeft--;
|
||||
else ft.chainLeft = stage && stage.chain && !P.soft && !P.roll && P !== PARTS.kick && Math.random() < stage.chain
|
||||
? 1 + (Math.random() * (stage.chainMax - 1) | 0) : 0;
|
||||
const inChain = ft.chained || ft.chainLeft > 0;
|
||||
if (inChain && !P.roll) ft.dur = (1.1 + Math.random() * .6) * (stage && stage.slow ? 1.3 : 1);
|
||||
// 原地连踢:脚丫 / 拳头 / 膝盖才有;晚期节奏放慢 1.4 倍。拍子排在第一下顶起来之后,整段时长跟着拉长
|
||||
ft.beats = null; ft.beat = 0; ft.pop = 0;
|
||||
if (!inChain && stage && stage.drum && !P.roll && !P.soft && P !== PARTS.kick && Math.random() < stage.drum) {
|
||||
const r = RHYTHMS[Math.random() * RHYTHMS.length | 0], m = stage.slow ? 1.4 : 1;
|
||||
let t = .7; ft.beats = r.map(d => (t += d * m));
|
||||
ft.dur = Math.max(ft.dur, t + .9); ft.kicked = true; // 有节奏就不再「中途再顶一下」
|
||||
}
|
||||
ft.catchable = P !== PARTS.kick;
|
||||
if (!skin) skin = findSkin(ft.x, ft.y);
|
||||
gather(ft.x, ft.y, Math.max(P.rx, P.ry) * 1.25 + 20);
|
||||
mode = 'show'; run();
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
||||
/* ---------- 声音:WebAudio 现场合成,不带音频文件;第一次摁的时候才建(要用户手势) ---------- */
|
||||
let ac = null;
|
||||
function audio() {
|
||||
try {
|
||||
if (!ac) {
|
||||
if (navigator.audioSession) navigator.audioSession.type = 'ambient'; // 跟随静音键
|
||||
const A = window.AudioContext || window.webkitAudioContext; if (!A) return null;
|
||||
ac = new A();
|
||||
}
|
||||
if (ac.state === 'suspended') ac.resume();
|
||||
} catch (e) { ac = null; }
|
||||
return ac;
|
||||
}
|
||||
function blip(f0, f1, vol, len, delay) {
|
||||
const a = audio(); if (!a) return;
|
||||
const t = a.currentTime + (delay || 0), o = a.createOscillator(), g = a.createGain();
|
||||
o.type = 'sine';
|
||||
o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + len * .8);
|
||||
g.gain.setValueAtTime(.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + .008); g.gain.exponentialRampToValueAtTime(.0001, t + len);
|
||||
o.connect(g); g.connect(a.destination); o.start(t); o.stop(t + len + .02);
|
||||
}
|
||||
const buzz = p => { try { navigator.vibrate && navigator.vibrate(p); } catch (e) {} };
|
||||
|
||||
function heart(x, y) {
|
||||
const r0 = scrRect(), lr = layer.getBoundingClientRect();
|
||||
const h = document.createElement('i'); h.className = 'bump-heart'; h.textContent = '♡';
|
||||
h.style.left = (x + r0.left - lr.left) + 'px'; h.style.top = (y - scr.scrollTop + r0.top - lr.top) + 'px';
|
||||
layer.appendChild(h); setTimeout(() => h.remove(), 950);
|
||||
}
|
||||
|
||||
/* ---------- 摁宝宝 ---------- */
|
||||
function poke(x, y) {
|
||||
const fx = ft.x + ft.ox, fy = ft.y + ft.oy;
|
||||
buzz([12, 70, 22]);
|
||||
blip(380, 120, .2, .16); blip(560, 260, .08, .12, .08);
|
||||
pk.x = x; pk.y = y; pk.held = true; pk.d.to = -12; pk.d.k = 380; pk.d.c = 22;
|
||||
gather(x, y, 80);
|
||||
// 鼓包先被压扁、往四周摊开,再往远离手指的方向滑走、沉下去
|
||||
ft.spread.to = 1.35; ft.spread.vel += 2;
|
||||
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); ft.h.k = 260; ft.h.c = 26;
|
||||
mode = 'flee'; wait = gap();
|
||||
setTimeout(() => heart(fx + ft.flee.dx * 18, fy + ft.flee.dy * 18 - 10), 80);
|
||||
// 三次里有一次:0.6 秒后在旁边轻轻反踢一下
|
||||
clearTimeout(kickTimer);
|
||||
if (stage && Math.random() < stage.again) kickTimer = setTimeout(() => {
|
||||
if (!active()) return;
|
||||
const k = { x: fx + ft.flee.dx * 55, y: fy + ft.flee.dy * 55 };
|
||||
const r0 = scrRect();
|
||||
if (!hitOK(r0.left + k.x, r0.top + k.y - scr.scrollTop)) return;
|
||||
buzz(10); blip(170, 90, .07, .12);
|
||||
appear('kick', k); wait = gap();
|
||||
}, 600);
|
||||
run();
|
||||
}
|
||||
function letGo(soft) {
|
||||
if (!pk.held) return;
|
||||
pk.held = false;
|
||||
if (soft) { pk.d.to = 0; pk.d.k = 300; pk.d.c = 34; return; } // 变成滚动了:窝悄悄平回去,不弹
|
||||
// 松手:弹簧变软、阻尼变小,皮肉弹回来并过冲 → duang duang,周围一起颤
|
||||
pk.d.to = 0; pk.d.k = 360; pk.d.c = 4.6; pk.d.vel += 150;
|
||||
jg.y.vel += 120; jg.x.vel += (Math.random() - .5) * 70;
|
||||
run();
|
||||
}
|
||||
|
||||
scr.addEventListener('pointerdown', e => {
|
||||
lastTouch = performance.now();
|
||||
if (isOff() || (mode !== 'show' && !(mode === 'hide' && ft.h.v > 3)) || !ft.catchable) return;
|
||||
const p = toContent(e.clientX, e.clientY), fx = ft.x + ft.ox, fy = ft.y + ft.oy;
|
||||
// 只认鼓包中心那一圈;点在边上照常走原来的点击
|
||||
const P = ft.part, d = Math.hypot((p.x - fx) / (P.rx * .62), (p.y - fy) / (P.ry * .62));
|
||||
if (d > 1) return;
|
||||
if (ft.life < .3 && mode === 'show') { hide(true); return; } // 刚冒头就点 = 本来要点按钮,不拦
|
||||
blockUntil = Infinity; // 这一整次手势(摁多久都算)之后的那个 click 都拦掉,松手时再收口
|
||||
poke(p.x, p.y);
|
||||
}, { capture: true, passive: true });
|
||||
scr.addEventListener('pointerup', () => { if (blockUntil === Infinity) blockUntil = performance.now() + 400; letGo(false); }, { passive: true });
|
||||
scr.addEventListener('pointercancel', () => { blockUntil = 0; letGo(true); }, { passive: true });
|
||||
// 摁宝宝的那一下不算点击:捕获阶段拦掉,下面的卡片不跳转
|
||||
window.addEventListener('click', e => {
|
||||
if (performance.now() < blockUntil) { blockUntil = 0; e.preventDefault(); e.stopImmediatePropagation(); }
|
||||
}, true);
|
||||
scr.addEventListener('scroll', () => { lastTouch = performance.now(); run(); }, { passive: true });
|
||||
|
||||
/* ---------- 物理 ---------- */
|
||||
function step(dt) {
|
||||
if (mode === 'wait') {
|
||||
if (active() && idle()) { wait -= dt; if (wait <= 0) appear(); }
|
||||
} else if (mode === 'show') {
|
||||
ft.life += dt;
|
||||
const u = ft.life, P = ft.part;
|
||||
if (P.roll) { // 小屁股:慢慢拱着往一边挪
|
||||
const k = Math.min(u / ft.dur, 1);
|
||||
ft.ox = ft.rollDir * (P.slide || 30) * k; ft.oy = -6 * Math.sin(k * Math.PI); ft.wob = Math.sin(u * 1.2) * .08;
|
||||
} else {
|
||||
ft.wob = Math.sin(u * 2.1) * .12;
|
||||
if (u > ft.dur * .4 && !ft.kicked && P !== PARTS.kick && !P.soft && stage && stage.kick) { ft.kicked = true; ft.h.vel += 70; } // 中途再顶一下
|
||||
// 一拍:先往回缩一下(鼓包塌一点),0.12 秒后再顶上来,位置跟着微微挪(脚尖 / 脚跟轮流使劲)
|
||||
if (ft.beats && ft.beat < ft.beats.length && u >= ft.beats[ft.beat]) {
|
||||
ft.beat++; ft.h.vel -= 85; ft.pop = .12;
|
||||
ft.ox += (Math.random() - .5) * 10; ft.oy += (Math.random() - .5) * 10;
|
||||
}
|
||||
if (ft.pop > 0 && (ft.pop -= dt) <= 0) ft.h.vel += stage && stage.slow ? 95 : 150;
|
||||
}
|
||||
if (u > ft.dur) {
|
||||
// 连续踢:这一下收得快一点,旁边紧接着再来,不等下一个间隔
|
||||
ft.chainNext = ft.chainLeft > 0;
|
||||
if (ft.chainNext) { ft.h.to = 0; ft.h.k = 200; ft.h.c = 24; mode = 'hide'; } else hide(false);
|
||||
}
|
||||
} else if (mode === 'hide') {
|
||||
if (ft.h.v < .3) {
|
||||
mode = 'wait'; wait = gap();
|
||||
if (ft.chainNext) {
|
||||
const a = Math.random() * Math.PI * 2, d = 50 + Math.random() * 40;
|
||||
near = { x: ft.x + ft.ox + Math.cos(a) * d, y: ft.y + ft.oy + Math.sin(a) * d }; wait = .2 + Math.random() * .35;
|
||||
}
|
||||
// 小鱼吐泡泡常常是连着的:一闪没了,旁边紧跟着又一下
|
||||
if (stage && stage.double && ft.part.soft && !ft.chained && Math.random() < stage.double) {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
near = { x: ft.x + Math.cos(a) * 42, y: ft.y + Math.sin(a) * 42 }; wait = .35 + Math.random() * .3; ft.chainNext = true;
|
||||
}
|
||||
}
|
||||
} else if (mode === 'flee') {
|
||||
const f = ft.flee; f.t += dt;
|
||||
const s = 24 * (1 - Math.exp(-f.t * 7));
|
||||
ft.ox = f.dx * s; ft.oy = f.dy * s;
|
||||
if (ft.h.v < .3) mode = 'wait';
|
||||
}
|
||||
// 滑动停下 0.4 秒:宝宝不在这一屏就挪过来,1 秒多后在这一屏里慢慢顶一下
|
||||
const top = scr.scrollTop;
|
||||
if (Math.abs(top - lastTop) > 1.5) { still = 0; lastTop = top; }
|
||||
else if (still < .4 && (still += dt) >= .4) {
|
||||
const fy = ft.y + ft.oy - top, seen = fy > 30 && fy < scr.clientHeight - 30;
|
||||
if (!seen && stage && stage.follow) {
|
||||
if (mode === 'show' || mode === 'hide') { ft.h.v = 0; ft.h.vel = 0; mode = 'wait'; release(); }
|
||||
if (mode === 'wait') wait = Math.min(wait, 1 + Math.random() * .6);
|
||||
}
|
||||
}
|
||||
spring(ft.h, dt); if (ft.h.v < 0) { ft.h.v = 0; ft.h.vel *= -.2; }
|
||||
spring(ft.spread, dt);
|
||||
if (mode === 'wait' && ft.spread.to !== 1 && ft.h.v < .3) { ft.spread.to = 1; ft.spread.v = 1; ft.spread.vel = 0; }
|
||||
spring(pk.d, dt); spring(jg.x, dt); spring(jg.y, dt);
|
||||
}
|
||||
const resting = () => ft.h.v < .05 && Math.abs(ft.h.vel) < .5 && Math.abs(pk.d.v) < .05 && Math.abs(pk.d.vel) < .5 && !pk.held &&
|
||||
Math.abs(jg.x.v) + Math.abs(jg.y.v) < .03 && Math.abs(jg.x.vel) + Math.abs(jg.y.vel) < .3;
|
||||
|
||||
/* ---------- 把物理量落到元素上 ---------- */
|
||||
function tentAt(x, y) {
|
||||
const P = ft.part, H = ft.h.v; if (H < .02) return 0;
|
||||
let dx = x - (ft.x + ft.ox), dy = y - (ft.y + ft.oy);
|
||||
const a = ft.ang + (mode === 'show' ? ft.wob : 0), c = Math.cos(a), s = Math.sin(a), sp = ft.spread.v;
|
||||
const lx = (c * dx + s * dy) / (P.rx * sp), ly = (-s * dx + c * dy) / (P.ry * sp);
|
||||
const r = Math.sqrt(lx * lx + ly * ly + .014), u = Math.max(0, Math.min(1, (1 - r) / (1 - .118)));
|
||||
return H * 2.3 * Math.pow(u, P.sharp) / sp;
|
||||
}
|
||||
function apply() {
|
||||
const fx = ft.x + ft.ox, fy = ft.y + ft.oy, R = Math.max(ft.part.rx, ft.part.ry) * ft.spread.v, D = pk.d.v;
|
||||
if (skin) {
|
||||
// 卡片以鼓包为原点轻轻放大、往上抬一点,手指按下去时反过来
|
||||
const k = Math.min(ft.h.v / 9, 1.3) + Math.max(-1, Math.min(1, D / 12)) * .6;
|
||||
const ox = Math.max(0, Math.min(skin.w, fx - skin.x)), oy = Math.max(0, Math.min(skin.h, fy - skin.y));
|
||||
skin.el.style.transformOrigin = ox.toFixed(1) + 'px ' + oy.toFixed(1) + 'px';
|
||||
skin.el.style.transform = Math.abs(k) < .005 ? skin.base.trim() :
|
||||
'translateY(' + (-3.2 * k).toFixed(2) + 'px) scale(' + (1 + .045 * k).toFixed(4) + ')' + skin.base;
|
||||
}
|
||||
for (const a of atoms) {
|
||||
let tx = 0, ty = 0, s = 1, ry = 0, rx = 0;
|
||||
// 鼓包:越靠近顶越被撑开、放大,坡上的微微斜切
|
||||
const th = Math.min(tentAt(a.x, a.y) / 14, 1.25) * a.damp;
|
||||
if (th > .002) {
|
||||
const dx = a.x - fx, dy = a.y - fy;
|
||||
tx += dx * .42 * th; ty += dy * .42 * th - th * 8; s += .4 * th;
|
||||
ry += 14 * Math.max(-1, Math.min(1, dx / R)) * th; rx -= 14 * Math.max(-1, Math.min(1, dy / R)) * th;
|
||||
}
|
||||
// 手指的窝:按下去往手指那边拽、缩小;回弹过冲时反过来
|
||||
if (Math.abs(D) > .02) {
|
||||
const dx = a.x - pk.x, dy = a.y - pk.y, g = Math.exp(-(dx * dx + dy * dy) / (46 * 46)) * a.damp;
|
||||
tx += dx * D * .03 * g; ty += dy * D * .03 * g; s += D * .015 * g;
|
||||
ry += 7 * Math.max(-1, Math.min(1, dx / 46)) * (D / 12) * g; rx -= 7 * Math.max(-1, Math.min(1, dy / 46)) * (D / 12) * g;
|
||||
}
|
||||
// 余波:周围一圈跟着颤
|
||||
const gx = a.x - pk.x, gy = a.y - pk.y, gw = Math.exp(-(gx * gx + gy * gy) / (140 * 140));
|
||||
tx -= jg.x.v * gw; ty -= jg.y.v * gw;
|
||||
a.el.style.transform = (Math.abs(tx) + Math.abs(ty) + Math.abs(s - 1) * 50 + Math.abs(rx) + Math.abs(ry) < .02) ? a.base.trim() :
|
||||
// 只用平面变换:带 perspective / rotateX 的 3D 变换会让文字走合成层,发糊。坡度改用轻微的斜切来暗示
|
||||
'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) skew(' + (ry * .5).toFixed(2) + 'deg,' + (-rx * .3).toFixed(2) + 'deg) scale(' + s.toFixed(4) + ')' + a.base;
|
||||
}
|
||||
}
|
||||
// 光影:鼓包 = 左上亮面 + 右下投影;窝 = 左上内阴影 + 右下反光
|
||||
function blob(x, y, rx, ry, ang, stops) {
|
||||
cx.save(); cx.translate(x, y); cx.rotate(ang); cx.scale(rx, ry);
|
||||
for (const [ox, oy, r, col] of stops) {
|
||||
const g = cx.createRadialGradient(ox, oy, 0, ox, oy, r);
|
||||
g.addColorStop(0, col); g.addColorStop(1, col.replace(/[\d.]+\)$/, '0)'));
|
||||
cx.fillStyle = g; cx.beginPath(); cx.arc(ox, oy, r, 0, 7); cx.fill();
|
||||
}
|
||||
cx.restore();
|
||||
}
|
||||
// 脚印:脚跟 / 足弓外沿 / 前掌 / 五个脚趾,单位坐标(脚尖朝 -y),整体缩到鼓包的 0.62
|
||||
const FOOT = [[0, .42, .2], [.1, .12, .15], [-.02, -.18, .24], [-.17, -.5, .1], [-.04, -.57, .08], [.07, -.55, .07], [.16, -.5, .06], [.23, -.41, .055]];
|
||||
function footprint(x, y, rx, ry, ang, k) {
|
||||
if (k < .25) return;
|
||||
const a = (k - .25) / .75, f = .62, dark = [], lite = [];
|
||||
for (const [px, py, r] of FOOT) {
|
||||
// 压痕:中性灰、很淡;左上角错开一点白,像皮肤底下被顶出的浅浅轮廓,而不是画上去的图案
|
||||
dark.push([px * f, py * f, r * f * 1.5, 'rgba(70,62,58,' + (.075 * a).toFixed(3) + ')']);
|
||||
lite.push([px * f - .03, py * f - .04, r * f * 1.2, 'rgba(255,255,255,' + (.12 * a).toFixed(3) + ')']);
|
||||
}
|
||||
blob(x, y, rx, ry, ang, dark.concat(lite));
|
||||
}
|
||||
function paint() {
|
||||
cx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
cx.clearRect(0, 0, cv.width, cv.height);
|
||||
const r0 = scrRect(), lr = layer.getBoundingClientRect(), ox = r0.left - lr.left, oy = r0.top - lr.top - scr.scrollTop;
|
||||
const H = ft.h.v;
|
||||
if (H > .1) {
|
||||
const P = ft.part, sp = ft.spread.v, k = Math.min(H / 9, 1.3) / sp;
|
||||
blob(ft.x + ft.ox + ox, ft.y + ft.oy + oy, P.rx * sp, P.ry * sp, ft.ang + (mode === 'show' ? ft.wob : 0), [
|
||||
// 2026-10-04 用户:「凸起就行,不要变颜色(之前踢完泛浅红)」—— 只留中性灰的背光阴影 + 白高光,不带任何暖色
|
||||
[.16, .22, 1.02, 'rgba(70,62,58,' + (.1 * k).toFixed(3) + ')'],
|
||||
[.34, .42, .66, 'rgba(70,62,58,' + (.08 * k).toFixed(3) + ')'],
|
||||
[-.26, -.32, .55, 'rgba(255,255,255,' + (.38 * k).toFixed(3) + ')'],
|
||||
// 尖顶:顶点处一小团亮光 + 紧贴着的背光,鼓包看起来是「被顶出一个尖」而不是一片圆丘(翻身/屁股那种大片的不加)
|
||||
...(P.roll ? [] : [[.07, .09, .3, 'rgba(70,62,58,' + (.09 * k).toFixed(3) + ')'],
|
||||
[-.07, -.09, .2, 'rgba(255,255,255,' + (.5 * k).toFixed(3) + ')']])
|
||||
]);
|
||||
// 小脚丫顶上来时,隐隐约约带出一个脚印:极淡、全是软边,峰值时才看得出,凑近看像有像无
|
||||
if (P === PARTS.foot || P === PARTS.kick) footprint(ft.x + ft.ox + ox, ft.y + ft.oy + oy, P.rx * sp, P.ry * sp, ft.ang + (mode === 'show' ? ft.wob : 0), Math.min(H / P.amp, 1));
|
||||
}
|
||||
const D = pk.d.v;
|
||||
if (D < -.2) {
|
||||
const k = Math.min(-D / 12, 1.2);
|
||||
blob(pk.x + ox, pk.y + oy, 40, 40, 0, [
|
||||
[-.16, -.2, .9, 'rgba(70,62,58,' + (.14 * k).toFixed(3) + ')'],
|
||||
[.32, .36, .55, 'rgba(255,255,255,' + (.45 * k).toFixed(3) + ')']
|
||||
]);
|
||||
} else if (D > .2) {
|
||||
const k = Math.min(D / 10, 1);
|
||||
blob(pk.x + ox, pk.y + oy, 40, 40, 0, [
|
||||
[.14, .2, .95, 'rgba(70,62,58,' + (.06 * k).toFixed(3) + ')'],
|
||||
[-.22, -.28, .65, 'rgba(255,255,255,' + (.3 * k).toFixed(3) + ')']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 循环:有东西在动才跑 rAF,静止时只用定时器数「下一次什么时候顶」 ---------- */
|
||||
let running = false, last = 0, painted = false;
|
||||
function frame(now) {
|
||||
const dt = Math.min((now - last) / 1000, 1 / 20); last = now;
|
||||
if (!active()) {
|
||||
if (mode !== 'wait') { mode = 'wait'; wait = 2; }
|
||||
ft.h.v = ft.h.vel = 0; pk.d.v = pk.d.vel = 0; pk.held = false; jg.x.v = jg.y.v = jg.x.vel = jg.y.vel = 0;
|
||||
}
|
||||
const n = Math.max(1, Math.round(dt * 120));
|
||||
for (let i = 0; i < n; i++) step(dt / n);
|
||||
if (mode === 'wait' && resting()) {
|
||||
if (atoms.length) release();
|
||||
if (painted) { cx.clearRect(0, 0, cv.width, cv.height); painted = false; }
|
||||
running = false; tick(); return;
|
||||
}
|
||||
apply(); paint(); painted = true;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
function run() { if (running || isOff()) return; running = true; last = performance.now(); requestAnimationFrame(frame); }
|
||||
// 等下一次冒头:粗粒度定时器,到点再交给 rAF
|
||||
let tickT = 0;
|
||||
function tick() {
|
||||
clearTimeout(tickT);
|
||||
tickT = setTimeout(() => {
|
||||
if (running) return;
|
||||
if (active() && idle() && mode === 'wait') { wait -= .25; if (wait <= 0) { appear(); if (running) return; } }
|
||||
tick();
|
||||
}, 250);
|
||||
}
|
||||
// 开关切换(「隐私权限」里那一行)
|
||||
addEventListener('mb-bump', () => {
|
||||
if (isOff()) { mode = 'wait'; ft.h.v = pk.d.v = 0; release(); cx.clearRect(0, 0, cv.width, cv.height); }
|
||||
else { wait = 1.2; tick(); }
|
||||
});
|
||||
// 调试 / 截图用:URL 带 bumpdebug 才挂
|
||||
if (/bumpdebug/.test(location.href)) window.__bump = { ft, pk, atoms: () => atoms.map(a => [a.el.className || a.el.tagName, Math.round(a.x), Math.round(a.y), a.el.style.transform.slice(0, 90)]), mode: () => mode, now: () => { wait = 0; lastTouch = 0; }, week: () => week, at: (k, x, y) => { lastTouch = 0; appear(k, { x, y: y + scr.scrollTop }); } };
|
||||
wait = 3; tick();
|
||||
})();
|
||||
219
js/hero-fx.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/* ================= 首页头图:按孕周换宝宝 + 阳光水波(2026-10-04) =================
|
||||
用户:「宝宝按孕周换、孕囊跟着宝宝大小变;顶部往下落的花瓣去掉,换成阳光和水波纹」。
|
||||
|
||||
① 换宝宝:孕周来自 app.js 的 mb-preg 事件(syncPregnancy 跑完广播,首屏一次、远程档案回来再一次)。
|
||||
9 个阶段各一张「羊水囊 + 宝宝」整幅图:静图(首帧)先出,GIF 加载完再盖上淡入。
|
||||
没建档 / 产后 / 事件迟迟不来 → 用第 6 阶段(原来那个 25 周的宝宝)。
|
||||
孕囊大小 = .hero-baby 的 --k(相对原 25 周囊的倍数),CSS 里围绕同一圆心缩放、带过渡。
|
||||
调试:URL 带 kidweek=12 强制按 12 周显示。
|
||||
② 羊水 + 光束:#heroWater 一张 WebGL 画布(搬自参考页 womb-water.js),在孕囊下面。
|
||||
柔光束 + 柔和水波光影 + 悬浮微粒,宝宝区域避开(详见下面那段)。
|
||||
不在首页 / 页面隐藏 / 头图滚出视口时停画;系统「减少动态效果」下整块不画(CSS 也隐藏)。 */
|
||||
(function () {
|
||||
'use strict';
|
||||
// [起始孕周, 静图, GIF(null = 只用静图), 孕囊倍数 k] 静图是透明底 PNG(囊壁外渐隐),叠在羊水层上 2026-10-04:宝宝在囊里缩到原图的 70%,孕囊不变
|
||||
const STAGES = [
|
||||
[0, 'c685f94d437c.png', null, .42], // 1 4–7 周 胚芽 + 卵黄囊
|
||||
[8, '6c5d82c9b093.png', null, .52], // 2 8–10 周 小豆芽胚胎
|
||||
[11, '450581c2f05f.png', null, .62], // 3 11–13 周 刚有人形
|
||||
[14, 'dc4c7eea1c50.png', null, .72], // 4 14–17 周 身子拉长、吮手
|
||||
[18, '7bf792b04123.png', null, .86], // 5 18–23 周 比例接近新生儿、偏瘦
|
||||
[24, 'f140f4367b48.png', null, 1], // 6 24–27 周 原来那个宝宝
|
||||
[28, '51e99b3060e2.png', null, 1.01], // 7 28–31 周 大一圈、有胎发
|
||||
[32, 'a5dbb24c1ef2.png', null, 1.02], // 8 32–35 周 肉嘟嘟、抱腿
|
||||
[36, '610c4d92c16d.png', null, 1.03] // 9 36 周起 足月、撑满羊水囊(头朝上:10-04 用户说头朝下看着不舒服)
|
||||
];
|
||||
const DEFAULT = 5;
|
||||
const IMG = 'assets/img/';
|
||||
const box = document.getElementById('heroBaby');
|
||||
const still = document.getElementById('heroStill');
|
||||
const kid = document.getElementById('heroKid');
|
||||
if (!box || !still || !kid) return;
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const force = (location.search.match(/[?&]kidweek=(\d+)/) || [])[1];
|
||||
function stageOf(week) {
|
||||
if (week == null || isNaN(week)) return DEFAULT;
|
||||
let i = 0;
|
||||
while (i + 1 < STAGES.length && week >= STAGES[i + 1][0]) i++;
|
||||
return i;
|
||||
}
|
||||
let cur = -1, seq = 0;
|
||||
function show(i) {
|
||||
if (i === cur) return;
|
||||
cur = i;
|
||||
const [, png, gif, k] = STAGES[i], my = ++seq;
|
||||
box.style.setProperty('--k', k);
|
||||
box.classList.toggle('is-big', STAGES[i][0] >= 24); // 24 周起孕囊大,星座牌改压在囊边(见 css .hero-baby.is-big .hero-zod)
|
||||
// 先把旧图淡掉再换,避免新旧宝宝叠在一起闪一下
|
||||
kid.classList.remove('is-on'); still.classList.remove('is-on');
|
||||
const s = new Image();
|
||||
s.onload = () => { if (my !== seq) return; still.src = s.src; still.classList.add('is-on'); };
|
||||
s.src = IMG + png;
|
||||
/* 2026-10-04 用户:「水波纹一出来,宝宝的脸型、腿型就变了」—— 那是 GIF 自带的整体扭动(生成动图的通病),
|
||||
不是页面特效。先只用静图:宝宝一像素都不变形。gif 位传 null;以后有了只动手脚/嘴角的新动图再填回来。 */
|
||||
if (reduce || !gif) return;
|
||||
const g = new Image();
|
||||
g.onload = () => { if (my !== seq) return; kid.src = g.src; kid.classList.add('is-on'); };
|
||||
g.src = IMG + gif;
|
||||
}
|
||||
/* ---- 星座牌(2026-10-04,用户:「按预产期推算宝宝是哪个星座,在羊水囊外边放上『巨蟹宝宝』之类,协调、字不要太大、高级一些」) ----
|
||||
挂在 .hero-baby 里(跟着孕囊一起浮),落在孕囊右下角那块空角上。
|
||||
只在孕期显示(未建档 / 产后不显示:产后应按真实生日,而这里只有预产期)。
|
||||
预产期离星座分界 ≤2 天时写成「巨蟹·狮子宝宝」—— 预产期本来就是估算,别说得太死。 */
|
||||
const ZOD = [ // [起始月, 起始日, 名, 符号] 按起始日期排
|
||||
[1, 20, '水瓶', '♒'], [2, 19, '双鱼', '♓'], [3, 21, '白羊', '♈'], [4, 20, '金牛', '♉'], [5, 21, '双子', '♊'], [6, 22, '巨蟹', '♋'],
|
||||
[7, 23, '狮子', '♌'], [8, 23, '处女', '♍'], [9, 23, '天秤', '♎'], [10, 24, '天蝎', '♏'], [11, 23, '射手', '♐'], [12, 22, '摩羯', '♑']];
|
||||
function zodiacOf(y, m, d) {
|
||||
const t = Date.UTC(y, m - 1, d);
|
||||
let i = ZOD.length - 1; // 1/1~1/19 是摩羯
|
||||
for (let k = 0; k < ZOD.length; k++) if (t >= Date.UTC(y, ZOD[k][0] - 1, ZOD[k][1])) i = k;
|
||||
// 摩羯跨年:1 月里的摩羯从去年 12/22 起、到今年 1/20 止;12 月里的摩羯到明年 1/20 止
|
||||
const jan = i === 11 && m === 1, day = 864e5;
|
||||
const start = Date.UTC(jan ? y - 1 : y, ZOD[i][0] - 1, ZOD[i][1]);
|
||||
const nx = ZOD[(i + 1) % 12], nextStart = Date.UTC(i === 11 && !jan ? y + 1 : y, nx[0] - 1, nx[1]);
|
||||
const near = (t - start) / day < 2 ? (i + 11) % 12 : (nextStart - t) / day <= 2 ? (i + 1) % 12 : -1;
|
||||
return { main: ZOD[i], alt: near >= 0 ? ZOD[near] : null, prevAlt: near === (i + 11) % 12 };
|
||||
}
|
||||
const zEl = document.createElement('span');
|
||||
zEl.className = 'hero-zod'; zEl.setAttribute('aria-hidden', 'true');
|
||||
box.appendChild(zEl);
|
||||
zEl.setAttribute('role', 'button'); zEl.removeAttribute('aria-hidden');
|
||||
let zNames = [];
|
||||
zEl.addEventListener('click', e => { e.stopPropagation(); if (zNames.length) window.dispatchEvent(new CustomEvent('mb-zodiac', { detail: { names: zNames } })); });
|
||||
function renderZod(d) {
|
||||
const due = d && !d.empty && !d.postpartum && d.due;
|
||||
if (!due) { zEl.classList.remove('is-on'); return; }
|
||||
const z = zodiacOf(due[0], due[1], due[2]);
|
||||
/* 2026-10-04 用户:「出现『摩羯·水瓶宝宝』相当于两个星座,必须只能是一个」→ 只按预产期落在哪个区间取一个,
|
||||
不再在分界 ±2 天写双星座(zodiacOf 的 alt 留着不用) */
|
||||
const pair = [z.main];
|
||||
// 图标:对应星座的自绘符号(js/zodiac-art.js;不用 ♋ 一类字符,部分安卓没有字形会成方框)
|
||||
zEl.innerHTML = '<i>' + (window.zodiacIcon ? window.zodiacIcon(pair[0][2], 10, 2.6) : '') + '</i>' +
|
||||
'<b>' + pair.map(x => x[2]).join('·') + '宝宝</b>';
|
||||
zNames = pair.map(x => x[2]);
|
||||
// 实体卡片按「象」上色(火 / 土 / 风 / 水,同弹窗主题;见 css .hero-zod.el-*)
|
||||
zEl.className = 'hero-zod el-' + ((window.ZODIAC_ELEMENT || {})[zNames[0]] || 'fire') + ' is-on';
|
||||
zEl.setAttribute('aria-label', zNames.join('·') + '宝宝,点开看看');
|
||||
zEl.classList.add('is-on');
|
||||
}
|
||||
function onPreg(d) { show(stageOf(force != null ? +force : (d && d.week))); renderZod(d); }
|
||||
addEventListener('mb-preg', e => onPreg(e.detail));
|
||||
if (force != null || window.__mbPreg) onPreg(window.__mbPreg);
|
||||
else setTimeout(() => { if (cur < 0) show(DEFAULT); }, 2500);
|
||||
|
||||
/* ---------------- 羊水 + 光束(2026-10-04 七版) ----------------
|
||||
用户:「按我给的网址改头部,主要是它的光束和羊水,右上角的羊水比较好,用那一部分放到现在的头部」
|
||||
参考页:beian3.douba.top/YANG-bak-10041632/?v=2(js/womb-water.js,WebGL)。
|
||||
这里**原样搬了它的 fragment shader**:羊水底色 + 整体慢晃 + 折射波纹、右半边斜射的光束和焦散光纹、
|
||||
背后会「呼吸」的暖光、上浮颗粒、暗角。只改两处:
|
||||
· 暖光中心对齐到我们的孕囊(uC,每帧读 .hero-baby 的位置,跟着孕囊浮动走)
|
||||
· 孕周从 mb-preg 事件拿(参考页读 #heroWeek 文字)
|
||||
画布 #heroWater 在 .hero-baby 下面(z1),孕囊 + 宝宝(透明底 PNG)叠在上面 → 宝宝一像素都不受影响;
|
||||
LOGO / 文案(z3)在最上面。之前自己写的 2D 水层、光束、光斑整段删掉(不叠两套)。
|
||||
没有 WebGL → 什么都不画,CSS 背景照常;系统「减少动态效果」→ 只画一帧。 */
|
||||
const cv = document.getElementById('heroWater');
|
||||
if (!cv) return;
|
||||
const hero = cv.parentNode, home = document.getElementById('screen-home');
|
||||
const gl = cv.getContext('webgl', { antialias: false, alpha: false }) || cv.getContext('experimental-webgl');
|
||||
if (!gl) return;
|
||||
const VS = 'attribute vec2 a;void main(){gl_Position=vec4(a,0.,1.);}';
|
||||
const FS = [
|
||||
'precision mediump float;',
|
||||
'uniform vec2 uRes,uC;uniform float uT,uDpr,uW;',
|
||||
'float h1(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
|
||||
'float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);',
|
||||
' return mix(mix(h1(i),h1(i+vec2(1,0)),f.x),mix(h1(i+vec2(0,1)),h1(i+vec2(1,1)),f.x),f.y);}',
|
||||
'float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<4;i++){v+=a*vn(p);p=p*2.03+vec2(1.7,9.2);a*=.5;}return v;}',
|
||||
'void main(){',
|
||||
' vec2 p=vec2(gl_FragCoord.x,uRes.y*uDpr-gl_FragCoord.y)/uDpr;',
|
||||
' vec2 q=p/uRes;float A=uRes.x/uRes.y;float t=uT;',
|
||||
// 水在晃:整体慢漂 + 折射波纹
|
||||
' vec2 sw=vec2(sin(t*.55)*.018+sin(t*.23)*.008,cos(t*.43)*.014);',
|
||||
' vec2 fl=vec2(fbm(q*vec2(A,1.)*1.6+vec2(t*.12,0.)),fbm(q*vec2(A,1.)*1.6+vec2(5.2,-t*.10)))-.5;',
|
||||
' vec2 qd=q+sw+.028*fl+.009*vec2(sin(q.y*14.+t*1.8),cos(q.x*A*10.+t*1.5));',
|
||||
// 羊水底色:头图同色系,上浅下深;左边(文案区)再压一点暖色
|
||||
' vec3 c=mix(vec3(.93,.64,.52),vec3(.79,.43,.36),smoothstep(-.15,1.05,qd.y));',
|
||||
' c=mix(c,vec3(.84,.49,.40),.35*(1.-smoothstep(.0,.6,q.x)));',
|
||||
' c+=.07*(fbm(qd*vec2(A,1.)*3.+vec2(t*.09,-t*.07))-.5);',
|
||||
// 光只在右半边:左上角是 LOGO + slogan,那里一点光都不给
|
||||
' float lk=smoothstep(.38,.75,q.x);',
|
||||
' vec2 cp=vec2(qd.x*A*3.,qd.y*7.);',
|
||||
' float a1=fbm(cp+vec2(t*.30,t*.18)),a2=fbm(cp*1.3-vec2(t*.26,-t*.22)+4.);',
|
||||
' float ca=1.-smoothstep(0.,.06,abs(a1-a2));',
|
||||
' c+=vec3(1.,.90,.74)*ca*pow(clamp(1.-q.y/.5,0.,1.),2.)*.22*lk;',
|
||||
// 阳光:隐隐约约的几道,慢慢起伏
|
||||
' vec2 r=qd-vec2(.74,-.45);float an=atan(r.x*A,r.y);',
|
||||
' float ry=vn(vec2(an*7.,t*.28))*vn(vec2(an*17.,-t*.33)+3.);',
|
||||
' ry=smoothstep(.15,.75,ry)*smoothstep(1.6,.2,length(r))*lk;',
|
||||
' float sun=.75+.25*sin(t*.6);',
|
||||
' c+=vec3(1.,.87,.66)*ry*.30*sun;',
|
||||
// ── 宝宝剪影已去掉(2026-10-04 用户要求),只留原来剪影背后那团会「呼吸」的暖光,位置和浮动照旧 ──
|
||||
' float m=smoothstep(14.,40.,uW);',
|
||||
' vec2 bc=uC+vec2(0.,uRes.y*.03*m);',
|
||||
' float bob=uRes.y*.022*sin(t*.55+.8);',
|
||||
' float gd=length((p-bc-vec2(0.,bob))/uRes.y);',
|
||||
' float br=.8+.2*sin(t*1.1);',
|
||||
' c+=vec3(1.,.83,.62)*(.22*exp(-gd*3.2)+.10*exp(-gd*1.2))*br;',
|
||||
// 上浮颗粒(只要很少几颗)
|
||||
' for(int i=0;i<2;i++){float Lr=float(i);',
|
||||
' vec2 g=q*vec2(A,1.)*(9.+Lr*7.)+vec2(sin(t*.4+Lr)*.3,t*(.30+Lr*.22));',
|
||||
' vec2 id=floor(g),f=fract(g)-.5;float rn=h1(id+Lr*7.);',
|
||||
' vec2 o=vec2(h1(id+3.1),h1(id+5.7))-.5;float dd=length(f-o*.6);',
|
||||
' float tw=.6+.4*sin(t*2.+rn*30.);',
|
||||
' c+=vec3(1.,.94,.84)*smoothstep(.05+.03*rn,0.,dd)*step(.80,rn)*(.35-Lr*.15)*tw;}',
|
||||
' c*=1.-.12*pow(length((q-vec2(.55,.4))*vec2(1.,1.3)),2.);',
|
||||
' gl_FragColor=vec4(c,1.);}'
|
||||
].join('\n');
|
||||
function sh(type, src) {
|
||||
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||||
return s;
|
||||
}
|
||||
let pr;
|
||||
try {
|
||||
pr = gl.createProgram();
|
||||
gl.attachShader(pr, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FS));
|
||||
gl.linkProgram(pr);
|
||||
if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(pr));
|
||||
} catch (err) { console.warn('[hero-water]', err); return; }
|
||||
gl.useProgram(pr);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
const aLoc = gl.getAttribLocation(pr, 'a');
|
||||
gl.enableVertexAttribArray(aLoc); gl.vertexAttribPointer(aLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
const U = {}; ['uRes', 'uC', 'uT', 'uDpr', 'uW'].forEach(n => { U[n] = gl.getUniformLocation(pr, n); });
|
||||
const dpr = Math.min(devicePixelRatio || 1, 2), t0 = performance.now();
|
||||
let W = 0, H = 0, raf = 0, seen = true, week = 20;
|
||||
addEventListener('mb-preg', e => { const w = e.detail && e.detail.week; week = w > 0 ? w : 20; });
|
||||
if (window.__mbPreg && window.__mbPreg.week > 0) week = window.__mbPreg.week;
|
||||
function resize() {
|
||||
const r = hero.getBoundingClientRect(); if (!r.width) return;
|
||||
W = r.width; H = r.height;
|
||||
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
|
||||
gl.viewport(0, 0, cv.width, cv.height);
|
||||
}
|
||||
function draw(now) {
|
||||
const hr = hero.getBoundingClientRect(), b = box.getBoundingClientRect();
|
||||
gl.uniform2f(U.uRes, W, H); gl.uniform1f(U.uDpr, dpr);
|
||||
gl.uniform2f(U.uC, b.left - hr.left + b.width / 2, b.top - hr.top + b.height / 2);
|
||||
gl.uniform1f(U.uW, Math.min(42, Math.max(5, week)));
|
||||
gl.uniform1f(U.uT, reduce ? 2 : (now - t0) / 1000);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
function loop(now) {
|
||||
raf = 0;
|
||||
if (!(seen && !document.hidden && (!home || home.classList.contains('is-active')))) return;
|
||||
draw(now);
|
||||
if (!reduce) raf = requestAnimationFrame(loop);
|
||||
}
|
||||
function kick() { if (!raf) raf = requestAnimationFrame(loop); }
|
||||
resize(); kick();
|
||||
addEventListener('resize', () => { resize(); kick(); });
|
||||
document.addEventListener('visibilitychange', kick);
|
||||
if (home) new MutationObserver(kick).observe(home, { attributes: true, attributeFilter: ['class'] });
|
||||
if ('IntersectionObserver' in window) new IntersectionObserver(es => { seen = es[0].isIntersecting; if (seen) { resize(); kick(); } }).observe(hero);
|
||||
if (reduce) addEventListener('mb-preg', kick);
|
||||
cv.addEventListener('webglcontextlost', () => { cancelAnimationFrame(raf); cv.classList.remove('is-on'); });
|
||||
cv.classList.add('is-on');
|
||||
})();
|
||||
62
js/zodiac-art.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/* 2026-10-04 星座宝宝的美术素材:12 个星座符号(自绘,部分安卓没有 ♋ 这类字形)、星图、四象配色与纹样。
|
||||
首页小牌(js/hero-fx.js)和星座弹窗(js/app.js)共用,所以单独成文件、在 app.js 之前加载。 */
|
||||
/* 星座符号(24×24,描边绘制,stroke=currentColor) */
|
||||
window.ZODIAC_ICON = {
|
||||
白羊: 'M12 20.5V9.5M12 9.5C12 5.5 9.8 3.6 7.6 3.6 5.6 3.6 4 5.1 4 7.1c0 1.8 1.3 3.1 3 3.1M12 9.5c0-4 2.2-5.9 4.4-5.9 2 0 3.6 1.5 3.6 3.5 0 1.8-1.3 3.1-3 3.1',
|
||||
金牛: 'M4 4.2c1.2 3.4 4.2 5.4 8 5.4s6.8-2 8-5.4M12 9.6a5.2 5.2 0 1 0 0 10.4 5.2 5.2 0 0 0 0-10.4z',
|
||||
双子: 'M4.5 4.2c4.6 1.6 10.4 1.6 15 0M4.5 19.8c4.6-1.6 10.4-1.6 15 0M9 5.4v13.2M15 5.4v13.2',
|
||||
巨蟹: 'M5 8.6a2.4 2.4 0 1 0 4.8 0 2.4 2.4 0 0 0-4.8 0zM7.4 6.2C10.4 4.2 16 4.2 20 7.6M19 15.4a2.4 2.4 0 1 0-4.8 0 2.4 2.4 0 0 0 4.8 0zM16.6 17.8C13.6 19.8 8 19.8 4 16.4',
|
||||
狮子: 'M5 15.6a3 3 0 1 0 6 0 3 3 0 0 0-6 0zM11 15.6C11 11 9.4 9.2 9.4 7.2a3.6 3.6 0 0 1 7.2 0c0 3.4-2.6 5.8-2.6 9.6 0 2 1.3 3.3 3 3.3 1.3 0 2.3-.7 2.9-1.7',
|
||||
处女: 'M3.6 6.6c1.3 0 2.1.8 2.1 2.1V17M5.7 8.7c0-1.3.9-2.1 2.1-2.1s2.1.8 2.1 2.1V17M9.9 8.7c0-1.3.9-2.1 2.1-2.1s2.1.8 2.1 2.1v6.6c0 2.3 1.3 3.7 3.2 4.1M14.1 13c.9-2.3 2.7-3.3 4.1-2.6 1.6.8 1.1 3.7-1.1 5.8-1.2 1.1-2.6 1.9-3.4 2.2',
|
||||
天秤: 'M4 19.2h16M4 15.4h4.4a3.8 3.8 0 1 1 7.2 0H20',
|
||||
天蝎: 'M3.4 6.6c1.3 0 2.1.8 2.1 2.1V17M5.5 8.7c0-1.3.9-2.1 2.1-2.1s2.1.8 2.1 2.1V17M9.7 8.7c0-1.3.9-2.1 2.1-2.1s2.1.8 2.1 2.1v7.4c0 1.5.9 2.4 2.4 2.4h3.9M18.3 16.3l2.2 2.2-2.2 2.2',
|
||||
射手: 'M5 19L19 5M12.2 5H19v6.8M7.6 11.4l5 5',
|
||||
摩羯: 'M3.6 6.6c1.3 0 2.1.8 2.1 2.1V17M5.7 9c0-1.5 1-2.4 2.3-2.4S10.4 7.5 10.4 9v7c0 2 1.2 3.4 3 3.4M10.4 13.2c.9-1.9 2.4-2.9 4.2-2.9a3.1 3.1 0 1 1 0 6.2c-1.9 0-3.1-1.1-3.7-2.4',
|
||||
水瓶: 'M3 10.4l3-2.6 3 2.6 3-2.6 3 2.6 3-2.6 3 2.6M3 16.2l3-2.6 3 2.6 3-2.6 3 2.6 3-2.6 3 2.6',
|
||||
双鱼: 'M6.2 4c3.2 3.4 3.2 12.6 0 16M17.8 4c-3.2 3.4-3.2 12.6 0 16M4.6 12h14.8'
|
||||
};
|
||||
|
||||
/* 星图(星座的真实连线,示意位置,0~1 归一化):[星点[x,y,亮度0~1], 连线[i,j]] —— 弹窗顶部右上角淡淡地画出来 */
|
||||
window.ZODIAC_STARS = {
|
||||
白羊: [[[.1,.62,.6],[.46,.42,1],[.7,.4,.8],[.84,.54,.5]], [[0,1],[1,2],[2,3]]],
|
||||
金牛: [[[.5,.62,1],[.42,.52,.6],[.36,.44,.5],[.56,.5,.6],[.62,.42,.5],[.12,.16,.8],[.88,.22,.7],[.72,.78,.4]], [[0,1],[1,2],[2,5],[0,3],[3,4],[4,6],[0,7]]],
|
||||
双子: [[[.3,.14,1],[.56,.12,.9],[.27,.48,.6],[.55,.48,.6],[.2,.86,.5],[.62,.86,.5],[.12,.62,.4]], [[0,2],[2,4],[1,3],[3,5],[2,3],[2,6]]],
|
||||
巨蟹: [[[.5,.46,.7],[.46,.16,.6],[.24,.82,.8],[.78,.78,.6]], [[0,1],[0,2],[0,3]]],
|
||||
狮子: [[[.2,.64,1],[.22,.44,.6],[.3,.28,.7],[.44,.22,.6],[.5,.34,.5],[.62,.46,.6],[.88,.52,.8],[.62,.64,.5]], [[0,1],[1,2],[2,3],[3,4],[1,5],[5,6],[6,7],[7,0]]],
|
||||
处女: [[[.12,.28,.5],[.32,.36,.6],[.5,.3,.6],[.56,.52,.7],[.78,.74,1],[.4,.56,.5],[.3,.78,.5]], [[0,1],[1,2],[2,3],[3,4],[3,5],[5,6]]],
|
||||
天秤: [[[.5,.14,.9],[.24,.4,.8],[.76,.44,.7],[.3,.82,.5],[.7,.86,.5]], [[0,1],[0,2],[1,2],[1,3],[2,4]]],
|
||||
天蝎: [[[.14,.18,.6],[.2,.3,.6],[.1,.4,.5],[.34,.4,1],[.44,.5,.6],[.5,.66,.6],[.56,.8,.6],[.7,.88,.6],[.83,.8,.7],[.86,.66,.6]], [[0,1],[1,2],[1,3],[3,4],[4,5],[5,6],[6,7],[7,8],[8,9]]],
|
||||
射手: [[[.28,.52,.7],[.44,.4,.8],[.6,.44,.7],[.7,.32,.6],[.82,.5,.8],[.66,.66,.7],[.44,.66,.6]], [[0,1],[1,2],[2,3],[2,4],[4,5],[5,6],[6,0],[6,1],[2,5]]],
|
||||
摩羯: [[[.1,.3,.8],[.38,.46,.6],[.86,.24,.7],[.72,.64,.6],[.36,.76,.6]], [[0,1],[1,2],[2,3],[3,4],[4,0]]],
|
||||
水瓶: [[[.1,.3,.6],[.28,.24,.7],[.44,.36,.8],[.56,.3,.5],[.62,.52,.6],[.76,.7,.6],[.88,.86,.5],[.5,.62,.5]], [[0,1],[1,2],[2,3],[2,4],[4,5],[5,6],[4,7]]],
|
||||
双鱼: [[[.08,.2,.5],[.16,.14,.5],[.2,.26,.5],[.3,.38,.6],[.5,.58,.6],[.66,.84,.9],[.74,.62,.6],[.8,.42,.6],[.9,.3,.5]], [[0,1],[1,2],[2,0],[2,3],[3,4],[4,5],[5,6],[6,7],[7,8]]]
|
||||
};
|
||||
|
||||
/* 四象:星座 → 元素;元素 → 配色与纹样(弹窗用) */
|
||||
window.ZODIAC_ELEMENT = { 白羊: 'fire', 狮子: 'fire', 射手: 'fire', 金牛: 'earth', 处女: 'earth', 摩羯: 'earth',
|
||||
双子: 'air', 天秤: 'air', 水瓶: 'air', 巨蟹: 'water', 天蝎: 'water', 双鱼: 'water' };
|
||||
window.ZODIAC_THEME = {
|
||||
fire: { name: '火象星座', words: '热情 · 勇敢 · 有行动力', band: 'linear-gradient(160deg,#FFA275 0%,#F0704A 48%,#C8443A 100%)', body: '#FFF7F2', ink: '#D9583C' },
|
||||
earth: { name: '土象星座', words: '踏实 · 温和 · 很靠得住', band: 'linear-gradient(160deg,#D8C092 0%,#AE9263 50%,#77704C 100%)', body: '#FAF6EE', ink: '#8E7448' },
|
||||
air: { name: '风象星座', words: '灵动 · 聪明 · 爱交朋友', band: 'linear-gradient(160deg,#CFE3F7 0%,#A3BDEC 50%,#8C93DC 100%)', body: '#F5F7FD', ink: '#6A79CC' },
|
||||
water: { name: '水象星座', words: '温柔 · 细腻 · 很有感知力', band: 'linear-gradient(160deg,#86D3DE 0%,#4C9FC6 50%,#33669F 100%)', body: '#F2F8FB', ink: '#2F7FAD' }
|
||||
};
|
||||
/* 头部底边纹样:最下一层用正文底色填,头部就「长」进正文里;上面两层半透明白做层次。viewBox 0 0 375 56 */
|
||||
window.ZODIAC_MOTIF = {
|
||||
fire: ['M0 56V40 C10.4 40.0 13.8 30.1 18.8 22.0 C21.8 31.0 33.3 40.0 41.7 40.0 C52.1 40.0 58.5 23.6 63.5 10.1 C66.5 25.1 75.0 40.0 83.3 40.0 C93.8 40.0 92.9 28.4 97.9 18.9 C100.9 29.5 116.7 40.0 125.0 40.0 C135.4 40.0 141.2 24.0 146.2 10.9 C149.2 25.4 158.3 40.0 166.7 40.0 C177.1 40.0 181.3 26.8 186.3 16.1 C189.3 28.0 200.0 40.0 208.3 40.0 C218.7 40.0 218.4 24.8 223.4 12.4 C226.4 26.2 241.7 40.0 250.0 40.0 C260.4 40.0 267.7 25.5 272.7 13.6 C275.7 26.8 283.3 40.0 291.7 40.0 C302.1 40.0 303.8 26.0 308.8 14.6 C311.8 27.3 325.0 40.0 333.3 40.0 C343.8 40.0 345.3 24.4 350.3 11.7 C353.3 25.8 366.7 40.0 375.0 40.0 V56z',
|
||||
'M0 56V46 C8.5 46.0 13.9 35.2 18.6 26.3 C21.5 36.1 27.3 46.0 34.1 46.0 C42.6 46.0 43.2 38.8 47.9 32.9 C50.8 39.4 61.4 46.0 68.2 46.0 C76.7 46.0 77.5 35.0 82.2 26.0 C85.1 36.0 95.5 46.0 102.3 46.0 C110.8 46.0 116.0 38.9 120.8 33.1 C123.7 39.5 129.5 46.0 136.4 46.0 C144.9 46.0 143.9 35.1 148.7 26.2 C151.6 36.1 163.6 46.0 170.5 46.0 C179.0 46.0 181.8 37.8 186.6 31.1 C189.4 38.5 197.7 46.0 204.5 46.0 C213.1 46.0 217.1 35.5 221.8 27.0 C224.7 36.5 231.8 46.0 238.6 46.0 C247.2 46.0 245.8 36.8 250.6 29.3 C253.5 37.6 265.9 46.0 272.7 46.0 C281.2 46.0 285.9 36.2 290.6 28.2 C293.5 37.1 300.0 46.0 306.8 46.0 C315.3 46.0 317.4 36.0 322.1 27.8 C325.0 36.9 334.1 46.0 340.9 46.0 C349.4 46.0 349.0 37.1 353.7 29.8 C356.6 37.9 368.2 46.0 375.0 46.0 V56z',
|
||||
'M0 56V50c30-3 60-6 94-4s62 5 94 3 62-6 94-4 62 5 93 3V56z'],
|
||||
earth: ['M0 56V30l38-18 30 14 44-24 36 20 28-10 46 26 34-16 40 18 30-12 49 22V56z',
|
||||
'M0 56V40l46-14 34 10 40-16 42 16 30-6 42 14 38-12 36 12 34-8 33 12V56z',
|
||||
'M0 56V48c40-6 80-8 125-4s90 6 130 0 80-6 120-2V56z'],
|
||||
air: ['M0 56V38c18 0 22-14 40-14s22 12 38 12 18-16 38-16 24 14 40 14 20-12 38-12 22 14 40 14 20-14 38-14 22 12 40 12 18-10 30-10 18 10 33 10V56z',
|
||||
'M0 56V44c22 0 30-10 52-10s28 10 48 10 28-12 50-12 30 12 50 12 28-10 50-10 30 10 50 10 26-8 42-8 22 8 33 8V56z',
|
||||
'M0 56V49c30 0 50-5 80-5s48 6 78 6 50-6 80-6 48 5 78 5 40-4 59-4V56z'],
|
||||
water: ['M0 56V36c22 0 26-12 47-12s25 12 47 12 25-12 47-12 25 12 47 12 25-12 47-12 25 12 47 12 25-12 47-12 25 12 46 12V56z',
|
||||
'M0 56V43c24 0 28-9 50-9s27 9 50 9 27-9 50-9 27 9 50 9 27-9 50-9 27 9 50 9 27-9 50-9 26 9 25 9V56z',
|
||||
'M0 56V49c26 0 30-6 54-6s28 6 54 6 28-6 54-6 28 6 54 6 28-6 54-6 28 6 54 6 26-5 51-5V56z']
|
||||
};
|
||||
window.zodiacIcon = function (n, w, sw) {
|
||||
const d = window.ZODIAC_ICON[n]; if (!d) return '';
|
||||
return '<svg viewBox="0 0 24 24" width="' + w + '" height="' + w + '" fill="none" stroke="currentColor" stroke-width="' + (sw || 2) + '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path fill="none" d="' + d + '"/></svg>';
|
||||
};
|
||||
2621
mama-box.css
220
tools/gen-night.py
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""夜晚模式样式生成器:读 mama-box.css,把浅色底 / 深色字自动翻成夜色,写进 night.css 的「自动生成」段。
|
||||
|
||||
为什么用生成而不是手写:mama-box.css 有 4700 行、几百处写死的 #fff / 暖白,手写一份夜间版
|
||||
既漏得多、又会和日间版各改各的。这里只做「颜色翻转」这一件机械的事:
|
||||
· 背景 / 边框类属性里,亮且不太艳的颜色(暖白、米色、浅灰线)→ 同色相的深色(卡片比页面底略亮);
|
||||
· color / fill / stroke 里,暗的颜色(正文墨色、灰字)→ 同色相的浅色;
|
||||
· 品牌橙、工具卡的绿 / 紫 / 蓝这些高饱和色、白字、透明度低的玻璃白,一律不动。
|
||||
每条规则原样复制选择器、前面加 html.night,所以优先级比日间版恰好高一级,顺序关系不变。
|
||||
自动翻不好的地方写在 night.css 的「手调」段(在生成段之后,同优先级后写的赢)。
|
||||
|
||||
用法:python3 tools/gen-night.py [mama-box.css] 改了 mama-box.css 的颜色后重跑一次。
|
||||
"""
|
||||
import re, sys, colorsys, pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
SRC = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / 'mama-box.css'
|
||||
OUT = ROOT / 'night.css'
|
||||
MARK_A = '/* ==== 自动生成段:tools/gen-night.py 写入,别手改 ==== */'
|
||||
MARK_B = '/* ==== 自动生成段结束 ==== */'
|
||||
|
||||
BG_PROPS = {'background', 'background-color', 'background-image', 'border', 'border-color', 'border-top',
|
||||
'border-bottom', 'border-left', 'border-right', 'border-top-color', 'border-bottom-color',
|
||||
'border-left-color', 'border-right-color', 'outline', 'outline-color', 'box-shadow', 'caret-color'}
|
||||
FG_PROPS = {'color', 'fill', 'stroke', '-webkit-text-fill-color', 'text-decoration-color'}
|
||||
SKIP_SEL = re.compile(r'home-hero|hero-|\.hi-|\.hd-|\.letter-card|zd-|ar-hero|page-hero|ny-peek|nyp-|::selection|\.skip')
|
||||
|
||||
COL = re.compile(r'#([0-9a-fA-F]{3,8})\b|rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)')
|
||||
|
||||
|
||||
def parse_hex(h):
|
||||
if len(h) in (3, 4):
|
||||
h = ''.join(c * 2 for c in h)
|
||||
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
||||
a = int(h[6:8], 16) / 255 if len(h) == 8 else 1
|
||||
return r, g, b, a
|
||||
|
||||
|
||||
def fmt(r, g, b, a):
|
||||
R, G, B = (round(max(0, min(1, x)) * 255) for x in (r, g, b))
|
||||
return f'#{R:02X}{G:02X}{B:02X}' if a >= .999 else f'rgba({R},{G},{B},{round(a, 3)})'
|
||||
|
||||
|
||||
NIGHT_H = 280 / 360
|
||||
|
||||
|
||||
def bg_map(r, g, b, a):
|
||||
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||||
if l < .80: # 艳色 / 中间调:品牌色、工具卡底,不动(浅粉浅黄这类高饱和的淡色照样翻)
|
||||
return None
|
||||
if a < .2: # 几乎透明的白:只是渐变收尾,不动
|
||||
return None
|
||||
nl = max(.115, .17 - (1 - l) * .5) # #fff→.17(卡片) 米色浅底 .93→.135 浅线→.12;页底另由 --bg 定
|
||||
if s < .3: # 近中性的暖白 / 灰:统一染成夜色的一点紫,不然一片死灰
|
||||
h, ns = NIGHT_H, .10
|
||||
else:
|
||||
ns = min(s, .5) * .45
|
||||
return fmt(*colorsys.hls_to_rgb(h, nl, ns), a)
|
||||
|
||||
|
||||
def fg_map(r, g, b, a):
|
||||
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||||
if l > .55 or (s > .55 and l > .3): # 已经够亮 / 艳色强调字,不动
|
||||
return None
|
||||
nl = min(.93, .95 - l * .55) # 墨色 .15→.87 灰字 .42→.72
|
||||
ns = min(s, .3) * .5
|
||||
if s < .3:
|
||||
h, ns = 25 / 360, .12 # 字色带一点暖,跟日间的暖墨色同一气质
|
||||
return fmt(*colorsys.hls_to_rgb(h, nl, ns), a)
|
||||
|
||||
|
||||
def vivid(val):
|
||||
"""这条背景里有没有「主色」(品牌橙、工具卡的绿紫蓝):有就整条不动,否则渐变里的白高光被翻黑、主色还在,成了脏灰。"""
|
||||
for m in COL.finditer(val):
|
||||
if m.group(1):
|
||||
if len(m.group(1)) not in (3, 4, 6, 8):
|
||||
continue
|
||||
r, g, b, a = parse_hex(m.group(1))
|
||||
else:
|
||||
r, g, b = (float(m.group(i)) / 255 for i in (2, 3, 4))
|
||||
a = m.group(5); a = 1 if a is None else (float(a[:-1]) / 100 if a.endswith('%') else float(a))
|
||||
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||||
if a > .5 and s > .45 and .3 < l < .78:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def recolor(val, fn):
|
||||
changed = False
|
||||
|
||||
def sub(m):
|
||||
nonlocal changed
|
||||
if m.group(1):
|
||||
if len(m.group(1)) not in (3, 4, 6, 8):
|
||||
return m.group(0)
|
||||
c = parse_hex(m.group(1))
|
||||
else:
|
||||
a = m.group(5)
|
||||
a = 1 if a is None else (float(a[:-1]) / 100 if a.endswith('%') else float(a))
|
||||
c = (float(m.group(2)) / 255, float(m.group(3)) / 255, float(m.group(4)) / 255, a)
|
||||
n = fn(*c)
|
||||
if n is None:
|
||||
return m.group(0)
|
||||
changed = True
|
||||
return n
|
||||
out = COL.sub(sub, val)
|
||||
return out if changed else None
|
||||
|
||||
|
||||
def map_keyword(prop, val):
|
||||
v = val.strip().lower().replace('!important', '').strip()
|
||||
if prop in BG_PROPS and v in ('white',):
|
||||
return val.lower().replace('white', '#2A2530')
|
||||
return None
|
||||
|
||||
|
||||
def strip_comments(s):
|
||||
return re.sub(r'/\*.*?\*/', '', s, flags=re.S)
|
||||
|
||||
|
||||
def split_decls(body):
|
||||
out, depth, cur = [], 0, ''
|
||||
for ch in body:
|
||||
if ch == '(':
|
||||
depth += 1
|
||||
elif ch == ')':
|
||||
depth -= 1
|
||||
if ch == ';' and depth == 0:
|
||||
out.append(cur); cur = ''
|
||||
else:
|
||||
cur += ch
|
||||
out.append(cur)
|
||||
return [d.strip() for d in out if d.strip()]
|
||||
|
||||
|
||||
def prefix_sel(sel):
|
||||
parts, depth, cur = [], 0, ''
|
||||
for ch in sel:
|
||||
if ch == '(':
|
||||
depth += 1
|
||||
elif ch == ')':
|
||||
depth -= 1
|
||||
if ch == ',' and depth == 0:
|
||||
parts.append(cur); cur = ''
|
||||
else:
|
||||
cur += ch
|
||||
parts.append(cur)
|
||||
res = []
|
||||
for p in parts:
|
||||
p = p.strip()
|
||||
if p.startswith(':root'):
|
||||
continue
|
||||
if re.match(r'html\b', p):
|
||||
p = 'html.night' + p[4:]
|
||||
else:
|
||||
p = 'html.night ' + p
|
||||
res.append(p)
|
||||
return ','.join(res)
|
||||
|
||||
|
||||
def walk(css):
|
||||
"""返回 [(prelude 或 None, 内容)]。嵌套 @media / @supports 递归,@keyframes / @font-face 跳过。"""
|
||||
i, n, out = 0, len(css), []
|
||||
while i < n:
|
||||
j = css.find('{', i)
|
||||
if j < 0:
|
||||
break
|
||||
pre = css[i:j].strip()
|
||||
depth, k = 1, j + 1
|
||||
while k < n and depth:
|
||||
if css[k] == '{':
|
||||
depth += 1
|
||||
elif css[k] == '}':
|
||||
depth -= 1
|
||||
k += 1
|
||||
body = css[j + 1:k - 1]
|
||||
i = k
|
||||
if pre.startswith('@'):
|
||||
if re.match(r'@(media|supports)', pre):
|
||||
inner = walk(body)
|
||||
if inner:
|
||||
out.append(pre + '{\n' + '\n'.join(inner) + '\n}')
|
||||
continue
|
||||
# ⚠️ 所有规则的**所有**颜色声明(含没翻的)都抄一份:
|
||||
# 日间版里后写的规则会盖掉先写的;只抄翻过色的那几条,先写的规则加了 html.night 反而赢过后写的(踩过:工具卡变灰)。
|
||||
# 同理,头图那些不翻色的规则也照抄,免得被前面翻过色的通用规则压住。
|
||||
skip = bool(SKIP_SEL.search(pre))
|
||||
changed = skip
|
||||
decls = []
|
||||
for d in split_decls(body):
|
||||
if ':' not in d or d.startswith('--'):
|
||||
continue
|
||||
prop, val = d.split(':', 1)
|
||||
prop = prop.strip().lower()
|
||||
if prop not in BG_PROPS and prop not in FG_PROPS:
|
||||
continue
|
||||
nv = None
|
||||
if not skip and prop in BG_PROPS and not vivid(val):
|
||||
nv = recolor(val, bg_map) or map_keyword(prop, val)
|
||||
elif not skip and prop in FG_PROPS:
|
||||
nv = recolor(val, fg_map)
|
||||
if nv:
|
||||
changed = True
|
||||
decls.append(f'{prop}:{(nv or val).strip()}')
|
||||
sel = prefix_sel(pre)
|
||||
if decls and sel: # 没翻色的也抄:它可能是在覆盖前面某条翻过色的规则(工具卡彩色底就是这么丢的)
|
||||
out.append(sel + '{' + ';'.join(decls) + '}')
|
||||
return out
|
||||
|
||||
|
||||
rules = walk(strip_comments(SRC.read_text()))
|
||||
gen = MARK_A + '\n' + '\n'.join(rules) + '\n' + MARK_B
|
||||
old = OUT.read_text() if OUT.exists() else ''
|
||||
if MARK_A in old:
|
||||
a, b = old.index(MARK_A), old.index(MARK_B) + len(MARK_B)
|
||||
new = old[:a] + gen + old[b:]
|
||||
else:
|
||||
new = old + '\n' + gen + '\n'
|
||||
OUT.write_text(new)
|
||||
print(f'{len(rules)} 条规则 → {OUT}')
|
||||
13
tools/gen-nursery-sky.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 婴儿房封面补天空(2026-10-06)
|
||||
|
||||
起因:首页「我们的家」视差缝往上滑时,原封面(nursery-day/night.jpg,1125×1500)顶边正好切在松树中间,
|
||||
树被一刀截断、上面是一块纯色。用户要求「把树上面整体调成天空,衔接过渡一下」。
|
||||
|
||||
做法(产出 assets/img/nursery-{day,night}-sky.jpg,1125×2000):
|
||||
1. 原图贴到 1125×2000 画布底部,上方 500 像素填原图顶部天色(白天 #F0F5F7、夜晚 #2F375A)。
|
||||
2. 用图片生成技能(tupian,模型 nano_banana_pro,9:16 2k)图生图:只补上方空白——树尖补完整,往上是天空(白天淡云,夜晚小星星),其余不变。
|
||||
3. AI 结果直接缩放到 1125×2000(实测和原图逐像素对齐,无需平移),y<600 用 AI、600–760 smoothstep 渐变、y>760 用原图像素。
|
||||
4. JPG quality 88。
|
||||
|
||||
3D 婴儿房会话(33d6c837)以后若重新导出封面:照上面四步重做一遍,改 app.js 的 NY_COVER_V 和 index.html 的 ?v=。
|
||||
旧的 nursery-day.jpg / nursery-night.jpg 保留没删,回滚时把 -sky 去掉、CSS 里 margin-top:-136px 那条删掉即可。
|
||||