Lua脚本事件检查工具

Unity工具---Lua脚本事件检查工具

1、目的

因为日常业务逻辑中有时候需要等一段时间执行某个操作的需求,所以项目中有通用的倒计时方法setTimer。

但是,使用setTimer时一定要记得在退出当前逻辑时调用removeTimer移除这个倒计时,否则容易引起不必要的报错。

因此,需要实现一个工具来检测代码中哪些Lua脚本注册了事件但没有在退出时移除事件。

2、思考

2.1简单判断可以吗?

如果只是简单的判断一个脚本中是否存在setTimer和removeTimer是不行的,因为一个脚本中可以设置多个timer,并且执行的回调方法各不相同,如果只是这样简单判断,就会漏掉这种情况。

2.2成组对应可以吗?

有多少个setTimer就对应多少个removeTimer,这样可以吗?

这样也不行。因为脚本中可以多次设置相同的方法,只在退出时移除一次就行,这样写是对的。

2.3记录设置的回调方法,去重,并逐个判断是否移除可以吗?

也不行,因为有可能退出时执行的不是removeTimer方法,而是一个自定义函数,例如执行removeAllTimer,在这个方法中再移除timer,这样写也是对的。

甚至,还有一种情况,一个Lua脚本继承另一个脚本,只重写其中一个方法,方法中存在setTimer方法,但是removeTimer方法在父类的退出函数中调用,这样写也是对的。

那么,有没有办法能完美检测出来上述情况呢?

有的,使用AST抽象语法树

本质上就是构建一个Lua脚本的方法定义映射以及调用映射,还要构造该脚本全部父类的方法映射和调用映射,并去重记录setTimer设置的回调方法,然后再通过抽象语法树,找到退出函数,检查退出函数中是否移除了对应回调方法的timer;如果没有移除,则检测在退出函数中执行的函数,若当前脚本存在该函数,检查该函数是否移除了对应回调方法的timer事件,如果还是没有移除,则按上述规则检测父类的情况,如果最终还是没有找到,则认为没有移除timer事件。

3、实现

csharp 复制代码
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;

public class LuaEventChecker
{
    #region ConfigAndConstants
    private static readonly Dictionary<string, string> LuaScriptDirDict = new Dictionary<string, string>
    {
        {"Logic",    GetProjectPath() + "Assets/Scripts/Lua/logic/"},
    };

    // Methods treated as "destroy functions". removetimer in this scope/call-chain is valid.
    public static readonly HashSet<string> DestroyFunctions = new HashSet<string>
    {
        "onExit", "destroy", "dispose", "onDispose", "onReset", "onExitScene", "stop",
        "onStop", "onExitFinished", "destroyUI", "OnDestroy", "_dispose", "_destroy"
    };

    private const int MaxRecursiveDepth = 10;
    #endregion

    #region RegexCache
    private static readonly Regex RegexClass = new Regex(@"class\s*\(.*?,\s*([\w\s,]+)\s*\)", RegexOptions.Compiled);
    private static readonly Regex RegexSetTimer = new Regex(@"settimer\(", RegexOptions.Compiled);
    private static readonly Regex RegexRemoveTimer = new Regex(@"removetimer\(", RegexOptions.Compiled);
    private static readonly Regex RegexFuncDef = new Regex(@"function\s+\w+:(\w+)\s*\(", RegexOptions.Compiled);
    private static readonly Regex RegexIfStart = new Regex(@"^\s*if\b.*\bthen\s*(--.*)?$", RegexOptions.Compiled);
    private static readonly Regex RegexForStart = new Regex(@"^\s*for\b.*\bdo\s*(--.*)?$", RegexOptions.Compiled);
    private static readonly Regex RegexWhileStart = new Regex(@"^\s*while\b.*\bdo\s*(--.*)?$", RegexOptions.Compiled);
    private static readonly Regex RegexDoStart = new Regex(@"^\s*do\s*(--.*)?$", RegexOptions.Compiled);
    private static readonly Regex RegexRepeatStart = new Regex(@"^\s*repeat\b.*$", RegexOptions.Compiled);
    private static readonly Regex RegexFunctionStart = new Regex(@"^\s*function\b.*$", RegexOptions.Compiled);
    private static readonly Regex RegexEndOnly = new Regex(@"^\s*end\b", RegexOptions.Compiled);
    private static readonly Regex RegexUntilOnly = new Regex(@"^\s*until\b", RegexOptions.Compiled);
    #endregion

