每日算法 — 使用java实现杀戮尖塔:卡牌构建蒙特卡洛模拟与期望伤害计算

引言

《杀戮尖塔》(Slay the Spire)是Roguelike卡牌游戏的标杆之作。玩家需要在爬塔过程中不断收集卡牌、构建牌组,并在回合制战斗中击败敌人。一个核心算法问题是:给定一套牌组,在随机抽牌机制下,每回合能打出多少期望伤害? 本文将用Java实现卡牌构建系统,通过蒙特卡洛模拟精确计算不同牌组的期望伤害输出,为牌组优化提供量化依据。

核心机制建模

杀戮尖塔的战斗系统包含以下关键要素:手牌、能量、卡牌效果、敌人状态。我们需要将这些都建模为数据结构。

基础数据模型

import java.util.*;

/**
 * 卡牌基类:定义所有卡牌的通用属性和行为
 */
abstract class Card {
    public final String name;
    public final int cost;           // 能量消耗
    public final CardType type;      // 卡牌类型
    public final CardRarity rarity;  // 稀有度

    public Card(String name, int cost, CardType type, CardRarity rarity) {
        this.name = name;
        this.cost = cost;
        this.type = type;
        this.rarity = rarity;
    }

    /**
     * 执行卡牌效果,返回造成的伤害值
     * @param caster 出牌者(玩家)
     * @param target 目标(敌人)
     * @param context 战斗上下文
     */
    public abstract int play(Character caster, Character target, BattleContext context);

    /**
     * 获取卡牌描述(用于调试)
     */
    public abstract String getDescription();
}

enum CardType { ATTACK, SKILL, POWER }
enum CardRarity { BASIC, COMMON, UNCOMMON, RARE }

/**
 * 攻击卡牌:直接造成伤害
 */
class AttackCard extends Card {
    public final int baseDamage;
    public final int hitCount;       // 攻击次数(如多重打击)

    public AttackCard(String name, int cost, int baseDamage, int hitCount, CardRarity rarity) {
        super(name, cost, CardType.ATTACK, rarity);
        this.baseDamage = baseDamage;
        this.hitCount = hitCount;
    }

    @Override
    public int play(Character caster, Character target, BattleContext context) {
        int totalDamage = 0;
        for (int i = 0; i < hitCount; i++) {
            int dmg = baseDamage + caster.strength;
            // 脆弱效果:增加50%伤害
            if (target.vulnerable > 0) {
                dmg = (int)(dmg * 1.5);
            }
            // 虚弱效果:减少25%伤害(对玩家生效)
            if (caster.weak > 0) {
                dmg = (int)(dmg * 0.75);
            }
            totalDamage += dmg;
            target.hp -= dmg;
        }
        return totalDamage;
    }

    @Override
    public String getDescription() {
        return String.format("造成 %d 点伤害%s", baseDamage, hitCount > 1 ? " x" + hitCount : "");
    }
}

/**
 * 技能卡牌:提供增益、抽牌或特殊效果
 */
class SkillCard extends Card {
    public final int drawAmount;      // 抽牌数
    public final int blockGain;       // 获得格挡
    public final int strengthGain;    // 获得力量

    public SkillCard(String name, int cost, int draw, int block, int strength, CardRarity rarity) {
        super(name, cost, CardType.SKILL, rarity);
        this.drawAmount = draw;
        this.blockGain = block;
        this.strengthGain = strength;
    }

    @Override
    public int play(Character caster, Character target, BattleContext context) {
        caster.block += blockGain;
        caster.strength += strengthGain;
        if (drawAmount > 0) {
            context.drawCards(caster, drawAmount);
        }
        return 0; // 技能牌不直接造成伤害
    }

    @Override
    public String getDescription() {
        StringBuilder sb = new StringBuilder();
        if (blockGain > 0) sb.append("获得 ").append(blockGain).append(" 点格挡 ");
        if (drawAmount > 0) sb.append("抽 ").append(drawAmount).append(" 张牌 ");
        if (strengthGain > 0) sb.append("获得 ").append(strengthGain).append(" 点力量");
        return sb.toString();
    }
}

