Cocos2d-x集成Amazon内购和GameCircle服务

No Comments

由于种种原因,这篇文章已经拖延了N多时间了。今天花了些时间把如何在Cocos2d-x(我用的版本是2.2.2)游戏中集成Amazon内购GameCircle服务(仅适用于Android版本)整理一下,发出来,作备忘。

之前在做“手指足球世界杯2014”时,想给这款小游戏加上内购(In-App Purchasing)和积分榜(ScoreBoard)功能。说到Android手机游戏的内购,人们第一时间想到的就是Google Play,不过悲催的是,Google Play在国内各种无法访问,行货机也不预装,其相关Service的测试十分困难,翻看了一些集成Google Game Service的文章,其过程坎坷之程度让人望而却步。于是我将目光转而投向了Amazon Game Service。亚马逊的游戏服务起步要晚些,成熟性肯定不如Google,但在国内来说也不失为另一个不错的选择,Google虽好,但访问不了有啥 法。但似乎国内同行使用Amazon游戏服务的并不多,度娘上相关中文资料甚少。但从Amazon发布的数据来看,其市场正在逐步扩大,并紧紧跟随 Google Play的脚步。

之前用kindle paperwhite时在amazon.com上注册了一个国际帐号,这次正好用这个。不过你要使用Amazon的Game Service,普通Amazon帐号是不行的。你要升级为Amazon的Developer。申请Developer帐号的过程还是蛮繁琐的,要提交一 堆资料,具体细节我大致忘的差不多了,这里就不说了。按照Amazon网站的提示一步一步做就是了。

有了帐号后,你可以下载Amazon的Game SDK了,这个包有近50M大小,本地解压后可以看到其提供的Android SDK种类:

AmazonSDK/Android$ ls
Ads  AmazonInsights  DeviceMessaging  GameCircle  InAppPurchasing  LoginWithAmazon  Maps  MobileAssociates  README.txt

Ads我之前用的是Google Admob,这里就不再用Amazon的了,我需要的是这里的InAppPurchasing和GameCircle。我们接下来一个一个来说。

* Amazon InAppPurchasing

Amazon支持三种内购类型:Consumables、Entitlements和Subscriptions:
    Consumables就像游戏中的红心、金币等,用户可以多次购买,每次可以买多个,并根据游戏规则,每次消耗若干个以达到某种游戏目的;在哪台设备上购买,就只能在哪台设备上使用。
    Entitlements是某种授权协议,一个用户只需购买一次,即可长期使用某种特权功能,并与设备无关,可在多个设备下授权使用。比如鳄鱼洗澡游戏中购买高级关卡等。
    Subscriptions有订阅的意思,需要某种Entitlements或某种访问权,在一定时间段内绑定有效,到期后自动renew,比如某种杂志的阅读权等。

我只想给游戏增加一些红心功能,一颗红心,可以让游戏者有一次续命的机会,因此我需要实现Consumables型内购。Amazon SDK中提供了Consumeables类内购的Android范例AmazonSDK/Android/InAppPurchasing/samples/SampleIAPConsumablesApp。我们可以参考这个例子来实现我的"红心内购"。

    1、添加依赖的jar包
    在你的游戏proj中添加内购功能所依赖的Amazon SDK jar包,包括AmazonInsights-android-sdk-2.1.26.jar、in-app-purchasing-1.0.3.jar 和login-with-amazon-sdk.jar。

    2、添加源文件
    参照例子,将AppPurchasingObserver.java、AppPurchasingObserverListener.java和MySKU.java拷贝到你的与XXActivity.java同级目录下。

    3、初始化Amazon IAP
   
    在你的XXActivity类中添加如下方法:

    public PurchaseDataStorage purchaseDataStorage;

    private void setupIAPOnCreate() {
        purchaseDataStorage = new PurchaseDataStorage(this);

        AppPurchasingObserver purchasingObserver
              = new AppPurchasingObserver(this, purchaseDataStorage);
        purchasingObserver.setListener(this);

        Log.i(TAG, "onCreate: registering AppPurchasingObserver");
        PurchasingManager.registerObserver(purchasingObserver);
    }

    protected void onCreate(Bundle savedInstanceState){
        … …
        setupIAPOnCreate();
    }

        protected void onResume() {
        super.onResume();
       
        Log.i(TAG, "onResume: call initiateGetUserIdRequest");
        PurchasingManager.initiateGetUserIdRequest();

        Log.i(TAG, "onResume: call initiateItemDataRequest for skus: "
                        + MySKU.getAll());
        PurchasingManager.initiateItemDataRequest(MySKU.getAll());
    }

    4、添加购买方法

    在Cocos2d-x的某个Scene或Layer中实现的购买方法事件的callback,后者通过Jni调用Java静态方法:

    void BuyHeartScene::buyHearts(int number) {
#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
    JniMethodInfo t;
    if (JniHelper::getStaticMethodInfo(t, "net/iwobi/game/flickworldcup/FlickWorldCupActivity",
                "onBuyHeartClick", "(I)V")) {
        t.env->CallStaticVoidMethod(t.classID, t.methodID, number);
        if (t.env->ExceptionOccurred()) {
            t.env->ExceptionDescribe();
            t.env->ExceptionClear();
            return;
        }
        t.env->DeleteLocalRef(t.classID);
    }
#endif
    }

    该Java方法的实现如下(我这里有五种商品ONEHEART到FIVEHEART):

    public static void onBuyHeartClick(int type) {
        String requestId;
      
        switch (type) {
            case 1:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.ONEHEART.getSku());
                break;
            case 2:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.TWOHEART.getSku());
                break;
            case 3:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.THREEHEART.getSku());
                break;
            case 4:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.FOURHEART.getSku());
                break;
            case 5:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.FIVEHEART.getSku());
                break;
            default:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.ONEHEART.getSku());
                break;
        }

        PurchaseData purchaseData = ((FlickWorldCupActivity)context).purchaseDataStorage
                .newPurchaseData(requestId);
        Log.i(TAG, "onBuyHeartClick: requestId (" + requestId
                + ") requestState (" + purchaseData.getRequestState() + ")");
    }

    5、修改各种回调方法

    将SampleIAPConsumablesApp/src/com/amazon/sample/iap/consumable /MainActivity.java中的onPurchase为前缀名的方法以及onGetUserIdResponseSuccessful挪到你的 Activity源文件中。这些方法绝大部分是不需要修改的,除非你不喜欢例子中日志输出的格式,或是想用toast之类的提示方式改造各种 callback的结果显示方式。

    这里我主要修改了一个方法:onPurchaseResponseSuccess。该方法在购买成功后被调用,我们在这个事件发生时更新Scene或Layer的显示(updateHeartInScene)。

    @Override
    public void onPurchaseResponseSuccess(String userId, String sku,
            String purchaseToken) {
        Log.i(TAG, "onPurchaseResponseSuccess: for userId (" + userId
                + ") sku (" + sku + ")");
        SKUData skuData = purchaseDataStorage.getSKUData(sku);
        if (skuData == null)
            return;

        if (MySKU.ONEHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(1);
        }

        if (MySKU.TWOHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(2);
        }

        if (MySKU.THREEHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(3);
        }

        if (MySKU.FOURHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(4);
        }

        if (MySKU.FIVEHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(5);
        }
    }

    6、AndroidManifest.xml和其他Java文件

    AppPurchasingObserver.java和AppPurchasingObserverListener.java你可以原封不动的使用。MySKU.java可以根据你的内购项目做改造:

    public enum MySKU {

    ONEHEART("net.iwobi.game.flickworldcup.iap.consumable.oneheart", 1),
    TWOHEART("net.iwobi.game.flickworldcup.iap.consumable.twoheart", 1),
    THREEHEART("net.iwobi.game.flickworldcup.iap.consumable.threeheart", 1),
    FOURHEART("net.iwobi.game.flickworldcup.iap.consumable.fourheart", 1),
    FIVEHEART("net.iwobi.game.flickworldcup.iap.consumable.fiveheart", 1);
   
    private String sku;
    private int quantity;

    private MySKU(String sku, int quantity) {
        this.sku = sku;
        this.quantity = quantity;
    }

    public static MySKU valueForSKU(String sku) {
        if (ONEHEART.getSku().equals(sku)) {
            return ONEHEART;
        }
       
        if (TWOHEART.getSku().equals(sku)) {
            return TWOHEART;
        }

        if (THREEHEART.getSku().equals(sku)) {
            return THREEHEART;
        }

        if (FOURHEART.getSku().equals(sku)) {
            return FOURHEART;
        }

        if (FIVEHEART.getSku().equals(sku)) {
            return FIVEHEART;
        }
       
        return null;
    }

    public String getSku() {
        return sku;
    }

    public int getQuantity() {
        return quantity;
    }

    private static Set<String> SKUS = new HashSet<String>();
    static {
        SKUS.add(ONEHEART.getSku());
        SKUS.add(TWOHEART.getSku());
        SKUS.add(THREEHEART.getSku());
        SKUS.add(FOURHEART.getSku());
        SKUS.add(FIVEHEART.getSku());
    }

    public static Set<String> getAll() {
        return SKUS;
    }

 }

 AndroidManifest.xml中在application标签下添加如下配置:
        <receiver android:name="com.amazon.inapp.purchasing.ResponseReceiver" >
            <intent-filter>
                <action
                    android:name="com.amazon.inapp.purchasing.NOTIFY"
                    android:permission="com.amazon.inapp.purchasing.Permission.NOTIFY" />
            </intent-filter>
        </receiver>
 有了以上代码,我们的内购就可以运行起来了。
   

* 内购测试

使用Amazon In-app Purchasing API一个最大好处就是测试简单。Amazon提供一个本地测试程序Amazon App Tester(安装到Android模拟器中),可以模拟内购Server,SDK自动判断当前场景,如果是测试,你的集成了内购SDK的游戏将连接本地 测试程序完成内购流程。通过在本地测试程序中设置模拟不同的内购流程,我们可以轻松完成测试。

你需要给Amazon App Tester提供一个名为amazon.sdktester.json的文件,这样Amazon App Tester可以知道你的游戏有哪些内购项目,并模拟出这些内购项目。这个json文件可以自行编辑,也可以在Amazon deveoper网站上生成下载。

我直接将内购项目添加到我的Amazon帐号的游戏应用下面,一共五个,添加成功后,下载json文件。将该文件放在模拟器的/mnt/sdcard下,绝对路径为/mnt/sdcard/amazon.sdktester.json。

之后,启动App Tester,再启动你的游戏,点击内购项目,看看是否能购买成功。

* 内购上线

按照Amazon官方说法,SDK会自动区分测试场景和正式场景,因此通过App Tester测试的游戏在发布后,理论上内购是没有问题的。不过我上线后还是遇到了问题,即点击购买某个项目后,游戏没有任何反应,等了若干分钟都是这 样。我将这个问题反馈给Amazon Support,得到的答复居然是游戏代码没有问题,他们测试了若干中机型,都可以打开内购页面,并进行内购。只是有时内购页面打开有些延迟,但都能打 开。看到这里,我猜是否又是大陆网络的问题呢!不管它了,至少通过Amazon Support的回复可以证明我的代码是ok的。只能希望美国人民多多购买我的内购项目了^_^。

* Amazon游戏圈

想给游戏增加成就榜和成就提交功能,如果自己实现服务端,显然麻烦,工作量大不说,还得维护一个Server。但市面上提供这类服务的游戏平台不多。 Google Play的游戏Service提供这种服务,不过还是上面提到的原因,我与Google的这个服务无缘啊。Amazon Game SDK后期推出了GameCircle服务。

GameCircle目前提供achievements, leaderboards和Whispersync三种特性:
    achievements就是奖励机制,帮助游戏提高玩家粘性。
    leaderboards类似于积分榜,可以用于提交玩家积分以及显示玩家的全球排名。
    Whispersync是一种数据游戏同步服务,同步玩家进度,保寸玩家个性化数据等。

