9
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Unity 2Advent Calendar 2016

Day 22

Unity+FuelPHP+PhotonNetworkでオンラインゲームの基礎作り

Posted at

##はじめに

この記事は Unity2 AdventCalendar 22日目の記事になります
http://qiita.com/advent-calendar/2016/unity2

本エントリでは、ネトゲ製作の入り口的な事を行います

具体的な内容は、UnityからHTTPでサーバーにログイン情報をPOSTし、ログイン処理を行い、ユーザー名を受け取ってPhotonNetworkに繋ぐという事を行います

本エントリの内容を応用すれば、サーバー側に所持金やアイテム所持情報を持たせて通信を行うなど、ネットゲームに欠かせない処理のアレコレができるようになります。

##想定している読者のレベル
ターゲットは、Unityは扱えるがPHPやサーバー回りはサッパリという方です。ある程度プログラミングを理解でき、C#とUnityを使える事を前提にしていますので、Unityの操作などは割愛させていただきます。

PHPコードの詳しい解説は省略します
サーバーにLinux環境を使用しますが、極力コマンド入力などは避けるようにしました。

今回は基礎部分の作り方・考え方を簡潔に紹介するように書いているため、セキュリティ部分などはガバガバになっています。パスワードのハッシュ化すらも省いてますしコードも簡潔です。その点お許しください。その手に持ってるマサカリをしまってください・・・

#Linux環境を用意しよう - Cloud9のセットアップ
今回は、簡単にLAMP(Linux Apache MySQL PHP)環境を用意でき、IDEも付属しているCloud9を利用してサーバーを構築します
https://c9.io/

メールアドレスの他、GitHub、BitBucketのアカウントを使用する事ができます

登録が完了したらWorkspaces画面に移動するので**「Create a new workspace」**をクリックしてワークスペースを作成します。

このワークスペースはUbuntuベースでVPSのように構築されており、sudoやaptを利用したパッケージ追加もできるなど、かなり自由度の高い構成になっています。

なお、一定時間経過でインスタンスがスリープするので、常時公開のサーバーとしての利用はできません。

##Cloud9 ワークスペースを作成する
・Workspace name - ワークスペース名を入力します
・Description - 説明を入力します。空欄のままでも構いません。
・公開設定 - Publicを選択しましょう
・Clone from Git or Mercurial URL - 既にリポジトリを所持している場合、ワークスペースへのクローンが行えますが、今回は使用しません
・Choose a Template - ここで選択した環境がセットアップ済みの状態でワークスペースが作成されます。今回は「PHP,Apache & ~」を選択しましょう

 
設定が終わったら、Create Workspaceをクリックして完了します
セットアップまで少し時間がかかりますが、完了するとCloud9 IDEへ移動します

1.png

この画面がCloud9 IDEになります。
左ペインがファイルブラウザ、右ペイン上部がエディタ、右ペイン下部がターミナルになります。

##PHPフレームワーク FuelPHPのインストール
http://fuelphp.com/
トップページの「Download v1.8.0 now!」をクリックし、FuelPHPをダウンロードします
次にローカルでzipを解凍し、ワークスペースへ転送します

ワークスペースにファイルを転送する方法は、Cloud9 IDEの左ペインにドラッグアンドドロップするだけです。

転送が完了したら、FuelPHPのディレクトリをリネームして「fuelphp」にしましょう
ファイル・ディレクトリのリネーム方法は、対象を右クリックしてRenameを選択します。

2.png

ここで一度、FuelPHPが正常に動作しているか確認しましょう

IDE上部、メニューバー右の**「Run Project」をクリックします
IDE右下ペインのタブが切り替わり、Apacheのログが出力されます
一番上の行に
「Starting Apache httpd, serving ~」**とURLが表示されるので、クリックしてOpenを選択
fuelphp->publicと辿って、Welcomeページが表示されたらFuelPHPの導入に成功です!

##データベースをセットアップしよう - phpmyadminnのインストール
次は、FuelPHPから利用するデータベースのセットアップを行います
MySQLは既に導入されていますので、データベースの作成のみ行います

まず、管理用のGUIツール**「PHPMyAdmin」**を導入します
右下ペインをbashに切り替えて、次のコマンドを入力してください

