はじめに(製作動機)
ゲーム好きな我が家族、妻と娘は時間を見つけてはNintendo Switchのゲーム(スプラトゥーン、マリオカートなど)で毎日楽しく遊んでいます! 息子もその横で、楽しそうに画面を見て喜んでいます。
しかし、見てるだけで、操作できない。
ゲーム依存が社会問題になっている様ですが、我が家はゲームから多くを学んでます。
消極的だった娘はゲームをやる様になって、チャレンジ精神、失敗する事の大切さを学び、学校の友達とも積極的に遊ぶ様になりました!
なので、息子にも息子もゲームを通して、
「自分で操作する楽しさ」や「失敗してやり直せる楽しさ」を感じて欲しい!
まず最初の関門は、市販のコントローラでは「手の不自由な息子は操作できない」という事。
・ボタンが多すぎて、どれを押せばいいか分からない
・コントローラを持てないので、狙ったボタンを押せない
という訳で、まずは第一歩として
「息子でも操作できる様な物理スイッチで操作できる
Nintendo Switchコントローラの開発」
の自作に取り組みました!
[要求仕様]
・3.5mmジャック対応スイッチで操作できる事
また、我が家で使っている無線対応ジョイスティックでも操作できる事
・操作ボタンはゲームごとに変更できる事
・操作ボタンは最小限として、
それ以外の操作は自動 or 介助者による代替操作でフォローできる事
(ただし、本人が操作してる感覚になれる事)
・介助者によるサポート操作が同時にできる事
製作物
上記要求仕様を踏まえた結果、効率良い手段として、
「市販品のNintendo Switchコントローラを改造して外部ハックする」
という方針で、ハックモジュールを開発・改造しました。
操作イメージを以下の動画にまとめました。
Nintendo Switchコントローラを改造して、手の不自由な息子でも操作しやすいスイッチに対応#M5Stack 経由で有線スイッチ4つ+無線スイッチを接続可。ゲーム毎に対応ボタンを切り変え、最低限のボタンでゲームできる
— おぎ-モトキ (@ogimotoki) 2019年11月30日
ゲーム好きな我が家、息子もゲームの輪に入れるぜ!(^-^)#家族のためのモノづくり pic.twitter.com/U2TgVCYbFU
今のところ、息子でも遊べそうなゲーム(少ボタンでも出来るゲーム)として、
・マリオカート
・ゼルダの伝説 ~夢を見る島~
・太鼓の達人
に対応しております。
製作方法
材料
改造するコントローラとしては、安価なコントローラとしてHORI製有線コントローラを使いました。
これに、お馴染みの無線対応マイコンモジュール"M5Stack" 及び 周辺デバイスを使います。
筐体は 3Dプリンタで作成しました。
部品名 | 個数 | 購入先 | 値段 |
---|---|---|---|
M5Stack Basic | 1 | Switch Science | ¥3575 |
M5Stack プロトモジュール | 1 | Switch Science | ¥1300 |
GPIO拡張IC MCP23017 | 1 | 秋月電子 | ¥110 |
3.5mmステレオジャック | 4 | 秋月電子 | ¥60 |
全体システム
システムイメージは以下の図になります。
市販のコントローラ内の基板上のボタン入力信号&ジョイスティック入力信号に外部から配線を追加接続して、外部マイコンから強制的に信号を切り替えるやり方です。ボタン制御用には8本接続し、ボタン押し制御を実施する際は外部からL(GND)信号を印加してやります。
ジョイスティックの場合、1つのジョイスティックに上下用と左右用の2本を接続します。この場合、ジョイスティックの制御がない場合は、信号は1.65V(中間値)になっていますので、
上 or 左に設定する場合 → H(3.3V)信号を出力
下 or 右に設定する場合 → L(0V)信号を出力
設定しない場合 → Hi-Zにする = 入力設定にする
という事で対応しました。これだとアナログジョイスティックならではの中間値が表現できませんが、まぁゲーム精度を求める事が目的ではないので、今回は妥協しました。
(こういう妥協も、スピード感あるモノづくりでは結構大事ですよね)
これは前職(家電関連の電気設計)でのエージング試験用としてよく行っていた手法です。
家電のリモコンを改造して、定期的な信号を発行し続けさせて、耐久性試験の効率化として活用していました。
ここにきて、前職での設計スキルが役に立ったー!
ハードウェア
それでは、HORI製のコントローラを分解してみる。裏側のネジを外すと、緑色の基板が出てきます。
この基板の上で、比較的はんだ付けしやすそうな場所を選んで半田をつけていきます。
A/B/X/Yボタンは残念ながら裏面にはんだ付けしやすそうな場所がなかったので、
表面側に直接付けます。(上級者であれば、配線のところのレジストを削る手段を取る方がラクかも)
ただし、ボタン面はコーティングされているので、カッターの刃裏面を使い、コーティング部を削っていきます。
キレイな銅のピカピカした色が見えてきたら、その上にはんだを乗せてみて、無事にひっつけばOKです
配線するとこんな感じになります。(汚くてスイマセン)
また、ハックユニット側には、3.5mmジャック用スイッチを4つ筐体に並べます。
残念ながら、M5Stack プロトモジュールの一端面には4つ並ばなかったので、専用筐体を3Dプリンタで製作してみました。
ソフトウェア
GPIO拡張IC MCP23017
今回の様に8本以上のスイッチ入力やON制御出力を実施する場合、信号本数が足りなくなる事が想定されます。
そんな時に便利なのが、『GPIO拡張IC MCP23017』です。
チップの詳細は以下のサイトをご覧ください。
※使う際の注意
・ICのリセット端子が必ずPU抵抗になっているか?
・アドレス信号(A0/A1/A2)は必ずGND or PU抵抗で接続されているか?
私は上記を忘れていた結果、不安定動作(IC近辺を手で触ると挙動が変わる)に悩まされました…
制御については、Arduinoライブラリとして提供されています。
これを使えば、通常GPIOと近いソフトウェアの記述イメージで製作できます。
ソースコード
全体を公開すると冗長になるため、必要部のみ抜粋します
#include <M5Stack.h> #include <WiFi.h> #include <WiFiUdp.h> #include "esp_system.h" #include "Adafruit_MCP23017.h" //MCP23017 (GPIOエキスパンダ)のピン割り当て #define L_H_JOY 7 #define L_V_JOY 6 #define R_H_JOY 5 #define R_V_JOY 4 #define A_BTN 8 #define B_BTN 9 #define X_BTN 10 #define Y_BTN 11 #define R_BTN 12 #define RZ_BTN 13 #define L_BTN 14 #define LZ_BTN 15 #define INPUT1_BTN 0 #define INPUT2_BTN 1 #define INPUT3_BTN 2 #define INPUT4_BTN 3 #define RENDA_JUDGE_TIME 250 #define RENDA_KANKAKU_MS 60 Adafruit_MCP23017 mcp; void setup() { M5.begin(); Serial.begin(115200); M5.Lcd.fillScreen(BLACK); mcp.begin(); // use default address 0 //PIN設定 mcp.pinMode(L_V_JOY, INPUT); mcp.pinMode(L_H_JOY, INPUT); mcp.pinMode(R_V_JOY, INPUT); mcp.pinMode(R_H_JOY, INPUT); mcp.pinMode(A_BTN, OUTPUT); mcp.digitalWrite(A_BTN, 1); mcp.pinMode(B_BTN, OUTPUT); mcp.digitalWrite(B_BTN, 1); mcp.pinMode(X_BTN, OUTPUT); mcp.digitalWrite(X_BTN, 1); mcp.pinMode(Y_BTN, OUTPUT); mcp.digitalWrite(Y_BTN, 1); mcp.pinMode(L_BTN, OUTPUT); mcp.digitalWrite(L_BTN, 1); mcp.pinMode(LZ_BTN, OUTPUT); mcp.digitalWrite(LZ_BTN, 1); mcp.pinMode(R_BTN, OUTPUT); mcp.digitalWrite(R_BTN, 1); mcp.pinMode(RZ_BTN, OUTPUT); mcp.digitalWrite(RZ_BTN, 1); mcp.pullUp(0, HIGH); mcp.pullUp(1, HIGH); mcp.pullUp(2, HIGH); mcp.pullUp(3, HIGH); } void loop(){ M5.update(); if(M5.BtnA.wasPressed()){ //現時点では3モードのみ game_idx = (game_idx-1)%3; } if(M5.BtnC.wasPressed()){ //現時点では3モードのみ game_idx = (game_idx+1)%3; } //ゲームモード選択 if(game_idx != game_idx_old){ //モード0 : マリオカート if(game_idx == 0){ M5.Lcd.drawJpgFile(SD, "/mariocart.jpg"); M5.Lcd.drawJpgFile(SD, "/mc_accel.jpg", 0, 180); M5.Lcd.drawJpgFile(SD, "/mc_item.jpg", 80, 180); M5.Lcd.drawJpgFile(SD, "/mc_left.jpg", 160, 180); M5.Lcd.drawJpgFile(SD, "/mc_right.jpg", 240, 180); } **** 中略 **** game_idx_old = game_idx; } //外部ボタンからのハック分をマージ int jyuji_val_update = jyuji_val; int btn_val_update = btn_val; int input_val = mcp.digitalRead(INPUT4_BTN)*8+mcp.digitalRead(INPUT3_BTN)*4+mcp.digitalRead(INPUT2_BTN)*2+mcp.digitalRead(INPUT1_BTN); // Mode0 マリオカートの場合 if(game_idx == 0){ // ボタン入力 if(mcp.digitalRead(INPUT1_BTN) == 0){ btn_val_update |= 0b00000001; //Aボタンを1にする } if(mcp.digitalRead(INPUT2_BTN) == 0){ btn_val_update |= 0b01000000; //Lボタンを1にする } if(mcp.digitalRead(INPUT3_BTN) == 0){ jyuji_val_update |= 0b0010; //左ボタンを1にする } if(mcp.digitalRead(INPUT4_BTN) == 0){ jyuji_val_update |= 0b0001; //右ボタンを1にする } // ボタン表示変更 if((input_val & 0b0001) != (input_val_old & 0b0001)){ if((input_val & 0b0001) == 0) M5.Lcd.drawJpgFile(SD, "/mc_accel_on.jpg", 0, 180); else M5.Lcd.drawJpgFile(SD, "/mc_accel.jpg", 0, 180); } if((input_val & 0b0010) != (input_val_old & 0b0010)){ if((input_val & 0b0010) == 0) M5.Lcd.drawJpgFile(SD, "/mc_item_on.jpg", 80, 180); else M5.Lcd.drawJpgFile(SD, "/mc_item.jpg", 80, 180); } if((input_val & 0b0100) != (input_val_old & 0b0100)){ if((input_val & 0b0100) == 0) M5.Lcd.drawJpgFile(SD, "/mc_left_on.jpg", 160, 180); else M5.Lcd.drawJpgFile(SD, "/mc_left.jpg", 160, 180); } if((input_val & 0b1000) != (input_val_old & 0b1000)){ if((input_val & 0b1000) == 0) M5.Lcd.drawJpgFile(SD, "/mc_right_on.jpg", 240, 180); else M5.Lcd.drawJpgFile(SD, "/mc_right.jpg", 240, 180); } *** 中略 ***** //////////////////////////// // コントローラボタン制御 /////////////////////////// // 上下ボタン if((jyuji_val_update & 0b1000) >> 3){ //上の場合 mcp.pinMode(L_V_JOY, OUTPUT); mcp.digitalWrite(L_V_JOY, 0); Serial.println("push UP"); }else if((jyuji_val_update & 0b0100) >> 2){ //下の場合 mcp.pinMode(L_V_JOY, OUTPUT); mcp.digitalWrite(L_V_JOY, 1); Serial.println("push DOWN"); }else{ mcp.pinMode(L_V_JOY, INPUT); } // 左右ボタン if((jyuji_val_update & 0b0010) >> 1){ //左の場合 mcp.pinMode(L_H_JOY, OUTPUT); mcp.digitalWrite(L_H_JOY, 0); Serial.println("push LEFT"); }else if(jyuji_val_update & 0b0001){ //右の場合 mcp.pinMode(L_H_JOY, OUTPUT); mcp.digitalWrite(L_H_JOY, 1); Serial.println("push RIGHT"); }else{ mcp.pinMode(L_H_JOY, INPUT); } // X(青)ボタン if((btn_val_update & 0b00000100) >> 2){ mcp.pinMode(X_BTN, OUTPUT); mcp.digitalWrite(X_BTN, 0); Serial.println("push X"); }else{ mcp.pinMode(X_BTN, INPUT); } // B(黄)ボタン if((btn_val_update & 0b00000010) >> 1){ mcp.pinMode(B_BTN, OUTPUT); mcp.digitalWrite(B_BTN, 0); Serial.println("push B"); }else{ mcp.pinMode(B_BTN, INPUT); } // Y(緑)ボタン if((btn_val_update & 0b00001000) >> 3){ mcp.pinMode(Y_BTN, OUTPUT); mcp.digitalWrite(Y_BTN, 0); Serial.println("push Y"); }else{ mcp.pinMode(Y_BTN, INPUT); } // A(赤)ボタン if(btn_val_update & 0b00000001){ mcp.pinMode(A_BTN, OUTPUT); mcp.digitalWrite(A_BTN, 0); Serial.println("push A"); // }else if(digitalRead(INPUT1_BTN) == 0){ // pinMode(A_BTN, OUTPUT); // digitalWrite(A_BTN, 0); // Serial.println("push A (Outside)"); // }else{ mcp.pinMode(A_BTN, INPUT); } // LZボタン if((btn_val_update & 0b10000000) >> 7){ mcp.pinMode(LZ_BTN, OUTPUT); mcp.digitalWrite(LZ_BTN, 0); Serial.println("push LZ"); }else{ mcp.pinMode(LZ_BTN, INPUT); } // Lボタン if((btn_val_update & 0b01000000) >> 6){ mcp.pinMode(L_BTN, OUTPUT); mcp.digitalWrite(L_BTN, 0); Serial.println("push L"); }else{ mcp.pinMode(L_BTN, INPUT); } // RZボタン if((btn_val_update & 0b00100000) >> 5){ mcp.pinMode(RZ_BTN, OUTPUT); mcp.digitalWrite(RZ_BTN, 0); Serial.println("push RZ"); }else{ mcp.pinMode(RZ_BTN, INPUT); } // Rボタン if((btn_val_update & 0b00010000) >> 4){ mcp.pinMode(R_BTN, OUTPUT); mcp.digitalWrite(R_BTN, 0); Serial.println("push R"); }else{ mcp.pinMode(R_BTN, INPUT); } // データ更新 input_val_old = input_val; delay(20); }