Skip to content

第 20 章:平台服务:聊天、跨服、社交、商业化、埋点

按运行端、安全身份和副作用边界接入聊天、跨服、传送、商业化与分析服务。

你会学到什么

  • 平台服务为什么要“按场景选型”,而不是一次性全学。
  • TextChatServiceMessageServiceTeleportService 的真实使用边界。
  • 社交、商业化、埋点服务的安全和体验要点。
  • 异步平台接口为什么必须放进协程,并用 pcall 保护。

平台服务怎么学

前面章节讲的是玩法核心:对象、事件、通信、UI、数据、物理、角色、资产和项目内可复用系统。平台服务更像“接入能力”:聊天、跨服消息、传送、社交邀请、商品、埋点。它们通常不属于第一轮主线,应该按项目需求逐个接入。

需求优先服务典型运行端核心风险
队伍频道、交易频道TextChatService频道操作 server;聊天 UI client混用运行端、读取未公开频道字段
跨服务器公告/状态同步MessageServiceserver异步调用、消息不保证可靠送达
切换地图、匹配到新场景TeleportServiceserver传送失败、异步错误
队伍查询SocialServiceserver异步失败、泄露队伍传送凭据
商品、道具、会员/粉丝权益CommodityServiceserver 为主购买结果和道具发放必须校验
进度、经济、漏斗、自定义行为AnalyticsServiceserver字段格式、事件命名、隐私与噪声

学习顺序建议:

  1. 先接聊天或埋点,因为它们对玩法侵入较小。
  2. 再接传送、跨服消息,因为它们涉及异步、失败和协程。
  3. 最后接商业化和社交,因为它们更依赖平台配置、审核和体验设计。

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 / RemoveCustomTextChannelserver
EnterTextChannel / LeaveTextChannelserver
SendMessageserver
SetOfficialTextChannelVisibleclient
SetChatButtonVisibleclient
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 只在服务端使用。TeleportAsyncReserveServerAsyncApplyIngameMatchAsync 都是异步方法,需要协程和 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)
end

GetPartyAsync 是异步方法,需在协程中调用;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)
会员/粉丝权益IsVipIsAuthorFansGetFanClubLevel

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,
    })
end

LogCustomEventcustomData 键只允许 CustomField01/02/03,并且值与字符串长度都有约束;这不是“建议”,而是公开契约。上例使用的是 progression 事件,其 customData 可以使用业务键,但仍应保持字段少、值短小且不含敏感信息。

平台服务接入清单

接入平台服务前,先写清楚这 5 件事:

  1. 这个能力由谁触发:玩家、回合系统、服务器事件,还是平台回调。
  2. 运行端在哪里:client、server,还是双端分别做不同部分。
  3. 失败时怎么办:日志、玩家提示、重试、降级。
  4. 是否异步:Async 方法放进协程并 pcall
  5. 是否需要防重:购买、奖励、传送、跨服消息都要考虑重复触发。

常见错误

错误:把 Async 方法裸调用

PublishAsyncSubscribeAsyncTeleportAsyncReserveServerAsyncGetPartyAsync 等平台异步调用都应放进协程,并用 pcall 保护。当前公开 SocialService 只有队伍查询,不要照搬旧示例中的分享或邀请函数名。

错误:把 MessageService 当可靠存储

跨服消息是实时通知,不保证可靠送达。需要持久化的数据仍应使用数据存储或服务端权威状态。

错误:传送失败没有反馈

必须监听 TeleportInitFailed,至少打印结构化日志;正式玩法还要给玩家 UI 提示和重试入口。

错误:客户端直接发放商品奖励

客户端只能请求和展示,奖励发放必须由 server 根据平台事件和自己的幂等逻辑处理。

练习任务

  1. 写一张平台服务接入设计单,包含触发者、运行端、Async/pcall、失败反馈和防重策略。
  2. 创建一个自定义聊天频道,让玩家加入时自动进入,并记录服务端日志。
  3. 写一个 TeleportAllPlayers(mapId),包含 Task:Spawnpcall 和失败日志。
  4. 设计 3 个稳定埋点:回合开始、回合完成、回合失败,并说明每个字段为什么稳定。
  5. 选择一个商品奖励场景,画出“可信订单 ID 从哪里来、怎样持久化、重复交付怎样返回已有结果”的流程图;不要用玩家 ID + 商品 ID 充当订单 ID。

本章验收标准

  • [ ] 我能按需求选择平台服务,而不是把所有服务都接进项目。
  • [ ] 我知道聊天频道创建和消息发送的 server/client 分工。
  • [ ] 我知道 Async 平台接口要放进协程并用 pcall 保护。
  • [ ] 我知道传送、商品、奖励必须有失败处理和防重思路。
  • [ ] 我知道埋点应稳定、少量、可解释。
  • [ ] 我能为一个平台服务接入写出端归属、失败处理和防重设计单。

本章产物

  • 一张平台服务接入设计单,至少包含触发者、运行端、Async/pcall、失败反馈、防重和玩家提示。
  • 一个商品奖励防重骨架,说明可信订单 ID 来源、发放函数、失败回滚和持久化计划。
  • 一份验证记录:平台副作用代码至少完成编辑器编译;传送、交易、埋点行为只在隔离测试地图和测试账号中执行,并按第 21 章模板写清日期、步骤、日志和结论。
  • 一组平台服务日志前缀约定,例如 [SE Lua Guide][Platform][WARN][ERROR],方便第 21 章 QA 复盘。

本章 API 对照

下一章预告

平台能力接入后,最重要的是能证明成功、失败和降级都被覆盖。下一章进入测试、调试、日志与 QA,把日志、playtest、testspec、截图和证据目录串成闭环。