本篇教程的视频

本篇教程的源代码

GitHub地址:

本篇教程目标

  • 理解 26.1 的交易为什么变成了数据驱动
  • 理解 VillagerTrade、TradeCost、TradeSet 这三个东西各自管什么
  • 学会给原版村民追加交易
  • 学会用数据生成把交易文件生成出来

介绍

在之前的教程里,我们给村民加过自定义交易

我们是用Fabric的API提供的TradeOfferHelper.registerVillagerOffers,直接往原版的交易列表里塞东西

但是在 26.1 里,这玩意没了,因为从 26.1 开始,村民交易变成了数据驱动的

也就是说,村民交易不再写在代码里,而是写成了json文件,放在数据包的注册表里

原版自己也是这么干的,我们可以找到原版的数据文件,即data/minecraft/villager_trade,现在里面有一堆的json

那么问题来了,一个职业某一等级的交易,怎么知道该读哪些json?

答案是三层结构,我们先把这三层理清楚,后面的代码就都好说了

第一层是单个交易,即VillagerTrade,对应一个json文件,也就是villager_trade下的那堆文件,里面写清楚「要什么」和「给什么」

第二层是交易集,即TradeSet,对应一个json文件,也就是trade_set下的那堆文件。它不写具体的交易,而是一个交易相关的tag,然后从这个tag里随机抽几条交易

第三层是职业,职业里存的是一个Map,等级对应交易集的id

所以我们写自定义交易的时候,实际上是在写这三层的东西

查看源代码

VillagerTrades

与之前的Blocks、Items类同理,这个类就是原版村民交易的注册类,里面有一堆ResourceKey<VillagerTrade>字段

1
public static final ResourceKey<VillagerTrade> FARMER_1_WHEAT_EMERALD = resourceKey("farmer/1/wheat_emerald");

这里面按照村民职业+等级+交易描述的方式命名了每一条交易的id,比如农夫一级收小麦的交易就是farmer/1/wheat_emerald,对应我们前面看的json

再往下看,我们找到bootstrap方法,这个方法就是用来注册交易的

1
2
3
4
5
6
register(
context,
FARMER_1_WHEAT_EMERALD,
new VillagerTrade(new TradeCost(Items.WHEAT, 20),
new ItemStackTemplate(Items.EMERALD), 16, 2, 0.05F, Optional.empty(), List.of())
);

那么上面的是FARMER_1_WHEAT_EMERALD的注册内容,然后我们来细看VillagerTrade

VillagerTrade

这个类就是单个交易的类

1
2
3
4
5
6
7
8
9
10
11
public class VillagerTrade implements Validatable {
private final TradeCost wants;
private final Optional<TradeCost> additionalWants;
private final ItemStackTemplate gives;
private final Optional<LootItemCondition> merchantPredicate;
private final List<LootItemFunction> givenItemModifiers;
private final NumberProvider maxUses;
private final NumberProvider reputationDiscount;
private final NumberProvider xp;
private final Optional<HolderSet<Enchantment>> doubleTradePriceEnchantments;
}

我们直接来看这个类里面的字段

wants是玩家要给的东西,也就是「村民想要什么」

additionalWants是额外的东西,可以为空。比如说原版的书呆子(图书管理员)卖附魔书的时候,有「绿宝石 + 书」这样的交易条目,这里的书就是additionalWants

gives是村民给玩家的东西,类型是ItemStackTemplate,不是ItemStack。这个类是 26.1 新加的,本质上就是「物品 + 数量 + 组件」,用来在数据文件里描述一个物品栈

merchantPredicate是一个战利品条件,作用在村民身上。写上去之后,这条交易只会出现在满足条件的村民那里,比如只出现在某个生物群系的村民身上

givenItemModifiers是一串战利品函数,作用在gives上,在给予一些附魔装备的时候会用到

剩下的maxUses、reputationDiscount、xp都是数字,只是类型是NumberProvider,所以可以写固定值,也可以写uniform这种随机区间

doubleTradePriceEnchantments是「哪些附魔会让价格翻倍」,原版书呆子用它来让高价值附魔书卖得更贵