    #region RuntimeCache
    private static readonly Dictionary<string, string> _classNameToPath = new Dictionary<string, string>();
    private static readonly Dictionary<string, LuaAstInfo> _pathToAst = new Dictionary<string, LuaAstInfo>();
    private static readonly Stopwatch _stopwatch = new Stopwatch();
    private static readonly List<string> _unremovedLoopTimerReportLines = new List<string>();
    private static readonly List<string> _unremovedNamedTimerReportLines = new List<string>();
    private static readonly List<string> _unremovedAnonymousTimerReportLines = new List<string>();
    private static bool _cancelCheck;
    #endregion

    [MenuItem("资源检查/Lua脚本工具/检查Lua脚本是否移除了settimer事件", false, 1000)]
    public static void LuaEventCheck()
    {
        _stopwatch.Restart();
        _cancelCheck = false;
        _classNameToPath.Clear();
        _pathToAst.Clear();
        _unremovedLoopTimerReportLines.Clear();
        _unremovedNamedTimerReportLines.Clear();
        _unremovedAnonymousTimerReportLines.Clear();
        EditorUtility.ClearProgressBar();

        Dictionary<string, List<string>> fileDict;
        int totalCount;
        ComputeAllFileAndCount(out fileDict, out totalCount);

        foreach (var kv in fileDict)
        {
            CheckFileGroup(kv.Value, totalCount);
        }

        ExportUnremovedTimerReport();
        EditorUtility.ClearProgressBar();
        _stopwatch.Stop();
        UnityEngine.Debug.Log($"LuaEventChecker 扫描完成,总耗时 {_stopwatch.ElapsedMilliseconds}ms");
    }

    #region MainFlow
    private static void ComputeAllFileAndCount(out Dictionary<string, List<string>> filesDic, out int totalFileCount)
    {
        totalFileCount = 0;
        filesDic = new Dictionary<string, List<string>>();
        foreach (var kv in LuaScriptDirDict)
        {
            if (!Directory.Exists(kv.Value))
            {
                UnityEngine.Debug.LogWarning($"目录不存在:{kv.Value}");
                continue;
            }

            var files = Directory.GetFiles(kv.Value, "*.lua", SearchOption.AllDirectories)
                                 .Select(p => p.Replace('\\', '/'))
                                 .ToList();
            filesDic.Add(kv.Key, files);
            totalFileCount += files.Count;
            CacheClassNames(files);
        }
    }

    private static void CheckFileGroup(List<string> files, int totalCount)
    {
        int checkedCount = 0;
        foreach (var path in files)
        {
            if (_cancelCheck) break;
            float progress = (float)checkedCount / totalCount;
            bool cancel = EditorUtility.DisplayCancelableProgressBar($"\u68C0\u67E5\u4E2D {checkedCount}/{totalCount}", path.Replace(GetProjectPath(), ""), progress);
            if (cancel) { _cancelCheck = true; break; }

            CheckSingleLuaFile(path);
            checkedCount++;
        }
    }

    public static void CheckSingleLuaFile(string path, int depth = 0)
    {
        if (_pathToAst.ContainsKey(path) || depth > MaxRecursiveDepth) return;
        var ast = new LuaAstInfo(path);
        _pathToAst.Add(path, ast);

        using var sr = new StreamReader(path, Encoding.UTF8);
        string line;
        int lineNum = 0;

        while ((line = sr.ReadLine()) != null)
        {
            lineNum++;
            if (line.TrimStart().StartsWith("--")) continue;

            if (lineNum <= 8) ParseParentClass(line, ast, path, depth);
            BuildAST(ast, line, lineNum);
        }

        // Output check results to both console and txt.
        var checkResult = ast.CheckUnRemovedTimer(_pathToAst);
        BuildResultMessages(path, checkResult);
    }
    #endregion

    #region AstBuild
    private static void ParseParentClass(string line, LuaAstInfo ast, string currentPath, int depth)
    {
        var match = RegexClass.Match(line);
        if (!match.Success) return;

        var parents = match.Groups[1].Value.Split(',').Select(s => s.Trim()).Where(s => !string.IsNullOrEmpty(s)).ToList();
        foreach (var cls in parents)
        {
            if (_classNameToPath.TryGetValue(cls, out string parentPath))
            {
                ast.ParentPaths.Add(parentPath);
                CheckSingleLuaFile(parentPath, depth + 1);
            }
        }
    }

