套餐功能包提供开箱即用的功能,允许以折扣价格向玩家出售物品集合。您可以选择是否允许玩家使用自定义体验内货币或Robux购买套餐,您想要使用的套餐类型,您想要出售的物品集合,以及您希望在玩家游戏时如何提示他们。
借助该包的自定义选项,您可以根据体验的设计和货币化目标量身定制您的套餐,例如:

获取包
创作者商店是工具箱中的一个标签,您可以使用它查找由Roblox及其社区制作的所有资产,用于您的项目,包括模型、图像、网格、音频、插件、视频和字体资产。您可以使用创作者商店将一个或多个资产直接添加到一个打开的体验中,包括功能包!
每个功能包都需要核心功能包才能正常运行。一旦核心和套餐功能包资产在您的库存中,您可以在平台的任何项目中复用它们。
要将包从您的库存导入体验中:
通过点击以下组件集中的添加到库存链接,将核心和套餐功能包添加到Studio中的库存。
在工具栏中,选择视图标签。
点击工具箱。工具箱窗口将显示。
在工具箱窗口中,点击库存标签。我的模型排序显示。
点击功能包核心图块,然后点击套餐功能包图块。两个包文件夹将在资源管理器窗口中显示。
将包文件夹拖入ReplicatedStorage。
允许数据存储调用以跟踪玩家的购买情况。
- 打开Studio的文件 ⟩ 游戏设置窗口。
- 导航到安全性标签,然后启用启用Studio访问API服务。
定义货币
如果您的体验有自己的货币系统,您可以通过在ReplicatedStorage.FeaturePackagesCore.Configs.Currencies中定义它们,将其注册到核心功能包中。这个文件中已经有一个注释掉的Gem货币示例;请将其替换为您自己的。
货币
Gems = {displayName = "宝石",symbol = "💎",icon = nil,},
Currencies脚本告诉核心功能包有关您的货币的一些元数据:
- (必需) displayName - 您的货币名称。如果您没有指定符号或图标,则此名称将用于购买按钮(例如,"100 宝石")。
- (可选) symbol - 如果您有一个文本字符可作为您货币的图标,则在购买按钮中使用该符号而不是displayName(例如,"💎100")。
- (可选) icon - 如果您有一个AssetId图像图标用于您的货币,则在购买按钮中使用该图标而不是displayName(例如,图像将放置在价格左侧 "🖼️100")。
一旦您的货币设置完成,您需要手动指定套餐的价格、货币和图标,而不是从套餐关联的开发者产品中获取这些信息。
套餐
-- 如果您想使用开发产品,您必须提供一个唯一的devProductId,仅用于一个套餐。-- 我们将从开发者产品获取套餐价格和图标pricing = {priceType = CurrencyTypes.PriceType.Marketplace,devProductId = 1795621566,},-- 否则,如果您想使用体验内货币而不是开发产品,则可以使用以下内容:-- 这里的价格是体验内货币,而不是Robuxpricing = {priceType = CurrencyTypes.PriceType.InExperience,price = 79,currencyId = "Gems",icon = 18712203759,},
您还需要引用BundlesExample脚本以调用setInExperiencePurchaseHandler。
BundlesExample
local function awardInExperiencePurchase(
_player: Player,
_bundleId: Types.BundleId,
_currencyId: CurrencyTypes.CurrencyId,
_price: number
)
-- 检查玩家是否有足够的货币来购买套餐
-- 更新玩家数据,给予物品等。
-- 从玩家处扣除货币
task.wait(2)
return true
end
local function initializePurchaseHandlers()
local bundles = Bundles.getBundles()
for bundleId, bundle in bundles do
-- 如果套餐没有与开发者产品关联,则它不与开发者产品关联
if not bundle or bundle.pricing.priceType ~= "Marketplace" then
continue
end
Bundles.setPurchaseHandler(bundleId, awardMarketplacePurchase)
receiptHandlers[bundle.pricing.devProductId] = receiptHandler
end
-- 如果您有任何体验内货币用于套餐,请在此处设置处理程序
for currencyId, _ in Currencies do
Bundles.setInExperiencePurchaseHandler(currencyId, awardInExperiencePurchase)
end
end
特别是,您需要填写awardInExperiencePurchase,该函数由Currencies中的循环调用,在示例initializePurchaseHandlers中(即,每个currencyId通过Bundles.setInExperiencePurchaseHandler(currencyId, awardInExperiencePurchase)连接到处理程序)。
定义套餐
您可以在ReplicatedStorage.Bundles.Configs.Bundles中定义所有可以在体验中提供的套餐,类型从同一文件夹中的Types脚本中导出。
如果您正在使用devProductId,则需要将套餐的主要devProductId更新为与您体验中的ID相匹配。这将通过MarketplaceService提示以购买套餐。强烈建议为套餐使用一个新的开发产品,以便于跟踪单独的销售。
如果您想要一个包含多个物品的套餐,并且这些物品已经在您的体验中表示为开发产品,您无需显式设置物品的价格/assetId/名称,这些信息将通过产品信息获取:
README
{itemType = ItemTypes.ItemType.DevProduct,devProductId = <DEV_PRODUCT_ID>,metadata = {caption = {text = "x1",color = Color3.fromRGB(236, 201, 74),} -- Caption是可选的!您也可以省略此字段}},
否则,您可以手动配置这些物品的详细信息:
README
{itemType = ItemTypes.ItemType.Robux,priceInRobux = 49,icon = <IMAGE_ASSET_ID>,metadata = {caption = {text = "x1",color = Color3.fromRGB(236, 201, 74),} -- Caption是可选的!您也可以省略此字段}},
例如,您的整个套餐可能看起来像这样:
README
local starterBundle: Types.RelativeTimeBundle = {bundleType = Types.BundleType.RelativeTime,-- 如果您想使用开发产品,您必须提供一个唯一的devProductId,仅用于一个套餐。-- 我们将从开发者产品获取套餐价格和图标pricing = {priceType = CurrencyTypes.PriceType.Marketplace,devProductId = <DEV_PRODUCT_ID>,},-- 否则, 如果您想使用体验内货币而不是开发产品,则可以使用以下内容:-- 这里的价格是体验内货币,而不是Robux-- pricing = {-- priceType = CurrencyTypes.PriceType.InExperience,-- price = 79,-- currencyId = <CURRENCY_ID>,-- icon = <IMAGE_ASSET_ID>,-- },includedItems = {[1] = {-- 物品本身不是通过开发者产品出售的,因此请指明它在Robux中的价值并给予图标-- priceInRobux帮助套餐显示套餐价格与其内容总和的相对价值itemType = ItemTypes.ItemType.Robux,priceInRobux = 49,icon = <IMAGE_ASSET_ID>,-- 或者,如果包含开发产品,请去掉上面的价格和图标,仅设置devProductId-- 价格和图标将从开发者产品中获取-- devProductId = <ITEM_DEV_PRODUCT_ID>-- 如果需要,还有更多选项元数据字段是UI特定的metadata = {caption = {text = "x1",color = Color3.fromRGB(236, 201, 74),},},},[2] = {itemType = ItemTypes.ItemType.Robux,priceInRobux = 99,icon = <IMAGE_ASSET_ID>,metadata = {caption = {text = "x1",color = Color3.fromRGB(236, 201, 74),},},},[3] = {itemType = ItemTypes.ItemType.Robux,priceInRobux = 149,icon = <IMAGE_ASSET_ID>,metadata = {caption = {text = "x1",color = Color3.fromRGB(236, 201, 74),},},},},singleUse = true, -- 一旦购买或过期,将不再有效,即使您的体验尝试提示(onPlayerAdded)。您可以在Studio中测试时将其设置为false。durationInSeconds = 900, -- 15分钟includesOfflineTime = false, -- 仅计算在体验中消耗的时间metadata = {displayName = "初学者套餐",description = "节省75%,助您起步!",},}
集成服务器逻辑
查看ReplicatedStorage.Bundles.Server.Examples.BundlesExample,它展示了您的服务器如何与套餐功能包及其上面的ModuleScript方法交互。以下代码片段来自该脚本。
您主要需要在将套餐功能包拖入体验后连接四样东西:
通过Bundles.setPurchaseHandler连接购买处理程序,以指定在处理购买时要调用的函数,以奖励物品。
BundlesExamplelocal function awardMarketplacePurchase(_player: Player, _bundleId: Types.BundleId, _receiptInfo: { [string]: any })-- 更新玩家数据,给予物品等。-- ... 记录receiptInfo.PurchaseId,以便检查用户是否已经拥有此套餐task.wait(2)return Enum.ProductPurchaseDecision.PurchaseGrantedendlocal function awardInExperiencePurchase(_player: Player,_bundleId: Types.BundleId,_currencyId: CurrencyTypes.CurrencyId,_price: number)-- 检查玩家是否有足够的货币来购买套餐-- 更新玩家数据,给予物品等。-- 从玩家处扣除货币task.wait(2)return trueendlocal function initializePurchaseHandlers()local bundles = Bundles.getBundles()for bundleId, bundle in bundles do-- 如果套餐没有与开发者产品关联,则它不与开发者产品关联if not bundle or bundle.pricing.priceType ~= "Marketplace" thencontinueendBundles.setPurchaseHandler(bundleId, awardMarketplacePurchase)receiptHandlers[bundle.pricing.devProductId] = receiptHandlerend-- 如果您有任何体验内货币用于套餐,请在此处设置处理程序for currencyId, _ in Currencies doBundles.setInExperiencePurchaseHandler(currencyId, awardInExperiencePurchase)endend连接您的逻辑以处理MarketplaceService.ProcessReceipt,但如果您的体验已经有开发产品销售,可能会在其他地方完成。基本上,当处理开发产品收据时,它们将调用Bundles.getBundleByDevProduct来检查该产品是否属于一个套餐。如果是,则脚本将调用Bundles.processReceipt。
BundlesExample-- 从市场处理收据以确定玩家是否需要被收取费用local function processReceipt(receiptInfo): Enum.ProductPurchaseDecisionlocal userId, productId = receiptInfo.PlayerId, receiptInfo.ProductIdlocal player = Players:GetPlayerByUserId(userId)if not player thenreturn Enum.ProductPurchaseDecision.NotProcessedYetendlocal handler = receiptHandlers[productId] -- 获取该产品的处理程序local success, result = pcall(handler, receiptInfo, player) -- 调用处理程序检查购买逻辑是否成功if not success or not result thenwarn("处理收据失败:", receiptInfo, result)return Enum.ProductPurchaseDecision.NotProcessedYetendreturn Enum.ProductPurchaseDecision.PurchaseGrantedendlocal function receiptHandler(receiptInfo: { [string]: any }, player: Player)local bundleId, _bundle = Bundles.getBundleByProductId(receiptInfo.ProductId)if bundleId then-- 该购买属于套餐,让Bundles处理它local purchaseDecision = Bundles.processReceiptAsync(player, bundleId, receiptInfo)return purchaseDecision == Enum.ProductPurchaseDecision.PurchaseGrantedend-- 该购买不属于套餐,-- ... 如果您有任何现有逻辑,在这里处理它return falseend连接Players.PlayerAdded:Connect(Bundles.OnPlayerAdded),以便套餐功能包会在玩家未过期的任何活动套餐上重新提示。
READMElocal function onPlayerAdded(player: Player)-- 告诉Bundles玩家加入时加载他们的数据Bundles.onPlayerAdded(player)-- 如果您有一些初学者套餐希望为所有新用户提供,则可以在此处提示-- ... Bundles将处理玩家是否已购买或是否过期,因为它不可重复-- Bundles.promptIfValidAsync(player, "StarterBundle")-- 这里调用只是为了示例,您可以在任何时候或任何地方调用此函数onPromptBundleXYZEvent(player)end提示套餐。虽然这取决于游戏玩法,但示例在onPlayerAdded中提示玩家的初学者套餐。
套餐功能包逻辑确保每个玩家不会在已购买套餐的情况下收到重复报价,或者如果他们让报价过期(根据套餐配置)。
每当您希望向玩家提示套餐时,请调用Bundles.promptIfValidAsync(player, bundleId)。
READMElocal function onPromptBundleXYZEvent(player: Player)-- 连接您想用来确定玩家何时提示套餐的体验事件-- ... 这将是在您满足资格标准为玩家提示套餐时-- ... 例如,如果您希望在玩家加入时提示套餐,或当玩家升级时task.spawn(Bundles.promptIfValidAsync, player, <Some_Bundle_Id>)-- ... 如果创建多个套餐,使用task.spawn()包装上述函数调用将最小化倒计时之间的差异end
请考虑以下关于冗余记录ReceiptIds的最佳实践指导:
虽然套餐功能包确实记录ReceiptIds以避免处理同一收据两次,但您还应该在表中记录ReceiptIds,以便如果购买流程在购买处理程序已经完成后失败,您知道在后续重试时不要再次授予物品。
如果在任何步骤购买失败,套餐功能包将不会记录ReceiptId,因此您应该确保在处理收据之前在表中记录ReceiptId,作为您的purchaseHandler的一部分。
这种冗余有助于确保所有购买逻辑已被妥善处理,并且您的数据存储和套餐功能包的数据存储最终达到一致性,您的数据存储作为真实数据源。
配置常量
核心功能包的常量位于两个位置:
共享常量位于ReplicatedStorage.FeaturePackagesCore.Configs.SharedConstants中。
包特定的常量,在这种情况下是套餐功能包,位于ReplicatedStorage.Bundles.Configs.Constants中。
您可能希望调整的主要内容以满足体验的设计要求:
- 声音资产ID
- 购买效果持续时间和粒子颜色
- Heads-up显示的可折叠性
此外,您可以找到用于翻译的字符串划分到一个位置:ReplicatedStorage.FeaturePackagesCore.Configs.TranslationStrings。
自定义UI组件
通过修改包对象,例如颜色、字体和透明度,您可以调整套餐提示的视觉呈现。请注意,如果您在层次结构中移动任何对象,代码将无法找到它们,您需要对代码进行调整。
一个提示包括两个高级组件:
- PromptItem – 为套餐中的每个物品(物品图像、说明、名称、价格)重复出的单个组件。
- Prompt – 提示窗口本身。
Heads-up显示也由两个组件组成:
- HudItem – 表示heads-up显示中每个菜单选项的单个组件。
- Hud – 将用HudItems进行编程填充。
如果您想在heads-up显示上有更大的控制权,而不仅仅是在ReplicatedStorage.Bundles.Objects.BundlesGui中使用现有的HUD UI,您可以移动物体以满足自己的设计要求。只需确保在ReplicatedStorage.Bundles.Client.UIController脚本中更新客户端脚本行为。
API参考
类型
RelativeTime
一旦将RelativeTime套餐提供给玩家,它将保持可用,直到时间持续时间结束。这种类型在玩家的heads-up显示上显示,并在未来的会话中自动提示,直到套餐过期或玩家购买它。
此类型的一个常见示例是对于所有新玩家提供的单次使用初学者包优惠,持续24小时。有关如何实施初学者包套餐的行业最佳实践,请参阅初学者包设计。
名称 | 类型 | 描述 |
---|---|---|
includeOfflineTime | bool | (可选) 如果未设置,只有在体验中的时间才会计算为剩余优惠持续时间。 |
singleUse | bool | (可选) 如果未设置,购买可以在购买或过期后重新激活。 如果设置,则一次购买或过期后,将不会再提示,即使您通过bundleId调用Bundles.promptIfValidAsync。 |
FixedTime
一旦将FixedTime套餐提供给玩家,它将保持可用,直到设定的协调世界时(UTC)结束。这种类型在玩家的heads-up显示上显示,并在未来的会话中自动提示,直到套餐过期或玩家购买它。
此类型的一个常见示例是仅在给定月份可用的节日优惠。
OneTime
OneTime套餐仅在提供给玩家的瞬间可用。它不会显示在玩家的heads-up显示上,一旦玩家关闭提示,它无法再次打开,直到服务器再次提示。
此类型的一个常见示例是当玩家用完时购买更多体验内货币的优惠。