vibe coding实战:AI辅助编程从零构建策略游戏原型
2026/8/31 2:41:03 网站建设 项目流程

刚接触“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 也容易理解。

项目名称暂定为《星域争锋》,核心玩法如下:

  1. 玩家和 AI 各自控制 3 个作战单位,分布在 10x10 的网格地图上。
  2. 每个单位有生命值、攻击力、移动范围、攻击范围。
  3. 玩家回合可以选中单位,查看可移动范围和可攻击目标。
  4. 执行移动或攻击后,结束回合,AI 自动行动。
  5. 任意一方全部单位被消灭,游戏结束。

游戏流程从“构思”开始,我用 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.css

3.2 AI 工具选择与工作流

这个暑假我主要使用了两类 AI 工具:

  1. 对话式 AI 编程助手:直接通过自然语言对话生成代码片段、排查报错。
  2. 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,也就是只能攻击相邻格子。
  • hasMovedhasAttacked:用来记录单位本回合是否已经移动或攻击,防止重复行动。

这个模型可以很方便地扩展:如果要加入“远程单位”,只需要把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 幻觉问题的思路是:

  1. 先看报错信息定位到具体行。
  2. 检查该行访问的数据结构是否被正确初始化。
  3. 观察 AI 的逻辑是否与真实需求一致,而不是它自己理解的规则。
  4. 如果错误无法快速定位,直接让 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 生成的代码不能直接信任,我每次提交前会重点检查几个方面:

  1. 边界条件:数组越界、空值判断、负值处理。
  2. 状态一致性:单位移动后攻击范围是否重新计算。
  3. 事件安全:重复绑定事件是否会造成重复操作。
  4. 性能问题:循环嵌套是否过深,是否有不必要的重复计算。
  5. 可读性:变量命名是否清晰,是否会误导后续 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. 如何继续迭代:从原型到完整游戏

第一款原型跑通后,我的暑假项目并没有结束。后续迭代方向和建议如下:

  1. 增加地形系统:在地图上添加障碍物、森林、山丘,让移动和攻击规则更复杂。这一步需要把移动范围计算从曼哈顿距离改成 BFS 搜索。
  2. 多种单位类型:近战单位(高血量高攻击)、远程单位(低血量远程攻击)、移动型单位(移动范围大但攻击弱),让阵容搭配产生策略深度。
  3. 战斗动画:用 Canvas 的 requestAnimationFrame 实现移动、攻击的动画过渡,需要把状态更新和渲染拆开。
  4. 存档功能:使用浏览器 localStorage 保存游戏状态,让玩家可以继续上次未完成的战役。
  5. 关卡设计:设计多个 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 协同开发、快速迭代真实项目的方法,这套方法在未来的学习与工作中都会长时间适用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询