云開發(fā)讓 Unity 微信小游戲?qū)崟r(shí)聊起來(lái)

寫在最前

本故事是《How Can Unity+騰訊云開發(fā)=微信小游戲?》的續(xù)篇,主要聊的是在使用 Unity 開發(fā)微信小游戲過(guò)程中,如何使用云開發(fā)來(lái)給小游戲增添一抹實(shí)時(shí)互動(dòng)的亮色(比如實(shí)時(shí)聊天)

溫馨提示:各家的云開發(fā)功能各具特色,本文的云開發(fā)特指騰訊云云開發(fā)

云開發(fā),哪個(gè)服務(wù)可實(shí)現(xiàn)實(shí)時(shí)聊天?

丹尼爾:蛋兄,我又來(lái)了。上次跟你聊完(請(qǐng)看上集《How Can Unity+騰訊云開發(fā)=微信小游戲?》)后,我已經(jīng)在 Unity 微信小游戲中用上云開發(fā)的數(shù)據(jù)模型了,云函數(shù)也順手捎上了

蛋先生:不錯(cuò),挺速度的嘛

丹尼爾:這些一來(lái)一回的后端接口,使用數(shù)據(jù)模型和云函數(shù),唰唰唰一下就搞定了,別提多爽

蛋先生:是的,對(duì)于后端接口的搭建,這些服務(wù)確實(shí)可以大大簡(jiǎn)化你的工作,讓你聚焦你的業(yè)務(wù)

丹尼爾:但我現(xiàn)在又遇到問(wèn)題了

蛋先生:我就知道,無(wú)事不登三寶殿

