Files
bansonic_beta_main/Assets/scripts/Combat/EnemyCombatant.cs
T
2026-07-30 23:15:58 +08:00

683 lines
24 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
// True (permanent) attack baseline. Timed buffs live in _attackModifiers instead of baking here,
// so percentages stay computed off the unbuffed base and re-triggers refresh rather than compound.
private int _attackBaseUnbuffed = 0;
private bool _attackBaseInitialized = false;
// Additive attack modifiers keyed by source (skillId); one entry per source, replaced on re-apply.
private readonly Dictionary<string, int> _attackModifiers = new Dictionary<string, int>();
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();
_attackModifiers.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);
}
// Reused only for the formula vars (safe: used synchronously in the leaf before any
// nested ExecuteSkill call). The `executed` set is intentionally NOT pooled: an enemy
// skill can restore the enemy's own mana and re-enter TryTriggerConfiguredSkills(OnManaFull)
// mid-loop, so a shared set could be cleared under the outer iteration.
private readonly Dictionary<string, float> _enemyFormulaVarsBuffer = new Dictionary<string, float>();
private void TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger when)
{
if (sourceData == null) return;
HashSet<SkillDefinition> executed = new HashSet<SkillDefinition>();
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<SkillDefinition> 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 = _enemyFormulaVarsBuffer;
vars.Clear();
vars["maxHP"] = maxHP;
vars["maxMana"] = maxMana;
vars["damageResistance"] = damageResistance;
// Attack-buff effects compute their percentage off the unbuffed base so repeated triggers
// add by source instead of compounding; output effects keep using current attack.
bool attackPercentOffBase = def.effectType == EffectType.IncreaseAttack || def.effectType == EffectType.DecreaseAttack;
vars["attack"] = attackPercentOffBase ? GetBaseAttack() : 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<AllyCombatant>();
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;
if (GameConfig.verboseLogs)
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();
int modifierSum = 0;
foreach (var kv in _attackModifiers) modifierSum += kv.Value;
attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult) + modifierSum);
}
public int GetBaseAttack()
{
EnsureAttackBaseInitialized();
return _attackBaseUnbuffed;
}
public void SetAttackModifier(string sourceKey, int delta)
{
if (string.IsNullOrEmpty(sourceKey)) { ModifyAttack(delta); return; }
EnsureAttackBaseInitialized();
if (delta == 0) _attackModifiers.Remove(sourceKey);
else _attackModifiers[sourceKey] = delta;
RecalculateAttackFromBuffs();
}
public void ClearAttackModifier(string sourceKey)
{
if (string.IsNullOrEmpty(sourceKey)) return;
if (_attackModifiers.Remove(sourceKey)) RecalculateAttackFromBuffs();
}
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)
{
if (GameConfig.verboseLogs)
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<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);
}
}
}
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<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 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<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 = SceneObjectLookupCache.FindAny<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
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<AllyCombatant>(FindObjectsInactive.Include, FindObjectsSortMode.None);
List<AllyCombatant> livingAllies = new List<AllyCombatant>();
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<ChartFileEntry> 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);
}
}