前言

很久没更这个系列,其实是我发现在国内如果想要运营发布游戏不是那么简单的事情,需要有公司并且去申请运营资格,如果要有收费内容还需要申请版号。作为一个独立开发者,可能很难做到这些,所以前段时间有些灰心,不太提的起劲做这个项目。

不过最近也想通了,最初这个项目也不是以运营为目的做的,单纯的分享讨论技术也是快乐的。

线上地址:http://cardgame.xiejingyang.com
github:https://github.com/xieisabug/card-game

这次实现的效果代码很多,推荐大家看视频:https://www.bilibili.com/video/av73030461,和视频结合博客一起看会比较直观。

开始

第一个实现的功能是断线重连,这是个实现简单但是作用巨大的功能,尤其是在网页游戏中更甚,防止了网页刷新、电脑断电、浏览器崩溃、网络断线等带来的影响。

断线重连的思路是使用userId去查询当前用户有没有已经存在的对局,如果有,直接加入到当前对局。

在之前的代码handler.js中,有个existUserGameRoomMap变量,这个变量保存了每个用户的userId对应的roomNumber,所以,只要在connect的函数中加上一段判断:

if (existUserGameRoomMap[userId]) { // 如果存在已经加入的对局,则直接进入之前的对战
    let roomNumber = existUserGameRoomMap[userId];
    let identity = memoryData[roomNumber]["one"].userId === userId ? "one" : "two";
    memoryData[roomNumber][identity].socket = socket;
    memoryData[roomNumber][identity].socket.emit("RECONNECT", {
        roomNumber: roomNumber,
        memberId: identity
    });

    sendCards(roomNumber, identity); // 把牌发送给玩家
} else {
	// ...之前的操作
}

这样就完成了断线重连。

第二个实现的功能是伤害,也就是在攻击的时候,进行伤害和血量的计算,这件事听起来比较简单,但是比断线重连要复杂很多:

  1. 要处理是否有嘲讽。
  2. 要处理圣盾,圣盾不扣血,只掉圣盾的状态。
  3. 要处理事件,比如当攻击时触发的特效。
  4. 要处理死亡卡牌,从桌面上去除掉。
  5. 要处理亡语导致的卡牌递归死亡。

首先要在桌面展示两张可攻击的牌,为了方便开发,就在开局初始化卡牌的时候随机给双方派一张牌,在之前的initCard方法中,为双方初始化手牌的时候,同时初始化桌面牌:

Object.assign(memoryData[roomNumber][first], {
    tableCards:[
        getNextCard(firstRemainingCards), // 记得后面要删除
    ],
    cards: [
        getNextCard(firstRemainingCards),
        getNextCard(firstRemainingCards),
    ]
});

Object.assign(memoryData[roomNumber][second], {
    tableCards:[
        getNextCard(secondRemainingCards), // 记得后面要删除
    ],
    cards: [
        getNextCard(secondRemainingCards),
    ]
});

初始化完成桌面卡牌之后,修改sendCards函数,在发送到客户端的数据中,添加桌面卡牌的数据:

function sendCards(roomNumber, identity) {
    if (identity) {
        let otherIdentity = identity === "one" ? "two" : "one";

        memoryData[roomNumber][identity].socket.emit("SEND_CARD", {
            myCard: memoryData[roomNumber][identity]["cards"],
            myTableCard: memoryData[roomNumber][identity]["tableCards"], // 双方的桌面卡牌也一并发送
            otherTableCard: memoryData[roomNumber][otherIdentity]["tableCards"],
        })
    } else {
        sendCards(roomNumber, "one");
        sendCards(roomNumber, "two");
    }
}

接下来在客户端处理桌面卡牌的显示,在客户端文件GameTable.vue中,在class为other-card-area的dom里展示对方桌面卡牌,在class为my-card-area的dom里展示我方桌面卡牌:

