前に戻る

※ 最低限のメモのようなもので、資料としてちゃんと整えておりません。あくまで参考までに。。

参考資料

論文色々あります。当時(2015年くらい?)、私(土田)と同期の渡邉くん、そして一学年上の伊藤先輩の3人で、ゼミ合宿のD学生企画として開発したのがウェアラブルだるまさんがころんだである。「身体を使ったゲームをつくろう」というテーマで議論する中で生まれたアイデアで、合宿で結構ウケて、その数年後に研究として扱ってもらえました。

三木隆裕,大西鮎美,出口嵐以貴,土田修平,伊藤悠真,寺田努,塚本昌彦,ウェアラブルだるまさんがころんだ:加速度センサによる機械判定を用いた多人数型ゲームの実装と運用,エンタテインメントコンピューティングシンポジウム2017論文集,pp.174–183,2017. https://cir.nii.ac.jp/crid/1050855522066186624

柳生 遥, 大西鮎美, 土田修平, 寺田 努, 塚本昌彦: スポーツにおける機械判定システムのための人の納得感に基づく判定基準調整手法, 情報処理学会研究報告ヒューマンコンピュータインタラクション(HCI), Vol. 2021-HCI-195, No. 46, pp. 1-8 (2021).

準備物

  • M5StickC Plus2
  • PC

システム全体のイメージ

PCとだるまさんが転んだができる

とりあえずGPT先生に相談

まずは以下のプロンプトをChatGPTに投げてみました。

「ウェアラブルだるまさんがころんだ」を作ろうと思う。 
イメージとしては
・PCが「だるまさんがころんだ」を読み上げる。
・「だー」「るー」など一音一音、mp3などで手元に置いておいて、順次再生していく。それぞれの文字はある程度ランダムな時間で再生される。
・読み終わったら、3秒程度待ったら、また読み始める。 
・スペースキーを押すと、ユーザの勝ち。最初に戻る 
・ユーザはM5stickCplus2を身体に身につけており、加速度の合成値がある一定以上の値を超えると動いたとみなす。 
・動いたとみなしたら、M5からPCにBluetooth経由で合図を送る。例えば番号など。この場合ユーザの負け。最初から。 
・PCの画面上に、ユーザが読み上げ中に動いたこと(アウトであること)がわかるように表示。例えば背景が赤色になって、文字でOUTと出るなど。

返ってきたものをベースにちょこちょこ修正しつつ完成目指します。

全体構成

役割分担

  • M5StickC Plus2
    • 3軸加速度センサから加速度の合成値を取得
    • しきい値を超えたら「動いた」とみなして OUT\n を Bluetooth (Serial) でPCへ送る
  • PC (Python)
    • 「だるまさんがころんだ」を1音ずつmp3再生(ランダム間隔)
    • 読み上げ中に OUT を受信したら「負け」表示(背景赤+OUT)
    • Spaceキーが押されたら「勝ち」表示(背景緑+WIN)
    • 読み上げ終わったら3秒待って自動で次のラウンド開始

ここでは PC 側は Python + pygame + pyserial 前提で書く。

1. M5StickC Plus2 側コード(Arduino)

仕様

  • 加速度の合成値 |a| = sqrt(ax²+ay²+az²) を計算
  • |a| > MOVE_THRESHOLD になったら「動いた」と判定
  • 連続検出を防ぐためにクールダウン時間(例: 1秒)を入れる
  • 動いたら Bluetooth シリアル経由で OUT\n を送信
  • デバッグ用に USB シリアルにも値を出す

修正

  • m5、赤色になっても加速度の値はディスプレイで確認できるようにしておきたい
  • ボタンを押すとリセットできるようにしたい
#include <M5StickCPlus2.h>
#include "BluetoothSerial.h"

BluetoothSerial SerialBT;

// ↓ 自分の環境で要調整
const float MOVE_THRESHOLD = 1.10f;          // g 単位。静止時はだいたい 1.0 前後
const unsigned long COOLDOWN_MS = 1000;     // 1秒に1回だけ OUT を送る

