本篇教程的视频

本篇教程的源代码

GitHub地址:

本篇教程目标

  • 搞清维度要写哪两份数据
  • 用数据生成写维度类型,手写维度本体
  • 自己实现一个传送门:方块、框架检测、点燃、传送
  • 知道水平传送门、流体激活该怎么改

介绍

这是这个系列最后一期

维度就是一个独立的世界:自己的群系、自己的地形、自己的规则

做维度要干两件事

一是告诉游戏「有这么个维度」,这是纯数据

二是告诉玩家「怎么进去」,这就是传送门

传送门为什么要自己写

以前做传送门有现成的库,比如 CustomPortalAPI,几行代码就能建一个门,框架方块、激活物品、目的地都能指定

现在这个库跟不上了,我们没有能直接用的东西

所以这期自己写一个,照着原版下界传送门的思路抄一遍

其实原版的逻辑不复杂,核心就三块:一个门方块、一段框架检测、一个传送目的地

第一部分:维度

两份数据

dimension_type:这个维度是什么样。有没有天空光、有没有天花板、多高、床能不能睡、时间流不流

dimension:这个维度的地形从哪来。用哪个区块生成器、哪个群系

名字起得有点像,别混:前者是「属性」,后者是「内容」

维度类型

新建 ModDimensions,先写注册键

1
2
3
4
public static final ResourceKey<Level> ICE_ETHER_LEVEL =
ResourceKey.create(Registries.DIMENSION, id("ice_ether"));
public static final ResourceKey<DimensionType> ICE_ETHER_TYPE =
ResourceKey.create(Registries.DIMENSION_TYPE, id("ice_ether_type"));

第一个是运行时用来找世界的键,第二个是维度类型的键

然后 bootstrap

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
public static void bootstrapType(BootstrapContext<DimensionType> context) {
HolderGetter<Timeline> timelines = context.lookup(Registries.TIMELINE);
HolderGetter<WorldClock> clocks = context.lookup(Registries.WORLD_CLOCK);

EnvironmentAttributeMap attributes = EnvironmentAttributeMap.builder()
.set(EnvironmentAttributes.FOG_COLOR, 0xFFC0D8FF) // 雾色
.set(EnvironmentAttributes.SKY_COLOR, OverworldBiomes.calculateSkyColor(0.8F)) // 天空色
.set(EnvironmentAttributes.AMBIENT_LIGHT_COLOR, 0xFF0A0A0A)
.set(EnvironmentAttributes.CLOUD_COLOR, ARGB.white(0.8F))
.set(EnvironmentAttributes.CLOUD_HEIGHT, 192.33F) // 云层高度
.set(EnvironmentAttributes.BACKGROUND_MUSIC, BackgroundMusic.OVERWORLD)
.set(EnvironmentAttributes.BED_RULE, BedRule.CAN_SLEEP_WHEN_DARK)
.set(EnvironmentAttributes.RESPAWN_ANCHOR_WORKS, false)
.set(EnvironmentAttributes.NETHER_PORTAL_SPAWNS_PIGLINS, true)
.set(EnvironmentAttributes.AMBIENT_SOUNDS, AmbientSounds.LEGACY_CAVE_SETTINGS)
.build();

context.register(ICE_ETHER_TYPE, new DimensionType(
false, // 有没有固定时间
true, // 有没有天空光
false, // 有没有天花板(下界那种)
false, // 有没有末影龙战斗
1.0, // 坐标缩放(下界是 8)
-64, // 最小高度
384, // 总高度
384, // 逻辑高度
BlockTags.INFINIBURN_OVERWORLD, // 什么方块上火不会灭
0.0F, // 环境光
new DimensionType.MonsterSettings(UniformInt.of(0, 7), 0),
DimensionType.Skybox.OVERWORLD, // 天空盒
CardinalLighting.Type.DEFAULT, // 光照方向
attributes, // 环境属性
timelines.getOrThrow(TimelineTags.IN_OVERWORLD),
Optional.of(clocks.getOrThrow(WorldClocks.OVERWORLD))));
}

