根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:

```
docs(readme): 更新文档说明

- 添加项目使用指南
- 完善API接口说明
- 修正错误的配置示例
```

注意:由于您提供的code differences部分为空,以上是一个示例模板。当您有具体的代码变更时,请提供详细的变更内容以便生成更准确的commit message。
This commit is contained in:
huadexuan committed 2026-10-02 10:04:12 +08:00
1 parent 8d58c2223c
commit 11f1af357d
191 files changed
+2315 -1539

No files matched your search

@@ -0,0 +1,96 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
/// <summary>
/// 底部英雄卡片的拖拽:拖到网格格位上放置英雄。
/// 吸附与放置由 FormationManager 负责,卡片只负责拖动表现和回调。
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public class FormationCardDrag : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[Header("界面引用")]
public Image background;
public Image icon;
public Text nameText;
[HideInInspector] public FormationManager manager;
[HideInInspector] public int heroIndex;
private RectTransform rectTransform;
private CanvasGroup canvasGroup;
private Vector2 homePosition;
private static readonly Color NormalColor = new Color(0.15f, 0.15f, 0.2f, 0.92f);
private static readonly Color PlacedColor = new Color(0.18f, 0.18f, 0.22f, 0.45f);
private void Awake()
{
rectTransform = GetComponent<RectTransform>();
canvasGroup = GetComponent<CanvasGroup>();
homePosition = rectTransform.anchoredPosition;
}
/// <summary>填入名字与头像。</summary>
public void SetContent(string displayName, Sprite iconSprite)
{
if (nameText != null) nameText.text = displayName;
if (icon != null)
{
if (iconSprite != null)
{
icon.sprite = iconSprite;
icon.preserveAspect = true;
icon.color = Color.white;
}
else
{
icon.color = new Color(0.4f, 0.4f, 0.45f, 1f);
}
}
}
/// <summary>已放置的卡片置灰并禁止再次拖拽。</summary>
public void SetPlaced(bool isPlaced)
{
if (background != null) background.color = isPlaced ? PlacedColor : NormalColor;
canvasGroup.alpha = isPlaced ? 0.55f : 1f;
}
public void OnBeginDrag(PointerEventData eventData)
{
if (manager == null || !manager.CanDrag(heroIndex))
{
return;
}
canvasGroup.blocksRaycasts = false;
rectTransform.position = eventData.position;
manager.OnCardDragStart(heroIndex);
}
public void OnDrag(PointerEventData eventData)
{
if (manager == null || !manager.CanDrag(heroIndex))
{
return;
}
rectTransform.position = eventData.position;
manager.OnCardDragging(heroIndex, eventData.position);
}
public void OnEndDrag(PointerEventData eventData)
{
canvasGroup.blocksRaycasts = true;
if (manager == null || !manager.CanDrag(heroIndex))
{
rectTransform.anchoredPosition = homePosition;
return;
}
rectTransform.anchoredPosition = homePosition;
manager.OnCardDragEnd(heroIndex, eventData.position);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: W3MXtSutW3y/K5R9XKZByOrVzg/RVUlLXbdPdYEtloT+moUCWJRKgso=
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+57
View File
@@ -0,0 +1,57 @@
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
/// <summary>
/// 金币 UI 显示,显示在屏幕右上角
/// </summary>
[RequireComponent(typeof(Text))]
public class GoldUI : MonoBehaviour
{
private Text goldText;
private bool isSubscribed = false;
private void Awake()
{
goldText = GetComponent<Text>();
}
private void Start()
{
// 延迟订阅,确保 GoldManager 已初始化
StartCoroutine(SubscribeToGoldManager());
}
private IEnumerator SubscribeToGoldManager()
{
// 等待 GoldManager 初始化
while (GoldManager.Instance == null)
{
yield return null;
}
// 订阅事件
GoldManager.Instance.OnGoldChanged += UpdateGoldDisplay;
isSubscribed = true;
// 立即更新显示
UpdateGoldDisplay(GoldManager.Instance.CurrentGold);
}
private void OnDestroy()
{
// 取消订阅
if (isSubscribed && GoldManager.Instance != null)
{
GoldManager.Instance.OnGoldChanged -= UpdateGoldDisplay;
}
}
private void UpdateGoldDisplay(int newGold)
{
if (goldText != null)
{
goldText.text = $"金币: {newGold}";
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: CSlM5Cv+UyrbON9bAvZxHmwXMP78EZGvGxWgLiRDDo5USD4mK1gBm3M=
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+304
View File
@@ -0,0 +1,304 @@
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// 英雄卡片UI控制
/// 显示英雄状态、血条、死亡倒计时、复活按钮
/// </summary>
public class HeroCardUI : MonoBehaviour
{
[Header("引用")]
public Image iconImage; // 英雄头像
public Image hpFillImage; // 血条填充
public GameObject deathPanel; // 死亡遮罩
public Text reviveTimerText; // 复活倒计时文本
public Button reviveButton; // 立即复活按钮(看广告)
public Text reviveCountText; // 剩余复活次数
[Header("绑定的英雄")]
public UnitBase heroUnit; // 关联的英雄单位
private WorldHealthBar heroHealthBar;
private Vector3 heroSpawnPosition; // 英雄出生位置
[Header("复活设置")]
public float reviveTime = 20f; // 自动复活时间
private float reviveTimer = 0f;
private bool isDead = false;
[Header("广告复活次数")]
public int maxAdRevives = 2; // 每局最多2次
private int remainingAdRevives = 2;
private void Start()
{
// 初始化复活次数
remainingAdRevives = maxAdRevives;
UpdateReviveCountUI();
// 绑定复活按钮
if (reviveButton != null)
{
reviveButton.onClick.AddListener(OnReviveButtonClicked);
}
// 默认隐藏死亡面板
if (deathPanel != null)
{
deathPanel.SetActive(false);
}
}
private void Update()
{
if (heroUnit == null || heroHealthBar == null)
{
return;
}
// 更新血条
UpdateHealthBar();
// 检查死亡状态
if (heroHealthBar.IsDead && !isDead)
{
OnHeroDeath();
}
// 检查是否已复活(外部复活,例如通过其他系统)
if (isDead && !heroHealthBar.IsDead && heroUnit.gameObject.activeSelf)
{
// 已复活,重置UI状态
isDead = false;
if (deathPanel != null)
{
deathPanel.SetActive(false);
}
}
// 更新复活倒计时
if (isDead)
{
UpdateReviveTimer();
}
}
/// <summary>
/// 设置英雄单位
/// </summary>
public void SetHero(UnitBase unit, Sprite icon, Vector3 spawnPos)
{
heroUnit = unit;
heroSpawnPosition = spawnPos;
if (unit != null)
{
heroHealthBar = unit.GetComponent<WorldHealthBar>();
}
// 设置头像
if (iconImage != null && icon != null)
{
iconImage.sprite = icon;
}
isDead = false;
if (deathPanel != null)
{
deathPanel.SetActive(false);
}
}
/// <summary>
/// 更新血条显示
/// </summary>
private void UpdateHealthBar()
{
if (hpFillImage != null && heroHealthBar != null)
{
float fillAmount = heroHealthBar.maxHealth > 0
? heroHealthBar.currentHealth / heroHealthBar.maxHealth
: 0f;
hpFillImage.fillAmount = fillAmount;
}
}
/// <summary>
/// 英雄死亡
/// </summary>
private void OnHeroDeath()
{
isDead = true;
reviveTimer = reviveTime;
// 显示死亡面板
if (deathPanel != null)
{
deathPanel.SetActive(true);
}
// 更新复活按钮状态
UpdateReviveButton();
// 阻止英雄单位被销毁(取消延迟销毁)
if (heroUnit != null)
{
// 取消所有Destroy调用
CancelInvoke();
}
Debug.Log($"英雄 {heroUnit.name} 死亡,{reviveTime}秒后复活");
}
/// <summary>
/// 更新复活倒计时
/// </summary>
private void UpdateReviveTimer()
{
reviveTimer -= Time.deltaTime;
// 更新倒计时文本
if (reviveTimerText != null)
{
reviveTimerText.text = Mathf.CeilToInt(reviveTimer).ToString() + "s";
}
// 时间到,自动复活
if (reviveTimer <= 0f)
{
ReviveHero();
}
}
/// <summary>
/// 复活英雄
/// </summary>
private void ReviveHero()
{
if (heroUnit == null || heroHealthBar == null)
{
return;
}
// 重新激活GameObject(必须先激活,否则无法操作组件)
heroUnit.gameObject.SetActive(true);
// 重新启用渲染器
foreach (Renderer renderer in heroUnit.GetComponentsInChildren<Renderer>())
{
renderer.enabled = true;
}
// 移动到出生位置
heroUnit.transform.position = heroSpawnPosition;
// 重置 NavMeshAgent
var navAgent = heroUnit.GetComponent<UnityEngine.AI.NavMeshAgent>();
if (navAgent != null)
{
navAgent.enabled = true; // 确保启用
if (!navAgent.isOnNavMesh)
{
navAgent.enabled = false;
navAgent.enabled = true;
}
navAgent.ResetPath(); // 清空旧路径
}
// 重置 UnitBase 状态(关键修复:清空目标让单位重新寻敌)
UnitBase unitBase = heroUnit.GetComponent<UnitBase>();
if (unitBase != null)
{
unitBase.enabled = true; // 确保脚本启用
unitBase.target = null; // 清空目标
unitBase.ResetAnchorPosition(); // 重置锚点位置到复活位置(修复一直尝试返回旧锚点的问题)
}
// 重置 Animator
Animator animator = heroUnit.GetComponent<Animator>();
if (animator != null)
{
animator.enabled = true;
animator.Rebind(); // 重置动画状态
animator.Update(0f);
}
// 使用WorldHealthBar的Revive方法(会重置isDead并恢复生命)
heroHealthBar.Revive(heroHealthBar.maxHealth);
// 重新显示血条(修复复活后没有血条的问题)
Transform barTransform = heroHealthBar.transform.Find("WorldHealthBar");
if (barTransform != null)
{
barTransform.gameObject.SetActive(true);
}
// 重置UI状态
isDead = false;
if (deathPanel != null)
{
deathPanel.SetActive(false);
}
Debug.Log($"HeroCardUI: {heroUnit.name} 复活完成");
}
/// <summary>
/// 点击立即复活按钮(看广告)
/// </summary>
private void OnReviveButtonClicked()
{
if (remainingAdRevives <= 0)
{
Debug.LogWarning("本局广告复活次数已用完");
return;
}
if (!isDead)
{
return;
}
// TODO: 播放广告
Debug.Log("播放广告...");
// 扣除复活次数
remainingAdRevives--;
UpdateReviveCountUI();
// 立即复活
ReviveHero();
}
/// <summary>
/// 更新复活按钮状态
/// </summary>
private void UpdateReviveButton()
{
if (reviveButton != null)
{
reviveButton.interactable = remainingAdRevives > 0;
}
}
/// <summary>
/// 更新剩余复活次数UI
/// </summary>
private void UpdateReviveCountUI()
{
if (reviveCountText != null)
{
reviveCountText.text = $"复活 {remainingAdRevives}/{maxAdRevives}";
}
UpdateReviveButton();
}
/// <summary>
/// 重置复活次数(新局开始时调用)
/// </summary>
public void ResetReviveCount()
{
remainingAdRevives = maxAdRevives;
UpdateReviveCountUI();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: CSkfvXukBy7IldLAxOe3n8vVL/8wxK0ZsTjaFFz3gIW77v7oE92PTo4=
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,54 @@
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// 召唤师专属血条 UI:顶部 HUD 显示双方召唤师血量,区别于单位头顶血条。
/// </summary>
public class SummonerHealthBarUI : MonoBehaviour
{
[Header("UI 引用")]
public Image playerHealthFill;
public Image enemyHealthFill;
public Text playerHealthText;
public Text enemyHealthText;
[Header("召唤师引用")]
public GameObject playerSummoner;
public GameObject enemySummoner;
private WorldHealthBar playerHealthBar;
private WorldHealthBar enemyHealthBar;
private void Start()
{
// 获取双方召唤师的血条组件
if (playerSummoner != null)
{
playerHealthBar = playerSummoner.GetComponent<WorldHealthBar>();
}
if (enemySummoner != null)
{
enemyHealthBar = enemySummoner.GetComponent<WorldHealthBar>();
}
}
private void Update()
{
UpdateHealthBar(playerHealthBar, playerHealthFill, playerHealthText);
UpdateHealthBar(enemyHealthBar, enemyHealthFill, enemyHealthText);
}
private void UpdateHealthBar(WorldHealthBar healthBar, Image fillImage, Text healthText)
{
if (healthBar == null || fillImage == null) return;
float ratio = healthBar.currentHealth / healthBar.maxHealth;
fillImage.fillAmount = ratio;
if (healthText != null)
{
healthText.text = $"{Mathf.CeilToInt(healthBar.currentHealth)}/{Mathf.CeilToInt(healthBar.maxHealth)}";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: XXxJsCmvBi4Thu7xe+PZhkE9AAxzPxBPSyGO7deYDxmGPQo1zez8FLA=
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+795
View File
@@ -0,0 +1,795 @@
using UnityEngine;
using UnityEngine.UI;
public class WorldHealthBar : MonoBehaviour
{
// 最大生命值。
[Min(1f)] public float maxHealth = 100f;
// 当前生命值,范围会自动限制在 0 到 maxHealth 之间。
[Min(0f)] public float currentHealth = 100f;
// 技能能量上限,英雄默认使用100点;没有技能的小怪可关闭能量条。
[Header("技能能量")]
public bool showEnergyBar = true;
[Min(1f)] public float maxEnergy = 100f;
// 当前技能能量,范围会自动限制在 0 到 maxEnergy 之间。
[Min(0f)]
[SerializeField]
private float _currentEnergy = 0f;
public float currentEnergy
{
get => _currentEnergy;
set
{
_currentEnergy = Mathf.Clamp(value, 0f, maxEnergy);
UpdateEnergyFill();
}
}
public bool IsEnergyFull => !isDead && currentEnergy >= maxEnergy;
// 血条相对于单位头顶的额外高度。
[Min(0f)] public float heightOffset = 0.25f;
// 所有单位使用统一的血条世界宽度。
[Min(0.1f)] public float barWidth = 1.2f;
// 血条边框颜色。
public Color borderColor = new Color(0.03f, 0.03f, 0.03f, 0.95f);
// 死亡特效持续时间,单位为秒。
[Min(0.1f)] public float deathEffectDuration = 1.1f;
// 死亡时使用的浅蓝色粒子颜色。
public Color deathEffectColor = new Color(0.55f, 0.9f, 1f, 1f);
[Header("复活设置")]
[Tooltip("是否可复活(友方英雄)")]
public bool canRevive = false;
[Header("伤害数字")]
public bool showDamageNumbers = true;
[Min(0.1f)] public float damageNumberDuration = 0.8f;
[Min(0f)] public float damageNumberRiseHeight = 1.2f;
[Min(0f)] public float damageNumberSpawnHeight = 1.5f;
[Min(0.01f)] public float damageNumberSize = 0.09f;
// 描边宽度设为私有常量,所有角色统一使用
private const float DamageNumberOutlineWidth = 0.025f; // 从 0.015 增加到 0.025,描边更粗
// 血条 UI 的像素尺寸,实际显示大小由 worldWidth 控制。
private const int PixelWidth = 100;
private const int PixelHeight = 14;
// 能量条紧贴血条下方,宽度更窄,高度更小。
private const int EnergyPixelWidth = 100; // 改为和血条一样宽
private const int EnergyPixelHeight = 12; // 从 8 增加到 12,更明显
private const int BarGap = 0; // 改为 0,让血条和能量条紧贴在一起
private const int TotalPixelHeight = PixelHeight + BarGap + EnergyPixelHeight;
// 世界空间 Canvas,用来承载血条 UI。
private Canvas canvas;
// 血条整体的 RectTransform。
private RectTransform barRect;
// 绿色血量填充 Image。
private Image fillImage;
// 金色技能能量填充 Image。
private Image energyFillImage;
// 血条相对于单位的本地位置。
private Vector3 localBarPosition;
// 血条最终使用的世界宽度。
private float worldWidth;
// 所有血条共用的圆角 Sprite。
private static Sprite roundedSprite;
// 防止死亡逻辑重复执行。
private bool isDead;
// 公开死亡状态供外部查询(用于胜负判定)
public bool IsDead => isDead;
// 初始化血量和血条 UI。
private void Awake()
{
maxHealth = Mathf.Max(1f, maxHealth);
currentHealth = Mathf.Clamp(currentHealth, 0f, maxHealth);
maxEnergy = Mathf.Max(1f, maxEnergy);
_currentEnergy = Mathf.Clamp(_currentEnergy, 0f, maxEnergy);
CalculateLayout();
CreateBar(); // 创建血条 UI 结构;无技能单位只创建血条
UpdateFill();
UpdateEnergyFill();
}
// 在 Start() 中根据最终标签设置血条颜色,确保 Instantiate 后的标签修改生效。
private void Start()
{
if (fillImage != null)
{
fillImage.color = GetHealthFillColor();
}
}
// 每帧让血条保持在单位头顶,并朝向主摄像机。
private void LateUpdate()
{
if (barRect == null) return;
barRect.localPosition = GetBarRootPosition();
ApplyScaleCompensation();
// 优先使用场景中名称完全为 Main Camera 的摄像机。
Camera camera = FindMainCamera();
if (camera == null) return;
canvas.worldCamera = camera;
if (camera.gameObject.name == "Main Camera")
{
// 使用摄像机的上方向,保证血条在摄像机画面中保持水平。
barRect.rotation = Quaternion.LookRotation(
camera.transform.position - barRect.position,
camera.transform.up);
}
else
{
// 找不到 Main Camera 时,保留原来的展示逻辑。
barRect.rotation = Quaternion.LookRotation(
camera.transform.position - barRect.position,
Vector3.up);
}
}
// 查找场景中名称为 Main Camera 的摄像机;找不到时回退到 Camera.main。
private Camera FindMainCamera()
{
GameObject mainCameraObject = GameObject.Find("Main Camera");
if (mainCameraObject != null)
{
Camera namedCamera = mainCameraObject.GetComponent<Camera>();
if (namedCamera != null && namedCamera.isActiveAndEnabled)
{
return namedCamera;
}
}
return Camera.main;
}
// 直接设置当前生命值,并刷新绿色填充比例。
public void SetHealth(float value)
{
if (isDead) return;
currentHealth = Mathf.Clamp(value, 0f, maxHealth);
UpdateFill();
if (currentHealth <= 0f)
{
Die();
}
}
// 让单位受到伤害,并弹出伤害数字。
// attacker: 攻击者,用于判断受击者(自己)是敌是友,决定数字颜色和浮动方向
public void TakeDamage(float amount, GameObject attacker = null)
{
bool wasDead = isDead;
SetHealth(currentHealth - amount);
if (showDamageNumbers && amount > 0f && !wasDead)
{
// 判断受击者(自己)是否是友方(Friend tag)
bool isVictimFriend = gameObject.CompareTag("Friend");
SpawnDamageNumber(amount, false, isVictimFriend);
}
}
// 不再需要这个方法,直接用 CompareTag 判断
// 让单位恢复生命值,并弹出绿色治疗数字。
public void Heal(float amount)
{
bool wasDead = isDead;
float healed = Mathf.Min(amount, maxHealth - currentHealth);
SetHealth(currentHealth + amount);
if (showDamageNumbers && healed > 0f && !wasDead)
{
SpawnDamageNumber(healed, true);
}
}
/// <summary>
/// 复活单位(用于友方英雄复活系统)
/// </summary>
public void Revive(float healthAmount)
{
if (!canRevive)
{
Debug.LogWarning($"{gameObject.name} 不可复活(canRevive=false)");
return;
}
// 重置死亡状态
isDead = false;
// 恢复生命值
currentHealth = Mathf.Clamp(healthAmount, 0f, maxHealth);
UpdateFill();
Debug.Log($"{gameObject.name} 已复活,生命值: {currentHealth}/{maxHealth}");
}
// 增加技能能量,并返回本次增加后是否达到满能量。
public bool AddEnergy(float amount)
{
if (isDead || amount <= 0f)
{
return false;
}
float previousEnergy = currentEnergy;
currentEnergy = Mathf.Clamp(currentEnergy + amount, 0f, maxEnergy);
UpdateEnergyFill();
return previousEnergy < maxEnergy && currentEnergy >= maxEnergy;
}
// 清空技能能量并刷新能量条。
public void ResetEnergy()
{
SetEnergy(0f);
}
// 设置下方条的当前填充值;单位用于技能能量,建筑用于生产进度。
public void SetEnergy(float value)
{
if (isDead)
{
return;
}
currentEnergy = Mathf.Clamp(value, 0f, maxEnergy);
UpdateEnergyFill();
}
// 处理单位死亡:隐藏模型和血条,播放蓝色向上爆散特效。
private void Die()
{
isDead = true;
currentHealth = 0f;
UpdateFill();
foreach (Renderer renderer in GetComponentsInChildren<Renderer>())
{
renderer.enabled = false;
}
// 立即从敌我扫描中移除,避免死亡等待期间继续被攻击。
gameObject.SetActive(false);
if (barRect != null)
{
barRect.gameObject.SetActive(false);
}
GameObject effect = CreateDeathEffect();
Destroy(effect, deathEffectDuration);
// 如果可以复活(友方英雄),不销毁GameObject
if (!canRevive)
{
Destroy(gameObject, deathEffectDuration);
}
}
// 创建蓝色粒子爆散特效。
private GameObject CreateDeathEffect()
{
GameObject effect = new GameObject("DeathEffect");
effect.transform.position = transform.position + Vector3.up * 1.35f;
ParticleSystem particles = effect.AddComponent<ParticleSystem>();
particles.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
var main = particles.main;
main.playOnAwake = false;
main.duration = deathEffectDuration;
main.startLifetime = new ParticleSystem.MinMaxCurve(0.9f, deathEffectDuration);
main.startSpeed = new ParticleSystem.MinMaxCurve(0.05f, 0.18f);
main.startSize = new ParticleSystem.MinMaxCurve(0.09f, 0.2f);
main.startRotation3D = true;
main.startRotationX = new ParticleSystem.MinMaxCurve(0f, 6.28f);
main.startRotationY = new ParticleSystem.MinMaxCurve(0f, 6.28f);
main.startRotationZ = new ParticleSystem.MinMaxCurve(0f, 6.28f);
main.startColor = deathEffectColor;
main.maxParticles = 32;
main.gravityModifier = new ParticleSystem.MinMaxCurve(1.6f);
main.simulationSpace = ParticleSystemSimulationSpace.World;
main.stopAction = ParticleSystemStopAction.None;
var emission = particles.emission;
emission.rateOverTime = 0f;
emission.SetBursts(new[] { new ParticleSystem.Burst(0f, 18) });
var shape = particles.shape;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.12f;
// 只保留轻微水平散落,主要依靠重力向下坠落。
var velocity = particles.velocityOverLifetime;
velocity.enabled = true;
velocity.space = ParticleSystemSimulationSpace.World;
velocity.x = new ParticleSystem.MinMaxCurve(-0.08f, 0.08f);
velocity.y = new ParticleSystem.MinMaxCurve(-0.25f, -0.05f);
velocity.z = new ParticleSystem.MinMaxCurve(-0.08f, 0.08f);
var collision = particles.collision;
collision.enabled = true;
collision.type = ParticleSystemCollisionType.World;
collision.mode = ParticleSystemCollisionMode.Collision3D;
collision.bounce = 0f;
collision.lifetimeLoss = 0.15f;
var size = particles.sizeOverLifetime;
size.enabled = true;
AnimationCurve fade = new AnimationCurve(
new Keyframe(0f, 1f),
new Keyframe(0.75f, 1f),
new Keyframe(1f, 0f));
size.size = new ParticleSystem.MinMaxCurve(1f, fade);
var renderer = effect.GetComponent<ParticleSystemRenderer>();
renderer.renderMode = ParticleSystemRenderMode.Mesh;
renderer.mesh = CreateCrystalMesh();
renderer.material = CreateParticleMaterial();
particles.Play();
return effect;
}
// 创建低模菱形水晶碎片 Mesh。
private Mesh CreateCrystalMesh()
{
Mesh mesh = new Mesh { name = "DeathCrystal" };
mesh.vertices = new[]
{
new Vector3(0f, 0.5f, 0f), new Vector3(0.5f, 0f, 0f),
new Vector3(0f, 0f, 0.5f), new Vector3(-0.5f, 0f, 0f),
new Vector3(0f, 0f, -0.5f), new Vector3(0f, -0.5f, 0f)
};
mesh.triangles = new[]
{
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1,
5, 2, 1, 5, 3, 2, 5, 4, 3, 5, 1, 4
};
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
// 创建带浅蓝发光效果的水晶材质。
private Material CreateParticleMaterial()
{
Shader shader = Shader.Find("Standard");
Material material = new Material(shader);
material.color = deathEffectColor;
material.SetFloat("_Metallic", 0.15f);
material.SetFloat("_Glossiness", 0.85f);
material.SetColor("_EmissionColor", deathEffectColor * 1.6f);
material.EnableKeyword("_EMISSION");
return material;
}
// 根据单位模型或碰撞体计算血条位置和宽度。
private void CalculateLayout()
{
Bounds bounds = new Bounds(transform.position, new Vector3(1f, 2f, 1f));
bool found = false;
foreach (Renderer renderer in GetComponentsInChildren<Renderer>(true))
{
if (!found)
{
bounds = renderer.bounds;
found = true;
}
else
{
bounds.Encapsulate(renderer.bounds);
}
}
if (!found)
{
CharacterController controller = GetComponent<CharacterController>();
if (controller != null) bounds = controller.bounds;
}
// 血条尺寸固定,不随单位模型大小变化;只根据模型顶部调整位置。
worldWidth = barWidth;
// 水平方向固定对齐单位根节点,只使用模型顶部计算血条高度。
Vector3 top = transform.InverseTransformPoint(new Vector3(transform.position.x, bounds.max.y, transform.position.z));
float localHeightOffset = transform.lossyScale.y != 0f
? heightOffset / transform.lossyScale.y
: heightOffset;
localBarPosition = new Vector3(0f, top.y + localHeightOffset, 0f);
}
// 创建世界空间 Canvas、边框和绿色血量填充。
private void CreateBar()
{
GameObject barObject = new GameObject("WorldHealthBar", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler));
barObject.transform.SetParent(transform, false);
barObject.layer = gameObject.layer;
canvas = barObject.GetComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.sortingOrder = 100;
barObject.GetComponent<CanvasScaler>().dynamicPixelsPerUnit = 10f;
barRect = barObject.GetComponent<RectTransform>();
barRect.sizeDelta = new Vector2(PixelWidth, showEnergyBar ? TotalPixelHeight : PixelHeight);
barRect.localScale = Vector3.one * (worldWidth / PixelWidth);
barRect.localPosition = GetBarRootPosition();
ApplyScaleCompensation();
RectTransform healthRow = CreateRow(barObject.transform, "HealthBar", PixelWidth, PixelHeight, 0);
Image background = CreateImage("Border", healthRow, RoundedSprite(), borderColor);
SetFullRect(background.rectTransform);
// 创建填充 Image,颜色暂时使用默认绿色,在 Start() 中根据最终标签设置
Image inner = CreateImage("Fill", healthRow, RoundedSprite(), Color.green);
inner.rectTransform.anchorMin = Vector2.zero;
inner.rectTransform.anchorMax = Vector2.one;
inner.rectTransform.offsetMin = new Vector2(2f, 2f);
inner.rectTransform.offsetMax = new Vector2(-2f, -2f);
inner.type = Image.Type.Filled;
inner.fillMethod = Image.FillMethod.Horizontal;
inner.fillOrigin = 0;
fillImage = inner;
if (!showEnergyBar)
{
energyFillImage = null;
return;
}
RectTransform energyRow = CreateRow(
barObject.transform,
"EnergyBar",
EnergyPixelWidth,
EnergyPixelHeight,
PixelHeight + BarGap);
Image energyBackground = CreateImage("EnergyBorder", energyRow, RoundedSprite(), borderColor);
SetFullRect(energyBackground.rectTransform);
Image energyInner = CreateImage(
"EnergyFill",
energyRow,
RoundedSprite(),
GetEnergyFillColor());
energyInner.rectTransform.anchorMin = Vector2.zero;
energyInner.rectTransform.anchorMax = Vector2.one;
energyInner.rectTransform.offsetMin = new Vector2(2f, 1f);
energyInner.rectTransform.offsetMax = new Vector2(-2f, -1f);
energyInner.type = Image.Type.Filled;
energyInner.fillMethod = Image.FillMethod.Horizontal;
energyInner.fillOrigin = 0;
energyFillImage = energyInner;
}
private void ApplyScaleCompensation()
{
Vector3 parentScale = transform.lossyScale;
float targetScale = worldWidth / PixelWidth;
barRect.localScale = new Vector3(
parentScale.x != 0f ? targetScale / parentScale.x : targetScale,
parentScale.y != 0f ? targetScale / parentScale.y : targetScale,
parentScale.z != 0f ? targetScale / parentScale.z : targetScale);
}
// 返回整体 Canvas 的位置,使血条中心保持原来的高度。
private Vector3 GetBarRootPosition()
{
float pixelToWorld = worldWidth / PixelWidth;
int barHeight = showEnergyBar ? TotalPixelHeight : PixelHeight;
float worldEnergyOffset = (barHeight - PixelHeight) * 0.5f * pixelToWorld;
float localEnergyOffset = transform.lossyScale.y != 0f
? worldEnergyOffset / transform.lossyScale.y
: worldEnergyOffset;
return localBarPosition - Vector3.up * localEnergyOffset;
}
// 创建一行固定像素高度的世界空间 UI。
private static RectTransform CreateRow(
Transform parent,
string name,
int width,
int height,
int topOffset)
{
GameObject rowObject = new GameObject(name, typeof(RectTransform));
rowObject.transform.SetParent(parent, false);
RectTransform rowRect = rowObject.GetComponent<RectTransform>();
rowRect.anchorMin = new Vector2(0.5f, 1f);
rowRect.anchorMax = new Vector2(0.5f, 1f);
rowRect.pivot = new Vector2(0.5f, 1f);
rowRect.sizeDelta = new Vector2(width, height);
rowRect.anchoredPosition = new Vector2(0f, -topOffset);
return rowRect;
}
private Color GetHealthFillColor()
{
// 使用阵营 ID 判断敌我(支持 PVP)
var unitTeam = GetComponent<UnitTeam>();
if (unitTeam != null && unitTeam.IsLocalPlayerTeam())
{
return new Color(0.1f, 0.8f, 0.18f, 1f); // 绿色(我方)
}
else
{
return new Color(0.88f, 0.12f, 0.12f, 1f); // 红色(敌方)
}
}
private Color GetEnergyFillColor()
{
return new Color(1f, 0.72f, 0.08f, 1f);
}
// 创建一个用于边框或填充的 UI Image。
private static Image CreateImage(string name, Transform parent, Sprite sprite, Color color)
{
GameObject imageObject = new GameObject(name, typeof(RectTransform), typeof(Image));
imageObject.transform.SetParent(parent, false);
Image image = imageObject.GetComponent<Image>();
image.sprite = sprite;
image.color = color;
image.raycastTarget = false;
return image;
}
// 让 UI 元素铺满父级 RectTransform。
private static void SetFullRect(RectTransform rect)
{
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
}
// 根据当前生命值更新绿色填充比例。
private void UpdateFill()
{
if (fillImage != null)
{
fillImage.fillAmount = Mathf.Clamp01(currentHealth / maxHealth);
}
}
// 根据当前技能能量更新金色填充比例。
private void UpdateEnergyFill()
{
if (energyFillImage != null)
{
energyFillImage.fillAmount = Mathf.Clamp01(currentEnergy / maxEnergy);
}
}
// 创建并缓存一个直角矩形白色 Sprite,供边框和填充复用。
private static Sprite RoundedSprite()
{
if (roundedSprite != null) return roundedSprite;
const int width = 64;
const int height = 16;
Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
texture.wrapMode = TextureWrapMode.Clamp;
texture.filterMode = FilterMode.Bilinear;
texture.hideFlags = HideFlags.HideAndDontSave;
// 填充纯白色,无圆角
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
texture.SetPixel(x, y, Color.white);
}
}
texture.Apply();
roundedSprite = Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2(0.5f, 0.5f), width);
roundedSprite.hideFlags = HideFlags.HideAndDontSave;
return roundedSprite;
}
// 在单位头顶生成一个漂浮伤害数字;数字独立于单位存活,死亡单位也能看到最后一击。
// TextMesh 不支持原生描边,用 4 个偏移的黑色副本 + 1 个白色主字组合出黑边效果。
private void SpawnDamageNumber(float amount, bool isHeal, bool isVictimFriend = false)
{
if (DamageNumber.ActiveCount >= DamageNumber.MaxActive)
{
return;
}
GameObject rootObject = new GameObject("DamageNumber");
Vector3 spawnPosition = transform.position + Vector3.up * damageNumberSpawnHeight;
spawnPosition.x += Random.Range(-0.3f, 0.3f);
rootObject.transform.position = spawnPosition;
string text = Mathf.RoundToInt(amount).ToString();
float outline = DamageNumberOutlineWidth;
// 8 方向黑色描边:上下左右 + 四个对角,形成更粗更均匀的描边
CreateNumberText(rootObject.transform, text, new Vector3(-outline, 0f, 0f), Color.black, damageNumberSize, 60); // 左
CreateNumberText(rootObject.transform, text, new Vector3(outline, 0f, 0f), Color.black, damageNumberSize, 60); // 右
CreateNumberText(rootObject.transform, text, new Vector3(0f, -outline, 0f), Color.black, damageNumberSize, 60); // 下
CreateNumberText(rootObject.transform, text, new Vector3(0f, outline, 0f), Color.black, damageNumberSize, 60); // 上
CreateNumberText(rootObject.transform, text, new Vector3(-outline, -outline, 0f), Color.black, damageNumberSize, 60); // 左下
CreateNumberText(rootObject.transform, text, new Vector3(outline, -outline, 0f), Color.black, damageNumberSize, 60); // 右下
CreateNumberText(rootObject.transform, text, new Vector3(-outline, outline, 0f), Color.black, damageNumberSize, 60); // 左上
CreateNumberText(rootObject.transform, text, new Vector3(outline, outline, 0f), Color.black, damageNumberSize, 60); // 右上
// 主字颜色:治疗=绿色,友方受伤=红色,敌方受伤=白色
Color mainColor = isHeal ? Color.green : (isVictimFriend ? Color.red : Color.white);
CreateNumberText(rootObject.transform, text, Vector3.zero, mainColor, damageNumberSize, 61);
DamageNumber number = rootObject.AddComponent<DamageNumber>();
number.duration = damageNumberDuration;
// 友方受伤向下浮动(负值),敌方受伤向上浮动(正值)
number.riseHeight = isVictimFriend ? -damageNumberRiseHeight : damageNumberRiseHeight;
}
// 创建一个配置好的数字 TextMesh 子物体。
private static TextMesh CreateNumberText(Transform parent, string text, Vector3 localOffset, Color color, float characterSize, int sortingOrder)
{
GameObject textObject = new GameObject("Text");
textObject.transform.SetParent(parent, false);
textObject.transform.localPosition = localOffset;
Font font = GetBuiltinFont();
TextMesh textMesh = textObject.AddComponent<TextMesh>();
textMesh.text = text;
textMesh.font = font;
textMesh.fontSize = 40;
textMesh.characterSize = characterSize;
textMesh.anchor = TextAnchor.MiddleCenter;
textMesh.alignment = TextAlignment.Center;
textMesh.fontStyle = FontStyle.Bold;
textMesh.richText = false;
textMesh.color = color;
MeshRenderer textRenderer = textObject.GetComponent<MeshRenderer>();
if (font != null)
{
textRenderer.sharedMaterial = font.material;
}
// 渲染在单位之上、血条(100)之下;白主字必须比黑边高一级,
// 同材质同 sortingOrder 的渲染顺序不确定,后画的黑边会盖住白字。
textRenderer.sortingOrder = sortingOrder;
return textMesh;
}
// 缓存引擎内置字体;Tuanjie 2022.3 使用 LegacyRuntime,旧版本回退 Arial。
private static Font cachedBuiltinFont;
private static Font GetBuiltinFont()
{
if (cachedBuiltinFont == null)
{
try
{
cachedBuiltinFont = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
}
catch
{
cachedBuiltinFont = null;
}
if (cachedBuiltinFont == null)
{
try
{
cachedBuiltinFont = Resources.GetBuiltinResource<Font>("Arial.ttf");
}
catch
{
cachedBuiltinFont = null;
}
}
}
return cachedBuiltinFont;
}
}
// 漂浮伤害数字:上升、缩放回弹、淡出后自动销毁。
// 独立于所属单位存活,即使单位死亡仍会完整播放动画。
public class DamageNumber : MonoBehaviour
{
// 全场同时存在的伤害数字上限,防止极端情况下刷屏。
public const int MaxActive = 60;
public static int ActiveCount { get; private set; }
public float duration = 0.8f;
public float riseHeight = 1.2f;
// 一个数字由主字 + 8 方向描边副本共 9 个 TextMesh 组成,统一驱动透明度。
private TextMesh[] textMeshes;
private Color[] startColors;
private Vector3 startPosition;
private Camera targetCamera;
private float elapsed;
private void Awake()
{
ActiveCount++;
textMeshes = GetComponentsInChildren<TextMesh>();
startColors = new Color[textMeshes.Length];
for (int i = 0; i < textMeshes.Length; i++)
{
startColors[i] = textMeshes[i].color;
}
startPosition = transform.position;
}
private void Start()
{
GameObject mainCameraObject = GameObject.Find("Main Camera");
if (mainCameraObject != null)
{
targetCamera = mainCameraObject.GetComponent<Camera>();
}
if (targetCamera == null)
{
targetCamera = Camera.main;
}
}
private void Update()
{
elapsed += Time.deltaTime;
if (elapsed >= duration)
{
Destroy(gameObject);
return;
}
float t = elapsed / duration;
// 匀加速上升。
Vector3 position = startPosition;
position.y += riseHeight * Mathf.SmoothStep(0f, 1f, t);
transform.position = position;
// 前20%从1.35倍缩放回弹到1倍。
float scale = t < 0.2f ? Mathf.Lerp(1.35f, 1f, t / 0.2f) : 1f;
transform.localScale = Vector3.one * scale;
// 后30%淡出。
float alpha = t < 0.7f ? 1f : 1f - (t - 0.7f) / 0.3f;
for (int i = 0; i < textMeshes.Length; i++)
{
if (textMeshes[i] != null)
{
textMeshes[i].color = new Color(startColors[i].r, startColors[i].g, startColors[i].b, alpha);
}
}
// 每帧朝向摄像机。TextMesh 可读面朝本地-Z,所以 +Z 要背向摄像机,
// 否则文字从背面被看到会左右镜像。
if (targetCamera != null)
{
transform.rotation = Quaternion.LookRotation(
transform.position - targetCamera.transform.position,
targetCamera.transform.up);
}
}
private void OnDestroy()
{
ActiveCount--;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: Dn9Mtir/WynqfJFHQs2/pcVJ6uw/Oddxw5Kl+N/5PfmOw6EGPyoeZ3w=
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: