TeaCon 2023 言传身教奖获奖文章·一·真正的和平模式(二)

原文载于知乎专栏《【真正的和平模式】二、任务系统的实现》,作者为 Viola-Siemens,
原地址:https://zhuanlan.zhihu.com/p/644603030


【真正的和平模式】二、任务系统的实现

我像迷途小鹿 得不到救赎

才会在此后的路 忽视了所有景物

我眼里的天空变得荒芜

连诗里的飞鸟也迷了路

我像迷途小鹿 得不到救赎

才会将所有心迹 毫无保留呈现出

你纵身跃进了满天大雾

我找不到你也忘了归途

——《迷途小鹿》(歌手:葛雨晴,作词:峦无眠)

偶然间听到的歌曲,主题稍微契合就写进来了(逃


背景

正如前文所述,我们的模组要实现一个任务系统,记录着每个玩家完成任务的进度,以及触发任务条件等等。

为了方便数据包作者和其它模组作者的扩展,我决定使用数据包形式,单向链表结构来添加任务。第一步就是确定数据格式——在这里我选择使用如下格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"requires": [
<多个resource location,表示前置任务,未完成所有前置任务的玩家无法触发本任务>
],
"reward": <可选,entity_type的resouce location,表示玩家完成任务后哪种生物将不再攻击他/她>,
"messages": [
<接收任务时的任务对话,包含多条服从如下格式的object>
{
"key": <本地化键名,为显示的文本>,
"speaker": <"player""npc",代表这句话是玩家说出的还是对话NPC说出的>
}
],
"messagesAfter": [
<完成任务时的任务对话,包含多条服从如下格式的object>
{
"key": <本地化键名,为显示的文本>,
"speaker": <"player""npc",代表这句话是玩家说出的还是对话NPC说出的>
}
],
"loot_table": <可选,战利品表id,表示任务完成时或接收时获得的战利品>,
"loot_before": <可选,truefalse(默认false),取决于是否在接受任务时获得战利品>
}

没错,任务强制只在开始和结束时才触发对话,而对话的目标最多只有一位NPC(当然也可以是玩家独白)。这是这个简易系统唯一的局限性。

至于触发,后面再讲。这里提前简要说一下,对于任务开始和结束,原生模组提供了两种任务触发方式:其一是summon_block,数据包作者们可以直接使用;其二是api.MissionHelper#triggerMissionForPlayers和api.MissionHelper#triggerMissionForPlayer,只有衍生和联动模组开发者们可以使用。

接下来就要想如何实现了。之前做独立游戏的时候实现过任务系统,不过跟Minecraft的情况相去较远,至少没办法直接搬。所以我直接重新造一个轮子。

不过做模组,很重要的一点就是,想想原版有什么类似的功能,那么只需要轻松照抄,稍加修改即可。

我最先想到的则是原版的进度系统——数据包作者们可以在data//advancements中自由添加进度。那就容易很多了,说干就干!

ServerAdvancementManager详解

打开net.minecraft.server.ServerAdvancementManager文件,我们可以看到原版进度系统的实现:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class ServerAdvancementManager extends SimpleJsonResourceReloadListener {
private static final Logger LOGGER = LogUtils.getLogger();
private static final Gson GSON = (new GsonBuilder()).create();
private AdvancementList advancements = new AdvancementList();

//...

public ServerAdvancementManager(LootDataManager lootData) {
super(GSON, "advancements");
this.lootData = lootData;
}

//...

@Override
protected void apply(Map<ResourceLocation, JsonElement> jsons, ResourceManager resourceManager, ProfilerFiller profilerFiller) {
Map<ResourceLocation, Advancement.Builder> map = Maps.newHashMap();
jsons.forEach((id, json) -> {
try {
JsonObject jsonobject = GsonHelper.convertToJsonObject(json, "advancement");
Advancement.Builder builder = Advancement.Builder.fromJson(jsonobject, new DeserializationContext(id, this.lootData), this.context);
if (builder == null) {
LOGGER.debug("Skipping loading advancement {} as its conditions were not met", id);
return;
}
map.put(id, builder);
} catch (Exception exception) {
LOGGER.error("Parsing error loading custom advancement {}: {}", id, exception.getMessage());
}

});
AdvancementList advancementlist = new AdvancementList();
advancementlist.add(map);

for(Advancement advancement : advancementlist.getRoots()) {
if (advancement.getDisplay() != null) {
TreeNodePosition.run(advancement);
}
}

this.advancements = advancementlist;
}

@Nullable
public Advancement getAdvancement(ResourceLocation id) {
return this.advancements.get(id);
}

public Collection<Advancement> getAllAdvancements() {
return this.advancements.getAllAdvancements();
}
}

不太重要的部分已经略去,对于这部分我们逐一解读。

1. MissionManager的实现

首先它继承了SimpleJsonResourceReloadListener类,这个类原版有两种Manager继承了它,其一是进度系统,其二是合成系统;而Forge也定义了LootModifierManager,用以实现战利品表的更改。这个父类的功能很简单,可以实现json格式的数据读取和自动加载,只需重写apply函数即可。

也许有写过低版本模组的同仁们就要问了,战利品表系统不也继承了它吗?不错,曾经是,不过1.20这部分被大幅修改了,如今LootDataManager仅仅是实现了SimpleJsonResourceReloadListener的爷爷接口PreparableReloadListener。

回归正题,apply函数传了三个参数,分别是所有JSON文件内容(按id索引在map中)、Resource Manager和Profiler Filler。事实上我们实现自己的需求也无需后两个参数,只要写好读取json文件的处理逻辑即可。

其次,构造函数传递了两个参数,一个是编码JSON文件的方法,一个是扫描文件目录。对于进度系统则是”advancements”,如果我希望任务系统的扫描目录是data//rpm/missions,则传入”rpm/missions”即可。

