前に戻る

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

このページは、M5StickC Plus2(IMU)×2台の動きデータをPCへ送り、Node.jsで2系統を取り込み、**ブラウザ(p5.js)**でリズムゲームとして動かすための手順書です。
学生がこのページだけ見て **「同じフォルダ構成を作る → コードを貼る → 実行する」**までできるように、どのファイルにどのコードを貼るかを全部書いています。


1. 最初に作るフォルダ構造(これが正)

まず、任意の場所に taiko_p5 フォルダを作り、以下の構造を作ってください。

taiko_p5/
  server.js
  package.json

  public/
    index.html
    sketch.js
    assets/
      notes.csv(自作してください)
      don.wav(太鼓の音は自分で探してください)
      test.mp4(合わせる映像も適宜自分で)

  m5/
    acc_gyro_to_pc.ino
    acc_gyro_to_pc_2.ino
  • server.js:PC側(Node.js)。2台のシリアル(Bluetooth)を同時に読むWebSocketでブラウザへ配信public/ を配信
  • public/:ブラウザ側(p5.js)。index.htmlsketch.js と素材(音・動画・譜面)
  • m5/:M5StickC Plus2に書き込むスケッチ(2台分)

ここでつまずく人が多いです。フォルダ名は public 固定にして下さい(server.jspublic/ を配信する実装になっています)。


2. どのコードをどこに貼るか(ファイル別:完全版)

以下の各ブロックを、指定されたファイルに そのままコピペしてください。


※ npmで整えた方がいいかも

2.1 taiko_p5/package.json に貼る

{
  "name": "taiko_p5",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "minimist": "^1.2.8",
    "serialport": "^13.0.0",
    "ws": "^8.18.3"
  }
}

2.2 taiko_p5/server.js に貼る

#!/usr/bin/env node
"use strict";

/**
 * server.js (2x Serial -> 1x WebSocket)
 * - 2台のM5のシリアルを同時に読む
 * - ブラウザへ {type:"imu", src:"A"|"B", ...} で配信
 * - public/ を静的配信(index.html, sketch.js, assets/don.wav)
 */

const http = require("http");
const path = require("path");
const fs = require("fs");
const minimist = require("minimist");
const { SerialPort } = require("serialport");
const { ReadlineParser } = require("@serialport/parser-readline");
const WebSocket = require("ws");

const argv = minimist(process.argv.slice(2), {
  string: ["portA", "portB"],
  default: { baud: 115200, http: 8080 },
});

if (!argv.portA || !argv.portB) {
  console.error('Usage: node server.js --portA "/dev/tty.M5_A" --portB "/dev/tty.M5_B" [--baud 115200] [--http 8080]');
  process.exit(1);
}

const SERIAL = {
  A: String(argv.portA),
  B: String(argv.portB),
};

const BAUD = Number(argv.baud) || 115200;
const HTTP_PORT = Number(argv.http) || 8080;

// ---- HTTP (static) ----
const server = http.createServer((req, res) => {
  const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);

  // health endpoint
  if (urlPath === "/health") {
    res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
    res.end(JSON.stringify({ ok: true, ports: SERIAL, baud: BAUD, http: HTTP_PORT }));
    return;
  }

  const publicDir = path.join(__dirname, "public");

  const safePath = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
  const filePath = path.join(publicDir, safePath === "/" ? "index.html" : safePath);

  if (!filePath.startsWith(publicDir)) {
    res.writeHead(403);
    res.end("Forbidden");
    return;
  }

  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404);
      res.end("Not found");
      return;
    }
    const ext = path.extname(filePath).toLowerCase();
    const mime =
      ext === ".html" ? "text/html; charset=utf-8" :
      ext === ".js"   ? "text/javascript; charset=utf-8" :
      ext === ".css"  ? "text/css; charset=utf-8" :
      ext === ".wav"  ? "audio/wav" :
      ext === ".mp3"  ? "audio/mpeg" :
      "application/octet-stream";

    res.writeHead(200, { "Content-Type": mime });
    res.end(data);
  });
});

