游戏服务器 API 使用清单
每个 API 都提供墨香中文原生 Lua两种写法,点击顶部按钮切换。
代码可直接复制使用,每一步都有"为什么这样写"的说明。

模块名称对照表 — 三种获取方式

① 模块单例:调用 XXX_获取实例() 无参数获取全局唯一实例
游戏= 游戏_获取实例()game
实体= 实体_获取实例()entity
网络= 网络_获取实例()network
工具= 工具_获取实例()util
引擎= 引擎_获取实例()engine
AOI= AOI_获取实例()aoi
广播= 广播_获取实例()broadcast
存储= 存储_获取实例()store
私有事件= 事件_获取实例()event
全局事件= 全局事件_获取实例()global_event
定时器= 定时器_获取实例()timer
协议= 协议_获取实例()protocol
配置= 配置_获取实例()config
网页= 网页_获取实例()http
地图= 地图_获取实例()map
AI= AI_获取实例()ai
脚本= 脚本_获取实例()script
脚本链= 脚本链_获取实例()script chain
数据库(db)= 数据库_获取实例()db
Redis缓存(cache)= Redis_获取实例()cache
② 回调参数类型:由框架自动传入,无需也不能通过获取实例()创建(所属类型=2)
实体代理= 引擎.创建实体() / 引擎.获取实体()engine.createEntity/getEntity
HTTP请求(req)= 网页对象.监听请求() 回调的 req 参数http.handle 回调参数
HTTP响应(resp)= HTTP_异步请求() 回调的 resp 参数http.request/get/post 回调参数

新手起步流程

必读
1第1步:初始化地图服务端启动时注册AOI地图
为什么需要AOI?AOI(九宫格)决定玩家能看到多远的其他玩家。不注册AOI,玩家之间互相看不到。

代码示例

墨香中文语法
// 必须用 引擎.注册模块 注册模块,初始化子程序在启动时自动调用
引擎.注册模块("world", &世界初始化)

.子程序 世界初始化, , , 服务端启动时执行一次
游戏对象.输出日志("[世界] 初始化...")

// 注册地图:地图ID=1, 宽3000, 高3000, 格子大小50, 视野范围1000
AOI.注册地图(1, 3000, 3000, 50, 1000)
-- 格子大小50:每50像素划分一个格子
-- 视野范围1000:玩家能看到周围1000像素内的其他玩家

存储.设置("地图", "1", {id: 1, 名称: "新手村", 宽度: 3000, 高度: 3000})
游戏对象.输出日志("[世界] 初始化完成")
原生 Lua 语法
-- 必须用 engine.registerModule 注册模块,onInit 中做初始化
engine.registerModule("world", { onInit = function()
        game.log("[世界] 初始化...")

        -- 注册地图:地图ID=1, 宽3000, 高3000, 格子大小50, 视野范围1000
        aoi.registerMap(1, 3000, 3000, 50, 1000)
        -- 格子大小50:每50像素划分一个格子
        -- 视野范围1000:玩家能看到周围1000像素内的其他玩家

        store.set("地图", "1", {id = 1, 名称 = "新手村", 宽度 = 3000, 高度 = 3000})
        game.log("[世界] 初始化完成")
    end
})
engine.registerModule:所有游戏逻辑必须写在 registerModule 里,onInit 在服务端启动时自动调用一次。
AOI参数选择:格子越小精度越高但消耗越大,一般50~100;视野范围根据游戏类型,传奇类800~1500。
2第2步:处理玩家登录创建实体→进入AOI→返回角色数据
这是最核心的流程。登录=创建实体+进入地图视野,登出=保存数据+离开地图+销毁实体。

代码示例

墨香中文语法
引擎.注册模块("login", &登录初始化)

.子程序 登录初始化, , , 注册消息处理器
游戏对象.监听消息("LOGIN", "LOGIN_RESULT", &登录处理)

.子程序 登录处理, 对象型, , 处理客户端发来的LOGIN消息
.参数 player, 玩家实体对象, , 发送消息的玩家对象
.参数 data, 表(消息数据), , 客户端发来的数据表

// 1. 从data中取出用户名和密码
let 用户名 = data.username 或 ""
let 密码 = data.password 或 ""

// 2. 校验账号密码(这里用内存表演示,有数据库时用数据库)
.如果(!accounts[用户名])
    返回({success: 假, error: "账号不存在"})
.如果结束
.如果(accounts[用户名] ~= 密码)
    返回({success: 假, error: "密码错误"})
.如果结束

// 3. 读取角色数据
let 角色数据 = characters[用户名]
.如果(!角色数据)
    返回({success: 假, error: "请先创建角色", needCreateRole: 真})
.如果结束

// 4. 创建实体——这一步让玩家"存在于游戏世界"
let 玩家 = 引擎.创建实体({ type: "player", name: 角色数据.nickname, hp: 角色数据.hp, maxHp: 角色数据.maxHp, mp: 角色数据.mp, maxMp: 角色数据.maxMp, level: 角色数据.level, x: 角色数据.x 或 1500, y: 角色数据.y 或 1500, mapId: 1, attrs: {uid: 用户名, job: 角色数据.job, attack: 角色数据.attack, gold: 角色数据.gold}
})

// 5. 绑定UID——让网络消息能通过UID找到玩家
玩家.绑定用户UID(用户名)

// 6. 让玩家进入AOI视野——这一步让其他玩家能看到他
AOI.进入(1, 玩家.id, 玩家.x, 玩家.y)

// 7. 记录在线玩家
onlinePlayers[用户名] = 玩家

// 8. 返回结果给客户端——return的表会自动发回
返回({success: 真, uid: 用户名, nickname: 角色数据.nickname, level: 角色数据.level})

.子程序 断线处理, , , 客户端断线时自动调用
.参数 data, 表(事件数据)
游戏对象.输出日志("[断线] 连接断开: " .. 转换到文本(data.connId))
-- 注意:断线时也要做和登出一样的清理工作
原生 Lua 语法
engine.registerModule("login", { onInit = function()
        game.onMessage("LOGIN", "LOGIN_RESULT", 登录处理)
    end,
    onDestroy = function()
        game.log("[断线] 模块销毁")
        -- 注意:断线时也要做和登出一样的清理工作
    end
})

-- 处理客户端发来的LOGIN消息
function 登录处理(data)
    -- 1. 从data中取出用户名和密码
    local 用户名 = data.username or ""
    local 密码 = data.password or ""

    -- 2. 校验账号密码
    if not accounts[用户名] then
        return {success = false, error = "账号不存在"}
    end
    if accounts[用户名] ~= 密码 then
        return {success = false, error = "密码错误"}
    end

    -- 3. 读取角色数据
    local 角色数据 = characters[用户名]
    if not 角色数据 then
        return {success = false, error = "请先创建角色", needCreateRole = true}
    end

    -- 4. 创建实体——这一步让玩家"存在于游戏世界"
    local 玩家 = engine.createEntity({ type = "player", name = 角色数据.nickname, hp = 角色数据.hp, maxHp = 角色数据.maxHp, mp = 角色数据.mp, maxMp = 角色数据.maxMp, level = 角色数据.level, x = 角色数据.x or 1500, y = 角色数据.y or 1500, mapId = 1, attrs = {uid = 用户名, job = 角色数据.job, attack = 角色数据.attack, gold = 角色数据.gold}
    })

    -- 5. 绑定UID——让网络消息能通过UID找到玩家
    玩家:bindUID(用户名)

    -- 6. 让玩家进入AOI视野——这一步让其他玩家能看到他
    aoi.enter(1, 玩家.id, 玩家.x, 玩家.y)

    -- 7. 记录在线玩家
    onlinePlayers[用户名] = 玩家

    -- 8. 返回结果给客户端——return的表会自动发回
    return {success = true, uid = 用户名, nickname = 角色数据.nickname, level = 角色数据.level}
end
核心流程:创建实体(engine.createEntity) → 绑定UID(:bindUID) → 进入AOI(aoi.enter) → 返回数据
实体属性:hp/mp/level等标准属性用.访问,自定义属性放在attrs表里用:getAttr/:setAttr访问。
断线处理:玩家直接关客户端不会发LOGOUT,必须监听onDisconnect做清理。
3第3步:处理玩家移动 + AOI视野同步移动时更新AOI,自动触发视野进出
移动=更新实体坐标+通知AOI。AOI会自动算出谁进出了谁的视野,然后触发事件,你只需要在事件里发消息通知客户端。

代码示例

墨香中文语法
引擎.注册模块("move", &移动初始化)

.子程序 移动初始化, , , 注册消息和事件
游戏对象.监听消息("MOVE", "MOVE_RESULT", &移动处理)

.子程序 移动处理, 对象型, , 处理移动消息
.参数 player, 玩家实体对象
.参数 data, 表(消息数据)

// 1. 取出新坐标,没有就用当前位置
let 新X = 转换到数值(data.x) 或 player.x
let 新Y = 转换到数值(data.y) 或 player.y

// 2. 更新实体坐标
player.x = 新X
player.y = 新Y

// 3. 通知AOI——这一步会自动触发视野进出事件
AOI.移动(player.mapId 或 1, player.id, 新X, 新Y)

// 4. 返回移动结果给客户端
返回({success: 真, x: 新X, y: 新Y})

.子程序 实体进入视野, , , 有实体进入玩家视野时触发
.参数 data, 表(视野事件数据)
// data.viewerId = 看到别人的玩家ID
// data.entityId = 被看到的实体ID
let e = 引擎.获取实体(data.entityId)
.如果(e)
    网络对象.发送给玩家(data.viewerId, { type: "PLAYER_ENTER", entityId: data.entityId, name: e.name, x: e.x, y: e.y, hp: e.hp, maxHp: e.maxHp, level: e.level
    })
.如果结束

.子程序 实体离开视野, , , 有实体离开玩家视野时触发
.参数 data, 表(视野事件数据)
网络对象.发送给玩家(data.viewerId, {type: "PLAYER_LEAVE", entityId: data.entityId})
原生 Lua 语法
engine.registerModule("move", { onInit = function()
        game.onMessage("MOVE", "MOVE_RESULT", 移动处理)
    end,
    onEntityEnterView = function(data)
        local e = engine.getEntity(data.entityId)
        if e then
            network.sendToPlayer(data.viewerId, { type = "PLAYER_ENTER", entityId = data.entityId, name = e.name, x = e.x, y = e.y, hp = e.hp, maxHp = e.maxHp, level = e.level
            })
        end
    end,
    onEntityLeaveView = function(data)
        network.sendToPlayer(data.viewerId, {type = "PLAYER_LEAVE", entityId = data.entityId})
    end
})

function 移动处理(player, data)
    -- 1. 取出新坐标,没有就用当前位置
    local 新X = tonumber(data.x) or player.x
    local 新Y = tonumber(data.y) or player.y

    -- 2. 更新实体坐标
    player.x = 新X
    player.y = 新Y

    -- 3. 通知AOI——这一步会自动触发视野进出事件
    aoi.move(player.mapId or 1, player.id, 新X, 新Y)

    -- 4. 返回移动结果给客户端
    return {success = true, x = 新X, y = 新Y}
end
AOI流程:aoi.enter进入地图 → aoi.move移动 → aoi.leave离开。
视野事件是自动的:调用aoi.move后AOI自动计算谁进出了视野,触发onEntityEnterView/onEntityLeaveView,你只需监听这两个事件发消息。
4第4步:聊天 + 广播三种广播:视野/地图/全服

代码示例

墨香中文语法
引擎.注册模块("chat", &聊天初始化)

.子程序 聊天初始化, , , 注册消息
游戏对象.监听消息("CHAT", "CHAT_RESULT", &聊天处理)

.子程序 聊天处理, 对象型, , 处理聊天消息
.参数 player, 玩家实体对象
.参数 data, 表(消息数据)

let 消息 = data.message 或 ""

// 方式1:地图内广播——同地图所有人都能看到
网络对象.广播给地图(player.mapId 或 1, { type: "CHAT_MSG", sender: player.name, message: 消息, timestamp: os.time() * 1000
})
返回({success: 真})

// 方式2:视野范围广播——只有附近的人能收到
-- 网络对象.广播给视野(player.id, {type: "CHAT_MSG", ...})

// 方式3:全服广播——所有人都能收到(慎用)
-- 网络对象.广播给全部({type: "SERVER_MSG", message: "全服公告"})
原生 Lua 语法
engine.registerModule("chat", { onInit = function()
        game.onMessage("CHAT", "CHAT_RESULT", function(data)
            local 消息 = data.message or ""

            -- 方式1:地图内广播——同地图所有人都能看到
            network.broadcastMap(player.mapId or 1, { type = "CHAT_MSG", sender = player.name, message = 消息, timestamp = os.time() * 1000
            })
            return {success = true}

            -- 方式2:视野范围广播——只有附近的人能收到
            -- network.broadcastRange(player.id, {type = "CHAT_MSG", ...})

            -- 方式3:全服广播——所有人都能收到(慎用)
            -- network.broadcastAll({type = "SERVER_MSG", message = "全服公告"})
        end)
    end
})
三种广播选哪个:聊天用地图广播,战斗特效用视野广播,服务器公告用全服广播。人越多越要少用全服广播。
5第5步:自动存档用定时器每60秒保存所有在线角色

代码示例

墨香中文语法
引擎.注册模块("autosave", &自动存档初始化)

.子程序 自动存档初始化, , , 启动定时器
游戏对象.创建循环定时器(&存档回调, 60000)  -- 每60000毫秒(60秒)执行一次

