読者です 読者をやめる 読者になる 読者になる

hyoromoのブログ

iOS/AndroidもしくはCocos2dxネタを書いています

Cocos2d-x を使ってカジュアルゲームっぽいアプリを作ってみた

f:id:hyoromo:20140905210539p:plain
世にあるカジュアルゲームがよく搭載している機能を実装してみました。
広告等の話が出てきますので、そういった類の話が嫌な人はここでタブを閉じてください。

開発環境
  • Cocos2d-x v3.2 final
  • iOS Deployment Target 7.0
  • Android minSdkVersion 15
開発アプリの概要

まずは開発したアプリ内容について簡単に説明します。アプリ名は「ノーパツ」。今年のお盆休みに名前を考え、盆休み中に主要機能を実装しました。

60秒の間に玉のどちらがラインに居るかを判断し、右(赤)左(青)をタップするシンプルな内容となっています。60秒間押し続けていたらClear、1度でもミスするとGame Overとなり、リザルト画面が表示されます。
このように素早い判断を行い、どれくらいコンボ数を伸ばせるかを競う音ゲーに必要な要素を強化する特訓アプリとなっています。面白くはないですが目と指を動かす速度は鍛えられるんじゃないかなと。

よろしければダウンロードしてプレイしてみてください。
iOS: https://itunes.apple.com/jp/app/id909767578
Android: https://play.google.com/store/apps/details?id=jp.hyoromo.redorblue

この程度のアプリでしたら、Cocos2d-xだとコンテンツ内容自体(タイトル, ゲーム本編, リザルト, ポップアップ)は1.5人日くらいで終わります。本エントリーで紹介予定のその他諸々の機能は下手すれば1週間くらい掛かってしまう内容ですが、本エントリー読めば1/4程度で済む!といいですね。
ではこれ以降、本エントリーのメインとなる搭載機能の説明になります。


ランキング/実績

f:id:hyoromo:20140905211303j:plain
iOS/Androidで別々に実装する必要がありますので、それぞれObj-C/Javaで書かれたコードを呼び出して対応しました。この対応方法についてはググれば腐るほど情報がありますので本エントリーでは省略します。

iOS/Androidでの大きな違い

同じ事をさせようとした時にハマるポイントは以下になります。iOS先行で開発しており、全く同じものを開発する必要がある場合は注意が必要です。

iOS Android
ランキング(Leaderboard) と 実績(Achievement) は片方ずつでも追加可能
実績に下限はない
ランキング(リーダーボード) と 実績 は両方追加する必要がある
実績は最低5つ追加する必要がある
iOSの Leaderboard/Achievement 実装

メタ情報は iTunes Connect 上で登録出来ますので、先にアプリを追加して登録しておく必要があります。
実装コードはググれば沢山情報があるのでその通りに行えば表示されます。

Androidの リーダーボード/実績 実装

メタ情報は Google Play Developer Console 上で登録出来ます。左メニュー上のゲームサービスから促されるままに登録していけば問題ありません。が、メタ情報の登録はアプリ情報を Devlopser Console 上で登録し、正式証明書をあてたapkをアップした状態で行うと道中のトラップに引っかからずに済みます。

実装コードはググるとリリースされた1年前の情報が沢山出てきますが、2014/07にアップデートされた Google Play Services 5.0 で実装方法がガラッと変更されたため参考にはなりません。
Google Play Serviceを最新にし、予めプロジェクトに参照ライブラリとして追加しておきます。
Googleが公開しているサンプルで BaseGameActivity.java 以外をプロジェクトにImport(Androidのsrc以下へ)します。次にBaseGameActivity.java を適当に参考にしながら src/org.cocos2dx.cpp.AppActivity.java を修正します。
https://github.com/playgameservices/android-basic-samples/tree/master/BasicSamples/libraries/BaseGameUtils/src/main/java/com/google/example/games/basegameutils
あとは AndroidManifest.xml に以下のコードを追加。

<meta-data android:name="com.google.android.gms.games.APP_ID"
    android:value="@string/app_id" />
<meta-data android:name="com.google.android.gms.version"
    android:value="@integer/google_play_services_version" />

    <uses-permission android:name="android.permission.INTERNET"/>

