云脚本教程
云脚本在云脚本服务中处理玩家请求、查询玩家与道具信息、读写存档,并把结果发回客户端。
你可以点击这里下载本文配套的云脚本示例项目(云脚本参考项目0916.zip),下文提到的“示例项目”都指这份工程;示例项目建议用本地多开运行,连接线上测试环境。
本文档的目标读者是使用 Y3 编辑器、具备基础 Lua 知识的地图作者。
首次接入,请先看“新项目初始化与文件迁移”和“快速开始”;
实现具体功能看“常见任务”;
查参数和返回值看“API 参考”;
设计容量、频率和超限处理时看“运行限制与配额”。
新项目初始化与文件迁移
新建 Y3 项目默认没有 cloud_script/、云脚本入口和 meta.lua。
使用时要先复制可运行示例项目的基础结构,再替换示例业务代码;示例地图中的 cloud_script/ 可直接作为迁移源。
该目录位于项目根目录,与 maps/、global_script/ 同级,不要放到 maps/<地图名>/script/ 下。
1. 建立目录并迁移基础文件
在新项目根目录创建 cloud_script/,再按下表复制:“必需”组成本教程的最小可运行结构,“按需”文件只有被引用时才需要。
| 示例项目中的路径 | 新项目中的路径 | 必需性 | 用途与迁移说明 |
|---|---|---|---|
cloud_script/main.lua | cloud_script/main.lua | 必需 | 云脚本唯一入口。保留基础 require,删除示例测试模块的加载行,再加入自己的业务模块。 |
cloud_script/meta.lua | cloud_script/meta.lua | 推荐 | 为 RegisterEvent、Ms*、redis、Log、Timer 等提供 Lua 类型提示;运行时不要 require("meta")。 |
cloud_script/com_api.lua | cloud_script/com_api.lua | 按需 | 示例通用字符串工具。只有 main.lua 或业务模块仍然 require("com_api") 时才复制。 |
cloud_script/ms_api.lua | cloud_script/ms_api.lua | 按需 | LogInfo、LogWarn、LogClient 等测试辅助封装。正式业务若不需要这些封装,应删除对应 require;LogClient 上线前应移除或禁用。 |
cloud_script/dao/*.lua | cloud_script/dao/*.lua | 按需 | 示例的玩家/房间数据对象。复制后要改成自己的数据模型,不能把示例字段当作项目存档设计。 |
cloud_script/testcase/*.lua | cloud_script/testcase/*.lua | 测试专用 | API、Lua 调试和云服务更新测试。仅在需要复现实例或跑测试时复制;生产入口不要加载测试模块。 |
cloud_script/game_entry.lua | cloud_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 可能包含测试模块加载和示例日志。迁移后逐项处理:
- 删除
require("testcase.*")等测试入口,或仅在本地测试分支保留。 - 保留
main.lua作为唯一入口,把业务逻辑拆到独立模块,再由入口按依赖顺序require。 meta.lua只用于编辑器补全和静态检查,不要当作业务模块,也不要在入口中加载。
3. 接上客户端入口并验证
客户端入口 maps/<地图名>/script/main.lua 不属于 cloud_script/,两端通过 y3.game.send_mlscript_msg、RegisterEvent 和 MsSendMlEvent 通信。验证顺序:
- 确认
cloud_script/main.lua存在,且每个require都能在cloud_script/下找到对应文件。 - 用 Y3 开发助手启动本地模拟服务,先不要开启多开模式。
- 在云脚本日志中确认入口和自定义事件监听器已加载,再从客户端发送一次测试事件。
- 出现
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. 启动并检查结果
- 使用 Y3 开发助手启动游戏,第一次验证时关闭“多开模式”,使用本地模拟服务。
- 在
cloud_script/script_log中确认出现GuideHello: listener ready。 - 进入游戏后按一次空格。
- 在客户端日志中确认出现
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
enditem_id 按现有接口声明取编辑器道具 ID,调用前应确认目标道具配置。返回 true 只表示扣减接口报告成功,扣减随后异步生效:同一次回调内查询到的还是旧数量,实际结果以 _item_info_changed、_consume_item_notify 给出的前后数量为准。不要在同一个回调里立即按新数量做后续发奖。
API 参考
本节“调用形式”用于查参数,变量由业务提供,不是独立运行脚本;完整示例见任务章节。除快速开始中的 y3.* 客户端接口外,所有 API 均用于云脚本环境。
事件系统
RegisterEvent
用途:注册系统事件或自定义事件的监听函数。
调用形式:local event_idx = RegisterEvent(event_name, callback)。
| 参数或返回值 | 类型 | 含义 |
|---|---|---|
event_name | string | 事件名称,系统事件见下表 |
callback | function | 接收事件参数的回调 |
返回值 event_idx | integer | 注册标识,供 UnRegisterEvent 使用 |
回调公共参数依次为 event_idx, role_id, event_name,后接各事件的附加参数。
| 事件 | 用途与触发说明 | 附加参数 |
|---|---|---|
_roomloaded | 房间加载完成 | player_guids:本局局内玩家 ID 序列;role_id 为 -1 |
_roomover | 房间结束;回调里的 role_id 固定为 -1 | reason: string:结束原因,如 UnsafeMemory、UnsafeCostTime、UnsafeTimeout、DeleteRoom,或平台结束对局时传入的原因 |
_playerexit | 玩家退出房间;主动退出、断线超时、被服务端踢出都会触发 | reason: string:主动退出为 CLIENT_GIVE_UP,断线超时为 CONNECTION_LOST,被踢出为 kick by server |
_playerleave | 玩家断线时触发,早于同一次断线的 _playerexit | reason: 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 或 nil | goods_id: integer,为编辑器道具 ID;不存在时返回 nil |
使用示例见“查询玩家信息”和“查询并尝试消耗道具”;这几个字段的返回值不稳定,不能当作有效业务数据,判定逻辑不要依赖它们的数值。
ItemInfo
| 字段 | 类型 | 含义 |
|---|---|---|
stack_cnt | integer | 玩家当前拥有该道具的数量 |
can_use | boolean | 是否可使用:消耗型平台道具为 true,时限型平台道具为 false |
expired_time | integer | 到期时间,Unix 时间戳(秒);无过期道具返回 -1 |
max_stack_cnt | integer | 该道具的最大数量;不要用特殊值判断有无上限 |
平台道具分两类:消耗型可以使用,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: string | boolean,接口报告是否保存成功 |
使用示例见“读取、修改并保存玩家存档”。自行用 JSON 定义字段结构和版本,不要把解析失败的旧存档替换为空档。
存档上限、读写频率、保存时机和并发写入规则见玩家脚本存档限制。
道具消耗
MsTryConsumeItem
用途:尝试消耗道具。调用形式:local code = MsTryConsumeItem(role_id, item_store_id, cnt)。
| 参数 | 类型 | 含义 |
|---|---|---|
role_id | integer | 玩家角色 ID |
item_store_id | integer | 编辑器道具 ID,与 MsGetPlayerItem 的 goods_id 是同一套 ID |
cnt | integer | 消耗数量;业务应校验为正整数 |
返回 MlRoomErrorCode 整数错误码,0 表示接口报告成功。扣减是异步生效的:同一次回调里立即查询仍是旧数量,新数量由 _item_info_changed、_consume_item_notify 带出。判定扣减结果请用事件给出的 before_cnt/after_cnt,或稍后再查。
事件参数 store_id 与查询、扣减参数指向同一套编辑器道具 ID,可以直接使用。
MlRoomErrorCode
| 错误码名称 | 值 | 含义 |
|---|---|---|
OK | 0 | 接口报告操作成功 |
RoomNotExist | 2 | 房间不存在 |
PlayerNotExist | 3 | 玩家不存在 |
ItemNotExisted | 12 | 道具不存在 |
ItemNotEnough | 13 | 道具数量不足 |
ItemNotUse | 14 | 道具不可使用 |
运行环境不保证提供带这些名称的全局枚举对象,可直接比较整数,或在业务中定义局部常量表;未列出的返回码也应按失败处理并记录。
房间信息
| 接口与调用形式 | 返回值 | 单位与说明 |
|---|---|---|
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_id | integer | 指定玩家角色 ID |
event_name | string | 最长 32 字节 |
event_msg | string | 最长 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
endLua 对字符串的 # 运算得到字节数,中文等字符可能占多个字节。上行和下行都是 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 接收两个返回值:
| 情况 | 第一个返回值 | 第二个返回值 |
|---|---|---|
| 正常响应 | true | Redis 的回复内容: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 只允许编辑器内启动或独立进程启动使用;本地多开连接线上环境,不能启用,正式上线同样禁止。
- 安装最新版 Y3 开发助手,并确认其依赖扩展已启用。
- 在开发助手中确认编辑器路径指向当前实际使用的 Y3 编辑器版本。
- 关闭“多开模式”。
- 打开 VS Code 左侧“Y3 开发助手”,展开“附加调试器”。
- 勾选“启动后附加本地云脚本”。
- 在已加载的云脚本模块中设置断点,点击“启动游戏”。
- 确认 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_log | Log 输出 |
| 本地存档 | cloud_script/mls_archive | 内容经过 Base64 编码 |
| 崩溃转储 | cloud_script/dump | 本地服务崩溃后才可能生成 |
维护约定
- 接口签名与
cloud_script/meta.lua同步。 - 迁移清单和目录树与示例项目的可运行结构同步。
- 教学示例保留放置位置、触发方式、预期结果和失败处理。
- 影响使用的结论同时保留在相应 API 旁边。

