前回「Obnizで戦車ラジコンを作ろう」で戦車ラジコンを作ったのですが、思いのほか面白かったので、拡張してみました。
M5Cameraをつなげて、戦車から撮影するのですが、戦車ラジコンで徘徊している最中にQRコードを見つけて、大砲を発射(ボタンを押下)すると得点がもらえる、というものです。
時間制限を付けたので、両指でキャタピラをなんとか操作しつつ、時間内にたくさんのQRコードを破壊すれば高得点です。
ということで、前回の記事ではM5Cameraは必須としていませんでしたが、今回は必須です。
毎度の通り、GitHubに上げておきました。
https://github.com/poruruba/obniz_motor
以下からもページを参照できます。(Obnizにつながないと使えないですが)。
https://poruruba.github.io/obniz_motor/
※M5Cameraがhttp接続なので、https上にある上記ページからは解像度の変更ができないようです。
仕組み
1. HTML Imageエレメントに、M5Cameraの画像を取得します。
2. 画像取得が完了すると、イベントが発生します。
3. HTMLページ上に配置したcanvasに取得した画像を描画します。
4. それと同時に、取得した画像にQRコードが含まれるか走査します。
5. QRコードが含まれていたら、QRコード部分を青四角で囲います。
この状態(QRコードが青四角で囲われている状態)で、大砲を発射(攻撃ボタンを押下)すると、QRコードを破壊したことになります。
画面
Obniz接続前の画面です。
Obnizに接続後、戦闘開始前の画面です。
戦闘中の画面です。
残りの秒数、残りの弾数が表示されています。ちょうど、青四角で囲われているQRコードを発見しているところです。ここで「攻撃!!」ボタンを押下すると、破壊できます。
戦闘終了後の画面です。
※ちなみに、見つけたQRコードが.mp3ファイルのURLの場合は、その音声を再生したのち、誤射とみなしちゃってます。要は減点対象です。
ソースコード
ソースコードを示した後で、説明を付記します。
まずは、Javascriptです。
'use strict';
//var vConsole = new VConsole();
let obniz;
var motor_right;
var motor_left;
var power_left_sign;
var power_right_sign;
var camera_image;
const COOKIE_EXPIRE = 365;
const POWER_MARGIN = 10;
const POWER_MAX = 40;
const TIMER_COUNT = 60.0;
const SHELL_COUNT = 10;
var vue_options = {
el: "#top",
data: {
progress_title: '',
obniz_id: '',
obniz_connected: false,
power_left: 0,
power_right: 0,
power_max: POWER_MAX + POWER_MARGIN,
power_min: -(POWER_MAX + POWER_MARGIN),
camera_url: 'http://192.168.1.248:81/stream',
qrcode_context: null,
qrcode_canvas: null,
qrcode_list: [],
qrcode: '',
counter: 0.0,
num_of_fail: 0,
num_of_total: 0,
shells: 0,
lockon: false,
},
computed: {
},
methods: {
battle_fire: function(){
console.log('fire');
if( this.shells <= 0 )
return;
this.shells--;
var fire = $('#snd_fire')[0];
fire.pause();
fire.currentTime = 0;
fire.play();
if( this.lockon ){
if( this.qrcode.endsWith('.mp3') ){
console.log("fail");
this.num_of_fail++;
setTimeout(() => {
var audioElem = new Audio();
audioElem.src = this.qrcode;
audioElem.play();
}, 500 );
return;
}
this.qrcode_list.push(this.qrcode);
setTimeout(() => {
var bomb = $('#snd_bomb')[0];
bomb.pause();
bomb.currentTime = 0;
bomb.play();
}, 700 );
}
},
battle_start: function(){
this.counter = TIMER_COUNT;
this.shells = SHELL_COUNT;
this.num_of_fail = 0;
this.qrcode_list = [];
this.lockon = false;
this.qrcode = null;
this.timer = setInterval(() =>{
this.counter -= 0.1;
if( this.counter <= 0.0){
clearInterval(this.timer);
this.counter = 0.0;
this.motor_reset();
this.num_of_total = this.qrcode_list.length - this.num_of_fail * 3;
if( this.num_of_total < 0)
this.num_of_total = 0;
// alert('終了ーっ!!');
this.dialog_open('#result_dialog', true);
}
}, 100);
},
camera_draw() {
if(this.qrcode_canvas == null ){
// this.qrcode_canvas = document.createElement('canvas');
this.qrcode_canvas = $('#camera_canvas')[0];
this.qrcode_canvas.width = camera_image.width;
this.qrcode_canvas.height = camera_image.height;
this.qrcode_context = this.qrcode_canvas.getContext('2d');
this.qrcode_context.strokeStyle = "blue";
this.qrcode_context.lineWidth = 3;
}
this.qrcode_context.drawImage(camera_image, 0, 0, this.qrcode_canvas.width, this.qrcode_canvas.height);
if( this.counter > 0.0 ){
const imageData = this.qrcode_context.getImageData(0, 0, this.qrcode_canvas.width, this.qrcode_canvas.height);
const code = jsQR(imageData.data, this.qrcode_canvas.width, this.qrcode_canvas.height);
if( code && code.data != "" ){
console.log(code);
if( this.qrcode_list.indexOf(code.data) < 0){
this.qrcode = code.data;
this.lockon = true;
var pos = code.location;
this.qrcode_context.beginPath();
this.qrcode_context.moveTo(pos.topLeftCorner.x, pos.topLeftCorner.y);
this.qrcode_context.lineTo(pos.topRightCorner.x, pos.topRightCorner.y);
this.qrcode_context.lineTo(pos.bottomRightCorner.x, pos.bottomRightCorner.y);
this.qrcode_context.lineTo(pos.bottomLeftCorner.x, pos.bottomLeftCorner.y);
this.qrcode_context.lineTo(pos.topLeftCorner.x, pos.topLeftCorner.y);
this.qrcode_context.stroke();
}else{
this.lockon = false;
this.qrcode = null;
}
}else{
this.lockon = false;
this.qrcode = null;
}
}
requestAnimationFrame(this.camera_draw);
},
obniz_connect: function(){
obniz = new Obniz(this.obniz_id);
this.progress_open('接続試行中です。', true);
obniz.onconnect = async () => {
this.progress_close();
Cookies.set('obniz_id', this.obniz_id, { expires: COOKIE_EXPIRE });
this.obniz_connected = true;
camera_image = new Image();
camera_image.crossOrigin = "Anonymous";
camera_image.addEventListener("load", this.camera_draw, false);
camera_image.src = this.camera_url;
motor_left = obniz.wired("DCMotor", {forward:0, back:1});
motor_right = obniz.wired("DCMotor", {forward:2, back:3});
this.motor_reset();
}
},
motor_reset: function(){
if( this.obniz_connected ){
motor_right.power(0);
motor_right.move(true);
motor_left.power(0);
motor_left.move(true);
}
power_right_sign = 0;
this.power_right = 0;
power_left_sign = 0;
this.power_left = 0;
},
motor_change_right: function(){
if( !this.obniz_connected ){
this.power_right = 0;
return;
}
var sign = Math.sign(this.power_right);
var power = Math.abs(this.power_right);
if( power <= POWER_MARGIN )
power = 0;
else
power -= POWER_MARGIN;
motor_right.power(power);
if( sign != power_right_sign ){
motor_right.move(sign >= 0);
power_right_sign = sign;
}
},
motor_change_left: function(){
if( !this.obniz_connected ){
this.power_left = 0;
return;
}
var sign = Math.sign(this.power_left);
var power = Math.abs(this.power_left);
if( power <= POWER_MARGIN )
power = 0;
else
power -= POWER_MARGIN;
motor_left.power(power);
if( sign != power_left_sign ){
motor_left.move(sign >= 0);
power_left_sign = sign;
}
},
motor_end_right: function(){
if( !this.obniz_connected ){
this.power_right = 0;
return;
}
motor_right.power(0);
motor_right.move(true);
power_right_sign = 0;
this.power_right = 0;
},
motor_end_left: function(){
if( !this.obniz_connected ){
this.power_left = 0;
return;
}
motor_left.power(0);
motor_left.move(true);
power_left_sign = 0;
this.power_left = 0;
},
},
created: function(){
},
mounted: function(){
proc_load();
this.obniz_id = Cookies.get('obniz_id');
}
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );
HTMLです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<!-- Optional theme -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
<!-- Latest compiled and minified JavaScript -->
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<title>Obnizラジコン</title>
<script src="js/methods_utils.js"></script>
<script src="js/vue_utils.js"></script>
<script src="dist/js/vconsole.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/obniz/obniz.js"></script>
<link rel="stylesheet" href="./css/index.css" />
<script src="dist/js/jsQR.js"></script>
</head>
<body>
<div id="top" class="container-fluid">
<div class="form-inline" v-if="!obniz_connected" >
<h1>Obnizラジコン</h1>
<button class="btn btn-default btn-sm" v-on:click="obniz_connect">接続</button>
<label>obniz id</label>
<input type="text" class="form-control" v-model="obniz_id">
<label>camera url</label>
<input type="text" class="form-control" v-model="camera_url">
</div>
<div class="controller">
<div class="control" style="float: left;">
<center><label>Left</label> {{power_left}}</center>
<input id="motor_left" type="range" v-bind:min="power_min" v-bind:max="power_max" v-model.number="power_left" v-on:input="motor_change_left()" v-on:touchend="motor_end_left()"><br>
</div>
<div class="control" style="float: right;">
<center><label>Right</label> {{power_right}}</center>
<input id="motor_right" type="range" v-bind:min="power_min" v-bind:max="power_max" v-model.number="power_right" v-on:input="motor_change_right()" v-on:touchend="motor_end_right()"><br>
</div>
</div>
<!--
<img v-show="obniz_connected" style="margin:0 auto;" class="img-responsive" id="camera_image" crossorigin="anonymous" v-on:load="camera_draw">
-->
<canvas v-show="obniz_connected" style="margin:0 auto;" class="img-responsive" id="camera_canvas"></canvas>
<div v-if="obniz_connected">
<button v-if="counter==0.0" class="btn btn-primary btn-lg center-block" v-on:click="battle_start">戦闘開始</button>
<button v-if="counter>0.0" class="btn btn-default btn-lg center-block" v-on:click="battle_fire">攻撃!!</button>
<div v-if="counter>0.0" class="text-center"><h2><small>残り</small> {{counter.toFixed(1)}} <small>s (弾数:{{shells}})</small></h2></div>
</div>
<div class="modal fade" id="result_dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
戦闘結果
<span class="pull-right">
<button class="btn btn-default btn-sm" v-on:click="dialog_close('#result_dialog')">閉じる</button>
</span>
</div>
<div class="modal-body">
<center>
<h3>敵:{{qrcode_list.length}} 誤:{{num_of_fail}}</h3>
<h1>今回の戦績: {{num_of_total}} <small>点</small></h1>
<br>
</center>
</div>
</div>
</div>
</div>
<div class="modal fade" id="progress">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{progress_title}}</h4>
</div>
<div class="modal-body">
<center><progress max="100" /></center>
</div>
</div>
</div>
</div>
</div>
<audio src="./raw/bomb.mp3" id="snd_bomb" preload></audio>
<audio src="./raw/fire.mp3" id="snd_fire" preload></audio>
<script src="js/start.js"></script>
</body>
</html>
その他、細かなソースはGitHubを参照してください。
解説
HTML ImageエレメントにM5Cameraの画像を取得
以下の部分です。
camera_image = new Image();
camera_image.crossOrigin = "Anonymous";
camera_image.addEventListener("load", this.camera_draw, false);
camera_image.src = this.camera_url;
HTMLページ上にimgエレメントを配置せずに、newしているのは、実際にHTMLページに表示するのは取得した画像ではなく、QRコードを青四角で囲った画像を描画したかったためです。
crossOriginにAnonymousを設定しています。M5Cameraの画像の取得元と、本ページのドメインが異なるためです。M5Cameraの画像には、Access-Control-Allow-Originを”*”としてくれているおかげで、世に言う汚染から免れます。
srcにURLを設定した瞬間から、画像の取得が完了したときにaddEventListenerに登録した関数が呼ばれます。
画像取得の完了イベント
以下で指定した関数です。
camera_image.addEventListener("load", this.camera_draw, false);
さらに、関数の中で以下を呼んでいます。
requestAnimationFrame(this.camera_draw);
これは、次のフレームでまた呼ばれるようにするためです。
M5Cameraで取得される画像は、 multipart/x-mixed-replace と呼ばれる連続した画像データです。画像が次から次へと受信されます。
canvasに取得した画像を描画
以下の部分です。
this.qrcode_context.drawImage(camera_image, 0, 0, this.qrcode_canvas.width, this.qrcode_canvas.height);
取得した画像にQRコードが含まれるか走査
以下の部分です。
const imageData = this.qrcode_context.getImageData(0, 0, this.qrcode_canvas.width, this.qrcode_canvas.height);
const code = jsQR(imageData.data, this.qrcode_canvas.width, this.qrcode_canvas.height);
if( code && code.data != "" ){
QRコードの操作には、以下のライブラリを使わせていただきました。
cozmo/jsQR
https://github.com/cozmo/jsQR
QRコード部分を青四角で囲う
以下の部分です。
var pos = code.location;
this.qrcode_context.beginPath();
this.qrcode_context.moveTo(pos.topLeftCorner.x, pos.topLeftCorner.y);
this.qrcode_context.lineTo(pos.topRightCorner.x, pos.topRightCorner.y);
this.qrcode_context.lineTo(pos.bottomRightCorner.x, pos.bottomRightCorner.y);
this.qrcode_context.lineTo(pos.bottomLeftCorner.x, pos.bottomLeftCorner.y);
this.qrcode_context.lineTo(pos.topLeftCorner.x, pos.topLeftCorner.y);
this.qrcode_context.stroke();
大砲発射
以下の関数です。
battle_fire: function(){
補足
コントローラを表示するページがHTTPSの場合、M5Cameraの画像もHTTPSでないといけないようです。
混在コンテンツ - Security | MDN
以上