补充内容

2024-08-09

  • 附上视频:

2024-08-07

  • 本文中对“寻路”的理解可能存在一定的问题。我查了维基百科上对“寻路”一词的解释,上面说寻路是“由计算机应用程序规划两点之间的最短路线”。因此实际上此处应该是不包括具体飞向路径的过程的。为了与视频中内容尽量呼应而不自相矛盾,我没有修改正文中的内容

2024-08-03

  • 下文中所有“已编号节点”的编号都是“MC规定好”的,而不是我自己规定的
  • 下文中所有阶段的中文名是我综合了Wiki中的末影龙页面对末影龙行为的描述、末影龙各个阶段的类的MCP名以及各个阶段的实际作用而命名的

前言

近来笔者想投稿与末影龙原理相关的视频,但笔者虽然对末影龙有一定的了解,却尚未对末影龙进行较细致的分析,因此写了这样一篇文章。笔者写这篇文章既是为了给自己即将制作的视频理清思路,也是为了提供一些有关末影龙的代码层面的解读。本文代码部分较多,因此较适合对Java与Minecraft模组制作有一定的了解的读者进行阅读。当然本文对于不了解Java语言或Minecraft modding的读者也有一定的参考意义。

注:

  1. 使用的Minecraft版本为1.20.2,Forge版本为48.0.30,MCP版本为20230921.100330,笔者曾查看了1.16.5与1.21版本中末影龙的底层实现,总体来看均没有明显差异(当然不排除笔者有疏忽的可能)
  2. 本文只是简要的分析,因而只会选择核心内容,并不面面俱到。1.20.1和1.20.2中末影龙的代码是没有区别的,但以后更新Polonium上有关末影龙的内容时笔者会写得更详细一些~

在原版世界中,末影龙是驻守在末路之地的一个强大的龙形生物,也是原版Minecraft的最终boss,不少玩家将击杀末影龙作为通关MC的标准。末影龙的底层实现也相当复杂,它不仅使用了一套独立的AI系统,寻路的算法也与一般的生物有一定的区别。一年多前笔者曾经仿照末影龙在一个未发布的mod中制作了一种与末影龙相似,但行为更复杂的龙型生物作为自定义维度的一个小boss,因此对末影龙有一定的了解。本文将从末影龙的寻路系统,末影龙的AI,及末影龙的其他重要行为这三个方面进行分析。

末影龙的寻路系统

在分析寻路系统之前,先介绍一下末影龙的24个已编号的路径节点(下文中会把这24个路径节点称为“已编号节点”)的分布。

已编号节点的分布

末影龙的已编号节点共分为3圈,其中外圈12个节点,中圈8个节点,内圈4个节点。可以用以下的一张图来表示(下图中青色点表示坐标原点,淡青色细线表示坐标轴,红色、蓝色箭头所指方向分别为x、z轴正半轴方向,蓝绿色、绿色、黄绿色点分别表示外、中、内圈的已编号节点):
Markers -Numbers, Circles.webp
将外圈已编号节点用0~11编号,中圈已编号节点用12~19编号,内圈已编号节点用20~23编号,可得到下图:
Markers -Circles.webp
而查阅源代码可知,外、中、内圈的已编号节点距离原点的水平距离分别近似为60、40、20。将它们分别放在3个同心圆上可得下图(注意不一定所有已编号节点都恰好位于同心圆上,因为任何路径节点的坐标都是取整存储的):
Markers.webp
这是一张末地的地形图(从30多张末地地形图中选出来的比较好看的一张233)
End Terrain Shadered.png
两图叠加可得:
End Terrain Shadered +All.png
从这张叠加的图中我们可以直观地看出24个已编号节点的大致分布情况,同时可以发现中圈与黑曜石柱所在圆的位置相近(黑曜石柱中心到原点的水平距离约为42,这个数值与中圈的已编号节点到原点的水平距离40接近)。

末影龙的寻路流程

下面正式开始分析末影龙的寻路系统,先看EnderDragon类里声明的这样的3个与寻路相关的成员变量。

private final Node[] nodes = new Node[24];
private final int[] nodeAdjacency = new int[24];
private final BinaryHeap openSet = new BinaryHeap();

其中nodes是末影龙的路径节点数组,其中所有节点的x、z坐标分别是定值,但y坐标根据末地的实际地形与节点在哪一圈来确定。nodeAdjacency是邻接矩阵,用于记录与每个节点相邻的节点的编号。而openSet是二叉堆,在末影龙的寻路过程中起到作用。

总的来说,末影龙的寻路过程的步骤可分为这样几步:

  1. 确定起点和终点的位置
  2. 寻找起点到终点的一条最短路径。这条路径中除了终点之外,其余的路径节点各自一定是已编号节点中的一个
  3. 根据寻路得到的路径,一步一步按路径点飞向终点

2024-08-07更新:这里的“寻路过程”改为“寻路与沿路径飞行的过程”更合适,因为第三步实际上不是“寻路”过程中的一部分

末影龙每次寻路时,都需要先确定到自己最近的节点的编号,下面两个方法则寻找了已编号节点中离末影龙自己最近的节点。

public int findClosestNode() {
    if (nodes[0] == null) {
        // 节点坐标的初始化,由此我们可以看出路径节点坐标是怎么来的
        for (int i = 0; i < 24; ++i) {
            int yOffset = 5;
            int x;
            int z;
            if (i < 12) {
                // 注:0.2617994F = Math.PI / 12F
                x = Mth.floor(60.0F * Mth.cos(2.0F * (-(float) Math.PI + 0.2617994F * (float) i)));
                z = Mth.floor(60.0F * Mth.sin(2.0F * (-(float) Math.PI + 0.2617994F * (float) i)));
            } else if (i < 20) {
                int j = i - 12;
                x = Mth.floor(40.0F * Mth.cos(2.0F * (-(float) Math.PI + ((float) Math.PI / 8F) * (float) j)));
                z = Mth.floor(40.0F * Mth.sin(2.0F * (-(float) Math.PI + ((float) Math.PI / 8F) * (float) j)));
                yOffset += 10; // 由此可知,中圈的已编号节点往往会更高,当然也不绝对,因为节点的y坐标还受到地形影响
            } else {
                int k = i - 20;
                x = Mth.floor(20.0F * Mth.cos(2.0F * (-(float) Math.PI + ((float) Math.PI / 4F) * (float) k)));
                z = Mth.floor(20.0F * Mth.sin(2.0F * (-(float) Math.PI + ((float) Math.PI / 4F) * (float) k)));
            }
            int y = Math.max(level().getSeaLevel() + 10, level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, new BlockPos(x, 0, z)).getY() + yOffset);
            nodes[i] = new Node(x, y, z);
        }
        // 邻接矩阵的初始化
        nodeAdjacency[0] = 6146;      // 0b000000000001100000000010
        nodeAdjacency[1] = 8197;      // 0b000000000010000000000101
        nodeAdjacency[2] = 8202;      // 0b000000000010000000001010
        nodeAdjacency[3] = 16404;     // 0b000000000100000000010100
        nodeAdjacency[4] = 32808;     // 0b000000001000000000101000
        nodeAdjacency[5] = 32848;     // 0b000000001000000001010000
        nodeAdjacency[6] = 65696;     // 0b000000010000000010100000
        nodeAdjacency[7] = 131392;    // 0b000000100000000101000000
        nodeAdjacency[8] = 131712;    // 0b000000100000001010000000
        nodeAdjacency[9] = 263424;    // 0b000001000000010100000000
        nodeAdjacency[10] = 526848;   // 0b000010000000101000000000
        nodeAdjacency[11] = 525313;   // 0b000010000000010000000001
        nodeAdjacency[12] = 1581057;  // 0b000110000010000000000001
        nodeAdjacency[13] = 3166214;  // 0b001100000101000000000110
        nodeAdjacency[14] = 2138120;  // 0b001000001010000000001000
        nodeAdjacency[15] = 6373424;  // 0b011000010100000000110000
        nodeAdjacency[16] = 4358208;  // 0b010000101000000001000000
        nodeAdjacency[17] = 12910976; // 0b110001010000000110000000
        nodeAdjacency[18] = 9044480;  // 0b100010100000001000000000
        nodeAdjacency[19] = 9706496;  // 0b100101000001110000000000
        nodeAdjacency[20] = 15216640; // 0b111010000011000000000000
        nodeAdjacency[21] = 13688832; // 0b110100001110000000000000
        nodeAdjacency[22] = 11763712; // 0b101100111000000000000000
        nodeAdjacency[23] = 8257536;  // 0b011111100000000000000000
    }
    return findClosestNode(getX(), getY(), getZ());
}

