2013-06-27

BeagleBone on OSX Mountain Lion

依照官方提供的 Getting Started 步驟來執行的結果就是卡在 Step3,... 無法 Launch USB-to-Ethernet interface 因此沒辦法開啟 http://192.168.7.2 的網頁 (使用的 BeagleBone 版本是 A6a)。
底下是更詳細的步驟:
  1. 安裝 Driver : FTDI USB to serial。
  2. 將 BeagleBorad 接上 mac,此時在 Finder 會看到一個 BEAGLE_BONE 的 USB Disk,然後 Eject 這個 Disk。 
  3. 此時 System Preferences 的 Network 裡應該會自動多出 interface : RNDIS/Ethernet Gadget,如圖所示 :
    左方有 RNDIS/Ethernet Gadget

  4. 若沒發現 RNDIS/Ethernet Gadget 的話是正常的,因為我的 Mountain Lion 也沒有。
  5. 開啟 Terminal,輸入  screen `ls /dev/{tty.usb*B,beaglebone-serial}` 115200
  6. Terminal 全黑之後再按下 Enter,出現 Login 畫面,如圖所示 :
    登入頁面

  7. 輸入 root 後登入,再輸入 systemctl start network-gadget-init.service
  8. 完成!RNDIS/Ethernet Gadget 出現!
  9. 開啟瀏覽器並輸入 http://192.168.7.2
在 BeagleBone 內建的作業系統上編譯新版 node.js :
  1. 執行 opkg install python python-compiler python-modules python-distutils python-misc
  2. 執行 opkg install openssl openssl-dev
  3. 使用 wget 下載新版 nodejs 然後使用 tar xf 解壓縮之
  4. 修改檔案 deps/v8/SConstruct 內的 :
    'CCFLAGS' : ['$DIALECTFLAGS', '$WARNINGFLAGS'],
    
    'CCFLAGS' : ['$DIALECTFLAGS', '$WARNINGFLAGS', '-march=armv7-a'],
    
  5. 執行 export CC='gcc -march=armv7-a -mtune=cortex-a8'
  6. 執行 export CXX='g++ -march=armv7-a -mtune=cortex-a8'
  7. 執行 ./configure
  8. 執行 make
ps.
1. Eject USB Disk 的指令是 systemctl stop storage-gadget-init.service
2. 在 OSX 上設定好 Internet Sharing 之後鍵入 udhcpc -i usb0 則可透過 OSX 連結上 Internet
3. 資料參考來源 https://groups.google.com/forum/#!topic/beagleboard/gKQtFpdpB6k
4. 資料參考來源 http://brachestudios.com/2012/03/13/installing-the-latest-version-of-node-js-on-the-beaglebone/
5. 資料參考來源 http://fastr.github.io/articles/Node.js-on-OpenEmbedded.html

2013-04-08

Unity 3D + Facebook SDK for Android


底下是將 Facebook SDK 打包成 Facebook Plugin 的步驟,執行的平台是 Android,Unity3D 的版本是 3.5。

1. 在這裡取得 Facebook SDK 。
2. 開啟 eclipse 之後在功能表上選擇 File --> Import...,出現對話框之後在 import source 選擇 Existing Android Code Into Workspace。

3. 在 Root Directory 選擇剛剛下載的 SDK 資料夾 (此範例為 /Users/macaronics/facebook-android-sdk-3.0.1),Project to Import 的部分只要勾選 facebook (Facebook SDK) 即可。

4. 按下 Finish 之後在 Package Explorer 應該可以發現 Eclipse 已經自動產生一些檔案 (FacebookSDK/gen) 並且自動編譯好 facebooksdk.jar (FacebookSDK/bin) 了。

5. 建立一個 Unity Project,並將 facebooksdk.jar 及 res 資料夾複製到 Unity 專案的 Assets/Plugins/Android 資料夾底下。(ps. res 資料夾是指 facebook-android-sdk-3.0.1/facebook/res)

6. 複製 android-support-v4.jar  至 Unity 專案的 Assets/Plugins/Android 資料夾底下。( ps. facebook sdk 裡的 android-support-v4.jar 版本似乎太舊所以在編譯時會出問題,因此檔案可以從 android sdk 裡的 extras/android/support/v4 資料夾取得)

7. 建立 AndroidManifest.xml 檔案至 Assets/Plugins/Android 資料夾,內容如下 :
<?xml version="1.0" encoding="utf-8"?>
<manifest android:versionCode="1" android:versionName="1.0" 
          android:installLocation="preferExternal" 
          package="com.macaronics.fbonandroid" 
          xmlns:android="http://schemas.android.com/apk/res/android">
  <supports-screens android:anyDensity="true" android:smallScreens="true" 
                    android:normalScreens="true" android:largeScreens="true" 
                    android:xlargeScreens="true" />
  <application android:label="@string/app_name" 
               android:icon="@drawable/app_icon" android:debuggable="false">
    <activity android:label="@string/app_name" 
              android:name="com.macaronics.iab.overrideActivity"
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"              
              >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              />
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerNativeActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              >
      <meta-data android:name="android.app.lib_name" android:value="unity" />
      <meta-data android:name="unityplayer.ForwardNativeEventsToDalvik" 
                 android:value="false" />
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.VideoPlayer" 
              android:screenOrientation="behind" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale" 
              />
    <activity android:name="com.facebook.LoginActivity" android:label="@string/app_name" />
  </application>
  <uses-feature android:glEsVersion="0x20000" />
 
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

</manifest>
值得注意的是這裡設定了 activity "com.facebook.LoginActivity" 以及 Permission "INTERNET"。

8. 利用 git ( https://github.com/douglascrockford/JSON-java.git ) 取得 org.json 原始碼並以下述指令編譯 Java (在相同資料夾輸入指令) :
javac *.java -d .
完成之後輸入 :
jar cvfM json.jar org/
然後將產生的 json.jar 檔案置於 Assets/Plugins/Android 資料夾底下。

9. 建立 overrideActivity.jar ,功用在於 relay 系統資訊給 facebook SDK。首先建立 overrideActivity.java 檔案如下 :
package com.macaronics.iab;

import com.unity3d.player.UnityPlayerActivity;
import android.os.Bundle;
import android.util.Log;
import android.content.Intent;

public class overrideActivity extends UnityPlayerActivity {
    public interface cbEvent{
        public boolean cbEvent(int requestCode, int resultCode, Intent data);
    }
    
    public interface cbSaveInstState{
        public boolean cbSaveInstState(Bundle outState);
    }

    protected cbEvent ie;
    protected cbSaveInstState isis;
    protected Bundle mSavedInstState;

    static protected overrideActivity inst;
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        inst =this;
        mSavedInstState =savedInstanceState;
        
        // print debug message to logcat
        Log.d("Unity::overrideActivity", "onCreate called!");
    }

    @Override
 public void onDestroy(){
        super.onDestroy();
        inst =null;
        Log.d("Unity::overrideActivity", "onDestroy called!");
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data){
        Log.d("Unity::overrideActivity", "onActivityResult called!");
        
        boolean ret =false;
        if (ie !=null){
            try{
                ret =ie.cbEvent(requestCode, resultCode, data);
            }
            catch(Exception e){
                ret =false;
            }
        }

        if (ret ==false){
            super.onActivityResult(requestCode, resultCode, data);
        }
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        Log.d("Unity::overrideActivity", "onSaveInstanceState called!");
    
        boolean ret =false;
        
        if (isis !=null)
        {
            try{
                ret =isis.cbSaveInstState(outState);
            }
            catch(Exception e){
                ret =false;
            }
        }
        
        if (ret ==false){
            super.onSaveInstanceState(outState);
        }
    }
    
    static public void registerOnActivityResultCBFunc(final cbEvent pcbfunc){
        if (inst !=null)
            inst.ie =pcbfunc;
    }
    
    static public void registerOnSaveInstanceSataeCBFunc(final cbSaveInstState pcbfunc){
        if (inst !=null)
            inst.isis =pcbfunc;
    }
    
    static public Bundle getSavedInstanceState()
    {
        if (inst !=null)
            return inst.mSavedInstState;
        
        return null;
    }
} 
編譯指令為 :
javac ./overrideActivity.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Applications/Unity3.5.6/Unity/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar -d .
其中,引入的 library 包含了 android-10/android.jar (Android 2.3.3) ,以及 Unity 的 classes.jar。編譯完成後將其打包成 overrideActivity.jar :
jar cvfM ../overrideActivity.jar com/
將產生的 overrideActivity.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下。此外,須注意資料夾 com 要刪除,以免後續執行 jar 時,打包到舊的 com package。

10. 建立 fbWrapper.jar (主要用於介接 facebook SDK 及 Unity),首先建立 fbWrapper.java 檔案如下 :
package com.macaronics;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

import android.os.Bundle;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;
import android.util.Base64;
import android.util.Log;
import android.view.Menu;
import android.view.View;
import android.widget.Toast;

import com.facebook.*;
import com.facebook.widget.WebDialog;
import com.facebook.widget.WebDialog.OnCompleteListener;
import com.facebook.model.GraphObject;

import com.unity3d.player.UnityPlayer;
import com.macaronics.iab.overrideActivity;

import org.json.JSONArray;


public class fbWrapper
{
    private String myHash;
    private String mEventHandler;
    private Activity mActivity;
    private String mAppId;
    
    public fbWrapper(String packageName, String applicationId, String strEventHandler){

        mActivity =UnityPlayer.currentActivity;
        mEventHandler =strEventHandler;
        mAppId =applicationId;
        
        //--------------------
        //fetch keyhash
        try
        {
            PackageInfo info =mActivity.getPackageManager().getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
            for (Signature sig : info.signatures){
                MessageDigest md =MessageDigest.getInstance("SHA");
                md.update(sig.toByteArray());
                myHash =Base64.encodeToString(md.digest(), Base64.DEFAULT);
            }
        }
        catch(NameNotFoundException e){
            Log.d("Unity::fbWrapper()", "error on fetch haskkey -name not found");
        }
        catch(NoSuchAlgorithmException e){
            Log.d("Unity::fbWrapper()", "error on fetch haskkey -no such algorithm");
        }

        
        //--------------------
        //register callback (onActivityResult)
        overrideActivity.registerOnActivityResultCBFunc(
            new overrideActivity.cbEvent(){
                public boolean cbEvent(int requestCode, int resultCode, Intent data)
                {
                    Session.getActiveSession().onActivityResult(mActivity, requestCode, resultCode, data);
                    return false;
                }
            });
        
        //--------------------
        //register callback (onSaveInstanceState)
        overrideActivity.registerOnSaveInstanceSataeCBFunc(
            new overrideActivity.cbSaveInstState(){
                public boolean cbSaveInstState(Bundle outState)
                {
                    Session session = Session.getActiveSession();
                    Session.saveSession(session, outState);
                    return false;
                }
            });
        
        //--------------------
  //facebook
        Bundle savedInstanceState =overrideActivity.getSavedInstanceState();
  Settings.addLoggingBehavior(LoggingBehavior.INCLUDE_ACCESS_TOKENS);
  Session session =Session.getActiveSession();
        if (session == null) {
            if (savedInstanceState != null) {
                session = Session.restoreSession(mActivity, null, statusCallback, savedInstanceState);
                Log.d("Unity::onCreate()", "Session.restoreSession()");
            }
            if (session == null) {
             session = new Session.Builder(mActivity).setApplicationId(mAppId).build();
                Log.d("Unity::onCreate()", "new Session.Builder(this)");
            }
            
            Session.setActiveSession(session);
            if (session.getState().equals(SessionState.CREATED_TOKEN_LOADED)) {
                session.openForRead(new Session.OpenRequest(mActivity).setCallback(statusCallback));
                Log.d("Unity::onCreate()", "openForRead()");
            }
        }        
    }

    public void dispose()
    {
        Log.d("Unity::dispose()", "dispose called");
    }
    
    public boolean isSessionOpened()
    {
        Session session =Session.getActiveSession();
        if (session !=null && session.isOpened()==true && session.getAccessToken() !=null)
        {
            return true;
        }
        
        return false;
    }

    public String getHash()
    {
        return myHash;
    }
    
    public void login()
    {
        Session session = Session.getActiveSession();
        if (!session.isOpened() && !session.isClosed()) {
            Log.d("Unity::onLogin()","session.openForRead()");
            session.openForRead(new Session.OpenRequest(mActivity).setCallback(statusCallback));
        }else if (session !=null){
            Log.d("Unity::onLogin()","Session.openActiveSession()");

            //regenerate session
            Session newsession = new Session.Builder(mActivity).setApplicationId(mAppId).build();
            Session.setActiveSession(newsession);
            newsession.openForRead(new Session.OpenRequest(mActivity).setCallback(statusCallback));
        }
        else
        {
         Log.d("Unity", "error -session ==null");
        }
    }
    
    public void logout()
    {
     Log.d("Unity::onLogout()","on logout called !");
        Session session = Session.getActiveSession();
        if (!session.isClosed()) {
            session.closeAndClearTokenInformation();
            Log.d("Unity::onLogout()","session.closeAndClearTokenInformation()");
        }
    }
    
    public void invokeQuery(String fqlQueryStr)
    {
     Session session =Session.getActiveSession();
     if (session.isOpened()==false)
     {
      Log.d("Unity::invokeQuery()", "invalid session");
      return;
     }

     Bundle params =new Bundle();
     params.putString("q", fqlQueryStr);

     final Request request =new Request(session,
       "/fql",
       params,
       HttpMethod.GET,
       new Request.Callback(){
        public void onCompleted(Response response){
                        GraphObject graphObject = response.getGraphObject();
                        if (graphObject !=null)
                        {
                            if (graphObject.getProperty("data")!=null &&
                                mEventHandler !=null)
                            {
                                try{
                                    String arr =graphObject.getProperty("data").toString();
                                    JSONArray pj =new JSONArray(arr);
                                    UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"ret\":\"true\",\"dat\":"+pj.toString()+"}");
                                }
                                catch(Exception e)
                                {
                                    if (mEventHandler!=null)
                                        UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"ret\":\"false\"}");
                                }
                                
                            }
                            else
                            {
                                if (mEventHandler!=null)
                                    UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"ret\":\"false\"}");
                            }
                            
                        }
                        else
                        {
                            if (mEventHandler!=null)
                                UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"ret\":\"false\"}");
                        }
                        
        }

       });

     Log.d("Unity::invokeQuery()", "send fql request...");
        
        mActivity.runOnUiThread(new Runnable(){
            public void run(){
                Request.executeBatchAsync(request);                
            }
        });


    }

    public void sendRequest(String reqMessage)
    {
     final Session session =Session.getActiveSession();
     if (session.isOpened()==false)
     {
      Log.d("Unity::SendRequest()", "invalid session");
      return;
     }

     final Bundle params =new Bundle();
     params.putString("message", reqMessage);

     final Activity act =mActivity;

        mActivity.runOnUiThread(new Runnable(){
            public void run(){
                
                WebDialog requestDialog =(
                    new WebDialog.RequestsDialogBuilder(act,
                        session.getActiveSession(),
                        params))
                        .setOnCompleteListener(new OnCompleteListener(){

                            @Override
                            public void onComplete(Bundle values,
                                    FacebookException error){
                                if (error !=null)
                                {
                                    if (error instanceof FacebookOperationCanceledException){
                                        Toast.makeText(act.getApplicationContext(),
                                        "Request cancelled",
                                        Toast.LENGTH_SHORT).show();
                                    }
                                    else
                                    {
                                        Toast.makeText(act.getApplicationContext(), "Network Error", Toast.LENGTH_SHORT).show();
                                    }
                                }
                                else
                                {
                                    final String requestId =values.getString("request");
                                    if (requestId !=null)
                                    {
                                        Toast.makeText(act.getApplicationContext(),
                                        "Request sent",
                                        Toast.LENGTH_SHORT).show();
                                    }
                                    else
                                    {
                                        Toast.makeText(act.getApplicationContext(),
                                        "Request cancelled",
                                        Toast.LENGTH_SHORT).show();
                                    }
                                }
                            }
                    })
                    .build();
                requestDialog.show();
                
            }
        });

    }
    
    private Session.StatusCallback statusCallback = new SessionStatusCallback();
    private class SessionStatusCallback implements Session.StatusCallback {
        @Override
        public void call(Session session, SessionState state, Exception exception) {
            if (state.isOpened())
            {
                Log.d("Unity::onSessionStateChange()", "state.isOpened()");
            }
            else
            {
                Log.d("Unity::onSessionStateChange()", "state.isOpened() ===false");
            }
        }
    }

}
其中除了 login 及 logout 外,還提供了兩個重要的功能,一是 FQL Query,另一個是 Requests Dialog。執行編譯的指令為 :
javac ./fbWrapper.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Applications/Unity/Unity.app/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar:./facebooksdk.jar:./overrideActivity.jar:./android-support-v4.jar:./json.jar -d .
其中,引入的 library 包含了 android-10/android.jar (Android 2.3.3),Unity 的 classes.jar,facebooksdk.jar,先前編譯的 overrideActivity.jar,json.jar,以及 android-support-v4.jar。編譯完成後將其打包成 fbWrapper.jar :
jar cvfM ../fbWrapper.jar com/
將產生的 fbWrapper.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下。直至目前為止,資料夾底下應該有這些檔案 :

