Skip to content

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

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

开始前与本章路线 ​

  • 准备:先写明真实测试对象、运行端和副作用范围;没有平台配置时只读设计与编译,不运行发送、传送或交易。
  • 安装范围:多数为接入骨架;函数定义不自动执行。reward_guard.lua 只演示内存去重,不是可上线的交付系统。
  • 本次要看到:每个服务独立记录成功、失败或未配置;不能用其他服务通过来代表整章通过。

停止试玩后再换入口;这些章节是能力实验,第 22 章才提供统一的六文件项目。每次只改变一个条件,并保留本次输出用于对照。

你会学到什么 ​

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

平台服务怎么学 ​

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

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

学习顺序建议:

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

TextChatService:自定义聊天频道 ​

TextChatService 可以创建自定义频道,让玩家进入或离开频道并发送频道消息。本章把频道创建、进出和发消息组织在 server,把聊天面板与按钮显隐组织在 client;这是本教程的工程分工,不能把 common 类型改写成全部成员仅限某端。具体成员限制仍按目标 Meta 核对,并在匹配的运行包实测。

运行端:server

示例 1|平台接入实验;会创建频道并发送消息,只在已配置隔离环境运行。 本次演示端: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)

频道信息也可以通过公开接口读取。当前 Meta 返回 Array<any>,返回说明提到 name,但没有建立完整的 Data 结构合同;本例只读取数量,不推断更多字段。既有客户端运行记录曾出现问题,新的运行包仍需复验,因此本章先在 server 做最小查询:

示例 2|独立实验;整体替换 server/main.lua。 本次演示端:server。

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

示例 3|平台接入骨架;加载即订阅,发布函数需另行调用。 本次演示端: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

示例 4|平台接入骨架;只定义传送函数并监听失败,不自动传送。 本次演示端: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)

如果要创建预留服务器:

示例 5|平台接入骨架;只定义 ReserveServer,不自动预留服务器。 本次演示端:server。

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] 预留服务器失败;请在受控服务端诊断中核对结果")
        end
    end)
end

-- 调用方从项目配置读取真实地图 ID;回调拿到 options 后再传送玩家。
-- ReserveServer(PROJECT_MAP_ID, function(options) ... end)

mapId 按当前公开契约选择:非多关卡地图只能传 "SELF",表示当前地图的其他服务器实例;多关卡地图也可传项目配置中的真实关卡 ID。它不是 map://... 资源 URI。Editor 环境中传送不生效、ReserveServerAsync 返回 nil,行为验收应在已部署的隔离测试地图完成。预留服务器访问码只留在服务端内存中传给后续传送逻辑,禁止打印或下发客户端。

SocialService:队伍查询 ​

当前公开的 SocialService 只提供队伍查询。队伍 ID 应来自真实玩家的 GetPartyId(),或由可信业务流程传入,不要伪造固定 ID。

运行端:server

示例 6|平台查询实验;需真实队伍和隔离账号。 本次演示端: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(以下事件处理与奖励业务)

示例 7|平台事件监听片段;不发奖。 本次演示端: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

示例 8|完整模块 server/reward_guard.lua;仅内存去重演示。 本次演示端:server。

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
        -- 回调报错时可能已部分发放,保留占位,交由权威交付流程核对。
        return false, "needs reconciliation: " .. tostring(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

示例 9|埋点函数片段;定义不代表上报成功。 本次演示端: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

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

平台服务接入清单 ​

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

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

常见错误 ​

错误:把 Async 方法裸调用 ​

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

错误:把 MessageService 当可靠存储 ​

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

错误:传送失败没有反馈 ​

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

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

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

练习任务 ​

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

练习参考与复核 ​

先完成练习,再展开对照;没有运行条件的项目填写“未验证”和缺少的条件。

参考思路与应观察的结果

可信 deliveryId 必须标识同一笔交付。grantFn 报错可能已部分发放,不能删除去重记录后自动重试;应标记待核对并走可恢复的权威事务。聊天/跨服/埋点验证要区分本地回调与平台实际送达。

  • [ ] 我已完成本章选定实验,记录了上面的预期现象。
  • [ ] 我能故意改变一个前置条件,解释失败发生在哪一步,并在恢复后重跑。

本章验收标准 ​

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

本章产物 ​

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

本章 API 对照 ​

下一章预告 ​

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