如何在徹底不懂服務器開發的狀況下作一個實時聯網對戰的微信小遊戲

微信小遊戲即將開放?有咱們在,你還趕得上!java

根據微信官方對外公開的消息,微信小遊戲的腳步愈來愈接近了。它的開發者資格門檻和使用者門檻都很低,之後必將引爆一波"全民開發小遊戲"浪潮。程序員

官方的開發工具建立項目便可獲取 打飛機 的源碼,這是一個很小但五臟俱全的2D遊戲,相信大多數嗅覺靈敏的程序員小哥哥們都已經體驗而且親手改造過啦。web

可是若是你想借助微信的平臺,作一個交互性、可玩性很強的 聯網遊戲 ,就有必定的難度啦。不用怕,有 Bmob 的最新產品 遊戲SDK 助力,第一波流量紅利你也能輕鬆抓住!此次教程咱們就來討論 如何在徹底不懂服務器開發的狀況下作一個實時聯網對戰的微信小遊戲 (聯網飛機大戰)。數據庫

跳一跳


前言

爲了能通讀這篇文章,你最好:json

  1. 已經掌握開發簡單的微信小遊戲,能看懂官方 打飛機 源碼就行,甚至會用 Javascript 輸出HelloWorld也行
  2. 略懂Java,其實不懂也行,在JS的基礎上很容易引伸,主要是要有 面向對象 的思想

下文重點都是講如何快速上手開發 聯網的微信小遊戲 , 但 若是你懂得一些U3D開發Bmob官方 也同時提供了 Unity3D版本的Demo+SDK二者能夠跨平臺互通一塊兒玩,且接口規範高度一致,基本上覆蓋市面上全部的主流終端 segmentfault

PS:微信小遊戲、Unity3D的SDK都是 開源 的,歡迎各位糾錯後端

小遊戲飛機大戰

最簡單的步驟

  1. 獲取 比目遊戲雲服務 (下稱 官網)的帳號,文章下方有得到方式;
  2. 官網下載 微信小遊戲Demo+SDK,導入到微信開發者工具(下稱 工具),並修改AppKey
  3. 官網配置玩家同步屬性,並發佈下載的雲端代碼,而後在官網選擇一個雲服務器開啓(PS:雲服務器是免費的)
  4. 試運行Demo,若是console沒有報錯的話,點擊工具預覽,用微信掃描二維碼;
  5. 如今,就能夠在遊戲內建立房間體驗電腦與手機聯網對戰啦

接下來大概介紹一下微信小遊戲項目開發的要點,雲端代碼的詳解和U3D版本的教程將陸續推出服務器

官網後臺

運行效果

左邊的是 微信小遊戲-開發者工具 的遊戲頁面,與右邊的 Unity3D-MacOS-Editor 跨平臺玩微信

Demo測試運行視頻 (B站無廣告傳送門) websocket

超清/720P模式觀看體驗更好哦

不得不說程序員本身來作UI真的醜得能夠,那個"房間"界面真的無力吐槽

目前的Demo跨平臺玩耍還有點小問題,例如玩家、怪物的移動速度不統一。但同平臺對戰是高度一致的。 這個問題與SDK沒有關係,都是Demo本地項目的參數設置,主要是由於Unity項目都用的是絕對值,微信小遊戲項目都是相對值,後續Unity也採用相對值的方式,完善Demo。


如何從零開發

論遊戲開發的經驗,相信各位讀者中比我厲害的人多了去了。我這裏就根據我我的的開發歷程,圍繞 聯網飛機大戰 這個項目,講一下從零開發遊戲的步驟吧。(嫌麻煩的能夠不用看這一篇)

  1. 肯定遊戲主題、玩法
  2. 理清多個客戶端之間須要 同步的屬性、互相通知的事件
  3. 分析客戶端與服務器須要 交互的事件
  4. 製做/收集圖片、動畫、音效素材;
  5. 開發/照搬遊戲世界的物理引擎,包括物體渲染、移動、碰撞檢測(以及內存管理)等;
  6. 先開發服務端遊戲邏輯(Java雲端代碼),有利於理清整個遊戲的邏輯;
  7. 後開發客戶端遊戲邏輯、接入SDK
  8. 測試、發佈;

