498 lines
17 KiB
C#
498 lines
17 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
// Documentation text normalized.
|
||
// Documentation text normalized.
|
||
public class EnemyCombatant : MonoBehaviour, ICombatant
|
||
{
|
||
public EnemyData_SO sourceData;
|
||
|
||
private int _currentDifficultyID;
|
||
private GameObject _lastAttacker;
|
||
|
||
// runtime stats
|
||
public int maxHP = 100;
|
||
public int currentHP = 100;
|
||
public int maxMana = 0;
|
||
public int currentMana = 0;
|
||
public float damageResistance = 0f;
|
||
public int attack = 0;
|
||
|
||
// Keep an unbuffed attack baseline so temporary Buff.attackMultiplier can be applied/reverted correctly.
|
||
private int _attackBaseUnbuffed = 0;
|
||
private bool _attackBaseInitialized = false;
|
||
|
||
private List<Buff> activeBuffs = new List<Buff>();
|
||
|
||
// Events to notify manager/UI
|
||
public event Action<EnemyCombatant> OnEnemyDied;
|
||
public event Action<EnemyCombatant> OnEnemyRevived;
|
||
|
||
private bool isDead = false;
|
||
|
||
private void Awake()
|
||
{
|
||
// Ensure consistent name so EffectSystem can find it
|
||
gameObject.name = "thisEnemy";
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
public void InitializeFromSO(EnemyData_SO so, int difficultyID)
|
||
{
|
||
// This enemy GameObject can be reused for multiple enemy entries in sequence.
|
||
// Clear any runtime buffs so they don't leak across enemy transitions.
|
||
activeBuffs.Clear();
|
||
_attackBaseInitialized = false;
|
||
_attackBaseUnbuffed = 0;
|
||
_lastAttacker = null;
|
||
_currentDifficultyID = difficultyID;
|
||
|
||
sourceData = so;
|
||
if (so != null)
|
||
{
|
||
var stats = so.GetStatsByDifficultyID(difficultyID);
|
||
maxHP = Mathf.Max(1, stats.enemy_maxHP);
|
||
damageResistance = stats.enemy_damageResistance;
|
||
SetAttack(stats.enemy_baseAttack);
|
||
maxMana = Mathf.Max(0, stats.enemy_maxMana);
|
||
}
|
||
else
|
||
{
|
||
maxHP = 100;
|
||
damageResistance = 0f;
|
||
SetAttack(0);
|
||
maxMana = 0;
|
||
}
|
||
|
||
// revive
|
||
currentHP = maxHP;
|
||
currentMana = 0;
|
||
isDead = false;
|
||
|
||
iBudeffPrefabController.Instance?.RegisterEnemyBaseline(this);
|
||
|
||
// notify listeners (UI/manager) that enemy revived
|
||
OnEnemyRevived?.Invoke(this);
|
||
|
||
// Notify SkillBuilder so ally skills with OnEnemyRevive can react
|
||
if (Application.isPlaying && SkillBuilder.Instance != null)
|
||
{
|
||
SkillBuilder.Instance.TriggerOnEnemyRevive(this);
|
||
}
|
||
|
||
// Trigger primary skill on revive if defined and trigger condition matches
|
||
TryTriggerPrimarySkill(SkillDefinition.SkillTrigger.OnEnemyRevive);
|
||
}
|
||
|
||
// Documentation text normalized.
|
||
private void TryTriggerPrimarySkill(SkillDefinition.SkillTrigger when)
|
||
{
|
||
if (sourceData == null) return;
|
||
var def = sourceData.GetPrimarySkill();
|
||
if (def == null) return;
|
||
if (def.triggerCondition != when) return;
|
||
|
||
// Evaluate formula into amount using enemy fields as variables
|
||
float perTick = 0f;
|
||
var vars = new Dictionary<string, float>();
|
||
vars["maxHP"] = maxHP;
|
||
vars["maxMana"] = maxMana;
|
||
vars["damageResistance"] = damageResistance;
|
||
vars["attack"] = attack;
|
||
vars["enemy_currentHP"] = currentHP;
|
||
|
||
if (!string.IsNullOrWhiteSpace(def.formula))
|
||
{
|
||
if (!SkillDefinition.TryEvaluateFormula(def.formula, vars, out float result))
|
||
perTick = 0f;
|
||
else
|
||
perTick = result;
|
||
}
|
||
|
||
float totalAmount = perTick;
|
||
bool scaleByTicks =
|
||
!def.IsSingleInstance &&
|
||
(def.effectType == EffectType.DamageOverTimeEnemy ||
|
||
def.effectType == EffectType.DamageOverTimeAlly ||
|
||
def.effectType == EffectType.HealOverTimeEnemy ||
|
||
def.effectType == EffectType.HealOverTimeSelf ||
|
||
def.effectType == EffectType.HealGroupOverTime ||
|
||
def.effectType == EffectType.IncreaseManaOverTime);
|
||
if (scaleByTicks)
|
||
{
|
||
float tickInterval = def.GetEffectiveTickInterval();
|
||
int ticks = Mathf.Max(1, Mathf.CeilToInt(def.defaultDuration / tickInterval));
|
||
totalAmount = perTick * ticks;
|
||
}
|
||
|
||
// Use SkillBuilder to execute effect, using this Enemy GameObject as caster
|
||
if (SkillBuilder.Instance != null)
|
||
{
|
||
SkillBuilder.Instance.ExecuteSkill(def.displayName ?? def.skillId, def.effectType, totalAmount, def.defaultSelector, this.gameObject, null, def.defaultDuration, def.defaultTickInterval);
|
||
}
|
||
}
|
||
|
||
public void ReceiveDamage(float amount, GameObject source, bool deferPopup = false)
|
||
{
|
||
if (isDead) return;
|
||
|
||
// Check if any allies are active before receiving damage
|
||
if (teamUIController.Instance != null && !teamUIController.Instance.IsAnyAllyActive())
|
||
{
|
||
return;
|
||
}
|
||
|
||
_lastAttacker = source;
|
||
int before = currentHP;
|
||
float effective = amount * (1f - damageResistance);
|
||
int delta = Mathf.CeilToInt(effective);
|
||
ModifyHP(-delta, true);
|
||
|
||
int actual = before - currentHP;
|
||
if (actual != 0)
|
||
{
|
||
if (deferPopup)
|
||
{
|
||
QueueDamagePopup(iNumberPrefabController.InstantNumberType.Damage, -actual);
|
||
}
|
||
else
|
||
{
|
||
iNumberPrefabController.SpawnForEnemyStatic(iNumberPrefabController.InstantNumberType.Damage, -actual);
|
||
}
|
||
}
|
||
|
||
// grant mana based on sourceData setting
|
||
if (sourceData != null && sourceData.manaGainOnDamagePercent > 0f && maxMana > 0)
|
||
{
|
||
float manaGainRaw = effective * sourceData.manaGainOnDamagePercent;
|
||
int manaGain = Mathf.CeilToInt(manaGainRaw);
|
||
ModifyMana(manaGain, true);
|
||
}
|
||
|
||
if (currentHP <= 0)
|
||
{
|
||
Die();
|
||
}
|
||
}
|
||
|
||
public void ReceiveHeal(float amount, GameObject source, bool deferPopup = false)
|
||
{
|
||
if (isDead) return;
|
||
int before = currentHP;
|
||
float mult = GetTotalHealReceivedMultiplier();
|
||
int delta = Mathf.CeilToInt(amount * mult);
|
||
ModifyHP(delta, true);
|
||
int actual = currentHP - before;
|
||
if (actual != 0)
|
||
{
|
||
if (deferPopup)
|
||
{
|
||
QueueDamagePopup(iNumberPrefabController.InstantNumberType.Heal, actual);
|
||
}
|
||
else
|
||
{
|
||
iNumberPrefabController.SpawnForEnemyStatic(iNumberPrefabController.InstantNumberType.Heal, actual);
|
||
}
|
||
}
|
||
}
|
||
|
||
public void ApplyBuff(Buff buff, GameObject source)
|
||
{
|
||
if (buff == null) return;
|
||
Debug.LogWarning($"[EnemyCombatant] ApplyBuff: {buff.description} (id={buff.buffId}) to {name}. AtkMult={buff.attackMultiplier}, HealMult={buff.healReceivedMultiplier}, ScoreMult={buff.scoreMultiplier}");
|
||
activeBuffs.Add(buff);
|
||
// Apply reversible multipliers. attackMultiplier is applied via baseline+recalc to ensure it can be reverted.
|
||
if (buff.attackMultiplier != 1f) RecalculateAttackFromBuffs();
|
||
iBudeffPrefabController.Instance?.NotifyEnemyBuffApplied(this, buff);
|
||
}
|
||
|
||
public void RemoveBuff(string buffId)
|
||
{
|
||
var b = activeBuffs.Find(x => x.buffId == buffId);
|
||
if (b != null)
|
||
{
|
||
activeBuffs.Remove(b);
|
||
if (b.attackMultiplier != 1f) RecalculateAttackFromBuffs();
|
||
iBudeffPrefabController.Instance?.NotifyEnemyBuffRemoved(this, b);
|
||
}
|
||
}
|
||
|
||
public float GetHealReceivedMultiplierForUI()
|
||
{
|
||
float mult = 1f;
|
||
for (int i = 0; i < activeBuffs.Count; i++)
|
||
{
|
||
var b = activeBuffs[i];
|
||
if (b == null) continue;
|
||
mult *= b.healReceivedMultiplier;
|
||
}
|
||
return mult;
|
||
}
|
||
|
||
private void EnsureAttackBaseInitialized()
|
||
{
|
||
if (_attackBaseInitialized) return;
|
||
_attackBaseInitialized = true;
|
||
_attackBaseUnbuffed = Mathf.Max(0, attack);
|
||
}
|
||
|
||
private float GetTotalAttackMultiplier()
|
||
{
|
||
float mult = 1f;
|
||
for (int i = 0; i < activeBuffs.Count; i++)
|
||
{
|
||
var b = activeBuffs[i];
|
||
if (b == null) continue;
|
||
mult *= b.attackMultiplier;
|
||
}
|
||
return mult;
|
||
}
|
||
|
||
private float GetTotalHealReceivedMultiplier()
|
||
{
|
||
float mult = 1f;
|
||
for (int i = 0; i < activeBuffs.Count; i++)
|
||
{
|
||
var b = activeBuffs[i];
|
||
if (b == null) continue;
|
||
mult *= b.healReceivedMultiplier;
|
||
}
|
||
return mult;
|
||
}
|
||
|
||
private void RecalculateAttackFromBuffs()
|
||
{
|
||
EnsureAttackBaseInitialized();
|
||
float mult = GetTotalAttackMultiplier();
|
||
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult));
|
||
}
|
||
|
||
public void ModifyAttack(int delta)
|
||
{
|
||
EnsureAttackBaseInitialized();
|
||
_attackBaseUnbuffed = Mathf.Max(0, _attackBaseUnbuffed + delta);
|
||
RecalculateAttackFromBuffs();
|
||
}
|
||
|
||
public void SetAttack(int value)
|
||
{
|
||
EnsureAttackBaseInitialized();
|
||
_attackBaseUnbuffed = Mathf.Max(0, value);
|
||
RecalculateAttackFromBuffs();
|
||
}
|
||
|
||
private List<(iNumberPrefabController.InstantNumberType type, int val)> _queuedPopups = new List<(iNumberPrefabController.InstantNumberType, int)>();
|
||
|
||
public void QueueDamagePopup(iNumberPrefabController.InstantNumberType type, int val)
|
||
{
|
||
_queuedPopups.Add((type, val));
|
||
}
|
||
|
||
public void TriggerQueuedPopups()
|
||
{
|
||
foreach (var p in _queuedPopups)
|
||
{
|
||
iNumberPrefabController.SpawnForEnemyStatic(p.type, p.val);
|
||
}
|
||
_queuedPopups.Clear();
|
||
}
|
||
|
||
public void ModifyHP(int delta)
|
||
{
|
||
ModifyHP(delta, false);
|
||
}
|
||
|
||
public void ModifyHP(int delta, bool skipPopup)
|
||
{
|
||
if (isDead && delta > 0) return;
|
||
int before = currentHP;
|
||
currentHP = Mathf.Clamp(currentHP + delta, 0, maxHP);
|
||
int actual = currentHP - before;
|
||
if (actual != 0 && !skipPopup)
|
||
{
|
||
var type = actual > 0 ? iNumberPrefabController.InstantNumberType.Heal : iNumberPrefabController.InstantNumberType.Damage;
|
||
iNumberPrefabController.SpawnForEnemyStatic(type, actual);
|
||
}
|
||
if (currentHP <= 0) Die();
|
||
}
|
||
|
||
public void ModifyMana(int delta, bool animate = true)
|
||
{
|
||
if (maxMana <= 0) return;
|
||
int old = currentMana;
|
||
currentMana = Mathf.Clamp(currentMana + delta, 0, maxMana);
|
||
int actual = currentMana - old;
|
||
|
||
if (actual != 0)
|
||
{
|
||
if (iNumberPrefabController.Instance != null)
|
||
{
|
||
var type = actual > 0 ? iNumberPrefabController.InstantNumberType.ManaPlus : iNumberPrefabController.InstantNumberType.ManaMinus;
|
||
iNumberPrefabController.SpawnForEnemyStatic(type, actual);
|
||
}
|
||
}
|
||
|
||
// If mana reached full, trigger primary skill and reset mana
|
||
if (currentMana >= maxMana && maxMana > 0 && Application.isPlaying)
|
||
{
|
||
Debug.Log($"[EnemyCombatant] Mana full for {gameObject.name} (slot) - triggering OnManaFull");
|
||
try
|
||
{
|
||
// Trigger primary skill configured for OnManaFull
|
||
TryTriggerPrimarySkill(SkillDefinition.SkillTrigger.OnManaFull);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"[EnemyCombatant] TryTriggerPrimarySkill threw: {ex}");
|
||
}
|
||
|
||
// reset mana after casting
|
||
ModifyMana(-currentMana, false);
|
||
}
|
||
}
|
||
|
||
private void Die()
|
||
{
|
||
if (isDead) return;
|
||
|
||
// award score to last attacker
|
||
if (sourceData != null)
|
||
{
|
||
var stats = sourceData.GetStatsByDifficultyID(_currentDifficultyID);
|
||
int award = stats.scoreOnBeingDefeated;
|
||
if (award > 0)
|
||
{
|
||
var ally = _lastAttacker != null ? _lastAttacker.GetComponent<AllyCombatant>() : null;
|
||
if (ally != null)
|
||
{
|
||
ally.AddScoreDirect(award);
|
||
}
|
||
else
|
||
{
|
||
// If no specific attacker, try to find current primary track or just add to global
|
||
ScoreManager.Instance?.AddIdolScoreForTrack(0, award);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Finally mark dead
|
||
isDead = true;
|
||
|
||
// 播放 KO 特效
|
||
if (GfxController.Instance != null)
|
||
{
|
||
GfxController.Instance.PlayKOFX(this.gameObject);
|
||
}
|
||
|
||
// Notify SkillBuilder so allies can react to each enemy death BEFORE listeners that may reuse this Enemy instance
|
||
if (Application.isPlaying)
|
||
{
|
||
// prefer singleton instance but fallback to scene lookup if singleton is null (robustness for scene initialization order)
|
||
if (SkillBuilder.Instance != null)
|
||
{
|
||
SkillBuilder.Instance.TriggerOnEnemyDead(this);
|
||
}
|
||
else
|
||
{
|
||
var sb = UnityEngine.Object.FindAnyObjectByType<SkillBuilder>();
|
||
if (sb != null)
|
||
{
|
||
Debug.LogWarning("[EnemyCombatant] SkillBuilder.Instance was null on Die(); using FindObjectOfType fallback");
|
||
sb.TriggerOnEnemyDead(this);
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[EnemyCombatant] Die(): no SkillBuilder found to notify TriggerOnEnemyDead");
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trigger primary skill on death if configured (enemy's own skill)
|
||
TryTriggerPrimarySkill(SkillDefinition.SkillTrigger.OnEnemyDead);
|
||
|
||
// notify listeners (UI/manager) that enemy died AFTER triggering skills so they operate on the current instance
|
||
OnEnemyDied?.Invoke(this);
|
||
|
||
// Check whether all enemies are now defeated.
|
||
// If teamUIController exists, it owns enemy-sequence completion and will fire this event exactly once.
|
||
// Fallback to legacy scene scan only when teamUIController is not present.
|
||
if (Application.isPlaying)
|
||
{
|
||
try
|
||
{
|
||
var ui = UnityEngine.Object.FindAnyObjectByType<teamUIController>();
|
||
if (ui != null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var all = UnityEngine.Object.FindObjectsByType<EnemyCombatant>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||
bool anyAlive = false;
|
||
foreach (var e in all)
|
||
{
|
||
if (e == null) continue;
|
||
// treat an enemy as alive if its currentHP > 0 and not flagged dead
|
||
if (e.currentHP > 0 && !e.IsDead)
|
||
{
|
||
anyAlive = true; break;
|
||
}
|
||
}
|
||
if (!anyAlive)
|
||
{
|
||
// notify SkillBuilder about all enemies defeated
|
||
if (SkillBuilder.Instance != null)
|
||
{
|
||
SkillBuilder.Instance.TriggerOnAllEnemiesDefeated();
|
||
Debug.Log("[EnemyCombatant] All enemies defeated -> TriggerOnAllEnemiesDefeated fired via singleton");
|
||
}
|
||
else
|
||
{
|
||
var sb = UnityEngine.Object.FindAnyObjectByType<SkillBuilder>();
|
||
if (sb != null)
|
||
{
|
||
sb.TriggerOnAllEnemiesDefeated();
|
||
Debug.LogWarning("[EnemyCombatant] SkillBuilder.Instance was null when firing AllEnemiesDefeated; used FindObjectOfType fallback");
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[EnemyCombatant] All enemies defeated but no SkillBuilder found to notify");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"[EnemyCombatant] Error while checking all-enemies-defeated: {ex}");
|
||
}
|
||
}
|
||
}
|
||
|
||
// Utility: expose a read-only status
|
||
public bool IsDead => isDead;
|
||
|
||
// New: allow EffectSystem/SkillBuilder to adjust enemy max HP safely
|
||
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);
|
||
}
|
||
|
||
// New: allow adjusting enemy max mana (mirror of AllyCombatant)
|
||
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);
|
||
}
|
||
}
|