编队系统大更新 基本快搞好了 准备做敌人和分数
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 718e488bcee5b6a4c8eedc073040d3a3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,680 @@
|
||||
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
|
||||
|
||||
// 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;
|
||||
|
||||
[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>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// ensure GameObject name matches pattern so EffectSystem can find it
|
||||
gameObject.name = $"ally_0{Mathf.Clamp(slotIndex + 1, 1, 5)}";
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Resolve UI and optionally pull data from SO
|
||||
ResolveUIReferences();
|
||||
if (allowOverwriteFromSO)
|
||||
InitializeStatsFromData();
|
||||
|
||||
// Ensure runtime invariants
|
||||
currentHP = Mathf.Clamp(currentHP, 0, Mathf.Max(1, maxHP));
|
||||
currentMana = Mathf.Clamp(currentMana, 0, Mathf.Max(1, maxMana));
|
||||
|
||||
UpdateUIImmediate();
|
||||
// Ensure score UI shows initial value
|
||||
UpdateScoreUI();
|
||||
}
|
||||
|
||||
// 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 currentMana in inspector while in Play mode, attempt to trigger mana-full behaviors
|
||||
// Guard to avoid calling TryCastOnFullMana before singletons are initialized
|
||||
if (Application.isPlaying && SkillBuilder.Instance != null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
// pick effective level based on current EXP if available
|
||||
if (a.levelStats != null && a.levelStats.Count > 0)
|
||||
{
|
||||
var eff = a.GetEffectiveLevelForCurrentEXP();
|
||||
if (eff != null)
|
||||
{
|
||||
maxHP = eff.maxHP;
|
||||
maxMana = eff.maxMana;
|
||||
damageResistance = eff.damageResistance;
|
||||
scoreEfficiency = eff.scoreEfficiency;
|
||||
attack = eff.attack; // populate attack from SO effective level
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scoring API
|
||||
public void AddScoreForJudge(string judge)
|
||||
{
|
||||
int add = 0;
|
||||
switch (judge)
|
||||
{
|
||||
case "Perfect":
|
||||
add = baseTrackScore;
|
||||
break;
|
||||
case "Great":
|
||||
add = Mathf.FloorToInt(baseTrackScore * greatRatio);
|
||||
break;
|
||||
case "Good":
|
||||
add = Mathf.FloorToInt(baseTrackScore * goodRatio);
|
||||
break;
|
||||
case "Miss":
|
||||
add = Mathf.FloorToInt(baseTrackScore * missRatio);
|
||||
break;
|
||||
default:
|
||||
add = 0;
|
||||
break;
|
||||
}
|
||||
// apply scoreEfficiency or other modifiers here
|
||||
add = Mathf.FloorToInt(add * scoreEfficiency);
|
||||
|
||||
currentScore = Mathf.Clamp(currentScore + add, 0, maxTrackScore);
|
||||
UpdateScoreUI();
|
||||
// update global total
|
||||
ScoreManager.Instance?.RecalculateTotal();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
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();
|
||||
}
|
||||
|
||||
public void SetCurrentMana(int mana, bool animateFade = true)
|
||||
{
|
||||
if (isDead) return; // dead characters cannot change mana
|
||||
int old = currentMana;
|
||||
currentMana = Mathf.Clamp(mana, 0, maxMana);
|
||||
UpdateManaVisuals(old, animateFade);
|
||||
TryCastOnFullMana();
|
||||
}
|
||||
|
||||
public void ModifyMana(int delta, bool animateFade = true)
|
||||
{
|
||||
if (isDead && delta > 0)
|
||||
{
|
||||
// dead units cannot gain mana
|
||||
return;
|
||||
}
|
||||
SetCurrentMana(currentMana + delta, animateFade);
|
||||
}
|
||||
|
||||
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);
|
||||
UpdateUIImmediate();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// if HP decreased, show hurt flash; if dead, keep hurt overlay visible
|
||||
if (currentHP < oldHP && hurtRedImage != null && Application.isPlaying)
|
||||
{
|
||||
StopCoroutine("HurtFlash");
|
||||
StartCoroutine(HurtFlash());
|
||||
}
|
||||
if (isDead && hurtRedImage != null)
|
||||
{
|
||||
Color c = hurtRedImage.color;
|
||||
c.a = 1f;
|
||||
hurtRedImage.color = c; // keep overlay visible when dead
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateManaVisuals(int oldMana, bool animateFade)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private IEnumerator HurtFlash()
|
||||
{
|
||||
if (hurtRedImage == null) yield break;
|
||||
Color c = hurtRedImage.color;
|
||||
c.a = 1f;
|
||||
hurtRedImage.color = c;
|
||||
// when injured, remain visible a bit longer; if dead, keep it on
|
||||
if (isDead)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
yield return new WaitForSeconds(0.2f);
|
||||
float dur = 0.4f;
|
||||
float t = 0f;
|
||||
while (t < dur)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
c.a = Mathf.Lerp(1f, 0f, t / dur);
|
||||
hurtRedImage.color = c;
|
||||
yield return null;
|
||||
}
|
||||
c.a = 0f;
|
||||
hurtRedImage.color = c;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
var def = so.GetPrimarySkill();
|
||||
if (def == null)
|
||||
{
|
||||
Debug.LogWarning($"[AllyCombatant] Slot {slotIndex + 1} cannot cast: primary skill not set in SO.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only auto-cast here if the skill's trigger condition matches ManaFull
|
||||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnManaFull)
|
||||
{
|
||||
// Use the SkillDefinition API to ensure selector/resolution/operateDirectly are respected
|
||||
SkillBuilder.Instance.UseSkillDefinition(def, slotIndex, -1f, null);
|
||||
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} cast primary skill '{def.skillId}' due to ManaFull.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[AllyCombatant] Slot {slotIndex + 1} primary skill '{def.skillId}' not set to trigger on ManaFull (trigger={def.triggerCondition}); skipping cast.");
|
||||
}
|
||||
}
|
||||
|
||||
public void TriggerOnManaFull()
|
||||
{
|
||||
if (currentMana >= maxMana)
|
||||
{
|
||||
TryCastOnFullMana();
|
||||
}
|
||||
}
|
||||
|
||||
[ContextMenu("Test Fill Mana")]
|
||||
private void TestFillMana()
|
||||
{
|
||||
SetCurrentMana(maxMana, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d966ccc05a974124baf07b34117841e8
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
[Serializable]
|
||||
public class Buff
|
||||
{
|
||||
// Unique identifier for this buff instance
|
||||
public string buffId;
|
||||
|
||||
// Duration in seconds
|
||||
public float duration = 0f;
|
||||
|
||||
// Optional human-readable description/type
|
||||
public string description = string.Empty;
|
||||
|
||||
// Multipliers / modifiers that systems may use
|
||||
public float attackMultiplier = 1f; // multiply outgoing attack
|
||||
public float scoreMultiplier = 1f; // multiply scoring efficiency
|
||||
public float healReceivedMultiplier = 1f; // multiply heals received
|
||||
|
||||
// Flat modifications (if needed)
|
||||
public int flatHP = 0;
|
||||
public int flatMana = 0;
|
||||
|
||||
// Convenience constructor
|
||||
public Buff() { buffId = Guid.NewGuid().ToString(); }
|
||||
|
||||
public Buff(string id)
|
||||
{
|
||||
buffId = id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25fcea503b24fdc4e89adbc8dd2bd409
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// Shared types used by combat/effect/skill systems
|
||||
public enum Selector
|
||||
{
|
||||
Self,
|
||||
AllAllies,
|
||||
AllAlliesExceptSelf,
|
||||
AdjacentAllies,
|
||||
CurrentEnemies,
|
||||
AllEntities
|
||||
}
|
||||
|
||||
public enum EffectType
|
||||
{
|
||||
DamageSingleEnemy,
|
||||
DamageOverTimeEnemy,
|
||||
HealSingleSelf,
|
||||
HealOverTimeSelf,
|
||||
HealGroupSingle,
|
||||
HealGroupOverTime,
|
||||
BuffDuration,
|
||||
DebuffDuration,
|
||||
IncreaseManaOverTime,
|
||||
ReduceEnemyHealOverTime,
|
||||
ScoreMultiplier,
|
||||
AddScore
|
||||
}
|
||||
|
||||
public interface ICombatant
|
||||
{
|
||||
void ReceiveDamage(float amount, GameObject source);
|
||||
void ReceiveHeal(float amount, GameObject source);
|
||||
void ApplyBuff(Buff buff, GameObject source);
|
||||
void RemoveBuff(string buffId);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b949a850de06357409a6342e542319b2
|
||||
@@ -0,0 +1,453 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// EffectSystem 提供技能效果的统一分发接口。
|
||||
/// - 选择器范围(Selector)决定目标集合(自己、全体友军、除自己外友军、相邻友军、场上敌人、所有人)
|
||||
/// - 效果类型(EffectType)定义了即时/持续的伤害/治疗/增益/减益等。
|
||||
///
|
||||
/// 目标实体应实现 ICombatant 接口以便 EffectSystem 能调用统一方法来处理伤害/治疗/增益等;如果没有实现,会在日志中提醒。
|
||||
///
|
||||
/// 该脚本为框架:不包含具体技能配置,只提供 ApplyEffect(...) 等方法供其他脚本调用。
|
||||
/// </summary>
|
||||
public class EffectSystem : MonoBehaviour
|
||||
{
|
||||
public static EffectSystem Instance { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null) Instance = this;
|
||||
else Destroy(gameObject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ApplyEffect: 对指定选择器范围内目标应用效果。
|
||||
/// - selector: 目标选择器
|
||||
/// - effectType: 效果类型(即时/持续等)
|
||||
/// - amount: 数值(伤害或治疗总量或效果强度)
|
||||
/// - duration: 持续时间(对持续效果或 Buff/Debuff 有效),即时效果可为 0
|
||||
/// - source: 发起者(用于 Self/AlliesExceptSelf 判断或记录)
|
||||
/// - specificTarget: 当 selector 为 CurrentEnemies 且只想指定单体时可传入具体目标
|
||||
/// - tickInterval: 持续效果的 tick 间隔(秒),默认 1 秒
|
||||
/// </summary>
|
||||
public void ApplyEffect(Selector selector, EffectType effectType, float amount, float duration = 0f, GameObject source = null, GameObject specificTarget = null, float tickInterval = 1f)
|
||||
{
|
||||
List<GameObject> targets = ResolveTargets(selector, source, specificTarget);
|
||||
|
||||
if (targets == null || targets.Count == 0)
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] No targets resolved for selector={selector}");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (effectType)
|
||||
{
|
||||
case EffectType.DamageSingleEnemy:
|
||||
// apply instantaneous damage to first applicable enemy in targets
|
||||
if (targets.Count > 0)
|
||||
ApplyInstantDamage(targets[0], amount, source);
|
||||
break;
|
||||
|
||||
case EffectType.DamageOverTimeEnemy:
|
||||
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);
|
||||
break;
|
||||
|
||||
case EffectType.HealOverTimeSelf:
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyHealOverTimeCoroutine(t, amount, duration, tickInterval, source));
|
||||
break;
|
||||
|
||||
case EffectType.HealGroupSingle:
|
||||
foreach (var t in targets)
|
||||
ApplyInstantHeal(t, amount, source);
|
||||
break;
|
||||
|
||||
case EffectType.HealGroupOverTime:
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyHealOverTimeCoroutine(t, amount, duration, tickInterval, source));
|
||||
break;
|
||||
|
||||
case EffectType.BuffDuration:
|
||||
// create a simple buff with id+duration; additional fields can be filled by caller when using Buff instances directly
|
||||
Buff buff = new Buff { buffId = Guid.NewGuid().ToString(), duration = duration };
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyTimedBuffCoroutine(t, buff, source));
|
||||
break;
|
||||
|
||||
case EffectType.DebuffDuration:
|
||||
Buff debuff = new Buff { buffId = Guid.NewGuid().ToString(), duration = duration };
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyTimedDebuffCoroutine(t, debuff, source));
|
||||
break;
|
||||
|
||||
case EffectType.IncreaseManaOverTime:
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyIncreaseManaOverTimeCoroutine(t, amount, duration, tickInterval, source));
|
||||
break;
|
||||
|
||||
case EffectType.ReduceEnemyHealOverTime:
|
||||
// create a debuff that marks heal reduction in its description; enforcement depends on ICombatant.ReceiveHeal implementation
|
||||
Buff healReductionDebuff = new Buff { buffId = Guid.NewGuid().ToString(), duration = duration, description = "ReduceHeal" };
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyTimedDebuffCoroutine(t, healReductionDebuff, source));
|
||||
break;
|
||||
|
||||
case EffectType.ScoreMultiplier:
|
||||
// create a buff that multiplies scoring efficiency
|
||||
Buff scoreBuff = new Buff { buffId = Guid.NewGuid().ToString(), duration = duration, scoreMultiplier = amount };
|
||||
foreach (var t in targets)
|
||||
StartCoroutine(ApplyTimedBuffCoroutine(t, scoreBuff, source));
|
||||
break;
|
||||
|
||||
case EffectType.AddScore:
|
||||
// amount interpreted as integer delta to add immediately to ally tracks
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null) continue;
|
||||
var ally = t.GetComponent<AllyCombatant>();
|
||||
if (ally != null)
|
||||
{
|
||||
ally.AddScoreDirect(Mathf.CeilToInt(amount));
|
||||
}
|
||||
else
|
||||
{
|
||||
// if target does not have AllyCombatant, try to find ICombatant and warn
|
||||
var ic = t.GetComponent<ICombatant>();
|
||||
if (ic != null)
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] AddScore: target {t.name} implements ICombatant but is not AllyCombatant; cannot AddScoreDirect");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] AddScore: target {t.name} does not support AddScore");
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
Debug.LogWarning($"[EffectSystem] Unhandled EffectType {effectType}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve targets based on selector. Tries several strategies:
|
||||
// - If selector == Self -> source only
|
||||
// - AllAllies / AllAlliesExceptSelf -> find GameObjects named ally_01..ally_05, and also fall back to objects with tag "Ally"
|
||||
// - AdjacentAllies -> use AllyCombatant.slotIndex (from source) and teamUIController helpers
|
||||
// - CurrentEnemies -> specificTarget if provided else all objects with tag "Enemy"
|
||||
// - AllEntities -> find all enemies + allies
|
||||
private List<GameObject> ResolveTargets(Selector selector, GameObject source, GameObject specificTarget)
|
||||
{
|
||||
List<GameObject> list = new List<GameObject>();
|
||||
|
||||
switch (selector)
|
||||
{
|
||||
case Selector.Self:
|
||||
if (source != null) list.Add(source);
|
||||
break;
|
||||
|
||||
case Selector.AllAllies:
|
||||
list.AddRange(FindAllAllies());
|
||||
break;
|
||||
|
||||
case Selector.AllAlliesExceptSelf:
|
||||
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:
|
||||
// find adjacent allies of the source (left/right). Use teamUIController if available.
|
||||
if (source == null)
|
||||
{
|
||||
Debug.LogWarning("[EffectSystem] AdjacentAllies selector requires a non-null source GameObject");
|
||||
break;
|
||||
}
|
||||
|
||||
// try to use AllyCombatant.slotIndex if present
|
||||
var allyComp = source.GetComponent<AllyCombatant>();
|
||||
int slotIndex = -1;
|
||||
if (allyComp != null)
|
||||
{
|
||||
slotIndex = allyComp.slotIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
// try to parse name like "ally_01"
|
||||
var n = source.name;
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
if (n.Contains($"ally_0{i}")) { slotIndex = i - 1; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (slotIndex >= 0)
|
||||
{
|
||||
var ui = teamUIController.Instance;
|
||||
if (ui != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var adj = ui.GetAdjacentAllyIndices(slotIndex);
|
||||
foreach (var idx in adj)
|
||||
{
|
||||
var go = ui.GetAllyObjectBySlot(idx);
|
||||
if (go != null) list.Add(go);
|
||||
else
|
||||
{
|
||||
var named = GameObject.Find($"ally_0{idx + 1}");
|
||||
if (named != null) list.Add(named);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] teamUIController does not expose adjacent helpers: {ex.Message}");
|
||||
int left = slotIndex - 1;
|
||||
int right = slotIndex + 1;
|
||||
if (left >= 0)
|
||||
{
|
||||
var g = GameObject.Find($"ally_0{left + 1}"); if (g != null) list.Add(g);
|
||||
}
|
||||
if (right <= 4)
|
||||
{
|
||||
var g = GameObject.Find($"ally_0{right + 1}"); if (g != null) list.Add(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int left = slotIndex - 1;
|
||||
int right = slotIndex + 1;
|
||||
if (left >= 0)
|
||||
{
|
||||
var g = GameObject.Find($"ally_0{left + 1}"); if (g != null) list.Add(g);
|
||||
}
|
||||
if (right <= 4)
|
||||
{
|
||||
var g = GameObject.Find($"ally_0{right + 1}"); if (g != null) list.Add(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[EffectSystem] Could not determine slot index for source when resolving AdjacentAllies");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case Selector.CurrentEnemies:
|
||||
if (specificTarget != null) list.Add(specificTarget);
|
||||
else list.AddRange(FindAllEnemies());
|
||||
break;
|
||||
|
||||
case Selector.AllEntities:
|
||||
list.AddRange(FindAllAllies());
|
||||
list.AddRange(FindAllEnemies());
|
||||
break;
|
||||
}
|
||||
|
||||
// remove nulls and duplicates
|
||||
list.RemoveAll(x => x == null);
|
||||
var uniq = new List<GameObject>();
|
||||
foreach (var g in list)
|
||||
if (!uniq.Contains(g)) uniq.Add(g);
|
||||
return uniq;
|
||||
}
|
||||
|
||||
private IEnumerable<GameObject> FindAllAllies()
|
||||
{
|
||||
List<GameObject> allies = new List<GameObject>();
|
||||
// try names ally_01..ally_05
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
string name = $"ally_0{i}"; // ally_01..ally_05
|
||||
var go = GameObject.Find(name);
|
||||
if (go != null) allies.Add(go);
|
||||
}
|
||||
|
||||
// also include objects tagged "Ally"
|
||||
try
|
||||
{
|
||||
var tagged = GameObject.FindGameObjectsWithTag("Ally");
|
||||
foreach (var g in tagged)
|
||||
if (!allies.Contains(g)) allies.Add(g);
|
||||
}
|
||||
catch { /* tag might not exist; ignore */ }
|
||||
|
||||
return allies;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
try
|
||||
{
|
||||
var tagged = GameObject.FindGameObjectsWithTag("Enemy");
|
||||
foreach (var g in tagged)
|
||||
if (!enemies.Contains(g)) enemies.Add(g);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return enemies;
|
||||
}
|
||||
|
||||
#region Instant / OverTime Helpers
|
||||
private void ApplyInstantDamage(GameObject target, float amount, GameObject source)
|
||||
{
|
||||
if (target == null) return;
|
||||
var comp = target.GetComponent<ICombatant>();
|
||||
if (comp != null)
|
||||
{
|
||||
comp.ReceiveDamage(amount, source);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] Target {target.name} does not implement ICombatant -> cannot apply damage");
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyInstantHeal(GameObject target, float amount, GameObject source)
|
||||
{
|
||||
if (target == null) return;
|
||||
var comp = target.GetComponent<ICombatant>();
|
||||
if (comp != null)
|
||||
{
|
||||
comp.ReceiveHeal(amount, source);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] Target {target.name} does not implement ICombatant -> cannot apply heal");
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator ApplyDamageOverTimeCoroutine(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source)
|
||||
{
|
||||
if (target == null) yield break;
|
||||
var comp = target.GetComponent<ICombatant>();
|
||||
if (comp == null)
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] Target {target.name} does not implement ICombatant -> cannot apply DOT");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (duration <= 0f)
|
||||
{
|
||||
comp.ReceiveDamage(totalAmount, source);
|
||||
yield break;
|
||||
}
|
||||
|
||||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||||
float perTick = totalAmount / ticks;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
comp.ReceiveDamage(perTick, source);
|
||||
yield return new WaitForSeconds(tickInterval);
|
||||
elapsed += tickInterval;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator ApplyHealOverTimeCoroutine(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source)
|
||||
{
|
||||
if (target == null) yield break;
|
||||
var comp = target.GetComponent<ICombatant>();
|
||||
if (comp == null)
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] Target {target.name} does not implement ICombatant -> cannot apply HOT");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (duration <= 0f)
|
||||
{
|
||||
comp.ReceiveHeal(totalAmount, source);
|
||||
yield break;
|
||||
}
|
||||
|
||||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||||
float perTick = totalAmount / ticks;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
comp.ReceiveHeal(perTick, source);
|
||||
yield return new WaitForSeconds(tickInterval);
|
||||
elapsed += tickInterval;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator ApplyIncreaseManaOverTimeCoroutine(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source)
|
||||
{
|
||||
if (target == null) yield break;
|
||||
var comp = target.GetComponent<ICombatant>();
|
||||
// If target implements a method to add mana, call it; otherwise try to find AllyCombatant component
|
||||
var ally = target.GetComponent<AllyCombatant>();
|
||||
if (ally == null && comp == null)
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] Target {target.name} cannot receive mana -> no AllyCombatant or ICombatant found");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (duration <= 0f)
|
||||
{
|
||||
// immediate add totalAmount to mana if ally present
|
||||
if (ally != null) ally.ModifyMana(Mathf.CeilToInt(totalAmount), true);
|
||||
yield break;
|
||||
}
|
||||
|
||||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||||
float perTick = totalAmount / ticks;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
if (ally != null) ally.ModifyMana(Mathf.CeilToInt(perTick), true);
|
||||
yield return new WaitForSeconds(tickInterval);
|
||||
elapsed += tickInterval;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator ApplyTimedBuffCoroutine(GameObject target, Buff buff, GameObject source)
|
||||
{
|
||||
if (target == null) yield break;
|
||||
var comp = target.GetComponent<ICombatant>();
|
||||
if (comp == null)
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] Target {target.name} does not implement ICombatant -> cannot apply Buff");
|
||||
yield break;
|
||||
}
|
||||
|
||||
comp.ApplyBuff(buff, source);
|
||||
yield return new WaitForSeconds(buff.duration);
|
||||
comp.RemoveBuff(buff.buffId);
|
||||
}
|
||||
|
||||
private IEnumerator ApplyTimedDebuffCoroutine(GameObject target, Buff debuff, GameObject source)
|
||||
{
|
||||
// For now debuff is same as buff but may be handled differently by ICombatant implementation
|
||||
if (target == null) yield break;
|
||||
var comp = target.GetComponent<ICombatant>();
|
||||
if (comp == null)
|
||||
{
|
||||
Debug.LogWarning($"[EffectSystem] Target {target.name} does not implement ICombatant -> cannot apply Debuff");
|
||||
yield break;
|
||||
}
|
||||
|
||||
comp.ApplyBuff(debuff, source);
|
||||
yield return new WaitForSeconds(debuff.duration);
|
||||
comp.RemoveBuff(debuff.buffId);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac6185c2505b3ec47af065d12a0dd2b6
|
||||
@@ -0,0 +1,127 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class ScoreManager : MonoBehaviour
|
||||
{
|
||||
public static ScoreManager Instance { get; private set; }
|
||||
|
||||
public int totalScore = 0;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null) Instance = this;
|
||||
else Destroy(gameObject);
|
||||
}
|
||||
|
||||
public void RecalculateTotal()
|
||||
{
|
||||
int sum = 0;
|
||||
int[] currents = new int[5];
|
||||
int[] maxes = new int[5];
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
var go = GameObject.Find($"ally_0{i}");
|
||||
if (go == null) { currents[i - 1] = 0; maxes[i - 1] = 0; continue; }
|
||||
var ally = go.GetComponent<AllyCombatant>();
|
||||
if (ally != null)
|
||||
{
|
||||
currents[i - 1] = ally.currentScore;
|
||||
maxes[i - 1] = ally.maxTrackScore;
|
||||
}
|
||||
else
|
||||
{
|
||||
currents[i - 1] = 0;
|
||||
maxes[i - 1] = 0;
|
||||
}
|
||||
|
||||
sum += currents[i - 1];
|
||||
}
|
||||
totalScore = sum;
|
||||
// update UI if available
|
||||
if (teamUIController.Instance != null)
|
||||
{
|
||||
var ui = teamUIController.Instance;
|
||||
|
||||
// helper lambda to set text on TMP or legacy Text under a parent fallback
|
||||
void SetScoreText(TextMeshProUGUI assignedTmp, GameObject parent, int cur, int max, string slotName)
|
||||
{
|
||||
string value = cur + "/" + max;
|
||||
if (assignedTmp != null)
|
||||
{
|
||||
assignedTmp.text = value;
|
||||
Debug.Log($"[ScoreManager] Wrote to assigned TMP for {slotName}: '{value}' -> {assignedTmp.gameObject.name}");
|
||||
return;
|
||||
}
|
||||
// try find TMP in parent
|
||||
if (parent != null)
|
||||
{
|
||||
var tmp = parent.GetComponentInChildren<TextMeshProUGUI>(true);
|
||||
if (tmp != null)
|
||||
{
|
||||
tmp.text = value;
|
||||
Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}");
|
||||
return;
|
||||
}
|
||||
var legacy = parent.GetComponentInChildren<Text>(true);
|
||||
if (legacy != null)
|
||||
{
|
||||
legacy.text = value;
|
||||
Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
Debug.LogWarning($"[ScoreManager] Could not find UI to write score for {slotName}. assignedTmp is null and parent '{parent?.name}' has no TMP/Text children.");
|
||||
}
|
||||
|
||||
// Update each slot: use the explicit TMP fields if set; otherwise try parent object fields
|
||||
SetScoreText(ui.teammate01_current_scoreText, ui.objectFather_ally01, currents[0], maxes[0], "teammate01");
|
||||
SetScoreText(ui.teammate02_current_scoreText, ui.objectFather_ally02, currents[1], maxes[1], "teammate02");
|
||||
SetScoreText(ui.teammate03_current_scoreText, ui.objectFather_ally03, currents[2], maxes[2], "teammate03");
|
||||
SetScoreText(ui.teammate04_current_scoreText, ui.objectFather_ally04, currents[3], maxes[3], "teammate04");
|
||||
SetScoreText(ui.teammate05_current_scoreText, ui.objectFather_ally05, currents[4], maxes[4], "teammate05");
|
||||
|
||||
// update total score (try TMP first, then legacy Text)
|
||||
if (ui.currentTotalScore != null)
|
||||
{
|
||||
var go = ui.currentTotalScore.gameObject;
|
||||
if (go != null)
|
||||
{
|
||||
var tmpComp = go.GetComponent<TextMeshProUGUI>();
|
||||
if (tmpComp != null)
|
||||
{
|
||||
tmpComp.text = totalScore.ToString();
|
||||
Debug.Log($"[ScoreManager] Wrote total to TMP: {totalScore} -> {tmpComp.gameObject.name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var legacyComp = go.GetComponent<Text>();
|
||||
if (legacyComp != null)
|
||||
{
|
||||
legacyComp.text = totalScore.ToString();
|
||||
Debug.Log($"[ScoreManager] Wrote total to legacy Text: {totalScore} -> {legacyComp.gameObject.name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ui.currentTotalScore.text = totalScore.ToString(); // fallback
|
||||
Debug.Log($"[ScoreManager] Wrote total to currentTotalScore field fallback: {totalScore}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ui.currentTotalScore.text = totalScore.ToString();
|
||||
Debug.Log($"[ScoreManager] Wrote total to currentTotalScore fallback (no go): {totalScore}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[ScoreManager] teamUIController.currentTotalScore is null; cannot display total score.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[ScoreManager] teamUIController.Instance is null; cannot update UI.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e98dd46b2f96dd42a078225abf0f31a
|
||||
@@ -0,0 +1,938 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// SkillBuilder: 集中定义可快速调用的技能函数。
|
||||
/// - 每个技能为一个独立的 public 方法,接受必要参数(施法者 source, 目标 specificTarget 等)
|
||||
/// - 内部调用 EffectSystem.Instance.ApplyEffect(...),统一使用 Selector 与 EffectType
|
||||
/// - 提供常用参数(amount, duration, tickInterval)并对参数做简单校验
|
||||
///
|
||||
/// 用法示例:
|
||||
/// SkillBuilder.Instance.ExecuteSkill("Fireball", EffectType.DamageSingleEnemy, 120f, Selector.CurrentEnemies, caster, target);
|
||||
/// SkillBuilder.Instance.ApplyScoreMultiplier(caster, Selector.AllAllies, 1.5f, 5f); // 5 秒内队伍得分乘 1.5
|
||||
/// </summary>
|
||||
public class SkillBuilder : MonoBehaviour
|
||||
{
|
||||
public static SkillBuilder Instance { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null) Instance = this;
|
||||
else Destroy(gameObject);
|
||||
}
|
||||
|
||||
// ----------------------------- 基础参数模板 -----------------------------
|
||||
public float defaultDamage = 100f;
|
||||
public float defaultHeal = 80f;
|
||||
public float defaultDuration = 5f;
|
||||
public float defaultTickInterval = 1f;
|
||||
|
||||
[Header("Shared note-hit auto-skill settings")]
|
||||
// These defaults remain as a global fallback but the preferred source is per-level fields in AllyHero_SO
|
||||
[Tooltip("(Fallback) Mana gained on Good judge if SO level value not present")] public int manaGainGood = 1;
|
||||
[Tooltip("(Fallback) Mana gained on Great judge if SO level value not present")] public int manaGainGreat = 2;
|
||||
[Tooltip("(Fallback) Mana gained on Perfect judge if SO level value not present")] public int manaGainPerfect = 3;
|
||||
[Tooltip("(Fallback) Mana gained on Miss judge if SO level value not present")] public int manaGainOnMiss = 5;
|
||||
|
||||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Good hits if SO level value not present")] public float damageMultiplierGood = 0.5f;
|
||||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Great hits if SO level value not present")] public float damageMultiplierGreat = 0.75f;
|
||||
[Tooltip("(Fallback) Damage multiplier applied to ally base attack for Perfect hits if SO level value not present")] public float damageMultiplierPerfect = 1f;
|
||||
|
||||
[Tooltip("(Fallback) Base HP loss on Miss will be (missHpLossBase * (1 - damageResistance)) if SO level value not present")] public float missHpLossBase = 10f;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
private void ApplySharedOnNoteHit(int trackIndex, string judgeResult)
|
||||
{
|
||||
if (trackIndex < 0) return;
|
||||
var allyGO = GetAllyObjectBySlot(trackIndex);
|
||||
if (allyGO == null) return;
|
||||
var ally = allyGO.GetComponent<AllyCombatant>();
|
||||
var so = GetAllyHeroSOBySlot(trackIndex);
|
||||
// try to obtain per-level params from SO if present
|
||||
AllyHero_SO.AllyLevelInfo levelInfo = so != null ? (so.GetEffectiveLevelForCurrentEXP()) : null;
|
||||
|
||||
int manaGain = 0;
|
||||
float damageMult = 0f;
|
||||
if (levelInfo != null)
|
||||
{
|
||||
switch (judgeResult)
|
||||
{
|
||||
case "Perfect": manaGain = levelInfo.manaGainPerfect; damageMult = levelInfo.damageMultiplierPerfect; break;
|
||||
case "Great": manaGain = levelInfo.manaGainGreat; damageMult = levelInfo.damageMultiplierGreat; break;
|
||||
case "Good": manaGain = levelInfo.manaGainGood; damageMult = levelInfo.damageMultiplierGood; break;
|
||||
default: manaGain = levelInfo.manaGainOnMiss; break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (judgeResult)
|
||||
{
|
||||
case "Perfect": manaGain = manaGainPerfect; damageMult = damageMultiplierPerfect; break;
|
||||
case "Great": manaGain = manaGainGreat; damageMult = damageMultiplierGreat; break;
|
||||
case "Good": manaGain = manaGainGood; damageMult = damageMultiplierGood; break;
|
||||
default: manaGain = manaGainOnMiss; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ally != null && manaGain != 0)
|
||||
{
|
||||
ally.ModifyMana(manaGain, true);
|
||||
Debug.Log($"[SkillBuilder] Applied mana gain {manaGain} to slot {trackIndex} due to judge {judgeResult}");
|
||||
}
|
||||
|
||||
if (judgeResult == "Miss")
|
||||
{
|
||||
if (ally != null)
|
||||
{
|
||||
float missBase = (levelInfo != null) ? levelInfo.missHpLossBase : missHpLossBase;
|
||||
// apply damage reduction by damageResistance: final loss = missBase * (1 - damageResistance)
|
||||
float loss = missBase * (1f - ally.damageResistance);
|
||||
ally.ModifyHP(-Mathf.CeilToInt(loss), true);
|
||||
Debug.Log($"[SkillBuilder] Applied miss HP loss {loss} to slot {trackIndex} (damageResistance={ally.damageResistance})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (damageMult > 0f)
|
||||
{
|
||||
int baseAtk = GetAllyBaseAttack(so);
|
||||
// damage should scale with (1 - damageResistance) of enemies when applied; here we pass raw amount = baseAtk * damageMult
|
||||
float dmg = baseAtk * damageMult;
|
||||
DealDamageFromAllyToEnemies(trackIndex, dmg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- 通用技能执行接口 -----------------------------
|
||||
// 通用执行:技能名, 效果类型, 数值, 目标选择器, 施法者, 可选具体目标, 持续时间和 tick
|
||||
public void ExecuteSkill(string skillName, EffectType effectType, float amount, Selector selector, GameObject caster, GameObject specificTarget = null, float duration = 0f, float tickInterval = 1f)
|
||||
{
|
||||
if (global::EffectSystem.Instance == null)
|
||||
{
|
||||
Debug.LogWarning($"ExecuteSkill failed: EffectSystem.Instance is null");
|
||||
return;
|
||||
}
|
||||
// basic logging
|
||||
Debug.Log($"[SkillBuilder] ExecuteSkill: {skillName} type={effectType} amount={amount} selector={selector} caster={(caster?caster.name:"null")} target={(specificTarget?specificTarget.name:"null")} duration={duration}");
|
||||
|
||||
// call EffectSystem using GameObject API (keeps compatibility with existing EffectSystem)
|
||||
global::EffectSystem.Instance.ApplyEffect(selector, effectType, amount, duration, caster, specificTarget, tickInterval);
|
||||
}
|
||||
|
||||
// New overload: execute by slot index (no GameObject required externally)
|
||||
public void ExecuteSkillBySlot(string skillName, EffectType effectType, float amount, Selector selector, int casterSlotIndex = -1, int specificTargetSlot = -1, float duration = 0f, float tickInterval = 1f)
|
||||
{
|
||||
GameObject casterGO = casterSlotIndex >= 0 ? GetAllyObjectBySlot(casterSlotIndex) : null;
|
||||
GameObject targetGO = specificTargetSlot >= 0 ? GetAllyObjectBySlot(specificTargetSlot) : null;
|
||||
ExecuteSkill(skillName, effectType, amount, selector, casterGO, targetGO, duration, tickInterval);
|
||||
}
|
||||
|
||||
// ----------------------------- 目标解析(供本脚本使用,和 EffectSystem 的解析一致) -----------------------------
|
||||
// 返回符合 selector 的 GameObject 列表,方便技能脚本自行操作(例如直接给 ICombatant.Buff)
|
||||
public List<GameObject> ResolveTargetsLocal(Selector selector, GameObject source = null, GameObject specificTarget = null)
|
||||
{
|
||||
List<GameObject> list = new List<GameObject>();
|
||||
switch (selector)
|
||||
{
|
||||
case Selector.Self:
|
||||
if (source != null) list.Add(source);
|
||||
break;
|
||||
case Selector.AllAllies:
|
||||
for (int i = 1; i <= 5; i++) { var go = GameObject.Find($"ally_0{i}"); if (go != null) list.Add(go); }
|
||||
try { var tagged = GameObject.FindGameObjectsWithTag("Ally"); foreach (var g in tagged) if (!list.Contains(g)) list.Add(g); } catch { }
|
||||
break;
|
||||
case Selector.AllAlliesExceptSelf:
|
||||
for (int i = 1; i <= 5; i++) { var go = GameObject.Find($"ally_0{i}"); if (go != null) list.Add(go); }
|
||||
try { var tagged = GameObject.FindGameObjectsWithTag("Ally"); foreach (var g in tagged) if (!list.Contains(g)) list.Add(g); } catch { }
|
||||
if (source != null) list.RemoveAll(g => g == null || g == source);
|
||||
break;
|
||||
case Selector.AdjacentAllies:
|
||||
if (source == null) break;
|
||||
int slotIndex = -1;
|
||||
var ac = source.GetComponent<AllyCombatant>();
|
||||
if (ac != null) slotIndex = ac.slotIndex;
|
||||
else
|
||||
{
|
||||
var n = source.name;
|
||||
for (int i = 1; i <= 5; i++) if (n.Contains($"ally_0{i}")) { slotIndex = i - 1; break; }
|
||||
}
|
||||
|
||||
var ui = teamUIController.Instance;
|
||||
// Additional attempt: if source isn't AllyCombatant and name parse failed, try to match source to UI slots (or child of slot object)
|
||||
if (slotIndex < 0 && ui != null)
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var slotObj = ui.GetAllyObjectBySlot(i);
|
||||
if (slotObj == null) continue;
|
||||
if (slotObj == source) { slotIndex = i; break; }
|
||||
// if source is a child of the slot object
|
||||
if (source.transform.IsChildOf(slotObj.transform)) { slotIndex = i; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (slotIndex >= 0)
|
||||
{
|
||||
if (ui != null)
|
||||
{
|
||||
var adj = ui.GetAdjacentAllyIndices(slotIndex);
|
||||
foreach (var idx in adj)
|
||||
{
|
||||
var go = ui.GetAllyObjectBySlot(idx);
|
||||
if (go != null) list.Add(go);
|
||||
else { var named = GameObject.Find($"ally_0{idx + 1}"); if (named != null) list.Add(named); }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int left = slotIndex - 1, right = slotIndex + 1;
|
||||
if (left >= 0) { var g = GameObject.Find($"ally_0{left + 1}"); if (g != null) list.Add(g); }
|
||||
if (right <= 4) { var g = GameObject.Find($"ally_0{right + 1}"); if (g != null) list.Add(g); }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[SkillBuilder] ResolveTargetsLocal: could not determine slot index for AdjacentAllies for source=" + (source?source.name:"null"));
|
||||
}
|
||||
break;
|
||||
case Selector.CurrentEnemies:
|
||||
if (specificTarget != null) list.Add(specificTarget);
|
||||
else { try { var tagged = GameObject.FindGameObjectsWithTag("Enemy"); foreach (var g in tagged) if (!list.Contains(g)) list.Add(g); } catch { var single = GameObject.Find("thisEnemy"); if (single != null) list.Add(single); } }
|
||||
break;
|
||||
case Selector.AllEntities:
|
||||
for (int i = 1; i <= 5; i++) { var go = GameObject.Find($"ally_0{i}"); if (go != null) list.Add(go); }
|
||||
try { var tagged = GameObject.FindGameObjectsWithTag("Ally"); foreach (var g in tagged) if (!list.Contains(g)) list.Add(g); } catch { }
|
||||
try { var taggedE = GameObject.FindGameObjectsWithTag("Enemy"); foreach (var g in taggedE) if (!list.Contains(g)) list.Add(g); } catch { var single = GameObject.Find("thisEnemy"); if (single != null && !list.Contains(single)) list.Add(single); }
|
||||
break;
|
||||
}
|
||||
list.RemoveAll(x => x == null);
|
||||
var uniq = new List<GameObject>();
|
||||
foreach (var g in list) if (!uniq.Contains(g)) uniq.Add(g);
|
||||
return uniq;
|
||||
}
|
||||
|
||||
// ----------------------------- 分数影响 API -----------------------------
|
||||
// 直接修改全局总分(立即生效)
|
||||
public void ModifyTotalScore(int delta)
|
||||
{
|
||||
if (ScoreManager.Instance == null) { Debug.LogWarning("ModifyTotalScore: ScoreManager.Instance == 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
|
||||
// so instead update displayed total directly if teamUIController present
|
||||
if (teamUIController.Instance != null && teamUIController.Instance.currentTotalScore != null)
|
||||
{
|
||||
teamUIController.Instance.currentTotalScore.text = ScoreManager.Instance.totalScore.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// 修改单个友军的分数(即时)
|
||||
public void ModifySingleAllyScoreDirect(GameObject allyObject, int delta)
|
||||
{
|
||||
if (allyObject == null) return;
|
||||
var ally = allyObject.GetComponent<AllyCombatant>();
|
||||
if (ally != null)
|
||||
{
|
||||
ally.AddScoreDirect(delta);
|
||||
}
|
||||
}
|
||||
|
||||
// Slot-based: modify single ally by slot index (no GameObject required)
|
||||
public void ModifySingleAllyScoreDirectBySlot(int slotIndex, int delta)
|
||||
{
|
||||
var go = GetAllyObjectBySlot(slotIndex);
|
||||
if (go == null) return;
|
||||
var ally = go.GetComponent<AllyCombatant>();
|
||||
if (ally != null) ally.AddScoreDirect(delta);
|
||||
}
|
||||
|
||||
// 对一组目标应用一次性的分数奖励/惩罚(按集合应用)
|
||||
public void ModifyGroupScoreDirect(Selector selector, GameObject caster, int delta)
|
||||
{
|
||||
var targets = ResolveTargetsLocal(selector, caster);
|
||||
foreach (var t in targets)
|
||||
{
|
||||
var ally = t.GetComponent<AllyCombatant>();
|
||||
if (ally != null) ally.AddScoreDirect(delta);
|
||||
}
|
||||
}
|
||||
|
||||
// 临时提高(或降低)目标的分数倍率,使用 EffectSystem.ScoreMultiplier(推荐)
|
||||
public void ApplyScoreMultiplier(Selector selector, GameObject caster, float multiplier, float duration)
|
||||
{
|
||||
if (multiplier <= 0f) { Debug.LogWarning("ApplyScoreMultiplier: invalid multiplier"); return; }
|
||||
global::EffectSystem.Instance.ApplyEffect(selector, EffectType.ScoreMultiplier, multiplier, duration, caster, null);
|
||||
}
|
||||
|
||||
// Slot-based helper: apply score multiplier to a single slot
|
||||
public void ApplyScoreMultiplierToSlot(int slotIndex, float multiplier, float duration)
|
||||
{
|
||||
if (multiplier <= 0f) { Debug.LogWarning("ApplyScoreMultiplierToSlot: invalid multiplier"); return; }
|
||||
var go = GetAllyObjectBySlot(slotIndex);
|
||||
if (go == null) { Debug.LogWarning($"ApplyScoreMultiplierToSlot: no ally object for slot {slotIndex}"); return; }
|
||||
global::EffectSystem.Instance.ApplyEffect(Selector.Self, EffectType.ScoreMultiplier, multiplier, duration, go, null);
|
||||
}
|
||||
|
||||
// Slot-based helper: add flat score to a single slot (immediate)
|
||||
public void AddScoreToSlot(int slotIndex, int delta)
|
||||
{
|
||||
var go = GetAllyObjectBySlot(slotIndex);
|
||||
if (go == null) { Debug.LogWarning($"AddScoreToSlot: no ally object for slot {slotIndex}"); return; }
|
||||
global::EffectSystem.Instance.ApplyEffect(Selector.Self, EffectType.AddScore, delta, 0f, go, null);
|
||||
}
|
||||
|
||||
private IEnumerator ApplyBuffToTargetsCoroutine(Buff buff, List<GameObject> targets, GameObject source)
|
||||
{
|
||||
if (buff == null) yield break;
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null) continue;
|
||||
var comp = t.GetComponent<ICombatant>();
|
||||
if (comp != null) comp.ApplyBuff(buff, source);
|
||||
else Debug.LogWarning($"ApplyBuffToTargets: target {t.name} has no ICombatant");
|
||||
}
|
||||
if (buff.duration > 0f)
|
||||
yield return new WaitForSeconds(buff.duration);
|
||||
else
|
||||
yield break;
|
||||
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null) continue;
|
||||
var comp = t.GetComponent<ICombatant>();
|
||||
if (comp != null) comp.RemoveBuff(buff.buffId);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- 现有便捷技能示例 -----------------------------
|
||||
public void DealSingleEnemyDamage(GameObject caster, GameObject enemyTarget, float amount)
|
||||
{
|
||||
if (enemyTarget == null) { Debug.LogWarning("DealSingleEnemyDamage: enemyTarget == null"); return; }
|
||||
ExecuteSkill("DealSingleEnemyDamage", EffectType.DamageSingleEnemy, amount, Selector.CurrentEnemies, caster, enemyTarget);
|
||||
}
|
||||
|
||||
public void ApplyDamageOverTimeToEnemies(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("DamageOverTime", EffectType.DamageOverTimeEnemy, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||||
}
|
||||
|
||||
public void HealSelf(GameObject caster, float amount)
|
||||
{
|
||||
ExecuteSkill("HealSelf", EffectType.HealSingleSelf, amount, Selector.Self, caster, caster);
|
||||
}
|
||||
|
||||
public void HealSelfOverTime(GameObject caster, float totalAmount, float duration, float tickInterval = 1f)
|
||||
{
|
||||
ExecuteSkill("HealSelfOverTime", EffectType.HealOverTimeSelf, totalAmount, Selector.Self, caster, caster, duration, tickInterval);
|
||||
}
|
||||
|
||||
public void HealGroupSingle(GameObject caster, float amount, bool includeSelf = true)
|
||||
{
|
||||
var sel = includeSelf ? Selector.AllAllies : Selector.AllAlliesExceptSelf;
|
||||
ExecuteSkill("HealGroupSingle", EffectType.HealGroupSingle, amount, sel, caster);
|
||||
}
|
||||
|
||||
public void HealGroupOverTime(GameObject caster, float totalAmount, float duration, float tickInterval = 1f, bool includeSelf = true)
|
||||
{
|
||||
var sel = includeSelf ? Selector.AllAllies : Selector.AllAlliesExceptSelf;
|
||||
ExecuteSkill("HealGroupOverTime", EffectType.HealGroupOverTime, totalAmount, sel, caster, null, duration, tickInterval);
|
||||
}
|
||||
|
||||
public void IncreaseManaOverTime(GameObject caster, Selector selector, float totalAmount, float duration, float tickInterval = 1f, GameObject specificTarget = null)
|
||||
{
|
||||
ExecuteSkill("IncreaseManaOverTime", EffectType.IncreaseManaOverTime, totalAmount, selector, caster, specificTarget, duration, tickInterval);
|
||||
}
|
||||
|
||||
public void ReduceEnemyHealOverTime(GameObject caster, Selector selector, float duration, GameObject specificTarget = null)
|
||||
{
|
||||
ExecuteSkill("ReduceEnemyHealOverTime", EffectType.ReduceEnemyHealOverTime, 0f, selector, caster, specificTarget, duration);
|
||||
}
|
||||
|
||||
public void HealAdjacentAllies(GameObject caster, float amount)
|
||||
{
|
||||
ExecuteSkill("HealAdjacentAllies", EffectType.HealGroupSingle, amount, Selector.AdjacentAllies, caster);
|
||||
}
|
||||
|
||||
public void IncreaseManaAdjacentAllies(GameObject caster, float totalAmount, float duration, float tickInterval = 1f)
|
||||
{
|
||||
ExecuteSkill("IncreaseManaAdjacentAllies", EffectType.IncreaseManaOverTime, totalAmount, Selector.AdjacentAllies, caster, null, duration, tickInterval);
|
||||
}
|
||||
|
||||
public void Hero_A_Ultimate(GameObject caster, GameObject enemyTarget)
|
||||
{
|
||||
DealSingleEnemyDamage(caster, enemyTarget, defaultDamage * 2f);
|
||||
HealAdjacentAllies(caster, defaultHeal * 0.5f);
|
||||
}
|
||||
|
||||
public void MassHeal(GameObject caster)
|
||||
{
|
||||
HealGroupOverTime(caster, defaultHeal * 5f, 4f, 1f, true);
|
||||
}
|
||||
|
||||
// ----------------------------- 基于 SO 的数值计算 helpers -----------------------------
|
||||
// 获取某个槽位的 ally GameObject(0-based slot index)
|
||||
public GameObject GetAllyObjectBySlot(int slotIndex)
|
||||
{
|
||||
var ui = teamUIController.Instance;
|
||||
if (ui != null) return ui.GetAllyObjectBySlot(slotIndex);
|
||||
var named = GameObject.Find($"ally_0{slotIndex + 1}");
|
||||
return named;
|
||||
}
|
||||
|
||||
// 根据槽位解析 AllyHero_SO(运行时从 Resources 中查找 ally_heroID)
|
||||
public AllyHero_SO GetAllyHeroSOBySlot(int slotIndex)
|
||||
{
|
||||
if (teamUIController.Instance == null || teamUIController.Instance.allySlotIds == null) return null;
|
||||
if (slotIndex < 0 || slotIndex >= teamUIController.Instance.allySlotIds.Count) return null;
|
||||
int id = teamUIController.Instance.allySlotIds[slotIndex];
|
||||
if (id <= 0) return null;
|
||||
|
||||
var all = Resources.LoadAll<AllyHero_SO>("");
|
||||
foreach (var a in all)
|
||||
{
|
||||
if (a != null && a.ally_heroID == id) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据当前经验选择生效的等级信息(返回 null 表示未找到)
|
||||
private AllyHero_SO.AllyLevelInfo GetEffectiveLevelInfo(AllyHero_SO so)
|
||||
{
|
||||
if (so == null || so.levelStats == null || so.levelStats.Count == 0) return null;
|
||||
// choose the highest requiredEXP that is <= ally_currentEXP
|
||||
AllyHero_SO.AllyLevelInfo best = null;
|
||||
int currentExp = so.ally_currentEXP;
|
||||
foreach (var lvl in so.levelStats)
|
||||
{
|
||||
if (best == null)
|
||||
{
|
||||
if (currentExp >= lvl.requiredEXP) best = lvl;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (currentExp >= lvl.requiredEXP && lvl.requiredEXP >= best.requiredEXP) best = lvl;
|
||||
}
|
||||
}
|
||||
// if no level matched (all requiredEXP > currentExp), fallback to lowest level (index 0)
|
||||
if (best == null) return so.levelStats[0];
|
||||
return best;
|
||||
}
|
||||
|
||||
// 取得英雄基础攻击力(使用当前经验匹配的等级的 attack),若没有则返回 0
|
||||
public int GetAllyBaseAttack(AllyHero_SO so)
|
||||
{
|
||||
if (so == null) return 0;
|
||||
var eff = GetEffectiveLevelInfo(so);
|
||||
if (eff != null) return eff.attack;
|
||||
if (so.levelStats != null && so.levelStats.Count > 0) return so.levelStats[0].attack;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 统一计算技能造成数值:当 inputValue == -1 时使用 skillStatic + allyAttack,
|
||||
// 否则直接使用 inputValue(按传入的 EffectType 做必要转换)
|
||||
public float ComputeSkillValue(GameObject caster, int slotIndex, float inputValue, int skillStatic)
|
||||
{
|
||||
if (inputValue != -1f) return inputValue;
|
||||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||||
int allyAtk = GetAllyBaseAttack(so);
|
||||
return skillStatic + allyAtk;
|
||||
}
|
||||
|
||||
// ----------------------------- 槽位技能通用接口 -----------------------------
|
||||
// 通常技能在被槽位的角色释放时调用此函数:
|
||||
// slotIndex: 0-based 槽位索引;skillId: 自定义技能标识;effectType,value,selector,duration等参数同前述约定
|
||||
public void AllySlotSkill(int slotIndex, string skillId, EffectType effectType, float value, Selector selector, float duration = 0f, GameObject specificTarget = null, float tickInterval = 1f)
|
||||
{
|
||||
GameObject caster = GetAllyObjectBySlot(slotIndex);
|
||||
if (caster == null)
|
||||
{
|
||||
Debug.LogWarning($"AllySlotSkill: no caster found for slot {slotIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
// determine skill-specific static values (示例:skill01 的固定伤害为 50)
|
||||
int skillStaticDamage = 0;
|
||||
switch (skillId)
|
||||
{
|
||||
case "skill01": skillStaticDamage = 50; break;
|
||||
case "skill_heal_small": skillStaticDamage = 30; break;
|
||||
// 在此添加更多技能静态值
|
||||
default: skillStaticDamage = 0; break;
|
||||
}
|
||||
|
||||
float finalValue = ComputeSkillValue(caster, slotIndex, value, skillStaticDamage);
|
||||
|
||||
// 特殊处理:若 effectType 是单次伤害但我们希望对所有敌人使用 CurrentEnemies,selector 参数已由调用方传入
|
||||
ExecuteSkill($"slot{slotIndex + 1}_{skillId}", effectType, finalValue, selector, caster, specificTarget, duration, tickInterval);
|
||||
}
|
||||
|
||||
// New: invoke a SkillDefinition directly (respects staticValue and SO attack when value == -1)
|
||||
public void UseSkillDefinition(SkillDefinition def, int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||||
{
|
||||
if (def == null) { Debug.LogWarning("UseSkillDefinition: null def"); return; }
|
||||
GameObject caster = GetAllyObjectBySlot(slotIndex);
|
||||
if (caster == null)
|
||||
{
|
||||
Debug.LogWarning($"UseSkillDefinition: no caster GameObject found for slot {slotIndex}. Proceeding with null caster.");
|
||||
}
|
||||
|
||||
float amount = 0f;
|
||||
// If caller provided explicit inputValue use it
|
||||
if (inputValue != -1f)
|
||||
{
|
||||
amount = inputValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Build variables from SO and caster; formula can be a plain number too
|
||||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||||
var vars = new Dictionary<string, float>();
|
||||
vars["slot"] = slotIndex;
|
||||
vars["attack"] = GetAllyBaseAttack(so);
|
||||
if (so != null && so.levelStats != null && so.levelStats.Count > 0)
|
||||
{
|
||||
var eff = GetEffectiveLevelInfo(so);
|
||||
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; // ensure attack from SO levelStats is available
|
||||
}
|
||||
else
|
||||
{
|
||||
vars["maxHP"] = 0f;
|
||||
vars["maxMana"] = 0f;
|
||||
vars["damageResistance"] = 0f;
|
||||
vars["scoreEfficiency"] = 1f;
|
||||
}
|
||||
if (so != null) vars["ally_currentEXP"] = so.ally_currentEXP;
|
||||
else vars["ally_currentEXP"] = 0f;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(def.formula))
|
||||
{
|
||||
Debug.LogWarning($"UseSkillDefinition: skill {def.skillId} has empty formula, defaulting to 0");
|
||||
amount = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!SkillDefinition.TryEvaluateFormula(def.formula, vars, out float fresult))
|
||||
{
|
||||
Debug.LogWarning($"UseSkillDefinition: formula evaluation failed for skill {def.skillId}, defaulting to 0");
|
||||
amount = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
amount = fresult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert formula result (interpreted as per-tick when skill is sustained) to total amount
|
||||
// amount now represents the formula result (per-tick for sustained effects, instant for single-instance)
|
||||
float amountPerTick = amount;
|
||||
float amountTotal = amountPerTick;
|
||||
if (!def.IsSingleInstance)
|
||||
{
|
||||
// compute ticks based on def duration and effective tick interval
|
||||
float tickInterval = def.GetEffectiveTickInterval();
|
||||
int ticks = Mathf.Max(1, Mathf.CeilToInt(def.defaultDuration / tickInterval));
|
||||
amountTotal = amountPerTick * ticks;
|
||||
}
|
||||
|
||||
// If this skill is intended to be triggered by note hits, treat it as a one-time application
|
||||
// (do not multiply by duration ticks). This prevents OnNoteHit triggers from unexpectedly
|
||||
// applying sustained totals (e.g. 5s * perTick) when the designer expects a single effect.
|
||||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnNoteHit)
|
||||
{
|
||||
amountTotal = amountPerTick;
|
||||
}
|
||||
|
||||
if (def.operateDirectly)
|
||||
{
|
||||
// Resolve targets as GameObjects but operate on their AllyCombatant / ICombatant data directly
|
||||
List<GameObject> targets = ResolveTargetsLocal(def.defaultSelector, caster, specificTarget);
|
||||
if (targets == null || targets.Count == 0)
|
||||
{
|
||||
Debug.LogWarning($"UseSkillDefinition: no targets for direct operation skill {def.skillId}");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var t in targets)
|
||||
{
|
||||
if (t == null) continue;
|
||||
var ally = t.GetComponent<AllyCombatant>();
|
||||
var ic = t.GetComponent<ICombatant>();
|
||||
|
||||
switch (def.effectType)
|
||||
{
|
||||
case EffectType.DamageSingleEnemy:
|
||||
// treat as damage to an enemy; if target has AllyCombatant, apply negative HP
|
||||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(amountTotal), true);
|
||||
else if (ic != null) ic.ReceiveDamage(amountTotal, caster);
|
||||
break;
|
||||
case EffectType.DamageOverTimeEnemy:
|
||||
// sustained: start coroutine to apply per-tick damage rather than instant total
|
||||
if (def.defaultDuration <= 0f)
|
||||
{
|
||||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(amountPerTick), true);
|
||||
else if (ic != null) ic.ReceiveDamage(amountPerTick, caster);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartCoroutine(ApplyDamageOverTimeDirect(t, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval(), caster));
|
||||
}
|
||||
break;
|
||||
case EffectType.HealSingleSelf:
|
||||
case EffectType.HealGroupSingle:
|
||||
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(amountTotal), true);
|
||||
else if (ic != null) ic.ReceiveHeal(amountTotal, caster);
|
||||
break;
|
||||
case EffectType.HealOverTimeSelf:
|
||||
case EffectType.HealGroupOverTime:
|
||||
if (def.defaultDuration <= 0f)
|
||||
{
|
||||
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(amountPerTick), true);
|
||||
else ic?.ReceiveHeal(amountPerTick, caster);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartCoroutine(ApplyHealOverTimeDirect(t, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval(), caster));
|
||||
}
|
||||
break;
|
||||
case EffectType.IncreaseManaOverTime:
|
||||
if (ally != null)
|
||||
{
|
||||
if (def.defaultDuration <= 0f)
|
||||
{
|
||||
// single instance - amountPerTick equals immediate amount
|
||||
ally.ModifyMana(Mathf.CeilToInt(amountPerTick), true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// for sustained, pass total amount (amountTotal) to coroutine which expects total
|
||||
StartCoroutine(ApplyIncreaseManaOverTimeDirect(ally, amountTotal, def.defaultDuration, def.GetEffectiveTickInterval()));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EffectType.ReduceEnemyHealOverTime:
|
||||
var deb = new Buff { buffId = System.Guid.NewGuid().ToString(), duration = def.defaultDuration, healReceivedMultiplier = 0.5f };
|
||||
if (ic != null) ic.ApplyBuff(deb, caster);
|
||||
break;
|
||||
case EffectType.BuffDuration:
|
||||
case EffectType.DebuffDuration:
|
||||
var b = new Buff { buffId = System.Guid.NewGuid().ToString(), duration = def.defaultDuration };
|
||||
if (def.effectType == EffectType.BuffDuration) b.scoreMultiplier = 1.5f;
|
||||
if (ic != null) ic.ApplyBuff(b, caster);
|
||||
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);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// For non-direct path, EffectSystem expects 'amount' to be total amount for sustained effects
|
||||
ExecuteSkill(def.displayName ?? def.skillId, def.effectType, amountTotal, def.defaultSelector, caster, specificTarget, def.defaultDuration, def.defaultTickInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the selected skill index from an AllyHero_SO for a given slot (calls UseSkillDefinition)
|
||||
public void UseSelectedSkillForSlot(int slotIndex, int selectedSkillIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||||
{
|
||||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||||
if (so == null || so.availableSkills == null || selectedSkillIndex < 0 || selectedSkillIndex >= so.availableSkills.Length)
|
||||
{
|
||||
Debug.LogWarning($"UseSelectedSkillForSlot: invalid selection for slot {slotIndex}");
|
||||
return;
|
||||
}
|
||||
UseSkillDefinition(so.availableSkills[selectedSkillIndex], slotIndex, inputValue, specificTarget);
|
||||
}
|
||||
|
||||
// Use the primary skill configured in the AllyHero_SO for this slot (primarySkillIndex dropdown)
|
||||
public void UsePrimarySkillForSlot(int slotIndex, float inputValue = -1f, GameObject specificTarget = null)
|
||||
{
|
||||
var so = GetAllyHeroSOBySlot(slotIndex);
|
||||
if (so == null)
|
||||
{
|
||||
Debug.LogWarning($"UsePrimarySkillForSlot: no SO for slot {slotIndex}");
|
||||
return;
|
||||
}
|
||||
int idx = so.primarySkillIndex;
|
||||
if (idx < 0) { Debug.LogWarning($"UsePrimarySkillForSlot: primarySkillIndex not set for slot {slotIndex}"); return; }
|
||||
UseSelectedSkillForSlot(slotIndex, idx, inputValue, specificTarget);
|
||||
}
|
||||
|
||||
// Called at game start to trigger any allies whose primary skill is set to trigger on game start
|
||||
public void TriggerOnGameStart()
|
||||
{
|
||||
if (teamUIController.Instance == null || teamUIController.Instance.allySlotIds == null) return;
|
||||
int slots = teamUIController.Instance.allySlotIds.Count;
|
||||
for (int i = 0; i < slots; i++)
|
||||
{
|
||||
var so = GetAllyHeroSOBySlot(i);
|
||||
if (so == null) continue;
|
||||
var def = so.GetPrimarySkill();
|
||||
if (def == null) continue;
|
||||
if (def.triggerCondition == SkillDefinition.SkillTrigger.OnGameStart)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] TriggerOnGameStart: casting primary skill {def.skillId} for slot {i}");
|
||||
UsePrimarySkillForSlot(i, -1f, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, float> _lastOnNoteHitTriggerTime = 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)
|
||||
{
|
||||
// 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}");
|
||||
if (trackIndex < 0) { Debug.Log("[SkillBuilder] NotifyNoteHit: invalid trackIndex"); return false; }
|
||||
var so = GetAllyHeroSOBySlot(trackIndex);
|
||||
if (so == null)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: no SO found for slot {trackIndex}, attempting fallback resolution...");
|
||||
// Dump current UI slot ids for diagnostics
|
||||
if (teamUIController.Instance != null && teamUIController.Instance.allySlotIds != null)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] teamUIController.allySlotIds = [{string.Join(",", teamUIController.Instance.allySlotIds)}]");
|
||||
}
|
||||
// Try to find ally GameObject named by convention
|
||||
var foundGO = GameObject.Find($"ally_0{trackIndex + 1}");
|
||||
if (foundGO != null)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] Found GameObject by name ally_0{trackIndex + 1}: {foundGO.name}");
|
||||
// try to map to UI slot
|
||||
var ui = teamUIController.Instance;
|
||||
if (ui != null && ui.allySlotIds != null)
|
||||
{
|
||||
for (int i = 0; i < ui.allySlotIds.Count; i++)
|
||||
{
|
||||
var slotObj = ui.GetAllyObjectBySlot(i);
|
||||
if (slotObj == null) continue;
|
||||
if (slotObj == foundGO || foundGO.transform.IsChildOf(slotObj.transform))
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] Resolved fallback slot {i} for GameObject {foundGO.name}");
|
||||
trackIndex = i; // override
|
||||
so = GetAllyHeroSOBySlot(trackIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (so == null)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: still no SO for resolved slot {trackIndex}, aborting NotifyNoteHit");
|
||||
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}");
|
||||
|
||||
// Treat Hold events as Tap-equivalent by default to improve compatibility: many skills expect Tap/Either
|
||||
var effectiveNoteType = noteType;
|
||||
if (noteType == SkillDefinition.NoteTypeTrigger.Hold)
|
||||
{
|
||||
// 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})");
|
||||
}
|
||||
|
||||
// Respect note type (Tap/Hold/Either)
|
||||
switch (def.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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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)
|
||||
{
|
||||
if (quality != 0)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {def.skillId} is Miss-only but quality={quality}, rejecting");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int required = (int)def.onNoteHitMinThreshold; // Good=1, Great=2, Perfect=3
|
||||
if (quality < required)
|
||||
{
|
||||
Debug.Log($"[SkillBuilder] NotifyNoteHit: skill {def.skillId} requires quality>={required} but got {quality}, rejecting");
|
||||
// not high enough
|
||||
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})");
|
||||
UsePrimarySkillForSlot(trackIndex, -1f, null);
|
||||
_lastOnNoteHitTriggerTime[key] = now;
|
||||
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.
|
||||
- GameObject specificTarget / enemyTarget: when a skill targets a specific unit (single enemy or ally), pass that GameObject here. If null, the selected Selector determines the set of targets (e.g. CurrentEnemies without specificTarget will resolve all enemies). Use Enemy GameObjects found by tag 'Enemy' or name 'thisEnemy', or keep a reference to the spawned enemy instance you want.
|
||||
|
||||
How to assign/choose:
|
||||
- Caster: when you call from gameplay code, pass the caster GameObject (or call UseSelectedSkillForSlot with slot index so SkillBuilder resolves caster for you).
|
||||
- EnemyTarget: if your skill should hit a specific enemy instance, supply that enemy's GameObject (for example from Enemy spawn manager or from collision detection). If you want to apply to all enemies, pass null and use Selector.CurrentEnemies.
|
||||
*/
|
||||
|
||||
// ----------------------------- 示例:为轨道3 封装一个快捷函数(用户示例) -----------------------------
|
||||
// 轨道3(索引2) 的技能1,遵循用户所需签名:_ally03skill01(EffectType, value, Selector, duration)
|
||||
public void _ally03skill01(EffectType effectType, float value, Selector selector, float duration = 0f, GameObject specificTarget = null)
|
||||
{
|
||||
AllySlotSkill(2, "skill01", effectType, value, selector, duration, specificTarget);
|
||||
}
|
||||
|
||||
// Coroutine helper for IncreaseManaOverTime
|
||||
private IEnumerator ApplyIncreaseManaOverTimeDirect(AllyCombatant ally, float totalAmount, float duration, float tickInterval)
|
||||
{
|
||||
if (ally == null) yield break;
|
||||
if (duration <= 0f || tickInterval <= 0f)
|
||||
{
|
||||
ally.ModifyMana(Mathf.CeilToInt(totalAmount), true);
|
||||
yield break;
|
||||
}
|
||||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||||
float perTick = totalAmount / ticks;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
ally.ModifyMana(Mathf.CeilToInt(perTick), true);
|
||||
yield return new WaitForSeconds(tickInterval);
|
||||
elapsed += tickInterval;
|
||||
}
|
||||
}
|
||||
|
||||
// Coroutine helpers for direct DoT/HoT on GameObject targets
|
||||
private IEnumerator ApplyDamageOverTimeDirect(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source)
|
||||
{
|
||||
if (target == null) yield break;
|
||||
var ic = target.GetComponent<ICombatant>();
|
||||
var ally = target.GetComponent<AllyCombatant>();
|
||||
if (ally == null && ic == null)
|
||||
{
|
||||
Debug.LogWarning($"ApplyDamageOverTimeDirect: target {target.name} cannot receive damage");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (duration <= 0f || tickInterval <= 0f)
|
||||
{
|
||||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(totalAmount), true);
|
||||
else ic?.ReceiveDamage(totalAmount, source);
|
||||
yield break;
|
||||
}
|
||||
|
||||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||||
float perTick = totalAmount / ticks;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
if (ally != null) ally.ModifyHP(-Mathf.CeilToInt(perTick), true);
|
||||
else ic?.ReceiveDamage(perTick, source);
|
||||
yield return new WaitForSeconds(tickInterval);
|
||||
elapsed += tickInterval;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator ApplyHealOverTimeDirect(GameObject target, float totalAmount, float duration, float tickInterval, GameObject source)
|
||||
{
|
||||
if (target == null) yield break;
|
||||
var ic = target.GetComponent<ICombatant>();
|
||||
var ally = target.GetComponent<AllyCombatant>();
|
||||
if (ally == null && ic == null)
|
||||
{
|
||||
Debug.LogWarning($"ApplyHealOverTimeDirect: target {target.name} cannot receive heal");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (duration <= 0f || tickInterval <= 0f)
|
||||
{
|
||||
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(totalAmount), true);
|
||||
else ic?.ReceiveHeal(totalAmount, source);
|
||||
yield break;
|
||||
}
|
||||
|
||||
int ticks = Mathf.Max(1, Mathf.CeilToInt(duration / tickInterval));
|
||||
float perTick = totalAmount / ticks;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
if (ally != null) ally.ModifyHP(Mathf.CeilToInt(perTick), true);
|
||||
else ic?.ReceiveHeal(perTick, source);
|
||||
yield return new WaitForSeconds(tickInterval);
|
||||
elapsed += tickInterval;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 53ea14de0f25c18469332fda60802db9
|
||||
@@ -0,0 +1,263 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
[CreateAssetMenu(fileName = "NewSkillDefinition", menuName = "Combat/SkillDefinition")]
|
||||
public class SkillDefinition : ScriptableObject
|
||||
{
|
||||
public enum SkillTrigger
|
||||
{
|
||||
None,
|
||||
OnGameStart,
|
||||
OnNoteHit,
|
||||
OnManaFull
|
||||
}
|
||||
|
||||
[Header("标识 (Identity)")]
|
||||
[Tooltip("技能的内部唯一 ID,用于代码中识别此技能")]
|
||||
public string skillId;
|
||||
|
||||
[Tooltip("技能在 UI 中显示的名称")]
|
||||
public string displayName;
|
||||
|
||||
[Tooltip("简短注释/摘要,仅供设计时参考,不影响运行")]
|
||||
[TextArea(1,2)]
|
||||
public string summaryInfo;
|
||||
|
||||
[Header("效果 (Effect)")]
|
||||
[Tooltip("技能的效果类型,例如即时伤害、持续伤害、治疗、增益/减益等")]
|
||||
public EffectType effectType = EffectType.DamageSingleEnemy;
|
||||
|
||||
[Tooltip("默认目标选择器:决定技能默认作用的目标范围(自身/所有友军/敌方等)")]
|
||||
public Selector defaultSelector = Selector.CurrentEnemies;
|
||||
|
||||
[Tooltip("当选择 FromFormula 时使用的表达式字符串。支持使用 AllyHero_SO 中同名数值字段作为变量,例如: attack, maxHP, maxMana, damageResistance, scoreEfficiency, ally_currentEXP, slot。示例: 'attack * 1.5 + maxHP * 0.1 + 20'。如果只填写一个数字(例如 '120'),则等同于指定固定数值。")]
|
||||
[TextArea(2,4)]
|
||||
public string formula = "";
|
||||
|
||||
[Tooltip("默认持续时间(秒),用于持续效果或 Buff 的持续时长。若为 0 则视为一次性/即时技能(不会按 tick 分发)")]
|
||||
public float defaultDuration = 0f;
|
||||
|
||||
[Tooltip("默认打点间隔(秒),用于持续效果的每次触发间隔(例如每秒扣血)。若为 0 则视为一次性/即时技能")]
|
||||
public float defaultTickInterval = 1f;
|
||||
|
||||
[Header("标志 (Flags)")]
|
||||
[Tooltip("是否必须指定单体目标(true 表示此技能通常需要传入 specificTarget)")]
|
||||
public bool requiresSpecificTarget = false; // if true, skill usually targets a specific GameObject
|
||||
|
||||
[Tooltip("是否直接作用于目标的数据(true 表示 SkillBuilder 会直接调用 ICombatant/AllyCombatant,而非通过 EffectSystem 分发)")]
|
||||
public bool operateDirectly = false; // if true, SkillBuilder will operate on ICombatant/AllyCombatant fields directly and update UI, instead of calling EffectSystem
|
||||
|
||||
[Tooltip("技能的描述或备注,便于设计时填写说明")]
|
||||
[TextArea(8,4)]
|
||||
public string description;
|
||||
|
||||
[Header("触发条件 (Trigger)")]
|
||||
[Tooltip("技能触发条件:进入游戏时、击打音符时、法力值满时等")]
|
||||
public SkillTrigger triggerCondition = SkillTrigger.None;
|
||||
|
||||
[Tooltip("仅当 triggerCondition == OnNoteHit 时生效:达到该判定或更高时触发(可选 Miss/Good/Great/Perfect)。特别说明:选择 Miss 表示“仅在判定为 Miss 时触发”,其它判定(Good/Great/Perfect)不会触发。)")]
|
||||
public NoteTriggerThreshold onNoteHitMinThreshold = NoteTriggerThreshold.Good;
|
||||
|
||||
[Tooltip("仅当 triggerCondition == OnNoteHit 时生效:指定此技能由 Tap(点击)/Hold(长按尾段)或 Either(任意)触发。若选择 Hold,则只有长音符尾段会触发;若选择 Tap,则仅短按触发;Either 则两者均可(但 Hold 仍可被显式禁用)。")]
|
||||
public NoteTypeTrigger noteTriggerType = NoteTypeTrigger.Either;
|
||||
|
||||
[Tooltip("仅当 triggerCondition == OnNoteHit 时生效:触发后的冷却时间(秒),在冷却时间内再次满足判定不会触发技能")]
|
||||
public float onNoteHitCooldown = 0f;
|
||||
|
||||
// Minimum judge quality required to trigger on note hit
|
||||
public enum NoteTriggerThreshold
|
||||
{
|
||||
// 当设置为 Miss 时,技能仅在判定为 Miss(quality == 0)时触发
|
||||
Miss = 0,
|
||||
Good = 1,
|
||||
Great = 2,
|
||||
Perfect = 3
|
||||
}
|
||||
|
||||
// 新增:指定技能应由 Tap/ Hold/ Either 触发
|
||||
public enum NoteTypeTrigger
|
||||
{
|
||||
Tap,
|
||||
Hold,
|
||||
Either
|
||||
}
|
||||
|
||||
// 运行时辅助属性:当 defaultDuration 或 defaultTickInterval 为 0 时,视作一次性即时技能
|
||||
public bool IsSingleInstance => defaultDuration <= 0f || defaultTickInterval <= 0f;
|
||||
|
||||
// 返回实际用于分发打点的间隔(若为一次性技能则返回 0)
|
||||
public float GetEffectiveTickInterval()
|
||||
{
|
||||
return IsSingleInstance ? 0f : Mathf.Max(0.0001f, defaultTickInterval);
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
// 保证数值为非负
|
||||
if (defaultDuration < 0f) defaultDuration = 0f;
|
||||
if (defaultTickInterval < 0f) defaultTickInterval = 0f;
|
||||
if (onNoteHitCooldown < 0f) onNoteHitCooldown = 0f;
|
||||
}
|
||||
|
||||
// --- Simple expression evaluator used when valueMode == FromFormula ---
|
||||
// Supports variables (alphanumeric), floats, operators + - * / and parentheses.
|
||||
public static bool TryEvaluateFormula(string expr, Dictionary<string, float> variables, out float result)
|
||||
{
|
||||
result = 0f;
|
||||
if (string.IsNullOrWhiteSpace(expr)) return false;
|
||||
try
|
||||
{
|
||||
var tokens = Tokenize(expr);
|
||||
var rpn = ToRPN(tokens);
|
||||
result = EvalRPN(rpn, variables);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"SkillDefinition: failed to evaluate formula '{expr}': {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// tokenization
|
||||
private enum TokType { Number, Ident, Op, LParen, RParen }
|
||||
private struct Token { public TokType type; public string text; }
|
||||
|
||||
private static List<Token> Tokenize(string s)
|
||||
{
|
||||
var outp = new List<Token>();
|
||||
int i = 0;
|
||||
while (i < s.Length)
|
||||
{
|
||||
char c = s[i];
|
||||
if (char.IsWhiteSpace(c)) { i++; continue; }
|
||||
if (c == '(') { outp.Add(new Token { type = TokType.LParen, text = "(" }); i++; continue; }
|
||||
if (c == ')') { outp.Add(new Token { type = TokType.RParen, text = ")" }); i++; continue; }
|
||||
if ("+-*/".IndexOf(c) >= 0)
|
||||
{
|
||||
outp.Add(new Token { type = TokType.Op, text = c.ToString() }); i++; continue;
|
||||
}
|
||||
if (char.IsDigit(c) || c == '.')
|
||||
{
|
||||
int start = i;
|
||||
while (i < s.Length && (char.IsDigit(s[i]) || s[i] == '.')) i++;
|
||||
outp.Add(new Token { type = TokType.Number, text = s.Substring(start, i - start) });
|
||||
continue;
|
||||
}
|
||||
if (char.IsLetter(c) || c == '_')
|
||||
{
|
||||
int start = i;
|
||||
while (i < s.Length && (char.IsLetterOrDigit(s[i]) || s[i] == '_')) i++;
|
||||
outp.Add(new Token { type = TokType.Ident, text = s.Substring(start, i - start) });
|
||||
continue;
|
||||
}
|
||||
throw new Exception($"Invalid char in formula: '{c}'");
|
||||
}
|
||||
// Handle unary plus/minus: when + or - appears at start or after operator or left paren
|
||||
var fixedTokens = new List<Token>();
|
||||
for (int idx = 0; idx < outp.Count; idx++)
|
||||
{
|
||||
var t = outp[idx];
|
||||
if (t.type == TokType.Op && (t.text == "-" || t.text == "+"))
|
||||
{
|
||||
bool unary = (idx == 0) || (outp[idx - 1].type == TokType.Op) || (outp[idx - 1].type == TokType.LParen);
|
||||
if (unary)
|
||||
{
|
||||
// insert 0 before unary +/- to convert unary into binary (0 +/- x)
|
||||
fixedTokens.Add(new Token { type = TokType.Number, text = "0" });
|
||||
}
|
||||
}
|
||||
fixedTokens.Add(t);
|
||||
}
|
||||
return fixedTokens;
|
||||
}
|
||||
|
||||
private static int Precedence(string op)
|
||||
{
|
||||
if (op == "+" || op == "-") return 1;
|
||||
if (op == "*" || op == "/") return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static List<Token> ToRPN(List<Token> tokens)
|
||||
{
|
||||
var output = new List<Token>();
|
||||
var stack = new Stack<Token>();
|
||||
foreach (var t in tokens)
|
||||
{
|
||||
if (t.type == TokType.Number || t.type == TokType.Ident)
|
||||
{
|
||||
output.Add(t);
|
||||
}
|
||||
else if (t.type == TokType.Op)
|
||||
{
|
||||
while (stack.Count > 0 && stack.Peek().type == TokType.Op && Precedence(stack.Peek().text) >= Precedence(t.text))
|
||||
{
|
||||
output.Add(stack.Pop());
|
||||
}
|
||||
stack.Push(t);
|
||||
}
|
||||
else if (t.type == TokType.LParen)
|
||||
{
|
||||
stack.Push(t);
|
||||
}
|
||||
else if (t.type == TokType.RParen)
|
||||
{
|
||||
while (stack.Count > 0 && stack.Peek().type != TokType.LParen)
|
||||
{
|
||||
output.Add(stack.Pop());
|
||||
}
|
||||
if (stack.Count == 0) throw new Exception("Mismatched parentheses");
|
||||
stack.Pop(); // pop LParen
|
||||
}
|
||||
}
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var tk = stack.Pop();
|
||||
if (tk.type == TokType.LParen || tk.type == TokType.RParen) throw new Exception("Mismatched parentheses");
|
||||
output.Add(tk);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private static float EvalRPN(List<Token> rpn, Dictionary<string, float> vars)
|
||||
{
|
||||
var st = new Stack<float>();
|
||||
foreach (var t in rpn)
|
||||
{
|
||||
if (t.type == TokType.Number)
|
||||
{
|
||||
if (!float.TryParse(t.text, NumberStyles.Float, CultureInfo.InvariantCulture, out float v)) throw new Exception($"Invalid number '{t.text}'");
|
||||
st.Push(v);
|
||||
}
|
||||
else if (t.type == TokType.Ident)
|
||||
{
|
||||
if (vars != null && vars.TryGetValue(t.text, out float v)) st.Push(v);
|
||||
else
|
||||
{
|
||||
// unknown identifiers treat as 0 but warn
|
||||
Debug.LogWarning($"SkillDefinition: unknown variable '{t.text}' in formula, treated as 0");
|
||||
st.Push(0f);
|
||||
}
|
||||
}
|
||||
else if (t.type == TokType.Op)
|
||||
{
|
||||
if (st.Count < 2) throw new Exception("Insufficient values for operator");
|
||||
float b = st.Pop(); float a = st.Pop();
|
||||
switch (t.text)
|
||||
{
|
||||
case "+": st.Push(a + b); break;
|
||||
case "-": st.Push(a - b); break;
|
||||
case "*": st.Push(a * b); break;
|
||||
case "/": st.Push(a / b); break;
|
||||
default: throw new Exception($"Unsupported operator {t.text}");
|
||||
}
|
||||
}
|
||||
else throw new Exception("Invalid token in RPN");
|
||||
}
|
||||
if (st.Count != 1) throw new Exception("Invalid expression evaluation");
|
||||
return st.Pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 265d1552ebf55c34a98934707b2c20ce
|
||||
@@ -0,0 +1,107 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[CreateAssetMenu(fileName = "NewAllyHero", menuName = "SO_Data/AllyHero")]
|
||||
public class AllyHero_SO : ScriptableObject
|
||||
{
|
||||
[Header("友方英雄基本信息")]
|
||||
public string ally_heroName;
|
||||
public int ally_heroID;
|
||||
|
||||
[Header("英雄立绘")]
|
||||
public Sprite ally_heroImage;
|
||||
[Header("英雄局内头像")]
|
||||
public Sprite ally_heroProfile;
|
||||
[Header("英雄轨道icon")]
|
||||
public Sprite ally_heroIcon;
|
||||
|
||||
[Header("等级属性列表")]
|
||||
public List<AllyLevelInfo> levelStats = new List<AllyLevelInfo>();
|
||||
|
||||
[System.Serializable]
|
||||
public class AllyLevelInfo
|
||||
{
|
||||
[Tooltip("等级名称或标识")]
|
||||
public string levelName;
|
||||
public int levelID;
|
||||
|
||||
[Tooltip("基础攻击力")]
|
||||
public int attack = 0;
|
||||
|
||||
[Tooltip("最大生命值")]
|
||||
public int maxHP = 100;
|
||||
|
||||
[Tooltip("伤害抗性 (0..1), 用于减少所受伤害")]
|
||||
public float damageResistance = 0f;
|
||||
|
||||
[Tooltip("最大法力值")]
|
||||
public int maxMana = 100;
|
||||
|
||||
[Tooltip("得分效率")]
|
||||
public float scoreEfficiency = 1f;
|
||||
|
||||
[Tooltip("升到本等级所需经验")]
|
||||
public int requiredEXP;
|
||||
|
||||
// --- 新增:每等级的共享音符击中参数(用于将来调整) ---
|
||||
[Header("Note-hit shared parameters")]
|
||||
[Tooltip("Good 判定时获得的法力值(整数)")]
|
||||
public int manaGainGood = 1;
|
||||
[Tooltip("Great 判定时获得的法力值(整数)")]
|
||||
public int manaGainGreat = 2;
|
||||
[Tooltip("Perfect 判定时获得的法力值(整数)")]
|
||||
public int manaGainPerfect = 3;
|
||||
[Tooltip("Miss 判定时获得的法力值(整数)")]
|
||||
public int manaGainOnMiss = 5;
|
||||
|
||||
[Tooltip("Good 判定时基于英雄攻击力的伤害倍率,例如 0.5 表示造成 50% 的基础攻击力伤害")]
|
||||
public float damageMultiplierGood = 0.5f;
|
||||
[Tooltip("Great 判定时基于英雄攻击力的伤害倍率,例如 0.75 表示造成 75% 的基础攻击力伤害")]
|
||||
public float damageMultiplierGreat = 0.75f;
|
||||
[Tooltip("Perfect 判定时基于英雄攻击力的伤害倍率,例如 1.0 表示造成 100% 的基础攻击力伤害")]
|
||||
public float damageMultiplierPerfect = 1f;
|
||||
|
||||
[Tooltip("Miss 时造成的基础生命损失基数。最终扣血将按 (missHpLossBase * (1 - damageResistance)) 计算,以考虑抗性")]
|
||||
public float missHpLossBase = 10f;
|
||||
}
|
||||
|
||||
[Header("当前经验")]
|
||||
public int ally_currentEXP;
|
||||
|
||||
[Header("Skills")]
|
||||
public SkillDefinition[] availableSkills;
|
||||
[Tooltip("Indexes into availableSkills for preselected skills; empty means none selected")]
|
||||
public int[] selectedSkillIndices = new int[0];
|
||||
|
||||
[Tooltip("Primary selected skill index into availableSkills (-1 = none)")]
|
||||
public int primarySkillIndex = -1;
|
||||
|
||||
public SkillDefinition GetPrimarySkill()
|
||||
{
|
||||
if (availableSkills == null || primarySkillIndex < 0 || primarySkillIndex >= availableSkills.Length) return null;
|
||||
return availableSkills[primarySkillIndex];
|
||||
}
|
||||
|
||||
// Return the AllyLevelInfo that corresponds to the current ally_currentEXP.
|
||||
// Picks the highest level whose requiredEXP <= ally_currentEXP. If none match, returns levelStats[0] if present, otherwise null.
|
||||
public AllyLevelInfo GetEffectiveLevelForCurrentEXP()
|
||||
{
|
||||
if (levelStats == null || levelStats.Count == 0) return null;
|
||||
AllyLevelInfo best = null;
|
||||
int currentExp = ally_currentEXP;
|
||||
foreach (var lvl in levelStats)
|
||||
{
|
||||
if (lvl == null) continue;
|
||||
if (best == null)
|
||||
{
|
||||
if (currentExp >= lvl.requiredEXP) best = lvl;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (currentExp >= lvl.requiredEXP && lvl.requiredEXP >= best.requiredEXP) best = lvl;
|
||||
}
|
||||
}
|
||||
if (best == null) return levelStats[0];
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bdd198d96872f3b42bdfc7e262ff2eed
|
||||
@@ -0,0 +1,9 @@
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(fileName = "NewBossData", menuName = "SO_Data/BossData")]
|
||||
public class BossData_SO : EnemyData_SO
|
||||
{
|
||||
[Header("boss特有信息")]
|
||||
[Tooltip("最大法力值")]
|
||||
public int enemy_maxMana;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5b254d6c08a7d94f99406da53f22341
|
||||
@@ -0,0 +1,24 @@
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(fileName = "NewEnemyData", menuName = "SO_Data/EnemyData")]
|
||||
public class EnemyData_SO : ScriptableObject
|
||||
{
|
||||
[Header("基本信息")]
|
||||
[Tooltip("敌人名称")]
|
||||
public string enemyName;
|
||||
public Sprite enemy_Image;
|
||||
public Sprite enemy_Profile;
|
||||
|
||||
[Tooltip("敌人ID")]
|
||||
public int enemyID;
|
||||
|
||||
[Header("Stats")]
|
||||
[Tooltip("最大生命值")]
|
||||
public int enemy_maxHP;
|
||||
|
||||
[Tooltip("敌人伤害抗性")]
|
||||
public float enemy_damageResistance;
|
||||
|
||||
[Tooltip("基础攻击力")]
|
||||
public int enemy_baseAttack;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8cdb6ddbbe019f498476071245f3237
|
||||
@@ -0,0 +1,12 @@
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(fileName = "PlayerDefaultSO", menuName = "SO_Data/PlayerSO")]
|
||||
public class Player_SO : ScriptableObject
|
||||
{
|
||||
[Header("玩家信息")]
|
||||
public string player_name;
|
||||
|
||||
public int player_ID; // steam ID
|
||||
|
||||
public int player_EXP;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27e379f45b1bb88449756219caee2f89
|
||||
@@ -8,7 +8,8 @@ public class TeamSetting
|
||||
{
|
||||
public TeamSetting(List<CharacterView> teamList, int leaderId)
|
||||
{
|
||||
this.teamIdList = teamIdList;
|
||||
// assign passed-in list to the backing field
|
||||
this.teamIdList = teamList ?? new List<CharacterView>();
|
||||
LeaderId = leaderId;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using UnityEngine.SceneManagement;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Simple pause manager. Call Pause(true) to pause or Pause(false) to resume.
|
||||
@@ -32,10 +36,30 @@ public class PauseManager : MonoBehaviour
|
||||
|
||||
void Update()
|
||||
{
|
||||
// Delete = immediate quit (stop play in editor or quit app)
|
||||
if (Input.GetKeyDown(KeyCode.Delete))
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
EditorApplication.isPlaying = false;
|
||||
#else
|
||||
Application.Quit();
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
if (Input.GetKeyDown(KeyCode.Escape))
|
||||
{
|
||||
TogglePause();
|
||||
}
|
||||
|
||||
// 当处于暂停状态且按下 Backspace 时返回主界面
|
||||
if (IsPaused && Input.GetKeyDown(KeyCode.Backspace))
|
||||
{
|
||||
// 恢复时间流并触发事件,然后加载主场景
|
||||
Pause(false);
|
||||
// 确保场景名称与项目中的主场景匹配
|
||||
SceneManager.LoadScene("Main_main");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -21,22 +21,39 @@ public class GameManager : MonoBehaviour
|
||||
[Header("Debug / Test")]
|
||||
public Text banflagTextUI;
|
||||
|
||||
private bool pauseSubscribed = false;
|
||||
|
||||
private void SubscribeToPauseManager()
|
||||
{
|
||||
if (pauseSubscribed) return;
|
||||
// prefer the singleton, but try to find in scene if null
|
||||
var pm = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
|
||||
if (pm != null)
|
||||
{
|
||||
pm.OnPauseStateChanged += HandlePauseStateChanged;
|
||||
pauseSubscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeFromPauseManager()
|
||||
{
|
||||
if (!pauseSubscribed) return;
|
||||
var pm = PauseManager.Instance ?? FindObjectOfType<PauseManager>();
|
||||
if (pm != null)
|
||||
{
|
||||
pm.OnPauseStateChanged -= HandlePauseStateChanged;
|
||||
}
|
||||
pauseSubscribed = false;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// 订阅暂停管理器的事件
|
||||
if (PauseManager.Instance != null)
|
||||
{
|
||||
PauseManager.Instance.OnPauseStateChanged += HandlePauseStateChanged;
|
||||
}
|
||||
SubscribeToPauseManager();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// 取消订阅暂停管理器的事件
|
||||
if (PauseManager.Instance != null)
|
||||
{
|
||||
PauseManager.Instance.OnPauseStateChanged -= HandlePauseStateChanged;
|
||||
}
|
||||
UnsubscribeFromPauseManager();
|
||||
}
|
||||
|
||||
private void HandlePauseStateChanged(bool isPaused)
|
||||
@@ -282,7 +299,8 @@ public class GameManager : MonoBehaviour
|
||||
}
|
||||
|
||||
// Pause the system until Space pressed
|
||||
// Use PauseManager instead of directly setting Time.timeScale
|
||||
// Ensure we are subscribed before invoking pause so we track audio state
|
||||
SubscribeToPauseManager();
|
||||
PauseManager.Instance?.Pause(true);
|
||||
UpdateStatusOnConsole("Paused: press Space to start playback");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
public enum NoteSegment { None, Start, Middle, End }
|
||||
@@ -40,6 +41,8 @@ public class HoldNote : BaseNote
|
||||
[Header("判定区间配置")]
|
||||
public NoteJudgeConfig judgeConfig; // 判定区间配置,需在预制体或生成时赋值
|
||||
|
||||
private bool _hasTriggeredOnThisHold = false; // ensure single trigger per hold note
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
controller = GetComponent<HoldNoteController>();
|
||||
@@ -92,6 +95,8 @@ public class HoldNote : BaseNote
|
||||
{
|
||||
this.id = id;
|
||||
this.trackIndex = trackIndex;
|
||||
// also set BaseNote.TrackIndex so other systems using TrackIndex property work consistently
|
||||
this.TrackIndex = trackIndex;
|
||||
this.speed = speed;
|
||||
this.startTime = time;
|
||||
this.delay = delay;
|
||||
@@ -346,6 +351,28 @@ public class HoldNote : BaseNote
|
||||
JudgeManager.Instance.RegisterStartJudged(noteID, true);
|
||||
isHoldActive = true; // 成功判定Start,长按状态激活
|
||||
PlayHitAnimation();
|
||||
|
||||
// Trigger skills configured to fire on note hits for this slot when the Start is successfully hit.
|
||||
if (!_hasTriggeredOnThisHold)
|
||||
{
|
||||
try
|
||||
{
|
||||
Debug.Log($"[HoldNote] Triggering SkillBuilder on START for slot {trackIndex} with result={result}");
|
||||
// Prefer Hold type notification so skills expecting Hold can react. If it fails, fallback to Tap.
|
||||
bool triggered = SkillBuilder.Instance?.NotifyNoteHit(this.trackIndex, result, SkillDefinition.NoteTypeTrigger.Hold) ?? false;
|
||||
if (!triggered)
|
||||
{
|
||||
Debug.Log($"[HoldNote] START Hold notify did not trigger skill for slot {trackIndex}, trying Tap fallback");
|
||||
triggered = SkillBuilder.Instance?.NotifyNoteHit(this.trackIndex, result, SkillDefinition.NoteTypeTrigger.Tap) ?? false;
|
||||
}
|
||||
Debug.Log($"[HoldNote] START skill trigger result for slot {trackIndex}: {triggered}");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogError($"[HoldNote] NotifyNoteHit(Start) threw: {ex}");
|
||||
}
|
||||
_hasTriggeredOnThisHold = true; // avoid triggering again at End
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -353,8 +380,26 @@ public class HoldNote : BaseNote
|
||||
Debug.Log($"[HoldNote] START Miss(偏差={offset:F2}秒): {noteColor}");
|
||||
JudgeManager.Instance.RegisterStartJudged(noteID, false); // 明确标记未判定
|
||||
isHoldActive = false; // Start Miss,长按状态不激活
|
||||
|
||||
// For Miss we should still notify shared logic once so it behaves like short notes
|
||||
try
|
||||
{
|
||||
// Treat as Tap-type miss for compatibility
|
||||
bool triggered = SkillBuilder.Instance?.NotifyNoteHit(this.trackIndex, result, SkillDefinition.NoteTypeTrigger.Tap) ?? false;
|
||||
Debug.Log($"[HoldNote] START Miss NotifyNoteHit fired for slot {trackIndex}: {triggered}");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogError($"[HoldNote] NotifyNoteHit(Start Miss) threw: {ex}");
|
||||
}
|
||||
_hasTriggeredOnThisHold = true; // ensure we don't trigger again at End
|
||||
}
|
||||
|
||||
// show judge result and play sound / update combo like short notes
|
||||
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
|
||||
JudgeSoundManager.Instance?.PlayJudgeSound(result);
|
||||
teamUIController.Instance?.OnJudgeResult(result);
|
||||
|
||||
StartCoroutine(DelayedReturn());
|
||||
}
|
||||
|
||||
@@ -442,6 +487,88 @@ public class HoldNote : BaseNote
|
||||
}
|
||||
InputManager.Instance?.ShowJudgeResult(trackIndex, result);
|
||||
teamUIController.Instance?.OnJudgeResult(result); // 新增:更新combo计数
|
||||
// Notify SkillBuilder so Hold notes can trigger skills configured for Hold
|
||||
if (!_hasTriggeredOnThisHold)
|
||||
{
|
||||
var sb = SkillBuilder.Instance;
|
||||
if (sb == null)
|
||||
{
|
||||
Debug.LogWarning($"[HoldNote] SkillBuilder.Instance is null when trying to notify for track {TrackIndex}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var so = sb.GetAllyHeroSOBySlot(TrackIndex);
|
||||
var def = so?.GetPrimarySkill();
|
||||
Debug.Log($"[HoldNote] About to NotifyNoteHit: TrackIndex={TrackIndex} trackIndexField={trackIndex} so={(so!=null?so.name:"null")} skill={(def!=null?def.skillId:"null")} trigger={(def!=null?def.triggerCondition.ToString():"-")}");
|
||||
}
|
||||
|
||||
bool triggeredHold = false;
|
||||
bool triggeredTap = false;
|
||||
|
||||
// Build candidate slot indices to try: prefer instance field, then BaseNote.TrackIndex, then try to match UI slots
|
||||
var candidates = new List<int>();
|
||||
candidates.Add(this.trackIndex);
|
||||
if (!candidates.Contains(this.TrackIndex)) candidates.Add(this.TrackIndex);
|
||||
// try to resolve by matching this object to UI slots
|
||||
var ui = teamUIController.Instance;
|
||||
if (ui != null)
|
||||
{
|
||||
for (int i = 0; i < ui.allySlotIds.Count; i++)
|
||||
{
|
||||
var slotObj = ui.GetAllyObjectBySlot(i);
|
||||
if (slotObj == null) continue;
|
||||
if (slotObj == this.gameObject || this.gameObject.transform.IsChildOf(slotObj.transform))
|
||||
{
|
||||
if (!candidates.Contains(i)) candidates.Add(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[HoldNote] NotifyNoteHit candidate slots: {string.Join(",", candidates)}");
|
||||
|
||||
// Try each candidate: prefer Hold notification. Stop on first successful trigger.
|
||||
foreach (var slot in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
triggeredHold = SkillBuilder.Instance?.NotifyNoteHit(slot, result, SkillDefinition.NoteTypeTrigger.Hold) ?? false;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogError($"[HoldNote] NotifyNoteHit(Hold) threw for slot {slot}: {ex}");
|
||||
}
|
||||
if (triggeredHold)
|
||||
{
|
||||
Debug.Log($"[HoldNote] NotifyNoteHit succeeded (Hold) for slot {slot}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no candidate triggered via Hold, try Tap fallback on same candidate list
|
||||
if (!triggeredHold)
|
||||
{
|
||||
foreach (var slot in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
triggeredTap = SkillBuilder.Instance?.NotifyNoteHit(slot, result, SkillDefinition.NoteTypeTrigger.Tap) ?? false;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogError($"[HoldNote] NotifyNoteHit(Tap) threw for slot {slot}: {ex}");
|
||||
}
|
||||
if (triggeredTap)
|
||||
{
|
||||
Debug.Log($"[HoldNote] NotifyNoteHit succeeded (Tap fallback) for slot {slot}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_hasTriggeredOnThisHold = true;
|
||||
Debug.Log($"[HoldNote] Hold end NotifyNoteHit called: TrackIndex={TrackIndex} trackIndexField={trackIndex} result={result} triggeredHold={triggeredHold} triggeredTap={triggeredTap}");
|
||||
}
|
||||
Debug.Log($"[HoldNote] END判定结果: {noteColor} {result} (release={releaseTime:F2}, target={scheduledEndTime:F2})");
|
||||
ReturnToPool();
|
||||
}
|
||||
@@ -507,6 +634,7 @@ public class HoldNote : BaseNote
|
||||
isJudged = false;
|
||||
isHoldActive = false; // 重置长按标记
|
||||
hasBeenHeldFromStart = false; // 重置
|
||||
_hasTriggeredOnThisHold = false; // 重置
|
||||
|
||||
if (autoReturnCoroutine != null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized judge sound player. Assign four AudioSources (one per judgement) in the Inspector.
|
||||
/// Designed to be low-latency: each judgement has a dedicated AudioSource and PlayOneShot is used.
|
||||
/// Place this component on a persistent gameobject in the scene (or your audio container).
|
||||
/// </summary>
|
||||
public class JudgeSoundManager : MonoBehaviour
|
||||
{
|
||||
public static JudgeSoundManager Instance { get; private set; }
|
||||
|
||||
[Header("AudioSources (assign dedicated AudioSource for each judgement)")]
|
||||
public AudioSource perfectSource;
|
||||
public AudioSource greatSource;
|
||||
public AudioSource goodSource;
|
||||
public AudioSource missSource;
|
||||
|
||||
[Header("Enable/Disable per-judgement playback")]
|
||||
[Tooltip("Toggle whether Perfect sounds are played")]
|
||||
public bool enablePerfect = true;
|
||||
[Tooltip("Toggle whether Great sounds are played")]
|
||||
public bool enableGreat = true;
|
||||
[Tooltip("Toggle whether Good sounds are played")]
|
||||
public bool enableGood = true;
|
||||
[Tooltip("Toggle whether Miss sounds are played")]
|
||||
public bool enableMiss = true;
|
||||
|
||||
[Header("Fallback clips (optional, used if the assigned AudioSource has no clip)")]
|
||||
public AudioClip perfectClip;
|
||||
public AudioClip greatClip;
|
||||
public AudioClip goodClip;
|
||||
public AudioClip missClip;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this;
|
||||
// Do not automatically destroy; leave lifecycle to scene management
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure AudioSources are configured for low latency
|
||||
ConfigureSource(perfectSource);
|
||||
ConfigureSource(greatSource);
|
||||
ConfigureSource(goodSource);
|
||||
ConfigureSource(missSource);
|
||||
}
|
||||
|
||||
private void ConfigureSource(AudioSource src)
|
||||
{
|
||||
if (src == null) return;
|
||||
src.playOnAwake = false;
|
||||
// keep 3D off for UI/flat sounds
|
||||
src.spatialBlend = 0f;
|
||||
// ensure not looping
|
||||
src.loop = false;
|
||||
}
|
||||
|
||||
public enum JudgeResult { Perfect, Great, Good, Miss }
|
||||
|
||||
public void PlayJudgeSound(string result)
|
||||
{
|
||||
if (string.IsNullOrEmpty(result)) return;
|
||||
switch (result)
|
||||
{
|
||||
case "Perfect": PlayJudgeSound(JudgeResult.Perfect); break;
|
||||
case "Great": PlayJudgeSound(JudgeResult.Great); break;
|
||||
case "Good": PlayJudgeSound(JudgeResult.Good); break;
|
||||
case "Miss": PlayJudgeSound(JudgeResult.Miss); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
public void PlayJudgeSound(JudgeResult r)
|
||||
{
|
||||
// honor inspector toggles to allow disabling specific judgement sounds
|
||||
switch (r)
|
||||
{
|
||||
case JudgeResult.Perfect:
|
||||
if (!enablePerfect) return;
|
||||
PlayOnSource(perfectSource, perfectClip);
|
||||
break;
|
||||
case JudgeResult.Great:
|
||||
if (!enableGreat) return;
|
||||
PlayOnSource(greatSource, greatClip);
|
||||
break;
|
||||
case JudgeResult.Good:
|
||||
if (!enableGood) return;
|
||||
PlayOnSource(goodSource, goodClip);
|
||||
break;
|
||||
case JudgeResult.Miss:
|
||||
if (!enableMiss) return;
|
||||
PlayOnSource(missSource, missClip);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void PlayOnSource(AudioSource src, AudioClip fallbackClip)
|
||||
{
|
||||
if (src != null)
|
||||
{
|
||||
// Prefer PlayOneShot so sources can overlap and we don't need to manage clip state
|
||||
if (src.clip != null)
|
||||
{
|
||||
src.PlayOneShot(src.clip);
|
||||
}
|
||||
else if (fallbackClip != null)
|
||||
{
|
||||
src.PlayOneShot(fallbackClip);
|
||||
}
|
||||
}
|
||||
else if (fallbackClip != null)
|
||||
{
|
||||
// no dedicated source provided: play via a temporary one-shot using AudioSource.PlayClipAtPoint at camera position
|
||||
AudioSource.PlayClipAtPoint(fallbackClip, Camera.main != null ? Camera.main.transform.position : Vector3.zero);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7692a74982af114ca6d79307fc43676
|
||||
@@ -76,22 +76,18 @@ public class Note : BaseNote
|
||||
{
|
||||
Debug.Log($"{keyToPress}: Miss");
|
||||
judgeResult = "Miss";
|
||||
InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult);
|
||||
JudgeMiss();
|
||||
return;
|
||||
// Do not return here - let shared post-judge logic run so NotifyNoteHit is invoked for Miss as well
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"{keyToPress}: Miss");
|
||||
judgeResult = "Miss";
|
||||
InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult);
|
||||
JudgeMiss();
|
||||
return;
|
||||
// Do not return here - let shared post-judge logic run so NotifyNoteHit is invoked for Miss as well
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 兼容旧逻辑
|
||||
// fallback timing
|
||||
if (timeDifference <= 0.08f)
|
||||
{
|
||||
Debug.Log($"{keyToPress}: Perfect");
|
||||
@@ -106,17 +102,18 @@ public class Note : BaseNote
|
||||
{
|
||||
Debug.Log($"{keyToPress}: Miss");
|
||||
judgeResult = "Miss";
|
||||
InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult);
|
||||
JudgeMiss();
|
||||
return;
|
||||
// Do not return here - let shared post-judge logic run so NotifyNoteHit is invoked for Miss as well
|
||||
}
|
||||
}
|
||||
|
||||
// 显示判定文本
|
||||
// 显示判定信息
|
||||
if (!string.IsNullOrEmpty(judgeResult))
|
||||
{
|
||||
InputManager.Instance?.ShowJudgeResult(TrackIndex, judgeResult);
|
||||
teamUIController.Instance?.OnJudgeResult(judgeResult); // 新增:更新combo计数
|
||||
JudgeSoundManager.Instance?.PlayJudgeSound(judgeResult);
|
||||
teamUIController.Instance?.OnJudgeResult(judgeResult); // 更新combo计数
|
||||
// Notify SkillBuilder about this note hit so OnNoteHit skills may trigger (tap notes)
|
||||
try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, judgeResult, SkillDefinition.NoteTypeTrigger.Tap); } catch { }
|
||||
}
|
||||
Judge();
|
||||
}
|
||||
@@ -152,7 +149,10 @@ public class Note : BaseNote
|
||||
Debug.Log($"{keyToPress} Miss");
|
||||
// Ensure UI shows Miss and combo is updated when a note auto-misses
|
||||
InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss");
|
||||
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
|
||||
teamUIController.Instance?.OnJudgeResult("Miss");
|
||||
// Notify SkillBuilder about Miss so OnNoteHit skills configured for Miss can trigger
|
||||
try { SkillBuilder.Instance?.NotifyNoteHit(TrackIndex, "Miss", SkillDefinition.NoteTypeTrigger.Tap); } catch { }
|
||||
// InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss"); // 已在HandlePress中调用 for manual presses
|
||||
ReturnToPool();
|
||||
}
|
||||
@@ -188,6 +188,7 @@ public class Note : BaseNote
|
||||
{
|
||||
// Show Miss and update combo
|
||||
InputManager.Instance?.ShowJudgeResult(TrackIndex, "Miss");
|
||||
JudgeSoundManager.Instance?.PlayJudgeSound("Miss");
|
||||
teamUIController.Instance?.OnJudgeResult("Miss");
|
||||
JudgeMiss();
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ using UnityEngine;
|
||||
public class NoteJudgeConfig : ScriptableObject
|
||||
{
|
||||
[Header("判定区间(单位:秒)")]
|
||||
[Tooltip("Perfect 判定最大时间偏差")]
|
||||
[Tooltip("Perfect")]
|
||||
public float perfectRange = 0.05f;
|
||||
[Tooltip("Great 判定最大时间偏差")]
|
||||
[Tooltip("Great")]
|
||||
public float greatRange = 0.1f;
|
||||
[Tooltip("Good 判定最大时间偏差")]
|
||||
[Tooltip("Good")]
|
||||
public float goodRange = 0.2f;
|
||||
[Tooltip("Miss 判定最大时间偏差")]
|
||||
[Tooltip("Miss")]
|
||||
public float missRange = 0.3f;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ public class NoteSpawner : MonoBehaviour
|
||||
public float spawnOffset = 0f; // 生成音符时的时间偏移量
|
||||
public float bpm;
|
||||
|
||||
[Header("Global timing adjustments")]
|
||||
[Tooltip("Global additional realtime offset (seconds) added to hit times for all notes. Use to test input latency or adjust judgement timing. Default 0. Can be negative to make notes arrive earlier.")]
|
||||
public float globalHitDelay = 0f;
|
||||
|
||||
public NoteJudgeConfig judgeConfig; // 判定区间配置,需在 Inspector 赋值
|
||||
|
||||
private Beatmap beatmap;
|
||||
@@ -108,8 +112,9 @@ public class NoteSpawner : MonoBehaviour
|
||||
if (noteScript != null)
|
||||
{
|
||||
float noteSpawnTime = Time.time;
|
||||
// pass realtime hit time to Note.Setup
|
||||
float realtimeHit = startTime + noteData.time;
|
||||
// pass realtime hit time to Note.Setup, include globalHitDelay
|
||||
float rawHit = startTime + noteData.time + globalHitDelay;
|
||||
float realtimeHit = Mathf.Max(0f, rawHit); // clamp to non-negative
|
||||
noteScript.Setup(key, noteData.trackIndex, CalculateSpeed(), realtimeHit, noteData.color, judgeConfig);
|
||||
if (GameConfig.verboseLogs) Debug.Log($"到达时间:{noteSpawnTime + (60f / bpm) * 4}");
|
||||
}
|
||||
@@ -152,7 +157,8 @@ public class NoteSpawner : MonoBehaviour
|
||||
|
||||
Transform spawnPoint = spawnPoints[noteData.trackIndex];
|
||||
// scheduledEndTime 使用谱面时间(note.time + length),但转换为实时
|
||||
float scheduledEndTime = startTime + (noteData.time + noteData.length);
|
||||
float rawScheduledEnd = startTime + (noteData.time + noteData.length) + globalHitDelay;
|
||||
float scheduledEndTime = Mathf.Max(0f, rawScheduledEnd); // clamp to non-negative
|
||||
|
||||
// 生成唯一 id(改为自增计数器,避免随机冲突)
|
||||
int holdNoteId = ++holdNoteIdCounter;
|
||||
@@ -170,8 +176,10 @@ public class NoteSpawner : MonoBehaviour
|
||||
HoldNote holdNote = startObj.GetComponent<HoldNote>();
|
||||
if (holdNote != null)
|
||||
{
|
||||
// pass realtime hit time (startTime + note.time) and delay 0
|
||||
holdNote.Setup(holdNoteId, noteData.trackIndex, CalculateSpeed(), startTime + noteData.time, 0f, false, scheduledEndTime, noteData.color, key, "start", judgeConfig);
|
||||
// pass realtime hit time (startTime + note.time) and delay 0, include globalHitDelay
|
||||
float rawStartHit = startTime + noteData.time + globalHitDelay;
|
||||
float startHit = Mathf.Max(0f, rawStartHit);
|
||||
holdNote.Setup(holdNoteId, noteData.trackIndex, CalculateSpeed(), startHit, 0f, false, scheduledEndTime, noteData.color, key, "start", judgeConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -198,7 +206,9 @@ public class NoteSpawner : MonoBehaviour
|
||||
if (holdSeg != null)
|
||||
{
|
||||
// 中段都标记为 middle,pass realtime base time and segmentDelay
|
||||
holdSeg.Setup(holdNoteId, noteData.trackIndex, CalculateSpeed(), startTime + noteData.time, segmentDelay, false, scheduledEndTime, noteData.color, key, "middle", judgeConfig);
|
||||
float rawBase = startTime + noteData.time + globalHitDelay;
|
||||
float baseHit = Mathf.Max(0f, rawBase);
|
||||
holdSeg.Setup(holdNoteId, noteData.trackIndex, CalculateSpeed(), baseHit, segmentDelay, false, scheduledEndTime, noteData.color, key, "middle", judgeConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -226,7 +236,9 @@ public class NoteSpawner : MonoBehaviour
|
||||
{
|
||||
// 将 delay 设置为整个 hold 长度 (或 segmentCount * actualSegmentInterval),确保尾部在最后中段之后
|
||||
float endDelay = segmentCount * actualSegmentInterval; // 通常等于 noteData.length
|
||||
holdEnd.Setup(holdNoteId, noteData.trackIndex, CalculateSpeed(), startTime + noteData.time, endDelay, true, scheduledEndTime, noteData.color, key, "end", judgeConfig);
|
||||
float rawBase = startTime + noteData.time + globalHitDelay;
|
||||
float baseHit = Mathf.Max(0f, rawBase);
|
||||
holdEnd.Setup(holdNoteId, noteData.trackIndex, CalculateSpeed(), baseHit, endDelay, true, scheduledEndTime, noteData.color, key, "end", judgeConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections.Generic;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
public class teamUIController : MonoBehaviour
|
||||
{
|
||||
@@ -9,6 +13,480 @@ public class teamUIController : MonoBehaviour
|
||||
private set;
|
||||
}
|
||||
|
||||
// --- 新增:队伍槽 ID 列表(从上到下 5 个友军) ---
|
||||
[Header("Runtime ally configuration")]
|
||||
[Tooltip("IDs for the 5 ally slots (top to bottom). These are used by PopulateAllySOsFromIds to resolve SOs into currentAllySOs.")]
|
||||
public List<int> allySlotIds = new List<int> { 0, 0, 0, 0, 0 };
|
||||
|
||||
// --- 新增:解析后的当前五个 SO 引用(按顺序) ---
|
||||
private TeamCharacterDataInfo[] currentAllySOs = new TeamCharacterDataInfo[5];
|
||||
|
||||
// previous active flags for detecting external changes
|
||||
private bool[] prevAllyActive = new bool[5];
|
||||
|
||||
// 两个路径字段:一个用于编辑器查找(可以填写 Assets/Resources/so/ally 或磁盘绝对路径),
|
||||
// 一个用于运行时代码查找(Resources 相对路径,例如 so/ally)
|
||||
[Header("SO folder paths")]
|
||||
[Tooltip("Editor-only: project path or absolute disk path to the folder that contains SOs. Example: Assets/Resources/so/ally")]
|
||||
public string editorSOFolderPath = "Assets/Resources/so/ally";
|
||||
|
||||
[Tooltip("Runtime: Resources subfolder path (no 'Resources/' prefix). Example: so/ally")]
|
||||
public string runtimeResourcesFolderPath = "so/ally";
|
||||
|
||||
// Optional per-ally object to toggle together with isAllyX_active. If null falls back to objectFather_allyXX.
|
||||
[Header("Ally runtime objects (optional)")]
|
||||
public GameObject ally01_object;
|
||||
public GameObject ally02_object;
|
||||
public GameObject ally03_object;
|
||||
public GameObject ally04_object;
|
||||
public GameObject ally05_object;
|
||||
|
||||
// 兼容旧字段(可选)
|
||||
[Header("Editor folder load (Editor only)")]
|
||||
[Tooltip("Deprecated: kept for compatibility. Use editorSOFolderPath or runtimeResourcesFolderPath instead.")]
|
||||
public string selectedProjectFolderPath = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 在运行/编辑器中调用:根据 allySlotIds(5 个 int)在指定文件夹中搜索对应的 TeamCharacterDataInfo 资产。
|
||||
/// Editor: 使用 editorSOFolderPath / selectedProjectFolderPath;Runtime: 使用 runtimeResourcesFolderPath / selectedProjectFolderPath。
|
||||
/// </summary>
|
||||
public void PopulateAllySOsFromIds()
|
||||
{
|
||||
if (allySlotIds == null || allySlotIds.Count < 5)
|
||||
{
|
||||
Debug.LogWarning("allySlotIds 未正确设置,自动填充为 5 个占位 ID");
|
||||
allySlotIds = new List<int> { 0, 0, 0, 0, 0 };
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Choose editor folder path preferentially from editorSOFolderPath, fallback to selectedProjectFolderPath
|
||||
string folderToUse = !string.IsNullOrEmpty(editorSOFolderPath) ? editorSOFolderPath : selectedProjectFolderPath;
|
||||
|
||||
// Ensure folderToUse points inside the project Assets folder when possible
|
||||
string projectRelative = null;
|
||||
if (!string.IsNullOrEmpty(folderToUse))
|
||||
{
|
||||
string projectPath = folderToUse.Replace("\\", "/");
|
||||
if (projectPath.StartsWith(Application.dataPath))
|
||||
{
|
||||
projectRelative = "Assets" + projectPath.Substring(Application.dataPath.Length);
|
||||
}
|
||||
else if (projectPath.StartsWith("Assets/"))
|
||||
{
|
||||
projectRelative = projectPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("editorSOFolderPath 必须位于项目的 Assets 文件夹下以便进行查询(建议填写 Assets/Resources/so/ally)。回退到全局列表查找。路径也可填写为磁盘绝对路径。");
|
||||
}
|
||||
}
|
||||
|
||||
// Collect TeamCharacterDataInfo and AllyHero_SO assets under the folder (or project-wide)
|
||||
List<Object> discovered = new List<Object>();
|
||||
if (!string.IsNullOrEmpty(projectRelative))
|
||||
{
|
||||
string[] guids = AssetDatabase.FindAssets("t:TeamCharacterDataInfo", new[] { projectRelative });
|
||||
foreach (var g in guids)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(g);
|
||||
var so = AssetDatabase.LoadAssetAtPath<TeamCharacterDataInfo>(assetPath);
|
||||
if (so != null) discovered.Add(so);
|
||||
}
|
||||
string[] guids2 = AssetDatabase.FindAssets("t:AllyHero_SO", new[] { projectRelative });
|
||||
foreach (var g in guids2)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(g);
|
||||
var so = AssetDatabase.LoadAssetAtPath<AllyHero_SO>(assetPath);
|
||||
if (so != null) discovered.Add(so);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// no valid folder chosen -> search entire project
|
||||
string[] guids = AssetDatabase.FindAssets("t:TeamCharacterDataInfo");
|
||||
foreach (var g in guids)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(g);
|
||||
var so = AssetDatabase.LoadAssetAtPath<TeamCharacterDataInfo>(assetPath);
|
||||
if (so != null) discovered.Add(so);
|
||||
}
|
||||
string[] guids2 = AssetDatabase.FindAssets("t:AllyHero_SO");
|
||||
foreach (var g in guids2)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(g);
|
||||
var so = AssetDatabase.LoadAssetAtPath<AllyHero_SO>(assetPath);
|
||||
if (so != null) discovered.Add(so);
|
||||
}
|
||||
}
|
||||
|
||||
TeamCharacterList globalList = null;
|
||||
if (teamSettingPanel.Instance != null)
|
||||
globalList = teamSettingPanel.Instance.teamCharacterList;
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
int id = allySlotIds[i];
|
||||
TeamCharacterDataInfo found = null;
|
||||
|
||||
// 如果 slot ID 为 0 -> 关闭对应 ally UI 并跳过
|
||||
if (id == 0)
|
||||
{
|
||||
currentAllySOs[i] = null;
|
||||
switch (i)
|
||||
{
|
||||
case 0: isAlly01_active = false; break;
|
||||
case 1: isAlly02_active = false; break;
|
||||
case 2: isAlly03_active = false; break;
|
||||
case 3: isAlly04_active = false; break;
|
||||
case 4: isAlly05_active = false; break;
|
||||
}
|
||||
ToggleAllyObject(i, false);
|
||||
Debug.Log($"[teamUIController] slot {i+1} id=0 -> disabled");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只要 asset 的路径或名称包含 id 即可引用(Editor)
|
||||
for (int j = 0; j < discovered.Count; j++)
|
||||
{
|
||||
var obj = discovered[j];
|
||||
if (obj == null) continue;
|
||||
string path = AssetDatabase.GetAssetPath(obj);
|
||||
if (!string.IsNullOrEmpty(path) && path.Contains(id.ToString()))
|
||||
{
|
||||
// if it's already TeamCharacterDataInfo, use it
|
||||
if (obj is TeamCharacterDataInfo tc)
|
||||
{
|
||||
found = tc;
|
||||
Debug.Log($"[teamUIController] (Editor) matched asset by path: {path} for id={id} slot={i+1}");
|
||||
break;
|
||||
}
|
||||
// if it's AllyHero_SO, map to a runtime TeamCharacterDataInfo instance
|
||||
if (obj is AllyHero_SO ah)
|
||||
{
|
||||
var mapped = ScriptableObject.CreateInstance<TeamCharacterDataInfo>();
|
||||
mapped.CharacterID = ah.ally_heroID;
|
||||
mapped.CharacterName = ah.ally_heroName;
|
||||
mapped.CharacterCardSprite = ah.ally_heroImage;
|
||||
mapped.CharacterTeamSprite = ah.ally_heroProfile;
|
||||
mapped.name = ah.name;
|
||||
found = mapped;
|
||||
Debug.Log($"[teamUIController] (Editor) matched AllyHero_SO by path: {path} for id={id} slot={i+1}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到全局 teamCharacterList
|
||||
if (found == null && globalList != null && globalList.characters != null)
|
||||
{
|
||||
for (int j = 0; j < globalList.characters.Count; j++)
|
||||
{
|
||||
var so = globalList.characters[j];
|
||||
if (so == null) continue;
|
||||
// globalList 中也以 name 包含 id 为准
|
||||
if (!string.IsNullOrEmpty(so.name) && so.name.Contains(id.ToString()))
|
||||
{
|
||||
found = so;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentAllySOs[i] = found;
|
||||
|
||||
if (found == null)
|
||||
{
|
||||
Debug.LogWarning($"[teamUIController] 未找到 id={id} 对应的 SO (slot {i + 1})");
|
||||
// disable UI slot when not found
|
||||
switch (i)
|
||||
{
|
||||
case 0: isAlly01_active = false; break;
|
||||
case 1: isAlly02_active = false; break;
|
||||
case 2: isAlly03_active = false; break;
|
||||
case 3: isAlly04_active = false; break;
|
||||
case 4: isAlly05_active = false; break;
|
||||
}
|
||||
ToggleAllyObject(i, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[teamUIController] (Editor) slot {i + 1} resolved to SO: {found.name} (id={found.CharacterID})");
|
||||
// 确保对应 UI slot 被开启
|
||||
switch (i)
|
||||
{
|
||||
case 0: isAlly01_active = true; break;
|
||||
case 1: isAlly02_active = true; break;
|
||||
case 2: isAlly03_active = true; break;
|
||||
case 3: isAlly04_active = true; break;
|
||||
case 4: isAlly05_active = true; break;
|
||||
}
|
||||
ToggleAllyObject(i, true);
|
||||
DebugCharacterSO(found, i+1, true);
|
||||
// Also attempt to set the teammate character image from an underlying AllyHero_SO if available
|
||||
try
|
||||
{
|
||||
// first try to find AllyHero_SO by the same id
|
||||
var ahList = Resources.LoadAll<AllyHero_SO>("");
|
||||
AllyHero_SO matched = null;
|
||||
foreach (var a in ahList) if (a != null && a.ally_heroID == id) { matched = a; break; }
|
||||
if (matched != null)
|
||||
{
|
||||
var sprite = matched.ally_heroProfile != null ? matched.ally_heroProfile : matched.ally_heroImage;
|
||||
SetTeammateCharacterImage(i, sprite);
|
||||
}
|
||||
else
|
||||
{
|
||||
// fallback: if current TeamCharacterDataInfo has a TeamSprite, use that
|
||||
var tc = found as TeamCharacterDataInfo;
|
||||
if (tc != null && tc.CharacterTeamSprite != null)
|
||||
SetTeammateCharacterImage(i, tc.CharacterTeamSprite);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
#else
|
||||
// Runtime: prefer runtimeResourcesFolderPath, fallback to selectedProjectFolderPath
|
||||
string resourcesPath = ResolveResourcesRelativePath(!string.IsNullOrEmpty(runtimeResourcesFolderPath) ? runtimeResourcesFolderPath : selectedProjectFolderPath);
|
||||
|
||||
List<Object> runtimeDiscovered = new List<Object>();
|
||||
if (!string.IsNullOrEmpty(resourcesPath))
|
||||
{
|
||||
resourcesPath = resourcesPath.Trim('/');
|
||||
try
|
||||
{
|
||||
var arr = Resources.LoadAll(resourcesPath);
|
||||
if (arr != null && arr.Length > 0)
|
||||
runtimeDiscovered.AddRange(arr);
|
||||
|
||||
// If nothing loaded from the specific folder, fallback to loading all and filter by name later
|
||||
if (runtimeDiscovered.Count == 0)
|
||||
{
|
||||
var arrAll = Resources.LoadAll("");
|
||||
if (arrAll != null && arrAll.Length > 0)
|
||||
{
|
||||
runtimeDiscovered.AddRange(arrAll);
|
||||
Debug.LogWarning($"[teamUIController] (Runtime) Resources.LoadAll returned 0 in '{resourcesPath}', fell back to loading all ScriptableObjects ({arrAll.Length})");
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[teamUIcontroller] (Runtime) Loaded {runtimeDiscovered.Count} objects from Resources/{resourcesPath}");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[teamUIController] (Runtime) Resources.LoadAll failed for '{resourcesPath}': {ex.Message}");
|
||||
var arrAll = Resources.LoadAll("");
|
||||
if (arrAll != null && arrAll.Length > 0)
|
||||
runtimeDiscovered.AddRange(arrAll);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var arr = Resources.LoadAll("");
|
||||
if (arr != null && arr.Length > 0)
|
||||
runtimeDiscovered.AddRange(arr);
|
||||
Debug.Log($"[teamUIController] (Runtime) Loaded {arr?.Length ?? 0} objects from Resources (project-wide)");
|
||||
}
|
||||
|
||||
TeamCharacterList globalList = null;
|
||||
if (teamSettingPanel.Instance != null)
|
||||
globalList = teamSettingPanel.Instance.teamCharacterList;
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
int id = allySlotIds[i];
|
||||
TeamCharacterDataInfo found = null;
|
||||
|
||||
// 如果 slot ID 为 0 -> 关闭对应 ally UI 并跳过
|
||||
if (id == 0)
|
||||
{
|
||||
currentAllySOs[i] = null;
|
||||
switch (i)
|
||||
{
|
||||
case 0: isAlly01_active = false; break;
|
||||
case 1: isAlly02_active = false; break;
|
||||
case 2: isAlly03_active = false; break;
|
||||
case 3: isAlly04_active = false; break;
|
||||
case 4: isAlly05_active = false; break;
|
||||
}
|
||||
// also toggle associated object
|
||||
ToggleAllyObject(i, false);
|
||||
|
||||
Debug.Log($"[teamUIController] (Runtime) slot {i+1} id=0 -> disabled");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只要资源名包含 id 即可引用(Runtime)。支持 TeamCharacterDataInfo 与 AllyHero_SO
|
||||
string runtimeSourceInfo = string.Empty;
|
||||
for (int j = 0; j < runtimeDiscovered.Count; j++)
|
||||
{
|
||||
var obj = runtimeDiscovered[j];
|
||||
if (obj == null) continue;
|
||||
// name match first
|
||||
if (!string.IsNullOrEmpty(obj.name) && obj.name.Contains(id.ToString()))
|
||||
{
|
||||
if (obj is TeamCharacterDataInfo tc)
|
||||
{
|
||||
found = tc; break;
|
||||
}
|
||||
if (obj is AllyHero_SO ah)
|
||||
{
|
||||
var mapped = ScriptableObject.CreateInstance<TeamCharacterDataInfo>();
|
||||
mapped.CharacterID = ah.ally_heroID;
|
||||
mapped.CharacterName = ah.ally_heroName;
|
||||
mapped.CharacterCardSprite = ah.ally_heroImage;
|
||||
mapped.CharacterTeamSprite = ah.ally_heroProfile;
|
||||
found = mapped; break;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: if object is TeamCharacterDataInfo, check its CharacterID
|
||||
if (obj is TeamCharacterDataInfo tco && tco.CharacterID == id)
|
||||
{
|
||||
found = tco; break;
|
||||
}
|
||||
// fallback: AllyHero_SO CharacterID equivalent
|
||||
if (obj is AllyHero_SO ah2 && ah2.ally_heroID == id)
|
||||
{
|
||||
var mapped2 = ScriptableObject.CreateInstance<TeamCharacterDataInfo>();
|
||||
mapped2.CharacterID = ah2.ally_heroID;
|
||||
mapped2.CharacterName = ah2.ally_heroName;
|
||||
mapped2.CharacterCardSprite = ah2.ally_heroImage;
|
||||
mapped2.CharacterTeamSprite = ah2.ally_heroProfile;
|
||||
found = mapped2; break;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到全局 teamCharacterList
|
||||
if (found == null && globalList != null && globalList.characters != null)
|
||||
{
|
||||
for (int j = 0; j < globalList.characters.Count; j++)
|
||||
{
|
||||
var so = globalList.characters[j];
|
||||
if (so == null) continue;
|
||||
if (!string.IsNullOrEmpty(so.name) && so.name.Contains(id.ToString()))
|
||||
{
|
||||
found = so;
|
||||
break;
|
||||
}
|
||||
if (so.CharacterID == id)
|
||||
{
|
||||
found = so;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// final fallback: try loading by resource path/id if resourcesPath provided
|
||||
if (found == null && !string.IsNullOrEmpty(resourcesPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var byId = Resources.Load<TeamCharacterDataInfo>($"{resourcesPath}/{id}");
|
||||
if (byId != null) found = byId;
|
||||
if (found != null) runtimeSourceInfo = $"Resources.Load: {resourcesPath}/{id}";
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
currentAllySOs[i] = found;
|
||||
|
||||
if (found == null)
|
||||
{
|
||||
Debug.LogWarning($"[teamUIController] (Runtime) 未找到 id={id} 对应的 SO (slot {i + 1})");
|
||||
switch (i)
|
||||
{
|
||||
case 0: isAlly01_active = false; break;
|
||||
case 1: isAlly02_active = false; break;
|
||||
case 2: isAlly03_active = false; break;
|
||||
case 3: isAlly04_active = false; break;
|
||||
case 4: isAlly05_active = false; break;
|
||||
}
|
||||
ToggleAllyObject(i, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// try to get a readable source for the matched object
|
||||
if (string.IsNullOrEmpty(runtimeSourceInfo))
|
||||
{
|
||||
runtimeSourceInfo = found.name + " (mapped)";
|
||||
}
|
||||
Debug.Log($"[teamUIcontroller] (Runtime) slot {i + 1} resolved to SO: {found.name} (id={found.CharacterID}) Source={runtimeSourceInfo}");
|
||||
switch (i)
|
||||
{
|
||||
case 0: isAlly01_active = true; break;
|
||||
case 1: isAlly02_active = true; break;
|
||||
case 2: isAlly03_active = true; break;
|
||||
case 3: isAlly04_active = true; break;
|
||||
case 4: isAlly05_active = true; break;
|
||||
}
|
||||
ToggleAllyObject(i, true);
|
||||
DebugCharacterSO(found, i+1, false);
|
||||
// attempt to set teammate image from underlying AllyHero_SO if possible
|
||||
try
|
||||
{
|
||||
var ahList = Resources.LoadAll<AllyHero_SO>("");
|
||||
AllyHero_SO matched = null;
|
||||
foreach (var a in ahList) if (a != null && a.ally_heroID == id) { matched = a; break; }
|
||||
if (matched != null)
|
||||
{
|
||||
var sprite = matched.ally_heroProfile != null ? matched.ally_heroProfile : matched.ally_heroImage;
|
||||
SetTeammateCharacterImage(i, sprite);
|
||||
}
|
||||
else
|
||||
{
|
||||
var tc = found as TeamCharacterDataInfo;
|
||||
if (tc != null && tc.CharacterTeamSprite != null)
|
||||
SetTeammateCharacterImage(i, tc.CharacterTeamSprite);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Resolve various forms of selectedProjectFolderPath into a Resources.Load relative path (no 'Resources/' prefix, no extension)
|
||||
private string ResolveResourcesRelativePath(string selectedPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(selectedPath)) return string.Empty;
|
||||
string p = selectedPath.Replace("\\", "/");
|
||||
// If user provided a path starting with "Assets/Resources/", strip to get relative
|
||||
int idx = p.IndexOf("Assets/Resources/");
|
||||
if (idx >= 0)
|
||||
{
|
||||
return p.Substring(idx + "Assets/Resources/".Length).Trim('/');
|
||||
}
|
||||
// If user provided an absolute path that contains Application.dataPath and Resources
|
||||
string appData = Application.dataPath.Replace("\\", "/");
|
||||
idx = p.IndexOf(appData + "/Resources/");
|
||||
if (idx >= 0)
|
||||
{
|
||||
return p.Substring((appData + "/Resources/").Length).Trim('/');
|
||||
}
|
||||
// If user already provided a Resources relative path (like "so/ally"), return it trimmed
|
||||
if (p.StartsWith("Resources/")) p = p.Substring("Resources/".Length);
|
||||
return p.Trim('/');
|
||||
}
|
||||
|
||||
// Debug helper to print key fields of a TeamCharacterDataInfo
|
||||
private void DebugCharacterSO(TeamCharacterDataInfo so, int slotIndex, bool isEditor)
|
||||
{
|
||||
if (so == null) return;
|
||||
string mode = isEditor ? "Editor" : "Runtime";
|
||||
string spriteCard = so.CharacterCardSprite != null ? so.CharacterCardSprite.name : "(null)";
|
||||
string spriteTeam = so.CharacterTeamSprite != null ? so.CharacterTeamSprite.name : "(null)";
|
||||
Debug.Log($"[teamUIController] ({mode}) Slot {slotIndex} -> CharacterID={so.CharacterID}, Name={so.CharacterName}, Skill={so.CharacterSkillName}, MaxHealth={so.CharacterMaxHealth}, CardSprite={spriteCard}, TeamSprite={spriteTeam}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回当前解析到的 SO 列表(数组副本)
|
||||
/// </summary>
|
||||
public TeamCharacterDataInfo[] GetCurrentAllySOs()
|
||||
{
|
||||
return (TeamCharacterDataInfo[])currentAllySOs.Clone();
|
||||
}
|
||||
|
||||
[Header("基本文本")]
|
||||
[Tooltip("title")]
|
||||
public TextMeshProUGUI songNametitle;
|
||||
@@ -22,7 +500,7 @@ public class teamUIController : MonoBehaviour
|
||||
[SerializeField] private int enemyCounterMax; // 本局游戏敌人最大数
|
||||
[SerializeField] private int enemyCurrentCount; // 现在的敌人顺序
|
||||
[Tooltip("当前总分")]
|
||||
public Text currentTotalScore;
|
||||
public TextMeshProUGUI currentTotalScore;
|
||||
|
||||
[Header("Combo判定最低要求")]
|
||||
public ComboJudgeType comboJudgeType = ComboJudgeType.Perfect;
|
||||
@@ -232,13 +710,32 @@ public class teamUIController : MonoBehaviour
|
||||
// Start is called once before the first execution of Update after the MonoBehaviour is created
|
||||
void Start()
|
||||
{
|
||||
PopulateAllySOsFromIds();
|
||||
|
||||
// initialize previous flags so Update can detect changes
|
||||
prevAllyActive[0] = isAlly01_active;
|
||||
prevAllyActive[1] = isAlly02_active;
|
||||
prevAllyActive[2] = isAlly03_active;
|
||||
prevAllyActive[3] = isAlly04_active;
|
||||
prevAllyActive[4] = isAlly05_active;
|
||||
|
||||
// Ensure score text references are resolved so ScoreManager and AllyCombatant can write to them
|
||||
ResolveScoreTextReferences();
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update()
|
||||
{
|
||||
|
||||
// detect external changes to isAllyX_active and toggle objects accordingly
|
||||
bool[] current = new bool[] { isAlly01_active, isAlly02_active, isAlly03_active, isAlly04_active, isAlly05_active };
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
if (prevAllyActive[i] != current[i])
|
||||
{
|
||||
ToggleAllyObject(i, current[i]);
|
||||
prevAllyActive[i] = current[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
@@ -289,5 +786,108 @@ public class teamUIController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helper to toggle ally object by slot index
|
||||
private void ToggleAllyObject(int slotIndex, bool active)
|
||||
{
|
||||
GameObject target = null;
|
||||
switch (slotIndex)
|
||||
{
|
||||
case 0: target = ally01_object ?? objectFather_ally01; break;
|
||||
case 1: target = ally02_object ?? objectFather_ally02; break;
|
||||
case 2: target = ally03_object ?? objectFather_ally03; break;
|
||||
case 3: target = ally04_object ?? objectFather_ally04; break;
|
||||
case 4: target = ally05_object ?? objectFather_ally05; break;
|
||||
}
|
||||
if (target != null)
|
||||
target.SetActive(active);
|
||||
}
|
||||
|
||||
// Try to ensure the teammateXX_current_scoreText fields are assigned; if null, search the corresponding parent for TMP/Text
|
||||
private void ResolveScoreTextReferences()
|
||||
{
|
||||
void TryResolve(ref TextMeshProUGUI field, GameObject parent, string slotName)
|
||||
{
|
||||
if (field != null) return;
|
||||
if (parent != null)
|
||||
{
|
||||
var tmp = parent.GetComponentInChildren<TextMeshProUGUI>(true);
|
||||
if (tmp != null)
|
||||
{
|
||||
field = tmp;
|
||||
Debug.Log($"[teamUIController] Resolved {slotName} TMP from parent {parent.name} -> {tmp.gameObject.name}");
|
||||
return;
|
||||
}
|
||||
var legacy = parent.GetComponentInChildren<Text>(true);
|
||||
if (legacy != null)
|
||||
{
|
||||
// if only legacy Text exists, try to create a TMP component to avoid type mismatch
|
||||
var go = legacy.gameObject;
|
||||
var created = go.GetComponent<TextMeshProUGUI>() ?? go.AddComponent<TextMeshProUGUI>();
|
||||
created.text = legacy.text;
|
||||
field = created;
|
||||
Debug.LogWarning($"[teamUIController] Found legacy Text for {slotName} under {parent.name}. Added/used TMP component on {go.name} and copied text.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
Debug.LogWarning($"[teamUIController] Could not resolve {slotName} TMP (parent '{parent?.name}')");
|
||||
}
|
||||
|
||||
TryResolve(ref teammate01_current_scoreText, objectFather_ally01, "teammate01_current_scoreText");
|
||||
TryResolve(ref teammate02_current_scoreText, objectFather_ally02, "teammate02_current_scoreText");
|
||||
TryResolve(ref teammate03_current_scoreText, objectFather_ally03, "teammate03_current_scoreText");
|
||||
TryResolve(ref teammate04_current_scoreText, objectFather_ally04, "teammate04_current_scoreText");
|
||||
TryResolve(ref teammate05_current_scoreText, objectFather_ally05, "teammate05_current_scoreText");
|
||||
|
||||
// total score: if assigned field is null, try to find any TMP in scene named "currentTotalScore" or under this object
|
||||
if (currentTotalScore == null)
|
||||
{
|
||||
var tmp = GetComponentInChildren<TextMeshProUGUI>(true);
|
||||
if (tmp != null)
|
||||
{
|
||||
currentTotalScore = tmp;
|
||||
Debug.Log($"[teamUIController] Resolved currentTotalScore from child {tmp.gameObject.name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回相邻的友军槽索引(左右),边侧只有一侧
|
||||
/// </summary>
|
||||
public int[] GetAdjacentAllyIndices(int slotIndex)
|
||||
{
|
||||
var list = new List<int>();
|
||||
if (slotIndex - 1 >= 0) list.Add(slotIndex - 1);
|
||||
if (slotIndex + 1 <= 4) list.Add(slotIndex + 1);
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回指定槽位对应的友军 GameObject(优先返回 allyX_object,否则返回 objectFather_allyXX),可能为 null
|
||||
/// </summary>
|
||||
public GameObject GetAllyObjectBySlot(int slotIndex)
|
||||
{
|
||||
switch (slotIndex)
|
||||
{
|
||||
case 0: return ally01_object ?? objectFather_ally01;
|
||||
case 1: return ally02_object ?? objectFather_ally02;
|
||||
case 2: return ally03_object ?? objectFather_ally03;
|
||||
case 3: return ally04_object ?? objectFather_ally04;
|
||||
case 4: return ally05_object ?? objectFather_ally05;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// helper to set teammate character image based on slot index
|
||||
private void SetTeammateCharacterImage(int slotIndex, Sprite sprite)
|
||||
{
|
||||
if (sprite == null) return;
|
||||
switch (slotIndex)
|
||||
{
|
||||
case 0: if (teammate01_characterImage != null) teammate01_characterImage.sprite = sprite; break;
|
||||
case 1: if (teammate02_characterImage != null) teammate02_characterImage.sprite = sprite; break;
|
||||
case 2: if (teammate03_characterImage != null) teammate03_characterImage.sprite = sprite; break;
|
||||
case 3: if (teammate04_characterImage != null) teammate04_characterImage.sprite = sprite; break;
|
||||
case 4: if (teammate05_characterImage != null) teammate05_characterImage.sprite = sprite; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user