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 activeBuffs = new List(); // Events to notify manager/UI public event Action OnEnemyDied; public event Action 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; GameplaySkillLogger.RecordEnemyLifeState( gameObject.name, "Spawned", currentHP, maxHP, currentMana, maxMana, sourceData != null ? sourceData.name : "None"); 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 configured enemy skills on revive if defined and trigger condition matches. TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger.OnEnemyRevive); } private void TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger when) { if (sourceData == null) return; HashSet executed = new HashSet(); SkillDefinition primary = sourceData.GetPrimarySkill(); ExecuteEnemySkillIfMatched(primary, when, executed); int[] groupIds = sourceData.GetEffectiveEquippedSkillGroupIDs(); if (groupIds == null || groupIds.Length == 0) return; for (int i = 0; i < groupIds.Length; i++) { int groupId = groupIds[i]; if (groupId == 0) continue; SkillGroup group = sourceData.GetSkillGroupByID(groupId); if (group == null || group.skills == null) continue; for (int j = 0; j < group.skills.Length; j++) { ExecuteEnemySkillIfMatched(group.skills[j], when, executed); } } } private void ExecuteEnemySkillIfMatched(SkillDefinition def, SkillDefinition.SkillTrigger when, HashSet executed) { if (def == null || executed == null) return; if (executed.Contains(def)) return; if (def.triggerCondition != when) return; executed.Add(def); float perTick = 0f; var vars = new Dictionary(); 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; } 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 && source != null && teamUIController.Instance != null) { var sourceAlly = source.GetComponent(); if (sourceAlly != null) { teamUIController.Instance.RecordDamage(sourceAlly.slotIndex, actual); } } GameplaySkillLogger.RecordEnemyHpEvent( gameObject.name, "Damage", amount, effective, before, currentHP, damageResistance, source != null ? source.name : "None"); if (actual != 0) { if (deferPopup) { QueueDamagePopup(iNumberPrefabController.InstantNumberType.Damage, -actual); } else { iNumberPrefabController.SpawnForEnemyStatic(iNumberPrefabController.InstantNumberType.Damage, -actual); } } if (actual > 0) { GameplayLevelRuleEventBus.NotifyEnemyDamaged(this, actual, source); } // 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; GameplaySkillLogger.RecordEnemyHpEvent( gameObject.name, "Heal", amount, amount * mult, before, currentHP, damageResistance, source != null ? source.name : "None"); 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) { if (GameplayLevelRuleEventBus.ShouldPreventEnemyDeath(this)) { currentHP = 1; return; } 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 configured skills and reset mana if (currentMana >= maxMana && maxMana > 0 && Application.isPlaying) { Debug.Log($"[EnemyCombatant] Mana full for {gameObject.name} (slot) - triggering OnManaFull"); try { TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger.OnManaFull); GameplayLevelRuleEventBus.NotifyEnemyManaFull(this); } catch (Exception ex) { Debug.LogError($"[EnemyCombatant] OnManaFull handling threw: {ex}"); } // reset mana after casting ModifyMana(-currentMana, false); } } private void Die() { if (isDead) return; GameplaySkillLogger.RecordEnemyLifeState( gameObject.name, "Dead", currentHP, maxHP, currentMana, maxMana, _lastAttacker != null ? _lastAttacker.name : "None"); // 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() : 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); } } } AwardBountyScoreToLivingAllies(); // 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 = SceneObjectLookupCache.FindAny(); 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 configured enemy skills on death if configured. TryTriggerConfiguredSkills(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 = SceneObjectLookupCache.FindAny(); if (ui != null) { return; } var all = UnityEngine.Object.FindObjectsByType(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 = SceneObjectLookupCache.FindAny(); 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 private void AwardBountyScoreToLivingAllies() { if (sourceData == null) return; float difficultyLevel = ResolveCurrentDifficultyLevel(); var stats = sourceData.GetStatsByDifficultyID(_currentDifficultyID); int calculatedMaxHp = Mathf.Max(0, stats.enemy_maxHP); float totalBounty = calculatedMaxHp * 0.05f * difficultyLevel + sourceData.enemyBountyBonus; if (totalBounty <= 0f) return; AllyCombatant[] allies = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); List livingAllies = new List(); for (int i = 0; i < allies.Length; i++) { AllyCombatant ally = allies[i]; if (ally == null) continue; if (ally.IsDead || ally.maxHP <= 0 || !ally.gameObject.activeInHierarchy) continue; livingAllies.Add(ally); } if (livingAllies.Count == 0) return; int awardPerAlly = Mathf.CeilToInt(totalBounty / livingAllies.Count); if (awardPerAlly <= 0) return; for (int i = 0; i < livingAllies.Count; i++) { livingAllies[i].AddScoreDirect(awardPerAlly); } } private float ResolveCurrentDifficultyLevel() { BeatmapManager beatmapManager = BeatmapManager.Instance; if (beatmapManager == null || beatmapManager.assignedSongData == null || beatmapManager.assignedSongData.chartFiles == null) return 1f; List chartFiles = beatmapManager.assignedSongData.chartFiles; for (int i = 0; i < chartFiles.Count; i++) { ChartFileEntry entry = chartFiles[i]; if (entry == null || entry.difficulty != _currentDifficultyID) continue; return Mathf.Max(0f, entry.difficultyLEVEL); } return 1f; } 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); } }