JR EAST Train SimulatorでZUIKIの電車でGO!!用コントローラを使えるようにする(完結)


# はじめに
**[この記事][1]の続きです。**
遂に[JR EAST Train Simulator][5]が正式リリースされました!!やったぜ。オープニングがカッコイイ。
ということで(?)、一応完成としていた前回記事の状態から、ちょっとだけ手を加えました。(これ以上の変更点の追記は可読性を下げそうな気がしたので別記事にしました。)

2022/12/10: コードを微修正し，[github][7]に置き直しました．
# 概要
* コントローラの操作をすべてマウス操作として入力する
* コントローラのボタンにキーボードのキーを割り当てる
# コントローラの操作をすべてマウス操作として入力する
前の記事では、ハンドルの位置変化をキー押下ではなくマウスホイールのスクロールとして入力するように変更しました。
すなわち、ハンドルがNおよびEB位置の場合の処理はそれぞれ`S`、`1`のキー押下のままなわけですが、これでは211系などの2ハンドル車ではうまく動作しません。2ハンドル車を1ハンドルマスコン型のコントローラで操作するというのはいかがなものか…という気はしますが、一応遊べるようにしておきます。
#### マウスホイールで操作する
[公式の操作マニュアル][2]を見ると、マウス操作においてEB投入に相当するボタン等は無いみたいです。また、ブレーキ・加速ゼロ(前回記事における`S`押下に相当)はマウスホイールのクリックのようです。
pyautoguiでマウスホイールをクイックするには、`pyautogui.click(button='midlle')`を実行すればよいみたいです。([参照][3])
また、`pyautogui.click(button='midlle')`を実行する際に
```terminal:
The Pillow package is required to use this function.
```
とエラーが出るので、[この記事][4]の通りPillowをインストールしました。

また、前回のコードでは`now_pos`がN位置かEB位置にあった場合、何度も`S`、`1`が押下されていたので、位置に変化が無い場合は何もしないように変更しました。
```python:keymouse.py
def mouse_scroller(prev_pos, now_pos, newtral_pos):
    if(prev_pos==now_pos):
        pass
    else:
        if(now_pos == newtral_pos):  # N位置ならホイールクリック
                pyautogui.click(button='middle')
        elif(now_pos == 0):
            pyautogui.scroll(10000)  # EB位置なら上方向に思いっきり回転
        else:
            delta = prev_pos-now_pos
            pyautogui.scroll(delta*130)
            # ゲーム上ではマウスホイールでの操作として認識させます。
            # 検証したところ、1段あたりだいたい130"click"だと適切に動作します。値が小さいと、コントローラでB7に入れてもゲーム上でB6になるなどします。
            # 値は環境に依存するかもしれません。
            # delta<0で下スクロール、delta>0で上スクロールです。
```
# コントローラのボタンにキーボードのキーを割り当てる
こっちが本題みたいな感じではあります。
が、マニュアルによれば使用するキーは18、コントローラには16個のボタンしかありません。
したがって、一部の機能はキーボードでの操作が必須になります。
運転動作への関連度を考慮して、いくつかの操作はボタンに割り振りません。
#### コントローラ上のボタンと押すべきキーの対応付け
とりあえず
* `A`ボタンを警笛1、`X`ボタンを警笛2、`Y`ボタンをEBリセット、`B`ボタンを連絡ブザー
* `↑`ボタンをレバーサ前、`↓`ボタンをレバーサ後、`→`をATS確認、`←`を警報持続
* `CAPTURE`を低速/抑速、`L`を勾配起動、`-`をTASC切

とします。
pygameのリファレンスにコントローラ上のボタンがどのjoystick内のbuttonに対応しているか[書かれています][6]。なので、リファレンスとシミュレータのマニュアルを反復横跳びして…buttonの番号とそのボタンに割り当てるキーを対応させておきます。
```python:
ButtonNum_PushKey={0:'enter', 1:'b', 2:'backspace', 3:'e', 4:'t', 7:'k', 11:'up', 12:'down', 13:'x', 14:'space', 15:'w'}
```
#### 押す動作と離す動作
シミュレータには、運転士がボタンを「押す音」と「離す音」がどちらも収録されています。また、警笛など長押しをしたいので、できるようにします。
pyautoguiでは、`keyDown()`でキー押下、`keyUp()`で離す動作ができるようです。したがって、以前のボタンの状態と現在のボタンの状態を比較して、`keyDown()`、`keyUp()`することにします。