于是我们便可以实现任务系统:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
public class MissionManager extends SimpleJsonResourceReloadListener {
private static final Gson GSON = (new GsonBuilder()).create();

private Map<ResourceLocation, Mission> missionsByName = ImmutableMap.of();

public MissionManager() {
super(GSON, "rpm/missions");
}

@Override
protected void apply(Map<ResourceLocation, JsonElement> missions, ResourceManager resourceManager, ProfilerFiller profilerFiller) {
ImmutableMap.Builder<ResourceLocation, Mission> builder = ImmutableMap.builder();
for(Map.Entry<ResourceLocation, JsonElement> entry: missions.entrySet()) {
ResourceLocation id = entry.getKey();
if (id.getPath().startsWith("_")) {
continue;
}

try {
if (entry.getValue().isJsonObject() && !processConditions(entry.getValue().getAsJsonObject())) {
RPMLogger.debug("Skipping loading mission %s as it's conditions were not met".formatted(id));
continue;
}
JsonObject jsonObject = GsonHelper.convertToJsonObject(entry.getValue(), "top element");
Mission mission = Mission.fromJson(id, jsonObject); //读取每个任务
builder.put(id, mission);
} catch (IllegalArgumentException | JsonParseException exception) {
RPMLogger.error("Parsing error loading mission %s.".formatted(id));
RPMLogger.error(exception);
}
}
this.missionsByName = builder.build();
}

public record Mission(ResourceLocation id,
List<Message> messages, List<Message> messagesAfter,
List<ResourceLocation> formers,
EntityType<?> reward, ResourceLocation rewardLootTable, boolean lootBefore) {
public record Message(String messageKey, Speaker speaker) {
//前文的说话者,包括玩家和NPC
public enum Speaker {
PLAYER,
NPC
}
}

private static Mission fromJson(ResourceLocation id, JsonObject json) {
List<Mission.Message> messages = Lists.newArrayList();
List<Mission.Message> messagesAfter = Lists.newArrayList();
JsonArray messageArray = GsonHelper.getAsJsonArray(json, "messages");
JsonArray messageAfterArray = GsonHelper.getAsJsonArray(json, "messagesAfter");
getMessages(messages, messageArray);
getMessages(messagesAfter, messageAfterArray);
JsonArray requires = GsonHelper.getAsJsonArray(json, "requires");
List<ResourceLocation> formers = Lists.newArrayList();
for(JsonElement element: requires) {
String otherId = GsonHelper.convertToString(element, "elements of requires");
ResourceLocation former = new ResourceLocation(otherId);
formers.add(former);
}
ResourceLocation reward = new ResourceLocation(GsonHelper.getAsString(json, "reward", "minecraft:player"));
EntityType<?> rewardEntityType = ForgeRegistries.ENTITY_TYPES.getValue(reward);
ResourceLocation rewardLootTable = new ResourceLocation(GsonHelper.getAsString(json, "loot_table", BuiltInLootTables.EMPTY.toString()));
boolean lootBefore = GsonHelper.getAsBoolean(json, "loot_before", false);
return new Mission(id, messages, messagesAfter, formers, rewardEntityType == null ? EntityType.PLAYER : rewardEntityType, rewardLootTable, lootBefore);
}

//尝试获得战利品
public void tryGetLoot(ServerPlayer player, LootDataManager lootTables, boolean finished) {
if(this.lootBefore != finished) {
if (this.rewardLootTable != null && !this.rewardLootTable.equals(BuiltInLootTables.EMPTY)) {
LootTable lootTable = lootTables.getLootTable(this.rewardLootTable);
lootTable.getRandomItems(new LootParams.Builder((ServerLevel) player.level()).create(LootContextParamSets.EMPTY), itemStack -> player.level().addFreshEntity(
new ItemEntity(player.level(), player.getX(), player.getY() + 0.5D, player.getZ(), itemStack)
));
}
}
}

//完成任务后的额外工作,包括获得战利品、通知任务完成和(如果有)将对应怪物设为友好
public void finish(ServerPlayer player, LootDataManager lootTables) {
if(!this.reward.equals(EntityType.PLAYER)) {
if(!((IMonsterHero)player).isHero(this.reward)) {
player.sendSystemMessage(Component.translatable(
"message.real_peaceful_mode.reward_monster",
player.getDisplayName(),
Component.translatable(this.reward.getDescriptionId()).withStyle(ChatFormatting.GREEN)
));
((IMonsterHero) player).setHero(this.reward);
}
}
this.tryGetLoot(player, lootTables, true);
}
}

private static final String CONDITIONS_FIELD = "conditions";
private static boolean processConditions(JsonObject json) {
return !json.has(CONDITIONS_FIELD) || MissionLoadCondition.fromJson(json.get(CONDITIONS_FIELD)).test();
}

private static void getMessages(List<Mission.Message> messages, JsonArray messageArray) {
for(JsonElement element: messageArray) {
if(element.isJsonObject()) {
JsonObject json = element.getAsJsonObject();
String speakerType = GsonHelper.getAsString(json, "speaker");
Mission.Message.Speaker speaker = switch(speakerType) {
case "player" -> Mission.Message.Speaker.PLAYER;
case "npc" -> Mission.Message.Speaker.NPC;
default -> throw new IllegalArgumentException("No speaker named \"%s\"!".formatted(speakerType));
};
messages.add(new Mission.Message(GsonHelper.getAsString(json, "key"), speaker));
} else if(element.isJsonPrimitive()) {
String message = element.getAsString();
messages.add(new Mission.Message(message, Mission.Message.Speaker.PLAYER));
} else {
throw new IllegalArgumentException("Field \"messages\" must be an array of strings and json objects!");
}
}
}

public Optional<Mission> getMission(ResourceLocation id) {
return Optional.ofNullable(this.missionsByName.get(id));
}

public Stream<ResourceLocation> getAllMissionIds() {
return this.missionsByName.keySet().stream();
}

public Collection<Mission> getAllMissions() {
return this.missionsByName.values();
}
}

