1. はじめに
今回、サークルでチーム対抗のハッカソンがありました。
世間であまり見ないユニークなゲームを作りたいという方向性のもとチームで議論していたところ、メンバーが「ジョイコンをWebで動かせるらしい」という衝撃ニュースを知らせてくれました。
それで、リングコンをリズムに合わせて動かすゲームがブラウザ上でできれば面白いという話になり、今回実装したので、忘備録も兼ねて経緯や実装方法を共有しようと思います。
目次
2. Web-HIDとは?
そもそも、HID(Human interface Devices)とは、人が操作する機器のことです。端的に言えばWebHIDとは、このような人が使う機器に対してブラウザーから入出力を操作することを指します。
通信の際にはその仕様に関して取り決めが必要です。WebではHTTPと言うプロトコルが使われますが、ここではHIDプロトコルが用いられているそうです。これはもともとUSBデバイス用に開発されたものですが、転じてBlueToothにも使われるようになりました。
つまり、Bluetoothでつながる機器はパソコンでも操作できるということでもあります。ただこれまではBluetoothでつながった機器を、キーボードや画面操作のように、ブラウザーで感知し結果を出力する手段はありませんでした。
この機能を提供するのがWebHIDであり、比較的新しい機能なのでまだ「実験的な段階」であるようで、ブラウザーの互換性は主にChrome, Edge, Operaに留まっています。Firefox, Safari, Andoroidでは使えないので注意してください。
3. joy-con-hidを使う
ジョイコンはBluetooth接続でPCとつなげることでHIDとして使うことができ、これもWebHIDを用いてブラウザーからデータの取得やデバイスの操作を制御できます。
WebHIDのAPIは、MDNのドキュメントにあるように、navigator.hid
を通してデバイス情報を取得したりnavigator.hid.addEventListener
を使って操作を加えたりします。
しかしながら、JavaScriptでAPIを直接操作するのは難易度が高く時間もかかります。そこで今回は、joy-con-webhidというライブラリーを使わせてもらいました。
これは、WebHIDのAPIをラップし、諸々の便利な関数にまとめてくれたライブラリーです。
実際の使い方は以下の通りです。
"use client";
import {
connectedJoyCons,
connectJoyCon,
JoyConDataPacket,
JoyConRight,
RingConDataPacket,
} from "joy-con-webhid";
import { useEffect, useState } from "react";
import { AnalogStick } from "joy-con-webhid";
export const RingConInitialStickValue = {
strain: 0,
hor: 0,
ver: 0,
acc: { x: 0, y: 0, z: 0 },
gyro: { x: 0, y: 0, z: 0 },
quaternion: { alpha: "", beta: "", gamma: "" },
rawQuaternion: { x: 0, y: 0, z: 0, w: 0 },
};
export async function igniteJoyCon() {
await connectJoyCon();
return connectedJoyCons.size > 0;
}
export function useRingConValues() {
const [rightController, setRightController] = useState(
RingConInitialStickValue,
);
useEffect(() => {
(async () => {
await listenReport();
})();
async function listenReport() {
setInterval(async () => {
for (const joyCon of connectedJoyCons.values()) {
if (joyCon.eventListenerAttached) {
continue;
}
joyCon.eventListenerAttached = true;
await joyCon.enableRingCon();
joyCon.addEventListener("hidinput", (e: any) => {
const packet = e.detail as JoyConDataPacket;
if (!packet) return null;
if (!(joyCon instanceof JoyConRight)) return null;
const stickValue = handleInput(packet);
setRightController(stickValue);
});
}
}, 2000);
}
}, []);
return rightController;
}
function handleInput(
packet: JoyConDataPacket,
): typeof RingConInitialStickValue {
const {
actualAccelerometer,
actualGyroscope,
actualOrientationQuaternion,
quaternion,
} = packet;
console.log(packet);
const joystick = packet.analogStickRight as AnalogStick;
const hor = Number(joystick.horizontal);
const ver = Number(joystick.vertical);
const ringCon = packet.ringCon as RingConDataPacket;
const strain = ringCon.strain;
const result = {
strain,
hor,
ver,
acc: actualAccelerometer,
gyro: actualGyroscope.rps,
quaternion: actualOrientationQuaternion || {
alpha: "0",
beta: "0",
gamma: "0",
},
rawQuaternion: quaternion,
};
return result;
}
joy-con-webhidで使った主な関数はわずか二つだけです。
connectJoyCon
とconnectedJoyCons
です。前者はボタンクリックなどをトリガーとしてジョイコンとブルートゥース接続するための関数であり、デバイスの追加などを実行してくれます。後者は繋がったジョイコンを扱うための関数です。connectedJoyCons.Valaues
から実際にデバイスを取得することができ、一つのスイッチのデバイスにつき左右のジョイコンがあります。
その操作をしているのがlistenReportの個所です。
async function listenReport() {
setInterval(async () => {
for (const joyCon of connectedJoyCons.values()) {
if (joyCon.eventListenerAttached) {
continue;
}
joyCon.eventListenerAttached = true;
+ await joyCon.enableRingCon();
+ joyCon.addEventListener("hidinput", (e: any) => {
const packet = e.detail as JoyConDataPacket;
if (!packet) return null;
+ if (!(joyCon instanceof JoyConRight)) return null;
const stickValue = handleInput(packet);
setRightController(stickValue);
});
}
}, 2000);
}
ポイントは三つあります。
1.リングコンを使うために、関数を実行
joycon.enableRingCon
関数を実行します。これは、特に筋力値(strain)を取得するために必要となります。
2.ジョイコンの入力データを取得する
joyConにaddEventListenerでイベントを登録することによってデータの取得が可能になります。イベントのdetailプロパティに、セットでジョイコン(リングコン)が取得したデータが入っています。
3.右のジョイコンだけを処理の対象にする
私は個人的に所有していなかったのでメンバーに教えてもらうまで知らなかったのですが、リングコンは右ジョイコンしか使えないらしいです。このため右側のジョイコンのみを対象とします。この判定はjoyCon instanceof JoyConRight
で行えます。
これで、使いまわし可能なジョイコンの接続およびデータ取得関数ができました。
4. リズムゲームのロジック
今回はさほど高度なリズムゲームは作れませんでした。
二人のゲームで手順は以下の通りです。
1.Aが一回動きを登録する
2.BがAの一回目の動きを真似し、次に新しく動きを登録
3.Aが二回の動きをトレースし(一回目は自分、二回目はB)、最後に新しく動きを登録
4.Bが三回の動きをトレースし、最後に新しく動きを登録
5.以降繰り返し
となります。
即ち、お互いに始めから数えてn-1回の動きをトレースし、最後にモーションを追加する仕様です。
まず、押したり引いたりするサイクルとその中での最大/最小strain値の取得ロジックを示します。
"use client";
import { useStepper } from "@/lib/hooks/stepper";
import { Box } from "@mui/material";
import { useEffect, useState } from "react";
import { RightTurn, LeftTurn } from "@/components/turn";
import {
baseValueAtom,
connectedAtom,
currentNodeAtom,
isPlessedAtom,
missCountAtom,
nodesAtom,
turnAtom,
turnStartTimeAtom,
} from "@/lib/atom";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useRingConValues } from "@/lib/rincon";
import { addNode } from "@/lib/addNode";
import { MissLimit } from "@/consts/constraints";
const NEUTRAL_STRAIN = 3000;
const NEUTRAL_STRAIN_RADIUS = 1024;
const NEUTRAL_STRAIN_RADIUS_MARGIN = 16;
export default function AlternatePlay() {
const { handleNext } = useStepper();
// 1が右の人、-1が左の人のターン
const [turn, setTurn] = useAtom(turnAtom);
// 譜面
const [nodes, setNodes] = useAtom(nodesAtom);
const [startTime, setStartTime] = useAtom(turnStartTimeAtom);
const [currentNode, setCurrentNode] = useAtom(currentNodeAtom);
// ミス数の配列 [左, 右]
const [missCount, setMissCount] = useAtom(missCountAtom);
const connected = useAtomValue(connectedAtom);
const setIsPressed = useSetAtom(isPlessedAtom);
// Ring-Con の状態管理
const [isMoving, setIsMoving] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [strainValues, setStrainValues] = useState<number[]>([]);
const [strainPerCycle, setStrainPerCycle] = useState(0);
const { strain } = useRingConValues();
// Ring-Con の動き検出(strain ベース)
useEffect(() => {
if (isMoving) {
// 継続中 → 値を記録
setStrainValues((prev) => [...prev, strain]);
// 中立域に戻ったら押し終了
if (
NEUTRAL_STRAIN - NEUTRAL_STRAIN_RADIUS + NEUTRAL_STRAIN_RADIUS_MARGIN <=
strain &&
strain <=
NEUTRAL_STRAIN + NEUTRAL_STRAIN_RADIUS - NEUTRAL_STRAIN_RADIUS_MARGIN
) {
const peakStrain = isPulling
? Math.min(...strainValues)
: Math.max(...strainValues);
setStrainPerCycle(peakStrain);
setIsMoving(false);
setIsPulling(false);
// バッファをリセット
setStrainValues([]);
}
} else {
// 始め検出
if (strain < NEUTRAL_STRAIN - NEUTRAL_STRAIN_RADIUS) {
setIsMoving(true);
setIsPulling(true);
// 新しい引きの記録開始
setStrainValues([strain]);
} else if (NEUTRAL_STRAIN + NEUTRAL_STRAIN_RADIUS < strain) {
setIsMoving(true);
// 新しい押しの記録開始
setStrainValues([strain]);
}
}
}, [strain]);
// Ring-Con コマンド処理
useEffect(() => {
if (!connected) return;
if (!isMoving && strainPerCycle !== 0) {
console.log("Ring-Con command detected");
console.log("strainPerCycle:", strainPerCycle);
setIsPressed(true);
addNode(
strainPerCycle,
turn,
startTime,
setStartTime,
nodes,
setNodes,
currentNode,
setCurrentNode,
setMissCount,
setTurn,
);
// strainPerCycle をリセットして重複実行を防ぐ
setStrainPerCycle(0);
} else {
setIsPressed(false);
}
}, [strainPerCycle, isMoving, connected]);
useEffect(() => {
if (MissLimit - missCount[0] <= 0 || MissLimit - missCount[1] <= 0) {
// If this function is properly called just once, you can call this without passing any number.
handleNext(2);
}
}, [missCount]);
return (
<Box>
<LeftTurn />
<RightTurn />
</Box>
);
}
サイクルの定義は、まず中立域を定め、閾値を超えてstrain値が上がったり(押し込み)下がったり(引っ張り)したときにデータの取得を開始し、中立域に戻った時にデータ取得を終了し、これを一つのサイクルとしています。
このロジックは以下の記事を参考にさせていただきました。
そのサイクルの中で最大の値をサイクルの中でのstrainの値として保持し、ノードに登録します。
以下、ノードの処理に関するロジックです。
import { Node } from "@/types/node";
export const addNode = function (
strain: number,
turn: number,
startTime: Date | null,
setStartTime: (time: Date | null) => void,
nodes: Node[],
setNodes: (nodes: Node[] | ((prev: Node[]) => Node[])) => void,
currentNode: number,
setCurrentNode: (node: number | ((prev: number) => number)) => void,
setMissCount: (fn: (prev: [number, number]) => [number, number]) => void,
setTurn: (prev: number) => void,
) {
let effectiveStartTime = startTime;
if (!effectiveStartTime) {
effectiveStartTime = new Date();
setStartTime(effectiveStartTime); // Initialize atom if it was null
}
const currentTime = new Date();
// 時間差(ms)
const elapsedTime = currentTime.getTime() - effectiveStartTime.getTime();
if (turn === 0) {
setTurn(1); // 初回は右ターン
}
// 末尾の場合
if (currentNode >= nodes.length) {
const newNode: Node = {
strain,
time: elapsedTime,
};
setNodes((prev) => [...prev, newNode]);
setCurrentNode(0);
setTurn(turn * -1); // ターンを反転
setStartTime(null);
} else {
// 配列の範囲チェックを追加
if (currentNode < nodes.length) {
setCurrentNode((prev) => prev + 1);
if (judgeNode(strain, nodes[currentNode], elapsedTime)) {
// OKの場合
} else {
// NGの場合
if (turn === 1) {
setMissCount((prev) => [prev[0], prev[1] + 1]);
} else if (turn === -1) {
setMissCount((prev) => [prev[0] + 1, prev[1]]);
}
}
} else {
// currentNode が範囲外の場合は新しいノードを追加
const newNode: Node = {
strain,
time: elapsedTime,
};
setNodes((prev) => [...prev, newNode]);
setCurrentNode(0);
setTurn(turn * -1); // ターンを反転
setStartTime(null);
}
}
};
// 誤差の許容範囲
const timeTolerance = 100;
const strainTolerance = 128;
const judgeNode = (
strain: number,
node: Node,
elapsedTime: number,
): boolean => {
// ノードが存在しない場合は false を返す
if (!node) {
console.warn("Invalid node data:", node);
return false;
}
const properStrain =
node.strain - strainTolerance <= strain &&
strain <= node.strain + strainTolerance;
const properTime =
node.time - timeTolerance <= elapsedTime &&
elapsedTime <= node.time + timeTolerance;
return properStrain && properTime;
};
turnのプラスマイナスでプレイヤーの操作する順番を制御しています
時間や筋力値において、ぴったり一致することはほぼあり得ないので、特定の範囲の中に値が収まるかどうかで検証を行っています。
この範囲(tolerance)次第でゲームの難易度が決まることになるでしょう。
先にミスが規定値を超えた方がゲームオーバーになり、残った方が勝者となります。
このような流れで、リズムゲームをブラウザーで実装しました。
5. おわりに
時間と技術力があれば、太鼓の達人のようにリズムに合わせてリングコンを押し引きし、ノードを叩くような一人遊びのゲームを作れたような気もするので、振り返ってみるとちょっともったいなかったなとも思いました。
ただ、今回はロジック面の紹介を中心に行いましたが、他にもMUIのstepperの実装や、紙吹雪を舞わせるライブラリーなど、ちょっとした新しい試みもできたので結構満足です。
また、ライブラリーの実装コードやデモのコードを読み解き、自分の開発に必要な箇所を抜き出す作業は始めてやったことでかなり苦労が伴いましたが、その分実際に動くものができたのでとてもよかったです。ライブラリーのドキュメントや実装コードの閲覧は、時間があるときにやっておくとよいように思います。
この記事が読んでくださった方のお役に立てれば幸いです。
ご高覧いただきありがとうございました。
参考