大家好,又是好久不见。最近虽然也很忙,但还是挤了很多时间来更新这个项目。一方面,在紧张的工作之余做这个项目对我来说相当于休息,另一方面也希望在开源事业上出自己的一份力,把自己对代码的思考和大家分享探讨共同进步。

首先介绍一下对项目进行的小改动吧。

  • 修复了多个bug
  • Vue2升级到了Vue3
  • 项目的依赖全部升级到最新并且调试通过
  • 重构代码结构,将各个逻辑全部提取出来放在单独的文件中
  • 项目增加日志,并且保存日志文件
  • 新增了和好友指定开房配对的功能
  • 超时功能更加完善
  • 在项目里使用授权,用jwt作为session方案

接下来对其中几个改动做详细的分享,包括:开房指定配对,超时功能,jwt授权。然后还会分享游戏中新添加的功能:职业技能。

开房指定配对

新增的和好友指定开房配对的功能是指用户可以选择与自己的好友进行一对一的匹配游戏,而不是随机匹配。这个功能实现的主要目的是为了让用户更好地和自己的朋友一起玩游戏,增加游戏的社交性。

这个功能的实现方式比较简单,之前的随机匹配创建了一个房间,只是无法指定房间号,所以这个功能只需要把之前的随机匹配稍加修改,分解为两个步骤:创建房间、加入房间。在之前的随机匹配的基础上,把房号返回给用户,但不会自动加入待匹配队列,只能通过房号进入房间。这样,用户就可以在界面上输入房号,如果房号匹配成功,后续的流程就和随机匹配一样了。

if (pvpGameMode === PvpMode.CREATE_ROOM) {
    // 创建房间
    let roomNumber = uuidv4();

    // 防止出现重复的房间号
    while (getRoomData(roomNumber)) {
        roomNumber = uuidv4();
    }
    console.log("create room : " + roomNumber)

    // r就是房间号
    const seed = Math.floor(Math.random() * 10000);
    // 创建房间,注意这里不要设置startTime,因为还在等待对手加入
    createRoomData(roomNumber, {
        isPve,
        gameMode: GameMode.PVP1,
        seed,
        rand: seedrandom(seed),
        round: 1
    });
    saveUserGameRoom(userId, roomNumber);

    changeRoomData(roomNumber, 'one', {
        userId, cardsId, socket, roomNumber, myMaxThinkTimeNumber: MAX_THINK_TIME_NUMBER
    });

    logger.info(`roomNumber:${roomNumber} userId:${userId} cardsId:${cardsId} pvp create`);

    // 等待的时候把房间号发给客户端,让客户端显示在桌面
    socket.emit("WAIT", {
        roomNumber
    });
    socket.join(roomNumber);
}
// pvp部分
// 加入房间
if (pvpGameMode === PvpMode.JOIN_ROOM) {
    if (!r) {
        socket.emit("ERROR", "房间号不能为空");
        return;
    }

    if (!getRoomData(r)) {
        socket.emit("ERROR", "房间号未找到");
        return;
    }

    if (getRoomData(r).startTime) {
        socket.emit("ERROR", "房间游戏已经开始");
        return;
    }

    // r就是房间号
    const roomNumber = r;
    const memoryData = getRoomData(roomNumber);
    saveUserGameRoom(userId, roomNumber);

    changeRoomData(roomNumber, 'startTime', new Date());
    changeRoomData(roomNumber, 'two', {
        userId, cardsId, socket, roomNumber, myMaxThinkTimeNumber: MAX_THINK_TIME_NUMBER
    });

    logger.info(`roomNumber:${roomNumber} one userId1:${memoryData['one'].userId} cardsId1:${memoryData['one'].cardsId} two userId2:${userId} cardsId2:${cardsId} pvp start`);

    socket.join(roomNumber);

    memoryData['one'].socket.emit("START", {
        roomNumber: roomNumber,
        memberId: "one"
    });

    socket.emit("START", {
        roomNumber: roomNumber,
        memberId: "two"
    });

    initCard(roomNumber, memoryData['one'].cardsId, cardsId, memoryData['one'].userId, userId);

    saveUserOperator(userId, { type: UserOperatorType.playPvp, with: memoryData['one'].userId, roomNumber: roomNumber });
}

超时功能