TradeCost

接下来我们再看看TradeCost,它是wants和additionalWants的类型

1
2
3
4
5
6
public record TradeCost(
Holder<Item> item,
NumberProvider count,
DataComponentExactPredicate components) implements Validatable {
}

这几个参数对应的是:

  • item:要交易的物品
  • count:要交易的数量
  • components:要交易的组件

构造函数有三个重载

1
2
3
4
5
6
7
8
9
10
11
12
public TradeCost(final ItemLike item, final int count) {
this(item.asItem().builtInRegistryHolder(), ConstantValue.exactly(count), DataComponentExactPredicate.EMPTY);
}

public TradeCost(final ItemLike item, final NumberProvider count) {
this(item.asItem().builtInRegistryHolder(), count, DataComponentExactPredicate.EMPTY);
}

public ItemCost toItemCost(final LootContext lootContext, final int additionalCost) {
int count = Mth.clamp(this.count().getInt(lootContext) + additionalCost, 0, this.item().value().getDefaultMaxStackSize());
return new ItemCost(this.item(), count, this.components());
}

一般我们用第一个或者第二个就够了

第三个多了一个组件断言,比如你只想收附魔过的物品,或者只收某个特定药水,就得用这个

TradeSets

TradeSets同样是注册类,里面有一堆ResourceKey<TradeSet>字段

1
public static final ResourceKey<TradeSet> FARMER_LEVEL_1 = resourceKey("farmer/level_1");

同样在bootstrap方法里注册交易集

1
register(context, FARMER_LEVEL_1, VillagerTradeTags.FARMER_LEVEL_1);

当然,我们可以发现这个ResourceKey是和一个TagKey绑定的,也就是VillagerTradeTags.FARMER_LEVEL_1,这个标签里存的是一堆交易的id

TradeSet

那么我们再来看看TradeSet这个类,它就是交易集的类

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TradeSet {
private final HolderSet<VillagerTrade> trades;
private final NumberProvider amount;
private final boolean allowDuplicates;
private final Optional<Identifier> randomSequence;

public TradeSet(final HolderSet<VillagerTrade> trades, final NumberProvider amount, final boolean allowDuplicates, final Optional<Identifier> randomSequence) {
this.trades = trades;
this.amount = amount;
this.allowDuplicates = allowDuplicates;
this.randomSequence = randomSequence;
}
}

我们来看看这些参数

trades是交易池,一般是传一个标签,让它引用标签里的所有交易

amount是「抽几条」,原版农夫一级是 2 条,这个我们可以在各种json里面看到

allowDuplicates是「能不能抽到重复的」

randomSequence是一个随机序列的id,是一个随机源。原版的写法是把自己id前面加上trade_set/,我们照抄就行

把交易塞给村民

然后我们来看真正把交易塞给村民的地方,在AbstractVillager的addOffersFromTradeSet方法里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected void addOffersFromTradeSet(final ServerLevel level, final MerchantOffers offers, final ResourceKey<TradeSet> resourceKey) {
Optional<TradeSet> tradeSetOpt = this.registryAccess().lookupOrThrow(Registries.TRADE_SET).getOptional(resourceKey);
if (tradeSetOpt.isEmpty()) {
LOGGER.debug("Missing expected trade set {}", resourceKey);
} else {
TradeSet tradeSet = (TradeSet)tradeSetOpt.get();
LootContext lootContext = new net.minecraft.world.level.storage.loot.LootContext.Builder(
new net.minecraft.world.level.storage.loot.LootParams.Builder(level)
.withParameter(LootContextParams.ORIGIN, this.position())
.withParameter(LootContextParams.THIS_ENTITY, this)
.withParameter(LootContextParams.ADDITIONAL_COST_COMPONENT_ALLOWED, Unit.INSTANCE)
.create(LootContextParamSets.VILLAGER_TRADE)
)
.create(tradeSet.randomSequence());
int numberOfOffers = tradeSet.calculateNumberOfTrades(lootContext);
if (tradeSet.allowDuplicates()) {
addOffersFromItemListings(lootContext, offers, tradeSet.getTrades(), numberOfOffers);
} else {
addOffersFromItemListingsWithoutDuplicates(lootContext, offers, tradeSet.getTrades(), numberOfOffers);
}
}
}

