跳到主要内容

脚本与插件开发 ​

两种扩展方式 ​

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 热重载,不需要重启服务器
  • 重载会卸载所有旧脚本再重新编译加载,脚本里的静态状态会丢

最小脚本 ​

nc
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] 已卸载")
}

三条硬性规则:

  1. package 必须写,且全服唯一。两个脚本写同一个 package 会加载失败
  2. 入口函数叫 enable() 和 disable()(无参顶层函数),不是 onEnable
  3. 加载顺序:编译 → 写缓存 → 依次调用所有脚本的 enable();卸载时先调 disable()

脚本语言是 nc(Kotlin 风格、静态编译到 JVM 字节码),语法细节看 Nacre 脚本语言。会 Kotlin / Java 基本可以直接上手。

拿到通道 ​

所有对外通信都从通道对象开始:

nc
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。每次用之前都要判空,否则脚本直接抛异常。

nc
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,认证成功后才非空

发了消息不关心回执就是「发完即忘」:

nc
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,三种用法:

nc
// 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 注册,它按「注册名」管理,同一个名字重复注册会自动覆盖,插件卸载时能干净注销。

nc
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 里按名注销更精确)。

事件里有什么 ​

nc
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 分流,是脚本里推荐的结构:

nc
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 / BridgeDisconnectEventWebSocket 线程,同上

用 SchedulerUtils 切线程:

nc
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()
nc
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 列表
nc
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:

kotlin
// build.gradle.kts
dependencies {
    compileOnly(files("libs/MC-BngeuitBridge.jar"))
    compileOnly("org.spigotmc:spigot-api:1.20.4-R0.1-SNAPSHOT")
}
xml
<!-- 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 类):

yaml
name: MyBridgeAddon
version: 1.0.0
main: my.plugin.MyBridgeAddon
depend: [MC-BngeuitBridge]

3. 监听消息 + 发消息 ​

java
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:

kotlin
@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. 主动发消息与等回执 ​

java
// 发了就不管
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、物品、任务系统插件
就想改一行立刻看到效果脚本