七:服务端资产库文件夹结构
http://kbengine.org/cn/docs/concepts/directorys.html
看assets, 注意:demo使用的不是默认的assets资产目录,而是上面章节下载的kbengine_demos_assets,但文件夹结构与意义是一致的。

八:客户端文件夹结构
kbengine_unity3d_demo
             -> Assets                                                         // Unity3d资产库
                      -> Plugins
                              -> kbengine                                   // KBEngine插件层(包含了网络消息处理、客户端实体维护、与服务端对接层)
                      -> Scripts
                              -> kbe_scripts                                // 客户端逻辑脚本层(https://github.com/kbengine/kben ... e_scripts/README.md
                                       -> Account.cs                       // 对应于服务端的账号实体的客户端部分实现
                                       -> Avatar.cs                          // 对应于服务端的角色实体的客户端部分实现
                                       -> clientapp.cs                      // 按照服务端的概念cellapp、baseapp、etc,这里我们抽象出一个clientapp
                                       -> Combat.cs                       // 对应于服务端的def interfaces/Combat的客户端部分实现
                                       -> GameObject.cs                 // 对应于服务端的def interfaces/GameObject的客户端部分实现
                                       -> Gate.cs                             // 对应于服务端的Gate实体的客户端部分实现
                                       -> Monster.cs                       // 对应于服务端的Monster实体的客户端部分实现
                                       -> NPC.cs                             // 对应于服务端的NPC实体的客户端部分实现
                                       -> Skill.cs                              // 一个简单的不能再简单的技能执行类,服务端cell/skill下面也有,而客户端主要是进行一些检查
                                       -> SkillBox.cs                        // 玩家的技能列表,对应于服务端的def interfaces/Skillbox的客户端部分实现
                                       -> SkillObject.cs                    // 技能对象(施法者、目标、受术者等),服务端cell/skill下面也有
                              -> u3d_scripts                               // 客户端UI等表现层
                                       -> UI.cs                                // 处理UI部分
                                       -> World.cs                          // 处理场景世界部分
                                       -> GameEntity.cs                 // 所有服务端同步过来的实体在表现层都必须继承该类,完成统一的表现(头顶名称、血条等)与控制(实体状态、移动)

------------------------------------------

基本设计结构:
                                                                  -游戏-
                                   |                                                                        |
                  表现层u3d_scripts(UI && 世界)                      KBE层kbe_scripts(插件 && 逻辑)

