0
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-11-04

はじめに

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

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

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


2022/11/07: 反応速度を改善したので、変更点を追記しました。
2022/11/16: 処理方法他を少々変更しました。また、コントローラのボタンに操作を割り当てました。詳細

概要

  • ZUIKIの電車でGO!!用ワンハンドルコントローラーを、JREast TrainSmuratorの操作入力に対応させる。
  • コントローラー(JoyStickとして認識されます)の状態に応じて、キー入力を行うプログラムを作成する。
  • pythonでプログラムを作ってみます、とりあえず。

pygameのインストール

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

早速インストール。

pip install pygame

終わったら、いじってみます。

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()でデバイスの名前を取得・列挙してみます。

出力
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_PROMPT1に設定すれば消せるようです。(公式リファレンスより)

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.

また、この記事によれば、以下のようにpygameをimportする前に変数を設定してやればいいようです。
自分の環境ではautopep8によってimport文がファイル頭に移動されてしまうため、この記事に従って# noqaを書き足しています。

from os import environ
environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame # noqa

マスコン位置を取得する

ゲームコントローラーの設定/プロパティから確認したところ、ZUIKIのコントローラーはマスコン位置をy軸の傾きとして出力しているようです。また、EB投入時はボタン7が押下されています。(下図はEB位置の状態)
スクリーンショット 2022-11-04 032633.png

リファレンスにProコンのボタンやスティックの情報がどの変数にあてがわれているか書かれていたので、

  • マスコンに相当する軸はAxis 1である
  • EBか否かはAxis 4に情報がある
    • EB投入時に1、それ以外は-1

と分かりました。

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以外の段数にしたい場合などを考えて、コネコネしました。
MasConMaster_Controllerの意です。

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()についてですが、リファレンス(日本語版)にはこうあります。

ゲームをプログラムする際には、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
したがって、

  • 以前のノッチ位置と今回取得したノッチ位置を比較し、差段数回QまたはZを押下する
    • 例えば、前回P5、今回B1であればQを6回押下します
  • 以前のノッチ位置と今回取得したノッチ位置を比較し、差段数に相当する角度分マウスホイールを回転させる
  • EB投入時に1(テンキーでない)を押下する
  • N位置の場合は、Sを押下する

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

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

キー入力を行うには、PyAutoGui(公式リファレンス参照記事)を使います。

pip install pyautogui

んでもって、キー入力はpyautogui.press(key_name)で行えます。

import pyautogui
for i in range(9):
    pyautogui.press('Q')

を実行すると、

> QQQQQQQQQ

Qが9回押されてますね。ククク…
さらに、マウスホイールの回転はpyautogui.scroll(amount_of_clicks)です。引数が正で上方向、負で下方向の回転をします。

pyautogui.scroll(130)

自分の環境では、約130clickでゲーム上のハンドルを1段動かすことができました。この値が小さいとコントローラでN->B7にしても、ゲーム上ではB6までしか動かないなどします。値は一意ではなく環境に依存すると思われます。

よって下のように書きました。

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) # ゲーム上ではマウスホイールでの操作として認識させます。

動くかテストします

ちゃんと認識していますね。起動時の初期位置に由来する位置のズレを、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です。

おわりに

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

また、作り終わってから「先に同じようなことやってる人居そうだなー」と思って調べたところ、案の定いらっしゃいました(他にもいらっしゃるかもしれません)。この方の方が完成度・機能充実度は高いです(すごいです)。
自分がこれを作った目的は勉強半分遊び半分なので、1人(とネットの集合知)で作り終えたことに意味があると思ってます。楽しかったー!

最終コード

2022/11/07
2022/11/16以降: https://qiita.com/10_tenk/items/f21203d7c55fd3b19d62#コード

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)
0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2