roblox-npc-ai

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Roblox NPC & AI

Roblox NPC 与 AI

Use this skill when creating NPCs, enemies, bosses, or any autonomous character that needs to navigate, detect players, or make decisions.
当你需要创建NPC、敌人、Boss或任何需要导航、检测玩家或做出决策的自主角色时,可以使用本技能。

PathfindingService

PathfindingService

CreatePath Parameters

CreatePath 参数

luau
local PathfindingService = game:GetService("PathfindingService")

local path = PathfindingService:CreatePath({
    AgentRadius = 2,       -- half-width of the NPC (default 2)
    AgentHeight = 5,       -- height of the NPC (default 5)
    AgentCanJump = true,   -- can the agent jump over gaps?
    AgentCanClimb = true,  -- can the agent climb TrussParts?
    WaypointSpacing = 4,   -- studs between waypoints (default 4)
    Costs = {              -- material/region traversal costs
        Water = 20,        -- avoid water (20x more expensive)
        CrackedLava = math.huge, -- never traverse lava
    },
})
All materials have a default cost of 1. Set
math.huge
to make a material completely non-traversable.
luau
local PathfindingService = game:GetService("PathfindingService")

local path = PathfindingService:CreatePath({
    AgentRadius = 2,       -- NPC的半宽(默认值为2)
    AgentHeight = 5,       -- NPC的高度(默认值为5)
    AgentCanJump = true,   -- 智能体能否跳跃过间隙?
    AgentCanClimb = true,  -- 智能体能否攀爬TrussParts?
    WaypointSpacing = 4,   -- 路点之间的距离(单位: studs,默认值为4)
    Costs = {              -- 材质/区域移动成本
        Water = 20,        -- 避开水域(成本为默认值的20倍)
        CrackedLava = math.huge, -- 绝对不经过熔岩区域
    },
})
所有材质的默认成本为1。设置
math.huge
可使该材质区域完全不可通行。

Computing and Following a Path

路径计算与跟随

From the official Roblox pathfinding docs:
luau
local PathfindingService = game:GetService("PathfindingService")

local path = PathfindingService:CreatePath({
    AgentCanClimb = true,
    Costs = { Water = 20 },
})

local character = script.Parent
local humanoid = character:WaitForChild("Humanoid")

local function followPath(destination: Vector3)
    path:ComputeAsync(humanoid.RootPart.Position, destination)

    if path.Status ~= Enum.PathStatus.Success then
        warn("Path failed:", path.Status)
        return false
    end

    local waypoints = path:GetWaypoints()

    -- Handle path blocked mid-traversal
    local blockedConnection
    blockedConnection = path.Blocked:Connect(function(blockedWaypointIndex)
        blockedConnection:Disconnect()
        -- Recompute from current position
        followPath(destination)
    end)

    for i, waypoint in waypoints do
        if waypoint.Action == Enum.PathWaypointAction.Jump then
            humanoid.Jump = true
        elseif waypoint.Action == Enum.PathWaypointAction.Custom then
            -- Custom action (e.g. open door, use ladder)
            -- Handle based on waypoint.Label
        end

        humanoid:MoveTo(waypoint.Position)
        local reached = humanoid.MoveToFinished:Wait()

        if not reached then
            blockedConnection:Disconnect()
            return false
        end
    end

    blockedConnection:Disconnect()
    return true
end
源自Roblox官方寻路文档:
luau
local PathfindingService = game:GetService("PathfindingService")

local path = PathfindingService:CreatePath({
    AgentCanClimb = true,
    Costs = { Water = 20 },
})

local character = script.Parent
local humanoid = character:WaitForChild("Humanoid")

local function followPath(destination: Vector3)
    path:ComputeAsync(humanoid.RootPart.Position, destination)

    if path.Status ~= Enum.PathStatus.Success then
        warn("路径生成失败:", path.Status)
        return false
    end

    local waypoints = path:GetWaypoints()

    -- 处理路径遍历中途被阻塞的情况
    local blockedConnection
    blockedConnection = path.Blocked:Connect(function(blockedWaypointIndex)
        blockedConnection:Disconnect()
        -- 从当前位置重新计算路径
        followPath(destination)
    end)

    for i, waypoint in waypoints do
        if waypoint.Action == Enum.PathWaypointAction.Jump then
            humanoid.Jump = true
        elseif waypoint.Action == Enum.PathWaypointAction.Custom then
            -- 自定义动作(例如开门、使用梯子)
            -- 根据waypoint.Label进行处理
        end

        humanoid:MoveTo(waypoint.Position)
        local reached = humanoid.MoveToFinished:Wait()

        if not reached then
            blockedConnection:Disconnect()
            return false
        end
    end

    blockedConnection:Disconnect()
    return true