// ---- WebSocket ----
const wss = new WebSocket.Server({ server });
function wsBroadcast(obj) {
  const s = JSON.stringify(obj);
  for (const c of wss.clients) {
    if (c.readyState === WebSocket.OPEN) c.send(s);
  }
}

wss.on("connection", (ws) => {
  ws.send(JSON.stringify({ type: "hello", t: Date.now() }));
});

// ---- Serial (2 ports) ----
function parseLine(line) {
  // expected: t_ms, ax, ay, az, gx, gy, gz
  // allow spaces
  const parts = line.trim().split(",").map((p) => p.trim());
  if (parts.length < 7) return null;

  const t_ms = Number(parts[0]);
  const ax = Number(parts[1]);
  const ay = Number(parts[2]);
  const az = Number(parts[3]);
  const gx = Number(parts[4]);
  const gy = Number(parts[5]);
  const gz = Number(parts[6]);

  if ([t_ms, ax, ay, az, gx, gy, gz].some((v) => Number.isNaN(v))) return null;

  return { t_ms, ax, ay, az, gx, gy, gz };
}

function attachSerial(src, portPath) {
  console.log(`[SERIAL ${src}] opening ${portPath} @ ${BAUD}...`);

  const sp = new SerialPort({ path: portPath, baudRate: BAUD, autoOpen: true });
  const parser = sp.pipe(new ReadlineParser({ delimiter: "\n" }));

  sp.on("open", () => console.log(`[SERIAL ${src}] opened`));
  sp.on("error", (e) => console.error(`[SERIAL ${src}] error:`, e.message));

  parser.on("data", (line) => {
    const parsed = parseLine(line);
    if (!parsed) return;

    // たまにログ(うるさければコメントアウト)
    // if (Math.random() < 0.001) console.log(`[SERIAL ${src}] gx=${parsed.gx} gy=${parsed.gy} gz=${parsed.gz}`);

    wsBroadcast({ type: "imu", src, t: Date.now(), ...parsed });
  });
}

attachSerial("A", SERIAL.A);
attachSerial("B", SERIAL.B);

server.listen(HTTP_PORT, "127.0.0.1", () => {
  console.log(`[HTTP] http://127.0.0.1:${HTTP_PORT}/  (serving ./public)`);
  console.log(`[WS]  ws://127.0.0.1:${HTTP_PORT}/  (same port)`);
  console.log(`[TIP] open /health: http://127.0.0.1:${HTTP_PORT}/health`);
});

2.3 taiko_p5/public/index.html に貼る

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Taiko IMU Rhythm</title>

  <script src="https://cdn.jsdelivr.net/npm/p5@1.10.0/lib/p5.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/p5@1.10.0/lib/addons/p5.sound.min.js"></script>

  <style>
    body { margin:0; font:14px/1.4 system-ui; overflow:hidden; }
    #ui{
      position:fixed; left:8px; top:8px;
      background:#0008; color:#fff;
      padding:10px; border-radius:10px;
      min-width:300px;
    }
    #ui input[type="range"] { width: 220px; }
    .row{ display:flex; gap:8px; align-items:center; margin:4px 0; }
    .small{ opacity:.85; font-size:12px; }
    .mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
    button{ padding:6px 10px; border-radius:8px; border:0; cursor:pointer; }
  </style>
