如何用JavaScript计算炉石传说战旗胜率

501 阅读5分钟

身为多年的炉石粉(严重白嫖党),对于近期炉石圈内十分火热的战旗模式也是十分喜爱。相信关注炉石战旗的同学们看相关直播的时候,会发现战局开始时候游戏界面上方会出现胜率相关的计算值。下面我们用js模拟一下这个具体是计算值是怎么得来的

For炉石小白

如果你没玩过炉石的战旗模式又想来参与这个有趣的计算,下面就来简单描述一下这个游戏是怎么玩的

我们可以看到上述图片,对局双方最多可以使用七个(可以少于七个棋子开局)棋子,每个棋子都有攻击力和生命值等属性。战局开始时,随机一方先开始攻击,棋子的攻击顺序都是固定从左到右开始攻击的(先不考虑伊利丹),最后一个攻击完又返回到左边第一个继续攻击,以此类推。而棋子是向对方随机一个棋子来做攻击的,一个棋子攻击另一个棋子后,双方棋子的生命值都会减去另一方棋子的攻击力。若棋子的生命值小于等于0,则表示当前棋子阵亡。

准备

上述就是战旗模式最最简单的玩法(排除各张牌的属性和嘲讽圣盾亡语等比较复杂的属性)

现在我们先对上述最简单的玩法建模

interface PlayerImpl {    
    chessArr: Array<ChessImpl>
}

interface ChessImpl {    
    aggressivity: number;    
    health: number
}

class Player implements PlayerImpl {    
    chessArr: []    
    constructor (params) {       
    this.chessArr = params.chessArr    
    }
}

创建了简单的玩家和棋子的对象后,我们就可以设计对局的结算了!

class Fighting {
    playerA: PlayerImpl
    playerB: PlayerImpl
    playerAWinRate: number    
    playerAWinCount: number    
    playerBWinRate: number    
    playerBWinCount: number    
    drawRate: number    
    drawCount: number    
    constructor (playerA: PlayerImpl, playerB: PlayerImpl) {        
    this.playerA = playerA        
    this.playerB = playerB        
    this.calWinRate()    
}

    calWinRate () {
        //....
    }
}

上述就是我们简单的对局计算模型,关键的计算在于calWinRate函数的执行。我们回到游戏部分,我们看到攻击模式,可以用一个队列来模拟当前棋子的攻击,棋子攻击一次若未阵亡则出队列并从新进队。但这里的计算有一个比较恐怖的地方,因为棋子的攻击是随机的,每次可能性的攻击都会带来一个可能的情况。要把所有的情况都统计出来,遍历下去是一个很恐怖的n叉树。我们先不考虑性能先暴力递归来实现算法。

class Fighting {    
    playerA: PlayerImpl    
    playerB: PlayerImpl    
    playerAWinRate: number    
    playerAWinCount: number    
    playerBWinRate: number    
    playerBWinCount: number    
    drawRate: number    
    drawCount: number    
    constructor (playerA: PlayerImpl, playerB: PlayerImpl) {        
        this.playerA = playerA        
        this.playerB = playerB
        this.drawCount = 0
        this.playerAWinCount = 0
        this.playerBWinCount = 0
        this.calWinRate()
    }
    calWinRate () {
        // 开局攻击顺序是谁的棋子数量较多则先攻击的
        // 若棋子数量一致,开局谁先攻击是任意的,所以这里要覆盖两种情况
        if (this.playerA.chessArr.length >= this.playerB.chessArr.length) {
            this.treeReduce({
                attacker: {
                    queue: JSON.parse(JSON.stringify(this.playerA.chessArr)),
                    player: 'A'
                },
                defender: {
                    queue: JSON.parse(JSON.stringify(this.playerB.chessArr)),
                    player: 'B'
                }
            })
        }
        if (this.playerB.chessArr.length >= this.playerA.chessArr.length) {
            this.treeReduce({
                attacker: {
                    queue: JSON.parse(JSON.stringify(this.playerB.chessArr)),
                    player: 'B'
                },
                defender: {
                    queue: JSON.parse(JSON.stringify(this.playerA.chessArr)),
                    player: 'A'
                }
            })
        }
        let total = this.playerAWinCount + this.playerBWinCount + this.drawCount
        this.drawRate = this.drawCount / total
        this.playerAWinRate = this.playerAWinCount / total
        this.playerBWinRate = this.playerBWinCount / total
    }
    treeReduce (node: TreeNode) {
        if(!node.attacker.queue.length && !node.defender.queue.length) {
            this.drawCount++ // draw
            return null
        }
        if(node.attacker.queue.length && !node.defender.queue.length) {
            if (node.attacker.player === 'A') {
                this.playerAWinCount++
            } else {
                this.playerBWinCount++
            }
            return null
        }
        if(!node.attacker.queue.length && node.defender.queue.length) {
            if (node.defender.player === 'A') {
                this.playerAWinCount++
            } else {
                this.playerBWinCount++
            }
            return null
        }
        node.defender.queue.forEach((dNode, index) => {
            let attackResult = this.chessAttack(node.attacker.queue[0], dNode)
            let _defenceQueue = JSON.parse(JSON.stringify(node.attacker.queue)) // 下次遍历交换攻击
            let _attackQueue = JSON.parse(JSON.stringify(node.defender.queue))
            _defenceQueue.shift() // 攻击完出队列
            if(attackResult[0].health > 0) { // 攻击完没死重新进队列
                _defenceQueue.push(attackResult[0])
            }
            if(attackResult[1].health <= 0) {
                _attackQueue.splice(index, 1)
            } else {
                _attackQueue[index] = attackResult[1]
            }
            let newNode = {
                attacker: {
                    queue: _attackQueue,
                    player: node.defender.player
                },
                defender: {
                    queue: _defenceQueue,
                    player: node.attacker.player
                }
            }
            this.treeReduce.apply(this, [newNode])
        })
    }
    chessAttack(chessX: ChessImpl, chessY: ChessImpl) {
        let XHealth = chessX.health - chessY.aggressivity;
        let YHealth = chessY.health - chessX.aggressivity;
        return [{ aggressivity: chessX.aggressivity, health: XHealth }, { aggressivity: chessY.aggressivity, health: YHealth }];
    }}

