##02 活塞
#2.1 机制
基础部分
- 活塞的组成与组成
- 无头活塞的创建
- 活塞的自检触发方式与红石信号判定 (QC 半连接性)
- 活塞尝试移动方块的结构分析与通俗逻辑
- 活塞的更新顺序与方块事件响应
进阶部分
- 活塞自检与信号判定的源码调用
- b36 转化顺序与推拉结构分析
- 方块移动底层实现与哈希表运用
- b36 到位与实体运算
#2.1.1 活塞的组成
完整意义上的活塞由两部分组成,分别是活塞底座与活塞头。
活塞底座完全控制活塞头,并且活塞由两种状态:收回与伸出。通常情况下,当活塞处于被激活状态时,其会同时更改活塞底座为伸出状态,并出现活塞头。当活塞处于收回状态时,其活塞头也随即消失。也有部分手段可以创造出只有活塞底座,没有活塞头的无头活塞;也可以通过某些手段创造出只有活塞头没有活塞底座的活塞。
#2.1.2 无头活塞
2.1.2.1 无头活塞的创建
所有无头活塞的创建本质上只有两种办法,分别是:
- 在活塞伸出时,活塞头处于 b36 状态时使用 TNT 等手段移除 b36
- 通过特定手段让未伸出的活塞触发收回事件。本内容会在后续更详细的阐述
2.1.2.2 无头活塞的基本机制
活塞头其实在活塞底座看来依然存在,它就位于活塞底座的正前方。注意,活塞底座认为自己正前方是活塞头与自己正前方是否真的是活塞头无关。如果更新未被充能的无头活塞,无头活塞就会自检并收回。
#2.1.3 活塞的自检机制
2.1.3.1 触发方式
活塞当且仅当以下两种情况发生时触发自检:
- 被放置 (包括被玩家放置,或被其他活塞推拉到位放置)
- 受到 NC 更新
必须强调的是,活塞的自检是可以发生在任意阶段的。换言之,其自检是瞬时的。当上述情况发生时,活塞会立即自检并通过一定的逻辑尝试推出或收回。这里的 “尝试” 并不代表一定会成功执行。
2.1.3.2 红石信号的检查与 QC 充能
自检的本质是为了确认活塞是否需要动作,因此它会先判断自己是否被充能。
活塞检查信号的范围包括:
- 自身一阶毗邻 (除推动方向外) 的所有方块是否正在输出红石信号
- 自身正上方一格方块为核心的一阶毗邻方块是否正在输出红石信号
由于活塞会检查其上方方块的毗邻充能情况,这就导致了活塞可以通过上方的空间被 “隔空” 充能。这种充能无法直接对活塞发出 NC 更新,因此需要额外的 NC 更新来更新它,这就是活塞的半连接性 (Quasi-Connectivity, 简称 QC) 的来源。这也是为什么 BUD 只能检查 NC 更新。
2.1.3.3 自检逻辑通俗解释
活塞自检时,总是会遵循以下行为树:
-
应伸出且未伸出:创建一个分析实例尝试分析前方的推动结构。如果分析失败 (超过推动上限,有不可推动方块阻挡等),则不动作;如果分析成功,则给自己当前位置添加一个伸出的方块事件
-
不应伸出且已伸出:活塞需要收回
- 如果前方是一个正在伸出的同向移动的 "移动的活塞"(Moving_Piston, 或称 B36,36 号方块, 后续均简称 b36),且当前刻不会到位 (进度 < 50%),则触发瞬推收回,添加对应方块事件
- 否则,执行普通收回,添加对应方块事件。
#2.1.4 活塞的推拉与可推动方块
2.1.4.1 可推动方块判定
一个方块要是能被活塞推动,必须同时满足以下要求:
- 在世界有效高度范围内
- 不能是黑曜石,哭泣的黑曜石,重生锚,或强化深板岩等硬编码不可推动方块
- 方块硬度不能为 - 1
- 如果是带釉陶瓦,被推动方向必须与活塞推动方向一致
- 该方块不能带有方块实体 (如箱子、熔炉、漏斗)
2.1.4.2 尝试移动方块与结构分析
活塞在尝试推动时,会动态维护两个列表:推动方块列表与破坏方块列表。
它会以一定的顺序去顶前方将要推动的方块和将要破坏的方块结构。活塞只会注意在推动轴方向的可破坏方块,若方块附着在推动结构的侧面,则会在稍后被更新并掉落,而不会进入破坏列表。
提示:由于结构分析极其复杂 (直线分析与分支分析交替入栈),人工分析极为困难。强烈建议使用Fallen_Breath 的PistOrder 模组来直接在游戏内查看活塞尝试推动的方块到位顺序。
#2.1.5 活塞的更新顺序
活塞的更新顺序总的来说分为五种情况:
- 活塞 / 粘性活塞只伸出
- 同活塞 / 粘性活塞推动方块,只是移动方块为空
- 活塞 / 粘性活塞只收回
- 创建活塞底座 b36,发出 PP 更新和 NC 更新
- 移除活塞头,发出 PP 更新和 NC 更新
- 活塞 / 粘性活塞推动方块
- 移除破坏方块位置的方块
- 在移动的目标位置创建 b36,发出 PP 更新
- 创建活塞头 b36,发出红石粉 prepare 更新与 PP 更新
- 在破坏方块位置发出红石粉 prepare 更新和 NC 更新
- 在移动原位置发出 NC 更新
- 在活塞头位置发出 NC 更新
- 在活塞底座发出 PP 更新和 NC 更新。
- 粘性活塞收回方块
- 创建活塞底座 b36,发出 PP 更新和 NC 更新
- 移除破坏方块位置的方块
- 在移动目标位置创建 b36,发出 PP 更新
- 移除未被覆盖的方块,发出红石粉 prepare 更新,PP 更新
- 移动原位置,发出 NC 更新
- 粘性活塞收回失败
- 创建活塞底座 b36 方块,发出 PP 更新和 NC 更新
我们发现,这个行为中不具备任何在活塞头的更新,也就是我们常说的粘性收回方块失败无更新。这里给几个实例:
#2.1.6 b36 与方块到位
2.1.2.1 常规到位
在每个游戏刻的方块时阶段,b36 会给自己的推动进度 + 0.5
- 0gt BE:开始推动,创建 b36,此时进度为 0
- 0gt TE:进度 + 0.5,进行实体位移运算
- 1gt TE:进度 + 0.5,进行实体位移运算,此事总进度达到 1.0
- 2gt TE:发现进度已经大于等于 1.0,b36 变回原来方块,发出最终的 PP 和 NC 更新。
若我们从 0gt AT 开始观测的话,在 3gt AT 才算完全完成方块到位,因此就有了所谓活塞 3gt 延迟的说法
2.1.2.2 瞬时到位
当活塞触发瞬推收回时,方块不会经理常规的 TE 阶段累加,而是直接强制到位。触发条件为:
- 收回事件,且活塞头位置当前方块时 b32.
- 收回事件,粘性活塞伸出方向一格外为 b36 且该 b36 正在朝活塞推动轴同方向移动。
这里举两个例子做演示:
活塞头位置为 b36
我们看如下的一个实例:
在这个实例中,红色玻璃被瞬间到位,这是从观测角度而言的,而从理论上,我们给出如下解释:
中继器的优先级高于红石火把,所以普通活塞先添加方块事件,无头粘性活塞再添加方块事件,尝试拉回更前方 1 格的方块(实际上此时 actionType = 1)
但是,在 BE 阶段,由于 b36 的插入,粘性活塞拉回受阻,并不会破坏这个 b36,此时活塞头位置为 b36,触发瞬时收回,红色玻璃瞬时到位。
粘性活塞伸出方向一格外为 b36 且 b36 移动方向与活塞移动方向一致
在以上实例中,红色玻璃被瞬时到位,是因为:
活塞推动粘液棒子,红色玻璃被移动,发出 NC 更新,触发无头粘性活塞的自检。此时,粘性活塞可以成功拉回,并且在伸出方向一格外存在 b36,该 b36 推动方向与粘性活塞推动方向是一致的,因此被瞬时到位。
当然,值得注意的是,这种瞬时到位只会影响一格方块,也就是粘性活塞面前的方块,其余方块仍然会被转化为 b36,所以,我们可以利用这一点稍微解读一下一种在许多人看来是魔法的装置:
在这个装置中,当拉杆被拉下,粘性活塞会在方块到位 1gt 后移动它侧翼的方块,此时这个方块被粘液块带着向前推动,但由于粘性活塞是被侦测器激活的,该粘液块被提前到位,被活塞回推,而此时这个方块尚处于 b36 的状态,因此被留在了被带过去的位置,这样就实现了对方块流的延长。
瞬时到位会将进度直接设为 1.0,移除 b36 方块实体,直接替换为原本的方块并发出更新。值得注意的是,瞬推不会附带对周围实体的位移推拉运算。
值得注意的是,由于笨蛋麻将并没有在这部分写任何实体运算或对含水方块的特殊判断,所以瞬推不会影响实体,也不会消除含水状态。但是值得注意的是,如果在此前经历了 TE 阶段的实体运算,在此前的实体运算会被保留,仅有当前 gt 的 TE 阶段的实体运算会被忽略。
#2.1.7 [进阶] 活塞自检逻辑与源码分析
2.1.7.1 活塞的自检函数
活塞的自检由以下方法触发,其核心在于它们只在服务 (!world.isClient) 端且满足特定条件时,调用统一的trymove方法:
2.1.7.2 信号判定的源码shouldExtend
活塞检查两个核心位置的一阶毗邻。遍历顺序时严格先检查活塞本身再检查上方核心的一阶毗邻,检查毗邻的顺序严格符合 NC 更新顺序。
2.1.7.3 尝试移动的源码tryMove
tryMove决定了方块事件的种类 (actionType), 0 为伸出, 1 为收回, 2 为瞬推收回
#2.1.8 [进阶] b36 转化与结构分析源码
温馨提示,这部分是本章节最为复杂的一部分之一,如果比较懒可以直接使用 pistOrder 模组而并不需要真正学习本部分在说的内容
当活塞需要推动时,会先执行以上代码,这段代码是用于分析移动结构的。这里需要更多更为详细的表述以辅助各位读者理解:movedBlocks和brokenBlocks实际是两个arrayList。没有了解过Java语言的读者可以简单理解为一个可以随意插入,取出的列表。这段代码本质在按照以下顺序执行操作:
- 清空
movedBlocks和brokenBlocks两个列表。
- 检查其起始位置是否可以推动
- 如果不可推动,则检查其对活塞行为的响应
- 如果可破坏,则将该方块添加至
brokenBlocks列表,推动成功
- 如果不可破坏,则推动失败
- 如果可推动,则尝试添加其直线结构(我们稍后会解析活塞是如何分析直线结构的)
- 如果无法添加其直线结构,则推动失败
- 如果成功添加其直线结构,则遍历所有需要推动的方块
- 如果存在粘性方块(粘液块和蜂蜜块),则尝试添加分支
- 如果以上均没有失败,则推动成功
Mojang 在这里通过两个方法实现了对直线结构的分析和对分支结构的分析。分别称为tryMove1和tryMoveAdjacentBlock。
2.1.8.1 直线结构分析tryMove
首先先明确这个方法会在何种情况下被调用:
- 这个方块直接被方块推动
- 这个方块直接被粘性方块带动
值得注意的是,这两种方法是严格互相独立的,这意味着这两种情况之间不会有任何重复。
当因第一种情况被调用时,这个方块本身一定不是一个不可推动的方块,详情可以回看上文 5.4.2 部分。当因为第二种情况调用时,这个方块本身如果是一个不可推动的方块,考虑到尝试带动它的方块是一个粘性方块,则没有必要考虑对其进行分析。(实际上也就是下图中的这种情况)
在进行了以上分析后,我们就可以正式的看看本方法的第一部分代码。
这段代码的逻辑如下:
- 如果活塞在试图推动空气,那么可以推动
- 如果这个方块不可移动,那么可以推动(看看上文我论证了这部分代码的合理性)
- 如果这个方块就是活塞本身,那么可以推动
- 如果这个方块已经在推了,那么可以推动
接下来,游戏首先定义了一个变量offset,它实际上表示了活塞正在尝试推动的方块。如果此时movedBlocks列表的大小 + 1 已经大于最大推动数量上限,则推动失败。(算上这个方块就超出可推动大小的上限了)
游戏会从这个方块开始朝着推动方向的相反方向开始递推方块,寻找直线拉动中断的位置,当这个位置是空气或者与此粘块不互粘的或者无法移动的方块,或者是活塞本身的一列方块便是粘块拉动的部分 (这部分存在的意义是处理以下这种情况:)
如果寻找到新直线结构的拉动部分加上原来的movedBlocks列表大小已经大于 12,则推动失败。
接下来,将这部分拉动的部分添加至移动方块列表中,添加方向为推动方向的反向,即最前方的方块先添加,最后方的方块后添加。
在完成拉动部分的分析后,游戏会接着处理推动部分。从我们一开始开始分析的方块沿移动方向向前寻找会被推动的方块,如果被被遍历的方块已经在推动列表中时,意味着这个直线结构结束。再往前遍历会与前面已分析的结构发生碰撞,此时将重新排列推动方块列表。
遍历已经走完的方块(取决于添加顺序),如果其是粘性方块,进入分支结构的检查,寻找侧面粘动的方块,如果侧面粘动使其推动失败,返回推动失败的结果。
如果正前方移动的方块是空气,直线结构结束。如果正前方方块是不可移动方块或活塞本身,推动失败。如果正前方移动的方块是可破坏方块,将该方块加入可破坏方块列表,直线结构结束。
将所有方块加入移动方块列表,检查移动方块列表,若其大小大于 12,则达到推动上限,推动失败。
2.1.8.2 尝试添加分支tryMoveAdjacentBlock
这里的形参输入的方块总是可粘方块,因此执行以下逻辑:
- 遍历其周围所有方块,这是通过遍历方向实现的,顺序和 NC 更新一致
- 由于与推动轴方向相同方向的两个相邻方块的分析是不必要的,故在这里排除。侧面的方块如果与此方块互粘,则尝试分析其直线结构,如果直线结构添加失败,逐级返回失败结果。
2.1.8.3 移动方块列表处理
movedBlocks和brokenBlocks直接倒过来就是 b36 到位顺序和破坏顺序。原因我们会在稍后详细解释。
2.1.8.4 b36 到位顺序的分析
我们来分析一下如下实例:
本图的推动轴是 + z 向 - z 方向
首先分析直线结构,活塞头前的两格粘液块最先被添入移动方块列表。
由于两格均为可粘方块,最贴近活塞的粘液块尝试分析其 - x 方向的普通方块,并将其添加进推动方块列表,再分析 + y 方向的粘液块,将其加入推动方块列表,其会有下一次调用,但此时这个调用任务会被压入系统栈。直线方块的第二格压栈分析其 - x 方向的粘液块,这个粘液块发现自己和后面的普通方块冲突了,于是处理碰撞,合成的新列表分别是普通方块下标为为 3,粘液块下标为 4。粘液块检查周围方块的任务被压入系统栈,等待下一轮调用。最后以此类推,最终可以得到整个结构的推动顺序。
当然你要是比较懒不如直接 pistorder。
#2.1.9 [进阶] 活塞响应事件与移动方块源码
2.1.9.1 事件的统一入口onSyncedBlockEvent
public boolean onSyncedBlockEvent(BlockState state, World world, BlockPos pos, int actionType, int directionData) { // 获取方块的方向(例如,活塞朝向的方向) Direction facing = (Direction)state.get(FACING); // 获取激活的方块状态(表示方块正在扩展) BlockState extendedState = (BlockState)state.with(EXTENDED, true); // 如果当前不是客户端,进行一些服务器端操作 if (!world.isClient) { // 检查是否需要扩展活塞(是否有红石信号) boolean shouldExtend = this.shouldExtend(world, pos, facing); // 如果需要扩展并且事件类型是收缩,更新方块状态并返回 if (shouldExtend && (actionType == 1 || actionType == 2)) { world.setBlockState(pos, extendedState, 2); return false; } // 如果不需要扩展并且事件类型是 0,直接返回 false if (!shouldExtend && actionType == 0) { return false; } } // 事件类型为 0:活塞伸展事件 if (actionType == 0) { // 如果移动方块失败,返回 false if (!this.move(world, pos, facing, true)) { return false; } // 设置方块为扩展状态,并播放伸展声音 world.setBlockState(pos, extendedState, 67); world.playSound((PlayerEntity)null, pos, SoundEvents.BLOCK_PISTON_EXTEND, SoundCategory.BLOCKS, 0.5F, world.random.nextFloat() * 0.25F + 0.6F); world.emitGameEvent(GameEvent.BLOCK_ACTIVATE, pos, Emitter.of(extendedState)); } // 事件类型为 1 或 2:活塞收缩事件 else if (actionType == 1 || actionType == 2) { // 获取活塞前方的方块实体 BlockEntity blockEntityInFront = world.getBlockEntity(pos.offset(facing)); // 如果该位置有活塞方块实体,完成收缩操作 if (blockEntityInFront instanceof PistonBlockEntity) { ((PistonBlockEntity)blockEntityInFront).finish(); } // 创建移动的活塞方块状态 BlockState pistonHeadState = Blocks.MOVING_PISTON.getDefaultState().with(PistonExtensionBlock.FACING, direction) .with(PistonExtensionBlock.TYPE, this.sticky ? PistonType.STICKY : PistonType.DEFAULT); // 设置新的活塞方块状态,并创建新的方块实体 world.setBlockState(pos, pistonHeadState, 20); world.addBlockEntity(PistonExtensionBlock.createBlockEntityPiston(pos, pistonHeadState, (BlockState)this.getDefaultState().with(FACING, Direction.byId(directionData & 7)), direction, false, true)); world.updateNeighbors(pos, pistonHeadState.getBlock()); pistonHeadState.updateNeighbors(world, pos, 2); // 如果是粘性活塞,执行特殊逻辑 if (this.sticky) { // 计算目标位置 BlockPos targetpos = pos.add(facing.getOffsetX() * 2, facing.getOffsetY() * 2, facing.getOffsetZ() * 2); BlockState targetState = world.getBlockState(targetpos); boolean hasMovingPistonInFront = false; // 如果目标位置已有移动活塞方块,尝试完成它的扩展 if (targetState.isOf(Blocks.MOVING_PISTON)) { BlockEntity blockEntity2 = world.getBlockEntity(targetpos); if (blockEntity2 instanceof PistonBlockEntity) { PistonBlockEntity pistonBlockEntity = (PistonBlockEntity)blockEntity2; if (pistonBlockEntity.getFacing() == facing && pistonBlockEntity.isExtending()) { pistonBlockEntity.finish(); hasMovingPistonInFront = true; } } } // 如果没有特殊情况,移除目标方块或继续移动 if (!hasMovingPistonInFront) { if (actionType != 1 || targetState.isAir() || !isMovable(targetState, world, targetpos, facing.getOpposite(), false, direction) || targetState.getPistonBehavior() != PistonBehavior.NORMAL && !targetState.isOf(Blocks.PISTON) && !targetState.isOf(Blocks.STICKY_PISTON)) { world.removeBlock(pos.offset(facing), false); } else { this.move(world, pos, facing, false); } } } // 如果不是粘性活塞,直接移除目标方块 else { world.removeBlock(pos.offset(facing), false); } // 播放收缩声音 world.playSound(null, pos, SoundEvents.BLOCK_PISTON_CONTRACT, SoundCategory.BLOCKS, 0.5F, world.random.nextFloat() * 0.15F + 0.6F); world.emitGameEvent(GameEvent.BLOCK_DEACTIVATE, pos, Emitter.of(pistonHeadState)); } // 返回 true 表示事件成功处理 return true;}
// SYNTAX_HIGHLIGHT
服务端
如果不是客户端,那么会执行几个服务端特有的事件:
- 检查激活情况,如果激活,并且方块事件为收回或者瞬推收回,将活塞设置为伸出状态,并进行寻路更新,方块事件执行失败。
- 如果没有激活并且方块事件为伸出,方块事件执行失败。
伸出事件
注意,本部分无论客户端,服务端都必须执行
活塞会尝试移动前方方块,这是通过move方法实现的,我稍后会补充这个方法的细节。如果移动失败,则活塞执行事件失败。
接下来,将活塞设置为伸出状态,此处bitflag为0b01000011放置移除更新,寻路更新,NC 更新
最后,播放活塞伸出的声音,方块事件执行成功
瞬间收回与收回事件
首先获取活塞头的方块实体,如果是 b36,那么使其瞬间到位。
接下来,将活塞设置为 b36,此处bitflag为0b00010100,无 PP 更新,寻路更新
再将活塞设置成 b36 方块实体,先发出 NC 更新,再发出 PP 更新
如果这个活塞是一个粘性活塞,那么将活塞头伸出 1 格外的地方,如果它试图放置活塞头的位置的一格外是一个 b36,并且该 b36 的推动方向与活塞同向,那么让方块瞬间到位。
如果活塞行为不为瞬推收回,并且该方块不是空气,活塞可以移动前方一格的方块,而且这个方块可以被正常拉回或者这个方块是活塞或者粘性活塞,那么尝试拉回前方方块。如果前方一格的方块无法被拉动,那么删除活塞头位置的方块。
如果是普通活塞,直接删除活塞头位置的方块。
无论何种情况,播放活塞收回的声音。方块事件执行成功。
2.1.9.2 活塞移动方块move
// retract = 0:收回// retract = 1:伸出private boolean move(World world, BlockPos pos, Direction dir, boolean retract) { // 活塞头的位置 BlockPos headPos = pos.offset(dir); // 如果是收缩动作并且目标位置是活塞头 if (!retract && world.getBlockState(headPos).isOf(Blocks.PISTON_HEAD)) { // 将活塞头设为空气 // flag = 0b00010100, 无 PP 更新,寻路更新 客户端 world.setBlockState(headPos, Blocks.AIR.getDefaultState(), 20); } // 创建 PistonHandler 实例,用于计算推动或拉回操作的合法性 PistonHandler pistonHandler = new PistonHandler(world, pos, dir, retract); // 如果无法执行推动/拉回操作,则无法移动 if (!pistonHandler.calculatePush()) { return false; } else { // 使用哈希表存储需要移动的方块及其状态,便于后续处理 Map<BlockPos, BlockState> map = Maps.newHashMap(); // 创建移动方块列表并获取移动方块列表 List<BlockPos> blocksToMove = pistonHandler.getMovedBlocks(); // 用于记录需要移动的方块的原始状态 List<BlockState> blocksOriginal = Lists.newArrayList(); // 遍历需要移动的方块,将其原始状态保存到列表中,并存储到 map 中 for (int i = 0; i < blocksToMove.size(); ++i) { BlockPos blockPos2 = (BlockPos) blocksToMove.get(i); BlockState blockState = world.getBlockState(blockPos2); blocksOriginal.add(blockState); map.put(blockPos2, blockState); } // 获取所有需要破坏的方块位置列表 List<BlockPos> list3 = pistonHandler.getBrokenBlocks(); // 用于记录被破坏方块的状态 BlockState[] blockStates = new BlockState[blocksToMove.size() + list3.size()]; // 确定移动方向 Direction direction = retract ? dir : dir.getOpposite(); // 处理被破坏的方块,从后到前 int j = 0; for (int k = list3.size() - 1; k >= 0; --k) { BlockPos blockPos3 = (BlockPos) list3.get(k); BlockState blockState2 = world.getBlockState(blockPos3); // 如果方块有方块实体,则先处理掉落逻辑 BlockEntity blockEntity = blockState2.hasBlockEntity() ? world.getBlockEntity(blockPos3) : null; dropStacks(blockState2, world, blockPos3, blockEntity); // 将方块设置为空气并触发销毁事件 world.setBlockState(blockPos3, Blocks.AIR.getDefaultState(), 18); world.emitGameEvent(GameEvent.BLOCK_DESTROY, blockPos3, Emitter.of(blockState2)); // 如果方块不是火类方块,则添加破坏粒子效果 if (!blockState2.isIn(BlockTags.FIRE)) { world.addBlockBreakParticles(blockPos3, blockState2); } // 保存被破坏方块的状态 blockStates[j++] = blockState2; } // 处理需要移动的方块 for (int k = blocksToMove.size() - 1; k >= 0; --k) { BlockPos targetPos = (BlockPos) blocksToMove.get(k); BlockState targetState = world.getBlockState(targetPos); // 移动到目标位置 targetPos = targetPos.offset(direction); map.remove(targetPos); // 设置目标位置为 MOVING_PISTON 状态,用于动画处理 BlockState blockState3 = (BlockState) Blocks.MOVING_PISTON.getDefaultState().with(FACING, dir); world.setBlockState(targetPos, blockState3, 68); // 创建 b36 方块实体,用于控制移动动画 world.addBlockEntity(PistonExtensionBlock.createBlockEntityPiston(targetPos, blockState3, (BlockState) blocksOriginal.get(k), dir, retract, false)); blockStates[j++] = targetState; } // 如果是伸出操作,添加活塞头 if (retract) { PistonType pistonType = this.sticky ? PistonType.STICKY : PistonType.DEFAULT; BlockState blockState4 = (BlockState) ((BlockState) Blocks.PISTON_HEAD.getDefaultState().with(PistonHeadBlock.FACING, dir)).with(PistonHeadBlock.TYPE, pistonType); BlockState targetState = (BlockState) ((BlockState) Blocks.MOVING_PISTON.getDefaultState().with(PistonExtensionBlock.FACING, dir)).with(PistonExtensionBlock.TYPE, this.sticky ? PistonType.STICKY : PistonType.DEFAULT); map.remove(headPos); // 设置当前活塞位置为 MOVING_PISTON 状态 world.setBlockState(headPos, targetState, 68); // 添加 b36 方块实体,用于控制动画 world.addBlockEntity(PistonExtensionBlock.createBlockEntityPiston(headPos, targetState, blockState4, dir, true, true)); } // 将 map 中剩余的方块设置为空气 BlockState blockState5 = Blocks.AIR.getDefaultState(); for (BlockPos blockPos4 : map.keySet()) { world.setBlockState(blockPos4, blockState5, 82); } // 更新邻居方块状态 for (Map.Entry<BlockPos, BlockState> entry : map.entrySet()) { BlockPos blockPos5 = entry.getKey(); BlockState blockState6 = entry.getValue(); blockState2.prepare(world, blockPos5, 2); blockState5.updateNeighbors(world, blockPos5, 2); blockState5.prepare(world, blockPos5, 2); } // 更新被破坏方块的邻居状态 for (int l = list3.size() - 1; l >= 0; --l) { BlockState targetState = blockStates[j++]; BlockPos blockPos5 = (BlockPos) list3.get(l); targetState.prepare(world, blockPos5, 2); world.updateNeighborsAlways(blockPos5, targetState.getBlock()); } // 更新移动方块的邻居状态 for (int l = blocksToMove.size() - 1; l >= 0; --l) { world.updateNeighborsAlways((BlockPos) blocksToMove.get(l), blockStates[j++].getBlock()); } // 如果是收缩动作,更新活塞位置邻居 if (retract) { world.updateNeighborsAlways(headPos, Blocks.PISTON_HEAD); } return true; // 如果执行成功,返回 true }}
// SYNTAX_HIGHLIGHT
活塞尝试移动方块时,会区分两种情况,伸出和收回。这里的retract布尔值代表了活塞的动作,当其值为false时,活塞收回,当其值为true时,活塞伸出,接下来,活塞会区分两种活塞动作,分别处理。
当活塞尝试收回时,游戏会首先检查预期是活塞头的位置是不是活塞头,如果是活塞头才执行收回。每次执行收回时,游戏都会先将活塞头的位置设置为空气(删除活塞头)。此时的setBlockState的传入bitflag为0b00010100,即无 PP 更新,和客户端的寻路更新。
在正式开始移动前,无论收回或伸出,游戏都会调用5.4.2部分分析的calculatePush方法,再次分析是否可以移动,如果不能成功移动,则活塞执行推动 / 拉动失败。
接下来,游戏会正式开始移动方块,它首先会逆序地处理破坏方块列表 (brokenBlocks),如果被破坏的方块具备掉落物品的方块实体,那么掉落物品,再将被锚定破坏的方块(也就是当前正在处理的方块)设置为空气,此时无 PP 更新,发出寻路更新。
破坏了应该破坏的方块后,游戏会开始移动方块。从后往前遍历移动方块列表 (movedBlocks),将该方块设置为 b36,bitflag为0b01101000,也就是放置移除更新,再向该方块添加 b36 的方块实体,并添加到blockStates列表中备用。
如果是伸出,就在活塞头位置创建 b36,放置移除更新,再向活塞头位置添加 b36 方块实体。
完成以上操作后,将未被覆盖到的方块全部设置为空气,并放置移除更新,寻路更新。此时无 PP 更新。再统一发出更新,对于哈希表中所有的方块,都先统一发出红石粉 prepare 更新,再给出方块更新(在方块原位置),如果是伸出,活塞头完成方块更新。
以上代码很好的解读了 b36 的添加为何是倒序与movedBlocks列表。另外由于哈希表是乱序的,所以这也解释了为什么在有些依赖 b36 到位顺序的机器卸载后会坏掉。(卸载后会丢失本来的索引)
#2.1.10 [进阶] 到位的源码解析
2.1.10.1 普通到位tick
这东西太简单了没什么好讲的,所以通俗解释都扔到前面去了 (笑),因此这里只放个代码供参考
2.1.10.2 瞬时到位finish
大部分内容前面也解释过了,所以这里直接略过。我们只看后半部分的 b36 特判看起。
在 1.20 中,只有活塞臂的source属性是true,其它都是false。因此当该方法被调用时,若该位置是目标方块为活塞臂的 b36,会直接变成空气。其余部分就不细说了,前面都讲过了。
#2.1.11 [进阶] 活塞头
本章其实没什么难度,但是由于除了前阵子Menggui233在PetrisAFE的视频中演示的装置外,未见得其它用途,故在这里放在了进阶的位置。
我们首先观察如下装置:
现在,拉下拉杆
观察到活塞底座被换成了侦测器,而活塞头被保留。为什么?
我们首先回顾一下 b36 到位时发生了什么:
- 给出自身位置的 PP 更新
- 给出自身位置的一阶毗邻的 NC,PP 更新
- 给出自身位置的 NC 更新
因此,我们分别查看活塞头受到 PP 更新和 NC 更新会发生什么
2.1.11.1 活塞头存在的条件判定canSurvive
isFittingBase方法本质上说明,当且仅当一个活塞头的后方 (更确切地说,是推动方向的反方向 1 格,后续均以后方简称) 是正在伸展的活塞,且该活塞的伸出方向与活塞头一致活塞头可以被称之为适配。而canSurvive则要求一个活塞头的存在是合法的,当且仅当该活塞头是适配的,或其后方是一个与活塞头推动方向相同的 b36。
2.1.11.2 活塞头的移除onRemove
一句话总结:移除时如果状态为适配,则连带着底座一起移除,并掉落活塞。
2.1.11.3 活塞头受到 PP 更新,NC 更新
PP 更新
活塞头实际上只关心来自后方的 PP 更新。当后方发生 PP 更新时,活塞头会调用canSurvive来检查自己是否合法,若不合法则立刻消失。
NC 更新
在任何情况下,活塞头均不会因受到 NC 更新而消失。当受到 NC 更新时,若自身合法,则将更新视为后方方块收到的 NC 更新。
2.1.11.4 案例分析
因此,对于在章首提到的案例:
- 0gt AT: 玩家拉下拉杆,活版门触发
- 2gt BE 深度 0: 粘性活塞被激活,更新无头活塞
- 2gt BE 深度 1: 无头活塞吃掉粘性活塞底座,粘液块 & 亮起侦测器被推动
- 4gt TE: 粘性活塞头先到位,此时检查后方是和自己同方向的 b36,自检通过。随后亮起侦测器 b36 到位,无更新。
因此侦测器成功取代了原本的活塞底座。
#2.1.12 [进阶] b36 相关的实体运算
//该方法用于处理活塞对实体的推动。它会获取 b36 在当前游戏刻的移动方向和距离,并找出路径上所有可能接触的实体。然后,它会精确计算每个实体与 b36 在移动方向上的最大重叠部分,并据此推动实体(同时增加微小位移 0.01 防卡入)。此过程还会特殊处理黏液块的弹射效果以及 b36 收回时对实体的影响。 private static void pushEntities(World world, BlockPos pos, float f, PistonBlockEntity blockEntity) { //获取 b36 移动的方向 Direction direction = blockEntity.getMovementDirection(); //计算在当前 tick,b36 需要移动的距离 double d = f - blockEntity.progress; //获取 b36 的碰撞箱体素模型 VoxelShape voxelShape = blockEntity.getHeadBlockState().getCollisionShape(world, pos); //检查 b36 是否有实际的碰撞体积。如果没有,就什么也不做 if (!voxelShape.isEmpty()) { //获取 b36 该 tick 移动后的包围盒 Box box = offsetHeadBox(pos, voxelShape.getBoundingBox(), blockEntity); //根据 b36 移动前后扫描过的区域,粗略筛选可能被推动的实体 List<Entity> list = world.getOtherEntities(null, Boxes.stretch(box, direction, d).union(box)); //如果扫描区域内有实体,则开始处理 if (!list.isEmpty()) { //获取组成 b36 体素模型的所有子包围盒列表 List<Box> list2 = voxelShape.getBoundingBoxes(); //检查 b36 是否是黏液块 boolean bl = blockEntity.pushedBlockState.isOf(Blocks.SLIME_BLOCK); Iterator var12 = list.iterator(); //遍历所有可能被影响的实体 while (true) { Entity entity; while (true) { if (!var12.hasNext()) { return; } entity = (Entity)var12.next(); //检查实体是否忽略 b36 推动 if (entity.getPistonBehavior() != PistonBehavior.IGNORE) { if (!bl) { break; } //如果是黏液块在推动,对于除服务端玩家外的实体,将其沿移动轴向的速度分量强制设为 1.0 if (!(entity instanceof ServerPlayerEntity)) { Vec3d vec3d = entity.getVelocity(); double e = vec3d.x; double g = vec3d.y; double h = vec3d.z; switch (direction.getAxis()) { case X: e = direction.getOffsetX(); break; case Y: g = direction.getOffsetY(); break; case Z: h = direction.getOffsetZ(); } entity.setVelocity(e, g, h); break; } } } //记录实体与 b36 在移动方向上的最大重叠距离 double i = 0.0; //遍历 b36 模型的每一个子包围盒 for (Box box2 : list2) { //检查 b36 的子包围盒的移动路径是否与实体的碰撞箱相交 Box box3 = Boxes.stretch(offsetHeadBox(pos, box2, blockEntity), direction, d); Box box4 = entity.getBoundingBox(); //如果相交,计算重叠的距离 if (box3.intersects(box4)) { i = Math.max(i, getIntersectionSize(box3, direction, box4)); //重叠的距离大于实际推动距离,这是最坏的情况,不需要进行多余检查了 if (i >= d) { break; } } } //如果最终计算出的最大重叠距离大于 0,进行推动 if (!(i <= 0.0)) { //确定最终的推动距离。这个距离不能超过 b36 本身在这一刻的移动距离 //并额外增加 0.01 防止下一 tick 实体被卡在 b36 里 i = Math.min(i, d) + 0.01; //沿移动方向,将实体推动计算出的距离 moveEntity(direction, entity, i, direction); //如果是 b36 在收回,则特殊处理被其影响的实体 if (!blockEntity.extending && blockEntity.source) { push(pos, entity, direction, d); } } } } }}//此方法用于解决实体卡在 b36 内的问题。它会检查实体是否与 b36 所在位置的方块存在重叠,如果存在,则计算出沿反方向将实体推出的最小距离。通过一次精细的重叠深度比较后,它会校准并执行这个位移,确保实体被平滑地推离 b36。private static void push(BlockPos pos, Entity entity, Direction direction, double amount) { //获取实体的包围盒 Box box = entity.getBoundingBox(); //创建一个位于 b36 位置的、标准大小 (1x1x1) 的方块包围盒 Box box2 = VoxelShapes.fullCube().getBoundingBox().offset(pos); // 如果实体与方块的包围盒没有重叠,则说明实体未被卡住,直接返回 if (box.intersects(box2)) { //获取原始推动的相反方向 Direction direction2 = direction.getOpposite(); //计算要使实体脱离方块所需的最小推出距离 double d = getIntersectionSize(box2, direction2, box) + 0.01; //计算实体与方块实际重叠区域的深度 double e = getIntersectionSize(box2, direction2, box.intersection(box2)) + 0.01; // 如果两次计算的推出距离几乎相等(表明实体只是轻微接触),则进行位置校准 (float align) if (Math.abs(d - e) < 0.01) { d = Math.min(d, amount) + 0.01; //沿相反方向,将实体推动计算出的距离 moveEntity(direction, entity, d, direction2); } }}//该方法用于实现移动的蜂蜜块对上方实体的粘滞效果,且仅在 b36 水平推动时生效。其核心逻辑是:在蜂蜜块表面上方定义一个 “粘滞区域”,然后将该区域内所有符合条件的实体,与蜂蜜块 b36 进行同步移动。private static void moveEntitiesInHoneyBlock(World world, BlockPos pos, float f, PistonBlockEntity blockEntity) { //整个逻辑只在 b36 是蜂蜜块时才会触发 if (blockEntity.isPushingHoneyBlock()) { //获取 b36 移动的方向 Direction direction = blockEntity.getMovementDirection(); //蜂蜜块的粘滞效果仅在水平方向推动时生效 if (direction.getAxis().isHorizontal()) { //获取蜂蜜块 b36 碰撞箱的实际顶部高度 double d = blockEntity.pushedBlockState.getCollisionShape(world, pos).getMax(Direction.Axis.Y); //定义一个位于蜂蜜块表面上方的粘滞区域 (从其顶部到 Y = 1.5),并使其随 b36 同步移动 Box box = offsetHeadBox(pos, new Box(0.0, d, 0.0, 1.0, 1.5000010000000001, 1.0), blockEntity); //计算在当前 tick,蜂蜜块 b36 需要移动的距离 double e = f - blockEntity.progress; //遍历粘滞区域内所有符合移动条件的实体 for (Entity entity : world.getOtherEntities((Entity)null, box, entityx -> canMoveEntity(box, entityx, pos))) { //沿移动方向,将所有符合条件的实体推动计算出的距离 moveEntity(direction, entity, e, direction); } } }}
// SYNTAX_HIGHLIGHT
pushEntities:处理活塞推动
该方法是活塞与实体交互的核心。它会获取 b36 在当前游戏刻的移动方向和距离,并找出路径上所有可能接触的实体。然后,它会精确计算每个实体与 b36 在移动方向上的最大重叠部分,并据此推动实体(同时增加微小位移 0.01 防卡入)。此过程还会特殊处理黏液块的弹射效果以及 b36 收回时对实体的影响。
其工作流程如下:
- 确定移动参数:首先,获取活塞头在当前游戏刻(tick)的移动方向和精确距离。
- 粗略筛选实体:通过计算活塞头在这一帧内扫过的空间,快速找出所有可能被推动的实体。
- 精确计算重叠:遍历每一个可能被影响的实体,并将其碰撞箱与活塞头模型的每一个子碰撞箱进行比较。它会精确计算出在活塞移动方向上,实体与活塞头之间发生的最大重叠距离。
- 执行推动:根据计算出的最大重叠距离,对实体施加一个位移。为了防止实体在下一帧被卡住,推动的距离会额外增加一个极小值(0.01)。
- 特殊情况处理:
- 黏液块:如果推动的方块是黏液块,它会对非玩家实体产生一个弹射效果,将其沿移动方向的速度设置为一个固定值。
- 活塞收回:如果活塞正在收回,它会调用 push 方法来特殊处理可能被拉动的实体,确保它们不会卡在活塞臂消失后的空间里。
push:解决实体卡入问题
这个方法是一个辅助函数,专门用于解决实体意外卡入活塞臂所在方块的问题。
其工作流程如下:
- 检查重叠:首先,检查实体的碰撞箱是否与活塞臂目标位置的方块碰撞箱有重叠。如果没有重叠,则说明实体没有卡住,方法直接结束。
- 计算推出距离:如果检测到重叠,它会计算需要将实体沿反方向推出多远才能恰好分离。它会进行两次计算和比较,以确保推出的距离精确且最小,避免过度位移。
- 执行位移:最后,它会沿活塞移动的相反方向,将实体推出计算好的距离,从而平滑地解决卡入问题。
moveEntitiesInHoneyBlock:实现蜂蜜块粘滞效果
这个方法专门用于实现移动中的蜂蜜块对上方实体的 “粘滞” 效果。
其工作流程如下:
- 条件检查:该功能只在活塞推动的是蜂蜜块,并且是水平移动时才会触发。
- 定义粘滞区域:它会在移动的蜂蜜块表面上方定义一个特定的 “粘滞区域”(从蜂蜜块顶部到 Y = 1.5 的高度)。
- 同步移动:系统会找出所有位于这个粘滞区域内的实体。
- 施加位移:最后,将这些实体与蜂蜜块一起,沿着相同的方向移动相同的距离,从而实现了实体被 “粘” 在蜂蜜块上一起移动的效果。
#2.1.13 [进阶] 基于Extended=False的活塞收回事件的无头活塞制造办法
我们先召回前面提到的onSyncedBlockEvent。值得注意的是,当服务端执行该方法并return false时,并不会向客户端发包,因此客户端也不会执行任何动画,或将任何东西变成 b36。(也就是客户端并不会执行该方法)
实际上在前文提到的setBlockState的传入形参flag仅仅在通常意义下是寻路更新,这里需要更为详细的解释。
我们观察如下现象:
- 放置一个任意方向的任意种类的活塞。并在其除活塞面向方向的一阶毗邻位置放置红石块。这一步会激活这个活塞
/tick freeze
- 移除红石块
- 将原本的活塞替换为任意方向的同一类型的活塞
- 放回红石块,红石块的位置要求等同与第一步提到的位置要求
- 在活塞面向方向放置一个不可推动方块
/tick freeze解除时停
如果操作完全没有错误,则该活塞变为无头活塞
这是因为在onSyncedBlockEvent中
方块事件在执行时只会校验方块类型和位置,不会校验这个方块的朝向,因此在这个活塞的位置,我们得到了一个可以校验通过并执行的,活塞状态处于 Extended = False 的活塞,并且该活塞将执行收回事件。在服务端,活塞会被校验是否应该伸出。此时活塞因为被红石块激活,确实应该伸出,所以进入第一个 if 分支条件。这个分支条件首先调用了setBlockState,而我们查看一下setBlockState在flag=2时做了什么:
我们发现在if ((flags & 2) != 0 && (!this.isClient || (flags & 4) == 0) && (this.isClient || worldChunk.getLevelType() != null && worldChunk.getLevelType().isAfter(ChunkLevelType.BLOCK_TICKING)))这个分支调用中,调用了一个方法updateListeners,我们会发现这个updateListeners有两个实现方法:
ServerWorld
ClientWorld
在ServerWorld中,这里就是普通的寻路更新。这里寻路更新会假定活塞臂存在,当然这是题外话了。
而在ClientWorld中,这个updateListeners负责了渲染。因此这里客户端抢跑了,直接渲染了伸出的活塞臂而没有渲染活塞头。
接下来是第二个,由于活塞自检引起的伸出事件。这个伸出事件在执行时会直接失败。无论客户端还是服务端,在这里不做进一步解释。
因此,这样就制造出了一个无头活塞。
#2.1.14 [进阶] 案例分析
2.1.14.1 案例 1
敲击音符盒,会发生什么?
活塞掉落
粘性活塞受到 NC 更新,自检。调用trymove,添加收回方块事件,调用onSyncedBlockEvent。进行下述判断:
稍做判断,进入move方法。
由于目标位置是活塞头,且是收缩动作,将活塞头位置设为空气,触发活塞头的onRemove,活塞头连带着活塞底座掉落。
2.1.14.2 案例 2
敲击音符盒,会发生什么?
粘性活塞变回完整状态,除此之外无事发生
步骤如前一例所述,但这里进入move后不符合上文的移除活塞头的方法。于是进入下一步
由于面前结构无法拉回,则直接return false,无事发生。
2.1.14.3 案例 3
敲击音符盒,会发生什么?
普通方块被移除
回到onSyncedBlockEvent,依旧是这个判断:
但在此处,if 分支成立 (黑曜石不可移动,!isMovable是true),因直接移除活塞面前的方块,普通方块被移除。
2.1.14.4 案例 4
敲击音符盒,会发生什么?
无事发生
同案例 2,进入move方法。当活塞尝试计算是否可拉回时,它把自己检查到了,而激活的活塞底座本身又是不可推动的,于是return false,无事发生。
2.1.14.5 案例 5
拉下拉杆,会发生什么?
前面的活塞变为无头活塞,后面的活塞变回普通活塞
依旧考察onSyncedBlockEvent,在此处注意到这段代码:
拉下拉杆时,前方的活塞先在面前位置创建一个 b36 (注意活塞底座并未变为 b36),然后执行上述操作,调用了前一个活塞的活塞臂的finish方法。我在前面提到,该方法对于活塞臂的 b36 会有特判,直接将其变为空气。因此前一个活塞的活塞臂就被移除,变为无头活塞。后一个位置的粘性活塞收回,变为Extended=False的活塞。
2.1.14.6 案例 6
拉下拉杆会发生什么?
两个活塞换头
原理和案例 5 类似,但是调用的是这里的代码:
面前是 b36 就直接调用finish方法,将其变为空气。
2.1.14.7 案例 7
拉下拉杆会发生什么?
红石块消失,最左侧粘性活塞丢方块
拉下拉杆,无头活塞面前方块为 b36,并被更新。红石块瞬间到位,造成 NC 更新,更新到中继器,添加 TT 事件
但注意,这里onSyncedBlockEvent还没被 return,继续执行如下操作:
于是红石块就被移除掉了。发出 NC 更新。添加 TT 事件。
中继器亮起 2gt,于是最左侧粘性活塞丢方块。
#2.2 应用
#2.2.1 复制
2.2.1.1 亮起侦测器的移除运算
亮起侦测器被移除时会向后方发出 NC 更新并熄灭自己。
2.2.1.2 基于侦测器的复制
复制的本质实际上是这个方块添加进了移动列表,但是这个时候方块还没转化为 b36。我以某种迅雷不及掩耳盗铃的姿势插入一个更新,这个时候这个更新导致了某些东西被激活 / 掉落,但是因为这个方块本身已经滚进移动方块列表,到位的时候又被以 b36 的形式放下来了。
我们将复制分为两种,基于侦测器和基于附着性方块的。其中,基于侦测器的复制正利用了上述只要侦测器被移除(换成了 b36)的时候就会瞬时产生 NC 更新。
这里给出基于侦测器的铁轨复制:
在移动方块列表中,当粘性活塞推出时,会先移动左侧侦测器,在目标位置创建 b36,再移动粘液块,覆盖了亮起侦测器,更新,铁轨发现下面居然是 b36,掉落。移动铁轨,在目标位置创建 b36。
这样我们就完成了一次复制
2.2.1.3 基于附着方块破碎的复制
基于 BUD 的 TNT 复制
附着方块的掉落同样会产生更新,但是由于附着方块通常会直接掉落而难以二次利用。例如:
基于粉导向的极简 TNT 复制
地毯复制
下图中是一种极为流行的地毯复制:
当活塞收回时其完成一次复制,我们在这里对其稍做分析:
移动方块列表:
- 移动最下方的粘液块,在目标位置创建 b36
- 移动失活的珊瑚扇,在目标位置创建 b36,发出 PP 更新,自检发现自己应该掉落,发出 PP 更新和 NC 更新,引导上方地毯连锁掉落
- 移动其余粘液块和地毯
这就完成了复制。
#2.2.2 信息不一致的方块事件
根据 5.1 章节、2.1.5 章节等,我们可以发现:活塞在执行方块事件时只对其方块位置和方块类型进行校对。尽管记录了活塞朝向,但它只管辖 b36 的方向。
而活塞执行方块事件时的真实方向是即时采样的。
这意味着,在一个活塞的方块事件实例被在方块事件阶段中执行前,存在一个时段:此时,我们可以随意改变其对应位置的活塞的属性,甚至对此活塞本生进行破坏,更换。
如图所示,从上一游戏刻的 EU 到当前游戏刻的 BE,这段时间内我们可以修改某一方块事件的作用对象。
同理,从当前游戏刻的 EU 到下一游戏刻的 BE,也存在这样一段时间。
但只要执行到这个活塞的方块事件实例时,如果执行位置有活塞,且活塞类型没有改变,那么活塞尝试推出 / 拉回的朝向就会由执行时的朝向决定,尽管这个朝向可能并不是方块事件添加时的朝向。
有趣的是,b36 的收回时的动画仍然以方块事件传入的 data 参数为准,
所以,尽管方块事件执行时活塞朝向与添加时不同,活塞头的动画仍会面向方向方块事件添加时的活塞朝向(但碰撞箱是准确的)。
2.2.2.1 破基岩
跨越游戏刻的破基岩
活塞的逆袭之旅
正如前面所提到的,我们可以在活塞的方块事件阶段前,偷偷摸摸地修改活塞的属性,包括朝向。
这意味着我们可以在活塞推出时,将其朝向改为朝向基岩,然后在活塞收回时,就会将基岩当作活塞头摧毁。
下面的展示动画是一个典型, 在方块事件阶段前更改活塞朝向的破基岩例子。
在整个过程中,发生了如下重要事件
#CECECE {gt0}-EU 阶段
- 左侧 TNT 爆炸。
-
爆炸破坏拉杆。
-
爆炸破坏圆石。
-
右侧 TNT 爆炸。
- 爆炸破坏活塞头, 发出 nc 更新。
- 活塞基座被更新,由于拉杆已经被炸掉,活塞给自己添加收回的方块事件。
可惜的是,它已经错过了这个游戏刻的 BE 阶段,所以只能在 gt1 的 BE 阶段默默等待了。。
- 活塞头破坏活塞基座。
#CECECE {gt0}-AT 阶段
#CECECE {gt0}-BE 阶段
以上便是破基岩的全部过程。
但是,在破基岩机等结构内,由玩家放置活塞是不可能的,我们必须通过其他方式来实现这个效果。
在方块事件阶段完成的破基岩
"在活塞的方块事件阶段前" 的 "前" 不仅仅适用于其他的游戏阶段。就算在方块事件阶段内,你仍然可以在某个方块事件之前更改它对应的方块。
如你所见,下图是部分破基岩机中的破基岩结构的简化版本..
如动图所示,在整个过程中,发生了如下重要事件
前提 -- 上图面标记的数字是各个活塞的深度,他们按照这个顺序在 BE 阶段尝试伸出或收回。
#CECECE {gt0}-BE 阶段
- 1 号活塞收回,破坏活塞头, 发出 nc 更新
- 1 号活塞前方的活塞基座被更新,由于处于 BUD 状态,活塞给自己添加收回的方块事件。
由于深度 1,2,3 已被占用,它将会被安排到 4 号,也就是目前情况下的 "最后执行"。
- 活塞头破坏活塞基座。
- 2 号活塞伸出,创建了 3 个 b36。
- 3 号粘性活塞收回,将正前方的朝下的活塞的 b36 强制瞬间到位。
- 朝下的活塞现在的位置拥有先前 1 号活塞导致的方块事件,按照现有朝向收回,破坏基岩。
#2.2.3 欺骗活塞添加 / 取消添加方块事件
2.2.3.1 欺骗活塞取消添加方块事件: 推动上限检测
方块事件也会出现活塞觉得自己要执行的事件与实际状况不一致的情况,最典型的便是推动上限检测。
推动上限检测机制最初由玩家_Kayleigh 和 Landmining 发现,其核心在于 "欺骗" 活塞。这里展示的是 Bright_Observer 制作的简化版本,便于分析理解。
该装置虽具有方向性设计,但这并不影响其检测功能,因此可自由选择建造方向。
运行现象:
- 放下紫色羊毛 (羊毛连接了一根很长很长的柱子...) 后敲击音符盒,普通活塞会启动
- 破坏紫色羊毛后敲击,普通活塞则保持不变
原理分析:
- 上推活塞在被更新时自检
- 系统检查推动上限条件
- 若羊毛放下,因上方玻璃柱子已超出推动上限,活塞不会添加方块事件
- 此时激活两个普通活塞
- 粘性活塞被普通活塞更新,自检后伸出
- 羊毛不存在时:
粘性活塞正常运作
上方普通活塞的推出状态取决于朝向
下方普通活塞因深度关系保持原位
关键发现:
粘性活塞的计划动作与实际执行时推动结构可能存在差异,这种特性被巧妙地用于检测机制。
2.2.3.2 欺骗活塞添加方块事件
遗憾的是,如果你试图复刻5.10.1 破基岩的布局和激活顺序