Unityでのオンライン対戦ゲームの作り方を理解すべく、Photon(PUN2)を用いてボードゲーム(オセロ)を作成してみます。
前提として、私はUnity初心者なので、ここであげるやり方は良いものではないかもしれないことをご承知おきください。
まず、PUN2の導入方法や基本的な使用方法等は以下を参考にしています。
URL:https://connect.unity.com/p/pun2deshi-meruonraingemukai-fa-ru-men-sono1
上記の例では、アクション型の対戦ゲームを例にあげています。
本稿では、こちらを参考に簡単なボードゲーム、オセロを作っていこうと思います。
とりあえずオフラインのオセロを作る
まずはオセロを実現するのに必要な構造を設計してみます。
ここではまだ、先にオセロのみに焦点を置く為に、オフラインを想定しています。

■オセロマス
オセロマスには自分が「何行何列目」のマスなのかという情報と、
「マスの状態(何もなし。黒。白の3状態)」及び、ここに「石が置けるかどうか」の情報を持っています。
「石が置けるかどうか」を知る為には、他のマスの情報が必要なのでオセロ盤が情報をセットしてくれます。
また、「マスの状態」に従い、マスの見た目を変えます。
■オセロ盤
オセロマスを64個保持しています。
オセロのルールそのものの処理に記載します。
・石は白と黒順番に置く。
・石は相手の石をとれる場所にしか置けない。
・石が置かれたら挟まれている相手の石をひっくり返す。
・石を置く場所が無い場合は自分の番をスキップする。
・両者共に石が置けなくなったらゲーム終了
■プレイヤー
マウス操作によって石を置くマスを指定する。
参考: PUN2で始めるオンラインゲーム開発入門【その1】
https://qiita.com/JunShimura/items/4547563fbb2691f40626
指定したマスに石を置かせる。
■UI(図に書き忘れた)
オセロ盤の情報を表示する。
以下の①~③を繰り返してゲームを進めます
①マウスクリックされたマスが座標をプレイヤーに返します。
②座標を得たプレイヤーはその座標に石を置くようにオセロ盤に命令をします。
③オセロ盤がルールに基づきゲームを進める。
この設計通りに作ったのがこちら
デバッグ用にマスの数は4×4にしております。

とりあえず最低限の機能が完成。
本来であれば、オフライン向け実装をする前にオンラインを前提とした設計をすべきです。(大きく手戻りする可能性が高い為)
ですが、オフラインからオンラインにする為にはどこを変えなければいけないかをわかりやすくする為に先にオフライン実装を行いました。
オンライン対応化
ここからさらにオンライン対応する為の設計をします。
ここで重要なのは、どのオブジェクトの何のデータを、プレイヤー間で同期更新すればオンライン対戦が実現できるかを考えることです。
大きく分けて2つの案がでてくるかと思います。
①ボード盤の状態を同期する。
→同期するデータ:盤面の全マスの状態。
②プレイヤーの操作を同期する。
→同期するデータ:プレイヤーがどこのマスを選んだか。
今回は②案で実装します。
②とした理由については、①案と比べて
・同期すべきデータが少ない(どこのマスを決定したかさえ分かればいい)
・オフライン向けに作成したオセロクラスに手を加えなくてよい。
からです。
デメリットとしては、ゲームスタート時から通しでプレイヤーがいなければなりません。
一度退出して再入出した際に、盤面の再現ができず、続きからできなくなってしまうからです。
まぁ、これに関しては再入出したときのみ②案の同期をすれば良いかとも思いますが、とりあえず今回は①案の同期のみとします。
まずはプレイヤーが2人になります。
プレイヤーが部屋に接続した際に生成されるようにします。

このままでも、2人のプレイヤーが各々の操作で石を置くことができます。
ですが、このままでは今が誰のターンなのかを区別することができません。
なので、プレイヤーの操作を監視し、適切なターンのときのみ石を置く統制役を追加することにします。
※後から気付きましたが、オセロマネージャを用意せずとも RPC(リモートプロシージャコール)を用いたほうが良いです。
参考:https://connect.unity.com/p/pun2deshi-meruonraingemukai-fa-ru-men-sono3