上述代码中我们做了一个简易的N叉树遍历,以一方数组为空视为战败,两方数组为空视为平局得逻辑,遍历最后一步得到战果后记录下战果。在上诉计算过程中有几步需要注意的。一个是因为开局时双方的攻击顺序是随意的,因此遍历的算法要把两个队列的位置都互换一遍覆盖所有的战局情况。

我们用几个简单的例子来测试一下上诉代码是否能通过。

例子1: 

我方阵容: 小老虎1号(攻击力1,生命值1),小老虎二号(攻击力1,生命值1)

敌方整容: 鱼人一号(攻击力2,生命值1),鱼人二号(攻击力1,生命值1)

测试代码

var force = new Player([{ aggressivity: 1, health: 1 }, { aggressivity: 1, health: 1 }]); // 两只小老虎
var enemy = new Player([{ aggressivity: 2, health: 1 }, { aggressivity: 1, health: 1 }]); // 两只小鱼人
var game = new Fighting(force, enemy);
console.log(`平局:${game.drawRate}`)
console.log(`胜利:${game.playerAWinRate}`)
console.log(`失败:${game.playerBWinRate}`)

结果

平局:1
胜利:0
失败:0

验证通过,用脑子就可以想得出无论棋子以什么顺序攻击,最终都只会时平局。

例子2:

我方阵容: 恶魔(攻击力2,生命值4)

敌方阵容:鱼人一号(攻击力2,生命值1),鱼人二号(攻击力1,升名字1)

测试代码

let force = new Player([{aggressivity: 2, health: 4}]) // 24恶魔
let enemy = new Player([{aggressivity: 2, health: 1}, {aggressivity: 1, health: 1}]) // 两只小鱼人
let game = new Fighting(force, enemy)
console.log(`平局:${game.drawRate}`)
console.log(`胜利:${game.playerAWinRate}`)
console.log(`失败:${game.playerBWinRate}`)

结果

平局:0
胜利:1
失败:0

验证也是通过的。

例子3:

复杂一点的阵容:

我方阵容: 鱼人一号(攻击力4,生命值4),鱼人二号(攻击力3,生命值3)

敌方整容:鱼人一号(攻击力3,生命值4),鱼人二号(攻击力1,生命值1)

验证代码:

let force = new Player([{aggressivity: 4, health: 4}, {aggressivity: 3, health: 3}]) // 24恶魔
let enemy = new Player([{aggressivity: 3, health: 4}, {aggressivity: 1, health: 1}]) // 两只小鱼人
let game = new Fighting(force, enemy)
console.log(`平局:${game.drawRate}`)
console.log(`胜利:${game.playerAWinRate}`)
console.log(`失败:${game.playerBWinRate}`)

结果

平局:0.42857142857142855
胜利:0.5714285714285714
失败:0

也是符合预期的

例子4:

双方都是七条(4,4)的鱼人

结果:

平局:1
胜利:0
失败:0
用时: 203.133ms

结果符合预期,且用时并不多

例子五:

决战阵容!

测试代码

let force = new Player([{aggressivity: 40, health: 47},{aggressivity: 42, health: 46},{aggressivity: 40, health: 24},{aggressivity: 44, health: 24},{aggressivity: 34, health: 24},{aggressivity: 34, health: 34},{aggressivity: 14, health: 24}])
let enemy = new Player([{aggressivity: 40, health: 43},{aggressivity: 48, health: 43},{aggressivity: 43, health: 34},{aggressivity: 24, health: 64},{aggressivity: 34, health: 24},{aggressivity: 24, health: 54},{aggressivity: 14, health: 14}])
let game = new Fighting(force, enemy)
console.log(`平局:${game.drawRate}`)
console.log(`胜利:${game.playerAWinRate}`)
console.log(`失败:${game.playerBWinRate}`)

测试结果

平局:0.16141298807649082
胜利:0.020378968704648136
失败:0.8182080432188611
用时: 24389.658ms

我们可以看到,算法计算是OK的,但需要进行多迭代时,用时非常大,当前十分简单的模式决赛阵容的计算耗时竟达到了20多秒,这显然是不合格的!

下面我们来优化一下代码!

我们来找找究竟是哪里耗时和消耗内存比较多呢。

(1)把浅拷贝换成Object.assign()

结果:

平局:0.16141298807649082
胜利:0.020378968704648136
失败:0.8182080432188611
用时: 11368.376ms

大家知道以后浅拷贝用哪个了吧?!!!!!!!! 性能直接提高一倍有多!!!!!

当然代码还有很多需要改进的地方,后续会继续更新。

下篇我们将引入炉石战旗更多有意思的属性:圣盾,亡语、嘲讽等会如何影响我们的计算。