オリジナルワンダーライドブックをつくる 〜逢魔降臨暦ワンダーライドブック〜

過去最長の7ヶ月ぶりの新作、『逢魔降臨暦ワンダーライドブック』のご紹介です。

制作経緯

「『仮面ライダーセイバー』のコレクションアイテムは『本』である」ということが判明した昨年(2020年)の夏頃には、「作るとしたらこれかな」という方向性はすぐに決まりました。全てのレジェンドライダーの活躍を記録した、年代記としての『逢魔降臨暦ワンダーライドブック』。いわゆる「オールインワン」系の改造なので、あんまりやらない方が良いかなと思いつつも(←オールインワン系は改造アイデアとしてはオリジナリティが出しづらく、またボリュームが多くなりがちで大変なため)、今回は『本』がテーマで、しかも『逢魔降臨暦』という、オールインワンを作るにはうってつけの受け皿となる素材があったので、発想がストレート過ぎるかなと思いながらもとりあえずやってみることにしました。

が、丁度同じ頃から、本業の方でクラスチェンジをするための研修やら試験やらの準備が始まって自由時間がほとんどなくなってしまったため、隙間時間を使って少しずつ少しずつプログラムを書いたり音声素材を編集をしたり…と作成期間が長期に渡っているウチに、新しい製品仕様(闇黒剣月闇の敵音声収録やドラゴニックブースターの「完全読破一閃」とか)が次々出てきてしまって、それをきっかけにまた新しい機能アイデアが出てきて対応するために更なる音声編集が必要になり…とやっているうちに、7ヶ月も経ってしまいました。

特徴

『逢魔降臨暦ワンダーライドブック』には、『クウガ』〜『ジオウ』までの平成ライダーに関する以下の特徴があります。

  1. ライダーズクレストの発光
  2. ストーリーページの音声再生
  3. ソードライバーへのセット時の変身待機音再生
  4. 抜刀・変身後のレジェンドライダー共通音声&変身音再生
  5. 火炎剣烈火のエンブレム内でのライダーズクレストの発光
  6. プッシュ必殺技での固有能力発動
  7. リード必殺技での固有必殺技発動&待機時の固有BGM再生
  8. 邪剣カリバードライバー&闇黒剣月闇での使用時の音声変更

以下、順に説明していきます。

1. ライダーズクレストの発光

 

「せっかく20作品も入っているのにずっと同じページが表示されているのも面白くないな」と思い、何か変化をつけたくて、ライダーの選択に合わせてライダーズクレストを変更して発光させる、という仕様にしました。一応これで、擬似的にではありますがページが変わっていくような表現ができたかなと思います。

ちょっと残念なのは、動画で見ると発光が結構チラついて見えてしまうことです。肉眼では全然気にならないのですが、これはもうしょうがないです。

なお、ここと後に出てくる「エンブレム発光」で使用しているライダーズクレストについては、最近の私のほとんど作品で協力してもらっている方に今回も全て書き下ろして頂きました。いつもありがとうございます。

また、このストーリーページ(←ワンダーライドブック文字で「2000 クウガ」〜「2018 ジオウ」と書いてある)の作成にあたり、こちらもしばしば協力頂いているクレーンの丈様が作成された文字フォントを使用させて頂きました。快く提供頂き本当にありがとうございました。フォントを提供頂いたおかげでメチャメチャ作業効率が上がりました。

2. ストーリーページの音声再生

 

ストーリーページの音声は、DX版のワンダーライドブックの音声をそのまま使用させてもらっています。そのため、残念ながらDX版が存在しないアギト〜キバについては音声が鳴りません。

これについては、当初は全てライドウォッチの説明音声(←「○○は○○だ!」)で統一して全ライダーの音声を鳴らす、という案を検討していました。ただ、実際にレジェンドライダーのワンダーライドブックが発売されてみると、設定されているストーリーページの文章がどれも秀逸でしたので、これを活かさない手はないと思い、アギト〜キバで音声再生不可になるのは承知の上で、DX版のストーリーページの音声で統一する方針にしました。

アギト〜キバだけライドウォッチの音声にする、という手もなくはなかったのですが、統一感がなくなるので避けました。望み薄ではありますが、『ゼロワン』のときのいくつかのプログライズキーのように、食玩/ガシャポン限定だったものをプレバンやAmazonで出す、というやり方で、アギト〜キバをDX化してくれることを待ちたいなと思います。

ちなみに、音声のないアギト〜キバを含め、全てのレジェンドライダーのワンダーライドブックに設定されているストーリーページの文章はこちらで確認することができます。どれも各ライダーの物語を端的にうまく表現していると思います。

3. ソードライバーへのセット時の変身待機音再生

 

各レジェンドライダーに対応させるからにはこれは当然いるだろう、ということで、特に考えることなく突っ込んだ機能になります。

ちょっと悩んだのは、『龍騎』と『エグゼイド』の扱いです。この二人、厳密には変身待機音がありません(←『エグゼイド』は強化フォームだと待機音が鳴りますが、初期フォームでは鳴らない)。ストーリーページの方では音声再生非対応は許容したものの、変身待機音についてはちょっと何も鳴らないのも寂しい感じがしましたので、『龍騎』についてはミラーワールドの音声(←「キーンキーン」というやつ)、『エグゼイド』についてはLEVEL 1への変身音(←「I’m a Kamen Rider」のやつ)を鳴らすことにしました。

4. 抜刀・変身後のレジェンドライダー共通音声&変身音再生

 

『聖剣ソードライバー』の仕様で個人的に残念だったのは、レジェンドライダーのワンダーライドブックはレジェンド同士で組み合わせればコンボ扱いになるものの、『セイバー』オリジナルのワンダーライドブックと組み合わせると、問答無用で『ワンダーライダー』の汎用音声になってしまうことでした。

せっかくのレジェンドの力なのにこの仕様はもったいない、ということで、音声としては大変長くなってしまいますが、通常の一冊変身音声の後に「増冊」扱いでレジェンドライダーの共通音声を鳴らし、さらにその後にレジェンドライダー名+変身音を鳴らすようにしました。

 

ちなみにライダーの力を切り替えるのにいちいち「納刀」→「ブックを抜く」→「ページ送り動作をする」→「ブックをセットする」→「抜刀」というのは面倒過ぎるので、ブックをソードライバーにセットしたままページをめくるように開閉することで、次のライダーの力に切り替えることができるようにしています。

5. 火炎剣烈火のエンブレム内でのライダーズクレストの発光

先の動画で出てきたとおり、レジェンドライダー名が鳴るタイミングで、火炎剣烈火のエンブレムの中にライダーズクレストが出現するようにしてみました。「レジェンドライダーの力が火炎剣烈火に宿った」ということを視覚的に表現したくて、こうなりました。これを実現するために火炎剣烈火側にも手を加えていますが、それについては後ほど。

6. プッシュ必殺技での固有能力発動

 

通常のDX版ワンダーライドブックでは、「プッシュ必殺技」と言いつつブックのタイトル名の音声が再生されるだけなのですが、そのままの仕様に合わせて「○○の刻!」となるのもイマイチだなあと思いましたので、せっかくなので各ライダーの必殺技以外の固有能力をここで発動できるようにしてみました(←必殺技はリード必殺技の方で発動させると決めていたため)。

「何かサポートがいるライダー多いし、基本的に召喚系の技にしよう!」という方針にはしたものの、何も召喚するものがないライダーもそれなりにいて(←アギト、ブレイド、カブト、ディケイド、鎧武、エグゼイド、ビルド)、そういう面々は無理に2号ライダーなどを呼び出すようにはせず、各ライダーの固有の能力(スライダーモード、クロックアップ、ファイナルフォームライド、ステージセレクト)や、ちょっと特殊なフォーム(スイカアームズ、ハザードトリガー)を割り当てるようにしました。

ちなみに、このプッシュ必殺技の割り当てにかなり悩んだライダーが二人います。一人は『オーズ』。アンクを呼び出すかどうか、というところでかなり迷ったのですが、自分の中で「アンクはこんな技のような形で呼び出してよいキャラクターではない。映司あってこそのアンク」という結論に落ち着きましたので、代わりにカンドロイドを呼び出す形にしました。

そして最も悩んだのが『ブレイド』です。まず呼び出すものがない。通常のカードコンボ(ライトニングスラッシュ)はリード必殺技で使う。ジャックフォームだとただの中間フォームへのパワーアップになる。ギャレンを呼び出すとブレイドだけネタッぽくなってしまう。プログライズキーのアビリティと同じく「ジョーカー」という手もなくはないけど、技として扱うには重過ぎる。等々色々考えた結果、『ブレイド』のトランプという特徴を活かした技ということで、キングフォームの技ではあるのですが、ポーカーのカードコンボを割り当てるようにしました(←劇中に登場したストレートフラッシュ、フォーカード、ロイヤルストレートフラッシュのいずれかがランダムに発動)。

