当前位置: 代码网 > it编程>游戏开发>ar > Unity简易存档系统实现

Unity简易存档系统实现

2024年08月03日 ar 我要评论
首先,先上源码,解释项目结构,后面再讲每个类、结构体和函数的作用源代码分两个类:ArchiveManager.cs和ArchiveSO.cs此文件包含一个单例:ArchiveManager,两个结构体定义:ArchiveData、SceneData一个接口:IArchive三个拓展类:SceneDataExtension、ArchiveDataExtension、ComponentExtension此文件包含一个继承SciptableObject的类:ArchiveSO。

 

此文章已废弃,存档系统已经做了很多修改。


源码

首先,先上源码,解释项目结构,后面再讲每个类、结构体和函数的作用

源代码分两个类:archivemanager.cs和archiveso.cs

archivemanager.cs



using system;
using system.collections.generic;
using system.linq;
using system.reflection;
using unityeditor;
using unityengine;
using unityengine.scenemanagement;

public class archivemanager : monobehaviour
{

    #region 成员、属性

    #region 静态
    private static archivemanager instance;
    public static archivemanager instance { get => instance; }
    public static archivedata currentarchive = archivedata.none;
    public static scene currentscene;
    public archivedata scenebuffer = new archivedata("buffer","buffer");
    #endregion

    #region 非静态
    [serializefield]
    private archiveso archivesodata;

    #endregion

    #endregion
    #region 生命周期

    private void awake()
    {
        instanceinit();
    }

    private void onenable()
    {
        scenemanager.sceneunloaded += sceneunloaded;
        scenemanager.sceneloaded += sceneloaded;
    }


    private void ondisable()
    {
        scenemanager.sceneunloaded -= sceneunloaded;
        scenemanager.sceneloaded -= sceneloaded;
    }

    #endregion
    #region 单例

    /// <summary>
    /// 实现单例
    /// </summary>
    private void instanceinit()
    {
        if (instance != null)
        {
            destroy(this);
        }
        instance = this;
        
        dontdestroyonload(this);
    }
    #endregion
    /// <summary>
    /// 卸载场景前,对场景所有物体进行遍历,将其值存到缓存结构体中
    /// </summary>
    /// <param name="scene"></param>
    private void sceneunloaded(scene scene)
    {
        //todo:装载场景里所有gameobject到缓存池中
        var objs = gameobject.findobjectsoftype<monobehaviour>().oftype<iarchive>();
        scenedata scenedata = new scenedata(scene.name);
        foreach (var archive in objs)
        {
            long id = archive.getcomponent().getonlyid();
            string value = archive.save();
            scenedata.savevalue(id,value);
        }
        scenebuffer.savevalue(scene,scenedata);

    }
    /// <summary>
    /// 场景加载后,对场景所有物体进行遍历,调用接口赋值
    /// </summary>
    /// <param name="scene"></param>
    /// <param name="mode"></param>
    private void sceneloaded(scene scene, loadscenemode mode)
    {
        //todo:加载数据到场景中
        if(currentarchive == archivedata.none)
            return;
        var scenedata = currentarchive.loadvalue(scene);
        if(scenedata == scenedata.none)
            return;
        var objs = gameobject.findobjectsoftype<monobehaviour>().oftype<iarchive>();
        foreach (var archive in objs)
        {
            long key = archive.getcomponent().getonlyid();
            var value = scenedata.loadvalue(key);
            archive.load(value);
        }

        currentscene = scene;
    }

    #region 函数
    /// <summary>
    /// 返回给ui去查看有哪些存档
    /// </summary>
    /// <returns></returns>
    public list<archivedata> getarchivelist()
    {
        return archivesodata.archives;
    }

    public void loadinindex(int index)
    {
        if (index < 0 || index >= archivesodata.archives.count)
        {
            debug.logerror("下标不在范围内");
            return;
        }
        currentarchive = archivesodata.archives[index];
        currentarchive.archivevalue = currentarchive.fromjson();
    }

    /// <summary>
    /// 保存为新存档
    /// </summary>
    public void saveasnew()
    {
        archivesodata.archives.add(save());
    }

