Splatoon3のパブロの連打を楽にしてくれるプログラム [Raspberry Pi / Python]

プログラミング

 みなさんSplatoon3やってますか? ぼんさんは3から始めたクチなんですが、すっかりハマって最近ずっとやっております。フェスを友人と回していたらえいえん+5まで行ってました(暇人)。

 で、パブロで筆をひいて敵陣を荒らして遊んでいた時に思いついたのですが、これ連打超高速で安定して出来たら強いんじゃないかと。調べてみると連射コンなるもので連打を自動化する手法が割と一般的なようです(野良で使うことについては賛否がありますが)。

 しかし、一般的な連射コンと呼ばれるものは、ボタンの長押しを連射に変換するようです。するとどういう問題が起こるかというと、筆をひけないのです(長押しすると連射になってしまうので)。それではつまらんということで、単押しと長押しを区別できるような仕組みを作ったうえで、単押ししたときにのみ連打してくれるようなプログラムを作ることにしました。

全体の仕組み

図1 全体のハードウェア構成

 今回は Switch とプロコンを Raspberry Pi 4B を経由する形で図1のように接続して、データを仲介する際にPythonのプログラムで書き換えるという方法で連射コン化を実現したいと思います。

 Switch からみて Raspberry Pi がプロコンとして認識されるように、Linux のカーネルに備わっている USB gadget(ガジェット)という機能を利用します。この機能を利用するためには初期設定と、再起動ごとのシェルスクリプトの実行が必要ですが、これについては「使い方」の章でお話します。

 まずは、メインとなる Python のプログラムについて解説します。

Python プログラム解説

 まず、仕様としては以下の通りです。

実は今回、4種類のプログラムを作っていてそれぞれ動作が違います。ここでは後述する GitHub で公開しているプログラムのうち、Mash-n-times.py についてのみ解説しています。

他のコードは基本的にやっていることは同じですので省略しますが、他のコードの仕様は「使い方」の章で説明しています。

  • ZRを押した時間が設定値以下であれば単押しと判定し、設定回数、設定した速度で連打する
  • ZRを押した時間が設定値以上であれば長押しと判定し、そのままZRの入力を流す
  • (おまけ機能)Raspberry Pi に繋がったキーボードの特定キーを押すと連射機能をオン/オフする

ここのコードは説明のために色々省略してます。
実際使えるコードはGithubで公開していて、このページの最後に記載してるよ。

def procon_input():
    while True:
        try:
            input_data = os.read(gadget, 128)
            #print('>>>', input_data.hex())
            os.write(procon, input_data)
        except BlockingIOError:
            pass
        except:
            os._exit(1)

def mash(data):
    global last_ZR, ZR_on, count, mash_flag, config_ms, config_count, last_mash
    if not last_ZR:
        if ((data[3] & 0b10000000) == 0b10000000):
            #ZR off -> on
            ZR_on = time.time()
            last_ZR = True
        else:
            pass
    else: 
        if ((data[3] & 0b10000000) == 0b10000000):
            last_ZR = True
        else:
            #ZR on -> off
            pressed_time = (time.time() - ZR_on) * 1000
            if pressed_time <= config_ms:
                mash_flag = True
                count = 0
                last_mash = time.time()
                print("ZR button shortly pressed")
            else:
                pass
            last_ZR = False
    if mash_flag:
        mash_interval = (time.time() - last_mash)
        if count >= config_count:
            mash_flag = False
        if mash_interval >= (1 / config_rate):
            data2 = bytearray(data)
            data2[3] |= 0x80
            data = bytes(data2)
            last_mash = time.time()
            count = count + 1
            print("Pressed ZR button")
        else:
            pass
    return data

def procon_output():
    while True:
        try:
            output_data = os.read(procon, 128)
            #print('<<<', output_data.hex())
            if toggle:
                output_data_2 = mash(output_data)
            else:
                output_data_2 = output_data
            os.write(gadget, output_data_2)
        except BlockingIOError:
            pass
        except Exception as e:
            print(e)
            os._exit(1)

threading.Thread(target=toggle).start()
threading.Thread(target=procon_input).start()
threading.Thread(target=procon_output).start()

 各関数ごとに解説していきます。

procon_input()

def procon_input():
    while True:
        try:
            input_data = os.read(gadget, 128)
            #print('>>>', input_data.hex())
            os.write(procon, input_data)
        except BlockingIOError:
            pass
        except:
            os._exit(1)

 名前が分かりにくいですが、ここでは Switch 側から流れてきたプロコン宛てのデータを、プロコン宛てにそのまま送信しています。input_data に Switch からのデータを代入して(4行目)、すぐプロコンに送信しています(6行目)。それを While True で無限ループ。

mash(data)

def mash(data):
    global last_ZR, ZR_on, count, mash_flag, config_ms, config_count, last_mash
    if not last_ZR:
        if ((data[3] & 0b10000000) == 0b10000000):
            #ZR off -> on
            ZR_on = time.time()
            last_ZR = True
        else:
            pass
    else: 
        if ((data[3] & 0b10000000) == 0b10000000):
            last_ZR = True
        else:
            #ZR on -> off
            pressed_time = (time.time() - ZR_on) * 1000
            if pressed_time <= config_ms:
                mash_flag = True
                count = 0
                last_mash = time.time()
                print("ZR button shortly pressed")
            else:
                pass
            last_ZR = False
    if mash_flag:
        mash_interval = (time.time() - last_mash)
        if count >= config_count:
            mash_flag = False
        if mash_interval >= (1 / config_rate):
            data2 = bytearray(data)
            data2[3] |= 0x80
            data = bytes(data2)
            last_mash = time.time()
            count = count + 1
            print("Pressed ZR button")
        else:
            pass
    return data

 ここで data に流れてきたプロコンからのデータを書き換え、連射などの機能を付加しています。

前半部分

def mash(data):
    global last_ZR, ZR_on, count, mash_flag, config_ms, config_count, last_mash
    if not last_ZR:
        if ((data[3] & 0b10000000) == 0b10000000):
            #ZR off -> on
            ZR_on = time.time()
            last_ZR = True
        else:
            pass
    else: 
        if ((data[3] & 0b10000000) == 0b10000000):
            last_ZR = True
        else:
            #ZR on -> off
            pressed_time = (time.time() - ZR_on) * 1000
            if pressed_time <= config_ms:
                mash_flag = True
                count = 0
                last_mash = time.time()
                print("ZR button shortly pressed")
            else:
                pass
            last_ZR = False

 まず last_ZR には、前回送られてきたデータではZRボタンが押されていたかどうかを boolean (TrueかFalse)で記録しています。前回はZRが押されていなかった場合、つまり last_ZR が False のとき(3行目)かつ、今回はZRが押されている場合、つまり (data[3] & 0b10000000) == 0b10000000 がTrueのとき(4行目)、5,6行目が実行されます。 すなわち、ZRがOFFの状態からONの状態に遷移した時のみ、ZR_on に現在時刻を代入しているわけです。

 ちなみに (data[3] & 0b10000000) == 0b10000000 は、プロコンから送られてきたデータの4バイト目の先頭ビットが0であれば False、1であれば True となる条件式です。プロコンのZRのデータが入ってるビットのみ見ています。

 反対に、 last_ZR が True で、ZRが押されていない場合、すなわちZRがONの状態からOFFの状態に遷移した時は、15行目から23行目の処理をしています。具体的には、現在時刻から ZR_on の値を引いた値を pressed_time に代入(15行目)、その値が設定値より小さければ単押しと判定し、17行目から19行目の処理を実行します。

 それぞれ、後半部分に記載している連射機能に関わるものです。連射機能を実行するかのフラグ mash_flag と、連打何回目であるかを記録する count 、最後に自動で押したZRの時刻を記録する last_mash をすべてリセットしています。