phpmyadmin-ctl install

 
コマンドが正常に実行されると
「PHPMyAdmin Installation complete. You can log in at: https://hogehoge」
のようなログが出力されます。ここで表示されるURLとUsernameを記録しておきます

 
上記で表示されたURLを開くと、PHPMyAdminのログイン画面が表示されます。
先ほど表示されたUsernameを入力してログインします(パスワードは空)
3.png

ログインに成功したら、画面上部の「データベース」をクリックします
次に、データベースを作成する の所に情報を入力し、データベースを作成します

・名前 - unity
・照合順序 - utf8_general_ci
 

必要な情報を入力したら「作成」をクリックします

データベースの作成に成功したら、データベース一覧に先ほど作成した「unity」が表示されるのでクリックします
「このデータベースにはテーブルがありません」というメッセージが表示されるので、テーブルを作成します

今回は、ログインに必要なアカウント情報を格納するテーブル「account」を作成します

必要な要素は
・DB側のインデックス(id)
・ログイン用のid(今回はメールアドレス形式を想定します)
・パスワード
・ゲーム内のプレイヤー名

なので

名前 - account
カラム数 - 4

と入力して「実行」をクリックします

各項目を次の通りに設定してください

6.png

idのインデックスをPRIMARYにして、A.I(オートインクリメント 自動で連番にする)のチェックを入れ忘れないで!

入力が終わったら「保存する」を押します。

これで、テーブルが作成されました。

##テストデータの作成
次に、FuelPHPからDBの接続を確認するためのテストデータを作成します
先ほど作成したaccountテーブルを選択し、PHPMyAdminの上部メニューより「挿入」を選択します

次の通りに入力し、実行します
testdata.png

正常に完了すると

** 1 行挿入しました。**
id 1 の行を挿入しました

と表示されます

##FuelPHPとDBをつなぐ
次のステップでは、FuelPHPからMySQLを利用するための設定を行います
今回は、FuelPHPのORMという機能を利用します

まず、ORMを使用するためにFuelPHPでORMパッケージを有効にします。次のファイルを開きます
fuelphp/fuel/app/config/config.php

設定ファイルは次の場所にあります
fuelphp/fuel/app/config/development/db.php

always_loadと書かれている場所を探し、先頭の//を消してコメントアウトをはずします
最後の// ),の部分のコメントアウトもはずし忘れないように!

次に、always_loadの数行下の次の部分を探します

// 'packages'  => array(
		// 	//'orm',
		// ),

これも全てコメントアウトをはずし、保存しましょう


 'packages'  => array(
		 	'orm',
		 ),

これでFuelPHPのORMを使用できるようになりました

config配下にはdevelopmentディレクトリの他に、productionというディレクトリも存在します
FuelPHPではApacheの環境変数を設定することで、development(開発サーバー)とproduction(本番運用環境)で設定を切り替えて動作させることができます

デフォルトではdevelopmentになっているので、今回はdevelopmentの方の設定ファイルを変更します

db.phpを開くと、次のようになっています

<?php
/**
 * The development database settings. These get merged with the global settings.
 */

return array(
	'default' => array(
		'connection'  => array(
			'dsn'        => 'mysql:host=localhost;dbname=fuel_dev',
			'username'   => 'root',
			'password'   => 'root',
		),
	),
);

これを次のように変更しましょう。自分のユーザー名や、MySQLで設定したパスワード等は適宜変更してください

<?php
/**
 * The development database settings. These get merged with the global settings.
 */

return array(
	'default' => array(
		'connection'  => array(
			'dsn'        => 'mysql:host=localhost;dbname=unity(他の名前でDBを作成していたらそのDB名)',
			'username'   => 'Cloud9のユーザー名',
			'password'   => 'MySQLのパスワード、設定していなければ空',
		),
	),
);

変更が完了したら保存します。ショートカットキーのCtrl+S(MacならCmd+S)で保存できます

##コントローラーとモデルの作成
このステップでは、データベースの操作を行うためのモデルと、データを扱ってリクエストを返すコントローラーを作成します

まずはModelを作成します
fuel/app/classes/model/ を開き、右クリックしてNew Fileを選択、ファイル名はaccount.phpにしましょう
account.phpの内容を次のようにします

<?php