简单来说,这个方法先是取交易集、建LootContext、算抽几条、按规则抽

交易集是从TRADE_SET这个数据包注册表获取

LootContext.Builder里塞的那三个Parameter:

  • ORIGIN是村民的坐标
  • THIS_ENTITY是村民自己
  • ADDITIONAL_COST_COMPONENT_ALLOWED是允许加价,也就是前面附魔书那条里「价格跟着附魔涨」的机制

外层的create(tradeSet.randomSequence())接的是交易集里的randomSequence,用它给这次抽签指定一条独立的随机序列

calculateNumberOfTrades就是获取交易条目的数量,对应我们之前在json里面看到的amount字段

再往下就是按allowDuplicates分流:允许重复的版本抽完放回,同一张交易可能抽到两次;不允许的版本先拷一份列表,抽一条删一条

两个方法都是拿到交易后调getOffer(lootContext),返回非null才加进offers,同时offersFound加一

getOffer返回null只有两种情况:merchantPredicate没过,或者givenItemModifiers把物品清空了(附魔失败被discard)

因为offersFound统计的是「成功凑齐的条数」,所以某条交易生成失败时这次不算数,它会继续抽,直到凑够numberOfOffers条

再回头看整个流程,其实就是一句话

村民们每次升级时,只会从交易集里抽amount条,而不是把标签里的交易全给你

这就是为什么你给农夫加了 5 条交易,结果村民身上只出现了 2 条

VillagerProfession

职业这边的变化也很直接

1
2
3
4
5
6
7
8
9
10
11
public record VillagerProfession(
Component name,
Predicate<Holder<PoiType>> heldJobSite,
Predicate<Holder<PoiType>> acquirableJobSite,
ImmutableSet<Item> requestedItems,
ImmutableSet<Block> secondaryPoi,
@Nullable SoundEvent workSound,
Int2ObjectMap<ResourceKey<TradeSet>> tradeSetsByLevel
) {
...
}

以前最后一个参数是没有的,交易靠Fabric API在外部注入

现在最后一个参数是Int2ObjectMap<ResourceKey<TradeSet>>,也就是「等级 → 交易集」的映射

另外,第一个参数也变成了Component,而 1.21.1 里是一个String的id

也就是说,现在职业的显示名要我们自己给翻译键,格式是entity.<命名空间>.villager.<id>,原版是entity.minecraft.villager.farmer

当然这个是后面我们实现自定义村民要看的了

原版数据长什么样

在动手写之前,我们先看看原版的json,照着抄是最快的

单个交易

农夫一级收小麦,这个是最简单的「玩家卖给村民」

1
2
3
4
5
6
7
8
9
10
11
12
{
"gives": {
"id": "minecraft:emerald"
},
"max_uses": 16.0,
"reputation_discount": 0.05,
"wants": {
"count": 20.0,
"id": "minecraft:wheat"
},
"xp": 2.0
}

wants里的count是 20,意思是玩家要给 20 个小麦

gives里没有写count,那就默认是 1,也就是给 1 个绿宝石

再看「玩家从村民这里买」的

1
2
3
4
5
6
7
8
9
10
11
{
"gives": {
"count": 6,
"id": "minecraft:bread"
},
"max_uses": 16.0,
"reputation_discount": 0.05,
"wants": {
"id": "minecraft:emerald"
}
}

一个是面包,这里gives里写了count,wants里没写count,所以是 1 个绿宝石换 6 个面包

这里要特别注意默认值

max_uses不写的话默认是4,不是 16

reputation_discount不写默认是0

xp不写默认是1

gives和wants里的count不写默认是1

这几个参数也可以在VillagerTrade的CODEC里面看到默认值

交易集与标签

交易集的文件长这样,这是农夫一级的

1
2
3
4
5
{
"amount": 2.0,
"random_sequence": "minecraft:trade_set/farmer/level_1",
"trades": "#minecraft:farmer/level_1"
}