res/values/ids.xml ファイルを新規作成して以下のように追加すれば動きます。もし動かない場合はLogCatの内容を確認して調整してみてください。

<?xml version="1.0" encoding="UTF-8"?>
<resources>
    <string name="app_id">370459062638</string>
</resources>

AdMob

AdMobでバナー広告(Mediationも含めて)導入は比較的簡単で、Pluginにあります。
iOS/Androidごとに実装が必要なくCocos2d-x側だけで対応しきれます。詳細は以前書いた会社のブログを参照ください。
http://laboyukai.blogspot.jp/2014/06/cocos2dx-v30-plugin-x-admob.html

若干手間なのはインタースティシャル広告を追加したい場合です。AdMobのインタースティシャル広告は日本でのよくあるポップアップタイプの広告ではなく、全面広告扱いのようで Mediation に国内アドネットワークを追加しても利用できません。
単にAdMobのインタースティシャル広告を表示したい場合は、PluginのiOS/Android側にメソッドだけ用意されているので中身をそれぞれのプラットフォームに合わせて書いてあげればAdMobの全面広告が表示されます。

Androidでの AdMob 実装

2014/08 から Google Play Service を利用する必要があるため、Pluginをそのまま利用した場合 jar を使ってしまうためReject対象となってしまいます。jarの参照を外し、Google Play Service のライブラリを参照するように変更を加えてください。

アイコン型広告

本アプリで追加してみたのは Nend と AppVador。
Nend は Cocos2d-x v3.2 向けのライブラリを公開していたので、あまり手間を掛けずに導入出来ました(Pluginではないため各プラットフォームのソースを若干変更する等の手間は発生)。
AppVadorは動画タイプの広告で1秒以上再生されると収益発生するらしいので試しに使ってみました。こちらは Cocos2d-x 向けのライブラリは存在しない為、iOS/Androidそれぞれ向けに公開されているSDKを導入する事になります*1

iOSでの AppVador 実装

実装コード XXX.mm で以下のように実装します。appKey には AppVador 管理画面で生成されたkeyを設定します。

#import <AppVador/AvAdView.h>

- (void)showAd:(NSString *)appKey
{
	AppController* appController = (AppController*) [UIApplication sharedApplication].delegate;
	avAdView = [[AvAdView alloc] initWithFrame:CGRectMake(0, 0, kIconWidth, kIconHeight) applicationId:appKey];
	avAdView.rootViewController = (UIViewController *)appController.viewController;
	// [avAdView isTest:YES];//テスト広告を表示する場合
	[((UIViewController *)appController.viewController).view addSubview:avAdView];
	[avAdView adStart];
}

iOS側で画面上に何か載せたい場合は ((UIViewController *)appController.viewController).view で、viewを取得出来ればどうとでもなります。これは広告だけではなく後述するウォール広告表示とかでも同じです。

Androidでの AppVador 実装

src/org.cocos2dx.cpp.AppActivity.java で以下のように実装します。

public class AppActivity extends Cocos2dxActivity {
    private static AppActivity appActivity;
    private static WindowManager mWindowManager = null;

    @Override
    protected void onCreate(Bundle b) {
        super.onCreate(b);
        mWindowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
    }

    @Override
    protected void onStart() {
        super.onStart();
        appActivity = this;
    }

    @Override
    protected void onStop() {
        super.onStop();
        appActivity = null;
    }

    @Override
    protected void onDestroy() {
        mWindowManager = null;
        super.onDestroy();
    }

    public static void showAppVadorAd(final String appId) {
        appActivity.runOnUiThread(new Runnable() {
            public void run() {
                // mAdView = new AdView(appActivity, appId, true, true); // テスト広告配信
                mAdView = new AdView(appActivity, appId, true); // 本番広告配信
                mAdView.setAdListener(appActivity);

                WindowManager.LayoutParams param = new WindowManager.LayoutParams();
                param.gravity = Gravity.TOP | Gravity.LEFT;
                param.x = 0;
                param.y = 0;
                param.width = WindowManager.LayoutParams.WRAP_CONTENT;
                param.height = WindowManager.LayoutParams.WRAP_CONTENT;
                param.type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL;
                param.format = PixelFormat.TRANSLUCENT;
                param.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;

                LinearLayout layout = new LinearLayout(appActivity);
                layout.addView(mAdView, param);

                mWindowManager.addView(layout, param);

                mAdView.adStart();
            }
        });
    }
}

