从零开发 2048 小游戏:开发过程、心得与关键代码解析

举报
yd_213866132 发表于 2026/08/18 10:56:07 2026/08/18
【摘要】 从零开发 2048 小游戏:开发过程、心得与关键代码解析 前言2048 是一款经典的数字合并益智游戏,规则简单却令人上瘾。本文将分享如何使用纯 HTML + CSS + JavaScript 单文件实现一个完整的 2048 游戏,涵盖开发过程、设计思路、关键代码解析以及开发心得。游戏已发布至华为云开发者作品展览馆:https://gallery.developer.huaweicloud....

从零开发 2048 小游戏:开发过程、心得与关键代码解析

前言

2048 是一款经典的数字合并益智游戏,规则简单却令人上瘾。本文将分享如何使用纯 HTML + CSS + JavaScript 单文件实现一个完整的 2048 游戏,涵盖开发过程、设计思路、关键代码解析以及开发心得。

游戏已发布至华为云开发者作品展览馆:https://gallery.developer.huaweicloud.cn/gallery/0-11e20eef400000

源码托管在 GitCode:https://gitcode.com/Eddygit/game2048.git

一、开发过程

1.1 需求分析

在动手之前,我先梳理了完整的功能清单:

  • 4×4 网格的经典 2048 玩法
  • 支持键盘方向键、WASD、触摸滑动、鼠标拖动多种操作
  • 方块滑动和合并的动画效果
  • 当前分数与历史最高分(localStorage 持久化)
  • 新游戏按钮和撤销按钮(最多 20 步)
  • 游戏结束/胜利提示
  • 响应式布局,适配移动端

1.2 架构设计

整个游戏采用 单文件 IIFE 架构,所有代码包裹在一个立即执行函数中,避免全局污染。核心设计分为三层:

┌─────────────────────────────────┐
│         输入层 (Input)          │
│  键盘 / 触摸 / 鼠标 → move(dir) │
├─────────────────────────────────┤
│       逻辑层 (Game Logic)       │
│  Grid + Tile + move + merge     │
├─────────────────────────────────┤
│       渲染层 (Render)           │
│  DOM 操作 + CSS 动画            │
└─────────────────────────────────┘
  • Tile:表示单个方块,记录位置、值、前一位置(用于动画)
  • Grid:4×4 二维数组,管理方块的增删查
  • move:核心移动逻辑,处理滑动、合并、得分
  • render:每次移动后重绘所有方块,利用 CSS transition 实现动画

1.3 开发步骤

  1. 搭建 HTML 骨架:游戏标题、分数栏、控制按钮、棋盘容器、覆盖层
  2. 编写 CSS 样式:CSS 变量驱动响应式布局、方块配色方案、动画 keyframes
  3. 实现游戏核心:Tile/Grid 数据结构 → 移动合并逻辑 → 随机生成方块
  4. 渲染与动画:DOM 渲染 + requestAnimationFrame 触发滑动 transition
  5. 输入处理:键盘事件 → 触摸手势 → 鼠标拖动
  6. 分数与撤销:localStorage 持久化 + 历史栈快照
  7. 游戏状态:胜负检测 + 覆盖层提示

二、关键代码解析

2.1 Tile 与 Grid 数据结构

游戏的核心数据结构非常简洁。Tile 记录每个方块的位置、数值和动画状态:

function Tile(x, y, value) {
  this.x = x;           // 当前 x 坐标
  this.y = y;           // 当前 y 坐标
  this.value = value || 2;
  this.prev = null;     // 前一位置 {x, y},用于滑动动画
  this.mergedFrom = null; // 合并来源 [tileA, tileB]
  this.id = Tile.nextId++;
}

Grid 是一个 4×4 的二维数组,提供方块的管理接口:

function Grid(size) {
  this.size = size;
  this.cells = [];
  for (var x = 0; x < size; x++) {
    this.cells[x] = [];
    for (var y = 0; y < this.size; y++)
      this.cells[x][y] = null;
  }
}

设计要点prevmergedFrom 是动画系统的关键。每次移动前,prepare() 会记录所有方块的当前位置到 prev;移动后,渲染层利用 prev 到当前位置的差值触发 CSS transition,实现平滑滑动效果。

2.2 核心移动逻辑

move() 是整个游戏最核心的函数,处理方块的滑动和合并:

function move(dir) {
  var v = vec(dir);           // 方向向量 {x, y}
  var tr = traversals(v);     // 遍历顺序(逆方向优先)
  var moved = false;
  var gained = 0;

  prepare();  // 记录所有方块的当前位置

  tr.x.forEach(function (x) {
    tr.y.forEach(function (y) {
      var tile = grid.content(x, y);
      if (!tile) return;

      var f = farthest(x, y, v);     // 找到最远空位
      var next = grid.content(f.next.x, f.next.y);  // 下一格的方块

      if (next && next.value === tile.value && !next.mergedFrom) {
        // 合并:相同值且目标格本回合未合并过
        var merged = new Tile(f.next.x, f.next.y, tile.value * 2);
        merged.mergedFrom = [tile, next];
        grid.insert(merged);
        grid.remove(tile);
        gained += merged.value;
        if (merged.value === 2048 && !won) won = true;
      } else {
        // 滑动:移到最远空位
        grid.remove(tile);
        tile.x = f.farthest.x;
        tile.y = f.farthest.y;
        grid.insert(tile);
      }
    });
  });

  if (moved) {
    history.push(snapshot());  // 保存撤销快照
    score += gained;
    addRandom();               // 生成新方块
    render();                  // 重绘
  }
}

关键设计

  1. 遍历顺序traversals(v) 根据移动方向反转遍历顺序。向右移动时从右往左遍历,避免一个方块被多次合并。
  2. !next.mergedFrom 检查:防止一个方块在一回合内被连续合并两次(如 [2, 2, 2, 2] 向左移动应得到 [4, 4, 0, 0] 而非 [8, 0, 0, 0])。
  3. farthest() 函数:沿方向向量逐步前进,直到遇到边界或非空格,返回最远可达位置。

2.3 动画渲染

渲染层是整个游戏视觉体验的关键。每次 render() 都会清空并重建所有方块 DOM,但通过巧妙利用 prev 位置和 requestAnimationFrame 实现平滑动画:

function render() {
  tilesEl.innerHTML = "";

  for (var x = 0; x < SIZE; x++) {
    for (var y = 0; y < SIZE; y++) {
      var tile = grid.cells[x][y];
      if (!tile) continue;

      if (tile.mergedFrom) {
        // 合并方块:先渲染两个源方块在旧位置,再滑动到合并位置
        var a = tile.mergedFrom[0], b = tile.mergedFrom[1];
        var ea = makeTileEl(a, false, false);
        ea.style.transform = posTransform(a.prev.x, a.prev.y);
        tilesEl.appendChild(ea);
        // 下一帧触发滑动到合并位置
        requestAnimationFrame(function (elA, ax, ay) {
          return function () {
            elA.style.transform = posTransform(ax, ay);
          };
        }(ea, tile.x, tile.y));
        // 合并后的新方块带弹出动画
        var em = makeTileEl(tile, false, true);
        tilesEl.appendChild(em);
      } else if (tile.prev && !samePos(tile.prev, tile)) {
        // 滑动方块:从旧位置开始,下一帧滑动到新位置
        var el = makeTileEl(tile, false, false);
        el.style.transform = posTransform(tile.prev.x, tile.prev.y);
        tilesEl.appendChild(el);
        requestAnimationFrame(function (elS, tx, ty) {
          return function () {
            elS.style.transform = posTransform(tx, ty);
          };
        })(el, tile.x, tile.y);
      } else {
        // 新生成的方块:带淡入动画
        var en = makeTileEl(tile, !tile.prev, false);
        tilesEl.appendChild(en);
      }
    }
  }
}

动画原理

  • 方块先在 prev 位置创建(DOM 初始 transform 设为旧位置)
  • requestAnimationFrame 在下一帧将 transform 改为新位置
  • CSS transition: transform 0.15s 自动补间,产生滑动效果
  • 合并方块额外添加 .merged class,触发 pop 动画

2.4 撤销系统

撤销系统通过快照实现,每次移动前保存完整状态:

function snapshot() {
  var snap = { score: score, cells: [] };
  for (var x = 0; x < SIZE; x++) {
    snap.cells[x] = [];
    for (var y = 0; y < SIZE; y++) {
      var t = grid.cells[x][y];
      snap.cells[x][y] = t ? { value: t.value } : null;
    }
  }
  return snap;
}

