User Tools

Site Tools


zh_cn:tutorial:persistent_states

状态持久化

引言

通常情况下,我们的模组中带有与玩家相关的信息,或是在玩家死亡、服务器重启时我们希望保留的世界信息。
现在我们举个例子,我们的模组记录着一个玩家挖掘了多少泥土方块:

int dirtBlocksBroken = 0;

相应地,模组同时记录了服务器侧总计被挖掘了多少泥土方块:

int totalDirtBlocksBroken = 0;

那么,我们如何能让 Fabric 保存这些信息,或者其他我们想保存的模组或游戏数据,以供下次玩家加载世界或重新登录时能读取到这些信息?

简单的消息发送——数据包

首先,既然我们的游戏保存在逻辑服务器上,那么我们就让服务器在检测到玩家挖掘泥土方块时向玩家发送一个数据包,并将其显示在玩家的聊天区内。
(注:即便是单人游戏也有一个逻辑服务器在运行。关于逻辑服务器的术语细节,请参见服务器和客户端术语一节。)

将模组内实现了 ModInitializer 的类做如下改动:

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "您的MOD_ID";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    private Integer totalDirtBlocksBroken = 0;
 
    @Override
    public void onInitialize() {
        // 注册一个方块挖掘事件监听器
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                // 当泥土方块被挖掘时增加计数
                totalDirtBlocksBroken += 1;
 
                // 向客户端发送数据包
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(totalDirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}
  • 注意:MOD_ID 务必是唯一的。开发者通常情况下在这里填入模组名称。您可以自由改动,但请注意您只能使用小写字母(a-z)和下划线(_),否则将会报错。
    • 我们使用 ServerPlayNetworking.send() 方法来发送数据包。方法需要三个参数:
      • ServerPlayerEntity:指定要哪个玩家的游戏客户端接收数据包;
      • Identifer:这是你发送数据包要使用到的通道名称。
      • PacketByteBuf:这是你要发送的数据包内容,您可以在里面放入任意数据。在本例中,我们将服务器侧被挖掘的泥土方块总数放入数据包中。

接下来,将实现了 ClientModInitializer 的类做如下改动:

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.text.Text; 
 
public class ExampleModClient implements ClientModInitializer {
 
    @Override
    public void onInitializeClient() {
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.DIRT_BROKEN, (client, handler, buf, responseSender) -> {
            int totalDirtBlocksBroken = buf.readInt();
            client.execute(() -> {
                client.player.sendMessage(Text.literal("总计被挖掘的泥土方块数:" + totalDirtBlocksBroken));
            });
        });
    }
}
  • 显然地,这个过程中我们所使用的通道名 Identifier 与服务器侧是严格一致的。由于我们使用了 writeInt() 方法写入数据,相应地,我们就要使用 buf.readInt() 方法来读取我们在 PackedByteBuf 中存入的数据。

现在,如果您运行模组并创建世界,您在挖掘泥土或草方块时就会收到消息提示。您每挖掘一次,数值就应当加一。您甚至会注意到即使退出世界重新进入,计数也停在您退出的那一刻。
但这其实是一种误判。如果您完全退出游戏(终止 Minecraft 实例)并重新打开,您会发现计数归零了。
那为什么计数器没有保持在原本的数值呢?

这是因为数值只是暂存于系统内存(RAM)中,并没有写入到本地文件。
那么,如果我们把上次游戏结束时的计数写入到了本地(如硬盘某处),我们就可以在游戏启动时读取并加载它,作为计数器的初始值。这样一来我们就可以继续统计下去。
但是,我们必须要做到这一点:数值要在游戏关闭时保存,在游戏启动时读取并加载。
想要达到这一目的,方法千千万,不过我们在这里使用 Minecraft 提供给我们的方法:实现一个继承了 PersistentState 的类。

状态持久化详述

首先,我们在项目目录新建一个名为 StateSaverAndLoader.java 的文件,它需要实现 ModInitializer 类:

import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.World;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
        return nbt;
    }
}

注:在继承 PersistentState 类时,必须实现 writeNbt。从功能上讲,我们通过 NbtCompound 将我们要存储到本地的数据进行打包。在本例中,我们将先前创建的 public Integer totalDirtBlocksBroken 移入了这个文件。

  • NbtCompound 不只是保存整数值,它还保存着其他类型的数据,甚至是其他的 NbtCompound 数据或者任意的字节数据。
  • 因此,如果您希望存储一个自定义类,那么您应当新建一个 NbtCompound 子对象,然后将您自定义类的相应字段打包进这个 NbtCompound 子对象中,再传入其父级 NbtCompound 对象。(我们正在为此努力!)