public int findClosestNode(double x, double y, double z) {
    float minDistanceSqr = 10000.0F;
    int nearestNodeId = 0;
    Node currentPositionNode = new Node(Mth.floor(x), Mth.floor(y), Mth.floor(z));
    int startId = 0;
    if (dragonFight == null || dragonFight.getCrystalsAlive() == 0) {
        startId = 12; // 如果不存在dragonFight或者末地水晶全部被破坏掉了,则忽视外圈的节点
    }
    for (int i = startId; i < 24; ++i) {
        if (nodes[i] != null) {
            float distanceToSqr = nodes[i].distanceToSqr(currentPositionNode);
            if (distanceToSqr < minDistanceSqr) {
                minDistanceSqr = distanceToSqr;
                nearestNodeId = i;
            }
        }
    }
    return nearestNodeId;
}

注:下表中列出了所有已编号节点的x、z坐标和相邻节点(即在nodeAdjacency中存储的,从一个已编号节点可以直接到达的另一个已编号节点)的编号:

节点编号x坐标z坐标相邻节点
06001, 11, 12
151300, 2, 13
229511, 3, 13
30602, 4, 14
4-31513, 5, 15
5-52294, 6, 15
6-6005, 7, 16
7-52-316, 8, 17
8-30-527, 9, 17
90-608, 10, 18
1029-529, 11, 19
1151-300, 10, 19
124000, 13, 19, 20
1328281, 2, 12, 14, 20, 21
140403, 13, 15, 21
15-29284, 5, 14, 16, 21, 22
16-4006, 15, 17, 22
17-29-297, 8, 16, 18, 22, 23
180-409, 17, 19, 23
1928-2910, 11, 12, 18, 20, 23
2020012, 13, 19, 21, 22, 23
2102013, 14, 15, 20, 22, 23
22-20015, 16, 17, 20, 21, 23
230-2017, 18, 19, 20, 21, 22

寻找完路径终点后,下一步是构建最短路径。这两个方法与路径的构建有关。下文中“希望经过的节点”指除已编号节点外的,末影龙想要到达的目标节点,比如末影龙攻击目标附近的一个位置等。注意末影龙寻路至任何未编号的节点时,其所得路径上倒数第二个节点一定是已编号节点。

@Nullable
public Path findPath(int startId, int endId, @Nullable Node expectedToPass) {
    // 初始化节点的f(综合优先级), g(距离起点的代价), h(距离终点的预计代价)值等
    for (int i = 0; i < 24; ++i) {
        Node node = nodes[i];
        node.closed = false;
        node.f = 0;
        node.g = 0;
        node.h = 0;
        node.cameFrom = null;
        node.heapIdx = -1;
    }
    Node start = nodes[startId];
    Node end = nodes[endId];
    start.g = 0;
    start.h = start.distanceTo(end);
    start.f = start.h;
    openSet.clear();
    openSet.insert(start);
    // “当前”的路径终点,如果未成功到达想要到达的终点则会返回到这个节点的路径      
    Node currentEnd = start;
    int startCheckingId = 0;
    if (dragonFight == null || dragonFight.getCrystalsAlive() == 0) {
        startCheckingId = 12;
    }
    // 以下内容是用A*算法寻路的核心过程
    while (!openSet.isEmpty()) {
        Node top = openSet.pop();
        // 堆顶节点为终点,则返回路径
        if (top.equals(end)) {
            if (expectedToPass != null) {
                // 如果末影龙有“希望经过的节点”,则把终点调整为这个“希望经过的节点”
                expectedToPass.cameFrom = end;
                end = expectedToPass;
            }
            return reconstructPath(start, end);
        }
        if (top.distanceTo(end) < currentEnd.distanceTo(end)) {
            currentEnd = top;
        }
        top.closed = true;
        int topId = 0;
        for (int i = 0; i < 24; ++i) {
            if (nodes[i] == top) {
                topId = i;
                break;
            }
        }
        for (int i = startCheckingId; i < 24; ++i) {
            if ((nodeAdjacency[topId] & 1 << i) > 0) {
                Node node = nodes[i];
                if (!node.closed) {
                    // 如果遍历到的节点可以直接到达,且不在closeSet中,则更新节点的f值
                    float g = top.g + top.distanceTo(node);
                    if (!node.inOpenSet() || g < node.g) {
                        node.cameFrom = top;
                        node.g = g;
                        node.h = node.distanceTo(end);
                        if (node.inOpenSet()) {
                            openSet.changeCost(node, node.g + node.h);
                        } else {
                            node.f = node.g + node.h;
                            openSet.insert(node);
                        }
                    }
                }
            }
        }
    }
    if (currentEnd == start) {
        return null;
    } else {
        LOGGER.debug("Failed to find path from {} to {}", startId, endId);
        if (expectedToPass != null) {
            expectedToPass.cameFrom = currentEnd;
            currentEnd = expectedToPass;
        }
        return reconstructPath(start, currentEnd);
    }
}

