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

File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: WX9L5imoAXkCHljMap97vqYyzg9I5X1I571UQ4RQ4VxeVSYM4KqSijA=
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+6 -4
View File
@@ -5778,10 +5778,11 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
moveSpeed: 4
clickStopDistance: 0.08
limitToBattlePlatform: 1
xLimits: {x: -7.5, y: 7.5}
zLimits: {x: -14, y: 80}
patrolStopDistance: 0.15
patrolPause: 1.5
patrolRadius: 5
leashRadius: 8
friendlyBuildingTag: Friend
--- !u!4 &1830287376 stripped
Transform:
m_CorrespondingSourceObject: {fileID: -8679921383154817045, guid: 6b859b567d8ba4a7a87c03aa4b87c18c,
@@ -5801,6 +5802,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
attackRange: 8
detectRange: 10
attackPower: 50
attackInterval: 2
modelFacingOffset: 90
+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--;
}
}
+4
View File
@@ -44,6 +44,10 @@
- [2026-09-11 14:55:27] 敌人血条颜色问题已修复:WorldHealthBar 在 Awake() 时根据 GameObject.tag 判断颜色,WaveSpawner 在 Instantiate() 后设置标签会导致时序错误(血条已创建成绿色)。解决方案:直接在敌人 Prefab 上设置 Enemy 标签,确保 Awake() 时标签已正确。已修改狼人/蓝色怪物/弓箭猎人/石巨人四个 Prefab。**Why:** 血条颜色在对象创建时确定,后设置标签无法自动刷新。**How to apply:** 后续新增敌人 Prefab 时直接在 Prefab 上设置 Enemy 标签,不依赖运行时代码设置。
- [2026-09-14 16:00:19] 2026-09-13 玩法大转向:从闯关推图改为"建筑生产+自动作战+主角辅助"的对攻玩法(见《召唤之战-核心玩法.md》)。旧闯关玩法由用户在另一台电脑的分支保存,当前 main 按新玩法开发。提交 beab4cf 已实现:GoldSystem/GoldMine 金币经济、Barracks 兵营产兵、BuildingPlacementManager 建筑摆放、CharacterData 新增 productionInterval/buildingCost、UnitBase/Projectile 支持攻击建筑、CoreBattleTest.scene 验证场景、CameraFollow 重写(WASD/鼠标/触摸拖动+缩放)。胜负目标改为摧毁对方主建筑。**How to apply:** 后续开发以建筑/经济/产兵系统为主线,能量豆召唤相关旧逻辑仅作为历史参考。
- [2026-09-14 18:24:46] 2026-09-14 性能实测(9 个角色 Prefab):大多数 5~8k 三角面/22 骨骼/512 贴图,性能没问题;两个待处理异常——蓝色怪物 34408 面(是量产低价单位,建议减面到 8k 左右)、法师贴图 2048x2048(建议缩到 512)。另建议设计层限制每方场上单位上限(约15)保性能与 2.5D 可读性,并确认方向光关实时阴影,最终以低端安卓真机小游戏包为准。**How to apply:** 后续做性能优化或新增量产型敌人时按此基准控制面数。
- [2026-09-14 21:19:53] 2026-09-13 新增建筑系统核心脚本:BuildingHealth(建筑血量+生产进度接口)、GoldSystem/GoldMine(金币经济与产出)、Barracks(兵营产兵,读取 CharacterData.productionInterval)。WorldHealthBar 增加 SetEnergy() 通用下方条接口,建筑复用该条显示生产进度,单位仍用于技能能量。BuildingPlacementManager 支持虚拟网格吸附、占位检测、金币扣除,新放置建筑自动挂 BuildingHealth。UnitBase/Projectile 的 ApplyDirectDamage/ApplySkillHit/ApplyAreaSkillDamage/HitTarget 均已接入 BuildingHealth,建筑可被近战/技能/范围/飞行物伤害。兵营 Prefab(Assets/Prefabs/建筑/兵营.prefab)已写入 Barracks+WorldHealthBar+BuildingHealth,默认阵营 Friend,场景实例保留 Enemy 覆盖。**Why:** 建筑作为可攻击目标是新对攻玩法的核心,生产进度条复用血条下方条避免额外 UI。**How to apply:** 后续新增建筑类型(防御塔/资源建筑)参照 BuildingHealth 接口;修改血条下方条逻辑时注意区分单位和建筑语义。
- [2026-09-14 21:34:17] 2026-09-14 发现:团结引擎 1.10.2 (macOS) 原生 NavMesh 烘焙失效——空场景放一个静态 Cube,用 NavMeshBuilder.BuildNavMeshData / UpdateNavMeshDataAsync / 经典全局 BuildNavMesh 烘焙全部产出 0 三角形,无报错;收集源(CollectSources)、Job 系统、AI 模块包均正常;换构建目标/域重载/重启编辑器均无效。**Why:** 原生导航烘焙在此安装上深层失效,继续用 NavMesh 会浪费时间。**How to apply:** 本项目寻路不要用 NavMesh/NavMeshAgent/NavMeshSurface,改用纯 C# 网格 A*(见 GridPathfinder.cs);若未来升级团结版本可先重跑最小 Cube 烘焙测试再决定。
- [2026-09-14 22:55:12] 2026-09-14 伤害数字系统(WorldHealthBar.SpawnDamageNumber + DamageNumber 组件)已上线:白字黑边样式(1 白主字 + 4 向偏移黑副本,TextMesh 无原生描边所以用副本组合),入口在 TakeDamage/Heal,全场并发上限 60。两个关键坑:1) TextMesh 可读面朝本地-Z,billboard 必须用 LookRotation(selfPos - cameraPos),方向写反会左右镜像;2) 同材质同 sortingOrder 的多个 TextMesh 渲染顺序不确定,后创建的黑色副本会盖住白字——必须显式分层:黑边 sortingOrder=60、白主字=61,白字必须最后创建或层级更高。**Why:** 用户要求白色黑边,先后修复了镜像和纯黑覆盖问题。**How to apply:** 后续做其他 3D 漂浮文字(暴击、金币)沿用 DamageNumber 模式、朝向写法和 sortingOrder 分层。
### Reference
- [2026-09-09 12:23:17] 飞行物脚本 Projectile.cs 负责锁敌追踪、碰撞检测和伤害结算。箭矢预制体 ArrowProjectile_White.prefab 已配置 Projectile 组件和 CapsuleCollider(Is Trigger)。远程单位需在攻击动画松弦帧添加 OnAttackEffect Event,并在 UnitBase 中设置 attackType=RangedProjectile、projectilePrefab、projectileSpawnPoint。
+3 -3
View File
@@ -1,6 +1,8 @@
{
"dependencies": {
"cn.tuanjie.ai.generators": "file:../.codely-cli/extensions/TJGenerators/Packages/cn.tuanjie.ai.generators",
"cn.tuanjie.ai.graph": "1.0.10",
"cn.tuanjie.codely.bridge": "1.0.81",
"com.qq.weixin.minigame": "https://gitee.com/wechat-minigame/minigame-tuanjie-transform-sdk.git",
"com.unity.2d.sprite": "1.0.0",
"com.unity.adaptiveperformance": "4.0.1",
@@ -43,8 +45,6 @@
"com.unity.modules.video": "1.0.0",
"com.unity.modules.vr": "1.0.0",
"com.unity.modules.wind": "1.0.0",
"com.unity.modules.xr": "1.0.0",
"cn.tuanjie.ai.generators": "file:../.codely-cli/extensions/TJGenerators/Packages/cn.tuanjie.ai.generators",
"cn.tuanjie.codely.bridge": "1.0.81"
"com.unity.modules.xr": "1.0.0"
}
}