AI 工具

ChatGPT + Codex 实战:开发可在手机运行的打砖块网页游戏

用 ChatGPT 设计玩法和关卡,用 Codex CLI 写出一个能在手机和电脑浏览器里直接玩的打砖块游戏,逻辑与画面分离、带自动化测试,最后可以和网站教程一样一条命令部署上线

BINBHO · 图文教程

用 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 能否按数字准确实现。