丹尼爾:瞧您說(shuō)的,主要是來(lái)看看您,順便問(wèn)下問(wèn)題啦 (′▽`〃)

蛋先生:直說(shuō)吧,啥問(wèn)題

丹尼爾:我的小游戲里,玩家之間是可以聊天的,但我沒發(fā)現(xiàn)云開發(fā)有 WebSocket 相關(guān)的服務(wù)

蛋先生:據(jù)我所知,云開發(fā)目前是沒有提供這種純粹的服務(wù)的。但是,云數(shù)據(jù)庫(kù)有實(shí)時(shí)推送的功能,用它來(lái)實(shí)現(xiàn)你的需求應(yīng)該是木有問(wèn)題的

丹尼爾:啊~,在云數(shù)據(jù)庫(kù)這啊,藏得夠深的,How?

Unity 如何用上云數(shù)據(jù)庫(kù)?

蛋先生:首先,咱們得讓 Unity 能用上云數(shù)據(jù)庫(kù),你需要……

(丹尼爾打斷了蛋先生的講話)

丹尼爾:我懂我懂,這跟《How Can Unity+騰訊云開發(fā)=微信小游戲?》提到的數(shù)據(jù)模型是一個(gè)套路的

蛋先生:那你先去擼代碼

1.jpeg

丹尼爾:蛋兄,搞不定 (o_ _)?。這云數(shù)據(jù)庫(kù)的 API 不像數(shù)據(jù)模型那么簡(jiǎn)單,我實(shí)在想不出如何用一個(gè)萬(wàn)能 JS 函數(shù)搞定

蛋先生:咳咳~。那咱們先把云數(shù)據(jù)庫(kù)增刪查改的調(diào)用示例整理出來(lái),如下

var db = app.database();

db.collection("hello").add({...})
db.collection("hello").doc("...").remove()
db.collection("hello").where({...}).remove()
db.collection("hello").doc("...").get()
db.collection("hello").where({...}).get()
db.collection("hello").get()
db.collection("hello").doc("...").update({...})
db.collection("hello").doc("...").set({...})
db.collection("hello").where({...}).update({...})

你看出什么門道了沒?

丹尼爾:都有 collection?都是鏈?zhǔn)秸{(diào)用?

蛋先生:說(shuō)到重點(diǎn)了,鏈?zhǔn)秸{(diào)用。鏈?zhǔn)秸{(diào)用就像是一串糖葫蘆,一步接一步:方法名,入?yún)?,方法名,入?yún)?..

丹尼爾:然后呢?

蛋先生:根據(jù)這個(gè)規(guī)律,我們可以定一個(gè) chainList 入?yún)?lái)實(shí)現(xiàn) JS 函數(shù),每一項(xiàng)就是一個(gè)方法名和方法入?yún)?。代碼如下

Database_API: async function (callbackId, params) {
    ...
    const { collectionName, chainList } = asmLibraryArg
        .Utils()
        .parseInputParams(params);
    ...
    let db;

    if (platform === constants.PLATFROM.WX) {
        db = wx.cloud.database();
    } else if (platform === constants.PLATFROM.WEB) {
        db = app.database();
    }

    let chainObj = db.collection(collectionName);
    chainList.forEach((chainItem) => {
        const method = chainItem.method;
        const optionsStr = chainItem.optionsStr;
        let options = optionsStr ? JSON.parse(optionsStr) : "";
        ...
        chainObj = chainObj[method](options);
    });
    const data = await chainObj;
    asmLibraryArg.Utils().sendMessage(callbackId, data.data || data);
}

丹尼爾:你他 * 的真是個(gè)人才

蛋先生:夸人可以,但要文明

丹尼爾:嘻嘻,接下來(lái)就是 Unity 實(shí)現(xiàn)了

蛋先生:我們可以把剛剛整理的調(diào)用示例發(fā)給 GPT,讓它幫咱們生成初步的接口定義和類實(shí)現(xiàn),我們?cè)僬{(diào)整一下即可。大概的 Prompt 如下

JS 是這么調(diào)用的
var db = app.database();
db.collection("hello").add({})
...

我希望在 Unity 也能這樣調(diào)用,請(qǐng)幫我設(shè)計(jì)相應(yīng)的類或接口

丹尼爾:可以啊,AI 用得溜溜的

蛋先生:基操而已。接下來(lái)我們來(lái)填補(bǔ)真正的實(shí)現(xiàn)細(xì)節(jié)

丹尼爾:好咧~

(溫馨提醒:請(qǐng)參考下邊的【代碼塊一】進(jìn)行閱讀)

蛋先生:對(duì)于每一個(gè)鏈?zhǔn)秸{(diào)用,我們只需實(shí)現(xiàn)最后的方法

比如 db.collection("hello").where({...}).get(),要填補(bǔ)實(shí)現(xiàn)的方法就是 QueryHandlerGet<T> 方法

而它的實(shí)現(xiàn)僅僅是提供 collection 名稱(collectionName)和鏈?zhǔn)秸{(diào)用的方法名和入?yún)ⅲ╟hainList)

公共邏輯實(shí)現(xiàn) CommonHandler 跟數(shù)據(jù)模型的實(shí)現(xiàn)基本一致,這里就不作贅述

//【代碼塊一】

private class Database : IDatabase
{
    public ICollection Collection(string name) => new CollectionHandler(name);

    private static async Task<T> CommonHandler<T>(DatabaseAPIParam param)
    {
        (string, TaskCompletionSource<string>) asyncTask = Internal.GetAsyncTask();

        Internal.Database_API(asyncTask.Item1, JsonConvert.SerializeObject(param));

        string result = await asyncTask.Item2.Task;
        return Internal.ParseOutputResult<T>(result);
    }

    public class CollectionHandler : ICollection
    {
        private readonly string collectionName;
        public CollectionHandler(string name)
        {
            collectionName = name;
        }

        ...
        public IQuery Where(object filter) => new QueryHandler(collectionName, filter);
        ...
    }

    ...

    public class QueryHandler : IQuery
    {
        private string collectionName;
        private object filter;
        public QueryHandler(string collectionName, object filter)
        {
            this.collectionName = collectionName;
            this.filter = filter;
        }

        public Task<T> Get<T>()
        {
            return CommonHandler<T>(new DatabaseAPIParam()
            {
                collectionName = collectionName,
                chainList = new[] {
                            new ChainItem() {
                                method = "where",
                                optionsStr = JsonConvert.SerializeObject(filter)
                            },
                            new ChainItem() {
                                method = "get",
                                optionsStr = ""
                            }
                        }
            });
        }
        ...
    }

}

private class ChainItem
{
    public string method { get; set; }
    public string optionsStr { get; set; }
}
private class DatabaseAPIParam
{
    public string collectionName { get; set; }
    public ChainItem[] chainList { get; set; }
}

實(shí)時(shí)推送 Watch,需要重點(diǎn)講講

丹尼爾:云數(shù)據(jù)庫(kù)這種一來(lái)一回的模式,被你這么一說(shuō),對(duì)接起來(lái)還是挺簡(jiǎn)單的。然而到現(xiàn)在,實(shí)時(shí)推送還沒有呢

蛋先生:實(shí)時(shí)推送的對(duì)接有點(diǎn)不一樣,我們先來(lái)看下 JS 的調(diào)用示例

var db = app.database();

const watcher = db
  .collection("hello")
  .where({
    // query...
  })
  .watch({
    onChange: function (data) {
      ...
    },
    onError: function (err) {
      ...
    }
  });
  
// watcher.close()

丹尼爾:恩,請(qǐng)把"有點(diǎn)"去掉,謝謝

蛋先生:為了更好地理解,我們要從實(shí)時(shí)推送的生命周期說(shuō)起。以下是對(duì)應(yīng) JS 版本的在 Unity 調(diào)用 Watch 的代碼

var watchObj = database.Collection("hello").Where(new Dictionary<string, object>
{
    // query...
})
.Watch(new WatchParams<ModelHello>()
{
    OnChange = (WatchChangeData<ModelHello> data) =>
    {
        ...
    },
    OnError = (string err) =>
    {
        ...
    }
});

丹尼爾:接下來(lái)又是一大波讓人頭疼的代碼片段嗎?(>人<;)

蛋先生:嘿嘿,代碼是不可避免的,依然需要結(jié)合下邊代碼【腳本C】和【腳本J】來(lái)看(溫馨提示:【腳本C】和【腳本J】為往下一點(diǎn)點(diǎn)的兩個(gè)大代碼塊)

連接的建立

丹尼爾:Come on,我已經(jīng)準(zhǔn)備好了!

蛋先生:【腳本C】中的 Watch<T> 方法是一切的開始

public IWatchObj Watch<T>(WatchParams<T> param)

首先,我們獲取 uuid,作為 JS 與 Unity 溝通的憑證

然后,實(shí)例化一個(gè) WatchObj 對(duì)象,并把它保存在 watchDictionary 字典中,以備后用

接著,調(diào)用 Database_API JS 方法

最后,把 WatchObj 返回

丹尼爾:我注意到 watch 的入?yún)⑹?action = open

蛋先生:眼力不錯(cuò)。這里設(shè)計(jì)了入?yún)?action,是為了可以支持多種行為(當(dāng)前只需支持 open 和 close)

丹尼爾:好,請(qǐng)繼續(xù)!

蛋先生:緊接著就到了 Database_API JS 方法這?!灸_本J】中加了個(gè)分支邏輯(通過(guò)判斷鏈?zhǔn)秸{(diào)用最后的方法名是否為 watch)來(lái)處理 watch 行為,即調(diào)用云數(shù)據(jù)庫(kù)的 watch API,這樣連接就建立上了。我們利用 JS 函數(shù)也是對(duì)象的特性,將 watch 對(duì)象同樣保存起來(lái),后續(xù) close 的實(shí)現(xiàn)就靠它了

消息的接收

丹尼爾:Nice,請(qǐng)繼續(xù)!

蛋先生:好嘞!我們通過(guò) onChange 和 onError 這兩位偵探,來(lái)監(jiān)聽消息(正常消息和異常消息一個(gè)不落)。只要有風(fēng)吹草動(dòng),它們就會(huì)通過(guò) SendMessage 去通知 Unity。

丹尼爾:那 Unity 在哪接收消息呢?

蛋先生:依然在 OnAsyncFnCompleted。我們?cè)?callbackId 上動(dòng)了點(diǎn)手腳,增加了分類信息。比如說(shuō),"watch_" 開頭的,就是專門為 watch 類型的。

丹尼爾:我剛剛就好奇 string uuid = "watch_" + Guid.NewGuid().ToString(); 這里的 uuid 生成規(guī)則,現(xiàn)在解惑了

蛋先生:恩,最后,我們通過(guò) watchObj 的 PerformXXXAction 來(lái)觸發(fā)具體事件的執(zhí)行。這就完成了整個(gè)消息監(jiān)聽的流程了

連接的關(guān)閉

丹尼爾:關(guān)閉應(yīng)該就是通過(guò) watchObj 的 close 方法了

蛋先生:沒錯(cuò)。具體就是通過(guò) action = close 去通知 JS 執(zhí)行實(shí)際的關(guān)閉邏輯了

//【腳本C】

public class TCBSDK : MonoBehaviour
{

    private class Database : IDatabase
    {
        ...

        public class QueryHandler : IQuery
        {
            ...

            public IWatchObj Watch<T>(WatchParams<T> param)
            {

                string uuid = "watch_" + Guid.NewGuid().ToString();
                WatchObj cls = new(uuid, (string data) => param.OnChange(JsonConvert.DeserializeObject<WatchChangeData<T>>(data)), (string data) => param.OnError(JsonConvert.DeserializeObject<string>(data)));
                Internal.watchDictionary.Add(uuid, cls);
                
                Internal.Database_API(uuid, JsonConvert.SerializeObject(new DatabaseAPIParam()
                {
                    collectionName = collectionName,
                    chainList = new[] {
                              new ChainItem()
                              {
                                method = "where",
                                optionsStr = JsonConvert.SerializeObject(filter)
                              },
                              new ChainItem()
                              {
                                method = "watch",
                                optionsStr = JsonConvert.SerializeObject(new Dictionary<string, string>{
                                    ["action"] = "open"
                                })
                              }
                        }
                }));
                
                return cls;
            }
        }
        
        ...
    }
    
    private class Internal {
        
        public static readonly Dictionary<string, WatchObj> watchDictionary = new();
        
        ...
    }

    ...

    private class WatchObj : IWatchObj
    {
        ...

        public WatchObj(string callbackIdInput, OnWatchHandler<string> changeCallback, OnWatchHandler<string> errorCallback)
        {
            callbackId = callbackIdInput;
            OnChange += changeCallback;
            OnError += errorCallback;
        }

        public void Close()
        {
            Internal.Database_API(callbackId, JsonConvert.SerializeObject(new DatabaseAPIParam()
            {
                chainList = new[] {
                              new ChainItem() {
                                method = "watch",
                                optionsStr = JsonConvert.SerializeObject(new Dictionary<string, string>{
                                    ["action"] = "close"
                                })
                            },
                        }
            }));
            Internal.watchDictionary.Remove(callbackId);
        }

        public void PerformChangeAction(string msg)
        {
            OnChange?.Invoke(msg);
        }

        public void PerformErrorAction(string err)
        {
            OnError?.Invoke(err);
        }
    }


    public void OnAsyncFnCompleted(string result)
    {
        AsyncResponse<string> res = Internal.ParseOutputResult<AsyncResponse<string>>(result);

        if (res.callbackId.StartsWith("watch_"))
        {
            var resultData = Internal.ParseOutputResult<Dictionary<string, object>>(res.result);
            if (resultData.ContainsKey("err"))
            {
                Internal.watchDictionary[res.callbackId].PerformErrorAction(resultData["err"] as string);
            }
            else
            {
                Internal.watchDictionary[res.callbackId].PerformChangeAction(JsonConvert.SerializeObject(resultData["data"]));
            }

        }
        else
        {
            ...
        }
    }

}
//【腳本J】

Database_API: async function (callbackId, params) {
    callbackId = UTF8ToString(callbackId);
    const { collectionName, chainList } = asmLibraryArg
        .Utils()
        .parseInputParams(params);
    ...

    let lastItem = chainList[chainList.length - 1];
    if (lastItem.method === "watch") {
        // watch 的特殊處理

        const { action } = JSON.parse(lastItem.optionsStr);
        if (action === "open") {
            // 啟動(dòng) watch

            chainList.forEach((chainItem) => {
                const method = chainItem.method;
                const optionsStr = chainItem.optionsStr;
                if (method === "watch") {
                    chainObj = chainObj.watch({
                        onChange: function (data) {
                            ...
                            asmLibraryArg.Utils().sendMessage(callbackId, { data });
                        },
                        onError: function (err) {
                            asmLibraryArg.Utils().sendMessage(callbackId, { err });
                        },
                    });
                } else {
                    chainObj = chainObj[method](
                        optionsStr ? JSON.parse(optionsStr) : ""
                    );
                }
            });
            asmLibraryArg.Database_API[callbackId] = chainObj;
        } else if (action === "close") {
            // 關(guān)閉 watch

            if (asmLibraryArg.Database_API[callbackId]) {
                asmLibraryArg.Database_API[callbackId].close();
                delete asmLibraryArg.Database_API[callbackId];
            }
        }
    } else {
        // 普通異步接口調(diào)
        ...
    }
}

如何用實(shí)時(shí)推送完成實(shí)時(shí)聊天

丹尼爾:這下終于可以用上云數(shù)據(jù)庫(kù)的實(shí)時(shí)推送了,那么具體怎么實(shí)現(xiàn)實(shí)時(shí)聊天呢?

蛋先生:好問(wèn)題,實(shí)時(shí)推送是靠監(jiān)聽云數(shù)據(jù)庫(kù)的數(shù)據(jù)變化來(lái)實(shí)現(xiàn)的。所以我們得先給聊天消息建一個(gè)數(shù)據(jù)模型 chat_message,大致信息如下:

2.png

丹尼爾:等等,不是說(shuō)要用云數(shù)據(jù)庫(kù)嗎?怎么變成了數(shù)據(jù)模型了?

蛋先生:數(shù)據(jù)模型其實(shí)是云數(shù)據(jù)庫(kù)的簡(jiǎn)化版本,底層仍然是云數(shù)據(jù)庫(kù)

丹尼爾:哦,原來(lái)如此!您繼續(xù)

接收消息

蛋先生:假設(shè)你的用戶名為 Daniel,你在和 Tom 聊天。那么要接收 Tom 發(fā)給你的消息,可以按 from 和 to 這兩個(gè)條件去查詢,如下

// 接收消息

var database = app.Database();

var watchObj = database.Collection("chat_message").Where(new Dictionary<string, object>
{
    ["from"] = "Tom",
    ["to"] = "Daniel"
})
.Watch(new WatchParams<ModelChatMessage>()
{
    OnChange = (WatchChangeData<ModelChatMessage> data) =>
    {
        if (data.type != "init")
        {
            Debug.Log($"接收到的消息:{JsonConvert.SerializeObject(data.docChanges)}");
        }
    },
    OnError = (string err) =>
    {
        Debug.Log($"watch err: {err}");
    }
});

這樣當(dāng)有符合查詢條件的數(shù)據(jù)插入時(shí),你就會(huì)實(shí)時(shí)收到插入的數(shù)據(jù)信息了

發(fā)送消息

丹尼爾:懂了!發(fā)送消息應(yīng)該就是插入一條數(shù)據(jù)咯,如下

await app.Models.Create<ModelsCreateRes>(new ModelsReqParams() 
{ 
    modelName = "chat_message", 
    options = new Dictionary<string, object>
    {
        ["data"] = new Dictionary<string, string>
        {
            ["from"] = "Daniel",  // 發(fā)送人
            ["to"] = "Tom",  // 接收方
            ["content"] = "Hi man"  // 消息內(nèi)容
        }
    } 
});

蛋先生:很好!接下來(lái)就是你的自由發(fā)揮時(shí)間了

以上完整代碼請(qǐng)移步到倉(cāng)庫(kù):https://github.com/daniel-dx/unity-cloudbase-demo
代碼有點(diǎn)粗糙,僅供參考,還望見諒!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容