読み書きプログラミング

日常のプログラミングで気づいたことを綴っています

Meteorで、Google StreetView上の「泥棒と巡査」


先日、慶応大学の増井教授(私が新入社員だった時の上司です^^;)がTwitterでこんなことをつぶやかれました。


「Meteorなら簡単にできる、多分予備実験含めて工数2時間ぐらいだろう、色々知っていれば」と思いました。で、挑戦した結果がこちらです。

http://sv-tag.meteor.com

コード的に特に人数制限はないのですが(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です。

#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..<プロパティ名>に、データベースをアクセスする関数を割り当てておくと、HTMLのテンプレートで定義されたDOMがリアクティブに更新されます。

サーバー

サーバー側で当たり判定(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のコネクションが切断されるという不具合があり、達成できませんでした。
今回は一定時間以上変更がない場合サーバー側で削除する方法を取りました。