烧绳子是炉石一个非常经典的场景。在这个场景中,玩家需要在规定的时间内完成操作,否则就会触发超时机制。超时机制是保障游戏公平性的重要机制之一。但是,之前的超时机制存在一个致命的问题,就是它被放在了客户端实现。这就意味着,只要对手关闭了网页,将永远不会触发超时功能,这让游戏的公平性受到了很大的影响。

为了解决这个问题,我们将超时功能放在了服务端来统计。这样,即使对手关闭了网页,超时机制仍然会如期触发。实现的思路也很简单:在房间内设置一个定时器,并且在切换出牌玩家的时候重置定时器。

// 超时计时
memoryData.timeoutId = setTimeout(() => {
    logger.info(`${roomNumber} ${other} 超时 ${memoryData[other].myMaxThinkTimeNumber}秒, 自动结束回合`);

    endMyTurn(args, getSocket(roomNumber, other));
    if (memoryData[other].timeoutTimes) {
        memoryData[other].timeoutTimes += 1;

        if (memoryData[other].timeoutTimes === 4) {
            logger.info(`${roomNumber} ${other} 超时 ${memoryData[other].myMaxThinkTimeNumber}秒, 达到4次, 开始超时惩罚`);
            memoryData[other].myMaxThinkTimeNumber = memoryData[other].myMaxThinkTimeNumber / 2;
        } else if (memoryData[other].timeoutTimes >= 6) {
            logger.info(`${roomNumber} ${other} 超时 ${memoryData[other].myMaxThinkTimeNumber}秒, 达到6次, 惩罚输掉游戏`);
            giveUp(args, getSocket(roomNumber, other));
        }
    } else {
        memoryData[other].timeoutTimes = 1;
    }
}, memoryData[other].myMaxThinkTimeNumber * 1000);

同时,我们参考了炉石的罚时机制,如果超时到达4吃,会给超时的玩家罚时惩罚,思考时间减半。如果超时6次,直接会判对手获胜。

这样,玩家就可以放心地享受到更加公正、公平的游戏体验了。这个改进将有助于提高游戏的整体质量和玩家的游戏体验。

JWT授权

首先什么是JWT,JWT(JSON Web Token)是一个基于JSON的开放标准,用于在网络上安全地传输信息,主要用于身份验证和授权。具体jwt的细节大家可以自行在网上搜索。

加入jwt授权的目的是为了保持网站接口无状态的同时,又能够安全的进行身份验证和授权,其实在进行游戏中是不需要进行身份验证的,因为游戏中使用的是websocket进行数据交互,只要一旦连接成功服务器,就代表websocket能持续保持连接不会修改。

在最新版本中,我们将使用JWT来进行授权,通过验证JWT令牌来确保用户的身份。具体实现步骤如下:

  1. 用户在进入网站时,需要先通过登录验证身份,并获取到JWT令牌。 const token = jwt.sign({id : result._id}, JWTSecret, {expiresIn: 60 * 60 * 24 * 7});
  2. 用户在使用我们的服务时,需要在请求中携带JWT令牌,以便服务端进行验证和授权。 axios.defaults.headers.common['Authorization'] = "Bearer " + res.data.data.token;
  3. 验证JWT令牌。服务端在接收到每个请求后,都会对JWT令牌进行验证。 app.use(jwt({ secret: JWTSecret, algorithms: ["HS256"] }).unless({ path: ["/users/login"] }))

职业技能

得益于使用的是js语言,这种语言非常的随意,能够非常便捷的获取数据和传递数据或者方法,所以想要实现职业技能,就和普通卡牌的效果是一样的实现方法。难点不在于职业技能的代码框架,反而在于如何设计比较平衡的职业技能。

如果和卡牌一样单纯的模仿炉石传说,可能和当前游戏的设定不太符合,所以我对职业技能重新进行了设计,基于这个框架,职业技能不再是单一的一个技能,而是可以进行多职业技能的携带,并且根据技能耗费的增多,技能的效果也会更加强大。

我对职业技能的界面设计如图:

本次文章里并不会设计所有职业技能,而是在关卡中放置了一个需要用到职业技能的关卡,这样会把复杂度降低,只关注到职业技能的实现。

首先服务端会把对战双方的技能发送到客户端,客户端展示在牌桌上。

this.gameData[first]['skillList'] = [
    {
        name: "阅读书籍",
        cost: 1,
        isTarget: true,
        targetType: TargetType.MY_TABLE_CARD,
        description: '通过阅读书籍提高自己,选择提高牌桌上1张卡牌1点攻击力',
        onChooseTarget: cardEffectFactory.oneChooseCardAddAttack(1)
    }
]

