-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsearch_plus_index.json
1 lines (1 loc) · 255 KB
/
search_plus_index.json
1
{"./":{"url":"./","title":"前言","keywords":"","body":"前言 实体是Minecraft世界的重要组成部分。在Minecraft中,无论是末影龙、凋灵等难以对付的Boss,还是猪、牛、村民、铁傀儡等玩家的好伙伴,甚至是箭、火球、物品展示框,都属于实体,可见实体的重要性之大。 然而,实体的实现也相当复杂,在1.20.1中,光是最基础的Entity类就有超过3500行代码(1.16.5中也有近3000行),而它最重要的一个实现类LivingEntity,代码也达到了3400行。由此我们不仅可以明白实体的复杂性,也可以体会面向对象编程的伟大之处。 实体的开发是Mod开发的初学者遇到的一个重难点,可是各大平台上虽然不缺少优质的Mod开发基础教程,但很少有教程对实体进行了更深入的探讨(例如关于如何从头写一个Boss,或是写一个类似唤魔者的怪物的教程)。笔者写这个教程的一个目的,就是改变这个现状,为初学者们提供进阶的教程,让想要写涉及到实体尤其是需要写复杂实体的初学者少走一些弯路。本教程将从对Minecraft的实例分析出发,通过实例进行讲解,再引入实战部分与练习部分,因此本教程是更重实用的教程。同时相较于实体的模型和渲染,本教程将会更侧重于实体的逻辑(但也不回避实体的模型和渲染) 本教程的目标是给Mod开发的初学者提供一个对实体的更深入的了解,而非明白如何制作一个最基础的实体,因此要求读者有如下几方面的能力: 有一定的Java语言及面向对象编程基础 知道如何搭建开发环境,能做一个最基础的Mod 掌握基本的制作实体的方法 如果你还不会写基本的实体,你可以先阅读Boson上的基础实体教程(注意Boson是1.16的教程)等其他基础性的教程 P.S. 非节假日期间,教程尽量月更(笔者是高中生,两周放假一次)教程作者的Blog教程GitHub仓库教程示例Mod的GitHub仓库个人介绍与联系方式(如有问题,欢迎反馈~ 也可以来这里催更,笔者有时间会回复哒) 本教程采用CC-BY-NC-SA许可证 This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. "},"notes.html":{"url":"notes.html","title":"一些零散的笔记","keywords":"","body":"一些零散的笔记 这里会记录一些笔者想记录的东西~ 不一定和教程内容有关,较新的内容会排在前面 2025-01-01更新: 更新了第1.2.3.2.3节(进度:100%)和第1.6.1节(进度:50%) 做了一些勘误工作 对教程前面的一些语言描述进行了修改 上一次的笔记中提到了MC视频的更新计划,可是笔者有时比较随性,因此可能会先更新一些整活向视频 2024-11-23更新: 整理并归类了一遍教程里的所有文章(教程里所有用编号指代章节的部分已修改,例如“见1.2.1.5节”已被修改为“见1.2.1.3.2节”)。 下一个MC视频基本确定是对唤魔者的较通俗的讲解,正好教程也更新到唤魔者的部分,有时间了笔者会结合较新的MC版本更新唤魔者的讲解视频。 笔者出了个与MC中的合成系统有关的OI题,灵感来源于笔者很久前就看到的这篇文章,对OI感兴趣的可以看看~ 2024-08-31更新: 写了一篇新的,关于烈焰人逻辑讲解的文章。 在学校里笔者向一些人交流了暑假遇到的一些烦心的事情,还是很高兴有人愿意听自己说话的。还有以后教程将继续保持更新。 2024-08-10更新: 前段时间在忙末影龙的讲解视频,因此教程搁置了一段时间。 可以去B站上投票决定下一个MC视频讲什么内容。 最近笔者精神状态较差,可能等到笔者恢复过来之后有时间了才会更新本教程。 2024-07-22更新: 为教程中的图片添加了水印。 更改了部分第一人称代词(“我”->“笔者”)。 写了一篇新的文章(笔者在Polonium上仅更新文章时一般不会把更新记录记在这里,但这次本来就有点话想说,也就提一嘴吧)。 新增“杂项”部分,但尚未更新( 另外,笔者准备在B站上投稿MC实体科普类视频,并为此在Blog上写了一篇有关末影龙核心行为的底层实现的简要分析的文章。 2024-06-30更新: 距离笔者开始创作该教程快要有一年了,期间也出现了许多新的变化,例如NeoForge的发展等。在教程开始创作之时NeoForge才刚与Forge分离,但如今它已较为成熟。不过NeoForge上的实体开发步骤与本教程几乎没有差异,并且较新的MC版本中实体的机制也没有大改(事实上实体的机制与地形生成机制等不同,各个版本间都没有较大的变动,1.14加入的Brain也没有颠覆之前一般实体的Goal系统与末影龙的Phase系统),因此本教程对更高版本的实体编写与开发仍有一定的参考意义。 随着笔者步入高三,未来一年更新速度可能会更慢,今天也是连上12天课后仅有的一天放假时间,明天还要上课。但是笔者不会因此而遗忘该教程,因为离该教程的完工还差很长一段路要走,所以该教程还不全面,需要进一步的维护和更新。 B站上看到一篇写MC寻路系统写得很详细的文章,虽然文章是1年前的,但是这方面的机制没有改变。文章的地址笔者附在1.2.1.3.1节与寻路系统有关的内容处了。 去除了前言部分一些忘记去除的失效链接。 "},"part1-monster/":{"url":"part1-monster/","title":"非Boss级怪物","keywords":"","body":"非Boss级怪物 在这节中,我们将学习如何写一个普通的怪物。 "},"part1-monster/melee/":{"url":"part1-monster/melee/","title":"近战攻击的怪物","keywords":"","body":"近战攻击的怪物 在这节中,我们将从最简单的怪物——近战攻击的怪物讲起。 "},"part1-monster/melee/zombie/":{"url":"part1-monster/melee/zombie/","title":"从原版僵尸开始","keywords":"","body":"从原版僵尸开始 僵尸(Zombie)是一种常见的亡灵敌对生物,具有多个变种。 ——Minecraft Wiki "},"part1-monster/melee/zombie/zombie.html":{"url":"part1-monster/melee/zombie/zombie.html","title":"僵尸的实现逻辑","keywords":"","body":"僵尸的实现逻辑 注: 在以后的实体分析中,所有较重要的内容会写进正文里,次重要的内容以及对原版代码的补充说明则会放在注释中。 可能会对引用的原版代码进行包括但不限于以下操作以方便阅读: 给重写了父类方法的方法添加@Override注解 重命名形参和局部变量 更改原版代码的缩进,换行等格式 省略可以省略的this 如果可以替换,就把全限定类名换成非限定类名 僵尸是我们最熟悉的近战怪物之一。这节中,我们将结合僵尸的源码分析僵尸的行为和底层实现。 先来看下面两行代码: public class Zombie extends Monster {} public abstract class Monster extends PathfinderMob implements Enemy {} 其中PathFinderMob在普通的Mob的基础上,添加了拴绳相关的行为(重写了tickLeash()方法) 而Enemy接口,则是在写任何怪物时都必须直接或间接实现的一个标记接口。 具体来说,Minecraft对实现Enemy接口的实体(以下简称Enemy)规定了一些特殊的性质与行为。例如: 铁傀儡会主动攻击Enemy Enemy不能被栓绳牵引 潮涌核心会对Enemy造成伤害 ...... 相较于Enemy接口,Monster实际上是可继承可不继承的,因为Monster类中只重写了一些方法,比如isPreventingPlayerRest(Player),并更改了一些音效(使用敌对生物的音效) 接下来看下面的常量与变量 // 小僵尸的速度提升的修饰符(AttributeModifier,译名修饰符)的UUID private static final UUID SPEED_MODIFIER_BABY_UUID = UUID.fromString(\"B9766B59-9566-4402-BC1F-2EE2A276D836\"); // 小僵尸的速度提升的修饰符(基础值变为原来的1.5倍) private static final AttributeModifier SPEED_MODIFIER_BABY = new AttributeModifier(SPEED_MODIFIER_BABY_UUID, \"Baby speed boost\", 0.5D, AttributeModifier.Operation.MULTIPLY_BASE); // 决定了僵尸是否为小僵尸,值为true则为小僵尸 private static final EntityDataAccessor DATA_BABY_ID = SynchedEntityData.defineId(Zombie.class, EntityDataSerializers.BOOLEAN); // 暂无实际用途 private static final EntityDataAccessor DATA_SPECIAL_TYPE_ID = SynchedEntityData.defineId(Zombie.class, EntityDataSerializers.INT); // 决定了僵尸是否正在转化为溺尸,值为true则正在转化 private static final EntityDataAccessor DATA_DROWNED_CONVERSION_ID = SynchedEntityData.defineId(Zombie.class, EntityDataSerializers.BOOLEAN); // 当难度为困难时,僵尸可以破门 private static final Predicate DOOR_BREAKING_PREDICATE = difficulty -> difficulty == Difficulty.HARD; // 僵尸破门的AI private final BreakDoorGoal breakDoorGoal = new BreakDoorGoal(this, DOOR_BREAKING_PREDICATE); // 决定了僵尸是否能破门,值为true则说明可以破门 private boolean canBreakDoors; // 僵尸泡在水里的时间(无特殊说明单位都为tick) private int inWaterTime; // 僵尸转化为溺尸(或尸壳转化为僵尸)剩余的时间 private int conversionTime; 大家应该知道EntityDataAccessor(原名DataParameter)具有在服务端与客户端之间自动同步数据的功能,不过当数据无需同步时,使用EntityDataAccessor却是多余的。在上面的例子中,小僵尸和僵尸的模型不同,但僵尸的尺寸却是在服务端决定的,所以我们需要同步数据,在僵尸转化为溺尸时,客户端只需要知道是否开始了转化(正在转化的僵尸会颤抖),不需要知道僵尸泡在水里的时间和转化剩余的时间,因此inWaterTime和conversionTime并没有同步,只同步了DATA_DROWNED_CONVERSION_ID。还有,不要忘记在defineSynchedData方法或实体的构造方法中定义EntityDataAccessor。 同时我们还要留意到以下关于僵尸体型设置的细节: public void setBaby(boolean baby) { getEntityData().set(DATA_BABY_ID, baby); if (level() != null && !level().isClientSide) { AttributeInstance instance = getAttribute(Attributes.MOVEMENT_SPEED); instance.removeModifier(SPEED_MODIFIER_BABY); if (baby) { instance.addTransientModifier(SPEED_MODIFIER_BABY); } } } @Override // @Override为手动添加,下同 public void onSyncedDataUpdated(EntityDataAccessor accessor) { if (DATA_BABY_ID.equals(accessor)) { // 注意这里的dimension指尺寸而不是维度 refreshDimensions(); } super.onSyncedDataUpdated(accessor); } @Override public int getExperienceReward() { if (isBaby()) { // 小僵尸掉落的xp是普通僵尸的2.5倍 xpReward = (int) ((double) xpReward * 2.5D); } return super.getExperienceReward(); } 首先要留意到setBaby方法不仅仅是设置了DATA_BABY_ID的值,而是在这之后还进行了这样一步:移除僵尸身上的SPEED_MODIFIER_BABY,如果僵尸是小僵尸,就给该僵尸临时添加这个修饰符。AttributeInstance类中还有addPermanentModifier方法(这个方法添加的修饰符将会保存到实体的NBT中),但因为setBaby方法还会在readAdditionalSaveData方法中被调用,因此不需要添加到实体的永久修饰符中。 其次还要注意onSyncedDataUpdated方法,在DATA_BABY_ID改变后,实体的尺寸也要在客户端随之改变,因此要调用refreshDimensions方法。 下面是僵尸的AI与属性注册: @Override protected void registerGoals() { goalSelector.addGoal(4, new Zombie.ZombieAttackTurtleEggGoal(this, 1.0D, 3)); goalSelector.addGoal(8, new LookAtPlayerGoal(this, Player.class, 8.0F)); goalSelector.addGoal(8, new RandomLookAroundGoal(this)); addBehaviourGoals(); } protected void addBehaviourGoals() { goalSelector.addGoal(2, new ZombieAttackGoal(this, 1.0D, false)); goalSelector.addGoal(6, new MoveThroughVillageGoal(this, 1.0D, true, 4, this::canBreakDoors)); goalSelector.addGoal(7, new WaterAvoidingRandomStrollGoal(this, 1.0D)); targetSelector.addGoal(1, (new HurtByTargetGoal(this)).setAlertOthers(ZombifiedPiglin.class)); targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, AbstractVillager.class, false)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, IronGolem.class, true)); // Turtle.BABY_ON_LAND_SELECTOR是用来选择可攻击的海龟的Predicate(也就是说僵尸只会攻击岸上的小(baby)海龟) targetSelector.addGoal(5, new NearestAttackableTargetGoal<>(this, Turtle.class, 10, true, false, Turtle.BABY_ON_LAND_SELECTOR)); } public static AttributeSupplier.Builder createAttributes() { // createMonsterAttributes方法中包含对Attributes.MAX_HEALTH的注册(事实上createLivingAttributes方法就注册了) // 而僵尸的最大生命值就是20,因此无需重复注册 return Monster.createMonsterAttributes() .add(Attributes.FOLLOW_RANGE, 35.0D) .add(Attributes.MOVEMENT_SPEED, (double) 0.23F) .add(Attributes.ATTACK_DAMAGE, 3.0D) .add(Attributes.ARMOR, 2.0D) // 不传入double参数则会使用一个属性的默认值 .add(Attributes.SPAWN_REINFORCEMENTS_CHANCE); } public boolean canBreakDoors() { return canBreakDoors; } 如果你对Mob的AI不是很熟悉,推荐阅读这篇教程(当然本文中不会涉及到Brain)。该文章中的后记也很好地解释了为什么大多数Boss都不会使用Goal和Brain。 不难发现僵尸在注册AI的registerGoals方法中调用了addBehaviourGoals方法,这是一种多态(在Husk等Zombie的子类中将会重写这个方法),在Zombie类中大多数被定义为protected的方法都用到了多态的思想。 注意这里没有注册breakDoorGoal,我们马上会讲到它。 接下来是setCanBreakDoors方法。setCanBreakDoors方法将在僵尸的finalizeSpawn中被调用,而后者是在Mob即将生成完毕时会被调用的方法,这个我们后面再讲。 public void setCanBreakDoors(boolean canBreakDoors) { // 如果Mob的一个实例mob使用的PathNavigation是GroundPathNavigation的实例(instanceof GroundPathNavigation),GoalUtils.hasGroundPathNavigation(mob) 就会返回true if (supportsBreakDoorGoal() && GoalUtils.hasGroundPathNavigation(this)) { if (this.canBreakDoors != canBreakDoors) { this.canBreakDoors = canBreakDoors; ((GroundPathNavigation) getNavigation()).setCanOpenDoors(canBreakDoors); if (canBreakDoors) { goalSelector.addGoal(1, breakDoorGoal); } else { goalSelector.removeGoal(breakDoorGoal); } } } else if (this.canBreakDoors) { goalSelector.removeGoal(breakDoorGoal); this.canBreakDoors = false; } } // 同样是多态(溺尸就不可能破门) protected boolean supportsBreakDoorGoal() { return true; } 我们发现,实际注册与更新了breakDoorGoal的位置就是在setCanBreakDoors这个方法里。这说明了Mob的AI的注册与更新不只局限在registerGoals中(但注意isClientSide的判断,不要在客户端注册与更新实体AI)。 接下来一部分是实体的更新,实体每tick的更新极其重要,不论是Mob的AI、寻路系统,还是弹射物的飞行,都在实体每tick的更新中完成。 @Override public void tick() { if (!level().isClientSide && isAlive() && !isNoAi()) { // 如果转化正在发生... if (isUnderWaterConverting()) { --conversionTime; // ForgeEventFactory.canLivingConvert的第三个参数是Consumer if (conversionTime conversionTime = timer)) { doUnderWaterConversion(); } } // 如果转化可以发生但还没发生... else if (convertsInWater()) { if (isEyeInFluid(FluidTags.WATER)) { ++inWaterTime; if (inWaterTime >= 600) { // 开始吧! startUnderWaterConversion(300); } } else { inWaterTime = -1; } } } super.tick(); } protected boolean convertsInWater() { return true; } private void startUnderWaterConversion(int conversionTime) { this.conversionTime = conversionTime; getEntityData().set(DATA_DROWNED_CONVERSION_ID, true); } protected void doUnderWaterConversion() { convertToZombieType(EntityType.DROWNED); if (!isSilent()) { // 广播1040号事件会播放SoundEvents.ZOMBIE_CONVERTED_TO_DROWNED(1041号事件则是播放SoundEvents.HUSK_CONVERTED_TO_ZOMBIE) level().levelEvent(null, 1040, blockPosition(), 0); } } protected void convertToZombieType(EntityType zombieType) { Zombie zombie = convertTo(zombieType, true); if (zombie != null) { zombie.handleAttributes(zombie.level().getCurrentDifficultyAt(zombie.blockPosition()).getSpecialMultiplier()); zombie.setCanBreakDoors(zombie.supportsBreakDoorGoal() && canBreakDoors()); ForgeEventFactory.onLivingConvert(this, zombie); } } @Override public void aiStep() { if (isAlive()) { boolean shouldBurn = isSunSensitive() && isSunBurnTick(); if (shouldBurn) { ItemStack helmet = getItemBySlot(EquipmentSlot.HEAD); // 僵尸只要有了头盔就可以抵抗阳光~ if (!helmet.isEmpty()) { if (helmet.isDamageableItem()) { helmet.setDamageValue(helmet.getDamageValue() + random.nextInt(2)); if (helmet.getDamageValue() >= helmet.getMaxDamage()) { broadcastBreakEvent(EquipmentSlot.HEAD); setItemSlot(EquipmentSlot.HEAD, ItemStack.EMPTY); } } shouldBurn = false; } if (shouldBurn) { setSecondsOnFire(8); } } } super.aiStep(); } 如果一个LivingEntity未被移除,那么这个实体的aiStep方法会在LivingEntity的tick方法中被调用(即每tick调用1次),在调用完aiStep后,将会更新实体的旋转角度。这里重写的tick方法中,主要更新了僵尸的转化(尸壳->僵尸->溺尸);而这里重写的aiStep方法,使僵尸在阳光下着火(同样的逻辑在AbstractSkeleton里,以几乎一样的代码,又出现了一次...)。 然后就到了分别与受击和攻击有关的hurt和doHurtTarget方法,这两个方法在复杂实体的开发中也非常常用。 @Override public boolean hurt(DamageSource source, float amount) { if (!super.hurt(source, amount)) { return false; } else if (!(level() instanceof ServerLevel)) { return false; } else { ServerLevel level = (ServerLevel) level(); LivingEntity target = getTarget(); if (target == null && source.getEntity() instanceof LivingEntity) { target = (LivingEntity) source.getEntity(); } int x = Mth.floor(getX()); int y = Mth.floor(getY()); int z = Mth.floor(getZ()); ZombieEvent.SummonAidEvent event = ForgeEventFactory.fireZombieSummonAid(this, level(), x, y, z, target, getAttribute(Attributes.SPAWN_REINFORCEMENTS_CHANCE).getValue()); if (event.getResult() == Event.Result.DENY) { return true; } // 大致解释一下这个超长条件: // 如果将事件SummonAidEvent的结果设置为ALLOW,则僵尸一定会呼叫增援 // 否则执行原版逻辑:若游戏难度是困难、游戏规则doMobSpawning为true并且被打时攻击目标或伤害自己者存在,则生成一个[0, 1)的随机浮点数(记为n), // 如果n小于Attributes.SPAWN_REINFORCEMENTS_CHANCE就呼叫增援 if (event.getResult() == Event.Result.ALLOW || target != null && level().getDifficulty() == Difficulty.HARD && (double) random.nextFloat() zombieType = zombie.getType(); SpawnPlacements.Type placementType = SpawnPlacements.getPlacementType(zombieType); if (NaturalSpawner.isSpawnPositionOk(placementType, level(), spawnPos, zombieType) && SpawnPlacements.checkSpawnRules(zombieType, level, MobSpawnType.REINFORCEMENT, spawnPos, level().random)) { zombie.setPos(randomX, randomY, randomZ); // 又一个长条件,大致解释一下: // 如果僵尸的生成位置7格之内没有(活的)玩家,同时生成的僵尸的碰撞箱内既没有障碍物,也没有液体,就允许生成支援的僵尸 if (!level().hasNearbyAlivePlayer(randomX, randomY, randomZ, 7.0D) && level().isUnobstructed(zombie) && level().noCollision(zombie) && !level().containsAnyLiquid(zombie.getBoundingBox())) { if (target != null) { zombie.setTarget(target); } zombie.finalizeSpawn(level, level().getCurrentDifficultyAt(zombie.blockPosition()), MobSpawnType.REINFORCEMENT, null, null); level.addFreshEntityWithPassengers(zombie); // 降低新生成的僵尸的召唤援助概率 getAttribute(Attributes.SPAWN_REINFORCEMENTS_CHANCE).addPermanentModifier(new AttributeModifier(\"Zombie reinforcement caller charge\", -0.05F, AttributeModifier.Operation.ADDITION)); zombie.getAttribute(Attributes.SPAWN_REINFORCEMENTS_CHANCE).addPermanentModifier(new AttributeModifier(\"Zombie reinforcement callee charge\", -0.05F, AttributeModifier.Operation.ADDITION)); break; } } } } return true; } } @Override public boolean doHurtTarget(Entity target) { boolean success = super.doHurtTarget(target); // 只有成功造成了伤害才会传火 if (success) { float difficulty = level().getCurrentDifficultyAt(blockPosition()).getEffectiveDifficulty(); if (getMainHandItem().isEmpty() && isOnFire() && random.nextFloat() 重写hurt方法让僵尸有了呼叫增援的能力,简单说一下如何实现僵尸的呼叫增援,这个思路也是不少召唤型Boss所采用的。 获取攻击的目标 判断是否满足呼叫增援的条件 重复尝试50次,如果成功(随机选择的位置符合要求)立即break 其中重复尝试的思想是一个重要的思想,我们在1.2.1.3.2节还会去讲。往往当你苦于如何生成满足要求的随机坐标时,它会派上大用场。植物魔法中盖亚守护者的随机传送位置的选定(第917行),就用到了这种思想。 重写doHurtTarget方法主要目的是为了让僵尸能在一定难度下传火给僵尸攻击的目标。这里的代码不难理解,关于区域难度的计算不是本教程的重点,如果你感兴趣,可以阅读DifficultyInstance类的源代码。 doHurtTarget和hurt方法都有返回值,如果成功造成了伤害(doHurtTarget)或受到了伤害(hurt),就应该返回true,否则一般返回false 还有一点需要注意,不管是上文所述的代码高度重复,还是这部分出现的if语句中使用长条件,都是不好的开发习惯,需要尽量避免。毕竟开发Mod不是参加OI(这种算法竞赛中只要你能AC,你全用单字母变量名与函数名都没人管你),要保证代码的可读性。 接着是killedEntity方法,这个方法虽不经常被重写,但对于僵尸依然重要。 @Override public boolean killedEntity(ServerLevel level, LivingEntity entity) { boolean killed = super.killedEntity(level, entity); if ((level.getDifficulty() == Difficulty.NORMAL || level.getDifficulty() == Difficulty.HARD) && entity instanceof Villager villager && ForgeEventFactory.canLivingConvert(entity, EntityType.ZOMBIE_VILLAGER, timer -> {})) { // 即普通难度下50%,困难难度下100%召唤僵尸村民 if (level.getDifficulty() != Difficulty.HARD && random.nextBoolean()) { return killed; } ZombieVillager zombieVillager = villager.convertTo(EntityType.ZOMBIE_VILLAGER, false); if (zombieVillager != null) { // 复制村民的部分数据到僵尸村民 zombieVillager.finalizeSpawn(level, level.getCurrentDifficultyAt(zombieVillager.blockPosition()), MobSpawnType.CONVERSION, new Zombie.ZombieGroupData(false, true), null); zombieVillager.setVillagerData(villager.getVillagerData()); zombieVillager.setGossips(villager.getGossips().store(NbtOps.INSTANCE)); zombieVillager.setTradeOffers(villager.getOffers().createTag()); zombieVillager.setVillagerXp(villager.getVillagerXp()); ForgeEventFactory.onLivingConvert(entity, zombieVillager); if (!isSilent()) { // 广播1026号事件会播放SoundEvents.ZOMBIE_INFECT level.levelEvent(null, 1026, blockPosition(), 0); } killed = false; } } return killed; } Minecraft中并没有很好的复制实体数据到另一个实体的方法,因此上面的代码中出现了许多a.set(b.get())的操作。 注意这个方法也有返回值,如果返回了false(MC里还没这样干过),那么GameEvent.ENTITY_DIE(GameEvent与新版本的“声音”有关)就不会被广播,实体也不会有任何掉落物(包括凋零玫瑰)。 然后是数据保存与加载。 @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); tag.putBoolean(\"IsBaby\", isBaby()); tag.putBoolean(\"CanBreakDoors\", canBreakDoors()); tag.putInt(\"InWaterTime\", isInWater() ? inWaterTime : -1); tag.putInt(\"DrownedConversionTime\", isUnderWaterConverting() ? conversionTime : -1); } @Override public void readAdditionalSaveData(CompoundTag tag) { super.readAdditionalSaveData(tag); setBaby(tag.getBoolean(\"IsBaby\")); setCanBreakDoors(tag.getBoolean(\"CanBreakDoors\")); inWaterTime = tag.getInt(\"InWaterTime\"); // 99表示的是任意数字型NBT标签 if (tag.contains(\"DrownedConversionTime\", 99) && tag.getInt(\"DrownedConversionTime\") > -1) { startUnderWaterConversion(tag.getInt(\"DrownedConversionTime\")); } } 数据保存的部分在较基础的教程中都有涉及,因此不做过多的赘述,等到后面如果讲到较复杂的数据结构(如List,Map)的保存时,再讲保存方式。需要注意的是,如果一个成员变量的初始值不是默认的初始值(0,false,null)或者该成员变量在addAdditionalSaveData保存时使用了条件(eg.if (a != null) tag.put(a);),那么在readAdditionalSaveData中就必须进行tag.contains(UUID可以用tag.hasUUID)的检查(否则一调用完这个方法就会给你换成0,false或null,甚至给你抛一个NPE)。 最后一个重点了!finalizeSpawn方法。 @Nullable @Override public SpawnGroupData finalizeSpawn(ServerLevelAccessor accessor, DifficultyInstance difficulty, MobSpawnType spawnType, @Nullable SpawnGroupData spawnData, @Nullable CompoundTag spawnTag) { RandomSource random = accessor.getRandom(); // 注意你在写的时候,要把这里换成ForgeEventFactory.onFinalizeSpawn(this, accessor, difficulty, spawnType, spawnData, spawnTag); spawnData = super.finalizeSpawn(accessor, difficulty, spawnType, spawnData, spawnTag); float specialMultiplier = difficulty.getSpecialMultiplier(); setCanPickUpLoot(random.nextFloat() chickens = accessor.getEntitiesOfClass(Chicken.class, getBoundingBox().inflate(5.0D, 3.0D, 5.0D), EntitySelector.ENTITY_NOT_BEING_RIDDEN); if (!chickens.isEmpty()) { Chicken chicken = chickens.get(0); chicken.setChickenJockey(true); startRiding(chicken); } } else if ((double) random.nextFloat() 1) { getAttribute(Attributes.FOLLOW_RANGE).addPermanentModifier(new AttributeModifier(\"Random zombie-spawn bonus\", bonusMultiplier, AttributeModifier.Operation.MULTIPLY_TOTAL)); } // 强化“领头”僵尸 if (random.nextFloat() finalizeSpawn方法为Mob的最终生成做了最后的调整。Zombie类重写了这个方法,使僵尸在生成时获得了加强。其中特别容易遗忘的一点是,populateDefaultEquipmentSlots(一般用来给予Mob生成时的装备)和populateDefaultEquipmentEnchantments(一般用来给Mob生成时的装备附魔)两个方法,虽然在Mob类中就声明了,但是必须在finalizeSpawn方法手动调用。举一个有关finalizeSpawn方法用途的例子:蜘蛛生成时所携带的药水效果,便是在这个方法中添加的。 注意事项:forge明确说明:在目前的forge版本中,这个方法只能被重写,直接调用finalizeSpawn方法会导致StackOverflowError!因此一定要使用ForgeEventFactory.onFinalizeSpawn! 然后是dropCustomDeathLoot,本节的内容也接近尾声了。 @Override protected void dropCustomDeathLoot(DamageSource source, int lootingLevel, boolean killedByPlayer) { super.dropCustomDeathLoot(source, lootingLevel, killedByPlayer); Entity entity = source.getEntity(); if (entity instanceof Creeper creeper) { if (creeper.canDropMobsSkull()) { ItemStack skull = getSkull(); if (!skull.isEmpty()) { creeper.increaseDroppedSkulls(); spawnAtLocation(skull); } } } } protected ItemStack getSkull() { return new ItemStack(Items.ZOMBIE_HEAD); } dropCustomDeathLoot主要让LivingEntity可以掉落较复杂的,常规战利品表难以实现的掉落物(比如被特殊的(高压且没炸掉过头的)苦力怕炸死时会掉落头颅),当然能用战利品表就用战利品表,不要掉什么都用dropCustomDeathLoot来实现。 最后是一些杂项。 @Override protected SoundEvent getAmbientSound() { return SoundEvents.ZOMBIE_AMBIENT; } @Override protected SoundEvent getHurtSound(DamageSource source) { return SoundEvents.ZOMBIE_HURT; } @Override protected SoundEvent getDeathSound() { return SoundEvents.ZOMBIE_DEATH; } protected SoundEvent getStepSound() { return SoundEvents.ZOMBIE_STEP; } @Override protected void playStepSound(BlockPos pos, BlockState state) { playSound(getStepSound(), 0.15F, 1.0F); } @Override public MobType getMobType() { return MobType.UNDEAD; } @Override protected float getStandingEyeHeight(Pose pose, EntityDimensions dimensions) { return isBaby() ? 0.93F : 1.74F; } @Override public boolean canHoldItem(ItemStack stack) { return stack.is(Items.EGG) && isBaby() && isPassenger() ? false : super.canHoldItem(stack); } @Override public boolean wantsToPickUp(ItemStack stack) { return stack.is(Items.GLOW_INK_SAC) ? false : super.wantsToPickUp(stack); } @Override public double getMyRidingOffset() { return isBaby() ? 0.0D : -0.45D; } 简单提及一下僵尸实体类型(EntityType)的注册。 public static final EntityType ZOMBIE = register(\"zombie\", EntityType.Builder.of(Zombie::new, MobCategory.MONSTER).sized(0.6F, 1.95F).clientTrackingRange(8)); 这部分理解难度不大,并且在基础的教程中也提到了一部分,不过尤其要注意一点:千万不要忽视这些细节!许多优秀的Mod,便优秀在对细节的重视。顺便说一下clientTrackingRange(单位为区块),当一个实体在这个追踪距离内时,这个实体将会被更新。一般“战场”面积越大的实体,clientTrackingRange越大(比如末影水晶是16) 僵尸的行为和底层实现便分析到这里了。虽然僵尸看上去很容易实现(也好打),可是与僵尸相关的实现细节却不少,需要一段时间才能理清楚。 下一节将会分析僵尸的模型及渲染。 "},"part1-monster/melee/zombie/zombie2.html":{"url":"part1-monster/melee/zombie/zombie2.html","title":"僵尸的模型与渲染","keywords":"","body":"僵尸的模型与渲染 由于模型和渲染部分不是本教程的重点,并且僵尸的模型也不复杂,所以本节的内容会稍少。 下面是僵尸的模型。 @OnlyIn(Dist.CLIENT) public class ZombieModel extends AbstractZombieModel { public ZombieModel(ModelPart part) { super(part); } public boolean isAggressive(T zombie) { return zombie.isAggressive(); } } @OnlyIn(Dist.CLIENT) public abstract class AbstractZombieModel extends HumanoidModel { protected AbstractZombieModel(ModelPart part) { super(part); } @Override public void setupAnim(T zombie, float limbSwing, float limbSwingAmount, float ageInTicks, float netHeadYaw, float headPitch) { super.setupAnim(zombie, limbSwing, limbSwingAmount, ageInTicks, netHeadYaw, headPitch); // 渲染僵尸的手臂的动画 AnimationUtils.animateZombieArms(leftArm, rightArm, isAggressive(zombie), attackTime, ageInTicks); } public abstract boolean isAggressive(T zombie); } 不难发现,僵尸的模型ZombieModel只是继承了AbstractZombieModel抽象类并实现了抽象方法isAggressive。而在AbstractZombieModel中,也只是在HumanoidModel(旧称BipedModel,用来提供人形的模型)的基础上调用了AnimationUtils.animateZombieArms方法来重写手臂的动画(僵尸的手臂不会自然下垂,攻击玩家时会双手同时攻击)。@OnlyIn(Dist.CLIENT)用来标记只在客户端存在的包,类,成员变量或方法等,实体模型只会在客户端用到,因此不要省略这个注解。 然后是僵尸的渲染。 @OnlyIn(Dist.CLIENT) public class ZombieRenderer extends AbstractZombieRenderer> { public ZombieRenderer(EntityRendererProvider.Context context) { this(context, ModelLayers.ZOMBIE, ModelLayers.ZOMBIE_INNER_ARMOR, ModelLayers.ZOMBIE_OUTER_ARMOR); } public ZombieRenderer(EntityRendererProvider.Context context, ModelLayerLocation mainModelLocation, ModelLayerLocation innerArmorModelLocation, ModelLayerLocation outerArmorModelLocation) { super(context, new ZombieModel<>(context.bakeLayer(mainModelLocation)), new ZombieModel<>(context.bakeLayer(innerArmorModelLocation)), new ZombieModel<>(context.bakeLayer(outerArmorModelLocation))); } } @OnlyIn(Dist.CLIENT) public abstract class AbstractZombieRenderer> extends HumanoidMobRenderer { private static final ResourceLocation ZOMBIE_LOCATION = new ResourceLocation(\"textures/entity/zombie/zombie.png\"); protected AbstractZombieRenderer(EntityRendererProvider.Context context, M mainModel, M armorInnerModel, M armorOuterModel) { // 0.5F表示阴影的半径是0.5m super(context, mainModel, 0.5F); addLayer(new HumanoidArmorLayer<>(this, armorInnerModel, armorOuterModel, context.getModelManager())); } @Override public ResourceLocation getTextureLocation(Zombie zombie) { return ZOMBIE_LOCATION; } @Override protected boolean isShaking(T zombie) { return super.isShaking(zombie) || zombie.isUnderWaterConverting(); } } 这里僵尸的渲染器也只是继承了AbstractZombieRenderer。这儿说一下AbstractZombieRenderer的构造方法中的三个模型参数: 第一个参数表示的是主要的模型,也就是渲染僵尸材质时用的模型 第二个参数表示的是盔甲的内层使用的模型,这个模型只会在渲染护腿时使用 第三个参数表示的是盔甲的外层使用的模型,这个模型会在渲染除护腿外盔甲时使用 同时三个模型的尺寸是依次增大的,这与玩家的2层皮肤相似。最后不要忘记当你写实体时,必须注册你的实体的渲染器。 僵尸的相关内容便告一段落了,下一节我们将讲一个近战怪物的实例~ "},"part1-monster/melee/actual_combat1.html":{"url":"part1-monster/melee/actual_combat1.html","title":"实战1 - 强化僵尸","keywords":"","body":"实战1 - 强化僵尸 注: 为避免实战部分过于复杂与偏离主题,除特殊说明外,此部分对资源包与数据包部分(如掉落物、语言文件等)不作要求,感兴趣的读者可以学习相关的资源包、数据包相关知识,以及DataGenerator使用教程(非必须)后自行添加。 让我们从一个最简单的实例开始。 任务 制作一种新的僵尸——强化僵尸 要求 强化僵尸的属性同普通僵尸,但有40的最大生命值 强化僵尸的生命值低于(时,在阳光下不会燃烧 被强化僵尸伤害的非怪物生物会获得20s的缓慢II状态效果(持续时间和区域难度无关) 强化僵尸在水中不会转化为溺尸,击杀时也不会掉落头颅 强化僵尸一定会尝试生成增援的强化僵尸,但前来增援的强化僵尸不会再次生成增援,且一个强化僵尸最多尝试生成增援3次 强化僵尸需要使用僵尸的模型,但音效和材质可以任意设置 参考步骤 首先创建ReinforcementZombie类,因为强化僵尸是一种特殊的僵尸,所以继承Zombie类。 public class ReinforcedZombie extends Zombie { public ReinforcedZombie(EntityType type, Level level) { super(type, level); } } 以及createAttributes方法。 public static AttributeSupplier.Builder createAttributes() { return Zombie.createAttributes().add(Attributes.MAX_HEALTH, 40); } 因为“强化僵尸的生命值低于(isSunSensitive方法。 @Override protected boolean isSunSensitive() { return getHealth() >= getMaxHealth() * 0.5F; } 然后重写doHurtTarget方法,实现添加缓慢II的效果。 @Override public boolean doHurtTarget(Entity target) { boolean hurt = super.doHurtTarget(target); if (hurt && target instanceof LivingEntity living && !(target instanceof Enemy)) { living.addEffect(new MobEffectInstance(MobEffects.MOVEMENT_SLOWDOWN, 20 * 20, 1)); } return hurt; } 这里我们通过调用addEffect方法并实例化了一个MobEffectInstance,给非怪物生物添加了20s的缓慢II效果(MobEffectInstance构造方法中的第三个参数是“倍率”而非等级,而倍率比等级小1)。比较复杂的是要求5,我们先用一个成员变量保存剩余的尝试生成增援的次数。 private static final String AIDS_REMAINING = \"aids_remaining\"; private int aidsRemaining = 3; public int getAidsRemaining() { return aidsRemaining; } public void setAidsRemaining(int aidsRemaining) { this.aidsRemaining = aidsRemaining; } // 不要忘了数据保存! @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); tag.putInt(AIDS_REMAINING, getAidsRemaining()); } @Override public void readAdditionalSaveData(CompoundTag tag) { super.readAdditionalSaveData(tag); if (tag.contains(AIDS_REMAINING, CompoundTag.TAG_ANY_NUMERIC)) { setAidsRemaining(tag.getInt(AIDS_REMAINING)); } } 根据要求5,直接使用Attributes.SPAWN_REINFORCEMENTS_CHANCE来控制增援的生成是不行的。我们选择用事件监听器,并监听ZombieEvent.SummonAidEvent。 @Mod.EventBusSubscriber(modid = Polonium.MOD_ID) public final class EntityEventListener { @SubscribeEvent public static void onZombieSummonAid(ZombieEvent.SummonAidEvent event) { if (event.getEntity() instanceof ReinforcedZombie reinforcedZombie) { if (reinforcedZombie.getAidsRemaining() 要求4很容易达到,重写下列2个方法即可。 @Override protected boolean convertsInWater() { return false; } @Override protected ItemStack getSkull() { return ItemStack.EMPTY; } 最后完善强化僵尸的音效。(此处使用了尸壳的音效) @Override protected SoundEvent getAmbientSound() { return SoundEvents.HUSK_AMBIENT; } @Override protected SoundEvent getHurtSound(DamageSource source) { return SoundEvents.HUSK_HURT; } @Override protected SoundEvent getDeathSound() { return SoundEvents.HUSK_DEATH; } @Override protected SoundEvent getStepSound() { return SoundEvents.HUSK_STEP; } 接下来注册我们的实体。 @Mod.EventBusSubscriber(modid = Polonium.MOD_ID, bus = Mod.EventBusSubscriber.Bus.MOD) public final class ModEntities { public static final DeferredRegister> ENTITIES = DeferredRegister.create(ForgeRegistries.ENTITY_TYPES, Polonium.MOD_ID); public static final RegistryObject> REINFORCED_ZOMBIE = ENTITIES.register(ModEntityNames.REINFORCED_ZOMBIE, () -> EntityType.Builder.of(ReinforcedZombie::new, MobCategory.MONSTER).sized(0.6F, 1.95F).clientTrackingRange(8).build(ModEntityNames.REINFORCED_ZOMBIE)); @SubscribeEvent public static void registerAttributes(EntityAttributeCreationEvent event) { event.put(REINFORCED_ZOMBIE.get(), ReinforcedZombie.createAttributes().build()); } private ModEntities() {} } 对于模型,直接使用僵尸的模型即可,下面是渲染部分。 public class ReinforcedZombieRenderer extends ZombieRenderer { private static final ResourceLocation REINFORCED_ZOMBIE = Utils.prefix(\"textures/entity/reinforced_zombie.png\"); public ReinforcedZombieRenderer(EntityRendererProvider.Context context) { super(context, ModModelLayers.REINFORCED_ZOMBIE, ModModelLayers.REINFORCED_ZOMBIE_INNER_ARMOR, ModModelLayers.REINFORCED_ZOMBIE_OUTER_ARMOR); } @Override public ResourceLocation getTextureLocation(Zombie zombie) { return REINFORCED_ZOMBIE; } } // ModelLayer public final class ModModelLayers { public static final ModelLayerLocation REINFORCED_ZOMBIE = createMain(ModEntityNames.REINFORCED_ZOMBIE); public static final ModelLayerLocation REINFORCED_ZOMBIE_INNER_ARMOR = createInnerArmor(ModEntityNames.REINFORCED_ZOMBIE); public static final ModelLayerLocation REINFORCED_ZOMBIE_OUTER_ARMOR = createOuterArmor(ModEntityNames.REINFORCED_ZOMBIE); private ModModelLayers() {} private static ModelLayerLocation createMain(String model) { return create(model, \"main\"); } private static ModelLayerLocation createInnerArmor(String model) { return create(model, \"inner_armor\"); } private static ModelLayerLocation createOuterArmor(String model) { return create(model, \"outer_armor\"); } private static ModelLayerLocation create(String model, String layer) { // 这里的Utils.prefix方法返回new ResourceLocation(Polonium.MOD_ID, model) return new ModelLayerLocation(Utils.prefix(model), layer); } } 别忘了注册实体渲染器以及LayerDefinition。 @Mod.EventBusSubscriber(modid = Polonium.MOD_ID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT) public class ModEntityRenderers { @SubscribeEvent public static void registerEntityRenderers(EntityRenderersEvent.RegisterRenderers event) { event.registerEntityRenderer(ModEntities.REINFORCED_ZOMBIE.get(), ReinforcedZombieRenderer::new); } @SubscribeEvent public static void registerLayerDefinitions(EntityRenderersEvent.RegisterLayerDefinitions event) { Supplier zombie = () -> LayerDefinition.create(HumanoidModel.createMesh(CubeDeformation.NONE, 0.0F), 64, 64); Supplier innerArmor = () -> LayerDefinition.create(HumanoidArmorModel.createBodyLayer(LayerDefinitions.INNER_ARMOR_DEFORMATION), 64, 32); Supplier outerArmor = () -> LayerDefinition.create(HumanoidArmorModel.createBodyLayer(LayerDefinitions.OUTER_ARMOR_DEFORMATION), 64, 32); event.registerLayerDefinition(ModModelLayers.REINFORCED_ZOMBIE, zombie); event.registerLayerDefinition(ModModelLayers.REINFORCED_ZOMBIE_INNER_ARMOR, innerArmor); event.registerLayerDefinition(ModModelLayers.REINFORCED_ZOMBIE_OUTER_ARMOR, outerArmor); } } 到此我们就做完了强化僵尸的全部内容。 源代码(ReinforcedZombie类)源代码(ReinforcedZombieRenderer类) 效果图(强化僵尸的材质使用了红眼的僵尸) 强化僵尸攻击玩家 思考与练习 此部分内容仅提供了思考方向,不给出解答与源代码 能否让强化僵尸造成的缓慢II效果的持续时间与区域难度呈正相关呢? 能否让强化僵尸主动攻击骷髅? 能否让强化僵尸泡在水中30s后转化为尸壳? 能否让强化僵尸掉落2倍于(普通)僵尸的xp? "},"part1-monster/melee/enderman/":{"url":"part1-monster/melee/enderman/","title":"空间移动能力者——末影人","keywords":"","body":"空间移动能力者——末影人 末影人(Enderman)是一种来自末地、会瞬移的高大黑色敌对生物。 ——Minecraft Wiki "},"part1-monster/melee/enderman/neutral_mob.html":{"url":"part1-monster/melee/enderman/neutral_mob.html","title":"末影人与“中立”生物","keywords":"","body":"末影人与“中立”生物 注:由于末影人更多地被认为是敌对的怪物而非友好的中立生物,所以在1.2节而非1.4节中进行分析。 末影人被认为是敌对性的中立生物,这与EnderMan(旧称EndermanEntity,感觉是把Enderman的字母“M”误打成大写了)类实现的2个接口密切相关。其中的一个接口就是前面说过的Enemy,而另一个接口,NeutralMob(旧称IAngerable),则不仅仅是一个标记接口,还定义了大量与“生气”相关的方法。 这节笔者主要讲NeutralMob这个接口,可能涉及少量实体类对此接口里抽象方法的实现。 在开始之前,先要说明一件事情。NeutralMob接口靠后的一部分抽象方法(即以下方法)在Mob或LivingEntity中都有实现,而且很容易理解,因此本节不讨论它们。 @Nullable LivingEntity getLastHurtByMob(); void setLastHurtByMob(@Nullable LivingEntity lastHurtByMob); void setLastHurtByPlayer(@Nullable Player lastHurtByPlayer); void setTarget(@Nullable LivingEntity target); boolean canAttack(LivingEntity entity); @Nullable LivingEntity getTarget(); 但是要注意,在你的接口中,千万不能这样写,除非你声明的方法不会被重混淆(例如你声明了一个toString)。举个例子,假设你写了如下的代码: public interface ExampleInterface { float getHealth(); default void foo() { ExampleMod.LOGGER.info(String.valueOf(getHealth())); } } public class ExampleEntity extends PathfinderMob implements ExampleInterface { public ExampleEntity(EntityType type, Level level) { super(type, level); } @Override public void tick() { super.tick(); foo(); } } 否则编译时正常,开发环境中也正常,而到生产环境中,由于LivingEntity类中的getHealth方法会被重混淆为SRG名,你的ExampleInterface里的getHealth却还是getHealth,于是产生了一个没有被实现的抽象方法。只要你的实体一tick,AbstractMethodError就会迎面而来~(本人已亲身体验过一次) 为了摆脱讨厌的AbstractMethodError,你需要这样写: public interface ExampleInterface { private LivingEntity self() { return (LivingEntity) this; } default void foo() { ExampleMod.LOGGER.info(String.valueOf(self().getHealth())); } } public class ExampleEntity extends PathfinderMob implements ExampleInterface { public ExampleEntity(EntityType type, Level level) { super(type, level); } @Override public void tick() { super.tick(); foo(); } } 这样就可以有效地赶走AbstractMethodError。你不妨去翻一下IForgeEntity,IForgeBlock等Forge的接口的源代码,里面就有很多这样的写法。 回到正题,我们先从前面的2组getter和setter说起。 // 注意单位为tick int getRemainingPersistentAngerTime(); void setRemainingPersistentAngerTime(int remainingPersistentAngerTime); @Nullable UUID getPersistentAngerTarget(); void setPersistentAngerTarget(@Nullable UUID persistentAngerTarget); EnderMan里的实现: @Override public int getRemainingPersistentAngerTime() { return remainingPersistentAngerTime; } @Override public void setRemainingPersistentAngerTime(int remainingPersistentAngerTime) { this.remainingPersistentAngerTime = remainingPersistentAngerTime; } @Nullable @Override public UUID getPersistentAngerTarget() { return persistentAngerTarget; } @Override public void setPersistentAngerTarget(@Nullable UUID persistentAngerTarget) { this.persistentAngerTarget = persistentAngerTarget; } Bee里前两个方法的实现。因为蜜蜂生气时与不生气时材质不同,所以这里用了entityData。 @Override public int getRemainingPersistentAngerTime() { return entityData.get(DATA_REMAINING_ANGER_TIME); } @Override public void setRemainingPersistentAngerTime(int remainingPersistentAngerTime) { entityData.set(DATA_REMAINING_ANGER_TIME, remainingPersistentAngerTime); } 上面一组getter和setter控制了剩余的生气时间(一旦remainingPersistentAngerTime为0就不会再生气),下面一组则控制了当前生气的目标(的UUID)(UUID并非只有玩家有,任何实体都有)。 下一个方法及实现。 void startPersistentAngerTimer(); private static final UniformInt PERSISTENT_ANGER_TIME = TimeUtil.rangeOfSeconds(20, 39); @Override public void startPersistentAngerTimer() { setRemainingPersistentAngerTime(PERSISTENT_ANGER_TIME.sample(random)); } 可见这个方法被调用时,remainingPersistentAngerTime会被设置为一个随机值(在末影人中,这个值是[400, 780]间的随机整数(静态工厂方法TimeUtil.rangeOfSeconds在创建UniformInt实例时,给20和39分别乘上了20))。当然如果你的实体实现了这个方法,你也可以做些其他的事,这个方法的调用时机马上会讲到。 下面就是一些默认方法了,这些方法通常不需要被重写。首先是数据保存与加载。 default void addPersistentAngerSaveData(CompoundTag tag) { tag.putInt(\"AngerTime\", getRemainingPersistentAngerTime()); if (getPersistentAngerTarget() != null) { tag.putUUID(\"AngryAt\", getPersistentAngerTarget()); } } default void readPersistentAngerSaveData(Level level, CompoundTag tag) { setRemainingPersistentAngerTime(tag.getInt(\"AngerTime\")); if (level instanceof ServerLevel) { if (!tag.hasUUID(\"AngryAt\")) { setPersistentAngerTarget(null); } else { UUID angryAtUUID = tag.getUUID(\"AngryAt\"); setPersistentAngerTarget(angryAtUUID); Entity angryAt = ((ServerLevel) level).getEntity(angryAtUUID); if (angryAt != null) { if (angryAt instanceof Mob) { setLastHurtByMob((Mob) angryAt); } if (entity.getType() == EntityType.PLAYER) { setLastHurtByPlayer((Player) angryAt); } } } } } 对于这里的数据与加载,需要注意如下几点: getPersistentAngerTarget可能返回null,在保存时需要一个null-check 在从NBT中读取数据时,因为可能没有保存persistentAngerTarget,因此要用到hasUUID的检查(这个之前提到过) 通过UUID获取实体一定要在服务端进行 接下来是Anger的更新。 default void updatePersistentAnger(ServerLevel level, boolean alwaysAngryIfTargetsPlayer) { LivingEntity target = getTarget(); UUID angryAt = getPersistentAngerTarget(); if ((target == null || target.isDeadOrDying()) && angryAt != null && level.getEntity(angryAt) instanceof Mob) { stopBeingAngry(); } else { if (target != null && !Objects.equals(angryAt, target.getUUID())) { setPersistentAngerTarget(target.getUUID()); // 这里就调用了startPersistentAngerTimer,下面还会有一次调用 startPersistentAngerTimer(); } if (getRemainingPersistentAngerTime() > 0 && (target == null || target.getType() != EntityType.PLAYER || !alwaysAngryIfTargetsPlayer)) { setRemainingPersistentAngerTime(getRemainingPersistentAngerTime() - 1); if (getRemainingPersistentAngerTime() == 0) { stopBeingAngry(); } } } } 这个updatePersistentAnger方法应该在实体更新时在服务端手动调用。如果给方法的第二个参数传入了false(在原版中,只有蜜蜂用了false),那么只要remainingPersistentAngerTime大于0,这个变量的值就会每游戏刻减少1,也就是说,你的生物会在追击你时慢慢原谅你。 接下来的几个方法用于获取该NeutralMob是否生气。 default boolean isAngryAt(LivingEntity entity) { if (!canAttack(entity)) { return false; } else { return entity.getType() == EntityType.PLAYER && isAngryAtAllPlayers(entity.level()) ? true : entity.getUUID().equals(getPersistentAngerTarget()); } } default boolean isAngryAtAllPlayers(Level level) { return level.getGameRules().getBoolean(GameRules.RULE_UNIVERSAL_ANGER) && isAngry() && getPersistentAngerTarget() == null; } default boolean isAngry() { return getRemainingPersistentAngerTime() > 0; } 在这些方法中,isAngryAt方法常作为方法引用使用在NearestAttackableTargetGoal中的最后一个参数中,来控制NeutralMob只攻击惹怒自己的玩家。isAngryAtAllPlayers只在isAngryAt方法中被调用。而isAngry方法使用得就比较广泛,常用于判断NeutralMob是否在生气中,并用于下一步的行为或材质控制。 最后是与移除生气状态相关的方法。 default void playerDied(Player player) { if (player.level().getGameRules().getBoolean(GameRules.RULE_FORGIVE_DEAD_PLAYERS)) { if (player.getUUID().equals(getPersistentAngerTarget())) { stopBeingAngry(); } } } default void forgetCurrentTargetAndRefreshUniversalAnger() { stopBeingAngry(); startPersistentAngerTimer(); } default void stopBeingAngry() { setLastHurtByMob(null); setPersistentAngerTarget(null); setTarget(null); setRemainingPersistentAngerTime(0); } 当玩家死亡后,playerDied方法将会在服务端被自动调用,也就是说,原版中player参数总是传入一个ServerPlayer的实例。forgetCurrentTargetAndRefreshUniversalAnger方法,则在NeutralMob的ResetUniversalAngerTargetGoal里的start方法中被调用,用来重置Anger。stopBeingAngry方法用于直接移除NeutralMob的生气状态,由于NeutralMob类中已经写好了部分方法,所以一般不需要直接调用它(唯一的例外是在蜜蜂成功伤害实体后,这个方法会被直接调用)。 这节的内容就是这么多啦,下一节将正式开始分析末影人~ "},"part1-monster/melee/enderman/enderman.html":{"url":"part1-monster/melee/enderman/enderman.html","title":"末影人的基本实现逻辑","keywords":"","body":"末影人的基本实现逻辑 末影人是比僵尸稍复杂的近战怪物。因为EnderMan类中有约40%的代码是继承了Goal的非公有静态内部类,也就是末影人独有的AI,内容较多,所以我们现在先不谈论它们。本节将以讨论末影人瞬移、搬运方块等能力与特性的实现为主。 先来看Fields: // 末影人追击时的速度提升的修饰符的UUID private static final UUID SPEED_MODIFIER_ATTACKING_UUID = UUID.fromString(\"020E0DFB-87AE-4653-9556-831010E291A0\"); // 末影人追击时的速度提升的修饰符 private static final AttributeModifier SPEED_MODIFIER_ATTACKING = new AttributeModifier(SPEED_MODIFIER_ATTACKING_UUID, \"Attacking speed boost\", (double)0.15F, AttributeModifier.Operation.ADDITION); // Forge提供的“魔法值”的“翻译” private static final int DELAY_BETWEEN_CREEPY_STARE_SOUND = 400; private static final int MIN_DEAGGRESSION_TIME = 600; // 末影人手持的方块 private static final EntityDataAccessor> DATA_CARRY_STATE = SynchedEntityData.defineId(EnderMan.class, EntityDataSerializers.OPTIONAL_BLOCK_STATE); // 决定了末影人是否在“生气”(张嘴和发抖),值为true则说明在“生气” private static final EntityDataAccessor DATA_CREEPY = SynchedEntityData.defineId(EnderMan.class, EntityDataSerializers.BOOLEAN); // 决定了末影人是否在盯着实体看,值为true则说明是这样 private static final EntityDataAccessor DATA_STARED_AT = SynchedEntityData.defineId(EnderMan.class, EntityDataSerializers.BOOLEAN); // 上次播放示威声的时间戳(tick),只在客户端有用 private int lastStareSound = Integer.MIN_VALUE; // 改变目标的时间戳(tick) private int targetChangeTime; // 生气时间的范围 private static final UniformInt PERSISTENT_ANGER_TIME = TimeUtil.rangeOfSeconds(20, 39); // 这些是上一节的内容(生气的剩余时间和目标UUID) private int remainingPersistentAngerTime; @Nullable private UUID persistentAngerTarget; 各Field的用途已经在注释中标注出来了。除了1.2.1.1.1节讲的修饰符和EntityDataAccessor外,你需要注意这里时间戳(timestamp)的运用。时间戳可以减少更新值的次数,因此一般会在Map或CompoundTag里存时间戳,而不是每刻数值减少1的“变量”。这里的时间戳的作用可以近似地理解为“标识了这次打开游戏后自实体首次被tick以来经过的tick数(前提是实体一直在被更新)”。因为实体的tickCount不会存到实体的NBT中,所以在实体NBT中保存基于tickCount的时间戳无意义。 时间戳的运用也十分常见,LivingEntity里的lastHurtByPlayerTime、lastHurtByMobTimestamp以及lastHurtMobTimestamp等成员变量都是基于tickCount的时间戳。 接下来是构造方法。这个构造方法成分复杂,所以我们来细细研究一下233。 public EnderMan(EntityType type, Level level) { super(type, level); setMaxUpStep(1.0F); setPathfindingMalus(BlockPathTypes.WATER, -1.0F); } (IForgeEntity) default float getStepHeight() { float vanillaStep = self().maxUpStep(); if (self() instanceof LivingEntity living) { // 获取ForgeMod.STEP_HEIGHT_ADDITION.get()属性的值,其中ForgeMod.STEP_HEIGHT_ADDITION是个Attribute的RegistryObject AttributeInstance stepHeightAttribute = living.getAttribute(ForgeMod.STEP_HEIGHT_ADDITION.get()); if (stepHeightAttribute != null) { return (float) Math.max(0, vanillaStep + stepHeightAttribute.getValue()); } } return vanillaStep; } 先是setMaxUpStep(1.0F);。 setMaxUpStep方法设置了实体的maxUpStep。maxUpStep变量的值(默认为0.6)和Forge提供的ForgeMod.STEP_HEIGHT_ADDITION.get()属性,一同决定了实体不需要跳跃就能一下走上去的方块的最低高度,例如,当getStepHeight的返回值大于等于0.5时,实体能不跳跃走上半砖,返回值大于等于1(如铁傀儡)时就可以一步走上大多数方块。 然后是setPathfindingMalus(BlockPathTypes.WATER, -1.0F);。 MC中使用了一个基于“可变堆内元素位置”的二叉堆(为什么要这样设计而不使用现成的PriorityQueue呢?因为Node是可变的)的A*寻路算法。如果你对A*算法比较陌生,你可以看看这篇教程,或者暂时跳过下面一段内容,因为本节的重点并不是寻路算法。如果你想更深入地了解MC中的寻路系统,这篇文章或许对你有帮助。 setPathfindingMalus方法间接地影响了NodeEvaluator(路径节点计算器)对符合BlockPathTypes.WATER类型的Node(可以理解为水上的路径节点)计算的costMalus的结果,Node的costMalus会影响Node的g值。这里简要说一下第二个参数一般的取值方式(以下内容将类名BlockPathTypes译为“方块路径类型”)。 如果你的Mob一定需要避免某一类方块(例如对TA有严重的危险),就把那类方块对应的方块路径类型对应的malus设置为-1 如果你的Mob需要尽量避免某一类方块(例如对TA有较低的危险),就把那类方块对应的方块路径类型对应的malus设置得高一些 如果你的Mob需要尽量在某一类方块上行走,就把那类方块对应的方块路径类型对应的malus设置得低一些(但一定要非负) 其中常用的取值为-1,0,8,16(除负数外,值越高表示越需要避免这类方块)。举几个原版使用的例子。 烈焰人: public Blaze(EntityType type, Level level) { super(type, level); setPathfindingMalus(BlockPathTypes.WATER, -1.0F); setPathfindingMalus(BlockPathTypes.LAVA, 8.0F); setPathfindingMalus(BlockPathTypes.DANGER_FIRE, 0.0F); setPathfindingMalus(BlockPathTypes.DAMAGE_FIRE, 0.0F); xpReward = 10; } 因为烈焰人不怕火却怕水(isSensitiveToWater),所以火焰(DAMAGE_FIRE和DANGER_FIRE)的malus都被设为了0,就连默认malus为-1,一般的生物都尽量避免的岩浆,malus也被设为了8,但水的malus却被设为了-1。 水生生物: protected WaterAnimal(EntityType type, Level level) { super(type, level); setPathfindingMalus(BlockPathTypes.WATER, 0.0F); } 水生生物离不开水,更不可能怕水,所以水的malus被设为了0。 提示:新版中BlockPathTypes实现了IExtensibleEnum接口,也就是说你可以实例化属于自己的方块路径类型。建议有需求的Modder重写IForgeBlock的getBlockPathType方法,以给方块自定义的路径类型。 回到末影人,现在应该能理解第二个参数“-1”的含义了。因为末影人遇水会受到伤害,所以把水的malus设置为了-1。今后在写自己的Mob时,也可以通过这个方式调整TA的寻路系统,让TA变得更聪明或更难对付~ AI部分这节先不讲。 然后是末影人的属性注册。 public static AttributeSupplier.Builder createAttributes() { return Monster.createMonsterAttributes() .add(Attributes.MAX_HEALTH, 40.0D) .add(Attributes.MOVEMENT_SPEED, (double) 0.3F) .add(Attributes.ATTACK_DAMAGE, 7.0D) .add(Attributes.FOLLOW_RANGE, 64.0D); } 属性注册应该理解起来没有什么难点。再接着是被大改的setTarget的方法。 @Override public void setTarget(@Nullable LivingEntity target) { AttributeInstance attr = getAttribute(Attributes.MOVEMENT_SPEED); if (target == null) { targetChangeTime = 0; entityData.set(DATA_CREEPY, false); entityData.set(DATA_STARED_AT, false); attr.removeModifier(SPEED_MODIFIER_ATTACKING); } else { // 时间戳的赋值 targetChangeTime = tickCount; entityData.set(DATA_CREEPY, true); if (!attr.hasModifier(SPEED_MODIFIER_ATTACKING)) { attr.addTransientModifier(SPEED_MODIFIER_ATTACKING); } } // Forge的注释,意思就是把super.setTarget移下来可以允许事件监听器更改当前末影人DATA_CREEPY和DATA_STARED_AT的值 super.setTarget(target); //Forge: Moved down to allow event handlers to write data manager values. } 以及Mob类的setTarget。 public void setTarget(@Nullable LivingEntity target) { LivingChangeTargetEvent changeTargetEvent = ForgeHooks.onLivingChangeTarget(this, target, LivingChangeTargetEvent.LivingTargetType.MOB_TARGET); if (!changeTargetEvent.isCanceled()) { this.target = changeTargetEvent.getNewTarget(); } } 首先要感谢Forge的一点是,新版的Forge大幅度优化了LivingChangeTargetEvent。以前这个事件既不能被取消,也不能改变target,而且如果使用Brain的Mob(例如猪灵)设置攻击的目标,这个事件甚至不会被post,可以说是几乎没什么用。现在这几个问题被彻底解决了! 接着看EnderMan类里重写的一部分:如果target为null,就重置时间戳targetChangeTime和末影人的几个状态,并移除速度修饰符。否则给targetChangeTime赋值当前的tickCount,并设置末影人为愤怒状态,添加速度修饰符。这为我们设计生物提供了一个有用的思路:要想让Mob在设置目标时有特殊的行为,可以重写setTarget方法或者监听事件LivingChangeTargetEvent。 另外要说的是,在setTarget的过程中,不难发现移除属性修饰符不需要hasModifier的检查,但添加属性修饰符一定要检查以前有没有添加过,不然会抛出IllegalArgumentException(Modifier is already applied on this attribute!)。 下面是示威声的播放。 @Override public void onSyncedDataUpdated(EntityDataAccessor accessor) { if (DATA_CREEPY.equals(accessor) && hasBeenStaredAt() && level().isClientSide) { playStareSound(); } super.onSyncedDataUpdated(accessor); } public void playStareSound() { // 这里是时间戳的应用 if (tickCount >= this.lastStareSound + 400) { this.lastStareSound = tickCount; if (!isSilent()) { level().playLocalSound(getX(), getEyeY(), getZ(), SoundEvents.ENDERMAN_STARE, getSoundSource(), 2.5F, 1.0F, false); } } } 因为lastStareSound成员变量只在客户端被使用,所以无需数据同步。有一个注意点是,如果你要用除Entity的playSound方法外的方式(例如上面用Level的playLocalSound方法)播放来自你的实体的声音,务必要进行if (!isSlient())的检查(playSound方法内置了检查,所以不需要额外判断一次)。 任何与播放声音有关的方法往往要涉及到两个float参数,一般前面一个表示音量(volume),后面一个表示音调(pitch)。部分情况下,可能用random.nextFloat()等实现音调和音量的随机化,使声音更自然。 接下来是数据保存与加载的部分。 @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); BlockState carried = getCarriedBlock(); if (carried != null) { tag.put(\"carriedBlockState\", NbtUtils.writeBlockState(carried)); } addPersistentAngerSaveData(tag); } @Override public void readAdditionalSaveData(CompoundTag tag) { super.readAdditionalSaveData(tag); BlockState state = null; // 10表示CompoundTag(“混合”的NBT标签) if (tag.contains(\"carriedBlockState\", 10)) { state = NbtUtils.readBlockState(level().holderLookup(Registries.BLOCK), tag.getCompound(\"carriedBlockState\")); if (blockstate.isAir()) { state = null; } } setCarriedBlock(state); readPersistentAngerSaveData(level(), tag); } 这里储存BlockState用的是NbtUtils里的writeBlockState方法,其余应该不难理解。但注意readBlockState在未找到NBT里的BlockState时,返回Blocks.AIR.defaultBlockState()而非null。 接着来看一个重点,isLookingAtMe。 // 这个方法的访问权限就是default boolean isLookingAtMe(Player player) { ItemStack stack = player.getInventory().armor.get(3); if (ForgeHooks.shouldSuppressEnderManAnger(this, player, stack)) { return false; } else { // getViewVector里的float参数与partialTicks与平滑渲染有关(0和1间差了一tick的更新),一般情况下填0或1即可 Vec3 viewVector = player.getViewVector(1.0F).normalize(); Vec3 vectorToPlayer = new Vec3(getX() - player.getX(), getEyeY() - player.getEyeY(), getZ() - player.getZ()); double len = vectorToPlayer.length(); vectorToPlayer = vectorToPlayer.normalize(); double dotValue = viewVector.dot(vectorToPlayer); return dotValue > 1.0D - 0.025D / len ? player.hasLineOfSight(this) : false; } } 这里涉及到了很多的向量运算,用来实现注视末影人的头部激怒末影人的效果。wiki上没有提到这个算法,所以用数学语言大致翻译一下: 设玩家坐标为(x1,y1,z1)(x_1, y_1, z_1)(x1,y1,z1),末影人坐标为(x2,y2,z2)(x_2, y_2, z_2)(x2,y2,z2)记指向玩家所看向方向的单位向量为p⃗\\vec{p}p⃗,记向量q⃗=(x2−x1,y2−y1,z2−z1)\\vec{q}=(x_2-x_1, y_2-y_1, z_2-z_1)q⃗=(x2−x1,y2−y1,z2−z1)(因为末影人的跟随范围为64,所以可以认为,总有∣q⃗∣≤64|\\vec{q}| \\leq 64∣q⃗∣≤64,这个下一节讲AI时再阐述原因) 如果满足p⃗⋅q⃗∣q⃗∣>1−0.025∣q⃗∣\\large \\frac{\\vec{p}\\cdot\\vec{q}}{|\\vec{q}|} > 1-\\frac{0.025}{|\\vec{q}|}∣q⃗∣p⃗⋅q⃗>1−∣q⃗∣0.025(即cos⟨p⃗,q⃗⟩>1−140∣q⃗∣\\large cos\\langle\\vec{p}, \\vec{q}\\rangle > 1-\\frac{1}{40|\\vec{q}|}cos⟨p⃗,q⃗⟩>1−40∣q⃗∣1) 就返回true,否则返回false。 可见良好的数学基础在Mod开发中也起着重要作用。这个方法的调用位置在末影人的AI里,下节再详细讲。 在进入下一个重点前,先看三个Override。 // 注意这里的dimension还是指尺寸 @Override protected float getStandingEyeHeight(Pose pose, EntityDimensions dimensions) { return 2.55F; } @Override public boolean isSensitiveToWater() { return true; } @Override public boolean requiresCustomPersistence() { return super.requiresCustomPersistence() || getCarriedBlock() != null; } 简要解释一下这三项: 因为末影人眼睛的高度偏高,高于默认值0.85 * 身高2.9 = 2.465,所以重新指定了眼睛的高度。 isSensitiveToWater如果返回true,就说明LivingEntity遇任何形式的水会受到伤害。 手持方块的末影人不应该被刷掉,所以requiresCustomPersistence返回了true(意味着不会刷掉末影人)。 然后是实体更新。 @Override public void aiStep() { if (level().isClientSide) { for (int i = 0; i = targetChangeTime + 600) { float lightLevel = getLightLevelDependentMagicValue(); if (lightLevel > 0.5F && level().canSeeSky(blockPosition()) && random.nextFloat() * 30.0F customServerAiStep与aiStep方法的区别在于customServerAiStep方法只会在服务端被调用,而aiStep是双端被调用的。因此在customServerAiStep中无需level().isClientSide(或level().isClientSide())的检查。 分析代码可以发现,末影人的瞬移频率与亮度值呈正相关,且只有lightLevelDependentMagicValue(这个值与实际亮度和维度的环境光照都有关,不过getLightLevelDependentMagicValue方法被弃用了)大于0.5时才会尝试随机遗忘攻击目标并瞬移。 接下来讲下一个重点:瞬移。 // 下面几个方法的访问权限很混乱,我也不知道为什么要这样写 protected boolean teleport() { if (!level().isClientSide() && isAlive()) { double rx = getX() + (random.nextDouble() - 0.5D) * 64.0D; // 水平32格范围随机传送 double ry = getY() + (double) (random.nextInt(64) - 32); // 竖直方向上随机选取(自身y + n)的y坐标作为传送基准y(n为[-32, 32)内的随机整数) double rz = getZ() + (random.nextDouble() - 0.5D) * 64.0D; return teleport(rx, ry, rz); } else { return false; } } boolean teleportTowards(Entity entity) { Vec3 targetVector = new Vec3(getX() - entity.getX(), getY(0.5D) - entity.getEyeY(), getZ() - entity.getZ()); targetVector = targetVector.normalize(); double teleportDistance = 16.0D; double x = getX() + (random.nextDouble() - 0.5D) * 8.0D - targetVector.x * 16.0D; // 水平方向上,向entity方向传送16格,并加以4格的随机干扰(16.0D -> teleportDistance) double y = getY() + (double) (random.nextInt(16) - 8) - targetVector.y * 16.0D; // 竖直方向上随机选取(自身y + n)的y坐标作为传送基准y(n为[-8, 8)内的随机整数) double z = getZ() + (random.nextDouble() - 0.5D) * 8.0D - targetVector.z * 16.0D; return teleport(x, y, z); } private boolean teleport(double x, double y, double z) { // 运用MutableBLockPos调节y坐标 BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(x, y, z); // 只要不是“固体方块”就下移 while (pos.getY() > level().getMinBuildHeight() && !level().getBlockState(pos).blocksMotion()) { // blocksMotion方法已弃用,实际开发中尽量少用 pos.move(Direction.DOWN); } BlockState state = level().getBlockState(pos); boolean blocksMotion = state.blocksMotion(); boolean water = state.getFluidState().is(FluidTags.WATER); if (blocksMotion && !water) { EntityTeleportEvent.EnderEntity event = ForgeEventFactory.onEnderTeleport(this, x, y, z); if (event.isCanceled()) { return false; } Vec3 position = position(); boolean teleported = randomTeleport(event.getTargetX(), event.getTargetY(), event.getTargetZ(), true); if (teleported) { level().gameEvent(GameEvent.TELEPORT, position, GameEvent.Context.of(this)); if (!isSilent()) { level().playSound(null, xo, yo, zo, SoundEvents.ENDERMAN_TELEPORT, getSoundSource(), 1.0F, 1.0F); // 这里的playSound其实可以移出最深层的if playSound(SoundEvents.ENDERMAN_TELEPORT, 1.0F, 1.0F); } } return teleported; } else { return false; } } 实体的瞬移的实现中含有大量的细节需要注意,一般分为2个主要的步骤: 确定大致的瞬移位置,主要是水平位置和基准y坐标,又可分为以下的3小步: 找大致方向 添加水平随机扰动 确定下一步调整y坐标时使用的y坐标的基准值(简称为基准y坐标) 在y上调整,直至找到合适的y坐标(这一步主要应用于LivingEntity的瞬移,对于弹射物等实体的瞬移可以省略) 前两个方法主要实现了上述步骤的第1步。确定水平位置有两种常用的基本方法: 随机生成x、z坐标,直接作为水平传送位置(适用于在矩形内生成水平位置) 随机生成角度和半径,利用三角函数算出水平传送位置(适用于在圆内生成水平位置) 如果有更复杂的需求,可以考虑重复尝试生成随机坐标,一成功就break。 瞬移和重复尝试思想可以结合使用。在末影人受伤害瞬移时、及1.2.1.1.1节说过的盖亚守护者随机传送时,就利用到了这种结合。暮色森林的巫妖(Lich)(第552~619行)也用到了很多的瞬移技巧,如果有需求或感兴趣,可以去阅读巫妖的源代码。 第3个方法就实现了上述步骤的第2步。在有基准y坐标的情况下,常用的调整最终y坐标的方式是使用MutableBlockPos类,这种情况多见于对实体传送位置或召唤物生成位置的计算。而在没有基准y坐标的情况下,常常借助高度图(Heightmap)获取适宜的y坐标,这种情况多见于袭击等事件中对袭击者生成位置的计算。 在第一次用MutableBlockPos调整完后,接下来又用randomTeleport方法做了第二步调整。最后简单看一下randomTeleport的实现(不要被方法名带偏了,这个方法中没有生成任何随机坐标): public boolean randomTeleport(double randomX, double randomY, double randomZ, boolean showParticles) { double x = getX(); double y = getY(); double z = getZ(); double finalY = randomY; boolean success = false; BlockPos targetPos = BlockPos.containing(randomX, randomY, randomZ); Level level = level(); if (level.hasChunkAt(targetPos)) { // 个人认为在这种情境下这一部分可以省略。可以直接跳到teleportTo(randomX, finalY, randomZ); boolean foundSolid = false; while (!foundSolid && targetPos.getY() > level.getMinBuildHeight()) { BlockPos below = targetPos.below(); BlockState belowBlockState = level.getBlockState(below); if (belowBlockState.blocksMotion()) { foundSolid = true; } else { --finalY; targetPos = below; } } if (foundSolid) { teleportTo(randomX, finalY, randomZ); if (level.noCollision(this) && !level.containsAnyLiquid(getBoundingBox())) { success = true; } } } if (!success) { teleportTo(x, y, z); return false; } else { if (showParticles) { // 广播46号实体事件会生成大量传送粒子效果 level.broadcastEntityEvent(this, (byte) 46); } if (this instanceof PathfinderMob) { ((PathfinderMob) this).getNavigation().stop(); } return true; } } 再看dropCustomDeathLoot方法。 @Override protected void dropCustomDeathLoot(DamageSource source, int lootingLevel, boolean killedByPlayer) { super.dropCustomDeathLoot(source, lootingLevel, killedByPlayer); BlockState carriedBlock = getCarriedBlock(); if (carriedBlock != null) { ItemStack axe = new ItemStack(Items.DIAMOND_AXE); axe.enchant(Enchantments.SILK_TOUCH, 1); // 这个强转是安全的,因为这个方法不会在客户端被调用 LootParams.Builder builder = new LootParams.Builder((ServerLevel) level()) .withParameter(LootContextParams.ORIGIN, position()) .withParameter(LootContextParams.TOOL, axe) .withOptionalParameter(LootContextParams.THIS_ENTITY, this); for (ItemStack drop : carriedBlock.getDrops(builder)) { // spawnLocation方法用于生成携带指定ItemStack的ItemEntity spawnAtLocation(drop); } } } 不难发现,杀死末影人后,如果末影人有手持的方块,会先获取末影人手持的方块被附魔了精准采集的钻石斧采集时的战利品表,根据这个战利品表再生成掉落物。 接下来是hurt方法。 @Override public boolean hurt(DamageSource source, float amount) { if (isInvulnerableTo(source)) { return false; } else { boolean willBeDamagedByPotion = source.getDirectEntity() instanceof ThrownPotion; if (!source.is(DamageTypeTags.IS_PROJECTILE) && !willBeDamagedByPotion) { boolean hurt = super.hurt(source, amount); if (!level().isClientSide() && !(source.getEntity() instanceof LivingEntity) && random.nextInt(10) != 0) { teleport(); } return hurt; } else { // source.is(DamageTypeTags.IS_PROJECTILE) || willBeDamagedByPotion boolean hurt = willBeDamagedByPotion && hurtWithCleanWater(source, (ThrownPotion) source.getDirectEntity(), amount); // 这就是重复尝试瞬移的一个应用 for (int i = 0; i potionEffects = PotionUtils.getMobEffects(potionItem); boolean empty = potion == Potions.WATER && potionEffects.isEmpty(); // 不能把super.hurt换成hurt,否则会导致无限递归 return empty ? super.hurt(source, amount) : false; } 这部分内容主要实现了“末影人不会被弹射物伤害(只会被喷溅水瓶伤害)”的特性。注意这里巧妙地调用了super.hurt,以避免出现无限递归。还要注意,这里末影人虽然没有成功受到伤害,hurt方法也返回了true,此时末影人会“变红”,但不会损失生命值。 最后就是音效了。末影人实体类型的注册与僵尸大同小异,没有新的要点,因此此处不再分析。 @Override protected SoundEvent getAmbientSound() { return isCreepy() ? SoundEvents.ENDERMAN_SCREAM : SoundEvents.ENDERMAN_AMBIENT; } @Override protected SoundEvent getHurtSound(DamageSource source) { return SoundEvents.ENDERMAN_HURT; } @Override protected SoundEvent getDeathSound() { return SoundEvents.ENDERMAN_DEATH; } 注意不要忽略音效这种细节哦~ 末影人能力与特性的实现便分析到这里了。下一节将会分析末影人的AI。 "},"part1-monster/melee/enderman/enderman2.html":{"url":"part1-monster/melee/enderman/enderman2.html","title":"末影人的AI","keywords":"","body":"末影人的AI 接下来到了末影人的AI。因为末影人的行为较简单,所以用Goal系统已经足够。因为这一节属于末影人部分,所以本节的内容以分析末影人特有的AI为主,其余的通用AI以后找机会再讲吧~ 下面来看registerGoals方法: @Override protected void registerGoals() { // FloatGoal旧称SwimGoal,游泳的AI。 goalSelector.addGoal(0, new FloatGoal(this)); goalSelector.addGoal(1, new EnderMan.EndermanFreezeWhenLookedAt(this)); goalSelector.addGoal(2, new MeleeAttackGoal(this, 1.0D, false)); goalSelector.addGoal(7, new WaterAvoidingRandomStrollGoal(this, 1.0D, 0.0F)); goalSelector.addGoal(8, new LookAtPlayerGoal(this, Player.class, 8.0F)); goalSelector.addGoal(8, new RandomLookAroundGoal(this)); goalSelector.addGoal(10, new EnderMan.EndermanLeaveBlockGoal(this)); goalSelector.addGoal(11, new EnderMan.EndermanTakeBlockGoal(this)); targetSelector.addGoal(1, new EnderMan.EndermanLookForPlayerGoal(this, this::isAngryAt)); targetSelector.addGoal(2, new HurtByTargetGoal(this)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, Endermite.class, true, false)); targetSelector.addGoal(4, new ResetUniversalAngerTargetGoal<>(this, false)); } 注意以下一部分实例化了静态内部类: @Override protected void registerGoals() { goalSelector.addGoal(1, new EnderMan.EndermanFreezeWhenLookedAt(this)); goalSelector.addGoal(10, new EnderMan.EndermanLeaveBlockGoal(this)); goalSelector.addGoal(11, new EnderMan.EndermanTakeBlockGoal(this)); targetSelector.addGoal(1, new EnderMan.EndermanLookForPlayerGoal(this, this::isAngryAt)); } 我们先看EndermanFreezeWhenLookedAt这个类。 static class EndermanFreezeWhenLookedAt extends Goal { private final EnderMan enderman; @Nullable private LivingEntity target; public EndermanFreezeWhenLookedAt(EnderMan enderman) { this.enderman = enderman; setFlags(EnumSet.of(Goal.Flag.JUMP, Goal.Flag.MOVE)); } @Override public boolean canUse() { target = enderman.getTarget(); if (!(target instanceof Player)) { return false; } else { double disSqr = target.distanceToSqr(enderman); return !(disSqr > 256.0D) && enderman.isLookingAtMe((Player) target); } } @Override public void start() { enderman.getNavigation().stop(); } @Override public void tick() { enderman.getLookControl().setLookAt(target.getX(), target.getEyeY(), target.getZ()); } // P.S. canContinueToUse省略了,意味着返回值与canUse相同 } 提示:构造方法内的setFlags(EnumSet)很容易遗漏,在设计生物的AI时需特别注意是否需要添加Flag,以防在禁用或启用Flag时(例如在被拴绳牵引时Flag.MOVE会被禁用)生物出现意料之外的问题 这个类比较简单,目的就是为了让末影人可以在攻击目标在不远处(16格内),且攻击目标正看向自己时看向其攻击目标。另外,这个AI的实时性不高,不需要每tick更新。 接下来是两个与方块操作相关的AI,先给出完整的代码。 static class EndermanLeaveBlockGoal extends Goal { private final EnderMan enderman; public EndermanLeaveBlockGoal(EnderMan enderman) { this.enderman = enderman; } @Override public boolean canUse() { if (enderman.getCarriedBlock() == null) { return false; } else if (!ForgeEventFactory.getMobGriefingEvent(enderman.level(), enderman)) { return false; } else { // 因为这是个每两刻更新的AI,所以必须调用reducedTickDelay(或adjustedTickDelay)方法,下同 // 执行的概率是1/2000每刻 return enderman.getRandom().nextInt(reducedTickDelay(2000)) == 0; } } @Override public void tick() { RandomSource random = enderman.getRandom(); Level level = enderman.level(); int x = Mth.floor(enderman.getX() - 1.0D + random.nextDouble() * 2.0D); int y = Mth.floor(enderman.getY() + random.nextDouble() * 2.0D); int z = Mth.floor(enderman.getZ() - 1.0D + random.nextDouble() * 2.0D); BlockPos pos = new BlockPos(x, y, z); BlockState currentState = level.getBlockState(pos); BlockPos belowPos = pos.below(); BlockState belowState = level.getBlockState(belowPos); BlockState carriedState = enderman.getCarriedBlock(); if (carriedState != null) { // 根据周围方块状态更新自己的方块状态 carriedState = Block.updateFromNeighbourShapes(carriedState, enderman.level(), pos); if (canPlaceBlock(level, pos, carriedState, currentState, belowState, belowPos) && !ForgeEventFactory.onBlockPlace(enderman, BlockSnapshot.create(level.dimension(), level, belowPos), Direction.UP)) { // 放置方块 level.setBlock(pos, carriedState, 3); level.gameEvent(GameEvent.BLOCK_PLACE, pos, GameEvent.Context.of(this.enderman, carriedState)); enderman.setCarriedBlock(null); } } } private boolean canPlaceBlock(Level level, BlockPos pos, BlockState carriedBlock, BlockState currentState, BlockState belowState, BlockPos belowPos) { return currentState.isAir() // 当前方块状态是空气 && !belowState.isAir() // 下方方块状态不是空气 && !belowState.is(Blocks.BEDROCK) // 下方方块不是基岩 && !belowState.is(Tags.Blocks.ENDERMAN_PLACE_ON_BLACKLIST) // 下方方块无ENDERMAN_PLACE_ON_BLACKLIST标签 && belowState.isCollisionShapeFullBlock(level, belowPos) // 下方方块是完整的 && carriedBlock.canSurvive(level, pos) // 末影人手上拿着的方块可以放在pos处 && level.getEntities(enderman, AABB.unitCubeFromLowerCorner(Vec3.atLowerCornerOf(pos))).isEmpty(); // 放置方块的位置没有实体 } } static class EndermanTakeBlockGoal extends Goal { private final EnderMan enderman; public EndermanTakeBlockGoal(EnderMan enderman) { this.enderman = enderman; } @Override public boolean canUse() { if (enderman.getCarriedBlock() != null) { return false; } else if (!ForgeEventFactory.getMobGriefingEvent(enderman.level(), enderman)) { return false; } else { // 执行的概率是1/20每刻 return enderman.getRandom().nextInt(reducedTickDelay(20)) == 0; } } @Override public void tick() { RandomSource random = enderman.getRandom(); Level level = enderman.level(); int x = Mth.floor(enderman.getX() - 2.0D + random.nextDouble() * 4.0D); int y = Mth.floor(enderman.getY() + random.nextDouble() * 3.0D); int z = Mth.floor(enderman.getZ() - 2.0D + random.nextDouble() * 4.0D); BlockPos placeBlockPos = new BlockPos(x, y, z); BlockState state = level.getBlockState(placeBlockPos); Vec3 myPos = new Vec3(enderman.getBlockX() + 0.5D, y + 0.5D, enderman.getBlockZ() + 0.5D); Vec3 placePos = new Vec3(x + 0.5D, y + 0.5D, z + 0.5D); // HitResult很重要,马上会讲 BlockHitResult res = level.clip(new ClipContext(myPos, placePos, ClipContext.Block.OUTLINE, ClipContext.Fluid.NONE, enderman)); boolean notBlocked = res.getBlockPos().equals(placeBlockPos); if (state.is(BlockTags.ENDERMAN_HOLDABLE) && notBlocked) { // 搬起方块 level.removeBlock(placeBlockPos, false); level.gameEvent(GameEvent.BLOCK_DESTROY, placeBlockPos, GameEvent.Context.of(enderman, state)); enderman.setCarriedBlock(state.getBlock().defaultBlockState()); } } } 可以发现,ForgeEventFactory.getMobGriefingEvent(enderman.level(), enderman)总是在canUse中被调用。这首先是因为GameRule只是标签,在操作前一定要确认GameRule是否允许这一行为,其次也因为Forge提供了EntityMobGriefingEvent这一事件。如果Forge有相关的事件(例如上面有EntityMobGriefingEvent和EntityPlaceEvent),不要忘记直接或间接post它们。 EndermanLeaveBlockGoal相对容易理解一些。每两刻如果AI可用,首先会生成一个随机方块坐标,然后检查这个方块坐标是否可以放下手中的方块。如果可以,那么canContinueToUse就会返回false,这个AI就会stop,否则这个AI就会继续运行并寻找方块。 EndermanTakeBlockGoal也用了类似的机制,但是该AI被“触发”的概率更大(因为搬起方块更难找到合适的,有可搬运方块的坐标)。 提一下EndermanTakeBlockGoal用到的HitResult(旧称RayTraceResult),不论是用于描述指向的方块的BlockHitResult,还是用于描述指向的实体的EntityHitResult,使用频率都很高。在这个AI中的作用,则是判断末影人坐标到尝试搬起方块的坐标间是否有障碍。下表对比了BlockHitResult与EntityHitResult的一些区别: 类别 BlockHitResult EntityHitResult 主要作用 描述指向的方块 描述指向的实体 用途举例 方块的放置(如在地上放置草方块) 与实体的交互(如用骨头喂狼) 一般的获取方式 Level类的clip实例方法 ProjectileUtil类中的一系列静态方法 最后是代码最长的AI,EndermanLookForPlayerGoal。Wiki上这样写:“玩家在64格距离内注视末影人的头部达到5游戏刻(0.25秒)也会激怒它们。”,这是怎么实现的呢? static class EndermanLookForPlayerGoal extends NearestAttackableTargetGoal { private final EnderMan enderman; @Nullable private Player pendingTarget; private int aggroTime; private int teleportTime; private final TargetingConditions startAggroTargetConditions; private final TargetingConditions continueAggroTargetConditions = TargetingConditions.forCombat().ignoreLineOfSight(); private final Predicate isAngerInducing; public EndermanLookForPlayerGoal(EnderMan enderman, @Nullable Predicate targetConditions) { super(enderman, Player.class, 10, false, false, targetConditions); this.enderman = enderman; this.isAngerInducing = entity -> (enderman.isLookingAtMe((Player) entity) || enderman.isAngryAt(entity)) && !enderman.hasIndirectPassenger(entity); // TargetingConditions里forCombat和forNonCombat的区别在于forCombat会检查是否是和平模式以及target是否为队友,而forNonCombat不会 this.startAggroTargetConditions = TargetingConditions.forCombat().range(getFollowDistance()).selector(isAngerInducing); } @Override public boolean canUse() { // pendingTarget,指待定的攻击目标 pendingTarget = enderman.level().getNearestPlayer(startAggroTargetConditions, enderman); return pendingTarget != null; } @Override public void start() { aggroTime = adjustedTickDelay(5); teleportTime = 0; enderman.setBeingStaredAt(); } @Override public void stop() { pendingTarget = null; super.stop(); } @Override public boolean canContinueToUse() { if (pendingTarget != null) { if (!isAngerInducing.test(pendingTarget)) { return false; } else { // lookAt的后两个float参数分别表示最大的yRot,最大的xRot enderman.lookAt(pendingTarget, 10.0F, 10.0F); return true; } } else { if (target != null) { if (enderman.hasIndirectPassenger(target)) { return false; } if (continueAggroTargetConditions.test(enderman, target)) { return true; } } return super.canContinueToUse(); } } @Override public void tick() { if (enderman.getTarget() == null) { super.setTarget(null); } if (pendingTarget != null) { if (--aggroTime 256.0D && teleportTime++ >= adjustedTickDelay(30) && enderman.teleportTowards(target)) { teleportTime = 0; } } super.tick(); } } } EndermanLookForPlayerGoal中先用pendingTarget临时记录了待定的攻击目标,等到注视5游戏刻后,再将其设置为真正的攻击目标。注意canUse被重写了,也就是说只要附近有玩家,这个AI就会“start”。 本节的内容就到此为止了,下一节将会简单讲一下末影人的模型与渲染~ "},"part1-monster/melee/enderman/enderman3.html":{"url":"part1-monster/melee/enderman/enderman3.html","title":"末影人的模型与渲染","keywords":"","body":"末影人的模型与渲染 本节将主要分析末影人的模型的特殊之处,以及末影人的渲染器中使用的Layer(RenderLayer,不是旧版生物群系使用的Layer) 先是成员变量与构造方法。 @OnlyIn(Dist.CLIENT) public class EndermanModel extends HumanoidModel { // 决定了末影人是否在搬着方块,值为true则表示正在搬着方块 public boolean carrying; // 决定了末影人是否处于愤怒状态,值为true则意味着处于愤怒状态 public boolean creepy; public EndermanModel(ModelPart part) { super(part); } } 因为末影人有2组不同的状态(生气-不生气,搬着方块-空手),所以模型中有两个公共且可变的boolean成员变量,用来控制末影人的模型。实际开发中,不提倡这种暴露成员变量的方式,尽量使用getter和setter。 然后是静态方法createBodyLayer,因为末影人的模型与标准的“人类模型”不同,所以要重新写一个LayerDefinition。 public static LayerDefinition createBodyLayer() { MeshDefinition meshDef = HumanoidModel.createMesh(CubeDeformation.NONE, -14.0F); PartDefinition partDef = meshDef.getRoot(); PartPose head = PartPose.offset(0.0F, -13.0F, 0.0F); // part的相对位置(x, y, z) partDef.addOrReplaceChild(\"hat\", // 此处与较早的MC版本定义模型的方式不同,材质位置,box的大小、位置、缩放比例都在PartDefinition中而不是ModelPart中定义 CubeListBuilder.create() .texOffs(0, 16) // 材质的uv位置 .addBox(-4.0F, -8.0F, -4.0F, // box的相对位置(x, y, z) 8.0F, 8.0F, 8.0F, // box的大小(x, y, z) new CubeDeformation(-0.5F) // 缩放比例(正值放大,负值收缩) ), head); partDef.addOrReplaceChild(\"head\", CubeListBuilder.create().texOffs(0, 0).addBox(-4.0F, -8.0F, -4.0F, 8.0F, 8.0F, 8.0F), head); partDef.addOrReplaceChild(\"body\", CubeListBuilder.create().texOffs(32, 16).addBox(-4.0F, 0.0F, -2.0F, 8.0F, 12.0F, 4.0F), PartPose.offset(0.0F, -14.0F, 0.0F)); partDef.addOrReplaceChild(\"right_arm\", CubeListBuilder.create().texOffs(56, 0).addBox(-1.0F, -2.0F, -1.0F, 2.0F, 30.0F, 2.0F), PartPose.offset(-5.0F, -12.0F, 0.0F)); partDef.addOrReplaceChild(\"left_arm\", CubeListBuilder.create().texOffs(56, 0).mirror().addBox(-1.0F, -2.0F, -1.0F, 2.0F, 30.0F, 2.0F), PartPose.offset(5.0F, -12.0F, 0.0F)); partDef.addOrReplaceChild(\"right_leg\", CubeListBuilder.create().texOffs(56, 0).addBox(-1.0F, 0.0F, -1.0F, 2.0F, 30.0F, 2.0F), PartPose.offset(-2.0F, -5.0F, 0.0F)); partDef.addOrReplaceChild(\"left_leg\", CubeListBuilder.create().texOffs(56, 0).mirror().addBox(-1.0F, 0.0F, -1.0F, 2.0F, 30.0F, 2.0F), PartPose.offset(2.0F, -5.0F, 0.0F)); return LayerDefinition.create(meshDef, 64, 32); // 模型使用的材质的尺寸 } 这一部分声明了末影人使用的模型,虽然比较抽象,但理解难度不是特别大。只要生物的模型与继承的父类提供的模型有出入(例如末影人的模型和一般的HumanoidModel不同),就需要重新写一个静态方法,用来创建LayerDefinition。同时别忘了注册LayerDefinition。 然后是setUpAnim,末影人的手臂的动画与other人形怪物不一样,所以需要重写这个方法。为方便阅读,调换了可调换的语句并删除了例如x -= 0的无意义的部分。 @Override public void setupAnim(T enderman, float limbSwing, float limbSwingAmount, float ageInTicks, float netHeadYaw, float headPitch) { super.setupAnim(enderman, limbSwing, limbSwingAmount, ageInTicks, netHeadYaw, headPitch); head.visible = true; body.xRot = 0.0F; body.y = -14.0F; body.z = -0.0F; rightLeg.z = 0.0F; leftLeg.z = 0.0F; rightLeg.y = -5.0F; leftLeg.y = -5.0F; head.z = -0.0F; head.y = -13.0F; hat.x = head.x; hat.y = head.y; hat.z = head.z; hat.xRot = head.xRot; hat.yRot = head.yRot; hat.zRot = head.zRot; rightArm.setPos(-5.0F, -12.0F, 0.0F); leftArm.setPos(5.0F, -12.0F, 0.0F); rightArm.xRot *= 0.5F; leftArm.xRot *= 0.5F; rightLeg.xRot *= 0.5F; leftLeg.xRot *= 0.5F; if (rightArm.xRot > 0.4F) { rightArm.xRot = 0.4F; } if (leftArm.xRot > 0.4F) { leftArm.xRot = 0.4F; } if (rightArm.xRot 0.4F) { rightLeg.xRot = 0.4F; } if (leftLeg.xRot > 0.4F) { leftLeg.xRot = 0.4F; } if (rightLeg.xRot 首先是模型box的重置。之所以要这样重置一遍,是因为末影人的模型和一般的HumanoidModel不同,因此box的位置也会有差异,需要在调用super方法后重置。 head.visible = true; body.xRot = 0.0F; body.y = -14.0F; body.z = -0.0F; rightLeg.z = 0.0F; leftLeg.z = 0.0F; rightLeg.y = -5.0F; leftLeg.y = -5.0F; head.z = -0.0F; head.y = -13.0F; hat.x = head.x; hat.y = head.y; hat.z = head.z; hat.xRot = head.xRot; hat.yRot = head.yRot; hat.zRot = head.zRot; rightArm.setPos(-5.0F, -12.0F, 0.0F); leftArm.setPos(5.0F, -12.0F, 0.0F); 接着是绕x轴旋转角度的限制,将xRot减半并把|xRot|限制在0.4,用于减小动作幅度,防止末影人做出一些“出格”的动作。 rightArm.xRot *= 0.5F; leftArm.xRot *= 0.5F; rightLeg.xRot *= 0.5F; leftLeg.xRot *= 0.5F; if (rightArm.xRot > 0.4F) { rightArm.xRot = 0.4F; } if (leftArm.xRot > 0.4F) { leftArm.xRot = 0.4F; } if (rightArm.xRot 0.4F) { rightLeg.xRot = 0.4F; } if (leftLeg.xRot > 0.4F) { leftLeg.xRot = 0.4F; } if (rightLeg.xRot 最后就是特殊状态下的姿势调整。 // 搬运方块时手臂的角度调整 if (carrying) { rightArm.xRot = -0.5F; leftArm.xRot = -0.5F; rightArm.zRot = 0.05F; leftArm.zRot = -0.05F; } // 生气时嘴巴下移 if (creepy) { head.y -= 5.0F; } 模型的内容就是这么多了,接下来是渲染器。 @OnlyIn(Dist.CLIENT) public class EndermanRenderer extends MobRenderer> { private static final ResourceLocation ENDERMAN_LOCATION = new ResourceLocation(\"textures/entity/enderman/enderman.png\"); private final RandomSource random = RandomSource.create(); public EndermanRenderer(EntityRendererProvider.Context context) { super(context, new EndermanModel<>(context.bakeLayer(ModelLayers.ENDERMAN)), 0.5F); addLayer(new EnderEyesLayer<>(this)); addLayer(new CarriedBlockLayer(this, context.getBlockRenderDispatcher())); } @Override public void render(EnderMan enderman, float yRot, float partialTicks, PoseStack stack, MultiBufferSource source, int packedLight) { BlockState carriedBlockState = enderman.getCarriedBlock(); EndermanModel model = getModel(); model.carrying = carriedBlockState != null; model.creepy = enderman.isCreepy(); super.render(enderman, yRot, partialTicks, stack, source, packedLight); } @Override public Vec3 getRenderOffset(EnderMan enderman, float partialTicks) { if (enderman.isCreepy()) { double scale = 0.02D; return new Vec3(random.nextGaussian() * 0.02D, 0.0D, random.nextGaussian() * 0.02D); } else { return super.getRenderOffset(enderman, partialTicks); } } @Override public ResourceLocation getTextureLocation(EnderMan enderman) { return ENDERMAN_LOCATION; } } 构造方法中添加了EnderEyesLayer与CarriedBlockLayer这两个Layer。EnderEyesLayer继承了EyesLayer,可以以特殊的RenderType渲染眼睛,同时用EyesLayer添加的眼睛不会因为实体隐身而消失。 想知道这是为什么吗?先来看EyesLayer的render方法。 @Override public void render(PoseStack stack, MultiBufferSource source, int packedLight, T entity, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { VertexConsumer consumer = source.getBuffer(renderType()); getParentModel().renderToBuffer(stack, consumer, 15728640, OverlayTexture.NO_OVERLAY, 1.0F, 1.0F, 1.0F, 1.0F); } 而游戏中狼却不会在隐身时显示项圈,对比一下WolfCollarLayer的片段。 @Override public void render(PoseStack stack, MultiBufferSource source, int packedLight, Wolf wolf, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { if (wolf.isTame() && !wolf.isInvisible()) { float[] colors = wolf.getCollarColor().getTextureDiffuseColors(); renderColoredCutoutModel(getParentModel(), WOLF_COLLAR_LOCATION, stack, source, packedLight, wolf, colors[0], colors[1], colors[2]); } } 真相就大白了!WolfCollarLayer中判断了狼是否隐身,如果隐身就不渲染项圈,所以隐身的狼的项圈是看不见的。EyesLayer中没有判断末影人是否隐身,因此隐身的末影人眼睛可见。 CarriedBlockLayer则用rotate, translate, scale等操作渲染了末影人手上拿着的方块,并将它移动到正确的位置。这个方法如下: @Override public void render(PoseStack stack, MultiBufferSource source, int packedLight, Wolf wolf, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { BlockState carriedBlock = enderman.getCarriedBlock(); if (carriedBlock != null) { // 这些方法的含义可以参考一些渲染方面的教程 stack.pushPose(); stack.translate(0.0F, 0.6875F, -0.75F); stack.mulPose(Axis.XP.rotationDegrees(20.0F)); stack.mulPose(Axis.YP.rotationDegrees(45.0F)); stack.translate(0.25F, 0.1875F, 0.25F); stack.scale(-0.5F, -0.5F, 0.5F); stack.mulPose(Axis.YP.rotationDegrees(90.0F)); blockRenderer.renderSingleBlock(carriedBlock, stack, bufferSource, packedLight, OverlayTexture.NO_OVERLAY); stack.popPose(); } } 应该不难理解其中的内容,如果对PoseStack(旧称MatrixStack)的操作不太熟悉,可以去阅读相关的渲染方面的教程,此处不做过多赘述。 接下来就没有什么难点了。然后来看render方法。 @Override public void render(EnderMan enderman, float rotation, float delta, PoseStack stack, MultiBufferSource bufferSource, int packedLight) { BlockState carriedBlock = enderman.getCarriedBlock(); EndermanModel model = getModel(); model.carrying = carriedBlock != null; model.creepy = enderman.isCreepy(); super.render(enderman, rotation, delta, stack, bufferSource, packedLight); } render方法重写的内容很简单,只是根据末影人的状态更新了模型的carrying和creepy两个boolean的值。 最后要讲的就是getRenderOffset方法。 @Override public Vec3 getRenderOffset(EnderMan enderman, float delta) { if (enderman.isCreepy()) { return new Vec3(random.nextGaussian() * 0.02D, 0.0D, random.nextGaussian() * 0.02D); } else { return super.getRenderOffset(enderman, delta); } } getRenderOffset方法的返回值被用来“translate”渲染实体用的PoseStack。因为末影人愤怒时身体会抖动,所以要重写此方法。 本节的内容就到此为止了,本章的内容也快要结束了。下一节我们再来讲一个自定义的近战怪物的实例~ "},"part1-monster/melee/actual_combat2.html":{"url":"part1-monster/melee/actual_combat2.html","title":"实战2 - 食尸鬼","keywords":"","body":"实战2 - 食尸鬼 本节的最后一部分是一个自定义的近战怪物的实例。 任务 制作一种新的怪物——食尸鬼 要求 食尸鬼攻击力是8,只有10的最大生命值,但是其护甲值是20,且盔甲韧性是8 食尸鬼受阳光照射会死亡,否则每秒恢复1点生命 食尸鬼死亡后,击杀该食尸鬼的玩家会获得10s的凋零II状态效果(持续时间和区域难度无关) 食尸鬼在50%生命值及以上时会主动攻击任何生物(包括其他食尸鬼),否则只会主动攻击玩家和铁傀儡(当然任何时候都会还击攻击自己的生物) 食尸鬼在50%生命值及以上时造成120%的伤害,在50%生命值以下时只受到80%的伤害 食尸鬼会游泳,空闲时会四处张望并随意走动 食尸鬼的音效、模型和材质可以任意设置 参考步骤 先是Ghoul类(此处未展示音效部分)。 public class Ghoul extends Monster { public Ghoul(EntityType type, Level level) { super(type, level); } public static AttributeSupplier.Builder createAttributes() { return createMonsterAttributes() .add(Attributes.MAX_HEALTH, 10) .add(Attributes.ATTACK_DAMAGE, 8) .add(Attributes.ARMOR, 20) .add(Attributes.ARMOR_TOUGHNESS, 8); } @Override protected void registerGoals() { super.registerGoals(); goalSelector.addGoal(0, new FloatGoal(this)); goalSelector.addGoal(2, new MeleeAttackGoal(this, 1, false)); goalSelector.addGoal(5, new RandomLookAroundGoal(this)); goalSelector.addGoal(6, new RandomStrollGoal(this, 1)); targetSelector.addGoal(1, new HurtByTargetGoal(this)); targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, IronGolem.class, true)); targetSelector.addGoal(4, new GhoulTargetAllMobsGoal(this)); // 这个AI优先级较低,因为我们希望食尸鬼会优先攻击玩家,其次是铁傀儡,最后是其他的生物 } @Override public void aiStep() { super.aiStep(); if (isAlive()) { if (isSunBurnTick()) { kill(); } else if (tickCount % 20 == 0) { heal(1); } } } public boolean isHighHealth() { return getHealth() >= getMaxHealth() * 0.5F; } } Ghoul类的内容不多,注意到这里定义了一个isHighHealth方法,为达到要求4和5提供便利,同时在registerGoals方法内(第17,19,20行)注册了3个AI,用来达到要求6。 下面来看此处为了达到要求2而重写的aiStep方法。 @Override public void aiStep() { super.aiStep(); if (isAlive()) { if (isSunBurnTick()) { kill(); } else if (tickCount % 20 == 0) { heal(1); } } } 此处用表达式tickCount % 20 == 0来判断是否需要回血。tickCount在实体被更新时会每tick自增一次,所以每20个游戏刻该表达式的值会有1刻为true。 然后是我们新写的AI——GhoulTargetAllMobsGoal。内容很简单,只是比原版的NearestAttackableTargetGoal多了一个判断。 public class GhoulTargetAllMobsGoal extends NearestAttackableTargetGoal { public GhoulTargetAllMobsGoal(Ghoul ghoul) { super(ghoul, Mob.class, true); } @Override public boolean canUse() { Ghoul ghoul = (Ghoul) mob; return ghoul.isHighHealth() && super.canUse(); } } 要求3、5可以用事件监听器来达到。下面是实战1中提到的EntityEventListener类里的新增内容。 @SubscribeEvent public static void onLivingDeath(LivingDeathEvent event) { if (event.getEntity() instanceof Ghoul) { DamageSource source = event.getSource(); if (source.getEntity() instanceof Player playerKiller) { playerKiller.addEffect(new MobEffectInstance(MobEffects.WITHER, 20 * 10, 1)); } } } @SubscribeEvent public static void onLivingHurt(LivingHurtEvent event) { if (event.getSource().getEntity() instanceof Ghoul ghoul && ghoul.isHighHealth()) { event.setAmount(event.getAmount() * 1.2f); } if (event.getEntity() instanceof Ghoul ghoul && !ghoul.isHighHealth()) { event.setAmount(event.getAmount() * 0.8f); } } 食尸鬼的注册以及模型与渲染就省略不写了(总体与强化僵尸差不多,当然如果你有好的idea,模型与渲染部分可以发挥自己的创意,结合一些渲染方面的教程自己完成~) 到此我们就做完了食尸鬼的全部内容(此处省略效果图)。当然在模组开发中,近战怪物在实现逻辑上都不会很复杂,而真正复杂的实体往往是那些有复杂行为的动物和会“法术”的怪物。 源代码(Ghoul类)源代码(GhoulTargetAllMobsGoal类) 思考与练习 能否让食尸鬼不自相残杀呢? 能否让食尸鬼的生命恢复速度随生命值减少而增大? 能否让凋灵不攻击食尸鬼?(提示:想想凋灵不攻击什么类型的生物) 能否让食尸鬼击杀其他生物时召唤新的食尸鬼? 能否使食尸鬼只能被阳光杀死?(也就是说,只要不被阳光照射,无论自己被攻击多少次都不会死亡) "},"part1-monster/ranged/":{"url":"part1-monster/ranged/","title":"远程攻击的怪物 #1","keywords":"","body":"远程攻击的怪物 #1(RangedAttackMob) 下面是远程攻击的怪物,这一部分我们分两块研究。制作一个实现RangedAttackMob接口的远程攻击的怪物相对简单,只需要为你的怪物添加一个AI,并实现该接口内的抽象方法performRangedAttack就可以。 添加的AI一般是RangedAttackGoal,但如果你的怪物使用弓,则应添加RangedBowAttackGoal,如果怪物使用弩,则不仅AI要改变,实现的接口也要变为继承了RangedAttackMob的CrossbowAttackMob。 接下来我们将围绕performRangedAttack方法出发,先熟悉这种最基础的远程攻击方式的实现。 "},"part1-monster/ranged/perform_ranged_attack.html":{"url":"part1-monster/ranged/perform_ranged_attack.html","title":"RangedAttackMob接口","keywords":"","body":"RangedAttackMob接口 本章节中介绍的远程攻击的怪物都实现了这个接口(当然为模组中新增的友好或中立生物实现这个接口也是可以的),这个接口可以用于实现远程攻击方式较简单且每次远程攻击在1游戏刻之内完成的生物。 public interface RangedAttackMob { void performRangedAttack(LivingEntity target, float power); } RangedAttackMob接口很简单,只有这个抽象方法(可不要把它当函数式接口使用哦)。这个方法中一共有2个参数,第一个参数是攻击目标,表示应该攻击谁,第二个参数是攻击的“力量”,使用较少,只在弓箭手型生物中被用来确定射出的箭的伤害。 然后来看RangedAttackGoal的构造方法与全局变量: public class RangedAttackGoal extends Goal { // 可以远程攻击的生物 private final Mob mob; private final RangedAttackMob rangedAttackMob; // 当前的攻击目标 @Nullable private LivingEntity target; // 攻击冷却时间(单位:tick),为0时表示生物正要攻击,为正时表示生物在等待攻击,为负时表示生物当前空闲 private int attackTime = -1; // 决定了生物远程攻击时的速度(生物远程攻击时的速度 = 基础移速 * speedModifier) private final double speedModifier; // 表示“能看见攻击目标”的状态持续了多久(单位:tick,例如“连续5tick能无阻挡地看见目标”时该值为5) private int seeTime; // 最短攻击间隔(单位:tick) private final int attackIntervalMin; // 最长攻击间隔(单位:tick) private final int attackIntervalMax; // 攻击半径(攻击范围所在圆的半径) private final float attackRadius; // 攻击半径的平方 private final float attackRadiusSqr; // 这个重载的构造方法使用十分广泛,其中attackIntervalMin与attackIntervalMax相等,表示攻击间隔固定 public RangedAttackGoal(RangedAttackMob mob, double speedModifier, int attackInterval, float attackRadius) { this(mob, speedModifier, attackInterval, attackInterval, attackRadius); } public RangedAttackGoal(RangedAttackMob mob, double speedModifier, int attackIntervalMin, int attackIntervalMax, float attackRadius) { if (!(mob instanceof LivingEntity)) { throw new IllegalArgumentException(\"ArrowAttackGoal requires Mob implements RangedAttackMob\"); } else { this.rangedAttackMob = mob; this.mob = (Mob) mob; this.speedModifier = speedModifier; this.attackIntervalMin = attackIntervalMin; this.attackIntervalMax = attackIntervalMax; this.attackRadius = attackRadius; this.attackRadiusSqr = attackRadius * attackRadius; setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); } } } 这里为了避免使用泛型或进行强制类型转换,此处用两个被声明为不同类型的引用指向了AI所有者。构造方法里也对传入的RangedAttackMob进行了检查,防止你传个lambda进去()。此外,不难发现攻击半径的平方被保存了,这是为了以后比较距离时用,至于为什么用攻击半径的平方,我们马上再说。 接下来是canUse与canContinueToUse两件套: public boolean canUse() { LivingEntity currentTarget = mob.getTarget(); if (currentTarget != null && currentTarget.isAlive()) { target = currentTarget; return true; } else { return false; } } public boolean canContinueToUse() { return canUse() || target.isAlive() && !mob.getNavigation().isDone(); } 代码很好理解,如果AI所有者有存活的攻击目标,就把target引用指向该目标,同时canUse返回true,否则自然返回false。而canContinueToUse加了一点点内容:如果已经保存的攻击目标还活着并且AI所有者仍在追击TA,那么canContinueToUse也返回true。 requiresUpdateEveryTick方法如下(这个方法总是返回true,因为与狡猾的敌人作战要求生物足够“机灵”): public boolean requiresUpdateEveryTick() { return true; } stop方法也没什么可说的,无非就是初始化了一些参数而已: public void stop() { target = null; seeTime = 0; attackTime = -1; } 重点来了!这个AI的核心部分!tick方法! public void tick() { double distSqr = mob.distanceToSqr(target.getX(), target.getY(), target.getZ()); boolean canSeeTarget = mob.getSensing().hasLineOfSight(target); // 如果该tick内能看见攻击目标,则seeTime自增一次 if (canSeeTarget) { ++seeTime; } else { seeTime = 0; } // 如果持续看到攻击目标超过0.25秒,停下,否则追逐目标 if (!(distSqr > (double) attackRadiusSqr) && seeTime >= 5) { mob.getNavigation().stop(); } else { mob.getNavigation().moveTo(target, speedModifier); } // 把头转向攻击目标 mob.getLookControl().setLookAt(target, 30.0F, 30.0F); if (--attackTime == 0) { if (!canSeeTarget) { return; } // 上文中攻击“力量”由到攻击目标的距离决定 float power = (float) Math.sqrt(distSqr) / attackRadius; float clampedPower = Mth.clamp(power, 0.1F, 1.0F); // performRangedAttack的调用位置 rangedAttackMob.performRangedAttack(target, clampedPower); attackTime = Mth.floor(power * (float) (attackIntervalMax - attackIntervalMin) + (float) attackIntervalMin); } else if (attackTime 该AI的核心行为在tick方法中被定义,实现的效果比较简单,就是若看不见目标,则追逐,与此同时若攻击冷却时间为0,则攻击。 值得一提的是如何算“看见”了目标。顺着一路找下去,关键的部分是Level类中的clip方法,由于该方法极其复杂,要想详细讲述原理需要占用大量篇幅,所以这里只讲一讲它的用处:计算一条射线在一定范围内与这个世界内方块的交点。其唯一的ClipContext(旧称RayTraceContext)参数可以控制诸如“是否考虑与液体方块的交点”一类的属性。 另外,判断弹射物是否击中实体时也用到了这个方法,这一点以后再讲。 要使用RangedAttackGoal也很简单,只需要实例化这个类即可,就像这样: goalSelector.addGoal(2, new RangedAttackGoal(this, 1.0D, 60, 10.0F)); 本节的内容就到此为止了,下一节我们将开始分析雪傀儡的实现。 "},"part1-monster/ranged/snow_golem/":{"url":"part1-monster/ranged/snow_golem/","title":"相对简单的雪傀儡","keywords":"","body":"相对简单的雪傀儡 雪傀儡(Snow Golem)是一种可以帮助抵御敌对生物的友好生物。 ——Minecraft Wiki "},"part1-monster/ranged/snow_golem/snow_golem.html":{"url":"part1-monster/ranged/snow_golem/snow_golem.html","title":"雪傀儡的实现逻辑","keywords":"","body":"雪傀儡的实现逻辑 为什么从雪傀儡讲起呢?这是因为虽然骷髅一类的生物是我们更为熟悉的远程攻击生物,但是骷髅的行为较为复杂,例如骷髅在玩家靠近时会尝试走离玩家,而且骷髅不只可以使用弓,还可以近战攻击,从骷髅入手会大大增加教程的复杂程度,引入较多与主题无关的内容且不利于读者理解。雪傀儡的行为则相对简单很多,单个雪傀儡所使用的Goal也只有5个,从雪傀儡讲起可以简化教程并使文章与本章节主题的关联性更强。 下面是一些之前曾经讲解过的、各种生物共有的或者易理解的部分,此处不再做详细解释。雪傀儡只有一个EntityDataAccessor,用于判断雪傀儡是否有南瓜头。 // 如果值为16则说明雪傀儡有南瓜头,尚不明确此处为什么不用Boolean。 private static final EntityDataAccessor DATA_PUMPKIN_ID = SynchedEntityData.defineId(SnowGolem.class, EntityDataSerializers.BYTE); public static AttributeSupplier.Builder createAttributes() { return Mob.createMobAttributes() .add(Attributes.MAX_HEALTH, 4.0D) .add(Attributes.MOVEMENT_SPEED, (double) 0.2F); } protected void defineSynchedData() { super.defineSynchedData(); entityData.define(DATA_PUMPKIN_ID, (byte) 16); } public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); tag.putBoolean(\"Pumpkin\", hasPumpkin()); } public void readAdditionalSaveData(CompoundTag tag) { super.readAdditionalSaveData(tag); if (tag.contains(\"Pumpkin\")) { setPumpkin(tag.getBoolean(\"Pumpkin\")); } } public boolean isSensitiveToWater() { return true; } @Nullable protected SoundEvent getAmbientSound() { return SoundEvents.SNOW_GOLEM_AMBIENT; } @Nullable protected SoundEvent getHurtSound(DamageSource source) { return SoundEvents.SNOW_GOLEM_HURT; } @Nullable protected SoundEvent getDeathSound() { return SoundEvents.SNOW_GOLEM_DEATH; } // 下面的部分使用了位运算,用于表示与修改雪傀儡的状态,如有不理解的可以去阅读相关内容 public boolean hasPumpkin() { return (entityData.get(DATA_PUMPKIN_ID) & 16) != 0; } public void setPumpkin(boolean pumpkin) { byte value = entityData.get(DATA_PUMPKIN_ID); if (pumpkin) { entityData.set(DATA_PUMPKIN_ID, (byte) (value | 16)); } else { entityData.set(DATA_PUMPKIN_ID, (byte) (value & -17)); } } 再来看AI。 protected void registerGoals() { goalSelector.addGoal(1, new RangedAttackGoal(this, 1.25D, 20, 10.0F)); goalSelector.addGoal(2, new WaterAvoidingRandomStrollGoal(this, 1.0D, 1.0000001E-5F)); // 1.0000001E-5F是散步的可能性,暂时不清楚为什么不直接使用1E-5F goalSelector.addGoal(3, new LookAtPlayerGoal(this, Player.class, 6.0F)); goalSelector.addGoal(4, new RandomLookAroundGoal(this)); targetSelector.addGoal(1, new NearestAttackableTargetGoal<>(this, Mob.class, 10, true, false, target -> { return target instanceof Enemy; })); } 注意一下RangedAttackGoal的参数。第二个参数speedModifier传入了1.25,意味着雪傀儡在攻击时的速度是基础移速的125%。其余参数的含义见1.2.2.1章节。 接下来是雪傀儡实现远程攻击的核心部分——performRangedAttack,以下的内容至关重要,几乎所有远程攻击的底层实现都是下面代码的变体。 public void performRangedAttack(LivingEntity target, float power) { Snowball snowball = new Snowball(level(), this); double targetY = target.getEyeY() - (double) 1.1F; double dx = target.getX() - this.getX(); double dy = targetY - snowball.getY(); double dz = target.getZ() - this.getZ(); // 编写发射火球一类弹射物的代码时,这下面的部分会稍有差别,我们以后再讲 double yModifier = Math.sqrt(dx * dx + dz * dz) * (double) 0.2F; snowball.shoot(dx, yModifier + dy, dz, 1.6F, 12.0F); playSound(SoundEvents.SNOW_GOLEM_SHOOT, 1, 0.4F / (getRandom().nextFloat() * 0.4F + 0.8F)); level().addFreshEntity(snowball); } 这段代码首先实例化了一个Snowball,代表了将要投掷出的雪球实体。 下面一段十分关键: double dx = target.getX() - this.getX(); double dy = targetY - snowball.getY(); double dz = target.getZ() - this.getZ(); 设玩家坐标为(x1,y1,z1)(x_1, y_1, z_1)(x1,y1,z1),雪傀儡坐标为(x2,y2,z2)(x_2, y_2, z_2)(x2,y2,z2),以下部分计算出了向量(x2−x1,y2−y1,z2−z1)(x_2-x_1, y_2-y_1, z_2-z_1)(x2−x1,y2−y1,z2−z1)的x、y及z坐标,这个向量与弹射物的飞行轨迹关系密切。对于雪球等继承了ThrowableProjectile的弹射物以及各种箭而言,这个向量决定了弹射物被射出时的方向与高度;对于各种火球(包括末影龙火球)、凋灵之首等继承了AbstractHurtingProjectile的弹射物而言,这个向量则直接决定了弹射物的轨迹所在的直线。此处我们先分析前一种情况,后一种以后再做分析。 计算好了发射的方向,接下来就应该把相应的属性应用给弹射物。Projectile类里的shoot方法已经帮助我们做好了这些准备。 double yModifier = Math.sqrt(dx * dx + dz * dz) * (double) 0.2F; snowball.shoot(dx, yModifier + dy, dz, 1.6F, 12.0F); 这里我们计算了到目标的距离,为什么要计算距离呢?根据物理知识,物体做斜抛运动时,若初速度方向与地面的夹角小于45°,则开始时抛得越高,最终扔得越远,在MC中也可以认为符合这个规律。因此当目标离雪傀儡较远时,需要把雪球扔高一些,从而能击中较远的目标。同时我们将到目标的距离乘上了0.2对dy(即上文所说的y2−y1y_2-y_1y2−y1)进行修正,来防止扔得过高使射程反而变短。 但是shoot方法还有2个参数,那么后两个float类型的参数是干什么的呢? Projectile: public void shoot(double x, double y, double z, float scale, float deviation) { Vec3 shootVector = new Vec3(x, y, z).normalize() .add(random.triangle(0, 0.0172275 * (double) deviation), random.triangle(0, 0.0172275 * (double) deviation), random.triangle(0, 0.0172275 * (double) deviation)) .scale(scale); setDeltaMovement(shootVector); double horizontalDistance = shootVector.horizontalDistance(); setYRot((float) (Mth.atan2(shootVector.x, shootVector.z) * (double) (180F / (float) Math.PI))); setXRot((float) (Mth.atan2(shootVector.y, horizontalDistance) * (double) (180F / (float) Math.PI))); yRotO = getYRot(); xRotO = getXRot(); } RandomSource(用法与Random类相近): default double triangle(double baseValue, double scale) { return baseValue + scale * (nextDouble() - nextDouble()); } 查阅源码可以发现,倒数第二个参数scale决定了扔出弹射物时的“力量”,值越大,则shootVector的模越大,扔得就越高、越远;最后一个参数deviation决定了射击的误差,值越大,设计的误差越大,射击精准度越低。 下表给出了其他一些常见生物攻击时使用的scale和deviation(其中k为难度ID,和平、简单、普通、困难难度的k值分别为0,1,2,3): 生物 scale deviation 骷髅(射箭) 1.6 14 - 4k 掠夺者(射箭) 1.6 14 - 4k 溺尸(三叉戟) 1.6 14 - 4k 女巫(投掷药水) 0.75 8 羊驼(吐口水) 1.5 10 最后两行是播放声音和添加实体。 playSound(SoundEvents.SNOW_GOLEM_SHOOT, 1, 0.4F / (getRandom().nextFloat() * 0.4F + 0.8F)); level().addFreshEntity(snowball); 调用playSound方法时需要提供声音种类(SoundEvent)、音量和音调。一般情况下都要对音调进行一定的随机化处理,使声音更加自然。 而对于addFreshEntity方法的使用,有以下的注意事项:一个实体只能被add一次,如果想要生成多个实体,则每次生成都要重新实例化实体。这看似显然,但若不清楚其中的原因则极易犯错。 举生成3只僵尸为例,代码应该这样写(此处省略对僵尸的一些必要操作,如更改僵尸坐标): for (int i = 0; i 而不是这样写: Zombie zombie = EntityType.ZOMBIE.create(level); for (int i = 0; i 如果采用下面这种错误的写法,日志中就会输出“UUID of added entity already exists”且实际只会生成1只僵尸,因为每次实例化实体的同时,也给予了实体一个唯一的UUID。这种错误的写法会使后生成的僵尸的UUID与第一只僵尸的UUID重复,违背了实体UUID的唯一性。 然后是处理用剪刀与雪傀儡交互部分的代码。 // 这个方法的代码原本不是这样,但被forge处理过后等价于下面四行 @Override protected InteractionResult mobInteract(Player player, InteractionHand hand) { return InteractionResult.PASS; } @Override public void shear(SoundSource source) { level().playSound(null, this, SoundEvents.SNOW_GOLEM_SHEAR, source, 1, 1); if (!level().isClientSide()) { setPumpkin(false); spawnAtLocation(new ItemStack(Items.CARVED_PUMPKIN), 1.7F); } } @Override public boolean readyForShearing() { return isAlive() && hasPumpkin(); } @Override public boolean isShearable(@NotNull ItemStack item, Level world, BlockPos pos) { return readyForShearing(); } @NotNull @Override public List onSheared(@Nullable Player player, @NotNull ItemStack item, Level world, BlockPos pos, int fortune) { world.playSound(null, this, SoundEvents.SNOW_GOLEM_SHEAR, player == null ? SoundSource.BLOCKS : SoundSource.PLAYERS, 1, 1); gameEvent(GameEvent.SHEAR, player); if (!world.isClientSide()) { setPumpkin(false); return Collections.singletonList(new ItemStack(Items.CARVED_PUMPKIN)); } return Collections.emptyList(); } 这部分的难度不大,如果读者需要编写玩家手持剪刀时可以交互的实体时,可以实现Shearable接口并参考这部分代码,而不是重写mobInteract后按自己的想法实现。 最后还有一个getLeashOffset方法。 @Override public Vec3 getLeashOffset() { return new Vec3(0, (double) (0.75F * getEyeHeight()), (double) (getBbWidth() * 0.4F)); } Entity类中的定义是这样的: protected Vec3 getLeashOffset() { return new Vec3(0, (double) getEyeHeight(), (double) (getBbWidth() * 0.4F)); } 该方法返回的向量的x、y、z分别决定了渲染拴绳时拴住实体的位置在左右、上下、前后方向的偏移。拴住雪傀儡时,我们希望拴住的位置不要太高,于是重写了该方法并将返回值的y坐标乘上了0.75。 到这里SnowGolem类的内容就分析完了,下一节将讲解雪傀儡的模型和渲染。 "},"part1-monster/ranged/snow_golem/snow_golem2.html":{"url":"part1-monster/ranged/snow_golem/snow_golem2.html","title":"雪傀儡的模型与渲染","keywords":"","body":"雪傀儡的模型与渲染 与前面说过的僵尸与末影人都不同,雪傀儡的模型类SnowGolemModel继承了HierarchicalModel而非HumanoidModel。HierarchicalModel类并未提供相应的ModelPart,因此需要单独声明这些成员变量。在创建非人形实体(生物和具有较复杂模型的弹射物)的模型时,一般都需要继承这个类来实现模型的自定义。 @OnlyIn(Dist.CLIENT) public class SnowGolemModel extends HierarchicalModel { private final ModelPart root; private final ModelPart upperBody; private final ModelPart head; private final ModelPart leftArm; private final ModelPart rightArm; public SnowGolemModel(ModelPart part) { this.root = part; this.head = part.getChild(\"head\"); this.leftArm = part.getChild(\"left_arm\"); this.rightArm = part.getChild(\"right_arm\"); this.upperBody = part.getChild(\"upper_body\"); } // 这个静态方法用于声明雪傀儡的模型,前面讲末影人的模型时讲过,此处也大同小异,因此不做赘述 public static LayerDefinition createBodyLayer() { MeshDefinition meshDef = new MeshDefinition(); PartDefinition partDef = meshDef.getRoot(); CubeDeformation cubeDef = new CubeDeformation(-0.5F); partDef.addOrReplaceChild(\"head\", CubeListBuilder.create().texOffs(0, 0).addBox(-4.0F, -8.0F, -4.0F, 8.0F, 8.0F, 8.0F, cubeDef), PartPose.offset(0.0F, 4.0F, 0.0F)); CubeListBuilder builder = CubeListBuilder.create().texOffs(32, 0).addBox(-1.0F, 0.0F, -1.0F, 12.0F, 2.0F, 2.0F, cubeDef); partDef.addOrReplaceChild(\"left_arm\", builder, PartPose.offsetAndRotation(5.0F, 6.0F, 1.0F, 0.0F, 0.0F, 1.0F)); partDef.addOrReplaceChild(\"right_arm\", builder, PartPose.offsetAndRotation(-5.0F, 6.0F, -1.0F, 0.0F, (float)Math.PI, -1.0F)); partDef.addOrReplaceChild(\"upper_body\", CubeListBuilder.create().texOffs(0, 16).addBox(-5.0F, -10.0F, -5.0F, 10.0F, 10.0F, 10.0F, cubeDef), PartPose.offset(0.0F, 13.0F, 0.0F)); partDef.addOrReplaceChild(\"lower_body\", CubeListBuilder.create().texOffs(0, 36).addBox(-6.0F, -12.0F, -6.0F, 12.0F, 12.0F, 12.0F, cubeDef), PartPose.offset(0.0F, 24.0F, 0.0F)); return LayerDefinition.create(meshDef, 64, 64); } } HierarchicalModel没有实现抽象方法setupAnim,要在子类中去实现该抽象方法。 @Override public void setupAnim(T entity, float limbSwing, float limbSwingAmount, float ageInTicks, float netHeadYaw, float headPitch) { head.yRot = netHeadYaw * ((float) Math.PI / 180F); head.xRot = headPitch * ((float) Math.PI / 180F); upperBody.yRot = netHeadYaw * ((float) Math.PI / 180F) * 0.25F; float sinRot = Mth.sin(upperBody.yRot); float cosRot = Mth.cos(upperBody.yRot); leftArm.yRot = upperBody.yRot; rightArm.yRot = upperBody.yRot + (float) Math.PI; leftArm.x = cosRot * 5.0F; leftArm.z = -sinRot * 5.0F; rightArm.x = -cosRot * 5.0F; rightArm.z = sinRot * 5.0F; } 此处setupAnim同步了头部、身体与手部的转动,注意在ModelPart中带rot的float类型变量(xRot,yRot,zRot)存储的都是弧度,而在Entity中带rot的float类型变量(yRot,xRot等)存储的却是角度,所以开发中涉及到旋转时要特别注意角度与弧度的转换,防止两者混淆导致实体的旋转变得怪异。 最后还有两个getter要实现。 @Override public ModelPart root() { return this.root; } @Override public ModelPart getHead() { return this.head; } 接下来是渲染类。 @OnlyIn(Dist.CLIENT) public class SnowGolemRenderer extends MobRenderer> { private static final ResourceLocation SNOW_GOLEM_LOCATION = new ResourceLocation(\"textures/entity/snow_golem.png\"); public SnowGolemRenderer(EntityRendererProvider.Context context) { super(context, new SnowGolemModel<>(context.bakeLayer(ModelLayers.SNOW_GOLEM)), 0.5F); addLayer(new SnowGolemHeadLayer(this, context.getBlockRenderDispatcher(), context.getItemRenderer())); } @Override public ResourceLocation getTextureLocation(SnowGolem golem) { return SNOW_GOLEM_LOCATION; } } 渲染类很短,似乎没什么可说的……等等,第7行是干什么的呢?这得从SnowGolemHeadLayer说起。 @OnlyIn(Dist.CLIENT) public class SnowGolemHeadLayer extends RenderLayer> { private final BlockRenderDispatcher blockRenderer; private final ItemRenderer itemRenderer; public SnowGolemHeadLayer(RenderLayerParent> parent, BlockRenderDispatcher blockRenderer, ItemRenderer itemRenderer) { super(parent); this.blockRenderer = blockRenderer; this.itemRenderer = itemRenderer; } @Override public void render(PoseStack poseStack, MultiBufferSource source, int packedLight, SnowGolem golem, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { // ...... } } 我们来看render方法。 @Override public void render(PoseStack poseStack, MultiBufferSource source, int packedLight, SnowGolem golem, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { if (golem.hasPumpkin()) { boolean invisibleButGlowing = Minecraft.getInstance().shouldEntityAppearGlowing(golem) && golem.isInvisible(); if (!golem.isInvisible() || invisibleButGlowing) { poseStack.pushPose(); getParentModel().getHead().translateAndRotate(poseStack); // 初步移动到指定的位置 poseStack.translate(0.0F, -0.34375F, 0.0F); // 旋转到适合的角度 poseStack.mulPose(Axis.YP.rotationDegrees(180.0F)); // 缩放到适当的大小 poseStack.scale(0.625F, -0.625F, -0.625F); ItemStack itemStack = new ItemStack(Blocks.CARVED_PUMPKIN); if (invisibleButGlowing) { BlockState pumpkinHead = Blocks.CARVED_PUMPKIN.defaultBlockState(); BakedModel pumpkinHeadModel = blockRenderer.getBlockModel(pumpkinHead); int overlayCoords = LivingEntityRenderer.getOverlayCoords(golem, 0.0F); poseStack.translate(-0.5F, -0.5F, -0.5F); blockRenderer.getModelRenderer().renderModel(poseStack.last(), source.getBuffer(RenderType.outline(TextureAtlas.LOCATION_BLOCKS)), pumpkinHead, pumpkinHeadModel, 0.0F, 0.0F, 0.0F, packedLight, overlayCoords); } else { itemRenderer.renderStatic(golem, itemStack, ItemDisplayContext.HEAD, false, poseStack, source, golem.level(), packedLight, LivingEntityRenderer.getOverlayCoords(golem, 0.0F), golem.getId()); } poseStack.popPose(); } } } 这里我们在雪傀儡可见或发光时渲染了它的南瓜头(不可见但发光时提供了南瓜头的轮廓),同时通过传入overlayCoords使南瓜头能在雪傀儡受伤时与雪傀儡一起“变红”。 本节内容到这里就结束啦,接下来是实战吗?是的!那么之前说过还有一类远程攻击方式,怎么写这类生物的代码呢?别急,使用这类方式的生物多半没继承RangedAttackMob且更为复杂,属于1.2.3章节讨论的范畴,我们等到1.2.3再讲~ "},"part1-monster/ranged/actual_combat3.html":{"url":"part1-monster/ranged/actual_combat3.html","title":"实战3 - 发射器僵尸","keywords":"","body":"实战3 - 发射器僵尸 让我们仿照pvz里的豌豆僵尸制作一种新的僵尸吧~ 任务 制作一种新的僵尸——发射器僵尸 要求 发射器僵尸除Attributes.SPAWN_REINFORCEMENTS_CHANCE以外的所有属性同普通的僵尸,而且也会在阳光下燃烧 发射器僵尸除不会以任何形式攻击海龟与海龟蛋外,行为与普通的僵尸一致 发射器僵尸对玩家和铁傀儡会射箭进行攻击,对敌对生物则只会对其发射雪球以进行警告 发射器僵尸攻击时移速降低15%,每2s攻击1次,攻击半径为10格 当发射器僵尸感知到自身所在的方块坐标四周有红石信号时,在四周有概率生成红石粒子效果,且每秒恢复1点生命值, 发射器僵尸在水中不会转化为溺尸,若满足掉落头颅的条件,击杀时会掉落发射器 发射器僵尸不可能尝试生成增援 发射器僵尸没有幼年状态 发射器僵尸需要使用僵尸的材质和模型 发射器僵尸发射任何弹射物时都要使用发射器发射成功时的音效,其他音效可以任意设置 与未经剪刀处理的雪傀儡相似,发射器僵尸会在头部渲染发射器的模型 提示 注:提示部分中会对难度较大/没讲过的内容进行提示或对“要求”部分进行补充说明 发射最普通的箭的方式非常简单,与发射雪球类似,只要把实例化的雪球替换成箭即可。就像这样: Arrow arrow = new Arrow(level(), this); arrow.shoot(...); level().addFreshEntity(arrow); 制作发射器僵尸时不需要对箭做过多处理,但是当箭由弓发射时,需要根据所持有箭的种类与弓的种类来综合决定发射出的箭的类型,这一块在后续的章节中会进行讲解 如何获取红石信号的大小呢?去SignalGetter类里寻找答案吧! 红石粒子效果的ParticleOptions(旧称IParticleData)是DustParticleOptions.REDSTONE 参考步骤 新建DispenserZombie类继承Zombie类,注意别忘了实现RangedAttackMob接口。 public class DispenserZombie extends Zombie implements RangedAttackMob { public DispenserZombie(EntityType type, Level level) { super(type, level); } } 由要求1、7可知,发射器僵尸的Attributes.SPAWN_REINFORCEMENTS_CHANCE值应为0,不要忘了还要重写randomizeReinforcementsChance方法。 public static AttributeSupplier.Builder createAttributes() { return Zombie.createAttributes() .add(Attributes.SPAWN_REINFORCEMENTS_CHANCE, 0); } @Override protected void randomizeReinforcementsChance() { Objects.requireNonNull(getAttribute(Attributes.SPAWN_REINFORCEMENTS_CHANCE)).setBaseValue(0); } 下面是AI。因为发射器僵尸不会攻击海龟与海龟蛋,所以要直接重写registerGoals方法。并且根据要求4,将相应的参数传给RangedAttackGoal的构造方法。 @Override protected void registerGoals() { goalSelector.addGoal(8, new LookAtPlayerGoal(this, Player.class, 8)); goalSelector.addGoal(8, new RandomLookAroundGoal(this)); addBehaviourGoals(); } @Override protected void addBehaviourGoals() { goalSelector.addGoal(2, new RangedAttackGoal(this, 0.85, 40, 10)); goalSelector.addGoal(6, new MoveThroughVillageGoal(this, 1, true, 4, this::canBreakDoors)); goalSelector.addGoal(7, new WaterAvoidingRandomStrollGoal(this, 1)); targetSelector.addGoal(1, (new HurtByTargetGoal(this)).setAlertOthers(ZombifiedPiglin.class)); targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, AbstractVillager.class, false)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, IronGolem.class, true)); } 接着是重要的performRangedAttack,其中findProjectileFor方法用于根据目标实体选择不同的弹射物。 @Override public void performRangedAttack(LivingEntity target, float power) { Projectile projectile = findProjectileFor(target); double targetY = target.getEyeY() - (double) 1.1F; double dx = target.getX() - this.getX(); double dy = targetY - projectile.getY(); double dz = target.getZ() - this.getZ(); double yModifier = Math.sqrt(dx * dx + dz * dz) * (double) 0.2F; projectile.shoot(dx, yModifier + dy, dz, 1.6F, 12.0F); // 此处修改了音效来达到要求10 playSound(SoundEvents.DISPENSER_DISPENSE, 1, 0.4F / (getRandom().nextFloat() * 0.4F + 0.8F)); level().addFreshEntity(projectile); } private Projectile findProjectileFor(LivingEntity target) { if (target instanceof Enemy) { return new Snowball(level(), this); } return new Arrow(level(), this); } 接下来达到要求5。此处我们在发射器僵尸被tick时,调用hasNeighborSignal方法进行判断。 @Override public void tick() { super.tick(); if (canHealSelf()) { if (level().isClientSide()) { maybeAddParticles(); } else { healSelf(); } } } private boolean canHealSelf() { BlockPos pos = blockPosition(); return level().hasNeighborSignal(pos) || level().hasSignal(pos.below(), Direction.DOWN); } // 每tick都有10%的概率生成粒子效果 private void maybeAddParticles() { if (random.nextInt(10) == 0) { level().addParticle(DustParticleOptions.REDSTONE, getRandomX(1), getRandomY() + 0.5, getRandomZ(1), 0, 0, 0); } } // 当tickCount是20的倍数时进行治疗,这样可以保证tps为20时每秒治疗1点生命值 private void healSelf() { if (tickCount % 20 == 0) { heal(1); } } 要达到要求6、8很简单,只需重写下面的方法即可。 @Override protected ItemStack getSkull() { return new ItemStack(Items.DISPENSER); } @Override protected boolean convertsInWater() { return false; } @Override public boolean isBaby() { return false; } @Override public void setBaby(boolean baby) {} 除发射弹射物以外的音效这里均使用僵尸的音效,又因为DispenserZombie类继承了Zombie类,所以不需要重写那一部分决定音效的方法了。 渲染类也很简单,但是要新的Layer以渲染发射器的模型。 public class DispenserZombieRenderer extends AbstractZombieRenderer> { public DispenserZombieRenderer(EntityRendererProvider.Context context) { super(context, new ZombieModel<>(context.bakeLayer(ModModelLayers.DISPENSER_ZOMBIE)), new ZombieModel<>(context.bakeLayer(ModModelLayers.DISPENSER_ZOMBIE_INNER_ARMOR)), new ZombieModel<>(context.bakeLayer(ModModelLayers.DISPENSER_ZOMBIE_OUTER_ARMOR))); addLayer(new DispenserZombieHeadLayer(this, context.getBlockRenderDispatcher(), context.getItemRenderer())); } } DispenserZombieHeadLayer只需要仿照SnowGolemHeadLayer来写就可以了。 public class DispenserZombieHeadLayer extends RenderLayer> { private final BlockRenderDispatcher blockRenderer; private final ItemRenderer itemRenderer; public DispenserZombieHeadLayer(RenderLayerParent> parent, BlockRenderDispatcher blockRenderer, ItemRenderer itemRenderer) { super(parent); this.blockRenderer = blockRenderer; this.itemRenderer = itemRenderer; } /** * [VanillaCopy] * * {@link net.minecraft.client.renderer.entity.layers.SnowGolemHeadLayer#render(PoseStack, MultiBufferSource, int, SnowGolem, float, float, float, float, float, float)} */ @SuppressWarnings(\"deprecation\") @Override public void render(PoseStack poseStack, MultiBufferSource source, int packedLight, DispenserZombie zombie, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { boolean invisibleButGlowing = Minecraft.getInstance().shouldEntityAppearGlowing(zombie) && zombie.isInvisible(); if (!zombie.isInvisible() || invisibleButGlowing) { poseStack.pushPose(); getParentModel().getHead().translateAndRotate(poseStack); poseStack.translate(0.0F, -0.34375F, 0.0F); poseStack.mulPose(Axis.YP.rotationDegrees(180.0F)); poseStack.scale(0.625F, -0.625F, -0.625F); Block dispenserHeadBlock = Blocks.DISPENSER; ItemStack itemStack = new ItemStack(dispenserHeadBlock); if (invisibleButGlowing) { BlockState dispenserHead = dispenserHeadBlock.defaultBlockState(); BakedModel dispenserHeadModel = blockRenderer.getBlockModel(dispenserHead); int overlayCoords = LivingEntityRenderer.getOverlayCoords(zombie, 0.0F); poseStack.translate(-0.5F, -0.5F, -0.5F); blockRenderer.getModelRenderer().renderModel(poseStack.last(), source.getBuffer(RenderType.outline(TextureAtlas.LOCATION_BLOCKS)), dispenserHead, dispenserHeadModel, 0.0F, 0.0F, 0.0F, packedLight, overlayCoords); } else { itemRenderer.renderStatic(zombie, itemStack, ItemDisplayContext.HEAD, false, poseStack, source, zombie.level(), packedLight, LivingEntityRenderer.getOverlayCoords(zombie, 0.0F), zombie.getId()); } poseStack.popPose(); } } } 最后不要忘记实体的注册。 到此我们就做完了发射器僵尸的全部内容。 源代码(DispenserZombie类)源代码(DispenserZombieRenderer类)源代码(DispenserZombieHeadLayer类) 效果图 发射器僵尸攻击铁傀儡,同时在有红石信号处恢复自身生命值 发射器僵尸用雪球警告攻击自己的骷髅 思考与练习 测试实体时不难发现,发射器僵尸使用雪球进行攻击时,雪球有时会发射得偏高。是否可以通过调整相关的参数修复这一问题? 能否让发射器僵尸也拥有对应的幼年形态,且小型发射器僵尸的发射器头颅也会缩小? 能否让发射器僵尸发射雪球的速度变为发射箭的速度的2倍?(提示:需要写一个新的类似RangedAttackGoal的AI来实现) 能否让发射器僵尸在附近5x3x5区域内检测到红石块或点燃的红石火把时也能治疗自身? "},"part1-monster/ranged/witch/":{"url":"part1-monster/ranged/witch/","title":"令玩家头疼的女巫","keywords":"","body":"令玩家头疼的女巫 女巫(Witch)是一种投掷喷溅药水进行攻击、饮用药水增强自身的敌对生物。 ——Minecraft Wiki "},"part1-monster/ranged/witch/witch.html":{"url":"part1-monster/ranged/witch/witch.html","title":"女巫的实现逻辑","keywords":"","body":"女巫的实现逻辑 注: 以后的实体分析中,如果实体较复杂,将只会分析其中重要的内容,一些共通的内容例如实体属性、音效的添加/实体类型的注册由于曾经讲过,将不再赘述。 在1.2.2的上半部分中,我们学习了实现简单的远程攻击的怪物的方式,但是写出来的怪物好像过于单调,缺乏吸引力与对付难度。作为模组开发者,我们自然要为难一下玩家,以提高模组的挑战性。因此我们需要更进一步地,学习拥有多种能力的怪物的实现方式,而玩家们痛恨的女巫便给了我们一个极好的参考。所以本部分我们将分析女巫的实现,来帮助我们更好地理解这种攻击方式复杂多变,令玩家头疼不已的敌对生物。 在游戏过程中不难发现女巫有如下的3个能力(此处的“能力”与Forge提供的Capability系统无关): 向玩家投掷带有负面效果的喷溅药水 (在袭击中)向其他袭击者投掷带有正面效果的喷溅药水 喝带有正面效果的药水以治疗自身 这些能力分别是怎样实现的呢?我们来对女巫的能力归类,可以发现能力1、2十分相似,都是“投掷药水”的能力。因此自然想到通过设置女巫的target,并让女巫“攻击”玩家和袭击者时使用不同的药水,来使女巫具备这两个能力。 但是如何才能让女巫能有条不紊地在治疗袭击者的同时攻击玩家,避免女巫光顾着治疗不攻击呢? 这个问题值得思考,我们可以想到通过精准地调控2个不同的TargetGoal来实现,查阅女巫的源代码,可以找到这样的两个AI。 private NearestHealableRaiderTargetGoal healRaidersGoal; // 治疗袭击者的AI private NearestAttackableWitchTargetGoal attackPlayersGoal; // 攻击玩家的AI 这两个AI是如何发挥作用的呢?先看registerGoals()方法。 @Override protected void registerGoals() { super.registerGoals(); healRaidersGoal = new NearestHealableRaiderTargetGoal<>(this, Raider.class, true, (healTarget) -> { return healTarget != null && hasActiveRaid() && healTarget.getType() != EntityType.WITCH; }); attackPlayersGoal = new NearestAttackableWitchTargetGoal<>(this, Player.class, 10, true, false, null); // “10”表示每刻有1/10的概率寻找目标 goalSelector.addGoal(1, new FloatGoal(this)); goalSelector.addGoal(2, new RangedAttackGoal(this, 1.0D, 60, 10.0F)); goalSelector.addGoal(2, new WaterAvoidingRandomStrollGoal(this, 1.0D)); goalSelector.addGoal(3, new LookAtPlayerGoal(this, Player.class, 8.0F)); goalSelector.addGoal(3, new RandomLookAroundGoal(this)); targetSelector.addGoal(1, new HurtByTargetGoal(this, Raider.class)); targetSelector.addGoal(2, healRaidersGoal); targetSelector.addGoal(3, attackPlayersGoal); } 这里除了一些通用的AI外,还分别对healRaidersGoal和attackPlayersGoal进行了赋值,并把它们添加进了targetSelector中。我们也可以发现,healRaidersGoal与attackPlayersGoal的优先级不同,这使得女巫会优先治疗袭击者。 那么为什么女巫不会光顾着治疗不攻击呢?在这里似乎不能找到答案,但是我们可以关注NearestHealableRaiderTargetGoal与NearestAttackableWitchTargetGoal,看看这两个类有没有暗藏玄机。 NearestHealableRaiderTargetGoal: public class NearestHealableRaiderTargetGoal extends NearestAttackableTargetGoal { private static final int DEFAULT_COOLDOWN = 200; private int cooldown = 0; public NearestHealableRaiderTargetGoal(Raider raider, Class targetType, boolean mustSee, @Nullable Predicate targetSelector) { super(raider, targetType, 500, mustSee, false, targetSelector); // “500”表示每刻有1/500的概率寻找目标 } public int getCooldown() { return cooldown; } public void decrementCooldown() { --cooldown; } @Override public boolean canUse() { if (cooldown 0时即AI在冷却时返回false return false; } } @Override public void start() { cooldown = reducedTickDelay(200); super.start(); } } NearestAttackableWitchTargetGoal: public class NearestAttackableWitchTargetGoal extends NearestAttackableTargetGoal { private boolean canAttack = true; public NearestAttackableWitchTargetGoal(Raider raider, Class targetType, int randomInterval, boolean mustSee, boolean mustReach, @Nullable Predicate targetSelector) { super(raider, targetType, randomInterval, mustSee, mustReach, targetSelector); } public void setCanAttack(boolean canAttack) { this.canAttack = canAttack; } @Override public boolean canUse() { return canAttack && super.canUse(); } } 具体更新这两个AI的部分则在aiStep方法里。 @Override public void aiStep() { if (!level().isClientSide && isAlive()) { // 一定不能忘了只能在女巫存活的条件下在服务端执行这些逻辑 healRaidersGoal.decrementCooldown(); if (healRaidersGoal.getCooldown() 这下便可以发现女巫的两个AI中一个有冷却,另一个需要手动激活。此外,在performRangedAttack方法中,一旦女巫选择了治疗袭击者,就会在执行完选择药水的逻辑后马上重置攻击目标(具体代码下文会提到),这就可以解释女巫为什么不会一直治疗袭击者。同时能发现女巫在治疗完袭击者的短暂时间内,不能再选取玩家作为攻击目标。 选择好了要攻击的目标,接下来到了攻击目标的环节。这个performRangedAttack方法内容有些多,我们把它分解一下。 首先要确保在不在喝药水的时候进行攻击。 @Override public void performRangedAttack(LivingEntity target, float power) { if (!isDrinkingPotion()) { Vec3 movement = target.getDeltaMovement(); double x = target.getX() + movement.x - this.getX(); double y = target.getEyeY() - (double)1.1F - this.getY(); double z = target.getZ() + movement.z - this.getZ(); double distance = Math.sqrt(x * x + z * z); Potion potion = Potions.HARMING; if (target instanceof Raider) { if (target.getHealth() = 8.0D && !target.hasEffect(MobEffects.MOVEMENT_SLOWDOWN)) { potion = Potions.SLOWNESS; } else if (target.getHealth() >= 8.0F && !target.hasEffect(MobEffects.POISON)) { potion = Potions.POISON; } else if (distance 然后说这个if里面的内容,可以被拆成“计算坐标”,“选择药水”,“投掷药水”三个部分。先看“计算坐标”的部分。 Vec3 movement = target.getDeltaMovement(); double x = target.getX() + movement.x - getX(); double y = target.getEyeY() - (double) 1.1F - getY(); double z = target.getZ() + movement.z - getZ(); 值得注意的是,这里获取了攻击目标的deltaMovement,并根据这个deltaMovement改变了攻击方向,避免因喷溅药水飞行需要时间而打不中运动的目标。 再来看“选择药水”的部分。 double distance = Math.sqrt(x * x + z * z); // 默认使用瞬间伤害药水 Potion potion = Potions.HARMING; // 需要注意以下使用过长的if-else语句不是一种好的编程习惯,会导致代码可读性差并且难以维护。下面将解释这一大堆if-else的含义~ if (target instanceof Raider) { // (1)如果攻击目标是袭击者,则一定选择治疗类型的药水,并在治疗完目标后重置攻击目标,避免反复治疗 if (target.getHealth() = 8.0D && !target.hasEffect(MobEffects.MOVEMENT_SLOWDOWN)) { // (2)如果不满足(1)所述的条件,并且与攻击目标的距离大于8,同时目标没有缓慢效果,则一定选择迟缓药水 potion = Potions.SLOWNESS; } else if (target.getHealth() >= 8.0F && !target.hasEffect(MobEffects.POISON)) { // (3)如果不满足(2)所述的条件,并且攻击目标的生命值大于8,同时目标没有中毒效果,则一定选择剧毒药水 potion = Potions.POISON; } else if (distance 这里使用了复杂的条件判断语句来决定应该投掷出的药水种类,当然用过长的if-else语句未必是最合适的方式。 最后看“投掷药水”的部分。 ThrownPotion thrownPotion = new ThrownPotion(level(), this); thrownPotion.setItem(PotionUtils.setPotion(new ItemStack(Items.SPLASH_POTION), potion)); thrownPotion.setXRot(thrownPotion.getXRot() - -20.0F); thrownPotion.shoot(x, y + distance * 0.2D, z, 0.75F, 8.0F); if (!isSilent()) { level().playSound(null, getX(), getY(), getZ(), SoundEvents.WITCH_THROW, getSoundSource(), 1.0F, 0.8F + random.nextFloat() * 0.4F); } level().addFreshEntity(thrownPotion); 与投掷雪球不同的是,投掷药水时额外指定了喷溅药水的xRot,以确保投掷出的药水方向朝前。还要记得将Potion(决定了药水的成分)绑定到投掷物上。 说完了能力1、2,能力3又是怎样实现的呢?可以从刚才省略的代码中找到答案。 // 对应着前面提到的aiStep方法中省略的部分 if (isDrinkingPotion()) { // 这里成员变量usingTime代表着喝药水的剩余时间 if (usingTime-- effects = PotionUtils.getMobEffects(mainHandItem); if (effects != null) { // 遍历并应用药水中效果 for (MobEffectInstance effect : effects) { addEffect(new MobEffectInstance(effect)); } } } // 因为喝完药水了,所以要移除之前添加的降低移速的Modifier getAttribute(Attributes.MOVEMENT_SPEED).removeModifier(SPEED_MODIFIER_DRINKING); } } else { Potion potion = null; if (random.nextFloat() 121.0D) { // (4)如果不满足(3)所述的条件,并且与攻击目标的距离大于11,同时自身没有速度效果,则每刻有50%的可能选择迅捷药水 potion = Potions.SWIFTNESS; } if (potion != null) { // 如果选择了药水,则准备喝该药水 setItemSlot(EquipmentSlot.MAINHAND, PotionUtils.setPotion(new ItemStack(Items.POTION), potion)); usingTime = getMainHandItem().getUseDuration(); setUsingItem(true); if (!isSilent()) { level().playSound(null, getX(), getY(), getZ(), SoundEvents.WITCH_DRINK, getSoundSource(), 1.0F, 0.8F + random.nextFloat() * 0.4F); } // 添加喝药水时降低移速的Modifier AttributeInstance speedAttribute = getAttribute(Attributes.MOVEMENT_SPEED); speedAttribute.removeModifier(SPEED_MODIFIER_DRINKING); speedAttribute.addTransientModifier(SPEED_MODIFIER_DRINKING); } } if (random.nextFloat() 这里又一次使用了复杂的条件判断语句,来决定应该喝下的药水种类。 另外,此处通过broadcastEntityEvent与handleEntityEvent来实现服务端与客户端的数据同步,原版里这样的同步方式很常见,但是在写模组时需要避免用这种方式同步数据。首先我们有强大的SimpleChannel,其次这样做容易出现事件id冲突。 女巫重写了getDamageAfterMagicAbsorb方法,来实现避免受到来自自身的伤害及对有DamageTypeTags.WITCH_RESISTANT_TO标签的(主要是魔法类)伤害减免85%。 @Override protected float getDamageAfterMagicAbsorb(DamageSource source, float amount) { amount = super.getDamageAfterMagicAbsorb(source, amount); if (source.getEntity() == this) { amount = 0.0F; } if (source.is(DamageTypeTags.WITCH_RESISTANT_TO)) { amount *= 0.15F; } return amount; } 最后是与袭击相关的内容。 // 该方法中的布尔值在该方法被调用时总是传入false,原版代码中也从未用到过这个值,因此暂不明确其作用 @Override public void applyRaidBuffs(int nextWave, boolean alwaysFalse) {} @Override public boolean canBeLeader() { return false; } @Override public SoundEvent getCelebrateSound() { return SoundEvents.WITCH_CELEBRATE; } 女巫不能成为袭击的领导者,在参与袭击时不会获得任何buff,且在袭击失败(此处说的失败是指“玩家失败”)时会播放特有的庆祝声。 还有个细节:女巫的眼睛的高度被单独指定为1.62。 @Override protected float getStandingEyeHeight(Pose pose, EntityDimensions dimensions) { return 1.62F; } 女巫的逻辑就分析到这里了哦。女巫的模型有个注意点,我们下次再说。 "},"part1-monster/ranged/witch/witch2.html":{"url":"part1-monster/ranged/witch/witch2.html","title":"女巫的渲染细节","keywords":"","body":"女巫的渲染细节 我们可以通过观察女巫发现如下两个问题: 女巫的鼻子会抖动,这具体是如何实现的呢? 当女巫手持药水瓶准备喝药水时,可以发现女巫手中的药水瓶进行了一定的旋转与偏移,这又是怎么实现的呢? 这部分我们就来探讨一下这两个问题。 先看WitchModel,注册的部分就省略不看了。 @Override public void setupAnim(T witch, float limbSwing, float limbSwingAmount, float ageInTicks, float netHeadYaw, float headPitch) { super.setupAnim(witch, limbSwing, limbSwingAmount, ageInTicks, netHeadYaw, headPitch); nose.setPos(0.0F, -2.0F, 0.0F); // 这个值决定了女巫鼻子晃动的频率。它的大小实际上与女巫的id有关,但因为实体的id不容易确定,所以可以理解为随机的 float shakeFrequency = 0.01F * (float) (witch.getId() % 10); nose.xRot = Mth.sin((float) witch.tickCount * shakeFrequency) * 4.5F * ((float) Math.PI / 180F); nose.yRot = 0.0F; nose.zRot = Mth.cos((float) witch.tickCount * shakeFrequency) * 2.5F * ((float) Math.PI / 180F); // 当女巫手持物品时,鼻子应当改变位置 if (holdingItem) { nose.setPos(0.0F, 1.0F, -1.5F); nose.xRot = -0.9F; } } // 这两个方法在女巫的渲染中会用到 public ModelPart getNose() { return nose; } public void setHoldingItem(boolean holdingItem) { this.holdingItem = holdingItem; } 这里在setupAnim中为女巫的鼻子进行了一定的变换,从而实现了鼻子的抖动效果。 女巫的渲染类WitchRenderer似乎平淡无奇,但是我们可以发现一个重要的Layer(WitchItemLayer)。 WitchRenderer(节选): public WitchRenderer(EntityRendererProvider.Context context) { super(context, new WitchModel<>(context.bakeLayer(ModelLayers.WITCH)), 0.5F); addLayer(new WitchItemLayer<>(this, context.getItemInHandRenderer())); } @Override public void render(Witch witch, float yRot, float partialTicks, PoseStack stack, MultiBufferSource source, int packedLight) { model.setHoldingItem(!witch.getMainHandItem().isEmpty()); super.render(witch, yRot, partialTicks, stack, source, packedLight); } WitchItemLayer: @OnlyIn(Dist.CLIENT) public class WitchItemLayer extends CrossedArmsItemLayer> { public WitchItemLayer(RenderLayerParent> parent, ItemInHandRenderer itemInHandRenderer) { super(parent, itemInHandRenderer); } @Override public void render(PoseStack stack, MultiBufferSource source, int packedLight, T witch, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { ItemStack itemstack = witch.getMainHandItem(); stack.pushPose(); if (itemstack.is(Items.POTION)) { getParentModel().getHead().translateAndRotate(stack); getParentModel().getNose().translateAndRotate(stack); stack.translate(0.0625F, 0.25F, 0.0F); stack.mulPose(Axis.ZP.rotationDegrees(180.0F)); stack.mulPose(Axis.XP.rotationDegrees(140.0F)); stack.mulPose(Axis.ZP.rotationDegrees(10.0F)); stack.translate(0.0F, -0.4F, 0.4F); } super.render(stack, source, packedLight, witch, limbSwing, limbSwingAmount, partialTicks, ageInTicks, netHeadYaw, headPitch); stack.popPose(); } } 看一下WitchItemLayer中的render方法。 @Override public void render(PoseStack stack, MultiBufferSource source, int packedLight, T witch, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { ItemStack mainHandItem = witch.getMainHandItem(); stack.pushPose(); if (mainHandItem.is(Items.POTION)) { // 更新头、鼻子的位置与旋转角度 getParentModel().getHead().translateAndRotate(stack); getParentModel().getNose().translateAndRotate(stack); // 对PoseStack进行translate和mulPose,以确保将来会在正确的位置渲染物品 stack.translate(0.0625F, 0.25F, 0.0F); stack.mulPose(Axis.ZP.rotationDegrees(180.0F)); stack.mulPose(Axis.XP.rotationDegrees(140.0F)); stack.mulPose(Axis.ZP.rotationDegrees(10.0F)); stack.translate(0.0F, -0.4F, 0.4F); } super.render(stack, source, packedLight, witch, limbSwing, limbSwingAmount, partialTicks, ageInTicks, netHeadYaw, headPitch); stack.popPose(); } 但是仿佛找不到一行与渲染物品有关的代码……别急,我们看看它的父类CrossedArmsItemLayer的render方法。 public void render(PoseStack stack, MultiBufferSource source, int packedLight, T entity, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { stack.pushPose(); stack.translate(0.0F, 0.4F, -0.4F); stack.mulPose(Axis.XP.rotationDegrees(180.0F)); ItemStack mainHandItem = entity.getItemBySlot(EquipmentSlot.MAINHAND); itemInHandRenderer.renderItem(entity, mainHandItem, ItemDisplayContext.GROUND, false, stack, source, packedLight); stack.popPose(); } 可以发现物品的渲染是在父类完成的,而WitchItemLayer则完成了对药水位置的重新确定。 女巫就到此为止吧……下一节我们将讲解骷髅的具体实现,并作为1.2.2的最后一个原版实例讲解。骷髅的综合性较强,可能需要联系1.2.1所学的内容进行理解。 "},"part1-monster/ranged/skeleton/":{"url":"part1-monster/ranged/skeleton/","title":"全能的骷髅","keywords":"","body":"全能的骷髅 骷髅(Skeleton)是一种装备了弓并发射箭的常见的亡灵敌对生物。 ——Minecraft Wiki "},"part1-monster/ranged/skeleton/skeleton.html":{"url":"part1-monster/ranged/skeleton/skeleton.html","title":"骷髅的实现逻辑","keywords":"","body":"骷髅的实现逻辑 骷髅不仅是一种远程攻击的生物,失去弓后还能近战,并且十分聪明。在游戏中,它往往是第一天晚上玩家最害怕的怪物之一。本节将分析骷髅的基本实现方式,从而基本理清这个行为较复杂的怪物的实现逻辑。骷髅和僵尸在底层实现上有很多相似之处,本节也会经常提到1.2.1.1.1节的内容,因此可以先复习一下1.2.1.1.1讲过的东西~ Skeleton类的代码似乎不多,但它的父类AbstractSkeleton内容丰富。我们先来看AbstractSkeleton类。 protected AbstractSkeleton(EntityType type, Level level) { super(type, level); reassessWeaponGoal(); } 注意构造方法中调用了reassessWeaponGoal方法。顾名思义,这个方法用于根据骷髅自身的状态来决定使用近战的AI还是远程攻击的AI,让我们来看看这个方法。 // 远程攻击的AI private final RangedBowAttackGoal bowGoal = new RangedBowAttackGoal<>(this, 1.0D, 20, 15.0F); // 近战的AI。注意这个匿名内部类里调用了setAggressive方法,在渲染骷髅时,会根据骷髅是否aggressive来决定骷髅的动作 private final MeleeAttackGoal meleeGoal = new MeleeAttackGoal(this, 1.2D, false) { public void stop() { super.stop(); AbstractSkeleton.this.setAggressive(false); } public void start() { super.start(); AbstractSkeleton.this.setAggressive(true); } }; public void reassessWeaponGoal() { // 确保下面的逻辑在服务端执行,记住对goalSelector操作前一定要判断是否是服务端 if (level() != null && !level().isClientSide) { // 先移除两个AI,马上再按需添加 goalSelector.removeGoal(meleeGoal); goalSelector.removeGoal(bowGoal); // 这一段比较直白,因此不额外解释。注意下面的minAttackInterval为骷髅远程攻击的最短间隔时间 ItemStack stack = getItemInHand(ProjectileUtil.getWeaponHoldingHand(this, item -> item instanceof BowItem)); if (stack.is(Items.BOW)) { int minAttackInterval = 20; if (level().getDifficulty() != Difficulty.HARD) { minAttackInterval = 40; } bowGoal.setMinAttackInterval(minAttackInterval); goalSelector.addGoal(4, bowGoal); } else { goalSelector.addGoal(4, meleeGoal); } } } 骷髅(以及其他骷髅的变种)实现根据手上武器改变攻击方式的原理便是每当手上武器可能有变化时,调用reassessWeaponGoal方法来调整AI。以下是该方法的其他被调用的位置: finalizeSpawn方法,即骷髅生成时根据生成时手上的武器判断一次 readAdditionalSaveData方法,即骷髅被重新加载时判断一次,因为实体的AI不会被保存到NBT标签中,所以这个判断很有必要 setItemSlot方法,即骷髅手上的武器被改变时判断一次 AI部分,其中前两个Goal与骷髅躲避阳光的行为有关,下一节会具体说,剩余的Goal比较常规。 @Override protected void registerGoals() { goalSelector.addGoal(2, new RestrictSunGoal(this)); goalSelector.addGoal(3, new FleeSunGoal(this, 1.0D)); // 下面的AvoidEntityGoal实现了骷髅躲避狼的行为。倒数第三个参数分别表示了最大躲避距离(与狼的最近距离在这个值内就会躲开狼), // 最后两个参数分别决定了躲避过程中骷髅的行走速度与冲刺速度(与狼的最近距离在7以内会“冲刺”,否则会“行走”) goalSelector.addGoal(3, new AvoidEntityGoal<>(this, Wolf.class, 6.0F, 1.0D, 1.2D)); goalSelector.addGoal(5, new WaterAvoidingRandomStrollGoal(this, 1.0D)); goalSelector.addGoal(6, new LookAtPlayerGoal(this, Player.class, 8.0F)); goalSelector.addGoal(6, new RandomLookAroundGoal(this)); targetSelector.addGoal(1, new HurtByTargetGoal(this)); targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, IronGolem.class, true)); // 下面这行与僵尸完全相同,此处不重复解释 targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, Turtle.class, 10, true, false, Turtle.BABY_ON_LAND_SELECTOR)); } aiStep方法和finalizeSpawn方法,这两个方法与僵尸的这两个方法非常接近,所以不会详细解释 @Override public void aiStep() { // 使骷髅在阳光下着火(正如1.2.1.1.1所说,aiStep方法与僵尸的非常相似……) boolean shouldBurn = isSunBurnTick(); if (shouldBurn) { ItemStack helmet = getItemBySlot(EquipmentSlot.HEAD); if (!helmet.isEmpty()) { if (helmet.isDamageableItem()) { helmet.setDamageValue(helmet.getDamageValue() + random.nextInt(2)); if (helmet.getDamageValue() >= helmet.getMaxDamage()) { broadcastBreakEvent(EquipmentSlot.HEAD); setItemSlot(EquipmentSlot.HEAD, ItemStack.EMPTY); } } shouldBurn = false; } if (shouldBurn) { setSecondsOnFire(8); } } super.aiStep(); } @Override @Nullable public SpawnGroupData finalizeSpawn(ServerLevelAccessor accessor, DifficultyInstance difficulty, MobSpawnType type, @Nullable SpawnGroupData spawnData, @Nullable CompoundTag tag) { spawnData = super.finalizeSpawn(accessor, difficulty, type, spawnData, tag); // 除调用了reassessWeaponGoal方法以外与僵尸相同 RandomSource source = accessor.getRandom(); populateDefaultEquipmentSlots(source, difficulty); populateDefaultEquipmentEnchantments(source, difficulty); reassessWeaponGoal(); setCanPickUpLoot(source.nextFloat() 当然骷髅也有自己的独特之处,下面是与骷髅远程攻击有关的内容,也是本节的重难点。 @Override public void performRangedAttack(LivingEntity target, float power) { ItemStack projectile = getProjectile(getItemInHand(ProjectileUtil.getWeaponHoldingHand(this, item -> item instanceof BowItem))); AbstractArrow arrow = getArrow(projectile, power); if (getMainHandItem().getItem() instanceof BowItem) { arrow = ((BowItem) getMainHandItem().getItem()).customArrow(arrow); } double dx = target.getX() - getX(); double dy = target.getY(0.3333333333333333D) - arrow.getY(); double dz = target.getZ() - getZ(); double distance = Math.sqrt(dx * dx + dz * dz); arrow.shoot(dx, dy + distance * (double) 0.2F, dz, 1.6F, (float) (14 - level().getDifficulty().getId() * 4)); playSound(SoundEvents.SKELETON_SHOOT, 1.0F, 1.0F / (getRandom().nextFloat() * 0.4F + 0.8F)); level().addFreshEntity(arrow); } @Override protected AbstractArrow getArrow(ItemStack stack, float power) { return ProjectileUtil.getMobArrow(this, stack, power); } @Override public boolean canFireProjectileWeapon(ProjectileWeaponItem item) { return item == Items.BOW; } Monster类: @Override public ItemStack getProjectile(ItemStack stack) { if (stack.getItem() instanceof ProjectileWeaponItem) { // 返回的这个Predicate用于判断自己的武器能否使用手持的弹射物 Predicate predicate = ((ProjectileWeaponItem) stack.getItem()).getSupportedHeldProjectiles(); // getHeldProjectile方法返回手持的弹射物 ItemStack heldProjectile = ProjectileWeaponItem.getHeldProjectile(this, predicate); // ForgeHook里的getProjectile方法涉及到了LivingGetProjectileEvent事件的发送 return ForgeHooks.getProjectile(this, stack, itemstack.isEmpty() ? new ItemStack(Items.ARROW) : heldProjectile); } else { return ForgeHooks.getProjectile(this, stack, ItemStack.EMPTY); } } ProjectileUtil类: public static InteractionHand getWeaponHoldingHand(LivingEntity livingEntity, Predicate itemPredicate) { return itemPredicate.test(livingEntity.getMainHandItem().getItem()) ? InteractionHand.MAIN_HAND : InteractionHand.OFF_HAND; } public static AbstractArrow getMobArrow(LivingEntity livingEntity, ItemStack arrow, float power) { ArrowItem arrowItem = (ArrowItem) (arrow.getItem() instanceof ArrowItem ? arrow.getItem() : Items.ARROW); // createArrow方法创建了箭的实体,并且将箭具有的状态效果复制到了这个实体上 AbstractArrow newArrow = arrowItem.createArrow(livingEntity.level(), arrow, livingEntity); // 根据所持武器的附魔,为箭应用附魔的效果 newArrow.setEnchantmentEffectsFromEntity(livingEntity, power); // 如果箭是药箭,那就再一次将箭具有的状态效果复制到这个实体上(可能是为了防止setEnchantmentEffectsFromEntity方法对药箭具有的的状态效果产生影响) if (arrow.is(Items.TIPPED_ARROW) && newArrow instanceof Arrow) { ((Arrow) newArrow).setEffectsFromItem(arrow); } return newArrow; } BowItem类: // 根据弓的类型自定义箭的类型,这应该是Forge加的方法 public AbstractArrow customArrow(AbstractArrow arrow) { return arrow; } 拆解一下performRangedAttack方法。 这是上半部分: ItemStack projectile = getProjectile(getItemInHand(ProjectileUtil.getWeaponHoldingHand(this, item -> item instanceof BowItem))); AbstractArrow arrow = getArrow(projectile, power); if (getMainHandItem().getItem() instanceof BowItem) { arrow = ((BowItem) getMainHandItem().getItem()).customArrow(arrow); } 这部分获取了将要发射出去的弹射物的类型,注意所有使用弓的生物都需要这样的处理,以确保TA们发射出正确的弹射物。里面涉及到的几个方法上面都列出来了并写了注释,如果想弄清楚这个过程是如何实现的,可以参考上面的内容。 下半部分: double dx = target.getX() - getX(); // 为了确保箭瞄准目标的身体(偏下部)而非地面,这儿获取目标的y坐标时向上偏移了目标碰撞箱高度的1/3。 double dy = target.getY(0.3333333333333333D) - arrow.getY(); double dz = target.getZ() - getZ(); double distance = Math.sqrt(dx * dx + dz * dz); // 这里根据难度调整了骷髅射击的精度,困难模式下骷髅射得更准,就是因为最后一个参数的数值小 arrow.shoot(dx, dy + distance * (double) 0.2F, dz, 1.6F, (float) (14 - level().getDifficulty().getId() * 4)); playSound(SoundEvents.SKELETON_SHOOT, 1.0F, 1.0F / (getRandom().nextFloat() * 0.4F + 0.8F)); level().addFreshEntity(arrow); 这部分就没有什么非常特别的地方了,如果有不明白的,可以复习1.2.2.2与1.2.2.4的相关内容。 最后AbstractSkeleton类中的杂项。注意重写了rideTick方法使骷髅骑手的行为正常。 public static AttributeSupplier.Builder createAttributes() { return Monster.createMonsterAttributes().add(Attributes.MOVEMENT_SPEED, 0.25D); } @Override protected void playStepSound(BlockPos pos, BlockState state) { playSound(getStepSound(), 0.15F, 1.0F); } @Override protected abstract SoundEvent getStepSound(); @Override public MobType getMobType() { return MobType.UNDEAD; } @Override public void rideTick() { super.rideTick(); Entity vehicle = this.getControlledVehicle(); if (vehicle instanceof PathfinderMob pathfinderMob) { yBodyRot = pathfinderMob.yBodyRot; } } @Override public void readAdditionalSaveData(CompoundTag tag) { super.readAdditionalSaveData(tag); reassessWeaponGoal(); } @Override public void setItemSlot(EquipmentSlot slot, ItemStack stack) { super.setItemSlot(slot, stack); if (!level().isClientSide) { reassessWeaponGoal(); } } @Override protected float getStandingEyeHeight(Pose pose, EntityDimensions dimensions) { return 1.74F; } @Override public double getMyRidingOffset() { return -0.6D; } @Override public boolean isShaking() { return isFullyFrozen(); } 再来看子类Skeleton类的内容,子类实现了骷髅陷入细雪后的转化以及骷髅头颅的掉落。先来看与骷髅转化为流浪者的过程有关的代码。 private static final int TOTAL_CONVERSION_TIME = 300; // 决定了骷髅是否正在转化为流浪者,值为true则正在转化 private static final EntityDataAccessor DATA_STRAY_CONVERSION_ID = SynchedEntityData.defineId(Skeleton.class, EntityDataSerializers.BOOLEAN); public static final String CONVERSION_TAG = \"StrayConversionTime\"; // 陷入细雪的时间(tick) private int inPowderSnowTime; // 开始转化的时间(tick) private int conversionTime; public boolean isFreezeConverting() { return getEntityData().get(DATA_STRAY_CONVERSION_ID); } public void setFreezeConverting(boolean converting) { entityData.set(DATA_STRAY_CONVERSION_ID, converting); } // 正在转化过程中的骷髅身体会抖动 @Override public boolean isShaking() { return isFreezeConverting(); } @Override public void tick() { if (!level().isClientSide && isAlive() && !isNoAi()) { if (isInPowderSnow) { if (isFreezeConverting()) { // 如果正在转化过程中,每刻减少转换时间 --conversionTime; if (conversionTime = 140) { startFreezeConversion(300); } } } else { // 重置转化时间,并设置为不在转化过程中 inPowderSnowTime = -1; setFreezeConverting(false); } } super.tick(); } private void startFreezeConversion(int conversionTime) { this.conversionTime = conversionTime; setFreezeConverting(true); } protected void doFreezeConversion() { convertTo(EntityType.STRAY, true); if (!isSilent()) { level().levelEvent(null, 1048, blockPosition(), 0); } } @Override public boolean canFreeze() { // 骷髅不会被冻伤 return false; } 掉落头颅的部分与僵尸基本相同,只不过掉的是骷髅的头。 @Override protected void dropCustomDeathLoot(DamageSource source, int lootingLevel, boolean killedByPlayer) { super.dropCustomDeathLoot(source, lootingLevel, killedByPlayer); Entity entity = source.getEntity(); if (entity instanceof Creeper creeper) { if (creeper.canDropMobsSkull()) { creeper.increaseDroppedSkulls(); spawnAtLocation(Items.SKELETON_SKULL); } } } 本节的内容就到此为止了,下一节将分析上面说过的骷髅的两个特殊的AI以及骷髅远程攻击时所特有的行为。 "},"part1-monster/ranged/skeleton/skeleton2.html":{"url":"part1-monster/ranged/skeleton/skeleton2.html","title":"骷髅的“聪明之处”","keywords":"","body":"骷髅的“聪明之处” 众所周知,骷髅会在有阳光时走向遮阳的地方,并且在射击时会躲避靠近的玩家,这也是许多玩家认为骷髅“聪明”的地方。那为什么骷髅有这样的行为呢? 先来讲讲和躲避阳光有关的2个AI。 首先是RestrictSunGoal: public class RestrictSunGoal extends Goal { private final PathfinderMob mob; public RestrictSunGoal(PathfinderMob mob) { this.mob = mob; } @Override public boolean canUse() { return mob.level().isDay() && mob.getItemBySlot(EquipmentSlot.HEAD).isEmpty() && GoalUtils.hasGroundPathNavigation(mob); } @Override public void start() { ((GroundPathNavigation) mob.getNavigation()).setAvoidSun(true); } @Override public void stop() { if (GoalUtils.hasGroundPathNavigation(mob)) { ((GroundPathNavigation) mob.getNavigation()).setAvoidSun(false); } } } (GroundPathNavigation) protected void trimPath() { super.trimPath(); if (avoidSun) { if (level.canSeeSky(BlockPos.containing(mob.getX(), mob.getY() + 0.5D, mob.getZ()))) { return; } for (int i = 0; i 这个AI让骷髅具有在白天不带头盔时“切短”路径的能力,防止骷髅走入会被太阳晒到的区域。具体是这样的过程:如果avoidSun为true,那么就遍历骷髅当前的路径点,把会被太阳晒到的一部分从骷髅的路径中除去。 但单纯只有这个AI也不行,因为这个AI虽然会防止骷髅走进能被太阳晒到的地方,但如果可怜的骷髅就在太阳下怎么办呢? 这时另一个AI,FleeSunGoal,就要发挥作用啦。 public class FleeSunGoal extends Goal { protected final PathfinderMob mob; // 下面3个变量描述了“安全”位置的坐标 private double wantedX; private double wantedY; private double wantedZ; // 决定了逃离阳光的速度(逃离阳光的速度 = 基础移速 * speedModifier) private final double speedModifier; private final Level level; public FleeSunGoal(PathfinderMob mob, double speedModifier) { this.mob = mob; this.speedModifier = speedModifier; this.level = mob.level(); setFlags(EnumSet.of(Goal.Flag.MOVE)); } @Override public boolean canUse() { if (mob.getTarget() != null) { return false; } else if (!level.isDay()) { return false; } else if (!mob.isOnFire()) { return false; } else if (!level.canSeeSky(mob.blockPosition())) { return false; } else { // 只当骷髅白天在阳光下燃烧,且不攻击其他目标时可用该AI return mob.getItemBySlot(EquipmentSlot.HEAD).isEmpty() && setWantedPos(); } } protected boolean setWantedPos() { Vec3 hidePos = getHidePos(); if (hidePos == null) { return false; } else { this.wantedX = hidePos.x; this.wantedY = hidePos.y; this.wantedZ = hidePos.z; return true; } } @Override public boolean canContinueToUse() { return !mob.getNavigation().isDone(); } @Override public void start() { mob.getNavigation().moveTo(wantedX, wantedY, wantedZ, speedModifier); } @Nullable protected Vec3 getHidePos() { RandomSource random = mob.getRandom(); BlockPos pos = mob.blockPosition(); // 这里的hidePos(“安全”位置)也是通过1.2.1.3.2说过的重复尝试思想来寻找和确定的 for (int i = 0; i 注:分析源代码可知,对继承了Monster类的怪物而言,getWalkTargetValue(BlockPos)的返回值为8ab−120a−5b+60120−6b\\large \\frac{8ab-120a-5b+60}{120-6b} 120−6b8ab−120a−5b+60,式中a为怪物所在维度的环境光照(05(12−b)120−6b\\large \\frac{5(12-b)}{120-6b} 120−6b5(12−b)。由该式可得,当骷髅位于主世界时,会随机选择不被太阳直射且亮度低于12的位置作为“安全”位置。 在FleeSunGoal中,我们通过在骷髅被太阳直射时,生成一个“安全”位置,并尝试移动到该位置来避免被灼伤。RestrictSunGoal与FleeSunGoal巧妙配合,前者防患于未然,后者“亡羊补牢”,及时止损,共同确保了骷髅的生命安全。 然后是骷髅射击时躲避靠近的玩家的原理。这个我们可以在RangedBowAttackGoal里找到答案。RangedBowAttackGoal结构基本与RangedAttackGoal相同,但是tick方法处二者有一定区别。 注:后文中扫射(strafe)指骷髅等弓箭手远程攻击时以玩家为中心持续侧移绕圈以尝试规避攻击的行为 下面展示一下有区别的tick方法。 // 两个boolean表示扫射方向,其中strafingClockwise为左/右(顺时针/逆时针)方向,strafingBackwards为后/前方向 private boolean strafingClockwise; private boolean strafingBackwards; // 扫射时间,与扫射方向的调节有关,为-1时表示不在扫射状态 private int strafingTime = -1; @Override public void tick() { LivingEntity target = mob.getTarget(); if (target != null) { double distSqr = mob.distanceToSqr(target.getX(), target.getY(), target.getZ()); boolean hasLineOfSight = mob.getSensing().hasLineOfSight(target); boolean hasSawTarget = seeTime > 0; if (hasLineOfSight != hasSawTarget) { seeTime = 0; } if (hasLineOfSight) { ++seeTime; } else { --seeTime; } if (!(distSqr > (double) attackRadiusSqr) && seeTime >= 20) { mob.getNavigation().stop(); ++strafingTime; } else { mob.getNavigation().moveTo(target, speedModifier); strafingTime = -1; } if (strafingTime >= 20) { if ((double) mob.getRandom().nextFloat() -1) { if (distSqr > (double) (attackRadiusSqr * 0.75F)) { strafingBackwards = false; } else if (distSqr = 20) { mob.stopUsingItem(); mob.performRangedAttack(target, BowItem.getPowerForTime(ticksUsingItem)); attackTime = attackIntervalMin; } } } else if (--attackTime = -60) { mob.startUsingItem(ProjectileUtil.getWeaponHoldingHand(mob, item -> item instanceof BowItem)); } } } 我们将if (target != null) {...}里的内容拆解为4部分。 第一部分用来更新seeTime。 double distSqr = mob.distanceToSqr(target.getX(), target.getY(), target.getZ()); boolean hasLineOfSight = mob.getSensing().hasLineOfSight(target); boolean hasSawTarget = seeTime > 0; if (hasLineOfSight != hasSawTarget) { seeTime = 0; } if (hasLineOfSight) { ++seeTime; } else { --seeTime; } 这部分与RangedAttackGoal是相似的,但是如果seeTime到0后仍未看到目标,seeTime会继续下降至负值(负值的绝对值表示没有看到目标的时间)。 第二部分用来更新3个与扫射有关的变量。 if (!(distSqr > (double) attackRadiusSqr) && seeTime >= 20) { // 如果看到目标超过1秒,并且目标在攻击范围内,则准备进行扫射 mob.getNavigation().stop(); ++strafingTime; } else { // 否则移向目标,并把strafingTime重置为-1 mob.getNavigation().moveTo(target, speedModifier); strafingTime = -1; } // 当扫射持续1秒未变向时,每刻都有0.3的概率改变扫射方向(左右方向/前后方向) if (strafingTime >= 20) { if ((double) mob.getRandom().nextFloat() 注意到这部分随机化了扫射方向,防止一直向同一方向扫射。 第三部分根据之前更新过的扫射变量更新了射击者自身的移动方向,还调整了射击者及其坐骑的头部朝向,使TA们看向目标。 if (strafingTime > -1) { // 对扫射的前后方向做一些必要的调整,以防射击者走出其射程 if (distSqr > (double) (attackRadiusSqr * 0.75F)) { strafingBackwards = false; } else if (distSqr 第四部分则准备进行攻击。 if (mob.isUsingItem()) { // 没看到目标3秒以上,则放下手中的弓 if (!hasLineOfSight && seeTime = 20) { mob.stopUsingItem(); mob.performRangedAttack(target, BowItem.getPowerForTime(ticksUsingItem)); attackTime = attackIntervalMin; } } // 没看到目标的时间不足3秒,且经过了攻击间隔而可以攻击,则开始张弓 } else if (--attackTime = -60) { mob.startUsingItem(ProjectileUtil.getWeaponHoldingHand(mob, item -> item instanceof BowItem)); } 除tick方法外RangedBowAttackGoal中的内容基本上都是RangedAttackGoal中出现过的,此处省略不再次讲述。 下一节是骷髅的模型与渲染哦,我们下次再见~ "},"part1-monster/ranged/skeleton/skeleton3.html":{"url":"part1-monster/ranged/skeleton/skeleton3.html","title":"骷髅的模型(与渲染)","keywords":"","body":"骷髅的模型(与渲染) 注: 之前讲末影人的模型的时候曾经提到过xRot,yRot和zRot,但EndermanModel中对这三个变量的运用比较简单,所以没有细讲。本节中可能会涉及到更加复杂的旋转角度调节,如果读者对ModelPart绕x、y、z轴的旋转不够熟悉,下面斜体的内容可能会有所帮助。 ModelPart中xRot,yRot,zRot分别表示绕ModelPart的相对坐标系的x、y、z轴旋转的弧度。以下三张图分别展示了骷髅头部绕x、y、z轴正向(指当xRot/yRot/zRot值增大时的旋转方向)旋转的效果。(图中的骷髅经过了特殊处理,不具有AI、装备且不会在阳光下燃烧) x轴: y轴: z轴: 我们先来探讨一下要想制作骷髅的模型,除了一些实体模型共有的东西以外,还需要什么。 容易发现,骷髅会使用弓箭,因此要对此做特殊处理。在SkeletonModel中,有如下的代码。 @Override public void prepareMobModel(T skeleton, float limbSwing, float limbSwingAmount, float partialTicks) { rightArmPose = HumanoidModel.ArmPose.EMPTY; leftArmPose = HumanoidModel.ArmPose.EMPTY; ItemStack stack = skeleton.getItemInHand(InteractionHand.MAIN_HAND); if (stack.is(Items.BOW) && skeleton.isAggressive()) { if (skeleton.getMainArm() == HumanoidArm.RIGHT) { rightArmPose = HumanoidModel.ArmPose.BOW_AND_ARROW; } else { leftArmPose = HumanoidModel.ArmPose.BOW_AND_ARROW; } } super.prepareMobModel(skeleton, limbSwing, limbSwingAmount, partialTicks); } 这部分内容判断了骷髅是否在用弓箭攻击,如果是,则调整手臂的姿势为ArmPose.BOW_AND_ARROW。那如果姿势为ArmPose.BOW_AND_ARROW的时候,手臂的旋转又是怎样被处理的呢? 这就要看父类HumanoidModel中的poseRightArm和poseLeftArm方法在手臂姿势为ArmPose.BOW_AND_ARROW时的特有处理方式了。 poseRightArm: rightArm.yRot = -0.1F + head.yRot; leftArm.yRot = 0.1F + head.yRot + 0.4F; rightArm.xRot = (-(float) Math.PI / 2F) + head.xRot; leftArm.xRot = (-(float) Math.PI / 2F) + head.xRot; poseLeftArm: rightArm.yRot = -0.1F + head.yRot - 0.4F; leftArm.yRot = 0.1F + head.yRot; // 下面这两行和poseRightArm相同 rightArm.xRot = (-(float) Math.PI / 2F) + head.xRot; leftArm.xRot = (-(float) Math.PI / 2F) + head.xRot; 这两个方法中都将两只手臂抬高了90°,且又加上了一个head.xRot来确保手臂随头部抬高而抬高。在yRot的调节上,两个方法的处理则是互相“对称”的。当“右撇子”的骷髅射箭时,显然左臂的偏移角度应该大一些,所以leftArm.yRot额外向内旋转了0.4弧度,而当“左撇子”的骷髅射箭时则正好相反。下图展示了一个“右撇子”骷髅手持弓进行攻击时的姿势(图中的骷髅经过了特殊处理,不会在阳光下燃烧) “右撇子”骷髅手持弓进行攻击,可以很明显地发现它的左臂的旋转幅度更大 前面说过骷髅是一种既能远程攻击又能近战的生物,那么骷髅近战的时候手臂又是如何处理的呢?我们可以在setupAnim中找到答案。 @Override public void setupAnim(T skeleton, float limbSwing, float limbSwingAmount, float ageInTicks, float netHeadYaw, float headPitch) { super.setupAnim(skeleton, limbSwing, limbSwingAmount, ageInTicks, netHeadYaw, headPitch); ItemStack mainHandItem = skeleton.getMainHandItem(); if (skeleton.isAggressive() && (mainHandItem.isEmpty() || !mainHandItem.is(Items.BOW))) { // attackTime的计算方式:已经摆动(swing)手臂的时长/摆动手臂的总时长 // 注意attackTime在摆动手臂时才取非零(介于0~1间且不取1)的值,因此在骷髅未摆动手臂进行攻击时以下两个局部变量的值均为0 float f = Mth.sin(attackTime * (float) Math.PI); float f1 = Mth.sin((1.0F - (1.0F - attackTime) * (-attackTime)) * (float) Math.PI); // 重置了zRot的值 rightArm.zRot = 0.0F; leftArm.zRot = 0.0F; // 在发动近战攻击时,让手臂沿y轴小幅转动 rightArm.yRot = -(0.1F - f * 0.6F); leftArm.yRot = 0.1F - f * 0.6F; // 将手臂抬起90° rightArm.xRot = -(float) Math.PI / 2F; leftArm.xRot = -(float) Math.PI / 2F; // 在发动近战攻击时,将手臂抬得更高 rightArm.xRot -= f * 1.2F - f1 * 0.4F; leftArm.xRot -= f * 1.2F - f1 * 0.4F; // 该方法用于给手臂沿x、z轴添加规律的小幅摆动,使手臂更加自然 AnimationUtils.bobArms(rightArm, leftArm, ageInTicks); } } 附上rightArm.yRot和rightArm.xRot随attackTime变化的图象。 rightArm.yRot-attackTime图象: rightArm.xRot-attackTime图象: 骷髅的手臂较细,所以SkeletonModel中还重写了translateToHand方法,给了骷髅拿在手上的东西一个小小的向内的位置偏移。 @Override public void translateToHand(HumanoidArm arm, PoseStack stack) { float offset = arm == HumanoidArm.RIGHT ? 1.0F : -1.0F; ModelPart part = getArm(arm); part.x += offset; part.translateAndRotate(stack); part.x -= offset; } 骷髅的渲染类SkeletonRenderer没有什么重要的地方,而且基本上与僵尸是一致的,这里就不放代码了。 本节的内容就是这些了哦,下一节将会再讲一个实战。 "},"part1-monster/ranged/actual_combat4.html":{"url":"part1-monster/ranged/actual_combat4.html","title":"实战4 - 骷髅法师","keywords":"","body":"实战4 - 骷髅法师 我们已经有了女巫和骷髅,那么“杂交”女巫和骷髅可以得到什么新品种呢? 任务 制作一种新的骷髅——骷髅法师 要求 骷髅法师的生命值为100,护甲值为10,攻击力为3,其余数值可以任意设置 骷髅法师生成时不携带任何装备 当骷髅法师的生命值高于最大生命值的一半时,骷髅法师会投掷喷溅药水进行攻击,具体攻击方式如下: 如果自身与攻击目标的距离大于8,同时目标没有缓慢效果,则有50%的概率选择迟缓药水 如果不满足i所述的条件,并且攻击目标生命值大于等于攻击目标最大生命值的一半,同时目标不是亡灵生物且没有中毒效果,则一定选择剧毒药水 如果不满足ii所述的条件,与攻击目标的距离小于3,同时目标没有虚弱效果,则有25%的概率选择虚弱药水。若攻击目标是铁傀儡,则这个概率增加到100% 如果上述条件都不满足,则选择瞬间伤害药水,攻击亡灵生物则使用瞬间治疗药水 骷髅法师扔出的喷溅药水有20%的概率变为滞留药水,且药水的药水效果不变 当骷髅法师的生命值不高于最大生命值的一半时,骷髅法师主手会获得一把力量II附魔的弓,攻击方式变为与普通骷髅相同(注意该状态下移除武器后骷髅法师会进行近战攻击),同时再也无法回到投掷药水的状态 骷髅法师是亡灵生物,且免疫所有状态效果 骷髅法师免疫来自自己的伤害,对有DamageTypeTags.WITCH_RESISTANT_TO标签的伤害减免85%,受到来自铁傀儡的伤害减半 骷髅法师可以被“强化”,具体细节如下: 强化骷髅法师的生命值、护甲值和攻击力翻倍 强化骷髅法师不再免疫正面状态效果,但依然免疫所有负面状态效果,不过保持免疫中毒和生命恢复效果不变。 强化骷髅法师100%扔出二级药水【对于虚弱药水,则扔出虚弱药水(延长版),因为原版没有“虚弱药水 II”】,扔出滞留药水的概率增加到30% 强化骷髅法师的眼睛是红色的并且亮度不受环境影响,且眼睛在强化骷髅法师本体隐身的条件下不可见 当强化骷髅法师的生命值不高于最大生命值的一半时,骷髅法师主手获得的弓的附魔变为力量V,但强化骷髅法师射击的速度与非强化的骷髅法师相同 用骨头右键一个骷髅法师可以“强化”这个骷髅法师,这时骷髅法师的四周会生成粒子效果(粒子的种类任意),生命值会被重置为最大生命值,并回到投掷药水的状态(如果双手上有物品则会把物品清除)。非创造模式下“强化”一个骷髅法师还会消耗一根骨头。强化骷髅法师无法再次被“强化” 除以上要点所述内容外,强化骷髅法师的其他特性与非强化的骷髅法师相同 骷髅法师没有幼年状态 骷髅法师会像其他骷髅那样主动攻击玩家、铁傀儡和幼年海龟,不攻击时行为也与骷髅完全相同 骷髅法师不会转化为流浪者,也不会掉落头颅 骷髅法师应该使用“骷髅”类型的材质(否则为什么叫骷髅法师(ง •_•)ง,当然这条不强求) 非强化的骷髅法师的眼睛不能是红色的 骷髅法师的音效可以任意设置 提示 这个实战所在的1.2.2.6节是非Boss级怪物中最基础的前两章的最后一节,笔者想给这个实战略微上点难度,所以可能稍稍有一点复杂233 可以用ItemStack类中的enchant方法来附魔物品 想想骷髅法师的“强化”属性是不是用一个简单的boolean来表示就足够了…… 要求8-vi需要老老实实地重写mobInteract方法来满足(别想找Shearable之类的捷径) 滞留药水没有单独的实体类,要想扔出滞留药水需要将ThrownPotion的物品设置为Items.LINGERING_POTION,就像这样: ThrownPotion thrownPotion = new ThrownPotion(level(), this); thrownPotion.setItem(new ItemStack(Items.LINGERING_POTION)); 要求9、10、11、14和某些要求中的一部分其实是在简化问题() 参考步骤 #1 要求8十分复杂,我们先不管这个要求。参考步骤#1部分中标// TODO的部分在后面实现要求8中的内容时都会进行修改。 依旧是先创建实体类SkeletonWizard。 public class SkeletonWizard extends AbstractSkeleton { public SkeletonWizard(EntityType type, Level level) { super(type, level); } public static AttributeSupplier.Builder createAttributes() { return AbstractSkeleton.createAttributes() .add(Attributes.MAX_HEALTH, 100) .add(Attributes.ARMOR, 10) .add(Attributes.ATTACK_DAMAGE, 3); } } 根据要求2,重写populateDefaultEquipmentSlots方法使骷髅法师不掉落任何物品。 @Override protected void populateDefaultEquipmentSlots(RandomSource random, DifficultyInstance difficultyInstance) {} 要求3~5描述了骷髅法师的攻击方式,我们不妨用一个boolean来记录骷髅法师的攻击状态。 private static final String DOES_PHYSICAL_DAMAGE_TAG = \"DoesPhysicalDamage\"; private boolean doesPhysicalDamage; public boolean doesPhysicalDamage() { return doesPhysicalDamage; } public void setDoesPhysicalDamage(boolean doesPhysicalDamage) { this.doesPhysicalDamage = doesPhysicalDamage; } // 不要把下面这一对儿给忘了~ @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); tag.putBoolean(DOES_PHYSICAL_DAMAGE_TAG, doesPhysicalDamage()); // TODO } @Override public void readAdditionalSaveData(CompoundTag tag) { // TODO super.readAdditionalSaveData(tag); setDoesPhysicalDamage(tag.getBoolean(DOES_PHYSICAL_DAMAGE_TAG)); // TODO } 对于要求3,可以使用嵌套的if-else语句来实现判断,但这里笔者选择创建一个叫做PotionSelector(药水选择器)的成员内部类,利用面向对象的编程方式来对骷髅法师应该使用的药水进行判断。 将这个类声明为抽象类,并实现Predicate接口。这样每个PotionSelector的子类都可以test骷髅法师的攻击目标,检测通过了就选择这个子类对应的药水。 private boolean shouldThrowLingeringPotion() { return random.nextDouble() { private final Potion potion; private final Potion strongerPotion; // 预留一个strongerPotion,表示强化骷髅法师应该扔出的药水,将来达到要求8的时候会用到 public PotionSelector(Potion potion, Potion strongerPotion) { this.potion = potion; this.strongerPotion = strongerPotion; } public ThrownPotion getPotionProjectile() { ThrownPotion thrownPotion = new ThrownPotion(level(), SkeletonWizard.this); Item potionType = shouldThrowLingeringPotion() ? Items.LINGERING_POTION : Items.SPLASH_POTION; thrownPotion.setItem(PotionUtils.setPotion(new ItemStack(potionType), potion)); // TODO return thrownPotion; } } 根据要求3-i到3-iv,依次创建5个子类。其中后2个子类用来处理要求3-iv。 // 迟缓药水选择器 public class SlownessPotionSelector extends PotionSelector public SlownessPotionSelector() { super(Potions.SLOWNESS, Potions.STRONG_SLOWNESS); } @Override public boolean test(LivingEntity target) { if (target.hasEffect(MobEffects.MOVEMENT_SLOWDOWN)) return false; } // 这儿用random.nextBoolean()保证在其他条件都满足时扔出迟缓药水的概率为50% return distanceToSqr(target) > 8 * 8 && random.nextBoolean(); } } // 剧毒药水选择器 public class PoisonPotionSelector extends PotionSelector { public PoisonPotionSelector() { super(Potions.POISON, Potions.STRONG_POISON); } @Override public boolean test(LivingEntity target) { if (target.getMobType() == MobType.UNDEAD) { return false; } if (target.hasEffect(MobEffects.POISON)) { return false; } return target.getHealth() >= target.getMaxHealth() * 0.5F; } } // 虚弱药水选择器 public class WeaknessPotionSelector extends PotionSelector { public WeaknessPotionSelector() { super(Potions.WEAKNESS, Potions.LONG_WEAKNESS); } @Override public boolean test(LivingEntity target) { if (target.hasEffect(MobEffects.WEAKNESS)) { return false; } if (distanceToSqr(target) >= 3 * 3) { return false; } // 对铁傀儡而言这一步后应该100%选用虚弱药水,所以先判断target是否是铁傀儡,如果是的话就直接返回true return target instanceof IronGolem || random.nextDouble() 外部类中用一个列表存储所有的药水选择器。 private final List potionSelectors = List.of( new SlownessPotionSelector(), new PoisonPotionSelector(), new WeaknessPotionSelector(), new HealingPotionSelector(), new HarmingPotionSelector() ); 写个findPotion方法,依次检查所有药水选择器,用来挑选合适的药水对付攻击目标。 private ThrownPotion findPotion(LivingEntity target) { for (PotionSelector potionSelector : potionSelectors) { if (potionSelector.test(target)) { return potionSelector.getPotionProjectile(); } } // 上面的步骤中保证了一定有一个PotionSelector(即HarmingPotionSelector)能够返回true, // 因此正常情况下不会抛出这个异常 throw new IllegalStateException(\"No valid potion found\"); } 再仿照女巫的performRangedAttack方法写一个投掷药水进行攻击的方法preformPotionAttack。 private void preformPotionAttack(LivingEntity target) { Vec3 movement = target.getDeltaMovement(); double x = target.getX() + movement.x - this.getX(); double y = target.getEyeY() - 1.1 - this.getY(); double z = target.getZ() + movement.z - this.getZ(); double distance = Math.sqrt(x * x + z * z); ThrownPotion thrownPotion = findPotion(target); thrownPotion.setXRot(thrownPotion.getXRot() + 20.0F); thrownPotion.shoot(x, y + distance * 0.2, z, 0.75F, 8); if (!isSilent()) { // 这里的声音可以任意设置 level().playSound(null, getX(), getY(), getZ(), SoundEvents.WITCH_THROW, getSoundSource(), 1.0F, 0.8F + random.nextFloat() * 0.4F); } level().addFreshEntity(thrownPotion); } 然后重写performRangedAttack方法,根据骷髅法师攻击方式的不同选择不同的远程攻击方式。 @Override public void performRangedAttack(LivingEntity target, float power) { if (doesPhysicalDamage()) { super.performRangedAttack(target, power); } else { preformPotionAttack(target); } } 这样就结束了吗?读者可以先思考一下上面的内容写完是否足够。 答案是不够的。因为骷髅默认的远程攻击方式是射击,而不是投掷药水,毕竟一般的骷髅虽然聪明,却依然没有能力使用药水,因此我们需要第3个攻击类AI。还要记得重写reassessWeaponGoal方法,用于评估是否需要使用这个AI。由于这个方法在AbstractSkeleton的构造方法中被调用了,而且AbstractSkeleton类中的bowGoal和meleeGoal都是私有的,所以下面需要点小技巧。 private RangedAttackGoal potionAttackGoal; // 注意reassessWeaponGoal方法在AbstractSkeleton的构造方法中就会被调用,如果将potionAttackGoal变为final的, // 并在重写的reassessWeaponGoal方法中使用,就会因为potionAttackGoal未被初始化而抛出NPE。为了解决这个问题,笔 // 者额外创建了一个getPotionAttackGoal方法 private RangedAttackGoal getPotionAttackGoal() { if (potionAttackGoal == null) { potionAttackGoal = new RangedAttackGoal(this, 1, 60, 10); } return potionAttackGoal; } @Override public void reassessWeaponGoal() { if (!level().isClientSide()) { removeMeleeGoalAndBowGoal(); goalSelector.removeGoal(getPotionAttackGoal()); if (doesPhysicalDamage()) { super.reassessWeaponGoal(); } else { goalSelector.addGoal(4, getPotionAttackGoal()); } } } // 这个方法用来移除`AbstractSkeleton`类中的`bowGoal`和`meleeGoal`,防止骷髅法师同时有两个攻击类AI起效。 // 查阅GoalSelector中的方法可知,getAvailableGoals方法可以拿到所有的AI,又因为AbstractSkeleton中有且只有bowGoal继承了 // RangedBowAttackGoal类,加上有且只有meleeGoal继承了MeleeAttackGoal,所以我们只需移除所有MeleeAttackGoal的实例及所有 // RangedBowAttackGoal的实例即可 private void removeMeleeGoalAndBowGoal() { goalSelector.getAvailableGoals().stream() .filter(SkeletonWizard::isMeleeGoalOrBowGoal) .filter(WrappedGoal::isRunning) .forEach(WrappedGoal::stop); goalSelector.getAvailableGoals().removeIf(SkeletonWizard::isMeleeGoalOrBowGoal); } private static boolean isMeleeGoalOrBowGoal(WrappedGoal wrappedGoal) { return wrappedGoal.getGoal() instanceof MeleeAttackGoal || wrappedGoal.getGoal() instanceof RangedBowAttackGoal; } 还有readAdditionalSaveData方法需要调整。 @Override public void readAdditionalSaveData(CompoundTag tag) { // TODO super.readAdditionalSaveData(tag); setDoesPhysicalDamage(tag.getBoolean(DOES_PHYSICAL_DAMAGE_TAG)); // 这里必须重新重评估攻击类AI,因为骷髅法师的攻击方式发生了变化 reassessWeaponGoal(); } 因为readAdditionalSaveData方法中读取了doesPhysicalDamage的值,这会影响骷髅法师的攻击方式,因此在readAddtionalSaveData方法最后加上reassessWeaponGoal,来再次评估应该使用的攻击类AI。 最后重写aiStep方法,实现对骷髅法师生命值的实时判断,以实时准备更新骷髅法师的状态。 @Override public void aiStep() { super.aiStep(); if (getHealth() 这样要求3~5就基本达到了。下面来看要求6,骷髅本身就是亡灵生物,所以不需要重写getMobType方法,但是仍然需要重写canBeAffected方法。 @Override public boolean canBeAffected(MobEffectInstance instance) { return false; // TODO } 这里直接返回了false,稍后达到要求8时会修改这里的返回值。 仿照女巫的减伤方式,重写getDamageAfterMagicAbsorb方法来达到要求7。 @Override protected float getDamageAfterMagicAbsorb(DamageSource source, float amount) { amount = super.getDamageAfterMagicAbsorb(source, amount); if (source.getEntity() == this) { amount = 0; } if (source.getEntity() instanceof IronGolem) { amount *= 0.5F; } if (source.is(DamageTypeTags.WITCH_RESISTANT_TO)) { amount *= 0.15F; } return amount; } 要求9很容易达到。而对于要求10、11的话,其实AbstractSkeleton类定义的默认行为就已经可以满足这两个要求了,所以不需要特殊处理这两个要求。 @Override public boolean isBaby() { return false; } @Override public void setBaby(boolean baby) {} 最后添加一部分声音。 @Override protected SoundEvent getStepSound() { return SoundEvents.SKELETON_STEP; } @Override protected SoundEvent getAmbientSound() { return SoundEvents.SKELETON_AMBIENT; } @Override protected SoundEvent getHurtSound(DamageSource source) { return SoundEvents.SKELETON_HURT; } @Override protected SoundEvent getDeathSound() { return SoundEvents.SKELETON_DEATH; } 渲染类SkeletonWizardRenderer非常普通,但后面会在里面加点内容。 public class SkeletonWizardRenderer extends SkeletonRenderer { private static final ResourceLocation SKELETON_WIZARD = Utils.prefix(\"textures/entity/skeleton_wizard/skeleton_wizard.png\"); public SkeletonWizardRenderer(EntityRendererProvider.Context context) { super(context, ModModelLayers.SKELETON_WIZARD, ModModelLayers.SKELETON_WIZARD_INNER_ARMOR, ModModelLayers.SKELETON_WIZARD_OUTER_ARMOR); // TODO } @Override public ResourceLocation getTextureLocation(AbstractSkeleton skeleton) { return SKELETON_WIZARD; } } 注册部分仍然没展示,但千万不要把它们漏了!!! 到此为止如果一切顺利的话,只要骷髅法师的材质合适,我们应该就已经得到了一个满足除要求8外所有要求的骷髅法师了。 参考步骤 #2 下面正式开始“对付”要求8。还记得“提示”部分中留的那个小思考题吗?因为涉及到红色眼睛的渲染问题,所以只用一个boolean是不够的。我们需要借助SynchedEntityData。 private static final EntityDataAccessor REINFORCED = SynchedEntityData.defineId(SkeletonWizard.class, EntityDataSerializers.BOOLEAN); private static final String REINFORCED_TAG = \"Reinforced\"; @Override protected void defineSynchedData() { super.defineSynchedData(); entityData.define(REINFORCED, false); } public boolean isReinforced() { return entityData.get(REINFORCED); } 为达到要求8-i,新增1个AttributeModifier用于加倍骷髅法师的一些属性。同时我们对setReinforced方法做些小修改,使在设置骷髅法师是否被强化的同时能够更新骷髅法师的属性。 private static final UUID REINFORCED_BONUS_UUID = UUID.fromString(\"0153B2B3-CC49-470F-AD1C-B8D31EFAD17D\"); private static final AttributeModifier REINFORCED_BONUS = new AttributeModifier(REINFORCED_BONUS_UUID, \"Reinforced bonus\", 1, AttributeModifier.Operation.MULTIPLY_TOTAL); public void setReinforced(boolean reinforced, boolean update) { entityData.set(REINFORCED, reinforced); if (!level().isClientSide()) { if (reinforced) { Objects.requireNonNull(getAttribute(Attributes.MAX_HEALTH)).addTransientModifier(REINFORCED_BONUS); Objects.requireNonNull(getAttribute(Attributes.ARMOR)).addTransientModifier(REINFORCED_BONUS); Objects.requireNonNull(getAttribute(Attributes.ATTACK_DAMAGE)).addTransientModifier(REINFORCED_BONUS); if (update) { resetItemsInHands(); // 把双手的物品清除 setHealth(getMaxHealth()); // 注意调整最大生命值后要让实体具有新的最大生命值,需要setHealth(getMaxHealth()) setDoesPhysicalDamage(false); // 回到用药水攻击的状态 reassessWeaponGoal(); // 重新评估攻击类AI } } else { Objects.requireNonNull(getAttribute(Attributes.MAX_HEALTH)).removeModifier(REINFORCED_BONUS); Objects.requireNonNull(getAttribute(Attributes.ARMOR)).removeModifier(REINFORCED_BONUS); Objects.requireNonNull(getAttribute(Attributes.ATTACK_DAMAGE)).removeModifier(REINFORCED_BONUS); } } } 当update为true时,表示重置骷髅法师的一些数据,来实现要求8-vi中的一部分内容。当update为false时,只会更新骷髅法师的属性。 要完全达到要求8-vi,还需要重写mobInteract方法。 @Override public InteractionResult mobInteract(Player player, InteractionHand hand) { ItemStack stack = player.getItemInHand(hand); if (level().isClientSide()) { boolean canBeReinforced = stack.is(Items.BONE) && !isReinforced(); if (canBeReinforced) { // 这里用了刷怪笼刷出怪物时在怪物的位置生成的粒子效果,另外这个方法只有在客户端调用才会起效果 spawnAnim(); } // mobInteract方法在客户端的返回值会影响玩家到是否会摆动手臂(SUCCESS和CONSUME都使玩家摆动手臂) return canBeReinforced ? InteractionResult.CONSUME : InteractionResult.PASS; } else if (stack.is(Items.BONE)) { if (!player.getAbilities().instabuild) { stack.shrink(1); } if (!isReinforced()) { setReinforced(true, true); } return InteractionResult.SUCCESS; } else { return super.mobInteract(player, hand); } } 数据保存的部分也要再次随之调整。 @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); tag.putBoolean(DOES_PHYSICAL_DAMAGE_TAG, doesPhysicalDamage()); tag.putBoolean(REINFORCED_TAG, isReinforced()); } @Override public void readAdditionalSaveData(CompoundTag tag) { // 这个方法必须放在super.readAdditionalSaveData(tag)前,否则强化骷髅法师的生命值会加载出错(加载为min(正确的生命值, 100)) setReinforced(tag.getBoolean(REINFORCED_TAG), false); super.readAdditionalSaveData(tag); setDoesPhysicalDamage(tag.getBoolean(DOES_PHYSICAL_DAMAGE_TAG)); } 接着根据要求8-iii中的内容修改药水投掷部分的内容。 private boolean shouldThrowLingeringPotion() { return random.nextDouble() PotionSelector: public ThrownPotion getPotionProjectile() { ThrownPotion thrownPotion = new ThrownPotion(level(), SkeletonWizard.this); Item potionType = shouldThrowLingeringPotion() ? Items.LINGERING_POTION : Items.SPLASH_POTION; thrownPotion.setItem(PotionUtils.setPotion(new ItemStack(potionType), isReinforced() ? strongerPotion : potion)); return thrownPotion; } 此外,根据要求8-ii、8-v,canBeAffected和awardBow方法也有修改。 @Override public boolean canBeAffected(MobEffectInstance instance) { MobEffect effect = instance.getEffect(); // 原本就可以免疫的状态效果现在也一定是可以免疫的 if (!super.canBeAffected(instance)) { return false; } return effect.isBeneficial() && isReinforced(); } private void awardBow() { ItemStack bow = new ItemStack(Items.BOW); bow.enchant(Enchantments.POWER_ARROWS, isReinforced() ? 5 : 2); setItemInHand(InteractionHand.MAIN_HAND, bow); } 最后只剩下要求8-iv需要特殊处理了。由于骷髅法师的“红色眼睛”与末影人、蜘蛛等的眼睛不太相同,并且是否显示红眼与骷髅法师的自身状态有关,所以不能直接继承EyesLayer。 public class SkeletonWizardEyesLayer extends RenderLayer> { private static final ResourceLocation SKELETON_WIZARD_EYES = Utils.prefix(\"textures/entity/skeleton_wizard/skeleton_wizard_eyes.png\"); private static final RenderType SKELETON_WIZARD_EYES_RENDER_TYPE = RenderType.entityCutoutNoCull(SKELETON_WIZARD_EYES); public SkeletonWizardEyesLayer(RenderLayerParent> parent) { super(parent); } @Override public void render(PoseStack poseStack, MultiBufferSource source, int packedLight, AbstractSkeleton skeleton, float limbSwing, float limbSwingAmount, float partialTicks, float ageInTicks, float netHeadYaw, float headPitch) { // SkeletonWizard直接使用了骷髅的通用模型,因此只能在这里额外进行类型判断了…… if (!(skeleton instanceof SkeletonWizard wizard)) { throw new IllegalArgumentException(\"SkeletonWizardEyesLayer can only render SkeletonWizard, found: \" + skeleton.getClass().getSimpleName()); } if (wizard.isReinforced() && !wizard.isInvisible()) { VertexConsumer consumer = source.getBuffer(SKELETON_WIZARD_EYES_RENDER_TYPE); getParentModel().renderToBuffer(poseStack, consumer, 15728640, OverlayTexture.NO_OVERLAY, 1, 1, 1, 1); } } } 在SkeletonWizardRenderer的构造方法中添加这个Layer。 public SkeletonWizardRenderer(EntityRendererProvider.Context context) { super(context, ModModelLayers.SKELETON_WIZARD, ModModelLayers.SKELETON_WIZARD_INNER_ARMOR, ModModelLayers.SKELETON_WIZARD_OUTER_ARMOR); addLayer(new SkeletonWizardEyesLayer(this)); } 这样我们的“杂交实验”就成功完成了。 源代码(SkeletonWizard类)源代码(SkeletonWizardRenderer类)源代码(SkeletonWizardEyesLayer类) 效果图(非强化的骷髅法师使用了白眼、黄棕色躯体的骷髅的材质) 1个非强化的骷髅法师向玩家投掷滞留型瞬间伤害药水 2个强化骷髅法师与2个铁傀儡激烈交战 思考与练习 在笔者试图让强化骷髅法师与铁傀儡1v1作战时,发现用弓箭射击的阶段是强化骷髅法师的短板,因此能否使骷髅法师的射箭频率比一般的骷髅高,且强化骷髅法师射击得更快? 能否让骷髅法师每次在生命值恢复至超过一半时,都重新获得投掷药水的能力(就像Java版中凋灵生命值恢复至超过一半时凋灵护甲自动消失那样)? 能否让骷髅法师在投掷药水前,手上展示将要投掷出的药水种类? 能否让骷髅法师在生命值第一次降低至不高于最大生命值的一半时,在四周合适的位置召唤几个骷髅协助自己作战? "},"part1-monster/ranged-special/":{"url":"part1-monster/ranged-special/","title":"远程攻击的怪物 #2","keywords":"","body":"远程攻击的怪物 #2(非RangedAttackMob) 一个RangedAttackMob接口提供的功能毕竟是有限的,RangedAttackGoal也不支持较为复杂的远程攻击形式。那么如果我们想让我们的怪物有更加复杂的远程攻击方式,该怎么办呢?这个时候往往需要自己写一个新的AI,以实现复杂的远程攻击方式。 这一部分中笔者将选择烈焰人与唤魔者进行讲解。前者是一种看似“常规”,但与雪傀儡、骷髅等1.2.2中讲到的远程攻击的怪物底层实现上有一定区别的怪物,后者则被认为是会使用法术的灾厄村民。 "},"part1-monster/ranged-special/blaze/":{"url":"part1-monster/ranged-special/blaze/","title":"炽热的烈焰人","keywords":"","body":"炽热的烈焰人 烈焰人(Blaze)是生成在下界的飞行敌对生物。 ——Minecraft Wiki "},"part1-monster/ranged-special/blaze/blaze.html":{"url":"part1-monster/ranged-special/blaze/blaze.html","title":"烈焰人的实现逻辑","keywords":"","body":"烈焰人的实现逻辑 读者可以先想一想烈焰人和雪傀儡、女巫、骷髅等攻击方式的区别。 不难发现,烈焰人的小火球可以“三连发”,但后面三者每次攻击只能发射1个弹射物,这是由于performRangeAttack方法只能在远程攻击的那一游戏刻被调用一次的特性决定的。因此如果要实现烈焰人的“三连发”必须另起炉灶,自己写一个远程攻击的AI。 // 允许的最大自己与攻击目标间的高度差,当目标比自己高的距离大于这个值时烈焰人(绝大多数情况下)会向上运动 private float allowedHeightOffset = 0.5F; // 剩余的重置allowedHeightOffset的时间(单位:tick),当这个值为0时会重置allowedHeightOffset的值 private int nextHeightOffsetChangeTick; // 决定了烈焰人是否在准备攻击,如果这个值是奇数则表示烈焰人正准备攻击 private static final EntityDataAccessor DATA_FLAGS_ID = SynchedEntityData.defineId(Blaze.class, EntityDataSerializers.BYTE); public Blaze(EntityType type, Level level) { super(type, level); // 这几行在1.2.1.3.2中讲过,所以本部分不再赘述,感兴趣的读者可以翻回1.2.1.3.2中看看 setPathfindingMalus(BlockPathTypes.WATER, -1.0F); setPathfindingMalus(BlockPathTypes.LAVA, 8.0F); setPathfindingMalus(BlockPathTypes.DANGER_FIRE, 0.0F); setPathfindingMalus(BlockPathTypes.DAMAGE_FIRE, 0.0F); // 烈焰人被杀死后掉落10经验值 xpReward = 10; } 还有AI和属性。 @Override protected void registerGoals() { goalSelector.addGoal(4, new Blaze.BlazeAttackGoal(this)); goalSelector.addGoal(5, new MoveTowardsRestrictionGoal(this, 1.0D)); goalSelector.addGoal(7, new WaterAvoidingRandomStrollGoal(this, 1.0D, 0.0F)); goalSelector.addGoal(8, new LookAtPlayerGoal(this, Player.class, 8.0F)); goalSelector.addGoal(8, new RandomLookAroundGoal(this)); // HurtByTargetGoal设置为setAlertOthers,说明烈焰人受攻击时会警告附近的所有烈焰人来攻击攻击自己的玩家 targetSelector.addGoal(1, new HurtByTargetGoal(this).setAlertOthers()); targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true)); } public static AttributeSupplier.Builder createAttributes() { return Monster.createMonsterAttributes() .add(Attributes.ATTACK_DAMAGE, 6.0D) .add(Attributes.MOVEMENT_SPEED, (double) 0.23F) // 烈焰人的攻击范围明显大于许多怪物 .add(Attributes.FOLLOW_RANGE, 48.0D); } 这里的BlazeAttackGoal就是烈焰人实现“三连发”的关键所在,下节我们来专门分析这个AI。 烈焰人重写了getLightLevelDependentMagicValue方法(这个已被弃用的方法的返回值决定了实体的“亮度”,默认返回实体眼睛处所在方块的亮度,且返回值与isSunBurnTick的返回值有关,当这个方法的返回值小于等于0.5F时isSunBurnTick将总会返回false)和isSensitiveToWater方法。 @Override public float getLightLevelDependentMagicValue() { return 1.0F; } 下面是aiStep方法和customServerAiStep方法,与aiStep不同的是customServerAiStep方法只会在服务端执行。这些方法实现了烈焰人的行为,具体的一些细节在注释里给出了。 @Override public void aiStep() { // 当烈焰人正在下降时,使其运动速度在y轴上的分速度降低以向下减速, if (!onGround() && getDeltaMovement().y getEyeY() + (double) allowedHeightOffset && canAttack(target)) { Vec3 deltaMovement = getDeltaMovement(); // 大多数情况下给自己一个向上的加速度,如果烈焰人向上运动得足够快则会给它向下的加速度 setDeltaMovement(getDeltaMovement().add(0.0D, ((double) 0.3F - deltaMovement.y) * (double) 0.3F, 0.0D)); // 将hasImpulse赋值为true,以通知客户端更新实体的运动,注意只在服务端用setDeltaMovement方法使实体发生了运动后往往都要给hasImpluse赋值true hasImpulse = true; } super.customServerAiStep(); } 下面是有关烈焰人准备攻击(charged)的状态的一些逻辑。如果烈焰人在准备攻击,那么烈焰人会着火。 @Override protected void defineSynchedData() { super.defineSynchedData(); entityData.define(DATA_FLAGS_ID, (byte) 0); } @Override public boolean isOnFire() { return isCharged(); } private boolean isCharged() { return (entityData.get(DATA_FLAGS_ID) & 1) != 0; } void setCharged(boolean charged) { byte flags = entityData.get(DATA_FLAGS_ID); if (charged) { flags = (byte) (flags | 1); // 给flags的最后一位赋值1 } else { flags = (byte) (flags & -2); // 给flags的最后一位赋值0 } entityData.set(DATA_FLAGS_ID, flags); } 还有音效等杂项。 // 烈焰人在水/雨中会受到伤害 @Override public boolean isSensitiveToWater() { return true; } @Override protected SoundEvent getAmbientSound() { return SoundEvents.BLAZE_AMBIENT; } @Override protected SoundEvent getHurtSound(DamageSource source) { return SoundEvents.BLAZE_HURT; } @Override protected SoundEvent getDeathSound() { return SoundEvents.BLAZE_DEATH; } 本节的内容就是这些了,下一节将介绍BlazeAttackGoal的实现。 "},"part1-monster/ranged-special/blaze/blaze2.html":{"url":"part1-monster/ranged-special/blaze/blaze2.html","title":"烈焰人小火球的“三连发”","keywords":"","body":"烈焰人小火球的“三连发” 烈焰人每次攻击时,以较短的时间间隔连续发射3个小火球射击目标。这个攻击方式的实现位于烈焰人的一个AIBlaze.BlazeAttackGoal中。 这个AI类中定义了这样几个int类型的成员变量。 // 攻击阶段(可能取值为0,1,2,3,4,5) private int attackStep; // 攻击冷却时间(单位:tick) private int attackTime; // 距离上次看到攻击目标过去的时间(单位:tick),如果烈焰人能看到目标则该变量的值为0 private int lastSeen; 构造方法中为AI设置了Flag.MOVE和Flag.LOOK两个Flag,一般控制生物攻击的AI都会设置这两个Flag。 public BlazeAttackGoal(Blaze blaze) { this.blaze = blaze; setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); } 然后是AI使用的条件与开始结束时的行为。 @Override public boolean canUse() { LivingEntity entity = blaze.getTarget(); return entity != null && entity.isAlive() && blaze.canAttack(entity); } @Override public void start() { // 重置attackStep,意味着开始新的的一轮攻击 attackStep = 0; } @Override public void stop() { // 让烈焰人身上的火熄灭 blaze.setCharged(false); lastSeen = 0; } 这个AI每个游戏刻都需要更新(包括前面说过的RangedAttackGoal在内的几乎所有控制生物攻击的AI也是这样),所以重写requiresUpdateEveryTick方法。 @Override public boolean requiresUpdateEveryTick() { return true; } 下面说的tick方法是这个AI的关键。 @Override public void tick() { --attackTime; LivingEntity target = blaze.getTarget(); if (target != null) { boolean canSeeTarget = blaze.getSensing().hasLineOfSight(target); if (canSeeTarget) { lastSeen = 0; } else { ++lastSeen; } double distSqrToTarget = blaze.distanceToSqr(target); if (distSqrToTarget 1) { double horizontalOffset = Math.sqrt(Math.sqrt(distSqrToTarget)) * 0.5D; if (!blaze.isSilent()) { blaze.level().levelEvent(null, 1018, blaze.blockPosition(), 0); } for (int i = 0; i 与之前分析较复杂AI的方式一样,我们将if (target != null) {...}里的内容分为4个部分来分析。 第一部分用来更新attackTime和lastSeen。这两个变量的作用刚才提到过,因此此处不再赘述。 --attackTime; boolean canSeeTarget = blaze.getSensing().hasLineOfSight(target); if (canSeeTarget) { lastSeen = 0; } else { ++lastSeen; } 第二部分及以后的部分用来根据烈焰人与目标的位置关系调整烈焰人的行为。其中第二部分控制烈焰人在与目标的距离小于2时尝试近战攻击目标,并且每秒可以攻击一次。 double distSqrToTarget = blaze.distanceToSqr(target); if (distSqrToTarget 如果不满足近战攻击的条件,烈焰人会使用“小火球三连发”。第三部分中则实现了这样子的三连发小火球。 else if (distSqrToTarget 1) { // 为火球设置一个偏移量,这个偏移量的最大值与烈焰人到攻击目标距离的算术平方根成正比 double horizontalOffset = Math.sqrt(Math.sqrt(distSqrToTarget)) * 0.5D; if (!blaze.isSilent()) { // 播放烈焰人发射火球的声音 blaze.level().levelEvent(null, 1018, blaze.blockPosition(), 0); } // 其实我也很好奇为什么这里要用到for循环(我认为这里完全不需要循环) for (int i = 0; i 先说一下attackStep的具体作用。 attackStep的值 对应的烈焰人的行为 0 非远程攻击状态 1 准备远程攻击 2~4 发射小火球 5 结束远程攻击 当烈焰人处于可以远程攻击的状态时,每当attackTime为0时都会自增attackStep。在attackStep被设为1时,烈焰人会使自身着火,并在60tick(3s)之后开始发射小火球。每次发射小火球后6tick(0.3s)再次发射小火球,共重复3次这个过程。最后当attackStep增大到5时,烈焰人的所有攻击会进入100tick(5s)的冷却。 另外注意如果远程攻击时攻击目标距离自己过近(距离 下面来说让生物发射小火球等继承了AbstractHurtingProjectile的弹射物的具体方式。 计算发射方向的一步与发射雪球等继承了ThrowableProjectile的弹射物以及各种箭是一样的。与之前提到过的雪傀儡不同,烈焰人发射火球前计算y坐标的差值用的是攻击目标碰撞箱中心的y坐标减去烈焰人碰撞箱中心的y坐标,这是为了使小火球尽量击中目标碰撞箱中部。 double dx = target.getX() - blaze.getX(); double dy = target.getY(0.5D) - blaze.getY(0.5D); double dz = target.getZ() - blaze.getZ(); Entity: public double getY(double scale) { return position.y + (double) getBbHeight() * scale; } // getX(double), getZ(double)两个方法是类似的,只不过后面的getBbHeight()被替换为了getBbWidth()。这儿一并给出吧~ public double getX(double scale) { return position.x + (double) getBbWidth() * scale; } public double getZ(double scale) { return position.z + (double) getBbWidth() * scale; } 下面则是实例化弹射物。这里我们在计算发射方向后才实例化了小火球,是因为我们要向小火球的构造方法中传入发射小火球的方向(对于下面调用的这个构造方法而言,小火球被实例化后会移动到烈焰人的坐标处,并以向量(blaze.getRandom().triangle(dx, 2.297D * horizontalOffset), dy, blaze.getRandom().triangle(dz, 2.297D * horizontalOffset))为其弹道的方向向量)。还有小火球等继承了AbstractHurtingProjectile的弹射物都是不受重力影响的。 SmallFireball fireball = new SmallFireball(blaze.level(), blaze, blaze.getRandom().triangle(dx, 2.297D * horizontalOffset), dy, blaze.getRandom().triangle(dz, 2.297D * horizontalOffset)); // 把小火球的y坐标移动到烈焰人中心的y坐标上方0.5格 fireball.setPos(fireball.getX(), blaze.getY(0.5D) + 0.5D, fireball.getZ()); blaze.level().addFreshEntity(fireball); 但是我们把小火球直接移动到烈焰人的“脚”(指烈焰人碰撞箱底面中心,烈焰人哪有脚?)上显然不合适,所以要把火球移上去(增大其y坐标)一点。 最后添加小火球到世界中,这样就能看到烈焰人的小火球射向攻击目标啦! 回到烈焰人的AI。tick方法的第四部分用于在近、远程攻击的条件都不满足,但是5tick(0.25s)内曾看到过攻击目标的条件下,让烈焰人以1倍速移向攻击目标。 else if (lastSeen 这样烈焰人的AI就差不多讲完了。 你一定很好奇烈焰人的模型是怎样制作出来的,以及烈焰人为什么在完全黑暗时身体也是亮着的吧233,下一节我们就来研究这些内容。 "},"part1-monster/ranged-special/blaze/blaze3.html":{"url":"part1-monster/ranged-special/blaze/blaze3.html","title":"烈焰人身体结构的奥秘","keywords":"","body":"烈焰人身体结构的奥秘 烈焰人的身体由分为3层的,围绕一条轴旋转的“棒棒”构成。其中所有“棒棒”水平方向上绕轴做匀速圆周运动,竖直方向上做简谐运动,每层“棒棒”的运动又有一定的差异。 叠加起来看是这样的(图中的烈焰人经过了特殊处理,不具有AI且身上没有黑烟),本文中所有动态图片都减速50%播放: BlazeModel中的setupAnim方法实现了这个效果。我们对烈焰人逐“层”进行分析。setupAnim全部代码如下: @Override public void setupAnim(T blaze, float limbSwing, float limbSwingAmount, float ageInTicks, float netHeadYaw, float headPitch) { float rotation = ageInTicks * (float) Math.PI * -0.1F; for (int i = 0; i 每层“棒棒”竖直方向振动的振幅相等,而圆周运动的半径、角速度和竖直方向振动的频率满足下表(下表中所有数值为相对值): “棒棒”所在层 圆周运动的半径 圆周运动的角速度 竖直方向振动的频率 上层 9 -10 1 中层 7 3 1 下层 5 -5 2 先看上层“棒棒”: // 下面一行中,-0.1F为角速度 float rotation = ageInTicks * (float) Math.PI * -0.1F; for (int i = 0; i 这里rotation变量最后乘上的系数决定了“棒棒”旋转的角速度,upperBodyParts[i].x,upperBodyParts[i].z最后乘上的系数决定了“棒棒”旋转的半径,而upperBodyParts[i].y的变化决定了“棒棒”竖直方向上做什么样的简谐运动。注意每根“棒棒”的初始位置(初相)不同,因此rotation在循环末尾会自增,且控制upperBodyParts[i].y变化的余弦函数加上了i * 2一项。 然后看中层“棒棒”,注意中层“棒棒”与上下两层“棒棒”旋转方向相反: // 下面一行中,Math.PI / 4F为初相,0.03F为角速度 rotation = ((float) Math.PI / 4F) + ageInTicks * (float) Math.PI * 0.03F; for (int j = 4; j 代码是大同小异的,因此就不额外解释了。 还有下层“棒棒”: // 下面一行中,0.47123894F为初相,-0.05F为角速度 rotation = 0.47123894F + ageInTicks * (float) Math.PI * -0.05F; // 注:0.47123894 = Math.PI * 0.15 for (int k = 8; k 在创建LayerDefinition的时候,我们给上文中ageInTicks变量赋值0,得到每根“棒棒”的初始状态。 public static LayerDefinition createBodyLayer() { MeshDefinition meshDefinition = new MeshDefinition(); PartDefinition partDefinition = meshDefinition.getRoot(); partDefinition.addOrReplaceChild(\"head\", CubeListBuilder.create().texOffs(0, 0).addBox(-4.0F, -4.0F, -4.0F, 8.0F, 8.0F, 8.0F), PartPose.ZERO); float rotation = 0.0F; CubeListBuilder builder = CubeListBuilder.create().texOffs(0, 16).addBox (0.0F, 0.0F, 0.0F, 2.0F, 8.0F, 2.0F); for (int i = 0; i 构造方法中则初始化了upperBodyParts数组。 public BlazeModel(ModelPart root) { this.root = root; this.head = root.getChild(\"head\"); this.upperBodyParts = new ModelPart[12]; Arrays.setAll(upperBodyParts, index -> root.getChild(getPartName(index))); } 还有个问题是烈焰人为何可以一直保持明亮,其实这非常简单,只需要在BlazeRenderer中重写getBlockLightLevel方法,使其总是返回15就可以了: @Override protected int getBlockLightLevel(Blaze blaze, BlockPos pos) { return 15; } BlazeRenderer中的其他代码同样很常规,这里也同样不放代码了。 本节的内容差不多就是这些了,后面我们会开始分析唤魔者。 "},"part1-monster/ranged-special/evoker/":{"url":"part1-monster/ranged-special/evoker/","title":"法力强大的唤魔者","keywords":"","body":"法力强大的唤魔者 唤魔者(Evoker)是一种施展法术和召唤恼鬼的灾厄村民。它们是不死图腾的唯一来源。 ——Minecraft Wiki "},"part1-monster/ranged-special/evoker/evoker.html":{"url":"part1-monster/ranged-special/evoker/evoker.html","title":"唤魔者的基本实现逻辑","keywords":"","body":"唤魔者的基本实现逻辑 唤魔者看上去很复杂,但实际上Evoker类(除去里面所有的内部类)内容十分少。这是因为唤魔者的许多行为继承了灾厄村民的行为,而唤魔者的独有行为除去其特有的AI外很少。 照例先看成员变量和构造方法。 // 正因唤魔者施法而改变颜色的绵羊(唤魔者会施法改变绵羊的颜色) @Nullable private Sheep wololoTarget; public Evoker(EntityType type, Level level) { super(type, level); this.xpReward = 10; // 唤魔者掉落一般怪物的2倍的经验(10 instead of 5) } 再看属性注册部分。 public static AttributeSupplier.Builder createAttributes() { return Monster.createMonsterAttributes() .add(Attributes.MOVEMENT_SPEED, 0.5D) .add(Attributes.FOLLOW_RANGE, 12.0D) .add(Attributes.MAX_HEALTH, 24.0D); } 可以观察到唤魔者的追踪距离(即Attributes.FOLLOW_RANGE的值)与许多灾厄村民一样很短,但是唤魔者的速度达到了惊人的0.5。然而游戏中唤魔者四处闲逛时,移动速度似乎也不是很快,这是为什么呢? 答案藏在唤魔者的AI中。 @Override protected void registerGoals() { super.registerGoals(); goalSelector.addGoal(0, new FloatGoal(this)); goalSelector.addGoal(1, new Evoker.EvokerCastingSpellGoal()); goalSelector.addGoal(2, new AvoidEntityGoal<>(this, Player.class, 8.0F, 0.6D, 1.0D)); // 下面三个AI是唤魔者特有的施法AI,笔者将在1.2.3.2.3中分析它们 goalSelector.addGoal(4, new Evoker.EvokerSummonSpellGoal()); goalSelector.addGoal(5, new Evoker.EvokerAttackSpellGoal()); goalSelector.addGoal(6, new Evoker.EvokerWololoSpellGoal()); goalSelector.addGoal(8, new RandomStrollGoal(this, 0.6D)); goalSelector.addGoal(9, new LookAtPlayerGoal(this, Player.class, 3.0F, 1.0F)); goalSelector.addGoal(10, new LookAtPlayerGoal(this, Mob.class, 8.0F)); targetSelector.addGoal(1, new HurtByTargetGoal(this, Raider.class).setAlertOthers()); targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true).setUnseenMemoryTicks(300)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, AbstractVillager.class, false).setUnseenMemoryTicks(300)); targetSelector.addGoal(3, new NearestAttackableTargetGoal<>(this, IronGolem.class, false)); } 可以发现AvoidEntityGoal(这个AI在1.2.2.5.1中提到过)的walkSpeedModifier以及RandomStrollGoal的speedModifier都只有0.6,也就是说唤魔者许多时候只会以60%的移动速度移动,因此唤魔者看上去移动得并不快(尽管唤魔者试图逃离近战的玩家时可以全速逃跑)。 一个需要关注的方法是isAlliedTo方法。 @Override public boolean isAlliedTo(Entity entity) { if (entity == null) { return false; } else if (entity == this) { return true; } else if (super.isAlliedTo(entity)) { return true; } else if (entity instanceof Vex) { return isAlliedTo(((Vex) entity).getOwner()); } else if (entity instanceof LivingEntity && ((LivingEntity) entity).getMobType() == MobType.ILLAGER) { // 当唤魔者与另一灾厄村民都没有所属的队伍时,它们是“一伙的” return getTeam() == null && entity.getTeam() == null; } else { return false; } } 由于唤魔者尖牙不应该伤害到与召唤它们的唤魔者属于同一阵营的生物【例如自己召唤的恼鬼、其他灾厄村民(仅在唤魔者没有所属队伍时适用)】,需要在Evoker类中重写isAlliedTo方法并在EvokerFangs类中对攻击到的生物进行isAlliedTo的判断。 Evoker类中的其他方法为wololoTarget的getter和setter,决定音效的方法(如getCelebrateSound等方法),还有一些笔者认为没有太大意义的方法重写(如protected void defineSynchedData() { super.defineSynchedData(); }),此处就不展开了。 下面两节将会介绍唤魔者特有的施法AI,第一节介绍基本框架,第二节介绍具体逻辑。 "},"part1-monster/ranged-special/evoker/spellcaster_ai.html":{"url":"part1-monster/ranged-special/evoker/spellcaster_ai.html","title":"施法AI的框架","keywords":"","body":"施法AI的框架 观察唤魔者和幻术师的施法AI可以发现它们都有共同的父类SpellcasterUseSpellGoal。 class EvokerAttackSpellGoal extends SpellcasterIllager.SpellcasterUseSpellGoal { /*...*/ } class EvokerSummonSpellGoal extends SpellcasterIllager.SpellcasterUseSpellGoal { /*...*/ } public class EvokerWololoSpellGoal extends SpellcasterIllager.SpellcasterUseSpellGoal { /*...*/ } class IllusionerBlindnessSpellGoal extends SpellcasterIllager.SpellcasterUseSpellGoal { /*...*/ } class IllusionerMirrorSpellGoal extends SpellcasterIllager.SpellcasterUseSpellGoal { /*...*/ } 在进一步介绍施法AI的框架前,有必要先简单说说SpellcasterIllager。SpellcasterIllager继承了AbstractIllager,是所有原版的施法类灾厄村民类的父类。 注意如果想要实现一个新的拥有自定义法术的施法类灾厄村民,不应该直接继承这个类,而是应该继承AbstractIllager,然后可以根据这个类的代码自己重新实现。具体原因本文最后会提到。 // 此EntityDataAccessor代表的值表示目前施放的法术的ID,用于客户端获取目前施放的法术 private static final EntityDataAccessor DATA_SPELL_CASTING_ID = SynchedEntityData.defineId(SpellcasterIllager.class, EntityDataSerializers.BYTE); // 剩余的施法时间(单位:tick),也就是到该灾厄村民放下手并停止放出粒子效果所剩余的时间。若该灾厄村民不在施法则值为0 protected int spellCastingTickCount; // 目前施放的法术 private SpellcasterIllager.IllagerSpell currentSpell = IllagerSpell.NONE; 其中IllagerSpell是一个SpellcasterIllager内部的枚举类,列出了原版所有灾厄村民会使用的法术及法术的ID与颜色。由于枚举类的特性,模组开发者是无法添加新的IllagerSpell的。 protected static enum IllagerSpell { // 这是个dummy value,并不是个法术 NONE(0, 0.0D, 0.0D, 0.0D), // 唤魔者召唤恼鬼的法术 SUMMON_VEX(1, 0.7D, 0.7D, 0.8D), // 唤魔者召唤唤魔者尖牙的法术 FANGS(2, 0.4D, 0.3D, 0.35D), // 唤魔者把蓝色绵羊变成红色的法术 WOLOLO(3, 0.7D, 0.5D, 0.2D), // 幻术师隐身并召唤分身的法术 DISAPPEAR(4, 0.3D, 0.3D, 0.8D), // 幻术师使攻击目标失明的法术 BLINDNESS(5, 0.1D, 0.1D, 0.2D); private static final IntFunction BY_ID = ByIdMap.continuous(illagerSpell -> { return illagerSpell.id; }, values(), ByIdMap.OutOfBoundsStrategy.ZERO); final int id; final double[] spellColor; private IllagerSpell(int id, double r, double g, double b) { this.id = id; this.spellColor = new double[]{r, g, b}; } // byId返回id对应的法术值,根据BY_ID这一IntFunction的处理方式(OutOfBoundsStrategy.ZERO), // 如果传入的id为负或者数值大于等于法术的种类数(也就是越界),那么该方法的返回值是0 public static IllagerSpell byId(int id) { return BY_ID.apply(id); } } 数据保存部分则保存了spellCastingTickCount的值。 @Override public void readAdditionalSaveData(CompoundTag tag) { super.readAdditionalSaveData(tag); spellCastingTickCount = tag.getInt(\"SpellTicks\"); } @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); tag.putInt(\"SpellTicks\", spellCastingTickCount); } 下面是4个与法术有关的方法。这些方法中主要用于控制灾厄村民的模型与渲染,在施法AI中也有被用到。 public boolean isCastingSpell() { // 注意客户端一定要用entityData获取同步过的值 if (level().isClientSide) { return entityData.get(DATA_SPELL_CASTING_ID) > 0; } else { return spellCastingTickCount > 0; } } public void setIsCastingSpell(IllagerSpell currentSpell) { this.currentSpell = currentSpell; entityData.set(DATA_SPELL_CASTING_ID, (byte) currentSpell.id); } protected SpellcasterIllager.IllagerSpell getCurrentSpell() { return !level().isClientSide ? currentSpell : IllagerSpell.byId(entityData.get(DATA_SPELL_CASTING_ID)); } protected int getSpellCastingTime() { return spellCastingTickCount; } 接下来是实体更新部分。这一部分中重写customServerAiStep方法实现了每刻更新一次spellCastingTickCount,而重写tick方法用于给灾厄村民添加施法的粒子效果。 @Override protected void customServerAiStep() { super.customServerAiStep(); if (spellCastingTickCount > 0) { --spellCastingTickCount; } } @Override public void tick() { super.tick(); if (level().isClientSide && isCastingSpell()) { IllagerSpell spell = getCurrentSpell(); double r = spell.spellColor[0]; double g = spell.spellColor[1]; double b = spell.spellColor[2]; float particleSpawningAngle = yBodyRot * ((float) Math.PI / 180F) // 首先进行了角度到弧度的转换,把yBodyRot转化成弧度 + Mth.cos((float) tickCount * 0.6662F) * 0.25F; // 再给这个值加上一个周期性的小幅偏移 float particleXOffs = Mth.cos(particleSpawningAngle); float particleZOffs = Mth.sin(particleSpawningAngle); level().addParticle(ParticleTypes.ENTITY_EFFECT, getX() + (double) particleXOffs * 0.6D, getY() + 1.8D, getZ() + (double) particleZOffs * 0.6D, r, g, b); level().addParticle(ParticleTypes.ENTITY_EFFECT, getX() - (double) particleXOffs * 0.6D, getY() + 1.8D, getZ() - (double) particleZOffs * 0.6D, r, g, b); } } SpellcasterIllager还重写了getArmPose方法,用来给正在施法中的灾厄村民应用正确的施法的手臂动作,同时使TA们在袭击获胜(对于玩家而言即为失败)时有正确的庆祝动作。 @Override public IllagerArmPose getArmPose() { if (isCastingSpell()) { return IllagerArmPose.SPELLCASTING; } else { return isCelebrating() ? IllagerArmPose.CELEBRATING : IllagerArmPose.CROSSED; } } SpellcasterIllager有两个非静态的成员内部类,分别是控制施法时灾厄村民运动的AI(SpellcasterCastingSpellGoal)及施法的AI(SpellcasterUseSpellGoal)。 控制施法时灾厄村民运动的AI很简单,这个AI使灾厄村民施法时停止移动,并且看向攻击目标。 protected class SpellcasterCastingSpellGoal extends Goal { public SpellcasterCastingSpellGoal() { setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); } @Override public boolean canUse() { return getSpellCastingTime() > 0; } @Override public void start() { super.start(); navigation.stop(); } @Override public void stop() { super.stop(); setIsCastingSpell(IllagerSpell.NONE); } @Override public void tick() { if (getTarget() != null) { getLookControl().setLookAt(getTarget(), (float) getMaxHeadYRot(), (float) getMaxHeadXRot()); } } } Evoker中重写了这个类并做了小的改动,使唤魔者可以看向正因其施法而改变颜色的绵羊。 class EvokerCastingSpellGoal extends SpellcasterIllager.SpellcasterCastingSpellGoal { public void tick() { if (getTarget() != null) { getLookControl().setLookAt(getTarget(), (float) getMaxHeadYRot(), (float) getMaxHeadXRot()); } else if (getWololoTarget() != null) { getLookControl().setLookAt(getWololoTarget(), (float) getMaxHeadYRot(), (float) getMaxHeadXRot()); } } } 接下来说说施法AI吧。 首先看这两个成员变量,它们各自调控了法术的准备与冷却时间。 protected abstract class SpellcasterUseSpellGoal extends Goal { // 施法所剩余的准备时间,也就是到该灾厄村民真正施放法术所剩余的时间(单位:tick) // 而所有法术都是在attackWarmupDelay为0的“一瞬间”才真正被施放的 protected int attackWarmupDelay; // 这是个时间戳,当灾厄村民的tickCount大于等于该值时灾厄村民就可以施放该法术(tickCount每游戏刻都会自增) protected int nextAttackTickCount; // 不难发现施法AI是没有Flag的,也就是说它们不会与SpellcasterCastingSpellGoal等其他AI冲突 // 该AI的剩余部分暂时省略 } 注意区分attackWarmupDelay与spellCastingTickCount,前者指到放下手并停止放出粒子效果所剩余的时间,后者指到真正施放法术所剩余的时间,因此attackWarmupDelay应该总是小于等于spellCastingTickCount。 然后是“可用性”。当攻击目标存在且存活,同时法术不处于冷却状态时,施法AI可以被使用。而当攻击目标存在且存活,同时施法未结束时,施法AI可以被继续使用。 @Override public boolean canUse() { LivingEntity target = getTarget(); if (target != null && target.isAlive()) { if (isCastingSpell()) { return false; } else { return tickCount >= nextAttackTickCount; } } else { return false; } } @Override public boolean canContinueToUse() { LivingEntity target = getTarget(); return target != null && target.isAlive() && attackWarmupDelay > 0; } start方法主要初始化了部分数值,并播放了准备施法的音效。其中出现的抽象方法马上会一起说。 @Override public void start() { // 这个AI并不requiresUpdateEveryTick,因此要adjustedTickDelay attackWarmupDelay = adjustedTickDelay(getCastWarmupTime()); spellCastingTickCount = getCastingTime(); nextAttackTickCount = tickCount + getCastingInterval(); SoundEvent spellPrepareSound = getSpellPrepareSound(); if (spellPrepareSound != null) { playSound(spellPrepareSound, 1.0F, 1.0F); } setIsCastingSpell(getSpell()); } AI的更新比较简单。当施法所剩余的准备时间为0时会播放施法音效并应用法术效果。 @Override public void tick() { --attackWarmupDelay; if (attackWarmupDelay == 0) { performSpellCasting(); playSound(getCastingSoundEvent(), 1.0F, 1.0F); } } 最后是一些抽象方法。 // 决定施法的效果,也就是灾厄村民施法时会干什么。这个方法是施法AI中最核心的部分 protected abstract void performSpellCasting(); // 返回施法所需要的准备时间(单位:tick),也就是灾厄村民举起手并开始放出粒子效果,到真正施放法术所需的时间 protected int getCastWarmupTime() { return 20; } // 返回施法的(总)时间(单位:tick),也就是灾厄村民举起手并开始放出粒子效果,到放下手并停止放出粒子效果所需的时间 // 注意要让getCastWarmupTime小于等于getCastingTime protected abstract int getCastingTime(); // 返回施法的冷却时间(单位:tick),即两次施放同一法术的最短间隔时间 protected abstract int getCastingInterval(); // 返回准备施法的音效,返回null则说明无准备音效 @Nullable protected abstract SoundEvent getSpellPrepareSound(); // 返回该AI所代表的法术,也就是这是哪一种法术的AI protected abstract IllagerSpell getSpell(); 看到这里,读者也应该大致知道为什么实现一个新的施法类灾厄村民不应该直接继承SpellcasterIllager了吧。这是因为IllagerSpell是枚举类,我们无法添加新的IllagerSpell,而IllagerSpell在继承了SpellcasterIllager的施法类灾厄村民中是非常重要的,比如它决定了施法时的粒子效果的颜色,我们也必须重写SpellcasterUseSpellGoal中的getSpell方法并使其返回一个非空的值。因此,直接继承SpellcasterIllager无法实现新的拥有自定义法术的施法类灾厄村民,我们“从头开始”的时候也要自己写一个功能与SpellcasterUseSpellGoal类似的施法AI。 本节的内容就是这么多了,下一节将会介绍唤魔者的施法AI的具体实现逻辑。 "},"part1-monster/ranged-special/evoker/evoker2.html":{"url":"part1-monster/ranged-special/evoker/evoker2.html","title":"唤魔者的三大魔法","keywords":"","body":"唤魔者的三大魔法 唤魔者会使用的法术共分为3种,可以用以下的表格来简单概括: 施法AI类名 作用 优先级 准备时间 总用时 冷却时间 EvokerSummonSpellGoal 召唤恼鬼 4 20 40 340 EvokerAttackSpellGoal 召唤唤魔者尖牙 5 20 100 100 EvokerWololoSpellGoal 把蓝色绵羊变成红色 6 40 60 140 注: 上表中所有时间的单位均为tick 优先级:数字越低,优先级越高,当多个AI冷却完毕时,高优先级的AI优先执行 准备时间:即getCastWarmupTime返回值,原版中该方法总是返回常数 总用时:即getCastingTime返回值,原版中该方法总是返回常数 冷却时间:即getCastingInterval返回值,原版中该方法总是返回常数 上一节中讲过SpellcasterUseSpellGoal,其中的canUse方法保证施法AI之间是互斥的,因此同一时刻只会执行一个施法AI,且优先级高的施法AI先执行。 接下来具体分析每一种AI时,为了避免文章冗余,笔者将会只分析除getCastWarmupTime、getCastingTime与getCastingInterval外的,对施法AI的底层实现有重要意义的方法。 召唤恼鬼(EvokerSummonSpellGoal) 这是个“很不受玩家喜欢”的法术,但是这也是底层逻辑十分简单的法术~ 唤魔者只会在自身碰撞箱向6个方向扩展16格后形成的长方体内恼鬼数量小于8时召唤恼鬼,且四周的恼鬼越多,使用该法术的概率越小。 @Override public boolean canUse() { if (!super.canUse()) { return false; } else { int i = level().getNearbyEntities(Vex.class, vexCountTargeting, Evoker.this, getBoundingBox().inflate(16.0D)).size(); return random.nextInt(8) + 1 > i; } } 然后是performSpellCasting。该方法会在唤魔者四周固定生成3只恼鬼。 @Override protected void performSpellCasting() { ServerLevel serverLevel = (ServerLevel) level(); for (int i = 0; i 把蓝色绵羊变成红色(EvokerWololoSpellGoal) 这个法术本身也很简单,只是canUse比较复杂而已。 @Override public boolean canUse() { // 显然唤魔者在战斗的时候没有闲心调教绵羊 if (getTarget() != null) { return false; } else if (isCastingSpell()) { return false; } // 这个AI彻底重写了canUse方法,所以要重新判断法术是否在冷却 else if (tickCount list = level().getNearbyEntities(Sheep.class, wololoTargeting, Evoker.this, getBoundingBox().inflate(16.0D, 4.0D, 16.0D)); if (list.isEmpty()) { return false; } else { // wololoTarget和target是独立的,因此不能替换为setTarget setWololoTarget(list.get(random.nextInt(list.size()))); return true; } } } @Override public boolean canContinueToUse() { return getWololoTarget() != null && attackWarmupDelay > 0; } performSpellCasting中简单地改变了绵羊的颜色。 @Override protected void performSpellCasting() { Sheep sheep = Evoker.this.getWololoTarget(); if (sheep != null && sheep.isAlive()) { sheep.setColor(DyeColor.RED); } } 最后不要忘了重置wololoTarget。 @Override public void stop() { super.stop(); Evoker.this.setWololoTarget((Sheep)null); } 召唤唤魔者尖牙(EvokerAttackSpellGoal) 这看上去是唤魔者使用的最朴素的法术,可其实这个法术在“代码意义”上才是最复杂的法术。复杂就复杂在尖牙的位置的确定。 先考虑单个水平位置(x与z)已被确定的尖牙。尖牙的y坐标是如何确定的呢?读者可能会认为,使用地面的y坐标值不就行了吗?但问题是哪里才是所谓的“地面”呢? 回顾1.2.1.3.2中提到的找y坐标的方式:用MutableBlockPos调整或通过Heightmap获取。如果借助高度图(Heightmap)获取,显然因为这种方式因为通过获取水平位置上固体方块的最大y坐标值来确定坐标,所以在室内环境等环境下并不适用。 所以只能采用前一种方式,不断检查尖牙生成位置并以此调整y坐标。原版的实现中没有使用MutableBlockPos,但是思想是一样的。原版对一定y坐标范围内的方块进行了检查,如果检测到合适的可生成尖牙的位置就生成尖牙。 // warmupDelayTicks是尖牙出现的额外的延迟时间,算上施法本身的准备时间,在tps为20的情况下,一个warmupDelayTicks为5的尖牙会在从唤魔者举起手开始1.25s后出现 private void createSpellEntity(double x, double z, double minY, double maxY, float rotation, int warmupDelayTicks) { BlockPos pos = BlockPos.containing(x, maxY, z); boolean foundSuitablePos = false; double dy = 0.0D; do { BlockPos belowPos = pos.below(); BlockState belowState = level().getBlockState(belowPos); // 检查该位置下方的方块是否能够为该位置的方块提供SupportType.FULL,用于检查该位置方块是否“不透明”。 if (belowState.isFaceSturdy(level(), belowPos, Direction.UP)) { if (!level().isEmptyBlock(pos)) { BlockState state = level().getBlockState(pos); VoxelShape shape = state.getCollisionShape(level(), pos); if (!shape.isEmpty()) { dy = shape.max(Direction.Axis.Y); } } foundSuitablePos = true; break; } pos = pos.below(); } while (pos.getY() >= Mth.floor(minY) - 1); if (foundSuitablePos) { level().addFreshEntity(new EvokerFangs(level(), x, (double) pos.getY() + dy, z, rotation, warmupDelayTicks, Evoker.this)); } } 附上Wiki的描述:尖牙只会生成在不低于位置最低的目标的脚、不高于位置最高的目标的脚上方1格的位置。尖牙会尝试在这一区域的不透明方块上生成,但当这个方块被固体方块挡住时会生成失败。这意味着召唤的尖牙无法生成在深坑里或高墙顶,但如果唤魔者在深坑的底部而目标在上方,它会试图走到顶部以发动攻击,反之亦然。 然后考虑尖牙水平位置的确定。水平位置的确定则比较简单,只要进行一些三角函数的运算就可以了。 下图展示了唤魔者尖牙的水平位置的大致确定方式。 以及相关的代码。 @Override protected void performSpellCasting() { LivingEntity target = getTarget(); double minY = Math.min(target.getY(), getY()); double maxY = Math.max(target.getY(), getY()) + 1.0D; float facingAngle = (float) Mth.atan2(target.getZ() - getZ(), target.getX() - getX()); // 对距离较近的目标:召唤两圈尖牙 if (distanceToSqr(target) 那么唤魔者的逻辑部分到这里就结束了,接下来则是唤魔者的渲染部分。 "},"part1-monster/ranged-special/evoker/illager_arm_pose.html":{"url":"part1-monster/ranged-special/evoker/illager_arm_pose.html","title":"(WIP) 灾厄村民的手部动作","keywords":"","body":"灾厄村民的手部动作 "},"part1-monster/ranged-special/evoker/evoker3.html":{"url":"part1-monster/ranged-special/evoker/evoker3.html","title":"(WIP) 唤魔者的渲染","keywords":"","body":"唤魔者的渲染 "},"part1-monster/flyer-special/":{"url":"part1-monster/flyer-special/","title":"(WIP) 飞行的怪物","keywords":"","body":"飞行的怪物(不用FlyingMoveControl的飞行生物) "},"part1-monster/exercises/":{"url":"part1-monster/exercises/","title":"本章练习","keywords":"","body":"本章综合练习 与“实战”部分不同,本部分既不会提供非常详细的要求(体现在要求个数减少,要求覆盖面变小,要求正文部分不加粗等等),也不会专门提供代码,整体上类似实战中的“思考与练习”部分。以后所有的综合练习除特殊说明外都不是必须全部完成的,感兴趣的读者可以尝试实现这些综合练习中提到的实体。 "},"part1-monster/exercises/exercise1.html":{"url":"part1-monster/exercises/exercise1.html","title":"练习1 - 狗头人","keywords":"","body":"练习1 - 狗头人 注:“狗头人”是Kobold的新译名,这个实体的译名曾经是“哥布林” 任务 尝试复刻暮色森林中的狗头人 要求 狗头人的生命值是13,攻击力是4 狗头人是一种敌对生物,会主动攻击玩家,但不会主动攻击铁傀儡 如果目睹同伴的死亡,狗头人会受到惊吓四处乱走,头上会出现水的颗粒,但很快会回来为同伴报仇 在没有对任何实体产生敌意的条件下,狗头人会主动捡拾地上的面包吃,在吃完面包之前都会主动避开玩家 提示 攻击玩家的AI需要继承NearestAttackableTargetGoal并重写canUse方法,以防狗头人在吃面包时攻击玩家 可以通过判断entity.deathTime的值是否大于0来判断一个实体是否在死亡过程中 ItemEntity是掉落在地上的实体形式的物品的实体类 可以自由发挥,不一定要写得和暮色森林中的狗头人一模一样 狗头人的材质、模型比较复杂,除非是为了练习美工,否则复刻狗头人时可以不复刻这些东西 1.21的Kobold类源代码 "},"part2-neutral/":{"url":"part2-neutral/","title":"(WIP) 友好生物与被动生物","keywords":"","body":"友好生物与被动生物 "},"part3-projectile/":{"url":"part3-projectile/","title":"(WIP) 弹射物和功能性实体","keywords":"","body":"弹射物和功能性实体 "},"part4-boss/":{"url":"part4-boss/","title":"(WIP) Boss级怪物","keywords":"","body":"Boss级怪物 "},"part4-boss/wither/":{"url":"part4-boss/wither/","title":"(WIP) 凋灵","keywords":"","body":"凋灵 "},"part4-boss/ender_dragon/":{"url":"part4-boss/ender_dragon/","title":"(WIP) 末影龙","keywords":"","body":"末影龙 在原版世界中,末影龙是驻守在末路之地的一个强大的龙形生物,也是原版Minecraft的最终boss,不少玩家将击杀末影龙作为通关MC的标准。末影龙的底层实现也相当复杂,它不仅使用了一套独立的AI系统,寻路的算法也与一般的生物有一定的区别。 末影龙比凋灵复杂得多,我们分许多块分析吧。下面是我之前围绕末影龙核心行为写的文章和视频讲解,可供参考。 末影龙核心行为的视频讲解 注: 视频讲解的受众是普通玩家,力求通俗易懂,所以很多地方不够严谨 末影龙核心行为的文章讲解 末影龙核心行为的底层实现的简要分析 "},"part5-miscellaneous/":{"url":"part5-miscellaneous/","title":"杂项","keywords":"","body":"杂项 笔者会在这儿放一些实用技巧一类的东西,这章随缘更新吧~ "},"part5-miscellaneous/summoning.html":{"url":"part5-miscellaneous/summoning.html","title":"实体的召唤","keywords":"","body":"常见实体的召唤 本节将分类说明各种常见的实体的召唤方式。 注:下文中把实体类中除以EntityType和Level为参数的构造方法外的所有构造方法称为“特殊构造方法”。注意如果重载了任一不含EntityType参数的构造方法,或者继承了所有的构造方法都不含EntityType参数的实体类,则应该重写getType、getDimensions、causeFallDamage等一切使用到Entity类中的成员常量type的方法。这是因为如果重载/继承会导致实体的type与实体的真实EntityType不一致,需要特殊处理所有用到type的地方以防止实体出现意外的问题。 框架 流程 实体的召唤分为实例化、预处理和正式添加3个部分(后两部分顺序可以互换,甚至可以把“正式添加”部分夹在“预处理”中)。 假设有一种实体名叫Polonium,其对应的实体类型(EntityType)为POLONIUM。 实例化 这是实例化部分的代码示例: Polonium polonium = new Polonium(...); 这一部分实例化了一个新的实体类的对象,它代表着未来将要添加到世界中的实体。 预处理 这是预处理部分的代码示例: // 把实体移动到(10, 64, -25)处 polonium.moveTo(10.0, 64.0, -25.0); // 为Mob(非玩家生物)的最终生成做最后的调整 ForgeEventFactory.onFinalizeSpawn(polonium, level, level.getCurrentDifficultyAt(polonium.blockPosition()), MobSpawnType.MOB_SUMMONED, null, null); 这一部分不是所有实体都需要的,对于许多非生物实体(如弹射物),往往调用它们的特殊构造方法实例化新对象时就进行了预处理,所以无需额外的预处理部分。 正式添加 这是正式添加部分的代码示例: level.addFreshEntity(polonium); 用addFreshEntity把实体添加到世界中时,需要注意实体UUID的唯一性(在分析雪傀儡投掷雪球的方式时提到过这一点,具体的代码举例可以看1.2.2.2.1的相关内容)。 setPowRaw、setPos、moveTo、teleportTo和randomTeleport的区别 在召唤实体的过程中,几乎所有情况下都需要改变召唤的实体的位置。小标题中的5个方法都是更改实体位置的方法,那么它们之间有什么区别呢? setPowRaw setPosRaw是最底层的,用于改变且仅改变实体的坐标。在实体的坐标中,position表示实体碰撞箱底面中心的坐标,而blockPosition和chunkPosition表示position所在的方块坐标和区块坐标。 public final void setPosRaw(double x, double y, double z) { // 更新position, blockPosition和chunkPosition // position改变是blockPosition改变的必要不充分条件,而blockPosition改变是chunkPosition改变的必要不充分条件,因此从position开始依次更新坐标。 // 这几层逻辑关系也很容易理解,因为从position到chunkPosition是由“精确”位置到“粗略”位置的变化。 if (position.x != x || position.y != y || position.z != z) { position = new Vec3(x, y, z); int x1 = Mth.floor(x); int y1 = Mth.floor(y); int z1 = Mth.floor(z); if (x1 != blockPosition.getX() || y1 != blockPosition.getY() || z1 != blockPosition.getZ()) { blockPosition = new BlockPos(x1, y1, z1); feetBlockState = null; if (SectionPos.blockToSectionCoord(x1) != chunkPosition.x || SectionPos.blockToSectionCoord(z1) != chunkPosition.z) { chunkPosition = new ChunkPos(blockPosition); } } // 这里用于更新实体的section(section是世界中16x16x16的区域) levelCallback.onMove(); } if (isAddedToWorld() && !level.isClientSide && !isRemoved()) { level.getChunk((int) Math.floor(x) >> 4, (int) Math.floor(z) >> 4); // Forge - ensure target chunk is loaded. } } 但实际应用中,往往还需要改变实体的碰撞箱、朝向等,因此有了后面的4个方法。 setPos setPos除了调用了setPosRaw外,setPos还为实体重新设置了碰撞箱。 public void setPos(double x, double y, double z) { setPosRaw(x, y, z); setBoundingBox(makeBoundingBox()); } moveTo moveTo在setPos的基础上还更新了实体的朝向和一系列旧坐标值(把旧坐标值改为与新坐标值一致)。更改旧坐标值是为了防止旧坐标值与新坐标值差异过大造成渲染问题。 public void moveTo(double x, double y, double z, float yRot, float xRot) { setPosRaw(x, y, z); setYRot(yRot); setXRot(xRot); setOldPosAndRot(); // 更新实体的旧坐标和旧的旋转角度,旧坐标和旧的旋转角度常用于平滑实体的渲染 reapplyPosition(); } protected void reapplyPosition() { setPos(position.x, position.y, position.z); } moveTo还提供了许多方便的重载方法,以更方便地使用。 teleportTo teleportTo仅在服务端执行,调用时会一并对实体的骑乘者(passenger/rider)执行moveTo的操作。 public void teleportTo(double x, double y, double z) { if (level() instanceof ServerLevel) { moveTo(x, y, z, getYRot(), getXRot()); teleportPassengers(); } } private void teleportPassengers() { getSelfAndPassengers().forEach(entity -> { for (Entity entity : entity.passengers) { entity.positionRider(entity, Entity::moveTo); } }); } randomTeleport randomTeleport是LivingEntity中的方法,与teleportTo的区别在于对传送点的y坐标进行了调整,以确保生物落在固体方块上。注意这个方法中没有任何随机生成坐标的过程,因此传送点需要自己提前随机生成好。 randomTeleport有返回值,返回true则说明找到了合适的目标位置。 注:1.2.1.3.2中也提到了randomTeleport,其中说到笔者认为在末影人瞬移的情境下调整y坐标的地方可以省略,但是如果单独调用此方法而不像末影人一样在randomTeleport前就确定好了调整过的传送点,那么该方法内调整y坐标的地方则不可缺少 public boolean randomTeleport(double randomX, double randomY, double randomZ, boolean showParticles) { double x = getX(); double y = getY(); double z = getZ(); double finalY = randomY; boolean success = false; BlockPos targetPos = BlockPos.containing(randomX, randomY, randomZ); Level level = level(); if (level.hasChunkAt(targetPos)) { boolean foundSolid = false; while (!foundSolid && targetPos.getY() > level.getMinBuildHeight()) { BlockPos below = targetPos.below(); BlockState belowBlockState = level.getBlockState(below); if (belowBlockState.blocksMotion()) { foundSolid = true; } else { --finalY; targetPos = below; } } if (foundSolid) { teleportTo(randomX, finalY, randomZ); if (level.noCollision(this) && !level.containsAnyLiquid(getBoundingBox())) { success = true; } } } if (!success) { teleportTo(x, y, z); return false; } else { if (showParticles) { // 广播46号实体事件会生成大量传送粒子效果 level.broadcastEntityEvent(this, (byte) 46); } if (this instanceof PathfinderMob) { ((PathfinderMob) this).getNavigation().stop(); } return true; } } 总结 一般召唤实体的过程中,对生物用moveTo,而对非生物用setPos调整位置就行,当然有特殊需求的话也可以采取别的方式。还有一些其他的更改实体位置的方法用处相对较少,这儿就暂时略去不说了。 非生物的召唤 非生物实体的召唤相对简单,许多非生物实体都有特殊构造方法,以方便我们实例化该实体。 ThrowableProjectile与AbstractArrow(大多数受重力影响的弹射物) ThrowableProjectile的实现类通常有两个特殊构造方法。其中一个构造方法含有1个LivingEntity参数,另一个含有3个double参数。 ThrowableProjectile类: protected ThrowableProjectile(EntityType type, double x, double y, double z, Level level) { this(type, level); setPos(x, y, z); } protected ThrowableProjectile(EntityType type, LivingEntity owner, Level level) { this(type, owner.getX(), owner.getEyeY() - (double) 0.1F, owner.getZ(), level); setOwner(owner); } 对于含有LivingEntity参数的构造方法,调用后会将弹射物的所有者设置为传入的生物,并把弹射物移动到此生物眼睛坐标下0.1格处。 ThrowableProjectile throwableProjectile = new ThrowableProjectileImpl(level, owner); throwableProjectile.shoot(dx, dy, dz, scale, deviation); level.addFreshEntity(throwableProjectile); 而对于含有3个double参数的构造方法,调用后会把弹射物移动到这3个double参数组成的坐标上。 ThrowableProjectile throwableProjectile = new ThrowableProjectileImpl(level, x, y, z); throwableProjectile.shoot(dx, dy, dz, scale, deviation); level.addFreshEntity(throwableProjectile); 一般ThrowableProjectile由某一特定生物射出时用含有LivingEntity参数的构造方法来实例化(LivingEntity参数传入射出弹射物的生物),而不由生物射出的弹射物则用含有3个double参数的构造方法来实例化。 所有的箭(AbstractArrow)虽然都没有继承ThrowableProjectile,但是箭的召唤方式与ThrowableProjectile几乎完全一样。不过箭有很多变种,因此还需要注意往往要对某些生物发射出的箭进行特殊处理。 举骷髅射箭为例(下文中删去了坐标计算、音效播放部分的代码。另见1.2.2.5.1)。其中getProjectile、getArrow和customArrow就是这样的特殊处理。 ItemStack projectile = getProjectile(getItemInHand(ProjectileUtil.getWeaponHoldingHand(this, item -> item instanceof BowItem))); AbstractArrow arrow = getArrow(projectile, power); if (getMainHandItem().getItem() instanceof BowItem) { arrow = ((BowItem) getMainHandItem().getItem()).customArrow(arrow); } arrow.shoot(dx, dy + distance * (double) 0.2F, dz, 1.6F, (float) (14 - level().getDifficulty().getId() * 4)); level().addFreshEntity(arrow); AbstractHurtingProjectile(大多数不受重力影响的弹射物) AbstractHurtingProjectile的实现类通常也有两个特殊构造方法。其中一个构造方法含有1个LivingEntity参数和3个double参数,另一个含有6个double参数。 AbstractHurtingProjectile类: public AbstractHurtingProjectile(EntityType type, double x, double y, double z, double targetX, double targetY, double targetZ, Level level) { this(type, level); moveTo(x, y, z, getYRot(), getXRot()); reapplyPosition(); double targetDistance = Math.sqrt(targetX * targetX + targetY * targetY + targetZ * targetZ); if (targetDistance != 0.0D) { this.xPower = targetX / targetDistance * 0.1D; this.yPower = targetY / targetDistance * 0.1D; this.zPower = targetZ / targetDistance * 0.1D; } } public AbstractHurtingProjectile(EntityType type, LivingEntity owner, double targetX, double targetY, double targetZ, Level level) { this(type, owner.getX(), owner.getY(), owner.getZ(), targetX, targetY, targetZ, level); setOwner(owner); setRot(owner.getYRot(), owner.getXRot()); } 对于含有LivingEntity参数和double参数的构造方法,调用后会将弹射物的所有者设置为传入的生物,把弹射物移动到此生物的底面中心处,并以传入的3个double参数组成的向量为弹射物的发射方向。 AbstractHurtingProjectile hurtingProjectile = new AbstractHurtingProjectileImpl(level, owner, dx, dy, dz, level); // 此处可以直接发射弹射物,也可以用setPos等方法调整弹射物的初始坐标 level.addFreshEntity(hurtingProjectile); 但由于通常生物不会从自身的底面中心处射出弹射物,所以一般会调整弹射物初始坐标。例如凋灵会将凋灵之首调整到正确的头部上再发射凋灵之首。 WitherSkull skull = new WitherSkull(level(), this, dx, dy, dz); skull.setOwner(this); if (dangerous) { skull.setDangerous(true); } // 此处使用了setPosRaw,而前面说过setPosRaw不会更新碰撞箱,因此会导致凋灵之首的坐标与碰撞箱位置不一致 // 不过由于弹射物每刻更新时都会通过setPos改变自身坐标,所以不会造成大的问题 // 个人认为这里可以改成setPos,而且改成setPos可能更好 skull.setPosRaw(headX, headY, headZ); level().addFreshEntity(skull); 而对于含有6个double参数的构造方法,调用后会把弹射物移动到前3个double参数组成的坐标上,并以后3个double参数组成的向量为弹射物的发射方向。 AbstractHurtingProjectile hurtingProjectile = new AbstractHurtingProjectileImpl(level, x, y, z, dx, dy, dz, level); level.addFreshEntity(hurtingProjectile); 生物的召唤 召唤生物(LivingEntity)往往需要更复杂的预处理,同时大部分生物都没有特殊构造方法。 (WIP) "}}