オリジナルライドガジェットをつくる 〜カイザフォンX(テン)〜

あけましておめでとうございます。今年も細々と続けて参りますので、宜しくお願い致します。

さて、新年最初の作品は、冒頭動画のとおりで、オリジナルライドガジェット『カイザフォンX(テン)』です。

これを作った動機ですが、初めて『ファイズフォンX』の開閉ギミックを見たときに「このギミックはファイズフォンというよりかはカイザフォンだよなあ」と思ったことがきっかけです。

縦開きではなく横開き、ということですね。ただ、単純に見かけをカイザフォンっぽくしても、『5,5,5』のキー入力でいろんなギミックが発動するのはやっぱり違和感がありますし、またジクウドライバーに挿したときも『ライドガジェット!』と鳴るだけだとあんまり面白くないなあと思いましたので、「どうせならちゃんと未来のカイザフォンとして成立するもの作ろう!」と思い立った結果、過去に作った『大乱闘スマッシュブラザーズガシャット』や『ジーニアスフルボトル』並に複雑怪奇なものになってしまいました。

以下、カイザフォンXの特徴や工夫点をご紹介していきます。

1.ディスプレイ搭載

OLED(有機発光ダイオード)ディスプレイを搭載し、より携帯電話っぽいガジェットにしてみました。起動時にはそれっぽく、『SMART BRAIN』のロゴを表示するようにしています。

起動後の待受画面には、本家カイザフォンの待受画像を表示するようにしています。

変身完了後は、こんな感じでカイザのミッションメモリーを意識した画像を表示するようにしています。

細かいですが、「アーマータイム!」で上下逆さまに装填したときでも、カイザのミッションメモリー風の画像は逆さまにならずに表示するようにしています。いつもレジェンドライダーライドウォッチをジオウ・ゲイツ側に挿したときに上下逆さまになってしまうのが気になっていたので。

OLEDディスプレイはこんな感じで組み込んでいます。折りたたみギミックを省略してしまえばもっと簡単に組み込めたのですが、そこは妥協すべきではないと判断しましたので、ギリギリまでパーツをヤスリで削って組み込みました。

結果、ギリギリで折りたたみギミックを残したままディスプレイを組み込むことができました。本当にギリギリで、蓋閉じをする余裕はありませんでした。

ちなみに、使用したデバイスは以下です。

スイッチサイエンスさんで5,605円でした。なかなかお高いデバイスです。。。

テスト段階ではうまく動いてくれていたのですが、部品を組み上げていく途中でやらかしてしまったのか、出来上がった後に動作確認してみると、画面の下部に結構目立つ欠けが発生してしまいました。これは結構ショックだったのですが、部品を交換したり配線を見直したりするのは費用も時間もだいぶかかってしまいそうでしたので、現状でとりあえず良しとすることにしました。

2.音声差し替え

音声はCSM カイザギアの音声を録音して、MP3ファイルに変換したものを再生するようにしています。私自身はCSM カイザギアを所有していないため、音声収録には兄に協力頂きました。兄弟揃って特撮ヲタだとこういうときに捗ります。

MP3プレイヤーとスピーカーは、この部分に収納しています。元々のスピーカーはグリップ側にあるのですが、グリップ側にはマイコン基板やらバッテリーやらを詰め込む必要があったため、こちら側に何とかスペースを確保する形で実装しました。

こちらは何とか蓋閉じできました。スピーカーの部分はあとで穴を開けました。

使用したMP3プレイヤー(+SDカード)は、私の工作では毎度おなじみの以下のものです。

スピーカーは、元々の玩具組込みのものだとちょっとサイズが大きかったので、秋月で販売されているマイクロスピーカーに交換しています。

なお、動画では特に言及していませんが、待受状態のときにトリガーボタンを押すことで、私の方で勝手に厳選した草加雅人の名台詞を聞くことができるようになっています。以下、内容です。

  • 「変身!」
  • 「これも全て…乾巧って奴の仕業なんだ」
  • 「俺のことを好きにならない人間は邪魔なんだよ!」
  • 「君の力はこの程度…ということでいいのかな?」
  • 「カッコ悪いねぇ…ま、そんなもんか」
  • 「ひょっとして君は俺が嫌いなのかな?」
  • 「そう言うと思ったよ…じゃ、死んでもらおうかな」
  • 「邪魔なんだよ…俺の思い通りにならないものは全て!」

素敵ですね。

3.キー入力受付

先にも述べましたが、カイザが『555』で変身するのはやはり違和感がありますので、カイザフォンXとして作り上げるためには、『913』で変身できるようにするのは今回必ずクリアしなければいけないポイントでした。しかし、元々のファイズフォンXには『5』の部分にしかボタンがなく、そもそも『913』が入力できません。これをどう解決するかは結構頭を悩ませて、試しに12ボタンキーパッドを買ってみたりもしたのですが全然中に収まらず。。。ファイズフォンXはサイズ的にかなり小さく、各数字にボタンを割り当てるような実装は非常に困難でした。

最終的に、「2つのタッチ入力で、『なんちゃって』で913入力を再現する」というアイデアを思いつき、これを採用することにしました。どういうことかと言いますと、

グリップ部分の裏側に静電容量センサ(タッチセンサ)を仕込み、そこから2系統で配線を表に伸ばして。。。

グリップ部分の表側、ラベルの下に上下で銅テープを貼り付け、それぞれの銅テープと静電容量センサを半田付けで接続するようにしました。こうすると、ラベル上で各ボタンをタッチすると、タッチの認識としては「1,2,3,4,5,6」が同一、「7,8,9,*,0,#」が同一ということになります。それぞれの認識をA, Bと割り当てるとすると、キー入力は以下のように区別することができます:

9, 1, 3 変身 B, A, A
1, 0, 3 シングルモード A, B, A
1, 0, 6 バーストモード A, B, A
2, 7, 9 チャージ A, B, B
9, 8, 2, 1 サイドバッシャー B, B, A, A
3, 8, 2, 1 ジェットスライガー A, B, A, A

このように、「シングル/バーストモード」以外は、上下のタッチ領域をどういう順番でタッチされたかで区別することができます。「シングル/バーストモード」については、「シングルモードのときにABAが発生すればバーストモードに、バーストモードのときにABAが発生すればシングルモードに切り替える」という実装にすれば、それほど違和感なく遊ぶことができます。

