
简介这份回合制游戏源码资源面向Java初学者与面向对象设计练习者围绕轮流行动的战斗机制给出可运行的实现范式帮助读者理解如何用类与对象组织游戏流程与角色行为适合作为课程设计、小型练手项目或设计模式学习的参考素材。压缩包为rar格式资源包约1KB上游未提供文件清单与类型明细从内容预览可知核心为Manager、Soldier、Boss三个Java类Manager承担回合调度与状态更新Soldier封装士兵属性与攻防技能Boss在此基础上扩展高血量与阶段化AI可据此梳理工厂、策略、状态等模式的落地写法。该资源已有1016人浏览学习具备一定参考热度。读者可获得一套可直接编译调试的战斗骨架代码对照类间调用关系理解回合流转、技能触发与继承多态的运用并借鉴封装与职责划分思路为后续扩展关卡、技能树或AI行为提供起点。1. 改一个技能要动三处这份回合制游戏源码把战斗逻辑抽出来了接手一个回合制游戏项目最烦人的往往不是战斗表现而是改数值。我见过一份源码一个「烈焰斩」的伤害系数写死在三个地方技能表、战斗结算函数、UI 伤害预览改一次要同步三处漏一处就出现面板数字和实际伤害对不上。这份回合制游戏源码把这类问题拆开了战斗内核、数据配置、渲染表现三层分离另外配了几个小工具——配置校验、批量模拟、战斗回放用来在改数值之前先跑一遍。它能完整跑起一场 3v3 对战速度行动条、技能效果链、Buff 增减益、暴击命中判定、可回放的战斗日志全部落成可读的 Python 模块。适合两类人刚拿到回合制题材、想把战斗骨架先搭起来的新手照着配置表加角色就能出 demo想把战斗逻辑从渲染层剥出来、做服务端校验或批量跑平衡性的熟手core/目录不依赖任何图形库直接拿去 headless 跑。2. 目录与分层战斗内核、配置表和渲染层怎么各管各的一份能二次开发的回合制源码判断它好不好用先看目录。分层混乱的项目改动会像扯线头你想调一个技能倍率结果牵出 UI 刷新、资源加载、存档格式三处连带修改。这个项目用一条硬规矩隔开core/里禁止 importui/和pygame战斗内核必须是纯 Python能在没有显示器的机器上跑完一整场。2.1 目录结构core 不依赖 ui 是硬规矩拿到源码先别急着点main.py花五分钟把目录过一遍后面找代码会快很多。turnbased-demo/ ├── main.py # 表现层入口只负责开窗口、收输入、调 core ├── requirements.txt ├── config/ │ ├── units.json # 单位属性、初始技能、队伍归属 │ ├── skills.json # 技能消耗、冷却、效果链 │ ├── buffs.json # Buff持续时间、属性修正、触发时机 │ └── battle.json # 一场战斗的编排双方阵容、回合上限、随机种子 ├── core/ │ ├── entity.py # Stats / Unit纯数据 │ ├── skill.py # Skill / Effect 定义 │ ├── buff.py # Buff 实例与生命周期 │ ├── formula.py # 伤害、命中、暴击公式 │ ├── scheduler.py # 行动条谁先出手 │ ├── battle.py # 战斗状态机串起上面所有模块 │ ├── loader.py # JSON - 对象含校验 │ └── log.py # 事件日志支持回放 ├── ui/ │ ├── scene.py # 场景绘制 │ ├── hud.py # 血条、行动条、技能按钮 │ └── assets/ # 字体、贴图 ├── tools/ │ ├── sim.py # 批量模拟跑胜率 │ └── validate_config.py # 配置表引用校验 └── tests/ ├── test_scheduler.py ├── test_formula.py └── test_battle_flow.py几个目录的职责边界值得说清楚。core/是战斗的全部规则改动频率中等但每次改动影响面最大config/是策划改数值的地方改动频率最高理想状态下只改这里就能出新角色ui/只做三件事——把 core 的状态画出来、把玩家的点击翻译成指令、播放动画不参与任何数值计算tools/是防止你改崩数值的保护机制别当成可有可无的附加物。loader.py单独拎出来是有原因的。JSON 是弱类型的写错一个buff_id: brun编辑器不会报错要等战斗跑到一半才炸。把加载和校验收在一个文件里validate_config.py直接复用同一套逻辑命令行跑一次就能把全部引用错误列出来。2.2 战斗状态机为什么把一回合拆成六个阶段新手写回合制最爱写成一个 300 行的battle_loop()里面嵌套四层 if判断谁出手、判断技能够不够蓝、判断命中、判断死没死。跑到后面想插一个「回合开始时结算中毒」发现找不到插入点只能再加一层 if。这个项目把一回合拆成显式阶段每个阶段一个函数# core/battle.py from enum import Enum, auto class Phase(Enum): INIT auto() # 加载阵容初始化行动条 TURN_START auto() # 该单位回合开始结算中毒/再生/冷却递减 WAIT_COMMAND auto() # 等我方玩家输入指令 RESOLVE auto() # 执行指令逐个效果结算 TURN_END auto() # 回合结束Buff 持续时间递减清理死亡单位 CHECK_END auto() # 判定胜负 FINISHED auto() # 战斗结束冻结状态推进逻辑写成step()一次只走一步# core/battle.py片段 def step(self, commandNone): 推进一步。玩家回合需要外部传入 command其余阶段自动流转。 if self.phase is Phase.INIT: self._init() elif self.phase is Phase.TURN_START: self._turn_start() elif self.phase is Phase.WAIT_COMMAND: if command is None: return # 没输入就原地等不推进也不死循环 self._resolve(command) elif self.phase is Phase.TURN_END: self._turn_end() elif self.phase is Phase.CHECK_END: self._check_end()command参数是关键设计。表现层的主循环长这样while not battle.finished: battle.step(ui.get_command_if_any())玩家不点技能step()立刻返回UI 照常刷新帧不会卡住。同一份step()headless 脚本传一个 AI 生成的指令进去就变成全自动对战——第 4 章那个模拟工具靠的就是这一点。把阶段拆开还有个隐性好处动画。你想让「技能前摇 0.3 秒、结算、受击后仰 0.2 秒」按顺序播只需要在RESOLVE前后挂事件钩子不用去改战斗逻辑本身。2.3 配置表驱动单位、技能、Buff 三张表怎么关联三张表靠 id 互相引用units.json引技能 idskills.json引 buff idbuffs.json不引任何东西是最底层的原子。// config/skills.json { fire_slash: { name: 烈焰斩, cost: 20, cooldown: 2, effects: [ {type: damage, power: 140, target: enemy_single, element: fire}, {type: apply_buff, buff_id: burn, target: enemy_single, chance: 0.6} ] } }一个技能带多个效果按数组顺序结算。power是百分比系数140 表示「攻击力的 1.4 倍」这个约定要在全项目统一别有的技能写 1.4 有的写 140那是给自己埋雷。// config/buffs.json { burn: { name: 灼烧, duration: 3, max_stack: 3, tick: on_turn_start, modifiers: {}, dot: {type: percent_max_hp, value: 0.05} } }duration的语义必须写死并写进注释是「持有者行动 3 次」还是「全场回合 3 轮」两种做法在多人战斗里结果完全不同。这个项目选的是前者——按持有者的行动次数递减实现简单玩家也容易理解。加载层的核心逻辑就三行剩下的都是校验# core/loader.py片段 def load_units(raw, skills, buffs): units [] for item in raw: stats Stats(**item[stats]) # 深拷贝避免多个单位共享同一个 Stats unit Unit(uiditem[id], nameitem[name], teamitem[team], statsstats) unit.skills [skills[sid] for sid in item[skills]] units.append(unit) return unitsStats(**item[stats])每次调用都新建实例这一点不能省。直接unit.stats template_stats会让场上两个同职业单位共享同一份血量一个掉血另一个跟着掉——这是第 5 章要重点说的问题。3. 核心链路拆解从行动条排序到伤害结算的完整一回合前面讲的是骨架这一章走一遍完整流程一个单位从「轮到它」到「伤害数字飘出来」中间经过了哪些函数、每个函数的参数是什么、改哪里会有什么后果。3.1 行动条调度器先手是算出来的不是排出来的回合制里有两种排序模型。一种是「按速度排个序然后轮着来」简单但速度这个属性只影响初始顺序后期加速度的装备毫无意义。另一种是行动值模型速度决定「攒满行动条需要多久」速度翻倍就是出手次数翻倍属性成长曲线才对得上。# core/scheduler.py from dataclasses import dataclass, field BASE_AV 10000 # 行动条总长度数值越大整体节奏越慢 dataclass class Scheduler: units: list av: dict field(default_factorydict) # uid - 还需要多少行动值 def __post_init__(self): for u in self.units: self.av[u.uid] self._full_av(u) def _full_av(self, unit) - float: # 速度越高攒满所需行动值越少速度 0 时兜底避免除零 spd max(unit.stats.spd, 1) return BASE_AV / spd def next_actor(self): # 取行动值最小的单位同值时按 uid 稳定排序保证同种子下回放一致 uid min(self.av, keylambda k: (self.av[k], k)) elapsed self.av[uid] for k in self.av: # 全体一起向前推进 elapsed 个单位 self.av[k] - elapsed self.av[uid] self._full_av(self._get(uid)) # 出手后自己归零重攒 return self._get(uid)核心就是elapsed那一扣。速度 20 的单位攒满需要 500速度 10 的需要 1000前者出手两次后者才出手一次节奏是算出来的。min里的(self.av[k], k)这个二元组容易被忽略。如果只写keylambda k: self.av[k]两个单位行动值相同时min取谁取决于 dict 的迭代顺序而 dict 顺序又受插入顺序影响。同一份配置换个加载顺序战斗结果就不一样回放工具直接失效。加上 uid 做次级排序这个问题就没了。还有一个设计取舍要说清楚Buff 改了速度后av不会立刻重算要等这个单位下次出手才按新速度重攒。好处是实现简单、行动条不会因为一个加速 Buff 突然乱跳坏处是「加速 Buff 生效当回合吃不到收益」。如果你的玩法是「抢先手」就得在 Buff 生效时补一次self.av[uid] self._full_av(unit) * 剩余比例这个改动会影响平衡性动手前先跑一遍模拟看看胜率偏移多少。3.2 效果链一个技能为什么能打三个目标技能和效果是分开的两层。技能是容器负责消耗、冷却、动画 id效果是原子负责具体做什么。这样「群体火球 给自己加护盾」这种复合技能不用写新代码配置里加两个 effect 就行。# core/skill.py from dataclasses import dataclass, field dataclass class Effect: type: str # damage / heal / apply_buff / remove_buff power: float 100.0 # 百分比系数140 攻击力的 1.4 倍 target: str enemy_single # enemy_single / enemy_all / ally_single / ally_all / self buff_id: str | None None chance: float 1.0 # 触发概率0~1 element: str physical dataclass class Skill: sid: str name: str cost: int 0 cooldown: int 0 effects: list field(default_factorylist)结算走一个 dispatch 字典比 if-elif 链好扩展想加「吸血」类型注册一个函数就行不用动_resolve。# core/battle.py片段 def _resolve(self, command): actor command.actor skill self.skills[command.skill_id] if skill.cost actor.stats.mp: self.log.emit(action_failed, uidactor.uid, reasonnot_enough_mp) self.phase Phase.TURN_END return actor.stats.mp - skill.cost actor.cooldowns[skill.sid] skill.cooldown for eff in skill.effects: # 每个效果独立选目标才能支持打全体 给自己回血这类复合技能 targets self._pick_targets(eff.target, actor, command) for t in targets: if not t.alive: continue if eff.chance 1.0 and self.rng.random() eff.chance: self.log.emit(effect_resisted, uidt.uid, effecteff.type) continue self._apply_effect(eff, actor, t) self.phase Phase.TURN_END_pick_targets里有个必须处理的边界enemy_all在只剩一个敌人时返回列表长度 1enemy_single在目标已死时返回空列表这两种情况都会让for循环空转。空转本身不报错但日志里会少一条事件回放时看起来像「技能放了个寂寞」。所以command里存的是出手瞬间选定的 uid 列表目标死了就跳过并在日志里补一条target_dead。冷却和消耗都记在actor身上不放全局。多人战斗里每个单位有独立的冷却表这点别图省事用模块级 dict第 5 章会讲踩过的坑。3.3 伤害与 Buff 的结算顺序谁先乘谁后加公式这一块是最容易出问题的地方。同一个「攻击力 30%」的 Buff先算和最后算伤害能差出 20%。定规矩Buff 的百分比修正统一进加法区暴击、元素克制进乘法区顺序固定为「基础值 → 加法修正 → 乘法修正 → 随机浮动」。# core/formula.py import random DEF_K 200 # 防御常数def 恰好等于 200 时减伤 50% def calc_damage(attacker, defender, power: float, element_mult: float 1.0, rng: random.Random | None None) - dict: rng rng or random atk attacker.final_stats()[atk] # 已合并 Buff 的临时属性 dfn max(defender.final_stats()[def_], 0) # 软上限减伤防御再高也不会 100% 免伤只会逼近但到不了 mitigation dfn / (dfn DEF_K) raw atk * (power / 100.0) * (1 - mitigation) is_crit rng.random() attacker.final_stats()[crit] if is_crit: raw * attacker.final_stats()[crit_dmg] hit_rate min(max(attacker.final_stats()[hit] - defender.final_stats()[res], 0.05), 1.0) if rng.random() hit_rate: return {damage: 0, crit: False, miss: True} raw * rng.uniform(0.95, 1.05) # ±5% 浮动避免每场完全一致 return {damage: max(1, int(raw * element_mult)), crit: is_crit, miss: False}DEF_K是整份公式里最值得拿来调参的常数。它等于 200 意味着「防御 200 减伤一半」防御 400 减伤 66%防御 800 减伤 80%。你把它调小堆防御收益变陡调大变缓。低数值回合制攻击力几十用 100 上下高数值攻击力上万按同比例放大否则防御在公式里几乎不起作用。max(1, ...)那个兜底不能删。没有它高防单位会被算出 0 伤害然后int(0.9)也是 0日志里会出现一次「命中但伤害 0」的记录玩家看着像 bug。rng参数做成可注入的是为了让随机种子可控。默认用全局random跑得快模拟和回放时传一个random.Random(seed)实例进去同种子必然同结果。这一点在写第一版的时候没注意调试「为什么这场战斗有时赢有时输」花了大半天。3.4 事件日志把一场战斗压成可重放的 JSON日志不只是给人看的文本。把每个阶段的关键状态写成一个结构化的 JSON 事件配上随机种子和指令序列就能完整重放一场战斗——这对复现线上 bug 的意义比调试器还大。# core/log.py import json class BattleLog: def __init__(self, seed: int): self.seed seed self.events [] self._seq 0 def emit(self, type_: str, **payload): self._seq 1 self.events.append({seq: self._seq, type: type_, **payload}) def dump(self, path: str): with open(path, w, encodingutf-8) as f: json.dump({seed: self.seed, events: self.events}, f, ensure_asciiFalse, indent2)事件类型保持在十个以内turn_start、action、damage、heal、buff_apply、buff_expire、unit_die、battle_end。类型太多会让回放器写起来痛苦太少又还原不出现场。每条事件都带上seq回放时按序号重放中途出问题能立刻定位到第几步。日志里别写大段自然语言。想显示「烈焰斩对史莱姆造成 128 点伤害」让 UI 层拿{actor, skill, target, damage}这几个字段自己拼文案日志保持纯数据。这样换个语言、换个 UI 框架日志文件一个字都不用改。4. 跑通第一场战斗环境、配置项与 headless 自测到这一步该让代码真正跑起来了。建议分两段走先在命令行跑一场无界面对战确认战斗逻辑没问题再接图形界面。顺序反了会很痛苦——画面黑屏时你分不清是渲染错了还是战斗根本没启动。4.1 环境与依赖Python 版本、pygame、目录放哪依赖就三个都在requirements.txt里# 建议 Python 3.10因为代码里用了 str | None 这种新语法 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt # pygame2.5, pytest7, 其余为标准库requirements.txt内容大致是pygame2.5和pytest7两行战斗内核用到的东西dataclasses、json、random、enum、copy全在标准库里。这也是分层的收益想把这个内核挪到服务端或塞进别的引擎pip都不用重装。目录别放在中文路径或带空格的路径下。pygame 加载资源用相对路径遇到编码问题会抛一个很难读的FileNotFoundError新手容易在这里卡很久。虚拟环境激活后命令行提示符前面会出现(.venv)看到这个再往下走。4.2 battle.json 里每一项是干什么的一场战斗的编排全在config/battle.json{ max_rounds: 30, seed: 20240501, speed: 1.0, teams: [ {side: 0, units: [knight, mage, priest]}, {side: 1, units: [slime, slime, bat]} ] }字段作用改动影响max_rounds回合上限超时按剩余血量百分比判胜调太小会出现「还没打完就结束」seed随机种子固定它同配置同操作结果必然一致调试必备speed动画倍速只影响ui/对战斗结果零影响调 0 就是瞬间播放teams[].side阵营0 为我方1 为敌方AI 只接管 side1 的单位teams[].units单位 id 列表引用units.json的 key重复写同一个 id 就是上两个同名单位seed这一项别删。很多人调试时觉得「随机的才真实」然后陷入「这次赢了下次输」的怀疑循环。固定种子同一串操作跑十次结果必须一样不一样就说明有地方用了全局随机或者依赖了不确定的迭代顺序属于逻辑缺陷。4.3 headless 跑一场并读懂日志先用测试把内核跑一遍确认战斗能正常结束python -m pytest tests/ -q三个测试文件覆盖的点test_scheduler.py验证速度 20 的单位在 10 回合内出手次数是速度 10 的两倍test_formula.py验证防御 200 时减伤接近 50%、伤害永不为 0test_battle_flow.py跑一整场断言 30 回合内必出胜负且没有异常。接着手工跑一场把日志打出来看# tools/run_once.py import json from core.loader import load_battle cfg json.load(open(config/battle.json, encodingutf-8)) battle load_battle(cfg, seedcfg[seed]) while not battle.finished: battle.step() # 不传 commandAI 自动接管双方 battle.log.dump(logs/last_battle.json) print(胜方:, battle.result()[winner], 回合数:, battle.result()[rounds])跑完打开logs/last_battle.json重点看三件事turn_start的 uid 顺序是否符合你对速度的预期damage事件的数值量级是否合理几十还是几万buff_apply之后有没有对应的buff_expire。这三项对上了说明行动条、公式、Buff 生命周期三条链路都通了。4.4 接上 pygame 表现层UI 只做三件事确认逻辑没问题后开窗口python main.py --config config/battle.json --seed 20240501ui/scene.py的主循环结构很朴素# ui/scene.py片段 def run(self): while not self.battle.finished: for event in pygame.event.get(): if event.type pygame.QUIT: return cmd self.hud.handle_event(event) # 点击技能按钮 - 生成 Command if cmd: self.battle.step(cmd) self.battle.step() # 无输入时推进 AI 阶段 self.hud.draw(self.battle) # 只读 battle 状态不写 pygame.display.flip() self.clock.tick(60)注意hud.draw()只读不写。UI 层一旦开始改battle里的数值比如自己算一次伤害做预览两边的公式就会慢慢分叉最后变成「面板显示 120实际打 90」的经典问题。伤害预览要显示就让 core 暴露一个preview_damage(actor, skill, target)复用的还是同一个calc_damage。中文字体要显式指定路径pygame.font.SysFont(simhei, 20)在部分系统上会退回默认字体中文全变方块。项目里ui/assets/放一个 ttf用pygame.font.Font(path, size)加载跨平台最稳。5. 避坑与排查战斗跑不通时先看这五处下面五条都是实际踩出来的按「现象 → 原因 → 解决」写遇到问题时对号入座比通读代码快。5.1 启动与环境类现象两个同名单位一起掉血打死一个另一个也倒了。原因加载时把模板对象直接赋给了每个单位unit.stats template.stats两个单位指向同一个Stats实例。Python 里对象赋值传的是引用改一个全改。# 错误写法 unit.stats TEMPLATE_STATS # 正确写法每次新建或用 copy.deepcopy import copy unit.stats copy.deepcopy(TEMPLATE_STATS)如果Stats是 dataclass还有一个更隐蔽的版本skills: list []写在类属性上所有实例共享同一个列表给一个单位加技能全场单位都会多一个技能。必须写成field(default_factorylist)。现象python main.py报找不到config/units.json。原因用相对路径加载配置而启动时的工作目录不是项目根目录。IDE 里点运行时工作目录经常是脚本所在目录和终端里跑不一样。解决所有配置路径以Path(__file__).parent.parent / config为基准拼出来别依赖os.getcwd()。这一改从哪个目录启动都不会出问题。5.2 数值与结算类现象Buff 消失后攻击力没恢复一场打下来攻击力越来越低。原因Buff 生效时直接改了unit.stats.atk到期时忘了加回去或者按百分比改过之后还原值算错了。只要 Buff 写回原属性就一定会有还原不准的一天。解决Buff 只存修正量不碰原属性。结算时临时汇总# core/entity.py片段 def final_stats(self) - dict: 把 Buff 的修正汇总成一份临时属性原 stats 保持不变 total {atk: self.stats.atk, def_: self.stats.def_, spd: self.stats.spd, crit: self.stats.crit, crit_dmg: self.stats.crit_dmg, hit: self.stats.hit, res: self.stats.res} for b in self.buffs: for k, v in b.modifiers.items(): if k.endswith(_pct): # 百分比修正走乘法区 total[k[:-4]] * (1 v) else: # 固定值修正走加法区 total[k] total.get(k, 0) v return total改完以后Buff 到期只需要从self.buffs里移除属性自动恢复不存在「还原」这个动作。现象伤害数字偶尔是负数或者一次打出几百万。原因整数溢出不是主要问题更常见的是百分比修正叠加顺序不一致——有的地方atk * 1.3有的地方atk 30两个 Buff 叠加就飞天。另一个来源是power单位混用140 和 1.4 混着写。解决在validate_config.py里加一条断言所有power必须在 0 到 1000 之间超出直接报错并打印技能 id。数值范围检查比事后看日志便宜太多。5.3 状态与并发类现象程序跑着跑着 CPU 占满界面卡死。原因while not battle.finished: battle.step()在玩家回合撞上WAIT_COMMANDstep()拿到commandNone直接返回什么都没推进循环空转。解决给 headless 模式加一个自动指令生成器AI 接管双方并在step()里加断言——同一个WAIT_COMMAND阶段连续调用超过 1000 次就抛异常让问题在测试阶段暴露而不是线上把 CPU 吃满。现象同种子跑两遍结果不完全一样回放对不上。原因代码里混用了全局random某次改动在 UI 层也调了一次random.random()做粒子特效随机流被打乱。另外 dict 迭代顺序、set遍历顺序也会影响「同行动值时谁先出手」。解决把random.Random(seed)实例挂到Battle上所有随机都从self.rng取UI 层要做特效用独立的random.Random()绝不允许碰战斗的随机源。所有涉及顺序的地方显式sorted()别指望容器顺序。6. 二次开发加一个刺客职业和流血 Buff再用模拟工具验一遍改一个现成的回合制项目最稳的路径是「加 Buff → 加技能 → 加单位 → 跑模拟」从底层往上加每加一层都能立刻验证。先加 Buff。在buffs.json里追加一条流血按最大生命百分比掉血{ bleed: { name: 流血, duration: 4, max_stack: 5, tick: on_turn_start, modifiers: {}, dot: {type: percent_max_hp, value: 0.03} } }on_turn_start的结算在_turn_start()里持有者行动前统一触发一次。max_stack: 5意味着最多叠五层每层独立算持续时间五层同时生效就是每回合掉 15% 最大生命——这个强度偏高所以刺客的流血技能我给了 40% 的触发概率剩下的靠模拟调。再加速度修正 Buff 和刺客单位units.json里加{ assassin: { name: 夜刃, team: 0, skills: [bleed_stab, shadow_step], stats: {max_hp: 820, hp: 820, mp: 60, atk: 96, def_: 32, spd: 24, crit: 0.25, crit_dmg: 1.8, hit: 1.0, res: 0.1} } }速度 24比骑士的 11 高出一倍多行动条上基本是「刺客动两次骑士动一次」。这里要小心一件事刺客爆发太高会破坏原有平衡加完必须量化验证不能凭感觉。验证分两步。先校验引用完整性防止skills里写了不存在的 idpython tools/validate_config.py --config config/这个工具会遍历三张表检查技能引用的 buff 是否存在、单位引用的技能是否存在、属性字段是否缺漏一次性把所有错误列出来。再跑批量模拟python tools/sim.py --config config/battle.json --n 1000 # 输出示例我方胜率 61.3%平均回合 8.7平均单场伤害 12480胜率预期控制在 45% 到 60% 之间比较理想。跑到 61% 说明刺客偏强回去把atk从 96 降到 88 或者把流血概率从 0.4 调到 0.3再跑一遍。这个「改数值 → 跑 1000 场 → 看胜率」的循环一次不到十秒比开游戏手动打几十把靠谱得多。最后补一个测试把新行为固化成断言# tests/test_battle_flow.py片段 def test_bleed_stacks_up_to_max(): 连打五次流血层数不超过 max_stack5 battle make_battle(seed1) assassin, target battle.units[0], battle.units[3] for _ in range(5): battle.apply_buff(target, bleed, sourceassassin) assert len([b for b in target.buffs if b.bid bleed]) 5新加的职业和 Buff 不写测试下次改公式时就没法确认有没有连带影响。测试不用多把「上限」「持续时间」「伤害量级」三条卡住就够。我早期做这类改动吃过亏凭手感调完就开着游戏打了两把觉得「差不多」结果上线后玩家反馈刺客两回合秒人。从那以后我每加一个新 Buff、新职业都强制先跑一遍validate_config.py和 500 场以上模拟胜率偏差超过 8 个百分点就不许提交。说白了回合制的数值平衡靠的不是手感是把每一版改动都量化下来做对比的习惯。希望帮到你。本文还有配套的精品资源点击获取