プログラミングができない大学3年生がオンラインゲーム作りに挑戦する。

こんにちは、神奈川工科大学情報学部情報メディア科3年の齊藤です。今回私はUnityを使ったオンラインゲームの開発に挑戦したいと考え行動しました。
そして私は私と同じようにオンラインゲームに興味を持ち作りたいと考えている人たちの助けになるようにと私が参考にした本と自分が行った流れをこのブログに載せていこうと考えました。

私が使った本は河田匡稔著の「オンラインゲームのしくみ Unityで覚えるネットワークプログラミング」という本です。
本の内容に触れる前に一つ注意事項があります。
この本はタイトルにある通りUnityを使って様々なサンプルを実行するためUnityのインストールが必須になりますのでご注意ください。
以下この本を書いてくれた著者に感謝して記事を書いて行きます。

1章ではオンラインゲームを作るうえで考えていかなくてはいけないことやオンラインゲームならではの考え方などが載っています。正直なところ速くサンプルを使ってみたいという人たちからすると読み飛ばしたくなる場所ではあるかもしれないですが、個人的には軽くでもいいから一通り眼を通すべき場所だと思いました。
前知識なしで飛ばしてしまおうと考えた人でも1.3は確実に読んでもらいたいと感じました、個々では本書を読み進めるにあたってというタイトルで本の構成やサンプル、用語についてと、あるので本書を読み進める上で助けになります。

2章では通信プログラムの基礎知識というタイトルで通信の仕組みについてや送受信プロトコルであるTCPとUDPの違いなど、通信についての基礎知識の紹介になります。
TCPとUDPについて知っているからといってこの部分を全て飛ばさないでほしいと私は考えます。この章では基礎的な部分に加えオンラインゲームでよく使われるプロトコルや通信の遅延に対しどのように考えるべきかが書かれています。

3章から待ちに待ったサンプルプログラムを使用した演習になって行きます。3章ではソケットプログラムを用いたデータの送受信のサンプルを使います。

実行画面は以下のもののように非常に地味なものであります

サンプル実行画面

空白の部分に相手のIPアドレスかあるいはローカルホストでテストを行うならばlocalhostと入力して自分をサーバーとして動かすか、サーバーに接続するかを選択します。
相手との接続が成功していればサンプルが入っているファイルのbinフォルダの中にあるTCPを使ったサンプルのほうならばSocketSampleTCP_Dataを開いてUDPならばsocketSampleUDP_Dataを開く、そうすると中にoutput_log.txtができておりこれが通信の履歴になります。

ここからプログラムの解説を行いたいと思います。
まずはじめに待ち受けようのソケットの生成を行います。

// 待ち受け開始.
void StartListener()
{
Debug.Log("Start server communication.");

// ソケットを生成します.
m_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// 使用するポート番号を割り当てます.
m_listener.Bind(new IPEndPoint(IPAddress.Any, m_port));
// 待ち受けを開始します.
m_listener.Listen(1);

m_state = State.AcceptClient;
}

こちらにm_listenerと言う変数がありますね?こちらが待ち受けを行う専門のソケットでリスニングソケットと呼ばれるものです。
そしてそのすぐ下の部分でソケットの種類をStreamと選択しプロトコルをTCPと指定しています。その次にm_listenerにポート番号を割り当てます。そして最後に待ち受け状態にするためにListen関数を呼び出します。

次にクライアントからの接続要求受付についてです。

// クライアントからの接続待ち.
void AcceptClient()
{
if (m_listener != null && m_listener.Poll(0, SelectMode.SelectRead)) {
// クライアントから接続されました.
m_socket = m_listener.Accept();
Debug.Log("[TCP]Connected from client.");
m_state = State.ServerCommunication;
}
}

サーバーはクライアントからの接続要求の受付を行うためにこちらのAccept関数を呼び出します。この関数はクライアントからの接続要求があるまで処理をブロッキングします。
ブロッキングとは呼び出した関数の処理が完了するまで処理の制御が帰ってこないようにすることです。
しかしこのままだとゲームを作る際にブロッキングされてしまうとゲームとして成り立たなくなってしまうためPoll関数でクライアントからのデータ受信を監視して、データを受信したときだけACcept関数を呼び出すようにします。
Poll関数とは複数のファイルディスクリプタを監視、制御するものである。
ファイルディスクリプタで処理を待ち合わせるシステムコールを実行すると、データが到着するなどの一連の処理が完了するまでシステムコールから処理制御がリターンされなくなります。

