
1. 项目背景与整体设计思路1.1 为什么选择在这个节点做金钱系统做过类银河恶魔城游戏的朋友都知道角色的成长曲线和资源循环是撑起整个探索体验的骨架。在完成了角色移动、攻击、受伤、敌人AI、场景切换这些基础模块之后接下来最需要补上的就是经济系统——也就是让玩家打怪、探索、冒险之后能获得实实在在的回报并且这些回报能反过来影响角色的成长和世界的交互。这次要做的金钱系统核心参考的是经典横版探索游戏里那种“击杀掉落、死亡惩罚、可消费可存储”的闭环设计。具体来说玩家击杀敌人后掉落一种通用货币这种货币可以用来购买道具、解锁功能、开启捷径同时角色死亡时会掉落一部分甚至全部货币在死亡地点需要玩家跑回去捡回来。这个机制看似简单但它直接决定了游戏的风险回报节奏——你敢不敢带着一大笔钱去探索未知区域死了之后要不要冒险回去捡这些决策构成了探索类游戏最核心的张力。从技术实现角度看这个系统涉及几个关键模块货币数据的全局管理、掉落物的物理表现与拾取检测、HUD界面的实时刷新、死亡与重生时的数据持久化。这几个模块之间耦合度不高但每一个都有不少细节坑尤其是HUD部分因为这次还要顺带把Canvas的额外内容一起做了包括货币显示、生命值显示、灵魂/能量条的布局调整。1.2 整体架构选型为什么用单例事件驱动在动手写代码之前先想清楚架构。我见过不少新手朋友做这类系统时习惯把货币数量直接挂在玩家脚本上然后HUD每帧去FindObjectOfType找玩家、读数值、刷新文本。这种做法在小型Demo里能跑但一旦场景里有多个需要读取货币的物体比如商店NPC、存档点、UI面板代码就会变成一团乱麻。这次采用的方案是全局单例管理器 事件驱动刷新。具体来说建一个GameManager或者专门的CurrencyManager用静态实例保证全局唯一货币的增减、查询、持久化都走这个管理器。HUD不直接轮询数值而是订阅一个OnCurrencyChanged事件数值变了才刷新UI。这样做的好处很明显第一任何物体想读写货币都只依赖管理器不依赖玩家第二UI刷新从每帧一次变成事件触发一次性能开销几乎为零第三后面要做存档系统时只需要序列化管理器里的一个字段就行。提示单例模式在Unity里争议不小但在这种小型Demo里对于真正全局唯一的数据货币、游戏状态、存档单例是最直接有效的方案。关键是要控制单例的数量不要什么都往GameManager里塞。1.3 掉落物与拾取的设计取舍货币掉落有两种常见做法一种是击杀敌人后直接加钱屏幕上飘个数字就完事另一种是生成一个物理掉落物玩家走过去或者按拾取键才能捡起来。这次选择后者原因有三第一物理掉落物能给玩家更直观的反馈看到一地闪闪发光的货币成就感更强第二掉落物可以被击飞、可以被场景机关影响增加了战斗的趣味性第三后面要做“磁吸拾取”或者“自动拾取范围”这类升级时物理掉落物是天然的基础。拾取检测这块我选择用触发器Trigger而不是碰撞体Collider。掉落物生成时给一个小的圆形触发器玩家身上本来就有碰撞体两者重叠时触发OnTriggerEnter2D判断是不是玩家是就加钱、播放音效、销毁掉落物。这里有个细节掉落物刚生成时应该有一个短暂的“不可拾取”时间否则敌人死亡瞬间玩家如果贴脸站着会立刻捡到视觉上很突兀。我一般给个0.3到0.5秒的延迟同时让掉落物有一个小的随机初速度看起来像是从敌人身上“爆”出来的。2. 核心细节解析与实操要点2.1 货币管理器的字段设计与持久化预留先看货币管理器需要哪些字段。最核心的是一个int类型的当前货币数量这个没什么好说的。但为了后面做存档和调试我建议再加几个一个是string类型的货币名称虽然这次只有一种货币但保不齐后面要加第二种一个是int类型的货币上限防止数值溢出或者做背包容量限制还有一个bool类型的调试开关打开后可以在Console里打印每次货币变化。持久化方面Unity自带的PlayerPrefs对于Demo来说够用了但它的缺点是只能存基本类型而且在不同平台上的存储位置不透明。更稳妥的做法是写一个简单的JSON存档把货币数量、玩家位置、已解锁功能这些打包成一个对象序列化到Application.persistentDataPath下。这次先不做完整存档但管理器里要预留Save()和Load()的空方法后面接存档系统时直接填逻辑就行。public class CurrencyManager : MonoBehaviour { public static CurrencyManager Instance { get; private set; } [SerializeField] private int currentCurrency 0; [SerializeField] private string currencyName Geo; [SerializeField] private int maxCurrency 999999; [SerializeField] private bool debugMode false; public int CurrentCurrency currentCurrency; public string CurrencyName currencyName; public event Actionint OnCurrencyChanged; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } public void AddCurrency(int amount) { if (amount 0) return; currentCurrency Mathf.Min(currentCurrency amount, maxCurrency); OnCurrencyChanged?.Invoke(currentCurrency); if (debugMode) Debug.Log($Added {amount} {currencyName}, total: {currentCurrency}); } public bool SpendCurrency(int amount) { if (amount 0 || currentCurrency amount) return false; currentCurrency - amount; OnCurrencyChanged?.Invoke(currentCurrency); if (debugMode) Debug.Log($Spent {amount} {currencyName}, total: {currentCurrency}); return true; } public void SetCurrency(int amount) { currentCurrency Mathf.Clamp(amount, 0, maxCurrency); OnCurrencyChanged?.Invoke(currentCurrency); } }这段代码里有个容易忽略的点DontDestroyOnLoad。因为货币是跨场景的全局数据切换场景时管理器不能被销毁否则数据就丢了。但要注意如果场景里已经有一个管理器实例新场景加载时又生成了一个就会冲突。所以Awake里要先判断Instance是否为空不为空就销毁自己。这是单例模式的标准写法但新手经常忘记加这个判断导致切换场景后出现两个管理器货币数据错乱。2.2 掉落物预制体的物理参数调优掉落物的预制体结构很简单一个空物体挂SpriteRenderer显示货币图标挂CircleCollider2D设为触发器挂Rigidbody2D设为动态刚体再挂一个自定义的CurrencyPickup脚本。但物理参数的调优才是决定手感的关键。首先是质量Mass。掉落物的质量要设得很小0.1到0.5之间这样它才会被玩家的攻击击飞而不是像块石头一样纹丝不动。其次是阻力Linear Drag设成2到5左右让掉落物飞出去之后很快减速不会滑得太远。重力缩放Gravity Scale设成1到2让它有自然的下落弧线。弹性Bounciness给个0.3到0.5落地时弹一两下视觉上更活泼。初速度的施加方式也有讲究。我一般用Rigidbody2D.AddForce配合一个随机方向方向范围控制在斜上方45度到135度之间力度在3到8之间随机。这样每次掉落的散开效果都不一样不会显得机械。但要注意如果一次掉落多个货币比如击杀Boss掉20个全部同时生成会瞬间产生大量物理计算低端机上可能卡顿。解决办法是分帧生成用协程每隔0.02秒生成一个或者干脆限制单次最大掉落数量超过的部分直接合并成一个“大额货币”掉落物。public class CurrencyPickup : MonoBehaviour { [SerializeField] private int currencyValue 1; [SerializeField] private float pickupDelay 0.4f; [SerializeField] private float magnetRange 0f; [SerializeField] private float magnetSpeed 8f; private bool canBePickedUp false; private Transform playerTransform; private void Start() { StartCoroutine(EnablePickupAfterDelay()); var player GameObject.FindGameObjectWithTag(Player); if (player ! null) playerTransform player.transform; } private IEnumerator EnablePickupAfterDelay() { yield return new WaitForSeconds(pickupDelay); canBePickedUp true; } private void Update() { if (magnetRange 0f playerTransform ! null) { float dist Vector2.Distance(transform.position, playerTransform.position); if (dist magnetRange) { transform.position Vector2.MoveTowards( transform.position, playerTransform.position, magnetSpeed * Time.deltaTime); } } } private void OnTriggerEnter2D(Collider2D other) { if (!canBePickedUp) return; if (!other.CompareTag(Player)) return; CurrencyManager.Instance.AddCurrency(currencyValue); // 播放拾取音效 // 播放拾取粒子效果 Destroy(gameObject); } }这里有个磁吸拾取的预留逻辑。虽然这次不一定用得上但代码里留个magnetRange字段后面做“拾取范围升级”时直接改数值就行不用重构。磁吸的实现是在Update里每帧检测玩家距离小于范围就朝玩家移动。注意这里用的是MoveTowards而不是物理力因为物理力容易让掉落物在玩家周围乱飞MoveTowards更可控。2.3 HUD Canvas的布局策略与锚点设置HUD这块是这次的重头戏。很多新手做UI时习惯用绝对坐标摆位置结果换个分辨率就全乱了。正确的做法是用锚点Anchor和相对布局。Unity的Canvas有几种缩放模式对于横版游戏我推荐用Scale With Screen Size参考分辨率设成1920x1080匹配模式选Match Width Or Height横版游戏一般把匹配值拉到0.5左右这样在不同宽高比下UI不会变形太严重。货币显示放在左上角用Anchor预设的左上角锚点Pivot也设成左上角然后Pos X和Pos Y给一个小的正值比如40和-40这样无论屏幕多大货币图标和数字都固定在左上角往里缩40像素的位置。生命值显示放在货币下面或者放在右上角看个人喜好。我习惯把生命值放左上角货币下方灵魂/能量条放右上角这样视觉重心比较平衡。Canvas的层级顺序也要注意。HUD的Sort Order要设得比游戏世界高但又不能挡住暂停菜单。我一般把HUD的Sort Order设成10暂停菜单设成100这样层级关系清晰。另外HUD里的元素如果不需要接收点击事件记得把Raycast Target关掉减少不必要的性能开销。虽然对现代设备来说这点开销微不足道但养成好习惯总没错。注意Canvas的Render Mode选Screen Space - Overlay还是Screen Space - Camera取决于你要不要在后处理之后渲染UI。如果游戏用了后处理比如Bloom、Color GradingOverlay模式的UI不会被后处理影响看起来更清晰如果想让UI也吃后处理效果比如UI发光就用Camera模式。这次Demo没上后处理用Overlay就行。3. 实操过程与核心环节实现3.1 从零搭建货币管理器的完整步骤第一步在场景里新建一个空物体命名为GameManagers把CurrencyManager脚本挂上去。这个物体后面还可以挂存档管理器、音频管理器、场景加载管理器统一管理全局系统。记得把这个物体放在场景根目录下不要作为任何其他物体的子物体否则DontDestroyOnLoad的行为会出问题。第二步配置CurrencyManager的Inspector参数。Current Currency初始值设0Currency Name填“Geo”Max Currency设999999Debug Mode先打开方便调试。运行游戏后在Console里应该能看到管理器初始化成功的日志如果加了的话。第三步写一个测试脚本按空格键加100货币按回车键花50货币。把这个脚本挂到玩家身上或者任意物体上运行游戏测试。按空格Console应该打印“Added 100 Geo, total: 100”按回车打印“Spent 50 Geo, total: 50”。如果按回车时货币不足50SpendCurrency返回false不扣钱Console不打印消费日志。这一步验证了管理器的基本读写逻辑。第四步做掉落物预制体。在Hierarchy里右键新建2D Object Sprites Circle命名为GeoPickup。给它加Rigidbody2DBody Type选DynamicMass设0.2Linear Drag设3Angular Drag设3Gravity Scale设1.5。加CircleCollider2D勾选Is Trigger半径设0.15左右。挂上CurrencyPickup脚本Currency Value设1Pickup Delay设0.4。把这个物体拖到Project窗口做成预制体然后从场景里删掉。第五步修改敌人死亡逻辑。在敌人脚本的Die()方法里实例化掉落物预制体位置在敌人中心然后给刚体施加随机初速度。如果敌人掉落多个货币用协程分帧生成。这里有个细节实例化时父物体设成什么我建议设成null让掉落物直接放在场景根目录下这样敌人被销毁时掉落物不会跟着消失。如果设成敌人的子物体敌人Destroy时掉落物也没了。private IEnumerator SpawnCurrencyDrops(int totalAmount, int dropCount) { int amountPerDrop totalAmount / dropCount; int remainder totalAmount % dropCount; for (int i 0; i dropCount; i) { int value amountPerDrop (i remainder ? 1 : 0); GameObject drop Instantiate(currencyPrefab, transform.position, Quaternion.identity); drop.GetComponentCurrencyPickup().SetValue(value); Rigidbody2D rb drop.GetComponentRigidbody2D(); Vector2 randomDir new Vector2(Random.Range(-1f, 1f), Random.Range(0.5f, 1.5f)).normalized; rb.AddForce(randomDir * Random.Range(3f, 8f), ForceMode2D.Impulse); yield return new WaitForSeconds(0.03f); } }3.2 HUD货币显示与事件订阅的接线HUD这边先在Canvas下新建一个Panel命名为HUDPanel锚点设成左上角大小设成300x120左右。在Panel里新建一个Image显示货币图标再新建一个TextMeshPro文本显示数字。文本的锚点设成左中Pos X给个正值让它在图标右边。然后写一个HUDCurrencyDisplay脚本挂到Panel或者文本上。这个脚本在OnEnable时订阅CurrencyManager.Instance.OnCurrencyChanged事件在OnDisable时取消订阅。事件回调里更新文本内容。这里有个时序问题如果HUD在CurrencyManager之前AwakeInstance可能还是null。解决办法是在Start里订阅而不是Awake因为Unity保证所有物体的Awake都在任何Start之前执行。或者更稳妥的做法是在OnEnable里先判断Instance是否为空为空就延迟一帧再订阅。public class HUDCurrencyDisplay : MonoBehaviour { [SerializeField] private TextMeshProUGUI currencyText; [SerializeField] private Image currencyIcon; private void Start() { if (CurrencyManager.Instance ! null) { CurrencyManager.Instance.OnCurrencyChanged UpdateDisplay; UpdateDisplay(CurrencyManager.Instance.CurrentCurrency); } } private void OnDestroy() { if (CurrencyManager.Instance ! null) CurrencyManager.Instance.OnCurrencyChanged - UpdateDisplay; } private void UpdateDisplay(int amount) { currencyText.text amount.ToString(); // 可以加一个数字滚动动画这里先直接赋值 } }数字滚动动画是个加分项。当货币增加时不要瞬间跳到新数值而是让数字快速滚动过去视觉上更有“收获感”。实现方式可以用协程每帧把显示值往目标值靠拢用Mathf.MoveTowards或者Mathf.Lerp。滚动速度控制在0.2到0.3秒内完成太快没感觉太慢显得拖沓。3.3 死亡掉落与重生回收的完整逻辑死亡掉落是货币系统的灵魂。玩家死亡时把当前货币的一部分或者全部掉落在死亡位置玩家重生后货币归零需要跑回去捡。这里有几个设计决策掉多少掉全部还是掉一半掉落物会不会消失被敌人捡走怎么办这次Demo采用掉落全部的方案因为这样风险回报的张力最强。死亡时在死亡位置生成一个特殊的“大额掉落物”外观和普通掉落物略有区别比如更大、有光效价值等于玩家死亡时的货币总量。玩家重生后货币归零。这个掉落物永久存在直到玩家捡回来。如果玩家在捡回之前又死了新的掉落物会覆盖旧的——也就是说旧掉落物直接销毁只保留最新的。这个规则要明确否则玩家死两次会有一地掉落物捡起来很混乱。实现上在玩家脚本的Die()方法里先读取CurrencyManager.Instance.CurrentCurrency如果大于0就在死亡位置生成一个GeoPickup把currencyValue设成这个数值同时把pickupDelay设长一点比如1秒防止玩家重生后立刻捡到。然后调用CurrencyManager.Instance.SetCurrency(0)清空货币。重生逻辑里不需要额外处理因为货币已经是0了。提示如果要做“死亡掉落一半”的温和方案把SetCurrency的参数改成currentCurrency / 2就行。但要注意掉落物价值也要相应改成currentCurrency - currentCurrency / 2否则会凭空多出钱来。3.4 场景切换时的数据保持与HUD重建因为CurrencyManager挂了DontDestroyOnLoad切换场景时货币数据不会丢。但HUD是场景内的物体切换场景后旧的HUD被销毁新的HUD需要重新订阅事件。这就是为什么HUDCurrencyDisplay要在Start里订阅而不是Awake——新场景的HUD在Start时CurrencyManager.Instance已经存在了因为它是DontDestroyOnLoad的订阅能成功。但这里有个坑如果新场景的HUD在Start里订阅了事件而旧场景的HUD在OnDestroy里取消订阅顺序上没问题。但如果旧HUD的OnDestroy在新HUD的Start之后才执行会不会把新HUD的订阅也取消掉不会因为C#的事件订阅是基于委托实例的取消订阅时传的是方法引用只会取消自己那个实例的订阅不会影响其他实例。所以放心用。另一个坑是重复订阅。如果HUD物体被反复启用禁用比如打开关闭背包界面OnEnable里订阅、OnDisable里取消订阅要确保成对出现。我见过有人只在OnEnable订阅忘了在OnDisable取消结果打开关闭几次背包后一次货币变化触发了多次UI刷新数字跳得飞快。这种bug排查起来很隐蔽因为逻辑上“看起来”没问题。4. 常见问题与排查技巧实录4.1 掉落物不生成或者生成后不动这是最常见的问题通常有三个原因。第一预制体没挂Rigidbody2D或者Body Type设成了Static导致物理力施加无效。检查预制体的刚体设置确保是Dynamic。第二AddForce调用时机不对。如果刚体还没初始化就施加力力会丢失。解决办法是在Instantiate之后等一帧再施加力或者直接在预制体的Start里施加力。第三掉落物的Collider2D没勾Is Trigger和地面碰撞后卡住了。检查碰撞体设置。还有一种情况是掉落物生成了但瞬间被销毁。这通常是因为OnTriggerEnter2D在pickupDelay之前就触发了而canBePickedUp还是false所以没捡起来。但如果玩家一直站在掉落物上OnTriggerEnter2D只触发一次延迟结束后不会再次触发掉落物就永远捡不起来了。解决办法是在Update里也检测一次或者用OnTriggerStay2D。我一般用OnTriggerStay2D配合canBePickedUp判断这样延迟结束后如果玩家还在范围内立刻就能捡起来。4.2 HUD数字不刷新或者刷新成乱码数字不刷新先检查事件订阅是否成功。在Start里加一行Debug.Log(CurrencyManager.Instance ! null)如果打印false说明管理器实例没找到。可能是管理器没挂DontDestroyOnLoad或者场景里根本没有管理器。如果打印true但数字还是不动检查OnCurrencyChanged事件是否真的被触发了。在AddCurrency里加日志看看有没有打印。刷新成乱码通常是TextMeshPro的字体资源没设置好。TMP需要生成字体图集如果字体图集里没有数字字符就会显示成方块或者问号。解决办法是选中TMP文本组件在Font Asset里选一个包含数字的字体然后点Generate Font Atlas重新生成。另外如果货币数量很大比如超过99999文本可能超出显示区域需要调整字号或者加一个背景框。4.3 死亡掉落物捡不起来或者捡起来数值不对捡不起来先看掉落物的pickupDelay是不是设得太长。如果设了1秒玩家重生后立刻跑过去可能还没到1秒捡不起来。但等1秒后如果玩家还在范围内OnTriggerStay2D会触发应该能捡起来。如果还是不行检查掉落物的Tag和玩家的Tag是否匹配。OnTriggerEnter2D里判断的是other.CompareTag(Player)如果玩家物体的Tag不是“Player”就不会触发。数值不对通常是掉落物的currencyValue没设置对。死亡掉落时SetValue传的是死亡时的货币总量但如果玩家在死亡动画播放期间又获得了货币比如毒圈跳伤害击杀了敌人这个数值会变。解决办法是在死亡瞬间就把货币数量缓存到一个局部变量里生成掉落物时用这个缓存值而不是实时读取。另外如果掉落物被场景切换销毁了比如玩家死亡后场景重置掉落物也会消失货币就永久丢失了。如果不想让玩家损失太惨可以在场景切换时把掉落物转移到新场景但这涉及跨场景引用比较复杂Demo里可以先不做。4.4 常见问题速查表问题现象可能原因排查方法解决方案掉落物不生成预制体未赋值/敌人死亡逻辑未调用检查Inspector引用和日志补上引用在Die()里加日志确认调用掉落物生成后不动刚体类型错误/未施加力检查Rigidbody2D设置改为DynamicInstantiate后施加力掉落物捡不起来延迟未过/Tag不匹配/触发器未勾选检查pickupDelay和Tag用OnTriggerStay2D确认Tag为PlayerHUD数字不刷新事件未订阅/管理器实例为空加日志检查订阅和实例在Start订阅确保管理器DontDestroyOnLoad数字显示为方块TMP字体图集缺字符查看TMP字体资源重新生成字体图集包含数字死亡后货币丢失掉落物被销毁/未生成检查死亡逻辑和场景切换缓存死亡时数值掉落物设DontDestroyOnLoad切换场景后货币归零管理器被销毁/新实例覆盖检查DontDestroyOnLoad和单例判断加单例判重确保只有一个管理器4.5 几个我踩过的坑和独家技巧第一个坑是物理材质。掉落物和地面碰撞时如果地面有物理材质Physics Material 2D且摩擦力很大掉落物会卡在地面边缘不动。解决办法是给掉落物的碰撞体也加一个物理材质摩擦力设0弹性设0.3。或者更简单把掉落物的碰撞体设成触发器不和地面发生物理碰撞只和玩家触发。但这样掉落物会穿过地面掉下去所以需要加一个地面检测落地后把刚体设成Kinematic。这个方案更可控推荐使用。第二个技巧是对象池。如果游戏里敌人很多频繁Instantiate和Destroy掉落物会造成GC压力。用一个简单的对象池掉落物捡起来后不销毁而是设SetActive(false)放回池子下次生成时从池子里取。对象池的实现不难但要注意重置掉落物的状态速度归零、位置归位、canBePickedUp设回false、pickupDelay重新计时。漏掉任何一个都会导致奇怪的bug。第三个技巧是货币变化的音效反馈。每次货币增加时播放一个短促的“叮”声音调随连续拾取次数略微升高形成连击感。这个效果在商业游戏里很常见实现起来也简单维护一个连击计数器每次拾取加一音效的pitch设成1 comboCount * 0.05上限1.5。如果超过0.5秒没有新的拾取连击计数器归零。这个细节能大幅提升拾取的爽快感成本却很低。第四个坑是UI的Canvas Scaler。如果Canvas Scaler的参考分辨率设得不对在不同分辨率下UI要么太大要么太小。我试过在2K屏幕上UI小得看不清在720p屏幕上UI又大得挡住半个屏幕。解决办法是参考分辨率设成1920x1080匹配模式选Match Width Or Height横版游戏把匹配值设成0.5。如果游戏要适配手机竖屏匹配值要拉到1匹配高度因为竖屏时高度更关键。5. 后续扩展方向与个人体会货币系统做完之后下一步自然是商店系统。商店NPC需要读取货币数量显示商品列表玩家点击购买时调用SpendCurrency成功则给玩家道具。商店的UI可以复用HUD的Canvas但要注意层级管理商店面板的Sort Order要高于HUD关闭时恢复。商店的库存和价格可以做成ScriptableObject方便策划配置。另一个扩展方向是多种货币。比如除了通用货币再加一种稀有货币只能通过Boss掉落或者隐藏区域获得。CurrencyManager可以扩展成管理一个货币字典Dictionarystring, int每种货币有独立的名称、上限和事件。HUD上可以只显示通用货币稀有货币在背包界面里显示。这个扩展不难但要在架构初期就预留好接口否则后期重构成本很高。我个人在实际操作中的体会是经济系统的数值平衡比技术实现难得多。技术实现无非是增删改查但“击杀一个敌人掉多少货币”“一个道具卖多少钱”“死亡掉落全部还是部分”这些决策直接决定了游戏的节奏和难度。我建议在Demo阶段先把数值调得宽松一些让玩家能快速体验到购买和升级的乐趣等核心玩法验证之后再收紧数值增加挑战性。毕竟一个让人玩不下去的经济系统技术再完美也没用。最后分享一个小技巧在CurrencyManager里加一个CheatAddCurrency方法只在Debug.isDebugBuild为true时生效按某个快捷键比如F1加1000货币。这样测试商店和升级系统时不用反复刷怪效率高很多。发布正式版时这个功能会自动禁用不用担心玩家作弊。这个技巧我在每个项目里都会用省下的测试时间相当可观。