※ 最低限のメモのようなもので、資料としてちゃんと整えておりません。あくまで参考までに。。
このページは、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.htmlとsketch.jsと素材(音・動画・譜面)m5/:M5StickC Plus2に書き込むスケッチ(2台分)
ここでつまずく人が多いです。フォルダ名は public 固定にして下さい(
server.jsがpublic/を配信する実装になっています)。
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)」でCOM5 と COM7 みたいに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.jsのonHit(msg.src)でsrcを見て分岐 - 判定の改良:今は
-sqrt(gx^2+gy^2+gz^2)を閾値比較しているだけ。叩き動作なら加速度ピークを使う方が筋が良い - UI/表示の拡張:スコアボード、判定エフェクト、ノーツの種類追加
- 譜面編集ツール:CSVのmsを打ち込む簡易UIを作る(教育的にちょうど良い)
9. よくある失敗(先に潰す)
- 音が鳴らない:ブラウザが自動再生をブロック。必ず Startボタンを押す(ユーザー操作)
- WS: error:
server.jsが起動してない / ポートが違う / 8080を別プロセスが使用中 - 譜面がズレる:
notes.csvがms基準。動画と合わせるなら「Start押下=0ms」前提で揃える必要あり - ノーツが消えない・missが変:譜面が昇順じゃないと壊れる(
parseNotesCSVでsortしているが、意味が変わる)