很久没更这个系列,其实是我发现在国内如果想要运营发布游戏不是那么简单的事情,需要有公司并且去申请运营资格,如果要有收费内容还需要申请版号。作为一个独立开发者,可能很难做到这些,所以前段时间有些灰心,不太提的起劲做这个项目。
不过最近也想通了,最初这个项目也不是以运营为目的做的,单纯的分享讨论技术也是快乐的。
线上地址: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 {
// ...之前的操作
}
这样就完成了断线重连。
第二个实现的功能是伤害,也就是在攻击的时候,进行伤害和血量的计算,这件事听起来比较简单,但是比断线重连要复杂很多:
首先要在桌面展示两张可攻击的牌,为了方便开发,就在开局初始化卡牌的时候随机给双方派一张牌,在之前的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)
我还会继续往后面做这个游戏,毕竟倾注了很多心血在里面,也许哪天就真的注册公司申请运营了呢。
下次会分享下我最新制作的登录界面,然后做几张有特殊效果的牌,看看卡牌的特殊效果是什么制作的。