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 的顏色動畫曲線,編輯時能同步觀察變化


2012-11-01

Lua Callback Function

底下在 Unity3D ( MAC OSX ) 環境下實作 Lua 呼叫 C# 函數 (tellMeNum) 並且同時把 Lua 的函數 (dispResult) 告訴 C#,待 C# 回呼此函數並告知數字 12345.678。
C# 端程式碼如下 :
using UnityEngine;
using System;
using System.Collections;
using System.Runtime.InteropServices;
 
public class luaTest : MonoBehaviour
{
    [DllImport("lua521")]
    public static extern IntPtr luaL_newstate();
    [DllImport("lua521")]
    public static extern void luaL_openlibs(IntPtr lua_State);
    [DllImport("lua521")]
    public static extern void lua_close(IntPtr lua_State);
    [DllImport("lua521")]
    public static extern void lua_pushcclosure(IntPtr lua_State, LuaFunction func, int n);
    [DllImport("lua521")]
    public static extern void lua_setglobal(IntPtr lua_State, string s);
    [DllImport("lua521")]
    public static extern int lua_pcallk(IntPtr lua_State, int nargs, int nresults, int errfunc, int ctx, LuaFunction func);
    [DllImport("lua521")]
    public static extern int luaL_loadfilex(IntPtr lua_State, string s, string mode);
    [DllImport("lua521")]
    public static extern int luaL_loadstring(IntPtr lua_State, string s);
    [DllImport("lua521")]
    public static extern IntPtr luaL_checklstring(IntPtr lua_State, int idx, IntPtr len);
 
    [DllImport("lua521")]
    public static extern int lua_rawgeti(IntPtr lua_State, int idx0, int idx1);
    [DllImport("lua521")]
    public static extern int luaL_ref(IntPtr lua_State, int idx);
    [DllImport("lua521")]
    public static extern int luaL_unref(IntPtr lua_State, int idx0, int idx1);
    [DllImport("lua521")]
    public static extern int lua_pushnumber(IntPtr lua_State, double num);
 
    //------------------------------------
    // Lua
    //
 
    //註冊 C# Function 讓 Lua 可以呼叫執行
    public delegate int LuaFunction(IntPtr pLuaState);
    public static void lua_register(IntPtr pLuaState, string strFuncName, LuaFunction pFunc)
    {
        lua_pushcclosure(pLuaState, pFunc, 0);
        lua_setglobal(pLuaState, strFuncName);
    }
 
    //執行 Lua 檔案
    public static int luaL_dofile(IntPtr lua_State, string s)
    {
        if (luaL_loadfilex(lua_State, s, null) != 0)
            return 1;
        return lua_pcallk(lua_State, 0, -1, 0, 0, null);
    }
 
    //執行 Lua Callback Function
    public static int luaL_doCallBackFromCBIdx(IntPtr lua_State, int idx, double num)
    {
        lua_rawgeti(lua_State, -1000000 - 1000, idx);
        lua_pushnumber(lua_State, num);
        int ret = lua_pcallk(lua_State, 1, 0, 0, 0, null);
        if (ret != 0)
        {
            //Error...
        }
        luaL_unref(lua_State, -1000000 - 1000, idx);
        return ret;
    }
 
 
    //------------------------------------
    // Application
    //
    public static int nLuaCBFuncIdx = -1;
    public static IntPtr m_luaState = IntPtr.Zero;
 
    public static int tellMeNum(IntPtr pLuaState)
    {
        //取得回呼函數堆疊上的 index
        nLuaCBFuncIdx = luaL_ref(pLuaState, -1000000 - 1000);
        return 0;
    }
 
    //讓 Lua 在 unity 裡顯示 log 訊息
    public static int msg(IntPtr pLuaState)
    {
        IntPtr retPtr = luaL_checklstring(pLuaState, 1, IntPtr.Zero);
        string retStr = System.Runtime.InteropServices.Marshal.PtrToStringAnsi(retPtr);
 
        Debug.Log(retStr);
        return 0;
    }
 
    //取得 unity 執行路徑
    public static string GetAppPath()
    {
        return Application.dataPath.Substring(0, Application.dataPath.Length - 6);
    }
 
    // Use this for initialization
    void Start()
    {
        //初始化 Lua
        m_luaState = luaL_newstate();
        luaL_openlibs(m_luaState);
 
        //註冊 C# Function
        lua_register(m_luaState, "tellMeNum", tellMeNum);
        lua_register(m_luaState, "msg", msg);
 
        //執行 Lua 檔案
        string strPath = GetAppPath() + "LuaScript/lua.txt";
        luaL_dofile(m_luaState, strPath);
    }
 
    // Update is called once per frame
    void Update()
    {
        if (nLuaCBFuncIdx != -1)
        {
            //回呼取得的 lua callback function, 並給予數字 12345.678
            luaL_doCallBackFromCBIdx(m_luaState, nLuaCBFuncIdx, 12345.678);
            nLuaCBFuncIdx = -1;
        }
    }
 
    void OnApplicationQuit()
    {
        lua_close(m_luaState);
    }
}
其中值得注意的是 luaL_doCallBackFromCBIdx 函數,其用來回呼 Lua Callback Function。

lua.txt 內容如下:
function dispResult(num)
   msg(num)
end
 
tellMeNum(dispResult)
此 lua script 呼叫 tellMeNum 並將 dispResult 以參數傳給 C#,待 C# 呼叫 dispResult,dispResult 接收到 num 之後呼叫 msg 使 num 顯示在 Unity Console。

完整範例程式可在此取得:https://github.com/phardera/unity3d_lua_callback_function.git