private Path reconstructPath(Node start, Node end) {
    List<Node> pathNodes = Lists.newArrayList();
    Node node = end;
    pathNodes.add(0, end);
    while (node.cameFrom != null) {
        node = node.cameFrom;
        pathNodes.add(0, node);
    }
    return new Path(pathNodes, new BlockPos(end.x, end.y, end.z), true);

这样我们就获得了一条具体的路径,接下来末影龙要做的就是沿着这条路径飞行至终点即可。飞行的具体过程与寻路系统无关,此处不做讲述。下面举个例子来形象地说明整个寻路过程:

末影龙现在位于点(5, 70, 42),它想要飞到点(-23, 75, 4)附近,且它没有任何其他“希望经过的节点”。已知场上还有5个末地水晶未被破坏,假设场上所有已编号节点的y坐标均为73。

第一步是选择寻找距离自身最近的已编号节点。此处因为末地水晶有剩余,所以也要检查外圈节点。遍历所有已编号节点可知14号节点(0, 73, 40)离它最近,然后寻找距离(-23, 75, 4)最近的已编号节点,可知这个节点为22号节点(-20, 73, 0)。

接着进行路径的构建,首先根据邻接矩阵获取与14号节点相邻的已编号节点,结果为3、13、15、21号节点。计算得3号节点的f值约为20.0+63.2=83.2,13号节点的f值约为30.5+55.6=86.1,15号节点的f值约为31.4+29.4=60.8,21号节点的f值约为20.0+28.3=48.3,因此21号节点优先级最高。接下来取出21号节点,获取与21号节点相邻的已编号节点,计算得22号节点优先级最高。下一步发现22号节点已经是终点了,因此停止A*算法流程,返回路径14->21->22。

最后按照14->21->22的顺序飞向终点,从而完成了一次寻路与沿路径飞行的过程。

末影龙的AI

MC中有两个主要的AI系统,一个是早就有的目标(Goal)系统,另一个是1.14后加入的Brain(大脑系统?感觉不太好听,就不翻译了233)系统。而末影龙使用的是这两个AI系统中的其中一个吗?

不幸的是,末影龙拥有独属于自己的复杂AI系统,因此这给分析末影龙的行为带来了一定的挑战性。下文中笔者将末影龙使用的这套AI系统称为阶段(Phase)系统。

阶段系统的运作方式

阶段系统的的核心为EnderDragonPhaseManager类,末影龙当前处于的阶段的数据在该类中进行管理。

public class EnderDragonPhaseManager {
    private static final Logger LOGGER = LogUtils.getLogger();
    private final EnderDragon dragon;
    private final DragonPhaseInstance[] phases = new DragonPhaseInstance[EnderDragonPhase.getCount()];
    @Nullable
    private DragonPhaseInstance currentPhase;

    public EnderDragonPhaseManager(EnderDragon dragon) {
        this.dragon = dragon;
        setPhase(EnderDragonPhase.HOVERING);
    }

    public void setPhase(EnderDragonPhase<?> phase) {
        if (currentPhase == null || phase != currentPhase.getPhase()) {
            if (currentPhase != null) {
                currentPhase.end();
            }
            currentPhase = getPhase(phase);
            if (!dragon.level().isClientSide) {
                dragon.getEntityData().set(EnderDragon.DATA_PHASE, phase.getId());
            }
            LOGGER.debug("Dragon is now in phase {} on the {}", phase, dragon.level().isClientSide ? "client" : "server");
            currentPhase.begin();
        }
    }

    public DragonPhaseInstance getCurrentPhase() {
        return currentPhase;
    }

    // 根据传入的阶段返回该阶段的阶段实例(DragonPhaseInstance)
    public <T extends DragonPhaseInstance> T getPhase(EnderDragonPhase<T> phase) {
        int phaseId = phase.getId();
        if (phases[phaseId] == null) {
            phases[phaseId] = phase.createInstance(this.dragon);
        }
        return (T) phases[phaseId];
    }
}    

这个类中的setPhase方法则更是控制末影龙不同阶段之间的切换的关键,下面仔细看一下这个方法。

public void setPhase(EnderDragonPhase<?> phase) {
    if (currentPhase == null || phase != currentPhase.getPhase()) {
        if (currentPhase != null) {
            // 结束当前阶段
            currentPhase.end();
        }
        // 开始下一阶段
        currentPhase = getPhase(phase);
        if (!dragon.level().isClientSide) {
            dragon.getEntityData().set(EnderDragon.DATA_PHASE, phase.getId());
        }
        LOGGER.debug("Dragon is now in phase {} on the {}", phase, dragon.level().isClientSide ? "client" : "server");
        currentPhase.begin();
    }
}

在这个方法中,先收尾了旧的阶段,然后切换为新的阶段,并执行了新的阶段开始时的一些行为。末影龙阶段每刻的更新则在aiStep方法中,由于aiStep方法很长,所以下面只展示与阶段的更新有关的代码。

if (level().isClientSide) {
    phaseManager.getCurrentPhase().doClientTick();
} else {
    DragonPhaseInstance phase = phaseManager.getCurrentPhase();
    phase.doServerTick();
    // 这儿有个细节,因为每个阶段更新后可能会改变currentPhase的值,所以要检查现在
    // currentPhase的新值,如果该变量的值改变了则需要更新新的阶段
    if (phaseManager.getCurrentPhase() != phase) {
        phase = phaseManager.getCurrentPhase();
        phase.doServerTick();
    }
}

不同阶段的特点介绍

说完了阶段系统的运作方式,那么末影龙具体有哪些阶段呢?其实各阶段的主要行为在Wiki中的末影龙页面上有简单的说明和定性的分析,以下内容中将给出更详细的阐述。下表中列出了所有阶段的名称和ID,马上将会对每个阶段的特点进行介绍。

阶段类型 阶段名 阶段ID
飞行类阶段 盘旋阶段(DragonHoldingPatternPhase) 0
扫射阶段(DragonStrafePlayerPhase) 1
准备降落阶段(DragonLandingApproachPhase) 2
降落/起飞类阶段 降落阶段(DragonLandingPhase) 3
起飞阶段(DragonTakeoffPhase) 4
栖息类阶段 吐息阶段(DragonSittingFlamingPhase) 5
面向玩家阶段(DragonSittingScanningPhase) 6
咆哮阶段(DragonSittingAttackingPhase) 7
其他类型阶段 俯冲阶段(DragonChargePlayerPhase) 8
死亡阶段(DragonDeathPhase) 9
悬停阶段(DragonHoverPhase) 10

盘旋阶段

盘旋阶段是末影龙处于的总时长最长的一个阶段。在这一阶段中,末影龙的行为主要是环绕相邻的黑曜石柱飞行,并有概率切换到扫射阶段及准备降落阶段。

以下是盘旋阶段末影龙的服务端每刻的更新部分的代码:

@Override
public void doServerTick() {
    double distanceToTargetSqr = targetLocation == null ? 0.0D : targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ());
    if (distanceToTargetSqr < 100.0D || distanceToTargetSqr > 22500.0D || dragon.horizontalCollision || dragon.verticalCollision) {
        findNewTarget();
    }
}

可以看出,当末影龙距离当前飞行目标(Wiki上相关内容上写的是“攻击目标”,笔者觉得称为“飞行目标”更合适)近于10格/远于150格,或是任意方向上撞到方块或世界边界时会尝试选择新的飞行目标。

findNewTarget内容又是怎样的呢?该方法大致可以分为“阶段的切换”和“下一飞行目标的确定”两部分。

private void findNewTarget() {
    // 阶段的切换
    if (currentPath != null && currentPath.isDone()) {
        BlockPos origin = dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, new BlockPos(EndPodiumFeature.getLocation(dragon.getFightOrigin())));
        int crystalsAlive = dragon.getDragonFight() == null ? 0 : dragon.getDragonFight().getCrystalsAlive();
        if (dragon.getRandom().nextInt(crystalsAlive + 3) == 0) {
            dragon.getPhaseManager().setPhase(EnderDragonPhase.LANDING_APPROACH);
            return;
        }

        Player targetPlayer = dragon.level().getNearestPlayer(NEW_TARGET_TARGETING, dragon, (double) origin.getX(), (double) origin.getY(), (double) origin.getZ());
        double m; // 不知道怎样给这个变量重命名最好……暂且称它为m吧。m的值越小,末影龙扫射玩家的概率越大
        if (targetPlayer != null) {
            m = origin.distToCenterSqr(targetPlayer.position()) / 512.0D;
        } else {
            m = 64.0D;
        }

        if (targetPlayer != null && (dragon.getRandom().nextInt((int) (m + 2.0D)) == 0 || dragon.getRandom().nextInt(crystalsAlive + 2) == 0)) {
            strafePlayer(targetPlayer);
            return;
        }
    }

    // 下一飞行目标的确定
    if (currentPath == null || currentPath.isDone()) {
        int closestNodeId = dragon.findClosestNode();
        int endNodeId = closestNodeId;
        if (dragon.getRandom().nextInt(8) == 0) {
            clockwise = !clockwise;
            endNodeId = closestNodeId + 6;
        }

        if (clockwise) {
            ++endNodeId;
        } else {
            --endNodeId;
        }

        if (dragon.getDragonFight() != null && dragon.getDragonFight().getCrystalsAlive() >= 0) {
            endNodeId %= 12;
            if (endNodeId < 0) {
                endNodeId += 12;
            }
        } else {
            endNodeId -= 12;
            endNodeId &= 7;
            endNodeId += 12;
        }

        currentPath = dragon.findPath(closestNodeId, endNodeId, (Node) null);
        if (currentPath != null) {
            currentPath.advance();
        }
    }

    navigateToNextPathNode();
}

private void strafePlayer(Player player) {
    dragon.getPhaseManager().setPhase(EnderDragonPhase.STRAFE_PLAYER);
    dragon.getPhaseManager().getPhase(EnderDragonPhase.STRAFE_PLAYER).setTarget(player);
}

private void navigateToNextPathNode() {
    if (currentPath != null && !currentPath.isDone()) {
        Vec3i nextNodePos = currentPath.getNextNodePos();
        currentPath.advance();
        double targetX = nextNodePos.getX();
        double targetZ = nextNodePos.getZ();
        double targetY;
        do {
            targetY = (double) ((float) nextNodePos.getY() + dragon.getRandom().nextFloat() * 20.0F);
        } while (targetY < (double) nextNodePos.getY());
        targetLocation = new Vec3(targetX, targetY, targetZ);
    }
}

由此可知,末影龙在寻找新的飞行目标时,如果当前飞行路径非空且末影龙已经飞完了整条路径,令$c$为剩余末地水晶的数量,则首先有$\frac{1}{c+3}$的概率准备降落在返回传送门上。如果末影龙未准备降落,则有一定的概率进入扫射阶段,攻击玩家。这个概率的计算较为复杂,令$d$为离x=0,z=0位置最高的固体方块正上方的方块最近的玩家到该方块的坐标的距离(如果玩家不存在则d取$128\sqrt{2}$,顺带提一下上述代码中的m值等于$\frac{d^2}{512}$),则该概率为(式子未化简):

$$ 1-\left(1-\frac{1}{\lfloor\frac{d^2}{512}+2\rfloor}\right)\left(1-\frac{1}{c+2}\right) $$

如果末影龙的阶段没有被切换,且当前飞行路径为空/已经飞完了整条路径,那么末影龙将会寻找并飞向另一个已编号路径节点。这个节点的编号是怎样被计算出来的呢?首先末影龙在末地水晶有剩余的时候只围绕外圈节点盘旋,否则只围绕中圈节点盘旋。同时末影龙有$\frac{1}{8}$的概率“横穿”【稍微解释一下何为“横穿”:如果末影龙在外圈盘旋,则下一个路径节点变为沿顺/逆时针方向旋转的第6个节点,如果末影龙在中圈盘旋,则下一个路径节点变为沿逆时针方向旋转的第2个节点。例如如果末影龙在外圈盘旋且此时在8号节点,则下一个路径节点为2号节点(正好与8号节点关于原点对称),而如果末影龙在中圈盘旋且此时在16号节点,则下一个路径节点为14号节点】末地主岛并进行反向盘旋(原本顺时针盘旋变为逆时针盘旋,反之亦然),剩下$\frac{7}{8}$的概率这一步不进行任何操作。再根据末影龙现在的盘旋方向,进一步沿这个方向把上一步确定的路径节点调整为沿这个方向旋转的下一个节点。这样我们就得到了将要飞向的下一个已编号节点的位置。

确定好下一个已编号节点后,具体飞向的坐标点则在navigateToNextPathNode方法中被确定。将这个已编号节点的坐标记为$\left(x_0,y_0,z_0\right)$,则这个坐标点的x、z坐标分别等于$x_0$,$z_0$,而y坐标在区间$[y_0, y_0+20)$中。

该类还重写了onCrystalDestroyed方法,使得末影龙在盘旋状态下如果一个可被攻击的玩家破坏了末地水晶,则一定会进入扫射状态,开始攻击这个玩家。

@Override
public void onCrystalDestroyed(EndCrystal crystal, BlockPos pos, DamageSource source, @Nullable Player player) {
    if (player != null && dragon.canAttack(player)) {
        strafePlayer(player);
    }
}

此外,该类重写的begin方法中初始化了targetLocationcurrentPath,确保了末影龙的行为正常。其余阶段重写的begin方法中亦有此类初始化操作。多测不清空,爆零两行泪

扫射阶段

扫射阶段每刻的更新内容较多,且不同的部分间基本相互独立,因此我们把doServerTick方法拆开来分析。先看最外层的条件判断语句。

@Override
public void doServerTick() {
    if (attackTarget == null) {
        LOGGER.warn("Skipping player strafe phase because no player was found");
        dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN);
    } else {
        // ...
    }
}

可见如果attackTargetnull,这代表着没有可攻击的玩家,就把末影龙的阶段切换到盘旋阶段,否则执行扫射的逻辑。

然后寻找下一个飞行目标。

if (currentPath != null && currentPath.isDone()) {
    double targetX = attackTarget.getX();
    double targetZ = attackTarget.getZ();
    // 这里dx、dz分别指攻击目标的x、z值与末影龙位置的x、z值的差值
    double dx = targetX - dragon.getX();
    double dz = targetZ - dragon.getZ();
    double horizontalDistance = Math.sqrt(dx * dx + dz * dz);
    double yOffset = Math.min((double) 0.4F + horizontalDistance / 80.0D - 1.0D, 10.0D);
    // 将飞行目标设置在攻击目标正上方的某一点处
    targetLocation = new Vec3(targetX, attackTarget.getY() + yOffset, targetZ);
}
double distanceToTargetSqr = targetLocation == null ? 0.0D : targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ());
if (distanceToTargetSqr < 100.0D || distanceToTargetSqr > 22500.0D) {
    // 这里findNewTarget方法的具体作用与DragonHoldingPatternPhase类的同名方法基本一致,
    // 但只有“下一飞行目标的确定”部分,而没有“阶段的切换”,所以不再展开
    findNewTarget(); 
}

由此可知末影龙在扫射阶段下会尝试先根据攻击目标确定飞行目标,如果失败了再使用DragonHoldingPatternPhase中的逻辑,环绕路径点飞行。

第三步是生成并发射火球。

if (attackTarget.distanceToSqr(dragon) < 4096.0D) {
    if (dragon.hasLineOfSight(attackTarget)) {
        ++fireballCharge;
        Vec3 horizontalUnitVectorToTarget = (new Vec3(attackTarget.getX() - dragon.getX(), 0.0D, attackTarget.getZ() - dragon.getZ())).normalize();
        Vec3 horizontalViewUnitVector = (new Vec3(Mth.sin(dragon.getYRot() * ((float) Math.PI / 180F)), 0.0D, -Mth.cos(dragon.getYRot() * ((float) Math.PI / 180F)))).normalize();
        float dotProduct = (float) horizontalViewUnitVector.dot(horizontalUnitVectorToTarget);
        float includedAngle = (float) (Math.acos(dotProduct) * (double) (180F / (float) Math.PI));
        includedAngle += 0.5F;
        if (fireballCharge >= 5 && includedAngle >= 0.0F && includedAngle < 10.0F) {
            Vec3 viewVector = dragon.getViewVector(1.0F);
            double x = dragon.head.getX() - viewVector.x;
            double y = dragon.head.getY(0.5D) + 0.5D;
            double z = dragon.head.getZ() - viewVector.z;
            double dx = attackTarget.getX() - x;
            double dy = attackTarget.getY(0.5D) - y;
            double dz = attackTarget.getZ() - z;
            if (!dragon.isSilent()) {
                // 播放发射末影龙火球的声音
                dragon.level().levelEvent(null, 1017, dragon.blockPosition(), 0);
            }

            DragonFireball dragonFireball = new DragonFireball(dragon.level(), dragon, dx, dy, dz);
            dragonFireball.moveTo(x, y, z, 0.0F, 0.0F);
            dragon.level().addFreshEntity(dragonFireball);
            fireballCharge = 0;

            // (强制)完成当前路径
            if (currentPath != null) {
                while (!currentPath.isDone()) {
                    currentPath.advance();
                }
            }
            dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN);
        }
    } else if (fireballCharge > 0) {
        --fireballCharge;
    }
} else if (fireballCharge > 0) {
    --fireballCharge;
}

