feat(gameplay): 提升单位检测范围并添加法师属性 - 将所有单位的检测范围从默认值提升至100 - 为法师预制体添加护盾、等级等新属性 - 为法师添加暴击、吸血、伤害减免等战斗属性 - 更换法师弹丸预设并调整相关参数 - 添加锚点交战Z线控制功能 - 更新玩家数据中配备的召唤师技能 - 移除多余的纹理配置文件 ```
919 lines
33 KiB
C#
919 lines
33 KiB
C#
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
|
||
public class WorldHealthBar : MonoBehaviour
|
||
{
|
||
// 最大生命值。
|
||
[Min(1f)] public float maxHealth = 100f;
|
||
|
||
// 当前生命值,范围会自动限制在 0 到 maxHealth 之间。
|
||
[Min(0f)] public float currentHealth = 100f;
|
||
|
||
[Header("护盾")]
|
||
public float currentShield = 0f;
|
||
|
||
/// <summary>
|
||
/// 为单位添加护盾
|
||
/// </summary>
|
||
public void AddShield(float amount)
|
||
{
|
||
if (amount <= 0f || isDead) return;
|
||
currentShield += amount;
|
||
UpdateFill();
|
||
Debug.Log($"{gameObject.name} 获得护盾 +{amount:F1},当前护盾: {currentShield:F1}");
|
||
}
|
||
|
||
// 技能能量上限,英雄默认使用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;
|
||
|
||
[Header("等级显示")]
|
||
public int unitLevel = 1;
|
||
|
||
// 所有血条共用的圆角 Sprite 和圆形 Sprite。
|
||
private static Sprite roundedSprite;
|
||
private static Sprite circleSprite;
|
||
private Text levelText;
|
||
private Image levelBadgeBg;
|
||
|
||
// 防止死亡逻辑重复执行。
|
||
private bool isDead;
|
||
|
||
// 公开死亡状态供外部查询(用于胜负判定)
|
||
public bool IsDead => isDead;
|
||
|
||
public void SetLevel(int level)
|
||
{
|
||
unitLevel = Mathf.Max(1, level);
|
||
if (levelText != null)
|
||
{
|
||
levelText.text = unitLevel.ToString();
|
||
}
|
||
}
|
||
|
||
// 初始化血量和血条 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")
|
||
{
|
||
// 面向摄像机背面,保证 UI 左右方向不发生镜像反转
|
||
barRect.rotation = Quaternion.LookRotation(
|
||
barRect.position - camera.transform.position,
|
||
camera.transform.up);
|
||
}
|
||
else
|
||
{
|
||
// 找不到 Main Camera 时,保留原来的展示逻辑。
|
||
barRect.rotation = Quaternion.LookRotation(
|
||
barRect.position - camera.transform.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;
|
||
float originalDamage = amount;
|
||
|
||
// 优先消耗护盾抵扣伤害
|
||
if (currentShield > 0f && amount > 0f)
|
||
{
|
||
float absorbed = Mathf.Min(currentShield, amount);
|
||
currentShield -= absorbed;
|
||
amount -= absorbed;
|
||
Debug.Log($"{gameObject.name} 护盾吸收了 {absorbed:F1} 伤害,剩余护盾: {currentShield:F1}");
|
||
}
|
||
|
||
if (amount > 0f)
|
||
{
|
||
SetHealth(currentHealth - amount);
|
||
}
|
||
|
||
if (showDamageNumbers && originalDamage > 0f && !wasDead)
|
||
{
|
||
// 判断受击者(自己)是否是友方(Friend tag)
|
||
bool isVictimFriend = gameObject.CompareTag("Friend");
|
||
SpawnDamageNumber(originalDamage, 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;
|
||
CreateLevelBadge(barObject.transform);
|
||
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;
|
||
|
||
CreateLevelBadge(barObject.transform);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// 创建圆形 Sprite 用于等级徽章背景。
|
||
private static Sprite CircleSprite()
|
||
{
|
||
if (circleSprite != null) return circleSprite;
|
||
|
||
Texture2D texture = new Texture2D(64, 64, TextureFormat.RGBA32, false);
|
||
float radius = 30f;
|
||
Vector2 center = new Vector2(31.5f, 31.5f);
|
||
|
||
for (int y = 0; y < 64; y++)
|
||
{
|
||
for (int x = 0; x < 64; x++)
|
||
{
|
||
float dist = Vector2.Distance(new Vector2(x, y), center);
|
||
if (dist > radius)
|
||
{
|
||
texture.SetPixel(x, y, Color.clear);
|
||
}
|
||
else
|
||
{
|
||
float alpha = Mathf.Clamp01(radius + 0.5f - dist);
|
||
texture.SetPixel(x, y, new Color(1f, 1f, 1f, alpha));
|
||
}
|
||
}
|
||
}
|
||
texture.Apply();
|
||
circleSprite = Sprite.Create(texture, new Rect(0, 0, 64, 64), new Vector2(0.5f, 0.5f));
|
||
return circleSprite;
|
||
}
|
||
|
||
// 创建血条左侧的圆圈等级徽章
|
||
private void CreateLevelBadge(Transform parent)
|
||
{
|
||
int badgeSize = 26; // 圆圈徽章直径
|
||
GameObject badgeObj = new GameObject("LevelBadge", typeof(RectTransform));
|
||
badgeObj.transform.SetParent(parent, false);
|
||
|
||
RectTransform badgeRect = badgeObj.GetComponent<RectTransform>();
|
||
badgeRect.anchorMin = new Vector2(0f, 1f);
|
||
badgeRect.anchorMax = new Vector2(0f, 1f);
|
||
badgeRect.pivot = new Vector2(1f, 0.5f); // 右边缘切齐,紧贴血条左边缘
|
||
badgeRect.sizeDelta = new Vector2(badgeSize, badgeSize);
|
||
badgeRect.anchoredPosition = new Vector2(2f, -PixelHeight * 0.5f); // 总是对齐第一行血条中心
|
||
|
||
// 外层黑边圆圈
|
||
Image outerCircle = CreateImage("OuterCircle", badgeRect, CircleSprite(), new Color(0.05f, 0.05f, 0.05f, 0.95f));
|
||
SetFullRect(outerCircle.rectTransform);
|
||
|
||
// 内层深色底板
|
||
Image innerCircle = CreateImage("InnerCircle", outerCircle.transform, CircleSprite(), new Color(0.12f, 0.18f, 0.15f, 1f));
|
||
innerCircle.rectTransform.anchorMin = Vector2.zero;
|
||
innerCircle.rectTransform.anchorMax = Vector2.one;
|
||
innerCircle.rectTransform.offsetMin = new Vector2(2f, 2f);
|
||
innerCircle.rectTransform.offsetMax = new Vector2(-2f, -2f);
|
||
levelBadgeBg = innerCircle;
|
||
|
||
// 居中阿拉伯数字 Text
|
||
GameObject textObj = new GameObject("LevelText", typeof(RectTransform), typeof(Text));
|
||
textObj.transform.SetParent(innerCircle.transform, false);
|
||
|
||
RectTransform textRect = textObj.GetComponent<RectTransform>();
|
||
SetFullRect(textRect);
|
||
|
||
levelText = textObj.GetComponent<Text>();
|
||
levelText.text = unitLevel.ToString();
|
||
levelText.font = GetBuiltinFont();
|
||
levelText.fontSize = 18;
|
||
levelText.fontStyle = FontStyle.Bold;
|
||
levelText.alignment = TextAnchor.MiddleCenter;
|
||
levelText.color = new Color(0.95f, 0.98f, 0.95f, 1f);
|
||
|
||
// 加上外发光/描边效果
|
||
var shadow = textObj.AddComponent<Shadow>();
|
||
shadow.effectColor = new Color(0f, 0f, 0f, 0.9f);
|
||
shadow.effectDistance = new Vector2(1f, -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--;
|
||
}
|
||
} |