- 投稿日:2020-06-29T23:00:12+09:00
【Unity】アウトゲームでの設計でMV(R)Pパターンを採用しました【設計】
0.前提と推奨するターゲット
前提
・実際に長期運用を行ったわけではないため、メリット、デメリットが自己解釈になる部分があります。
ターゲット
・
UniRx
をある程度、理解している方
・MVPパターンなどのアーキテクチャパターン
をある程度、理解している方
・uGUI
に触れたことがある方1.MV(R)Pパターンとは
UniRx開発者の@neue ccさんが提唱している
MVPパターンとUniRxを組み合わせたUIアーキテクチャパターン
です。最近のソシャゲ設計では、割とスタンダードなのかなと思います。上記画像の②ViewとPresenter間の入力検知や、③ModelとPresenter間の値の変更検知をUniRxで執り行います。
2.アウトゲームでのMV(R)Pパターン
2.1 画面を階層構造で構築しました
画面は階層構造で構築しました。
Scene(Class) -> (Canvas) -> Window(Class) -> Screen(Class)
SceneがWindowを管理し、WindowがScreenを管理すると言うルールを決めました。とても参考になった記事
もしあなたがアウトゲーム(UI)を つくることになったら with Unity
Web出身のUnityエンジニアによる大規模ゲームの基盤設計OutGameScene内にGachaWindowやCharacterWindowなどがあり、GachaWindowにはGachaTopScreenやGachaResultScreenなどがあります。
2.2 画面単位でMVPを構築しました
SceneやWindow、Screenと言った画面単位でMVPを構築します。
参考になる記事はこちらです。UniRx開発者さんの記事です。
neue cc / UniRx 4.8 - 軽量イベントフックとuGUI連携によるデータバインディング2.3 ViewとPresenterの要素数と関係
MVPでもViewとPresenterのそれぞれの数にもだいたい2パターン考えられ、
ViewとPresenterが1:1のもの
、ViewとPresenterが複数:1のもの
があります。
今回はSceneごと、WindowごとなどにMVPが構成されているので、Viewは、下記のようにHomeWindowViewクラスの中に、ボタンやテキストがまとめられています。通常のMV(R)Pでの1:1のもの、複数:1のもののそれぞれのメリット、デメリットがあります。下記のスライドはとても参考になります。
今回はViewが画面単位でまとめられているため、上記サイトで記載されているメリット、デメリットに合致しないことが複数あり、自分なりの今回の実装特有のメリット、デメリットを以下に書きます。
メリット
・Viewが画面単位でまとめられていることで、View要素の取得がしやすい(紐付けがしやすい)。
・画面単位でまとめられているため、ざっと見やすいと言う感覚
・ルールが決まっているため、画面単位でのコードが追いやすい。
デメリット
Viewが画面単位でまとめられていることで、小さいテストがしにくかったり、要素数の変更がしにくい可能性あり。3.実装
シンプルな実装を例に一画面を解説します。
ボタンをクリックすると、テキストにクリック回数が反映されます。
この画面を仮にHomeWindowとします。WindowやScreen単位でプレハブ化し、生成や破棄などを行うことで、画面遷移を実現します。
View
HomeWindowView.cspublic class HomeWindowView : MonoBehaviour { [SerializeField] Button m_Button; // ButtonをPropertyとして外部に公開 public Button m_ButtonProp => m_Button; // クリック回数を表示するテキスト [SerializeField] Text m_ClickCountText; // クリック回数を表示するテキストの描画を更新する public void SetCount(string count){ m_ClickCountText.text = count; } }Viewには、描画に関する処理を限定的に書きます。
ここでは、uGUIであるButtonを用意します。Presenter側でSubscribeすることで、通知を検知することができます。また、外部からクリック回数を変更できるように、SetCount関数を用意します。
ボタンなどのuGUI以外での通知を送りたい場合は、Subjectなどを用いています。
[SerializeField]など、インスペクタでぽちぽち入れるのがめんどくさい、忘れる可能性がある場合は、コードで取得するようにした方が良いです。Model
HomeWindowModel.cs// 追加する using UniRx; public class HomeWindowModel { // クリック回数を保持する private ReactiveProperty<int> m_ClickCount = new ReactiveProperty<int>(); public IReadOnlyReactiveProperty<int> m_ClickCountProp => m_ClickCount; // クリック回数の更新 public void UpdateClickCount(){ m_ClickCount.Value++; } }Modelは、ビジネスロジックや、データの取得を担います。
今回はクリック回数とクリック回数を変更する関数を用意しています。
クリック回数はReactiveProperty
で、外部公開用に読み取り専用のIReadOnlyReactiveProperty
を用いています。
バインディング周りがすっきりと書けるので、ありがたいです。
また、Modelは、DBからのリソースの確保や計算などが複雑化し場合は、それぞれでさらに分割することを推奨します。Presenter
HomeWindowPresenter.cs// 追加する using UniRx; public class HomeWindowPresenter : MonoBehaviour { // view private HomeWindowView m_HomeWindowView; // model private HomeWindowModel m_HomeWindowModel; private void Awake() { Initialized(); } // インスタンス生成時に呼ばれる初期化関数 private void Initialized() { m_HomeWindowView = GetComponent<HomeWindowView>(); m_HomeWindowModel = new HomeWindowModel(); SetEvent(); Bind(); } // Viewからの通知 private void SetEvent(){ // ボタンのクリック通知を監視 m_HomeWindowView.m_ButtonProp.OnClickAsObservable().Subscribe(_ => m_HomeWindowModel.UpdateClickCount()); } // Modelからの通知 private void Bind(){ m_HomeWindowModel.m_ClickCountProp.Subscribe(x =>m_HomeWindowView.SetCount(x.ToString())); } }Presenterは、ViewとModelの橋渡しを担い、他を担うとしても簡単な値の変換程度に修めます。
SetEvent関数は、Viewであるボタンのクリック通知を監視し、Modelの変更を行います。
Bind関数は、Modelの値の変更を監視し、Viewであるテキストの変更を行います。
また、今回は書いてませんが、Presenterも階層構造のために必要になる経路の選択も複雑化した場合は、Model同様に、さらに分割することを推奨します。4.まとめ
コードの可読性や保守性に合わせて、どの設計を選択するかを決めるべきで、これが正しいと言うことはないと思います。
個人的には、ルールを決めることが大切だと思います。
今後は、
VIPERアーキテクチャ
やClean Architecture
にも、触れていこうと思います。(気になった方は是非調べてみてください)
- 投稿日:2020-06-29T18:26:40+09:00
【Unity(C#),PUN2】OculusQuestのハンドトラッキング同期実装
デモ
やってることは見たまんまの位置同期ですが、
ハンドトラッキングの実装はOculusIntegration内に存在するOVR系のコンポーネントを
理解する必要があり、私のレベルでは非常に面倒でした。しかし、一度理解してしまえば使い回すだけなので、
同じ苦労をする人が一人でも減るようにメモしておきます。バージョン情報
Unity2019.3.10f1
Oculus Integration 1.49
PUN2 Version 2.19.1ハンドトラッキングの流れ
まずはOculusIntegration内に存在するOVR系のコンポーネントがどのような役割を持ち、
どのような流れでハンドトラッキングを行っているかを理解していきます。簡単に言うと下記です。
①手を認識
②ボーンとなるオブジェクトを生成
③手のメッシュを作成
④手のメッシュにボーンを設定
⑤生成したボーンを認識した手の関節の座標に合わせるおおざっぱに理解したにすぎないので、間違いがあったらコメントください。
①
OVRHand
が手を認識
OVRHand
が手を認識しているというのは詳細を言うと少し誤った表現です。もう少し正確に言うと、
OVRHand
が認識した手のデータを受け取って様々なクラスに
インタフェース経由でデータを渡しているという説明になるかと思います。もっと辿っていくと
OVRPlugin
というクラスが存在しており、
デバイスが手を認識した際のデータを
C#で利用できるようにするラッパークラスとしての役割を担っています。②
OVRSkeleton
がボーンとなるオブジェクトを生成
OVRSkeleton
のコードを見ていくと手のボーンを生成するコードを見つけました。OVRSkeleton内のボーン生成箇所virtual protected void InitializeBones(OVRPlugin.Skeleton skeleton) { _bones = new List<OVRBone>(new OVRBone[skeleton.NumBones]); Bones = _bones.AsReadOnly(); if (!_bonesGO) { _bonesGO = new GameObject("Bones"); _bonesGO.transform.SetParent(transform, false); _bonesGO.transform.localPosition = Vector3.zero; _bonesGO.transform.localRotation = Quaternion.identity; } // pre-populate bones list before attempting to apply bone hierarchy for (int i = 0; i < skeleton.NumBones; ++i) { BoneId id = (OVRSkeleton.BoneId)skeleton.Bones[i].Id; short parentIdx = skeleton.Bones[i].ParentBoneIndex; Vector3 pos = skeleton.Bones[i].Pose.Position.FromFlippedXVector3f(); Quaternion rot = skeleton.Bones[i].Pose.Orientation.FromFlippedXQuatf(); var boneGO = new GameObject(id.ToString()); boneGO.transform.localPosition = pos; boneGO.transform.localRotation = rot; _bones[i] = new OVRBone(id, parentIdx, boneGO.transform); } for (int i = 0; i < skeleton.NumBones; ++i) { if (((OVRPlugin.BoneId)skeleton.Bones[i].ParentBoneIndex) == OVRPlugin.BoneId.Invalid) { _bones[i].Transform.SetParent(_bonesGO.transform, false); } else { _bones[i].Transform.SetParent(_bones[_bones[i].ParentBoneIndex].Transform, false); } } }この処理によって、動的にボーンとなるオブジェクトが生成されます。
このBonesの子階層にあるオブジェクトはEditor上で確認すると
手の動きに追従して回転しているのが確認できます。
(InspectorでShould Update Bone
にチェックを入れた場合)【参考リンク】:【Unity】Oculus Link使ってEditor上でデバッグ
③
OVRMesh
が手のメッシュを生成
OVRMesh
のコード内で手のメッシュを生成しています。
BoneWeightの設定も行っています。OVRMesh内の手のメッシュ生成箇所private void Initialize(MeshType meshType) { _mesh = new Mesh(); var ovrpMesh = new OVRPlugin.Mesh(); if (OVRPlugin.GetMesh((OVRPlugin.MeshType)_meshType, out ovrpMesh)) { var vertices = new Vector3[ovrpMesh.NumVertices]; for (int i = 0; i < ovrpMesh.NumVertices; ++i) { vertices[i] = ovrpMesh.VertexPositions[i].FromFlippedXVector3f(); } _mesh.vertices = vertices; var uv = new Vector2[ovrpMesh.NumVertices]; for (int i = 0; i < ovrpMesh.NumVertices; ++i) { uv[i] = new Vector2(ovrpMesh.VertexUV0[i].x, -ovrpMesh.VertexUV0[i].y); } _mesh.uv = uv; var triangles = new int[ovrpMesh.NumIndices]; for (int i = 0; i < ovrpMesh.NumIndices; ++i) { triangles[i] = ovrpMesh.Indices[ovrpMesh.NumIndices - i - 1]; } _mesh.triangles = triangles; var normals = new Vector3[ovrpMesh.NumVertices]; for (int i = 0; i < ovrpMesh.NumVertices; ++i) { normals[i] = ovrpMesh.VertexNormals[i].FromFlippedXVector3f(); } _mesh.normals = normals; var boneWeights = new BoneWeight[ovrpMesh.NumVertices]; for (int i = 0; i < ovrpMesh.NumVertices; ++i) { var currentBlendWeight = ovrpMesh.BlendWeights[i]; var currentBlendIndices = ovrpMesh.BlendIndices[i]; boneWeights[i].boneIndex0 = (int)currentBlendIndices.x; boneWeights[i].weight0 = currentBlendWeight.x; boneWeights[i].boneIndex1 = (int)currentBlendIndices.y; boneWeights[i].weight1 = currentBlendWeight.y; boneWeights[i].boneIndex2 = (int)currentBlendIndices.z; boneWeights[i].weight2 = currentBlendWeight.z; boneWeights[i].boneIndex3 = (int)currentBlendIndices.w; boneWeights[i].weight3 = currentBlendWeight.w; } _mesh.boneWeights = boneWeights; IsInitialized = true; } }OVRMeshは生成したメッシュをSkinnedMeshrendererに設定します。
PlayModeを押すとMeshが動的に生成、設定されているのがわかります。
④
OVRMeshRenderer
が手のメッシュにボーンを設定先ほど生成したMeshにボーンを設定します。
正確に言うと、SkinnedMeshrendererの持つMeshのボーン情報に設定します。OVRMeshRenderer内のボーンを設定箇所private void Initialize() { _skinnedMeshRenderer = GetComponent<SkinnedMeshRenderer>(); if (!_skinnedMeshRenderer) { _skinnedMeshRenderer = gameObject.AddComponent<SkinnedMeshRenderer>(); } if (_ovrMesh != null && _ovrSkeleton != null) { if (_ovrMesh.IsInitialized && _ovrSkeleton.IsInitialized) { _skinnedMeshRenderer.sharedMesh = _ovrMesh.Mesh; _originalMaterial = _skinnedMeshRenderer.sharedMaterial; int numSkinnableBones = _ovrSkeleton.GetCurrentNumSkinnableBones(); var bindPoses = new Matrix4x4[numSkinnableBones]; var bones = new Transform[numSkinnableBones]; var localToWorldMatrix = transform.localToWorldMatrix; for (int i = 0; i < numSkinnableBones && i < _ovrSkeleton.Bones.Count; ++i) { bones[i] = _ovrSkeleton.Bones[i].Transform; bindPoses[i] = _ovrSkeleton.BindPoses[i].Transform.worldToLocalMatrix * localToWorldMatrix; } _ovrMesh.Mesh.bindposes = bindPoses; _skinnedMeshRenderer.bones = bones; _skinnedMeshRenderer.updateWhenOffscreen = true; #if UNITY_EDITOR _ovrSkeleton.ShouldUpdateBonePoses = true; #endif IsInitialized = true; } } }このコード内ではメッシュのデフォルトの位置となる
bindposes
も設定しています。
Meshの持つボーンのデフォルトの位置を設定することで、
ボーンの移動した値とデフォルト値の差分から計算が可能になるそうです。⑤
OVRSkeleton
が生成したボーンを認識した手の関節の座標に合わせる最後に再度
OVRSkeleton
の登場です。自身で生成したBonesを認識した手の関節情報にそれぞれ追従させます。
OVRSkeleton内の生成したボーンを認識した手の関節の座標に合わせる箇所void Update() { //~省略~ var data = _dataProvider.GetSkeletonPoseData(); IsDataValid = data.IsDataValid; if (data.IsDataValid) { IsDataHighConfidence = data.IsDataHighConfidence; if (_updateRootPose) { transform.localPosition = data.RootPose.Position.FromFlippedZVector3f(); transform.localRotation = data.RootPose.Orientation.FromFlippedZQuatf(); } if (_updateRootScale) { transform.localScale = new Vector3(data.RootScale, data.RootScale, data.RootScale); } for (var i = 0; i < _bones.Count; ++i) { if (_bones[i].Transform != null) { _bones[i].Transform.localRotation = data.BoneRotations[i].FromFlippedXQuatf(); if (_bones[i].Id == BoneId.Hand_WristRoot) { _bones[i].Transform.localRotation *= wristFixupRotation; } } } } }同期実装の流れ
ハンドトラッキングの一連の流れが明らかになったので、
いよいよ同期処理を考えていきます。オブジェクト同期と呼ばれる手法を用います。
流れとしては下記イメージです。
①各クライアントが手の位置情報を保持
②手の見た目のみの役割を持つオブジェクトを用意し、各自の手の位置情報に追従させる
③手の見た目のみの役割を持つオブジェクトを双方のクライアントに生成
④お互いの手の位置情報を送り合い、生成した手の位置情報を更新
おおざっぱではありますが、こんな感じです。
ハンドトラッキングでの同期方法
ここまでの理解でもかなり骨が折れましたが、本当に大変なのはここからでした。
一連の流れを見ればわかりますが、
手の見た目のみの役割を果たす同期用オブジェクトを
用意する必要があります。やり方としては、2つの選択肢があります。
1つは、事前にボーンとなるオブジェクト及び、手のメッシュを用意することです。実際に下記の海外勢のサンプルでは、この手法を用いていました。
【参考リンク】:SpeakGeek-Normcore-Quest-Hand-Tracking(サンプルではPUN2とは別のNormcoreというライブラリを使用して同期の実装を行っています)
下記GIFのように、あらかじめ同期するオブジェクトの中に
Bone及びBindPoseのオブジェクトがびっしりと用意されています。
ボーンの役割を担うオブジェクトはBindPoseも合わせると片手だけで全部で48個あります。
しかも、それぞれの座標が生成時に(0,0,0)ではないデフォルト値を持つので
自前で事前に用意するとなると、
生成時のすべての値を48×2回メモして一つずつ手打ち、、、もしくは
プレイモードで生成されたオブジェクトをそのまま保存できるスクリプトを用意する、、、
などなかなかの手間となります。さらに、プロジェクトを跨いで利用する際には
毎回オブジェクトをインポートする必要があるので少々効率が悪いです。そこで、もう一つのやり方として、
OVR系コンポーネントと同様に手の見た目のオブジェクトを動的に生成する方法を用います。しかし、既存のOVR系コンポーネントは切り離すことが困難な蜜結合な状態になっています。
ですので、手の見た目の役割を果たすオブジェクトを生成する処理を
OVR系コンポーネントから拝借して自前で用意する必要がありました。同期実装のコード
ここから実装の核心となるコードの説明です。
先ほどの同期実装の流れ
と合わせて見ていきます。
①各クライアントが手の位置情報を保持
この処理に関しては完全にOVR系コンポーネントに担ってもらいます。
OVRHand
とOVRSkeleton
をアタッチしたオブジェクトを各手のAnchorの子階層に配置します。
②手の見た目のみの役割を持つオブジェクトを用意し、各自の手の位置情報に追従させる
この処理に関しては長くなるので
・手の見た目のみの役割を持つオブジェクトを用意
・各自の手の位置情報に追従
の二つに分けて説明していきます。
手の見た目のみの役割を持つオブジェクトを用意
メッシュの生成に関しては
OVRMesh
をそのまま利用します。
生成するオブジェクトにSkinnedMeshrendererと共にアタッチしておきます。
ボーンの役割を担うオブジェクトの生成に関しては
OVRSkeleton
での処理をほぼ丸パクリです。/// <summary> /// Bonesを生成 /// </summary> /// <param name="skeleton">あらかじめ用意されたボーンの情報</param> /// <param name="hand">左右どちらかの手</param> private void InitializeBones(OVRPlugin.Skeleton skeleton, GameObject hand) { _bones = new List<OVRBone>(new OVRBone[skeleton.NumBones]); GameObject _bonesGO = new GameObject("Bones"); _bonesGO.transform.SetParent(hand.transform, false); _bonesGO.transform.localPosition = Vector3.zero; _bonesGO.transform.localRotation = Quaternion.identity; for (int i = 0; i < skeleton.NumBones; ++i) { OVRSkeleton.BoneId id = (OVRSkeleton.BoneId) skeleton.Bones[i].Id; short parentIdx = skeleton.Bones[i].ParentBoneIndex; Vector3 pos = skeleton.Bones[i].Pose.Position.FromFlippedXVector3f(); Quaternion rot = skeleton.Bones[i].Pose.Orientation.FromFlippedXQuatf(); GameObject boneGO = new GameObject(id.ToString()); boneGO.transform.localPosition = pos; boneGO.transform.localRotation = rot; _bones[i] = new OVRBone(id, parentIdx, boneGO.transform); } for (int i = 0; i < skeleton.NumBones; ++i) { if (((OVRPlugin.BoneId) skeleton.Bones[i].ParentBoneIndex) == OVRPlugin.BoneId.Invalid) { _bones[i].Transform.SetParent(_bonesGO.transform, false); } else { _bones[i].Transform.SetParent(_bones[_bones[i].ParentBoneIndex].Transform, false); } } }次にMeshの生成を行います。
ついでにMesh、SkinnedMeshRendererにBindPose、Boneの登録もそれぞれ行います。/// <summary> /// 手のボーンのリストを作成 /// 後にOculusの持つボーン情報のリストと照らし合わせて値を更新するので順番に一工夫して作成 /// </summary> /// <param name="hand">子にボーンを持っている手</param> /// <param name="bones">空のリスト</param> private void ReadyHand(GameObject hand, List<Transform> bones) { //'Bones'と名の付くオブジェクトからリストを作成する foreach (Transform child in hand.transform) { _listOfChildren = new List<Transform>(); GetChildRecursive(child.transform); //まずは指先以外のリストを作成 List<Transform> fingerTips = new List<Transform>(); foreach (Transform bone in _listOfChildren) { if (bone.name.Contains("Tip")) { fingerTips.Add(bone); } else { bones.Add(bone); } } //指先もリストに追加 foreach (Transform bone in fingerTips) { bones.Add(bone); } } //動的に生成されるメッシュをSkinnedMeshRendererに反映 SkinnedMeshRenderer skinMeshRenderer = hand.GetComponent<SkinnedMeshRenderer>(); OVRMesh ovrMesh = hand.GetComponent<OVRMesh>(); Matrix4x4[] bindPoses = new Matrix4x4[bones.Count]; Matrix4x4 localToWorldMatrix = transform.localToWorldMatrix; for (int i = 0; i < bones.Count; ++i) { bindPoses[i] = bones[i].worldToLocalMatrix * localToWorldMatrix; } //Mesh、SkinnedMeshRendererにBindPose、Boneを反映 ovrMesh.Mesh.bindposes = bindPoses; skinMeshRenderer.bones = bones.ToArray(); skinMeshRenderer.sharedMesh = ovrMesh.Mesh; } /// <summary> /// 子のオブジェクトのTransformを再帰的に全て取得 /// </summary> /// <param name="obj">自身の子を全て取得したいルートオブジェクト</param> private void GetChildRecursive(Transform obj) { if (null == obj) return; foreach (Transform child in obj.transform) { if (null == child) continue; if (child != obj) { _listOfChildren.Add(child); } GetChildRecursive(child); } }Bonesの子階層、すなわち指のボーンとなるオブジェクトから謎のリストを作成している理由は
次の 各自の手の位置情報に追従 で説明します。
各自の手の位置情報に追従
先ほど作成した謎の順番整理を行ったリストですが、各自の手の位置情報に追従させる際に
利用する上で都合が良いです。というのも、
IOVRSkeletonDataProvider
から渡ってきたボーン情報の順番が少し複雑だからです。
"Tip"と名の付く指先以外のボーンの位置情報以外が親指から順に列挙して送られてきたのち、
"Tip"と名の付く指先の情報が親指から順に送られてきます。下記コードで見るとより理解しやすいと思います。
ミニサンプル(左手のみ)[SerializeField] private GameObject _leftHandVisual; private readonly List<Transform> _bonesL = new List<Transform>(); private List<Transform> _listOfChildren = new List<Transform>(); private Quaternion _wristFixupRotation void Start() { OVRSkeleton ovrSkeletonL = GameObject.Find("OVRHandL").GetComponent<OVRSkeleton>(); OVRSkeleton.IOVRSkeletonDataProvider dataProviderL = ovrSkeletonL.GetComponent<OVRSkeleton.IOVRSkeletonDataProvider>(); //ボーンの情報をC#で利用可能にするラッパークラス OVRPlugin.Skeleton skeleton = new OVRPlugin.Skeleton(); //ボーンの元データを生成 OVRPlugin.GetSkeleton((OVRPlugin.SkeletonType) dataProviderL.GetSkeletonType(), out skeleton); InitializeBones(skeleton, _leftHandVisual); //正しい順序で生成したボーンのリストを作成 ReadyHand(_leftHandVisual, _bonesL); _wristFixupRotation = new Quaternion(0.0f, 1.0f, 0.0f, 0.0f); } void Update() { //左手 if (_dataL.IsDataValid && _dataL.IsDataHighConfidence) { //ルートのローカルポジションを適用 _leftHandVisual.transform.localPosition = _dataL.RootPose.Position.FromFlippedZVector3f(); _leftHandVisual.transform.localRotation = _dataL.RootPose.Orientation.FromFlippedZQuatf(); _leftHandVisual.transform.localScale = new Vector3(_dataL.RootScale, _dataL.RootScale, _dataL.RootScale); //ボーンのリストに受け取った値を反映 for (int i = 0; i < _bonesL.Count; ++i) { _bonesL[i].transform.localRotation = _dataL.BoneRotations[i].FromFlippedXQuatf(); if (_bonesL[i].name == OVRSkeleton.BoneId.Hand_WristRoot.ToString()) { _bonesL[i].transform.localRotation *= _wristFixupRotation; } } } }順番を整理したおかげで、for文を利用したボーンの情報をリストに順番通り取得してくる処理
が容易になっています。ここまでの処理で
手の見た目のオブジェクト
だけを同期オブジェクトとして実装することが可能となりました。③手の見た目のみの役割を持つオブジェクトを双方のクライアントに生成
この処理に関しては非常に簡単です。
PhotonNetwork.Instantiate
を使えばPUN2が自動で生成してくれます。コードに落とし込むと下記です。
適当なオブジェクトにアタッチusing Photon.Pun; using Photon.Realtime; using UnityEngine; public class PunConnect : MonoBehaviourPunCallbacks { [SerializeField] private GameObject _avatar; private const int _PLAYER_UPPER_LIMIT = 2; //ルームオプションのプロパティー private RoomOptions _roomOptions = new RoomOptions() { MaxPlayers = _PLAYER_UPPER_LIMIT, //人数制限 IsOpen = true, //部屋に参加できるか IsVisible = true, //この部屋がロビーにリストされるか }; private void Start() { //PhotonServerSettingsに設定した内容を使ってマスターサーバーへ接続する PhotonNetwork.ConnectUsingSettings(); } //マスターサーバーへの接続が成功した時に呼ばれるコールバック public override void OnConnectedToMaster() { // "Test"という名前のルームに参加する(ルームが無ければ作成してから参加する) PhotonNetwork.JoinOrCreateRoom("Test", _roomOptions, TypedLobby.Default); } //部屋への接続が成功した時に呼ばれるコールバック public override void OnJoinedRoom() { //アバターを生成 GameObject avatar = PhotonNetwork.Instantiate( _avatar.name, Vector3.zero, Quaternion.identity); avatar.name = _avatar.name; } }
_avatar
はPrefabをアタッチする必要があり、そのPrefabはAssets/Photon/PhotonUnityNetworking/Resources
に配置する必要があります。④お互いの手の位置情報を送り合い、生成した手の位置情報を更新
最後に同期オブジェクトの位置情報を共有する実装です。
PUN2の
IPunObservable
を経由して送受信します。ミニ同期処理サンプル(左手のみ)/// <summary> /// Transformをやり取りする /// </summary> /// <param name="stream">値のやり取りを可能にするストリーム</param> /// <param name="info">タイムスタンプ等の細かい情報がやり取り可能</param> void IPunObservable.OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { //自身のクライアントから相手クライアントの同期オブジェクトに送る情報 if (stream.IsWriting) { stream.SendNext(_leftHandVisual.transform.localPosition); stream.SendNext(_leftHandVisual.transform.localRotation); //ボーンのリストに受け取った値を反映 for (var i = 0; i < _bonesL.Count; ++i) { stream.SendNext(_bonesL[i].transform.localRotation); } } //相手のクライアントから自身のクライアントの同期オブジェクトに送られてくる情報 else { _leftHandVisual.transform.localPosition = (Vector3) stream.ReceiveNext(); _leftHandVisual.transform.localRotation = (Quaternion) stream.ReceiveNext(); //ボーンのリストに受け取った値を反映 for (var i = 0; i < _bonesL.Count; ++i) { _bonesL[i].transform.localRotation = (Quaternion) stream.ReceiveNext(); } } }これでようやく同期が完了しました。
最後に
ここまでの理解ですらかなりの時間を要しましたが、
最適化やUI/UXの面からまだまだ課題は多いです。今後も引き続きハンドトラッキング含め、同期に関して
調査しようと思っています。参考リンク
- 投稿日:2020-06-29T14:38:11+09:00
UnityにおけるInvokeとCoroutineの精度比較
環境
windows10
Unity 2019.3.15f1指定時間後に動作する処理を書きたい
⇒高精度を求めるならCoroutine一択!※
理由は下記の記事になります。※2020/06/30 FPSに依存するようです。高精度ではなく、Updateと同じ精度と考えるのが良さそうです。
InvokeとCoroutine
「Unity 指定時間後」で調べるとこの二つが出てきます。
【Unity】スクリプトの処理の実行タイミングを操作する
↑こちらの記事がわかりやすいです。精度はどうなの?
「BPM200の16分音符を鳴らしたい=音を出してから75ms後に音を止めたい」
これが私にとっての課題です。InvokeもしくはCoroutineはこの課題を解決してくれるのでしょうか。測定をする
測定にはStopwatchクラスを使用します。今回の測定を行うには十分に高精度です。
どちらも「スペースキー」が押されるとタイマースタート、
一定時間後にタイマーストップをするプログラムです。Invokeのテストソース
Invoke(xxx, Time); の第二引数で時間を決めます。
InvokeTest.cspublic class InvokeTimerTest : MonoBehaviour { [SerializeField, Range(0.01f, 1.0f)] float Time; List<long> results = new List<long>(); Stopwatch sw = new Stopwatch(); private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { sw.Start(); //計測開始 Invoke("TestInvoke", Time); } } void TestInvoke() { sw.Stop(); UnityEngine.Debug.Log($"TestInvoke()[{results.Count + 1}]:{sw.ElapsedMilliseconds}ms"); results.Add(sw.ElapsedMilliseconds); sw.Reset(); } }Coroutineのテストソース
yield return new WaitForSecondsRealtime(Time);の第一引数で時間を決めます。
Coroutine.cspublic class CoroutineTimerTest : MonoBehaviour { [SerializeField, Range(0.01f, 1.0f)] float Time; List<long> results = new List<long>(); Stopwatch sw = new Stopwatch(); private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { sw.Start(); //計測開始 StartCoroutine(TestCoroutine()); } } IEnumerator TestCoroutine() { yield return new WaitForSecondsRealtime(Time); sw.Stop(); UnityEngine.Debug.Log($"TestCoroutine()[{results.Count + 1}]:{sw.ElapsedMilliseconds}ms"); results.Add(sw.ElapsedMilliseconds); sw.Reset(); } }結果
Timeの値を変えて、各条件で100回実行した結果になります。
条件1.Time=100msの場合
最大値 最小値 平均 標準偏差 Invoke 104 83 93.79 6.02 Coroutine 108 100 102.50 1.67 条件2.Time=50msの場合
最大値 最小値 平均 標準偏差 Invoke 57 34 44.28 7.06 Coroutine 57 52 55.8 0.89 条件3.Time=10msの場合
最大値 最小値 平均 標準偏差 Invoke 18 0 4.03 5.13 Coroutine 25 10 17.54 2.24 条件4.Time=500msの場合
最大値 最小値 平均 標準偏差 Invoke 506 480 494.23 6.28 Coroutine 509 500 503.58 2.97 考察
1. 誤差
Invokeの誤差は、+に8ms, -に20msが最大です。
Coroutineの誤差は、+に15ms, -に0msが最大です。
偏差を比べてもCoroutineの方が小さい傾向にあります。
1ms単位とは言いませんが、±10ms程度のタイマーとしては有効ではないでしょうか。2. 最小値
Coroutineでは最小値が設定値を下回る事がありません。タイマーの時間が過ぎたら実行するプログラムと考えると納得がいきます。しかし、Invokeの最小値は設定値を下回っています。おおよそ60Hzの1フレーム分くらい早めに判定される事があるようです。(理由はよくわからないので、詳しい方補足をお願いします。)
3. Timeの限界値
Time=10msではInvokeの最小値が0になる、Coroutineの誤差も最大+15ms(つまり、目標値の2.5倍)となり、正確な値が取れているとは言い難い状況です。Coroutineの方が精度が良いとはいえ、Time=50msでも約15%の誤差がありますので、過信は禁物です。どちらの誤差も総時間によらない固定値にも思えますので、長時間になればなるほど誤差率は下がりそうです。
まとめ
Invokeでもシビアじゃない環境では十分に使える精度で動作しますが、Coroutineの方が精度よく引数も使えると利点があります。私の課題「75ms音を出す」は「誤差+10ms以内」で達成出来そうです。「この精度で十分かどうか」、それはもちろん使う用途によりますが、このデータが判断の手助けになれば幸いです。
- 投稿日:2020-06-29T11:17:20+09:00
【ProBuilder】プロビルダーを使ってみよう【cluster】
開発環境は、clusterで開発しやすいように、
unity2019.2.21f1 を使用しています。
他のバージョンでも、同様にProBuilderを導入することができると思います。cluster公式サイトでもProBuilderの解説はされています。
この記事では、特に必要な要素のみ抽出しています。参考サイト
【cluster公式】誰でも簡単にプロ並みのワールドを。ProBuilderでワールド制作
解説動画はこちら
【うのっちチャンネル】
ProBuilderの使い方(基本編)
ProBuilderのインストール編
unityのメニューバーから、[Window]-[Package Manager]を選択する。
検索ウィンドウに probuilder と入力して、ProBuilderが表示されたら、[Install]ボタンを押下する。
ProBuilderの準備編
unityのメニューバーから、[Tools]-[ProBuilder]-[ProBuilder Window]を選択する。
最初はリスト表示だとわけわからんと思うので、アイコン表示に切り替える。
ウィンドウ右上にあるリスト切り替えのアイコンをクリックして、[Use Icon Mode]を選択する。
(無理やりアイコンにする必要はない)
ProBuilderのウィンドウは、しばらく使うので、他のウィンドウとドッキングしておくと楽。
ウィンドウのドッキングについてピンとこない方は下記記事を参考にしてください。
【unity】ウィンドウタブの動かし方
ProBuilderでオブジェクトを出してみる編
下図のように、[Alt]キーを押しながら、アイコンをクリック。
下図のウィンドウが表示される。
使い方は、
適当にオブジェクトやサイズを選択して、[Build]を押下すると、Scene上に配置される。
今回の使用例については後述。
下図のように、ヒエラルキーウィンドウに、ProBuilderやShapeToolをドッキングしておくと楽だったりする。
好みの配置を見つけておこう。
ProBuilderでオブジェクトをいじってみる編
オブジェクトの加工編
前項のつづき。
キューブを選択した状態で、
下図を参考にして、アイコンをクリックする。(Flip Object Normals というアイコン)
さらに、下図を参考にして、アイコンをクリックする。(Subdivide Objectというアイコン)
下図のように、キューブに分割線が入る。
ここに関しては、Blender経験者もわかってくるポイントだと思う。
下図のように、面選択モードのアイコンを選択した状態で、
オブジェクト内の適当な面を選択する。
下図を参考にして、アイコンをクリックする。(Extrude Facesというアイコン)
下図は、分割と押し出しを駆使して作成したもの。
元はひとつのキューブなので、ProBuilderのすごさが少しでも体感できると思う。
マテリアル編
下図を参考にして、アイコンをクリックする。(Material Editorというアイコン)
下図のように、ProBuilderで使用するオブジェクト用のマテリアルのパレットが表示される。
ここでマテリアルを登録すると、ProBuilderで設置したオブジェクトのみ、これらが適用される。
下図のように、オブジェクトを選択→マテリアルのパレットから選択、を繰り返すと、かんたんにマテリアルを反映することができる。
これらは、ProBuilderで設置したオブジェクトが対象となるので注意。
- 投稿日:2020-06-29T10:57:38+09:00
[Unity]ML-Agentsでサンプルプログラムが動かないときの対処法
何があった?
Unityで強化学習ができるML-Agentsを使って見ようと思い、ここを見ながら環境を構築していたら、こんなエラーが
The API number is not compatible between Unity and python. Python API: API-13, Unity API: 1.0.0.
Google翻訳
API番号はUnityとPythonの間で互換性がありません。 Python API:API-13、Unity API:1.0.0。
原因
どうやら調べてみると、
pip install ml-agentsでインストールできるml-agentsは古いらしく、ml-agentsの最新リポジトリからインストールする必要があるみたい
対処法
ml-agentsをダウンロードし、ターミナルでml-agents-masterまで移動し、以下のコマンドで最新リポジトリのml-agentsをインストールできます。
pip install -e ./ml-agents pip install -e ./ml-agents-envs
- 投稿日:2020-06-29T08:10:08+09:00
C#のみを使って、今ソーシャルゲームアプリを作るとしたら
はじめに
現在進行形でC#のみを使って個人でソシャゲ作りを試しているyoship1639です。
本記事はQiita夏祭り2020「〇〇(言語)のみを使って、今△△(アプリ)を作るとしたら」のテーマに沿った内容となっています。近年のソーシャルゲーム界隈は多様化が進んでクライアントサイドだけではなくサーバーサイドもあらゆる言語やフレームワークが試みられていますが、クライアントもサーバーも統一の言語で構成されているのはほとんどないかと思われます。言語にはその言語の得意分野があると思うので。
しかし、今まさに私が開発中の環境が好きな言語で開発しやすいという理由でクライアントもサーバーもC#で構成した作りになっているので、どのような構成でどうすれば最低限のソシャゲの基盤が作れるかを、解説が長くなり過ぎないようにまとめることが出来ればと思います。
三部構成で、クライアント実装、サーバー実装、AWS EC2へのデプロイまで解説できればと思っています。
Let's、C#のみでソシャゲを作ろう!ソシャゲの概要
内容に入る前に、ソシャゲがどの様な流れで動作するのかを軽く説明します。
ソシャゲは基本的にクライアント(スマホ端末)とサーバーとのやり取りで動いています。サーバーが動いていないとクライアントは基本動作しません。これはクライアント側で不正にデータの書き換えをされると運営が困るからです。
サーバー側は大体以下の様なAPI機能を備えています。
- アプリバージョン判定
- マスターデータ・アセットバンドル更新判定
- ログイン (セッション管理)
- アカウント作成
- クエスト開始・終了
- ガチャ
- etc...
挙げたらきりがないくらいにはサーバーにはやらなければならない仕事があります。それだけクライアントとサーバーは適所で通信しています。こうすることで、例えばクライアントのデータが紛失したとしてもサーバーから復元することが出来ますし、クライアント側で不正があったらサーバー側で検知してBANすることもできますし、ユーザーのアプリ上での動向から問い合わせにも対応することができるようになりますし、課金周りのレシート検証もサーバー側で正確に行えるので、課金したのに石が反映されないみたいな場面でも補填対応することが出来るようになります。基本ユーザにとっても運営にとってもメリットしかないです。
近年バックエンドはBaas(PlayFab、Firebase、GameSparks、GS2など)が鎬を削っており態々バックエンドを自前で準備しなくてもBaasを使うという手段がありますが、ドキュメントが英語のみだったり痒いところに手が届かなかったりと一長一短なので、どうしてもサーバーサイドを触りたくないという訳ではないのであれば個人的にはまだ自前で準備したほうが良いかな感はあります。
ソシャゲの動作の最初の流れとしては以下の様になります。
- アプリバージョンを検証
- ログイン (ログインできなかったらアカウント作成)
- 更新データ確認 (アセバン、マスターデータ)
- 以降アプリによって色々
今回は最低限の基盤だけ考えるので、2番の「アカウント作成」と「ログイン」機能を作りたいと思います。
構成の全体像
今回作るサンプルは、C#のみで構成するソシャゲの最低限の基盤で以下の構成となっています。
クライアント:C#(Unity2019.X)
サーバー:C#(.NetCore3.1)
デプロイ:AWS EC2(Amazon Linux 2)
サーバー <--> クライアント:MagicOnion(HTTP/2, gRPC)クライアントは皆大好きUnity、サーバーはプラットフォーム関係なく動かせる.NetCore、デプロイはEC2、クライアントとサーバーのやり取りは巷で噂のMagicOnion(gRPCのC#ラッパー+α)です。最低限の構成であれば全部無料で準備できます。
本来であれば、DB用意したり、直じゃなくDockerコンテナでデプロイとかすべきですが、本記事から内容が逸れそうなので簡単な構成にしています。
まずは、クライアントサイドから作ってみます。
クライアントサイド
クライアントサイドはエンジンとしてUnity2019.Xを使います。言語は当然C#です。
実装手順としては以下の通りとなります。
① MagicOnion, MessagePack, grpc をUnityにインポートする
② Serviceを定義
③ NetworkManagerを実装
④ ログインテストコードを実装① MagicOnion, MessagePack, grpc をUnityにインポートする
まず、MagicOnion、MessagePack、grpcをUnityにインポートします。サーバーと通信するのに必要なものです。
これらを簡単に説明すると、
- MagicOnion: リアルタイム/API通信フレームワーク。gRPCをC#で使いやすいようにラップしたイメージ。
- MessagePack: 高効率のバイナリ形式のシリアライズフォーマット。JSONよりすごいやつ。MagicOnionに必要。
- grpc: googleが作ったRPCフレームワーク。MagicOnionの中身はこれ。
となっています。
なんでMagicOnionを使うかというと、以下のメリットがあるからです。
- HTTP/2の恩恵を受け、かつ通信データが高効率で圧縮されるため通信が早い。
- インターフェースベースの通信が実現されるのでデータフォーマットを考えなくていい。
- エンドポイントやAPIスキーマを考えなくていい。
- APIだけでなくリアルタイム通信としても使える。
使うには十分すぎるメリットではないかと思います。MagicOnionの詳細は解説しないので、各自調べていただければと思います。
まず、MagicOnionをインポートします。
https://github.com/Cysharp/MagicOnion/releasesこちらのリリースページにある「MagicOnion.Client.Unity.unitypackage」をダウンロードしUnityにインポートしてください。色々足りないと怒られますが気にせず次へいきます。
次に、MessagePackをインポートします。
https://github.com/neuecc/MessagePack-CSharp/releasesこちらのリリースページにある「MessagePack.Unity.XXXXX.unitypackage」をダウンロードしUnityにD&Dしてください。最新のリリースで問題なく動作するはずです。
この時、Pluginsフォルダ内のdllが既に取り込まれているよと警告されるので、Pluginsフォルダのチェックを外してインポートしてください。
最後に、grpcをインポートします。
https://packages.grpc.io/こちらのページの最新のコミットのBuild IDをクリックし、C#欄にある「grpc_unity_package.XXXXX-dev.zip」をダウンロード、解凍します。
解凍すると「Plugins」フォルダがあるはずなので、Pluginsフォルダの中身をUnityのAssets/Pluginsフォルダに入れてインポートします。それでもまだ怒られると思うので、エラーを解決していきます。
- System.Buffersが被っているので、どちらかを削除
- System.Memoryが被っているので、どちらかを削除
- System.Runtime.CompilerServices.Unsafeが被っているので、どちらかを削除
- unsafeコードが許可されていないぞ☆って怒られるのでunsafeコードを許可
これでエラーは出なくなるはずです。
② Serviceを定義
諸々インポートが完了したらServiceを定義します。ServiceとはWebAPIと同様のものと考えていただければと思います。
ソーシャルゲームは基本的に特定の動作ごとにサーバーにAPIを投げてそのレスポンスを基にクライアントを動かします。本来、API定義を考える場合「https://〇〇〇〇/create_account」みたいなエンドポイントやらスキーマやらを考えなくてはいけませんが、MagicOnionの場合はインターフェース定義自体がそれに当たります。これメチャクチャ便利です。
アカウント作成とログインの機能は、以下の様に定義できます。
IAccountService.csusing MagicOnion; // アカウント周りのサービスを定義するインターフェース public interface IAccountService : IService<IAccountService> { // アカウント作成 UnaryResult<(string userId, string password)> CreateAccount(); // ログイン UnaryResult<string> Login(string userId, string password); }CreateAccountはサーバー側で作成されたユーザIDとパスワードを返し、Loginは引数にユーザIDとパスワードを入力するとログイン中であるセッション情報(string)を返します。
クライアントはIAccountServiceだけを知っていればいいので、IAccountServiceの実態はサーバー側で実装します。
③ NetworkManagerを実装
Serviceの定義が終わったら実際にサーバーと通信する処理を担当するNetworkManagerを実装します。
クライアントはこのNetworkManagerを使ってサーバーとのやり取りをします。NetworkManager.csusing System; using System.Threading.Tasks; using Grpc.Core; using MagicOnion.Client; using UnityEngine; public class NetworkManager : MonoBehaviour { [SerializeField] private string applicationHost = "localhost"; [SerializeField] private int applicationPort = 12345; private IAccountService accountService; private string session; void Start() { var channel = new Channel(applicationHost, applicationPort, ChannelCredentials.Insecure); accountService = MagicOnionClient.Create<IAccountService>(channel); } // アカウント作成 public async Task<(string userId, string password)> CreateAccount() { try { // サーバーにアカウント作成を要求、レスポンスは作成されたユーザIDとパスワード return await accountService.CreateAccount(); } catch (Exception e) { Debug.Log(e); return (null, null); } } // ログイン public async Task<bool> Login(string userId, string password) { try { // ユーザIDとパスワードをサーバーに投げてログイン、レスポンスはセッション情報 session = await accountService.Login(userId, password); return session != null; } catch (Exception e) { Debug.Log(e); session = null; return false; } } }applicationHostは
localhost
にしてありますが、後でデプロイ先のエンドポイントに切り替えます。
セキュリティの関係からsslにすべきですが、今回は割愛です。④ ログインテストコードを実装
実際にログインのテストコードを記述してみます。
処理内容はとても単純で、まずローカルに保存してあるユーザー情報(ユーザーID、パスワード)を読み込みます。ユーザー情報そのものがなかったらアカウントを作成し作成されたユーザー情報を保存します。次に、ユーザー情報を元にログインし、通った時と通らなかった時で処理を分けるという形です。LoginTest.csusing System.IO; using MessagePack; using UnityEngine; [MessagePackObject] public class UserData { [Key(0)] public string userId; [Key(1)] public string password; } public class LoginTest : MonoBehaviour { async void Start() { // ネットワークマネージャ取得 var network = GetComponent<NetworkManager>(); // 保存してあるユーザーデータ情報を読み込み UserData userData = null; try { userData = MessagePackSerializer.Deserialize<UserData>(File.ReadAllBytes(Application.persistentDataPath + "/userData.dat")); } catch { } // ユーザーデータが存在しなかったらアカウント作成 if (userData == null) { Debug.Log("アカウント作成開始"); var res = await network.CreateAccount(); if (res.userId == null || res.password == null) { // TODO: アカウント作成失敗時の処理 Debug.LogWarning("アカウント作成失敗。。。"); return; } userData = new UserData(); userData.userId = res.userId; userData.password = res.password; // ユーザー情報保存(※本来は暗号化等する事!) var data = MessagePackSerializer.Serialize(userData); File.WriteAllBytes(Application.persistentDataPath + "/userData.dat", data); Debug.Log("アカウント作成成功"); } // ログイン Debug.Log("ログイン中..."); var loginResult = await network.Login(userData.userId, userData.password); if (!loginResult) { // TODO: ログイン失敗時の処理 Debug.LogWarning("ログイン失敗。。。"); return; } // TODO: ログインが通った後の処理 Debug.Log("ログイン成功!"); } }本来ならばもっと厳密にログイン処理を行うべきですが、今回はテストなので超単純に作っています。
ここを通ればログインに成功したことになるので、後はクライアント側は煮るなり焼くなりするだけです。次に、サーバーサイドの実装に移ります。
サーバーサイド
サーバーサイドはフレームワークとして.NetCore3.1を使います。言語は当然C#です。
.NetFrameworkを使ってしまうとデプロイ周りで苦労することになるので、サーバーサイドC#は.NetCoreを使ってください。サーバーの実装手順としては以下の様になります。
① プロジェクトの準備、MagicOnionのインストール
② Mainプログラムの記述
③ AccountServiceの実装
④ ローカル環境で動作確認① プロジェクトの準備、MagicOnionのインストール
まず、プロジェクトを作成します。プロジェクトは「コンソール アプリ(.NET Core)」を選択してください。プロジェクト名は何でもいいです。私はとりあえず「Qiita2020TestServer」にしました。
プロジェクトの作成が終わったら、クライアントとの通信に必要なコンポーネントをNuget経由でインストールします。
プロジェクトのコンテキストメニューの「Nuget パッケージの管理(N)...」からMagicOnion.Hosting
をインストールします。バージョンは最新の安定板で大丈夫です。
一応、Unityで使われている型をサーバーでも扱えるように
MessagePack.UnityShims
もインストールしておきます。
これでサーバーサイドに必要なコンポーネントがインストールできました。
② Mainプログラムの記述
サーバーを起動するMainプログラムを記述します。
やっていることはとても単純で、ログ出力先をコンソールに指定し、ホストとポートを指定して起動しているだけです。Program.csusing Grpc.Core; using MagicOnion.Hosting; using MagicOnion.Server; using Microsoft.Extensions.Hosting; using System.Threading.Tasks; namespace Qiita2020TestServer { class Program { static async Task Main(string[] args) { // コンソールにログ出力するように設定 GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger()); // MagicOnionを使ってホスト作成、起動 await MagicOnionHost.CreateDefaultBuilder() .UseMagicOnion( new MagicOnionOptions(isReturnExceptionStackTraceInErrorDetail: true), new ServerPort("0.0.0.0", 12345, ServerCredentials.Insecure)) .RunConsoleAsync(); } } }一応これだけでもサーバーを起動することはできます。デバッグ実行すると以下の様な画面が出るはずです。
これだけでは何の機能もない張りぼてサーバーなので、クライアント側で実装した「アカウント作成」と「ログイン」機能を実装していきます。
③ AccountServiceの実装
サーバー側のアカウント作成とログイン機能の実装をします。
クライアントで定義したIAccountService.csが必要なので、予め丸々コピーしておいてください。(本来は、submodule等用いてソースコードの共有をすることをお勧めします。)アカウント作成は、ランダムなハッシュ値を用います。ユーザーIDは20桁、パスワードは12桁にしておきます。ログインは作成されたユーザーIDとパスワードを検証し、一致したら以降のAPIを呼び出すことが出来るセッションを返します。セッションも一先ずランダムな20桁のハッシュ値を返します。
AccountService.csusing MagicOnion; using MagicOnion.Server; using System; using System.Collections.Generic; using System.IO; using System.Security.Cryptography; using System.Text; namespace Qiita2020TestServer { class AccountService : ServiceBase<IAccountService>, IAccountService { // セッション情報管理(本来はRedis等用いる事!) private static Dictionary<string, (string userId, DateTime expireAt)> sessions = new Dictionary<string, (string userId, DateTime expireAt)>(); private static object lockObject = new object(); // アカウント作成 public async UnaryResult<(string userId, string password)> CreateAccount() { Logger.Info("CreateAccount Request"); var userId = GenerateHash(20); var password = GenerateHash(12); // アカウント情報を仮でファイルに保存(本来はDBに入れる事!) try { if (!Directory.Exists("accounts")) Directory.CreateDirectory("accounts"); File.WriteAllText("accounts/" + userId, password); } catch (Exception e) { Logger.Error(e, "CreateAccount Error"); return (null, null); } Logger.Info($"CreateAccount UserId:{userId}, Password:{password}"); return (userId, password); } // ログイン public async UnaryResult<string> Login(string userId, string password) { Logger.Info("Login Request"); try { // アカウントがない if (!File.Exists("accounts/" + userId)) return null; // パスワードが一致しない if (File.ReadAllText("accounts/" + userId) != password) { Logger.Warning("Login failed: " + (userId, password)); return null; }; } catch (Exception e) { Logger.Error(e, "Login Error"); return null; } // セッション情報作成 var session = GenerateHash(20); lock (lockObject) { // 一先ず1日有効なセッションを保存 sessions[session] = (userId, DateTime.UtcNow.AddDays(1)); } Logger.Info("【" + userId + "】Login succeeded!"); // セッションを返す return session; } // 指定の長さのランダムハッシュ値を取得 private static string GenerateHash(int length) { return Sha256(Guid.NewGuid().ToString("N")).Substring(0, length).ToLower(); } // Sha256ハッシュ private static string Sha256(string str) { var input = Encoding.ASCII.GetBytes(str); var sha = new SHA256CryptoServiceProvider(); var sha256 = sha.ComputeHash(input); var sb = new StringBuilder(); for (int i = 0; i < sha256.Length; i++) { sb.Append(string.Format("{0:X2}", sha256[i])); } return sb.ToString(); } } }これで、アカウント作成とログイン機能を備えたサーバープログラムが整いました。
④ ローカル環境で動作確認
ここまでで、ローカル環境で動作確認をすることが出来るようになったので、確認してみます。
サーバーをデバッグ実行してローカルサーバーを立ち上げ、クライアントをデバッグ実行します。
問題がなければクライアントは以下の様に表示されるはずです。
サーバー側は以下の様に表示されます。
ローカルで問題なく動作できていることが確認できました。
最後に、実際にクラウド上にデプロイして確認してみたいと思います。デプロイ
ローカル環境で問題なく動作させることが確認できれば、本来はデプロイまでは頑張らなくてもいいですが、せっかくなのでEC2へのデプロイまでやってみたいと思います。CI/CDやDockerコンテナでもよかったのですが解説が逸れそうなので直デプロイします。
手順としては、以下の様になります。
① awsでEC2インスタンスを用意、起動する
② ターミナルでEC2インスタンスにログイン
③ .NetCore3.1をインストール
④ サーバープロジェクトを配置、実行
⑤ クライアント動作確認① awsでEC2インスタンスを用意、起動する
最初にAWSコンソールにサインインします。アカウントを持ってない人は作ってください。
サインインしたらEC2を選択します。EC2は仮想サーバーみたいなものだと思ってください。
名前を「Qiita2020TestServer」(名前は何でもいいです)にして、「キーペアを作成」をクリックします。
キーペアが作成されppkファイルがダウンロードされます。
このキーは後で作るインスタンスへのログインに必要なので、大切に保管しましょう。何のインスタンスを作るか聞かれるので、「Amazon Linux 2 AMI」を選択してください。
どのスペックの仮想マシンを立ち上げるか聞かれるので、無料で使える「t2.micro」を選択し、「次のステップ:インスタンスの詳細と設定」をクリック。
いろんな設定項目がありますが、ここでは「自動割り当てパブリックIP」を「有効」にします。
有効にしたら「次のステップ:ストレージの追加」へ。
ストレージは30GBまで無料らしいので一先ず30GBに設定し「次のステップ:タグの追加」へ。
タグの追加を押し、キーに「Name」、値に「Qiita2020TestServer」と入力します。(値はわかれば何でもいいです)
入力したら「次のステップ:セキュリティグループの設定」へ。
「ルールの追加」を押し、以下の様に入力し12345ポートを解放します。
分かりやすいように説明も入れておきましょう。(※画像は日本語ですが、日本語の説明ではインスタンスが作成できなかったので、英語で入力してください!)
入力したら「確認と作成」をクリック。
確認画面で「すべてのIPからインスタンスにアクセスできるよ、いいの?」と警告されますが気にせず「起動」を押します。
インスタンスに安全に接続するためのキーペアを選びます。先ほど作成した「Qiita2020TestServer」キーペアを選んで、「インスタンスの作成」をクリックします。
インスタンス作成中の画面が表示されるので、右下の「インスタンスの表示」をクリックしてください。
すると、作成されたインスタンス一覧が表示されます。問題がなければインスタンスはそのまま起動します。
これで、インスタンスの準備は整いました。
作成したインスタンスの「IPv4パブリックIP」はターミナル接続先なので控えておいてください。② ターミナルでEC2インスタンスにログイン
EC2インスタンスにログインします。
ターミナルソフトは何でもいいですが、私は「TeraTerm」を使って解説します。TeraTermを起動したらホストに先ほど作成したインスタンスのパブリックIPを入力してOKをクリック。
SSH認証が必要なので、ユーザ名に「ec2-user」、認証方式には最初の方に作成したキーペアの秘密鍵「Qiira2020TestServer.ppk」を指定します。
問題なくSSH接続できたら以下の様に表示されます。ここからはいつものターミナルです。
③ .NetCore3.1をインストール
必要なパッケージをインストールします。今回は.NetCoreを動かすためのランタイム「.NetCoreRuntime」をインストールすればOKです。
まずはパッケージ更新
$ sudo yum update
Microsoftパッケージリポジトリを追加。
$ sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm
.NetCore3.1SDKインストール
$ sudo yum install dotnet-sdk-3.1
.NetCore3.1ランタイムインストール
$ sudo yum install dotnet-runtime-3.1
これでEC2上で.NetCore3.1プロジェクトが実行できるようになりました。
④ サーバープロジェクトを配置、実行
サーバープロジェクトを配置します。配置方法は何でもいいですが個人的にはgitを使うのが一番楽です(余力のある方はGithubActions等を使ったCI/CDをお勧めします。)。サーバープロジェクトをgithubでリモート管理し、git cloneでそのまま配置します。こうすると何が良いかというと、サーバープロジェクトの更新が入った時にgit pullするだけで更新できます。
ただ今回はプロジェクトをgithubに配置しないので、おとなしくsftpで配置します。sftpの解説はしません。各自良い感じにサーバープロジェクトを根こそぎ持ってきてください。配置場所は
ec2-user
フォルダ内に「Qiita2020TestServer」を作ります。
$ mkdir Qiita2020TestServer
この中にプロジェクトを配置します。配置したら、Qiita2020TestServer.csprojがあるフォルダまで移動します。
.Netはcsprojをそのままdotnet run
で実行することが出来るので、実行してみます。
$ dotnet run
これで以下の様にEC2上でサーバーが立ち上がりました。
直に立ち上げるとSSHを終了した段階でサーバープログラムが止まってしまうのでscreen
を使うと色々捗ります。
最後にクライアントからEC2上にアクセスできるか確認します。
⑤ クライアント動作確認
クライアントのapplicationHostが
localhost
のままなので、NetworkManagerのこの部分をEC2インスタンスのパブリックIPに置き換えてください。
置き換えて、クライアントをデバッグ実行します。そして、以下の表示が出たらEC2との接続に成功です!
これで、C#のみで作ったソシャゲの最低限の基盤ができました。
ここからマスターデータやアセバン管理、クエスト処理やガチャ処理等を生やしていけば、C#のみで作るソシャゲの出来上がりです。お疲れまでした。おわりに
いかがでしょうか、意外と簡単にソシャゲの最低限の基盤を作ることができたのではないかと思います。もちろん本物はこれだけじゃ済まないボリュームですが、基礎を捉えるのは大きな前進になるのではないかと思います。
今回は省きまくりましたが、セキュリティだけはしっかり設定してください。ソシャゲはユーザの大事な情報を管理するので、ガバガバ設定では当然許されません。最低でも、ユーザーデータの暗号化、SSL、サーバー監視はしっかりするように!
皆様はもちろんC#信者だと思うので、その熱意をUnityだけではなくそのままサーバーサイドにも向けてみてはいかがでしょうか。私もまだまだサーバーサイドは勉強中なので偉そうなことは言えませんが、クライアントサイドとはまた別の面白さがあるので、やりがいはいっぱいあるかと思います。
頑張れば、一人でもソシャゲが作れる時代です。
最後まで読んでいただき、ありがとうございました。
参考資料
- 投稿日:2020-06-29T02:13:05+09:00
Unity で REST API を呼ぶ (そして Json を parse して Class に変換する)
注意
UniRx 周りの理解が甘いので、変なこと書いてるかもです。
https://www.hanachiru-blog.com/entry/2020/02/29/120000
現在この辺りを読みながら勉強中なので、何かわかれば追記、修正します。やること
https://xxxxx.com/xxxxx
にアクセスすると下記のような Json を返す API を、{ "items": [ { "user_name": "post", "points": { "of_distance": 5, "of_coin": 5 }, "total_point": 10, "timestamp": 1592761117 }, { "user_name": "post", "points": { "of_distance": 5, "of_coin": 5 }, "total_point": 10, "timestamp": 1592761117 } ] }Unity から呼び出して、 同じ構造を持った Class へ Deserialize, Parse する。
ライブラリのインポート
UniRx
Unity で非同期処理のタイミング調整(?)が行えるライブラリ。
async/await が使えるようになる。書き方によっては使わなくても良さそう。
導入方法
AssetStore から一式インポート
https://assetstore.unity.com/packages/tools/integration/unirx-reactive-extensions-for-unity-17276
Newtonsoft.Json
Json の Deserialize, Parse をいい感じにやってくれる。
導入方法
https://qiita.com/kingyo222/items/11100e8f7be396b98453
この方法で NuGet を import して、 NuGet の機能で Newtonsoft.Json を取得。
すると、 Assets/Packages/Newtonsoft.Json.12.0.3 が取得できる。
実装
通信を行うクラス
UnityWebRequest を使って json を取得して、JsonConvert.DeserializeObject を使って Deserialize, Parse を行う。
using System.Collections; using UnityEngine; using UnityEngine.Networking; using System.Collections.Generic; using Newtonsoft.Json; public class HttpSample { public string url; public Scores scores { get; private set; } public IEnumerator Get(System.IObserver<Scores> observer) { UnityWebRequest req = UnityWebRequest.Get(url); yield return req.SendWebRequest(); if (req.isNetworkError) { Debug.Log(req.error); } else if (req.isHttpError) { Debug.Log(req.error); } else { // json が返る string result = req.downloadHandler.text; // Scores 型に変換 this.scores = JsonConvert.DeserializeObject<Scores>(); observer.OnNext(scores); observer.OnCompleted(); } } // クラスに `[JsonObject("xxxxx")]` , // クラスプロパティに `[JsonProperty("xxxxx")]` をつけると、 // json parse 時のフォーマットを変更できる [JsonObject("scores")] public class Scores { [JsonProperty("items")] public List<Score> items; [JsonObject("score")] public class Score { [JsonProperty("user_name")] public string user_name; [JsonProperty("points")] public Points points; [JsonProperty("total_point")] public int total_point; [JsonProperty("timestamp")] public int timestamp; [JsonObject("points")] public class Points { [JsonProperty("of_distance")] public int of_distance; [JsonProperty("of_coin")] public int of_coin; } } } }呼び出すクラス
async/await を使って同期的に結果を取得し、取得したクラスを使っていく。
using UnityEngine; using UniRx; public class ScoresController : MonoBehaviour { // UniRx を入れると async が書ける async void Start() { HttpSample scoreHttp = new HttpSample("https://xxxxxxxxxxxxxxx.com/api"); // await を使うことで、結果を同期的に扱える。 HttpSample.Scores result = await Observable.FromCoroutine<HttpSample.Scores>(observer => scoreHttp.Get(observer)); Debug.Log(result) } }