こんな感じで、元々のキー入力の設定が上手い具合にばらけてくれていたおかげで、『913』だけではなく、元々のカイザフォンに実装されていた大体の入力受付は(なんちゃってではありますが)実装することができました。個人的にはサイドバッシャーまで実装できたのが嬉しいです。なお、あくまで「なんちゃって」ですので、実は「名護さん(753→BAA)」とかで入力してもカイザに変身できてしまうのですが、そこは使用者の気分的なところでカバーしてもらえればと思います。

ここで使用した静電容量センサ(タッチセンサ)は以下です。

12chまで個別に認識可能なので、その気になれば各数字ボタンを個別に認識させることも不可能ではなかったかもしれませんが、配線が恐ろしいことになりそうでしたので早々に諦めました。

4.シングル/バーストモード対応

キー入力で「シングル/バーストモード」になることは先に述べたとおりですが、それぞれのモードにしたときに銃声が鳴るだけだと面白くないかなと思いましたので、もう少し頑張ってみました。

まず、銃口の発光ギミックです。ここは元々のファイズフォンXのときから気になっていたところで、銃口というよりは「折りたたみギミック用のボタン」感の方が強かったので、ちゃんと銃口らしくなるように発光させてみました。

まず、ボタンの頭頂部に大きく穴を開けて、以下のクリアパーツをはめ込むことで光線銃っぽくなるようにしました。

それから発光用のLEDを仕込みました。。。と、言うのは簡単なのですが、「折りたたみギミック用のボタン」としての機能を残しつつ発光させるというのは地味に大変でした。

LEDと配線がボタン操作やグリップの収納ギミックに干渉しないようにするため、配線のルートやそのための穴あけ加工などは結構頭を使うことになりました。なお、使用した黄色LEDはこちらになります。

続いて、ディスプレイの残弾表示です。これは、「せっかくディスプレイ搭載したし」ということで盛り込んだ、独自解釈のギミックです。

弾数は12発ということで、それをインジケータで表示するようにしています。

弾を打つごとに残弾目盛りが減っていきます。シングルモードなら一つずつ、バーストモードなら連続して三つ減るようになっています。もちろん、『チャージ』を入力すれば、ちゃんと残弾数が回復します。

5.ジクウドライバー表示対応

せっかくカイザフォンXを作っても、ジクウドライバーに挿したときに『RIDE GADGET』やら『FAIZ 2003』とか表示されてしまうとやっぱりちょっとがっかりしてしまうよなー、でもジクウドライバーの表示をいじれるほどのスキルはないしなー、と悩みながらクレーンの丈様の解析結果一覧を眺めていたところで、一つの解決策を思いつきました。某世界の破壊者の強化形態のためにジクウドライバーに用意されていた言葉を表示させれば良いのではないかと。

そう、『COMPLETE』です。これなら、カイザフォンの変身完了音の『COMPLETE!』の意味合いとして表示させても違和感はないはずです。

ということで、シリコンとレジンで認識ピンのマスタープレートを作成し、

クレーンの丈様の解析結果一覧を参考にディケイト・コンプリートフォーム用のピン配列になるようにピンを削りました。この型取りの方法は以前にフルボトルでやったときと全く同じですので、宜しければそちらもご参照ください。

ちなみに細かい話ですが、元々ファイズフォンXはジクウドライバーのLスロット側(いわゆるレジェンドライダー側)に挿すことを前提に設計されています。今回、カイザフォンXは単体使用可能を前提としてRスロット側(いわゆるジオウ・ゲイツ側)に挿すつもりだったのですが、そうすると、せっかく複製したプレートが、そのままではカイザフォンに装着できなくなります。プレートの向きが反対になるので、ネジ止めの穴の位置が合わなくなってしまうのです。

これを解消するために、ネジ止めの部分を一度切り取って、位置を変更してから接着剤で仮止めした後、プラリペアでガチガチに固めました。プラリペアは「うっかり切ってしまったプラ部品を何とか繋ぎ直したい、でも接着剤だと強度不足」というようなケースには大変便利なものですので、一つ常備していると何かと役立ちます。

6.ジクウドライバー装填・回転認識対応

ジクウドライバーはスロットの根元に本体の回転に応じて上下するパーツがあって、

ライドウォッチ側は、このパーツに自身のスイッチを押されることで、自身がジクウドライバーにセットされて回転したことを認識します。

しかし、

ファイズフォンXには元々ジクウドライバーとの連動ギミックがない(←認識ピンをジクウドライバーが読み取るだけ)ので、自身のドライバーへの装填および回転を認識するためのスイッチがありません。そこで、装填・回転を認識するための手段を何かしら後付けで用意してやる必要があります。

まず、装填の認識自体は簡単です。

この部分にディテクタスイッチ(←秋月で購入)を埋め込んでやれば、

こんな感じで、ジクウドライバーに装填するとスイッチが押されるので、自身が装填されたことを認識することができます。

内部配線はこんな感じです。

これでドライバーへの装填自体は認識できますが、カイザフォンとしては自身がRスロットとLスロットのどちらに装填されたかは認識できていません。また、当然これだけでは自身の回転も認識できません。

これらの問題は、加速度センサを使うことで解決させました。使用したデバイスはこちらです。

先ほどの装填認識のスイッチが押された際に、Y軸のプラスマイナスのどちらの方に重力加速度がかかっているかをチェックすることで、自身がRスロット側に装填されているのかLスロット側に装填されているのかを判別することができるようになります。

さらに、X軸に対する加速度のかかり方の変化をチェックすることで、自身が回転しているかどうかを判別することが可能になります。加速度のかかり方は重力加速度とジクウドライバーを回転させたときの勢いによって決まってきますが、どのように加速度が変化するにしても、ドライバーが一回転するとき、X軸は「回転前に必ず静止する(加速度が0付近になる)」「回転中に必ず一度は重力加速度以上の加速度がかかる」「回転後に必ず静止する(加速度が0付近になる)」を満たすので、この3点だけをチェックすれば、比較的簡単にドライバーの回転を認識することができます。

7.制御マイコン

以上が主な工夫ポイントですが、今回制御に使っているマイコンをいつものArduino Pro miniから変更しているので、併せてご紹介しておきます。