接下来,将这个方法添加到同一个文件中:

public class StateSaverAndLoader extends PersistentState {
 
    // ... (先前写好的代码部分)
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
        return state;
    }
}

这个方法的作用与 writeNbt 相反,它需要传入一个 NbtCompound (在 writeNbt 时写入的同一个对象),之后新建一个 StateSaverAndLoader 以供我们传入在 NbtCompound 中的数据。

  • 注:与 getInt 读取整数的方式类似,writeNbt 传递字符串的方式是一样的。

现在我们再添加一个工具类方法,这个方法需要导入 MinecraftServer.PersistentStateManagerPersistentStateManager 有一个名为 getOrCreate 的方法,其中 MOD_ID 作为字段传递给 StateSaverAndLoader 以确定是否有其实例,若没有则创建。
当创建时,其调用我们刚刚写到的 createFromNbt 并将我们已经存储在本地的 NbtCompound 传入进去。最后,这个方法会向给定的 MinecraftServer 返回 StateSaverAndLoader

public class StateSaverAndLoader extends PersistentState {
 
    // ... (先前写好的代码部分)
 
    private static Type<StateSaverAndLoader> type = new Type<>(
            StateSaverAndLoader::new, // 若不存在 'StateSaverAndLoader' 则创建
            StateSaverAndLoader::createFromNbt, // 若存在 'StateSaverAndLoader' NBT, 则调用 'createFromNbt' 传入参数
            null // 此处理论上应为 'DataFixTypes' 的枚举,但我们直接传递为空(null)也可以
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (注:如需在任意维度生效,请使用 'World.OVERWORLD' ,不要使用 'World.END' 或 'World.NETHER')
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // 当第一次调用了方法 'getOrCreate' 后,它会创建新的 'StateSaverAndLoader' 并将其存储于  'PersistentStateManager' 中。
        //  'getOrCreate' 的后续调用将本地的 'StateSaverAndLoader' NBT 传递给 'StateSaverAndLoader::createFromNbt'。
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // 若状态未标记为脏(dirty),当 Minecraft 关闭时, 'writeNbt' 不会被调用,相应地,没有数据会被保存。
        // 从技术上讲,只有在事实上发生数据变更时才应当将状态标记为脏(dirty)。
        // 但大多数开发者和模组作者会对他们的数据未能保存而感到困惑,所以不妨直接使用 'markDirty' 。
        // 另外,这只将对应的布尔值设定为 TRUE,代价是文件写入磁盘时模组的状态不会有任何改变。(这种情况非常少见)
        state.markDirty();
 
        return state;
    }
}

现在,您的 StateSaverAndLoader 文件应如下所示:

import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.World;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
        return nbt;
    }
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
        return state;
    }
 
    private static Type<StateSaverAndLoader> type = new Type<>(
            StateSaverAndLoader::new, // 若不存在 'StateSaverAndLoader' 则创建
            StateSaverAndLoader::createFromNbt, // 若存在 'StateSaverAndLoader' NBT, 则调用 'createFromNbt' 传入参数
            null // 此处理论上应为 'DataFixTypes' 的枚举,但我们直接传递为空(null)也可以
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (注:如需在任意维度生效,请使用 'World.OVERWORLD' ,不要使用 'World.END' 或 'World.NETHER')
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // 当第一次调用了方法 'getOrCreate' 后,它会创建新的 'StateSaverAndLoader' 并将其存储于  'PersistentStateManager' 中。
        //  'getOrCreate' 的后续调用将本地的 'StateSaverAndLoader' NBT 传递给 'StateSaverAndLoader::createFromNbt'。
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // 若状态未标记为脏(dirty),当 Minecraft 关闭时, 'writeNbt' 不会被调用,相应地,没有数据会被保存。
        // 从技术上讲,只有在事实上发生数据变更时才应当将状态标记为脏(dirty)。
        // 但大多数开发者和模组作者会对他们的数据未能保存而感到困惑,所以不妨直接使用 'markDirty' 。
        // 另外,这只将对应的布尔值设定为 TRUE,代价是文件写入磁盘时模组的状态不会有任何改变。(这种情况非常少见)
        state.markDirty();
 
        return state;
    }
}

