简介:围绕回合制游戏机制整理的Java源码示例,面向具备基础语法、希望理解游戏流程调度与面向对象设计的初学者。内容以游戏管理器协调回合推进为主线,配合士兵与Boss角色的属性、攻击防御行为及特殊技能设定,呈现工厂、策略、状态等设计模式的落地思路,也便于体会封装、继承与多态在角色体系中的组织方式。压缩包为rar格式,包体约1KB,文件总数显示为0,文件类型明细暂未提供,实际内容需以解压结果为准;这一数据差异建议下载后先行核对。已有1016人学习,说明该示例对入门者具有一定参考价值。读者可从中梳理回合制游戏的运行骨架、角色行为扩展方式与Boss阶段化设计,并借助版本控制、IDE、构建工具和单元测试等常见工具链理解源码组织与调试路径。
1. 一份回合制游戏源码,能拿来干什么
“回合制游戏”这四个字看着简单,真把它做成一份能跑的源码,难点全在看不见的地方。前阵子我把一份回合制游戏源码从解压到跑通完整走了一遍,最直观的感受是:画面表现只是壳,真正决定成败的是服务端那条从出手判定到伤害结算的流水线。这份资源把客户端、服务端、配置表和配套工具链放在一起,适合两类人:想弄清楚回合制底层怎么运转、打算自己搭一套对战系统的开发者;以及手里有个半成品 Demo、缺一套能直接改的技能与 Buff 框架的人。它不教美术,也不承诺一套就能上线,但战斗核心、回合调度、配置驱动这三块是完整的,改起来有地方下手。
2. 工程结构与启动链路:从双击运行到进入第一场战斗
拿到一个不熟悉的项目,我一般不会先读 README,而是把目录树摊开看三遍:哪一层是纯逻辑、哪一层依赖引擎、哪一层只是壳。这份源码的分层做得比较干净,值得先讲清楚,后面所有代码都挂在这个骨架上。技术栈是 C# 服务端加 Unity 客户端,这个组合在回合制品类里很常见,好处是战斗核心可以做成一份纯 C# 类库,服务端和客户端共用,客户端拿它做表现预测,服务端拿它做权威结算,两边算出来的结果理论上必须一致。
2.1 三层目录:客户端、服务端、配置与工具
把压缩包解开,主干大致是下面这个样子。注意Tools目录,很多人会直接忽略它,但它恰恰是这套代码能用起来的关键——导表、校验、战斗回放全靠它。
RoundBattle/ ├── Client/ # Unity 客户端工程 │ ├── Assets/Scripts/ │ │ ├── Battle/ # 战斗表现层:动画、飘字、血条、镜头 │ │ ├── Net/ # 协议收发、消息分发、心跳 │ │ └── UI/ # 大厅、编队、结算界面 │ └── Assets/Res/ # 预制体、图集、特效 ├── Server/ │ ├── BattleCore/ # 纯逻辑类库,不引用 UnityEngine │ │ ├── Unit/ # 战斗单位、属性容器 │ │ ├── Action/ # 行动条、出手队列 │ │ ├── Skill/ # 技能效果执行器 │ │ ├── Buff/ # Buff 实例与生命周期 │ │ └── Random/ # 可复现的伪随机 │ ├── GameServer/ # 网关、房间管理、玩家会话 │ └── DataAccess/ # 玩家存档、战斗日志落库 ├── Config/ # Excel 导出的 json │ ├── unit.json # 单位基础属性 │ ├── skill.json # 技能定义 │ └── buff.json # Buff 定义 └── Tools/ ├── ExcelExporter/ # Excel 转 json + 生成 C# 常量类 └── BattleReplay/ # 输入战斗日志,重算一遍比对结果BattleCore不引用任何引擎库,这是刻意的设计。一旦它using UnityEngine,你就没法在服务端跑,也没法在单元测试里跑,更别想做战斗回放校验。我见过不少项目把战斗逻辑写在 MonoBehaviour 里,后期想改成服务端权威结算,基本等于重写。
Config目录里是导表产物,原始 Excel 一般放在策划目录,不进版本库或者单独一个仓库。Tools/ExcelExporter负责把表转成 json 并生成对应的 C# 常量类,这样代码里写SkillId.FireBall而不是魔法数字1001,改起来不至于满项目搜索。
2.2 服务端启动:配置加载与房间注册
服务端启动的顺序不能乱,尤其是静态配置必须全部加载完、校验通过之后,才允许开端口监听。否则玩家一上来就打,读到半张表直接空引用。启动入口通常在GameServer/Program.cs,核心步骤是这样:
// GameServer/Program.cs public static void Main(string[] args) { // 1. 先加载静态配置,任何一张表失败就直接退出,不要带病启动 var config = new ConfigLoader("./Config"); if (!config.LoadAll(out var error)) { Console.WriteLine($"[启动失败] 配置加载异常: {error}"); return; // 关键:不要继续往下走 } Console.WriteLine($"[配置] 技能 {config.Skills.Count} 条, Buff {config.Buffs.Count} 条"); // 2. 注册战斗房间工厂,把 BattleCore 挂进来 var roomMgr = new BattleRoomManager(config); var sessionMgr = new SessionManager(roomMgr); // 3. 启动网络监听,这一步之后才开始收包 var server = new TcpServer(sessionMgr, port: 9001); server.Start(); // 4. 主循环:驱动房间 tick 与心跳超时检测 var ticker = new GameTicker(intervalMs: 50); ticker.OnTick += roomMgr.Tick; ticker.OnTick += sessionMgr.CheckTimeout; ticker.Run(); }这段代码里有两个参数值得注意。intervalMs: 50是房间 tick 的间隔,也就是每秒 20 次驱动。回合制不像实时对战那样需要 60 帧,20 次完全够用,但如果你把回合内的持续伤害、行动条推进都放在这里计算,间隔太小会浪费 CPU,太大又会让出手顺序显得迟钝。我一般从 50 毫秒起步,压测之后再看。
port: 9001是监听端口,一般会做成配置项,方便同一台机器起多个进程做压测。注意config.LoadAll用了一个out参数回传错误信息,这种做法比抛异常要可控——配置错误属于启动期问题,抛异常容易被外层 catch 掉然后服务继续跑,变成“服务看起来起来了但一进战斗就炸”的经典翻车场景。
BattleRoomManager.Tick里做的是遍历所有活跃房间,推进每个房间的战斗状态机。这里有个常见误区:不要在 Tick 里做同步的数据库写入。战斗中的状态全部在内存里,战斗结束后才落一次日志,否则每次 Tick 都写库,几十个房间就能把 IO 打满。
2.3 客户端启动:握手、拉配置、进战斗
客户端这边,Net目录负责协议收发。握手流程一般是客户端连上后先发一个LoginReq,服务端返回LoginResp带会话 token,然后客户端拉一次配置版本号,版本不一致就触发下载或提示重启。
进入战斗的关键在BattleEnterResp这个消息。服务端会下发完整的战斗初始快照:双方单位列表、属性、初始 Buff、随机种子。客户端收到后先本地构造一份BattleCore的战斗上下文,再开始播动画。
// Client/Assets/Scripts/Net/BattleHandler.cs public class BattleHandler : IMessageHandler { public void OnBattleEnter(BattleEnterResp resp) { // 用服务端下发的种子初始化本地随机数,保证随机结果可复现 var ctx = new BattleContext(resp.RandomSeed); foreach (var dto in resp.Units) { ctx.AddUnit(BattleUnit.FromDto(dto)); } // 本地先跑一遍完整的战斗逻辑,得到每一帧的表现指令 var frames = BattleSimulator.Simulate(ctx, resp.Inputs); // 交给表现层按时间轴播放,表现层不参与任何数值计算 BattleView.Instance.Play(frames); } }这里最要紧的一句话是“表现层不参与任何数值计算”。客户端本地跑一遍BattleSimulator,只是为了提前知道“第几帧谁掉多少血、谁放什么技能”,好让动画能连贯播放。真实伤害数字必须以服务端为准,如果两边算出来不一样,客户端要做一次静默纠偏,而不是把错的血量条一直挂在那儿。种子RandomSeed由服务端生成并在快照里下发,这是整个可复现机制的起点,后面讲随机数时会再碰到它。
3. 回合战斗核心:行动条、状态机与一次完整的结算
把启动链路走通之后,就可以进到最有意思的部分了。回合制的“回合”两个字,落到代码里其实是一套调度系统:谁在这一个时间片里能出手、出手之后其他人怎么排序、一整套结算按什么顺序执行。这三件事搞不清楚,写出来的战斗要么乱序,要么伤害对不上。
3.1 战斗上下文与单位实体
战斗的一切状态都收在一个BattleContext里,这个对象是整场战斗的唯一真相来源。它持有单位列表、当前回合数、随机数发生器、事件队列。
// Server/BattleCore/BattleContext.cs public class BattleContext { public int Round { get; private set; } // 当前回合数,从 1 开始 public List<BattleUnit> Units { get; } = new(); // 所有单位,含已死亡的 public DeterministicRandom Random { get; } // 可复现随机 public List<BattleEvent> Events { get; } = new(); // 结算过程中产生的表现事件 private int _nextActionTick; // 下一次有人能出手的时间点 public void AddUnit(BattleUnit unit) { unit.ActionGauge = 0; Units.Add(unit); } public IEnumerable<BattleUnit> AliveUnits(int camp) => Units.Where(u => u.Camp == camp && u.Hp > 0); }Units里保留已死亡单位而不是直接Remove,这点很关键。很多技能的效果是“对已死亡目标生效”(比如复活、亡语触发),一旦你把死掉的单位从列表里删了,这些逻辑就找不到人了,只能靠额外维护一个墓碑列表来补救,平白多出一堆状态。
Events是一个表现事件队列,服务端每算完一步就往里塞一条,最后打包发给客户端。这样做的好处是结算逻辑和网络发送解耦,服务端可以先把一整回合算完,再一次性下发,客户端按顺序播就行,不用来回等包。
BattleUnit这边,属性容器的设计有个小讲究:基础值和加成值要分开存。
// Server/BattleCore/Unit/BattleUnit.cs public class BattleUnit { public long Uid; public int Camp; // 0 = 我方, 1 = 敌方 public int Hp; public int MaxHp; public int Speed; // 决定行动条推进速度 public int BaseAtk; public int BaseDef; // 加成来自 Buff 和装备,每次读取时实时算,不做缓存 public int Atk => Math.Max(0, BaseAtk + GetBuffModifier(StatType.Atk)); public int Def => Math.Max(0, BaseDef + GetBuffModifier(StatType.Def)); public float ActionGauge; // 行动条进度,达到阈值即可出手 public List<BuffInstance> Buffs = new(); }Atk做成只读计算属性而不是一个可变字段,这是血泪经验。早期图省事,Buff 上加攻就直接改Atk字段,Buff 结束时再减回去,结果一旦出现“加攻 Buff 被驱散时目标已经死了”“两个 Buff 同时改同一个字段”这类情况,数值就永久漂移了,玩家越打攻击越高,查半天查不出来。改成实时计算之后,Buff 只负责往列表里加或删,数值永远由基础值加当前生效的加成推导出来,不可能漂。
3.2 行动条:谁先出手是怎么算出来的
回合制里最容易被玩家感知、也最容易被做错的就是出手顺序。这套源码用的是行动条(Action Gauge)方案,本质是给每个单位维护一个进度值,速度越快进度涨得越快,谁先满谁先动。
// Server/BattleCore/Action/ActionScheduler.cs public static class ActionScheduler { private const float GAUGE_MAX = 1000f; // 行动条满值 // 推进所有单位的行动条,返回第一个到达满值的单位 public static BattleUnit AdvanceUntilReady(BattleContext ctx) { while (true) { BattleUnit fastest = null; float minSteps = float.MaxValue; // 先算出谁最快到满,一次性推进到位,避免逐帧空转 foreach (var u in ctx.Units.Where(x => x.Hp > 0)) { float remain = GAUGE_MAX - u.ActionGauge; float steps = remain / Math.Max(1, u.Speed); if (steps < minSteps) { minSteps = steps; fastest = u; } } if (fastest == null) return null; // 全场无人存活 // 所有单位同时推进 minSteps 这么多的量 foreach (var u in ctx.Units.Where(x => x.Hp > 0)) { u.ActionGauge += Math.Max(1, u.Speed) * minSteps; } // 处理“多人同时满条”的平局,按速度高者优先,再相同按 Uid 稳定排序 var ready = ctx.Units .Where(u => u.Hp > 0 && u.ActionGauge >= GAUGE_MAX - 0.01f) .OrderByDescending(u => u.Speed) .ThenBy(u => u.Uid) .ToList(); if (ready.Count > 0) { var actor = ready[0]; actor.ActionGauge -= GAUGE_MAX; // 扣掉满值,剩下的留到下轮 return actor; } } } }这段代码有两个细节值得说。第一个是“一次性推进到位”而不是每帧给所有单位加一点速度。逐帧推进的写法在单位多的时候会空转很多次,而且浮点误差会累积,导致同样的初始条件在不同帧率下算出不同的出手顺序——这就是那个经典的玄学问题:本地测试好好的,线上就乱序。一次推进到最近的一个满条事件,数学上等价但精度可控。
第二个是平局处理。OrderByDescending(Speed).ThenBy(Uid)这两行看着不起眼,但没有它,同一速度的两个单位谁先动取决于List的遍历顺序,而遍历顺序在增删单位之后是会变的。玩家看到的就是“同样阵容,这次我先手下次他先手”,论坛上能吵三天。加上一个稳定的第二排序键,问题就没了。
actor.ActionGauge -= GAUGE_MAX而不是清零,是为了保留溢出的进度。速度 200 的单位一次推进可能超过满值很多,直接清零等于白送了这部分速度,速度收益会变成阶梯状的,玩家算不明白。
GAUGE_MAX = 1000f这个常量决定了速度的“刻度感”。如果你想要速度差距更明显,把满值调小;想要差距被稀释,调大。一般配合数值策划给的战斗时长目标来定,一个回合内平均每人出手 1 到 2 次是常见区间。
3.3 一个回合的结算流水线
有了行动条,接下来就是一整回合怎么跑。这套代码把回合拆成 7 个阶段,顺序固定,任何新加的机制都必须挂到某个阶段上,不允许插队。
// Server/BattleCore/BattleRunner.cs public class BattleRunner { public void RunRound(BattleContext ctx) { ctx.Round++; Emit(ctx, BattleEventType.RoundStart); // 阶段 1: 回合开始,结算“每回合开始”类 Buff ProcessBuffs(ctx, BuffTrigger.OnRoundStart); // 阶段 2: 行动循环,直到本回合的行动次数耗尽 int guard = 0; while (guard++ < 200) // 死循环保护,防止某个 Buff 让行动条永远满 { var actor = ActionScheduler.AdvanceUntilReady(ctx); if (actor == null) break; // 阶段 3: 出手前,处理眩晕、沉默等控制 if (IsStunned(actor)) { Emit(ctx, BattleEventType.Stunned, actor); continue; } // 阶段 4: 选技能、选目标、执行效果 var action = actor.PickAction(ctx); SkillExecutor.Execute(ctx, actor, action); // 阶段 5: 出手后,处理反击、连击、吸血等 ProcessBuffs(ctx, BuffTrigger.OnAfterAction, actor); // 阶段 6: 死亡结算,统一在行动结束后处理,避免中途改列表 ResolveDeaths(ctx); if (IsBattleOver(ctx)) return; } // 阶段 7: 回合结束,结算持续伤害、Buff 持续时间递减 ProcessBuffs(ctx, BuffTrigger.OnRoundEnd); TickBuffDuration(ctx); Emit(ctx, BattleEventType.RoundEnd); } }guard < 200这个死循环保护是我强烈建议每个项目都加的。只要 Buff 系统允许“行动条加速”,就存在一种可能:某个单位的行动条增速超过消耗速度,AdvanceUntilReady每次都返回它,循环永远不结束。线上表现就是这一场战斗卡死,房间不释放,越积越多,最后整个服卡住。加一个硬上限,超了就打日志并强制结束本回合,至少不会把服务拖垮。
ResolveDeaths单独抽成一个阶段,是为了避免在遍历Units的过程中删除元素。C# 里foreach的时候改集合会直接抛异常,就算用for循环,边遍历边删也会漏掉元素。统一在行动结束后扫一遍,把血量小于等于 0 的标记成死亡并触发亡语,逻辑清晰得多。
ProcessBuffs带了一个BuffTrigger参数,这是一个枚举,标记 Buff 在哪个时机生效。加新机制的时候,你只需要决定它挂在哪一个触发点上,不用去改主循环,这是这套结构最值钱的地方。
4. 技能与 Buff 系统:配置表驱动的判定逻辑
战斗循环搭好之后,剩下 80% 的需求都是“加个新技能”。如果每加一个技能就改一次主循环,这个项目三个月就没法维护了。这套源码走的是配置表加执行器的路子:技能长什么样写在表里,怎么算写在执行器里,两者靠一个约定好的 id 关联。
4.1 技能配置表长什么样
Excel 导出的skill.json结构大致如下,字段名对应生成的 C# 类:
{ "id": 1001, "name": "烈焰冲击", "targetType": 0, "power": 180, "hitRate": 950, "cooldown": 2, "effects": [ { "type": "Damage", "power": 180, "element": 1 }, { "type": "AddBuff", "buffId": 2001, "chance": 300 } ] }targetType用一个整数枚举:0 敌方单体、1 敌方全体、2 己方单体、3 己方全体。power是伤害系数,按万分比存,180 表示 1.8 倍。hitRate950 表示 95% 命中率,同样按万分比。effects是一个数组,一个技能可以挂多个效果,按顺序执行。
把数值存成整数万分比而不是浮点,是我吃过亏之后养成的习惯。json 里的1.8在反序列化成 float 之后可能变成1.7999999,再乘来乘去,最后伤害差个一两点,测试提 bug 你都复现不了。整数运算没有这个问题。
GenerateCSharp这一步会产出类似public const int SkillId_FireBall = 1001;的常量类,代码里就不该再出现裸数字。项目大了之后,你一定会遇到“某个技能 id 到底对应哪个技能”的困惑,常量类能省下大量搜索时间。
4.2 效果执行器:把配置翻译成一次结算
执行器这层是一个简单的策略模式。每个effect.type对应一个实现类,注册到字典里,执行时按名字查。
// Server/BattleCore/Skill/SkillExecutor.cs public interface ISkillEffect { void Execute(BattleContext ctx, BattleUnit caster, List<BattleUnit> targets, SkillEffectConfig cfg); } public static class SkillExecutor { private static readonly Dictionary<string, ISkillEffect> _handlers = new() { ["Damage"] = new DamageEffect(), ["Heal"] = new HealEffect(), ["AddBuff"] = new AddBuffEffect(), }; public static void Execute(BattleContext ctx, BattleUnit caster, SkillAction action) { var cfg = ConfigLoader.GetSkill(action.SkillId); var targets = TargetSelector.Select(ctx, caster, cfg.TargetType); foreach (var effect in cfg.Effects) { if (!_handlers.TryGetValue(effect.Type, out var handler)) { // 配置里写了代码不支持的效果,宁可打日志也不要静默跳过 Console.WriteLine($"[技能] 未实现的效果类型: {effect.Type}, 技能 {cfg.Id}"); continue; } handler.Execute(ctx, caster, targets, effect); } } }TryGetValue失败时打日志而不是抛异常,是因为配置表是策划在改,出现未知类型是常态。抛异常会导致整场战斗中断,玩家那边看到的就是卡死。打日志、跳过这一个效果、其余照常,是更稳妥的选择。
伤害计算本身单独拿出来看:
// Server/BattleCore/Skill/DamageEffect.cs public class DamageEffect : ISkillEffect { public void Execute(BattleContext ctx, BattleUnit caster, List<BattleUnit> targets, SkillEffectConfig cfg) { foreach (var target in targets.Where(t => t.Hp > 0)) { // 1. 命中判定,用可复现随机 int roll = ctx.Random.Next(0, 10000); if (roll >= caster.GetSkillHitRate(cfg)) { Emit(ctx, BattleEventType.Miss, target); continue; } // 2. 基础伤害 = 攻击 * 系数 - 防御 * 减免系数 long raw = (long)caster.Atk * cfg.Power / 10000; long reduce = (long)target.Def * 5 / 10; long dmg = Math.Max(1, raw - reduce); // 保底 1 点伤害,防止完全打不动 // 3. 暴击 bool crit = ctx.Random.Next(0, 10000) < caster.CritRate; if (crit) dmg = dmg * 150 / 100; target.Hp -= (int)dmg; Emit(ctx, crit ? BattleEventType.Crit : BattleEventType.Hit, target, (int)dmg); } } }raw和reduce都用long中间变量,最后才转回int。这不是洁癖,是踩过坑的:攻击 8 万、系数 3 倍,乘出来 24 万,int装得下,但如果再叠个暴击、再叠个 Buff 加成,很容易顶到 21 亿的上限然后变成负数,玩家看到的是“打出了一次加血”。long中间变量加上最后的范围裁剪,能挡住这类问题。
Math.Max(1, raw - reduce)这个保底也是必须的。没有保底的话,高防单位面对低攻单位会完全免疫,战斗变成无限回合。策划那边通常会要求至少 1 点伤害或者 5% 保底,具体看数值设计,但代码里一定要有这个位置。
Emit往ctx.Events里塞一条事件,带目标 uid 和伤害值。客户端的飘字、受击动画全靠这条事件驱动,服务端不关心表现,只负责算准。
4.3 Buff 的生命周期与叠加规则
Buff 系统是回合制里最容易失控的部分,因为它涉及“什么时候加、什么时候减、能不能叠、叠几层、被驱散怎么办”这一串问题。
// Server/BattleCore/Buff/BuffInstance.cs public class BuffInstance { public int BuffId; public BattleUnit Source; public int RemainRounds; // 剩余回合数 public int StackCount; // 叠加层数 public void OnApply(BattleUnit owner) { var cfg = ConfigLoader.GetBuff(BuffId); if (cfg.StackType == StackType.Refresh) { RemainRounds = cfg.Duration; // 刷新持续时间,层数不变 } else if (cfg.StackType == StackType.Stack) { StackCount = Math.Min(StackCount + 1, cfg.MaxStack); RemainRounds = cfg.Duration; // 叠层同时刷新时间 } } public int GetModifier(StatType stat) { var cfg = ConfigLoader.GetBuff(BuffId); return cfg.Modifiers.Where(m => m.Stat == stat) .Sum(m => m.Value * StackCount); } }StackType至少有三种:Refresh(刷新时间不叠层,适合控制类)、Stack(叠层,适合持续伤害)、Independent(独立存在,多个实例互不干扰,适合不同来源的同类 Buff)。这三种一定要在配置表里显式指定,不要用“有 MaxStack 就是叠层”这种隐式规则,否则策划加个新 Buff 忘了填字段,行为就跟你预期的不一样。
GetModifier里乘了StackCount,这就是 3.1 节里说的实时计算的入口。Buff 只管自己存了几层,加成值每次读属性时现算,从根上避免了数值漂移。
回合结束时递减持续时间的逻辑要特别注意顺序:先结算OnRoundEnd的持续伤害,再减RemainRounds,减到 0 才移除。如果顺序反了,一个 1 回合的毒会在施加当回合就消失,一次伤害都打不出来。这个顺序在BattleRunner的阶段 7 里体现得很清楚。
5. 避坑与排查:联调阶段最容易翻车的五个点
代码跑起来只是开始,真正耗时间的是联调。下面这五条都是我在类似项目里实际遇到过的,按“现象 → 原因 → 解决”写,看到了能少走点弯路。
5.1 随机数不一致,战斗回放对不上
现象:服务端日志里记的伤害是 1240,客户端播出来的飘字是 1180,玩家截图来投诉。或者回放工具重算一遍,结果和原始日志对不上。
原因:客户端和服务端各自用了new Random()或者UnityEngine.Random,两边的种子根本不同,命中、暴击、Buff 触发概率的判定结果自然不一样。还有一种更隐蔽的情况:两边用的是同一个种子,但调用Next()的次数不同——比如客户端为了做特效预演多摇了一次随机,后面所有序列就全错位了。
解决:整个战斗流程只允许存在一个随机源,就是ctx.Random,种子由服务端生成并随战斗快照下发。所有随机调用必须走它,禁止在战斗代码里出现new Random()、Guid.NewGuid()、DateTime.Now这类不可复现的东西。再加一条硬约束:任何随机调用都要有对应的日志记录,回放工具按日志重放,能对上才算通过。
5.2 客户端表现抢跑,动画播完服务端还没结算
现象:玩家点了技能,动画播完了,血条也掉了,然后突然回滚,血量又涨回去,再掉一次。或者按钮点了没反应,过两秒才动。
原因:客户端在本地预测之后直接改了 UI 状态,服务端权威结果回来发现不一致,又改了一遍。常见于网络抖动或者服务端 Tick 负载高的时候。
解决:把“预测”和“确认”分成两层。预测层只负责让动画立刻播起来,UI 上的血条走一个平滑插值;服务端结果回来之后,如果数值一致就什么都不做,不一致就静默纠偏到正确值,不做回滚动画。另外给所有请求加一个递增的序列号,服务端按序处理,客户端丢弃过期响应,避免旧包覆盖新状态。
5.3 配置表热更,改了没生效或者生效后错乱
现象:策划改了一个技能系数,推上去之后部分玩家生效了,部分玩家还是老数值,重启客户端才好。
原因:配置被缓存在多个地方——服务端有一份内存字典,客户端有一份,战斗房间里可能还持有一份引用。热更只刷新了其中一处,其他地方还是旧的。更麻烦的是,如果战斗进行中热更,同一场战斗里前半段用旧表后半段用新表,结算直接乱掉。
解决:给配置加版本号,进战斗时把版本号记在BattleContext里,整场战斗只读这一个版本的快照。热更时新建一份配置对象,新开的房间用新的,老房间继续用老的,等老房间打完自然释放。客户端的版本号和服务端不一致时,强制拉一次全量配置,不要试图做增量。
5.4 断线重连回来,发现自己血是满的
现象:玩家网络闪断一下,重连进战斗,血量显示满的,Buff 全没了,回合数也对不上。
原因:重连逻辑只下发了单位的基础属性,没有下发当前战斗的完整快照。或者下发了,但客户端重建BattleContext之后没有重放已经发生的事件。
解决:服务端必须能导出任意时刻的完整战斗快照,包括每个单位的当前血量、行动条进度、Buff 列表和剩余回合、当前回合数、随机数发生器的当前状态。客户端拿到快照后直接替换本地上下文,而不是在旧的上做增量。随机数状态也要能导出,否则重连之后第一次判定的结果会和断线前不一致。
5.5 数值溢出与负数血量
现象:偶尔出现一次巨额伤害,或者血量显示成负数,或者治疗把死人治活了。
原因:伤害计算全程用int,多段加成叠乘之后溢出;或者血量扣到负数之后没有及时判定死亡,后续的治疗逻辑又把负数加回正数。
解决:中间计算一律用long,最后落库前裁剪到[0, MaxHp]。血量变化统一走一个ChangeHp方法,里面处理死亡判定和事件触发,禁止直接赋值unit.Hp = xxx。死亡状态用一个独立的IsDead标记,不要只靠Hp <= 0判断,因为有些技能允许在 0 血状态下继续行动(比如亡语结算期间)。
6. 二次开发:加一个新技能,到底要动几个地方
假设现在要加一个“对敌方全体造成伤害,并有 30% 概率附加灼烧”的技能。听起来是一个需求,实际要动的地方有五处,顺序不能乱。
第一处是配置表。在skill.json里加一条,targetType填 1(敌方全体),effects数组里放一个Damage和一个AddBuff,后者带chance: 3000。同时在buff.json里加灼烧的 Buff 定义,StackType选Stack,MaxStack填 3。
第二处是导表。跑一次Tools/ExcelExporter,生成新的 json 和常量类。这一步别偷懒手动改 json,容易和 Excel 源对不上,下次导表就被覆盖了。
第三处是效果执行器。Damage和AddBuff这两个类型如果已经存在,什么都不用改;如果AddBuff不支持chance参数,就要在AddBuffEffect里加一段概率判定,走ctx.Random。
// Server/BattleCore/Skill/AddBuffEffect.cs public class AddBuffEffect : ISkillEffect { public void Execute(BattleContext ctx, BattleUnit caster, List<BattleUnit> targets, SkillEffectConfig cfg) { foreach (var target in targets.Where(t => t.Hp > 0)) { // chance 按万分比配置,0 或缺省表示必定命中 if (cfg.Chance > 0 && ctx.Random.Next(0, 10000) >= cfg.Chance) continue; var buff = new BuffInstance { BuffId = cfg.BuffId, Source = caster, RemainRounds = ConfigLoader.GetBuff(cfg.BuffId).Duration, StackCount = 1 }; target.Buffs.Add(buff); buff.OnApply(target); } } }第四处是客户端表现。新技能需要一个特效和一段动画,挂在BattleView的技能表现映射表里,key 是技能 id。如果客户端是按effect.type泛化处理的,这一步可能都不用改。
第五处是验证。跑一遍Tools/BattleReplay,构造一场包含新技能的战斗,把服务端日志喂进去重算,比对每一个事件的伤害值和目标。这一步不能省,我见过太多“配置改了但代码里switch没加分支,技能放出去毫无反应”的情况,回放工具一跑就露馅。
说到验证,我一般会在加完技能之后做一个笨办法但很有效的检查:把同一个随机种子固定住,跑一百次战斗,看伤害分布是不是符合预期。如果每次都一样,说明随机源被写死了;如果分布明显偏离配置的概率,说明某处Next()的调用次数不对。这个方法不需要任何测试框架,一个控制台循环就够。
从那以后我每次动技能或 Buff 相关的代码,都会强制走一遍“导表 → 回放比对 → 固定种子跑一百次”这条链路,哪怕只是改了一个数字。回合制这种东西,表面上风平浪静,底下全是顺序和状态,少走一步,后面排查起来就是黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取