name: nms-custom-entity description: "建立自定義 NMS 實體:繼承現有 Mob 類別、自訂 PathfinderGoal、替換 vanilla 實體行為 / Create custom NMS entities with custom PathfinderGoal AI"
NMS Custom Entity / NMS 自定義實體
技能名稱 / Skill Name
nms-custom-entity
目的 / Purpose
透過繼承 NMS Mob 類別並覆寫 registerGoals() 加入自訂 PathfinderGoal,實現自定義 AI 行為。適用於客製化 Boss、NPC、守衛等場景。
NMS 版本需求 / NMS Version Requirements
- Paper 1.21 – 1.21.3
- Paperweight userdev 1.7.2+
- 須使用
paper-plugin.yml(確保早於 Bukkit plugin 載入)
觸發條件 / Triggers
- 「自定義實體」「custom entity」「NMS AI」
- 「PathfinderGoal」「自訂 mob」「custom mob」
- 「entity goal」「custom zombie」「replace entity」
輸入參數 / Inputs
| 參數 | 範例 | 說明 |
|---|---|---|
package_name |
com.example.entities |
產出類別所在 package |
entity_class_name |
CustomZombie |
自定義實體類名 |
base_entity |
Zombie |
繼承的 NMS 基類 |
entity_id |
my_custom_zombie |
註冊用 ID(namespaced) |
goal_class_name |
FollowClosestPlayerGoal |
自訂 goal 類名 |
輸出產物 / Outputs
CustomZombie.java— 繼承net.minecraft.world.entity.monster.Zombie的自定義實體FollowClosestPlayerGoal.java— 自訂PathfinderGoal範本EntitySpawner.java— 生成工具類EntityListener.java(選)— 攔截 vanilla spawn 替換為自定義實體
Paperweight 建置設定 / Build Setup
參見 Skills/paper-nms/PLATFORM.md。
代碼範本 / Code Template
CustomZombie.java
package com.example.entities;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.ai.goal.FloatGoal;
import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal;
import net.minecraft.world.entity.ai.goal.MeleeAttackGoal;
import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal;
import net.minecraft.world.entity.ai.goal.target.NearestAttackableTargetGoal;
import net.minecraft.world.entity.monster.Zombie;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
@SuppressWarnings("UnstableApiUsage")
public class CustomZombie extends Zombie {
public CustomZombie(EntityType<? extends Zombie> type, Level level) {
super(type, level);
}
@Override
protected void registerGoals() {
// 游泳(必備)
this.goalSelector.addGoal(0, new FloatGoal(this));
// 自訂:鎖定最近的玩家
this.goalSelector.addGoal(1, new FollowClosestPlayerGoal(this, 1.2D, 32.0D));
// 近戰攻擊(速度倍率 1.0,追擊時不鬆手)
this.goalSelector.addGoal(2, new MeleeAttackGoal(this, 1.0D, false));
// 隨機觀察周圍
this.goalSelector.addGoal(8, new LookAtPlayerGoal(this, Player.class, 8.0F));
this.goalSelector.addGoal(8, new RandomLookAroundGoal(this));
// 目標選擇器:玩家優先
this.targetSelector.addGoal(1, new NearestAttackableTargetGoal<>(this, Player.class, true));
}
/** 自訂屬性:更高的血量與攻擊。 */
public static net.minecraft.world.entity.ai.attributes.AttributeSupplier.Builder createAttributes() {
return Zombie.createAttributes()
.add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH, 40.0D)
.add(net.minecraft.world.entity.ai.attributes.Attributes.ATTACK_DAMAGE, 8.0D)
.add(net.minecraft.world.entity.ai.attributes.Attributes.MOVEMENT_SPEED, 0.3D);
}
}
FollowClosestPlayerGoal.java
package com.example.entities;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.ai.goal.Goal;
import net.minecraft.world.entity.player.Player;
import java.util.EnumSet;
@SuppressWarnings("UnstableApiUsage")
public class FollowClosestPlayerGoal extends Goal {
private final Mob mob;
private final double speed;
private final double range;
private Player target;
public FollowClosestPlayerGoal(Mob mob, double speed, double range) {
this.mob = mob;
this.speed = speed;
this.range = range;
this.setFlags(EnumSet.of(Flag.MOVE));
}
@Override
public boolean canUse() {
this.target = this.mob.level().getNearestPlayer(this.mob, this.range);
return this.target != null && !this.target.isCreative() && !this.target.isSpectator();
}
@Override
public boolean canContinueToUse() {
return this.target != null
&& this.target.isAlive()
&& this.mob.distanceToSqr(this.target) < this.range * this.range;
}
@Override
public void start() {
this.mob.getNavigation().moveTo(this.target, this.speed);
}
@Override
public void stop() {
this.target = null;
this.mob.getNavigation().stop();
}
@Override
public void tick() {
if (this.target == null) return;
this.mob.getLookControl().setLookAt(this.target, 30.0F, 30.0F);
// 每 10 tick 重新規劃路徑
if (this.mob.tickCount % 10 == 0) {
this.mob.getNavigation().moveTo(this.target, this.speed);
}
}
}
EntitySpawner.java
package com.example.entities;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.MobSpawnType;
import org.bukkit.Location;
import org.bukkit.craftbukkit.v1_21_R1.CraftWorld;
@SuppressWarnings("UnstableApiUsage")
public final class EntitySpawner {
private EntitySpawner() {}
/**
* 在指定位置生成自定義殭屍。必須在主執行緒呼叫。
*/
public static org.bukkit.entity.Entity spawnCustomZombie(Location loc) {
ServerLevel level = ((CraftWorld) loc.getWorld()).getHandle();
CustomZombie zombie = new CustomZombie(EntityType.ZOMBIE, level);
zombie.moveTo(loc.getX(), loc.getY(), loc.getZ(), loc.getYaw(), loc.getPitch());
// 設定自訂名稱(顯示在頭上)
zombie.setCustomName(net.minecraft.network.chat.Component.literal("§c自訂殭屍"));
zombie.setCustomNameVisible(true);
level.addFreshEntity(zombie, org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason.CUSTOM);
return zombie.getBukkitEntity();
}
}
推薦目錄結構 / Recommended Directory Structure
src/main/java/com/example/
├── MyNmsPlugin.java
└── entities/
├── CustomZombie.java
├── FollowClosestPlayerGoal.java
├── EntitySpawner.java
└── EntityListener.java
執行緒安全注意事項 / Thread Safety
- ⚠️ 實體建立、
addFreshEntity()、moveTo()必須在主執行緒 - ⚠️
PathfinderGoal.tick()由 NMS 在主執行緒的 tick 迴圈呼叫,勿做耗時操作 - ⚠️ 存取
mob.level()時確認 chunk 已載入 - ✅ 可在 async 預計算路徑資料,但
Navigation.moveTo()必須在主執行緒呼叫 - 詳見
Skills/_shared/nms-threading.md
失敗回退 / Fallback
| 錯誤 | 原因 | 解法 |
|---|---|---|
| 實體不動 | Goal.canUse() 未回傳 true |
加 log 檢查 target 是否被過濾掉 |
| Chunk 卸載後實體消失 | 未標記 persistent | zombie.setPersistenceRequired() |
| 屬性無效 | 未呼叫 createAttributes() 註冊 |
Paper 自定義實體需透過 EntityType.Builder 註冊(或繼承現有 Type) |
| 客戶端顯示空白實體 | Entity type 不存在於客戶端 | 繼承 vanilla 類型(如 Zombie)而非建立新 EntityType |
UnsupportedOperationException 在 registerGoals |
Goal 被重複加入 | 清空 goalSelector 再重建:goalSelector.removeAllGoals(g -> true) |