joystickオブジェクトのbuttonの状態を手に入れます。
```python:main.py
def get_button_status(device): # pygame.joystickオブジェクトを引数にとり、全てのbuttonの状態を配列で返します。
    button_num=device.get_numbuttons()
    button_status=[device.get_button(i) for i in range(button_num)]
    return button_status
```
値が0なら押されておらず、1なら押されている状態を表すので、前状態と比較して0->1であれば`keyDown()`、1->0であれば`keyUp()`すればよいですね。
```python:keymouse.py
def key_press(prev_btnstatus,now_btnstatus,ButtonNum_Key_Table):
    # 前回のボタン状態配列、現在のボタン状態配列、ボタンの番号と押すべきキーの対応辞書を引数にとります。
    for button_num,keyname in ButtonNum_Key_Table.items():
        if(prev_btnstatus[button_num] < now_btnstatus[button_num]): # status 0->1: 押された
            pyautogui.keyDown(keyname)
        elif(prev_btnstatus[button_num] > now_btnstatus[button_num]): # status 1->0: 離された
            pyautogui.keyUp(keyname)
        else:
            pass
```
# 動作確認
<iframe width="560" height="315" src="https://www.youtube.com/embed/5LHTr8t-uzs" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
良い感じです。動画は無いですが、211系でも正常に運転できます。

# コード
2022/12/10
[github][7]に置いてあります．