後半部分

    if mash_flag:
        mash_interval = (time.time() - last_mash)
        if count >= config_count:
            mash_flag = False
        if mash_interval >= (1 / config_rate):
            data2 = bytearray(data)
            data2[3] |= 0x80
            data = bytes(data2)
            last_mash = time.time()
            count = count + 1
            print("Pressed ZR button")
        else:
            pass
    return data

 この部分では、前半部分で mash_flag が True となっている場合に、連打する処理を実行します。

 まず、 last_mash には最後に自動でZRボタンを押した時刻が記録されているので、これを現在時刻から引いた値を mash_interval に代入します(2行目)。そして、まず既に行った連打回数が設定値より大きい場合には(3行目)、mash_flag を False にして連打機能を無効にしています(4行目)。

 次に、先程の mash_interval が設定値より大きければ(5行目)、6行目から11行目の処理を実行しています。設定した連射間隔(時間)より、最後に自動でZRを押した時間から現在時間までの方が長ければ、次のZRを押すという処理を行う訳です。

 内容としては、まず data を bytearray型 に変換してから(6行目)、4バイト目の先頭ビットを1にしています(7行目)。data2[3] |= 0x80 の部分です。前述したZRのデータが入っているビットですね。そして8行目で 再び byte型に変換し data に代入しなおしています。

 なぜこんな回りくどい事をしているかというと、Pythonでは byte型 のままでは編集できないからです。一旦 bytearray型 に変換してあげると編集できます。僕はこれで詰まって十数分無駄にしたのでめっちゃ赤太字にしておきます。

 そしてに last_mash に時間を代入し、count に1を加えています。

 最終行では、編集した data を return しています。

procon_output()

def procon_output():
    while True:
        try:
            output_data = os.read(procon, 128)
            #print('<<<', output_data.hex())
            if toggle:
                output_data_2 = mash(output_data)
            else:
                output_data_2 = output_data
            os.write(gadget, output_data_2)
        except BlockingIOError:
            pass
        except Exception as e:
            print(e)
            os._exit(1)

 これもちょい名前が分かりにくい気もするんですが、プロコンが出したデータを、先程の mash 関数に送って、戻ってきたデータを Switch に送信しています。それを While True で無限ループ。

補足

threading.Thread(target=toggle).start()
threading.Thread(target=procon_input).start()
threading.Thread(target=procon_output).start()

 ちなみにこういう3行が最後にありますが、これはそれぞれを別のスレッドで実行しろというやつです。それぞれ別々に同時並行で処理してほしいからですね。

使い方

 まぁ折角作ったんで、みんなにも使ってほしいというのと、後々の自分が忘れそうなんで詳細に書いておきます。

最初にもチラッと書きましたが、連射機能を野良で使うのは賛否があります。連射機能付きの任天堂公式ライセンスコントローラーとかも出てるので、連射機能自体に問題はないかと思いますが、改造は普通に規約違反です。真似する場合は諸々自己責任で。

セットアップ

用意するもの

  • Raspberry Pi 4B
     USB gadget 機能を使う関係上、4Bじゃないとだめです。
  • プロコン
     任天堂公式の奴です。
  • USB A to C のケーブル2本
     Switch と Raspberry Pi 、Raspberry Pi と プロコンを接続できれば良いです。

初めて使うときにだけすること

 Switch に対して Raspberry Pi を プロコンに見せかけるため、Linux の USB gadget 機能を使います。標準では無効化されているので、それを有効にする設定。OSは Raspbian の前提です。

echo "dtoverlay=dwc2" | sudo tee -a /boot/config.txt
echo "dwc2" | sudo tee -a /etc/modules
echo "libcomposite" | sudo tee -a /etc/modules

 上記コマンドをそれぞれ実行し、Raspberry Pi を再起動してください。

 それが出来たら、こちらのページから add_procon_gadget.sh をダウンロードし、適当なフォルダに保存しておいてください。

再起動後に毎回行うこと

 Switch の USB type-C ポートもしくはドックの USB ポートと、Raspberry Pi の USB type-C ポート を USB ケーブルで接続してください。Raspberry Pi の Type-C 以外のポートでは動作しないことに注意。接続したら上記でダウンロードした add_procon_gadget.sh を実行してください。

sudo sh add_procon_gadget.sh

 これで、Switch 側から Raspberry Pi がプロコンのように認識される訳です。(この時点でコントローラーとして認識されるわけではない)