11. 建立 fbWrapper.cs ,讓 fbWrapper class 更適用於 Unity 的 Component 架構之環境,程式碼如下 :
using UnityEngine;

using System.Collections;
using System.Collections.Generic;

public class fbWrapper : MonoBehaviour {

    AndroidJavaObject fbWrapperObj =null;
    public static fbWrapper inst =null;

 // Use this for initialization
 void Start () {
        inst =this;
 }

    void OnApplicationQuit(){
        dispose();
        inst =null;
    }

    public bool init(string unity_bundle_id, string fb_app_id)
    {
        dispose();
        fbWrapperObj =new AndroidJavaObject("com.macaronics.fbWrapper", new object[3]{unity_bundle_id, fb_app_id, "fbWrapper"});

        return true;
    }

    public void dispose()
    {
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("dispose");
            fbWrapperObj.Dispose();
            fbWrapperObj =null;
        }
    }

    void msgReceiver(string msg)
    {
        if (tmpCBFunc !=null)
        {
            Dictionary<string, object> cache =(Dictionary<string, object>)MiniJSON.Json.Deserialize(msg);
            if (cache.ContainsKey("ret")==true)
            {
                string ret =(string)cache["ret"];
                if (ret=="false")
                {
                    tmpCBFunc(false, null);
                }
                else
                {
                    if (cache.ContainsKey("dat")==true)
                    {
                        tmpCBFunc(true, cache["dat"]);
                    }
                }
            }
            else
            {
                tmpCBFunc(false, null);
            }
        }
    }

    //目前是否已登入
    public bool isSessionAvailable()
    {
        if(fbWrapperObj==null)
            return false;

        return fbWrapperObj.Call<bool>("isSessionOpened");
    }

    //登入
    public bool login()
    {
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("login");
            return true;
        }

        return false;
    }

    //登出
    public bool logout()
    {
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("logout");
            return true;
        }
        return false;
    }

    public string getHashKey()
    {
        string myhash =fbWrapperObj.Call<string>("getHash");
        if (myhash ==null)
            myhash ="package name not found";

        return myhash;
    }

    public delegate void queryCBFunc(bool ret, object data);
    protected queryCBFunc tmpCBFunc =null;
    public bool invokeQuery(string fql, queryCBFunc pcbfunc)
    {
        tmpCBFunc =pcbfunc;
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("invokeQuery", new object[1]{fql});
            return true;
        }
        return false;
    }

    //送出邀請
    public bool sendRequest(string message)
    {
        if (fbWrapperObj !=null)
        {
            fbWrapperObj.Call("sendRequest", new object[1]{message});
            return true;
        }
        return false;
    }

}
在 Unity 建立一個 GameObject 並命名為 fbWrapper,然後將 fbWrapper.cs 設定成此 GameObject 之 Component。

此外值得注意的是,函數 init 的第一個參數是 Unity 之 Bundle Identifier,第二個參數是 Facebook App ID,此 ID 應該在建立 Facebook App 時,由 Facebook 所提供,如圖所示 :

函數 getHashKey 所回傳的字串必須設定至 Native Android App 之 Key Hash 欄位 ,如圖所示 :

12. 底下是簡單的 fbWrapper 應用範例,建立一個  C# script 名稱為 test.cs 並設定成為 GameObject "Main Camera" 之 Component。
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class test : MonoBehaviour {

 // Use this for initialization
    string myhash =null;
 void Start () {
        fbWrapper.inst.init("com.macaronics.fbonandroid", "FB_APP_ID");
        myhash =fbWrapper.inst.getHashKey();
 }

    void OnGUI()
    {
        if (GUI.Button(new Rect(10,10,100,100), "login"))
        {
            fbWrapper.inst.login();
            myhash =fbWrapper.inst.getHashKey();
        }

        if (GUI.Button(new Rect(10,120,100,100), "logout"))
        {
            fbWrapper.inst.logout();
            queryresult =null;
            myhash =null;
        }

        if (fbWrapper.inst.isSessionAvailable())
        {
            if (GUI.Button(new Rect(10,230,100,100), "invoke query"))
            {
                fbWrapper.inst.invokeQuery("SELECT uid, name, pic_square, is_app_user FROM user WHERE uid IN (SELECT uid2 FROM friend WHERE uid1 =me())", queryCB);
            }

            if (GUI.Button(new Rect(10,340,100,100), "send request"))
            {
                fbWrapper.inst.sendRequest("check out my app !");
            }
        }

        if (myhash !=null)
        {
            GUI.Label(new Rect(120, 10, Screen.width-120, 40), "Hash Key: "+myhash);
        }
        if (queryresult !=null)
        {
            GUI.Label(new Rect(120, 50, Screen.width-120, Screen.height-50), queryresult);
        }
    }

    string queryresult =null;
    void queryCB(bool ret, object data)
    {
        string tmpstr ="";
        List<object> OBJARR =(List<object>)data;
        for (int i=0;i<OBJARR.Count;++i)
        {
            
            Dictionary<string, object> items =(Dictionary<string, object>)OBJARR[i];
            foreach(KeyValuePair<string, object> item in items)
            {
                tmpstr +=(item.Key+":"+item.Value+"\n");
            }
        }

        queryresult =tmpstr;
    }
}
將 Script 內的 FB_APP_ID 換成當初建立 FB APP 時,Facebook 給予的 App ID。此外,"com.macaronics.fbonandroid" 是本範例所設定之 Bundle Identifier 。另外,呼叫 invokeQuery 函數的第一個參數是 FQL 字串,第二個參數是作為回傳結果用的 Callback Function,回傳的結果字串屬於 JSON 格式,範例把字串內容解析並顯示出來。

13. 設定 Untiy 載入 Scene 之後,各 Script 的執行順序,將 fbWrapper 設定早於預設的時間 (Edit --> Project Settings --> Script Execution Order)。

14. 當一切設定就緒之後 (記得設定 Player Settings 之 Other Settings 的 Bundle Identifier),嘗試 Build apk 檔案,Build 完成之後,在 Unity 專案資料夾下會多出一個 Temp 資料夾,在路徑 Temp/StagingArea/gen/com/macaronics/fbonandroid/ 找到檔案 R.java。

打開此檔案,將 package com.macaronics.fbonandroid; 改為 package com.facebook.android; 之後另存成 R.java 在別的資料夾。

15. 編譯 R.java :
javac R.java -d .
打包成為 jar 檔案 :
jar cvfM facebooksdk_R.jar com/
將檔案放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下。

16. 重新編譯 apk 檔案。