/**
 * 能力卡牌:战斗全程持续生效的被动效果
 */
class PowerCard extends Card {
    public final int strengthPerTurn; // 每回合获得力量
    public final int dexterityPerTurn; // 每回合获得敏捷

    public PowerCard(String name, int cost, int str, int dex, CardRarity rarity) {
        super(name, cost, CardType.POWER, rarity);
        this.strengthPerTurn = str;
        this.dexterityPerTurn = dex;
    }

    @Override
    public int play(Character caster, Character target, BattleContext context) {
        caster.powerStrength += strengthPerTurn;
        caster.powerDexterity += dexterityPerTurn;
        return 0;
    }

    @Override
    public String getDescription() {
        return String.format("每回合获得 %d 力量 %d 敏捷", strengthPerTurn, dexterityPerTurn);
    }
}

角色与战斗上下文

/**
 * 角色(玩家或敌人)
 */
class Character {
    public int hp;
    public int maxHp;
    public int energy;
    public int maxEnergy = 3;
    public int strength = 0;       // 力量:增加攻击伤害
    public int dexterity = 0;      // 敏捷:增加格挡值
    public int block = 0;          // 当前格挡
    public int vulnerable = 0;     // 脆弱回合数
    public int weak = 0;           // 虚弱回合数
    public int powerStrength = 0;  // 能力牌提供的每回合力量
    public int powerDexterity = 0; // 能力牌提供的每回合敏捷

    public Character(int maxHp) {
        this.maxHp = maxHp;
        this.hp = maxHp;
    }

    /**
     * 回合开始时重置状态
     */
    public void onTurnStart() {
        energy = maxEnergy;
        block = 0; // 格挡在回合开始时清空
        strength += powerStrength;
        dexterity += powerDexterity;
        if (vulnerable > 0) vulnerable--;
        if (weak > 0) weak--;
    }
}

/**
 * 战斗上下文:管理牌库、手牌、弃牌堆
 */
class BattleContext {
    public final List<Card> drawPile;      // 抽牌堆
    public final List<Card> hand;          // 手牌
    public final List<Card> discardPile;   // 弃牌堆
    public final List<Card> exhaustPile;   // 消耗堆
    public final Random random;

    public BattleContext(List<Card> deck, long seed) {
        this.drawPile = new ArrayList<>(deck);
        this.hand = new ArrayList<>();
        this.discardPile = new ArrayList<>();
        this.exhaustPile = new ArrayList<>();
        this.random = new Random(seed);
        Collections.shuffle(drawPile, random);
    }

    /**
     * 抽指定数量的牌
     */
    public void drawCards(Character player, int amount) {
        for (int i = 0; i < amount; i++) {
            if (drawPile.isEmpty()) {
                // 抽牌堆空时,将弃牌堆洗牌后作为新的抽牌堆
                if (discardPile.isEmpty()) break;
                drawPile.addAll(discardPile);
                discardPile.clear();
                Collections.shuffle(drawPile, random);
            }
            if (!drawPile.isEmpty()) {
                hand.add(drawPile.remove(0));
            }
        }
    }

    /**
     * 打出一张牌
     */
    public void playCard(Character player, Character enemy, Card card) {
        if (!hand.contains(card)) return;
        if (player.energy < card.cost) return;

        player.energy -= card.cost;
        hand.remove(card);
        card.play(player, enemy, this);

        // 根据卡牌类型放入不同堆
        if (card.type == CardType.POWER) {
            // 能力牌直接进入消耗堆(不再出现)
            exhaustPile.add(card);
        } else {
            discardPile.add(card);
        }
    }

    /**
     * 回合结束:将手牌放入弃牌堆
     */
    public void endTurn() {
        discardPile.addAll(hand);
        hand.clear();
    }
}

蒙特卡洛模拟引擎

蒙特卡洛方法通过大量随机采样来估计期望值。对于卡牌游戏,核心随机性来源于抽牌的顺序。

/**
 * 蒙特卡洛模拟引擎:通过大量随机对局估计牌组性能
 */
