流程

  1. 游戏对象上挂C#对接脚本,可以拖对象,C#对接脚本继承一个基类,使用xLua的Global.Get<LuaTable>("table名字")得到lua表,luaTable.Set给表注入成员,xLua还有新建表、设置元表、得到lua函数等操作。C#对接脚本用UnityAction<LuaTable>存从lua得到的生命周期函数,用luaTable.Get<UnityAction<LuaTable>>("Awake")等得到lua函数,在C#生命周期函数里执行;
  2. luaEnv.AddLoader增加从AB包加载lua脚本的方法,如果是安卓平台必须用UnityWebRequest异步加载则进入游戏先异步加载lua AB包在回调函数里和之后执行任何执行lua代码;
  3. 程序写好后把所有lua脚本后缀替换成.txt,打AB包。把从项目文件夹加载lua的方法注释掉验证从AB包加载lua成功。在编辑器里使用Android AB包加载lua没问题,只有某些粒子效果会变粉色,到Android平台就会正常;
  4. 测试时能明显看到初始显示的面板有一个延迟。

传递C#对象给Lua

不能在编辑器拖对象,根对象使用GameObject.Find(),子对象使用transform.Find()寻找。

function PanelMain:Open()--使用Find获得控件的笨方法
    if self.obj==nil then
        prefab=ABManager:LoadRes(uiAB,"PanelMain",typeof(GameObject))
        self.obj=GameObject.Instantiate(prefab)
        self.obj.transform:SetParent(Canvas.transform,false)
        self.buttonPlay=self.obj.transform:Find("ButtonPlay"):GetComponent(typeof(Button))
        self.buttonSettings=self.obj.transform:Find("ButtonSettings"):GetComponent(typeof(Button))
        self.buttonRank=self.obj.transform:Find("ButtonRank"):GetComponent(typeof(Button))
        self.buttonPlay.onClick:AddListener(function()
            self:ClickPlay()
        end)
        self.buttonSettings.onClick:AddListener(function()
            self:ClickSettings()
        end)
        self.buttonRank.onClick:AddListener(function()
            self:ClickRank()
        end)
    else
        self.obj:SetActive(true)
    end
end

除此还有什么方法吗?一个C#脚本可以用luaTable.Set()给表写入成员。

luaTable = MyXLuaManager.Instance.Global.Get<LuaTable>(luaTableName);
        luaTable.Set("obj",gameObject);

传递面板的控件给Lua

对于一个面板,有Image、Text、Button等组件。如果一个面板有专用的C#脚本,那么每个面板都有一个C#脚本,脚本数量增加了一倍。我尝试使用UGUI控件的共同父类UIBehaviour列表,结果lua得到的都是UIBehaviour对象,根本没有按钮的onClick这些各种控件独有的成员。

那么还想用列表注入,就还需要把具体类型传过去,但是Type不能序列化到检查器,无法在编辑器配置类型。

然后懒得设计,就给每个面板专用一个C#脚本。这些脚本的共同点是获取Lua的生命周期函数,在生命周期函数里执行,所以它们继承这样的基类。

//得到一个lua表,注入一些成员,执行它的生命周期函数
public class LuaSingleton : MonoBehaviour{
    public string luaScriptName;
    public string luaTableName;
    protected LuaTable luaTable;
    protected UnityAction awake, start, update, onDestroy;
    protected void InitTable()
    {
        if (luaScriptName != null && luaScriptName != "")
        {
            MyXLuaManager.Instance.DoLuaFile(luaScriptName);
        }
        luaTable = MyXLuaManager.Instance.Global.Get<LuaTable>(luaTableName);
        luaTable.Set("obj", gameObject);
    }
    protected virtual void InjectTable()
    {
        
    }
    protected virtual void Awake()
    {
        InitTable();
        awake = luaTable.Get<UnityAction>("Awake");
        start = luaTable.Get<UnityAction>("Start");
        update = luaTable.Get<UnityAction>("Update");
        onDestroy = luaTable.Get<UnityAction>("OnDestroy");
        awake?.Invoke();
    }
    void Start()
    {
        start?.Invoke();
    }
    void Update(){
        update?.Invoke();
    }
    void OnDestroy()
    {
        onDestroy?.Invoke();
        luaTable.Dispose();
    }
}

各面板和基类只有注入的成员不同