ポイントは appActivity.runOnUiThread でメインスレッド上で実行する事と、悪名高き WindowManager APIを利用する事です。Activityとか関係なく画面上の上に Layout を配置出来ますので、こちらもiOSと同じく様々な用途で同じ手法が取れます。なお、位置調整は param.gravity と param.x/y を使って行ってください。

ウォール広告

AppliPromotion を利用してみました。Cocos2d-x 向けのライブラリ提供はありませんが、「アイコン型広告」で記載した方法で問題なく表示されます。

インタースティシャル広告

Nend を利用しました。アイコン型と同じくCocos2d-x向けライブラリが提供されていたので簡単に導入出来ました。
本アプリでは10回連続で20秒未満のリザルトを記録した場合に表示しています。他アプリでたまにボタン押したら2つくらい「ポン......ポン」と遅延表示するのがあったりして、この広告タイプはユーザーにとって凄く悪いイメージでしょうね。

アプリ終了時の広告

Androidでアプリをbackキーで終了させるタイミングでアプリを終了させずに広告表示させる広告には AID を利用してみました。

Androidでの アプリ終了時の広告 実装

Cocos2d-x でのデフォルトではAndroidのキー操作(homeを除く)は無効化されています。
使用したい場合は対象Sceneで以下のようにします。

bool TitleScene::init() {
    if (!Layer::init()) {
        return false;
    }
    auto keyboardListener = EventListenerKeyboard::create();
    keyboardListener->onKeyReleased = CC_CALLBACK_2(TitleScene::onKeyReleased, this);
    this->getEventDispatcher()->addEventListenerWithSceneGraphPriority(keyboardListener, this);
    return true;
}

void TitleScene::onKeyReleased(cocos2d::EventKeyboard::KeyCode keyCode, cocos2d::Event * event) {
    if (keyCode == EventKeyboard::KeyCode::KEY_BACK) {
        // ここで処理
    }
}

「ここで処理」の箇所でJava側のAID広告表示メソッドを呼び出してあげれば実装完了です。
ただ、ここで1つ問題が発生します。AID側のポップアップ上の「アプリ終了」でアプリを終了させた後、アプリ起動させるとアプリ動作が不安定になり実装内容によってはクラッシュに繋がります。原因はAID側のライブラリ内で「アプリ終了」を finish() しているため、再開した時に static 定義している箇所で支障が出るからです。moveTaskToBack(true) させていれば問題ない...ような感じなのですがライブラリ更新されないとダメそうな感じです。
GooglePlayで公開されている他Cocos2d-x製のアプリで、アプリ終了時の広告表示しているアプリで尽く同じ現象が発生してアプリクラッシュしているので要らぬところで品質低下させていますね。
なので本アプリではbackキーをフックして広告表示する機能を追加するのは止めておきました。

アプリ情報をソーシャル共有

f:id:hyoromo:20140905212713j:plain
Cocos2d-x v3.2 からスクリーンショットを撮影するAPIが追加されました。撮影成功するとアプリ内のサンドボックススクリーンショットが保存されます。撮影後、ファイル名をiOS/Androidそれぞれに受け渡して共有処理を実行します。
http://www.cocos2d-x.org/wiki/How_to_Save_a_Screenshot

utils::captureScreen([](bool succeed, const std::string &filename) {
    if (succeed) {
        // 撮影完了したのでここで処理
    } else {
        // 撮影失敗
    }
}, "screen_schott.jpg");

h3. iOSでの共有機能実装

Social.frameworkを使うのも手ですが、今回は今後期待できる AppController で共有させました。

- (void)postWithImage:(NSString *)message filePath:(NSString *)filePath
{
    UIImage *postImage = [UIImage imageWithContentsOfFile:filePath];
    
    NSArray *activityItems;
    if ([filePath length] == 0) {
        activityItems = @[message];
    } else {
        activityItems = @[message, postImage];
    }
    
    UIActivityViewController *activityController = [[UIActivityViewController alloc]
                                                     initWithActivityItems:activityItems
                                                     applicationActivities:nil];

    AppController *appController = (AppController*) [UIApplication sharedApplication].delegate;
    [appController.viewController presentViewController:activityController
                                               animated:YES completion:nil];
}
Androidでの共有機能実装

