Files
daluandou/Assets/Scripts/UI脚本/WorldHealthBar.cs
T
huadexuan 23c559d9f7 ```
feat(gameplay): 提升单位检测范围并添加法师属性

- 将所有单位的检测范围从默认值提升至100
- 为法师预制体添加护盾、等级等新属性
- 为法师添加暴击、吸血、伤害减免等战斗属性
- 更换法师弹丸预设并调整相关参数
- 添加锚点交战Z线控制功能
- 更新玩家数据中配备的召唤师技能
- 移除多余的纹理配置文件
```
2026-10-07 23:59:17 +08:00

919 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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--;
}
}