User Tools

Site Tools


zh_cn:tutorial:persistent_states

This is an old revision of the document!


状态持久化

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

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

Player Specific Persistent State

We can store player-specific data by extending what we already wrote.

First write a new class PlayerData.java (again placing it in the same folder as our class which implements ModInitializer).

  • Extremely important note: Since we'll be creating a HashMap which seemingly stores PlayerData's, it'll be tempting when you want to know some PlayerData client-side to use that hashmap, but, you won't be able to, because this data only exists on the server. If you want the client to see some or all the player's data. You'll have to send it through a packet. (We'll create an INITIAL_SYNC packet later which we will use to demonstrate this).
class PlayerData {
    public int dirtBlocksBroken = 0;
}

To simplify, we're just continuing with our simple example, but you could put any fields you'd like in the PlayerData (and it's expected that you will).

Next, we'll modify the top of our StateSaverAndLoader.java class as follows:

// ... (Previous imports)
import java.util.HashMap;
import java.util.UUID;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    public HashMap<UUID, PlayerData> players = new HashMap<>();
 
    // ... (Rest of the code)
 
}

Note: We create a HashMap of UUID's to PlayereData's. If you don't know what a hashmap does: you give them a 'key', in our case a UUID and they give you back something, in our case PlayerData. The reason we use UUID's is because every player that connects to our 'server' has a unique UUID that only they are associated with. This lets us differentiate between different players and lets us 'pull' the right data for them. (Or create it if it doesn't exist yet).

Let's add a utility function to StateSaverAndLoader which will take a LivingEntity and return the associated PlayerData in our 'HashMap'.

public class StateSaverAndLoader extends PersistentState {
 
    // ... (Previously written code)
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // Either get the player by the uuid, or we don't have data for him yet, make a new player state
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}
  • If our StateSaverAndLoader has never seen the UUID before then it makes sense that our HashMap won't return anything. That's why we use the convenience function computeIfAbsent so that in the case where that UUID doesn't exist in the hashmap yet, it will automatically create a new PlayerData and puts it in our hashmap, using that players specific UUID as the key so that it can be retrieved later.

Now update the class which implements ModInitializer as follows:

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 = "your_unique_mod_id_change_me_please";
 
    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());
                // Increment the amount of dirt blocks that have been broken
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // Send a packet to the client
                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);
                });
            }
        });
    }
}

You'll also have to modify the class which implements ClientModInitializer as follows:

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("Total dirt blocks broken: " + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("Player specific dirt blocks broken: " + playerSpecificDirtBlocksBroken));
            });
        });
    }
}

If you ran the client now, it would seem as if everything is working, but we are forgetting a crucial step: We haven't updated our writeNbt and createFromNbt to save and load our hashmap.

The updated functions are as follows:

public class StateSaverAndLoader extends PersistentState {
 
    // ... (Rest of code)
 
    @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;
    }
 
    // ... (Rest of code)
 
}

The final StateSaverAndLoader should be as follows:

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, // If there's no 'StateSaverAndLoader' yet create one
            StateSaverAndLoader::createFromNbt, // If there is a 'StateSaverAndLoader' NBT, parse it with 'createFromNbt'
            null // Supposed to be an 'DataFixTypes' enum, but we can just pass null
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (Note: arbitrary choice to use 'World.OVERWORLD' instead of 'World.END' or 'World.NETHER'.  Any work)
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // The first time the following 'getOrCreate' function is called, it creates a brand new 'StateSaverAndLoader' and
        // stores it inside the 'PersistentStateManager'. The subsequent calls to 'getOrCreate' pass in the saved
        // 'StateSaverAndLoader' NBT on disk to our function 'StateSaverAndLoader::createFromNbt'.
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // If state is not marked dirty, when Minecraft closes, 'writeNbt' won't be called and therefore nothing will be saved.
        // Technically it's 'cleaner' if you only mark state as dirty when there was actually a change, but the vast majority
        // of mod writers are just going to be confused when their data isn't being saved, and so it's best just to 'markDirty' for them.
        // Besides, it's literally just setting a bool to true, and the only time there's a 'cost' is when the file is written to disk when
        // there were no actual change to any of the mods state (INCREDIBLY RARE).
        state.markDirty();
 
        return state;
    }
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // Either get the player by the uuid, or we don't have data for him yet, make a new player state
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}

