見出し画像

プログラムと電子工作・ラジコンカー(3)ジョイスティック風アプリ

第3段階は、スマートフォン・アプリを作ります。今回は M5StickC Plus の出番はありません。純粋に Webサーバとブラウザのお話です。

スマートフォンをジョイスティックにみたて、スマートフォンの画面を指でなぞって、右へ左へ自在にラジコンカーを操縦したいと思います。どんな操作が便利でしょうか。

指をタップした点から前に滑らせると、ロボットカーが前進し、右に滑らせると、右に旋回し、左に滑らせると、左に旋回する、タップした点から滑らせた距離が大きいほど、速度が上がる、というのはどうでしょうか。

  1. スマートフォンの画面をタップする →ロボットカーは停止したまま

  2. まっすぐ前に滑らせる →距離に応じた速度で前進

  3. 右に滑らせる →右に旋回

  4. 左に滑らせる →左に旋回

  5. 指を画面から離す →停止

図1 スマートフォンの操作

目標

  • ジョイスティック風アプリは、Webアプリとします。

  • 最初の点から滑らせた指の位置に応じて、直進と旋回の値をリアルタイムに Webサーバへ送信します。

  • 値を受け取る疑似サイトを作ります。

  • PCのブラウザのデベロッパーツール、開発者ツールで、動作を確認します。

部品・機材

使用する機材は次のとおりです。電子部品と M5StickC Plus は不要です。

開発用機材

  • PC(Windows10 または 11)、Python 導入ずみ

  • スマートフォン(なくてもOK)

  • Wi-Fi ルータ

開発手順

  1. PC に Python が導入されていないときは、Microsoft Store から Python 3.xx をインストールする。Python の公式サイトからダウンロードしてもよい。

  2. PC のテキストエディタもしくはメモ帳で、index.html、drive.html を作成する。日本語の文字コードは UTF-8で保存してください。

  3. PC のテキストエディタもしくはメモ帳で、webserver.bat を作成する。

  4. index.html、drive.html、webserver.bat を同じフォルダに保存する。

  5. webserver.bat をダブルクリックして、実行する。

  6. ブラウザ(Chrome、Edge など)で http://localhost/index.html を開く。

  7. ブラウザのデベロッパーツール、開発者ツール[Ctrl+Shift+I]を選ぶ。対象デバイスをスマートフォンに切り替える。

  8. マウスを使用して、図1の操作を行う。

  9. デベロッパーツール、開発者ツールのネットワーク情報を確認する。

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">
    *** ブラウザがキャンバス &lt;canvas&gt; をサポートしていません。 ***
</canvas>
</div>

タッチインターフェース

図1 の操作を実現するためには、最初にタップした、指を滑らせた、指を離したの各タッチイベントを捕捉し、それぞれに応じた処理を行います。

  1. 最初にタップした→'touchstart' イベント処理 function touchStart(event)
    最初のタップ点(ノーマル位置)の座標(startX, startY)を記憶し、差分(dX, dY)を(0, 0)にリセットします。
    ノーマル位置に赤色の小さな円を描きます。

  2. 指を滑らせた→'touchmove' イベント処理 function touchMove(event)
    最初のタップと同じ指を滑らせた点(現在位置)の座標(cX, cY)を求め、差分(dX, dY)に(cX-startX, cY-startY)を記憶します。
    ジョイスティック領域を消去し、ノーマル位置に赤色の小さな円を描き、現在位置に水色の大きな円を描き、ノーマル位置と現在位置を灰色の直線で結びます。

  3. 指を離した→'touchend' イベント処理 function touchEnd(event)
    最初のタップと同じ指を離した時点で、差分(dX, dY)を(0, 0)にリセットします。
    ジョイスティック領域を消去し、指を離した点に青色の小さな円を描きます。

  4. 取り消し→'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">
        *** ブラウザがキャンバス &lt;canvas&gt; をサポートしていません。 ***
    </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 のようになります。うまく通信できていることが分かります。

図2 PCブラウザのデベロッパーツール
図3 Pythonによる簡易Webサーバのコマンドプロンプト

スマートフォンで確認する

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年度助成をいただき製作しました。

この記事が気に入ったらサポートをしてみませんか?