26.1 的 DimensionType 比老版本多了好几个参数,都是这套新系统带进来的

Skybox 决定抬头看到什么(主世界、下界、末地、没有)

CardinalLighting 是「按方向来的光照」,主世界默认,下界和末地是另一套

EnvironmentAttributeMap 就是上一期群系用的那个环境属性表

这里不能给空,得把主世界那套抄过来

这里踩过的一个坑:地平线以下发黑

第一版我图省事,直接塞了 EnvironmentAttributeMap.EMPTY

进维度一看,头顶的天空还行,但地平线以下是一片死黑,飞高了往下看很诡异

原因是天空渲染要靠环境属性里的 FOG_COLOR 来画远处

给它空,远处就没有雾色可混,直接按黑色画

主世界那份写得很清楚

1
2
3
4
.set(EnvironmentAttributes.FOG_COLOR, -4138753)          // #FFC0D8FF
.set(EnvironmentAttributes.SKY_COLOR, OverworldBiomes.calculateSkyColor(0.8F))
.set(EnvironmentAttributes.CLOUD_COLOR, ARGB.white(0.8F))
.set(EnvironmentAttributes.CLOUD_HEIGHT, 192.33F)

所以整段照抄过来就对了

顺便把 BED_RULE(能不能睡)、BACKGROUND_MUSIC(放什么音乐)、AMBIENT_SOUNDS(洞穴音效)也一起抄了,不然维度里睡觉会提示无法入睡、音乐也怪

EnvironmentAttributes 这些键在 26.1 是 minecraft:visual/fog_color 这种带斜杠的名字,看生成的 json 一眼就懂

timelines 和 defaultClock 是 26.1 新的「时间线 / 世界时钟」系统,我们直接借主世界的

三个高度为什么是 -64 / 384,而不是随手写的 0 / 256

因为维度本体里 settings 用的是 minecraft:overworld,那套噪声是照着 -64 到 320 设计的

维度类型的高度比它小,地形就会被从底下削掉一截,进去一看脚下全是深板岩

后面「维度本体」那节会再讲一遍

维度本体

维度本体是另一份 json,但它不能靠数据生成,只能手写

这份 json 的骨架很简单,难的是 biome_source:这个维度里放哪些群系、它们怎么分布

用可视化工具生成

手写那堆气候参数很容易出错,好在有个现成的生成器

打开 https://misode.github.io/dimension/

页面分三块

  • 左边是树形的参数编辑区,展开一层就是 json 里的一层
  • 右上是一大块可视化预览,能直接看到群系在世界里怎么分布
  • 右下是 Files,里面是它帮你生成好的文件,dimension/xxx.json 和 dimension_type/xxx.json 各一份

我们只要 dimension 那份,维度类型那一份我们自己用数据生成写

怎么填

先在 Generator 里选 minecraft:noise,就是用原版那套噪声地形

然后把 Biome source 选成 minecraft:multi_noise,也就是多重噪声,了解过地形生成的应该都知道这个噪声是什么东西

这时候右上角会变成一张群系分布图,可以拖动着看

接着在 biomes 里一条条加

biome 填 tutorial:ice_ether_forest,命名空间加注册名,写错的话世界生成会直接崩

下面那七个参数就是上一期讲过的气候维度

  • temperature 温度
  • humidity 湿度
  • continentalness 大陆性
  • erosion 侵蚀
  • weirdness 奇异度
  • depth 深度
  • offset 偏移

想再混几个原版群系进去,就继续加条目,比如丛林、平原、海洋

参数随便玩,改完看右上预览,觉得分布合理了就点 Files 里那份下载

我们生成出来的

