⑬UNETで じゃんけん を作る
UNETを使ってネットワーク対戦のじゃんけんを作ってみたいと思います。
完成イメージ
今回は、UNETのSyncVarとhookを駆使して作ります。Commandを使うのは1箇所のみで、それ以外の機能(例えばClientRPCやMessage等)は使いません。
じゃんけんゲームの仕様
- タイトル画面にてプレイヤー名を入力できる
- ゲーム画面では、それぞれのプレイヤー名が表示される
- 2人以上接続されると対戦が始まる
- タイミングや時間制限などの要素は無く、全員が手を選び終わると、各自の手と勝敗が表示される
- 他のプレイヤーが考え中なのか入力済みなのかわかる
- 勝ち抜き的な要素は無く、1度の勝負が終わったら(あいこ含む)、また全員で次の勝負を行う(それの繰り返し)
- プレイヤーの途中参加OK
- 最大5人まで対戦可能
- ServerOnlyモードはサポートしない
準備
必要な画像リソースをこちらからダウンロードしてください。
今回は2Dゲームです。新規プロジェクトを2Dで作成してください。
プロジェクトを作ったら、ダウンロードした画像リソースをインポートしておいてください。
Gameシーンの作成
メインのGameシーンを作成します。
シーンを「Game」という名前で保存しておきましょう。
今回は、各種表示をUGUIで作ろうと思います。
というわけで、シーンにCanvasを追加してください。
で、CanvasScalerの設定を次のようにします。
アスペクト比4:3で作ります(深い意図はないです)
Main CameraのBackgroundは好きな色にしておいてください。
プレイヤーの作成(表示編)
プレイヤーの表示部分を作っていきます。
LayoutGroup
今回は、プレイヤーの数に応じて、フレキシブルに配置していきたいと思います。
UGUIの Horizontal Layout Group を使うと楽に実現できます。
Canvas配下に空のオブジェクトを追加し、名前をPlayerLayoutGroupとします。
HorizontalLayoutGroupコンポーネントをアタッチします。
次のような設定にします。
Player
PlayerLayoutGroupオブジェクトの子として空のオブジェクトを追加します。名前をPlayerとします。
PlayerオブジェクトにLayoutElementコンポーネントをアタッチし、次のように設定します。
キャラクター画像
Playerオブジェクトを右クリックし、UI>Imageを追加してください。名前をCharacterImageとします。
次のように設定します。
ここで、試しにPlayerオブジェクトを複製してみると、LayoutGroupとLayoutElementの効果により、自動でレイアウトが行われることが確認できると思います。
※確認後は1つだけ残して削除しておいてください。
手の画像
Playerオブジェクトを右クリックし、UI>Imageを追加してください。名前をHandImageとします。
次のように設定します。
プレイヤー名表示
Playerオブジェクトを右クリックし、UI>Textを追加してください。名前をPlayerNameTextとします。
次のように設定します。Outlineコンポーネントも追加します。
とりあえず、現状、次のような表示になっているはずです。
以上でプレイヤーのUI作成は完了です。
プレイヤーの作成(処理編)
まず、グー・チョキ・パーを表すenumを作っておきましょう。Projectビューで新規C#スクリプトを作成し、Handという名前にします。
public enum Hand { None, Guu, Choki, Paa, }
PlayerオブジェクトにNetworkIdentityコンポーネントをアタッチしてください。
Playerオブジェクトに新規スクリプトを作成・アタッチします。名前はPlayerとします。
using UnityEngine; using UnityEngine.UI; using UnityEngine.Networking; // プレイヤー1人分の表示を管理する。 // あとは選んだ手をCommandでサーバーへ送信する。 [NetworkSettings(sendInterval = 0)] public class Player : NetworkBehaviour { // タイトル画面で入力されたプレイヤー名 public static string s_LocalPlayerName = "名無しさん"; // プレイヤーの状態種別 public enum State { Initializing, /* 初期化中 */ Ready, /* 準備完了、待機中 */ Inputting, /* 手 入力中 */ Inputted, /* 手 入力完了 */ Win, /* 勝った */ Lose, /* 負けた */ Aiko, /* あいこ */ } // プレイヤーの状態 [SyncVar(hook = "OnStateChanged")] public State m_State = State.Initializing; // プレイヤーの画像 [SerializeField] Sprite m_NormalSprite; [SerializeField] Sprite m_InputtingSprite; [SerializeField] Sprite m_InputtedSprite; [SerializeField] Sprite m_WinSprite; [SerializeField] Sprite m_LoseSprite; [SerializeField] Sprite m_AikoSprite; // 手の画像 [SerializeField] Sprite m_GuuSprite; [SerializeField] Sprite m_ChokiSprite; [SerializeField] Sprite m_PaaSprite; // 手(グー・チョキ・パー) [SyncVar(hook = "OnHandChanged")] public Hand m_Hand = Hand.None; // プレイヤー名 [SyncVar(hook = "OnPlayerNameChanged")] string m_PlayerName; // プレイヤー画像を表示するImageの参照 Image m_CharacterImage; // 手を表示するImageの参照 Image m_HandImage; // プレイヤー名Textの参照 Text m_PlayerNameText; void Awake() { // プレイヤーオブジェクトはシーンのルートに生成されるので、 // PlayerLayoutGroupの中に移動する。 transform.SetParent(GameObject.Find("PlayerLayoutGroup").transform); // シーンのルートからPlayerLayoutGroupの中に移動すると、 // CanvasScalerの絡みでScaleがおかしくなるので、Scale=1に戻す transform.localScale = Vector3.one; // 各種参照を取得する m_HandImage = transform.Find("HandImage").GetComponent<Image>(); m_CharacterImage = transform.Find("CharacterImage").GetComponent<Image>(); m_PlayerNameText = transform.Find("PlayerNameText").GetComponent<Text>(); } void Start() { if (isLocalPlayer) { // プレイヤーオブジェクトは、生成された瞬間はInitializingという非表示状態である。 // クライアントで生成されたら、それをサーバーへ通知し、 // 状態がReadyとなる。 // (同時にプレイヤー名も送信する) CmdInitialize(s_LocalPlayerName); } // 各種表示の初期化 OnPlayerNameChanged(m_PlayerName); OnStateChanged(m_State); OnHandChanged(m_Hand); } [Command] void CmdInitialize(string playerName) { ChangeState(State.Ready); ChangePlayerName(playerName); } // プレイヤー名変更のhook void OnPlayerNameChanged(string name) { m_PlayerName = name; m_PlayerNameText.text = m_PlayerName; } // 状態変更のhook void OnStateChanged(State state) { m_State = state; // 状態に応じて、各表示を切り替える switch (m_State) { case State.Initializing: m_CharacterImage.enabled = false; // キャラ画像非表示 m_HandImage.enabled = false; // 手を非表示に m_PlayerNameText.enabled = false; // プレイヤー名非表示 break; case State.Ready: m_CharacterImage.enabled = true; // キャラ画像表示 m_CharacterImage.sprite = m_NormalSprite; // 通常時画像 m_PlayerNameText.enabled = true; // プレイヤー名表示 m_HandImage.enabled = false; // 手を非表示に break; case State.Inputting: m_CharacterImage.sprite = m_InputtingSprite; // 考え中画像 m_HandImage.enabled = false; // 手を非表示に break; case State.Inputted: m_CharacterImage.sprite = m_InputtedSprite; // 確定画像 m_HandImage.enabled = false; // 手を非表示に break; case State.Win: m_CharacterImage.sprite = m_WinSprite; // WIN画像 m_HandImage.enabled = true; // 手を表示 break; case State.Lose: m_CharacterImage.sprite = m_LoseSprite; // LOSE画像 m_HandImage.enabled = true; // 手を表示 break; case State.Aiko: m_CharacterImage.sprite = m_AikoSprite; // あいこ画像 m_HandImage.enabled = true; // 手を表示 break; default: Debug.LogError("想定外のstate:" + m_State); break; } } // 手が変更されたときのhook void OnHandChanged(Hand hand) { m_Hand = hand; // 選ばれた手の画像を表示する switch (m_Hand) { case Hand.None: break; case Hand.Guu: m_HandImage.sprite = m_GuuSprite; break; case Hand.Choki: m_HandImage.sprite = m_ChokiSprite; break; case Hand.Paa: m_HandImage.sprite = m_PaaSprite; break; default: Debug.LogError("想定外の手:" + m_Hand); break; } } // 状態を変更する [Server] public void ChangeState(State state) { // m_Stateを変更すれば、あとはhookによりOnStateChangedが実行される m_State = state; } // プレイヤー名を変更する [Server] void ChangePlayerName(string name) { // m_PlayerNameを変更すれば、あとはhookによりOnPlayerNameChangedが実行される m_PlayerName = name; } // 手の入力をサーバーへ通知する [Command] public void CmdInput(Hand hand) { ChangeState(State.Inputted); // m_Handを変更すれば、あとはhookによりOnHandChangedが実行される m_Hand = hand; } }
解説は記事後半でまとめてします。。。
Playerオブジェクトを選択しなおし、Playerコンポーネントの各Sprite欄に画像を設定していきます。
以上でPlayerオブジェクトの作成は完了です。Playerオブジェクトをプレハブ化し、シーンからは削除してください。
グー・チョキ・パー ボタンの作成
手を入力するためのボタンを作っていきます。
親オブジェクト
Canvasの子として空のオブジェクトを作り、名前をJankenButtonsとします。
PosYを-250にします。
グーのボタン
JankenButtonsオブジェクトを右クリックし、UI>Buttonを追加します。名前はGuuButtonにします。
次のように設定します。
GuuButtonオブジェクト配下のTextオブジェクトを削除します。
GuuButtonオブジェクトを右クリックし、UI>Imageを追加します。
Imageを次のように設定します。
以上でグーのボタンは完成です。
チョキのボタン
GuuButtonオブジェクトを複製し、名前をChokiButtonに変更します。
ChokiButtonオブジェクトのPosXを0にします。
ChokiButtonオブジェクト配下のImageオブジェクトのImage>SourceImageをjanken_chokiにします。
以上でチョキのボタンは完成です。
パーのボタン
ChokiButtonオブジェクトを複製し、名前をPaaButtonに変更します。
PaaButtonオブジェクトのPosXを100にします。
PaaButtonオブジェクト配下のImageオブジェクトのImage>SourceImageをjanken_paaにします。
以上でパーのボタンも完成です。
ボタンの処理
JankenButtonsオブジェクトに新規スクリプトを作成・アタッチします。スクリプト名はJankenButtonsとします。
using UnityEngine; using UnityEngine.UI; using UnityEngine.Networking; // グー・チョキ・パーのボタンの管理を行う public class JankenButtons : MonoBehaviour { GameObject m_GuuButton; GameObject m_ChokiButton; GameObject m_PaaButton; void Start() { // ボタンオブジェクトの参照を取得 m_GuuButton = GameObject.Find("GuuButton"); m_ChokiButton = GameObject.Find("ChokiButton"); m_PaaButton = GameObject.Find("PaaButton"); // ボタンが押された時の処理を登録 m_GuuButton.GetComponent<Button>().onClick.AddListener(() => OnClick(Hand.Guu)); m_ChokiButton.GetComponent<Button>().onClick.AddListener(() => OnClick(Hand.Choki)); m_PaaButton.GetComponent<Button>().onClick.AddListener(() => OnClick(Hand.Paa)); } // ボタンの表示/非表示を設定する public void SetVisibility(bool visible) { m_GuuButton.SetActive(visible); m_ChokiButton.SetActive(visible); m_PaaButton.SetActive(visible); } // ボタンが押された時の処理 void OnClick(Hand hand) { // 押されたことをCommandによってサーバーへ通知する ClientScene.localPlayers[0].gameObject.GetComponent<Player>().CmdInput(hand); // ボタンを非表示にする SetVisibility(false); } }
以上でボタン部分の作成は完了です。次のような表示になっているはずです。
ステータスのUIの作成
ゲームの状態を表示するUIを作っていきます。
Canvasを右クリックし、UI>Imageを追加します。名前はStatusImageとします。
次のように設定します。
GameControllerの作成
ゲーム全体を管理する機能を作っていきます。
シーンに空のオブジェクトを追加し、名前をGameControllerとします。
GameControllerオブジェクトにNetworkIdentityをアタッチします。
GameControllerオブジェクトに新規スクリプトを作成・アタッチします。スクリプト名はGameControllerとします。
using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using UnityEngine.Networking; // ゲーム全体を管理する [NetworkSettings(sendInterval = 0)] public class GameController : NetworkBehaviour { public enum State { WaitOtherPlayerConnect, /* 他プレイヤーの参加待ち */ Inputting, /* 入力中 */ Result, /* 結果表示 */ } [SyncVar(hook = "OnStateChanged")] State m_State = State.WaitOtherPlayerConnect; // ゲームの状況を表す画像リソース [SerializeField] Sprite m_WaitingSprite; // 「対戦相手募集中」 [SerializeField] Sprite m_InputtingSprite; // 「じゃん…けん…」 [SerializeField] Sprite m_ResultSprite; // 「ぽん!!」 // ゲームの状況を表示するImageの参照 Image m_StatusImage; // グー・チョキ・パーボタンを制御するコンポーネント JankenButtons m_JankenButtons; // Resultとなった瞬間の時間(Resultを終えるタイミングの判定に使う) float m_ResultStartTime; void Start() { m_StatusImage = GameObject.Find("StatusImage").GetComponent<Image>(); m_JankenButtons = FindObjectOfType<JankenButtons>(); OnStateChanged(m_State); } // Updateはサーバーでのみ行う [ServerCallback] void Update() { // 長くなってしまうので、Update処理は、状態別に分けた switch (m_State) { case State.WaitOtherPlayerConnect: UpdateWaitOtherPlayerConnect(); break; case State.Inputting: UpdateInputting(); break; case State.Result: UpdateResult(); break; default: Debug.LogError("想定外のGameState:" + m_State); break; } } // 他のプレイヤーの接続を待っているときのUpdate [Server] void UpdateWaitOtherPlayerConnect() { // Ready状態のプレイヤーの数を数え、2以上の場合は対戦を開始する int readyPlayerCount = 0; foreach (Player player in FindObjectsOfType<Player>()) { if (player.m_State == Player.State.Ready) { readyPlayerCount++; } } if (readyPlayerCount > 1) { ChangeGameState(State.Inputting); } } // 各プレイヤーが入力中のときのUpdate [Server] void UpdateInputting() { Player[] players = FindObjectsOfType<Player>(); // 入力中状態のときに、他プレイヤーが離脱して // プレイヤー数が1になった(自分だけとなった)場合は、 // 他のプレイヤー待機状態に戻る if (players.Length == 1) { ChangeGameState(State.WaitOtherPlayerConnect); players[0].ChangeState(Player.State.Ready); return; } // 入力中状態のときに新規接続してきたプレイヤーをInputting状態にしてあげる foreach (Player player in players) { if (player.m_State == Player.State.Ready) { player.ChangeState(Player.State.Inputting); } } // 未入力のプレイヤーがいる場合は、勝敗判定せず終了 foreach (Player player in players) { if (player.m_State != Player.State.Inputted) { return; } } // 全プレイヤーが入力し終わっている場合は、勝敗判定を行い、結果表示状態へ ChangeGameState(State.Result); // 場に出された全員の手の種類を調べる HashSet<Hand> hands = new HashSet<Hand>(); foreach (Player player in players) { hands.Add(player.m_Hand); } // 場に出された手の種類が2種類ということは、決着が着く if (hands.Count == 2) { Hand winHand; // グーが出ていないということは、パーとチョキのみが出ていたということなので、チョキが勝ちである if (!hands.Contains(Hand.Guu)) winHand = Hand.Choki; // チョキが出ていないということは、パーとグーのみが出ていたということなので、パーが勝ちである else if (!hands.Contains(Hand.Choki)) winHand = Hand.Paa; // 上記に当てはまらなければ、グーが勝ちである else winHand = Hand.Guu; // 全プレイヤーに対して、勝ちか負けを設定する foreach (Player player in players) { if (player.m_Hand == winHand) player.ChangeState(Player.State.Win); else player.ChangeState(Player.State.Lose); } } // 場に出された手の種類が2種類でないということは、あいこである else { // 全プレイヤーをあいこ状態にする foreach (Player player in players) { player.ChangeState(Player.State.Aiko); } } } // 結果表示状態のときのUpdate [Server] void UpdateResult() { // Result表示後、一定時間経過したら、入力状態にする if (Time.time >= m_ResultStartTime + 3f) { ChangeGameState(State.Inputting); } } // 状態を変更する [Server] void ChangeGameState(State state) { // m_GameStateを変更すれば、あとはhookによりOnStateChangedが実行される m_State = state; } // GameStateが変更されたときのhook void OnStateChanged(State state) { m_State = state; switch (m_State) { case State.WaitOtherPlayerConnect: m_StatusImage.sprite = m_WaitingSprite; m_JankenButtons.SetVisibility(false); break; case State.Inputting: m_StatusImage.sprite = m_InputtingSprite; m_JankenButtons.SetVisibility(true); if (isServer) { // 全プレイヤーをInputting状態にする // ※ただしInitializing状態(初期化完了していない状態)のものは除く foreach (Player player in FindObjectsOfType<Player>()) { if (player.m_State != Player.State.Initializing) player.ChangeState(Player.State.Inputting); } } break; case State.Result: m_StatusImage.sprite = m_ResultSprite; m_ResultStartTime = Time.time; // Resultになった時刻を覚えておく break; default: Debug.LogError("想定外のGameState:" + m_State); break; } } }
スクリプトを保存したら、GameControllerオブジェクトを選択しなおし、Spriteを設定していきます。
以上でGameシーンは完成です。保存しておきましょう。
Titleシーンの作成
新規シーンを作成します。
MainCameraのBackgroundを好きな色にします。
シーンは「Title」という名前で保存しておきましょう。
Canvasの作成
Canvasを追加し、次のように設定します。
タイトルロゴ
Canvasを右クリックし、UI>Imageを追加し、次のように設定します。
プレイヤー名入力欄
Canvasを右クリックし、UI>InputFieldを追加します。名前はPlayerNameInputFieldとします。
PosYを-90にします。
PlayerNameInputFieldオブジェクトに新規スクリプトを作成・アタッチします。スクリプト名はPlayerNameInputFieldとします。
using UnityEngine; using UnityEngine.UI; // タイトル画面のプレイヤー名入力欄を管理する public class PlayerNameInputField : MonoBehaviour { void Start() { InputField inputField = GetComponent<InputField>(); // 初期値設定 inputField.text = Player.s_LocalPlayerName; // 編集完了時の処理を登録 inputField.onEndEdit.AddListener(newValue => { Player.s_LocalPlayerName = newValue; }); } }
以上でプレイヤー名入力欄は完成です。この時点で画面は、次のような見た目になっているはずです。
NetworkManagerの作成
シーンに空のオブジェクトを追加し、名前をNetworkManagerとします。
NetworkManagerオブジェクトにNetworkManagerコンポーネントを追加します。
BuildSettings画面を開き、TitleシーンとGameシーンを登録します。
NetworkManagerを次のように設定します。
上記のMaxConnectionsというのは、サーバーに何人接続できるのかという値です。なので4を設定すると、ホスト/クライアントシステムの場合、5人まで対戦できます。
同オブジェクトにNetworkManagerHUDコンポーネントも追加します。
以上で完成です!!
動作確認してみてください!
プログラムの解説
Player.cs
// プレイヤー1人分の表示を管理する。
// あとは選んだ手をCommandでサーバーへ送信する。
[NetworkSettings(sendInterval = 0)]
public class Player : NetworkBehaviour
{
これは、SyncVarの送信頻度の設定です。デフォルトは0.1であり、最小0.1秒間隔で送信するということです。これは、常に0.1秒おきに送信するというわけではなく、一度情報を送信してから、0.1秒間は次の送信を行わないということです。なおSyncVarの送信(同期)は変更があったときのみ行われます。今回は0を設定しているので、制限をかけることなく、SyncVarが変更されるとすぐに送信するという意味になります(Updateと同じ周期で送信)。
今回のゲームのように、たまにしかSyncVar変数が変わらないゲームでは、sendIntervalは0にしておくと、ラグが最小限に抑えられて良いです。逆に、値が毎フレーム変化しつづけるゲームでsendInterval=0にしてしまうと、通信量が大きくなりすぎる恐れがあります。
void Start() { if (isLocalPlayer) { // プレイヤーオブジェクトは、生成された瞬間はInitializingという非表示状態である。 // クライアントで生成されたら、それをサーバーへ通知し、 // 状態がReadyとなる。 // (同時にプレイヤー名も送信する) CmdInitialize(s_LocalPlayerName); } // 各種表示の初期化 OnPlayerNameChanged(m_PlayerName); OnStateChanged(m_State); OnHandChanged(m_Hand); } [Command] void CmdInitialize(string playerName) { ChangeState(State.Ready); ChangePlayerName(playerName); }
これは、リモートクライアント上でプレイヤーオブジェクトが生成完了したことをサーバーへ通知するための処理です。UNETでは、プレイヤーオブジェクトの生成は、まずサーバーで行われ、その後にリモートクライアント上で行われます。下手をすると、リモートクライアント上で各種初期化処理(OnStartLocalPlayer, OnStartClient, Start)が呼ばれる前にClientRPCやhookが実行されてしまいます。それはちょっと嫌なので、リモートクライアント上で生成されたことをサーバーで判断可能にするために、上記の処理を入れています。ついでに、プレイヤー名をサーバーに通知します。
// 状態を変更する
[Server]
public void ChangeState(State state)
{
// m_Stateを変更すれば、あとはhookによりOnStateChangedが実行される
m_State = state;
}
今回は、上記のように、メソッドに [Server] 属性を付けているメソッドが複数あります。この属性が付けられたメソッドは、サーバー(ホスト含む)でのみ実行可能となり、もしリモートクライアントで実行した場合は、エラーが出ます。「これはServer専用の機能だ」というメソッドに付けておくことで、目印になりますし、リモートクライアントで実行するようなプログラムを誤って書いてしまった場合はすぐに気付くことができます。今回は、SyncVar変数を変更する各メソッドに [Server] 属性を付けています(SyncVarをリモートクライアントで変更するのは意味がないため)
GameController.cs
// Updateはサーバーでのみ行う
[ServerCallback]
void Update()
{
// 長くなってしまうので、Update処理は、状態別に分けた
switch (m_State)
GameControllerはホスト上でもリモートクライアント上でも働きますが、Updateで毎フレームの処理が必要なのは、ホスト上だけなので、[ServerCallback] 属性を付けています。Unityが呼ぶメソッド(Start, Update, OnCollisionEnter等)に [ServerCallback] 属性を付けると、サーバー(ホスト)でのみ呼ばれるようになります。
要は、属性を付けずに、メソッド内冒頭で
if (!isServer) return;
とするのと同じ動きになります。
ServerOnly非対応な件について
今回のゲームはホスト/クライアント方式を前提として作られています。ホストではないServerOnlyなサーバーによる動作は対応していません。
理由は、ServerOnlyなサーバーでは、hookが呼ばれないからです。今回は、hookを多用しています。ServerOnlyモードで動かすと、hookの処理が実行されないため、正しく動きません。
ServerOnlyなサーバーと、ホストの両方に対応するプログラムを作ることも出来ますが、より複雑さが増します。
UNETのプログラムを書く際は、常に「これはサーバーか?リモートクライアントか?」ということを意識する必要がありますが、そこにさらに「サーバーの場合は、ServerOnlyか?ホストか?」という判断が増えることになります。ちょっと勘弁願いたいです。
どーーしてもServerOnlyとホストの両対応をしなければならない場合以外は、どちらかのみを前提にして作ることをおすすめします。
同様の話題ですが、ClientRPCの挙動もServerOnlyとホストでは異なります。ClientRPCは、ホストでは実行されますが、ServerOnlyなサーバーでは実行されません(まあClientRPCという名前からして、Clientを持たないServerOnlyサーバーでは実行されないのは当然といえば当然ですが…)。
完成済みのプロジェクトデータはこちらからダウンロードできます。
この作品はユニティちゃんライセンス条項の元に提供されています