深度剖析Minecraft #2 方块更新

2 方块更新

感谢 迟昫123qwrrdshfsghv 的捉虫

2.1 方块更新性质

2.1.1 方块更新的种类

1.13 之后,方块更新分为了两种类型,分别是 NeighborChangedPostPlacement,简称 NC 与 PP 更新。若要与 1.13 之前相比,可将 1.13 之前的所有方块更新都视为 NC 更新。下文将用方块更新一词指代 NC 更新,用状态更新一词指代 PP 更新

这两种方块更新的类型在官方的反混淆表(来自 1.14.4)中的名称分别为 neighborChanged 与 updateShape,其命名也能有助于理解上述对两类方块更新具体含义

2.1.1.1 NeighborChanged

NeighborChanged 更新,也即方块更新,指的是最基础的,最符合“方块更新”一次含义的更新。红石元件的状态变化、方块的放置与破坏、方块开始移动以及方块到位都可以产生方块更新。除此之外,各种杂七杂八地方块变化大多也都能产生方块更新

游戏里能产生方块更新的事件太多了,不便于一一列举。不过能响应方块更新的却不算多。所有能响应方块更新的事件有:

  • 活板门、栅栏门、木门、铁门更新开关状态
  • 霜冰检测融化
  • 活塞检测移动
  • 活塞头给予活塞底座方块更新
  • 红石粉、中继器、比较器、红石火把、各类铁轨、命令方块、投掷器、发射器、音符盒、红石灯、TNT更新状态
  • 水、岩浆检测状态
  • 灵魂沙、岩浆块添加生成气泡柱的 TT 事件
  • 海绵尝试吸水

NC更新方块

对,就这些。不过,根据日常经验来看,有不少需要方块更新的事件却不在这。不要着急,他们在 PostPlacement 更新中

有此可见,各类红石元件处于 bud 态时,如无信号源的伸出活塞,不点燃的 TNT 等,需要的是方块更新,才能让它们意识到状态改变而恢复到正常的状态

2.1.1.2 PostPlacement

PostPlacement,也即状态更新,指的是方块发生变化后导致的临近方块与之交互情况发生变化的更新。

所有能响应状态更新的事件有:

  • 各类依附性方块(火把、雪片地毯蛋糕、各类花草作物、拉杆按钮、木门铁门、火焰等)判断依附的方块是否合法并决定是否掉落
  • 连接型方块(栅栏石墙玻璃板、楼梯、箱子、红石粉绊线、地狱门、高草紫颂植物等)更新当前与相邻方块连接状态
  • 中继器更新被锁状态
  • 音符盒更新乐器类型
  • 树叶更新离木距离
  • 混凝土粉末判定是否凝固
  • 水源与可含水的方块添加更新流体状态的 TT 事件
  • 侦测器添加发出信号的 TT 事件
  • 重力方块添加检测掉落的 TT 事件
  • 草径、耕地添加检测是否被压的 TT 事件
  • 仙人掌添加状态是否合法的 TT 事件
  • 活珊瑚添加检测是否离水的 TT 事件

PP更新方块

有一点值得注意,调用状态更新时是带有一个方向参数的,也就是状态更新是有着方向区别的,方向不对的状态更新在某些代码中存在特判情况下并不能影响方块的状态。比如,对于一个依附于西面方块上的浮空火把,从东南北上下放置破坏方块触发状态更新是无法让它掉落的。这一点也是 1.13+ 与 1.12- 的一个重要的区别。

2.2 方块更新的实现

先把会出现的名词列一遍:

  • 一个位置的方块(以下简称方块)受到了方块更新
  • 一个方块发出了方块更新
  • 一个方块发出了除 <方向> 外的方块更新
  • 一个方块受到了状态更新
  • 一个方块发出了状态更新

注1:<方向> 西 中的任意一者

注2:一个方块发出方块更新,也就是一个方块于其所在位置发出方块更新,或者说是一个位置发出了方块更新,由于指的都是一种事件并无歧义,都是可行的表述,可视上下文语境挑选合适者

2.2.1 一个方块受到了方块更新

一个方块受到了方块更新,将会调用位于这个方块的 neighborChanged 方法,并处理受到更新后的改变(见2.1.1.1 NeighborChanged)

具体代码见下 World 类中的 neighborChanged 方法

2.2.2 一个方块发出了方块更新

六毗邻方块

