引言
《杀戮尖塔》(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可在数秒内完成。
扩展方向
-
更智能的AI策略:可用Minimax或MCTS替代贪心策略,考虑未来回合的牌序预期。
-
** relic 与药水建模**:加入遗物(永久被动)和药水(一次性消耗品)的效果,使模拟更接近真实游戏。
-
多层敌人建模:模拟精英怪和Boss的特殊机制(如能力牌、多阶段形态转换)。
-
牌组进化模拟:用遗传算法迭代优化牌组构成,自动发现高胜率组合。
总结
本文通过Java实现了杀戮尖塔的核心战斗模拟系统,并运用蒙特卡洛方法量化了不同牌组的期望性能。蒙特卡洛模拟的价值在于将”这套牌强不强”的感性判断,转化为”平均每回合32.5点伤害,5回合击杀率78.3%”的精确数据。这种将概率采样与游戏策略结合的方法,不仅适用于卡牌游戏,也可推广到任何具有随机性的策略评估场景。