public class PanelMainLua : LuaSingleton{
    public Button buttonPlay,buttonSettings,buttonRank;
    protected override void InjectTable()
    {
        base.InjectTable();
        luaTable.Set(nameof(buttonPlay), buttonPlay);
        luaTable.Set(nameof(buttonSettings), buttonSettings);
        luaTable.Set(nameof(buttonRank), buttonRank);
    }
}

然后如果加载面板由Lua完成,就变成Lua加载面板>C#脚本执行Awake()>执行Lua Awake(),里面执行初始化。这种踢皮球的流程,省去了找控件的代码,可以继续用拖拽控件,也可以执行生命周期函数。

function PanelMain:Load()
    local prefab=ABManager:LoadRes(uiAB,"PanelMain",typeof(GameObject))
    self.obj=GameObject.Instantiate(prefab,Canvas.transform)
end
function PanelMain:Init()
        
        -- self.obj.transform:SetParent(,false)
        self.buttonPlay.onClick:AddListener(function()
            self:ClickPlay()
        end)
        self.buttonSettings.onClick:AddListener(function()
            self:ClickSettings()
        end)
        self.buttonRank.onClick:AddListener(function()
            self:ClickRank()
        end)
end
function PanelMain.Awake()
    PanelMain:Init()
end

传递多实例对象给Lua

有一个“类表”,创建一个新表,设置类表为元表。和写面板一样的思想,原来每个C#类现在充当到Lua的桥接器,C#脚本数不变,增加Lua脚本,总脚本数增加一倍。

public class LuaInstance : MonoBehaviour
{
    public string luaScriptName;
    public string luaClassTableName;
    protected LuaTable luaClassTable, luaTable;
    UnityAction awake, start, update, onDestroy;
    UnityAction<LuaTable,GameObject>onTriggerEnter;
    protected virtual void InjectObjects()
    {
        
    }
    void Awake()
    {
        if (luaScriptName != null && luaScriptName != "")
        {
            MyXLuaManager.Instance.DoLuaFile(luaScriptName);
        }
        luaClassTable = MyXLuaManager.Instance.Global.Get<LuaTable>(luaClassTableName);
        luaTable = MyXLuaManager.Instance.luaEnv.NewTable();
        luaTable.SetMetaTable(luaClassTable);
        luaTable.Set("obj",gameObject);
        InjectObjects();
        awake = luaClassTable.Get<UnityAction>("Awake");
        start = luaClassTable.Get<UnityAction>("Start");
        update = luaClassTable.Get<UnityAction>("Update");
        onTriggerEnter=luaClassTable.Get<UnityAction<LuaTable,GameObject>>("OnTriggerEnter");
        onDestroy = luaClassTable.Get<UnityAction>("OnDestroy");
        awake?.Invoke();
    }
    void Start()
    {
        start?.Invoke();
    }

    void Update()
    {
        update?.Invoke();
    }
    void OnTriggerEnter(Collider other)
    {
        onTriggerEnter?.Invoke(luaTable,other.gameObject);
    }
    void OnDestroy() {
        onDestroy?.Invoke();
        luaClassTable.Dispose();
        luaTable.Dispose();
    }
}

一个Lua表对象访问另一个类的Lua表对象

通过C#传递脚本已经能把场景里的对象传给Lua表了,但一个Lua表现在的成员除了简单数据类型、Unity内置类型,就是C#脚本,要访问其他类的Lua表,需要通过其他类的C#传递脚本。

继承关系怎么处理

上面写好了单例和实例的Lua对接脚本。现在如果有一个坦克基类,坦克有很多实例,它使用实例对接脚本,玩家坦克继承坦克基类,玩家坦克是单例。玩家坦克对接脚本如果

  1. 继承坦克对接脚本基类则新建一个空表,以玩家坦克表为元表,多建了一个表;
  2. 继承单例对接脚本,则没有了坦克基类所有的成员;

经过权衡还是使用1。然后玩家坦克对接脚本的gameObject被注入给了新建的空表,PlayerTank表的obj还是空的。那么只能玩家坦克对接脚本自己写一个Awake,里面直接获得那个表。即使如此,还是会造成脚本数量爆炸。我打算玩家的坦克、武器不再作为一个子类,而把玩家多出来的数据放在一个成员类,非玩家对象的这个成员类为空。

然后我觉得再把直接使用Lua表和创建新Lua表的C#传递类分2个类是没有意义的,不如直接给类一个公开字段,控制是直接用还是创建新的。

全局初始化和场景初始化

lua同样有全局初始化的(定义符号简写、加载依赖模块)和每个场景初始化的(加载场景的初始面板)。一个场景会多次进入,需要多次执行。require()执行完一个脚本后,再次require()同一个脚本,不会执行,需要package.loaded['XXX']=nil。可以在执行lua脚本的OnDestroy执行这个清除。场景初始化代码要么初始化时require,离开场景时清除,要么不用require,直接执行函数;

--全局初始化
require("Object")
Json=require("JsonUtility")
--取名称简写
UE=CS.UnityEngine
GameObject=UE.GameObject
Transform=UE.Transform
Resources=UE.Resources
RectTransform=UE.RectTransform
AudioSource=UE.AudioSource
Time=UE.Time

Vector3=UE.Vector3
Vector2=UE.Vector2

UI=UE.UI
Image=UI.Image
Text=UI.Text
Button=UI.Button
Slider=UI.Slider
Toggle=UI.Toggle

ABManager=CS.MyABManager.Instance
SceneManager=UE.SceneManagement.SceneManager
File=CS.System.IO.File
dataPath=UE.Application.persistentDataPath
--声明全局量
gameSceneName="GameScene"
require("DataManager")
require("Music")
require("PanelMain")
require("PanelSettings")
require("PanelRank")
require("PanelGame")
Canvas=GameObject.Find("Canvas")
Audio:Init()
PanelMain:Open()

function LeaveBeginScene()
    --本场景加载的对象全部置空
    PanelSettings.obj=nil
    PanelMain.obj=nil
    PanelRank.obj=nil
    Audio.music=nil
