从零新增一个 ChiliCraft 附属模块的完整步骤。开始前先读 architecture.md(架构总览)与 events-protocol.md(事件与数据协议)。
设计原则:核心 cc-core 零改动——新玩法全部以附属形式挂接,跨模块协作只走事件总线。
先定三样东西,写进模块主类 javadoc:
- 模块 ID:配置使用
cc-<name>,统一菜单/命令注册使用不带前缀的规范 ID<name>;工程名、plugin.ymlname、registerModuleConfig参数仍保持一致; - 统一入口:确定
MenuCategory、显示名、Material、排序值、chilicraft.<name>.use/admin权限,以及ModuleCommandRoute的别名和旧命令兼容关系; - 事件名清单:遵循「模块名.事件名」(如
soul.relic_gained),见 events-protocol.md; - 配置键草案:数值段 +
messages段,全部可热载。
新模块先建立独立 ChiliCraft/cc-<name> 仓库,以现有附属的 Gradle Wrapper、settings.gradle.kts、gradle.properties、CI 和 README 为模板。仓库建立后在聚合仓同名路径登记 Git 子模块,并在聚合仓 settings.gradle.kts 追加 include(以下 cc-street 仅为规划模块示例,不表示已经建仓):
include("cc-street")新建子仓根目录 build.gradle.kts(以 cc-survival/build.gradle.kts 为模板,全部依赖 compileOnly,普通 jar 即最终构件):
// cc-<name>:<一句话职责>
plugins {
id("java-library")
}
group = "com.chilicraft"
version = "1.0.0"
// 独立构建也使用 Java 21,避免中文 Windows 的 JDK 17 argfile 编码问题。
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.release = 21
options.compilerArgs.add("-Xlint:deprecation")
}
tasks.withType<Javadoc>().configureEach {
options.encoding = "UTF-8"
}
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/") { name = "papermc" }
mavenLocal()
maven {
url = uri("https://maven.pkg.github.com/ChiliCraft/cc-core")
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN")
}
}
}
dependencies {
// 核心 API:运行时由服务端上已安装的 cc-core 提供,不打进 jar
compileOnly("com.chilicraft:cc-core:1.0.0")
// 服务端 API:运行时由服务端提供,不打进 jar
compileOnly("io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT")
// 日志门面:服务端自带,不打进 jar
compileOnly("org.slf4j:slf4j-api:2.0.13")
}
// 把版本号注入 plugin.yml(plugin.yml 中用 ${version} 占位)
tasks.processResources {
val props = mapOf("version" to project.version.toString())
inputs.properties(props)
filesMatching("plugin.yml") {
expand(props)
}
}独立子仓保留 Java 21、UTF-8 和 release=21 配置;聚合仓根脚本施加相同约定,并把 com.chilicraft:cc-core 替换为本地 :cc-core。独立构建需先在核心仓执行 sh gradlew publishToMavenLocal,或按模块 README 配置 GitHub Packages 访问;聚合构建不需要预发布核心。子仓和文档提交推送后,再更新父仓的 gitlink。
cc-<name>/src/main/resources/plugin.yml:
# ChiliCraft <中文名>附属(cc-<name>)
name: cc-<name>
version: "${version}"
main: com.chilicraft.<name>.Chili<Name>Plugin
api-version: "1.21"
authors: [ChiliCraft]
description: <一句话中文描述>
# 硬依赖核心:核心未启用时本附属自动禁用
depend: [cc-core]
# 兼容基线:兼容的核心 API 版本(核心装载时校验,未达标给出可读告警)
compatible-core: ">=1.0"
softdepend: []
# 旧顶层命令仅在需要兼容时声明;统一入口由 cc-core 的 /cc 路由提供
commands:
<name>:
description: <中文名>模块命令
usage: /<name> <sub...>
permission: chilicraft.<name>.use
permissions:
chilicraft.<name>.use:
description: 允许使用 <name> 模块
default: true
chilicraft.<name>.admin:
description: 允许使用 <name> 模块管理操作
default: op
children:
chilicraft.<name>.use: true
# 迁移中的旧 user 节点可保留,并与 use 任一满足;危险子命令仍须代码二次校验 admin。不要声明对其他附属的 depend/softdepend——跨模块协作只走事件总线(字符串事件名,无编译期依赖,详见 events-protocol.md)。
包名 com.chilicraft.<name>,固定五个角色(完整活例见 cc-demon / cc-survival):
public final class ChiliStreetPlugin extends JavaPlugin {
private ChiliCraftAPI api;
private StreetSettings settings;
private BukkitTask task;
private EventHandler reloadHandler;
@Override
public void onEnable() {
Logger log = getSLF4JLogger();
saveDefaultConfig(); // 缺失时 getConfig 返回空配置
// 1. ServicesManager 取核心 API,取不到则自禁用
RegisteredServiceProvider<ChiliCraftAPI> reg =
getServer().getServicesManager().getRegistration(ChiliCraftAPI.class);
if (reg == null) {
log.error("未找到 cc-core 提供的 ChiliCraftAPI 服务,cc-street 已自行禁用");
getServer().getPluginManager().disablePlugin(this);
return;
}
api = reg.getProvider();
// 2. 注册模块配置(包装活配置,防 reloadConfig 换实例后读到旧引用)
settings = new StreetSettings(this);
settings.refresh();
try {
api.registerModuleConfig("cc-street", new LiveModuleConfig(this));
} catch (IllegalStateException e) { // moduleId 重复注册
log.error("模块配置注册失败:{}", e.getMessage());
getServer().getPluginManager().disablePlugin(this);
return;
}
// 3. 装配服务 / 监听器,启动周期任务(延迟起步避开启动尖峰)
// task = getServer().getScheduler().runTaskTimer(this, new StreetTask(...), 20L, 20L);
// 4. 业务对象就绪后只注册一次统一菜单入口和命令路由
api.registerMenuEntry(menuEntry);
api.registerCommandRoute(commandRoute);
// 5. 订阅:core.reload 热载 + 其他模块业务事件
reloadHandler = (eventName, data) -> { reloadConfig(); settings.refresh(); };
api.subscribe("core.reload", reloadHandler);
// api.subscribe("street.world_enter", streetHandler); // 跨模块订阅范式
}
@Override
public void onDisable() {
if (task != null) { task.cancel(); task = null; }
// 事件总线持订阅者强引用,禁用必须退订
if (api != null && reloadHandler != null) {
api.unsubscribe("core.reload", reloadHandler);
}
if (api != null) {
api.unregisterCommandRoute("street");
api.unregisterMenuEntry("street");
}
api = null; settings = null; reloadHandler = null;
}
}LiveModuleConfig 是每模块自带的 30 行包装类(逐 getter 穿透 plugin.getConfig()),从任一附属复制后改 moduleId() 返回值即可——不要把 getConfig() 瞬时引用直接交给核心,reloadConfig() 会替换内部实例导致旧引用读到过期数据。
统一入口注册补充规范:附属通过公开 ChiliCraftAPI 注册 ModuleMenuEntry 与 ModuleCommandRoute,不要依赖 com.chilicraft.core.*。菜单入口的模块 ID、分类、排序、Material、usePermissions、可选 adminPermissions、可用性和打开/帮助回调必须与路由保持一致;命令执行和 Tab 补全分别使用 ModuleCommandExecutor、ModuleTabCompleter。回调只捕获服务或 GUI 门面,不捕获单个 Player;注册表不保存玩家对象。
旧顶层命令若需兼容,应让 Bukkit 的 onCommand/onTabComplete 与统一路由共同调用同一个 execute/complete 分发方法,不复制业务逻辑。核心 /cc 先检查模块基础使用权限,模块处理器仍须对管理子命令二次检查 chilicraft.<name>.admin(兼容旧管理节点),并在 complete 中隐藏无权限管理候选。规划模块不注册虚假路由,由核心菜单和 Tab 显示开发中。
注册失败必须回滚另一项注册并禁用附属。core.reload 处理器只能刷新配置、Settings、文案和内容缓存,不得再次调用注册 API;所有菜单/Inventory 与玩家操作只在主线程执行。
周期任务禁止每秒穿透配置树,全部数值进缓存类:
- 字段包内可见(
boolean/double/int/List/Map); refresh()从plugin.getConfig()重建全部字段,onEnable 与 core.reload 时各调一次;- 非法值回退默认并
logger.warning(复用 positive / nonNegative / clamp 辅助); - 消息模板整体读入
Map<String, String>,message(key)缺失返回空串,展示层判空静默跳过。
- Service:纯业务逻辑,状态经构造注入,无静态可变字段;
- Listener:只挂低频 Bukkit 事件(交互、死亡、实体死亡)。移动、背包变化等高频事件一律禁止;
- Task:周期任务单任务单句柄(一个
runTaskTimer覆盖全部循环业务),run()内逐玩家try-catch,一人异常不中断整轮。周期取值参考:cc-demon 2 秒(40L, 40L)、cc-survival 1 秒(20L, 20L)。
- 模板存 config
messages段,玩家文本一律 MiniMessage; - 有占位符用
Placeholder.unparsed("key", value)resolver;模板缺失返回空组件(静默跳过),解析异常回退纯文本(范式见 cc-survivalPressureTask#msg)。
src/main/resources/config.yml:头部注释块说明约定 → 数值段(每键中文注释)→ messages 段。参考任一现有模块。数值键缺失或非法必须回退内置默认值(代码层兜底),配置文件被管理员删键不应导致功能失效。
以下范式由 cc-adventure 沉淀,非五件套必需,内容体量或外部集成达到相当规模时采用:
- 内容文件与 config 分离:条目型内容(地城 ×10、Boss ×5)拆为独立 YAML(
dungeons.yml/bosses.yml),onEnable 用「缺失才释放资源」的方式落盘,由独立解析类(DungeonContent/BossContent)读成定义对象;config.yml 只留数值与messages段。内容文件同样经core.reload热载(重读文件 + 重建定义缓存)。 - 消息键含点号的加载:消息键形如
expedition.start时,Bukkit 配置以.为路径分隔符,getKeys(false)+getString(key)永远取不到值(静默全空)。范式:getKeys(true)遍历深键、跳过中间ConfigurationSection,YAML 按嵌套结构书写,还原出的深路径恰与代码键一致(见 AdventureSettings.java)。键名无点号(如 cc-martial 的连字符键)不受此坑影响。 - 可选第三方插件集成:plugin.yml 声明
softdepend(如 MythicMobs),构建侧使用官方 Maven 坐标并声明compileOnly,不引用本地 jar 或test-server/;运行侧独立适配器封装全部外部调用,每次调用catch (Throwable)降级到原版实现路径——外部插件缺失、版本不符、内部异常均不影响本模块核心玩法(见 MythicAdapter.java)。 - 附属箱子 GUI:复用 cc-core 的
GuiHolder/GuiListener(cc-core 已全局注册,instanceof GuiHolder识别并统一取消点击),附属直接new GuiHolder(...),零监听器、零 plugin.yml 变更。范式(见 cc-adventureAdventureGui等 6 类、cc-martialMartialGui等 5 类、cc-soulSoulGui等 3 类、cc-demonDemonGui等 3 类):快照式构建——每次打开全新构建面板,点击动作成功后重建刷新;面板类与服务同包(服务多为包私有);文案走messageOr(key, def)兜底——按钮名/lore 不可空白,configmessages的 gui 键段为权威文案源,代码 def 仅作保险丝且与 config 默认值保持一致;gui 文案键命名跟随模块 Settings 的消息加载深度——深键模块按嵌套小节书写(cc-adventuremessages.gui.*),浅键加载(getKeys(false))模块必须平铺 kebab-case(cc-martialgui-title-main风格,嵌套小节浅加载读不到);数据文件(dungeons.yml 等)串的 displayName 按纯文本展示(Component.text,与服务层广播Placeholder.unparsed同口径),模板占位符先Texts.parse再replaceText(matchLiteral)替换(占位符命名避开 MiniMessage 标准标签);玩家头仅对在线玩家setOwningPlayer(离线拉档案会卡主线程)。
.\gradlew.bat build --offline # 网络受限时的兜底;常规用 .\gradlew.bat build- 独立模块仓已登记为同名子模块;聚合仓 settings.gradle.kts 已 include;
gradlew build通过 - plugin.yml:
depend: [cc-core]、compatible-core: ">=1.0",无对其他附属的依赖声明 - onEnable:API 取不到时自禁用;
registerModuleConfig包 IllegalStateException - 全部 subscribe 在 onDisable 有对应 unsubscribe;周期任务 cancel
- 菜单入口和命令路由在 onEnable 各注册一次,注册失败回滚;onDisable 成对调用
unregisterMenuEntry/unregisterCommandRoute -
core.reload只刷新配置/缓存,不重复注册;注册回调不持有Player - 旧顶层命令与统一路由复用同一
execute/complete;Tab 按权限过滤管理候选 - 周期任务单句柄、逐玩家 try-catch;无 PlayerMoveEvent / 高频事件监听
- Settings 全字段经 refresh() 重建;非法值回退默认
- 消息模板缺失静默、解析失败回退纯文本
- 注释与配置全中文;源码 UTF-8