1. はじめに
先日開催された「DroidKaigi 2025」と「iOSDC Japan 2025」の企業ブースにて、本田技研工業ではスマホから操作する「ラジコン縦列駐車ゲーム」を展示しました。
60秒以内にラジコンを操作して縦列駐車を成功させる、というシンプルなゲームですが、多くの方に楽しんでいただくことができました!
この記事では、この企画で私たちが作ったものの技術的な構成や、その裏側についてご紹介します。
2. システムの全体構成
この企画は、大きく分けて3つの要素で構成されています。
- ラジコン本体 (ラジコンキット+Raspberry Pi Pico 2 W)
- コントローラーアプリ (iOS/Android)
- ランキング表示アプリ (macOS)
- ユーザーはスマートフォンアプリを操作し、BLE(Bluetooth Low Energy)でラジコンに指示を送ります。
- 駐車完了後、終了ボタンを押すと、アプリからFirestoreにタイムが送信されます。
- 会場に設置されたディスプレイでは、macOSアプリがFirestoreのデータ変更をリアルタイムに検知し、最新のランキングを表示します。
3. ラジコン本体の仕組み
ゲームの主役であるラジコンの仕組みです。
ハードウェア
制御マイコン
Wi-Fi/Bluetoothが使用可能なRaspberry Pi Pico2 Wを使用しました。
駆動系
市販の4輪駆動ラジコンキットのモーターやタイヤを使用しています。
車体
3Dプリンターでボディを出力しました。モデルはApple Vision Pro向けのプロダクトで利用していたEmileというキャラクターの3Dモデルをラジコンのサイズに合わせて調整しました。
ソフトウェア
ラジコンの制御プログラムは Embedded Swift で開発しました。
スマートフォンからの操作は、BLE通信を介して受け取ります。
アプリから左右のモーターの回転数を示す文字列(例: 14001600
)が送られてくると、Pico側でそれを受信し、PWM(パルス幅変調)信号を生成してモータードライバーに伝えます。
以下に、メイン処理の一部を抜粋します。
// サーボのGPIOピン定義
let SERVO_A_PIN: UInt32 = 0 // PWM Slice 0, Channel A
let SERVO_B_PIN: UInt32 = 1 // PWM Slice 0, Channel B
let PWM_FREQ: UInt32 = 50
// Pico2の周波数:150MHz
let CLOCK_DIVISION: Float = 150.0 // 150MHz / 150 = 1MHz
let WRAP: UInt16 = 19999 // 50Hz (CLOCK_DIVISION / (19999+1) = 50Hz)
let serviceUUID: [UInt8] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
]
let characteristicUUID: [UInt8] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
]
var leftLevel: UInt16 = 1500
var rightLevel: UInt16 = 1500
func setup_profile() {
att_db_util_init()
att_db_util_add_service_uuid128(serviceUUID)
att_db_util_add_characteristic_uuid128(
characteristicUUID,
UInt16(ATT_PROPERTY_WRITE | ATT_PROPERTY_WRITE_WITHOUT_RESPONSE | ATT_PROPERTY_DYNAMIC),
UInt8(ATT_SECURITY_NONE),
UInt8(ATT_SECURITY_NONE),
nil, 0 // 初期値なし
)
}
func attReadCallback(conHandle: hci_con_handle_t,
attHandle: UInt16,
offset: UInt16,
buffer: UnsafeMutablePointer<UInt8>?,
bufferSize: UInt16) -> UInt16 {
// 今回は READ を使わない
return 0
}
// att_write_callback に相当
func attWriteCallback(conHandle: hci_con_handle_t,
attHandle: UInt16,
transactionMode: UInt16,
offset: UInt16,
buffer: UnsafeMutablePointer<UInt8>?,
bufferSize: UInt16) -> Int32 {
guard let buffer else {
print("Error: buffer is nil.")
return -1 // エラーを示す値を返す
}
guard bufferSize == 8 else {
return 0;
}
// 前半4桁の数値を格納するローカル変数
var left: UInt16 = 0
// 後半4桁の数値を格納するローカル変数
var right: UInt16 = 0
// C言語の`uint8_t *`ポインタを`Array`として扱う
let data = UnsafeBufferPointer(start: buffer, count: Int(bufferSize))
// 前半4桁の復元
for i in 0..<4 {
let digit = UInt16(data[i]) - 48
left = left * 10 + digit
}
// 後半4桁の復元
for i in 4..<8 {
let digit = UInt16(data[i]) - 48
right = right * 10 + digit
}
leftLevel = left
rightLevel = right
return 0
}
@main
struct Main {
static func main() {
if cyw43_arch_init() != 0 {
print("Wi-Fi/Bluetooth init failed")
return
}
stdio_init_all()
l2cap_init()
sm_init()
setup_profile()
att_server_init(att_db_util_get_address(), attReadCallback, attWriteCallback)
// Swift側で定義するとエラーが生じて解消できなかったので、この部分の処理はBridgingHeader.hのCのコードで定義しそれを呼ぶ
setup_gap_advertisements()
let led = UInt32(CYW43_WL_GPIO_LED_PIN)
let gpioFunc: gpio_function_rp2350 = GPIO_FUNC_PWM
gpio_set_function(SERVO_A_PIN, gpioFunc)
gpio_set_function(SERVO_B_PIN, gpioFunc)
let slice_num: UInt32 = pwm_gpio_to_slice_num(SERVO_A_PIN) // SERVO_B_PINでも同じスライス番号
let chan_a: UInt32 = pwm_gpio_to_channel(SERVO_A_PIN)
let chan_b: UInt32 = pwm_gpio_to_channel(SERVO_B_PIN)
// スライス全体のクロックとラップ値を一度だけ設定
pwm_set_clkdiv(slice_num, CLOCK_DIVISION)
pwm_set_wrap(slice_num, WRAP)
pwm_set_enabled(slice_num, true)
cyw43_arch_gpio_put(led, true)
while true {
pwm_set_chan_level(slice_num, chan_a, leftLevel)
pwm_set_chan_level(slice_num, chan_b, rightLevel)
sleep_ms(100)
}
}
}
attWriteCallback
でアプリから送られてきた8バイトのデータ(ASCII文字列)をパースし、左右のモーターに対応する leftLevel
と rightLevel
という変数に格納しています。
main
ループでは、これらの値を元に絶えずPWM信号を更新し続けることで、モーターの回転を制御しています。
4. コントローラーアプリ(iOS/Android)
ラジコンを操作するためのスマートフォンアプリは、iOS (Swift) と Android (Kotlin) でそれぞれネイティブ開発しました。
ジャイロセンサーによる操作
操作は、画面右側のスティックを上下に動かすことで前進・後退、スマートフォンのジャイロセンサーを使い、端末自体をハンドルのように傾けることで左右に曲がる、という直感的な操作を目指しました。
ラジコン操作の計算
ラジコンのモーター制御
このラジコンは一般的な自動車のように前輪の向きを変えて曲がるのではなく、左右の車輪の回転速度に差をつけることで曲がるようになっています。
4つのタイヤそれぞれにモーターが付いていますが、制御は左側2輪と右側2輪の2系統で行い、同じ側の前後輪は常に同じ値で回転します
モーターに送る値の仕様は以下の通りです。前進と後退で、値の大小と速度の関係が左右で逆になっているのが特徴です。
-
左車輪:
1000
~1480
で前進(値が小さいほど高速)、1520
~2000
で後退(値が大きいほど高速) -
右(R)車輪:
1520
~2000
で前進(値が大きいほど高速)、1000
~1480
で後退(値が小さいほど高速) -
停止: 左右ともに
1480
~1520
操作インターフェース:スマホの傾き
スマートフォンをハンドルに見立て、傾きの角度に応じてモーターへ送信する値を変化させます。
- 有効角度: 無理なく操作できる 90° (左) から +90° (右) までを有効値としています。この範囲を超えて傾けた場合は、それぞれ
90°
または+90°
の値として扱います。 - 操作性の調整:
1°
変わるごとにモーター出力を更新すると、反応が敏感すぎて操作が難しくなりました。そのため、傾きが 5° 変化するごとに出力値を更新するように調整することで、安定した操作ができるようにしました。
具体的な計算式
Step 1: 旋回比率の算出
まず、スマホから取得した生の角度 θ を、5°
単位に丸めます。そして、その角度が最大値(90°
)に対してどのくらいの割合なのかを示す旋回比率 (turnRatio) を算出します。
turnRatio
は、スマホを右に最大まで傾けると1.0
、左に最大まで傾けると-1.0
になります。
θ{adjusted}=round(θ/5.0)×5.0
turnRatio=\frac{θ{adjusted}}{90.0}
Step 2: 前進時のモーター出力計算
算出した turnRatio
を使って、前進時の左右のモーター出力を計算します。
直進時(turnRatio = 0
)の出力を基準値とし、turnRatio
の値に応じて左右の出力に差をつけていきます。グラフを見るとわかるように、直進(角度0°
)時の出力は左(L)が1400
、右(R)が1600
です。
計算式は以下のようになります。turnRatio
がプラス(右旋回)かマイナス(左旋回)かによって、左右の車輪のどちらが速くなるかが変わる仕組みです。
- 左(L)車輪の出力値
L = 1400 − 80 \times turnRatio
- 右(R)車輪の出力値
R = 1600 − 80 \times turnRatio
例えば、右に旋回(turnRatio > 0
)する場合を考えてみます。
- 左車輪(L)は
1400
から値が引かれ、出力値が小さくなります。仕様上、左車輪は値が小さいほど高速になるため加速します。 - 右車輪(R)も
1600
から値が引かれ、出力値が小さくなります。仕様上、右車輪は値が大きいほど高速なので減速します。
この結果、「左車輪が加速し、右車輪が減速する」ため、車体は右に曲がります。左旋回時はこの逆の動作となります。
Step 3: 後退時のモーター出力計算
後退時も基本的な考え方は同じですが、基準となる出力値と速度の関係が前進時とは逆になります。
計算式は以下の通りです。
- 左(L)車輪の出力値
L = 1600 + 80\times turnRatio
- 右(R)車輪の出力値
R = 1400 + 80 \times turnRatio
BLEでのコマンド送信
BLEでラジコンを操作するためのプロトコルは非常にシンプルです。
上述した計算式で左右のモーター回転数を計算し、ラジコン本体のCharacteristicに対して8桁の数値をwrite without responseで書き込みます。
(例:左モーター1400、右モーター1600の場合 → 14001600
)
Firestoreへのタイム保存
駐車に成功し、結果画面に遷移したタイミングで、Firestoreに結果のレコードを追加しています。
5. ランキング表示アプリ(macOS)
ブースのディスプレイに表示するランキングは、macOSアプリとして作成しました。
ランキング内容はダミーです。
Firestoreのスナップショットリスナーを使い、誰かがゴールしてFirestoreに新しいタイムが書き込まれると、アプリ側で変更を検知し自動でランキングを再計算して表示を更新します。
6. イベントでの最終ランキング
2つのイベントでの最終的な上位ランキングはこのようになりました。
非常にハイレベルな戦いでした!
DroidKaigi 2025 の結果
iOSDC Japan 2025 の結果
iOSDCの方がクリア率が高い傾向にありましたが、これはDroidKaigiで一度体験した方が再挑戦してくれたり、Honda社員がデモ走行を見せる機会が多かったことなどが影響しているかもしれません。
挑戦していただいた皆さん、本当にありがとうございました!
7. まとめ
参加してくださった方からは、「すごく面白い!」「自分で作ってみたい」「これ売ってないんですか?」など、嬉しい感想をたくさんいただきました。
自分たちの作ったプロダクトで目の前のお客さんが楽しんでくれているのを肌で感じられたのはとてもいい体験で、これからもこの感覚を忘れずにプロダクトを作っていきたいなと思います。
普段触れることの少ないハードウェア寄りの開発は新鮮でしたが、実物を触りながら試行錯誤したり、3DプリンターではXRプロダクトを手がけるチームに助けてもらったりしつつ、なんとか形にできて本当によかったです。
- iOSはCiscoの登録商標であり、Apple Inc.がライセンスに基づき使用しています。
- Swift,Apple Vision ProはApple Inc.の商標です。
- macOSは米国およびその他の国や地域で登録されたApple Inc.の商標です。
- KotlinはKotlin Foundationの登録商標です。
- AndroidおよびFirestoreはGoogle LLCの登録商標です。
- BluetoothはBluetooth SIG, Inc.の登録商標です。
- Wi-FiはWi-Fi Allianceの登録商標です。
- Raspberry Pi は Raspberry Pi Ltdの登録商標です。