<?php

class Model_Account extends Orm\Model{
    
 protected static $_properties = array(
     'id',
     'email',
     'password',
     'player_name',
 );
 
 protected static $_table_name = 'account';
 protected static $_primariy = array('id');
 
 public static function login($data){
     
    $query_result = array();
    $result = array(
        'result' => false,
        'player_name' => ''
        );
    

    $query = self::find('all', array(
        'where' => array(
            array('email', $data['email']),
            array('password', $data['password']),
        )
    ));
    
    foreach ($query as $query) {
      array_push($query_result, $query->to_array());
    }
    
    if(!empty($query_result)){
        $result['result'] = true;
        $result['player_name'] = $query['player_name'];
    } else {
        $result['result'] = false;
    }
    
    
    
    return $result;
 }

}

?>

次に、Modelにデータを渡す、またModelからデータを受け取ってリクエストとして返すControllerを作成します

fuel/app/classes/controller/ を開き、右クリックしてNew Fileを選択、ファイル名は同じくaccount.phpにしましょう

内容は以下のようにします

<?php
class Controller_Account extends Controller
{
	
	public function action_test()
	{
		
		$test_account = array(
			'email' => 'test@example.com',
			'password' => 'test',
		);
		
		$response = Model_Account::login($test_account);
		
		if($response['result'] == true){
		    print_r('login success Player_Name :' . $response['player_name']);
		} else {
		    print_r('login error!');
		}
	}
}

ControllerとModelの作成が終わったら動作チェックを行ってみましょう。次のURLにアクセスしましょう
https://Cloud9のワークスペースのURL/fuelphp/public/account/test

fuelPHPでは
publicのURL/コントローラー名/メソッド というルールでアクセスします
action_indexメソッドに処理を記述した場合は、URLからメソッドを省略できます

ワークスペースのURLは、Run Projectを実行したときに表示される
「Starting Apache httpd, serving https://hoehoge.c9users.io/.」
で確認できます

データベースとの接続が上手くいけば
「login success Player_Name :testdata」
と表示されます。

次は、テスト用ではなく、Unityからのログイン処理を行うメソッドを作りましょう
先ほど作成したaction_testメソッドの下に、次のメソッドを追加します

public function post_login()
{
    $post   = file_get_contents('php://input');
    $account = json_decode(file_get_contents('php://input'), 'json');
    $result = array();
	    
    //データチェック
    if(!isset($account['email']) && !isset($account['password'])){
	$result['error_msg'] = "ユーザー名もしくはパスワードが入力されていません";
	$result['result'] = false;
			
	print_r(Format::forge($result)->to_json());
	return null;
    }
		
    $query = Model_Account::login($account);
		
    if($query['result'] == true){
	$result['player_name'] = $query['player_name'];
	$result['result'] = true;
    } else {
        $result['error_msg'] = "ログインに失敗しました";
	$result['result'] = false;
    }
		
    print_r(Format::forge($result)->to_json());
    return null;
}

FuelPHPでは、action_またはget_から始まるメソッドはGETリクエストに対し、post_で始まるメソッドはPOSTリクエストに対するメソッドとして機能します

今回は、json形式でemailとpasswordをPOSTで投げる想定で作っています。

こちらも出来たら動作確認を行いましょう。GETではないので今度はブラウザでアクセスして確認はできません

Google Chromeを使用している場合は、DHCという機能拡張でPOSTの動作確認ができます
https://chrome.google.com/webstore/detail/dhc-restlet-client/aejoelaoggembcahagimdiliamlcdmfm

Cloud9のワークスペース**/fuelphp/public/account/login** に、次の値をPOSTしてみましょう

{"email":"test@example.com","password":"test"}

上手くいけば、次のようにJSONでレスポンスが帰ってきます

{"player_name":"testdata","result":true}

パラメータを変えてテストしてみましょう。存在しないアカウントでPOSTするときちんとエラーメッセージが帰ってきます

以上でログインサーバーの準備が完了しました

##UnityでHTTP POSTしてみる
このステップではUnityから先ほど構築したログインサーバーへ、HTTP POSTでログインする処理を作成します
Unity5.3から追加されたJsonUtilityを使用するので、Unity 5.3以降を使用します

