Skip to content

核心概念

四件事:生命周期、句柄、线程、错误。

生命周期

rust
impl LeviMod for MyMod {
    fn on_load(ctx: &ModContext) -> Result<Self> { /* 构造 */ Ok(MyMod) }
    fn on_enable(&mut self, ctx: &ModContext) -> Result<()> { Ok(()) }
    fn on_disable(&mut self, ctx: &ModContext) -> Result<()> { Ok(()) }
    fn on_unload(&mut self, ctx: &ModContext) -> Result<()> { Ok(()) }
}
钩子此时可以做什么
on_load读配置、开数据库、建目录。关卡还没打开
on_enable注册命令 / 事件 / 维度,读世界数据
on_disable停掉自己的定时任务,保存状态
on_unload最后的清理

on_load 里不能碰世界

关卡(Level)在 on_load 阶段还是 null。注册维度会抛异常,读玩家列表拿到的是空的。任何和世界有关的东西都放在 on_enable

on_load 失败应该让模组加载失败

配置读不了、数据库开不了,就直接 return Err(...)。静默用默认值硬跑,服主看到的现象是"改了配置没生效",而日志里一个字都没有。

句柄是标识符,不是指针

这是整套设计的核心。

类型内部存的
Player一个选择器(名字 / XUID / UUID)
Actor / Entity一个 ActorUniqueID
Block(维度, 坐标)
ItemStack一段 SNBT
Container(归属者, 哪一个)

每次调用方法时才去解析。带来三个结果:

1. 不可能悬垂。 目标没了就返回 Err,不会崩服。这不是"小心写就没事",是架构上做不到

2. 每次调用有查找成本。 热循环里别反复构造句柄:

rust
// ❌ 每格查一次玩家
for i in 0..1000 {
    Player::by_name("Steve").send_message("...")?;
}

// ✅
let p = Player::by_name("Steve");
for i in 0..1000 { p.send_message("...")?; }

3. 什么该存、什么不该存是有讲究的。

存这个别存这个为什么
Player(按 XUID)Actor玩家重进后 ActorUniqueID 会变
Player(按 XUID)Player(按名字)名字会改
坐标BlockBlock 本来就是坐标,存哪个都行
rust
// 长期存储
struct MyMod {
    owners: HashMap<String, Player>,     // key 是 xuid
}
// 用的时候
let actor = owners.get(&xuid).unwrap().get_actor()?;

线程规则

所有回调都在游戏线程。 生命周期钩子、事件、命令、表单回调、调度任务——全部。

所以模组实例可以放心持有 !Send 的东西(比如 Listener),不需要 Arc<Mutex<>> 包一层。

两个例外

1. 包拦截器不在游戏线程。

rust
ctx.packets().intercept(Direction::Both, |p| {
    // 这里不在游戏线程!
    // 不能碰世界。共享状态要 Mutex / 原子类型。
    Verdict::Forward
})?.forget();

签名是 Fn + Send + Sync,编译器会逼你做对。要碰世界就 Server::schedule 弹回去。

2. 这几样是线程安全的,后台线程可以直接用:

  • Server::schedule / schedule_after / gaming_status
  • Client::schedule / schedule_after / gaming_status
  • KvDb 全部方法
  • system::* 全部
  • Logger 全部

后台线程的标准写法

rust
std::thread::spawn(move || {
    let result = 很慢的活();                  // 后台,随便慢

    Server::get().schedule(move || {          // 回到游戏线程
        Player::broadcast(&result);
    });
});

别在后台线程调用世界 API

上面那张白名单之外的一切,在游戏线程之外调用都是未定义行为。可能崩,也可能更糟——静默的数据损坏。

错误处理

rust
pub struct Error(pub String);
pub type Result<T> = std::result::Result<T, Error>;

Err 的含义几乎总是「目标不在了」,不是"出错了":

调用Err 意味着
player.xxx()玩家下线了
actor.xxx()实体消失了
block.xxx()区块没加载
service::call()CallError

所以这样写是正常的,不是在吞错误:

rust
if let Ok(hp) = actor.health() {
    // 实体还在
}

// 或者
let _ = player.send_message("hi");     // 下线了就算了

什么时候该 ? 什么时候该忽略

  • 后续逻辑依赖这次调用的结果 → ?if let Ok
  • 只是"尽量做一下"(发个提示、放个粒子)→ .ok() / let _ =
  • on_enable 里注册东西失败 → ?,让模组加载失败比半个模组跑起来强

panic 不会拖垮服务器

每个 FFI 入口都套了 catch_unwind。模组里 panic 只在控制台打一条 error,服务器继续跑。

这不是让你放心 unwrap 的理由。这是让别人的 bug 不至于连累整台服务器。

RAII 句柄

四种回调句柄,行为一致:

rust
let l = ctx.server().subscribe_event(...)?;
drop(l);          // 退订

ctx.server().subscribe_event(...)?.forget();     // 活到模组卸载
类型来自
Listenersubscribe_event
Subscriptionbus::subscribe
Registrationservice::register
PacketHookpackets().intercept

99% 的情况是 on_enable 里注册完直接 .forget()

版本与 ABI

加载器的 ABI 大版本必须 >= 模组编译时的版本,加载时自动检查。

  • 新加载器 → 能跑旧模组 ✅
  • 旧加载器 → 拒绝新模组 ❌

当前:levilamina 26.20.4,ABI v5,对应 BDS 1.26.20 / LeviLamina 26.20.4

crate 版本和 Minecraft 版本是两回事:crate 走自己的 SemVer(ABI 在 1.0.0..26.20 之间一直是 v5),而 [package.metadata.minecraft] 记的才是"这一版在哪个游戏 / 加载器构建上验证过"。