主题
第 20 章:平台服务:聊天、跨服、社交、商业化、埋点
按运行端、安全身份和副作用边界接入聊天、跨服、传送、商业化与分析服务。
你会学到什么
- 平台服务为什么要“按场景选型”,而不是一次性全学。
TextChatService、MessageService、TeleportService的真实使用边界。- 社交、商业化、埋点服务的安全和体验要点。
- 异步平台接口为什么必须放进协程,并用
pcall保护。
平台服务怎么学
前面章节讲的是玩法核心:对象、事件、通信、UI、数据、物理、角色、资产和项目内可复用系统。平台服务更像“接入能力”:聊天、跨服消息、传送、社交邀请、商品、埋点。它们通常不属于第一轮主线,应该按项目需求逐个接入。
| 需求 | 优先服务 | 典型运行端 | 核心风险 |
|---|---|---|---|
| 队伍频道、交易频道 | TextChatService | 频道操作 server;聊天 UI client | 混用运行端、读取未公开频道字段 |
| 跨服务器公告/状态同步 | MessageService | server | 异步调用、消息不保证可靠送达 |
| 切换地图、匹配到新场景 | TeleportService | server | 传送失败、异步错误 |
| 队伍查询 | SocialService | server | 异步失败、泄露队伍传送凭据 |
| 商品、道具、会员/粉丝权益 | CommodityService | server 为主 | 购买结果和道具发放必须校验 |
| 进度、经济、漏斗、自定义行为 | AnalyticsService | server | 字段格式、事件命名、隐私与噪声 |
学习顺序建议:
- 先接聊天或埋点,因为它们对玩法侵入较小。
- 再接传送、跨服消息,因为它们涉及异步、失败和协程。
- 最后接商业化和社交,因为它们更依赖平台配置、审核和体验设计。
TextChatService:自定义聊天频道
TextChatService 可以创建自定义频道,让玩家进入或离开频道并发送频道消息。当前公开 API 对运行端已有更细约束:频道创建、进出和发消息放在 server;聊天面板与按钮的显隐放在 client。不要因为类型级 Realm 是 common,就推断每个函数都能在双端混用。
运行端:server
lua
-- @runtime server
local TextChatService = game:GetService("TextChatService")
local Players = game:GetService("Players")
if TextChatService == nil or Players == nil then
return
end
local CHANNEL_NAME = "队伍频道"
TextChatService:AddCustomTextChannel(CHANNEL_NAME)
local function JoinChannel(player)
if player == nil then return end
TextChatService:EnterTextChannel(player, CHANNEL_NAME)
TextChatService:SendMessage(player, CHANNEL_NAME, "欢迎加入队伍频道!")
end
for _, player in ipairs(Players:GetPlayers()) do
JoinChannel(player)
end
local playerAddedConnection = Players.PlayerAdded:Connect(JoinChannel)
local playerRemovingConnection = Players.PlayerRemoving:Connect(function(player)
if player == nil then return end
TextChatService:LeaveTextChannel(player, CHANNEL_NAME)
end)
game:BindToClose(function()
playerAddedConnection:Disconnect()
playerRemovingConnection:Disconnect()
TextChatService:RemoveCustomTextChannel(CHANNEL_NAME)
end)频道信息也可以通过公开接口读取,但数组项的字段结构尚未公开。当前已知客户端实现还存在运行时问题,所以只在 server 读取数量,不写 info.name 之类猜测字段:
lua
-- @runtime server
local TextChatService = game:GetService("TextChatService")
if TextChatService == nil then return end
local channels = TextChatService:GetTextChannelInfo()
print("[SE Lua Guide] 当前频道数量:", #channels)注意契约边界:
| 接口 | 当前公开调用端 |
|---|---|
AddCustomTextChannel / RemoveCustomTextChannel | server |
EnterTextChannel / LeaveTextChannel | server |
SendMessage | server |
SetOfficialTextChannelVisible | client |
SetChatButtonVisible | client |
GetTextChannelInfo | 暂按 server 使用;数组项视为不透明值 |
MessageService:跨服务器消息
MessageService 用于同一地图不同服务器之间的主题发布/订阅。它是“尽力而为”的实时消息,不适合作为必须可靠保存的数据通道。
运行端:server
lua
-- @runtime server
local MessageService = game:GetService("MessageService")
local Task = game:GetService("Task")
if MessageService == nil or Task == nil then
return
end
local TOPIC = "GlobalAnnouncement"
local connection = nil
local stopped = false
local function OnMessage(message)
-- message 的公开类型是 Any;先校验本项目约定的消息结构。
if type(message) ~= "table" or type(message.text) ~= "string" then
print("[SE Lua Guide][WARN] 忽略格式错误的跨服消息")
return
end
print("[SE Lua Guide] 收到跨服公告:", message.text)
end
Task:Spawn(function()
local ok, result = pcall(function()
return MessageService:SubscribeAsync(TOPIC, OnMessage)
end)
if not ok then
print("[SE Lua Guide][ERROR] 订阅跨服消息失败:", result)
return
end
if stopped then
result:Disconnect()
else
connection = result
end
end)
local function PublishAnnouncement(text)
if type(text) ~= "string" or text == "" then return end
Task:Spawn(function()
local ok, err = pcall(function()
MessageService:PublishAsync(TOPIC, {
text = text,
version = 1,
})
end)
if not ok then
print("[SE Lua Guide][ERROR] 跨服消息发布失败:", err)
end
end)
end
game:BindToClose(function()
stopped = true
if connection ~= nil then
connection:Disconnect()
connection = nil
end
end)
-- 由活动系统在需要时调用:PublishAnnouncement("本轮活动即将开始")关键规则:
- 只有
PublishAsync,没有Publish。 PublishAsync和订阅建立都可能失败;示例统一放进Task:Spawn并用pcall保护。- 订阅回调不能写成异步回调;耗时工作应再投递给自己的任务队列。
- 订阅返回
Connection,长期系统要在销毁时Disconnect()。
TeleportService:传送和匹配
TeleportService 只在服务端使用。TeleportAsync、ReserveServerAsync、ApplyIngameMatchAsync 都是异步方法,需要协程和 pcall。
运行端:server
lua
-- @runtime server
local TeleportService = game:GetService("TeleportService")
local Players = game:GetService("Players")
local Task = game:GetService("Task")
if TeleportService == nil or Players == nil or Task == nil then
return
end
TeleportService.TeleportInitFailed:Connect(function(player, result, message, mapId, teleportOptions)
local playerName = player and player:GetName() or "<unknown>"
print("[SE Lua Guide][ERROR] 传送失败:", playerName, tostring(result), message, mapId)
-- teleportOptions 可能含敏感的预留服务器信息,不写入日志。
end)
local function TeleportAllPlayers(mapId)
if type(mapId) ~= "string" or mapId == "" then return end
Task:Spawn(function()
local options = TeleportService:CreateTeleportOptions()
local players = Players:GetPlayers()
if #players == 0 then
return
end
local ok, result = pcall(function()
return TeleportService:TeleportAsync(mapId, players, options)
end)
if not ok then
print("[SE Lua Guide] TeleportAsync 调用失败:", result)
end
end)
end
-- 调用方从项目配置读取真实地图 ID,再传给 TeleportAllPlayers
-- TeleportAllPlayers(PROJECT_MAP_ID)如果要创建预留服务器:
lua
-- @runtime server
local TeleportService = game:GetService("TeleportService")
local Task = game:GetService("Task")
if TeleportService == nil or Task == nil then
return
end
local function ReserveServer(mapId, onReady)
if type(mapId) ~= "string" or mapId == "" then return end
if type(onReady) ~= "function" then return end
Task:Spawn(function()
local ok, reserveResult = pcall(function()
return TeleportService:ReserveServerAsync(mapId)
end)
local accessCode = ok and reserveResult and reserveResult.ReservedServerAccessCode
if type(accessCode) == "string" and accessCode ~= "" then
local options = TeleportService:CreateTeleportOptions()
options.ReservedServerAccessCode = accessCode
options.ShouldReserveServer = true
onReady(options)
else
print("[SE Lua Guide][ERROR] 预留服务器失败:", reserveResult)
end
end)
end
-- 调用方从项目配置读取真实地图 ID;回调拿到 options 后再传送玩家。
-- ReserveServer(PROJECT_MAP_ID, function(options) ... end)mapId 必须来自项目配置中的真实地图 ID,不能编造 map://... 一类占位 URI。预留服务器访问码是敏感凭据,只留在服务端内存中传给后续传送逻辑,禁止打印或下发客户端。
SocialService:队伍查询
当前公开的 SocialService 只提供队伍查询。队伍 ID 应来自真实玩家的 GetPartyId(),或由可信业务流程传入,不要伪造固定 ID。
运行端:server
lua
-- @runtime server
local SocialService = game:GetService("SocialService")
local Players = game:GetService("Players")
local Task = game:GetService("Task")
if SocialService == nil or Players == nil or Task == nil then
return
end
local firstPlayer = Players:GetPlayers()[1]
if firstPlayer then
local partyId = firstPlayer:GetPartyId()
if partyId == nil or partyId == "" then return end
Task:Spawn(function()
local ok, partyInfo = pcall(function()
return SocialService:GetPartyAsync(partyId)
end)
if ok and partyInfo ~= nil then
print("[SE Lua Guide] 已取得队伍信息")
elseif not ok then
print("[SE Lua Guide][ERROR] 队伍信息查询失败:", partyInfo)
end
local partyPlayers = SocialService:GetPlayersByPartyId(partyId) or {}
print("[SE Lua Guide] 队伍玩家数:", #partyPlayers)
end)
endGetPartyAsync 是异步方法,需在协程中调用;GetPlayersByPartyId 是同步方法。两者查询不到队伍时都可能返回 nil,因此示例分别用条件判断和空表兜底。GetPartyAsync 的结果可能含预留服务器访问凭据,只用于服务端业务判断,不要整表打印或发给客户端。
CommodityService:商品、道具和权益
商业化相关服务必须保守处理:客户端可以请求打开面板,但购买结果、道具消耗、权益状态要以服务端和平台事件为准。
运行端:server(以下事件处理与奖励业务)
lua
-- @runtime server
local CommodityService = game:GetService("CommodityService")
if CommodityService == nil then return end
CommodityService.GoodsPurchaseCompleted:Connect(function(goodsId, goodsNum, player)
if player == nil or type(goodsId) ~= "string" or type(goodsNum) ~= "number" then
print("[SE Lua Guide][WARN] 忽略格式错误的购买完成事件")
return
end
print("[SE Lua Guide] 商品购买完成:", player:GetName(), goodsId, goodsNum)
-- 当前事件没有公开订单 ID,不能仅凭这三个参数安全地发放一次性奖励。
end)
CommodityService.CommodityConsumed:Connect(function(commodityId, consumeNum, player)
if player == nil or type(commodityId) ~= "number" or type(consumeNum) ~= "number" then
print("[SE Lua Guide][WARN] 忽略格式错误的道具消耗事件")
return
end
print("[SE Lua Guide] 道具被消耗:", player:GetName(), commodityId, consumeNum)
end)商品奖励还要处理“同一笔交付重复到达”的情况,但防重键必须对应一笔真实订单或一次权威交付。GoodsPurchaseCompleted(goodsId, goodsNum, player) 当前没有公开订单 ID;把 player.UserId .. goodsId 当 key,会把同一玩家以后再次购买同一商品也误判为重复。
下面只演示幂等执行器本身。deliveryId 必须由可信订单系统提供,不能在这个事件回调里用玩家 ID、商品 ID 或时间戳临时拼出来。
运行端:server
文件:server/reward_guard.lua
lua
-- @runtime server
local RewardGuard = {}
RewardGuard.__index = RewardGuard
function RewardGuard.New()
return setmetatable({ granted = {} }, RewardGuard)
end
function RewardGuard:GrantOnce(deliveryId, grantFn)
if type(deliveryId) ~= "string" or deliveryId == "" then
return false, "missing trusted delivery id"
end
if type(grantFn) ~= "function" then
return false, "grantFn must be a function"
end
if self.granted[deliveryId] then
return false, "duplicate"
end
self.granted[deliveryId] = true
local ok, err = pcall(grantFn)
if not ok then
self.granted[deliveryId] = nil
return false, err
end
return true, "granted"
end
return RewardGuard不要把这个骨架直接接到 GoodsPurchaseCompleted 并自行猜 key:当前公开事件参数不足以证明“是哪一笔订单”。正式接入应先取得平台或权威订单系统提供的稳定 deliveryId,再把“检查、发放、记录成功”设计成可恢复的持久化流程。上面的内存表只解释接口形状,服务器重启后会丢失,不能用于上线发奖。
常用接口:
| 需求 | 接口 |
|---|---|
| 查询玩家是否拥有道具 | HasCommodity(player, commodityId) |
| 查询数量 | GetCommodityCount(player, commodityId) |
| 消耗道具 | ConsumeCommodity(player, commodityId, num) |
| 显示商品详情 | ShowGoodsDetailPanel(player, goodsId) |
| 显示购买面板 | ShowGoodsPurchasePanel(player, goodsId, showTime) |
| 免费领取 | RequestFreeGoods(player, goodsId) |
| 会员/粉丝权益 | IsVip、IsAuthorFans、GetFanClubLevel |
AnalyticsService:埋点不是日志
日志用于调试,埋点用于分析。不要把每帧状态、临时调试信息、超长字符串塞进埋点。埋点要少、稳定、可解释。
运行端:server
lua
-- @runtime server
local AnalyticsService = game:GetService("AnalyticsService")
if AnalyticsService == nil then return end
local function LogRoundStart(player, roundId)
if player == nil or roundId == nil then return end
AnalyticsService:LogProgressionStartEvent(player, "Round", roundId, "MainRound", {
mode = "classic",
})
end
local function LogRoundComplete(player, roundId, score)
if player == nil or roundId == nil or type(score) ~= "number" then return end
AnalyticsService:LogProgressionCompleteEvent(player, "Round", roundId, "MainRound", {
score = score,
})
endLogCustomEvent 的 customData 键只允许 CustomField01/02/03,并且值与字符串长度都有约束;这不是“建议”,而是公开契约。上例使用的是 progression 事件,其 customData 可以使用业务键,但仍应保持字段少、值短小且不含敏感信息。
平台服务接入清单
接入平台服务前,先写清楚这 5 件事:
- 这个能力由谁触发:玩家、回合系统、服务器事件,还是平台回调。
- 运行端在哪里:client、server,还是双端分别做不同部分。
- 失败时怎么办:日志、玩家提示、重试、降级。
- 是否异步:Async 方法放进协程并
pcall。 - 是否需要防重:购买、奖励、传送、跨服消息都要考虑重复触发。
常见错误
错误:把 Async 方法裸调用
PublishAsync、SubscribeAsync、TeleportAsync、ReserveServerAsync、GetPartyAsync 等平台异步调用都应放进协程,并用 pcall 保护。当前公开 SocialService 只有队伍查询,不要照搬旧示例中的分享或邀请函数名。
错误:把 MessageService 当可靠存储
跨服消息是实时通知,不保证可靠送达。需要持久化的数据仍应使用数据存储或服务端权威状态。
错误:传送失败没有反馈
必须监听 TeleportInitFailed,至少打印结构化日志;正式玩法还要给玩家 UI 提示和重试入口。
错误:客户端直接发放商品奖励
客户端只能请求和展示,奖励发放必须由 server 根据平台事件和自己的幂等逻辑处理。
练习任务
- 写一张平台服务接入设计单,包含触发者、运行端、Async/pcall、失败反馈和防重策略。
- 创建一个自定义聊天频道,让玩家加入时自动进入,并记录服务端日志。
- 写一个
TeleportAllPlayers(mapId),包含Task:Spawn、pcall和失败日志。 - 设计 3 个稳定埋点:回合开始、回合完成、回合失败,并说明每个字段为什么稳定。
- 选择一个商品奖励场景,画出“可信订单 ID 从哪里来、怎样持久化、重复交付怎样返回已有结果”的流程图;不要用玩家 ID + 商品 ID 充当订单 ID。
本章验收标准
- [ ] 我能按需求选择平台服务,而不是把所有服务都接进项目。
- [ ] 我知道聊天频道创建和消息发送的 server/client 分工。
- [ ] 我知道 Async 平台接口要放进协程并用
pcall保护。 - [ ] 我知道传送、商品、奖励必须有失败处理和防重思路。
- [ ] 我知道埋点应稳定、少量、可解释。
- [ ] 我能为一个平台服务接入写出端归属、失败处理和防重设计单。
本章产物
- 一张平台服务接入设计单,至少包含触发者、运行端、Async/pcall、失败反馈、防重和玩家提示。
- 一个商品奖励防重骨架,说明可信订单 ID 来源、发放函数、失败回滚和持久化计划。
- 一份验证记录:平台副作用代码至少完成编辑器编译;传送、交易、埋点行为只在隔离测试地图和测试账号中执行,并按第 21 章模板写清日期、步骤、日志和结论。
- 一组平台服务日志前缀约定,例如
[SE Lua Guide][Platform]、[WARN]、[ERROR],方便第 21 章 QA 复盘。
本章 API 对照
- TextChatService
- MessageService
- TeleportService
- SocialService
- CommodityService
- AnalyticsService
- Task
- Connection
- TeleportOptions
下一章预告
平台能力接入后,最重要的是能证明成功、失败和降级都被覆盖。下一章进入测试、调试、日志与 QA,把日志、playtest、testspec、截图和证据目录串成闭环。
