套餐功能包提供了开箱即用的功能,允许向玩家销售折扣商品集合。您可以选择是否允许玩家使用自定义的体验内货币或Robux购买套餐,选择使用的套餐类型,您希望销售的商品集,以及在游戏过程中如何提示玩家。
使用该包的自定义选项,您可以根据体验的设计和货币化目标来调整您的套餐,例如:
- 通过提供有价值的折扣入门包来针对低转化率指标,从而鼓励新玩家的早期消费。
- 通过将各个价格点的商品捆绑在一起,增加消费深度,以吸引多种类型的玩家。
- 通过提供限时的独特商品套餐来货币化实时运营(LiveOps)活动。

获取套餐
创作者商店是工具箱的一个选项卡,您可以用来查找Roblox和Roblox社区制作的所有可用于您项目的资产,包括模型、图像、网格、音频、插件、视频和字体资产。您可以使用创作者商店直接将一个或多个资产添加到打开的体验中,包括功能包!
每个功能包都需要核心功能包才能正常工作。一旦核心和套餐功能包资产在您的库存中,您可以在平台上的任何项目中重复使用它们。
要将库存中的包添加到您的体验中:
在Studio中通过点击以下组件中的添加到库存链接,将核心和套餐功能包添加到您的库存中。
从Studio的窗口菜单或主页选项卡工具栏中,打开工具箱。
在工具箱窗口中,点击库存选项卡。我的模型排序方式将显示。

