本篇教程的视频

本篇教程的源代码

GitHub地址:

本篇教程目标

  • 理解村民职业和工作站点的关系
  • 学会注册兴趣点,也就是工作站点
  • 学会注册职业,并把交易集接进去
  • 学会用数据生成写职业的交易、标签和语言文件

介绍

上一篇我们讲了怎么给原版村民加交易,那么这一篇我们自己来写一个自定义的村民职业

村民这个东西,说白了是三个部分拼起来的

第一个是兴趣点,也就是PoiType。它决定了「哪个方块算工作站点」,比如高炉是盔甲商的工作站点,堆肥桶是农民的工作站点

第二个是职业,也就是VillagerProfession。它里面写着「我认哪个兴趣点」「我叫什么名字」「我工作的时候放什么音效」,还有最关键的「我每个等级卖什么」

第三个是交易,也就是上一篇讲的那些json

流程就是:放一个工作站点方块,村民走过来认领,然后根据兴趣点找到对应的职业,最后按等级和交易集把交易条目摆出来

不过在26.1里面,会明显比1.21.1麻烦一些:职业要自己写注册方法,交易得走数据生成

具体来说,26.1 和 1.21.1 有这么几个区别

第一,VillagerProfessionBuilder被删了,注册职业得自己封装register方法

第二,PointOfInterestHelper改名成了PoiHelper

第三,职业的构造器多了一个参数,也就是「等级 → 交易集」的映射,交易不再靠API注入

第四,职业的名字不再是传一个String的id进去,而是传Component,翻译键要自己拼

第五,交易集和交易标签得用数据生成来写,这部分上一篇我们已经了解过了

查看源代码

VillagerProfession

我们还是先来看看村民职业这个类

1
2
3
4
5
6
7
8
9
10
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
) {
}

一共七个参数,我们挨个看

name是显示名,类型是Component

heldJobSite是「工作站点」,是个Predicate<Holder<PoiType>>

acquirableJobSite是「我可以认的工作站点」,同样是个Predicate。一般来说这两个写一样的就行

requestedItems是村民会去捡的物品,比如农民会捡小麦

secondaryPoi是辅助工作站点,比如农民的耕地

workSound是工作时放的音效,可以为空

tradeSetsByLevel就是「等级 → 交易集」的表,一级对应哪个交易集,二级对应哪个,全在这

注册

原版在VillagerProfession里有一堆常量,不过要注意,它们现在的类型是ResourceKey,不是VillagerProfession本身

1
public static final ResourceKey<VillagerProfession> ARMORER = createKey("armorer");

也就是说,职业现在是「注册表里的一个条目」

具体怎么把兴趣点、音效、交易集拼起来,是在VillagerProfession.bootstrap里做的

1
2
3
4
5
6
7
8
9
10
11
12
13
register(
registry,
ARMORER,
PoiTypes.ARMORER,
SoundEvents.VILLAGER_WORK_ARMORER,
Int2ObjectMap.ofEntries(
Int2ObjectMap.entry(1, TradeSets.ARMORER_LEVEL_1),
Int2ObjectMap.entry(2, TradeSets.ARMORER_LEVEL_2),
Int2ObjectMap.entry(3, TradeSets.ARMORER_LEVEL_3),
Int2ObjectMap.entry(4, TradeSets.ARMORER_LEVEL_4),
Int2ObjectMap.entry(5, TradeSets.ARMORER_LEVEL_5)
)
);

盔甲商用的是PoiTypes.ARMORER,音效是SoundEvents.VILLAGER_WORK_ARMORER,交易集是TradeSets.ARMORER_LEVEL_1到LEVEL_5五个

这里还有一个细节,VillagerProfession里有一个现成的谓词

1
public static final Predicate<Holder<PoiType>> ALL_ACQUIRABLE_JOBS = poiType -> poiType.is(PoiTypeTags.ACQUIRABLE_JOB_SITE);

它是「所有能当工作站点的兴趣点」,判断依据就是#minecraft:acquirable_job_site这个标签

一会儿我们会看到,这个标签很关键

PoiTypes

再看兴趣点,原版的常量都在PoiTypes这个类里

1
public static final ResourceKey<PoiType> ARMORER = createKey("armorer");

同样只有钥匙,方块是在PoiTypes.bootstrap里绑上去的

