PythonとC++で雑に作るオンラインマルバツゲーム

この記事はCCS †裏† Advent Calendar 2018

adventar.org

の5日目の記事です。前日のとっちーの記事はこちら yoooomaruuuu.hatenablog.com

どうも千葉大電子計算機研究会(以下CCS)、老害㌠いかろちゃんです。おっさんではありません。

はじめに

現代のゲームはいわゆるソシャゲをはじめとして非常に多くのゲームがインターネットに接続されています。しかし、いざ個人でそういった開発しようと思っても情報が豊富にある状況とはいえません。そこで本記事ではオンラインマルバツゲームを題材に実際に動くサンプルを提供することで、インターネットを介したゲームをつくるヒントを提供できればと思います。とおもったが、途中でやる気が消失したので読む人はがんばってよみといてくだせぇ...

概要

通信する方法は複数考えられます。 速度を考えなくて良い場合ではプロトコルとしてHTTPを使用するのが良い選択です。 実際に多くのソシャゲでHTTPが利用されています*1。 HTTPというのはHypertext Transfer Protocolの略です。 もともとはwebサイトのhtmlなどのファイルをwebサーバ-ブラウザ間でやりとりするために考えられました。 しかし、HTTPプロトコルはどのようにデータをやりとりするかを規定しているだけです。 そのためhtml以外のデータをサーバ-クライアントとやりとりするために使っても問題ありません。 多くのソシャゲではサーバとクライアントでJSONという形式でやり取りすることで通信を成立させます。 クライアント-クライアント間の通信はサーバを仲介することによって実現します。

つまり、作る必要があるものとしては - HTTPプロトコルをつかってJSONをサーバとやりとりできるクライアント - HTTPプロトコルを使ってクライアントとJSONをやりとりできるWebアプリケーション があります。 ライブラリとしてHTTPプロトコルライブラリ及びJSONを扱えるライブラリがあればよさそうですね。 クライアント側のライブラリ、つまりC++のライブラリ、としてはcpprestsdkがあります。 cpprestsdkはMicrosoftが出しているクロスプラットフォームなHTTPライブラリでJSONも扱えます。 サーバぐぁのライブラリ、つまりPythonのライブラリ、としては今回はFlaskを利用します。

また、当然Webアプリケーションを実行するためのサーバ環境(=インフラ)も必要です。 こちらも時間があれば書きます。

APIの仕様

次はAPIの仕様を決定します。 書くのだるくなってきた... ソースコードみて察してください

ソースコード

めんどくさくなってきて途中まで...裏だしいいよね。 後ろに改善点等書きます。 また、勝敗判定の実装は読者への課題とします。 ユーザ登録もバグってる気がするのでDBに手動で書き込むかなおすかしてあげてください。

サーバ側

import json
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)


class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True)
    victoryCount = db.Column(db.Integer, nullable=False)
    defeatCount = db.Column(db.Integer, nullable=False)

    def __init__(self, username):
        self.username = username
        self.victoryCount = 0
        self.defeatCount = 0

    def __repr__(self):
        return '<User %r>' % self.username


class Room(db.Model):
    __tablename__ = 'rooms'
    id = db.Column(db.Integer, primary_key=True)
    board = db.Column(db.Text)
    player1 = db.Column(db.Integer, db.ForeignKey('users.id'))
    player2 = db.Column(db.Integer, db.ForeignKey('users.id'))
    nowPlayer = db.Column(db.Integer)
    # 0 : waiting user, 1: battle, 2 end
    nowState = db.Column(db.Integer)
    player1_relationship = db.relationship("User", foreign_keys=[player1])
    player2_relationship = db.relationship("User", foreign_keys=[player2])

    def __init__(self, player1):
        self.board = '[[0,0,0],[0,0,0],[0,0,0]]'
        self.nowState = 0
        self.player1 = player1

    def __repr__(self):
        return '<Room %r>' % self.id


# ホントはGETも必要だけど...
@app.route("/api/v1.0/user", methods=['POST'])
def get_user():
    req_data = request.get_json()
    user = db.session.query(User).filter_by(username=req_data["user_name"]).first()
    if user is None:
        user = User(name)
        db.session.add(user)
        db.session.commit()
        result = {'result': {'user_id': int(user.id), 'user_name': user.username}}
        return jsonify(result)
    else:
        result = {'result': {'user_id': int(user.id), 'user_name': user.username}}
        return jsonify(result)


