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

1143 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. 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;
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
public BeatmapManager bmm;
// 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;
// Editable multipliers for each judge quality (exposed for designers)
[Tooltip("Score multiplier for Perfect (default 1.0)")]
public float perfectRatio = 1f;
[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>();
// Track skills that are currently active because HP percent condition is satisfied.
private HashSet<string> _activeHpPercentSkills = new HashSet<string>();
// Track applied reversible effects for HP-percent skills so we can revert them on leave
private Dictionary<string, AppliedEffect> _appliedHpPercentEffects = new Dictionary<string, AppliedEffect>();
private class AppliedEffect
{
public EffectType effectType;
public float amount;
public AppliedEffect(EffectType t, float a) { effectType = t; amount = a; }
}
private void Awake()
{
// ensure GameObject name matches pattern so EffectSystem can find it
gameObject.name = $"ally_0{Mathf.Clamp(slotIndex + 1, 1, 5)}";
if (_activeHpPercentSkills == null) _activeHpPercentSkills = new HashSet<string>();
if (_appliedHpPercentEffects == null) _appliedHpPercentEffects = new Dictionary<string, AppliedEffect>();
}
private void Start()
{
// Resolve UI and optionally pull data from SO
ResolveUIReferences();
if (allowOverwriteFromSO)
InitializeStatsFromData();
// ؼ޸볡ʱӦѪ/0
// ֮ǰֻ Clamp prefab/ inspector ϵľ currentHPѪ
isDead = false;
currentHP = Mathf.Max(1, maxHP);
currentMana = 0;
UpdateUIImmediate();
// Ensure score UI shows initial value
UpdateScoreUI();
// Evaluate HP-percent triggers on start after a short delay to ensure singletons are ready
if (Application.isPlaying)
{
StartCoroutine(InitialTriggerCheckCoroutine());
}
}
private IEnumerator InitialTriggerCheckCoroutine()
{
// Wait for singletons to be ready
while (SkillBuilder.Instance == null || teamUIController.Instance == null)
{
yield return null;
}
// Wait until teamUIController has finished loading ally SOs if they are async
// or just wait a couple of frames to be sure
yield return null;
yield return null;
// Re-run initialization to ensure we have the correct MaxHP and skills from the loaded team
if (allowOverwriteFromSO)
{
InitializeStatsFromData();
currentHP = Mathf.Max(1, maxHP);
UpdateUIImmediate();
}
// Final wait to ensure SkillBuilder's internal mapping is ready
while (SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex) == null)
{
yield return null;
}
// Perform the initial HP-percentage trigger check (oldHP = currentHP to trigger "ENTER" if condition met)
EvaluateHPPercentageTriggers(currentHP);
}
// 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 currentHP or currentMana in inspector while in Play mode, attempt to trigger behaviors
if (Application.isPlaying && SkillBuilder.Instance != null)
{
EvaluateHPPercentageTriggers(currentHP);
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)
{
// ؼ޸ͬһ AllyHero_SO λʹʱֱ a.ally_currentEXP
// ԴҴ浵/PlayerPrefs ȡλľֵ
// Լselected_heroSlot0{slotIndex+1}_exp selected_heroSlot01_exp
int expKeySlot = Mathf.Clamp(slotIndex + 1, 1, 5);
string expKey = $"selected_heroSlot0{expKeySlot}_exp";
int slotExp = a.ally_currentEXP;
if (PlayerPrefs.HasKey(expKey))
slotExp = PlayerPrefs.GetInt(expKey, a.ally_currentEXP);
// pick effective level based on per-slot EXP
if (a.levelStats != null && a.levelStats.Count > 0)
{
var eff = GetEffectiveLevelForExp(a, slotExp);
if (eff != null)
{
maxHP = eff.maxHP;
maxMana = eff.maxMana;
damageResistance = eff.damageResistance;
scoreEfficiency = eff.scoreEfficiency;
attack = eff.attack;
}
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;
}
}
}
}
// ݴ EXP ȼ޸ SO ϵ ally_currentEXPλ״̬
private static AllyHero_SO.AllyLevelInfo GetEffectiveLevelForExp(AllyHero_SO so, int exp)
{
if (so == null || so.levelStats == null || so.levelStats.Count == 0) return null;
AllyHero_SO.AllyLevelInfo best = null;
foreach (var lvl in so.levelStats)
{
if (lvl == null) continue;
if (exp >= lvl.requiredEXP)
{
if (best == null || lvl.requiredEXP >= best.requiredEXP)
best = lvl;
}
}
return best ?? so.levelStats[0];
}
// Scoring API
// Modified to return the amount added so callers can track per-track contributions
public int AddScoreForJudge(string judge)
{
int add = 0;
float multiplier = 0f;
switch (judge)
{
case "Perfect":
multiplier = perfectRatio;
break;
case "Great":
multiplier = greatRatio;
break;
case "Good":
multiplier = goodRatio;
break;
case "Miss":
multiplier = missRatio;
break;
default:
multiplier = 0f;
break;
}
// Use perNoteScore from BeatmapManager as the base score for each note hit
int baseScore = bmm != null ? bmm.perNoteScore : baseTrackScore;
add = Mathf.FloorToInt(baseScore * multiplier);
int before = currentScore;
currentScore = Mathf.Clamp(currentScore + add, 0, maxTrackScore);
int actuallyAdded = currentScore - before;
UpdateScoreUI();
// update global total
ScoreManager.Instance?.RecalculateTotal();
return actuallyAdded;
}
// 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);
// Trigger HP changed events
int delta = currentHP - old;
if (delta > 0)
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnHPHealed);
}
else if (delta < 0)
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnHPLost);
}
// Evaluate HP-percentage based trigger transitions (entry/exit)
EvaluateHPPercentageTriggers(old);
}
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;
int oldMax = maxHP;
int oldHP = currentHP;
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();
// Max HP change affects percentage triggers
EvaluateHPPercentageTriggers(oldHP);
}
public void SetCurrentMana(int mana, bool animateFade = true, bool flashVisuals = true)
{
if (isDead) return; // dead characters cannot change mana
int old = currentMana;
currentMana = Mathf.Clamp(mana, 0, maxMana);
UpdateManaVisuals(old, animateFade, flashVisuals);
TryCastOnFullMana();
// Trigger mana changed events
int delta = currentMana - old;
if (delta > 0)
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaGained);
}
else if (delta < 0)
{
TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger.OnManaLost);
}
}
public void ModifyMana(int delta, bool animateFade = true, bool flashVisuals = true)
{
if (isDead && delta > 0)
{
// dead units cannot gain mana
return;
}
SetCurrentMana(currentMana + delta, animateFade, flashVisuals);
}
public void SetMaxMana(int newMax, bool keepCurrentRatio = true)
{
if (newMax < 1) newMax = 1;
int oldMax = maxMana;
int oldMana = currentMana;
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();
// Mana doesn't currently have percentage triggers, but for consistency:
// EvaluateHPPercentageTriggers(currentHP); // Not needed for mana but keep in mind
}
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;
}
// HP change flash effects
if (hurtRedImage != null && Application.isPlaying)
{
var ui = teamUIController.Instance;
if (ui != null)
{
if (currentHP < oldHP)
{
// Damage flash (Red)
if (!isDead)
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.hurtColor));
}
else if (currentHP > oldHP)
{
// Heal flash (Green)
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.healColor));
}
}
}
// if dead, trigger grayscale effect on character image
if (isDead && characterImage != null && Application.isPlaying)
{
var ui = teamUIController.Instance;
if (ui != null)
{
// 移除原有的红闪常亮逻辑,改为调用黑白渐变效果
StartCoroutine(ui.AllyDeathGrayscale(characterImage));
}
}
}
private void UpdateManaVisuals(int oldMana, bool animateFade, bool flashVisuals = true)
{
// 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;
}
// Mana recovery flash effect
if (flashVisuals && currentMana > oldMana && hurtRedImage != null && Application.isPlaying)
{
var ui = teamUIController.Instance;
if (ui != null)
{
StartCoroutine(ui.AllyImageFlash(hurtRedImage, ui.manaColor));
}
}
}
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);
}
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 TryTriggerSkillsOnEvent(SkillDefinition.SkillTrigger when)
{
if (!Application.isPlaying) return;
if (SkillBuilder.Instance == null) return;
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
if (so == null) return;
// If equipped groups exist, iterate them first
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (var gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++)
{
var g = so.skillGroups[k];
if (g != null && g.skillGroupID == gid) { group = g; break; }
}
if (group == null) continue;
foreach (var def in group.skills)
{
if (def == null) continue;
if (def.triggerCondition != when) continue;
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
}
}
return;
}
// Fallback: check primary group
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null)
{
foreach (var def in fallbackGroup.skills)
{
if (def == null) continue;
if (def.triggerCondition != when) continue;
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
}
return;
}
// Final fallback: iterate availableSkills
if (so.availableSkills != null)
{
foreach (var def in so.availableSkills)
{
if (def == null) continue;
if (def.triggerCondition != when) continue;
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
}
}
}
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;
}
bool anyTriggered = false;
// First: equipped groups
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (int gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++) { var g = so.skillGroups[k]; if (g != null && g.skillGroupID == gid) { group = g; break; } }
if (group == null) continue;
foreach (var skill in group.skills)
{
if (skill == null) continue;
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} cast skill from group: {skill.skillId} (OnManaFull)");
anyTriggered = true;
}
}
}
// Second: primary group
if (!anyTriggered)
{
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null)
{
foreach (var skill in fallbackGroup.skills)
{
if (skill == null) continue;
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} cast skill from primary group: {skill.skillId} (OnManaFull)");
anyTriggered = true;
}
}
}
// Third: availableSkills / primary skill fallback
if (!anyTriggered)
{
// try primary skill specifically
var def = so.GetPrimarySkill();
if (def != null && def.triggerCondition == SkillDefinition.SkillTrigger.OnManaFull)
{
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} cast primary skill '{def.skillId}' due to ManaFull.");
anyTriggered = true;
}
else if (so.availableSkills != null)
{
foreach (var skill in so.availableSkills)
{
if (skill == null) continue;
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull) continue;
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} cast available skill: {skill.skillId} (OnManaFull)");
anyTriggered = true;
}
}
}
if (!anyTriggered)
{
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1}: no skills configured for OnManaFull.");
}
}
private void CastSkillGroup(SkillGroup group, int slotIndex)
{
if (group == null) return;
// For auto-casting triggered by ManaFull, only cast skills that are configured to trigger on ManaFull.
for (int i = 0; i < group.skills.Length; i++)
{
var skill = group.skills[i];
if (skill == null) continue;
// Only cast skills whose triggerCondition matches OnManaFull when this method is invoked from auto-cast path
if (skill.triggerCondition != SkillDefinition.SkillTrigger.OnManaFull)
{
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} skill '{skill.skillId}' skipped: triggerCondition={skill.triggerCondition}.");
continue;
}
SkillBuilder.Instance.UseSkillDefinition(skill, slotIndex, -1f, null);
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} cast skill from group: {skill.skillId}");
}
}
public void TriggerOnManaFull()
{
if (currentMana >= maxMana)
{
TryCastOnFullMana();
}
}
[ContextMenu("Test Fill Mana")]
private void TestFillMana()
{
SetCurrentMana(maxMana, true);
}
// New: Evaluate HP-percent triggers and handle enter/leave transitions
private void EvaluateHPPercentageTriggers(int oldHP)
{
if (!Application.isPlaying) return;
if (SkillBuilder.Instance == null) return;
var so = SkillBuilder.Instance.GetAllyHeroSOBySlot(slotIndex);
if (so == null) return;
float oldPct = (maxHP > 0) ? (float)oldHP / (float)maxHP : 0f;
float currPct = (maxHP > 0) ? (float)currentHP / (float)maxHP : 0f;
Action<SkillDefinition> processDef = (def) =>
{
if (def == null) return;
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnHPAbovePercent && def.triggerCondition != SkillDefinition.SkillTrigger.OnHPBelowPercent)
return;
bool wasActive = _activeHpPercentSkills.Contains(def.skillId);
bool nowActive = false;
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPAbovePercent)
nowActive = currPct >= def.hpTriggerPercent;
else if (def.triggerCondition == SkillDefinition.SkillTrigger.OnHPBelowPercent)
nowActive = currPct <= def.hpTriggerPercent;
// Evaluate formula into amount using similar variables as SkillBuilder
float amount = 0f;
var vars = new Dictionary<string, float>();
vars["slot"] = slotIndex;
vars["attack"] = attack;
if (so != null && so.levelStats != null && so.levelStats.Count > 0)
{
var eff = so.GetEffectiveLevelForCurrentEXP();
var lvl = eff ?? so.levelStats[0];
vars["maxHP"] = lvl.maxHP;
vars["maxMana"] = lvl.maxMana;
vars["damageResistance"] = lvl.damageResistance;
vars["scoreEfficiency"] = lvl.scoreEfficiency;
vars["attack"] = lvl.attack;
}
else
{
vars["maxHP"] = maxHP;
vars["maxMana"] = maxMana;
vars["damageResistance"] = damageResistance;
vars["scoreEfficiency"] = scoreEfficiency;
}
vars["ally_currentEXP"] = so != null ? so.ally_currentEXP : 0f;
if (!string.IsNullOrWhiteSpace(def.formula))
{
if (!SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
amount = 0f;
else
amount = fresult;
}
// Decide if this effect type is reversible (state-based)
bool isReversible = IsEffectReversible(def.effectType);
if (!isReversible)
{
// For non-reversible effects (like Damage or instant Heal), we don't track state
// Just trigger it once when the condition is first met
if (!wasActive && nowActive)
{
_activeHpPercentSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null);
}
else if (wasActive && !nowActive)
{
_activeHpPercentSkills.Remove(def.skillId);
}
return;
}
if (!wasActive && nowActive)
{
// ENTER condition: Apply effect and record amount
_activeHpPercentSkills.Add(def.skillId);
EffectSystem.Instance.ApplyEffect(def.defaultSelector, def.effectType, amount, 0f, this.gameObject, null);
_appliedHpPercentEffects[def.skillId] = new AppliedEffect(def.effectType, amount);
}
else if (wasActive && !nowActive)
{
// LEAVE condition: Revert effect using recorded amount
_activeHpPercentSkills.Remove(def.skillId);
if (_appliedHpPercentEffects.TryGetValue(def.skillId, out var applied))
{
ApplyInverseEffect(def.defaultSelector, applied.effectType, applied.amount);
_appliedHpPercentEffects.Remove(def.skillId);
}
}
};
// iterate equipped groups -> primary group -> availableSkills (same as other triggers)
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
{
foreach (var gid in so.equippedSkillGroupIDs)
{
if (gid == 0) continue;
SkillGroup group = null;
for (int k = 0; k < so.skillGroups.Length; k++)
{
var g = so.skillGroups[k];
if (g != null && g.skillGroupID == gid) { group = g; break; }
}
if (group == null) continue;
foreach (var def in group.skills)
{
processDef(def);
}
}
return;
}
var fallbackGroup = so.GetPrimarySkillGroup();
if (fallbackGroup != null)
{
foreach (var def in fallbackGroup.skills)
{
processDef(def);
}
return;
}
if (so.availableSkills != null)
{
foreach (var def in so.availableSkills)
{
processDef(def);
}
}
}
private bool IsEffectReversible(EffectType type)
{
switch (type)
{
case EffectType.IncreaseAttack:
case EffectType.DecreaseAttack:
case EffectType.IncreaseMaxHP:
case EffectType.DecreaseMaxHP:
case EffectType.IncreaseMaxMana:
case EffectType.DecreaseMaxMana:
case EffectType.IncreaseScoreEfficiency:
case EffectType.DecreaseScoreEfficiency:
case EffectType.IncreaseDamageResistance:
case EffectType.DecreaseDamageResistance:
case EffectType.ScoreMultiplier:
case EffectType.AddScore:
return true;
default:
return false;
}
}
private void ApplyInverseEffect(Selector selector, EffectType type, float amount)
{
if (EffectSystem.Instance == null) return;
switch (type)
{
case EffectType.IncreaseAttack:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseAttack, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseAttack:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseAttack, amount, 0f, this.gameObject, null);
break;
case EffectType.IncreaseMaxHP:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseMaxHP, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseMaxHP:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseMaxHP, amount, 0f, this.gameObject, null);
break;
case EffectType.IncreaseMaxMana:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseMaxMana, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseMaxMana:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseMaxMana, amount, 0f, this.gameObject, null);
break;
case EffectType.IncreaseScoreEfficiency:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseScoreEfficiency, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseScoreEfficiency:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseScoreEfficiency, amount, 0f, this.gameObject, null);
break;
case EffectType.IncreaseDamageResistance:
EffectSystem.Instance.ApplyEffect(selector, EffectType.DecreaseDamageResistance, amount, 0f, this.gameObject, null);
break;
case EffectType.DecreaseDamageResistance:
EffectSystem.Instance.ApplyEffect(selector, EffectType.IncreaseDamageResistance, amount, 0f, this.gameObject, null);
break;
case EffectType.ScoreMultiplier:
if (Mathf.Abs(amount) > 0.0001f)
EffectSystem.Instance.ApplyEffect(selector, EffectType.ScoreMultiplier, 1f / amount, 0f, this.gameObject, null);
break;
case EffectType.AddScore:
EffectSystem.Instance.ApplyEffect(selector, EffectType.AddScore, -amount, 0f, this.gameObject, null);
break;
default:
// Fallback: apply same type with negative amount if it's likely a numeric stat
EffectSystem.Instance.ApplyEffect(selector, type, -amount, 0f, this.gameObject, null);
break;
}
}
}