ここでは、ヒーローズリーグオンライン2020に応募した「みんなでドライブ」に関する開発日記をまとめます。JavaScriptの初心者親子がかなり試行錯誤を繰り返し作成しました。自分たちへの備忘録と、これからJavaScriptを勉強する方の参考になればと思います。
『「みんなでドライブ」の紹介ページ』
https://protopedia.net/prototype/1936
『みんなでドライブ』
https://masaki.page/web/mindr/start.html
##「みんなでドライブ」とは
「みんなでドライブ」は、JavaScriptで作られた仮想ドライブサービスです。↓
See the Pen Drive together! by sunagimo (@sunagimo2) on CodePen.
画面右下のレバーを動かしてみてください。その方向に移動したかのように車窓が変わり、Map上の車アイコンも少しだけ移動したと思います。本サービスでは、車窓の出力はGoogle Street View、地図の表示にはGoogle MapのAPIを利用しています。
特徴は、本サービスにアクセスする全員で、仮想的な車の座標を共有していることです。誰かが車を動かすと、アクセスしている全員の車窓がリアルタイムに変わります。複数ブラウザで同時に開くことでもこの動作を確認できると思います。この、1台の車の座標を共有するという機能により、「みんなでドライブ」している状態となっています。
また、本記事の終盤で説明しますが、ニンテンドーラボのペダルやハンドルを使い、運転操作ができるようにもしています。
##サ-ビス設計
本サービスを作成する際に、データが各サーバ感をどのように流れるかを検討しました。ここでは、そのときに親子で検討したメモ画像を用いて説明します。汚くてすみません。
まず、今回のサービスするユーザとして、「運転手」と「同乗者」という2つの役割を定義しました。「運転手」は現在車を動かしている(緑のレバーを操作している)ユーザで、「同乗者」は運転はしていないが、サービスにアクセスしているユーザです。この役割は入れ替わることもあります。また、緑のレバーを操作している人が複数いる場合、最後に操作情報を更新した人が運転手と定義しました。
前述のとおり、緑のレバーを操作すると、その人は運転手となり、車を動かすことができるようになります。車の操作のデータはgoogleに送られ、そこで新たな車の座標が算出されます。その座標は座標管理サーバに送られ、同時に車から見た車窓をgoogleで生成します。これにより、運転手は自分の車窓を更新していきます。
同乗者は定期的に車の座標をサーバに問い合わせ、車の座標が変化したことを知ると、新しい座標から見た車窓をgoogle で生成し、自分のViewに反映します。この動作により、同乗者の車窓は、サービスにアクセスする運転手の操作により変化し続けることになります。
##サーバ構築
上記のサービスを実現するために、次のようなサーバ構築を行いました。
上の図は、このサービスに関わるサーバ構成を表しています。
参加者で共有する車の座標は、Microsoft Azure (①)上に構築されたMattermostのチャットデータとして管理されています。また、車の座標から車窓の画像を生成するために、Google Street View (②)のサービスを利用しています。 みんなでドライブのHTMLやJavascript(③)は、一般的なWebサーバ上に配置されているだけですので、ここでは説明を省略します。
###座標管理サーバ①の構築
車の座標管理には、Mattermostを利用しています。Mattermostは有名なチャットサービスですが、「応答が早く安定している」、「Web APIのドキュメントが充実している」という特徴があります。特に、本サービスでは、各ユーザから車の座標のやり取りが頻繁に発生することが予想できましたので、この「応答が早く安定している」という特徴を重視し、採用しました。
Azure では、はじめからMattermostを組み込んだVirtual Machineを簡単に生成でき、数クリックでサーバ構築が完了します。また、Virtual Machineの配置先を国内に設定すれば、かなりの反応速度が達成できます。価格は月に960円ぐらいで運用できています。
『Microsoft Azure』
https://azure.microsoft.com/ja-jp/
『Mattermost API Reference』
https://api.mattermost.com/
なお、後で説明する機能の実現のため、どうしても①のサーバをSSL化する必要がありました。その際の手順は次の記事が参考になりました。
『GCP無料枠を使ってたったの30分でOSS版Slack「Mattermost」を構築する【SSL化も対応】』
https://www.karelie.net/mattermost-gce-ssl/
みんなでドライブ側から車の座標データ等を書き込むため、Mattermost側でパーソナルアクセストークンを作成する必要があります。
この作業には、下記のサイトが役立ちました。
『Personal Access Tokens』
https://docs.mattermost.com/developer/personal-access-tokens.html
パーソナルアクセストークンがあれば、特定のチャットデータに外部サイトから読み書きすることができるようになります。例えば、読み込みは次のコードで実現可能です。
$.ajax({
url: [Mattermostが立ち上がっているURL] + '/api/v4/posts/' + [コメントのID],
type: 'PUT',
headers: {
'Authorization': 'Bearer ' + [アクセストークン],
},
data: JSON.stringify({
'id': [コメントのID],
'message': [書き込むコメント内容],
})
}).then(
);
###Google Street View サービス②の構築
車の車窓画像を生成したり、車の位置を地図上で表示するため、Google のマップサービスをいくつか利用登録しました。本来なら、Azureのサービスだけで構築できたら楽だったのですが、MicrosoftのStreetside は対応している国が少なかったため、Google サービスとの併用という形になりました。
まず、Google Cloud Platform のサイトから、必要なAPIを有効化し、APIキーを取得する必要があります。この作業には、下記の記事が大変参考になります。
『Google Maps API を使ってみた』
https://qiita.com/Haruka-Ogawa/items/997401a2edcd20e61037
取得したAPIキーをhtmlファイルのhead部に次のように記載すると、これらのAPIをJavascriptから利用することができるようになります。
<script
src="https://maps.googleapis.com/maps/api/js?key=[取得したAPIキー]&libraries=&v=weekly"
defer></script>
「みんなでドライブ」では、車窓の画像を生成するために「Maps JavaScript API」を利用しています。今回は、指定した緯度・経度のStreet View を表示するため、Dynamic Street View という使い方になります。この使い方では、1000アクセスあたり$14となります。また、後ほど説明する車内から記念撮影をする機能のために「Street View Static API」を利用しています。こちらは、1000アクセスあたり$7です。これらの料金は、本サービスの利用者が一度画面を更新する度にカウントされますので、利用回数の上限を設定していないと、思わぬ金額が請求されることになります。とはいえ、Google Cloud Platformでは月200ドルまでの無料枠があるため、1日に数百回程度の利用であれば金額の支払いは発生しません。従って、1日の利用上限値を500ロードに設定しました。
『Maps JavaScript API』
https://developers.google.com/maps/documentation/javascript/reference?hl=ja
『Street View Static API』
https://developers.google.com/maps/documentation/streetview/overview?hl=ja
##Javascriptコンテンツの作成
ここからは、Javascriptによる実装を中心に説明します。「みんなでドライブ」の動作フローは次の図のようになっています。
###初期画面の生成とユーザ操作の取り込み
初期画面は、次のようにオブジェクトを配置しています。
StreetViewPanoramaとMapは、GoogleのWeb APIを利用して生成し、それぞれを連携しておきます(次のコード)。これにより、車の位置が変わった場合には、地図上の車アイコンと車窓が連動するようになります。基本的に、これだけで、Webページ上の"map"と"pano"というidが付けられたcanvasに、Street Viewとmapが表示されるようになります。
map = new google.maps.Map(
document.getElementById("map"),
{
zoom: current_zoomlevel,
clickableIcons: false,
fullscreenControl: false,
mapTypeControl: false,
streetViewControl: false,
mapId: 'c7d246db6e1e7f77',
}
);
panorama = new google.maps.StreetViewPanorama(
document.getElementById("pano"),
{
linksControl: false,
panControl: false,
zoomControl: false,
addressControl: false,
clickToGo: false,
imageDateControl: false,
}
);
// map と panoramaを関連付ける
map.setStreetView(panorama);
また、車の操作には、「JoyStick」というJavascriptライブラリを利用させていただきました。
『JoyStick』
https://bobboteck.github.io/joy/joy.html
このJavascriptライブラリを利用すると、次のようなコードで、現在のレバーの操作情報を取得することができます。マウスでもタッチ操作でも利用でき、非常に軽いのでおすすめです。
if (Joy1.GetDir() == 'C') {
// レバーが倒されていない
}
else{
// レバーが倒された
// 倒された方向
let joy1X = Joy1.GetX();
let joy1Y = Joy1.GetY();
}
###同乗者フロー
次に、車の操作をしていないユーザである同乗者のフローを説明します。
同乗者フローでは、まずMattermostのWeb APIを用いて、サーバから現在の車の位置を取得します。次のコードのposition_id は、Mattermost上のメッセージIDであり、ある決められた値が入っています。AzureとMattermost組み合わせでは、一回の座標の取り込みに50msぐらいの応答時間となり、非常に高速です。
function get_position() {
let deferred = new $.Deferred();
// position_idに格納されたチャット文字列を取得
$.ajax({
url: masaki_ga_mattermost + '/api/v4/posts/' + position_id,
type: 'GET',
headers: {
'Authorization': 'Bearer ' + masaki_ga_Authorization,
},
data: JSON.stringify({
'post_id': position_id,
})
}).then(
function (result) {
// 取得した文字列から、新しい車の座標を取得
new_fenway.lat = result['message'].split(',')[0];
new_fenway.lng = result['message'].split(',')[1];
deferred.resolve();
},
function () {
deferred.resolve();
}
);
return deferred;
}
車の最新の座標を取得したあと、直前の車の座標から変化しているかを判定し、変化があった場合のみ車窓の変更を行います。
// 車の座標のx, yが変わっていたら
if (new_fenway.lat != fenway.lat || new_fenway.lng != fenway.lng) {
// 車の座標を更新
fenway.lat = new_fenway.lat;
fenway.lng = new_fenway.lng;
let lating = new google.maps.LatLng(fenway.lat, fenway.lng);
// 車窓を変更
panorama.setPosition(lating);
// 地図の中心をずらす
map.setCenter(lating);
// 地図上の車アイコンを移動
marker.setPosition(lating);
}
###運転手フロー
次に、車を動かしている運転手のフローを説明します。
まず、レバーが倒された方向を検知し、現在の車の座標から移動できる地点の中から、現在の向き、レバーの操作方向を考慮して最も適切な地点を探します。そして、その地点に移動します。これにより、車窓も連動して変わります。
let si; // レバーが倒された方向
// 向きを取得
if (joy1X >= 0) {
si = 90 - Math.atan(joy1Y / joy1X) * 180 / Math.PI;
}
else {
si = 270 - Math.atan(joy1Y / joy1X) * 180 / Math.PI;
}
// 現在の座標から、移動可能な方向を取得
let Links = panorama.getLinks();
// 現在向ている方向、レバーの倒された方向を考慮し、
// 最も適切な移動先を選択
let val = 360;
let target = 0;
let currentPov = panorama.getPov();
let idou_houkou = handle_hosei(si + currentPov.heading);
Links.forEach(function (element, index) {
if (idou_houkou - element.heading > 180) {
idou_houkou = idou_houkou - 360;
}
else if (idou_houkou - element.heading < -180) {
idou_houkou = idou_houkou + 360;
}
let ans = Math.abs(idou_houkou - element.heading);
if (val > ans) {
val = ans;
target = index;
}
});
// 実際に移動
panorama.setPano(Links[target]['pano']);
また、車の操作によって変更された車の座標は、Mattermostサーバに送られ、そこで管理されます。
//位置情報をサーバに登録
function post_position() {
$.ajax({
url: masaki_ga_mattermost + '/api/v4/posts/' + position_id,
type: 'PUT',
headers: {
'Authorization': 'Bearer ' + masaki_ga_Authorization,
},
data: JSON.stringify({
'id': position_id,
'message': fenway.lat.toString() + ',' + fenway.lng.toString(),
})
}).then(
function (result) {
},
function () {
}
);
}
以上が基本的なフローとなります。
なお、蛇足ですが、車の座標とユーザが書き込んだ最終コメントは、Mattermost上にテキストデータとして管理しています。運転手が車を動かしたり、誰かがコメントを書き込むと、このチャット内容が書き換わり、アクセスする全員に配信されることになります。
###撮影機能
「みんなでドライブ」では、実際のドライブのように、気に入った車窓で記念写真がとれるようになっています。現在の車窓データをStreet View Static API を利用して取得し、その上にランダムで選択されるフレームを描画します。さらに、日時データを右下に描画し、写真っぽく仕上げています。現在用意しているフレームは10種類です。
生成された写真画像は、Viewer.js を使って表示しています。
『Viewer.js』
https://fengyuanchen.github.io/viewerjs/
// 写真撮影
function take_picture() {
// 作業用のcanvasを取得
let board = document.getElementById("board");
board.width = pic_width;
board.height = pic_height;
let ctx = board.getContext("2d");
// 撮影時の車の位置、向きを取得
let lat = panorama.getPosition().lat();
let lng = panorama.getPosition().lng();
let head = panorama.getPov().heading;
let pitch = panorama.getPov().pitch;
// Street View Static API を利用して、画像を生成
const chara1 = new Image();
chara1.crossOrigin = "Anonymous";
let str = "https://maps.googleapis.com/maps/api/streetview?key=[XXXXアクセスID]&location="
+ lat + "," + lng + "&heading=" + head + "&pitch=" + pitch + "&size=" + pic_width + "x" + pic_height;
chara1.src = str;
chara1.onload = () => {
ctx.drawImage(chara1, 0, 0);
const chara2 = new Image();
chara2.crossOrigin = "Anonymous";
// 乱数を利用し、フレームを選択
let hakei_num = getRandomInt(haikei_max);
chara2.src = haikei_src[hakei_num];
chara2.onload = () => {
ctx.drawImage(chara2, 0, 0, pic_width, pic_height);
// 撮影日時を描画
const date1 = new Date();
const date2 = date1.getFullYear() + "/" +
('00' + (date1.getMonth() + 1)).slice(-2) + "/" +
('00' + date1.getDate()).slice(-2) + " " +
('00' + date1.getHours()).slice(-2) + ":" +
('00' + date1.getMinutes()).slice(-2);
ctx.font = "bold 24px serif";
ctx.fillStyle = '#fff';
ctx.fillText(date2, 410, 385, 200);
// canvasを画像に変換
let dataURI = board.toDataURL();
// Viewer.js で画像を描画
set_viewer(dataURI);
};
};
}
###コメント表示
車内で話をするように、同乗者全員にコメントを配信することができます。ここではコメントを受信し、地図上に表示する処理を説明します。
コメントも、車の座標同様に、定期的にサーバから取得します。この処理は基本的に車の座標を取得する処理と変わりません。
//コメントを取り出す
function get_comment() {
let deferred = new $.Deferred();
$.ajax({
url: masaki_ga_mattermost + '/api/v4/posts/' + comment_id,
type: 'GET',
headers: {
'Authorization': 'Bearer ' + masaki_ga_Authorization,
},
data: JSON.stringify({
'post_id': comment_id,
})
}).then(
function (result) {
new_comment = result['message'];
deferred.resolve();
},
function () {
deferred.resolve();
}
);
return deferred;
}
そして、新しいコメントが投稿されたことを検知すると、Google map のオブジェクトであるInfoWindowを生成し、その中に取得したコメント入れ、マップ上に表示します。InfoWindowは、コメントが出された座標上に表示されるようになっており、どの場所でどのようなコメントが出たかを、地図上で確認できるようになっています。なお、どのユーザがコメントを出したかはわからない仕組みになっています。
//コメントを表示
function update_comment(flag) {
let deferred = get_comment();
deferred.done(function () {
if (new_comment != comment) {
comment = new_comment;
let lating = new google.maps.LatLng(fenway.lat, fenway.lng);
// infowindowを作成し、中にコメントを入れて表示
let infowindow = new google.maps.InfoWindow();
infowindow.setContent(comment);
infowindow.setPosition(lating);
infowindow.setOptions(
{
pixelOffset: {
width: 0,
height: -40,
zIndex: comment_zindex,
},
}
);
infowindow.open(map);
comment_zindex++;
}
});
}
##ニンテンドーラボ連携
ニンテンドーラボのハンドル、ペダルを利用し、実際の車のように操作できるようにしています。
基本的なアイデアは、ニンテンドーラボ側でダンボール部品の各種操作に応じた音が出るようにし、ブラウザ側でその音を解析することで、各種操作を取り出しています。
音を介してニンテンドーラボの操作を取り出す部分に関しては、下記の記事を参考にさせて頂きました。
『ニンテンドーラボとスクラッチを連動させよう』
http://logiclab.blog.jp/archives/nintendolabo.html
###ニンテンドーラボ側の準備
車の操作は、ハンドルを回しながらペダルを押すといったように、複数の操作を同時に行う必要があります。このため、各種操作(ハンドルを回す、ペダルを押す)でそれぞれ異なる音を出力するようなプログラムをラボ側で作成します。混ざった音からも分離できるように、異なる音程の音を割り当てることが重要です。
そして、ニンテンドースイッチとPCを音声ケーブルで接続します。
ニンテンドーラボのペダルとハンドルにそれぞれジョイコンをセットします。このジョイコンが各パーツの傾きを検知することで、ハンドル操作、ペダル操作を検知します。
###Javascript側の準備
Javascriptでは、Web Audio API を用い、ニンテンドーラボから取り込んだ音声を解析し、各種操作情報を取り出します。Web Audio API による音声解析に関しては、次の記事を参考にさせて頂いてます。かなり複雑な処理のため説明を省略しますが、これを利用することで、どの音がどれだけ大きいかを検出することができます。
『getUserMediaで音声を拾いリアルタイムで波形を出力する』
https://qiita.com/mhagita/items/6c7d73932d9a207eb94d
現時点の「みんなでドライブ」では、次のように音声の解析を行っています。コード中の「★注意1」~「★注意3」の部分が、ニンテンドーラボで設定した音程によって影響を受けるところです。異なる音を設定した場合は、この数値も変えてください。
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
let localMediaStream = null;
let localScriptProcessor = null;
let audioContext = null;
let bufferSize = 1024;
let audioData = []; // 録音データ
let audioAnalyser = null;
let canvas = document.getElementById('sound_graph');
let canvasContext = canvas.getContext('2d');
// 録音中に連続して呼び出される関数
let onAudioProcess = function (e)
{
// 音声を取り込み、バッファに格納
let input = e.inputBuffer.getChannelData(0);
let bufferData = new Float32Array(bufferSize);
for (let i = 0; i < bufferSize; i++) {
bufferData[i] = input[i];
}
audioData.push(bufferData);
// 取り込んだ音声を解析
analyseVoice();
};
// 音声の解析
let analyseVoice = function ()
{
let fsDivN = audioContext.sampleRate / audioAnalyser.fftSize;
let spectrums = new Uint8Array(audioAnalyser.frequencyBinCount);
audioAnalyser.getByteFrequencyData(spectrums);
// ペダルの押下量を取得
let ped = Math.log(spectrums[45] + 1); // ★注意1
// ハンドルの回転量(右回転、左回転)を取得
let han1 = Math.log(spectrums[25] + 1); // ★注意2
let han2 = Math.log(spectrums[28] + 1); // ★注意3
: それぞれの操作に応じた車の操作(以後省略)
}
// 録音開始
let startRecording = function ()
{
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
catch (error) {
ons.notification.alert("音声取り込みに失敗しました。", { title: "みんなでドライブ" });
return;
}
try {
navigator.mediaDevices.getUserMedia({ audio: true })
.then((stream) => {
// 録音関連の設定
localMediaStream = stream;
let scriptProcessor = audioContext.createScriptProcessor(bufferSize, 1, 1);
localScriptProcessor = scriptProcessor;
let mediastreamsource = audioContext.createMediaStreamSource(stream);
mediastreamsource.connect(scriptProcessor);
scriptProcessor.onaudioprocess = onAudioProcess; // コールバックの登録
scriptProcessor.connect(audioContext.createMediaStreamDestination());
// 音声解析関連の設定
audioAnalyser = audioContext.createAnalyser();
audioAnalyser.fftSize = 2048;
mediastreamsource.connect(audioAnalyser);
})
.catch((err) => {
ons.notification.alert("音声取り込みに失敗しました。", { title: "みんなでドライブ" });
delete audioContext;
audioContext = null;
return;
});
}
catch (error) {
ons.notification.alert("音声取り込みに失敗しました。", { title: "みんなでドライブ" });
delete audioContext;
audioContext = null;
return;
}
};
// 録音終了
let endRecording = function ()
{
audioContext.close();
delete audioContext;
audioContext = null;
};
なお、ニンテンドーラボの操作に応じて、画面上のハンドルやタコメータが動くようにしています。また、取り込んだ音声(周波数ごとの強さ)を波形として表示するようにしています。
これらの部品は次の部品を利用させてもらっています。
『gauge.js』
https://bernii.github.io/gauge.js/
これにより、実際の車のようにハンドルとペダルを使い、本サービスを利用できるようになっています。
##まとめ
ここでは、Google Street View上をドライブすることができる「みんなでドライブ」の仕組みを説明しました。HTML、Javascriptの進化の速度は凄まじく、既存のライブラリを組み合わせることで、面白いサービスが簡単にできるようになっていることを、あらためて認識しました。