44

用js写卡牌游戏(六) | Xieisabug

 4 years ago
source link: http://www.xiejingyang.com/2019/10/24/js-write-card-game-6/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

用js写卡牌游戏(六) | Xieisabug

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

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

线上地址: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项目下来看,不贴了。

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK