游戏服务器 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。
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核心流程:创建实体(
实体属性:hp/mp/level等标准属性用
断线处理:玩家直接关客户端不会发LOGOUT,必须监听
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}
endAOI流程:
视野事件是自动的:调用
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区别:
常用事件:onConnect(连接建立)、onDisconnect(断开连接)、onPlayerLogin(玩家登录)、onPlayerLogout(玩家登出)。
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连接建立处理器,新连接建立时触发。
注意:此回调在多线程环境并发执行,早于高层
适用场景:IP黑名单、连接限流、二进制握手等底层控制。
注意:此回调在多线程环境并发执行,早于高层
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) → 无返回值
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.sendToPlayer和network.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: 切换地图IDsetPosition是瞬移(传送),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, 用户名)必须绑定后
sendToPlayer和broadcastUIDs才能通过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)随机浮点数 NEWFNutil.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/jsonDecode与game.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)
endFNengine.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))
endFNaoi.setViewRange(半径) NEW动态调整视野范围▶
墨香中文语法
AOI.设置视野范围(1500) -- 扩大视野到1500像素原生 Lua 语法
aoi.setViewRange(1500) -- 扩大视野到1500像素运行时动态调整视野范围,例如GM命令扩大/缩小可视距离。
broadcast
广播broadcast 是 network 的语义化别名,函数名更直观。功能完全相同。
Go层修复:广播系统现在支持数字类型码和字符串类型两种消息格式——
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是监听系统事件(连接/断开等)。
⚠️ 重要:
如果你的逻辑涉及跨玩家操作(Boss刷新、拍卖结算、排行榜、全服广播等),请使用
私有事件 和 全局事件 不能在同一个文件中混用!它们运行在不同的虚拟机中,API互不兼容。
私有事件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/clearInterval与game.setInterval/clearInterval功能完全相同。timer额外提供一次性定时器。FNtimer.setTimeout / timer.clearTimeout NEW一次性延时定时器▶
timer.setTimeout(回调函数, 延迟毫秒) → 定时器ID
timer.clearTimeout(定时器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互不影响。
全局函数:这些函数直接全局可用,不需要模块前缀。
按许可名自动创建:相同的key共享同一把锁,不同key互不影响。
全局函数:这些函数直接全局可用,不需要模块前缀。
FN许可写进入(key) / 许可写退出(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 | 字符串 | 必填 | 锁的唯一名称,相同key共享同一把锁 |
代码示例
墨香中文语法
// 读操作前加读锁(多个读可以并发)
许可读进入("排行榜")
let 排行数据 = 存储.读取数据("排行榜")
游戏对象.输出日志("排行榜: " .. 转换到文本(排行数据))
许可读退出("排行榜")原生 Lua 语法
-- 读操作前加读锁(多个读可以并发)
许可读进入("排行榜")
local 排行数据 = store.load("排行榜")
game.log("排行榜: " .. tostring(排行数据))
许可读退出("排行榜")读锁是共享的:多个线程可以同时持有读锁,但写锁会等待所有读锁释放。必须配对:每个
许可读进入必须有对应的许可读退出!FN许可尝试写进入(key, 超时ms) / 许可尝试读进入(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)"章节。
返回值:直接return一个表,框架自动转JSON。
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 NEWHTTP客户端方法用于从服务端发起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.get是http.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)
回调参数 NEWHTTP回调请求对象是回调参数类型(所属类型=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.query | 表 | URL查询参数,?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)
回调参数 NEWHTTP响应对象也是回调参数类型(所属类型=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))
endai
怪物AIFNai.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)返回值:
内置TYPE:101=对话文本,102=选项列表,103=对话+音效,120=结束。无需注册处理器。
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. 玩家虚拟机 中不能直接调用
2. 全局虚拟机 中需要操作指定玩家时,
3. Boss血量等共享数据用
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 | 从事件数据获取uiddata.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 (通用) | 不引用私有事件或全局事件 |
本页目录