<div class="table">
   <div class="other-card-area">
        <Card 
            :key="c.k"
            :index="index"
            :data="c"
            v-for="(c, index) in gameData.otherTableCard"
        />
    </div>
    <div class="my-card-area">
        <Card 
            :key="c.k"
            :index="index"
            :data="c"
            @onAttackStart="onAttackStart"
            v-for="(c, index) in gameData.myTableCard"
        />
    </div>
</div>

完善之前的代码,打开Card.vue,以前在最外层我们是用index做为dom的dataset,但是现在最好改为k,因为k是整个对局中标记这张卡牌的唯一值,以后我们标记卡牌最好也都使用k。

<div class="card" @mousedown="mouseDown($event)" :data-k="data.k">

同时在mouseDown里,还要将当前index发送给外部,让外部能够获取到对应的卡牌:

mouseDown(e) {
    this.$emit('onAttackStart', {
        startX: e.pageX, startY: e.pageY, index: this.index
    })
}

对应的,在GameTable.vue中,onAttackStart接收对应的index,并且保存起来:

onAttackStart({startX, startY, index}) {
    this.showCanvas = true;
    window.isAttackDrag = true;
    this.attackStartX = startX;
    this.attackStartY = startY;

    this.currentTableCardK = this.gameData.myTableCard[index].k; // 将k保存
},

在到之前onmouseup事件中,修改之前为了测试方便而写的attackCard相关代码:

if (x > left && x < (left + width) && y > top && y < (top + height)) { // 边缘检测
    k = cd.dataset.k; // 修改之前的index,改为k

    // this.attackAnimate(0, k);
    this.attackCard(k);
}

attackCard中的参数也修改:

attackCard(k) {
    this.socket.emit("COMMAND", {
        type: "ATTACK_CARD",
        r: this.roomNumber,
        myK: this.currentTableCardK, // 改为真实的k
        attackK: k
    })
},

然后进行后端基础数据获取,确保拿到两张卡牌,保证后面的逻辑不出错:

let index = memoryData[roomNumber][belong]["tableCards"].findIndex(c => c.k === myK);
let attackIndex = memoryData[roomNumber][other]["tableCards"].findIndex(c => c.k === attackK);

if (index !== -1 && attackIndex !== -1
        && memoryData[roomNumber][other]["tableCards"].length > attackIndex
        && memoryData[roomNumber][belong]["tableCards"].length > index) {
	card = memoryData[roomNumber][belong]["tableCards"][index];
	attackCard = memoryData[roomNumber][other]["tableCards"][attackIndex];

	// 后面从这里继续逻辑
}

接着判断嘲讽,思路是看看桌上的卡牌有没有带嘲讽的,然后看自己攻击的卡牌是不是带嘲讽,如果桌上有嘲讽的卡牌,而攻击的不是带嘲讽的卡牌,那么攻击应该就是无效的,代码很简单:

let hasDedication = memoryData[roomNumber][other]["tableCards"].some(c => c.isDedication);

if (attackCard.isDedication || !hasDedication) {
	// 做我们攻击的其他逻辑
} else {
	// error 您必须攻击带有奉献的单位
}

处理圣盾代码也比较简单,就是分别判断两张卡牌有没有圣盾,有圣盾的扣除圣盾状态,没有圣盾的扣血:

if (attackCard.isStrong) { // 强壮
    attackCard.isStrong = false;
} else {
    attackCard.life -= card.attack;
}

if (card.isStrong) { // 强壮
    card.isStrong = false;
} else {
    card.life -= attackCard.attack;
}

然后就是处理事件了,因为之前没做过这样的卡牌,所以这里就只处理攻击和被攻击,然后做一张攻击特效和被攻击特效的卡牌试试。

处理事件其实就是在卡牌上写了回调函数,如果卡牌上有这个回调,就是带有这个事件,代码如下:

if (card.onAttack) {
    card.onAttack({
        myGameData: memoryData[roomNumber][belong],
        otherGameData: memoryData[roomNumber][other],
        thisCard: card,
        beAttackedCard: attackCard,
        // specialMethod: getSpecialMethod(belong, roomNumber),
    })
}
if (attackCard.onBeAttacked) {
    attackCard.onBeAttacked({
        myGameData: memoryData[roomNumber][other],
        otherGameData: memoryData[roomNumber][belong],
        thisCard: attackCard,
        attackCard: card,
        // specialMethod: getSpecialMethod(other, roomNumber),
    })
}