末影龙火球有一定的“装填时间”,并且要“装填”5个游戏刻(0.25s)才能发射,期间攻击目标与末影龙的距离不近于64或者末影龙看不见攻击目标(即末影龙与攻击目标间有障碍物)时这个“装填时间”会自减。如果已经“装填完成”,则会检查horizontalUnitVectorToTargethorizontalViewUnitVector的夹角,也就是水平方向上方向由末影龙指向目标的单位向量与方向为末影龙看向的方向的单位向量的夹角。如果这个夹角小于9.5°,那么末影龙会发动攻击,将“装填时间”重置为0并切换到盘旋阶段。

还有个看似简单实则暗藏玄机的setTarget方法值得关注。

public void setTarget(LivingEntity attackTarget) {
    this.attackTarget = attackTarget;
    int startNodeId = dragon.findClosestNode();
    int endNodeId = dragon.findClosestNode(attackTarget.getX(), attackTarget.getY(), attackTarget.getZ());
    int targetX = attackTarget.getBlockX();
    int targetZ = attackTarget.getBlockZ();
    double dx = (double) targetX - dragon.getX();
    double dz = (double) targetZ - dragon.getZ();
    double horizontalDistance = Math.sqrt(dx * dx + dz * dz);
    double yOffset = Math.min((double) 0.4F + horizontalDistance / 80.0D - 1.0D, 10.0D);
    int targetY = Mth.floor(attackTarget.getY() + yOffset);
    Node targetNode = new Node(targetX, targetY, targetZ);
    currentPath = dragon.findPath(startNodeId, endNodeId, targetNode); // 这里将精确的飞行目标作为了“希望经过的节点”
    if (currentPath != null) {
        currentPath.advance();
        // 这里的navigateToNextPathNode方法与DragonHoldingPatternPhase类的同名方法功能完全相同
        // 下文中出现的任何navigateToNextPathNode方法若不做额外说明,则同理
        navigateToNextPathNode();
    }
}

这个方法中,不仅修改了attackTarget的值,还为末影龙设置了飞行路径,使它能够先飞到离攻击目标最近的已编号节点处,再飞到攻击目标正上方。

准备降落阶段

准备降落阶段整体不复杂,这一阶段每刻的更新逻辑与盘旋阶段是完全一样的,但由于begin方法中会初始化targetLocation,所以刚刚进入该阶段时必定会寻找新的飞行目标。这一阶段中飞行目标的选择是这样的:

// 这个目标选择器是忽视视线的,并且只会选择可攻击的玩家
private static final TargetingConditions NEAR_EGG_TARGETING = TargetingConditions.forCombat().ignoreLineOfSight();

private void findNewTarget() {
    if (currentPath == null || currentPath.isDone()) {
        int startNodeId = dragon.findClosestNode();
        BlockPos origin = dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, EndPodiumFeature.getLocation(dragon.getFightOrigin()));
        Player player = dragon.level().getNearestPlayer(NEAR_EGG_TARGETING, dragon, (double) origin.getX(), (double) origin.getY(), (double) origin.getZ());
        int endNodeId;
        if (player != null) {
            Vec3 unitHorizontalVectorFromZeroToPlayer = (new Vec3(player.getX(), 0.0D, player.getZ())).normalize();
            endNodeId = dragon.findClosestNode(-unitHorizontalVectorFromZeroToPlayer.x * 40.0D, 105.0D, -unitHorizontalVectorFromZeroToPlayer.z * 40.0D);
        } else {
            endNodeId = dragon.findClosestNode(40.0D, (double) origin.getY(), 0.0D); // 即12号节点
        }

        Node node = new Node(origin.getX(), origin.getY(), origin.getZ());
        currentPath = dragon.findPath(startNodeId, endNodeId, node);
        if (currentPath != null) {
            currentPath.advance();
        }
    }

    navigateToNextPathNode();
    // 在飞行完成当前路径后,进入(正式的)降落阶段
    if (currentPath != null && currentPath.isDone()) {
        dragon.getPhaseManager().setPhase(EnderDragonPhase.LANDING);
    }
}

令选择的玩家的坐标为$\left(x_0,y_0,z_0\right)$,则末影龙将会选择距离$\left(-\frac{40x_0}{\sqrt{x_0^2+z_0^2}},105,-\frac{40z_0}{\sqrt{x_0^2+z_0^2}}\right)$最近的已编号节点(也就往往是选择水平面上距离原点与玩家坐标连线的反向延长线与中圈的交点最近的已编号节点,若玩家不存在则默认选择12号节点)作为飞行目标。构造该坐标的方式的特殊性导致了这一步选择的飞行目标节点一定位于中圈,而这也是为了下一阶段的降落做准备。

降落阶段

在降落状态下,不但服务端每刻会更新,而且客户端每刻也有更新的内容。

先看客户端每刻更新的部分:

@Override
public void doClientTick() {
    Vec3 lookVector = dragon.getHeadLookVector(1.0F).normalize();
    lookVector.yRot(-(float) Math.PI / 4F); // 绕y轴逆时针旋转45度
    double headX = dragon.head.getX();
    double headY = dragon.head.getY(0.5D);
    double headZ = dragon.head.getZ();

    for (int i = 0; i < 8; ++i) {
        RandomSource random = dragon.getRandom();
        double x = headX + random.nextGaussian() / 2.0D;
        double y = headY + random.nextGaussian() / 2.0D;
        double z = headZ + random.nextGaussian() / 2.0D;
        Vec3 movement = dragon.getDeltaMovement();
        dragon.level().addParticle(ParticleTypes.DRAGON_BREATH, x, y, z, -lookVector.x * (double) 0.08F + movement.x, -lookVector.y * (double) 0.3F + movement.y, -lookVector.z * (double) 0.08F + movement.z);
        lookVector.yRot(0.19634955F); // 注:0.19634955F = Math.PI / 16F
    }
}

这部分内容生成了龙息的粒子效果,但也仅仅生成了粒子效果,不能对玩家造成实际的伤害。

再看服务端每刻更新的部分:

@Override
public void doServerTick() {
    if (targetLocation == null) {
        targetLocation = Vec3.atBottomCenterOf(dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, EndPodiumFeature.getLocation(dragon.getFightOrigin())));
    }

    if (targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ()) < 1.0D) {
        // 将吐息阶段中的flameCount变量重置为0
        dragon.getPhaseManager().getPhase(EnderDragonPhase.SITTING_FLAMING).resetFlameCount();
        dragon.getPhaseManager().setPhase(EnderDragonPhase.SITTING_SCANNING);
    }
}

这个部分将末影龙的飞行目标调整为了返回传送门正上方,并且当末影龙距离飞行目标足够近时切换到面向玩家阶段。

有个细节需要注意,这一阶段下末影龙的飞行速度提升了150%,且转向速度也大大提高。

@Override
public float getFlySpeed() {
    return 1.5F; // 父类中返回0.6F
}

@Override
public float getTurnSpeed() {
    float movementLen = (float) dragon.getDeltaMovement().horizontalDistance() + 1.0F;
    float movementLen1 = Math.min(movementLen, 40.0F);

    // 父类中返回0.7F / movementLen1 / movementLen,而实际情况下movementLen远远小于40,所以
    // movementLen1 / movementLen的值几乎总是1.0F
    return movementLen1 / movementLen; 
}

起飞阶段

注意到起飞阶段引入了一个布尔值firstTick,用来区分这个阶段是不是首次被更新。由于起飞阶段只需要寻找一次飞行目标,所以用这个变量来标记是否寻找过飞行目标。同时如果末影龙成功起飞了(与返回传送门正上方的距离不小于10),则进入盘旋阶段。

