从零手写推箱子(Sokoban):单文件 HTML 游戏开发实战 + 华为云临时沙箱部署分享
适合人群:会基础 HTML/CSS/JS,想完整走一遍"写游戏 → 验证关卡 → 部署 → 分享"的初学者
最终成品:一个 21KB 的index.html,5 个关卡、支持撤销、双端操作
核心彩蛋:文中会用一个 30 行的 BFS 求解器,证明自己设计的关卡真的可解
写在前面:推箱子难在哪
推箱子规则一句话就能说完:把箱子推到目标点上,箱子只能推、不能拉。
但它的实现难点不在渲染,而在三件事:
- 状态要能被完整记录与回滚(箱子位置 + 玩家位置),否则撤销就是错的;
- 推箱判定比普通移动多一层:前方是箱子时,要看箱子后方是否可通行;
- 关卡设计必须有解:手写地图很容易做出"看着能过、实际死局"的关卡。
第 3 点很多教程会跳过。本文会用 BFS 求解器把 5 个关卡逐一验证,并分享一个我真实踩到的坑——地图里"空格"的语义,它曾让我的 4 个关卡全部无解。
一、准备工作
一个浏览器 + 一个编辑器,新建 index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>经典推箱子</title>
<style>/* 样式内联 */</style>
</head>
<body>
<div class="board" id="board"></div>
<script>/* 逻辑内联 */</script>
</body>
</html>
二、分步实现
Step 1|用字符画地图
推箱子的经典做法是直接拿"字符画"当地图,可读性极好,改关卡不需要打开任何工具:
const LEVELS = [
{
name: '第 1 关 · 第一次推动',
map: [
'#######',
'# #',
'# .$@ #',
'# #',
'#######'
]
}
// ...
];
图例:
| 字符 | 含义 | 字符 | 含义 |
|---|---|---|---|
# |
墙 | . |
目标点 |
$ |
箱子 | * |
箱子已在目标点 |
@ |
玩家 | + |
玩家站在目标点 |
| 空格 | 地板 |
空格是地板还是"地图外"?这个选择会在 Step 8 引发一次重大 bug,先记住这里用地板。
Step 2|把字符画解析成数据结构
渲染和判定都不该反复读字符,先解析成 4 个集合 + 2 个实体:
function parseLevel(def) {
const rows = def.map.length;
const cols = Math.max(...def.map.map(l => l.length));
const walls = new Set();
const floors = new Set();
const goals = new Set();
const boxes = [];
let player = { r: 0, c: 0 };
const key = (r, c) => r + ',' + c;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ch = def.map[r][c] || ' ';
const k = key(r, c);
if (ch === '#') { walls.add(k); continue; }
floors.add(k); // 空格按地板处理
if ('.*+'.includes(ch)) goals.add(k);
if ('$*'.includes(ch)) boxes.push({ r, c });
if ('@+'.includes(ch)) player = { r, c };
}
}
return { rows, cols, walls, floors, goals, boxes, player };
}
用 Set 存坐标字符串("r,c")有两个好处:查询是 O(1),而且判重、判墙都只写一行:
const isFloor = (r, c) => level.floors.has(r + ',' + c);
const isWall = (r, c) => !isFloor(r, c);
const boxAt = (r, c) => level.boxes.find(b => b.r === r && b.c === c) || null;
Step 3|渲染分三层
不要每次移动都重建整个棋盘(会丢动画)。分三层:
- 静态层:墙、地板、目标点 —— 只在加载关卡时创建一次;
- 箱子层:每个箱子一个元素;
- 玩家层:一个元素。
用百分比定位,天然响应式:
const cw = 100 / cols; // 每格宽度(%)
const ch = 100 / rows; // 每格高度(%)
// 静态格
cell.style.left = c * cw + '%';
cell.style.top = r * ch + '%';
cell.style.width = cw + '%';
cell.style.height = ch + '%';
箱子和玩家用 transform 定位,这样移动才有过渡动画:
el.style.width = cw + '%';
el.style.height = ch + '%';
el.style.transform = `translate(${box.c * 100}%, ${box.r * 100}%)`;
.actor {
position: absolute; top: 0; left: 0;
transition: transform 0.115s ease-out;
}
为什么
translate(c*100%)正好是一格?因为translate的百分比是相对元素自身尺寸算的,而元素正好占一格。写死像素反而要处理缩放。
Step 4|核心:移动与推动
这是整个游戏的心脏。注意"能走"和"能推"是两个不同判断:
function move(dir) {
if (solved) return;
const [dr, dc] = DIRS[dir];
const nr = level.player.r + dr; // 玩家想去的格子
const nc = level.player.c + dc;
if (isWall(nr, nc)) return; // ① 撞墙 → 什么都不做
const box = boxAt(nr, nc);
if (box) {
const br = nr + dr; // ② 箱子后面那格
const bc = nc + dc;
if (isWall(br, bc) || boxAt(br, bc)) return; // 推不动:后方是墙或另一个箱子
snapshot(); // 记录快照,供撤销
box.r = br; box.c = bc; // 推箱
pushes++;
} else {
snapshot();
}
level.player.r = nr;
level.player.c = nc;
steps++;
// ... 更新 DOM 与胜利判定
}
三个关键点:
- 先判墙再判箱:顺序反了会把"撞墙"当成"推箱"处理;
- 箱子的后方也要判:既要判墙,也要判"是不是另一个箱子"(否则箱子会叠在一起);
- 推不动时直接 return:不要消耗步数,玩家会觉得"我没动却被记了一步"。
Step 5|撤销:快照栈最省事
推箱子的状态很小(玩家 + 若干箱子),直接存完整快照,比"记录反向操作"简单得多,也不容易出 bug:
let history = [];
function snapshot() {
history.push({
player: { r: level.player.r, c: level.player.c },
boxes: level.boxes.map(b => ({ r: b.r, c: b.c })),
steps,
pushes
});
if (history.length > 300) history.shift(); // 防止无限增长
}
function undo() {
if (!history.length) return;
const s = history.pop();
level.player = s.player;
level.boxes.forEach((b, i) => { b.r = s.boxes[i].r; b.c = s.boxes[i].c; });
steps = s.steps;
pushes = s.pushes;
// 重新同步 DOM
}
注意
boxes.map(b => ({...}))里的展开是必须的:直接存对象引用,改箱子位置时快照会跟着变,撤销就失效了。
Step 6|胜利判定与"卡死"提示
胜利条件只有一句话:所有箱子都在目标点上。
const isWin = () => level.boxes.every(b => level.goals.has(b.r + ',' + b.c));
再补一个很实用的新手保护——角落死锁检测:箱子不在目标点、且被两面墙夹住时,它永远推不出来了。
function checkStuck() {
for (const b of level.boxes) {
if (level.goals.has(b.r + ',' + b.c)) continue;
const up = isWall(b.r - 1, b.c), down = isWall(b.r + 1, b.c);
const left = isWall(b.r, b.c - 1), right = isWall(b.r, b.c + 1);
if ((up || down) && (left || right)) {
// 提示:按 U 撤销 或 R 重玩
return;
}
}
}
这是保守规则(只判定最明显的死锁),不会误报,但能救回 80% 的新手挫败感。
Step 7|双端操作
键盘一套,手机一套,都收敛到同一个 move(dir):
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (k === 'arrowup' || k === 'w') return move('up');
if (k === 'arrowdown' || k === 's') return move('down');
if (k === 'arrowleft' || k === 'a') return move('left');
if (k === 'arrowright' || k === 'd') return move('right');
if (k === 'u') return undo();
if (k === 'r') return loadLevel(levelIndex);
if (k === 'n') return nextLevel();
});
手机端用滑动:
boardEl.addEventListener('touchend', (e) => {
const t = e.changedTouches[0];
const dx = t.clientX - tsX;
const dy = t.clientY - tsY;
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 });
再加一组屏幕方向键兜底,老人机/小手也能玩得舒服。
三、用 BFS 求解器证明关卡可解(强烈推荐)
3.1 为什么要写求解器
手写推箱子地图时,很容易做出"差一步推不动"的死局。人眼看不出来,玩家会骂你。写个求解器,几秒钟就能验证。
3.2 思路:把"推动"当搜索步
玩家可以自由走动,真正改变局面的只有推动。所以 BFS 的状态定义为:
state = (玩家位置, 所有箱子位置的有序集合)
每一步:
- 从玩家当前位置做一次泛洪,得到所有能走到的格子;
- 对每个可达格子、每个方向,检查"前方是箱子且箱子后方可通行";
- 执行推动,得到新状态;若所有箱子都在目标点,找到解。
核心代码(节选):
for (const posKey of reach) { // 玩家所有可达位置
const [pr, pc] = posKey.split(',').map(Number);
for (const [dr, dc] of DIRS) {
const bk = k(pr + dr, pc + dc);
if (!cur.boxes.has(bk)) continue; // 前方不是箱子
const tk = k(pr + dr * 2, pc + dc * 2);
if (!floors.has(tk)) continue; // 箱子会被推进墙
if (cur.boxes.has(tk)) continue; // 两个箱子相撞
const newBoxes = new Set(cur.boxes);
newBoxes.delete(bk);
newBoxes.add(tk);
// 入队新状态
}
}
3.3 实测结果
把 5 个关卡丢进求解器,输出:
L1: SOLVABLE minPushes=1 states=2 1ms
L2: SOLVABLE minPushes=1 states=4 1ms
L3: SOLVABLE minPushes=2 states=42 3ms
L4: SOLVABLE minPushes=2 states=23 1ms
L5: SOLVABLE minPushes=3 states=192 6ms
ALL_LEVELS_SOLVABLE
minPushes 就是最少推动次数,可以直接当难度指标:1 → 1 → 2 → 2 → 3,难度递增且平滑。
这套验证思路也能用到其他关卡制游戏:能用穷举证明的东西,就别靠肉眼。
3.4 我踩过的真实大坑:空格到底是地板还是虚空
第一版求解器的结果是这样的:
L1: SOLVABLE minPushes=1 states=2
L2: UNSOLVABLE (states=1)
L3: UNSOLVABLE (states=1)
L4: UNSOLVABLE (states=1)
L5: UNSOLVABLE (states=1)
除了 L1 全军覆没,而且 states=1 意味着"从起点一步都走不出去"。
原因:地图里我用空格表示地板,但解析代码写的是
if (ch === ' ') continue; // 我原本以为这是"地图外的虚空"
于是所有走廊都变成了不可通行的虚空,玩家被关在出生点。L1 之所以"可解",是因为它只需要原地向左推一次箱子,根本不用走路 —— 恰好掩盖了 bug。
结论:地图字符的语义必须一开始就定清楚,并在解析处写注释。这类"看起来能跑、其实逻辑错了"的 bug,靠点几下页面是发现不了的,必须靠自动化验证。
四、调试技巧与常见坑
| 现象 | 原因 | 解决 |
|---|---|---|
| 箱子能推进墙里 | 只判了玩家前方,没判箱子后方 | 推动前同时检查 br/bc |
| 两个箱子叠在一起 | 箱子后方没判"是否有箱子" | boxAt(br, bc) 一并判 |
| 撤销后箱子乱跳 | 快照里存了对象引用 | map(b => ({...b})) 深拷贝 |
| 撞墙也被记一步 | 步数在判定之前自增 | 判定通过后再 steps++ |
| 手机上滑动误触发 | 阈值太小 | 位移 < 24px 直接忽略 |
| 关卡"看起来能过"却过不去 | 地图设计有死局 | 用 BFS 求解器验证 |
| 玩家能走"地图外" | 空格语义没定清 | 明确空格=地板,并用外墙包围 |
| 缩放后格子错位 | 用了固定像素尺寸 | 全部改成百分比 + translate(%) |
再补一个效率习惯:给每个关卡加一行 console.log 打印最少推动次数,改地图后立刻重跑求解器,比反复手玩快得多。
五、用华为云临时沙箱把游戏发布到公网
代码写完了,怎么发给朋友玩?传统做法要买服务器、配安全组、绑域名、走备案。这里用华为云开发者空间的临时沙箱 + DevBridge 开发隧道,全程几分钟,不需要公网 IP,也不需要备案。
5.1 这套组合为什么适合"分享作品"
| 对比项 | 传统云服务器 | 临时沙箱 + DevBridge |
|---|---|---|
| 起环境 | 选规格、装 Nginx、开安全组 | 秒级拉起,镜像自带 Nginx |
| 拿公网地址 | 绑弹性 IP | 隧道主动出站建链,自动给 HTTPS 域名 |
| 入站端口 | 需要开放 | 不需要任何入站端口 |
| 成本与回收 | 按量计费,忘关机就烧钱 | 沙箱与隧道都有有效期,到点自动释放 |
| 备案 | 需要 | 使用平台域名,无需备案 |
一句话理解原理:沙箱内的进程主动向外建立加密长连接,网关把公网请求顺着这条连接转发进来。所以沙箱本身没有公网 IP,也能被外部访问。
5.2 四步发布(照着做)
第 1 步:连接沙箱
// huaweicloud_sandbox_connect
{ "source": "WEB" }
返回结果里有 devStageId,后续所有命令都在这个沙箱内执行。
⚠️ 两种常见"连不上",处理方式完全不同:
HDKIT_RESOURCE_BUSY(“1024系列活动火爆,当前资源紧张”)→ 只是容量排队,隔 10~15 秒重试即可HDKIT_SANDBOX_MAINTENANCE(“沙箱功能正在升级优化”)→ 平台正在维护,任何重试都无效,只能等维护窗口结束本文写作时恰好碰上第二种,所以部署章节按流程完整给出,等平台恢复后按同样步骤即可复现。
第 2 步:上传单文件
单文件项目在这里优势最明显——没有 node_modules、没有构建产物:
sandbox_upload_file(
local_path = ".../sokoban/index.html",
remote_path = "/workspace/sokoban/index.html"
)
→ bytes = 21508, md5Verified = true
再修一下静态读取权限,否则 Nginx 可能 403:
chmod -R o+rX /workspace/sokoban
第 3 步:Nginx 托管静态文件
写入 /etc/nginx/conf.d/sokoban.conf:
server {
listen 8086;
root /workspace/sokoban;
index index.html;
location / {
try_files $uri $uri.html $uri/ =404;
autoindex off;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1h;
add_header Cache-Control "public, immutable";
}
}
先测后 reload,别把线上打挂:
sudo nginx -t # syntax is ok / test is successful
sudo nginx -s reload 2>/dev/null || sudo nginx
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8086
# → 200
第 4 步:DevBridge 暴露公网
devbridge host -p 8086 -e 72
-p:要暴露的本地端口,可以写多个-e:隧道有效期(小时,最长 720)
终端会打印:
Created tunnel: <隧道ID>
Hosting port: 8086
Tunnel URL: https://<隧道ID>-8086.devbridge-s2.hwtunnel.com
Ready to accept connections
5.3 一个必须知道的坑:多个作品怎么同时分享
如果你已经用同一条隧道分享过别的作品(比如 8084 的俄罗斯方块),不要直接再执行一次 devbridge host -p 8086 —— 后启动的 host 会接管主机上的隧道,之前那些端口的链接会立刻变成 404。
两个正确做法:
做法 A:一次带多个端口(推荐)
devbridge host -p 8084 -p 8085 -p 8086 -e 72
输出会给出同一隧道 ID 下的多个地址,devbridge list 里也能看到 Port Count = 3。
做法 B:复用已有隧道 ID
devbridge host <已有隧道ID> -p 8084 -p 8085 -p 8086 -e 72
这样隧道 ID 不变,之前分享出去的链接依然有效。
5.4 验收:curl -sI 必须 200
沙箱内 curl localhost:8086 返回 200,只证明"沙箱自己能访问"。公网是否真通,要用隧道域名再验一次:
$ curl -sI https://<隧道ID>-8086.devbridge-s2.hwtunnel.com
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 21508
Server: CW
看到 HTTP/1.1 200 OK 才敢把链接发出去。
失败速查:
404+ 极短 text/plain → 网关说"隧道不存在/端口未注册",host 进程没了,重新启动 host502→ 隧道在但本地端口没服务,回去curl localhost:端口自查 Nginx000→ 本机到网关不通,检查网络后重试
5.5 分享与收尾
- 发链接:直接把 HTTPS 地址发出去,PC 与手机都能打开
- 发二维码:把链接生成二维码贴群里,扫码即玩,演示最好用
- 注意时效:隧道到期失效,重要演示前重跑一次(URL 会变,记得更新分享内容)
- 备份代码:沙箱是临时的,务必把
index.html推到 GitCode/GitHub 或存本地 - 用完清理:
devbridge delete-all删隧道;控制台关闭/销毁沙箱实例
六、还能怎么继续加料
按实现难度递增,可以继续挑战:
- 关卡编辑器:点格子切换
# . $ @,实时生成字符画并一键复制,做关卡效率能提升十倍 - 完全死锁判定:在角落规则之外,加入"箱子被推成一排靠墙且不在目标"等模式(可参考求解器思路做反向可达性分析)
- 移动步数动画:给玩家加一个"走路上下起伏"的小动效,手感立刻不一样
- 音效:用 Web Audio API 合成推动的"咚"声,不需要任何音频文件
- 进度存档:
localStorage记录已通关关卡与每关最好成绩(最少步数) - 关卡数量与主题:把地图抽成一个数组文件,加一个关卡选择页,做成真正的关卡包
一个建议:每加一个关卡,都先跑一遍求解器。这是本项目最值得保留的工程习惯。
七、小结
这个项目真正值得带走的是四点:
- 规则逻辑要收敛成几个纯函数:
parseLevel、move、canPush、isWin。渲染只消费状态,改皮肤不影响玩法。 - 快照栈是小型游戏最划算的撤销方案:状态小、实现简单、几乎不会出 bug(注意深拷贝)。
- 关卡必须被验证:肉眼看不出死局,一个 30 行的 BFS 求解器就能给你确定的答案。
- 发布链路可以很短:上传 → Nginx →
devbridge host→curl -sI验 200,四步把本地文件变成公网作品。
如果你第一次做这类项目,建议严格按本文顺序敲一遍,每完成一步就刷新页面看效果。遇到"现象和预期不符",先翻"调试技巧"表格——尤其是空格语义那一行,那是本文作者亲测踩过的坑。
代码已在本机完整可运行(双击 index.html 即可)。公网分享按第五章四步操作,沙箱维护窗口结束后即可复现。
- 点赞
- 收藏
- 关注作者
评论(0)