← 返回游乐场

纯前端作品:从 0 做到公网可访问

这份文档是干嘛的:一份自包含的操作手册。任何 AI(OpenCode / Claude / Codex / 随便什么)读完它,都应该能独立完成——写一个零依赖的纯前端作品,把 bug 挡在发布前,然后挂到公网上。 怎么用:整篇粘给你的 AI,后面附现成的提示词。或者只粘贴第 5 节让它学会部署。

0. 先定死边界:零依赖、零素材

这不是洁癖,是工程决策。放弃框架和素材文件,你换来四件事:

收益说明
体积极小两个完整项目一共 124KB,秒开,弱网也能跑
不会白屏没有 CDN、没有外链字体、没有图片 404,唯一的失败模式是你自己的代码
不会被墙国内环境下不依赖任何境外 CDN
没有版权风险图标用 emoji,音效现搓,图形手画,全部原创

具体怎么做:

``js ctx.font = '64px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif'; ``


1. 项目结构

项目名/
  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: ... };
})();

2. 移动端骨架(照抄)

2.1 viewport

<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">

2.2 CSS

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-*) 处理刘海屏和底部小横条,不加会因为被挡住而点不到。

2.3 高分屏(DPR)

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 倍像素量。全屏粒子/模糊效果下,填充率是移动端第一瓶颈,为了肉眼看不出的锐度去翻倍开销不值得。

2.4 输入:统一用 Pointer Events

一套代码覆盖触摸、鼠标、触控笔(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,别假设只有一个手指。流体/绘画类项目两根手指体验差别巨大。

2.5 帧循环

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 会瞬间产生天文数字的位移,物理直接爆炸(照片飞出银河系)。


3. 十条可复用配方

3.1 弹簧回弹(受击 / 拖拽回位)

比缓动函数(tween)更自然,因为它自带过冲。

// K=210, D=17 → 阻尼比 ≈ 0.59,会过冲一次再稳下来,手感"弹"
vx += ((targetX - x) * 210 - vx * 17) * dt;
x  += vx * dt;

dt 封顶 0.05 时这个数值是稳定的。想要更"软"就降 K,想要更"黏"就升 D。

3.2 屏幕震动

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();
// 全屏闪光之类的不要放进震动里

3.3 Web Audio 合成音效(不要音频文件)

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)(十二平均律往上爬)

两个必须记住的坑:

  1. exponentialRampToValueAtTime 的目标值必须 > 0,用 0.0001 代替 0,否则抛异常。时间也不能为负。
  2. 浏览器要求用户手势后才能出声。首次 pointerdown / click 里调 resume()。这不是 bug,所有网站都一样。

3.4 粒子系统

一个通用结构就够了:

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;
}

关键点:

3.5 手写流体(Stable Fluids)

Jos Stam 的经典解法,控制代码约 150 行。三个核心步骤:

  1. 平流(advect):半拉格朗日法——从当前格子顺着速度往回找,双线性插值取上一步的值
  2. 投影(project):解泊松方程求压力场,再把"会挤压"的速度分量减掉,保证速度场无散度
  3. 注入:手指经过处加染料 + 加力

非正方形网格的正确离散化(网上大部分代码是抄正方形版的,会各向异性):

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

性能技巧:

3.6 手写三维投影

不需要 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 缩放。

3.7 极坐标镜像(万花筒)

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。

3.8 持久伤痕 / 涂鸦层(增量绘制)

想要"痕迹一直留在图上"的效果:

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();

3.9 性能保命五条

  1. 缓存渐变对象。createRadialGradient 每帧调用会产生垃圾,在 resize 时建一次存起来
  2. 别每帧新建 ctx.font 字符串,拼字符串也是分配
  3. 批量描边:几千条线要按透明度分 3~4 档,每档一次 beginPath + 多次 moveTo/lineTo + 一次 stroke
  4. 大 shadowBlur 慎用:宁可预渲染精灵
  5. 粒子设硬上限,并优先删除最老的

