这份文档是干嘛的:一份自包含的操作手册。任何 AI(OpenCode / Claude / Codex / 随便什么)读完它,都应该能独立完成——写一个零依赖的纯前端作品,把 bug 挡在发布前,然后挂到公网上。 怎么用:整篇粘给你的 AI,后面附现成的提示词。或者只粘贴第 5 节让它学会部署。
这不是洁癖,是工程决策。放弃框架和素材文件,你换来四件事:
| 收益 | 说明 |
|---|---|
| 体积极小 | 两个完整项目一共 124KB,秒开,弱网也能跑 |
| 不会白屏 | 没有 CDN、没有外链字体、没有图片 404,唯一的失败模式是你自己的代码 |
| 不会被墙 | 国内环境下不依赖任何境外 CDN |
| 没有版权风险 | 图标用 emoji,音效现搓,图形手画,全部原创 |
具体怎么做:
fillText,字体栈写全: ``js ctx.font = '64px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif'; ``
document.createElement('canvas') 现画,或让用户上传项目名/
index.html
style.css
js/
app.js # 调度器:尺寸、输入、帧循环
<功能模块>.js # 每个模块挂到 window.XXX 命名空间
用经典 <script> 顺序加载,不用 ES module。 理由:模块需要正确的 MIME 类型和 CORS 头,静态托管一配错就整个白屏;经典脚本没有这个失败模式。
模块之间通过全局命名空间通信:
// js/fluid.js
(function () {
window.DEMOS = window.DEMOS || {};
window.DEMOS.fluid = { init: ..., frame: ..., down: ..., destroy: ... };
})();
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#05060a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
html,body{height:100%;overflow:hidden;background:#05060a}
body{overscroll-behavior:none; /* 掐掉 iOS 橡皮筋回弹 */
touch-action:none; /* 掐掉双指缩放和滚动 */
user-select:none;-webkit-user-select:none;
-webkit-touch-callout:none} /* 掐掉长按菜单 */
#stage{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none}
/* HUD 叠在 canvas 上:默认不吃事件,只有按钮吃 */
.hud{position:fixed;inset:0;pointer-events:none;
padding:calc(env(safe-area-inset-top,0px) + 18px) 20px
calc(env(safe-area-inset-bottom,0px) + 16px)}
.hud button{pointer-events:auto}
env(safe-area-inset-*) 处理刘海屏和底部小横条,不加会因为被挡住而点不到。
var DPR = Math.min(window.devicePixelRatio || 1, 2); // 必须封顶!
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(DPR, 0, 0, DPR, 0, 0); // 之后一律用 CSS 像素坐标,不用自己乘
为什么封顶 2:3x 屏意味着 3 倍像素量。全屏粒子/模糊效果下,填充率是移动端第一瓶颈,为了肉眼看不出的锐度去翻倍开销不值得。
一套代码覆盖触摸、鼠标、触控笔(iOS 13+ / 安卓 Chrome 全支持)。
canvas.addEventListener('pointerdown', onDown, { passive: false });
window.addEventListener('pointermove', onMove, { passive: true });
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp); // 系统打断也要收尾
window.addEventListener('contextmenu', e => e.preventDefault());
多点触控:用 e.pointerId 当 key 存 Map,别假设只有一个手指。流体/绘画类项目两根手指体验差别巨大。
var raf = 0, last = 0;
function loop(ts) {
raf = requestAnimationFrame(loop);
var dt = (ts - last) / 1000;
last = ts;
if (!(dt > 0)) dt = 1 / 60;
if (dt > 0.05) dt = 0.05; // ★ 必须夹紧
step(dt);
render();
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) cancelAnimationFrame(raf);
else { last = performance.now(); raf = requestAnimationFrame(loop); }
});
为什么必须夹紧 dt:切走标签页再切回来,两帧之间可能隔了几十秒。不夹紧的话,v += a * dt 会瞬间产生天文数字的位移,物理直接爆炸(照片飞出银河系)。
比缓动函数(tween)更自然,因为它自带过冲。
// K=210, D=17 → 阻尼比 ≈ 0.59,会过冲一次再稳下来,手感"弹"
vx += ((targetX - x) * 210 - vx * 17) * dt;
x += vx * dt;
dt 封顶 0.05 时这个数值是稳定的。想要更"软"就降 K,想要更"黏"就升 D。
shake *= Math.exp(-8.5 * dt); // 指数衰减,比线性自然
if (shake < 0.15) shake = 0;
// 画的时候:
ctx.save();
ctx.translate((Math.random()*2-1)*shake*0.5, (Math.random()*2-1)*shake*0.5);
// ... 画整个场景 ...
ctx.restore();
// 全屏闪光之类的不要放进震动里
var ac = null, noiseBuf = null;
function boot() {
if (ac) return ac;
ac = new (window.AudioContext || window.webkitAudioContext)();
var len = Math.floor(ac.sampleRate * 2);
noiseBuf = ac.createBuffer(1, len, ac.sampleRate);
var d = noiseBuf.getChannelData(0);
for (var i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; // 白噪声缓存,播时复用
return ac;
}
function resume() { if (ac && ac.state === 'suspended') ac.resume(); }
/* 噪声 + 滤波扫频 —— 打击/爆炸/摩擦类音效的主力 */
function noise(dur, gain, type, f0, f1) {
var t = ac.currentTime;
var s = ac.createBufferSource(); s.buffer = noiseBuf; s.loop = true;
var f = ac.createBiquadFilter(); f.type = type;
f.frequency.setValueAtTime(f0, t);
f.frequency.exponentialRampToValueAtTime(Math.max(24, f1), t + dur);
var g = ac.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.006); // 6ms 起音 = 有冲击力
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(f); f.connect(g); g.connect(ac.destination);
s.start(t); s.stop(t + dur + 0.03);
}
/* 振荡器包络 —— 音高/金属/提示音 */
function tone(type, f0, f1, dur, gain) {
var t = ac.currentTime;
var o = ac.createOscillator(); o.type = type;
o.frequency.setValueAtTime(f0, t);
o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + dur);
var g = ac.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(ac.destination);
o.start(t); o.stop(t + dur + 0.03);
}
配方速查表:
| 想要的声音 | 做法 |
|---|---|
| 湿的爆汁 | 带通噪声 1500→260Hz + 正弦 340→88Hz |
| 闷雷重击 | 正弦 150→34Hz 长尾 + 低通噪声 1100→110Hz |
| 金属"当" | 三个失谐方波(790 / 1180 / 1960Hz)各自快速衰减 + 高通噪声起音 |
| 干硬拳击 | 带通噪声 700→140Hz + 正弦 240→62Hz,都压到 0.12s |
| 电流噼啪 | 锯齿波每 ~20ms 随机跳频 + 高通噪声 |
| 爆炸 | 长噪声低通下扫 + 次低频正弦 + 一层高通碎屑 |
| 连击递增 | f = 660 * Math.pow(1.0595, n)(十二平均律往上爬) |
两个必须记住的坑:
exponentialRampToValueAtTime 的目标值必须 > 0,用 0.0001 代替 0,否则抛异常。时间也不能为负。pointerdown / click 里调 resume()。这不是 bug,所有网站都一样。一个通用结构就够了:
var parts = [];
function push(p) { if (parts.length < 700) parts.push(p); } // ★ 必须设上限
// p = { k:'juice'|'spark'|'smoke'|'debris'|'bolt',
// x,y,vx,vy, life,max, r, col, g, drag, rot, vr }
// 每帧(倒序遍历,边删边迭代):
for (var i = parts.length - 1; i >= 0; i--) {
var p = parts[i];
p.life += dt;
if (p.life >= p.max) { parts.splice(i, 1); continue; }
p.vy += p.g * dt;
var dr = p.drag != null ? Math.pow(p.drag, dt * 60) : 1; // 帧率无关的阻尼
p.vx *= dr; p.vy *= dr;
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.vr) p.rot += p.vr * dt;
}
关键点:
Math.pow(drag, dt*60) 而不是 v *= 0.9。后者在不同帧率下表现不同(144Hz 屏上会瞬间停住)。Jos Stam 的经典解法,控制代码约 150 行。三个核心步骤:
非正方形网格的正确离散化(网上大部分代码是抄正方形版的,会各向异性):
dx = 1/N, dy = 1/M (域取 [0,1]²)
散度 div = (u[i+1]-u[i-1])/(2dx) + (v[j+1]-v[j-1])/(2dy)
泊松 p = [ (p[i+1]+p[i-1])/dx² + (p[j+1]+p[j-1])/dy² - div ] / (2/dx² + 2/dy²)
去梯度 u -= (p[i+1]-p[i-1])/(2dx), v -= (p[j+1]-p[j-1])/(2dy)
平流 x_src = i - dt*u/dx = i - dt*u*N
性能技巧:
visc = 0 直接跳过扩散步(半拉格朗日平流已经自带数值耗散),省掉一半开销putImageData 到 N×M 的离屏小 canvas,再 drawImage 放大到全屏。别逐像素画不需要 WebGL 也能有立体感,约 60 行:
// 1) 生成三维顶点(例:(2,3) 环面纽结参数曲线)
for (i...) {
var u = i/n * Math.PI * 2 * 2; // p = 2
var v = u * 3 / 2; // q = 3
var R = 2 + Math.cos(v);
push({ x: R*Math.cos(u)/3, y: R*Math.sin(u)/3, z: Math.sin(v)*0.68 });
}
// 2) 两个轴的旋转矩阵
var y1 = y*cX - z*sX, z1 = y*sX + z*cX; // 绕 X
var x1 = x*cY + z1*sY; z1 = -x*sY + z1*cY; // 绕 Y
// 3) 透视投影
var k = focal / (camD - z1);
screenX = cx + x1 * k * scale;
screenY = cy + y1 * k * scale;
// 4) 深度调制:近大远小、近亮远暗
让它好看的关键是深度分层:按 z 分 8 档,每档一个 beginPath 批量描边,透明度按档渐变。逐段改 globalAlpha 画 600 段会卡。
辉光不要用 shadowBlur(填充率杀手)。预渲染 12~14 张不同色相的径向渐变精灵,按深度 drawImage 缩放。
function mirror(ctx, cx, cy, ax, ay, bx, by, N) {
var seg = Math.PI * 2 / N;
var r0 = Math.max(7, Math.hypot(ax, ay)); // ★ 半径下限,否则中心处角度乱跳
var r1 = Math.max(7, Math.hypot(bx, by));
var a0 = Math.atan2(ay, ax), a1 = Math.atan2(by, bx);
var b0 = a0 - Math.floor(a0 / seg) * seg; // 折到 [0, seg)
var b1 = a1 - Math.floor(a1 / seg) * seg;
if (Math.abs(b1 - b0) > seg * 0.45) return; // 跨扇区就跳过,避免拉出弦线
for (var i = 0; i < N; i++) {
var o = i * seg, o2 = (i + 1) * seg;
// 扇区内两个镜像角:o+b 和 (i+1)*seg-b
line(cx + Math.cos(o + b0) * r0, cy + Math.sin(o + b0) * r0,
cx + Math.cos(o + b1) * r1, cy + Math.sin(o + b1) * r1);
line(cx + Math.cos(o2 - b0) * r0, cy + Math.sin(o2 - b0) * r0,
cx + Math.cos(o2 - b1) * r1, cy + Math.sin(o2 - b1) * r1);
}
}
两个坑:半径低于 ~7px 时角度会剧烈跳变(画面炸成放射状);线段跨扇区必须跳过或细分,否则会出现横穿整个圆的弦线。长线段按 7px 细分再逐段 mirror。
想要"痕迹一直留在图上"的效果:
var dmgCv = document.createElement('canvas');
var dmgCtx = dmgCv.getContext('2d');
var marks = []; // 伤痕数据,用于窗口缩放后重绘
// 单次命中:只画这一道新的(增量,O(1))
marks.push(mk);
WEAPONS.drawMark(dmgCtx, mk, photoW, photoH);
// 只有在 resize 时才全量重绘
function redraw() {
dmgCtx.clearRect(0, 0, photoW, photoH);
for (var i = 0; i < marks.length; i++) WEAPONS.drawMark(dmgCtx, marks[i], photoW, photoH);
// 并同步重设 dmgCv.width/height = photoW*DPR ...
}
每次命中都全量重绘是 O(n²) 的坑,打到 100 道痕就会明显掉帧。
伤痕要用可复现随机数(种子固定),否则 resize 重绘后每道痕会长得不一样:
function rng(seed) {
var a = seed >>> 0;
return function () {
a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
绘制时 ctx.save(); ctx.translate(x*W, y*H); ctx.rotate(rng()*6.28); ...; ctx.restore();
createRadialGradient 每帧调用会产生垃圾,在 resize 时建一次存起来ctx.font 字符串,拼字符串也是分配beginPath + 多次 moveTo/lineTo + 一次 strokeshadowBlur 慎用:宁可预渲染精灵一条通用的时间轴,任何打击/点击类交互都能套:
挥入 dur(ease = p² 加速,有"砸下来"的感觉)
↓ 命中瞬间:触发音效 + 粒子 + 震屏 + 音效 + 定格
定格 50~60ms(关键!没有这一帧,打击感会"飘")
↓
收回 back(ease = 1 - smoothstep(q),缓出)
定格那 50ms 是打击感的灵魂。 少了它,快挥=没有重量。
这是整份文档里我最推荐照抄的部分。用最小 shim 在 Node 里跑真实代码路径。
const fs = require('fs'), vm = require('vm');
/* ---- 假 Canvas:每个方法都断言参数有限性 ---- */
function guard(tag, args) {
for (const v of args)
if (typeof v === 'number' && !isFinite(v))
throw new Error(tag + ' 出现 NaN/Infinity: ' + JSON.stringify(args));
}
function makeCtx() {
return {
globalAlpha: 1, fillStyle: '#000', strokeStyle: '#000', lineWidth: 1,
font: '', textAlign: '', textBaseline: '', globalCompositeOperation: 'source-over',
shadowColor: '', shadowBlur: 0, shadowOffsetY: 0, imageSmoothingEnabled: false,
setTransform(...a){ guard('setTransform', a); },
save(){}, restore(){}, translate(...a){ guard('translate', a); },
rotate(...a){ guard('rotate', a); }, scale(...a){ guard('scale', a); },
clearRect(...a){ guard('clearRect', a); }, fillRect(...a){ guard('fillRect', a); },
beginPath(){}, closePath(){},
moveTo(...a){ guard('moveTo', a); }, lineTo(...a){ guard('lineTo', a); },
quadraticCurveTo(...a){ guard('quadraticCurveTo', a); },
arc(...a){ guard('arc', a); }, ellipse(...a){ guard('ellipse', a); },
stroke(){}, fill(){}, strokeText(){}, fillText(){},
measureText(){ return { width: 10 }; },
drawImage(img, ...a){ guard('drawImage', a); if (!img) throw new Error('drawImage 空图像'); },
putImageData(){}, getImageData(x,y,w,h){ return { width:w, height:h, data:new Uint8ClampedArray(w*h*4) }; },
createImageData(w,h){ return { width:w, height:h, data:new Uint8ClampedArray(w*h*4) }; },
createRadialGradient(...a){ guard('createRadialGradient', a); return { addColorStop(){} }; },
createLinearGradient(...a){ guard('createLinearGradient', a); return { addColorStop(){} }; }
};
}
/* ---- 手动驱动 rAF,才能确定性地推进帧 ---- */
let rafCb = null, clock = 0;
global.requestAnimationFrame = fn => { rafCb = fn; return 1; };
global.cancelAnimationFrame = () => { rafCb = null; };
function pump(frames, ms) {
for (let i = 0; i < frames; i++) {
const fn = rafCb; if (!fn) return;
clock += (ms || 16.7); fn(clock);
}
}
/* ---- 加载真实源码 ---- */
for (const f of files) vm.runInThisContext(fs.readFileSync(f, 'utf8'), { filename: f });
必须覆盖的场景:
为什么 NaN 断言最重要:Canvas 碰到 NaN 坐标静默忽略,不报错。结果是控制台干干净净、屏幕一片黑——最难查的一类 bug。
性能基准:
const t0 = process.hrtime.bigint();
for (let i = 0; i < 400; i++) { step(1/60); render(); }
const ms = Number(process.hrtime.bigint() - t0) / 1e6 / 400;
console.log(ms.toFixed(2) + ' ms/帧 (' + (1000/ms).toFixed(0) + ' fps 上限)');
逻辑层能到 1~2ms/帧 就说明 JS 不是瓶颈,剩下的看 GPU 填充率。手机通常比 Node 慢 2~3 倍,所以留出 3 倍余量再发布。
⚠️ 一个环境坑:别在 shim 里覆写 global.URL。WorkBuddy 的 node 文件读写走了一层 broker,里面用 filePath instanceof URL 判断,替换掉 URL 会抛 Right-hand side of 'instanceof' is not callable。要用 URL.createObjectURL 就单独挂那个方法,别整个替换对象。
免费、不用绑卡、不用自有域名、给 xxx.pages.dev。任何 AI 或终端都能跑:
CLOUDFLARE_API_TOKEN=<令牌> CLOUDFLARE_ACCOUNT_ID=<账户ID> \
npx --yes wrangler@latest pages deploy ./你的目录 \
--project-name=<项目名> --branch=main
# → https://<项目名>.pages.dev
首次要先建项目:
CLOUDFLARE_API_TOKEN=<令牌> CLOUDFLARE_ACCOUNT_ID=<账户ID> \
npx --yes wrangler@latest pages project create <项目名> --production-branch=main
令牌权限要求:必须有 Pages Write。先查,不要猜:
curl -sS "https://api.cloudflare.com/client/v4/accounts/$ACC/tokens" \
-H "Authorization: Bearer $TOKEN" \
| python -c "import json,sys; d=json.load(sys.stdin); t=d['result'][0]; \
print([p['name'] for pol in t['policies'] for p in pol['permission_groups']])"
在输出里找 Pages Write。没有就去后台 My Profile → API Tokens 新建一个,模板选 "Edit Cloudflare Workers" 或自定义勾上 Account → Cloudflare Pages → Edit。
这是个纯静态目录,不需要构建步骤,直接把文件夹传上去。
🚨 我的 AI 说"API 无效"怎么办 —— 九成是它验证错端点
Cloudflare 的令牌分两种,验证端点不一样,用错端点会返回"Invalid API Token",但令牌其实是好的:
| 令牌前缀 | 类型 | 正确验证端点 |
|---|---|---|
cfat_ | 账号级 Account-owned | GET /accounts/{账户ID}/tokens/verify |
| 32 位无前缀 | 用户级 User | GET /user/tokens/verify |
实测对照(同一个 cfat_ 令牌):
TK=cfat_xxx; ACC=你的账户ID
curl -sS -H "Authorization: Bearer $TK" \
"https://api.cloudflare.com/client/v4/user/tokens/verify"
# → 401 {"success":false,"errors":[{"code":1000,"message":"Invalid API Token"}]} ← 假警报!
curl -sS -H "Authorization: Bearer $TK" \
"https://api.cloudflare.com/client/v4/accounts/$ACC/tokens/verify"
# → 200 {"result":{"status":"active"},"success":true} ← 真相:令牌有效
结论:code:1000 Invalid API Token 只说明"这个端点不认识这个令牌",不等于令牌失效。 把上面这两条命令原样丢给你的 AI,让它自己跑一遍就明白了。
同一个坑还有第二张脸 —— code:9109
账号级令牌去碰任何 /user/* 接口都会栽,而且报的错更唬人:
curl -sS -H "Authorization: Bearer $TK" "https://api.cloudflare.com/client/v4/user"
# → 403 {"success":false,"errors":[{"code":9109,
# "message":"Valid user-level authentication not found"}]}
9109 Valid user-level authentication not found 字面意思是"没找到有效的用户级身份", 不是"你的密钥是错的"。很多 AI 看到这句直接下结论"你的 API 用不了"。 记账原理:账号级令牌代表的是「这个账户」,而不是「某个登录用户」, 所以所有挂在 /user/ 下的接口(用户信息、用户级令牌列表……)它一律无权访问,这是设计如此。
一张表认全三种"假故障":
| 现象 | 真实含义 | 该怎么做 |
|---|---|---|
401 code:1000 Invalid API Token | 端点用错了(拿账号级令牌验用户级端点) | 换 /accounts/{id}/tokens/verify |
403 code:9109 Valid user-level authentication not found | 拿账号级令牌访问 /user/* | 改用 /accounts/{id}/... 下的对应接口 |
403 code:10042 Please enable R2... | R2 功能没开 | 去后台开 R2 绑卡;或干脆改用 Pages |
其他也会被误报成"密钥无效"的情况:
code:10042 "Please enable R2 through the Cloudflare Dashboard."R2 需先在后台启用 + 绑国际信用卡,没卡就走 Pages,别在 R2 上耗。
2b54df...r2.cloudflarestorage.com TLS 握手直接断 (Windows 报 SEC_E_ILLEGAL_MESSAGE 0x80090326),这不是签名算错,是链路不通。
code 既不是 1000 也不是 9109。用上面那条 /accounts/$ACC/tokens 命令查自己真实权限列表。喂别的 AI 凭据时,务必把这一节一起喂过去,否则它会用 /user/tokens/verify 验一次, 然后一口咬定"你的 API 无效"。
大号令牌(几百项权限、含 Billing 和 Zero Trust)一旦泄露损失很大。 用有 Account API Tokens Write 权限的旧令牌,可以直接调 API 造一个新的, 全程不用打开后台网页:
# 1) 先查权限组的 id(按名字找)
# GET /accounts/{ACC}/tokens/permission_groups → 每个组有 id 和 name
# 2) 再创建令牌
POST /accounts/{ACC}/tokens
{
"name": "deploy-minimal",
"policies": [{
"effect": "allow",
"resources": {"com.cloudflare.api.account.{ACC}": "*"},
"permission_groups": [ {"id": "<Pages Write 的 id>"}, ... ]
}]
}
# → 响应里的 result.value 就是新令牌明文,只出现这一次,务必当场存下
部署专用最小权限清单(9 项,够用且安全):
Pages Write / Pages Read
Workers Scripts Write / Read
Workers KV Storage Write / Read
Workers R2 Storage Write / Read
Account Settings Read
注意:Zone Read、Workers Routes Write 属于域名级权限, 必须挂在 com.cloudflare.api.account.zone.* 资源下,不能放进账号级 policy 里, 否则创建会失败。账户里没有域名的话,这两个也不需要。
创建后立刻自测(两条都过才算真的能用):
curl -sS -H "Authorization: Bearer $NEW" \
"https://api.cloudflare.com/client/v4/accounts/$ACC/tokens/verify" # 期望 200 active
CLOUDFLARE_API_TOKEN=$NEW CLOUDFLARE_ACCOUNT_ID=$ACC \
npx --yes wrangler@latest pages project list # 期望列出项目表
cd 你的目录
git init && git add . && git commit -m "init"
gh repo create <仓库名> --public --source=. --push
gh api -X POST repos/:owner/<仓库名>/pages \
-f 'source[branch]=main' -f 'source[path]=/'
# → https://<用户名>.github.io/<仓库名>/
⚠️ 国内网络对 github.com 不稳定(可能要挂代理),但 github.io 通常可达。传文件时可能需要代理。
npx vercel deploy --prod ./你的目录 # → xxx.vercel.app
npx netlify deploy --prod --dir=./你的目录 # → xxx.netlify.app
npx surge ./你的目录 # 最简单,需填一次邮箱
URL="https://xxx.pages.dev/"
# 每个页面都要 200(别忘了子目录页)
for p in "" "lab/" "smash/"; do
printf "%-20s %s\n" "$p" "$(curl -sS -o /dev/null -w '%{http_code}' "${URL}${p}")"
done
# MIME 要对
curl -sSI "${URL}js/app.js" | grep -i content-type # → application/javascript
# 字节数和本地一致(抓上传不完整)
curl -sS "${URL}js/app.js" | wc -c
wc -c < ./js/app.js
两个 curl 坑:
"${URL}path"。写成 "$URLpath" 会被 shell 当成变量 URLpath,报 Port number was not a decimal number502 / CONNECT tunnel failed 是代理抖动,重试即可,别当成文件缺失本节的提示词用<令牌>占位,不要把真令牌写进会被公开的文档里。 实际值存在本地Cloudflare部署凭据.txt。带真令牌的完整版在给AI的提示词.md。
帮我做一个纯前端的网页小游戏,要求: 1. 零依赖零素材:不用任何框架和库,不引 CDN,不加载图片/音频文件。图标用 emoji,音效用 Web Audio API 现场合成,图形用 Canvas 2D 手画。 2. 移动端优先:竖屏手机设计,同时兼容横屏和桌面。viewport 用user-scalable=no,viewport-fit=cover,DPR 封顶 2,HUD 用 DOM 叠在 canvas 上并处理safe-area-inset。 3. 输入用 Pointer Events(一次覆盖触摸/鼠标),支持多点触控。 4. 帧循环里 dt 必须夹紧到 0.05 秒以内。 5. 做完在项目根目录外写一个 Node 冒烟测试脚本,用最小 DOM/Canvas/WebAudio shim 驱动真实主循环,覆盖:初始化、每种交互、重置、终局、空状态不崩、四种视口。绘图 stub 的每个方法都要断言参数有限性来抓 NaN。 6. 全部测试通过后,再执行发布。 7. 发布完必须用 curl 逐个页面验证 HTTP 状态码并贴给我,不接受"发布成功"四个字。 具体玩法:\<在这里写你要的东西\>
把这个目录发布到公网,用 Cloudflare Pages(免费、不用绑卡):
CLOUDFLARE_API_TOKEN=<令牌> CLOUDFLARE_ACCOUNT_ID=<账户ID> \
npx --yes wrangler@latest pages deploy ./目录 --project-name=xxx --branch=main --commit-dirty=true
发布完必须用 curl 逐个页面验证 HTTP 状态码和文件字节数,然后把链接给我。别只说"发布成功"。
检查这个纯前端项目,重点看: 1. 有没有每帧创建对象的分配热点(渐变、字体字符串、数组) 2. 有没有逐段改globalAlpha或大shadowBlur的性能陷阱 3. 粒子数/数组有没有硬上限 4.dt有没有夹紧;切标签页回来会不会物理爆炸 5. 高分屏 DPR 有没有封顶 6. 有没有 NaN 坐标的可能(Canvas 会静默忽略,只表现为黑屏)
这一段是整个第 7 节的灵魂。 不带上它,别的 AI 大概率会报"你的 API 无效"然后停摆:
上面那个 cfat_ 开头的令牌是「账号级令牌」(Account-owned Token),不是用户级令牌。
验证和使用它时,端点必须走 /accounts/ 这一侧。
■ 不要这样做(会得到假故障):
GET /user/tokens/verify
→ 401 {"code":1000,"message":"Invalid API Token"}
↑ 不是令牌失效,是端点用错了
GET /user 或 GET /user/tokens
→ 403 {"code":9109,"message":"Valid user-level authentication not found"}
↑ 账号级令牌代表「这个账户」而非「某个登录用户」,
所有 /user/* 接口它天生无权,设计如此,不是密钥错
■ 正确做法:
GET /accounts/{账户ID}/tokens/verify
→ 200 {"result":{"status":"active"},"success":true} ← 真相
■ 三种"假故障"对照表:
| 你看到的 | 真实含义 | 怎么办 |
|---|---|---|
| 401 code:1000 Invalid API Token | 端点用错 | 换 /accounts/{id}/tokens/verify |
| 403 code:9109 Valid user-level authentication not found | 碰了 /user/* | 改用 /accounts/{id}/... |
| 403 code:10042 Please enable R2... | R2 没开 | 别用 R2,改用 Pages |
■ 明确不要碰 R2:
该账户的 R2 从未启用(403 code:10042),启用需绑国际信用卡,且
*.r2.cloudflarestorage.com 在国内 TLS 握手会被掐断(SEC_E_ILLEGAL_MESSAGE)。
用 Cloudflare Pages 就够 —— 免费、不绑卡、纯静态直接传、无需构建步骤。
■ 动手前先自检两条,都过了再开始:
1) /accounts/{id}/tokens/verify 返回 200 active
2) npx --yes wrangler@latest pages project list 能列出项目表
实测产出,可直接打开看源码(右键查看源代码 / F12):
| 页面 | 地址 |
|---|---|
| 首页 | https://canvas-playground-2im.pages.dev/ |
| CANVAS LAB(四个视觉实验) | https://canvas-playground-2im.pages.dev/lab/ |
| 出气筒(上传照片打击游戏) | https://canvas-playground-2im.pages.dev/smash/ |
lab/js/fluid.js —— 完整流体求解器lab/js/knot.js —— 手写三维投影lab/js/kaleido.js —— 极坐标镜像smash/js/sfx.js —— 11 种合成音效smash/js/weapons.js —— 8 种伤痕绘制 + 可复现随机数smash/js/game.js —— 弹簧物理 + 粒子 + 持久层两个项目加起来 124KB,零依赖零素材。
https://canvas-playground-2im.pages.dev/guide/