対戦型ポケモンWordle「VS Pokémon Wordle」を作ってみた


# アプリの更新履歴

<details><summary>更新履歴</summary>

__■2022/12/09__
第9世代(SV)のポケモンを追加

__■2022/03/21__
初回起動時に設定画面からユーザー名を登録、その値が保存されるように修正
部屋の作成と入室の処理を分離
部屋作成時に自動でランダムな4桁の部屋コードが反映される機能を追加
制限時間を変更できるように修正
ハンディキャップとして、プレイヤーごとに制限時間を設定できる機能を追加
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/2d12bc67-0c38-2025-5ca2-3b2402dbe365.gif">　　 

__■2022/03/03__
制限時間を追加（制限時間が切れると無回答で相手のターンに）
<img width="225" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/2253884f-0774-7e73-5d9d-728a29b52336.gif">　　 
__■2022/02/20__
「フリック入力 + 入力履歴」の機能を追加
フリック入力の実装についての記事は[こちら](https://qiita.com/YottyPG/items/2ef19f386871ee26216f)。
<img width="225" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/15ce018f-ab0c-7946-554e-c5dc110018bd.gif">　　 <img width="330" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/e055aeb3-6244-6fbd-671e-d751568fe2f5.png">
__■2022/02/15__
正誤判定の方法を修正
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/a5f55d83-215e-aaf9-cc03-9ae4c6245c46.png">
__■2022/02/14__
設定画面からハイコントラストモードを選べる機能を追加

__■2022/02/12__
リリース

</details>

# はじめに

最近話題の5文字の英単語を当てるゲーム「[Wordle](https://www.nytimes.com/games/wordle/index.html)」。
それをポケモンの名前で遊べるようにアレンジした「[ポケモンWordle](https://wordle.mega-yadoran.jp/)」。

シンプルだけど意外と奥が深いこのゲームですが、「リアルタイムに友達と対戦できる機能があったらもっと面白そう」ということで作ってみました！


**完成品はこちら**

https://www.vs-pokemon-wordle.com/

![wordle.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/722a014e-6de9-5347-4e6c-de098c0928d3.gif)




ソースの全容は以下のリンクから確認できます。

https://github.com/Yotty0404/VS_Pokemon_Wordle



# プロローグ
先日、兄から「ポケモンWordleで対戦しよう。LINEで。」というメッセージがきました。
さっそく以下のような形で実際に対戦して遊んでみました。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/78cea5c5-1c02-2eb6-cf04-bf1aee362c98.png">
子どもの頃よくやっていた数字当てゲームと今流行りのWordleを掛け合わせたような遊びで、たしかに面白いなと思いました。
ただ、事前に決めたポケモンを記憶しておいて、いちいち相手の予想が当たっているかの判定を自分で作るのがめんどくさいです。
そこで、対戦用のアプリを自分で作ってしまおうということでアプリの開発を始めました。

ちなみに兄の答えは「ハリーセン」でした。そんなマイナーどころ出てこんわ！




# 実装
flaskを利用してPythonで開発し、herokuにデプロイしています。
また、リアルタイムな対戦ゲームとするためには、双方向通信が必要となります。
双方向通信部分は「Flask-SocketIO」というライブラリを使って実現しました。

「Flask-SocketIO」の基本的な使い方については以下の2つ記事が大変参考になりました。
[FlaskでWebSocketを使う](https://kivantium.hateblo.jp/entry/2021/10/18/110509)
[How to use Socket.IO with Flask/Heroku](https://medium.com/@rohanjdev/how-to-use-socket-io-with-flask-heroku-af9909e2e9a4)

ポケモンのデータは、こちらからお借りしました。
[8世代対応全ポケモンの.jsonファイルを作ってみた](https://qiita.com/dayu_282_/items/f03edc0f4266c5d67d69)

<br>

この記事では、その他ポイントをかいつまんで紹介していきたいと思います。

## 部屋の実装

事前準備として、以下を用意しておきます。
ゲームの情報を管理するclass「GameInfo」
部屋ごとにゲームの情報を管理するdictionary「d_info」
部屋ごとに人数を管理するdictionary「d_user_count」

```py:python
class GameInfo:
    def __init__(self):
        self.p1_id = ""
        self.p1_user_name = ""
        self.p1_poke_name = ""
        self.p2_id = ""
        self.p2_user_name = ""
        self.p2_poke_name = ""
        self.is_in_game = False
        self.correct = [0, 0]

d_info = defaultdict(GameInfo)

#部屋ごとの人数を保持
d_user_count = defaultdict(int)
```

上述した記事を参考に、入力した値をほかのユーザの画面に表示させることはできました。
ただ、2人ずつ並行して対戦できるようにしたいので、特定のユーザにのみ値を送信したいです。
そこで、「Flask-SocketIO」の「[Rooms](https://flask-socketio.readthedocs.io/en/latest/getting_started.html#rooms)」という仕組みを活用します。

`join_room(room_code)`で部屋に入れさせて、
`emit('hogehoge', broadcast=True, to=room_code)`で同じ部屋の人にのみ送信することができます。

**■JOINボタンクリック時の処理**
```javascript:javascript
$(document).on('click', '#btn_join', function () {
    var temp_user_name = $('#txt_user_name').val();
    var temp_room_code = $('#txt_room_code').val();

    socket.emit('join', { user_name: temp_user_name, room_code: temp_room_code });
});
```

```py:python
@socketio.on('join')
def join(json):
    global d_user_count, d_info
    room_code = json["room_code"]
    user_name = json["user_name"]

    #満室だった場合
    if d_user_count[room_code] >= 2:
        emit('full_error')
        return

    join_room(room_code)
    d_user_count[room_code] += 1

    temp_info = d_info[room_code]

    if d_user_count[room_code] == 1:
        temp_info.p1_id = request.sid
        temp_info.p1_user_name = user_name

    elif d_user_count[room_code] == 2:
        temp_info.p2_id = request.sid
        temp_info.p2_user_name = user_name

    emit('update_info_join', {'room_code': room_code
                             ,'p1_user_name': temp_info.p1_user_name
                             ,'p2_user_name': temp_info.p2_user_name
                             ,'p1_id': temp_info.p1_id
                             ,'p2_id': temp_info.p2_id}, broadcast=True, to=room_code)

    d_info[room_code] = temp_info
```

```javascript:javascript
var room_code = '';
var p1_id = '';
var p2_id = '';

socket.on('update_info_join', function (data) {
    room_code = data.room_code;
    p1_id = data.p1_id;
    p2_id = data.p2_id;

    $('#room_info').text('部屋コード：' + room_code);
    $('#player1_name').text(data.p1_user_name);
    $('#player2_name').text(data.p2_user_name);
});
```

**■ENTERボタンクリック時の処理**

```javascript:javascript
$(document).on('click', '#btn', function () {
    var poke_name = $('#txt_poke_name').val();

   //プレイヤー判断
   if (p1_id == socket.id) {
       update_row(poke_name, $('#answer_l'));
       socket.emit('btn_click', { room_code: room_code, is_p1: true, poke_name: poke_name });
   }
   else if (p2_id == socket.id) {
       update_row(poke_name, $('#answer_r'));
       socket.emit('btn_click', { room_code: room_code, is_p1: false, poke_name: poke_name });
   }
});
```

```py:python
@socketio.on('btn_click')
def btn_click(json):
    global d_info
    room_code = json["room_code"]
    is_p1 = json["is_p1"]
    poke_name = json["poke_name"]

    temp_info = d_info[room_code]
    
    if is_p1:
        temp_info.p1_poke_name = poke_name
        emit('update_answer', {'is_p1': is_p1 }, broadcast=True, include_self=False, to=room_code)
    else:
        temp_info.p2_poke_name = poke_name
        emit('update_answer', {'is_p1': is_p1 }, broadcast=True, include_self=False, to=room_code)

    d_info[room_code] = temp_info
```

```javascript:javascript
socket.on('update_answer', function (data) {
    //相手の正解を「?????」に更新
    if (data.is_p1) {
        update_row('?????', $('#answer_l'));
    }
    else {
        update_row('?????', $('#answer_r'));
    }
});
```

## 部屋コードの共有
部屋に入室後、赤枠のアイコンからTwitterかLINEで部屋コードを共有できるようにしています。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/b8614e71-694b-1cfa-4200-2d06a8b7addd.png">

ただ、単に部屋コードを共有するだけだと、部屋コードをコピペするなり手入力するなりひと手間かかります。
そこで、URLパラメータを利用して、共有されたURLから飛んできた場合は部屋コードが入力された状態で立ち上がるようにしています。

```py:python
@app.route("/", methods=["GET"])
def get_user():
    room_code = ''

    try:
        req = request.args
        room_code = req.get("room_code")
    except:
        return render_template('home.html')

    if room_code != '':
        return render_template('home.html', room_code = room_code)

    else:
        return render_template('home.html')
```

```html:html
<input id="txt_room_code" value={{ room_code }}>
```



## 対戦相手の退室

Webアプリなので、対戦相手がブラウザを閉じて終了する場合があります。
対戦中に相手がぬけたことに気づかず、ただ相手を待つような状態は時間の無駄になってしまいます。
そこで、相手が抜けたことを検知して通知してあげるようにします。
![exit.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581889/3ace031c-a7ac-d046-5059-55a807e351ff.gif)

```py:python
@socketio.on('disconnect')
def disconnect():
    global d_user_count, d_info

    room_code = ''
    #強引にroom_codeを取得
    for s in rooms():
        if len(s) < 5:
            room_code = s

    emit('opponent_exit', broadcast=True, to=room_code)

    close_room(room_code)
    d_user_count[room_code] = 0
    d_info[room_code] = GameInfo()
```

```javascript:javascript
socket.on('opponent_exit', function (data) {
    $('#opponent_exit_container').removeClass('transparent');
});
```

`rooms()`で抜けた人のSessionIDと部屋コードをlist型で取得することができます。
ただ、`[SessionID, 部屋コード]`の形だったり`[部屋コード, SessionID]`の形だったり、どちらに部屋コードが入っているのか決まってなさそうだったので、少し強引な方法で取得しています。

# おわりに
双方向通信が必要ということで「WebSocket」に初めて触れましたが、実装にかなり苦労しました。
それ以外は大きなつまりもなく、1週間ほどで形にすることができました。
これまでやってきたことが活きる部分が多くあり、改めて自分の成長を感じました。
やっぱり何か形にしてアウトプットすることは重要ですね。

完成後、兄にこのアプリを紹介して一緒に遊んでみたらかなり好評でした。
みなさんもよかったら家族や友人たちと一緒に遊んでみてください！

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">「VS Pokémon Wordle」を作ってみました！<br>リアルタイムに相手のポケモンを予想して当てる「Wordle」風 対戦ゲームです。<br>部屋コードを共有することで対戦することができます。<br><br>バグや要望などあればDMにてご連絡ください。<a href="https://t.co/9tm6Wp8EoC">https://t.co/9tm6Wp8EoC</a> <a href="https://twitter.com/hashtag/VsPokemonWordle?src=hash&amp;ref_src=twsrc%5Etfw">#VsPokemonWordle</a></p>&mdash; よってぃ@プログラミング垢 (@YottyPG) <a href="https://twitter.com/YottyPG/status/1492453444182941705?ref_src=twsrc%5Etfw">February 12, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


# 参考
[Flask-SocketIO  documentation](https://flask-socketio.readthedocs.io/en/latest/index.html)
[FlaskでWebSocketを使う](https://kivantium.hateblo.jp/entry/2021/10/18/110509)
[How to use Socket.IO with Flask/Heroku](https://medium.com/@rohanjdev/how-to-use-socket-io-with-flask-heroku-af9909e2e9a4)
[お名前.comで購入したドメインをHerokuに設定する](https://qiita.com/ozin/items/62bc7ef1dd3c827177fb)
[8世代対応全ポケモンの.jsonファイルを作ってみた](https://qiita.com/dayu_282_/items/f03edc0f4266c5d67d69)