简介:这是一份基于Unity3D与GameFramework框架开发的战棋类生存经营游戏完整工程,面向想学习策略回合制游戏架构、准备毕业设计或需要实战案例的Unity开发者。项目实现战斗地图、角色行动、回合结算,以及资源收集、基地建设、角色成长等玩法,并通过框架的消息系统、事件管理、资源加载与UI服务组织代码,降低模块间耦合,适合作为扩展开发的基础。压缩包共1373个文件,包括422个C#脚本、42个Prefab预制体、72个PNG贴图、25个DAT数据文件以及一组asset、xml、json配置,整体约57.89MB。目录按场景、脚本、资源、UI、数据配置划分;脚本中覆盖队列管理行动顺序、状态机处理角色状态等典型算法应用,便于直接研读核心逻辑。已有852人学习下载。对想借鉴GameFramework框架完整用法、或需要从零搭建战棋类生存经营游戏的开发者而言,这份资源可直接导入运行,也能帮助理解Unity项目从战斗计算到资源配置的整体组织方式。
1. 战棋生存项目的本质:Unity3D做表现,GameFramework管秩序
你拿到的这个压缩包,标题写得很直白:Unity3D 做客户端表现,GameFramework 做项目骨架,里面跑的是战棋回合战斗加生存经营两条循环。第一次用 Unity 打开这类工程的人通常会先懵住——Assets 下十几层目录,数据表、流程、实体、对象池堆在一起,像进了一个陌生框架的黑匣子。这篇笔记就是按这套组合拳展开的:先看懂骨架,再把战棋回合、生存数值、建筑经营分别接进 GameFramework 的对应模块,最后把最容易翻车的五个坑铺开讲。适合已经写过 Unity 脚本、想认真组织一个中等体量玩法项目的中级开发者;新手跟着步骤也能把一个现成的 GF 战棋项目跑起来并改得动。
2. 读工程项目骨架:GameFramework 的模块拆法和战棋生存的对应关系
拿到一个用 GameFramework 组织的 Unity 工程,我一般不会直接点 Play,而是先把目录结构读一遍。GF 把所有通用能力拆成了模块:Procedure 管流程、DataTable 管配置、Entity 管场景对象、Fsm 管状态切换、ObjectPool 管复用、Resource 管加载、UI 管界面。战棋生存游戏恰好每一块都用得上:流程对应“主菜单→战斗→经营→结算”,数据表对应兵种和事件表,实体对应单位与建筑。
2.1 解压后先看四类目录:流程、数据、实体、工具
典型做法是下面这样一个目录骨架,压缩包里大概率也是类似结构:
Assets/ ├─ GameMain/ │ ├─ Procedure/ # 游戏主流程:启动、加载、主循环、结算 │ ├─ DataTable/ # 兵种、技能、道具、事件表(.txt) │ ├─ Entity/ # 单位、建筑、飘字等 EntityLogic 子类 │ ├─ Fsm/ # 回合状态机、AI 行为状态机 │ ├─ UI/ # UIForm 逻辑(战斗界面、经营面板) │ └─ Config/ # 全局配置项 ├─ GameFramework/ # 框架源码,通常不直接改 └─ StreamingAssets/ # 可更新资源、视频、语音为什么要先读这里?战棋骨架里最常见的问题不是逻辑写错,而是把玩法逻辑散落在各个 MonoBehaviour 的 Update 里。GameFramework 的思路是把“游戏该干什么”变成一串 Procedure——每个 Procedure 是一个流程状态,GameMain 里默认从 ProcedureLaunch 开始,经过初始化资源、加载数据表,最后进 ProcedureMain。做战棋生存游戏,我会把大流程拆成“标题→战役选择→进入营地(经营)→进入战斗→战斗结算→回营地”,每个环节一个 Procedure,切换用 ChangeState 而不是在场景里挂一堆开关。
读目录时顺手做一件事:在 Unity 里找 Game Framework 组件上那个 Debugger 开关,运行时按 ~ 键能弹出调试窗口,直接看对象池占用、实体数量、数据表行数和当前 Fsm 状态。这个窗口后面排错会反复用,先确定它在工程里是开着的。
提示:GF Debugger 默认热键是 ~ 键(数字 1 左边那个),在部分键盘布局下会和其他快捷键冲突,入场时先改掉。
2.2 数据表驱动:把兵种数值和生存事件从代码里拆出去
战棋和生存经营这两类玩法的共同点是“数值特别多”:兵种的血量、移动力、攻击范围,食物的增益,天数事件的概率,都要反复调平衡。写死在代码里等于每次调数值都要重新出包,所以 GameFramework 工程的主流做法是数据表驱动。GF 的 DataTable 模块读取按 Tab 分隔的 txt(从 Excel 导出),每一行由自定义的 IDataRow 解析。下面是我常用的兵种行定义:
// BattleUnitRow.cs —— 对应 DataTable/BattleUnit.txt 的一行 public sealed class BattleUnitRow : IDataRow { public int Id { get; private set; } public string Name { get; private set; } public int Hp { get; private set; } public int MoveRange { get; private set; } // 移动力:BFS 层数上限 public int AttackRange { get; private set; } // 攻击范围:默认 1(近战) public int ActionPoint { get; private set; } // 每回合行动点 public string PrefabName { get; private set; } // 对应 Entity 资源路径 public bool ParseDataRow(string dataRowString, object userData) { string[] cols = dataRowString.Split('\t'); if (cols.Length < 7) return false; // 列数不够直接拒掉 Id = int.Parse(cols[0].Trim()); Name = cols[1].Trim(); Hp = int.Parse(cols[2].Trim()); MoveRange = int.Parse(cols[3].Trim()); AttackRange = int.Parse(cols[4].Trim()); ActionPoint = int.Parse(cols[5].Trim()); PrefabName = cols[6].Trim(); return true; } }解析逻辑有两点要强调:第一,Split 用的是 '\t',任何一列里混进空格都会让 Parse 失败,所以 Trim 是必须的;第二,列的顺序必须和表头一致,我见过太多“Excel 里加了一列,忘了改 C# 解析”导致的读表全错。加载通常在流程里做:
// 在某个 Procedure 的 OnEnter 里统一加载所有表 private DataTable<BattleUnitRow> m_UnitTable; protected override void OnEnter(ProcedureBase procedureBase) { base.OnEnter(procedureBase); m_UnitTable = GameEntry.DataTable.GetDataTable<BattleUnitRow>(); if (m_UnitTable == null) { m_UnitTable = GameEntry.DataTable.CreateDataTable<BattleUnitRow>(); m_UnitTable.LoadDataTable("Assets/GameMain/DataTable/BattleUnit.txt", this); } }这里 CreateDataTable 之后必须紧跟着 LoadDataTable,否则表是空的。加载失败时 GF 会打 Error 日志并带行号,排错就先去 Console 看日志,别靠猜。
2.3 把单位做成 Entity:对象池对战棋不是优化,是底线
战棋一局可能同时存在几十个单位加十几栋建筑,每次生成都 Instantiate、结束又 Destroy,很快就会出现 GC 卡顿。GF 的 Entity 模块把“显示”和“数据”分离:GameEntry.Entity.ShowEntity 负责从对象池取实例,HideEntity 负责回收到池子。单位逻辑类继承 EntityLogic:
// BattleUnit.cs —— 所有战场单位(我方、敌方、NPC)共用的实体逻辑 public class BattleUnit : EntityLogic { public int UnitId { get; private set; } public int GridX { get; private set; } public int GridY { get; private set; } protected override void OnInit(object userData) { base.OnInit(userData); UnitData data = userData as UnitData; if (data == null) { Log.Error("BattleUnit 初始化失败:userData 不是 UnitData"); return; } UnitId = data.UnitId; GridX = data.GridX; GridY = data.GridY; transform.position = BattleGrid.CellToWorld(GridX, GridY); } protected override void OnRecycle() { base.OnRecycle(); // 回到池子前清干净运行时状态,否则下一局复用会带残留 UnitId = 0; GridX = 0; GridY = 0; } }调用方只负责提供数据和编号:
GameEntry.Entity.ShowEntity<BattleUnit>( unitEntityId, // 逻辑编号,用于 HideEntity 引用 unitRow.PrefabName, // 实体资源路径 new UnitData(unitRow.Id, gridX, gridY)); // userData,OnInit 里接收这里最容易踩的坑是:ShowEntity 的第二个参数不是 Prefab 本身,而是资源路径,路径写错时 GF 不会崩溃,只打一条加载失败日志,单位就“凭空消失”。开局前把所有 PrefabName 在编辑器里核对一遍,路径和大小写都要一致。
3. 回合系统落地:FSM管流程、BFS算移动、数据表定技能数值
战棋的“回合”是整个玩法的心脏。把回合做成一个有限状态机,比在 Update 里写分支清晰得多:玩家操作回合、敌方 AI 回合、结算回合三态循环,任何时刻系统只可能处于其中一个状态。
3.1 用 GameFramework 的 Fsm 模块搭玩家回合、敌人回合、结算回合
GF 的 Fsm 模块支持泛型状态机,状态类继承 FsmState 。回合状态机挂在场景里一个单例组件上:
// TurnFsm.cs —— 持有回合状态机的组件 public class TurnFsm : GameFrameworkComponent { private IFsm<TurnFsm> m_TurnFsm; public void Init() { m_TurnFsm = GameEntry.Fsm.CreateFsm<TurnFsm>("TurnFsm", new PlayerTurnState(), // 玩家操作 new EnemyTurnState(), // 敌方行动 new SettleTurnState()); // 天结算(对应生存循环) m_TurnFsm.Start<PlayerTurnState>(); } public void ChangeToEnemy() => m_TurnFsm.ChangeState<EnemyTurnState>(); public void ChangeToSettle() => m_TurnFsm.ChangeState<SettleTurnState>(); }玩家回合状态类要处理“谁可以动、怎么选目标、怎么确认”:
// PlayerTurnState.cs public class PlayerTurnState : FsmState<TurnFsm> { protected override void OnEnter(IFsm<TurnFsm> fsm) { base.OnEnter(fsm); // 进入玩家回合:恢复我方单位行动点、重置高亮 GameEntry.UI.OpenUIForm(UIFormId.BattleActionBar, this); } protected override void OnUpdate(IFsm<TurnFsm> fsm, float elapseSeconds, float realElapseSeconds) { base.OnUpdate(fsm, elapseSeconds, realElapseSeconds); if (Input.GetKeyDown(KeyCode.Return)) // 结束回合 { fsm.ChangeState<EnemyTurnState>(); } } }注意 OnUpdate 里不要做任何耗时操作,回合切换的判断只需要一个按钮或快捷键。有个细节:ChangeState 不能在 OnEnter 里马上调用(同一帧内会触发断言),所以“开局直接进入敌方回合”这种需求要把切换放到 OnUpdate 的第一帧判断里。
3.2 移动范围不是距离,是步数:一层 BFS 就够
战棋玩家看到的是角色脚下那片格子“范围”,背后是图论里的可达性问题。最常见的实现是 BFS,步数上限就是移动力。直接把移动范围算出来,再让特效沿范围边缘高亮:
// BattleGrid.cs —— 四方向格子的移动范围计算 public static HashSet<Vector2Int> GetMoveRange(int[,] costMap, Vector2Int start, int moveRange) { var result = new HashSet<Vector2Int>(); var visited = new HashSet<Vector2Int>(); var queue = new Queue<(Vector2Int pos, int cost)>(); queue.Enqueue((start, 0)); visited.Add(start); int[] dx = { 1, -1, 0, 0 }; int[] dy = { 0, 0, 1, -1 }; while (queue.Count > 0) // 用队列迭代,别用递归 { var (pos, cost) = queue.Dequeue(); result.Add(pos); for (int i = 0; i < 4; i++) { Vector2Int next = new Vector2Int(pos.x + dx[i], pos.y + dy[i]); if (!IsInsideMap(next)) continue; int nextCost = cost + costMap[next.x, next.y]; // 地形消耗 if (nextCost > moveRange) continue; if (visited.Contains(next)) continue; visited.Add(next); queue.Enqueue((next, nextCost)); } } return result; }这里用队列迭代代替递归,原因很实际:战棋地图 30×30 时递归调用深度不大,但地图上百格后递归容易爆栈,而且调试时栈信息根本没法看。costMap 里 1 是平地、2 是丛林、3 是沼泽,不同移动力的单位自然算出不同范围。六边形地图就把 dx/dy 换成正六边形邻居表,算法本身不用动。
3.3 行动点和技能范围:这些参数放表里,不放代码里
战斗公式和技能参数是平衡调整最频繁的部分。把下面这些列作为 BattleUnit.txt 的扩展,调数值时改 Excel 重新导出就行,不需要碰代码:
| 列名 | 含义 | 取值示例 |
|---|---|---|
| AttackRange | 攻击距离(格) | 1 近战 / 2 远程 |
| ActionPoint | 每回合行动点 | 2 |
| MoveRange | 移动力 | 4 |
| CostHunger | 每天消耗饱食 | 12 |
| SkillId | 指向 Skill.txt 的武器技能 | 101 |
回合开始时按表格恢复行动点,这是我认为最不容易漏的做法:
// 回合开始:按表恢复每个单位的行动点 foreach (var entity in GameEntry.Entity.GetEntityGroup("BattleUnit").GetAllEntities()) { var bu = (BattleUnit)entity.Logic; var row = m_UnitTable.GetDataRow(bu.UnitId); if (row == null) continue; bu.ResetActionPoint(row.ActionPoint); }行动点的恢复时机放在 PlayerTurnState.OnEnter,因为“每个我方回合开始”是一个明确的时机点;放在 Update 里判断“是否跨回合”很容易出现重复加点或漏加。技能迭代也走同一张 Skill.txt,增行不动代码,平衡性调整的成本就压到了最低。
4. 天数和资源循环接入:生存经营和战斗共用一套实体体系
战棋解决“一场仗怎么打”,生存经营解决“这些仗之间怎么活”。天数推进、饱食下降、木材积累、建筑解锁,这些循环和战斗不是两套代码,而是同一套 Procedure + DataTable + Entity 的另一种用法。
4.1 饱食、天数、木材:运行态数值归管理器,配置归表
生存数值分两类:初始值和每回合变化量。初始值(开局 100 饱食、第 1 天)放 Config 或常量可以接受;每回合变化量(每天 -12 饱食)放进数据表,因为设计上要频繁调。运行中的当前值放在一个全局管理器里:
// SurvivalManager.cs —— 生存数值的运行时单例 public class SurvivalManager : GameFrameworkComponent { public int Day { get; private set; } = 1; public int Hunger { get; private set; } = 100; public int Wood { get; private set; } = 20; // 每天凌晨结算一次,由 SettleTurnState 调用 public void DailyTick() { Day++; Hunger -= 12; // 每日消耗,后续可以改读表 Wood += GetWoodPerDay(); // 产出来自建筑和工作分配 if (Hunger <= 0) OnStarve(); // 饥饿事件:扣血或减员 Hunger = Mathf.Clamp(Hunger, 0, 150); GameEntry.Event.Fire(this, new DayChangedEventArgs(Day, Hunger, Wood)); } }数值管理器不要塞 Entity 里,实体可能在战斗结束被回收;放在 GameFrameworkComponent 子类上,切换场景也不会丢。界面刷新用事件:DayChanged 事件触发后,UI 面板统一 Update,不要在 UIForm 的 OnUpdate 里轮询数值,否则每次数值变化还要手动去找谁在订阅。
4.2 建造物也是 Entity:经营和战斗共用同一个对象池
营地里的伐木屋、农田、仓库,本质上和战场单位一样是实体。把它们注册为 Entity 的好处直接可见:对象池复用、资源路径统一、显隐由 GF 管理。建筑逻辑类只需要扩展“每回合产出”一个行为:
// BuildingLogic.cs —— 营地建筑实体 public class BuildingLogic : EntityLogic { public int BuildingId { get; private set; } private BuildingRow m_Row; protected override void OnInit(object userData) { base.OnInit(userData); BuildingData data = userData as BuildingData; BuildingId = data.BuildingId; m_Row = GameEntry.DataTable.GetDataTable<BuildingRow>().GetDataRow(BuildingId); transform.position = data.Position; } public int GetDailyOutput() => m_Row == null ? 0 : m_Row.OutputPerDay; }战斗进场时,把营地建筑 HideEntity 掉、单位 ShowEntity 出来;回营地反过来。同一个实体管理方式贯穿两个玩法,内存开销好预估,读代码的人也不会在两套系统之间反复横跳。要注意的是:OnRecycle 只清运行时状态,配置引用不用清;所以新建筑的 BuildingRow 初始化必须放 OnInit,而不是 Awake——实体从池子里复用回来,Awake 不会再次触发。
4.3 过场演出和视频流:讲故事时别在 Update 里轮询
生存经营游戏经常有“第 5 天晚上来了暴风雪”这种事件演出,开场和关键节点还要播一段视频流。做法是把它做成独立的 Procedure 或 UIForm。如果工程里用 VideoPlayer 播视频,我一般把播放器挂在一个专用的 UIForm 或相机节点上,进过场时打开、播完触发播放结束回调再关闭:
// ProcedureStory.cs —— 事件过场流程,播完自动切回主流程 public class ProcedureStory : BaseProcedure { protected override void OnEnter(ProcedureBase lastProcedure) { base.OnEnter(lastProcedure); var storyForm = GameEntry.UI.GetUIForm(UIFormId.StoryForm); storyForm.Play("Assets/GameMain/Story/storm_clip.mp4", OnStoryFinished); } private void OnStoryFinished() { // 切回经营主流程,注意用 ChangeState,不要重新加载场景 ChangeState<ProcedureCamp>(); } }关键点是不要用一个循环在 Update 里检测视频播放进度然后手动切场景。原因很简单:Update 里的轮询无法妥善处理暂停、切后台、用户点击跳过这些交互,最后必然要靠事件回调统一收口。视频流资源和语音资源也建议统一放 StreamingAssets,走 GF 的 Resource 模块按需加载,不要做成一直挂在场景里的常驻节点。
5. 战棋生存项目的五个易翻车点:现象、原因、对策
代码到一个阶段后能跑起来,但“能跑”只是开始。这几条是我在 GF 工程里实际踩坑的记录,每一条都按现象、原因、对策排开,照着排查能省一整个晚上。
5.1 数据表全读出 0 或解析抛异常
现象:Console 刷“ParseDataRow failed”,兵种全是默认值,单位都不显示。 原因:最常见是 Excel 导出 txt 时带了 UTF-8 BOM,第一列 Id 解析出乱码;其次是加了列没改 C# 解析,列数对不上。 对策:数据表统一用“UTF-8 无 BOM”导出;解析函数开头先打一行 Debug.Log 看原始字符串,用 Notepad++ 之类的工具把文件转成无 BOM 格式。改完表要重新进 Play 模式,DataTable 有缓存,编辑器里的热重载不一定生效。
5.2 打到第三天开始一卡一顿
现象:开场流畅,越往后操作延迟越明显,帧时间从 30 帧掉到不到 20 帧。 原因:伤害飘字、移动范围高亮、建造预览这些零散对象直接 Instantiate,每回合生成两三百个 GameObject 再 Destroy,实例化和 GC 开销把帧时间拖垮。 对策:把所有临时对象统一走 GF 的 ObjectPool 或注册成 Entity。我给自己定了一条规则:凡是“出现在场景里、存活时间短”的对象,一律不许自己 Instantiate。排查时打开 GF Debugger 的对象池面板,看哪些预制体的驻留数量在持续上涨,一抓一个准。
5.3 点完结束回合,AI 卡死主线程
现象:点“结束回合”后游戏假死几秒,严重时直接无响应。 原因:AI 找最优目标时写了递归搜索,地图格子多、递归无上限;循环里还打了 Debug.Log,打印本身在每帧几千次时就能把主线程卡住。 对策:搜索一律改成 BFS 或迭代加深,并且加节点上限——战棋 AI 不需要搜索全图,限制在“移动范围 + 攻击范围”的重叠格内就够。Debug.Log 在性能热点里要么删掉,要么包一层条件编译宏,发布版本直接剔除。
5.4 热更新后真机提示找不到程序集
现象:加了热更新后,部分数据表驱动的类在真机上“消失”,编辑器里一切正常,看起来像玄学。 原因:GameFramework 本身只负责资源和流程管理,C# 逻辑热更常见是配 HybridCLR 这类方案。真机报缺失程序集,通常是 AOT 泛型元数据没补齐,或者热更程序集没打进出包配置。 对策:检查工程的 AOT 泛型补充列表,把用到的泛型类型(比如 DataTable 、IFsm )显式引用一遍;再确认热更 dll 确实落进了 StreamingAssets。这类问题不同版本表现很不一样,先看日志里缺失的类型名,再对着类型名找补漏,别全量加引用。
5.5 读档回来回合直接卡住或重复行动
现象:存档后再读档,敌人站在原地不动,回合切换失灵,或者单位上一个行动没结算完。 原因:存档时只保存了实体位置和数值,没保存 Fsm 的当前状态;读档后实体按存档重建,状态机却停在旧时刻,两边对不上。 对策:存档里显式写 TurnFsm 当前状态名,读档后先 ChangeState 强制切到正确状态,再恢复实体;实体统一按 UnitId 重建,不要保留旧引用。顺序错一步,后面所有回合判断都会跟着错。
6. 自查清单:用调试器、命令行和 Profiler 做一次项目体检
改完一轮之后,别急着说“能跑”。我给自己定了个体检流程,顺序固定,十分钟能走完。
第一步,跑一遍命令行冒烟。Unity 支持 batchmode 启动工程并执行指定静态方法,适合做“打开主场景、初始化数据表、模拟一回合”的自动化验证:
Unity.exe -batchmode -projectPath D:/TacticsSurvival -executeMethod SmokeTest.RunOneTurn -quit对应脚本里做的事很简单:加载主场景,等一帧让 ProcedureMain 起来,调用 TurnFsm.ChangeToEnemy,再检查敌方单位是否都执行了移动。能走到最后没有 Error 日志,就算过了第一关。
第二步,看 GF Debugger。运行时按 ~ 打开调试器,依次看三块:ObjectPool 的数字是否平稳不持续上涨、DataTable 的行数与配置表一致、Fsm 当前状态是否总在预期三态里。这三个数字比任何“感觉流畅”都可靠。
第三步,用 Profiler 抓一局完整战斗。关注 GC Alloc 每帧是否平稳,以及 Instantiate/Destroy 的调用次数。只要看到这两个函数出现在热点里,说明还有对象没走对象池,优先级最高——战棋单位多,这个问题会随着关卡推进指数级放大。
最后说个我的习惯:在菜单里加一个“作弊面板”,一键加资源、跳天数、强制胜利。这不是给人用的,是给测试和调平衡用的。战棋生存游戏最耗时的是数值调整,没有这个面板,每次都要从第 1 天打到第 15 天才看得见调整效果。我第一个 GF 战棋项目就栽在这里:只做了关卡跳过,没做资源作弊,结果调一个食物消耗参数要反复重开,两天就这么没了。后来每个同类项目都先做作弊面板,这是我最确定的后悔药。
体检工具齐了,剩下就是一件事:把这个工程项目当成自己的来改,从改一个兵种数值开始,到加一个天气事件,再到把整条经营循环接起来。希望帮到你。
本文还有配套的精品资源,点击获取