这里传进去了相当多的参数,几乎是整个游戏的参数都放到了回调的方法中,这样能够保证我们在onAttack这种类似的方法里,能够实现任何天马行空的效果。

大家还注意到了我注释了一行specialMethod,这个specialMethod是一系列快捷工具方法的集合,目前还没编写,后面会有的。

处理完事件,接下来要处理卡牌的死亡检查,思路是遍历整个桌面看看是否有血量到0或者以下的卡牌,如果有,则判定为死亡。遍历整个桌面是因为有可能有AOE技能导致大量怪物死亡。

for (let i = memoryData[roomNumber]["one"]["tableCards"].length - 1; i >= 0; i--) {
    let c = memoryData[roomNumber]["one"]["tableCards"][i];
    if (c.life <= 0) {
        if (c.onEnd) {
            c.onEnd({
                myGameData: memoryData[roomNumber]["one"],
                otherGameData: memoryData[roomNumber]["two"],
                thisCard: c,
                // specialMethod: oneSpecialMethod
            });
        }
        memoryData[roomNumber]["one"]["tableCards"].splice(i, 1);
    }
}
for (let i = memoryData[roomNumber]["two"]["tableCards"].length - 1; i >= 0; i--) {
    let c = memoryData[roomNumber]["two"]["tableCards"][i];
    if (c.life <= 0) {
        if (c.onEnd) {
            c.onEnd({
                myGameData: memoryData[roomNumber]["two"],
                otherGameData: memoryData[roomNumber]["one"],
                thisCard: c,
                // specialMethod: twoSpecialMethod
            });
        }
        memoryData[roomNumber]["two"]["tableCards"].splice(i, 1);
    }
}

卡牌死亡需要发送到客户端,因为卡牌死亡在很多地方会用到,所以将这个方法封装到工具方法里比较好,那么就来编写一下刚刚一直出现的specialMethod吧,封装一个获取对应对战房间和某个玩家的specialMethod方法,在handler.js中添加:

function getSpecialMethod(identity, roomNumber) {
    let otherIdentity = identity === "one" ? "two" : "one";

    return {
    }
}

向里面添加卡牌死亡的方法:

function getSpecialMethod(identity, roomNumber) {
    let otherIdentity = identity === "one" ? "two" : "one";

    return {
    	dieCardAnimation(isMine, myKList, otherKList) {
            memoryData[roomNumber][identity].socket.emit("DIE_CARD", {
                isMine,
                myKList,
                otherKList,
                myHero: extractHeroInfo(memoryData[roomNumber][identity]),
                otherHero: extractHeroInfo(memoryData[roomNumber][otherIdentity])
            });

            memoryData[roomNumber][otherIdentity].socket.emit("DIE_CARD", {
                isMine: !isMine,
                myKList: otherKList,
                otherKList: myKList,
                myHero: extractHeroInfo(memoryData[roomNumber][identity]),
                otherHero: extractHeroInfo(memoryData[roomNumber][otherIdentity])
            });
        },
    }
}

后续会向这个方法里面添加许多常用的工具方法,这些方法在回调的事件中也可以实现,但是因为非常频繁的使用,所以封装到一个工具方法集合里面,会提高很多以后制作卡牌的速度,之前代码里注释掉的specialMethod也可以去掉注释了。

目前还有问题,也就是刚刚说的第五点,当onEnd亡语触发的时候,有可能还会造成伤害,这个时候还得检查一次,但是死亡的怪物可能又会有亡语伤害,这个时候只能递归进行判断了,所以将这个卡牌死亡封装一个方法,进行递归调用。

/**
 * 检查卡片是否有死亡
 * @param roomNumber 游戏房间
 * @param level 递归层级
 * @param myKList 我方死亡卡牌k值
 * @param otherKList 对方死亡卡牌k值
 */
