广告与分享
在小游戏宿主(微信、抖音)上,变现与分享是每个游戏都要手动接的平台调用——而接错的
恰恰是接线本身:激励视频盖着屏幕时游戏还在跑、广告底下音频还在响、出错路径把世界
永远留在暂停里。Ads 与 Share 服务把这套仪式收进引擎,并且在每个平台上都存在,
所以玩法代码只写一份,用询问代替按平台分支。
Ads.showRewarded(adUnitId) 展示一条激励视频,并在广告关闭、游戏恢复运行之后
resolve,结果是奖励有没有赚到:
import { defineSystem, Res, GetWorld, Ads } from 'esengine';
const reviveSystem = defineSystem([Res(Ads), GetWorld()], (ads, world) => { if (!reviveClicked(world)) return; ads.showRewarded('adunit-xxxx').then(({ completed }) => { if (completed) revivePlayer(world); // 看完了 → 发奖 else showToast(world, '看完整条视频才能复活'); }).catch(() => { showToast(world, '暂时没有可用的广告'); });}, { name: 'ReviveSystem' });广告覆盖屏幕期间,服务会:
- 暂停游戏时钟——复活广告不能反过来害玩家丢掉这条命;
- 挂起音频设备——不碰用户设置的任何音量;
- 无论广告怎么结束都恢复以上两者,包括所有出错路径。你自己在广告前暂停的游戏, 广告后保持暂停。
宿主文档里的 load/show 补救舞步(没有填充的广告要 load 一次再重试)已经折叠在内;
老运行时“发了奖但不报 isEnded“的情况也是——关闭记录缺失按看完处理,因为宿主
就是这个意思。
Ads.showInterstitial(adUnitId) 是同一契约去掉奖励;Ads.preloadRewarded(adUnitId)
预热一个广告位,让展示即点即出。
广告在哪些地方存在
Section titled “广告在哪些地方存在”Ads.available 在存在某个广告源时为 true——平台自带的,或安装进来的 provider。
用它诚实地隐藏“看广告”按钮:
- 微信 / 抖音构建——宿主的激励/插屏广告,同一份家族实现。
- 编辑器 Play 模式——自动安装 mock provider:
showRewarded“播放”片刻后 resolvecompleted: true,真实的暂停/音频仪式照常运行。复活流程在工位上就能排练。 - Web 与原生构建——没有广告系统,
available为 false。接入聚合 SDK 的原生壳 走编辑器同一扇门:Ads.setProvider(...)。
分享和内购以包的形式发布,不在引擎里——大多数游戏两个都不会打开:
npm install estella-plugin-minigame-servicesimport { addPlugin } from 'esengine';import { miniGameServicesPlugin } from 'estella-plugin-minigame-services';
addPlugin(miniGameServicesPlugin);小游戏宿主有两个分享面,游戏两个都该配置:
import { defineSystem, addStartupSystem, Res } from 'esengine';import { Share } from 'estella-plugin-minigame-services';
addStartupSystem(defineSystem([Res(Share)], (share) => { // 默认卡片:宿主自己的分享菜单(微信右上角)展示的内容,也是 share() 不带参数时 // 用的内容。传函数则在分享那一刻被询问,卡片可以携带实时状态——分数、房间号。 share.setShareCard(() => ({ title: `我拿了 ${currentScore()} 分,来超过我!`, query: `room=${currentRoomCode()}`, }));}, { name: 'SetupShare' }));
// 在游戏自己的分享按钮回调里(share 同上,来自 Res(Share)):share.share(); // 默认卡片share.share({ title: '来加入我的游戏' }); // 或一次性卡片打开被分享卡片的人会在宿主的启动参数里收到 query——邀请链接和房间号就是这样
传递的。
分享是有意的 fire-and-forget:2021 年起没有任何小游戏宿主报告玩家是否真的分享了,
所以没有可等待的结果。平台之外(web、原生、编辑器)Share.available 为 false 且
share() 返回 false——把按钮藏起来,而不是承诺一张打不开的分享面板。
小游戏的排行榜不是一个「拉回来的列表」。玩家的好友数据只能在开放数据域里读 ——那是宿主在你的游戏旁边启动的第二个 JavaScript 运行时,里面没有引擎、没有 WebGL、也没有 wasm——而且它没有回到你这边的通道。所以榜是在那边画的,到你 这边时已经是像素。
它以包的形式发布而不是待在引擎里:一个从不开排行榜的游戏,不该背着一份榜的 渲染器。
npm install estella-plugin-minigame-servicesAPI 把宿主的这条约束直说,而不是遮掩:
import { UIVisual } from 'esengine';import { miniGameServicesPlugin, Leaderboard } from 'estella-plugin-minigame-services';
addPlugin(miniGameServicesPlugin);const leaderboard = app.getResource(Leaderboard);
// 你这一半:写自己的那一行。这是游戏本身唯一能做的云操作——// 读任何人的(包括你自己的)都属于开放数据域。leaderboard.submit(score);
// 请开放数据域画。这是请求不是提问:不会有行数据回来,// 没有条数,也没有「成功了吗」。leaderboard.show({ limit: 10, order: 'desc' });
// 它画出来的东西,作为纹理句柄,任何 UIVisual 都能戴上。world.get(panel, UIVisual).texture = leaderboard.texture;
leaderboard.hide(); // 清空并停止采样没有开放数据域的地方 Leaderboard.available 是 false——网页、原生,以及任何
没有声明开放数据域的包。把按钮藏起来,别打开一个永远空白的面板。
开放数据域这个目录属于项目,里面放什么由包提供:
import 'estella-plugin-minigame-services/open-data';整个文件就这一行。导出器会把 open-data/ 单独打一个包并写进 game.json;项目
里没有这个目录就不带开放数据域,available 会如实说没有,而不是让一块榜到设备
上才画不出来。
带来的这块榜:行会排名,玩家自己那一行会加重,从没玩过的好友不会以 0 分出现,
而是不出现。样式走 show({ style })——颜色、行高、字号、画不画头像。画布尺寸固定
且不能滚动:没有任何指针或按键事件能到达那个运行时,所以 limit 是「放得下
几行」,不是分页。
不 import 包的那份,自己写这个文件就行。它运行在开放数据域里,所以不允许
import esengine——真这么写导出会失败,进 Play 也会失败,这比到设备上才发现要
好。在里面你有 wx.getSharedCanvas() 给的 2D 画布、wx.getFriendCloudStorage(),
以及 wx.onMessage() 收到的 show() 发过去的内容。
在你自己的桌面上
Section titled “在你自己的桌面上”Play 模式里没有宿主,所以编辑器来当这个宿主:它跑的是你自己那份
open-data/index.ts,画在离屏画布上,配一份一眼就看得出是假的好友数据,并按设备
上同样的那几个能力回答 Leaderboard。所以你对着排版的那块榜就是会发出去的那块
——不管它是谁写的——而且 submit() 真的能进去,自己那一行会动。
它替代不了的是真正属于宿主的那部分——真实好友,以及它们所在的沙箱。发版前请在真 机上过一眼。
小游戏宿主把玩家登录后交给你一个一次性 code。这个 code 不是身份:要把它换成 身份需要你的 app secret,而放在客户端里的 app secret 等于人人可读。所以这一步 交换属于你自己的服务器,引擎的职责到 code 为止。
import { defineSystem, Res, Identity } from 'esengine';
if (!identity.available) return; // 网页、原生、编辑器都没有
// 你服务器手上的会话还有效时,跳过这趟往返。if (await identity.sessionValid()) return;
const { code } = await identity.login(); // 是 code,不是会话const session = await postToYourServer('/session', { code });登录失败时 login() 会 reject,并带上宿主自己的说法;平台压根没有登录时也会
立刻 reject——这样跳过了 available 的调用方会听到消息,而不是 await 一个永远不
settle 的 promise。code 短期有效且只能用一次:缓存你服务器返回的会话,不要缓存
code。
这里刻意没有本地替身,和广告、排行榜不同。假广告仍然是真的暂停、假排行榜仍然
是真的渲染器,排练它们有意义;而假 code 是任何服务器都换不了的字符串——拿它排练,
排练的只是一个注定失败的请求。在没有该能力的平台上 available 是 false,游戏走
它在「没有账号」时本来该走的那条路。
在小游戏里付费是一种权限,不是功能。微信上它只在安卓可用:在 iPhone 上这个
调用是存在的,而平台会拒绝它。所以 available 回答的是这台设备能不能买,商店
应该在打开之前就问,而不是等玩家点了「购买」才发现。
import { defineSystem, Res } from 'esengine';import { Payment } from 'estella-plugin-minigame-services';
if (!payment.available) return; // iOS、网页、原生——不要打开商店
try { await payment.request({ offerId: '你的 offerId', quantity: 10 }); // 是**宿主**说购买完成了。现在去问**你自己的服务器**玩家拥有什么, // 不要在这里发货。 await refreshInventoryFromYourServer();} catch (err) { // 宿主自己的消息和错误码,包括「玩家改主意了」。 // 这两种要不同的 UI,而只有宿主分得清。 if ((err as { code?: number }).code === /* 你的宿主的取消码 */ 2) return; showPurchaseFailed(err);}调试期传 sandbox: true;游戏有多个区服时传 zoneId。
这个服务刻意不做两件事。它不解释宿主的错误码——各家不一样,引擎自己编一张
映射表就是一个猜测,而你的游戏会照着它分支。它也不发货:客户端相信的购买等于
攻击者可以声称的购买。宿主通知的是你的服务器,发货是服务器的事;request()
resolve 只是「去问服务器」的信号。
Play 模式里没有本地替身,理由和登录那节一样——一次不扣钱也不发货的排练,排练的只有 那个弹窗,而真正会出问题的是弹窗后面。