如果有多个技能,只需要发送整个技能列表到客户端就可以。

点击其中某个技能的时候,客户端会进行一次消费的计算,如果费用不够则不会进行技能的释放,如果费用够了,会交由服务端进行判断,满足所有技能释放条件时,会发送技能释放的指令到客户端进行执行。

onSkillClick(index, skill) {
    if (skill.cost > this.gameData.myFee) {
        this.showError("费用不足");
    } else {
        this.currentSkillIndex = index;
        if (skill.isTarget) {
            let card = this.gameData.myCard[this.currentCardIndex];
            if (skill.targetType === TargetType.MY_TABLE_CARD) {
                this.chooseCardList = this.gameData.myTableCard.slice();
            } else if (skill.targetType === TargetType.OTHER_TABLE_CARD) {
                this.chooseCardList = this.gameData.otherTableCard.slice();
            } else if (skill.targetType === TargetType.ALL_TABLE_CARD) {
                this.chooseCardList =
                    this.gameData.otherTableCard.slice()
                        .concat(this.gameData.myTableCard.slice());
            } else if (skill.targetType === TargetType.ALL_TABLE_CARD_FILTER_INCLUDE) { // 全桌面卡,过滤条件包含
                this.chooseCardList =
                    this.gameData.otherTableCard
                        .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) !== -1) && !i.isHide)
                        .map(i => Object.assign({}, i, {name: i.name + "(敌方)"}))
                        .concat(this.gameData.myTableCard.slice().filter(i => skill.filter.every(t => i.type.indexOf(t) !== -1)));
            } else if (skill.targetType === TargetType.ALL_TABLE_CARD_FILTER_EXCLUDE) {
                this.chooseCardList =
                    this.gameData.otherTableCard
                        .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) === -1) && !i.isHide)
                        .concat(this.gameData.myTableCard
                            .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) === -1)));
            } else if (skill.targetType === TargetType.MY_TABLE_CARD_FILTER_INCLUDE) {
                this.chooseCardList = this.gameData.myTableCard
                    .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) !== -1));
            } else if (skill.targetType === TargetType.MY_TABLE_CARD_FILTER_EXCLUDE) {
                this.chooseCardList = this.gameData.myTableCard
                    .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) === -1));
            } else if (skill.targetType === TargetType.OTHER_TABLE_CARD_FILTER_INCLUDE) {
                this.chooseCardList = this.gameData.otherTableCard
                    .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) !== -1));
            } else if (skill.targetType === TargetType.OTHER_TABLE_CARD_FILTER_EXCLUDE) {
                this.chooseCardList = this.gameData.otherTableCard
                    .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) === -1));
            }

            // 展示选择框
            this.chooseDialogType = ChooseDialogType.SKILL;
            this.chooseDialogShow = true;
        } else {
            useSkillCommand.apply(this);
        }
    }
},

服务端:

/**
 * 使用技能
 * @param args
 * @param socket
 */
