この記事はK3 Advent Calendar 2025の5日目です。
「おねえちゃんは濡れたくない!」とは
傘を持った女の子を操作して、雨や水しぶきをガードしてゴールを目指すゲームです。
公式サイト→https://berusukobo7.github.io/UmbrellaGirl/
法政大学小金井祭やデジゲー博2025にて出展しました。
このゲームの操作には、小型の傘をモチーフにしたオリジナルのコントローラーを用います。「傘を振り回すだけ」という直感的な操作が子供から大人、普段ゲームをやらない人まで幅広い層に人気でした。
今回の記事では、このコントローラーのハード部分(加速度センサ+マイコンを用いたデータ送信)とソフト部分(UnrealEngine上でのデータ受信)の連携について、ソフト部分に主軸を置いて実装について書いていきます。
受信データの仕様
コントローラーから送信されるデータは、Bluetoothを使用したシリアル通信で受信します(UEを用いた受信方法については後述)。データは1パケット19バイトとなっており、内訳は以下のようになっています。
- ヘッダー(2バイト)
データパケットの開始を示す識別子。0xAA,0xBBで固定。 - クォータニオンによる姿勢データ(16バイト)
傘に取り付けられた加速度センサから取得した姿勢データがfloat型4つのクォータニオン(x,y,z,w)として連続して与えられる。 - ボタンステータス(1バイト)
傘に取り付けられたボタンからの入力が0(押されていない)、1(押されている)として与えられる。
受信データの活用
コントローラーから受信したデータは以下のように使用します。
- ゲーム中の傘の操作
受け取ったクォータニオンを元に、現実での傘の角度とゲーム内の傘の角度をリンクさせます。ここでは、「ゲーム画面に対して、傘を真上に向け、ボタンがプレイヤーの体と向き合う状態」を基準軸としています。もし基準軸がズレていた場合は、上述の状態にしたうえでボタンを押したときに基準軸をリセットできるようにします。 - その他の画面(タイトル画面、クリア画面、ゲームオーバー画面、ランキング画面)の操作
タイトル画面ではゲーム中と同様の基準軸で傘を左右に傾けることで難易度選択等をできるようにします。また、これらの画面ではこのとき傘のボタンは基準軸リセットのためではなく、決定ボタンとして使用されます。
以上のように、傘からの受信データはゲーム中ほぼ全ての場面で使用します。
よって、受信データは 「ゲームが起動している間いつでも受け取れる」 「どんなクラスからもアクセスできる」 という状態にしておきたい!という気持ちがありました。
UEにおいて、最も簡単に以上のような要求を実現する方法は「GameInstance」を使用することです。GameIntanceはゲーム起動時から終了時まで常に1つだけ存在することが確約されているクラスであり、どんなクラスからも簡単にアクセス可能です。よって、ここに受信処理を書けばOK…なのですが、問題があります。
それは、「GameInstanceの神クラス化」 です。GameInstanceはその性質からSingletonのように働き、非常に便利なので多用してしまいがちです。結果、なんでもかんでもGameInstanceに書くことに繋がり、やがて神クラスを生み出します。
本来、処理の役割に応じて適切にクラスを切り分けるべきでしょう。ここで登場する便利機能が、「Subsystem」 です。ここで紹介するとそれだけで1つの記事になってしまうので、良い参考資料を置いときます。
Subsystemの1つである「GameInstanceSubsystem」を使用すれば、「ゲーム中常に存在し、どのクラスからもアクセスできる」 という特性そのままに、処理毎にクラスを切り分けることが可能です。今回は、これを用いて「SerialComInputManagerSubsystem」というクラスを作成することにします。ただし、これは現状、C++でしか作成できないというデメリットがあります。
UnrealEngine上でのデータ受信
では、受信データの仕様と、そのデータの活用方法が決まったところで、具体的なデータ受信の方法について書いていきます。
UnrealEngineのバージョンは5.4です。
データの受信には有志が公開しているプラグイン「SerialCOM」を使用します。
こちらは、Blueprintにシリアル通信を受信できるノードを追加してくれるというスグレモノです。
…はい、ここで問題発生です。今回、データの受信をしたいクラスは、Subsystemを使うのでC++でしか作成できないというデメリットがありました。BP使えないやんけ!
SerialCOM公式のドキュメントや、ネット上の記事を調べて、C++での使用例を探しましたが、どうにもBP上での受信方法しか記載されていません。さて困った…
よし、プラグインコードを読もう。
BPはC++への互換性を保っているという前提があるので、BP上に実装されているノードには必ずC++の関数が存在します。BPで使用したいノードの元コードを見つけ出し、関数名を知ることができればC++上からも同じ機能を使うことができます。
となれば後は探すだけです。今回使用した機能の、BPノードとC++関数の対応を以下にまとめました。
C++上でSerialCOMを使いたい!という人は是非参考にしてください。
SerialCOMプラグインをC++上で使用する方法
事前準備
○○.Build.csのPublicDependencyModuleNames.AddRange()にSERIALCOMを追加
使用したいC++クラスで#include "SerialCom.h"
BPノードとの対応(私が今回使用したもののみ)
OpenSerialPort
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Open Serial Port"), Category = "Communication Serial", meta = (Keywords = "communication com SERIALCOM duino arduino serial port start open serial"))
static USerialCom* OpenComPort(bool &bOpened, int32 Port = 1, int32 BaudRate = 9600);
使用例
USerialCom* SerialCom;
bool Opened;
int32 Port=1;
int32 BaudRate = 115200;
SerialCom = USerialCom::OpenComPort(Opened, Port, BaudRate);
Flush Serial Port
C++
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Flush Serial Port"), Category = "Communication Serial")
void Flush();
使用例
USerialCom* SerialCom;
bool Opened;
SerialCom = USerialCom::OpenComPort(Opened, 1, 115200);
if(SerialCom) SerialCom->Flush();
Close Serial Port
C++
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Close Serial Port"), Category = "Communication Serial", meta = (Keywords = "communication com SERIALCOM duino arduino serial end finish release close port"))
void Close();
使用例
USerialCom* SerialCom;
bool Opened;
SerialCom = USerialCom::OpenComPort(Opened, 1, 115200);
if (SerialCom) SerialCom->Close();
Serial Read Bytes
C++
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Serial Read Bytes", keywords = "communication com SERIALCOM duino arduino serial read bytes get read receive"), Category = "Communication Serial")
TArray<uint8> ReadBytes(int32 Limit = 256);
使用例
USerialCom* SerialCom;
bool Opened;
SerialCom = USerialCom::OpenComPort(Opened, 1, 115200);
if(SerialCom)
{
SerialCom->Flush();
SerialCom->ReadBytes(256);
}
ここに書かれていないノードも、SerialCom.hを見れば全て載っています。
C++の関数名と、BPノード名が必ずしも一致しないことには注意してください。BPノード名は、対応する関数のUFUNCTION(meta = (DisplayName = ""))に記載してあります。
「おねえちゃんは濡れたくない!」での受信部分のコード
おまけですが、今回のゲームでの受信部分のコードを載せておきます。これをBPで書きたくなかったのもC++化した理由の一つです…
void USerialComInputManagerSubsystem::Tick(float DeltaTime)
{
uint8 PreButtonPressedFlag = 0;
if (InputBytes.Num() == 19) PreButtonPressedFlag = InputBytes[18];
if (ReadInputBytes())
{
//受信成功時の処理
}
if (InputBytes.Num() == 19)
{
if (InputBytes[18] == 1 && PreButtonPressedFlag == 0)
{
//ボタンが押された時の処理
}
}
}
bool USerialComInputManagerSubsystem::ReadInputBytes() {
//コントローラー未接続なら失敗
if (!SerialCom || !(SerialCom->IsOpened()))
{
UE_LOG(LogTemp, Log, TEXT("Contoroller Unconnected"));
return false;
}
Buffer.Append(SerialCom->ReadBytes(256)); //バッファに受信データを読み込む
TArray<uint8> TmpBuffer = Buffer;
int32 HeaderIdx;
//最新のヘッダーを検索、見つからなければ終了
while ((HeaderIdx = TmpBuffer.FindLast(0xAA)) != INDEX_NONE) {
//最新のヘッダー以降のデータが揃っていない場合は一時的に以降のデータを消去して続行
if (TmpBuffer.Num() < HeaderIdx + 19 || TmpBuffer[HeaderIdx + 1] != 0xBB)
{
for (int i = TmpBuffer.Num() - 1; i >= HeaderIdx; i--)
{
TmpBuffer.RemoveAt(i);
}
}
//データが揃っていた場合はデータを格納して終了
else
{
//データ格納
InputBytes.Empty();
for (int i = 0; i < 19; i++)
{
InputBytes.Add(TmpBuffer[HeaderIdx + i]);
}
//不要なバッファを削除
TmpBuffer = Buffer;
Buffer.Empty();
for (int i = HeaderIdx + 19; i < TmpBuffer.Num(); i++)
{
Buffer.Add(TmpBuffer[i]);
}
return true;
}
}
UE_LOG(LogTemp, Log, TEXT("Data Not Enough"));
return false;
}