private boolean firstTick;

public void doServerTick() {
    if (!firstTick && currentPath != null) {
        BlockPos origin = dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, EndPodiumFeature.getLocation(dragon.getFightOrigin()));
        if (!origin.closerToCenterThan(dragon.position(), 10.0D)) {
            dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN);
        }
    } else {
        firstTick = false;
        findNewTarget();
    }
}

public void begin() {
    firstTick = true;
    currentPath = null;
    targetLocation = null;
}

而这个飞行目标的寻找方式有一点特别:

private void findNewTarget() {
    int startNodeId = dragon.findClosestNode();
    Vec3 headLookVector = dragon.getHeadLookVector(1.0F); // getHeadLookVector返回值在误差允许范围内可以看作单位向量
    int endNodeId = dragon.findClosestNode(-headLookVector.x * 40.0D, 105.0D, -headLookVector.z * 40.0D);
    
    // 下面这一部分则与盘旋阶段是相近的(末影龙在末地水晶有剩余的时候只围绕外圈节点盘旋,否则只围绕中圈节点盘旋)
    if (dragon.getDragonFight() != null && dragon.getDragonFight().getCrystalsAlive() > 0) {
        endNodeId %= 12;
        if (endNodeId < 0) {
            endNodeId += 12;
        }
    } else {
        endNodeId -= 12;
        endNodeId &= 7;
        endNodeId += 12;
    }
    currentPath = dragon.findPath(startNodeId, endNodeId, null);
    navigateToNextPathNode();
}

在第一步中,选择了水平面上距离过原点,且以离末影龙看向的方向向量的相反向量为自身方向向量的射线与中圈的交点最近的已编号节点作为飞行目标。然后和盘旋阶段一样,通过取模操作变换飞行目标到正确的一圈。

栖息类阶段

概述

栖息类阶段指继承了AbstractDragonSittingPhase类的3个阶段。在栖息类阶段下,末影龙会停在返回传送门上方,免疫所有来自继承了AbstractArrow的实体(也就是各种箭及掷出的三叉戟等实体)的直接伤害并点燃该实体1s。

@Override 
public float onHurt(DamageSource source, float amount) {
    if (source.getDirectEntity() instanceof AbstractArrow) {
        source.getDirectEntity().setSecondsOnFire(1);
        return 0.0F;
    } else {
        return super.onHurt(source, amount);
    }
}

在栖息类阶段及悬停阶段下,末影龙有这样一些特殊的行为:

  • 末影龙不会随机咆哮
  • 末影龙扇动翅膀的频率会提高
  • 末影龙不会伤害撞上它翅膀的实体
  • 当末影龙生命值归零时,末影龙会直接死亡而不是进入(死亡前飞向返回传送门的)死亡阶段
  • 末影龙在这类阶段累计受到相当于最大生命值的0.25倍(Wiki上写的是50,但当末影龙的最大生命值不是200时,50这个数值是错误的)的伤害时,末影龙会进入起飞阶段并清零累计伤害(注意在悬停阶段下末影龙也有此行为,因此可以通过对用/summon指令召唤出的末影龙造成足量的伤害来使它获得“正常末影龙的行为”)

接下来调一下讲解顺序,不按阶段的ID顺序讲这3个栖息类阶段,而按阶段执行的先后顺序讲,这样理解起来可能更容易一些。

面向玩家阶段

面向玩家阶段的重点就是服务端每刻的更新了,该阶段主要起到“承上启下”的作用,承接了降落阶段的着陆,调整了末影龙的旋转角度,并为接下来末影龙的攻击做准备。

// 俯冲阶段的目标选择器
private static final TargetingConditions CHARGE_TARGETING = TargetingConditions.forCombat().range(150.0D);

public DragonSittingScanningPhase(EnderDragon dragon) {
    super(dragon);
    // 咆哮阶段的目标选择器
    this.scanTargeting = TargetingConditions.forCombat().range(20.0D).selector(entity -> {
        return Math.abs(entity .getY() - dragon.getY()) <= 10.0D;
    });
}

@Override
public void doServerTick() {
    ++scanningTime;
    LivingEntity nearestPlayer = dragon.level().getNearestPlayer(scanTargeting, dragon, dragon.getX(), dragon.getY(), dragon.getZ());
    if (nearestPlayer != null) {
        if (scanningTime > 25) {
            dragon.getPhaseManager().setPhase(EnderDragonPhase.SITTING_ATTACKING);
        } else {
            Vec3 horizontalUnitVectorToPlayer = new Vec3(nearestPlayer.getX() - dragon.getX(), 0.0D, nearestPlayer.getZ() - dragon.getZ()).normalize();
            Vec3 horizontalViewUnitVector = new Vec3(Mth.sin(dragon.getYRot() * ((float) Math.PI / 180F)), 0.0D, -Mth.cos(dragon.getYRot() * ((float) Math.PI / 180F))).normalize();
            float dotProduct = (float) horizontalViewUnitVector.dot(horizontalUnitVectorToPlayer);
            float includedAngle = (float) (Math.acos(dotProduct) * (double) (180F / (float) Math.PI)) + 0.5F;
            // 夹角过大时进行调整
            if (includedAngle < 0.0F || includedAngle > 10.0F) {
                double dx = nearestPlayer.getX() - dragon.head.getX();
                double dz = nearestPlayer.getZ() - dragon.head.getZ();
                double dyRotA = Mth.clamp(Mth.wrapDegrees(180.0D - Mth.atan2(dx, dz) * (double) (180F / (float)Math.PI) - dragon.getYRot()), -100.0D, 100.0D);
                // “A”应该可以理解为acceleration
                dragon.yRotA *= 0.8F;
                float length = (float) Math.sqrt(dx * dx + dz * dz) + 1.0F;
                float length1 = length;
                if (length > 40.0F) {
                    length = 40.0F;
                }
                
                dragon.yRotA += (float) dyRotA * (0.7F / length / length1);
                dragon.setYRot(dragon.getYRot() + dragon.yRotA);
            }
        }
    } else if (scanningTime >= 100) {
        nearestPlayer = dragon.level().getNearestPlayer(CHARGE_TARGETING, dragon, dragon.getX(), dragon.getY(), dragon.getZ());
        dragon.getPhaseManager().setPhase(EnderDragonPhase.TAKEOFF);
        if (nearestPlayer != null) {
            dragon.getPhaseManager().setPhase(EnderDragonPhase.CHARGING_PLAYER);
            dragon.getPhaseManager().getPhase(EnderDragonPhase.CHARGING_PLAYER).setTarget(new Vec3(nearestPlayer.getX(), nearestPlayer.getY(), nearestPlayer.getZ()));
        }
    }
}

这一阶段基本上是对末影龙绕y轴旋转角度的调整,如果在20格的范围内发现了与自身垂直距离不大于10的可被攻击的玩家,且该阶段持续了超过1.25s,则会进入咆哮阶段。如果第一个要求未满足,但5s后在150格的范围内发现了可被攻击的玩家,且玩家与末影龙的距离大于20或垂直距离大于10,则会进入俯冲阶段。如果仍未找到玩家,则会进入起飞阶段。

咆哮阶段

咆哮阶段非常简单,需要关注的只有双端的更新。在客户端播放了末影龙咆哮的声音,而在服务端实现了对吐息阶段的调控。

@Override
public void doClientTick() {
    dragon.level().playLocalSound(dragon.getX(), dragon.getY(), dragon.getZ(), SoundEvents.ENDER_DRAGON_GROWL, dragon.getSoundSource(), 2.5F, 0.8F + dragon.getRandom().nextFloat() * 0.3F, false);
}

@Override
public void doServerTick() {
    // 等待2s后进入吐息阶段
    if (attackingTicks++ >= 40) {
        dragon.getPhaseManager().setPhase(EnderDragonPhase.SITTING_FLAMING);
    }
}

