指南

组织模组

模组写一次,却要维护好几年。对维护帮助最大的不是语言,而是每样东西放在哪里。本指南分别为 CraftScript 和 Kotlin 推荐一种结构,并解释原因。

四条规则

  1. 每个文件只负责一件事。矿石一个文件,配方一个文件,每条命令各一个文件。如果描述一个文件需要用到“和”字,它多半应该是两个文件。
  2. 按主题分组,而不是按大小。使用 content/、events/、commands/、ui/ 和 lib/ 这样的文件夹。任何人打开模组,不读代码也知道该去哪里找。
  3. 数据与行为分开。存在的东西(物品、方块、配方)是一组属性。发生的事情(事件、命令)是代码。把它们混在一起,改一个数字就得去读逻辑。
  4. 入口只负责连接各部分。main.craft 或模组类只说明有哪些部分,不包含游戏规则。

这样一来,重新平衡模组只需编辑 content/recipes.craft;修复命令只需打开 commands/;事件中的 bug 也不会藏在两百行方块定义之间。

CraftScript 中

推荐结构文本
mods/rubies/
├── main.craft               the mod and the list of its parts
├── settings.craft           settings for the server owner
├── content/                 data: what exists
│   ├── items.craft
│   ├── ores.craft
│   ├── world.craft
│   ├── dimensions.craft
│   ├── creatures.craft
│   ├── sounds.craft
│   ├── particles.craft
│   └── recipes.craft
├── events/                  behaviour: what happens
│   ├── mining.craft
│   ├── hunting.craft
│   └── welcome.craft
├── ui/                      forms: what the player sees
│   └── shop.craft
├── commands/
│   ├── realm.craft
│   ├── rubies.craft
│   └── shop.craft
├── lib/                     shared logic
│   ├── rewards.craft
│   └── storage.craft        saved data: the keys, in one place
├── sounds/
│   └── ruby/chime.ogg
├── structures/
│   └── ruby_shrine.nbt
└── textures/
    ├── item/ruby.png
    ├── block/ruby_ore.png, ruby_log.png, ruby_leaves.png, ruby_flower.png
    ├── entity/ruby_golem.png
    └── particle/ruby_spark_0.png, ruby_spark_1.png

mods/rubies/

只声明模组并列出它的各个部分。读一遍就知道有什么、在哪里。

mods/rubies/main.craftCraftScript
mod "rubies" { name: "Rubies", version: "1.0.0", author: "you" }

// What exists
import "content/items"
import "content/ores"
import "content/world"
import "content/dimensions"
import "content/creatures"
import "content/sounds"
import "content/particles"
import "content/recipes"

// What happens
import "events/mining"
import "events/hunting"
import "events/welcome"
import "commands/realm"
import "commands/rubies"
import "commands/shop"

导入的工作方式

  • import "content/items" 会运行该文件一次。它声明的物品、方块、事件和命令成为模组的一部分,就像写在 main.craft 中一样。
  • import { give_rubies } from "/lib/rewards" 导入指定的名称。以 / 开头的路径从模组文件夹开始解析,所以在任何子文件夹中效果都一样;不带 / 则相对于导入它的文件。.craft 扩展名可省略。
  • import "lib/rewards" as rewards 以一个名称导入整个模块(rewards.give_rubies(p, 1))。不带 as 时,名称就是文件名,因此两个同名的导入文件会互相覆盖:请使用不同的名称或 as。
  • 任何路径都不能离开模组文件夹,循环导入会报错并指出位置。

共享逻辑

被两个文件使用的函数放在 lib/ 中。如果逻辑有自己的状态(经济系统、任务队列),就用带方法的类,同样放在 lib/ 中;见 CraftScript。只被一个文件使用的变量留在该文件中,但要记住它会在重启后丢失:需要长期保留的内容应放进存档数据。

设置与存档数据各占一个文件

有两样东西模组的其他部分会用到,但不应了解其内部细节:

  • 设置(根目录下的 settings.craft):一个包含所有默认值的 config(…)。其他文件导入 SETTINGS 并读取所需的值;没有任何文件把平衡数值写死。
  • 存档数据(lib/storage.craft):键以及几个小函数,比如 visits(p) 和 add_visit(p)。统计访问次数的代码调用 add_visit(player),并不知道 rubies.visits 这个键的存在。哪天它要改,只需改一处。

不要把这个文件命名为 config.craft:不带 as 导入时,它的名称会遮蔽 config 函数。

Kotlin 中

推荐结构文本
rubies/
├── build.gradle.kts
├── settings.gradle.kts
└── src/main/
    ├── kotlin/com/example/rubies/
    │   ├── RubiesMod.kt         entry point: only wires the parts
    │   ├── RubiesConfig.kt      player settings
    │   ├── content/
    │   │   ├── Items.kt
    │   │   ├── Blocks.kt
    │   │   ├── Creatures.kt
    │   │   ├── Sounds.kt
    │   │   ├── Particles.kt
    │   │   └── Recipes.kt
    │   ├── storage/
    │   │   └── Visits.kt        saved data: the keys, in one place
    │   ├── events/
    │   │   ├── MiningEvents.kt
    │   │   ├── CombatEvents.kt
    │   │   └── WelcomeEvents.kt
    │   ├── ui/
    │   │   └── ShopForm.kt       forms: what the player sees
    │   └── commands/
    │       ├── RubyCommand.kt
    │       └── ShopCommand.kt
    └── resources/
        ├── crafting.mod.json
        └── assets/rubies/
            ├── sounds/ruby/chime.ogg
            └── textures/
                ├── item/ruby.png
                ├── entity/ruby_golem.png
                └── particle/ruby_spark_0.png, ruby_spark_1.png