此範例的原始碼可以從這裡 ( http://github.com/phardera/unity3d_fb_android.git ) 取得。

2013-03-25

Unity 3D + Google Play In-app Billing (IAB)


底下是 Unity 介接 Google Play In-app Billing (IAB) 金流的步驟,使用的環境是 OSX,IAB 是 V3 版 :

1. 編譯 IInAppBillingService.java

a. 按照 GooglePlay 官方網站的指示 (http://developer.android.com/training/in-app-billing/preparing-iab-app.html) 產生此檔案,其實這是一個自動產生的檔案,詳細方法可以參考連結網頁敘述。此外值得注意的是,程式碼也可以由 git 從此處下載 :

git clone https://code.google.com/p/marketbilling/

(附註 : 也許現在已經更新了,但文章撰寫的當下,若是從 Android SDK Manager 勾選 Google Play Billing Library 下載的版本有 Bug,所以才由 git 下載。 ref :
http://stackoverflow.com/questions/14397343/google-play-in-app-billing-version-3-crash-on-item-already-owned-and-missing )

b. 編譯 IInAppBillingService.java,指令如下 (此處假設 android sdk 之資料夾路徑在 /Users/macaronics/android-sdks/) :

javac ./IInAppBillingService.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar -d .

其中 -cp 指示編譯時參考之 library (android-10, Android 2.3.3)。編譯完成後輸入 :

jar cvfM ./iiabs.jar com/

產生的 iiabs.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下( 此範例將檔案置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android/) 。
注意要刪除資料夾 com,以免後續執行 jar 時,打包到舊的 com package。

2. 編譯 class IabHelper

a. 此類別由九個檔案組成,方便用來介接 Google IAB,九個 java 檔案應該位於前步驟下載之程式碼的 marketbilling/v3/src/com/example/android/trivialdrivesample/util 底下。

b. 逐一修改九個檔案裡的 "package com.example.android.trivialdrivesample.util" 改為你的 package name,例如  package com.macaronics.iab.util。

c. 建立欲編譯的檔案的 list,在 Terminal 底下的話直接輸入 cat > sources 然後依序輸入九個檔案檔名後按 control+d 儲存離開。

在 terminal 底下利用 cat 建立 sources file

d. 編譯 class IabHelper,輸入指令如下 :

javac @sources -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Users/macaronics/UnityIabProj/Assets/Plugins/Android/iiabs.jar -d .

其中 @sources 表示參考檔案 sources 裡的路徑。此外,引入的 library 除了 android-10/android.jar (Android 2.3.3) 外,還包含前一步驟所編譯的檔案 iiabs.jar。完成後接著輸入 :

jar cvfM ./iabhelper.jar com/

產生的 iabhelper.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下( 此範例將檔案置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android/) 。


3. 處理 onActivityResult 的結果訊息

a. 為了讓 IabHelper 能在 Activity 結束時處理 Activity 的結果,這裡我們需要自己撰寫繼承 UnityPlayerActivity 的類別來將結果 Relay 給 IabHelper。底下是範例的程式碼 :
package com.macaronics.iab;

import com.unity3d.player.UnityPlayerActivity;

import android.os.Bundle;
import android.util.Log;
import android.content.Intent;

public class overrideActivity extends UnityPlayerActivity {
    public interface cbEvent{
        public boolean cbEvent(int requestCode, int resultCode, Intent data);
    }

    protected cbEvent ie;
    static protected overrideActivity inst;
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        inst =this;
        
        // print debug message to logcat
        Log.d("overrideActivity", "onCreate called!");
    }

    @Override
    public void onDestroy(){
        super.onDestroy();
        inst =null;
        Log.d("overrideActivity", "onDestroy called!");
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data){
        Log.d("overrideActivity", "onActivityResult called!");
        
        boolean ret =false;
        if (ie !=null){
            try{
                ret =ie.cbEvent(requestCode, resultCode, data);
            }
            catch(Exception e){
                ret =false;
            }
        }

        if (ret ==false){
            super.onActivityResult(requestCode, resultCode, data);
        }
    }
    
    static public void registerOnActivityResultCBFunc(final cbEvent pcbfunc){
        if (inst !=null)
            inst.ie =pcbfunc;
    }
} 
其中函數 registerOnActivityResultCBFunc 只能記憶單一個 call back function,讀者可以把功能擴充成使用佇列來記憶更多的 call back function。

b. 同樣的編譯這個 class :

javac ./overrideActivity.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Applications/Unity/Unity.app/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar -d .

其中引入的 library 有 android-10/android.jar (Android 2.3.3),及 Unity 的 classes.jar。然後輸入 :

jar cvfM ./overrideActivity.jar com/

產生的 overrideActivity.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下( 此範例將檔案置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android/) 。


4. 撰寫 iabWrapper.java

a. 主要用於介接 IabHelper 及 Unity ,提供基本的下單功能。程式碼分別敘述如下 :
package com.macaronics.iab;

import com.unity3d.player.UnityPlayer;

import android.app.Activity;
import android.util.Log;
import android.os.Bundle;
import android.os.Looper;
import android.content.Intent;

import java.util.*;

import com.macaronics.iab.util.*;
import com.macaronics.iab.overrideActivity;
值得注意的是這裡引入 UnityPlayer,步驟2編譯的 IabHelper (com.macaronics.iab.util),及步驟3編譯的 overrideActivity (com.macaronics.iab.overrideActivity)。

底下是 class iabWrapper 的私有變數部分 :
public class iabWrapper{
    private Activity mActivity;
    private IabHelper mHelper;
    private String mEventHandler;  
    ...
其中 mEventHandler 是回呼 Unity 時,接收訊息的 GameObject 名稱。此 class 有一個 Constructor 函數,三個成員函數及兩個給 IabHelper 回呼的函數,分別敘述如下 :

Constructor 函數在呼叫時給予 GooglePlay API PublicKey (由 GooglePlay 提供),以及回呼 Unity 時,接收訊息的 GameObject 之名稱 :
public iabWrapper(String base64EncodedPublicKey, String strEventHandler){
    mActivity =UnityPlayer.currentActivity;
    mEventHandler =strEventHandler;

    if (mHelper !=null){
        dispose();
    }

    mHelper =new IabHelper(mActivity, base64EncodedPublicKey);
    mHelper.enableDebugLogging(true);

    mHelper.startSetup(new IabHelper.OnIabSetupFinishedListener() {
        public void onIabSetupFinished(IabResult result){
            if (!result.isSuccess()){
                //回呼 Unity GameObject 之函數 "msgReceiver", 並傳送字串訊息 (JSON 格式)
                UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"1\",\"ret\":\"false\",\"desc\":\""+result.toString()+"\"}");
                dispose();
                return;
            }
            
            //回呼 Unity GameObject 之函數 "msgReceiver", 並傳送字串訊息 (JSON 格式)
            UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"1\",\"ret\":\"true\",\"desc\":\""+result.toString()+"\"}");

            //register mHelper
            //向 overrideActivity 註冊 onActivityResult 回呼函數,並將資料 Relay 給 mHelper
            overrideActivity.registerOnActivityResultCBFunc(
                new overrideActivity.cbEvent(){
                    public boolean cbEvent(int requestCode, int resultCode, Intent data)
                    {
                            
                        if (mHelper.handleActivityResult(requestCode, resultCode, data)){
                            return true;
                        }
                        else{
                            return false;
                        }
                    }
                }
            );
        }
    });
}
其中的 UnitySendMessage 用來呼叫 Unity GameObject 之函數 "msgReceiver", 並傳送字串訊息 (JSON 格式)。此外值得注意的是此 Constructor 函數還向 overrideActivity 註冊用來接收 onActivityResult 訊息的回呼函數。底下的dispose 函數主要用於釋放 mHelper :
public void dispose()
{
    if (mHelper !=null)
    {
        mHelper.dispose();
    }
    mHelper =null;
}
purchase 函數會啟動購買商品的介面,其中第一項參數是 Product SKU (在 GooglePlay 設定的產品 SKU),第二項是回呼 onActivityResult 函數時用來分辨購買動作的 reqCode,第三項是用來確認此筆講買是否合法的 payloadString (購買成功之後, GooglePlay 回覆的資訊裡會包含此字串。)。
public void purchase(String strSKU, String reqCode, String payloadString)
{
    int intVal =Integer.parseInt(reqCode);
    if (mHelper !=null)
        mHelper.launchPurchaseFlow(mActivity, strSKU, intVal, mPurchaseFinishedListener, payloadString);
}
底下是完成購買之後回呼的函數 (onIabPurchaseFinished),這裡把接收的資訊以 JSON 格式轉送給 Unity :
IabHelper.OnIabPurchaseFinishedListener mPurchaseFinishedListener =new IabHelper.OnIabPurchaseFinishedListener() {
    public void onIabPurchaseFinished(IabResult result, Purchase purchase) {
        if (result.isFailure()){
            UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"2\",\"ret\":\"false\",\"desc\":\"\",\"sign\":\"\"}");
            return;
        }
  
        boolean ret =false;
        String result_json ="";
        String result_sign ="";
        if (purchase !=null){
            ret =true;
            result_json =purchase.getOriginalJson().replace('\"', '\'');
            result_sign =purchase.getSignature();
        }

        UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"2\",\"ret\":\""+ret+"\",\"desc\":\""+result_json+"\",\"sign\":\""+result_sign+"\"}");
    }
};
產品在購買 (Purchase) 之後若再次購買則會失敗,在 Console 裡可以看到 "Item already owned" 訊息。若產品的類型屬於消耗性的產品,則需要執行 consume 指令才可再次購買 :
public void consume(String itemType, String jsonPurchaseInfo, String signature)
{
    String transedJSON =jsonPurchaseInfo.replace('\'', '\"');
    if (mHelper ==null)
        return;

    Purchase pp =null;
    try{
        pp =new Purchase(itemType, transedJSON, signature);
    }
    catch(Exception e){
        pp=null;
    }

    if (pp !=null){
        final Purchase currpp =pp;
        mActivity.runOnUiThread(new Runnable(){
            public void run(){
                mHelper.consumeAsync(currpp, mConsumeFinishedListener);
            }
        });
    }
}
其中參數 itemType 在此範例為 "inapp",jsonPurchaseInfo 及 signature 是在 purchase 完成後, IabHelper 回呼 onIabPurchaseFinished 所給的資料。consume 完成之後回呼的函數如下 :
IabHelper.OnConsumeFinishedListener mConsumeFinishedListener =new IabHelper.OnConsumeFinishedListener() {
    public void onConsumeFinished(Purchase purchase, IabResult result) {
        if (result.isSuccess()){
            Log.d("iabWrapper", "Consumption successful. Provisioning");
            UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"3\",\"ret\":\"true\",\"desc\":\""+purchase.getOriginalJson().replace('\"', '\'')+"\",\"sign\":\""+purchase.getSignature()+"\"}");
        }
        else{
            UnityPlayer.UnitySendMessage(mEventHandler, "msgReceiver", "{\"code\":\"3\",\"ret\":\"false\",\"desc\":\"\",\"sign\":\"\"}");
        }
    }
};

