先日、慶応大学の増井教授(私が新入社員だった時の上司です^^;)がTwitterでこんなことをつぶやかれました。
GoogleStreetView上で鬼ごっこするシステムある?
2012-09-09 12:28:22 via web
「Meteorなら簡単にできる、多分予備実験含めて工数2時間ぐらいだろう、色々知っていれば」と思いました。で、挑戦した結果がこちらです。
コード的に特に人数制限はないのですが(JavaScriptの配列の最大?)、一体何人ぐらいで遊んでも動くもんなんでしょうね。
前からMeteorを触ってはみたかったのですが、あのリアクティブな環境を使うシーンが浮かばなくて、以前にドキュメントの触りを翻訳しただけで、実際のコーディングまで進めませんでした。そこにいいお題が降って来たのでこれ幸いと挑戦しました。
何分知らないことが多くて本来なら常套句のような部分もあれこれ思案した結果、2日掛かってしまいました。でも、簡単にできると思った直感(当たり前でしたか?)が正しいことを証明できて嬉しかった。
コード
JavaScriptの部分はすべてCoffeeScriptで記述しました。meteor coffeescriptを実行すると、CoffeeScriptファイルは自動コンパイルしてくれるようになります。以下のコードス二ペットではソースコード内のコメントは割愛しました。
共通
プレーヤの状態を管理するための、分散リアクティブなデータの定義します。
- collections.coffee
Players = new Meteor.Collection 'players'
この一行を複数のクライアント、サーバーで実行すると、同期が取れたデータベースの利用が可能になります。Collectionはデータベースのテーブルのようなものです。MongoDBを使っているのでスキーマがなく、JavaScriptオブジェクトを好きに入れていけます。
共用の関数として、緯度経度から2点間の距離を求める関数を用意しました。
- functions.coffee
distance = (lat1, lng1, lat2, lng2) -> latAve = degree2radian (lat1 + lat2) / 2 latDiff = degree2radian lat1 - lat2 lngDiff = degree2radian lng1 - lng2 w2 = 1 - eccentricity2 * Math.pow(Math.sin(latAve), 2) meridianRadius = coeffForMeridian / Math.pow(w2, 3/2) primeVerticalRadius = majorRadius / Math.sqrt(w2) x = meridianRadius * latDiff y = primeVerticalRadius * Math.cos(latAve) * lngDiff Math.sqrt(Math.pow(x,2) + Math.pow(y,2)) # auxiliaries degree2radian = (degree) -> degree * Math.PI / 180 oblateness = 1 / 298.257222101 majorRadius = 6378137 # m coeffForMeridian = majorRadius * Math.pow(1 - oblateness, 2) eccentricity2 = 2 * oblateness - Math.pow(oblateness, 2) # eccentricity ^ 2
クライアント
表示を担当するhtmlファイルです。
- sv-tag.html
<head> <title>Escapers and Police on Google Street View by Meteor</title> <script src="http://maps.google.com/maps/api/js?sensor=false"></script> </head> <body> <div id="demo"> {{> num_of_players}} <table> <tr> <td><label for="name">Name: </label></td> <td><input id="name" type="text" name="name" size="20"></input></td> </tr> <tr> <td><label for="role">Role: </label></td> <td><input type="radio" name="role" value="escaper" checked></input>escaper<input id="tagger" type="radio" name="role" value="tagger"></input>police</td> </tr> </table> <div id="map_canvas"></div> </div> </body> <template name="num_of_players"> <p>There are {{count}} players now.</p> </template>
templateを使うと、リアクティブなDOMが用意できます。クライアントのCoffeeScriptに対応する簡単なコードが必要になります。
あとはGoogle Maps APIを使うときの極普通のHTMLです。
- sv-tag.css
#demo { border: solid 1px black; padding: 20px; } #map_canvas { margin: 10px; width: 600px; height: 400px; }
CSSは特別なことはまったくなし。
一番長いコードがクライアントのCoffeeScriptです。
Playersコレクションに変更があると、その情報をGoogleマップ上のマーカに反映します。Googleマップ上のマーカは、マップのデフォルトストリートビューでも同期して反映されるので、なんの工夫も必要なくストリートビュー内にプレイヤをマーカとして表示することができました。(表示をもう少し工夫したいですね。)
Googleマップ上をクリックすると、プレイヤ情報を登録し、ストリートビューに移ります。
ストリートビュー上を移動した時、プレイヤの位置情報を更新します。
Template関連の定義をします。
- client.coffee
CENTER_OF_MAP = {lat: 35.681457, lng: 139.766178} # Tokyo station map = null sv = null markers = {} selfId = null score = 0 startupGoogleMaps = -> map = new google.maps.Map document.getElementById('map_canvas'), zoom: 1 center: new google.maps.LatLng CENTER_OF_MAP.lat, CENTER_OF_MAP.lng mapTypeId: google.maps.MapTypeId.SATELLITE sv = map.getStreetView() google.maps.event.addListener map, 'click', mapClickHandler # When a player moves, the correspondent collection is udpated. google.maps.event.addListener sv, 'position_changed', moveHandler mapClickHandler = (event) -> if selfId is null name = document.getElementById('name') if name.value is '' alert 'Put your name!' return new google.maps.StreetViewService().getPanoramaByLocation event.latLng, 49, getLocationHandler # 49 (less than 50) is a Google's magic number to get nearest location. else sv.setVisible(true) getLocationHandler = (data, status) -> switch status when google.maps.StreetViewStatus.OK selfId = Players.insert name: name.value tagger: document.getElementById('tagger').checked lat: data.location.latLng.lat() lng: data.location.latLng.lng() score: 0 updated_at: new Date().getTime() sv.setPosition data.location.latLng sv.setPov heading: 0 pitch: 0 zoom: 1 sv.setVisible true when google.maps.StreetViewStatus.ZERO_RESULTS alert "Please click near street." else alert "Sorry, unknown error. Try again." moveHandler = -> position = sv.getPosition() Players.update selfId, $set: lat: position.lat() lng: position.lng() updated_at: new Date().getTime() startupMeteorRelated = -> taggerImage = new google.maps.MarkerImage('http://maps.google.co.jp/mapfiles/ms/icons/police.png') taggerShadow = new google.maps.MarkerImage('http://maps.google.co.jp/mapfiles/ms/icons/police.shadow.png', new google.maps.Size(59, 32), new google.maps.Point(0, 0), new google.maps.Point(16, 31)) escaperImage = new google.maps.MarkerImage('http://maps.google.com/mapfiles/ms/micons/man.png') escaperShadow = new google.maps.MarkerImage('http://maps.google.com/mapfiles/ms/micons/man.shadow.png', new google.maps.Size(59, 32), new google.maps.Point(0, 0), new google.maps.Point(16, 31)) Players.find({}).observe added: (player) -> markers[player._id] = new google.maps.Marker title: player.name position: new google.maps.LatLng player.lat, player.lng map: map icon: if player.tagger then taggerImage else escaperImage shadow: if player.tagger then taggerShadow else escaperShadow changed: (player) -> markers[player._id].setPosition new google.maps.LatLng player.lat, player.lng if selfId is player._id and player.score > score score = player.score alert 'You got it!' removed: (player) -> markers[player._id].setMap null delete markers[player._id] if player._id is selfId selfId = null alert (if player.tagger then '' else 'Captured or ') + 'time over. Join again!' sv.setVisible(false) window.onload = -> isTagger = Math.random() < 0.5 document.getElementById('name').value = (if isTagger then 'police' else 'escaper') + Math.floor(Math.random()*1000) document.getElementsByName('role')[if isTagger then 1 else 0].checked = true Template.num_of_players.count = -> Players.find({}).count() Meteor.startup -> startupGoogleMaps() startupMeteorRelated()
Players.find(condition).observeでディパッチ用の関数群を登録すると、conditionを満たすドキュメントが更新される度に対応するディスパッチが呼び出されます。
Template.
サーバー
サーバー側で当たり判定(taggerがescaperを捕まえたかどうか)を行っています。
また、ブラウザ側でページをアンロードした時に、プレイヤの情報を削除する方法が見当たらなかったので、サーバー側で1分毎に静止しているプレイヤを削除するようにしています。
- server.coffee
interval = 1*60*1000 # ms. Time for player to survive still. capturing_distance = 5 # m. Meteor.startup -> # Capturing process # If a tagger moves within capturing_distance around some escaper, the tagger captures the escaper. Players.find({tagger: true}).observe changed: (tagger) -> Players.find({tagger: false}).forEach (escaper) -> if distance(escaper.lat, escaper.lng, tagger.lat, tagger.lng) < capturing_distance Players.update tagger._id, {$inc: {score: 1}} Players.remove escaper._id # Process to terminate a play # This is a work around against a Meteor's bug(https://github.com/meteor/meteor/issues/170). # I wanted to remove a player document when her browser unloads the page, but it can't due to the above bug. # So I decided to remove player documents which stand for more than "interval" variable. Meteor.setInterval (-> Players.remove {updated_at: {$lt: new Date().getTime() - interval}}), interval
備忘録
ローディング、実行の順番
Meteor用のjsはheadタグ内のscriptよりも先に実行されます。headタグの先頭にMeteor関連scriptタグが追加されるからです。なので、headタグ内のscriptを前提にしてはダメです。前提にするためには、Meteor.startupに渡す関数内で処理を行います。startupに渡した関数はwindow.onloadより先に実行されます。
リアクティブなコード
暗黙のリアクティブコードは、Meteor.render/renderListで生成されたDOMとMeteor.autosubscribeに渡した関数とTemplate関連のみです。それ以外で必要な場合には明示的に書きます。
データベース内のドキュメントの更新に応じて処理する場合は、対象となるCursorを取得して、cursor.observeでイベントのディスパッチ関数群を渡します。
暗黙のリアクティブコードを増やしたい場合には、Meteor.deps関連を使って設計します。
後処理
クライアント側でページから抜ける直前にデータベースを変更したい(特定のドキュメントを削除したい)ことがありますが、現状、window.onbeforeunloadでMeteorのコネクションが切断されるという不具合があり、達成できませんでした。
今回は一定時間以上変更がない場合サーバー側で削除する方法を取りました。