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

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

 
こんにちは。この記事は下記の続きです。

前回、簡単なプログラムを書いて、ゲームとサーバーの通信を動作確認しました。
今回は、実際にスコアを送信して、スコアボードを取得する処理をサンプルプロジェクトとして実装していきます。
あと、後半からサーバープログラムの実装がありますが、そこからはCHAT GPTなどのAIをお供に作業することをお勧めします。
サーバープログラムはデバッグが難しく、一つうまくいかないことがあるとべらぼうに時間を取られることもあります。
(VScodeを使ってデバッグするやり方もあるようですが、私は面倒でやりませんでした。)
AIにコードやエラーメッセージを丸ごと投げれば、問題のある個所を指摘してくれるので、非常に助けになると思います。
私も、CHAT GPTがなかったらランキングの実装にもう2,3倍の時間がかかっていたかもしれません。

サンプルプロジェクトの仕様

今回、下記のようなサンプルプロジェクトを作っていきます。
ご自分のゲームにランキングを実装する場合は、必要な処理を必要なタイミングで実行するように手直ししてください。
  • ゲームはプレイヤーネーム(主人公の名前)とスコアとユーザーIDを送信する。
  • それに対しサーバーはランキングをゲームへ応答する。
  • ランキングはレコード100件を降順で応答する。
  • ゲームは初回ゲーム開始時にユーザーIDをサーバーから取得し、これをセーブデータに含める。
  • 同一のユーザーIDのスコアは同一人物のものとみなし、スコアボード上で重複しないようにする。(つまり、同じセーブデータを同一人物とみなす。)
  • 同一人物のスコアがすでにスコアボード上にある場合は、送信したスコアが大きい場合のみデータベースを更新する。
「同じセーブデータを同一人物とみなす」について、理想はログインのような仕組みを作ることですが
先述の通り、SSL暗号化通信が使えない環境でそのようなシステムを実装するべきではありません。(というか、素人が実装するべきではありません。)
ので、妥協案としてこのような仕様を採用しています。


MySQLの準備

これから、ゲームからスコアを送信してMySQLへレコードを追加する処理を作っていきますが、その前にMySQLの設定をします。
XFREEの公式サイトにデータベースやユーザー登録の方法が書かれていますので、そちらを参考に設定を済ませてください。


設定はとりあえずこれで終わりですが、少しデータベースをいじってみて、どんな感じで動くのかをなんとなく知っておきましょう。
そのまま続けて「phpmyadmin」をクリックします。


こういう画面になると思いますので、データベース名が書いてあるところをクリックします。


こういう画面になると思いますので、テーブル名にrankingなどとつけて、フィールド数は3とし、実行を押します。


次にフィールドの追加画面が出てきますので、user_id、user_name、scoreを下記のように入力して保存します。
「長さ」はそのまま文字数を表すと思っていいです。下記の例の場合、最大12文字までデータとして入ることができます。お好みで長さを設定してください。
長すぎるとあとで表示スペースに困りますが・・・。


そうすると、下記のような画面が出てきてテーブルにフィールドが追加されたことがわかると思います。
そしたら挿入タブを押して、適当にデータを挿入してみましょう。


こんな感じに入力して、実行してください。


ついでに何個かデータを挿入してみて、データベースをちょっとにぎやかにしてみましょう。
そして表示タブをクリックすると例えば下記のようにデータベースが作られていることがわかると思います。


データベースいじりはこの辺にして、次はゲームからスコアを送ってデータベースへレコードを挿入できるようにしていきます。

スコアをデータベースへ挿入する

ゲームからスコアを送信して、データベースに挿入できるようにします。

・スコア送信処理

まずはゲームからプレイヤー名とスコアを送信する処理を作ります。
下記に作る処理はあくまでサンプルプロジェクトの物であり、実際のゲームでは適切な内容、タイミングで名前の入力を行い、送信するスコアにはゲームのスコアを使用してください。

<RPGツクール>

適当にイベントを配置して、プレイヤーの名前を入力する処理と、乱数からスコアを取得する処理を作ります。


一番左のイベント内容


