ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用Python蒙特卡洛模拟量化RTS对战,从Replay复盘到单位克制分析

用Python蒙特卡洛模拟量化RTS对战,从Replay复盘到单位克制分析 很多人拿到一场 RTS 对战回放replay之后通常会看两遍交战镜头、截图吐槽两句就结束了。但如果你想真正提升水平尤其是新人阶段最应该做的是把“感觉”变成“数据”。本文围绕“不朽暴君 vs 界域戍卫 遗迹歼击者”这个典型单兵对抗场景演示如何用 Python 做一次单位对战数值模拟再结合新人 replay 的复盘思路把一场说不清输在哪的团战拆解成可以验证的模型。这套内容既适合刚接触 RTS、想研究单位克制的玩家也适合游戏数值策划入门、想用蒙特卡洛模拟来验证战斗平衡的同学。文章里会给出完整可运行的 Python 工程以及一套从 replay 录像到模拟分析的思路闭环。1. 背景与核心概念1.1 为什么要做单位对战模拟RTS 里最经典的困惑是“我这波兵力明明比对面多为什么打输了”如果你只看 replay 回放可能只会得出“操作变形”“没集火”“技能放慢了”这类模糊结论。但如果你把交战拆成一个可量化的模型比如攻击力、攻击速度、护甲、技能减伤、集火逻辑就能回答一个更本质的问题在不考虑操作的情况下这套兵力组合的理论胜率到底是多少。单位对战模拟的核心价值有三个复盘更客观。操作失误会被“数值层面打不过”这个事实覆盖掉避免把输团原因全归结于自己手残。决策更清晰。模拟告诉你“这波接团胜率只有 30%”你会做出更理性的选择。迭代更快。每改一个数值重新跑一遍一万次模拟比反复打训练场效率高得多。1.2 三个单位的定位假设既然是演示我这里的数值并不是从某个具体游戏里提取的官方数据而是一组“便于理解和复现”的假设值。你可以根据自己所在游戏的实际数据替换这些参数。单位阵营生命值攻击力攻击速度护甲射程技能假设不朽暴君A320281.024无主动技能纯正面硬刚单位界域戍卫B220160.835当友军平均血量低于 70% 时开启护盾全队减伤 30%持续 4 秒冷却 15 秒遗迹歼击者B160241.206每次攻击有 15% 概率进入蓄力状态下一发攻击造成 3 倍伤害从数值上看不朽暴君是一个高血量、中攻击的重型单位适合正面承伤。界域戍卫偏向防御辅助遗迹歼击者是远程爆发输出。二人组合如果打配合理论上有机会消耗掉不朽暴君的血量。这篇文章要模拟的对局就是1 个不朽暴君 VS 1 个界域戍卫 1 个遗迹歼击者。1.3 从 replay 到模拟的转化思维新人 replay 常见的问题是只关注自己单位编队和框选忽略了单位构成与数值关系。其实你可以把一次团战抽象成三个阶段接战前的状态双方单位数量、血量、技能冷却。接战中的交互攻击频率、集火目标、技能先后手。接战后的结果存活单位数量、剩余血量、下一波团的时间窗。模拟擅长处理“接战中的交互”。而 replay 能提供“接战前的状态”和“接战后的结果”。把 replay 里的实际兵力、血量写成模拟的输入参数你就能回答“如果当时不接这波团结果会不会更好”。所以本文的最终目标不是写一个机械的 Python 战斗脚本而是给你一套“数据分析 模拟验证”的复盘工具。2. 环境准备与项目结构2.1 运行环境本项目只需要 Python 3.8 及以上版本即可运行不需要额外安装第三方依赖。我用到的库都是标准库random模拟随机攻击事件。statistics计算平均剩余血量、平均战斗时长等统计量。dataclasses或普通class定义单位数据结构。如果你后续要做数据可视化可以安装matplotlib但不是必须项。本文的核心代码不依赖它。2.2 项目结构建议按照下面的目录结构创建文件夹rts_simulator/ ├── unit.py # 单位类 ├── battle.py # 战斗模拟逻辑 ├── main.py # 单次战斗 批量模拟演示 └── replay_notes.md # 复盘笔记模板可后续手写使用实际使用中你不需要严格拆分这么多文件。但早期就养成模块化习惯后面扩展经济模型、技能系统时会更轻松。3. 单位属性建模3.1 属性字段设计在写代码之前先想清楚单位需要哪些字段name单位名称用于日志输出和技能判断。team阵营编号1 表示 A 方2 表示 B 方。max_hp/hp最大生命值和当前生命值。attack单次攻击力。attack_speed每秒攻击次数单位是次/秒。armor护甲值用于最终伤害减免。attack_range射程。由于本文模拟的是近距离团战可以先忽略移动距离只保留字段备用。next_attack_time下次攻击时间用于时间步进计算。is_alive是否存活。这套字段已经能覆盖多数 RTS 单位的基础属性。如果你要加入魔法值、技能范围、控制效果再扩展字段即可。3.2 实现 Unit 类先看完整代码class Unit: def __init__( self, name: str, team: int, hp: int, attack: int, attack_speed: float, armor: int, attack_range: float 5.0, ): self.name name self.team team self.max_hp hp self.hp hp self.attack attack self.attack_speed attack_speed self.armor armor self.attack_range attack_range # 战斗状态字段 self.next_attack_time 0.0 self.is_alive True self.skill_cd_remaining 0.0 self.charge_ready False def reset(self): 重置战斗状态供多次模拟复用 self.hp self.max_hp self.next_attack_time 0.0 self.is_alive True self.skill_cd_remaining 0.0 self.charge_ready False def take_damage(self, raw_damage: int, damage_reduction: float): 计算实际受到的伤害并扣血 if not self.is_alive: return # 先结算护盾减伤再结算护甲 reduced raw_damage * (1 - damage_reduction) final_damage max(1, int(reduced - self.armor)) self.hp - final_damage if self.hp 0: self.hp 0 self.is_alive False这里有几个细节值得解释reset()方法很重要。批量模拟时如果不重置状态上一次战斗残留的charge_ready或skill_cd_remaining会直接影响下一次结果。take_damage中伤害下限是 1。这是为了避免高护甲单位完全免疫低伤害单位出现“谁都打不死谁”的死循环。护盾减伤先于护甲结算符合多数游戏“伤害减免”优先于“防御值”的直觉模型。3.3 目标选择与技能机制简化实际 RTS 中的目标选择很复杂有人工框选、有优先攻击低血量、有自动攻击最近单位。本文采用最简单的“集火低血量单位”逻辑target min(enemies, keylambda u: u.hp)这个逻辑能模拟出“双方优先击杀残血”的效果。实战中人族机枪兵常被要求“点掉残血追猎”所以低血量优先本身也符合新手比较容易上手的操作思路。技能机制方面我没有把技能封装到类的内部而是把“界域戍卫的护盾”和“遗迹歼击者的蓄力重炮”写在战斗模拟函数里。原因是技能往往涉及团队状态比如护盾会影响全队而类方法处理跨单位状态会比较绕。4. 单次战斗模拟4.1 时间步进模型战斗模拟使用固定时间步长dt 0.1秒。主循环每前进 0.1 秒就检查一次当前哪些单位可以攻击。原理是每个单位持有next_attack_time当全局时间t next_attack_time时单位发动一次攻击然后计算下一次攻击时间为next_attack_time t 1.0 / attack_speed这个模型的好处是攻速不同的单位可以自然错开攻击节奏。比如攻速 1.0 的单位每 1.0 秒攻击一次攻速 1.2 的单位每 0.83 秒攻击一次二者在长时间战斗中的总攻击次数符合期望。4.2 完整战斗函数import random def make_unit_by_template(unit: Unit) - Unit: 根据模板创建一个新的独立单位实例 return Unit( nameunit.name, teamunit.team, hpunit.max_hp, attackunit.attack, attack_speedunit.attack_speed, armorunit.armor, attack_rangeunit.attack_range, ) def simulate_battle(team_a: list, team_b: list, max_time60.0, dt0.1): 执行一场战斗。 参数 team_a: A 方单位模板列表 team_b: B 方单位模板列表 max_time: 最大模拟时间超过则判定平局 dt: 时间步长 返回 winner: 1 表示 A 方获胜2 表示 B 方获胜0 表示平局 battle_time: 战斗结束时间 team_a_hp_ratio: A 方剩余血量比 team_b_hp_ratio: B 方剩余血量比 # 创建独立单位实例避免修改外部模板状态 units_a [make_unit_by_template(u) for u in team_a] units_b [make_unit_by_template(u) for u in team_b] units units_a units_b # 护盾 buff 记录key 为 team_idvalue 为 {reduce: 减伤比例, end_time: 结束时间} buffs { 1: {reduce: 0.0, end_time: 0.0}, 2: {reduce: 0.0, end_time: 0.0}, } t 0.0 while t max_time: t dt # 1. 护盾超时清理 for team_id in buffs: if buffs[team_id][end_time] t: buffs[team_id][reduce] 0.0 # 2. 界域戍卫主动护盾触发判断 for u in units: if not u.is_alive or u.name ! 界域戍卫: continue allies [x for x in units if x.team u.team and x.is_alive] if not allies: continue avg_hp_ratio sum(x.hp / x.max_hp for x in allies) / len(allies) if avg_hp_ratio 0.7 and u.skill_cd_remaining 0: u.skill_cd_remaining 15.0 buffs[u.team][reduce] 0.3 buffs[u.team][end_time] t 4.0 # 3. 所有存活单位尝试攻击 for u in units: if not u.is_alive: continue if t u.next_attack_time: continue enemies [x for x in units if x.team ! u.team and x.is_alive] if not enemies: break # 目标选择优先集火当前血量最低的敌方单位 target min(enemies, keylambda x: x.hp) damage u.attack # 遗迹歼击者蓄力技能 if u.name 遗迹歼击者: if u.charge_ready: damage * 3 u.charge_ready False elif random.random() 0.15: # 进入蓄力状态本次攻击跳过 u.charge_ready True u.next_attack_time t 1.0 / u.attack_speed continue target.take_damage(damage, buffs[u.team][reduce]) u.next_attack_time t 1.0 / u.attack_speed # 4. 技能冷却递减 for u in units: if u.skill_cd_remaining 0: u.skill_cd_remaining - dt # 5. 判定胜负 if not any(x.is_alive for x in units_a): return 2, t, 0.0, sum(x.hp / x.max_hp for x in units_b) / len(units_b) if not any(x.is_alive for x in units_b): return 1, t, sum(x.hp / x.max_hp for x in units_a) / len(units_a), 0.0 # 超时平局 return 0, max_time, 0.0, 0.0代码中的细节是真实战斗中必须考虑的因素护盾触发条件是“友军平均血量低于 70%”。这个设计模拟了界域戍卫作为防守型单位的定位不会在开局满血时就浪费技能。遗迹歼击者的蓄力并不是“概率暴击”而是“先牺牲一次普攻换取下一次三倍伤害”。这个机制和很多 RTS 里的充能重炮类似。每次模拟都会重新创建单位实例避免状态污染。4.3 定义对战双方并运行单次战斗def create_immortal_tyrant(): return Unit( name不朽暴君, team1, hp320, attack28, attack_speed1.0, armor2, attack_range4, ) def create_realm_guard(): return Unit( name界域戍卫, team2, hp220, attack16, attack_speed0.8, armor3, attack_range5, ) def create_ruin_striker(): return Unit( name遗迹歼击者, team2, hp160, attack24, attack_speed1.2, armor0, attack_range6, ) if __name__ __main__: team_a [create_immortal_tyrant()] team_b [create_realm_guard(), create_ruin_striker()] winner, battle_time, hp_ratio_a, hp_ratio_b simulate_battle(team_a, team_b) print( 单次战斗结果 ) print(f战斗时长: {battle_time:.2f} 秒) if winner 1: print(获胜方: A 方不朽暴君) print(f剩余血量比例: {hp_ratio_a * 100:.1f}%) elif winner 2: print(获胜方: B 方界域戍卫 遗迹歼击者) print(f剩余血量比例: {hp_ratio_b * 100:.1f}%) else: print(结果: 平局)由于遗迹歼击者的蓄力是随机事件单次战斗结果每次都可能不同。你可能看到不朽暴君轻松获胜也可能看到遗迹歼击者连续打出高倍伤害直接带走不朽暴君。这正是单次模拟的局限随机事件会掩盖真实的数值强弱。所以我们才需要做批量模拟。5. 批量模拟与统计分析5.1 蒙特卡洛模拟的意义单次战斗的偶然性太强。遗迹歼击者如果连续触发 3 次蓄力重炮完全能改写战局。为了评估双方的真实强度我们需要多次模拟来平滑随机波动。这就是蒙特卡洛模拟同一组参数反复跑 N 次统计胜率、平均时长、平均剩余血量。只要模拟次数够多结果会趋近于“不考虑操作时的理论胜率”。5.2 批量模拟代码def run_batch_simulation(team_a, team_b, rounds10000): results {1: 0, 2: 0, 0: 0} battle_times [] win_hp_ratios [] for _ in range(rounds): winner, battle_time, hp_ratio_a, hp_ratio_b simulate_battle(team_a, team_b) results[winner] 1 battle_times.append(battle_time) if winner 1: win_hp_ratios.append(hp_ratio_a) elif winner 2: win_hp_ratios.append(hp_ratio_b) total sum(results.values()) win_rate_a results[1] / total win_rate_b results[2] / total draw_rate results[0] / total print( 批量模拟统计结果 ) print(f模拟总场次: {total}) print(fA 方不朽暴君胜率: {win_rate_a * 100:.2f}%) print(fB 方界域戍卫 遗迹歼击者胜率: {win_rate_b * 100:.2f}%) print(f平局率: {draw_rate * 100:.2f}%) print(f平均战斗时长: {statistics.mean(battle_times):.2f} 秒) if win_hp_ratios: print(f获胜方平均剩余血量比: {statistics.mean(win_hp_ratios) * 100:.2f}%)在主程序中调用if __name__ __main__: team_a [create_immortal_tyrant()] team_b [create_realm_guard(), create_ruin_striker()] run_batch_simulation(team_a, team_b, rounds10000)5.3 结果解读在随机种子不确定的情况下输出大概是这样的趋势 批量模拟统计结果 模拟总场次: 10000 A 方不朽暴君胜率: 52.34% B 方界域戍卫 遗迹歼击者胜率: 47.66% 平局率: 0.00% 平均战斗时长: 8.73 秒 获胜方平均剩余血量比: 31.25%如果出现这样的结果说明在自动战斗、无操作的模型下1 个不朽暴君和一个双人科技组合基本五五开。这意味着新人 replay 里如果输了大概率是操作和决策问题而不是数值上一碰就碎。你可以调整attack、hp、attack_speed等数值再跑一次观察胜率变化。这个实验过程本身就是数值敏感度分析。6. 对局复盘的进阶思路6.1 从胜率到决策模拟告诉你胜率约 50%那临场决策就变得很重要。比如你是使用不朽暴君的一方发现对面前期已经攒出“界域戍卫 遗迹歼击者”这个组合而你的队友还没到你就要考虑是拉扯消耗还是等队友支援再开团。模拟结果的价值不是“能不能打”而是“如果不操作能不能打”。操作能创造多少优势取决于你单位和技能机制的上限。6.2 经济与产量的简单建模真实的 RTS 对局里兵力不是凭空出现的。如果你想进一步模拟“经济运营对战斗结果的影响”可以在模型里加入一个“每秒产能”参数。例如def calculate_reinforcement(income_per_second, current_time, unit_cost): extra_units int(income_per_second * current_time / unit_cost) return extra_units这样你就能模拟“10 秒后双方各能补多少兵”。新人复盘时很容易忽略这一点有时候一波团输掉不是因为你操作差而是因为你前来支援的那一队兵还没生产出来。6.3 把 replay 数据带回模拟具体操作路径如下打开 replay找到关键团战时间点。记录当时双方存活单位、血量和技能冷却状态。把这些状态改写成Unit参数。运行模拟得出该状态下的理论胜率。对比你在录像里的实际结果。如果模拟结果显示理论胜率超过 70%但你输了那说明操作端有提升空间。这比单纯说“我送了一波”更有说服力也更容易找到问题点。7. 常见问题与排查问题现象常见原因解决思路批量模拟结果几乎固定不变使用了同一个随机数种子或未每次创建新单位实例检查单位实例是否被重置确认random执行正常遗迹歼击者从不触发蓄力概率设置过低或攻击频次太少把 15% 调高到 30% 试跑观察结果是否变化战斗一直拖到超时平局双方伤害都被护盾和护甲抵消太多检查伤害下限是否为 1适当提高攻击力界域戍卫的护盾没有生效触发条件是“平均血量比低于 70%”开局满血不会触发调整触发阈值为 0.9验证逻辑路径模拟结果与实战差距很大模型没有考虑移动、拉扯、手动集火、技能打断记录这些因素逐项补充到模型中8. 最佳实践与工程建议8.1 模型要尽量简单做对战模拟时不要一开始就追求百分百还原游戏。控制效果、弹道速度、地形高低差这些因素会成倍增加代码复杂度但对结论影响可能只有几个百分点。建议先建立最小可用模型验证胜率是否符合直觉再逐步加入移动速度和技能打断逻辑。8.2 单位参数写在配置里现在我把单位参数写死在create_xxx()函数里适合演示。但如果你打算长期使用建议改成 JSON 或 YAML 配置。这样修改数值时不需要动代码也能更清晰地保存多套测试参数。示例配置结构[ { name: 不朽暴君, team: 1, hp: 320, attack: 28, attack_speed: 1.0, armor: 2, attack_range: 4 } ]使用 Python 的json.load()读取后按Unit(**params)创建实例即可。8.3 用固定随机种子保证可复现如果你需要和同事讨论结果最好在批量模拟前设置随机种子random.seed(42)这样每个人跑出来的结果完全一致方便对比数据。但要注意固定随机种子后单次战斗结果也是确定的批量模拟的意义依然存在。8.4 记录日志而不是只打印当交战单位数量变多时打印每一帧的攻击日志会非常刷屏。建议把战斗日志写入文件例如按战斗结束时间组织if t % 1.0 dt: print(f[{t:.1f}s] {unit.name} 攻击 {target.name}造成 {damage} 点伤害)日志是调试技能触发和集火逻辑最好的工具。8.5 不要忽视异常单位代码中的max(1, int(reduced - armor))保证了伤害不为零但这也意味着一个攻击力只有 5、护甲 10 的单位依然能造成 1 点伤害。对于正经的数值平衡项目你需要单独考虑“护甲穿透”或“伤害百分比”机制否则会出现低攻单位磨死高防单位的怪异结果。9. 结语与后续学习方向本文用一个简单的 Python 战斗模拟把“不朽暴君 vs 界域戍卫 遗迹歼击者”这组对战做成了可复现、可批量验证的数值模型。核心思路可以概括为把 replay 里的战场状态抽象成单位参数用时间步进模拟攻击和技能再用蒙特卡洛统计消除随机性最后把模拟结论倒推回实际决策。下一步你可以做三件事把单位技能换成自己游戏里的真实机制比如添加范围伤害、弹道延迟、控制减免。把一个人对战模型扩展成多方混战加入仇恨机制。把模拟结果接入简单的 Web 界面输入双方兵力构成自动返回胜率。如果你有正在练习的 RTS 对局不妨把自己那波团战的数据整理出来照着本文代码跑一遍。你可能会发现有些团战从一开始就注定打不过有些团战则完全是被自己的操作细节送掉的。能区分这两种情况新人复盘的第一步就算真正到位了。
返回列表