public class MonteCarloEngine {
    private final List<Card> deck;       // 牌组
    private final int simulationCount;   // 模拟次数
    private final int turnLimit;         // 每局模拟回合数

    public MonteCarloEngine(List<Card> deck, int simulationCount, int turnLimit) {
        this.deck = new ArrayList<>(deck);
        this.simulationCount = simulationCount;
        this.turnLimit = turnLimit;
    }

    /**
     * 运行模拟并返回统计结果
     */
    public SimulationResult runSimulation() {
        List<Double> turnDamages = new ArrayList<>();       // 每回合伤害
        List<Double> cumulativeDamages = new ArrayList<>();  // 累计伤害
        List<Integer> cardsPlayed = new ArrayList<>();       // 每回合出牌数

        for (int sim = 0; sim < simulationCount; sim++) {
            // 每次模拟使用不同的随机种子
            BattleContext context = new BattleContext(deck, System.nanoTime() + sim);
            Character player = new Character(80);
            Character enemy = new Character(200);

            double totalDamage = 0;

            for (int turn = 0; turn < turnLimit; turn++) {
                player.onTurnStart();
                context.drawCards(player, 5); // 每回合抽5张牌

                // 简单AI:贪心选择能打出且伤害最高的牌
                int turnDamage = playTurnGreedy(player, enemy, context);
                totalDamage += turnDamage;

                turnDamages.add((double) turnDamage);
                cumulativeDamages.add(totalDamage);

                context.endTurn();
            }
        }

        return new SimulationResult(turnDamages, cumulativeDamages);
    }

    /**
     * 贪心策略打出一个回合:优先打出高伤害/高效能的牌
     */
    private int playTurnGreedy(Character player, Character enemy, BattleContext context) {
        int turnDamage = 0;
        int cardsThisTurn = 0;

        while (player.energy > 0 && !context.hand.isEmpty()) {
            Card bestCard = null;
            double bestScore = -1;

            for (Card card : context.hand) {
                if (card.cost > player.energy) continue;

                double score = evaluateCard(card, player, enemy, context);
                if (score > bestScore) {
                    bestScore = score;
                    bestCard = card;
                }
            }

            if (bestCard == null) break;

            int beforeHp = enemy.hp;
            context.playCard(player, enemy, bestCard);
            turnDamage += (beforeHp - enemy.hp);
            cardsThisTurn++;
        }

        return turnDamage;
    }

    /**
     * 卡牌评分函数:用于贪心决策
     */
    private double evaluateCard(Card card, Character player, Character enemy, BattleContext context) {
        if (card instanceof AttackCard) {
            AttackCard atk = (AttackCard) card;
            double dmg = atk.baseDamage + player.strength;
            if (enemy.vulnerable > 0) dmg *= 1.5;
            if (player.weak > 0) dmg *= 0.75;
            return dmg * atk.hitCount / Math.max(1, card.cost);
        } else if (card instanceof SkillCard) {
            SkillCard skill = (SkillCard) card;
            // 抽牌的价值约为"期望能抽到1张攻击牌的伤害"
            double drawValue = skill.drawAmount * 8.0;
            double strengthValue = skill.strengthGain * 15.0; // 力量持续多回合
            return (drawValue + strengthValue + skill.blockGain) / Math.max(1, card.cost);
        } else if (card instanceof PowerCard) {
            // 能力牌的价值 = 每回合收益 × 剩余回合数
            PowerCard power = (PowerCard) card;
            return (power.strengthPerTurn * 10.0 * turnLimit) / Math.max(1, card.cost);
        }
        return 0;
    }

    public static class SimulationResult {
        public final List<Double> turnDamages;
        public final List<Double> cumulativeDamages;

        public SimulationResult(List<Double> turnDamages, List<Double> cumulativeDamages) {
            this.turnDamages = turnDamages;
            this.cumulativeDamages = cumulativeDamages;
        }

        public double getAverageTurnDamage() {
            return turnDamages.stream().mapToDouble(d -> d).average().orElse(0);
        }