1
register(registry, ARMORER, getBlockStates(Blocks.BLAST_FURNACE), 1, 1);

比如盔甲商绑的是高炉,后面跟的两个数字是ticketCount(容纳数量)和searchDistance(搜索距离)

容纳数量一般写1,意思是一个工作站点只能对应一个村民。钟的容纳数量是 32,因为那是集会点;蜂巢是 0,因为那是给蜜蜂用的

这两个数字我们抄原版就行,职业站点一律1和1

村民是怎么认领工作站的

这一段是我觉得最值得看的地方,因为它解释了「为什么我把方块放那儿了,村民却不变职业」

我们可以找到AssignProfessionFromJobSite这个类,它是村民的 AI 行为

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
38
public class AssignProfessionFromJobSite {
public static BehaviorControl<Villager> create() {
return BehaviorBuilder.create(
i -> i.group(i.present(MemoryModuleType.POTENTIAL_JOB_SITE), i.registered(MemoryModuleType.JOB_SITE))
.apply(
i,
(potentialJobSite, jobSite) -> (level, body, timestamp) -> {
GlobalPos pos = i.get(potentialJobSite);
if (!pos.pos().closerToCenterThan(body.position(), 2.0) && !body.assignProfessionWhenSpawned()) {
return false;
} else {
potentialJobSite.erase();
jobSite.set(pos);
level.broadcastEntityEvent(body, (byte)14);
if (!body.getVillagerData().profession().is(VillagerProfession.NONE)) {
return true;
} else {
MinecraftServer server = level.getServer();
Optional.ofNullable(server.getLevel(pos.dimension()))
.flatMap(l -> l.getPoiManager().getType(pos.pos()))
.flatMap(
poiType -> BuiltInRegistries.VILLAGER_PROFESSION
.listElements()
.filter(profession -> ((VillagerProfession)profession.value()).heldJobSite().test(poiType))
.findFirst()
)
.ifPresent(profession -> {
body.setVillagerData(body.getVillagerData().withProfession(profession));
body.refreshBrain(level);
});
return true;
}
}
}
)
);
}
}

首先是村民会有一个候选工作站点,也就是POTENTIAL_JOB_SITE。这个行为先看它离得够不够近,够近就转正,记成JOB_SITE

然后拿这个位置的方块去问PoiManager,这个位置是什么兴趣点

接着遍历整个职业注册表,用每个职业的heldJobSite谓词去测这个兴趣点,第一个匹配上的就是村民的新职业

而村民在找工作站点的时候,只会考虑#minecraft:acquirable_job_site标签里的兴趣点

所以想让村民转职,两件事缺一不可

一是兴趣点得在#minecraft:acquirable_job_site标签里,不然村民根本不去看它

二是职业的heldJobSite谓词得认这个兴趣点,不然匹配不上

这两件事我们一会儿分别用代码和数据生成来补

动手写

准备工作

先把上一篇的东西用上

我们已经有ModCustomTrades(单个交易)、ModTradeSets(交易集)、ModVillagerTradeTags(交易标签常量)这几个类了

这一期新加的是工作站点和职业,都在ModVillagers里

顺便说一下方块,工作站点得是个真实存在的方块,我们这里用之前注册的ICE_ETHER_BLOCK

注册工作站点

先注册工作站点,这一步和 1.21.1 几乎一样,只是类名变了

移除混淆后的类现在叫PoiHelper

1
2
3
4
5
public static final PoiType ICE_ETHER_POI_TYPE = registerPointOfInterestType("ice_ether_poi", ModBlocks.ICE_ETHER_BLOCK);

private static PoiType registerPointOfInterestType(String id, Block block) {
return PoiHelper.register(Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, id), 1, 1, block);
}

参数是id、容纳数量、搜索距离和方块

注册职业

然后是职业,因为VillagerProfessionBuilder没了,我们只能自己封装一个register

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static VillagerProfession register(
final ResourceKey<VillagerProfession> name,
final Predicate<Holder<PoiType>> heldJobSite,
final Predicate<Holder<PoiType>> acquirableJobSite,
final ImmutableSet<Item> requestedItems,
final ImmutableSet<Block> secondaryPoi,
@Nullable final SoundEvent workSound,
final Int2ObjectMap<ResourceKey<TradeSet>> trades
) {
return Registry.register(
BuiltInRegistries.VILLAGER_PROFESSION,
name,
new VillagerProfession(
Component.translatable("entity." + name.identifier().getNamespace() + ".villager." + name.identifier().getPath()),
heldJobSite, acquirableJobSite,
requestedItems, secondaryPoi,
workSound, trades
)
);
}

这里有几个点要注意

第一个参数是ResourceKey,不是Identifier

翻译键是用命名空间和路径拼出来的,格式是entity.<命名空间>.villager.<职业id>

所以我们这个职业的翻译键就是entity.tutorial.villager.ice_ether_master,一会儿语言文件里要加

这个键别照抄 1.21.1 教程里的entity.minecraft.villager.xxx

那时候是游戏内部固定拿minecraft当命名空间拼的,现在这个键是我们自己拼,命名空间得换成自己的

然后是职业本身和它的钥匙

1
2
3
4
5
6
7
8
9
public static final ResourceKey<PoiType> ICE_ETHER_POI = of("ice_ether_poi");
public static final ResourceKey<VillagerProfession> ICE_ETHER_MASTER = createKey("ice_ether_master");

private static ResourceKey<VillagerProfession> createKey(final String name) {
return ResourceKey.create(Registries.VILLAGER_PROFESSION, Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, name));
}
private static ResourceKey<PoiType> of(String id) {
return ResourceKey.create(Registries.POINT_OF_INTEREST_TYPE, Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, id));
}

这两个of和createKey方法同样可以去原版里面copy

名字这里我用的是ice_ether_poi和ice_ether_master,注意这两个名字一定要和后面标签里写的一致,不然游戏找不到东西

最后把职业注册上,把「等级 → 交易集」的表传进去

1
2
3
4
5
6
7
8
9
public static void registerVillagers() {
register(ICE_ETHER_MASTER,
p -> p.is(ICE_ETHER_POI), p -> p.is(ICE_ETHER_POI),
ImmutableSet.of(), ImmutableSet.of(), SoundEvents.VILLAGER_WORK_ARMORER,
Int2ObjectMap.ofEntries(
Int2ObjectMap.entry(1, ModTradeSets.ICE_ETHER_MASTER_LEVEL_1),
Int2ObjectMap.entry(2, ModTradeSets.ICE_ETHER_MASTER_LEVEL_2)
));
}

p -> p.is(ICE_ETHER_POI)的意思是「工作站点是否是这个兴趣点」,两个谓词我们都写成同一个

Int2ObjectMap.ofEntries就是那张等级表,一级指向一级的交易集,二级指向二级的

这里的声音事件借用了盔甲商的音效SoundEvents.VILLAGER_WORK_ARMORER,你想换成别的也行,原版的工作音效随便挑一个

注册到主类

1
ModVillagers.registerVillagers();

老规矩,在主类的onInitialize里调一下

这里的注册其实都写在类的静态初始化里,也就是ModVillagers第一次被用到的时候。所以只要在主类里调一次,职业和兴趣点就都注册好了

顺便提一句,ModVillagers初始化时会用到ModBlocks.ICE_ETHER_BLOCK,这会顺带触发ModBlocks的静态初始化,方块会先注册好,不用担心顺序

写职业的交易

上一篇我们把交易单独拆出来讲,这里只补职业自己的三条

在ModCustomTrades里加三个ResourceKey

1
2
3
public static final ResourceKey<VillagerTrade> ICE_ETHER_MASTER_1_EMERALD_PICKAXE_AXE = resourceKey("ice_ether_master/1/emerald_pickaxe_axe");
public static final ResourceKey<VillagerTrade> ICE_ETHER_MASTER_1_ICE_ETHER_EMERALD = resourceKey("ice_ether_master/1/ice_ether_emerald");
public static final ResourceKey<VillagerTrade> ICE_ETHER_MASTER_2_EMERALD_RAW_ICE_ETHER = resourceKey("ice_ether_master/2/emerald_raw_ice_ether");

