
1. 项目概述与核心目标最近在复刻《空洞骑士》的Demo做到第二十一集终于要开始处理游戏里最让人有“获得感”的部分之一了——吉欧Geo金钱系统和基础的HUD平视显示器。很多朋友在开发自己的2D动作游戏时往往会先专注于角色移动、攻击这些核心玩法把经济系统留到最后。但实际上一个设计良好的金钱系统不仅能驱动游戏进程更是连接玩家行为与游戏世界反馈的核心纽带。这次我们就来聊聊如何在Unity里从零搭建一个既稳定又易于扩展的吉欧系统并初步构建起承载它的HUD Canvas。简单来说我们要实现的目标很明确第一创建一个GeoManager单例来全局管理玩家的吉欧数量确保数据持久化且线程安全虽然Unity主线程操作但我们要有好的架构意识。第二设计一个直观的HUD_GeoDisplay组件它能实时响应吉欧数量的变化并播放获取/消耗吉欧的动画反馈。第三构建一个灵活的GeoPickup可收集物预制体当玩家角色与之碰撞时能触发拾取逻辑并更新HUD。这个过程会涉及到Unity的UI系统Canvas, TextMeshPro、单例模式、事件C# Action/UnityEvent以及简单的动画状态控制。无论你是刚学完C#基础想找个项目练手还是已经有一定经验但想优化自己的游戏架构相信这个从数据到表现层的完整实现过程都能给你带来一些启发。2. 系统架构设计与核心思路拆解在动手写代码之前我们先花点时间把整个系统的架构理清楚。一个健壮的系统不是一堆脚本的堆砌而是有清晰的责任划分和数据流。对于吉欧系统我们可以将其分为三个核心层数据管理层、游戏逻辑层和表现层。2.1 三层架构解析数据管理层的核心是GeoManager。它必须是一个单例Singleton因为整个游戏中只应该存在一个权威的吉欧数据源。它的职责包括持有并维护当前的吉欧数量提供一个属性如CurrentGeo供外部读取但修改必须通过其提供的方法如AddGeo,SpendGeo。持久化存储使用PlayerPrefs或更专业的序列化方案如JSON文件存储来保存和加载吉欧数据。考虑到《空洞骑士》这类游戏需要跨场景、甚至退出游戏后仍保持数据这是必须的。提供数据变更的事件这是连接数据与表现的关键。当吉欧数量发生变化时GeoManager应该抛出一个事件例如public static event Actionint OnGeoChanged任何关心此变化的UI组件都可以订阅它从而实现解耦。游戏逻辑层的核心是GeoPickup。它代表场景中一个具体的、可被玩家拾取的吉欧物体比如闪闪发光的钱币。它的职责是定义自身价值这个拾取物值多少吉欧例如小钱币值1大钱币值5。处理碰撞检测通过OnTriggerEnter2D检测与玩家的碰撞。触发拾取逻辑碰撞发生后调用GeoManager.Instance.AddGeo(value)来增加吉欧然后销毁自身或播放一个“被吸收”的动画。表现层的核心是HUD_GeoDisplay。它挂载在UI Canvas下的一个专门用于显示吉欧的UI元素上比如一个包含图标和文本的面板。它的职责是监听数据变化在Start()或OnEnable()中订阅GeoManager.OnGeoChanged事件。更新UI文本当事件触发时将新的吉欧数量更新到TextMeshProUGUI组件上。提供视觉反馈不仅仅是改数字。当获得吉欧时文本可以短暂放大、变色或播放一个“”的动画消耗时则可以播放缩小的动画。这能极大地增强玩家的正反馈。2.2 为什么选择事件驱动这里重点说一下事件驱动架构的好处。最直接的做法是在GeoPickup中拾取后直接去查找HUD_GeoDisplay并调用它的更新方法。但这样做耦合度太高。如果以后我们想在拾取时除了更新HUD还要播放全局音效、更新成就系统或者触发任务进度就需要反复修改GeoPickup脚本。而使用事件驱动GeoPickup只负责“通知”系统“吉欧增加了X”至于谁关心、要做什么它一概不管。GeoManager作为中枢广播这个消息HUD_GeoDisplay、AudioManager、AchievementManager都可以独立订阅并做出反应。系统的可扩展性和可维护性会好很多。注意单例模式的陷阱。虽然单例方便全局访问但要小心“单例滥用”。确保GeoManager只管理最核心的、全局唯一的数据。不要把它变成什么都往里塞的“垃圾箱”。另外在Awake()中实现单例时要做好重复实例的检查与销毁防止场景切换时产生多个管理器。3. 核心模块实现与代码详解理论讲完了我们进入实战环节。我会按照数据层-逻辑层-表现层的顺序展示核心代码和实现细节。3.1 GeoManager数据中枢的实现首先创建GeoManager.cs脚本。它不挂载在任何场景物体上我们通过代码创建它。using UnityEngine; using System; // 为了使用Action public class GeoManager : MonoBehaviour { // 1. 单例实例 public static GeoManager Instance { get; private set; } // 2. 当前吉欧数量私有字段公有属性便于控制 private int _currentGeo 0; public int CurrentGeo _currentGeo; // 3. 定义吉欧变化事件 public static event Actionint OnGeoChanged; // 4. 定义存储用的键名 private const string PREFS_GEO_KEY PlayerGeo; private void Awake() { // 单例初始化 if (Instance ! null Instance ! this) { Destroy(this.gameObject); return; } Instance this; DontDestroyOnLoad(this.gameObject); // 跨场景不销毁 LoadGeo(); } // 5. 增加吉欧 public void AddGeo(int amount) { if (amount 0) { Debug.LogWarning($尝试增加非正数的吉欧: {amount}); return; } _currentGeo amount; SaveGeo(); OnGeoChanged?.Invoke(_currentGeo); // 安全触发事件 Debug.Log($获得 {amount} 吉欧当前总计: {_currentGeo}); } // 6. 消耗吉欧 public bool SpendGeo(int amount) { if (amount 0) { Debug.LogWarning($尝试消耗非正数的吉欧: {amount}); return false; } if (_currentGeo amount) { _currentGeo - amount; SaveGeo(); OnGeoChanged?.Invoke(_currentGeo); Debug.Log($消耗 {amount} 吉欧剩余: {_currentGeo}); return true; } else { Debug.Log($吉欧不足需要 {amount}但只有 {_currentGeo}); // 这里可以触发一个“钱不够”的UI提示或音效 return false; } } // 7. 持久化方法 private void SaveGeo() { PlayerPrefs.SetInt(PREFS_GEO_KEY, _currentGeo); PlayerPrefs.Save(); // 建议显式保存 } private void LoadGeo() { _currentGeo PlayerPrefs.GetInt(PREFS_GEO_KEY, 0); // 默认值为0 Debug.Log($加载吉欧: {_currentGeo}); } // 8. 重置吉欧用于调试或新游戏 public void ResetGeo() { _currentGeo 0; SaveGeo(); OnGeoChanged?.Invoke(_currentGeo); } }关键点解析DontDestroyOnLoad: 这行代码保证了GeoManagerGameObject在切换场景时不会被销毁从而维持了数据的连续性。这是实现跨场景单例的常用方法。事件的安全调用?.Invoke(): 使用空条件运算符即使没有订阅者也不会报空引用异常。SpendGeo返回bool: 这是一个很好的设计。消费行为可能失败钱不够通过返回值调用方可以立即知道结果并做出反应比如显示提示。使用PlayerPrefs: 对于Demo或小型项目PlayerPrefs简单够用。但对于更复杂的存档系统需要存储多个变量、字典、列表等建议使用JsonUtility或Newtonsoft.Json将数据类序列化成字符串再存入PlayerPrefs或者直接读写文件。3.2 GeoPickup可收集物的实现接下来创建GeoPickup.cs并将其挂载到吉欧预制体上。这个预制体通常是一个带有SpriteRenderer显示吉欧图片和Collider2D设置为Is Trigger的GameObject。using UnityEngine; public class GeoPickup : MonoBehaviour { [SerializeField] private int geoValue 1; // 在Inspector中可设置价值 [SerializeField] private AudioClip pickupSound; // 拾取音效 [SerializeField] private GameObject pickupEffect; // 拾取粒子特效预制体 private bool isCollected false; // 防止重复拾取 private void OnTriggerEnter2D(Collider2D collision) { // 1. 检查碰撞对象是否是玩家 if (isCollected) return; // 已拾取则直接返回 // 假设玩家有一个Player标签或特定的组件这里用标签检查 if (!collision.CompareTag(Player)) return; // 2. 标记为已拾取防止同一帧内多次触发 isCollected true; // 3. 调用GeoManager增加吉欧 if (GeoManager.Instance ! null) { GeoManager.Instance.AddGeo(geoValue); } else { Debug.LogError(GeoManager实例未找到); } // 4. 播放音效如果有 if (pickupSound ! null AudioManager.Instance ! null) // 假设有AudioManager单例 { AudioManager.Instance.PlaySound(pickupSound); } // 5. 生成拾取特效如果有 if (pickupEffect ! null) { Instantiate(pickupEffect, transform.position, Quaternion.identity); } // 6. 销毁自身或先播放一个动画再销毁 // 可以先禁用渲染器和碰撞体播放一个缩放消失的动画动画结束后再Destroy GetComponentSpriteRenderer().enabled false; GetComponentCollider2D().enabled false; // 假设有一个简单的渐隐动画协程 StartCoroutine(DisappearAnimation()); } private System.Collections.IEnumerator DisappearAnimation() { // 这里可以做一个简单的向上飘动并渐隐的动画 float duration 0.5f; float timer 0f; Vector3 startPos transform.position; while (timer duration) { timer Time.deltaTime; float t timer / duration; transform.position startPos Vector3.up * Mathf.Lerp(0f, 1f, t); // 向上移动 // 如果需要渐隐可以控制SpriteRenderer的Color.a yield return null; } Destroy(gameObject); } }实操心得isCollected标志位非常重要物理触发可能在极短时间内被调用多次这个标志位可以确保拾取逻辑只执行一次避免玩家一次碰撞加了两次钱。延迟销毁与动画直接Destroy(gameObject)会很突兀。更好的做法是立即禁用视觉和碰撞组件然后播放一个简短的“收集动画”如上飘、渐隐、缩放动画结束后再销毁。这能提供更平滑的反馈。依赖检查在调用GeoManager.Instance前做空检查是好习惯可以快速定位问题。3.3 HUD_GeoDisplayUI表现层的实现现在来制作HUD。首先在场景中创建UIGameObject - UI - Canvas。将Canvas的Render Mode设置为Screen Space - Overlay。在Canvas下创建一个Panel命名为GeoHUD并调整其锚点位置比如右上角。在这个Panel里放一个Image吉欧图标和一个TextMeshPro - Text (UI)对象显示数字。然后创建HUD_GeoDisplay.cs脚本并挂载到GeoHUDPanel上。using UnityEngine; using TMPro; // 引入TextMeshPro命名空间 using System.Collections; public class HUD_GeoDisplay : MonoBehaviour { [SerializeField] private TextMeshProUGUI geoText; // 拖拽赋值 [SerializeField] private Animator animator; // 用于控制反馈动画 [SerializeField] private string gainAnimationTrigger Gain; [SerializeField] private string spendAnimationTrigger Spend; private int displayedGeo; // 当前UI上显示的数字用于平滑变化 private void Start() { if (geoText null) { geoText GetComponentInChildrenTextMeshProUGUI(); } if (animator null) { animator GetComponentAnimator(); } // 初始化显示直接获取当前吉欧数 if (GeoManager.Instance ! null) { displayedGeo GeoManager.Instance.CurrentGeo; UpdateUIText(); } // 订阅事件 GeoManager.OnGeoChanged OnGeoChanged; } private void OnDestroy() { // 非常重要取消订阅防止内存泄漏 GeoManager.OnGeoChanged - OnGeoChanged; } private void OnGeoChanged(int newGeoAmount) { // 计算变化量 int delta newGeoAmount - displayedGeo; // 更新目标显示值 displayedGeo newGeoAmount; // 根据变化量触发不同动画 if (delta 0 animator ! null) { animator.SetTrigger(gainAnimationTrigger); } else if (delta 0 animator ! null) { animator.SetTrigger(spendAnimationTrigger); } // 更新UI文本可以在这里做平滑数字变化 // 直接更新 UpdateUIText(); // 或者启动一个协程做数字滚动效果 // StartCoroutine(CountGeoSmoothly(newGeoAmount)); } private void UpdateUIText() { geoText.text displayedGeo.ToString(); } // 可选的平滑计数协程 private IEnumerator CountGeoSmoothly(int targetGeo) { float duration 0.5f; // 计数动画时长 float startValue displayedGeo; float timer 0f; while (timer duration) { timer Time.deltaTime; float t timer / duration; // 使用缓动函数让动画更自然例如Mathf.SmoothStep t Mathf.SmoothStep(0f, 1f, t); int currentDisplay (int)Mathf.Lerp(startValue, targetGeo, t); geoText.text currentDisplay.ToString(); yield return null; } // 确保最终显示准确 geoText.text targetGeo.ToString(); displayedGeo targetGeo; // 更新内部状态 } }UI动画设置在GeoHUDPanel上添加Animator组件。在项目窗口中右键Create - Animator Controller命名为GeoHUD_Animator并拖拽给Animator组件。双击打开Animator Controller窗口。创建两个Trigger类型的参数Gain和Spend。从Entry状态连接到默认的Idle状态。创建两个新的动画状态比如GainAnimation和SpendAnimation。制作两个简单的动画GainAnimation可以让文本的Scale从1放大到1.2再弹回1颜色可以短暂变黄SpendAnimation可以让文本短暂变红并轻微抖动。将这两个动画片段拖入对应的状态。在Animator中设置状态转换从Idle到GainAnimation的条件是GainTrigger被触发从Idle到SpendAnimation的条件是SpendTrigger被触发。两个动画状态都要设置Has Exit Time并勾选确保播放完后自动回到Idle状态。踩坑提醒UI事件订阅与内存泄漏。在Start中订阅事件必须在OnDestroy中取消订阅。如果这个UI对象被动态销毁比如切换HUD风格而不取消订阅GeoManager中的事件列表会一直保留着一个对已销毁对象方法的引用导致内存无法释放这就是内存泄漏。这是一个非常常见且隐蔽的问题。4. Canvas搭建与HUD布局实战有了核心逻辑我们需要一个美观且实用的HUD来承载它。Unity的UI Canvas是功能强大但设置繁琐的系统合理的设置能避免后续很多缩放和适配问题。4.1 Canvas与屏幕适配首先我们专门为游戏HUD创建一个Canvas。在Hierarchy中右键 -UI - Canvas重命名为HUD_Canvas。选中HUD_Canvas在Inspector中做如下关键设置Render Mode:Screen Space - Overlay。这是最常用的HUD模式UI会渲染在所有3D/2D物体之上。UI Scale Mode:Scale With Screen Size。这是实现多分辨率适配的核心。Reference Resolution: 设置为你设计UI时的基准分辨率例如1920 x 1080。这意味着你在这个分辨率下摆放UI元素。Screen Match Mode: 推荐设置为Match Width or Height并将滑块拉到0.5均衡或根据游戏偏向宽屏拉向Width竖屏拉向Height。这个设置决定了在不同宽高比的屏幕上UI是以宽度还是高度为基准进行缩放能有效防止UI在极端比例下变形或溢出屏幕。4.2 吉欧显示面板制作在HUD_Canvas下创建UI - Panel重命名为Geo_Panel。我们将用它来分组吉欧相关的UI元素。锚点Anchors设置这是UI定位的灵魂。选中Geo_Panel在Rect Transform组件中点击左上角的方形锚点预设图标。我们希望它固定在屏幕右上角所以选择右上角的预设Anchor Preset: Top-Right。同时按住Alt键点击这个预设这样Panel的轴心Pivot也会对齐到右上角。位置与大小设置Pos X和Pos Y为负值例如-50, -50让Panel距离屏幕边缘有一点内边距。设置Width和Height为一个合适的值比如300和100。背景你可以取消Image组件的勾选让背景透明或者调整颜色和透明度做一个半透明的背景框。在Geo_Panel内创建子对象图标UI - Image重命名为Icon。将你的吉欧精灵图Sprite拖拽到Source Image。调整其大小和位置锚点设为Left并调整Pos X和Width/Height。文本UI - Text - TextMeshPro重命名为Text_Amount。这是显示数字的地方。锚点设为Stretch左右拉伸这样文本长度变化时能自动适应。将Left和Right的Pos值设置为正数例如60和20让文本区域从图标右侧开始到Panel右侧结束。在TextMeshPro - Text (UI)组件中Text Input: 先输入“0”。Font Asset: 选择一个像素风或风格匹配的字体。Font Size: 设置一个合适的大小如48。Alignment: 水平左对齐垂直居中。Color: 设置为金色或白色高亮显示。为了更好的视觉效果可以给文本添加一个Outline或Shadow效果在TextMeshPro组件下方点击Add Component添加。最后将Text_Amount对象拖拽到HUD_GeoDisplay脚本的geoText字段中。如果Panel上有Animator也一并拖拽赋值。4.3 扩展HUD内容灵魂与血量一个完整的《空洞骑士》风格HUD当然不止吉欧。我们可以用同样的架构思想来扩展。灵魂Soul系统创建一个SoulManager单例管理当前灵魂和灵魂上限一个HUD_SoulDisplayUI组件。灵魂UI通常是一个容器比如多个小圆点或一个条状填充图来显示存量。当角色集中Focus时调用SoulManager.SpendSoul()并播放相应的UI动画。血量Health系统创建HealthManager管理玩家和敌人的血量。HUD上的血量通常用“面具”图标表示每个面具代表一点血。当受伤时HealthManager触发OnHealthChanged事件HUD_HealthDisplay监听并更新面具的显示状态完整、破裂、消失。快速收集栏Quick Item可以创建一个ItemManager和对应的HUD栏用于显示当前装备的护符或法术图标。布局技巧将这些面板Geo, Soul, Health都作为HUD_Canvas的子对象并分别设置它们的锚点到屏幕的四个角如血量在左上灵魂在左中吉欧在右上。使用Horizontal Layout Group和Vertical Layout Group组件可以自动排列同一面板内的多个元素比如多个血格非常方便。5. 调试、优化与常见问题排查系统搭建好了但在实际运行中总会遇到各种问题。这里记录一些我调试过程中遇到的典型问题和解决方案。5.1 常见问题速查表问题现象可能原因排查步骤与解决方案吉欧数量不显示或显示为01.GeoManager实例未创建或初始化失败。2.HUD_GeoDisplay未正确订阅事件。3. UI Text组件未赋值或禁用。1. 检查场景中是否存在GeoManager的GameObject。可以在Awake中加Debug.Log。2. 在HUD_GeoDisplay的Start和OnGeoChanged方法开头加Debug.Log看是否被调用。3. 检查Hierarchy中Text_Amount对象是否激活geoText字段是否在Inspector中拖拽赋值。拾取吉欧后UI不更新1. 事件未触发或订阅关系错误。2.OnGeoChanged事件被多个脚本订阅可能被其他脚本错误地重置了数值。1. 在GeoManager的AddGeo方法里在调用OnGeoChanged前后加Debug.Log确认事件触发。2. 检查项目中是否有其他脚本也订阅了OnGeoChanged并可能修改了geoText。使用Unity的Console窗口的调用堆栈功能点击事件触发的Log可以看到所有调用路径。切换场景后吉欧数量重置GeoManagerGameObject未设置为DontDestroyOnLoad或被新场景中的另一个实例覆盖。1. 确保GeoManager脚本的Awake中有DontDestroyOnLoad。2. 确保GeoManager是单例并且在新场景中不会重复创建。检查单例模式代码是否正确销毁了新实例。拾取音效或特效不播放1.AudioManager或粒子系统未正确设置或引用为空。2. 拾取物在播放动画前就被销毁。1. 在Inspector中检查GeoPickup上的pickupSound和pickupEffect字段是否赋值。2. 确保AudioManager.Instance不为空。3. 将Destroy(gameObject)改为延迟销毁如用协程确保音效和特效有足够时间播放。UI在不同分辨率下错位Canvas的UI Scale Mode或UI元素的锚点设置不正确。1. 确认Canvas的Scale Mode为Scale With Screen Size并设置合理的Reference Resolution。2. 对每个UI元素如Geo_Panel使用锚点预设进行定位而不是直接设置PosX/PosY。对于需要保持宽高比的元素可以结合Aspect Ratio Fitter组件。数字变化生硬没有反馈感UI仅直接更新文本缺乏视觉过渡。1. 实现CountGeoSmoothly协程让数字滚动变化。2. 为Animator制作更丰富的动画状态不仅触发放大缩小还可以加入颜色闪烁、图标抖动等。PlayerPrefs存储的数据被篡改或丢失玩家可能手动清除数据或不同版本键名冲突。1. 对于重要数据考虑使用加密存储简单的XOR或更复杂的AES。2. 在游戏版本更新时如果存储结构变化需要编写数据迁移代码。可以为存储数据增加一个版本号键。5.2 性能优化小贴士UI更新频率OnGeoChanged事件可能在一帧内被频繁触发比如连续拾取多个吉欧。如果CountGeoSmoothly协程每次都被重启会导致数字跳动异常。可以在协程开始前StopCoroutine或者更优的做法是在HUD_GeoDisplay中只记录目标值在Update中平滑地向目标值逼近这样能保证每帧只更新一次UI且过渡平滑。对象池管理GeoPickup在游戏中会频繁生成和销毁。如果吉欧数量很多频繁的Instantiate和Destroy会产生GC垃圾回收压力。可以考虑使用对象池Object Pooling。预先创建一堆吉欧物体并禁用需要时从池中取出激活并设置位置拾取后不是销毁而是放回池中并禁用。Unity 2021版之后提供了内置的ObjectPool类非常好用。事件清理再次强调任何基于委托/事件的脚本在OnDestroy或OnDisable中取消订阅是必须的编程习惯否则会导致难以排查的内存泄漏和空引用异常。5.3 架构扩展思考当前的系统是一个很好的起点。随着游戏复杂度的增加我们可以考虑以下扩展引入观察者模式变体如果觉得静态事件太多难以管理可以引入一个中央的GameEvent系统使用ScriptableObject来创建事件资产实现更松散的耦合。数据与表现彻底分离将GeoManager管理的核心数据当前吉欧数抽象成一个PlayerData的ScriptableObjectGeoManager只负责读写这个资产。这样存档/读档、数据重置会变得更清晰。本地化与格式化在HUD_GeoDisplay的UpdateUIText中可以使用string.Format或ToString(“N0”)来为数字添加千位分隔符让大数字更易读如“1,250”。实现完这个吉欧系统和基础HUD看着自己操控的角色在屏幕上奔跑、击碎环境中的钱罐、UI上的数字随之跳动并伴有清脆的音效和动画反馈那种亲手搭建起游戏世界一环的成就感是无与伦比的。这套模式不仅适用于吉欧更是你构建任何游戏内资源系统经验、弹药、能量的通用蓝图。记住好的系统是迭代出来的先让它跑起来再让它跑得漂亮、跑得稳健。接下来你就可以基于这个框架去挑战更复杂的系统比如商店购买、技能升级等需要消费吉欧的玩法了。