这里我要用到的是leaderboards。

    1、建立GameCircle
    使用游戏圈前,你需要在Amazon官方的Amazon Apps & Services Developer Console下创建属于你的Game Circle,然后创建一个LeaderBoard,设置LeaderBoard属性。SDK中提供了GameCircle的Demo:AmazonSDK/Android/GameCircle

    2、导入jar包,设置AndroidManifest.xml
    要想使用GameCircle,我们需要导入相应的SDK jar包:gamecirclesdk.jar。

    在AndroidManifest.xml中,需要在application标签下添加以下配置:

                <activity
            android:name="com.amazon.ags.html5.overlay.GameCircleUserInterface"
            android:hardwareAccelerated="false"
            android:theme="@style/GCOverlay" >
        </activity>
        <activity
            android:name="com.amazon.identity.auth.device.authorization.AuthorizationActivity"
            android:allowTaskReparenting="true"
            android:launchMode="singleTask"
            android:theme="@android:style/Theme.NoDisplay" >
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <data
                    android:host="net.iwobi.game.flickworldcup"
                    android:scheme="amzn" />
            </intent-filter>
        </activity>
        <activity
            android:name="com.amazon.ags.html5.overlay.GameCircleAlertUserInterface"
            android:hardwareAccelerated="false"
            android:theme="@style/GCAlert" >
        </activity>

        <receiver
            android:name="com.amazon.identity.auth.device.authorization.PackageIntentReceiver"
            android:enabled="true" >
            <intent-filter>
                <action android:name="android.intent.action.PACKAGE_INSTALL" />
                <action android:name="android.intent.action.PACKAGE_ADDED" />

                <data android:scheme="package" />
            </intent-filter>
        </receiver>

   
        这些配置中需要的res,可以从AmazonSDK/Android/GameCircle/GameCircleSDK/res/中找到并copy到你的project中。

    3、初始化GameCircle

    GameCircleSDK这个Demo中没有提供太多源码,src目录下是空的。因此我们只能参考Amazon Developer站点上页面上的说明一步步的添加和调整我们的代码了。

    在你的XXActivity类中,我们添加如下方法:

    //reference to the agsClient
    public AmazonGamesClient agsClient;
    
    AmazonGamesCallback callback = new AmazonGamesCallback() {
            @Override
            public void onServiceNotReady(AmazonGamesStatus status) {
                Message msg = new Message();
                switch (status) {
                // The SDK failed to initialize correctly.
                case CANNOT_INITIALIZE:
                    Log.i(TAG, "onServiceNotReady: CANNOT_INITIALIZE");
                    msg.obj = "Can not initialize Amazon Game Services";
                    break;

                // The SDK is in the process of initializing.
                case INITIALIZING:
                    Log.i(TAG, "onServiceNotReady: INITIALIZING");
                    msg.obj = "Initializing Amazon Game Services";
                    break;

                // The device not registered with an account
                case NOT_AUTHENTICATED:
                    Log.i(TAG, "onServiceNotReady: NOT_AUTHENTICATED");
                    msg.obj = "The Device does not registered with an account";
                    break;

                // The game is not authorized to use this service.
                case NOT_AUTHORIZED:
                    Log.i(TAG, "onServiceNotReady: NOT_AUTHORIZED");
                    msg.obj = "Not authorized to use Amazon Game Services";                   
                    break;
                }
               
                //unable to use service
                msg.what = 21;               
                notifyHandler.sendMessage(msg);
            }
            @Override
            public void onServiceReady(AmazonGamesClient amazonGamesClient) {
                agsClient = amazonGamesClient;
             
                //ready to use GameCircle
                if (agsClient != null)
                    Log.i(TAG, "on AmazonGamesCallback: call onServiceReady, agsClient init ok");
                else
                    Log.i(TAG, "on AmazonGamesCallback: call onServiceReady, agsClient init failed");
            }
    };
    
    //list of features your game uses (in this example, achievements and leaderboards)
    EnumSet<AmazonGamesFeature> myGameFeatures = EnumSet.of(
            AmazonGamesFeature.Leaderboards);

    protected void onResume() {
        super.onResume();
       
        … …
        AmazonGamesClient.initialize(this, callback, myGameFeatures);
    }

        public void onPause() {
        super.onPause();
        if (agsClient != null) {
            agsClient.release();
        }
    }

   
    4、提交成就积分

    当玩家结束游戏时,可以选择将此次的高分上传到leaderboards上。游戏中应对积分提交的代码也在XXActivity中。

    public static void onSubmitScoreToLeaderBoard(int score) {
        if (((FlickWorldCupActivity)context).agsClient == null) {
            Message msg = new Message();
            msg.what = 21;
            msg.obj = "Unable to use Amazon Game Services";
            notifyHandler.sendMessage(msg);
            return;
        }
       
        LeaderboardsClient lbClient = ((FlickWorldCupActivity)context).agsClient.getLeaderboardsClient();
        AGResponseHandle<SubmitScoreResponse> handle = lbClient.submitScore("FlickWorldCupTopScore", score);
         
        // Optional callback to receive notification of success/failure.
        handle.setCallback(new AGResponseCallback<SubmitScoreResponse>() {
         
            @Override
            public void onComplete(SubmitScoreResponse result) {
                if (result.isError()) {
                    // Add optional error handling here.  Not strictly required
                    // since retries and on-device request caching are automatic.
                    Message msg = new Message();
                    msg.what = 22;
                    msg.obj = "Submit Score to LeaderBoard Failed!";
                    notifyHandler.sendMessage(msg);
                } else {
                    // Continue game flow.
                    Message msg = new Message();
                    msg.what = 23;
                    msg.obj = "Submit Score to LeaderBoard OK!";
                    notifyHandler.sendMessage(msg);
                }
            }
        });       
    }

    如果仅是查看积分排行,可以用下面这个方法:

    public static void onShowLeaderBoardOverlay() {
        if (((FlickWorldCupActivity)context).agsClient == null) {
            Message msg = new Message();
            msg.what = 21;
            msg.obj = "Unable to use Amazon Game Services";
            notifyHandler.sendMessage(msg);
            return;
        }
       
        LeaderboardsClient lbClient = ((FlickWorldCupActivity)context).agsClient.getLeaderboardsClient();
        AGResponseHandle<RequestResponse> handle = lbClient.showLeaderboardOverlay("FlickWorldCupTopScore");
       
        handle.setCallback(new AGResponseCallback<RequestResponse>() {
            
            @Override
            public void onComplete(RequestResponse result) {
                if (result.isError()) {
                    // Add optional error handling here.  Not strictly required
                    // since retries and on-device request caching are automatic.
                    Log.i(TAG, "onShowLeaderBoardOverlay – onComplete: Show LeaderBoard Request Failed!");
                }
            }
        });     
    }

   
* 游戏圈上线

游戏圈无法在本地进行测试,只能在真实的游戏圈中测试代码是否ok。不过Amazon的游戏圈提供了管理功能,在测试后发布前可将游戏圈 leaderboard的值reset。游戏圈leaderboard发布后,你就可以使用leaderboard了。游戏圈功能在国内访问是没有任何问 题的,查看积分榜,提交分数到积分榜都很顺畅。

* 小结

Amazon游戏SDK在国内的应用估计比较小众,大家可能更多的选择用Google Play提供的服务或是AppStore的,但Amazon毕竟为游戏开发者提供了一个选择(而且是完全免费的哦),另外Amazon的Support对 提交问题的反馈较为及时(无论是mail还是forum上的提问),基本24小时内就会有答复。各种设施的发布也比较快,有时候3-4个小时即可生效。

目前Amazon Game SDK的资料多为英文,且集中在Amazon官方站点以及官方维护的support论坛中。遇到问题,亚马逊的论坛是第一选择。

世界足球的那个“王”还会出现吗?

No Comments

准球王梅西最终没能将巴西世界杯的决赛赛场变成自己到加冕地,潘帕斯雄鹰阿根廷连续第三届世界杯被德意志战车践踏,让我这个老阿迷痛心不已。

足球是一种信仰,在足球这个信仰的世界里有神,更要有王。但现代足球趋向整体的战术体系让类似贝利、马拉多纳那样的“王”的出现日益困难。足球界、球迷们 实际上都期望新“王”的诞生,这样才能带来更多的信仰满足感和成就感。因此每当有天赋异秉的球员出现时,大家都会给予足够的关注目光。梅西就是从2005 年世青赛一直被关注到今天上午的决赛赛场的。但就是像梅西这样50年不遇的足球天才也没能最终加冕为像马拉多纳那样的“王”,世界足球的“王”依旧缺失!

我们不禁要问,足球界的那个“王”还会出现吗?现实似乎给了我们比较悲观的答案。虽说很多人认为球王已不再需要世界杯去证明,但传统认知的惯性还是会影响诸多球迷:新球王就应该像贝利和马拉多纳那样拿下大力神杯。这里提到的“王”依旧遵循着传统的认知。

1、整体足球无法诞生“球王”

足球百年历史上公认的球王只有两个:贝利和马拉多纳。两人都来自南美洲,两人都在各自的世界杯舞台上有着被公认是个人英雄式的表演,这让全世界球迷顶礼膜 拜。但在今天整体足球逐渐成为主流趋势的情况下,我们再也很难见到想马拉多纳那样以一己之力带领球队拿到世界杯的表演了。2010年的西班牙,2014年 的德国都是整体足球、团队足球的典型代表,他们虽然夺冠了,但我们无法从中选出一个马拉多纳式的人物,冠军的成绩依靠的是整体的流畅运转,就像我们评价此 次德国队的那样,球队更像一个机器,每个螺丝,每个细节都是关键。

从此次世界杯的表现来看,要说不够“整体”的,还真的只有阿根廷了,这届梅西的表现其实已经十分接近马拉多纳当年的作用了,没能成王十分可惜。巴西队和德国、西班牙比起来,整体性也有不足,似乎具有诞生球王的潜质。但巴西、阿根廷在未来真的能诞生新王吗?我们继续分析。

2、巴西、阿根廷足球的"衰落"

2002年巴西在亚洲夺冠,让人们看到南美足球似乎在恢复统治力。但随着德国、西班牙对青训体系的计划、投入以及严格执行,西班牙、德国从2002年后诞 生了一大批年轻有天赋的足球青年,在2010、2014年,西班牙和德国先后捧杯就是一流青训计划带来的结果。反观这一时段的巴西和阿根廷队,人才青黄不 接。巴西中前场再无3R组合的豪华,阿根廷更加悲惨,不仅后卫趋向三流,进攻组织型中场也销声匿迹,唯独锋线还能拿得出手。更让南美球迷担心的是,巴西、 阿根廷本土联赛的水准日渐下滑,从近几次世俱杯的欧美对抗结果即可看出。巴西、阿根廷绝对不缺少天才型球员,缺少的则是像德国那样的长远规划和强有力的执 行。

另外巴阿两国在球员培养和输送方面开始“拜金”,什么样的球员受欧洲球队欢迎就培养什么位置的球员。哪个俱乐部给钱多,就把球员卖给哪个俱乐部。这导致大 量天才球员登陆欧洲的第一站往往是俄罗斯这样的三流联赛,让这些球员失去了向一流球员学习经验的机会,天赋逐渐被磨灭,经验也都是三流的,没法登上一流的 赛场上,逐渐变得平庸。另外南美球员由于分布在不同国家联赛,导致打法很难统一,一到国家队磨合起来十分困难,间接削弱了国家队的战斗力。

3、欧洲足球是“反球王”模式的

欧洲依旧是世界足球的中心,这有利于欧洲国家的球员在一支球队内磨合和统一风格。比如2010的西班牙,以巴萨为班底;本届的德国以拜仁为班底。核心球员 在俱乐部里配合默契娴熟,到了国家队基本不需要磨合就可以发挥出100%战力,让整体足球体现的淋漓尽致。但这种技术领先、团队合作的欧洲足球是反球王模 式的,这在相当大的程度上阻止了像阿根廷、巴西这样具有“王”诞生条件的国家队站在世界杯的最高领奖台上。

综上三点,世界足球“新球王”诞生真的不再乐观。也许我们依旧能看到像齐达内这样的神,但短时间内很难再看到马拉多纳这样的王的出现。梅西在这届世界杯上 已经尽力了,但依旧无法称王,我实在难以想象得到未来几十年中还能有像梅西这样天赋异秉的足球天才的出现。巴西、阿根廷足球体系的衰落也让王的出现更为难 上加难。

Cocos2d-x屏幕适配之Sprite绘制原理

8 Comments

手机(智能终端)游戏绝大多数为全屏(Full Screen)显示,这样开发人员在制作游戏时势必要考虑不同手机(智能终端)屏幕大小、宽高比的不同给游戏画面带来的影响,并且要将这种影响降低到最 小,努力使用不同终端的游戏玩家拥有几乎相同的游戏画面体验。为此各种游戏引擎在屏幕适配方面都给出了自己的方案,Cocos2d-x也不例外。 在Cocos2d-x官网Wiki上特地撰写了一篇讲解Cocos2d-x多屏幕适配原理的文章“Detailed explanation of Cocos2d-x Multi-resolution adaptation”。

这里我们以Cocos2d-x引擎(基于2.2.2版本)自带的Sample项目HelloCpp(cocos2d-x-2.2.2/samples/Cpp/HelloCpp)为例,直观的看看这个方案带来的好 处。首先,我们对HelloCpp项目做些许改造:
    – 注释掉AppDelegate.cpp中applicationDidFinishLaunching下的pEGLView->setDesignResolutionSize(designResolutionSize.width, designResolutionSize.height, kResolutionNoBorder);
    – 仅使用Resource/iphone下的资源,即仅searchPath.push_back(smallResource.directory); 这里我们有一张480×320分辨率大小PNG文件。
    – 通过改变proj.linux/main.cpp中的eglView->setFrameSize(960, 640);来改变屏幕参数。(用linux工程模拟甚为方便,编译和运行占用资源小,极为迅捷,效果与Android平台是等 效的)

我们对比一下以下三种条件下的游戏Demo显示结果:
    1) 屏幕大小480×320,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。
    2) 屏幕大小960×640,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。
    3) 屏幕大小同为960×640,按照上面Cocos2d-x屏幕适配指南Wiki中的做法,调用pEGLView->setDesignResolutionSize(480, 320);

如我们所料,我们得到三个截然不同的结果。

第一种情况,我们所得到的游戏屏幕截图如下:

第二种情况,我们所得到的游戏屏幕截图如下:

第三种情况,我们所得到的游戏屏幕截图如下:

第一种情况是最理想的情况,屏幕大小与背景图片大小相同,如我们所愿,屏幕与背景图片吻合的天衣无缝。
第二种情况显然是模拟我们初次遇到问题的场景。屏幕Size扩大为原先的二倍,在资源没有变化的情况下,我们发现480×320大小的背景图片没 有铺满屏幕,仅仅是居中显示,并在四周露出较多”黑边“,这显然不是我们想要的。
第三种情况,也就是我们按照官方屏幕适配方案调整后得到的结果,在资源依旧不变的情况下,我们得到了相对令人满意的结果:背景图片恰如其分的铺满 整个屏幕,比例正确。这样我们用一套资源就可以同时适配两个屏幕了:480×320、960×640。这两种终端的玩家至少不会对我们的游戏心生 抱怨之情^_^。

当然在遇到第二种情况的时候,你也大可再准备一套新资源,比如一张960×640的背景图片。在480×320手机上,使用480×320的图 片;在960×640的手机上,使用960×640的背景图片。但这种方法的弊端至少有三:
    – 包大了:游戏的安装包Size急剧变大。
    – 活儿多了:因适配屏幕种类太多而制作大量的图片。
    – 新屏幕出来咋办:如果某个厂家突然于某天出品一款手机,其分辨率与以往市面上的所有手机均不同,那你的游戏因没有对应的资源,肯定无法很好适配该手机,导 致较差用户体验。

为此,适配屏幕唯一的出路似乎只有按照官方推荐的方案进行了,当然适当结合有限种类的资源也许可以更好的提升游戏体验。

如果仅仅从游戏制作角度来看,我们找到了可以适配屏幕的方法就可以了,没有必要刨根问底。甚至当有人问起来:为何 setDesignResolutionSize后,背景图片就可以充满屏幕了呢?我们可以回答:“引擎对精灵进行了缩放,就是这样”。但对于上 面的背景精灵来说,真的是我们理解的普通意义上的“精灵缩放(Scale)吗?本着“知其然,也要知其所以然”的精神,这里对引擎如何对 Sprite进行绘制进行了一番研究,我还真发现了一些与我之前理解差异较大的“深奥”原理,这里与大家一起分享一下。

一、绘制参数初始化