スイッチサイエンスさんで購入できます。Arduino Pro mini (3.3V, 8MHz)の互換機ですが、

  • リチウムイオンポリマー充電池を直接接続できる
  • リチウムイオンポリマー充電池を充電できる
  • 通常のArduino Pro miniよりも低消費電力

という特徴があります。今回初めて試験的に触ってみたところ、これまでMP3プレイヤー(DFPlayer mini)の電源をArduinoのVccから取ろうとするとうまく動かなかったものが動いてくれるようになったり、スライド式の電源スイッチの取り付けスペースが初めから用意されていたりと、色々配線をシンプルにできそうでしたので、今回採用してみました。今後も活躍の機会があると思います。

ついでにいうと、電源はおなじみリチウムイオンポリマー充電池です。

100mAhのタイプのものにすればもっとスペース的に余裕のある工作にできたのですが、今回はディスプレイを搭載しているため、できるだけ容量の大きい電池を無理やり組み込むことにしました。

おかげでなかなかの大惨事になりました。

8.塗装

塗装は正直申し上げまして、自分より上手な方々がたくさんいらっしゃるのであんまり語りたくないのですが、一応参考程度に使った塗料は載せておきます。

カバー側は下地としてブラックを吹っかけた後、その上からライトガンメタルを吹っかけて重ためにしています。一応、元々のカイザフォンに近めの色を選んだつもりです。

リング部分はゴールドだけだとゴールド過ぎて(?)何だかカイザというよりはオーガっぽくなった気がしたので、さらに上からクリアーイエローを吹っかけて無理やり黄色を強めてみました。塗装についてはちゃんと勉強していないので、この辺は全部感覚です。

 

以上が、今回作ったカイザフォンXの特徴・工夫点になります。参考として、回路図とソースコードは最後に掲載いたします。

 

以上、カイザフォンXのご紹介でした。万丈ライドウォッチを作ってから比較的すぐにとりかかったのですが、がっつり二ヶ月ほどかかってしまいました。今回の工作でそれだけ時間がかかってしまった一番の理由は、カイザフォンXが折りたたみギミックを搭載しており、全体的にスペースが少ない上に、可動のことを考えながら全体に散らばった部品たちを配線しなければならなかったからです。それゆえに、全体をどういう設計にすれば良いかに非常に頭を悩ませることになりました。結局、実際に手を動かしてみないとわからない部分が多かったため、かなりの見切り発車で制作をスタートすることになってしまいました。最終的に、ディスプレイの不具合は出てしまったものの、何とか遊べるカタチまで持っていくことができて、正直ホッとしています。

次に作りたいものはもう決まっていて、このカイザフォンXよりは小規模なものになる予定です。が、果たして出来上がるのはいつになることやら。。。

今年もこんな感じですが、お付き合い頂けますと幸いです。宜しくお願い致します。

 

以下、ご参考程度に。

回路図

配線が複雑なので、とにかく間違えないように間違えないように、ヒヤヒヤしながら半田付け&グルーガン固定をしていきました。

ソースコード

/*-------------------------- 基本定義 --------------------------*/
#define ENTER_PIN   A0
#define TRIGGER_PIN A1
#define DRIVER_PIN  A2

#define LOOP_INTERVAL_MS 20

#define ON  LOW
#define OFF HIGH

#define STATE_INIT                     0
#define STATE_SINGLE_MODE              1
#define STATE_BURST_MODE               2
#define STATE_STANDING_BY              3
#define STATE_RIDER_TIME_READY         4
#define STATE_ARMOR_TIME_READY         5
#define STATE_RIDER_TIME_CHANGED       6
#define STATE_ARMOR_TIME_CHANGED       7
#define STATE_RIDER_TIME_FINISH_READY  8
#define STATE_ARMOR_TIME_FINISH_READY  9

boolean prev_enter    = OFF;
boolean enter         = OFF;
boolean prev_trigger  = OFF;
boolean trigger       = OFF;
boolean driver        = OFF;
boolean prev_driver   = OFF;
uint8_t state = STATE_INIT;
uint8_t prev_state = STATE_INIT;
uint8_t num_of_bullet = 12;

/*-------------------------- ディスプレイ関係定義 --------------------------*/
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1351.h>
#include <SD.h>
#include <SPI.h>

// ディスプレイ関係のピン
#define DC     4
#define CS     5
#define RST    6
#define SD_CS 10
#define MOSI  11
#define SCLK  13

// ディスプレイ描画色定義
#define BLACK           0x0000
#define RED             0xF800
#define YELLOW          0xFFE0
#define WHITE           0xFFFF
#define PURPLE          0xF81F
#define DARKGRAY        0x2104
#define SILVER          0xC618

uint8_t display_counter = 0;
boolean is_yellow_charge = true;

Adafruit_SSD1351 tft = Adafruit_SSD1351(CS, DC, RST);
File bmpFile;
int bmpWidth, bmpHeight;
uint8_t bmpDepth, bmpImageoffset;

#define BUFFPIXEL 20

