☰
Unity农场经营游戏源码解析:种植状态机与JSON存档实践
2026/10/18 16:25:08 网站建设 项目流程

简介:这是一份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,数据类单独放Data,UI交互全在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 Dictionary<string, CropData_SO> cropDict; private void Awake() { instance = this; cropDict = new Dictionary<string, 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会提示过时或不识别,报错一大片却指向同一处。排查路径是Edit>Project Settings>Player>Other 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 Settings>Graphics>Scriptable Render Pipeline Settings,为空就导入自带URP包或重建管线资产。这三个设置检查完再进场景,能过滤掉七成莫名奇妙的报错。

3. 种植核心流程:从空地到收获的状态机与交互

种植是这份Unity游戏源码的主循环,耕地、播种、浇水、收获看着简单,真正写起来最容易在状态互斥上出bug:成熟的地块还能再播种,播种过的地还能再耕地,一乱玩家就骂。这块工程用状态机把流程锁死,是整份源码最值得抄的部分。

3.1 状态机:比bool组合更省心的地块管理

很多初学Unity的开发者会这么设计地块:用三个bool表示isPlowed、isSown、isRipe,每次交互都手动置位。但三个bool有8种组合,其中有5种是非法状态,比如「既耕了又没收」。一份地块数据一旦进入非法状态,存档恢复和UI显示会一起错乱。

工程里的做法是定义一个SoilState枚举,把地块生命周期限制在4个状态里:

当前状态玩家操作下一状态
Empty(空地)用锄头Plowed
Plowed(耕地)播种Sown
Sown(已播种)浇水后等待Ripe
Ripe(成熟)收获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的判断换成具体的种子物品ID,Sow里按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窗口右键Create>Farm>CropData,生成新资产后填字段,再把资产拖进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 List<ItemStack>(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.FromJson<SaveData>(json); PlayerData.instance.gold = data.gold; PlayerData.instance.Inventory = new List<ItemStack>(data.inventory); SoilManager.instance.RestoreSoilSnapshot(data.soils); } }

逻辑说明:Inventory在运行时是Dictionary<int,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.timeScale=0,Time.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目录右键Create>Farm>CropData,填cropId和growthDuration;第二步把新作物模型放进Prefabs/Plants下,在SoilTile的Sow()里按cropId加载对应模型;第三步在商店数据里注册种子售价。

字段测试值说明
cropIdstrawberry存档唯一标识,确定后别改
growthDuration20测试期用秒,正式再调大
waterBonus1.5浇水后的生长倍率
sellPrice15商店出售单价

整个过程不碰状态机,也不需要改存档格式,因为作物ID是写进存档结构里的,数据层面天然支持多作物。

6.2 加每日订单:给经营加入目标

种了卖、卖了种容易无聊,常见做法是加订单系统:每天刷几条订单,要求指定作物和数量,限时完成给金币。订单生成可以用一个很轻的脚本:

public class OrderManager : MonoBehaviour { public List<CropData_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农场经营游戏完整项目源码,我也会先看存档结构和状态机再进场景。这个习惯帮我少走了很多弯路,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询