次にサーバーへの接続、メッセージの送信、通信の切断の説明です。

void ClientProcess()
{
Debug.Log("[TCP]Start client communication.");

// サーバへ接続.
m_socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); ・・・①
m_socket.NoDelay = true; ・・・②
m_socket.SendBufferSize = 0;
m_socket.Connect(m_address, m_port); ・・・③

// メッセージ送信.
byte[] buffer = System.Text.Encoding.UTF8.GetBytes("Hello, this is client.");
m_socket.Send(buffer, buffer.Length, SocketFlags.None);

// 切断.
m_socket.Shutdown(SocketShutdown.Both);
m_socket.Close();

Debug.Log("[TCP]End client communication.");
}

サーバーとの接続を行うためのソケットの生成を行います。
m_soketにsoketクラスのインスタンスを生成します。これが①の部分です。次に小さなパケットをバッファリングしないようにSoket.NoDelayプロパティをtureにsoket.SendBufferSizeの値を0に設定する、これが②の部分です。最後にm_soketに接続先のIPアドレスとポート番号を指定して接続要求を行います、これが③の部分になります。

ソケットの送受信にはSend関数とReceive関数を使ってデータの送受信をすることができます。この2つの関数はペアで扱われる関数であり、Send関数で送ったデータをReceive関数を呼び出すことによってデータを取り出すことができるようになっています。

最後に切断についてです。通信を終了させるときはShutdown関数を使用してパケットの送受信を遮断します、そしてClose関数を使用して通信の切断を行います。

// 待ち受け終了.
void StopListener()
{
// 待ち受けを終了します.
if (m_listener != null) {
m_listener.Close();
m_listener = null;
}

m_state = State.Endcommunication;

Debug.Log("[TCP]End server communication.");
}

お互いに送信したデータを全て受け取ってから通信を終了させる場合は、Shutdown関数で送信だけ終了させて、全ての受信が終了してからClose関数を呼び出して終了します。

サーバーの待ち受けを終了する場合は、リスニングソケットを、Close関数を呼び出して破棄します。

UDPのソケットプログラミングではTCPと違い接続処理を行わずに通信することができる。なので待ち受けを行う必要がありません。
UDPの送信、受信ではSendTo関数とReceiveFrom関数を使用します。TCPのほうで使われていたSend関数とReceive関数と同じようにこの二つの関数はペアで扱われる関数でありUDP通信時(コネクションレス型のデータ送信時)にデータ送受信を行う関数です。
UDPでのポート番号の割り当てを行って通信可能になるまでのプログラムを以下に示します。

void SendMessage()
{
Debug.Log("[UDP]Start communication.");

// サーバへ接続.
m_socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

// メッセージ送信.
byte[] buffer = System.Text.Encoding.UTF8.GetBytes("Hello, this is client.");
IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse(m_address), m_port);
m_socket.SendTo(buffer, buffer.Length, SocketFlags.None, endpoint);

// 切断.
m_socket.Shutdown(SocketShutdown.Both);
m_socket.Close();

m_state = State.Endcommunication;

Debug.Log("[UDP]End communication.");
}

このように接続の処理がないだけでソースがかなり短くなります。

 

次に4章のチャットプログラムについての解説をします。チャットプログラムを作る際考えておくべきことがあります。それは会話の流れです。相手が送っている間自分が入力できないなんて仕様だと会話として成り立たなくなってしまいます。他にもチャットを打ち込んだはずなのに相手に届いていないという事態になってしまっても会話が成り立ちません。なので相手に確実にデータを届け、かつ相手とのデータのやり取りで片方が待たなくてはいけないという状況を作らないようにしなくてはいけません。