.子程序 存档回调, , , 定时保存角色数据
let 数量 = 0
.遍历(用户名, p, onlinePlayers)
    characters[用户名] = {
        nickname: p.name, job: p.读取属性("job"), level: p.level, hp: p.hp, maxHp: p.maxHp, mp: p.mp, maxMp: p.maxMp, x: p.x, y: p.y, attack: p.读取属性("attack"), gold: p.读取属性("gold")
    }
    数量 = 数量 + 1
.遍历结束
游戏对象.输出日志("[存档] 已保存 " .. 数量 .. " 个角色")
原生 Lua 语法
engine.registerModule("autosave", { onInit = function()
        game.setInterval(function()
            local 数量 = 0
            for 用户名, p in pairs(onlinePlayers) do
                characters[用户名] = {
                    nickname = p.name, job = p:getAttr("job"), level = p.level, hp = p.hp, maxHp = p.maxHp, mp = p.mp, maxMp = p.maxMp, x = p.x, y = p.y, attack = p:getAttr("attack"), gold = p:getAttr("gold")
                }
                数量 = 数量 + 1
            end
            game.log("[存档] 已保存 " .. 数量 .. " 个角色")
        end, 60000)  -- 每60000毫秒(60秒)执行一次
    end
})
game.setInterval创建循环定时器,返回的ID可用于game.clearInterval取消。最小间隔100毫秒。

game

游戏核心
FNgame.log(消息内容)输出日志
game.log(消息内容: 字符串) → 无返回值

参数说明

参数名类型必填/默认说明
消息内容字符串必填要输出的日志文字

代码示例

墨香中文语法
游戏对象.输出日志("[系统] 服务器启动完成")  game -- 用[模块名]前缀方便排查
游戏对象.输出日志("[战斗] 玩家 " .. 名字 .. " 击杀了怪物")
原生 Lua 语法
game.log("[系统] 服务器启动完成")  -- 用[模块名]前缀方便排查
game.log("[战斗] 玩家 " .. 名字 .. " 击杀了怪物")
用途:在控制台和日志文件中输出信息。建议:[模块名]前缀区分来源。
FNgame.onMessage(消息类型, 响应类型, 处理函数)注册消息处理函数 三参数
game.onMessage(消息类型: 字符串/数字, 响应类型: 字符串/数字, 处理函数) → 无返回值

参数说明

参数名类型必填/默认说明
消息类型字符串/数字必填客户端发送的消息类型编号或名称,如 "10001" 或 "LOGIN"
响应类型字符串/数字必填服务端回复的消息类型编号或名称,如 "10002" 或 "LOGIN_RESULT"
处理函数函数必填收到消息时执行,参数(data),返回表自动回复,返回false不回复

代码示例

墨香中文语法
游戏对象.监听消息(10001, 10002, &登录处理)
游戏对象.监听消息("CHAT", "CHAT_RESULT", &聊天处理)

.子程序 登录处理, , , 处理登录消息
.参数 data, 表(消息数据), , 客户端发来的数据
let 用户名 = data.username 或 ""
游戏对象.输出日志("[登录] 用户: " .. 用户名)
// return的表会自动以响应类型返回给客户端
返回({success: 真, uid: 用户名})

.子程序 聊天处理, , , 处理聊天消息
.参数 data, 表(消息数据), , 客户端发来的数据
let 内容 = data.message 或 ""
游戏对象.输出日志("[聊天] " .. 内容)
返回({success: 真})

// 返回 false 则不回复客户端
游戏对象.监听消息("HEARTBEAT", "", &心跳处理)
.子程序 心跳处理, , , 心跳
.参数 data, 表(消息数据)
返回(假)
原生 Lua 语法
game.onMessage(10001, 10002, function(data)
    game.log("[登录] 用户: " .. tostring(data.username))
    -- return的表会自动以响应类型(10002)返回给客户端
    return {success = true, uid = data.username}
end)

game.onMessage("CHAT", "CHAT_RESULT", function(data)
    local 内容 = data.message or ""
    game.log("[聊天] " .. 内容)
    return {success = true}
end)

-- 返回 false 则不回复客户端
game.onMessage("HEARTBEAT", "", function(data)
    return false
end)
三参数说明:消息类型是客户端发来的编号/名称,响应类型是服务端回复的编号/名称。处理函数参数:data是客户端发来的表。返回值:return的表会自动以响应类型发回客户端;return false则不回复。
FNgame.onEvent(事件名, 处理函数)监听服务器内部事件 NEW
game.onEvent(事件名: 字符串, 处理函数) → 无返回值

参数说明

参数名类型必填/默认说明
事件名字符串必填要监听的事件名称,如 onConnect/onDisconnect
处理函数函数必填事件触发时执行的函数

代码示例

墨香中文语法
游戏对象.监听事件("onConnect", &连接事件)
游戏对象.监听事件("onDisconnect", &断线事件)

.子程序 连接事件, , , 客户端连接时触发
.参数 data, 表(事件数据)
游戏对象.输出日志("[连接] 新连接: " .. 转换到文本(data.connId))

.子程序 断线事件, , , 客户端断开时触发
.参数 data, 表(事件数据)
游戏对象.输出日志("[断线] 连接断开: " .. 转换到文本(data.connId))
原生 Lua 语法
game.onEvent("onConnect", function(data)
    game.log("[连接] 新连接: " .. tostring(data.connId))
end)

game.onEvent("onDisconnect", function(data)
    game.log("[断线] 连接断开: " .. tostring(data.connId))
end)
与game.onMessage区别:onMessage处理客户端发的消息,onEvent处理服务端内部事件(连接/断开等)。
常用事件:onConnect(连接建立)、onDisconnect(断开连接)、onPlayerLogin(玩家登录)、onPlayerLogout(玩家登出)。
FNgame.onRawMessage(处理函数)底层原始WebSocket消息 NEW
game.onRawMessage(处理函数) → 无返回值

参数说明

参数名类型必填/默认说明
处理函数函数必填原始消息处理函数,参数(connId, uid, rawData, isBinary)

代码示例

墨香中文语法
// 当客户端发送二进制/加密消息时触发(JSON解析失败的消息)
游戏对象.监听原始消息(&原始消息处理)

.子程序 原始消息处理, , , 处理原始消息
.参数 connId, 文本型, , 连接ID
.参数 uid, 文本型, , 玩家UID
.参数 rawData, 文本型, , 原始消息字符串
.参数 isBinary, 逻辑型, , 是否二进制消息
游戏对象.输出日志("[原始消息] connId=" .. connId .. " binary=" .. 转换到文本(isBinary))
// rawData 可自行解析/解密后处理
原生 Lua 语法
-- 当客户端发送二进制/加密消息时触发(JSON解析失败的消息)
game.onRawMessage(function(connId, uid, rawData, isBinary)
    game.log("[原始消息] connId=" .. connId .. " binary=" .. tostring(isBinary))
    -- rawData 是原始字符串,可自行解析/解密
end)
用途:注册底层WebSocket原始消息处理器,当客户端发送的消息无法解析为JSON时触发(如二进制加密消息)。
注意:此回调在多线程环境并发执行,需注意线程安全。
参数:connId连接ID,uid玩家UID,rawData原始消息字符串,isBinary是否二进制消息。
FNgame.onWsConnect(处理函数)底层WebSocket连接建立 NEW
game.onWsConnect(处理函数) → 无返回值

参数说明

参数名类型必填/默认说明
处理函数函数必填连接建立处理函数,参数(connId, uid, ip)

代码示例

墨香中文语法
// 底层连接建立,早于高层 onConnect 事件
游戏对象.监听WS连接(&连接回调)

.子程序 连接回调, , , 底层连接建立
.参数 connId, 文本型, , 连接ID
.参数 uid, 文本型, , 玩家UID
.参数 ip, 文本型, , 客户端IP
游戏对象.输出日志("[WS连接] " .. connId .. " from " .. ip)
// 可用于IP黑名单、连接限流、二进制握手等底层控制
原生 Lua 语法
-- 底层连接建立,早于高层 onConnect 事件
game.onWsConnect(function(connId, uid, ip)
    game.log("[WS连接] " .. connId .. " from " .. ip)
    -- 可用于IP黑名单、连接限流、二进制握手等底层控制
end)
用途:注册底层WebSocket连接建立处理器,新连接建立时触发。
注意:此回调在多线程环境并发执行,早于高层 game.onEvent("onConnect") 事件。
适用场景:IP黑名单、连接限流、二进制握手等底层控制。
FNgame.onWsDisconnect(处理函数)底层WebSocket连接断开 NEW
game.onWsDisconnect(处理函数) → 无返回值

参数说明

参数名类型必填/默认说明
处理函数函数必填连接断开处理函数,参数(connId, uid)

代码示例

墨香中文语法
// 底层连接断开,早于高层 onDisconnect 事件
游戏对象.监听WS断开(&断线回调)

.子程序 断线回调, , , 底层连接断开
.参数 connId, 文本型, , 连接ID
.参数 uid, 文本型, , 玩家UID
游戏对象.输出日志("[WS断开] " .. connId)
// 可用于资源清理、会话存档、断线统计等
原生 Lua 语法
-- 底层连接断开,早于高层 onDisconnect 事件
game.onWsDisconnect(function(connId, uid)
    game.log("[WS断开] " .. connId)
    -- 可用于资源清理、会话存档、断线统计等
end)
用途:注册底层WebSocket连接断开处理器,连接断开时触发。
注意:此回调在多线程环境并发执行,早于高层 game.onEvent("onDisconnect") 事件。
适用场景:资源清理、会话存档、断线统计等底层控制。
FNgame.setInterval / game.clearInterval创建/取消循环定时器
game.setInterval(回调函数, 间隔毫秒) → 定时器ID
game.clearInterval(定时器ID) → 无返回值

代码示例

墨香中文语法
// 创建定时器:每1秒执行一次
let 定时器ID = 游戏对象.创建循环定时器(&每秒回调, 1000)

.子程序 每秒回调, , , 定时器回调
游戏对象.输出日志("[定时] 1秒过去了")

// 取消定时器(不再执行)
-- 游戏对象.取消循环定时器(定时器ID)
原生 Lua 语法
-- 创建定时器:每1秒执行一次
local 定时器ID = game.setInterval(function()
    game.log("[定时] 1秒过去了")
end, 1000)

-- 取消定时器(不再执行)
-- game.clearInterval(定时器ID)
常见场景:刷怪、回血、排行榜刷新。注意:最小间隔100毫秒,不取消会一直运行。
FNgame.jsonEncode / game.jsonDecodeLua表 ↔ JSON字符串

代码示例

墨香中文语法
// Lua表 → JSON字符串(用于Redis存储等)
let JSON文本, 错误 = 游戏对象.JSON编码({名字: "张三", 等级: 10})

// JSON字符串 → Lua表(用于读取Redis数据等)
let 数据, 错误 = 游戏对象.JSON解码('{"名字":"张三","等级":10}')
原生 Lua 语法
-- Lua表 → JSON字符串(用于Redis存储等)
local JSON文本, 错误 = game.jsonEncode({名字 = "张三", 等级 = 10})

-- JSON字符串 → Lua表(用于读取Redis数据等)
local 数据, 错误 = game.jsonDecode('{"名字":"张三","等级":10}')
用途:Redis只存字符串,需要用jsonEncode把Lua表转成JSON再存,读取时用jsonDecode转回。util.jsonEncode/jsonDecode功能相同。
FNgame.sendMessage / game.broadcast主动给玩家发消息

代码示例

墨香中文语法
// 发给单个玩家
游戏对象.发送消息(玩家ID, {type: "SYSTEM_MSG", 内容: "欢迎进入游戏!"})

// 发给所有在线玩家
游戏对象.全服广播({type: "ANNOUNCEMENT", 内容: "服务器5分钟后维护!", 倒计时: 300})
原生 Lua 语法
-- 发给单个玩家
game.sendMessage(玩家ID, {type = "SYSTEM_MSG", 内容 = "欢迎进入游戏!"})

-- 发给所有在线玩家
game.broadcast({type = "ANNOUNCEMENT", 内容 = "服务器5分钟后维护!", 倒计时 = 300})
等价于network.sendToPlayernetwork.broadcastAll,提供更简短的写法。
FNgame.getTime()服务器运行毫秒数

代码示例

墨香中文语法
let 运行秒 = 游戏对象.获取运行时间() / 1000
游戏对象.输出日志("服务器已运行 " .. 转换到文本(运行秒) .. " 秒")
原生 Lua 语法
local 运行秒 = game.getTime() / 1000
game.log("服务器已运行 " .. tostring(运行秒) .. " 秒")
适合计算技能冷却时间、心跳超时检测。
FNgame.setLogLevel / game.getLogLevel控制日志详细程度

代码示例

墨香中文语法
游戏对象.设置日志级别("warn")    -- 只输出warn和error
游戏对象.设置日志级别("debug")   -- 输出全部(开发时用)
let 级别 = 游戏对象.获取日志级别()
原生 Lua 语法
game.setLogLevel("warn")     -- 只输出warn和error
game.setLogLevel("debug")    -- 输出全部(开发时用)
local 级别 = game.getLogLevel()
级别:debug(最详细) > info > warn > error(最精简)。开发时用debug,上线后用warn。
FNgame.enableLogFile / game.enableConsole开关日志文件和控制台

代码示例

