🏃‍♂️

【Roblox】クライアントで管理するHumanoidキャラクターの動作とAnimationを他のクライアント視点でも一致させる

に公開

1. はじめに

Humanoidで動くキャラクターは、サーバー側に存在するものをクライアントで動かした場合、サーバーを介して自動的に動作が同期されます。
これによってクライアント側でも動作を管理することが可能で、リアルタイム性が高い要素で利用すると便利ですが、クライアント側で再生したAnimationについては同期されないため、なんらかの別の通信を介さなければならず、キャラクターの動作とAnimationの整合性をとるのが難しくなっています。
そこで今回は、クライアントで管理しているHumanoidキャラクターの動作とAnimationを、他のクライアントから見ても一致させる方法についてご紹介します。

バージョン:0.660.0.6600648

2. 問題点と解決策

クライアントでHumanoidキャラクターを管理している場合によくあるAnimationの問題点として、以下が挙げられます。

  • クライアントでAnimationを再生しても、他のクライアントでは再生されない
  • サーバー側でHumanoidなどの状態を見てAnimationを再生すると、発火が不安定なイベントや同期が遅いイベントなどがあり、違和感のある動きになってしまう
  • クライアントからRemoteEvent等を介してAnimationを再生すると、通信状況によっては動作とズレが発生してしまう場合がある

三つ目については許容する考え方もありますが、今回はこれらの問題の解決策として「全てのクライアント上でそれぞれが状態に応じたAnimationを再生する」という手法を用います。
どういうことかというと、クライアント上でHumanoidなどの状態に応じてアニメーションを再生するスクリプトを、全てのクライアントで実行すれば、たとえ通信状況が悪くてもそれぞれのクライアントから見た時の動作とアニメーションは必ず一致する!というわけです。

3. 前準備

今回は説明を簡単にするため、通常は使用しない形ですが最初に入室したPlayerのクライアントで動かすNPCを作ってみることにします。
「クライアントでHumanoidキャラクターを動作させる方法はわかってるよ!」って方はこの項目は読み飛ばしてしまっても問題ありません。

まずは、リグビルダーでWorkspace下にRigを置き、その中にScriptを追加します。
名前はSetupとしておきましょう。

そして、クライアントから動作させるためにそのScriptでNetworkOwnerを変更します。
NetworkOwnerがデフォルトのままだと、クライアント側から安定して動作させることはできません。

Setup
--!strict

-- 今回は説明のため、最初に入室したPlayerのクライアントで動かすことにする.
local player_ = game:GetService("Players").PlayerAdded:Wait()

-- 全てのBasePartのNetworkOwnerを最初に入室したPlayerにする.
for i, v in ipairs(script.Parent:GetDescendants()) do
	if v:IsA("BasePart") then
		v:SetNetworkOwner(player_)
	end
end

次に、StarterPlayerScriptsにLocalScriptを、ReplicatedStorageにRemoteEventを追加して、サーバーから特定のPlayerを指定してRigを動かさせられるようにします。
LocalScriptの名前はRigMove、RemoteEventの名前はStartMovingとしておきましょう。

RigMove
--!strict

-- 移動距離.
local DISTANCE = 20

-- 右に行くか否か
local isMovingRight = true

-- RemoteEventを取得して接続.
local StartMoving = game:GetService("ReplicatedStorage"):WaitForChild("StartMoving")
StartMoving.OnClientEvent:Connect(function(rig: Model)
	if not rig or not rig:IsA("Model") then
		return
	end
	
	local humanoid = rig:FindFirstChildWhichIsA("Humanoid")::Humanoid
	if not humanoid then
		return
	end
	
	-- MoveToFinishedの度に次の移動を行う.
	humanoid.MoveToFinished:Connect(function(reached: boolean)
		task.wait(0.5)
		local pos = rig:GetPivot().Position
		
		-- 一回ごとに左右交互に移動する.
		if isMovingRight then
			pos += Vector3.new(DISTANCE, 0, 0)
		else
			pos += Vector3.new(-DISTANCE, 0, 0)
		end
		isMovingRight = not isMovingRight

		humanoid:MoveTo(pos)
		task.wait(0.25)
		
		-- 移動しながらジャンプ.
		humanoid:ChangeState(Enum.HumanoidStateType.Jumping)
	end)

	-- その場に対してMoveToすることでMoveToFinishedを発火.
	humanoid:MoveTo(rig:GetPivot().Position)
end)

最後にSetupの中でRemoteEventを発火させるようにします。

Setup
 --!strict

 -- 今回は説明のため、最初に入室したPlayerのクライアントで動かすことにする.
