主题
商店与支付系统 (Shop & Payment System)
概述
星火编辑器提供两层支付能力:
- 商店系统(GameSystemUI)— 数据驱动的完整商店框架,内置商品展示、购买流程和真实货币支付引导
- 支付 API(WasiCore)— 底层支付接口,可直接调用平台支付 SDK 和管理支付回调
方案选择
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 游戏内商店(含虚拟/真实货币) | ShopWindow + ShopSystemClient | 零代码搭建,数据驱动,自动处理支付 |
| 自定义 UI 但需要购买逻辑 | ShopSystemClient | 仅使用后端 API,自行搭建 UI |
| 完全自定义支付流程 | PaymentClient + PaymentServer | 直接调用底层支付 API |
> 推荐:大多数情况下直接使用 ShopWindow 组件即可。它已经集成了商品展示、余额检查、购买确认、真实货币支付引导等完整流程。
> 后台配置注意:如果你的支付流程涉及充值星火币(例如余额不足时拉起平台充值,或游戏内提供显式充值入口),则在发布游戏后还需要前往创作者中心后台的「运营配置 → 游戏基础配置 → 游戏大厅配置」,将「进入游戏保留大厅局」设置为「是」。 > > 否则,充值星火币后的大厅返回链路可能不符合预期,影响实际支付体验。 > > 创作者中心地址: > - 测试版本:https://developer-alpha.spark.xd.com/ > - 正式版本:https://developer.spark.xd.com/
支付 API(底层接口)
如果商店模板不满足需求(例如需要完全自定义的支付 UI),可以直接使用 WasiCore 框架提供的支付 API。
支付流程
客户端 平台 SDK 支付服务器 服务端
│ │ │ │
│ PaymentClient │ │ │
│ .RequestPayment() ──────→│ 发起支付 │ │
│ │ │ │
│ │ 用户完成付款 ────────→│ │
│ │ │ │
│ │ │ 通知 ─────────────→│ PaymentServer
│ │ │ │ 调用 PaymentHandler
│ │ │ │ 原子提交:商品发放 + 星火币扣除
│ │ │ │ 触发 OnPaymentDelivered
│ │ │←── 确认 ───────────│客户端:发起支付
csharp
#if CLIENT
using GameCore.PaymentSystem;
var currentBalance = PaymentClient.GetCurrentBalance();
if (currentBalance is long balance)
{
Game.Logger.LogInformation("当前星火币余额: {Balance}", balance);
}
// 推荐:自动使用当前游戏 SessionId
PaymentClient.RequestPayment(
productId: "gem_pack_100",
productName: "100 钻石",
productDesc: "购买 100 颗钻石",
price: 600 // 星火币数量
);
#endifRequestPayment 会调用平台原生支付 SDK,用户在平台 UI 上完成付款。这是一个异步过程——方法返回只表示请求已发出,不代表支付已完成。
> 商品标识说明:底层支付 API 中的 productId / 服务端通知里的 ProductionId 只是字符串标识。框架支付层不会根据这个字符串自动反查 GameDataShopItem 或其他数编数据。 > > - 使用 GameSystemUI 内置商店时,商品 ID 来自真实的 GameDataShopItem Link,商品配置应按正规数编流程创建。 > - 完全自定义支付流程可以使用自定义商品 ID,但服务端 PaymentHandler 必须自行把该 ID 映射到商品配置、价格校验和发货逻辑。 > - 如果自定义流程选择把 productId 当数编 Link 使用,必须保证这条 GameData 在服务端同样存在。只在客户端 UI 事件中动态构造商品数据,再把 Link ID 传给服务端,服务端会因为没有注册该数编而无法反解。
客户端:查询当前用户星火币余额
PaymentClient.GetCurrentBalance() 会查询当前客户端登录用户的星火币余额,返回 long?:
- 返回具体数值:查询成功
- 返回
null:查询失败(例如未登录、网络失败、支付服务暂时不可用)
csharp
#if CLIENT
using GameCore.PaymentSystem;
var balance = PaymentClient.GetCurrentBalance();
if (balance is null)
{
Game.Logger.LogWarning("星火币余额查询失败");
}
else
{
Game.Logger.LogInformation("当前星火币余额: {Balance}", balance.Value);
}
#endif此 API 只允许查询当前用户,不提供 userId 参数,也不能用于查询其他玩家余额。这个限制由 C++ Host 层强制保证,而不是依赖业务代码自觉遵守。
客户端:旧版余额详情接口(已废弃)
PaymentClient.GetBalanceInfo() 是旧版兼容接口,返回 PaymentBalanceInfo?。 CurrentBalance 是当前可用的星火币余额;TotalCoin 已废弃,不代表当前项目消费,也不应再用于业务判断。
csharp
#if CLIENT
using GameCore.PaymentSystem;
var balanceInfo = PaymentClient.GetBalanceInfo();
if (balanceInfo is null)
{
Game.Logger.LogWarning("星火币余额详情查询失败");
}
else
{
Game.Logger.LogInformation("当前星火币余额: {Balance}", balanceInfo.CurrentBalance);
}
#endif此 API 只允许查询当前用户,不提供 userId 参数,也不能用于查询其他玩家余额。它已废弃;旧项目调用仍保留兼容,但会记录错误日志。需要当前项目消费统计时,应使用服务端 API。
余额查询使用建议
- 推荐调用时机:打开商店页、打开充值页、点击购买按钮前、收到支付成功回调后手动刷新
- 不推荐:每帧轮询、列表渲染时对每个商品重复调用、短时间内连续刷新多次
- 失败处理:
null通常只代表“这次没查到”,不要把它当作 0 余额写回 UI 或存档 - UI 建议:首次进入页面时可先展示“加载中”或上一次缓存值,查询完成后再刷新数字
- 一致性预期:
GetCurrentBalance()走客户端到支付服务的实时查询;GetBalanceInfo()的TotalCoin不属于项目消费统计,已不再推荐使用。 - 购买前确认:如果你的流程会消耗星火币,建议先查一次余额并展示确认 UI,但真正能否扣款仍以服务端支付处理结果为准
服务端:订阅支付通知
> 重要:必须为每个在线用户调用 PaymentServer.SubscribePayment(user)。不调用会导致: > > - 星火币购买商品失败:扣币请求会因等不到服务端确认而超时,星火币不会被扣除,商品无法发放 > > 真实货币充值不受此影响——充值的星火币会直接入账到支付服务器数据库,不需要游戏服务端确认。但 OnPaymentDelivered 事件不会触发,游戏内的余额显示也不会自动更新。 > > 如果你使用的是 ShopWindow + ShopSystemServer(即 GameSystemUI 内置商店),订阅/取消订阅已自动处理,无需额外操作。下面的代码仅适用于完全自定义支付流程的情况。
调用 SubscribePayment 后,框架会自动处理支付通知:
- 创建
PaymentDeliveryContext(包含通知数据和事务操作器) - 调用已注册的
PaymentHandler,Handler 将商品发放操作追加到事务 - 框架自动追加星火币扣除操作,与商品发放在同一原子事务中提交
- 如果提交结果出现名称冲突等异常,框架查询该订单是否已经结算;未结算时尝试不重复扣币的退化发货,退化发货仍失败时再进入项目内付费货币补偿
- 向支付服务器发送确认
- 按最终结算结果触发
OnPaymentDelivered或OnPaymentCompensated
服务端:查询当前项目累计消耗
如果业务需要判断玩家在本项目中累计消耗了多少星火币,应使用服务端的 PaymentServer.QueryCurrentProjectConsumptionAsync。它按当前项目和当前游戏局中的用户汇总已经结算的 payment_to_game.gemstone;Host 会校验用户属于当前游戏局,并固定项目 ID。
csharp
#if SERVER
var consumed = await PaymentServer.QueryCurrentProjectConsumptionAsync(user);
if (consumed is long value)
{
Game.Logger.LogInformation("当前项目累计消耗: {Consumption}", value);
}
#endif此值是服务端权威统计,只包含当前项目、当前用户、已结算订单;不能用客户端 PaymentBalanceInfo.TotalCoin 替代,也不能让客户端直接查询其他玩家。
PaymentHandler:处理商品发放
PaymentServer.PaymentHandler 是商品发放的核心入口。注册此委托后,收到星火币购买通知时框架会自动调用它。(使用 GameSystemUI的商店系统时,会自动注册处理,无需这一步。)
Handler 通过 context.Transaction 追加物品发放操作,返回 true 表示准备好发放。不要在 Handler 中调用 Transaction.ExecuteAsync(),框架会在 Handler 返回后追加星火币扣除并统一原子提交。
csharp
#if SERVER
using GameCore.PaymentSystem;
using GameCore.UserCloudData;
public static void OnRegisterGameClass()
{
Game.OnGameTriggerInitialization += OnGameTriggerInitialization;
}
private static void OnGameTriggerInitialization()
{
if (Game.GameModeLink != ScopeData.GameDataGameMode.MapGameMode) return;
// 注册 PaymentHandler:根据 ProductionId 发放商品
PaymentServer.PaymentHandler = HandlePaymentAsync;
// 用户连接时订阅支付通知(必须)
var connectTrigger = new Trigger<EventPlayerUserConnected>((sender, data) =>
{
var user = data.Player?.User;
if (user is not null)
PaymentServer.SubscribePayment(user);
return Task.FromResult(true);
});
connectTrigger.Register(Game.Instance);
// 用户断开时取消订阅
var disconnectTrigger = new Trigger<EventPlayerUserDisconnected>((sender, data) =>
{
var user = data.User;
if (user is not null)
PaymentServer.UnsubscribePayment(user);
return Task.FromResult(true);
});
disconnectTrigger.Register(Game.Instance);
// 监听支付完成事件(商品已发放、星火币已扣除,此处可做额外逻辑)
PaymentServer.OnPaymentDelivered += (user, notification) =>
{
Game.Logger.LogInformation(
"用户 {UserId} 支付成功: {Gemstone} 星火币, 订单 {OrderId}",
notification.UserId, notification.Gemstone, notification.OrderId);
};
}
private static Task<bool> HandlePaymentAsync(User user, PaymentDeliveryContext context)
{
var notification = context.Notification;
var productionId = notification.ProductionId;
// 根据 productionId 在业务自己的商品表中查找配置,追加发放操作
// 例如发放 100 金币:
context.Transaction.AddCurrency("gold", 100);
// 设置后台商品购买统计。框架仅在商品发放和星火币扣除均成功后写入。
context.SetPurchaseStat(new PurchaseStat
{
ItemLink = productionId,
CostLink = "星火币",
Count = 1,
Price = (int)notification.Gemstone,
IsRealMoney = true,
});
// 返回 true 表示商品已准备好发放
// 框架会自动追加星火币扣除,并在同一原子事务中提交
return Task.FromResult(true);
}
#endif这里的“查找商品配置”是业务层责任,不是 PaymentServer 自动反查数编。若商品配置使用 GameDataShopItem 或其他 GameData,请在 Game.OnGameDataInitialization 等双端一致的初始化路径注册;若商品只属于自定义支付逻辑,也可以使用普通字符串 ID,并在服务端维护显式映射表。
> 原子性与恢复:正常情况下,商品发放和星火币扣除在同一个 ScoreArchive 事务中提交。若事务因 NameAlreadyExists 等异常返回失败,不能只根据失败结果判断商品是否已经发放;框架会查询统一结算标记,必要时先走不重复扣币的退化发货,再在退化发货失败后尝试项目内付费货币补偿。
扣费成功后的退化发货与项目货币补偿
支付事务可能在服务端已经完成部分底层处理后返回名称冲突,或因为 PaymentHandler 追加的其他操作导致整个事务提交失败。为避免重复扣除星火币,支付框架为 GameSystemUI 商店提供了按订单恢复的互斥结算路径:
- 查询订单的统一结算状态。已正式发货或已补偿的订单直接视为已经处理,不再执行其他路径。
- 订单尚未结算时,尝试退化发货。该事务仍会调用商店的发货逻辑,但不会再次追加星火币扣除。
- 退化发货仍失败时,按本次实际扣除的星火币数量返还商品配置中
IsPremiumCurrency = true的项目内付费货币。 - 正式发货、退化发货和补偿共用同一订单结算标记,三者互斥;其中一个成功后,其他路径不会再次成功处理同一订单。
GameSystemUI 会从商品 Cost.Resource 读取项目货币的 CloudDataKey,并使用支付通知中的 Gemstone 作为补偿数量。因此,如果商品总价为 6、用户原本已有 2 个项目付费货币、实际通过星火币支付剩余 4 个,补偿只返还本次实际扣除的 4 个,不会重复返还原有的 2 个。
GameSystemUI 还会在补偿成功后把订单写入独立的 shop_payment_compensations 记录,供项目查询补偿审计;该记录不是普通商品购买历史,也不是所有自定义支付流程都会自动生成。
> 自定义支付边界:如果项目绕过 GameSystemUI,直接使用 PaymentClient + PaymentServer,框架不知道 ProductionId 对应哪种项目内付费货币,也不会自动生成项目的补偿记录。项目必须自行注册并实现 PaymentServer.PaymentCompensationHandler,在 context.Transaction 中追加等值货币返还操作;OnPaymentCompensated 只是补偿成功后的通知,不能替代补偿 Handler。
csharp
#if SERVER
private static void RegisterPayment()
{
PaymentServer.PaymentHandler = HandlePaymentAsync;
PaymentServer.PaymentCompensationHandler = HandlePaymentCompensationAsync;
}
private static Task<bool> HandlePaymentCompensationAsync(
User user, PaymentCompensationContext context)
{
var notification = context.Notification;
// 项目必须按 ProductionId 查找商品配置并校验金额、货币类型。
// 这里的 key 仅为示例,不应在真实项目中无条件硬编码。
const string currencyKey = "premium_currency";
context.Transaction.AddCurrency(currencyKey, notification.Gemstone);
return Task.FromResult(true);
}
#endif自定义补偿 Handler 至少应校验 ProductionId、商品价格、notification.Gemstone 和项目付费货币映射;不能把补偿逻辑写入原始 PaymentHandler,也不要在 OnPaymentCompensated 中才首次执行返还。
> 购买日志:使用 GameSystemUI 内置商店时,系统会自动写入后台商品购买统计。完全自定义支付流程中,建议在 PaymentHandler 内调用 context.SetPurchaseStat(...) 补充商品语义;若未设置,框架会用 ProductionId 和实际星火币扣除额生成兜底统计。
商品购买统计
后台“商品购买记录”来自受限的商品购买统计。SDK 不开放任意事件名或任意字段写入,只提供购买统计语义:
- GameSystemUI 内置商店会在购买成功后自动写入。
- 自定义星火币支付流程应在
PaymentServer.PaymentHandler里调用context.SetPurchaseStat(...)。框架会等商品发放和星火币扣除事务成功后再写入。 - 完全自建的非星火币商店,如果不走
PaymentServer,可以在自己的扣款和发货事务成功后调用PurchaseStatReporter.EmitBuyStat(user.UserId, stat)。不要在事务提交前调用,避免后台出现未实际成交的商品记录。
PurchaseStat 字段含义:
| 字段 | 含义 |
|---|---|
ItemLink | 商品 ID / 商品 link,对应后台 link |
CostLink | 消耗资源 ID / 资源 link,对应后台 cost_link;星火币可传 "星火币" |
Count | 购买数量 |
Price | 商品单价 |
IsRealMoney | 是否为星火币消耗;为 true 时后台会按星火币展示消耗资源 |
安全说明
为什么支付流程需要客户端发起、服务端验证?
星火币是玩家用真实货币购买的平台资产。支付流程采用客户端发起 → 平台 SDK 收款 → 支付服务器通知游戏服务端 → 服务端验证并发货的模式,确保:
- 扣款由平台支付服务器执行,游戏代码无法伪造或篡改扣款金额
- 商品发放必须经过服务端
PaymentHandler验证,客户端无法单方面伪造发货 - 所有订单、确认、扣款记录均持久化在平台数据库中,可追溯审计
支付通道的安全隔离由 C++ Host 层保证:
- 通道名由 Host 构造:WASM 沙盒内的代码只需传
userId,无法直接操作底层通道 - 跨项目隔离:不同项目的支付通知互不可见
- 确认验证:支付确认只能发送到合法的支付服务器回调通道
快速开始
1. 在编辑器中配置数据
商店系统基于以下数据结构:
GameDataShop(商店)
├── Categories: List<GameDataShopCategory>(分类列表)
│ └── Items: List<GameDataShopItem>(商品列表)
│ ├── Cost: GameDataResourceAmount(消耗资源)
│ └── Rewards: List<GameDataResourceAmount>(获得资源)
└── Resources: List<GameDataLobbyResource>(顶部资源栏配置)在编辑器的数据编辑器中创建上述数据节点。每个 GameDataLobbyResource 需要配置:
| 字段 | 说明 |
|---|---|
CloudDataKey | 对应 CloudData 中的货币键名 |
ResourceName | 显示名称(如"金币"、"钻石") |
IconPath | 图标路径 |
IsPremiumCurrency | 是否为星火币资源(可使用星火币购买)。设为 true 后,余额不足时系统自动发起平台支付 |
数据来源选择
商店数据可以来自数据编辑器,也可以由 C# 在初始化阶段注册。选择哪种方式取决于项目工作流:
- 需要策划在可视化数据编辑器中维护、预览、继承或调参时,优先使用数据编辑器 /
editor/data。 - 纯代码或纯 AI 辅助开发、且不需要编辑器管理这组商店数据时,可以在 C# 的
Game.OnGameDataInitialization回调里注册GameDataShop、GameDataShopCategory、GameDataShopItem、GameDataLobbyResource和GameDataResourceAmount。 - 同一张静态 GameData 表不要同时由 C# 和
editor/data定义同一批条目,避免客户端、服务端和编辑器预览看到不同事实来源。
用 C# 注册商品时,建议集中定义完整字符串 ID,并用该字符串构造 Link:
csharp
private const string Pack10Id =
"$GameEntry.ScopeData.GameDataShopItem.PayShop.coin_pack_10";
private static readonly GameLink<GameDataShopItem, GameDataShopItem> Pack10 =
new(Pack10Id);支付商品 ID 必须和服务端能解析到的商品 ID 完全一致。不要用短 ID 或 raw UTF-8 字面量替代需要跨端一致的完整 Link 字符串。需要按游戏模式区分数据时,在 OnGameDataInitialization 中判断 Game.GameModeLink;不要在 OnRegisterGameClass 中读取它。
2. 创建商店窗口
csharp
#if CLIENT
using GameSystemUI.ShopSystemUI.Advanced;
using GameSystemUI.ShopSystem;
public class MyShopUI
{
private ShopWindow _shopWindow;
public void CreateShop(IGameLink<GameDataShop> shopLink)
{
_shopWindow = new ShopWindow();
_shopWindow.ShopLink = shopLink;
// 监听返回按钮
_shopWindow.OnBackClicked += (s, e) => _shopWindow.Hide();
// 监听购买事件
_shopWindow.OnBuyClicked += OnBuyClicked;
// 显示商店
_shopWindow.Show();
}
private void OnBuyClicked(object? sender, (string? ItemId, int Quantity) args)
{
if (args.ItemId == null) return;
ShopSystemClient.Purchase(args.ItemId, args.Quantity);
}
}
#endif3. 监听购买结果
csharp
#if CLIENT
// 在初始化时注册事件
ShopSystemClient.OnPurchaseSuccess += (s, response) =>
{
Game.Logger.LogInformation("购买成功: {ItemId} x{Qty}", response.ItemId, response.Quantity);
_shopWindow.ShowPurchaseResult(response.ItemId, response.Quantity);
};
ShopSystemClient.OnPurchaseFailed += (s, response) =>
{
_shopWindow.ShowPurchaseFailed(ShopSystemClient.GetResultMessage(response.Result));
};
#endif这就是全部所需的代码。商店系统会自动:
- 从 CloudData 查询并显示资源余额
- 检查限购数量
- 处理虚拟货币扣除
- 当真实货币(
IsPremiumCurrency)余额不足时,自动发起平台支付
如果项目要完全自定义商店界面,仍然优先调用 ShopSystemClient.Purchase(itemId, quantity) 发起购买,而不是直接调用底层 PaymentClient.RequestPayment(...)。只要商品数据和 IsPremiumCurrency 配置正确,商店系统会继续负责服务端验单、扣款、发货、订单记录和统计。
商品配置详解
GameDataShopItem
| 字段 | 类型 | 说明 |
|---|---|---|
ItemName | string | 商品显示名称 |
ImagePath | string | 商品图片路径 |
Description | string? | 商品描述文本 |
Cost | IGameLink<GameDataResourceAmount>? | 购买消耗(关联资源类型和数量) |
Rewards | List<IGameLink<GameDataResourceAmount>> | 购买获得的资源列表 |
PurchaseLimit | int | 限购数量(0 = 不限购) |
PurchaseLimitResetType | PurchaseLimitResetType | 限购重置周期(None/Daily/Weekly/Monthly/Yearly) |
ResetInterval | int | 重置间隔(如每 2 天重置一次) |
MaxPurchasePerTransaction | int | 单次购买上限(默认 999) |
真实货币商品
将商品 Cost 关联的 GameDataLobbyResource 的 IsPremiumCurrency 设为 true,该商品即被视为真实货币商品。
购买流程差异:
| 虚拟货币商品 | 真实货币商品 | |
|---|---|---|
| 余额充足 | 直接扣除 CloudData | 直接扣除 CloudData |
| 余额不足 | 提示"资源不足" | 自动发起平台支付请求 |
| 资源栏显示 | 显示余额 | 显示余额 + "+" 充值按钮 |
周期商品(月卡/周卡)
使用 GameDataPeriodShopItem(继承自 GameDataShopItem):
| 字段 | 说明 |
|---|---|
ValidityPeriod | 有效天数(如 30 = 月卡) |
PeriodRewards | 每周期可领取的奖励 |
ClaimPeriodType | 领取周期(Daily/Weekly/Monthly) |
玩家登录、购买周期商品或支付到账后,如果服务端检测到周期奖励可领取,会向客户端发送领取提醒。默认情况下,PeriodClaimDialog 会响应提醒并显示内置领取弹窗;点击弹窗后会调用 ShopSystemClient.ClaimPeriodReward(itemId)。领取成功后弹窗自行关闭。如果 ShopWindow 实例仍然存在,它还会默认显示领取成功或失败的结果弹窗。
项目需要完全自定义领取 UI 时,可以关闭这两层默认 UI,但不会影响服务端检查、商品状态缓存、领取请求或领取结果事件:
csharp
PeriodClaimDialog.AutoShowEnabled = false; // 关闭默认领取弹窗
shopWindow.AutoShowPeriodClaimResult = false; // 关闭该 ShopWindow 的默认领取结果弹窗ShopSystemClient API
ShopSystemClient 是商店的客户端逻辑层,负责与服务端通信。
购买
csharp
// 发起购买
bool success = ShopSystemClient.Purchase("item_sword_01", quantity: 1);
// 监听结果
ShopSystemClient.OnPurchaseSuccess += (s, resp) => { /* 成功 */ };
ShopSystemClient.OnPurchaseFailed += (s, resp) => { /* 失败 */ };查询
csharp
// 获取缓存的资源余额
long gold = ShopSystemClient.GetCachedResourceBalance("gold");
// 获取商品购买状态
ShopItemState? state = ShopSystemClient.GetCachedItemState("item_sword_01");
int purchased = state?.PurchasedCount ?? 0;
// 刷新商店数据
ShopSystemClient.RefreshShop(shopLink);周期奖励
csharp
// 检查是否可领取
bool canClaim = ShopSystemClient.CanClaimPeriodReward("monthly_card");
// 领取
ShopSystemClient.ClaimPeriodReward("monthly_card");
// 收到周期商品可领取提醒;每个可领取商品触发一次
ShopSystemClient.OnPeriodClaimAvailable += (s, item) =>
{
Game.Logger.LogInformation("周期商品可领取: {ItemId}", item.ItemId);
};
// 监听领取结果
ShopSystemClient.OnPeriodClaimSuccess += (s, resp) => { /* 领取成功 */ };
ShopSystemClient.OnPeriodClaimFailed += (s, resp) => { /* 领取失败 */ };OnPeriodClaimAvailable 是专用于 UI 提醒的客户端事件。客户端收到 SyncReason.PeriodClaimReminder、更新本地商品状态缓存后,会对其中每个 CanClaim == true 的商品触发一次该事件。它可能在登录检查、周期商品购买成功或支付到账后触发;同一商品收到新的提醒推送时也可能再次触发。自定义 UI 应按 ItemId 去重或排队,并在玩家真正点击领取按钮时再调用 ClaimPeriodReward(itemId)。
替换默认周期领取 UI
csharp
#if CLIENT
using GameSystemUI.ShopSystem;
using GameSystemUI.ShopSystemUI.Advanced;
public sealed class MyPeriodClaimUI
{
public void Initialize(ShopWindow shopWindow)
{
// 应在服务端发送登录领取提醒前设置。
PeriodClaimDialog.AutoShowEnabled = false;
// 可选:关闭这个 ShopWindow 自动显示的领取成功/失败结果弹窗。
shopWindow.AutoShowPeriodClaimResult = false;
ShopSystemClient.OnPeriodClaimAvailable += OnPeriodClaimAvailable;
ShopSystemClient.OnPeriodClaimSuccess += OnPeriodClaimSuccess;
ShopSystemClient.OnPeriodClaimFailed += OnPeriodClaimFailed;
}
private void OnPeriodClaimAvailable(object? sender, ShopItemState item)
{
// 显示项目自己的领取 UI;多个商品可领取时应自行排队。
ShowCustomClaimDialog(item.ItemId, item.EndTime);
}
private void OnCustomClaimClicked(string itemId)
{
if (!ShopSystemClient.CanClaimPeriodReward(itemId)) return;
if (!ShopSystemClient.ClaimPeriodReward(itemId))
{
// 请求未发出,例如已有领取请求正在处理中;恢复按钮状态。
RestoreClaimButton(itemId);
}
}
private void OnPeriodClaimSuccess(object? sender, PeriodClaimResponse response)
{
CloseCustomClaimDialog(response.ItemId);
ShowCustomClaimResult(response.ItemId);
}
private void OnPeriodClaimFailed(object? sender, PeriodClaimResponse response)
{
RestoreClaimButton(response.ItemId);
}
}
#endif示例中的 ShowCustomClaimDialog、CloseCustomClaimDialog、ShowCustomClaimResult 和 RestoreClaimButton 代表项目自己的 UI 方法,不属于 SDK API。若自定义 UI 对象会在游戏过程中销毁,还应在销毁时从三个静态事件中取消订阅。
PeriodClaimDialog.AutoShowEnabled 是全局静态开关,只控制以后收到提醒时是否创建默认领取弹窗;它不会关闭已经创建的弹窗。ShopWindow.AutoShowPeriodClaimResult 是实例属性,只控制对应商店窗口在周期领取成功或失败后是否显示默认结果弹窗。二者默认均为 true,现有项目无需修改。
交易记录
csharp
ShopSystemClient.QueryOrders(maxCount: 50);
ShopSystemClient.OnOrdersReceived += (s, resp) =>
{
foreach (var order in resp.Orders)
{
Game.Logger.LogInformation("订单 {Id}: {Item} x{Qty}", order.OrderId, order.ItemId, order.Quantity);
}
};ShopWindow UI 组件
ShopWindow 是开箱即用的商店 UI 组件,包含:
- 顶部资源栏:显示关键货币余额,真实货币旁带"+"充值按钮
- 左侧分类菜单:自动从
GameDataShop.Categories生成 - 中间商品网格:支持虚拟化滚动,高性能渲染大量商品
- 右侧详情面板:显示商品详情、价格、购买按钮
- 购买确认弹窗:支持数量选择和滑块
- 交易记录弹窗:查看历史购买记录
常用属性和事件
csharp
// 属性
shopWindow.ShopLink = myShopLink; // 绑定商店数据
shopWindow.CurrentCategoryId; // 当前选中的分类
shopWindow.AutoShowPeriodClaimResult = false; // 关闭周期领取成功/失败的默认结果弹窗
// 事件
shopWindow.OnBackClicked // 返回按钮
shopWindow.OnBuyClicked // 点击购买(需调用 ShopSystemClient.Purchase)
shopWindow.OnCategoryChanged // 分类切换
shopWindow.OnItemSelected // 商品选中
shopWindow.OnTransactionHistoryClicked // 查看交易记录
// 方法
shopWindow.Show(); // 显示商店
shopWindow.Hide(); // 隐藏商店
shopWindow.SelectCategory("weapons"); // 程序化切换分类
shopWindow.ShowPurchaseResult(itemId, qty); // 显示购买成功动画
shopWindow.ShowPurchaseFailed("资源不足"); // 显示购买失败提示完整示例
csharp
#if CLIENT
using GameSystemUI.ShopSystemUI.Advanced;
using GameSystemUI.ShopSystem;
public partial class MyGame
{
private ShopWindow? _shop;
public void OpenShop(IGameLink<GameDataShop> shopLink)
{
if (_shop == null)
{
_shop = new ShopWindow();
_shop.OnBackClicked += (s, e) => _shop.Hide();
_shop.OnBuyClicked += OnBuyClicked;
ShopSystemClient.OnPurchaseSuccess += OnPurchaseSuccess;
ShopSystemClient.OnPurchaseFailed += OnPurchaseFailed;
}
_shop.ShopLink = shopLink;
_shop.Show();
}
private void OnBuyClicked(object? sender, (string? ItemId, int Quantity) args)
{
if (args.ItemId != null)
ShopSystemClient.Purchase(args.ItemId, args.Quantity);
}
private void OnPurchaseSuccess(object? sender, ShopPurchaseResponse resp)
{
_shop?.ShowPurchaseResult(resp.ItemId, resp.Quantity);
}
private void OnPurchaseFailed(object? sender, ShopPurchaseResponse resp)
{
_shop?.ShowPurchaseFailed(ShopSystemClient.GetResultMessage(resp.Result));
}
}
#endif上线与排查清单
支付链路涉及平台资产,发布前建议至少完成以下检查:
- 所有支付商品使用完整且稳定的商品 ID;客户端
itemId/productId与服务端GameDataShopItemID 完全一致。 - 星火币商品的
Cost.Resource.Data.IsPremiumCurrency == true。 - 请求价格直接来自商品
Cost.Amount,不要在 UI 中另写一份价格常量。 - 奖励只由服务端商店系统或底层
PaymentHandler发放,客户端不在请求发出后直接发奖励。 - 购买按钮有处理中锁,避免连续点击生成多个真实订单。
ShopSystemClient.OnPurchaseSuccess/OnPurchaseFailed等静态事件只注册一次;可销毁 UI 在销毁时取消订阅。PaymentClient.GetCurrentBalance()只在打开页面、购买前、支付成功后等低频场景调用;已废弃的PaymentClient.GetBalanceInfo()不再用于展示累计充值或项目消费。Client-Debug、Server-Debug均可编译;发布前建议同时编译Client-Release、Server-Release。- 发布后在平台环境完成一笔低价真实订单测试,确认扣款、发货、资源同步、订单记录和后台统计都正确。
- 涉及充值星火币或余额不足拉起平台支付时,在创作者中心检查「运营配置 -> 游戏基础配置 -> 游戏大厅配置」中的「进入游戏保留大厅局」设置符合项目预期。
常见现象速查:
| 日志或现象 | 常见原因 | 处理 |
|---|---|---|
商品不存在 / ItemNotFound | 客户端 ID 与服务端商品 ID 不一致,或商品只在客户端动态创建 | 统一使用服务端也能解析的完整商品 ID,并保证商品数据双端一致 |
Platform product cost resource is not premium currency | 商品 Cost 指向的资源没有设置 IsPremiumCurrency | 在 GameDataLobbyResource 上设置 IsPremiumCurrency = true |
| 金额不符 | RequestPayment.price 与商品 Cost.Amount 不一致 | 价格直接读取商品 Cost,不在 UI 中重复写常量 |
| 请求已发出但没有奖励 | 把 RequestPayment 返回值当成支付成功 | 等 ShopSystemClient.OnPurchaseSuccess 或服务端 OnPaymentDelivered |
| 同一玩家短时间出现多笔订单 | 购买按钮未锁定或事件重复订阅 | 请求发出后锁按钮,检查初始化逻辑只注册一次事件 |
| 底层支付等不到确认 | 自定义底层流程没有订阅用户支付通道 | 用户连接时 SubscribePayment,断开时 UnsubscribePayment |
排查运行时问题时优先查看编辑器安装目录下的实时日志:
- 服务端:
<EditorRoot>/logs/server - 客户端:
<EditorRoot>/logs/client
项目根目录中的 Client-Debug.log / Server-Debug.log 通常只是构建输出,不是实际游玩日志。
常见问题
使用 ShopWindow(内置商店)
Q: 星火币购买商品失败?
如果你使用的是 GameSystemUI 内置商店(ShopWindow + ShopSystemServer),订阅、商品发放、星火币扣除均已自动处理。请检查:
- 商品的
Cost关联的资源是否正确设置了IsPremiumCurrency = true - 商品的
Rewards是否配置了有效的资源和数量
Q: 如何实现充值页面?
在商店数据中创建一个"充值"分类,其中商品的 Rewards 包含 IsPremiumCurrency = true 的资源。玩家点击资源栏的"+"按钮会自动跳转到该分类。
Q: 如何知道支付是否成功?
监听 ShopSystemClient.OnPurchaseSuccess 事件即可。支付成功后,货币会自动到账并更新余额显示。
Q: 虚拟货币和真实货币可以混用吗?
可以。同一个商店中可以同时存在虚拟货币商品和真实货币商品,系统会根据 IsPremiumCurrency 标记自动选择正确的购买流程。
Q: 如何自定义商品卡片的外观?
ShopItemCard 和 ShopItemRow 支持通过 IGameLink<GameDataControl> 模板自定义外观。在编辑器中创建对应的控件模板数据即可。
Q: 限购重置后购买次数会清零吗?
是的。根据 PurchaseLimitResetType 配置,限购计数会在对应的时间点(每日/每周/每月)自动重置。
使用底层支付 API(自定义支付流程)
Q: 星火币购买失败?
常见原因:
- 没有调用
PaymentServer.SubscribePayment(user)— 支付服务器等不到游戏服务端确认,购买请求会超时失败(星火币不会被扣)。请确保在用户连接时调用SubscribePayment,断开时调用UnsubscribePayment。 - 没有注册
PaymentServer.PaymentHandler— 框架没有商品发放逻辑,会直接拒绝订单。
Q: PaymentClient.GetCurrentBalance() 可以用来查其他玩家余额吗?
不可以。这个接口没有 userId 参数,C++ Host 层会固定查询当前客户端登录用户的余额,避免泄漏其他玩家隐私。
Q: PaymentClient.GetCurrentBalance() 适合高频轮询吗?
不适合。它会直接访问支付服务,推荐只在页面打开、购买前或支付完成后刷新这类低频场景使用。如果你需要高频 UI 更新,请自行在业务层做短时缓存或显式“刷新余额”按钮。
Q: PaymentHandler 中可以调用 Transaction.ExecuteAsync() 吗?
不可以。框架会在 Handler 返回后自动追加星火币扣除操作并统一提交。如果在 Handler 中提前调用 ExecuteAsync(),商品发放和星火币扣除会变成两个独立事务,失去原子性保证。
Q: PaymentHandler 返回 false 会怎样?
框架不会提交任何事务,也不会扣除星火币。支付服务器会收到失败确认,订单会被标记为失败。
Q: 日志出现 NameAlreadyExists,是否说明商品已经发放?
不一定。它表示某个事务中的名称占用操作发生冲突,可能来自订单结算标记,也可能来自 PaymentHandler 追加的其他操作。框架会先查询统一结算状态;若订单未结算,会依次尝试退化发货和项目货币补偿。应结合后续的 Payment delivered、Payment settled by compensation、Payment fallback 和 Payment delivery failed 日志判断最终结果。
Q: 不使用 GameSystemUI 商店,是否也会自动返还项目付费货币?
不会。直接使用 PaymentClient + PaymentServer 时,项目必须自行注册 PaymentCompensationHandler,根据自己的商品表将 notification.Gemstone 映射为应返还的项目内付费货币,并在独立的 context.Transaction 中追加返还操作。OnPaymentCompensated 只适合刷新余额、记录审计或通知 UI。
Q: 如何查询是否已经补偿?
GameSystemUI 的补偿记录写入 shop_payment_compensations,项目可以按自己的数据访问方式查询该列表并按 PlatformOrderId 查找订单。自定义支付流程需要自行设计并写入等价的补偿审计记录。
Q: 如何知道支付是否成功?
使用 GameSystemUI 时,客户端可监听 ShopSystemClient.OnPurchaseSuccess;服务端可监听 PaymentServer.OnPaymentDelivered 表示商品已发放,或监听 PaymentServer.OnPaymentCompensated 表示商品未发放但项目货币已补偿。底层自定义流程也可以监听这两个服务端事件,但必须自行注册对应 Handler。
支付合规须知
> 星火币是玩家使用真实货币购买的平台资产,请严肃对待所有涉及星火币扣除的操作。
开发者责任(使用底层支付API而非商店系统时)
- 明确的确认流程:所有涉及星火币消耗的购买操作,如果使用底层支付API,必须在客户端提供醒目的确认界面,让玩家明确知晓即将消耗的星火币数量和获得的商品内容,由玩家自主确认后再发起支付请求。
- 等价交换:商品定价和实际发放内容必须一致,不得以虚假或误导性的商品描述诱导玩家消费。
- 不得滥用底层 API:底层支付 API 赋予开发者灵活性,但这不意味着可以绕过合理的用户确认流程。禁止在玩家不知情或未明确同意的情况下发起扣款。
平台监管
- 上架审核:所有使用星火币支付的游戏在上架前会经过平台审核,审核内容包括支付流程的合理性和用户体验。
- 全链路审计:所有订单创建、确认、星火币扣除和商品发放记录均持久化在平台数据库中,可随时追溯。
- 违规处理:若有证据表明开发者滥用支付 API 骗取玩家星火币,平台将退还相关星火币收入,并保留追究法律责任的权利。