我们还是从代码开始,了解一下引擎绘制参数的初始化工作是如何做的、在哪里做的,为后续的分析做些铺垫。这里以Cocos2d-x 2.2.2 Android平台为例。关于Cocos2d-x 2.2.2 Android平台的引擎粗线条启动流程分析,可以参考《Hello,Cocos2d-x》这篇文章。看完这篇文章,你就会知道我们这次应该从Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit开 始。

// samples/Cpp/HelloCpp/proj.android/jni/hellocpp/main.cpp
void Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit(
               JNIEnv*  env, jobject thiz, jint w, jint h)
{
    if (!CCDirector::sharedDirector()->getOpenGLView())
    {
        CCEGLView *view = CCEGLView::sharedOpenGLView();
        view->setFrameSize(w, h);

        AppDelegate *pAppDelegate = new AppDelegate();
        CCApplication::sharedApplication()->run();
    }
    … …
}

这里是引擎部分初始化的起点:CCDirector和CCEGLView先后完成创建与初始化。接下来我们分别看一下这两个过程,我们主要关 注与绘制参数设置相关的内容:

bool CCDirector::init(void)
{
    setDefaultValues();

    … …
    m_obWinSizeInPoints = CCSizeZero;

    m_pobOpenGLView = NULL;

    m_fContentScaleFactor = 1.0f;
    … …
    return true;
}

void CCDirector::setDefaultValues(void)
{
    CCConfiguration *conf =
     CCConfiguration::sharedConfiguration();
    … …
    // GL projection
    const char *projection =
        conf->getCString("cocos2d.x.gl.projection",
                         "3d");
    if( strcmp(projection, "3d") == 0 )
        m_eProjection = kCCDirectorProjection3D;
    … …
}

由于conf中没有配置“cocos2d.x.gl.projection”,因此projection使用了 getCString传入的默认值:"3d",m_eProjection则被赋值为kCCDirectorProjection3D。

CCEGLView的创建更为简单:

CCEGLView::CCEGLView()
{
    initExtensions();
}

但背后真正发挥关键作用的是其父类CCEGLViewProtocol。

CCEGLViewProtocol::CCEGLViewProtocol()
: m_pDelegate(NULL)
, m_fScaleX(1.0f)
, m_fScaleY(1.0f)
, m_eResolutionPolicy(kResolutionUnKnown)
{
}

这里我们看到了三个重要的字段:m_fScaleX、m_fScaleY以及m_eResolutionPolicy,这三个字段对于后续屏 幕适配起到至关重要的作用。

nativeInit中的view->SetFrameSize(w, h)用于设置的屏幕物理分辨率,如果你的手机是960×640分辨率的,那FrameSize就是960×640。

void CCEGLViewProtocol::setFrameSize(float width,
                                     float height)
{
    m_obDesignResolutionSize
      = m_obScreenSize
      = CCSizeMake(width, height);
}
初始情况下,CCEGLViewProtocol将“设计分辨率”m_obDesignResolutionSize也设置为与 FrameSize or m_obScreenSize同等大小。

我们回到游戏逻辑层代码AppDelegate.cpp,我们知道游戏逻辑的入口在这里,最初的参数初始化是在为Director设置 GLView实例时进行的:

bool AppDelegate::applicationDidFinishLaunching() {
    // initialize director
    CCDirector* pDirector = CCDirector::sharedDirector();
    CCEGLView* pEGLView = CCEGLView::sharedOpenGLView();

    pDirector->setOpenGLView(pEGLView);
    CCSize frameSize = pEGLView->getFrameSize();
    … …
}

void CCDirector::setOpenGLView(CCEGLView *pobOpenGLView)
{
        m_pobOpenGLView = pobOpenGLView;

        // set size
        m_obWinSizeInPoints =
           m_pobOpenGLView->getDesignResolutionSize();
        … …

        if (m_pobOpenGLView)
        {
            setGLDefaultValues();
        }

        CHECK_GL_ERROR_DEBUG();
        … …
    }
}

由于尚未调用setDesignResolutionSize,因此m_obWinSizeInPoints的值与FrameSize大小相 同。

setGLDefaultValues最为关键,这是我们第一次遇到该函数,该方法用于初始化一些OpenGL的参数,建立好后续 OpenGL操作时所需要的各种数据结构。

void CCDirector::setGLDefaultValues(void)
{
    … …
    setAlphaBlending(true);
    setDepthTest(false);
    setProjection(m_eProjection);
    // set other opengl default values
    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
}

glClearColor(0.0f, 0.0f, 0.0f, 1.0f);设置初始颜色为黑色,alpha为1.0f,即完全不透明。setProjection是实际上绘制参数设置的核心。

void CCDirector::setProjection(ccDirectorProjection kProjection)
{
    CCSize size = m_obWinSizeInPoints;

    setViewport();
   
    switch (kProjection)
    {
    case kCCDirectorProjection3D:
        {
            float zeye = this->getZEye();

            kmMat4 matrixPerspective, matrixLookup;

            kmGLMatrixMode(KM_GL_PROJECTION);
            kmGLLoadIdentity();

            … …

            // issue #1334
            kmMat4PerspectiveProjection( &matrixPerspective,
                   60,
                  (GLfloat)size.width/size.height,
                   0.1f, zeye*2);

            kmGLMultMatrix(&matrixPerspective);

            kmGLMatrixMode(KM_GL_MODELVIEW);
            kmGLLoadIdentity();
            kmVec3 eye, center, up;
            kmVec3Fill( &eye, size.width/2,
                   size.height/2, zeye );
            kmVec3Fill( &center, size.width/2,
                   size.height/2, 0.0f );
            kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
            kmMat4LookAt(&matrixLookup, &eye,
                         &center, &up);
            kmGLMultMatrix(&matrixLookup);
        }
        break;
        … …
    }

    m_eProjection = kProjection;
    ccSetProjectionMatrixDirty();
}

由于前面m_eProjection已经被赋值为kCCDirectorProjection3D,因此我们只分析 kCCDirectorProjection3D这个case分支。该函数大致进行设置的顺序是:设置视口变换(ViewPort)、设置投影变换矩阵和 设置模型视图变换矩阵。我们分别来看:

 * 设置视口(ViewPort)

void CCDirector::setViewport()
{
    if (m_pobOpenGLView)
    {
        m_pobOpenGLView->setViewPortInPoints(0, 0,
              m_obWinSizeInPoints.width,
              m_obWinSizeInPoints.height);
    }
}

void CCEGLViewProtocol::setViewPortInPoints(float x ,
                     float y , float w , float h)
{
    glViewport((GLint)(x * m_fScaleX
               + m_obViewPortRect.origin.x),
               (GLint)(y * m_fScaleY
               + m_obViewPortRect.origin.y),
               (GLsizei)(w * m_fScaleX),
               (GLsizei)(h * m_fScaleY));
}

这是我们遇到的第一个OpenGL概念:设置视口变换,关于视口变换究竟起到什么作用,后续会细说。

 * 设置“投影变换”矩阵参数

 kmMat4PerspectiveProjection( &matrixPerspective, 60,
        (GLfloat)size.width/size.height, 0.1f, zeye*2);
 kmGLMultMatrix(&matrixPerspective);

 * 设置“模型视图变换”矩阵参数

 kmVec3 eye, center, up;
 kmVec3Fill( &eye, size.width/2,
             size.height/2, zeye );
 kmVec3Fill( &center, size.width/2,
             size.height/2, 0.0f );
 kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
 kmMat4LookAt(&matrixLookup, &eye,
             &center, &up);

至此,引擎的绘制参数初始化设置就OK了,在你调用setDesignResolutionSize之前,这些参数不会被改变。

二、kazmath

Cocos2d-x引擎最底层采用OpenGL ES 2.0进行图形绘制,这样要想搞清楚前面的问题缘由,对OpenGL那一套技术体系至少要有一些直观认识才行。在这之前,我们还要先了解一些 Cocos2d-x深度使用的kazmath库。根据《Cocos2d-x高级开发教程》书 中说: “因为在Cocos2d-x 2.0采用的OpenGL ES 2.0中,而那些OpenGL ES 1.0函数已经不可使用了。但OpenGL ES 2.0已经放弃了固定的渲染流水线,取而代之的是自定义的各种着色器,在这种情况下变换操作通常需要由开发者来维护。所幸引擎也引入了一套第三方库 Kazmath,它使得我们几乎可以按照原来OpenGL ES 1.0所采用的方式进行开发”。

至此,我们大致知道了Kazmath库是用来辅助我们按照OpenGL ES 1.0的方式管理变换矩阵以及做变换操作的,接下来我们一起来看看kazmath库的结构吧:

//cocos2d-x-2.2.2/cocos2dx/kazmath/src/GL/matrix.c

km_mat4_stack modelview_matrix_stack;
km_mat4_stack projection_matrix_stack;
km_mat4_stack texture_matrix_stack;
km_mat4_stack* current_stack = NULL;
static unsigned char initialized = 0;

以上是Cocos2d-x整个引擎生命周期内会用到的与opengl变换矩阵相关的一些全局变量。

kazmath声明了三个变换矩阵的栈,modelview_matrix_stack(模型视图矩阵栈)、 projection_matrix_stack(投影矩阵栈)以及texture_matrix_stack(纹理矩阵栈)。不过Cocos2d-x引 擎只用到了前两个变化矩阵栈。current_stack指向当前所使用的那个变换矩阵栈。

这些栈的初始化在lazyInitialize中:

void lazyInitialize()
{

    if (!initialized) {
        kmMat4 identity; //Temporary identity matrix

        //Initialize all 3 stacks
        //modelview_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&modelview_matrix_stack);

        //projection_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&projection_matrix_stack);

        //texture_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&texture_matrix_stack);

        current_stack = &modelview_matrix_stack;
        initialized = 1;

        kmMat4Identity(&identity);

        //Make sure that each stack has the identity matrix
        km_mat4_stack_push(&modelview_matrix_stack, &identity);
        km_mat4_stack_push(&projection_matrix_stack, &identity);
        km_mat4_stack_push(&texture_matrix_stack, &identity);
    }
}

kmMat4Identify用于初始化“单位矩阵(Indentify Matrix)”,所谓"单位矩阵",指的是对脚线上元素都为1的矩阵。从kmMat4Identify的实现,我们也可以看出这一点:

kmMat4* const kmMat4Identity(kmMat4* pOut)
{
    memset(pOut->mat, 0, sizeof(float) * 16);
    pOut->mat[0] = pOut->mat[5]
     = pOut->mat[10]
     = pOut->mat[15] = 1.0f;

    return pOut;
}

最后,lazyInitialize函数将单位矩阵分别圧入(km_mat4_stack_push)不同的matrix stack。

再回顾一下CCDirector::setProjection,该函数通过kazmath先后设置了 projection_matrix_stack和modelview_matrix_stack的top元素。

   kmGLMatrixMode(KM_GL_PROJECTION);
   kmGLLoadIdentity();
   kmMat4PerspectiveProjection( &matrixPerspective, 60,
     (GLfloat)size.width/size.height, 0.1f, zeye*2);
   kmGLMultMatrix(&matrixPerspective);
  
   kmGLMatrixMode(KM_GL_MODELVIEW);
   kmGLLoadIdentity();
   kmVec3 eye, center, up;
   kmVec3Fill( &eye, size.width/2,
               size.height/2, zeye );
   kmVec3Fill( &center, size.width/2,
               size.height/2, 0.0f );
   kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
   kmMat4LookAt(&matrixLookup, &eye,
               &center, &up);
   kmGLMultMatrix(&matrixLookup);

三、精灵绘制

由《Hello,Cocos2d-x》一文我们知道,一旦引擎初始化完毕,就开始了每帧图像的绘制工作,Render Thread在一个“死循环”中反复调用CCDirector的drawScene方法 (CCDisplayLinkDirector::mainLoop中调用了drawScene):

void CCDirector::drawScene(void)
{
    … …
    glClear(GL_COLOR_BUFFER_BIT
           | GL_DEPTH_BUFFER_BIT);
    … …
    kmGLPushMatrix();

    // draw the scene
    if (m_pRunningScene)
    {
        m_pRunningScene->visit();
    }
    … …
    kmGLPopMatrix();
    … …
}

Cocos2d-x采用“渲染树”的方式进行绘制,即先从场景(Scene)的顶层根节点开始,深度优先的递归绘制Child Node。而整个绘制的顶层节点是CCScene。绘制从m_pRunningScene->visit()真正开始。visit是Scene、 Layer、Sprite的共同父类CCNode实现的方法:

void CCNode::visit()
{
    if (!m_bVisible)
    {
        return;
    }
    kmGLPushMatrix();
    … …
    this->transform();
    … …
   
    if(m_pChildren &&
       m_pChildren->count() > 0)
    {
        sortAllChildren();
        // draw children zOrder < 0
        … ..
        // self draw
        this->draw();

        // draw other children nodes
        … …
    } else {
        this->draw();
    }
    … …
    kmGLPopMatrix();
}
   
Visit大致做了这么几件事:
    – 向当前OpenGL变换矩阵栈Push元素
    – 用当前OpenGL变换矩阵栈栈顶元素的变换参数做节点变换
    – 递归绘制zOrder < 0 的子节点
    – 绘制自己
    – 递归绘制其他子节点
    – 从当前OpenGL变换矩阵栈Pop元素

如果你想知道为什么父节点缩放(Scale)、旋转(Rotate)、扭曲(Skew)后,子节点也会跟着父节点同样缩放(Scale)、旋 转(Rotate)、扭曲?其原理就在这里的transform方法中:

void CCNode::transform()
{
    kmMat4 transfrom4x4;

    // Convert 3×3 into 4×4 matrix
    CCAffineTransform tmpAffine
       = this->nodeToParentTransform();
    CGAffineToGL(&tmpAffine,
                 transfrom4x4.mat);

    // Update Z vertex manually
    transfrom4x4.mat[14] = m_fVertexZ;

    kmGLMultMatrix( &transfrom4x4 );
    … …
}

在进入tranform以前,Cocos2d-x做了啥?对了,kmGLPushMatrix():

void kmGLPushMatrix(void)
{
    kmMat4 top;

    lazyInitialize();

    //Duplicate the top of the stack (i.e the current matrix)
    kmMat4Assign(&top, current_stack->top);
    km_mat4_stack_push(current_stack, &top);
}

在引擎初始化后,我们的current_stack是模型视图矩阵栈modelview_matrix_stack。所有设置的初始参数都保 存在该栈的栈顶元素中。在每次Node绘制前,Node都会创建自己的变换矩阵,但这个矩阵不是凭空创造的,从kmGLPushMatrix 可以看出,在当前Node将新创建的矩阵元素圧栈前,它复制了原栈顶元素,也就携带有父节点所有的初始变换信息,也就是说在 km_mat4_stack_push后,栈顶放置的元素其实是原栈顶元素的复制品,而后续所有操作都是基于这个复制品的。这样一来,如果父 节点做了缩放或旋转或扭曲,那这些信息都会作为初始信息作为子节点变换的基础,后续子节点自身的变换参数也都是在这个基础上做出的,最终的矩 阵是transform方法中的kmGLMultMatrix后得出的。真正的矩阵变换计算都在nodeToParentTransform 中,不过要想看懂这个函数,需要对OpenGL有更深入的了解才行,这里略过^_^。

真正绘制Node的方法是CCNode::draw的override方法。CCNode::draw是一个空函数,各个子类 override该方法进行各自的绘制。以CCSprite::draw为例:

void CCSprite::draw(void)
{
    CC_NODE_DRAW_SETUP();

    ccGLBlendFunc( m_sBlendFunc.src, m_sBlendFunc.dst );

    ccGLBindTexture2D( m_pobTexture->getName() );
    ccGLEnableVertexAttribs( kCCVertexAttribFlag_PosColorTex );

#define kQuadSize sizeof(m_sQuad.bl)
    long offset = (long)&m_sQuad;

    // vertex
    int diff = offsetof( ccV3F_C4B_T2F, vertices);
    glVertexAttribPointer(kCCVertexAttrib_Position, 3,
     GL_FLOAT, GL_FALSE, kQuadSize, (void*) (offset + diff));

    // texCoods
    diff = offsetof( ccV3F_C4B_T2F, texCoords);
    glVertexAttribPointer(kCCVertexAttrib_TexCoords, 2,
      GL_FLOAT, GL_FALSE, kQuadSize, (void*)(offset + diff));

    // color
    diff = offsetof( ccV3F_C4B_T2F, colors);
    glVertexAttribPointer(kCCVertexAttrib_Color, 4,
           GL_UNSIGNED_BYTE, GL_TRUE,
           kQuadSize, (void*)(offset + diff));

    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    … …
}

这里的draw是一个典型的OpenGL绘制工序。CC_NODE_DRAW_SETUP()将之前的经过若干准备而得到的最终各类变换矩阵 整合并传给OpenGL:

/** @def CC_NODE_DRAW_SETUP
 Helpful macro that setups the GL server state,
 the correct GL program and sets the Model View
 Projection matrix
 @since v2.0
 */
#define CC_NODE_DRAW_SETUP() \
do { \
    ccGLEnable(m_eGLServerState); \
    CCAssert(getShaderProgram(), "No shader program set for this node"); \
    { \
        getShaderProgram()->use(); \
        getShaderProgram()->setUniformsForBuiltins(); \
    } \
} while(0)

void CCGLProgram::setUniformsForBuiltins()
{
    kmMat4 matrixP;
    kmMat4 matrixMV;
    kmMat4 matrixMVP;

    kmGLGetMatrix(KM_GL_PROJECTION, &matrixP);
    kmGLGetMatrix(KM_GL_MODELVIEW, &matrixMV);

    kmMat4Multiply(&matrixMVP, &matrixP, &matrixMV);

    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformPMatrix],
                                    matrixP.mat, 1);
    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformMVMatrix],
                                    matrixMV.mat, 1);
    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformMVPMatrix],
                                    matrixMVP.mat, 1);
    … …
}

经过计算顶点、绑定纹理等步骤后,最终由glDrawArrays完成Node绘制。

四、m_fScaleX和m_fScaleY都是1.0,背景精灵为何被放大?

根据上面的分析,我们了解到“子节点将跟随父节点的缩放而缩放”。据此,我们来分析一下前面提到的屏幕适配例子中的第三种情况,即屏幕大小为 960×640,按照Cocos2d-x屏幕适配指南Wiki中的做法,调用 pEGLView->setDesignResolutionSize(480, 320)。在该情况中,我们得到的结果是480×320大小的背景图片充满了大小为960×640的屏幕窗口,这给我们的直观印象就是背景图片被放大了一 倍。下面我们就尝试用上面的分析来解释一下这个现象。

在这个例子中,渲染树结构如下:
   CCScene
        – CCLayer
            – CCSprite – 背景图精灵

按照之前的理论,背景图精灵自身或父类应该有缩放的设置,比如m_fScaleX = 2.0之类的设置,于是我在代码中输出了Scene、Layer以及Sprite的m_fScaleX和m_fScaleY值。但出乎预料的是,这些 Node子类的两个轴向缩放值都保持了默认值,即1.0f。在代码里翻了半天,也的确没有找到改写Scene、Layer或Sprite Scale的地方。又一想:代码中调用了setDesignResolutionSize,这样CCEGLView的m_fScaleX = m_fScaleY = 2.0f,难道是CCEGLView的m_fScale传递给了CCScene等Node子类,但事实总是残酷的,代表这一联系的代码也始终未被我所找 到,看来继续纠结m_fScale的值设置是无法搞清楚真正原因,应该换换思路了。这里背景图的放大不应该是Node scale值设置的问题,也就是说关键环节不应该在绘制流程,而是在之前的OpenGL变换矩阵参数设置,看来不再深入学习点OpenGL知识,这个问题 就很难搞定了,于是开始翻看《OpenGL编程指南7th》(号称OpenGL红宝书)和《OpenGL超级宝典》(号称OpenGL蓝宝 书)。虽然我的阅读是粗粒度的,但还是收获到了一些答案。

五、OpenGL基础

OpenGL是帮助我们将三维世界的物体转换到二维屏幕上的一组接口。在新技术尚未出现之前,我们的屏幕永远是二维的,即便是现在的3D电影 也是双眼视角二维图像叠加的结果。我们知道“将大象装进冰箱总共分三 步”,将一个三维模型转换到二维屏幕上,OpenGL也规定了相对流水线般的步骤。

OpenGL三维图形的显示流程

三维图形显示流程中,涉及到OpenGL的一个重要操作,那就是“变换(Transformation)”,主要的变换包括模型视图变换 (model-view transformation)、投影变换(projection transformation)以及视口变换(ViewPort transformation)。我们经常用相机模拟来对比OpenGL解决这一问题的过程以及相关概念。

回顾一下我们自己用相机拍照的步骤吧。

第零步,选景。景就是所谓的三维模型或三维物体,或简称模型(Model),就是我们要显示到屏幕上的物体;
第一步,确定相机位置。让相机以一定的距离、高度、角度对准模型。在这里,相机的位置变换,对应OpenGL的“视图变换或叫视点变换 (View Transformation)”。在这一步里(对应上面图中的第二步),我们还可以调整三维物体的相对位置、角度与相机的距离,这就是模型变换 (Modeling Transformation),两种变换达成的效果是相同的,因此总称模型视图变换(Model-View Transformation)。
第二步,选镜头,并调焦。确定图像投影在胶片上的范围以及景深等。这一步叫投影变换(Projection Transformation)。
第三步,冲洗照片。拍摄好的图像放在底片上,但我们需要选择冲洗后最终是放在6寸相纸还是20寸相纸上,显然在不同大小相纸上,图像的显示效 果不同(比如大小)。这个过程叫视口变换(Viewport Transformation)。

三维空间的物体都是用三维坐标描述的,谈到坐标就离不开坐标系,OpenGL中的坐标系就有多种,我们最常用的就是世界坐标系。

世界坐标系是以屏幕中心为原点(0, 0, 0),你面对屏幕,你的右边是x正轴,上面是y正轴,屏幕指向你的为z正轴。无论如何变换,世界坐标系都不动。我们在Cocos2d-x中设置 初始参数时,参数的单位多为世界坐标系中的单位。

视点变换时会涉及到视点坐标系,但这个变换由opengl接口来负责,我们不用过多关心。

绘图坐标系(局部坐标系),当前绘图坐标系是绘制物体时的坐标系。程序刚初始化时,世界坐标系和当前绘图坐标系是重合的,当用 glTranslatef()等变换函数做移动和旋转时,都是改变的当前绘图坐标系,改变的位置都是当前绘图坐标系相对自己的x,y,z轴所做的 改变,改变以后,再绘图时,都是在当前绘图坐标系进行绘图,所有的函数参数也都是相对当前绘图坐标系来讲的。

屏幕坐标系,即终端屏幕上的坐标系,与世界坐标系有不同,它以屏幕左上角的点为原点,向右是x正轴,向下是y正轴,屏幕指向你的为z正轴。

注意视口(Viewport)的设置是以实际屏幕坐标定义了窗口中的区域,长度宽度都是以实际像素为单位。当然引擎在精灵绘图时用 的是绘图坐标系,我们理解原点在左下角即可。

六、Cocos2d-x各种变换矩阵的初始参数设置

前面说过,Cocos2d-x在CCDirector::setProjection中完成了对变换矩阵的初始参数设置,我们逐一来看看这些设置对模型映射后的二维图像有何影响,这也是理解篇头几个问题的关键环节。

  * 投影变换
   
    前面提到过,投影变换相当于调节相机镜头。OpenGL中提供了两种投影方式,一种是正射投影,另一种是透视投影。Cocos2d-x使用的是透视投影 (Perspective Projection)。透视投影是实际人们观察事物的真实反馈,即离视点近的物体大,离视点远的物体小,远到极点即为消失,成为灭点。Cocos2d- x使用的是kmMat4PerspectiveProjection,对应OpenGL中的gluPerspective,该方法创建一个对称透视视景体 (View Volumn),见下图:

gluPerspective的函数原型如下:void gluPerspective(GLdouble fovy,GLdouble aspect,GLdouble zNear, GLdouble zFar);

    参数fovy定义视野在X-Z平面的角度,范围是[0.0, 180.0],也就是上图中的“视角”;
    参数aspect是投影平面宽度与高度的比率;
    参数zNear和Far分别是近远裁剪面沿Z负轴到视点的距离,它们总为正值。
  
Cocos2d-x中是这么设置投影变换矩阵的:

  float zeye = this->getZEye();
  kmMat4PerspectiveProjection( &matrixPerspective, 60, (GLfloat)size.width/size.height, 0.1f, zeye*2);

  float CCDirector::getZEye(void)
  {
    return (m_obWinSizeInPoints.height / 1.1566f);
  }

从参数上来看,
    视角 = 60度
    宽高比 = 设计分辨率的宽高比,
    近平面 = 距离视点0.1f,几乎与视点重合
    远平面 = 距离视点zeye * 2距离。
    视点位置 = 设计分辨率.height / 1.1566f

投影是用来对模型进行截取的,只有在投影变换所建立的平头截体(Frustum,投影的近、远两个截面以及其他四个面构成的立体体)内的模型部分才会被最终映射和显示。我们用下面的图来直观了解一下各个参数在三维空间的概念吧。

显然引擎如此设置投影矩阵的参数是有考虑的:
首先就是投影平头截体的宽高比 = 设计分辨率的宽高比,这样设置使得一切符合设计分辨率宽高比的模型都可以被理想截取。
其次,视角60度,zEye的在Z轴正方向距离世界原点的距离 = (m_obWinSizeInPoints.height / 1.1566f),这里的1.1566f是怎么来的呢?我们沿着X轴负方向向zy平面投影,得到下图:

看这个图,让我想起了初中几何,通过60度的视角,我们可以推断由eye、XZ截断上平面与Y轴的交点、XZ截断下平面与Y轴的交点组成一个等边三角形, 现在我们已知在Zy平面投影中视点与原点的距离为m_obWinSizeInPoints.height / 1.1566f, 我们还知道夹角是60度,我们求一下投影在(z=0,XY平面)的截面高度h。

cos30 = (m_obWinSizeInPoints.height / 1.1566f)/ h
h = (m_obWinSizeInPoints.height / 1.1566f)/cos30 = m_obWinSizeInPoints.height;

我们计算出来的结果是 h = m_obWinSizeInPoints.height = 设计分辨率中的高度分量。这意味这什么呢?Cocos2d-x是2D游戏渲染引擎,针对该引擎的模型的z坐标都是0,因此模型实际上就在xy平面内,也就 是说eye与原点的距离恰好就是eye与模型的距离,而模型可显示区域的最大高度也就是h,即m_obWinSizeInPoints.height。这 个结论会在后续问题分析时发挥作用。

注意虽然这里知道eye在Z轴正方向距离世界原点的距离,但eye的(x, y)坐标在投影设置后依旧无法确认,我们需要在设置模型视图变换时得到eye的(x, y)坐标。

  * 视图变换

    kmGLMatrixMode(KM_GL_MODELVIEW);
    kmGLLoadIdentity();
    kmVec3 eye, center, up;
    kmVec3Fill( &eye, size.width/2, size.height/2, zeye );
    kmVec3Fill( &center, size.width/2, size.height/2, 0.0f );
    kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
    kmMat4LookAt(&matrixLookup, &eye, &center, &up);
    kmGLMultMatrix(&matrixLookup);

OpenGL原生的视图变换参数设置方法是gluLookAt,在kazmath中对应的方法为kmMat4LookAt。gluLookAt的函数原型是:

    void gluLookAt(GLdouble eyex, GLdouble exey, GLdouble eyez,
       GLdouble centrex, GLdouble centrey, GLdouble centrez,
       GLdouble upx, GLdouble upy, GLdouble upz);

eye的坐标(eyex, eyey, eyez), Cocos2d-x中是这么设置的kmVec3Fill( &eye, size.width/2, size.height/2, zeye )。可以看出eye在xy平面的投影恰好是以屏幕分辨率构成的矩形的中心。

centre坐标,表示的是视线方向,该方向矢量是由eye坐标、centre坐标共同构成的,由eye指向center。Cocos2d-x的设置 kmVec3Fill( &center, size.width/2, size.height/2, 0.0f )。x, y坐标与eye的相同,因此视线平行于Z轴。

最后的up参数可以理解为头顶方向,这里设置为Y轴方向。

可以看出,eye就在投影区的中心,由于投影区的高度为size.height(投影变换时分析得到的),这样根据投影矩阵设置的宽高比,得出该投影区的宽度也恰为size.width。

七、再分析