unsigned long lastSendMillis = 0;
bool moved = false;      // 一度「動いた」ことがあるか
bool isRedBg = false;    // 画面が赤背景かどうか

// BT 受信用バッファ
char btBuf[32];
int  btPos = 0;

void applyBgColor() {
  // 現在の isRedBg に応じて背景を塗り直してヘッダを書き直す
  uint16_t bg = isRedBg ? RED : BLACK;
  uint16_t fg = WHITE;

  M5.Display.fillScreen(bg);
  M5.Display.setTextColor(fg, bg);
  M5.Display.setTextSize(2);
  M5.Display.setCursor(0, 0);
  M5.Display.println("Daruma Stick");
  M5.Display.println("BT: DARUMA_STICK");
}

void drawAccel(float mag) {
  uint16_t bg = isRedBg ? RED : BLACK;
  uint16_t fg = WHITE;

  M5.Display.setTextSize(2);
  // 数値部分だけ上書きするために小さめの矩形を塗ってクリア
  M5.Display.fillRect(0, 80, 200, 30, bg);
  M5.Display.setCursor(0, 80);
  M5.Display.setTextColor(fg, bg);
  M5.Display.printf("|a|=%.2f g", mag);
}

void resetState() {
  moved = false;
  lastSendMillis = 0;
  // 背景色は変えない(isRedBg はそのまま)
  applyBgColor();
}

void handleBtCommand(const char* cmd) {
  Serial.print("BT CMD: ");
  Serial.println(cmd);

  if (strcmp(cmd, "RED") == 0) {
    isRedBg = true;
    applyBgColor();
  } else if (strcmp(cmd, "BLACK") == 0 || strcmp(cmd, "RESET") == 0) {
    isRedBg = false;
    applyBgColor();
  }
  // 必要ならここに他コマンド追加
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);

  M5.Imu.init();  // IMU初期化(M5StickC Plus2用)

  Serial.begin(115200);              // USBシリアル(デバッグ用)
  SerialBT.begin("DARUMA_STICK");    // PCからこの名前で見える

  M5.Display.setRotation(1);
  applyBgColor();                    // 初期背景は黒
}

void loop() {
  M5.update();

  // ===== Bluetooth からのコマンド処理 =====
  while (SerialBT.available()) {
    char c = SerialBT.read();
    if (c == '\r' || c == '\n') {
      if (btPos > 0) {
        btBuf[btPos] = '\0';
        handleBtCommand(btBuf);
        btPos = 0;
      }
    } else if (btPos < (int)sizeof(btBuf) - 1) {
      btBuf[btPos++] = c;
    }
  }

  // ===== リセットボタン処理(表示と状態だけリセット)=====
  if (M5.BtnA.wasPressed()) {
    resetState();
    Serial.println("RESET");
    // SerialBT.println("RESET");  ← BT には送らない仕様にした
  }

  // ===== 加速度取得 =====
  float ax, ay, az;
  M5.Imu.getAccelData(&ax, &ay, &az);

  // 合成加速度 [g]
  float mag = sqrt(ax * ax + ay * ay + az * az);

  // デバッグ出力(USB のみ)
  Serial.printf("ax=%.3f ay=%.3f az=%.3f |a|=%.3f\n", ax, ay, az, mag);

  unsigned long now = millis();

  // ===== 動いた判定 =====
  if (mag > MOVE_THRESHOLD && now - lastSendMillis > COOLDOWN_MS) {
    moved = true;
    lastSendMillis = now;

    Serial.println("OUT");
    SerialBT.println("OUT");   // PC側はこれを読む(OUT の時だけ)

    // 画面色はここでは変えない → PC 側から "RED" が来たら変える
  }

  // ===== 合成加速度の表示(常に更新) =====
  drawAccel(mag);

  delay(20);  // 50Hzぐらいでサンプリング
}