墨香中文语法
游戏对象.开关日志文件(真)   -- 写入server_debug.log
游戏对象.开关控制台(假)     -- 隐藏黑窗口
原生 Lua 语法
game.enableLogFile(true)   -- 写入server_debug.log
game.enableConsole(false)  -- 隐藏黑窗口

entity

实体管理
FNentity.create / entity.destroy创建/销毁实体

代码示例

墨香中文语法
// 创建实体(简化版,返回实体ID)
let 怪物ID = 实体.创建("monster", 1001, 200, 300)  entity
let 玩家ID = 实体.创建("player", 0, 1500, 1500)

// 销毁实体
实体.销毁(怪物ID)
原生 Lua 语法
-- 创建实体(简化版,返回实体ID)
local 怪物ID = entity.create("monster", 1001, 200, 300)
local 玩家ID = entity.create("player", 0, 1500, 1500)

-- 销毁实体
entity.destroy(怪物ID)
类型:player / monster / npc / pet / drop。推荐用engine.createEntity(返回代理对象更方便)。
FNentity.getProxy(实体ID) NEW获取实体代理对象

代码示例

墨香中文语法
let 代理 = 实体.获取代理(实体ID)
.如果(代理)
    游戏对象.输出日志("名称: " .. 代理.name)
.如果结束
原生 Lua 语法
local 代理 = entity.getProxy(实体ID)
if 代理 then
    game.log("名称: " .. 代理.name)
end
engine.getEntity等价。返回的代理对象可直接用.读写属性,详见"实体代理对象"章节。
FNentity.exists(实体ID) NEW检查实体是否存在

代码示例

墨香中文语法
.如果(实体.是否存在(怪物ID))
    实体.销毁(怪物ID)
.如果结束
原生 Lua 语法
if entity.exists(怪物ID) then
    entity.destroy(怪物ID)
end
删除前先检查,避免操作已销毁的实体报错。
FNentity.getType(实体ID) NEW获取实体类型

代码示例

墨香中文语法
let 类型 = 实体.获取类型(实体ID)  -- "player"/"monster"/"npc"/"pet"/"drop"
原生 Lua 语法
local 类型 = entity.getType(实体ID)  -- "player"/"monster"/"npc"/"pet"/"drop"
FNentity.getAttr / entity.setAttr / entity.modifyAttr读写实体自定义属性

代码示例

墨香中文语法
// 读取属性
let 攻击力 = 实体.读取属性(玩家ID, "attack")

// 设置属性(不存在会自动创建)
实体.设置属性(玩家ID, "attack", 80)

// 增减属性(线程安全,战斗扣血必须用这个)
let 新金币 = 实体.修改属性(玩家ID, "gold", -100)  -- 花了100金币
let 新经验 = 实体.修改属性(玩家ID, "exp", 50)     -- 加了50经验
原生 Lua 语法
-- 读取属性
local 攻击力 = entity.getAttr(玩家ID, "attack")

-- 设置属性(不存在会自动创建)
entity.setAttr(玩家ID, "attack", 80)

-- 增减属性(线程安全,战斗扣血必须用这个)
local 新金币 = entity.modifyAttr(玩家ID, "gold", -100) -- 花了100金币
local 新经验 = entity.modifyAttr(玩家ID, "exp", 50)    -- 加了50经验
为什么战斗要用modifyAttr?多人同时攻击同一怪物时,用getAttr+setAttr可能丢数据(读到的都是旧值),modifyAttr是原子操作不会出问题。
FNentity.getHP / setHP / modifyHP / getMaxHP / setMaxHP / getMP / setMP NEW生命值/魔法值操作

代码示例

墨香中文语法
let 血量 = 实体.读取HP(玩家ID)
实体.设置HP(玩家ID, 1000)       -- 直接设置
实体.修改HP(玩家ID, -50)        -- 扣50血

let 最大血 = 实体.读取最大HP(玩家ID)  -- NEW
实体.设置最大HP(玩家ID, 2000)         -- NEW

let 蓝 = 实体.读取MP(玩家ID)           -- NEW
实体.设置MP(玩家ID, 500)              -- NEW
原生 Lua 语法
local 血量 = entity.getHP(玩家ID)
entity.setHP(玩家ID, 1000)
entity.modifyHP(玩家ID, -50)

local 最大血 = entity.getMaxHP(玩家ID)  -- NEW
entity.setMaxHP(玩家ID, 2000)          -- NEW

local 蓝 = entity.getMP(玩家ID)         -- NEW
entity.setMP(玩家ID, 500)              -- NEW
升级时记得同步调大maxHp/maxMp。
FNentity.getPosition / setPosition / moveTo / setMap坐标/地图操作

代码示例

墨香中文语法
let X坐标, Y坐标 = 实体.读取坐标(玩家ID)
实体.设置坐标(玩家ID, 1600, 1600)  -- 瞬移(不触发移动事件)
实体.移动到(玩家ID, 1600, 1600)     -- NEW: 有移动过程
实体.设置地图(玩家ID, 2)            -- NEW: 切换地图ID
原生 Lua 语法
local X坐标, Y坐标 = entity.getPosition(玩家ID)
entity.setPosition(玩家ID, 1600, 1600)  -- 瞬移
entity.moveTo(玩家ID, 1600, 1600)       -- NEW: 有移动过程
entity.setMap(玩家ID, 2)                -- NEW: 切换地图ID
setPosition是瞬移(传送),moveTo有移动过程。正常移动用AOI系统。
FNentity.getName / setName / getLevel / setLevel读写名称和等级

代码示例

墨香中文语法
let 名字 = 实体.读取名称(玩家ID)
实体.设置名称(玩家ID, "新名字")
let 等级 = 实体.读取等级(玩家ID)
实体.设置等级(玩家ID, 等级 + 1)
原生 Lua 语法
local 名字 = entity.getName(玩家ID)
entity.setName(玩家ID, "新名字")
local 等级 = entity.getLevel(玩家ID)
entity.setLevel(玩家ID, 等级 + 1)
FNentity.getAllAttrs / entity.setAllAttrs批量读写所有属性

代码示例

墨香中文语法
let 全部 = 实体.读取所有属性(玩家ID)
实体.设置所有属性(玩家ID, {attack: 80, defense: 30, job: "warrior"})
原生 Lua 语法
local 全部 = entity.getAllAttrs(玩家ID)
entity.setAllAttrs(玩家ID, {attack = 80, defense = 30, job = "warrior"})

network

网络通信
FNnetwork.sendToPlayer(用户UID, 消息包)按UID发给单个玩家

代码示例

墨香中文语法
网络对象.发送给玩家("zhangsan", {type: "DAMAGE", 目标ID: 1001, 伤害值: 50})  network
原生 Lua 语法
network.sendToPlayer("zhangsan", {type = "DAMAGE", 目标ID = 1001, 伤害值 = 50})
用户UID发送,需要先用:bindUID绑定。消息包为完整的数据对象,会自动序列化为JSON发给客户端。
FNnetwork.sendToConn(连接ID, 消息包) NEW按连接ID发给客户端

代码示例

墨香中文语法
网络对象.发送给连接(data.connId, {type: "WELCOME", 消息: "连接成功"})
原生 Lua 语法
network.sendToConn(data.connId, {type = "WELCOME", 消息 = "连接成功"})
与sendToPlayer区别:sendToConn按连接ID发(登录前也能用),sendToPlayer按用户UID发(登录后用)。
FNnetwork.broadcastRange / broadcastMap / broadcastAll三种广播方式

代码示例

墨香中文语法
// 视野内广播——只有附近玩家收到
网络对象.广播给视野(怪物ID, {type: "ENTITY_MOVE", 实体ID: 怪物ID, X: 200, Y: 300})

// 地图内广播——同地图所有人收到
网络对象.广播给地图(1, {type: "SERVER_MSG", 内容: "BOSS已刷新!"})

// 全服广播——所有人收到(慎用!)
网络对象.广播给全部({type: "CHAT_MSG", 频道: "world", 发送者: "系统", 内容: "欢迎!"})
原生 Lua 语法
-- 视野内广播——只有附近玩家收到
network.broadcastRange(怪物ID, {type = "ENTITY_MOVE", 实体ID = 怪物ID, X = 200, Y = 300})

-- 地图内广播——同地图所有人收到
network.broadcastMap(1, {type = "SERVER_MSG", 内容 = "BOSS已刷新!"})

-- 全服广播——所有人收到(慎用!)
network.broadcastAll({type = "CHAT_MSG", 频道 = "world", 发送者 = "系统", 内容 = "欢迎!"})
选择原则:聊天→地图广播,战斗特效→视野广播,服务器公告→全服广播。
FNnetwork.bindUID(连接ID, 用户UID) NEW绑定连接与用户

代码示例

墨香中文语法
// 在登录成功后绑定,之后可用UID发消息
网络对象.绑定用户UID(data.connId, 用户名)
原生 Lua 语法
-- 在登录成功后绑定,之后可用UID发消息
network.bindUID(data.connId, 用户名)
必须绑定sendToPlayerbroadcastUIDs才能通过UID找到对应连接。建议在登录成功时绑定。
FNnetwork.kickPlayer(实体ID, 原因) NEW强制踢出玩家

代码示例

墨香中文语法
网络对象.踢出玩家(玩家ID, "违规操作")
原生 Lua 语法
network.kickPlayer(玩家ID, "违规操作")
强制断开玩家连接,原因会发给客户端。
FNnetwork.isOnline(实体ID) / getOnlineCount() NEW在线状态/人数

代码示例

墨香中文语法
.如果(网络对象.是否在线(玩家ID))
    游戏对象.输出日志("玩家在线")
.如果结束
let 在线人数 = 网络对象.获取在线人数()
原生 Lua 语法
if network.isOnline(玩家ID) then
    game.log("玩家在线")
end
local 在线人数 = network.getOnlineCount()
FNnetwork.broadcastUIDs(UID列表, 消息包) NEW向一组UID广播

代码示例

墨香中文语法
网络对象.广播给指定用户({"a","b","c"}, {type: "TEAM_MSG", 内容: "组队消息"})
原生 Lua 语法
network.broadcastUIDs({"a","b","c"}, {type = "TEAM_MSG", 内容 = "组队消息"})
适合组队、公会等场景,只给指定的几个玩家发消息。

util

工具函数
FNutil.now()当前Unix时间戳(毫秒)
墨香中文语法
let 开始时间 = 工具.当前时间()  util
let 耗时 = 工具.当前时间() - 开始时间
游戏对象.输出日志("耗时 " .. 转换到文本(耗时) .. " 毫秒")
原生 Lua 语法
local 开始时间 = util.now()
local 耗时 = util.now() - 开始时间
game.log("耗时 " .. tostring(耗时) .. " 毫秒")
FNutil.timestamp() NEW当前Unix时间戳(秒)
墨香中文语法
let 秒 = 工具.获取时间戳()
游戏对象.输出日志("当前时间戳: " .. 转换到文本(秒))
原生 Lua 语法
local 秒 = util.timestamp()
game.log("当前时间戳: " .. tostring(秒))
util.now()返回毫秒,util.timestamp()返回秒。存数据库用秒,计算耗时用毫秒。
FNutil.formatTime(时间戳, 格式) NEW时间戳格式化
墨香中文语法
let 时间文本 = 工具.格式化时间(工具.获取时间戳(), "%Y-%m-%d %H:%M:%S")
原生 Lua 语法
local 时间文本 = util.formatTime(util.timestamp(), "%Y-%m-%d %H:%M:%S")
格式默认"%Y-%m-%d %H:%M:%S",时间戳为秒级。
FNutil.random(最小值, 最大值) / util.randomFloat()随机数
墨香中文语法
let 骰子 = 工具.随机整数(1, 6)  -- 1到6的随机整数
let 暴击率 = 工具.随机浮点数()    -- [0,1)随机浮点数 NEW
原生 Lua 语法
local 骰子 = util.random(1, 6)  -- 1到6的随机整数
local 暴击率 = util.randomFloat()   -- [0,1)随机浮点数 NEW
FNutil.distance / util.distanceXY计算距离
墨香中文语法
let 距离 = 工具.计算距离(玩家ID, 怪物ID)      -- 两个实体间
let 距离2 = 工具.计算坐标距离(0, 0, 3, 4)      -- 两个坐标间
原生 Lua 语法
local 距离 = util.distance(玩家ID, 怪物ID)      -- 两个实体间
local 距离2 = util.distanceXY(0, 0, 3, 4)       -- 两个坐标间
FNutil.parseInt / parseFloat / toString / trim类型转换
墨香中文语法
let 数值 = 转换到数值("42")       -- 字符串→整数
let 小数 = 转换到小数("3.14")     -- 字符串→小数
let 文本 = 转换到文本(100)        -- 任意→字符串
let 干净 = 删首尾空("  张三  ")   -- 去掉两端空格
原生 Lua 语法
local 数值 = util.parseInt("42")       -- 字符串→整数
local 小数 = util.parseFloat("3.14")    -- 字符串→小数
local 文本 = util.toString(100)         -- 任意→字符串
local 干净 = util.trim("  张三  ")      -- 去掉两端空格
FNutil.md5 / util.base64Encode / util.base64Decode NEW加密和编码
墨香中文语法
let 哈希 = 工具.MD5哈希("password123")       -- MD5哈希
let 编码 = 工具.Base64编码("Hello世界")        -- Base64编码
let 解码 = 工具.Base64解码(编码)               -- Base64解码
原生 Lua 语法
local 哈希 = util.md5("password123")           -- MD5哈希
local 编码 = util.base64Encode("Hello世界")     -- Base64编码
local 解码 = util.base64Decode(编码)            -- Base64解码
md5用于密码哈希、数据校验。base64用于二进制数据编码传输。
FNutil.sleep(毫秒) NEW协程休眠(不阻塞服务器)
墨香中文语法
// 等待2秒后再执行(不阻塞其他玩家)
工具.休眠(2000)
游戏对象.输出日志("2秒后执行")
原生 Lua 语法
-- 等待2秒后再执行(不阻塞其他玩家)
util.sleep(2000)
game.log("2秒后执行")
协程休眠:只暂停当前协程,不阻塞服务器。其他玩家的请求照常处理。适合等数据库返回、延时操作等场景。
FNutil.jsonEncode / util.jsonDecode NEWJSON编解码(与game版相同)
util.jsonEncode/jsonDecodegame.jsonEncode/jsonDecode功能完全相同,提供两种写法。