有了以上关于Cocos2d-x引擎的了解,我们再回过头来用OpenGL的变换原理对篇头的三种情况做分析。

 1) 屏幕大小480×320,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。结果:背景图充满窗口。

    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(240, 160, 320/1.1566f);
        投影变换矩阵在xy平面的截面区域恰好是480×320;
        背景图锚点位置(240, 160, 0);

    在这种情况下,截面区域恰与背景图重合,显示在屏幕上后,背景图恰充满窗口,见下图:

   
   
 2) 屏幕大小960×640,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。结果:背景图未充满窗口,四周有较大黑边。
 
    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(480, 320, 480/1.1566f);
        投影变换矩阵在xy平面的截面区域是960×640;
        而背景图锚点位置(480, 320, 0);

    因此背景图(480×320)未能完整充满截面区域(960×640),背景图周围将有较大黑边,见下图:
   
     

 3) 屏幕大小同为960×640,按照上面Cocos2d-x屏幕适配指南Wiki中的做法,调用pEGLView->setDesignResolutionSize(480, 320)。结果:背景图放大为原来2倍,充满屏幕窗口。

    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(240, 160, 320/1.1566f);
        投影变换矩阵在xy平面的截面区域是480×320;
        而背景图锚点位置(240, 160, 0);

    在这种情况下,截面区域恰与背景图重合。但这里需要注意的是现在屏幕是960×640,而截面区域仅仅是480×320,为何映射后,背景图充满屏幕了呢?这里就不能不提到视口的作用了。

    前面说过视口相当于相片,现在我们拍摄出的图片是480×320的,但我们选择的底片Viewport却是960×640的,怎么办,在视口转换 时,OpenGL自动将480×320的图片映射到960×640的底片上,相当于对图像进行的放大。而960×640的视口恰好与屏幕窗口大小一致且坐 标重叠,于是我们就在屏幕上看到了一个铺满屏幕的背景图,见下图:

   

 4) 我们再来说两个有关视口的例子

    以第三种情况为基础,我们修改一下引擎代码,看看视口的作用。
   
    我们手工将CCDirector::setViewport()中的:
        m_pobOpenGLView->setViewPortInPoints(0, 0, m_obWinSizeInPoints.width, m_obWinSizeInPoints.height);
    改为:
        m_pobOpenGLView->setViewPortInPoints(0, 0, m_obWinSizeInPoints.width/2, m_obWinSizeInPoints.height/2);

    这样修改后,Viewport从point(0,0), rect (960×640)变成了point(0,0), rect (480×320)。也就是说用照相机拍出的景物大小是480×320,底片也是480×320,但屏幕是960×640,我们可以将屏幕理解为相框,把 一张480×320的照片,放到960×640大小的相框里,相片只能占据相框的四分之一。这个例子的最终屏幕显示结果见下图:

   

    前面的例子中背景图片size均小于屏幕大小,我们再来举一个资源图片大于屏幕大小的例子,看看经过一系列变换会得到什么样的结果。
   
    首先将CCDirector::setViewport()中的代码恢复原先状态。然后我们准备一张1024×768(>屏幕的960×640)的 背景图片"HelloWorld-1024×768.jpg",修改HelloWorldScene.cpp,将:
    CCSprite* pSprite = CCSprite::create("HelloWorld.png");
    修改为:
    CCSprite* pSprite = CCSprite::create("HelloWorld-1024×768.png");

    注释掉AppDelegate.cpp中的pEGLView->setDesignResolutionSize调用,这样更直观。

    这样修改后,各参数如下:
        eye视点坐标(480, 320, 640/1.1566f);
        投影变换矩阵在xy平面的截面区域是960×640;
        而背景图锚点位置(480, 320, 0);
        Viewport point(0,0), rect (960×640)
   
    由于背景资源图片太大(1024×768),大于我们的投影截面区域960×640,因此模型真正能显示的部分仅仅是投影截面区域中的那960×640范围内的图片。于是显示结果如下:

   

    矩阵变换过程如下:

   

    投影截面区域与视口区域重叠,这里就不再赘述了。

八、CCDirector::m_fContentScaleFactor

决定图像在屏幕上的最终显示结果的因素还有一个,那就是CCDirector::m_fContentScaleFactor。在最初的HelloCpp例子中,我们能看到这样的代码:

    if (frameSize.height > mediumResource.size.height)
    {
        searchPath.push_back(largeResource.directory);
        pDirector->setContentScaleFactor(
          MIN(largeResource.size.height/designResolutionSize.height,
              largeResource.size.width/designResolutionSize.width));
    }
    … …

    可以看出这个contentScaleFactor存储的是资源分辨率与设计分辨率的比值。我们还是用例子来看看该元素对显示的影响。我们在第一种情况的基础上验证。

    第一种情况:屏幕480×320,未调用setDesignResolutionSize,资源大小480×320。结果:图片充满屏幕。

    现在我们增加并使用一个新资源:HelloWorld-960×640.png,这个图片大小960×640,是屏幕大小的二倍,根据上面的分析,我们很容易猜测到最终结果是:只有图片中央区域(480×320)可以显示出来,其余部分被投影矩阵截掉。

    现在我们使用setContentScaleFactor,在AppDelegate.cpp中做如下调用:

    pDirector->setContentScaleFactor(MIN(960/480, 640/320));

    这样我们得到的m_fContentScaleFactor = 2。而我们编译运行后得到的结果是:图片铺满整个屏幕。为什么会这样呢?

    我们在代码中搜索contentScaleFactor,我们找到一些宏和调用:

   
#define CC_CONTENT_SCALE_FACTOR() CCDirector::sharedDirector()->getContentScaleFactor()

CCSize CCTexture2D::getContentSize()
{

    CCSize ret;
    ret.width = m_tContentSize.width / CC_CONTENT_SCALE_FACTOR();
    ret.height = m_tContentSize.height / CC_CONTENT_SCALE_FACTOR();

    return ret;
}

#define CC_RECT_PIXELS_TO_POINTS(__rect_in_pixels__)                                                                        \
    CCRectMake( (__rect_in_pixels__).origin.x / CC_CONTENT_SCALE_FACTOR(), (__rect_in_pixels__).origin.y / CC_CONTENT_SCALE_FACTOR(),    \
            (__rect_in_pixels__).size.width / CC_CONTENT_SCALE_FACTOR(), (__rect_in_pixels__).size.height / CC_CONTENT_SCALE_FACTOR() )

… …

bool CCSprite::initWithTexture(CCTexture2D *pTexture)
{
    CCAssert(pTexture != NULL, "Invalid texture for sprite");

    CCRect rect = CCRectZero;
    rect.size = pTexture->getContentSize();

    return initWithTexture(pTexture, rect);
}

    这些代码都在告诉我们,如果m_fContentScaleFactor = 2,那代码会对Sprite的纹理进行缩放,让上面得到的数据是经过contentScaleFactor变换的,我们可以认为我们所用的实际资源大小是 原资源的1/m_fContentScaleFactor即可。

Cocos2d-x 3.0rc0集成Google AdMob SDK

1 Comment

话说Cocos2d-x 3.0上一周迫不及待地发布了正式版,本是一件值得庆幸的事情。但由于不可解决的技术问题,引擎无奈将Android平台的NativeActivity 实现重新回退到了Cocos2d-x 2.2.x版本的实现方案。由于之前已经将 GameDemo移植到了Cocos2d-x 3.0rc0版,直观感受到了NativeActivity方案带来的游戏操作体验上的提升(触屏事件的响应),因此心里总是“挂念”引擎的 NativeActivity方案。按照Cocos2d-x官方人士的说法,只要NDK的问题修复,NativeActivity还是未来引擎在 Android平台上的第一选择。我的理解,将来Cocos2d-x的某个版本中还会恢复NativeActivity的实现方案。

上次只是将GameDemo的核心逻辑移植到了Cocos2d-x 3.0rc0上,但一些外部SDK集成尚未做完,这两天闲遐时开始着手研究如何做,而首先要移植的就是Google AdMob SDK。关于Cocos2d-x 3.0rc0集成Google AdMob SDK,网上已经有了技术方案原型,最初应该是一个外国开发者提出的方案,后来被CocoaChina cocos2d-x社区版主翻译成了中文教程,这里基本上也是参考的这个方案,只是做了局部细化和进一步说明,希望能帮助大家进一步解惑。

一、功能说明

GameDemo游戏分为三个场景:WelcomeScene、GameScene以及EndScene。集成AdMob SDK的功能要求如下:

    – 当游戏进入到WelcomeScene时,AdMob SDK完成初始化,发出Ad Request,当收到Ad的时候才会在屏幕上方显示带有Ad的窗口;
    – 当点击Start进入到GameScene时,隐藏Ad窗口,以不干扰玩家的游戏操作为优先;
    – 当游戏Over进入到EndScene的时候,恢复显示Ad窗口;
    – 当玩家点击“Retry”回到GameScene时,隐藏Ad窗口。

二、方案原理

这个方案就是通过android.widget.PopupWindow实现广告窗口悬浮在当前窗口之上。按照Android官方Doc中的说 明,PopupWindow用于实现一个弹出框,它可以使用任意布局的View作为其内容,这个弹出框是悬浮在当前activity之上的。

至于PopupWindow为何能显示在当前Activity之上,可以查看PopupWindow的源码,大致思路就是PopupWindow通过 new时传入的context(当前Activity)获得了当前WindowManager,并将AdView作为子View添加到该窗口中。这里简要列出一些关键源码,大家可粗略理解一下:

    public PopupWindow(Context context, AttributeSet attrs,
         int defStyleAttr, int defStyleRes) {
        mContext = context;
        mWindowManager = (WindowManager)context
               .getSystemService(Context.WINDOW_SERVICE);
        …. …
    }

    public void setContentView(View contentView) {
        if (isShowing()) {
            return;
        }

        mContentView = contentView;

        if (mContext == null && mContentView != null) {
            mContext = mContentView.getContext();
        }

        if (mWindowManager == null && mContentView != null) {
            mWindowManager = (WindowManager) mContext
                  .getSystemService(Context.WINDOW_SERVICE);
        }
    }

    public void showAtLocation(View parent, int gravity, int x, int y) {
        showAtLocation(parent.getWindowToken(), gravity, x, y);
    }

    public void showAtLocation(IBinder token, int gravity, int x, int y) {
        if (isShowing() || mContentView == null) {
            return;
        }

        unregisterForScrollChanged();

        mIsShowing = true;
        mIsDropdown = false;

        WindowManager.LayoutParams p = createPopupLayout(token);
        p.windowAnimations = computeAnimationResource();
      
        preparePopup(p);
        if (gravity == Gravity.NO_GRAVITY) {
            gravity = Gravity.TOP | Gravity.START;
        }
        p.gravity = gravity;
        p.x = x;
        p.y = y;
        if (mHeightMode < 0) p.height = mLastHeight = mHeightMode;
        if (mWidthMode < 0) p.width = mLastWidth = mWidthMode;
        invokePopup(p);
    }
   
    private void invokePopup(WindowManager.LayoutParams p) {
        if (mContext != null) {
            p.packageName = mContext.getPackageName();
        }
        mPopupView.setFitsSystemWindows(mLayoutInsetDecor);
        setLayoutDirectionFromAnchor();
        mWindowManager.addView(mPopupView, p);
    }

UI线程负责更新窗口,因此popupWindow的创建与操作都应该通过runOnUiThread传递给UI线程执行。

游戏逻辑的C++层代码通过Jni控制PopupWindow的Show和dismiss。别忘了C++层的代码是在渲染线程执行的哦,这也是为何要用handler和runOnUIThread的原因。

在Cocos2d-x 2.2.2版本集成AdMob时,AdMob只是在收到ad后才显示出广告Banner,但是在cocos2d-x 3.0rc0中,当广告加载未成功时,android.widget.PopupWindow会显示一个小黑框,特难看,因此我们需要自己来控制。

三、集成步骤

【AdMob准备】

  到Google AdMob注册一个帐号,如果你有Google帐号,那直接开通AdMob(需填写更加详细的信息)。
  下载Google AdMob SDK,之前一直用GoogleAdMobAdsSdk-6.4.1.jar,这里还以这个版本为例。但Google官方已经不推荐这个版本了,你可以下 载Google Play Services版本的Mobile Ads API,但代码与这里会稍有不同。将下载的jar包放到GameDemo/proj.android/libs中。
 
【修改AndroidManifest.xml】

  在<Application>标签里添加:

  <activity
     android:name="com.google.ads.AdActivity"
     android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize" />
  <meta-data
     android:name="ADMOB_PUBLISHER_ID"
     android:value="YOUR_PUBLISHER_ID_VALUE" />

  权限方面至少包含如下两个:
   <uses-permission android:name="android.permission.INTERNET"/>
   <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

【Java层代码集成】

我们只需要修改GameDemoActivity.java这个文件。

import android.app.NativeActivity;
import android.os.Bundle;
import android.util.Log;

import android.os.Handler;
import android.os.Message;
import android.view.Gravity;
import android.view.View;
import android.view.Gravity;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewGroup.MarginLayoutParams;
import android.view.WindowManager;
import android.widget.LinearLayout;

import android.widget.PopupWindow;
import com.google.ads.AdRequest;
import com.google.ads.AdSize;
import com.google.ads.AdView;
import com.google.ads.AdListener;
import com.google.ads.Ad;

public class GameDemoActivity extends NativeActivity{
    private static GameDemoActivity context;

    private AdView adView;
    private PopupWindow popUpWindow;
    private LinearLayout popupWindowLayout;
    private LinearLayout mainActivityLayout;
    private boolean hasAdReceived;