结构大概是这样,完整的在我们仓库里

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
{
"type": "tutorial:ice_ether_type",
"generator": {
"type": "minecraft:noise",
"settings": "minecraft:overworld",
"biome_source": {
"type": "minecraft:multi_noise",
"biomes": [
{
"biome": "tutorial:ice_ether_forest",
"parameters": {
"temperature": 0.3,
"humidity": 0.5,
"continentalness": 0.1,
"erosion": 0,
"weirdness": 0,
"depth": 0,
"offset": 0
}
},
{ "biome": "minecraft:jungle", "parameters": { "..." } },
{ "biome": "minecraft:plains", "parameters": { "..." } },
{ "biome": "minecraft:ocean", "parameters": { "..." } },
{ "biome": "minecraft:forest", "parameters": { "..." } }
]
}
}
}

type 指向我们数据生成出来的维度类型

generator.type 用 minecraft:noise,就是原版那种噪声地形

settings 用主世界那套噪声设置,地形长什么样就看它

biome_source 里每一块都是一个参数点

游戏生成区块时,先算出这个位置的气候参数,然后在这堆点里找最近的那个,用它的群系

所以点给得越稀,边界就越硬,看起来一块一块像拼图

原版那张表有几万个点,密密麻麻铺满整个空间,主世界的群系才是连续过渡的

我们自己写几个点,能跑,但过渡会很生硬,这是正常的

别忘了改 type

下载下来的那份,type 是工具自己编的名字

我们这次就是直接下载没改,写成了 tutorial:test_type,进世界直接报维度类型找不到

要改成我们注册的那个:tutorial:ice_ether_type

这大概是这期最容易漏的一步,工具不知道我们代码里叫什么名字

只要一个群系的写法

如果你不想搞这么多参数,就想「整个维度都是冰以太森林」,用 fixed 更省事

1
2
3
4
"biome_source": {
"type": "minecraft:fixed",
"biome": "tutorial:ice_ether_forest"
}

fixed 就是整个世界一个群系,不参与气候计算,也不用填参数

multi_noise 和 fixed 二选一,看你要「混着来的世界」还是「纯一个群系的世界」

两种都行,我们仓库里现在挂的是 multi_noise 那版

高度得对得上

settings 用了 minecraft:overworld

那套噪声是照着 -64 到 320 设计的,海平面 63

进去一看:脚下一片深板岩,水面几乎贴着基岩,山上还少一截

所以维度类型里的三个高度,要和 settings 那套噪声对齐

我们把 ModDimensions 改成了 -64 / 384 / 384,跟主世界一模一样

数据生成

buildRegistry 里加一行

1
registryBuilder.add(Registries.DIMENSION_TYPE, ModDimensions::bootstrapType);

ModDynamicRegistryProvider 里加一行

1
entries.addAll(registries.lookupOrThrow(Registries.DIMENSION_TYPE));

跑完会生成

1
data/tutorial/dimension_type/ice_ether_type.json

维度的本体还是手写那份

第二部分:传送门

门方块

先看原版怎么做的:NetherPortalBlock

它 implements Portal,那个接口只有两个方法

getPortalDestination:算出去哪、从哪出来

getPortalTransitionTime:站多久才会被传走

剩下的事交给 entityInside:实体碰到门方块,就把自己标记成「在传送门里」,游戏每 tick 检查

我们的门方块照着写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class IceEtherPortalBlock extends Block implements Portal {
public static final MapCodec<IceEtherPortalBlock> CODEC = simpleCodec(IceEtherPortalBlock::new);
public static final EnumProperty<Direction.Axis> AXIS = BlockStateProperties.HORIZONTAL_AXIS;
private static final Map<Direction.Axis, VoxelShape> SHAPES = Shapes.rotateHorizontalAxis(Block.column(4.0, 16.0, 0.0, 16.0));

public IceEtherPortalBlock(Properties properties) {
super(properties);
this.registerDefaultState(this.stateDefinition.any().setValue(AXIS, Direction.Axis.X));
}

@Override
public MapCodec<IceEtherPortalBlock> codec() {
return CODEC;
}

@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(AXIS);
}

