从零开发一个网页版 1024 小游戏
从零开发一个网页版 1024 小游戏
一篇完整的开发手记:从需求分析、架构设计、核心算法到 UI 打磨,最后发布到华为云开发者作品展览馆的全流程。
一、项目背景
1024 是一款经典的数字合并益智游戏,玩法与 2048 一脉相承——只是目标更加亲民:合成 1024 即可获胜。本项目面向学生群体,希望提供一个课间即可打开、无需安装、跨平台运行的轻量级网页游戏。
技术选型:纯 HTML + CSS + JavaScript,零框架、零依赖。一个 python3 -m http.server 就能跑起来。
最终效果预览:
![1024 小游戏封面]

二、游戏规则
- 棋盘:4×4 网格,共 16 个格子
- 初始状态:随机生成 2 个方块(值为 2 或 4)
- 操作方式:键盘方向键(↑↓←→)或触摸滑动
- 合并规则:相同数字的方块碰撞时合并,值翻倍
- 生成规则:每次有效移动后,随机空位生成一个新方块(90% 概率为 2,10% 概率为 4)
- 胜利条件:合成 1024
- 失败条件:所有格子被填满且无法再合并
三、架构设计
项目采用经典的三层分离结构,没有使用任何前端框架:
game1024/
├── index.html # 页面结构层(55 行)
├── style.css # 视觉表现层(273 行)
├── game.js # 游戏逻辑层(285 行)
└── README.md # 项目说明
设计分层
┌─────────────────────────────────────────┐
│ 表现层 (UI Layer) │
│ index.html + style.css │
│ · 页面结构 · 渐变样式 · 动画效果 │
├─────────────────────────────────────────┤
│ 逻辑层 (Game Logic) │
│ game.js │
│ · 网格管理 · 合并算法 · 输入处理 │
├─────────────────────────────────────────┤
│ 数据层 (Data Layer) │
│ localStorage │
│ · 最高分持久化 · 游戏状态 │
└─────────────────────────────────────────┘
为什么不用框架?因为这是一个 4×4 网格的数字游戏,交互逻辑简单,DOM 操作量可控。引入 React/Vue 反而增加了包体积和构建复杂度。纯 JS 直接操作 DOM,代码量 285 行,清晰易读,对学生来说也是很好的学习材料。
四、核心实现
4.1 数据结构:方块对象
每个方块不是一个简单的数字,而是一个带有唯一 ID 的对象:
const t = {
id: nextId++, // 唯一标识,用于 DOM 追踪
value: 2, // 当前数值
row: 0, // 行坐标
col: 1, // 列坐标
isNew: true, // 是否新生成(触发淡入动画)
merged: false, // 是否刚合并(触发缩放动画)
pendingValue: null // 合并后的待更新值
};
为什么需要 ID? 因为合并动画需要两个方块先滑到同一位置,再"吃掉"其中一个。如果没有 ID,渲染时无法区分"哪个方块要消失"和"哪个方块要留下"。ID 让每个方块在整个生命周期中可追踪。
4.2 网格与棋盘
棋盘用一个 4×4 的二维数组表示,每个位置存放方块对象或 null:
function emptyGrid() {
return Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
}
// grid 示例:
// [ [tile, null, tile, null],
// [null, tile, null, null],
// [tile, null, null, tile],
// [null, null, tile, null] ]
4.3 随机生成方块
每次有效移动后,在随机空位生成一个新方块:
function addRandom() {
const empty = [];
for (let r = 0; r < SIZE; r++)
for (let c = 0; c < SIZE; c++)
if (!grid[r][c]) empty.push([r, c]);
if (!empty.length) return null;
const [r, c] = empty[Math.floor(Math.random() * empty.length)];
const value = Math.random() < 0.9 ? 2 : 4; // 90% 生成 2,10% 生成 4
const t = { id: nextId++, value, row: r, col: c, isNew: true };
grid[r][c] = t;
return t;
}
4.4 核心算法:移动与合并
这是整个游戏最关键的部分。思路是:将 4×4 网格按方向拆成 4 条线,每条线独立做"压缩+合并"。
第一步:按方向提取线条
function getLines(direction) {
const lines = [];
for (let i = 0; i < SIZE; i++) {
const line = [];
for (let j = 0; j < SIZE; j++) {
let r, c;
switch (direction) {
case 'left': r = i; c = j; break;
case 'right': r = i; c = SIZE - 1 - j; break;
case 'up': r = j; c = i; break;
case 'down': r = SIZE - 1 - j; c = i; break;
}
line.push([r, c]);
}
lines.push(line);
}
return lines;
}
无论哪个方向,都统一转换成"从左到右"的处理:向左移动直接取行,向右移动反向取行,向上/向下则按列处理。
第二步:压缩 + 合并
对每条线,先取出非空方块,然后从左到右扫描:如果相邻两个值相同,就合并——左边的方块值翻倍,右边的方块标记为"待移除":
for (let k = 0; k < tiles.length; k++) {
if (k + 1 < tiles.length && tiles[k].value === tiles[k + 1].value) {
// 合并:tiles[k] 留下并翻倍,tiles[k+1] 移向 tiles[k] 的位置后消失
stay.pendingValue = stay.value * 2;
scoreGain += stay.pendingValue;
go.targetId = stay.id; // 记录要移向哪个方块
removed.push(go); // 加入待移除列表
result.push(stay);
k++; // 跳过下一个(已被合并)
} else {
result.push(tiles[k]);
}
}
第三步:动画时序
合并不是瞬间完成的——需要先让两个方块滑到一起(CSS transition 0.16s),然后才更新数值并移除多余方块:
// 1. 先渲染滑动(被合并的方块滑向目标位置)
renderTiles = removed.concat(allGridTiles());
render();
// 2. 等待动画完成后,更新数值、移除方块、生成新方块
setTimeout(() => {
// 移除被合并的方块 DOM
for (const go of removed) {
const el = document.getElementById('tile-' + go.id);
if (el) el.remove();
}
// 更新合并后方块的数值
forEachTile((t) => {
if (t.pendingValue) {
t.value = t.pendingValue;
t.merged = true;
delete t.pendingValue;
}
});
// 生成新方块
addRandom();
render();
}, ANIM_MS); // ANIM_MS = 170ms
4.5 游戏结束判定
function canMove() {
for (let r = 0; r < SIZE; r++)
for (let c = 0; c < SIZE; c++) {
if (!grid[r][c]) return true; // 有空位 → 可移动
const v = grid[r][c].value;
if (c + 1 < SIZE && grid[r][c + 1]?.value === v) return true; // 右边相同
if (r + 1 < SIZE && grid[r + 1][c]?.value === v) return true; // 下面相同
}
return false; // 没空位且没有可合并的相邻方块 → 游戏结束
}
只检查右方和下方相邻方块即可——如果右和下都不相同,左和上自然也不会相同(对称性)。
4.6 输入处理
同时支持键盘和触摸:
// 键盘:方向键 + WASD
const keyMap = {
ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down',
a: 'left', d: 'right', w: 'up', s: 'down',
};
window.addEventListener('keydown', (e) => {
const dir = keyMap[e.key];
if (dir) { e.preventDefault(); move(dir); }
});
// 触摸:记录起点,在 touchend 时判断方向
gameEl.addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - touchStart.x;
const dy = e.changedTouches[0].clientY - touchStart.y;
if (Math.max(Math.abs(dx), 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 });
五、UI 设计
5.1 配色方案
采用温暖的渐变色系,从浅黄到粉橙的背景渐变,方块颜色从奶白到深红再到金色,形成清晰的视觉层次:
body {
background: linear-gradient(135deg, #f6d365 0%, #fda085 45%, #f8a5c2 100%);
}
每个数字方块有独立的颜色,数值越大颜色越深越鲜艳:
| 数值 | 背景色 | 说明 |
|---|---|---|
| 2 | #fff7ed |
奶白色 |
| 4 | #fde68a |
浅黄 |
| 8 | #fbbf24 |
琥珀 |
| 16 | #f59e0b |
橙黄 |
| 32 | #f97316 |
橙色 |
| 64 | #ea580c |
深橙 |
| 128 | #ef4444 |
红色 |
| 256 | #e11d48 |
玫红 |
| 512 | #be185d |
品红 |
| 1024 | linear-gradient(135deg, #f59e0b, #d4af37) |
金色渐变 + 发光 |
1024 方块特别使用了金色渐变 + 发光阴影,让达成目标时有强烈的视觉成就感。
5.2 毛玻璃效果
分数栏和游戏网格都使用了 backdrop-filter: blur() 实现毛玻璃效果,配合半透明背景,在渐变背景上显得通透精致:
.grid {
background: rgba(255, 247, 237, 0.6);
backdrop-filter: blur(8px);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(180, 83, 9, 0.18);
}
5.3 动画
两个核心动画:
新方块淡入——从 0.2 倍缩放到 1.1 倍再回到 1 倍,模拟"弹出"效果:
@keyframes appear {
0% { opacity: 0; transform: scale(0.2); }
60% { opacity: 1; transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); }
}
合并缩放——合并瞬间放大到 1.22 倍再回弹,给玩家正反馈:
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.22); }
100% { transform: scale(1); }
}
5.4 响应式
使用 clamp() 和 vw 单位实现自适应:
--gap: clamp(8px, 2.4vw, 14px); /* 间距随屏宽变化 */
.title { font-size: clamp(40px, 11vw, 56px); } /* 标题大小自适应 */
.tile { font-size: clamp(20px, 6vw, 34px); } /* 方块文字自适应 */
.grid { width: min(92vw, 460px); } /* 网格最大 460px */
桌面端和移动端无需写两套样式,一个 clamp 搞定。
六、数据持久化
最高分使用 localStorage 存储,简单可靠:
// 读取
bestScore = parseInt(localStorage.getItem('best1024') || '0', 10) || 0;
// 更新
if (score > bestScore) {
bestScore = score;
localStorage.setItem('best1024', String(bestScore));
}
七、部署与发布
7.1 本地运行
python3 -m http.server 8080
# 浏览器打开 http://localhost:8080
7.2 代码托管
代码已推送到 GitCode 仓库:https://gitcode.com/gcw_nfhkuMAb/game1024
7.3 发布到华为云开发者作品展览馆
通过华为云高校运营平台发布作品,流程包括:
- 获取 IAM 凭证:创建自委托生成 STS 临时凭证
- 打开 DevBridge 隧道:将本地 8080 端口暴露为公网可访问地址
- 推送代码到 GitCode:OAuth 授权 → 创建仓库 → push
- 生成封面截图:Playwright 截图 + 封面合成
- 生成作品详情:架构图 + 详解文章打包为 zip
- 选择投稿活动:选择「布道师通用产品体验」
- 发布:门禁复核 → 提交发布请求 → 获得 +200 积分
作品地址:https://gallery.developer.huaweicloud.com/gallery/005d7ea41f800000
八、开发心得
为什么选 1024 而不是 2048?
1024 的目标更低,新手更容易达成,作为学生课间休闲游戏更合适。2048 对很多人来说太难了——玩了很多次都到不了 1024,挫败感太强。1024 让人能在几分钟内体验到胜利的成就感。
方块 ID 的必要性
最初尝试用坐标作为方块标识,但合并动画期间两个方块会短暂出现在同一位置,坐标无法区分它们。引入唯一 ID 后,每个方块在整个生命周期(生成 → 移动 → 合并 → 消失)中都可追踪,DOM 渲染逻辑变得清晰。
动画时序的控制
合并动画的关键是时序:先滑动(0.16s CSS transition),等滑动结束后再更新数值和移除方块。用 setTimeout(ANIM_MS) 等待,ANIM_MS = 170 略大于 transition 的 160ms,留 10ms 余量确保动画完成。期间设置 animating = true 锁定输入,防止玩家快速连按导致状态混乱。
零依赖的优势
整个项目 613 行代码(HTML 55 + CSS 273 + JS 285),没有任何 node_modules,没有构建步骤,没有打包工具。git clone 后直接打开 index.html 就能玩。这种简洁性对教学场景特别有价值——学生可以完整理解每一行代码。
九、项目信息
| 项目 | 内容 |
|---|---|
| 作品名称 | 1024 小游戏 |
| 技术栈 | 纯 HTML / CSS / JavaScript |
| 代码行数 | 613 行(HTML 55 + CSS 273 + JS 285) |
| 外部依赖 | 无 |
| GitCode 仓库 | https://gitcode.com/gcw_nfhkuMAb/game1024 |
| 展览馆链接 | https://gallery.developer.huaweicloud.com/gallery/005d7ea41f800000 |
| 投稿活动 | 布道师通用产品体验 |
本文记录了 1024 小游戏从需求到发布的完整开发过程。如果你也是学生,想快速做一个能运行、能分享的小项目,纯前端 + 零依赖是最省心的选择。
- 点赞
- 收藏
- 关注作者
评论(0)