ゴマちゃんフロンティア

アザラシが大好きなエンジニアの開発日記です

【Unity】ランキング機能を自作してみたお話

time 2019/01/17

というわけで、今回はUnityで「ランキング機能」を実装してみました!

いきなり作り出した理由ですが、去年末の「Unity1週間ゲームジャム」に投稿した作品に実装したかったためです。
スコアを競うゲームなのにランキングがないのは盛り上がりに欠けるというか…せっかくいろいろな方にプレイいただいているのに、ちょっともったいないと感じてきました。

unityroomさんに簡単な実装方法のリンクが貼ってあったので、それに従えば作れそうです。
https://blog.naichilab.com/entry/webgl-simple-ranking

ただ、それでは私自身の勉強にならないため、1から自分で作ってみることにしました。
ちょうどマイVPSがあるので、バックエンドとしてPHP+MySQLでランキング機能用のシステムを作りました。

設計

前述通りPHP+MySQLでランキング管理用サーバを作成します。
ランキングに表示する情報は「順位」「名前(半角英数字)」「スコア」くらいで十分かと思います。

Unity側から以下のタイミングでランキングサーバと通信を行います。
・ランキングを表示するとき
・ゲーム終了後にランクインしたか判定するとき
・ランキングに登録するとき

通信はHTTPリクエストで、JSONを用いる至って普通の形式です。
他に特に変わったことはしないので、とりあえずレッツトライ(`・ω・´)

ランキングサーバ側の作成

PHPのフレームワークとして「Laravel(ver5.7)」を使います。ちょっと大げさな気もしますが、Laravelの練習にはちょうどよかったです。

プロジェクト作成

ここでは「UnityRankingServer」というプロジェクト名で作成しています。開発環境のOSはWindowsなので、「composer」は事前にインストールしておきました。

【コマンド】

composer create-project laravel/laravel ./UnityRankingServer

マイグレーションの作成

ランキングを管理するテーブルを作成するため、マイグレーションファイルを作成します。

【コマンド】

php artisan make:migrate CreateRanking

作成されたマイグレーションファイルにランキング用テーブルの記述を追加します。

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateRanking extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('ranking', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('game_id')->unsigned();
            $table->integer('rank')->unsigned();
            $table->string('name', 50);
            $table->string('score', 100);
            $table->string('comment', 100)->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('ranking');
    }
}

ランキングに必要な要素の他、ゲームIDや作成日、コメントのカラムも(一応)作っておきます。
別のゲームでも使用することを想定し、スコアはstringを指定しました。

作成後にマイグレーションを実行し、対応するモデルクラスも作っておきます。

【コマンド】

php artisan migrate
php artisan make:model Ranking

【モデル】

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Ranking extends Model
{
    /**
     * テーブル名指定
     */
    protected $table = 'ranking';
}

どうもLaravelはモデル名を複数形にしたものをテーブル名として扱うそうで、先のマイグレーションでは「ranking」となっていたため、そのままでは動きませんでした。
仕方ないので公式マニュアル通り$tableプロパティを設定します。

API用コントローラの作成

API用のルーティングを定義します。
今回必要なAPIは3つしかないので、1コントローラに集約させてしまいます。

<?php

use Illuminate\Http\Request;

Route::group(['middleware' => 'api'], function () {
    Route::get('/get', 'RankingController@get');
    Route::get('/check_rankin', 'RankingController@checkRankin');
    Route::post('/regist', 'RankingController@regist');
});

続いてコントローラを作成します。

【コマンド】

php artisan make:controller Ranking

【コントローラ】

<?php

namespace App\Http\Controllers;

use App\Ranking;
use App\Services\RankingService;
use Illuminate\Http\Request;

class RankingController extends Controller
{
    protected $rankingService;

    public function __construct(RankingService $rankingService)
    {
        $this->rankingService = $rankingService;
    }

    public function get(Request $request)
    {
        // ゲームIDに対応するランキング情報を取得
        $rankings = Ranking::where('game_id', $request['game_id'])->get();

        // Unity側のJsonUtilityでrootが配列だと受け取れないので1クッション挟む
        $responseJson = [
            'rankings' => $rankings
        ];

        return response()->json($responseJson);
    }

    public function checkRankin(Request $request)
    {
        // ランク取得
        $rank = $this->rankingService->getScoreRank($request['game_id'], $request['score']);

        // ランクが10以内であればランクイン
        $result_rankin = false;
        if ($rank <= 10) {
            $result_rankin = true;
        }

        $responseJson = [
            'rankin' => $result_rankin,
            'rank' => $rank,
        ];

        return response()->json($responseJson);
    }

    public function regist(Request $request)
    {
        $this->rankingService->registRanking($request);

        $responseJson = [
            'result' => true,
        ];

        return response()->json($responseJson);
    }
}

コントローラ内に長いロジックを書きたくないので、追加でサービスクラスを作成しました。
AppServiceProviderregister()内でbind()を記述し、コントローラのコンストラクタでサービスクラスのインスタンスを受け取れるようにしました。

<?php

namespace App\Services;

use App\Ranking;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class RankingService {

    public function getScoreRank($game_id, $score)
    {
        $query = Ranking::query();
        $query->where('game_id', (int)$game_id);
        $query->where('score', '>=', (int)$score);

        $rankings = $query->get()->toArray();

        // リクエストスコア以上のスコアを持つレコード数+1をランクとして返す
        return count($rankings) + 1;
    }

    public function registRanking(Request $request)
    {
        // リクエストされた順位以下の順位をすべて1下げる
        $query = Ranking::query();
        $query->where('game_id', (int)$request['game_id']);
        $query->where('rank', '>=', (int)$request['rank']);
        $query->orderBy('rank');
        $rankings = $query->get();

        $rank = (int)$request['rank'];
        foreach ($rankings as $ranking) {
            $rank += 1;
            $ranking['rank'] = $rank;

            $ranking->save();
        }

        // リクエスト内容をランキングに登録
        $registRanking = new Ranking();
        $registRanking->game_id = $request['game_id'];
        $registRanking->rank = $request['rank'];
        $registRanking->name = $request['name'];
        $registRanking->score = $request['score'];
        $registRanking->save();
    }
}

長々載せましたが、ポイントはサービスクラスでの「スコアに対するランクの取得」と「ランキングへの登録」です。
ランク取得は「リクエストされたスコアより大きいスコアのレコード数+1」で算出できます。ただしスコアが重複した場合は考慮されていません。パッといい仕組みが思いつかなかったので今回はスルーしました。
ランキングへの登録は登録前に「登録するランク以下の全レコード」のランクを1下げる必要があります。ランキングの途中の割り込んで入れるのだから当然ではありますが、怠るとランクが重複しまくるので注意です。

今見返すと「SELECT COUNT使えよ」とか「スコア降順にすればランクいらないかも」とか気になるところはありますが、実現できてはいるのでとりあえず良しとします。
またサンプルでは入力値のチェック等を行っていないので、実際に使う場合はしっかり実装するようにしないとですね。

Unity側の作成

既存のゲームに組み込む形で作成しました。
その関係ですべて載せると長くなってしまうので、ランキングに関係する部分のみをピックアップしてご紹介します。

サーバ通信用クラスの作成

まずはUnityWebRequestクラスを使ってリクエストを送信するクラスを作成します。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;

public class HttpService : MonoBehaviour
{
    /// <summary>
    /// サーバへGETリクエストを送信
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="url"></param>
    /// <param name="requestParams"></param>
    /// <returns></returns>
    public static IEnumerator Get<T>(string url, IDictionary<string, string> requestParams = null)
    {
        string requestUrl = url;

        // リクエストパラメータがある場合はURLに結合
        if (requestParams != null) {
            requestUrl += "?";

            // パラメータ文keyとvalueを結合
            foreach (var requestParam in requestParams) {
                requestUrl += requestParam.Key + "=" + requestParam.Value + "&";
            }

            // 後ろの&を削除
            requestUrl = requestUrl.Substring(0, requestUrl.Length - 1);
        }

        var request = UnityWebRequest.Get(requestUrl);

        // リクエスト送信
        yield return request.SendWebRequest();

        // エラー判定
        if (request.isHttpError || request.isNetworkError) {
            throw new Exception("通信に失敗しました。(" + request.error + ")");
        }

        yield return JsonUtility.FromJson<T>(request.downloadHandler.text);
    }

    /// <summary>
    /// サーバへPOSTリクエストを送信
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="url"></param>
    /// <param name="requestParams"></param>
    /// <returns></returns>
    public static IEnumerator Post<T>(string url, IDictionary<string, string> requestParams)
    {
        var request = UnityWebRequest.Post(url, (Dictionary<string, string>)requestParams);

        // リクエスト送信
        yield return request.SendWebRequest();

        // エラー判定
        if (request.isHttpError || request.isNetworkError) {
            throw new Exception("通信に失敗しました。(" + request.error + ")");
        }

        yield return JsonUtility.FromJson<T>(request.downloadHandler.text);
    }
}

UnityWebRequestでは「GET」「POST」以外のメソッドにも対応しているようですが、今回は不要なので2つだけ作成しました。
最後のyield returnJsonUtilityがパースした結果を返すのがポイントです。このように書いておくと、呼び出し元でパース結果を取得できるようになります。

データクラスの作成

JSONレスポンスのパースで使うデータクラスをいくつか作成しておきます。

【ランキング情報】

 
using System;
using System.Collections.Generic;

[Serializable]
public class ResnponseGetRanking
{
    public List<Ranking> rankings;
}

[Serializable]
public class Ranking
{
    public int id;
    public int game_id;
    public int rank;
    public string name;
    public string score;
    public string comment;
    public DateTime created_at;
    public DateTime updated_at;
}

【ランクイン判定】

 
public class ResponseCheckRankin
{
    public bool rankin;
    public int rank;
}

【ランキング登録】

 
public class ResponseRegistRanking
{
    public bool result;
}

定数クラスの作成

サーバのベースとなるURLやゲームIDを定数で定義しておきます。

 
public class SystemConstants
{
    public const int GAME_ID = 2;
    public const string RANKING_SERVER_BASE_URL = "http://localhost";
}

ここではlocalhostと書いていますが、使用する際には接続先のサーバに書き換えましょう。

ランクインの判定

ゲーム終了時のスコアを元に、TOP10以内にランクインしているかを判定します。既に終了時に実行するOnGameFinish()というメソッドがあるので、それをコルーチンとして呼び出します。
いくつかの変数はフィールドに定義している関係でここに載っていませんが、大抵はスコアを示すint型かUI系コンポーネントのどちらかです。

/// <summary>
/// ゲーム終了時の処理
/// </summary>
protected IEnumerator OnGameFinish()
{
    // ランクインしているか判定
    var httpGet = HttpService.Get<ResponseCheckRankin>(
        SystemConstants.RANKING_SERVER_BASE_URL + "/api/check_rankin",
        new Dictionary<string, string>()
        {
            { "game_id", SystemConstants.GAME_ID.ToString() },
            { "score", score.ToString() }
        }
    );
    yield return StartCoroutine(httpGet);
    var responseCheckRankin = httpGet.Current as ResponseCheckRankin;

    if (!responseCheckRankin.rankin) {
        // ランクインしていない場合の処理 (タイトルへ戻す)
        yield break;
    }

    // ランクインしている場合は少し待ってから登録パネル表示
    yield return new WaitForSeconds(2);

    // ランキング登録パネル表示
    registRankingPanel.SetActive(true);

    // ランキング登録パネルの情報更新
    registRankingRank.text = responseCheckRankin.rank.ToString();

    yield break;
}

ポイントはHttpServiceのメソッドの返り値の取り方です。
コルーチンとして実行するためreturnで値を返すことはできませんが、実行後にIEnumerator.Currentを参照することで、最後にyield returnした内容を取得できます。それを対応するデータクラスの型にキャストすればOKです。

ランキングへの登録

ランクインしている場合はタイトル画面に戻さず、ランキング登録用のパネルを表示します。

名前を入力するInputFieldと登録用のButtonを用意します。Buttonには以下のスクリプトのOnClickRegistRanking()メソッドを指定します。

 
/// <summary>
/// ランキング登録ボタンクリック時のイベント
/// </summary>
public void OnClickRegistRanking()
{
    // 登録ボタンを無効化
    registRankingButton.enabled = false;

    // 名前のバリデーションチェック (半角英数字ハイフンアンダースコアのみか)
    if (!Regex.IsMatch(registRankingName.text, "^[a-zA-Z0-9-_]*$")) {
        registRankingMessage.text = "名前は半角英数字のみ使用できます";
        registRankingButton.enabled = true;
        return;
    }

    StartCoroutine(RegistRanking());
}

/// <summary>
/// ランキング登録時の処理
/// </summary>
/// <returns></returns>
protected IEnumerator RegistRanking()
{
    // メッセージ更新
    registRankingMessage.text = "ランキングに登録中です…";

    // サーバへ登録リクエスト送信
    var httpPost = HttpService.Post<ResponseRegistRanking>(
        SystemConstants.RANKING_SERVER_BASE_URL + "/api/regist",
        new Dictionary<string, string>()
        {
            { "game_id", SystemConstants.GAME_ID.ToString() },
            { "rank", registRankingRank.text },
            { "name", registRankingName.text },
            { "score", score.ToString() }
        }
    );

    yield return StartCoroutine(httpPost);
    var responseRegistRanking = httpPost.Current as ResponseRegistRanking;

    // 失敗した場合はメッセージを変えて処理を中止
    if (!responseRegistRanking.result) {
        registRankingMessage.text = "登録に失敗しました。" + Environment.NewLine + "再度お試しください。";
        registRankingButton.enabled = true;
        yield break;
    }

    // メッセージ更新
    registRankingMessage.text = "登録しました!";

    // タイトル画面へ戻す処理

    yield break;
}

名前は半角英数字+ハイフン+アンダーバーのみ許可するようにしました。マルチバイト文字でも問題なさそうではありますが、念のため…。
先ほどLaravel側で作ったリクエストパラメータに合うようにパラメータのDictionaryを作成します。

ランキング表示

タイトル画面でRキーを押すことでランキングを表示できるようにしました。
Rキーが押された場合はサーバと通信し、トップスコア10人までのランキングを表示します。

こちらはタイトル画面のマネージャクラスにOutputRanking()というメソッドを実装しました。Rキー押下時にコルーチンとして実行します。

private IEnumerator OutputRanking()
{
    // 既存のランキングアイテムのUIオブジェクトを削除
    foreach (RectTransform item in rankingItemBase) {
        Destroy(item.gameObject);
    }

    // ランキングパネル表示
    rankingPanel.SetActive(true);

    // 取得中テキスト表示
    GameObject waitText = rankingPanel.transform.Find("WaitText").gameObject;
    waitText.SetActive(true);

    // ランキング情報取得
    var httpGet = HttpService.Get<ResnponseGetRanking>(
        SystemConstants.RANKING_SERVER_BASE_URL + "/api/get",
        new Dictionary<string, string>()
        {
            { "game_id", SystemConstants.GAME_ID.ToString() }
        }
    );
    yield return StartCoroutine(httpGet);
    var responseRanking = httpGet.Current as ResnponseGetRanking;

    // ランキング情報をパネルに設定
    int positionCount = 0;

    if (responseRanking.rankings.Any()) {
        var rankings = responseRanking.rankings
            .OrderBy(x => x.rank)
            .Take(10);

        foreach (var ranking in rankings) {
            GameObject rankingObject = Instantiate(rankingItem, rankingItemBase.transform.position, Quaternion.identity, rankingItemBase);

            rankingObject.transform.Find("Rank").GetComponent<Text>().text = ranking.rank.ToString();
            rankingObject.transform.Find("Score").GetComponent<Text>().text = ranking.score;
            rankingObject.transform.Find("Name").GetComponent<Text>().text = ranking.name;

            Vector3 itemOffset = new Vector3(0, -30f * positionCount, 0);
            rankingObject.transform.GetComponent<RectTransform>().position += itemOffset;

            positionCount++;
        }
    }

    // 取得中テキスト非表示
    waitText.SetActive(false);
}

rankingItemはプレハブで、「ランク」「スコア」「名前」のUI.Textを持ったゲームオブジェクトをまとめた親オブジェクトです。ランキングの1レコードにつき1つ生成するので、最大で10個になります。
rankingItemBaserankingItemの生成先の基準とするゲームオブジェクトです。ランク1の表示位置に配置しているので、ここを基準にランクに合わせてY軸を下にずらします。
生成時はrankingItemBaseを親オブジェクトに設定することで、ランキングの再表示時に円滑に削除できるようにしました。メソッド冒頭でループしてDestroy()を読んでいる部分ですね。

あとがき

そんなわけで、Unityで使用するランキング機能を自作してみました!
これで次の1週間ゲームジャムが来ても無理なく組み込めますね。

今回作成したランキング機能は近いうちに「ばくれつうさぴょん」に実装するので、お楽しみに~!

down

コメントする