</head>
<body>

  <div id="ui">
    <div class="row">
      <div class="mono">status:</div>
      <div id="st" class="mono">connecting...</div>
    </div>

    <div class="row">
      <div class="mono">gNorm:</div>
      <div id="gn" class="mono">-</div>
    </div>

    <div class="row">
      <div class="mono">threshold:</div>
      <input id="thr" type="range" min="-2000" max="0" step="1" value="-700" />
      <div id="thrVal" class="mono">-700</div>
    </div>

    <div class="row">
      <button id="startBtn">Start / Restart</button>
      <button id="loadBtn">Load Chart CSV</button>
      <input id="fileInp" type="file" accept=".csv,text/csv" style="display:none;" />
    </div>

    <div class="row small">
      <div>Hit: <span id="hitN" class="mono">0</span></div>
      <div>Score: <span id="score" class="mono">0</span></div>
      <div>Combo: <span id="combo" class="mono">0</span></div>
    </div>

    <div class="row small">
      <div>Judge: <span id="judge" class="mono">-</span></div>
    </div>

    <div class="small" style="margin-top:8px;">
      CSV譜面: 1行1ノーツ(ms)。例:300 / 1000 / 1500 ...<br>
      ※音が鳴らない時は、画面をクリックして音声を許可
    </div>

    <div class="row small" style="margin-top:10px;">
      <div>Hキー: UI表示/非表示</div>
      <div>Vキー: Video表示/非表示</div>
    </div>
  </div>

  <script src="./sketch.js"></script>
</body>
</html>

2.4 taiko_p5/public/sketch.js に貼る

let ws;
let latest = null;

let threshold = -700;
let hitCount = 0;

// 連打抑制(ms)
const COOLDOWN_MS = 120;
let lastHitAt = 0;

// 音
let don;
let audioReady = false;

// ===== Rhythm Game =====
let notes = [];              // ms timings
let noteStates = [];         // "pending" | "hit" | "miss"
let noteIdx = 0;             // next note index to judge
let gameStartAt = null;      // millis() at start
let playing = false;

// 判定窓(ms)
const W_PERFECT = 60;
const W_GOOD = 120;
const W_OK = 180;

// スコア
let score = 0;
let combo = 0;
let lastJudgeText = "-";

// 表示
let canvasW, canvasH;
const HIT_X = 120;           // 判定ラインx
const LANE_Y = 160;          // レーンy
const NOTE_R = 16;           // ノーツ半径
const SPEED_PX_PER_MS = 0.28; // ノーツの流れる速さ(px/ms)
// ↑この値を変えると見た目のスピードが変わる(譜面ms自体は変えない)

// 動画
let vid;
let videoVisible = true;
const VIDEO_PATH = "assets/test.mp4";

// UI参照
let stEl, thrEl, thrValEl, hitEl, scoreEl, comboEl, judgeEl;
let startBtn, loadBtn, fileInp;

// UIの表示切り替え
let uiVisible = true;

function toggleUI() {
  uiVisible = !uiVisible;
  const ui = document.getElementById("ui");
  if (ui) ui.style.display = uiVisible ? "block" : "none";
}

// キー操作(HでUI表示/非表示、Vで動画表示/非表示)
function keyPressed() {
  if (key === "h" || key === "H") toggleUI();
  if (key === "v" || key === "V") videoVisible = !videoVisible;
}

function preload() {
  soundFormats("wav", "mp3");
  don = loadSound("assets/don.wav");
}