end

    让一个Lua表通过调用C#调用另一个Lua表

    Lua使用GetComponent、Instantiate获取的是C#类,如果这个类是一个传递脚本,需要通过它的luaTable成员得到处理它逻辑的Lua表。

    lua写逻辑的注意

    • 主脚本里要require所有用到的脚本,类似编译,还要注意require顺序。然后发现这样每新写一个脚本,都要在主脚本require,能不能把lua文件夹的脚本全执行了?;
    • 为了模拟面向对象的写法,声明表后会把成员声明一下,在VSCode里其他地方使用会有提示且没有黄色下划线;

    调用Physics的Raycast或Linecast的有RaycastHit的重载

    尝试用多返回值

        local hit=false
        local hitInfo=UE.RaycastHit()
        hit,hitInfo=Physics.Linecast(self.obj.transform.position,self.lastFramePos)

    尝试把RaycastHit传入

    local hit=false
    local hitInfo=UE.RaycastHit()
    hit,hitInfo=Physics.Linecast(self.obj.transform.position,self.lastFramePos,hitInfo)

    只能在C#封装一个方法

    public static GameObject CheckHit(Vector3 start, Vector3 end)
        {
            RaycastHit hit;
            if (Physics.Linecast(start, end, out hit))
            {
                return hit.collider.gameObject;
            }
            return null;
        }

    把创建lua脚本加入Create菜单的脚本

    脚本名和类名必须相同

    using UnityEditor;
    using UnityEngine;
    using System.IO;
    
    public class LuaFileCreator : Editor
    {
        [MenuItem("Assets/Create/Lua Script", false, 80)]
        public static void CreateLuaScript()
        {
            // 获取当前选中的路径
            string path = GetCurrentPath();
            
            // 创建唯一的文件名
            string fileName = "NewLuaScript.lua";
            string fullPath = Path.Combine(path, fileName);
            fullPath = AssetDatabase.GenerateUniqueAssetPath(fullPath);
            
            // 创建.lua文件内容
            string content = "";
            
            // 写入文件
            File.WriteAllText(fullPath, content);
            
            // 刷新AssetDatabase
            AssetDatabase.Refresh();
            
            // 选中新创建的文件
            Selection.activeObject = AssetDatabase.LoadMainAssetAtPath(fullPath);
        }
        
        private static string GetCurrentPath()
        {
            string path = "Assets";
            
            foreach (Object obj in Selection.GetFiltered(typeof(Object), SelectionMode.Assets))
            {
                path = AssetDatabase.GetAssetPath(obj);
                if (File.Exists(path))
                {
                    path = Path.GetDirectoryName(path);
                }
                break;
            }
            
            return path;
        }
    }

    lua引用变量的判空:==nil和:Equals(nil)

    第一种用于变量从未赋值,第二种用于赋值过但是对象被销毁(Unity检查器里的Missing,或者打印的大写开头Null)。为了考虑全面应该两种情况或运算,而且顺序必须先==nil再:Equals(nil)。

    只写第一种,引用的对象被销毁后会判断不通过,只写第二种,一开始加载对象时会报错试图索引一个nil。

    在编辑器里lua脚本后缀是.lua,require时无需后缀名,打AB包前后缀要变成.txt,如果是.lua后面加.txt,那么代码里所有require后面都要加.lua?

    既然如此,那么打AB包前把所有lua脚本后缀改成.txt而不是追加可以吗?

    [MenuItem("热更新工具/lua脚本改成txt后缀")]
        static void luaToTxt()
        {
            if(!Directory.Exists(luaPath)){
                return;
            }
            string[] files=Directory.GetFiles(luaPath,"*.lua");
            if(!Directory.Exists(targetPath)){
                Directory.CreateDirectory(targetPath);
            }
            else{
                string[] oldFiles=Directory.GetFiles(targetPath);
                for(int i=0;i<oldFiles.Length;i++){
                    File.Delete(oldFiles[i]);
                }
            }
            string fileName;
            for(int i=0;i<files.Length;i++){
                
                fileName=Path.Combine(targetPath,Path.GetFileNameWithoutExtension(files[i])+".txt");
                File.Copy(files[i],fileName);
            }
            AssetDatabase.Refresh();
        }

      关于AB包加载

      unity ab包里的预制体上挂的脚本是什么状态?有代码逻辑吗?替换ab包能更新里面预制体的脚本吗?

      很好,这是一个非常核心且常见的Unity开发问题。我们来分点详细解释。

      1. AB包里的预制体上挂的脚本是什么状态?有代码逻辑吗?

      简单回答: 脚本在AB包中只保存了引用和序列化数据,没有保存代码逻辑本身。代码逻辑存在于编译后的程序集(DLL)中。

      详细解释:

      当你在Unity编辑器中创建一个预制体并挂上脚本(例如 MyBehaviour.cs),Unity会做两件事:

      · 脚本引用: 预制体资源文件里会记录一个对脚本的引用。这个引用包括脚本的GUID和FileID,这些信息在Unity的meta文件中定义。打包成AssetBundle时,这个引用信息会被一起打包。
      · 序列化数据: 你在Inspector窗口中为脚本设置的公开字段(如 public int health;)的值会被序列化,并保存到预制体文件中。这些数据也会被打包进AB包。

      代码逻辑在哪里?
      你的C#代码逻辑在经过编译后,会变成程序集(通常是 Assembly-CSharp.dll),这个DLL文件不会被自动打包到AssetBundle中。它属于你项目的代码部分,在构建游戏时会被打包到主程序(如.exe、.apk、.ipa)里。

      所以,从AB包中加载出来的预制体上的脚本状态是:

      · 它知道它应该挂载哪个脚本(通过GUID/FileID引用)。
      · 它拥有该脚本所有序列化字段的初始值。
      · 当这个预制体被实例化到场景中时,Unity运行时会根据这个引用去当前已加载的程序集(DLL)中查找对应的脚本类。如果找到,就会为游戏对象动态添加这个脚本组件,并将序列化的数据赋给相应的字段。然后,脚本的 Awake(), Start() 等生命周期方法就会按顺序执行。

      ---

      2. 替换AB包能更新里面预制体的脚本吗?

      这个问题的答案比较复杂,需要分情况讨论:

      情况一:只替换AB包,不更新游戏客户端(不更新DLL)

      不能更新脚本逻辑。

      · 原因: 正如上面所说,脚本逻辑在DLL里。如果你只从服务器下载并替换了新的AB包,但客户端的应用程序(包含旧的DLL)没有更新,那么:
        1. 新的AB包里的预制体依然引用着同名的脚本。
        2. 游戏运行时,它会从旧的DLL中加载旧的脚本逻辑来执行。
        3. 因此,脚本的行为不会有任何改变。
      · 什么会改变?
        · 你可以更新序列化的数据。例如,你可以在新AB包中把脚本的 health 字段从100改为200。实例化后,脚本里的 health 初始值就是200。
        · 你可以更新模型、纹理、音频等其他资源。

      情况二:同时替换AB包和更新包含新脚本的DLL(热更新方案)

      可以更新脚本逻辑。这是实现热更新的核心原理。

      这需要借助Unity的程序集热更新技术,通常有两种主流方式:

      a) 使用ILRuntime/Lua等脚本语言

      · 将核心逻辑用Lua或支持热更的脚本语言编写。
      · 脚本(如Lua文件)可以作为TextAsset打包到AB包里。
      · 替换AB包后,加载新的Lua脚本并执行,就等于更新了逻辑。
      · C#层只充当一个“虚拟机”的作用。

      b) 使用HybridCLR(原xLua团队的增强型热更新方案)

      · 这是目前最主流、最强大的方案。它允许你更新C#的DLL。
      · 流程:
        1. 你将需要热更的C#代码编译成一个独立的DLL(例如 HotUpdate.dll)。
        2. 将这个DLL文件当作一个普通的TextAsset资源,打包到一个AB包中。
        3. 游戏启动时,从服务器下载包含新DLL的AB包。
        4. 使用 AssetBundle.LoadAsset<TextAsset> 加载DLL的bytes。
        5. 使用HybridCLR加载这个DLL到应用程序域中。
        6. 此时,再加载包含预制体的AB包。当预制体实例化时,Unity运行时会从新加载的 HotUpdate.dll 中找到新的脚本逻辑并执行。

      在这种情况下,替换AB包就实现了脚本逻辑的更新。

      ---

      总结与最佳实践建议

      操作 能否更新脚本逻辑? 说明
      只替换AB包 否 脚本逻辑存在于主程序DLL中,AB包只含资源和数据。
      替换AB包 + 代码热更新框架 是 通过将新DLL作为资源打入AB包,运行时动态加载,实现逻辑热更。

      重要提醒:

      · 脚本引用断裂: 如果你在更新代码时删除或重命名了一个脚本类,然后只替换了AB包,但没有更新客户端DLL,那么从AB包加载预制体会失败,并出现“Missing Script”的错误。因为运行时在DLL里找不到对应的类了。
      · 版本兼容性: 在设计热更新时,要特别注意数据结构的兼容性。如果新脚本删除或修改了一个序列化字段,而旧AB包里的数据还包含这个字段,可能会导致反序列化错误。
      · 推荐架构: 为了更好的热更新管理,建议使用 Assembly Definition Files (asmdef) 将需要热更的代码组织到单独的程序集中,与引擎相关、不易热更的代码分离开。

      希望这个详细的解释能帮助你彻底理解这个问题!

      遇到的问题

      打包的时候已经把之前属于的练习包的资源都设置None了,也Remove Unused Names了,原来的包还是打出来了

      注意点

      路径切换

      因为Resources加载是路径/资源名使用AB包需要给AB包路径和包名,但是不用写路径,直接写资源名。然后要把一大堆加载资源的代码从Resources路径改成资源名。有什么方便的改法吗?

      我决定加载资源输入路径/资源名的形式,用AB包时就用Path.GetFileNameWithoutExtension()把资源名提取出来。

      xLua使用UnityWebRequest异步加载AB包和资源

      public class MyResManagerUWQ : MonoSingletonDontDestroy<MyResManagerUWQ>
      {
          static string ABFolder = Application.streamingAssetsPath;
          Dictionary<string,AssetBundle>abDic=new Dictionary<string,AssetBundle>();
          public void LoadRes(string resName, string abName, UnityAction<GameObject> callback = null)
          {
              if (!abDic.ContainsKey(abName))
              {
                  string abPath = Path.Combine(ABFolder, abName);
                  StartCoroutine(LoadResIE<AssetBundle>(UwrPathProcessor(abPath), (ab) =>
                  {
                      abDic.Add(abName, ab);
                      GameObject go = ab.LoadAsset<GameObject>(resName);
                      callback?.Invoke(go);
                  }));
              }
              else
              {
                  GameObject go =abDic[abName].LoadAsset<GameObject>(resName);
                  callback?.Invoke(go);
              }
          }
          IEnumerator LoadResIE<T>(string path, UnityAction<T> callback = null) where T : class
          {
              UnityWebRequest request = null;
              if (typeof(T) == typeof(string))
              {
                  request = UnityWebRequest.Get(path);
              }
              else if (typeof(T) == typeof(Texture))
              {
                  request = UnityWebRequestTexture.GetTexture(path);
              }
              else if (typeof(T) == typeof(AssetBundle))
              {
                  request = UnityWebRequestAssetBundle.GetAssetBundle(path);
              }
              yield return request.SendWebRequest();
              if (request.result == UnityWebRequest.Result.Success)
              {
                  if (callback != null)
                  {
                      if (typeof(T) == typeof(string))
                      {
                          callback(request.downloadHandler.text as T);
                      }
                      else if (typeof(T) == typeof(Texture))
                      {
                          callback((request.downloadHandler as DownloadHandlerTexture).texture as T);
                      }
                      else if (typeof(T) == typeof(AssetBundle))
                      {
                          callback((request.downloadHandler as DownloadHandlerAssetBundle).assetBundle as T);
                      }
                  }
              }
          }
          public static string UwrPathProcessor(string path)
          {
      #if UNITY_EDITOR || UNITY_STANDALONE_WIN
              return "file://" + path;
      #elif UNITY_ANDROID
              return "jar:file://"+path;
      #endif
              return path;
          }
      }
      ResManager=CS.MyResManagerUWQ.Instance
      function LoadPanelAsync(panel)
          ResManager:LoadRes(panel.resName,uiAB,function(prefab)
              panel.obj=GameObject.Instantiate(prefab,Canvas.transform)
          end)
      end

      xLua的CustomLoader和UnityWebRequest的异步加载怎么配合

      我写了一个循环等待异步加载完成。但是如果不加最大尝试次数,程序直接卡死,加上程序不卡死了,还是加载不出来。

      byte[] ProjectLoaderABAndroid(ref string filePath){
              #if UNITY_EDITOR|| UNITY_ANDROID
              Debug.Log("在Android AB包里找"+filePath);
      #endif
              byte[] bytes=null;
              int tryNum =0,maxTryNum=50;
              MyResManagerUWQ.Instance.LoadText(luaABName,filePath+".lua",(luaScript)=>
              {
                  bytes = luaScript.bytes;
              });
              while (bytes== null&&tryNum<maxTryNum)
              {
                  tryNum++;
              }
              return bytes;
          }

      解决方法:进入游戏第一个场景只有一个初始化脚本,这个脚本先异步加载lua AB包,在异步加载回调里把这个AB包存起来,然后初始化xLua管理器,xLua管理器AddLoader增加从这个AB包加载TextAsset的同步加载方法。

      public class LoadLuaThenRun : MonoBehaviour
      {
          public string luaScript;
          public List<string>codes=new List<string>();
          void Start()
          {
              MyResManagerUWQ.Instance.LoadAB(MyXLuaManager.luaABName, (ab) =>
              {
                  MyXLuaManager.Instance.luaAB = ab;
                  MyXLuaManager.Instance.Init();
                  if (!string.IsNullOrEmpty(luaScript))
                  {
                      MyXLuaManager.Instance.DoLuaFile(luaScript);
                  }
                  for (int i = 0; i < codes.Count; i++) {
                      MyXLuaManager.Instance.RunLua(codes[i]);
                  }
              });
          }
      
      }

      xLua管理器:

      byte[] LoadFromAB(ref string filePath){
              #if UNITY_EDITOR|| UNITY_ANDROID
              Debug.Log("在AB包里找"+filePath);
      #endif
              byte[] bytes = luaAB?.LoadAsset<TextAsset>(filePath).bytes;
              return bytes;
          }

      热更新AB包

      首先分为服务端和客户端两部分处理。

      服务端

      1. 服务端生成对比文件;
      2. 服务端上传AB包和比较文件;

      客户端

      1. 客户端开启时下载对比文件;
      2. 解析服务端对比文件;
      3. 读取客户端对比文件,解析;
      4. 对比;
      5. 如果和本地对比文件不一样,则下载更新的AB包;
      6. 把服务端对比文件作为新的客户端对比文件;

      问题

      Sublime Text问题

      不识别粘贴来的空格

      有时执行代码会报这种有�的错

      经检查发现这是被识别的空格,里面有一点:

      这是没被识别的空格,里面没有一点:

      Logo

      有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

      更多推荐