如何在彻底不懂服务器开发的状况下作一个实时联网对战的微信小游戏

微信小游戏即将开放?有咱们在,你还赶得上!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

如何实现各类游戏的思路杂想

相关文章
相关标签/搜索