Intentで共有します。画像を共有する場合、アプリ内からアプリ外へスクリーンショット画像をコピーしてから行う必要があります。
src/org.cocos2dx.cpp.AppActivity.java で以下のように実装しておきます*2

public static void postWithImage(String message, String filePath) {
    final String path = filePath;
    final String tweetMessage = message;

    appActivity.runOnUiThread(new Runnable() {
        @Override
        public void run() {
            Intent intent = new Intent(Intent.ACTION_SEND);
            intent.putExtra(Intent.EXTRA_SUBJECT, "");
            intent.putExtra(Intent.EXTRA_TEXT, tweetMessage);
            if (path.length() > 0) {
                byte[] data;
                try {
                    data = readFileToByte(path);
                } catch (Exception e) {
                    return;
                }

                File savePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
                File saveFile = new File(savePath, "screenshot.jpeg");

                if (!savePath.exists()) {
                    savePath.mkdir();
                }

                FileOutputStream fos = null;

                try {
                    fos = new FileOutputStream(saveFile);
                    fos.write(data);
                    fos.close();
                } catch (Exception e) {
                    return;
                }

                Uri uri = Uri.fromFile(saveFile);
                intent.putExtra(Intent.EXTRA_STREAM, uri);
                intent.setType("image/jpeg");
            } else {
                intent.setType("text/plain");
            }
            appActivity.startActivity(Intent.createChooser(intent, "Share"));
        }
    });
}

アプリ外へ画像を書き込む為、AndroidManifest.xml へ以下のPermissionを追加します。

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

課金

f:id:hyoromo:20140905213130j:plain
本エントリーのラスボスです。私が思いつく限り対応方法は全4種類。どの道を進むにしても即死級のトラップがあること間違い無しのデスゾーンです。一応4種類をリストアップしておきます。

  1. 全部ネイティブ開発
    一番安全ですが両プラットフォームで課金処理を実装した経験が無い人にとっては心が折れる作業となります。特にAndroid
  2. Cocos2d-xのPluginを利用
    iOS/AndroidともにPluginとして公開されていますが、どちらもあまり利用されていないようで実装内容に不足があります。不足点に気づけて追加していける場合はPlugin利用が良いかと。
  3. appC Cloud SDKを利用
    簡単な実装で導入可能らしいSDKです。Cocos2d-x向けプラグインも公開されており本当に簡単に導入可能ならアリでしょう。課金導入時期が iOS 64bit未対応状態でしたのでスルーしましたが、現在では対応されているのでイイカモシレマセンネ。
  4. soomlaライブラリを利用
    2014/8中 時点でiOS側に致命的なエラーが発生していて、issueが上げられ対応されてる感じでしたが危険な匂いしかしません。ちなみに私は半日くらい掛けて導入した後に該当エラーで全てを放棄する事になりました。ブログ用に100行にも渡る苦心の文面をお蔵入りBOXという名のゴミ箱へシュートしました。

本エントリーは「2: Cocos2d-xのPluginを利用」について書きます。なお、2014/08下旬時点の最新Pluginを導入して実装しました。Plugin導入方法は「AdMob」で記載したリンク先を参照ください。
※今でも頻繁に更新があるようなので、v3.3を待ってPlugin側の更新が落ち着いた時に導入を検討した方が良いかと思います。

以下、非消費型アイテムの購入についての内容になります。Cocos2d-x側の課金処理実装クラスは「InAppPurchaseManager」としています。

InAppPurchaseManager.h

#include "cocos2d.h"
#include "ProtocolIAP.h"

using namespace cocos2d;
using namespace plugin;

class InAppPurchaseManager : public PayResultListener {
public:
    static InAppPurchaseManager* getInstance();
    InAppPurchaseManager();

private:
    ProtocolIAP *iosIap;
    ProtocolIAP *androidIap;

