当"肉鸽抽卡"遇上"自走棋":一款三国题材手游的战斗内核设计本文基于项目内部技术分析整理,面向团队内部/技术社区分享,聊聊一款"肉鸽 + 自走棋"手游(内部代号小兵2)在战斗模拟、热更新适配、数据驱动几个方向上的具体设计取舍。引言:这是一款什么游戏如果用一句话概括玩法:每一回合结束后随机三选一抽卡,逐步拼出自己的三国阵容,然后交给一个"自动战斗黑盒"去演算胜负。这个组合听起来很常见——TFT 的抽卡逻辑 + 自走棋的自动战斗——但真正做起来,会遇到几个绕不开的工程问题:战斗过程要不要能回放?要不要考虑未来上服务器强校验?卡牌效果(Buff)有几百个,新增一个效果的成本能不能做到"加个文件就好"?项目要接热更新(HybridCLR),那些"扫描所有类"的反射代码在热更程序集里还能用吗?没有真人对战服务器的情况下,PVP 怎么做?下面就按这几个问题,把项目里对应的设计逐一拆开看。一、战斗内核:把"战斗"做成一个纯 C# 黑盒1.1 为什么要"纯 C#、零 Unity 依赖"项目把整个战斗模拟单独抽成了一个命名空间BattleCore,规则只有一条:不依赖任何UnityEngine的 API(除了极个别编辑器工具代码)。这么做的直接收益是:战斗逻辑可以脱离 Unity 引擎单独跑单元测试、单独编译;只要输入相同,理论上可以把这份代码搬到服务器上原样跑一遍,用来做防作弊校验或者多人对战的权威判定;逻辑层完全不碰渲染,View 层只能"读事件",不能反向影响逻辑——架构上天然杜绝了"表现层偷偷改数值"这种糊代码方式。架构长这样:外部(View) ── BattleDirector 播放节奏控制:Play / Pause / Fast / Skip │ 按固定步长 LogicDt = 0.05s(20Hz)驱动 ▼ BattleSimulation 主循环,Tick(dt) → 产出 ListBattleEvent ┌──────────────┼───────────────┬─────────────┐ ▼ ▼ ▼ ▼ SquadLogic[] UnitLogic[] System_* BuffManager (小队/战术) (单位状态机) (网格/避障/投射物) (卡牌Buff)BattleDirector只负责"播放",真正跑逻辑的是BattleSimulation。它每一帧只做一件事:推进一个固定步长,然后把这一步发生了什么打包成事件扔出来(近战命中、投射物发射/命中、单位死亡、战斗结束……),View 层拿着事件去播动画、放特效,逻辑层自己完全不关心。1.2 主循环:10 步走完一帧,顺序不能乱1. 重建空间网格 所有存活单位按位置重新插入网格,供索敌/查询 2. 更新 Buff 限时 Buff 倒计时、到期还原 3. 小队 Tick 统计攻击次数变化,触发事件供 Buff 监听 4. 单位决策 索敌、预约攻击插槽、算出"期望速度"(还不移动) 5. ORCA 求解 所有单位注册为 Agent,求解避障后的真实速度 6. 应用速度 + 行动 真正位移;处于攻击状态的单位推进攻击计时 7. 硬分离(PBD) 3 次迭代消除残余重叠,兜底 ORCA 8. 投射物 Tick 移动、命中检测、结算、销毁 9. 收集死亡事件 Dead → Release,发 UnitDead 10. 结算胜负 任一方全灭即结束这个顺序背后是一个很朴素但容易被忽略的原则:"决策"和"移动"必须分两步(第 4 步只算期望速度,第 5、6 步才真正求解和位移)。如果决策阶段直接改位置,ORCA 的输入数据就会在计算过程中被污染,避障效果会变得不可预测。1.3 确定性:所有随机数都要能"对得上"自走棋战斗一旦要支持回放、要支持"PVP 用别人的存档数据离线重演",确定性就是硬指标——同样的输入,必须永远算出同样的结果,不能有一丝浮点误差或者随机数错位。项目里的做法:① 自研PredictableRandom,PCG32 算法,配合Fork()做分层派生:Sim.Fork() → Squad.Fork() → Unit.Fork() → Buff.Fork()每一层拿到的都是从上一层"分叉"出来的独立随机流。这样做的好处是:某个 Buff 内部多消耗了几次随机数,不会导致后面别的单位的随机结果跟着错位——如果全局共用一个