然后在bootstrap里注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
register(context, ICE_ETHER_MASTER_1_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)
));
register(context, ICE_ETHER_MASTER_1_ICE_ETHER_EMERALD, new VillagerTrade(
new TradeCost(ModItems.ICE_ETHER, 2),
new ItemStackTemplate(Items.EMERALD, 1),
16, 5, 0.5f, Optional.empty(), List.of()
));
register(context, ICE_ETHER_MASTER_2_EMERALD_RAW_ICE_ETHER, new VillagerTrade(
new TradeCost(Items.EMERALD, 5),
new ItemStackTemplate(ModItems.RAW_ICE_ETHER, 20),
16, 5, 0.5f, Optional.empty(), List.of()
));

一级两条:绿宝石买附魔镐斧,ICE_ETHER换绿宝石

二级一条:绿宝石买RAW_ICE_ETHER

enchantedItem是上一篇写的辅助方法,直接复用,不用重写

创建交易集

交易集上一篇讲过概念,这次我们自己建一个,因为原版没有我们这个职业的池子

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
public class ModTradeSets {
public static final ResourceKey<TradeSet> ICE_ETHER_MASTER_LEVEL_1 = resourceKey("ice_ether_master_level_1");
public static final ResourceKey<TradeSet> ICE_ETHER_MASTER_LEVEL_2 = resourceKey("ice_ether_master_level_2");

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

public static void bootstrap(BootstrapContext<TradeSet> context) {
register(context, ICE_ETHER_MASTER_LEVEL_1, ModVillagerTradeTags.ICE_ETHER_MASTER_LEVEL_1);
register(context, ICE_ETHER_MASTER_LEVEL_2, ModVillagerTradeTags.ICE_ETHER_MASTER_LEVEL_2);
}

public static Holder.Reference<TradeSet> register(
final BootstrapContext<TradeSet> context, final ResourceKey<TradeSet> resourceKey,
final TagKey<VillagerTrade> tradeTag
) {
return register(context, resourceKey, tradeTag, ConstantValue.exactly(2.0F));
}

public static Holder.Reference<TradeSet> register(
final BootstrapContext<TradeSet> context, final ResourceKey<TradeSet> resourceKey,
final TagKey<VillagerTrade> tradeTag, final NumberProvider numberProvider
) {
return context.register(
resourceKey,
new TradeSet(
context.lookup(Registries.VILLAGER_TRADE).getOrThrow(tradeTag),
numberProvider, false, Optional.of(resourceKey.identifier().withPrefix("trade_set/"))
)
);
}
}

context.lookup(Registries.VILLAGER_TRADE).getOrThrow(tradeTag)是把这个标签里的交易全拿出来,做成一个HolderSet

第三个参数false是不允许重复,第四个是随机序列,我们照抄原版的trade_set/前缀

amount默认写 2,因为我们一级正好两条交易,二级只有一条

这里提醒一下,二级只有一条、amount是 2,那也只能抽到一条,不会把同一条给你两次(因为不允许重复)

创建交易标签

交易集引用的是标签,所以我们先来定义标签

1
2
3
4
5
6
7
8
public class ModVillagerTradeTags {
public static final TagKey<VillagerTrade> ICE_ETHER_MASTER_LEVEL_1 = create("ice_ether_master/level_1");
public static final TagKey<VillagerTrade> ICE_ETHER_MASTER_LEVEL_2 = create("ice_ether_master/level_2");

private static TagKey<VillagerTrade> create(final String name) {
return TagKey.create(Registries.VILLAGER_TRADE, Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, name));
}
}

当然内容是数据生成写的

名字用的是ice_ether_master/level_1这种格式,和原版的farmer/level_1对得上,纯粹是为了好找

数据生成

交易标签生成器

上一篇我们已经建了ModVillagerTradesTagsProvider,这次往里加两段

1
2
3
4
5
6
7
8
@Override
protected void addTags(HolderLookup.Provider registries) {
tag(ModVillagerTradeTags.ICE_ETHER_MASTER_LEVEL_1)
.add(ModCustomTrades.ICE_ETHER_MASTER_1_EMERALD_PICKAXE_AXE)
.add(ModCustomTrades.ICE_ETHER_MASTER_1_ICE_ETHER_EMERALD);
tag(ModVillagerTradeTags.ICE_ETHER_MASTER_LEVEL_2)
.add(ModCustomTrades.ICE_ETHER_MASTER_2_EMERALD_RAW_ICE_ETHER);
}

工作站点标签

这个就是前面说的那个坑,忘了村民就不转职

我们新建一个ModPoiTagsProvider,继承KeyTagProvider,注册表填POINT_OF_INTEREST_TYPE

1
2
3
4
5
6
7
8
9
10
11
12
public class ModPoiTagsProvider extends KeyTagProvider<PoiType> {
public ModPoiTagsProvider(PackOutput output, CompletableFuture<HolderLookup.Provider> lookupProvider) {
super(output, Registries.POINT_OF_INTEREST_TYPE, lookupProvider);
}

@Override
protected void addTags(HolderLookup.Provider registries) {
tag(PoiTypeTags.ACQUIRABLE_JOB_SITE)
.addOptional(ResourceKey.create(Registries.POINT_OF_INTEREST_TYPE,
Identifier.fromNamespaceAndPath(TutorialMod.MOD_ID, "ice_ether_poi")));
}
}

PoiTypeTags.ACQUIRABLE_JOB_SITE就是前面源码里那个#minecraft:acquirable_job_site

注意这里用的是addOptional,因为我们要往原版的标签里塞东西,而原版那个标签里已经有一堆兴趣点了

addOptional生成出来是带required: false的条目,就算某个兴趣点没注册也不会导致整个标签加载失败

还有个细节,这里填的字符串是ice_ether_poi,必须和前面ResourceKey里的名字一模一样

动态注册表生成器

职业一接交易,交易集就成了必需品,所以动态注册表这里也要多写一行

1
2
3
4
@Override
protected void configure(HolderLookup.Provider registries, Entries entries) {
entries.addAll(registries.lookupOrThrow(Registries.TRADE_SET));
}

我们把交易集加上

注册到 DataGenerator

1
pack.addProvider(ModPoiTagsProvider::new);

加上一个ModPoiTagsProvider

然后是buildRegistry,这里也得多挂一个交易集

1
2
3
4
@Override
public void buildRegistry(RegistrySetBuilder registryBuilder) {
registryBuilder.add(Registries.TRADE_SET, ModTradeSets::bootstrap);
}

语言文件

职业的名字是Component,所以我们得给它翻译

1
translationBuilder.add("entity.tutorial.villager.ice_ether_master", "Ice Ether Master");
1
translationBuilder.add("entity.tutorial.villager.ice_ether_master", "冰霜大师");

英文和中文都要加,中文那个放到ModZhCnLangProvider里

这个键必须和代码里拼出来的完全一致,差一个字母,游戏里就会把这一长串键名原样显示出来

材质文件

村民的皮肤分两层,一层是村民本身,一层是职业

职业那一层是单独一张贴图,放在这个位置

1
assets/tutorial/textures/entity/villager/profession/ice_ether_master.png

注意几点

第一,命名空间跟着职业的id走。我们的职业是tutorial:ice_ether_master,所以它在assets/tutorial/下面

第二,文件名就是职业的路径,也就是ice_ether_master.png,不用加命名空间前缀

第三,尺寸和原版一样是 64×64,画的只是职业那部分,比如围裙、帽子、工具,不是一整个村民

原版有些职业的贴图旁边还有.mcmeta文件,那是用来定义帽子的,我们这里不需要,不给也能用

如果不给这张贴图,村民照常会转职,只是职业那一层会显示成紫黑方块

测试

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

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

  • 村民不转职:先看acquirable_job_site标签有没有生成,再看PoiHelper注册的名字和标签里写的是不是同一个
  • 转职了但没有交易:交易集没注册,或者职业的Int2ObjectMap里填的钥匙不对
  • 交易数量不对:二级只有一条交易,amount是 2 也只能抽到一条,这是正常的
  • 职业名显示成entity.tutorial.villager.xxx:语言文件没加,或者键名和职业id不一致
  • 职业贴图是紫黑方块:贴图路径不对,检查是不是assets/<命名空间>/textures/entity/villager/profession/<职业id>.png
  • 村民站在那里不动也不转职:可能附近已经有别的工作站点被它认领了,先把周围的堆肥桶、高炉之类的挪开
  • 数据生成跑完没有trade_set目录:buildRegistry里漏了Registries.TRADE_SET
  • 一个站点只能一个村民认领:想多个村民共用一个站点,把PoiHelper里的容纳数量改大