Unityで新規プロジェクトを作成し、以下のGameObjectを作成しましょう

 
・メールアドレスの入力フィールド(UI/Input Field)
・パスワードの入力(UI/Input Field)
・ログインボタン(UI/Button)

ui_example.png

次に、プロジェクト内に**「LoginController.cs」**を作成し、以下の通りにします

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;

public class LoginController : MonoBehaviour {

    //POSTするJSONデータ構造
    [System.Serializable]
    public class Item
    {
        public string email;
        public string password;
    }

    //サーバーから帰ってくるJSONデータ構造
    [System.Serializable]
    public class login_result
    {
        public string player_name;
        public bool result;
        public string error_msg;
    }

    public InputField mailInput;
    public InputField passInput;

    public string LoginServer;
    public string PlayerName;

    private login_result result;
    

    // Use this for initialization
    void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
	
	}

    //ログインボタンを押したときの動作
    public void Login() {
        if (!string.IsNullOrEmpty(mailInput.text) && !string.IsNullOrEmpty(passInput.text))
        {
            //JSONデータを準備
            string baseJson = "{ \"email\": \"" + mailInput.text + "\", \"password\": \"" + passInput.text + "\" }";

            Item json = JsonUtility.FromJson<Item>(baseJson);
            string serialisedJson = JsonUtility.ToJson(json);
            
            //WWWをセットアップ
            Dictionary<string, string> header = new Dictionary<string, string>();
            header.Add("Content-Type", "application/json");

            byte[] data = System.Text.Encoding.UTF8.GetBytes(serialisedJson);
            WWW www = new WWW(LoginServer, data,header);

            StartCoroutine(PostLogin(www));

        } else {
            Debug.Log("IDかパスワードが入力されていません");
        }
    }

    //HTTP POSTを行うコルーチン
    private IEnumerator PostLogin(WWW www) {

        //POST結果が返ってくるまで待つ
        yield return www;

        //POSTに成功した場合
        if (www.error == null)
        {
            result = JsonUtility.FromJson<login_result>(www.text);

            if (result.result)
            {
                Debug.Log("ログインに成功!");
                PlayerName = result.player_name;
            } else {
                Debug.Log("ログインに失敗");
            }
        } else {
            Debug.Log("HTTPエラー:" + www.error);
        }
    }
}

新規に空のGameObjectを作成し、各プロパティを設定しましょう

Mail InputにはメールアドレスのInputFieldを
Pass InputにはパスワードのInputFieldを
Login ServerにはログインサーバーのURLを入れましょう

logincontroller_1.png

次に、作成したログインボタンを選択し、On Click()にクリックしたときのイベントを登録します
先ほどLoginController.csをアタッチしたオブジェクトを入れ、LoginControllerのLoginを入れます

loginbutton_onclick.png

ここまでエラーなく出来たら、一度シーンをPlayしてテストしてみましょう

DBに登録したIDとパスワードを正しく入力し、POSTが正常に行えていれば結果が返ってきます
接続できない場合は、LoginServerのURLが正しいか、Cloud9のProjectはRunになっているか、確認しましょう

console_log1.png

##Photon Unity Networkを導入し、接続する
このステップでは、Unityで使用できるゲームネットワーク「Photon」のセットアップを行います

https://www.photonengine.com/ja-JP/pun/
 

アカウントの取得、Appの登録の説明は今回は省きます
Photon RealtimeのダッシュボードでAppIDを作成します
https://www.photonengine.com/ja-JP/Dashboard/Realtime

フリープランでは20CCU(同時接続数)まで利用することができます。
 

ダッシュボードで表示されるアプリケーション IDをメモしておきましょう
(アプリケーション IDは省略されていますがクリックすると全て表示されます)

次に、UnityのAsset Storeから**「Photon Unity Networking Free」**を導入してImportします
pun-asset.png

Importが完了すると、PhotonのApp IDを入力するよう求められます
先ほど取得したApp IDを入力しましょう
PUN.png

##PhotonNetworkのコントローラを作る

このステップでは、PhotonNetworkに接続するコントローラを作ります
プロジェクト内にPhotonNetworkManager.csを作成します

PhotonNetworkManager.csのコードを以下のようにしてください

using UnityEngine;
using System.Collections;

