アツマールの思い出と学び

【ゲーム制作】レンタルサーバーを使ってスコアボードを実装する②実装編2(RPGツクール/godot)

この記事は前回の続きです。
パート1:
パート2:

前回は、ゲームからスコアの送信と、それをデータベースへ挿入するサーバープログラムと、スコアボードをゲームへ応答するサーバープログラムを作りました。
今回は、レコード保存件数が上限に達した場合にスコアが最も低いレコードを削除する処理や、同一人物のレコードが重複しないようにする処理などを実装していきます。
今回実装する処理はあくまで一例で、何を同一人物とするか、新たに受信したスコアをどう扱うかは様々考え方があると思います。
ご自分の作りたいシステムに合わせて、処理内容を変えてください。

ユーザーIDの割り当て

ゲームにユーザーIDを割り当てます。
ユーザーIDを割り当てるやり方はいくつか思いつきます。
①.ユーザーIDを取得するためだけのデータベースを作り、そこで管理する。
②.スコアボードを記録するデータベースにユーザーID管理用のレコードを仕込む。 など。
多分、一番合理的なのは、①.ユーザーIDを取得するためだけのデータベースを作り、そこで管理することです。
ただ、レンタルサーバーにはデータベースの数に限りがある物もあり、そのためだけのデータベースを作成するというのはちょっともったいなく思うところで・・・。
②.スコアボードを記録するデータベースにユーザーID管理用のレコードを仕込む方法なら、データベース数に限りがある状況でも実装ができるので、ここではその方法を説明しようと思います。

・ユーザーIDの要求を送信する

後ほど、ユーザーIDを取得し応答する処理をサーバープログラムに書いていきますが、ユーザーIDを応答する処理はスコアボードを応答する処理とは別に書く必要があります。
スコアボードを応答するphpファイルと同じディレクトリに「getID.php」などのphpファイルを作り、そこにユーザーIDを応答する処理を書いていきます。
ユーザーIDの要求をゲームから行うには、この場合「http://ドメイン名/フォルダ名/getID.php」へHTTPリクエストを送信することに気を付けてください。

<RPGツクール>

前回、サンプルプロジェクトにスコアの送信とスコアボードを要求するイベントを作りました。それを丸ごとコモンイベントにしてすっきりさせます。
ついでに、仮でユーザーIDを0で送信していたのを変数の値を送るようにします。


それから、ユーザーIDを要求するイベントを新規にコモンイベントで作ります。
今回は、POSTデータは必要ありません。
例によって返ってくるデータは文字列ですのでparseIntで数値に変換します。
取得したユーザーIDは変数2に入れます。


イベント全体は次のようにします。
パート2で作った一番右のイベントです。


ユーザーIDを未取得の場合はユーザーIDの要求を行うようにし、ユーザーIDが取得済みの場合のみスコア送信とスコアボード要求を行うようにします。
この例のようにユーザーID要求とスコアボード要求がセットである必要はありませんが、ユーザーID要求をした後はできれば強制的にセーブする処理が欲しいところです。
もし、ユーザーID取得→スコア送信→セーブせずにリセット という操作を繰り返すユーザーがいると、新規のユーザーIDが次々と消費されてしまいます。

それから、動作確認用にユーザーID要求するだけのイベントを作っておきましょう。右から二番目のイベントです。



<GODOT>

前回作ったサンプルプロジェクトのスコアを送信する処理で、仮に"user_id=0"としていた行を削除しておきます。
新たにユーザーIDを取得するためにHTTPリクエストを行うボタンとHTTPrequestノードを配置します。


新規のコードは下記のような感じ。
func _on_Button_getID_button_down():
	var error = $HTTPRequest_getID.request("http://ドメイン名/フォルダ名/getID.php")
	if error != OK:
		push_error("An error occurred in the HTTP request")
	pass

func _on_HTTPRequest_getID_request_completed(result, response_code, headers, body):
	var json = JSON.parse(body.get_string_from_utf8())
	var new_user_id = int(json.result)
	user_id = new_user_id
	$Button_getID/Label.text = str(user_id)
	pass

・ユーザーID管理用レコードの追加

ユーザーIDを管理するためのレコードを下記のような感じで追加してください。


それから"tag"といったような名前のフィールドを追加してください。
「構造」タブからテーブルの末尾に追加を実行して


