JR EAST Train SimulatorをZUIKIの電車でGo!!用コントローラーで遊べるようにする(python)

# はじめに
JR公式から突如、社員教育用のシミュレータのSteam配信がアナウンスされ、界隈を震撼させました。
[JR東日本トレインシミュレータ][1]
しばらくβ版の配信が行われていたのですが、来る11月15日、ついに本格配信が決定！([公式プレス][2])
やったぜ～！！

さすがは公式、車両の挙動とかは結構いい感じなのですが、操作方法が…キーボードかマウスホイールのみ…
どうせなら電車でGO!!とかのコントローラーで運転したいなーと思い、お勉強がてら対応させてみたいと思います。

(結構表記ゆれがあったり、単語の意味を間違えて使ったりしているかもしれません。
にわかなのでやさしく指摘してくれるとありがたいです。)


***
*2022/11/07*: 反応速度を改善したので、変更点を追記しました。
*2022/11/16*: 処理方法他を少々変更しました。また、コントローラのボタンに操作を割り当てました。**[詳細][15]**
# 概要
* ZUIKIの電車でGO!!用ワンハンドルコントローラーを、JREast TrainSmuratorの操作入力に対応させる。
* コントローラー(JoyStickとして認識されます)の状態に応じて、キー入力を行うプログラムを作成する。
* pythonでプログラムを作ってみます、とりあえず。

## pygameのインストール
pythonでジョイスティックの入力を扱うためには、[**pygame**][3]というライブラリの**Joystick**モジュールを用いればいいみたいです。
[公式リファレンス][4]、[日本語版][5](翻訳してくれてありがとうございます)

早速インストール。
```terminal
pip install pygame
```

終わったら、いじってみます。
```python:main.py
import pygame

pygame.joystick.init()
device_num = pygame.joystick.get_count()
print("検出したJoystickの数:", device_num)
controllers = [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))
```
`pygame.joystick.init()`で初期化して(初期化しないとエラーが出ます)、`.get_count()`で接続されたデバイスの数を取得して、`.get_name()`でデバイスの名前を取得・列挙してみます。
```terminal:出力
pygame 2.1.2 (SDL 2.0.18, Python 3.10.0)
Hello from the pygame community. https://www.pygame.org/contribute.html
検出したJoystickの数: 1
デバイス1: Nintendo Switch Pro Controller
```
ZUIKIのコントローラを接続していますが、どうやらこいつはプロコンとして名前が登録されているようです。
***
ちなみに、はじめの2行はpygameをimportしたときに出るメッセージのようで、環境変数`PYGAME_HIDE_SUPPORT_PROMPT`を`1`に設定すれば消せるようです。([公式リファレンス][7]より)
>```
>PYGAME_HIDE_SUPPORT_PROMPT -
>Set to "1" to hide the prompt.
>```
>This stops the welcome message popping up in the console that tells you which version of python, pygame & SDL you are using. Must be set before importing pygame.



また、[この記事][6]によれば、以下のようにpygameをimportする前に変数を設定してやればいいようです。
自分の環境ではautopep8によってimport文がファイル頭に移動されてしまうため、[この記事][8]に従って`# noqa`を書き足しています。
```python
from os import environ
environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame # noqa
```
## マスコン位置を取得する
`ゲームコントローラーの設定/プロパティ`から確認したところ、ZUIKIのコントローラーはマスコン位置をy軸の傾きとして出力しているようです。また、EB投入時はボタン7が押下されています。(下図はEB位置の状態)
![スクリーンショット 2022-11-04 032633.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953096/495b6884-ea62-a3b4-15e6-59a39ad126c0.png)

リファレンスにProコンのボタンやスティックの情報がどの変数にあてがわれているか[書かれて][9]いたので、
* マスコンに相当する軸は`Axis 1`である
* EBか否かは`Axis 4`に情報がある
    * EB投入時に`1`、それ以外は`-1`

