
简介面向Java游戏开发入门者与计算机专业学生的回合制游戏源码包围绕轮流行动机制拆解战斗流程、角色建模与面向对象设计。内容以管理器类统筹回合调度、状态更新和输入处理士兵类承载生命值、攻击力、防御力及攻防移动等行为Boss类在此基础上扩展高血量、特殊技能与阶段变化并涉及工厂、策略、状态等设计模式的落地思路同时可参考Git、IDE、Maven/Gradle与JUnit等工具链组织代码和验证逻辑。压缩包为rar格式体积约1KB文件总数记录为0类型明细暂未提供下载前可留意包体完整性。已有1016人学习适合通过小体量示例理解回合制游戏的核心骨架、角色继承关系与Boss AI扩展方式并为后续补充完整工程、练习单元测试和梳理类职责划分提供切入点。1. 回合制游戏源码不是那段 while 循环先看清这套包里真正值钱的四层结构很多人拿到回合制游戏源码第一反应是改数值、换皮、加两个技能结果战斗能跑一加 Buff 就崩一断线重连就错位。这套包真正有价值的不是主循环那几十行而是把行动队列、效果结算、配置导表、回放校验拆成了四层可替换模块源码和配套工具都能单独拎出来用。它适合想快速搭出可维护战斗框架的独立开发者也适合接手老项目、需要把「玄学战斗」拆开看的人。2. 战斗状态机怎么落地从行动队列到结算的四个关键类回合制战斗看起来简单写起来很容易变成一个巨大的while谁先动、放什么技能、伤害多少、什么时候播动画全塞在一个函数里。项目小的时候没事一旦加入追击、反击、延迟伤害、Buff 到期结算这个循环就会变成一团解不开的线。这套源码的处理方式是把「战斗」拆成一个可暂停的状态机加一条按行动值排序的队列再配一个只产出事件、不碰表现的效果结算器。下面按「为什么这么设计 → 类怎么分 → 一次普攻怎么走完 → 参数怎么调」四步拆。2.1 为什么回合制要用「事件驱动 队列」而不是 while 循环while循环的问题不在于性能而在于它把三件本该分开的事绑死了逻辑推进、玩家输入、动画播放。回合制战斗天然要等——等玩家选技能、等动画播完、等服务器确认。用循环写就只能靠回调或者协程硬掰掰到最后没人说得清一次伤害到底在哪一行算的。事件驱动的做法是反过来的结算阶段只做纯计算把「谁对谁造成了多少伤害、触发了什么效果」写成一条条事件塞进一个事件列表表现层再从这个列表里取事件决定播什么动画、弹什么字。这样做有三个直接好处。第一服务器可以只跑逻辑、不跑动画同一份代码在小霸王服务器上也能算出结果。第二断线重连只需要把事件列表重放一遍不需要保存每一帧的动画状态。第三回放和战报是同一套数据不用另写一套日志。队列解决的是「谁先动」的问题。最土的办法是每回合按速度排序一次但这样处理不了「行动值」类的机制——速度快的单位在两回合之间多动一次、被减速后行动条回退、某些技能强行插队。用行动值队列每个单位持有一个action_value每次行动后按BASE / speed重新累加队列按值从小到大取插队只需要临时改一下这个值或者给一个更高的优先级。这套模型能覆盖绝大多数回合制变体改起来也只动一个数字。注意事件列表必须是「只追加、不修改」的。一旦允许结算过程中回头改已经产出的事件回放就一定对不上而且这种 bug 极难复现。2.2 核心类的职责划分与最小可运行骨架这套源码把战斗拆成六个角色每个角色只干一件事边界清楚到可以直接替换实现。模块职责明确不该做的事BattleContext持有单位表、队列、随机源、事件列表、当前阶段不写具体伤害公式Unit单位属性、Buff 容器、技能列表、当前行动值不直接改别的单位ActionQueue按行动值排序、取出、重新入队、插队不判断技能是否合法EffectResolver把技能配置翻译成一系列效果并结算不碰动画和 UIEventBus收集结算事件供表现层和回放消费不做业务判断RandomService带种子的确定性随机按固定顺序消费不在表现层被调用这里最容易写歪的是Unit。很多人习惯让单位自己算伤害、自己找目标结果单位之间互相引用测试一个技能要造一整个战场。正确的做法是单位只暴露属性和 Buff 查询接口所有「打谁、打多少」都由EffectResolver拿着上下文来算。这样写单测时造两个假单位、一个假随机源就能跑不需要启动整个战斗。RandomService单独拎出来是血泪经验。随机数一旦散落在各个类里回放就会因为调用顺序不同而漂移。统一到一个带种子的随机源所有命中、暴击、Buff 概率都从它取顺序固定回放才能一比一还原。骨架大概长这样核心是tick只推进一个阶段不包办所有事# battle/engine.py from dataclasses import dataclass, field from enum import Enum class Phase(Enum): WAIT_INPUT 1 # 等玩家指令或 AI 决策 RESOLVING 2 # 纯逻辑结算禁止碰表现 ANIMATING 3 # 客户端按事件列表播表现 FINISHED 4 dataclass class BattleContext: units: dict # unit_id - Unit queue: ActionQueue rng: RandomService events: list field(default_factorylist) phase: Phase Phase.WAIT_INPUT round_no: int 0 # 大回合计数仅用于展示和部分 Buff def tick(ctx: BattleContext): 每次调用只推进一步调用方决定节奏。 if ctx.phase is not Phase.RESOLVING: return actor ctx.queue.pop() if actor is None: ctx.phase Phase.FINISHED return action actor.take_action(ctx) # 玩家指令或 AI 决策 for event in action.execute(ctx): # 结算器只产出事件 ctx.events.append(event) ctx.queue.push_back(actor) # 按行动值重新入队 ctx.phase Phase.ANIMATING # 交给表现层消费事件这段代码的关键点不在语法而在三个约束。tick一次只处理一个行动者调用方可以是服务器定时器也可以是客户端协程节奏完全解耦。action.execute返回事件而不是直接改状态保证了结算过程可记录、可重放。phase显式区分结算和表现避免有人在结算阶段去读动画进度。参数上round_no只在所有单位都行动过一次后加一不要用「队列走一圈」来算否则插队会打乱回合计数。2.3 把一次普攻拆成七个结算阶段一次普通攻击看着就是「扣血」实际要经过前置校验、目标选取、命中判定、暴击判定、伤害计算、效果附加、死亡结算七步。每一步都可以被 Buff 修改所以必须拆开写不能一行公式糊过去。下面这段是简化后的实现保留了阶段划分和事件产出# battle/effect.py def resolve_attack(ctx, caster, target, skill): events [] # 1. 前置校验沉默、眩晕、目标已死亡都直接返回 if not caster.can_act() or target.is_dead(): return events # 2. 目标选取单体技能直接锁目标群体技能在调用方展开 if skill.target_type single and target is None: return events # 3. 命中判定命中率减去目标闪避结果裁剪到 [0.05, 0.95] hit_rate clamp(caster.hit - target.dodge, 0.05, 0.95) if ctx.rng.next_float() hit_rate: events.append(Event(miss, castercaster.id, targettarget.id)) return events # 4. 暴击判定暴击率同样要裁剪避免出现必暴或永不暴 crit ctx.rng.next_float() clamp(caster.crit, 0.0, 1.0) # 5. 伤害计算走独立函数方便单测和数值调整 damage calc_damage(caster, target, skill, crit) # 6. 效果附加技能携带的 Buff、持续伤害、驱散都在这里挂 for effect in skill.effects: effect.apply(ctx, caster, target, events) # 7. 死亡结算统一在这里判死避免每个效果各写一遍 target.hp - damage events.append(Event(damage, castercaster.id, targettarget.id, valuedamage, critcrit)) if target.hp 0: target.hp 0 events.append(Event(death, targettarget.id)) return events七个阶段的顺序不能随便换。命中在暴击之前是因为「未命中就不该消耗暴击随机数」——这个细节直接决定回放能不能对上。效果附加放在伤害之后是为了让「受到伤害时触发」的 Buff 能读到正确的血量。死亡结算统一放在最后避免一个 AOE 里第一个目标死了、后面的效果还在给尸体加 Buff。参数上有三个地方最容易翻车。hit_rate的下限不能设成 0否则高闪避单位会变成无敌上限不能设成 1否则命中属性堆满后战斗失去悬念。暴击率同理裁剪区间建议[0, 1]但在配置层限制投放。calc_damage里所有中间结果用整数或者定点数浮点数在客户端和服务器上可能因为编译优化产生末位差异回放对不上时第一个要怀疑的就是这里。2.4 行动值、速度与插队规则怎么定行动值模型的核心公式只有一个action_value BASE / speed。BASE取 10000 是个经验值能让速度 80 到 200 的单位都有足够的区分度又不至于让行动值小数位太多。每次单位行动完把它的action_value重新设为BASE / speed队列按值升序取最小值。速度 100 的单位每 100 点行动值动一次速度 200 的单位每 50 点动一次两回合里多动一次符合直觉。插队规则建议只用两种优先级插队和行动值插队。优先级插队给「必定先手」这类技能用实现上是在队列里临时插入一个priority1的节点取的时候先比优先级再比行动值。行动值插队给「推条」「拉条」用直接改目标或自己的action_value改完重新排序。不要引入第三种插队方式否则行动顺序会变成没人能预测的黑匣子。还有一个容易被忽略的参数同值排序。两个单位行动值完全相同时必须有一个稳定的兜底规则常见做法是先比速度再比队伍位次最后用战斗种子派生一个固定随机数。顺序一旦确定就不能改改了之后所有历史回放都会错位。3. 数值与技能配置Excel 表怎么变成运行时可用的数据战斗逻辑写完之后真正拖慢迭代的是配置。策划改一个技能倍率程序要重新导表、重启客户端、重新打一场一天下来改不了几个数。这套源码配了一套导表工具把 Excel 拆成技能、效果、Buff 三张表导出成 JSON 加二进制两份运行时热重载。下面讲表结构怎么设计、导表脚本怎么写、热重载怎么保证不串场。3.1 技能、效果、Buff 三张表的关系很多人把技能和效果写在一张表里结果一个技能带三段伤害就要加三列加第四段就得改代码。正确的拆法是三层技能表描述「谁、在什么条件下、放什么」效果表描述「具体做什么」Buff 表描述「持续多久、叠几层、什么时候触发」。技能表的关键字段字段类型说明skill_idint主键全局唯一不要复用target_typeenumsingle / all_enemy / all_ally / selfeffect_idsint[]按顺序执行的效果 ID 列表costint消耗的行动值或资源cooldownint冷却回合数0 表示无冷却priorityint插队优先级默认 0效果表的核心是effect_type常见取值有damage、heal、add_buff、remove_buff、dispel、summon。每种类型对应一组参数字段用 JSON 存额外参数比开一堆空列更好维护。Buff 表则要写清max_stack、stack_policy叠加、刷新、独立、duration、trigger_timing行动前、行动后、受伤时、死亡时。三张表的关系是技能引用效果效果可以引用 BuffBuff 不反向引用技能。这条单向依赖很重要它保证了改 Buff 不会影响技能表的解析也保证了导表时可以用拓扑顺序校验外键。3.2 导表脚本从 xlsx 到 json 的转换与校验导表工具最怕的是「导出来了但导错了」。所以脚本不能只做格式转换必须带校验必填字段为空直接报错、外键找不到直接报错、枚举值不在白名单直接报错、数值超出策划约定的区间给警告。下面是一个最小可用的版本# tools/export_config.py import json import openpyxl REQUIRED { skill: [skill_id, target_type, effect_ids], effect: [effect_id, effect_type], buff: [buff_id, duration, max_stack], } ENUMS { target_type: {single, all_enemy, all_ally, self}, effect_type: {damage, heal, add_buff, remove_buff}, } def load_sheet(path, sheet_name): wb openpyxl.load_workbook(path, data_onlyTrue) ws wb[sheet_name] rows list(ws.iter_rows(values_onlyTrue)) header rows[0] return [dict(zip(header, row)) for row in rows[1:] if row[0] is not None] def validate(table_name, rows, id_set): errors [] for i, row in enumerate(rows, start2): for field in REQUIRED[table_name]: if row.get(field) in (None, ): errors.append(f{table_name} 第 {i} 行缺少 {field}) for field, allowed in ENUMS.items(): if field in row and row[field] not in allowed: errors.append(f{table_name} 第 {i} 行 {field}{row[field]} 非法) if table_name skill: for eid in row.get(effect_ids, []): if eid not in id_set[effect]: errors.append(f技能 {row[skill_id]} 引用不存在的效果 {eid}) return errors def export(xlsx_path, out_dir): skills load_sheet(xlsx_path, skill) effects load_sheet(xlsx_path, effect) buffs load_sheet(xlsx_path, buff) id_set { effect: {r[effect_id] for r in effects}, buff: {r[buff_id] for r in buffs}, } errors [] for name, rows in ((skill, skills), (effect, effects), (buff, buffs)): errors validate(name, rows, id_set) if errors: raise SystemExit(\n.join(errors)) # 有错就不出包避免脏数据进运行时 for name, rows in ((skill, skills), (effect, effects), (buff, buffs)): with open(f{out_dir}/{name}.json, w, encodingutf-8) as f: json.dump(rows, f, ensure_asciiFalse, indent2)这段脚本的校验逻辑比转换逻辑更重要。REQUIRED保证必填字段不为空空值进到运行时往往表现为「技能放出来没反应」排查成本极高。ENUMS把拼写错误挡在导表阶段all_enemies和all_enemy这种差异靠人眼是看不出来的。外键校验放在最后统一做是因为技能表可能先于效果表加载边加载边校验会误报。参数上data_onlyTrue必须加否则读到的是公式而不是计算值。id_set要在三张表都加载完之后再构建避免顺序依赖。导出格式选 JSON 是为了可读和便于 diff真到包体敏感的阶段可以再加一份二进制但 JSON 要保留出问题时靠它定位。3.3 热重载双缓冲、版本号和失败回滚热重载最容易出的问题是「改到一半读到半份配置」。这套源码的做法是双缓冲加版本号新配置先加载到一个独立的字典里全部校验通过后原子替换当前引用同时把版本号加一。正在进行的战斗持有旧版本引用新战斗拿新版本互不干扰。具体实现上ConfigRepository对外只暴露get_skill(skill_id)这样的查询方法内部持有current和pending两个字典。重载时先填pending校验通过后执行current, pending pending, current。引用计数用来决定旧版本什么时候真正释放每场战斗开始时retain()结束时release()计数归零才回收。这样即使重载时正好有战斗在跑也不会出现「技能放到一半配置没了」。失败回滚同样重要。校验不通过时pending直接丢弃current不受影响同时把错误写进日志并推给导表工具。不要让「加载失败」变成「运行时静默使用空配置」后者会表现成所有技能伤害为 0比直接报错难查十倍。提示热重载只适用于配置数据不要用它热更战斗逻辑代码。逻辑和配置的生命周期不一样混在一起会让回放版本彻底失控。3.4 伤害公式的参数化写法伤害公式建议写成「基础值 × 技能倍率 固定值再乘一系列修正」每个修正单独一个函数方便单独测试和单独开关。常见的形式是def calc_damage(caster, target, skill, crit): base caster.atk * skill.ratio skill.flat # 防御减伤用除法而不是减法避免高防单位完全免伤 defense_factor 1000 / (1000 target.def) element_factor ELEMENT_TABLE.get((skill.element, target.element), 1.0) crit_factor 1.5 if crit else 1.0 raw base * defense_factor * element_factor * crit_factor # 统一取整最小伤害保底 1避免出现 0 伤害的观感问题 return max(1, int(raw))defense_factor用除法是这套源码和很多手写项目的关键差别。减法减伤在高防单位身上会直接归零战斗变成「谁防高谁赢」除法减伤是渐进曲线防御再高也不会完全免伤数值上限可控。1000这个常数决定减伤曲线的陡峭程度防御堆到 1000 时减伤 50%堆到 3000 时减伤 75%适合大部分中等数值规模的回合制。ELEMENT_TABLE用字典存克制关系键是「攻击属性、防御属性」的二元组值是倍率。不要用一长串 if-else属性一多就没法维护。crit_factor固定 1.5 是常见做法但如果项目有暴击伤害属性应该改成1.5 caster.crit_damage并且同样做区间裁剪。取整放在最后一步、只取一次是所有数值计算的红线。中间步骤各自取整会导致误差累积客户端和服务器算出来的结果差一点回放就会在某个回合开始漂移。4. 客户端表现与服务器校验同一场战斗怎么两边算得一样单机回合制怎么算都行一旦涉及联网问题就变成「客户端算的和服务器算的不一致怎么办」。这套源码的答案是逻辑只有一份表现是逻辑的投影服务器不相信客户端传来的任何结果只相信指令。下面讲分离方式、确定性随机、回放重连和校验点设计。4.1 逻辑与表现分离事件流是唯一的桥梁战斗逻辑跑完之后产出的是一串事件客户端拿到事件之后做三件事查表、播动画、更新 UI。这个过程中客户端不能回头改逻辑状态也不能自己算伤害。所有数值都从事件里读事件里没有的就是不需要表现的。这样做的好处是客户端可以「演」得比服务器慢。服务器一次性算完整个回合客户端按事件顺序慢慢播玩家看到的是动画服务器看到的是结果。中间如果玩家断线重连时服务器把事件流重新发一遍客户端从头播或者从快照接着播逻辑状态不会丢。实现上事件要带足够的信息不能只带「伤害 100」。至少要带施法者 ID、目标 ID、数值、是否暴击、技能 ID、效果类型。客户端根据这些字段决定播哪个特效、弹什么数字、要不要震屏。事件里不要塞指针或者对象引用必须是可序列化的纯数据否则回放和网络传输都会出问题。4.2 确定性随机种子、消费顺序和禁止跨层调用回放对不上的头号原因是随机数。两个客户端用同样的种子但调用次数不一样结果就完全不同。解决办法有三条所有随机走同一个RandomService随机的消费顺序在逻辑层固定表现层禁止调用随机。随机算法本身用哪个不重要LCG、xorshift、PCG 都可以重要的是可复现。下面是一个最简的 xorshift 实现够用且快# battle/random.py class RandomService: def __init__(self, seed: int): self.state seed 0xFFFFFFFF or 1 # 种子不能为 0 def next_u32(self) - int: x self.state x ^ (x 13) 0xFFFFFFFF x ^ (x 17) x ^ (x 5) 0xFFFFFFFF self.state x 0xFFFFFFFF return self.state def next_float(self) - float: # 除以 2^32结果落在 [0, 1) return self.next_u32() / 4294967296.0 def next_int(self, low: int, high: int) - int: # 闭区间注意 high - low 1 不能为 0 return low self.next_u32() % (high - low 1)state不能为 0 是 xorshift 的硬性要求种子为 0 时整个序列恒为 0表现为「所有随机都失败」。next_float用除法而不是取模是为了避免低位周期性问题。next_int用取模会有极其轻微的分布偏差回合制里可以忽略如果介意可以换成拒绝采样。消费顺序上建议在战斗开始时就把「本场战斗的随机种子」写进战斗记录所有随机都从这个种子派生。命中、暴击、Buff 触发、掉落各用一个独立的子种子互相不影响。这样即使某个版本多消耗了一次暴击随机也不会把后面的掉落全部打乱。4.3 战斗回放与断线重连回放的本质是「种子 指令序列」。战斗开始时记录种子和双方阵容之后每次玩家或 AI 做出决策就把指令追加到列表里。回放时新建一个战斗上下文用同样的种子、同样的阵容按顺序喂指令跑出来的事件流应该和原始战斗完全一致。断线重连比重放简单一点但要求更快。常见做法是服务器每隔几个回合存一个快照快照里包含所有单位的血量、Buff、行动值、随机源状态。玩家重连时服务器发最近一个快照加上之后的指令列表客户端从快照恢复再重放不用从第一回合开始。# battle/replay.py def replay(seed, lineup, commands, snapshotNone): ctx BattleContext.new(seedseed, lineuplineup) if snapshot: ctx.restore(snapshot) # 恢复到最近快照 for cmd in commands: ctx.apply_command(cmd) # 指令驱动不是直接改状态 while ctx.phase is Phase.RESOLVING: tick(ctx) ctx.phase Phase.RESOLVING return ctx.eventscommands里存的必须是「玩家选了什么技能、打谁」这种决策而不是「造成了多少伤害」这种结果。存结果的话一旦公式调整历史回放全部失效存决策的话公式改了回放会算出新结果但这反而方便对比版本差异。snapshot里的随机源状态必须包含否则重放时随机序列会从头开始和原始战斗对不上。4.4 校验点设计哪些数据必须服务器复核服务器不能相信客户端的任何计算结果但也不需要每一步都重算。合理的做法是客户端上报指令服务器用同一份逻辑跑一遍然后比对关键校验点。校验点是否必须说明行动顺序必须客户端改顺序是最常见的作弊方式技能合法性必须冷却、消耗、目标是否存活伤害数值必须允许极小误差超出即判定异常随机结果建议命中、暴击由服务器种子决定Buff 层数必须层数错了后续全错配置版本必须版本不一致直接拒绝战斗比对时不要用浮点数相等用整数或者定点数并且允许配置层面的容差。出现不一致时服务器以自己算的为准同时记录一条异常日志日志里带上种子、指令序列和双方版本号方便事后复现。5. 避坑与排查五个让战斗「看起来能跑」却上线翻车的问题下面这五个问题每一个都曾经让一套看起来跑得好好的战斗在真机上翻车。共同点是本地测试很难复现线上日志又不够用。每条按「现象 → 原因 → 解决」写方便对照排查。5.1 现象同样的阵容两次战斗结果不一样原因随机源没有统一或者随机的消费顺序依赖了字典遍历顺序。Python 3.7 之后字典有序但很多项目从别的语言迁过来习惯性用set或者无序容器遍历每次顺序都可能不同随机数就被消耗到了不同的地方。解决所有涉及随机的循环先把候选目标按unit_id排序再遍历。随机源只允许在逻辑层调用表现层的粒子、音效、飘字抖动一律用不依赖战斗结果的伪随机。排查时在每次随机调用处打一条日志带上调用点和消耗后的状态两次战斗 diff 一下就能定位。5.2 现象Buff 到期后属性没还原原因Buff 加属性时直接改了unit.atk到期时减回去但中间如果发生了「按当前攻击力重新计算」的逻辑减回去的数值就不对了。更隐蔽的情况是 Buff 叠加时加了一次、到期时按层数减了多次。解决属性不要直接改改成「基础值 修正列表」的计算属性。每个 Buff 持有一个修正项读属性时实时求和Buff 移除时把修正项摘掉天然不会算错。这套源码里Unit的atk是个 property内部遍历modifiers求和改起来麻烦一点但省掉了后面所有的还原逻辑。5.3 现象技能连携时伤害少算一次原因效果结算用了「边遍历边修改」的列表。比如技能 A 命中后触发被动 BB 又往效果列表里追加了一个效果而遍历用的是for effect in effects新追加的项不会被处理或者在某些语言里直接抛异常。解决结算用一个显式的效果队列处理完一个就 pop 一个新效果追加到队尾。队列空之前一直循环并且设一个最大迭代次数比如 100防止无限连锁。最大次数这个参数很重要没有它两个互相触发的被动能把服务器跑死。5.4 现象热重载后旧战斗读不到新配置原因热重载直接替换了全局配置字典正在跑的战斗持有的是旧引用但某些查询走的是全局单例于是同一场战斗里前半段用旧配置、后半段用新配置。解决战斗上下文在创建时就把配置仓库的当前版本引用住整场战斗只读这一个版本。重载只影响新创建的战斗。如果确实需要中途生效必须显式地结束当前战斗再重建不要试图在运行中混用两个版本。5.5 现象回放对不上日志里找不到原因原因日志只记了结果没记输入。伤害对不上时只能看到「打了 100」看不到当时的攻击力、防御、Buff 层数、随机值。解决每条伤害事件都把参与计算的中间值带上至少包括攻击、防御、技能倍率、各类修正、随机数。日志按战斗 ID 聚合回放时用同样的种子重跑逐条比对事件。这套源码里事件结构体留了debug字段线上关掉、内网打开排查效率差好几个数量级。6. 用工具把验证成本压下来批量模拟脚本与验收清单战斗逻辑写到能跑只是完成了一半。真正决定上线质量的是「改一个数值能不能在十分钟内知道它有没有把平衡性搞崩」。这套源码附带的批量模拟工具就是干这个的不起客户端、不播动画直接跑逻辑统计胜率和回合数分布。6.1 批量模拟一万场战斗跑出胜率分布模拟脚本的关键是「无表现、可并行、结果可聚合」。下面这个版本跑 N 场相同阵容的对战统计胜率和平均回合数用来快速判断一次数值改动的影响# tools/simulate.py from collections import Counter from battle.engine import BattleContext, tick, Phase def run_one(seed, lineup_a, lineup_b): ctx BattleContext.new(seedseed, lineup_alineup_a, lineup_blineup_b) ctx.phase Phase.RESOLVING steps 0 while ctx.phase is not Phase.FINISHED and steps 500: tick(ctx) steps 1 winner ctx.winner() # 0 平局1 左胜2 右胜 return winner, ctx.round_no def simulate(n10000, lineup_aNone, lineup_bNone): counter Counter() rounds [] for seed in range(1, n 1): winner, r run_one(seed, lineup_a, lineup_b) counter[winner] 1 rounds.append(r) total sum(counter.values()) print(f左胜率 {counter[1] / total:.2%}右胜率 {counter[2] / total:.2%} f平局 {counter[0] / total:.2%}) print(f平均回合数 {sum(rounds) / len(rounds):.1f} f最长 {max(rounds)}最短 {min(rounds)}) if __name__ __main__: simulate(n10000)steps 500是死循环保护没有它两个高回复单位能把脚本跑到天荒地老。winner的判定要放在ctx里不要在脚本里临时判断否则模拟结果和线上不一致。种子从 1 递增而不是随机是为了保证每次跑出来的结果可复现改完数值前后对比才有意义。跑完之后看三个数胜率偏离 50% 太多说明阵容或数值有明显倾斜平均回合数突然变长说明出现了拖回合的组合最长回合数逼近上限说明有无限循环的风险。这三个信号比单看一场战斗靠谱得多。6.2 上线前的验收清单检查项通过标准同种子回放事件流逐条一致无偏差断线重连从快照恢复后结果与原战斗一致配置版本客户端与服务器版本号一致才允许开战Buff 叠加叠满、刷新、独立三种策略各测一遍死亡结算AOE 溢出伤害不作用于已死目标随机消费命中失败时不消耗暴击随机数清单里最容易被跳过的是「同种子回放」。本地跑十场都一致不代表没问题要跑够一千场并且覆盖连携、反击、复活这些边缘路径。这套源码的回放工具支持从日志文件直接重跑改完逻辑先跑一遍历史战斗比重新造场景快得多。我现在改任何一行结算代码都会先把历史战斗日志重放一遍再跑一万场模拟看胜率曲线有没有跳变。刚开始觉得这一步多余直到有一次改了个「小优化」把暴击随机的消费顺序挪了一位线上回放全部对不上查了整整两天。从那以后回放和模拟这两步就再也没省过。希望帮到你。本文还有配套的精品资源点击获取