b. 編譯 iabWrapper.java

javac ./iabWrapper.java -cp /Users/macaronics/android-sdks/platforms/android-10/android.jar:/Applications/Unity/Unity.app/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar:/Users/macaronics/UnityIabProj/Assets/Plugins/Android/iabhelper.jar:/Users/macaronics/UnityIabProj/Assets/Plugins/Android/overrideActivity.jar -d .

其中引入的 library 有 android-10/android.jar (Android 2.3.3), Unity 的 classes.jar,先前編譯的 iabhelper.jar 及 overrideActivity.jar。然後輸入 :

jar cvfM ./iabWrapper.jar com/

產生的 iabWrapper.jar 放在 Unity 專案的 Assets/Plugins/Android/ 資料夾底下( 此範例將檔案置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android/) 。


5. 設定 Permission ( AndroidManifest.xml )

若要執行 GooglePlay Billing 功能則要設定 AndroidManifest.xml,將底下的 AndroidManifest.xml 置於 /Users/macaronics/UnityIabProj/Assets/Plugins/Android 資料夾。
<?xml version="1.0" encoding="utf-8"?>
<manifest android:versionCode="1" android:versionName="1.0" 
          android:installLocation="preferExternal" 
          package="com.macaronics.iab" 
          xmlns:android="http://schemas.android.com/apk/res/android">
  <supports-screens android:anyDensity="true" android:smallScreens="true" 
                    android:normalScreens="true" android:largeScreens="true" 
                    android:xlargeScreens="true" />
  <application android:label="@string/app_name" 
               android:icon="@drawable/app_icon" android:debuggable="false">
    <activity android:label="@string/app_name" 
              android:name="com.macaronics.iab.overrideActivity"
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"              
              >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              />
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerNativeActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              >
      <meta-data android:name="android.app.lib_name" android:value="unity" />
      <meta-data android:name="unityplayer.ForwardNativeEventsToDalvik" 
                 android:value="false" />
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.VideoPlayer" 
              android:screenOrientation="behind" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale" 
              />
  </application>
  <uses-feature android:glEsVersion="0x20000" />
 
  <uses-permission android:name="com.android.vending.BILLING" />
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

</manifest>

注意其中的 package="com.macaronics.iab" 要改成你自己的 package 名稱,與 Unity 裡的 Bundle Identifier 相同。此外,這裡還設定了主要啟動的 Activity 為 overrideActivity,以及 uses-permission。

到目前為止 /Users/macaronics/UnityIabProj/Assets/Plugins/Android 資料夾底下應該會有這些檔案 (iabhelper.jar, iabWrapper.jar, iiabs.jar, overrideActivity.jar, AndroidManifest.xml ) :

到目前為止應該要有的檔案

6. Unity 部分的 iabWrapper.cs

利用 AndroidJNI 介接 Java 程式 (注意為了接收 java 回傳的字串訊息,此 script (component) 必須加入到名為 iabWrapper 的 GameObject 裡)。 :
using UnityEngine;

using System.Collections;
using System.Collections.Generic;

public class iabWrapper : MonoBehaviour 
{
    public delegate void cbFunc(object[] retarr);
    cbFunc iabSetupCB =null;
    cbFunc iabPurchaseCB =null;
    cbFunc iabConsumeCB =null;

    AndroidJavaObject mIABHelperObj =null;
    static iabWrapper g_inst =null;

    void Start(){
        g_inst =this;
    }

    static public void init(string base64EncodedPublicKey, cbFunc tmpIabSetupCBFunc){
        if (g_inst ==null)
            return;

        g_inst.iabSetupCB =tmpIabSetupCBFunc;

        dispose();
        g_inst.mIABHelperObj =new AndroidJavaObject("com.macaronics.iab.iabWrapper", new object[2]{base64EncodedPublicKey, "iabWrapper"});
    }

    static public void dispose(){
        if (g_inst ==null)
            return;

        if (g_inst.mIABHelperObj !=null){
            g_inst.mIABHelperObj.Call("dispose");
            g_inst.mIABHelperObj.Dispose();
            g_inst.mIABHelperObj =null;
        }
    }

        ...

其中 init 負責呼叫 iabWrapper.java 之建構函數並建立該物件,dispose 則是負責刪除釋放 iabWrapper.java 物件。底下是主要的兩個功能,purchase 和 consume :
static public void purchase(string strSKU, int reqCode, string payload, cbFunc tmpIabPurchaseCBFunc){
    if (g_inst ==null)
        return;

    g_inst.iabPurchaseCB =tmpIabPurchaseCBFunc;

    if (g_inst.mIABHelperObj !=null){
        g_inst.mIABHelperObj.Call("purchase", new object[3]{strSKU, reqCode.ToString(), payload});
    }
}

static public void consume_inapp(string strPurchaseJsonInfo, string strSignature, cbFunc tmpIabConsumeCBFunc){
    if (g_inst ==null)
        return;

    g_inst.iabConsumeCB =tmpIabConsumeCBFunc;

    if (g_inst.mIABHelperObj !=null){
        g_inst.mIABHelperObj.Call("consume", new object[3]{"inapp", strPurchaseJsonInfo, strSignature});
    }
}
其中先將 callback 函數記錄起來,然後呼叫其對應的 java function。最後一部分是 msgReceiver,負責接收 java 回呼的結果並將資訊回呼對應的 callback function (先前呼叫 purchase 或是 consume 所給予的 callback function)。
void msgReceiver(string msg){
    if (g_inst ==null)
        return;
 
    //parse json
    Dictionary<string, object> cache =(Dictionary<string, object>)MiniJSON.Json.Deserialize(msg);

    //dispatch msg
    if (cache.ContainsKey("code")==true){
        int val =0;
        int.TryParse((string)cache["code"], out val);
        switch(val){
            case 0:{
                //unknown
                Debug.Log("Unity-iabWrappe :cannot parse cache[code]");

            }
            break;

            case 1:{
                //OnIabSetupFinishedListener
                if (cache.ContainsKey("ret")==true){
                    string retval =(string)cache["ret"];
                    if (retval =="true"){
                        //可使用
                        if (iabSetupCB !=null){
                            iabSetupCB( new object[1]{true} );
                        }

                    }
                    else if (retval =="false"){
                        //不可使用
                        if (iabSetupCB !=null)
                        {
                            iabSetupCB( new object[1]{false} );
                        }
                    }else{
                        Debug.Log("Unity-iabWrapper :cannot parse cache[ret], code=1");
                    }
                }
            }
            break;

            case 2:{
                //onIabPurchaseFinished
                if (cache.ContainsKey("ret")==true){
                    string retval =(string)cache["ret"];
                    if (retval =="true"){
                        //可使用
                        if (iabPurchaseCB !=null){
                            iabPurchaseCB( new object[3]{true, (string)cache["desc"], (string)cache["sign"]} );
                        }

                    }
                    else if (retval =="false"){
                        //不可使用
                        if (iabPurchaseCB !=null)
                        {
                            iabPurchaseCB( new object[3]{false, "", ""} );
                        }

                    }
                    else{
                        Debug.Log("Unity-iabWrapper  :cannot parse cache[ret], code=2");
                    }
                }
            }
            break;

            case 3:{
                //OnConsumeFinishedListener
                if (cache.ContainsKey("ret")==true){
                    string retval =(string)cache["ret"];
                    if (retval =="true"){
                        //可使用
                        if (iabConsumeCB !=null)
                        {
                            iabConsumeCB( new object[3]{true, (string)cache["desc"], (string)cache["sign"]} );
                        }

                    }else if (retval =="false"){
                        //不可使用
                        if (iabConsumeCB !=null)
                        {
                            iabConsumeCB( new object[3]{false, "", ""} );
                        }

                    }
                    else{
                        Debug.Log("Unity-iabWrapper :cannot parse cache[ret], code=3");

                    }
                }
            }
            break;
        }
    }
}
程式碼先將 JSON 字串轉為 object 物件並依照物件種類做對應的處理,其中 purchase 的 callback (case 2),回傳的參數依序為 : 1. 是否成功,2. purchase 結果字串以及 3. purchase 結果之 signature (可作為驗証之用,參考這篇)。


7. 實作 IAB APP

建立一個 script 名為 main,並將此 script 加入至 Camera。這裡實作一個 purchase 功能,並在 purchase 完成之後立刻執行 consume。
using UnityEngine;
using UnityEngine;
using System.Collections;
 
public class main : MonoBehaviour {
 
