====== フルーツフィールド for M5 Stack / M5 Core2 ====== ===== 概要 ===== フルーツフィールドの勝手移植第二段。 M5 Stack に BLEキーボードをつないだら、何か、アクション性のあるものも作ってみたくなりました。 でないと、キーボードにオートリピートをつける動機づけができないので。 そんなわけで、アクション性、というほどのものではないが、パズルは程よい題材だということで、フルーツフィールドを移植することに。 {{:ffm5:title.png?400|}} {{::ffm5:phase0.png?400|}} {{::ffm5:phase1.png?400|}} ===== 移植の方針 ===== PC-6001mkII版に使った、PC-8001版のキャラクターをベースにしたものを今回も流用することに。 ただし、PC-6001mkII版は、PC-6001mkIIのVRAMに合わせたデータ構造にしてしまったので、これを逆に汎用の形に戻して、改めてRGB332 ((8bitカラー))にすることに。 ハイハイスクールアドベンチャー経験から、スプライトが使えるといってばかばか使っているとあっという間にメモリを使い切ってしまうので最初から8bppで、しかも、8x8のキャラクターを16x16に引き伸ばして表示する方向でさらにメモリをケチることに。 音は、本格的なサウンドドライバーを作ったりしないで、単純に割り込みの中で、M5.Speaker.tone() を使って適当に鳴らすだけにします。 この割り込みを使うのも今回のテーマの一つです。 あとは、BLEキーボードハンドラに、オートリピートを持たせること。 とりあえず、コンストラクションモードは省いて、したがって、コンストラクションデータの保存と読込もとりあえずは省いていくことにします。 以下、実装にあたってのあれこれを書き綴ります。 ===== 画面 ===== M5 Stack/M5 Core2 は 320x240 の 16bppの画面を持っているので、160x100 の PC-8001 由来のゲームを移植するのには不足はありません。 方針のところにも書いたように、キャラクターは8x8でM5GFX のスプライトとして保持します。 描画するときに、いずれかの段階で16x16にストレッチすることにします。 ストレッチのコストはありますが、メモリをけちるのはこの環境では大切です。 キャラクタを16x16で表示することにすると、ゲームマップは 16x10キャラクター構成なので、256x160 pixels の画面領域を占有します。 一応、M5 ATOM + 外部液晶(240x240)も視野に入れているので、拡大縮小は必須でもあります。 なお、タイトルロゴは、PC-8001版からの流用ではなく、Google Geminiに作らせたものを使っています。 ===== 割込 ===== キーのリピート処理や、BGMなど、ゲームの負荷に関係なく一定間隔で行いたいものは、割込み処理で行う。 ESP32は4本のタイマー割込みを持っているので、キーとBGMと分けてもいいなとか思ったんですが、収拾がつかなくなるだけのような気もしたので、一つの割込みの中かっら、キーボードと、BGM処理をそれぞれ呼び出すことで済ませています。 タイマーは0.1秒間隔で割り込むように設定してある。 ===== 音 ===== 本格的なサウンドドライバーとか作ってMMLを処理するなんていう大それた野望はなく、そもそも、X1版のフルーツフィールドもPSGをひっぱたくための周波数データが置いてあるだけだったので、それを M5.Speker.tone()に食わせられる値に変換して持ってきただけで済ませてある。 タイマー割込みでタイミングをとって、APIをひっぱたくだけで済ませている。 ちょっと音痴なので、気が向いたら手を入れるかもしれないが、そもそも私自身が音痴なので、かえっておかしくしてしまうかもしれない。 ===== キーボード回り ===== わかっている。 本当はゲームなら、オートリピートじゃなくて、今押されているキーを取得できるIFをつけるべきだっていうのは。 まあ、正直、それもできなくもない。 多分、それをやるために、何か次のゲームをM5に乗せようとするに違いない。 あるいは、フルーツフィールドで試すかもしれない。 でも、とりあえずは、まずはオートリピートである。 ==== BLEキーボードとの通信 ==== ほとんどすべてのBLEキーボードは、キーを押したときと離したときに通知を送ってくる。 それ以外は通信しない。 だからこその省電力なので、当然なのだが、なので、キーのリピートなんかの処理はセントラル/クライアント側でやらないといけない。 ===== デモ ===== デモは、タイトル画面の時に、REM君が動き回って、ブロックを動かし、壊して、フルーツを回収する奴が、PC-8001版で用意されていたので、それをそのまま頂戴します。 まあ、疑似的なキー入力を定義しておいて、順にそれを食っていくだけなので、大したことはありません。 ただ、よく考えると、キー操作の説明がどこにもないなと思ったので、キー操作の説明画面も作り、上のデモと交互に出るようにしました。 PC-6001mkIIでは [SHIFT]でGive upでしたが、M5版では [ESC]をGive upに割り付けてあります。 まあ、それが表示されるだけなんですけれどね。 タイトルロゴは、Geminiに「作って」っていって作ってもらいました。 ===== スクリーンショット ===== M5Stackにせよ、なんにせよ、スクリーンショットを撮る機能は搭載されていないので、スクリーンショットが欲しかったら、それをプログラムに組み込むしかない。 めんどくさいファイル形式はコードがデカくなるだけなので、BMP形式で出力するものとする。 めんどくさいからパレットも省略して((多分これはちょっとまずいかもしれないけれど))、M5.Displayの表示している16bppの画像をがさっと書き出す。 大したことないだろうと思ったら、ちょっとだけめんどくさかった。 ==== ヘッダを作れ ==== BMP形式のファイルは、ファイルヘッダ+情報ヘッダ+パレット情報+画像データという構成になっている。 なので、ヘッダをつけてやればBMP形式ファイルの出来上がりである。 そう。キモはヘッダなのだ。 ヘッダの構成は次のようになっているらしい。 ファイルヘッダ ^オフセット^サイズ^名称^内容^ |+0x0000|2|bfType|ファイルタイプ 'BM' (0x4d42)| |+0x0002|4|bfSize|画像サイズ in bytes| |+0x0006|2|bfReserved1|予約領域(0)| |+0x0008|2|bfReserved2|予約領域(0)| |+0x000a|4|bfOffBits|ファイルの先頭から画像データまでのオフセット in bytes パレットがなければ 14 + 40)| 情報ヘッダ ^オフセット^サイズ^名称^内容^ |+0x000e|4|biSize|情報ヘッダサイズ 40 (OS/2だと違うらしい)| |+0x0012|4|biWidth|画像幅 in pixels| |+0x0016|4|bcHeight|画像高 in pixels ... 正数の場合画像データは下から上に向かって、負数なら上から下向かって並ぶ| |+0x001a|2|bcPlanes|プレーン数 (1)| |+0x001c|2|bcBitCount|画素当たりのデータサイズ in bits| |+0x001e|4|biCompression|圧縮形式 無圧縮なら 0| |+0x0022|4|biSizeImage|画像サイズ in bytes / bfSizeと同じ| |+0x0026|4|biXPixPerMeter|X方向の画像密度 2インチモニタのM5Stackでは200dpiで7874になる(はず)| |+0x002a|4|biYPixPerMeter|Y方向の画像密度 上に同じ| |+0x002e|4|biClrUsed|格納されているパレット数(0)| |+0x0032|4|biClrImportant|重要なパレットのインデックス(0)| 定義はシンプルだし、大してめんどくさくなさそうだけれど、いくつか注意しないといけないポイントがある。 === エンディアン === ESP32は Little Endianである。 なんで、'BM'という文字コードを格納しようとして、bfType = ('B' << 8) | 'M'; とかすると、'MB'の順に格納されてしまう。 0x424d としても同じこと。 定義にそって、uint16_t bfType とするより、uint8_t bfType[2] として、'B', 'M'の順に格納する方がいいかもしれない。 === アラインメント === 昔からそうだけれど、CPUがメモリーにアクセスするとき、アラインメントに沿っていないとアクセスが遅くなったり、場合によっては、取り出しが分割されたりするので、気の利いたコンパイラだと、構造体の定義は、いい感じにアラインメントされるように処理される。 すると、ヘッダのデータが狙った場所に出てこなくなる。 なので、構造体を使ってヘッダーを作成するなら、ヘッダーを詰めて配置するようにコンパイラに指示しなければならない。 大体が、組込みプロセッサであるESP32の場合、デフォルトがこっちでいいんじゃないかという気もするんだが。 struct __attribute__((__packed__)) BMP_File_Header { uint16_t bfType; uint32_t bfSize; uint16_t bfReserved1, bfReserved2; uint32_t bfOffBits; }; struct __attribute__((__packed__)) BMP_Info_Header { uint32_t biSize; uint32_t biWidth; uint32_t biHeight; uint16_t biPlanes; uint16_t biBitCount; uint32_t biCompression; uint32_t biSizeImage; uint32_t biXPixPerMeter; uint32_t biYPixPerMeter; uint32_t biClrUsed; uint32_t biClrImportant; }; なんで、こんなことを書いているかというと、まさにこれがゆえに、作成されたBMPファイルは「サポートされない形式」っていわれてしまったのだから。 ==== ファイル名 ==== これはスクリーンショットと直接関係はないのだけれど、ファイル名を自動的に生成できるようにしておいた方が、多分便利。 RTCが入っていれば、yyyymmdd_hhmmss とかを入れておけば、一秒以内に立て続けにスクショされない限りはユニークなファイル名が補償できるが、M5StackにRTCがつながっているかどうかわからない。 というか、多分使われていないだろう。 スクショを撮る関数はファイル名を受け付けるようにしておくが、なかったら、screenshot_0000.bmp から始めて、9999まで10000枚の画像を保存できるようにしておこう. 4桁にしたことに深い意味はない。 5桁や6桁が良ければそのようにプログラムを改変すればいいだろう。 char ScreenShot::_filename[PATH_MAX] = "/screenshot_0000.bmp"; const char * ScreenShot::get_next_filename() { char num[5]; strncpy(num, strchr(_filename, '_') + 1, 4); int n = strtol(num, nullptr, 0); while (SD.exists(_filename)) { if (++n > 9999) n = 0; snprintf(num, 5, "%04d", n); strncpy(strchr(_filename, '_') + 1, num, 4); } return _filename; } ==== スクリーンショット本体 ==== あとはヘッダを整えて、画像データをくっつけるだけである。 void ScreenShot::take(const char *filename) { if (filename == nullptr) { filename = get_next_filename(); } _bf = { ('M'<<8)|'B', (uint32_t)(M5.Displays(0).width() * M5.Displays(0).height() * (M5.Displays(0).getColorDepth() / 8)), 0, 0, 14 + 40, }; _bi = { 40, (uint32_t)M5.Displays(0).width(), (uint32_t)M5.Displays(0).height(), 1, M5.Displays(0).getColorDepth(), 0, (uint32_t)(M5.Displays(0).width() * M5.Displays(0).height() * (M5.Displays(0).getColorDepth() / 8)), 7874, 7874, 0, 0, }; uint16_t lineBuf[M5.Displays(0).width()]; uint8_t b = M5.Displays(0).getBrightness(); M5.Displays(0).setBrightness(20); File fp = SD.open(filename, FILE_WRITE); if (fp) { fp.write((uint8_t*)&_bf, sizeof(_bf)); fp.write((uint8_t*)&_bi, sizeof(_bi)); for (int y = M5.Displays(0).height() - 1 ; y >= 0 ; y--) { for (int x = 0 ; x < M5.Displays(0).width() ; x++) { lineBuf[x] = M5.Displays(0).readPixel(x, y); } fp.write((uint8_t*)lineBuf, sizeof(lineBuf)); } fp.close(); } M5.Displays(0).setBrightness(b); } 画面を暗くしているのは、SDにアクセスするときに暗くしないとうまく動かないとかいう噂を聞きつけたからだけれど、[[ハイハイスクールアドベンチャー]]で既に平気で動かしていたのだから、関係ないのはわかっている。 でも、スクショの処理をしている間暗くなるのはシャッターを切っているみたいでいいので、コードに含めている。 なお、スクリーンショット撮るの思ったより時間がかかるなっていうのが印象。 SDへの書き込み速度の問題だと思う。