然后,如何将这个监听器真正监听在资源加载阶段呢?当然你可以mixin,但Forge是有这个API的,所以我优先去调用这个API:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ForgeEventHandler {
private static MissionManager missionManager;

@SubscribeEvent
public void onResourceReload(AddReloadListenerEvent event) {
missionManager = new MissionManager();
event.addListener(missionManager);
}

public static MissionManager getMissionManager() {
return missionManager;
}
}

并在主类中,通过Forge bus中注册它。

1
MinecraftForge.EVENT_BUS.register(new ForgeEventHandler());

接着,我们有了总领的任务系统,进一步的,如何去维护每个人的任务的进度?于是我们发现了PlayerAdvancements类。

2. PlayerMissions的实现

首先,我们可以看到在PlayerList中有一个维护每个人进度完成情况的成员:

1
private final Map<UUID, PlayerAdvancements> advancements = Maps.newHashMap();

而在ServerPlayer中也有自身独立的PlayerAdvancements:

1
private final PlayerAdvancements advancements;

当然,这个advancements只是PlayerList中对应的那个PlayerAdvancements的一个影子。

参考这个类,我们可以实现自己的PlayerMissions。它需要包含玩家完成过的任务、玩家正在进行的任务(其余都是还未接收的任务):

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
public record PlayerMissions(Path playerSavePath, ServerPlayer player, List<ResourceLocation> activeMissions, List<ResourceLocation> finishedMissions) {
public PlayerMissions(Path playerSavePath, ServerPlayer player) {
this(playerSavePath, player, Lists.newArrayList(), Lists.newArrayList());
}

private static final String PLAYER_MISSIONS = "RealPeacefulModeMissions";
private static final String ACTIVE_MISSIONS = "activeMissions";
private static final String FINISHED_MISSIONS = "finishedMissions";
public void readNBT(CompoundTag nbt) {
this.activeMissions.clear();
this.finishedMissions.clear();

if(nbt.contains(PLAYER_MISSIONS, Tag.TAG_COMPOUND)) {
CompoundTag missions = nbt.getCompound(PLAYER_MISSIONS);
ListTag activeMissions = missions.getList(ACTIVE_MISSIONS, Tag.TAG_STRING);
ListTag finishedMissions = missions.getList(FINISHED_MISSIONS, Tag.TAG_STRING);
for(Tag tag: activeMissions) {
this.activeMissions.add(new ResourceLocation(tag.getAsString()));
}
for(Tag tag: finishedMissions) {
this.finishedMissions.add(new ResourceLocation(tag.getAsString()));
}
}
}

public void writeNBT(CompoundTag nbt) {
ListTag activeMissions = new ListTag();
ListTag finishedMissions = new ListTag();
for(ResourceLocation id: this.activeMissions) {
activeMissions.add(StringTag.valueOf(id.toString()));
}
for(ResourceLocation id: this.finishedMissions) {
finishedMissions.add(StringTag.valueOf(id.toString()));
}
CompoundTag missions = new CompoundTag();
missions.put(ACTIVE_MISSIONS, activeMissions);
missions.put(FINISHED_MISSIONS, finishedMissions);

nbt.put(PLAYER_MISSIONS, missions);
}

public void replaceWith(PlayerMissions other) {
this.activeMissions.clear();
this.finishedMissions.clear();

this.activeMissions.addAll(other.activeMissions);
this.finishedMissions.addAll(other.finishedMissions);
}

public void receiveNewMission(MissionManager.Mission mission, @Nullable LivingEntity npc) {
if (this.player instanceof FakePlayer) {
return;
}

mission.formers().stream().filter(id -> !this.finishedMissions.contains(id)).findAny().ifPresentOrElse(
id -> RPMLogger.debug("Ignore receive mission %s for not finishing mission %s.".formatted(mission.id(), id)),
() -> {
MessagedMissionInstance instance = new MessagedMissionInstance(this.player, npc, mission.messages());
OptionalInt id = this.player.openMenu(new SimpleMenuProvider((counter, inventory, player) ->
new MissionMessageMenu(counter, instance, () -> {
this.player.sendSystemMessage(Component.translatable(
"message.real_peaceful_mode.receive_mission",
ComponentUtils.wrapInSquareBrackets(
Component.translatable(getMissionDescriptionId(mission))
.withStyle(ChatFormatting.GREEN)
.withStyle((style -> style.withHoverEvent(
new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.translatable(getMissionInformationId(mission)))
)))
)
));
this.activeMissions().add(mission.id());
mission.tryGetLoot(this.player, Objects.requireNonNull(this.player.getServer()).getLootData(), false);
}), Component.translatable("title.real_peaceful_mode.menu.mission")
));
if(id.isPresent()) {
RealPeacefulMode.packetHandler.send(
PacketDistributor.PLAYER.with(() -> this.player),
new ClientboundMissionMessagePacket(instance, id.getAsInt())
);
}
}
);
}

public void finishMission(MissionManager.Mission mission, @Nullable LivingEntity npc) {
if (this.player instanceof FakePlayer) {
return;
}

MessagedMissionInstance instance = new MessagedMissionInstance(this.player, npc, mission.messagesAfter());
OptionalInt id = this.player.openMenu(new SimpleMenuProvider((counter, inventory, player) ->
new MissionMessageMenu(counter, instance, () -> {
this.player.sendSystemMessage(Component.translatable(
"message.real_peaceful_mode.finish_mission",
ComponentUtils.wrapInSquareBrackets(
Component.translatable(getMissionDescriptionId(mission))
.withStyle(ChatFormatting.GREEN)
.withStyle((style -> style.withHoverEvent(
new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.translatable(getMissionInformationId(mission)))
)))
)
));
this.activeMissions().remove(mission.id());
this.finishedMissions().add(mission.id());
mission.finish(this.player, Objects.requireNonNull(this.player.getServer()).getLootData());
}), Component.translatable("title.real_peaceful_mode.menu.mission")
));
if(id.isPresent()) {
RealPeacefulMode.packetHandler.send(
PacketDistributor.PLAYER.with(() -> this.player),
new ClientboundMissionMessagePacket(instance, id.getAsInt())
);
}
}

