跳转到内容

广告与分享

在小游戏宿主(微信、抖音)上,变现与分享是每个游戏都要手动接的平台调用——而接错的 恰恰是接线本身:激励视频盖着屏幕时游戏还在跑、广告底下音频还在响、出错路径把世界 永远留在暂停里。AdsShare 服务把这套仪式收进引擎,并且在每个平台上都存在, 所以玩法代码只写一份,用询问代替按平台分支。

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) 预热一个广告位,让展示即点即出。

Ads.available存在某个广告源时为 true——平台自带的,或安装进来的 provider。 用它诚实地隐藏“看广告”按钮:

  • 微信 / 抖音构建——宿主的激励/插屏广告,同一份家族实现。
  • 编辑器 Play 模式——自动安装 mock provider:showRewarded“播放”片刻后 resolve completed: true,真实的暂停/音频仪式照常运行。复活流程在工位上就能排练。
  • Web 与原生构建——没有广告系统,available 为 false。接入聚合 SDK 的原生壳 走编辑器同一扇门:Ads.setProvider(...)

分享和内购以包的形式发布,不在引擎里——大多数游戏两个都不会打开:

Terminal window
npm install estella-plugin-minigame-services
src/main.ts
import { 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——而且它没有回到你这边的通道。所以榜是在那边画的,到你 这边时已经是像素。

它以的形式发布而不是待在引擎里:一个从不开排行榜的游戏,不该背着一份榜的 渲染器。

Terminal window
npm install estella-plugin-minigame-services

API 把宿主的这条约束直说,而不是遮掩:

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.availablefalse——网页、原生,以及任何 没有声明开放数据域的包。把按钮藏起来,别打开一个永远空白的面板。

开放数据域这个目录属于项目,里面放什么由包提供:

open-data/index.ts
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() 发过去的内容。

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 是任何服务器都换不了的字符串——拿它排练, 排练的只是一个注定失败的请求。在没有该能力的平台上 availablefalse,游戏走 它在「没有账号」时本来该走的那条路。

在小游戏里付费是一种权限,不是功能。微信上它只在安卓可用:在 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 模式里没有本地替身,理由和登录那节一样——一次不扣钱也不发货的排练,排练的只有 那个弹窗,而真正会出问题的是弹窗后面。