用华为云码道做一个 1024 网页版游戏:AI 生成主流程,手写补全核心逻辑
前言
1024 这个游戏,规则一句话就能说清:4×4 的格子,数字每次随机出现两个,方向键控制滑动,同样数字撞在一起就合并,目标是拼出 2048。
正因为规则简单,它特别适合当"AI 编程能力的第一道测试题"。规则越简单,越容易看清楚 AI 在什么地方开始不靠谱。
这篇记录我用华为云码道(CodeArts)代码智能体做这个游戏的完整过程,包括 AI 生成的部分、我接手重写的部分,以及最后沉淀下来的单文件版本长什么样。
一、先明确目标:不是做一个游戏,是测一个工具
开始之前我想清楚这次到底要干什么。
如果目标是"做一个能玩的 1024",那我大可以自己写,二百来行代码的事,一个下午足够。但那样这篇记录就没有意义了。
我真正的目标是把码道当成一个"初级开发者"来用,看看它到底能替我干多少活。所以这次的评估标准是三条:
| 评估维度 | 具体问题 |
|---|---|
| 生成速度 | 从一句话到能跑起来,需要多少轮交互 |
| 代码质量 | 生成的代码有没有明显逻辑错误,改起来成本高不高 |
| 调试表现 | 报错时它能不能自己定位问题,还是需要我逐行解释 |
带着这三个问题往下走,体验和收获完全不一样。
二、一句话生成,拿到什么
2.1 第一次交互
我的第一句话很简短:
"帮我创建一个简单的 1024 网页版游戏,纯 HTML 单文件,用方向键操作"
码道很快给出了回应,生成逻辑很清晰:
- 4×4 网格,数字 2 和 4 按一定概率随机出现
- 方向键触发滑动,数字撞墙不动
- 相同数字合并,合并后继续尝试移动
- 检测是否还有可移动方向,无路可走则游戏结束
- 分数统计,数字变大时加分
它给的实现是标准的"一维数组 + 滑动窗口"写法,这个选择是对的,后面会讲为什么。
2.2 直接跑起来是什么效果
生成的代码是单个 index.html,双击就能在浏览器打开,不需要任何构建步骤,也不需要起本地服务。这一点对新手特别友好——省掉了"装 Node、装依赖、跑 dev server"这一整条让人劝退的链路。
第一次运行确实能玩,方向键响应正常,数字会合并,分数会累加。
但它有几个明显问题。
三、AI 生成的三个问题,以及我怎么改
这一节是本文最有价值的部分。三个问题都不是"AI 不聪明",而是"AI 不知道我想要什么"。
3.1 问题一:移动后不刷新,界面看起来没动
现象:按方向键,数字确实合并了,但视觉上有延迟,中间还会闪一下旧的位置。
原因:AI 的实现里,滑动动画是用 CSS transition 做的,逻辑层直接修改了数字数组,但数组变化和 DOM 更新之间的时序没对齐。
我的处理:把"数据层"和"渲染层"彻底分开。一次滑动只做两件事——先算出新的格子状态,再整体重绘一次格子。不做逐个方块的过渡动画。
// 数据层:只关心数字怎么移动和合并
function move(dir) {
const next = mergeAll(readBoard(), dir);
if (isSame(next, board)) return false; // 没变化就不重绘
board = next;
addRandomTile();
render(); // 渲染层:一次性重画
return true;
}
// 渲染层:整盘重绘
function render() {
boardEl.innerHTML = '';
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
boardEl.appendChild(createTile(board[r][c]));
}
}
}
这个改法的好处是彻底消灭了时序问题。4×4 只有 16 个格子,整盘重绘的开销完全可以忽略,没必要为了"性能优化"引入增量更新的复杂度。
3.2 问题二:合并逻辑处理了连续合并,但漏了"本回合锁定"规则
现象:滑动方向上如果出现 [2, 2, 2, 2],正确结果应该是 [4, 4, 0, 0](两两合并),而不是 [8, 0, 0, 0]。
原因:AI 的合并函数在一次遍历里把相同数字累加,但累加后的结果没有再参与下一轮合并——而这恰恰也是错的。真实规则是:本回合刚合成的新数字要锁定,不能立刻再撞下一个。
我第一次改的时候也只修对了一半:把累加改成"合成后不压栈",结果 [2,2,4,4] 又变成了 [8,4](正确是 [4,8])。连续改了两版才把规则理解对。
这是 1024 最核心的规则,写错这个游戏基本就废了。
我的处理:给合并加一个"本回合锁定"标记——本回合刚合并出来的数字,不能再参与下一次合并。
这条规则是 1024 的灵魂,也是最容易写错的地方:
// 合并一行:lastMerged 标记当前格子是否由本回合合并产生
function mergeLine(line) {
const out = [];
let lastMerged = false;
for (const num of line) {
if (num === 0) continue;
if (out.length && out[out.length - 1] === num && !lastMerged) {
out[out.length - 1] = num * 2; // 合并
lastMerged = true; // 本回合产出的格子锁定
} else {
out.push(num);
lastMerged = false; // 原有格子可继续参与合并
}
}
while (out.length < 4) out.push(0);
return out;
}
为什么必须加这个标记,看一个反例就清楚了:
| 初始行 | 正确结果 | 如果不加标记会得到 |
|---|---|---|
| `[2, 2, 2, 2]` | `[4, 4, 0, 0]` | `[8, 0, 0, 0]` ❌ |
| `[2, 2, 4, 4]` | `[4, 8, 0, 0]` | `[8, 4, 0, 0]` ❌ |
| `[4, 4, 8, 8]` | `[8, 16, 0, 0]` | `[16, 8, 0, 0]` ❌ |
以 [2,2,4,4] 为例,正确过程是:先 2+2 合成 4(这个 4 本回合锁定,不能再撞),然后剩下两个 4 合成 8,得到 [4, 8]。
如果没加标记,那个刚合成的 4 会立刻和后面的 4 撞上变成 8,结果就错成了 [8, 4]。
我把这个算法拿 10 组边界用例跑了一遍验证,覆盖连续四连、空格穿插、异数不合并、超长行等情形,全部通过。这一步花的时间不到十分钟,但它是整个游戏正确性的地基。
3.3 问题三:不支持触摸滑动,手机上玩不了
现象:在电脑浏览器里没问题,但用手机访问页面,方向键不存在,完全没法操作。
原因:AI 只按桌面端场景生成,键盘事件是默认需求,触摸它没想到。
我的处理:补一套 touch 事件,和键盘事件共用同一个 move(dir) 入口:
// 触摸手势:识别主方向
let touchStart = null;
boardEl.addEventListener('touchstart', (e) => {
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, { passive: true });
boardEl.addEventListener('touchend', (e) => {
if (!touchStart) return;
const t = e.changedTouches[0];
const dx = t.clientX - touchStart.x;
const dy = t.clientY - touchStart.y;
// 位移太小视为误触
if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return;
move(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left')
: (dy > 0 ? 'down' : 'up'));
touchStart = null;
}, { passive: true });
关键设计是手势和键盘都只负责"决定方向",实际的移动逻辑只有一份。这样两种输入永远不会出现行为不一致。
四、补上游戏之外的东西
核心玩法修好了,但一个能玩的小游戏和一个好玩的小游戏之间还有距离。我又加了三样东西。
4.1 分数与最高分
合并时加分,分数记录到 localStorage,刷新页面不丢:
function saveBest(score) {
const best = Number(localStorage.getItem('best2048') || 0);
if (score > best) localStorage.setItem('best2048', String(score));
}
4.2 重新开始按钮
一局结束要给明确出口,不能让用户刷新页面。
4.3 合并时的数字动画
这里我用了一个小技巧:给新生成的格子加一个 CSS 动画类,让它"弹"一下出现。纯 CSS 实现,不需要一行 JavaScript 计时器:
@keyframes pop {
0% { transform: scale(0.3); opacity: 0.4; }
60% { transform: scale(1.08); }
100% { transform: scale(1); opacity: 1; }
}
.tile.new { animation: pop 0.18s ease-out; }
数字越大颜色越深,我用 HSL 按数值算一个色阶,比写死十几档颜色省事:
function tileColor(n) {
if (n === 0) return 'background: #eee';
const hue = 40 - Math.min(n, 2048) / 2048 * 30; // 40 → 10
return `background: hsl(${hue}, 70%, ${90 - Math.log2(n) * 5}%)`;
}
五、最终的文件结构
折腾完之后,整个游戏就是一个 index.html,大概两百行出头,包含三部分:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<!-- viewport 必须有,否则手机上不缩放 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2048</title>
<style>/* 布局、格子、动画 */</style>
</head>
<body>
<div class="score-bar">分数 / 最高分 / 重新开始</div>
<div id="board" class="board"></div>
<div class="tip">用方向键或滑动屏幕操作</div>
<script>/* 游戏逻辑 */</script>
</body>
</html>
我特意列一下这个单文件版本的取舍,因为这个选择其实影响了不少决策:
| 决策 | 选择 | 理由 |
|---|---|---|
| 部署 | 纯静态单文件 | 不需要服务器,双击就能玩 |
| 状态管理 | 一个二维数组 | 16 个格子,复杂状态管理是负担 |
| 合并算法 | 就地扫描 + 锁定标记 | 正确处理"本回合合并不再参与合并" |
| 渲染 | 整盘重绘 | 16 格开销可忽略,杜绝时序 bug |
| 动画 | 纯 CSS | 不引入 JS 计时器,不会有清理负担 |
没有用构建工具,没有用框架,没有依赖。 这个项目里没有任何一个环节需要我配置环境。
六、这次体验的三点体会
6.1 AI 适合写"结构清晰但细节繁琐"的代码
1024 的整体骨架——滑动、合并、随机生成、游戏结束判定——AI 一次就给出了合理的实现,而且用的是正确的数据结构。这部分代码写起来不难但很啰嗦,正好是 AI 的甜区。
6.2 但核心规则必须自己验证
合并逻辑写错这件事,AI 自己不会发现,测试也测不出来,只有玩的时候才知道。越是"看起来能跑"的代码,越要手动验证核心规则。
这也是我觉得最值得警惕的一点:AI 生成的代码给你的信心是虚假的。它能跑,但不代表它对。
6.3 单文件是新手最友好的起点
整个过程我没有配环境、没有装依赖、没有处理构建报错。对想试试 AI 编程工具的人来说,这种"从零到能玩"的路径几乎没有摩擦。
想深入折腾的时候再考虑拆分模块、加状态管理、加 AI 辅助,但第一步先让它跑起来,这个顺序不能反。
七、写在最后
1024 是个很小的游戏,但我认为它是个很好的 AI 编程起点:规则足够简单让你能一眼看出对错,又包含足够多的细节(连续合并、手势兼容、动画时序)让你能真实体会到 AI 的边界。
如果你也想试试,有两个建议:
- 先写清楚需求再让它动手,包括格子数量、操作方式、要不要触摸支持,这些直接影响生成结果
- 核心规则一定要自己验,别因为"能跑"就认为"是对的"
代码我放在下面,复制下来直接存成 .html 打开就能玩。
做了什么
- 用华为云码道(CodeArts)代码智能体生成了 1024 游戏的初版代码
- 重写了连续合并的核心逻辑,加了"本回合合并锁定"标记,修复了
[2,2,2,2]和[2,2,4,4]的合并错误 - 补齐了触摸滑动支持、分数记录、重新开始和数字动画
- 最终收敛为单个
index.html文件,零依赖、零构建
一些体会
- AI 写结构清晰的骨架很好用,核心规则正确性必须自己把关
- "能跑"和"是对的"是两件事,合并规则这种细节只有穷举边界用例才验得出来
- 16 个格子的规模下,整盘重绘是最优解,能避开一整类时序 bug
- 触屏与键盘共用同一个
move(dir)入口,两种输入就不会行为不一致 - 越小的项目越该坚持单文件,工程化的收益在真正变大之后才出现
代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2048</title>
<style>
body { font-family: system-ui, sans-serif; background: #faf8ef; text-align: center; padding: 20px; }
.score-bar { display: flex; gap: 12px; justify-content: center; align-items: center; margin-bottom: 16px; }
.score-box { background: #bbada0; color: #fff; padding: 6px 18px; border-radius: 6px; }
.score-box b { display: block; font-size: 22px; }
.board { display: grid; grid-template-columns: repeat(4, 76px); grid-template-rows: repeat(4, 76px);
gap: 8px; background: #bbada0; padding: 8px; border-radius: 8px; margin: 0 auto; }
.tile { display: flex; align-items: center; justify-content: center; border-radius: 6px;
font-size: 28px; font-weight: 700; color: #776e65; background: #eee; transition: all .1s; }
@keyframes pop { 0% { transform: scale(.3); opacity:.4 } 60% { transform: scale(1.08) } 100% { transform: scale(1); opacity:1 } }
.tile.new { animation: pop .18s ease-out; }
.tip { margin-top: 16px; color: #776e65; font-size: 14px; }
button { padding: 8px 18px; border: none; border-radius: 6px; background: #8f7a66; color: #fff; cursor: pointer; font-size: 14px; }
</style>
</head>
<body>
<div class="score-bar">
<div class="score-box">分数<b id="score">0</b></div>
<div class="score-box">最高分<b id="best">0</b></div>
<button onclick="restart()">重新开始</button>
</div>
<div id="board" class="board"></div>
<div class="tip">用方向键或滑动屏幕操作</div>
<script>
const N = 4;
let board, score;
const boardEl = document.getElementById('board');
function init() {
board = Array.from({length: N}, () => Array(N).fill(0));
score = 0;
addTile(); addTile();
render();
document.getElementById('best').textContent = localStorage.getItem('best2048') || 0;
}
function addTile() {
const empty = [];
for (let r=0;r<N;r++) for (let c=0;c<N;c++) if(!board[r][c]) empty.push([r,c]);
if(!empty.length) return;
const [r,c] = empty[Math.floor(Math.random()*empty.length)];
board[r][c] = Math.random() < 0.9 ? 2 : 4;
}
function mergeLine(line) {
const out = [];
let lastMerged = false;
for (const num of line) {
if (num === 0) continue;
if (out.length && out[out.length-1] === num && !lastMerged) {
out[out.length-1] = num * 2;
lastMerged = true;
} else {
out.push(num);
lastMerged = false;
}
}
while (out.length < N) out.push(0);
return out;
}
function readLines(dir) {
const lines = [];
for (let i=0;i<N;i++) {
const line = [];
for (let j=0;j<N;j++) {
if (dir==='left') line.push(board[i][j]);
if (dir==='right') line.push(board[i][N-1-j]);
if (dir==='up') line.push(board[j][i]);
if (dir==='down') line.push(board[N-1-j][i]);
}
lines.push(line);
}
return lines;
}
function writeLines(dir, lines) {
for (let i=0;i<N;i++) for (let j=0;j<N;j++) {
if (dir==='left') board[i][j] = lines[i][j];
if (dir==='right') board[i][N-1-j] = lines[i][j];
if (dir==='up') board[j][i] = lines[i][j];
if (dir==='down') board[N-1-j][i] = lines[i][j];
}
}
function move(dir) {
const before = JSON.stringify(board);
const lines = readLines(dir);
let gained = 0;
const merged = lines.map(line => {
const m = mergeLine(line);
for (let i = 0; i < N; i++) if (m[i] > line[i]) gained += m[i];
return m;
});
writeLines(dir, merged);
if (JSON.stringify(board) === before) { checkEnd(); return false; }
score += gained;
addTile();
render();
checkEnd();
return true;
}
function checkEnd() {
for (let r=0;r<N;r++) for (let c=0;c<N;c++) if(!board[r][c]) return;
for (const d of ['left','right','up','down']) {
const lines = readLines(d);
for (let i=0;i<N;i++) {
const m = mergeLine(lines[i]);
for (let j=0;j<N;j++) if (m[j] !== lines[i][j]) return;
}
}
alert('游戏结束!得分 ' + score);
}
function tileColor(n) {
if (!n) return 'background:#eee';
const hue = 40 - Math.min(n,2048)/2048*30;
return `background:hsl(${hue},70%,${90-Math.log2(n)*5}%)`;
}
function render() {
boardEl.innerHTML = '';
for (let r=0;r<N;r++) for (let c=0;c<N;c++) {
const v = board[r][c];
const d = document.createElement('div');
d.className = 'tile';
d.style.cssText = tileColor(v);
d.textContent = v || '';
if (v >= 4) d.classList.add('new');
boardEl.appendChild(d);
}
document.getElementById('score').textContent = score;
const best = Number(localStorage.getItem('best2048')||0);
if (score > best) localStorage.setItem('best2048', String(score));
document.getElementById('best').textContent = Math.max(best, score);
}
function restart() { init(); }
document.addEventListener('keydown', (e) => {
const map = {ArrowLeft:'left', ArrowRight:'right', ArrowUp:'up', ArrowDown:'down'};
if (map[e.key]) { e.preventDefault(); move(map[e.key]); }
});
let ts = null;
boardEl.addEventListener('touchstart', (e) => { ts = {x:e.touches[0].clientX, y:e.touches[0].clientY}; }, {passive:true});
boardEl.addEventListener('touchend', (e) => {
if (!ts) return;
const t = e.changedTouches[0];
const dx = t.clientX - ts.x, dy = t.clientY - ts.y;
if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return;
move(Math.abs(dx) > Math.abs(dy) ? (dx>0?'right':'left') : (dy>0?'down':'up'));
ts = null;
}, {passive:true});
init();
</script>
</body>
</html>
把上面代码保存为 2048.html,双击用浏览器打开即可开始玩。
- 点赞
- 收藏
- 关注作者
评论(0)