public static String getMissionDescriptionId(MissionManager.Mission mission) {
ResourceLocation id = mission.id();
return "mission.%s.%s.name".formatted(id.getNamespace(), id.getPath());
}

public static String getMissionInformationId(MissionManager.Mission mission) {
ResourceLocation id = mission.id();
return "mission.%s.%s.description".formatted(id.getNamespace(), id.getPath());
}
}

为了安全性,在这里做了检查,允许玩家接收的任务,玩家必须已经完成过所有前置任务。

那么,如何将它加进PlayerList里呢?其实未必要加进PlayerList中,你也可以自己写一个SavedData来实现,不过这次的mixin没有副作用,而且更符合直觉架构,因此我选择了mixin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Mixin(PlayerList.class)
public class PlayerListMixin implements IPlayerListWithMissions {
@Shadow @Final
private MinecraftServer server;

private final Map<UUID, PlayerMissions> missions = Maps.newHashMap();

@Override
public PlayerMissions getPlayerMissions(ServerPlayer player) {
UUID uuid = player.getUUID();
PlayerMissions playerMissions = this.missions.get(uuid);
if (playerMissions == null) {
Path path = this.server.getWorldPath(PLAYER_MISSIONS_DIR).resolve(uuid + ".json");
playerMissions = new PlayerMissions(path, player);
this.missions.put(uuid, playerMissions);
}

return playerMissions;
}
}

这里抽象了一个IPlayerListWithMissions接口,作用是,由于Mixin类无法被实例化或强制转化,所以要想调用getPlayerMissions函数,必须通过一个接口来访问。比如:

1
((IPlayerListWithMissions) serverLevel.getServer().getPlayerList()).getPlayerMissions(player)

没办法,都mixin了,还在意啥代码美观(划掉)。

那么如何将它进行序列化呢?我们又要mixin进ServerPlayer类,注入读写nbt和restoreFrom方法:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@Mixin(ServerPlayer.class)
public class ServerPlayerMixin implements IMonsterHero {
private final Map<ResourceLocation, Integer> helpedMonsters = Maps.newHashMap();

private PlayerMissions playerMissions;

@Inject(method = "<init>", at = @At(value = "TAIL"))
public void create(MinecraftServer server, ServerLevel level, GameProfile gameProfile, CallbackInfo ci) {
this.playerMissions = ((IPlayerListWithMissions)server.getPlayerList()).getPlayerMissions((ServerPlayer)(Object)this);
}

@Override
public boolean isHero(EntityType<?> monsterType) {
return this.helpedMonsters.containsKey(getRegistryName(monsterType));
}

@Override
public void setHero(EntityType<?> monsterType) {
this.helpedMonsters.compute(getRegistryName(monsterType), (type, count) -> {
if(count == null) {
return 1;
}
return count + 1;
});
}

@Override
public Map<ResourceLocation, Integer> getHelpedMonsters() {
return this.helpedMonsters;
}

@Override
public PlayerMissions getPlayerMissions() {
return this.playerMissions;
}

private static final String HELPED_MONSTERS = "helpedMonsters";
@Inject(method = "readAdditionalSaveData", at = @At(value = "TAIL"))
public void readRPMData(CompoundTag nbt, CallbackInfo ci) {
if(nbt.contains(HELPED_MONSTERS, Tag.TAG_LIST)) {
ListTag list = nbt.getList(HELPED_MONSTERS, Tag.TAG_COMPOUND);
list.forEach(tag -> {
CompoundTag compound = (CompoundTag)tag;
this.helpedMonsters.compute(new ResourceLocation(compound.getString("type")), (type, count) -> compound.getInt("count"));
});
}
this.playerMissions.readNBT(nbt);
}

@Inject(method = "addAdditionalSaveData", at = @At(value = "TAIL"))
public void addRPMData(CompoundTag nbt, CallbackInfo ci) {
ListTag tags = new ListTag();
this.helpedMonsters.forEach((type, count) -> {
CompoundTag tag = new CompoundTag();
tag.putString("type", type.toString());
tag.putInt("count", count);
tags.add(tag);
});
nbt.put(HELPED_MONSTERS, tags);
this.playerMissions.writeNBT(nbt);
}

@Inject(method = "restoreFrom", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/level/ServerPlayer;setLastDeathLocation(Ljava/util/Optional;)V"))
public void restoreRPMDataFrom(ServerPlayer player, boolean won, CallbackInfo ci) {
if(player instanceof IMonsterHero hero) {
hero.getHelpedMonsters().forEach((type, count) -> this.helpedMonsters.compute(type, (type1, count1) -> count));
}
}
}

这里的IMonsterHero接口也是同样,方便其它部分调用,判断玩家是否已经实现了某个怪物的全部委托。

一定不要忘记restoreFrom函数!否则玩家不论是从末地返回主世界,还是死亡后重生,这些信息都会消失!

至于为什么不restore playerMissions,别忘了它只是个影子,之前我也是restore了,debug过程中发现了这个问题,于是把它删掉了。

那么任务系统算是成功实现了,不过还需要客户端的UI,显示任务对话,如题图所示。该怎么实现呢?

首先介绍一下Minecraft的UI架构。一般的功能性UI都是两层结构:第一层是Menu,位于服务端(客户端会同步它),便于与世界交互,如玩家放入熔炉一根烈焰棒(真有人这么富吗?);第二层是Screen,位于客户端,执行显示界面,处理玩家请求的功能,如显示熔炉UI,显示燃料剩余量、烧炼的进度等等。有些UI由于无需与服务端部分交互,便只有Screen没有Menu,比如玩家进度、统计界面等,只需一次发包后便可显示。

而我们的需求是,首先,任务界面打开过程中,怪物不能攻击玩家——这就限制了我们的实现,Menu部分必须要存在;其次,玩家客户端要显示对话,这部分是由服务端的MissionManager.Mission发包过来的;最后,对话结束后要提示玩家接收到了新的任务或完成了任务,这又是服务端向客户端发送的。

那么我们可以做如下设计:

  1. Menu部分维护了该任务的所有对话。
  2. Screen显示了对话内容和讲话的生物,玩家可以按下按钮来向前/向后阅读。

于是我们可以实现如下Menu:

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
39
40
41
42
43
44
45
46
47
48
49
50
public class MissionMessageMenu extends AbstractContainerMenu {
private MessagedMission mission;
private final Runnable onRemoved;

public MissionMessageMenu(int counter, Inventory inventory) {
this(counter, new ClientSideMessagedMission(inventory.player), () -> {});
}

public MissionMessageMenu(int counter, MessagedMission mission, Runnable onRemoved) {
super(RPMMenuTypes.MISSION_MESSAGE_MENU.get(), counter);
this.mission = mission;
this.onRemoved = onRemoved;
}

@Override
public ItemStack quickMoveStack(Player player, int slot) {
return ItemStack.EMPTY;
}

@Override
public boolean stillValid(Player player) {
LivingEntity npc = this.mission.npc();
if(npc == null) {
return true;
}
return this.mission.player().closerThan(npc, 24.0D);
}

public MessagedMission getMission() {
return this.mission;
}

public void setMission(MessagedMission mission) {
this.mission = mission;
}

@Nullable
public LivingEntity getSpeaker(MissionManager.Mission.Message.Speaker speaker) {
return switch (speaker) {
case PLAYER -> this.mission.player();
case NPC -> this.mission.npc();
};
}

@Override
public void removed(Player player) {
super.removed(player);
this.onRemoved.run();
}
}

第一个构造函数用于RPMMenuTypes中的注册:

1
2
3
private static final DeferredRegister<MenuType<?>> REGISTER = DeferredRegister.create(ForgeRegistries.MENU_TYPES, MODID);

public static final RegistryObject<MenuType<MissionMessageMenu>> MISSION_MESSAGE_MENU = REGISTER.register("mission_message", () -> new MenuType<>(MissionMessageMenu::new, FeatureFlags.VANILLA_SET));

第二个构造函数则是用于在接收/完成任务时玩家openMenu的传参。

由于无需物品栏和槽位的操作,quickMoveStack可直接返回空;stillValid也可以随便写写了,这里是根据与NPC的距离判断的。

mission的getter和setter则是用于服务端向客户端的发包:

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
public class ClientboundMissionMessagePacket implements IRPMPacket {
private final MessagedMission mission;
private final int containerId;

public ClientboundMissionMessagePacket(MessagedMission mission, int containerId) {
this.mission = mission;
this.containerId = containerId;
}

public ClientboundMissionMessagePacket(FriendlyByteBuf buf) {
this.mission = new MessagedMissionInstance(Objects.requireNonNull(buf.readNbt()));
this.containerId = buf.readInt();
}

@Override
public void write(FriendlyByteBuf buf) {
buf.writeNbt(this.mission.createTag());
buf.writeInt(this.containerId);
}

@Override
public void handle(NetworkEvent.Context context) {
LocalPlayer player = Minecraft.getInstance().player;
RPMLogger.debug(this.mission.createTag());
if(player != null) {
AbstractContainerMenu menu = player.containerMenu;
if(menu.containerId == this.containerId && menu instanceof MissionMessageMenu missionMessageMenu) {
missionMessageMenu.setMission(mission);
}
}
}
}

这一步将服务端的任务内容传给了客户端,并进行了验证。

注册发包则是在主类中完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static final String VERSION = ModList.get().getModFileById(MODID).versionString();

public static final SimpleChannel packetHandler = NetworkRegistry.ChannelBuilder
.named(new ResourceLocation(MODID, "main"))
.networkProtocolVersion(() -> VERSION)
.serverAcceptedVersions(VERSION::equals)
.clientAcceptedVersions(VERSION::equals)
.simpleChannel();

//...

private static int messageId = 0;
private static <T extends IRPMPacket> void registerMessage(Class<T> packetType,
Function<FriendlyByteBuf, T> constructor) {
packetHandler.registerMessage(messageId++, packetType, IRPMPacket::write, constructor, (packet, ctx) -> packet.handle(ctx.get()));
}

private void setup(final FMLCommonSetupEvent event) {
//...
registerMessage(ClientboundMissionMessagePacket.class, ClientboundMissionMessagePacket::new);
//...
}

Screen的实现

既然Menu实现好了,那Screen不就简单了吗?

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
@OnlyIn(Dist.CLIENT)
public class MissionMessageScreen extends AbstractContainerScreen<MissionMessageMenu> {
private static final ResourceLocation BG_LOCATION = new ResourceLocation(MODID, "textures/gui/mission_message.png");

private int messageIndex = 0;
private int deltaIndex = 0;

private List<FormattedCharSequence> cachedText;

public MissionMessageScreen(MissionMessageMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
--this.titleLabelY;
this.loadCachedText();
}

private void loadCachedText() {
List<MissionManager.Mission.Message> messages = this.menu.getMission().messages();
if(messages.size() > 0) {
this.cachedText = this.font.split(Component.translatable(messages.get(this.messageIndex).messageKey()), 140);
}
}

@Override
protected void renderLabels(GuiGraphics transform, int x, int y) {
transform.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 0x404040, false);
}

@Override
protected void renderBg(GuiGraphics transform, float ticks, int x, int y) {
int i = (this.width - this.imageWidth) / 2;
int j = (this.height - this.imageHeight) / 2;
transform.blit(BG_LOCATION, i, j, 0, 0, this.imageWidth, this.imageHeight);
List<MissionManager.Mission.Message> messages = this.menu.getMission().messages();
if(messages.size() <= 0) {
return;
}
MissionManager.Mission.Message message = messages.get(this.messageIndex);
LivingEntity currentSpeaker = this.menu.getSpeaker(message.speaker());
if(currentSpeaker != null) {
FormattedCharSequence name = currentSpeaker.getDisplayName().getVisualOrderText();
transform.drawString(this.font, name, i + 116 - this.font.width(name), j + 108, 0xa0a0a0);
InventoryScreen.renderEntityInInventoryFollowsMouse(transform, i + 143, j + 151, 24, i + 143 - x, j + 120 - y, currentSpeaker);
}
if(this.cachedText == null || this.cachedText.size() <= 0) {
this.loadCachedText();
}
for(int l = 0; l < this.cachedText.size(); ++l) {
transform.drawString(this.font, this.cachedText.get(l), i + 16, j + 16 + l * 9, 0x404040, false);
}
this.renderButtons(transform, x, y);
}

private void renderButtons(GuiGraphics transform, int x, int y) {
int buttonX1 = this.leftPos + 13;
int buttonX2 = this.leftPos + 49;
int buttonY = this.topPos + 78;
boolean x1InRange = x >= buttonX1 && x < buttonX1 + 18;
boolean x2InRange = x >= buttonX2 && x < buttonX2 + 18;
boolean yInRange = y >= buttonY && y < buttonY + 18;
int buttonHeightLeft = (x1InRange && yInRange) ? this.imageHeight + 36 : this.imageHeight;
int buttonHeightRight = (x2InRange && yInRange) ? this.imageHeight + 36 : this.imageHeight;
switch(this.deltaIndex) {
case -1 -> buttonHeightLeft = this.imageHeight + 18;
case 1 -> buttonHeightRight = this.imageHeight + 18;
}
transform.blit(BG_LOCATION, buttonX1, buttonY, 0, buttonHeightLeft, 18, 18);
transform.blit(BG_LOCATION, buttonX2, buttonY, 18, buttonHeightRight, 18, 18);
}

@Override
public boolean mouseClicked(double x, double y, int button) {
double buttonX1 = this.leftPos + 13;
double buttonX2 = this.leftPos + 49;
double buttonY = this.topPos + 78;
if(y >= buttonY && y < buttonY + 18.0D) {
if(x >= buttonX1 && x < buttonX1 + 18.0D) {
Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_STONECUTTER_SELECT_RECIPE, 1.0F));
this.deltaIndex = -1;
return true;
}
if(x >= buttonX2 && x < buttonX2 + 18.0D) {
Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_STONECUTTER_SELECT_RECIPE, 1.0F));
this.deltaIndex = 1;
return true;
}
}
return super.mouseClicked(x, y, button);
}