    /// <summary>
    /// 替换存档
    /// </summary>
    /// <param name="index"></param>
    public void saveinindex(int index)
    {
        if(index < 0 || index >= archivesodata.archives.count)
            return;
        archivesodata.archives[index] = save();
    }

    /// <summary>
    /// 创建新档
    /// </summary>
    public void creatasnew()
    {
        currentarchive = archivedata.none;
    }
    private archivedata save()
    {
        var data = new archivedata(string.empty,scenemanager.getactivescene().name);
        //更新当前场景的数据到缓存中
        sceneunloaded(scenemanager.getactivescene());
        data.archivevalue = scenebuffer.archivevalue;
        data.archivevaluejson = data.tojson();
        return data;
    }
    #endregion

}
/// <summary>
/// 单个场景存储的信息
/// </summary>
[serializable]
public struct scenedata
{
    public static scenedata none = new scenedata() { scenename = null };
    /// <summary>
    /// 场景名称
    /// </summary>
    public string scenename;
    /// <summary>
    /// 储存的数据
    /// </summary>
    public dictionary<long, string> scenevalue;
        
    public scenedata(string scenename)
    {
        this.scenename = scenename;
        scenevalue = new dictionary<long, string>();
    }

    public static bool operator ==(scenedata data1, scenedata data2)
    {
        if (data1.scenename == data2.scenename)
            return true;
        return false;
    }
    public static bool operator !=(scenedata data1, scenedata data2)
    {
        if (data1.scenename == data2.scenename)
            return false;
        return true;
    }
}
/// <summary>
/// 单个存档的信息
/// </summary>
[serializable]
public struct archivedata
{
    public static archivedata none = new archivedata();
    /// <summary>
    /// 当前存档名
    /// </summary>
    public string archivename;

    /// <summary>
    /// 存档最后一次存储时间
    /// </summary>
    public string currenttime;

    /// <summary>
    /// 存档所处的场景
    /// </summary>
    public string currentscenename;
    [textarea]
    public string archivevaluejson;

    
    public dictionary<string, scenedata> archivevalue;

    public archivedata(string archivename,string scenename)
    {
        archivename = archivename;
        currenttime = system.datetime.now.tostring();
        currentscenename = scenename;
        archivevalue = new dictionary<string, scenedata>();
        archivevaluejson = string.empty;
    }
    public static bool operator ==(archivedata data1, archivedata data2)
    {
        if (data1.archivename == data2.archivename && data1.archivevalue == data2.archivevalue)
            return true;
        return false;
    }
    public static bool operator !=(archivedata data1, archivedata data2)
    {
        if (data1.archivename == data2.archivename && data1.archivevalue == data2.archivevalue)
            return false;
        return true;
    }
}


public static class scenedataextension
{
    public static void savevalue(this scenedata data, component component, string jsonvalue)
    {
        long onlyid = component.getonlyid();
        data.savevalue(onlyid,jsonvalue);
    }
    public static void savevalue(this scenedata data, long id, string jsonvalue)
    {
        if (data.scenevalue.containskey(id))
            data.scenevalue[id] = jsonvalue;
        else
            data.scenevalue.add(id,jsonvalue);
    }

    public static string loadvalue(this scenedata data, component component)
    {
        long id = component.getonlyid();
        return data.loadvalue(id);
    }

    public static string loadvalue(this scenedata data, long id)
    {
        if(!data.scenevalue.containskey(id))
            return string.empty;
        return data.scenevalue[id];
    }
}

public static class archivedataextension
{
    public static void savevalue(this archivedata data,string scenename,scenedata scenedata)
    {
        if (data.archivevalue.containskey(scenename))
            data.archivevalue[scenename] = scenedata;
        else
            data.archivevalue.add(scenename,scenedata);
    }

    public static void savevalue(this archivedata data, scene scene, scenedata scenedata)
    {
        data.savevalue(scene.name,scenedata);
    }

    public static scenedata loadvalue(this archivedata data,string scenename)
    {
        if(!data.archivevalue.containskey(scenename))
            return scenedata.none;
        return data.archivevalue[scenename];
    }

    public static scenedata loadvalue(this archivedata data, scene scene)
    {
        return data.loadvalue(scene.name);
    }

    public static string tojson(this archivedata data)
    {
        string value = jsonconvert.serializeobject(data.archivevalue);
        return value;
    }

    public static  dictionary<string, scenedata> fromjson(this archivedata data)
    {
        if(data.archivevaluejson == string.empty)
            return null;
        dictionary<string, scenedata> datas =
            jsonconvert.deserializeobject<dictionary<string, scenedata>>(data.archivevaluejson);
        return datas;
    }
}

public static class componentextension
{
    public static long getonlyid(this component component)
    {
        long onlyid = component.getinstanceid() - component.gameobject.getinstanceid();
        string s = onlyid.tostring() + component.getlocalid();
        onlyid = convert.toint64(s);
        return onlyid;
    }

    public static int getlocalid(this component component)
    {
        propertyinfo info = typeof(serializedobject).getproperty("inspectormode", bindingflags.nonpublic | bindingflags.instance);
        serializedobject sobj = new serializedobject(component);
        info.setvalue(sobj, inspectormode.debug, null);
        serializedproperty localidprop = sobj.findproperty("m_localidentfierinfile");
        return localidprop.intvalue;
    }
}

public interface iarchive
{
    public void load(string jsonvalue);
    public string save();
    public component getcomponent();
}

 此文件包含

        一个单例:archivemanager,

        两个结构体定义:archivedata、scenedata

        一个接口:iarchive

        三个拓展类:scenedataextension、archivedataextension、componentextension

archiveso.cs

using system.collections.generic;
using unityengine;
[createassetmenu(filename = "archiveso", menuname = "data/archiveso")]
public class archiveso : scriptableobject
{
    public list<archivedata> archives = new list<archivedata>();
}

此文件包含

        一个继承sciptableobject的类:archiveso

存储原理

        该系统本着可拓展,简易,低耦合的理念(实际上是我不会)

        其核心存储介质其实就是scriptableobject,数据类型是json。

        本来是打算用playerprefs做存储介质,但项目开工前做技术调查发现,好像playerprefs的setstring有点问题,且加上这个类是静态类不方便我管理和查看(可视化),所以最终使用scriptableobject作为存储介质。

数据架构

从上往下

 

代码解释

scenedata(struct)

[serializable]
public struct scenedata
{
    public static scenedata none = new scenedata() { scenename = null };
    /// <summary>
    /// 场景名称
    /// </summary>
    public string scenename;
    /// <summary>
    /// 储存的数据
    /// </summary>
    public dictionary<long, string> scenevalue;
        
    public scenedata(string scenename)
    {
        this.scenename = scenename;
        scenevalue = new dictionary<long, string>();
    }

    public static bool operator ==(scenedata data1, scenedata data2)
    {
        if (data1.scenename == data2.scenename)
            return true;
        return false;
    }
    public static bool operator !=(scenedata data1, scenedata data2)
    {
        if (data1.scenename == data2.scenename)
            return false;
        return true;
    }
}

scenedata结构体主要用于存储单个场景里需要保存的信息,其主要由一个scenename(string)记录场景名,和一个scenevalue(dictionary<long, string>)字典,主要讲解一下这个字典存的什么东西。

public dictionary<long, string> scenevalue

        在讲解这个字典前,我们需要先理清一下思路:

首先,我们使用

 查找场景内,所有实例中,带有继承了iarchive接口的组件的实例。

然后调用其接口,把每一个实例需要保存的json数据存在当前场景的scenedata的字典中。

那么问题来了,我们应该如何设置字典的key值,以达到每一个实例的每一个脚本(component)都独一无二,能精准在字典里获取其独有的数据。

可能稍微了解过一点gameobject类的读者会想到,使用gameobject.getinstance()作为字典的key值。

确实,在unity编辑器的inspector的debug模式下,我们可以看到,一个gameobject的每个组件的instanceid都是互不相同的。但我们需要注意一个点,我们这里的key,需要保证在整个项目里独一无二。但我们可以看官方api文档解释

618dd7f7605d4766963a0e82373d1b26.png

 这一句:the instance id of an object is always unique.

翻译中文:对象的实例 id 始终是唯一的。

只是看这句话,很多人可能都会和我一样被这句话误导,会以为每个对象的instanceid在这个对象被创立的时候就固定不变了。