吐息阶段

吐息阶段相对复杂一些,重要的方法也较多,我们来分解后研究。先看客户端的更新。

@Override
public void doClientTick() {
    ++flameTicks;
    if (flameTicks % 2 == 0 && flameTicks < 10) {
        Vec3 headLookVector = dragon.getHeadLookVector(1.0F).normalize();
        headLookVector.yRot(-(float) Math.PI / 4F);
        double headX = dragon.head.getX();
        double headY = dragon.head.getY(0.5D);
        double headZ = dragon.head.getZ();
        for (int i = 0; i < 8; ++i) {
            double x = headX + dragon.getRandom().nextGaussian() / 2.0D;
            double y = headY + dragon.getRandom().nextGaussian() / 2.0D;
            double z = headZ + dragon.getRandom().nextGaussian() / 2.0D;
            for (int j = 0; j < 6; ++j) {
                dragon.level().addParticle(ParticleTypes.DRAGON_BREATH, x, y, z, -headLookVector.x * (double) 0.08F * (double) j, -headLookVector.y * (double) 0.6F, -headLookVector.z * (double) 0.08F * (double) j);
            }
            headLookVector.yRot(0.19634955F); // 注:0.19634955F = Math.PI / 16F
        }
    }
}

这个方法实现了在末影龙的头部的位置喷出龙息粒子效果,这里给龙息粒子指定了运动的方向向量,使龙息粒子可以沿着一条射线运动,看上去好像是从末影龙嘴里高速喷出的一样。

再看服务端的更新。

@Override
public void doServerTick() {
    ++flameTicks;
    if (flameTicks >= 200) {
        if (flameCount >= 4) {
            dragon.getPhaseManager().setPhase(EnderDragonPhase.TAKEOFF);
        } else {
            dragon.getPhaseManager().setPhase(EnderDragonPhase.SITTING_SCANNING);
        }
    } else if (flameTicks == 10) {
        Vec3 unitVectorToHead = new Vec3(dragon.head.getX() - dragon.getX(), 0.0D, dragon.head.getZ() - dragon.getZ()).normalize();
        double x = dragon.head.getX() + unitVectorToHead.x * 5.0D / 2.0D;
        double z = dragon.head.getZ() + unitVectorToHead.z * 5.0D / 2.0D;
        double headY = dragon.head.getY(0.5D);
        double y = headY;
        BlockPos.MutableBlockPos mutablePos = new BlockPos.MutableBlockPos(x, headY, z);

        while (dragon.level().isEmptyBlock(mutablePos)) {
            --y;
            if (y < 0.0D) {
                y = headY;
                break;
            }
            mutablePos.set(x, y, z);
        }

        y = Mth.floor(y) + 1;
        flame = new AreaEffectCloud(dragon.level(), x, y, z);
        flame.setOwner(dragon);
        flame.setRadius(5.0F);
        flame.setDuration(200);
        flame.setParticle(ParticleTypes.DRAGON_BREATH);
        flame.addEffect(new MobEffectInstance(MobEffects.HARM));
        dragon.level().addFreshEntity(flame);
    }
}

这个方法则实现了龙息“药水云”的生成,让龙息“药水云”生成在向头部方向继续额外延伸一段距离(2.5格)的位置。

吐息阶段还同时重写了beginend方法,实现了喷出的龙息“药水云”的累计数量的统计,并且让龙息“药水云”在本阶段结束时自动消失。

@Override
public void begin() {
    flameTicks = 0;
    ++flameCount;
}

@Override
public void end() {
    if (flame != null) {
        flame.discard();
        flame = null;
    }
}

俯冲阶段

俯冲阶段下末影龙的飞行速度非常快,达到了盘旋阶段的5倍,降落阶段的2倍。但俯冲阶段的重要方法不多,以下是服务端的更新部分。

@Override
public void doServerTick() {
    if (targetLocation == null) {
        LOGGER.warn("Aborting charge player as no target was set.");
        dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN);
    } else if (timeSinceCharge > 0 && timeSinceCharge++ >= 10) {
        dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN);
    } else {
        double distanceToTargetSqr = targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ());
        if (distanceToTargetSqr < 100.0D || distanceToTargetSqr > 22500.0D || dragon.horizontalCollision || dragon.verticalCollision) {
            ++timeSinceCharge;
        }
    }
}

俯冲阶段维护了一个变量timeSinceCharge,当这个变量的值大于10,即“激活”俯冲阶段超过0.5s时,会进入盘旋阶段。此处的“激活”指末影龙与俯冲目标的距离小于10/大于150或末影龙任意方向上撞到方块或世界边界。

俯冲阶段的其余方法不是特别重要,也较容易理解,此处省略不讲。

死亡阶段

易错点:死亡阶段指末影龙濒死时“飞向返回传送门”的过程,而非末影龙“在返回传送门上缓慢升起”的死亡动画

在死亡阶段下,末影龙每0.5s在四周生成大量爆炸粒子效果,这一点通过重写doClientTick方法实现。

@Override
public void doClientTick() {
    if (time++ % 10 == 0) {
        float xOffset = (dragon.getRandom().nextFloat() - 0.5F) * 8.0F;
        float yOffset = (dragon.getRandom().nextFloat() - 0.5F) * 4.0F;
        float zOffset = (dragon.getRandom().nextFloat() - 0.5F) * 8.0F;
        dragon.level().addParticle(ParticleTypes.EXPLOSION_EMITTER,
                dragon.getX() + (double) xOffset,
                dragon.getY() + 2.0D + (double) yOffset,
                dragon.getZ() + (double) zOffset,
                0.0D,
                0.0D,
                0.0D);
    }
}

死亡阶段下,末影龙会飞到返回传送门上方再开始死亡过程,这个过程在服务端的更新中完成。

public void doServerTick() {
    ++time;
    if (targetLocation == null) {
        BlockPos origin = dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING, EndPodiumFeature.getLocation(dragon.getFightOrigin()));
        targetLocation = Vec3.atBottomCenterOf(origin);
    }
    double distanceToTargetSqr = targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ());
    if (!(distanceToTargetSqr < 100.0D) && !(distanceToTargetSqr > 22500.0D) && !dragon.horizontalCollision && !dragon.verticalCollision) {
        dragon.setHealth(1.0F);
    } else {
        dragon.setHealth(0.0F);
    }
}

由此还可以看出,如果末影龙距离返回传送门超过150格,或者撞到了东西,则会跳过飞到返回传送门上方的过程而直接死亡。

悬停阶段

民间有一种观点认为用/summon指令召唤出来的末影龙是“没有AI”的,其实这是错误的观点。这样召唤出的末影龙仍然是有AI的,只不过它的阶段被设置为了悬停阶段而已,而悬停阶段的末影龙只会把它的当前坐标作为飞行目标。

@Override
public void doServerTick() {
    if (targetLocation == null) {
        targetLocation = dragon.position();
    }
} 

前文中提到可以通过对用/summon指令召唤出的末影龙造成足量的伤害来使它获得“正常末影龙的行为”,这是悬停阶段中重写了isSitting方法所致。

@Override
public boolean isSitting() {
    return true;
}

另外,悬停状态下末影龙的飞行速度为盘旋状态下的$\frac{5}{3}$。

全阶段总结

下图说明了各个阶段之间的联系,可供参考。其中箭头代表由一个阶段可以转到另一个阶段,不同颜色则代表了不同类型的阶段。

注:3个栖息类阶段以及悬停阶段不能直接转到死亡阶段
Ender Dragon Phases.webp

末影龙的其他重要行为

末影龙战斗

