刚接触“vibe coding”这个概念时,我的第一反应是:这不就是让人工智能帮我写代码吗?真正动手做了一个暑假的独立策略游戏原型后,我对它的理解发生了很大变化。vibe coding 的本质并不是“偷懒”,而是把开发重心从“逐行敲代码”转移到“产品设计、系统拆解、代码审查与调试验证”上,让 AI 承担可预测的编码工作,把人的精力留给更需要判断力的环节。
这篇文章记录了我用 vibe coding 方式从零开发一个独立策略游戏的全过程,包括工具选择、工作流搭建、需求拆解、核心系统实现、AI 生成代码的审查与排错,以及暑假开发期间踩过的坑。文章面向两类读者:一类是刚开始接触 AI 辅助编程,想用它做点真实项目的大学生;另一类是有一定开发基础、想尝试把 AI 引入游戏开发的独立开发者。读完你能掌握一套相对完整的“需求设计 + AI 生成 + 人工审查 + 反复迭代”的开发闭环。
1. vibe coding 是什么,为什么适合独立游戏
1.1 从“写代码”到“描述系统”的转变
vibe coding 最早由 Andrej Karpathy 提出,大意是开发者用自然语言描述需求,AI 生成代码,开发者看完运行结果后继续提出修改要求,整体处于一种“跟着感觉走”的开发节奏中。很多人把它理解为“随口说需求,代码自动完成”,但在实际项目中必须加一个前提:你需要知道自己想要什么,并且能看懂 AI 给出的代码是否合理。
以策略游戏为例,如果你对 AI 说“做一个文明游戏”,AI 大概率会生成一个非常空泛的页面,因为你没有拆解清楚“文明游戏”包含哪些系统。但如果你说“做一款 10x10 网格地图的回合制策略游戏,玩家和 AI 各有 3 个单位,每个单位可以移动 2 格、攻击范围为 1 格,输出到 Canvas 上”,AI 生成结果的可用性会大幅提升。
1.2 独立策略游戏为什么适合 vibe coding
策略游戏的核心是数据结构、规则逻辑和状态流转,而不是复杂的物理引擎或大量美术资源。游戏循环可以抽象成“玩家行动 → 校验规则 → 更新状态 → 渲染画面 → AI 行动 → 校验胜负”,边界清晰,非常适合让 AI 理解并生成。
同时,策略游戏的规模可控。暑假一个多月时间,要做 3A 大作不现实,但做一个回合制战棋原型、一个简单的资源运营游戏、或者一个 roguelike 地牢探索,都是完全可行的。这些项目不需要团队协作,不需要美术外包,代码量通常在几百到几千行之间,正好是 vibe coding 发挥效率的最佳区间。
1.3 开发者仍然需要掌握的核心能力
vibe coding 不是“不需要会编程”,而是对编程能力提出了另一种要求:
- 系统设计能力:能把完整游戏拆成地图、单位、行动、AI、渲染等模块。
- 代码审查能力:AI 生成的代码可能有逻辑漏洞,你需要能发现并修复。
- 调试能力:报错信息、状态异常、AI 行为不合预期,这些都需要自己定位。
- 需求描述能力:把模糊想法变成 AI 能执行的具体任务。
如果你完全看不懂代码,错误出现时只会不断把报错信息复制给 AI,很容易陷入“AI 改一版坏一版”的循环。我在项目后期深有体会,当代码量上到一定程度后,没有人工审查,AI 会不断引入新的回归问题。
2. 项目规划:打磨游戏设计文档
2.1 确定游戏类型和核心玩法
暑假开始时,我给自己定的目标是“用 vibe coding 做一款能玩 10 分钟的回合制策略游戏”。我选择的是网格战棋 + 单位控制的玩法,因为这一类游戏结构清晰,AI 也容易理解。
项目名称暂定为《星域争锋》,核心玩法如下:
- 玩家和 AI 各自控制 3 个作战单位,分布在 10x10 的网格地图上。
- 每个单位有生命值、攻击力、移动范围、攻击范围。
- 玩家回合可以选中单位,查看可移动范围和可攻击目标。
- 执行移动或攻击后,结束回合,AI 自动行动。
- 任意一方全部单位被消灭,游戏结束。
游戏流程从“构思”开始,我用 Markdown 整理了一份简版设计文档,先写清楚系统边界,再开始写代码。
2.2 技术选型:为什么选用 HTML5 Canvas
我选择 HTML5 Canvas + 原生 JavaScript 作为技术栈,原因非常实际:
- 浏览器直接运行,不需要安装额外的游戏引擎。
- Canvas 绘制的网格、单位、选中状态,便于 AI 理解。
- 原生 JS 减少框架依赖,AI 生成的代码更可控。
- 游戏状态可以直接存储在 JS 对象中,方便调试和扩展。
如果你后续想做更复杂的策略游戏,可以迁移到 Phaser 等 2D 游戏框架,但作为第一款 AI 辅助开发的原型,原生 Canvas 足够用。
2.3 建立 MVP 功能清单
在开始写代码前,我明确了 MVP(最小可行产品)需要包含哪些功能,防止功能蔓延:
必须功能: 1. 渲染 10x10 网格地图 2. 渲染双方单位,区分颜色 3. 单位数据模型:id、阵营、位置、生命值、攻击力、移动范围、攻击范围 4. 玩家点击选中单位,显示可移动格子 5. 玩家点击可移动格子,单位移动 6. 玩家点击可攻击敌方单位,发动攻击 7. 结束回合按钮,AI 自动行动 8. 胜负判断 可选功能(后期再做): 1. 地形遮挡与掩体 2. 不同单位类型(近战、远程) 3. 攻击动画 4. 音效和背景音乐把功能清单给到 AI 后,生成的代码方向就非常明确了。这也是 vibe coding 最重要的一步:不是让 AI 替你决定做什么,而是你决定做什么,AI 负责怎么做。
3. 开发环境准备与 AI 工作流搭建
3.1 本地开发环境
我用的开发环境非常轻量:
- 操作系统:Windows 11
- 编辑器:VS Code
- 语言:JavaScript(ES6+)
- 运行环境:Chrome 浏览器
- 本地服务器:VS Code Live Server 插件
对于纯 Canvas 项目,其实不装 Live Server 也能直接打开 HTML 文件运行,但建议加上 Live Server,因为后续调试时修改代码自动刷新会方便很多。
项目结构如下:
star-conflict/ ├── index.html ├── game.js └── style.css3.2 AI 工具选择与工作流
这个暑假我主要使用了两类 AI 工具:
- 对话式 AI 编程助手:直接通过自然语言对话生成代码片段、排查报错。
- AI 构建平台:可以输入一个完整的项目描述,AI 自动生成整个项目结构,但它生成的代码骨架更通用,通常还需要手动调整。
实际使用下来,我的工作流是:
第一步:在 Markdown 中撰写需求描述,尽量具体 第二步:让 AI 根据需求生成完整代码文件 第三步:在本地运行,观察错误和效果 第四步:发现问题,带着错误信息和上下文让 AI 修改 第五步:人工审查关键逻辑,确认没有隐藏问题 第六步:重复第三步到第五步,直到功能稳定这个循环看起来很朴素,但效率提升非常明显。以前我写一个带 AI 敌人的战棋原型可能要两周,现在两天就能跑通基础版本。
3.3 写好提示词的核心原则
我用了一个暑假,总结出给 AI 下达编码任务时效果最好的提示词结构:
角色:你是一名精通 JavaScript 和 HTML5 Canvas 的游戏开发者。 任务:实现一个 10x10 网格的回合制战棋游戏。 功能要求: 1. 网格大小为 10x10,每个格子渲染为 50x50 像素。 2. 玩家单位用蓝色圆表示,AI 单位用红色圆表示。 3. 玩家点击蓝色单位时显示可移动范围(黄色高亮)。 4. 玩家点击高亮格子完成移动。 5. 玩家单位附近有敌方单位时,显示可攻击目标。 6. 点击“结束回合”按钮后,AI 自动移动并攻击。 输出要求:生成 index.html、style.css、game.js 三个文件的完整代码。关键点在于“功能要求”部分要写成可验证的行为描述,而不是抽象概念。例如“显示可移动范围”比“做一个移动系统”更容易让 AI 生成正确结果。
4. 核心开发实战:从空项目到可玩游戏
4.1 创建项目基础结构
首先创建index.html,它负责承载游戏画布和操作按钮。
<!-- 文件路径:star-conflict/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>星域争锋 - 回合制策略游戏</title> <link rel="stylesheet" href="style.css"> </head> <body> <div id="game-container"> <h1>星域争锋</h1> <div id="game-status">当前回合:玩家</div> <canvas id="gameCanvas" width="500" height="500"></canvas> <div id="controls"> <button id="endTurnBtn">结束回合</button> <button id="restartBtn">重新开始</button> </div> <div id="battle-log"></div> </div> <script src="game.js"></script> </body> </html>接着是style.css,主要做居中布局和按钮样式。
/* 文件路径:star-conflict/style.css */ body { margin: 0; padding: 20px; background: #1a1a2e; display: flex; justify-content: center; align-items: center; min-height: 100vh; font-family: "Microsoft YaHei", sans-serif; color: #eee; } #game-container { background: #16213e; padding: 24px; border-radius: 12px; box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); text-align: center; } h1 { margin: 0 0 10px 0; font-size: 24px; letter-spacing: 4px; } #game-status { margin-bottom: 12px; font-size: 16px; color: #e94560; } canvas { border: 2px solid #0f3460; background: #0f3460; cursor: pointer; display: block; margin: 0 auto 12px auto; border-radius: 4px; } #controls { margin-bottom: 12px; } button { background: #e94560; color: #fff; border: none; padding: 10px 20px; margin: 0 6px; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background 0.2s; } button:hover { background: #c73652; } #battle-log { min-height: 40px; max-height: 80px; overflow-y: auto; background: #0f3460; padding: 8px; border-radius: 6px; text-align: left; font-size: 13px; }4.2 编写游戏核心数据模型
游戏的核心数据模型是整个项目的地基。我要求 AI 把所有状态集中到GameState对象中,方便调试。
// 文件路径:star-conflict/game.js // 游戏配置常量 const CONFIG = { GRID_SIZE: 10, CELL_SIZE: 50, CANVAS_WIDTH: 500, CANVAS_HEIGHT: 500, PLAYER_COLOR: '#3498db', AI_COLOR: '#e74c3c', MOVE_COLOR: 'rgba(241, 196, 15, 0.5)', ATTACK_COLOR: 'rgba(231, 76, 60, 0.5)', SELECTED_COLOR: '#2ecc71' }; // 单位工厂函数 function createUnit(id, team, row, col) { return { id: id, team: team, // 'player' 或 'ai' row: row, col: col, hp: 100, maxHp: 100, attack: 30, moveRange: 2, attackRange: 1, hasMoved: false, hasAttacked: false }; } // 游戏全局状态 let gameState = { currentTurn: 'player', units: [], selectedUnitId: null, moveTargets: [], attackTargets: [], gameOver: false, turnNumber: 1 }; // 初始化游戏 function initGame() { gameState.units = [ createUnit(1, 'player', 8, 1), createUnit(2, 'player', 8, 4), createUnit(3, 'player', 8, 8), createUnit(4, 'ai', 1, 1), createUnit(5, 'ai', 1, 4), createUnit(6, 'ai', 1, 8) ]; gameState.currentTurn = 'player'; gameState.selectedUnitId = null; gameState.moveTargets = []; gameState.attackTargets = []; gameState.gameOver = false; gameState.turnNumber = 1; updateStatus(); clearLog(); addLog('游戏开始!你有 3 个作战单位。'); renderGame(); }这里重点解释单位数据模型中的几个字段:
moveRange:单位每回合可以移动的格数,我暂时统一设为 2。attackRange:攻击距离,这里设为 1,也就是只能攻击相邻格子。hasMoved和hasAttacked:用来记录单位本回合是否已经移动或攻击,防止重复行动。
这个模型可以很方便地扩展:如果要加入“远程单位”,只需要把attackRange改成 3 或 4,AI 在寻路时也会自动适配。
4.3 实现网格寻路与行动范围计算
策略游戏最核心的算法之一就是可选范围计算。当一个单位被选中时,我们需要找出它在moveRange内能到达的所有格子。
由于当前版本没有障碍物,移动范围的计算等同于计算曼哈顿距离。
// 判断坐标是否在地图内 function isInBounds(row, col) { return row >= 0 && row < CONFIG.GRID_SIZE && col >= 0 && col < CONFIG.GRID_SIZE; } // 计算两个格子的曼哈顿距离 function manhattanDistance(r1, c1, r2, c2) { return Math.abs(r1 - r2) + Math.abs(c1 - c2); } // 获取某个单位可以移动到的格子列表 function getMoveTargets(unit) { const targets = []; for (let row = 0; row < CONFIG.GRID_SIZE; row++) { for (let col = 0; col < CONFIG.GRID_SIZE; col++) { // 排除自己所在的格子 if (row === unit.row && col === unit.col) continue; // 排除其他单位所在的格子 const occupied = gameState.units.some(u => u.row === row && u.col === col); if (occupied) continue; // 判断是否在移动范围内 if (manhattanDistance(unit.row, unit.col, row, col) <= unit.moveRange) { targets.push({ row, col }); } } } return targets; } // 获取单位可以攻击的目标列表 function getAttackTargets(unit) { let enemyTeam = unit.team === 'player' ? 'ai' : 'player'; return gameState.units.filter(u => { if (u.team !== enemyTeam) return false; if (u.hp <= 0) return false; const distance = manhattanDistance(unit.row, unit.col, u.row, u.col); return distance <= unit.attackRange; }); }这一段逻辑虽然简单,但它决定了游戏的基础规则。在没有障碍物的版本里,曼哈顿距离足够;但后续如果要添加“堵路”的障碍物,就需要替换成广度优先搜索(BFS)算法。这也是我在迭代中要求 AI 完成的第一个升级。
4.4 实现 Canvas 渲染
接下来是渲染部分。Canvas 渲染的逻辑很直观:先画网格,再画高亮区域,最后画单位。
// 获取 canvas 上下文 const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); // 绘制游戏画面 function renderGame() { ctx.clearRect(0, 0, CONFIG.CANVAS_WIDTH, CONFIG.CANVAS_HEIGHT); drawGrid(); drawMoveTargets(); drawAttackTargets(); drawUnits(); } // 绘制网格 function drawGrid() { ctx.strokeStyle = '#1a4968'; ctx.lineWidth = 1; for (let row = 0; row <= CONFIG.GRID_SIZE; row++) { ctx.beginPath(); ctx.moveTo(0, row * CONFIG.CELL_SIZE); ctx.lineTo(CONFIG.CANVAS_WIDTH, row * CONFIG.CELL_SIZE); ctx.stroke(); } for (let col = 0; col <= CONFIG.GRID_SIZE; col++) { ctx.beginPath(); ctx.moveTo(col * CONFIG.CELL_SIZE, 0); ctx.lineTo(col * CONFIG.CELL_SIZE, CONFIG.CANVAS_HEIGHT); ctx.stroke(); } } // 绘制可移动范围高亮 function drawMoveTargets() { gameState.moveTargets.forEach(target => { ctx.fillStyle = CONFIG.MOVE_COLOR; ctx.fillRect( target.col * CONFIG.CELL_SIZE, target.row * CONFIG.CELL_SIZE, CONFIG.CELL_SIZE, CONFIG.CELL_SIZE ); }); } // 绘制可攻击目标高亮 function drawAttackTargets() { gameState.attackTargets.forEach(unit => { ctx.fillStyle = CONFIG.ATTACK_COLOR; ctx.fillRect( unit.col * CONFIG.CELL_SIZE, unit.row * CONFIG.CELL_SIZE, CONFIG.CELL_SIZE, CONFIG.CELL_SIZE ); }); } // 绘制所有单位 function drawUnits() { gameState.units.forEach(unit => { // 跳过已阵亡单位 if (unit.hp <= 0) return; const centerX = unit.col * CONFIG.CELL_SIZE + CONFIG.CELL_SIZE / 2; const centerY = unit.row * CONFIG.CELL_SIZE + CONFIG.CELL_SIZE / 2; const radius = CONFIG.CELL_SIZE * 0.35; // 绘制单位外圈 if (gameState.selectedUnitId === unit.id) { ctx.fillStyle = CONFIG.SELECTED_COLOR; ctx.beginPath(); ctx.arc(centerX, centerY, radius + 5, 0, Math.PI * 2); ctx.fill(); } // 绘制单位主体 ctx.fillStyle = unit.team === 'player' ? CONFIG.PLAYER_COLOR : CONFIG.AI_COLOR; ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.fill(); // 绘制单位边框 ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); // 绘制血条背景 ctx.fillStyle = '#333'; ctx.fillRect(unit.col * CONFIG.CELL_SIZE + 8, unit.row * CONFIG.CELL_SIZE + 6, 34, 6); // 绘制血条 let hpPercent = unit.hp / unit.maxHp; let hpColor = hpPercent > 0.5 ? '#2ecc71' : (hpPercent > 0.25 ? '#f39c12' : '#e74c3c'); ctx.fillStyle = hpColor; ctx.fillRect(unit.col * CONFIG.CELL_SIZE + 8, unit.row * CONFIG.CELL_SIZE + 6, 34 * hpPercent, 6); // 绘制单位 ID ctx.fillStyle = '#fff'; ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(unit.id, centerX, centerY); }); }这段渲染代码的可维护性不错,但还不支持动画效果。如果要加攻击动画,需要在renderGame之外维护一个动画队列,这部分我放到了后续迭代中。
4.5 实现鼠标交互与回合管理
交互逻辑是整个游戏最重要也最容易出错的部分。我需要处理三种点击场景:点击我方单位(选中)、点击移动格子(移动)、点击敌方单位(攻击)。
// 将 canvas 坐标转换为网格坐标 function canvasToGrid(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; const col = Math.floor(x / CONFIG.CELL_SIZE); const row = Math.floor(y / CONFIG.CELL_SIZE); return { row, col }; } // 点击事件处理 canvas.addEventListener('click', (e) => { if (gameState.gameOver) return; if (gameState.currentTurn !== 'player') return; const { row, col } = canvasToGrid(e); // 情况1:点击我方单位,选中它 const clickedUnit = gameState.units.find(u => u.row === row && u.col === col && u.team === 'player' && u.hp > 0); if (clickedUnit) { gameState.selectedUnitId = clickedUnit.id; gameState.moveTargets = getMoveTargets(clickedUnit); gameState.attackTargets = getAttackTargets(clickedUnit); renderGame(); return; } // 情况2:没有选中单位,忽略点击 if (gameState.selectedUnitId === null) return; const selectedUnit = gameState.units.find(u => u.id === gameState.selectedUnitId); // 情况3:点击可攻击目标,执行攻击 const attackTarget = gameState.attackTargets.find(u => u.row === row && u.col === col); if (attackTarget) { performAttack(selectedUnit, attackTarget); return; } // 情况4:点击可移动格子,执行移动 const moveTarget = gameState.moveTargets.find(target => target.row === row && target.col === col); if (moveTarget) { performMove(selectedUnit, moveTarget); return; } // 情况5:点击空白区域,取消选中 gameState.selectedUnitId = null; gameState.moveTargets = []; gameState.attackTargets = []; renderGame(); });4.6 实现移动、攻击与 AI 回合
移动和攻击逻辑相对直接,但有几个细节值得注意:单位攻击后不能再移动吗?移动后还能攻击吗?在传统战棋里,通常移动后可以攻击,但攻击后不能再移动。我的实现是:单位只要本回合没有移动,就可以移动;移动后如果没有攻击,仍然可以攻击。
// 执行移动 function performMove(unit, target) { unit.row = target.row; unit.col = target.col; unit.hasMoved = true; addLog(`单位 ${unit.id} 移动到 (${target.row}, ${target.col})。`); // 移动后攻击范围发生变化,重新计算 gameState.moveTargets = []; gameState.attackTargets = getAttackTargets(unit); // 如果移动后周围没有攻击目标,取消选中状态 if (gameState.attackTargets.length === 0) { gameState.selectedUnitId = null; } renderGame(); } // 执行攻击 function performAttack(attacker, target) { target.hp -= attacker.attack; attacker.hasAttacked = true; addLog(`单位 ${attacker.id} 攻击单位 ${target.id},造成 ${attacker.attack} 点伤害。`); if (target.hp <= 0) { target.hp = 0; addLog(`单位 ${target.id} 被消灭了!`); } // 攻击后取消选中 gameState.selectedUnitId = null; gameState.moveTargets = []; gameState.attackTargets = []; // 检查是否分出胜负 checkGameOver(); renderGame(); } // 检查游戏是否结束 function checkGameOver() { const playerUnits = gameState.units.filter(u => u.team === 'player' && u.hp > 0); const aiUnits = gameState.units.filter(u => u.team === 'ai' && u.hp > 0); if (playerUnits.length === 0) { gameState.gameOver = true; updateStatus('你输了…… AI 取得了胜利。'); addLog('游戏结束:AI 胜利。'); } else if (aiUnits.length === 0) { gameState.gameOver = true; updateStatus('你赢了!所有敌方单位已被消灭。'); addLog('游戏结束:玩家胜利。'); } }AI 回合的实现我用了最简单的策略:AI 单位逐个执行“寻找最近敌人 → 靠近敌人 → 攻击”的动作。这里的关键在于,AI 不能因为移动路径上的格子被占就死循环,所以我限制了最大寻路步数。
// 获取距离坐标最近的敌方单位 function getNearestEnemy(unit) { const enemyTeam = unit.team === 'player' ? 'ai' : 'player'; let nearest = null; let minDistance = Infinity; gameState.units.forEach(enemy => { if (enemy.team !== enemyTeam || enemy.hp <= 0) return; const distance = manhattanDistance(unit.row, unit.col, enemy.row, enemy.col); if (distance < minDistance) { minDistance = distance; nearest = enemy; } }); return nearest; } // AI 回合执行 function aiTurn() { updateStatus('AI 回合...'); const aiUnits = gameState.units.filter(u => u.team === 'ai' && u.hp > 0); let turnDelay = 0; aiUnits.forEach((unit, index) => { // 每个 AI 单位行动间隔 500ms,方便玩家观察 setTimeout(() => { if (gameState.gameOver) return; const enemy = getNearestEnemy(unit); if (!enemy) return; const distance = manhattanDistance(unit.row, unit.col, enemy.row, enemy.col); // 如果在攻击范围内,直接攻击 if (distance <= unit.attackRange) { performAttack(unit, enemy); return; } // 否则向敌人移动一格 const move = getAiMoveStep(unit, enemy); if (move) { performMoveSimple(unit, move.row, move.col); } }, turnDelay + index * 500); }); // 所有 AI 行动结束后,切换回玩家回合 setTimeout(() => { if (gameState.gameOver) return; startPlayerTurn(); }, turnDelay + aiUnits.length * 500 + 300); } // 获取 AI 移动一步的坐标(向敌人靠近) function getAiMoveStep(unit, enemy) { let bestMove = null; let bestDistance = manhattanDistance(unit.row, unit.col, enemy.row, enemy.col); const directions = [ { row: unit.row - 1, col: unit.col }, { row: unit.row + 1, col: unit.col }, { row: unit.row, col: unit.col - 1 }, { row: unit.row, col: unit.col + 1 } ]; directions.forEach(pos => { if (!isInBounds(pos.row, pos.col)) return; const occupied = gameState.units.some(u => u.row === pos.row && u.col === pos.col); if (occupied) return; const distance = manhattanDistance(pos.row, pos.col, enemy.row, enemy.col); if (distance < bestDistance) { bestDistance = distance; bestMove = pos; } }); return bestMove; } // AI 单位简单移动(不触发玩家交互逻辑) function performMoveSimple(unit, row, col) { unit.row = row; unit.col = col; addLog(`AI 单位 ${unit.id} 移动至 (${row}, ${col})。`); renderGame(); } // 开始玩家回合 function startPlayerTurn() { gameState.currentTurn = 'player'; gameState.turnNumber++; gameState.selectedUnitId = null; gameState.moveTargets = []; gameState.attackTargets = []; // 重置所有单位行动状态 gameState.units.forEach(unit => { unit.hasMoved = false; unit.hasAttacked = false; }); updateStatus(`当前回合:玩家(第 ${gameState.turnNumber} 回合)`); addLog(`第 ${gameState.turnNumber} 回合开始,轮到玩家行动。`); renderGame(); }最后是按钮事件绑定、状态更新函数和初始化入口:
// 初始化事件绑定 function setupEvents() { document.getElementById('endTurnBtn').addEventListener('click', () => { if (gameState.currentTurn !== 'player') return; if (gameState.gameOver) return; // 清空选中状态 gameState.selectedUnitId = null; gameState.moveTargets = []; gameState.attackTargets = []; // 切换到 AI 回合 gameState.currentTurn = 'ai'; updateStatus('AI 回合...'); renderGame(); aiTurn(); }); document.getElementById('restartBtn').addEventListener('click', () => { initGame(); }); } // 更新顶部状态文字 function updateStatus(text) { document.getElementById('game-status').textContent = text; } // 添加战斗日志 function addLog(text) { const log = document.getElementById('battle-log'); const line = document.createElement('div'); line.textContent = `[第${gameState.turnNumber}回合] ${text}`; log.appendChild(line); log.scrollTop = log.scrollHeight; } // 清空战斗日志 function clearLog() { document.getElementById('battle-log').innerHTML = ''; } // 启动游戏 setupEvents(); initGame();到这里,一个可玩的回合制战棋游戏原型就完成了。用 VS Code 打开index.html,在浏览器中运行,就能看到 10x10 的网格地图、双方各 3 个单位,点击蓝色单位可以看到黄色移动范围,移动后如果附近有红色单位,红色单位会显示攻击高亮,点击即可发动攻击。
5. 实战常见问题与排查思路
一个暑假的开发过程中,AI 生成的代码并不是一蹴而就的。我整理了最常见的几类问题以及排查思路,这些经验比代码本身更有价值。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 点击单位没有反应 | 事件绑定重复或 canvas 坐标换算错误 | 检查 canvas.addEventListener 是否只绑定一次,检查 canvasToGrid 是否考虑 CSS 缩放 |
| 单位移动后仍然能移动 | hasMoved 状态没有正确更新 | 在 performMove 中检查并设置 hasMoved = true,并在 startPlayerTurn 中重置 |
| AI 回合没有执行 | setTimeout 回调中 gameOver 状态判断过早 | 检查 AI 行动多个 setTimeout 之间的时序,确保玩家不会在 AI 行动期间操作 |
| 单位重叠在同一个格子 | 移动目标列表没有排除单位占用格子 | 在 getMoveTargets 中过滤已占用格子 |
| AI 攻击自己人 | getNearestEnemy 筛选条件写错 | 检查单位筛选时 enemyTeam 是否正确排除己方 |
| 重新开始后状态没有重置 | 初始化函数没有重置所有字段 | 确保 initGame 中重置所有全局变量,包括回合数、选中单位、行动状态 |
| 画面显示模糊 | canvas 尺寸与 CSS 尺寸不一致 | 使用 getBoundingClientRect 做坐标换算,或将 canvas 尺寸设置为固定值 |
5.1 AI 生成代码的“幻觉”问题
vibe coding 最折磨人的是 AI 会像人类一样“一本正经地犯错”。有一次我要求 AI 添加地形系统,它给每个格子随机生成了地形,但在计算移动范围时,直接通过terrain[row][col]读取数据,却没有先初始化地形数组,导致点击单位时直接抛错。
排查这种 AI 幻觉问题的思路是:
- 先看报错信息定位到具体行。
- 检查该行访问的数据结构是否被正确初始化。
- 观察 AI 的逻辑是否与真实需求一致,而不是它自己理解的规则。
- 如果错误无法快速定位,直接让 AI 打印关键状态到控制台。
5.2 代码越来越难以维护怎么办
随着功能增加,game.js 文件会不断膨胀。我第一版代码只有 300 多行,后来加入了多种单位类型和地形后,直接涨到了 1500 行。这时 AI 修改代码经常会破坏其他功能。
我的解决方案是“模块化重构”。把代码拆成几个文件:
star-conflict/ ├── index.html ├── game.js # 入口和初始化 ├── units.js # 单位数据操作 ├── ai.js # AI 行动逻辑 ├── render.js # Canvas 渲染 └── style.css模块化之后,每次让 AI 修改 AI 逻辑时,我会明确告诉它“只修改 ai.js 文件”,其他文件不要动。这样大幅减少了回归问题。
6. 从原型到独立游戏:工程化最佳实践
6.1 使用 Git 进行版本管理
不管是不是 vibe coding,但凡写超过 500 行的代码,都应该用 Git。AI 改代码的速度太快了,如果每次修改都不留痕迹,你会经常面临“这个功能以前是好的,怎么现在坏了”的困境。
我的习惯是每完成一个小功能就提交一次:
git add . git commit -m "feat: 实现敌方 AI 基础行动逻辑"如果 AI 修改后出现严重问题,直接git revert或者git stash回到上一个可用版本,避免在错误基础上继续迭代。
6.2 建立提示词仓库
暑假开发到中期,我发现很多提示词是可以复用的。我建了一个prompts/目录,专门存放常用提示词模板。
prompts/ ├── 生成项目骨架.md ├── 添加地形系统.md ├── 实现单位升级.md ├── 修复点击事件.md └── AI生成单元测试.md每次遇到一个完成度较高的提示词,我就保存下来。到了一周之后,新功能的生成效率明显提高,因为可以直接套用之前验证过的提示词结构,而不是每次重新描述需求。
6.3 代码审查的关键检查点
AI 生成的代码不能直接信任,我每次提交前会重点检查几个方面:
- 边界条件:数组越界、空值判断、负值处理。
- 状态一致性:单位移动后攻击范围是否重新计算。
- 事件安全:重复绑定事件是否会造成重复操作。
- 性能问题:循环嵌套是否过深,是否有不必要的重复计算。
- 可读性:变量命名是否清晰,是否会误导后续 AI 修改。
6.4 用 AI 辅助测试
对于策略游戏这种规则复杂的项目,人工测试很难覆盖所有情况。我会让 AI 生成单元测试代码,用 Node.js 跑纯逻辑测试。
例如,为getMoveTargets写一个测试:
// 文件路径:star-conflict/test/movement.test.js // 以下为示例思路,需根据你的模块导出方式调整 const { getMoveTargets } = require('../game'); function testMoveTargets() { const unit = createUnit(1, 'player', 5, 5); const targets = getMoveTargets(unit); // 移动到 (5,5) 的曼哈顿距离为 2 以内,且不包含自己 const containsSelf = targets.some(t => t.row === 5 && t.col === 5); const allInRange = targets.every(t => Math.abs(t.row - 5) + Math.abs(t.col - 5) <= 2 ); const allInBounds = targets.every(t => t.row >= 0 && t.row < 10 && t.col >= 0 && t.col < 10 ); console.assert(!containsSelf, '移动目标不应包含自身'); console.assert(allInRange, '所有移动目标应在范围内'); console.assert(allInBounds, '所有移动目标应在地图内'); console.log('移动范围测试通过'); } testMoveTargets();AI 辅助生成测试用例的好处是,它能快速想到成千上万种边界情况。但要注意,测试断言的结果必须由人来判断,AI 不能替你做质量把关。
6.5 安全与合规提醒
暑假做项目时,要注意一个容易被忽略的问题:如果你打算把项目开源或发布,使用的 AI 工具生成代码时,要留意代码许可证问题。目前主流的代码生成工具通常不会直接复制 GitHub 上的受保护代码,但仍然建议发布前进行人工审查。涉及用户数据的游戏,不要在后端硬编码密钥;即使只是本地原型,也要养成“密钥不入代码库”的习惯。
7. 如何继续迭代:从原型到完整游戏
第一款原型跑通后,我的暑假项目并没有结束。后续迭代方向和建议如下:
- 增加地形系统:在地图上添加障碍物、森林、山丘,让移动和攻击规则更复杂。这一步需要把移动范围计算从曼哈顿距离改成 BFS 搜索。
- 多种单位类型:近战单位(高血量高攻击)、远程单位(低血量远程攻击)、移动型单位(移动范围大但攻击弱),让阵容搭配产生策略深度。
- 战斗动画:用 Canvas 的 requestAnimationFrame 实现移动、攻击的动画过渡,需要把状态更新和渲染拆开。
- 存档功能:使用浏览器 localStorage 保存游戏状态,让玩家可以继续上次未完成的战役。
- 关卡设计:设计多个 PvE 关卡,每关有不同的地图、敌人配置和胜利条件。
这些迭代中建议继续遵循同一个循环:写需求文档 → 让 AI 生成核心模块 → 人工审查 → 测试 → 提交 Git。
一个可以复用的“新增单位类型”提示词示例:
在当前游戏项目中增加远程单位类型: 1. 远程单位攻击范围为 3,移动范围为 2,生命值为 70,攻击力为 20。 2. 在 createUnit 工厂函数中增加 unitType 字段,取值为 'melee' 或 'ranged'。 3. 渲染时远程单位用三角形表示,近战单位继续用圆形表示。 4. 远程单位在移动后可以攻击,攻击后不能再移动。 5. 只在 game.js 中修改,其他文件不动。 6. 修改之前先打印当前 createUnit 函数的完整代码,确认理解后再改。明确告诉 AI“打印现有代码再修改”,可以显著降低它“凭记忆重写”带来的回归问题。
如果你也想在暑假用 vibe coding 做一个独立策略游戏,我的核心建议只有一条:不要把任务交由 AI 全权接管,而是让自己做架构师,让 AI 做施工队。你需要理解每一段核心代码的作用,掌握基础的调试手段,并且在关键时刻敢于对 AI 说“不对,重写这块”。这个暑假我最大的收获不是游戏成品,而是掌握了与 AI 协同开发、快速迭代真实项目的方法,这套方法在未来的学习与工作中都会长时间适用。