調整ポイント

  • MOVE_THRESHOLD
    • 実際に腕に付けて、動いていないとき・少し動いたときの |a| をシリアルモニタで見て決める。
    • 例えば静止 0.98〜1.02、軽い動きで 1.2〜1.4 とかなら、しきい値 1.25〜1.30 くらい?
  • COOLDOWN_MS
    • 一回動くと「ガガガッ」と連続検出するので、1秒とか入れておくと扱いやすい。

2. PC 側 Python ゲーム(pygame + pyserial)

前提

  • 必要ライブラリ
pip install pygame pyserial
  • M5StickC Plus2 の Bluetooth シリアルポートを OS 側でペアリングしておく
    • macOS なら /dev/tty.M5Stack-xxxx みたいな名前になるはず
    • Windows なら COM5 とか
  • 音声ファイル
    • 例えばカレントディレクトリに sounds/ フォルダを作り、そこに以下のように置く想定:
sounds/da.mp3
sounds/ru.mp3
sounds/ma.mp3
sounds/sa.mp3
sounds/n.mp3
sounds/ga.mp3
sounds/ko.mp3
sounds/ro.mp3
sounds/n2.mp3
sounds/da2.mp3

sounds/out.mp3(OUT時)

sounds/win.mp3(WIN時 ← 新規追加)

※ファイル名は好きに変えていいけど、後ろの Python 側のリストと合わせる。

ゲーム仕様(状態遷移)

  • CHANTING(読み上げ中)
    • ランダム間隔で1音ずつ再生
    • この間に OUT を受信 → 「負け」状態へ
  • BETWEEN(読み上げ後の待ち時間)
    • 3秒待って次のラウンド開始
    • この間に OUT が来ても無視(「読み上げ中に動いたらアウト」という仕様に合わせる)
  • OUT(負け表示)
    • 背景赤+「OUT」
    • 3秒後に次のラウンド
  • WIN(勝ち表示)
    • Spaceキーが読み上げ中 or BETWEEN中に押されたら WIN
    • 背景緑+「WIN」
    • 3秒後に次のラウンド


ls /dev/tty.*
で接続確認

Bluetoothは一度M 5を書き換えたらペアリング解除して、ペアリングし直した方がいいです。

コード

"""
Wearable 「だるまさんがころんだ」ゲーム(Python側)

【仕様】
- M5StickC Plus2 から Bluetooth シリアルで送られる "OUT" を監視
- 「だるまさんがころんだ」を 1 音ずつランダムに読み上げる
- 読み上げ中(CHANTING)は OUT を無視(安全時間)
- 読み上げ終了後の停止時間(BETWEEN)が判定時間
- BETWEEN のときに OUT が来たら「負け(OUT)」
    → out.mp3 を再生
    → M5 に "RED\n" を送信して赤背景に
    → このラウンドは停止状態になる(自動再スタートなし)
- `r` キーでラウンドを再スタート
- スペースキーで「勝ち(WIN)」
    → win.mp3 を再生
    → 数秒表示後に自動で次ラウンド開始
- Enter キーで M5Stick の画面色(RED / BLACK)をトグル切り替え
"""

import threading
import time
import random
import pygame
import serial

# ========= 設定 =========

# ★あなたの環境の Bluetooth シリアルポート名を設定 ★
SERIAL_PORT = "/dev/cu.DARUMA_STICK"
BAUD_RATE   = 115200

# 読み上げ用の音声ファイル(順番に再生)
SYLLABLE_FILES = [
    "sounds/da.mp3",
    "sounds/ru.mp3",
    "sounds/ma.mp3",
    "sounds/sa.mp3",
    "sounds/n.mp3",
    "sounds/ga.mp3",
    "sounds/ko.mp3",
    "sounds/ro.mp3",
    "sounds/n2.mp3",
    "sounds/da2.mp3",
]

# OUT / WIN の効果音
OUT_SOUND_FILE = "sounds/out.mp3"
WIN_SOUND_FILE = "sounds/win.mp3"

# 読み上げのランダム間隔(秒)
MIN_GAP = 0.3
MAX_GAP = 0.9

# 読み上げ後の静止待ち時間(ここが判定時間)
PAUSE_AFTER_PHRASE = 3.0