相应地,您也需要将实现了 ModInitializer 的类做如下改动:

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "您的MOD_ID";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    @Override
    public void onInitialize() {
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                StateSaverAndLoader serverState = StateSaverAndLoader.getServerState(world.getServer());
                // 当泥土方块被挖掘时增加计数
                serverState.totalDirtBlocksBroken += 1;
 
                // 向客户端发送数据包
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(serverState.totalDirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}

如果您现在运行游戏,您会发现计数器正常递增,但现在即使您关闭游戏并重新启动,计数器也会从退出时的位置继续递增。

有一点您有可能会忽略,totalDirtBlocksBroken 不是玩家限定的,这意味着若这个模组运行在服务器侧,并同时有好几个玩家挖掘泥土方块,他们都会收到相同的统计数字。
这对我们模组中的特定类型数据是很好的,表明它们能正常工作。但更多时候,我们期望数据是玩家限定(因人而异)的。 正如我们开篇所提,如果我们想要保留任意玩家特定方块的挖掘数据,这时我们要怎么做?

因玩家而异的持久化状态

我们可以将我们所写的代码延伸一下,这样就可以存储每个玩家的特定数据了。

首先,新建一个名为 PlayerData.java 的类(需要和实现了 ModInitializer 的类位于同一层次的软件包中).

  • 特别提示: 我们会创建 HashMap 用以存储 PlayerData 数据,, 当客户端侧希望获知 PlayerData 时非常有效, 但是若在服务器侧如法炮制,客户端将无法获知任何数据,因为这些数据只存在于服务器上

如果您期望在客户端侧获知部分或全部的 PlayerData 数据,则必须使用数据包进行传递。(稍后我们会创建一个内联同步(INITIAL_SYNC)数据包以作演示).

class PlayerData {
    public int dirtBlocksBroken = 0;
}

为尽可能简明地说明问题,在示例代码中我们依然使用这个简单的例子,但您在实际应用中可以向 PlayerData 内放入您所期望使用的任意字段。

接下来,将 StateSaverAndLoader.java 类的头部做如下改动:

// ... (其他的引用包)
import java.util.HashMap;
import java.util.UUID;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    public HashMap<UUID, PlayerData> players = new HashMap<>();
 
    // ... (代码的剩余部分)
 
}

注:我们创建了一个关于 UUID 数据的 HashMap 并将其存储于 PlayerData 中。
Hashmap 即哈希表,简单而言,在本例中,您向表中给出一个特定的“键值”(key),表从 PlayerData 中返回对应“键值”的 UUID
我们使用 UUID进行记录的原因是每位连接到服务器的玩家的 UUID 必定是唯一的。这就使得我们得以区分不同玩家,并针对其返回相对应的数据。如对应玩家的数据不存在,则创建之。

接下来我们向 StateSaverAndLoader 类内添加一个方法,其接受一个传入参数 LivingEntity 并从我们的哈希表中返回对应的 PlayerData

public class StateSaverAndLoader extends PersistentState {
 
    // ... (先前写好的代码部分)
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // 根据 UUID 获取对应玩家的状态,如果没有该玩家的数据,就创建一个新的玩家状态。
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}
  • 若类 StateSaverAndLoader 从未处理过对应玩家的 UUID ,那么哈希表的返回值将会为空。这就是为何我们使用了 computeIfAbsent 方法,这样当该 UUID 未在表中存储时,模组会自动创建一个对应该玩家的新 PlayerData 并存入表中。我们使用 UUID 作为键值以便稍后从表中检索。

接下来将实现了 ModInitializer 的类再改动一下:

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "您的MOD_ID";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    @Override
    public void onInitialize() {
        // 注册一个方块挖掘事件监听器
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                StateSaverAndLoader serverState = StateSaverAndLoader.getServerState(world.getServer());
                // 当泥土方块被挖掘时增加计数
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // 向客户端发送数据包
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(serverState.totalDirtBlocksBroken);
                data.writeInt(playerState.dirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}

您同时需要将实现了 ClientModInitializer 的类做如下改动:

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.text.Text;
 
public class ExampleModClient implements ClientModInitializer {
 
    @Override
    public void onInitializeClient() {
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.DIRT_BROKEN, (client, handler, buf, responseSender) -> {
            int totalDirtBlocksBroken = buf.readInt();
            int playerSpecificDirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("总计被挖掘的泥土方块数:" + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("当前玩家挖掘的泥土方块数:" + playerSpecificDirtBlocksBroken));
            });
        });
    }
}

