roblox-npc-ai
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseRoblox 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 to make a material completely non-traversable.
math.hugeluau
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.hugeComputing 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
endPathfinding 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:
- Create an Anchored, CanCollide=false Part around the region
- Add a child with a
PathfindingModifier(e.g. "DangerZone")Label - 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):
- Create an Anchored, CanCollide=false Part around the door
- Add a with
PathfindingModifierPassThrough = true - The path will route through the door as if it's open
PathfindingLink — connect disconnected navmesh areas:
Use to tell the pathfinder about custom traversal (teleporters, ziplines, boats). Set a Label and handle it in the waypoint loop via .
PathfindingLinkwaypoint.Action == Enum.PathWaypointAction.Custom控制寻路系统对特定区域的处理方式:
材质成本 — 让特定地形材质的移动成本更高:
luau
local path = PathfindingService:CreatePath({
Costs = {
Water = 20, -- 成本为默认值的20倍
CrackedLava = 100, -- 几乎无法通行
Slate = 20, -- 避开板岩区域
},
})区域修饰符 — 将任意区域标记为高成本或不可通行:
- 在目标区域周围创建一个锚定、CanCollide=false的Part
- 添加一个子对象并设置
PathfindingModifier(例如"DangerZone")Label - 在Costs中引用该标签:
luau
local path = PathfindingService:CreatePath({
Costs = {
DangerZone = math.huge, -- 完全避开该区域
},
})PassThrough — 穿过固体障碍物(例如门):
- 在门周围创建一个锚定、CanCollide=false的Part
- 添加一个并设置
PathfindingModifierPassThrough = true - 寻路系统会将门视为开启状态并直接穿过
PathfindingLink — 连接断开的导航网格区域:
使用告知寻路系统自定义移动方式(传送器、滑索、船只)。设置Label后,可在路点循环中通过进行处理。
PathfindingLinkwaypoint.Action == Enum.PathWaypointAction.CustomNavigation 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
endState 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
endluau
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
endLine 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)
endluau
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)
endCombined 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}
)
endluau
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}
)
endSpawn 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
endluau
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
endNetwork 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 and recompute.
path.Blocked - 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: has an 8-second timeout. If the NPC gets stuck,
Humanoid:MoveTo()fires withMoveToFinished. Handle it.reached = false - 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超时:有8秒超时时间。如果NPC被卡住,
Humanoid:MoveTo()会返回MoveToFinished,需处理这种情况。reached = false - 客户端寻路:PathfindingService在客户端和服务器端都能运行,但NPC移动必须由服务器掌控。请在服务器端计算路径。
- 大量NPC未错开更新:50个NPC在同一帧计算路径会导致服务器卡顿。需错开更新时间。