资讯动态

Unity农场经营游戏源码解析:种植状态机与JSON存档实践

发布时间:2026/10/18 16:25:23 来源:尧图企业网站定制
简介这是一份Unity精品农场经营游戏完整项目源码面向Unity初中级开发者、休闲游戏爱好者及需要参考完整经营类游戏架构的从业者。资源为Unitypackage包含完整可运行工程已在Unity 2021.3.14f1版本验证可正常导入运行适合快速体验经典农场玩法、学习项目结构或基于此做二次开发。压缩包约172.86MB共13281个文件涵盖807个C#核心逻辑脚本、217个Prefab场景预设、121个Anim动画文件、16个Shader着色器以及大量PNG美术资源、OGG/MP3音频、动画控制器等目录层级清晰便于定位与修改。目前已有3978人学习下载。整套资源包含种田、养殖、建造、经营等完整流程与UI、音效、动画配置导入空项目即可直接运行无论是Unity新手练习还是个人开发者快速搭建休闲游戏原型都有不错的参考价值。1. 一份Unity农场经营完整项目源码先看种植闭环再谈能不能跑很多人拿到Unity农场经营游戏完整项目源码第一反应是双击主场景然后直接按Play结果要么Console里飘红要么画面里静悄悄的什么都没发生。这不怪引擎怪没先理清种植系统是怎么把空地、耕地、播种、成熟、收获串起来的。这份完整项目把休闲经营闭环做成了可复用框架数据配置、UI面板、存档恢复都挂在同一个主场景里状态机、ScriptableObject、JSON存档这几个Unity游戏源码常见的硬骨头全都踩过了。它解决的是自己从零搭容易翻车的问题适合想快速搭经营Demo的开发者也适合拿来做课程设计的初学者照着场景捋一遍就能改成自己版本的农场。2. 项目结构与主场景读懂工程比急着点播放更重要下载过一堆Unity游戏源码的开发者应该都有这个感觉能跑起来不代表能改真正难受的是改了A处发现B处又坏了。所以我拿到这份源码的第一件事不是按Play而是先把工程目录和场景层级完整过一遍搞清楚每个脚本挂在哪个GameObject上、谁先初始化、数据从哪里来。这个步骤花十几分钟后面能省下几小时。2.1 Assets目录数据、逻辑、表现三层分离先说结论这份工程的目录拆得比较规矩Assets下主要就是Scripts、Prefabs、Scenes、Data、Art五个目录。Scripts里面按功能再分Core、Data、UI三层Data目录放的是已配置好的作物资产Prefabs把土壤地块、作物模型、UI面板都做成了预制体场景里只负责摆放和引用。Assets/ ├── Scenes/ # 主场景与测试场景 │ └── MainScene.unity ├── Scripts/ │ ├── Core/ # 游戏入口、管理器、存档 │ ├── Data/ # ScriptableObject 数据类 │ └── UI/ # 背包、商店、HUD 面板 ├── Prefabs/ # 地块、作物、UI 预制体 ├── Data/ # 已配置好的作物资产 └── Art/ # 模型、贴图、字体这段目录树对应的是「数据、逻辑、表现」三层分离逻辑脚本集中在Core数据类单独放DataUI交互全在UI目录。这样分层最大的好处是改功能边界清晰——你想改商店定价就去Data和UI目录翻想改种植逻辑就去Core翻不用在几十个脚本里乱找。这里有个细节值得提如果下载的压缩包里没有ProjectSettings目录说明它只是资源包而不是完整工程运行时会缺输入和玩家设置。我一般会先确认这一点再决定是直接打开还是新建工程把Assets拖进去。另外Prefabs和Art目录尽量保持路径不变动了路径会导致场景里一堆Missing后面第5章会专门说。2.2 主场景层级把管理器挂在哪决定初始化顺序打开MainScene.unity后Hierarchy的顶层结构大约是下面这样MainScene ├── GameManager # 全局单例、作物数据字典 ├── Canvas │ ├── UIManager # 背包、商店、HUD 刷新入口 │ └── EventSystem ├── WorldRoot # 所有地块的父节点 │ ├── SoilPlot_0 │ ├── SoilPlot_1 │ └── ... └── Player # 玩家移动与交互检测关键点在于这些管理器都在同一帧Awake但代码里刻意规定了先后顺序。GameManager先加载Data目录下的作物资产并建立字典SoilManager再根据字典初始化所有地块然后UIManager在Start里第一次刷新金币和背包显示最后SaveManager尝试读档覆盖默认状态。给一段GameManager的核心示意代码你在工程里看到的应该就是这种写法public class GameManager : MonoBehaviour { public static GameManager instance; public CropData_SO[] allCrops; // Inspector 里拖入所有作物资产 private Dictionarystring, CropData_SO cropDict; private void Awake() { instance this; cropDict new Dictionarystring, CropData_SO(); foreach (var crop in allCrops) cropDict[crop.cropId] crop; // 用 cropId 做索引存档里只存字符串 ID } public CropData_SO GetCrop(string cropId) { return cropDict.TryGetValue(cropId, out var crop) ? crop : null; } }这段代码把「在Inspector里拖一数组资产」转成「运行时按cropId查字典」后面SoilTile、商店、订单脚本都通过GetCrop查数据不需要到处GetComponent。两点说明allCrops里数组顺序无所谓但cropId必须全局唯一并且大小写敏感GetCrop返回null时上游别直接访问字段先判断再操作否则会空引用。初始化顺序为什么要这么排因为UIManager刷新背包时要读PlayerData里的库存而PlayerData的库存可能在读档时被整体替换如果UIManager在SaveManager之前刷新就白刷了一次。这类「先加载配置、再初始化场景、最后覆盖存档」的顺序在完整项目里比单个脚本写法更重要养成习惯后换别的Unity游戏源码也通用。2.3 打开项目前先确认三个设置虽然资源是完整工程但换一台机器打开时最常在这三处翻车。我建议按顺序排查比直接看报错日志更高效。第一Scripting Runtime版本。如果编辑器版本比打包工程用的旧很多API会提示过时或不识别报错一大片却指向同一处。排查路径是EditProject SettingsPlayerOther Settings看Api Compatibility Level和Scripting Backend设置必要时把脚本运行时切到兼容模式。第二输入系统。Unity现在默认新Input System但很多农场类项目用的是旧Input Manager写法。如果场景里事件老是收不到先看Active Input Handling是Old还是Both游戏里用了Input.GetMouseButtonDown这种旧API就切成Old或Both。第三渲染管线。用URP打包的工程如果没带URP Asset打开后材质会整体变成粉紫色。检查方式是Project SettingsGraphicsScriptable Render Pipeline Settings为空就导入自带URP包或重建管线资产。这三个设置检查完再进场景能过滤掉七成莫名奇妙的报错。3. 种植核心流程从空地到收获的状态机与交互种植是这份Unity游戏源码的主循环耕地、播种、浇水、收获看着简单真正写起来最容易在状态互斥上出bug成熟的地块还能再播种播种过的地还能再耕地一乱玩家就骂。这块工程用状态机把流程锁死是整份源码最值得抄的部分。3.1 状态机比bool组合更省心的地块管理很多初学Unity的开发者会这么设计地块用三个bool表示isPlowed、isSown、isRipe每次交互都手动置位。但三个bool有8种组合其中有5种是非法状态比如「既耕了又没收」。一份地块数据一旦进入非法状态存档恢复和UI显示会一起错乱。工程里的做法是定义一个SoilState枚举把地块生命周期限制在4个状态里当前状态玩家操作下一状态Empty空地用锄头PlowedPlowed耕地播种SownSown已播种浇水后等待RipeRipe成熟收获Empty这个表格看起来简单但它保证了任何时刻地块只能处于一个状态交互代码就是一层switch不存在又浇水又收获这种平行分支。后面要加施肥、防虫都是在枚举里加状态再在switch里加一个case不会动到其他地块的字段。3.2 SoilTile脚本交互入口与状态流转SoilState枚举和SoilTile脚本是这套系统的核心SoilTile挂在每块田地上保存自己的状态、作物数据和剩余生长时间。下面是核心结构public enum SoilState { Empty, Plowed, Sown, Ripe } public class SoilTile : MonoBehaviour { public SoilState state SoilState.Empty; public CropData_SO currentCrop; // 当前种的是什么作物 public bool watered; // 浇水状态独立于状态机 public void OnPlayerInteract(ItemType heldItem) { switch (state) { case SoilState.Empty: if (heldItem ItemType.Hoe) state SoilState.Plowed; break; case SoilState.Plowed: if (heldItem ItemType.Seed) Sow(); break; case SoilState.Sown: if (heldItem ItemType.WateringCan !watered) watered true; break; case SoilState.Ripe: Harvest(); break; } } private void Sow() { // 扣背包种子、挂载作物模型、启动生长协程 } private void Harvest() { // 加背包产物、状态回 Empty、清理模型 } }OnPlayerInteract由玩家控制的检测脚本调用heldItem是当前工具栏选中的物品ItemType是个全局枚举里面有Hoe、Seed、WateringCan等。这块设计的细节watered单独存bool而不是塞进枚举是为了让浇水和播种两个维度能叠加同一块地今天浇水明天不用浇成熟后watered要重置。如果你要改成不同种子类型把ItemType.Seed的判断换成具体的种子物品IDSow里按currentCrop扣对应库存。有一点要注意如果地块的交互改成由PlayerController的点击射线驱动SoilTile所在的Layer必须加进Raycast的LayerMask否则点上去永远没反应。这个坑我见过很多次先查Layer再查脚本。3.3 生长计时协程比Update计时好改存档作物生长可以用Update每帧累加也可以用一个协程来计时。这份源码选的是协程理由是它天然支持暂停和恢复而且和存档逻辑对称——存档里只记录剩余时间读档后根据剩余时间重新启动协程就可以不需要处理上一次Update是什么时候这类问题。private IEnumerator GrowCrop() { float duration currentCrop.growthDuration; float remain duration; while (remain 0f) { float step Time.deltaTime; if (watered) step * currentCrop.waterBonus; // 浇水只加速本回合生长 remain - step; yield return null; } state SoilState.Ripe; }参数说明growthDuration和waterBonus都来自CropData_SO资产比如小麦测试期配8秒、正式配60秒waterBonus配1.5就表示浇水后生长速度提升50%。这里有个很容易忽略的边界yield return null会等待一帧如果SoilTile被SetActive(false)协程会暂停执行看上去就像作物永远长不熟。短期单机游戏无所谓但如果你要加后台离线成长就得把计时代码从SoilTile挪到SoilManager里统一用Time.deltaTime驱动并配合存档里的剩余时间做计算。4. 作物数据与存档把数值交给配置把进度交给JSON经营类Unity游戏源码最容易看出功力的地方除了种植状态机就是存档。种植是玩家肉眼能看到反馈的部分存档则是玩家隔天回来时对游戏的信任读档后作物全没了、金币变负数这种事一次就能劝退玩家。这份工程在数据驱动和序列化上做得比较稳。4.1 ScriptableObject改作物数值不用碰代码Data目录下的CropData_SO是一个ScriptableObject数据类每个资产文件代表一种作物。之所以这么设计是因为数值调整频率远高于代码调整小麦卖8块还是10块生长需要30秒还是60秒这些改在Inspector里就行完全不用重新编译脚本。来看这个类[CreateAssetMenu(fileName CropData, menuName Farm/CropData)] public class CropData_SO : ScriptableObject { public string cropId; // 存档唯一标识改了老档就找不到 public string displayName; // 显示名可以随便改文案 public int seedItemId; // 种子在背包里的物品 ID public float growthDuration; // 生长总时长秒 public float waterBonus 1.5f; // 浇水后的生长加速倍率 public int yieldCount 1; // 单次收获数量 public int sellPrice; // 商店出售单价 }添加新作物的操作路径是在Project窗口右键CreateFarmCropData生成新资产后填字段再把资产拖进GameManager的allCrops数组。重点强调一下cropId它是存档和字典的双重索引displayName可以随时改成中文文案但cropId一旦在存档里出现就不能乱改改了等于旧档全部作废。4.2 存档结构为什么存剩余时间而不是过去了多久农场游戏存档最关键的字段是农作物进度。常见有两种方案一种是记录「种植时刻」的时间戳读档时拿当前时间和它做差另一种是存档时直接算好「剩余生长时间」。工程选了第二种。方案优势风险种植时间戳可支持离线成长玩家改系统时间会秒熟换算逻辑复杂剩余生长时间读档即用逻辑简单退出期间作物不生长对单机休闲经营来说大多数玩家关了游戏就关了退出期间生长本身是个可选功能不值得为它引入系统时间换算的风险。所以这里存剩余时间更稳妥。存档的JSON结构大概长这样{ m_version: 1, gold: 1200, inventory: [ { itemId: 101, count: 8 } ], soils: [ { gridX: 1, gridY: 3, state: Sown, cropId: wheat, remainSeconds: 12.4 } ] }几个字段的细节m_version是存档结构版本号以后增删字段就靠它迁移state用字符串而不是数字避开枚举顺序一变旧档全乱的大坑cropId配合4.1节CropData字典反查remainSeconds是剩余生长时间直接喂给协程。inventory用数组而不是对象是因为Unity的JsonUtility不支持Dictionary序列化这是个硬限制具体解决手段放到了SaveManager里处理。4.3 SaveManager序列化、写盘、恢复SaveManager负责把运行时数据变成JSON写到本地再在启动时读回来覆盖。代码核心如下public class SaveManager : MonoBehaviour { private string savePath; private void Awake() { // persistentDataPath 是各平台均可写目录不要硬编码绝对路径 savePath Path.Combine(Application.persistentDataPath, farm_save.json); } public void SaveGame() { SaveData data new SaveData(); data.gold PlayerData.instance.gold; data.inventory new ListItemStack(PlayerData.instance.Inventory); data.soils SoilManager.instance.CreateSoilSnapshot(); string json JsonUtility.ToJson(data, true); File.WriteAllText(savePath, json); } public void LoadGame() { if (!File.Exists(savePath)) return; string json File.ReadAllText(savePath); SaveData data JsonUtility.FromJsonSaveData(json); PlayerData.instance.gold data.gold; PlayerData.instance.Inventory new ListItemStack(data.inventory); SoilManager.instance.RestoreSoilSnapshot(data.soils); } }逻辑说明Inventory在运行时是Dictionaryint,int但JsonUtility序列化不了字典所以在存档结构里用List 承接ItemStack就是itemId加count的普通类恢复时再转会成字典。SaveGame里的CreateSoilSnapshot遍历所有SoilTile把状态、作物ID、剩余时间逐项抽出LoadGame里的RestoreSoilSnapshot反过来一一还原。提示存档触发时机建议放在「睡觉结算」和「切换场景之前」不要每帧写盘手机平台上频繁IO会卡顿。SaveGame里JsonUtility.ToJson的第二参数传true输出带缩进的JSON调试存档字段时眼睛会舒服很多。5. 常见问题与避坑导入、交互、存档三个重灾区这部分是我实际改这份源码时遇到最多的也基本覆盖了Unity完整项目从下载到跑起来的三个重灾区。每条都是现象、原因、解决三段结构照着排查比盯着Console猜快得多。5.1 导入后脚本大面积报错先看配置再改代码现象Unity打开工程后Console里刷出几十条报错看上去每个文件都坏了但错误集中在某个API或者命名空间找不到。原因发行方用的编辑器版本比你的新或者项目启用了新Input System而你的机器没安装对应包。多数情况不是源码问题是环境不一致。解决先读Packages/manifest.json看依赖清单再按报错类型处理。报Input相关就改Active Input Handling为Both报某个命名空间缺失就查包是否安装。不要第一反应去改脚本环境排查永远在改代码之前。5.2 点击地块没反应OnMouseDown被UI挡住现象工具栏选中锄头鼠标点地块没有任何反馈但点UI按钮一切正常。原因地块交互用的是OnMouseDown鼠标点下去时EventSystem认为你点在UI上物理射线被拦截了。解决在交互入口前加一层UI拦截判断就行if (EventSystem.current ! null EventSystem.current.IsPointerOverGameObject()) return;这个判断放在OnPlayerInteract调用之前UI遮挡、背包面板开着点地块这类误操作全部屏蔽掉。从那以后我做这类游戏都会把「输入拦截」当成默认检查项而不是等出bug再补。5.3 作物到时间不成熟协程被暂停或Time.timeScale为零现象种下去等了很久地块还是Sown状态时间一到也没变Ripe。原因协程挂在SoilTile上SoilTile被SetActive(false)后协程暂停或者暂停游戏时用了Time.timeScale0Time.deltaTime变成0循环永远推不动。解决暂停功能改用游戏内的bool变量控制不要动timeScale地块数量不多时把协程挂到常活的SoilManager下由它驱动所有地块的计时。如果你要加后台挂机就得改成4.2说的时间戳方案。5.4 读档后地图全乱枚举存数字的隐患现象昨天存的是耕地今天读档变成成熟背包物品数量也跟着张冠李戴。原因存档里state用的是枚举的int下标后来在枚举中间插入了新状态所有下标往后挪了一位旧档全部错位。解决把状态字段从int改成字符串就像4.2里展示的那样state存Plowed而不是数字2。这个改动能延续到以后的每次迭代是最值得提前做的一步。5.5 预制体引用丢失协作和搬运后的GUID失效现象场景打开后一堆Prefab显示Missing材质泛紫但Assets目录里文件看着都还在。原因Unity靠.meta文件里的GUID引用预制体下载包重新生成meta后GUID变了场景里的引用就找不到了。解决确认.meta文件存在后把关键预制体重新拖进场景对应位置更好的做法是把运行时动态加载的东西放进Resources目录用Resources.Load (path)按名加载从根上绕开GUID耦合。这个思路在后续扩展时作用很大。6. 扩展方向换作物、加订单、把存档结构做成向后兼容6.1 加一个新作物三步走把这份Unity经营游戏源码改成自己的版本第一个实验我建议加一种新作物路径短、见效快、成就感强。第一步在Data目录右键CreateFarmCropData填cropId和growthDuration第二步把新作物模型放进Prefabs/Plants下在SoilTile的Sow()里按cropId加载对应模型第三步在商店数据里注册种子售价。字段测试值说明cropIdstrawberry存档唯一标识确定后别改growthDuration20测试期用秒正式再调大waterBonus1.5浇水后的生长倍率sellPrice15商店出售单价整个过程不碰状态机也不需要改存档格式因为作物ID是写进存档结构里的数据层面天然支持多作物。6.2 加每日订单给经营加入目标种了卖、卖了种容易无聊常见做法是加订单系统每天刷几条订单要求指定作物和数量限时完成给金币。订单生成可以用一个很轻的脚本public class OrderManager : MonoBehaviour { public ListCropData_SO unlockCrops; // 解锁的作物列表 public OrderData GenerateDailyOrder() { CropData_SO crop unlockCrops[Random.Range(0, unlockCrops.Count)]; OrderData order new OrderData(); order.cropId crop.cropId; order.amount Random.Range(3, 10); order.timeLimit crop.growthDuration * order.amount * 0.8f; order.rewardGold crop.sellPrice * order.amount 20; return order; } }参数说明timeLimit设成种植总耗时乘0.8既造成压力又不至于完不成rewardGold在基础售价上再加20玩家才有去做订单的动机。订单完成时调用一次SaveManager存档把订单状态写进存档结构时记得把m_version升到2并写迁移逻辑。我自己第一次改这份源码为了图省事把背包从List改成Dictionary没加版本号结果调试期存的老档全部读乱金币变成了萝卜数量。那之后我给自己立了条死规矩动存档结构先写m_version再动字段拿到任何Unity农场经营游戏完整项目源码我也会先看存档结构和状态机再进场景。这个习惯帮我少走了很多弯路希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