跳转到内容

小游戏平台

微信小游戏在 Estella 里不是特例,它只是平台家族的一个 profile。各家小游戏宿主 (微信的 wx、抖音的 tt 等)暴露的 API 高度相似:没有 DOM、打包式文件系统、分包、 触摸输入、键值存储。Estella 把这套模型只实现一次,每个平台厂商用数据来描述。

本指南面向「接入 Estella 未内置的平台」。如果你的目标是微信,请看 微信小游戏

一个 profile 就是三个事实,外加至多一个方法

import { installMiniGamePlatform } from 'esengine/minigame';
declare const myHost: any; // 宿主全局对象,例如 `tt`
installMiniGamePlatform({
id: 'myvendor',
hostLabel: 'MyVendor',
get global() { return myHost; },
});

这就是一个完整的平台。文件系统、fetch、canvas、图片解码、触摸与按键输入、存储、 分包、内存告警、设备像素比、语言,全部来自归一化后的宿主全局对象。音频createInnerAudioContext)和 socketconnectSocket)也一样。视频走引擎自带的 wasm MPEG-1 解码器,对任何宿主都可移植。

global 要像上面那样用 getter 读取:宿主全局在运行时才存在,用 getter 才能保证在打包 工具或测试里 import 你的 profile 时不会碰到未定义的绑定。

WASM 实例化。微信走 WXWebAssembly.instantiate(path) 而不是标准 WebAssembly, 所以它的 profile 覆盖了这一个方法:

{
id: 'wechat',
hostLabel: 'WeChat',
get global() { return wx as unknown as MiniGameGlobal; },
instantiateWasm(pathOrBuffer, imports) { /* WXWebAssembly */ },
}

如果你的宿主用标准 WebAssembly,就不用写它——家族会通过打包文件系统读出二进制并 实例化。

createAudioBackendcreateVideoBackendcreateSocket 同样是可选的。只在宿主 确实不同的地方覆盖;默认实现是 MiniGameAudioBackend、wasm 视频解码器和 MiniGameSocket,它们都从 esengine/minigame 导出,你可以包装而不必重写。

这几个可选方法不只用于「宿主有差异」——它们就是替换你不满意的实现的接缝。 比如自己写一套视频软解码,只需要一个方法:

import type { MiniGameProfile, PlatformVideoBackend, VideoBackendContext } from 'esengine/minigame';
class SoftwareVideoBackend implements PlatformVideoBackend {
readonly name = 'my-software-decoder';
constructor(private ctx: VideoBackendContext) {}
createStream(url: string, options) { /* 你的解码 → VideoStreamHandle */ }
dispose() {}
}
export default {
id: 'myvendor',
hostLabel: 'MyVendor',
get global() { return myHost; },
createVideoBackend: (ctx) => new SoftwareVideoBackend(ctx),
} satisfies MiniGameProfile;

VideoPlugin 在 build 时向平台要一次后端,你返回什么,游戏里所有 VideoPlayer 就驱动什么。要满足的契约是 PlatformVideoBackendcreateStream(url, options) 返回一个 VideoStreamHandle,它的 pump(module) 每帧把当前帧上传到 textureHandle。音频(PlatformAudioBackend)和 socket(PlatformSocket)同理。

想保留某个宿主的大部分、只换其中一件,把它的 profile 展开:

import { wechatProfile } from 'esengine/wechat';
export default { ...wechatProfile, createVideoBackend: (ctx) => new SoftwareVideoBackend(ctx) };

你的 global 需要满足 MiniGameGlobal。必需成员:

成员 用途
createCanvas() 上屏画布(第一次调用)+ 离屏 2D
createImage() 图片解码
getFileSystemManager() readFileSync / readFile / access / accessSync / writeFile
request(opts) fetch polyfill
createInnerAudioContext() 音频播放
connectSocket(opts) GameSocket / 状态同步
getSystemInfoSync() 视口尺寸、像素比、语言
onTouchStart/Move/End + off* 指针输入
getStorageSync / setStorageSync / removeStorageSync / getStorageInfoSync 存档、热更新记账

可选成员——适配器会探测,宿主缺失时优雅降级:onTouchCancel/offTouchCancelonKeyDown/onKeyUp(及 off*)、loadSubpackageonMemoryWarning/offMemoryWarningonShow/onHide

发布包的入口调用家族 runtime:

// game.js —— 宿主执行这个文件
const engineFactory = require('./wasm/esengine.js');
const bundle = require('./game-bundle.js');
bundle.boot(engineFactory, { /* side module 工厂 */ });
// 与游戏一起打包
import { installMiniGamePlatform, initMiniGameRuntime } from 'esengine/minigame';
import { myProfile } from './myProfile';
export function boot(engineFactory, sideModuleFactories) {
installMiniGamePlatform(myProfile);
return initMiniGameRuntime({
engineFactory,
engineWasmPath: 'wasm/esengine.wasm',
sceneNames: ['Main'],
firstScene: 'Main',
sideModuleFactories,
});
}

initMiniGameRuntime 从适配器取上屏画布,把它的 GL 上下文注册到引擎模块,加载可寻址 manifest,然后跑起 app——和微信走的是同一条序列。initWeChatRuntime 就是这个函数 填上微信构建产物的 wasm 文件名,没有别的。

打包项目对话框里,把鼠标移到平台列表的「项目自定义」分组上,点 +。 填一个标识和名称,编辑器就会把平台的两半都写好——并且已经连接好——然后选中这个 新平台;只要你描述完自己的宿主,就能直接打包。

它写出来的就是普通的项目文件,不会在别处注册什么。下面是打包那一半:

.esengine/platforms/acme-play.mjs
export default {
id: 'acme-play',
label: 'ACME Play',
blurb: 'ACME 小游戏包。',
defaultOut: 'dist-acme',
emitConfigFiles(ctx) {
return [{
file: 'game.json',
content: JSON.stringify({
orientation: ctx.orientation,
appName: ctx.title,
...(ctx.subPackages.length > 0 ? { subPackages: ctx.subPackages } : {}),
}, null, 2) + '\n',
}];
},
};

整个文件就这些。三个事实加一个 emitter——其余全部落到「标准小游戏宿主」的默认值: 平台中立的 SDK 入口(index.minigame.js)、initMiniGameRuntime、标准 WebAssembly 引擎 glue,以及通用的 CommonJS game.js。剩下的一切——cook、 manifest、场景变换、side-module 扫描、打包、运行时拷贝——与微信走的是同一条管线。

上面这个文件是打包那一半(点 + 会帮你生成)。运行时那一半就是本指南开头的 MiniGameProfile ——宿主全局对象,以及你替换掉的任何能力。用 runtimeProfile 指过去,生成的入口 就会在启动前把它装上:

.esengine/platforms/acme-play.mjs
export default {
id: 'acme-play',
label: 'ACME Play',
runtimeProfile: 'src/acme-runtime.ts', // ← 另一半
emitConfigFiles(ctx) { /* … */ },
};
// src/acme-runtime.ts —— 默认导出运行时 MiniGameProfile
export default {
id: 'acme-play',
hostLabel: 'ACME Play',
get global() { return acme; },
createVideoBackend: (ctx) => new SoftwareVideoBackend(ctx),
};

这个连接很关键:esengine/minigame 在游戏指明宿主之前不装任何平台。没有它, 包能正常构建,然后在设备上抛错。只有当你的游戏自己调用 installMiniGamePlatform 时才可以省略 runtimeProfile;编辑器会检查这个路径是否存在,不存在就不会把该平台 标为就绪。

id 只能是小写字母/数字/短横线,且不能与内置平台重名。它同时是 cook 的 导入设置键,所以逐纹理的压缩覆盖可以按名字指定你的平台。

可覆盖的全部字段,以及管线拿它做什么:

字段 含义
id 平台身份——同时是 cook 逐纹理读取的导入设置键
sdkEntryFile esengine 被 alias 到的 SDK dist 入口
runtimeInit 生成的入口所 import 的启动函数
engineGlueCandidates 按优先级查找的引擎 glue 文件名
esTarget 游戏包与降级后 glue 的语法下限
wasmBuildHint / sideModuleBuildTargets 错误提示里的 build -t … 名字
nativeSuffixes / binRestageExts 打包器的后缀策略
subpackageDir 懒加载分组落盘的位置
emitConfigFiles / emitEntry 该平台的配置文件与 game.js
wasmDir 相对项目的引擎运行时目录(默认用编辑器自带的 web 运行时)
runtimeProfile 相对项目的模块路径,默认导出运行时 MiniGameProfile

只有当它加载不了 web 引擎的 wasm 时才需要。微信需要(-t wechatES_BUILD_WXGAME), 因为它有 WXWebAssembly 且没有 WebGPU。一个支持标准 WebAssembly 和 WebGL2 的宿主 可以直接用 web 构建的 glue——engineGlueCandidates 里已经有回退到 esengine.js 的 分支,正是为这种情况准备的。