左から二番目のイベント内容


それから、パート①で作った一番右のイベントをもっときれいに整えていきます。
まずはエラー判別処理と応答待ち処理をコモンイベントにしてすっきりさせます。

エラー判別処理のコモンイベント
プラグインの仕様に合わせて、エラー文字列が変数に入ってきたらエラーフラグをONする処理です。


応答待ち処理のコモンイベント
ループの中で1フレームごとにエラーをチェックし、エラーがあった、または正常に応答があった場合ループを中断する処理です。
上記のエラーチェック処理も使っています。


そしてスコアを送信する処理。一番右のNPCのイベントです。
プラグインヘルプにあるようにユーザーIDとユーザーネームとスコアをサーバーへ送信します。
ユーザーIDはまだ実装しないので、とりあえず0としておきます。
これらはPOSTメソッドで送信されます。
また、受信データはjson形式の文字列で返ってくることになりますので、応答データ受信時にはその文字列をjson形式に変換する処理を入れています。


スコア送信処理はとりあえずここまで。この時点でイベント実行しても何も起こりませんよ。

<GODOT>

ランダムにスコアを取得するボタンと、プレイヤー名を入力するNODEを配置します。パート①で作った仮のプロジェクトからNODEの名前が変わっていることに注意してください。
ユーザーIDはまだ実装しないので、とりあえず0としておきます。


コードは下記のような感じ。
extends CanvasLayer

var user_id = 0
var user_name
var random_score

func _ready():
	randomize()
	pass 

func _on_LineEdit_text_changed(new_text):
	user_name = new_text
	pass
	
func _on_Button_RandScore_button_down():
	random_score = randi() % 10001
	$Button_RandScore/Label2.text = "Score:" + str(random_score)
	
func _on_Button_button_down():
	var headers = ["Content-Type: application/json"]
	var params = {"user_id":str(user_id),"user_name":user_name,"score":str(random_score)}
	var body = JSON.print(params)
	var error = $HTTPRequest_SendScore.request("http://ドメイン名/フォルダ名/ranking.php", headers, false, HTTPClient.METHOD_POST, body)
	if error != OK:
		push_error("An error occurred in the HTTP request")

func _on_HTTPRequest_SendScore_request_completed(result, response_code, headers, body):
	var res = body.get_string_from_utf8()
	res = JSON.parse(res).result
	print(res)

requestメソッドに引数が追加されています。詳しくは公式リファレンスをご確認ください。

・レコードを挿入するサーバープログラム

パート①で作った「ranking.php」を書き換えて、POSTメソッドによりゲームから送信されたデータをデータベースへ挿入していきます。
で、POSTされたデータの取り出し方なのですが、RPGツクールとGODOTで取り出し方が違います。これはPOSTでくっつけたデータの形式が違うからです。
RPGツクールの方は"application/x-www-form-urlencoded"という形式、GODOTは"application/json"という形式なのですが
RPGツクールの方を"application/json"にするにはjavascriptに自信がなく、GODOTの方を"application/x-www-form-urlencoded"にしようとしたらうまくいきませんでした・・・。
というわけで、ちょっと見にくくなっちゃって申し訳ないのですが、ツールごとに書き分けますので、必要な方を参考にしてください。

<RPGツクール>

下記のように書くことで、$user_idにユーザーID、$user_nameにユーザーネーム、$scoreにスコアを取得できます。
予期しない形式のデータが来た場合には、プラグインの仕様に合わせて"ERROR"が返るようにしています。
<?php
    // 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>

下記のように書くことで、$user_idにユーザーID、$user_nameにユーザーネーム、$scoreにスコアを取得できます。
<?php
    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;
    }
?>

<共通>

ここから共通です。変数へ取得した受信データをいよいよデータベースへ挿入していきます。
できるだけひとつずつ説明しますが、そうするとコードの提示が前後しちゃいます。
最後にコードをまとめて載せておきますので、ささっとコピペで済ませたい方は、そこまで流し読みしてください。
まず、データベースへ接続します。PHPでデータベースへ接続するやり方はいくつかあるようですが、PDOクラスというのを使うのが一般的の用です。
詳しく知りたい方は調べてみてくださいね。

