AstraTemplate

AstraTemplate

Paper/Fabric/Valocity 的模板插件,具有纯净而强大的功能

paper forge neoforge

AstraTemplate

一个用 Kotlin 编写的生产级 Minecraft 插件/模组模板。提供模块化、生命周期驱动的架构,可在 Paper、Forge 和 NeoForge 上通过单一共享代码库运行。


基于此模板构建的插件


项目结构

AstraTemplate/
├── instances/
│   ├── bukkit/        ← Paper 入口点 + 平台接线
│   ├── forge/         ← Forge 入口点 + 平台接线
│   └── neoforge/      ← NeoForge 入口点 + 平台接线
└── modules/
    ├── api/
    │   ├── local/     ← 数据库(Exposed ORM,平台无关)
    │   └── remote/    ← REST 客户端(Ktor,平台无关)
    ├── core/          ← 配置、翻译、协程作用域
    ├── build-konfig/  ← 编译期常量(id、version 等)
    ├── feature-command/   ← 所有命令(平台无关!)
    ├── feature-gui/
    │   ├── api/       ← GUI 接口(Router、GuiModule)
    │   └── bukkit/    ← Bukkit 箱子 GUI 实现
    └── feature-event/
        ├── bukkit/    ← Bukkit 事件监听器
        ├── forge/     ← Forge 事件监听器
        └── neoforge/  ← NeoForge 事件监听器

每个 instances/<platform> 通过 ShadowJar 构建一个 fat jar,并且是唯一知道特定平台的地方。modules/ 中的所有内容要么完全平台无关,要么有明确命名的平台变体。

模块

modules/core — 配置、翻译、协程作用域

其他所有模块都依赖的基础。提供:

  • 配置 — PluginConfiguration 是一个写入 config.yml 的 @Serializable 数据类。通过 StateFlowKrate 在 /atempreload 时重新加载。
  • 翻译 — PluginTranslation 以相同方式使用 translation.yml。每个字符串都有默认值,因此插件在没有文件的情况下也可以开箱即用。
  • 协程作用域 — ioScope、mainScope 和 unconfinedScope,由 KotlinDispatchers(平台提供的、对 Dispatchers.IO / 主线程等的抽象)支持。所有作用域都会在 onDisable 中取消。
modules/api/local — 通过 Exposed ORM 访问本地数据库

通过 Jetbrains Exposed ORM 访问本地数据库。LocalDao 接口公开了用于对 UserTable 和 UserRatingTable 进行 CRUD 操作的挂起函数。底层数据库连接会响应式地从配置流派生,因此从 H2 切换到 MySQL 只需修改一行配置并重新加载。

支持的驱动(在 libs.versions.toml 中配置):H2、SQLite、MySQL、MariaDB。

modules/api/remote — 通过 Ktor 访问 REST API 客户端

使用 Ktor 构建的 REST API 客户端。演示如何从外部 HTTP 端点获取数据(Rick & Morty API)。RickMortyApi 接口返回 Result<T> —— 错误从不会抛出,总是显式返回。

modules/build-konfig — 编译期常量

通过 BuildConfig Gradle 插件生成编译期常量(id、version 等)。任何需要在运行时引用插件身份而无需硬编码字符串的模块都可以导入。

modules/feature-command — 跨平台命令(无平台导入)

所有命令集中在一处,且没有平台导入。使用 AstraLibs 的 Brigadier DSL 定义命令,这些命令在 Paper、Forge 和 NeoForge 上编译和运行方式完全相同。特定于平台的 MultiplatformCommand 适配器在 RootModule 层级注入。

modules/feature-gui — 箱子 GUI(Bukkit,并为其他平台提供存根)

拆分为 api(Router 接口 + GuiModule)和 bukkit(实现)。Bukkit 实现提供由 StateFlow 驱动的分页箱子库存 —— 只要底层数据发生变化,GUI 就会自动重新渲染。在 Forge/NeoForge 上,StubGuiModule 满足该接口,因此共享的 command 模块无需引入 Bukkit 即可编译。

modules/feature-event — 平台特定的事件监听器

平台特定的事件监听器,每个平台一个子模块。Bukkit 变体监听 BlockPlaceEvent;Forge 和 NeoForge 变体监听服务器 tick。每个子模块都公开一个 Lifecycle,以便 RootModule 可以干净地注册和注销监听器。


架构

生命周期树

每个模块都公开一个 Lifecycle,包含三个回调:onEnable、onDisable、onReload。插件入口点创建一个 RootModule,串联所有子生命周期,并委托给它们:

// instances/bukkit — AstraTemplate.kt
class AstraTemplate : LifecyclePlugin() {
    private val rootModule = RootModule(this)

    override fun onEnable() = rootModule.lifecycle.onEnable()
    override fun onDisable() = rootModule.lifecycle.onDisable()
    override fun onReload() = rootModule.lifecycle.onReload()
}
// instances/bukkit — RootModule.kt
class RootModule(plugin: AstraTemplate) {
    val coreModule = CoreModule(plugin.dataFolder, DefaultBukkitDispatchers(plugin))
    val apiLocalModule = ApiLocalModule(coreModule.configKrate.cachedStateFlow, coreModule.ioScope)
    val apiRemoteModule = ApiRemoteModule()
    val eventModule = EventModule(coreModule, plugin)
    val guiModule = BukkitGuiModule(coreModule, apiLocalModule)
    val commandModule = CommandModule(coreModule, apiRemoteModule, guiModule, ...)

    val lifecycle = Lifecycle.Lambda(
        onEnable = { listOf(coreModule, eventModule, apiLocalModule, commandModule).forEach(Lifecycle::onEnable) },
        onDisable = { /* same list, reversed */ },
        onReload = { /* same list */ }
    )
}

