上一篇我们聊了游戏循环与状态机——单台游戏的骨架。这篇往上一层:框架。
「咖啡街机厅」现在有六台机器:坦克大战、打砖块、Flappy 咖啡杯、下一百层、浓缩时序、小丑杯。它们的玩法天差地别,但接入框架的方式完全一样——每台游戏文件的最后一行都是:
ARCADE.register({ id: 'tank', cn: '坦克大战', en: 'BATTLE COFFEE · 1985', create: function (api) { /* ... */ } });
一个对象、一个 register 调用,菜单图标、暂停、最高分、文章页挂载全部自动到位。第六台机器「小丑杯」接入时,框架层只改了 3 行(还是为了它特有的图标)。这篇就拆解这个”一行注册”背后的设计。
一、先看反面:不做框架会怎样
假设没有框架,六台游戏各自为政,你会得到六份这样的代码:
// 六个文件里各写一遍……
document.addEventListener('keydown', handleKeys); // 自己管键盘
setInterval(loop, 1000 / 60); // 自己管循环
var best = localStorage.getItem('tank_best'); // 自己管最高分
canvas.addEventListener('mousedown', onClick); // 自己管点击
// 还有:暂停、退出清理、菜单入口、文章页嵌入尺寸……
重复六遍还不是最糟的,最糟的是不一致:A 游戏的暂停键是 P、B 游戏是 Esc;A 的最高分存了、B 忘了存。玩家在不同机器间获得的体验是割裂的,你修一个 bug 要修六个地方(后来我们真修过一次框架级 bug——暂停期间 keyup 被吞掉导致方向键”卡死”,正因为逻辑收拢在框架里,一处修复六台游戏同时痊愈)。
二、正面:注册表 + 依赖注入
框架的全部魔法只有两个动作。
第一个动作:注册(把选择权交给游戏)。 框架暴露一个极小的入口:
var games = [];
function register(def) { games.push(def); }
window.ARCADE = { register: register, open: openArcade, close: closeArcade };
games 数组就是注册表。每台游戏文件在加载时调用一次 register,把自己塞进表里——框架不认识任何一台具体的游戏,它只认识”游戏们”。
框架维护一张"清单",任何符合约定的模块往清单里登记自己,框架只遍历清单干活。浏览器自己的
addEventListener、CSS 里的一条条规则,本质都是注册表。它的好处是扩展不改框架:新增一个模块 = 清单多一项,框架代码零修改。
第二个动作:注入(把依赖送给游戏)。 玩家点开某台机器时,框架才调用它的 create,并把游戏需要的”公共设施”打包送进去:
function makeApi(def) {
return {
canvas: canvas, ctx: ctx, W: W, H: H, // 画布与尺寸
best: getBest(def.id), // 这台机器的历史最高分
panel: function (hints, tip) { ... }, // 在机台下方登记按键说明
gameOver: function (score, opts) { ... }// 结算:存最高分、切结算画面
};
}
function startGame(def) {
active = def.create(makeApi(def)); // 游戏的入口就是 create(api)
mode = 'game';
}
游戏从头到尾不需要知道 document、localStorage、菜单怎么画——它只拿到一个 api 对象,要用什么伸手拿什么。这个方向的反转,术语叫控制反转(Inversion of Control)。
想象两种开餐厅的方式:A)每道菜自己种菜、养鸡、打水;B)食材由后厨统一配送,厨师只管做菜。方式 A 里每道菜都被杂事拖累,方式 B 里厨师换成谁都行——因为"食材从哪来"被反转给了后厨。
makeApi 就是后厨:游戏(厨师)声明自己需要什么,框架(后厨)在开火时把 ctx、gameOver 这些"食材"送进来。测试时还能送"假食材"(下一篇的无头测试正是这么干的)。
三、约定优于配置:菜单为什么”自动长出来”
注册表建好后,框架的所有界面都变成了对注册表的遍历。菜单绘制:
for (var i = 0; i < games.length; i++) {
var g = games[i];
drawIcon(g.id, p.x + L.cw / 2 - 22, p.y + 12, 44); // 按 id 画图标
ctx.fillText(g.cn, p.x + L.cw / 2, p.y + 74); // 中文名
ctx.fillText(g.en, p.x + L.cw / 2, p.y + 87); // 英文副标题
}
菜单的网格布局也由数量驱动——第二行摆几张,完全看注册了几台:
var n2 = Math.max(1, games.length - 3); // 第一行最多 3 台
var row2 = (W - (cw * n2 + gx * (n2 - 1))) / 2; // 剩下的居中排开
从 5 台升级到 6 台时,布局代码只改了这一处;标题「FIVE MACHINES」顺手改成动态的 'SKYFURY ARCADE · ' + games.length + ' MACHINES'。界面上每一个”看似精心设计”的细节,背后都是一次对注册表的 for 循环。
这整套思路有个口号式的名字:约定优于配置(Convention over Configuration)。框架和游戏之间签一份君子协定:
| 约定 | 内容 |
|---|---|
id |
唯一标识,用于图标与最高分存取 |
cn / en |
菜单展示名 |
create(api) |
唯一入口,返回 { update, draw, onKey, onPointer } |
api.gameOver(score) |
唯一的结算出口,框架负责收尾 |
只要守约,框架就保证:菜单有你、暂停可用(P 键由框架托管)、最高分自动记录、ESC 安全退出。
君子协定最大的风险是"对方不守约"。有两个真实的教训:其一,某台游戏在
update 里抛了异常,rAF 链断裂,整个街机厅画面冻结——框架后来学会了在文档里写明"update 不得抛异常",而游戏侧用状态机把所有非法路径挡在门外;其二,约定的字段拼错一个字母(gameOver 写成 gameover)不会报错,只会静默失效。所以注册时可以加一层断言:if (!def.id || !def.create) throw new Error('游戏注册缺少 id/create')——让错误大声失败,好过静默成功。
四、可视化:亲手往注册表里塞一台机器
点击下面的按钮,体验”注册”如何让菜单生长(也可以把它拆掉——注册表是动态的):
render() 从头到尾只遍历 registry——和 arcade.js 的 drawMenu() 一模一样的姿势。
五、跟着复现:15 行写一个微型插件框架
<!DOCTYPE html>
<div id="menu"></div>
<button onclick="ARCADE.register(makeGreeter('汪', '🐕')).run()">注册小狗</button>
<button onclick="ARCADE.register(makeGreeter('喵', '🐈')).run()">注册小猫</button>
<script>
var ARCADE = { registry: [], register: function (def) { this.registry.push(def); return def; } };
ARCADE.run = function (def) { // 框架调用游戏的唯一入口
var api = { shout: function (s) { document.title = s; } }; // 注入的公共设施
def.instance = def.create(api);
};
function makeGreeter(sound, icon) {
return {
id: sound, cn: icon + ' ' + sound + '叫机器',
create: function (api) {
return { update: function () { /* 每帧逻辑 */ },
draw: function () { api.shout(sound + ' ~'); } };
}
};
}
function renderMenu() {
document.getElementById('menu').innerHTML =
ARCADE.registry.map(function (d) { return d.cn; }).join(' | ');
}
</script>
注册表、注入、遍历渲染——三个动作,一个迷你框架就成了。真实框架无非是把 shout 换成 ctx,把 run 换成 60Hz 的游戏循环。
六、小结
- 注册表模式:框架只认清单,扩展零改动
- 控制反转:游戏声明依赖,框架开火时注入(
create(api)) - 约定优于配置:
id / cn / en / create / update / draw / gameOver这份君子协定,换来菜单、暂停、最高分全自动 - 约定要防守:注册加断言,让错误大声失败
框架让游戏”接入得整齐”,但游戏内部还有一块硬骨头——两个东西撞在一起了,怎么判定?下一篇讲碰撞检测:从两个数字的比较,讲到瓦片地图的四角采样。
本篇所属系列:博客开发指南 · 上一篇:(七)游戏循环与状态机 · 下一篇:(九)别让指针穿墙——游戏碰撞检测入门