当一个方块发出方块更新时,这个方块将会使其毗邻的六个方块依次受到方块更新。这里的“依次受到”的顺序为:

  1. 西 -x
  2. 东 +x
  3. 下 -y
  4. 上 +y
  5. 北 -z
  6. 南 +z

具体代码见下 World 类中的 notifyNeighborsOfStateChange

2.2.3 一个方块发出了除 <方向> 外的方块更新

注:<方向> 西 中的任意一者

一个方块发出了除 <方向> 外的方块更新的表现,与上 2.2.2 并无太大区别区别仅为这次发出的方块更新将会跳过指定的一个方向,也就是只更新 5 个毗邻的方块

这种特殊的方块更新于中继器/比较器/侦测器在其指向方块发出更新时使用

五毗邻方块

具体代码见下 World 类中的 notifyNeighborsOfStateExcept 方法

2.2.4 一个方块受到了状态更新

一个方块受到了状态更新,将会调用位于这个方块的 updatePostPlacement 方法,并处理受到更新后的改变

具体代码见下 IBlockState 类中的 updatePostPlacement 方法(见 PostPlacement 小节)

2.2.5 一个方块发出了状态更新

六毗邻方块

当一个方块发出状态更新时,这个方块将会使其毗邻的六个方块依次受到状态更新。这里的“依次受到”的顺序为:

  1. 西 -x
  2. 东 +x
  3. 北 -z
  4. 南 +z
  5. 下 -y
  6. 上 +y

可以发现,PP 更新的更新顺序是 xzy 而非方块更新的 xyz。这一点是值得注意的

具体代码见下 Block 类中的 updateNeighbors 方法

2.3 瞎扯

方块与状态更新这两种更新概念非常类似容易混淆,我个人觉得可以这样理解:

  • 方块致一个方块状态改变的更新
  • PP 是因与周围方块交互,导致一个方块状态改变的更新

因为方块更新的大部分是属于红石元件的相应,而状态更新更偏向于相邻方块状态变化造成的响应。不过得具体情况具体分析不能一概而论。

可以发现上述部分我描述方块更新的具体例子时都是列举响应方块更新的实例,没有列举产生方块更新的事件。这是因为 MC 里能产生方块更新的地方实在是太多了。

1.13 把方块更新拆成两类的做法有些魔幻,不知道麻将是想优化游戏还是只是想乱改改赌一赌能不能修掉 TNT 复制。不过至少对于绝大部分电路中利用到方块更新的部分是没有影响的

麻将代码中还有些很迷惑的地方,比如同样是气泡柱产生源,除了放下方块时均会添加尝试生成气泡的 TT 事件外,灵魂沙只在受到方块更新时添加 TT 事件,而岩浆块只在受到上方的来自水的状态更新时添加 TT 事件

2.4 方块变化的实现

MC 里绝大部分对世界中方块的修改是通过 World 类的 setBlockState [1] 来实现的。以将位置 Pos 的方块从 A 修改至 B 的流程如下:

  1. 运算方块变化导致区段信息的改变
  2. 更新 Heightmap
  3. 调用 A 的 onReplaced 方法
  4. 更新天空光 5. 调用 B 的 onBlockAdded 方法
  5. 更新可能的新的方块实体
  6. 更新方块光
  7. 位于 Pos 的方块发出一次方块更新
  8. 若 B 能被比较器响应则更新附近的比较器
  9. 位于 Pos 的方块发出一次状态更新

其中 8.、9.、10. 在不同类型中的方块变化里是可选跳过的,具体实现是游戏在调用 setBlockState 的时候会传递一个 flags 参数,通过设置 flags 不同二进制位中的 0/1 来实现对修改完方块后触发各种方式各种类型方块更新的控制,也就是说 setBlockState 会产生的方块更新的种类与方式有着非常多种可能,因此一一列举是不太现实的。不过,经验告诉我们:

  • 方块的人工放置与破坏、音符盒与活塞的动作会先后产生方块更新与状态更新
  • 各种方块状态的改变如门的开关、栅栏的连接、中继器的激活会在其所在位置产生状态更新
  • 可强充能的红石信号源元件能产生大范围的方块更新
  • 等等

2.5 方块更新检测器 / 发生器

一个被 QC 激活的 bud 态活塞,即为一个方块更新检测器,这也是最常用的方块更新检测器。当活塞底座受到方块更新时,活塞将在下一个 BE 阶段开始伸缩一次

bud活塞