function setup() {
  canvasW = windowWidth;
  canvasH = windowHeight;
  createCanvas(canvasW, canvasH);

  // 動画(自動再生はブラウザ制限あり。Startボタンで再生する)
  vid = createVideo([VIDEO_PATH], () => {});
  vid.hide(); // p5が勝手にDOM配置するのを避ける(drawで描画)
  vid.volume(0); // 音は不要なら0(必要なら調整)

  // UI
  stEl = document.getElementById("st");
  thrEl = document.getElementById("thr");
  thrValEl = document.getElementById("thrVal");
  hitEl = document.getElementById("hitN");
  scoreEl = document.getElementById("score");
  comboEl = document.getElementById("combo");
  judgeEl = document.getElementById("judge");

  startBtn = document.getElementById("startBtn");
  loadBtn = document.getElementById("loadBtn");
  fileInp = document.getElementById("fileInp");

  thrEl.addEventListener("input", () => {
    threshold = Number(thrEl.value);
    thrValEl.textContent = String(threshold);
  });

  startBtn.addEventListener("click", async () => {
    // 音声を確実に有効化(ブラウザ制限対策)
    await userStartAudio();
    audioReady = true;

    startGame();
  });

  loadBtn.addEventListener("click", () => fileInp.click());
  fileInp.addEventListener("change", (e) => {
    const f = e.target.files?.[0];
    if (f) loadNotesFromFile(f);
  });

  // デフォルト譜面を読み込み
  loadNotesFromURL("assets/notes.csv");

  // WebSocket
  ws = new WebSocket(`ws://${location.hostname}:8080/`);
  ws.onopen = () => { stEl.textContent = "WS: connected"; };
  ws.onclose = () => { stEl.textContent = "WS: closed"; };
  ws.onerror = () => { stEl.textContent = "WS: error"; };

  ws.onmessage = (ev) => {
    try {
      const msg = JSON.parse(ev.data);
      if (msg.type !== "imu") return;

      latest = msg;

      // ジャイロのノルムで簡易判定(-gNormがしきい値以下でHit)
      const gNorm = Math.sqrt(msg.gx * msg.gx + msg.gy * msg.gy + msg.gz * msg.gz);
      const scoreVal = -gNorm;
      document.getElementById("gn").textContent = `gNorm:    ${scoreVal.toFixed(1)}`;

      const now = millis();
      if (scoreVal <= threshold && (now - lastHitAt) > COOLDOWN_MS) {
        lastHitAt = now;
        onHit(msg.src);
      }
    } catch (e) {
      // ignore parse errors
    }
  };
}

function windowResized() {
  canvasW = windowWidth;
  canvasH = windowHeight;
  resizeCanvas(canvasW, canvasH);
}

function startGame() {
  playing = true;
  score = 0;
  combo = 0;
  lastJudgeText = "-";
  noteIdx = 0;

  // 状態初期化
  noteStates = notes.map(() => "pending");

  gameStartAt = millis();

  // 動画を0秒から再生
  startVideoFromZero();
}

function startVideoFromZero() {
  if (!vid) return;

  // ブラウザ規制があるので user gesture(Startボタン)で呼ぶのが重要
  try {
    vid.stop();  // 一旦止める
    vid.time(0);
    const p = vid.play();
    if (p && typeof p.catch === "function") p.catch(() => {});
  } catch (e) {}
}

function onHit(src) {
  hitCount++;
  hitEl.textContent = String(hitCount);

  // 効果音
  if (audioReady && don && don.isLoaded()) {
    don.play();
  }

  if (!playing || gameStartAt == null) return;

  const t = millis() - gameStartAt; // 現在のゲーム時刻(ms)
  judgeAtTime(t, src);
}

function judgeAtTime(t, src) {
  // noteIdx から先の "pending" ノーツを探して一番近いものを評価
  let bestIdx = -1;
  let bestAbs = Infinity;

  for (let i = noteIdx; i < notes.length; i++) {
    if (noteStates[i] !== "pending") continue;
    const dt = notes[i] - t;
    const adt = Math.abs(dt);
    if (adt < bestAbs) {
      bestAbs = adt;
      bestIdx = i;
    }
    // 未来すぎるノーツを早期打ち切り(適当)
    if (dt > W_OK * 2) break;
  }

  if (bestIdx < 0) {
    lastJudgeText = "NO NOTE";
    combo = 0;
    updateHUD();
    return;
  }

  // 判定
  if (bestAbs <= W_PERFECT) {
    applyJudge(bestIdx, "PERFECT", 300);
  } else if (bestAbs <= W_GOOD) {
    applyJudge(bestIdx, "GOOD", 150);
  } else if (bestAbs <= W_OK) {
    applyJudge(bestIdx, "OK", 80);
  } else {
    // 遠すぎる:ミス扱い(ただしノーツは消さない)
    lastJudgeText = "MISS";
    combo = 0;
    updateHUD();
    return;
  }

  // noteIdxを進める(手前にpendingが残っていたらそこまで)
  while (noteIdx < notes.length && noteStates[noteIdx] !== "pending") noteIdx++;

  updateHUD();
}

function applyJudge(idx, text, addScore) {
  noteStates[idx] = "hit";
  lastJudgeText = text;
  score += addScore;
  combo += 1;
}

function autoMiss(t) {
  // t がノーツ + W_OK を超えたら miss
  for (let i = 0; i < notes.length; i++) {
    if (noteStates[i] !== "pending") continue;
    if (t > notes[i] + W_OK) {
      noteStates[i] = "miss";
      combo = 0;
      // 進行中のnoteIdx更新
      while (noteIdx < notes.length && noteStates[noteIdx] !== "pending") noteIdx++;
    } else {
      // notesは昇順想定なので、ここで打ち切り
      // (昇順じゃない譜面を入れたら壊れる)
      break;
    }
  }
}

function updateHUD() {
  // 表示更新
  judgeEl.textContent = lastJudgeText;
  scoreEl.textContent = String(score);
  comboEl.textContent = String(combo);
}

function draw() {
  background(20);

  // 動画を下半分に表示
  if (vid && videoVisible) {
    const w = width;
    const h = height / 2;
    image(vid, 0, height - h, w, h);
  }

  // レーン
  stroke(255);
  strokeWeight(2);
  line(HIT_X, LANE_Y - 40, HIT_X, LANE_Y + 40);
  line(0, LANE_Y, width, LANE_Y);

  // ゲーム進行
  if (playing && gameStartAt != null) {
    const t = millis() - gameStartAt;
    autoMiss(t);
    drawNotes(t);

    // 終了判定
    const allDone = noteStates.every((s) => s !== "pending");
    if (allDone) {
      playing = false;
      lastJudgeText = "FINISH";
      updateHUD();
    }
  } else {
    // 待機中表示
    noStroke();
    fill(255);
    textSize(16);
    text("Press Start", 20, 30);
  }
}

function drawNotes(t) {
  // pendingノーツを描画:ノーツ時刻が近づくほど左へ流れる
  for (let i = 0; i < notes.length; i++) {
    const state = noteStates[i];
    if (state === "hit") continue;

    const dt = notes[i] - t; // ms
    const x = HIT_X + dt * SPEED_PX_PER_MS;
    const y = LANE_Y;

    if (x < -50 || x > width + 50) continue;

    if (state === "miss") {
      fill(255, 80, 80);
    } else {
      fill(240);
    }
    noStroke();
    circle(x, y, NOTE_R * 2);
  }
}

// ===== CSV loading =====
async function loadNotesFromURL(url) {
  try {
    const res = await fetch(url);
    const text = await res.text();
    notes = parseNotesCSV(text);
    noteStates = notes.map(() => "pending");
    noteIdx = 0;
  } catch (e) {
    console.warn("failed to load notes:", e);
  }
}

function loadNotesFromFile(file) {
  const reader = new FileReader();
  reader.onload = () => {
    notes = parseNotesCSV(String(reader.result || ""));
    noteStates = notes.map(() => "pending");
    noteIdx = 0;
  };
  reader.readAsText(file);
}

function parseNotesCSV(text) {
  // 1行1ノーツ、先頭列をmsとして読む
  const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
  const arr = [];
  for (const l of lines) {
    const first = l.split(",")[0].trim();
    const v = Number(first);
    if (!Number.isNaN(v)) arr.push(v);
  }
  // 昇順にする(前提を壊さないため)
  arr.sort((a, b) => a - b);
  return arr;
}

2.5 taiko_p5/public/assets/notes.csv に貼る(譜面サンプル)

これは例です。1行=1ノーツのmsでOK。自分で作るならこの形式に合わせてください。

300
1000
1500
2300
2600
3200
5000
6800
7800
9000

(長い譜面にしたいならこの行を増やすだけ)


2.6 taiko_p5/m5/acc_gyro_to_pc.ino に貼る(M5:1台目)

/**
 * M5StickC Plus2: IMU 6-axis streaming over Bluetooth SPP
 * - Sends: t_ms, ax, ay, az, gx, gy, gz (CSV) at ~100Hz
 *
 * Notes:
 * - Pair PC with "M5IMU" then open the created COM port (Windows) / rfcomm (Linux) / serial device (mac depends)
 */