7. リード必殺技での固有必殺技発動&待機時の固有BGM再生

 

「リード必殺技で各ライダーの必殺技を発動させる」という方針は早々に決めていました。丁度良いことに、オーマジオウドライバーが各ライダーの必殺技を発動できる仕様になっていたので、これを公式見解として、その音声をそのまま使用させてもらうことにしました。

その後に「どうせなら、必殺待機のときのBGMも各ライダーの劇中の雰囲気に合わせてみよう」と思いついてしまったため、音声の選出と編集に膨大な時間を費やすことになってしまいました。動画内では数十秒しか流れませんが、実際にはすべて1〜2分の長さで収まるように編集しています(←例えば、龍騎の「果てなき希望」などは、冒頭のイントロの後はいきなりサビに入るように編集している)。

そしてYouTube動画の中で「おわりに」として収録していますが、『クウガ』〜『ジオウ』までを一気に連続リードすることで、『完全読破一閃』として、究極技である『逢魔時王必殺撃』を発動できるようにしてみました。あくまでオマケ要素ではありますが、なんとなく説得力のありそうな(?)設定じゃないかなと思っています。もし実戦で使うとなれば発動までに恐ろしいほどの時間がかかって隙だらけになりそうですが、威力を考えればそれ相応のリスクですし、またセイバーが隙だらけの間を仲間に守ってもらうというのも、『セイバー』っぽくって良いのではないか…などなど考えたりしていました。

8. 邪剣カリバードライバー&闇黒剣月闇での使用時の音声変更

 

このワンダーライドブックの仕様を検討している最中に邪剣カリバードライバー&闇黒剣月闇の仕様が発表になり、『レジェンドライダーのワンダーライドブックを必殺リードすると、敵の名前(「グロンギ」や「アンノウン」など)が再生される』という、なかなか珍しい面白仕様が判明しました。この設定は活かした方が良いだろうと思い、邪剣カリバードライバー&闇黒剣月闇で使用するときには、おまけ要素としてアナザーライダー風の音声を再生させることにしました。

 

さらに「どうせ音声変えるなら、リード必殺技待機BGMの選考で次点になったやつも入れてしまえ」とか考えてしまったために、音声編集作業量が2倍近くまで膨れ上がるという、愚か者としか言いようがない状態になってしまいました。

結果として、リード必殺技待機のときのBGMは以下のようになりました。

火炎剣烈火 闇黒剣月闇
クウガ 転身 激闘
アギト BELIEVE YOURSELF DEEP BREATH
龍騎 果てなき希望 Revolution
ファイズ Dead or alive The people with no name
ブレイド 覚醒 take it a try
響鬼 爆炎
カブト ONLY ONE LOAD OF THE SPEED
電王 Double-Action 俺、参上
キバ ウェイクアップ Supernova
ディケイド ディケイド Ride the Wind
ダブル ハードボイルド 疾走のアクセル
オーズ スキャニング・チャージ 対決、グリード
フォーゼ フォーゼ変身 (左に同じ)
ウィザード 運命の鼓動 イッツショータイム
鎧武 始動 鎧武 ロックシード
ドライブ スタートドライブ (左に同じ)
ゴースト 命燃やすぜ! (左に同じ)
エグゼイド 患者の運命は俺が変える! ノーコンティニューでクリアしてやるぜ!
ビルド 勝利の法則は決まった! さぁ、実験を始めようか
ジオウ アーマータイム (左に同じ)

あくまでおまけ要素ということで、個人的に「これしかないかな」と思ったライダーについては、火炎剣烈火と闇黒剣月闇で無理に差をつけてはいません。また、選考で次点になったものを放り込んでいるだけですので、「闇黒剣だから」みたいな関係づけは特にありません。

 

以上が今回作成した逢魔降臨暦ワンダーライドブックの特徴になります。発想としてはシンプルかなと思いますが、色々盛り込み過ぎてボリュームだけは大変なことになってしまいました。

以下からは、中身についての技術的な説明になります。

ハードウェア解説

1. 使用具材

はじめに、今回使用した具材のご紹介です。

入れ物となるワンダーライドブックは、最終的にカバー側を茶色、中身を黒としたかったので、以下の2つを組み合わせて使用しました。

ニードルヘッジホッグは中身が黒い通常のワンダーライドブックであればなんでもOKですが、カバー側の方は茶色いDXのワンダーライドブックが存在しなかったため、後から塗装が剥げても目立ちにくいように、唯一カバー側が黒の成型色になっているオーズを使用することにしました。

「塗装するなら別にそこまで気にして組み合わせなくても、一つで良いのでは?」と思われた方もいるかもしれません。これは前作ハザードメモリを作ったときに痛感したのですが、何かしらの入れ物にセットするタイプのアイテムは、遊んでいるうちにとにかく塗装が剥げます。

実際、ハザードメモリはこんな感じになってしまいました。

一度作って後は飾るだけ、ということなら元の色は何だって良いと思いますが、自分のように作った後にガシガシ遊ぶ、というタイプだと、下地選びまで気にしておいた方が良いと思います。特にワンダーライドブックの場合、プッシュ必殺技という、本を歪める恐ろしい操作があるので、なおさら剥げやすいです

実際、動画撮影を続けているうちにガンガン剥げてきて、こんな感じになってしまいました。多分、ニードルヘッジホッグのカバーに塗装していたら再塗装待った無しで挫けるレベルだったと思うのですが、下地を黒にしておいたおかげで、「これぐらいならええわ、これで最後までいったれ」と、動画撮影まで完遂することができました。

 

続いてマイコンです。

エンブレム発光ギミックのために、前作ハザードメモリと同様にBLE通信が可能なATOM Liteを使用しています。ワンダーライドブック側と火炎剣烈火側で2個使用。このギミックがなかったら、多分Seeeduino XIAOを使っていたと思います。

MP3プレイヤーについては、最近はコスト重視でマイクロSDカード不要のMP3ボイスモジュールを使うことが多かったのですが、今回は音声が大ボリューム(全部でおよそ220ファイル、計100MB弱)でこのモジュールでは対応しきれなかったため、久しぶりにDFPlayerを使用しました。

私が上記のMP3ボイスモジュールやDFPlayerとよく組み合わせて使用するスピーカーです。玩具にもともと使用されているものとほぼ同じ直径で、多少薄く、多少音が良い…気がします。

バッテリーはいつもの110mAhのリチウムイオンポリマー充電池です。可能ならリチウムイオンポリマー充電池は使いたくないものの、ワンダーライドブックは過去最高に内側のスペースがないので、これ以外に選択の余地はありませんでした。こちらもATOM Lite同様、ワンダーライドブック側と火炎剣烈火側で2個使用です。

あと、リチウムイオンポリマー充電池を扱うときは対向のソケットも持っていると便利です。これまで充電池を扱ったことがない方は、充電器の購入も忘れずに。

こちらのディスプレイはワンダーライドブック側に仕込む方として使用しました。ディスプレイを使うときは配線が複雑になることが多いのですが、このディスプレイはI2C接続に対応しているので、全部で4本配線するだけで接続できます。一応、「Qwiic」というシステムのパーツの一つとして販売されていますが、別にQwiicシステム以外でも、ただのI2Cデバイスとして使用できます。

こちらは火炎剣烈火のエンブレムに仕込む方として使用しました。こちらも先のディスプレイと同様にI2C接続に対応しているので、配線をシンプルにできます。エンブレムに仕込むにはこれがギリギリサイズでした(※これでも多少エンブレム側を削る必要あり)。これより小さい64×48のディスプレイもあって、そちらだともう少し余裕を持って仕込めたのですが、どうせなら表示するライダーズクレストをできるかぎり迫力あるものにしたくて、こちらを採用しました。

後述しますが、今回、ワンダーライドブックの開閉を検知するためのスイッチを一つ追加しています。その仕組みとして、このホールセンサ+ネオジム磁石を使用しています。対向のネオジム磁石は、100均とかプラモ売り場とかでよく売られているようなやつを使用しています。

リード必殺技を実現するためのスイッチ追加に一つ使用したのと、あとワンダーライドブックが開いたときに押されるスイッチの改修に使用しました。このディテクタスイッチは非常に使い勝手が良いので、玩具改造するなら何個あっても困らないと思います。

以上がメインの部品です、あとは電子工作の定番部品(ピンソケット、ピンヘッダ、リード線、スライドスイッチあたり)ぐらいです。

2. ワンダーライドブックの改造

次に、ワンダーライドブック側と火炎剣烈火側のハード改造のポイントを順に説明していきます。まずはワンダーライドブック側から。

回路図は以下のようになります。

実際に組み込んだものがこちらになります。

とにかくスペースがないので、中でカットできそうなところはことごとくカットしています。

ディスプレイはこんな感じで埋め込んでいます(※ストーリーページのシールを貼る前の状態)

本来のストーリーページに相当する部分を四角にくり抜いて、その中にディスプレイをはめ込む感じです。