如果您现在运行客户端,看上去一切工作正常,但我们忘记了非常关键的一点:我们没有使用 writeNbtcreateFromNbt 方法来存储、加载我们的哈希表。

改动后的方法代码如下所示:

public class StateSaverAndLoader extends PersistentState {
 
    // ... (代码的剩余部分)
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
 
        NbtCompound playersNbt = new NbtCompound();
        players.forEach((uuid, playerData) -> {
            NbtCompound playerNbt = new NbtCompound();
 
            playerNbt.putInt("dirtBlocksBroken", playerData.dirtBlocksBroken);
 
            playersNbt.put(uuid.toString(), playerNbt);
        });
        nbt.put("players", playersNbt);
 
        return nbt;
    }
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
 
        NbtCompound playersNbt = tag.getCompound("players");
        playersNbt.getKeys().forEach(key -> {
            PlayerData playerData = new PlayerData();
 
            playerData.dirtBlocksBroken = playersNbt.getCompound(key).getInt("dirtBlocksBroken");
 
            UUID uuid = UUID.fromString(key);
            state.players.put(uuid, playerData);
        });
 
        return state;
    }
 
    // ... (代码的剩余部分)
 
}

最终的 StateSaverAndLoader 类代码如下所示:

import net.minecraft.entity.LivingEntity;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.World;
 
import java.util.HashMap;
import java.util.UUID;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    public HashMap<UUID, PlayerData> players = new HashMap<>();
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
 
        NbtCompound playersNbt = new NbtCompound();
        players.forEach((uuid, playerData) -> {
            NbtCompound playerNbt = new NbtCompound();
 
            playerNbt.putInt("dirtBlocksBroken", playerData.dirtBlocksBroken);
 
            playersNbt.put(uuid.toString(), playerNbt);
        });
        nbt.put("players", playersNbt);
 
        return nbt;
    }
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
 
        NbtCompound playersNbt = tag.getCompound("players");
        playersNbt.getKeys().forEach(key -> {
            PlayerData playerData = new PlayerData();
 
            playerData.dirtBlocksBroken = playersNbt.getCompound(key).getInt("dirtBlocksBroken");
 
            UUID uuid = UUID.fromString(key);
            state.players.put(uuid, playerData);
        });
 
        return state;
    }
 
    private static Type<StateSaverAndLoader> type = new Type<>(
            StateSaverAndLoader::new, // 若不存在 'StateSaverAndLoader' 则创建
            StateSaverAndLoader::createFromNbt, // 若存在 'StateSaverAndLoader' NBT, 则调用 'createFromNbt' 传入参数
            null // 此处理论上应为 'DataFixTypes' 的枚举,但我们直接传递为空(null)也可以
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (注:如需在任意维度生效,请使用 'World.OVERWORLD' ,不要使用 'World.END' 或 'World.NETHER')
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // 当第一次调用了方法 'getOrCreate' 后,它会创建新的 'StateSaverAndLoader' 并将其存储于  'PersistentStateManager' 中。
        //  'getOrCreate' 的后续调用将本地的 'StateSaverAndLoader' NBT 传递给 'StateSaverAndLoader::createFromNbt'。
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // 若状态未标记为脏(dirty),当 Minecraft 关闭时, 'writeNbt' 不会被调用,相应地,没有数据会被保存。
        // 从技术上讲,只有在事实上发生数据变更时才应当将状态标记为脏(dirty)。
        // 但大多数开发者和模组作者会对他们的数据未能保存而感到困惑,所以不妨直接使用 'markDirty' 。
        // 另外,这只将对应的布尔值设定为 TRUE,代价是文件写入磁盘时模组的状态不会有任何改变。(这种情况非常少见)
        state.markDirty();
 
        return state;
    }
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // 根据 UUID 获取对应玩家的状态,如果没有该玩家的数据,就创建一个新的玩家状态。
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}

接下来运行客户端,玩家特定的数据现在都可以被正确地保存了。

  • 注:每次您使用 Fabric 调试启动客户端时,您所分配到的 UUID 都是不一样的,所以您可能会认为代码没有正确工作,但这只是因为您使用的开发环境使然。(如果您在生产环境运行一个 Minecraft 服务器并使用 Authme,您就可以确认模组在按照我们的预期目的工作了。)