实际上不是的,如果我们单只开一个场景查看,不管怎么重启项目,移动实例,确实它的instanceid始终是不变的,但如果我们尝试使用scenemanagment卸载加载场景,我们会发现它的instanceid发生了改变。

如果读者愿意再去查查,会发现unity的实例的instanceid并不是固定存在,而是通过两个值计算而来:guid、localid。

guid读者可以自行百度,该系统没有使用到该属性就不做讲解。

如果我们再仔细查看,我们可以发现

314adfb605334f7ea2f8a71b133f078a.png

组件属性里有一个local identfier in file的属性,这个属性值无论怎么切换场景,重启项目,值都是不变的,根据官方说法,这个值是存在asset下,至于具体怎么存的我们先不管。 然后就是我们可以看到这个值在整个gameobject中所有组件包括gameobject都是一样的,所以可以知道这个是指的gameobject实例的localid。

这个id的获取,unity并没有提供方法,我们需要使用c#的反射来获取

    public static int getlocalid(this component component)
    {
        propertyinfo info = typeof(serializedobject).getproperty("inspectormode", bindingflags.nonpublic | bindingflags.instance);
        serializedobject sobj = new serializedobject(component);
        info.setvalue(sobj, inspectormode.debug, null);
        serializedproperty localidprop = sobj.findproperty("m_localidentfierinfile");
        return localidprop.intvalue;
    }

如此,我们就获取了gameobject实例的唯一id。

接下来,我们需要解决的是,单个实例下,多个相同组件的唯一问题。

其实解决方法很简单,组件component有自己的instanceid,gameobject也有自己的instanceid,这两者并不相同,而且通过实验,组件的id和组件附属的gameobject的id之间的差值,其实是固定的,也就是:

 这里使用long数据类型来储存,为后面的处理做准备。

然后将onlyid和localid组合一下,就得到该组件的唯一id;

拓展方法如下

public static class componentextension
{
    public static long getonlyid(this component component)
    {
        long onlyid = component.getinstanceid() - component.gameobject.getinstanceid();
        string s = onlyid.tostring() + component.getlocalid();
        onlyid = convert.toint64(s);
        return onlyid;
        
    }

    public static int getlocalid(this component component)
    {
        propertyinfo info = typeof(serializedobject).getproperty("inspectormode", bindingflags.nonpublic | bindingflags.instance);
        serializedobject sobj = new serializedobject(component);
        info.setvalue(sobj, inspectormode.debug, null);
        serializedproperty localidprop = sobj.findproperty("m_localidentfierinfile");
        return localidprop.intvalue;
    }
}

综上,scenevalue的key就可以确定好了。

然后就是value,文章开头已经解释了,该存档系统的存储数据格式就是json字符串,所以这里的value也就是字符串,不过是序列化成json格式的字符串。

scenedata函数拓展

public static class scenedataextension
{
    public static void savevalue(this scenedata data, component component, string jsonvalue)
    {
        long onlyid = component.getonlyid();
        data.savevalue(onlyid,jsonvalue);
    }
    public static void savevalue(this scenedata data, long id, string jsonvalue)
    {
        if (data.scenevalue.containskey(id))
            data.scenevalue[id] = jsonvalue;
        else
            data.scenevalue.add(id,jsonvalue);
    }

    public static string loadvalue(this scenedata data, component component)
    {
        long id = component.getonlyid();
        return data.loadvalue(id);
    }

    public static string loadvalue(this scenedata data, long id)
    {
        if(!data.scenevalue.containskey(id))
            return string.empty;
        return data.scenevalue[id];
    }
}

archivedata(struct)

[serializable]
public struct archivedata
{
    public static archivedata none = new archivedata();
    /// <summary>
    /// 当前存档名
    /// </summary>
    public string archivename;

    /// <summary>
    /// 存档最后一次存储时间
    /// </summary>
    public string currenttime;

    /// <summary>
    /// 存档所处的场景
    /// </summary>
    public string currentscenename;
    [textarea]
    public string archivevaluejson;

    
    public dictionary<string, scenedata> archivevalue;

