PROJECT 03

3 / 11

Roblox Studioで敵AIを作る:プレイヤーを発見して追跡する方法

AshKnightが40 studs以内の生存プレイヤーを見つけ、障害物を避けて6 studsまで追跡するserver AIを作ろう。

公開日 更新日
AshKnightがIDLEからCHASE、MOVING、6 studsのHOLDへ進む図

前の記事TrainingDummyを、近くの生存プレイヤーを見つけて追うAshKnightへ発展させる。40 studs以内で追跡を始め、壁を回り、6 studsまで近づいたら攻撃せずに止まる。

AshKnightのIDLE、CHASE、MOVING、HOLDを示すstate図

1. AshKnightと障害物を置く

TrainingDummyAshKnightへ変える。Humanoid.MaxHealthHealth150WalkSpeed10にし、HumanoidRootPart.Anchoredをオフにする。rigのすべてのPartが床へめり込まず、rootが地面から約3 studs上にある位置へ移そう。

combat-02で横へ退避したTestWallを選び、PathfindingObstacleへ名前を変える。新しい壁を増やす必要はない。Size = 12, 7, 2Position = 0, 3.5, 1へ変え、AnchoredCanCollideをオンにする。AshKnightとCombatSpawnの間で真っすぐな進路を遮るため、経路計算が左右へ曲がる様子を確認できる。

Tip — navigation meshは「歩ける場所の地図」

Studioは床、壁、段差からAIが歩ける面を計算する。高さ5、半径2 studsのagentに合わせてpathを作ると、AshKnightの身体が通れるwaypointを得られる。

2. serverで一番近い生存プレイヤーを探す

AshKnightScriptを追加し、AshKnightServerと名付ける。次のcodeへ置き換えよう。

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

local npc = script.Parent
local humanoid = npc:WaitForChild("Humanoid")
local root = npc:WaitForChild("HumanoidRootPart")

local DETECTION_RANGE = 40
local STOP_DISTANCE = 6
local STOP_TOLERANCE = 0.35
local REPATH_DELAY = 0.2

for _, item in npc:GetDescendants() do
	if item:IsA("BasePart") and not item.Anchored then
		item:SetNetworkOwner(nil)
	end
end

local function setState(state, targetName, distance)
	npc:SetAttribute("AIState", state)
	npc:SetAttribute("TargetName", targetName or "")
	npc:SetAttribute("TargetDistance", distance or -1)
end

local function nearestTarget()
	local nearest
	local nearestDistance = DETECTION_RANGE

	for _, player in Players:GetPlayers() do
		local character = player.Character
		local targetHumanoid = character
			and character:FindFirstChildOfClass("Humanoid")
		local targetRoot = character
			and character:FindFirstChild("HumanoidRootPart")

		if targetHumanoid and targetHumanoid.Health > 0 and targetRoot then
			local distance = (targetRoot.Position - root.Position).Magnitude
			if distance <= nearestDistance then
				nearest = character
				nearestDistance = distance
			end
		end
	end
	return nearest, nearestDistance
end

local function nextWaypoint(targetPosition)
	local path = PathfindingService:CreatePath({
		AgentRadius = 2,
		AgentHeight = 5,
		AgentCanJump = false,
		WaypointSpacing = 4,
	})

	local ok = pcall(function()
		path:ComputeAsync(root.Position, targetPosition)
	end)
	if not ok or path.Status ~= Enum.PathStatus.Success then
		npc:SetAttribute("PathStatus", "NO_PATH")
		return nil
	end

	npc:SetAttribute("PathStatus", "SUCCESS")
	path.Blocked:Once(function()
		npc:SetAttribute("PathStatus", "BLOCKED_REPATH")
		humanoid:MoveTo(root.Position)
	end)

	local waypoints = path:GetWaypoints()
	return waypoints[2] and waypoints[2].Position or targetPosition
end

setState("IDLE")

while humanoid.Health > 0 do
	local target, distance = nearestTarget()
	if not target then
		setState("IDLE")
		npc:SetAttribute("MoveStatus", "HOLD")
		humanoid.WalkSpeed = 0
		humanoid:MoveTo(root.Position)
		task.wait(REPATH_DELAY)
		continue
	end

	local targetRoot = target:FindFirstChild("HumanoidRootPart")
	if not targetRoot then
		task.wait(REPATH_DELAY)
		continue
	end

	if distance <= STOP_DISTANCE + STOP_TOLERANCE then
		setState("CHASE", target.Name, distance)
		npc:SetAttribute("MoveStatus", "HOLD")
		humanoid.WalkSpeed = 0
		root.AssemblyLinearVelocity = Vector3.zero
		humanoid:MoveTo(root.Position)
		task.wait(REPATH_DELAY)
		continue
	end

	setState("CHASE", target.Name, distance)
	humanoid.WalkSpeed = 10
	local offset = targetRoot.Position - root.Position
	local desired = targetRoot.Position
		- offset.Unit * (STOP_DISTANCE - 1)
	local waypoint = nextWaypoint(desired)

	if waypoint then
		npc:SetAttribute("MoveStatus", "MOVING")
		humanoid:MoveTo(waypoint)
	else
		npc:SetAttribute("MoveStatus", "NO_PATH")
		humanoid:MoveTo(root.Position)
	end
	task.wait(REPATH_DELAY)
