2つの描画方法
まずはngCoreの描画について見ていきましょう。
ngCoreでは主に2つの方法で画面を描画できます。1つ目はUIパッケージを使う方法、2つ目はGL2パッケージを使う方法です。UIパッケージはボタンやスクロール画面などのUIパーツを簡単に描画できます。また、GL2パッケージはOpenGL ESを利用しているので、従来のWebベースのアプリケーションが苦手としていた高速な描画を実現できます。
UIパッケージによる描画
UIパッケージに用意されている各種パーツはUI.Window.documentにaddChild()することで画面に描画されます。W3CのDOMに少し似ています。
ここでは、ボタンを描画するコード(LIST1)を解説しながら、UIパッケージについて説明していきます。
1: var UI = require('../NGCore/Client/UI').UI; 2: var LocalGameList = require('../NGCore/Client/Core/LocalGameList').LocalGameList; 3: 4: function main() { 5: var y = UI.Window.outerHeight - 30; 6: var button = new UI.Button({ 7: frame: [10, y, 80, 20], 8: backgroundColor: "88000000", 9: normalText: " Reload ", 10: pressedText: "*Reload*", 11: textSize: 12, 12: textColor: "FFFFFFFF", 13: textGravity: [0.5, 0.5], 14: touchable: true, 15: onClick: function() { 16: LocalGameList.restartGame(); 17: } 18: }); 19: UI.Window.document.addChild(button); 20: }
連載第1回の「スマートフォン向けゲームエンジンngCoreとは何か?」で説明したように、SDKに付属している開発用のサーバーを起動させ、Webブラウザでアクセスすると図1のように表示されます。画面の下の方に「Reload」と書かれた四角いボタンが表示されれば成功です。このボタンを押すとアプリが再起動します。
もちろん、iOSやAndroidの実機やシュミレーター上で実行しても同じように表示されます。iPhone Simulatorで実行すると図2のようになります。
図3はAndroid実機(Nexus S)で起動して、Eclipseを用いてスクリーンショットを取ったものです。
ngCoreによるゲーム開発ではコンパイル作業が必要ありません。コードを修正したらアプリを再起動するだけで修正を即座に反映させて結果を確認できます。Webブラウザの場合はブラウザのリロード機能を利用することで修正を反映させることができます。
コード解説
ステップごとに丁寧にコードを解説していきます。
- (LIST1の1~2行目)require関数でUIパッケージやLocalGameListクラスの読み込みをしています。LocalGameListクラスはゲームを再起動させるために呼び出しています。また、このrequire関数はngCore独自の関数です。
- (LIST1の4行目)処理の起点となるmain関数の定義をしています。
- (LIST1の5行目)ボタン表示位置を決めるため画面の高さを取得しています。UI.Window.outerHeightで端末画面の高さを取得できます。iPhone4だと960、Nexus SやGALAXY Sだと800になります。単位はピクセルです。
-
(LIST1の6~18行目)UI.Buttonオブジェクトを生成しています。コンストラクタでプロパティを設定していますが、各プロパティが描画や挙動にどのように作用するかは表1を参照してください。
表1:UI.Buttonのプロパティ
プロパティ 説明 frame UIオブジェクトの表示位置とサイズを指定します。[表示位置X,表示位置Y,幅,高さ]となります。 backgroundColor 背景色をARGB値で指定します。 normalText 通常時に表示されるテキストを指定します。 pressedText ボタンが押された時に表示されるテキストを指定します。 textSize テキストサイズを指定します。 textColor テキストカラーをARGB値で指定します。 textGravity テキストの水平配置、垂直位置を指定します。[0,0]とすると左寄せ、上寄せ、[0.5, 0.5]とすると共に中央寄せ、[1,1]とすると右寄せ、下寄せとなります。 touchable タッチ可能な要素かどうか指定します。 onClick タッチイベントのコールバック関数を指定します。ボタンが押されるとここで指定された関数が実行されます。UI.Buttonクラス特有のプロパティです。 - (LIST1の19行目)ボタンオブジェクトを画面に描画します。
プロパティ設定方法
UIオブジェクトはいくつかの方法でプロパティを設定できます。ボタンを表示させるコードではコンストラクタで値を指定していましたが、個別のsetterメソッド、setAttributeメソッド、setAttributesメソッドを使うこともできます。
画面に「GAME OVER」とテキストを表示するコードでその方法を示します。
var x = (UI.Window.outerWidth - 200) /2; var y = (UI.Window.outerHeight - 50) / 2; var label = new UI.Label(); label.setFrame([x, y, 200, 50]); label.setText("GAME OVER"); UI.Window.document.addChild(label);
var x = (UI.Window.outerWidth - 200) /2; var y = (UI.Window.outerHeight - 50) / 2; var label = new UI.Label(); label.setAttribute( "frame", [x, y, 200, 50]); label.setAttribute("text", "GAME OVER"); UI.Window.document.addChild(label);
var x = (UI.Window.outerWidth - 200) /2; var y = (UI.Window.outerHeight - 50) / 2; var label = new UI.Label(); label.setAttributes({ "frame": [x, y, 200, 50], "text" : "GAME OVER" }); UI.Window.document.addChild(label);
UIパッケージの探索
非常に簡単でしたが、UIパッケージの説明は終了です。ここでは紹介できませんでしたが、UIパッケージには様々なUIパーツが用意されています。興味のある方は、SDKのSamplesディレクトリや、NGCore/Client/UIディレクトリを探索してみて下さい。
GL2パッケージによる描画
ここからはGL2パッケージを使ってスプライトアニメーションの描画をしてみます。GL2パッケージのオブジェクトはGL2.RootにaddChild()することで画面に描画されます。まずはSpriteクラスの使い方を掘り下げてみましょう。
Spriteクラスの基本と基準点
GL2.Spriteクラスの基本を説明していきます。まずは、ピコピコハンマーの画像(図4)を表示させるだけのコードを見てください(LIST2)。
1: var UI = require('../NGCore/Client/UI').UI; 2: var GL2 = require('../NGCore/Client/GL2').GL2; 3: 4: function main() { 5: var glView = new UI.GLView({ 6: frame: [0, 0, 80, 80], 7: onLoad: onLoad 8: }); 9: glView.setActive(true); 10: } 11: 12: function onLoad() { 13: var sprite = new GL2.Sprite(); 14: sprite.setPosition([0, 0]); 15: sprite.setImage('Content/gl/hammer001.png', [80,80]); 16: GL2.Root.addChild(sprite); 17: }
まず、5ステップ目でUI.GLViewを初期化して描画領域を生成しています。GL2パッケージを使って描画するためには、このサンプルコードのようにUI.GLViewを初期化して描画領域を生成する必要があります。描画領域の読み込みが完了するとonLoadコールバックが呼ばれます。
次にonLoad関数内の処理を説明します。LIST2の14行目と15行目で、(0, 0)座標にピコピコハンマーの画像(Content/gl/hammer001.png)を80x80ピクセルで表示しようとしています。
実行結果は図5のようになりました。予想に反してピコピコハンマーの画像が見切れています。
この原因は、基準点が画像の中心に設定されているためです。つまり基準点である画像の中心を(0,0)座標に描画してしまったのです。基準点はsetImageメソッドの第3引数で設定できます。LIST2の15行目を以下のように変更して、基準点を画像左上に設定します。これで画面内に画像が表示されるようになります。
sprite.setImage( 'Content/gl/hammer001.png', [80,80], [0,0]);
画像を回転させる場合など、基準点を画像の中心にしておきたいケースもあります。そのような場合は、setPositionメソッドで描画座標を調整します。
sprite.setPosition([40, 40]);
アニメーションの描画
先ほどのピコピコハンマーをアニメーションさせるために、9枚の画像を用意しました(図6)。これを使って9フレームのアニメーションを生成してみます。
用意した9枚の画像をContent/gl/に置きmanifest.jsonに以下を追記します。
"textures": [ "./Content/gl/*.png" ]
これでngCoreはContent/gl/に置かれた.pngファイルをテクスチャーとして使用する準備を行います。
まずはシンプルなアニメーションのコードです(LIST3)。
var UI = require('../NGCore/Client/UI').UI; var GL2 = require('../NGCore/Client/GL2').GL2; function main() { var glView = new UI.GLView({ frame: [0, 0, 80, 80], onLoad: onLoad }); glView.setActive(true); } function onLoad() { var a = new GL2.Animation(); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer001.png', 1000/9, [80, 80], [0, 0])); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer002.png', 1000/9, [80, 80], [0, 0])); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer003.png', 1000/9, [80, 80], [0, 0])); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer004.png', 1000/9, [80, 80], [0, 0])); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer005.png', 1000/9, [80, 80], [0, 0])); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer006.png', 1000/9, [80, 80], [0, 0])); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer007.png', 1000/9, [80, 80], [0, 0])); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer008.png', 1000/9, [80, 80], [0, 0])); a.pushFrame(new GL2.Animation.Frame('Content/gl/hammer009.png', 1000/9, [80, 80], [0, 0])); var sprite = new GL2.Sprite(); sprite.setAnimation(animation); GL2.Root.addChild(sprite); }
このコードはhammer001.pngからhammer009.pngまでの9枚の画像を順番に表示することでアニメーションを実現しています。
9個のGL2.Animation.FrameオブジェクトとそれをまとめあげるGL2.Animationオブジェクトが登場します。
Frameクラスのコンストラクタの第2引数は指定した画像を何ミリ秒表示するかを設定します。ここでは1000/9となっていますので、1枚あたりの表示秒数は1000/9ミリ秒で、9枚全て表示されるのに1秒かかります。つまり、9fpsとなります。
ここで生成されたFrameオブジェクトがパラパラ漫画の要領で高速に描画されることでアニメーションを実現しています。
Sprite、Animation、Frameの各オブジェクトの関係を図示すると図7のようになります。
アニメーション用の画像は Abobe Flashで作ったムービーをシーケンス書き出ししています。既にFlash素材があれば簡単に準備できます。
スプライトシートを使った描画
さて、実際の制作現場ではパフォーマンスを考慮し、複数の画像をまとめてスプライトシートと呼ばれる1枚の大きな画像を用いることがあります。
ここではImageMagickを利用して簡単にスプライトシートを生成する方法と、スプライトシートから任意のフレームをクリップして描画する方法を紹介します。
先ほど用いた9枚のピコピコハンマーの画像を1つのスプライトシートにまとめてみましょう。LIST4のコマンドで9枚の画像が縦横3×3で並んだ大きな1枚のスプライトシートができ上がります。
montage -background none -geometry +0+0 -tile 3x3 hammer00*.png hammer.png
でき上がる画像は図8のとおりです。
さて、この大きな画像ファイルから任意のフレームをクリップするためにはFrameコンストラクタの第5引数にUV座標と呼ばれる情報を指定します。UV座標は以下の形式で指定します。
[クリップ開始のx座標,クリップ開始のy座標,クリップ領域の幅,クリップ領域の高さ]
またUV座標は0から1の範囲で指定する必要がありますので、先ほど作った3x3のスプライトシートから1フレーム目をクリップする場合は以下のようになります。
[0, 0, 1/3, 1/3]
次に2フレーム目ですが、(1/3, 0)から同じく1/3ずつクリッピングするので以下のようになります。
[1/3, 0, 1/3, 1/3]
もう書くまでもありませんが、最後の9フレーム目は(2/3,2/3)から1/3ずつクリップするのでこのようになります。
[2/3, 2/3, 1/3, 1/3]
最後にコードを示しておきます。LIST5のようになります。
function main() { var glView = new UI.GLView({ frame: [0, 0, 80, 80], onLoad: onLoad }); glView.setActive(true); } function onLoad() { var path = "Content/gl/hammer.png"; var animation = new GL2.Animation(); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[0/3,0/3,1/3,1/3])); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[1/3,0/3,1/3,1/3])); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[2/3,0/3,1/3,1/3])); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[0/3,1/3,1/3,1/3])); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[1/3,1/3,1/3,1/3])); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[2/3,1/3,1/3,1/3])); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[0/3,2/3,1/3,1/3])); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[1/3,2/3,1/3,1/3])); animation.pushFrame(new GL2.Animation.Frame(path, 1000/9, [80, 80], [0, 0],[2/3,2/3,1/3,1/3])); var sprite = new GL2.Sprite(); sprite.setAnimation(animation); GL2.Root.addChild(sprite); }
GL2レイヤーとUIレイヤー
ここまでで、UIパッケージとGL2パッケージを使った描画方法を説明しました。GL2パッケージを使って描画する世界と、UIパッケージを使って描画する世界は独立したレイヤーのようになっていて、常にGL2の上にUIが表示されるようになっているので注意してください。
OpenGLの制約上、画像のサイズは縦横ともに2のn乗であることが求められています。ngCoreではその点も考慮して画像サイズの変換なども行ってくれますので、ここで作った画像も実行後生成される、build/の中を覗くと512x512のサイズに変換されていることが確認できます。
サーバーとの連携
ソーシャルゲームを作る上で、重要な要素の1つにサーバーとの連携があります。ngCoreではHTTPプロトコルを使って通信を行うことができます。ここからngCoreの通信機能について説明していきます。
Twitterのタイムラインを取得
ここではngCoreの通信機能を説明するために、TwitterのAPIをお借りして、任意のアカウントのタイムラインをHTTPで取得してみます。LIST6のコードはDeNAPRアカウントのタイムラインから最新の1件を表示します。
1: var XHR = require('../NGCore/Client/Network/XHR').XHR; 2: 3: function main() { 4: var url = 'http://twitter.com/statuses/user_timeline/DeNAPR.json'; 5: request('GET', url, { 6: success: function(request){ 7: var data = JSON.parse(request.responseText); 8: (new UI.Toast({text: data[0].text})).show(); 9: } 10: }); 11: } 12: 13: function request(method, url, opts) { 14: opts = opts || {}; 15: var default_opts = { 16: success : function(){}, 17: error : function(){}, 18: }; 19: for (var key in default_opts) { 20: opts[key] = opts[key] || default_opts[key]; 21: } 22: var _request = new XHR(); 23: _request.onreadystatechange = function () { 24: if (_request.readyState === 4) { 25: if (_request.status === 200) { 26: opts.success(_request); 27: } else { 28: NgLogD("Error:" + _request.error); 29: //NgLogD(JSON.stringify(_request)); 30: opts.error(_request); 31: } 32: } 33: //NgLogD("onReadystatechange:" + _request.readyState); 34: }; 35: _request.open(method, url); 36: _request.send(); 37: }
コードを実行する際の注意点
このコードを実行する際に2つの注意点があります。まずWebブラウザ環境では正しく動きません。クロスサイトドメインへの通信がうまく処理できないためです。次にTwitter APIの制限として、1クライアントからのアクセスは1日150回までとなっているので、150回を超えてTwitter APIへリクエストを送るとエラーが返されます。
request関数
13ステップ目から定義されているrequest関数ですが、ブラウザ環境でXMLHttpRequestを扱うケースとほぼ同じような処理で、ngCore特有の話題ではないので、ここでは特に解説しません。
また、このrequest関数をより高機能化したモジュールなど、ゲーム開発に便利なライブラリをDeveloperサイトで公開しています。このセクションの文末に記載したURLをご覧ください。
コード解説
ではコードを順に説明していきます。
- (LIST6の1行目)HTTPプロトコルで通信するためにはXHRクラスを使います。
- (LIST6の4行目)今回はDeNAPRアカウントのタイムラインを取得していますが、他のアカウントのタイムラインを取得したければ「DeNAPR」の部分を任意のTwitterアカウント名に書き換えます。
- (LIST6の5行目)request関数を用いてGETメソッドでHTTPリクエストをしています。
- (LIST6の6行目)リクエストが成功した場合のコールバック関数を指定しています。HTTPリクエストが適切なレスポンスを返した場合、この関数が呼ばれます。
- (LIST6の7行目)このTwitter APIからのレスポンスはrequest.responseTextに入ります。このレスポンスはJSON形式のテキストですので、JSON.parse関数でJavaScriptのオブジェクト化をしています。
- (LIST6の8行目)いかにもJavaScript的な書き方になっていますが、分解するとこのようになります。
var toast = new UI.Toast( {text: data[0].text}); toast.show();
UI.Toastクラスを使ってレスポンス内容を画面に表示しています。
UI.Toastクラスを使うと画面上部に数秒間テキストを表示させることができます。数秒後に自動的に消えますので、開発時にはプリントデバッグの手段として使うこともできます。
実行結果
このサンプルをiPhone Simulatorで実行すると図9のように表示されます。DeNAPRアカウントのタイムラインから最新の1件が表示されているのが分かります。
ゲームAPIとの連携
今回はTwitter APIをお借りしてngCoreの通信機能を説明しました。実際にソーシャルゲームを作る場合は、自分たちが管理するAPIサーバーと通信を行うことになります。その際はケースに応じて、ユーザー認証やセッション管理などを行う必要があります。
サンプルゲーム
最後にサンプルゲームを紹介します。このコードはページ上部のリンクまたはこちらからダウンロード可能ですので、ぜひダウンロードして動かしてみてください。
ルール説明
このゲームはちょっと変わったモグラ叩きゲームです。ハンマーとモンスターが画面に登場しますので、ハンマーを掴んでモンスターを叩きます。ただしハンマーは掴み続けていると消滅してしまいますので、掴んだらすばやくモンスターを叩かないといけません。またモンスターも画面上を点々と移動しますのでモンスターを素早く追いかけてハンマーを打ちおろします。見事ハンマーでモンスターを叩くとモンスターは何かひとこと呟いて消滅します。10個のハンマーで何匹のモンスターを退治できるか競います。
実行画面
ゲーム画面をキャプチャしました(図10)。ゲーム内で使われている素材は農園ホッコリーナ/牧場ホッコリーナのものを使用しています。
コード解説
サンプルゲームの8割程の内容は、ここまでの解説で既に理解していただける内容となっていますので、ここでは残りの2割を補足していきます。
まず、3つのクラスがあります。Game、Hammer、Monsterです。HammerとMonsterは分かりやすいと思います。Gameは“とりあえず”ここに入れとけというダメなクラスになっていますが、コードの成長に伴ってGameが分割されていくことでしょう。ではコードを見ていきましょう。
17行目あたりのLIST7の処理ですが端末の向きを変更しています。これで画面がランドスケープモード(横向き)になります。
Device.OrientationEmitter.setInterfaceOrientation( Device.OrientationEmitter.Orientation.LandscapeLeft);
ランドスケープモードでもUI.Window.outerWidthやUI.Window.outerHeightで取得できる数値は変わらないことに注意してください。つまり、横向き状態で画面幅を取得したい場合はUI.Window.outerHeightを、横向き状態で画面の高さを取得したい場合はUI.Window.
outerWidthをそれぞれ呼び出す必要があります。26行目あたりで、UI.GLViewのオブジェクトに対して、setActive(true)しています。これはOpenGLの描画ループを開始するための処理です。これを忘れるとGL2レイヤーが表示されませんので注意してください。
LIST8はHammerクラス内のハンマーが消滅する際の処理です。
this._sprite.setAnimation(this._anim['lost']); this._sprite.getAnimationCompleteEmitter().removeListener(this); this._sprite.getAnimationCompleteEmitter().addListener(this, this.warp);
this._anim['lost']がハンマーが消える際に出るモクモクアニメーションです。モクモクアニメーションが最後まで再生されたタイミングで、次の処理を実行したいのでAnimationCompleteEmitterにlistenerとして、this.warpを登録しています。EmitterとListenerについては、連載第2回「サンプルで学ぶ、ngCoreを使ったゲーム開発」で解説されていますので、そちらを参照してください。
また、AnimationオブジェクトにsetLoopingEnabled(true)を設定するとAnimationCompleteEmitterは永遠にemitされないので注意が必要です。
GameクラスのdrawUIメソッド、undrawUIメソッドについて説明します(LIST9)。
drawUI: function(func) { var views = Game[func](); if (views) { Game.uiView[func] = views; for (var key in views) { UI.Window.document.addChild(views[key]); } } }, undrawUI: function(func) { var views = Game.uiView[func]; if (views) { for (var key in views) { views[key].destroy(); } } delete Game.uiView[func]; },
Game.glView.setActive(true);
drawUIメソッドでは、生成済みのUIオブジェクトを内部で保持しています。これによりundrawUIメソッドが呼び出されたタイミングで、確実にdestroyを呼び出すことができるようになっています。
ngCoreでは生成したオブジェクトはdestroyされるまでメモリ上に居座り続けます。使わなくなったタイミングで確実にdestroyメソッドを呼ぶ必要がありますので注意が必要です。
UIパッケージのUI.WebViewや、UI.ScrollViewを使って全画面描画をするような場合、スクロールが重くなるケースがあります。このような場合は、UI.GLViewのオブジェクトに対してsetActive(false)して描画ループを止め、スクロールの処理速度を稼いだりもします。
SDKバージョンについて
ここで用いたサンプルコードは全てngCore SDK 1.0.6で作られています。最新のngCore SDKでは端末の画面取得に関するAPIなどの仕様変更が予定されていますので注意してください。
さらに詳細な情報
ngCoreのAPIドキュメントや、DeNAの内製チームで得たノウハウやライブラリなど、さらに豊富な情報が以下のURLで公開されていますので、ご活用ください。
最後に
以上で、「ngCoreによるゲーム開発入門」は終わりです。Objective-CもJavaもほとんど書けない筆者が、iPhoneとAndroidのゲームを同時に開発するなんて1年前までは想像できませんでした。実は今でも実感が湧きませんが、それはngCoreによってOSや開発言語の違いが完全に隠蔽され、開発者はそれを意識する必要がほとんどないからに他なりません。この冊子をお読みになっている皆さんにもngCoreを使って、OSや言語の違いを気にすることなく、面白いゲーム開発にエネルギーを注いでいただきたいと思います。
ngCoreにて開発された牧場シミュレーションゲーム。自分の牧場で野菜や果物を育てて収穫したり、時折訪れるいたずら動物を捕まえたりしながら、自分の牧場を発展させていくゲームです(※iOS/Androidの両OSに対応しています)。