@Override
public boolean mouseReleased(double x, double y, int button) {
double buttonX1 = this.leftPos + 13;
double buttonX2 = this.leftPos + 49;
double buttonY = this.topPos + 78;
if(y >= buttonY && y < buttonY + 18.0D) {
if((x >= buttonX1 && x < buttonX1 + 18.0D && this.deltaIndex == -1) ||
(x >= buttonX2 && x < buttonX2 + 18.0D && this.deltaIndex == 1)) {
this.messageIndex += this.deltaIndex;
this.deltaIndex = 0;
if(this.messageIndex < 0) {
this.messageIndex = 0;
} else if(this.messageIndex >= this.menu.getMission().messages().size()) {
this.onClose();
return true;
}
this.loadCachedText();
return true;
}
}
this.deltaIndex = 0;
return super.mouseReleased(x, y, button);
}
}

注意高版本的PoseStack被UI系统弃用了,改用GuiGraphics做渲染,个人感觉更加方便了。

renderBg函数实现了背景渲染,以及界面右下方对话实体的显示。renderButtons显示了向前向后两个按钮的渲染,而处理玩家请求则是在mouseClicked(按下按钮,改变按钮颜色)和mouseReleased(释放按钮,执行按钮功能)中实现。而对话文本的分行是在loadCachedText函数中实现。

具体blit的数值取决于GUI资源图片的排版,由于我将按钮元素排版在下方,所以便从下方截取图像并贴在对应位置(详见仓库中GUI资源文件)。

实现了核心功能,也许玩家会想查看自己的任务完成情况和任务描述。接下来介绍显示客户端玩家任务这部分的实现方法。

显示玩家任务完成情况

首先我添加了绑定按键(默认M键),按下按键后可显示任务屏幕GUI。

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
@OnlyIn(Dist.CLIENT)
public final class RPMKeys {
public static final KeyEntry MISSION_SCREEN = new KeyEntry("mission_screen", GLFW.GLFW_KEY_M);

public static final class KeyEntry {
public static final List<KeyEntry> ALL_KEYS = Lists.newArrayList();

private final KeyMapping keyMapping;

@SuppressWarnings("SameParameterValue")
private KeyEntry(String name, int defaultKey) {
String descriptionId = MODID + ".keyinfo." + name;
this.keyMapping = new KeyMapping(descriptionId, defaultKey, MODNAME);

ALL_KEYS.add(this);
}

public boolean isDown() {
return this.keyMapping.isDown();
}
}

public static void init() {

}
}

客户端也要监听玩家按键的事件,处理打开窗口的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Mod.EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.FORGE, value = Dist.CLIENT)
public class ClientEventHandler {
@SubscribeEvent
public static void onKeyboardInput(InputEvent.Key event) {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) {
return;
}
if (RPMKeys.MISSION_SCREEN.isDown()) {
RealPeacefulMode.packetHandler.sendToServer(new GetMissionsPacket());
}
}
}