<!--
```python:main.py
import time
from os import environ
environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame  # noqa
from mascon import Master_Controller
import keymouse

def select_devices(dev_num=-1):
    pygame.init()
    if(dev_num == -1):
        device_num = pygame.joystick.get_count()
        if(device_num <= 0):
            raise ValueError("No device detected.")
        print("検出したJoystickの数:", device_num)
        controllers = [0]*device_num
        for i in range(device_num):
            controllers[i] = pygame.joystick.Joystick(i)
            device_name = controllers[i].get_name()
            print("デバイス%d: %s" % (i+1, device_name))
        selected_device_num = int(input("選択するデバイス番号: "))-1
        if(selected_device_num > device_num+1):
            raise ValueError(
                "The device No.%d is undefineed.".format(selected_device_num+1))
    else:
        selected_device_num = dev_num-1
    selected_device = pygame.joystick.Joystick(selected_device_num)
    selected_device.init()
    return selected_device


def convert_position(MasCon, inted_pos):  # マスコン位置に対応する位置(日本語)を返します、デバッグ用
    return MasCon.notch_table[inted_pos]

def get_button_status(device): # pygame.joystickオブジェクトを引数にとり、全てのbuttonの状態を配列で返します。
    pygame.event.pump()
    button_num=device.get_numbuttons()
    button_status=[device.get_button(i) for i in range(button_num)]
    return button_status


notch_table = ["EB", "B8", "B7", "B6", "B5", "B4",
               "B3", "B2", "B1", "N", "P1", "P2", "P3", "P4", "P5"]
notch_table_axes = [-1.0000000000, -0.9607849121, -0.8509826660, -0.7490234375, -0.6392211914, -0.5294189453, -0.4274597168, -
                    0.3176574707, -0.2078552246, 0.0039062500, 0.2470397949, 0.4352722168, 0.6156616211, 0.8038940430, 0.9999694824]
# 後で別の段数とか、個体差に対応できるように、ここで値を設定します。
ButtonNum_PushKey={0:'enter', 1:'b', 2:'backspace', 3:'e', 4:'t', 7:'k', 11:'up', 12:'down', 13:'x', 14:'space', 15:'w'}
# buttonIDと押すキーとの対応表

mascon = Master_Controller()
powercount = 5
bkcount = 8
frames=0

try:
    target_device=select_devices()
    mascon.set_data(target_device, notch_table,
                    notch_table_axes, powercount, bkcount)
    newtral_pos = mascon.step_count-powercount-1
    prev_pos = mascon.get_position()
    now_pos = prev_pos

    prev_button_status=get_button_status(target_device)
    now_button_status=prev_button_status

    print("Running...")
    while(True):
        now_pos = mascon.get_position()
        now_button_status=get_button_status(target_device)
        keymouse.mouse_scroller(prev_pos, now_pos, newtral_pos)
        keymouse.key_press(prev_button_status,now_button_status,ButtonNum_PushKey)
        prev_pos = now_pos
        prev_button_status=now_button_status
        frames=0
    
except Exception as err:
    print(err)
    time.sleep(3)
```
```python:mascon.py
from os import environ
environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame  # noqa


class Master_Controller:
    def __init__(self):  # pygame.joystickオブジェクト、ノッチ情報、ノッチ段数、各ノッチでのaxisの値、P段数、BK段数を持ちます。
        self.device = 0
        self.notch_table = []
        # 例えば["EB","B8","B7","B6","B5",…,"P2","P3","P4","P5"]、P5B8以外にも対応できるようにしました。
        self.step_count = 0
        self.notches_axesvalue = []
        # 例えば[-1.0000000000, -0.9607849121, -0.8509826660,…, 0.8038940430, 0.9999694824]、個体差があるかもしれないので最初に設定します。段数を変更したい場合は、この値を工夫します。
        # notch_table[]とnotches_axisvalue[]は当然同じ長さです。
        self.multiply_size = 100
        # 測定時に四捨五入されるなどして生じうる小さな誤差を許容するために、notches_axesvalue[]の値を整数に丸めます。その倍率。
        self.power_steps = 0
        self.normalbrake_steps = 0

    def set_data(self, device, notch_table, notches_axesvalue, power_steps, normalbrake_steps):
        self.device = device
        self.notch_table = list(notch_table)
        self.step_count = len(self.notch_table)
        self.notches_axesvalue = list(notches_axesvalue)
        self.power_steps = power_steps
        self.normalbrake_steps = normalbrake_steps
        for i in range(self.step_count):
            axis_val = self.notches_axesvalue[i]
            self.notches_axesvalue[i] = int(axis_val * self.multiply_size)
            # 整数に丸めこんで小さな誤差を許容します。先述。

    def get_position(self):  # マスコンの位置をintで返します
        pygame.event.pump()
        position_raw = self.device.get_axis(1)
        position_inted = int(position_raw*self.multiply_size)
        position_decided = False
        for i in range(len(self.notch_table)):
            if(position_inted == self.notches_axesvalue[i]):
                position_decided = True
                return i
        if(position_decided == False):
            raise ValueError(
                "Failed to decide a position of controller.")
```
```keymouse.py
import pyautogui
def mouse_scroller(prev_pos, now_pos, newtral_pos):
    if(prev_pos==now_pos):
        pass
    else:
        if(now_pos == newtral_pos):  # N位置ならホイールクリック
                pyautogui.click(button='middle')
        elif(now_pos == 0):
            pyautogui.scroll(10000)  # EB位置なら上方向に思いっきり回転
        else:
            delta = prev_pos-now_pos
            pyautogui.scroll(delta*130)
            # ゲーム上ではマウスホイールでの操作として認識させます。
            # 検証したところ、1段あたりだいたい130"click"だと適切に動作します。値が小さいと、コントローラでB7に入れてもゲーム上でB6になるなどします。
            # 値は環境に依存するかもしれません。
            # delta<0で下スクロール、delta>0で上スクロールです。

def key_press(prev_btnstatus,now_btnstatus,ButtonNum_Key_Table):
    # 前回のボタン状態配列、現在のボタン状態配列、ボタンの番号と押すべきキーの対応辞書を引数にとります。
    for button_num,keyname in ButtonNum_Key_Table.items():
        if(prev_btnstatus[button_num]<now_btnstatus[button_num]): # status 0->1: 押された
            pyautogui.keyDown(keyname)
        elif(prev_btnstatus[button_num]>now_btnstatus[button_num]): # status 1->0: 離された
            pyautogui.keyUp(keyname)
        else:
            pass
```

-->

[1]:https://qiita.com/10_tenk/items/973d78eff7bb3d9de3a2
[2]:https://cdn.akamai.steamstatic.com/steam/apps/2111630/manuals/JR_EAST_TrainSimulator_Instruction_Manual_202211JP.pdf#page=13
[3]:https://pyautogui.readthedocs.io/en/latest/mouse.html#mouse-clicks
[4]:https://qiita.com/YoshikiIto/items/15d8937fcfcf2d7aa9fb
[5]:https://www.jreast.co.jp/simulator/
[6]:https://www.pygame.org/docs/ref/joystick.html#nintendo-switch-pro-controller-pygame-2-x
[7]:https://github.com/tenk-9/zuiki_1hcontr