编写扩展
Cultivation 0.5.0 新增。
事件让你重塑 Cultivation 的所作所为。注册表让你添加它视如己出的内容。而本页的三个钩子走得更远:它们让另一个模组接管 Cultivation 的一整个层次,同时让其余一切照常在其之上运行。
| 钩子 | 所替换的 |
|---|---|
ProgressionProvider | 整条境界/阶段/灵气的阶梯。 |
CultivationTheme | 模组展示给玩家的每一个字。 |
AdminConfigSection | 什么也不替换 —— 它是把你自己的设置添加进 Cultivation 的管理菜单。 |
三者皆从你插件的 setup() 中安装,相对于 Cultivation 自身的加载顺序不限。在玩家打坐、打开菜单或触及某道境界门槛之前,无人读取它们,而那已远在每个插件加载完毕之后。
ProgressionProvider
CultivationAPI.setProgressionProvider(@Nullable ProgressionProvider provider)
CultivationAPI.getProgressionProvider()
把境界/阶段/灵气的阶梯交到你的模组手里。自安装的那一刻起,Cultivation 中每一条指令、每一个界面页、每一行状态栏与每一道玩法门槛,都改从你这里读取数字,而不再读玩家的 CultivationComponent:/cultivation 显示你的等级名,状态栏显示你的经验条,打坐仪式跑的是你的升级。
模组中其余一切照常运作。宗门、大道、功法、灵兽、阵法、洞府、斗法、炼丹、炼器与天赋树,一律架在当前所安装的那条阶梯之上。
你需要实现的方法
plugin.siren.API.ProgressionProvider 共有 22 个方法。其中十七个是抽象方法,必须由你来写;其余五个带有默认实现。除另有注明者外,每个方法拿到的都是被询问玩家的 ComponentAccessor<EntityStore> accessor 与 Ref<EntityStore> ref(下表缩写为 accessor, ref)。
| 方法 | 返回 | 用途 |
|---|---|---|
getId() | String | 一个稳定、唯一、以你的模组名作命名空间的 id("SoulRings:spirit_power")。用于日志,也让 /cultivation admin 能报告当前生效的是哪套体系。 |
getLevel(accessor, ref) | int | 玩家那个平直、只增不减的力量数值。驱动最大生命与伤害曲线、跨玩家排行,以及任何需要用单一标量表示「这名修士有多强」的地方。它不必与内置的 0 到 27 的刻度一致。 |
getMaxLevel() | int | 可达到的最高等级,供进度条与「x / y」展示使用。不接受玩家参数。 |
isMaxLevel(accessor, ref) | boolean | 玩家是否已修至圆满。一旦为真,打坐不再授予进度,两种仪式也不再提供。 |
getProgress(accessor, ref) | float | 玩家通往下一级的已存进度。内置体系把它显示为「灵气」。 |
getProgressRequiredForNext(accessor, ref) | float | 下一级的花费,且已计入你自己的任何折扣。满级时返回 Float.MAX_VALUE。 |
addProgress(accessor, ref, float amount, PlayerRef playerRef) | void | 存入来自 Cultivation 自身某个来源的进度 —— 在灵脉上打坐、一次魔道击杀、管理员的 addqi。该数额已经过种族、天赋树、丹药、宗门与阴阳倍率,以及可取消的 PreQiGainEvent。什么都不做,即彻底拒绝该来源。playerRef 可为 null。 |
getRankLabel(accessor, ref) | Message | 内置体系显示境界名的地方,要显示什么:一个等级称号、一个职业、一个层级。出现在状态栏、属性页、/cultivation info 与排行中。 |
getSubRankLabel(accessor, ref) | Message,可为 null | 内置体系显示阶段名的地方,要显示什么 —— 通常是该等级之内的层次。返回 null 则该位置什么也不显示。 |
getEquivalentRealm(accessor, ref) | CultivationRealm | 这名玩家算作达到了七个境界中的哪一个,供每一道境界门槛使用(见下文)。 |
isReadyForBreakthrough(accessor, ref) | boolean | 玩家是否已存够,可以尝试里程碑式的仪式。最先被检验。 |
isReadyForAdvancement(accessor, ref) | boolean | 玩家是否已存够,可以尝试寻常的仪式。 |
getBreakthroughDurationSeconds(accessor, ref) | float | 里程碑式仪式需要多久,以不被打断的打坐秒数计。 |
getAdvancementDurationSeconds(accessor, ref) | float | 寻常仪式需要多久。 |
completeBreakthrough(accessor, ref, PlayerRef playerRef) | void | 里程碑式仪式已完成:扣除已存进度并让玩家升级。Cultivation 仍会处理围绕它的一切 —— 天赋点、庆贺粒子、状态栏刷新与 BreakthroughEvent —— 因此你只需移动自己的数字并告知玩家。 |
completeAdvancement(accessor, ref, PlayerRef playerRef) | void | 寻常仪式已完成。契约相同。 |
demote(accessor, ref, PlayerRef playerRef, boolean wasBreakthrough) | void | 仪式失败 —— 中途离开,或因走火入魔而落败。内置体系会让玩家跌落一个阶段并清空存蓄灵气;请按你的体系,给出与之相当的惩罚。 |
getRankLabelForLevel(int level) | Message,默认 null | 为一名未载入的玩家给出等级标签。排行榜会列出每一位曾被见到的修士,包括所有离线者,所以手头只有一个记录下来的等级。凡是仅凭等级就能决定位阶的,都应实现它;默认值会让排行回落到与等级一并记下的境界名。 |
getSubRankLabelForLevel(int level) | Message,默认 null | getSubRankLabel 的离线对应。仅当上一个方法返回了标签时才会被查询,因为二者合起来填满的是同一行「位阶(子阶)」。 |
supportsProfiles() | boolean,默认 false | 你是否让自己的进阶体系与 Cultivation 的存档保持同步。默认值会在你安装期间回绝存档切换 —— 见下文。 |
getHealthBonus(accessor, ref, float configuredHealthPerLevel) | float,默认 level * configuredHealthPerLevel | 这名玩家进阶体系所带来的附加最大生命。以带键的属性修正施加,因此日后数值变小时,加成会随之正确缩减。仅当你的等级想要不同的曲线时才覆写。 |
getDamageMultiplier(accessor, ref, float configuredDamagePercentPerLevel) | float,默认 1 + level * (percent / 100) | 仅由进阶体系带来的输出伤害倍率。种族、天赋树与功法的倍率会叠加在你返回的值之上,因此「无加成」请返回 1.0,绝不要返回 0。 |
一个最小的 provider
一个完整的 provider,只维系一个属于它自己的数字。LadderComponent 是你自己持久化的组件;这个接口刻意是无状态的,所以你的组件始终是唯一的事实来源。
public final class MyLadder implements ProgressionProvider {
private static final int MAX_LEVEL = 30;
@Nonnull @Override
public String getId(){
return "MyMod:ladder";
}
// --- 等级 ---
@Override
public int getLevel(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
return ladder == null ? 1 : ladder.getLevel();
}
@Override
public int getMaxLevel(){
return MAX_LEVEL;
}
@Override
public boolean isMaxLevel(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
return getLevel(accessor, ref) >= MAX_LEVEL;
}
// --- 进度 ---
@Override
public float getProgress(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
return ladder == null ? 0f : ladder.getXp();
}
@Override
public float getProgressRequiredForNext(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
return isMaxLevel(accessor, ref) ? Float.MAX_VALUE : 100f * getLevel(accessor, ref);
}
@Override
public void addProgress(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref,
float amount, @Nullable PlayerRef playerRef){
LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
if(ladder == null || isMaxLevel(accessor, ref)){
return;
}
ladder.addXp(amount);
CultivationAPI.refreshProgression(accessor, ref); // 让状态栏的进度条保持实时
}
// --- 展示 ---
@Nonnull @Override
public Message getRankLabel(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
return Message.translation("server.mymod.rank." + (getLevel(accessor, ref) / 10));
}
@Nullable @Override
public Message getSubRankLabel(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
return Message.translation("server.mymod.label.level").param("level", String.valueOf(getLevel(accessor, ref)));
}
// --- 门槛 ---
@Nonnull @Override
public CultivationRealm getEquivalentRealm(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
CultivationRealm[] realms = CultivationRealm.values();
return realms[Math.min(getLevel(accessor, ref) / 5, realms.length - 1)];
}
// --- 仪式:每逢第十级为里程碑,其余皆寻常 ---
private boolean banked(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref){
return !isMaxLevel(accessor, ref) && getProgress(accessor, ref) >= getProgressRequiredForNext(accessor, ref);
}
@Override
public boolean isReadyForBreakthrough(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
return banked(accessor, ref) && getLevel(accessor, ref) % 10 == 9;
}
@Override
public boolean isReadyForAdvancement(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
return banked(accessor, ref) && getLevel(accessor, ref) % 10 != 9;
}
@Override
public float getBreakthroughDurationSeconds(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
return 60f;
}
@Override
public float getAdvancementDurationSeconds(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
return 30f;
}
@Override
public void completeBreakthrough(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref,
@Nullable PlayerRef playerRef){
levelUp(accessor, ref);
}
@Override
public void completeAdvancement(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref,
@Nullable PlayerRef playerRef){
levelUp(accessor, ref);
}
private void levelUp(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref){
LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
if(ladder != null){
ladder.levelUp(); // 花掉已存进度,并加一级
}
}
@Override
public void demote(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref,
@Nullable PlayerRef playerRef, boolean wasBreakthrough){
LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
if(ladder != null){
ladder.setXp(0f); // 仪式失败只损失已存进度,绝不掉级
}
}
}
只需在你插件的 setup() 中安装一次:
CultivationAPI.setProgressionProvider(new MyLadder());
上例刻意略去了那五个带默认实现的方法:getHealthBonus 与 getDamageMultiplier 本就随 getLevel 缩放;supportsProfiles() 在你处理好存档切换之前应保持 false;而那两个 ...ForLevel 标签,只有当你想让离线玩家也以你的名号上榜时才有意义。
境界门槛
模组中数十道门槛是针对 CultivationRealm 写的 —— 一门功法的解锁境界、一个种族的解锁境界、一种灵兽的最低境界、炼器阶数、洞府品质、大道解锁。Cultivation 不去重写它们,而是询问你的 provider:这名玩家算作哪个境界:
@Nonnull CultivationRealm getEquivalentRealm(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref);
随你把自己的等级映射到那七个境界上,此后每一道既有门槛都会白白地读懂你的进阶体系。把你的等级区间按比例均分,通常就是对的;返回一个常量也行,但那样一来,模组中每一项受境界限制的功能,不是永远敞开,就是永远关死。
两种升级
Cultivation 通过其计时打坐仪式驱动升级,并提供两种风味。随你把自己的进阶体系映射上去:
| 风味 | 仪式 |
|---|---|
| 晋阶 | 寻常的一步。更短、灵脉要求更低,默认不带天劫雷霆。 |
| 突破 | 里程碑式的一步。更长、灵脉要求更高、伴有天劫雷霆 —— 对偏倚极深的修士则是心魔劫。 |
同一时刻只能有一种就绪;isReadyForBreakthrough 会被先行检验。两者都不返回 true,不过意味着打坐在这一 tick 做它寻常该做的事。仪式时长也由你自己经由 getBreakthroughDurationSeconds 与 getAdvancementDurationSeconds 供给,因此内置的那套时长对你的阶梯并不适用。
你所存储的东西
这个接口刻意是无状态的 —— 每个方法都会拿到被询问玩家的 accessor 与 ref,因此你自己持久化的组件始终是唯一的事实来源。玩家的 CultivationComponent 依然存在且被完全放置不动,正是这一点让服务器得以卸载你的扩展,并原封不动地取回它原本的境界。
线程。每个方法都在该玩家的世界线程上被调用,而那个 accessor 可能是一个 CommandBuffer 而非 Store。组件的创建必须走 accessor,而不是 ref.getStore().putComponent —— 后者会抛出 "Store is currently processing!" 并把世界线程带崩。读取则两种方式都无妨。
同一时刻只能安装一个 provider。最后注册者胜出,并会记录一条同时点名双方的警告 —— 因为两个模组各自都认为自己拥有进阶体系,这是一种配置错误,而不是该悄悄挑一个赢家的事。传入 null 即可把进阶体系交还给内置系统。
每当你自己的代码在升级之外改动了玩家的等级,或在进度条正显示于屏幕时改动了他的已存进度,请调用 CultivationAPI.refreshProgression(accessor, ref)。Cultivation 自己无从察觉别人组件内部发生的改变,所以凡是跳过这一步的,都会让状态栏、排行与每一道境界门槛仍显示玩家先前的境况。
provider 治下的存档
v0.7.0 该接口新增了一个 default 方法:
default boolean supportsProfiles(){
return false;
}
而这个默认值会在你的 provider 安装期间直接回绝存档切换 —— 玩家会被告知此处的进阶体系由另一个模组掌管。这是刻意为之:一次切换会在你脚下换掉玩家的 CultivationComponent、种族、大道、天赋树与功法,而一个未曾为此做好准备的 provider,届时描述的将是错误的那名修士 —— 做到一半,比直接回绝更糟。
要解除这道回绝,请完成以下两件事,随后返回 true:
- 处理
ProfileEvents.PreProfileSwitchEvent—— 为即将离开的那份存档保存你自己的状态。 - 处理
ProfileEvents.ProfileSwitchEvent—— 为抵达的那份存档载入你的状态。
存档监听器是整个接口中唯一被允许直接经由 Store 写入组件的地方 —— 它们运行在世界线程上,且在任何 tick 系统之外,正是为了让 provider 能做这份记账。完整的 ProfileEvents 表见事件。这道回绝有一个例外:操作员的试炼存档沙盒即便在 provider 治下也照样被允许 —— 它不会改变你的扩展对该玩家的看法,指令本身也会如此说明。
CultivationTheme
CultivationAPI.setTheme(@Nullable CultivationTheme theme)
CultivationAPI.getTheme()
在丝毫不触碰行为的前提下,重写模组面向玩家的全套用词,如此一来,一个讲魂师与学院而非修士与宗门的设定,读起来便是它自己的一款游戏,而不是一层换皮。Cultivation 展示给玩家的每一个字符串 —— 聊天、指令、状态栏、菜单、物品名、提示框 —— 都会先经由你的 translate 路由,而你返回什么就原样采用什么。
CultivationTheme 有两个方法:
@Nonnull String getId(); // 带命名空间("SoulRings:soul_land");仅用于日志与 /cultivation admin
@Nullable Message translate(@Nonnull String key); // 完整的键,始终带有 `server.` 前缀
对任何你不打算改写的键,从 translate 返回 null,即采用 Cultivation 自己的用词,因此一套主题想覆盖多少便覆盖多少。
为何是钩子而非语言文件
因为引擎不允许你那样做。所有资源包的语言文件会被合并进单一目录,而这次合并是先写者胜 —— 来自较晚资源包的重复键会被丢弃,并记录为 'x' has multiple definitions。Cultivation 自己的资源包先于任何依赖它的扩展加载,因此扩展若在自己的 server.lang 中提供 server.cultivation.playerMsg.breakthrough,会被悄然忽略;而即便侥幸赢下这场竞争,也不过是赢在那一次启动而已。
所以主题把自己的字符串放在它自己的键之下 —— 那与任何东西都不冲突 —— 再把 Cultivation 的键映射过去。
只要可能,就返回你自己键的 Message.translation(...),而非 Message.raw(...)。原始文本在每种语言下都长得一模一样,而 Cultivation 是有翻译的。
translate 是在正构建该消息的那个线程上调用的,且每渲染一行往往要调用数次,因此它必须快速、线程安全且无副作用 —— 在一个不可变 map 中查表,正是它本该有的形状。Cultivation 所填入的任何 .param(...) 占位符,都会施加到你所返回的内容上,因此替换文本必须保留同样的占位符名;漏掉一个,那一处便会渲染为空。同一时刻只能安装一套主题;最后注册者胜出,并会记录一条同时点名双方的警告。
AdminConfigSection
CultivationAPI.registerAdminConfigSection(@Nonnull AdminConfigSection section)
CultivationAPI.unregisterAdminConfigSection(@Nonnull String sectionKey)
CultivationAPI.getAdminConfigSections()
CultivationAPI.getAdminConfigSection(@Nonnull String sectionKey)
CultivationAPI.newAdminConfigSection(String key, String labelKey, String hintKey, int sortOrder, Runnable save, List<AdminConfigField> fields)
向 Cultivation 的管理菜单添加一组可调数值,它会作为一个独立的分节按钮,与 Cultivation 自己的 80 个(v0.11.2 比 0.11.1 多十个,v0.11.1 比 0.11.0 多八个)并列出现在 /cultivation admin 的配置页上 —— 同样的行、同样的编辑方式、同样的一次保存。这些设置同时也出现在寻常 Cultivation 设置菜单底部的一块模组设置区域中,仅管理员可见。完整示例、五种行工厂与提示框规则,见注册表。
一份配置文件对应一个分节,这是 Cultivation 自己所采用、也是读起来最顺的形状。
| 成员 | 用途 |
|---|---|
getKey() | 一个稳定的 id,在所有已注册分节中唯一。请加命名空间("SoulRings:spiritPower")。点击按钮时回传的正是它,故而刻意与列表顺序无关。 |
getLabel() | 分节按钮与标题。请使用 Message.translation(...) 以便本地化。 |
getHint() | 一行说明此分节调整的是什么 —— 即按钮提示框与标题下方的文字。 |
getFields() | 各行,按显示顺序排列。请用 newAdmin...Field 工厂方法构建,而非自行实现 AdminConfigField。 |
save() | 管理员按下保存后持久化此分节。 |
getSortOrder() | 默认 SORT_LAST(100000)。Cultivation 自己的分节从 SORT_BUILTIN_FIRST(100)起、以 100 为步长往上占位,因此介于其间的数值,能把你的分节嵌在它们当中。 |
isVisible() | 默认 true。每次渲染都会读取,所以分节可以自行隐藏 —— 未安装 Endless Leveling 时,它的分节便是这样做的。 |
isShownInSettings() | 默认 true。返回 false(或用 CultivationAPI.adminPageOnly(section) 包裹该分节),即可只留在管理页上;若一个模组贡献了好几个装着真正平衡数值的分节,这么做很值得。 |
一行共有五种类型(AdminConfigField.Kind):NUMBER(小数输入框,默认)、INT、BOOLEAN(真正的复选框)、CHOICE(基于 AdminConfigChoice 的下拉框)与 TEXT。0.6.1 之前只有数字,所以总开关只得伪装成一个 0/1 字段;如今已不必如此。任何列表形状的东西,请留在配置文件里。
两个宿主页面都以 cultivation.admin 为门槛,且该门槛在保存时是在服务端重新核验的,而非信任菜单的说法,因此一个分节尽可安全地暴露真正的平衡数值。
返回一份稳定的字段列表。页面是按键把管理员正在编辑的内容与字段对应起来的,因此一份在渲染与保存之间改变形状的列表,会悄悄丢掉那些编辑。请在字段的 set 中夹紧越界值 —— 页面此后会重新显示 get 所返回的内容,因此被强制修正的值会回显给管理员,而不是与他所输入的悄然不符。
以同一分节键重复注册会替换先前那一个,因此在你的插件重载时这是安全的。
在任何阶梯下读取玩家
三个门面方法会在当前生效的任一体系下报告修士的境况,因此你的代码无需知道是否安装了 provider:
| 方法 | 返回 |
|---|---|
CultivationAPI.getGlobalLevel(accessor, ref) | 已安装 provider 的等级,或内置境界/阶段压平成的一个只增不减的数。 |
CultivationAPI.getQi(accessor, ref) | 通往下一次升级的已存进度 —— 若安装了 provider 则为其进度,否则为存蓄灵气。 |
CultivationAPI.refreshProgression(accessor, ref) | 在你自己的模组于升级之外改动了玩家等级之后,重新读取 provider 对该玩家的视图。 |
CultivationAPI.getRealm(accessor, ref) 返回的是有效境界,即在安装了 provider 时其所给出的等价境界 —— 模组中每一道门槛检验的正是它。在 provider 之下,CultivationAPI.getStage(accessor, ref) 始终为 null,因为替换阶梯并没有子阶段 —— 它只有自己的 getSubRankLabel。凡涉及数值,请优先使用 getGlobalLevel。
排行与 /cultivation top 按那个压平后的等级排序,因此一套替换进阶体系能正确地与自身相互排名。对内置阶梯而言,其顺序恰是「先境界后阶段」一贯产出的结果。