    private static void BuildAST(LuaAstInfo ast, string line, int lineNum)
    {
        // Parse function definition and detect destroy scope.
        var funcMatch = RegexFuncDef.Match(line);
        if (funcMatch.Success)
        {
            ast.CurrentFunc = funcMatch.Groups[1].Value;
            ast.IsInDestroyFunc = DestroyFunctions.Contains(ast.CurrentFunc);
            ast.CurrentFuncBlockDepth = 1;
            return;
        }

        // Skip if we are not inside a function body.
        if (string.IsNullOrEmpty(ast.CurrentFunc)) return;

        // 1) Record settimer callbacks in current function.
        if (RegexSetTimer.IsMatch(line))
        {
            var callbackInfo = ExtractSetTimerCallback(line);
            if (callbackInfo != null)
            {
                ast.AddSetTimerCallback(callbackInfo, lineNum);
            }
        }

        // 2) Record removetimer callbacks in current function.
        if (RegexRemoveTimer.IsMatch(line))
        {
            string cb = ExtractRemoveTimerCallback(line);
            if (!string.IsNullOrEmpty(cb)) ast.AddRemovedCallbackInCurrentFunc(cb);
        }

        // 3) Build function call graph in destroy scope.
        if (ast.IsInDestroyFunc)
        {
            var funcCalls = ExtractAllFuncCalls(line);
            foreach (var func in funcCalls)
            {
                ast.AddDestroyChildFunc(func);
            }
        }

        UpdateFunctionBlockDepth(ast, line);
    }

    private static void UpdateFunctionBlockDepth(LuaAstInfo ast, string line)
    {
        if (string.IsNullOrEmpty(ast.CurrentFunc)) return;

        int delta = 0;
        if (RegexIfStart.IsMatch(line)) delta++;
        if (RegexForStart.IsMatch(line)) delta++;
        if (RegexWhileStart.IsMatch(line)) delta++;
        if (RegexDoStart.IsMatch(line)) delta++;
        if (RegexRepeatStart.IsMatch(line)) delta++;
        if (RegexFunctionStart.IsMatch(line)) delta++;
        if (RegexEndOnly.IsMatch(line)) delta--;
        if (RegexUntilOnly.IsMatch(line)) delta--;

        ast.CurrentFuncBlockDepth += delta;
        if (ast.CurrentFuncBlockDepth <= 0)
        {
            ast.CurrentFunc = string.Empty;
            ast.IsInDestroyFunc = false;
            ast.CurrentFuncBlockDepth = 0;
        }
    }

    // Extract callback argument and loop flag from settimer.
    private static TimerCallbackInfo ExtractSetTimerCallback(string line)
    {
        try
        {
            var argsMatch = Regex.Match(line, @"settimer\s*\((?<args>.*)\)\s*$", RegexOptions.IgnoreCase);
            if (!argsMatch.Success) return null;

            string argsRaw = argsMatch.Groups["args"].Value;
            var args = SplitLuaArgs(argsRaw);
            if (args.Count < 2) return null;

            string callbackArg = args[1].Trim();
            bool isLoop = ParseIsLoopTimer(args);
            if (callbackArg.StartsWith("function", StringComparison.OrdinalIgnoreCase))
            {
                return new TimerCallbackInfo("__anonymous__", true, isLoop);
            }

            string callbackName = callbackArg.TrimEnd(')').Split('.', ':').Last();
            return string.IsNullOrEmpty(callbackName) ? null : new TimerCallbackInfo(callbackName, false, isLoop);
        }
        catch { return null; }
    }

    private static bool ParseIsLoopTimer(List<string> args)
    {
        // Convention: settimer(interval, callback, self) is treated as loop;
        // explicit 4th arg true is also loop.
        if (args.Count == 3) return true;
        if (args.Count >= 4)
        {
            return string.Equals(args[3].Trim(), "true", StringComparison.OrdinalIgnoreCase);
        }

        return false;
    }

    private static List<string> SplitLuaArgs(string input)
    {
        var result = new List<string>();
        if (string.IsNullOrWhiteSpace(input)) return result;

        var sb = new StringBuilder();
        int round = 0;
        int square = 0;
        int curly = 0;
        bool inSingleQuote = false;
        bool inDoubleQuote = false;

        for (int i = 0; i < input.Length; i++)
        {
            char c = input[i];
            char prev = i > 0 ? input[i - 1] : '\0';

            if (c == '\'' && !inDoubleQuote && prev != '\\') inSingleQuote = !inSingleQuote;
            else if (c == '"' && !inSingleQuote && prev != '\\') inDoubleQuote = !inDoubleQuote;

            if (!inSingleQuote && !inDoubleQuote)
            {
                if (c == '(') round++;
                else if (c == ')') round--;
                else if (c == '[') square++;
                else if (c == ']') square--;
                else if (c == '{') curly++;
                else if (c == '}') curly--;

                if (c == ',' && round == 0 && square == 0 && curly == 0)
                {
                    result.Add(sb.ToString().Trim());
                    sb.Clear();
                    continue;
                }
            }

            sb.Append(c);
        }

        if (sb.Length > 0) result.Add(sb.ToString().Trim());
        return result;
    }