        public double getStdDev() {
            double avg = getAverageTurnDamage();
            double variance = turnDamages.stream()
                .mapToDouble(d -> Math.pow(d - avg, 2))
                .average().orElse(0);
            return Math.sqrt(variance);
        }

        public double getKillProbability(int enemyHp) {
            long kills = cumulativeDamages.stream()
                .filter(d -> d >= enemyHp)
                .count();
            return (double) kills / cumulativeDamages.size();
        }
    }
}

牌组构建与比较

public class SlayTheSpireSimulation {

    /**
     * 创建示例牌组1:攻击型(高伤害输出)
     */
    public static List<Card> createAggroDeck() {
        List<Card> deck = new ArrayList<>();
        // 10张攻击牌
        for (int i = 0; i < 4; i++) deck.add(new AttackCard("打击", 1, 6, 1, CardRarity.BASIC));
        for (int i = 0; i < 3; i++) deck.add(new AttackCard("重击", 2, 14, 1, CardRarity.COMMON));
        deck.add(new AttackCard("完美打击", 2, 18, 1, CardRarity.UNCOMMON));
        deck.add(new AttackCard("飞剑回旋镖", 1, 3, 4, CardRarity.COMMON));

        // 5张技能牌
        deck.add(new SkillCard("防御", 1, 0, 5, 0, CardRarity.BASIC));
        deck.add(new SkillCard("耸肩无视", 1, 1, 8, 0, CardRarity.COMMON));
        deck.add(new SkillCard("战斗专注", 0, 0, 0, 2, CardRarity.UNCOMMON));

        // 2张能力牌
        deck.add(new PowerCard("恶魔形态", 3, 2, 0, CardRarity.RARE));

        return deck;
    }

    /**
     * 创建示例牌组2:过牌 Combo 型(通过大量抽牌找到关键牌)
     */
    public static List<Card> createCycleDeck() {
        List<Card> deck = new ArrayList<>();
        for (int i = 0; i < 3; i++) deck.add(new AttackCard("打击", 1, 6, 1, CardRarity.BASIC));
        deck.add(new AttackCard("重击", 2, 14, 1, CardRarity.COMMON));

        // 大量过牌技能
        deck.add(new SkillCard("快斩", 1, 1, 0, 0, CardRarity.COMMON));
        deck.add(new SkillCard("全神贯注", 0, 2, 0, 0, CardRarity.UNCOMMON));
        deck.add(new SkillCard("爆发", 0, 3, 0, 0, CardRarity.RARE));
        deck.add(new SkillCard("杂技", 1, 3, 0, 0, CardRarity.COMMON));
        deck.add(new SkillCard("盘算", 0, 2, 0, 0, CardRarity.COMMON));
        deck.add(new SkillCard("战斗专注", 0, 0, 0, 1, CardRarity.UNCOMMON));
        deck.add(new SkillCard("耸肩无视", 1, 1, 8, 0, CardRarity.COMMON));
        deck.add(new SkillCard("防御", 1, 0, 5, 0, CardRarity.BASIC));

        deck.add(new PowerCard("无限刃", 1, 1, 0, CardRarity.UNCOMMON));

        return deck;
    }

    public static void main(String[] args) {
        System.out.println("=== 杀戮尖塔牌组蒙特卡洛模拟 ===\n");

        // 牌组1:攻击型
        List<Card> aggroDeck = createAggroDeck();
        System.out.println("【攻击型牌组】");
        printDeckInfo(aggroDeck);

        MonteCarloEngine engine1 = new MonteCarloEngine(aggroDeck, 10000, 5);
        SimulationResult result1 = engine1.runSimulation();
        System.out.printf("平均每回合伤害: %.2f ± %.2f%n", 
            result1.getAverageTurnDamage(), result1.getStdDev());
        System.out.printf("5回合击杀200血敌人的概率: %.2f%%%n%n", 
            result1.getKillProbability(200) * 100);

        // 牌组2:过牌型
        List<Card> cycleDeck = createCycleDeck();
        System.out.println("【过牌Combo型牌组】");
        printDeckInfo(cycleDeck);

        MonteCarloEngine engine2 = new MonteCarloEngine(cycleDeck, 10000, 5);
        SimulationResult result2 = engine2.runSimulation();
        System.out.printf("平均每回合伤害: %.2f ± %.2f%n", 
            result2.getAverageTurnDamage(), result2.getStdDev());
        System.out.printf("5回合击杀200血敌人的概率: %.2f%%%n%n", 
            result2.getKillProbability(200) * 100);

        // 对比分析
        System.out.println("=== 牌组对比分析 ===");
        System.out.printf("攻击型 vs 过牌型 每回合伤害差: %+.2f%n",
            result1.getAverageTurnDamage() - result2.getAverageTurnDamage());
        System.out.printf("攻击型击杀概率 / 过牌型击杀概率: %.2f%n",
            result1.getKillProbability(200) / result2.getKillProbability(200));
    }

