简介:这是一份面向Unity游戏开发初学者与C#编程学习者的环保主题挂机类游戏完整项目源码,适用于休闲游戏开发实践、Idle机制实现及生态模拟系统学习。资源包含2000个文件,主体为187个C#脚本(实现点击、升级、离线收益等核心逻辑)、250个Asset资源(含场景、预制体与动画控制器)、132个WAV/78个MP3音效文件,以及46个PNG纹理、22个Prefab和68个JSON配置数据,整体压缩包达295.79MB,结构完整,便于模块化理解与二次开发。已有338人下载学习,适合通过真实项目掌握Unity 2020.3.25f1+版本下的UI交互、状态管理、资源加载与多线程优化技巧。项目内置丰富动画资源(如WaterClearSlot_idle.anim、Car_garbage_idle.anim等),配合清晰的层级命名与功能划分,可直接运行调试,并支持扩展星球环境、绿色技术树与污染治理系统,是理解Idle Tycoon类游戏架构的优质教学范例。
1. 这不是又一个“点点点”挂机游戏:Eco Clicker Idle Tycoon 是一套可调试、可扩展、带完整经济闭环的 Unity 环保模拟骨架
你点开过几十个“Idle Tycoon”模板项目,最后发现全是预制体堆叠+硬编码数值+无状态保存——改个回收速率要翻三页脚本,加个新污染类型得重写整个事件分发器。Eco Clicker Idle Tycoon 不同:它用 C# 实现了清晰分层的状态管理(ResourceSystem+WorkerManager+FacilityController),所有核心逻辑集中在Assets/Scripts/Core/下,动画资源命名规范(如Poacher_idle.anim、WaterClearSlot_idle.anim)直接映射到污染类型与清理动作,连Car garbage_idle.anim都对应真实垃圾模型的空闲循环。它解决的不是“怎么让数字变大”,而是“如何让环保行为在离线状态下仍能持续产出纯净能源”。适合想练手 Unity 数据驱动设计、熟悉 ScriptableObject 构建配置表、或需要快速搭建带多星球生态链的休闲游戏原型的开发者——尤其当你已经卡在PlayerPrefs存档崩溃、协程调度混乱、或 UI 数值不同步超过 3 小时的时候。
2. 从 Unity 2020.3.25f1 启动到首屏可交互:环境准备与核心模块加载流程
2.1 Unity 版本兼容性验证与基础依赖配置
项目明确要求 Unity 2020.3.25f1 及以上,这是关键约束。低于该版本会触发UnityEditor.BuildPlayerOptions中enableHeadlessMode的缺失报错;高于 2021.3 则需手动降级UnityEngine.UI的CanvasScaler兼容性(因Scale Factor在 2021.3+ 默认启用UI Scale Mode: Scale With Screen Size)。验证方式为打开项目后立即执行:
# 在 Unity Console 中粘贴并回车(非编辑器外终端) Debug.Log($"Unity Version: {Application.unityVersion} | Scripting Backend: {PlayerSettings.GetScriptingBackend(BuildTargetGroup.Standalone)}");提示:若输出
Scripting Backend: IL2CPP且版本为 2020.3.x,则无需额外设置;若为 Mono,请在Edit > Project Settings > Player > Other Settings中将Scripting Backend显式设为IL2CPP,否则ResourceSystem中的ConcurrentDictionary会在构建时抛出NotSupportedException。
项目未使用第三方包管理器(如 OpenUPM),所有依赖内置于Assets/Plugins/:
DOTween(v1.2.600):用于 UI 动画缓动(如能量条增长、按钮点击反馈)TextMeshPro(v3.0.6):所有文本渲染强制走 TMP,FontAsset存于Assets/Resources/Fonts/Addressables(v1.19.17):仅用于加载Assets/StreamingAssets/Config/下的.json配置表,未启用远程加载,因此Addressables.InitializeAsync()可安全移除
2.2 核心数据流初始化:从 ScriptableObject 配置到运行时 ResourceSystem
游戏经济系统不靠硬编码数值,而是通过ScriptableObject加载配置。关键路径如下:
2.2.1 配置表加载机制
所有平衡参数存于Assets/Resources/Config/,结构为:
Config/ ├── Resources/ │ ├── Energy.asset # 纯净能源基础产出率、离线倍率 │ └── PollutionTypes.asset # 污染类型ID、图标、清理耗时、产出能量值 ├── Facilities/ │ └── RecyclingPlant.asset # 回收设施等级、升级成本、处理速率 └── Workers/ └── EcoActivist.asset # 工人基础效率、雇佣成本、离线工作权重加载逻辑在GameManager.cs的Awake()中触发:
// Assets/Scripts/Core/GameManager.cs 第 42 行 private void Awake() { // 1. 加载全局资源配置 resourceConfig = Resources.Load<ResourceConfig>("Config/Resources/Energy"); pollutionConfig = Resources.Load<PollutionConfig>("Config/Resources/PollutionTypes"); // 2. 初始化运行时资源系统(单例) ResourceSystem.Instance.Initialize(resourceConfig, pollutionConfig); // 3. 加载设施与工人配置(按需延迟加载,避免启动卡顿) StartCoroutine(LoadFacilityConfigs()); }注意:
Resources.Load<T>()调用必须确保路径中不含.asset扩展名,否则返回 null;PollutionConfig继承自ScriptableObject,其pollutionEntries字段是List<PollutionEntry>,每个PollutionEntry包含id: string(如"water_27"对应Water_27_Flares.anim)、cleanupTime: float(秒)、energyYield: int(清理后获得能量值)。
2.2.2 ResourceSystem 的状态同步与离线计算
ResourceSystem是整个经济引擎的核心,它维护:
public Dictionary<string, int> currentResources:实时资源量(key 为"energy"、"workers"、"facilities")private Dictionary<string, float> lastSaveTime:各资源上次保存时间戳private readonly List<WorkerData> activeWorkers:当前生效的工人列表(含离线工作权重)
离线收益计算逻辑在Update()中每帧检查:
// Assets/Scripts/Core/ResourceSystem.cs 第 187 行 public void UpdateOfflineProduction() { float deltaTime = Time.time - lastSaveTime["energy"]; if (deltaTime < 1f) return; // 避免首帧误算 // 计算离线期间总产出 = 工人效率 × 时间 × 离线权重 float offlineEnergy = 0f; foreach (var worker in activeWorkers) { offlineEnergy += worker.baseEfficiency * deltaTime * worker.offlineWeight; } // 应用设施加成(如 RecyclingPlant 升级后提升 15% 处理速率) offlineEnergy *= GetFacilityBonus("recycling_plant", "production_rate"); currentResources["energy"] += Mathf.FloorToInt(offlineEnergy); lastSaveTime["energy"] = Time.time; }关键参数说明:
worker.offlineWeight默认为 0.7,表示离线时仅 70% 效率;GetFacilityBonus()读取FacilityConfig中upgradeLevels[0].bonusValues["production_rate"],该值在RecyclingPlant.asset中定义为0.15f(即 15%)。
3. 动画资源与污染类型绑定:从 Poacher_idle.anim 到游戏内行为逻辑映射
3.1 动画命名规则与污染类型注册机制
项目中列出的.anim文件(如Poacher_idle.anim、WaterClearSlot_idle.anim)并非随意命名,而是严格遵循污染源_动作.anim格式:
- 前缀
Poacher、WaterClearSlot、DirtSlot_(1)对应PollutionConfig.pollutionEntries中的id字段 - 后缀
_idle表示该污染源的默认空闲动画,用于场景中循环播放 - 同一污染源可能有
_cleanup、_destroyed等变体(虽未在列表中出现,但代码预留了AnimationClip[]数组支持)
注册流程在PollutionSpawner.cs中完成:
// Assets/Scripts/Environment/PollutionSpawner.cs 第 63 行 private void RegisterPollutionAnimations() { // 1. 获取 Resources/Animations/ 下所有 AnimationClip AnimationClip[] clips = Resources.LoadAll<AnimationClip>("Animations/"); // 2. 按命名前缀分组(如 "Poacher_idle" → "Poacher") foreach (AnimationClip clip in clips) { string prefix = clip.name.Split('_')[0]; // 提取 "Poacher" if (pollutionConfig.GetEntryById(prefix) != null) { pollutionAnimations[prefix] = clip; // 存入字典供后续调用 } } }提示:
pollutionConfig.GetEntryById(prefix)内部执行pollutionEntries.Find(e => e.id == prefix),因此Poacher_idle.anim必须在PollutionTypes.asset中存在id = "Poacher"的条目,否则该动画不会被加载到内存。
3.2 点击交互与动画状态机切换
玩家点击污染源时,触发PollutionObject.cs的OnMouseDown():
// Assets/Scripts/Environment/PollutionObject.cs 第 112 行 public void OnMouseDown() { if (!canInteract) return; // 1. 播放清理动画(替换 _idle 为 _cleanup) string cleanupClipName = pollutionId + "_cleanup"; AnimationClip cleanupClip = AnimationUtility.GetAnimationClip(cleanupClipName); if (cleanupClip != null) { animator.Play(cleanupClip.name); } // 2. 触发资源系统结算 ResourceSystem.Instance.AddResource("energy", pollutionConfig.GetEntryById(pollutionId).energyYield); // 3. 销毁当前对象(清理完成) Destroy(gameObject, cleanupClip.length); // 动画播完后销毁 }3.2.1 Animator Controller 结构解析
每个污染源挂载的Animator Controller位于Assets/Animations/Controllers/,以Pollution_Poacher.controller为例:
- State Machine Layers:
- Base Layer(默认):包含
Idle、Cleanup、Destroyed三个 State IdleState 的 Motion 设为Poacher_idle.anim,Transition 条件为isCleaning == trueCleanupState 的 Motion 设为Poacher_cleanup.anim,Exit Time 设为0.95(确保动画播完再跳转)
- Base Layer(默认):包含
- Parameters:
isCleaning(Bool):由PollutionObject.OnMouseDown()设置为trueisDestroyed(Bool):由CleanupState 的OnStateExit事件触发,调用DestroySelf()
注意:
AnimationUtility.GetAnimationClip()是自定义工具方法,实际代码中需确保Assets/Animations/Clips/下存在对应.anim文件,否则cleanupClip为 null,导致仅增加能量但无视觉反馈。
3.3 多星球环境切换与污染类型动态加载
游戏支持PlanetEarth、PlanetOcean、PlanetForest三种环境,每种环境加载不同的污染配置:
// Assets/Scripts/UI/PlanetSelector.cs 第 89 行 public void SwitchToPlanet(string planetId) { // 1. 卸载当前污染对象 foreach (Transform child in pollutionContainer.transform) { Destroy(child.gameObject); } // 2. 加载新星球配置 string configPath = $"Config/Planets/{planetId}"; PlanetConfig planetConfig = Resources.Load<PlanetConfig>(configPath); // 3. 按配置生成污染源(如 PlanetOcean 加载 Water_20_Flares、Water_27_Flares) foreach (string pollutionId in planetConfig.pollutionIds) { PollutionObject obj = Instantiate(pollutionPrefab, pollutionContainer); obj.Initialize(pollutionId); // 绑定动画、配置、UI } }PlanetConfig的pollutionIds字段是字符串数组,例如PlanetOcean.asset中为["water_20", "water_27", "water_21"],直接对应Water_20_Flares.anim等文件名前缀。这种设计使新增星球只需创建新.asset文件并填充 ID 列表,无需修改任何 C# 逻辑。
4. 设施升级与工人雇佣系统:基于 ScriptableObject 的可扩展经济模型
4.1 设施系统:从 RecyclingPlant.asset 到运行时 FacilityController
设施数据完全由ScriptableObject驱动,以RecyclingPlant.asset为例,其字段结构为:
| 字段名 | 类型 | 示例值 | 说明 |
|---|---|---|---|
facilityId | string | "recycling_plant" | 设施唯一标识,用于ResourceSystem查找加成 |
baseCost | int | 500 | 初始购买成本(单位:energy) |
upgradeCostMultiplier | float | 1.8f | 每次升级成本增幅(第2级=500×1.8,第3级=500×1.8²) |
upgradeLevels | List | [Lv1, Lv2, Lv3] | 每级提供的具体加成 |
UpgradeLevel子结构:
level: int(1,2,3…)bonusValues: Dictionary<string, float>(如{"production_rate": 0.15f, "storage_capacity": 200f})unlockConditions: List (如["workers_eco_activist_level_2"],依赖工人等级)
运行时FacilityController通过Upgrade()方法应用加成:
// Assets/Scripts/Core/FacilityController.cs 第 144 行 public void Upgrade() { if (currentLevel >= upgradeConfig.upgradeLevels.Count) return; int nextLevel = currentLevel + 1; UpgradeLevel levelData = upgradeConfig.upgradeLevels[nextLevel - 1]; // 1. 扣除升级成本 if (!ResourceSystem.Instance.TrySpendResource("energy", GetUpgradeCost(nextLevel))) return; // 2. 应用加成到 ResourceSystem foreach (var kvp in levelData.bonusValues) { ResourceSystem.Instance.SetFacilityBonus(upgradeConfig.facilityId, kvp.Key, kvp.Value); } currentLevel = nextLevel; }关键逻辑:
SetFacilityBonus()并非直接修改数值,而是将(facilityId, bonusKey)作为 key 存入ResourceSystem.facilityBonuses字典,后续GetFacilityBonus()会遍历所有激活设施的同名 bonus 并累加。例如recycling_plant.production_rate和solar_farm.production_rate可同时生效。
4.2 工人系统:EcoActivist.asset 与 WorkerManager 的协同调度
工人数据同样由ScriptableObject定义,EcoActivist.asset包含:
workerId:"eco_activist"baseCost:200(雇佣成本)baseEfficiency:1.2f(每秒产出 energy 基础值)offlineWeight:0.7f(离线效率权重)unlockLevel:1(解锁所需星球等级)
WorkerManager负责实例化与调度:
// Assets/Scripts/Core/WorkerManager.cs 第 95 行 public void HireWorker(string workerId, int count = 1) { WorkerConfig config = Resources.Load<WorkerConfig>($"Config/Workers/{workerId}"); int totalCost = config.baseCost * count; if (!ResourceSystem.Instance.TrySpendResource("energy", totalCost)) return; for (int i = 0; i < count; i++) { WorkerData data = new WorkerData { id = workerId, efficiency = config.baseEfficiency, offlineWeight = config.offlineWeight, hireTime = Time.time }; activeWorkers.Add(data); } }WorkerData是纯数据结构,不继承MonoBehaviour,因此activeWorkers列表可被ResourceSystem.UpdateOfflineProduction()直接遍历计算,避免GetComponent<Worker>()的反射开销。
4.3 经济平衡调试技巧:修改 ScriptableObject 而非硬编码
当需要调整游戏节奏时,绝不修改 C# 中的数值常量,而是:
- 打开
Assets/Resources/Config/Resources/Energy.asset - 修改
offlineProductionMultiplier(离线收益倍率,默认1.0f) - 修改
clickEnergyYield(单次点击基础收益,默认1) - 保存后进入 Play Mode,无需重启编辑器即可生效
提示:若修改后数值未更新,检查
ResourceSystem.Instance是否在Awake()中重新加载了配置——项目中Initialize()方法会强制重载Resources.Load,因此修改.asset文件后必须点击 Unity 编辑器右上角的Play按钮(而非Enter Play Mode快捷键),确保Awake()重新执行。
5. 构建 WebGl 时的 I/O 陷阱与存档持久化方案优化
5.1 WebGL 构建失败根因:IDBFS 写入权限与存档路径冲突
项目在PlayerPrefs基础上扩展了SaveSystem.cs,使用System.IO.File.WriteAllText()写入Application.persistentDataPath + "/save.dat"。但在 WebGL 构建时,persistentDataPath指向浏览器 IndexedDB(IDBFS),而 Unity 默认未启用写入权限。常见错误日志:
Failed to write save file: System.UnauthorizedAccessException: Access to the path '.../save.dat' is denied.解决方案分两步:
5.1.1 启用 IDBFS 并挂载虚拟文件系统
在WebGL构建前,在Player Settings > Publishing Settings中勾选:
- ✅Use Preloaded Files(确保
save.dat被预加载) - ✅Decompression Fallback(防止 LZ4 解压失败)
并在index.html的<head>中插入:
<script> Module.onRuntimeInitialized = function() { FS.mkdir('/IDBFS'); FS.mount(IDBFS, {}, '/IDBFS'); FS.syncfs(true, function(err) { if (err) console.error('IDBFS sync failed:', err); }); }; </script>5.1.2 修改 SaveSystem 适配 WebGL
原SaveSystem.SaveGame()方法需判断平台:
// Assets/Scripts/Utils/SaveSystem.cs 第 72 行 public static void SaveGame(GameData data) { string json = JsonUtility.ToJson(data); #if UNITY_WEBGL && !UNITY_EDITOR // WebGL 使用 IDBFS 路径 string savePath = "/IDBFS/save.dat"; try { FS.writeFile(savePath, json); Debug.Log("WebGL save success"); } catch (Exception e) { Debug.LogError($"WebGL save failed: {e.Message}"); } #else // 其他平台使用 persistentDataPath string savePath = Path.Combine(Application.persistentDataPath, "save.dat"); File.WriteAllText(savePath, json); #endif }注意:
FS.writeFile()是 Emscripten 提供的 API,无需额外引用;#if UNITY_WEBGL编译指令确保桌面平台仍走原逻辑。
5.2 存档加密与防篡改:轻量级 CRC32 校验实现
为防止玩家手动修改save.dat,项目在SaveSystem中集成 CRC32 校验:
// Assets/Scripts/Utils/SaveSystem.cs 第 128 行 private static string GenerateChecksum(string json) { byte[] bytes = Encoding.UTF8.GetBytes(json); uint crc = 0xFFFFFFFF; foreach (byte b in bytes) { crc ^= b; for (int i = 0; i < 8; i++) { if ((crc & 1) == 1) crc = (crc >> 1) ^ 0xEDB88320; else crc >>= 1; } } return (crc ^ 0xFFFFFFFF).ToString("X8"); // 8位十六进制 } public static bool LoadGame(out GameData data) { string json; #if UNITY_WEBGL && !UNITY_EDITOR json = FS.readFile("/IDBFS/save.dat", { encoding: 'utf8' }); #else string savePath = Path.Combine(Application.persistentDataPath, "save.dat"); json = File.ReadAllText(savePath); #endif // 校验 CRC32 int splitIndex = json.LastIndexOf('|'); if (splitIndex == -1 || splitIndex >= json.Length - 9) { data = default; return false; } string savedChecksum = json.Substring(splitIndex + 1); string content = json.Substring(0, splitIndex); if (savedChecksum != GenerateChecksum(content)) { Debug.LogWarning("Save file corrupted or tampered!"); data = default; return false; } data = JsonUtility.FromJson<GameData>(content); return true; }存档格式变为{"energy":123,"workers":[...]...}|A1B2C3D4,末尾 8 位为 CRC32 校验码。此方案不依赖外部库,且校验开销低于 1ms(对 50KB JSON),适合挂机游戏高频存档场景。
5.3 离线模式下资源溢出防护:动态限幅与软上限策略
当玩家长时间离线,UpdateOfflineProduction()可能产生超大数值(如energy达int.MaxValue)。项目采用双层防护:
- 硬限幅:在
ResourceSystem.AddResource()中:
public void AddResource(string resourceId, int amount) { if (!currentResources.ContainsKey(resourceId)) return; int newValue = currentResources[resourceId] + amount; // 防整数溢出 if (newValue > int.MaxValue - 1000000 || newValue < int.MinValue + 1000000) { currentResources[resourceId] = int.MaxValue - 1000000; Debug.LogWarning($"{resourceId} capped at max safe value"); return; } currentResources[resourceId] = newValue; }- 软上限提示:当
energy超过10000000时,UI 弹出提示:
// Assets/Scripts/UI/ResourceDisplay.cs 第 67 行 if (resourceId == "energy" && value > 10000000) { TooltipManager.Show("Energy cap reached! Build more facilities to increase storage."); }此设计避免数值爆炸导致 UI 文本渲染异常(如9999999999999999999超出 TMP 字体 atlas 宽度),同时引导玩家进行设施升级,符合“挂机+建设”的核心循环。
本文还有配套的精品资源,点击获取