end

Pathfinding Modifiers

寻路修饰符

Control how the pathfinder treats specific regions:
Material costs — make certain terrain materials expensive:
luau
local path = PathfindingService:CreatePath({
    Costs = {
        Water = 20,         -- 20x more expensive than default
        CrackedLava = 100,  -- nearly impassable
        Slate = 20,         -- avoid slate areas
    },
})
Region modifiers — mark arbitrary zones as costly or impassable:
  1. Create an Anchored, CanCollide=false Part around the region
  2. Add a
    PathfindingModifier
    child with a
    Label
    (e.g. "DangerZone")
  3. Reference the label in Costs:
luau
local path = PathfindingService:CreatePath({
    Costs = {
        DangerZone = math.huge, -- completely avoid this region
    },
})
PassThrough — pathfind through solid obstacles (e.g. doors):
  1. Create an Anchored, CanCollide=false Part around the door
  2. Add a
    PathfindingModifier
    with
    PassThrough = true
  3. The path will route through the door as if it's open
PathfindingLink — connect disconnected navmesh areas: Use
PathfindingLink
to tell the pathfinder about custom traversal (teleporters, ziplines, boats). Set a Label and handle it in the waypoint loop via
waypoint.Action == Enum.PathWaypointAction.Custom
.
控制寻路系统对特定区域的处理方式:
材质成本 — 让特定地形材质的移动成本更高:
luau
local path = PathfindingService:CreatePath({
    Costs = {
        Water = 20,         -- 成本为默认值的20倍
        CrackedLava = 100,  -- 几乎无法通行
        Slate = 20,         -- 避开板岩区域
    },
})
区域修饰符 — 将任意区域标记为高成本或不可通行:
  1. 在目标区域周围创建一个锚定、CanCollide=false的Part
  2. 添加一个
    PathfindingModifier
    子对象并设置
    Label
    (例如"DangerZone")
  3. 在Costs中引用该标签:
luau
local path = PathfindingService:CreatePath({
    Costs = {
        DangerZone = math.huge, -- 完全避开该区域
    },
})
PassThrough — 穿过固体障碍物(例如门):
  1. 在门周围创建一个锚定、CanCollide=false的Part
  2. 添加一个
    PathfindingModifier
    并设置
    PassThrough = true
  3. 寻路系统会将门视为开启状态并直接穿过
PathfindingLink — 连接断开的导航网格区域: 使用
PathfindingLink
告知寻路系统自定义移动方式(传送器、滑索、船只)。设置Label后,可在路点循环中通过
waypoint.Action == Enum.PathWaypointAction.Custom
进行处理。

Navigation Mesh

导航网格

The navigation mesh is auto-generated from geometry. Debug it in Studio:
  • View → Visualization Options → Navigation Mesh (shows walkable areas)
  • View → Visualization Options → Pathfinding Modifiers (shows labels)
Colored areas = walkable. Small arrows = jump connections. Uncolored = impassable.
导航网格由场景几何体自动生成。可在Studio中调试:
  • 视图 → 可视化选项 → 导航网格(显示可行走区域)
  • 视图 → 可视化选项 → 寻路修饰符(显示标签)
彩色区域 = 可行走区域。小箭头 = 跳跃连接。无颜色区域 = 不可通行。

State Machine Pattern

状态机模式

The most reliable NPC AI architecture. Each NPC has a current state and transitions based on conditions.
luau
--!strict
type State = "idle" | "patrol" | "chase" | "attack" | "flee" | "dead"

type NPCConfig = {
    detectionRange: number,
    attackRange: number,
    fleeHealthPercent: number,
    patrolPoints: {Vector3},
    attackCooldown: number,
    walkSpeed: number,
    runSpeed: number,
}

type NPCState = {
    current: State,
    target: Player?,
    patrolIndex: number,
    lastAttackTime: number,
    humanoid: Humanoid,
    rootPart: BasePart,
    config: NPCConfig,
}

local function transition(npc: NPCState, newState: State)
    -- Exit current state
    if npc.current == "chase" or npc.current == "patrol" then
        npc.humanoid.WalkSpeed = npc.config.walkSpeed
    end

    -- Enter new state
    npc.current = newState

    if newState == "chase" then
        npc.humanoid.WalkSpeed = npc.config.runSpeed
    elseif newState == "idle" then
        npc.target = nil
    end