Unity版效果

下面是展開來說 (獲取Demo、SDK完整源碼的方式見文章底部)


  • 玩法:這個項目準備作成能夠容納超多人同時在線的飛機大戰,全部設定基本上和微信小遊戲官方Demo同樣,增長了幾個設定:

    • 有四種造型、級別不一樣的Bot(有些人習慣稱爲 '電腦',也能夠稱爲'飛機NPC')
    • 第三、4級的Bot能夠開火,子彈(下稱Fire)飛行速度與玩家一致,4級Bot的開火頻率更高
    • Bot有生命值(再也不是一碰就死),分別是二、三、四、4,表示能夠承受的Fire攻擊次數
    • Player(玩家)和Bot都分爲兩個陣營,陣營內無隊友傷害
    • Player的陣營由服務器隨機劃分,也能夠改爲玩家本身決定
    • 刷怪邏輯放在雲端,指定新產生的Bot的陣營、位置、類型
    • Player受到傷害即淘汰,Fire碰到任何物體都消失
    • Player之間、Bot之間、Player與Bot 若是發生碰撞,會玉石俱焚
    • Player的開火暫時作成自動的,而不是按鍵開火
    • Player的開火事件(開火座標)是直接發送到其它客戶端,不通過雲端代碼
    • Player的淘汰交由雲端處理,由雲端校驗後,再把該事件和勝負斷定分發下去
    • Bot的淘汰斷定交由雲端處理、分發
    • 當某一方Player所有死亡時,另外一方勝利;雙方各剩一人時玉石俱焚則平局

  • 客戶端間屬性同步、事件通知:玩家僅有兩個屬性須要自動同步、分發,一個是 位置,另外一個是 分數;直接同步的事件僅有 開火

    • 位置:這是一個2D遊戲,因此玩家位置能夠用float[2]類型表達
      可是爲了保持一致性,Demo用了int[2],數值由0-65535,表達0%-100%
      (一致性,是指跨平臺或分辨率、屏幕大小不一樣時,座標須要達成一致最好用百分比)
    • 分數:僅雲端代碼有權限修改,根據Player、Bot的擊落事件加分
      能夠在遊戲結束時,結算成經驗值,保存到Bmob數據庫
    • 開火:直接通知到其它客戶端,僅記錄Fire的起點座標便可,也就是[0-65535,0-65535]
      表達成byte[]時,一個0-65535的int能夠變成兩個0-255的數字組成
      再加上須要標記此次通知的事件類型(開火),這裏定flag爲50
      也就是開火時向其它玩家發送 [50, 0-255, 0-255, 0-255, 0-255]

官網屬性配置


  • 客戶端-雲端交互事件:須要服務器作的事情有:保存房間信息;分配隊伍;正式通知遊戲開始;刷怪邏輯;斷定Bot淘汰;斷定Player淘汰;添加Player分數;斷定勝負結果;戰績記錄

    • 房間、戰績信息:經過雲端代碼的Bmob數據庫操做API完成
    • 分配隊伍:在客戶端Scene.OnLoad後通知服務器,服務器進行隊伍分配
      將玩家隨機、均勻分紅兩隊,而後下發,客戶端處理完畢再通知服務器
    • 正式開始:服務器確認全部客戶端處理了隊伍信息後,通知全部客戶端開始遊戲
    • 刷怪邏輯:隨機Bot的陣營、x軸位置、類型、名字,下發給客戶端處理
    • Bot淘汰:任意客戶端上報'目擊'某Bot被擊毀,雲端即採信、下發、記分
      所謂'目擊',就是客戶端渲染時進行碰撞檢測,發現這個Bot的hp爲0
    • Player淘汰:n個客戶端'目擊'某Player被擊毀,在短期內n>=m,雲端才採信、下發、記分
      當玩家僅有二、3人時,m爲1,也就是上報即採信
      當玩家有四、五、6人時,m爲2,不採信單個上報
      當玩家超過6人時,m爲3,也就是起碼3人上報才採信
      '短期'目前是設爲2000ms,也就是上報信息的有效期爲2秒
    • 斷定勝負結果:兩隊最後一人同時淘汰時平局;某隊先於敵隊全員淘汰則敗

  • 素材:來自美工/Unity Assets商店

