脚本与插件开发
两种扩展方式
MC 侧有两种扩展方式,都能用,挑顺手的那个:
写脚本(.nc) | 写附属插件(Java / Kotlin) | |
|---|---|---|
| 放哪 | plugins/MC-BngeuitBridge/script/*.nc | 自己打一个 jar,放 plugins/ |
| 要不要编译 | 不用,丢文件进去就行 | 要,正常打包 |
| 改完怎么生效 | /bngeuitbridge r,秒生效、不重启 | 重载插件或重启服务器 |
| 手里有什么 | 本插件 API + Bukkit API(nc 语法) | 本插件 API + Bukkit API(完整 JVM 生态) |
| 适合 | 消息路由、转发、查询、轻量业务 | 复杂逻辑、跨插件调用、自己的配置 / 命令 / 数据库 |
两者拿到的是同一套对象:MCBngeuitBridge.INSTANCE.getChannel() 和 BridgeMessageReceiveEvent 这些事件,在脚本和插件里没有区别,只是写的语言不同。
下面整篇讲脚本(推荐,改一行不用重启);插件方式见文末 写附属插件。
脚本引擎(nacre)已内置在本插件里,不需要额外装 CraneCore 或别的脚本前置。
两侧用的是同一个引擎
MC 侧和 Bngeuit 侧都是 nacre 引擎(各自内置一份),写的都是 nc 语言,一套语法两边通用 —— MC 侧的 JoinAndQuit.nc 和 Bngeuit 侧的 McJoinAndQuit.nc 就是同一套写法。
区别只在能调用的 API:
| 事件 | 发消息 | |
|---|---|---|
| MC 侧 | Bukkit 事件(org.bukkit.event.*) | MCBngeuitBridge.INSTANCE.getChannel() |
| Bngeuit 侧 | Bngeuit 事件(cn.org.bukkit.bngeuit.event.*、桥接事件) | BngeuitBridge.getInstance().getChannel() |
Bngeuit 侧同样只支持 nc 脚本(早期的 JavaScript 脚本支持已经移除),所以两侧写法完全一致。
两侧引擎版本可能不同
MC 侧和 Bngeuit 侧各自内置一份 nacre,版本不一定相同。常规语法都通用,真遇到某个写法在某一侧编译不过,换个等价写法即可。
脚本放在哪
plugins/MC-BngeuitBridge/
├── config.yml
├── script/
│ ├── JoinAndQuit.nc ← 玩家上下线上报
│ ├── CommandRelay.nc ← 群内执行命令
│ └── 任意子目录/你的脚本.nc ← 支持多层子目录,递归扫描
└── .loaded-nacre-classes/ ← 编译缓存,别动- 只认
.nc后缀,其他文件忽略 - 保存后在游戏里执行
/bngeuitbridge r热重载,不需要重启服务器 - 重载会卸载所有旧脚本再重新编译加载,脚本里的静态状态会丢
最小脚本
package my.first.script
import cn.org.bukkit.bngeuit.bridge.MCBngeuitBridge
import org.bukkit.Bukkit
fun enable() {
Bukkit.getLogger().info("[MyScript] 已启用")
}
fun disable() {
Bukkit.getLogger().info("[MyScript] 已卸载")
}三条硬性规则:
package必须写,且全服唯一。两个脚本写同一个 package 会加载失败- 入口函数叫
enable()和disable()(无参顶层函数),不是onEnable - 加载顺序:编译 → 写缓存 → 依次调用所有脚本的
enable();卸载时先调disable()
脚本语言是 nc(Kotlin 风格、静态编译到 JVM 字节码),语法细节看 Nacre 脚本语言。会 Kotlin / Java 基本可以直接上手。
拿到通道
所有对外通信都从通道对象开始:
import cn.org.bukkit.bngeuit.bridge.MCBngeuitBridge
import cn.org.bukkit.bngeuit.bridge.channel.BridgeChannel
val channel = MCBngeuitBridge.INSTANCE.getChannel()通道可能为 null
插件刚启动、配置校验没过、或者 BngeuitBridge 还没连上时,getChannel() 返回 null 或 isConnected() 为 false。每次用之前都要判空,否则脚本直接抛异常。
val channel = MCBngeuitBridge.INSTANCE.getChannel()
if (channel == null || !channel.isConnected()) return false通道 API
| 方法 | 返回 | 说明 |
|---|---|---|
send(type, action, data) | BridgeResponse | 发一条消息,clientId 与时间戳自动填 |
send(message) | BridgeResponse | 发一个自己组装的 BridgeMessage |
isConnected() | boolean | 是否已连接且已通过认证 |
getClientId() | String | 当前连接的 clientId,认证成功后才非空 |
发了消息不关心回执就是「发完即忘」:
val payload = HashMap<String, String>()
payload.put("msg", "服务器将在 5 分钟后重启")
channel.send("mc_notice", "broadcast", payload)消息对象
需要精细控制时自己组装 BridgeMessage(cn.org.bukkit.bngeuit.bridge.channel.BridgeMessage):
| 成员 | 说明 |
|---|---|
BridgeMessage.of(type, action, data) | 静态构造,自动填时间戳 |
type / action / data / clientId / timestamp / requestId / replyTo | 只读属性 |
reply(data) | 生成「回复这条消息」的新消息,自动带 replyTo |
withRequestId() | 生成带 requestId 的副本,一般不用手动调(send 会自动加) |
toJson() / BridgeMessage.fromJson(json) | 序列化 / 反序列化 |
响应对象
send 返回 BridgeResponse,三种用法:
// 1. 异步回调(推荐)
channel.send(BridgeMessage.of("mc_query", "tps", null))
.onSuccess { resp -> Bukkit.getLogger().info("收到回复: " + resp.getData()) }
.onFailure { err -> Bukkit.getLogger().warning("失败: " + err) }
// 2. 完全忽略
channel.send("mc_event", "heartbeat", null)
// 3. 阻塞等待(不要在事件监听器或主线程里用)
val resp = channel.send(BridgeMessage.of("mc_query", "tps", null)).get(5, TimeUnit.SECONDS)超时 30 秒;连接断开时所有等待中的请求立刻失败。
监听消息
监听器用 ListenerUtils 注册,它按「注册名」管理,同一个名字重复注册会自动覆盖,插件卸载时能干净注销。
import org.bukkit.event.Event
import cn.org.bukkit.bngeuit.bridge.event.BridgeMessageReceiveEvent
import cn.org.bukkit.bngeuit.bridge.utils.ListenerUtils
val KEY = "MyScript:监听"
fun enable() {
ListenerUtils.registerListener(
KEY, // 注册名(注销时用)
"cn.org.bukkit.bngeuit.bridge.event.BridgeMessageReceiveEvent", // 事件类全名
"NORMAL", // 优先级
false // 是否忽略已取消的事件
) { e: Event ->
handle(e as BridgeMessageReceiveEvent)
null
}
}
fun disable() {
ListenerUtils.unregisterListener(KEY)
}lambda 最后那个 null 不能省
registerListener 的 lambda 是 Kotlin 的 Function1,返回值类型是平台类型 Unit!,与 nc 的 Unit 对不上。在 lambda 体末尾补一个 null 就能绕过(运行期无影响)。漏了会编译报错,是这套组合最常见的坑。
优先级可选:LOWEST、LOW、NORMAL、HIGH、HIGHEST、MONITOR。
ListenerUtils 另外两个方法:unregisterAllListener() 注销本插件注册的全部监听器(一般不用,disable 里按名注销更精确)。
事件里有什么
fun handle(event: BridgeMessageReceiveEvent) {
val type = event.getType() // 消息大类
val action = event.getAction() // 细分动作,可能为 null
val data = event.getData() // 任意对象,通常是 Map
val clientId = event.getClientId() // 发送方(Bngeuit 侧填的是目标,广播时是 *)
}按 type 路由,再按 action 分流,是脚本里推荐的结构:
fun handle(event: BridgeMessageReceiveEvent) {
val type = event.getType()
if (type == "mc_query") {
if (event.getAction() == "online") replyOnline(event)
}
if (type == "mc_notice") {
if (event.getAction() == "broadcast") broadcastToPlayers(event)
}
}线程:最容易翻车的地方
| 你在哪 | 能做什么 |
|---|---|
enable() / disable() | 主线程(插件加载时),Bukkit API 随便用 |
BridgeMessageReceiveEvent 监听器 | WebSocket 线程,只能做纯计算,碰 Bukkit 必须切主线程 |
BridgeMessageSendEvent 监听器 | 异步线程,同上 |
BridgeConnectEvent / BridgeAuthEvent / BridgeDisconnectEvent | WebSocket 线程,同上 |
用 SchedulerUtils 切线程:
import cn.org.bukkit.bngeuit.bridge.utils.SchedulerUtils
SchedulerUtils.sync { // 切主线程,立刻执行
Bukkit.broadcastMessage("来自 QQ 的公告")
}
SchedulerUtils.async { // 异步执行(数据库、HTTP 等耗时操作)
Bukkit.getLogger().info("异步任务")
}
SchedulerUtils.syncLater("MyScript:延迟", 20L) { // 20 tick 后主线程执行
Bukkit.getLogger().info("1 秒后")
}
SchedulerUtils.syncTimer("MyScript:定时", 0L, 20L * 60) { // 每 60 秒
// 不要在这里做耗时操作
}
SchedulerUtils.cancelTask("MyScript:定时") // 按名字取消| 方法 | 说明 |
|---|---|
sync(task) | 主线程执行,当前已在主线程则直接跑 |
async(task) | 丢到异步线程池 |
syncLater(source, delay, task) / asyncLater(...) | 延迟一次,tick 为单位 |
syncTimer(source, delay, period, task) / asyncTimer(...) | 周期执行,需手动取消 |
callSyncMethod(callable) | 主线程执行并返回值,会阻塞当前线程 |
cancelTask(source) | 取消带名字的任务 |
带 source 的延迟任务执行完会自动清理,不会堆积。
在 Bngeuit 侧接收
Bngeuit 侧的脚本放在 Bngeuit 的 plugins/ 目录,同样是 nacre 引擎 + .nc 文件(引擎已随 Bngeuit 主程序打包,不用另装任何东西),放进去重启 Bngeuit 就会加载。语法、package 规则、onEnable / onDisable 生命周期见 Bngeuit 脚本插件文档。官方的三个配套脚本(McJoinAndQuit.nc、McCommandRelay.nc、McBridgeStatus.nc)就是按下面这套写法实现的,可以直接拿来改。
关键点:
- 必须声明
pluginDepend = listOf("BngeuitBridge"),否则import cn.org.bukkit.bngeuit.bridge.*会因为插件还没加载而失败 - 事件类在
cn.org.bukkit.bngeuit.bridge.event包,类和 MC 侧同名但不是同一个类 - 发消息用
BngeuitBridge.getInstance().getChannel()
package mc.bridge.qq
import cn.org.bukkit.bngeuit.Bngeuit
import cn.org.bukkit.bngeuit.bridge.BngeuitBridge
import cn.org.bukkit.bngeuit.bridge.event.BridgeMessageReceiveEvent
import cn.org.bukkit.bngeuit.script.NcBridge
val pluginName = "McNotify"
val pluginVersion = "1.0.0"
val pluginDepend = listOf("BngeuitBridge")
val b = NcBridge.of("mc.bridge.qq")
// 替换成你的群 OpenID
val GROUP = "ABCDEFG..."
fun onEnable() {
b.on(BridgeMessageReceiveEvent.class) { e ->
if (e.getType() == "mc_event") {
val data = e.getData() as Map
if (e.getAction() == "player_join") {
Bngeuit.sendGroupText(GROUP, data.get("player") + " 加入了游戏")
}
if (e.getAction() == "player_quit") {
Bngeuit.sendGroupText(GROUP, data.get("player") + " 离开了游戏")
}
}
}
b.info("McNotify 已启用")
}
fun onDisable() {
}Bngeuit 侧通道 API:
| 方法 | 说明 |
|---|---|
broadcast(type, action, data) | 广播给所有已连接的 MC 服务器 |
send(clientId, type, action, data) | 只发给指定服务器,返回 BridgeResponse |
getConnectedClients() | 当前在线的 clientId 列表 |
val channel = BngeuitBridge.getInstance().getChannel()
// 广播
val payload = HashMap<String, Object>()
payload.put("text", "服务器将在 5 分钟后重启")
channel.broadcast("mc_notice", "broadcast", payload)
// 定向
channel.send("survival", "mc_command", "exec", payload)脚本报错怎么排查
- 脚本编译失败:控制台会打
xxx.nc 的 enable 函数出现错误加堆栈,通常是语法错、import 的类不存在、漏了null - 脚本没执行:
/bngeuitbridge r后看控制台打印的脚本数量,脚本 N个对不上说明文件没被扫到(后缀不是.nc?放错目录?) - 事件收不到:确认
type大小写一致;MC 侧收消息要求type和action都非空(内置脚本会打 warning 提示)
写附属插件(方式二)
不想用脚本、或者业务复杂到脚本写不动时,可以写一个普通的 Bukkit 插件,把 MC-BngeuitBridge.jar 当编译期依赖引进来,运行期由插件本身提供类(所以 compileOnly,不要打进自己的 jar)。
什么时候选插件
- 要跟别的插件深度交互(PlaceholderAPI、经济、数据库、自定义 GUI)
- 需要自己的配置文件、命令、权限节点
- 逻辑复杂到需要多文件、多类协作
- 对性能敏感、需要精细控制线程与缓存
代价是改一次要重新打包 + 重载/重启,不像脚本 /bngeuitbridge r 就完事。
1. 加依赖
把 MC-BngeuitBridge.jar 放进你项目的 libs/,构建脚本里声明为 compileOnly:
// build.gradle.kts
dependencies {
compileOnly(files("libs/MC-BngeuitBridge.jar"))
compileOnly("org.spigotmc:spigot-api:1.20.4-R0.1-SNAPSHOT")
}<!-- pom.xml -->
<dependency>
<groupId>cn.org.bukkit.bngeuit</groupId>
<artifactId>mc-bngeuitbridge</artifactId>
<version>1.1.0</version>
<scope>provided</scope>
<systemPath>${project.basedir}/libs/MC-BngeuitBridge.jar</systemPath>
</dependency>2. 声明插件依赖
plugin.yml 里写 depend,保证加载顺序(不然启动时找不到 MCBngeuitBridge 类):
name: MyBridgeAddon
version: 1.0.0
main: my.plugin.MyBridgeAddon
depend: [MC-BngeuitBridge]3. 监听消息 + 发消息
package my.plugin;
import cn.org.bukkit.bngeuit.bridge.MCBngeuitBridge;
import cn.org.bukkit.bngeuit.bridge.channel.BridgeChannel;
import cn.org.bukkit.bngeuit.bridge.event.BridgeMessageReceiveEvent;
import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.HashMap;
import java.util.Map;
public class MyBridgeAddon extends JavaPlugin implements Listener {
@Override
public void onEnable() {
getServer().getPluginManager().registerEvents(this, this);
}
/** 收到 Bngeuit 转来的业务消息(异步线程触发) */
@EventHandler
public void onBridgeMessage(BridgeMessageReceiveEvent event) {
if (!"mc_query".equals(event.getType())) return;
if (!"online".equals(event.getAction())) return;
BridgeChannel channel = MCBngeuitBridge.INSTANCE.getChannel();
if (channel == null || !channel.isConnected()) return;
// 事件在 WebSocket 线程,取 Bukkit 数据必须回主线程
Bukkit.getScheduler().runTask(this, () -> {
Map<String, Object> data = new HashMap<>();
data.put("count", Bukkit.getOnlinePlayers().size());
// reply() 生成带 replyTo 的回复,对端发起方的 onSuccess 会被触发
channel.send(event.getMessage().reply(data));
});
}
}Kotlin 项目里更短,MCBngeuitBridge 是 Kotlin object,可以直接按属性访问,也不需要 INSTANCE:
@EventHandler
fun onBridgeMessage(event: BridgeMessageReceiveEvent) {
if (event.type != "mc_query" || event.action != "online") return
val channel = MCBngeuitBridge.channel ?: return
if (!channel.isConnected()) return
Bukkit.getScheduler().runTask(this) {
val data = mapOf("count" to Bukkit.getOnlinePlayers().size)
channel.send(event.message.reply(data))
}
}4. 主动发消息与等回执
// 发了就不管
channel.send("mc_notice", "broadcast", data);
// 需要回执:包装类里监听不到回调,直接在异步任务里阻塞等(别在主线程调用)
Bukkit.getScheduler().runTaskAsynchronously(this, () -> {
try {
BridgeMessage resp = channel.send("mc_query", "tps", null).get(5, TimeUnit.SECONDS);
getLogger().info("对方回了: " + resp.getData());
} catch (Exception e) {
getLogger().warning("没等到回复: " + e.getMessage());
}
});Java 里别硬写 onSuccess
BridgeResponse.onSuccess 的参数是 Kotlin 的函数类型(Function1),Java 传 lambda 会编译不过,得写匿名类。Java 项目建议用上面的 get(timeout, unit);Kotlin 项目直接 channel.send(msg).onSuccess { ... } 就好。
5. 和脚本能共存吗
能。插件监听的是同一个 BridgeMessageReceiveEvent,脚本注册的监听器也在跑,两边按 type 各管各的不会打架 —— 但同一个 type/action 别两边都处理,否则同一条消息会被执行两次(比如命令转发了两遍)。
脚本 vs 插件:一张表定去留
| 你的需求 | 选 |
|---|---|
| 群里显示上下线、转发聊天、发公告 | 脚本 |
| 群里查 TPS / 在线人数 / 内存 | 脚本 |
| 群里执行命令(加自己的白名单逻辑) | 脚本 |
| 对接经济插件做充值/兑换 | 插件 |
| 把 MC 事件写进数据库、做统计报表 | 插件 |
| 需要自己的 GUI、物品、任务系统 | 插件 |
| 就想改一行立刻看到效果 | 脚本 |