请谨记,只要您向 PlayerDataStateSaveAndLoader 添加了新的字段,您就需要将对应的字段调用 writeNbtcreateFromNbt 方法。如果您忘记了这一步,那么数据就无法被正确地保存到磁盘或从磁盘读取。

内联同步

那么,当玩家加入服务器时,他们应当收到与他们相关的部分或全部玩家数据(PlayerData),而这一点对我们的模组至关重要。
对于这一点,当玩家加入世界时,我们会向玩家发送一个用于内联同步(INITIAL_SYNC)的数据包,从而指示服务器发送该玩家的数据。

现在,我们将实现了ModInitializer 的类做如下改动:

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "您的MOD_ID";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    public static final Identifier INITIAL_SYNC = new Identifier(MOD_ID, "initial_sync");
 
    @Override
    public void onInitialize() {
        ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
            PlayerData playerState = StateSaverAndLoader.getPlayerState(handler.getPlayer());
            PacketByteBuf data = PacketByteBufs.create();
            data.writeInt(playerState.dirtBlocksBroken);
            server.execute(() -> {
                ServerPlayNetworking.send(handler.getPlayer(), INITIAL_SYNC, data);
            });
        });
 
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                StateSaverAndLoader serverState = StateSaverAndLoader.getServerState(world.getServer());
                // 当泥土方块被挖掘时增加计数
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // 向客户端发送数据包
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(serverState.totalDirtBlocksBroken);
                data.writeInt(playerState.dirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}

随后,再将实现了 ClientModInitializer 的类做如下改动:

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.text.Text;
 
public class ExampleModClient implements ClientModInitializer {
 
    public static PlayerData playerData = new PlayerData();
 
    @Override
    public void onInitializeClient() {
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.DIRT_BROKEN, (client, handler, buf, responseSender) -> {
            int totalDirtBlocksBroken = buf.readInt();
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("总计被挖掘的泥土方块数:" + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("当前玩家挖掘的泥土方块数:" + playerData.dirtBlocksBroken));
            });
        });
 
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.INITIAL_SYNC, (client, handler, buf, responseSender) -> {
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("初始化的当前玩家挖掘的泥土方块数:" + playerData.dirtBlocksBroken));
            });
        });
    }
}

这样一来,当您进入本地世界或服务器时,您就会看到有一条消息提示您已经挖掘了多少泥土方块。

  • 注①:我们在客户端侧所创建的 PlayerData 并非与服务器侧实时同步。我们只是从服务器侧接收并在客户端侧创建了相应副本,并在客户端侧刷新。由于其关键字为 public static,这意味着您可以在客户端的任意类中访问。
  • 注②:每次您使用 Fabric 调试启动客户端时,您所分配到的 UUID 都是不一样的,所以您可能会认为代码没有正确工作,但这只是因为您使用的开发环境使然。(如果您在生产环境运行一个 Minecraft 服务器并使用 Authme,您就可以确认模组在按照我们的预期目的工作了。)

更复杂的玩家数据

现在我们已经不能满足单一方块的统计需求了,我们来看另一个例子:如果我们的 PlayerData 数据更多(包含列表甚至前文所提到的哈希表),我们的 StateSaverAndLoader 类需要怎么编写?

假设我们的 PlayerData 是这样的:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
 
public class PlayerData {
    public int dirtBlocksBroken = 0;
 
    public HashMap<Integer, Integer> fatigue = new HashMap<>();
 
    public List<Integer> oldCravings = new ArrayList<>();
}

我们的 StateSaverAndLoader 如下所示:

import net.minecraft.entity.LivingEntity;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.World;
 