engine

引擎模块
FNengine.createEntity(配置表)创建实体(推荐方式)
推荐用这个而不是entity.create,因为返回代理对象可以直接用.读写属性。

配置表字段

字段类型默认说明
type字符串"player"实体类型
name字符串""名称
hp/maxHp/mp/maxMp数字0生命/魔法
level数字0等级
x/y数字0坐标
mapId数字0地图ID
attrs可选自定义属性,放任意键值对

代码示例

墨香中文语法
let 玩家 = 引擎.创建实体({  engine
    type: "player", name: "张三",
    hp: 1000, maxHp: 1000, mp: 500, maxMp: 500,
    level: 1, x: 1500, y: 1500, mapId: 1,
    attrs: {uid: "zhangsan", job: "warrior", attack: 60, gold: 100}
})
// 代理对象可以直接读写属性
游戏对象.输出日志(玩家.name)    -- 读取名称
玩家.hp = 800               -- 修改HP
玩家.设置属性("exp", 100)    -- 设置自定义属性
原生 Lua 语法
local 玩家 = engine.createEntity({ type = "player", name = "张三", hp = 1000, maxHp = 1000, mp = 500, maxMp = 500, level = 1, x = 1500, y = 1500, mapId = 1, attrs = {uid = "zhangsan", job = "warrior", attack = 60, gold = 100}
})
-- 代理对象可以直接读写属性
game.log(玩家.name)         -- 读取名称
玩家.hp = 800                -- 修改HP
玩家:setAttr("exp", 100)     -- 设置自定义属性
自定义属性放在attrs表里,通过:getAttr/:setAttr访问。标准属性(hp/mp/level/x/y)用.访问。详细用法见"实体代理对象"章节。
FNengine.removeEntity / engine.getEntity删除/获取实体
墨香中文语法
引擎.移除实体(怪物ID)
let 实体 = 引擎.获取实体(实体ID)
.如果(实体)
    游戏对象.输出日志("实体名称: " .. 实体.name)
.如果结束
原生 Lua 语法
engine.removeEntity(怪物ID)
local 实体 = engine.getEntity(实体ID)
if 实体 then
    game.log("实体名称: " .. 实体.name)
end
FNengine.onInit / onUpdate / onDestroy生命周期事件
墨香中文语法
引擎.注册模块("my_module", &模块初始化)  -- 注册模块,启动时调用初始化

.子程序 模块初始化, , , 启动时执行
游戏对象.输出日志("[模块] 初始化")

.子程序 每帧更新, , , 每帧回调
.参数 deltaMs, 数值型

.子程序 模块销毁, , , 关闭时执行
原生 Lua 语法
engine.registerModule("my_module", { onInit = function()
        game.log("[模块] 初始化")          -- 启动时调用一次
    end,
    onUpdate = function(deltaMs)
        -- 每帧调用,deltaMs是帧间隔毫秒数
    end,
    onDestroy = function()
        game.log("[模块] 销毁")            -- 关闭时调用
    end
})
onInit注册消息/初始化数据,onUpdate做定时轮询(如AI逻辑),onDestroy保存/清理。
FNengine.emit手动触发事件
墨香中文语法
引擎.触发事件("onBossDeath", {bossId: 1001, killerId: 玩家ID})
原生 Lua 语法
engine.emit("onBossDeath", {bossId = 1001, killerId = 玩家ID})
触发事件后,所有用event.on注册了该事件的处理器都会被调用。

aoi

AOI九宫格
FNaoi.registerMap / enter / leave / move地图注册+实体进出+移动
墨香中文语法
// 注册地图(启动时调用一次)
AOI.注册地图(1, 3000, 3000, 50, 1000)  aoi
-- 参数:地图ID, 宽, 高, 格子大小, 视野范围

// 实体进入地图
AOI.进入(1, 玩家ID, 玩家.x, 玩家.y)

// 实体移动
AOI.移动(1, 玩家ID, 新X, 新Y)

// 实体离开地图
AOI.离开(1, 玩家ID)
原生 Lua 语法
-- 注册地图(启动时调用一次)
aoi.registerMap(1, 3000, 3000, 50, 1000)
-- 参数:地图ID, 宽, 高, 格子大小, 视野范围

-- 实体进入地图
aoi.enter(1, 玩家ID, 玩家.x, 玩家.y)

-- 实体移动
aoi.move(1, 玩家ID, 新X, 新Y)

-- 实体离开地图
aoi.leave(1, 玩家ID)
格子大小:越小精度越高但消耗越大,一般50~100。视野范围:决定玩家能看到多远的实体。
FNaoi.getEntities获取视野内实体列表
墨香中文语法
let 附近实体 = AOI.获取视野实体(1, 玩家ID)
.遍历(i, id, 附近实体)
    游戏对象.输出日志("附近实体: " .. 转换到文本(id))
.遍历结束
原生 Lua 语法
local 附近实体 = aoi.getEntities(1, 玩家ID)
for i, id in ipairs(附近实体) do
    game.log("附近实体: " .. tostring(id))
end
FNaoi.setViewRange(半径) NEW动态调整视野范围
墨香中文语法
AOI.设置视野范围(1500)  -- 扩大视野到1500像素
原生 Lua 语法
aoi.setViewRange(1500)  -- 扩大视野到1500像素
运行时动态调整视野范围,例如GM命令扩大/缩小可视距离。

broadcast

广播
broadcast 是 network 的语义化别名,函数名更直观。功能完全相同。
Go层修复:广播系统现在支持数字类型码字符串类型两种消息格式——game.onMessage中直接传数字编码(如10001, 10002)即使用高性能数字通信;传字符串名称则自动回退为raw JSON格式{"type":"XXX","data":{...}},确保所有广播函数都能正常工作。
FNbroadcast.broadcastToView / broadcastToArea / broadcastToWorld / sendToPlayers四种广播方式
墨香中文语法
// 视野内广播
广播.广播到视野(BOSS_ID, "SKILL_EFFECT", {技能ID: 1})  broadcast

// 区域广播——指定中心坐标 NEW
广播.广播到区域(1500, 1500, "AREA_MSG", {内容: "此区域公告"})

// 全世界广播 NEW
广播.广播到世界("WORLD_MSG", {内容: "全服公告"})

// 发给多个指定玩家 NEW
广播.发送给多个玩家({玩家1ID, 玩家2ID}, "TEAM_MSG", {内容: "组队消息"})
原生 Lua 语法
-- 视野内广播
broadcast.broadcastToView(BOSS_ID, "SKILL_EFFECT", {技能ID = 1})

-- 区域广播——指定中心坐标 NEW
broadcast.broadcastToArea(1500, 1500, "AREA_MSG", {内容 = "此区域公告"})

-- 全世界广播 NEW
broadcast.broadcastToWorld("WORLD_MSG", {内容 = "全服公告"})

-- 发给多个指定玩家 NEW
broadcast.sendToPlayers({玩家1ID, 玩家2ID}, "TEAM_MSG", {内容 = "组队消息"})
broadcastToView=视野广播,broadcastToArea=坐标区域广播,broadcastToWorld=全服广播,sendToPlayers=多玩家发送。

store

内存KV存储
始终可用,不依赖数据库。但重启后数据丢失,适合缓存和临时数据。
FNstore.save / store.load / store.remove保存/读取/删除
墨香中文语法
存储.保存数据("地图_1", {id: 1, 名称: "新手村", 宽度: 3000})  store
存储.保存数据("配置_最大等级", 100)

let 地图数据 = 存储.读取数据("地图_1")
.如果(地图数据)
    游戏对象.输出日志("地图: " .. 地图数据.名称)
.如果结束

存储.删除数据("临时_上次BOSS")
原生 Lua 语法
store.save("地图_1", {id = 1, 名称 = "新手村", 宽度 = 3000})
store.save("配置_最大等级", 100)

local 地图数据 = store.load("地图_1")
if 地图数据 then
    game.log("地图: " .. 地图数据.名称)
end

store.remove("临时_上次BOSS")
建议:用下划线分类命名键名(如"地图_1"、"配置_最大等级"),避免冲突。

私有事件 event

事件系统
FN私有事件.注册 / 私有事件.触发 event.on / event.emit注册监听/触发事件
墨香中文语法
// 注册事件监听(跨脚本通信)
事件.注册("onBossDeath", &BOSS死亡处理)  event

.子程序 BOSS死亡处理, , , BOSS死亡时触发
.参数 参数, 表(事件数据)
游戏对象.输出日志("[事件] BOSS " .. 参数.bossId .. " 被击杀")

// 触发事件(可在任意位置调用)
事件.触发("onBossDeath", {bossId: 1001, killerId: 玩家ID})
原生 Lua 语法
-- 注册事件监听(跨脚本通信)
event.on("onBossDeath", function(参数)
    game.log("[事件] BOSS " .. 参数.bossId .. " 被击杀")
end)

-- 触发事件(可在任意位置调用)
event.emit("onBossDeath", {bossId = 1001, killerId = 玩家ID})
与game.onEvent区别:私有事件.注册event.on是自定义事件的跨脚本通信,游戏对象.监听事件game.onEvent是监听系统事件(连接/断开等)。
⚠️ 重要:私有事件event 模块属于 玩家虚拟机(每玩家独立),每个玩家独享一个 Lua 虚拟机。
如果你的逻辑涉及跨玩家操作(Boss刷新、拍卖结算、排行榜、全服广播等),请使用 全局事件global_event(见下一节)。
私有事件 和 全局事件 不能在同一个文件中混用!它们运行在不同的虚拟机中,API互不兼容。

全局事件 global_event

全局事件系统 NEW
全局事件global_event私有事件event 看起来相似,但运行在完全不同的环境中:
私有事件event → 每个玩家的独立虚拟机(玩家VM),全局事件global_event → 全服唯一的虚拟机(全局VM)。
简单判断:逻辑只涉及一个玩家 → 用 私有事件event;逻辑涉及多个玩家或全服数据 → 用 全局事件global_event
FN全局事件.注册 / 全局事件.触发 global_event.on / global_event.emit注册/触发全局事件
墨香中文语法
// 注册全局事件监听(跨玩家通信)
全局事件.注册("boss_spawn", &BOSS刷新处理)  global_event

.子程序 BOSS刷新处理, , , BOSS刷新时触发
.参数 参数, 表(事件数据)
游戏对象.输出日志("[全局] BOSS " .. 参数.bossId .. " 刷新了")

// 触发全局事件(可在任意位置调用)
全局事件.触发("boss_spawn", {bossId: 1001})
原生 Lua 语法
-- 注册全局事件监听(跨玩家通信)
global_event.on("boss_spawn", function(参数)
    game.log("[全局] BOSS " .. 参数.bossId .. " 刷新了")
end)

-- 触发全局事件(可在任意位置调用)
global_event.emit("boss_spawn", {bossId = 1001})
🚫 绝对禁止混用!
私有事件event全局事件global_event 不能在同一个文件中使用!服务端通过检测脚本内容自动判断脚本归属哪种虚拟机:
• 包含 全局事件global_event → 加载到全局虚拟机(全服唯一)
• 包含 私有事件event → 加载到玩家虚拟机(每玩家一个)
• 两者混用 → 服务端按全局虚拟机加载,但 私有事件 在全局虚拟机中不存在,运行时报错!

正确做法:把全局逻辑和玩家逻辑拆分到两个文件中。
FN全局事件 与 私有事件 对比选择正确的事件系统

对比表

对比项私有事件event(玩家事件)全局事件global_event(全局事件)
运行环境玩家虚拟机(每玩家1个VM)全局虚拟机(全服1个VM)
并发模型多个玩家并发执行严格串行(无需加锁)
可用变量我的UIDmy_uid(当前玩家UID)无 我的UID
典型场景玩家登录、背包操作、移动Boss刷新、拍卖结算、排行榜
玩家对象差异玩家对象.添加物品(id, n)玩家对象.添加物品(uid, id, n)
在线查询❌ 不支持玩家对象.是否在线(uid)player.is_online

timer

定时器
timer.setInterval/clearIntervalgame.setInterval/clearInterval功能完全相同。timer额外提供一次性定时器
FNtimer.setTimeout / timer.clearTimeout NEW一次性延时定时器
timer.setTimeout(回调函数, 延迟毫秒) → 定时器ID
timer.clearTimeout(定时器ID) → 无返回值

代码示例

墨香中文语法
// 3秒后执行一次(不循环)
let 定时器ID = 定时器对象.延时执行(&延时回调, 3000)

.子程序 延时回调, , , 3秒后执行
游戏对象.输出日志("[定时] 3秒到了!")

