从零手写推箱子(Sokoban):单文件 HTML 游戏开发实战 + 华为云临时沙箱部署分享

举报
yd_229606956 发表于 2026/10/27 16:40:05 2026/10/27
【摘要】 适合人群:会基础 HTML/CSS/JS,想完整走一遍"写游戏 → 验证关卡 → 部署 → 分享"的初学者最终成品:一个 21KB 的 index.html,5 个关卡、支持撤销、双端操作核心彩蛋:文中会用一个 30 行的 BFS 求解器,证明自己设计的关卡真的可解 写在前面:推箱子难在哪推箱子规则一句话就能说完:把箱子推到目标点上,箱子只能推、不能拉。但它的实现难点不在渲染,而在三件事:状...

适合人群:会基础 HTML/CSS/JS,想完整走一遍"写游戏 → 验证关卡 → 部署 → 分享"的初学者
最终成品:一个 21KB 的 index.html,5 个关卡、支持撤销、双端操作
核心彩蛋:文中会用一个 30 行的 BFS 求解器,证明自己设计的关卡真的可解


写在前面:推箱子难在哪

推箱子规则一句话就能说完:把箱子推到目标点上,箱子只能推、不能拉。

但它的实现难点不在渲染,而在三件事:

  1. 状态要能被完整记录与回滚(箱子位置 + 玩家位置),否则撤销就是错的;
  2. 推箱判定比普通移动多一层:前方是箱子时,要看箱子后方是否可通行;
  3. 关卡设计必须有解:手写地图很容易做出"看着能过、实际死局"的关卡。

第 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|渲染分三层

不要每次移动都重建整个棋盘(会丢动画)。分三层:

  1. 静态层:墙、地板、目标点 —— 只在加载关卡时创建一次;
  2. 箱子层:每个箱子一个元素;
  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 = (玩家位置, 所有箱子位置的有序集合)

每一步:

  1. 从玩家当前位置做一次泛洪,得到所有能走到的格子;
  2. 对每个可达格子、每个方向,检查"前方是箱子且箱子后方可通行";
  3. 执行推动,得到新状态;若所有箱子都在目标点,找到解。

核心代码(节选):

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 进程没了,重新启动 host
  • 502 → 隧道在但本地端口没服务,回去 curl localhost:端口 自查 Nginx
  • 000 → 本机到网关不通,检查网络后重试

5.5 分享与收尾

  • 发链接:直接把 HTTPS 地址发出去,PC 与手机都能打开
  • 发二维码:把链接生成二维码贴群里,扫码即玩,演示最好用
  • 注意时效:隧道到期失效,重要演示前重跑一次(URL 会变,记得更新分享内容)
  • 备份代码:沙箱是临时的,务必把 index.html 推到 GitCode/GitHub 或存本地
  • 用完清理:devbridge delete-all 删隧道;控制台关闭/销毁沙箱实例

六、还能怎么继续加料

按实现难度递增,可以继续挑战:

  1. 关卡编辑器:点格子切换 # . $ @,实时生成字符画并一键复制,做关卡效率能提升十倍
  2. 完全死锁判定:在角落规则之外,加入"箱子被推成一排靠墙且不在目标"等模式(可参考求解器思路做反向可达性分析)
  3. 移动步数动画:给玩家加一个"走路上下起伏"的小动效,手感立刻不一样
  4. 音效:用 Web Audio API 合成推动的"咚"声,不需要任何音频文件
  5. 进度存档:localStorage 记录已通关关卡与每关最好成绩(最少步数)
  6. 关卡数量与主题:把地图抽成一个数组文件,加一个关卡选择页,做成真正的关卡包

一个建议:每加一个关卡,都先跑一遍求解器。这是本项目最值得保留的工程习惯。


七、小结

这个项目真正值得带走的是四点:

  1. 规则逻辑要收敛成几个纯函数:parseLevel、move、canPush、isWin。渲染只消费状态,改皮肤不影响玩法。
  2. 快照栈是小型游戏最划算的撤销方案:状态小、实现简单、几乎不会出 bug(注意深拷贝)。
  3. 关卡必须被验证:肉眼看不出死局,一个 30 行的 BFS 求解器就能给你确定的答案。
  4. 发布链路可以很短:上传 → Nginx → devbridge host → curl -sI 验 200,四步把本地文件变成公网作品。

如果你第一次做这类项目,建议严格按本文顺序敲一遍,每完成一步就刷新页面看效果。遇到"现象和预期不符",先翻"调试技巧"表格——尤其是空格语义那一行,那是本文作者亲测踩过的坑。

代码已在本机完整可运行(双击 index.html 即可)。公网分享按第五章四步操作,沙箱维护窗口结束后即可复现。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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