# Nms Custom Entity

> 建立自定義 NMS 實體：繼承現有 Mob 類別、自訂 PathfinderGoal、替換 vanilla 實體行為 / Create custom NMS entities with custom PathfinderGoal AI

- Skill: `mrpippi/nms-custom-entity` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add mrpippi/nms-custom-entity`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mrpippi/nms-custom-entity/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: MrPippi (https://skillmd.com/u/mrpippi)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mrpippi/nms-custom-entity

---


# 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`

```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`

```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`

```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)` |