末影龙战斗(EndDragonFight)是一个比较重要且复杂的类,细细分析很需要时间与精力,而且本文以讲末影龙自身的行为为主,所以这一块笔者只稍稍提一下。

末影龙战斗实现了对末地水晶数量、末影龙Boss栏的更新,以及返回传送门与末地折跃门的构建,并处理了末影龙的生成和复活。在与末影龙的战斗过程中起到“背后支撑”的作用。

末影龙与末地水晶的交互

末影龙通过末地水晶来治疗自身,如果末影龙正被一末地水晶治疗,则每0.5秒会回复1的生命值。同时末影龙每刻有0.1的概率检查并更新离自己最近的末地水晶。这一逻辑的实现位于checkCrystals方法中。

private void checkCrystals() {
    // nearesrCrystal也是与末影龙通过白色光柱相连的末地水晶
    if (nearestCrystal != null) {
        if (nearestCrystal.isRemoved()) {
            nearestCrystal = null;
        } else if (tickCount % 10 == 0 && getHealth() < getMaxHealth()) {
            setHealth(getHealth() + 1.0F);
        }
    }

    if (random.nextInt(10) == 0) {
        List<EndCrystal> nearbyCrystals = level().getEntitiesOfClass(EndCrystal.class, getBoundingBox().inflate(32.0D));
        EndCrystal newNearestCrystal = null;
        double minDistanceSqr = Double.MAX_VALUE;

        for (EndCrystal nearbyCrystal : nearbyCrystals) {
            double distanceToCrystalSqr = nearbyCrystal.distanceToSqr(this);
            if (distanceToCrystalSqr < minDistanceSqr) {
                minDistanceSqr = distanceToCrystalSqr;
                newNearestCrystal = nearbyCrystal;
            }
        }
        // 注意newNearestCrystal可能是null,这意味着检查并更新离自己最近的末地水晶时
        // 有可能将nearestCrystal赋值为null
        nearestCrystal = newNearestCrystal;
    }
}

末影龙在onCrystalDestroyed方法中实现了当正在治疗末影龙的末地水晶被摧毁时,使末影龙受到10来自某位玩家的爆炸伤害。这个玩家是怎样被确定的呢?

private static final TargetingConditions CRYSTAL_DESTROY_TARGETING = TargetingConditions.forCombat().range(64.0D);

// 这个方法会在末地水晶被破坏时被调用
public void onCrystalDestroyed(EndCrystal crystal, BlockPos pos, DamageSource source) {
    Player player;
    if (source.getEntity() instanceof Player) {
        player = (Player) source.getEntity();
    } else {
        player = level().getNearestPlayer(CRYSTAL_DESTROY_TARGETING, (double) pos.getX(), (double) pos.getY(), (double) pos.getZ());
    }
    if (crystal == nearestCrystal) {
        hurt(head, damageSources().explosion(crystal, player), 10.0F);
    }
    phaseManager.getCurrentPhase().onCrystalDestroyed(crystal, pos, source, player);
}

如果破坏末地水晶的伤害来源是一个玩家,则会选择这个玩家。而如果破坏末地水晶的伤害来源是其他东西,则会选择64格内离该末地水晶位置最近的可攻击玩家。

末影龙的死亡

易错点:这是指末影龙在返回传送门正上方缓慢升起的过程,而非前文中的“死亡阶段”

末影龙死亡的逻辑在aiSteptickDeath方法中均有涉及,主要则是在tickDeath方法中。

aiStep(节选):

if (isDeadOrDying()) {
    float xOffset = (random.nextFloat() - 0.5F) * 8.0F;
    float yOffset = (random.nextFloat() - 0.5F) * 4.0F;
    float zOffset = (random.nextFloat() - 0.5F) * 8.0F;
    level().addParticle(ParticleTypes.EXPLOSION, getX() + (double) xOffset, getY() + 2.0D + (double) yOffset, getZ() + (double) zOffset, 0.0D, 0.0D, 0.0D);
}

tickDeath

@Override
protected void tickDeath() {
    if (dragonFight != null) {
        dragonFight.updateDragon(this);
    }
    ++dragonDeathTime;
    
    // 死亡的最后1s生成更多的爆炸粒子效果
    if (dragonDeathTime >= 180 && dragonDeathTime <= 200) {
        float xOffset = (random.nextFloat() - 0.5F) * 8.0F;
        float yOffset = (random.nextFloat() - 0.5F) * 4.0F;
        float zOffset = (random.nextFloat() - 0.5F) * 8.0F;
        level().addParticle(ParticleTypes.EXPLOSION_EMITTER, getX() + (double) xOffset, getY() + 2.0D + (double) yOffset, getZ() + (double) zOffset, 0.0D, 0.0D, 0.0D);
    }
    boolean loot = level().getGameRules().getBoolean(GameRules.RULE_DOMOBLOOT);
    int xpAmount = 500;
    if (dragonFight != null && !dragonFight.hasPreviouslyKilledDragon()) {
        xpAmount = 12000;
    }

    if (level() instanceof ServerLevel) {
        // 死亡7.5s后分批掉落经验
        if (dragonDeathTime > 150 && dragonDeathTime % 5 == 0 && loot) {
            int award = ForgeEventFactory.getExperienceDrop(this, unlimitedLastHurtByPlayer, Mth.floor((float) xpAmount * 0.08F));
            ExperienceOrb.award((ServerLevel) level(), position(), award);
        }
        if (dragonDeathTime == 1 && !isSilent()) {
            level().globalLevelEvent(1028, blockPosition(), 0);
        }
    }

    // 死亡过程中每刻向上移动0.1格
    move(MoverType.SELF, new Vec3(0.0D, 0.1F, 0.0D));

    // 当死亡过程结束时
    if (dragonDeathTime == 200 && level() instanceof ServerLevel) {
        if (loot) {
            // 掉落完剩余的经验
            int award = ForgeEventFactory.getExperienceDrop(this, unlimitedLastHurtByPlayer, Mth.floor((float) xpAmount * 0.2F));
            ExperienceOrb.award((ServerLevel) level(), position(), award);
        }
        if (dragonFight != null) {
            dragonFight.setDragonKilled(this);
        }
        remove(Entity.RemovalReason.KILLED);
        gameEvent(GameEvent.ENTITY_DIE);
    }
}

末影龙在死亡过程中,每刻上升0.1格,并在四周不断生成爆炸粒子效果,且最后1s生成更多粒子。死亡7.5s后,末影龙每隔0.25s掉落8%的应该掉落的经验值总量,最后掉落20%应该掉落的经验,这里应该掉落的经验与是否是第一次击杀末影龙有关,如果是,则掉落总计12000的经验值,否则只掉落500经验值。当末影龙彻底死亡时,末影龙战斗会移除Boss栏,激活返回传送门,如果末地折跃门总数小于20则会生成新的末地折跃门。

末影龙被/kill时的行为

EnderDragon类重写了kill方法,直接移除末影龙而不对其造成Float.MAX_VALUE的伤害,保证了末影龙与末影龙战斗(EndDragonFight)的行为正常。

public void kill() {
    remove(Entity.RemovalReason.KILLED);
    gameEvent(GameEvent.ENTITY_DIE);
    if (dragonFight != null) {
        dragonFight.updateDragon(this);
        dragonFight.setDragonKilled(this);
    }
}

后记

末影龙的核心行为到此就基本上讲完啦~ 鉴于笔者水平有限,文章中难免有不足或错误之处。如果文章某处有问题,可以委婉地指出来,笔者会对相关内容进行检查的。末影龙有关的代码复杂,虽然Wiki上有一部分的内容,但这些内容不够具体,且有些写得不完全正确,因此写作本文也额外耗费了笔者不少精力。尽管如此,笔者希望本文能给大家带来帮助,如果本文对各位读者有一定的作用与价值,那笔者付出这些努力也是值得的。