Running the client now, all our player-specific data is correctly saved.

  • Note: each time you restart the minecraft client with fabric, you're assigned a new UUID, so it may seem like it's not working, but that's just because of the developer environment. (If you run the fabric multiplayer server, 'Minecraft Server' and use authme, you could verify it does indeed work as it's supposed to.)

Just remember if you add new fields to PlayerData or StateSaveAndLoader you need to correctly do the work of writing and loading those fields in the writeNbt and createFromNbt functions always. If you forget this step, your data won't be properly saved or loaded from disk.

Initial Sync

What if it's important for our mod that as soon as a player joins they receive some or all the PlayerData associated with them? For this, we will crate a new packet INITIAL_SYNC which will send the player, their specific player data as soon as they join the world.

Modify your class which implements ModInitializer as follows:

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 = "your_unique_mod_id_change_me_please";
 
    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());
                // Increment the amount of dirt blocks that have been broken
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // Send a packet to the client
                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);
                });
            }
        });
    }
}

Then modify your class which implements ClientModInitializer as follows:

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("Total dirt blocks broken: " + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("Player specific dirt blocks broken: " + playerData.dirtBlocksBroken));
            });
        });
 
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.INITIAL_SYNC, (client, handler, buf, responseSender) -> {
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("Initial specific dirt blocks broken: " + playerData.dirtBlocksBroken));
            });
        });
    }
}

As soon as you join the world/server you should see a message popup telling you the amount of dirt blocks you've specifically broken.

  • Note: The playerData we created isn't the up-to-date one that lives on the server. We simply create our own copy of PlayerData client-side and update it as we receive packets. Since it's public static that means you can access it from anywhere on the client.
  • Note: each time you restart the minecraft client with fabric, you're assigned a new UUID, so it may seem like it's not working, but that's just because of the developer environment. (If you run the fabric multiplayer server, 'Minecraft Server' and use authme, you could verify it does indeed work as it's supposed to.)

More Involved Player Data

And just for good measure, let's see an example of how our StateSaverAndLoader class would look if our PlayerData has more than primitives, like lists, and even its own hashmap. How would that look?

Let's say this is our 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<>();
}

This would be our 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, // If there's no 'StateSaverAndLoader' yet create one
            StateSaverAndLoader::createFromNbt, // If there is a 'StateSaverAndLoader' NBT, parse it with 'createFromNbt'
            null // Supposed to be an 'DataFixTypes' enum, but we can just pass null
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (Note: arbitrary choice to use 'World.OVERWORLD' instead of 'World.END' or 'World.NETHER'.  Any work)
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // The first time the following 'getOrCreate' function is called, it creates a brand new 'StateSaverAndLoader' and
        // stores it inside the 'PersistentStateManager'. The subsequent calls to 'getOrCreate' pass in the saved
        // 'StateSaverAndLoader' NBT on disk to our function 'StateSaverAndLoader::createFromNbt'.
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // If state is not marked dirty, when Minecraft closes, 'writeNbt' won't be called and therefore nothing will be saved.
        // Technically it's 'cleaner' if you only mark state as dirty when there was actually a change, but the vast majority
        // of mod writers are just going to be confused when their data isn't being saved, and so it's best just to 'markDirty' for them.
        // Besides, it's literally just setting a bool to true, and the only time there's a 'cost' is when the file is written to disk when
        // there were no actual change to any of the mods state (INCREDIBLY RARE).
        state.markDirty();
 
        return state;
    }
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // Either get the player by the uuid, or we don't have data for him yet, make a new player state
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}

Our classes which implement ClientModInitializer and ModInitializer would be the same as before, but we include them here so that this section of the article is easy to copy and paste into you program and experiment with persistent state.

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("Total dirt blocks broken: " + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("Player specific dirt blocks broken: " + playerData.dirtBlocksBroken));
            });
        });
 
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.INITIAL_SYNC, (client, handler, buf, responseSender) -> {
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("Initial specific dirt blocks broken: " + 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 = "your_unique_mod_id_change_me_please";
 
    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());
                // Increment the amount of dirt blocks that have been broken
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // Send a packet to the client
                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.1702781538.txt.gz · Last modified: 2023/12/17 02:52 by dreamuniverse