    // Use this for initialization
    void Start () {
        iabWrapper.init(
            "PUBLIC_KEY",
            delegate(object[] ret){
                if (true ==(bool)ret[0]){
                    Debug.Log("iab successfully initialized");
                }
                else{
                    Debug.Log("failed to initialize iab");
                }
            });
    }
 
    void OnGUI(){
        if (GUI.Button(new Rect(0, 0, 100, 100), "purchase")){
            iabWrapper.purchase("PRODUCT_SKU", 10001, "PRODUCT_SKU_AND_USER_ID_AND_DATE",
                delegate(object[] ret){
                    if (false ==(bool)ret[0]){
                        Debug.Log("purchase cancelled");
                    }
                    else{
                        string purchaseinfo =(string)ret[1];
                        string signature =(string)ret[2];
                        iabWrapper.consume_inapp(purchaseinfo, signature, 
                            delegate(object[] ret2){
                                if (false ==(bool)ret2[0])
                                {
                                    Debug.Log("failed to consume product");
                                }
                            });
                    }
                });
        }
    }
 
    void OnApplicationQuit(){
        iabWrapper.dispose();
    }
}
其中的 PUBLIC_KEY,更換成你的 Public Key,代入 purchase 函數的參數更改成你的參數。範例原始碼可以從這裡取得 : http://github.com/phardera/unity3d_googleplay_iab.git

2013-03-17

NodeJS 驗證 Google Play In-app Billing Signature

使用 IabHelper 可以方便介接 Google Play 的金流系統 (In-App billing, IAB) ,IabHelper 提供一個用來驗證下單結果 (使用者下單之後,由 GooglePlay IAB 回傳的結果 ) 的函數(Security.verifyPurchase),它主要用來確定資料是否有被竄改或是不完整。
若 App 的架構具有後端伺服器,則 IAB 回傳的結果應該由伺服器端來做驗證,底下是以 NodeJS 為例子的驗證程序 :
var crypto =require('crypto');
var verifySignature =function(publicKey, purchaseData, signature)
{
    var genPublicKey =function(key)
    {
        var chunkSize, chunks, str;
        str = key;
        chunks = [];
        chunkSize = 64;
        while (str) {
            if (str.length < chunkSize) {
                chunks.push(str);
                break;
            } else {
                chunks.push(str.substr(0, chunkSize));
                str = str.substr(chunkSize);
            }
        }
        str = chunks.join("\n");
        str = '-----BEGIN PUBLIC KEY-----\n' + str + '\n-----END PUBLIC KEY-----';
        return str;
    }
    
    return crypto.createVerify('RSA-SHA1').update(purchaseData).verify(genPublicKey(publicKey), signature, 'base64');
}


回傳若為 true 則資料正確。

參考來源 : https://github.com/nothing2lose/node-InAppBilling

2013-02-18

C# AES-256-CBC 加解密

使用到的名稱空間 :
using System;
using System.Text;
using System.Security.Cryptography;
using System.IO;

程式碼如下,將字串 "text to be encrypted" 加密後解密看結果是否相同 :
RijndaelManaged rijalg = new RijndaelManaged();
 
//-----------------
//設定 cipher 格式 AES-256-CBC
rijalg.BlockSize = 128;
rijalg.KeySize = 256;
rijalg.FeedbackSize = 128;
rijalg.Padding = PaddingMode.PKCS7;
rijalg.Mode = CipherMode.CBC;
 
rijalg.Key = (new SHA256Managed()).ComputeHash(Encoding.ASCII.GetBytes("IHazSekretKey"));
rijalg.IV = System.Text.Encoding.ASCII.GetBytes("1234567890123456");
 
//-----------------
//加密
ICryptoTransform encryptor =rijalg.CreateEncryptor(rijalg.Key, rijalg.IV);
 
byte[] encrypted;
// Create the streams used for encryption.
using (MemoryStream msEncrypt = new MemoryStream())
{
    using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
    {
        using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
        {
            //Write all data to the stream.
            swEncrypt.Write("text to be encrypted");
        }
        encrypted = msEncrypt.ToArray();
    }
}
 
//-----------------
//加密後的 base64 字串 :
//eiLbdhFSFrDqvUJmjbUgwD8REjBRoRWWwHHImmMLNZA=
System.Console.WriteLine(Convert.ToBase64String(encrypted));
 
//-----------------
//解密
ICryptoTransform decryptor = rijalg.CreateDecryptor(rijalg.Key, rijalg.IV);
 
string plaintext;
// Create the streams used for decryption. 
using (MemoryStream msDecrypt = new MemoryStream(encrypted))
{
    using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
    {
        using (StreamReader srDecrypt = new StreamReader(csDecrypt))
        {
 
            // Read the decrypted bytes from the decrypting stream 
            // and place them in a string.
            plaintext = srDecrypt.ReadToEnd();
        }
    }
}
 
//-----------------
//最後印出字串 "text to be encrypted"
System.Console.WriteLine(plaintext);

iOS App 在背景狀態下持續更新 GPS 回報之位置

一般 App 在進入 Background 背景狀態之後就會被暫停執行,但做以下設定之後 App 可在此狀態持續更新 GPS 回報的使用者座標位置 :

1. 在 App 的 Info.plist 檔案的 Required device capabilities 項目底下加入 location-services 及 gps
2. 同樣在該檔案的 Required background modes 底下加入 App registers for location updates

設定 Info.plist 檔案之 Required device capabilities 及 Required background modes

接下來的範例是開一個 empty iOS Application project 然後加入一些程式碼來測試 :

1. 為 project 加入 CoreLocation.framework

加入 CoreLocation.framework 至 Linked Frameworks and framework

2. 在 AppDelegate.h 檔案增加 / 修改程式碼  :
#import <CoreLocation/CoreLocation.h>
@interface AppDelegate : UIResponder <UIApplicationDelegate, CLLocationManagerDelegate>
3. 在 AppDelegate.cpp 檔案增加程式碼 :
CLLocationManager* locationManager =nil;
- (void)startStandardUpdates
{
    if (nil == locationManager)
        locationManager = [[CLLocationManager alloc] init];
    
    locationManager.delegate = self;
    locationManager.desiredAccuracy = kCLLocationAccuracyBest;
    locationManager.distanceFilter = kCLDistanceFilterNone;
    [locationManager startUpdatingLocation];
}

-(void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error
{
    NSLog(@"update location err-\n%@", error);
}

// Delegate method from the CLLocationManagerDelegate protocol.
- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations
{
    CLLocation* location = [locations lastObject];
    NSString* pp =[NSString stringWithFormat:@"http://192.168.11.6:10001/?lat=%.3f&lon=%.3f", location.coordinate.latitude, location.coordinate.longitude];
    
    NSURL* response =[NSURL URLWithString:pp];
    NSString* response_str =[NSString stringWithContentsOfURL:response encoding:NSUTF8StringEncoding error:nil];
    NSLog(@"%@, response of report=%@", pp, response_str);
}

函數 didUpdateLocations 在每次更新 GPS 回報的位置時使用 NSURL 以 http 方式向伺服器 192.168.11.6 以 GET 方式回報位置 (lat, lon)。

4. 在 AppDelegate.cpp 檔案之函數 didFinishLaunchingWithOptions 內加入程式碼 :
[self startStandardUpdates];
在程式啟動時呼叫 startStandardUpdates 開始取得 GPS 回報的使用者位置。

2013-01-30

NodeJS 簡單的 HTTP Server

底下的範例在啟動之後自動指定同一資料夾為 HTTP Server 之根目錄:
var http = require('http'),
    url = require('url'),
    path = require('path'),
    fs = require('fs');
 
var mimeTypes = {
    "html": "text/html",
    "jpeg": "image/jpeg",
    "jpg": "image/jpeg",
    "png": "image/png",
    "js": "text/javascript",
    "css": "text/css"};
 
http.createServer(function(req, res) {
    var uri = url.parse(req.url).pathname;
    console.log(uri);
    var empty =function(){
        res.writeHead(200, {'Content-Type': 'text/plain'});     
        res.end();
    };
    if (uri ==='/'){
        return empty();
    };
    var filename = path.join(process.cwd(), uri);
    fs.exists(filename, function(exists){
        if(!exists || fs.lstatSync(filename).isDirectory()===true){
            return empty();
        }
        res.writeHead(200, {
            'Content-Type':mimeTypes[path.extname(filename).split(".")[1]]
        });
        fs.createReadStream(filename).pipe(res);
    }); //end fs.exists
}).listen(8080);
在同一資料夾裡擺一個檔案 (index.html),啟動之後利用網址 http://localhost:8080/index.html 即可瀏覽該檔案。

2012-12-17

刪除 Open With 選單裡重覆的項目


OSX 用久了會發現當要用右鍵開啟某些檔案時,在 Open With 選單裡有重覆的項目,此時可在 Terminal 裡輸入 :

/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -kill -r -domain local\ -domain system -domain user

待執行完成之後 Relaunch Finder (control+option+滑鼠右鍵 點選 Finder ),即可刪除重覆的項目。

ref : http://itpixie.com/2011/05/fix-duplicate-old-items-open-with-list/#.UM3vUqWVgcQ

2012-11-29

Unity 3D + JmDNS + Android

此篇文章實作將 JmDNS 包裝成可以在 Android 平台上執行之 Unity3D Plugin。使用 JmDNS 可以方便行動裝置在區域網路內發現對方,以進行後續的連線。(此機制也稱為 Bonjour ,最初由 Apple 提出。)因為 iOS 平台的 NSNetService 也提供相同的功能,所以 Android 裝置和 iOS 裝置皆可以透過此機制取得對方的網路位置。底下為實作步驟:

A) 至 jmdns.sourceforge.net 取得 JmDNS 並利用 ant 進行編譯:
1) 以 osx 平台為例,在 terminal 輸入:
svn checkout https://jmdns.svn.sourceforge.net/svnroot/jmdns/tags/jmdns-3.4.1 jmdns