void bmpDraw(char *filename, uint8_t x, uint8_t y) {

  File     bmpFile;
  int      bmpWidth, bmpHeight;   // W+H in pixels
  uint8_t  bmpDepth;              // Bit depth (currently must be 24)
  uint32_t bmpImageoffset;        // Start of image data in file
  uint32_t rowSize;               // Not always = bmpWidth; may have padding
  uint8_t  sdbuffer[3*BUFFPIXEL]; // pixel buffer (R+G+B per pixel)
  uint8_t  buffidx = sizeof(sdbuffer); // Current position in sdbuffer
  boolean  goodBmp = false;       // Set to true on valid header parse
  boolean  flip    = true;        // BMP is stored bottom-to-top
  int      w, h, row, col;
  uint8_t  r, g, b;
  uint32_t pos = 0;

  if((x >= tft.width()) || (y >= tft.height())) return;

  // Open requested file on SD card
  if ((bmpFile = SD.open(filename)) == NULL) {
    return;
  }

  // Parse BMP header
  if(read16(bmpFile) == 0x4D42) { // BMP signature
    read32(bmpFile);
    (void)read32(bmpFile); // Read & ignore creator bytes
    bmpImageoffset = read32(bmpFile); // Start of image data
    // Read DIB header
    read32(bmpFile);
    bmpWidth  = read32(bmpFile);
    bmpHeight = read32(bmpFile);
    if(read16(bmpFile) == 1) { // # planes -- must be '1'
      bmpDepth = read16(bmpFile); // bits per pixel
      if((bmpDepth == 24) && (read32(bmpFile) == 0)) { // 0 = uncompressed

        goodBmp = true; // Supported BMP format -- proceed!

        // BMP rows are padded (if needed) to 4-byte boundary
        rowSize = (bmpWidth * 3 + 3) & ~3;

        // If bmpHeight is negative, image is in top-down order.
        // This is not canon but has been observed in the wild.
        if(bmpHeight < 0) {
          bmpHeight = -bmpHeight;
          flip      = false;
        }

        // Crop area to be loaded
        w = bmpWidth;
        h = bmpHeight;
        if((x+w-1) >= tft.width())  w = tft.width()  - x;
        if((y+h-1) >= tft.height()) h = tft.height() - y;

        for (row=0; row<h; row++) { // For each scanline...
          tft.goTo(x, y+row);

          // Seek to start of scan line.  It might seem labor-
          // intensive to be doing this on every line, but this
          // method covers a lot of gritty details like cropping
          // and scanline padding.  Also, the seek only takes
          // place if the file position actually needs to change
          // (avoids a lot of cluster math in SD library).
          if(flip) // Bitmap is stored bottom-to-top order (normal BMP)
            pos = bmpImageoffset + (bmpHeight - 1 - row) * rowSize;
          else     // Bitmap is stored top-to-bottom
            pos = bmpImageoffset + row * rowSize;
          if(bmpFile.position() != pos) { // Need seek?
            bmpFile.seek(pos);
            buffidx = sizeof(sdbuffer); // Force buffer reload
          }

          // optimize by setting pins now
          for (col=0; col<w; col++) { // For each pixel...
            // Time to read more pixel data?
            if (buffidx >= sizeof(sdbuffer)) { // Indeed
              bmpFile.read(sdbuffer, sizeof(sdbuffer));
              buffidx = 0; // Set index to beginning
            }

            // Convert pixel from BMP to TFT format, push to display
            b = sdbuffer[buffidx++];
            g = sdbuffer[buffidx++];
            r = sdbuffer[buffidx++];

            tft.drawPixel(x+col, y+row, tft.Color565(r,g,b));
            // optimized!
            //tft.pushColor(tft.Color565(r,g,b));
          } // end pixel
        } // end scanline
      } // end goodBmp
    }
  }

  bmpFile.close();
  if(!goodBmp){
    ;
  }
}

// These read 16- and 32-bit types from the SD card file.
// BMP data is stored little-endian, Arduino is little-endian too.
// May need to reverse subscript order if porting elsewhere.

uint16_t read16(File f) {
  uint16_t result;
  ((uint8_t *)&result)[0] = f.read(); // LSB
  ((uint8_t *)&result)[1] = f.read(); // MSB
  return result;
}

uint32_t read32(File f) {
  uint32_t result;
  ((uint8_t *)&result)[0] = f.read(); // LSB
  ((uint8_t *)&result)[1] = f.read();
  ((uint8_t *)&result)[2] = f.read();
  ((uint8_t *)&result)[3] = f.read(); // MSB
  return result;
}

void reset_display(){
  tft.fillScreen(BLACK);
  display_counter = 0;
}

void draw_listening(){
  bmpDraw("2.bmp", 0, 0);
}

void draw_kaixa(){
  reset_display();

  tft.fillCircle(64, 64, 63, YELLOW);
  tft.fillCircle(64, 64, 57, PURPLE);
  for(uint8_t i=0;i<57;i+=3){
    tft.drawCircle(64, 64, i, BLACK);
  }

  for(uint8_t i=0;i<8;i++){
    tft.drawLine(0,18+i,23,18+i,YELLOW);
  }
  for(uint8_t i=0;i<8;i++){
    tft.drawLine(18+i,21,103+i,107,YELLOW);
  }
  for(uint8_t i=0;i<8;i++){
    tft.drawLine(105,103+i,127,103+i,YELLOW);
  }

  for(uint8_t i=0;i<8;i++){
    tft.drawLine(127,18+i,105,18+i,YELLOW);
  }
  for(uint8_t i=0;i<8;i++){
    tft.drawLine(110-i,21,25-i,107,YELLOW);
  }
  for(uint8_t i=0;i<8;i++){
    tft.drawLine(23,103+i,0,103+i,YELLOW);
  }

  for(uint8_t i=0;i<4;i++){
    tft.drawFastVLine(54+i, 9-i, 41+i*2, SILVER);
    tft.drawFastVLine(74-i, 9-i, 41+i*2, SILVER);
  }
  for(uint8_t i=0;i<7;i++){
    tft.drawFastVLine(58+i, 6, 48+i, DARKGRAY);
    tft.drawFastVLine(70-i, 6, 48+i, DARKGRAY);
  }
  for(uint8_t i=0;i<6;i++){
    tft.fillRect(60, 8+i*8, 8, 5, SILVER);
  }
  tft.fillTriangle(60, 54, 67, 54, 64, 57, RED);
}

void draw_single_burst_mode(){
  reset_display();
  for(uint8_t i=0;i<24;i++){
    if(i<18){
      tft.drawFastVLine(    i,     0, 20+i, YELLOW);
      tft.drawFastVLine(    i, 107-i, 20+i, YELLOW);
      tft.drawFastVLine(127-i,     0, 20+i, YELLOW);
      tft.drawFastVLine(127-i, 107-i, 20+i, YELLOW);
    }else{
      tft.drawFastVLine(    i,     i,   20, YELLOW);
      tft.drawFastVLine(    i, 107-i,   20, YELLOW);
      tft.drawFastVLine(127-i,    i,    20, YELLOW);
      tft.drawFastVLine(127-i, 107-i,   20, YELLOW);
    }
  }

  tft.fillRect(54, 0, 20, 31, RED);

  for(uint8_t i=0;i<num_of_bullet;i++){
    tft.fillRect(29, 32+i*8, 70, 7, YELLOW);
  }
}

