AI辅助开发:用HTML5 Canvas构建坦克大战关卡编辑器
“AI 写不了完整项目”这句话,我最近有了一点新看法。
前阵子我想做一个坦克大战的关卡编辑器,不是那种简单画几个方块的小 Demo,而是一个能绘制地图、摆放坦克、配置敌人波次、导出 JSON、再让游戏运行时直接加载的可视化工具。本来以为要写两天,结果在 AI 辅助下,一个周末就把核心闭环跑通了。
我最大的体感是:AI 的真正价值,不在于替你一口气生成几百行代码,而在于把“想法 → 拆解 → 编码 → 验证”这个循环压缩到极致。尤其是当你把任务拆成“数据建模 + 可视化交互 + 序列化”三个阶段之后,AI 几乎可以一路助推到底。
这篇文章不打算只贴最终代码,而是把整个过程复盘一遍:编辑器需要哪些功能、为什么用 Canvas 而不是游戏引擎、每一段核心代码是怎么和 AI 协作写出来的、中间踩了哪些坑、以及哪些事情 AI 始终做不好,必须由人来判断。
1. 先说结论:AI 编程的真实难点不是“生成代码”
很多人试过用 AI 写代码,体验通常是两种极端。
一种是小任务,比如“给这段数组去重”“封装一个时间格式化函数”,AI 几秒钟给出答案,复制粘贴就能跑。另一种是大任务,比如“帮我写一个坦克大战编辑器”,AI 会列出一大堆文件、类、接口,看起来很有架构感,但真正运行起来全是问题,最后你还是要自己动手改。
于是大家得出结论:AI 适合写函数,不适合做项目。
这个判断只对了一半。我这次做编辑器最大的体会是,AI 做不好“完整项目”,是因为提问者把“项目”当成一个整体扔给 AI,而不是把项目拆成它可以逐个消化的模块。如果你把一个编辑器拆成“地图数据结构”“网格渲染”“鼠标交互”“JSON 导出”这样的小任务,每个任务都给 AI 足够清晰的上下文,它生成的代码质量会完全不同。
所以,这篇文章里最重要的不是某个具体功能,而是一套工作方法:
- 先自己确定产品边界:编辑器到底要有哪些功能,做出来给谁用。
- 再把功能拆成 AI 能独立完成的模块,每个模块都是“一个小项目”。
- 每完成一个模块,都要跑通、验证、再进入下一个。
- 最后把模块拼装起来,不要指望 AI 一步到位。
这套方法,适用于任何“用 AI 做工具”的场景,不只是坦克大战编辑器。
2. 编辑器应该长什么样:先定义最小闭环
在动手写代码之前,我先列了一个功能清单。这一步很关键,因为 AI 无法替你做产品决策。
我想要的坦克大战编辑器,至少要满足这些需求:
| 功能 | 说明 | 优先级 |
|---|---|---|
| 地图绘制 | 用画刷在 26x26 的网格上绘制砖墙、钢墙、水面、草地 | 必须 |
| 地块擦除 | 右键擦除已绘制的地块,方便修改 | 必须 |
| 坦克摆放 | 在指定位置放置玩家坦克和敌方坦克,并设置方向 | 必须 |
| 敌人波次配置 | 配置每波敌人的出生点和数量 | 推荐 |
| 关卡导出 | 把当前编辑结果保存成 JSON 文件 | 必须 |
| 关卡导入 | 把 JSON 文件重新加载回编辑器,方便二次修改 | 推荐 |
| 预览模式 | 从“编辑态”切换为“运行态”,简单预览效果 | 可选 |
这里有一个重要的产品判断:编辑器最核心的价值不是“画图爽”,而是“数据能被游戏消费”。很多编辑器项目做到最后无法落地,就是因为把精力花在界面上,却没有统一的数据模型。所以我把“数据结构设计”放在最先做,它决定后面所有功能能不能串起来。
最小闭环是:创建地图 → 画几个地块 → 放几辆坦克 → 导出 JSON → 在游戏引擎里加载这段 JSON。只要这个闭环能跑通,编辑器就有了实际使用价值。
3. 技术选型:为什么用 HTML5 Canvas,而不是游戏引擎
这个决定看似简单,其实很容易被带偏。
如果打开搜索引擎,可能会看到很多“坦克大战编辑器”方案,有人推荐 Unity,有人推荐 Godot,还有人说用 Python + Pygame。不是说这些方案不行,而是你的编辑器目标越简单,技术选型就越应该克制。
我最后选了 HTML5 Canvas + 原生 JavaScript,理由很明确:
| 方案 | 优势 | 劣势 | 适合场景 |
|---|---|---|---|
| Unity / Godot | 引擎能力强,适合做完整游戏 | 编辑器开发成本高,项目结构重 | 做商业游戏,需要复杂玩法 |
| Python + Pygame | 适合写游戏逻辑 | 依赖安装略繁琐,Web 端分享不方便 | 学习 Python,桌面程序 |
| HTML5 Canvas + JS | 零安装、浏览器直接运行、AI 生成代码成功率高 | 性能上限低于引擎,不适合大型 3D | 关卡编辑器、小型工具原型 |
最关键的一点是:AI 对 Canvas 2D 编程的训练素材非常多。从绘制矩形、网格线,到鼠标坐标换算,这类问题在网络上已经积累了海量的问答对,AI 生成这类代码的准确性远高于生成 Unity C# 脚本。
所以,如果你也是第一次用 AI 做工具类项目,建议优先选择 AI“语料充足”的技术栈。这不是技术洁癖问题,而是降低排错成本。
4. AI 辅助开发的工作流:把需求拆成 AI 能消化的任务
这是全文最想分享的部分。
很多人在 AI 编程时犯的最大错误,是告诉 AI“我要一个坦克大战编辑器”,然后等待奇迹。AI 确实会给出一个“看起来完整”的代码,但通常会有几百行,分散在好几个文件里,你根本不知道它为什么这样写,出问题也没法排查。
我采用的策略是:每次只让 AI 做一件小事。
整个开发过程大概是这样的:
- 先让 AI 生成一个完整的 HTML 页面骨架,里面只包含 Canvas 画布和基本样式。
- 让 AI 实现“网格绘制”函数,画出一个 26x26 的网格线。
- 让 AI 实现“地图数据结构”和“地块颜色映射表”。
- 让 AI 实现“鼠标点击时,把网格坐标转换成行列下标,然后修改地图数据”。
- 让 AI 实现“导出 JSON”功能。
- 让 AI 实现“导入 JSON”功能。
每一步都是一个独立的、可以运行的小任务。每一个步骤完成后,我都会在浏览器里刷新页面,验证这一步是否正常,再进入下一步。这种“小步快跑”的方式,让我始终知道问题出在哪里。
这里分享一个项目中真正好用的提示词模板:
我正在用 HTML5 Canvas 做坦克大战关卡编辑器。 当前画布是 26x26 网格,每个格子 24px。 请帮我实现一个函数:根据鼠标点击事件,把 clientX/clientY 换算成对应的行和列,并且用左键绘制当前选中的地块类型, 右键擦除地块。请直接输出完整函数,并解释坐标换算的原理。注意,这个提示词有几个关键要素:
- 先交代项目背景,让 AI 知道这是游戏编辑器。
- 给出明确的尺寸参数:26x26 网格、24px。
- 需求非常具体:左键绘制、右键擦除。
- 要求输出完整函数,不要碎片代码。
- 要求解释原理,方便你检查 AI 是否真的懂了。
等 AI 输出代码后,不要直接复制。先读一遍它的坐标换算逻辑,特别是Math.floor((e.clientX - rect.left) / TILE_SIZE)这一段,确认它减去了画布左边界偏移。这个细节如果漏掉,鼠标点击位置会对不上网格。
5. 核心数据结构:让地图可配置
很多教程一上来就写绘制代码,我反而建议先把数据模型定清楚。
我设计的地图数据结构,核心是一个二维数组。数组的索引就是格子坐标,数组的值就是地块类型。这样设计的好处是:渲染、编辑、导出、导入,全部围绕同一个二维数组工作,任何功能都不会出现“数据不一致”的问题。
// 文件路径:js/level-model.js const MAP_COLS = 26; const MAP_ROWS = 26; const TILE_SIZE = 24; const TILE_TYPES = { EMPTY: 0, BRICK: 1, STEEL: 2, WATER: 3, GRASS: 4, BASE: 5 }; const TILE_COLORS = { [TILE_TYPES.EMPTY]: '#000000', [TILE_TYPES.BRICK]: '#c84c30', [TILE_TYPES.STEEL]: '#d0d0d0', [TILE_TYPES.WATER]: '#1e88e5', [TILE_TYPES.GRASS]: '#2e7d32', [TILE_TYPES.BASE]: '#ffd54f' }; // 初始化空地图 function createEmptyMap() { const map = []; for (let row = 0; row < MAP_ROWS; row++) { const line = []; for (let col = 0; col < MAP_COLS; col++) { line.push(TILE_TYPES.EMPTY); } map.push(line); } return map; }这只是地图部分。坦克和波次的数据结构,我用的是对象数组:
// 文件路径:js/level-model.js(追加内容) // 坦克列表中每一项描述一个坦克 const tanks = [ { id: 1, type: 'player', col: 8, row: 24, direction: 'up', level: 1 }, { id: 2, type: 'enemy', col: 0, row: 0, direction: 'down', level: 2 } ]; // 敌人波次配置 const enemyWaves = [ { wave: 1, total: 5, enemies: [ { col: 0, row: 0, level: 1 }, { col: 12, row: 0, level: 1 } ] }, { wave: 2, total: 8, enemies: [ { col: 0, row: 0, level: 2 }, { col: 24, row: 0, level: 2 } ] } ]; const levelConfig = { name: 'ai-made-level', cols: MAP_COLS, rows: MAP_ROWS, tileSize: TILE_SIZE, map: createEmptyMap(), tanks: tanks, enemyWaves: enemyWaves };这份结构有什么好处?我可以直接把levelConfig用JSON.stringify序列化成文件,然后任何支持这个格式的游戏运行时都能解析。比如在 PK 或者另一个渲染引擎里,只需要读取config.map[row][col]来渲染地块,读取config.tanks来生成实体对象。
写到这里,我希望你应该已经感受到了“数据与表现分离”这个设计的价值。地图数据不依赖任何 Canvas API,它只是一个普通数组。这意味着,如果之后想换成 WebGL 渲染,或者导出给 Electron 桌面程序用,数据结构完全不用改。
6. 网格渲染与地图绘制实现
数据结构定好了,接下来就是让 AI 实现渲染和交互。这是整个编辑器中最“看得见摸得着”的部分。
// 文件路径:js/editor-render.js // 假设 canvas 和 ctx 已经通过 DOM 获取 // const canvas = document.getElementById('editorCanvas'); // const ctx = canvas.getContext('2d'); // const map = createEmptyMap(); function drawMap(ctx, map) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, canvas.width, canvas.height); for (let row = 0; row < MAP_ROWS; row++) { for (let col = 0; col < MAP_COLS; col++) { const tile = map[row][col]; if (tile === TILE_TYPES.EMPTY) continue; ctx.fillStyle = TILE_COLORS[tile]; ctx.fillRect(col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE); } } } function drawGrid(ctx) { ctx.strokeStyle = '#333333'; ctx.lineWidth = 1; for (let col = 0; col <= MAP_COLS; col++) { ctx.beginPath(); ctx.moveTo(col * TILE_SIZE, 0); ctx.lineTo(col * TILE_SIZE, canvas.height); ctx.stroke(); } for (let row = 0; row <= MAP_ROWS; row++) { ctx.beginPath(); ctx.moveTo(0, row * TILE_SIZE); ctx.lineTo(canvas.width, row * TILE_SIZE); ctx.stroke(); } } function renderEditor() { drawMap(ctx, map); drawGrid(ctx); }接下来是鼠标交互。这个模块是整个项目里最容易出 bug 的地方,因为坐标换算涉及两个坐标系:DOM 坐标系和 Canvas 内容坐标系。如果 Canvas 外边距、内边距没有处理好,鼠标点下去的格子就会偏一位。
// 文件路径:js/editor-interaction.js let selectedTileType = TILE_TYPES.BRICK; canvas.addEventListener('mousedown', (e) => { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const col = Math.floor((e.clientX - rect.left) * scaleX / TILE_SIZE); const row = Math.floor((e.clientY - rect.top) * scaleY / TILE_SIZE); if (col < 0 || col >= MAP_COLS || row < 0 || row >= MAP_ROWS) return; if (e.button === 2) { map[row][col] = TILE_TYPES.EMPTY; } else { map[row][col] = selectedTileType; } renderEditor(); }); canvas.addEventListener('contextmenu', (e) => { e.preventDefault(); }); // 通过 UI 切换当前画刷 function setSelectedTileType(tileType) { selectedTileType = tileType; document.querySelectorAll('.tile-btn').forEach(btn => { btn.classList.toggle('active', Number(btn.dataset.tileType) === tileType); }); }这里有一个值得展开的细节:为什么要用scaleX和scaleY?
因为canvas.width是 Canvas 的内部分辨率,而rect.width是 CSS 中渲染出来的实际宽度。如果 Canvas 被 CSS 缩放,两者不一致,直接用canvas.width / rect.width做比例换算,才能保证手指或鼠标点击的位置精确对应到 Canvas 内部坐标。这个细节,AI 第一次未必会主动帮你处理,但只要你意识到需要校验,就能马上让 AI 修正。
7. JSON 导出与导入:让编辑器真正可用
编辑器能不能用在真实游戏里,就看导出模块是否可靠。
导出功能其实不复杂,核心就是JSON.stringify加上文件下载。但这里有一个容易忽略的点:导出前一定要做数据校验。比如检查基地是否被错误地放置在不可到达的区域、是否缺少玩家坦克出生点、地图数组是否是合法的二维数组。
我在 AI 辅助下完成的校验和导出逻辑如下:
// 文件路径:js/level-io.js function validateLevel(levelData) { const errors = []; if (!levelData.map || levelData.map.length !== 26) { errors.push('地图行数必须为 26'); } if (!Array.isArray(levelData.tanks) || levelData.tanks.length === 0) { errors.push('关卡中必须包含玩家坦克出生点'); } const hasPlayer = levelData.tanks.some(tank => tank.type === 'player'); if (!hasPlayer) { errors.push('关卡中缺少玩家坦克'); } return errors; } function exportLevel() { const levelData = { name: levelConfig.name, config: { cols: MAP_COLS, rows: MAP_ROWS, tileSize: TILE_SIZE }, map: map, tanks: tanks, enemyWaves: enemyWaves }; const errors = validateLevel(levelData); if (errors.length > 0) { alert('导出失败:\n' + errors.join('\n')); return; } const jsonStr = JSON.stringify(levelData, null, 2); const blob = new Blob([jsonStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = levelConfig.name + '.json'; a.click(); URL.revokeObjectURL(url); } function importLevel(jsonText) { try { const parsed = JSON.parse(jsonText); if (!parsed.map || !parsed.config) { throw new Error('JSON 缺少 map 或 config 字段'); } map = parsed.map; levelConfig.name = parsed.name || 'imported-level'; // 同步全局配置 if (parsed.config.cols) { // 这里仅做提示,实际项目要动态调整画布尺寸 console.log('导入配置:', parsed.config); } renderEditor(); } catch (err) { alert('导入失败:' + err.message); } }导入功能的使用场景很明确:当你在游戏里发现某个关卡设计得不好,可以反向打开编辑器,导入原文件,微调后重新导出。这样整个“设计 → 测试 → 修改”的循环就闭环了。很多编辑器做了一半就放弃,就是因为缺少导入功能,导致每次都是“重新设计”,没法迭代。
从实际开发来看,这个模块用 AI 实现的时候几乎没遇到卡点,因为 JSON 的读写是编程中最普遍的场景之一。只要数据结构定义清晰,AI 生成的代码基本上可以直接使用。
8. 常见问题与排查思路
很多读者问,用 AI 写代码最大的风险是“出了问题不知道去哪查”。下面这张表是我在整个开发过程中实际遇到过的,以及 AI 生成代码时最常见的同类问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 鼠标点击后画出的格子偏移 | 没有计算 Canvas 的 CSS 缩放比例getBoundingClientRect().width | 打印col/row和鼠标坐标,对比真实的格子索引 | 使用(clientX - rect.left) * canvas.width / rect.width换算 |
| 右键点击弹出浏览器菜单,导致无法擦除 | Canvas 默认不阻止contextmenu事件 | 检查事件监听 | 在 Canvas 上添加contextmenu事件并调用preventDefault() |
| 导出 JSON 后游戏加载报错 | 地图数组中有undefined或者行数不一致 | 用JSON.parse后打印数组长度,检查末尾元素 | 在导出前用validateLevel做数据校验,初始化时保证二维数组完整 |
| 网格线和地图块重叠后看不清 | 绘制顺序不对,先绘制了地图块,没有在网格层重新绘制 3D 效果 | 检查renderEditor()的调用顺序 | 先drawMap,再drawGrid,最后根据当前选中块叠加高亮 |
| AI 生成的代码中,某个模块引用了未定义的变量 | 生成代码时没有把全局变量上下文传给 AI | 复制完整报错信息反问 AI | 把所有全局变量声明放到单独 JS 文件,并在提示词中说明“变量已存在” |
排查这类问题的通用顺序是:先看控制台报错信息,再看变量的作用域,最后检查数据和渲染之间的同步关系。如果你按这个顺序查,80% 的编辑器问题都能定位。
9. 哪些环节 AI 做不好:值得停下来人工判断
文章最后想聊一个很多人不愿意面对的问题:AI 编程的边界在哪里。
先给结论:AI 能写出功能正确的代码,但很难替你做“权衡”。尤其是下面三类事情,必须由人来判断。
第一类是产品优先级的取舍。比如,是我让编辑器支持 26x26 的经典尺寸,还是支持任意尺寸动态网格?是支持 16 种地块类型,还是先支持 5 种?这类决策看起来简单,但直接决定开发成本。AI 不会告诉你“先做 5 种类型验证主流程,不要一开始就做 16 种”。
第二类是数据结构的扩展性。AI 倾向于“满足当前需求”,不会主动考虑“未来这个字段会不会被游戏中的物理碰撞系统使用”。像我在设计坦克对象时,特意加了direction和level字段,就是为了以后游戏运行时能直接用。这种前瞻性,只能由开发者自己判断。
第三类是调试路径的选择。AI 给出的 bug 修复方案未必是问题最小的方案。比如有一次网格线显示错乱,AI 建议加一个“偏移量参数”,但我实际排查后发现问题只是绘制顺序错误。如果盲目听从 AI,可能引入一个更复杂的配置。遇到 bug 时,先自己理解一遍代码,再决定要不要百分百采纳 AI 的建议。
总而言之,我的建议是:让 AI 帮你把“从想法到功能”的时间从半天压缩到半小时,但在这半小时里,你必须保持一个真正开发者应有的警觉。可能听起来有点反复,但这恰恰是这次实验给我最大的收获。编辑器这个项目本身不难,难的是在 AI 辅助下依然保持对代码的掌控力。如果你也想做一个类似的可视化工具,建议从今天开始,先搭一个 Canvas 画布,画上网格,你会发现你离“编辑器”其实只有几步之遥。
