这个是我新开的一个系列,说是分析,其实就是简单的看下源码,比较仔细的了解一下游戏机制。之所以开这个坑,一是因为最近玩MC比较多,被MC独特的游戏方式所吸引,碰到很多问题比如刷铁机、刷怪塔、物品分类的时候,会很想深入研究里面的原理。而MC虽然并不是开源软件,但是多年来玩家对MC的解包反编译工作已经非常成熟,甚至官方还自己提供了用于反编译的mapping,因此只需要懂一点java,就可以深入了解游戏内的各种机制。二是因为最近刚刚宣布1.17版本,前几天出了第一个snapshot,其中有一个特性让我觉得有点怪,就是潜影贝在被其他潜影贝的子弹攻击后,有几率生成一只新的潜影贝。虽然我并不是要在MC里讲科学,但这个改动本身确实有点“不太合常理”,而且这一改动意味着将来玩家会有稳定的潜影盒获取途径,从而很大程度上影响玩家之后的游戏体验,可能会导致潜影盒“掉价”。当然我不觉得这是一件坏事,毕竟在末地到处找潜影贝的痛苦我还是感同身受的。从这一点上来说,潜影贝农场以后会是玩家必做农场,没有之一,什么女巫塔、凋零骷髅塔、刷铁机全都靠边站。当然我不是专业的java程序员,对java的了解仅限于能大概看懂代码而已,所以可能文章会有错漏,如果你发现了任何错漏,或者单纯的想和我在这方面交流,都欢迎与我联系。

多说几句,MC彻底的高自由度决定了,任何游戏机制或是特性、BUG,都会被玩家利用到极致,以刷铁机为例,本身是一种村民的保护机制,在检测到危险的时候主动召唤铁傀儡来保护村庄,但是因为铁傀儡死掉会掉落铁锭,从而导致这一机制几乎只是为了玩家刷铁锭而存在的。我对这一现象其实颇为不满,我认为要么官方不要提供这样会被玩家滥用的机制,要么官方能提供更加优雅的机制。当然这不是今天的重点,因此就不展开谈了,我之后还会针对刷铁机进行分析,到时候再讨论这个问题吧。

言归正传,我对比了20w46a和1.16.3的服务端代码中有关潜影贝的部分,这是他们的diff结果