@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPES.get(state.getValue(AXIS));
}

@Override
protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity,
InsideBlockEffectApplier effectApplier, boolean isPrecise) {
if (entity.canUsePortal(false)) {
entity.setAsInsidePortal(this, pos);
}
}
}

AXIS 是固定的那一套,叫 HORIZONTAL_AXIS,只有 X 和 Z 两个值

它表示的是「门宽度沿着哪个轴」,不是「门朝哪边」

这个区别后面会单独讲,写反了门板就会跟框架垂直

getShape 给一个 4 像素厚的板,这样玩家是「站在门里」,不是「站在方块上」

entityInside 就是原版那两行,setAsInsidePortal 之后游戏会自己计时

然后是目的地

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Nullable
@Override
public TeleportTransition getPortalDestination(ServerLevel currentLevel, Entity entity, BlockPos portalEntryPos) {
ResourceKey<Level> targetKey = currentLevel.dimension().equals(ModDimensions.ICE_ETHER_LEVEL)
? Level.OVERWORLD
: ModDimensions.ICE_ETHER_LEVEL;
ServerLevel target = currentLevel.getServer().getLevel(targetKey);
if (target == null) {
return null;
}

BlockPos targetPos = entity.blockPosition();
BlockPos exit = IceEtherPortalShape.findExisting(target, targetPos, 16);
if (exit == null) {
BlockPos ground = target.getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, targetPos);
exit = IceEtherPortalShape.buildPortal(target, ground);
}

return new TeleportTransition(target, Vec3.atBottomCenterOf(exit), Vec3.ZERO,
entity.getYRot(), entity.getXRot(), TeleportTransition.PLAY_PORTAL_SOUND);
}

逻辑是:从主世界进就送去冰以太,从冰以太进就送回主世界

目标坐标直接取玩家当前位置,先在附近 16 格找找有没有现成的门,没有就在地表现搭一个

注意这里不能写成 new BlockPos(x, 0, z)

我一开始图省事把 y 写死成 0,结果 findExisting 的搜索范围变成 y ∈ [-16, 16],而门一般在 y=60 以上

于是永远找不到,每次传送回来都在原地重新搭一个门,跟原来那个叠在一起

这个 bug 很隐蔽:门确实「能用」,就是会越来越多

TeleportTransition 是 26.1 的叫法(老版本叫 DimensionTransition),带上目的地、坐标、朝向和落地后的回调

PLAY_PORTAL_SOUND 就是传送完放个音效,PLACE_PORTAL_TICKET 是让目标区块保持加载

框架检测

原版有个 PortalShape 类专门干这个,但它把框架方块写死成了黑曜石

1
private static final BlockBehaviour.StatePredicate FRAME = (state, level, pos) -> state.is(Blocks.OBSIDIAN);

这个字段是 private 的,我们改不了,所以只能自己写一份

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
public final class IceEtherPortalShape {
public static final int WIDTH = 2;
public static final int HEIGHT = 3;

public static boolean tryLight(ServerLevel level, BlockPos clickedPos) {
BlockPos base = clickedPos.above();

for (Direction widthDir : new Direction[]{Direction.SOUTH, Direction.EAST}) {
if (isValidFrame(level, base, widthDir)) {
fillPortal(level, base, widthDir);
return true;
}
}
return false;
}

private static void fillPortal(ServerLevel level, BlockPos base, Direction widthDir) {
BlockState portal = ModBlocks.ICE_ETHER_PORTAL.defaultBlockState()
.setValue(IceEtherPortalBlock.AXIS, widthDir.getAxis());

for (int w = 0; w < WIDTH; w++) {
for (int h = 0; h < HEIGHT; h++) {
level.setBlockAndUpdate(base.relative(widthDir, w).above(h), portal);
}
}
level.playSound(null, base, SoundEvents.FLINTANDSTEEL_USE, SoundSource.BLOCKS, 1.0F, 1.0F);
}
}

