用 ChatGPT 设计玩法和关卡,用 Codex CLI 写出一个能在手机和电脑浏览器里直接玩的打砖块游戏,逻辑与画面分离、带自动化测试,最后可以和网站教程一样一条命令部署上线。
先看清成品
成品是一个 480×640 的 Canvas 小游戏:5 行 8 列砖块(上两行要打两次),挡板可用鼠标、手指或方向键控制,3 条命,记录最高分。纯 HTML 和原生 JavaScript,无任何依赖。文中代码已在本地运行,5 个自动化测试全部通过,并用无头浏览器截图确认画面正常。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 一个可玩的游戏 | 发球、反弹、击碎砖块、漏球扣命、通关和失败 |
| 手感细节 | 击中挡板不同位置反弹角度不同,每次反弹略微加速 |
| 多端操作 | 鼠标、触摸、键盘三种输入都能用 |
| 测试 | 碰撞、计分、胜负和 60 秒连续模拟都有测试覆盖 |
需要准备哪些环境?
| 项目 | 要求 | 检查命令 |
|---|---|---|
| Node.js | 18 或更高即可跑测试,部署用 Wrangler 需 22+ | node --version |
| Git | 任意较新版本 | git --version |
| Codex CLI | 已安装并登录 | codex --version |
| ChatGPT | 用于设计玩法 | — |
| 浏览器 | 桌面或手机浏览器均可 | — |
Codex 的安装和登录步骤见本系列网站教程,这里不重复。
第一步:怎样让 ChatGPT 把玩法写成“可实现的规格”?
游戏比网页更容易“做出来但不好玩”。先让 ChatGPT 把手感写成数字,Codex 才有明确目标。
1. 发送玩法设计提示词
我要做一个浏览器打砖块游戏,单关即可,目标是 3 分钟内能玩完一局。
请输出一份给程序员的规格,全部用具体数字:
1. 画布尺寸、砖块行列数、砖块尺寸和间距、哪些砖块需要打两次
2. 球的初速度(像素/秒)、每次碰挡板的加速比例、最大反弹角度
3. 击中挡板不同位置时反弹角度怎么算
4. 生命数、计分规则、胜负条件
5. 三种输入方式(鼠标、触摸、键盘)各自怎么映射到挡板位置
6. 5 条最容易出现的 bug 及预防方法(例如高速穿透砖块)
不要写代码。
2. 检查结果
规格里每一条都应该是数字或明确规则。像“速度适中”这种描述要追问改成数字。
本项目最终采用的关键数值:
| 参数 | 数值 |
|---|---|
| 画布 | 480 × 640 |
| 砖块 | 5 行 × 8 列,52 × 18,间距 6,上两行 2 点耐久 |
| 球初速度 | vx 180、vy -260 像素/秒 |
| 挡板反弹 | 按击中位置在 ±60° 内取角,速度乘 1.02 |
| 生命 | 3 |
| 计分 | 每击碎一块 10 分 |
3. 让 ChatGPT 生成 AGENTS.md
把上面的规格整理成 AGENTS.md,给 AI 编程助手看。必须包含:
- 逻辑放在 game/logic.js,纯函数,不访问 DOM 和 canvas,可在 Node 中测试
- 绘制和输入放在 game/main.js
- 用 requestAnimationFrame 驱动,按真实经过的时间 dt 更新,dt 上限 1/30 秒
- 测试文件 logic.test.mjs,用 node --test 运行
控制在 40 行以内。
第二步:怎样让 Codex 生成游戏?
1. 初始化项目
mkdir breakout && cd breakout
git init
# 把 ChatGPT 生成的内容保存为 AGENTS.md
git add AGENTS.md && git commit -m "规格"
codex
2. 发送生成提示词
按 AGENTS.md 实现打砖块游戏。
logic.js 需要导出:W、H、createLevel、createState、hitRect(ball, rect)、launch(state)、step(state, dt, paddleX)。
hitRect 返回碰撞轴 "x" 或 "y",没碰到返回 null。
step 在发球前让球跟随挡板;每帧最多处理一块砖的碰撞;球落到底部扣一条命并重新发球。
main.js:pointermove/pointerdown 控制挡板,空格发球,左右方向键移动;最高分存 localStorage。
index.html:canvas 宽高 480x640,CSS 让它在手机上按比例缩放,禁止触摸时页面滚动。
写好测试:碰撞检测、发球前跟随、漏球扣命至判负、全部击碎判胜、连续模拟 60 秒球不越界。
完成后运行 node --test 并汇报结果。
3. 检查生成的文件
breakout/
├── AGENTS.md
├── package.json
├── logic.test.mjs
└── game/
├── index.html
├── logic.js
└── main.js
4. 跑测试
npm test
期望看到 pass 5、fail 0。
关键代码长什么样?
1. 游戏逻辑 game/logic.js
// 打砖块的纯逻辑:不依赖 canvas,可在 Node 里测试
export const W = 480, H = 640;
export function createLevel({ rows = 5, cols = 8 } = {}) {
const bw = 52, bh = 18, gap = 6, top = 60;
const left = (W - (cols * bw + (cols - 1) * gap)) / 2;
const bricks = [];
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
bricks.push({ x: left + c * (bw + gap), y: top + r * (bh + gap), w: bw, h: bh, hp: r < 2 ? 2 : 1 });
return bricks;
}
export function createState() {
return {
paddle: { x: W / 2 - 40, y: H - 40, w: 80, h: 12 },
ball: { x: W / 2, y: H - 60, r: 7, vx: 180, vy: -260, stuck: true },
bricks: createLevel(), score: 0, lives: 3, status: "ready", // ready | playing | won | lost
};
}
// 圆与矩形碰撞:返回碰撞的轴,未碰撞返回 null
export function hitRect(b, rc) {
const cx = Math.max(rc.x, Math.min(b.x, rc.x + rc.w));
const cy = Math.max(rc.y, Math.min(b.y, rc.y + rc.h));
const dx = b.x - cx, dy = b.y - cy;
if (dx * dx + dy * dy > b.r * b.r) return null;
return Math.abs(dx) > Math.abs(dy) ? "x" : "y";
}
export function launch(s) {
if (s.status === "ready") { s.status = "playing"; s.ball.stuck = false; }
}
// dt 单位为秒;paddleX 为目标挡板中心位置(鼠标/触摸/键盘给出)
export function step(s, dt, paddleX) {
const p = s.paddle, b = s.ball;
if (paddleX !== undefined) p.x = Math.max(0, Math.min(W - p.w, paddleX - p.w / 2));
if (b.stuck) { b.x = p.x + p.w / 2; b.y = p.y - b.r - 1; return s; }
if (s.status !== "playing") return s;
dt = Math.min(dt, 1 / 30); // 防止切后台回来一帧穿墙
b.x += b.vx * dt; b.y += b.vy * dt;
if (b.x < b.r) { b.x = b.r; b.vx = Math.abs(b.vx); }
if (b.x > W - b.r) { b.x = W - b.r; b.vx = -Math.abs(b.vx); }
if (b.y < b.r) { b.y = b.r; b.vy = Math.abs(b.vy); }
if (b.vy > 0 && hitRect(b, p)) {
const t = (b.x - (p.x + p.w / 2)) / (p.w / 2); // -1..1,击中位置决定反弹角度
const speed = Math.hypot(b.vx, b.vy) * 1.02;
const ang = t * (Math.PI / 3);
b.vx = speed * Math.sin(ang); b.vy = -speed * Math.cos(ang);
b.y = p.y - b.r - 1;
}
for (const br of s.bricks) {
if (br.hp <= 0) continue;
const axis = hitRect(b, br);
if (!axis) continue;
if (axis === "x") b.vx = -b.vx; else b.vy = -b.vy;
br.hp -= 1;
if (br.hp === 0) s.score += 10;
break; // 一帧只处理一块,避免双重反弹
}
if (s.bricks.every((br) => br.hp <= 0)) s.status = "won";
if (b.y > H + b.r) {
s.lives -= 1;
if (s.lives <= 0) s.status = "lost";
else { s.status = "ready"; b.stuck = true; b.vx = 180; b.vy = -260; }
}
return s;
}
两个关键设计:碰撞用“圆到矩形最近点”的距离判断,比用包围盒更准;dt 设上限,切回标签页时不会一帧飞出画面。
2. 绘制和输入 game/main.js
import { W, H, createState, launch, step } from "./logic.js";
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let s = createState();
let targetX;
let best = Number(localStorage.getItem("breakout-best") || 0);
const keys = new Set();
function toGameX(clientX) {
const r = cv.getBoundingClientRect();
return ((clientX - r.left) / r.width) * W;
}
cv.addEventListener("pointermove", (e) => { targetX = toGameX(e.clientX); });
cv.addEventListener("pointerdown", (e) => { targetX = toGameX(e.clientX); act(); });
addEventListener("keydown", (e) => {
keys.add(e.key);
if (e.key === " ") { e.preventDefault(); act(); }
});
addEventListener("keyup", (e) => keys.delete(e.key));
function act() {
if (s.status === "won" || s.status === "lost") s = createState();
else launch(s);
}
function draw() {
ctx.fillStyle = "#0b1020"; ctx.fillRect(0, 0, W, H);
for (const b of s.bricks) {
if (b.hp <= 0) continue;
ctx.fillStyle = b.hp === 2 ? "#f97316" : "#38bdf8";
ctx.fillRect(b.x, b.y, b.w, b.h);
}
ctx.fillStyle = "#e2e8f0";
ctx.fillRect(s.paddle.x, s.paddle.y, s.paddle.w, s.paddle.h);
ctx.beginPath(); ctx.arc(s.ball.x, s.ball.y, s.ball.r, 0, Math.PI * 2); ctx.fill();
ctx.font = "16px system-ui"; ctx.fillText(`分数 ${s.score} 生命 ${s.lives} 最高 ${best}`, 12, 28);
const tip = { ready: "点击或按空格发球", won: "通关!点击重来", lost: "游戏结束,点击重来" }[s.status];
if (tip) { ctx.font = "22px system-ui"; ctx.textAlign = "center"; ctx.fillText(tip, W / 2, H / 2); ctx.textAlign = "left"; }
}
let last = performance.now();
function loop(now) {
const dt = (now - last) / 1000; last = now;
if (keys.has("ArrowLeft")) targetX = (targetX ?? W / 2) - 480 * dt;
if (keys.has("ArrowRight")) targetX = (targetX ?? W / 2) + 480 * dt;
step(s, dt, targetX);
if (s.score > best) { best = s.score; localStorage.setItem("breakout-best", String(best)); }
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
toGameX 把屏幕坐标换算成 480 宽的游戏坐标,所以画布被 CSS 缩放后,手指位置依然对得上。
3. 页面 game/index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>打砖块</title>
<style>
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#020617; color:#e2e8f0; font-family:system-ui,sans-serif; }
canvas { width:min(96vw, 480px); aspect-ratio:3/4; border-radius:12px; touch-action:none; }
p { text-align:center; opacity:.7; }
</style>
</head>
<body>
<main>
<canvas id="cv" width="480" height="640"></canvas>
<p>鼠标/手指移动挡板,← → 键也可以;点击或空格发球</p>
</main>
<script type="module" src="main.js"></script>
</body>
</html>
touch-action:none 让手机上拖动挡板时页面不会跟着滚动。
4. 测试 logic.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { createState, launch, step, hitRect, H } from "./game/logic.js";
test("碰撞检测", () => {
assert.equal(hitRect({ x: 5, y: 5, r: 3 }, { x: 7, y: 0, w: 10, h: 10 }), "x");
assert.equal(hitRect({ x: 50, y: 50, r: 3 }, { x: 0, y: 0, w: 10, h: 10 }), null);
});
test("发球前球跟着挡板", () => {
const s = createState();
step(s, 0.016, 100);
assert.equal(s.ball.x, s.paddle.x + s.paddle.w / 2);
});
test("漏球扣命,命用完判负", () => {
const s = createState();
launch(s);
for (let i = 0; i < 3; i++) {
s.status = "playing"; s.ball.stuck = false;
s.ball.x = 10; s.ball.y = H + 20; s.ball.vy = 100;
step(s, 0.016, 400);
}
assert.equal(s.lives, 0);
assert.equal(s.status, "lost");
});
test("打掉全部砖块判胜", () => {
const s = createState();
launch(s);
s.bricks.forEach((b) => (b.hp = 0));
s.bricks[0].hp = 1;
const t = s.bricks[0];
s.ball.x = t.x + t.w / 2; s.ball.y = t.y + t.h + 5; s.ball.vy = -200; s.ball.vx = 0;
step(s, 0.016, 240);
assert.equal(s.status, "won");
assert.equal(s.score, 10);
});
test("模拟 60 秒不崩溃、球不越界", () => {
const s = createState();
launch(s);
for (let i = 0; i < 3600; i++) {
step(s, 1 / 60, s.ball.x); // 挡板始终跟球,模拟完美玩家
if (s.status === "ready") launch(s);
assert.ok(s.ball.x >= 0 && s.ball.x <= 480);
}
assert.ok(s.score > 0);
});
最后一个测试让挡板永远跟着球,模拟 3600 帧,用来抓“球卡在墙里”“越界”这类偶发问题。
第三步:怎样在本地试玩和验收?
1. 起服务器
python3 -m http.server 8080 -d game
浏览器打开 http://localhost:8080。
2. 逐项检查
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 初始画面 | 打开页面 | 5 行砖块,上两行橙色,提示“点击或按空格发球” |
| 发球 | 按空格 | 球向上飞出 |
| 挡板角度 | 用挡板边缘接球 | 球斜着弹出,比中间接球更偏 |
| 两次耐久 | 打橙色砖 | 第一次不消失,第二次消失并加 10 分 |
| 漏球 | 故意不接 | 生命减 1,球回到挡板上 |
| 失败 | 漏 3 次 | 显示“游戏结束” |
| 最高分 | 刷新页面 | 左上角最高分保留 |
| 手机 | 用手机访问局域网地址 | 拖动挡板时页面不滚动 |
手机访问时,把地址里的 localhost 换成电脑的局域网 IP(http.server 默认监听所有网卡),并确认电脑防火墙放行了 8080 端口。
3. 提交
git add -A && git commit -m "打砖块第一版"
出问题时该怎么跟 AI 说?
1. 球偶尔直接穿过砖块
速度越来越快后,球一帧移动的距离可能超过砖块厚度。
球速变快后偶尔会直接穿过砖块。请分析原因,给出两种方案的对比:
1. 给速度设上限;2. 每帧拆成多个子步骤移动。
选择改动最小的方案实现,并补一个测试:把球速设为 2000 像素/秒,连续模拟 10 秒不能穿过砖块。
2. 球在两块砖之间来回抖动、一次消掉两块
同一帧和两块砖都相交时,速度被反转两次等于没反转。本文代码用“每帧只处理一块”的 break 解决。
球打到两块砖的接缝时会一次消掉两块,有时还会卡住抖动。
请确保每一帧最多处理一次砖块碰撞,并说明修改了哪一行。
3. 高分屏上画面发虚
在 Retina 屏幕上 canvas 画面模糊。请按 devicePixelRatio 放大 canvas 的实际像素,
用 ctx.scale 保持游戏坐标仍是 480x640,不要改动 logic.js。
4. 手机上拖动时整个页面在滚
确认 canvas 有 touch-action:none,并且用的是 pointer 事件而不是 mouse 事件。可以这样说:
手机上拖动挡板时页面跟着滚动。请检查 canvas 的 CSS 和事件类型,改为 pointer 事件并禁止默认触摸行为。
怎样发布给朋友玩?
和网站教程完全一样,部署 game 目录即可:
npx wrangler pages deploy game --project-name=breakout-demo
注意:Wrangler 需要 Node.js 22 或更高。游戏里如果使用了别人的音效、图片或字体,要确认授权允许公开使用。
常见报错怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 页面空白,控制台报 CORS | 直接双击打开了 HTML | 用本地服务器打开 |
getContext of null |
脚本在 canvas 之前执行,或 id 不一致 | 用 type="module"(默认延迟执行),核对 id |
| 球一出来就消失 | 切换标签页后 dt 过大 | 给 dt 设上限 |
| 挡板位置和手指对不上 | 没把屏幕坐标换算成游戏坐标 | 按 getBoundingClientRect 比例换算 |
测试报 Cannot use import statement |
package.json 缺 "type": "module" |
补上 |
| 方向键让页面滚动 | 没阻止默认行为 | 对方向键和空格调用 preventDefault() |
| 最高分不保存 | 无痕模式或 key 写错 | 用普通窗口,检查 localStorage |
还能怎么扩展?
| 方向 | 提示词 |
|---|---|
| 多关卡 | “把关卡改成字符串数组配置,例如 '22111122',通关后进入下一关” |
| 道具 | “击碎砖块有 10% 概率掉落加长挡板道具,持续 10 秒” |
| 音效 | “用 Web Audio API 合成碰撞音效,不使用外部音频文件” |
| 暂停 | “按 P 暂停,切到后台时自动暂停” |
项目资源
| 用途 | 地址 |
|---|---|
| ChatGPT | https://chatgpt.com |
| Codex CLI 文档 | https://developers.openai.com/codex/cli |
| Codex CLI 源码 | https://github.com/openai/codex |
| Canvas API 教程 | https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial |
| requestAnimationFrame | https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame |
| Pointer 事件 | https://developer.mozilla.org/zh-CN/docs/Web/API/Pointer_events |
| Node.js 测试运行器 | https://nodejs.org/api/test.html |
| Cloudflare Pages 直接上传 | https://developers.cloudflare.com/pages/get-started/direct-upload/ |
建议先把上面三个游戏文件和测试文件复制到本地,跑通 npm test 再打开试玩,确认手感符合预期。
然后从第一步开始让 ChatGPT 重新出一份你自己的规格,例如换成更快的球速或更多行砖块,看 Codex 能否按数字准确实现。