function undo() {
  if (!history.length) return;
  var snap = history.pop();
  score = snap.score;
  grid = new Grid(SIZE);
  for (var x = 0; x < SIZE; x++)
    for (var y = 0; y < SIZE; y++) {
      var c = snap.cells[x][y];
      if (c) grid.insert(new Tile(x, y, c.value));
    }
  render();
}

历史栈限制为 20 步,避免内存占用过大。

2.5 多端输入处理

游戏支持三种输入方式,统一映射到 move(dir) (0=上, 1=右, 2=下, 3=左):

// 键盘
var keyMap = {
  ArrowUp: 0, ArrowRight: 1, ArrowDown: 2, ArrowLeft: 3,
  w: 0, d: 1, s: 2, a: 3
};

// 触摸滑动
boardWrap.addEventListener("touchend", function (e) {
  var dx = t.clientX - touchStart.x;
  var dy = t.clientY - touchStart.y;
  if (Math.abs(dx) > Math.abs(dy))
    move(dx > 0 ? 1 : 3);  // 水平滑动
  else
    move(dy > 0 ? 2 : 0);  // 垂直滑动
});

三、开发心得

3.1 单文件架构的优势

选择单文件实现并非偷懒,而是针对这个项目规模的合理决策:

  • 零依赖:不需要 npm、webpack、任何构建工具
  • 即开即玩:一个 HTML 文件,浏览器打开就能运行
  • 部署简单:放到任意静态服务器即可,华为云 OBS 静态网站托管也能直接用
  • 代码量可控:624 行代码涵盖全部功能,阅读和理解成本低

3.2 CSS 变量驱动响应式

整个棋盘的尺寸由一个 CSS 变量驱动:

:root {
  --board-size: min(92vw, 460px);
  --gap: calc(var(--board-size) * 0.025);
  --cell-size: calc((var(--board-size) - var(--gap) * 5) / 4);
}

所有尺寸(方块大小、间距、圆角、字体)都从 --board-size 派生,修改一处即可全局缩放。这种设计让响应式适配变得极其简单。

3.3 动画的关键:prev 位置 + requestAnimationFrame

实现滑动动画最直觉的想法是用 JS 动画库或手写动画函数。但实际上,利用 CSS transition + DOM 重建就能实现:

  1. 在旧位置创建 DOM 元素
  2. requestAnimationFrame 下一帧改到新位置
  3. CSS transition 自动补间

这种方案的优点是完全利用浏览器原生动画能力,性能好、代码少。关键是 Tile.prev 记录了移动前的位置,渲染时据此设置初始 transform。

3.4 遍历顺序的重要性

移动逻辑中一个容易出错的细节是遍历顺序。向右移动时,如果从左往右遍历,一个方块可能被合并后又被下一个方块合并,导致 [2, 2, 2, 2] 变成 [0, 0, 0, 8] 而非正确的 [0, 0, 4, 4]

解决方案是逆方向遍历:向右移动时从右往左处理,这样每个方块只会被处理一次。traversals() 函数根据方向向量反转遍历顺序。

3.5 mergedFrom 双重作用

mergedFrom 字段有两个用途:

  1. 逻辑层:标记一个方块本回合已合并过,防止二次合并
  2. 渲染层:告诉渲染器这个方块是由两个方块合并而来,需要先渲染源方块的滑动动画

一个字段同时服务于逻辑和渲染,减少了状态管理的复杂度。

四、项目成果

特性 实现情况
代码行数 624 行(单文件)
文件大小 18.9 KB
外部依赖 0
操作方式 键盘 + 触摸 + 鼠标
动画效果 滑动 + 合并弹出 + 淡入 + 分数 bump
持久化 localStorage(最高分)
撤销 最多 20 步
响应式 ✅ 移动端适配

五、总结

2048 小游戏的开发过程展示了在不引入任何框架和依赖的情况下,如何用纯前端技术实现一个完整、流畅、多端适配的交互式游戏。核心要点在于:

  1. 数据结构先行:Tile/Grid 的设计决定了整个代码的清晰度
  2. 动画利用原生能力:CSS transition + requestAnimationFrame 胜过手写动画
  3. 状态管理要简洁:prev/mergedFrom 两个字段解决动画和合并逻辑
  4. 单文件不是反模式:在合适的项目规模下,单文件是最简洁的架构

游戏源码:https://gitcode.com/Eddygit/game2048

作品展览馆:https://gallery.developer.huaweicloud.cn/gallery/0-11e20eef400000

感谢阅读,欢迎在 GitCode 上 Star 和 Fork!

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

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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