feat(UnitBase): 添加单位分散机制和目标选择偏移

- 新增阵型分散参数:separationRadius、separationWeight、targetSpreadBias
- 实现ComputeSeparation方法计算友军分离力,避免单位扎堆
- 添加确定性分散偏移,让不同单位偏好不同目标,避免全部涌向同一敌人
- 在移动逻辑中集成分离向量计算

feat(WorldHealthBar): 实现伤害数字显示系统

- 添加伤害数字相关配置参数:showDamageNumbers、damageNumberDuration等
- 实现TakeDamage和Heal方法中的伤害数字生成逻辑
- 创建DamageNumber组件处理数字的上升、缩放、淡出动画
- 实现白字黑边效果,使用TextMesh副本组合方式实现描边

refactor(CoreBattleTest): 更新AI巡逻和检测参数

- 将clickStopDistance替换为patrolStopDistance、patrolPause、patrolRadius
- 添加leashRadius和friendlyBuildingTag参数
- 为攻击单位增加detectRange检测范围参数

chore(dependencies): 更新包管理依赖配置

- 添加TJGenerators和CodelyBridge包引用
- 调整包依赖顺序,保持XR模块引用

docs(CODELY): 更新开发日志记录

- 添加建筑系统核心脚本实现说明
- 记录NavMesh烘焙问题及解决方案
- 详细说明伤害数字系统的实现要点和技术难点
```
This commit is contained in:
huadexuan committed 2026-09-14 23:11:35 +08:00
1 parent 8d43961f67
commit 4e892d74c4
7 files changed
+2060 -13

No files matched your search

+58 -2
View File
@@ -67,6 +67,13 @@ public class UnitBase : MonoBehaviour
[Min(0f)]
public float energyPerAttack = 20f;
[Header("阵型分散")]
[Min(0f)] public float separationRadius = 1.5f;
[Min(0f)] public float separationWeight = 3f;
[Min(0f)] public float targetSpreadBias = 6f;
private static Collider[] s_separationBuffer = new Collider[30];
//角色状态
private State state = State.Alert;
@@ -305,9 +312,12 @@ public class UnitBase : MonoBehaviour
}
float sqrDistance = (opponent.transform.position - transform.position).sqrMagnitude;
if (sqrDistance < nearestSqrDistance)
//加入确定性分散偏移:让不同单位偏好不同目标,避免全部涌向同一个敌人
float bias = ((uint)(gameObject.GetInstanceID() * 7919) ^ (uint)(opponent.GetInstanceID())) % 10u / 10f * targetSpreadBias;
float adjustedSqrDistance = sqrDistance + bias;
if (adjustedSqrDistance < nearestSqrDistance)
{
nearestSqrDistance = sqrDistance;
nearestSqrDistance = adjustedSqrDistance;
nearestOpponent = opponent;
}
}
@@ -882,6 +892,15 @@ public class UnitBase : MonoBehaviour
void move(Vector3 movement)
{
movement.y = 0;
//友军分离力:与过近的同阵营单位互相推开,避免扎堆
Vector3 separation = ComputeSeparation();
if (separation.sqrMagnitude > 0.0001f)
{
movement += separation * separationWeight;
movement.y = 0;
}
if (movement == Vector3.zero)
{
animator.SetInteger("speed", 0);
@@ -900,6 +919,43 @@ public class UnitBase : MonoBehaviour
transform.rotation = Quaternion.Euler(0f, movementRotation.eulerAngles.y, 0f);
}
//计算与附近同阵营单位的分离向量;越近推力越大。
private Vector3 ComputeSeparation()
{
if (separationRadius <= 0f)
{
return Vector3.zero;
}
int hitCount = Physics.OverlapSphereNonAlloc(transform.position, separationRadius, s_separationBuffer);
Vector3 separation = Vector3.zero;
for (int i = 0; i < hitCount; i++)
{
Collider other = s_separationBuffer[i];
if (other == null || other.gameObject == gameObject)
{
continue;
}
//只推开同阵营单位,敌人间的碰撞由CharacterController自然阻挡
if (!other.CompareTag(gameObject.tag))
{
continue;
}
Vector3 diff = transform.position - other.transform.position;
diff.y = 0f;
float distance = diff.magnitude;
if (distance > 0.01f && distance < separationRadius)
{
separation += diff / distance * (separationRadius - distance);
}
}
return separation;
}
private float getVerticalVelocity()
{
+211 -4
View File
@@ -33,6 +33,14 @@ public class WorldHealthBar : MonoBehaviour
// 死亡时使用的浅蓝色粒子颜色。
public Color deathEffectColor = new Color(0.55f, 0.9f, 1f, 1f);
[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;
[Min(0f)] public float damageNumberOutlineWidth = 0.015f;
// 血条 UI 的像素尺寸,实际显示大小由 worldWidth 控制。
private const int PixelWidth = 100;
private const int PixelHeight = 14;
@@ -146,11 +154,28 @@ public class WorldHealthBar : MonoBehaviour
}
}
// 让单位受到伤害。
public void TakeDamage(float amount) => SetHealth(currentHealth - amount);
// 让单位受到伤害,并弹出伤害数字。
public void TakeDamage(float amount)
{
bool wasDead = isDead;
SetHealth(currentHealth - amount);
if (showDamageNumbers && amount > 0f && !wasDead)
{
SpawnDamageNumber(amount, false);
}
}
// 让单位恢复生命值。
public void Heal(float amount) => SetHealth(currentHealth + amount);
// 让单位恢复生命值,并弹出绿色治疗数字。
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);
}
}
// 增加技能能量,并返回本次增加后是否达到满能量。
public bool AddEnergy(float amount)
@@ -514,4 +539,186 @@ public class WorldHealthBar : MonoBehaviour
roundedSprite.hideFlags = HideFlags.HideAndDontSave;
return roundedSprite;
}
// 在单位头顶生成一个漂浮伤害数字;数字独立于单位存活,死亡单位也能看到最后一击。
// TextMesh 不支持原生描边,用 4 个偏移的黑色副本 + 1 个白色主字组合出黑边效果。
private void SpawnDamageNumber(float amount, bool isHeal)
{
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;
// 黑色描边副本先画(sortingOrder 低)。
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);
// 白色主字最后画(sortingOrder 高),覆盖在黑边之上。
CreateNumberText(rootObject.transform, text, Vector3.zero, Color.white, damageNumberSize, 61);
DamageNumber number = rootObject.AddComponent<DamageNumber>();
number.duration = damageNumberDuration;
number.riseHeight = 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;
// 一个数字由主字+描边副本共5个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--;
}
}