trades里的#表示这是一个标签,指向data/minecraft/tags/villager_trade/farmer/level_1.json

那个标签里就是一堆交易的id

1
2
3
4
5
6
7
8
9
{
"values": [
"minecraft:farmer/1/wheat_emerald",
"minecraft:farmer/1/potato_emerald",
"minecraft:farmer/1/carrot_emerald",
"minecraft:farmer/1/beetroot_emerald",
"minecraft:farmer/1/emerald_bread"
]
}

所以原版农夫一级一共有 5 条交易,每次升级随机抽 2 条给这个村民

这也顺带解释了一个现象:同样是农夫,每一个的交易都不一样,因为抽签的结果不一样

附魔书那条交易

这个是原版里最复杂的一条,也是最能体现「数据驱动」的一条,在librarian/1/emerald_and_book_enchanted_book.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
"additional_wants": {
"id": "minecraft:book"
},
"double_trade_price_enchantments": "#minecraft:double_trade_price",
"given_item_modifiers": [
{
"function": "minecraft:enchant_randomly",
"include_additional_cost_component": true,
"only_compatible": false,
"options": "#minecraft:tradeable"
},
{
"function": "minecraft:filtered",
"item_filter": {
"items": "minecraft:enchanted_book",
"predicates": {
"minecraft:stored_enchantments": [
{}
]
}
},
"on_fail": {
"function": "minecraft:discard"
}
}
],
"gives": {
"id": "minecraft:enchanted_book"
},
"max_uses": 12.0,
"reputation_discount": 0.2,
"wants": {
"count": 0.0,
"id": "minecraft:emerald"
}
}

wants里绿宝石是 0 个,additional_wants里要一本书。所以这条交易的本质是「一本书换一本附魔书」,价格是随机附魔算出来的

given_item_modifiers里有两个函数

第一个enchant_randomly,从#minecraft:tradeable这个附魔标签里随机挑一个附魔,等级随机。include_additional_cost_component表示把附魔的「额外价值」写进物品的组件里,这个组件最后会被算进价格

第二个filtered,是个过滤器。它检查物品是不是「带着附魔的附魔书」,如果不是,就执行on_fail里的discard,把物品丢掉

丢掉之后物品变成空栈,getOffer就返回null,这条交易就不会出现在村民身上

动手写

创建 ModCustomTrades

我们新建一个villager包,先写单个交易

这个类的结构是「一堆ResourceKey常量 + 一个注册辅助方法 + 一个bootstrap方法」

resourceKey方法可以从原版的类里面直接copy过来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ModCustomTrades {
public static final ResourceKey<VillagerTrade> FARMER_1_EMERALD_STRAWBERRY = resourceKey("farmer/1/emerald_strawberry");
public static final ResourceKey<VillagerTrade> FARMER_2_CORN_EMERALD = resourceKey("farmer/2/corn_emerald");
public static final ResourceKey<VillagerTrade> TOOLSMITH_2_EMERALD_PICKAXE_AXE = resourceKey("toolsmith/2/emerald_pickaxe_axe");

public static ResourceKey<VillagerTrade> resourceKey(final String path) {
return ResourceKey.create(Registries.VILLAGER_TRADE, Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, path));
}

public static Holder.Reference<VillagerTrade> register(
final BootstrapContext<VillagerTrade> context, final ResourceKey<VillagerTrade> resourceKey,
final VillagerTrade villagerTrade
) {
return context.register(resourceKey, villagerTrade);
}

public static void bootstrap(BootstrapContext<VillagerTrade> context) {

}
}

这里的path我用了职业/等级/描述这种写法,比如farmer/1/emerald_strawberry

这不是强制的,你随便起名也能跑。但原版就是这么命名的,照着写的话,生成出来的文件路径和你脑子里的结构是对得上的,找起来方便