    private static Handler adHandler = new Handler() {
        public void handleMessage(Message msg) {
            if (!context.hasAdReceived)
                return;

            switch (msg.what) {
                case 0:
                    if (View.VISIBLE ==
                        context.adView.getVisibility()) {
                        context.adView.setVisibility(View.GONE);
                        context.popUpWindow.dismiss();
                    }
                    break;
                case 1:
                    if (View.VISIBLE !=
                        context.adView.getVisibility()) {
                        context.adView.setVisibility(View.VISIBLE);
                        context.popUpWindow.showAtLocation(
                           context.mainActivityLayout, Gravity.TOP, 0, 0);
                    }
                    break;
            }
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        context = this;
        adView = new AdView(this, AdSize.BANNER, "a1533d6de900e31");
        adView.setAdListener(new AdmobListener());
        //初始时,广告View隐藏起来
        adView.setVisibility(View.GONE);
    }

    public static void setupAds(){
        context.initAdPopupWindow();
    }

    public void initAdPopupWindow() {
        if (adView != null) {
            context.runOnUiThread(new Runnable() {
                 @Override
                 public void run() {
                     MarginLayoutParams params = new MarginLayoutParams(
                                     LayoutParams.WRAP_CONTENT,
                                     LayoutParams.WRAP_CONTENT);
                     params.setMargins(0, 0, 0, 0);

                     popupWindowLayout = new LinearLayout(context);
                     popupWindowLayout.setPadding(-5, -5, -5, -5);
                     popupWindowLayout.setOrientation(LinearLayout.VERTICAL);
                     popupWindowLayout.addView(adView, params);

                     popUpWindow = new PopupWindow(context);
                     popUpWindow.setWidth(320);
                     popUpWindow.setHeight(50);
                     popUpWindow.setWindowLayoutMode(LayoutParams.WRAP_CONTENT,
                                               LayoutParams.WRAP_CONTENT);
                     popUpWindow.setClippingEnabled(false);
                     popUpWindow.setContentView(popupWindowLayout);

                     mainActivityLayout = new LinearLayout(context);
                     context.setContentView(mainActivityLayout, params);

                     context.hasAdReceived = false;

                     AdRequest adRequest = new AdRequest();
                     //测试Admob时使用测试Device,发布版需要去掉这行代码
                     adRequest.addTestDevice("CCE4220B2509A406B515E7C9A205AEE1");
                     context.adView.loadAd(adRequest);

                     popUpWindow.update();
                }
            });
        }
    }

    //GoogleAdMobAdsSdk-6.4.1版本中的AdListener是interface,因此
    //我们需要override所有接口,但只有onReceiveAd是我们关心的。
    private class AdmobListener implements AdListener {
        @Override
        public void onReceiveAd(Ad ad) {
            //只有第一次成功接收Ad后,我们后续才显示广告窗口,否则
            //popupWindow会显示为一个小黑框,特难看。
            Log.d("GameDemo", "onReceiveAd");
            if (!hasAdReceived){
                hasAdReceived = true;               
            }
        }

        @Override
        public void onFailedToReceiveAd(Ad ad,
                            AdRequest.ErrorCode error) {
            Log.d("GameDemo",
              "failed to receive ad (" + error+ ")");
        }

        @Override
        public void onPresentScreen(Ad ad) {
            Log.d("GameDemo", "onPresentScreen");
        }

        @Override
        public void onDismissScreen(Ad ad) {
            Log.d("GameDemo", "onDismisScreen");
        }

        @Override
        public void onLeaveApplication(Ad ad) {
            Log.d("GameDemo", "onLeaveApp");
        }
    }

    public static void setAdVisible(boolean b) {
       Message msg = new Message();
        if (b) {
            msg.what = 1;
        } else {
            msg.what = 0;
        }
        adHandler.sendMessage(msg);
    }
}

【C++层代码集成】

在AppDelegate.cpp中添加setupAds方法:

void AppDelegate::setupAds()
{
#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
    JniMethodInfo t;
    if (JniHelper::getStaticMethodInfo(t, "com/tonybai/game/GameDemoActivity", "setupAds", "()V")) {
        t.env->CallStaticVoidMethod(t.classID, t.methodID);
        if (t.env->ExceptionOccurred()) {
            t.env->ExceptionDescribe();
            t.env->ExceptionClear();
            return;
        }
        t.env->DeleteLocalRef(t.classID);
    }
#endif
}

在WelcomeScene的init方法中调用setupAds:

bool WelcomeScene::init()
{
    bool bRet = false;
    do {
       … …
       AppDelegate *app = (AppDelegate*)Application::getInstance();
       app->setupAds();

       bRet=true;
    } while(0);

    return bRet;
}

在点击Start按钮时,隐藏Ad:

void WelcomeScene::menuStartCallback(Ref* pSender)
{
    AppDelegate *app = (AppDelegate*)Application::getInstance();

    app->setAdVisible(false);
    GameScene *gameScene = GameScene::create();
    Director::getInstance()->replaceScene(gameScene);
}

集成步骤到此就结束了,编译ok就可以部署到模拟器上运行测试一番了。

四、使用Cocos2d-x 3.0rc0引擎遇到的两个问题

【问题1】用cocos2d-x 3.0rc0的cocos编译高版本引擎生成的工程遇到的问题

 cocos2d-x 3.0rc0生成的proj.android/build-cfg.json与高版本(rc1~正式版)  cocos生成的工程的build-cfg.json稍有不同,用cocos2d-x 3.0rc0版cocos编译高版本cocos生成的project,会提示build_android组件无法找到“copy_to_assets”。 因此需要手动修改proj.android/build-cfg.json,将其中的:

    "copy_resources": [
        {
            "from": "../Resources",
            "to": ""
        }
    ],

改为:

   "copy_to_assets" :[
            "../Resources/"
            ],

【问题2】 W/dalvikvm(1194): dvmFindClassByName rejecting 'org/cocos2dx/lib/Cocos2dxHelper'

使用cocos2d-x 3.0rc0编译的项目,总是出现如下问题,但似乎这个错误还不影响程序的运行:

04-29 09:44:34.968: D/JniHelper(1194): JniHelper::setJavaVM(0xb8835730), pthread_self() = B897B518
04-29 09:44:34.968: W/dalvikvm(1194): dvmFindClassByName rejecting 'org/cocos2dx/lib/Cocos2dxHelper'

Google了一下,这个问题很多童鞋都遇到了,但给出的solution却不甚令我满意,于是我就求甚解的细致挖掘了一下,终于找到了一个让我还算满意 的答案。我们分析一下这两条日志,rejecting那条日志总是伴随setJavaVM后面出现的。可引擎什么时候setJavaVM了呢?显然是在 native_app_glue创建的子进程中,引擎需要attachCurrentThread,获得JniEnv时才做的。于是我们打开cocos2d-x-3.0rc0/cocos/2d/platform/android/nativeactivity.cpp一看究竟。

在nativeactivity.cpp中有两处"org/cocos2dx/lib/Cocos2dxHelper",我们先看engine_handle_cmd中的那处:

static void engine_handle_cmd(struct android_app* app, int32_t cmd)
{
        …. ….
        case APP_CMD_INIT_WINDOW:
            LOG_RENDER_DEBUG("android_main : APP_CMD_INIT_WINDOW");
            // The window is being shown, get it ready.
            if (engine->app->window != NULL) {
                cocos_dimensions d = engine_init_display(engine);
                if ((d.w > 0) &&
                    (d.h > 0)) {
                    cocos2d::JniHelper::setJavaVM(app->activity->vm);
                    cocos2d::JniHelper::setClassLoaderFrom(app->activity->clazz);

                    // call Cocos2dxHelper.init()
                    cocos2d::JniMethodInfo ccxhelperInit;
                    if (!cocos2d::JniHelper::getStaticMethodInfo(ccxhelperInit,
                                                                 "org/cocos2dx/lib/Cocos2dxHelper",
                                                                 "init",
                                                                 "(Landroid/app/Activity;)V")) {
                        LOGI("cocos2d::JniHelper::getStaticMethodInfo(ccxhelperInit) FAILED");
                    }
                    ccxhelperInit.env->CallStaticVoidMethod(ccxhelperInit.classID,
                                                            ccxhelperInit.methodID,
                                                            app->activity->clazz);

                    cocos_init(d, app);
                }
                engine->animating = 1;
                engine_draw_frame(engine);
            }
            break;
    … …
}

初步可以断定,那两条日志就是执行到这里输出的,但为何dvmFindClassByName方法会rejecting “org/cocos2dx/lib/Cocos2dxHelper”这个类名呢?我们还得翻看dalvik虚拟机源码:

 /dalvik/vm/native/InternalNative.cpp

/*
 * Find a class by name, initializing it if requested.
 */
ClassObject* dvmFindClassByName(StringObject* nameObj, Object* loader,
        bool doInit)
{
    ClassObject* clazz = NULL;
    char* name = NULL;
    char* descriptor = NULL;
    if (nameObj == NULL) {
        dvmThrowNullPointerException("name == null");
        goto bail;
    }
    name = dvmCreateCstrFromString(nameObj);
    /*
     * We need to validate and convert the name (from x.y.z to x/y/z). This
     * is especially handy for array types, since we want to avoid
     * auto-generating bogus array classes.
     */
    if (!dexIsValidClassName(name, true)) {
        ALOGW("dvmFindClassByName rejecting '%s'", name);
        dvmThrowClassNotFoundException(name);
        goto bail;
    }
    … …
}

我们的确找到了输出rejecting日志的地方,通过注释我们可以看到这个方法是用来验证名字对象,并将x.y.z形式的名字转换成x/y/z的。但引 擎中传入的就是“x/y/z”格式,因此这个方法输出了错误日志。我尝试将上面engine_handle_cmd中的"org/cocos2dx/lib/Cocos2dxHelper"改成"org.cocos2dx.lib.Cocos2dxHelper",错误日志果然消失了。

不过目前仍不能解释一点:为何在其他位置,比如在前面的AppDelegate::setupAds中,使用x/y/z格式的jni调用参数却没有输出错误日志!难道两个位置dalvik虚拟机使用的是不同的名字对象查找和加载方法?这个目前尚无定论。

Cocos2d-x 3.0多线程异步资源加载

7 Comments

Cocos2d-x2.x版本到上周刚刚才发布的Cocos2d-x 3.0 Final版,其引擎驱动核心依旧是一个单线程的“死循环”,一旦某一帧遇到了“大活儿”,比如Size很大的纹理资源加载或网络IO或大量计算,画面将 不可避免出现卡顿以及响应迟缓的现象。从古老的Win32 GUI编程那时起,Guru们就告诉我们:别阻塞主线程(UI线程),让Worker线程去做那些“大活儿”吧。

手机游戏,即便是休闲类的小游戏,往往也涉及大量纹理资源、音视频资源、文件读写以及网络通信,处理的稍有不甚就会出现画面卡顿,交互不畅的情况。虽然引 擎在某些方面提供了一些支持,但有些时候还是自己祭出Worker线程这个法宝比较灵活,下面就以Cocos2d-x 3.0 Final版游戏初始化为例(针对Android平台),说说如何进行多线程资源加载。

我们经常看到一些手机游戏,启动之后首先会显示一个带有公司Logo的闪屏画面(Flash Screen),然后才会进入一个游戏Welcome场景,点击“开始”才正式进入游戏主场景。而这里Flash Screen的展示环节往往在后台还会做另外一件事,那就是加载游戏的图片资源,音乐音效资源以及配置数据读取,这算是一个“障眼法”吧,目的就是提高用 户体验,这样后续场景渲染以及场景切换直接使用已经cache到内存中的数据即可,无需再行加载。

一、为游戏添加FlashScene

在游戏App初始化时,我们首先创建FlashScene,让游戏尽快显示FlashScene画面:

// AppDelegate.cpp
bool AppDelegate::applicationDidFinishLaunching() {
    … …
    FlashScene* scene = FlashScene::create();
    pDirector->runWithScene(scene);

    return true;
}

在FlashScene init时,我们创建一个Resource Load Thread,我们用一个ResourceLoadIndicator作为渲染线程与Worker线程之间交互的媒介。

//FlashScene.h

struct ResourceLoadIndicator {
    pthread_mutex_t mutex;
    bool load_done;
    void *context;
};

class FlashScene : public Scene
{
public:
    FlashScene(void);
    ~FlashScene(void);

    virtual bool init();