// 取消(如果还没执行)
-- 定时器对象.取消定时(定时器ID)
原生 Lua 语法
-- 3秒后执行一次(不循环)
local 定时器ID = timer.setTimeout(function()
    game.log("[定时] 3秒到了!")
end, 3000)

-- 取消(如果还没执行)
-- timer.clearTimeout(定时器ID)
与setInterval区别:setTimeout只执行一次,setInterval循环执行。场景:技能冷却、延时奖励发放、限时活动倒计时。

读写锁 lock

并发控制 NEW
读写锁用于多线程并发场景下的数据保护。多个读操作可以同时进行,但写操作会独占锁。
按许可名自动创建:相同的key共享同一把锁,不同key互不影响。
全局函数:这些函数直接全局可用,不需要模块前缀。
FN许可写进入(key) / 许可写退出(key)获取/释放写锁
许可写进入(key: 字符串) → 无返回值(阻塞直到获取锁)
许可写退出(key: 字符串) → 无返回值

参数说明

参数名类型必填/默认说明
key字符串必填锁的唯一名称,相同key共享同一把锁

代码示例

墨香中文语法
// 写操作前加锁
许可写进入("银行_张三")
let 余额 = 存储.读取数据("银行_张三") 或 0
存储.保存数据("银行_张三", 余额 + 100)
许可写退出("银行_张三")
原生 Lua 语法
-- 写操作前加锁
许可写进入("银行_张三")
local 余额 = store.load("银行_张三") or 0
store.save("银行_张三", 余额 + 100)
许可写退出("银行_张三")
写锁是独占的:同一时刻只有一个线程可以持有写锁,其他读写操作都会阻塞等待。必须配对:每个许可写进入必须有对应的许可写退出,否则会死锁!
FN许可读进入(key) / 许可读退出(key)获取/释放读锁
许可读进入(key: 字符串) → 无返回值(阻塞直到获取锁)
许可读退出(key: 字符串) → 无返回值

参数说明

参数名类型必填/默认说明
key字符串必填锁的唯一名称,相同key共享同一把锁

代码示例

墨香中文语法
// 读操作前加读锁(多个读可以并发)
许可读进入("排行榜")
let 排行数据 = 存储.读取数据("排行榜")
游戏对象.输出日志("排行榜: " .. 转换到文本(排行数据))
许可读退出("排行榜")
原生 Lua 语法
-- 读操作前加读锁(多个读可以并发)
许可读进入("排行榜")
local 排行数据 = store.load("排行榜")
game.log("排行榜: " .. tostring(排行数据))
许可读退出("排行榜")
读锁是共享的:多个线程可以同时持有读锁,但写锁会等待所有读锁释放。必须配对:每个许可读进入必须有对应的许可读退出
FN许可尝试写进入(key, 超时ms) / 许可尝试读进入(key, 超时ms)尝试获取锁(带超时)
许可尝试写进入(key: 字符串, 超时ms?: 数字) → 布尔值(是否成功获取锁)
许可尝试读进入(key: 字符串, 超时ms?: 数字) → 布尔值(是否成功获取锁)

参数说明

参数名类型必填/默认说明
key字符串必填锁的唯一名称
超时ms数字默认5000超时时间(毫秒),超时返回false

代码示例

墨香中文语法
// 尝试3秒内获取写锁
let 成功 = 许可尝试写进入("银行_张三", 3000)
.如果(成功)
    let 余额 = 存储.读取数据("银行_张三") 或 0
    存储.保存数据("银行_张三", 余额 - 50)
    许可写退出("银行_张三")
.否则
    游戏对象.输出日志("[警告] 操作太频繁,请稍后重试")
.如果结束
原生 Lua 语法
-- 尝试3秒内获取写锁
local 成功 = 许可尝试写进入("银行_张三", 3000)
if 成功 then
    local 余额 = store.load("银行_张三") or 0
    store.save("银行_张三", 余额 - 50)
    许可写退出("银行_张三")
else
    game.log("[警告] 操作太频繁,请稍后重试")
end
非阻塞尝试:在指定时间内尝试获取锁,超时返回false不会死锁。超时后:如果获取锁失败,不需要调用许可写退出成功后:必须配对调用许可写退出许可读退出释放锁。默认超时5秒。
FN许可销毁(key)销毁不再使用的锁
许可销毁(key: 字符串) → 无返回值

参数说明

参数名类型必填/默认说明
key字符串必填要销毁的锁名称

代码示例

墨香中文语法
// 销毁不再使用的锁(释放内存)
许可销毁("临时任务锁_123")
原生 Lua 语法
-- 销毁不再使用的锁(释放内存)
许可销毁("临时任务锁_123")
用途:销毁不再需要的锁,释放内存。适合临时锁场景(如限时活动结束后)。注意:确保没有线程正在使用该锁时再销毁。

http

HTTP服务端+客户端
http 模块包含两套功能:HTTP服务端(搭建Web接口)和HTTP客户端(调用外部API)。服务端方法用于接收请求,客户端方法用于发起请求。
FNhttp.handle注册HTTP路由

代码示例

墨香中文语法
// GET请求:用req.query获取URL参数
网页对象.监听请求("/api/status", "GET", &查询状态)  http

.子程序 查询状态, 对象型, , GET /api/status
.参数 req, HTTP请求对象
返回({code: 0, data: {online: 网络对象.获取在线人数()}})

// POST请求:用req.json获取请求体
网页对象.监听请求("/api/order", "POST", &创建订单)

.子程序 创建订单, 对象型, , POST /api/order
.参数 req, HTTP请求对象
let data = req.json 或 {}
.如果(!data.itemId)
    返回({code: 1, error: "itemId必填"})
.如果结束
返回({code: 0, data: {orderId: "ORD_001"}})
原生 Lua 语法
-- GET请求:用req.query获取URL参数
http.handle("/api/status", "GET", function(req)
    return {code = 0, data = {online = network.getOnlineCount()}}
end)

-- POST请求:用req.json获取请求体
http.handle("/api/order", "POST", function(req)
    local data = req.json or {}
    if not data.itemId then
        return {code = 1, error = "itemId必填"}
    end
    return {code = 0, data = {orderId = "ORD_001"}}
end)
req对象:详见"HTTP请求(req)"章节。req.query=URL参数,req.json=JSON请求体,req.header=请求头,req.remoteAddr=客户端IP。
返回值:直接return一个表,框架自动转JSON。
FNhttp.html / file / redirect / template / static / responseHTML/下载/跳转/模板/静态/原始响应
墨香中文语法
// 返回HTML页面
-- 返回(网页对象.返回网页("<h1>Hello</h1>"))

// 返回模板渲染
-- 返回(网页对象.返回模板([[<h1>{{.title}}</h1>]], {title: "首页"}))

// 文件下载
-- 返回(网页对象.返回文件("数据,等级\n张三,10", "players.csv", "text/csv"))

// 重定向
-- 返回(网页对象.重定向("/new"))

// 原始响应(自定义状态码) NEW
-- 返回(网页对象.返回原始响应(404, "text/plain", "Not Found"))

// 静态资源映射
网页对象.静态文件("/static", "./public")
原生 Lua 语法
-- 返回HTML页面
-- return http.html("<h1>Hello</h1>")

-- 返回模板渲染
-- return http.template([[<h1>{{.title}}</h1>]], {title = "首页"})

-- 文件下载
-- return http.file("数据,等级\n张三,10", "players.csv", "text/csv")

-- 重定向
-- return http.redirect("/new")

-- 原始响应(自定义状态码) NEW
-- return http.response(404, "text/plain", "Not Found")

-- 静态资源映射
http.static("/static", "./public")
模板语法:{{.key}}会被替换为数据表中对应的值。response可自定义状态码和Content-Type。

HTTP 客户端 — 外发请求

调用外部API NEW
HTTP客户端方法用于从服务端发起HTTP请求,调用外部API接口。所有请求都是异步的,不会阻塞游戏主线程,响应到达后自动调用回调函数。
典型场景:玩家实名认证、支付验证、第三方数据接口、微信登录校验等。
FNhttp.request(url, options, callback)异步HTTP请求
http.request(url: 字符串, options?: 表, callback: 函数) → true

代码示例

墨香中文语法
// 调用实名认证API
HTTP_异步请求("https://api.example.com/verify", { method: "POST", headers: {Authorization: "Bearer TOKEN123"}, body: {name: "张三", idCard: "110101199001011234"}, timeout: 5000 }, &认证回调)

.子程序 认证回调, , , HTTP响应回调
.参数 resp, HTTP响应对象
.如果(resp.error)
    游戏对象.输出日志("[认证] 请求失败: " .. 转换到文本(resp.error))
.否则
    .如果(resp.json.verified)
        游戏对象.输出日志("[认证] 实名认证通过")
    .否则
        游戏对象.输出日志("[认证] 实名认证未通过")
    .如果结束
.如果结束
原生 Lua 语法
-- 调用实名认证API
http.request("https://api.example.com/verify", { method = "POST", headers = {Authorization = "Bearer TOKEN123"}, body = {name = "张三", idCard = "110101199001011234"}, timeout = 5000
}, function(resp)
    if resp.error then
        game.log("[认证] 请求失败: " .. tostring(resp.error))
    else
        if resp.json and resp.json.verified then
            game.log("[认证] 实名认证通过")
        else
            game.log("[认证] 实名认证未通过")
        end
    end
end)
options参数:method请求方法、headers请求头表、body请求体(字符串或表,表自动转JSON)、timeout超时毫秒(默认10000)、queryURL查询参数表。响应对象:statusCode状态码、body原始响应体、json自动解析的JSON、headers响应头、error错误信息。
FNhttp.get(url, options, callback)异步GET请求
http.get(url: 字符串, options?: 表, callback: 函数) → true

代码示例

墨香中文语法
// GET请求获取服务器列表
HTTP_GET请求("https://api.example.com/servers", { headers: {Authorization: "Bearer TOKEN"}, timeout: 3000
}, &获取列表回调)