接着我们往bootstrap里填交易

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void bootstrap(BootstrapContext<VillagerTrade> context) {
HolderGetter<Item> items = context.lookup(Registries.ITEM);
Optional<HolderSet<Enchantment>> enchantmentsForTradedEquipment = context.lookup(Registries.ENCHANTMENT)
.get(EnchantmentTags.ON_TRADED_EQUIPMENT)
.map(named -> named);

register(context, FARMER_2_CORN_EMERALD, new VillagerTrade(
new TradeCost(ModItems.CORN, 10),
new ItemStackTemplate(Items.EMERALD, 5),
16, 5, 0.5f, Optional.empty(), List.of()
));

register(context, FARMER_1_EMERALD_STRAWBERRY, new VillagerTrade(
new TradeCost(Items.EMERALD, 2),
new ItemStackTemplate(ModItems.STRAWBERRY, 15),
16, 5, 0.5f, Optional.empty(), List.of()
));
}

enchantmentsForTradedEquipment是我们从EnchantmentTags.ON_TRADED_EQUIPMENT这个标签里取的附魔池,后面写附魔装备交易的时候会用到

VillagerTrade的参数我们逐个对一下

  • wants,也就是村民要什么
  • gives,村民给什么
  • 16是maxUses,最多交易 16 次
  • 5是xp,每次交易给村民 5 点经验
  • 0.5f是reputationDiscount,声望打折的系数
  • merchantPredicate和givenItemModifiers,我们这里用不上,就传Optional.empty()和List.of()

附魔装备的写法

接下来这条是铁匠二级,卖一把附魔的镐斧

因为1.21之后附魔也变成了数据驱动,你没法在代码里直接new一个附魔出来,所以我们得靠战利品函数

这里我们按照原版的方法先写一个辅助方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static List<LootItemFunction> enchantedItem(final HolderGetter<Item> items,
final Optional<HolderSet<Enchantment>> options,
final Item expectedItem) {
return List.of(
new EnchantWithLevelsFunction.Builder(UniformGenerator.between(5.0F, 19.0F))
.withOptions(options)
.includeAdditionalCostComponent()
.build(),
FilteredFunction.filtered(
new ItemPredicate.Builder()
.of(items, expectedItem)
.withComponents(
DataComponentMatchers.Builder.components()
.partial(DataComponentPredicates.ENCHANTMENTS,
EnchantmentsPredicate.enchantments(List.of(
new EnchantmentPredicate(Optional.empty(), MinMaxBounds.Ints.ANY))))
.build()
)
.build()
)
.onFail(Optional.of(DiscardItem.discardItem().build()))
.build()
);
}

第一个函数是enchant_with_levels,等级在 5 到 19 之间随机,options是我们后面要指定的附魔池

第二个函数是过滤器,检查物品是不是「带附魔的镐斧」,不是就丢掉

然后这条交易就这么写

1
2
3
4
5
6
register(context, TOOLSMITH_2_EMERALD_PICKAXE_AXE, new VillagerTrade(
new TradeCost(Items.EMERALD, 10),
new ItemStackTemplate(ModItems.PICKAXE_AXE_ITEM, 1),
16, 5, 0.5f, Optional.empty(),
enchantedItem(items, enchantmentsForTradedEquipment, ModItems.PICKAXE_AXE_ITEM)
));

PICKAXE_AXE_ITEM是之前教程里那个斧+镐的结合物品,你随意指定,当然注意相应的物品是否真的可以上附魔

其他的参数与之前的交易类似,这里就不赘述了

到这里,单个交易就写完了

有同学可能要问:那交易集TradeSet和职业呢,不用写吗?

这次不用,因为我们只是往原版职业里加交易。原版农夫、铁匠的交易集和「等级 → 交易集」的映射都是现成的,我们只要把交易塞进对应的标签就行

至于自定义职业——自己的工作方块、自己的交易集、自己的等级表——那是下一期「自定义村民」的内容,这一期先把交易本身弄明白

所以下面直接进数据生成

数据生成

ModVillagerTradesTagsProvider

我们新建一个ModVillagerTradesTagsProvider

注意它继承的不是FabricTagProvider,而是原版的KeyTagProvider,因为FabricTagProvider只提供了方块和物品的现成包装