2) 待程式碼下載完成後進入 jmdns 資料夾輸入 :
ant jar

3) 完成之後進入資料夾 ./build/lib/ (此時應該找到檔案 jmdns.jar),並輸入底下指令:
mkdir unjar
cd unjar
jar xf ../jmdns.jar
jar cfm ../jmdns.jar META-INF/MANIFEST.MF javax/

目的在於去除重覆的 class 檔案,以避免 Android dex 工具編譯時出現錯誤。

B) 建立 Unity 專案並設定權限:
1) 將 jmdns.jar 檔案復製到 Unity 專案底下資料夾 (Assets/Plugins/Android)
2) 在 Assets/Plugins/Android 資料夾底下新增檔案 AndroidManifest.xml 並設定內容如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest android:versionCode="1" android:versionName="1.0" 
          android:installLocation="preferExternal" 
          package="com.macaronics.jmdns" 
          xmlns:android="http://schemas.android.com/apk/res/android">
  <supports-screens android:anyDensity="true" android:smallScreens="true" 
                    android:normalScreens="true" android:largeScreens="true" 
                    android:xlargeScreens="true" />
  <application android:label="@string/app_name" 
               android:icon="@drawable/app_icon" android:debuggable="false">
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerProxyActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              />
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.UnityPlayerNativeActivity" 
              android:screenOrientation="portrait" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale"
              >
      <meta-data android:name="android.app.lib_name" android:value="unity" />
      <meta-data android:name="unityplayer.ForwardNativeEventsToDalvik" 
                 android:value="false" />
    </activity>
    <activity android:label="@string/app_name" 
              android:name="com.unity3d.player.VideoPlayer" 
              android:screenOrientation="behind" 
              android:configChanges=
"locale|mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale" 
              />
  </application>
  <uses-feature android:glEsVersion="0x20000" />
 
  <uses-permission android:name="android.permission.INTERNET"/>
  <uses-permission 
    android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
</manifest>

值得注意的是 uses-permission 的部分,其設定網路使用權限使 JmDNS 在 Android 系統下能夠作用。

C) 撰寫使用 JmDNS 之 Java Class ( jmDNSWrapper.java ):
這裡不直接從 C# 透過 JNI 呼叫 JmDNS 之函數,而是先撰寫一個名為 JmDNSWrapper 的 Java Class 將 JmDNS 的使用簡化,再讓 Unity 透過 JNI 呼叫使用此 JmDNSWrapper Class,目的是簡化 JNI 的使用。此 Class 大略分為五個部分分別敘述如下

1) 初始化 jmDNS 及 wifi service:
public jmDNSWrapper()
{
    Log.i("jmDNSWrapper", "onStart()...");
    mActivity =UnityPlayer.currentActivity;
    if (wifi ==null){
        Log.i("jmDNSWrapper", "get wifi manager...");
        wifi = (android.net.wifi.WifiManager)mActivity.getSystemService(
            android.content.Context.WIFI_SERVICE
        );
    }
        
    if (lock ==null && wifi !=null){
        Log.i("jmDNSWrapper", "lock wifi multicast...");
        lock = wifi.createMulticastLock("mylockthereturn");
        lock.setReferenceCounted(true);
        lock.acquire();
    }
        
    if (mJmDNS ==null){
        try{
            mJmDNS =JmDNS.create();
        }
        catch(IOException e){
            e.printStackTrace();
        }
    } 
}

2) 結束此 class(停止 jmDNS):
public void dispose()
{
    Log.i("jmDNSWrapper", "onStop()...");
 
    stopListener();
    stopService();
        
    if (mJmDNS !=null){
        try{
            mJmDNS.close();
        }
        catch(IOException e){
            e.printStackTrace();
        }
        mJmDNS =null;
    }
        
    if (lock !=null){
        Log.i("jmDNSWrapper", "unlock wifi multicast...");
        lock.release();
    }
    lock =null; 
}

3) 發佈/停止 Service :
public void publishService(
    String strService, 
    String strDeviceName, 
    String strDesc, 
    int port)
{
    if (mJmDNS ==null)
        return;
        
    stopService();
        
    Log.i("jmDNSWrapper", "trying to create service...");
    try{
        ServiceInfo serviceInfo = 
            ServiceInfo.create(strService, strDeviceName, port, strDesc);
        mJmDNS.registerService(serviceInfo);
            
    }
    catch(IOException e){
        e.printStackTrace();
    }    
}
public void stopService()
{
    if (mJmDNS !=null){
        Log.i("jmDNSWrapper", "unregisterAllServices()...");
        mJmDNS.unregisterAllServices();
    }
}

4) 搜尋/停止 Service :
public void startListener(
    String strServiceType, 
    String strEventHandlerObjName)
{
    if (mJmDNS ==null)
        return;
        
    stopListener();
    mStrEventHandlerName =strEventHandlerObjName;
    mListener =new ServiceListener()
    {
        @Override
        public void serviceResolved(ServiceEvent e)
        {
            String additions = "";
            if (e.getInfo().getInetAddresses() != null && 
                e.getInfo().getInetAddresses().length > 0) {
                additions = 
                    e.getInfo().getInetAddresses()[0].getHostAddress();
            }
                
            Log.i("jmDNSWrapper", "service resolved: "+
                e.getInfo().getQualifiedName()+", port: "+
                e.getInfo().getPort()+", ip: "+additions);
 
            UnityPlayer.UnitySendMessage(
                mStrEventHandlerName, 
                "serviceResolved", "{\"name\":\""+
                e.getInfo().getQualifiedName()+"\",\"ip\":\""+additions+
                "\",\"port\":\""+e.getInfo().getPort()+"\"}");
        }
            
        @Override
        public void serviceRemoved(ServiceEvent e)
        {
            String additions = "";
            if (e.getInfo().getInetAddresses() != null && 
                e.getInfo().getInetAddresses().length > 0) {
                additions = 
                    e.getInfo().getInetAddresses()[0].getHostAddress();
            }
                
            Log.i("jmDNSWrapper", "service removed: "+e.getName());
            UnityPlayer.UnitySendMessage(mStrEventHandlerName, 
                "serviceRemoved", 
                "{\"name\":\""+e.getInfo().getQualifiedName()+
                "\",\"ip\":\""+additions+"\",\"port\":\""+
                e.getInfo().getPort()+"\"}");
        }
            
        @Override
        public void serviceAdded(ServiceEvent e)
        {
            mJmDNS.requestServiceInfo(e.getType(), e.getName(), 1);
        }
    };
        
    Log.i("jmDNSWrapper", "add service listener...");
    mStrServiceType =strServiceType;        
    mJmDNS.addServiceListener( mStrServiceType, mListener);
}
    