src/main/kotlin/com/example/rubies/

入口类本身不做任何事:它创建共享的部分并调用每个部分。

src/main/kotlin/com/example/rubies/RubiesMod.ktKotlin
package com.example.rubies

class RubiesMod : CraftingMod {
    override fun onLoad(ctx: ModContext) {
        val config = Crafting.configs.load(RubiesConfig())
        val content = ContentBuilder(ctx.metadata.id, Crafting.registries)

        Items.register(content)
        Blocks.register(content)
        Creatures.register(content)
        Sounds.register(content)
        Particles.register(content)
        Recipes.register(content)

        MiningEvents.register(config)
        CombatEvents.register(config)
        WelcomeEvents.register()
        RubyCommand.register()
        ShopCommand.register()
    }
}

为便于阅读,省略了每个文件的 import 行。

  • 按主题分包:content、events、commands。每个文件一个 object,带一个 register 函数。
  • 模组类只负责连接各部分:它创建多个部分共享的东西(配置、ContentBuilder)并传给它们。没有哪个部分自己去获取东西,所以只读它自己的文件就能理解它。
  • id 作为常量,放在注册它们的 object 中(Items.RUBY)。拼写错误会变成编译错误。
  • 存档数据放在 storage/:每个键放在一个 object 中,配有 Visits.of(player) 和 Visits.add(player) 这样的函数。设置放在 Config 类中,传给需要它的部分。

Java 中

结构相同,只是用类代替 object。通过 Crafting.INSTANCE 使用 API,监听器返回 Unit.INSTANCE:

src/main/java/com/example/rubies/RubiesMod.javaJava
package com.example.rubies;

public final class RubiesMod implements CraftingMod {
    @Override
    public void onLoad(ModContext ctx) {
        ContentBuilder content = new ContentBuilder(ctx.getMetadata().getId(), Crafting.INSTANCE.getRegistries());
        Items.register(content);
        WelcomeEvents.register();
    }
}
src/main/java/com/example/rubies/events/WelcomeEvents.javaJava
package com.example.rubies.events;

public final class WelcomeEvents {
    public static void register() {
        Crafting.INSTANCE.getEvents().subscribe(PlayerJoinEvent.class, EventPriority.NORMAL, false, null, e -> {
            e.getPlayer().send("&aWelcome, " + e.getPlayer().getName() + "!");
            return Unit.INSTANCE;
        });
    }
}

完整示例:Frostlands

Frostlands 是一个按部分组织的 CraftScript 模组,用到了本指南介绍的大部分内容。它的 main.craft 只列出各个部分:

mods/frostlands/main.craftCraftScript
mod "frostlands" { name: "Frostlands", version: "1.0.0" }

// One file per part of the mod. Each part only declares what it is about.
import "content/materials"   // frost crystals: their ore, the recipes made from them
import "content/gear"        // armor, blade, wand, the frozen effect and the frost bolt
import "content/blocks"      // the gate, a lamp, an ice chest, a frost pad
import "world/frostlands"    // the frozen dimension
import "creatures/drake"     // tame, ride and fly a frost drake
import "creatures/wisp"      // wisps that shoot frost bolts at the undead
import "creatures/yak"       // yaks to breed
import "controls/keys"       // dash and roar keys
import "events/welcome"      // a first greeting
import "commands/kit"        // /frostkit for trying it all out
  • content/materials:一种水晶、它的矿石,以及用它制作的配方。
  • content/gear:护甲、一把冰冻之刃、一根带冷却、发射模组投射物的法杖,以及一个模组效果。
  • content/blocks:一座传送门、一盏可由点击或红石点亮的灯、一个冰箱子和一个跳跃垫。
  • world/frostlands:一个维度。
  • creatures/drake:一个带有自己模型和平滑动画的生物;可以驯服、骑乘和飞行,按键还会咆哮。
  • creatures/wisp:会射击亡灵的幽灵火焰。
  • creatures/yak:可以繁殖的牦牛。
  • controls/keys:冲刺和咆哮按键。
  • events/welcome:初次问候。
  • commands/kit:用来试玩所有内容的命令。

生物文件只声明那一个生物。这是 drake:

mods/frostlands/creatures/drake.craftCraftScript
creature "drake" {
    body: "minecraft:bee"
    model: "entity/drake"
    texture: "entity/drake"
    size: [1.4, 1.1]
    health: 30
    speed: 0.4
    tame_item: "frostlands:frost_crystal"
    rideable: true
    behaviour: [{ follow_owner: 1.4, distance: 10 }, { fly_around: 1 }, { look_at_players: 8 }, "look_around"]
    spawns: [{ biomes: ["minecraft:ice_spikes"], weight: 3, group: [1, 1] }]
}

这些属性的详细说明见生物。

何时拆分

信号 怎么做
一个文件超过大约 150 行 找出它混杂的主题,把它们分开。
你把同一个函数复制到了两个文件中 把它移到 lib/ 并导入。
同一个平衡数值出现在多处 在 settings.craft(或 Kotlin 中的 Config 类)中设为一项设置;如果不应被修改,就设为常量。
你在多个文件中写了同一个数据键 把它移到 lib/storage.craft,配上读取和保存两个函数。
一个事件做了三件不同的事 为同一事件写三个监听器,各自放在所属主题的文件中。