    private static void printDeckInfo(List<Card> deck) {
        Map<String, Long> typeCount = new HashMap<>();
        for (Card c : deck) {
            typeCount.merge(c.type.name(), 1L, Long::sum);
        }
        System.out.println("  牌组大小: " + deck.size() + " 张");
        System.out.println("  卡牌构成: " + typeCount);
    }
}

算法复杂度与收敛性分析

/**
 * 统计收敛性分析:模拟次数与估计精度的关系
 */
class ConvergenceAnalyzer {

    /**
     * 分析不同模拟次数下的估计稳定性
     */
    public static void analyzeConvergence(List<Card> deck, int maxSimulations) {
        System.out.println("=== 蒙特卡洛收敛性分析 ===");
        System.out.println("模拟次数\t期望伤害\t95%置信区间半宽");

        List<Double> estimates = new ArrayList<>();

        for (int n = 100; n <= maxSimulations; n *= 2) {
            MonteCarloEngine engine = new MonteCarloEngine(deck, n, 5);
            SimulationResult result = engine.runSimulation();

            double mean = result.getAverageTurnDamage();
            double std = result.getStdDev();
            double margin = 1.96 * std / Math.sqrt(n); // 95% CI

            estimates.add(mean);
            System.out.printf("%d\t\t%.4f\t\t%.4f%n", n, mean, margin);
        }

        // 计算相邻估计的变化率,判断收敛
        System.out.println("\n相邻估计变化率:");
        for (int i = 1; i < estimates.size(); i++) {
            double change = Math.abs(estimates.get(i) - estimates.get(i-1)) / estimates.get(i-1);
            System.out.printf("  %d -> %d: %.4f%%%n", 
                (int)(100 * Math.pow(2, i-1)), 
                (int)(100 * Math.pow(2, i)), 
                change * 100);
        }
    }
}

算法复杂度分析

模块 时间复杂度 说明
单局模拟 O(T × H × C) T=回合数, H=手牌上限, C=牌组大小
贪心出牌 O(H² × C) 每次选择最优牌需遍历手牌
蒙特卡洛 O(N × T × H² × C) N=模拟次数,通常取1000-10000
抽牌洗牌 O(C log C) 使用Collections.shuffle

实际运行中,对于20张牌的牌组、5回合模拟、10000次采样,Java可在数秒内完成。

扩展方向

  1. 更智能的AI策略:可用Minimax或MCTS替代贪心策略,考虑未来回合的牌序预期。

  2. ** relic 与药水建模**:加入遗物(永久被动)和药水(一次性消耗品)的效果,使模拟更接近真实游戏。

  3. 多层敌人建模:模拟精英怪和Boss的特殊机制(如能力牌、多阶段形态转换)。

  4. 牌组进化模拟:用遗传算法迭代优化牌组构成,自动发现高胜率组合。

总结

本文通过Java实现了杀戮尖塔的核心战斗模拟系统,并运用蒙特卡洛方法量化了不同牌组的期望性能。蒙特卡洛模拟的价值在于将”这套牌强不强”的感性判断,转化为”平均每回合32.5点伤害,5回合击杀率78.3%”的精确数据。这种将概率采样与游戏策略结合的方法,不仅适用于卡牌游戏,也可推广到任何具有随机性的策略评估场景。