能响应状态更新并能多次使用的方块寥寥无几,做常用的就是侦测器了。当侦测器脸朝着的方块发出一个状态更新时,侦测器将会立即添加一个 TT 事件并在对应的 TT 阶段输出脉冲,因此侦测器是一个很棒的状态更新检测器。图里则是侦测器检测栅栏门开关的一个例子

侦测器

上面就是两种十分常用的方块更新检测器,当然能检测方块更新或状态更新的方式还有很多,这里就不一一叙述了。

至于方块更新发生器,能同时发出方块更新+状态更新的事件有很多,如方块的放置、音符盒的点击。他们都能同时使方块更新检测器与状态更新检测器响应

放置方块

如果要只发出方块更新,可以使用红石元件,如中继器。中继器在调节档位时会在其指向的方块的位置发出一个方块更新

中继器更新

还有一种可行的方法是打开箱子。对于一个箱子,当玩家打开它时它会发出一个方块更新

打开箱子

如果仅需发出状态更新,常用的方法则是开关栅栏门/活板门。当栅栏门/活板门开关时,它们仅会发出一个状态更新而无方块更新

打开栅栏门

2.6 即时更新元件

即时更新元件,指的是那些收到方块更新后立即在当前阶段改变其状态,并发出指定类型的更新(可能没有)的元件。它们被广泛地用于信号传递以及方块更新的传导

2.6.1 充能 / 激活铁轨

当充能 / 激活铁轨(以下以充能铁轨为例)受到方块更新时,它会先检测当前位置是否合法。若不合法,则掉落,否则执行铁轨的更新

在铁轨的更新中,首先铁轨会通过计算临近铁轨状态来判断当前的状态是否需要改变,具体怎么判断的这里就不多解释了。如果需要更新,铁轨会依次执行位于 BlockRailPowered 类的 updateState 方法中的以下内容:

1
2
3
4
5
6
7
worldIn.setBlockState(pos, state.with(POWERED, Boolean.valueOf(flag1)), 3);
worldIn.notifyNeighborsOfStateChange(pos.down(), this);

if (state.get(SHAPE).isAscending())
{
worldIn.notifyNeighborsOfStateChange(pos.up(), this);
}

也就是:

  1. 改变方块状态,在铁轨处依次发出方块与状态更新
  2. 方块更新发出方块更新
  3. 若铁轨是倾斜放置的,则在铁轨上方一格发出方块更新

不过,注意到,充能铁轨的 onReplaced 方法本身也有进行方块更新。对于充能铁轨而言,onReplaced 方法将依次执行:

  1. 若铁轨是倾斜放置的,则在铁轨上方一格发出方块更新
  2. 在铁轨自身位置发出方块更新
  3. 在铁轨下方一格发出方块更新

因此,合并起来,在充能铁轨状态改变时,将会依次执行这些方块更新:

  1. 若铁轨是倾斜放置的,则在铁轨上方一格发出方块更新
  2. 在铁轨自身位置发出方块更新
  3. 在铁轨下方一格发出方块更新
  4. 在铁轨自身位置发出方块更新
  5. 在铁轨自身位置发出状态更新
  6. 在铁轨下方一格发出方块更新
  7. 若铁轨是倾斜放置的,则在铁轨上方一格发出方块更新

别问我为什么要这么繁琐地更新,要问就问麻将

2.6.2 红石粉

红石粉方块几乎仅对方块更新进行响应,即使是判断自身位置是否合法也是通过方块更新进行的。对于状态更新,红石粉接受上方和四周的状态更新用来更新与相邻红石粉的被实体方块压线/连接状态

当红石粉受到方块更新时,它会计算不考虑自己时它能达到的信号强度,如果不同,则进行更新。红石粉将会在其位置发出方块更新,并在于其毗邻的 6 个方块的位置上发出方块更新,总共有 7 个位置将会发出方块更新。这七个位置的更新顺序并不是固定的,游戏会将这 7 个位置存入一个哈希表并导出成列表,随后逐个读取列表里的位置进行发出方块更新。至于为什么要用哈希表打乱,问麻将去¯_(ツ)_/¯

2.6.3 活板/栅栏/木门、漏斗

各类活板门、栅栏门、木门铁门,以及漏斗的机制,算是比较简单的。它们在由于玩家操作或者红石信号导致开关状态的改变时,会在所在位置立即触发一个状态更新,仅此而已。因此,它们并不能用来更新 bud 态的元件

2.6.4 音符盒

