ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

单物品承载7×10²²⁴⁴个变体:用NBT绕过Minecraft注册表限制

单物品承载7×10²²⁴⁴个变体:用NBT绕过Minecraft注册表限制 老实说刚看到“7×10²²⁴⁴”这个数字时我的第一反应是这一定是在玩数学梗而不是真的在写 Minecraft Mod。因为任何一个写过注册物品的开发都知道Minecraft 的物品注册维度是非常有限的别说 10²²⁴⁴就算注册一万个物品都能让游戏启动时间明显变长。但问题是如果不去注册 7×10²²⁴⁴ 个“物品对象”而是注册一个“物品类型”再用 NBT 去承载无限多的变体呢这才是这篇文章真正想聊的事。本文会从 Minecraft 的注册表机制说起解释为什么“物品数量”不等于“注册表条目数量”然后带你用单个物品加一段超长 NBT 的方式做出一个规模接近 7×10²²⁴⁴ 个变体的“物品系统”。整个过程不需要魔改核心类不需要绕过注册表只需要理解 ItemStack、NBT 和一点组合数学。看完之后你可以亲手在 Forge 1.20.1 工程里复现这套设计也可以把它直接迁移到你的自定义物品、自定义装备、自定义卡片或数据驱动道具系统中。1. 这篇文章真正要解决的问题很多 Mod 开发者第一次做自定义物品时会遇到一个灵魂拷问如果我想让一把剑有 100 种颜色、1000 种词条、10000 种等级是不是要注册 1.1 万个物品答案是千万不要。原因在于 Minecraft 的物品注册表是一个有限的对象空间。每个物品都要有唯一 ID、对应的模型、材质、分类、配方、战利值、渲染状态等。如果你真的去注册 7×10²²⁴⁴ 个物品游戏在启动阶段就会因为内存溢出、ID 撑爆、模型解析崩溃等问题直接失败。哪怕你只注册几万个物品也会带来肉眼可见的启动负担和存档兼容性问题。所以真正的解法不是“去注册很多物品”而是“让一个物品承载很多状态”。Minecraft 中同样的物品 ID可以通过 NBT 区分出完全不同的内容。例如minecraft:filled_map是一个物品但不同地图 ID 显示不同内容。minecraft:leather_helmet是一个物品但通过 NBT 可以染色成任意 RGB 颜色。minecraft:firework_rocket是一个物品但爆炸效果可以组合出大量花样。minecraft:player_head是一个物品但可以存放任意玩家的皮肤信息。这说明什么说明 Minecraft 本身就有“单注册类型 多状态变体”的能力。我们只是把它用到极致。本文要解决的问题就是如何设计成一个可以承载 7×10²²⁴⁴ 个变体的物品系统并且这个系统在服务器、客户端、存档、命令和合成链路上都能正常工作。2. 核心概念注册表与 NBT 变体的区别先做一个概念区分否则后面代码看不明白。2.1 注册物品Item注册物品是指通过DeferredRegister或类似机制向游戏注册中心提交的Item对象。注册之后它才拥有一个全局唯一 ID例如example:infinite_item。注册表决定了“这个世界的物品类型集合”。整个集合的大小是有限的。你不可能在一个有限列表里塞进一个指数级数量的对象。2.2 ItemStack物品堆ItemStack 是玩家背包、箱子、掉落物里真正存在的“一组物品”。它除了记录物品类型之外还包含堆叠数量耐久值如果可损坏NBT 数据也就是说物品类型相同不代表物品内容相同。NBT 才是区分“同一物品类型下不同个体”的关键。2.3 NBT 是什么NBT 是 Minecraft 的命名二进制标签格式类似一个树形 Map。它可以存放整数、字符串、列表、复合标签、字节数组等类型。所有 ItemStack 里的附加数据都是通过 NBT 保存的。关键点NBT 是动态的。你可以在运行时写入任意字段也可以读取任意字段。只要你的启动类、服务端和客户端读的是同一个 key它就能被正确解析。2.4 为什么传统 Mod 没有大量使用这种方式传统 Mod 里大部分物品是“静态类型”的一把铁剑材质固定攻击伤害固定。一个方块纹理固定硬度固定。一旦物品表现高度依赖数据NBT 变体就更有优势。比如许多 RPG Mod 的装备、武器、宝石系统内部本质就是“共享少数注册物品 大量随机 NBT”。因此理解这套思路不仅能写玩具 Mod也能理解许多大型 RPG 模组的架构。3. 7×10²²⁴⁴ 是怎么算出来的现在来解释标题里的规模。如果我们设计一个长度为 2244 的十进制数字串每一位可以取 0-9那么每一位有 10 种选择总共的组合数是10 × 10 × 10 × ...2244 次 10²²⁴⁴但标题要的是约 7×10²²⁴⁴。我们可以加一个 7 选 1 的前缀字段例如前缀字段范围0-6共 7 种。主体字段2244 位十进制数字串。总状态数7 × 10²²⁴⁴所以这套系统的完整“变体空间”可以写成前缀状态数 7 × 主体状态数 10²²⁴⁴ ≈ 7×10²²⁴⁴这个数字在 Java 中不需要真的枚举出来它只是一个理论计数。只要你的 NBT 字段能表达这个范围的任意一个值你的系统就拥有了这么大的状态空间。3.1 为什么不直接用二进制其实用二进制表示会省很多空间。7×10²²⁴⁴ 只需要约 7455 个二进制位也就是约 933 字节。你完全可以用 NBT 的 byte array 来存储这样比十进制字符串更节省空间。但用十进制字符串的好处是可读性好方便调试。和命令、聊天栏、文档展示更直观。便于切分成多个“维度字段”。本文的例子为了直观使用字符串形式的十进制 seed。真实项目中我更推荐在存储层转成 byte array 或 BigInteger配合固定长度前缀。4. 整体设计单注册物品 高维 NBT现在进入实现层面。我们不写一堆注册表代码而是采用以下设计4.1 设计目标只注册一个物品infinite_item这个物品的 NBT 包含一个 keyinfinite_seedinfinite_seed是一个十进制字符串字符串长度最大为 2244且必须全部是数字 0-9物品最大堆叠数设为 1避免同一槽位混入多个不同 seed客户端通过 seed 的哈希值计算颜色让不同 seed 在背包里显示不同色调4.2 为什么不设堆叠数 64假设一个槽位放 64 个物品每个物品的 NBT 不同那么物品堆叠合并逻辑会非常复杂自动合成和容器分类会把不同变体混在一起客户端渲染和服务器同步都可能出现不一致所以一般这类“唯一编号物品”会把getMaxStackSize设置为 1。这既保证数据正确也能在 UI 上明确传递“每个个体都是唯一”的语义。4.3 数据流这个系统里的数据流是服务端或命令生成一个 seed。seed 写入物品的 NBT。玩家手持、放入箱子、掉落时NBT 跟随 ItemStack 一起保存。客户端读取 NBT计算渲染颜色。玩家再把物品交给其他 Mod 处理时对方可以通过相同的 key 读取。重要提醒这套设计并不是“真注册了 7×10²²⁴⁴ 个物品”而是“把任意一个变体编码到了 NBT 中”。在外部系统看来所有变体都是同一个物品 ID但因为 NBT 不同它们可以被区分。这也是很多高版本 Mod 的常见做法不把数量堆在注册表而是把数量放在数据维度。5. 环境准备与开发工程本文示例基于 Forge 1.20.1。整体思路同样适用于 NeoForge、Fabric 或其他版本只是 API 类名和组织方式不同。5.1 环境要求JDK 17 或更高Minecraft 1.20.1Forge 1.20.1 对应版本Gradle版本细节以你的实际开发环境为准本文不锁死具体 Forge 版本号。5.2 准备一个 Forge 工程你可以使用 MDK 创建工程也可以直接复用已有的 Mod 开发工程。关键依赖是 Forge 的forgeGradle 插件// 文件路径build.gradle plugins { id java id net.minecraftforge.gradle version [6.0,6.2) } group com.example version 1.0.0 minecraft { // 具体 mappings 版本以你的 MDK 为准 } dependencies { minecraft net.minecraftforge:forge:1.20.1-47.x.x } java { toolchain.languageVersion JavaLanguageVersion.of(17) }5.3 工程目录结构核心代码放在src/main/java资源文件放在src/main/resourcessrc/main/java/com/example/infinite/ ├── InfiniteItemsMod.java └── InfiniteItem.java src/main/resources/ ├── assets/infinite/models/item/infinite_item.json ├── assets/infinite/textures/item/infinite_item.png └── data/这里的infinite_item.png可以先用一张白色底图代替后面客户端染色逻辑会基于模型染色。6. 核心代码实现下面给出一个可以直接放进去跑通的最小工程骨架。6.1 主类与物品注册// 文件路径src/main/java/com/example/infinite/InfiniteItemsMod.java package com.example.infinite; import net.minecraft.world.item.Item; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.eventbus.api.IEventBus; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.registries.DeferredRegister; import net.minecraftforge.registries.ForgeRegistries; import net.minecraftforge.registries.RegistryObject; Mod(InfiniteItemsMod.MODID) public class InfiniteItemsMod { public static final String MODID infinite; private static final DeferredRegisterItem ITEMS DeferredRegister.create(ForgeRegistries.ITEMS, MODID); public static final RegistryObjectItem INFINITE_ITEM ITEMS.register(infinite_item, () - new InfiniteItem(new Item.Properties())); public InfiniteItemsMod() { IEventBus modBus FMLJavaModLoadingContext.get().getModEventBus(); ITEMS.register(modBus); MinecraftForge.EVENT_BUS.register(this); } }这里只注册了一个物品 IDinfinite:infinite_item。所有后续的“变体”都不会新增注册表条目。6.2 物品类// 文件路径src/main/java/com/example/infinite/InfiniteItem.java package com.example.infinite; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.level.Level; import org.jetbrains.annotations.Nullable; import java.util.List; public class InfiniteItem extends Item { public static final String TAG_SEED infinite_seed; public static final int MAX_SEED_LENGTH 2244; public InfiniteItem(Properties properties) { super(properties); } public static void setSeed(ItemStack stack, String seed) { stack.getOrCreateTag().putString(TAG_SEED, seed); } public static String getSeed(ItemStack stack) { CompoundTag tag stack.getTag(); if (tag null || !tag.contains(TAG_SEED)) { return ; } return tag.getString(TAG_SEED); } public static boolean isValidSeed(String seed) { if (seed null || seed.isEmpty() || seed.length() MAX_SEED_LENGTH) { return false; } for (int i 0; i seed.length(); i) { char c seed.charAt(i); if (c 0 || c 9) { return false; } } return true; } Override public int getMaxStackSize(ItemStack stack) { return 1; } Override public boolean isEnchantable(ItemStack stack) { return false; } Override public void appendHoverText(ItemStack stack, Nullable Level level, ListComponent tooltip, TooltipFlag flag) { String seed getSeed(stack); if (seed.isEmpty()) { tooltip.add(Component.literal(未设置 seed)); return; } String preview seed.length() 64 ? seed.substring(0, 64) ... : seed; tooltip.add(Component.literal(seed: preview)); tooltip.add(Component.literal(seed length: seed.length())); } }这个类的核心逻辑很简单读写 NBT校验 seed 合法性限制堆叠数为 1并在 Tooltip 中展示当前 seed 的前 64 位。这里的infinite_seed字符串虽然不是一个 Java 整数但它本质上就是一个“十进制编码的变体 ID”。长度越长可表达的状态空间越大。6.3 客户端染色为了让不同 seed 在背包里肉眼可见地区别我们给这个物品注册一个颜色处理器// 文件路径src/main/java/com/example/infinite/InfiniteItemClient.java package com.example.infinite; import net.minecraft.world.item.ItemStack; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.client.event.RegisterColorHandlersEvent; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; Mod.EventBusSubscriber( modid InfiniteItemsMod.MODID, bus Mod.EventBusSubscriber.Bus.MOD, value Dist.CLIENT ) public class InfiniteItemClient { SubscribeEvent public static void onRegisterItemColors(RegisterColorHandlersEvent.Item event) { event.register((ItemStack stack, int tintIndex) - { String seed InfiniteItem.getSeed(stack); return colorFromSeed(seed); }, InfiniteItemsMod.INFINITE_ITEM.get()); } private static int colorFromSeed(String seed) { int color 0xFFFFFF; for (int i 0; i seed.length(); i) { color (color * 31 seed.charAt(i)) 0xFFFFFF; } return color; } }这个染色函数并不完美比如存在哈希碰撞但对于演示已经足够。它把任意 seed 映射成一个 RGB 颜色玩家可以看到不同 seed 呈现不同颜色。如果你希望显示更丰富比如根据 seed 的不同区段控制“纹理、外发光、特效、名称颜色”可以继续扩展。核心机制是一样的读取同一个 seed按位拆分计算展示参数。6.4 物品模型与贴图为了让染色生效需要给物品配置一个模型。底图建议用白色因为 Minecraft 的染色逻辑是“贴图颜色乘以 tint 颜色”。// 文件路径src/main/resources/assets/infinite/models/item/infinite_item.json { parent: minecraft:item/generated, textures: { layer0: infinite:item/infinite_item } }对应的贴图路径是src/main/resources/assets/infinite/textures/item/infinite_item.png我用一张白色底图即可。不同的 tint 颜色会在运行时叠加上去产生不同视觉结果。6.5 生成变体的命令进入游戏后不需要写额外 GUI直接用命令就能构造出任意变体/give p infinite:infinite_item{infinite_seed:0123456789ABCDEF...} 0注意infinite:infinite_item中的infinite是 MODIDinfinite_item是注册的物品 ID。如果要放一个比较短的 seed可以直接/give p infinite:infinite_item{infinite_seed:20240808000001} 1为了演示“最大空间”你可以用脚本生成一个长度 2244 的字符串再写进命令不过手动输入会很长。建议在开发阶段写一个专门生成随机 seed 的测试命令。7. 运行结果与效果验证7.1 启动客户端在 Gradle 工程目录下执行./gradlew runClient启动成功后进入存档打开聊天栏。7.2 生成物品并查看 Tooltip输入/give p infinite:infinite_item{infinite_seed:20240808001122} 1手持这个物品你会在 Tooltip 里看到类似seed: 20240808001122 seed length: 14如果物品显示为不可堆叠说明getMaxStackSize生效。7.3 验证 NBT 数据输入以下命令可以查看当前选中物品的完整 NBT/data get entity p SelectedItem输出中应该包含类似内容SelectedItem: {id:infinite:infinite_item, Count:1, tag:{infinite_seed:20240808001122}}只要能查看到infinite_seed就说明这个变体已经完整存在于物品数据中。7.4 如何验证“7×10²²⁴⁴ 规模”严格来说你无法在游戏中肉眼遍历所有变体。但你可以验证两件事任意符合规则的 seed 都能写进物品 NBT。两个不同 seed 的物品被视为不同个体且不会互相合并。只要这两个条件成立理论上这个系统就能覆盖前缀 7 × 主体 10²²⁴⁴ 的整个状态空间。8. 常见问题与排查思路问题现象可能原因排查方式解决方案物品在背包里无法堆叠getMaxStackSize返回 1这是预期设计检查物品类是否正确覆写如果希望同 seed 可堆叠需要自定义堆叠判断逻辑Tooltip 不显示 seedNBT key 写入和读取不一致使用/data get entity p SelectedItem查看实际 NBT检查 TAG_SEED 常量是否一致物品颜色不变化模型没有开启 tintindex或贴图不是白色查看物品模型和贴图在模型 layer 中声明 tintindex0并使用白色底图seed 过长导致命令无法输入聊天栏有输入长度限制使用函数文件或命令方块编写数据包函数或在开发环境中生成随机 seed存档体积变大每个物品都保存了长字符串 NBT查看具体 NBT 大小生产环境换成 byte array 或 BigInteger 压缩存储不同变体被合成系统混在一起大部分原版合成配方只判断物品类型不判断 NBT测试自定义配方行为需要自定义 Recipe Serializer 来判定 seed8.1 为什么不同变体可能被合成配方混在一起这是最容易被忽略的坑。原版很多配方使用Ingredient匹配物品类型。对于同一种物品 ID只要 NBT 满足配方要求就会被视为相同。这意味着如果你的infinite_item还能参与原版合成那么不同 seed 的变体可能被无差别消耗。如果你希望配方只接受某个特定 seed需要写自定义 recipe type 或自定义 ingredient。这也解释了为什么很多大型 Mod 的数值系统要自己接管合成逻辑而不是完全依赖原版配方表。9. 工程最佳实践与后续扩展9.1 存储层压缩建议在正式项目中使用 byte array 而不是超长字符串。把 7×10²²⁴⁴ 的数值转成二进制后NBT 大小大约只有 933 字节远小于 2244 位十进制字符串。这样能够明显降低存档体积和网络同步开销。示例思路// 存储层把 seed 字符串压缩为 BigInteger BigInteger value new BigInteger(seed, 10); byte[] bytes value.toByteArray(); tag.putByteArray(TAG_SEED_BYTES, bytes);读取时再通过new BigInteger(bytes).toString(10)还原。9.2 服务端校验不要在客户端信任输入。服务端收到物品数据时必须校验 seed 是否存在、是否为空、长度是否超限、是否只包含数字。生产环境必须校验seed 长度不超过 2244seed 不能包含非数字字符seed 不能为空前缀字段必须在 0-6 范围内否则恶意客户端可能塞入一个超长 NBT导致封包过大或解析耗时。9.3 防止 NBT 滥用Minecraft 的网络包和存档对 NBT 大小有一定容忍度但过大 NBT 依然会影响性能。不要在物品上同时存放大量无意义的 NBT 字段。如果这个系统要设计成“一个 seed 表示一把完整武器”建议把 seed 当作唯一 ID然后通过 ID 在服务端查找属性和能力表而不是把所有属性明文写在 NBT 里。9.4 客户端渲染扩展染色只是最基础的一步。更进阶的做法是使用自定义物品渲染器或动态模型根据 seed 的前 N 位决定武器模型根据 seed 的中间段决定粒子效果根据 seed 的尾部决定 Tooltip 数量这样同一个注册物品可以表现出完全不同的视觉内容同时不会增加注册表条目。9.5 与 JEI 的兼容JEI 无法自动枚举 7×10²²⁴⁴ 种变体。如果你希望 JEI 显示这个物品通常只能添加一个默认变体展示。添加一个“随机样例”展示插件展示几个典型 seed。把变体的合成方式作为查询入口而不是把每种变体都列出来。这是这类数据驱动物品的常见取舍状态空间太大外部系统只能展示采样不能全量枚举。9.6 多 Mod 协作时的注意事项如果其他 Mod 拿到你的物品读取infinite_seed时不一定会处理超长字符串或非法 seed。因此所有对外暴露的接口最好都经过统一封装而不是直接让其他 Mod 去解析 NBT。建议提供一个公开工具类例如InfiniteItem.getSeed(ItemStack)、InfiniteItem.isValidSeed(String)让协作方只调用方法不依赖内部字段名。10. 总结回到标题我们真的注册了 7×10²²⁴⁴ 个物品吗没有。我们只注册了一个物品但通过“前缀字段 7 × 主体 10²²⁴⁴ 的 NBT 编码”表达了一个规模约 7×10²²⁴⁴ 的状态空间。这个方案的核心不是去对抗注册表而是绕过注册表。如果你想把这套思路落地到自己的 Mod 中我建议按这个顺序做先做一个只注册一个物品的最小工程跑通 NBT 写入和 Tooltip 显示。加上客户端染色或简单模型变化让变体在视觉上可区分。再补服务端校验、压缩存储、自定义渲染和配方系统。这套架构的灵感并不只适用于 Minecraft Mod。任何一个“有限枚举类型 无限数据状态”的系统都可以用类似的思路设计把有限的分类交给枚举把无限的差异交给数据。如果你正在考虑做一个大型 Mod或者正在为一个物品系统设计数据结构我建议先不要急着写几千个物品注册代码试着停下来想一想你的系统里哪些部分应该属于“注册表”哪些部分应该属于“数据状态”。把这个问题想清楚你的 Mod 后续扩展会舒服得多。
返回列表