実際はここからさらにカットしていますが、大体のくり抜きイメージはこんな感じです。

通常のDX版ワンダーライドブックなら、抜刀したときにストーリーページが開いて変身ページになるのですが、上記のようにストーリーページをくり抜いてディスプレイを埋め込んだ結果、同じギミックを実現することは不可能になりました。でもワンダーライドブックの玩具改造をするからには、「抜刀でページが開く」というギミックは何とか残したい。色々考えた結果、「抜刀でストーリーページではなく表紙が開く」という形にすることにしました。

そのために、まず軸の真ん中パーツの部分をカットして、ページを開くためのバネが表紙まで届くようにしています。これでとりあえず、バネで開くようにはなります。後は表紙をどうロック&解除させるかです。

 

色々考えてはみたのですが、最終的に上記のように磁石でロック&解除させるやり方が、現状の私のスキルでは唯一実現できる方法でした。しかしこれがまた、磁石の調整がかなりセンシティブで、正直かなり遊びづらいです。

こちらが表紙側につけたネオジム磁石です。これだけでは調整がかなり難しかったので、

上側にも同様にネオジム磁石をつけました。そしてその対向側が、

こうなっています。ストーリーページの隙間の部分にネジが見えます。ここのネジを回してネジの高さを変えることで、ネオジム磁石(を取り付けた表紙)と引き合う強度を調整し、下部の対向磁石のスライドで表紙が開くギリギリのロック強度に調整した、というわけです。うーん、遊びづらい。

ちなみにネジの横に見えている3本足の部品がホールセンサです。つまり、対向のネオジム磁石は、表紙の開閉ギミックの調整の役目と共に、表紙の開閉を検知するセンサの役割も果たしています。

 

もう一つのポイントは、リード必殺技を実現するためのスイッチの追加です。背面のスピーカーの横に、物理スイッチを一つ追加しています。

最初はカッコ良く(?)、「火炎剣烈火のリード部が発している読み取り電波をセンシングできないか」とか考えていたのですが、火炎剣烈火のICタグ&リード部はかなり省電力な仕組みになっているのか、私が考えていたやり方だとそれがうまくいきませんでした(ちなみにオースキャナーならできた)。というわけで、あまり無理せず、「物理スイッチを火炎剣烈火に押し当ててリード必殺技の音声を再生させる」というやり方にしました。

ここまで読まれて、「いや、そもそもワンダーライドブックのICタグはどうした?」と思われた方もいるかもしれません。

上図は先ほどの内部写真の再掲ですが、見ての通り、今回、ICタグは入れていません。つまり、抜刀・変身後の追加音声やリード時・リード後の音声は、全てワンダーライドブック側から再生させています。

例えば今回の改造内容に最も近そうな『ジオウ降臨暦』のICタグを入れてしまうと、認識としては『ジオウ』固定となってしまい、変身時に『ワンダーライダー』の汎用音声が鳴ってしまったり、リード必殺技のときも火炎剣烈火が問答無用で『ジオウ!』と叫んでしまいます。せっかくレジェンドライダーのページ(チカラ)を切り替えられるのが売りなのに、ひたすら『ジオウ』と言ってしまうのはイマイチだなと思いましたので、ICタグはなくし、必要な音声は全てワンダーライドブック側から再生させることで、音声再生の自由度を確保しています。

また、この物理スイッチは、このワンダーライドブックがソードライバーにセットされているか、カリバードライバーにセットされているかの判定にも使用しています。

 

上記動画のとおり、ソードライバーにセットしたときはこのスイッチが解放状態になり、カリバードライバーにセットしたときにはこのスイッチが押された状態になります。これによって、ワンダーライドブックは自分がどちらに挿さっているかを判定して、再生する音声を切り替えています。

ちなみにこのやり方には弱点があって、「特殊聖剣(土豪剣激土/風双剣翠風/音銃剣錫音)にセットしても、カリバードライバーにセットしたとして認識される」ということがあります。これについては、プログラムのロジック的には(表紙の開閉動作に要する時間を考慮することで)回避可能と考えていたのですが、実際に動かしてみると成功確率がかなり低かったため、今回は「特殊聖剣には非対応」という仕様でカタをつけることにしました。

ワンダーライドブック側のハード改造ポイントは以上です。続いて、火炎剣烈火側の方の改造ポイントです。

3. 火炎剣烈火の改造

火炎剣烈火側は、ワンダーライドブック側に比べるとずっとシンプルです。回路図は以下のようになります。

主な部品は火炎剣烈火の柄の部分に埋め込んでいます。

実は、電源については当初、火炎剣烈火に元々搭載されている単四電池×3から引っ張ってこようとしていました。過去に改造したビルドドライバージクウドライバーゼロワンドライバーと同じ考え方です。そうすれば、追加のバッテリーが不要のため、取り扱いが容易になります。

ただ、実際にやって見ると、火炎剣烈火の本来の動作が不安定になってしまいました。本来必要とする電力が使えなくなってしまうからなのか、とにかくソードライバー、本体の読取部のコイルの配線といい、ハード的にもソフト的にも非常にセンシティブなデバイスになっているようです。

頑張って火炎剣烈火の動作を安定化させる方向もあったのかもしれませんが、ここにあんまり時間をかけているわけにもいかなかったので、手っ取り早い方法として、火炎剣烈火の本体回路とは完全に切り離して回路を組み込むことで今回は進めることにしました。

続いてギミックの核になるディスプレイですが、

こんな感じで組み込んでみました。

ピンヘッダとソケットでソードとエンブレムを容易に分離可能にしておいたので、火炎剣烈火のエンブレム以外にはディスプレイは搭載していませんが、エンブレムに穴を開けることで、水勢剣流水、雷鳴剣黄雷も通常通りにリード&セットして遊ぶことができるようになっています。

 

ハードウェア解説は以上で、最後にソフトウェア(プログラム)解説になります。

ソフトウェア解説

まずは何はともあれ、状態遷移図の作成です。

これができたらプログラムの大筋はほぼ出来上がったようなものなので、あとはうまくいかないところをトライ&エラーを繰り返しながら解決していくだけです。ソースコードの全文は最後に掲載しますので、ここでは私が色々ハマりながら解決していったところを記載していきます。

DFPlayer

これはDFPlayerに限らず、以下のほぼ全てのデバイスに当てはまることなのですが、動作させるためのライブラリが提供されていたとしても、ほとんどの場合は「Arduinoのマイコン」向けに提供されているので、いくらArduino IDEで開発できるにしても、ATOM Lite (=ESP32マイコン)ではうまく動作しない、ということがしばしばあります。

DFPlayerについても、こちらで手に入るDFRobotのオフィシャル(?)ライブラリ(DFRobotDFPlayerMini)だとESP32のハードウェアシリアルで一部うまく動かないところがあり、別の方が作成されたライブラリ(DFPlayer Mini Fast)だと上手く動いたので、そちらを使用させて頂きました。

Zio Qwiic 1.5インチOLEDディスプレイ (128×128)

ワンダーライドブックに埋め込んだ方のディスプレイです。使い方はこちらに公式ドキュメントがあるのですが、手順通りにライブラリを入れてみても、こちらもうまく動作せず。色々試してみたところ、ドキュメントでは「以下の部分のコメントを外して有効化しろ」とあるのですが、

//U8G2_SSD1327_EA_W128128_1_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE);

上記ではなく、以下の方のコメントを外して有効化すると、上手く表示されました。

// U8G2_SSD1327_MIDAS_128X128_1_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE);

原因ははっきりしませんが、動けば良いのだ動けば。

さて、こちらのディスプレイと火炎剣烈火側のディスプレイに共通の話になりますが、これらのディスプレイにはSDカードスロットがあるわけではないので、画像を表示させる場合はおそらくbitmapを表示させることになると思います。この場合、「どうやって128x128pxbitmapデータを作れば良いのか?」が問題になりますが、自分は以下の手順で作成しました。

  1. パワーポイントで128x128pxbitmap画像データを作成
  2. 1.で作成したデータを変換ソフトでデータ配列として出力

以下、順に説明します。まずはパワーポイントでの画像データ作成から。

何かしらのグラフィックソフトを使いこなせる人には特に問題ない話なのかもしれませんが、私のように、そういったソフトが全然使えない人は、パワーポイントで以下のようにすれば、128x128pxbitmap画像データを出力できると思います。

  1. 「デザイン」→「スライドのサイズ」→「ページ設定」→「ユーザー設定のスライドサイズ」で、「幅」と「高さ」を共に「3.39cm」に指定
  2. 「ファイル」→「エクスポート」でファイル形式に「BMP」を指定し、「幅」と「高さ」を共に「128」に指定してエクスポート