PDOインスタンスを生成する処理、データベースへ接続する準備をするコードです。
    // DBに接続
    $user = 'ユーザー名';
    $pass = 'パスワード';
    $dsn = 'mysql:host=ホスト名;dbname=データベース名;';
    //インスタンスの生成
    $pdo = new PDO($dsn,$user,$pass);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

ここでいうユーザー名、ホスト名、データベース名は「MySQL設定」の画面の下記赤枠のところに書いてあるやつです。


ぶっちゃけこの辺私もよくわかっていないので説明は避けます。
ちゃんと知りたい方はPHPの公式リファレンス等をご確認ください。
この接続処理をtry-catchの中に書きます。前述の、POSTデータ取り込みの処理の下に追加してください。
    try{
        //インスタンスの生成
    	$user = 'ユーザー名';
    	$pass = 'パスワード';
    	$dsn = 'mysql:host=ホスト名;dbname=データベース名;';
        $pdo = new PDO($dsn,$user,$pass);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }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';
    }

次にデータベースへスコアを挿入する処理を書いていきます。下記のように関数をtry-catchの下に追加します。
    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();
    }

関数の呼び出しをtryの中へ追加します。
    try{
        //インスタンスの生成
    	$user = 'ユーザー名';
    	$pass = 'パスワード';
    	$dsn = 'mysql:host=ホスト名;dbname=データベース名;';
        $pdo = new PDO($dsn,$user,$pass);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        //受信したスコアをテーブルへ追加
        insertScore($pdo,$user_id,$user_name,$score);
    }catch (PDOException $e) {

ここでちょっと解説です。
"INSERT INTO ranking (user_id, user_name, score) VALUES (:user_id,:user_name,:score)"
これはSQL文といって、データベースを操作する指示を出すものです。
"ranking"はテーブル名であることに注意してください。
先ほど、データベースから直接レコードを挿入した際に、下記のような実行結果が表示された時に、似たような文があったことに気がついたかもしれません。


これを、下記のPHPコードを通して実行するわけです。
	$sql = "INSERT INTO ranking (user_id, user_name, score) VALUES (:user_id,:user_name,:score)";
        $stmt = $pdo->prepare($sql);
        //略
        $stmt->execute();	//実行
"INSERT INTO"はテーブルへデータを挿入することを表します。
ほかにも"SELECT"と"WHERE"でデータベース内の特定のレコードを取得したり、"UPDATE"で既存のレコードを更新したりできます。
ほかにも、さまざまなSQL文が存在します。プログラムに心得のある方ならいろいろな使い方に応用ができると思いますので、興味のある方は調べてみてください。
そしてテーブルのフィールド(user_id, user_name, score)として(:user_id,:user_name,:score)の値を入れてINSERTすることを意味します。
それで次
        $stmt->bindValue(':user_id', $user_id , PDO::PARAM_INT);
この一文は「プレースホルダーに値をバインドする」処理です。雑に言うと「’:user_id’は変数$user_idのことですよ~」ということを指示するもので
セキュリティリスクを下げるための記述だそうです。
ちゃんと知りたい方は「プレースホルダー」「バインド」「SQLインジェクション攻撃」等で検索してみてください。
"PDO::PARAM_INT"は型の指定です。これは省略してもPHPは変数の内容から勝手に判別してくれるそうですが、まあ、あった方が良いでしょう。
それから注意しておいてほしいことがあって、PHPは「"」(ダブルクォート)と「'」(シングルクォート)と「`」(バッククォート)に区別があります。 
私はそれに気づかなくて死ぬほどハマりました。それでCHAT GPTを使い始めて、その違いを知った次第です。
PHPでSQL文の書き方を解説しているサイトには、各クォートの使い方がくちゃくちゃになっているところもあったように思います。
(私がハマったのもたぶんそのせいです)
正しく説明できる自信がないので私からの説明は避けますが、今後ご自分でこのようなサーバープログラムを書きたいと思っている方は、ちゃんと調べて知っておくべきです。

これで、ゲームからスコアを送信してデータベースへ挿入する処理が出来ました。
ゲームを実行して、実際にスコアを送信してみましょう。
まだサーバーからデータを応答する処理を作っていないので、エラーになると思いますが
例えば下記のようにレコードが挿入されていたら、成功です!


・スコアボードを応答するサーバープログラム

次にスコアボードを応答するサーバープログラムを書いていきます。
ここまでできたのならそう難しくはないはず・・・。
ここでは、とりあえずデータベースのレコード全てを降順で取得し応答するようにします。
そのために、下記のようなSQL文を実行する関数を追加します。先ほど追加したinsetScoreの下に追加してくださいね。
    function getScoreBoard($pdo) {
        // スコア降順に並べ替えつつ全レコード取得
        $sql = "SELECT * FROM ranking ORDER BY score DESC";
        $stmt = $pdo->prepare($sql);    
        $stmt->execute();
        $result = $stmt->fetchall(PDO::FETCH_ASSOC);
        return $result;
    }

この関数の呼び出しを、レコードを挿入する関数の呼び出しの下に追加します。
    try{
        //インスタンスの生成
    	$user = 'ユーザー名';
    	$pass = 'パスワード';
    	$dsn = 'mysql:host=ホスト名;dbname=データベース名;';
        $pdo = new PDO($dsn,$user,$pass);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        //受信したスコアをテーブルへ追加
        insertScore($pdo,$user_id,$user_name,$score);
        // スコア降順に並べ替えつつ全レコード取得
        $result = getScoreBoard($pdo);
    }catch (PDOException $e) {

ちょっと解説。
"SELECT"はデータの取得を意味し"*"は全てのフィールドを取得することを意味します。
"*"の部分は、例えば"user_name,score"とすると、user_nameとscoreだけ取り出すことができます。
"ORDER BY score DESC"は、scoreを降順にして取り出すことを意味します。
"$result = $stmt->fetchall(PDO::FETCH_ASSOC);"はSELECTによって取り出したデータを連想配列として変数$resultに格納することを意味します。
データを連想配列にすると、簡単にjson形式に変換することができます。
連想配列とかjsonがわからないという方は、とりあえずまだわからなくて大丈夫ですので、真似してください。

次に、データベースとの接続を閉じる処理、応答データの形式を指定する処理、応答データをjsonに変換する処理、取得データを応答する処理を追加します。
try-catchの下に追加してくださいね。
    }catch (PDOException $e) {
        //略
    }
    //接続を閉じる(省略可)
    $pdo = null;
    //Content-Type: application/json
    header("Content-Type: application/json; charset=utf-8");
    //取得したデータをjsonに変換
    $response = json_encode($result);
    //応答
    echo $response;
"header("Content-Type: application/json; charset=utf-8")"は、応答するデータをjson形式とすることを意味します。
これを指定しないと応答データ全てがテキスト形式で返ることになります。
"$response = json_encode($result);"この行で、取得したデータをjson形式に変換しています。

これで、サーバーからスコアボードを応答する処理もできました。
間違いがなければ、ゲームからスコアを送信し、スコアボードを受信する様を確認できるはずです!
下記のように出力されれば成功です!
RPGツクールはF12を押してコンソールを開いてね。



うまくできたでしょうか?
これで、スコアを送信してスコアボードを取得する最低限の処理が出来ました。
ここまででもとりあえずゲームに組み込んで使えますが、レコードの保存件数上限を決めたり、同一人物からのデータが重複しないような処理を追加して行きます。

パート③までちょっと待ってね。

最後に、今回書いたサーバープログラムを全部載せときます。
<?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);
        //受信したスコアをテーブルへ追加
        insertScore($pdo,$user_id,$user_name,$score);
        // スコア降順に並べ替えつつ全レコード取得
        $result = getScoreBoard($pdo);
    }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 ORDER BY score DESC";
        $stmt = $pdo->prepare($sql);    
        $stmt->execute();
        $result = $stmt->fetchall(PDO::FETCH_ASSOC);
        return $result;
    }
?>

コメント