と分かりました。
```python:main.py
pygame.init()
mascon = pygame.joystick.Joystick(0) # この引数は先に羅列したコントローラーのidで、0で一意ではないです。
mascon.init()
while(True):
    pygame.event.pump() # これを書かないとpygameが起こったイベントを処理できません(後述)
    axis = mascon.get_axis(1)
    print("{:.10f}".format(axis))
```
また、上のコードを以て、各位置で`Axis 1`が以下の値を取っているとわかりました。
|マスコン位置|値|
|----|----|
|EB|-1.0000000000|
|B8|-0.9607849121|
|B7|-0.8509826660|
|B6|-0.7490234375|
|B5|-0.6392211914|
|B4|-0.5294189453|
|B3|-0.4274597168|
|B2|-0.3176574707|
|B1|-0.2078552246|
|N|0.0039062500|
|P1|0.2470397949|
|P2|0.4352722168|
|P3|0.6156616211|
|P4|0.8038940430|
|P5|0.9999694824|

この値には個体差があるかもしれません。(1台しかないので検証できないです…)
後のことも考えて、段数を整数で表したいと思います。
上の値に個体差があると仮定した場合や、P5B8以外の段数にしたい場合などを考えて、コネコネしました。
`MasCon`は`Master_Controller`の意です。
```python:main.py
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.notch_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.notch_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.notch_count):
            axis_val = self.notches_axesvalue[i]
            self.notches_axesvalue[i] = int(axis_val * self.multiply_size)
            # 整数に丸めこんで小さな誤差を許容します。先述。

def get_position(MasCon):  # マスコンの位置をintで返します、0がEB、Master_Controller.notch_count - 1が最大ノッチです。
    pygame.event.pump()
    position_raw = MasCon.device.get_axis(1)
    position_inted = int(position_raw * MasCon.multiply_size)
    for i in range(len(MasCon.notch_table)):
        if(position_inted == MasCon.notches_axesvalue[i]):
            return i
```
変数名とかは***ガチで***適当です。自分で使うのが目的なのでエラー処理とかしてないです。ゆるして。
***
ちなみに`pygame.event.pump()`についてですが、[リファレンス(日本語版)][10]にはこうあります。
>ゲームをプログラムする際には、1フレーム毎に何らかの方法でイベントキューを呼び出す必要があります。そうすることによって、プログラムを通してrest of the operating systemを操作することができます。特にイベントを制御する命令を実行していない場合は、このpygame.event.pump命令を実行してpygameがイベントを処理できるようにしなければいけません。
pygame.eventの他の命令を使って常にイベントキューに溜まったイベントの処理を行っているのであれば、この命令を使う必要はありません。
イベントキューに関して、必ず対処しておかなければならないことがあります。 ゲームではユーザーの操作によって表示内容が常に移り変わっていくので、システムから送られてくるクリックやキー入力といったイベントをイベントキューから常時受け取って処理をし続けなければ正常に動きません。そのため長時間イベントキューが処理されないような状況になると、システム側の判断によってpygameプログラムが停止させられてしまうことがあります。

デバイスが認識した現実世界でのイベント(クリック、ボタン押下など)はキューに蓄積されていくので、`pygame.event.pump()`でイベントを逐一pygameに渡してやらないとpygameはイベントを処理できないということらしいです。
今回では、コントローラーが認識したイベントが
`傾きがaになった、bになった、cになった…`
とキューに蓄積されていくので、これらのイベントをpygameに渡さないとpygame側で
`傾きがaである、bである…`
と認識(処理)できないんですね…
まあ要は毎フレーム`pygame.event.pump()`を実行してやればいいということです。
## マスコン位置に応じてキー入力をする
傾きの値に応じてゲームに情報を渡し、直接ノッチを指定できればいいんですけど、ちょっとやり方が分かりませんでした。
(そもそもできるんですかね、できるならやってみたいので詳しい方教えてください！)

そして、JREast TrainSimulatorの操作方法は下図の通りです(ゲームキャプチャより(2022/11/03現在))。
![スクリーンショット (25).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953096/67e5f042-7cdf-915f-4475-01c0a070ee95.png)
したがって、
* ~~以前のノッチ位置と今回取得したノッチ位置を比較し、差段数回`Q`または`Z`を押下する~~
    * ~~例えば、前回P5、今回B1であれば`Q`を6回押下します~~
