编队系统重置 选角页面基本完成 等待存入playerprefs
This commit is contained in:
@@ -664,6 +664,25 @@ public class AllyCombatant : MonoBehaviour, ICombatant
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -16,6 +16,10 @@ public enum EffectType
|
||||
{
|
||||
DamageSingleEnemy,
|
||||
DamageOverTimeEnemy,
|
||||
HealSingleEnemy,
|
||||
HealOverTimeEnemy,
|
||||
DamageSingleAlly,
|
||||
DamageOverTimeAlly,
|
||||
HealSingleSelf,
|
||||
HealOverTimeSelf,
|
||||
HealGroupSingle,
|
||||
@@ -25,7 +29,15 @@ public enum EffectType
|
||||
IncreaseManaOverTime,
|
||||
ReduceEnemyHealOverTime,
|
||||
ScoreMultiplier,
|
||||
AddScore
|
||||
AddScore,
|
||||
IncreaseMaxHP,
|
||||
DecreaseMaxHP,
|
||||
IncreaseMaxMana,
|
||||
DecreaseMaxMana,
|
||||
IncreaseScoreEfficiency,
|
||||
DecreaseScoreEfficiency,
|
||||
IncreaseAttack,
|
||||
DecreaseAttack
|
||||
}
|
||||
|
||||
public interface ICombatant
|
||||
|
||||
@@ -55,6 +55,28 @@ public class EffectSystem : MonoBehaviour
|
||||
StartCoroutine(ApplyDamageOverTimeCoroutine(t, amount, duration, tickInterval, source));
|
||||
break;
|
||||
|
||||
case EffectType.HealSingleEnemy:
|
||||
// heal single enemy (first target)
|
||||
if (targets.Count > 0)
|
||||
ApplyInstantHeal(targets[0], amount, source);
|
||||
break;
|
||||
|
||||
case EffectType.HealOverTimeEnemy:
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyHealOverTimeCoroutine(t, amount, duration, tickInterval, source));
|
||||
break;
|
||||
|
||||
case EffectType.DamageSingleAlly:
|
||||
// damage a single ally (first target)
|
||||
if (targets.Count > 0)
|
||||
ApplyInstantDamage(targets[0], amount, source);
|
||||
break;
|
||||
|
||||
case EffectType.DamageOverTimeAlly:
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyDamageOverTimeCoroutine(t, amount, duration, tickInterval, source));
|
||||
break;
|
||||
|
||||
case EffectType.HealSingleSelf:
|
||||
if (targets.Count > 0)
|
||||
ApplyInstantHeal(targets[0], amount, source);
|
||||
@@ -163,7 +185,6 @@ public class EffectSystem : MonoBehaviour
|
||||
list.AddRange(FindAllAllies());
|
||||
if (source != null)
|
||||
list.RemoveAll(g => g == null || g == source || g.gameObject == source.gameObject);
|
||||
list.RemoveAll(g => g == null || g == source);
|
||||
break;
|
||||
|
||||
case Selector.AdjacentAllies:
|
||||
@@ -291,17 +312,30 @@ public class EffectSystem : MonoBehaviour
|
||||
private IEnumerable<GameObject> FindAllEnemies()
|
||||
{
|
||||
List<GameObject> enemies = new List<GameObject>();
|
||||
// try to find object named "thisEnemy" (single) or many by tag
|
||||
var single = GameObject.Find("thisEnemy");
|
||||
if (single != null) enemies.Add(single);
|
||||
|
||||
// Prefer explicit EnemyCombatant components (more robust than tags)
|
||||
try
|
||||
{
|
||||
var comps = GameObject.FindObjectsOfType<EnemyCombatant>(true);
|
||||
foreach (var c in comps)
|
||||
{
|
||||
if (c != null && c.gameObject != null && !enemies.Contains(c.gameObject)) enemies.Add(c.gameObject);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Also include a single named instance if present
|
||||
var single = GameObject.Find("thisEnemy");
|
||||
if (single != null && !enemies.Contains(single)) enemies.Add(single);
|
||||
|
||||
// Finally include any objects tagged "Enemy" (if tag exists)
|
||||
try
|
||||
{
|
||||
var tagged = GameObject.FindGameObjectsWithTag("Enemy");
|
||||
foreach (var g in tagged)
|
||||
if (!enemies.Contains(g)) enemies.Add(g);
|
||||
}
|
||||
catch { }
|
||||
catch { /* ignore missing tag */ }
|
||||
|
||||
return enemies;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// EnemyCombatant 是单个场上敌人的运行时表现(所有敌人共用同一个 GameObject,按顺序登场)
|
||||
// 实现 ICombatant:被 EffectSystem/SkillBuilder 调用
|
||||
public class EnemyCombatant : MonoBehaviour, ICombatant
|
||||
{
|
||||
public EnemyData_SO sourceData;
|
||||
|
||||
// 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;
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
// 初始化/复活为指定的 EnemyData_SO
|
||||
public void InitializeFromSO(EnemyData_SO so)
|
||||
{
|
||||
sourceData = so;
|
||||
if (so != null)
|
||||
{
|
||||
maxHP = Mathf.Max(1, so.enemy_maxHP);
|
||||
damageResistance = so.enemy_damageResistance;
|
||||
attack = so.enemy_baseAttack;
|
||||
maxMana = Mathf.Max(0, so.GetEffectiveMaxMana());
|
||||
}
|
||||
else
|
||||
{
|
||||
maxHP = 100;
|
||||
damageResistance = 0f;
|
||||
attack = 0;
|
||||
maxMana = 0;
|
||||
}
|
||||
|
||||
// revive
|
||||
currentHP = maxHP;
|
||||
currentMana = 0;
|
||||
isDead = false;
|
||||
|
||||
// notify listeners (UI/manager) that enemy revived
|
||||
OnEnemyRevived?.Invoke(this);
|
||||
|
||||
// Trigger primary skill on revive if defined and trigger condition matches
|
||||
TryTriggerPrimarySkill(SkillDefinition.SkillTrigger.OnEnemyRevive);
|
||||
}
|
||||
|
||||
// 尝试触发 primary skill,当触发条件匹配时调用 SkillBuilder
|
||||
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;
|
||||
if (!def.IsSingleInstance)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (isDead) return;
|
||||
|
||||
float effective = amount * (1f - damageResistance);
|
||||
int delta = Mathf.CeilToInt(effective);
|
||||
currentHP = Mathf.Clamp(currentHP - delta, 0, maxHP);
|
||||
|
||||
// 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)
|
||||
{
|
||||
if (isDead) return;
|
||||
int delta = Mathf.CeilToInt(amount);
|
||||
currentHP = Mathf.Clamp(currentHP + delta, 0, maxHP);
|
||||
}
|
||||
|
||||
public void ApplyBuff(Buff buff, GameObject source)
|
||||
{
|
||||
if (buff == null) return;
|
||||
activeBuffs.Add(buff);
|
||||
// Simple apply: only modify healReceivedMultiplier or attackMultiplier as example
|
||||
if (buff.attackMultiplier != 1f) attack = Mathf.RoundToInt(attack * buff.attackMultiplier);
|
||||
}
|
||||
|
||||
public void RemoveBuff(string buffId)
|
||||
{
|
||||
var b = activeBuffs.Find(x => x.buffId == buffId);
|
||||
if (b != null)
|
||||
{
|
||||
activeBuffs.Remove(b);
|
||||
}
|
||||
}
|
||||
|
||||
public void ModifyHP(int delta)
|
||||
{
|
||||
if (isDead && delta > 0) return;
|
||||
currentHP = Mathf.Clamp(currentHP + delta, 0, maxHP);
|
||||
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);
|
||||
|
||||
// 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
|
||||
currentMana = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void Die()
|
||||
{
|
||||
if (isDead) return;
|
||||
isDead = true;
|
||||
// notify listeners before possibly being reused
|
||||
OnEnemyDied?.Invoke(this);
|
||||
|
||||
// Trigger primary skill on death if configured
|
||||
TryTriggerPrimarySkill(SkillDefinition.SkillTrigger.OnEnemyDead);
|
||||
}
|
||||
|
||||
// Utility: expose a read-only status
|
||||
public bool IsDead => isDead;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef11f98db21141b4ab519043f45dd0b5
|
||||
@@ -44,10 +44,16 @@ public class SkillBuilder : MonoBehaviour
|
||||
// Placeholder: if you want to route damage through EffectSystem later, use this.
|
||||
public void DealDamageFromAllyToEnemies(int slotIndex, float amount)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] DealDamageFromAllyToEnemies (placeholder): slot={slotIndex} amount={amount}");
|
||||
// Future: integrate with EffectSystem/Enemy system here. Example (commented):
|
||||
// var caster = GetAllyObjectBySlot(slotIndex);
|
||||
// global::EffectSystem.Instance?.ApplyEffect(Selector.CurrentEnemies, EffectType.DamageSingleEnemy, amount, 0f, caster, null);
|
||||
// Resolve caster GameObject and route damage to EffectSystem so enemies receive damage with resistances etc.
|
||||
var caster = GetAllyObjectBySlot(slotIndex);
|
||||
if (global::EffectSystem.Instance == null)
|
||||
{
|
||||
Debug.LogWarning($"DealDamageFromAllyToEnemies: EffectSystem.Instance is null. slot={slotIndex} amount={amount}");
|
||||
return;
|
||||
}
|
||||
// Apply as single-instance damage to current enemies; EffectSystem will pick first applicable enemy for DamageSingleEnemy
|
||||
global::EffectSystem.Instance.ApplyEffect(Selector.CurrentEnemies, EffectType.DamageSingleEnemy, amount, 0f, caster, null);
|
||||
Debug.Log($"[SkillBuilder] DealDamageFromAllyToEnemies: applied {amount} from slot {slotIndex} via EffectSystem");
|
||||
}
|
||||
|
||||
private void ApplySharedOnNoteHit(int trackIndex, string judgeResult)
|
||||
@@ -224,7 +230,7 @@ public class SkillBuilder : MonoBehaviour
|
||||
// 直接修改全局总分(立即生效)
|
||||
public void ModifyTotalScore(int delta)
|
||||
{
|
||||
if (ScoreManager.Instance == null) { Debug.LogWarning("ModifyTotalScore: ScoreManager.Instance == null"); return; }
|
||||
if (ScoreManager.Instance == null) { Debug.LogWarning("ModifyTotalScore: ScoreManager.Instance is null"); return; }
|
||||
// adjust total and attempt to update UI
|
||||
ScoreManager.Instance.totalScore += delta;
|
||||
// try to force UI update via ScoreManager.RecalculateTotal() is not appropriate because it recalculates from allies
|
||||
@@ -320,6 +326,36 @@ public class SkillBuilder : MonoBehaviour
|
||||
ExecuteSkill("DealSingleEnemyDamage", EffectType.DamageSingleEnemy, amount, Selector.CurrentEnemies, caster, enemyTarget);
|
||||
}
|
||||
|
||||
// New: heal enemy directly (single)
|
||||
public void HealSingleEnemy(GameObject caster, GameObject enemyTarget, float amount)
|
||||
{
|
||||
if (enemyTarget == null) { Debug.LogWarning("HealSingleEnemy: enemyTarget == null"); return; }
|
||||
ExecuteSkill("HealSingleEnemy", EffectType.HealSingleEnemy, amount, Selector.CurrentEnemies, caster, enemyTarget);
|
||||
}
|
||||
|
||||
// New: heal enemy over time
|
||||
public void ApplyHealOverTimeToEnemies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||||
{
|
||||
if (duration <= 0f) duration = defaultDuration;
|
||||
if (tickInterval <= 0f) tickInterval = defaultTickInterval;
|
||||
ExecuteSkill("HealOverTimeEnemy", EffectType.HealOverTimeEnemy, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||||
}
|
||||
|
||||
// New: damage allies directly (single)
|
||||
public void DealSingleAllyDamage(GameObject caster, GameObject allyTarget, float amount)
|
||||
{
|
||||
if (allyTarget == null) { Debug.LogWarning("DealSingleAllyDamage: allyTarget == null"); return; }
|
||||
ExecuteSkill("DealSingleAllyDamage", EffectType.DamageSingleAlly, amount, Selector.AllAllies, caster, allyTarget);
|
||||
}
|
||||
|
||||
// New: damage allies over time
|
||||
public void ApplyDamageOverTimeToAllies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||||
{
|
||||
if (duration <= 0f) duration = defaultDuration;
|
||||
if (tickInterval <= 0f) tickInterval = defaultTickInterval;
|
||||
ExecuteSkill("DamageOverTimeAlly", EffectType.DamageOverTimeAlly, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||||
}
|
||||
|
||||
public void ApplyDamageOverTimeToEnemies(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||||
{
|
||||
if (duration <= 0f) duration = defaultDuration;
|
||||
@@ -636,6 +672,37 @@ public class SkillBuilder : MonoBehaviour
|
||||
if (def.effectType == EffectType.BuffDuration) b.scoreMultiplier = 1.5f;
|
||||
if (ic != null) ic.ApplyBuff(b, caster);
|
||||
break;
|
||||
// 新增 effect type 处理
|
||||
case EffectType.IncreaseMaxHP:
|
||||
if (ally != null) ally.SetMaxHP(ally.maxHP + Mathf.CeilToInt(amountTotal), false);
|
||||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy)) enemy.maxHP += Mathf.CeilToInt(amountTotal);
|
||||
break;
|
||||
case EffectType.DecreaseMaxHP:
|
||||
if (ally != null) ally.SetMaxHP(Mathf.Max(1, ally.maxHP - Mathf.CeilToInt(amountTotal)), false);
|
||||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy2)) enemy2.maxHP = Mathf.Max(1, enemy2.maxHP - Mathf.CeilToInt(amountTotal));
|
||||
break;
|
||||
case EffectType.IncreaseMaxMana:
|
||||
if (ally != null) ally.SetMaxMana(ally.maxMana + Mathf.CeilToInt(amountTotal), false);
|
||||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy3)) enemy3.maxMana += Mathf.CeilToInt(amountTotal);
|
||||
break;
|
||||
case EffectType.DecreaseMaxMana:
|
||||
if (ally != null) ally.SetMaxMana(Mathf.Max(1, ally.maxMana - Mathf.CeilToInt(amountTotal)), false);
|
||||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy4)) enemy4.maxMana = Mathf.Max(1, enemy4.maxMana - Mathf.CeilToInt(amountTotal));
|
||||
break;
|
||||
case EffectType.IncreaseScoreEfficiency:
|
||||
if (ally != null) ally.scoreEfficiency += amountTotal;
|
||||
break;
|
||||
case EffectType.DecreaseScoreEfficiency:
|
||||
if (ally != null) ally.scoreEfficiency = Mathf.Max(0f, ally.scoreEfficiency - amountTotal);
|
||||
break;
|
||||
case EffectType.IncreaseAttack:
|
||||
if (ally != null) ally.attack += Mathf.CeilToInt(amountTotal);
|
||||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy5)) enemy5.attack += Mathf.CeilToInt(amountTotal);
|
||||
break;
|
||||
case EffectType.DecreaseAttack:
|
||||
if (ally != null) ally.attack = Mathf.Max(0, ally.attack - Mathf.CeilToInt(amountTotal));
|
||||
else if (t.TryGetComponent<EnemyCombatant>(out var enemy6)) enemy6.attack = Mathf.Max(0, enemy6.attack - Mathf.CeilToInt(amountTotal));
|
||||
break;
|
||||
default:
|
||||
// non-direct path handled below, but keep compatibility
|
||||
ExecuteSkill(def.displayName ?? def.skillId, def.effectType, amountTotal, def.defaultSelector, caster, specificTarget, def.defaultDuration, def.defaultTickInterval);
|
||||
@@ -663,6 +730,7 @@ public class SkillBuilder : MonoBehaviour
|
||||
}
|
||||
|
||||
// Use the primary skill configured in the AllyHero_SO for this slot (primarySkillIndex dropdown)
|
||||
// Updated: runtime activation now prefers equippedSkillGroupIDs on the SO. Only skills inside equipped groups are considered active.
|
||||
public void UsePrimarySkillForSlot(int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||||
{
|
||||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||||
@@ -671,11 +739,51 @@ public class SkillBuilder : MonoBehaviour
|
||||
Debug.LogWarning($"UsePrimarySkillForSlot: no SO for slot {slotIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
// If SO defines equipped group IDs, cast skills from those groups (these represent active/owned skills at runtime)
|
||||
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;
|
||||
UseSkillGroupForSlot(group, slotIndex, inputValue, specificTarget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: prefer any defined primary group (SO-level) via GetPrimarySkillGroup(), otherwise use primarySkillIndex
|
||||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||||
if (fallbackGroup != null)
|
||||
{
|
||||
UseSkillGroupForSlot(fallbackGroup, slotIndex, inputValue, specificTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = so.primarySkillIndex;
|
||||
if (idx < 0) { Debug.LogWarning($"UsePrimarySkillForSlot: primarySkillIndex not set for slot {slotIndex}"); return; }
|
||||
UseSelectedSkillForSlot(slotIndex, idx, inputValue, specificTarget);
|
||||
}
|
||||
|
||||
// Cast all non-null skills in a SkillGroup for a given slotIndex. Each skill is invoked via UseSkillDefinition.
|
||||
public void UseSkillGroupForSlot(SkillGroup group, int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||||
{
|
||||
if (group == null) return;
|
||||
for (int i = 0; i < group.skills.Length; i++)
|
||||
{
|
||||
var s = group.skills[i];
|
||||
if (s == null) continue;
|
||||
UseSkillDefinition(s, slotIndex, inputValue, specificTarget);
|
||||
Debug.Log($"[SkillBuilder] UseSkillGroupForSlot: cast skill {s.skillId} from group '{group.groupName}' for slot {slotIndex}");
|
||||
}
|
||||
}
|
||||
|
||||
// Called at game start to trigger any allies whose primary skill is set to trigger on game start
|
||||
public void TriggerOnGameStart()
|
||||
{
|
||||
@@ -685,6 +793,45 @@ public class SkillBuilder : MonoBehaviour
|
||||
{
|
||||
var so = GetAllyHeroSOBySlot(i);
|
||||
if (so == null) continue;
|
||||
|
||||
// If equipped groups exist, iterate them and cast group skills whose triggerCondition == OnGameStart
|
||||
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 sk in group.skills)
|
||||
{
|
||||
if (sk == null) continue;
|
||||
if (sk.triggerCondition == SkillDefinition.SkillTrigger.OnGameStart)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] TriggerOnGameStart: casting equipped group skill {sk.skillId} for slot {i}");
|
||||
UseSkillDefinition(sk, i, -1f, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: check SO-level primary group via GetPrimarySkillGroup()
|
||||
var fallbackGroup = so.GetPrimarySkillGroup();
|
||||
if (fallbackGroup != null)
|
||||
{
|
||||
foreach (var sk in fallbackGroup.skills)
|
||||
{
|
||||
if (sk == null) continue;
|
||||
if (sk.triggerCondition == SkillDefinition.SkillTrigger.OnGameStart)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] TriggerOnGameStart: casting group skill {sk.skillId} for slot {i}");
|
||||
UseSkillDefinition(sk, i, -1f, null);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var def = so.GetPrimarySkill();
|
||||
if (def == null) continue;
|
||||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnGameStart)
|
||||
@@ -697,14 +844,44 @@ public class SkillBuilder : MonoBehaviour
|
||||
|
||||
private Dictionary<string, float> _lastOnNoteHitTriggerTime = new Dictionary<string, float>();
|
||||
|
||||
// track processed unique note IDs (e.g. long-hold note id) so shared effects (mana/hp) are applied only once
|
||||
private Dictionary<string, float> _processedNoteHitTimestamps = new Dictionary<string, float>();
|
||||
|
||||
// Called when a note on a particular track is hit (judgeResult e.g. "Perfect"/"Great"/"Good").
|
||||
// Will cast the primary skill for the ally in that track if its primary skill is configured to trigger on note hit.
|
||||
// noteType indicates whether the hit event came from a Tap or Hold (tail)
|
||||
public bool NotifyNoteHit(int trackIndex, string judgeResult, SkillDefinition.NoteTypeTrigger noteType = SkillDefinition.NoteTypeTrigger.Tap)
|
||||
public bool NotifyNoteHit(int trackIndex, string judgeResult, SkillDefinition.NoteTypeTrigger noteType = SkillDefinition.NoteTypeTrigger.Tap, string uniqueNoteId = null)
|
||||
{
|
||||
// Always apply shared per-note behavior (mana gain / miss penalty / reserved damage) regardless of SO-defined primary skill
|
||||
try { ApplySharedOnNoteHit(trackIndex, judgeResult); } catch (System.Exception ex) { Debug.LogError($"[SkillBuilder] ApplySharedOnNoteHit threw: {ex}"); }
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit RECEIVED: track={trackIndex} judge={judgeResult} noteType={noteType}");
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit RECEIVED: track={trackIndex} judge={judgeResult} noteType={noteType} uniqueId={uniqueNoteId}");
|
||||
// cleanup old processed ids (> 10s)
|
||||
float nowCleanup = Time.time;
|
||||
var toRemove = new List<string>();
|
||||
foreach (var kv in _processedNoteHitTimestamps)
|
||||
{
|
||||
if (nowCleanup - kv.Value > 10f) toRemove.Add(kv.Key);
|
||||
}
|
||||
foreach (var k in toRemove) _processedNoteHitTimestamps.Remove(k);
|
||||
|
||||
// Determine whether to apply shared effects (mana/H P loss) for this event.
|
||||
bool applyShared = true;
|
||||
if (!string.IsNullOrEmpty(uniqueNoteId))
|
||||
{
|
||||
if (_processedNoteHitTimestamps.ContainsKey(uniqueNoteId))
|
||||
{
|
||||
applyShared = false; // already applied for this hold note
|
||||
}
|
||||
else
|
||||
{
|
||||
_processedNoteHitTimestamps[uniqueNoteId] = Time.time;
|
||||
}
|
||||
}
|
||||
|
||||
// Always apply shared per-note behavior (mana gain / miss penalty / reserved damage) unless this unique note id was already processed
|
||||
if (applyShared)
|
||||
{
|
||||
try { ApplySharedOnNoteHit(trackIndex, judgeResult); } catch (System.Exception ex) { Debug.LogError($"[SkillBuilder] ApplySharedOnNoteHit threw: {ex}"); }
|
||||
}
|
||||
|
||||
if (trackIndex < 0) { Debug.Log("[SkillBuilder] NotifyNoteHit: invalid trackIndex"); return false; }
|
||||
var so = GetAllyHeroSOBySlot(trackIndex);
|
||||
if (so == null)
|
||||
@@ -744,100 +921,113 @@ public class SkillBuilder : MonoBehaviour
|
||||
return false;
|
||||
}
|
||||
}
|
||||
var def = so.GetPrimarySkill();
|
||||
if (def == null) { Debug.Log($"[SkillBuilder] NotifyNoteHit: no primary skill for slot {trackIndex}"); return false; }
|
||||
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill={def.skillId} noteTriggerType={def.noteTriggerType} onNoteHitMinThreshold={def.onNoteHitMinThreshold} cooldown={def.onNoteHitCooldown}");
|
||||
bool anyTriggered = false;
|
||||
|
||||
// Treat Hold events as Tap-equivalent by default to improve compatibility: many skills expect Tap/Either
|
||||
var effectiveNoteType = noteType;
|
||||
if (noteType == SkillDefinition.NoteTypeTrigger.Hold)
|
||||
// If equipped groups exist, iterate equipped groups and check each skill for OnNoteHit
|
||||
if (so.equippedSkillGroupIDs != null && so.equippedSkillGroupIDs.Length > 0 && so.skillGroups != null)
|
||||
{
|
||||
// Only convert to Tap-equivalent for checking; keep original in logs
|
||||
effectiveNoteType = SkillDefinition.NoteTypeTrigger.Tap;
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: treating incoming Hold event as Tap-equivalent for skill checks (track={trackIndex})");
|
||||
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 def in group.skills)
|
||||
{
|
||||
if (def == null) continue;
|
||||
if (def.triggerCondition != SkillDefinition.SkillTrigger.OnNoteHit) continue;
|
||||
|
||||
// Respect note type (Tap/Hold/Either) - treat Hold as Tap-equivalent for checking as before
|
||||
var effectiveNoteType = noteType;
|
||||
if (noteType == SkillDefinition.NoteTypeTrigger.Hold) effectiveNoteType = SkillDefinition.NoteTypeTrigger.Tap;
|
||||
if (def.noteTriggerType == SkillDefinition.NoteTypeTrigger.Tap && effectiveNoteType != SkillDefinition.NoteTypeTrigger.Tap) continue;
|
||||
if (def.noteTriggerType == SkillDefinition.NoteTypeTrigger.Hold && noteType != SkillDefinition.NoteTypeTrigger.Hold) continue;
|
||||
|
||||
int quality = JudgeQualityFromString(judgeResult);
|
||||
if (def.onNoteHitMinThreshold == SkillDefinition.NoteTriggerThreshold.Miss)
|
||||
{
|
||||
if (quality != 0) continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
int required = (int)def.onNoteHitMinThreshold;
|
||||
if (quality < required) continue;
|
||||
}
|
||||
|
||||
// cooldown per slot+skill
|
||||
string key = $"{trackIndex}:{def.skillId}";
|
||||
float now = Time.time;
|
||||
if (def.onNoteHitCooldown > 0f)
|
||||
{
|
||||
if (_lastOnNoteHitTriggerTime.TryGetValue(key, out float last))
|
||||
{
|
||||
if (now - last < def.onNoteHitCooldown) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// trigger
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: triggering equipped skill {def.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})");
|
||||
UseSkillDefinition(def, trackIndex, -1f, null);
|
||||
_lastOnNoteHitTriggerTime[key] = now;
|
||||
anyTriggered = true;
|
||||
}
|
||||
}
|
||||
return anyTriggered;
|
||||
}
|
||||
|
||||
// Respect note type (Tap/Hold/Either)
|
||||
switch (def.noteTriggerType)
|
||||
// Fallback to previous behavior using primary skill (availableSkills)
|
||||
var defPrimary = so.GetPrimarySkill();
|
||||
if (defPrimary == null) { Debug.Log($"[SkillBuilder] NotifyNoteHit: no primary skill for slot {trackIndex}"); return false; }
|
||||
if (defPrimary.triggerCondition != SkillDefinition.SkillTrigger.OnNoteHit)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: primary skill {defPrimary.skillId} for slot {trackIndex} is not set to OnNoteHit (actual={defPrimary.triggerCondition}), rejecting");
|
||||
return false;
|
||||
}
|
||||
|
||||
// previous checks preserved
|
||||
var effectiveNoteTypePrimary = noteType;
|
||||
if (noteType == SkillDefinition.NoteTypeTrigger.Hold) effectiveNoteTypePrimary = SkillDefinition.NoteTypeTrigger.Tap;
|
||||
switch (defPrimary.noteTriggerType)
|
||||
{
|
||||
case SkillDefinition.NoteTypeTrigger.Tap:
|
||||
// accept Tap (and treat Hold as Tap-equivalent)
|
||||
if (effectiveNoteType != SkillDefinition.NoteTypeTrigger.Tap)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {def.skillId} requires Tap but event is {noteType}, rejecting");
|
||||
return false;
|
||||
}
|
||||
if (effectiveNoteTypePrimary != SkillDefinition.NoteTypeTrigger.Tap) { Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} requires Tap but event is {noteType}, rejecting"); return false; }
|
||||
break;
|
||||
case SkillDefinition.NoteTypeTrigger.Hold:
|
||||
if (noteType != SkillDefinition.NoteTypeTrigger.Hold)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {def.skillId} requires Hold but event is {noteType}, rejecting");
|
||||
return false;
|
||||
}
|
||||
if (noteType != SkillDefinition.NoteTypeTrigger.Hold) { Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} requires Hold but event is {noteType}, rejecting"); return false; }
|
||||
break;
|
||||
case SkillDefinition.NoteTypeTrigger.Either:
|
||||
// accept both
|
||||
break;
|
||||
}
|
||||
|
||||
// Map judgeResult to numeric quality
|
||||
int quality = JudgeQualityFromString(judgeResult);
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: judgeResult='{judgeResult}' mappedQuality={quality}");
|
||||
// Special case: if threshold == Miss, only trigger on Miss (quality == 0)
|
||||
if (def.onNoteHitMinThreshold == SkillDefinition.NoteTriggerThreshold.Miss)
|
||||
int qualityPrimary = JudgeQualityFromString(judgeResult);
|
||||
if (defPrimary.onNoteHitMinThreshold == SkillDefinition.NoteTriggerThreshold.Miss)
|
||||
{
|
||||
if (quality != 0)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {def.skillId} is Miss-only but quality={quality}, rejecting");
|
||||
return false;
|
||||
}
|
||||
if (qualityPrimary != 0) { Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} is Miss-only but quality={qualityPrimary}, rejecting"); return false; }
|
||||
}
|
||||
else
|
||||
{
|
||||
int required = (int)def.onNoteHitMinThreshold; // Good=1, Great=2, Perfect=3
|
||||
if (quality < required)
|
||||
int required = (int)defPrimary.onNoteHitMinThreshold;
|
||||
if (qualityPrimary < required) { Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} requires quality>={required} but got {qualityPrimary}, rejecting"); return false; }
|
||||
}
|
||||
|
||||
string keyPrimary = $"{trackIndex}:{defPrimary.skillId}";
|
||||
float nowPrimary = Time.time;
|
||||
if (defPrimary.onNoteHitCooldown > 0f && _lastOnNoteHitTriggerTime.TryGetValue(keyPrimary, out float lastPrimary))
|
||||
{
|
||||
if (nowPrimary - lastPrimary < defPrimary.onNoteHitCooldown)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {def.skillId} requires quality>={required} but got {quality}, rejecting");
|
||||
// not high enough
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {defPrimary.skillId} on cooldown for slot {trackIndex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// cooldown: per slot+skill
|
||||
string key = $"{trackIndex}:{def.skillId}";
|
||||
float now = Time.time;
|
||||
if (def.onNoteHitCooldown > 0f)
|
||||
{
|
||||
if (_lastOnNoteHitTriggerTime.TryGetValue(key, out float last))
|
||||
{
|
||||
if (now - last < def.onNoteHitCooldown)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {def.skillId} on cooldown for slot {trackIndex}");
|
||||
// still cooling down
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Passed checks -> invoke primary skill for slot
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: triggering primary skill {def.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})");
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: triggering primary skill {defPrimary.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})");
|
||||
UsePrimarySkillForSlot(trackIndex, -1f, null);
|
||||
_lastOnNoteHitTriggerTime[key] = now;
|
||||
_lastOnNoteHitTriggerTime[keyPrimary] = nowPrimary;
|
||||
return true;
|
||||
}
|
||||
|
||||
private int JudgeQualityFromString(string judge)
|
||||
{
|
||||
switch (judge)
|
||||
{
|
||||
case "Perfect": return 3;
|
||||
case "Great": return 2;
|
||||
case "Good": return 1;
|
||||
default: return 0; // Miss or unknown
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Explanation of caster and enemyTarget:
|
||||
- GameObject caster: the GameObject that performs (casts) the skill. For ally skills this should be the in-scene ally GameObject (usually name ally_01..ally_05) and should have AllyCombatant / ICombatant components. SkillBuilder uses slotIndex -> GetAllyObjectBySlot to resolve this.
|
||||
@@ -935,4 +1125,16 @@ public class SkillBuilder : MonoBehaviour
|
||||
elapsed += tickInterval;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: map judge string to numeric quality
|
||||
private int JudgeQualityFromString(string judge)
|
||||
{
|
||||
switch (judge)
|
||||
{
|
||||
case "Perfect": return 3;
|
||||
case "Great": return 2;
|
||||
case "Good": return 1;
|
||||
default: return 0; // Miss or unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ public class SkillDefinition : ScriptableObject
|
||||
None,
|
||||
OnGameStart,
|
||||
OnNoteHit,
|
||||
OnManaFull
|
||||
OnManaFull,
|
||||
OnEnemyRevive, // 新增:当敌人登场时触发
|
||||
OnEnemyDead // 新增:当敌人死亡时触发
|
||||
}
|
||||
|
||||
[Header("标识 (Identity)")]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using UnityEngine;
|
||||
|
||||
[System.Serializable]
|
||||
public class SkillGroup
|
||||
{
|
||||
[Tooltip("Numeric ID used to reference this skill group (e.g. from equipped slots). Use 0 for 'none'.")]
|
||||
public int skillGroupID = 0;
|
||||
|
||||
[Tooltip("Optional human-readable name for the group (for designers).")]
|
||||
public string groupName = "";
|
||||
|
||||
// 新增:栏目编号,用于分组和排序
|
||||
public int columnID = 0;
|
||||
|
||||
// 新增:栏目名字,用于UI显示
|
||||
public string columnName = "";
|
||||
|
||||
// Variable-length list of SkillDefinitions so one group ID can map to multiple skills
|
||||
public SkillDefinition[] skills = new SkillDefinition[0];
|
||||
|
||||
// Single icon representing this skill group (editable in Inspector)
|
||||
[Header("Group Icon")]
|
||||
[Tooltip("Optional single Sprite used as an icon for this skill group.")]
|
||||
public Sprite groupIcon = null;
|
||||
|
||||
// Single multiline description for the skill group (editable in Inspector)
|
||||
[Header("Group-level designer notes")]
|
||||
[TextArea(3, 10)]
|
||||
[Tooltip("A multiline description for this skill group. Use this to store notes or an overview that can be shown at runtime.")]
|
||||
public string skillDescriptionsText = "";
|
||||
|
||||
// Backwards-compatible accessor that previously returned a per-skill description.
|
||||
// Now returns the single group-level text so callers can still obtain a description string.
|
||||
public string GetSkillDescription(int index)
|
||||
{
|
||||
return skillDescriptionsText ?? string.Empty;
|
||||
}
|
||||
|
||||
// Lookup by SkillDefinition instance; also returns group-level text for compatibility.
|
||||
public string GetSkillDescription(SkillDefinition def)
|
||||
{
|
||||
return skillDescriptionsText ?? string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a8d9099267f4da45bbcc157c9e7a89b
|
||||
Reference in New Issue
Block a user