    // Extract callback argument from removetimer.
    private static string ExtractRemoveTimerCallback(string line)
    {
        try
        {
            var match = Regex.Match(line, @"removetimer\s*\(\s*(?<callback>[^,\)]+)", RegexOptions.IgnoreCase);
            if (!match.Success) return null;
            return match.Groups["callback"].Value.Trim().Split('.', ':').Last();
        }
        catch { return null; }
    }

    private static void ExportUnremovedTimerReport()
    {
        string exportPath = Directory.GetCurrentDirectory() + "\\EditorExport\\setTimer未移除回调列表.txt";
        var reportLines = new List<string>();
        AppendReportSection(reportLines, "【第一种】未移除的循环方法,以及循环匿名方法", _unremovedLoopTimerReportLines);
        AppendReportSection(reportLines, "【第二种】未移除的方法", _unremovedNamedTimerReportLines);
        AppendReportSection(reportLines, "【第三种】未移除的匿名方法", _unremovedAnonymousTimerReportLines);

        if (reportLines.Count == 0)
        {
            File.WriteAllText(exportPath, "未检测到 settimer 未移除回调。", Encoding.UTF8);
        }
        else
        {
            File.WriteAllLines(exportPath, reportLines, Encoding.UTF8);
        }

        UnityEngine.Debug.Log($"settimer 未移除回调报告已导出:{exportPath}");
    }

    // Extract function calls from one line for call graph construction.
    private static List<string> ExtractAllFuncCalls(string line)
    {
        List<string> calls = new List<string>();
        int idx = 0;
        while ((idx = line.IndexOf('(', idx)) != -1)
        {
            int start = idx - 1;
            while (start >= 0 && (char.IsLetterOrDigit(line[start]) || line[start] == '_')) start--;
            if (start + 1 < idx) calls.Add(line.Substring(start + 1, idx - start - 1));
            idx++;
        }
        return calls;
    }

    private static string GetProjectPath() => Application.dataPath.Replace("Assets", "");
    private static void CacheClassNames(List<string> files)
    {
        foreach (var p in files)
        {
            string name = Path.GetFileNameWithoutExtension(p).Trim();
            if (!_classNameToPath.ContainsKey(name)) _classNameToPath.Add(name, p);
        }
    }

    private static void AppendReportSection(List<string> reportLines, string title, List<string> sectionLines)
    {
        if (!sectionLines.Any()) return;
        reportLines.Add(title);
        reportLines.AddRange(sectionLines);
        reportLines.Add(string.Empty);
    }

    private static void BuildResultMessages(string path, TimerCheckResult checkResult)
    {
        var relativePath = path.Replace(GetProjectPath(), "");

        if (checkResult.UnremovedLoopNamedCallbacks.Any())
        {
            string line = $"[{relativePath}] 未移除的循环方法:{string.Join(", ", checkResult.UnremovedLoopNamedCallbacks)}";
            UnityEngine.Debug.LogError(line);
            _unremovedLoopTimerReportLines.Add(line);
        }

        if (checkResult.UnremovedLoopAnonymousTimerRecords.Any())
        {
            string line = $"[{relativePath}] 未移除的循环匿名方法位置:{string.Join("; ", checkResult.UnremovedLoopAnonymousTimerRecords)}";
            UnityEngine.Debug.LogError(line);
            _unremovedLoopTimerReportLines.Add(line);
        }

        if (checkResult.UnremovedNamedCallbacks.Any())
        {
            string line = $"[{relativePath}] 未移除的命名回调:{string.Join(", ", checkResult.UnremovedNamedCallbacks)}";
            UnityEngine.Debug.LogError(line);
            _unremovedNamedTimerReportLines.Add(line);
        }

        if (checkResult.AnonymousTimerRecords.Any())
        {
            // Anonymous callbacks must be persisted to txt as well for manual review.
            string line = $"[{relativePath}] 匿名方法(未注销)位置:{string.Join("; ", checkResult.AnonymousTimerRecords)}";
            UnityEngine.Debug.LogWarning(line);
            _unremovedAnonymousTimerReportLines.Add(line);
        }
    }
    #endregion

