从零实现 2048 小游戏:核心算法、完整代码与华为云部署思路
从零实现 2048 小游戏:核心算法、完整代码与华为云部署思路
2048 是一个规则简单、状态机清晰的小游戏:4×4 棋盘,每次滑动让所有方块向一个方向移动并合并相同数字,目标是通过合并得到 2048。它非常适合作为前端练手项目,也很适合用来验证一条从本地开发到云端上线的完整链路。
本文不依赖任何框架,用原生 HTML/CSS/JavaScript 实现一个可玩的 2048,并把移动、合并、计分和游戏结束判断讲清楚,最后给出在华为云上部署的几种可行路径。
一、先确定状态模型
整个游戏只有四个核心状态:
board:4×4 的二维数组,0 表示空格;score:当前得分;best:历史最高分,写入 localStorage;over:是否已经无路可走。
每次有效滑动都会经历「压缩 → 合并 → 再压缩」三个阶段。以向左滑动为例:
- 去掉每一行的 0,把数字向左压缩;
- 从左到右扫描,若相邻两个数字相同就合并,合并后的数字翻倍并计分;
- 合并后再次压缩,避免出现
[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 上传为对象,开启静态网站托管并绑定自定义域名即可。适合访问量不大、没有后端逻辑的场景。
五、几个容易踩的坑
- 合并顺序:
[2,2,2,2]向左应得到[4,4,0,0],而不是[8,0,0,0]。合并时索引要跳过已合并的元素。 - 一次滑动只合并一次:新生成的数字不能在同一回合继续参与合并。
- 无效滑动不生成新数字:必须先比较棋盘是否变化,再决定是否
addRandom。 - 触摸事件方向判断:用 24px 左右的阈值过滤误触,否则轻微滑动也会触发移动。
- 最高分持久化:
localStorage只存字符串,读取时记得Number()转换。
六、小结
2048 的价值不在于玩法本身,而在于它把「状态建模 → 核心算法 → 交互处理 → 部署上线」这条链路压缩进了一个足够小的项目里。把单文件版本跑通之后,可以继续尝试加入动画、撤销、排行榜或后端存档,也可以把它作为云端部署练习的载体。
如果使用华为云码道(CodeArts)这类 AI 编码工具,可以让它基于本文的状态模型直接生成初版代码,再通过人工审查合并逻辑和边界条件,效率会更高。
- 点赞
- 收藏
- 关注作者
评论(0)