end
最可靠的NPC AI架构。每个NPC都有当前状态,并根据条件进行状态转换。
luau
--!strict
type State = "idle" | "patrol" | "chase" | "attack" | "flee" | "dead"

type NPCConfig = {
    detectionRange: number,
    attackRange: number,
    fleeHealthPercent: number,
    patrolPoints: {Vector3},
    attackCooldown: number,
    walkSpeed: number,
    runSpeed: number,
}

type NPCState = {
    current: State,
    target: Player?,
    patrolIndex: number,
    lastAttackTime: number,
    humanoid: Humanoid,
    rootPart: BasePart,
    config: NPCConfig,
}

local function transition(npc: NPCState, newState: State)
    -- 退出当前状态
    if npc.current == "chase" or npc.current == "patrol" then
        npc.humanoid.WalkSpeed = npc.config.walkSpeed
    end

    -- 进入新状态
    npc.current = newState

    if newState == "chase" then
        npc.humanoid.WalkSpeed = npc.config.runSpeed
    elseif newState == "idle" then
        npc.target = nil
    end
end

State Transitions

状态转换规则

idle → patrol (has patrol points)
idle → chase (player detected)
patrol → chase (player detected)
chase → attack (in attack range)
chase → idle (target lost/died)
chase → flee (health low)
attack → chase (target moved out of range)
attack → flee (health low)
flee → idle (safe distance reached)
any → dead (health <= 0)
idle → patrol(存在巡逻点)
idle → chase(检测到玩家)
patrol → chase(检测到玩家)
chase → attack(进入攻击范围)
chase → idle(目标丢失/死亡)
chase → flee(生命值过低)
attack → chase(目标移出攻击范围)
attack → flee(生命值过低)
flee → idle(到达安全距离)
任意状态 → dead(生命值 ≤ 0)

Detection

检测系统

Distance Check (cheapest, do first)

距离检测(成本最低,优先执行)

luau
local Players = game:GetService("Players")

local function findNearestPlayer(position: Vector3, range: number): Player?
    local nearest: Player? = nil
    local nearestDist = range

    for _, player in Players:GetPlayers() do
        local char = player.Character
        if not char then continue end
        local root = char:FindFirstChild("HumanoidRootPart")
        if not root then continue end
        local humanoid = char:FindFirstChildOfClass("Humanoid")
        if not humanoid or humanoid.Health <= 0 then continue end

        local dist = (root.Position - position).Magnitude
        if dist < nearestDist then
            nearest = player
            nearestDist = dist
        end
    end

    return nearest
end
luau
local Players = game:GetService("Players")

local function findNearestPlayer(position: Vector3, range: number): Player?
    local nearest: Player? = nil
    local nearestDist = range

    for _, player in Players:GetPlayers() do
        local char = player.Character
        if not char then continue end
        local root = char:FindFirstChild("HumanoidRootPart")
        if not root then continue end
        local humanoid = char:FindFirstChildOfClass("Humanoid")
        if not humanoid or humanoid.Health <= 0 then continue end

        local dist = (root.Position - position).Magnitude
        if dist < nearestDist then
            nearest = player
            nearestDist = dist
        end
    end

    return nearest
end

Line of Sight (raycast)

视线检测(射线投射)

Only check LOS after distance check passes (raycasts are expensive):
luau
local function hasLineOfSight(from: Vector3, to: Vector3, ignore: {Instance}): boolean
    local direction = to - from
    local params = RaycastParams.new()
    params.FilterDescendantsInstances = ignore
    params.FilterType = Enum.RaycastFilterType.Exclude

    local result = workspace:Raycast(from, direction, params)
    -- nil result means nothing blocked the ray
    return result == nil
end

-- Usage: check if NPC can see player
local canSee = hasLineOfSight(
    npc.rootPart.Position + Vector3.new(0, 2, 0), -- eye height
    targetRoot.Position + Vector3.new(0, 2, 0),   -- target eye height
    {npc.rootPart.Parent}                          -- ignore NPC's own model
)
仅在距离检测通过后再进行视线检测(射线投射成本较高):
luau
local function hasLineOfSight(from: Vector3, to: Vector3, ignore: {Instance}): boolean
    local direction = to - from
    local params = RaycastParams.new()
    params.FilterDescendantsInstances = ignore
    params.FilterType = Enum.RaycastFilterType.Exclude

    local result = workspace:Raycast(from, direction, params)
    -- result为nil表示没有物体阻挡射线
    return result == nil