    #region AstData
    public class LuaAstInfo
    {
        public string Path { get; }
        public List<string> ParentPaths { get; } = new List<string>();
        private readonly Dictionary<string, List<string>> _namedSetTimerCallbackRecords = new Dictionary<string, List<string>>();
        private readonly Dictionary<string, List<string>> _loopNamedSetTimerCallbackRecords = new Dictionary<string, List<string>>();
        private readonly List<string> _anonymousTimerRecords = new List<string>();
        private readonly List<string> _loopAnonymousTimerRecords = new List<string>();

        // removetimer callbacks by function + function call graph in destroy scope.
        private readonly Dictionary<string, HashSet<string>> _funcRemovedCallbacks = new Dictionary<string, HashSet<string>>();
        private readonly Dictionary<string, HashSet<string>> _funcCallTree = new Dictionary<string, HashSet<string>>();

        public string CurrentFunc { get; set; } = string.Empty;
        public bool IsInDestroyFunc { get; set; } = false;
        public int CurrentFuncBlockDepth { get; set; } = 0;

        public LuaAstInfo(string path) => Path = path;

        public void AddSetTimerCallback(TimerCallbackInfo callbackInfo, int lineNum)
        {
            if (callbackInfo.IsAnonymous)
            {
                if (callbackInfo.IsLoop)
                {
                    _loopAnonymousTimerRecords.Add($"{CurrentFunc}:L{lineNum}");
                }
                else
                {
                    _anonymousTimerRecords.Add($"{CurrentFunc}:L{lineNum}");
                }
                return;
            }

            string record = $"{CurrentFunc}:L{lineNum}";
            if (callbackInfo.IsLoop)
            {
                AddNamedCallbackRecord(_loopNamedSetTimerCallbackRecords, callbackInfo.Name, record);
            }
            else
            {
                AddNamedCallbackRecord(_namedSetTimerCallbackRecords, callbackInfo.Name, record);
            }
        }

        private static void AddNamedCallbackRecord(Dictionary<string, List<string>> records, string callbackName, string locationRecord)
        {
            if (!records.TryGetValue(callbackName, out var list))
            {
                list = new List<string>();
                records[callbackName] = list;
            }
            list.Add(locationRecord);
        }

        // Record removetimer callback in current function.
        public void AddRemovedCallbackInCurrentFunc(string cb)
        {
            if (string.IsNullOrEmpty(CurrentFunc)) return;
            if (!_funcRemovedCallbacks.TryGetValue(CurrentFunc, out var removedSet))
            {
                removedSet = new HashSet<string>();
                _funcRemovedCallbacks[CurrentFunc] = removedSet;
            }
            removedSet.Add(cb);
        }

        // Record function call relation in destroy scope.
        public void AddDestroyChildFunc(string childFunc)
        {
            if (!_funcCallTree.ContainsKey(CurrentFunc)) _funcCallTree[CurrentFunc] = new HashSet<string>();
            _funcCallTree[CurrentFunc].Add(childFunc);
        }

        // Collect all callbacks considered as removed in current class.
        private HashSet<string> GetAllValidRemoved()
        {
            HashSet<string> result = new HashSet<string>();
            HashSet<string> visited = new HashSet<string>();

            foreach (var root in LuaEventChecker.DestroyFunctions)
            {
                RecursiveCollect(root, result, visited, 0);
            }
            return result;
        }

        private void RecursiveCollect(string func, HashSet<string> result, HashSet<string> visited, int depth)
        {
            if (depth > MaxRecursiveDepth || visited.Contains(func)) return;
            visited.Add(func);

            if (_funcRemovedCallbacks.TryGetValue(func, out var removedSet))
            {
                foreach (var cb in removedSet) result.Add(cb);
            }

            // DFS through destroy-function call graph.
            if (_funcCallTree.TryGetValue(func, out var children))
            {
                foreach (var child in children) RecursiveCollect(child, result, visited, depth + 1);
            }
        }