続いて作成したbitmap画像データのデータ配列化ですが、こちらからダウンロードできる”image2LCD”を使用します。Windows用アプリケーションしかないのでどうしようかと思いましたが(私は普段開発にMacを使用)、幸い複雑なアプリケーションではないため、Wineを使ってMacでも動かすことができました(WineのMacへのインストールについてはここでは省略)。

image2LCDの使い方ですが、詳しい理由は分かりませんが、以下のような設定にしてエクスポートをすれば、このディスプレイ&ライブラリで正しく表示できる配列データを取得することができました。

長過ぎるので全部は表示できませんが、以下のような感じで出力されます。

const unsigned char gImage_01kuga[2048] = { /* 0X30,0X01,0X00,0X80,0X00,0X80, */
0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,
0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,
0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,
...
0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,0X00,
};

あとはこれをプログラム内に記述すればOKです。私の場合は20個のライダーズクレストの画像データを表示する必要があり、メインの”.ino”ファイルに記述するととそれだけでとても長くなってしまうため、”riders_crest.h”という別ファイルにまとめて記述して、それをincludeするようにしています。

STEMMA QT/Qwiic互換 128×64 OLED モノクロディスプレイ

こちらは火炎剣烈火のエンブレムに仕込む方のディスプレイです。一応公式のライブラリ(Adafruit_SSD1306)はこちらなのですが、ESP32と相性が悪いため、こちらのライブラリ(SSD1306Wire)を使用しています。

こちらは画像データを128x64pxで作成する必要がありますが、パワーポイントを使って作る場合は、先程の128x128pxの画像データを作成したときから少しパラメータを変えるだけで、同じように作成することができます。

  1. 「デザイン」→「スライドのサイズ」→「ページ設定」→「ユーザー設定のスライドサイズ」で、「幅」を「6.77cm」、「高さ」を「3.39cm」に指定
  2. 「ファイル」→「エクスポート」でファイル形式に「BMP」を指定し、「幅」を「128」、「高さ」を「64」に指定してエクスポート

データ配列化も、先程と同じimage2LCDを使用してできます。こちらは、以下のように設定してエクスポートしたら上手く表示されました。

以上が私のハマりどころになります。最終的なソースコードは本記事の最後に載せていますので、興味がありましたらご参照ください。

まとめ

以上、『逢魔降臨暦ワンダーライドブック』のご紹介でした。おそらく分量的には三ヶ月ぐらいあれば作製できる内容だったのですが、本業の忙しい時期に被ってしまい、結果的に七ヶ月もかかってしまいました。

作って遊んでみた感想としては、「非常に遊びごたえはあるが非常に遊びづらい」ということです。 とにかく開閉ギミックがセンシティブ過ぎて、抜刀する前から勝手に開いたり、逆に抜刀しても開かなかったり

上記が1番困ったところですが、他にもそもそもの仕様として見直したいなあと思うところもあり、遠くないうちにそれらを改善したVer.2.0を作りたいな思っています。改修の方向性は見えているのですが、ちょっと新しいスキルを習得しないといけないので、早く出来たとしても5月のGW開けぐらいかなと思っています。もう少し延びる可能性が大ですが、『セイバー』の本編が終わるまでには出したいところです。

また、それまでの間に細々とした改造も挟めればと思っています。今回かなり間が空いてしまいましたが、引退する気は全然ないので、今後も気長にお付き合い頂けますと幸いです。

 

 

ソースコード

以下、ワンダーライドブック側と火炎剣烈火側のソースコードを掲載します。まずはワンダーライドブック側から。

////////// 基本定義 ////////////////////////////////////////////////////////////

// #include "M5Atom.h"
#include "M5StickC.h" 

#include "BLEDevice.h"
#include "BLEServer.h"
#include "BLEUtils.h"

#define LOOP_DELAY_MS 10

#define MP3_RX_PIN      32
#define MP3_TX_PIN      26
#define I2C_SDA_PIN     22
#define I2C_SCL_PIN     19
#define BOTTOM_SW_PIN   25
#define SIDE_SW_PIN     21
#define BACK_SW_PIN     33
#define HALL_SENSOR_PIN 23

#define ON  LOW
#define OFF HIGH

#define OPEN  LOW
#define CLOSE HIGH

uint8_t bottom_sw   = OFF;
uint8_t side_sw     = OFF;
uint8_t back_sw     = OFF;
uint8_t hall_sensor = CLOSE;

uint8_t prev_bottom_sw   = OFF;
uint8_t prev_side_sw     = OFF;
uint8_t prev_back_sw     = OFF;
uint8_t prev_hall_sensor = CLOSE;

#define BOTTOM_SW_LONG_PRESS_MS 300
unsigned long bottom_sw_long_press_start_time = 0;
boolean is_bottom_sw_pressing = false;

#define back_sw_LONG_PRESS_MS 1000
unsigned long back_sw_long_press_start_time = 0;
boolean is_back_sw_pressing = false;

#define SWORD_MODE_HOLY 0 // 聖剣ソードライバー
#define SWORD_MODE_EVIL 1 // 邪剣カリバードライバー
uint8_t sword_mode = SWORD_MODE_HOLY;

// 1秒以内にDRIVE_IN -> DRIVER_OPEN -> DRIVER_PUSH -> DRIVER_OPEN
// の状態遷移が起こったときに、通常とは異なる音声を再生させる
#define SPECIAL_SWORD_TIMER_MS 1000
unsigned long special_sword_timer_start_time = 0;

#define STATE_INIT           0
#define STATE_TITLE          1
#define STATE_DRIVER_IN      2
#define STATE_DRIVER_OPEN    3
#define STATE_DRIVER_CLOSE   4
#define STATE_DRIVER_PUSH    5
#define STATE_STORY          6
#define STATE_READING        7
#define STATE_READING_NEXT   8

uint8_t state      = STATE_INIT;
uint8_t prev_state = STATE_INIT;

void print_state(){
  if(prev_state != state){
    switch(state){
    case STATE_INIT:         Serial.println(F("STATE_INIT"));         break;
    case STATE_TITLE:        Serial.println(F("STATE_TITLE"));        break;
    case STATE_DRIVER_IN:    Serial.println(F("STATE_DRIVER_IN"));    break;
    case STATE_DRIVER_OPEN:  Serial.println(F("STATE_DRIVER_OPEN"));  break;
    case STATE_DRIVER_CLOSE: Serial.println(F("STATE_DRIVER_CLOSE")); break;
    case STATE_DRIVER_PUSH:  Serial.println(F("STATE_DRIVER_PUSH"));  break;
    case STATE_STORY:        Serial.println(F("STATE_STORY"));        break;
    case STATE_READING:      Serial.println(F("STATE_READING"));      break;
    case STATE_READING_NEXT: Serial.println(F("STATE_READING_NEXT")); break;
    default: ;
    }
  }
}

#define NUM_HEISEI_RIDERS 20
// プログラム上、アギト〜ビルドの定義は不要。ブレイドはプッシュ必殺技の関係で定義
#define PAGE_TOP     0
#define PAGE_KUGA    1
#define PAGE_BLADE   5
#define PAGE_ZIO    20

uint8_t page = PAGE_TOP;

boolean legend_read[NUM_HEISEI_RIDERS];

void reset_legend_read(){
  for(uint8_t i=0;i<NUM_HEISEI_RIDERS;i++){
    legend_read[i] = false;
  }
}

boolean check_read_all_legend(){
  boolean flag_read_all = true;
  for(uint8_t i=0;i<NUM_HEISEI_RIDERS;i++){
    if(legend_read[i] == false){
      flag_read_all = false;
      break;
    }
  }
  return flag_read_all;
}

////////// 描画処理 //////////////////////////////////////////////////////

// 使用モジュールはZio Qwiic 1.5インチ
// https://www.smart-prototyping.com/blog/Zio-Qwiic-1.5in-OLED-Display-Qwiic-Start-Guide
// https://github.com/olikraus/u8g2/wiki/u8g2reference

#include <U8g2lib.h>
#include <Wire.h>
#include "riders_crest.h" // クレストのバイナリデータは全てこちらに記述

//U8G2_SSD1327_EA_W128128_1_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE);
U8G2_SSD1327_MIDAS_128X128_1_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE); 

#define cross_width  128
#define cross_height 128

void clear_display(){
  u8g2.clearDisplay();
}

void draw_riders_crest(uint8_t page){
  u8g2.firstPage();
  do{
    u8g2.drawXBM(0,0,cross_width,cross_height,riders_crest[page-1]);
  }while(u8g2.nextPage());
}

void control_display(){
  if(prev_state != state){
    if((prev_state == STATE_TITLE        && state == STATE_INIT) ||
       (prev_state == STATE_DRIVER_IN    && state == STATE_DRIVER_CLOSE) ||
       (prev_state == STATE_DRIVER_OPEN  && state == STATE_DRIVER_CLOSE) ||
       (prev_state == STATE_READING_NEXT && state == STATE_READING) ){
        if(page == PAGE_TOP){
          clear_display();
        }else{
          draw_riders_crest(page);
        }
    }
  }
}