Unity素材


  • 物理引擎:來自微信官方Demo(Sprite.js)/腦洞+造輪子/第三方途徑下載

    // 小改進後的矩形碰撞檢測:
    isCollideWith(sp) {

    if (this.visible && sp.visible) {
        let dis = sp.x - this.x;
        if (-sp.width < dis && dis < this.width) {
            dis = sp.y - this.y;
            if (-sp.height < dis && dis < this.height)
                return true;
        }
    }
    return false;

    }


  • Java雲端代碼:在上面第3點已經有說明,這裏放幾段代碼:

雲端代碼

Room.java

// public class Room extends RoomBase
    
    // 保存到Bmob數據庫的id
    public String mObjectId = null;
    // 先分配隊伍,後開始遊戲。分配隊伍這段時間,不是真正的遊戲開始,不要刷怪
    public boolean isNotReallyStart;
    // 刷怪的時間間隔(毫秒),決定了刷怪的頻率,根據玩家人數來定。人越多,刷怪越快
    private long botSpawnSpan;
    // 上次刷怪的時間記錄
    private long lastBotSpawnTime = 0;
    // 怪物的個數,也順便做爲id
    private long botCount = 0;
    // 置信區間: 計算擊中的邏輯放到了客戶端的時候,擊中敵人/怪物的事件,不能徹底聽信其中一個客戶端,防止ping差別擊殺、外掛
    // 怪物還相對可有可無,某一個客戶端上報了,就選擇相信他
    // 可是玩家的淘汰影響到體驗,須要多個玩家同時認證的狀況下斷定
    // 因而約定:若是房間有二、3人,能夠一我的說了算(以避免掉線玩家無敵)
    // 若是有4我的玩遊戲,須要2我的在短期內"看到"某個玩家的死亡,那麼這個玩家纔是真正的死亡了
    // 更多人的狀況下,最多隻要3我的在短期內說某個玩家死亡,就能夠做出斷定
    // 特殊的,若是某個玩家是彙報本身死亡,那麼不用通過置信區間檢測,直接斷定死亡
    public int confidenceInterval = 1;
    private final Set<String> dieBotsNames = new HashSet<String>();

    public static final byte//
            NotifyType_AssignTeam = 1,//
            NotifyType_BotSpawn = 2,//
            NotifyType_ReallyStart = 3,//
            NotifyType_PlayerCrash = 4,//
            NotifyType_BotDie = 5,//
            NotifyType_GameOver = 6//
            ;


    @Override
    public void onCreate() {
        // 各1個玩家的時候,1秒2個怪;以此類推
        // botSpawnSpan = (1000 / 2) / (playerCount / 2);
        botSpawnSpan = (2000) / (playerCount / 2);
        // 計算死亡斷定的置信區間
        if (playerCount > 3)
            confidenceInterval = 2;
        else if (playerCount > 5)
            confidenceInterval = 3;

        HttpResponse response = Bmob.getInstance().insert("Room", JSON.toJson(//
                "roomId", roomId,//
                "master", masterId,//
                "masterKey", masterKey,//
                "joinKey", joinKey,//
                "playerCount", playerCount,//
                "address", address,//
                "tcpPort", tcpPort,//
                "udpPort", udpPort,//
                "websocketPort", websocketPort,//
                "status", 0// 0: 開啓中,1: 遊戲中,2:
                            // 房間關閉
                ));
        mObjectId = response.jsonData.getString("objectId");
    }


    @Override
    public void onGameStart() {
        if (!Functions.isStrEmpty(mObjectId))
            Bmob.getInstance().update("Room", mObjectId,
                    JSON.toJson("status", 1));

        dieBotsNames.clear();
        isNotReallyStart = true;
        lastBotSpawnTime = 0;
        botCount = 0;
    }

    @Override
    public void onDestroy() {
        if (!Functions.isStrEmpty(mObjectId))
            Bmob.getInstance().update("Room", mObjectId,
                    JSON.toJson("status", 2));
    }

    @Override
    @BmobGameSDKHook
    public void onTick() {
        if (isNotReallyStart)
            return;

        long curTime = getTime();
        if (curTime > lastBotSpawnTime + botSpawnSpan) {
            spawnBot();
            lastBotSpawnTime = curTime;
        }
    }
    
    // 分配隊伍
    public void assignTeam() {
        // 遊戲開始,全部玩家就位了,將房間內的玩家隨機、平均分到兩隊
        // 服務器發送到客戶端的通知,就拿第一位看成消息類型的區分吧(flag)
        for (Player p : players)
            p.teamId = 0;
        // 若是[1]=1,表示players[0]是隊伍1; [2]=0表示players[1]是隊伍2
        byte[] team = new byte[playerCount + 1];
        // (flag)1表示分隊狀況
        team[0] = NotifyType_AssignTeam;
        // 其中一個隊的人數
        int team1Count = playerCount / 2;
        while (team1Count != 0) {
            int id = ((int) (Math.random() * 100000) % playerCount) + 1;
            if (team[id] != 1) {
                players[id - 1].teamId = 1;
                team[id] = 1;
                team1Count--;
            }
        }
        sendToAll(team);
    }

    // 刷怪
    private void spawnBot() {
        botCount++;
        // 遊戲裏面有4種難度不一樣的怪,將機率按1:2:3:4來劃分,越難打的怪出現概率越低
        // 位置(主要是x軸)隨機,按byte表示,0-255,表示最左邊到最右邊,128是在屏幕中鍵

        // [0]表示flag,這個通知是一個刷怪事件
        // [1]表示隊伍代號,這個怪是哪一邊的(和assignTeam的分配一致)
        // [2]表示刷怪點x軸的位置
        // [3]表示怪物種類
        // [4-]表示怪物名(Bot[Type]_[Id])

        byte botTeam = (byte) (((int) (Math.random() * 100)) % 2);
        byte botPositionX = (byte) (((int) (Math.random() * 0xffff)) & 0xff);
        byte botType = (byte) (Math.random() * 10); // 0-9
        if (botType == 9) // 9
            botType = 3;
        else if (botType > 6) // 七、8
            botType = 2;
        else if (botType > 3) // 四、五、6
            botType = 1;
        else
            botType = 0; // 0、一、二、3,默認都是怪物0

        byte[] botName = ("Bot" + botType + "_" + Long.toHexString(botCount))
                .getBytes();
        byte[] botInfo = new byte[4 + botName.length];
        // (flag)2表示分隊狀況
        botInfo[0] = NotifyType_BotSpawn;
        botInfo[1] = botTeam;
        botInfo[2] = botPositionX;
        botInfo[3] = botType;
        arraycopy(botName, 0, botInfo, 4, botName.length);
        sendToAll(botInfo);
    }