このプログラムは確実にデータを届けるためTCP通信を使います。なので通信ライブラリ、TransportTCPクラスを使用を使用して作成されています。
チャットプログラムはプレイヤーが自分でルームを作るか参加するかを選択するシーケンスと実際にチャットを行うシーケンスの2つで作られます。

チャット

はじめにチャットルームの選択を行う処理のソースについてです。

void SelectHostTypeGUI()
{
float sx = 800.0f;
float sy = 600.0f;
float px = sx * 0.5f - 100.0f;
float py = sy * 0.75f;

if (GUI.Button(new Rect(px, py, 200, 30), "チャットルームの作成")) {

m_transport.StartServer(m_port, 1);

m_state = ChatState.CHATTING;
m_isServer = true;
}


Rect labelRect = new Rect(px, py + 80, 200, 30);
GUIStyle style = new GUIStyle();
style.fontStyle = FontStyle.Bold;
style.normal.textColor = Color.white;
GUI.Label(labelRect, "あいてのIPあどれす", style);
labelRect.y -= 2;
style.fontStyle = FontStyle.Normal;
style.normal.textColor = Color.black;
GUI.Label(labelRect, "あいてのIPアドレス", style);

Rect textRect = new Rect(px, py + 100, 200, 30);
m_hostAddress = GUI.TextField(textRect, m_hostAddress);


if (GUI.Button(new Rect(px, py + 40, 200, 30), "チャットルームへの参加")) {
bool ret = m_transport.Connect(m_hostAddress, m_port);
if (ret) {
m_state = ChatState.CHATTING;
}
else {
m_state = ChatState.ERROR;
}
}
}

この部分でサーバーとなるかクライアントを選ぶかで接続の方法を変えています。サーバーとして部屋を作るボタンを選択すると3章であったようにStartSeaver関数を呼び出し待ち受け状態になり相手からの接続を待ちます。
既存のサーバーに接続する際はテキストに記入された相手のIPを読み取りそのIPアドレスに接続します。サーバーに接続できればチャットに移行し、接続に失敗すると再度チャットルームの選択に戻ります。

次にチャットルームでのメッセージのやり取りの部分になります。

enum ChatState {
HOST_TYPE_SELECT = 0,    // ルーム選択.
CHATTING,                // チャット中.
LEAVE,                    // 退出.
ERROR,                    // エラー.
};

 

void Update()
{
switch (m_state) {
case ChatState.HOST_TYPE_SELECT:
for (int i = 0; i < CHAT_MEMBER_NUM; ++i) {
m_message[i].Clear();
}
break;

case ChatState.CHATTING:
UpdateChatting();
break;

case ChatState.LEAVE:
UpdateLeave();
break;
}
}


void UpdateChatting()
    {
        byte[] buffer = new byte[1400];

        int recvSize = m_transport.Receive(ref buffer, buffer.Length);
        if (recvSize > 0) {
            string message = System.Text.Encoding.UTF8.GetString(buffer);
            Debug.Log("Recv data:" + message );
            m_chatMessage += message + "   ";// + "\n";

            int id = (m_isServer == true)? 1 : 0;
            AddMessage(ref m_message[id], message);
        }    
    }
    

Update部分はチャットを退出するまで繰り返すというものである。
UpdateChatting関数は送受信するメッセージをキャラクターごとのバッファに保存されます。このバッファはm_messageに保管されています。

次にチャットの送受信を行う部分のソースについてです。