import java.util.HashMap;
import java.util.UUID;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    public HashMap<UUID, PlayerData> players = new HashMap<>();
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
 
        NbtCompound playersNbt = new NbtCompound();
        players.forEach((uuid, playerData) -> {
            NbtCompound playerNbt = new NbtCompound();
 
            playerNbt.putInt("dirtBlocksBroken", playerData.dirtBlocksBroken);
 
            playerNbt.putIntArray("oldCravings", playerData.oldCravings);
 
            NbtCompound fatigueTag = new NbtCompound();
            playerData.fatigue.forEach((foodID, fatigueAmount) -> fatigueTag.putInt(String.valueOf(foodID), fatigueAmount));
            playerNbt.put("fatigue", fatigueTag);
 
            playersNbt.put(uuid.toString(), playerNbt);
        });
        nbt.put("players", playersNbt);
 
        return nbt;
    }
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
 
        NbtCompound playersNbt = tag.getCompound("players");
        playersNbt.getKeys().forEach(key -> {
            PlayerData playerData = new PlayerData();
 
            playerData.dirtBlocksBroken = playersNbt.getCompound(key).getInt("dirtBlocksBroken");
 
            NbtCompound fatigueCompound = playersNbt.getCompound(key).getCompound("fatigue");
            fatigueCompound.getKeys().forEach(s -> {
                Integer foodID = Integer.valueOf(s);
                int fatigueAmount = fatigueCompound.getInt(s);
                playerData.fatigue.put(foodID, fatigueAmount);
            });
 
            for (int oldCravings : playersNbt.getCompound(key).getIntArray("oldCravings")) {
                playerData.oldCravings.add(oldCravings);
            }
 
            UUID uuid = UUID.fromString(key);
            state.players.put(uuid, playerData);
        });
 
        return state;
    }
 
    private static Type<StateSaverAndLoader> type = new Type<>(
            StateSaverAndLoader::new, // 若不存在 'StateSaverAndLoader' 则创建
            StateSaverAndLoader::createFromNbt, // 若存在 'StateSaverAndLoader' NBT, 则调用 'createFromNbt' 传入参数
            null // 此处理论上应为 'DataFixTypes' 的枚举,但我们直接传递为空(null)也可以
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (注:如需在任意维度生效,请使用 'World.OVERWORLD' ,不要使用 'World.END' 或 'World.NETHER')
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // 当第一次调用了方法 'getOrCreate' 后,它会创建新的 'StateSaverAndLoader' 并将其存储于  'PersistentStateManager' 中。
        //  'getOrCreate' 的后续调用将本地的 'StateSaverAndLoader' NBT 传递给 'StateSaverAndLoader::createFromNbt'。
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // 若状态未标记为脏(dirty),当 Minecraft 关闭时, 'writeNbt' 不会被调用,相应地,没有数据会被保存。
        // 从技术上讲,只有在事实上发生数据变更时才应当将状态标记为脏(dirty)。
        // 但大多数开发者和模组作者会对他们的数据未能保存而感到困惑,所以不妨直接使用 'markDirty' 。
        // 另外,这只将对应的布尔值设定为 TRUE,代价是文件写入磁盘时模组的状态不会有任何改变。(这种情况非常少见)
        state.markDirty();
 
        return state;
    }
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // 根据 UUID 获取对应玩家的状态,如果没有该玩家的数据,就创建一个新的玩家状态。
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}

对已经实现了 ClientModInitializerModInitializer 的类无需改动,但我们保留了其代码,这样一来您就可以直接复制粘贴,直观地体会状态持久化带来的效果。

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.text.Text;
 
public class ExampleModClient implements ClientModInitializer {
 
    public static PlayerData playerData = new PlayerData();
 
    @Override
    public void onInitializeClient() {
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.DIRT_BROKEN, (client, handler, buf, responseSender) -> {
            int totalDirtBlocksBroken = buf.readInt();
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("总计被挖掘的泥土方块数:" + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("当前玩家挖掘的泥土方块数:" + playerData.dirtBlocksBroken));
            });
        });
 
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.INITIAL_SYNC, (client, handler, buf, responseSender) -> {
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("初始化的当前玩家挖掘的泥土方块数:" + playerData.dirtBlocksBroken));
            });
        });
    }
}
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "您的MOD_ID";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    public static final Identifier INITIAL_SYNC = new Identifier(MOD_ID, "initial_sync");
 
    @Override
    public void onInitialize() {
        ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
            PlayerData playerState = StateSaverAndLoader.getPlayerState(handler.getPlayer());
            PacketByteBuf data = PacketByteBufs.create();
            data.writeInt(playerState.dirtBlocksBroken);
            server.execute(() -> {
                ServerPlayNetworking.send(handler.getPlayer(), INITIAL_SYNC, data);
            });
        });
 
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                StateSaverAndLoader serverState = StateSaverAndLoader.getServerState(world.getServer());
                // 当泥土方块被挖掘时增加计数
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // 向客户端发送数据包
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(serverState.totalDirtBlocksBroken);
                data.writeInt(playerState.dirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}
zh_cn/tutorial/persistent_states.txt · Last modified: 2023/12/23 02:39 by dreamuniverse