这使插件可在运行时重新加载 —— /atempreload 会以相反顺序遍历同一链条并重新启用它,从而即时获取任何配置或翻译更改。

graph TD
    Plugin --> RootModule
    RootModule --> CoreModule
    RootModule --> ApiLocalModule
    RootModule --> ApiRemoteModule
    RootModule --> EventModule
    RootModule --> CommandModule
    EventModule --> TemplateEvent
    EventModule --> BetterAnotherEvent

依赖注入

没有 DI 框架。每个模块都是一个普通类,其构造函数接收它所依赖的其他模块接口。RootModule 是组合根,按正确顺序实例化所有内容,并在初始化必须延迟时使用 lazy {}。

// Pass the whole module interface, not individual services extracted from it
val commandModule = CommandModule(
    coreModule = coreModule,
    guiModule = guiModule,
    apiRemoteModule = apiRemoteModule,
    ...
)

这使耦合保持显式,并避免因缺失绑定而导致的隐藏运行时故障。


跨平台命令

命令位于 modules/feature-command —— 一个零平台依赖的普通 Kotlin 模块。它们使用 AstraLibs 的 Brigadier DSL,该 DSL 抽象了 Paper 和 Forge 的原生 Brigadier 适配器。

// Works on Paper, Forge, and NeoForge without any changes
command("rickandmorty") {
    literal("random") {
        runs { ctx ->
            scope.launch(dispatchers.IO) {
                rmApi.getRandomCharacter(Random.nextInt(0, 100))
                    .onSuccess { ctx.getSender().sendMessage(...) }
                    .onFailure { ctx.getSender().sendMessage(...) }
            }
        }
    }
    literal("specific") {
        argument("number", IntegerArgumentType.integer()) { numberArg ->
            runs { ctx -> send(ctx.getSender(), ctx.requireArgument(numberArg)) }
        }
    }
}

在每个平台上,RootModule 都提供一个由正确适配器(PaperMultiplatformCommands、MinecraftMultiplatformCommands)支持的 MultiplatformCommand。共享命令代码永远不需要更改。

可用命令

命令 描述
/add <player> <material> [amount] 将物品添加到玩家的物品栏
/translation 显示当前翻译值(在重新加载后很有用)
/adamage <player> <amount> 对玩家造成伤害
/atempgui 打开示例分页 GUI
/rickandmorty random 通过 REST 获取随机 Rick & Morty 角色
/rickandmorty specific <id> 按 id 获取特定角色
/atempreload 重新加载配置、翻译和数据库连接

配置

配置和翻译都是普通的 @Serializable 数据类,通过 kaml 序列化为 YAML。内联文档注释会直接渲染在生成的 YAML 文件中:

@Serializable
data class PluginConfiguration(
    @YamlComment("First line description for config1", "Second line description for config2")
    @SerialName("config_1")
    val config1: String = "NONE",

    @SerialName("database")
    val database: DatabaseConfiguration = DatabaseConfiguration.H2("db")
)

配置和翻译都存储为 StateFlowKrate / CachedKrate。任何读取它们的模块在重新加载后始终能看到最新值 —— 无需手动传播。


本地数据库

modules/api/local 使用 Jetbrains Exposed 作为 ORM。数据库连接会响应式地从配置流派生 —— 当配置以新的数据库 URL 重新加载时,连接会自动替换:

private val databaseFlow = configFlow
    .map { it.database }
    .distinctUntilChanged()
    .flatMapLatest { configuration -> configuration.connectAsFlow() }
    .onEach { db ->
        transaction(db) { SchemaUtils.create(UserRatingTable, UserTable) }
    }
    .shareIn(ioScope, SharingStarted.Eagerly, 1)

支持的驱动(在 libs.versions.toml 中替换):H2、SQLite、MySQL、MariaDB。


远程 API

modules/api/remote 展示了如何使用 Ktor 调用外部 REST 端点。接口非常简洁:

interface RickMortyApi {
    suspend fun getRandomCharacter(id: Int): Result<RMResponse>
}

错误以 Result<T> 返回 —— 从不抛出 —— 因此调用方会显式处理失败。


GUI(Bukkit)

GUI 层位于 modules/feature-gui/api 中定义的 Router 接口之后。Bukkit 实现通过 Kotlin StateFlow 提供具有响应式状态的分页箱子库存:

  • SampleGuiComponent 拥有状态(Loading / Items / Users)
  • SampleGUI 观察状态并在每次发出时重新渲染
  • 导航(下一页/上一页、更改模式、添加用户、返回/关闭)由专门的按钮对象处理

在 Forge/NeoForge 上,StubGuiModule 满足 GuiModule 接口,因此共享的 CommandModule 无需 Bukkit 依赖即可编译。


构建

# Paper plugin
./gradlew :instances:bukkit:shadowJar

# Forge mod
./gradlew :instances:forge:shadowJar

# NeoForge mod
./gradlew :instances:neoforge:shadowJar

# Run all tests
./gradlew allTests

输出的 jar 会落在每个实例的 build/libs/ 目录中,并可选择由 FTP Gradle 插件复制到远程服务器(在 libs.versions.toml 中配置目标位置)。


测试服务器(Docker)

项目根目录下的 docker-compose.yml 使用 itzg/minecraft-server 启动本地测试服务器。

在运行之前,请手动编辑 docker-compose.yml,取消注释目标平台(Forge、NeoForge 或 Paper)的代码块,并注释掉其他代码块。每个代码块都设置 TYPE、VERSION 和平台特定的版本变量,以及其下方匹配的 volumes 条目。

docker compose up