雑貨屋に売っていた脳トレゲームを作ってみた(M5StackCore2+GPT)

# はじめに
雑貨屋をうろついていたときに、脳トレ系ゲームのデモ機が置いてありました。
触っているうちに「これM5で似たようなもの作れそうだな」って思ったので正月休みの工作として作ってみました。

一方で0ベースで作るのは面倒だったので、GPTを駆使してどこまでできるかチャレンジしてみた。


## 作りたかったもの
脳トレゲーム機です。

* 数字が書かれたカードを挿入
* 小さい数字から順番に押していく
* タイムアタックを行う


<iframe width="1236" height="695" src="https://www.youtube.com/embed/pzwLZ_9Vn1U" title="【ナンスピ/NUMBER SPEED】ポチっと脳トレ！老若男女が遊び感覚で脳を鍛えられる脳トレトイ！ 【日本おもちゃ大賞2024 ゲーム&amp;パズル部門 優秀賞受賞】" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

カワダ(Kawada) ナンスピ 

https://www.kawada-toys.com/brand/game/catalog/kg-039/



# 実際に作ったもの

## デモ動画
3*3マスで作ってみました。なんかそれっぽい。

<iframe width="458" height="814" src="https://www.youtube.com/embed/iUztmdL0dxo" title="M5stackCore2 countup game" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

## 作成手順

GPTも使いながら最速で実装する方法を言語化してみた。

1. まず仕様を書き出す。機能ごとに粒度を分けてGPTへ指示・デバックしやすいように。
1. 仕様メモを元に機能を作っていく
    1. 足りない内容があれば都度仕様メモに追記していく
1. バグがあればさらに粒度を分解して調査する。
    1. 今回座標の微調整だけは手作業で行った。
1. 各機能ごとにデバックを行い、バグをつぶしてから次へ



## 仕様メモ
実際にGPTにインプットしたメモです。
作りながら仕様は都度追記しています。

:::note
仕様自体もGPTに相談しながら作ると良いです。

:::

```
# ゲーム名
数字消しゲーム

# ハードウェア
- M5StackCore2

## 使用ライブラリ
- M5Core2
- LovyanGFX  

# 概要
3*3で数字が表示されます。その数字を小さい順番に押していくゲームです。
時間がスコアになります。


# 仕様
- 3*3の数字が表示されます。
- 数字は1~20までの数字がランダムに表示されます。
- 数字を押すと、その数字が消えます。
- 消す順番は小さい順番に押していく必要があります。
- 全ての数字を消すとクリアです。
- クリアした時間がスコアになります。

# 開発手順

1. 数字の表示
2. タッチした数字を判定する
3. 消す順番が正しいかどうかをチェックする
4. クリア判定とクリア後の処理


## 1. 数字の表示
- 3*3の数字を表示する
- 数字は1~20までの数字をランダムに表示する
- 数字は重複しないようにする
`
## 2. タッチした数字を判定する
- タッチした数字を判定する

## 3. 消す順番が正しいかどうかをチェックする
- 表示されている数字で最小の数字を探す
- 最小の数字をタッチしたら消す
    - 青い枠で囲む
- 最小でない数字をタッチした場合
    - 赤い枠で囲む

## 4. クリア判定とクリア後の処理
- 全ての数字を消したらクリア
- クリアした時にスコアを表示する
- タイムを表示する
- リトライボタンを表示する
```

## コード

```cpp
#include <M5Core2.h>
#include <vector>
#include <algorithm> // std::shuffle
#include <random>    // std::mt19937, std::random_device

// LovyanGFX 関連
#define LGFX_AUTODETECT
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
#include <LGFX_AUTODETECT.hpp>

static LGFX lcd;                 // LovyanGFX本体
static LGFX_Sprite sprite(&lcd); // スプライトで描画を行う

const int screenWidth = 320;
const int screenHeight = 240;
const int radius = 36;
const int textSize = 4;
const int textHeight = 18; 

const int gridWidth = 3;
const int gridHeight = 3;
const int cellSize = 80;

int numbers[gridWidth][gridHeight];

int gridTotalWidth = cellSize * gridWidth;
int gridTotalHeight = cellSize * gridHeight;

int startX = (screenWidth - gridTotalWidth);
int startY = cellSize / 2;

int lastRemoved = 0;
unsigned long startTime;
unsigned long finalTime = 0;

void displayRandomNumbers()
{
  sprite.fillScreen(TFT_BLACK);

  // 1～20の数字を用意
  std::vector<int> candidates;
  candidates.reserve(20);
  for (int n = 1; n <= 20; n++)
  {
    candidates.push_back(n);
  }

  uint32_t seedVal = esp_random();
  std::mt19937 g(seedVal);
  std::shuffle(candidates.begin(), candidates.end(), g);

  // 先頭9個を 3x3 に配置
  int idx = 0;
  for (int i = 0; i < gridWidth; i++)
  {
    for (int j = 0; j < gridHeight; j++)
    {
      numbers[i][j] = candidates[idx];
      idx++;

      int x = startX + i * cellSize;
      int y = startY + j * cellSize;

      sprite.drawCircle(x, y, radius, TFT_WHITE);

      String numStr = String(numbers[i][j]);

      sprite.setTextSize(textSize);
      sprite.setTextColor(TFT_WHITE);
      sprite.setFont(&fonts::efontJA_10);

      int16_t txtWidth = sprite.textWidth(numStr);
      int16_t txtHeight = sprite.fontHeight();

      int txtX = x - (txtWidth / 2);
      int txtY = y - (txtHeight / 2);

      sprite.setCursor(txtX, txtY);
      sprite.print(numStr);
    }
  }

  sprite.pushSprite(0, 0);
}

// -----------------------------
// 枠線を描画 (赤/青枠)
// -----------------------------
void drawBorder(int color)
{

  sprite.fillRect(0, 0, screenWidth, 10, color);                 // 上端
  sprite.fillRect(0, 0, 10, screenHeight, color);                // 左端
  sprite.fillRect(screenWidth - 10, 0, 10, screenHeight, color); // 右端
  sprite.fillRect(0, screenHeight - 10, screenWidth, 10, color); // 下端

  sprite.pushSprite(0, 0);
}

// -----------------------------
// 最小の数字を調べる
// -----------------------------
int findSmallestNumber()
{
  int smallestNumber = 21;
  for (int i = 0; i < gridWidth; i++)
  {
    for (int j = 0; j < gridHeight; j++)
    {
      if (numbers[i][j] != 0 && numbers[i][j] < smallestNumber)
      {
        smallestNumber = numbers[i][j];
      }
    }
  }
  return smallestNumber;
}

// -----------------------------
// タッチされた数字を消す
// -----------------------------
void removeNumberAtTouch()
{
  int smallestNumber = findSmallestNumber();

  for (int i = 0; i < gridWidth; i++)
  {
    for (int j = 0; j < gridHeight; j++)
    {
      int x = startX + (i * cellSize);
      int y = startY + (j * cellSize);

      TouchPoint_t tp = M5.Touch.getPressPoint();
      if (abs(tp.x - x) <= radius && abs(tp.y - y) <= radius)
      {
        // タッチされた数字
        int touchedNumber = numbers[i][j];
        if (touchedNumber == smallestNumber)
        {
          numbers[i][j] = 0;
          sprite.fillCircle(x, y, radius, TFT_BLACK);

          lastRemoved = touchedNumber;
          drawBorder(TFT_BLUE);
        }
        else
        {
          drawBorder(TFT_RED);
        }
      }
    }
  }
}

// -----------------------------
// ゲームクリア画面を表示 (スプライト)
// -----------------------------
void displayClearTime()
{
  if (finalTime == 0)
  {
    finalTime = millis() - startTime;
  }

  float seconds = finalTime / 1000.0f;

  sprite.fillScreen(TFT_BLACK);

  sprite.setCursor(80, 60);
  sprite.setTextSize(3);
  sprite.setTextColor(TFT_YELLOW);
  sprite.print("Excellent!!");

  sprite.setCursor(80, 100);
  sprite.setTextSize(3);
  sprite.setTextColor(TFT_WHITE);
  sprite.printf("Time: %.1f s", seconds);

  // リトライボタン描画 --------------------------
  int rectX = 80;
  int rectY = 155;
  int rectW = 160;
  int rectH = 40;
  sprite.drawRect(rectX, rectY, rectW, rectH, TFT_GREEN);

  // 中心に「RETRY」文字を描画
  sprite.setTextSize(3);
  sprite.setTextColor(TFT_GREEN);
  String label = "RETRY";
  int16_t labelWidth = sprite.textWidth(label);
  int16_t labelHeight = sprite.fontHeight();
  int labelX = rectX + (rectW - labelWidth) / 2;
  int labelY = rectY + (rectH - labelHeight) / 2;
  sprite.setCursor(labelX, labelY);
  sprite.print(label);

  sprite.pushSprite(0, 0);

  // リトライボタンをタッチしたかチェック -------
  TouchPoint_t tp = M5.Touch.getPressPoint();
  if (tp.x >= rectX && tp.x <= rectX + rectW &&
      tp.y >= rectY && tp.y <= rectY + rectH)
  {
    // ゲームリセット処理（必要十分な処理に絞る）
    finalTime = 0;
    lastRemoved = 0;
    displayRandomNumbers();
    startTime = millis();
  }
}

// -----------------------------
// ゲームの進行状況を監視
// -----------------------------
void checkGameStatus()
{
  bool gameCleared = true;

  for (int i = 0; i < gridWidth; i++)
  {
    for (int j = 0; j < gridHeight; j++)
    {
      if (numbers[i][j] != 0)
      {
        gameCleared = false;
        break;
      }
    }
  }

  if (gameCleared)
  {
    displayClearTime();
  }
}

// -----------------------------
// 初期化
// -----------------------------
void setup()
{
  M5.begin();
  lcd.init();
  randomSeed(micros());

  sprite.setColorDepth(8);
  sprite.createSprite(lcd.width(), lcd.height());

  sprite.fillScreen(TFT_BLACK);
  sprite.pushSprite(0, 0);

  startTime = millis();

  displayRandomNumbers();
}

// -----------------------------
// ループ
// -----------------------------
void loop()
{
  removeNumberAtTouch();
  checkGameStatus();
}

```