这里这里通过客户端和服务端之间互相发包的方式,来获取玩家任务列表(包括已完成和进行中):

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public class GetMissionsPacket implements IRPMPacket {
private final PacketType type;
private final List<MissionManager.Mission> activeMissions;
private final List<MissionManager.Mission> finishedMissions;

public GetMissionsPacket() {
this.type = PacketType.REQUEST;
this.activeMissions = List.of();
this.finishedMissions = List.of();
}
public GetMissionsPacket(List<MissionManager.Mission> activeMissions, List<MissionManager.Mission> finishedMissions) {
this.type = PacketType.RESPONSE;
this.activeMissions = activeMissions;
this.finishedMissions = finishedMissions;
}

public GetMissionsPacket(FriendlyByteBuf buf) {
this.type = buf.readEnum(PacketType.class);
this.activeMissions = buf.readCollection(Lists::newArrayListWithCapacity, readerBuf -> {
ResourceLocation id = readerBuf.readResourceLocation();
EntityType<?> entityType = ForgeRegistries.ENTITY_TYPES.getValue(readerBuf.readResourceLocation());
if(entityType == null) {
entityType = EntityType.PLAYER;
}
ResourceLocation loot = readerBuf.readResourceLocation();
boolean lootBefore = readerBuf.readBoolean();
return new MissionManager.Mission(id, List.of(), List.of(), List.of(), entityType, loot, lootBefore);
});
this.finishedMissions = buf.readCollection(Lists::newArrayListWithCapacity, readerBuf -> {
ResourceLocation id = readerBuf.readResourceLocation();
EntityType<?> entityType = ForgeRegistries.ENTITY_TYPES.getValue(readerBuf.readResourceLocation());
if(entityType == null) {
entityType = EntityType.PLAYER;
}
ResourceLocation loot = readerBuf.readResourceLocation();
boolean lootBefore = readerBuf.readBoolean();
return new MissionManager.Mission(id, List.of(), List.of(), List.of(), entityType, loot, lootBefore);
});
}

@Override
public void write(FriendlyByteBuf buf) {
buf.writeEnum(this.type);
buf.writeCollection(this.activeMissions, (writerBuf, mission) -> {
writerBuf.writeResourceLocation(mission.id());
writerBuf.writeResourceLocation(getRegistryName(mission.reward()));
writerBuf.writeResourceLocation(mission.rewardLootTable());
writerBuf.writeBoolean(mission.lootBefore());
});
buf.writeCollection(this.finishedMissions, (writerBuf, mission) -> {
writerBuf.writeResourceLocation(mission.id());
writerBuf.writeResourceLocation(getRegistryName(mission.reward()));
writerBuf.writeResourceLocation(mission.rewardLootTable());
writerBuf.writeBoolean(mission.lootBefore());
});
}

@Override
public void handle(NetworkEvent.Context context) {
ServerPlayer sender = context.getSender();
assert (sender == null) ^ (this.type == PacketType.REQUEST);
context.enqueueWork(() -> {
if(sender == null) {
Minecraft.getInstance().setScreen(new MissionListScreen(this.activeMissions, this.finishedMissions));
} else {
PlayerMissions playerMissions = ((IPlayerListWithMissions) Objects.requireNonNull(sender.getServer()).getPlayerList()).getPlayerMissions(sender);
List<MissionManager.Mission> activeMissions = playerMissions.activeMissions()
.stream().map(id -> ForgeEventHandler.getMissionManager().getMission(id))
.filter(Optional::isPresent).map(Optional::get)
.toList();
List<MissionManager.Mission> finishedMissions = playerMissions.finishedMissions()
.stream().map(id -> ForgeEventHandler.getMissionManager().getMission(id))
.filter(Optional::isPresent).map(Optional::get)
.toList();
GetMissionsPacket packet = new GetMissionsPacket(activeMissions, finishedMissions);
RealPeacefulMode.packetHandler.send(PacketDistributor.PLAYER.with(() -> sender), packet);
}
});
}
}

发包注册方式和前文相似:

1
2
3
4
5
private void setup(final FMLCommonSetupEvent event) {
//...
registerMessage(GetMissionsPacket.class, GetMissionsPacket::new);
registerMessage(ClientboundMissionMessagePacket.class, ClientboundMissionMessagePacket::new);
}