void draw_standing_by(){
  if(display_counter < 64){
    if(is_yellow_charge){
      tft.drawRect(64-display_counter, 64-display_counter, display_counter*2, display_counter*2, YELLOW);
    }else{
      tft.drawRect(64-display_counter, 64-display_counter, display_counter*2, display_counter*2, PURPLE);
    }
    display_counter += 4;
  }else{
    is_yellow_charge = !is_yellow_charge;
    display_counter = 0;
  }
}

/*-------------------------- タッチ関係定義 --------------------------*/
#define TOUCH_COUNT_MAX  4
#define TOUCH_ON  HIGH
#define TOUCH_OFF LOW
#define TOUCH_DETECT_COUNT 5
#define TOUCH_NONE 0
#define TOUCH_A    1
#define TOUCH_B    2

#include "Adafruit_MPR121.h"
#ifndef _BV
#define _BV(bit) (1 << (bit))
#endif

// You can have up to 4 on one i2c bus but one is enough for testing!
Adafruit_MPR121 cap = Adafruit_MPR121();
uint16_t lasttouched = 0;
uint16_t currtouched = 0;

const uint8_t CODE_CHANGE[]       = {TOUCH_B, TOUCH_A, TOUCH_A};
const uint8_t CODE_SINGLE_BURST[] = {TOUCH_A, TOUCH_B, TOUCH_A};
const uint8_t CODE_CHARGE[]       = {TOUCH_A, TOUCH_B, TOUCH_B};
const uint8_t CODE_SIDE_BASHER[]  = {TOUCH_B, TOUCH_B, TOUCH_A, TOUCH_A};
const uint8_t CODE_JET_SLIGER[]   = {TOUCH_A, TOUCH_B, TOUCH_A, TOUCH_A};

uint8_t touch_history[] = {TOUCH_NONE,TOUCH_NONE,TOUCH_NONE,TOUCH_NONE};
uint8_t touch_index  = 0;

void reset_touch(){
  for(uint8_t i=0;i<TOUCH_COUNT_MAX;i++){
    touch_history[i] = TOUCH_NONE;
  }
  touch_index = 0;
}

/*-------------------------- 音声関係定義 --------------------------*/
#include <SoftwareSerial.h>
#include <DFRobotDFPlayerMini.h>

#define SOUND_POWER_ON       1
#define SOUND_STANDING_BY    2
#define SOUND_EJECT          3
#define SOUND_ERROR          4
#define SOUND_SINGLE_MODE    5
#define SOUND_BURST_MODE     6
#define SOUND_CHARGE         7
#define SOUND_SIDE_BASHER    8
#define SOUND_JET_SLIGER     9
#define SOUND_PUSH_1        10
#define SOUND_PUSH_2        11
#define SOUND_PUSH_3        12
#define SOUND_SINGLE_BULLET 13
#define SOUND_BURST_BULLET  14
#define SOUND_EMPTY         15
#define SOUND_RIDER_TIME    16
#define SOUND_ARMOR_TIME    17
#define SOUND_RIDER_TIME_FINISH_READY 18
#define SOUND_ARMOR_TIME_FINISH_READY 19
#define SOUND_RIDER_TIME_FINISH 20
#define SOUND_ARMOR_TIME_FINISH 21
#define VOICE_KUSAKA 22
#define VOICE_KUSAKA_ADVERT 1

#define ARMOR_TIME_WAIT 6500
#define RIDER_TIME_WAIT 1000
#define FINISH_WAIT_SHORT 1000
#define FINISH_WAIT_LONG  2000

SoftwareSerial ss_mp3_player(2, 3); // RX, TX
DFRobotDFPlayerMini mp3_player;
uint8_t voice_kusaka_index = 0;

void play_and_delay(uint8_t id, uint16_t ms){ // タッチセンサの誤検知防止
  mp3_player.playMp3Folder(id);
  delay(ms);
}

void push_sound(){
  switch(touch_index){
  case 0: play_and_delay(SOUND_PUSH_1,100); break;
  case 1: play_and_delay(SOUND_PUSH_2,100); break;
  case 2: play_and_delay(SOUND_PUSH_3,100); break;
  case 3: play_and_delay(SOUND_PUSH_3,100); break;
  default: ;
  }
}

/*-------------------------- 加速度センサ関係定義 --------------------------*/
#include <Wire.h>
#include <Adafruit_MMA8451.h>
#include <Adafruit_Sensor.h>

/*
ジクウドライバーの回転判定方法

第一条件:
 加速度xの値が3回連続で-1〜+1に入る
 このとき、加速度yの値が負であればライダータイム、正であればアーマータイム待ち状態とする

第二条件:
 加速度xの値が3回連続でおよそ重力加速度(9.8m/s^2)以上になる(*閾値自体は誤差を考慮して9.0m/s^2に設定)

第三条件:
 加速度xの値が3回連続で-1〜+1に入り、かつ、加速度yの値の正負が第一条件のときと一致している

上記全てを順に満たしたら、ライダータイム or アーマータイム発動

*/

#define ACC_STABLE_COUNT 3

Adafruit_MMA8451 mma = Adafruit_MMA8451();

uint8_t acc_x_check_counter = 0;
boolean prev_acc_x_near_0  = false;
boolean prev_acc_x_over_g  = false;
boolean rotate_check_flags[] = {false, false, false};

void reset_acc_state(){
  for(uint8_t i=0;i<3;i++){
    rotate_check_flags[i] = false;
  }
}

/*-------------------------- LED関係定義 --------------------------*/
#define LED_PIN 9 // to use PWM
#define LED_BRIGHTNESS 255 // 255が上限値

unsigned long inc_dim_start_time = 0;

void led_base_pattern_on(){
  analogWrite(LED_PIN, LED_BRIGHTNESS);
}

void led_base_pattern_off(){
  analogWrite(LED_PIN, 0);
}

void led_base_pattern_dim(int interval_ms, uint8_t steps){
  unsigned long now = millis();
  if(inc_dim_start_time == 0){
    inc_dim_start_time = now;
  }
  int ms_per_step = interval_ms / steps;
  int current_step = (now - inc_dim_start_time) / ms_per_step;
  uint8_t l_step = LED_BRIGHTNESS/steps;
  analogWrite(LED_PIN, l_step*(steps-current_step));
  if(now - inc_dim_start_time > interval_ms){
    inc_dim_start_time = 0;
  }
}

