本篇教程的视频
本篇教程的源代码
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
注意它继承的不是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这一对有没有写,少了的话附魔失败也会照常出售