public class PhotonNetworkManager : Photon.PunBehaviour {

    public string playerName;

	// Use this for initialization
	void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
	
	}

    public void ConnectPhotonNetwork(string playerName) {
        //Photonロビーに接続
        Debug.Log("ConnectPhotonNetwork");
        PhotonNetwork.ConnectUsingSettings("v1.0");
        this.playerName = playerName;
    }

    public override void OnConnectedToMaster()
    {
        Debug.Log("PhotonNetwork ロビーに接続成功");

        //プレイヤー名を設定
        PhotonNetwork.playerName = playerName;

        //ランダムなルームに入室を試みる
        PhotonNetwork.JoinRandomRoom();
    }

    //ルームへの入室に失敗したときに呼ばれる
    public void OnPhotonRandomJoinFailed()
    {
        Debug.Log("ルームへの入室に失敗、ルームを作成します");
        PhotonNetwork.CreateRoom(null);
    }

    public override void OnJoinedRoom()
    {
        Debug.Log("ルームへの入室に成功");
        Debug.Log("プレイヤー名:" + PhotonNetwork.playerName);
    }
   
}

普通Unity上で動かすクラスを作成すると、MonoBehaviorを継承します
しかし、Photonを使用するクラスの場合はPhoton.PunBehaviorやPhoton.MonoBehaviorを継承する点に気をつけてください

 
各メソッドの説明です

ConnectPhotonNetwork

public void ConnectPhotonNetwork(string playerName) {
        //Photonロビーに接続
        Debug.Log("ConnectPhotonNetwork");
        PhotonNetwork.ConnectUsingSettings("v1.0");
        this.playerName = playerName;
    }

このメソッドを外部のスクリプトから叩いてPhotonNetworkに接続させています

PhotonNetwork.ConnectUsingSettings("v1.0");

この中で大事なのはこの1行で、PhotonNetwork.ConnectUsingSettings() が叩かれるとPhotonNetworkに自動で接続されます
引数に渡している文字列は、渡す値が違うプログラム同士ではマッチングしないようになっています

主に、古いバージョンのプログラムと新しいバージョンのプログラムが通信してしまわないように分けるために使います

たとえば1.0と1.1を渡したプログラム同士を放り込んでもマッチングしません。

OnConnectedToMaster

public override void OnConnectedToMaster()
    {
        Debug.Log("PhotonNetwork ロビーに接続成功");

        //プレイヤー名を設定
        PhotonNetwork.playerName = playerName;

        //ランダムなルームに入室を試みる
        PhotonNetwork.JoinRandomRoom();
    }

PhotonNetwork.ConnectUsingSettings() によってPhotonNetworkに接続に成功すると自動で呼ばれます
overrideしないといけません。気をつけましょう

この時のクライアントの状態は、PhotonNetwork内のロビーに居る状態になります
ロビーは一番大きいグループの単位です。
それよりさらに分割されたルームがあり、各クライアントはルームに入室して、ルーム単位でゲームを行います。

FPSゲームのロビーとルームのような関係を想像していただけたら良いかと。

 
ルームに入室するためには、次のメソッドを叩きます

PhotonNetwork.JoinRandomRoom();

**JoinRandomRoom()**は、ロビー内に存在するランダムなルームへ入室を試みます
ルームが存在しない場合は入室に失敗するので、ハンドリング処理が必要です。

ランダムではなく指定したルームに入室する方法もありますが、今回は省略

 
OnPhotonRandomJoinFailed

//ルームへの入室に失敗したときに呼ばれる
    public void OnPhotonRandomJoinFailed()
    {
        Debug.Log("ルームへの入室に失敗、ルームを作成します");
        PhotonNetwork.CreateRoom(null);
    }

ルームへの入室に失敗したときに呼ばれるメソッドです
先ほどの**JoinRandomRoom()**で入室を試みたが部屋が無かった等の場合に呼ばれるメソッドもここです
**PhotonNetwork.CreateRoom()**を叩くと、新しいルームを作成して入室します
引数としてルーム名を渡す事ができます。

 
OnJoinedRoom

public override void OnJoinedRoom()
    {
        Debug.Log("ルームへの入室に成功");
        Debug.Log("プレイヤー名:" + PhotonNetwork.playerName);
    }