    // PayResultListener
    virtual void onPayResult(PayResultCode ret, const char* msg, TProductInfo info);
    virtual void onRequestProductsResult(IAPProductRequest ret, TProductList info);

InAppPurchaseManager.cpp

#include "PluginManager.h"

// 課金情報取得
InAppPurchaseManager::InAppPurchaseManager() {
#if CC_TARGET_PLATFORM == CC_PLATFORM_IOS
    iosIap = dynamic_cast<ProtocolIAP*>(PluginManager::getInstance()->loadPlugin("IOSIAP"));
    iosIap->setResultListener(this);
    PluginParam param(iOSのアイテムID);
    iosIap->callFuncWithParam("requestProducts", &param, NULL);
#elif CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID
    androidIap = dynamic_cast<ProtocolIAP*>(PluginManager::getInstance()->loadPlugin("IAPGooglePlay"));
    androidIap->setResultListener(this);
    TProductInfo pInfo;
    pInfo["GooglePlayAppKey"] = AndroidのプロダクトKEY; // AndroidのKeyはDeveloperサイトのアプリから「サービスとAPI」で確認可能
    androidIap->configDeveloperInfo(pInfo);
#endif
}

// 課金処理。productIdは各プラットフォームのアイテムID
void InAppPurchaseManager::requestPurchaseProcess(const char *productId) {
#if CC_TARGET_PLATFORM == CC_PLATFORM_IOS
    TProductInfo productInfo;
    productInfo["productId"] = productId;
    iosIap->payForProduct(productInfo);
#elif CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID
    TProductInfo productInfo;
    productInfo["IAPId"] = productId;
    productInfo["IAPSecKey"] = "bGoa+V7g/yqDXvKRqq+JTFn4uQZbPiQJo4pf9RzJ"; // セキュリティキー。毎回ランダムな値を設定します
    androidIap->payForProduct(productInfo);
#endif
}

// リストア(iOS限定)
void InAppPurchaseManager::requestRestorePurchaseProcess() {
    iosIap->callFuncWithParam("restoreCompletedTransactions", NULL);
}

// PayResultListener
// 課金処理後に呼び出される
void InAppPurchaseManager::onPayResult(PayResultCode ret, const char* msg, TProductInfo info) {
    // プログレスを表示指定た場合は閉じておく

    if (ret == kPaySuccess && msg != NULL) {
        const char* checkProductId;
#if CC_TARGET_PLATFORM == CC_PLATFORM_IOS
        checkProductId = AppConstant::PRODUCT_HIDE_AD_ID_IOS;
#elif CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID
        checkProductId = AppConstant::PRODUCT_HIDE_AD_ID_ANDROID;
#endif
        if ((strcmp(msg, checkProductId) == 0)) {
            // 課金された
        }
    }
}

void InAppPurchaseManager::onRequestProductsResult(IAPProductRequest ret, TProductList info) {
    // プログレスを表示指定た場合は閉じておく
}

delegateメソッドのonPayResultの内容ですが、IAP(課金Plugin)側でどのような実装になっているか次第なので対応時期や改変している場合は実装内容が異なるかと思います。

iOSでの課金実装

メタ情報は iTunes Connect 上のアプリ詳細から行います。課金アイテムを作成してIDを取得しておきます。

利用するCocos2d-xのPluginは「iosiap」です。エラーダイアログの表示周りが変だったり、中国語が表示されたりした記憶があるので少し修正が必要だったと思います。
https://github.com/cocos2d-x/plugin-x/tree/develop/plugins/iosiap

Androidでの課金実装

メタ情報は Developer Console 上のアプリ詳細から行います。課金アイテムを作成してIDを取得しておきます。

利用するCocos2d-xのPluginは「googleplay」です。絶賛更新中のようなので静観しておいたほうがいいです。今回導入するにあたって、まだ改修途中だっただけあって色々おかしかったので都合の良いようにかなり変更しました。でも1から実装するよりは楽でしたので良かったかなと思います(今後バグさえ発生しなければ...)。
https://github.com/cocos2d-x/plugin-x/tree/develop/plugins/googleplay

*1:フィルレートが凄く低いようで、テスト中に広告が表示された事はありませんでした

*2:ここのコードは誰かのコードを参考に適当に改造して作ったのですが、参考元の場所を忘れて参考元リンクを載せられませんでした...