用华为云码道做一个 1024 网页版游戏:AI 生成主流程,手写补全核心逻辑

举报
yd_232401777 发表于 2026/10/22 11:02:12 2026/10/22
【摘要】 1024 是一个几乎零门槛的网页小游戏,却很适合用来试水 AI 编程工具的能力边界。本文记录我用华为云码道(CodeArts)代码智能体从零搭建 1024 游戏的完整过程:一句自然语言能生成到哪一步、哪些核心逻辑 AI 写不对必须自己接手、方向键与滑动的手势兼容怎么调试,以及一个单文件 HTML 版本该有的工程取舍。适合想低成本体验 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,双击用浏览器打开即可开始玩。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。