OnJoinedRoomは、ルームへの入室に成功したときに自動で呼ばれます
今回は、Debug.Logで接続成功のメッセージと、PhotonNetwork内でのプレイヤーネームを出力してみました。

 
これでPhotonコントローラは完成です
空のGameObjectを作ってスクリプトをアタッチしましょう

##ログインコントローラとPhotonを繋ぐ
最後のステップです。先ほど作成したPhotonNetworkManagerとLoginControllerを繋げて、POSTでサーバーにログインしてユーザー名を受け取り、そのユーザー名を使ってPhotonNetworkに接続できるようにします

LoginController.csを開きます
Startメソッドの前、変数を定義しているところに、次の1行を足します

public PhotonNetworkManager photonNetworkManager;

次に、IEnumerator PostLoginのところ、ログインに成功した時の処理のところに、次の1行を足します

photonNetworkManager.ConnectPhotonNetwork(this.PlayerName);

これで、ログインに成功した時に、PhotonNetworkManagerを叩いてPhotonNetworkに接続できるようになりました

 
最終的なLoginController.csのコードは次の通りになります

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;


public class LoginController : Photon.MonoBehaviour {

    //POSTするJSONデータ構造
    [System.Serializable]
    public class Item
    {
        public string email;
        public string password;
    }

    //サーバーから帰ってくるJSONデータ構造
    [System.Serializable]
    public class login_result
    {
        public string player_name;
        public bool result;
        public string error_msg;
    }

    public InputField mailInput;
    public InputField passInput;

    public string LoginServer;
    public string PlayerName;

    private login_result result;

    public PhotonNetworkManager photonNetworkManager;
    

    // Use this for initialization
    void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
	
	}

    //ログインボタンを押したときの動作
    public void Login() {
        if (!string.IsNullOrEmpty(mailInput.text) && !string.IsNullOrEmpty(passInput.text))
        {
            //JSONデータを準備
            string baseJson = "{ \"email\": \"" + mailInput.text + "\", \"password\": \"" + passInput.text + "\" }";

            Item json = JsonUtility.FromJson<Item>(baseJson);
            string serialisedJson = JsonUtility.ToJson(json);
            
            //WWWをセットアップ
            Dictionary<string, string> header = new Dictionary<string, string>();
            header.Add("Content-Type", "application/json");

            byte[] data = System.Text.Encoding.UTF8.GetBytes(serialisedJson);
            WWW www = new WWW(LoginServer, data,header);

            StartCoroutine(PostLogin(www));

        } else {
            Debug.Log("IDかパスワードが入力されていません");
        }
    }

    //HTTP POSTを行うコルーチン
    private IEnumerator PostLogin(WWW www) {

        //POST結果が返ってくるまで待つ
        yield return www;

        //POSTに成功した場合
        if (www.error == null)
        {
            result = JsonUtility.FromJson<login_result>(www.text);

            if (result.result)
            {
                Debug.Log("ログインに成功!");
                PlayerName = result.player_name;

                //PhotonNetworkに接続
                photonNetworkManager.ConnectPhotonNetwork(this.PlayerName);

            } else {
                Debug.Log("ログインに失敗");
            }
        } else {
            Debug.Log("HTTPエラー:" + www.error);
        }
    }
   


}

保存し終えたら、LoginControllerをアタッチしているGameObjectを選択し、プロパティのPhotonNetworkManagerのところにPhotonNetworkManagerをアタッチしたオブジェクトを指定します
logincontroller2.png

ここまでの作業が終わったら、Sceneをプレイして、ログインしてみましょう

Photonへの接続に成功すると、Consoleにプレイヤー名のログが出力されます

##あとがき
今回はサーバーにログインしてプレイヤー名を取得し、ログインするまでの処理を作りました
本当はユニティちゃんを配置して同期させる処理まで解説したかったのですが、時間不足で断念しました
以下に、自分が作ったサーバーとクライアントファイルを公開します
クライアント側はキャラクター同期まで作る予定でしたが、間に合わなかったので現状のものを公開します。年内を目標にバージョンアップしたクライアントを公開します

サーバー
https://github.com/Erish/advent2016-server

クライアント
https://github.com/Erish/advent2016-client

9
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?