.子程序 获取列表回调, , , HTTP响应回调
.参数 resp, HTTP响应对象
.如果(!resp.error 且 resp.statusCode == 200)
    let 服务器列表 = resp.json.列表
    游戏对象.输出日志("获取到 " .. 转换到文本(#服务器列表) .. " 个服务器")
.否则
    游戏对象.输出日志("[HTTP] GET失败: " .. 转换到文本(resp.error))
.如果结束
原生 Lua 语法
-- GET请求获取服务器列表
http.get("https://api.example.com/servers", { headers = {Authorization = "Bearer TOKEN"}, timeout = 3000
}, function(resp)
    if not resp.error and resp.statusCode == 200 then
        local 服务器列表 = resp.json and resp.json.list or {}
        game.log("获取到 " .. #服务器列表 .. " 个服务器")
    else
        game.log("[HTTP] GET失败: " .. tostring(resp.error))
    end
end)
http.gethttp.request的快捷方法,固定method为GET。只需传url和回调即可。
FNhttp.post(url, body, options, callback)异步POST请求
http.post(url: 字符串, body: 任意, options?: 表, callback: 函数) → true

代码示例

墨香中文语法
// POST请求 - 微信登录校验
HTTP_POST请求("https://api.weixin.qq.com/sns/jscode2session", { appid: "wx1234567890", secret: "YOUR_SECRET", js_code: 登录码, grant_type: "authorization_code"
}, {timeout: 5000}, &微信登录回调)

.子程序 微信登录回调, , , HTTP响应回调
.参数 resp, HTTP响应对象
.如果(!resp.error 且 resp.json.openid)
    let openid = resp.json.openid
    游戏对象.输出日志("[微信] 登录成功: " .. openid)
.否则
    游戏对象.输出日志("[微信] 登录失败")
.如果结束
原生 Lua 语法
-- POST请求 - 微信登录校验
http.post("https://api.weixin.qq.com/sns/jscode2session", { appid = "wx1234567890", secret = "YOUR_SECRET", js_code = 登录码, grant_type = "authorization_code"
}, {timeout = 5000}, function(resp)
    if not resp.error and resp.json and resp.json.openid then
        local openid = resp.json.openid
        game.log("[微信] 登录成功: " .. openid)
    else
        game.log("[微信] 登录失败")
    end
end)
http.post自动设置Content-Type: application/json,body传表会自动转JSON,传字符串则直接发送。

redis

Redis缓存
使用模式:先获取实例,再通过实例调用连接和操作方法。Redis_获取实例()返回cache模块,所有操作都是实例方法。
FNcache.connect连接Redis服务
墨香中文语法
// 第1步:获取Redis实例
let RedisA = Redis_获取实例()  cache

// 第2步:连接Redis服务
let 结果 = RedisA.连接("主库", "127.0.0.1:6379", "", 0, 20)
.如果(!结果)
    游戏对象.输出日志("[Redis] 连接失败!")
    返回()
.如果结束
原生 Lua 语法
local cache = cache  -- Redis模块实例
local 结果 = cache.connect("主库", {addr = "127.0.0.1:6379", password = "", db = 0, poolSize = 20})
if not 结果 then
    game.log("[Redis] 连接失败!")
    return
end
注意:Redis不是必需的,连接失败不影响服务器运行,只是缓存功能不可用。连接名用于区分多个Redis连接。
FNcache:断开 / :Ping / :状态连接管理
墨香中文语法
// 断开连接
RedisA.断开("主库")

// 测试连接是否存活
let 存活 = RedisA.Ping("主库")
.如果(存活)
    游戏对象.输出日志("[Redis] 连接正常")
.如果结束

// 查看所有连接状态
let 状态 = RedisA.状态()
原生 Lua 语法
-- 断开连接
cache.close("主库")

-- 测试连接是否存活
local 存活 = cache.ping("主库")
if 存活 then
    game.log("[Redis] 连接正常")
end

-- 查看所有连接状态
local 状态 = cache.status()
FNcache:set / get / del / exists基本KV操作
墨香中文语法
RedisA.设置键值("主库", "player.1001.name", "张三")
RedisA.设置键值("主库", "token.abc", "有效", 3600)  -- 1小时后过期
let 名字 = RedisA.获取键值("主库", "player.1001.name")
RedisA.删除键("主库", "player.1001.name")
let 存在 = RedisA.键是否存在("主库", "player.1001.name")
原生 Lua 语法
cache.set("主库", "player:1001:name", "张三")
cache.set("主库", "token:abc", "有效", 3600)   -- 1小时后过期
local 名字 = cache.get("主库", "player:1001:name")
cache.del("主库", "player:1001:name")
local 存在 = cache.exists("主库", "player:1001:name")
值必须是字符串,Lua表需要先用game.jsonEncode转成JSON再存。第一个参数为连接名。
FNcache:hset / hget / hdel / hgetallHash操作
墨香中文语法
RedisA.哈希设置("主库", "player.1001", "name", "张三")
RedisA.哈希设置("主库", "player.1001", "level", "10")
let 名字 = RedisA.哈希获取("主库", "player.1001", "name")
RedisA.哈希删除("主库", "player.1001", "tempData")
let 全部 = RedisA.哈希获取全部("主库", "player.1001")
原生 Lua 语法
cache.hset("主库", "player:1001", "name", "张三")
cache.hset("主库", "player:1001", "level", "10")
local 名字 = cache.hget("主库", "player:1001", "name")
cache.hdel("主库", "player:1001", "tempData")
local 全部 = cache.hgetall("主库", "player:1001")
FNcache:incr / decr / expire / ttl / lpush / lrange计数器/过期/List
墨香中文语法
let 新值 = RedisA.自增("主库", "counter.login")      -- 原子+1
let 新值2 = RedisA.自减("主库", "counter.online")     -- 原子-1
RedisA.设置过期("主库", "token.abc", 3600)              -- 设置TTL
let 剩余 = RedisA.查看剩余过期("主库", "token.abc")     -- 查看剩余秒数
RedisA.列表左推入("主库", "chat.world", "张三.你好")     -- 推入消息
let 消息 = RedisA.读取列表范围("主库", "chat.world", 0, 9) -- 取最近10条
原生 Lua 语法
local 新值 = cache.incr("主库", "counter:login")       -- 原子+1
local 新值2 = cache.decr("主库", "counter:online")      -- 原子-1
cache.expire("主库", "token:abc", 3600)                  -- 设置TTL
local 剩余 = cache.ttl("主库", "token:abc")               -- 查看剩余秒数
cache.lpush("主库", "chat:world", "张三:你好")            -- 推入消息
local 消息 = cache.lrange("主库", "chat:world", 0, 9)     -- 取最近10条

mysql

MySQL数据库
使用模式:先获取实例,再通过实例调用连接和操作方法。数据库_获取实例()返回db模块,所有操作都是实例方法。
FNdb.connect连接MySQL数据库
墨香中文语法
// 第1步:获取数据库实例
let MySqlA = 数据库_获取实例()  db

// 第2步:连接MySQL数据库
let 结果 = MySqlA.连接("主库", "127.0.0.1", 3306, "root", "123456", "game_db")
.如果(!结果)
    游戏对象.输出日志("[MySQL] 连接失败!")
    返回()
.如果结束
原生 Lua 语法
local db = db  -- 数据库模块实例
local 结果 = db.connect("主库", {host = "127.0.0.1", port = 3306, user = "root", password = "123456", database = "game_db", maxOpen = 20, maxIdle = 10})
if not 结果 then
    game.log("[MySQL] 连接失败!")
    return
end
连接名用于区分多个数据库连接,如"主库"、"日志库"等。后续操作都通过连接名指定目标。
FNdb:断开 / :状态 / :已连接列表 / :Ping连接管理
墨香中文语法
// 断开连接
MySqlA.断开("主库")

// 查看所有连接状态
let 状态 = MySqlA.状态()

// 查看已连接列表
let 连接列表 = MySqlA.已连接列表()
原生 Lua 语法
-- 断开连接
db.close("主库")

-- 查看所有连接状态
local 状态 = db.status()

-- 查看已连接列表
local 连接列表 = db.listConnected()
FNdb:query / exec / queryOne / queryList查询/执行/单行/列表
墨香中文语法
// 查询多行
let 行列表 = MySqlA.查询列表("主库", "SELECT * FROM players WHERE level > 10")
.遍历(i, 行, 行列表)
    游戏对象.输出日志("玩家: " .. 行.name)
.遍历结束

// 执行修改
let 影响行数 = MySqlA.执行("主库", "UPDATE players SET gold = gold - 100 WHERE id = 1")

// 查询单行
let 玩家数据 = MySqlA.查询单行("主库", "SELECT * FROM players WHERE id = 1")
原生 Lua 语法
-- 查询多行
local 行列表 = db.queryList("主库", "SELECT * FROM players WHERE level > 10")
for i, 行 in ipairs(行列表) do
    game.log("玩家: " .. 行.name)
end

-- 执行修改
local 影响行数 = db.execute("主库", "UPDATE players SET gold = gold - 100 WHERE id = 1")

-- 查询单行
local 玩家数据 = db.queryOne("主库", "SELECT * FROM players WHERE id = 1")
FNdb:参数化执行 / 参数化查询单行 / 参数化查询列表参数化查询(防SQL注入)
墨香中文语法
// 参数化执行(INSERT/UPDATE/DELETE)
let 影响行数 = MySqlA.参数化执行("主库", "UPDATE players SET gold = ? WHERE id = ?", {100, 玩家ID})

// 参数化查询单行
let 玩家 = MySqlA.参数化查询单行("主库", "SELECT * FROM players WHERE id = ?", {玩家ID})

// 参数化查询列表
let 列表 = MySqlA.参数化查询列表("主库", "SELECT * FROM players WHERE level > ?", {10})
原生 Lua 语法
-- 参数化执行
local 影响行数 = db.execute("主库", "UPDATE players SET gold = ? WHERE id = ?", {100, 玩家ID})

-- 参数化查询单行
local 玩家 = db.queryOne("主库", "SELECT * FROM players WHERE id = ?", {玩家ID})

-- 参数化查询列表
local 列表 = db.queryList("主库", "SELECT * FROM players WHERE level > ?", {10})
安全提示:务必用?占位符传参,不要拼接SQL字符串,防止注入攻击。

实体代理对象

按需获取 NEW
实体代理对象由engine.createEntity()创建时返回,或通过engine.getEntity(实体ID)/entity.getProxy(实体ID)获取。
它是对实体的直接引用,可用.读写标准属性,用:方法()调用操作。
PROP标准属性(用 . 直接读写)id / type / name / hp / maxHp / mp / maxMp / level / x / y
墨香中文语法
let 玩家 = 引擎.创建实体({type: "player", name: "张三", ...})

// 读取标准属性
游戏对象.输出日志("ID=" .. 玩家.id)
游戏对象.输出日志("类型=" .. 玩家.type)
游戏对象.输出日志("名称=" .. 玩家.name)
游戏对象.输出日志("HP=" .. 玩家.hp .. "/" .. 玩家.maxHp)
游戏对象.输出日志("MP=" .. 玩家.mp .. "/" .. 玩家.maxMp)
游戏对象.输出日志("等级=" .. 玩家.level)
游戏对象.输出日志("坐标=" .. 玩家.x .. "," .. 玩家.y)

// 修改标准属性
玩家.hp = 800
玩家.x = 1600
原生 Lua 语法
local 玩家 = engine.createEntity({type = "player", name = "张三", ...})

-- 读取标准属性
game.log("ID=" .. 玩家.id)
game.log("类型=" .. 玩家.type)
game.log("名称=" .. 玩家.name)
game.log("HP=" .. 玩家.hp .. "/" .. 玩家.maxHp)
game.log("MP=" .. 玩家.mp .. "/" .. 玩家.maxMp)
game.log("等级=" .. 玩家.level)
game.log("坐标=" .. 玩家.x .. "," .. 玩家.y)

-- 修改标准属性
玩家.hp = 800
玩家.x = 1600
标准属性直接用.读写。自定义属性(如attack/gold)用:getAttr/:setAttr
FN代理:getAttr / :setAttr / :modifyAttr读写自定义属性
墨香中文语法
let 攻击力 = 玩家.读取属性("attack")
玩家.设置属性("attack", 80)
let 新金币 = 玩家.修改属性("gold", -100)  -- 原子操作,线程安全
原生 Lua 语法
local 攻击力 = 玩家:getAttr("attack")
玩家:setAttr("attack", 80)
local 新金币 = 玩家:modifyAttr("gold", -100)  -- 原子操作,线程安全
自定义属性放在attrs表里,通过:getAttr/:setAttr访问。:modifyAttr是原子操作,战斗扣血必须用这个。
FN代理:bindUID(用户UID)绑定用户UID
墨香中文语法
玩家.绑定用户UID("zhangsan")  -- 绑定后可用UID发消息
原生 Lua 语法
玩家:bindUID("zhangsan")  -- 绑定后可用UID发消息
绑定后,network.sendToPlayer(UID, 消息包)可通过UID找到该玩家。通常在登录成功后调用。
FN代理:moveTo(目标X, 目标Y [, 速度])A*寻路移动
墨香中文语法
玩家.移动到(2000, 2000)        -- 默认速度200像素/秒
玩家.移动到(2000, 2000, 300)   -- 自定义速度300
原生 Lua 语法
玩家:moveTo(2000, 2000)         -- 默认速度200像素/秒
玩家:moveTo(2000, 2000, 300)    -- 自定义速度300
使用A*寻路,有移动过程。与entity.setPosition瞬移不同。可配合:stopMove():isMoving()使用。
FN代理:stopMove / :isMoving / :setMap / :destroy停止移动/是否移动中/设地图/销毁
墨香中文语法
玩家.停止移动()          -- 停止当前移动
.如果(玩家.是否移动中())  -- 检查是否在移动
    玩家.停止移动()
.如果结束
玩家.设置地图(2)         -- 切换到地图2
玩家.销毁()              -- 销毁实体
原生 Lua 语法
玩家:stopMove()            -- 停止当前移动
if 玩家:isMoving() then    -- 检查是否在移动
    玩家:stopMove()
end
玩家:setMap(2)             -- 切换到地图2
玩家:destroy()             -- 销毁实体
FN代理:aiState / :aiTarget / :forceTarget / :forceIdle / :setAIConfig / :setHomeAI相关操作(怪物用)
墨香中文语法
let 状态 = 怪物.取AI状态()      -- "idle"/"chase"/"attack"/"return"
let 目标 = 怪物.取AI目标()      -- 当前追踪的目标
怪物.强制追踪(目标玩家)         -- 跳过视野直接追
怪物.强制空闲()                -- 停止追击
怪物.置AI配置({viewRange: 400, attackDamage: 20})  -- 修改AI参数
怪物.设置出生点(800, 600)      -- 超出追距后返回此点
原生 Lua 语法
local 状态 = 怪物:aiState()      -- "idle"/"chase"/"attack"/"return"
local 目标 = 怪物:aiTarget()     -- 当前追踪的目标
怪物:forceTarget(目标玩家)       -- 跳过视野直接追
怪物:forceIdle()                 -- 停止追击
怪物:setAIConfig({viewRange = 400, attackDamage = 20})  -- 修改AI参数
怪物:setHome(800, 600)           -- 超出追距后返回此点
AI操作也可通过ai模块的静态方法调用(如ai.getAIState(实体ID)),代理对象写法更简洁。

HTTP请求(req)

回调参数 NEW
HTTP回调请求对象是回调参数类型(所属类型=2),不是模块单例,不能通过XXX_获取实例()创建。它由http.handle()回调函数的参数req自动传入,框架在收到HTTP请求时自动创建。同理,http.request/get/post的回调参数resp是HTTP响应对象(也是回调参数类型),包含statusCode、body、json、headers、error等字段。
关键区别:模块单例(如网页_获取实例()http)是全局唯一实例;回调参数类型(如req/resp)每次回调都由框架新创建并传入。
PROPreq 所有属性method / path / query / header / body / json / remoteAddr

属性说明

属性类型说明
req.method字符串HTTP方法:GET / POST / PUT / DELETE
req.path字符串请求URL路径,如 "/api/login"
req.queryURL查询参数,?uid=123 → req.query.uid
req.header请求头,如 req.header["Authorization"]
req.body字符串请求体原始内容(POST/PUT时)
req.json自动解析的JSON请求体(Content-Type为application/json时)
req.remoteAddr字符串客户端IP地址和端口

代码示例

墨香中文语法
网页对象.监听请求("/api/player", "POST", &处理请求)

.子程序 处理请求, 对象型
.参数 req, HTTP请求对象
// 读取请求信息
游戏对象.输出日志("方法: " .. req.method)        -- "POST"
游戏对象.输出日志("路径: " .. req.path)          -- "/api/player"
游戏对象.输出日志("IP: " .. req.remoteAddr)      -- "192.168.1.1.12345"

// GET参数
let uid = req.query.uid 或 ""

// POST的JSON数据
let data = req.json 或 {}
let 名字 = data.name 或 ""

// 请求头
let 认证 = req.header["Authorization"] 或 ""
原生 Lua 语法
http.handle("/api/player", "POST", function(req)
    -- 读取请求信息
    game.log("方法: " .. req.method)        -- "POST"
    game.log("路径: " .. req.path)          -- "/api/player"
    game.log("IP: " .. req.remoteAddr)      -- "192.168.1.1:12345"

    -- GET参数
    local uid = req.query.uid or ""

    -- POST的JSON数据
    local data = req.json or {}
    local 名字 = data.name or ""

    -- 请求头
    local 认证 = req.header["Authorization"] or ""

    return {code = 0}
end)
req 是回调参数,不是通过"获取实例"创建的。它由框架在收到HTTP请求时自动创建并传入处理函数。

HTTP响应(resp)

回调参数 NEW
HTTP响应对象也是回调参数类型(所属类型=2),不能通过XXX_获取实例()创建。它由http.request()http.get()http.post()的回调函数参数resp自动传入,框架在收到外部API响应时自动创建。请求失败时resp.error不为nil。
PROPresp 所有属性statusCode / body / json / headers / error

属性说明

属性类型说明
resp.statusCode数值HTTP状态码,如200、404、500
resp.body字符串响应体原始内容
resp.json自动解析的JSON响应体(Content-Type为application/json时)
resp.headers响应头表
resp.error字符串/nil请求失败时的错误信息,成功时为nil

代码示例

墨香中文语法
HTTP_POST请求("https://api.weixin.qq.com/sns/jscode2session", 请求数据, {}, &微信回调)

.子程序 微信回调, , , HTTP响应回调
.参数 resp, HTTP响应对象
// 检查错误
.如果(resp.error)
    游戏对象.输出日志("[HTTP] 请求失败: " .. resp.error)
    返回()
.如果结束

// 读取响应
游戏对象.输出日志("状态码: " .. resp.statusCode)  -- 200
.如果(resp.json)
    let openid = resp.json.openid 或 ""
    游戏对象.输出日志("openid: " .. openid)
.如果结束
原生 Lua 语法
http.post("https://api.weixin.qq.com/sns/jscode2session", data, {}, function(resp)
    -- 检查错误
    if resp.error then
        game.log("[HTTP] 请求失败: " .. tostring(resp.error))
        return
    end

    -- 读取响应
    game.log("状态码: " .. resp.statusCode)  -- 200
    if resp.json then
        local openid = resp.json.openid or ""
        game.log("openid: " .. openid)
    end
end)
resp 是回调参数,不是通过"获取实例"创建的。它由框架在收到外部API响应时自动创建并传入回调函数。

map

地图寻路
FNmap.loadFromJSON / map.create加载地图数据
墨香中文语法
// 从JSON加载(含碰撞数据)
let 地图ID = 地图.从JSON加载([[{"id":1,"name":"比奇省","width":100,"height":100,"tileWidth":32,"tileHeight":32,"collision":[[0,0,1,0],[0,1,1,0]]}]])  map

// 创建空白地图(全部可通行)
let 地图ID2 = 地图.创建(2, "测试地图", 100, 100, 32, 32)
原生 Lua 语法
-- 从JSON加载(含碰撞数据)
local 地图ID = map.loadFromJSON([[{"id":1,"name":"比奇省","width":100,"height":100,"tileWidth":32,"tileHeight":32,"collision":[[0,0,1,0],[0,1,1,0]]}]])

-- 创建空白地图(全部可通行)
local 地图ID2 = map.create(2, "测试地图", 100, 100, 32, 32)
FNmap.findPathA*寻路
墨香中文语法
let 结果 = 地图.寻路(1, 5, 5, 50, 50)
.如果(结果)
    游戏对象.输出日志("路径步数=" .. #结果.smoothPath)
    .遍历(i, p, 结果.smoothPath)
        游戏对象.输出日志("路点" .. i .. ": (" .. p.x .. "," .. p.y .. ")")
    .遍历结束
.否则
    游戏对象.输出日志("无法到达")
.如果结束
原生 Lua 语法
local 结果 = map.findPath(1, 5, 5, 50, 50)
if 结果 then
    game.log("路径步数=" .. #结果.smoothPath)
    for i, p in ipairs(结果.smoothPath) do
        game.log("路点" .. i .. ": (" .. p.x .. "," .. p.y .. ")")
    end
else
    game.log("无法到达")
end
参数为格子坐标(整数),不是像素坐标。返回smoothPath(推荐)和fullPath两种路径。
FNmap.isWalkable / pixelToCell / cellToPixel / setPathStyle碰撞/坐标转换/风格
墨香中文语法
.如果(地图.可通行(1, 50, 50))
    游戏对象.输出日志("(50,50) 可通行")
.如果结束

let 格子X, 格子Y = 地图.像素转格子(1, 1600, 960)
let 像素X, 像素Y = 地图.格子转像素(1, 50, 30)

地图.设置风格(1, "传奇")  -- 标准/传奇/梦幻
原生 Lua 语法
if map.isWalkable(1, 50, 50) then
    game.log("(50,50) 可通行")
end

local 格子X, 格子Y = map.pixelToCell(1, 1600, 960)
local 像素X, 像素Y = map.cellToPixel(1, 50, 30)

map.setPathStyle(1, "传奇")  -- 标准/传奇/梦幻
风格说明:标准=通用,传奇=靠墙惩罚高(避免贴墙),梦幻=转弯惩罚高(路径更直)。
FNmap.getAllIDs() NEW获取所有已加载地图ID
墨香中文语法
let 列表 = 地图.取所有地图ID()
.遍历(i, id, 列表)
    游戏对象.输出日志("地图ID: " .. 转换到文本(id))
.遍历结束
原生 Lua 语法
local 列表 = map.getAllIDs()
for i, id in ipairs(列表) do
    game.log("地图ID: " .. tostring(id))
end

ai

怪物AI
FNai.createMonster创建带AI的怪物
墨香中文语法
let 怪物 = AI.创建怪物(1001, 800, 600, 1, {  ai
    viewRange: 300,       -- 视野范围(像素)
    attackRange: 60,      -- 攻击范围
    chaseSpeed: 150,      -- 追踪速度
    attackDamage: 10,      -- 攻击伤害
    maxChaseDist: 500,     -- 最大追踪距离
    patrolRange: 150       -- 巡逻范围
})
.如果(怪物)
    游戏对象.输出日志("[AI] 创建怪物: id=" .. 怪物.id)
.如果结束
原生 Lua 语法
local 怪物 = ai.createMonster(1001, 800, 600, 1, { viewRange = 300,       -- 视野范围(像素) attackRange = 60,      -- 攻击范围 chaseSpeed = 150,      -- 追踪速度 attackDamage = 10,      -- 攻击伤害 maxChaseDist = 500,     -- 最大追踪距离 patrolRange = 150       -- 巡逻范围
})
if 怪物 then
    game.log("[AI] 创建怪物: id=" .. 怪物.id)
end
怪物会自动检测视野内玩家→追踪→攻击→超出范围返回出生点。返回实体代理对象。
FNai.removeMonster / getAIState / setAIConfig / getAIConfig / forceTarget / forceIdleAI管理
墨香中文语法
AI.删除怪物(怪物ID)
let 状态 = AI.取AI状态(怪物ID)  -- idle/chase/attack/return/none
AI.置AI配置(怪物ID, {viewRange: 400, attackDamage: 20})
let 配置 = AI.取AI配置(怪物ID)  -- NEW: 读取当前配置
AI.强制追踪(怪物ID, 玩家ID)     -- 跳过视野直接追
AI.强制空闲(怪物ID)             -- 停止追击
原生 Lua 语法
ai.removeMonster(怪物ID)
local 状态 = ai.getAIState(怪物ID)  -- idle/chase/attack/return/none
ai.setAIConfig(怪物ID, {viewRange = 400, attackDamage = 20})
local 配置 = ai.getAIConfig(怪物ID)  -- NEW: 读取当前配置
ai.forceTarget(怪物ID, 玩家ID)     -- 跳过视野直接追
ai.forceIdle(怪物ID)                -- 停止追击
FNai.setOnAttack(回调函数) / ai.getMonsterCount() NEW攻击回调/怪物数量
墨香中文语法
// 设置攻击回调
AI.置攻击回调(函数(攻击者, 目标, 伤害)
    网络对象.广播给地图(1, {type: "MONSTER_ATTACK", monsterId: 攻击者.id, targetId: 目标.id, damage: 伤害, targetHp: 目标.hp
    })
结束)

// 获取当前怪物总数
let 数量 = AI.取怪物数量()
原生 Lua 语法
-- 设置攻击回调
ai.setOnAttack(function(攻击者, 目标, 伤害)
    network.broadcastMap(1, {type = "MONSTER_ATTACK", monsterId = 攻击者.id, targetId = 目标.id, damage = 伤害, targetHp = 目标.hp
    })
end)

-- 获取当前怪物总数
local 数量 = ai.getMonsterCount()

script

脚本挂载
FNscript.register / attach / detach / call / fireEvent脚本注册/挂载/卸载/调用/触发
墨香中文语法
// 注册脚本模板
脚本.注册("npc/shop_merchant", {  script onInit: 函数(实体, 参数) 实体.设置脚本状态("greeting", "欢迎光临!") 结束, onInteract: 函数(实体, 参数) 网络对象.发送给玩家(参数.playerId, {type: "NPC_SHOP_OPEN", npcId: 实体.id}) 结束
})

// 挂载到实体
脚本.挂载脚本(npcId, "npc/shop_merchant", {onInteract: &交互处理})

// 卸载
脚本.卸载脚本(npcId)

// 调用脚本自定义函数
let 结果 = 脚本.调用(npcId, "getShopItems", {category: "weapon"})

// 触发脚本事件
脚本.触发事件(npcId, "onInteract", {playerId: 玩家ID})
原生 Lua 语法
-- 注册脚本模板
script.register("npc/shop_merchant", { onInit = function(实体, 参数) 实体:setScriptState("greeting", "欢迎光临!") end, onInteract = function(实体, 参数) network.sendToPlayer(参数.playerId, {type = "NPC_SHOP_OPEN", npcId = 实体.id}) end
})

-- 挂载到实体
script.mount(npcId, "npc/shop_merchant", {onInteract = 交互处理})

-- 卸载
script.unmount(npcId)

-- 调用脚本自定义函数
local 结果 = script.call(npcId, "getShopItems", {category = "weapon"})

-- 触发脚本事件
script.triggerEvent(npcId, "onInteract", {playerId = 玩家ID})
FNscript.setState / getState / has / getInfo / getRegistered脚本状态管理
墨香中文语法
脚本.置脚本状态(npcId, "questProgress", 3)
let 进度 = 脚本.取脚本状态(npcId, "questProgress")
.如果(脚本.是否有(npcId)) { 游戏对象.输出日志("已有脚本") }
let 信息 = 脚本.取脚本信息(npcId)
let 脚本列表 = 脚本.取已注册脚本列表()
原生 Lua 语法
script.setState(npcId, "questProgress", 3)
local 进度 = script.getState(npcId, "questProgress")
if script.has(npcId) then game.log("已有脚本") end
local 信息 = script.getInfo(npcId)
local 脚本列表 = script.getRegistered()

script chain

脚本链(TQ风格)
FNscript.registerTypeHandler注册TYPE处理器
脚本链是传奇类游戏常用的对话/任务系统。每个脚本行有TYPE值,Go底层处理内置TYPE(101/102/103/120),自定义TYPE(1001+)用Lua处理器。
墨香中文语法
// 注册自定义TYPE处理器
脚本链.注册类型处理器(1001, &购买物品)

.子程序 购买物品, 对象型, , TYPE=1001
.参数 player, 玩家实体对象, , 触发者
.参数 target, 实体代理对象, , 目标(NPC/怪物)
.参数 line, 表(脚本行数据), , 当前脚本行

let 物品ID = line.param
let 价格 = 转换到数值(line.data) 或 0
let 金币 = 转换到数值(实体.读取属性(player.id, "gold")) 或 0

.如果(金币 < 价格)
    游戏对象.发送消息(转换到文本(player.id), "NPC_TIP", {text: "金币不足!"})
    返回({success: 假})  -- 跳转NextFail
.如果结束

实体.设置属性(player.id, "gold", 金币 - 价格)
游戏对象.发送消息(转换到文本(player.id), "NPC_TIP", {text: "购买成功!"})
返回({success: 真})  -- 跳转NextID
原生 Lua 语法
-- 注册自定义TYPE处理器
script.registerTypeHandler(1001, function(player, target, line)
    local 物品ID = line.param
    local 价格 = tonumber(line.data) or 0
    local 金币 = tonumber(entity.getAttr(player.id, "gold")) or 0

    if 金币 < 价格 then
        game.sendMessage(tostring(player.id), "NPC_TIP", {text = "金币不足!"})
        return {success = false}   -- 跳转NextFail
    end

    entity.setAttr(player.id, "gold", 金币 - 价格)
    game.sendMessage(tostring(player.id), "NPC_TIP", {text = "购买成功!"})
    return {success = true}    -- 跳转NextID
end)
返回值:success=true跳转NextID,success=false跳转NextFail。
内置TYPE:101=对话文本,102=选项列表,103=对话+音效,120=结束。无需注册处理器。
FNscript.continueChain / endChain / listChainScripts推进/结束/列出脚本链
墨香中文语法
// 玩家选择选项后推进脚本链
let 结果 = 脚本链.继续脚本链(玩家ID, 100)  -- 100=选项对应的跳转ID
.如果(结果)
    游戏对象.输出日志("对话: " .. 转换到文本(结果.dialog))
.否则
    游戏对象.输出日志("对话结束")
.如果结束

// 强制结束脚本链会话
脚本链.结束脚本链(玩家ID)

// 列出所有链式脚本
let 列表 = 脚本链.列出链式脚本()
原生 Lua 语法
-- 玩家选择选项后推进脚本链
local 结果 = script.continueChain(玩家ID, 100) -- 100=选项对应的跳转ID
if 结果 then
    game.log("对话: " .. tostring(结果.dialog))
else
    game.log("对话结束")
end

-- 强制结束脚本链会话
script.endChain(玩家ID)

-- 列出所有链式脚本
local 列表 = script.listChainScripts()
安全:服务端验证nextID是否在Triggerableable数组中,防止客户端伪造。nextID=0表示结束对话。

脚本运行架构与事件系统

必读 NEW
服务端脚本会自动根据内容分配到不同的虚拟机,理解这个架构是写出正确代码的关键。
1架构概述:两种虚拟机理解脚本运行环境

架构图

┌─────────────────────────────────────────────────────┐
│                    游戏服务端                          │
│                                                      │
│  ┌──────────┐ ┌──────────┐ ┌───────────────────┐     │
│  │玩家虚拟机│ │玩家虚拟机│ │   全局虚拟机      │     │
│  │ 玩家A的VM │ │ 玩家B的VM │ │  全服唯一的VM    │     │
│  │          │ │          │ │                  │     │
│  │私有事件  │ │私有事件  │ │   全局事件        │     │
│  │(event)  │ │(event)  │ │ (global_event)   │     │
│  │ 我的UID  │ │ 我的UID  │ │  (无我的UID)      │     │
│  │ 玩家对象 │ │ 玩家对象 │ │ 玩家对象+uid参数  │     │
│  └──────────┘ └──────────┘ └───────────────────┘     │
│                                                      │
│  自动规则:脚本写了 全局事件 → 全局虚拟机            │
│          脚本写了 私有事件    → 玩家虚拟机            │
│          两个都没写           → 通用脚本               │
└─────────────────────────────────────────────────────┘
2脚本自动分类规则服务端如何判断脚本归属

自动检测逻辑

脚本内容包含自动归类运行环境
全局事件global_event全局脚本(global)全局虚拟机
私有事件event玩家脚本(player)每个玩家虚拟机各加载一份
都不包含通用脚本(common)HTTP LState池
🚫 禁止混用!如果同一个文件同时包含 全局事件global_event私有事件event,服务端会按全局虚拟机加载,但 私有事件 在全局虚拟机中不存在,运行时会报错!请将两种逻辑拆分到不同文件。
3完整实战案例:Boss系统全局虚拟机 + 玩家虚拟机 协作
这个案例演示了一个完整的 Boss 系统:Boss 在全局虚拟机中管理(跨玩家),玩家击杀 Boss 通过全局事件通知,奖励发放也由全局虚拟机统一处理。
两个文件必须分开写——一个用 全局事件global_event(全局虚拟机),一个用 私有事件event(玩家虚拟机),不能混用。

🔴 文件A:Boss管理(全局虚拟机)

墨香中文语法
// Boss管理脚本(全局虚拟机环境)
// 包含 全局事件 → 自动加载到全局虚拟机

// 监听Boss刷新事件
全局事件.注册("boss_spawn", &BOSS刷新处理)  global_event

.子程序 BOSS刷新处理, , , Boss刷新时触发
.参数 data, 表(事件数据)
    全局变量.设置("boss_alive_" .. data.bossId, 真)
    全局变量.设置("boss_hp_" .. data.bossId, 100000)
    日志.信息("[Boss] BOSS " .. data.bossId .. " 刷新!")

    // Boss AI:每2秒执行一次
    let 定时器 = 定时器对象.循环执行(&BOSS攻击AI, 2, data)

.子程序 BOSS攻击AI, , , Boss每2秒攻击
.参数 data, 表(事件数据)
    let 存活 = 全局变量.获取("boss_alive_" .. data.bossId)
    .如果(存活 != 真)
        返回 ()
    .如果结束
    let 血量 = 全局变量.获取("boss_hp_" .. data.bossId)
    .如果(血量 > 0)
        // Boss随机攻击附近玩家
        let 目标列表 = data.targetUids 或 []
        .如果(数组_取成员数(目标列表) > 0)
            let 索引 = 取随机数(1, 数组_取成员数(目标列表))
            let 目标UID = 目标列表[索引 - 1]
            玩家对象.发送消息(目标UID, {
                cmd: "boss_attack",
                bossId: data.bossId,
                damage: 取随机数(100, 500)
            })
        .如果结束
    .如果结束

// 监听Boss被击杀事件
全局事件.注册("boss_dead", &BOSS击杀处理)  global_event

.子程序 BOSS击杀处理, , , Boss被击杀时触发
.参数 data, 表(事件数据)
    全局变量.设置("boss_alive_" .. data.bossId, 假)
    日志.信息("[Boss] BOSS " .. data.bossId .. " 被 " .. data.killerUid .. " 击杀!")

    // 发放击杀奖励(跨玩家事务,必须在这里)
    玩家对象.添加物品(data.killerUid, 9001, 1)  // 全局虚拟机中玩家对象需要传uid
    玩家对象.发送消息(data.killerUid, {
        cmd: "boss_reward",
        bossId: data.bossId,
        items: [{id: 9001, count: 1}]
    })

// 注册HTTP接口查看Boss状态
网页对象.监听请求("/api/boss/status", "GET", &Boss状态查询)

日志.信息("[Boss] 全局Boss系统加载完成")
原生 Lua 语法
-- Boss管理脚本(全局虚拟机环境)
-- 包含 global_event → 自动加载到全局虚拟机

-- 监听Boss刷新事件
global_event.on("boss_spawn", function(data)
    global.set("boss_alive_" .. data.bossId, true)
    global.set("boss_hp_" .. data.bossId, 100000)

    logger.info("[Boss] BOSS " .. data.bossId .. " 刷新!")

    -- Boss AI:每2秒执行一次
    timer.loop(2, function()
        local alive = global.get("boss_alive_" .. data.bossId)
        if not alive then return end

        local hp = global.get("boss_hp_" .. data.bossId)
        if hp and hp > 0 then
            -- Boss随机攻击附近玩家
            local targets = data.targetUids or {}
            if #targets > 0 then
                local idx = math.random(1, #targets)
                local targetUid = targets[idx]
                player.send(targetUid, {
                    cmd = "boss_attack",
                    bossId = data.bossId,
                    damage = math.random(100, 500)
                })
            end
        end
    end)
end)

-- 监听Boss被击杀事件
global_event.on("boss_dead", function(data)
    global.set("boss_alive_" .. data.bossId, false)
    logger.info("[Boss] BOSS " .. data.bossId .. " 被 " .. data.killerUid .. " 击杀!")

    -- 发放击杀奖励(跨玩家事务,必须在这里)
    player.add_item(data.killerUid, 9001, 1)  -- 全局虚拟机中player需要传uid
    player.send(data.killerUid, {
        cmd = "boss_reward",
        bossId = data.bossId,
        items = {{id = 9001, count = 1}}
    })
end)

-- 注册HTTP接口查看Boss状态
http.handle("/api/boss/status", "GET", "handle_boss_status")

logger.info("[Boss] 全局Boss系统加载完成")

🔵 文件B:玩家战斗(玩家虚拟机)

墨香中文语法
// 玩家战斗脚本(玩家虚拟机环境)
// 包含 事件.注册 → 自动加载到每个玩家虚拟机

// 玩家登录时初始化
事件.注册("player_login", &玩家登录处理)  event

.子程序 玩家登录处理, , , 玩家登录时触发
.参数 data, 表(事件数据)
    日志.信息("[战斗] 玩家登录 uid=" .. 到文本(我的UID))
    // 我的UID 是当前玩家UID,只有玩家虚拟机有

// 处理客户端攻击Boss消息
游戏对象.监听消息("ATTACK_BOSS", "ATTACK_RESULT", &攻击Boss处理)

.子程序 攻击Boss处理, 对象型, , 处理玩家攻击Boss
.参数 data, 表(消息数据)
    let bossId = data.bossId
    let damage = data.damage 或 100

    // 扣减Boss血量(原子操作,线程安全)
    let 新血量 = 全局变量.原子增减("boss_hp_" .. bossId, -damage)

    .如果(新血量 <= 0)
        // Boss死亡 → 投递全局事件
        // 这里不能用 全局事件.触发!
        // 必须通过 Go 引擎转发到全局虚拟机
        游戏对象.投递全局事件("boss_dead", {
            bossId: bossId,
            killerUid: 我的UID
        })
    .如果结束

    // 通知客户端伤害数字
    玩家对象.发送消息(我的UID, {
        cmd: "damage_number",
        value: damage
    })

// 处理Boss攻击消息(从全局虚拟机发来)
游戏对象.监听消息("boss_attack", "boss_hit", &Boss攻击处理)

.子程序 Boss攻击处理, , , 被Boss攻击时触发
.参数 data, 表(消息数据)
    let 伤害 = data.damage
    // 扣减自己血量...
    玩家对象.发送消息(我的UID, {
        cmd: "boss_hit",
        damage: 伤害
    })

日志.信息("[战斗] 玩家战斗脚本加载完成 uid=" .. 到文本(我的UID))
原生 Lua 语法
-- 玩家战斗脚本(玩家虚拟机环境)
-- 包含 event.on → 自动加载到每个玩家虚拟机

-- 玩家登录时初始化
event.on("player_login", function(data)
    logger.info("[战斗] 玩家登录 uid=" .. tostring(my_uid))
    -- my_uid 是当前玩家UID,只有玩家虚拟机有
end)

-- 处理客户端攻击Boss消息
game.onMessage("ATTACK_BOSS", "ATTACK_RESULT", function(data)
    local bossId = data.bossId
    local damage = data.damage or 100

    -- 扣减Boss血量(原子操作,线程安全)
    local newHp = global.atomicIncr("boss_hp_" .. bossId, -damage)

    if newHp <= 0 then
        -- Boss死亡 → 投递全局事件
        -- 这里不能用 global_event.emit!
        -- 必须通过 Go 引擎转发到全局虚拟机
        game.emitGlobal("boss_dead", {
            bossId = bossId,
            killerUid = my_uid
        })
    end

    -- 通知客户端伤害数字
    player.send(my_uid, {
        cmd = "damage_number",
        value = damage
    })
end)

-- 处理Boss攻击消息(从全局虚拟机发来)
game.onMessage("boss_attack", "boss_hit", function(data)
    local dmg = data.damage
    -- 扣减自己血量...
    player.send(my_uid, {
        cmd = "boss_hit",
        damage = dmg
    })
end)

logger.info("[战斗] 玩家战斗脚本加载完成 uid=" .. tostring(my_uid))
💡 关键点:
1. 玩家虚拟机 中不能直接调用 全局事件.触发global_event.emit(不在同一个VM),必须用 游戏对象.投递全局事件("事件名", 数据)game.emitGlobal 通过 Go 引擎转发到全局虚拟机
2. 全局虚拟机 中需要操作指定玩家时,玩家对象 的方法需要多传一个 uid 参数
3. Boss血量等共享数据用 全局变量.获取/设置/原子增减global.get/set/atomicIncr 操作,不要用 Lua 全局变量(各VM隔离)
4常见错误与正确写法对照避免踩坑

错误 vs 正确

❌ 错误写法✅ 正确写法原因
同一文件同时写
全局事件.注册global_event
私有事件.注册event
拆分成两个文件
一个用 全局事件
一个用 私有事件
两者运行在不同VM,混用必报错
玩家虚拟机中
全局事件.触发(...)global_event.emit
游戏对象.投递全局事件(...)game.emitGlobal
通过Go引擎转发
玩家虚拟机没有全局事件模块
全局虚拟机中
玩家对象.添加物品(id, n)
玩家对象.添加物品(uid, id, n)全局虚拟机无我的UID,必须指定uid
全局虚拟机中
我的UIDmy_uid
从事件数据获取uid
data.uid
全局虚拟机无我的UID变量
用Lua全局变量
存Boss血量
全局变量.获取/设置global.get/set
全局变量.原子增减global.atomicIncr
各VM隔离,Lua全局变量不共享
5脚本类型速查:我该用哪个?一张表搞定

场景选择表

你的逻辑是...用哪个代码特征
只涉及一个玩家私有事件event (玩家虚拟机)使用 我的UIDmy_uid
玩家背包/装备/属性私有事件event (玩家虚拟机)使用 玩家对象.添加物品(id,n)
Boss刷新/全服怪全局事件global_event (全局虚拟机)使用 全局变量.获取/设置global.get/set
拍卖行/交易全局事件global_event (全局虚拟机)需同时操作多个玩家
排行榜结算全局事件global_event (全局虚拟机)遍历全服数据
HTTP API路由全局事件global_event (全局虚拟机)使用 网页对象.监听请求http.handle
定时全服任务全局事件global_event (全局虚拟机)整点刷怪/每日重置
纯工具函数common (通用)不引用私有事件或全局事件
本页目录