FlaskとElectronで麻雀リアルタイムシミュレーターを作っていく記録③

[←前回の記事](https://qiita.com/mshok0511/items/71005bb003b1d46fcc1f)
<!--
[次回の記事→]()
-->

# 前置き
前回の記事の続きです。ご覧になっていない方は記録①、②の方からぜひ見ていってください。

麻雀リアルタイムシミュレーター.V1をFlaskとElectronで作った記録になっています。
前回の記事では、アプリを作る上で3つの壁があり、前回、前々回で第１・第２関門を突破した記事を投稿しました。

今回は第３関門であった「何切るシミュレーターとの連携」について記述します。

# 第３関門「何切るシミュレーターとの連携」
今回の制作にあたって最大の難所であった第３関門。
本題に入る前に、「何切るシミュレーター」について説明します。

## 何切るシミュレーターとは
何切るシミュレーターとは、こちらの[麻雀何切るシミュレーター version 0.9.0](https://pystyle.info/apps/mahjong-nanikiru-simulator/)というサイトで行える麻雀シミュレーションです。

麻雀には「何切る問題」という、山や河から牌をツモり、手牌が14枚になったときどの牌を切るべきかという問題があります。

このサイトは手持ち牌や、鳴き牌、和了考慮などを自由に設定することで、和了率や期待値などを計算してくれるとても便利なサイトとなっております。

サイト内のURLから計算プログラムが公開されているGitHubへ行くことができるので、こちらの計算プログラムをGit Cloneしてお借りしようと考えました。

# 本題
この何切るシミュレーターの計算プログラムをお借りする、口で言えば正直簡単ではありますね。

**では何が難所なのか？**
この計算プログラム、なんとC++言語で記述されていました。
忘れてはいけません、私が作った麻雀リアルタイムシミュレーターのバックエンドで動いている言語はPythonです。
インタプリタ言語のPythonからコンパイラ言語のC++を呼び出す...。考えただけで頭が痛くなりそうでした。

しかし、正直「Pythonならぶっちゃけどうにでもなるでしょ」っと思っていたので、いろいろ調べてみました。

## PythonからC++プログラムを呼び出すには？
どうやら、「PyBind11」や「Boost.Python」といったライブラリでＣ++側にラッパーを作る必要があるんだとか...。

**私Ｃ++言語まったくやったことないんですよね...！**

自分で調べて作ればいいのでは？と思ったのでプログラミング学習サイト「Paiza」でC++を少し勉強してみて、ラッパーの作り方について色々調べてみましたが、できないことはないが完成するのに時間がかかりそう...というのが私の結論でした。

では、どうすればいいか。悩みに悩んだ結果、一つの答えにたどり着きました。

## 麻雀何切るシミュレーター作者本人に聞く

もうまんまです。作った本人に直接問い合わせるのが一番手っ取り早いと考え、何切るシミュレーターサイトにあるブログ記事のURLへ行き、ブログの質問として、相談のメッセージを送ってみました。

![スクリーンショット 2024-10-29 235436.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3917265/64ad3835-64ca-07cf-09c7-dc022540a51d.png)
正直、望みは薄いと思っており、返事がなかったら本気でC++を勉強してラッパーを作ろうと思っていました。

## 返事がきました
なんと驚くことに、メッセージを送って６時間後に返事が届き、**さらにサンプルプログラムまで作ってGitHubに公開までしていただきました！！！**

本当にびっくりしました。感謝しかなかったです。

返信の内容としては

「pythonから直接C++のプログラムを呼び出すインターフェースは用意してないけど、HTTPリクエストでJsonデータでやり取りするインターフェースは既にあるのでそちらでよければ使ってみてくださ～い(*^^)v」

とのこと。

**いや仏過ぎん？**
sampleプログラムに加えてビルド済みのバイナリファイル(exe)まで作ってもらっちゃいました。お忙しい中本当にありがとうございますm(_ _ )m

## pythonからHTTPリクエストして計算結果を取得する
っというわけで、pythonからC++を呼び出すのではなく、HTTPリクエストでやろうと思います。
こちらの[GitHub](https://github.com/nekobean/mahjong-cpp/tree/master/python_sample)からGit Cloneしていただき、app.pyファイルと同階層にmahjong.pyという名前のファイルを置いてください。

そしたら次に、mahjong-cpp.zipを解凍してください。

### ソースコード

かなり長いソースコードを追記します。といっても、sample.pyにある関数をまんま持ってくるものもあるので、そこまで難しくないです。
htmlファイルとjsファイルにも追記します。

```app.py
from flask import Flask, render_template
from flask_socketio import SocketIO, emit
import numpy as np
import cv2
import os
import mss
import pygetwindow as gw
from pathlib import Path
import base64
import asyncio

import json
from pprint import pprint

import requests

#====追記====#

from mahjong import *

#============#

#######################
        # 関数
#######################

# 手牌、自模牌、捨て牌、ドラ牌の画像の切り抜き関数
def cropMyHandImage(jantamaMainImage):
    height, width = jantamaMainImage.shape[:2]
    myHandLeft = int(width*203/1665)
    myHandRight = int(width*1255/1665)
    myHandTop = int(height*760/938)
    myHandBottom = int(height*870/938)
    tsumoLeft = int(width*1278/1665)
    tsumoRight = int(width*1359/1665)

    doraLeft = int(width*34/1665)
    doraRight = int(width*273/1665)
    doraTop = int(height*72/938)
    doraBottom = int(height*127/938)

    myHandImage = jantamaMainImage[myHandTop:myHandBottom, myHandLeft:myHandRight]
    myTsumoImage = jantamaMainImage[myHandTop:myHandBottom, tsumoLeft:tsumoRight]
    doraImage = jantamaMainImage[doraTop:doraBottom, doraLeft:doraRight]

    myHandImage = cv2.resize(myHandImage, dsize = (1068,131))
    myTsumoImage = cv2.resize(myTsumoImage, dsize = (81,131))

    return [myHandImage, myTsumoImage, doraImage]

# Base64エンコード化関数
def encode_image_to_base64(image):
    # 画像をJPEG形式でエンコード
    _, buffer = cv2.imencode('.jpg', image)
    # バイナリデータをBase64エンコード
    encoded_image = base64.b64encode(buffer).decode('utf-8')
    return encoded_image

# 手牌の切り分け関数
def divideMyHandImage(myHandImage):
    myHandImageList = []
    for i in range(2,1068,82):
        myHandImageList.append(myHandImage[:,i:i+81])
    return myHandImageList

# ドラ牌の切り分け関数
def divideDoraImage(doraImage):
    dora_w = 64
    doraImageList = []
    for i in range(4):
        doraImage_resize = cv2.resize(doraImage[:,i + dora_w * i:dora_w * (i + 1)], dsize = (81,131))
        doraImageList.append(doraImage_resize)
    return doraImageList

# テンプレートマッチング処理関数
def recogPaiImage(paiImage, paiListImage, threshold = 0.7):
    # 雀牌表画像のグレースケール化
    paiListImage_gray = cv2.cvtColor(paiListImage, cv2.COLOR_BGR2GRAY)
    
    # 識別する雀牌画像のグレースケール化
    paiImage_gray = cv2.cvtColor(paiImage, cv2.COLOR_BGR2GRAY)

    # キャプチャ画像に対して、テンプレート画像との類似度を算出する
    res = cv2.matchTemplate(paiListImage_gray, paiImage_gray, cv2.TM_CCOEFF_NORMED)

    # 類似度の高い部分を検出する
    loc_candidate = np.where(res >= threshold)

    if len(loc_candidate[0]) == 0:
        return None

    # マッチング座標の中で最頻値座標を求める
    mode = []
    for loc_it in loc_candidate:
        unique, freq = np.unique(loc_it, return_counts=True)
        mode.append(unique[np.argmax(freq)])

    # 座標を元に牌の種類を識別する
    paiList = (
        ('Manzu1','Manzu2','Manzu3','Manzu4','Manzu5','Manzu6','Manzu7','Manzu8','Manzu9'),
        ('Pinzu1','Pinzu2','Pinzu3','Pinzu4','Pinzu5','Pinzu6','Pinzu7','Pinzu8','Pinzu9'),
        ('Sozu1','Sozu2','Sozu3','Sozu4','Sozu5','Sozu6','Sozu7','Sozu8','Sozu9'),
        ('Ton','Nan','Sya','Pe','Haku','Hatu','Tyun')
    )
    listHeight, listWidth = paiListImage.shape[:2]
    paiKind = int((mode[0]+listHeight/8)/(listHeight/4))
    paiNum = int((mode[1]+listWidth/18)/(listWidth/9))
    return paiList[paiKind][paiNum]

#==============追記==============#

def calc_remaining_tiles(hand_tiles, dora_indicators, melded_blocks):
    # counts[0] ~ counts[34]: 各牌の残り枚数、counts[34] ~ counts[36]: 赤牌が残っているかどうか
    counts = [4 for _ in range(34)] + [1, 1, 1]
    meld_tiles = [tile for meld in melded_blocks for tile in meld["tiles"]]
    visible_tiles = hand_tiles + dora_indicators + meld_tiles

    for tile in visible_tiles:
        counts[tile] -= 1
        if tile == Tile.AkaManzu5:
            counts[Tile.Manzu5] -= 1
        elif tile == Tile.AkaPinzu5:
            counts[Tile.Pinzu5] -= 1
        elif tile == Tile.AkaSozu5:
            counts[Tile.Sozu5] -= 1

    return counts

def print_result(result):

    result_emit = []

    result_type = result["result_type"]  # 結果の種類
    syanten = result["syanten"]  # 向聴数
    time_us = result["time"]  # 計算時間 (マイクロ秒)

    # print(f"計算時間: {time_us / 1e6}秒")
    result_emit.append(f"向聴数: {syanten['syanten']}")
    result_emit.append(f" (通常手: {syanten['normal']}, 七対子手: {syanten['tiitoi']}, 国士無双手: {syanten['kokusi']})")
    result_emit.append(f"計算時間: {time_us / 1e6}秒")

    if result_type == 0:
        #
        # 手牌の枚数が13枚の場合、有効牌、期待値、和了確率、聴牌確率が得られる。
        #
        required_tiles = result["required_tiles"]  # 有効牌
        exp_values = result["exp_values"]  # 期待値 (1~17巡目)
        win_probs = result["win_probs"]  # 和了確率 (1~17巡目)
        tenpai_probs = result["tenpai_probs"]  # 聴牌確率 (1~17巡目)

        tiles = [f"{tile['tile']}: {tile['count']}枚" for tile in required_tiles]
        # print(f"  有効牌: {', '.join(tiles)}")
        result_emit.append(f"  有効牌: {', '.join(tiles)}")

        for turn, (exp, win_prop, tenpai_prop) in enumerate(
            zip(exp_values, win_probs, tenpai_probs), 1
        ):

            result_emit.append(f"  {turn}巡目 期待値: {exp:.0f}点, 和了確率: {win_prop:.1%}, 聴牌確率: {tenpai_prop:.1%}")

    elif result_type == 1:
        #
        # 手牌の枚数が14枚の場合、打牌候補ごとに有効牌、期待値、和了確率、聴牌確率が得られる。
        #
        for candidate in result["candidates"]:
            tile = candidate["tile"]  # 打牌候補
            syanten_down = candidate["syanten_down"]  # 向聴戻しとなる打牌かどうか
            required_tiles = candidate["required_tiles"]  # 有効牌
            exp_values = candidate["exp_values"]  # 期待値 (1~17巡目)
            win_probs = candidate["win_probs"]  # 和了確率 (1~17巡目)
            tenpai_probs = candidate["tenpai_probs"]  # 聴牌確率 (1~17巡目)

            result_emit.append(f"打牌候補: {Tile.Name[tile]} (向聴落とし: {syanten_down})")

            tiles = [f"{tile['tile']}: {tile['count']}枚" for tile in required_tiles]
            
            result_emit.append(f"  有効牌: {', '.join(tiles)}")

            for turn, (exp, win_prop, tenpai_prop) in enumerate(
                zip(exp_values, win_probs, tenpai_probs), 1
            ):
                result_emit.append(f"  {turn}巡目 期待値: {exp:.0f}点, 和了確率: {win_prop:.1%}, 聴牌確率: {tenpai_prop:.1%}")

    return result_emit

# 不要になったら消す
def create_sample_request1():
    ###########################
    # サンプル: 手牌が14枚で面前の場合
    # 例: 222567m34p33667s北
    ###########################
    # 手牌
    hand_tiles = [
        Tile.Manzu2,
        Tile.Manzu2,
        Tile.Manzu2,
        Tile.Manzu5,
        Tile.Manzu6,
        Tile.Manzu7,
        Tile.Pinzu3,
        Tile.Pinzu4,
        Tile.Sozu3,
        Tile.Sozu3,
        Tile.Sozu6,
        Tile.Sozu6,
        Tile.Sozu7,
        Tile.Pe,
    ]
    # 副露牌 (4個まで指定可能)
    melded_blocks = []

    # ドラ表示牌 (4枚まで指定可能)
    dora_indicators = [Tile.Ton]
    # 場風 (東: Tile.Ton, 南: Tile.Nan, 西: Tile.Sya, 北: Tile.Pe)
    bakaze = Tile.Ton
    # 自風 (東: Tile.Ton, 南: Tile.Nan, 西: Tile.Sya, 北: Tile.Pe)
    zikaze = Tile.Ton
    # 計算する向聴数の種類 (通常手: SyantenType.Normal, 七対子手: SyantenType.Tiitoi, 国士無双手: SyantenType.Kokusi)
    syanten_type = SyantenType.Normal
    # 現在の巡目 (1~17巡目の間で指定可能)
    turn = 3
    # 場に見えていない牌の枚数を計算する。
    counts = calc_remaining_tiles(hand_tiles, dora_indicators, melded_blocks)
    # その他、手牌とドラ表示牌以外に場に見えている牌がある場合、それらを引いておけば、山にないものとして計算できる。

    # 期待値を計算する際の設定 (有効にする設定を指定)
    exp_option = (
        ExpOption.CalcSyantenDown  # 向聴落とし考慮
        | ExpOption.CalcTegawari  # 手変わり考慮
        | ExpOption.CalcDoubleReach  # ダブル立直考慮
        | ExpOption.CalcIppatu  # 一発考慮
        | ExpOption.CalcHaiteitumo  # 海底撈月考慮
        | ExpOption.CalcUradora  # 裏ドラ考慮
        | ExpOption.CalcAkaTileTumo  # 赤牌自摸考慮
    )

    # リクエストデータを作成する。
    req_data = {
        "version": "0.9.0",
        "zikaze": bakaze,
        "bakaze": zikaze,
        "turn": turn,
        "syanten_type": syanten_type,
        "dora_indicators": dora_indicators,
        "flag": exp_option,
        "hand_tiles": hand_tiles,
        "melded_blocks": melded_blocks,
        "counts": counts,
    }

    return req_data

def remakeDataForJson(li):
    remakeData = []
    pai_dict = {
        'Manzu1':Tile.Manzu1,'Manzu2':Tile.Manzu2,'Manzu3':Tile.Manzu3,'Manzu4':Tile.Manzu4,'Manzu5':Tile.Manzu5,'Manzu6':Tile.Manzu6,'Manzu7':Tile.Manzu7,'Manzu8':Tile.Manzu8,'Manzu9':Tile.Manzu9,'AkaManzu5':Tile.AkaManzu5,
        'Pinzu1':Tile.Pinzu1,'Pinzu2':Tile.Pinzu2,'Pinzu3':Tile.Pinzu3,'Pinzu4':Tile.Pinzu4,'Pinzu5':Tile.Pinzu5,'Pinzu6':Tile.Pinzu6,'Pinzu7':Tile.Pinzu7,'Pinzu8':Tile.Pinzu8,'Pinzu9':Tile.Pinzu9,'AkaPinzu5':Tile.AkaPinzu5,
        'Sozu1':Tile.Sozu1,'Sozu2':Tile.Sozu2,'Sozu3':Tile.Sozu3,'Sozu4':Tile.Sozu4,'Sozu5':Tile.Sozu5,'Sozu6':Tile.Sozu6,'Sozu7':Tile.Sozu7,'Sozu8':Tile.Sozu8,'Sozu9':Tile.Sozu9,'AkaSozu5':Tile.AkaSozu5,
        'Ton':Tile.Ton,'Nan':Tile.Nan,'Sya':Tile.Sya,'Pe':Tile.Pe,'Haku':Tile.Haku,'Hatu':Tile.Hatu,'Tyun':Tile.Tyun
    }

    for i in li:
        remakeData.append(pai_dict[i])

    return remakeData

async def async_score_clac(doraList,tehai):
    emit('error_calc', {'error_calc': ""})
    ########################################
    # 計算実行
    ########################################

    doraData = [item for item in doraList if item != "Unknown"]
    dora_indicators = remakeDataForJson(doraData)

    hand_tiles = remakeDataForJson(tehai)

    melded_blocks = []

    counts = calc_remaining_tiles(hand_tiles, dora_indicators, melded_blocks)

    req_data = {
        "version": "0.9.0",
        "zikaze": Tile.Ton,
        "bakaze": Tile.Ton,
        "turn": turn,
        "syanten_type": syanten_Type,
        "dora_indicators": dora_indicators,
        "flag": consideration,
        "hand_tiles": hand_tiles,
        "melded_blocks": melded_blocks,
        "counts": counts,
    }
    
    # サンプルリクエストでする場合はこっち
    # req_data = create_sample_request1()

    # dict -> json
    payload = json.dumps(req_data)
    # リクエストを送信する。
    res = requests.post(
        "http://localhost:8888", payload, headers={"Content-Type": "application/json"}
    )
    res_data = res.json()

    ########################################
    # 結果出力
    ########################################
    if not res_data["success"]:
        emit('error', {'error': f"計算の実行に失敗しました。(理由: {res_data['err_msg']})"})
        raise RuntimeError(f"計算の実行に失敗しました。(理由: {res_data['err_msg']})")
    
    result = res_data["response"]
    result_emit = print_result(result)

    emit('result', {'result': result_emit})

#==========================================================#

##################################
        # 実行プログラム #
##################################

app = Flask(__name__, instance_relative_config=True)
socketio = SocketIO(app)

paiListPath = './static/data/template_images/paiList.png'
img_dir_name = "./static/data/jantama_capture"
dir_path = Path(img_dir_name)
dir_path.mkdir(parents=True, exist_ok=True)
os.makedirs(img_dir_name, exist_ok=True)

@app.route("/")
def index():
    return render_template('index.html')

@socketio.on('start_capture')
def window_capture():
    img_No = 0
    FPS = 14
    #繰り返しスクリーンショットを撮る
    with mss.mss() as sct:
        windows = gw.getWindowsWithTitle("雀魂-じゃんたま-")
        if not windows:
            emit('error', {'error': "雀魂を先に開いてください"})
            return
        else:
            emit('error', {'error': ""})
        
        #キャプチャスタート
        global capturing
        capturing = True

        #====追記====#

        global calc
        calc = False

        global syanten_Type
        global consideration
        global turn

        #============#

        try:
            paiListImage = cv2.imread(paiListPath)
            emit('error', {'error': ""})
        except Exception as e:
            emit('error', {'error': "雀牌表画像の読み込みエラーです。"})
            print(e)

        window = windows[0]
        left, top, width, height = window.left, window.top, window.width, window.height
        monitor = {"top": top, "left": left, "width": width, "height": height}
        while capturing:
            emit('msg', {'msg': "Count:{}".format(img_No)})
            try:
                img_No = img_No + 1
                img = sct.grab(monitor)
                img = np.asarray(img)
                encoded_image = encode_image_to_base64(img)
                emit('new_image', {'img_path': f'data:image/jpeg;base64,{encoded_image}'})

                myHandImage, myTsumoImage, doraImage = cropMyHandImage(img)
                myHandImageList = divideMyHandImage(myHandImage)

                paiList = []
                for count in range(13):
                    response = recogPaiImage(myHandImageList[count], paiListImage)
                    if response:
                        paiList.append(response)
                    else:
                        paiList.append("Unknown")

                tsumo_response = recogPaiImage(myTsumoImage, paiListImage)
                if tsumo_response:
                    tsumopai = tsumo_response
                else:
                    tsumopai = "Unknown"

                doraImageList = divideDoraImage(doraImage)

                doraList = []
                for dora in doraImageList:
                    response = recogPaiImage(dora, paiListImage)
                    if response:
                        doraList.append(response)
                    else:
                        doraList.append("Unknown")

                tehai = list(paiList)
                if tsumopai != "Unknown":
                    tehai.append(tsumopai)

                # 非同期計算処理
                if calc and "Unknown" not in paiList :
                    asyncio.run(async_score_clac(doraList,tehai))
                else:
                    emit('error_calc', {'error_calc': "手牌が上手く読み込まれていません"})
                calc = False

                socketio.sleep(1 / FPS)
            except Exception as e:
                emit('error', {'error': f"キャプチャエラー: {e}"})
                continue

@socketio.on('stop_capture')
def capture_stop():
    global capturing
    capturing = False

@socketio.on('checkbox_change')
def handle_checkbox_change(data):
    global consideration
    consideration = sum(data["values"])

@socketio.on('number_input')
def handle_number_input(data):
    global turn
    turn = data['value']

@socketio.on('radio_change')
def handle_radio_change(data):
    global syanten_Type
    syanten_Type = data['value']

@socketio.on('calc')
def calc_start():
    global calc
    calc = True

if __name__ == "__main__":
    socketio.run(app, host="127.0.0.1", port=5000, debug=True, allow_unsafe_werkzeug=True)
```

```script.js
document.addEventListener('DOMContentLoaded', (event) => {
    const socket = io();
    const toggleButton = document.getElementById("toggleButton");
    var capturing = false;

    toggleButton.addEventListener("click",function(){
        if (capturing) {
            //ストップ処理
            socket.emit('stop_capture');
            toggleButton.innerText = 'Start Capture';
        } else {
            //スタート処理
            socket.emit('start_capture');
            toggleButton.innerText = 'Stop Capture';
        }
        capturing = !capturing;
    });

    socket.on('new_image', function(data) {
        if (capturing) {
            let img = document.getElementById("screenshot");
            img.src = data.img_path
        }
    });

    socket.on('error', function(data) {
        const errorBox = document.getElementById("error");
        errorBox.innerHTML = '<div class="error">' + data.error + '</div>';
    });

    // ==================ここから下すべて追記処理================== //

    document.getElementById("calc").addEventListener("click",function(){
        socket.emit('calc');
    });

    socket.on('error_calc', function(data) {
        const errorcBox = document.getElementById("error_calc");
        errorcBox.innerHTML = data.error_calc;
    });

    const radios = document.querySelectorAll('input[type="radio"][name="option"]');
    radios.forEach((radio) => {
        radio.addEventListener('change', () => {
            const selectedValue = parseInt(document.querySelector('input[type="radio"][name="option"]:checked').value, 10);
            socket.emit('radio_change', { value: selectedValue });
        });
    });

    // デフォルト選択された状態をサーバーに送信する
    const selectedValue = parseInt(document.querySelector('input[type="radio"][name="option"]:checked').value, 10);
    socket.emit('radio_change', { value: selectedValue });

    socket.on('result', function(data) {
        const resultListBox = document.getElementById("response");
        resultListBox.innerHTML = "";
    
        data.result.forEach(item => {
            const listItem = document.createElement('li');
            listItem.textContent = item;
            resultListBox.appendChild(listItem);
        });
    });

    const checkboxes = document.querySelectorAll('input[type="checkbox"][name="option"]');
    checkboxes.forEach((checkbox) => {
        checkbox.addEventListener('change', () => {
            const selectedValues = Array.from(document.querySelectorAll('input[type="checkbox"][name="option"]:checked')).map(cb => parseInt(cb.value, 10));
            socket.emit('checkbox_change', { values: selectedValues });
        });
    });

    const numberInput = document.getElementById('tentacles');

    const defaultValue = parseInt(numberInput.value, 10);
    socket.emit('number_input', { value: defaultValue });

    numberInput.addEventListener('input', () => {
        const value = parseInt(numberInput.value, 10);
        socket.emit('number_input', { value: value });
    });


    const inputField = document.getElementById('tentacles');
    const decrementButton = document.getElementById('decrement');
    const incrementButton = document.getElementById('increment');

    // マイナスボタンをクリックしたときの処理
    decrementButton.addEventListener('click', () => {
        const currentValue = parseInt(inputField.value);
        if (currentValue > parseInt(inputField.min)) {
            inputField.value = currentValue - 1;
        }
    });

    // プラスボタンをクリックしたときの処理
    incrementButton.addEventListener('click', () => {
        const currentValue = parseInt(inputField.value);
        if (currentValue < parseInt(inputField.max)) {
            inputField.value = currentValue + 1;
        }
    });
});
```

```index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <link rel="stylesheet" href="../static/css/style.css">
        <title>麻雀リアルタイムシミュレーター</title>
        <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
        <script src="https://cdn.socket.io/4.0.1/socket.io.min.js"></script>
    </head>
        <h1>シミュレーション結果出力画面</h1>
        <a id="toggleButton" class="tglbtn">Start Capture</a>
        <div id="error"></div>

        <div class="image-wrapper">
            <img id="screenshot" src="" alt="screenshot">
        </div>

        <!-- ここから下すべて追記 -->

        <div>
            <fieldset class="option_box">
                <legend class="config_item">
                    <h3>向聴タイプを選択してください</h3>
                </legend>
                <label>
                    <input type="radio" name="option" value="1" checked> 一般手
                </label>
                &nbsp;<label>
                    <input type="radio" name="option" value="2"> 七対子手
                </label>
                &nbsp;<label>
                    <input type="radio" name="option" value="4"> 国士無双手
                </label>
            </fieldset>
        </div>

        <div class="number-input-container">
            <h3>現在の巡目を入力</h3>
            <button id="decrement">-</button>
            <input type="number" id="tentacles" name="tentacles" min="1" max="17" value="1"/>
            <button id="increment">+</button>
        </div>

        <div>
            <fieldset class="checkbox">
                <legend class="config_item">
                    <h3>考慮項目</h3>
                </legend>
                <div>
                    <label>
                        <input type="checkbox" name="option" value="1"> 向聴落とし考慮
                    </label>
                </div>

                <div>
                    <label>
                        <input type="checkbox" name="option" value="2"> 手変わり考慮
                    </label>
                </div>

                <div>
                    <label>
                        <input type="checkbox" name="option" value="4"> ダブル立直考慮
                    </label>
                </div>

                <div>
                    <label>
                        <input type="checkbox" name="option" value="8"> 一発考慮
                    </label>
                </div>

                <div>
                    <label>
                        <input type="checkbox" name="option" value="16"> 海底撈月考慮
                    </label>
                </div>

                <div>
                    <label>
                        <input type="checkbox" name="option" value="32"> 裏ドラ考慮
                    </label>
                </div>

                <div>
                    <label>
                        <input type="checkbox" name="option" value="64"> 和了確率を最大化
                    </label>
                </div>
            </fieldset>
        </div>

        <div>
            <a id="calc" class="tglbtn">計算実行</a>
            <p id="error_calc"></p>
            <ul id="response">
            </ul>
        </div>
    </body>
    <script src="../static/js/script.js"></script>
</html>
```

私のGitHubに公開しているソースコードとは若干違いますが、不要な部分を消しただけですので気にしないでください。

## app.py

pythonに追加したコードはこちらです。

```app.py
def calc_remaining_tiles(hand_tiles, dora_indicators, melded_blocks):
    # counts[0] ~ counts[34]: 各牌の残り枚数、counts[34] ~ counts[36]: 赤牌が残っているかどうか
    counts = [4 for _ in range(34)] + [1, 1, 1]
    meld_tiles = [tile for meld in melded_blocks for tile in meld["tiles"]]
    visible_tiles = hand_tiles + dora_indicators + meld_tiles

    for tile in visible_tiles:
        counts[tile] -= 1
        if tile == Tile.AkaManzu5:
            counts[Tile.Manzu5] -= 1
        elif tile == Tile.AkaPinzu5:
            counts[Tile.Pinzu5] -= 1
        elif tile == Tile.AkaSozu5:
            counts[Tile.Sozu5] -= 1

    return counts

def print_result(result):

    result_emit = []

    result_type = result["result_type"]  # 結果の種類
    syanten = result["syanten"]  # 向聴数
    time_us = result["time"]  # 計算時間 (マイクロ秒)

    # print(f"計算時間: {time_us / 1e6}秒")
    result_emit.append(f"向聴数: {syanten['syanten']}")
    result_emit.append(f" (通常手: {syanten['normal']}, 七対子手: {syanten['tiitoi']}, 国士無双手: {syanten['kokusi']})")
    result_emit.append(f"計算時間: {time_us / 1e6}秒")

    if result_type == 0:
        #
        # 手牌の枚数が13枚の場合、有効牌、期待値、和了確率、聴牌確率が得られる。
        #
        required_tiles = result["required_tiles"]  # 有効牌
        exp_values = result["exp_values"]  # 期待値 (1~17巡目)
        win_probs = result["win_probs"]  # 和了確率 (1~17巡目)
        tenpai_probs = result["tenpai_probs"]  # 聴牌確率 (1~17巡目)

        tiles = [f"{tile['tile']}: {tile['count']}枚" for tile in required_tiles]
        # print(f"  有効牌: {', '.join(tiles)}")
        result_emit.append(f"  有効牌: {', '.join(tiles)}")

        for turn, (exp, win_prop, tenpai_prop) in enumerate(
            zip(exp_values, win_probs, tenpai_probs), 1
        ):

            result_emit.append(f"  {turn}巡目 期待値: {exp:.0f}点, 和了確率: {win_prop:.1%}, 聴牌確率: {tenpai_prop:.1%}")

    elif result_type == 1:
        #
        # 手牌の枚数が14枚の場合、打牌候補ごとに有効牌、期待値、和了確率、聴牌確率が得られる。
        #
        for candidate in result["candidates"]:
            tile = candidate["tile"]  # 打牌候補
            syanten_down = candidate["syanten_down"]  # 向聴戻しとなる打牌かどうか
            required_tiles = candidate["required_tiles"]  # 有効牌
            exp_values = candidate["exp_values"]  # 期待値 (1~17巡目)
            win_probs = candidate["win_probs"]  # 和了確率 (1~17巡目)
            tenpai_probs = candidate["tenpai_probs"]  # 聴牌確率 (1~17巡目)

            result_emit.append(f"打牌候補: {Tile.Name[tile]} (向聴落とし: {syanten_down})")

            tiles = [f"{tile['tile']}: {tile['count']}枚" for tile in required_tiles]
            
            result_emit.append(f"  有効牌: {', '.join(tiles)}")

            for turn, (exp, win_prop, tenpai_prop) in enumerate(
                zip(exp_values, win_probs, tenpai_probs), 1
            ):
                result_emit.append(f"  {turn}巡目 期待値: {exp:.0f}点, 和了確率: {win_prop:.1%}, 聴牌確率: {tenpai_prop:.1%}")

    return result_emit

# 不要になったら消す
def create_sample_request1():
    ###########################
    # サンプル: 手牌が14枚で面前の場合
    # 例: 222567m34p33667s北
    ###########################
    # 手牌
    hand_tiles = [
        Tile.Manzu2,
        Tile.Manzu2,
        Tile.Manzu2,
        Tile.Manzu5,
        Tile.Manzu6,
        Tile.Manzu7,
        Tile.Pinzu3,
        Tile.Pinzu4,
        Tile.Sozu3,
        Tile.Sozu3,
        Tile.Sozu6,
        Tile.Sozu6,
        Tile.Sozu7,
        Tile.Pe,
    ]
    # 副露牌 (4個まで指定可能)
    melded_blocks = []

    # ドラ表示牌 (4枚まで指定可能)
    dora_indicators = [Tile.Ton]
    # 場風 (東: Tile.Ton, 南: Tile.Nan, 西: Tile.Sya, 北: Tile.Pe)
    bakaze = Tile.Ton
    # 自風 (東: Tile.Ton, 南: Tile.Nan, 西: Tile.Sya, 北: Tile.Pe)
    zikaze = Tile.Ton
    # 計算する向聴数の種類 (通常手: SyantenType.Normal, 七対子手: SyantenType.Tiitoi, 国士無双手: SyantenType.Kokusi)
    syanten_type = SyantenType.Normal
    # 現在の巡目 (1~17巡目の間で指定可能)
    turn = 3
    # 場に見えていない牌の枚数を計算する。
    counts = calc_remaining_tiles(hand_tiles, dora_indicators, melded_blocks)
    # その他、手牌とドラ表示牌以外に場に見えている牌がある場合、それらを引いておけば、山にないものとして計算できる。

    # 期待値を計算する際の設定 (有効にする設定を指定)
    exp_option = (
        ExpOption.CalcSyantenDown  # 向聴落とし考慮
        | ExpOption.CalcTegawari  # 手変わり考慮
        | ExpOption.CalcDoubleReach  # ダブル立直考慮
        | ExpOption.CalcIppatu  # 一発考慮
        | ExpOption.CalcHaiteitumo  # 海底撈月考慮
        | ExpOption.CalcUradora  # 裏ドラ考慮
        | ExpOption.CalcAkaTileTumo  # 赤牌自摸考慮
    )

    # リクエストデータを作成する。
    req_data = {
        "version": "0.9.0",
        "zikaze": bakaze,
        "bakaze": zikaze,
        "turn": turn,
        "syanten_type": syanten_type,
        "dora_indicators": dora_indicators,
        "flag": exp_option,
        "hand_tiles": hand_tiles,
        "melded_blocks": melded_blocks,
        "counts": counts,
    }

    return req_data

def remakeDataForJson(li):
    remakeData = []
    pai_dict = {
        'Manzu1':Tile.Manzu1,'Manzu2':Tile.Manzu2,'Manzu3':Tile.Manzu3,'Manzu4':Tile.Manzu4,'Manzu5':Tile.Manzu5,'Manzu6':Tile.Manzu6,'Manzu7':Tile.Manzu7,'Manzu8':Tile.Manzu8,'Manzu9':Tile.Manzu9,'AkaManzu5':Tile.AkaManzu5,
        'Pinzu1':Tile.Pinzu1,'Pinzu2':Tile.Pinzu2,'Pinzu3':Tile.Pinzu3,'Pinzu4':Tile.Pinzu4,'Pinzu5':Tile.Pinzu5,'Pinzu6':Tile.Pinzu6,'Pinzu7':Tile.Pinzu7,'Pinzu8':Tile.Pinzu8,'Pinzu9':Tile.Pinzu9,'AkaPinzu5':Tile.AkaPinzu5,
        'Sozu1':Tile.Sozu1,'Sozu2':Tile.Sozu2,'Sozu3':Tile.Sozu3,'Sozu4':Tile.Sozu4,'Sozu5':Tile.Sozu5,'Sozu6':Tile.Sozu6,'Sozu7':Tile.Sozu7,'Sozu8':Tile.Sozu8,'Sozu9':Tile.Sozu9,'AkaSozu5':Tile.AkaSozu5,
        'Ton':Tile.Ton,'Nan':Tile.Nan,'Sya':Tile.Sya,'Pe':Tile.Pe,'Haku':Tile.Haku,'Hatu':Tile.Hatu,'Tyun':Tile.Tyun
    }

    for i in li:
        remakeData.append(pai_dict[i])

    return remakeData

async def async_score_clac(doraList,tehai):
    emit('error_calc', {'error_calc': ""})
    ########################################
    # 計算実行
    ########################################

    doraData = [item for item in doraList if item != "Unknown"]
    dora_indicators = remakeDataForJson(doraData)

    hand_tiles = remakeDataForJson(tehai)

    melded_blocks = []

    counts = calc_remaining_tiles(hand_tiles, dora_indicators, melded_blocks)

    req_data = {
        "version": "0.9.0",
        "zikaze": Tile.Ton,
        "bakaze": Tile.Ton,
        "turn": turn,
        "syanten_type": syanten_Type,
        "dora_indicators": dora_indicators,
        "flag": consideration,
        "hand_tiles": hand_tiles,
        "melded_blocks": melded_blocks,
        "counts": counts,
    }
    
    # サンプルリクエストでする場合はこっち
    # req_data = create_sample_request1()

    # dict -> json
    payload = json.dumps(req_data)
    # リクエストを送信する。
    res = requests.post(
        "http://localhost:8888", payload, headers={"Content-Type": "application/json"}
    )
    res_data = res.json()

    ########################################
    # 結果出力
    ########################################
    if not res_data["success"]:
        emit('error', {'error': f"計算の実行に失敗しました。(理由: {res_data['err_msg']})"})
        raise RuntimeError(f"計算の実行に失敗しました。(理由: {res_data['err_msg']})")
    
    result = res_data["response"]
    result_emit = print_result(result)

    emit('result', {'result': result_emit})
```

### sample.pyから持ってきた関数

`calc_remaining_tiles`
場に見えていない牌の枚数を計算する関数。

`print_result`
Jsonデータで受け取った計算結果を表示用に整える関数。なくても大丈夫です。

`create_sample_request1`
サンプルのリクエストJsonデータを作成する関数。これもなくても大丈夫です。

`async_score_clac`
名前は違いますが、sample.pyのmainにあたるところです。多少追記していますが、処理内容は変わりません。
html側の計算実行ボタンが押されたときに処理が走ります。

### 自身で作った関数
`remakeDataForJson`
物体検出で取得したデータをJson形式に合わせるための関数。
この記事書いてて今更気づきましたが、検出した時点でJson形式に合わせるようにすればおそらくこの処理は不要になります。

## script.js

jsに追加した処理です。
python側でHTTPリクエストのやり取りをする際に、送信するJsonデータに考慮項目を指定する必要があります。それはユーザーが任意で指定するものなので、HTMLで入力させた項目内容をpythonに送信する処理を記述しています。
また、その処理の追加に伴ったHTMLとpythonの追加処理もあります。

```script.js

//計算実行ボタンが押されたことをバックエンドに知らせます。
document.getElementById("calc").addEventListener("click",function(){
    socket.emit('calc');
});

// 計算失敗時のエラー内容の取得処理
socket.on('error_calc', function(data) {
    const errorcBox = document.getElementById("error_calc");
    errorcBox.innerHTML = data.error_calc;
});

// 向聴タイプの指定情報の送信
const radios = document.querySelectorAll('input[type="radio"][name="option"]');
radios.forEach((radio) => {
    radio.addEventListener('change', () => {
        const selectedValue = parseInt(document.querySelector('input[type="radio"][name="option"]:checked').value, 10);
        socket.emit('radio_change', { value: selectedValue });
    });
});

// デフォルト選択された向聴タイプをサーバーに送信
const selectedValue = parseInt(document.querySelector('input[type="radio"][name="option"]:checked').value, 10);
socket.emit('radio_change', { value: selectedValue });

// 計算結果の取得
socket.on('result', function(data) {
    const resultListBox = document.getElementById("response");
    resultListBox.innerHTML = "";

    data.result.forEach(item => {
        const listItem = document.createElement('li');
        listItem.textContent = item;
        resultListBox.appendChild(listItem);
    });
});

// 考慮項目情報の送信
const checkboxes = document.querySelectorAll('input[type="checkbox"][name="option"]');
checkboxes.forEach((checkbox) => {
    checkbox.addEventListener('change', () => {
        const selectedValues = Array.from(document.querySelectorAll('input[type="checkbox"][name="option"]:checked')).map(cb => parseInt(cb.value, 10));
        socket.emit('checkbox_change', { values: selectedValues });
    });
});

// 巡目数の送信
const numberInput = document.getElementById('tentacles');

const defaultValue = parseInt(numberInput.value, 10);
socket.emit('number_input', { value: defaultValue });

numberInput.addEventListener('input', () => {
    const value = parseInt(numberInput.value, 10);
    socket.emit('number_input', { value: value });
});


const inputField = document.getElementById('tentacles');
const decrementButton = document.getElementById('decrement');
const incrementButton = document.getElementById('increment');

// マイナスボタンをクリックしたときの処理
decrementButton.addEventListener('click', () => {
    const currentValue = parseInt(inputField.value);
    if (currentValue > parseInt(inputField.min)) {
        inputField.value = currentValue - 1;
    }
});

// プラスボタンをクリックしたときの処理
incrementButton.addEventListener('click', () => {
    const currentValue = parseInt(inputField.value);
    if (currentValue < parseInt(inputField.max)) {
        inputField.value = currentValue + 1;
    }
});
```

コメントアウトに書いた通りです。

```app.py
# def window_capture():内
global calc
calc = False

global syanten_Type
global consideration
global turn
```
python側はキャプチャ処理内に以上のグローバル変数を用意しておき、以下のようにソケットで入力値を取得できる部分を作っておきます。

```app.py
@socketio.on('checkbox_change')
def handle_checkbox_change(data):
    global consideration
    consideration = sum(data["values"])

@socketio.on('number_input')
def handle_number_input(data):
    global turn
    turn = data['value']

@socketio.on('radio_change')
def handle_radio_change(data):
    global syanten_Type
    syanten_Type = data['value']

@socketio.on('calc')
def calc_start():
    global calc
    calc = True
```

## index.html
あまり説明はいらないと思いますが、考慮項目や向聴タイプの指定ができるようにしました。

```index.html
<div>
    <fieldset class="option_box">
        <legend class="config_item">
            <h3>向聴タイプを選択してください</h3>
        </legend>
        <label>
            <input type="radio" name="option" value="1" checked> 一般手
        </label>
        &nbsp;<label>
            <input type="radio" name="option" value="2"> 七対子手
        </label>
        &nbsp;<label>
            <input type="radio" name="option" value="4"> 国士無双手
        </label>
    </fieldset>
</div>
    
<div class="number-input-container">
    <h3>現在の巡目を入力</h3>
    <button id="decrement">-</button>
    <input type="number" id="tentacles" name="tentacles" min="1" max="17" value="1"/>
    <button id="increment">+</button>
</div>
    
<div>
    <fieldset class="checkbox">
        <legend class="config_item">
            <h3>考慮項目</h3>
        </legend>
        <div>
            <label>
                <input type="checkbox" name="option" value="1"> 向聴落とし考慮
            </label>
        </div>
    
        <div>
            <label>
                <input type="checkbox" name="option" value="2"> 手変わり考慮
            </label>
        </div>
    
        <div>
            <label>
                <input type="checkbox" name="option" value="4"> ダブル立直考慮
            </label>
        </div>
    
        <div>
            <label>
                <input type="checkbox" name="option" value="8"> 一発考慮
            </label>
        </div>
    
        <div>
            <label>
                <input type="checkbox" name="option" value="16"> 海底撈月考慮
            </label>
        </div>
    
        <div>
            <label>
                <input type="checkbox" name="option" value="32"> 裏ドラ考慮
            </label>
        </div>
    
        <div>
            <label>
                <input type="checkbox" name="option" value="64"> 和了確率を最大化
            </label>
        </div>
    </fieldset>
</div>

<div>
    <a id="calc" class="tglbtn">計算実行</a>
    <p id="error_calc"></p>
    <ul id="response">
    </ul>
</div>
```

これでようやく、第３関門突破です。
ですが、当然こんなもので完成ではありません。抱える課題は山ほどあります。

# 今後の課題
麻雀リアルタイムシミュレーター.V1としてはこれで完了ですが、完全版には程遠いです。
この制作の今後の考えは、全部で7つです。

1. ゲーム画面のスクリーンサイズの対応
1. 処理速度の向上
1. 場風、自風検出
1. 捨て牌、鳴き牌の検出
1. 確信度の向上
1. ソースコードの整理
1. 雀魂以外でも対応可能に

優先度順に並べてみました。それぞれについて説明します。

## 1. ゲーム画面のスクリーンサイズの対応
プログラムを見て気づいた方もいるかもしれませんが、スクリーンサイズの変化に対応できない処理があります。それは、手牌、自摸牌。ドラ牌の切り抜きです。

```app.py
# 手牌、自模牌、捨て牌、ドラ牌の画像の切り抜き関数
def cropMyHandImage(jantamaMainImage):
    height, width = jantamaMainImage.shape[:2]
    myHandLeft = int(width*203/1665)
    myHandRight = int(width*1255/1665)
    myHandTop = int(height*760/938)
    myHandBottom = int(height*870/938)
    tsumoLeft = int(width*1278/1665)
    tsumoRight = int(width*1359/1665)

    doraLeft = int(width*34/1665)
    doraRight = int(width*273/1665)
    doraTop = int(height*72/938)
    doraBottom = int(height*127/938)

    myHandImage = jantamaMainImage[myHandTop:myHandBottom, myHandLeft:myHandRight]
    myTsumoImage = jantamaMainImage[myHandTop:myHandBottom, tsumoLeft:tsumoRight]
    doraImage = jantamaMainImage[doraTop:doraBottom, doraLeft:doraRight]

    myHandImage = cv2.resize(myHandImage, dsize = (1068,131))
    myTsumoImage = cv2.resize(myTsumoImage, dsize = (81,131))

    return [myHandImage, myTsumoImage, doraImage]
```

もう一度`cropMyHandImage`関数を見ていただくと、「width*1255/1665」だったり、「height*760/938」だったりと、固定の数値で切り抜いています。これだと、スクリーンサイズが変わったときずれた位置で切り抜きを行ってしまいます。プログラム的にもあまり綺麗な書き方とは言えませんので、最優先で改善します。

## 2. 処理速度の向上
pythonだから仕方ないのかもしれませんが、処理速度がちょっと遅すぎだと感じました。特に計算処理です。計算実行ボタンが押されたとき、レスポンスがすごく遅かったです。これを少しでもレスポンスが早くなるように修正します。プログラム全体(while文)の回転速度も遅いと感じましたので、これも修正します。

並行処理できそうな部分があるので、pythonのthreadを使ってみようかと思います。
また、`remakeDataForJson`関数のような不要にできそうな処理もなくしていきます。

記事を書いていて新たに気が付きましたが、計算実行ボタンが押されたときに切り抜き処理と物体検出処理をさせてみるのはどうでしょうか。今のプログラムですと、while文で回転するたびに無条件で毎回切り抜き物体検出をしています。正直どちらも計算時にしか使わないので、そうしてみるのもありなのかもしれません。そうすれば、while文の回転速度は向上します。しかし、そうすると計算実行ボタンが押されてからの処理時間が長くなってしまいますね。どうしましょうか...。自分でも考えてみますが、もし何かいい案やアドバイスがあればコメントいただけると幸いです。

## 3. 場風、自風検出
json形式で、HTTPリクエストする際に場風と自風の情報も取得しなければなりません。ユーザーが入力するように処理を書けば簡単ではありますが、せっかくですので、ゲームの対戦画面から取得してみます。

## 4. 捨て牌、鳴き牌の検出
こちらも未実装です。切り抜きで参考にした[雀魂の画面から画像認識で対戦情報を持ってくる（Vol. 4）](https://qiita.com/xenepic/items/786f110eb5be6ccf246d)に鳴き牌の切り抜き方法は書いてありましたが、自身の鳴き牌のみの切り抜きだけなのです。

何が問題なのかといいますと、これでは巡目数を計算できないのです。
自身の鳴き牌もjson形式でHTTPリクエストする際に必要な情報ではあります。そして巡目数もまた必要なのです。
巡目数は「自身の捨て牌の数」と「他プレイヤーに鳴かれた自身の捨て牌の数」を足す必要があります。ですので、自身の鳴き牌では計算できないのです。
また、自身の捨て牌も巡目計算に必要ですが、相手の捨て牌も別の計算で必要になってきます。
何に使うのかといいますと、牌の残り枚数の計算です。
こちらもjson形式に入れる情報ですので、場に見える牌はすべて検出させないといけません。

どちらも自分でプログラムを作るつもりで、実は記事を参考に試しに捨て牌の切り抜き処理を書いてみました。しかしかなり難しかったのでいったん断念しました。射影変換というものを使うらしく、深堀はまだしてないので詳しくはまだわかりませんが、おそらく数学がかなりかかわってくるのではないかと思います。特に座標系の数学。私は苦手ですすごく。でも完成させたいのでガンバリマス...。こちらも何かアドバイスや意見、おすすめの記事などがありましたら是非教えて下さい。

## 5. 確信度の向上
現在65％で設定していますが、ちょっと低い気がしています。また、65%でも時々誤検知することがあったのでこれも改善していきます。最低でも70％、もしできたら80％ぐらいまで上げたいです。

## 6. ソースコードの整理
言わずもがなですが、ソースコードがぐちゃぐちゃです。おそらく現場で活躍していらっしゃるプロのプログラマーが私のソースコードを見たら「汚ぇプログラムだな」と思う方がほとんどだと思います。私も思っています。
なので、こちらも整理したいです。とても見ずらいです。

## 7. 雀魂以外でも対応可能に
今は雀魂だけでしかシミュレーションできないのですが、いずれは他の麻雀アプリやリアル雀卓を外部カメラから映像を取得してシミュレーションできるようにしたいです。
外部カメラで取得した雀卓からならYOLOが使えそうですよね。これは少し興味があります。ただ優先度としては一番下です。

# 最後に
ここまで読んでくださりありがとうございます。
まだまだ未熟なエンジニア志望の学生ではありますが、なんとか完成させたいです。
来年が専門学校最後の年なので、こちらのアプリのバージョンアップを卒業制作にしようと思います。どこまで作れるか分かりませんが、中途半端で終わらせないよう頑張って完成させます。

[←前回の記事](https://qiita.com/mshok0511/items/71005bb003b1d46fcc1f)