点击核心功能包图块,然后点击套餐功能包图块。这两个包文件夹将显示在资源管理器窗口中。
将包文件夹拖入ReplicatedStorage。
允许数据存储调用跟踪玩家使用这些包的购买行为。
- 打开Studio的文件 ⟩ 体验设置窗口。
- 导航到安全性选项卡,然后启用启用Studio对API服务的访问。
定义货币
如果您的体验有自己的货币系统,您可以通过在ReplicatedStorage.FeaturePackagesCore.Configs.Currencies中定义它们来在核心功能包中注册这些货币。此文件中已提供一个注释掉的Gems货币示例;请用您自己的货币替换它。
货币Gems = {displayName = "宝石",symbol = "💎",icon = nil,},
货币脚本告诉核心功能包有关您货币的一些元数据:
- (必需) 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,它会在示例initializePurchaseHandlers中的Currencies循环中被调用(即每个currencyId通过Bundles.setInExperiencePurchaseHandler(currencyId, awardInExperiencePurchase)连接到处理程序)。
定义套餐
在ReplicatedStorage.Bundles.Configs.Bundles中可以定义您体验中可提供的所有套餐,其类型由同一文件夹中的Types脚本导出。
如果您使用的是devProductId,您需要更新套餐的主要devProductId以匹配您体验中的那个。这就是通过MarketplaceService提示购买套餐的内容。强烈建议为套餐使用一个新的开发产品,以便更轻松地跟踪单独的销售。
如果您希望套餐包含多个商品,并且这些商品已在您的体验中通过开发者产品进行表示,则不需要显式设置商品价格/assetId/名称,这些信息将通过产品信息获取:
README{itemType = ItemTypes.ItemType.DevProduct,devProductId = <DEV_PRODUCT_ID>,metadata = {caption = {text = "x1",color = Color3.fromRGB(236, 201, 74),} -- 标题是可选的!您也可以省略此字段}},
否则,您可以手动配置这些商品详情:
README{itemType = ItemTypes.ItemType.Robux,priceInRobux = 49,icon = <IMAGE_ASSET_ID>,metadata = {caption = {text = "x1",color = Color3.fromRGB(236, 201, 74),} -- 标题是可选的!您也可以省略此字段}},
例如,您整个套餐可能看起来像这样:
READMElocal 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)。在测试时,您可以将其设为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中提示玩家一个StarterBundle。
套餐功能包的逻辑确保每个玩家不会在已购买套餐或让提议过期(基于套餐配置)的情况下收到重复提示。
每当您想提示一个套餐给玩家时,调用Bundles.promptIfValidAsync(player, bundleId)。
READMElocal function onPromptBundleXYZEvent(player: Player)-- 连接您想要使用的任何体验事件,以确定玩家何时被提示该套餐-- ... 这将是当您满足提示玩家套餐的资格标准时-- ... 例如,如果您想在玩家加入时提示套餐,或在玩家升级时task.spawn(Bundles.promptIfValidAsync, player, <Some_Bundle_Id>)-- ... 如果创建多个套餐,使用task.spawn()将上述函数调用包装起来,将最小化倒计时之间的差异end
考虑以下关于收据ID冗余记录的最佳实践指导:
虽然套餐功能包确实记录ReceiptIds以避免处理相同的收据两次,但您也应该在您的表中记录ReceiptIds,以便在购买流程在购买处理程序完成后失败时,您知道在后续重试时不要再次授予物品。
如果购买在任何步骤失败,套餐功能包将不会记录ReceiptId,因此您应该确保在处理收据之前在您的表中记录ReceiptId作为您购买Handler的一部分。
这种冗余有助于确保所有购买逻辑已得到适当处理,并且您的数据存储和套餐功能包的数据存储达到最终一致性,而您的数据存储是权威来源。
配置常量
核心功能包的常量存放在两个地方:
共享常量存放在ReplicatedStorage.FeaturePackagesCore.Configs.SharedConstants中。
特定于包的常量,在这种情况下是套餐功能包,存放在ReplicatedStorage.Bundles.Configs.Constants中。
您可能希望调整以满足体验的设计要求的主要内容:
- 声音资产ID
- 购买效果持续时间和粒子颜色
- 提示的可折叠性
此外,您可以找到用于翻译的字符串,集中在一个位置:ReplicatedStorage.FeaturePackagesCore.Configs.TranslationStrings。
自定义UI组件
通过修改包对象,例如颜色、字体和透明度,您可以调整套餐提示的视觉呈现。但是,请记住,如果您以层次结构移动任何对象,代码将无法找到它们,您需要对代码进行调整。
一个提示由两个高级组件组成:
- PromptItem – 重复的单个组件,每个套餐中的每个商品都有(商品图像、标题、名称、价格)。
- Prompt – 提示窗口本身。
头上显示的组件也由两个组件组成:
- HudItem – 表示头上显示中每个菜单选项的单个组件。
- Hud – 用HudItems程序化填充。
如果您想更好地控制头上显示,而不仅仅是使用ReplicatedStorage.Bundles.Objects.BundlesGui中的现有HUD UI,您可以移动内容以满足您自己的设计要求。只需确保在ReplicatedStorage.Bundles.Client.UIController脚本中更新客户端脚本行为。
API参考
类型
相对时间
一旦相对时间套餐被提供给玩家,它将在时间持续期结束之前保持可用。此类型在玩家的头上显示,并会在未来的会话中自动提示,直到套餐过期或玩家购买它。
此套餐类型的一个常见示例是一个一次性入门包提议,在24小时内向所有新玩家显示。
| 名称 | 类型 | 描述 |
|---|---|---|
| includeOfflineTime | bool | (可选) 如果未设置,则只有在体验中消耗的时间才会计入剩余优惠持续时间。 |
| singleUse | bool | (可选) 如果未设置,购买可以在其购买后或过期后重新激活。 如果设置,则一旦首次购买或过期,将永远无法重新提示,即使您调用Bundles.promptIfValidAsync与bundleId。 |
固定时间
一旦固定时间套餐被提供给玩家,它将在设定的协调世界时(UTC)结束之前保持可用。此类型在玩家的头上显示,并会在未来的会话中自动提示,直到套餐过期或玩家购买它。
此套餐类型的一个常见示例是仅在特定月份可用的节日优惠。
一次性
一次性套餐仅在提供给玩家的那一刻可用。它不会在玩家的头上显示,一旦玩家关闭提示,直到服务器再次提示时,无法重新打开。
此套餐类型的一个常见示例是在玩家用完体验内货币的那一刻提示购买更多货币的优惠。