function useSkill(args, socket) {
    let roomNumber = args.r, index = args.index, targetIndex = args.targetIndex, skill;
    const memoryData = getRoomData(roomNumber);

    let belong = getSocket(roomNumber, "one").id === socket.id ? "one" : "two"; // 判断当前是哪个玩家出牌
    let other = getSocket(roomNumber, "one").id !== socket.id ? "one" : "two";

    if (index !== -1 && memoryData[belong]["skillList"][index].cost <= memoryData[belong]["fee"]) {
        skill = memoryData[belong]["skillList"][index];

        if (!skill.roundMaxUseTimes) {
            skill.roundMaxUseTimes = 1;
        }
        if (!memoryData[belong]["useSkillRoundTimes"]) {
            memoryData[belong]["useSkillRoundTimes"] = 0;
        }
        if (skill.roundMaxUseTimes <= memoryData[belong]["useSkillRoundTimes"]
            || (skill.maxUseTimes && skill.maxUseTimes <= +memoryData[belong]["useSkillTimes"])) {
            error(socket, "技能使用次数超过上限");
            return;
        }

        // 检查是否违反卡牌的必须选择施法对象属性(isForceTarget)
        let chooseCardList = [];
        if (skill.isTarget) {
            if (skill.targetType === TargetType.MY_TABLE_CARD) {
                chooseCardList = memoryData[belong]["tableCards"];
            } else if (skill.targetType === TargetType.OTHER_TABLE_CARD) {
                chooseCardList = memoryData[other]["tableCards"];
            } else if (skill.targetType === TargetType.ALL_TABLE_CARD) {
                chooseCardList =
                    memoryData[other]["tableCards"].slice()
                        .concat(memoryData[belong]["tableCards"].slice());
            } else if (skill.targetType === TargetType.ALL_TABLE_CARD_FILTER_INCLUDE) {
                chooseCardList =
                    memoryData[other]["tableCards"]
                        .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) !== -1) && !i.isHide)
                        .concat(memoryData[belong]["tableCards"]
                            .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) !== -1)));
            } else if (skill.targetType === TargetType.ALL_TABLE_CARD_FILTER_EXCLUDE) {
                chooseCardList =
                    memoryData[other]["tableCards"]
                        .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) === -1) && !i.isHide)
                        .concat(memoryData[belong]["tableCards"]
                            .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) === -1)));
            } else if (skill.targetType === TargetType.MY_TABLE_CARD_FILTER_INCLUDE) {
                chooseCardList = memoryData[belong]["tableCards"]
                    .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) !== -1));
            } else if (skill.targetType === TargetType.MY_TABLE_CARD_FILTER_EXCLUDE) {
                chooseCardList = memoryData[belong]["tableCards"]
                    .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) === -1));
            } else if (skill.targetType === TargetType.OTHER_TABLE_CARD_FILTER_INCLUDE) {
                chooseCardList = memoryData[other]["tableCards"]
                    .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) !== -1));
            } else if (skill.targetType === TargetType.OTHER_TABLE_CARD_FILTER_EXCLUDE) {
                chooseCardList = memoryData[other]["tableCards"]
                    .slice().filter(i => skill.filter.every(t => i.type.indexOf(t) === -1));
            }
            // 必须选择施法对象,返回错误
            if (chooseCardList.length === 0 && targetIndex === -1 && skill.isForceTarget) {
                error(getSocket(roomNumber, belong), "请选择目标");
                return;
            }
        }
        memoryData[belong]["fee"] -= skill.cost;

        let mySpecialMethod = getSpecialMethod(belong, roomNumber);

        getSocket(roomNumber, belong).emit("USE_SKILL", {
            index,
            skill,
            isMine: true,
            myHero: extractHeroInfo(memoryData[belong]),
            otherHero: extractHeroInfo(memoryData[other])
        });
        getSocket(roomNumber, other).emit("USE_SKILL", {
            index,
            skill,
            isMine: false,
            myHero: extractHeroInfo(memoryData[other]),
            otherHero: extractHeroInfo(memoryData[belong])
        })

        if (skill.isTarget) {
            skill.onChooseTarget({
                myGameData: memoryData[belong],
                otherGameData: memoryData[other],
                source: skill,
                chooseCard: chooseCardList[targetIndex],
                effectIndex: args.effectIndex,
                fromIndex: -1,
                toIndex: targetIndex,
                specialMethod: mySpecialMethod
            });
        }

        if (skill && skill.onStart) {
            skill.onStart({
                myGameData: memoryData[belong],
                otherGameData: memoryData[other],
                source: skill,
                specialMethod: mySpecialMethod
            });
        }

        memoryData[belong]["useSkillTimes"]++;
        memoryData[belong]["useSkillRoundTimes"]++;

        checkCardDieEvent(roomNumber);
    } else {
        error(socket, '费用不足或未选择使用的技能');
    }

    checkPvpWin(roomNumber);
    checkPveWin(roomNumber);
}

接下来的计划

由于现在的AI很强很火(想蹭个热度),同时既然我想把这个项目做好,所以我接下来会用AI把整个游戏进行一次新的设计。

并且我希望制作一款更便于操作的工具,让卡牌对战类的游戏更方便制作。

想做的事情很多,但是奈何业余时间太少,大家如果有什么好的想法也请多多留言。


4 条评论

查理 · 2023年6月28日 09:29

在线版登录不了?

    xiejingyang · 2023年6月30日 20:50

    写了个bug。。现在已经修复了。

动词 · 2023年10月16日 11:25

请问github上的源码是最新版吗?

    xiejingyang · 2023年11月5日 14:53

    你看dev分支,应该是最新的

发表回复

Avatar placeholder

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