Unity3D GameFramework:战棋生存经营游戏架构与实现
简介这套Unity3D结合GameFramework框架实现的战棋类生存经营游戏工程面向学习回合制策略与经营玩法的开发者可用作研究战斗逻辑、资源管理、UI交互及框架集成的完整参照。包体共1373个文件其中422个C#脚本负责核心玩法逻辑72个PNG贴图、42个Prefab预制体与17个Asset资源支撑场景表现另有25个Dat、8个XML与4个JSON数据文件保存角色、物品与规则配置整包约57.89MB目录结构覆盖场景、脚本、资源、UI与配置等常见模块。压缩包内还包含字体、音频及版本仓库配置等辅助文件便于了解Unity工程整体环境。已有852人学习本工程适合对照源码梳理GameFramework的事件消息、资源加载与实体管理方式也可从中拆解回合制行动顺序、状态机切换和生存经营数值设计。直接查看工程可快速理解Unity项目的分层方式对进阶框架使用或课程设计均有参考价值。1. 只有文件名者很容易高估它只看文件名使用Unity3D基于GameFramework框架开发的战棋类生存经营游戏.zip 很容易被当成“回合制战斗加一套资源数值”的简单拼装。真跑起来之后多数团队才会发现战棋和经营各自都不难难的是“走一步棋”和“过一天”在同一套框架里不打架。GameFramework 的价值在于把流程、实体、数据表、UI 这些公共骨架固定下来让战斗和经营长在同一棵树上但框架不会替你解决节奏耦合也不会替你想清楚一次战斗该消耗多少食物。这篇文章按“先立架构、再写战棋、再写经营、最后排坑”的顺序把这类工程最常见的落地方案、参数设置和踩坑点讲透。适合正在改造同类工程包的开发者也适合打算立项选型的中小团队。下文所有代码按 GF 的常见写法给出数据结构可以按你手里的包自由调整但架构边界建议不要动。2. 先立框架再写玩法GameFramework 在战棋与经营里的模块划分和初始化顺序2.1 为什么选 GameFramework实体、流程、数据表刚好覆盖两类玩法的公共骨架战棋类游戏最核心的对象是“站在格子上的单位”。单位有移动、攻击、待机三种行为地图需要三张独立信息阻挡、地块类型、可采集资源。生存经营最核心的对象则是“资源与建筑”资源每天产出、每天消耗、存量随时间变化。这两套系统在代码上天然需要一个共同的底盘单位要有清晰的生命周期上场、行动、退场要可接管资源要有配置表例会刷新回合要有状态流转UI 与战斗要能互相触发。GameFramework 的模块划分恰好卡在这些点上。它没有把更多业务规则硬塞给开发者而是把流程、实体、数据表、事件、引用池这些基础能力分开。战棋的单位、地图块、战斗特效可以全部走实体系统单位的显隐回收交给对象池经营侧的数值不是写在 MonoBehaviour 里而是写在数据表里策划能直接改一局战斗从头到尾的“玩家阶段→敌人阶段→结算阶段”就是流程系统最典型的用法。选型理由总结下来有三条。第一实体组件自带生命周期方法棋子的出场、退场、入队不需要自己写一套对象管理器不容易出现“场上单位已销毁但列表还引用着”的悬空问题。第二数据表组件支持把单位属性、地形消耗、经营资源参数全部外置成表格改数值不动代码。第三流程组件天然适合回合制玩家回合和敌人回合实现为子流程状态边界比在 Update 里用布尔开关清晰得多。还有一条常被忽略的好处GF 内置引用池与对象池战棋里反复刷新的单位、飘字、攻击特效都是高频显隐对象这一层省下来的 GC 压力在手机上非常明显。2.2 从 zip 到可运行目录约定、程序集入口与自定义流程注册拿到这类工程包我不建议先看战斗脚本。第一步是找框架入口和流程注册。GF 在工程里通常表现为一组基础模块组件挂在一个入口 GameObject 上业务侧继承 ProcedureBase 写自己的流程。目录结构上建议把 Battle、Survival、Data、UI 分成独立目录Framework 目录保持不动。这样后续替换框架版本时只动 Framework 目录业务代码不受影响。如果框架脚本和业务脚本混在一起每次调试都要在几千行代码里分辨模块归属维护代价会越来越高。一个最小可运行的流程注册长这样using GameFramework.Procedure; using UnityEngine; public sealed class ProcedureLaunch : ProcedureBase { protected override void OnEnter(ProcedureBase prevProcedure) { base.OnEnter(prevProcedure); ChangeStateProcedureLoadData(); } }ChangeState 是流程基类提供的状态切换入口泛型参数填目标流程类型。OnEnter 在进入该流程时调用一次适合做初始化OnUpdate 每帧调用适合做轮询OnLeave 在离开前调用适合做清理。把 Launch 单独抽出来而不是直接进入主菜单是为了给加载数据表、初始化存档留一个确定性的顺序先流程、再数据、再玩法避免出现“战斗已经开始了某张表还没加载完”的时序问题。更完整的做法是再写一个 ProcedureLoadData 专门加载数据表加载完再进入主菜单。所有表的加载都收敛在同一个流程里。我见过不少工程把 LoadDataTable 直接写在某个 UI 脚本里一旦 UI 被提前关闭后续取表全部空引用。规范做法是由流程驱动数据表加载不由 UI 驱动。耗时操作不要直接卡在 OnEnter 里可以在 OnEnter 发起加载请求OnUpdate 检查进度加载完再 ChangeState。提示GF 的流程切换在同一帧内完成不要在 ChangeState 之后立刻访问目标流程的运行时数据目标流程的 OnEnter 可能还没执行。2.3 数据表驱动单位属性与生存资源用同一套配表体系GF 的数据表组件用起来有几个固定动作定义 DataRow 子类、准备数据表内容、由流程加载、运行时按 Id 读取。战棋里的单位、地形消耗、敌人波次生存经营里的建筑、资源道具全都可以走同一套配表。好处是数值工作流统一策划只需要维护表坏处是列顺序或字段映射一旦出错运行时报的是转换异常而不是明明白白的“找不到字段”。单位表的 DataRow 可以这样写using GameFramework.DataTable; public sealed class UnitRow : IDataRow { public int Id { get; private set; } public int MoveRange { get; private set; } public int AttackRange { get; private set; } public int Attack { get; private set; } public int MaxHp { get; private set; } public int CostFood { get; private set; } // 招募后每天维护消耗的食物 public bool ParseDataRow(string[] columns) { Id int.Parse(columns[0]); MoveRange int.Parse(columns[1]); AttackRange int.Parse(columns[2]); Attack int.Parse(columns[3]); MaxHp int.Parse(columns[4]); CostFood int.Parse(columns[5]); return true; } }ParseDataRow 的入参是字符串数组顺序必须和数据表表头完全一致。任何人改动表列顺序后这里最容易翻车。Id 是约定字段GF 的数据表以 Id 为主键定位行。CostFood 是我额外加的字段把战棋单位的维护费和经营资源直接挂钩。运行时取数通过数据表组件按泛型类型获取拿到后按 Id 查行不要在每次访问时重新解析字符串。数据表本体推荐用 JSON 或 CSV 维护运行时转成二进制。开发期用文本表方便热改打包时把二进制表打进 AssetBundle。这里有一个常见误区有人为了让“方便”最大化把表内容在运行时按字符串动态解析结果每次访问都在切割字符串和转型单位一多帧率直接掉。GF 在加载时会把整表解析进内存运行时按 Id 索引战棋一回合里频繁读取单位属性时这个特性很关键。[ { Id: 1, Name: 侦察兵, MaxHp: 60, MoveRange: 5, AttackRange: 1, Attack: 12, CostFood: 2 }, { Id: 2, Name: 弓手, MaxHp: 40, MoveRange: 3, AttackRange: 3, Attack: 15, CostFood: 3 } ]列名严格对应字段名Id 不要重复。MoveRange 与 AttackRange 的数值要匹配地图尺寸20×20 的地图上侦察兵的 5 格移动力意味着探索面积接近地图的四分之一配表时先定地图再定单位数值顺序反了会出现“一整局都在赶路”的体验问题。3. 战棋系统落地格子地图、寻路与回合状态机的实现细节3.1 地图数据建模阻挡、地块类型与移动消耗分开存战棋地图最容易犯的错是把“地块类型”和“是否阻挡”混在一个字段里。实际上这两个信息变化频率不同阻挡信息在一整局里几乎不变地块类型决定移动消耗和战术价值森林减移动力加闪避草地消耗 1道路消耗 0.5。把地图拆成三个数组分别存储会更清晰阻挡数组、地形消耗数组、资源点数组。寻路只看前两个采集只看第三个互不干扰。地图格的数据结构可以这样定义using UnityEngine; public enum TileTerrain { Plain 1, Forest 2, Road 3, Water 4 } public sealed class TileData { public int X; public int Y; public bool Blocked; // 是否完全阻挡通行 public TileTerrain Terrain; // 地形类型 public int MoveCost; // 移动消耗 public int ResourceId; // 0 表示该格没有资源点非 0 是经营资源表 Id }Blocked 用于水面和障碍物Terrain 用于表现和战斗内加成MoveCost 是最终消耗。不要把 MoveCost 的取值分散在各种 switch case 里直接由表格给值效率更高也更方便策划调整。ResourceId 是接经营侧的关键字段战棋里踩到资源格触发采集事件本质上就是检查这个 Id 是否非 0。地图大小按格数设定而不是按像素这一层是纯数据层和任何渲染组件解耦。不要在地形枚举里掺 UI 信息比如“标红”这种选中状态应该由选中逻辑维护。如果你手里的包把地图数据和渲染网格耦合在一起后面加寻路高亮、攻击范围高亮会非常痛苦。我的习惯是战斗场景加载时把地图数据全部读入一个 MapData 对象所有算子只依赖它渲染层脚本去订阅地图事件刷新表现。这样寻路算法可以用纯 C# 做逻辑测试不用每次修改都开场景跑。3.2 移动范围和攻击范围用 BFS 而不是 A* 算可达格很多开发者一上来就写 A*但战棋里的两个高频需求——移动范围显示、攻击范围显示——本质上不是“求路径”而是“求一定步数内所有可达格”。A* 适合单个单位移动到目标格的最优路径范围计算是全图遍历BFS 天然按步数扩散直接算出一个可达集合性能可控。A* 留给点击目标格后的自动寻路动画范围计算统一用 BFS。一个基于移动力的可达格计算可以这样写using System.Collections.Generic; using UnityEngine; public static class RangeCalculator { public static ListVector2Int GetMoveRange(TileData[,] map, int startX, int startY, int movePower) { int width map.GetLength(0); int height map.GetLength(1); var visited new bool[width, height]; var queue new QueueVector2Int(); var remainDict new DictionaryVector2Int, int(); queue.Enqueue(new Vector2Int(startX, startY)); remainDict[new Vector2Int(startX, startY)] movePower; var result new ListVector2Int(); while (queue.Count 0) { var current queue.Dequeue(); int remain remainDict[current]; if (visited[current.x, current.y]) continue; visited[current.x, current.y] true; if (current.x ! startX || current.y ! startY) result.Add(current); Vector2Int[] dirs { new Vector2Int(1, 0), new Vector2Int(-1, 0), new Vector2Int(0, 1), new Vector2Int(0, -1) }; for (int i 0; i dirs.Length; i) { int nx current.x dirs[i].x; int ny current.y dirs[i].y; if (nx 0 || ny 0 || nx width || ny height) continue; if (map[nx, ny].Blocked) continue; int nextRemain remain - map[nx, ny].MoveCost; if (nextRemain 0) continue; var nextPos new Vector2Int(nx, ny); if (remainDict.ContainsKey(nextPos) remainDict[nextPos] nextRemain) continue; remainDict[nextPos] nextRemain; queue.Enqueue(nextPos); } } return result; } }逻辑说明queue 是待扩散的格子remainDict 记录走到每个格子时还剩多少移动力。每弹出一个格子检查四个方向目标格未被阻挡且剩余移动力足够时入队。visited 防止重复出队remainDict 保证一个格子不会被更低剩余移动力覆盖。攻击范围的计算方法完全一致只需要把 movePower 换成武器射程并忽略阻挡格近战射程 1弓手射程 3再在 UI 里把攻击范围红色高亮。参数调优有两个点。第一MoveCost 不要全部配成 1至少要有 1 和 2 两档否则“移动力越多走得越远”变成了“格子数越多走得越远”地形毫无战术意义。第二范围计算不要每帧调用只在选中单位、移动力改变、地形变化时触发一次并缓存结果。一帧里做几十次 BFS 在 20×20 地图上是毫秒级但到 100×100 又会变成卡顿来源缓存是必须的。3.3 玩家回合与敌人回合用流程状态机避免战斗逻辑散落战棋战斗流程是所有脚本最容易散落的地方。用布尔量 isPlayerTurn 加一堆 Update 判断前期跑得动加技能、加敌人 AI、加动画回调之后就是一场事故。更推荐把一场战斗拆成战斗流程子状态GF 的流程机制在这里复用性极好PlayerTurn、EnemyTurn、TurnResult 做成三个流程按序切换。using GameFramework.Procedure; public sealed class ProcedureTurnPlayer : ProcedureBase { private int selectedUnitId -1; protected override void OnEnter(ProcedureBase prevProcedure) { base.OnEnter(prevProcedure); // 打开单位指令面板高亮可行动单位 } protected override void OnUpdate(ProcedureBase prevProcedure, float elapseSeconds, float realElapseSeconds) { base.OnUpdate(prevProcedure, elapseSeconds, realElapseSeconds); if (UnitManager.AllUnitsFinished()) { ChangeStateProcedureTurnEnemy(); } } }关键是不要在每个单位脚本里各自调用 ChangeState而是由回合管理器统一收集“每个单位是否行动完”全部完成才切换。这样后续加入“待机也算行动完毕”“被控制单位跳过回合”时只需要在 UnitManager 里统一处理。敌人回合我一般拆成三步感知、决策、行动每步之间用延迟或事件驱动。一帧内所有敌人同时移动表现会乱加上延迟后也要处理“延迟期间敌人全部死亡”的边界。敌人 AI 写完最容易漏的是“没有可行动敌人”和“没有可达目标”两个分支。这两个分支必须直接结束敌人回合并切回玩家回合否则流程永远卡在敌人阶段。战斗结束后战斗流程向上层流程发一个事件由上层决定进入经营结算还是重复刷关。战斗只负责战斗经营只负责经营这个边界从架构上就要守住。4. 生存经营系统落地资源循环与战棋战斗的耦合点4.1 资源数值模型天数、人口、食物与建材的产出消耗生存经营的关键不是写几个 UI 数字而是明确资源模型的时间基准和产出消耗关系。战棋是一次行动一回合经营是一天一个结算两者天然不同步。建议只做一种硬时间基准全局天数。所有经营结算都挂在天数推进上战棋战斗表现为“探索一天”结束后返回大本营时自动推进若干天。这个设计比“战斗结束立即结算一次”更符合生存直觉也避免玩家反复刷同一场战斗导致数值失控。一张最小资源模型表可以这样配资源产出方式消耗方式与战棋的耦合点食物农田产出、战斗胜利奖励人口每日消耗、招募单位维护招募单位需要消耗食物库存木材采集地图资源点建筑升级、修复据点探索采集直接增加木材人口时间自动增长死亡、战损人口上限决定可出战单位数建材拆解地图建筑修复城墙、建造新设施影响据点防御间接影响战斗难度这张表真正起作用的点是资源是战棋行动的经济约束。食物库存为 0 时单位每天行动扣血木材不足时据点防御下降敌人袭击造成的损伤永久累积。经营失败会“涌入”战棋战斗让战斗更难而不是只在经营画面里弹一个失败提示。表配好后测试时改一天的食物产出就能调试整条循环不需要改代码。4.2 经营结算实现把每日结算挂在回合循环之外经营结算如果放在战棋流程内部会形成“一次战斗结算一次”的错误节奏。经营侧应该做成独立系统挂在全局流程下由天数推进触发。using UnityEngine; public sealed class SurvivalManager : MonoBehaviour { public int Day { get; private set; } public int Food { get; private set; } public int Wood { get; private set; } public int Population { get; private set; } public void AdvanceDay() { Day; int foodDelta FoodProduce - Population * PerCapitaConsume; Food Mathf.Max(0, Food foodDelta); Wood WoodProduce; // 触发每日事件资源不足提示、人口变化、敌人按强度渗透 } }AdvanceDay 是唯一允许修改天数的入口所有资源变动都以天为单位批量结算而不是每次拾取都单独入库。FoodProduce、PerCapitaConsume、WoodProduce 在进入新一天前从数据表刷新保证结算逻辑不读过期数值。计算顺序固定先生产、再消耗、最后截断到 0。资源不足提示不要在这个方法里直接弹 UI而是发事件交给界面层避免管理器持有 UI 引用UI 销毁后空引用崩溃。SurvivalManager 用 MonoBehaviour 是因为它需要响应全局流程事件但它的状态必须能被序列化。我惯例做法是加一个 ToSaveData() 方法返回一个纯数据对象存档时把 Day、Food、Wood、Population 全部写进 JSON。不要用 PlayerPrefs 散点写十几个字段时非常容易漏字段读档后数值对不上。4.3 战斗与经营的接口战前消耗、战中采集、战后奖励两个系统之间不要互相 new 对方的类而是通过事件和接口做松耦合。战棋里踩到资源点触发的不是“加 5 木材”的逻辑而是发一个 ResourceGained 事件携带 ResourceId 和数量由经营侧决定入库。战斗胜利同理发 BattleResult 事件。这样战棋和经营可以被分别替换和测试。实现上推荐定义一个桥接接口public interface ISurvivalBridge { int Food { get; } int Wood { get; } void AddResource(int resourceId, int amount); bool TryConsume(int resourceId, int amount); }战棋侧只依赖这个接口。战斗前的出兵消耗检查调用 TryConsume地图采集调用 AddResource战后奖励也调用 AddResource。接入时把实现该接口的 SurvivalManager 注册进游戏入口战斗场景通过框架的事件系统或容器取到它。后续把经营从整局结算改成战斗内生经营时战棋侧脚本一行不用动。耦合还有一个容易忽略的点经营侧的据点修复影响战斗初始状态。木材不足导致防御下降敌人波次进攻强度增加。这个值应该在战斗初始化时读取一次经营快照作为本场战斗的初始参数。不要实时读取经营侧每帧值否则战斗中资源变化反过来影响战斗判断出现“打一半兵力突然变强”的诡异现象。存档时战斗开始前的经营快照和战斗结束后的资源增量一起写入读档能完整恢复。5. 开发中的避坑记录数据表、UI 层级、对象池与回合卡死的排查5.1 数据表列名改动导致运行时类型转换报错现象工程在编辑器里正常运行把数据表的列顺序一调再跑就抛 FormatException 或 IndexOutOfRange。诡异的是报错点往往不在数据表加载处而在某个单位攻击时才炸。 原因ParseDataRow 按字符串数组下标取列列顺序变了但代码下标没同步改。表加载时 Id 正常不会立刻报错直到某个字段被转型才暴露。 解决给数据表加解析前自检。ParseDataRow 开头先检查 columns.Length 是否小于期望长度少了直接返回 false 并打日志。更省心的做法是用配表工具自动生成解析器不要手写下标映射。我自己的习惯是在数据加载流程的 OnUpdate 里加一个断言加载完必须能读到配置的第一行读不到就暂停流程并输出表名。5.2 UI 弹窗被棋盘实体遮挡渲染顺序与显示层级现象点击单位弹出属性面板没问题但弹窗半透明背景偶尔被棋盘上的选中小箭头或飘字盖住看起来像一层脏玻璃。 原因棋子飘字、选区特效往往用的是世界空间 Canvas 或单独渲染层和 UI Canvas 排序没对齐。GF 的实体层和 UI 层是两套体系实体默认在世界空间UI 在屏幕空间两者之间默认不互斥Canvas 的 Render Mode 一乱就穿插。 解决统一 Canvas 策略。所有战斗内浮字、血条用同一 CanvasSorting Order 按“棋盘网格0、单位10、飘字20、弹窗30”分配。实体上不要挂世界空间 Canvas如果血条必须跟随单位用单独 Sorting Layer并让弹窗的 Sorting Order 更高。调试时先分清是渲染顺序问题还是 Canvas 的 Raycast Target 未关闭导致的“能看见但点不到”后者不归渲染管。5.3 棋子反复显隐后内存上涨引用池对象没有正确回收现象一场战斗进出 10 次Profiler 里内存稳步上涨棋子对象只增不减但业务代码里明明每次退场都调了销毁。 原因GF 的实体通过引用池复用HideEntity 默认是回收对象而不是销毁。如果实体在 OnHide 里没有把绑定的组件引用清掉或者引用了某个不会释放的动态资源对象池里的残留引用会阻止 GC 回收整棵对象树。 解决在 EntityLogic 的 OnHide 中把 GameObject、Material、动态特效等引用置空。确认对象入池走 GF 的接口而不是直接 Destroy。检查方法是用 Memory Profiler 搜索棋子名称正常情况同时存在的数量等于“场上数量池内数量”等于累计创建数就说明回收路径断了。5.4 敌人回合卡死流程状态机的三个常见未考虑分支现象玩家单位全部行动完进入敌人回合后画面静止没有任何报错CPU 占用还高有时是协程卡在某个等待上。 原因敌人阶段最常见三种情况。第一敌人列表为空但流程没有显式跳过第二敌人无法到达任何我方单位时寻路返回空集合决策逻辑没处理“无目标”第三协程等待动画或延迟等待期间单位全部死亡回调永远不来。 解决敌人回合 OnEnter 先做两个空判——敌人为 0 或目标为 0直接切回玩家回合。寻路结果为空时敌人原地待机而不是卡住。所有协程等待加超时保护等待超过 3 秒强制结束当前阶段。调试时在流程切换处加日志打印敌人数量和目标数量一次就能区分是逻辑死循环还是表现层等待。5.5 打包后数据表和资源加载路径错位现象编辑器里一切正常打了 Android 包后所有单位属性变成默认值关卡加载到一半卡住日志里常见数据表加载失败或 AssetBundle 找不到。 原因开发期数据和 AssetBundle 走资源目录打包后资源组件切到包内路径。如果加载代码写死了“Assets/...”绝对路径或者数据表没有打进 AssetBundle 而是放在了无关目录运行时就找不到。 解决统一用资源组件提供的路径接口不写死 Assets 前缀。数据表全部通过数据表组件加载打包时检查 AssetBundle 依赖。打出的包在真机跑之前先做空跑冒烟只加载数据表、初始化流程、读一行配置能读到再进场景。这个冒烟步骤我放在启动流程里用宏控制Release 包也保留出问题能第一时间判断是表缺失还是场景资源缺失。6. 再进一步性能验证、存档设计与回合经营统一的 Tick6.1 用帧率和 GC 指标验证实体数量上限战棋实体上限不能靠感觉定要用真机数据说话。我的验证方法在战斗场景里固定站位批量生成 30、50、80 个单位分别记录平均帧率和每帧 GC.Alloc。GF 的实体和引用池生效后50 个单位压力通常不大真正吃内存的是每个单位身上的特效、飘字和可破坏对象。如果 GC.Alloc 每帧超过 5KB优先检查范围计算和飘字是否在反复生成临时对象和字符串。6.2 存档设计一次序列化全部经营和地图状态存档不要只存经营数值还要存地图上已采集资源点、伤亡单位、已解锁建筑。建议把整个状态合并成一个可序列化对象经营数据、地图快照、队伍数据写进同一个 JSON。读档时先恢复经营侧再加载地图最后恢复单位。某项目里我曾只存经营数据读档后地图采集点全部刷新木材凭空翻倍数值直接崩盘。这个教训之后我所有的战棋加经营存档都坚持“一份文件、三段数据、固定顺序”。6.3 一个习惯把战棋的回合 Tick 和经营的日结算共用同一个时间基准战棋的一次行动和经营的过一天在概念上属于同一个时间流。我用全局时间轴驱动它们战斗中每完成一次“玩家阶段敌人阶段”时间轴推进一步经营侧以这个步进为最小单位结算。好处是存档只有一份时间戳不会出现“战斗内过了 10 回合经营才过 1 天”的错乱。现在拿到这类战棋兼经营的包我第一件事就是检查时间基准是否统一如果战斗和经营各维护一套时间后续加季节系统、倒计时任务时一定会翻车。这个习惯是模拟项目X里用血泪换来的也希望帮到你。本文还有配套的精品资源点击获取