从零实现 2048 小游戏:核心算法、完整代码与华为云部署思路

举报
yd_265060061 发表于 2026/10/22 01:24:44 2026/10/22
【摘要】 用原生 HTML/CSS/JavaScript 实现一个可玩的 2048:讲解压缩-合并-再压缩的核心算法与四方向旋转复用,给出完整单文件代码,并梳理华为云沙箱、ECS 与对象存储三种部署路径及计费注意事项。

从零实现 2048 小游戏:核心算法、完整代码与华为云部署思路

2048 是一个规则简单、状态机清晰的小游戏:4×4 棋盘,每次滑动让所有方块向一个方向移动并合并相同数字,目标是通过合并得到 2048。它非常适合作为前端练手项目,也很适合用来验证一条从本地开发到云端上线的完整链路。

本文不依赖任何框架,用原生 HTML/CSS/JavaScript 实现一个可玩的 2048,并把移动、合并、计分和游戏结束判断讲清楚,最后给出在华为云上部署的几种可行路径。

一、先确定状态模型

整个游戏只有四个核心状态:

  • board:4×4 的二维数组,0 表示空格;
  • score:当前得分;
  • best:历史最高分,写入 localStorage;
  • over:是否已经无路可走。

每次有效滑动都会经历「压缩 → 合并 → 再压缩」三个阶段。以向左滑动为例:

  1. 去掉每一行的 0,把数字向左压缩;
  2. 从左到右扫描,若相邻两个数字相同就合并,合并后的数字翻倍并计分;
  3. 合并后再次压缩,避免出现 [2, 0, 2, 0] 这类中间空洞。

向右、向上、向下只是方向不同。工程上最省事的做法是:统一先旋转棋盘,向左处理,再旋转回去。这样只需要维护一份合并逻辑。

二、核心算法

function slideLeft(row) {
  // 1. 压缩
  let nums = row.filter((v) => v !== 0);
  let gained = 0;
  // 2. 合并
  for (let i = 0; i < nums.length - 1; i++) {
    if (nums[i] === nums[i + 1]) {
      nums[i] *= 2;
      gained += nums[i];
      nums.splice(i + 1, 1);
    }
  }
  // 3. 补零
  while (nums.length < 4) nums.push(0);
  return { row: nums, gained };
}

旋转棋盘的实现很轻量:

function rotate(board) {
  const n = board.length;
  const out = Array.from({ length: n }, () => Array(n).fill(0));
  for (let r = 0; r < n; r++) {
    for (let c = 0; c < n; c++) {
      out[c][n - 1 - r] = board[r][c];
    }
  }
  return out;
}

有了旋转,四个方向都可以复用 slideLeft:

  • 向左:直接处理;
  • 向上:顺时针旋转 270° 后向左,再转回来;
  • 向右:旋转 180° 后向左,再转回来;
  • 向下:顺时针旋转 90° 后向左,再转回来。

判断一次滑动是否有效,只需要比较处理前后的棋盘是否发生变化;如果发生变化,就随机在一个空格生成 2 或 4(通常 2 的概率为 90%)。

游戏结束的条件有两个:棋盘已满,且四个方向都无法产生有效移动。

三、完整可运行代码

把下面的内容保存为 index.html,双击即可在浏览器中运行:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>2048</title>
  <style>
    * { box-sizing: border-box; }
    body {
      margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
      background: #faf8ef; color: #776e65;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", sans-serif;
      user-select: none;
    }
    .wrap { width: min(92vw, 480px); }
    .head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
    h1 { font-size: 44px; margin: 0; }
    .scores { display: flex; gap: 8px; }
    .score {
      min-width: 92px; padding: 8px 14px; border-radius: 8px; background: #bbada0;
      color: #eee4da; text-align: center; font-size: 12px;
    }
    .score strong { display: block; font-size: 22px; color: #fff; }
    .bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
    .hint { font-size: 13px; color: #8f857d; }
    button {
      border: 0; border-radius: 8px; background: #8f7a66; color: #fff;
      padding: 10px 18px; font-size: 14px; cursor: pointer;
    }
    button:hover { background: #7f6a56; }
    .board {
      position: relative; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
      padding: 10px; border-radius: 10px; background: #bbada0; aspect-ratio: 1 / 1;
    }
    .cell {
      display: flex; align-items: center; justify-content: center; border-radius: 6px;
      background: rgba(238, 228, 218, 0.35); font-weight: 700; font-size: clamp(22px, 7vw, 42px);
    }
    .tile-2    { background: #eee4da; color: #776e65; }
    .tile-4    { background: #ede0c8; color: #776e65; }
    .tile-8    { background: #f2b179; color: #fff; }
    .tile-16   { background: #f59563; color: #fff; }
    .tile-32   { background: #f67c5f; color: #fff; }
    .tile-64   { background: #f65e3b; color: #fff; }
    .tile-128  { background: #edcf72; color: #fff; }
    .tile-256  { background: #edcc61; color: #fff; }
    .tile-512  { background: #edc850; color: #fff; }
    .tile-1024 { background: #edc53f; color: #fff; font-size: clamp(18px, 5.4vw, 32px); }
    .tile-2048 { background: #edc22e; color: #fff; font-size: clamp(18px, 5.4vw, 32px); }
    .overlay {
      position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
      flex-direction: column; gap: 16px; border-radius: 10px;
      background: rgba(238, 228, 218, 0.73); font-size: 30px; font-weight: 700;
    }
    .overlay.show { display: flex; }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="head">
      <h1>2048</h1>
      <div class="scores">
        <div class="score">得分<strong id="score">0</strong></div>
        <div class="score">最高<strong id="best">0</strong></div>
      </div>
    </div>
    <div class="bar">
      <span class="hint">方向键 / WASD 移动,触屏可滑动</span>
      <button id="restart">重新开始</button>
    </div>
    <div class="board" id="board">
      <div class="overlay" id="overlay">
        <span id="overlayText">游戏结束</span>
        <button id="again">再来一局</button>
      </div>
    </div>
  </div>

  <script>
    const SIZE = 4;
    const boardEl = document.getElementById('board');
    const scoreEl = document.getElementById('score');
    const bestEl = document.getElementById('best');
    const overlay = document.getElementById('overlay');
    const overlayText = document.getElementById('overlayText');

    let board = [];
    let score = 0;
    let best = Number(localStorage.getItem('best2048') || 0);
    let over = false;

    function emptyBoard() {
      return Array.from({ length: SIZE }, () => Array(SIZE).fill(0));
    }

    function addRandom() {
      const empty = [];
      for (let r = 0; r < SIZE; r++) {
        for (let c = 0; c < SIZE; c++) {
          if (board[r][c] === 0) 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 slideLeft(row) {
      let nums = row.filter((v) => v !== 0);
      let gained = 0;
      for (let i = 0; i < nums.length - 1; i++) {
        if (nums[i] === nums[i + 1]) {
          nums[i] *= 2;
          gained += nums[i];
          nums.splice(i + 1, 1);
        }
      }
      while (nums.length < SIZE) nums.push(0);
      return { row: nums, gained };
    }

    function rotate(g) {
      const out = emptyBoard();
      for (let r = 0; r < SIZE; r++) {
        for (let c = 0; c < SIZE; c++) {
          out[c][SIZE - 1 - r] = g[r][c];
        }
      }
      return out;
    }

    function move(dir) {
      if (over) return;
      const times = { left: 0, up: 3, right: 2, down: 1 }[dir];
      let g = board.map((row) => row.slice());
      for (let i = 0; i < times; i++) g = rotate(g);

      let gained = 0;
      for (let r = 0; r < SIZE; r++) {
        const res = slideLeft(g[r]);
        g[r] = res.row;
        gained += res.gained;
      }
      for (let i = 0; i < (4 - times) % 4; i++) g = rotate(g);

      const changed = g.some((row, r) => row.some((v, c) => v !== board[r][c]));
      if (!changed) return;

      board = g;
      score += gained;
      if (score > best) {
        best = score;
        localStorage.setItem('best2048', String(best));
      }
      addRandom();
      render();
      if (!canMove()) {
        over = true;
        overlayText.textContent = '游戏结束';
        overlay.classList.add('show');
      }
    }

    function canMove() {
      for (let r = 0; r < SIZE; r++) {
        for (let c = 0; c < SIZE; c++) {
          if (board[r][c] === 0) return true;
          if (c < SIZE - 1 && board[r][c] === board[r][c + 1]) return true;
          if (r < SIZE - 1 && board[r][c] === board[r + 1][c]) return true;
        }
      }
      return false;
    }

    function render() {
      boardEl.querySelectorAll('.cell').forEach((el) => el.remove());
      for (let r = 0; r < SIZE; r++) {
        for (let c = 0; c < SIZE; c++) {
          const v = board[r][c];
          const el = document.createElement('div');
          el.className = 'cell' + (v ? ' tile-' + v : '');
          el.textContent = v || '';
          boardEl.appendChild(el);
        }
      }
      scoreEl.textContent = score;
      bestEl.textContent = best;
    }

    function reset() {
      board = emptyBoard();
      score = 0;
      over = false;
      overlay.classList.remove('show');
      addRandom();
      addRandom();
      render();
    }

    document.addEventListener('keydown', (e) => {
      const map = {
        ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down',
        a: 'left', d: 'right', w: 'up', s: 'down',
      };
      const dir = map[e.key];
      if (dir) {
        e.preventDefault();
        move(dir);
      }
    });

    let sx = 0, sy = 0;
    document.addEventListener('touchstart', (e) => {
      sx = e.touches[0].clientX;
      sy = e.touches[0].clientY;
    }, { passive: true });
    document.addEventListener('touchend', (e) => {
      const dx = e.changedTouches[0].clientX - sx;
      const dy = e.changedTouches[0].clientY - sy;
      if (Math.abs(dx) < 24 && Math.abs(dy) < 24) return;
      if (Math.abs(dx) > Math.abs(dy)) move(dx > 0 ? 'right' : 'left');
      else move(dy > 0 ? 'down' : 'up');
    }, { passive: true });

    document.getElementById('restart').addEventListener('click', reset);
    document.getElementById('again').addEventListener('click', reset);

    reset();
  </script>
</body>
</html>

这份实现刻意保持单文件:没有构建步骤、没有外部依赖,方便后续放到任何静态托管环境里。

四、在华为云上部署的几条路径

1. 开发者空间沙箱(最快验证)

华为云开发者空间提供开箱即用的 AI 开发环境,适合快速预览。把单文件项目放进工作区,启动内置的静态服务器,通过平台提供的沙箱访问地址即可预览。沙箱更适合验证和演示,数据通常不会长期保留。

2. 弹性云服务器 ECS(长期在线)

如果希望游戏长期在线,可以在 ECS 上使用 Nginx 托管静态文件:

# 安装 Nginx(以 HCE / openEuler 为例)
sudo dnf install -y nginx
sudo systemctl enable --now nginx

# 上传页面
sudo mkdir -p /usr/share/nginx/html/2048
sudo cp index.html /usr/share/nginx/html/2048/

# 配置站点
sudo tee /etc/nginx/conf.d/2048.conf > /dev/null <<'EOF'
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html/2048;
    index index.html;
}
EOF

sudo nginx -t && sudo systemctl reload nginx

安全组放行 80 端口后即可通过公网 IP 访问。需要注意的是,ECS 会产生实例与带宽费用,验证完成后应及时释放资源,避免持续计费。

3. 对象存储静态托管(成本最低)

如果只是托管静态页面,对象存储的静态网站托管通常比 ECS 更划算:把 index.html 上传为对象,开启静态网站托管并绑定自定义域名即可。适合访问量不大、没有后端逻辑的场景。

五、几个容易踩的坑

  1. 合并顺序:[2,2,2,2] 向左应得到 [4,4,0,0],而不是 [8,0,0,0]。合并时索引要跳过已合并的元素。
  2. 一次滑动只合并一次:新生成的数字不能在同一回合继续参与合并。
  3. 无效滑动不生成新数字:必须先比较棋盘是否变化,再决定是否 addRandom。
  4. 触摸事件方向判断:用 24px 左右的阈值过滤误触,否则轻微滑动也会触发移动。
  5. 最高分持久化:localStorage 只存字符串,读取时记得 Number() 转换。

六、小结

2048 的价值不在于玩法本身,而在于它把「状态建模 → 核心算法 → 交互处理 → 部署上线」这条链路压缩进了一个足够小的项目里。把单文件版本跑通之后,可以继续尝试加入动画、撤销、排行榜或后端存档,也可以把它作为云端部署练习的载体。

如果使用华为云码道(CodeArts)这类 AI 编码工具,可以让它基于本文的状态模型直接生成初版代码,再通过人工审查合并逻辑和边界条件,效率会更高。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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