    CREATE_FUNC(FlashScene);
    bool getResourceLoadIndicator();
    void setResourceLoadIndicator(bool flag);

private:
     void updateScene(float dt);

private:
     ResourceLoadIndicator rli;
};

// FlashScene.cpp
bool FlashScene::init()
{
    bool bRet = false;
    do {
        CC_BREAK_IF(!CCScene::init());
        Size winSize = Director::getInstance()->getWinSize();

        //FlashScene自己的资源只能同步加载了
        Sprite *bg = Sprite::create("FlashSceenBg.png");
        CC_BREAK_IF(!bg);
        bg->setPosition(ccp(winSize.width/2, winSize.height/2));
        this->addChild(bg, 0);

        this->schedule(schedule_selector(FlashScene::updateScene)
                       , 0.01f);

        //start the resource loading thread
        rli.load_done = false;
        rli.context = (void*)this;
        pthread_mutex_init(&rli.mutex, NULL);
        pthread_attr_t attr;
        pthread_attr_init(&attr);
        pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
        pthread_t thread;
        pthread_create(&thread, &attr,
                    resource_load_thread_entry, &rli);

        bRet=true;
    } while(0);

    return bRet;
}

static void* resource_load_thread_entry(void* param)
{
    AppDelegate *app = (AppDelegate*)Application::getInstance();
    ResourceLoadIndicator *rli = (ResourceLoadIndicator*)param;
    FlashScene *scene = (FlashScene*)rli->context;

    //load music effect resource
    … …

    //init from config files
    … …

    //load images data in worker thread
    SpriteFrameCache::getInstance()->addSpriteFramesWithFile(
                                       "All-Sprites.plist");
    … …

    //set loading done
    scene->setResourceLoadIndicator(true);
    return NULL;
}

bool FlashScene::getResourceLoadIndicator()
{
    bool flag;
    pthread_mutex_lock(&rli.mutex);
    flag = rli.load_done;
    pthread_mutex_unlock(&rli.mutex);
    return flag;
}

void FlashScene::setResourceLoadIndicator(bool flag)
{
    pthread_mutex_lock(&rli.mutex);
    rli.load_done = flag;
    pthread_mutex_unlock(&rli.mutex);
    return;
}

我们在定时器回调函数中对indicator标志位进行检查,当发现加载ok后,切换到接下来的游戏开始场景:

void FlashScene::updateScene(float dt)
{
    if (getResourceLoadIndicator()) {
        Director::getInstance()->replaceScene(
                              WelcomeScene::create());
    }
}

到此,FlashScene的初始设计和实现完成了。Run一下试试吧。

二、崩溃

GenyMotion的4.4.2模拟器上,游戏运行的结果并没有如我期望,FlashScreen显现后游戏就异常崩溃退出了。

通过monitor分析游戏的运行日志,我们看到了如下一些异常日志:

threadid=24: thread exiting, not yet detached (count=0)
threadid=24: thread exiting, not yet detached (count=1)
threadid=24: native thread exited without detaching

很是奇怪啊,我们在创建线程时,明明设置了 PTHREAD_CREATE_DETACHED属性了啊:

pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

怎么还会出现这个问题,而且居然有三条日志。翻看了一下引擎内核的代码TextureCache::addImageAsync,在线程创建以及线程主函数中也没有发现什么特别的设置。为何内核可以创建线程,我自己创建就会崩溃呢。Debug多个来回,问题似乎聚焦在resource_load_thread_entry中执行的任务。在我的代码里,我利用SimpleAudioEngine加载了音效资源、利用UserDefault读取了一些持久化的数据,把这两个任务去掉,游戏就会进入到下一个环节而不会崩溃。

SimpleAudioEngine和UserDefault能有什么共同点呢?Jni调用。没错,这两个接口底层要适配多个平台,而对于Android 平台,他们都用到了Jni提供的接口去调用Java中的方法。而Jni对多线程是有约束的。Android开发者官网上有这么一段话:

All threads are Linux threads, scheduled by the kernel. They're usually started from managed code (using Thread.start), but they can also be created elsewhere and then attached to the JavaVM. For example, a thread started with pthread_create can be attached with the JNI AttachCurrentThread or AttachCurrentThreadAsDaemon functions. Until a thread is attached, it has no JNIEnv, and cannot make JNI calls.

由此看来pthread_create创建的新线程默认情况下是不能进行Jni接口调用的,除非Attach到Vm,获得一个JniEnv对象,并且在线 程exit前要Detach Vm。好,我们来尝试一下,Cocos2d-x引擎提供了一些JniHelper方法,可以方便进行Jni相关操作。

#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
#include "platform/android/jni/JniHelper.h"
#include <jni.h>
#endif

static void* resource_load_thread_entry(void* param)
{
    … …

    JavaVM *vm;
    JNIEnv *env;
    vm = JniHelper::getJavaVM();

    JavaVMAttachArgs thread_args;

    thread_args.name = "Resource Load";
    thread_args.version = JNI_VERSION_1_4;
    thread_args.group = NULL;

    vm->AttachCurrentThread(&env, &thread_args);
    … …
    //Your Jni Calls
    … …

    vm->DetachCurrentThread();
    … …
    return NULL;
}

关于什么是JavaVM,什么是JniEnv,Android Developer官方文档中是这样描述的:

The JavaVM provides the "invocation interface" functions, which allow you to create and destroy a JavaVM. In theory you can have multiple JavaVMs per process, but Android only allows one.
The JNIEnv provides most of the JNI functions. Your native functions all receive a JNIEnv as the first argument.
The JNIEnv is used for thread-local storage. For this reason, you cannot share a JNIEnv between threads.

三、黑屏

上面的代码成功解决了线程崩溃的问题,但问题还没完,因为接下来我们又遇到了“黑屏”事件。所谓的“黑屏”,其实并不是全黑。但进入游戏 WelcomScene时,只有Scene中的LabelTTF实例能显示出来,其余Sprite都无法显示。显然肯定与我们在Worker线程加载纹理 资源有关了:

SpriteFrameCache::getInstance()->addSpriteFramesWithFile("All-Sprites.plist");

我们通过碎图压缩到一张大纹理的方式建立SpriteFrame,这是Cocos2d-x推荐的优化手段。但要想找到这个问题的根源,还得看monitor日志。我们的确发现了一些异常日志:

libEGL: call to OpenGL ES API with no current context (logged once per thread)

通过Google得知,只有Renderer Thread才能进行egl调用,因为egl的context是在Renderer Thread创建的,Worker Thread并没有EGL的context,在进行egl操作时,无法找到context,因此操作都是失败的,纹理也就无法显示出来。要解决这个问题就 得查看一下TextureCache::addImageAsync是如何做的了。

TextureCache::addImageAsync只是在worker线程进行了image数据的加载,而纹理对象Texture2D instance则是在addImageAsyncCallBack中创建的。也就是说纹理还是在Renderer线程中创建的,因此不会出现我们上面的 “黑屏”问题。模仿addImageAsync,我们来修改一下代码:

static void* resource_load_thread_entry(void* param)
{
    … …
    allSpritesImage = new Image();
    allSpritesImage->initWithImageFile("All-Sprites.png");
    … …
}

void FlashScene::updateScene(float dt)
{
    if (getResourceLoadIndicator()) {
        // construct texture with preloaded images
        Texture2D *allSpritesTexture = TextureCache::getInstance()->
                           addImage(allSpritesImage, "All-Sprites.png");
        allSpritesImage->release();
        SpriteFrameCache::getInstance()->addSpriteFramesWithFile(
                           "All-Sprites.plist", allSpritesTexture);
     
        Director::getInstance()->replaceScene(WelcomeScene::create());
    }
}

完成这一修改后,游戏画面就变得一切正常了,多线程资源加载机制正式生效。

Cocos2d-x 3.0rc2集成ShareSDK

1 Comment

给自己的手机游戏增加些社交分享功能,有助于游戏宣传和提升知名度,是一种不错的社交营销手段。国内这方面的第三方插件有不少,比如ShareSDK友 盟分享组件Baidu分享组件等,之前在研究2.2.2版本时,集成了ShareSDK这个组件,这次迁移到Cocos2d-x 3.0rc2依旧选择集成ShareSDK,这里就来说说集成的过程,遇到的一些问题以及解决方法。这里仅以Android平台游戏集成为例。

一、功能描述、SDK版本和帐号准备

功能大致是这样的:在游戏中设置一个按钮,点击这个按钮,弹出知名社交平台的分享图标集窗口,用户选择分享目标后,相关信息分享到对应的社交平台。分享结果通知通过Toast显示在屏幕的下方。

这次依旧使用ShareSDK for Android 2.3.7版本(ShareSDK-Android-2.3.7),Cocos2d-x的版本为3.0rc2

集成前,你需要有一个基于Cocos2d-x 3.0rc2的可运行的Android平台游戏project,我们的集成就基于该project,这里我们的project名为GameDemo,GameDemo的源码结构大致是:

GameDemo/
    – Classes/
    – proj.android/
    – Resources/
    – cocos2d/
    – CMakeLists.txt
    – … …

使用ShareSDK前,你需要在各大主流社交平台(微信微博)申请开发者帐号以及游戏接入权限(app_key、app_secret)等,当然在ShareSDK站点也应该有自己的帐号和应用AppKey,这些申请的审核需要几个工作日,甚至更长。

二、ShareSDK集成步骤

按照ShareSDK官方manual说法,Cocos2d-x集成ShareSDK有三种方式,之前在Cocos2d-x 2.2.2引擎中采用的是专用组件集成的方式,该组件(C2DXShareSDKSample)可以在这里下载(该组件近期已经fix了我之前发现的bug)。

1.  jar包集成

这次我们主要做微博、微信的社交分享,因此只需要微博、微信相关jar包。在C2DXShareSDKSample/proj.android/libs下,我们找到以下几个jar包:

  -rw-rw-r– 1 tonybai tonybai  97K  4月  8 18:10 mframework.jar
  -rw-rw-r– 1 tonybai tonybai 112K  4月  8 17:39 ShareSDK-Core-2.3.7.jar
  -rw-rw-r– 1 tonybai tonybai  19K  4月  8 17:39 ShareSDK-SinaWeibo-2.3.7.jar
  -rw-rw-r– 1 tonybai tonybai 4.3K  4月  8 17:39 ShareSDK-Wechat-2.3.7.jar
  -rw-rw-r– 1 tonybai tonybai  29K  4月  8 17:39 ShareSDK-Wechat-Core-2.3.7.jar
  -rw-rw-r– 1 tonybai tonybai 4.6K  4月  8 17:39 ShareSDK-Wechat-Favorite-2.3.7.jar
  -rw-rw-r– 1 tonybai tonybai 4.4K  4月  8 17:39 ShareSDK-Wechat-Moments-2.3.7.jar

把这些jar包文件Copy到GameDemo/proj.android/libs下。

2. 配置文件与资源部分集成

修改GameDemo/proj.android/AndroidManifest.xml文件,在application标签下,添加如下Activity标签:

        <activity
            android:name="cn.sharesdk.framework.ShareSDKUIShell"
            android:configChanges="keyboardHidden|orientation|screenSize"
            android:screenOrientation="portrait"
            android:theme="@android:style/Theme.Translucent.NoTitleBar"
            android:windowSoftInputMode="stateHidden|adjustResize" >
    </activity>
    <activity
            android:name=".wxapi.WXEntryActivity"
            android:configChanges="keyboardHidden|orientation|screenSize"
            android:exported="true"
            android:screenOrientation="portrait"
            android:theme="@android:style/Theme.Translucent.NoTitleBar" />

将C2DXShareSDKSample/proj.android/res下的如下目录中的文件复制到GameDemo/proj.android/res下:

   drawable-hdpi/  drawable-ldpi/  drawable-mdpi/ 
   drawable-xhdpi/  layout/  values/  values-en/

注意,类似icon.png这种文件就不要复制了,自己做一下判断就好。

3. C++部分代码集成

将C2DXShareSDKSample/Classes下的C2DXShareSDK文件夹Copy到GameDemo/Classes下面。

由于Cocos2d-x 3.0rc2的类命名发生了变化,我们需要对C2DXShareSDK中使用到的引擎中的类名以及方法名进行修改。但实际上Cocos2d-x 3.0rc2考虑到了一些兼容性的问题,大部分名字通过cocos2d/cocos/deprecated/CCDeprecated.h中定义的typedef得以保留,虽然这些名字已经被建议deprecated了。rc2中CCObject被改名为Ref了,这个我们需要手工在C2DXShareSDK进行修改。

另外ShareSDK组件在实现时大量使用了CCDictionaryCCArrayCCString,而这三个类在Cocos2d-x 3.0rc2中均被deprecated了,但我们依然可以使用,所以我们可以不做修改。但以后随着cocos2d-x版本的演进,这些类很可能被彻底移除出引擎,我们就需要重新使用其替代品进行实现了。

此外我们还需要手工修改一下C2DXShareSDK/Android/JSON/CCJSONConverter.cpp文件中的getObjJson方 法,因为rc2中CCDictionary、CCString、CCArray这些类的真实名称都已经换成了__Dictionary、__String 和__Array,CCDictionary、CCString、CCArray只是些typedef,因此要像下面这样做些修改(如果你是集成 cocos2d-x 2.x.x版本,则无需做下面修改):

cJSON * CCJSONConverter::getObjJson(Ref * obj)
{
    std::string s = typeid(*obj).name();
    if(s.find("__Dictionary")!=std::string::npos){
        cJSON * json = cJSON_CreateObject();
        convertDictionaryToJson((CCDictionary *)obj, json);
        return json;
    }else if(s.find("__Array")!=std::string::npos){
        cJSON * json = cJSON_CreateArray();
        convertArrayToJson((CCArray *)obj, json);
        return json;
    }else if(s.find("__String")!=std::string::npos){
        CCString * s = (CCString *)obj;
        cJSON * json = cJSON_CreateString(s->getCString());
        return json;
    }else if(s.find("CCNumber")!=std::string::npos){
        CCNumber * n = (CCNumber *)obj;
        cJSON * json = cJSON_CreateNumber(n->getDoubleValue());
        return json;
    }else if(s.find("CCNull")!=std::string::npos){
        cJSON * json = cJSON_CreateNull();
        return json;
    }
    CCLog("CCJSONConverter encountered an unrecognized type");
    return NULL;
}

CCNumber和CCNull是ShareSDK组件自己实现的类名,这里无需修改。

接下来我们需要在AppDelegate.cpp中对ShareSDK做初始化了:

bool AppDelegate::applicationDidFinishLaunching() {
    … …
    initShareSDK();
    … ..
}

void AppDelegate::initShareSDK()
{
    // sina weibo
    CCDictionary *sinaConfigDict = CCDictionary::create();
    sinaConfigDict->setObject(CCString::create("YOUR_WEIBO_APPKEY"), "app_key");
    sinaConfigDict->setObject(CCString::create("YOUR_WEBIO_APPSECRET"), "app_secret");
    sinaConfigDict->setObject(CCString::create("http://www.sharesdk.cn"), "redirect_uri");
    C2DXShareSDK::setPlatformConfig(C2DXPlatTypeSinaWeibo, sinaConfigDict);

    // wechat
    CCDictionary *wcConfigDict = CCDictionary::create();
    wcConfigDict->setObject(CCString::create("YOUR_WECHAT_APPID"), "app_id");
    C2DXShareSDK::setPlatformConfig(C2DXPlatTypeWeixiSession, wcConfigDict);
    C2DXShareSDK::setPlatformConfig(C2DXPlatTypeWeixiTimeline, wcConfigDict);
    C2DXShareSDK::setPlatformConfig(C2DXPlatTypeWeixiFav, wcConfigDict);

    C2DXShareSDK::open(CCString::create("YOUR_SHARESDK_APPKEY"), false);
}

在Share按钮的事件回调函数中调用ShareSDK的接口进行社交平台分享:

void GameScene::menuShareCallback(Ref* sender)
{
    Dictionary *content = Dictionary::create();

    content->setObject(String::create("ShareSDK for Cocos2d-x 3.0rc2社交分享测试。")
                        , "content");
    content->setObject(String::create("ShareSDK分享测试"), "title");
    content->setObject(String::create("http://tonybai.com"), "titleUrl");
    content->setObject(String::create("http://tonybai.com"), "url");
    content->setObject(String::create("Tony Bai"), "site");
    content->setObject(String::create("http://tonybai.com"), "siteUrl");
    content->setObject(String::createWithFormat("%s", YOUR_LOCAL_IMAGE_PATH)
                       , "image");
    content->setObject(String::createWithFormat("%d", C2DXContentTypeNews)
                       , "type");

    C2DXShareSDK::showShareMenu(NULL, content, CCPointMake(100, 100),
                          C2DXMenuArrowDirectionLeft, shareResultHandler);
}

void shareResultHandler(C2DXResponseState state,
                        C2DXPlatType platType,
                        Dictionary *shareInfo,
                        Dictionary *error)
{
    AppDelegate *app = (AppDelegate*)Application::getInstance();
    switch (state) {
        case C2DXResponseStateSuccess:
            CCLog("Share Ok");
            app->showShareResultToast("分享成功");
            break;
        case C2DXResponseStateFail:
            app->showShareResultToast("分享失败");
            CCLog("Share Failed");
            break;
        default:
            break;
    }
}

showShareResultToast实现如下:

void AppDelegate::showShareResultToast(const char *msg)
{
    JniMethodInfo t;
    if (JniHelper::getStaticMethodInfo(t, "YOUR_ACTIVITY_NAME",
        "showShareResultToast", "(Ljava/lang/String;)V")) {
        jstring jmsg = t.env->NewStringUTF(msg);
        t.env->CallStaticVoidMethod(t.classID, t.methodID, jmsg);
        if (t.env->ExceptionOccurred()) {
            t.env->ExceptionDescribe();
            t.env->ExceptionClear();
            return;
        }
        t.env->DeleteLocalRef(t.classID);
    }
}

4. Java部分代码集成

在GameDemo/proj.android/src下面建立cn/sharesdk路径,将C2DXShareSDKSample /proj.android/src/cn/sharesdk下的onekeyshare和ShareSDKUtils.java Copy到GameDemo/proj.android/src/cn/sharesdk下面。

将ShareSDK-Android-2.3.7.zip解压后的ShareSDK for Android/Src/wxapi Copy到GameDemo/proj.android/src/com.tonybai.game/下。

修改GameDemo/proj.android/src/com.tonybai.game/GameDemoActivity.java文件:

import android.widget.Toast;
import cn.sharesdk.ShareSDKUtils;

public class GameDemoActivity extends Cocos2dxActivity {

