news 2026/9/14 15:19:15

Unity环保挂机游戏骨架:ScriptableObject驱动的经济系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity环保挂机游戏骨架:ScriptableObject驱动的经济系统

简介:这是一份面向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.animWaterClearSlot_idle.anim)直接映射到污染类型与清理动作,连Car garbage_idle.anim都对应真实垃圾模型的空闲循环。它解决的不是“怎么让数字变大”,而是“如何让环保行为在离线状态下仍能持续产出纯净能源”。适合想练手 Unity 数据驱动设计、熟悉 ScriptableObject 构建配置表、或需要快速搭建带多星球生态链的休闲游戏原型的开发者——尤其当你已经卡在PlayerPrefs存档崩溃、协程调度混乱、或 UI 数值不同步超过 3 小时的时候。


2. 从 Unity 2020.3.25f1 启动到首屏可交互:环境准备与核心模块加载流程

2.1 Unity 版本兼容性验证与基础依赖配置

项目明确要求 Unity 2020.3.25f1 及以上,这是关键约束。低于该版本会触发UnityEditor.BuildPlayerOptionsenableHeadlessMode的缺失报错;高于 2021.3 则需手动降级UnityEngine.UICanvasScaler兼容性(因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.csAwake()中触发:

// 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()读取FacilityConfigupgradeLevels[0].bonusValues["production_rate"],该值在RecyclingPlant.asset中定义为0.15f(即 15%)。


3. 动画资源与污染类型绑定:从 Poacher_idle.anim 到游戏内行为逻辑映射

3.1 动画命名规则与污染类型注册机制

项目中列出的.anim文件(如Poacher_idle.animWaterClearSlot_idle.anim)并非随意命名,而是严格遵循污染源_动作.anim格式:

  • 前缀PoacherWaterClearSlotDirtSlot_(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.csOnMouseDown()

// 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(默认):包含IdleCleanupDestroyed三个 State
    • IdleState 的 Motion 设为Poacher_idle.anim,Transition 条件为isCleaning == true
    • CleanupState 的 Motion 设为Poacher_cleanup.anim,Exit Time 设为0.95(确保动画播完再跳转)
  • Parameters:
    • isCleaning(Bool):由PollutionObject.OnMouseDown()设置为true
    • isDestroyed(Bool):由CleanupState 的OnStateExit事件触发,调用DestroySelf()

注意:AnimationUtility.GetAnimationClip()是自定义工具方法,实际代码中需确保Assets/Animations/Clips/下存在对应.anim文件,否则cleanupClip为 null,导致仅增加能量但无视觉反馈。

3.3 多星球环境切换与污染类型动态加载

游戏支持PlanetEarthPlanetOceanPlanetForest三种环境,每种环境加载不同的污染配置:

// 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 } }

PlanetConfigpollutionIds字段是字符串数组,例如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为例,其字段结构为:

字段名类型示例值说明
facilityIdstring"recycling_plant"设施唯一标识,用于ResourceSystem查找加成
baseCostint500初始购买成本(单位:energy)
upgradeCostMultiplierfloat1.8f每次升级成本增幅(第2级=500×1.8,第3级=500×1.8²)
upgradeLevelsList[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_ratesolar_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# 中的数值常量,而是:

  1. 打开Assets/Resources/Config/Resources/Energy.asset
  2. 修改offlineProductionMultiplier(离线收益倍率,默认1.0f
  3. 修改clickEnergyYield(单次点击基础收益,默认1
  4. 保存后进入 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()可能产生超大数值(如energyint.MaxValue)。项目采用双层防护:

  1. 硬限幅:在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; }
  1. 软上限提示:当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 宽度),同时引导玩家进行设施升级,符合“挂机+建设”的核心循环。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 15:18:13

数据结构分类地图:从逻辑结构到四大经典类型的完整梳理

先说个判断&#xff1a;数据结构这门课&#xff0c;很多人第一遍学完&#xff0c;记了一堆名词&#xff0c;但你要问他“数组和链表是什么关系”“栈和队列到底算不算线性表”&#xff0c;他反而支支吾吾。我在帮人复习考研、准备面试的过程中&#xff0c;发现绝大多数困惑都出…

作者头像 李华
网站建设 2026/9/14 15:17:50

GeneralUpdate组件:高效差分更新技术解析与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 15:16:22

手把手搭建团队级AI命令行工作流:MCP协议+Git集成实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 15:16:11

Android Gradle编译配置全解析:从基础参数到构建性能调优

写这篇文章的原因是&#xff0c;我最近在帮几个刚转安卓开发的朋友排查编译问题&#xff0c;结果发现十个报了八个卡在build.gradle的配置上。有人把targetSdk和compileSdk写成同一个值导致一堆废弃API警告&#xff0c;有人把依赖版本号用号通配结果某天突然拉下来一个破坏性更…

作者头像 李华
网站建设 2026/9/14 15:15:43

JSP+Servlet+MVC+MySQL:手把手构建高并发手机销售网站

简介&#xff1a;一套基于 JSP 与 MVC 模式、使用 MySQL 作为后台数据库的手机销售网站源码&#xff0c;适合 Java Web 初学者、课程设计及毕业设计人群&#xff0c;可帮助读者理解分层开发思想并掌握动态网站构建流程。资源共 58 个文件&#xff0c;以 13 个 Java 类、9 个 JS…

作者头像 李华