# POST(player_id)
# GET(room_id)
@app.route("/api/v1.0/lobby", methods=['GET', 'POST'])
def get_lobby():
    req_data = request.get_json()
    if request.method == 'GET':
        room_id = request.args.get("room_id", type=int)
        room = db.session.query(Room).filter_by(id=room_id).first()
        result = {'result': {'room_id': int(room.id), 'state': room.nowState}}
        return jsonify(result)
    elif request.method == 'POST':
        # 空きの検索
        room = db.session.query(Room).filter_by(nowState=0).first()
        if room is None:
            room = Room(req_data['player_id'])
            db.session.add(room)
            db.session.commit()
            result = {'result': {'room_id': int(room.id), 'state': room.nowState}}
            return jsonify(result)
        else:
            room.nowState = 1
            room.player2 = req_data['player_id']
            room.nowPlayer = 1
            db.session.add(room)
            db.session.commit()
            result = {'result': {'room_id': int(room.id), 'state': room.nowState}}
        return jsonify(result)


# POST(location_x,location_y,player_id)
@app.route("/api/v1.0/board/<room_id>", methods=['GET', 'POST'])
def get_board(room_id):
    room = db.session.query(Room).filter_by(id=int(room_id)).first()
    if request.method == 'GET':
        if room.nowPlayer == 1:
            now_player = room.player1
        elif room.nowPlayer == 2:
            now_player = room.player2
        result = {'result': {'room_id': int(room_id), 'now_player_id': now_player, 'board': json.loads(room.board)}}
        return jsonify(result)
    elif request.method == 'POST':

        req_data = request.get_json()

        if room.nowPlayer == 1:
            now_player = room.player1
        elif room.nowPlayer == 2:
            now_player = room.player2

        if req_data['player_id'] != now_player:
            result = {'status': "bad request"}
            # ステータスコードをかえしたい...
            return jsonify(result)

        board = json.loads(room.board)
        if board[req_data["location_y"]][req_data["location_x"]] == 0:
            board[req_data["location_y"]][req_data["location_x"]] = room.nowPlayer
            room.board = json.dumps(board)
            if room.nowPlayer == 1:
                room.nowPlayer = 2
            elif room.nowPlayer == 2:
                room.nowPlayer = 1
            db.session.add(room)
            db.session.commit()
            result = {'result': {'room_id': int(room_id), 'now_player_id': now_player, 'board': json.loads(room.board)}}
            return jsonify(result)
        else:
            result = {'result': "bad"}

            return jsonify(result)


@app.route("/api/v1.0/battle_status/<room_id>", methods=['GET'])
def get_battle_status(room_id):
    room = db.session.query(Room).filter_by(id=int(room_id)).first()
    result = {'result': {'now_user_id': room.nowPlayer, 'now_state': room.nowState, 'room_id': int(room_id)}}
    return jsonify(result)


if __name__ == "__main__":
    app.run()

requirements.txt

Click==7.0
Flask==1.0.2
Flask-SQLAlchemy==2.3.2
itsdangerous==1.1.0
Jinja2==2.10
MarkupSafe==1.1.0
SQLAlchemy==1.2.14
Werkzeug==0.14.1

クライアント側

#include <cpprest/http_client.h>
#include <cpprest/filestream.h>
#include<iostream>
#include<string>

using namespace utility;                    // Common utilities like string conversions
using namespace web;                        // Common features like URIs.
using namespace web::http;                  // Common HTTP functionality
using namespace web::http::client;          // HTTP client features
using namespace concurrency::streams;       // Asynchronous streams