プログラムを実行する

 ここまで出来たらあとは普通に Python のプログラムを実行してください。プログラムは GitHub で公開しています。

sudo python3 Mash-n-times.py

バリエーション

 最初に作ったプログラム Mash-n-times.py は、ZRを単押しすると設定回数連打してくれるという仕様でした。ZRのみを使って連射と筆ひき両方が出来るのが個人的に画期的な気がしていて、最初は良いかなと思っていたんですが、使っていると連射後の硬直など使いにくい面もありました。

 一度ZRを単押しすると設定回数連打するまでイカになれないということですね。また連続して連打し続けようとすると、結構コツが要りました。

 そこで、連打する条件やボタンを変更したいくつかのバリエーションを作ってみました。これも GitHub の同じページに配布していますので、個々人の使いやすいものを使ってみてください。

Mash-n-times.pyZRを単押しすると設定回数連打・長押しすると連打せずそのまま長押し(筆ひき)
Toggle-masher.pyZRを単押しするごとに連打オン/オフ・長押しすると連打せずそのまま長押し(筆ひき)
L-brush-move.pyZRを押している間連打・LをZRに変換(Lを長押しで筆ひき)
ZL-ZR-brush-move.pyZRを押している間連打・ZRとZLを同時押ししている間ZR入力(ZRとZLを同時長押しで筆ひき)
表1 各プログラムの仕様

コンフィグ

 個々人の好みによって設定を変更できるように、コンフィグを用意してあります。プログラムを直接エディタで開いて、コンフィグ欄の数値を変更してください。

# ============= CONFIG =============

# Single push threshold (ms).
# Initial value : 300
config_ms = 300

# Number of shots (integer).
# Initial value : 6
config_count = 6

# Mash function switch key (one character).
# Initial value : 'p'
config_key = 'p'

# Mash rate [times per second] (numerical value)
# Initial value : 30
config_rate = 30

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

 プログラムによっては一部の設定項目はありません。内容はすべて共通です。

変数名説明初期値
config_msZRを押した時間がどれだけ以下なら単押しと判定するか(ミリ秒)
[Mash-n-times.py Toggle-masher.py のみ]
300
config_countZRを単押しした際何回連打するか(整数)
[Mash-n-times.py のみ]
6
config_keyRaspberry Piに接続したキーボードのどのキーを押せば機能をオンオフ出来るか(任意のキーの1文字)p
config_rate1秒間に何回連打するか(数値)30
表2 各設定項目の説明

おわりに

 実はこのプログラムは単体で作った訳ではなく、元々別のプログラムを作っていてその派生として作ったものです。元々作っていたプログラムとは、パソコンに使うようなキーボードとマウスの入力をプロコン形式の入力に変換し、Splatoon をキーマウで遊べるようにしようというものでした。

 そちらは一応動く形にはなりました。キーボードの各操作をボタン入力、マウスの動きをジャイロ入力に変換という具合です。しかし実用的には厳しい遅延や、マウス入力の変換に関しては別の問題もあり、ある程度のところで開発を止めていました。

 これについてはまた別の機会に再チャレンジして記事にしようと思いますが(多分)、今回とりあえず実用的なプログラムとしてその時に得た知見を活かせたので良かったです。まぁ、全然難しい事はしてないんですけどね……。

 今回5億年ぶりにWordPressでホームページを作りましたが、その初回記事にはふさわしい内容になった気がします。色々記法とかも試せて楽しかったです。まぁこの記事書くの相当時間かかったので、ここまで詳細な記事はもうそうそう書かないと思いますが……。とはいえ元々文章書いたりするのは好きなので、ちょくちょく色々書いていこうと思います。

 ぼんさんって何やってるの? って聞かれたとき、このホームページを見てもらえれば大体わかる、みたいなものにしていきたいですね。今はTwitterがその役目を担っていて、あんま良くない気もするので……。

 今日はここまで。最後まで読んでくれた人がもしいたら、どうもありがとうございました(?)

参考サイト等

 このプログラム・記事を作成するにあたって、参考にしたサイトです。ありがとうございました。

 こちらも参考にしました。

コメント

タイトルとURLをコピーしました