• 产品手册
  • 编辑器功能手册
  • 云脚本

云脚本教程

云脚本在云脚本服务中处理玩家请求、查询玩家与道具信息、读写存档,并把结果发回客户端。

你可以点击这里下载本文配套的云脚本示例项目(云脚本参考项目0916.zip),下文提到的“示例项目”都指这份工程;示例项目建议用本地多开运行,连接线上测试环境。

本文档的目标读者是使用 Y3 编辑器、具备基础 Lua 知识的地图作者。

首次接入,请先看“新项目初始化与文件迁移”和“快速开始”;

实现具体功能看“常见任务”;

查参数和返回值看“API 参考”;

设计容量、频率和超限处理时看“运行限制与配额”。

新项目初始化与文件迁移

新建 Y3 项目默认没有 cloud_script/、云脚本入口和 meta.lua。

使用时要先复制可运行示例项目的基础结构,再替换示例业务代码;示例地图中的 cloud_script/ 可直接作为迁移源。

该目录位于项目根目录,与 maps/、global_script/ 同级,不要放到 maps/<地图名>/script/ 下。

1. 建立目录并迁移基础文件

在新项目根目录创建 cloud_script/,再按下表复制:“必需”组成本教程的最小可运行结构,“按需”文件只有被引用时才需要。