int main(int argc, char* argv[])
{


    //ユーザ認証・登録処理
    std::string user_name;
    int user_id;
    std::cout << "user IDを入力してください" << std::endl;
    std::cin >> user_name;

    {
        json::value PostData;
        PostData["user_name"] = json::value::string(user_name);
        http_client client(("http://127.0.0.1:5000/api/v1.0/user"));


        auto post = client.request(methods::POST,"",  PostData.serialize(), "application/json").then(
                [&user_id](http_response response)
                {
                    if (response.status_code() == status_codes::OK){
                        return response.extract_json();
                    }
                }).then([&user_id](json::value json)
                {
                    user_id =  json["result"].as_object()["user_id"].as_integer() ;
                }
                );
        post.wait();
    }
    std::cout << "user id" << user_id << std::endl;
    int player_id = user_id;

    // ロビー処理
    int lobby_state =0;
    int room_id = 0;

    {
        json::value PostData;
        PostData["player_id"] = json::value::number(player_id);
        http_client client(("http://127.0.0.1:5000/api/v1.0/lobby"));


        auto lobby_post = client.request(methods::POST,"",  PostData.serialize(), "application/json").then(
                [&lobby_state, &room_id](http_response response)
                {
                    if (response.status_code() == status_codes::OK){
                        return response.extract_json();
                    }
                }).then([&lobby_state,&room_id](json::value json)
                {
                    room_id =  json["result"].as_object()["room_id"].as_integer() ;
                    lobby_state = json["result"].as_object()["state"].as_integer() ;
                }
                );
        lobby_post.wait();
    }

    std::cout << lobby_state << std::endl;


    std::cout << "対戦相手の選別中..." << std::endl;
    while(lobby_state == 0){
        http_client client(("http://127.0.0.1:5000/api/v1.0/lobby"));
        uri_builder builder;
        builder.append_query(U("room_id"), U(std::to_string(room_id)));

        auto lobby_get = client.request(methods::GET,builder.to_string()).then(
                [&lobby_state](http_response response)
                {
                    if (response.status_code() == status_codes::OK){
                        return response.extract_json();
                    }
                }).then([&lobby_state](json::value json)
                {

                    lobby_state = json["result"].as_object()["state"].as_integer() ;
                }
                );

        lobby_get.wait();
    }

    // ゲーム本体

    while(1){
        int now_player = -1;
        while(now_player != player_id){
            http_client client(("http://127.0.0.1:5000/api/v1.0/board"));
            uri_builder builder(U(std::string("/")+std::to_string(room_id)));

            auto board_get = client.request(methods::GET,builder.to_string()).then(
                    [&now_player](http_response response)
                    {
                        if (response.status_code() == status_codes::OK){
                            return response.extract_json();
                        }
                    }).then([&now_player](json::value json)
                    {

                        now_player = json["result"].as_object()["now_player_id"].as_integer() ;
                    }
                    );

            board_get.wait();
        }


        {
            http_client client(("http://127.0.0.1:5000/api/v1.0/board"));
            uri_builder builder(U(std::string("/")+std::to_string(room_id)));

            auto board_get = client.request(methods::GET,builder.to_string()).then(
                    [&now_player](http_response response)
                    {
                        if (response.status_code() == status_codes::OK){
                            return response.extract_json();
                        }
                    }).then([&now_player](json::value json)
                    {

                        std::cout << "現在の盤面" << std::endl;
                        for(auto col :json["result"].as_object()["board"].as_array()){
                            for(auto val:col.as_array()){
                                std::cout << val;
                            }
                            std::cout << std::endl;
                        }
                    }
                    );

            board_get.wait();
        }

        int x,y;
        std::cout << "石を置く位置を選択してください" << std::endl;
        std::cin >> x >> y;

        {
            json::value PostData;
            PostData["player_id"] = json::value::number(player_id);
            PostData["location_x"] = json::value::number(x);
            PostData["location_y"] = json::value::number(y);


            http_client client(("http://127.0.0.1:5000/api/v1.0/board"));
            uri_builder builder(U(std::string("/")+std::to_string(room_id)));

            auto board_get = client.request(methods::POST,builder.to_string(),  PostData.serialize(), "application/json").then(
                    [](http_response response)
                    {
                        if (response.status_code() == status_codes::OK){
                            return response.extract_json();
                        }
                    }).then([](json::value json)
                    {
                        std::cout << "現在の盤面" << std::endl;
                        for(auto col :json["result"].as_object()["board"].as_array()){
                            for(auto val:col.as_array()){
                                std::cout << val;
                            }
                            std::cout << std::endl;
                        }
                    }
                    );

            board_get.wait();
        }


    }
    return 0;
}

追加要素

エラーハンドリングをまったくと言っていいほどしていません。 悪いプログラムの見本です。実際には接続切れ、想定しない結果が返ってきたなどに備える必要があります。 ちゃんとしましょう。

また、データは暗号化しないとチートされる確率があがります。実際にはちゃんと暗号化しましょう。暗号化アルゴリズムとしてはAESを使うという記事をよく見かけます。 Roomの情報をDBで持ってますが、こういう情報はRedis等のKVSにもたせたほうがbetterだと思います。

今回は雑にGETとPOSTをつかう、ステータスコードも200しか返してませんが実際にはもっとちゃんとします。RestfulAPIとかGraphQLとかで調べてみるといいでしょう。

GUIを使う際はローディング画面なり出すと思います。その時はクライアント側はwaitじゃなくて非同期にできるものもあるのでそちらを利用しましょう。

また、すごい勢いでgetしてますが普通こういうデータはWebSocketとかつかってサーバ側からpushするようにしたほうがいいんじゃないかな。

おわりに

ほんとはもっとちゃんと書くつもりだったけど途中でやる気が無くなってしまいました。 気が向いたらなおすかもしれません。 会員の皆さんは本記事を参考に(反面教師に?)ソシャゲをつくってみましょう!

明日はkamewo氏のマイクラ話です。

*1:ただし、HTTPはセキュアではないのでSSL/TLSプロトコル上でHTTPを運用するHTTPSを使うのが普通です。