#include <M5Unified.h>
#include "BluetoothSerial.h"

BluetoothSerial SerialBT;

static const char* BT_NAME = "M5IMU";   // Bluetooth device name shown on PC
static const uint32_t SEND_HZ = 100;    // target sending rate
static const uint32_t SEND_INTERVAL_MS = 1000 / SEND_HZ;

uint32_t lastSend = 0;

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

  M5.Display.clear();
  M5.Display.setCursor(0, 0);
  M5.Display.println("Initializing IMU...");

  if (!M5.Imu.begin()) {
    M5.Display.println("IMU begin failed");
    while (true) { delay(1000); }
  }

  M5.Display.println("Initializing BT...");
  if (!SerialBT.begin(BT_NAME)) {
    M5.Display.clear();
    M5.Display.setCursor(0, 0);
    M5.Display.println("BT begin failed");
    while (true) { delay(1000); }
  }

  M5.Display.clear();
  M5.Display.setCursor(0, 0);
  M5.Display.println("BT IMU streamer");
  M5.Display.print("Name: ");
  M5.Display.println(BT_NAME);
  M5.Display.println("Pair on PC, then open COM");

  delay(200);
}

void loop() {
  M5.update();

  const uint32_t now = millis();
  if (now - lastSend >= SEND_INTERVAL_MS) {
    lastSend = now;

    float ax, ay, az;
    float gx, gy, gz;

    M5.Imu.getAccel(&ax, &ay, &az);
    M5.Imu.getGyro(&gx, &gy, &gz);

    // CSV: t_ms, ax, ay, az, gx, gy, gz
    SerialBT.print(now);
    SerialBT.print(',');
    SerialBT.print(ax, 6); SerialBT.print(',');
    SerialBT.print(ay, 6); SerialBT.print(',');
    SerialBT.print(az, 6); SerialBT.print(',');
    SerialBT.print(gx, 6); SerialBT.print(',');
    SerialBT.print(gy, 6); SerialBT.print(',');
    SerialBT.print(gz, 6);
    SerialBT.print('\n');
  }
}

2.7 taiko_p5/m5/acc_gyro_to_pc_2.ino に貼る(M5:2台目)

/**
 * M5StickC Plus2: IMU 6-axis streaming over Bluetooth SPP
 * - Sends: t_ms, ax, ay, az, gx, gy, gz (CSV) at ~100Hz
 *
 * Notes:
 * - Pair PC with "M5IMU2" then open the created COM port (Windows) / rfcomm (Linux) / serial device (mac depends)
 */

#include <M5Unified.h>
#include "BluetoothSerial.h"

BluetoothSerial SerialBT;

static const char* BT_NAME = "M5IMU2";  // 2台目は別名にする(重要)
static const uint32_t SEND_HZ = 100;
static const uint32_t SEND_INTERVAL_MS = 1000 / SEND_HZ;

uint32_t lastSend = 0;

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

  M5.Display.clear();
  M5.Display.setCursor(0, 0);
  M5.Display.println("Initializing IMU...");

  if (!M5.Imu.begin()) {
    M5.Display.println("IMU begin failed");
    while (true) { delay(1000); }
  }

  M5.Display.println("Initializing BT...");
  if (!SerialBT.begin(BT_NAME)) {
    M5.Display.clear();
    M5.Display.setCursor(0, 0);
    M5.Display.println("BT begin failed");
    while (true) { delay(1000); }
  }

  M5.Display.clear();
  M5.Display.setCursor(0, 0);
  M5.Display.println("BT IMU streamer");
  M5.Display.print("Name: ");
  M5.Display.println(BT_NAME);
  M5.Display.println("Pair on PC, then open COM");

  delay(200);
}