////////// 音声処理 /////////////////////////////////////////////////////////////

//#include <DFRobotDFPlayerMini.h>
// DFRobot提供のライブラリを使ってESP32のハードウェアシリアルで動作させようとすると
// なぜか動作不安定になるため、別の方が作成されたDFPlayer Mini Fastライブラリを使用する
// https://github.com/PowerBroker2/DFPlayerMini_Fast

#include <DFPlayerMini_Fast.h>

HardwareSerial hs_mp3_player(1);
DFPlayerMini_Fast mp3_player;

#define SOUND_VOLUME_DEFAULT 20 // 0〜30 20

#define SOUND_BUTTON                       1
#define SOUND_PUSH                         2
#define SOUND_INSERT                       3
#define SOUND_EJECT                        4
#define SOUND_CLOSE                        5
#define SOUND_PUSH_STORY_LEGEND            6
#define SOUND_ADD_LEGEND                   7
#define SOUND_READ_HOLY                    8
#define SOUND_LEARNED_HOLY                 9
#define SOUND_OHMA_ZIO_UNDERSTANDING_HOLY 10
#define SOUND_LEARNED_OHMA_ZIO_HOLY       11
#define SOUND_EVIL_READ_ANOTHER_RIDER     12
#define SOUND_READ_EVIL                   13
#define SOUND_LEARNED_EVIL                14
#define SOUND_OHMA_ZIO_UNDERSTANDING_EVIL 15
#define SOUND_LEARNED_OHMA_ZIO_EVIL       16
#define SOUND_SKILL_BLADE_A               17
#define SOUND_SKILL_BLADE_B               18
#define SOUND_SKILL_BLADE_C               19

#define SOUND_OFFSET_RIDER_TIME_CHANGE                 20
#define SOUND_OFFSET_PUSH_STORY                        40
#define SOUND_OFFSET_CHANGE_WAITING                    60
#define SOUND_OFFSET_RIDER_NAME_CHANGE                 80
#define SOUND_OFFSET_SKILL                            100
#define SOUND_OFFSET_RIDER_TIME_UNDERSTANDING         120
#define SOUND_OFFSET_RIDER_NAME_CRITICAL              140
#define SOUND_OFFSET_ANOTHER_RIDER_NAME_CHANGE        160
#define SOUND_OFFSET_ANOTHER_RIDER_TIME_UNDERSTANDING 180
#define SOUND_OFFSET_ANOTHER_RIDER_NAME_CRITICAL      200

#define WAIT_SOUND_STATE_INIT                              0
#define WAIT_SOUND_STATE_RIDER_TIME_CHANGE                 1
#define WAIT_SOUND_STATE_RIDER_NAME_CRITICAL               2
#define WAIT_SOUND_STATE_ANOTHER_RIDER_NAME_CRITICAL       3
#define WAIT_SOUND_STATE_CHANGE_WAITING                    4
#define WAIT_SOUND_STATE_ADD_LEGEND                        5
#define WAIT_SOUND_STATE_RIDER_NAME_CHANGE                 6
#define WAIT_SOUND_STATE_EVIL_READ                         7
#define WAIT_SOUND_STATE_ANOTHER_RIDER_NAME_CHANGE         8
#define WAIT_SOUND_STATE_RIDER_NAME_CHANGE_FROM_CLOSE      9
#define WAIT_SOUND_STATE_SKILL                            10
#define WAIT_SOUND_STATE_LEARNED_HOLY                     11
#define WAIT_SOUND_STATE_LEARNED_EVIL                     12
#define WAIT_SOUND_STATE_RIDER_TIME_UNDERSTANDING         13
#define WAIT_SOUND_STATE_ANOTHER_RIDER_TIME_UNDERSTANDING 14

#define WAIT_SOUND_RIDER_TIME_CHANGE_MS                1800
#define WAIT_SOUND_RIDER_NAME_CRITICAL_MS              2000
#define WAIT_SOUND_ANOTHER_RIDER_NAME_CRITICAL_MS      2000
#define WAIT_SOUND_CHANGE_WAITING_MS                   1200
#define WAIT_SOUND_ADD_LEGENGD_MS                     19000
#define WAIT_SOUND_RIDER_NAME_CHANGE_MS               15500
#define WAIT_SOUND_EVIL_READ_MS                        1200
#define WAIT_SOUND_ANOTHER_RIDER_NAME_CHANGE_MS        4500
#define WAIT_SOUND_RIDER_NAME_CHANGE_FROM_CLOSE_MS     1800
#define WAIT_SOUND_SKILL_MS                            1800
#define WAIT_SOUND_RIDER_TIME_UNDERSTANDING_MS         1500
#define WAIT_SOUND_ANOTHER_RIDER_TIME_UNDERSTANDING_MS 4300

uint8_t wait_sound_state = WAIT_SOUND_STATE_INIT;
unsigned long sound_wait_start_time = 0;

void play_sound(uint8_t sound_num){
  mp3_player.playFromMP3Folder(sound_num);
  Serial.print(F("Play sound: "));
  Serial.println(sound_num);
}

void pause_sound(){
  mp3_player.pause();
}

