☰
Unity类银河恶魔城游戏开发:金钱系统与HUD实现详解
2026/10/24 7:52:39 网站建设 项目流程

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 Action<int> 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 Grading),Overlay模式的UI不会被后处理影响,看起来更清晰;如果想让UI也吃后处理效果(比如UI发光),就用Camera模式。这次Demo没上后处理,用Overlay就行。

3. 实操过程与核心环节实现

3.1 从零搭建货币管理器的完整步骤

第一步,在场景里新建一个空物体,命名为GameManagers,把CurrencyManager脚本挂上去。这个物体后面还可以挂存档管理器、音频管理器、场景加载管理器,统一管理全局系统。记得把这个物体放在场景根目录下,不要作为任何其他物体的子物体,否则DontDestroyOnLoad的行为会出问题。

第二步,配置CurrencyManager的Inspector参数。Current Currency初始值设0,Currency Name填“Geo”,Max Currency设999999,Debug Mode先打开方便调试。运行游戏后,在Console里应该能看到管理器初始化成功的日志(如果加了的话)。

第三步,写一个测试脚本,按空格键加100货币,按回车键花50货币。把这个脚本挂到玩家身上或者任意物体上,运行游戏测试。按空格,Console应该打印“Added 100 Geo, total: 100”;按回车,打印“Spent 50 Geo, total: 50”。如果按回车时货币不足50,SpendCurrency返回false,不扣钱,Console不打印消费日志。这一步验证了管理器的基本读写逻辑。

第四步,做掉落物预制体。在Hierarchy里右键新建2D Object > Sprites > Circle,命名为GeoPickup。给它加Rigidbody2D,Body Type选Dynamic,Mass设0.2,Linear Drag设3,Angular Drag设3,Gravity Scale设1.5。加CircleCollider2D,勾选Is Trigger,半径设0.15左右。挂上CurrencyPickup脚本,Currency Value设1,Pickup 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.GetComponent<CurrencyPickup>().SetValue(value); Rigidbody2D rb = drop.GetComponent<Rigidbody2D>(); 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之前Awake,Instance可能还是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设置改为Dynamic,Instantiate后施加力
掉落物捡不起来延迟未过/Tag不匹配/触发器未勾选检查pickupDelay和Tag用OnTriggerStay2D,确认Tag为Player
HUD数字不刷新事件未订阅/管理器实例为空加日志检查订阅和实例在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可以扩展成管理一个货币字典,Dictionary<string, int>,每种货币有独立的名称、上限和事件。HUD上可以只显示通用货币,稀有货币在背包界面里显示。这个扩展不难,但要在架构初期就预留好接口,否则后期重构成本很高。

我个人在实际操作中的体会是,经济系统的数值平衡比技术实现难得多。技术实现无非是增删改查,但“击杀一个敌人掉多少货币”“一个道具卖多少钱”“死亡掉落全部还是部分”这些决策,直接决定了游戏的节奏和难度。我建议在Demo阶段先把数值调得宽松一些,让玩家能快速体验到购买和升级的乐趣,等核心玩法验证之后再收紧数值,增加挑战性。毕竟,一个让人玩不下去的经济系统,技术再完美也没用。

最后分享一个小技巧:在CurrencyManager里加一个CheatAddCurrency方法,只在Debug.isDebugBuild为true时生效,按某个快捷键(比如F1)加1000货币。这样测试商店和升级系统时不用反复刷怪,效率高很多。发布正式版时这个功能会自动禁用,不用担心玩家作弊。这个技巧我在每个项目里都会用,省下的测试时间相当可观。

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

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

立即咨询