diff 1.16.3/server/net/minecraft/world/entity/monster/Shulker.java 20w46a/server/net/minecraft/world/entity/monster/Shulker.java
41a42
> import net.minecraft.world.item.DyeColor;
46a48
> import net.minecraft.world.level.entity.EntityTypeTest;
72c74
<       this.targetSelector.addGoal(1, (new HurtByTargetGoal(this, new Class[0])).setAlertOthers());
---
>       this.targetSelector.addGoal(1, (new HurtByTargetGoal(this, new Class[]{this.getClass()})).setAlertOthers());
290c292
<             if (var3.getY() > 0 && this.level.isEmptyBlock(var3) && this.level.getWorldBorder().isWithinBounds(var3) && this.level.noCollision(this, new AABB(var3))) {
---
>             if (var3.getY() > this.level.getMinBuildHeight() && this.level.isEmptyBlock(var3) && this.level.getWorldBorder().isWithinBounds(var3) && this.level.noCollision(this, new AABB(var3))) {
292a295
>                   this.unRide();
305c308
<          return true;
---
>          return false;
336a340
>       Entity var3;
338c342
<          Entity var3 = var1.getDirectEntity();
---
>          var3 = var1.getDirectEntity();
344c348,350
<       if (super.hurt(var1, var2)) {
---
>       if (!super.hurt(var1, var2)) {
>          return false;
>       } else {
346a353,357
>          } else if (var1.isProjectile()) {
>             var3 = var1.getDirectEntity();
>             if (var3 != null && var3.getType() == EntityType.SHULKER_BULLET) {
>                this.hitByShulkerBullet();
>             }
350,351d360
<       } else {
<          return false;
358a368,386
>    private void hitByShulkerBullet() {
>       Vec3 var1 = this.position();
>       AABB var2 = this.getBoundingBox();
>       if (!this.isClosed() && this.teleportSomewhere()) {
>          int var3 = this.level.getEntities((EntityTypeTest)EntityType.SHULKER, var2.inflate(8.0D), Entity::isAlive).size();
>          float var4 = (float)(var3 - 1) / 5.0F;
>          if (this.level.random.nextFloat() >= var4) {
>             Shulker var5 = (Shulker)EntityType.SHULKER.create(this.level);
>             DyeColor var6 = this.getColor();
>             if (var6 != null) {
>                var5.setColor(var6);
>             }
>
>             var5.moveTo(var1);
>             this.level.addFreshEntity(var5);
>          }
>       }
>    }
>
410a439,448
>    }
>
>    public void setColor(DyeColor var1) {
>       this.entityData.set(DATA_COLOR_ID, (byte)var1.getId());
>    }
>
>    @Nullable
>    public DyeColor getColor() {
>       byte var1 = (Byte)this.entityData.get(DATA_COLOR_ID);
>       return var1 != 16 && var1 <= 15 ? DyeColor.byId(var1) : null;

我们主要关注的其实是其中的hitByShulkerBullet这个函数,那么先单独摘出来看看:

private void hitByShulkerBullet() {
    // 被击中的潜影贝所在位置
    Vec3 var1 = this.position();
    // 被击中的潜影贝所处的BoundingBox(边界框)
    AABB var2 = this.getBoundingBox();
    // 如果 潜影贝不处于关闭状态 并且 成功的传送走了
    if (!this.isClosed() && this.teleportSomewhere()) {
        // var3 代表当前潜影贝17x17x17范围内的潜影贝总数
        int var3 = this.level.getEntities((EntityTypeTest)EntityType.SHULKER, var2.inflate(8.0D), Entity::isAlive).size();
        // var4 是一个小数,根据潜影贝总数不同而不同,决定了潜影贝生成的几率,
        // 1 -> 100%
        // 2 -> 80%
        // 3 -> 60%
        // 4 -> 40%
        // 5 -> 20%
        // 6 -> 0%
        float var4 = (float)(var3 - 1) / 5.0F;
        // 此处即是决定潜影贝是否生成成功
        if (this.level.random.nextFloat() >= var4) {
            // 生成一个新的潜影贝
            Shulker var5 = (Shulker)EntityType.SHULKER.create(this.level);
            // 下面4行设定潜影贝的颜色
            DyeColor var6 = this.getColor();
            if (var6 != null) {
                var5.setColor(var6);
            }
            // 移动新潜影贝至原潜影贝的位置
            var5.moveTo(var1);
            // 将新潜影贝注册至当前level
            this.level.addFreshEntity(var5);
        }
    }
}

其实这里已经非常清楚了,但是还有一处相关的代码需要贴出来:

public boolean hurt(DamageSource var1, float var2) {
    // hurt 是一个用于处理伤害的函数,var1是施加伤害的实体,var2是伤害数值,最终如果伤害成功,则返回true,否则返回false
    Entity var3;
    if (this.isClosed()) {
        // 此处为潜影贝关闭时免疫箭矢伤害,和小黑类似
        var3 = var1.getDirectEntity();
        if (var3 instanceof AbstractArrow) {
            return false;
        }
    }

    if (!super.hurt(var1, var2)) {
        // 如果伤害施加不成功,则返回false,此时不会生成新潜影贝
        return false;
    } else {
        if ((double)this.getHealth() < (double)this.getMaxHealth() * 0.5D && this.random.nextInt(4) == 0) {
            // 成功施加伤害,且潜影贝生命值少于50%,此时有20%的几率潜影贝会直接传送走且不会生成新潜影贝
            this.teleportSomewhere();
        } else if (var1.isProjectile()) {
            var3 = var1.getDirectEntity();
            if (var3 != null && var3.getType() == EntityType.SHULKER_BULLET) {
                // 如果不满足上面的条件且施加伤害的实体是潜影贝的子弹,则会调用上面的函数
                this.hitByShulkerBullet();
            }
        }

        return true;
    }
}

之前说了,生成新潜影贝的条件之一是旧的潜影贝成功传送走,那么成功传送走的条件是什么呢?

protected boolean teleportSomewhere() {
    if (!this.isNoAi() && this.isAlive()) {
        // 当前位置
        BlockPos var1 = this.blockPosition();
        // 总共进行5次传送尝试
        for(int var2 = 0; var2 < 5; ++var2) {
            // 随机选取17x17x17范围内的一格
            BlockPos var3 = var1.offset(8 - this.random.nextInt(17), 8 - this.random.nextInt(17), 8 - this.random.nextInt(17));
            // 如果该位置大于最小建筑高度,且是空方块(空气方块),且不会有碰撞冲突问题
            if (var3.getY() > this.level.getMinBuildHeight() && this.level.isEmptyBlock(var3) && this.level.getWorldBorder().isWithinBounds(var3) && this.level.noCollision(this, new AABB(var3))) {
                // 在该位置上找一个可以附着的面
                Direction var4 = this.findAttachableFace(var3);
                if (var4 != null) {
                    // 如果成功找到,则首先尝试“下车”,再传送,设置朝向,播放传送音效登,仅有此时传送成功,返回true,其他情况均返回false
                    this.unRide();
                    this.entityData.set(DATA_ATTACH_FACE_ID, var4);
                    this.playSound(SoundEvents.SHULKER_TELEPORT, 1.0F, 1.0F);
                    this.entityData.set(DATA_ATTACH_POS_ID, Optional.of(var3));
                    this.entityData.set(DATA_PEEK_ID, (byte)0);
                    this.setTarget((LivingEntity)null);
                    return true;
                }
            }
        }

        return false;
    } else {
        return false;
    }
}

可以看到还是相当明白的,那么总结一下潜影贝的生成条件:

  1. 被潜影贝子弹击中,无论是该潜影贝自己发出的还是其他潜影贝发出的都可以
  2. 如果此时潜影贝生命值低于50%,则有20%机会直接传送走而不生成新潜影贝
  3. 处于打开状态,被潜影贝子弹击中,此时潜影贝会尝试传送走
  4. 成功传送走,条件比较复杂
  5. 如果满足了上述四个条件,则新潜影贝的生成机率和以潜影贝传送前位置为中心17x17x17范围内的潜影贝数量有关。用S代表潜影贝总数,其概率为

正常情况下S不会小于1,因为潜影贝只能传送到17x17x17范围内

理论上来说,生成效率主要取决于潜影贝的攻击频率,攻击频率越高,可能的效率就越高,类似ilmango的设计应该是相当高效的。总的来说,提高效率的思路无非是以下几点:

  1. 尽量快速清除生成的/传送走的潜影贝,最好使周围17x17x17范围内的仅存在一个潜影贝
  2. 尽量提高潜影贝成功传送的概率,需设计17x17x17范围内排布方式,假设该范围内有n个位置可传送,则传送成功的概率为 𝑝=1−((173−𝑛)/173)5
  3. 尽量保持潜影贝的血量在50%以上,这条影响不是很大,如果不满足,大约会降低20%的效率
  4. 尽量使潜影贝子弹成功击中

其中,4是必须要想办法满足的前提,且比较复杂,不细说。能动性比较高的主要是1和2,如果能保持17x17x17范围内仅存在一个潜影贝,那么1就不会影响效率。而2,假设17x17x17范围内有100个位置可以传送,则每次传送成功的概率约为10%,而大约在636个位置的时候,传送成功率达到了50%,也就是说,如果其他条件都一样,那么636个位置的效率会是100个位置的效率的5倍。636个位置如果不管排布方式,则其实并不是很难,因为每个方块有6个面,则放106个建筑方块就满足条件了,实际情况中如ilmongo的设计采用的是单平面可传送,那么每个方块只有一个面可以传送,此时需要636个方块作为潜在的传送位置。也就是一片53x12的墙,或者近似做6块10x12的墙,这在17x17x17的范围内并不是什么难事。

下图展示了传送成功概率随可传送位置数量变化的曲线,可以看到,可传送位置越多,提升就越不明显,当概率高于0.8以上时,就明显不值得在这里付出精力了。而且范围内总共也就4913个方块,要在一定条件下满足这么多可传送位置并不容易。即使按照每个方块提供6个可传送位置来算,范围内最多可传送位置也仅有4200个。而实际上这是根本不可能做到的事情,因为还需要做处死或者转移潜影贝的装置。

Figure 4: 传送成功概率随可传送位置数量而变化的函数