    public archivedata(string archivename,string scenename)
    {
        archivename = archivename;
        currenttime = system.datetime.now.tostring();
        currentscenename = scenename;
        archivevalue = new dictionary<string, scenedata>();
        archivevaluejson = string.empty;
    }
    public static bool operator ==(archivedata data1, archivedata data2)
    {
        if (data1.archivename == data2.archivename && data1.archivevalue == data2.archivevalue)
            return true;
        return false;
    }
    public static bool operator !=(archivedata data1, archivedata data2)
    {
        if (data1.archivename == data2.archivename && data1.archivevalue == data2.archivevalue)
            return false;
        return true;
    }
}

archivedata结构体是单个存档的数据结构,内容和scenedata相似。

存档名称、存档最后一次存储时间、存档最后一次存储时的场景名称三个属性就不一一描述了,都是string类型,最后一个场景名称就是scene.name,主要方便告诉场景管理器这个存档最后一次存储时所在的场景,如果游戏有需要,可以选择加载存档时加载到该场景。

主要讲解内容是

archivevaluejson(string)
archivevalue(dictionary<string, scenedata>)

可能会有读者会有疑惑,archivevaluejson的作用是什么,不是已经有一个字典存数据了嘛。

这里就涉及到一个unity理论知识,根据官方定义,unity的scriptableobject只能存储可序列化的数据,也就是那个标签[serializable]。

熟悉unity序列化的读者都知道,unity的字典是没有被序列化的,可能会有人想到odin的对字典序列化。

但我这里为了降低耦合度,非必要我是不会使用非官方插件,而且odin的序列化只在odin插件中有效,如果要存储到scriptableobject中,可能需要重写一些东西(没仔细研究过odin)。

所以,字典的数据最终是不会被存储到scriptableobject中,所以这里我们使用newtonsoft.json库的json序列化字典为json格式字符串并储存,读取时再取出来反序列化为字典。

archivedata函数拓展

public static class archivedataextension
{
    public static void savevalue(this archivedata data,string scenename,scenedata scenedata)
    {
        if (data.archivevalue.containskey(scenename))
            data.archivevalue[scenename] = scenedata;
        else
            data.archivevalue.add(scenename,scenedata);
    }

    public static void savevalue(this archivedata data, scene scene, scenedata scenedata)
    {
        data.savevalue(scene.name,scenedata);
    }

    public static scenedata loadvalue(this archivedata data,string scenename)
    {
        if(!data.archivevalue.containskey(scenename))
            return scenedata.none;
        return data.archivevalue[scenename];
    }

    public static scenedata loadvalue(this archivedata data, scene scene)
    {
        return data.loadvalue(scene.name);
    }

    public static string tojson(this archivedata data)
    {
        string value = jsonconvert.serializeobject(data.archivevalue);
        return value;
    }

    public static  dictionary<string, scenedata> fromjson(this archivedata data)
    {
        if(data.archivevaluejson == string.empty)
            return null;
        dictionary<string, scenedata> datas =
            jsonconvert.deserializeobject<dictionary<string, scenedata>>(data.archivevaluejson);
        return datas;
    }
}

archiveso(scriptableobject)

using system.collections.generic;
using unityengine;
[createassetmenu(filename = "archiveso", menuname = "data/archiveso")]
public class archiveso : scriptableobject
{
    public list<archivedata> archives = new list<archivedata>();
}

这个就没必要多解释,一个list列表。

archivemanager(monobehaviour)

public class archivemanager : monobehaviour
{
    // public buttonfunction 保存存档;
    #region 成员、属性

    #region 静态
    private static archivemanager instance;
    public static archivemanager instance { get => instance; }
    public static archivedata currentarchive = archivedata.none;
    public static scene currentscene;
    public archivedata scenebuffer = new archivedata("buffer","buffer");
    #endregion

    #region 非静态
    [serializefield]
    private archiveso archivesodata;

    #endregion

    #endregion
    #region 生命周期

    private void awake()
    {
        instanceinit();
        // 保存存档 = new buttonfunction(this,"保存新档", saveasnew);
    }

    private void onenable()
    {
        scenemanager.sceneunloaded += sceneunloaded;
        scenemanager.sceneloaded += sceneloaded;
    }


    private void ondisable()
    {
        scenemanager.sceneunloaded -= sceneunloaded;
        scenemanager.sceneloaded -= sceneloaded;
    }

    #endregion
    #region 单例