* 以前のノッチ位置と今回取得したノッチ位置を比較し、差段数に相当する角度分マウスホイールを回転させる
* EB投入時に`1(テンキーでない)`を押下する
* N位置の場合は、`S`を押下する

といった処理をしたいと思います。下2つの処理は、運転中にコントローラとゲーム内のノッチ位置にズレが生じた場合、N位置かEB位置にすることでズレをリセットできるようにするためです。

>**2022/11/07追記** 
>上の操作説明の画像にある通り、マウスホイールによる操作ができます。複数回キーを入力するより、マウスホイールを差段数分回転させる方が高速に入力できたので、マウスホイールによる操作入力に変更しました。
(n回キーを押す処理->1回マウスホイールを回す操作になったので、その分高速です。[見違えるほど早くなってます][14])


キー入力を行うには、**PyAutoGui**([公式リファレンス][11]、[参照記事][12])を使います。
```
pip install pyautogui
```
んでもって、キー入力は`pyautogui.press(key_name)`で行えます。
```python
import pyautogui
for i in range(9):
    pyautogui.press('Q')
```
を実行すると、
```terminal
> QQQQQQQQQ
```
Qが9回押されてますね。ｸｸｸ…
さらに、マウスホイールの回転は`pyautogui.scroll(amount_of_clicks)`です。引数が正で上方向、負で下方向の回転をします。
```python
pyautogui.scroll(130)
```
自分の環境では、約130clickでゲーム上のハンドルを1段動かすことができました。この値が小さいとコントローラでN->B7にしても、ゲーム上ではB6までしか動かないなどします。値は一意ではなく環境に依存すると思われます。

よって下のように書きました。
```python:main.py
def key_presser(prev_pos, curr_pos, newtral_pos):  # 前回位置、今回位置、N位置を受け取る
        if(curr_pos == newtral_pos):  # N位置ならS
            pyautogui.press('s')
        elif(curr_pos == 0):
            pyautogui.press('1')  # EB位置なら1(テンキーでない)
        else:
            delta = prev_pos-curr_pos
            pyautogui.scroll(delta*130) # ゲーム上ではマウスホイールでの操作として認識させます。
```
## 動くかテストします
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">いい感じ、ちょいラグあり <a href="https://t.co/VlOu6sEdSh">pic.twitter.com/VlOu6sEdSh</a></p>&mdash; tenk-10 (@10_tenk) <a href="https://twitter.com/10_tenk/status/1588540558548824064?ref_src=twsrc%5Etfw">November 4, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

ちゃんと認識していますね。起動時の初期位置に由来する位置のズレを、EB投入によって修正できているのもよい感じです。
~~ただ、ちょっとラグがありますね。特に常用Bを数段動かしたときとかです。~~
~~改善の余地あり。~~(2022/11/07: 改善しました。)

素早く動かしたりとか、いろいろ試してみた結果、
* 1段の移動処理を複数回行うより、複数段の移動処理を1回行うほうが入力が高速になる
    * 例えばB1->B6にコントローラーを動かしたとき、`prev_pos - curr_pos = -1`の処理を5回やるのではなく、`prev_pos - curr_pos = -5`の処理を1回するほうが高速

まあ当たり前と言えば当たり前ですね、余計な動作が無くなるので…

~~素早く動かしたときに、動かし始めから終わりまで何フレームくらいなのか調べたところ、`200`くらいでした(B7からB1、値は一意ではなくCPU等の実行環境に依存すると思います)。~~
~~したがって、200フレームごとにマスコン位置を取得し、キーを押すようにしました。~~
**2022/11/07追記**: 処理方法をマウスホイールでの入力に変えたので、この待機時間も見直しました。今は`15000`です。

## おわりに
取り合えずマスコンを動かすという当初の目的は達成できたので、ひとまず完成とします。
コントローラのボタンを警笛に割り当てたりしてもいい気がしますが、どのボタンがどの変数に割り振られているか[分かっている][9]ので、すぐできるはずです。~~気が向いたらやります。~~**[やりました][15]**(2022/11/16)