        // Check unremoved settimer callbacks, including inherited destroy chains.
        public TimerCheckResult CheckUnRemovedTimer(Dictionary<string, LuaAstInfo> astDict)
        {
            var allRemoved = GetAllValidRemoved();

            // Include removed callbacks from parent classes.
            foreach (var parentPath in ParentPaths)
            {
                if (astDict.TryGetValue(parentPath, out var parent))
                {
                    foreach (var cb in parent.GetAllValidRemoved()) allRemoved.Add(cb);
                }
            }

            var unremovedLoopNamedRecords = _loopNamedSetTimerCallbackRecords
                .Where(kv => !allRemoved.Contains(kv.Key))
                .Select(kv => $"{kv.Key}({string.Join("/", kv.Value.Distinct())})")
                .ToList();

            var unremovedNamedRecords = _namedSetTimerCallbackRecords
                .Where(kv => !allRemoved.Contains(kv.Key))
                .Select(kv => $"{kv.Key}({string.Join("/", kv.Value.Distinct())})")
                .ToList();

            return new TimerCheckResult(
                unremovedLoopNamedRecords,
                new List<string>(_loopAnonymousTimerRecords),
                unremovedNamedRecords,
                new List<string>(_anonymousTimerRecords)
            );
        }
    }

    public class TimerCallbackInfo
    {
        public string Name { get; }
        public bool IsAnonymous { get; }
        public bool IsLoop { get; }

        public TimerCallbackInfo(string name, bool isAnonymous, bool isLoop)
        {
            Name = name;
            IsAnonymous = isAnonymous;
            IsLoop = isLoop;
        }
    }

    public class TimerCheckResult
    {
        public List<string> UnremovedLoopNamedCallbacks { get; }
        public List<string> UnremovedLoopAnonymousTimerRecords { get; }
        public List<string> UnremovedNamedCallbacks { get; }
        public List<string> AnonymousTimerRecords { get; }

        public TimerCheckResult(List<string> unremovedLoopNamedCallbacks, List<string> unremovedLoopAnonymousTimerRecords, List<string> unremovedNamedCallbacks, List<string> anonymousTimerRecords)
        {
            UnremovedLoopNamedCallbacks = unremovedLoopNamedCallbacks ?? new List<string>();
            UnremovedLoopAnonymousTimerRecords = unremovedLoopAnonymousTimerRecords ?? new List<string>();
            UnremovedNamedCallbacks = unremovedNamedCallbacks ?? new List<string>();
            AnonymousTimerRecords = anonymousTimerRecords ?? new List<string>();
        }
    }
    #endregion
}

核心逻辑很简单,就是逐Lua文件构造AST,剩下的就是通过正则表达式来检测一行代码是否匹配了。

对于一个Lua文件:

前八行会尝试识别继承的父类,并把父类文件也递归解析AST。

BuildAST识别函数定义、settimer、removetime、退出函数内调用关系。

文件结束后执行CheckUnRemovedTimer产出结果。

工具还有区分是否匿名函数,一般来说,匿名函数是不会移除的,这部分大概率会有问题。

工具还会区分是否是循环触发的回调,并且会在输出的txt文件中放到最前,这种情况最有可能出问题。

4、优缺点

这个工具能检测出上述的所有情况,但部分特殊情况还是无法检测。例如,定义了一个类,其退出回调用的是自定义的方法,例如方法名叫quit,然后在使用到这个类的类中的onExit方法中调用这个类的quit方法,这种情况就无法检测,当然,可以继续扩展DestroyFunctions中的方法名,使其适配更多的退出函数名。

另外,工具待补充白名单逻辑,部分特殊写法的Lua脚本可能会被检测到,但实际上是没有问题的,这种情况应该加一个白名单。

相关推荐
其实防守也摸鱼4 小时前
带你了解与配置phpmyadmin
笔记·安全·网络安全·pdf·编辑器·工具·调试
leo__5204 小时前
单载波中继系统资源分配算法MATLAB仿真程序
算法·matlab·unity
努力长头发的程序猿6 小时前
Unity使用ScriptableObject序列化资源
unity·游戏引擎
mxwin6 小时前
Unity Shader 手写基于 PBR 的 URP Lit Shader 核心光照计算
unity·游戏引擎·shader
小贺儿开发6 小时前
Unity3D 智能云端数字标牌系统
unity·阿里云·人机交互·视频·oss·广告·互动
魔士于安6 小时前
Unity windows 同步 异步 打开文件文件夹工具
游戏·unity·游戏引擎·贴图·模型
笑虾7 小时前
cocos2d-x lua 加载 Cocos Studio 导出的 csb
游戏引擎·lua·cocos2d
魔士于安7 小时前
unity lowpoly 风格 城市 建筑 道路 交通标志
游戏·unity·游戏引擎·贴图·模型
mxwin7 小时前
Unity GPU Shader 性能优化指南
unity·游戏引擎·shader