光速电梯的完全解析
TL;DR:
24w45a为setOnGroundWithMovement增加了额外条件,使玩家在纯水平推动时不会更新onGround,从而允许同一游戏刻内无限次自动爬台阶。
最近,一款“光速电梯”(B站搬运:BV1dtEJ68Eyb)的设计横空出世,引起不小关注。
遗憾的是,原作者,明月庄主与CraftyMasterMan对这款电梯原理的解释尚不够深入,仍有诸多细节问题未得到充分说明。为填补这些空白,本文将围绕以下几个问题展开研究与讨论:
- 玩家究竟是在游戏的哪个阶段被传送至电梯顶部?
- 误区: 左侧方块必须不能与墙连接吗?
- 玩家移动的具体原理是什么?
- 为什么其他实体无法像玩家一样使用该电梯?
- 为什么该电梯仅能在 1.21.4 及之后的版本中实现?
玩家究竟是在游戏的哪个阶段被传送至电梯顶部?
解释视频中提到,该电梯的原理是在同一个游戏刻内,通过两侧活塞交替推动玩家,使玩家不断攀爬至另一侧已推出的活塞顶部: 一侧活塞首先推动玩家,使其爬上另一侧先前推出的活塞;随后,另一侧活塞再次推动玩家,使其爬上刚刚推动自己的活塞。如此循环,玩家便能在一个游戏刻内一路攀爬至电梯顶部。
由于两个视频均通过放慢活塞激活速度来人工模拟单个游戏刻内发生的过程,因此容易造成误解:玩家的攀爬似乎发生在移动中的活塞转变为普通方块时,由方块挤压玩家所导致。实际上并非如此。
在该电梯中,玩家从底部连续攀爬至电梯顶部的整个过程,发生在两侧活塞开始推出后的第 2 个游戏刻的方块实体(移动的活塞)运算阶段,而非移动活塞完成推出、转变为普通方块之后。 在方块实体阶段,是"移动的活塞"方块主动调用了实体的move(MoverType movertype, Vec3 vec3)方法,从而触发了实体的自动爬高逻辑,导致上爬。
误区:左侧方块必须不能与墙连接吗?
明月庄主的视频中提到:
如果左边使用完整方块,石墙会与其连接,从而使石墙的面积变窄(影响有效的推动范围)。 因此,需要使用不会与石墙连接的方块,例如楼梯或半砖。
结合前文“玩家究竟是在游戏的哪个阶段被传送至电梯顶部?”的结论,我们可以重新分析这一说法。
首先,根据前文的结论,玩家的运动发生在方块实体阶段。此时,一侧的墙方块仍然只是“移动的活塞”伪装成的墙,另一侧的方块也同样。因此二者不会发生墙连接,自然也就不存在因连接而导致石墙面积减小的问题。
因此,这一解释并不能成立。至于原作者为何采用左侧使用楼梯而非完整方块的设计,我们不得而知,也许是出于对类似问题的担忧。
对应的优化:
所以实际上机器的大小可以进一步缩减:一侧粘性活塞+墙,一侧普通活塞即可。看起来像这样子:
自 Tjs-Redstone 的优化版
玩家移动的具体原理是什么?
关于具体的爬高部分,我们可以追溯到Entity.java中,Vec3 collide(final Vec3 movement) 方法的这段逻辑:
boolean xCollision = movement.x != movementStep.x;
boolean yCollision = movement.y != movementStep.y;
boolean zCollision = movement.z != movementStep.z;
boolean onGroundAfterCollision = yCollision && movement.y < 0.0;
if (this.maxUpStep() > 0.0F && (onGroundAfterCollision || this.onGround()) && (xCollision || zCollision)) {
AABB groundedAABB = onGroundAfterCollision ? aabb.move(0.0, movementStep.y, 0.0) : aabb;
AABB stepUpAABB = groundedAABB.expandTowards(movement.x, this.maxUpStep(), movement.z);
if (!onGroundAfterCollision) {
stepUpAABB = stepUpAABB.expandTowards(0.0, -1.0E-5F, 0.0);
}
List<VoxelShape> colliders = collectCollidersIgnoringWorldBorder(this, this.level, entityColliders, stepUpAABB);
float stepHeightToSkip = (float)movementStep.y;
float[] candidateStepUpHeights = collectCandidateStepUpHeights(groundedAABB, colliders, this.maxUpStep(), stepHeightToSkip);
for (float candidateStepUpHeight : candidateStepUpHeights) {
Vec3 stepFromGround = collideWithShapes(new Vec3(movement.x, candidateStepUpHeight, movement.z), groundedAABB, colliders);
if (stepFromGround.horizontalDistanceSqr() > movementStep.horizontalDistanceSqr()) {
double distanceToGround = aabb.minY - groundedAABB.minY;
return stepFromGround.subtract(0.0, distanceToGround, 0.0);
}
}
}别害怕,下面有文字版的伪代码:
计算正常碰撞后的移动结果 movementStep;
如果 (
最大可攀爬属性 > 0
且 (碰撞后落地 或 当前站在地面)
且 (X 或 Z 方向发生碰撞)
) {
构造允许尝试攀爬的碰撞区域;
收集该区域内所有碰撞体;
计算所有可能的攀爬高度;
对每一个候选攀爬高度 {
模拟:
先向上攀爬 candidateStepUpHeight;
再执行原本的水平移动;
如果模拟后的水平移动距离 大于 普通碰撞后的水平移动距离 {
返回"攀爬后的移动结果";
}
}
}
否则返回普通碰撞后的移动结果;简而言之,当玩家发生水平碰撞 且 满足自动攀爬条件(碰撞后落地 或 当前站在地面)时,游戏会尝试模拟不同的攀爬高度;如果某一种方案能够使玩家移动得更远,就采用该方案,否则保持原有的碰撞结果。
在电梯传送玩家的场景里(注意,电玩家的场景,后文会细说。):
- 玩家的最大可攀爬属性 > 0(否则会像船一样,即使只有 1 像素高的碰撞体也无法越过)
- 使玩家与另一侧高出脚部 0.5 格的碰撞体发生水平碰撞,因此满足
玩家发生水平碰撞。 - 玩家被推前站的好好的,满足
当前站在地面的条件。 碰撞后落地条件并不成立(由于活塞仅在水平方向推动玩家,y轴移动 为 0,自然不可能产生向下的竖直碰撞)
由于当前站在地面与碰撞后落地为或关系,因此即使后者不成立,玩家仍然能够进入自动攀爬逻辑。 随后,游戏会尝试计算攀爬后的移动结果,从而使玩家爬到另一侧先前已经推出的,移动的活塞方块的顶部。
为什么其他实体无法使用这款电梯?
这才是这款电梯真正有趣的地方。 在研究这一问题的过程中,我们不仅找到了其他实体无法使用该电梯的原因,还发现了此前未曾注意到的新机制与现象。下面,我们将逐一展开分析。
仔细观察后,又有了一个新的发现:实体虽然会爬高,但只会爬高一次。 这意味着,某种机制阻止了实体在同一个游戏刻内多次执行自动爬高。 实体在完成第一次爬高后,一定有某个条件发生了变化,导致它无法再次满足进入自动爬高逻辑所需的条件。 回顾前文提到的四个条件,我们逐一进行排除:
实体的最大可攀爬属性 > 0: 一定是真对于大部分实体,如村民等,这个属性都是大于0的。使实体与另一侧高出脚部 0.5 格的碰撞体发生水平碰撞,因此满足玩家发生水平碰撞:一定是真,实体被推动时必然会被另一侧挡下碰撞后落地 条件并不成立(由于活塞仅在水平方向推动玩家,y轴移动 为 0,自然不可能产生向下的竖直碰撞)一定是假,如括号内的描述
那只剩下最后一个条件是可疑的了:
实体被推前站的好好的,满足 当前站在地面 的条件
通过 1.21.1 的 Fabric Mod 开发环境,进行断点调试,我们验证了上述猜想。
当实体第一次被活塞推动时,它成功进入了自动爬高逻辑;而当第二次被推动时,却没有再次进入该逻辑。我们发现,表示当前是否站在地面的 onGround 变量,在第一次攀爬完成后便由 true 变为了 false,并且在同一游戏刻内后续的所有推动过程中始终保持为 false,直到下一游戏刻才重新恢复为 true。
这也就解释了为什么其他实体只能完成第一次爬高:第一次攀爬后,onGround 状态不再为真,导致它们无法再次满足自动攀爬所需的 当前站在地面 条件,因此后续的推动都不会再次触发自动爬高逻辑。
##问题在于,这是什么导致的呢,为什么玩家的onGround状态就不会被重置为false呢?
我们继续追踪源码,看看 onGround 究竟是如何被设置的。
实体的移动由 move 方法负责。在该方法中,游戏首先调用前文分析过的 collide 方法,计算碰撞后的实际移动结果;随后,根据移动结果重新更新实体的碰撞状态,并判断是否处于地面上。
在 1.21.11 中,这部分逻辑如下:
boolean xCollision = !Mth.equal(delta.x, movement.x);
boolean zCollision = !Mth.equal(delta.z, movement.z);
this.horizontalCollision = xCollision || zCollision;
boolean movedVertically = Math.abs(delta.y) > 0.0;
if (movedVertically || this.isLocalInstanceAuthoritative()) {
this.verticalCollision = delta.y != movement.y;
this.verticalCollisionBelow = this.verticalCollision && delta.y < 0.0;
this.setOnGroundWithMovement(this.verticalCollisionBelow, this.horizontalCollision, movement);
}伪代码如下:
调用 collide(),计算碰撞后的实际移动结果 delta
计算是否发生水平碰撞(实际移动后的位置是否于“移动前的位置+动量”相同
如果(
y动量非0
或
本地[服务器端]实体拥有对自己位置的解释权 //注意这里!
){
计算是否发生竖直碰撞;
设置onGround为:“发生竖直碰撞 且 y动量 < 0”
}我们也可以来分析一下,活塞水平推动时这一段的逻辑表现:
- y动量非0 : 假,水平推动不提供y轴动量
- 本地[服务器端]实体拥有对自己位置的解释权 : ==this.isLocalInstanceAuthoritative(),待考察,先假设通过吧。
因此,对于村民这样的实体来说,是可以走入这段“设置onGround”逻辑的。
接下来:
- 是否发生竖直碰撞:假,活塞不提供y轴动量。
- 发生竖直碰撞 且 y动量 < 0 : 假
“活塞不提供y轴动量”已经说了n次了哈哈
因此,实体的onGround被设为了false?!
也就是说,在电梯的运行环境下,只要 this.isLocalInstanceAuthoritative() 返回 true,实体在第一次完成攀爬后,onGround 就会立即被重新计算,并由于没有发生向下碰撞而变为 false。这样一来,后续推动便无法再次满足 当前站在地面 的条件,自然也就无法再次进入自动攀爬逻辑。
那么,this.isLocalInstanceAuthoritative() 又是什么呢?我们继续查看源码:
public final boolean isLocalInstanceAuthoritative() {
return this.level.isClientSide()
? this.isLocalClientAuthoritative()
: !this.isClientAuthoritative();
}
protected boolean isLocalClientAuthoritative() {
LivingEntity passenger = this.getControllingPassenger();
return passenger != null && passenger.isLocalClientAuthoritative();
}
public boolean isClientAuthoritative() {
LivingEntity passenger = this.getControllingPassenger();
return passenger != null && passenger.isClientAuthoritative();
}可以看出,这实际上是一套移动解释权(Authority,至少我倾向于这样描述)机制。
对于绝大多数实体,isClientAuthoritative() 都返回 false,因此在服务端环境下,isLocalInstanceAuthoritative() 为 true。这意味着它们每次移动后都会立即更新 onGround,从而在第一次攀爬后失去再次进入自动攀爬逻辑的机会。
而对于玩家实体以及正在被玩家骑乘的载具,的isClientAuthoritative() 返回 或 因被玩家控制而返回true,进而使服务端的 isLocalInstanceAuthoritative() 返回 false。在这种情况下,onGround 不会在每次水平推动后立即重新计算,从而得以保留为 true!
于是着能够允许实体在同一个游戏刻内反复进入自动攀爬逻辑,最终一路攀爬至电梯顶部。
因此,并非只有玩家能够使用这款电梯。 凡是能够由玩家操控,且具有越过方块能力的实体,在被玩家骑乘的情况下,同样可以正常使用这款电梯。 例如,马、骆驼,以及满足上述条件的部分模组实体,都可以正常使用该电梯。 需要注意的是,猪和羊驼虽然也可以被玩家骑乘,但玩家并不拥有它们的实际移动控制权,因此它们并不属于客户端解释权实体,无法利用这一机制使用该电梯。
对应的优化:
这一发现也带来了一个更加激进的优化思路。
马的最大可攀爬高度达到 1 格方块。这意味着,当玩家骑乘马使用电梯时,两侧均可使用普通活塞,无需再依赖粘性活塞 + 墙的设计来提供 0.5 格高的碰撞体。
在骑乘马的情况下,这款电梯完全可以采用更加简单的双普通活塞结构。
这是首个架构
以及自 Tjs-Redstone 的优化版
收获:
在分析这一机制的过程中,我们还收获了两个有趣的机制,以及一个 Carpet 模组问题:
普通实体在仅发生水平移动(
y轴动量为 0)时,无论实际是否站在地面,其onGround都会被更新为false。值得注意的是,绝大多数实体在自主移动时都会持续受到重力影响,因此即使站在地面,
y轴动量通常也不会为 0,这一现象平时并不容易观察到。玩家以及由玩家控制的实体在仅发生水平移动(
y轴动量为 0)时,onGround不会被自动更新,因此能够在同一游戏刻内多次进入自动攀爬逻辑。同样,由于正常情况下实体始终受到重力影响,这一机制在普通游戏过程中也并不明显。
Carpet 模组的假玩家无法复现第二种行为,因为 Carpet 注入了
Entity.java,修改了假玩家对应的isLocalInstanceAuthoritative行为。当服务器端的乘客为假玩家时,该方法会返回true,使骑乘的马能够正常更新onGround状态,从而仍然无法在同一个游戏刻内多次爬高。
@Inject(method = "isLocalInstanceAuthoritative", at = @At("HEAD"), cancellable = true)
private void isFakePlayer(CallbackInfoReturnable<Boolean> cir)
{
// getControllingPassenger() does not return the EntityPlayerMPFake if there are no passengers involved with it
if ((Object) this instanceof EntityPlayerMPFake || getControllingPassenger() instanceof EntityPlayerMPFake) cir.setReturnValue(!level.isClientSide());
}为什么该电梯仅能在 1.21.4 及之后的版本中实现?
有了前文的分析,这个问题的答案就十分简单了。
通过对比不同版本的源码可以发现,isLocalInstanceAuthoritative 这套移动解释权机制是在 25w02a 附近开始约束onGround的更新。在此之前,如1.21.3,重新计算onGround不受任何条件约束:
this.horizontalCollision = bl || bl2;
this.verticalCollision = vec3.y != vec32.y;
this.verticalCollisionBelow = this.verticalCollision && vec3.y < 0.0;
if (this.horizontalCollision) {
this.minorHorizontalCollision = this.isHorizontalCollisionMinor(vec32);
} else {
this.minorHorizontalCollision = false;
}
this.setOnGroundWithMovement(this.verticalCollisionBelow, this.horizontalCollision, vec32); //不受任何约束
BlockPos blockPos = this.getOnPosLegacy();
BlockState blockState = this.level().getBlockState(blockPos);
if (!this.level().isClientSide() || this.isControlledByLocalInstance()) {
this.checkFallDamage(vec32.y, this.onGround(), blockState, blockPos);
}因此,在此之间,玩家和其他实体一样,只能爬高一次,随后会因为onGround为false而无法再次爬高!
在具体的查找下,我们发现了关键的初次修改,现在,我们可以绘制出完整的代码变更路径:
- 24w44a以前:
this.verticalCollision = vec3.y != vec32.y;
this.verticalCollisionBelow = this.verticalCollision && vec3.y < 0.0;
if (this.horizontalCollision) {
this.minorHorizontalCollision = this.isHorizontalCollisionMinor(vec32);
} else {
this.minorHorizontalCollision = false;
}
this.setOnGroundWithMovement(this.verticalCollisionBelow, this.horizontalCollision, vec32); //不受任何约束- 24w45a:
if (this.horizontalCollision) {
this.minorHorizontalCollision = this.isHorizontalCollisionMinor(vec32);
} else {
this.minorHorizontalCollision = false;
}
if (Math.abs(vec3.y) > 0.0 || this.isControlledByOrIsLocalPlayer()) {
this.verticalCollision = vec3.y != vec32.y;
this.verticalCollisionBelow = this.verticalCollision && vec3.y < 0.0;
this.setOnGroundWithMovement(this.verticalCollisionBelow, this.horizontalCollision, vec32);
}- 25w02a以后:
if (Math.abs(vec3.y) > 0.0 || this.isControlledByOrIsLocalPlayer()) {
if (Math.abs(vec3.y) > 0.0 || this.isLocalInstanceAuthoritative()) {
this.verticalCollision = delta.y != movement.y;
this.verticalCollisionBelow = this.verticalCollision && delta.y < 0.0;
this.setOnGroundWithMovement(this.verticalCollisionBelow, this.horizontalCollision, movement);
}由此,我们可以得出电梯的具体可用范围从24w45a快照开始。
总结
总结: 光速电梯从
24w45a快照开始得以实现,其根本原因是move方法中的setOnGroundWithMovement在该版本新增了movedVertically || this.isLocalInstanceAuthoritative()条件。此前,纯水平(如活塞水平推出,y动量为0)移动实体时,onGround总会更新为false,阻止实体在同一游戏刻内多次利用自动爬台阶连续上升;而对于玩家及玩家控制的实体,isLocalInstanceAuthoritative()始终为false,因此在仅受水平推动(movedVertically == false)时,setOnGroundWithMovement不再执行,也就不会将onGround重置为false。这使得玩家等能够在同一游戏刻内反复保持onGround = true并连续触发自动爬台阶,从而实现无限次爬高,即光速电梯。