end

-- 使用示例:检查NPC是否能看到玩家
local canSee = hasLineOfSight(
    npc.rootPart.Position + Vector3.new(0, 2, 0), -- 眼睛高度
    targetRoot.Position + Vector3.new(0, 2, 0),   -- 目标眼睛高度
    {npc.rootPart.Parent}                          -- 忽略NPC自身模型
)

Field of View (cone check)

视野检测(锥形范围)

luau
local function isInFOV(npcCFrame: CFrame, targetPos: Vector3, fovDegrees: number): boolean
    local toTarget = (targetPos - npcCFrame.Position).Unit
    local forward = npcCFrame.LookVector
    local dot = forward:Dot(toTarget)
    local angle = math.acos(math.clamp(dot, -1, 1))
    return angle <= math.rad(fovDegrees / 2)
end
luau
local function isInFOV(npcCFrame: CFrame, targetPos: Vector3, fovDegrees: number): boolean
    local toTarget = (targetPos - npcCFrame.Position).Unit
    local forward = npcCFrame.LookVector
    local dot = forward:Dot(toTarget)
    local angle = math.acos(math.clamp(dot, -1, 1))
    return angle <= math.rad(fovDegrees / 2)
end

Combined Detection (distance → FOV → LOS)

组合检测(距离 → 视野 → 视线)

luau
local function canDetectPlayer(npc: NPCState, player: Player): boolean
    local char = player.Character
    if not char then return false end
    local root = char:FindFirstChild("HumanoidRootPart")
    if not root then return false end

    -- 1. Distance (cheapest check first)
    local dist = (root.Position - npc.rootPart.Position).Magnitude
    if dist > npc.config.detectionRange then return false end

    -- 2. Field of view (medium cost)
    if not isInFOV(npc.rootPart.CFrame, root.Position, 120) then
        -- Can still detect if very close (hearing range)
        if dist > npc.config.detectionRange * 0.3 then return false end
    end

    -- 3. Line of sight (expensive, do last)
    return hasLineOfSight(
        npc.rootPart.Position + Vector3.new(0, 2, 0),
        root.Position + Vector3.new(0, 2, 0),
        {npc.rootPart.Parent}
    )
end
luau
local function canDetectPlayer(npc: NPCState, player: Player): boolean
    local char = player.Character
    if not char then return false end
    local root = char:FindFirstChild("HumanoidRootPart")
    if not root then return false end

    -- 1. 距离检测(成本最低,优先执行)
    local dist = (root.Position - npc.rootPart.Position).Magnitude
    if dist > npc.config.detectionRange then return false end

    -- 2. 视野检测(中等成本)
    if not isInFOV(npc.rootPart.CFrame, root.Position, 120) then
        -- 如果距离非常近(听觉范围),依然可以检测到
        if dist > npc.config.detectionRange * 0.3 then return false end
    end

    -- 3. 视线检测(成本最高,最后执行)
    return hasLineOfSight(
        npc.rootPart.Position + Vector3.new(0, 2, 0),
        root.Position + Vector3.new(0, 2, 0),
        {npc.rootPart.Parent}
    )
end

Spawn Systems

生成系统

Basic Spawner with Cap

带数量上限的基础生成器

luau
local MAX_ENEMIES = 10
local SPAWN_INTERVAL = 5
local activeEnemies: {Model} = {}

local function spawnEnemy(spawnPoint: BasePart): Model?
    if #activeEnemies >= MAX_ENEMIES then return nil end

    local enemy = ServerStorage.EnemyTemplate:Clone()
    enemy:PivotTo(spawnPoint.CFrame + Vector3.new(0, 3, 0))
    enemy.Parent = workspace.Enemies
    table.insert(activeEnemies, enemy)

    -- Track death
    local humanoid = enemy:FindFirstChildOfClass("Humanoid")
    if humanoid then
        humanoid.Died:Once(function()
            local idx = table.find(activeEnemies, enemy)
            if idx then table.remove(activeEnemies, idx) end
            task.delay(3, function() enemy:Destroy() end) -- cleanup after death anim
        end)
    end

    return enemy
end

