Unity游戏开发:构建模块化攻击与血量系统的核心架构与实现
1. 项目概述与核心价值
在Unity游戏开发中,无论你是想做一款横版过关的像素游戏,还是一个3D开放世界的RPG,攻击与血量系统都是最基础、最核心的游戏性支柱。它定义了玩家与敌人、玩家与世界交互的基本规则。很多新手开发者可能会觉得,这不就是两个数值的加减法吗?但真正上手实现时,你会发现,一个健壮、可扩展、手感良好的攻击与血量系统,远不止“扣血”这么简单。它涉及到碰撞检测的时机、伤害计算的逻辑、事件驱动的架构、UI的实时反馈以及后续技能、Buff等系统的无缝接入。
我见过不少独立开发者的项目,初期为了快速出原型,把攻击和血量的逻辑全部写在一个脚本里,用public float health和if(collision.tag == “Bullet”)就草草了事。结果到了项目中后期,想要添加一个“暴击”、“伤害吸收盾”或者“伤害类型克制”时,发现代码已经盘根错节,牵一发而动全身,修改成本极高,甚至不得不推倒重来。这个项目的目的,就是带你从零开始,搭建一个模块化、可扩展、易于调试的简单攻击与血量系统。我们不仅会实现“扣血”这个基础功能,更会深入探讨其背后的设计哲学,让你理解如何构建一个能伴随项目一起成长的底层系统,而不是一堆未来会变成“技术债”的临时代码。
2. 系统架构设计与核心思路
在动手写代码之前,我们先要像建筑师画蓝图一样,规划好整个系统的结构。一个糟糕的架构会让后续的维护和扩展举步维艰,而一个好的架构则能让新功能的添加如搭积木般顺畅。
2.1 为何选择组件化与事件驱动
我们的核心设计思路是高内聚、低耦合的组件化架构,并辅以事件驱动的通信机制。这是什么意思呢?
- 组件化(高内聚):我们把不同的功能拆分到独立的脚本(组件)中。比如,一个
Health组件只负责管理生命值、承受伤害、治疗和死亡;一个Attack组件只负责发起攻击、计算伤害、触发攻击动画和特效。它们各自管理自己的数据和逻辑,不越界干涉其他组件的工作。这就是“高内聚”。 - 事件驱动(低耦合):组件之间不直接互相调用方法。比如,当
Attack组件命中目标时,它不会直接调用目标对象的Health.TakeDamage()方法。相反,它会“发布”一个事件,比如OnDamageDealt,事件里携带了伤害值、攻击者等信息。而目标的Health组件则“订阅”了这个事件(或类似的事件),在接收到事件后,自行处理伤害逻辑。这样,Attack组件完全不知道Health组件是如何实现的,它们之间通过一个中立的事件通道进行通信,依赖关系被降到最低。这就是“低耦合”。
这样做的好处是巨大的:
- 易于调试:每个组件功能单一,出了问题很容易定位。
- 高度可复用:
Health组件可以挂在玩家、敌人、甚至一个可破坏的木箱上。Attack组件可以用于近战武器、远程枪械、甚至陷阱。 - 便于扩展:未来要添加一个“伤害数字弹出”功能,你只需要创建一个新的
DamageNumber组件,让它订阅伤害事件即可,完全不需要修改Health或Attack组件的代码。 - 支持多人游戏:事件驱动的模型非常适合网络同步,你可以将关键事件(如造成伤害、角色死亡)进行网络广播。
2.2 核心组件与数据流设计
基于以上思路,我们规划出以下几个核心组件:
Health (健康/血量组件):
- 职责:管理一个游戏实体的生命值状态。
- 核心数据:当前生命值(
CurrentHealth)、最大生命值(MaxHealth)。 - 核心方法:
TakeDamage(float damage)(承受伤害)、Heal(float amount)(治疗)。 - 触发事件:
OnHealthChanged(血量变化时)、OnDeath(死亡时)。
Attack (攻击组件):
- 职责:定义一次攻击的行为。
- 核心数据:基础伤害(
BaseDamage)、攻击范围/距离(AttackRange)、攻击间隔(AttackCooldown)。 - 核心方法:
PerformAttack()(执行攻击逻辑)。 - 触发事件:
OnAttackPerformed(攻击动作发出时)、OnHitConfirmed(确认命中目标时,并携带伤害数据)。
Damageable (可受伤接口):
- 这不是一个组件,而是一个C#接口(
Interface)。任何带有Health组件并希望被攻击的游戏对象,都可以实现这个接口。Attack组件在检测到命中时,会寻找目标对象是否实现了IDamageable接口,然后调用其TakeDamage方法。这是一种更优雅的依赖方式,进一步降低了耦合。
- 这不是一个组件,而是一个C#接口(
UI_HealthBar (血条UI组件):
- 职责:监听
Health组件的OnHealthChanged事件,实时更新UI血条的显示。
- 职责:监听
数据流示例:
- 玩家按下攻击键 ->
PlayerAttack.PerformAttack()被调用。 PlayerAttack进行射线检测或碰撞体检测,发现命中了敌人。PlayerAttack确认敌人实现了IDamageable接口。PlayerAttack触发OnHitConfirmed事件,并传入一个DamageInfo结构体(包含伤害值、攻击者引用等)。- 敌人的
Health组件订阅了OnHitConfirmed事件(或通过IDamageable接口被直接调用),接收到DamageInfo。 - 敌人的
Health组件执行TakeDamage逻辑,扣减生命值,并触发自身的OnHealthChanged事件。 - 敌人的
UI_HealthBar组件监听到OnHealthChanged事件,更新血条Slider的value。 - 如果生命值<=0,
Health组件触发OnDeath事件。 - 敌人的
Death控制器(另一个独立组件)监听到OnDeath事件,播放死亡动画,销毁对象或触发其他游戏逻辑。
整个流程清晰、模块化,每个环节都职责分明。
3. 核心组件实现与代码详解
理论讲完了,现在我们进入实战环节,一步步实现这些核心组件。我会提供关键代码并解释每一行的意图和注意事项。
3.1 创建可受伤接口(IDamageable)
首先,我们创建一个接口,为所有可受伤对象定义一个契约。
// IDamageable.cs using UnityEngine; public interface IDamageable { /// <summary> /// 承受伤害的通用方法 /// </summary> /// <param name="damageInfo">包含伤害值、攻击者等信息的结构体</param> void TakeDamage(DamageInfo damageInfo); } // DamageInfo.cs [System.Serializable] public struct DamageInfo { public float DamageAmount; // 伤害值 public GameObject DamageSource; // 伤害来源(谁发起的攻击) public Vector3 HitPoint; // 击中点(用于播放受击特效、音效) // 可以扩展:伤害类型(物理、魔法、火焰)、是否暴击等 public DamageInfo(float damage, GameObject source, Vector3 point) { DamageAmount = damage; DamageSource = source; HitPoint = point; } }注意:使用结构体
struct而非类class来传递DamageInfo,是因为它作为小型数据容器,在事件传递中更高效(值类型)。同时,将其设计为可序列化([System.Serializable]),方便在Inspector中调试或配置预制件。
3.2 实现血量组件(Health)
这是系统的核心之一。我们将使用C#的事件(event)和Unity的UnityEvent相结合,前者用于脚本内部高效通信,后者用于在Inspector中可视化地绑定其他组件(如动画、音效),这对设计师和非程序员同事非常友好。
// Health.cs using UnityEngine; using UnityEngine.Events; // 引入UnityEvent命名空间 public class Health : MonoBehaviour, IDamageable // 实现接口 { [Header("Health Settings")] [SerializeField] private float maxHealth = 100f; [SerializeField] private float currentHealth; // 使用C#事件供其他脚本高效订阅 public event System.Action<float, float> OnHealthChanged; // 参数:当前血量,最大血量 public event System.Action<GameObject> OnDeath; // 参数:可能包含击杀者信息 // 使用UnityEvent供Inspector可视化配置 [Header("Events")] public UnityEvent<float, float> onHealthChangedUnityEvent; public UnityEvent onDeathUnityEvent; private bool isDead = false; private void Start() { currentHealth = maxHealth; // 初始化时也触发一次血量更新,确保UI正确显示 InvokeHealthChanged(); } public void TakeDamage(DamageInfo damageInfo) { if (isDead) return; // 已死亡单位不再受到伤害 // 伤害计算可以在这里扩展,比如防御力减伤、伤害类型抵抗等 float finalDamage = damageInfo.DamageAmount; currentHealth -= finalDamage; // 触发血量变化事件 InvokeHealthChanged(); // 可以在这里触发受击特效/音效,使用damageInfo.HitPoint // Debug.Log($"{gameObject.name} took {finalDamage} damage from {damageInfo.DamageSource.name}. Remaining: {currentHealth}"); // 检查死亡 if (currentHealth <= 0) { Die(damageInfo.DamageSource); } } public void Heal(float healAmount) { if (isDead) return; currentHealth = Mathf.Min(currentHealth + healAmount, maxHealth); InvokeHealthChanged(); } private void InvokeHealthChanged() { // 触发C#事件 OnHealthChanged?.Invoke(currentHealth, maxHealth); // 触发UnityEvent onHealthChangedUnityEvent?.Invoke(currentHealth, maxHealth); } private void Die(GameObject killer = null) { if (isDead) return; isDead = true; currentHealth = 0; InvokeHealthChanged(); // 触发死亡事件 OnDeath?.Invoke(killer); onDeathUnityEvent?.Invoke(); // 这里可以处理死亡逻辑,例如播放动画、掉落物品、禁用控制器等。 // 注意:不要立即Destroy(gameObject),否则可能打断动画或事件传递。 // 通常的做法是禁用碰撞体和控制器,播放死亡动画,在动画末尾事件中销毁或回收对象。 // Debug.Log($"{gameObject.name} has died."); } // 在Inspector中调试用的方法 [ContextMenu("Take 10 Damage")] private void DebugTakeDamage() { TakeDamage(new DamageInfo(10, null, transform.position)); } }实操心得:
- 事件双保险:同时提供C#原生事件和
UnityEvent。C#事件性能更好,适合脚本间通信;UnityEvent可以在Inspector里拖拽绑定,比如直接绑定一个播放音效的AudioSource或者触发一个粒子系统,对策划和美术非常方便。- 死亡状态管理:
isDead标志位非常重要。它可以防止一个单位在死亡动画播放期间重复触发死亡逻辑,或者死后还受到伤害。- 延迟销毁:在
Die()方法里,我注释了不要立即Destroy。这是一个常见的坑。立即销毁会中断所有协程、动画和可能还在传递的事件。更好的做法是:禁用角色的移动控制器(CharacterController或Rigidbody)、碰撞体,播放一段死亡动画,然后在动画的最后一帧添加一个事件来调用真正的销毁或对象池回收函数。
3.3 实现攻击组件(Attack)
攻击组件分为近战和远程,逻辑稍有不同。我们先实现一个通用的基础攻击类,再派生出近战攻击。
// BaseAttack.cs using UnityEngine; public abstract class BaseAttack : MonoBehaviour { [Header("Attack Settings")] [SerializeField] protected float baseDamage = 10f; [SerializeField] protected float attackCooldown = 1f; [SerializeField] protected LayerMask targetLayer; // 可以攻击的目标层级 protected float lastAttackTime = -Mathf.Infinity; // 确保第一次可以立即攻击 protected bool canAttack = true; // 事件:攻击动作开始(用于触发动画) public event System.Action OnAttackPerformed; // 事件:确认命中(用于传递伤害信息) public event System.Action<DamageInfo> OnHitConfirmed; public virtual bool TryAttack() { if (!canAttack) return false; if (Time.time < lastAttackTime + attackCooldown) return false; lastAttackTime = Time.time; PerformAttackLogic(); OnAttackPerformed?.Invoke(); // 触发攻击动作事件 return true; } protected abstract void PerformAttackLogic(); // 由子类实现具体攻击逻辑 protected void ConfirmHit(GameObject target, Vector3 hitPoint) { if (target == null) return; // 检查目标是否可受伤 IDamageable damageable = target.GetComponent<IDamageable>(); if (damageable != null) { DamageInfo info = new DamageInfo(baseDamage, gameObject, hitPoint); // 触发命中事件,伤害处理由Health组件监听后执行 OnHitConfirmed?.Invoke(info); // 也可以选择直接调用接口:damageable.TakeDamage(info); // 但通过事件解耦更好,方便扩展“伤害吸收”、“伤害反弹”等中间件逻辑。 } } // 供外部(如输入系统)调用的方法 public void RequestAttack() { TryAttack(); } }现在实现一个简单的近战攻击,比如角色挥动武器。
// MeleeAttack.cs using UnityEngine; public class MeleeAttack : BaseAttack { [Header("Melee Settings")] [SerializeField] private float attackRange = 1.5f; [SerializeField] private Transform attackOrigin; // 攻击起点(通常是手部或武器位置) protected override void PerformAttackLogic() { if (attackOrigin == null) attackOrigin = transform; RaycastHit hit; // 从攻击原点向前方做射线检测 if (Physics.Raycast(attackOrigin.position, attackOrigin.forward, out hit, attackRange, targetLayer)) { Debug.DrawRay(attackOrigin.position, attackOrigin.forward * attackRange, Color.red, 1f); ConfirmHit(hit.collider.gameObject, hit.point); } else { // 未命中,可能也需要播放挥空动画或音效 } } // 在Scene视图中可视化攻击范围 private void OnDrawGizmosSelected() { if (attackOrigin != null) { Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(attackOrigin.position, 0.1f); Gizmos.DrawLine(attackOrigin.position, attackOrigin.position + attackOrigin.forward * attackRange); } } }注意事项:
- 攻击原点:
attackOrigin非常重要,它决定了射线从何处发出。通常应该绑定到角色的手部骨骼或武器模型上,这样攻击范围会随着动画移动,更准确。- 图层掩码(LayerMask):务必在Inspector中设置
targetLayer。例如,玩家的攻击应该只检测“Enemy”层,避免打到自己或场景中的其他物体。这是性能优化和逻辑正确性的关键。- Gizmos可视化:
OnDrawGizmosSelected方法可以在Scene视图里用黄线画出攻击射线,极大方便了调试和调整attackRange参数。
3.4 实现血条UI(UI_HealthBar)
血条需要实时响应Health组件的事件。我们使用经典的Slider组件来实现。
// UI_HealthBar.cs using UnityEngine; using UnityEngine.UI; public class UI_HealthBar : MonoBehaviour { [SerializeField] private Slider healthSlider; [SerializeField] private Health targetHealth; // 关联的Health组件 [SerializeField] private bool hideWhenFull = true; // 满血时是否隐藏 private void Start() { if (healthSlider == null) healthSlider = GetComponentInChildren<Slider>(); if (targetHealth == null) targetHealth = GetComponentInParent<Health>(); // 尝试从父物体查找 if (targetHealth != null) { // 订阅血量变化事件 targetHealth.OnHealthChanged += UpdateHealthBar; // 初始化血条 UpdateHealthBar(targetHealth.CurrentHealth, targetHealth.MaxHealth); } else { Debug.LogWarning("UI_HealthBar: No Health component found to link to.", this); } } private void OnDestroy() { // 非常重要!取消订阅,防止内存泄漏 if (targetHealth != null) { targetHealth.OnHealthChanged -= UpdateHealthBar; } } private void UpdateHealthBar(float currentHealth, float maxHealth) { if (healthSlider == null) return; healthSlider.maxValue = maxHealth; healthSlider.value = currentHealth; // 可选:满血时隐藏血条 if (hideWhenFull && Mathf.Approximately(currentHealth, maxHealth)) { healthSlider.gameObject.SetActive(false); } else if (hideWhenFull) { healthSlider.gameObject.SetActive(true); } } // 提供一个方法,允许外部动态设置监控的目标(例如,玩家切换锁定敌人时) public void SetTarget(Health newTarget) { if (targetHealth != null) { targetHealth.OnHealthChanged -= UpdateHealthBar; } targetHealth = newTarget; if (targetHealth != null) { targetHealth.OnHealthChanged += UpdateHealthBar; UpdateHealthBar(targetHealth.CurrentHealth, targetHealth.MaxHealth); } } }避坑技巧:
- 事件订阅与取消:在
OnDestroy中取消订阅事件是必须的。如果血条UI被销毁(比如敌人死亡),而它仍然订阅着Health的事件,那么Health组件会持有一个对已销毁血条对象的无效引用,这不仅会导致内存泄漏(Health对象无法被垃圾回收),还可能引发MissingReferenceException。这是一个非常常见且隐蔽的Bug。- 查找策略:
GetComponentInParent是一个方便的查找方法,适合将血条UI作为敌人或玩家子物体的情况。你也可以通过其他方式(如Tag、单例模式)来获取玩家的Health引用。- 性能考虑:对于大量敌人的血条(如RTS游戏),频繁更新Canvas UI是性能瓶颈。此时应考虑使用世界空间UI(
World Space Canvas)和对象池,或者使用Shader在模型上绘制血条。
4. 系统集成与场景搭建
现在我们把所有零件组装起来,在Unity编辑器中创建一个可运行的简单战斗场景。
4.1 创建玩家角色
- 在场景中创建一个Cube或导入一个人形模型作为
Player。 - 为
Player添加Health组件。在Inspector中设置Max Health,勾选onDeathUnityEvent,可以临时拖拽一个Debug日志或播放音效来测试。 - 为
Player添加MeleeAttack组件。设置Base Damage、Attack Cooldown。将Target Layer设置为“Enemy”(你需要先在Layer中创建这个层)。把Attack Origin拖拽到代表武器或手部位置的子物体上。 - 创建一个空物体作为
Player的子物体,命名为HealthBarCanvas,为其添加Canvas组件,将Render Mode设置为World Space,并调整合适的大小和位置(如头顶)。在HealthBarCanvas下创建Slider,并配置其背景和填充图。最后,将Slider拖拽给UI_HealthBar脚本的对应字段,脚本会自动找到父物体的Health组件。 - 创建一个脚本
PlayerController来处理输入(这里简化,仅攻击):
// SimplePlayerController.cs using UnityEngine; public class SimplePlayerController : MonoBehaviour { private MeleeAttack meleeAttack; private void Start() { meleeAttack = GetComponent<MeleeAttack>(); } private void Update() { if (Input.GetMouseButtonDown(0)) // 鼠标左键攻击 { if (meleeAttack != null) { meleeAttack.RequestAttack(); } } } }将SimplePlayerController挂到Player上。
4.2 创建敌人角色
- 创建另一个Cube或模型作为
Enemy。将其Layer设置为“Enemy”。 - 同样为
Enemy添加Health组件和UI_HealthBar(世界空间Canvas)。 - 为了让敌人也能反击,可以同样添加
MeleeAttack组件,并将其Target Layer设置为“Player”(需要给Player对象设置“Player”层)。并添加一个简单的AI脚本,比如定期向玩家移动并在接近时攻击。
// SimpleEnemyAI.cs using UnityEngine; public class SimpleEnemyAI : MonoBehaviour { public Transform playerTarget; public float moveSpeed = 2f; public float attackRange = 2f; private MeleeAttack attack; private Health health; private void Start() { attack = GetComponent<MeleeAttack>(); health = GetComponent<Health>(); if (playerTarget == null) { // 简单查找玩家标签,实际项目建议用更高效的方式(如单例、管理器) playerTarget = GameObject.FindGameObjectWithTag("Player")?.transform; } } private void Update() { if (playerTarget == null || health.IsDead) return; float distance = Vector3.Distance(transform.position, playerTarget.position); if (distance > attackRange) { // 移动向玩家 Vector3 direction = (playerTarget.position - transform.position).normalized; transform.position += direction * moveSpeed * Time.deltaTime; transform.LookAt(new Vector3(playerTarget.position.x, transform.position.y, playerTarget.position.z)); } else { // 在攻击范围内,尝试攻击 if (attack != null) { attack.RequestAttack(); } } } }4.3 配置与测试
- 确保
Player和Enemy的Health组件上,onDeathUnityEvent都配置了相应逻辑(例如,播放死亡动画后销毁对象)。 - 运行游戏。控制玩家移动靠近敌人,点击鼠标左键攻击。你应该能看到敌人的血条减少。如果敌人配置了攻击,它也会对玩家造成伤害。
- 打开
Console窗口,观察Health组件中Debug.Log输出的伤害信息,确认系统工作正常。
5. 高级扩展与常见问题排查
一个基础系统搭建完成后,我们来看看如何扩展它,以及开发中可能遇到的“坑”和解决方案。
5.1 系统功能扩展思路
伤害类型与抗性系统:
- 在
DamageInfo结构体中增加DamageType枚举(如Physical, Fire, Ice)。 - 在
Health组件或新建一个Defense组件中,维护一个抗性字典Dictionary<DamageType, float>。 - 在
TakeDamage方法中,根据伤害类型查找抗性系数,计算最终伤害:finalDamage = damageInfo.DamageAmount * (1 - resistance)。
- 在
暴击与闪避:
- 在
Attack组件中增加暴击率(CritChance)、暴击伤害倍数(CritMultiplier)和命中率(Accuracy)。 - 在
ConfirmHit或计算伤害前,先进行随机判定。如果未命中,触发OnAttackMissed事件;如果命中,再判定是否暴击,并修正最终的DamageInfo。
- 在
伤害数字弹出:
- 创建一个
DamageNumber预制件,包含一个向上漂浮并渐隐的TextMeshPro组件。 - 创建一个
DamageNumberManager(单例模式),监听全局的OnHitConfirmed或OnHealthChanged事件。 - 当收到事件时,在击中点(
DamageInfo.HitPoint)附近实例化一个DamageNumber预制件,并设置其文本为伤害值。
- 创建一个
对象池管理:
- 对于频繁创建和销毁的对象,如子弹、特效、敌人,一定要使用对象池。Unity自2021版起内置了
ObjectPool类,非常好用。 - 将敌人的生成和回收改为对象池管理,在
Health的OnDeath事件中,不是Destroy,而是调用ObjectPool.Release(),并重置其状态(血量回满、位置重置等)。
- 对于频繁创建和销毁的对象,如子弹、特效、敌人,一定要使用对象池。Unity自2021版起内置了
5.2 常见问题与解决方案实录
问题1:攻击检测不准确,尤其是快速移动时。
- 原因:在
Update中使用射线检测(Raycast)是瞬时的。如果攻击动画帧和射线检测帧错开,或者物体移动太快,射线可能打不到。 - 解决方案:
- 使用碰撞体检测:为武器绑定一个
Collider(如Box Collider)并设置为Is Trigger。在武器上挂一个WeaponHitbox脚本,在OnTriggerEnter中调用ConfirmHit。这更符合视觉,尤其适用于挥砍类攻击。 - 使用动画事件:在攻击动画的关键帧(如武器挥到最远处)添加动画事件,触发一个方法执行射线或球形检测(
Physics.OverlapSphere)。 - 持续检测:对于持续性的攻击(如火焰喷射),可以在
FixedUpdate中进行持续的OverlapSphere检测,并记录已命中的对象列表避免重复伤害。
- 使用碰撞体检测:为武器绑定一个
问题2:血条UI在世界空间中渲染顺序错乱,被场景物体遮挡。
- 原因:World Space Canvas的渲染顺序和3D场景物体的深度测试问题。
- 解决方案:
- 确保血条Canvas的
Sorting Layer和Order in Layer设置正确,使其在UI渲染层中位于前面。 - 为血条Canvas下的所有UI元素(如Image、Text)的材质使用
UI/Default或UI/Default OverdrawShader,这些Shader通常关闭了深度写入。 - 最简单粗暴但有效的一招:将血条Canvas的
Render Mode改为Screen Space - Camera,然后将其Render Camera指定为主相机。这样血条会一直显示在屏幕固定位置(比如屏幕顶部),通过脚本将血条的世界坐标转换为屏幕坐标来定位。这常用于MOBA或MMO游戏的角色头顶信息。
- 确保血条Canvas的
问题3:事件订阅导致的内存泄漏或空引用异常。
- 现象:敌人死亡被销毁后,控制台偶尔报
MissingReferenceException,指向Health组件的事件。 - 原因:如之前所述,
UI_HealthBar或其他组件订阅了Health.OnHealthChanged事件,但在自身被销毁时没有取消订阅。Health组件还持有对这些已销毁对象的引用。 - 根治方案:
- 严格配对:在
OnEnable中订阅,在OnDisable中取消订阅。这是最规范的写法。 - 使用弱事件:对于复杂的系统,可以考虑使用弱事件模式(
WeakReference),但这在Unity中并不常用,因为Unity的生命周期管理已经很清晰。 - 统一事件中心:引入一个全局的
EventManager单例,所有组件都通过字符串或枚举向中心订阅和发布事件。在组件OnDestroy时,由EventManager统一清理该组件的所有订阅。这增加了架构复杂度,但管理起来更集中。
- 严格配对:在
问题4:多人游戏(Netcode)下的同步问题。
- 挑战:伤害计算必须在服务端进行,客户端只做表现和预测。
- 解决方案(以Unity Netcode for GameObjects为例):
- 将
Health组件改为继承NetworkBehaviour。 - 将
currentHealth改为NetworkVariable<float>,这样它的变化会自动同步给所有客户端。 TakeDamage方法应标记为[ServerRpc],只有服务端有权执行扣血逻辑。- 客户端调用攻击时,先本地播放攻击动画(预测),然后向服务端发送一个
AttackServerRpc请求。服务端验证后,执行伤害计算并同步NetworkVariable。所有客户端的Health组件收到currentHealth变化后,再触发本地的OnHealthChanged事件更新血条。 - 死亡逻辑同样由服务端控制,通过RPC通知客户端播放死亡动画。
- 将
构建一个攻击与血量系统,就像搭建一座房子的地基。我们从一个简单的数值加减开始,逐步引入了组件化、事件驱动、接口等设计模式,让它变得健壮和可扩展。这套架构不仅能满足当前“简单”的需求,更为未来添加暴击、闪避、伤害类型、Buff/Debuff、连击系统等复杂功能预留了清晰的接口和扩展点。记住,好的代码不是一次写成的,而是在清晰的架构下一次次迭代出来的。希望这个详细的实现过程能为你自己的游戏开发之旅打下坚实的基础。在实际项目中,你可能会遇到更多具体问题,但只要你掌握了“高内聚、低耦合”和“事件驱动”这两个核心思想,大部分难题都能找到优雅的解决方案。