local player_ = game:GetService("Players").PlayerAdded:Wait()

 -- 全てのBasePartのNetworkOwnerを最初に入室したPlayerにする.
for i, v in ipairs(script.Parent:GetDescendants()) do
	if v:IsA("BasePart") then
		v:SetNetworkOwner(player_)
	end
end

+-- RigMove側でRemoteEventの接続が終わっていない場合があるので少しwaitを入れる.
+task.wait(2)
+
+-- RemoteEventを取得し、発火させる.
+local StartMoving = game:GetService("ReplicatedStorage"):WaitForChild("StartMoving")
+StartMoving:FireClient(player_, workspace:WaitForChild("Rig"))

色々と簡易的な対応ですが、とりあえずこれで特定のクライアント側からNPCが動作するようになりました。
ただし、まだAnimationは再生していないので棒立ち状態で動いています。

4. 各クライアントでAnimationさせる

それでは本題の、動作と同期したAnimationについてです。
Humanoidの状態を見てAnimationを再生してくれる、クライアントで動作するScriptが必要なわけですが、説明用に一から用意するのは大変なので、今回はデフォルトで付いてるものを流用しちゃいます。
Rigの中を見てみると、初めからAnimateというLocalScriptが入っているのが確認できると思います。

これは、移動やジャンプなどRobloxのデフォルトの機能のAnimationを再生しているLocalScriptで、Humanoidなどの状態を見て、再生するAnimationや再生速度などを管理し決定しています。
ただし、これはLocalScriptなので、PlayerCharacterやPlayerScriptsなどの中でないと動作してくれません。
先ほど作ったRigMoveと同じように、StarterPlayerScriptsに入れておいて、サーバーから各Playerに実行させられるように改造する方法もあるのですが、全てのクライアントで実行させるのはなかなか手間ですし、動かしたい物の数だけLocalScriptが必要になるので管理が面倒です。
そこで、ScriptのRunContextというプロパティを利用します。

RunContext

RunContextはScriptが実行されるコンテキストを決定するプロパティです。
デフォルトはLegacyになっており、WorkspaceかServerScriptServiceの中でのみ実行されます。
これをServerClientにすると、基本的に置いた場所に関係なく自動的に実行され、Serverならサーバー側、Clientならクライアント側で実行されるようになります。
ただし、「ServerScriptServiceやServerStorageの中にClient設定のScript」のような、複製されずそもそも存在しない場合は実行されない事に注意しましょう。

さて、それでは早速使ってみます。
まずは、Rigに対して新たなScriptを追加します。LocalScriptではなくScriptなので注意。
名前はClientAnimateとしておきましょう。

そして、プロパティのRunContextからClientを選択します。

するとアイコンがLocalScriptと同じアイコンになったかと思います。

これで、クライアント側のWorkspace下でも実行されるようになりました。通常のInstanceと同様、各クライアントに複製されるため、全てのクライアント上で実行されることになります。

次に、元から付いていたAnimateのソースコードを、ClientAnimateにまるごとコピペします。
Animate下にある各Instanceも全てClientAnimateに移したら、Animateは削除してしまってOKです。

これで、全てのクライアントで、その時のHumanoidなどの状態を見てAnimationを再生するようになったはずです。
早速、ローカルサーバーテストで確認してみます。

Player2から見ても、正しくAnimationしています!

このように、RunContextは便利ですが、Legacy設定のScriptやLocalScriptを用いた基本的な作り方と混在すると、どれがどの環境で実行されるのかわかり辛くなってしまいます。
Server設定、Client設定のScriptにはそれぞれ特定の接頭辞を付けるなど、コーディングルールで命名規則を決めて、区別がつくようにして利用するとよいでしょう。

5. まとめ

  • 全てのクライアント上で、状態を見てAnimationすることで、常に動作とAnimationを一致させることができる
  • RunContextをClientにすることで、存在すればどこでもクライアント上で実行されるようになる
  • RunContextを利用する際は、通常のScript等と区別がつくように運用する

今回使った手法だけでなく、やはり動作させているクライアント側からAnimationを指示したい場合もあると思います。
そういった場合には、RemoteEventを介してサーバーを経由し、指定されたAnimationを再生する仕組みも用意して、併用するとよいでしょう。
場合によって使い分けて、違和感のないAnimationとリアルタイム性を両立させましょう!
最後までお読みいただき、ありがとうございました!

6. 参考

https://create.roblox.com/docs/en-us/animation
https://create.roblox.com/docs/en-us/reference/engine/classes/BaseScript#RunContext
https://create.roblox.com/docs/en-us/reference/engine/enums/RunContext

ランド・ホー Roblox開発チーム

Discussion