public void stopListener()
{
    if (mJmDNS !=null && mListener !=null){
        Log.i("jmDNSWrapper", "removeServiceListener()...");            
        mJmDNS.removeServiceListener( mStrServiceType, mListener );
    }
        
    mListener =null;
    mStrServiceType =null;
}
其中使用 UnitySendMessage 來傳送字串資訊給 Unity,字串格式為 JSON 格式。

D) 編譯 jmDNSWrapper.java:
與 jmDNS 相同,將 jmDNSWrapper 編譯成 jar 檔案,此文章假設使用者已安裝 Android SDK 且安裝之 Android 平台版本為 4.0.3 (API Level 15)。

1) 輸入指令,將 java 編譯成 jmDNSWrapper.class :
javac jmDNSWrapper.java -cp ~/Downloads/android-sdk-macosx/platforms/android-15/android.jar:../jmDNS.jar:/Applications/Unity/Unity.app/Contents/PlaybackEngines/AndroidPlayer/bin/classes.jar -d .

此指令分別提供三個 jar 檔在編譯時使用。

2) 將 com 資料夾(資料夾按照 jmDNSWrapper.java 裡 "package com.macaronics" 產生)底下的 class 檔壓成 jar 檔案:
jar cvfM jmDNSWrapper.jar com/

3) 將 jmDNSWrapper.jar 檔案復製到 Unity 專案底下資料夾 (Assets/Plugins/Android)

E) Unity 引用 jmDNSWrapper :
這邊的目的是讓 jmDNSWrapper.java 裡的函數能在 C# 環境下呼叫。
public class jmDNSWrapper
{
    AndroidJavaObject mJmDNSWrapperObj = null;
    public void init()
    {
        if (mJmDNSWrapperObj != null){
            mJmDNSWrapperObj.Call("dispose");
            mJmDNSWrapperObj.Dispose();
            mJmDNSWrapperObj = null;
        }
        mJmDNSWrapperObj = 
            new AndroidJavaObject("com.macaronics.jmDNSWrapper");
    }
 
    public void dispose()
    {
        if (mJmDNSWrapperObj != null){
            mJmDNSWrapperObj.Call("dispose");
            mJmDNSWrapperObj.Dispose();
        }
        mJmDNSWrapperObj = null;
    }
 
    public void publishService(
        string strService,
        string strDeviceName,
        string strDesc,
        int port)
    {
        if (mJmDNSWrapperObj == null)
            return;
        mJmDNSWrapperObj.Call("publishService",
            new object[4] { strService, strDeviceName, strDesc, port });
    }
 
    public void stopService()
    {
        if (mJmDNSWrapperObj == null)
            return;
        mJmDNSWrapperObj.Call("stopService");
    }
 
    public void startListener(
        string strServiceType, 
        string strEventHandlerObjName){
        if (mJmDNSWrapperObj == null)
            return;
        mJmDNSWrapperObj.Call("startListener",
            new object[2] { strServiceType, strEventHandlerObjName });
    }
 
    public void stopListener()
    {
        if (mJmDNSWrapperObj == null)
            return;
        mJmDNSWrapperObj.Call("stopListener");
    }
}
其利用 Unity 提供的 AndroidJavaObject 來呼叫連結外部 Java 程式碼。

F) 實際範例
最後撰寫一個簡單的範例來實際測試一下 jmDNS,此範例有兩個按鈕,分別是發佈與停止服務,還有將搜尋到的服務顯示在 Console Window。
public class main : MonoBehaviour
{
    string strServiceType = "_macaronics._tcp.local.";
    jmDNSWrapper pp = null;
 
    void Start()
    {
        pp = new jmDNSWrapper();
        pp.init();
 
        //設定服務名稱及 jmDNS 回呼之物件名稱
        pp.startListener(strServiceType, gameObject.name);
    }
 
    void OnDisable()
    {
        if (pp != null)
        {
            pp.dispose();
            pp = null;
        }
    }
 
    void OnGUI()
    {
        if (GUI.Button(new Rect(15, 125, 450, 100), "publish service"))
        {
            pp.publishService(strServiceType,
                "MACARONICS",
                "BONJOUR TEST FROM MACARONICS",
                9000);
        }
 
        if (GUI.Button(new Rect(15, 125 + 100, 450, 100), "stop service"))
        {
            pp.stopService();
        }
    }
 
    public void serviceResolved(string msg)
    {
        //顯示發現的服務
        Debug.Log(msg);
    }
 
    public void serviceRemoved(string msg)
    {
        //顯示移除的服務
        Debug.Log(msg);
    }
}

G) 最後實機測試
很不幸的在 Android Emulator 上無法做 wifi 連線測試,因此需要一台真正的 Android 手機。在 Unity 上設定好 Android 編譯參數後按下 Build And Run,順利的話會看到程式在手機上跑起來了。此時在 Android SDK 裡找到 adb 執行檔,執行 adb logcat 之後就可以看到 Unity 的 Log 訊息。

2012-11-14

Unity 3D 使用 Animation 工具調整 NGUI 之 UISprite 的 Color 及 Alpha 值

Unity 的 Animation 工具可以在 NGUI 的 UISprite 上增加位移,縮放,旋轉等動作控制,如下圖所示,圖中使用 Animation 工具編輯 Sprite 物件的位置資訊,值得注意的是,移動時間軸 (紅色垂直線) 即可同步觀察 Sprite 物件移動。

Animation 工具控制 Sprite 的位置資訊

但若更改 Sprite 的顏色 (Animation 工具的 MColor) ,會發現只有 Inspector 視窗內 Color Tint 屬性及 Previw 會依照時間軸的移動作相對應的變化,Scene 視窗裡 Sprite 的顏色沒有變化。如下圖所示,在時間軸所在位置的 Sprite 應該呈現黃色,但 Scene 內的 Sprite 仍然為紅色。

Animation 工具對顏色的控制

礙於 NGUI 程式架構的關係,無法直接以修改 MColor 參數的方法控制顏色。
本文提供方法讓 Animation 工具可以編輯顏色且 "在編輯的同時就可以同步觀察變化"
建立一 Component (C# Script 如下所述) 並加入到要編輯的物件上。
using UnityEngine;
using UnityEditor;
using System.Collections;
 
public class ngui_color_ani_setup : MonoBehaviour
{
    public float Alpha = 1.0f;
    public float Red = 1.0f;
    public float Green = 1.0f;
    public float Blue = 1.0f;
 
    protected UISprite uis = null;
 
    void LateUpdate()
    {
        setupcolor();
    }
 
    void OnDrawGizmos()
    {
        //關閉自動產生 keyframe
        //(避免因為更動 mColor 而自動產生新的 keyframe)
        SetDisableAutoRecordMode();
        setupcolor();
    }
 
    void setupcolor()
    {
        if (uis == null)
            uis = gameObject.GetComponent<UISprite>();
 
        uis.color = new Color(Red, Green, Blue, Alpha);
    }
 
    void SetDisableAutoRecordMode()
    {
        System.Type T =
            System.Type.GetType("UnityEditor.AnimationWindow,UnityEditor");
        Object[] allAniWindows = Resources.FindObjectsOfTypeAll(T);
        if (allAniWindows.Length > 0)
        {
            UnityEditor.EditorWindow win = 
                (UnityEditor.EditorWindow)allAniWindows[0];
            T.InvokeMember("SetAutoRecordMode",
                System.Reflection.BindingFlags.InvokeMethod,
                null,
                win,
                new object[1] { false });
        }
    }
 
}

如下圖所示,只要編輯 ngui_color_ani_setup 之值即可控制該 Sprite 之顏色,且在編輯時就能同步觀察顏色的變化。

編輯 NGUI 之 Sprite 的顏色動畫曲線,編輯時能同步觀察變化