ばかおもちゃ本店:Youtube twitter:@sashimizakana Amazon.co.jpアソシエイト

2013年12月15日日曜日

AngularJSでゲームをつくる「艦隊くりっかー」

まえおき

これはAngularJS Startup AdventCalendar の15日目の記事です。
14日目:AngularJSとAngularJSモジュールでi18nを実現する(2) - Qiita [キータ]

本稿ではAngularJSでゲームを作ったときに気づいたこととかを元に、どんな感じでアプリケーションを構成するかをざっくり書きます。実際のゲームを作る上でのTipsとかってよりは、基礎的なディレクティブとかサービスの使い方中心ですが、ゲームの実例も多少書くのできっとどう分けるかを理解できるはず。で、それが把握できればゲームをどう作るかもわかるはず。
一応ある程度AngularJSの基礎用語くらいは聞いたことあるくらいの人対象です。
それのどこがスタートアップなんだとか気にしない。泣かない。
ちなみにAngularJSはゲームを作るのに適したフレームワークではないです。
もっとも数値を操作するだけのゲームならAngularJSでも十分です。
そういうわけでAngularJSやろうぜ!

自己紹介

ばかおもちゃ本店という名前でニコニコ動画に電子工作とかの動画をあげています。
全自動スカートめくり機とか、念力スカートめくり機とか、そういうのを作りました。人様に言いづらい。

そして本稿で説明する艦隊くりっかーという二次創作ではない(重要)ゲームを作りました。

艦隊くりっかー?

艦くり - Kantai Clicker

CookieClickerのクローンです。
本当は更に艦隊これくしょんの二次創作のつもりでしたが、ゲームは禁止されているので、
キャラクター要素を削って良くわからない艦隊これくしょん便乗商品のようなものになった。
数値を増やすために数値を増やすという超単純なゲームです。

(本文と関係のない画像です)

AngularJSアプリの大まかな構成

AngularJSのアプリケーションは、ざっくり以下の様な構成になる。

directive
ディレクティブ。HTML内に埋め込んで表示を制御する。値の実際の入出力を処理する。
配列渡されたらフォーマットしてリスト出したり、入力値を値にセットする。

controller
コントローラー。ng-controllerで呼ぶとそのタグの階層以下のscopeを管理できる。
scopeってのはAngularJSでビューと値をやりとりするときのオブジェクトのこと。

service
サービス。値を保存したり読み込んだり、それに伴う通信したり。
もちろん値への操作も入れちゃって構わない。いわゆるモデル。

filtter
フィルター。リストの内容を変えたり値を整形したりするディレクティブの補助的なもの。
今回使ってないのでもうこれっきり出てきません。

つまりAngularJSはHTMLに利用するコントローラーとか含めてdirectiveでテンプレートを書いて、
controllerがそのdirectiveとserviceの間の値のやり取りをscopeってのを使ってやる。
なるほど、かんたん。

各部の詳細とか

以下、詳細というか使ってる時に思ったこととか、注意点みたいなのをまとめる。

controller
AngularJSの公式のサンプルなんかを見て、モジュールを分けることを意識しないで書くとファットコントローラーになって手に負えない状況になりがち。見た目は全部ディレクティブ、値の操作はサービスでする。
ここで値を直接変更してたり、見た目に関連するフラグの変更とかやってるならたぶん間違ってる。
コントローラー間での値の受け渡しとかで悩んでたりするなら考え方が根本的に違う可能性が高い。

directive
雑に言うと、この中でならjQueryとか素で使ってもOK。
なのでjQueryの拡張とかをそのまま使うときもこの中に閉じ込める。
directiveのcompileの第一引数とかlinkの第二引数とかはディレクティブがつけてある要素そのものなのだけど、これはもともとjQueryオブジェクトの形で渡される。
ちなみにAngularJSよりjQueryを先に読み込んでないとjqLiteという機能縮小版になる。
(たしかfindとかなかったりremoveが無かったりして不便なときが多い)