    /// <summary>
    /// 实现单例
    /// </summary>
    private void instanceinit()
    {
        if (instance != null)
        {
            destroy(this);
        }
        instance = this;
        
        dontdestroyonload(this);
    }
    #endregion
    /// <summary>
    /// 卸载场景前,对场景所有物体进行遍历,将其值存到缓存结构体中
    /// </summary>
    /// <param name="scene"></param>
    private void sceneunloaded(scene scene)
    {
        //todo:装载场景里所有gameobject到缓存池中
        var objs = gameobject.findobjectsoftype<monobehaviour>().oftype<iarchive>();
        scenedata scenedata = new scenedata(scene.name);
        foreach (var archive in objs)
        {
            long id = archive.getcomponent().getonlyid();
            string value = archive.save();
            scenedata.savevalue(id,value);
        }
        scenebuffer.savevalue(scene,scenedata);

    }
    /// <summary>
    /// 场景加载后,对场景所有物体进行遍历,调用接口赋值
    /// </summary>
    /// <param name="scene"></param>
    /// <param name="mode"></param>
    private void sceneloaded(scene scene, loadscenemode mode)
    {
        //todo:加载数据到场景中
        if(currentarchive == archivedata.none)
            return;
        var scenedata = currentarchive.loadvalue(scene);
        if(scenedata == scenedata.none)
            return;
        var objs = gameobject.findobjectsoftype<monobehaviour>().oftype<iarchive>();
        foreach (var archive in objs)
        {
            long key = archive.getcomponent().getonlyid();
            var value = scenedata.loadvalue(key);
            archive.load(value);
        }

        currentscene = scene;
    }

    #region 函数
    /// <summary>
    /// 返回给ui去查看有哪些存档
    /// </summary>
    /// <returns></returns>
    public list<archivedata> getarchivelist()
    {
        return archivesodata.archives;
    }

   

    /// <summary>
    /// 保存为新存档
    /// </summary>
    public void saveasnew()
    {
        archivesodata.archives.add(save());
    }

    /// <summary>
    /// 替换存档
    /// </summary>
    /// <param name="index"></param>
    public void saveinindex(int index)
    {
        if(index < 0 || index >= archivesodata.archives.count)
            return;
        archivesodata.archives[index] = save();
    }

    /// <summary>
    /// 创建新档
    /// </summary>
    public void loadasnew()
    {
        currentarchive = archivedata.none;
    }
     public void loadinindex(int index)
    {
        if (index < 0 || index >= archivesodata.archives.count)
        {
            debug.logerror("下标不在范围内");
            return;
        }
        currentarchive = archivesodata.archives[index];
        currentarchive.archivevalue = currentarchive.fromjson();
    }
    private archivedata save()
    {
        var data = new archivedata(string.empty,scenemanager.getactivescene().name);
        //更新当前场景的数据到缓存中
        sceneunloaded(scenemanager.getactivescene());
        data.archivevalue = scenebuffer.archivevalue;
        data.archivevaluejson = data.tojson();
        return data;
    }
    #endregion

}

archivemanager类作为管理类,其使用单例模式保证项目里只会有一个实例。

我们先讲该类的运行逻辑再根据逻辑衍生去讲属性和函数的意义。

程序逻辑

当我们新建存档后,就加载进游戏场景,然后根据游戏需求,可能会存在卸载场景,加载场景的步骤。根据官方api文档介绍,scenemanager.sceneunloaded

scenemanager.sceneloaded

两个事件分别对应场景卸载前和场景加载后(?加载后还有待商议,为进行大场景测试)。

根据我们的框架逻辑:场景卸载前遍历场景实例储存数据到临时的archivedata容器,场景加载后遍历场景实例加载当前存档数据到实例中。

于是我们就可以订阅这两个事件,让框架自动在切换场景时存储和加载数据。

ps,笔者能力、时间有限,为进行大场景测试,所有这里笔者推荐大家不要使用框架的自动读取加载,而是自己手动控制流程

 

然后介绍一下几个公共函数接口

getarchivelist :返回存档的列表

saveasnew :保存为新建存档

saveinindex(int index):替换存档列表里下标为index的存档为当前存档。

loadasnew:新建存档时调用此函数,重置存档管理器的临时存档。

loadinindex(int index):加载下标index的存档

 

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2025  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com