AI结对编程实战:用Codex从零复刻2048游戏

发布时间:2026/7/14 4:42:58
AI结对编程实战:用Codex从零复刻2048游戏 1. 项目概述当AI成为你的游戏开发搭档最近在开发者圈子里一个话题热度居高不下如何利用AI辅助编程工具真正提升我们的开发效率而不是仅仅停留在“玩具”或“聊天”的层面。我尝试了市面上不少工具最终决定拿一个经典项目来“开刀”——用OpenAI的Codex模型从零开始复刻一个完整的2048游戏。这不仅仅是为了复刻更是想探索在AI的辅助下一个前端项目的开发流程、思考方式以及最终的代码质量会发生怎样的变化。最终我不仅实现了游戏的核心逻辑还额外添加了滑动动画、合并特效和背景音效让这个经典的数字游戏焕然一新。这个案例非常适合有一定前端基础了解HTML、CSS、JavaScript的开发者。无论你是想学习如何与AI高效协作还是希望快速验证一个游戏原型或者单纯好奇AI编程的边界在哪里这个过程都能给你带来不少启发。我们将从零开始一步步拆解需求与Codex对话调试代码并最终整合成一个可玩、可看、可听的完整作品。你会发现AI不是来取代你的而是一个理解力超强、不知疲倦的结对编程伙伴。2. 核心思路与工具选型解析2.1 为什么选择2048作为复刻目标选择2048游戏作为AI编程的实践案例背后有非常实际的考量。首先它的规则明确且有限。游戏的核心逻辑可以清晰地分解为几个独立的函数初始化棋盘、处理键盘输入上下左右、在指定方向上移动并合并数字、在空白处随机生成新数字、判断游戏胜负。这种模块化的结构非常适合我们向AI分步骤、分功能地描述需求。其次2048的状态表示简单。一个4x4的二维数组就能完美表示整个游戏棋盘每个格子要么是0空要么是2的幂次方数字。这种数据结构对于AI来说非常容易理解和操作。最后它具备适度的复杂性。虽然规则简单但实现滑动合并算法时需要考虑行列遍历的顺序、相邻相同数字的合并、一次移动中每个数字只能合并一次等细节。这些“坑”正是检验AI代码生成能力和我们调试能力的绝佳试金石。2.2 AI编程搭档Codex的能力边界与协作模式我使用的是基于GPT-3.5/4架构的Codex模型它被专门训练用于理解和生成代码。与通用聊天AI不同Codex对编程语言的语法、常见库和模式有更深的理解。但在开始之前我们必须明确它的定位一个强大的代码自动补全和片段生成工具而非一个能理解完整项目架构、自动处理复杂业务逻辑的“全自动程序员”。我们的协作模式应该是“驾驶员-领航员”模式。我们是掌握方向盘和最终决策的“驾驶员”负责提出清晰、具体的问题定义函数接口规划项目结构。Codex则是“领航员”根据我们的指令快速提供多条可行的代码路径供我们选择。例如我不会对AI说“给我写个2048游戏”而是会说“请用JavaScript写一个函数moveLeft(grid)它接收一个4x4的二维数组grid模拟2048游戏中向左滑动的操作返回新的网格状态和本次移动获得的分数。”注意与AI协作编程最关键的技巧是“小步快跑持续验证”。每次只让它生成一小段功能明确的代码通常不超过一个函数然后立刻在浏览器控制台或简单的测试环境中运行验证。这样可以快速定位问题是出在AI的理解上还是我们自己的描述上。2.3 技术栈选择简约而不简单为了聚焦于AI协作与游戏逻辑本身我选择了最纯粹的前端技术栈HTML5: 负责搭建页面骨架。我们将主要使用div和CSS Grid来构建游戏棋盘这比传统的表格更灵活、更现代。CSS3: 负责游戏的外观。包括棋盘的样式、不同数字方块的背景色、字体样式以及最关键的——CSS过渡Transition和动画Keyframes用于实现方块滑动和合并的特效。Vanilla JavaScript (原生JS): 负责游戏的所有逻辑。不依赖任何外部游戏框架如Phaser或UI框架如React这能让我们最清晰地展示AI如何生成算法代码也避免了因框架特定语法带来的复杂性。这个选择确保了项目的轻量化和可移植性生成的代码也更容易被不同技术背景的开发者理解和复用。3. 游戏核心模块的AI实现与详解3.1 游戏状态管理与初始化一切从数据开始。我们首先需要定义游戏的核心状态。我向Codex提出了第一个请求“用JavaScript创建一个2048游戏的状态对象包含一个4x4的二维数组表示棋盘一个分数以及一个游戏是否结束的标志。”AI生成的代码基本符合预期但我在此基础上做了优化和解释// gameState.js - 游戏状态管理模块 const GameState { grid: Array(4).fill().map(() Array(4).fill(0)), // 4x4网格0代表空 score: 0, isGameOver: false, hasChanged: false, // 新增标记本次操作网格是否实际发生了变化 // 初始化游戏在随机两个位置生成数字2或4 init() { this.grid Array(4).fill().map(() Array(4).fill(0)); this.score 0; this.isGameOver false; this.addRandomTile(); this.addRandomTile(); return this.grid; }, // 在随机一个空白位置添加一个数字90%概率为210%概率为4 addRandomTile() { const emptyCells []; for (let r 0; r 4; r) { for (let c 0; c 4; c) { if (this.grid[r][c] 0) { emptyCells.push({ r, c }); } } } if (emptyCells.length 0) { const { r, c } emptyCells[Math.floor(Math.random() * emptyCells.length)]; this.grid[r][c] Math.random() 0.9 ? 2 : 4; } } };关键点解析Array(4).fill().map(() Array(4).fill(0))是创建二维数组的简洁写法。Array(4)创建长度为4的稀疏数组.fill()将其填充为undefined使其可迭代然后.map为每一项再创建一个填充为0的长度为4的数组。我额外添加了hasChanged标志。这是实现滑动逻辑时的重要技巧。在判断一次键盘操作是否有效时不能只看操作后是否有数字合并还要看数字的位置是否发生了移动。hasChanged将在移动函数中被更新用于判断是否需要调用addRandomTile。初始生成两个数字以及新数字按9:1的比例生成2或4这些都是2048的标准规则需要在提示词中明确告诉AI。3.2 核心算法方向移动与数字合并这是整个游戏最复杂的部分。我选择将“向左移动”作为基础算法实现然后通过矩阵旋转来复用该逻辑实现其他三个方向的移动。我向AI描述“写一个函数slideAndMerge(row)它处理一个长度为4的数组代表一行模拟向左滑动合并的效果。例如输入[2, 0, 2, 4]应该返回[4, 4, 0, 0]并返回合并得到的分数4。”经过几次调整得到了清晰的核心行处理函数// logic.js - 核心算法模块 function slideAndMerge(row) { // 1. 过滤零值移除所有0将数字紧凑到左侧 let filtered row.filter(val val ! 0); let score 0; // 2. 合并相邻相同数字 for (let i 0; i filtered.length - 1; i) { if (filtered[i] filtered[i 1]) { filtered[i] * 2; // 数字合并 score filtered[i]; // 计算得分 filtered.splice(i 1, 1); // 移除被合并的后一个元素 // 注意这里不需要i因为移除元素后下一个元素已经前移 } } // 3. 补零至长度4 while (filtered.length 4) { filtered.push(0); } return { newRow: filtered, score }; }为什么这样写这个“过滤-合并-补零”的三步法是实现2048合并的经典且高效的方法。它逻辑清晰避免了在原始数组上进行复杂的原地操作和多重循环判断。接下来基于这个行处理函数构建整个网格的移动函数。我让AI生成moveLeft函数function moveLeft(grid) { let newGrid []; let totalScore 0; let changed false; for (let row of grid) { const { newRow, score } slideAndMerge(row); newGrid.push(newRow); totalScore score; // 检查这一行是否发生了变化 if (JSON.stringify(row) ! JSON.stringify(newRow)) { changed true; } } return { grid: newGrid, score: totalScore, changed }; }实现其他方向的关键技巧不需要为每个方向重写一套逻辑。我们可以利用矩阵的旋转操作。例如向上移动相当于将网格逆时针旋转90度然后执行向左移动最后再旋转回来。我向AI提问“写一个函数rotateGrid(grid)将4x4二维数组逆时针旋转90度。” AI轻松给出了矩阵旋转的算法。这样moveUp、moveRight、moveDown都可以通过组合rotateGrid和moveLeft来实现极大减少了代码量和出错概率。function rotateGrid(grid, times 1) { let result grid.map(row [...row]); // 深拷贝 for (let t 0; t times; t) { const N result.length; const rotated Array(N).fill().map(() Array(N).fill(0)); for (let r 0; r N; r) { for (let c 0; c N; c) { rotated[N - 1 - c][r] result[r][c]; // 逆时针旋转 } } result rotated; } return result; } function moveUp(grid) { // 逆时针旋转90度 - 向左移动 - 顺时针旋转90度即旋转3次逆时针 let rotated rotateGrid(grid, 1); // 逆时针90度 let { grid: moved, score, changed } moveLeft(rotated); return { grid: rotateGrid(moved, 3), score, changed }; // 逆时针270度 顺时针90度 } // moveRight, moveDown 同理...实操心得在让AI生成矩阵旋转这类算法时一定要明确输入输出的数据结构。最初AI生成的是一个原地修改数组的函数这会影响后续操作。我补充了提示词“请返回一个新的二维数组不要修改原数组。” 这才得到了上面正确的版本。与AI沟通细节决定成败。3.3 游戏结束判定与胜负逻辑游戏结束的条件有两个1. 网格被填满没有02. 填满后任意相邻上下左右的格子之间没有相同的数字即无法合并。我让AI生成一个检查函数function isGameOver(grid) { // 条件1检查是否有空格 for (let r 0; r 4; r) { for (let c 0; c 4; c) { if (grid[r][c] 0) return false; // 有空位游戏继续 } } // 条件2检查相邻格子是否有相同数字 const directions [[0, 1], [1, 0]]; // 只需检查右方和下方避免重复 for (let r 0; r 4; r) { for (let c 0; c 4; c) { for (let [dr, dc] of directions) { const nr r dr; const nc c dc; if (nr 4 nc 4 grid[r][c] grid[nr][nc]) { return false; // 有可合并项游戏继续 } } } } // 两个条件都不满足游戏结束 return true; }同时胜利条件是出现2048这个数字。这个判断可以在每次移动合并后遍历网格轻松实现。4. 用户界面与交互实现4.1 基于CSS Grid的棋盘绘制现代CSS的Grid布局非常适合构建2048的棋盘。我给了AI一个简单的描述“用HTML和CSS创建一个4x4的网格容器每个格子是正方形有圆角和背景色格子之间有间隙。”AI生成的CSS结构很不错但我根据视觉设计做了增强!-- index.html -- div classgame-container div classheader h12048/h1 div classscores div classscore-box分数: span idscore0/span/div button idrestart新游戏/button /div /div div classgrid-container idgrid-container !-- 16个格子将由JS动态生成 -- /div div classinstructions使用 strong方向键/strong 或 strongWASD/strong 移动方块/div /div/* style.css */ .grid-container { display: grid; grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr); gap: 12px; width: 500px; height: 500px; background-color: #bbada0; border-radius: 10px; padding: 12px; position: relative; } .grid-cell { background-color: rgba(238, 228, 218, 0.35); border-radius: 6px; } .tile { position: absolute; /* 使用绝对定位来实现动画 */ width: calc((500px - 12px * 5) / 4); /* 计算格子精确宽度 */ height: calc((500px - 12px * 5) / 4); border-radius: 6px; display: flex; justify-content: center; align-items: center; font-size: 36px; font-weight: bold; color: #776e65; transition: all 0.15s ease-in-out; /* 为位置和缩放变化添加过渡效果 */ z-index: 2; }关键决策我选择将棋盘背景.grid-cell和数字方块.tile分离。16个.grid-cell作为静态的网格背景而.tile则作为绝对定位的元素通过JavaScript动态计算其top和left值来放置在对应位置上。这种分离为后续实现平滑的滑动动画打下了基础。4.2 动态渲染与状态同步我们需要一个render函数将GameState.grid的数据映射到屏幕上的方块。我向AI描述“写一个函数renderGrid(grid, container)它接收网格状态和DOM容器。清除容器内旧的方块然后遍历网格为每个非零值创建一个带有对应数字和颜色的div元素类名为tile并计算其位置添加到容器中。”AI生成的函数骨架正确但颜色映射和位置计算需要细化。我补充了完整的实现// render.js const tileColors { 2: #eee4da, 4: #ede0c8, 8: #f2b179, 16: #f59563, 32: #f67c5f, 64: #f65e3b, 128: #edcf72, 256: #edcc61, 512: #edc850, 1024: #edc53f, 2048: #edc22e }; function renderGrid(grid, container) { // 移除所有现有方块 const oldTiles container.querySelectorAll(.tile); oldTiles.forEach(tile tile.remove()); const cellSize container.clientWidth / 4; // 每个格子的大小 const gap 12; // 与CSS中的gap一致 for (let r 0; r 4; r) { for (let c 0; c 4; c) { const value grid[r][c]; if (value ! 0) { const tile document.createElement(div); tile.className tile; tile.textContent value; tile.style.backgroundColor tileColors[value] || #3c3a32; tile.style.color value 4 ? #f9f6f2 : #776e65; // 数字大了用浅色字 tile.style.fontSize value 100 ? 36px : value 1000 ? 30px : 24px; // 关键计算绝对定位的坐标 tile.style.left ${c * (cellSize gap) gap}px; tile.style.top ${r * (cellSize gap) gap}px; container.appendChild(tile); } } } }注意事项这里有一个常见的坑CSS的gap属性会影响绝对定位的计算。方块的left和top值必须是(列索引 * (格子大小 间隙)) 间隙。很多初学者会忘记加最后的gap导致方块位置偏移。在与AI协作时这种对UI细节的精确描述至关重要。4.3 键盘事件绑定与游戏主循环最后我们需要把所有的模块串联起来。监听键盘事件调用对应的移动函数更新状态重新渲染并检查游戏状态。// main.js document.addEventListener(DOMContentLoaded, () { const gridContainer document.getElementById(grid-container); const scoreElement document.getElementById(score); const restartButton document.getElementById(restart); // 初始化游戏 let currentGrid GameState.init(); renderGrid(currentGrid, gridContainer); updateScore(); // 键盘事件监听 document.addEventListener(keydown, (event) { if (GameState.isGameOver) return; let result null; switch(event.key) { case ArrowLeft: case a: case A: result moveLeft(currentGrid); break; case ArrowUp: case w: case W: result moveUp(currentGrid); break; case ArrowRight: case d: case D: result moveRight(currentGrid); break; case ArrowDown: case s: case S: result moveDown(currentGrid); break; default: return; // 按其他键无效 } if (result.changed) { currentGrid result.grid; GameState.score result.score; GameState.addRandomTile(); GameState.isGameOver isGameOver(currentGrid); renderGrid(currentGrid, gridContainer); updateScore(); if (GameState.isGameOver) { setTimeout(() alert(游戏结束最终分数 GameState.score), 300); } // 检查是否获胜 if (currentGrid.flat().includes(2048)) { setTimeout(() alert(恭喜你达成2048), 300); } } }); function updateScore() { scoreElement.textContent GameState.score; } restartButton.addEventListener(click, () { currentGrid GameState.init(); renderGrid(currentGrid, gridContainer); updateScore(); }); });至此一个功能完整的2048游戏核心已经实现了。你可以用方向键或WASD进行游戏分数会更新游戏结束也会有提示。5. 特效与音效的进阶实现5.1 平滑滑动与合并动画基础的渲染是“闪现”式的方块直接出现在新位置。为了更好的体验我们需要动画。思路是在移动操作发生时不立即销毁旧方块并创建新方块而是为每个方块标记一个唯一的ID例如基于其行列和值然后计算移动前后的位置。如果只是移动则用CSStransition平滑改变top/left如果是合并则先移动然后触发一个“放大再还原”的动画。这需要修改render函数和状态管理。我们引入一个Tile类来管理每个方块的DOM元素和状态// tileManager.js class TileManager { constructor(container) { this.container container; this.tiles new Map(); // key: r-c, value: {domElement, value} } updateOrCreateTile(r, c, value) { const key ${r}-${c}; const cellSize this.container.clientWidth / 4; const gap 12; const targetLeft c * (cellSize gap) gap; const targetTop r * (cellSize gap) gap; if (this.tiles.has(key)) { // 已有方块移动到新位置可能是合并后的位置 const tile this.tiles.get(key); tile.domElement.style.left ${targetLeft}px; tile.domElement.style.top ${targetTop}px; if (tile.value ! value) { // 值改变了合并发生更新外观并触发合并动画 tile.value value; tile.domElement.textContent value; tile.domElement.style.backgroundColor tileColors[value] || #3c3a32; tile.domElement.classList.add(merge-animation); setTimeout(() tile.domElement.classList.remove(merge-animation), 200); } } else { // 创建新方块新生成的数字赋予一个“出现”动画 const tileEl document.createElement(div); // ... 设置样式和内容同前 tileEl.style.left ${targetLeft}px; tileEl.style.top ${targetTop}px; tileEl.classList.add(new-animation); this.container.appendChild(tileEl); this.tiles.set(key, { domElement: tileEl, value }); } } // 在每次移动前清除所有方块准备根据新网格重新创建/更新 clearTiles() { this.tiles.forEach(tile tile.domElement.remove()); this.tiles.clear(); } }对应的CSS动画/* 新方块出现动画 */ keyframes appear { 0% { transform: scale(0); opacity: 0; } 100% { transform: scale(1); opacity: 1; } } .tile.new-animation { animation: appear 0.2s ease-out; } /* 合并动画 */ keyframes pop { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } } .tile.merge-animation { animation: pop 0.2s ease-in-out; }主循环中在移动计算完成后不再调用简单的renderGrid而是调用TileManager的clearTiles然后遍历新网格为每个有数字的格子调用updateOrCreateTile。这样未移动的方块会平滑过渡到新位置尽管位置没变移动的方块会滑动过去新生成的方块有出现动画合并的方块有放大动画。5.2 游戏音效的集成音效能极大增强游戏的沉浸感。我们准备三种音效移动声轻微、合并声清脆、游戏结束声低沉。可以使用Web Audio API或简单的HTML5audio元素。这里选择更简单的后者。首先准备或生成简短的音效文件.mp3或.wav格式如move.mp3,merge.mp3,gameover.mp3。// audioManager.js const AudioManager { sounds: {}, init() { const soundFiles { move: sounds/move.mp3, merge: sounds/merge.mp3, gameover: sounds/gameover.mp3 }; for (const [key, url] of Object.entries(soundFiles)) { this.sounds[key] new Audio(url); this.sounds[key].volume 0.3; // 设置音量 } }, play(soundName) { if (this.sounds[soundName]) { // 每次播放前重置到开始以实现快速连续播放 this.sounds[soundName].currentTime 0; this.sounds[soundName].play().catch(e console.log(音频播放被阻止:, e)); } } }; // 在游戏初始化时调用 AudioManager.init()然后在主循环的相应位置触发音效移动音效在if (result.changed)块内AudioManager.play(move)。合并音效在slideAndMerge函数中每当合并发生时filtered[i] filtered[i 1]可以返回一个标志或者更简单地在主循环中如果本次移动获得的score 0则额外播放AudioManager.play(merge)。游戏结束音效在触发游戏结束提示前AudioManager.play(gameover)。实操心得浏览器通常要求音频播放必须由用户手势如点击触发否则会被自动阻止。这就是为什么我们在keydown事件用户操作中播放音效是安全的。如果游戏有自动演示模式音效可能会被阻止需要更复杂的用户交互预处理。6. 项目优化、调试与扩展思考6.1 性能优化与常见问题排查在开发过程中你可能会遇到一些典型问题动画卡顿或闪烁原因可能是在同一帧内频繁进行DOM操作如清除大量元素再立即创建。解决使用requestAnimationFrame来同步动画与浏览器刷新率。在我们的TileManager中一次性更新所有方块的top/left属性浏览器会智能地批量处理这些样式变更性能通常很好。确保CSS动画属性如transform,opacity使用GPU加速。移动后新方块出现位置不对原因计算方块位置时cellSize的计算依赖于容器的实际宽度。如果容器宽度因为CSS加载或布局变化而未确定clientWidth可能为0。解决确保在容器布局稳定后如DOMContentLoaded或window.onload再进行初始渲染和计算。或者使用getComputedStyle来获取包含padding和border的精确尺寸。键盘事件冲突或无效原因页面其他元素可能捕获了键盘事件或者事件监听被意外移除。排查在事件监听函数开始处console.log(event.key)确认事件被触发。检查是否有其他全局脚本绑定了相同事件并调用了event.preventDefault()。AI生成代码的逻辑错误场景例如最初的slideAndMerge函数在合并[2,2,2,2]行时AI可能生成返回[4,4,0,0]的代码但正确结果应该是[4,4,0,0]吗不对应该是[4,4,0,0]但注意第一次合并前两个2得到4此时数组变为[4,2,2]下一个循环会合并中间的两个2吗不会因为索引已经移动了。实际上对于[2,2,2,2]标准2048的向左合并结果应该是[4,4,0,0]。但有些AI实现可能会错误地产生[8,0,0,0]连续合并。解决这就是为什么必须用边界用例测试AI生成的代码。编写简单的测试用例console.assert(slideAndMerge([2,2,2,2]).newRow.join(,) 4,4,0,0)。与AI协作时你要扮演测试员的角色。6.2 项目扩展方向一个基础版本完成之后你可以利用AI继续探索更多功能这能极大锻炼你描述复杂需求的能力本地存储“请添加功能使用localStorage自动保存当前游戏进度网格和分数当玩家刷新页面时能自动加载。”撤销功能“实现一个撤销按钮可以回退到上一步操作。提示你需要用一个数组来保存历史网格状态。”难度调整“增加一个滑块允许玩家调整新生成数字中‘4’出现的概率从0%到30%。”响应式设计“修改CSS使游戏棋盘在手机屏幕上也能正常显示格子大小随屏幕宽度变化。”触屏支持“为移动设备添加触摸滑动手势支持。提示监听touchstart和touchend事件计算滑动方向和距离。”6.3 与AI协作的最终体会通过这个完整的项目我最深的体会是AI极大地提升了“编码”环节的效率但“思考”和“设计”的责任完全在开发者自己肩上。它就像一个反应极快、知识渊博的实习生你可以把明确、琐碎的任务交给它“写一个逆时针旋转矩阵的函数”但它无法理解模糊的、高层次的目标“让游戏感觉更流畅”。成功的AI协作编程要求你能把复杂问题分解成原子化的、可精确描述的子任务。你的提示词质量直接决定了输出代码的质量。这次2048项目从状态初始化、核心算法、UI渲染到动画音效每一步都是对需求分解能力和提示词撰写能力的练习。当你习惯了这种模式你会发现自己的开发流程变得更加清晰和高效能够将更多精力集中在架构设计和用户体验这些真正创造性的工作上。