下記のように設定して保存します。デフォルト値は"user"などとしておきます。
管理用と、プレイヤーのレコードを区別するためのものです。


追加したら「表示」タブで"tag"が"user"になっていることを確認します。そしたらから編集ボタンを押して


管理用のIDの"tag"を"admin"などとしておきます。


これで、データベース側はひとまず準備が整いました。



・ユーザーIDを応答するサーバープログラム

次に、サーバープログラムを書いていきます。
スコアボードを応答するphpファイルと同じディレクトリに「getID.php」などのphpファイルを作り、そこにユーザーIDを応答する処理を書いていきます。
SQLに接続する処理などは前回の記事で説明済みなので、一気にドバっと書きますね。
<?php
    try{
        //インスタンスの生成
        $user = 'ユーザー名';
        $pass = 'パスワード';
        $dsn = 'mysql:host=ホスト名;dbname=データベース名;';
        $pdo = new PDO($dsn,$user,$pass);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        //管理用user_idを+1に更新
        $sql = "UPDATE ranking SET user_id = user_id + 1 WHERE tag = 'admin'";
        $stmt = $pdo->prepare($sql);
        $stmt->execute();
        
        //user_id最大値を取得
        $sql = "SELECT user_id AS new_user_id FROM ranking WHERE tag = 'admin'";
        $stmt = $pdo->prepare($sql);
        $stmt->execute();
        $result = $stmt->fetchColumn();
    }catch (PDOException $e) {
        //エラー処理
        //echo 'データベース接続失敗: ' . $e->getMessage() . '<br>';
        //echo 'ファイル名:' . $e->getFile() . '<br>';
        //echo '行番号:' . $e->getLine() . '<br>';
        //echo '例外の種類:' . get_class($e) . '<br>';
        echo 'ERROR';
    }
    //接続を閉じる(省略可)
    $pdo = null;
    //Content-Type: application/json
    header("Content-Type: application/json; charset=utf-8");
    //取得したデータをjsonに変換
    $response = json_encode($result);
    //応答
    echo $response;
?>

ちょっと解説。
$sql = "UPDATE ranking SET user_id = user_id + 1 WHERE tag = 'admin'";
このSQL文は、"tag"が"admin"になっているレコードの"user_id"をuser_id + 1に更新するものです。

$sql = "SELECT user_id AS new_user_id FROM ranking WHERE tag = 'admin'";
このSQL文は、"tag"が"admin"になっているレコードの"user_id"を、連想配列の要素"new_user_id"として取得するものです。

これらの処理によって、ユーザーIDの要求があるたびに"user_id"は+1され、その値を取得する。つまり、ゲームはゲームプレイごとに値が重複することなく"user_id"を取得できます。

以上で、ユーザーIDを取得する処理が出来ました。動作確認してみましょう。
ユーザーIDを要求するたびに、表示される数値が増加していけば成功です!



データベースの管理用レコードが表示した通りの"user_id"になっていることも確認しましょう。


ついでに、IDを取得したうえでスコアの送信が可能であることも確認しておきましょう。

同一ユーザーのレコードが重複しないようにする

同一ユーザーのレコードが重複しないよう、データベースを挿入する処理を作ります。
今のままでは、同じユーザーのレコードばかりがランキングに並ぶような事態になりかねません。
まあ、今となってはそれもまたいいのかな、なんて思いますし
ここから先はご自分で必要と思う処理を取捨選択して実装してください。

手順は次のような感じです。ゲームから受信したスコアについて
・データベース内に既に同一のユーザーIDのレコードがあるかチェックし、無い場合はそのまま挿入する。
・ある場合は、すでにあるスコアと新しいスコアを比較し、新しいスコアの方が高い場合はレコードを更新する。

まず、管理用レコードを作りましたので、前回作ったスコアボードを取得する処理(getScoreBoard)を、管理用レコードを取得しないように修正します。
    function getScoreBoard($pdo) {
        // スコア降順に並べ替えつつ全レコード取得
        $sql = "SELECT * FROM ranking WHERE tag = 'user' ORDER BY score DESC";
        $stmt = $pdo->prepare($sql);    
        $stmt->execute();
        $result = $stmt->fetchall(PDO::FETCH_ASSOC);
        return $result;
    }
SQL文に"WHERE tag = 'user'"の条件が追加されています。