void control_sound(){
  unsigned long now_ms = millis();

  if(prev_state != state){ // 状態遷移直後の処理

    // 状態変化が起きれば待ちのステータスはリセットする
    wait_sound_state = WAIT_SOUND_STATE_INIT;

    switch(state){
    case STATE_INIT:
      switch(prev_state){
      case STATE_TITLE:
        play_sound(SOUND_BUTTON);
        wait_sound_state = WAIT_SOUND_STATE_RIDER_TIME_CHANGE;
        sound_wait_start_time = now_ms;
        break;
      case STATE_STORY:
        break;
      case STATE_DRIVER_IN:
      case STATE_DRIVER_OPEN:
      case STATE_DRIVER_CLOSE:
        play_sound(SOUND_EJECT);
        break;
      case STATE_DRIVER_PUSH:
        if(page == PAGE_TOP){
          play_sound(SOUND_PUSH_STORY_LEGEND);
        }else{
          play_sound(SOUND_OFFSET_PUSH_STORY + page);
        }
        break;
      case STATE_READING:
        if(sword_mode == SWORD_MODE_HOLY){
          if(check_read_all_legend()){
            play_sound(SOUND_LEARNED_OHMA_ZIO_HOLY);
          }else{
            if(page != PAGE_TOP){
              play_sound(SOUND_LEARNED_HOLY);
            }
            wait_sound_state = WAIT_SOUND_STATE_RIDER_NAME_CRITICAL;
            sound_wait_start_time = now_ms;
          }
        }else{
          if(check_read_all_legend()){
            play_sound(SOUND_LEARNED_OHMA_ZIO_EVIL);
          }else{
            if(page != PAGE_TOP){
              play_sound(SOUND_LEARNED_EVIL);
            }
            wait_sound_state = WAIT_SOUND_STATE_ANOTHER_RIDER_NAME_CRITICAL;
            sound_wait_start_time = now_ms;
          }
        }
        break;
      default:
        ;
      }
      break;
    case STATE_TITLE:
      ;
      break;
    case STATE_DRIVER_IN:
      switch(prev_state){
      case STATE_TITLE:
        play_sound(SOUND_INSERT);
        wait_sound_state = WAIT_SOUND_STATE_CHANGE_WAITING;
        sound_wait_start_time = now_ms;
        break;
      default:
        ;
      }
      break;
    case STATE_DRIVER_OPEN:
      switch(prev_state){
      case STATE_DRIVER_IN:
        if(sword_mode == SWORD_MODE_HOLY){
          pause_sound();
          wait_sound_state = WAIT_SOUND_STATE_ADD_LEGEND;
        }else{
          pause_sound();
          wait_sound_state = WAIT_SOUND_STATE_EVIL_READ;
        }
        sound_wait_start_time = now_ms;
        break;
      case STATE_DRIVER_CLOSE:
        play_sound(SOUND_BUTTON);
        wait_sound_state = WAIT_SOUND_STATE_RIDER_NAME_CHANGE_FROM_CLOSE;
        sound_wait_start_time = now_ms;
        break;
      case STATE_DRIVER_PUSH:
        if(now_ms - special_sword_timer_start_time <= SPECIAL_SWORD_TIMER_MS){
          // 特殊聖剣用の特殊音声再生ルート
          sword_mode = SWORD_MODE_HOLY; // スイッチ位置の関係で邪剣モードになっているのを、聖剣モードに戻す
          play_sound(SOUND_BUTTON);
          wait_sound_state = WAIT_SOUND_STATE_RIDER_NAME_CHANGE_FROM_CLOSE;
        }else{
          play_sound(SOUND_BUTTON);
          wait_sound_state = WAIT_SOUND_STATE_SKILL;
        }
        sound_wait_start_time = now_ms;
        break;
      default:
        ;
      }
      break;
    case STATE_DRIVER_CLOSE:
      switch(prev_state){
      case STATE_DRIVER_IN:
      case STATE_DRIVER_OPEN:
        play_sound(SOUND_CLOSE);
        wait_sound_state = WAIT_SOUND_STATE_CHANGE_WAITING;
        sound_wait_start_time = now_ms;
        break;
      default:
        ;
      }
      break;
    case STATE_DRIVER_PUSH:
      pause_sound();
      break;
    case STATE_STORY:
      if(prev_state == STATE_INIT){
        if(page == PAGE_TOP){
          play_sound(SOUND_PUSH_STORY_LEGEND);
        }else{
          play_sound(SOUND_OFFSET_PUSH_STORY + page);
        }
      }else{
        pause_sound();
      }
      break;
    case STATE_READING:
      switch(prev_state){
      case STATE_INIT:
        if(sword_mode == SWORD_MODE_HOLY){
          play_sound(SOUND_READ_HOLY);
          wait_sound_state = WAIT_SOUND_STATE_RIDER_TIME_UNDERSTANDING;
        }else{
          play_sound(SOUND_READ_EVIL);
          wait_sound_state = WAIT_SOUND_STATE_ANOTHER_RIDER_TIME_UNDERSTANDING;
        }
        sound_wait_start_time = now_ms;
        break;
      case STATE_READING_NEXT:
        if(sword_mode == SWORD_MODE_HOLY){
          play_sound(SOUND_READ_HOLY);
          wait_sound_state = WAIT_SOUND_STATE_RIDER_TIME_UNDERSTANDING;
        }else{
          play_sound(SOUND_READ_EVIL);
          wait_sound_state = WAIT_SOUND_STATE_ANOTHER_RIDER_TIME_UNDERSTANDING;
        }
        sound_wait_start_time = now_ms;
        break;
      default:
        ;
      }
      break;
    case STATE_READING_NEXT:
      pause_sound();
      break;
    default:
      ;
    }
  }else{ // 状態遷移後の時間経過処理
    switch(wait_sound_state){
    case WAIT_SOUND_STATE_INIT:
      break;
    case WAIT_SOUND_STATE_RIDER_TIME_CHANGE:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_RIDER_TIME_CHANGE_MS){
        play_sound(SOUND_OFFSET_RIDER_TIME_CHANGE + page);
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_RIDER_NAME_CRITICAL:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_RIDER_NAME_CRITICAL_MS){
        if(page != PAGE_TOP){
          play_sound(SOUND_OFFSET_RIDER_NAME_CRITICAL + page);
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_ANOTHER_RIDER_NAME_CRITICAL:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_ANOTHER_RIDER_NAME_CRITICAL_MS){
        if(page != PAGE_TOP){
          play_sound(SOUND_OFFSET_ANOTHER_RIDER_NAME_CRITICAL + page);
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_CHANGE_WAITING:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_CHANGE_WAITING_MS){
        if(page != PAGE_TOP){
          play_sound(SOUND_OFFSET_CHANGE_WAITING + page);
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_ADD_LEGEND:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_ADD_LEGENGD_MS){
        play_sound(SOUND_ADD_LEGEND);
        wait_sound_state = WAIT_SOUND_STATE_RIDER_NAME_CHANGE;
        sound_wait_start_time = now_ms;
      }
      break;
    case WAIT_SOUND_STATE_RIDER_NAME_CHANGE:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_RIDER_NAME_CHANGE_MS){
        if(page != PAGE_TOP){
          play_sound(SOUND_OFFSET_RIDER_NAME_CHANGE + page);
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_EVIL_READ:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_EVIL_READ_MS){
        play_sound(SOUND_EVIL_READ_ANOTHER_RIDER);
        wait_sound_state = WAIT_SOUND_STATE_ANOTHER_RIDER_NAME_CHANGE;
        sound_wait_start_time = now_ms;
      }
      break;
    case WAIT_SOUND_STATE_ANOTHER_RIDER_NAME_CHANGE:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_ANOTHER_RIDER_NAME_CHANGE_MS){
        if(page != PAGE_TOP){
          play_sound(SOUND_OFFSET_ANOTHER_RIDER_NAME_CHANGE + page);
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_RIDER_NAME_CHANGE_FROM_CLOSE:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_RIDER_NAME_CHANGE_FROM_CLOSE_MS){
        if(sword_mode == SWORD_MODE_HOLY){
          play_sound(SOUND_OFFSET_RIDER_NAME_CHANGE + page);
        }else{
          play_sound(SOUND_OFFSET_ANOTHER_RIDER_NAME_CHANGE + page);
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_SKILL:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_SKILL_MS){
        if(page == PAGE_TOP){
          ;
        }else if(page == PAGE_BLADE){
          play_sound(random(SOUND_SKILL_BLADE_A, SOUND_SKILL_BLADE_C+1));
        }else{
          play_sound(SOUND_OFFSET_SKILL + page);
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_RIDER_TIME_UNDERSTANDING:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_RIDER_TIME_UNDERSTANDING_MS){
        if(check_read_all_legend()){
          play_sound(SOUND_OHMA_ZIO_UNDERSTANDING_HOLY);
        }else{
          if(page != PAGE_TOP){
            play_sound(SOUND_OFFSET_RIDER_TIME_UNDERSTANDING + page);
          }
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    case WAIT_SOUND_STATE_ANOTHER_RIDER_TIME_UNDERSTANDING:
      if(now_ms - sound_wait_start_time >= WAIT_SOUND_ANOTHER_RIDER_TIME_UNDERSTANDING_MS){
        if(check_read_all_legend()){
          if(page != PAGE_TOP){
            play_sound(SOUND_OHMA_ZIO_UNDERSTANDING_EVIL);
          }
        }else{
          play_sound(SOUND_OFFSET_ANOTHER_RIDER_TIME_UNDERSTANDING + page);
        }
        wait_sound_state = WAIT_SOUND_STATE_INIT;
      }
      break;
    default :
      ;
    }
  }
}

////////// BLE処理 ////////////////////////////////////////////////////////////

// Service UUID
// Macのターミナルの"uuidgen"コマンドで生成
#define SERVICE_UUID "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"

#define WAIT_BLE_ADVERTISE_STATE_NONE                         0
#define WAIT_BLE_ADVERTISE_STATE_READY_NAME_CHANGE            1
#define WAIT_BLE_ADVERTISE_STATE_READY_NAME_CHANGE_FROM_CLOSE 2
#define WAIT_BLE_ADVERTISE_STATE_READY_READING                3
#define WAIT_BLE_ADVERTISE_STATE_ADVERTISING                  4
uint8_t wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_NONE;

#define WAIT_BLE_ADVERTISE_FOR_END_MS 200
unsigned long const WAIT_BLE_ADVERTISE_FOR_NAME_CHANGE_MS = WAIT_SOUND_ADD_LEGENGD_MS + WAIT_SOUND_RIDER_NAME_CHANGE_MS;
unsigned long const WAIT_BLE_ADVERTISE_FOR_NAME_CHANGE_FROM_CLOSE_MS = WAIT_SOUND_RIDER_NAME_CHANGE_FROM_CLOSE_MS;
unsigned long const WAIT_BLE_ADVERTISE_FOR_READING_MS = WAIT_SOUND_RIDER_TIME_UNDERSTANDING_MS;
unsigned long ble_advertise_wait_start_time = 0;

BLEServer *pServer;
BLEAdvertising *pAdvertising;

void setAdvData(BLEAdvertising *pAdvertising, uint8_t value){
  BLEAdvertisementData oAdvertisementData = BLEAdvertisementData();

  // アドバタイズはペイロードとして全体で31バイトまでデータを詰め込める。
  // 「長さ(1 byte)+AD Type(1 byte)+AD Data(X byte)」(=AD structure)を繰り返す形で格納する。
  // 「長さ」は、AD TypeとAD Dataを足した長さ。
  // 以下は1行で一つのAD structureを設定している

  // Flags ... AD Type:0x01 デバイスの発見や機能についての設定
  //  ESP_BLE_ADV_FLAG_LIMIT_DISC         ... ビット0 ... LE Limited Discoverable Mode(デバイスの発見に時間制限がある場合)
  //  ESP_BLE_ADV_FLAG_GEN_DISC           ... ビット1 ... LE General Discoverable Mode(通常はこのモードを使用)
  //  ESP_BLE_ADV_FLAG_BREDR_NOT_SPT      ... ビット2 ... BR/EDR Not Supported(BLEのみをサポートする場合)
  //  ESP_BLE_ADV_FLAG_DMT_CONTROLLER_SPT ... ビット3 ... Simultaneous LE and BR/EDR to Same Device Capable (Controller)
  //  ESP_BLE_ADV_FLAG_DMT_HOST_SPT       ... ビット4 ... Simultaneous LE and BR/EDR to Same Device Capble (Host)
  oAdvertisementData.setFlags(ESP_BLE_ADV_FLAG_BREDR_NOT_SPT || ESP_BLE_ADV_FLAG_GEN_DISC); // 1+1+1=3 byte

  // Complete local name ... AD Type:0x09 デバイスの完全な名前
  // 完全な名前が長くなりそうなときは、AD Type:0x08のShortened local nameを使う(setShortName)
  oAdvertisementData.setName("WRB"); // 1+1+3=5 byte

  // ServiceUUIDの設定(※長さとAD Typeの値は関数内で自動設定される)
  oAdvertisementData.setCompleteServices(BLEUUID(SERVICE_UUID)); // 1+1+16=18 byte

  // Manufacture specific dataの設定 ... AD Type:0xFF
  //  先頭2バイトは企業の識別子の設定が必要。テスト用には0xFFFFを設定して良い
  std::string strManufacturerData = "";
  strManufacturerData += (char)0xff; // Manufaucture ID
  strManufacturerData += (char)0xff; // Manufaucture ID
  strManufacturerData += (char)value;
  oAdvertisementData.setManufacturerData(strManufacturerData); // 1+1+3=5 byte

  // 計 3+5+18+5=31
  pAdvertising->setAdvertisementData(oAdvertisementData);
}

void ble_advertise_start(uint8_t value){
  Serial.println(F("BLE Advertise Start."));
  setAdvData(pAdvertising, value);
  pAdvertising->start();
}

void ble_advertise_stop(){
  pAdvertising->stop();
  Serial.println(F("BLE Advertise Stop."));
}

// アドバタイズをするのは、以下の条件のとき
// 1. STATE_TITLEからSTATE_DRIVER_INに変化したとき(表示クリア)
// 2. STATE_DRIVER_INからSTATE_DRIVER_OPENに変化して、「ライダー名+変身音」が鳴るとき
// 3. STATE_OPENからSTATE_CLOSEに変化したとき(表示クリア)
// 4. STATE_DRIVER_CLOSEからDTATE_DRIVER_OPENに変化して、「ライダー名+変身音」が鳴るとき
// 5. STATE_INITからSTATE_READINGに変化して、「〜の刻+ふむふむ+待機音」が鳴るとき
// 6. STATE_READING_NEXTからSTATE_READINGに変化して、「〜の刻+ふむふむ+待機音」が鳴るとき

void control_ble(){

  unsigned long now_ms = millis();

  if(prev_state != state){
    // 状態遷移直後の処理
    switch(state){
    case STATE_DRIVER_IN:
      ble_advertise_start(PAGE_TOP);
      wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_ADVERTISING;
      ble_advertise_wait_start_time = now_ms;
      break;
    case STATE_DRIVER_OPEN:
      if(prev_state == STATE_DRIVER_IN){
        ble_advertise_wait_start_time = now_ms;
        wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_READY_NAME_CHANGE;
      }else if(prev_state == STATE_DRIVER_CLOSE){
        ble_advertise_wait_start_time = now_ms;
        wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_READY_NAME_CHANGE_FROM_CLOSE;
      }
      break;
    case STATE_DRIVER_CLOSE:
      ble_advertise_start(PAGE_TOP);
      wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_ADVERTISING;
      ble_advertise_wait_start_time = now_ms;
      break;
    case STATE_READING:
      ble_advertise_wait_start_time = now_ms;
      wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_READY_READING;
      break;
    default:
      ;
    }
  }else{
    switch(wait_ble_advertise_state){
    case WAIT_BLE_ADVERTISE_STATE_NONE:
      break;
    case WAIT_BLE_ADVERTISE_STATE_READY_NAME_CHANGE:
      if(now_ms - ble_advertise_wait_start_time >= WAIT_BLE_ADVERTISE_FOR_NAME_CHANGE_MS){
        ble_advertise_start(page);
        wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_ADVERTISING;
        ble_advertise_wait_start_time = now_ms;
      }
      break;
    case WAIT_BLE_ADVERTISE_STATE_READY_NAME_CHANGE_FROM_CLOSE:
      if(now_ms - ble_advertise_wait_start_time >= WAIT_BLE_ADVERTISE_FOR_NAME_CHANGE_FROM_CLOSE_MS){
        ble_advertise_start(page);
        wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_ADVERTISING;
        ble_advertise_wait_start_time = now_ms;
      }
      break;
    case WAIT_BLE_ADVERTISE_STATE_READY_READING:
      if(now_ms - ble_advertise_wait_start_time >= WAIT_BLE_ADVERTISE_FOR_READING_MS){
        ble_advertise_start(page);
        wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_ADVERTISING;
        ble_advertise_wait_start_time = now_ms;
      }
      break;
    case WAIT_BLE_ADVERTISE_STATE_ADVERTISING:
      if(now_ms - ble_advertise_wait_start_time >= WAIT_BLE_ADVERTISE_FOR_END_MS){
        ble_advertise_stop();
        wait_ble_advertise_state = WAIT_BLE_ADVERTISE_STATE_NONE;
      }
      break;
    default:
      ;
    }
  }
}

////////// メイン処理 ////////////////////////////////////////////////////////////

void setup(){
  M5.begin();

  Serial.begin(115200);
  pinMode(BOTTOM_SW_PIN,   INPUT_PULLUP);
  pinMode(SIDE_SW_PIN,     INPUT_PULLUP);
  pinMode(BACK_SW_PIN,     INPUT_PULLUP);
  pinMode(HALL_SENSOR_PIN, INPUT);

  //---------- ディスプレイ ----------
  delay(100);
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  u8g2.begin();
  u8g2.clearBuffer();
  u8g2.setBitmapMode(false);
  u8g2.setDrawColor(1);

  //---------- MP3プレイヤー ----------
  hs_mp3_player.begin(9600, SERIAL_8N1, MP3_RX_PIN, MP3_TX_PIN);

  if(!mp3_player.begin(hs_mp3_player)) {
    Serial.println(F("Unable to begin music_player:"));
    Serial.println(F("1.Please recheck the connection!"));
    Serial.println(F("2.Please insert the SD card!"));
    while(true);
  }

  Serial.println(F("mp3_player online."));
  delay(100);
  mp3_player.volume(SOUND_VOLUME_DEFAULT); 

  // BLEアドバタイズ準備
  BLEDevice::init("WRB");
  pServer = BLEDevice::createServer();
  pAdvertising = pServer->getAdvertising();
}

void loop(){

  unsigned long now_ms = millis();

  //////////////////// 状態遷移管理 ////////////////////
  bottom_sw   = digitalRead(BOTTOM_SW_PIN);
  side_sw     = digitalRead(SIDE_SW_PIN);
  back_sw     = digitalRead(BACK_SW_PIN);
  hall_sensor = digitalRead(HALL_SENSOR_PIN);

  switch(state){
  case STATE_INIT:
    if(prev_bottom_sw == OFF && prev_side_sw == OFF){
      if(bottom_sw == ON && side_sw == OFF){
        state = STATE_TITLE;
        is_bottom_sw_pressing = true; // 下部ボタン長押し認識開始
        bottom_sw_long_press_start_time = now_ms;
      }else if(bottom_sw == OFF && side_sw == ON){
        state = STATE_STORY;
      }else if(bottom_sw == ON && side_sw == ON){
        state = STATE_DRIVER_PUSH;
      }else if(prev_back_sw == OFF && back_sw == ON){
        is_back_sw_pressing = true; // 追加ボタン長押し認識開始
        back_sw_long_press_start_time = now_ms;
      }
    }
    break;
  case STATE_TITLE:
    // STATE_DRIVER_INへの遷移は時間経過なので別処理
    if(prev_bottom_sw == ON && prev_side_sw == OFF){
      if(bottom_sw == OFF && side_sw == OFF){
        state = STATE_INIT;
        is_bottom_sw_pressing = false; // 下部ボタン長押し認識終了
      }else if(bottom_sw == ON && side_sw == ON){
        state = STATE_DRIVER_PUSH;
        is_bottom_sw_pressing = false; // 下部ボタン長押し認識終了
      }
    }
    break;
  case STATE_DRIVER_IN:
    if(prev_bottom_sw == ON && prev_side_sw == OFF){
      if(bottom_sw == OFF && side_sw == OFF){
        state = STATE_INIT;
      }else if(prev_hall_sensor == CLOSE && hall_sensor == OPEN){
        state = STATE_DRIVER_OPEN;
        special_sword_timer_start_time = now_ms; // 特殊聖剣用のタイマー
      }else if(prev_hall_sensor == OPEN && hall_sensor == CLOSE){
        state = STATE_DRIVER_CLOSE;
      }
    }
    break;
  case STATE_DRIVER_OPEN:
    if(prev_bottom_sw == ON && prev_hall_sensor == OPEN){
      if(bottom_sw == OFF && hall_sensor == OPEN){
        state = STATE_INIT;
      }else if(bottom_sw == ON && side_sw == ON){
        state = STATE_DRIVER_PUSH;
      }else if(bottom_sw == ON && hall_sensor == CLOSE){
        state = STATE_DRIVER_CLOSE;
      }
    }
    break;
  case STATE_DRIVER_CLOSE:
    if(prev_bottom_sw == ON && prev_hall_sensor == CLOSE){
      if(bottom_sw == OFF && hall_sensor == CLOSE){
        state = STATE_INIT;
      }else if(bottom_sw == ON && hall_sensor == OPEN){
        state = STATE_DRIVER_OPEN;
      }
    }
    break;
  case STATE_DRIVER_PUSH:
    if(prev_bottom_sw == ON && prev_side_sw == ON){
      if(bottom_sw == OFF && side_sw == OFF){
        state = STATE_INIT;
      }else if(bottom_sw == ON && side_sw == OFF){
        state = STATE_DRIVER_OPEN;
      }else if(bottom_sw == OFF && side_sw == ON){
        state = STATE_STORY;
      }
    }
    break;
  case STATE_STORY:
    if(prev_bottom_sw == OFF && prev_side_sw == ON){
      if(bottom_sw == OFF && side_sw == OFF){
        state = STATE_INIT;
      }else if(bottom_sw == ON && side_sw == ON){
        state = STATE_DRIVER_PUSH;
      }
    }
    break;
  case STATE_READING:
    if(prev_bottom_sw == OFF && prev_back_sw == ON){
      if(bottom_sw == OFF && back_sw == OFF){
        state = STATE_INIT;
      }else if(bottom_sw == ON && back_sw == ON){
        state = STATE_READING_NEXT;
      }
    }
    break;
  case STATE_READING_NEXT:
    if(prev_bottom_sw == ON && prev_back_sw == ON){
      if(bottom_sw == ON && back_sw == OFF){
        state = STATE_INIT;
      }else if(bottom_sw == OFF && back_sw == ON){
        state = STATE_READING;
      }
    }
    break;
  default:
    ;
  }

  //---------- bottom_sw 長押し処理 ----------//
  if(is_bottom_sw_pressing && now_ms - bottom_sw_long_press_start_time >= BOTTOM_SW_LONG_PRESS_MS){
    state = STATE_DRIVER_IN;
    is_bottom_sw_pressing = false;    // 下部ボタン長押し認識終了

    // このタイミングで、聖剣か邪剣かを判定する
    if(back_sw == OFF){
      sword_mode = SWORD_MODE_HOLY;
    }else{
      sword_mode = SWORD_MODE_EVIL;
    }
  }

  //---------- back_sw 長押し処理 ----------//
  if(prev_back_sw == ON && back_sw == OFF){
    is_back_sw_pressing = false;
  }

  if(is_back_sw_pressing && now_ms - back_sw_long_press_start_time >= back_sw_LONG_PRESS_MS){
    if(state == STATE_INIT){
      state = STATE_READING;
    }
    is_back_sw_pressing = false;    // 追加ボタン長押し認識終了
  }

  //---------- ページ送り処理 ----------//
  if((prev_state == STATE_TITLE        && state == STATE_INIT) ||
     (prev_state == STATE_DRIVER_IN    && state == STATE_DRIVER_CLOSE) ||
     (prev_state == STATE_DRIVER_OPEN  && state == STATE_DRIVER_CLOSE) ||
     (prev_state == STATE_READING_NEXT && state == STATE_READING) ){
    page++;
    if(page > PAGE_ZIO){
      page = PAGE_KUGA;
    }
    Serial.print(F("Page No."));
    Serial.println(page);
  }

  ////////// 必殺技用ページ読取管理1 /////////////
  if((prev_state == STATE_INIT || prev_state == STATE_READING_NEXT) && state == STATE_READING){
    if(page != PAGE_TOP){
      legend_read[page-1] = true;
    }
  }

  ////////// 音声再生処理 ////////////////////
  control_sound();

  ////////// 必殺技ページ読取管理2(リセット) /////////////
  if((prev_state == STATE_READING || prev_state == STATE_READING_NEXT) && state == STATE_INIT){
    reset_legend_read();
  }

  ////////// ディスプレイ処理 ////////////////////
  control_display();

  ////////// BLE処理 ///////////////////////
  control_ble();

  ////////// デバッグ処理 ////////////////////
  print_state();

  ////////// 処理状態の保持 //////////////////
  prev_bottom_sw   = bottom_sw;
  prev_side_sw     = side_sw;
  prev_back_sw     = back_sw;
  prev_hall_sensor = hall_sensor;
  prev_state       = state;

  delay(LOOP_DELAY_MS);
}

一点ご了承頂きたいのは、実際にはこのファイルの他に、先のソフトウェア解説の方でも触れましたが、”riders_crest.h”というファイルが必要になります。こちらに全てのライダーズクレストのbitmap画像のデータ配列を記述しているのですが、これを全文掲載してしまうと、それだけでおよそ2600行になってしまいます。これは流石にブログに書くには大き過ぎるので、省略させて頂きました。作成の仕方(ヒント)は上記解説内で述べておりますので、データ配列を記載したファイルをご自身で用意頂けますと幸いです。

次に、火炎剣烈火側のソースコードです。こちらはワンラダーライドブック側に比べると、ずっとシンプルです。常時BLEアドバタイズの受信待ちをしていて、受信したら受信待ちを解除し、ディスプレイ表示を更新して、再度BLEアドバタイズの受信を待つ、という動作になっています。こちらもワンダーライドブック側と同様、bitmap画像のデータ配列を記述した”riders_crest.h”の掲載は省略させて頂いておりますので、ご了承ください。

////////// 基本定義 ////////////////////////////////////////////////////////////

#include "M5Atom.h"

#define LOOP_DELAY_MS 5
#define I2C_SDA_PIN 22
#define I2C_SCL_PIN 19  

uint8_t page = 0;

////////// 描画処理 //////////////////////////////////////////////////////
#include "riders_crest.h" // クレストのバイナリデータは全てこちらに記述

// ESP32と純正ライブラリは相性が悪いので、以下のライブラリを使う
// https://github.com/ThingPulse/esp8266-oled-ssd1306

#include <Wire.h>
#include "SSD1306Wire.h"
#define WIDTH 128
#define HEIGHT 64

// デフォルトではアドレスは0X3D
SSD1306Wire ssd1306(0x3D, I2C_SDA_PIN, I2C_SCL_PIN, GEOMETRY_128_64);

////////// BLE処理 ////////////////////////////////////////////////////////////

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>

// Service UUID
// Macのターミナルの"uuidgen"コマンドで生成
#define SERVICE_UUID "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"

#define SCAN_DURATION_S   600 // 一旦10分間でスキャンはやめさせる
#define SCAN_INTERVAL_MS  100
#define SCAN_WINDOWS_MS   100 // Interval以下の値にする必要あり。Intervalの間の内、何msスキャンするか

BLEScan* pBLEScan;

void ble_scan_start(){
  Serial.println(F("BLE Scan Start."));
  pBLEScan->start(SCAN_DURATION_S, false);
}

void ble_scan_stop(){
  pBLEScan->clearResults();
  pBLEScan->stop();
  Serial.println(F("BLE Scan Stop."));
}

class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
    void onResult(BLEAdvertisedDevice advertisedDevice) {
      //Serial.printf("Advertised Device: %s \n", advertisedDevice.toString().c_str());
      if(advertisedDevice.haveServiceUUID() && advertisedDevice.getServiceUUID().equals(BLEUUID(SERVICE_UUID))){
        Serial.println(F("WRB Found!"));
        // サービスUUIDを見て、WRBの発するアドバタイズパケットであることを確認
        if(advertisedDevice.haveManufacturerData()){
          // ManufacturerDataが入っていれば処理
          std::string data = advertisedDevice.getManufacturerData();
          int manufacture_id = data[1] << 8 | data[0];
          if(manufacture_id == 0xffff){
            // 受信ページの認識
            page = (uint8_t)data[2];
            // 描画が終わるまで一旦スキャンを止める
            ble_scan_stop();
          }
        }
      }
    }
};

////////// メイン処理 ////////////////////////////////////////////////////////////

void setup(){
  M5.begin();
  Serial.begin(115200);

  // ディスプレイセットアップ
  ssd1306.init();    //ディスプレイを初期化
  ssd1306.display();   //指定された情報を描画

  // BLEスキャン準備
  BLEDevice::init("");
  pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
  pBLEScan->setActiveScan(false); // パッシブスキャン設定
  pBLEScan->setInterval(SCAN_INTERVAL_MS);
  pBLEScan->setWindow(SCAN_WINDOWS_MS);
}

void loop(){
  ble_scan_start(); // ここで、受信までループは止まる

  // 描画処理
  ssd1306.clear();
  if(page != 0){
    ssd1306.drawFastImage(0,0,WIDTH,HEIGHT,riders_crest[page-1]);
  }
  ssd1306.display();

  delay(LOOP_DELAY_MS);
}