1:  表现层与KBE层可以配置为不同线程也能配置为同一个线程跑(单线程)
2:  表现层与KBE层使用事件交互, 向KBE层触发的事件使用fireIn(...),KBE层向外部触发的事件使用fireOut(...)。 那么表现层想要监听KBE触发的Out事件,需要注册监听Event.registerOut, KBE需要监听外部触发进来的事件则反之。
3: 使用unity3D插件与服务端配套则服务端中的scripts/client文件夹可以忽略(https://github.com/kbengine/kben ... e_scripts/README.md)

九:游戏配置
服务端demo所有的配置都存放于kbengine_demos_assets\scripts\data之下。
scripts\data\
               d_avatar_inittab.py    // 角色初始化表, 用于新建立的角色设置初始值, 由kbengine\kbe\tools\xlsx2py\rpgdemo\avatar_init.bat导出。
               d_dialogs.py               // NPC对话表, 其中'menu1'对于的是一个对话协议的ID,服务端根据不同的协议ID执行不同的对话功能, 由kbengine\kbe\tools\xlsx2py\rpgdemo\dialogs.bat导出。
               d_entities.py               // 实体类型表,描述某类型怪移动速度,攻击力等,由kbengine\kbe\tools\xlsx2py\rpgdemo\NPC.bat导出。
               d_skills.py                   // 技能表,描述某类型技能判定条件,输出等,由kbengine\kbe\tools\xlsx2py\rpgdemo\skils.bat导出。
               d_spaces.py               // 场景副本表,描述space是大地图还是副本,以及地图名称等,由kbengine\kbe\tools\xlsx2py\rpgdemo\spaces.bat导出。
               d_spaces_spawns.py // NPC、Monster等出生点信息,目前是手填的,也可以采用工具布点导出。

        spawnpoints\
                 xinshoucun_spawnpoints.xml   // 这个出生点信息主要用于warring这个demo,(NPC、Monster等出生点信息,采用Unity3d布点导出, 可以在unity打开warring这个demo,
                                                                 // 在unity3d(菜单上)->Publish->Build Publish AssetBundles(打包所有需要动态加载资源),然后在Assets->StreamingAssets目录下会得到 "场景名称_spawnpoints.xml"的出生点表)。

十:创建账号

客户端部分:
1: kbengine_unity3d_demo\Assets\Scripts\u3d_scripts\UI.cs
        1.1 点击登录按钮导致createAccount()被调用, createAccount中向KBE层触发了一个创建账号事件,参数是账号名与密码。
          注意:KBEngine插件kbengine_unity3d_demo\Assets\Plugins\kbengine\kbengine_unity3d_plugins\KBEngine.cs中已经注册了这个“createAccount”事件,对应于KBEngineApp.createAccount函数。

  1. public void createAccount()
  2. {
  3. KBEngine.Event.fireIn("createAccount", new object[]{stringAccount, stringPasswd});
  4. }

复制代码

2. 事件在KBE插件中kbengine_unity3d_demo\Assets\Plugins\kbengine\kbengine_unity3d_plugins\KBEngine.cs, KBEngineApp.process()中被正式处理

  1. /*
  2. 插件的主循环处理函数
  3. */
  4. public virtual void process()
  5. {
  6. // 处理网络
  7. _networkInterface.process();
  8. // 处理外层抛入的事件
  9. Event.processInEvents();
  10. // 向服务端发送心跳以及同步角色信息到服务端
  11. sendTick();
  12. }

复制代码

3. 创建账号函数被调用, createAccount_loginapp函数表示请求向服务端loginapp进程要求创建一个账号,而此时可能还没有连接服务器,需要先连接,如果已经连接上了则向loginapp发送一个包“bundle.send”。
            可以看到向Bundle中写入了相关需要的数据,而Bundle会将数据序列化成二进制流,服务端会采用相同的协议将其归原并将调用服务端协议所绑定的方法(后面会讲到服务端具体方法)。

  1. public void createAccount(string username, string password)
  2. {
  3. KBEngineApp.app.username = username;
  4. KBEngineApp.app.password = password;
  5. KBEngineApp.app.createAccount_loginapp(true);
  6. }
  7. /*
  8. 创建账号,通过loginapp
  9. */
  10. public void createAccount_loginapp(bool noconnect)
  11. {
  12. if(noconnect)
  13. {
  14. reset();
  15. _networkInterface.connectTo(_args.ip, _args.port, onConnectTo_createAccount_callback, null);
  16. }
  17. else
  18. {
  19. Bundle bundle = new Bundle();
  20. bundle.newMessage(Message.messages["Loginapp_reqCreateAccount"]);
  21. bundle.writeString(username);
  22. bundle.writeString(password);
  23. bundle.writeBlob(new byte[0]);
  24. bundle.send(_networkInterface);
  25. }
  26. }

复制代码

创建返回结果:
UI.cs -> onCreateAccountResult

服务端部分:
1. 通过上面可以得知客户端向服务端发送了一条创建账号的协议, 协议名称为“Loginapp_reqCreateAccount”(注意,所有的协议名称都能在服务端找到对应的方法, Loginapp_代表了协议的作用域仅为Loginapp, 方法名称为reqCreateAccount)

  1. void Loginapp::reqCreateAccount(Network::Channel* pChannel, MemoryStream& s)
  2. {
  3. std::string accountName, password, datas;
  4. s >> accountName >> password;
  5. s.readBlob(datas);
  6. if(!_createAccount(pChannel, accountName, password, datas, ACCOUNT_TYPE(g_serverConfig.getLoginApp().account_type)))
  7. return;
  8. }

复制代码

服务端解析出了账号名与密码,在_createAccount函数中会将这条请求最终送到dbmgr,dbmgr检查之后决定是否创建数据库账号,并最终将结果返回到loginapp,然后由loginapp将结果中转至客户端。

十一:登录账号
1: kbengine_unity3d_demo\Assets\Scripts\u3d_scripts\UI.cs, 向KBE层触发了登陆事件

  1. public void login()
  2. {
  3. info("connect to server...(连接到服务端...)");
  4. KBEngine.Event.fireIn("login", new object[]{stringAccount, stringPasswd});
  5. }

复制代码

2: kbengine_unity3d_demo\Assets\Plugins\kbengine\kbengine_unity3d_plugins\KBEngine.cs, 插件触发登陆函数,并最终向loginapp发送了一个登陆包“Loginapp_login”

  1. public void login(string username, string password)
  2. {
  3. KBEngineApp.app.username = username;
  4. KBEngineApp.app.password = password;
  5. KBEngineApp.app.login_loginapp(true);
  6. }
  7. /*
  8. 登录到服务端(loginapp), 登录成功后还必须登录到网关(baseapp)登录流程才算完毕
  9. */
  10. public void login_loginapp(bool noconnect)
  11. {
  12. if(noconnect)
  13. {
  14. reset();
  15. _networkInterface.connectTo(_args.ip, _args.port, onConnectTo_loginapp_callback, null);
  16. }
  17. else
  18. {
  19. Dbg.DEBUG_MSG("KBEngine::login_loginapp(): send login! username=" + username);
  20. Bundle bundle = new Bundle();
  21. bundle.newMessage(Message.messages["Loginapp_login"]);
  22. bundle.writeInt8((sbyte)_args.clientType); // clientType
  23. bundle.writeBlob(new byte[0]);
  24. bundle.writeString(username);
  25. bundle.writeString(password);
  26. bundle.send(_networkInterface);
  27. }
  28. }

复制代码

服务端部分:
1:服务端loginapp.cpp中“void Loginapp::login(Network::Channel* pChannel, MemoryStream& s)”被触发, 这个函数进行了一系列的检查,
确定合法后向dbmgr发送一个登陆请求包“(*pBundle).newMessage(DbmgrInterface::onAccountLogin);”, dbmgr也会进行一系列的检查并将登陆结果返回到loginapp。

  1. void Loginapp::login(Network::Channel* pChannel, MemoryStream& s)
  2. {
  3. ...
  4. ...
  5. if(loginName.size() > ACCOUNT_NAME_MAX_LENGTH)
  6. {
  7. INFO_MSG(fmt::format("Loginapp::login: loginName is too long, size={}, limit={}.\n",
  8. loginName.size(), ACCOUNT_NAME_MAX_LENGTH));
  9. _loginFailed(pChannel, loginName, SERVER_ERR_NAME, datas, true);
  10. s.done();
  11. return;
  12. }
  13. if(password.size() > ACCOUNT_PASSWD_MAX_LENGTH)
  14. {
  15. INFO_MSG(fmt::format("Loginapp::login: password is too long, size={}, limit={}.\n",
  16. password.size(), ACCOUNT_PASSWD_MAX_LENGTH));
  17. ...
  18. ...
  19. ...
  20. // 向dbmgr查询用户合法性
  21. Network::Bundle* pBundle = Network::Bundle::ObjPool().createObject();
  22. (*pBundle).newMessage(DbmgrInterface::onAccountLogin);
  23. (*pBundle) << loginName << password;
  24. (*pBundle).appendBlob(datas);
  25. dbmgrinfos->pChannel->send(pBundle);
  26. }

复制代码

1.1: loginapp得到dbmgr的登录合法结果后向baseappmgr发送了分配网关(baseapp)请求(registerPendingAccountToBaseapp), 通常是负载较低的一个baseapp进程.

  1. void Loginapp::onLoginAccountQueryResultFromDbmgr(Network::Channel* pChannel, MemoryStream& s)
  2. {
  3. ...
  4. ...
  5. ...
  6. // 如果大于0则说明当前账号仍然存活于某个baseapp上
  7. if(componentID > 0)
  8. {
  9. Network::Bundle* pBundle = Network::Bundle::ObjPool().createObject();
  10. (*pBundle).newMessage(BaseappmgrInterface::registerPendingAccountToBaseappAddr);
  11. (*pBundle) << componentID << loginName << accountName << password << entityID << dbid << flags << deadline << infos->ctype;
  12. baseappmgrinfos->pChannel->send(pBundle);
  13. return;
  14. }
  15. else
  16. {
  17. // 注册到baseapp并且获取baseapp的地址
  18. Network::Bundle* pBundle = Network::Bundle::ObjPool().createObject();
  19. (*pBundle).newMessage(BaseappmgrInterface::registerPendingAccountToBaseapp);
  20. (*pBundle) << loginName;
  21. (*pBundle) << accountName;
  22. (*pBundle) << password;
  23. (*pBundle) << dbid;
  24. (*pBundle) << flags;
  25. (*pBundle) << deadline;
  26. (*pBundle) << infos->ctype;
  27. baseappmgrinfos->pChannel->send(pBundle);
  28. }
  29. }

复制代码

1.2:baseappmgr最终返回所分配的baseapp的ip地址等信息,loginapp将其转发给客户端(登录成功协议onLoginSuccessfully,包含baseapp的ip和端口信息)

  1. void Loginapp::onLoginAccountQueryBaseappAddrFromBaseappmgr(Network::Channel* pChannel, std::string& loginName,
  2. std::string& accountName, std::string& addr, uint16 port)
  3. {
  4. ...
  5. ...
  6. ...
  7. Network::Bundle* pBundle = Network::Bundle::ObjPool().createObject();
  8. (*pBundle).newMessage(ClientInterface::onLoginSuccessfully);
  9. uint16 fport = ntohs(port);
  10. (*pBundle) << accountName;
  11. (*pBundle) << addr;
  12. (*pBundle) << fport;
  13. (*pBundle).appendBlob(infos->datas);
  14. pClientChannel->send(pBundle);
  15. SAFE_RELEASE(infos);
  16. }

复制代码

2: 客户端插件得到返回结果后调用KBEngineApp.cs->login_baseapp()函数开始正式登录到baseapp。

3:baseapp收到登录请求

  1. void Baseapp::loginGateway(Network::Channel* pChannel,
  2. std::string& accountName,
  3. std::string& password)

复制代码

进行了一系列的检查,包括:账号是否已经在线,是否可以在这里登录等等。
当检查合法后,向dbmgr发送了一个查询账号信息的请求“DbmgrInterface::queryAccount”,dbmgr将查询到的账号数据(包括属性等)返回到baseapp, Baseapp::onQueryAccountCBFromDbmgr
当函数结果为合法时,根据配置中定义的账号实体脚本名称“g_serverConfig.getDBMgr().dbAccountEntityScriptType”创建了Account实体, 同时还创建了一个clientMailbox,账号实体中调用clientMailbox->方法()即可与客户端通讯了。
Account实体被创建后, 首先__init__被调用, 接着onEntitiesEnabled被调用, 此时实体正式可用了。

账号登陆成功后, 客户端Account.cs中会调用__init__() -> baseCall("reqAvatarList");来请求获得角色列表,
UI.cs中onReqAvatarList得到结果。

十二:创建角色与选择角色进入游戏
1. 创建角色UI.cs -> void onSelAvatarUI()中
       account.reqCreateAvatar(1, stringAvatarName);
       UI.cs中onCreateAvatarResult得到结果。

2.选择角色进入游戏
UI.cs -> onSelAvatarUI()中
account.selectAvatarGame(selAvatarDBID);
这里使用角色的数据库ID作为标识,服务端上Account实体有角色列表属性,角色列表的数据结构大概为
AvatarList <Dict<AvatarDBID(UINT64), INFOS>>

十三:创建世界(大地图与副本)
1. 创建世界管理器服务端启动之后,baseapp与cellapp准备完毕、准备关闭等事件都会通知到kbengine_defs.xml配置中指定的个性化脚本。kbe默认个性化脚本为kbengine.py,  baseapp进程准备好之后会调用kbengine.py的onBaseAppReady回调函数, demo在这个函数中判定是否为第一个启动的baseapp(假如启动了很多baseapps),
如果是第一个baseapp,脚本创建了一个世界管理实体“spaces”:

  1. def onBaseAppReady(isBootstrap):
  2. """
  3. KBEngine method.
  4. baseapp已经准备好了
  5. @param isBootstrap: 是否为第一个启动的baseapp
  6. @type isBootstrap: BOOL
  7. """
  8. INFO_MSG('onBaseAppReady: isBootstrap=%s' % isBootstrap)
  9. # 安装监视器
  10. Watcher.setup()
  11. if isBootstrap:
  12. # 创建spacemanager
  13. KBEngine.createBaseLocally( "Spaces", {} )

复制代码

2. 世界管理器创建出所有的场景
在spaces.py中, spaces通过initAlloc函数根据配置中scripts/data/d_spaces.py创建出space实体,space实体描述的是一个抽象空间,一个空间可以被逻辑定义为大地图、场景、房间、宇宙等等。

  1. def initAlloc(self):
  2. # 注册一个定时器,在这个定时器中我们每个周期都创建出一些Space,直到创建完所有
  3. self._spaceAllocs = {}
  4. self.addTimer(3, 1, SCDefine.TIMER_TYPE_CREATE_SPACES)
  5. self._tmpDatas = list(d_spaces.datas.keys())
  6. for utype in self._tmpDatas:
  7. spaceData = d_spaces.datas.get(utype)
  8. if spaceData["entityType"] == "SpaceDuplicate":
  9. self._spaceAllocs[utype] = SpaceAllocDuplicate(utype)
  10. else:
  11. self._spaceAllocs[utype] = SpaceAlloc(utype)

复制代码

SpaceAlloc: 普通地图,可以理解为大地图,但整个世界中只能有一个这样类型的地图。
SpaceAllocDuplicate:副本地图,可以复制出很多个

上面函数注册了一个定时器, 这里是定时器的回调, 每一秒回调一次。
self._spaceAllocs[spaceUType].init(), 这里真正开始创建这些space实体, 里面调用的createBaseAnywhere函数来创建实体, 如果启动了多个baseapp这个函数根据负载情况将实体选择到合适的进程中创建。

  1. def createSpaceOnTimer(self, tid, tno):
  2. """
  3. 创建space
  4. """
  5. if len(self._tmpDatas) > 0:
  6. spaceUType = self._tmpDatas.pop(0)
  7. self._spaceAllocs[spaceUType].init()
  8. if len(self._tmpDatas) <= 0:
  9. del self._tmpDatas
  10. self.delTimer(tid)

复制代码

Space实体创建出来之后,此时还没有真正创建出空间, 这个实体仅仅是将要与某个真正空间关联的实体, 可以通过它来操控那个空间。
但空间只能在cellapp上存在, 因此我们需要调用API让实体在cell上创建出一个空间,并在cell上创建出一个实体与空间关联, 这个实体就像一个空间的句柄。

  1. class Space(KBEngine.Base, GameObject):
  2. def __init__(self):
  3. self.createInNewSpace(None)

复制代码

此功能由createInNewSpace完成, __init__可以理解为Space的构造函数。

3. 为这个抽象的空间增加几何数据
有指定几何数据的空间可以被看做是一个特定的场景, 这些几何数据与客户端对应的场景表现相关联, 例如:导航网格(navmesh), 服务端通过这些数据让NPC进行正确的移动,碰撞等。
上面Space创建cell部分之后, cell上的Space._init__也会被调用, 其中addSpaceGeometryMapping API接口完成几何数据加载工作
(注意:为了加载大量数据不让进程卡顿,这个数据加载是多线程的,它会通过一些回调来告诉开发者加载状态,具体参考API手册)。

  1. class Space(KBEngine.Entity, GameObject):
  2. def __init__(self):
  3. KBEngine.addSpaceGeometryMapping(self.spaceID, None, resPath)

复制代码

KBEngine简单RPG-Demo源码解析(2)的更多相关文章

  1. 使用.Net Core + Vue + IdentityServer4 + Ocelot 实现一个简单的DEMO +源码

    运行环境 Vue 使用的是D2admin: https://doc.d2admin.fairyever.com/zh/ Github地址:https://github.com/Fengddd/Perm ...

  2. springmvc运行流程简单解释(源码解析,文末附自己画的流程图)

    首先看一下DispatcherServlet结构: 观察HandlerExecutionChain对象的创建与赋值,这个方法用来表示执行这个方法的整条链. 进入getHandler方法: 此时的变量h ...

  3. 动画 ---Animejs 简单使用与源码解析

    Anime是什么 Anime有什么用 Anime是作何做的 requireAnimationFrame() engine(){ // 处理让多个帧运动起来 ​ play() ​ step()} ani ...

  4. Android 开源项目源码解析(第二期)

    Android 开源项目源码解析(第二期) 阅读目录 android-Ultra-Pull-To-Refresh 源码解析 DynamicLoadApk 源码解析 NineOldAnimations ...

  5. Mybatis源码解析-DynamicSqlSource和RawSqlSource的区别

    XMLLanguageDriver是ibatis的默认解析sql节点帮助类,其中的方法其会调用生成DynamicSqlSource和RawSqlSource这两个帮助类,本文将对此作下简单的简析 应用 ...

  6. Caffe2源码解析

    写在前面 上一篇文章对Caffe2中的core模块进行了简单拆解Caffe2源码解析之core,本篇给出其它模块的拆解,目的是大致了解每个模块的内容和目标,进一步理解Caffe2的整体框架.内容不多, ...

  7. KBEngine简单RPG-Demo源码解析(1)

    一:环境搭建1. 确保已经下载过KBEngine服务端引擎,如果没有下载请先下载          下载服务端源码(KBEngine):              https://github.com ...

  8. 简单理解 OAuth 2.0 及资料收集,IdentityServer4 部分源码解析

    简单理解 OAuth 2.0 及资料收集,IdentityServer4 部分源码解析 虽然经常用 OAuth 2.0,但是原理却不曾了解,印象里觉得很简单,请求跳来跳去,今天看完相关介绍,就来捋一捋 ...

  9. 使用CEF(三)— 从CEF官方Demo源码入手解析CEF架构与CefApp、CefClient对象

    在上文<使用CEF(2)- 基于VS2019编写一个简单CEF样例>中,我们介绍了如何编写一个CEF的样例,在文章中提供了一些代码清单,在这些代码清单中提到了一些CEF的定义的类,例如Ce ...

  10. 实现简单的手写涂鸦板(demo源码)

    在一些软件系统中,需要用到手写涂鸦的功能,然后可以将涂鸦的结果保存为图片,并可以将"真迹"通过网络发送给对方.这种手写涂鸦功能是如何实现的了?最直接的,我们可以使用Windows提 ...

随机推荐

  1. 【JAVAWEB学习笔记】11_XML&反射

    解析XML总结(SAX.Pull.Dom三种方式) 图一 XML的解析方式 图二 XML的Schema的约束 反射的简单介绍: 反射 1.什么是反射技术? 动态获取指定类以及类中的内容(成员),并运行 ...

  2. 基于腾讯云的Centos6.2系统搭建Apache+Mysql+PHP开发环境

    搭建环境,我肯定需要先购买腾讯云服务器的哦! 然后,我们打开SecureCRT 7.3,这是一款可以连接Linux系统的客户端工具,使用的很方便快捷,要注意的是,若你是Linux系统的就要用22端口, ...

  3. Node.js入门第一天

    一.Node.js简介 1.1 简介 V8引擎本身就是用于Chrome浏览器的JS解释部分,但是Ryan Dahl这哥们,鬼才般的,把这个V8搬到了服务器上,用于做服务器的软件. Node.js是一个 ...

  4. Opencv在linux下安装

    Opencv in Linux These steps have been tested for Ubuntu 10.04 but should work with other distros as ...

  5. Linux 常 用 命 令

    一:关机命令 1:shutdown 语 法:shutdown [-efFhknr][-t 秒数][时间][警告信息] 说明:shutdown指令可以关闭所有程序,并依用户的需要,进行重新开机或关机的动 ...

  6. Codeforces Round #102 (Div. 2) 题解

    A. 解一个方程. 还是厚颜无耻地暴力吧~ #include <iostream> using namespace std; int r1, r2, c1, c2, d1, d2; boo ...

  7. netty基础--基本收发

    使用maven构建一个基本的netty收发应用,作为其他应用的基础.客户端使用packet sender工具. 1  添加netty依赖 1  maven netty依赖 <dependency ...

  8. arcgis sde 导出栅格文件失败,提示“Database user name and current user schema do not match ”.

    具体错误/警告如下: 翻译一下:数据库用户名和当前用户数据库对象的集合不匹配 没有空间参考存在 数据库表没找到 主要还是第一句的问题. 解决方法:切换当前sde账户为能够写入sde的账户,这块不是很了 ...

  9. {网络编程}和{多线程}应用:基于UDP协议【实现多发送方发送数据到同一个接收者】--练习

    要求: 使用多线程实现多发送方发送数据到同一个接收者 代码: 发送端:可以在多台电脑上启动发送端,同时向接收端发送数据 注意:匹配地址和端口号 package com.qf.demo; import ...

  10. a标签实现文件下载

    如果想通过纯前端技术实现文件下载,直接把a标签的href属性设置为文件路径即可,如下: <a href="https://cdn.shopify.com/s/files/1/1545/ ...