# WIN 表示後の自動再スタートまでの時間
RESULT_DISPLAY_TIME = 3.0

# ウィンドウサイズ
WIDTH, HEIGHT = 800, 600

# ========= シリアル関連の共有状態 =========
movement_flag = False       # OUT が来たら True
last_serial_line = ""       # 最後に読まれた行(デバッグ用)
ser = None                  # シリアルポートオブジェクト
is_red = False              # M5 の背景色トグル管理(RED / BLACK)


# =======================================================
# ≪ 別スレッド ≫ M5StickC からのシリアルデータ読み込み
# =======================================================
def serial_worker():
    """M5StickC Plus2 から OUT / RESET を受信し続けるスレッド"""
    global movement_flag, last_serial_line, ser

    if ser is None:
        print("Serial worker: Serial port not opened.")
        return

    print("Serial worker started.")

    while True:
        try:
            raw = ser.readline()
            if not raw:
                continue

            # デコード
            line = raw.decode("utf-8", errors="ignore").strip()
            last_serial_line = line
            print("SERIAL RAW:", repr(line))

            # OUT が含まれているかチェック
            if "OUT" in line:
                movement_flag = True

        except Exception as e:
            print("Serial read error:", e)
            break


# =======================================================
# ≪ メインゲームロジック ≫
# =======================================================
def main():
    global movement_flag, last_serial_line, ser, is_red

    # -----------------------------
    # pygame 初期化
    # -----------------------------
    pygame.init()
    pygame.mixer.init()

    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Wearable Daruma-san ga Koronda")

    font       = pygame.font.SysFont(None, 80)
    small_font = pygame.font.SysFont(None, 26)

    # 読み上げ音声
    syllables = [pygame.mixer.Sound(path) for path in SYLLABLE_FILES]

    # 効果音
    out_sound = pygame.mixer.Sound(OUT_SOUND_FILE)
    win_sound = pygame.mixer.Sound(WIN_SOUND_FILE)

    clock = pygame.time.Clock()

    # -----------------------------
    # ゲーム状態定義
    # -----------------------------
    # CHANTING : 読み上げ中(安全時間)
    # BETWEEN  : 読み上げ後の判定時間
    # OUT      : 動いて負け → out.mp3 再生、停止
    # WIN      : 勝ち → win.mp3 再生、数秒後に自動で再スタート
    state = "CHANTING"

    syllable_index = 0
    next_syllable_time = time.time() + 1.0
    after_phrase_deadline = None
    result_deadline = None  # WIN のときのみ使用(OUT は自動復帰しない)

    # -----------------------------
    # ラウンド初期化関数
    # -----------------------------
    def start_new_round():
        """新しいラウンドを開始する"""
        nonlocal state, syllable_index, next_syllable_time, after_phrase_deadline, result_deadline
        state = "CHANTING"
        syllable_index = 0
        next_syllable_time = time.time() + 1.0
        after_phrase_deadline = None
        result_deadline = None

        pygame.mixer.stop()
        print("--- NEW ROUND ---")

    start_new_round()

    running = True
    while running:
        now = time.time()

        # ======================================================
        # イベント処理(キー入力)
        # ======================================================
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

            elif event.type == pygame.KEYDOWN:

                # ---- WIN ----
                if event.key == pygame.K_SPACE and state in ("CHANTING", "BETWEEN"):
                    state = "WIN"
                    result_deadline = now + RESULT_DISPLAY_TIME
                    pygame.mixer.stop()
                    win_sound.play()
                    print("WIN (space key)")

                # ---- デバッグ用 強制OUT ----
                if event.key == pygame.K_o and state == "BETWEEN":
                    state = "OUT"
                    pygame.mixer.stop()
                    out_sound.play()
                    print("OUT (debug O key)")

                    if ser:
                        ser.write(b"RED\n")
                        is_red = True

                # ---- Enterで M5 の背景色切替 ----
                if event.key == pygame.K_RETURN:
                    is_red = not is_red
                    if ser:
                        ser.write(b"RED\n" if is_red else b"BLACK\n")
                    print("SEND:", "RED" if is_red else "BLACK")

                # ---- rキーで OUT から再スタート ----
                if event.key == pygame.K_r and state == "OUT":
                    start_new_round()

        # ======================================================
        # シリアルから OUT を受信した場合の処理
        # ======================================================
        if movement_flag:
            # 読み上げ中(CHANTING)は無視(安全時間)
            # BETWEEN(停止時間)なら OUT 判定
            if state == "BETWEEN":
                state = "OUT"
                pygame.mixer.stop()
                out_sound.play()
                print("OUT (moved during BETWEEN)")

                # M5 を赤に
                if ser and not is_red:
                    ser.write(b"RED\n")
                    is_red = True

            movement_flag = False

        # ======================================================
        # 状態遷移(ゲーム進行)
        # ======================================================
        if state == "CHANTING":
            # 順番に読み上げる
            if syllable_index < len(syllables) and now >= next_syllable_time:
                syllables[syllable_index].play()
                syllable_index += 1

                if syllable_index < len(syllables):
                    next_syllable_time = now + random.uniform(MIN_GAP, MAX_GAP)
                else:
                    # 読み上げ終わり → 判定時間(BETWEEN)へ
                    state = "BETWEEN"
                    after_phrase_deadline = now + PAUSE_AFTER_PHRASE
                    print("Phrase finished → JUDGE TIME")

        elif state == "BETWEEN":
            # 静止していてほしい時間
            if now >= after_phrase_deadline:
                start_new_round()

        elif state == "WIN":
            # WIN → 数秒後に自動で再スタート
            if now >= result_deadline:
                start_new_round()

        elif state == "OUT":
            # OUT → 停止状態、rキーを待つ
            pass

        # ======================================================
        # 描画処理
        # ======================================================
        if state == "CHANTING":
            bg_color = (0, 0, 0)
            text = "DARUMA..."
        elif state == "BETWEEN":
            bg_color = (0, 0, 100)
            text = "JUDGE..."
        elif state == "OUT":
            bg_color = (150, 0, 0)
            text = "OUT"
        elif state == "WIN":
            bg_color = (0, 120, 0)
            text = "WIN"
        else:
            bg_color = (0, 0, 0)
            text = ""

        screen.fill(bg_color)

        # 中央の大きい文字
        label = font.render(text, True, (255, 255, 255))
        rect = label.get_rect(center=(WIDTH // 2, HEIGHT // 2))
        screen.blit(label, rect)

        # 下部のステータス表示
        info_lines = [
            "Space: WIN / O: force OUT / Enter: toggle M5 color",
            "r: restart (when OUT)",
            f"STATE : {state}",
            f"M5 BG : {'RED' if is_red else 'BLACK'}",
            f"SERIAL: {last_serial_line}",
        ]
        for i, line in enumerate(info_lines):
            s = small_font.render(line, True, (255, 255, 255))
            screen.blit(s, (20, HEIGHT - 140 + 30 * i))

        pygame.display.flip()
        clock.tick(60)

    pygame.quit()


# =======================================================
# メインエントリ
# =======================================================
if __name__ == "__main__":
    try:
        ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
        print("Serial connected:", SERIAL_PORT)
    except Exception as e:
        print("Serial open error:", e)
        ser = None

    th = threading.Thread(target=serial_worker, daemon=True)
    th.start()

    main()

実装するときのチェック順

  1. M5 単体テスト
    • シリアルモニタで |a| の値を見る
    • 動くときだけ OUT と表示されるようにしきい値を調整
  2. Bluetooth シリアル接続確認
    • OSでペアリング → screen /dev/tty.xxx 115200 などで開いて、M5を振ると OUT が流れてくるかを見る
  3. Python 側
    • まず SERIAL_PORT を正しく設定
    • 音声ファイルパスを合わせる
    • 実行して、読み上げ中に M5 を振る → 画面が赤+OUT になるか確認
    • Space押下で緑+WIN になるか確認

完成

Bluetoothのペアリングやり直しせずに進めた結果ハマってしまって時間かかりました。
制作時間2時間ほど