Files
bansonic_beta_main/Assets/scripts/Combat/AllyCombatant.cs
T

681 lines
26 KiB
C#

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
[ExecuteAlways]
/// <summary>
/// AllyCombatant 绑定到场上友军的运行时 GameObject(命名为 ally_01..ally_05),
/// 实现 ICombatant 接口以供 EffectSystem 调用。
/// - 从 teamUIController 获取 UI 引用并同步显示血量/法力
/// - 启动时从 TeamCharacterDataInfo 或 AllyHero_SO 填充最大生命/法力等基础属性
/// - 初始生命值为最大生命值,初始法力为 0
/// </summary>
public class AllyCombatant : MonoBehaviour, ICombatant
{
public int slotIndex = 0; // 0..4 对应 ally_01..ally_05
// runtime stats
public int maxHP = 100;
public int currentHP = 100;
public int maxMana = 100;
public int currentMana = 0;
public float damageResistance = 0f; // 0..1
public float scoreEfficiency = 1f;
private bool isDead = false; // new: dead flag
// add attack stat to be available at runtime
[Tooltip("基础攻击力(运行时使用),会从 AllyHero_SO.levelStats[0].attack 填充(如果存在)")]
public int attack = 0;
// scoring fields
[Header("Scoring")]
[Tooltip("Base score value for a perfect hit on this track")]
public int baseTrackScore = 1000;
[Tooltip("Score multiplier for Great (e.g. 0.75)")]
public float greatRatio = 0.75f;
[Tooltip("Score multiplier for Good (e.g. 0.5)")]
public float goodRatio = 0.5f;
[Tooltip("Score multiplier for Miss (e.g. 0)")]
public float missRatio = 0f;
[Tooltip("角色当前轨道得分(累加),显示于 UI 的 currentScore 文本)")]
public int currentScore = 0;
[Tooltip("轨道分数上限,暂设为 int.MaxValue - 1")]
public int maxTrackScore = int.MaxValue - 1;
// When true, InitializeStatsFromData() will overwrite inspector values on Start.
// Set to false if you want to keep manual inspector values when entering Play mode.
public bool allowOverwriteFromSO = true;
// UI refs (resolved from teamUIController)
private Image characterImage;
private Image healthImage;
private Image fadeHealthImage;
private Image manaImage;
private Image fadeManaImage;
private TextMeshProUGUI nameText;
private TextMeshProUGUI healthRateText;
private TextMeshProUGUI currentScoreText;
private Image hurtRedImage;
private Coroutine fadeHealthCoroutine;
private Coroutine fadeManaCoroutine;
private List<Buff> activeBuffs = new List<Buff>();
private void Awake()
{
// ensure GameObject name matches pattern so EffectSystem can find it
gameObject.name = $"ally_0{Mathf.Clamp(slotIndex + 1, 1, 5)}";
}
private void Start()
{
// Resolve UI and optionally pull data from SO
ResolveUIReferences();
if (allowOverwriteFromSO)
InitializeStatsFromData();
// Ensure runtime invariants
currentHP = Mathf.Clamp(currentHP, 0, Mathf.Max(1, maxHP));
currentMana = Mathf.Clamp(currentMana, 0, Mathf.Max(1, maxMana));
UpdateUIImmediate();
// Ensure score UI shows initial value
UpdateScoreUI();
}
// Allow editing in inspector to immediately reflect on UI (editor only)
private void OnValidate()
{
// Avoid heavy editor-only operations, but resolve UI references so inspector changes show
ResolveUIReferences();
// Clamp values to valid ranges
if (maxHP < 1) maxHP = 1;
if (maxMana < 1) maxMana = 1;
currentHP = Mathf.Clamp(currentHP, 0, maxHP);
currentMana = Mathf.Clamp(currentMana, 0, maxMana);
// Do not start coroutines from OnValidate - update immediately
UpdateUIImmediate();
// If user adjusts currentMana in inspector while in Play mode, attempt to trigger mana-full behaviors
// Guard to avoid calling TryCastOnFullMana before singletons are initialized
if (Application.isPlaying && SkillBuilder.Instance != null)
{
TryCastOnFullMana();
}
}
private void ResolveUIReferences()
{
// Try singleton first, fallback to scene search (works in editor)
var ui = teamUIController.Instance ?? FindObjectOfType<teamUIController>();
if (ui == null) return;
switch (slotIndex)
{
case 0:
characterImage = ui.teammate01_characterImage;
healthImage = ui.teammate01_healthImage;
fadeHealthImage = ui.teammate01_fadehealthImage;
manaImage = ui.teammate01_manaImage;
fadeManaImage = ui.teammate01_fadeManaImage;
nameText = ui.teammate01_nameText;
healthRateText = ui.teammate01_healthRate;
currentScoreText = ui.teammate01_current_scoreText;
hurtRedImage = ui.teammate01_hurtRedImage;
break;
case 1:
characterImage = ui.teammate02_characterImage;
healthImage = ui.teammate02_healthImage;
fadeHealthImage = ui.teammate02_fadehealthImage;
manaImage = ui.teammate02_manaImage;
fadeManaImage = ui.teammate02_fadeManaImage;
nameText = ui.teammate02_nameText;
healthRateText = ui.teammate02_healthRate;
currentScoreText = ui.teammate02_current_scoreText;
hurtRedImage = ui.teammate02_hurtRedImage;
break;
case 2:
characterImage = ui.teammate03_characterImage;
healthImage = ui.teammate03_healthImage;
fadeHealthImage = ui.teammate03_fadehealthImage;
manaImage = ui.teammate03_manaImage;
fadeManaImage = ui.teammate03_fadeManaImage;
nameText = ui.teammate03_nameText;
healthRateText = ui.teammate03_healthRate;
currentScoreText = ui.teammate03_current_scoreText;
hurtRedImage = ui.teammate03_hurtRedImage;
break;
case 3:
characterImage = ui.teammate04_characterImage;
healthImage = ui.teammate04_healthImage;
fadeHealthImage = ui.teammate04_fadehealthImage;
manaImage = ui.teammate04_manaImage;
fadeManaImage = ui.teammate04_fadeManaImage;
nameText = ui.teammate04_nameText;
healthRateText = ui.teammate04_healthRate;
currentScoreText = ui.teammate04_current_scoreText;
hurtRedImage = ui.teammate04_hurtRedImage;
break;
case 4:
characterImage = ui.teammate05_characterImage;
healthImage = ui.teammate05_healthImage;
fadeHealthImage = ui.teammate05_fadehealthImage;
manaImage = ui.teammate05_manaImage;
fadeManaImage = ui.teammate05_fadeManaImage;
nameText = ui.teammate05_nameText;
healthRateText = ui.teammate05_healthRate;
currentScoreText = ui.teammate05_current_scoreText;
hurtRedImage = ui.teammate05_hurtRedImage;
break;
}
// If the assigned TMP field is null, attempt to resolve from the UI parent's object (teamUIController keeps objectFather references)
if (currentScoreText == null)
{
GameObject parent = null;
switch (slotIndex)
{
case 0: parent = ui.objectFather_ally01; break;
case 1: parent = ui.objectFather_ally02; break;
case 2: parent = ui.objectFather_ally03; break;
case 3: parent = ui.objectFather_ally04; break;
case 4: parent = ui.objectFather_ally05; break;
}
if (parent != null)
{
var tmp = parent.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null)
{
currentScoreText = tmp;
// also assign back to teamUIController so other systems can find it
switch (slotIndex)
{
case 0: ui.teammate01_current_scoreText = tmp; break;
case 1: ui.teammate02_current_scoreText = tmp; break;
case 2: ui.teammate03_current_scoreText = tmp; break;
case 3: ui.teammate04_current_scoreText = tmp; break;
case 4: ui.teammate05_current_scoreText = tmp; break;
}
Debug.Log($"[AllyCombatant] Resolved currentScoreText for slot {slotIndex+1} from parent {parent.name} -> {tmp.gameObject.name}");
}
else
{
// legacy UnityEngine.UI.Text fallback
var legacy = parent.GetComponentInChildren<Text>(true);
if (legacy != null)
{
var go = legacy.gameObject;
var created = go.GetComponent<TextMeshProUGUI>() ?? go.AddComponent<TextMeshProUGUI>();
created.text = legacy.text;
currentScoreText = created;
switch (slotIndex)
{
case 0: ui.teammate01_current_scoreText = created; break;
case 1: ui.teammate02_current_scoreText = created; break;
case 2: ui.teammate03_current_scoreText = created; break;
case 3: ui.teammate04_current_scoreText = created; break;
case 4: ui.teammate05_current_scoreText = created; break;
}
Debug.LogWarning($"[AllyCombatant] Found legacy Text for slot {slotIndex+1} under {parent.name}. Added/used TMP component on {go.name} and copied text.");
}
}
}
}
}
private void InitializeStatsFromData()
{
// Try TeamCharacterDataInfo first
var arr = teamUIController.Instance?.GetCurrentAllySOs();
if (arr != null && slotIndex >= 0 && slotIndex < arr.Length)
{
var so = arr[slotIndex];
if (so != null)
{
// TeamCharacterDataInfo has limited fields; use CharacterMaxHealth if present
if (so.CharacterMaxHealth > 0)
{
maxHP = so.CharacterMaxHealth;
}
if (!string.IsNullOrEmpty(so.CharacterName) && nameText != null)
{
nameText.text = so.CharacterName;
}
if (characterImage != null && so.CharacterImage_gameplay != null)
{
characterImage.sprite = so.CharacterImage_gameplay.sprite;
}
}
}
// If more detailed info exists in AllyHero_SO, try to load by ally ID
int id = -1;
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null && slotIndex < teamUIController.Instance.allySlotIds.Count)
id = teamUIController.Instance.allySlotIds[slotIndex];
if (id > 0)
{
var all = Resources.LoadAll<AllyHero_SO>("");
foreach (var a in all)
{
if (a != null && a.ally_heroID == id)
{
// pick effective level based on current EXP if available
if (a.levelStats != null && a.levelStats.Count > 0)
{
var eff = a.GetEffectiveLevelForCurrentEXP();
if (eff != null)
{
maxHP = eff.maxHP;
maxMana = eff.maxMana;
damageResistance = eff.damageResistance;
scoreEfficiency = eff.scoreEfficiency;
attack = eff.attack; // populate attack from SO effective level
}
else
{
var lvl = a.levelStats[0];
maxHP = lvl.maxHP;
maxMana = lvl.maxMana;
damageResistance = lvl.damageResistance;
scoreEfficiency = lvl.scoreEfficiency;
attack = lvl.attack;
}
}
if (nameText != null) nameText.text = a.ally_heroName;
if (characterImage != null && a.ally_heroProfile != null) characterImage.sprite = a.ally_heroProfile;
break;
}
}
}
}
// Scoring API
public void AddScoreForJudge(string judge)
{
int add = 0;
switch (judge)
{
case "Perfect":
add = baseTrackScore;
break;
case "Great":
add = Mathf.FloorToInt(baseTrackScore * greatRatio);
break;
case "Good":
add = Mathf.FloorToInt(baseTrackScore * goodRatio);
break;
case "Miss":
add = Mathf.FloorToInt(baseTrackScore * missRatio);
break;
default:
add = 0;
break;
}
// apply scoreEfficiency or other modifiers here
add = Mathf.FloorToInt(add * scoreEfficiency);
currentScore = Mathf.Clamp(currentScore + add, 0, maxTrackScore);
UpdateScoreUI();
// update global total
ScoreManager.Instance?.RecalculateTotal();
}
// Add an arbitrary score delta directly (used by skills to modify single judge or grant/penalize score)
public void AddScoreDirect(int delta)
{
currentScore = Mathf.Clamp(currentScore + delta, 0, maxTrackScore);
UpdateScoreUI();
ScoreManager.Instance?.RecalculateTotal();
}
private void UpdateScoreUI()
{
string value = $"{currentScore}/{maxTrackScore}";
if (currentScoreText != null)
{
currentScoreText.text = value;
Debug.Log($"[AllyCombatant] Updated slot {slotIndex+1} score UI -> {value} (target {currentScoreText.gameObject.name})");
return;
}
// As a fallback, try to write directly to teamUIController.Instance fields if available
var ui = teamUIController.Instance;
if (ui != null)
{
switch (slotIndex)
{
case 0:
if (ui.teammate01_current_scoreText != null) { ui.teammate01_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate01_current_scoreText: {value}"); }
break;
case 1:
if (ui.teammate02_current_scoreText != null) { ui.teammate02_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate02_current_scoreText: {value}"); }
break;
case 2:
if (ui.teammate03_current_scoreText != null) { ui.teammate03_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate03_current_scoreText: {value}"); }
break;
case 3:
if (ui.teammate04_current_scoreText != null) { ui.teammate04_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate04_current_scoreText: {value}"); }
break;
case 4:
if (ui.teammate05_current_scoreText != null) { ui.teammate05_current_scoreText.text = value; Debug.Log($"[AllyCombatant] Fallback wrote to ui.teammate05_current_scoreText: {value}"); }
break;
}
}
}
// Public setters that update UI and optionally animate fade bars
public void SetCurrentHP(int hp, bool animateFade = true)
{
if (isDead) return; // dead characters cannot change HP
int old = currentHP;
currentHP = Mathf.Clamp(hp, 0, maxHP);
if (currentHP <= 0) isDead = true;
UpdateHealthVisuals(old, animateFade);
}
public void ModifyHP(int delta, bool animateFade = true)
{
if (isDead && delta > 0)
{
// cannot heal a dead unit
return;
}
SetCurrentHP(currentHP + delta, animateFade);
}
public void SetMaxHP(int newMax, bool keepCurrentRatio = true)
{
if (newMax < 1) newMax = 1;
float ratio = maxHP > 0 ? (float)currentHP / maxHP : 1f;
maxHP = newMax;
if (keepCurrentRatio)
currentHP = Mathf.Clamp(Mathf.RoundToInt(ratio * maxHP), 0, maxHP);
else
currentHP = Mathf.Clamp(currentHP, 0, maxHP);
UpdateUIImmediate();
}
public void SetCurrentMana(int mana, bool animateFade = true)
{
if (isDead) return; // dead characters cannot change mana
int old = currentMana;
currentMana = Mathf.Clamp(mana, 0, maxMana);
UpdateManaVisuals(old, animateFade);
TryCastOnFullMana();
}
public void ModifyMana(int delta, bool animateFade = true)
{
if (isDead && delta > 0)
{
// dead units cannot gain mana
return;
}
SetCurrentMana(currentMana + delta, animateFade);
}
public void SetMaxMana(int newMax, bool keepCurrentRatio = true)
{
if (newMax < 1) newMax = 1;
float ratio = maxMana > 0 ? (float)currentMana / maxMana : 1f;
maxMana = newMax;
if (keepCurrentRatio)
currentMana = Mathf.Clamp(Mathf.RoundToInt(ratio * maxMana), 0, maxMana);
else
currentMana = Mathf.Clamp(currentMana, 0, maxMana);
UpdateUIImmediate();
}
private void UpdateHealthVisuals(int oldHP, bool animateFade)
{
// main bar should snap quickly to new value, while fade bar lerps towards it
if (healthImage != null)
{
float newFill = maxHP > 0 ? (float)currentHP / maxHP : 0f;
// snap main bar quickly for visual pop
healthImage.fillAmount = Mathf.MoveTowards(healthImage.fillAmount, newFill, 1f);
}
if (healthRateText != null)
healthRateText.text = $"{currentHP}/{maxHP}";
if (fadeHealthCoroutine != null) StopCoroutine(fadeHealthCoroutine);
if (fadeHealthImage != null)
{
float target = healthImage != null ? healthImage.fillAmount : maxHP > 0 ? (float)currentHP / maxHP : 0f;
if (animateFade && Application.isPlaying)
fadeHealthCoroutine = StartCoroutine(FadeHealthCoroutine(target));
else
fadeHealthImage.fillAmount = target;
}
// if HP decreased, show hurt flash; if dead, keep hurt overlay visible
if (currentHP < oldHP && hurtRedImage != null && Application.isPlaying)
{
StopCoroutine("HurtFlash");
StartCoroutine(HurtFlash());
}
if (isDead && hurtRedImage != null)
{
Color c = hurtRedImage.color;
c.a = 1f;
hurtRedImage.color = c; // keep overlay visible when dead
}
}
private void UpdateManaVisuals(int oldMana, bool animateFade)
{
// main bar snaps, fade bar lerps to new value for visual effect
if (manaImage != null)
{
float newFill = maxMana > 0 ? (float)currentMana / maxMana : 0f;
manaImage.fillAmount = Mathf.MoveTowards(manaImage.fillAmount, newFill, 1f);
}
if (fadeManaCoroutine != null) StopCoroutine(fadeManaCoroutine);
if (fadeManaImage != null)
{
float target = manaImage != null ? manaImage.fillAmount : maxMana > 0 ? (float)currentMana / maxMana : 0f;
if (animateFade && Application.isPlaying)
fadeManaCoroutine = StartCoroutine(FadeManaCoroutine(target));
else
fadeManaImage.fillAmount = target;
}
}
private void UpdateUIImmediate()
{
if (healthImage != null)
{
healthImage.fillAmount = maxHP > 0 ? (float)currentHP / maxHP : 0f;
}
if (fadeHealthImage != null)
{
// keep fade bar equal to main bar immediately
fadeHealthImage.fillAmount = healthImage != null ? healthImage.fillAmount : maxHP > 0 ? (float)currentHP / maxHP : 0f;
}
if (manaImage != null)
{
manaImage.fillAmount = maxMana > 0 ? (float)currentMana / maxMana : 0f;
}
if (fadeManaImage != null)
{
fadeManaImage.fillAmount = manaImage != null ? manaImage.fillAmount : maxMana > 0 ? (float)currentMana / maxMana : 0f;
}
if (healthRateText != null)
{
healthRateText.text = $"{currentHP}/{maxHP}";
}
}
private IEnumerator FadeHealthCoroutine(float targetFill)
{
if (fadeHealthImage == null) yield break;
float start = fadeHealthImage.fillAmount;
float duration = 0.6f;
float t = 0f;
while (t < duration)
{
t += Time.deltaTime;
fadeHealthImage.fillAmount = Mathf.Lerp(start, targetFill, t / duration);
yield return null;
}
fadeHealthImage.fillAmount = targetFill;
}
private IEnumerator FadeManaCoroutine(float targetFill)
{
if (fadeManaImage == null) yield break;
float start = fadeManaImage.fillAmount;
float duration = 0.4f;
float t = 0f;
while (t < duration)
{
t += Time.deltaTime;
fadeManaImage.fillAmount = Mathf.Lerp(start, targetFill, t / duration);
yield return null;
}
fadeManaImage.fillAmount = targetFill;
}
public void ReceiveDamage(float amount, GameObject source)
{
float effective = amount * (1f - damageResistance);
int delta = Mathf.CeilToInt(effective);
ModifyHP(-delta, true);
}
private IEnumerator HurtFlash()
{
if (hurtRedImage == null) yield break;
Color c = hurtRedImage.color;
c.a = 1f;
hurtRedImage.color = c;
// when injured, remain visible a bit longer; if dead, keep it on
if (isDead)
{
yield break;
}
yield return new WaitForSeconds(0.2f);
float dur = 0.4f;
float t = 0f;
while (t < dur)
{
t += Time.deltaTime;
c.a = Mathf.Lerp(1f, 0f, t / dur);
hurtRedImage.color = c;
yield return null;
}
c.a = 0f;
hurtRedImage.color = c;
}
public void ReceiveHeal(float amount, GameObject source)
{
int delta = Mathf.CeilToInt(amount);
ModifyHP(delta, true);
}
public void ApplyBuff(Buff buff, GameObject source)
{
if (buff == null) return;
activeBuffs.Add(buff);
// simple immediate apply: multiply stats
if (buff.attackMultiplier != 1f) { attack = Mathf.RoundToInt(attack * buff.attackMultiplier); }
if (buff.scoreMultiplier != 1f) scoreEfficiency *= buff.scoreMultiplier;
// UI/visual could be added
}
public void RemoveBuff(string buffId)
{
var b = activeBuffs.Find(x => x.buffId == buffId);
if (b != null)
{
activeBuffs.Remove(b);
if (b.scoreMultiplier != 1f) scoreEfficiency /= b.scoreMultiplier;
// cannot easily restore attack without storing original; keep simple
}
}
private void TryCastOnFullMana()
{
if (currentMana >= maxMana && Application.isPlaying)
{
// Ensure SkillBuilder singleton exists before attempting to cast.
if (SkillBuilder.Instance == null)
{
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast right now: SkillBuilder.Instance is null. Will not attempt cast.");
return;
}
int prevMana = currentMana;
// Attempt to cast primary skill configured in SO via SkillBuilder
CastSkill();
// Reset mana after casting
currentMana = 0;
// Update visuals using previous mana as old value so fade works
UpdateManaVisuals(prevMana, true);
}
}
private void CastSkill()
{
// Use SkillBuilder to invoke the primary skill defined in the AllyHero_SO for this slot
if (SkillBuilder.Instance == null)
{
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast: SkillBuilder.Instance is null.");
return;
}
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
if (so == null)
{
// Can't locate SO for this slot; attempt best-effort: call UsePrimarySkillForSlot which will log details
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1}: AllyHero_SO not found for slot. Falling back to UsePrimarySkillForSlot.");
SkillBuilder.Instance.UsePrimarySkillForSlot(slotIndex, -1f, null);
return;
}
var def = so.GetPrimarySkill();
if (def == null)
{
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast: primary skill not set in SO.");
return;
}
// Only auto-cast here if the skill's trigger condition matches ManaFull
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnManaFull)
{
// Use the SkillDefinition API to ensure selector/resolution/operateDirectly are respected
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} cast primary skill '{def.skillId}' due to ManaFull.");
}
else
{
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} primary skill '{def.skillId}' not set to trigger on ManaFull (trigger={def.triggerCondition}); skipping cast.");
}
}
public void TriggerOnManaFull()
{
if (currentMana >= maxMana)
{
TryCastOnFullMana();
}
}
[ContextMenu("Test Fill Mana")]
private void TestFillMana()
{
SetCurrentMana(maxMana, true);
}
}