では実装していきます。
まずはプレイヤークラスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
using System.Collections; using System.Collections.Generic; using UnityEngine; using Photon.Pun; public class GamePlayer : MonoBehaviourPunCallbacks, IPunObservable { public int data_x; public int data_z; public byte decision; private byte sendDecision; public int id; private PhotonView photonView; // Start is called before the first frame update void Start() { //プレイヤーのIDを取得する。 photonView = this.GetComponent<PhotonView>(); id = photonView.OwnerActorNr; } // Update is called once per frame void Update() { if (photonView.IsMine) { Ray ray = new Ray(); RaycastHit hit = new RaycastHit(); ray = Camera.main.ScreenPointToRay(Input.mousePosition); //マウスクリックした場所からRayを飛ばし、オブジェクトがあればtrue if (Physics.Raycast(ray.origin, ray.direction, out hit, Mathf.Infinity)) { if (hit.collider.gameObject.CompareTag("ClickObj")) { //マウスカーソルと重なっている間の処理 hit.collider.gameObject.GetComponent<ClickObject>().OnCursorAction(); if (Input.GetMouseButtonDown(0)) { //クリックされたときの処理 if (sendDecision == 0) { //前回の選択項目の送信が完了している場合 hit.collider.gameObject.GetComponent<ClickObject>().OnUserAction(this.gameObject); } } } } } //何かしらをクリックした場合、送信用に保持しておく if (decision != 0) sendDecision = decision; } public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { // オーナーの場合 if (stream.IsWriting) { stream.SendNext(this.data_x); stream.SendNext(this.data_z); stream.SendNext(this.sendDecision); sendDecision = 0; } // オーナー以外の場合 else { this.data_x = (int)stream.ReceiveNext(); this.data_z = (int)stream.ReceiveNext(); this.decision = (byte)stream.ReceiveNext(); } } } |
動作:
マウスクリックしたオブジェクトにアクションを起こさせる関数を実装しています。
これにより、クリックしたマスの座標を取得します。
同期する変数は以下です。
id:各プレイヤーを識別する為のID
data_x:選んだマスの座標
data_z:選んだマスの座標
decision:どこかのマスを選んだことを伝える
上記変数を以下のように同期しています。
参考: PUN2で始めるオンラインゲーム開発入門【その2】
https://connect.unity.com/p/pun2deshi-meruonraingemukai-fa-ru-men-sono2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { // オーナーの場合 if (stream.IsWriting) { stream.SendNext(this.data_x); stream.SendNext(this.data_z); stream.SendNext(this.sendDecision); sendDecision = 0; } // オーナー以外の場合 else { this.data_x = (int)stream.ReceiveNext(); this.data_z = (int)stream.ReceiveNext(); this.decision = (byte)stream.ReceiveNext(); } } |
ここでsendDecision変数を用いてdecision変数を同期していることに注目してください。
decision変数はマスをクリックしたら0→1
クリックしたことによる処理(石を配置)をしたら1→0に戻す
といった使い方をしています。ここで危惧されるのは以下の現象です。
マスをクリックしてdecision変数が0→1になった後、
他プレイヤーの為にdecision変数を同期送信する前に、
処理が完了してdecision変数が1→0に戻ってしまうことです。
こうなってしまうと、石を置いたプレイヤーのゲームアプリ上では相手の手番に進みますが、
他プレイヤーからは相手プレイヤーが石を置いたことが分からず、永遠に手番が進まなくなってしまいます。
この現象を回避する為に、sendDecision変数を使用しています。
また、プレイヤーidを付与する為に
このオブジェクトを生成したオーナーのIDを取得し、格納しています。
1 2 3 4 5 6 |
void Start() { //プレイヤーのIDを取得する。 photonView = this.GetComponent<PhotonView>(); id = photonView.OwnerActorNr; } |
次にサーバに接続するためのクラスです。
これは参考サイトのものに、入室した際に各接続者毎にプレイヤーオブジェクトが生成されるようにしただけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
using System.Collections; using System.Collections.Generic; using Photon.Pun; using Photon.Realtime; using UnityEngine; public class SampleScene : MonoBehaviourPunCallbacks { private void Start() { // PhotonServerSettingsに設定した内容を使ってマスターサーバーへ接続する Debug.Log("接続を試みる"); PhotonNetwork.ConnectUsingSettings(); } // マスターサーバーへの接続が成功した時に呼ばれるコールバック public override void OnConnectedToMaster() { Debug.Log("マスターサーバに接続完了"); // "room"という名前のルームに参加する(ルームが無ければ作成してから参加する) PhotonNetwork.JoinOrCreateRoom("room", new RoomOptions(), TypedLobby.Default); } // マッチングが成功した時に呼ばれるコールバック public override void OnJoinedRoom() { // マッチング後、自分自身のネットワークオブジェクトを生成する PhotonNetwork.Instantiate("Player", Vector3.zero, Quaternion.identity); Debug.Log("マッチング"); } } |
最後にオセロマネージャクラスです。
各プレイヤーのリクエスト(どこのマスをクリックしたぞという情報)を元に、
そのプレイヤーのターンならば石を置き、違うならリクエストを棄却します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
using System.Collections; using System.Collections.Generic; using UnityEngine; public class OthelloManager : MonoBehaviour { public Othello othello; // Start is called before the first frame update void Start() { } // Update is called once per frame void Update() { GameObject[] players = GameObject.FindGameObjectsWithTag("Player"); for (int i = 0;i < players.Length; i++) { GamePlayer player = players[i].GetComponent<GamePlayer>(); if (player.decision != 0) { //何かしらをクリックしていた場合。 if(othello.whitchTurn == Othello.TURN_BLACK) { if (player.id == 1) othello.Reverse(player.data_x, player.data_z); } else { if (player.id == 2) othello.Reverse(player.data_x, player.data_z); } player.decision = 0; } } } } |
これにより以下のようなオセロアプリができました。

次回は機能の追加や、見た目のブラッシュアップを行います。
また、オセロクラスとオセロマスクラスのコードを参考に貼っときます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
using System.Collections; using System.Collections.Generic; using UnityEngine; using Photon.Pun; using Photon.Realtime; public class Othello : MonoBehaviour { private int sideLength = 4; public GameObject othelloBlock; public OthelloBlock[,] othelloBlocks; public int whitchTurn; public const int TURN_BLACK = 0; public const int TURN_WHITE = 1; private const int directionNum = 8; private bool preSkipFlag; public bool endFlag; struct Directions { public int x; public int z; public Directions(int x, int z) { this.x = x; this.z = z; } } Directions[] DIR = { new Directions(0,1), new Directions(0,-1), new Directions(1,0), new Directions(1,1), new Directions(1,-1), new Directions(-1,0), new Directions(-1,1), new Directions(-1,-1), }; // Start is called before the first frame update void Start() { //オセロブロックの生成 othelloBlocks = new OthelloBlock[sideLength, sideLength]; for (int i = 0; i < sideLength; i++) { for (int j = 0; j < sideLength; j++) { Vector3 vec3 = new Vector3(i - sideLength/2, 0,j - sideLength / 2); othelloBlocks[i, j] = Instantiate(othelloBlock, vec3, Quaternion.identity).GetComponent<OthelloBlock>();//PhotonNetwork.Instantiate("OthelloBlock", vec3, Quaternion.identity).GetComponent<OthelloBlock>();// othelloBlocks[i, j].mng = this.gameObject.GetComponent<Othello>(); othelloBlocks[i, j].x = i; othelloBlocks[i, j].z = j; } } //初期配置の生成 othelloBlocks[sideLength / 2 - 1, sideLength / 2 - 1].state = OthelloBlock.WHITE; othelloBlocks[sideLength / 2 - 1, sideLength / 2 ].state = OthelloBlock.BLACK; othelloBlocks[sideLength / 2 , sideLength / 2 - 1].state = OthelloBlock.BLACK; othelloBlocks[sideLength / 2 , sideLength / 2 ].state = OthelloBlock.WHITE; //黒が先行 whitchTurn = TURN_BLACK; CanPutCheck(); } void Update() { if (endFlag) Debug.Log("ゲーム終了"); } public void TurnChage() { if (whitchTurn == TURN_BLACK) whitchTurn = TURN_WHITE; else whitchTurn = TURN_BLACK; CanPutCheck(); skipCheck(); } /* 石を置く場所があるか確認する。*/ private void skipCheck() { bool skipFlag = true; for (int i = 0; i < sideLength; i++) { for (int j = 0; j < sideLength; j++) { if (othelloBlocks[i, j].canPut) { skipFlag = false; } } } if(skipFlag == true) { if (preSkipFlag == true) { //スキップが2回連続した場合、もう石を置けないのゲーム終了 endFlag = true; } else { //スキップ発生 preSkipFlag = skipFlag; TurnChage(); } } preSkipFlag = skipFlag; } /*指定された座標に石を置き、周りの石をひっくり返す*/ public void Reverse(int tx,int tz) { int enemyColor; int myColor; if (whitchTurn == TURN_BLACK) { enemyColor = OthelloBlock.WHITE; myColor = OthelloBlock.BLACK; } else { enemyColor = OthelloBlock.BLACK; myColor = OthelloBlock.WHITE; } //指定座標に石を置く othelloBlocks[tx, tz].state = myColor; for (int k = 0; k < directionNum; k++) { bool adjacentEnemy = false; for (int l = 1; ; l++) { int x = tx + DIR[k].x * l; int z = tz + DIR[k].z * l; //端まで到達してしまったらブレイク if (x < 0 || sideLength <= x) break; if (z < 0 || sideLength <= z) break; //隣の状態を取得 int state = othelloBlocks[x, z].state; //隣が空白ならループ終了 if (state == OthelloBlock.NONE) break; //隣が敵ならループを続ける if (state == enemyColor) adjacentEnemy = true; //隣が味方なら if (state == myColor) { //間に敵がいたので間の石を自分の色にする。 if (adjacentEnemy) { for (int i = 1; i < l; i++) { othelloBlocks[tx + DIR[k].x * i, tz + DIR[k].z * i].state = myColor; } break; } } } } TurnChage(); } /*各マス、石が置けるか確認する。*/ private void CanPutCheck() { int enemyColor; int myColor; if (whitchTurn == TURN_BLACK) { enemyColor = OthelloBlock.WHITE; myColor = OthelloBlock.BLACK; } else { enemyColor = OthelloBlock.BLACK; myColor = OthelloBlock.WHITE; } for (int i = 0; i < sideLength; i++) { for (int j = 0; j < sideLength; j++) { //配置不可に初期化 othelloBlocks[i, j].canPut = false; for (int k = 0; k < directionNum; k++) { bool adjacentEnemy = false; //既に配置済みの箇所はスキップ if (othelloBlocks[i, j].state != OthelloBlock.NONE) break; for(int l = 1; ; l++) { int x = i + DIR[k].x * l; int z = j + DIR[k].z * l; //端まで到達してしまったらブレイク if (x < 0 || sideLength <= x) break; if (z < 0 || sideLength <= z) break; //隣の状態を取得 int state = othelloBlocks[x, z].state; //隣が空白ならループ終了 if (state == OthelloBlock.NONE) break; //隣が敵ならループを続ける if (state == enemyColor) adjacentEnemy = true; //隣が味方なら if (state == myColor) { //間に敵がいたなら置ける。いなかったらまた違う方向を確認。 if(adjacentEnemy) othelloBlocks[i, j].canPut = true; break; } } //置けることが判明したらさっさと次のマスへ if (othelloBlocks[i, j].canPut == true) break; } } } } /* 指定された色の石をカウントし、返す */ public int ColorCount(int color) { int count = 0; for (int i = 0; i < sideLength; i++) { for (int j = 0; j < sideLength; j++) { if (othelloBlocks[i, j].state == color) count++; } } return count; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
using System.Collections; using System.Collections.Generic; using UnityEngine; public class OthelloBlock : ClickObject { // Start is called before the first frame update public const int NONE = 0; public const int BLACK = 1; public const int WHITE = 2; public int state = NONE; private bool empty; public bool canPut = false; public Othello mng; public int x; public int z; void Start() { empty = true; } // Update is called once per frame void Update() { //現在の石の状態に合わせて色を変える if (state == NONE) GetComponent<Renderer>().material.color = Color.green; else if (state == BLACK) GetComponent<Renderer>().material.color = Color.black; else GetComponent<Renderer>().material.color = Color.white; if (state != NONE) empty = false; } /*プレイヤーにクリックされた際のアクション*/ public override void OnUserAction(GameObject obj) { if (empty && canPut) { //石を置くことが可能なら、このマスの座標をプレイヤーに渡す GamePlayer player = obj.GetComponent<GamePlayer>(); player.data_x = x; player.data_z = z; //プレイヤーにクリックによるアクションフラグがたったことを伝える player.decision = 1; } return; } public override void OnCursorAction() { } } |
コメント
I truly appreciate this article. Really looking forward to read more. Want more. Caitlin Cecil Severin
Hello to every one, the contents present at this site are actually amazing for people knowledge,
well, keep up the good work fellows.