void led_pattern_single_bullet(uint16_t led_counter_ms){
  if(led_counter_ms <= 80){                              led_base_pattern_on();}
  else if(80 < led_counter_ms && led_counter_ms <= 880){ led_base_pattern_dim(800, 10);}
  else{                                                  led_base_pattern_off();}
}

void led_pattern_burst_bullet(uint16_t led_counter_ms){
  if(led_counter_ms <= 80){                                led_base_pattern_on();}
  else if(  80 < led_counter_ms && led_counter_ms <= 140){ led_base_pattern_off();}
  else if( 140 < led_counter_ms && led_counter_ms <= 220){ led_base_pattern_on();}
  else if( 280 < led_counter_ms && led_counter_ms <= 360){ led_base_pattern_off();}
  else if( 360 < led_counter_ms && led_counter_ms <= 440){ led_base_pattern_on();}
  else if( 440 < led_counter_ms && led_counter_ms <=1240){ led_base_pattern_dim(800, 10);}
  else{                                                    led_base_pattern_off();}
}

void led_pattern_charge(uint16_t led_counter_ms){
  if(led_counter_ms <= 80){                              led_base_pattern_on();}
  else if(80 < led_counter_ms && led_counter_ms <= 880){ led_base_pattern_dim(800, 10);}
  else{                                                  led_base_pattern_off();}
}

/*-------------------------- セットアップ処理 --------------------------*/

void setup() {
  Serial.begin(115200);
  pinMode(ENTER_PIN,   INPUT_PULLUP);
  pinMode(TRIGGER_PIN, INPUT_PULLUP);
  pinMode(DRIVER_PIN,  INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);

  // ---------- 加速度センサセットアップ ----------
  if(!mma.begin()){
    while(1);
  }
  mma.setRange(MMA8451_RANGE_2_G);

  // ---------- タッチセンサセットアップ ----------
  if (!cap.begin(0x5A)) {
    while (1);
  }

  // ---------- ディスプレイセットアップ ----------
  pinMode(CS, OUTPUT);
  digitalWrite(CS, HIGH);
  // initialize the OLED
  tft.begin();
  tft.setRotation(2); // 座標を180度回転させる
  tft.fillScreen(BLACK);
  delay(500);
  if (!SD.begin(SD_CS)) {
    return;
  }

  // ---------- MP3プレイヤーセットアップ ----------
  ss_mp3_player.begin(9600);
  if (!mp3_player.begin(ss_mp3_player)) {  //Use softwareSerial to communicate with mp3.
    while(true);
  }
  mp3_player.setTimeOut(500); //Set serial communictaion time out 500ms
  mp3_player.volume(20);  //Set volume value (0~30).

  // ---------- 起動エフェクト ----------
  bmpDraw("1.bmp", 0, 0);
  play_and_delay(SOUND_POWER_ON,3000);
  draw_listening();
}

/*-------------------------- ループ処理 --------------------------*/

