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 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 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(); } }