また、作り終わってから「先に同じようなことやってる人居そうだなー」と思って調べたところ、案の定[いらっしゃいました][13](他にもいらっしゃるかもしれません)。この方の方が完成度・機能充実度は高いです(すごいです)。
自分がこれを作った目的は勉強半分遊び半分なので、1人(とネットの集合知)で作り終えたことに意味があると思ってます。楽しかったー！
<iframe width="560" height="315" src="https://www.youtube.com/embed/bwT5aHYHjvQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

## 最終コード
2022/11/07
**2022/11/16以降: https://qiita.com/10_tenk/items/f21203d7c55fd3b19d62#コード**
```python:main.py
import pyautogui
import time
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 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
        print("Running...")
        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 get_position(MasCon):  # マスコンの位置をintで返します
    pygame.event.pump()
    position_raw = MasCon.device.get_axis(1)
    position_inted = int(position_raw*MasCon.multiply_size)
    position_decided = False
    for i in range(len(MasCon.notch_table)):
        if(position_inted == MasCon.notches_axesvalue[i]):
            position_decided = True
            return i
    if(position_decided == False):
        raise ValueError(
            "Failed to decide a position of controller.")


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


def key_presser(prev_pos, curr_pos, newtral_pos):  # 前回位置、今回位置、N位置を受け取る
        if(curr_pos == newtral_pos):  # N位置ならS
            pyautogui.press('s')
        elif(curr_pos == 0):
            pyautogui.press('1')  # EB位置なら1(テンキーでない)
        else:
            delta = prev_pos-curr_pos
            pyautogui.scroll(delta*130)
            # ゲーム上ではマウスホイールでの操作として認識させます。
            # 検証したところ、1段あたりだいたい130"click"だと適切に動作します。値が小さいと、コントローラでB7に入れてもゲーム上でB6になるなどします。
            # 恐らくこの値は環境によります。
            # delta<0で下スクロール、delta>0で上スクロールです。




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]
# 後で別の段数とか、個体差に対応できるように、ここで値を設定します。

mascon = Master_Controller()
powercount = 5
bkcount = 8
frames=0
try:
    mascon.set_data(select_devices(), notch_table,
                    notch_table_axes, powercount, bkcount)
    newtral_pos = mascon.step_count-powercount-1
    prev_pos = get_position(mascon)
    now_pos = get_position(mascon)
    while(True):
        if(frames>=15000):#15000フレームごとにnow_posを更新し、キーを押します。
            now_pos = get_position(mascon)
            key_presser(prev_pos, now_pos, newtral_pos)
            prev_pos = now_pos
            frames=0
        frames+=1
        
except Exception as err:
    print(err)
    time.sleep(3)
```


[1]:https://www.jreast.co.jp/simulator/
[2]:https://www.jreast.co.jp/press/2022/20221101_ho02.pdf
[3]:https://github.com/pygame/pygame
[4]:https://www.pygame.org/docs/ref/joystick.html
[5]:http://westplain.sakuraweb.com/translate/pygame/Joystick.cgi
[6]:https://stackoverflow.com/questions/54246668/how-do-i-delete-the-hello-from-the-pygame-community-console-alert-while-using
[7]:https://www.pygame.org/docs/ref/pygame.html
[8]:https://thewheyyouare.com/vscode_auto_sort/
[9]:https://www.pygame.org/docs/ref/joystick.html#nintendo-switch-pro-controller-pygame-2-x
[10]:http://westplain.sakuraweb.com/translate/pygame/Event.cgi
[11]:https://pyautogui.readthedocs.io/en/latest/index.html
[12]:https://qiita.com/konitech913/items/301bb63c8a69c3fcb1bd
[13]:https://github.com/mipsparc/JRESim_Dengo
[14]:https://youtube.com/shorts/eOAEmx0_LRs?feature=share
[15]:https://qiita.com/10_tenk/items/f21203d7c55fd3b19d62