基础部分
进阶部分
在 Minecraft 中,我们常常见到许多红石元件在被触发后并不是立即变化的,例如中继器、比较器。这些元件总是在被触发后延迟一段时间,再发生变化。
让我们以一个生活中的例子来看。上午,你收到了一封邮件,于是你计划下午去处理这封邮件,然后定了一个闹钟。到了下午,闹钟响了,你想到自己要处理邮件了,于是打开邮箱开始处理邮件。
必须要注意的是:这个 “闹钟” 并没有任何 “文字注释”。它只负责在你的手机中、在正确的时间、提醒你一个人 “有事情”。至于具体是什么事情,闹钟并不关心。 这个闹钟包含且只包含这些内容:
计划刻就是这样一个 “闹钟”。红石元件在被触发后,为自己添加了一个计划刻。当计划刻执行时,就像是闹钟响了,红石元件就变化了。
由计划刻控制行为的红石元件,也就称为计划刻元件
像上文中的闹钟一样,计划刻只包含这些内容,我们称之为一个计划刻的信息结构:
triggerTick:在何时执行,或称延迟多久后执行1subTickOrder:计划刻添加顺序priority:计划刻有多优先pos:执行的坐标type:哪一方块种类执行这个计划刻位置和方块种类都是易于理解的属性。毕竟,方块自己的计划刻不能由其他方块乱执行 (如果你恰好在方块执行计划刻前把它推走或破坏掉换成其他方块的话) 、也不能在世界里到处乱跑。
执行时间,指的是宏观时序上计划刻的执行时间,也就是计划刻应该在哪一 gt 执行。例如,一个 1 挡位的中继器被触发后添加 2gt 后的计划刻,2gt 后,该计划刻就会被执行。我们通常说的 “计划刻元件的延迟” 就是指计划刻在多少 gt 后执行。
子顺序,指的是相同时间内的计划刻添加顺序。例如,在同一 gt 内,中继器 A 先被触发,中继器 B 后被触发。那么,在子序列中,中继器 A 就在中继器 B 前面。
优先级,指的是计划刻的优先程度。优先级是一个 - 3~3 的整数2,其中数值越小,优先级越高。也就是说,在同一 gt 内,优先级为 - 3 的计划刻总是比优先级 - 2、-1、0 的计划刻更先执行。
通常在讨论优先级时,“优先级更高” 和 “优先级数值更低” 的意思是相同的。为了避免由 “优先” 这一概念和 “优先级数值” 造成的歧义,我们更推荐读者在向其他人说明时使用 “某一元件的计划刻更优先” 来表述。
同样的,就和上文中的闹钟一样:计划刻只是一个 “提醒方块有事要做” 的闹钟,它并不关心方块实际要做什么。一切执行计划刻时的行为都由方块自身控制,“执行计划刻的行为” 并不在 “计划刻的信息结构” 中。
而当我们说 “某一元件已存在一个计划刻时”,指的就是在当前位置、存在一个方块类型和当前方块相同的、且还没有被执行的计划刻。3
在前两篇中,我们已经学过了宏观时序的分析,并且认识了微时序。我们知道,宏观时序总是优于微时序的,计划刻也是同理。对于执行时间不同的计划刻,执行时间早的计划刻总是更先执行。对于执行时间相同的计划刻,优先级更优先的计划刻总是更先执行。对于优先级相同的计划刻,子序列更小的、也就是更早添加计划刻的总是更先执行。
所以,在比较计划刻执行顺序时,我们可以遵循这样的逻辑:
这就像比数字一样,宏观时序就是百位,优先级就是十位,添加顺序就是个位。
那么,让我们来看看实际的例子。
已知在图示情况下,比较器的优先级为 0,中继器的优先级为 - 1。比较器和中继器的延迟都为 2gt。
答案:
中继器和比较器统称为红石二极管或红石门(Redstone Gate)4
在刻与刻间时序中,我们已经初步认识了中继器和比较器。现在,让我们深入中继器的计划刻行为。
若中继器被锁定,则不会添加计划刻,也不会在执行计划刻时改变任何状态。 若中继器未被锁定,则具有以下行为:
添加计划刻行为:
中继器挡位*2 gt,除了下面这种情况:执行计划刻行为:
举个例子:
从表现上来看,举例来说,如果给一个 2 挡位中继器一个时长<=4gt5的信号,中继器的行为如下:
这一例子中包含了中继器的全部计划刻行为。
特殊的优先级变化:
-3。-2。-1。添加计划刻行为 :
2gt,除了:执行计划刻行为:
比较器在执行计划刻时,实际上只做了一件事情:
这里的输出能量等级指的就是比较器经过计算后得出的输出能量等级 (具体计算方法见后文)。而这里的更新包括了更新能量等级和发出更新两件事。并且对于比较器来说,从表观上可能会有输出能量等级不发生变化的情况,但实际上此时比较器依然 “更新” 了自己的输出能量等级。
比较器还有相对复杂一点的更新行为 ——
比较器发出更新的行为:
举例来说,就是:
这些行为在一些特殊的布线中可能会有所应用。如果读者对这部分的理解感到困难,可以暂时只记忆比较器最常规的用法 —— 即比较信号大小并判断是否输出、检测容器容量和减法模式,或者直接查表。
比较器输出能量等级计算
如果信号输入是红石粉或者其他比较器,则继承输入的能量。
如果是容器,请查看此部分内容比较器信号强度计算。
特别的,当同时有容器信号和红石信号输入时,比较器会根据情况优先选择不同的信号输入来计算输出, 这一现象也被称为容器屏蔽。
案例 (木桶中均没有物品):
特殊的优先级变化:
-10至此,我们已经可以分析上一章中的案例
读者可以自行分析一下下面这个类似的案例,在按下按钮后,比较器是否会亮起。
添加计划刻行为:
执行计划刻行为:
添加计划刻行为:
执行计划刻行为
应当熄灭 / 亮起:红石火把所附着的方块是否为实体方块且受到红石信号。
燃尽:如果红石火把在 60gt 内亮起了 8 次,那么自身燃尽。
红石火把燃尽后,你可以通过破坏再重新放置一个红石火把来让它 “亮起”,或者等到 160gt 后火把执行计划刻自行亮起。
通常不能。 让我们来简单分析一下:
侦测器和红石火把的优先级都是0。并且侦测器亮起时先发出 PP 更新,然后添加计划刻,最后发出 NC 更新。红石火把接受 NC 更新。这里的执行计划刻内的行为顺序和元件接受什么更新根本上影响了接下来的分析。所以假设一个侦测器对着一个红石火把附着的实体方块,它的计划刻顺序如下:
2gt 后:
所以概括来说就是:红石火把在要亮起的时候发现没充能了,遂开摆)
但是,到这里我们就可以发现一个问题:假如我放置两个侦测器,其中一个侦测器用来 “触发” 红石火把添加计划刻,另一个用来 “延续” 红石信号来熄灭火把,那么红石火把是不是就可以亮起了呢?没错。一个简易的结构如下图所示:
在本部分的说明中,我们会省略一些已被声明但未被调用或极少涉及的变量。
为了提高读者在阅读计划刻执行过程中的流畅度,我们会先解释所有涉及的重要概念和方法,最后再解释计划刻的执行逻辑。在阅读时,你可以先初步了解这些概念和方法,再在理解计划执行逻辑遇到困难时回看查阅。
整个世界的计划刻在实际执行时都是由一个世界层计划器(WorldTickScheduler,或称世界层计划刻调度器)8控制的;每个位置的计划刻在添加与存储时都由对应区块的一个区块层计划器(ChunkTickScheduler,或称区块层计划刻调度器)控制。
一个包含了计划刻的所有信息的对象可称为计划刻对象。计划刻类OrderedTick则定义了计划刻对象的属性和方法。它除了具有方块类型type、坐标pos,执行时间triggerTick、优先级priority、子顺序subTickOrder这五个基本属性外,还实现了以下四个方法:
先比较triggerTick大小;若相同,则比较priority大小;若仍然相同,则比较subTickOrder大小。
此方法实现了区块层计划器中的优先级队列中的 “优先级” 比较(见下文)。
不比较triggerTick大小。仅先比较priority大小;若相同,则比较subTickOrder大小。
此方法实现了世界层计划器中的区块层计划器优先级队列中的 “优先级” 比较(见下文)
对type和pos的哈希码进行运算后得到该计划刻对象的哈希码:
在 Java 中,所有的哈希码都是整数。
除了常规的实例化方法外,计划刻类还实现了一个create方法,用于只基于type和pos临时创建一个计划刻对象。它的其他三个属性都为0。这一临时计划刻对象用于判断某一位置是否存在某一方块的计划刻。
区块层计划器管理计划刻的添加、“检查”与存储。它主要维护了一个优先级队列(PriorityQueue)和一个哈希集(ObjectOpenCustomHashSet)
尝试向哈希集中添加哈希化后的计划刻,若添加成功(即集合中不存在该计划刻),则同时向优先级队列中添加该计划刻的计划刻对象。
所有计划刻的哈希码都存储在对应区块的区块层计划器的哈希表中,所有计划刻的计划刻对象则按顺序存储在对应区块的区块层计划器的优先级队列中。
优先级队列的排序依据触发时间比较,即它将同时考虑triggerTick、priority和subTickOrder。
peekNextTick或称 “访问头部计划刻”,此方法会返回当前区块下一个执行的计划刻的计划刻对象,即优先级队列中的第一个计划刻
pollNextTick或称 “取出头部计划刻”。此方法会尝试取出当前区块下一个执行的计划刻的计划刻对象,若取出成功,则同时删除哈希表中该计划刻对象的哈希码。
世界层计划器管理计划刻的 “检查” 和执行。在计划刻的执行阶段,它统筹管理所有区块的区块层计划器,并按一定逻辑按顺序取出计划刻放入可执行计划刻列表中,最后一并执行。
详见执行计划刻。
此方法即我们常说的 “某元件自身不存在计划刻” 或 “某元件当前位置不存在自身计划刻”,显而易见的,它描述了两个核心参数:“某原件”—— 方块类型type,“自身”“当前位置”—— 坐标pos。
即上文中描述的添加计划刻方法。向哈希集中添加哈希化后的计划刻实际上就是一种 “检查”:如果哈希集中已经存在位于某一坐标的某一方块的计划刻,此时这一方块再尝试添加位于该处的计划刻时就会失败。
isTicking此方法用于检查元件自身是否会在当前 gt 执行计划刻。
它接收方块类型和坐标两个参数,先复制一份当前 gt 将执行的计划刻的队列,然后检查这一队列中是否存在方块类型、坐标都相同的计划刻。
在执行计划刻时,当前 tick 的所有可执行的计划刻(如果没有达到计划刻上限的话)都会被从各个区块层计划器的哈希集和优先级队列中取出(详见下文执行计划刻),也就是说 —— 如果元件将在这一 gt 执行计划刻,此时又恰好在执行前尝试添加计划刻,那么这一添加行为就无法被方法 1“拦截”。
中继器、比较器、红石火把等元件在添加计划刻时就会使用此方法检查计划刻是否 “将要执行”。以中继器为例,由于此方法的作用,假如中继器在第 2gt 时将亮起,那么在第 2gt 时、中继器亮起前,由于中继器检查到自身将在当前 gt 执行计划刻,那么无论信号如何变化,它都不会添加计划刻;而在第 0gt 和第 1gt 时,如果中继器输入端的充能状态改变且中继器受到更新,那么它就会尝试添加计划刻,但由于方法 1 的 “检查”,这一添加行为不会成功。所以方法 1 和方法 2 共同保证了中继器不会在执行计划刻前重复添加计划刻。
isQueued此方法是侦测器所调用的的 “检查计划刻是否存在” 方法,其实际功能和方法 1 完全一致。
我真的不知道它除了可能能减少卡顿外有什么用。 ——tanh_Heng
它接收方块类型和坐标两个参数,先将坐标对应到区块,再访问该区块对应的区块层计划器,然后快速实例化一个具有对应type和pos的计划刻,最后检查哈希集中是否存在与这一临时计划刻的哈希码相同的计划刻。
显而易见的,与方法 2 的根本区别就在于此方法不会检查当前 gt 将要执行的计划刻。这是 4gt 侦测器高频的核心原理。
执行计划刻分为收集(collect)、执行(run)和清理(cleanup)三个阶段。其中最重要的是收集阶段。在收集阶段,世界层计划器将按如下步骤将计划刻添加至可执行计划刻列表(tickableTicks)中:
区块层计划器的头部计划刻:区块层计划器中,排在最前面的、将要最先执行的那一计划刻。
所有没有明确表述取出计划刻的描述都为访问方法。
达到了计划刻上限:游戏中每 gt 能够执行的计划刻数量是有限的,达到了计划刻上限即指可执行计划刻列表中计划刻的数量大于等于计划刻上限maxmaxTicks。
collectTickableChunkTickSchedulers扫描所有区块层计划器(负责管理单个区块内的计划刻)。只选择那些:
把这些符合条件的区块层计划器放入一个优先级队列中,称为可执行的区块层计划器的优先级队列(tickableChunkTickSchedulers,以下简称计划器队列)。
那么,这个优先级队列如何排序?队列根据每个区块层计划器的头部计划刻进行排序。排序使用简单比较(即与区块层计划刻的执行时间比较相比,简单比较不会考虑triggerTick属性)。这个排序机制是后续计划刻抑制器实现的关键。
collectNextTicks取出第一个计划器: 从计划器队列的最前面取出一个区块层计划器。我们称它为当前区块层计划器。
取出它的第一个计划刻: 从当前区块层计划器的计划刻队列中取出其头部计划刻。
标记为可执行: 把这个取出的计划刻添加到可执行计划刻列表中,等待实际执行。
尝试连续提取更多计划刻: 接下来,游戏会尝试从当前区块层计划器中连续地、尽可能多地取出后续的计划刻(只要满足特定条件):
上限检查: 如果已经达到了本次处理允许的计划刻上限,则停止整个连续提取过程。
访问队列下一个计划器: 访问当前计划器队列最前面的区块层计划器(因为第一个计划器已经被取走了,所以这是原本的队列中的第二个计划器)。我们称它为区块层计划器 2。
检查队列是否非空: 如果计划器队列不是空的(即成功访问到区块层计划器 2),那么访问区块层计划器 2 的头部计划刻。
开始连续取出: 如果还没达到计划刻上限,就循环执行下面的步骤 (5 和 6)。
查看当前计划器的下一个计划刻: 再次访问当前区块层计划器的计划刻队列,获取当前它最前面的计划刻(和步骤 2 同理,因为第一个计划刻已经被取走了,所以这是排在原先的头部计划刻的后面的那一个计划刻)。
决定是否取出 (核心条件):
如果当前区块层计划器中确实还有计划刻(成功访问到其当前头部计划刻),
并且这个计划刻也需要在当前游戏刻(gt)执行,
并且使用简单比较规则判断,当前区块层计划器 的这个计划刻的执行时间早于区块层计划器 2 的头部计划刻的执行时间,
那么就把当前区块层计划器的这个计划刻也取出来,加入到可执行计划刻列表中。
如果以上任何一个条件不满足(没有计划刻了、不是当前 gt 执行、或者不比队列里下一个计划器的计划刻早),就退出这个连续提取的循环,即不再尝试连续提取更多计划刻。
处理未完成计划器: 当步骤 4 的连续提取循环结束后,意味着暂时无法再从当前区块层计划器中连续取出更多符合条件的计划刻了。此时检查:
如果当前区块层计划器里还有未被取出的计划刻(队列没空),
并且它现在队列最前面的计划刻(新的头部计划刻)仍然需要在当前游戏刻(gt)执行,
并且整个处理过程还没有达到计划刻上限,
那么就把当前区块层计划器重新加入到计划器队列中(根据其新头部计划刻的执行优先级重新排序),等待后续再次处理。
循环以上步骤(步骤 1 至步骤 5),直到达到计划刻上限或计划器队列被取空。
概括来讲,上述步骤可以这样描述:
游戏首先筛选出所有在当前 gt 有计划刻要执行、且区块已加载的区块层计划器,并按它们最优先计划刻(头部计划刻)的简单比较顺序排队。然后,它从队头取一个区块层计划器,将它最优先的计划刻加入准备执行的队列中,并尝试从这个计划器里 “连续” 提取更多同样在当前 gt 执行的、且比队列里下一个计划器最优先的计划刻还要优先的任务来一起加入准备执行队列中(目的是提高效率)。如果连续提取被打断(比如下一个计划刻不够优先、或者达到执行上限),而这个区块还有当前 gt 的计划刻未被添加且未超上限,就把当前计划器按照头部计划刻的优先顺序放回计划器队列中,以便稍后继续处理它剩下的任务。
delayAllTicks将所有计划器队列中还没有被取出的区块层计划器加入一个区块坐标 -> 下一执行时间的映射表(nextTriggerTickByChunkPos)中,以待后续执行。
由执行计划刻的逻辑我们可以注意到:区块层计划器的排序是不考虑triggerTick的。也就是说,如果某个或某几个区块层计划器的头部计划刻优先级足够高、且该计划器中的计划刻数量足够多,就可以使世界层计划器不断地只从这几个计划器中取出计划刻直到达到计划刻上限,而不会取出其他计划器中的计划刻。并且这一抑制器只能对抑制器所在区块外的其他区块生效。
例如,如果在某几个区块以一定高频激活足够多的中继器,在其他区块的优先级为 0 的元件就会在高频期间被 “抑制”,如比较器不亮起、侦测器高频不工作,直到高频结束这些计划刻才会重新执行。
“延迟多久后执行” 并不严谨。实际上,执行时间记录的是世界刻,“延迟xgt” 就是执行时间的世界刻=当前世界刻+x。当前世界刻>=执行时间的世界刻时,计划刻执行。但这一区别并不影响大多数情况下的计划刻分析,区块卸载的情况除外。 ↩
但是目前并没有优先级为 1、2、3 的计划刻。它们只是在代码中被声明了。 ↩
由于后文说明需要,基础部分仅作简单表述。具体内容涉及计划刻队列的维护,参见进阶部分。 ↩
因为在源码中,它们继承自同一个抽象基类AbstractRedstoneGateBlock ↩
准确来说,应为 “在中继器执行计划刻(进行亮起)前熄灭的信号” ↩
由于计划刻的执行逻辑,在当前 gt 即将执行的计划刻不会视为 “存在计划刻”。也就是说,如果在某一 gt 侦测器将会亮起,而在侦测器亮起前对侦测器发出 PP 更新,此时侦测器也会添加计划刻。该逻辑是 4gt 侦测器高频的核心原理。具体行为和实例详见进阶部分。 ↩
此处熄灭通过WorldEvent(世界事件)控制,而非直接调用setblockstate。 ↩
本部分(3.4)所使用的这些译名均为编者自行拟定,目前尚未在其他相关文献中发现对应译法。如果读者有更好的译名或修改意见,请直接提交 issue 与我们联系。 ↩