    private static Context context;

    private static Handler notifyHandler = new Handler() {
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case 1:
                    String message = (String) msg.obj;
                    Toast.makeText(context, message,
                      Toast.LENGTH_SHORT).show();
                    break;
                default:
                    break;
            }
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        context = this;
        ShareSDKUtils.prepare();
        ShareSDKUtils.initSDK("YOUR_SHARESDK_APPKEY", true);
    }

    public static void showShareResultToast(String result) {
        Message msg = new Message();
        msg.what = 1;
        msg.obj = result;
        notifyHandler.sendMessage(msg);
    }

    @Override
    public void onDestroy() {
        ShareSDKUtils.stopSDK();
        super.onDestroy();
    }
}

三、问题与解决方法

按照上面的集成方法修改后,通过cocos编译app,在模拟器运行GameDemo,点击Share,理论上屏幕下方会出现ShareSDK的分享窗口,选择“新浪微博”图标,会打开“图文分享”内容窗口,点击窗口右上角的“分享”即可。

问题1】“图文分享”窗口内容可编辑,并且总是弹出软键盘,影响体验。
 
 期望:内容不可编辑,默认不弹出软键盘
 解决方法:
      打开proj.android/src/cn/sharesdk/onekeyshare/EditPage.java,做如下修改:

      将窗口的软输入方式默认改为SOFT_INPUT_STATE_HIDDEN。

      public void setActivity(Activity activity) {
        super.setActivity(activity);
        if (dialogMode) {
            activity.setTheme(android.R.style.Theme_Dialog);
            activity.requestWindowFeature(Window.FEATURE_NO_TITLE);
        }
        activity.getWindow().setSoftInputMode(
                   //WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE);
                   WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN);//default: hidden
    }

    在initPageView中增加一行:etContent.setKeyListener(null)。让窗口内容无法修改。
    private void initPageView() {
         … …
        // 文字输入区域
        etContent = new EditText(getContext());
        etContent.setGravity(Gravity.LEFT | Gravity.TOP);
        etContent.setBackgroundDrawable(null);
        etContent.setText(String.valueOf(reqData.get("text")));
        etContent.setKeyListener(null);//make the edittext uneditable
        etContent.setLayoutParams(lpEt);
        … …
    }

【问题2】向微博分享,点击“分享”后,过一会程序异常停止。

 原因分析:
        通过调试观察,发现ShareSDK在解析从Weibo收到的Json包时出现内存违法访问。具体位置是在解析一个数组对象时出现的问题。 ShareSDK用CCArray来存储Json中的数组对象。该问题在cocos2d-x 2.2.2版本中不会出现,但在cocos2d-x 3.0rc2版本中会出现。经代码对比发现,3.0rc2版本中的CCArray的实现与2.2.2 CCArray实现有很大不同,似乎是做了较大重构,暂不能确定是否是3.0rc2版本中CCArray实现的bug。

 解决方法:由于后续的分享结果通知成功与否只需要根据分享的状态来决定,因此我们只需解析出"status"、“action”和“platform” 这三个CCNumber类型字段的值即可。CCArray类型的对象我们并不需要,因此我们只需绕过对Array类型字段的解析和存储即可,修改如下:

// Classes/C2DXShareSDK/Android/JSON/CCJSONConverter.cpp

void CCJSONConverter::convertJsonToDictionary(cJSON *json, CCDictionary *dictionary)
{
    dictionary->removeAllObjects();
    cJSON * j = json->child;
    while (j) {
        if (j->type == cJSON_Number) {
            Ref * obj = getJsonObj(j);
            dictionary->setObject(obj, j->string);
        }
        j = j->next;
    }
}

四、其他

在使用ShareSDK做社交分享时,注意下面两个现象:
1) 第一次进行微博或微信分享时,会打开授权页面,授权后才能分享成功;
2) 微信分享窗口只有在手机联网状态下才能打开。如果手机无法联网,那微信好友、朋友圈和收藏分享将无法打开分享窗口,也不会有什么提示。

Cocos2d-x 3.0rc2针对Android平台的变动

No Comments

Hello, Cocos2d-x 3.0》一文发出后没多久,我就迫不及待地将手头的一个习作尝试从2.2.2版本迁移到3.0rc0引擎上。

核心代码迁移相对顺利,大致流程如下:

  * 创建项目

    1) cd cocos2d-x-3.0rc0;
    2) 执行setup.py,设置引擎依赖的环境变量,脚本会将COCOS_CONSOLE_ROOTANT_ROOT写入到~/.bash_profile中; 执行source ~/.bash_profile使得环境变量生效;
    3) 在cocos2d-x-3.0rc0下建立projects目录;
    4) 利用cocos2d-console工具建立新项目: cocos new GameDemo -p com.tonybai.game.gamedemo -l cpp -d ./projects
    5) cd ./projects/GameDemo,我们可以看到项目目录结构如下:
            bin/  Classes/  CMakeLists.txt  cocos2d/  proj.android/ 
      proj.ios_mac/  proj.linux/  proj.win32/  Resources/

    6) 执行cocos compile -p android -j 4  –ap 19 -m release,这个Demo的apk就会被生成,大致就是一个cpp-empty-test;
   
  * 代码移植
    
     代码移植的主要工作包括:
    1) 改名
            带有CC前缀的类名大都要将前缀去掉;
            各主要类的单例方法sharedXXXX都改为getInstance;

    2) 菜单、按钮事件处理       
            由menu_selector(GameScene::menuStartCallback) 改为CC_CALLBACK_1(GameScene::menuStartCallback, this);

    3) 触屏事件处理

            在Cocos2d-x 2.2.2中,我们直接使用Layer的setTouchEnabled(true),并Override 三个触屏事件处理函数;
            在新版引擎中,我们需要建立事件Listener,并将Listener注册到全局EventDispatcher中,诸如:

                auto listener = EventListenerTouchOneByOne::create();
        listener->setSwallowTouches(true);
        listener->onTouchBegan = CC_CALLBACK_2(GameLayer::onTouchBegan, this);
        listener->onTouchMoved = CC_CALLBACK_2(GameLayer::onTouchMoved, this);
        listener->onTouchEnded = CC_CALLBACK_2(GameLayer::onTouchEnded, this);
        Director::getInstance()->getEventDispatcher()->addEventListenerWithSceneGraphPriority(listener, this);

            然后将这里的三个事件处理方法实现出来即可。

核心功能迁移后,GameDemo在genymotion 4.4 Android模拟器以及真机上都能正常运行,在模拟器上能保持40左右的帧率,在真机上帧率一直在60左右。玩了一会后,感觉引擎渲染性能的确有提升, 而且这种提升是可以在真机上直观感受到的。

不过好景不长,我又尝试将GameDemo在genymotion 2.3.7 Android上运行,这回得到的结果却是:黑屏。 又将Cocos2d-x 3.0rc0自带的cpp-empty-test编译后放到模拟器上运行,得到了同样的黑屏结果,显然这可能是rc0的一个问题。在Cocos2d-x forum上粗略搜到的结果是:升级到最新版本可以解决黑屏问题。于是到官方下载目前最新发布版Cocos2d-x 3.0rc2。这里也吐槽一下:cocos2d-x引擎包Size太大了,似乎也没有提供什么patch文件,导致每发一个版本都要下载几百M的包。官方 git repository也太大了,尝试clone了几次都失败了,最终还只能下载源码的zip包。

Cocos2d-x 3.0rc2下载解压后,先编译了一下cpp-empty-test,然后部署到Android 2.3.7上运行,这回“黑屏”的确不见了,看来rc2修正了这个问题。接下来就是将我的GameDemo移植到rc2上了。

我用解压后的“cocos2d-x 3.0rc2”替换GameDemo下的cocos2d,然后运行cocos compile编译,install到模拟器行运行,程序启动失败,从monitor logcat中看到一行错误日志:

    “ANativeActivity_onCreate not found

怎么会呢?ANativeActivity_onCreate是由NDK的 native_app_glue static library提供的,怎么会找不到呢?

于是乎打开GameDemo/cocos2d/cocos/2d/platform/android/Android.mk打算查看一下究竟:

LOCAL_WHOLE_STATIC_LIBRARIES    := cocos_png_static cocos_jpeg_static cocos_tiff_static cocos_webp_static

include $(BUILD_STATIC_LIBRARY)

$(call import-module,jpeg/prebuilt/android)
$(call import-module,png/prebuilt/android)
$(call import-module,tiff/prebuilt/android)
$(call import-module,webp/prebuilt/android)

Android.mk内容中居然没有将native_app_glue列入,又翻看了一下cocos2d-x 3.0rc0中的同位置Android.mk,后者是有native_app_glue的库依赖的。难道是rc2这块忘记了?于是我尝试将 native_app_glue依赖加上:

LOCAL_WHOLE_STATIC_LIBRARIES   := android_native_app_glue cocos_png_static cocos_jpeg_static cocos_tiff_static cocos_webp_static

include $(BUILD_STATIC_LIBRARY)

$(call import-module,jpeg/prebuilt/android)
$(call import-module,png/prebuilt/android)
$(call import-module,tiff/prebuilt/android)
$(call import-module,webp/prebuilt/android)
$(call import-module,android/native_app_glue)

再次尝试编译,不过这次连编译都没能通过,错误的build结果如下:

/home1/tonybai/android-dev/adt-bundle-linux-x86_64/android-ndk-r9c/sources/android/native_app_glue/android_native_app_glue.c:232: error: undefined reference to 'android_main'
collect2: error: ld returned 1 exit status
make: *** [obj/local/armeabi/libgamedemo.so] Error 1

从结果来看,链接器没能找到native_app_glue中android_main对 应的函数体定义。android_main可是cocos2d-x 3.0引擎提供的实现啊。于是乎再次进入到rc2引擎代码中查找原因,结果却让我很是吃惊:“NativeActivity被引擎移除了”!cocos2d/cocos/2d/platform /android目录下面已经没有了nativeactivity.h和nativeactivity.cpp了:

$ ls -F cocos2d/cocos/2d/platform/android
Android.mk         CCApplication.h  CCDevice.cpp            CCFileUtilsAndroid.h  CCGLView.cpp  CCPlatformDefine.h  java/             jni/
CCApplication.cpp  CCCommon.cpp     CCFileUtilsAndroid.cpp  CCGL.h                CCGLView.h    CCStdC.h            javaactivity.cpp

我们看到了一个新文件:javaactivity.cpp,打开该文件,我们发现了和cocos2d-x 2.2.2版本类似的名字:Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit。难道rc2针对 Android平台的引擎入口代码回退到2.x版本的设计了?于是乎赶紧进到/cocos2d/cocos/2d/platform/android/java/src/org/cocos2dx/lib目 录下一看究竟。

果不其然,一切看起来都那么的熟悉:Cocos2dxActivity.java、Cocos2dxGLSurfaceView.java、 Cocos2dxRenderer.java….。自此可以断定,rc2中Android平台的引擎设计退回到了2.x版:
    – 你的GameActivity要集成Cocos2dxActivity;
    – mGLSurfaceView.setCocos2dxRenderer(new Cocos2dxRenderer())时,GLThread(渲染线程)诞生
    – 死循环调用Cocos2dxRenderer.onDrawFrame
    – 引擎逻辑就在Cocos2dxRenderer.onDrawFrame中被执行。

关于2.2.2版Cocos2dx引擎的结构说明可以参考我的《Hello, Cocos2d-x》一文。

回到了2.2.2版本设计的引擎在性能上是否会像rc0那样给人以直观提升的感觉呢,即便渲染器是新写的?真机测试的结果表明,没有直观感觉到提 升。难道是Native Thread(pthread_create创建)和Java Thread之间的差别?不得而知,后续慢慢体会吧。

另外要提一句:javaactivity.cpp将以往2.2.2版本放在项目jni中的 Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit挪到了引擎中,本来就是基本不变的代码, 放在引擎中的确更好。rc2的设计回归也有一定好处,一是以前对引擎的认识还适用,二呢就是适合集成在2.2.2版本中的第三方工具的方法应该同样适合3.0rc2版本,这样移 植成本估计会小些。这样我们针对新的3.0引擎,重点还是去关注渲染器、事件分发机制以及物理引擎的变化吧。

最后要做的一件事,就是将上一篇blog的名字做下修改,那篇文章的分析只能对3.0rc0版本有效了,对后续版本无效,已经不能代表3.0的引 擎结构了。事实上NativeActivity是在rc1就被移除了,这种较大的改动让人始料不急。这么大的改动,这么短时间发布,让人对目前的 3.0引擎,至少是Android版本引擎的质量表示些许担忧啊。不知道3.0正式版中这块的代码会变啥样,拭目以待吧。

BTW,rc2版本cpp-empty-test在Android 2.3.7模拟器上的帧数在10帧以下,我的Demo也只有5帧,而在4.4版本模拟器上,可以达到40帧,还好还好。

Older Entries