余談だけど、linkのサンプルコードで第一引数のscopeに$マークがないのは、ここは名前で引数が決定されるわけじゃなくて、普通に順番で引数が決定するからだとか何とか。
ECMA3で機械的に挿入されたコードのみで$の利用が許可されるとか言ってるのと関係有るかも(ないかも)。


書き始めると終われないので飛ばしていくけど、directiveはscope周りが一番面倒なので注意深く勉強するといいかも。
親の値をそのまま変更可能だったり(scopeが未設定かfalse)、親の値が上書きのみ別の値になるか指定したもののみ上書きも可(scopeがtrueかオブジェクトで指定)とかある。
ちなみに普通の引数と同じくオブジェクトの場合は参照で渡されるので、falseでもオブジェクトのプロパティの変更のみは出来たりするので混乱の元になる。
自分の作ったディレクティブにng-hideとかを組み合わせて使うつもりが無いなら、スコープは常に作ったほうが事故ることが少ないと思う。
逆に言うと、親スコープ内で使うつもりなら、スコープをそもそも使わないか、親スコープと絶対にかぶらないような名前で利用しない限り危険。うっかり名前かぶったりするとミスに気付きづらくて泣く。

service
複数画面のアプリケーションの場合もserviceはシングルトンなので、データは共有される。
これ重要。
angular.('モジュール名',[依存モジュール...]).factory('ディレクティブ名',[利用サービス...,function(){
    return サービスのオブジェクト;
}]);
みたいな感じで作るわけだけど、もちろん、
var MyService = function(){
    this.initialize.apply(this,arguments);
}
MyService.prototype = {
    initialize:function()...
}
return MyService;
みたいにクラス渡して、コントローラー側で、
['$scope','MyService',function($scope,MyService){
    var svc = new MyService();
}];
てな感じで使ってもいい。
私はこういう感じのクラスにデータ取得するメソッドとキャッシュとか持たせて使ったりしてる。
var MyService = function(){
    this.initialize.apply(this,arguments);
}

MyService.prototype = {
    initialize:function()...
}

MyService.cache = {};

MyService.getData = function(id){
    if(this.cache[id]){
        //キャッシュにあればそれを返す
        return this.cache[id];
    }

    //なければバックエンドとかからデータ持ってくる
    var svc = new MyService(バックエンドから持ってきたデータ);
    this.cache[id] = svc;
    return svc;
}
return MyService;
でMyService.getData(4);みたいな感じで呼び出す。
factoryが呼ばれるのは最初の一回だけなので、複数画面とかならどの画面を最初に開いても、データが既に読み込まれてたらキャッシュを呼ぶし、そうでないならバックエンドからデータを持ってくることになる。
一覧と詳細のリストを持ってて、詳細を開くと追加データを読んでキャッシュするとかそういう使い方に便利。有効期限とかキャッシュの強制削除とかそういうのもいるかも。

小休止

何回、controller、directive、serviceって繰り返すんだよクソっ! って気になってきたかと思いますので本文とは特に関係ない挿絵を御覧ください(あと一回繰り返します)。

艦隊くりっかーでの全体の構成

敵のパラメーターと説明、施設のパラメーターと設定なんかはjsonに書いてある。

controller
データの保存とかロードをやってるだけ。
上記のjsonのデータを呼び出すserviceとかクッキーの入出力serviceとかの実行。でその値をscopeに入れるだけ。

directive
ディレクティブとして作ったのは、体力とか経験値のバーと船の部分、施設の部分。
今見直すと、施設の部分はディレクティブとして分ける必要性が薄いし、船の部分も含めてビジネスロジックまで書いててまずい。
体力とか経験値のバーは与えられた数値で要素の長さを変えてバーっぽく見せてるだけ。
船の数値のやつは、$rootScopeのイベントを見張って攻撃されたりクリックしたら渡された数値を表示するだけ。
この辺後述するけど、$watchをどこでも使うよりイベントのがいい感じの時も多い。

service
ディレクティブと同じく分離がうまく言ってない箇所があって構成が説明しづらい。
本来だとユーザー関連と敵関連と施設関連を分離してそれぞれがクッキーへの入出力サービスに依存してるような感じにすべきだったと思う。で、クッキーへの入出力モジュールがロード時にデータをキャッシュしたり、一定時間おきにデータの変更を確認して保存すれば良かった。
ちなみにAngularJSのクッキーモジュールはブラウザ閉じるまで以外の有効期限を設定できないので、
クッキーへの書き出しとか読み込みとかは自前で作った。