思路很朴素

被点的那格框架的上面一格,当作门内左下角

然后试两个方向:沿 Z 铺(SOUTH)、沿 X 铺(EAST)

只试两个就够了,因为 isValidFrame 两头都查了(-1 和 WIDTH 那两圈)

isValidFrame 检查两件事:内部 2×3 全是空气,四周一圈都是 ICE_ETHER_BLOCK

fillPortal 把内部六格替换成门方块,AXIS 直接取 widthDir.getAxis()

沿 Z 铺就填 Z,沿 X 铺就填 X,不用自己配对

原版支持任意尺寸的门,我们固定 2×3,这样代码短、也好讲

(顺带说一句:原版的门宽高范围是 2~21,它是一格一格探边界探出来的)

这里踩过的一个坑:门板歪了

第一版我把两个轴配错了,写成了这样

1
2
3
4
for (Direction.Axis axis : new Direction.Axis[]{Direction.Axis.X, Direction.Axis.Z}) {
Direction width = axis == Direction.Axis.X ? Direction.SOUTH : Direction.EAST;
// ...
}

axis = X 却沿 SOUTH(Z 轴)铺宽度

进游戏一看,框架立得好好的,门方块却是歪的,跟框架垂直

原因就是那个 AXIS 我一直把它当成「门朝哪边」

它其实是「门宽度沿着哪个轴」

对照原版的方块状态就清楚了

1
2
"axis=x": { "model": "minecraft:block/nether_portal_ns" },
"axis=z": { "model": "minecraft:block/nether_portal_ew" }

axis=x 用的是 _ns,也就是 north-south

因为宽度沿 X 的时候,门板是立在 X-Y 平面上的,脸朝着南北

所以填 AXIS 时,看的是「你说的那个方向铺出去的那条轴」,不是它的垂线

激活

用冰以太右键框架

1
2
3
4
5
6
7
8
9
10
11
12
13
public class IceEtherItem extends Item {
@Override
public InteractionResult useOn(UseOnContext context) {
if (!(context.getLevel() instanceof ServerLevel level)) {
return InteractionResult.SUCCESS;
}
if (IceEtherPortalShape.tryLight(level, context.getClickedPos())) {
level.playSound(null, context.getClickedPos(), SoundEvents.END_PORTAL_SPAWN, SoundSource.BLOCKS, 0.5F, 1.0F);
return InteractionResult.SUCCESS;
}
return super.useOn(context);
}
}

只在服务端处理,客户端给 SUCCESS 只是为了不让手上出现「用不了」的动作

把 ModItems.ICE_ETHER 换成 new IceEtherItem(...) 就行了

出口的搭建

buildPortal 就是反向作业:先摆框架,再填门方块

我们从地表开始,往上门搭一个 4 宽 × 5 高 的框(内部 2×3),然后点亮

显示

门方块没有自己的模型,这次直接借了原版下界门的

assets/tutorial/blockstates/ice_ether_portal.json

1
2
3
4
5
6
{
"variants": {
"axis=x": { "model": "minecraft:block/nether_portal_ns" },
"axis=z": { "model": "minecraft:block/nether_portal_ew" }
}
}

所以游戏里看起来是紫色的门

注意这份映射跟前面那个坑是同一件事:axis=x 配的是 _ns

写了 axis=z 却配 _ns,门板就转到别的平面上去了

想换成自己的颜色,就照着原版那两个模型做两份 json,换成自己的贴图(贴图要有透明通道)

还能怎么做

水平传送门

末地那种传送门是躺在地上的,其实它根本不能「点燃」

它的做法是:十二个末地框架摆成一个环,游戏自己检测到环完整,就在中间生成传送门方块

想仿的话,改动点有两处

一是「激活」不是右键,而是结构检测:在框架方块的 onPlace / neighborChanged 里判断周围一圈是不是凑齐了