显示任务列表的Screen无需与世界交互,所以只实现一个Screen即可,无需Menu:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
public class MissionListScreen extends Screen {
private static final int MAX_MISSIONS_PER_SCREEN = 6;

protected final int imageWidth = 176;
protected final int imageHeight = 166;
protected final int titleLabelX = 16;
protected final int titleLabelY = 6;
protected int leftPos;
protected int topPos;

private int beginIndex = 0;
private float scrollOffs;
private boolean showFinished = false;
private boolean scrolling = false;

public static final ResourceLocation BG_LOCATION = new ResourceLocation(MODID, "textures/gui/mission_list.png");

private final List<MissionManager.Mission> activeMissions;
private final List<MissionManager.Mission> finishedMissions;

private List<Tuple<MissionManager.Mission, Boolean>> shadows;

@Nullable
private List<FormattedCharSequence> noMissionText = null;

public MissionListScreen(List<MissionManager.Mission> activeMissions, List<MissionManager.Mission> finishedMissions) {
super(Component.translatable("title.real_peaceful_mode.menu.mission_list"));
this.activeMissions = activeMissions;
this.finishedMissions = finishedMissions;
this.shadows = this.activeMissions.stream().map(mission -> new Tuple<>(mission, true)).collect(Collectors.toList());
}

@Override
protected void init() {
this.leftPos = (this.width - this.imageWidth) / 2;
this.topPos = (this.height - this.imageHeight) / 2;
}

@Override
public void render(GuiGraphics transform, int x, int y, float ticks) {
transform.drawString(this.font, this.title, this.leftPos + this.titleLabelX, this.topPos + this.titleLabelY, 0x404040);
this.renderBg(transform, x, y);
super.render(transform, x, y, ticks);
}

protected void renderBg(GuiGraphics transform, int x, int y) {
transform.blit(BG_LOCATION, this.leftPos, this.topPos, 0, 0, this.imageWidth, this.imageHeight);
int k = (int)(91.0F * this.scrollOffs);
transform.blit(BG_LOCATION, this.leftPos + 149, this.topPos + 39 + k, 176 + (this.isScrollBarActive() ? 0 : 12), 0, 12, 15);
this.renderButtons(transform, x, y);
this.renderMissions(transform);
this.renderTooltip(transform, x, y);
}

private void renderButtons(GuiGraphics transform, int x, int y) {
int buttonX = this.leftPos + 116;
int buttonY = this.topPos + 6;
boolean xInRange = x >= buttonX && x < buttonX + 54;
boolean yInRange = y >= buttonY && y < buttonY + 18;
int buttonHeight = (xInRange && yInRange) ? this.imageHeight + 36 : this.imageHeight;
if(this.showFinished) {
buttonHeight += 18;
}
transform.blit(BG_LOCATION, buttonX, buttonY, 0, buttonHeight, 54, 18);
}

private void renderMissions(GuiGraphics transform) {
int bound = Math.min(this.shadows.size(), MAX_MISSIONS_PER_SCREEN);
if(bound == 0) {
if(this.noMissionText == null) {
this.noMissionText = this.font.split(Component.translatable("gui.real_peaceful_mode.menu.mission_list.no_mission"), 128);
}
for(int i = 0; i < this.noMissionText.size(); ++i) {
transform.drawString(this.font, this.noMissionText.get(i), this.leftPos + 6, this.topPos + 38 + i * 9, 0xa0a0a0, false);
}
} else {
for (int i = 0; i < bound; ++i) {
if(this.shadows.get(this.beginIndex + i).getB()) {
transform.blit(BG_LOCATION, this.leftPos + 6, this.topPos + 38 + 18 * i, 54, 166, 140, 18);
} else {
transform.blit(BG_LOCATION, this.leftPos + 6, this.topPos + 38 + 18 * i, 54, 184, 140, 18);
}
ResourceLocation id = this.shadows.get(this.beginIndex + i).getA().id();
transform.drawString(this.font, Component.translatable("mission.%s.%s.name".formatted(id.getNamespace(), id.getPath())), this.leftPos + 8, this.topPos + 42 + 18 * i, 0xffffff, false);
}
}
}

private void renderTooltip(GuiGraphics transform, int x, int y) {
if(x >= this.leftPos + 6 && x < this.leftPos + 6 + 140) {
int bound = Math.min(this.shadows.size(), MAX_MISSIONS_PER_SCREEN);
int i = (y - this.topPos - 38) / 18;
if(i >= 0 && i < bound) {
ResourceLocation id = this.shadows.get(this.beginIndex + i).getA().id();
if(this.shadows.get(this.beginIndex + i).getB()) {
transform.renderTooltip(this.font, Component.translatable("mission.%s.%s.description".formatted(id.getNamespace(), id.getPath())), x, y);
} else {
transform.renderTooltip(this.font, Component.translatable("mission.%s.%s.after".formatted(id.getNamespace(), id.getPath())), x, y);
}
}
}
}

@Override
public boolean mouseClicked(double x, double y, int button) {
this.scrolling = false;
int buttonX = this.leftPos + 116;
int buttonY = this.topPos + 6;
if(y >= buttonY && y < buttonY + 18.0D) {
if(x >= buttonX && x < buttonX + 54.0D) {
Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_STONECUTTER_SELECT_RECIPE, 1.0F));
this.showFinished = !this.showFinished;
if(this.showFinished) {
ImmutableList.Builder<Tuple<MissionManager.Mission, Boolean>> builder = ImmutableList.builder();
builder.addAll(this.activeMissions.stream().map(mission -> new Tuple<>(mission, true)).collect(Collectors.toList()));
builder.addAll(this.finishedMissions.stream().map(mission -> new Tuple<>(mission, false)).collect(Collectors.toList()));
this.shadows = builder.build();
} else {
this.shadows = this.activeMissions.stream().map(mission -> new Tuple<>(mission, true)).collect(Collectors.toList());
}
return true;
}
}
buttonX = this.leftPos + 148;
buttonY = this.topPos + 38;
if (x >= buttonX && x < buttonX + 12 && y >= buttonY && y < buttonY + 108) {
this.scrolling = true;
}
return super.mouseClicked(x, y, button);
}

@Override
public boolean mouseDragged(double fromX, double fromY, int activeButton, double toX, double toY) {
if (this.scrolling && this.isScrollBarActive()) {
int i = this.topPos + 14;
int j = i + 54;
this.scrollOffs = ((float)fromY - (float)i - 7.5F) / ((float)(j - i) - 15.0F);
this.scrollOffs = Mth.clamp(this.scrollOffs, 0.0F, 1.0F);
this.beginIndex = (int)(this.scrollOffs * this.getScreenTotalScrollRows());
return true;
}
return super.mouseDragged(fromX, fromY, activeButton, toX, toY);
}

@Override
public boolean mouseScrolled(double x, double y, double delta) {
if (this.isScrollBarActive()) {
int totalRows = this.getScreenTotalScrollRows();
float f = (float)delta / totalRows;
this.scrollOffs = Mth.clamp(this.scrollOffs - f, 0.0F, 1.0F);
this.beginIndex = (int)(this.scrollOffs * totalRows);
}

return true;
}

private boolean isScrollBarActive() {
return this.shadows.size() > MAX_MISSIONS_PER_SCREEN;
}

private int getScreenTotalScrollRows() {
return this.shadows.size() - 5;
}
}

这里UI右上方有一个按钮,用来决定客户端玩家是否查看自己已完成的任务。这是唯一需要额外接收请求的部分。对于已完成与否的任务,要显示不同的提示,由于比较简单,不做额外讲解了。

总结

这样,我们成功地把整个任务与对话系统搬到了Minecraft中,而且支持数据包作者和拓展模组开发者们添加任务,可以说实现地非常完美了。下一部分打算讲轻松点的,主要是模组世界生成中添加结构的方法。