--

Player.java

// public class Player extends PlayerBase
    
    public int teamId = 0;
    private boolean isDead = false;
    private boolean isLoadOk = false, isTeamClear = false;
    private long[] dieReports;

    // 不重複下發怪物死亡事件

    @BmobGameSDKHook
    public native void setIsDead(boolean isDead);

    @Override
    public void onGameStart() {
        dieReports = new long[room.playerCount];
        isLoadOk = false;
        isDead = false;
        setIsDead(isDead);
        syncToClient();
    }

    @BmobGameSDKHook
    public strictfp void onAction_OnGameLoad(byte[] bs) {
        // 加載好了遊戲場景
        this.isLoadOk = true;
        // 檢查是否所有都準備好了
        for (Player p : roommates)
            if (!p.isLoadOk)
                return;
        // 開始分配隊伍
        room.assignTeam();
    }

    @BmobGameSDKHook
    public strictfp void onAction_OnTeamInfoGet(byte[] bs) {
        // 收到了隊伍安排
        this.isTeamClear = true;
        // 檢查是否所有都準備好了
        for (Player p : roommates)
            if (!p.isTeamClear)
                return;
        // 讓房間真正運做起來
        room.reallyPlaying();
    }

    // 有玩家上報,發現某一個玩家死亡
    @BmobGameSDKHook
    public strictfp void onAction_PlayerCrash(byte[] infos) {
        if (room.isNotReallyStart || isDead)// 已經死亡的玩家,彙報不予採信
            return;
        // 注意,若是是敵機碰到本身,會發送兩條,一條說本身被對方撞死,另外一條是對方被本身撞死,這個時候都看成是彙報本身死亡
        // 0: 墜機對象的no,用byte表達的話,最多兼容256人大房間
        // 1: 傷害者類型(0: 敵方玩家(直接碰撞); 1: 敵方炮彈; 2: 敵方Bot)
        // 2: 若是是敵方玩家直接碰撞,那麼對方的no是什麼
        int dieNo = (int) infos[0];
        if (dieNo < 0 || dieNo > room.playerCount) {// 若是是128人以上的房間,dieNo多是-127~-1,要考慮兼容
            kick(); // 不合法的上報,踢出玩家
            return;
        }
        int murdererNo = -1;
        if (infos[1] == 0) {
            murdererNo = (int) infos[2];
            if (murdererNo < 0 || murdererNo > room.playerCount) {
                kick(); // 不合法的上報,踢出玩家
                return;
            }
        }
        if (dieNo == no || murdererNo == no) {
            // 給另一個玩家添加一個死亡報告
            if (dieNo == no) {
                if (murdererNo != -1)
                    roommates[murdererNo].reportDie(this);
            } else
                roommates[dieNo].reportDie(this);

            die();// 本玩家死亡
        } else { // 觀察其它玩家的死亡
            roommates[dieNo].reportDie(this);
        }
    }

    void reportDie(Player reporter) {
        if (room.isNotReallyStart || isDead) // 死豬不怕開水燙
            return;
        long curTime = getTime();
        dieReports[reporter.no] = curTime;
        int dieCount = 0;
        long reportExpired = curTime - 2000;
        for (long time : dieReports)
            if (time > reportExpired)
                dieCount++;
        if (dieCount < room.confidenceInterval)
            return;
        die();
    }

    void die() {
        isDead = true;
        setIsDead(isDead);
        syncToClient();
        sendToAll(new byte[] { Room.NotifyType_PlayerCrash, (byte) no });

        int[] teamAliveCounts = new int[] { 0, 0 };
        String msg = String.format("Player[%d][%s] die\n", no, getUserId());
        for (Player p : roommates) {
            if (p.isDead) {
                msg += p.no + " is dead, team " + p.teamId + "\n";
                continue;
            }
            teamAliveCounts[p.teamId]++;
            msg += p.no + " is alive, team " + p.teamId + "\n";
        }
        msg += String.format("team_0 has alive[%d] and team_1 is [%d]", no,
                teamAliveCounts[0], teamAliveCounts[1]);

        if (teamAliveCounts[0] == 0 || teamAliveCounts[1] == 0) { // 有一個隊沒人了
            // 準備發送GameOver, 0:平局,1:勝利,2:失敗
            byte[] toTeam0 = new byte[] { Room.NotifyType_GameOver, 0 }, //
            toTeam1 = new byte[] { Room.NotifyType_GameOver, 0 };
            if (teamAliveCounts[0] == teamAliveCounts[1]) {// 都沒人了

            } else if (teamAliveCounts[0] == 0) { // 隊伍1勝利
                toTeam0[1] = 2;
                toTeam1[1] = 1;
            } else {
                toTeam0[1] = 1;
                toTeam1[1] = 2;
            }
            for (Player p : roommates)
                p.send(p.teamId == 0 ? toTeam0 : toTeam1);
            room.gameOver(); // 遊戲結束
        }
    }

    // 有玩家上報,怪物死亡
    @BmobGameSDKHook
    public strictfp void onAction_BotDie(byte[] infos) { // 暫時放怪物名
        if (room.isNotReallyStart)
            return;
        // cn.bmob.gamesdk.server.Main.l("BotDie: (" +
        // java.util.Arrays.toString(infos) + ") : " + infos.length);
        if (room.isBotDieNow(new String(infos))) {// 不重複的
            byte[] sendInfos = new byte[1 + infos.length];
            sendInfos[0] = Room.NotifyType_BotDie;
            arraycopy(infos, 0, sendInfos, 1, infos.length);
            sendToAll(sendInfos);
        }
    }

    // 遊戲中掉線,看成死亡
    @Override
    public void onOffline() {
        if (room.isNotReallyStart)
            return;
        die();
    }

    // 遊戲中離開房間,看成死亡
    @Override
    public void onLeave() {
        if (room.isNotReallyStart)
            return;
        die();
    }
  • 接入SDK

    // game.js
        
    // 根據屏幕大小來定玩家的大小, 咱們定玩家若是須要穿過整個y軸最少須要2秒,怪物須要8秒
    const PlayerMaxSpeed = screenHeight / 2000; // px per sec
    const BotSpeed = screenHeight / 8000; // px per sec
    const EnemyFireSpeed = screenHeight / 3000; // px per sec
    const FriendFireSpeed = -EnemyFireSpeed;
    
    // 其它玩家更新屬性
    onOthersStatus(no, changedAttr, hisStatus) {
        if (changedAttr.position) {
            let y = hisStatus.position[1];
            let gameObj = this.players[no].gameObject;
            if (gameObj.isTeammate)
                y = 65535 - y;
            gameObj.x = hisStatus.position[0] / WidthRatio - PlayerWidth / 2;
            gameObj.y = y / HeightRatio - PlayerHeight / 2;
        }
    }
    
    // 其它玩家發送事件
    onTransfer(no, body) {
        switch (body.shift()) {
            case 50:
                console.log('Fire from: ', this.players[no]);
                let isTeammate = this.players[no].gameObject.isTeammate,
                    x = (body[0] << 8) | body[1],
                    y = (body[2] << 8) | body[3];
                if (isTeammate)
                    y = 65535 - y;
    
                let fire = new Sprite(
                    isTeammate ? ImgSrc_Fire_Friend : ImgSrc_Fire_Enemy,
                    FireWidth,
                    FireHeight,
                    x / WidthRatio,
                    y / HeightRatio
                );
                fire.objType = 3; // 0: sundries; 1: player; 2: bot; 3: fire
                fire.velocity = isTeammate ? FriendFireSpeed : EnemyFireSpeed;
                fire.teamId = isTeammate ? this.mTeamId : (1 - this.mTeamId);
                this.gameObjArr.push(fire);
                break;
        }
    }
    
    // 雲端通知
    onCloudNotify(notify) {
        switch (notify.shift()) {
            case NotifyType_AssignTeam:
                this.assignTeam(notify);
                break;
            case NotifyType_BotSpawn:
                this.botSpawn(
                    notify[0] == this.mTeamId,
                    (notify[1]) * screenWidth / 255,
                    notify[2],
                    model.bytesToString(notify, 3, notify.length)
                );
                break;
            case NotifyType_ReallyStart:
                this.startGame();
                break;
            case NotifyType_PlayerCrash:
                this.renderPlayerDie(notify[0]);
                break;
            case NotifyType_BotDie:
                this.botDie(model.bytesToString(notify, 0, notify.length));
                break;
            case NotifyType_GameOver:
                this.isGameStart = false;
                switch (notify[0]) {
                    case 0:
                        this.gameDraw();
                        break;
                    case 1:
                        this.gameWin();
                        break;
                    case 2:
                        this.gameLose();
                        break;
                }
                break;
        }
    }
  • 測試、發佈:灰常好玩,下階段準備作成四個陣營的玩法

開發體驗

在基本素材、組件(物理引擎)等預備充分的狀況下,花了不到兩個小時就將一個單機遊戲改形成了聯網對戰的遊戲,並且邏輯也足夠健壯,效果仍是很酷的。再加上SDK是開源的,有什麼問題很容易定位。

整體來說,Bmob Game SDK真正拉低了網絡遊戲開發的門檻,徹底沒有了之前龐大、繁雜的後端開發和服務器運維工做,讓不少受限於資源、只能開發單機遊戲的團隊和項目有了新的出路~

獲取Demo、SDK完整源碼的方式:

加官方客服,小小琪QQ:2967459363

其餘教程

落地成盒?Bmob幫你開發本身的聯網"吃雞"遊戲

Unity聯網對戰遊戲小Demo

如何實現各類遊戲的思路雜想

相關文章
相關標籤/搜索