ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

回合制游戏源码拆解:战斗状态机与数据驱动工程实践

回合制游戏源码拆解:战斗状态机与数据驱动工程实践

简介:这份资源面向Java初学者与游戏开发入门者,提供一套回合制游戏核心逻辑的源码实现,帮助理解轮流行动机制下游戏流程与角色行为的组织方式。包内共0个文件,压缩包约1KB,以Java源码类文件为主,涵盖游戏管理器、士兵与Boss三类核心角色,分别承担流程调度、基础战斗单位与高难度敌人逻辑的职责。已有1015人学习下载,适合作为面向对象编程与设计模式的练手素材。读者可从中了解如何用管理器统一调度回合、决定当前行动者并调用其行为方法,如何为士兵定义生命值、攻击力、防御力与移动速度等属性及攻击、防御、移动等动作,以及如何让Boss扩展士兵能力并加入特殊技能、阶段变化或复杂AI逻辑。代码还体现了工厂模式创建角色、策略模式定义行为技能、状态模式处理战斗与非战斗状态等思路,并涉及封装、继承与多态等原则,便于对照学习如何构建可扩展的回合制游戏骨架。

1. 回合制游戏源码拆解:从战斗状态机到可运行工程的落地笔记

回合制游戏看起来简单——你一刀我一刀,轮流动手。但真去翻一份能跑的源码,你会发现它比很多即时制项目更考验状态管理:行动顺序、技能冷却、Buff 叠加、AI 决策、战斗结算,每一步都得有明确的阶段划分,否则一个回合没走完就乱套。我最近拆了一份回合制游戏的完整工程源码,它把战斗流程拆成了清晰的状态机,角色属性、技能配置、AI 行为都做了数据驱动,改数值不用动逻辑代码。这份资源适合两类人:想学游戏状态管理的开发者,以及想拿一套能跑的战斗框架做二次开发的人。下面按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲。

2. 工程结构与技术选型:为什么用状态机而不是协程

2.1 目录分层与模块职责

拿到源码先看目录,这份工程的顶层结构大致是这样:

TurnBasedGame/ ├── Assets/ │ ├── Scripts/ │ │ ├── Core/ # 战斗状态机、回合调度、事件总线 │ │ ├── Units/ # 角色基类、属性组件、Buff 容器 │ │ ├── Skills/ # 技能基类、效果实现、目标选择器 │ │ ├── AI/ # 行为树节点、决策评分 │ │ ├── UI/ # 战斗界面、行动条、伤害飘字 │ │ └── Data/ # ScriptableObject 配置、加载器 │ ├── Resources/ │ │ ├── Units/ # 角色配置资产 │ │ ├── Skills/ # 技能配置资产 │ │ └── AIDecisions/ # AI 决策权重配置 │ └── Scenes/ │ └── Battle.unity # 战斗主场景 ├── Packages/ └── ProjectSettings/

Core 层是整场战斗的骨架,Units 和 Skills 是数据载体,AI 层只负责「选什么技能打谁」,UI 层订阅事件刷新表现。这种分层的好处是:战斗逻辑不依赖 MonoBehaviour 的生命周期,可以脱离场景单独跑单元测试。常见做法是把状态机做成纯 C# 类,Unity 只负责驱动Tick。

2.2 战斗状态机的阶段划分

回合制战斗的核心是「阶段推进」。这份源码把一场战斗拆成六个阶段,用枚举驱动:

public enum BattlePhase { BattleStart, // 战斗初始化,加载双方单位 RoundStart, // 回合开始,重置行动条、结算回合 Buff TurnStart, // 单个单位行动开始,结算该单位 Buff WaitingForInput, // 等待玩家输入或 AI 决策 ExecutingAction, // 执行技能,播放表现 TurnEnd, // 行动结束,检查死亡、触发被动 RoundEnd, // 回合结束,检查胜负 BattleEnd // 战斗结束,结算奖励 }

状态机在BattleManager.Update里推进,每个阶段只做一件事。比如TurnStart阶段会先结算「中毒」「再生」这类持续效果,再判断单位是否被控制(眩晕跳过行动),最后才进入WaitingForInput。这样拆的好处是:任何一个环节出问题,看当前处于哪个 Phase 就能定位。

注意:状态机推进必须加超时保护。如果某个阶段因为异常没退出,战斗会卡死。常见做法是在BattleManager里记录每个阶段的进入时间,超过阈值强制跳到下一阶段并打日志。

2.3 行动条与速度排序的实现

回合制里「谁先动手」由行动条决定。这份源码没有用简单的速度排序,而是用了「行动值累积」模型:

public class ActionGauge { public Unit Owner; public float CurrentValue; public float Speed => Owner.GetAttribute(AttributeType.Speed); // 每帧累积行动值,达到阈值即可行动 public bool Tick(float deltaTime) { CurrentValue += Speed * deltaTime; if (CurrentValue >= 1000f) { CurrentValue -= 1000f; return true; // 该单位可以行动 } return false; } }

Speed越高,行动值攒得越快,出手频率越高。阈值 1000 是拍脑袋定的,实际项目里可以调。这种模型比「按速度排个序轮流动」更灵活,因为速度 Buff 可以实时影响出手顺序,不用重排整个队列。参数说明:deltaTime是帧间隔,CurrentValue是当前累积值,Speed从属性组件读取,支持装备和 Buff 加成。

2.4 事件总线解耦战斗逻辑与表现

战斗逻辑不该直接调 UI。这份源码用了一个轻量事件总线:

public static class EventBus { private static readonly Dictionary<Type, Delegate> handlers = new(); public static void Subscribe<T>(Action<T> callback) where T : IGameEvent { var type = typeof(T); handlers[type] = Delegate.Combine(handlers.GetValueOrDefault(type), callback); } public static void Publish<T>(T evt) where T : IGameEvent { if (handlers.TryGetValue(typeof(T), out var d)) (d as Action<T>)?.Invoke(evt); } }

伤害计算完发一个DamageEvent,UI 层订阅后播飘字;单位死亡发UnitDeathEvent,AI 层订阅后重新评估目标。逻辑层和表现层互不引用,改 UI 不会碰坏战斗。常见坑是忘记取消订阅导致内存泄漏,我一般会在OnDestroy里统一反注册。

3. 核心模块拆解:技能系统与 AI 决策怎么写

3.1 技能基类与效果组合

技能系统是回合制游戏最容易被改烂的地方。这份源码把技能拆成「目标选择 + 效果列表」两部分:

[CreateAssetMenu(menuName = "Game/Skill")] public class SkillConfig : ScriptableObject { public string skillId; public string displayName; public int cost; // 消耗(怒气/法力) public TargetSelector selector; // 目标选择器 public List<SkillEffect> effects; // 效果列表,按顺序执行 public int cooldown; // 冷却回合数 } public abstract class SkillEffect : ScriptableObject { public abstract void Apply(Unit caster, Unit target, BattleContext ctx); }

TargetSelector也是 ScriptableObject,常见实现有「单体敌方」「全体敌方」「血量最低友方」「随机敌人」。效果则包括DamageEffect、HealEffect、BuffEffect、SummonEffect。一个技能就是一组效果的组合,比如「火球术」= 单体敌方 + 伤害效果 + 灼烧 Buff。

这种设计的好处是加新技能不用写新类,策划在编辑器里拖几个效果资产就完事。我见过不少项目把技能写成硬编码的switch-case,加一个技能改一次代码,后期维护成本极高。

3.2 伤害计算公式与属性穿透

伤害计算放在DamageEffect.Apply里,公式大致是:

public override void Apply(Unit caster, Unit target, BattleContext ctx) { float atk = caster.GetAttribute(AttributeType.Attack); float def = target.GetAttribute(AttributeType.Defense); float penetration = caster.GetAttribute(AttributeType.Penetration); // 防御减免公式:防御越高减伤越多,但有下限 float effectiveDef = Mathf.Max(0, def - penetration); float reduction = effectiveDef / (effectiveDef + 200f); float damage = atk * (1f - reduction); // 暴击判定 float critRate = caster.GetAttribute(AttributeType.CritRate); bool isCrit = Random.value < critRate; if (isCrit) damage *= caster.GetAttribute(AttributeType.CritDamage); target.TakeDamage(damage, isCrit, caster); }

200f是防御系数,调大则防御收益降低,调小则防御更值钱。penetration是穿透属性,先减防御再算减免。暴击率和暴击伤害从属性组件读,支持 Buff 动态修改。这段代码里Random.value是 Unity 的全局随机,如果要做战斗回放,得换成可播种的随机数生成器,否则同一场战斗重放结果不一致。

3.3 AI 决策:评分制比行为树更轻

这份源码的 AI 没有用行为树,而是用了「评分制」——给每个可选行动打分,选最高分执行:

public class AIDecisionMaker { public SkillConfig ChooseAction(Unit self, List<Unit> allies, List<Unit> enemies) { var options = new List<(SkillConfig skill, Unit target, float score)>(); foreach (var skill in self.GetAvailableSkills()) { foreach (var target in skill.selector.SelectTargets(self, allies, enemies)) { float score = Evaluate(skill, self, target, allies, enemies); options.Add((skill, target, score)); } } return options.OrderByDescending(o => o.score).First().skill; } private float Evaluate(SkillConfig skill, Unit self, Unit target, List<Unit> allies, List<Unit> enemies) { float score = 0f; // 治疗技能:友方血量越低分越高 if (skill.HasEffect<HealEffect>()) score += (1f - target.HpRatio) * 100f; // 伤害技能:目标血量越低分越高(收割) if (skill.HasEffect<DamageEffect>()) score += (1f - target.HpRatio) * 80f; // 击杀预判:如果预计伤害超过目标血量,大幅加分 if (skill.EstimatedDamage(self, target) >= target.CurrentHp) score += 200f; return score; } }

评分权重都写在配置里,调 AI 性格不用改代码。比如「激进型」AI 把伤害权重调高,「保守型」把治疗权重调高。这种方案比行为树轻量得多,适合回合制这种决策空间有限的场景。

3.4 Buff 容器与叠加规则

Buff 管理是回合制的另一个重灾区。这份源码用BuffContainer统一管理:

public class BuffContainer { private List<BuffInstance> buffs = new(); public void AddBuff(BuffConfig config, Unit source) { var existing = buffs.FirstOrDefault(b => b.Config.buffId == config.buffId); if (existing != null) { switch (config.stackRule) { case StackRule.RefreshDuration: existing.RemainingTurns = config.duration; break; case StackRule.StackLayer: existing.Layers = Mathf.Min(existing.Layers + 1, config.maxLayers); break; case StackRule.Independent: buffs.Add(new BuffInstance(config, source)); break; } } else { buffs.Add(new BuffInstance(config, source)); } } public void TickTurn() { foreach (var buff in buffs) buff.OnTurnStart(); buffs.RemoveAll(b => b.IsExpired); } }

StackRule决定同名 Buff 怎么处理:刷新持续时间、叠层、还是独立共存。TickTurn在每个回合开始时调用,结算持续伤害、减益倒计时。常见坑是 Buff 在单位死亡后还在结算,所以UnitDeathEvent里要清空 Buff 容器。

4. 跑起来:环境准备与首次运行排查

4.1 环境要求与打开工程

这份工程是 Unity 项目,常见做法是用 Unity 2021 LTS 或更高版本打开。步骤:

  1. 安装 Unity Hub,添加对应版本的编辑器
  2. 在 Hub 里「Add project from disk」,选中工程根目录
  3. 等待首次导入完成,Unity 会编译脚本、生成 Library 缓存
  4. 打开Assets/Scenes/Battle.unity
  5. 点 Play 按钮

如果打开后报编译错误,先看 Console 面板。常见原因是缺少依赖包,检查Packages/manifest.json里是否包含com.unity.textmeshpro和com.unity.inputsystem。这两个是 UI 和输入的基础依赖,缺了会编译不过。

4.2 战斗场景的对象结构

Battle.unity里的核心对象不多:

对象名挂载脚本职责
BattleManagerBattleManager.cs驱动状态机、管理回合
UnitSpawnerUnitSpawner.cs从配置生成双方单位
BattleUIBattleUI.cs行动条、技能按钮、血条
EventSystemUnity 自带处理 UI 点击

BattleManager上有个battleConfig字段,指向一个BattleConfig资产,里面配了双方阵容。想换敌人,改这个资产就行,不用动场景。

4.3 首次运行常见报错与处理

第一次跑最容易遇到三类问题:

空引用异常,提示UnitSpawner找不到配置。原因是Resources/Units下的角色资产没有正确加载。检查UnitSpawner的unitIds列表,确保每个 id 在Resources/Units下都有同名资产。常见做法是用Resources.Load<UnitConfig>($"Units/{id}")加载,路径大小写敏感。

UI 按钮点了没反应。检查BattleUI是否订阅了WaitingForInputEvent,以及按钮的onClick是否绑定了BattleManager.OnSkillSelected。如果用的是新输入系统,还要确认EventSystem上挂的是InputSystemUIInputModule而不是旧的StandaloneInputModule。

战斗卡在某个阶段不动。在BattleManager里加一行日志,打印当前Phase。如果卡在ExecutingAction,多半是技能动画的AnimationEvent没触发回调。检查动画片段上是否有关键帧事件调用BattleManager.OnActionAnimationEnd。

提示:Unity 的Resources文件夹加载方式在大型项目里不推荐,但这份工程规模小,用起来最省事。二次开发时如果资产变多,建议换成 Addressables。

5. 避坑与常见问题:那些让我重跑三遍的坑

5.1 行动条累积导致出手顺序错乱

现象:两个速度相同的单位,出手顺序每次不一样。

原因:ActionGauge.Tick里用deltaTime累积,帧率波动导致累积值有微小差异,排序时不稳定。

解决:在行动值相同时,用单位 id 做次级排序。或者在RoundStart时预计算一次出手顺序,回合内不再重排。我一般会在ActionGauge里加一个Priority字段,速度相同时按优先级排。

5.2 Buff 结算顺序影响伤害结果

现象:同一个技能,有时伤害高有时低。

原因:Buff 的OnTurnStart和OnTurnEnd执行顺序不确定,导致攻击加成 Buff 在伤害计算之后才生效。

解决:给 Buff 加Priority字段,TickTurn时按优先级排序执行。增益类 Buff 优先级设高,在伤害计算前生效;持续伤害类设低,在行动后结算。这份源码里BuffConfig已经有priority字段,但默认都是 0,需要手动配。

5.3 AI 在无可用技能时死循环

现象:战斗卡住,AI 单位不行动。

原因:AI 的所有技能都在冷却,GetAvailableSkills返回空列表,ChooseAction里First()抛异常。

解决:在ChooseAction开头判断,如果没有可用技能,返回一个「跳过回合」的默认行动。常见做法是加一个SkipTurnSkill,不消耗资源、无效果,只用来占位。

5.4 伤害飘字位置偏移

现象:飘字出现在屏幕角落而不是单位头顶。

原因:WorldToScreenPoint用的是主相机,但 UI 用的是 Overlay 模式,坐标系不一致。

解决:把伤害飘字的父节点设为 UI 根节点,用RectTransformUtility.WorldToScreenPoint转换坐标。或者把 UI 改成 Screen Space - Camera 模式,和主相机绑定。

5.5 战斗回放结果不一致

现象:同一场战斗,录制的操作回放后结果不同。

原因:用了Random.value做暴击判定,回放时随机种子不同。

解决:把随机数生成器封装成可播种的BattleRandom,战斗开始时用固定种子初始化。所有随机判定都走这个实例,回放时用相同种子就能复现。这份源码目前用的是全局随机,二次开发如果要加回放功能,这是必须改的地方。

6. 二次开发:从改数值到加新机制的实操路径

6.1 最快上手:改配置不碰代码

如果你只是想调数值、加角色、加技能,完全不用动 C# 代码。流程是:

  1. 在Resources/Units右键 → Create → Game → UnitConfig,填 id、血量、攻击、防御、速度
  2. 在Resources/Skills右键 → Create → Game → SkillConfig,选目标选择器,拖入效果资产
  3. 把技能 id 填到角色的skillIds列表
  4. 在BattleConfig里把新角色加进阵容

效果资产也是 ScriptableObject,DamageEffect填倍率,BuffEffect填 Buff 配置和层数。这套数据驱动的好处是策划能自己配,程序不用陪着加班。

6.2 加新技能效果:继承 SkillEffect

要加一个「吸血」效果,新建一个类:

[CreateAssetMenu(menuName = "Game/SkillEffects/LifeSteal")] public class LifeStealEffect : SkillEffect { public float stealRatio = 0.3f; // 吸血比例 public override void Apply(Unit caster, Unit target, BattleContext ctx) { // 从上下文取本次伤害值(由 DamageEffect 写入) float damageDealt = ctx.GetLastDamage(target); float healAmount = damageDealt * stealRatio; caster.Heal(healAmount); } }

关键点是BattleContext要能拿到本次技能造成的伤害。常见做法是在DamageEffect.Apply里把伤害值写进ctx.lastDamage,后续效果按顺序读取。效果列表的执行顺序就是资产里拖拽的顺序,吸血效果必须放在伤害效果后面。

6.3 加新 AI 策略:扩展评分函数

想加一个「优先攻击防御最低的敌人」策略,改Evaluate方法:

// 在 Evaluate 里加一段 if (skill.HasEffect<DamageEffect>()) { float targetDef = target.GetAttribute(AttributeType.Defense); float lowestDef = enemies.Min(e => e.GetAttribute(AttributeType.Defense)); if (Mathf.Approximately(targetDef, lowestDef)) score += 50f; // 打防御最低的加分 }

权重值建议做成配置字段而不是硬编码,这样调 AI 行为不用重新编译。我一般会在AIDecisionConfig里放一个Dictionary<string, float>,把「收割权重」「治疗权重」「集火权重」都做成可调参数。

6.4 验证改动是否生效

改完代码或配置后,跑一遍战斗,重点看三处:

  • Console 有没有报错或警告
  • 行动条顺序是否符合预期
  • 伤害数字是否和手算一致

我习惯在DamageEffect里加一行Debug.Log,打印攻击、防御、减免、最终伤害,方便对账。上线前把这些日志用#if UNITY_EDITOR包起来,避免性能开销。

6.5 一个我踩过的坑

有次我加了个「反击」Buff,想让单位受击后自动反击。结果发现反击触发的伤害又触发了对方的反击,无限递归直接栈溢出。后来在BattleContext里加了个isCounterAttack标记,反击伤害不再触发反击,才解决。

从那以后我每次加「触发型」效果,都强制走一遍「会不会自己触发自己」的检查。回合制游戏的触发链很容易绕成环,加个标记位或者深度限制是最省事的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进