示例项目中的路径新项目中的路径必需性用途与迁移说明
cloud_script/main.luacloud_script/main.lua必需云脚本唯一入口。保留基础 require,删除示例测试模块的加载行,再加入自己的业务模块。
cloud_script/meta.luacloud_script/meta.lua推荐为 RegisterEvent、Ms*、redis、Log、Timer 等提供 Lua 类型提示;运行时不要 require("meta")。
cloud_script/com_api.luacloud_script/com_api.lua按需示例通用字符串工具。只有 main.lua 或业务模块仍然 require("com_api") 时才复制。
cloud_script/ms_api.luacloud_script/ms_api.lua按需LogInfo、LogWarn、LogClient 等测试辅助封装。正式业务若不需要这些封装,应删除对应 require;LogClient 上线前应移除或禁用。
cloud_script/dao/*.luacloud_script/dao/*.lua按需示例的玩家/房间数据对象。复制后要改成自己的数据模型,不能把示例字段当作项目存档设计。
cloud_script/testcase/*.luacloud_script/testcase/*.lua测试专用API、Lua 调试和云服务更新测试。仅在需要复现实例或跑测试时复制;生产入口不要加载测试模块。
cloud_script/game_entry.luacloud_script/game_entry.lua不需要历史兼容文件,不会被云脚本服务自动加载。只有旧项目明确依赖它时才保留。
cloud_script/ 下的日志、转储和存档产物不迁移不需要dump/、mls_archive/、mls_log/、script_log/ 是运行或测试产生的文件,不是初始化依赖,也不应作为模板复制。

复制 main.lua 后,必须检查其中的 require:

cjson = require("cjson")       -- 云脚本服务提供的模块
require("com_api")             -- 复制了 com_api.lua 才保留
require("ms_api")              -- 复制了 ms_api.lua 才保留
require("my_feature")           -- 新项目自己的模块

require 的模块根目录是 cloud_script/,不写 .lua 后缀:cloud_script/dao/player.lua 写成 require("dao.player")。路径区分大小写,应与实际文件名一致。

2. 清理示例专属代码

示例 main.lua 可能包含测试模块加载和示例日志。迁移后逐项处理:

  1. 删除 require("testcase.*") 等测试入口,或仅在本地测试分支保留。
  2. 保留 main.lua 作为唯一入口,把业务逻辑拆到独立模块,再由入口按依赖顺序 require。
  3. meta.lua 只用于编辑器补全和静态检查,不要当作业务模块,也不要在入口中加载。

3. 接上客户端入口并验证

客户端入口 maps/<地图名>/script/main.lua 不属于 cloud_script/,两端通过 y3.game.send_mlscript_msg、RegisterEvent 和 MsSendMlEvent 通信。验证顺序:

  1. 确认 cloud_script/main.lua 存在,且每个 require 都能在 cloud_script/ 下找到对应文件。
  2. 用 Y3 开发助手启动本地模拟服务,先不要开启多开模式。
  3. 在云脚本日志中确认入口和自定义事件监听器已加载,再从客户端发送一次测试事件。
  4. 出现 module not found、入口无日志或事件无响应时,先检查目录层级、require 路径和入口依赖,再进入调试与排错。

cloud_script 文件夹结构

迁移后推荐采用下面的目录结构。“必需”文件组成最小运行集,“按需”目录用于业务或测试,“产物”目录由运行过程生成。

<项目根目录>/
├── cloud_script/                         # 云脚本服务目录,与 maps/ 同级
│   ├── main.lua                          # 必需:唯一云脚本入口
│   ├── meta.lua                          # 推荐:IDE 类型提示,不作为运行模块
│   ├── com_api.lua                       # 按需:通用工具
│   ├── ms_api.lua                        # 按需:日志/消息测试封装
│   ├── game_entry.lua                    # 可选:历史兼容,不会自动加载
│   ├── <业务模块>.lua                    # 按需:自定义业务模块
│   ├── dao/                              # 按需:数据访问或领域对象
│   │   ├── dao_player.lua
│   │   └── dao_room.lua
│   ├── testcase/                         # 测试专用,不要从生产入口加载
│   │   ├── cloud_service_update_testcase.lua
│   │   ├── lua_debug_probe.lua
│   │   ├── y3_lua_testcase.lua
│   │   └── y3_testcase.lua
│   ├── dump/                             # 产物:调试转储,不迁移
│   ├── mls_archive/                      # 产物:本地存档测试数据,不迁移
│   ├── mls_log/                          # 产物:云脚本服务日志,不迁移
│   └── script_log/                       # 产物:脚本日志,不迁移
├── maps/
│   └── <地图名>/script/main.lua          # 客户端入口,与云脚本分开
└── global_script/                        # 可选:全局脚本,不属于云脚本服务

最小运行结构是 cloud_script/main.lua 加上入口引用的业务模块,建议连同 meta.lua 保留以获得类型提示;不要为“保持示例目录完整”复制日志、转储和历史测试数据。

快速开始

本例实现:进入游戏后按空格,客户端发送 GuideHello 请求,云脚本回复 GuideHelloReply,客户端输出 Hello from cloud。

1. 确认代码放置位置

代码本示例地图中的位置执行环境
云脚本入口cloud_script/main.lua云脚本服务
新增云脚本示例cloud_script/tutorial_hello.lua由云脚本入口加载
客户端入口maps/EntryMap/script/main.lua游戏客户端

其他地图的客户端入口以对应地图脚本目录为准。客户端示例依赖已初始化的 y3;云脚本示例使用服务的 RegisterEvent、MsSendMlEvent 和 Log。

2. 添加云脚本代码

将下面的完整代码放入 cloud_script/tutorial_hello.lua:

local json = require("cjson")
 
RegisterEvent("GuideHello", function(event_idx, role_id, event_name, evalue)
    local decode_ok, request = pcall(json.decode, evalue)
    if not decode_ok or type(request) ~= "table" then
        Log.warn("GuideHello: invalid JSON object")
        return
    end
    if type(request.msg) ~= "string" or #request.msg > 120 then
        Log.warn("GuideHello: invalid msg")
        return
    end
 
    -- 使用服务回调给出的玩家标识确定回复对象。
    local response = json.encode({ msg = "Hello from cloud" })
    local code = MsSendMlEvent(role_id, "GuideHelloReply", response)
    if code ~= 0 then
        Log.warn("GuideHello: send failed, code=" .. tostring(code))
        return
    end
    Log.info("GuideHello: reply submitted, role=" .. tostring(role_id))
end)
 
Log.info("GuideHello: listener ready")

在 cloud_script/main.lua 中添加一行加载代码:

require("tutorial_hello")

每个示例模块只加载一次,require 规则同前。cjson 由服务提供;meta.lua 只用于编辑器类型提示,不要当作运行模块加载。

3. 添加客户端代码

将下面代码添加到 maps/EntryMap/script/main.lua。消息名与原项目示例不同,可与已有的空格测试并存,但会分别发送请求。

y3.game:event("玩家-收到云脚本消息", function(trg, data)
    if data.mlscript_event_name ~= "GuideHelloReply" then
        return
    end
    local response = data.mlscript_event_data
    if response == nil then
        return
    end
    print(tostring(response.msg))
end)
 
y3.game:event("键盘-按下", "SPACE", function()
    y3.game.send_mlscript_msg("GuideHello", {
        msg = "Hello from client",
    })
end)

客户端发送接口接收 Lua 表,无需编码;云脚本回调的 evalue 是字符串,需解码,回复需编码成字符串。客户端事件的 mlscript_event_data 由 Y3 封装为可按字段访问的对象,无需再解码,也不能假定其 type() 是 table。

云脚本的下行消息会广播给房间内所有客户端,而不是只发给目标玩家;收到消息后要用事件里的玩家字段判断是不是发给自己的,例如 if data.player ~= 本机玩家 then return end。

上行的参数表只能使用基本类型:整数/浮点数、布尔值、字符串和表;传入玩家对象等其他类型会在发送时序列化失败。需要带玩家信息时,自己传一个数字,例如 y3.game.send_mlscript_msg("GuideHello", { player = data.player:get_id(), msg = "..." })。

4. 用 ECA 发送与接收(可选)

不用 Lua 时也可以在触发器里接入云脚本:

  • 发送:ECA【游戏 - 发送云脚本自定义消息】,接收两个参数——字符串类型的事件名、表类型的数据表。数据表只能使用基础数据类型,传入单位、玩家等复杂类型会在序列化时发送失败。
  • 接收:监听 ECA 事件【游戏 - 收到云脚本自定义消息】,带三个参数——表类型的云脚本数据、字符串类型的事件名、玩家类型的目标玩家。
  • 注意:云脚本的下行消息会广播给房间内所有客户端,并不是只发给指定玩家;无论用 ECA 还是 Lua 接收,都要按目标玩家字段自行过滤。

5. 启动并检查结果

  1. 使用 Y3 开发助手启动游戏,第一次验证时关闭“多开模式”,使用本地模拟服务。
  2. 在 cloud_script/script_log 中确认出现 GuideHello: listener ready。
  3. 进入游戏后按一次空格。
  4. 在客户端日志中确认出现 Hello from cloud;云脚本日志应出现 GuideHello: reply submitted。

本示例项目在开发模式下会把客户端日志显示到游戏中,其他项目应检查自己的日志配置。没有预期结果时按调试与排错检查。

核心概念

客户端与云脚本是两个运行环境

客户端用 y3.game.send_mlscript_msg 发送请求,云脚本用 RegisterEvent 接收、MsSendMlEvent 回复,客户端监听“玩家-收到云脚本消息”。两端不共享 Lua 变量。

业务自定义事件名不要以 _ 开头,该前缀用于系统事件。本文使用的 GuideHello 等名称是示例自行约定的消息名,不是内置事件。

玩家标识从哪里来

role_id 是局内玩家 ID:本局房间内的玩家编号,用来调用玩家接口。plat_aid 是用户唯一 ID:平台账号的唯一标识,跨局不变,只在 _playerjoin 的附加参数里出现。

局内玩家 ID 与玩家槽位绑定:对局开始时按槽位分配,一局内不会改变——例如对局上限 4 人、开局时只有槽位 1 和 3 有玩家,这两位玩家的 role_id 就是 1 和 3,中途不会重新编号。编号不保证连续;玩家通常无法中途加入,所以一局内每个玩家的 role_id 自始至终不变,可以放心当作房间内的 key 或映射下标使用。

标识来源含义与使用方式
云脚本 role_id玩家相关或自定义事件的回调参数局内玩家 ID,传给 MsGetPlayerName、MsSendMlEvent 等玩家接口
plat_aid_playerjoin 附加参数里的 JSON 字符串用户唯一 ID,跨局稳定;需要账号级数据时自行解析并保存
客户端 data.player:get_id()客户端 Player 对象的玩家 ID作为数字字段上行给云脚本,值等于回调的 role_id
客户端 data.player“玩家-收到云脚本消息”的事件参数Y3 的 Player 对象,不是云脚本的整数参数
player_guids_roomloaded 的附加参数本局局内玩家 ID 序列,与 role_id 同一套编号,不含用户唯一 ID

_roomloaded 的 player_guids 可以用来枚举本局玩家;账号级身份要从 _playerjoin 的 plat_aid 建立映射。处理请求时直接使用回调给出的 role_id,客户端上报的玩家字段只作为自检,不能单独作为身份依据;不要把局内玩家编号当作用户唯一 ID 使用。

_playerjoin 的附加参数是 JSON 字符串,需要自行解析;解析后可以维护一份 role_id 到 plat_aid 的映射,供本局后续请求使用:

local plat_aid_by_role = {}
 
RegisterEvent("_playerjoin", function(event_idx, role_id, event_name, payload)
    local ok, data = pcall(cjson.decode, payload)
    if not ok or type(data) ~= "table" then
        Log.warn("_playerjoin: unexpected payload")
        return
    end
    plat_aid_by_role[role_id] = data.plat_aid
end)

房间级事件(_roomloaded、_roomover、_localmodeloaded)由房间状态触发,不是某个玩家的动作,所以回调里的 role_id 是 -1,表示“没有具体玩家”。把 -1 直接传给 MsGetPlayerName、MsSendMlEvent 等玩家接口不会作用到任何玩家,返回的也是空结果。要在这些事件里按玩家处理,就用事件自己给出的玩家列表(_roomloaded 的 player_guids),或者先判断 role_id 是否有效:

-- 房间加载完成时逐个玩家处理:用 player_guids,而不是回调里的 role_id
RegisterEvent("_roomloaded", function(event_idx, role_id, event_name, player_guids)
    if role_id ~= -1 then
        Log.warn("_roomloaded: unexpected role_id=" .. tostring(role_id))
    end
    for _, player_role_id in ipairs(player_guids) do
        Log.info("_roomloaded: player " .. tostring(player_role_id) .. " = " .. tostring(MsGetPlayerName(player_role_id)))
    end
end)

玩家级事件(_playerjoin、_playerleave、_playerexit 和业务自定义事件)里的 role_id 才是可用的玩家编号,可以直接传给玩家接口。

先看返回值约定,再写条件判断

接口类别判断方式
查询玩家或房间信息检查是否返回 nil
保存玩家存档判断返回的 boolean
发送消息、消耗道具使用 code == 0 判断接口报告成功
Redis先看第一个返回值,再校验第二个值是结果还是错误文本

Lua 中 0 和空字符串都是真值,if MsTryConsumeItem(...) then 不能判断扣减是否成功;返回成功的含义还要结合接口限制理解,尤其是道具消耗、存档和 Redis 的错误返回。

两类存档如何选择

需求接口数据范围
保存某位玩家的脚本数据MsGetScriptArchive、MsSaveScriptArchive玩家脚本存档
保存同一地图多个房间共享的数据redis.get_redis_client()当前地图共享的全局存档

玩家存档传入和返回字符串,通常用 JSON 组织字段。地图全局存档由 Redis 实现,开放 Get、Set、IncrBy;多房间可访问同一 key,单条 IncrBy 是原子的,但多次调用组成的读改写流程不是不可分割的操作。

常见任务

以下示例分别放在独立模块中,在 cloud_script/main.lua 中用 require("模块名") 加载;各例自带所需局部变量,不要依赖测试工程的 LogInfo、LogClient 等辅助函数。

事件驱动示例需要客户端发送对应事件:把快速开始中空格回调里的发送语句换成各节的调用,一次只选一个示例,避免一次按键触发多项写操作。

查询玩家信息

场景:玩家请求查看自己的名字和地图等级。云脚本文件可命名为 tutorial_profile.lua。

RegisterEvent("GuideProfile", function(event_idx, role_id, event_name, evalue)
    local name = MsGetPlayerName(role_id)
    local level = MsGetPlayerMapLevel(role_id)
    if name == nil or level == nil then
        Log.warn("GuideProfile: player or room unavailable")
        return
    end
    Log.info("GuideProfile: name=" .. name .. ", level=" .. tostring(level))
end)

客户端触发调用:

y3.game.send_mlscript_msg("GuideProfile", {})

预期结果:云脚本日志中输出名字和等级。本地启动环境中的地图等级固定为 1,不能用这个结果验证正式地图等级。

读取、修改并保存玩家存档

场景:保存一个教学用访问计数。云脚本文件可命名为 tutorial_archive.lua。

该示例会写入玩家存档,应在专用测试玩家上运行。示例自定义了 schema_version 和 visits 字段,已有业务存档需使用自己的结构和迁移规则。

local json = require("cjson")
 
RegisterEvent("GuideVisit", function(event_idx, role_id, event_name, evalue)
    if type(role_id) ~= "number" or role_id < 0 then
        Log.warn("GuideVisit: invalid role; no write performed")
        return
    end
 
    local raw = MsGetScriptArchive(role_id)
    local archive
    if raw == nil or raw == "" then
        -- 首次无存档返回 nil;本例把 nil 和空字符串都当作可初始化的空档。
        archive = { schema_version = 1, visits = 0 }
    else
        local decode_ok, decoded = pcall(json.decode, raw)
        if not decode_ok or type(decoded) ~= "table" then
            Log.warn("GuideVisit: invalid archive; no write performed")
            return
        end
        archive = decoded
    end
 
    local visits = archive.visits
    if archive.schema_version ~= 1
        or type(visits) ~= "number"
        or visits ~= visits
        or visits < 0
        or visits >= 1000000
        or visits % 1 ~= 0 then
        Log.warn("GuideVisit: unsupported archive data; no write performed")
        return
    end
 
    archive.visits = visits + 1
    local encode_ok, encoded = pcall(json.encode, archive)
    if not encode_ok then
        Log.warn("GuideVisit: encode failed")
        return
    end
    if not MsSaveScriptArchive(role_id, encoded) then
        Log.warn("GuideVisit: save failed")
        return
    end
    Log.info("GuideVisit: save reported success, visits=" .. archive.visits)
end)

客户端触发调用:

y3.game.send_mlscript_msg("GuideVisit", {})

预期结果:首次无存档或已有兼容存档时,每次请求把计数加一并输出保存结果。1000000 是本例设置的计数边界,不是平台上限;单份存档的真实上限是 1,048,576 字节。

首次无存档时 MsGetScriptArchive 返回 nil,玩家或房间不存在时也返回 nil,只看返回值无法区分这两种情况,所以示例先校验 role_id 再决定是否建新档。保存被拒绝时返回 false,原因可能是超过大小上限、房间或玩家不存在。

读写地图全局存档

场景:按顺序设置、读取和递增一个共享值。下面两种写法任选一种放入 tutorial_redis.lua 并加载。

示例使用固定 key tutorial:redis:counter:v1,每次触发先设为 100。只在无其他房间访问该 key 的测试中核对结果;它不是累计线上数据的实现。

回调写法

初次使用建议从回调写法开始:下一步放在上一步成功的回调中,不能在异步回调外立即读取尚未返回的结果。

RegisterEvent("GuideRedis", function(event_idx, role_id, event_name, evalue)
    local client = redis.get_redis_client()
    local key = "tutorial:redis:counter:v1"
 
    client:Set(key, 100, function(set_ok, set_value)
        if not set_ok then
            Log.warn("GuideRedis: Set failed")
            return
        end
        client:Get(key, function(get_ok, get_value)
            if not get_ok then
                Log.warn("GuideRedis: Get failed")
                return
            end
            Log.info("GuideRedis: before=" .. tostring(get_value))
            client:IncrBy(key, 2, function(incr_ok, incr_value)
                if not incr_ok then
                    Log.warn("GuideRedis: IncrBy failed")
                    return
                end
                client:Get(key, function(final_ok, final_value)
                    if not final_ok then
                        Log.warn("GuideRedis: final Get failed")
                        return
                    end
                    Log.info("GuideRedis: after=" .. tostring(final_value))
                end)
            end)
        end)
    end)
end)

协程写法

省略回调时,调用必须位于协程内;不要在普通事件回调中直接使用 local ok, value = client:Get(key)。

RegisterEvent("GuideRedis", function(event_idx, role_id, event_name, evalue)
    local co = coroutine.create(function()
        local client = redis.get_redis_client()
        local key = "tutorial:redis:counter:v1"
        local set_ok, set_value = client:Set(key, 100)
        if not set_ok then
            Log.warn("GuideRedis: Set failed")
            return
        end
        local get_ok, get_value = client:Get(key)
        if not get_ok then
            Log.warn("GuideRedis: Get failed")
            return
        end
        Log.info("GuideRedis: before=" .. tostring(get_value))
        local incr_ok, incr_value = client:IncrBy(key, 2)
        if not incr_ok then
            Log.warn("GuideRedis: IncrBy failed")
            return
        end
        local final_ok, final_value = client:Get(key)
        if not final_ok then
            Log.warn("GuideRedis: final Get failed")
            return
        end
        Log.info("GuideRedis: after=" .. tostring(final_value))
    end)
    local resume_ok, resume_error = coroutine.resume(co)
    if not resume_ok then
        Log.warn("GuideRedis: start failed")
    end
end)

coroutine.resume 成功只说明本次恢复执行没有立即报错,不能代替后续每个 Redis 操作的结果判断。

客户端触发调用:

y3.game.send_mlscript_msg("GuideRedis", {})

预期结果:无其他写入干扰且操作成功时,云脚本日志输出 before=100 和 after=102。失败时在对应步骤停止;示例不自动重试,以免重复递增。

正式业务使用稳定、有业务含义的 key。单条 IncrBy 由 Redis 原子递增命令执行,跨房间也适用;但多个 key 或多个调用之间没有事务保证,结果不明确时重试仍可能重复递增。细节见地图全局存档限制。

创建会自行停止的定时器

场景:每秒输出一次,三次后停止。云脚本文件可命名为 tutorial_timer.lua,加载后即开始计时,无需客户端触发。

local count = 0
local timer_id = Timer.new_timer(1000, function(id)
    count = count + 1
    Log.info("GuideTimer: tick=" .. count)
    return count < 3
end)

预期结果:约每秒一条日志,依次为 tick=1、tick=2、tick=3,随后停止。回调返回 true 继续,返回 false 停止。间隔单位是毫秒,最小 1 ms,0 按 1 ms 处理,负数返回定时器 ID 0。

需要由业务主动取消时,用下面的独立示例,放在 tutorial_cancel_timer.lua 中加载:

local timer_id = Timer.new_timer(1000, function(id)
    Log.info("GuideTimer: running")
    return true
end)
 
RegisterEvent("_roomover", function(event_idx, role_id, event_name, reason)
    Timer.cancel_timer(timer_id)
end)

此例在房间结束事件中取消定时器,不会在创建后立即取消;它只用于展示取消调用的位置。房间结束会进入约 5000 ms 的结束阶段,期间定时器仍可能触发,见定时器。

查询并尝试消耗道具

场景:业务已经确认本次操作允许消耗某个固定道具。道具 ID 和数量由云脚本业务决定;不要直接信任客户端提交的任意扣减参数。

以下是云脚本局部函数示例,可放在业务模块中,在完成业务校验后调用;它不会自动执行扣减。

local function try_consume(role_id, item_id, count)
    if type(count) ~= "number" or count ~= count
        or count <= 0 or count == math.huge or count % 1 ~= 0 then
        return false, "invalid_count"
    end
    local item = MsGetPlayerItem(role_id, item_id)
    if item == nil then
        return false, "item_unavailable"
    end
    if not item.can_use or item.stack_cnt < count then
        return false, "item_not_usable_or_not_enough"
    end
 
    local code = MsTryConsumeItem(role_id, item_id, count)
    if code ~= 0 then
        return false, code
    end
    return true, code
end

item_id 按现有接口声明取编辑器道具 ID,调用前应确认目标道具配置。返回 true 只表示扣减接口报告成功,扣减随后异步生效:同一次回调内查询到的还是旧数量,实际结果以 _item_info_changed、_consume_item_notify 给出的前后数量为准。不要在同一个回调里立即按新数量做后续发奖。

API 参考

本节“调用形式”用于查参数,变量由业务提供,不是独立运行脚本;完整示例见任务章节。除快速开始中的 y3.* 客户端接口外,所有 API 均用于云脚本环境。

事件系统

RegisterEvent

用途:注册系统事件或自定义事件的监听函数。

调用形式:local event_idx = RegisterEvent(event_name, callback)。

参数或返回值类型含义
event_namestring事件名称,系统事件见下表
callbackfunction接收事件参数的回调
返回值 event_idxinteger注册标识,供 UnRegisterEvent 使用

回调公共参数依次为 event_idx, role_id, event_name,后接各事件的附加参数。

事件用途与触发说明附加参数
_roomloaded房间加载完成player_guids:本局局内玩家 ID 序列;role_id 为 -1
_roomover房间结束;回调里的 role_id 固定为 -1reason: string:结束原因,如 UnsafeMemory、UnsafeCostTime、UnsafeTimeout、DeleteRoom,或平台结束对局时传入的原因
_playerexit玩家退出房间;主动退出、断线超时、被服务端踢出都会触发reason: string:主动退出为 CLIENT_GIVE_UP,断线超时为 CONNECTION_LOST,被踢出为 kick by server
_playerleave玩家断线时触发,早于同一次断线的 _playerexitreason: string;固定为 player disconnect
_playerreconnect断线玩家重连回本局时触发reason: string;固定为 player reconnect
_playerjoin玩家存档加载完成、该玩家的云脚本数据可用时触发(首次进房,以及房间已运行时补加载完成)JSON 字符串,内容是用户唯一 ID,形如 {"plat_aid":"<用户唯一 ID>"}
_item_info_changed道具信息变化;扣减生效后触发store_id, before_cnt, after_cnt,均为 integer
_obtain_item_notify获得道具通知;玩家购买道具时触发store_id, before_cnt, after_cnt,均为 integer
_consume_item_notify消耗道具通知;扣减生效后触发store_id, before_cnt, after_cnt,均为 integer
_localmodeloaded本地模拟服务的房间加载;当前 role_id 约定为 -1无
业务自定义名称接收客户端上行消息evalue: string,单条上限为 10 KB

player_guids 实际是 userdata(不是 table),可用 ipairs 遍历或用索引读取,单个元素是数字;不要依赖 type(player_guids) == "table",也不要直接把它交给 JSON 编码。

玩家生命周期事件还有几条顺序约定:

  • 断线时先触发 _playerleave,3 分钟后才触发同一次断线的 _playerexit(固定超时时长)。
  • _playerleave 和 _playerexit 的附加参数都是原因字符串,但取值不同,不要用同一套判断逻辑处理。
  • 重连触发 _playerreconnect,它只清除断线状态,不会撤销已经发生的退出:玩家一旦进入退出状态,房间仍会在退出检查里保存他的存档并把他移出房间。

相关操作:监听器移除使用 UnRegisterEvent;自定义消息回复使用 MsSendMlEvent。

UnRegisterEvent

用途:移除已经注册的监听器。调用形式:UnRegisterEvent(event_idx),无已声明的返回值。

下面的监听器只处理一次请求:

RegisterEvent("GuideOnce", function(event_idx, role_id, event_name, evalue)
    Log.info("GuideOnce: received")
    UnRegisterEvent(event_idx)
end)

玩家查询

下列接口的 role_id 均为 integer,优先取事件回调中的参数;查询失败时不要对 nil 做字符串拼接或数值计算。

接口与调用形式用途返回值注意事项
MsGetPlayerName(role_id)查询玩家名字string 或 nil玩家或房间不存在时返回 nil
MsGetPlayerMapLevel(role_id)查询地图等级integer 或 nil玩家或房间不存在时返回 nil;本地模拟服务固定为 1
MsGetPlayedTime(role_id)查询游玩时长integer 或 nil单位为秒;玩家不存在时返回 nil;本地模拟服务固定为 0
MsGetPlayedCount(role_id)查询游玩次数integer 或 nil玩家不存在时返回 nil;本地模拟服务固定为 1
MsGetPlayerItem(role_id, goods_id)查询指定道具信息ItemInfo 或 nilgoods_id: integer,为编辑器道具 ID;不存在时返回 nil

使用示例见“查询玩家信息”和“查询并尝试消耗道具”;这几个字段的返回值不稳定,不能当作有效业务数据,判定逻辑不要依赖它们的数值。

ItemInfo

字段类型含义
stack_cntinteger玩家当前拥有该道具的数量
can_useboolean是否可使用:消耗型平台道具为 true,时限型平台道具为 false
expired_timeinteger到期时间,Unix 时间戳(秒);无过期道具返回 -1
max_stack_cntinteger该道具的最大数量;不要用特殊值判断有无上限

平台道具分两类:消耗型可以使用,can_use 为 true;时限型不能使用,can_use 为 false,但带 expired_time 到期时间戳。能否使用看 can_use,什么时候到期看 expired_time。

玩家脚本存档

接口与调用形式参数返回值
MsGetScriptArchive(role_id)role_id: integer存档字符串;首次无存档和玩家不存在都返回 nil
MsSaveScriptArchive(role_id, script_save_data)role_id: integer,script_save_data: stringboolean,接口报告是否保存成功

使用示例见“读取、修改并保存玩家存档”。自行用 JSON 定义字段结构和版本,不要把解析失败的旧存档替换为空档。

存档上限、读写频率、保存时机和并发写入规则见玩家脚本存档限制。

道具消耗

MsTryConsumeItem

用途:尝试消耗道具。调用形式:local code = MsTryConsumeItem(role_id, item_store_id, cnt)。

参数类型含义
role_idinteger玩家角色 ID
item_store_idinteger编辑器道具 ID,与 MsGetPlayerItem 的 goods_id 是同一套 ID
cntinteger消耗数量;业务应校验为正整数

返回 MlRoomErrorCode 整数错误码,0 表示接口报告成功。扣减是异步生效的:同一次回调里立即查询仍是旧数量,新数量由 _item_info_changed、_consume_item_notify 带出。判定扣减结果请用事件给出的 before_cnt/after_cnt,或稍后再查。

事件参数 store_id 与查询、扣减参数指向同一套编辑器道具 ID,可以直接使用。

MlRoomErrorCode

错误码名称值含义
OK0接口报告操作成功
RoomNotExist2房间不存在
PlayerNotExist3玩家不存在
ItemNotExisted12道具不存在
ItemNotEnough13道具数量不足
ItemNotUse14道具不可使用

运行环境不保证提供带这些名称的全局枚举对象,可直接比较整数,或在业务中定义局部常量表;未列出的返回码也应按失败处理并记录。

房间信息

接口与调用形式返回值单位与说明
MsGetRoomLoadedTs()integer 或 nil房间加载完成的时间戳,单位为秒;房间不存在时为 nil
MsGetRoomGameTime()integer 或 nil当前游戏时长,单位为秒;房间不存在时为 nil
MsGetRoomPlayerCount()integer 或 nil当前玩家数量;房间不存在时为 nil

这些接口无参数。游戏经过时间用 MsGetRoomGameTime,定时回调用 Timer;不要把单位为秒的结果直接当作 Timer.new_timer 的毫秒间隔。

通信与流程控制

MsSendMlEvent

用途:向指定玩家客户端发送自定义 ML 事件。

调用形式:local code = MsSendMlEvent(role_id, event_name, event_msg)。

参数类型限制
role_idinteger指定玩家角色 ID
event_namestring最长 32 字节
event_msgstring最长 10,240 字节

长度等于上限允许通过;事件名超过 32 字节返回 4,消息超过 10,240 字节返回 6,超限的消息不会进入发送队列。返回 0 表示消息已被接受入队,不代表客户端已经收到并处理。

role_id 只是消息携带的目标玩家:消息会广播给房间内所有客户端,所有监听该事件的客户端都会收到,需要由客户端按事件里的玩家字段过滤。

发送 JSON 时按编码后的字符串检查长度。下面是局部函数示例,供云脚本业务调用:

local json = require("cjson")
 
local function send_json(role_id, event_name, payload)
    if type(event_name) ~= "string" or #event_name > 32 then
        return false, "invalid_event_name"
    end
    local encode_ok, text = pcall(json.encode, payload)
    if not encode_ok then
        return false, "encode_failed"
    end
    if #text > 10240 then
        return false, "message_too_large"
    end
    local code = MsSendMlEvent(role_id, event_name, text)
    return code == 0, code
end

Lua 对字符串的 # 运算得到字节数,中文等字符可能占多个字节。上行和下行都是 10 KB(10,240 字节),但方向不同、校验位置不同,见消息与事件。客户端完整接收代码见“快速开始”。

MsEnd(已废弃)

云服务已废弃该接口,不要在新代码或测试中调用。旧调用形式为 MsEnd(role_id, reason),用于结束玩家脚本流程并触发房间结算。

旧参数为 role_id: integer 和 reason: string(最长 128 字节),旧返回值为整数错误码(0 表示成功)。

地图全局存档

redis.get_redis_client

调用形式:local client = redis.get_redis_client(),无参数,返回当前地图的 RedisClient。

每张地图共享一份跨房间数据。当前只开放以下三个方法,不支持 pub/sub 或 Lua script,不应直接套用完整 Redis 服务的其他能力。

RedisClient

方法参数用途
client:Get(key, callback)key: string读取 key 对应的值
client:Set(key, value, callback)key: string,value: string 或可转成字符串的数字设置值,数字会按字符串存储
client:IncrBy(key, increment, callback)key: string,increment: integer对整数值执行增量操作,增量必须是整数

callback 接收两个返回值:

情况第一个返回值第二个返回值
正常响应trueRedis 的回复内容:Set 为状态文本(如 "OK"),Get 为存储的字符串或 nil(key 不存在),IncrBy 为递增后的整数
连接或传输失败false错误文本
Redis 返回协议错误true错误文本

因此不能只看第一个返回值判断命令成功:使用 Get 时要确认第二个值不是错误文本,成功时用第二个值取数据。协程中可以省略回调并接收同样的两个返回值,协程外省略回调会报错。多个 key 或多步操作没有事务保证;key/value 限制、调用顺序、过期与删除方式见地图全局存档限制。

使用示例见“读写地图全局存档”。

JSON、日志与定时器

cjson

通过 local json = require("cjson") 获得 JSON 编解码模块。

调用形式用途使用说明
json.encode(value)编码为 JSON 字符串本文使用 Lua 表组织消息和存档;编码可能失败
json.decode(text)解码 JSON 字符串外部输入使用 pcall 捕获失败,并检查结果结构

解码结果不一定符合业务预期,示例会显式检查;null、空数组与空对象的转换规则未明确。

不要写 cjson = {} 初始化模块。这样会覆盖同名对象,而不会加载 JSON 功能。

Log

服务提供日志对象,无需写 Log = {}。以下方法均接收一个字符串,无已声明的返回值:

方法日志级别
Log.trace(text)TRACE
Log.debug(text)DEBUG
Log.info(text)INFO
Log.warn(text)WARN
Log.error(text)ERROR
Log.critical(text)CRITICAL
local code = 3
Log.info("Request received")
Log.warn("Request failed, code=" .. tostring(code))

本地脚本日志位于 cloud_script/script_log;频率和单条大小限制在线上环境生效(本地模拟服务不执行),见日志限制。

Timer

服务提供定时器对象,无需写 Timer = {}。

调用形式参数与返回值
Timer.new_timer(ms, callback)ms: integer,间隔单位为毫秒,最小 1 ms;返回整数定时器 ID
Timer.cancel_timer(timer_id)timer_id: integer,取消指定定时器;无已声明的返回值

callback 接收整数定时器 ID,返回 true 继续、false 停止。间隔必须是整数毫秒,传入浮点数会报参数错误、计时器不会创建。示例见“创建会自行停止的定时器”,调度精度与数量限制见定时器。

运行限制与配额

本章汇总作者需要遵守的容量、频率、时长和超限行为。未在此列出的项目没有明确承诺,按各 API 小节中的说明处理。

启动方式决定连接的服务

启动方式云脚本服务说明
编辑器内启动本地模拟服务可使用 LuaDebug,用来验证流程
独立进程启动本地模拟服务可使用 LuaDebug,用来验证流程
本地多开线上测试环境不使用 LuaDebug;专用于测试,与正式环境、线上地图自测环境相互隔离
正式上线正式环境不使用 LuaDebug

功能与数据以线上服务为准(本地多开连的是线上测试环境,正式上线连的是正式环境)。本地模拟服务的事件集合、返回值、资源限制和存档数据都可能与线上不同,不能由本地缺少某个事件推断线上不触发,本地跑通也只代表流程可用;日志、内存等限制在本地模拟服务上不生效,线上环境(测试环境与正式环境)都会执行。

本地多开连的线上测试环境与正式环境、线上地图自测环境相互隔离,但同一地图的房间共享一份全局存档,测试数据会在房间之间残留;请使用专用测试账号和带业务前缀的 key。

线上环境的平台限制

线上环境关闭部分系统级函数,业务入口不应依赖 os 或 LuaDebug。上传内容以 cloud_script 为根目录、main.lua 为入口;线上运行的 require 不搜索 C 模块和文件系统中的 Lua 文件,不要依赖开发机上的任意文件和库。

os 接口本地模拟服务线上环境(本地多开)
clock、date、difftime、time可调用可调用
execute、exit、getenv、remove、rename、setlocale、tmpname可调用不存在

上表之外的 os 字段不要假定存在;即使在本地可用,也不要在业务逻辑里依赖 os。

资源与消息限制

项目限制使用建议
单房间内存50 MB(52,428,800 字节)控制常驻缓存与积累的数据
客户端到云脚本单条上行事件数据 10 KB(10,240 字节)减少发送字段,避免接近边界
云脚本到客户端的事件名最长 32 字节使用简短稳定的名称
云脚本到客户端的消息内容最长 10,240 字节编码后检查字符串字节数

日志的频率与单条大小限制见日志限制。

消息与事件

  • 上行限制在客户端/战斗服侧检查,云脚本服务不校验上行长度,也不校验上行事件名的字符内容,上行事件名不单独限制长度。
  • 长度等于上限允许通过;下行超限时 MsSendMlEvent 返回 4(事件名)或 6(消息),且不进入发送队列。
  • 上行内容含内嵌 \0 时会在 \0 处被截断,非 UTF-8 内容不保证送达。
  • 上下行的发送频率都没有限制,单玩家和单房间均无限制。
  • 事件先进入房间事件列表,每个 tick 按顺序处理,处理期间新增的事件留到下一批;下行事件按房间累积,在 tick 中批量发送。

玩家脚本存档限制

项目限制
单份存档大小1,048,576 字节(1 MB),超过返回 false 并写云脚本日志
读取与写入频率都无限制
写入后可读性同一房间内保存成功后,后续读取直接读到新值,不需要等待落库
保存时机玩家退出房间后约 3 秒保存该玩家存档并把他移出房间;断线本身不触发保存,要等玩家进入退出状态;房间结束时保存全部在线玩家
并发写入同一账号同一时间只能运行一个对局,不存在多个房间同时写同一玩家存档的场景

地图全局存档限制

项目限制
key 长度无限制
key 数量无限制
value 类型字符串,或可转成字符串的数字
value 大小与总容量暂不承诺,后续根据情况设定限制
调用频率单房间、单地图均无限制
单条递增由 Redis 原子递增命令执行,跨房间适用
调用顺序同一房间内与代码顺序一致,跨房间不保证顺序
过期正式环境不设置过期时间;线上测试环境(本地多开)一天未访问后过期,Set、IncrBy 会刷新
删除没有删除接口,重置用 Set(key, 初始值)

内存与执行时长

  • 内存上限按当前房间 Lua 虚拟机的 GC 内存统计。
  • 计入范围包括 Lua 表、字符串、函数和模块对象、协程及其栈,以及加载到 Lua 的脚本源码;定时器和异步请求引用的 Lua 回调、参数也计入。原生内存(例如 C++ 定时器节点、Redis 网络缓冲、JSON 编解码的临时缓冲)不计入,所以该上限不等于房间全部原生内存的上限。
  • 超限不是拒绝分配:线上环境在 tick 之后检查,发现超过阈值就结束房间并进入结束流程,因此不要长期积累大对象。
  • 单次事件回调有执行量上限,默认按 Lua 字节码指令计数,上限 10,000,000 条,无法换算成固定毫秒数;超限会抛出执行超时错误。
  • 线上环境在整个 tick 结束后还会检查耗时:单次 tick 达到 5000 ms,或耗时超过 500 ms 的 tick 累计达到 10 次,都会结束房间。不要在事件回调里做长时间同步计算。
  • main.lua 的启动阶段同样受执行量上限约束,没有独立的毫秒级时限;入口加载失败会记录错误并停止,不会进入正常运行状态。

日志限制

以下限制在线上环境生效(线上测试环境与正式环境都会执行),本地模拟服务不执行大小限制、频率限制和异常去重:

项目规则
频率统计范围每个房间共用一个计数器,所有日志级别与 print 合计,按固定秒窗口划分,换秒清零
频率上限每秒 100 条;同一房间同一秒的第 101 条起直接丢弃,不写脚本日志,也不转发客户端
单条大小最多保留 2048 字节,长度等于 2048 时不截断;超出部分被截断,末尾三个字节替换为 ...
元数据不计入单条大小

截断按字节执行,可能截开 UTF-8 字符,因此不要把关键数据放在长日志的末尾。

定时器

项目规则
最小间隔1 ms;传入 0 按 1 ms 处理,负数返回定时器 ID 0;参数必须是毫秒整数
触发方式回调可能在同一 tick 内集中执行,不保证按毫秒均匀触发
数量没有硬性上限,但数量过多会推高内存和 CPU,最终可能导致房间关闭
房间结束进入约 5000 ms 的结束阶段,期间定时器和已返回的异步回调仍可能执行

小间隔的循环计时器会在短时间内产生大量回调,日志和性能压力随之上升;需要高频逻辑时优先按帧或按较大间隔合并处理。

示例工程的计时器测试按钮只拦截 timeout <= 0(空输入或 0),其余值都会发给云脚本。

调试与排错

使用 LuaDebug 设置断点

LuaDebug 只允许编辑器内启动或独立进程启动使用;本地多开连接线上环境,不能启用,正式上线同样禁止。

  1. 安装最新版 Y3 开发助手,并确认其依赖扩展已启用。
  2. 在开发助手中确认编辑器路径指向当前实际使用的 Y3 编辑器版本。
  3. 关闭“多开模式”。
  4. 打开 VS Code 左侧“Y3 开发助手”,展开“附加调试器”。
  5. 勾选“启动后附加本地云脚本”。
  6. 在已加载的云脚本模块中设置断点,点击“启动游戏”。
  7. 确认 VS Code 出现 Y3 Local Cloud Script 调试会话,然后触发对应事件。

“启动游戏后立即附加”控制地图 Lua 调试器,与“启动后附加本地云脚本”相互独立;仅调试云脚本时可以关闭前者。

关闭游戏时,本地云脚本进程和对应调试会话会随之结束。异常退出后有残留时,关闭相关游戏或调试进程再重试。

按现象排查

现象优先检查
没有 listener ready 日志云脚本入口是否加载了示例模块;是否存在语法或模块加载错误
客户端发消息后云脚本无响应事件名称是否完全一致;是否在游戏就绪后触发;是否连接了预期服务
云脚本有处理日志,客户端无结果下行返回码、消息名、编码后字节数,以及客户端监听器是否已注册
下行返回 4 或 6事件名是否超过 32 字节、消息是否超过 10,240 字节
JSON 解码失败云脚本是否收到字符串;客户端是否多做了一次不必要的编码;消息结构是否符合约定
Redis 提示不能在当前上下文调用使用回调形式,或把省略回调的调用放入协程
Redis 返回成功但数据不对第二个返回值是否其实是错误文本;命令被接受不等于结果正确
定时器没有输出模块是否加载;是否刚创建就取消;回调是否已经返回 false;单位是否为毫秒
地图等级、时长等数据固定检查是否使用本地模拟服务;本地存在固定返回值
日志缺行或结尾被截断是否超过每秒 100 条;单条是否超过 2048 字节
房间被结束检查内存占用,以及单次回调和单 tick 的执行时长
没有调试会话检查多开开关、附加选项、扩展依赖、编辑器路径和本地服务启动日志

本地文件位置

以下路径相对于项目根目录,仅描述本地模拟服务产生的文件;线上环境的日志不会写到这里。

内容路径说明
系统日志cloud_script/mls_log本地服务启动及运行日志
脚本日志cloud_script/script_logLog 输出
本地存档cloud_script/mls_archive内容经过 Base64 编码
崩溃转储cloud_script/dump本地服务崩溃后才可能生成

维护约定

  • 接口签名与 cloud_script/meta.lua 同步。
  • 迁移清单和目录树与示例项目的可运行结构同步。
  • 教学示例保留放置位置、触发方式、预期结果和失败处理。
  • 影响使用的结论同时保留在相应 API 旁边。