だいたい終わった

というあたりで大まかな構造はだいたい語れたので満足した。
つうかそんな複雑な構造してない。
あとその他開発中に思ったことを列挙。

・クッキーすげえ壊れる

クッキーは壊れる。ポッケに入れて膝に矢を受けたら真っ二つ。
結構長いデータをJSONにしてbase64にしただけっていう横着なやり方だからか結構壊れた報告を受けた。
受けたから、テキストで保存できるようにしたのだけど、根本的に直す方法は良く分からない。
確認した限りでは全部真っ白になるわけではなくて、後半データが切れるみたいな壊れ方が大半だったので、容量に余裕があるなら一つ前の状態も保存して、壊れてたら前のデータ参照みたいなやり方ならまだ被害は少ないかもしれない。

・イベント利用($emit,$on)のすすめ

普通AngularJS的には、各ディレクティブ内で見た目とかが変わるときは依存性として渡してあるモデルを$watchしてその状況に応じて変更するみたいなやりかたが常道っぽい。
ただこれだと、っていうかゲームのようなDOMの見た目変更が複数条件で決定されかつ複雑なときには、ディレクティブに片っ端からサービス渡したりすることになって現実的じゃない。
しかも今回は小規模なので問題になってないけど、私がやっちゃったみたいにディレクティブ内でサービスのデータ変えたりするともう目も当てられない。
どこで何が変わっているのか、どこに何が依存しているのかの把握がむずかしくなる。
でっかいオブジェクトなんかをまるごと$watchするのがあちこちにあるとこれもまた怖い。
そのへん、イベントは投げたところと受け取るところがはっきりするので、ある程度統制を取りやすい気がする。

自データサービスは敵に攻撃されたことをイベントで受け取って、引数の攻撃力に応じて自分の艦数を減らして、
もし0になったら敗北イベントを発生させる。
敵サービス側は攻撃状態にある間は一定時間おきに攻撃イベントを発生させて、プレイヤー側の攻撃、またはクリックのイベントを受けて自分の艦数を減らして、もし0になったら敗北イベントを発生させて攻撃状態を解除、あるいはプレイヤーの敗北イベントが発生しても攻撃状態を解除する。

というような感じ。

・ぐるぐる回る

そもそも上記のように$watchを多用してオブジェクトとかを監視しまくる構成になると、把握が難しいならまだしも最悪の場合はループするし、そうでなくても無駄な呼び出しが増える傾向にある。
これは大変に厄い。
最初のうち何も考えずに$watchを活用しまくって適当に書いていると、なんか知らないけどこのプロパティ変えただけで、関係ない処理が山ほど流れるなんてことになったことがあった。
filterなんかとくにそうなりやすい。console.logで出力してみると何故かちょこっと動かすたびにフィルタ動いてるみたいになる。
オブジェクトのプロパティも監視対象にできるのであんまり大きなオブジェクト全体を見張らないで、必要な部分だけにすべきなのだと思う。
(わざわざ第三引数にtrue渡さないとプロパティを含めたオブジェクト全体は見張らないし、もともとそういう想定なのだと思う)

まとめ

個人的な印象で言うと、アクションゲームみたいな動きの多いものでなければ、AngularJSでゲームを作るのは結構簡単で楽しいのではないかと思いました。$watch周りとか$scope周りはややこしいこともあって、混乱することもありますが、その辺をある程度整理したり、こういうことはすべきではないということが分かれば、本当に楽に書けて良いと思います。
時間と場が残っていればこのAdventCalendar内で、scopeあたりについても書ければいいなと思います。

宣伝

艦くりは今年年末から来年初くらいまでの間にアップデートを予定しています。
ちなみに私が作ったゲームやおもちゃその他や、それらの制作動画のお知らせはこのブログ(RSS)か@sashimizakanaに掲載しています。
君もばかおもちゃの最新情報をチェックだ!