void loop() {
  M5.update();

  const uint32_t now = millis();
  if (now - lastSend >= SEND_INTERVAL_MS) {
    lastSend = now;

    float ax, ay, az;
    float gx, gy, gz;

    M5.Imu.getAccel(&ax, &ay, &az);
    M5.Imu.getGyro(&gx, &gy, &gz);

    SerialBT.print(now);
    SerialBT.print(',');
    SerialBT.print(ax, 6); SerialBT.print(',');
    SerialBT.print(ay, 6); SerialBT.print(',');
    SerialBT.print(az, 6); SerialBT.print(',');
    SerialBT.print(gx, 6); SerialBT.print(',');
    SerialBT.print(gy, 6); SerialBT.print(',');
    SerialBT.print(gz, 6);
    SerialBT.print('\n');
  }
}

3. assets(音・動画)を置く場所(ここを落とすと動かない)

taiko_p5/public/assets/ に以下を置きます:

  • don.wav:叩いた時の効果音(自前でOK)
  • test.mp4:下半分に表示する動画(自前でOK)
  • notes.csv:譜面(上で貼った形式)

4. M5StickC Plus2 への書き込み(2台)

1台目は m5/acc_gyro_to_pc.ino、2台目は m5/acc_gyro_to_pc_2.ino を、それぞれ別のM5に書き込みます。

  • Arduino IDEでボード設定をM5StickC Plus2に合わせる
  • ライブラリ:M5Unified を入れる
  • 書き込み後、M5の画面に BT名(M5IMU / M5IMU2) が出ればOK

5. PC側:Bluetoothペアリング → シリアルポート名を確認

ここが実務的に一番詰まります。ポート名が分からないと server.js が起動できません。

macOS(例)

ペアリング後、ターミナルで:

ls /dev/tty.* | grep -i M5
ls /dev/cu.*  | grep -i M5

出てきた2本をメモ(例:/dev/tty.M5IMU-xxxx/dev/tty.M5IMU2-yyyy

Windows(例)

「デバイス マネージャー → ポート(COMとLPT)」で
COM5COM7 みたいに2つ出るのでメモ。


6. PC側:Nodeサーバ起動(2台シリアル → WebSocket → ブラウザ)

taiko_p5/ に移動して:

cd taiko_p5
npm install

起動(ポート名は自分の環境に合わせて置換):

macOS例

node server.js --portA "/dev/tty.M5IMU-XXXXX" --portB "/dev/tty.M5IMU2-YYYYY" --baud 115200 --http 8080

Windows例

node server.js --portA "COM5" --portB "COM7" --baud 115200 --http 8080

起動したらブラウザで:

  • http://127.0.0.1:8080/
  • 動作確認:http://127.0.0.1:8080/health

7. 使い方(ブラウザ側)

  • Start / Restart:ゲーム開始(同時に動画が0秒から再生)
  • thresholdスライダー:Hitしやすさ調整(値が小さい=より強い動きが必要、という感覚でOK)
  • Load Chart CSV:譜面CSVを差し替え(ローカルから選択)
  • Hキー:UI表示/非表示(メニューが邪魔なとき)
  • Vキー:動画表示/非表示

8. 追実装の入口(学生向け課題にしやすい点)

この実装は「最低限」なので改造余地があります。例えば:

  • 2台の役割分担(左手=don、右手=ka など):sketch.jsonHit(msg.src)src を見て分岐
  • 判定の改良:今は -sqrt(gx^2+gy^2+gz^2) を閾値比較しているだけ。叩き動作なら加速度ピークを使う方が筋が良い
  • UI/表示の拡張:スコアボード、判定エフェクト、ノーツの種類追加
  • 譜面編集ツール:CSVのmsを打ち込む簡易UIを作る(教育的にちょうど良い)

9. よくある失敗(先に潰す)

  • 音が鳴らない:ブラウザが自動再生をブロック。必ず Startボタンを押す(ユーザー操作)
  • WS: errorserver.js が起動してない / ポートが違う / 8080を別プロセスが使用中
  • 譜面がズレるnotes.csv がms基準。動画と合わせるなら「Start押下=0ms」前提で揃える必要あり
  • ノーツが消えない・missが変:譜面が昇順じゃないと壊れる(parseNotesCSVでsortしているが、意味が変わる)