Alpaca Push 这个项目名听着像新玩具,其实它说的是那件事:用 HTML5 把 2004 年 ICQ 里那个 Slide-a-Lama 小游戏重新做出来。ICQ 到今天已经是个怀旧符号,当年上面内置或第三方传开的一堆 Flash 小游戏,现在绝大多数浏览器都跑不了。想再玩,只能换条路,把玩法搬到 HTML5 Canvas 和 JavaScript 里重写。这篇东西会按一次完整重制流程来拆:需求边界、技术选型、核心代码结构、操作手感、浏览器兼容、部署上线,最后还会留一份排查清单。如果你手头也有类似“老游戏想在浏览器里复活”的项目,可以参考的不仅是实现代码,更是怎么做决定、怎么控制范围、怎么少走弯路。
很多人拿到这种项目后的第一反应是“先去下载原版 SWF,分析资源,然后自动转成 HTML5”。我建议先停一下。先搞清楚一件事:你要复刻的是那个游戏本身,还是你记忆里那个“在 ICQ 聊天窗口旁边偷偷玩一把”的体验?这两个目标差别很大,会影响后面所有技术决策。
1. 动手前先理清:你要复刻的是“体验”还是“代码”
1.1 年代越久,原始素材越难拿
2004 年前后的浏览器游戏,最常见的载体是 Flash。当时很多小游戏是网站附带、好友互传或者聊天软件里嵌入的,未必有人专门保存过原始 SWF 文件。就算你找到了 SWF,也不一定能顺利读到内部对象,Flash Player 和对应调试工具早就停止维护,逆向一套几十年前的 ActionScript 2 项目,成本非常高。
所以,把这个项目理解成“重制”比“移植”更准确。移植是拿原工程转换,重制是基于已知玩法重新做一套。Alpaca Push 这个标题本身也带着点再创作的意味:它把羊驼主题放进一个滑动或推动玩法的容器里,目标不是和旧版像素级一致,而是让当年喜欢这个游戏的人再次打开网页时,能立刻明白该怎么玩、能顺畅玩下去,还能找到一点熟悉的乐趣。
这里有一条很现实的经验:先确认手头有没有原始素材、有没有授权,再决定作品是“学习还原”还是“上线发布”。如果拿不到原版美术和音频资源,就尽量自己画占位图、自己找可商用素材,或者干脆用几何色块构建最少视觉。公开博客和仓库里不能出现来路不明的原版资源。
1.2 把玩法拆成一张能验收的清单
原始资料越少,越要先把玩法定义写出来,否则代码写到一半会越改越乱。就算只看到“Slide-a-Lama”这个名字,也能推断出一些基础问题:是不是羊驼在棋盘里滑动?是否有可移动方块、障碍物、滑动的终点?是按方向键后滑到底,还是只滑一格?关卡失败怎么判定?
在没有确凿素材时,我会建立一个更稳的玩法模型,所有机制都用文字先写清楚。比如一个典型模型是这样的:
- 游戏区域是一块矩形网格,每个格子可能是空、墙体、可推动块、目的地或羊驼角色。
- 玩家通过键盘方向键、按钮或触摸滑动发出移动指令。
- 羊驼在一个方向移动时,如果前方是空位,就移动过去;如果是可推动且推动链末端有空格,就连同前面的块一起滑动;如果前方是墙或不可推动块,则忽略这次移动。
- 当所有目标格被可推动块覆盖,本关通关。
这个描述看起来很像 Sokoban,但它也能覆盖很多“推到位”“连推”“整行滑动”的变化。对 Slide-a-Lama 这一类玩法,滑动距离可能是单格还是长距离整列滑动,需要你根据实际能看到的信息验证。我建议先做一个单格判定模式,等拿到更多截图或录像后再改成“滑动到尽头”,两种逻辑可以共用一个状态机,只是移动步数不同。
验收标准也要提前定。初期 Demo 不需要完美:能加载页面、棋盘显示、键盘操作正常、移动一步不会穿墙,就算通过。第二阶段再加目标格、通关判定和下一关。第三阶段才加动画、音效、画面上羊驼的待机动作。
1.3 边界条件别写进需求里
我看到很多个人项目的翻车点不是玩法没做出来,而是把太多边界想当然。比如“把所有关卡一次做进去”,可你连完整关卡数据都没有。不要为了凑 50 关去编一堆自己都测不完的图,宁可先做 3 到 5 个精心设计过的关卡,把机制说明白。
范围一旦扩大,测试量会跟着涨。一次重制最理想的状态是:一周内跑通主流程,两周内打磨手感,三周内解决浏览器兼容和移动端操作。如果两周后还在改核心移动逻辑,说明前面的玩法定义没写够。
现在,我们先把技术选型定下来,因为在浏览器里重做一个老游戏,选错技术路线会带来一波接一波的兼容问题。
2. 技术选型不是越新越好:HTML5 Canvas 仍是这类重制的关键
2.1 Canvas 和 DOM 场景怎么选
HTML5 并不是一个单一功能,它是一整套浏览器能力。做网页游戏最常用的两个方向是 Canvas 2D 和 DOM 操作。遇到 Alpaca Push 这种基于网格、有动画、需要频繁重绘的游戏,我首选<canvas>。
原因是:网格类游戏的画面状态变化很简单,本质是“棋盘不变,角色和可动块变”。用 Canvas 每次重绘十几到几十个格子,性能完全没有压力;用 DOM 来做反而要维护很多节点和 CSS class,调试时会很烦。图片、背景、加在角色脚下的阴影、滑动过程中的晕影,都可以用 Canvas 的drawImage、fillRect、globalAlpha轻松完成。
如果游戏界面里有很多按钮、弹窗、关卡选择列表,也不一定要全 Canvas。正确做法是把 Canvas 放在页面中央,旁边用普通 HTML 做按钮,用 Canvas 负责游戏区域,用 DOM 负责界面文字。这样最省事,也方便做无障碍文本。
2.2 一个干净的页面骨架
不管原版是不是 Flash,重制版都要以标准网页方式加载。下面是最基本的 HTML 骨架,没有使用任何框架,适合直接作项目起点。注意给画布设置了tabindex,这样它才能接收键盘焦点。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"> <title>Alpaca Push - HTML5 重制</title> <style> html, body { margin: 0; height: 100%; background: #1d2430; display: flex; align-items: center; justify-content: center; } #game { max-width: 90vmin; max-height: 90vmin; background: #222; box-shadow: 0 8px 24px rgba(0,0,0,.5); touch-action: none; } </style> </head> <body> <canvas id="game" tabindex="0"></canvas> <script src="game.js"></script> </body> </html>为什么给viewport加user-scalable=no?因为在移动端,如果玩家双击或者连续触摸画布,浏览器会执行缩放,游戏里的触摸滑动会被打断。限制缩放是为了避免这个问题。但无障碍角度不完全鼓励禁止缩放,所以更稳妥的做法是只给游戏容器设置touch-action: none,而不是全局禁用缩放。上面的示例里,#game的touch-action: none就足够保护画布区域了。
代码里的尺寸用了90vmin,意思是让画布最大尺寸不超过视口宽度和高度中的较小者。这样手机横竖屏切换时,棋盘不会被挤出屏幕。Canvas 内部逻辑尺寸可以固定为 640×640,CSS 尺寸用响应式控制,后面会再讲怎么避免高分辨率屏幕下的模糊问题。
2.3 依赖选择:能少用就少用
对于这种结构简单的游戏,我建议只用原生 JavaScript。原因有三个。第一,没有构建步骤,双击 index.html 就能运行。第二,你可以准确控制所有重绘逻辑,不会被框架抽象掉。第三,多年后打开源码维护时,不需要先装几十个依赖才能跑。
如果你希望将来能快速加 UI、加动画,也可以引入一个两三百 KB 的库,但不要为了“看起来专业”去把 React 或大型引擎装进来。引擎是为大场景设计的,一个几千行以内的滑块续作,用原生 Canvas 加两个工具函数就够了。
同时要说清楚:现代浏览器对 HTML5 Canvas 的支持已经相当统一,真正容易出问题的点反而是 CSS 尺寸、DPR 缩放、音频上下文和输入事件。这些在第六章会专门列出来。
3. 用一个小型网格状态机把核心玩法落实
3.1 二维数组是最直白的关卡模型
无论原版游戏看起来多花哨,核心都要落到状态上。我会把棋盘建模成二维数组。数组里的数字只做一件事:表示这个格子是墙、空地、目标还是可移动块。角色位置单独用player对象保存。
下面是数据结构示例。它不代表 Alpaca Push 的正式关卡,只说明一种稳定的结构:
const ROWS = 8; const COLS = 8; const TILE = 64; const EMPTY = 0; const WALL = 1; const BOX = 2; const TARGET = 3; const PLAYER_FLOOR = 4; // 初始化一个空棋盘,再往里放墙和箱子 let grid = Array.from({ length: ROWS }, () => Array(COLS).fill(EMPTY)); let player = { x: 3, y: 3 }; function setCell(row, col, value) { if (row >= 0 && row < ROWS && col >= 0 && col < COLS) { grid[row][col] = value; } } function getCellType(row, col) { if (row < 0 || row >= ROWS || col < 0 || col >= COLS) { return WALL; } return grid[row][col]; }为什么把越界也当作墙?因为滑动移动时最怕出现“跑了半天突然跑出棋盘”,统一在getCellType里把越界视作墙,移动逻辑就能少写很多边界判断。
3.2 滑动逻辑:处理边界、阻挡,还有“是否可反向”
以一个最简单的单格移动为例。玩家按下方向键后,游戏先计算出下一格坐标。如果下一格是空位,玩家移动过去;如果是可推动块,再看推动方向的下一个格子是否为空。若为空,就推动;不为空,则整次移动无效。
这里最容易写错的一点是“推动的判断不能只查一次”。如果一个方向上排着两个可推动块,你还要循环检查一整串,直到遇到墙或空格。很多滑块类的 bug 都来自只做了单层判断,导致推第一块时却穿过了第二块。下面是一个通用版移动函数:
function tryMove(dx, dy) { const nextX = player.x + dx; const nextY = player.y + dy; const nextType = getCellType(nextY, nextX); if (nextType === EMPTY || nextType === TARGET) { player.x = nextX; player.y = nextY; return true; } if (nextType === BOX) { const beyondX = nextX + dx; const beyondY = nextY + dy; const beyondType = getCellType(beyondY, beyondX); if (beyondType === EMPTY || beyondType === TARGET) { grid[nextY][nextX] = EMPTY; grid[beyondY][beyondX] = BOX; player.x = nextX; player.y = nextY; return true; } } return false; }上面这块只处理了一个箱子的情况。你要做多箱子连推,就把判断改成循环,移动时从离玩家最远的箱子里往回收拾。处理顺序特别注意:从最远的一格开始,而不是从最近的一格开始,否则后面的盒子会把前面的盒子挤到错误位置。
3.3 胜利判断、步数与关卡编排
胜利条件通常可以抽象成“所有目标格上都有箱子,且每个箱子上都有目标格”。最简单但不保险的判断方式是只数箱子,因为箱子数量可能和目标数量不一致。保险的做法是遍历目标格,检查每个目标格上的箱子状态:
function isLevelComplete(targets) { for (const t of targets) { if (grid[t.row][t.col] !== BOX) { return false; } } return true; }这要求你单独保存一份目标格列表,不要只扫描全棋盘。
步数和撤销也需要状态管理。每次尝试移动都会改变player和grid,为了支持撤销,我会先把每次成功移动前的完整状态快照放入一个数组。快照直接存grid.map(row => row.slice()),虽然会多占一点内存,但一个棋盘只有几十个格子,现代浏览器完全承受得住,代码却少很多。
关卡数据可以用数组的数组来定义,每个数字对应一个字母,加载关卡时再映射。你也可以把关卡顺序写在 JSON 文件里:
[ { "rows": 8, "cols": 8, "board": [ "11111111", "10023001", "10000001", "10400001", "10020001", "11111111" ], "targets": [ { "row": 1, "col": 3 }, { "row": 4, "col": 3 } ] } ]这里要注意:如果关卡里同时有“空地”和“普通路”的视觉,用字母会让关卡更易读。你可以定义1=墙、0=空地、2=箱子、3=目标、4=玩家出生点。加载后把棋盘转换成整数数组,但玩家出生点仍然是player的初始位置。
到了这一步,游戏已经能够在一张静态棋盘上玩起来。在继续加动画之前,我们一定要先处理操作层。因为基于网格的游戏,键盘是标准操作,鼠标点击和移动端触摸拖动,才是现代玩家最先试的动作。
4. 别让操作手感毁掉怀旧体验:键盘、拖拽、触摸都要接
4.1 用 Pointer Events 统一鼠标、触摸和手写笔
旧版 Flash 游戏大多只用鼠标操作。重制到 HTML5 后,如果你只接一个click事件,在手机上体验会很差。更好的选择是使用 Pointer Events。它把鼠标、触摸笔、手指触摸统一成一种事件体系,在桌面和移动端都能工作,不必分别监听 touch/mouse。
一个典型的滑动操作流程是:玩家在画布上按下,记录起始点;移动超过一定距离后,判定滑动方向;松开后执行一次tryMove。 距离阈值很关键。我觉得大于 24 像素才触发方向,太短会误触成“原地点击”。
let startX = 0; let startY = 0; let tracking = false; const THRESHOLD = 24; canvas.addEventListener('pointerdown', (e) => { e.preventDefault(); tracking = true; startX = e.clientX; startY = e.clientY; }); canvas.addEventListener('pointerup', (e) => { if (!tracking) return; tracking = false; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) { // 单击:可以做一些选中或确认操作 return; } if (Math.abs(dx) > Math.abs(dy)) { tryMove(dx > 0 ? 1 : -1, 0); } else { tryMove(0, dy > 0 ? 1 : -1); } });为什么用clientX而不是offsetX?因为如果你是读取 Canvas 的相对坐标,在 CSS 放大后需要做缩放;而做滑动方向只关心差分,使用clientX就足够,并且不容易被画布缩放影响。
4.2 禁掉浏览器默认手势
在移动端,触摸滑动有时会被浏览器解释成页面滚动、下拉刷新或者双击缩放。为了让画布能像原生游戏一样吃下所有触摸,需要做两步。第一步在 CSS 里对 canvas 设置touch-action: none,告诉浏览器不要处理该区域里的手势。第二步在pointerdown里调用preventDefault,阻止后续默认事件。
另外也要监听键盘事件。要记得keydown事件会重复触发,按键不放会自动连续移动。对滑块类游戏,连续移动并不是坏事,但如果你希望每次按键只走一步,可以在事件里判断e.repeat:
window.addEventListener('keydown', (e) => { if (e.repeat) return; switch (e.key) { case 'ArrowUp': case 'w': tryMove(0, -1); break; case 'ArrowDown': case 's': tryMove(0, 1); break; case 'ArrowLeft': case 'a': tryMove(-1, 0); break; case 'ArrowRight': case 'd': tryMove(1, 0); break; } });e.repeat为 true 时就跳过,可以让玩家更精确地控制每一步,避免手一抖就连走好几步。那种“想按一下方向键结果走出两格”的感觉,非常影响手感。
4.3 操作后必须立刻更新画面
很多网格游戏做到能操作后,会有一个隐藏问题:你按下方向键,画面没有变化,或者延迟到下一步才刷新。原因通常是代码里“改变状态”和“渲染”分了两次,而渲染又被 setTimeout 缠住了。我建议不要依赖 setTimeout,而是用一个统一的requestAnimationFrame循环,每帧检查 “当前是否需要重绘”,是才绘制。
这样操作响应延迟基本控制在一帧以内,也就是 16ms 左右。对于这种老游戏,玩家不会感觉到卡顿,反而会觉得很跟手。
到这里,一个可玩的版本已经成型:棋盘、角色、箱子、目标、键盘和触摸。但如果你只在控制台里看到正确逻辑,玩家看到的还是一张会“瞬移”的图片,那仍然不合格。接下来要做的是动画、撤销、重开、关卡切换这层“成品感”。
5. 从“能玩”到“像个成品”:动画、撤销、操作栏和报错
5.1 动画时长控制在 120 到 180 毫秒之间
纯网格游戏如果只改坐标,每次移动都是瞬移。玩家按下方向键,角色的渲染位置瞬间跳到下一个格子。在逻辑上是正确的,但视觉上很“硬”。我建议不管角色还是箱子,移动动画都加一段 120ms 到 180ms 的过渡。太短等于没效果,太长会让人感觉游戏很拖。
实现方法也不复杂。不要每帧改变网格数据,而是把“逻辑目标坐标”和“当前渲染坐标”分开。渲染坐标从旧位置向新位置插值:
let currentRenderX = player.x * TILE; let currentRenderY = player.y * TILE; let startTileX = player.x; let startTileY = player.y; let animation = null; function startAnimation(duringMs = 140) { animation = { startTime: performance.now(), duration: duringMs, fromX: currentRenderX, fromY: currentRenderY, toX: player.x * TILE, toY: player.y * TILE, }; } function render(now) { if (animation) { const p = Math.min((now - animation.startTime) / animation.duration, 1); const ease = 1 - Math.pow(1 - p, 3); // easeOutCubic currentRenderX = animation.fromX + (animation.toX - animation.fromX) * ease; currentRenderY = animation.fromY + (animation.toY - animation.fromY) * ease; if (p >= 1) { animation = null; } } // 清空、绘制墙体、目标、箱子、角色 }为什么用 easeOutCubic?它会让移动先快后慢,符合人手操作微小滑动的直觉。如果全程匀速,看起来会像机器人平推,缺少一点弹性。
5.2 撤销、重开和步数统计
Add buttons. 撤销需要恢复历史栈。开始前先定义以下函数:
undo():从历史栈中弹出一份之前的状态快照,恢复 grid 与 player,并将步数减一。restart():把整个棋盘重新加载为当前关卡,同时清空历史栈和步数。nextLevel():如果当前关通过,则加载下一关,重置一切。
为了把操作栏做成合适的 HTML 按钮:
<div id="toolbar"> <button id="undoBtn">撤销</button> <button id="restartBtn">重开</button> <span id="stepLabel">0 步</span> </div>按钮样式使用普通 DOM,并监听 click 事件。会让 UI 比画布内自己画按钮要容易得多,而且天然支持无障碍键盘焦点。
这部分的坑通常是撤销恢复后,动画没有同步。比如你撤销了一步,逻辑上格子位置已经变了,但动画插值还在往旧位置跑。解决办法是在恢复快照时,强制把currentRenderX/currentRenderY同步到格子的新渲染坐标,并清除animation:
function cancelAnimation() { animation = null; currentRenderX = player.x * TILE; currentRenderY = player.y * TILE; }5.3 存档与关卡进度
关通过之后,如果玩家刷新页面,就回到第一关,体验会很挫。我建议在本机浏览器环境里用localStorage保存进度。一个很小的实现不会影响本地缓存:
function saveProgress(levelIndex) { try { localStorage.setItem('alpaca-push-level', String(levelIndex)); } catch (e) { // 隐私模式或禁用缓存时忽略 } } function loadProgress() { try { const saved = Number(localStorage.getItem('alpaca-push-level')); return Number.isFinite(saved) ? saved : 0; } catch (e) { return 0; } }保存进度要放到“通关后”的事件里,不要每次移动都写,避免频繁读写。这里特别提醒:localStorage保存的是字符串,读回来一定要转成数字并且校验有效性。直接做if (saved)会被saved = 0这种合法值坑到。
5.4 运行时错误处理思路
在开发过程中,如果打开页面是一片空白,最可能的原因不是逻辑残缺,而是某个脚本在早期就抛了异常。避免“错误白屏”的一个方案是监听全局错误并展示到页面上一块隐藏的div里。先定位优先级:打开 DevTools Console,看第一条红色错误;再看 Source 标签里的文件行号;不要先怀疑浏览器兼容。绝大多数问题都出在关卡数据写错了,比如某个字符解析成了 undefined,然后立即读取 row/col 就会报错。
为了让玩家看不到难看的undefined或灰屏,也可以在最外层包一个 try/catch,动态把 error 的 message 写进页面顶部。简洁写法如下:
window.addEventListener('error', (e) => { const el = document.getElementById('error-box'); if (el) { el.style.display = 'block'; el.textContent = e.message || '未知错误'; } });这不会替你解决 Bug,但能避免“白屏之后连问题在哪都看不到”。
6. 上线前最重要的不是堆功能,而是兼容性和部署方式
6.1 在真实浏览器里跑一遍测试清单
我在最终发布前,会先列一个表格,而不是凭感觉点两下就算测完。因为不同浏览器对 HTML5 Canvas、Pointer Events、CSS 单位的细节支持确实存在差异,虽然不是大差异,但恰恰是这些小差异最容易让人误判。
| 测试项 | 预期结果 | 检查方式 |
|---|---|---|
| Canvas 尺寸和 CSS 尺寸 | 棋盘边缘清晰,无拉伸模糊 | 放大 / 缩小窗口查看 |
| 高 DPI 屏幕 | 画面不糊,边缘平滑 | 在 Retina 屏或手机上看 |
| 键盘移动 | 方向键控制角色每次移动一格 | 桌面手动操作 |
| 触摸滑动方向 | 手指左滑触发左移,不小于 24px | 手机浏览器或 DevTools 触摸模拟 |
| 浏览器回退/刷新 | 棋盘不会跳出滚动条 | 上下拖动页面查看 |
| 撤销和重开 | 状态栈清空,步数归零 | 多次反复操作 |
| 从第 0 关到下一关 | 最后一格过关后进入新关卡 | 连续通关测试 |
| 旧版本兼容 | WebView/旧版系统不崩 | 低配手机浏览器实测 |
“旧版不支持 Pointer Events”这类情况怎么处理?通过特性检测避免报错。如果window.PointerEvent不存在,可以回退到mousedown+touchstart事件组合,或者直接给出“请使用较新浏览器”的提示。
6.2 处理高分辨率屏的模糊问题
大多数 HTML5 游戏容易犯的一个毛病是没处理 devicePixelRatio。假设你的逻辑画布是 640×640,CSS 也设置成 640px,看起来没问题;但手机或高分屏的物理像素可能是 2 倍,系统会自动拉伸 Canvas,画面会出现明显锯齿和毛边。
预防代码很简单,在初始化时把 Canvas 的实际像素尺寸乘以devicePixelRatio,然后通过 CSS 把显示尺寸控制在逻辑尺寸:
const ratio = window.devicePixelRatio || 1; canvas.width = LOGIC_WIDTH * ratio; canvas.height = LOGIC_HEIGHT * ratio; canvas.style.width = LOGIC_WIDTH + 'px'; canvas.style.height = LOGIC_HEIGHT + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(ratio, ratio);这样,游戏内部所有绘图坐标仍然以 640×640 为基准,画布自动变得清晰。
如果发现某些浏览器里点击坐标错位,常见原因是 CSS 缩放。点击事件里的坐标是 CSS 像素,你如果要判断点中了某个格子,需要同时除以逻辑尺寸与显示尺寸的比例。如果上面代码已经用 ratio 做了 scale,那坐标关系会复杂一些。我建议方向操作只通过滑动差分来判定,不要依赖点击时具体在哪个格子,可以避免这类坐标换算困扰。
6.3 部署只需要静态文件
重制作品最终形态是一组静态文件:index.html、game.js、style.css、可能还有一些图片和音频。这意味着你可以放到任何静态托管平台上,不需要后端。若只是本地演示,直接双击 index.html 即可。
但要提醒的是:本地双击和线上访问在localStorage上的表现类似,但获取关卡 JSON 时会有跨域限制。如果你在本地用文件协议直接读取 level.json,浏览器可能拦截。为了测试方便,更稳的方案是把关卡数据直接写进 game.js,或在本地起一个简单静态服务器。方法有很多,不需要在代码里做特殊处理。
部署上线后,最好再让另一个人轮流操作。你一个人玩一遍很容易“习惯各种细节”,比如你默认知道某个交互是这么设计的,但新玩家不知道。把原型发给朋友,什么都不提醒,看他们会不会卡在第一步。如果他们不知道“按方向键而不是点棋盘”,就在界面加一行文字提示。
最后,还是要把复刻这件事用平稳心态收尾。老游戏重制最有意思的不只是技术,而是对“当年玩法为什么好玩”的一次复盘。Alpaca Push 要重现 2004 年 ICQ Slide-a-Lama 的体验,重点不是去下载某段老旧资源,而是把一个围绕网格、方向、推动和关卡的小循环干干净净地重写出来。对这个项目来说,最该优先交付的不是花哨特效,也不是 100 个关卡,而是一套在主流浏览器里能稳定打开、键盘触摸都能操作、有明确胜负反馈的页面。等这层地基稳了,再慢慢加羊驼的表情、复古音效、关卡选择器,才不会把代码堆得越来越难维护。真正做完后你会发现,HTML5 的宽容度比二十年前的 Flash Player 高得多,限制你的更多是玩法的清晰度和测试覆盖,而不是浏览器能力。