次に、同一のユーザーIDのレコードがあるかチェックしてレコードを挿入・更新する処理です。下記のような関数をサーバープログラムへ新規に追記します。
    function getScoreByID($pdo,$user_id) {
        // 同じユーザIDの既存のレコードを取得する
        $stmt = $pdo->prepare("SELECT MAX(score) FROM ranking WHERE user_id = :user_id AND tag = 'user'");
        $stmt->bindValue(':user_id', $user_id , PDO::PARAM_INT);
        $stmt->execute();
        $exist_score = $stmt->fetchColumn();
        return $exist_score;
    }
    function updateHighScore($pdo,$user_id,$user_name,$score) {
        //ハイスコア獲得 データを更新する
        $stmt = $pdo->prepare("UPDATE ranking SET score = :score, user_name = :user_name WHERE user_id = :user_id AND tag = 'user'");
        $stmt->bindValue(':user_id', $user_id , PDO::PARAM_INT);
        $stmt->bindValue(':user_name', $user_name , PDO::PARAM_STR);
        $stmt->bindValue(':score', $score , PDO::PARAM_INT);
        $stmt->execute();
    }
try-catch内を次のように書き換えます。
    try{
        //インスタンスの生成
        $user = 'ユーザー名';
        $pass = 'パスワード';
        $dsn = 'mysql:host=ホスト名;dbname=データベース名;';
        $pdo = new PDO($dsn,$user,$pass);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        // 同じユーザIDの既存のレコードを取得する
        $exist_score = getScoreByID($pdo,$user_id);
        // スコアが高い場合に新しいスコアを記録する
        if (getScoreByID($pdo,$user_id) != null) {
            if ($score > $exist_score) {
                //ハイスコア獲得 データを更新する
                updateHighScore($pdo,$user_id,$user_name,$score);
            } else {
                //今回のスコアは既存のスコアより低い データ更新なし
            }
        } else {
            //受信したスコアをテーブルへ追加
            insertScore($pdo,$user_id,$user_name,$score);
        }
        // スコア降順に並べ替えつつ全レコード取得
        $result = getScoreBoard($pdo);
    }catch (PDOException $e) {
レコードを挿入・更新する処理がこれでできました。ついでに、レコード最大件数を超えたらスコアが最低のレコードを削除する処理と
ゲームから送信したスコアが新規に挿入されたのか、既存のレコードを更新したのか、スコアが低かったから何もされなかったのかをスコアボードと一緒に応答する処理を実装します。

レコードの削除と更新内容の応答

下記のような関数を追記します。レコード最大保存件数は、とりあえず100件です。お好きな数に調整してください。
    function deleteLowestScoreRecord($pdo) {
        // レコード数が最大件数より多い場合に、最もscoreが低いレコードを削除する
        $max_records = 100;
        $stmt = $pdo->prepare("SELECT COUNT(*) FROM ranking");
        $stmt->execute();
        $count = $stmt->fetchColumn();
        if ($count > $max_records) {
            $stmt = $pdo->prepare("DELETE FROM ranking ORDER BY score ASC LIMIT :delete_limit");
            $stmt->bindValue(':delete_limit', $count - $max_records, PDO::PARAM_INT);
            $stmt->execute();
        }
    }
"getScoreBoard"の手前でこの関数を呼び出します。
    (略)
        // レコード数が最大件数より多い場合に、最もscoreが低いレコードを削除する
        deleteLowestScoreRecord($pdo);
        // スコア降順に並べ替えつつ全レコード取得
        $result = getScoreBoard($pdo);
    }catch (PDOException $e) {
これで、レコードが100件を超えた場合自動的に削除されるようになりました。
次に、応答データ内にスコアが新規に挿入されたのか、既存のレコードを更新したのか、スコアが低かったから何もされなかったのかがわかるようにフラグセットを追加して行きます。
スコアを更新する処理に下記のようなフラグセット($actionflag = 'xxx';)を追記します。
また、スコアボードと一緒に$actionflagの値が返されるように、応答データの連想配列に含めるようにします。($result["actionflag"] = $actionflag;)
        // スコアが高い場合に新しいスコアを記録する
        if (getScoreByID($pdo,$user_id) != null) {
            if ($score > $exist_score) {
                //ハイスコア獲得 データを更新する
                updateHighScore($pdo,$user_id,$user_name,$score);
                $actionflag = 'update';
            } else {
                //今回のスコアは既存のスコアより低い データ更新なし
                $actionflag = 'nothing';
            }
        } else {
            //受信したスコアをテーブルへ追加
            insertScore($pdo,$user_id,$user_name,$score);
            $actionflag = 'insert';
        }
        // レコード数が最大件数より多い場合に、最もscoreが低いレコードを削除する
        deleteLowestScoreRecord($pdo);
        // スコア降順に並べ替えつつ全レコード取得
        $result = getScoreBoard($pdo);
        // 更新フラグ
        $result["actionflag"] = $actionflag;

動作確認

これで、この記事におけるゲームからサーバーへスコアを送信してスコアボードを応答する処理の実装はすべて完了しました。
動作確認してみましょう。
下記のように、スコア全レコードと、actionflagが出力されたら成功です!
actionflagの値は、スコアボードに新たにレコードが挿入された場合は"insert"
高いスコアに更新された場合は"update"
既存のスコアよりも低くて更新されなかった場合"nothing" となるはずです。
確認してみてください。



応答データの取り出し

次のようにしてデータを取り出せます。
サーバーから応答されてきたデータは、全て文字列であることに気を付けてください。

<RPGツクール>

例えば、ランキング1位のレコードは次のように取り出します。


2位のレコードは次のように取り出します。


スコア更新情報actionflagは次のように取り出します。


<GODOT>

ランキング1位のレコードは次のように取り出します。
	var rank_user_name = res["0"]["user_name"]
	var rank_score = int(res["0"]["score"])
2位のレコードは次のように取り出します。
    var rank_user_name = res["1"]["user_name"]
	var rank_score = int(res["1"]["score"])
スコア更新情報actionflagは次のように取り出します。
	var actionflag = res["actionflag"]

ひとまず完成

応答データからユーザー名とスコアの取り出しが出来たら、あとはランキングを実装するだけです。
プログラムの知識がある程度ある方なら、ここまでの情報でランキング表示の実装ができると思います。
ただ、この記事では無料のレンタルサーバーを使った方法を紹介しており、セキュリティ的に非常に脆弱で、簡単になりすましなどが出来てしまいます。
パート1にも書きましたが、ネットワーク通信を利用したシステムを導入したい場合は有料のレンタルサーバーを契約してSSL暗号化通信を利用するべきです。
暗号化なしだと、例えば、私があなたの制作したゲームをダウンロードして、スコアを送信する通信内容を覗き見て
全く同じ送信データを全く同じドメインへ送信するようなゲームを作ったら、簡単にデータベースを荒らすことができてしまいます。
どうにか無料でネットワーク通信を運用したい場合、自前でセキュリティを強化する必要があります。
セキュリティの強化というのは、どんな強固なものでも穴はできるものだと思いますし、全てに対策しようとしたらきりがありませんが、その程度のいたずら防止くらいはしたいですね。

そんなわけで、次回は
・自前でセキュリティ強化
・ブラウザからHTTPリクエストする
・RPGツクールのイベントコマンドだけでランキング表示を実装する
を予定しています。
上記を見る必要がない方は、ここで終了です。

(ちょっとモチベが低下しちゃったので、上記は要望があったら更新することにします。)

最後に、全体のサーバープログラムを載せておきます。
<?php
    //RPGツクールではこっちをつかう
    // POSTリクエストで送信されたデータを取得する
    if ($_SERVER["REQUEST_METHOD"] == "POST" && $_SERVER["CONTENT_TYPE"] == "application/x-www-form-urlencoded") {
        $user_id = $_POST["user_id"];
        $user_name = $_POST["user_name"];
        $score = $_POST["score"];
    } else {
        echo 'ERROR';
        exit;
    }
    //GODOTではこっちをつかう
    if ($_SERVER["REQUEST_METHOD"] == "POST" && $_SERVER["CONTENT_TYPE"] == "application/json") {
        // POSTされたJSON文字列を取り出し
        $json = file_get_contents("php://input");
        // JSON文字列をobjectに変換
        $contents = json_decode($json, true);
        //受信データ取得
        $user_id = $contents["user_id"];
        $user_name = $contents["user_name"];
        $score = $contents["score"];
    } else {
        echo 'ERROR';
        exit;
    }

    try{
        //インスタンスの生成
        $user = 'ユーザー名';
        $pass = 'パスワード';
        $dsn = 'mysql:host=ホスト名;dbname=データベース名;';
        $pdo = new PDO($dsn,$user,$pass);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        // 同じユーザIDの既存のレコードを取得する
        $exist_score = getScoreByID($pdo,$user_id);
        // スコアが高い場合に新しいスコアを記録する
        if (getScoreByID($pdo,$user_id) != null) {
            if ($score > $exist_score) {
                //ハイスコア獲得 データを更新する
                updateHighScore($pdo,$user_id,$user_name,$score);
                $actionflag = 'update';
            } else {
                //今回のスコアは既存のスコアより低い データ更新なし
                $actionflag = 'nothing';
            }
        } else {
            //受信したスコアをテーブルへ追加
            insertScore($pdo,$user_id,$user_name,$score);
            $actionflag = 'insert';
        }
        // レコード数が最大件数より多い場合に、最もscoreが低いレコードを削除する
        deleteLowestScoreRecord($pdo);
        // スコア降順に並べ替えつつ全レコード取得
        $result = getScoreBoard($pdo);
        // 更新フラグ
        $result["actionflag"] = $actionflag;
    }catch (PDOException $e) {
        //エラー処理
        //echo 'データベース接続失敗: ' . $e->getMessage() . '<br />';
        //echo 'エラーメッセージ:' . $e->getMessage() . '<br />';
        //echo 'ファイル名:' . $e->getFile() . '<br />';
        //echo '行番号:' . $e->getLine() . '<br />';
        //echo '例外の種類:' . get_class($e) . '<br />';
        echo 'ERROR';
    }
    //接続を閉じる(省略可)
    $pdo = null;
    //Content-Type: application/json
    header("Content-Type: application/json; charset=utf-8");
    //取得したデータをjsonに変換
    $response = json_encode($result);
    //応答
    echo $response;

    function insertScore($pdo,$user_id,$user_name,$score) {
        // 受信データをデータベースへ追加
        $sql = "INSERT INTO ranking (user_id, user_name, score) VALUES (:user_id,:user_name,:score)";
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue(':user_id', $user_id , PDO::PARAM_INT);
        $stmt->bindValue(':user_name', $user_name , PDO::PARAM_STR);
        $stmt->bindValue(':score', $score , PDO::PARAM_INT);
        $stmt->execute();
    }
    function getScoreBoard($pdo) {
        // スコア降順に並べ替えつつ全レコード取得
        $sql = "SELECT * FROM ranking WHERE tag = 'user' ORDER BY score DESC";
        $stmt = $pdo->prepare($sql);    
        $stmt->execute();
        $result = $stmt->fetchall(PDO::FETCH_ASSOC);
        return $result;
    }
    function getScoreByID($pdo,$user_id) {
        // 同じユーザIDの既存のレコードを取得する
        $stmt = $pdo->prepare("SELECT MAX(score) FROM ranking WHERE user_id = :user_id AND tag = 'user'");
        $stmt->bindValue(':user_id', $user_id , PDO::PARAM_INT);
        $stmt->execute();
        $exist_score = $stmt->fetchColumn();
        return $exist_score;
    }
    function updateHighScore($pdo,$user_id,$user_name,$score) {
        //ハイスコア獲得 データを更新する
        $stmt = $pdo->prepare("UPDATE ranking SET score = :score, user_name = :user_name WHERE user_id = :user_id AND tag = 'user'");
        $stmt->bindValue(':user_id', $user_id , PDO::PARAM_INT);
        $stmt->bindValue(':user_name', $user_name , PDO::PARAM_STR);
        $stmt->bindValue(':score', $score , PDO::PARAM_INT);
        $stmt->execute();
    }
    function deleteLowestScoreRecord($pdo) {
        // レコード数が最大件数より多い場合に、最もscoreが低いレコードを削除する
        $max_records = 100;
        $stmt = $pdo->prepare("SELECT COUNT(*) FROM ranking");
        $stmt->execute();
        $count = $stmt->fetchColumn();
        if ($count > $max_records) {
            $stmt = $pdo->prepare("DELETE FROM ranking ORDER BY score ASC LIMIT :delete_limit");
            $stmt->bindValue(':delete_limit', $count - $max_records, PDO::PARAM_INT);
            $stmt->execute();
        }
    }
?>


コメント