二是目的地可以写死:末地传送门永远送你去固定的那个坐标,不需要像我们这样找出口、建出口

门方块本身还是 Portal 那一套,entityInside 和 getPortalDestination 一模一样

用流体激活

把 useOn 换成流体检测就行

思路是:在门内那个位置的下方放一个自己写的方块,覆写 onPlace 或者 neighborChanged,检查这个位置是不是变成了水或者岩浆(level.getFluidState(pos).is(FluidTags.WATER))

成立就调 IceEtherPortalShape.tryLight(...)

这样倒一桶水就能开门,适合做「献祭」「封印」这类玩法

测试

先在平地上搭一个框

外框 4 宽 × 5 高,用的是 ICE_ETHER_BLOCK,中间 2×3 留空

然后拿冰以太右键最下面那根框架

  • 中间六格应该变成传送门方块,是紫色的
  • 站上去等一秒,会被传走
  • 落在冰以太维度里,脚下是一个新建的门
  • 那边的天是浅蓝色,雾发白
  • 脚下这一片应该是冰以太森林,往外走走会碰到丛林、平原、海洋这些原版群系(multi_noise 是混着来的)
  • 走回去应该回到主世界你刚才站的地方附近
  • F3 看维度那一行,应该显示 tutorial:ice_ether
  • 往下挖,-64 到 320 这个高度段应该和主世界一样,有深板岩、有水、有山

想只看纯冰以太森林

把 dimension json 里的 biome_source 换成 fixed 那版,重新进世界

整个维度就只剩一个群系,方便截图和录视频

如果门没有亮,先检查内部是不是真的空的(有草、有雪都不行)

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

  • 右键没反应:内部不是 2×3 空气,或者框架缺一块;注意外框是 4×5
  • 亮了但站进去不传送:getPortalDestination 返回了 null,说明目标世界没拿到(维度 json 没生效)
  • 传送之后卡在方块里:出口位置没算好,我们的做法是取地表,如果你那边地形很怪就会卡
  • 门方块是竖着的、和框架垂直:AXIS 填错了,它要填「宽度方向的轴」,不是门的法线
  • 每次传送回来原地又多一个门:findExisting 的搜索中心 y 写死成 0,范围只剩 [-16, 16],改成 entity.blockPosition()
  • 维度里地平线以下一片黑:维度类型的 EnvironmentAttributeMap 给了空,缺 FOG_COLOR,抄主世界那套
  • 传送门是紫黑方块:blockstates/ice_ether_portal.json 里模型路径写错
  • 启动报 Registry minecraft:dimension is not loaded from datapacks:LEVEL_STEM 不能进 FabricDynamicRegistryProvider,维度本体手写 json
  • 维度能进但地形是空的:dimension json 里 generator 写错,或者 settings 指向了不存在的键
  • 进去就掉进虚空:维度类型的 min_y / height 和 settings 那套噪声没对齐,地形生成在范围外
  • 进世界直接报「找不到维度类型」:dimension json 里的 type 还是工具编的名字(test_type),要改成你自己注册的
  • 脚下全是深板岩、水面贴着基岩:维度类型的高度比 settings 小,地形被从底下削掉了一截
  • 群系边界像拼图、一块一块的:multi_noise 里参数点给太少,最近邻匹配就是这个效果,想连续过渡得铺满整张表
  • 想改门的颜色:换模型和贴图,贴图要有透明通道,不然是一块实心板
  • 想支持更大的门:IceEtherPortalShape 的 WIDTH / HEIGHT 是写死的常量,改成探测边界
  • 拆了框架门还在:我们没写「框架破了就销毁」的逻辑,原版是靠 updateShape 自己消失的,可以自己补

最后

到这期,这个系列就写完了

从一个空工程,到一个有方块、物品、配方、流体、世界生成、结构、群系、维度的模组

中间踩过的坑基本都写进文章了,剩下的就靠你自己往下挖

模组开发这东西,看懂原版怎么写的,基本就什么都会写了