void loop() {
  //------------------------------ キーパッド入力受付 ------------------------------

  switch(state){ // 誤操作防止のため、ドライバー非装填時のみ入力を受け付ける
  case STATE_INIT:
  case STATE_SINGLE_MODE:
  case STATE_BURST_MODE:
    currtouched = cap.touched();

    if((currtouched & _BV(11)) && !(lasttouched & _BV(11))){
      push_sound();
      touch_history[touch_index] = TOUCH_A;
      touch_index++;
      if(touch_index == TOUCH_COUNT_MAX){
        touch_index = 0;
      }
    }

    if((currtouched & _BV(0)) && !(lasttouched & _BV(0))){
      push_sound();
      touch_history[touch_index] = TOUCH_B;
      touch_index++;
      if(touch_index == TOUCH_COUNT_MAX){
        touch_index = 0;
      }
    }

    lasttouched = currtouched;
    break;
  default: ;
  }

  //------------------------------ エンター入力受付 ------------------------------
  enter = digitalRead(ENTER_PIN);

  if(prev_enter == OFF && enter == ON){
    switch(state){
    case STATE_INIT:
    case STATE_SINGLE_MODE:
    case STATE_BURST_MODE:
    case STATE_STANDING_BY:
      if(touch_history[TOUCH_COUNT_MAX-1] != TOUCH_NONE){
        // 4回入力判定
        if(memcmp(touch_history, CODE_SIDE_BASHER, TOUCH_COUNT_MAX) == 0){
          play_and_delay(SOUND_SIDE_BASHER, 300);
        }else if(memcmp(touch_history, CODE_JET_SLIGER, TOUCH_COUNT_MAX) == 0){
          play_and_delay(SOUND_JET_SLIGER, 300);
        }else{
          play_and_delay(SOUND_ERROR, 300);
        }
        state = STATE_INIT;
        reset_touch();
      }else{
        // 3回入力判定
        if(memcmp(touch_history, CODE_CHANGE, TOUCH_COUNT_MAX-1) == 0){
          mp3_player.playMp3Folder(SOUND_STANDING_BY);
          state = STATE_STANDING_BY;
        }else if(memcmp(touch_history, CODE_SINGLE_BURST, TOUCH_COUNT_MAX-1) == 0){
          if(state == STATE_SINGLE_MODE){
            play_and_delay(SOUND_BURST_MODE, 300);
            state = STATE_BURST_MODE;
          }else{
            play_and_delay(SOUND_SINGLE_MODE, 300);
            state = STATE_SINGLE_MODE;
          }
        }else if(memcmp(touch_history, CODE_CHARGE, TOUCH_COUNT_MAX-1) == 0){
          if(!(state == STATE_SINGLE_MODE || state == STATE_BURST_MODE)){
            draw_single_burst_mode();
          }
          play_and_delay(SOUND_CHARGE, 500);
          for(uint8_t i=0;i<50;i++){
            led_pattern_charge(i*LOOP_INTERVAL_MS);
            delay(LOOP_INTERVAL_MS);
          }
          for(uint8_t i=num_of_bullet;i<12;i++){
            tft.fillRect(29, 32+i*8, 70, 7, YELLOW);
          }
          num_of_bullet = 12;
          if(!(state == STATE_SINGLE_MODE || state == STATE_BURST_MODE)){
            delay(3000);
            reset_display();
            draw_listening();
          }
        }else{
          play_and_delay(SOUND_ERROR, 1500);
          state = STATE_INIT;
        }
        reset_touch();
      }
      break;
    case STATE_RIDER_TIME_CHANGED:
      mp3_player.playMp3Folder(SOUND_RIDER_TIME_FINISH_READY);
      state = STATE_RIDER_TIME_FINISH_READY;
      break;
    case STATE_ARMOR_TIME_CHANGED:
      mp3_player.playMp3Folder(SOUND_ARMOR_TIME_FINISH_READY);
      state = STATE_ARMOR_TIME_FINISH_READY;
      break;
    default:
      ;
    }
  }

  prev_enter   = enter;

  //------------------------------ トリガー入力受付 ------------------------------
  trigger = digitalRead(TRIGGER_PIN);
  if(prev_trigger == OFF && trigger == ON){
    switch(state){
    case STATE_INIT:
      // 順に草加の音声再生
      mp3_player.playMp3Folder(VOICE_KUSAKA + voice_kusaka_index);
      voice_kusaka_index++;
      if(voice_kusaka_index == 8){
        voice_kusaka_index = 0;
      }
      break;
    case STATE_STANDING_BY:
      // 順に草加の音声再生
      mp3_player.advertise(VOICE_KUSAKA_ADVERT + voice_kusaka_index);
      voice_kusaka_index++;
      if(voice_kusaka_index == 8){
        voice_kusaka_index = 0;
      }
      break;
    case STATE_SINGLE_MODE:
      if(num_of_bullet > 0){
        mp3_player.playMp3Folder(SOUND_SINGLE_BULLET);
        for(uint8_t i=0;i<50;i++){
          led_pattern_single_bullet(i*LOOP_INTERVAL_MS);
          if(i == 0){
            tft.fillRect(29, 32+(num_of_bullet-1)*8, 70, 7, WHITE);
          }else if(i == 10){
            tft.fillRect(29, 32+(num_of_bullet-1)*8, 70, 7, BLACK);
            num_of_bullet--;
          }
          delay(LOOP_INTERVAL_MS);
        }
      }else{
        mp3_player.playMp3Folder(SOUND_EMPTY);
        tft.fillRect(54, 0, 20, 31, WHITE);
        delay(100);
        tft.fillRect(54, 0, 20, 31, RED);
      }
      break;
    case STATE_BURST_MODE:
      if(num_of_bullet >= 3){
        mp3_player.playMp3Folder(SOUND_BURST_BULLET);
        for(uint8_t i=0;i<70;i++){
          led_pattern_burst_bullet(i*LOOP_INTERVAL_MS);
          if(i == 0 || i == 10 || i == 20){
            tft.fillRect(29, 32+(num_of_bullet-1)*8, 70, 7, WHITE);
          }else if(i == 5 || i == 15 || i == 25){
            tft.fillRect(29, 32+(num_of_bullet-1)*8, 70, 7, BLACK);
            num_of_bullet--;
          }
          delay(LOOP_INTERVAL_MS);
        }
      }else{
        mp3_player.playMp3Folder(SOUND_EMPTY);
        tft.fillRect(54, 0, 20, 31, WHITE);
        delay(100);
        tft.fillRect(54, 0, 20, 31, RED);
      }
      break;
    default:
      ;
    }
  }

  prev_trigger = trigger;

  //------------------------------ ドライバー装填検知・加速度センサ処理 ------------------------------
  driver  = digitalRead(DRIVER_PIN);
  if(driver == ON){
    switch(state){
    case STATE_STANDING_BY:
    case STATE_RIDER_TIME_READY:
    case STATE_ARMOR_TIME_READY:
    case STATE_RIDER_TIME_CHANGED:
    case STATE_ARMOR_TIME_CHANGED:
    case STATE_RIDER_TIME_FINISH_READY:
    case STATE_ARMOR_TIME_FINISH_READY:
    { // このカッコは、case文の中でacc_x, acc_yのスコープを閉じるために必要
      sensors_event_t event;
      mma.getEvent(&event);
      float acc_x = event.acceleration.x;
      float acc_y = event.acceleration.y;

      // 1つめの条件チェック
      if(rotate_check_flags[0] == false){

        if((-1 < acc_x) && (acc_x < 1)){
          if(prev_acc_x_near_0 == true){
            acc_x_check_counter++;
          }
          prev_acc_x_near_0 = true;
        }else{
          prev_acc_x_near_0 = false;
          acc_x_check_counter = 0;
        }

        if(acc_x_check_counter == ACC_STABLE_COUNT){
          //Serial.println(F("1st check clear!"));
          rotate_check_flags[0] = true;
          acc_x_check_counter = 0;
          prev_acc_x_near_0 = false;
          float acc_y = event.acceleration.y;
          if(acc_y < 0){
            if(state == STATE_STANDING_BY){
              state = STATE_RIDER_TIME_READY;
            }
            /*else if(state == STATE_RIDER_TIME_CHANGED){
              state = STATE_RIDER_TIME_FINISH_READY;
            }
            */
          }else if(acc_y > 0){
            if(state == STATE_STANDING_BY){
              state = STATE_ARMOR_TIME_READY;
            }
            /*else if(state == STATE_ARMOR_TIME_CHANGED){
              state = STATE_ARMOR_TIME_FINISH_READY;
            }
            */
          }
        }
      }

      // 2つめの条件チェック
      if(rotate_check_flags[0] == true && rotate_check_flags[1] == false){

        if(acc_x <= -9.0){
          if(prev_acc_x_over_g == true){
            acc_x_check_counter++;
          }
          prev_acc_x_over_g = true;
        }else{
          prev_acc_x_over_g = false;
          acc_x_check_counter = 0;
        }

        if(acc_x_check_counter == ACC_STABLE_COUNT){
          //Serial.println(F("2nd check clear!"));
          rotate_check_flags[1] = true;
          acc_x_check_counter = 0;
          prev_acc_x_over_g = false;
        }
      }

      // 3つめの条件チェック
      if(rotate_check_flags[0] == true && rotate_check_flags[1] == true){

        if((-1 < acc_x) && (acc_x < 1)){
          if(prev_acc_x_near_0 == true){
            acc_x_check_counter++;
          }
          prev_acc_x_near_0 = true;
        }else{
          prev_acc_x_near_0 = false;
          acc_x_check_counter = 0;
        }

        if(acc_x_check_counter == ACC_STABLE_COUNT){
          if(((state == STATE_RIDER_TIME_READY || state == STATE_RIDER_TIME_CHANGED || state == STATE_RIDER_TIME_FINISH_READY) && (acc_y < 0)) ||
             ((state == STATE_ARMOR_TIME_READY || state == STATE_ARMOR_TIME_CHANGED || state == STATE_ARMOR_TIME_FINISH_READY) && (acc_y > 0))){
            //Serial.println(F("3rd check clear!"));
            rotate_check_flags[2] = true;
          }else{
            acc_x_check_counter = 0;
            prev_acc_x_near_0 = false;
          }
        }
      }

      // 回転条件を満たしたかチェック
      if(rotate_check_flags[0] == true && rotate_check_flags[1] == true && rotate_check_flags[2] == true){
        mp3_player.pause();
        switch(state){
        case STATE_RIDER_TIME_READY:
          reset_display(); // 音声再生開始まで間があるので、先にディスプレイをリセットしておく
          delay(RIDER_TIME_WAIT);
          mp3_player.playMp3Folder(SOUND_RIDER_TIME);
          state = STATE_RIDER_TIME_CHANGED;
          break;
        case STATE_RIDER_TIME_CHANGED:
          delay(RIDER_TIME_WAIT);
          mp3_player.playMp3Folder(SOUND_RIDER_TIME);
          break;
        case STATE_RIDER_TIME_FINISH_READY:
          delay(FINISH_WAIT_LONG);
          mp3_player.playMp3Folder(SOUND_RIDER_TIME_FINISH);
          state = STATE_RIDER_TIME_CHANGED;
          break;
        case STATE_ARMOR_TIME_READY:
          reset_display(); // 音声再生開始まで間があるので、先にディスプレイをリセットしておく
          tft.setRotation(0); // 座標を一時的に180度回転させる
          delay(ARMOR_TIME_WAIT);
          mp3_player.playMp3Folder(SOUND_ARMOR_TIME);
          state = STATE_ARMOR_TIME_CHANGED;
          delay(1500); // 「アーマータイム!」の後に描画が走るように、少しディレイをかける
          break;
        case STATE_ARMOR_TIME_CHANGED:
          delay(ARMOR_TIME_WAIT);
          mp3_player.playMp3Folder(SOUND_ARMOR_TIME);
          break;
        case STATE_ARMOR_TIME_FINISH_READY:
          delay(FINISH_WAIT_SHORT);
          mp3_player.playMp3Folder(SOUND_ARMOR_TIME_FINISH);
          state = STATE_ARMOR_TIME_CHANGED;
          break;
        default: ;
        }
        reset_acc_state();
      }
      break;
    }
    default:
      ;
    }
  }

  if(prev_driver == OFF && driver == ON){
    mp3_player.pause();
    if(state != STATE_STANDING_BY){
      play_and_delay(SOUND_ERROR, 300);
      state = STATE_INIT;
      reset_acc_state();
    }
  }

  if(prev_driver == ON && driver == OFF){
    switch(state){
    case STATE_STANDING_BY:
    case STATE_RIDER_TIME_READY:
    case STATE_ARMOR_TIME_READY:
    case STATE_RIDER_TIME_CHANGED:
    case STATE_ARMOR_TIME_CHANGED:
    case STATE_RIDER_TIME_FINISH_READY:
    case STATE_ARMOR_TIME_FINISH_READY:
      mp3_player.playMp3Folder(SOUND_EJECT);
      break;
    default: ;
    }
    state = STATE_INIT;
    reset_acc_state();
  }

  prev_driver = driver;

  //------------------------------ 描画処理 ------------------------------
  switch(prev_state){
  case STATE_INIT:
    switch(state){
    case STATE_SINGLE_MODE:
    case STATE_BURST_MODE:
      reset_display();
      draw_single_burst_mode();
      break;
    case STATE_STANDING_BY:
      reset_display();
      draw_standing_by();
      break;
    default: ;
    }
    break;
  case STATE_SINGLE_MODE:
  case STATE_BURST_MODE:
    switch(state){
    case STATE_INIT:
      reset_display();
      draw_listening();
      break;
    case STATE_STANDING_BY:
      reset_display();
      draw_standing_by();
      break;
    default: ;
    }
    break;
  case STATE_STANDING_BY:
    switch(state){
    case STATE_INIT:
      reset_display();
      draw_listening();
      break;
    case STATE_STANDING_BY:
    case STATE_RIDER_TIME_READY:
    case STATE_ARMOR_TIME_READY:
      draw_standing_by();
      break;
    default: ;
    }
    break;
  case STATE_RIDER_TIME_READY:
    switch(state){
    case STATE_INIT:
      reset_display();
      draw_listening();
      break;
    case STATE_RIDER_TIME_READY:
      draw_standing_by();
      break;
    case STATE_RIDER_TIME_CHANGED:
      draw_kaixa();
      break;
    default: ;
    }
    break;
  case STATE_ARMOR_TIME_READY:
    switch(state){
    case STATE_INIT:
      reset_display();
      draw_listening();
      break;
    case STATE_ARMOR_TIME_READY:
      draw_standing_by();
      break;
    case STATE_ARMOR_TIME_CHANGED:
      draw_kaixa();
      break;
    default: ;
    }
    break;
  case STATE_RIDER_TIME_CHANGED:
  case STATE_RIDER_TIME_FINISH_READY:
    switch(state){
    case STATE_INIT:
      reset_display();
      draw_listening();
      break;
    default: ;
    }
    break;
  case STATE_ARMOR_TIME_CHANGED:
  case STATE_ARMOR_TIME_FINISH_READY:
    switch(state){
    case STATE_INIT:
      reset_display();
      tft.setRotation(2); // 一時的に回転させた座標を元に戻す
      draw_listening();
      break;
    default: ;
    }
    break;
  default: ;
  }  

  prev_state = state;

  delay(LOOP_INTERVAL_MS);
}

今回は「とにかく動けばいい」ぐらいの気持ちで作ったこともあり、あまり洗練されていないソースコードになっていると思います。

ちなみに、フラッシュメモリの使用率は97%、RAMの使用率は74%で、プログラムのサイズとしては本当にギリギリでした。ディスプレイをライブラリで使おうとすると、毎回こんな感じでメモリ容量との戦いになります。