1
2
3
4
5
6
7
8
9
10
public class ModVillagerTradesTagsProvider extends KeyTagProvider<VillagerTrade> {
public ModVillagerTradesTagsProvider(PackOutput output, CompletableFuture<HolderLookup.Provider> lookupProvider) {
super(output, Registries.VILLAGER_TRADE, lookupProvider);
}

@Override
protected void addTags(HolderLookup.Provider registries) {

}
}

super的第二个参数是注册表,这里直接写Registries.VILLAGER_TRADE

然后我们把标签内容写进去

1
2
3
4
5
6
7
8
9
@Override
protected void addTags(HolderLookup.Provider registries) {
tag(VillagerTradeTags.FARMER_LEVEL_1)
.add(ModCustomTrades.FARMER_1_EMERALD_STRAWBERRY);
tag(VillagerTradeTags.FARMER_LEVEL_2)
.add(ModCustomTrades.FARMER_2_CORN_EMERALD);
tag(VillagerTradeTags.TOOLSMITH_LEVEL_2)
.add(ModCustomTrades.TOOLSMITH_2_EMERALD_PICKAXE_AXE);
}

VillagerTradeTags是原版定义好的标签类,直接调用即可

FabricDynamicRegistryProvider

交易是数据包注册表,属于「动态注册表」,所以要用FabricDynamicRegistryProvider来生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ModDynamicRegistryProvider extends FabricDynamicRegistryProvider {
public ModDynamicRegistryProvider(FabricPackOutput output, CompletableFuture<HolderLookup.Provider> registriesFuture) {
super(output, registriesFuture);
}

@Override
protected void configure(HolderLookup.Provider registries, Entries entries) {
entries.addAll(registries.lookupOrThrow(Registries.VILLAGER_TRADE));
}

@Override
public String getName() {
return "Mod Dynamic Registry Gen";
}
}

configure里就一行,把这个注册表里的条目全写出去

entries.addAll会自动遍历,写到data/<命名空间>/villager_trade/下面

注意getName是要重写的,这个是生成时的任务名,不写会报错

注册到 DataGenerator

两个Provider都写好了,得注册,不然不会被调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TutorialModDataGenerator implements DataGeneratorEntrypoint {
@Override
public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) {
FabricDataGenerator.Pack pack = fabricDataGenerator.createPack();
...
pack.addProvider(ModVillagerTradesTagsProvider::new);
pack.addProvider(ModDynamicRegistryProvider::new);
}

@Override
public void buildRegistry(RegistrySetBuilder registryBuilder) {
registryBuilder.add(Registries.VILLAGER_TRADE, ModCustomTrades::bootstrap);
}
}

buildRegistry这个方法一定要重写

这个方法是DataGeneratorEntrypoint里的默认方法,平时不写也没事,但只要你用了动态注册表,就必须在这里把你的bootstrap挂上去

registryBuilder.add的第一个参数是注册表,第二个参数是方法引用,指向我们写好的bootstrap

如果你漏了这一步,数据生成跑起来不会报错,但data/下面什么都没有,因为注册表里压根没有条目

运行数据生成

还是老样子,右上角的下拉框选Data Generation,或者跑Gradle里的runDatagen

测试

之后我们就可以进入游戏进行测试了

当然,因为交易本身也是从交易集里面抽取的,所以可能得多刷几次才能看到我们的交易

常见问题(大肥鱼总结版)

  • 村民身上没有我们的交易:标签文件没生成,或者tag(...)里的等级和职业对不上,比如FARMER_LEVEL_1就得是农夫一级
  • 进游戏报标签错误:标签里写的id和生成出来的交易路径不一致,tutorial:farmer/1/xxx这种要一一对上
  • 数据生成跑完data/是空的:buildRegistry没写,或者bootstrap没注册到registryBuilder里
  • 交易只能点 4 次就锁了:max_uses没写,默认是 4
  • 加了交易但村民身上看不到:池子里交易太多、amount太小,多刷几个村民试试
  • 铁匠二级看不到我们的交易:得先把铁匠升到二级,一级的池子里没有这条
  • 附魔装备的交易不出现:检查filtered和discard这一对有没有写,少了的话附魔失败也会照常出售