end

SetNetworkOwner(nil)でNPCの物理計算をserverへ固定する。clientごとに別の位置を正本にしないためだ。対象選択もserverで行い、clientからNPCの座標や対象名を受け取らない。

Tip — waypointはpath上の次の目印

GetWaypoints()は目的地までの曲がり角を順番に返す。このcodeは毎回2番目へMoveTo()し、0.2秒後に新しいプレイヤー位置からpathを作り直す。

Path.Blockedが発生したら一度停止し、次のloopで経路を再計算する。壁が増えた後も古いwaypointへ押し続けない。

stateをAttributeで見えるようにする

AIStateMoveStatusPathStatusは、追跡の判断をExplorerから読めるようにするAttributeだ。対象がいなければIDLE / HOLD、追跡中はCHASE / MOVING、距離へ到達するとCHASE / HOLDになる。path計算が成功したときはPathStatus = SUCCESSだ。

Attributeは移動を実行する命令ではない。Humanoid:MoveTo()WalkSpeedが実際の動きを変え、Attributeは現在の判断を観察するために使う。この分離により、画面で止まっている理由が「対象なし」「距離へ到達」「pathなし」のどれかを調べやすい。

nearestTarget()は毎回Playersを読み直し、Healthが0より大きいCharacterだけを候補にする。複数clientのtestでも一番近い生存targetを選び、1人目の名前を固定しない。最終ゲームを1人serverにしても、別clientへstateを誤適用しないことを先に確認できる。

3. 6 studs手前を目的地にする

プレイヤーの中心までpathを引くと、NPCが身体へぶつかる。desiredは対象からSTOP_DISTANCE - 1だけ手前だ。実距離が6.35以下になったら速度を0にするため、最終表示はほぼ6 studsになる。

障害物の横を通りながらMOVINGになったAshKnight

Tip — repathは動く対象へ経路を引き直すこと

playerは動くため、最初のpathだけでは古い場所へ向かう。今回はREPATH_DELAY = 0.2、つまり1秒に最大5回だけ計算し、毎frameの重い計算を避ける。

4. 追跡、停止、対象なしを確認する

PlayしてAshKnightから40 studs以内へ入る。障害物の正面で止まらず、左右どちらかのwaypointへ向かえばpathが成功している。

AshKnightがIDLEからCHASEとMOVINGを経て6 studsのHOLDへ進む

AshKnightが約6 studsで止まり、プレイヤーへ重ならないことを見る。

6 studsでHOLDになり追跡を止めたAshKnight

最後に、40 studsより外へ離れるか、ResetでプレイヤーのHealthを0にする。AIStateIDLEMoveStatusHOLDになり、その場で止まればよい。

生存する対象がなくIDLEで停止したAshKnight

敵が小刻みに震える場合は、STOP_TOLERANCEを消していないか、停止中にもWalkSpeed = 10を設定していないか確認する。壁へ押し続ける場合はPath.Blockedの接続とREPATH_DELAYを読み直そう。

pathが作れないとき

AshKnightがNO_PATHのままなら、床と障害物のCanCollideがオンか、rigの脚が床へ深く埋まっていないかを確認する。障害物とarenaの端の間には、半径2 studsのagentが通れる幅を残す。細い隙間を見た目だけで通れると判断せず、障害物を1〜2 studs内側へ動かして再度Playしよう。

追跡範囲はroot同士の直線距離だ。床に沿ったpathの長さではない。AshKnightの近くでTargetDistanceが40以下になってからCHASEへ変わり、40を超えると次の0.2秒周期でIDLEへ戻ることをAttributeでも確認できる。

最後はAshKnightが6 studsで止まる状態に戻して保存しよう。これが次の開始状態だ。

これでAshKnightは攻撃距離まで自分で移動できる。次は6 studsで止まった後に0.8秒の予備動作を見せ、その後だけ35ダメージを与えるboss attackを追加する。

公式リファレンス

画像の拡大表示