音符盒与各类门很相似,但却有不少不同。

  • 音符盒在被玩家右键调音时,或者在被红石信号激活/取消激活时,会立即在所在的位置先后触发方块更新与状态更新。这也是音符盒可以用于更新 bud 态的元件的原因
  • 音符盒在被玩家左键发音时,不会触发方块更新

音符盒进行上述三类操作(调音 / 发音 / 激活)后,会在可以发音时,即上方是空气方块时,计划一个 BlockEvent ,并在 BE 阶段发出对应的声音并生成音符粒子

2.6.5 依附性方块

绝大部分依附性方块,如火把、花草、红石粉、耕地上的作物,在受到来自其附着方块位置的 PP 或方块更新时,都会立即破坏并依据情况掉落。因此它们也可称为即时更新的元件

之所以说是绝大部分,是因为存在某些如紫颂花、仙人掌等的依附性方块是在 TileTick 阶段进行掉落的,这也给强制催熟的可能性打下了基础

2.6.6 半即时更新元件

这类元件仅在激活/触发时为即时更新,但在取消激活时是在 TileTick 阶段进行运算的。它们有:

  • 按钮
  • 压力板
  • 绊线
  • 绊线钩
  • 红石灯

2.7 更新抑制

一个方块发出方块的更新可以导致了另一个方块的更新,如果此时另一个方块也发出了方块更新,那么说不定可以再更新一个新的方块。如果有无限的方块排一排等着更新,如果给一它们一个方块更新,它们能一口气全部更新完吗?虽然实际上应该是可以的,但是基于 MC 的实现机制,答案是:不行

大面积的bud铁轨

在处理方块更新时,游戏内是简单地通过递归处理的,而最大的递归次数,是由 java 虚拟机的栈空间所决定的。栈空间不像堆空间,在默认条件下容量不大,这导致了当出现递归更新过量方块时栈空间将会耗尽,导致 java 抛出了栈溢出的异常(实际上栈溢出后可能抛出的是其他类型的异常,这里为了方便表述称其为栈溢出异常)

异常可是个很危险的东西。出现异常时,程序将会一直忽略之后的操作,一直往外跳出,直到异常被捕获。在大部分 MC 流程中,异常将会在最底层的 MinecraftServer 类的 run 方法中被捕获,随后游戏将生成崩溃报告并强行关闭服务器,也就是崩服,服务器崩溃了

不过,如果一个异常是在玩家操作阶段被玩家的动作触发,则这个异常将会在 Util 类的 runTask 方法中被捕获并作为一个 Fatal 输出,而非被 MinecraftServer 捕获,也就是说玩家动作引发的异常是不会引起服务端崩溃的。作为异常之一的栈溢出,如果是由玩家操作引起的,比如玩家打掉了一个接连插了几千个旗子的方块,或者玩家在大面积的 bud 态充能铁轨旁边放置了方块,虽会导致栈溢出,但都不会导致游戏崩溃

手放方块

由于栈溢出可以跳过在异常捕获前的所有运算,因此可以用此来跳过一些运算以实现普通游戏里不可能的操作,如跳过对特定方块的更新,从而制造各种悬空木门、贴在告示牌背面的告示牌、切片的地狱门;也可以跳过对玩家物品栏物品的操作,在不消耗工具耐久或者不消耗手中物品的情况下破坏/放置方块。这一类操作,我们统称为为更新抑制

更新抑制

关于更新抑制与可协助产生更新抑制的更新抑制器,Xcom6000 制作过一个非常详细的讲解视频,红石科技搬运组已在 bilibili 将其翻译成熟肉。推荐大家去观看学习。传送门

2.7.1 方块更新与深度优先搜索

方块受到更新,方块改变状态,方块发出更新,依次更新毗邻方块,新方块受到更新……这一层层递归的过程,本质上正是一个深度优先搜索,也就是 DFS(Depth First Search)。每当一个 bud 充能铁轨受到更新了,将会按照其发出方块更新的顺序,以及方块更新的更新顺序,往新的 bud 铁轨方块递归下去

关于深度优先搜索,或者 DFS,网络上相关的资料一搜一大把,这里就不再重复阐述了

使用更新抑制来阻断方块更新的一个难点,即是如何控制更新顺序,使方块更新在传递至需阻止更新的方块前就进入更新抑制器触发栈溢出。现在,方块更新传递的机制我们已探明(DFS),方块更新顺序我们也已清楚(方块/状态更新的更新顺序),只要了解清楚 DFS 的机制确定好更新顺序方向,就可以轻松的计算出来了


  1. net/minecraft/world/World.java:226 ↩︎