function checkCardDieEvent(roomNumber, level, myKList, otherKList) {
    if (!level) {
        level = 1;
        myKList = [];
        otherKList = [];
    }
    if (memoryData[roomNumber]["one"]["tableCards"].some(c => c.life <= 0) || memoryData[roomNumber]["two"]["tableCards"].some(c => c.life <= 0)) {

        let oneSpecialMethod = getSpecialMethod("one", roomNumber),
            twoSpecialMethod = getSpecialMethod("two", roomNumber);

        for (let i = memoryData[roomNumber]["one"]["tableCards"].length - 1; i >= 0; i--) {
            let c = memoryData[roomNumber]["one"]["tableCards"][i];
            if (c.life <= 0) {
                if (c.onEnd) {
                    c.onEnd({
                        myGameData: memoryData[roomNumber]["one"],
                        otherGameData: memoryData[roomNumber]["two"],
                        thisCard: c,
                        specialMethod: oneSpecialMethod
                    });
                }
                memoryData[roomNumber]["one"]["tableCards"].splice(i, 1);
                myKList.push(c.k);
            }
        }

        for (let i = memoryData[roomNumber]["two"]["tableCards"].length - 1; i >= 0; i--) {
            let c = memoryData[roomNumber]["two"]["tableCards"][i];
            if (c.life <= 0) {
                if (c.onEnd) {
                    c.onEnd({
                        myGameData: memoryData[roomNumber]["two"],
                        otherGameData: memoryData[roomNumber]["one"],
                        thisCard: c,
                        specialMethod: twoSpecialMethod
                    });
                }
                memoryData[roomNumber]["two"]["tableCards"].splice(i, 1);
                otherKList.push(c.k);
            }
        }
        checkCardDieEvent(roomNumber, level + 1, myKList, otherKList);
    }
    if (level === 1 && (myKList.length !== 0 || otherKList.length !== 0)) {
        let oneSpecialMethod = getSpecialMethod("one", roomNumber);

        oneSpecialMethod.dieCardAnimation(true, myKList, otherKList);
    }
}

这样判断卡牌死亡的方法算是完成了,在攻击之后调用checkCardDieEvent(roomNumber)就能完成进攻了。
不过之前发送给客户端的数据太少,不足以支撑客户端的展示,所以将更多的数据传送到客户端:

memoryData[roomNumber][belong].socket.emit("ATTACK_CARD", {
    index,
    attackIndex,
    attackType: AttackType.ATTACK,
    animationType: AttackAnimationType.NORMAL, // 为了日后不同的卡片攻击方式
    card,
    attackCard
});
memoryData[roomNumber][other].socket.emit("ATTACK_CARD", {
    index,
    attackIndex,
    attackType: AttackType.BE_ATTACKED,
    animationType: AttackAnimationType.NORMAL,
    card,
    attackCard
});

接下来在客户端处理卡牌死亡事件,也就是DIE_CARD

const { isMine, myKList, otherKList } = param;
let myCardList, otherCardList;
if (isMine) {
    myCardList = thiz.gameData.myTableCard;
    otherCardList = thiz.gameData.otherTableCard;
} else {
    myCardList = thiz.gameData.otherTableCard;
    otherCardList = thiz.gameData.myTableCard;
}
setTimeout(() => {
	myKList.forEach((k) => {
	    let index = myCardList.findIndex(c => c.k === k);
	    myCardList.splice(index, 1);
	});
	otherKList.forEach((k) => {
	    let index = otherCardList.findIndex(c => c.k === k);
	    otherCardList.splice(index, 1);
	});
}, 920)
完成效果

完整的代码太长,可以直接check项目下来看,不贴了。

总结

我还会继续往后面做这个游戏,毕竟倾注了很多心血在里面,也许哪天就真的注册公司申请运营了呢。

下次会分享下我最新制作的登录界面,然后做几张有特殊效果的牌,看看卡牌的特殊效果是什么制作的。

分类: 前端

0 条评论

发表回复

Avatar placeholder

您的电子邮箱地址不会被公开。 必填项已用*标注