3.10 交互反馈的"手感"公式

一条通用的时间轴,任何打击/点击类交互都能套:

挥入 dur(ease = p²  加速,有"砸下来"的感觉)
  ↓ 命中瞬间:触发音效 + 粒子 + 震屏 + 音效 + 定格
定格 50~60ms(关键!没有这一帧,打击感会"飘")
  ↓
收回 back(ease = 1 - smoothstep(q),缓出)

定格那 50ms 是打击感的灵魂。 少了它,快挥=没有重量。


4. 验证:没有浏览器也要能测

这是整份文档里我最推荐照抄的部分。用最小 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 });

必须覆盖的场景:

  1. 载入 / 初始化 —— 不报错且状态正确
  2. 每一种交互各走一遍 —— 例如 8 种武器每种都要真的命中至少 5 次
  3. 重置 —— 计数归零、状态清干净
  4. 终局 —— 能走到结束状态并弹出结算
  5. 空状态 —— 没载入任何东西就乱点,不能崩
  6. 四种视口 —— 390×844(手机竖)、360×640(小屏)、844×390(横屏)、1440×900(桌面)

为什么 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 就单独挂那个方法,别整个替换对象。


5. 部署:三条路

5.1 Cloudflare Pages —— 最推荐,跨平台

免费、不用绑卡、不用自有域名、给 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-ownedGET /accounts/{账户ID}/tokens/verify
32 位无前缀用户级 UserGET /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

其他也会被误报成"密钥无效"的情况:

R2 需先在后台启用 + 绑国际信用卡,没卡就走 Pages,别在 R2 上耗。

(Windows 报 SEC_E_ILLEGAL_MESSAGE 0x80090326),这不是签名算错,是链路不通。

喂别的 AI 凭据时,务必把这一节一起喂过去,否则它会用 /user/tokens/verify 验一次, 然后一口咬定"你的 API 无效"。

5.1.1 自己造一个干净的最小权限令牌

大号令牌(几百项权限、含 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                            # 期望列出项目表

5.2 GitHub Pages

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 通常可达。传文件时可能需要代理。

5.3 Vercel / Netlify / Surge

npx vercel deploy --prod ./你的目录          # → xxx.vercel.app
npx netlify deploy --prod --dir=./你的目录    # → xxx.netlify.app
npx surge ./你的目录                          # 最简单,需填一次邮箱

6. 发布后自查清单

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 坑:


7. 直接复制的提示词

本节的提示词用 <令牌> 占位,不要把真令牌写进会被公开的文档里。 实际值存在本地 Cloudflare部署凭据.txt。带真令牌的完整版在 给AI的提示词.md。

7.1 让它写一个新作品

帮我做一个纯前端的网页小游戏,要求: 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 状态码并贴给我,不接受"发布成功"四个字。 具体玩法:\<在这里写你要的东西\>

7.2 只让它部署

把这个目录发布到公网,用 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 状态码和文件字节数,然后把链接给我。别只说"发布成功"。

7.3 让它审代码

检查这个纯前端项目,重点看: 1. 有没有每帧创建对象的分配热点(渐变、字体字符串、数组) 2. 有没有逐段改 globalAlpha 或大 shadowBlur 的性能陷阱 3. 粒子数/数组有没有硬上限 4. dt 有没有夹紧;切标签页回来会不会物理爆炸 5. 高分屏 DPR 有没有封顶 6. 有没有 NaN 坐标的可能(Canvas 会静默忽略,只表现为黑屏)

7.4 ⭐ 通用故障排除卡(每次都要一起喂)

这一段是整个第 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/

两个项目加起来 124KB,零依赖零素材。

这份文档可以直接喂给 AI把下面这个地址给你的 AI,它能自己读完全文:https://canvas-playground-2im.pages.dev/guide/