void ChattingGUI()
{
Rect commentRect = new Rect(220, 450, 300, 30);
m_sendComment = GUI.TextField(commentRect, m_sendComment, 15);

bool isSent = GUI.Button(new Rect (530, 450, 100, 30), "しゃべる");
if (Event.current.isKey &&
Event.current.keyCode == KeyCode.Return) {
if (m_sendComment == m_prevComment) {
isSent = true;
m_prevComment = "";
}
else {
m_prevComment = m_sendComment;
}
}


if (isSent == true) {
string message = "[" + DateTime.Now.ToString("HH:mm:ss") + "] " + m_sendComment;
byte[] buffer = System.Text.Encoding.UTF8.GetBytes(message);
m_transport.Send(buffer, buffer.Length);
AddMessage(ref m_message[(m_isServer == true)? 0 : 1], message);
m_sendComment = "";
}


if (GUI.Button (new Rect (700, 560, 80, 30), "退出")) {
m_state = ChatState.LEAVE;
}


// とうふやさん(サーバ側)のメッセージ表示.
if (m_transport.IsServer() ||
m_transport.IsServer() == false && m_transport.IsConnected()) {
DispBalloon(ref m_message[0], new Vector2(200.0f, 200.0f), new Vector2(340.0f, 360.0f), Color.cyan, true);
GUI.DrawTexture(new Rect(50.0f, 370.0f, 145.0f, 200.0f), this.texture_tofu);
}

if (m_transport.IsServer() == false ||
m_transport.IsServer() && m_transport.IsConnected()) {
// だいずやさんの(クライアント側)のメッセージ表示.
DispBalloon(ref m_message[1], new Vector2(600.0f, 200.0f), new Vector2(340.0f, 360.0f), Color.green, false);
GUI.DrawTexture(new Rect(600.0f, 370.0f, 145.0f, 200.0f), this.texture_daizu);
}
}

この部分には3つの処理が存在しており1つ目が相手にメッセージを送るもの、2つ目に退出ボタンが押されているかの監視をして押されたときに退出処理をするものです。3つ目にメッセージを受信してそれを表示するものです。

最後に通信を切断行う部分についてです。

void UpdateLeave()
{
if (m_isServer == true) {
m_transport.StopServer();
}
else {
m_transport.Disconnect();
}

// メッセージの削除.
for (int i = 0; i < 2; ++i) {
m_message[i].Clear();
}

m_state = ChatState.HOST_TYPE_SELECT;
}

次に5章のターン製ゲームについてです。
サンプルでは3目並べのゲームを制作して行きます。
こちらのゲームはチャットプログラムのように好きなときに操作できてしまうとゲームとして成り立たなくなってしまいます。
なので相手の操作が終わるまで自分の操作ができないようにする必要があります。しかしそうするといつまでも相手が時間を掛けてしまうとゲームのテンポが非常に悪くなってしまいます。
上記の点について考えてゲームデザインを設計して行かなければいけません
ソースについて書いて行きます。

はじめに自分のターンかを判定する部分です