-- Spawn loop
task.spawn(function()
    while true do
        task.wait(SPAWN_INTERVAL)
        local spawnPoints = workspace.SpawnPoints:GetChildren()
        local point = spawnPoints[math.random(1, #spawnPoints)]
        spawnEnemy(point)
    end
end)
luau
local MAX_ENEMIES = 10
local SPAWN_INTERVAL = 5
local activeEnemies: {Model} = {}

local function spawnEnemy(spawnPoint: BasePart): Model?
    if #activeEnemies >= MAX_ENEMIES then return nil end

    local enemy = ServerStorage.EnemyTemplate:Clone()
    enemy:PivotTo(spawnPoint.CFrame + Vector3.new(0, 3, 0))
    enemy.Parent = workspace.Enemies
    table.insert(activeEnemies, enemy)

    -- 追踪死亡状态
    local humanoid = enemy:FindFirstChildOfClass("Humanoid")
    if humanoid then
        humanoid.Died:Once(function()
            local idx = table.find(activeEnemies, enemy)
            if idx then table.remove(activeEnemies, idx) end
            task.delay(3, function() enemy:Destroy() end) -- 死亡动画结束后清理
        end)
    end

    return enemy
end

-- 生成循环
task.spawn(function()
    while true do
        task.wait(SPAWN_INTERVAL)
        local spawnPoints = workspace.SpawnPoints:GetChildren()
        local point = spawnPoints[math.random(1, #spawnPoints)]
        spawnEnemy(point)
    end
end)

Wave System

波次生成系统

luau
type WaveConfig = {
    enemies: {{template: string, count: number}},
    spawnDelay: number, -- seconds between individual spawns
    waveDelay: number,  -- seconds between waves
}

local waves: {WaveConfig} = {
    {enemies = {{template = "Zombie", count = 5}}, spawnDelay = 1, waveDelay = 10},
    {enemies = {{template = "Zombie", count = 8}, {template = "FastZombie", count = 3}}, spawnDelay = 0.8, waveDelay = 15},
}

local function spawnWave(wave: WaveConfig, spawnPoints: {BasePart})
    for _, group in wave.enemies do
        for i = 1, group.count do
            local point = spawnPoints[math.random(1, #spawnPoints)]
            local template = ServerStorage.Enemies:FindFirstChild(group.template)
            if template then
                local enemy = template:Clone()
                enemy:PivotTo(point.CFrame + Vector3.new(0, 3, 0))
                enemy.Parent = workspace.Enemies
            end
            task.wait(wave.spawnDelay)
        end
    end
end
luau
type WaveConfig = {
    enemies: {{template: string, count: number}},
    spawnDelay: number, -- 单个敌人生成间隔(秒)
    waveDelay: number,  -- 波次间隔(秒)
}

local waves: {WaveConfig} = {
    {enemies = {{template = "Zombie", count = 5}}, spawnDelay = 1, waveDelay = 10},
    {enemies = {{template = "Zombie", count = 8}, {template = "FastZombie", count = 3}}, spawnDelay = 0.8, waveDelay = 15},
}

local function spawnWave(wave: WaveConfig, spawnPoints: {BasePart})
    for _, group in wave.enemies do
        for i = 1, group.count do
            local point = spawnPoints[math.random(1, #spawnPoints)]
            local template = ServerStorage.Enemies:FindFirstChild(group.template)
            if template then
                local enemy = template:Clone()
                enemy:PivotTo(point.CFrame + Vector3.new(0, 3, 0))
                enemy.Parent = workspace.Enemies
            end
            task.wait(wave.spawnDelay)
        end
    end
end

Network Ownership

网络所有权

Critical: By default, the nearest player "owns" the physics of unanchored parts. This means exploiters can fling your NPCs.
luau
-- Keep NPC physics server-authoritative
local function setServerOwned(model: Model)
    for _, part in model:GetDescendants() do
        if part:IsA("BasePart") then
            part:SetNetworkOwner(nil) -- server owns physics
        end
    end
end

-- Call after spawning
setServerOwned(enemy)
Trade-off: Server ownership means NPC movement is limited to server tick rate (30Hz). For smooth visual movement, replicate position to clients and interpolate.
关键注意事项:默认情况下,距离最近的玩家会“拥有”非锚定部件的物理权限。这意味着 exploiters 可以随意抛掷你的NPC。
luau
-- 保持NPC物理权限由服务器掌控
local function setServerOwned(model: Model)
    for _, part in model:GetDescendants() do
        if part:IsA("BasePart") then
            part:SetNetworkOwner(nil) -- 服务器拥有物理权限
        end
    end
end

-- 生成NPC后调用此函数
setServerOwned(enemy)
权衡:服务器所有权意味着NPC的移动受限于服务器帧率(30Hz)。如需流畅的视觉移动效果,需将位置同步到客户端并进行插值处理。

Update Loop

更新循环

Don't run AI logic every frame. Throttle to 5-10 updates per second:
luau
local AI_TICK_RATE = 1/10 -- 10 updates per second
local accumulated = 0

RunService.Heartbeat:Connect(function(dt)
    accumulated += dt
    if accumulated < AI_TICK_RATE then return end
    accumulated -= AI_TICK_RATE

    for _, npc in activeNPCs do
        updateNPC(npc)
    end
end)
For large NPC counts, stagger updates so not all NPCs think on the same frame:
luau
local npcIndex = 0
RunService.Heartbeat:Connect(function(dt)
    -- Process 5 NPCs per frame instead of all at once
    for i = 1, math.min(5, #activeNPCs) do
        npcIndex = (npcIndex % #activeNPCs) + 1
        updateNPC(activeNPCs[npcIndex])
    end
end)
不要每帧都运行AI逻辑。将更新频率限制为每秒5-10次:
luau
local AI_TICK_RATE = 1/10 -- 每秒更新10次
local accumulated = 0

RunService.Heartbeat:Connect(function(dt)
    accumulated += dt
    if accumulated < AI_TICK_RATE then return end
    accumulated -= AI_TICK_RATE

    for _, npc in activeNPCs do
        updateNPC(npc)
    end
end)
当NPC数量较多时,错开更新时间,避免所有NPC在同一帧执行逻辑:
luau
local npcIndex = 0
RunService.Heartbeat:Connect(function(dt)
    -- 每帧处理5个NPC,而非全部
    for i = 1, math.min(5, #activeNPCs) do
        npcIndex = (npcIndex % #activeNPCs) + 1
        updateNPC(activeNPCs[npcIndex])
    end
end)

Common Mistakes

常见错误

  • Client-side NPC logic: ALL NPC behavior must run on the server. Client only handles animations/visuals.
  • No path blocked handling: Paths go stale when the world changes. Always connect
    path.Blocked
    and recompute.
  • ComputeAsync with no fallback: If path computation fails (Status ~= Success), don't freeze. Fall back to direct movement or idle.
  • Tight detection loops: Don't check every NPC against every player every frame. Distance check is O(n*m). Throttle to 5-10/sec.
  • Forgetting SetNetworkOwner(nil): Without this, the nearest player owns NPC physics. Exploiters fling them.
  • Not cleaning up dead NPCs: Destroy models after death animation. Corpses accumulate and kill performance.
  • MoveTo timeout:
    Humanoid:MoveTo()
    has an 8-second timeout. If the NPC gets stuck,
    MoveToFinished
    fires with
    reached = false
    . Handle it.
  • Pathfinding on the client: PathfindingService works on both client and server, but NPC movement must be server-authoritative. Compute paths on the server.
  • No stagger for large NPC counts: 50 NPCs all computing paths on the same frame = server lag spike. Stagger updates.
  • 客户端NPC逻辑:所有NPC行为必须在服务器端运行。客户端仅负责动画/视觉效果。
  • 未处理路径阻塞:当场景变化时,路径会失效。务必连接
    path.Blocked
    事件并重新计算路径。
  • ComputeAsync无 fallback:如果路径计算失败(Status ~= Success),不要让NPC冻结。 fallback到直接移动或 idle状态。
  • 检测循环过于频繁:不要每帧都让所有NPC检测所有玩家。距离检测的时间复杂度为O(n*m)。将频率限制为每秒5-10次。
  • 忘记调用SetNetworkOwner(nil):如果不设置,距离最近的玩家会拥有NPC的物理权限,Exploiters可以随意抛掷NPC。
  • 未清理死亡NPC:死亡动画结束后销毁模型。尸体堆积会严重影响性能。
  • MoveTo超时
    Humanoid:MoveTo()
    有8秒超时时间。如果NPC被卡住,
    MoveToFinished
    会返回
    reached = false
    ,需处理这种情况。
  • 客户端寻路:PathfindingService在客户端和服务器端都能运行,但NPC移动必须由服务器掌控。请在服务器端计算路径。
  • 大量NPC未错开更新:50个NPC在同一帧计算路径会导致服务器卡顿。需错开更新时间。