プログラムと電子工作・ラジコンカー(3)ジョイスティック風アプリ
第3段階は、スマートフォン・アプリを作ります。今回は M5StickC Plus の出番はありません。純粋に Webサーバとブラウザのお話です。
スマートフォンをジョイスティックにみたて、スマートフォンの画面を指でなぞって、右へ左へ自在にラジコンカーを操縦したいと思います。どんな操作が便利でしょうか。
指をタップした点から前に滑らせると、ロボットカーが前進し、右に滑らせると、右に旋回し、左に滑らせると、左に旋回する、タップした点から滑らせた距離が大きいほど、速度が上がる、というのはどうでしょうか。
スマートフォンの画面をタップする →ロボットカーは停止したまま
まっすぐ前に滑らせる →距離に応じた速度で前進
右に滑らせる →右に旋回
左に滑らせる →左に旋回
指を画面から離す →停止
目標
ジョイスティック風アプリは、Webアプリとします。
最初の点から滑らせた指の位置に応じて、直進と旋回の値をリアルタイムに Webサーバへ送信します。
値を受け取る疑似サイトを作ります。
PCのブラウザのデベロッパーツール、開発者ツールで、動作を確認します。
部品・機材
使用する機材は次のとおりです。電子部品と M5StickC Plus は不要です。
開発用機材
PC(Windows10 または 11)、Python 導入ずみ
スマートフォン(なくてもOK)
Wi-Fi ルータ
開発手順
PC に Python が導入されていないときは、Microsoft Store から Python 3.xx をインストールする。Python の公式サイトからダウンロードしてもよい。
PC のテキストエディタもしくはメモ帳で、index.html、drive.html を作成する。日本語の文字コードは UTF-8で保存してください。
PC のテキストエディタもしくはメモ帳で、webserver.bat を作成する。
index.html、drive.html、webserver.bat を同じフォルダに保存する。
webserver.bat をダブルクリックして、実行する。
ブラウザ(Chrome、Edge など)で http://localhost/index.html を開く。
ブラウザのデベロッパーツール、開発者ツール[Ctrl+Shift+I]を選ぶ。対象デバイスをスマートフォンに切り替える。
マウスを使用して、図1の操作を行う。
デベロッパーツール、開発者ツールのネットワーク情報を確認する。
webserver.bat
webserver.bat は Python で簡易的に Webサーバを立てます。bat ファイルを作っておけば、コマンドプロンプトを起動しタイピングする手間が省けます。
2行目が Webサーバです。ポートを 80番に指定します。これだけで、同じフォルダの *.html ファイルが Webサイトになります。
webserver.bat
@echo off
python3 -m http.server 80
drive.html
drive.html は、スマートフォンからの指令に基づき、左右のモーターを制御します。この処理は第4段階で M5StickC Plus に実装します。第3段階は PC上でのシミュレーションですので、何もしません。「OK」を返すだけです。
drive.html
OK
index.html
index.html は、スマートフォンで動作する Webアプリです。第4段階で M5StickC Plus に実装する Webサーバはファイル構造を取り扱わないので、index.html 内に HTML、CSS、javascript をすべて含めます。
正直言って、スマートフォンの HTML や CSS は素人同然なので、ネット検索を多用しました。結果オーライで、なぜそうなるかの理屈が分かっていないところが多数あります。
スマートフォンのパネル上部に瞬時値を表示する領域を設けます。残りの下部すべてを操作領域とします。
瞬時値を表示する領域
最初のタップ点(ノーマル位置)の座標を(startX, startY)としたとき、原点から指を滑らせた点(現在位置)の座標(cX, cY)との差分(dX, dY)=(cX-startX, cY-startY)を表示します。スマートフォンの画面は、左上隅が絶対座標の原点で、右方向が +X、下方向が +Y となります。
操作領域
指でなぞる領域(ジョイスティック領域)は、スマートフォンのほぼ画面全面を <canvas> にします。
<div id="canvas">
<canvas id="joystick">
*** ブラウザがキャンバス <canvas> をサポートしていません。 ***
</canvas>
</div>
タッチインターフェース
図1 の操作を実現するためには、最初にタップした、指を滑らせた、指を離したの各タッチイベントを捕捉し、それぞれに応じた処理を行います。
最初にタップした→'touchstart' イベント処理 function touchStart(event)
最初のタップ点(ノーマル位置)の座標(startX, startY)を記憶し、差分(dX, dY)を(0, 0)にリセットします。
ノーマル位置に赤色の小さな円を描きます。指を滑らせた→'touchmove' イベント処理 function touchMove(event)
最初のタップと同じ指を滑らせた点(現在位置)の座標(cX, cY)を求め、差分(dX, dY)に(cX-startX, cY-startY)を記憶します。
ジョイスティック領域を消去し、ノーマル位置に赤色の小さな円を描き、現在位置に水色の大きな円を描き、ノーマル位置と現在位置を灰色の直線で結びます。指を離した→'touchend' イベント処理 function touchEnd(event)
最初のタップと同じ指を離した時点で、差分(dX, dY)を(0, 0)にリセットします。
ジョイスティック領域を消去し、指を離した点に青色の小さな円を描きます。取り消し→'touchcancel' イベント処理 function touchCancel(event)
指を離したときと同じ処理をします。
スマートフォンは複数の指のタップを認識し、各々の指の動きを追跡します。最初にタップした指のみの軌跡を処理する必要があります。
モーター制御指令
時々刻々変化する差分(dX, dY)を元に、モータ制御変数 forward、rotate を計算し、http://localhost/drive.html へ定期的に送信します。
//--------------------------------------------------------------------------
// 定期通信
setInterval(drive_by_get, INTERVAL_TO_SEND_COORDINATE);
実際は下記のコードのように 100ミリ秒にしますが、第3段階のシミュレーションでは、2000ミリ秒くらいに遅くした方が楽に目で追えます。
// モータ制御送信
const INTERVAL_TO_SEND_COORDINATE = 100; /*モータ制御送信の時間間隔(ms)*/
const MOTOR_DRIVE_URL = '/drive.html'; /*モータ制御送信先のURL、/drive.html?forward=F&rotate=R*/
/* F:0.0~1.0、R:-1.0~+1.0、+右旋回、-左旋回 */
スマートフォン・アプリと M5StickC Plus の Webサーバ間通信は、非同期通信 AJAX を使います。AJAX は次のような HTTPリクエストを GETメソッドで送信します。
/drive.html?forward=0.5&rotate=0.13
//--------------------------------------------------------------------------
async function drive_by_get() {
let motor_data = motor(dX, dY);
let data_src = MOTOR_DRIVE_URL
+ "?forward=" + motor_data['forward']
+ "&rotate=" + motor_data['rotate'];
console.log("fetch get: " + data_src);
const response = await fetch(data_src, {method: "get"});
if (response.status === 200) {
//alert("データを取得しました。");
const data = await response.text();
if (data) {
// --- データが取得できた。
console.log("response.text(): " + data);
}
}
else {
alert("データを取得できませんでした。");
}
}
motor(dX, dY) は差分(dX, dY)からモータ制御変数 forward、rotate を算出する関数です。
スマートフォンの画面サイズの半分を 1.0 として、dX、dY の割合を計算します。スマートフォンの画面は、左上隅が絶対座標の原点で、右方向が +X、下方向が +Y となりますので、上下方向は符号を反転します。また最大値・最小値を ±1.0 に制限します。この辺りは、適当に変えてください。
//--------------------------------------------------------------------------
// モータ制御変数を計算する。
// return: object モータ制御変数、e.g. {"forward": 0.3, "rotate": -0.5}
// in: int dx X座標のノーマル位置と現在位置の差(dX)
// int dy Y座標のノーマル位置と現在位置の差(dY)
function motor(dx, dy) {
let f = 0.0;
let r = 0.0;
// スマートフォンの画面サイズで正規化する。
// 画面サイズの半分を 1.0とし、符号反転、小数点以下 2桁で四捨五入する。
// 1.0が最大、 -1.0が最小。
if (canvasHeight > 0) {
f = Math.round(-200.0 * dy / canvasHeight) / 100;
f = f > 1.0 ? 1.0 : (f < -1.0 ? -1.0 : f);
}
if (canvasWidth > 0) {
r = Math.round(200.0 * dx / canvasWidth) / 100;
r = r > 1.0 ? 1.0 : (r < -1.0 ? -1.0 : r);
}
return {"forward": f, "rotate": r}; /*FIN*/
}
index.html 全体
長くなりますが、全体を掲載します。
<!doctype html>
<html lang="ja">
<head>
<!-- Required meta tags -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ラジコンカー</title>
<!-- CSS -->
<style>
<!--
body{
margin: 0;
padding: 0;
}
/*ジョイスティック領域*/
#joystick {
position: absolute;
top: 0;
left: 0;
}
/*瞬時値表示領域*/
td {
width: 50vw;
padding: 4;
}
-->
</style>
</head>
<body>
<!-- この中に要素を配置する。 -->
<div>
<!-- 瞬時値領域 -->
<div>
<table>
<tr>
<td>
<span id="dX-label">dX:</span><span id="dX-value">----</span>
</td>
<td>
<span id="dY-label">dY:</span><span id="dY-value">----</span>
</td>
</tr>
</table>
</div>
<div id="messageDiv">メッセージ</div>
</div>
<!-- ジョイスティック領域 -->
<div id="canvas">
<canvas id="joystick">
*** ブラウザがキャンバス <canvas> をサポートしていません。 ***
</canvas>
</div>
<script>
//--------------------------------------------------------------------------
// 初期化
//--------------------------------------------------------------------------
// タップ位置の印の半径
const radiusStart = 10;
const radiusMove = 60;
const radiusEnd = 10;
// タップ位置の印の色
const colorStart = 'red';
const colorMove = 'skyblue';
const colorEnd = 'blue';
// キャンバスのサイズ
var canvasWidth = 0;
var canvasHeight = 0;
// ノーマル位置の記録
var startX = 0.0;
var startY = 0.0;
var touch0identifier = 0;
var ongoing = false;
// 現在位置の記録
var dX = 0;
var dY = 0;
// モータ制御送信
const INTERVAL_TO_SEND_COORDINATE = 100; /*モータ制御送信の時間間隔(ms)*/
const MOTOR_DRIVE_URL = '/drive.html'; /*モータ制御送信先のURL、/drive.html?forward=F&rotate=R*/
/* F:0.0~1.0、R:-1.0~+1.0、+右旋回、-左旋回 */
document.addEventListener("DOMContentLoaded", startup);
//--------------------------------------------------------------------------
// 関数定義
//--------------------------------------------------------------------------
// メッセージを書く。
// in: String msg メッセージ文字列
function message(msg) {
let obj = document.getElementById('messageDiv');
obj.innerHTML = msg;
}
//--------------------------------------------------------------------------
// 座標を書く。
// in: int x X座標
// int y Y座標
function coordinate(x, y) {
let obj_x = document.getElementById('dX-value');
let obj_y = document.getElementById('dY-value');
obj_x.innerHTML = String(x);
obj_y.innerHTML = String(y);
}
//--------------------------------------------------------------------------
// モータ制御変数を計算する。
// return: object モータ制御変数、e.g. {"forward": 0.3, "rotate": -0.5}
// in: int dx X座標のノーマル位置と現在位置の差(dX)
// int dy Y座標のノーマル位置と現在位置の差(dY)
function motor(dx, dy) {
let f = 0.0;
let r = 0.0;
// スマートフォンの画面サイズで正規化する。
// 画面サイズの半分を 1.0とし、符号反転、小数点以下 2桁で四捨五入する。
// 1.0が最大、 -1.0が最小。
if (canvasHeight > 0) {
f = Math.round(-200.0 * dy / canvasHeight) / 100;
f = f > 1.0 ? 1.0 : (f < -1.0 ? -1.0 : f);
}
if (canvasWidth > 0) {
r = Math.round(200.0 * dx / canvasWidth) / 100;
r = r > 1.0 ? 1.0 : (r < -1.0 ? -1.0 : r);
}
return {"forward": f, "rotate": r}; /*FIN*/
}
//--------------------------------------------------------------------------
// タップ位置に印を描く。
// in: object ctx 2Dコンテキスト
// String color 色、e.g. 'red'
// int r 円の半径
// int x 中心座標X
// int y 中心座標Y
function mark(ctx, color, r, x, y) {
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI, false);
ctx.fill();
}
//--------------------------------------------------------------------------
// 直線で結ぶ。
// in: object ctx 2Dコンテキスト
// String color 色、e.g. 'red'
// int sx 開始座標X
// int sy 開始座標Y
// int ex 終了座標X
// int ey 終了座標Y
function line(ctx, color, sx, sy, ex, ey) {
ctx.strokeStyle = color;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.stroke();
}
//--------------------------------------------------------------------------
// イベント処理:最初のタップ
// in: object event イベント
function touchStart(event) {
/*fordebug*/
message("touchStart");
event.preventDefault();
// ジョイスティックのノーマル位置を記録する。
const touches = event.changedTouches;
if (touches[0]) {
startX = parseInt(touches[0].clientX);
startY = parseInt(touches[0].clientY);
touch0identifier = touches[0].identifier;
}
// ジョイスティックのノーマル位置に印をつける。
const cvs = document.getElementById('joystick');
const ctx = cvs.getContext('2d');
mark(ctx, colorStart, radiusStart, startX, startY);
// モータ制御用
dX = 0;
dY = 0;
// 座標を表示する。
coordinate(0, 0);
ongoing = true;
}
//--------------------------------------------------------------------------
// イベント処理:タップの終了
// in: object event イベント
function touchEnd(event) {
/*fordebug*/
message("touchEnd");
event.preventDefault();
const touches = event.changedTouches;
// 最初のタップと同じタップの移動かチェックする。
for (let i = 0; i < touches.length; i++) {
let id = touches[i].identifier;
if (id == touch0identifier) {
// --- 同じタップの移動である。
// キャンバスを消去する。
const cvs = document.getElementById('joystick');
const ctx = cvs.getContext('2d');
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// ジョイスティックの最終位置に印をつける。
let cX = parseInt(touches[i].clientX);
let cY = parseInt(touches[i].clientY);
mark(ctx, colorEnd, radiusEnd, cX, cY);
// モータ制御用
dX = 0;
dY = 0;
// 座標を表示する。
coordinate(cX - startX, cY - startY);
ongoing = false;
}
}
}
//--------------------------------------------------------------------------
// イベント処理:タップの中断
// in: object event イベント
function touchCancel(event) {
/*fordebug*/
message("touchCancel");
// タップの終了と同じ。
touchEnd(event);
}
//--------------------------------------------------------------------------
// イベント処理:タップの移動
// in: object event イベント
function touchMove(event) {
/*fordebug*/
message("touchMove");
event.preventDefault();
const touches = event.changedTouches;
// 最初のタップと同じタップの移動かチェックする。
for (let i = 0; i < touches.length; i++) {
let id = touches[i].identifier;
if (id == touch0identifier) {
// --- 同じタップの移動である。
// キャンバスを消去する。
const cvs = document.getElementById('joystick');
const ctx = cvs.getContext('2d');
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// ジョイスティックのノーマル位置に印をつける。
mark(ctx, colorStart, radiusStart, startX, startY);
// ジョイスティックの現在位置に印をつける。
let cX = parseInt(touches[i].clientX);
let cY = parseInt(touches[i].clientY);
mark(ctx, colorMove, radiusMove, cX, cY);
// ノーマル位置と現在位置を直線で結ぶ。
line(ctx, 'darkgray', startX, startY, cX, cY);
// モータ制御用
dX = cX - startX;
dY = cY - startY;
// 座標を表示する。
coordinate(dX, dY);
ongoing = true;
}
}
}
//--------------------------------------------------------------------------
// 定期通信
setInterval(drive_by_get, INTERVAL_TO_SEND_COORDINATE);
//--------------------------------------------------------------------------
async function drive_by_get() {
let motor_data = motor(dX, dY);
let data_src = MOTOR_DRIVE_URL
+ "?forward=" + motor_data['forward']
+ "&rotate=" + motor_data['rotate'];
console.log("fetch get: " + data_src);
const response = await fetch(data_src, {method: "get"});
if (response.status === 200) {
//alert("データを取得しました。");
const data = await response.text();
if (data) {
// --- データが取得できた。
console.log("response.text(): " + data);
}
}
else {
alert("データを取得できませんでした。");
}
}
//--------------------------------------------------------------------------
// イベントハンドラを登録する。
function startup() {
/*fordebug*/
message("startup");
const cvs = document.getElementById('joystick');
cvs.addEventListener('touchstart', touchStart);
cvs.addEventListener('touchend', touchEnd);
cvs.addEventListener('touchcancel', touchCancel);
cvs.addEventListener('touchmove', touchMove);
canvasWidth = window.innerWidth;
canvasHeight = window.innerHeight;
cvs.setAttribute('width', canvasWidth);
cvs.setAttribute('height', canvasHeight);
}
</script>
</body>
</html>
結果
PCのブラウザで確認する
デベロッパーツール、開発者ツールのネットワーク情報を確認し、HTTPリクエストがマウス操作と合致していればスマートフォン・アプリが正しく動作しています。例えば、/drive.html?forward=0.5&rotate=0.13 のような HTTPリクエストになります。forward、rotate の値は、それぞれ第2段階のスケッチで drive() 関数に渡した値に相当します。
図2は Chrome のデベロッパーツールで iPhone12 をシミュレーションしているスクリーンショットです。このときのコマンドプロンプトは図3 のようになります。うまく通信できていることが分かります。
スマートフォンで確認する
PCのブラウザで確認できたら、スマートフォンでもやってみましょう。
スマートフォンで確認するには、PCとスマートフォンが同じネットワークに接続されている必要があります。両方とも同じ Wi-Fiルータに接続されていればOKです。
まず PCの IPアドレスを確認します。PCの IPアドレスを調べる方法は、Microsoft のサポートに説明があります。操作は簡単です。
続いて、スマートフォンのブラウザ(Chrome、Safariなど)を開きます。
検索欄に PCの IPアドレスを入力します。例えば、「192.168.1.23」
スマートフォンの画面に何も表示されず、図3 のコマンドプロンプトも変化しないときは、PCのセキュリティ設定によって PC外からの通信がブロックされている可能性があります。じっと待っていたらスマートフォンにエラーメッセージが表示されます。
80番ポートの受信を許可するように、PCのセキュリティ設定を変更してください。ですが、セキュリティ設定をいじるのは危険になる恐れがありますので、スマートフォンでの動作確認をスキップして第4段階に進んでもいいです。
うまく行ったら、図3 のようにコマンドプロンプトに通信内容が表示されます。
参考
タッチイベント
Windows IPアドレスを調べる
ライセンス
このページのソースコードは、複製・改変・配布が自由です。営利目的にも使用してかまいませんが、何ら責任を負いません。
謝辞
福武教育文化振興財団から 2023年度助成をいただき製作しました。