public class TicTacToe : MonoBehaviour {

// ターン種別.
private enum Turn {
Own = 0,        // 自分のターン.
Opponent,        // 相手のターン.
};

// 現在のターン.
private Mark            turn;

// ローカルのマーク.
private Mark            localMark;

// リモートのマーク.
private Mark            remoteMark;

ここでは自分がマークを置く順番かを判定しています。

次に全体のターンの処理の部分になります

void UpdateTurn()
{
bool setMark = false;

if (turn == localMark) {
setMark = DoOwnTurn();

//置けない場所を押されたときは、クリック用のSEを鳴らします.
if (setMark == false && Input.GetMouseButtonDown(0)) {
AudioSource audio = GetComponent<AudioSource>();
audio.clip = se_click;
audio.Play();
}
}
else {
setMark = DoOppnentTurn();

//置けないときに押されたときは、クリック用のSEを鳴らします.
if (Input.GetMouseButtonDown(0)) {
AudioSource audio = GetComponent<AudioSource>();
audio.clip = se_click;
audio.Play();
}
}

if (setMark == false) {
// 置き場を検討中です.
return;
}
else {
//マークが置かれたSEを鳴らします.
AudioSource audio = GetComponent<AudioSource>();
audio.clip = se_setMark;
audio.Play();
}

// マークの並びをチェックします.
winner = CheckInPlacingMarks();
if (winner != Winner.None) {
//勝ちの場合はSEを鳴らします.
if ((winner == Winner.Circle && localMark == Mark.Circle)
|| (winner == Winner.Cross && localMark == Mark.Cross)) {
AudioSource audio = GetComponent<AudioSource>();
audio.clip = se_win;
audio.Play();
}
//BGM再生終了.
GameObject bgm = GameObject.Find("BGM");
bgm.GetComponent<AudioSource>().Stop();

// ゲーム終了です.
progress = GameProgress.Result;
}

// ターンを更新します.
turn = (turn == Mark.Circle)? Mark.Cross : Mark.Circle;
timer = turnTime;
}

この処理では自分のターンであればマークを配置し、相手のターンであれば相手がマークを配置するのを待ちます。

次に自分のターンの処理についてです。

// 自分のターンの時の処理.
bool DoOwnTurn()
{
int index = 0;

timer -= Time.deltaTime;
if (timer <= 0.0f) {
// 時間切れ.
timer = 0.0f;
do {
index = UnityEngine.Random.Range(0, 8);
} while (spaces[index] != -1);
}
else {
// マウスの左ボタンの押下状態を監視します.
bool isClicked = Input.GetMouseButtonDown(0);
if (isClicked == false) {
// 押されていないのでなにもしません.
return false;
}

Vector3 pos = Input.mousePosition;
Debug.Log("POS:" + pos.x + ", " + pos.y + ", " + pos.z);

// 受信した情報から選択されたマスに変換します.
index = ConvertPositionToIndex(pos);
if (index < 0) {
// 範囲外が選択されました.
return false;
}
}

// マスに目を置きます.
bool ret = SetMarkToSpace(index, localMark);
if (ret == false) {
// 置けない.
return false;
}

// 選択したマスの情報を送信します.
byte[] buffer = new byte[1];
buffer[0] = (byte)index;
m_transport.Send (buffer, buffer.Length);

return true;
}

自分のターンではマウンスからの入力を受け付け配置するマス番号を算出する処理、算出したマス番号にマークを配置する処理、配置したマス番号のデータを相手に送信する処理の3つで成り立っています。

次に相手の処理についてです。

// 相手のターンの時の処理.
bool DoOppnentTurn()
{
// 相手の情報を受信します.
byte[] buffer = new byte[1];
int recvSize = m_transport.Receive(ref buffer, buffer.Length);

if (recvSize <= 0) {
// まだ受信していません.
return false;
}

// サーバなら○クライアントなら×を指定します.
//Mark mark = (m_network.IsServer() == true)? Mark.Cross : Mark.Circle;

// 受信した情報から選択されたマスに変換します.
int index = (int) buffer[0];

Debug.Log("Recv:" + index + " [" + m_transport.IsServer() + "]");

// マスに目を置きます.
//bool ret = SetMarkToSpace(index, mark);
bool ret = SetMarkToSpace(index, remoteMark);
if (ret == false) {
// 置けない.
return false;
}
return true;
}

相手のターンでは相手から配置するマス番号の情報を受信する処理と受け取ったマス番号からマークを配置する処理の2つから成り立っています。

次にゲーム開始についての部分についてです。
ここでゲームの初期化を行います

// ゲーム開始.
public void GameStart()
{
// ゲーム開始の状態にします.
progress = GameProgress.Ready;

// サーバが先手になるように設定します.
turn = Mark.Circle;

// 自分と相手のマークを設定します.
if (m_transport.IsServer() == true) {
localMark = Mark.Circle;
remoteMark = Mark.Cross;
}
else {
localMark = Mark.Cross;
remoteMark = Mark.Circle;
}

最後にエラー処理についてです

// イベント発生時のコールバック関数.
public void EventCallback(NetEventState state)
{
switch (state.type) {
case NetEventType.Disconnect:
if (progress < GameProgress.Result && isGameOver == false) {
progress = GameProgress.Disconnect;
}
break;
}
}
}

この部分は回線切断などのゲームに大きな影響を与えてしまう通信エラーが起こってしまったときに呼び出されます。そしてゲームのモードが切断状態のGame.Progress.Disconnectに変更されます。

現状自分ができたのはここまです。
自分がここまでやるのにはおおよそ5時間くらいかかりました。
サンプルの実行だけならば1時間も必要ないのですがそれの理解となると
オフラインのものと違うところが多く時間がかかってしまいました。
サンプルはネットで配布されているのでそれをダウンロードすれば実行結果もソースもすぐに見ることができます。良ければ本も見ていただければ幸いです。

オンラインゲームを作ろうとしている人の助けになっていれば幸いです。

参考文献
・オンラインゲームのしくみ
「Unityで覚えるネットワークプログラミング」