using UnityEngine; using System; using System.Collections.Generic; using System.Globalization; [CreateAssetMenu(fileName = "NewSkillDefinition", menuName = "Combat/SkillDefinition")] public class SkillDefinition : ScriptableObject { // Cache compiled RPN for formulas to avoid re-tokenizing every trigger. private static readonly Dictionary> s_rpnCache = new Dictionary>(); private static readonly HashSet s_badFormulaCache = new HashSet(); public enum SkillTrigger { None, OnGameStart, OnNoteHit, OnManaFull, OnAttackZero, OnEnemyRevive, // Documentation text normalized. OnEnemyDead, // Documentation text normalized. // Documentation text normalized. OnAllEnemiesDefeated, // Documentation text normalized. OnHPHealed, // Documentation text normalized. OnHPLost, // Documentation text normalized. OnManaGained, // Documentation text normalized. OnManaLost, // Documentation text normalized. OnHPAbovePercent, // Documentation text normalized. OnHPBelowPercent, // New: attack threshold trigger (attack < attackTriggerValue) OnAttackBelowValue, // New: triggers once when this ally is defeated (HP reaches 0 from above). OnSelfDefeated, // New: triggers on this ally when an adjacent ally releases a skill (casts on mana full). OnAdjacentAllySkillCast, // New: attack threshold trigger (attack > attackTriggerValue) OnAttackAboveValue } [Header("Inspector")] [Tooltip("Documentation text normalized.")] public string skillId; [Tooltip("Documentation text normalized.")] public string displayName; [Tooltip("Documentation text normalized.")] [TextArea(1,2)] public string summaryInfo; [Header("Inspector")] [Tooltip("Documentation text normalized.")] public EffectType effectType = EffectType.DamageSingleEnemy; [Tooltip("Documentation text normalized.")] public Selector defaultSelector = Selector.CurrentEnemies; [Tooltip("Documentation text normalized.")] [TextArea(2,4)] public string formula = ""; [Tooltip("Documentation text normalized.")] public float defaultDuration = 0f; [Tooltip("Documentation text normalized.")] public float defaultTickInterval = 1f; [Header("Inspector")] [Tooltip("Documentation text normalized.")] public bool requiresSpecificTarget = false; // if true, skill usually targets a specific GameObject [Tooltip("Documentation text normalized.")] public bool operateDirectly = false; // if true, SkillBuilder will operate on ICombatant/AllyCombatant fields directly and update UI, instead of calling EffectSystem [Tooltip("Documentation text normalized.")] [TextArea(8,4)] public string description; [Header("Inspector")] [Tooltip("Documentation text normalized.")] public SkillTrigger triggerCondition = SkillTrigger.None; [Tooltip("Documentation text normalized.")] public NoteTriggerThreshold onNoteHitMinThreshold = NoteTriggerThreshold.Good; [Tooltip("Documentation text normalized.")] public NoteTypeTrigger noteTriggerType = NoteTypeTrigger.Either; [Tooltip("Documentation text normalized.")] public float onNoteHitCooldown = 0f; [Tooltip("Documentation text normalized.")] public float hpTriggerPercent = 0.5f; [Tooltip("Optional: for HP percent triggers, evaluate this formula (0..1) to get the threshold instead of hpTriggerPercent. Useful for per-level thresholds like 90/80/75/70.")] public string hpTriggerPercentFormula = ""; [Tooltip("For OnAttackBelowValue/OnAttackAboveValue: threshold for attack comparison.")] public int attackTriggerValue = 0; [Tooltip("Optional: if > 0 and repeatValue != 0, when the skill triggers again within this window, the computed amount is overridden by repeatValue.")] public float repeatWindowSeconds = 0f; public float repeatValue = 0f; [Header("技能专属")] [Tooltip("技能专属")] public specificBelongsTo specificBelongType = specificBelongsTo.None; public enum specificBelongsTo { None, Unique, Special } // Minimum judge quality required to trigger on note hit public enum NoteTriggerThreshold { // Documentation text normalized. Miss = 0, Good = 1, Great = 2, Perfect = 3 } // Documentation text normalized. public enum NoteTypeTrigger { Tap, Hold, Either } // Documentation text normalized. public bool IsSingleInstance => defaultDuration <= 0f || defaultTickInterval <= 0f; // Documentation text normalized. public float GetEffectiveTickInterval() { return IsSingleInstance ? 0f : Mathf.Max(0.0001f, defaultTickInterval); } private void OnValidate() { // Documentation text normalized. if (defaultDuration < 0f) defaultDuration = 0f; if (defaultTickInterval < 0f) defaultTickInterval = 0f; if (onNoteHitCooldown < 0f) onNoteHitCooldown = 0f; // clamp hpTriggerPercent if (hpTriggerPercent < 0f) hpTriggerPercent = 0f; if (hpTriggerPercent > 1f) hpTriggerPercent = 1f; if (attackTriggerValue < 0) attackTriggerValue = 0; if (repeatWindowSeconds < 0f) repeatWindowSeconds = 0f; } // --- Simple expression evaluator used when valueMode == FromFormula --- // Supports variables (alphanumeric), floats, operators + - * / and parentheses. public static bool TryEvaluateFormula(string expr, Dictionary variables, out float result) { result = 0f; if (string.IsNullOrWhiteSpace(expr)) return false; try { if (s_badFormulaCache.Contains(expr)) { return false; } if (!s_rpnCache.TryGetValue(expr, out var rpn) || rpn == null) { var tokens = Tokenize(expr); rpn = ToRPN(tokens); s_rpnCache[expr] = rpn; } result = EvalRPN(rpn, variables); return true; } catch (Exception e) { if (!s_badFormulaCache.Contains(expr)) { s_badFormulaCache.Add(expr); 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 Tokenize(string s) { var outp = new List(); 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(); 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 ToRPN(List tokens) { var output = new List(); var stack = new Stack(); 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 rpn, Dictionary vars) { var st = new Stack(); 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(); } }