简介这是一份以SDL2为基础实现的2D游戏引擎框架同时提供了复刻经典DOS游戏《金庸群侠传》的移植范例适合具备一定C基础、希望了解游戏循环、场景管理、战斗与事件系统的学习者。压缩包共186个文件体积约3.04MB包含69个头文件、56个C源文件、29个HPP头文件以及sln/vcxproj工程配置、txt/md说明文档、lib库、png图标素材目录按模块划分清晰。目前已有270人学习下载。从战斗场景、事件、粒子系统、子场景等源码模块中可以梳理完整的战斗流程、事件驱动机制、场景组织方式另有中文拼音转换与存档读写等功能代码可参考。代码采用h/cpp/hpp分离设计便于阅读与扩展能够直观理解SDL2环境下的窗口渲染、粒子特效、战斗脚本等核心实现。压缩包根目录附有说明文件对构建与运行中常见问题给出指引整体适合作为课程设计、毕业设计或游戏编程进阶的练手参考。1. 用 SDL2 复刻金庸群侠传这份 2D 引擎压缩包里到底有什么用 C 基于 SDL2 做一套 2D 游戏引擎再拿它去复刻 DOS 版金庸群侠传这是我见过最硬核也最实际的 C 练手路线。这份压缩包不是把原版画面重新描一遍的 Demo而是一个能跑起来的引擎框架窗口渲染、事件分发、子场景切换、战斗流程、粒子特效、存档读写、zip 资源包读取全都有对应源码且每一块都按「移植老游戏」这个目标组织。它的价值在于你拿到的不只是某个单独算法而是一条完整的移植链路。适合想把 C 从语法层面推进到工程层面的人也适合想研究老游戏数据格式的人。下面我会按模块拆开讲并给出能直接抄的代码骨架和踩坑记录。2. 环境与骨架SDL2 初始化、主循环与 zip 资源读取2.1 为什么是 SDL2窗口、渲染、输入一次搞定复刻 DOS 游戏时大部分时间花在「怎么把原来的逻辑挪到现代窗口环境」上。原版金庸群侠传是键盘操作、鼠标点击、固定分辨率 640×480 的老式界面需要在新的窗口系统里重新搭一套输入映射和绘制管线。SDL2 在这一步几乎是唯一不需要纠结的选型它把窗口创建、OpenGL/Direct3D 后端、键盘鼠标手柄输入、音频、定时器全部封装成统一的 C APIWindows、Linux、macOS 都能编译。对移植老游戏来说最省心的是它内置了SDL_Renderer这套 2D 加速接口可以直接把原版的 320×200 或者 640×480 像素帧缩放渲染到任意尺寸窗口不用自己处理位图拉伸。我一般会用SDL_CreateWindowAndRenderer一把创建窗口和渲染器省掉单独SDL_CreateRenderer的样板代码。窗口标题、位置、尺寸在这一个函数里全搞定。下面是这份引擎里最常见的初始化写法也是每个 SDL2 项目的地基if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER) ! 0) { log_error(SDL_Init failed: %s, SDL_GetError()); return -1; } SDL_Window *win SDL_CreateWindow( JY Remake, // 窗口标题 SDL_WINDOWPOS_CENTERED, // 水平居中 SDL_WINDOWPOS_CENTERED, // 垂直居中 640, 480, // 逻辑分辨率 SDL_WINDOW_SHOWN ); SDL_Renderer *ren SDL_CreateRenderer(win, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);逻辑说明SDL_INIT_TIMER建议显式开启因为后续帧率控制和事件延迟都依赖定时器。分辨率直接设成原版 640×480放大交给SDL_RenderSetLogicalSize处理这样绘制代码永远按老坐标写不会被窗口尺寸变化干扰。参数说明SDL_RENDERER_PRESENTVSYNC在有显示器垂直同步的情况下能把帧率锁到 60避免 CPU 空转如果做移植调试建议先关掉 VSYNC用SDL_GetTicks()自己控制节奏原因在避坑章节细说。2.2 主循环骨架先有 update 再造 render很多第一次写 SDL2 程序的人会在while (1)里直接写满绘制逻辑顺便在里面读键盘事件、更新游戏状态。这种做法在简单 Demo 里能跑但一旦战斗场景、对话、粒子特效叠在一起帧率会乱跳操作响应也开始飘。这份引擎的主循环用的是经典的 update/render 分离结构。事件放在每帧开头统一收集逻辑更新只依赖delta_time绘制只负责把当前状态画出来互不掺和。bool running true; Uint32 last_tick SDL_GetTicks(); while (running) { Uint32 now SDL_GetTicks(); float delta (now - last_tick) / 1000.0f; last_tick now; SDL_Event ev; while (SDL_PollEvent(ev)) { if (ev.type SDL_QUIT) running false; g_event_dispatcher.dispatch(ev); // 交给 Event.cpp 分发 } g_current_scene-update(delta); // 逻辑更新 g_current_scene-render(ren); // 绘制当前场景 SDL_RenderPresent(ren); SDL_Delay(1); }逻辑说明delta是以秒为单位的浮点时间差战斗动画、人物移动、粒子位移全部用它做缩放这样在 30 FPS 和 60 FPS 下跑出的速度一致。SDL_PollEvent用while包住而不是if是因为每帧可能排着多个输入事件只取一个会造成按键丢失。参数说明末尾的SDL_Delay(1)是给系统让出一点 CPU如果帧率锁不稳优先检查是不是SDL_RenderPresent之后马上又做了大量计算而不是去调这个延时。2.3 zip.c 资源包把素材读进 SDL_RWops金庸群侠传的素材量非常大地图块、头像、战斗贴图、音乐音效如果全部散落成普通文件工程目录会乱成一团而且移植版发布时丢一个文件就崩。这份资源里专门有一个zip.c它做的事情就是把素材打成 zip 包运行时直接用 SDL 的资源接口从压缩包里读文件不落地解压。SDL2 对这类需求有一个很合适的概念叫SDL_RWops它是一个抽象的读写句柄内部可以是文件、内存块、网络数据也可以是「从一个 zip 包里解压出来的内容」。引擎层面只需要拿到一个SDL_RWops*根本不用关心素材到底来自哪里。zip.c 相当于自己实现了一个「按文件名从 zip 包定位并解压单个文件」的读取器编译进工程后加载贴图的代码长这样SDL_RWops *rw zip_open_entry(jy.res, hd/001.grp); if (rw nullptr) { log_error(cannot open asset: hd/001.grp); return nullptr; } SDL_Surface *surf IMG_Load_RW(rw, 1); // 1 表示 RWops 由 IMG 接管释放逻辑说明第一步zip_open_entry打开资源包jy.res在 zip 中央目录里定位到hd/001.grp这个条目解压后包装成SDL_RWops第二步IMG_Load_RW是 SDL_image 提供的接口它能识别 PNG、JPG、BMP 等多种格式。参数说明第二个参数传 1 很关键它告诉 SDL_image 用完这个 RWops 后由它负责释放避免自己记SDL_RWclose导致 double-free如果不确定会不会在多个地方共用同一个 RWops就传 0 自己管。素材管理统一走这个入口之后后续换皮肤、换资源包都只需要换文件名不用改任何加载代码。3. 事件分发与战斗场景把回合制战斗搬进 SDL23.1 Event.cpp把 SDL 事件分发给场景SDL 本身只提供最底层的事件结构体比如键盘按下、鼠标移动、窗口关闭。如果每个场景都自己写一遍switch (ev.type)BattleScene 写一套、SubScene 又写一套代码很快就发散改键位时要同时改好几个文件。这份引擎里的Event.cpp起的是一个中间层的作用它把 SDL 事件转换成游戏内部的事件类型再交给当前场景注册的回调处理。这样上层场景不需要知道SDL_KEYDOWN和SDL_MOUSEBUTTONDOWN的区别它只关心「有一个确认键被按下了」或者「鼠标点在某个 UI 矩形上」。// event.h 里定义的事件类型 enum GameEventType { EVENT_CONFIRM, // 确认空格 / 回车 / 左键 EVENT_CANCEL, // 取消Esc / 右键 EVENT_MOVE_UP, EVENT_MOVE_DOWN, EVENT_MOVE_LEFT, EVENT_MOVE_RIGHT, EVENT_ITEM_SELECT, // 选中某个道具/菜单项 EVENT_QUIT }; void EventDispatcher::dispatch(SDL_Event *ev) { switch (ev-type) { case SDL_KEYDOWN: map_key(ev-key.keysym.sym); // 物理键 - 游戏事件 break; case SDL_MOUSEBUTTONDOWN: if (ev-button.button SDL_BUTTON_LEFT) notify(EVENT_CONFIRM, ev-button.x, ev-button.y); break; } }逻辑说明这里把键盘和鼠标的左键都归一到EVENT_CONFIRM所有场景只需要监听这一个事件。参数说明map_key内部是一个键映射表把SDLK_SPACE、SDLK_RETURN、SDLK_z都映射成EVENT_CONFIRM不同的机器、不同的键位习惯改这个表就行上层逻辑完全不动。这么做最大的好处是当你想让鼠标也能玩回合制战斗时只需要在dispatch里补鼠标分支战斗场景的代码一行不用改。3.2 BattleScene / BattleMod / BattleMenu战斗三件套怎么分工战斗是金庸群侠传里最复杂的子系统涉及场景绘制、敌我单位状态、菜单交互、招式动画四块内容。如果全塞进一个BattleScene.cpp文件能上千行改一个功能要滚动半天。这份引擎把战斗拆成了三个文件各管一摊文件职责核心内容BattleScene.cpp战斗流程控制回合切换、敌我 AI、招式命中结算、战斗结束条件BattleMod.cpp战斗数据与规则人物属性、武功伤害公式、内力/体力消耗、Buff 计算BattleMenu.cpp战斗菜单 UI攻击/武功/道具/逃跑菜单、光标移动、目标选择这个拆法在老游戏移植里尤其正确。泛用思路是规则和表现分离。BattleMod.cpp里没有任何绘制代码它只负责算BattleMenu.cpp只管怎么画不关心数值BattleScene.cpp把两者串起来。后续想改伤害公式只动BattleMod.cpp想改菜单样式只动BattleMenu.cpp互相不污染。3.3 回合制战斗的状态机老式回合制战斗的本质是一个很有限的状态机等待输入、执行指令、播放动画、结算伤害、检查死亡、进入下一回合。很多移植版翻车就是因为这些状态没有明确建模全写在update里的if嵌套里加一个新武功特效就得重构。我拆这类代码的习惯是先画状态枚举再写有限的转移条件。以下是一个最小可用的战斗流程框架enum BattlePhase { PHASE_SELECT_ACTOR, // 选择我方行动单位 PHASE_SELECT_ACTION, // 选择指令攻击/武功/道具/逃跑 PHASE_TARGETING, // 选择目标 PHASE_ANIMATION, // 招式动画播放 PHASE_DAMAGE, // 命中与伤害结算 PHASE_ENEMY_TURN, // 敌方行动 PHASE_VICTORY_CHECK // 检查战斗是否结束 }; void BattleScene::update(float delta) { switch (phase) { case PHASE_SELECT_ACTOR: // 光标在己方队列移动确认后进入选指令 break; case PHASE_SELECT_ACTION: // BattleMenu::handle_input() 决定下一步 break; case PHASE_ANIMATION: // 推进招式动画帧播放完毕进入 PHASE_DAMAGE anim_timer delta; if (anim_timer anim_duration) phase PHASE_DAMAGE; break; case PHASE_DAMAGE: // 调用 BattleMod::compute_damage(actor, target, skill_id) // 扣血、飘字、更新血条 break; // ... } }逻辑说明每个case只负责当前阶段的最小职责转移条件明确写在phase的赋值处。参数说明anim_timer用之前说的delta累加这样动画在低帧率机器上也不会瞬秒PHASE_DAMAGE里把伤害计算全部交给BattleMod::compute_damage让公式逻辑独立于表现层。这种状态机结构在菜单弹窗、地图对话、事件演出里都能复用是所有场景类代码的通用骨架。4. 场景、粒子与存档SubScene、ParticleSystem、NewSave 的配合4.1 SubScene 子场景栈地图、对话、战斗的切换方式金庸群侠传里的场景切换非常频繁大地图走到客栈门口进入室内室内对话又触发战斗战斗结束退回室内。如果只用单一场景指针切来切去容易把之前的场景状态丢光尤其是对话进行到一半被打断这种撕扯情况。这份引擎用SubScene.cpp管理一个场景栈。地图是底层场景弹窗和对话是压在上面的子场景战斗可以再往上压一层。每帧只更新栈顶场景底层场景自动暂停战斗结束时pop一次自动回到原来的地图现场不用重载地图数据。void SceneManager::push(Scene *sc) { stack.push_back(sc); sc-on_enter(); // 子场景入场初始化 } void SceneManager::pop() { if (stack.empty()) return; stack.back()-on_exit(); // 允许子场景保存临时状态 delete stack.back(); stack.pop_back(); stack.back()-on_resume(); // 底层场景恢复时刷新 } void SceneManager::update(float delta) { stack.back()-update(delta); // 只更新栈顶 }逻辑说明push用于打开新场景pop用于关闭当前场景并回到上一层。参数说明on_enter/on_exit/on_resume这三个回调是子场景协议的核心比如战斗场景要在这个里加载敌方素材、地图场景要在on_resume里重绘被遮挡的 NPC 状态。需要注意的是pop里释放场景内存时一定要保证该场景的事件监听已经全部移除否则悬空指针会在下一帧dispatch时崩掉。4.2 ParticleSystem粒子池与随机数粒子系统在老游戏里不是必需品但是移植版加一点刀光、加一个内力爆发的冲击波视觉反馈会立刻现代起来新玩家更容易接受。这份引擎里的ParticleSystem.cpp和ParticleExample.cpp是配套的前者是通用的粒子发射、更新、绘制逻辑后者是具体招式效果比如独孤九剑的剑影的演示写法。粒子最忌讳的是每帧new一大堆小对象。常见做法是维护一个固定大小的池子所有粒子在系统初始化时一次性分配update里只做标记死亡粒子在数组里被回收复用。struct Particle { bool active; float x, y, vx, vy; float life, max_life; Uint8 r, g, b, a; }; void ParticleSystem::spawn(float x, float y, int count, int seed) { std::mt19937 rng(seed); // C11 随机数引擎 std::uniform_real_distributionfloat angle(0.0f, 2 * M_PI); for (int i 0; i count; i) { Particle *p get_inactive(); // 从池中找空闲粒子 if (!p) break; // 池满则丢弃多余粒子 float dir angle(rng); p-active true; p-x x; p-y y; p-vx std::cos(dir) * 80.0f; // 初始速度 p-vy std::sin(dir) * 80.0f; p-life p-max_life 0.6f; } }逻辑说明粒子系统每一帧更新所有活跃粒子x vx * delta、life - deltalife 0时直接置active false。参数说明seed用固定的招式编号传入同一个招式每次打出的粒子散布是一致的——这在做招式调优时很关键能复现某个难看的角度分布如果希望每次有随机变化就把seed换成time(nullptr)。粒子池的容量建议按「单个画面最多需要的粒子数 × 1.5」预留get_inactive返回空就丢弃而不是临时扩容否则 GC 和性能抖动会把帧率拉垮。4.3 NewSave 与 Hanz2Piny存档格式和中文索引存档系统在老游戏里最容易做坏初期随便写个结构体往文件里一塞后期加了新字段就全线崩档。这份资源里的NewSave.cpp解决的问题就是存档的版本兼容。存档写入时先写一个魔数SAVE_MAGIC和版本号再逐字段写入读档时先校验魔数和版本号再做字段迁移。这样后续引擎升级时旧存档还能读最多是缺了一些新增字段的默认值。bool save_game(const char *path, const GameState state) { FILE *fp fopen(path, wb); if (!fp) return false; uint32_t magic 0x4A595346; // JYSF uint32_t version SAVE_VERSION; // 当前存档格式版本 fwrite(magic, sizeof(magic), 1, fp); fwrite(version, sizeof(version), 1, fp); fwrite(state.player_x, sizeof(float), 1, fp); fwrite(state.player_y, sizeof(float), 1, fp); // ... 逐字段写入主角属性、背包、队友列表 fclose(fp); return true; }逻辑说明fwrite按字段顺序逐个写入不直接用fwrite(state, sizeof(state), 1, fp)整体落盘。参数说明直接写整个结构体在 C 里是「玄学」——编译器会按对齐规则在字段间插 padding不同编译选项下打出的存档二进制完全不同换编译器直接读不了而且结构体中间塞进一个小改动偏移量全变旧档全毁。逐字段写入看起来啰嗦但是唯一能保证跨编译器和跨版本兼容的做法。Hanz2Piny.cpp在这里的服务对象是搜索和排序。原版存档里人名、武功名全是中文如果做内力排序、武功筛选功能直接比较std::string是字典序用户想要的往往是拼音序。这个文件把汉字转成拼音首字母或全拼生成一个拼音索引字段在 UI 里做快速定位。它的核心是一个查表把 GBK 或 UTF-8 编码的中文字符拆出来在拼音码表中查到对应的拼音。std::string hanzi_to_pinyin(const std::string han) { // 常见做法将输入按 UTF-8 解出一个 Unicode 码点 // 然后用码点在拼音码表中二分查找 // 找不到的生僻字统一归入 # 分组避免排序崩 std::string result; // ... 查表逻辑 return result; }逻辑说明这个函数不负责理解语义只负责把「张无忌」变成[zhang wu ji]或zwj这种索引串然后再拿去排序或首字母定位。参数说明生僻字在码表里查不到是必然的不要返回空字符串统一归到#分组否则std::sort在混排时可能因为空串位置不确定而乱序。选音序还是全拼取决于作用域菜单快捷定位用首字母足够模糊搜索才需要全拼。5. 编译链接避坑从 C/C 混编到中文路径的五个常见问题5.1 undefined reference toSDL_main现象在 VS Code 配好 SDL2 后编译链接阶段报undefined reference to SDL_main或者是main函数定义冲突程序一闪而过。原因SDL2 在 Windows 上默认会引入SDL2main.lib它把main包装成了SDL_main目的是在进入你的main之前先初始化 Windows 子系统。如果你的链接命令里没有加SDL2main.lib或者入口函数写成了SDL_main链接器就会找不到入口。解决两种做法选一个。一个是确认链接命令里带上SDL2main.lib并且main函数签名保持标准int main(int argc, char *argv[])另一个是在包含 SDL 头文件之前定义SDL_MAIN_HANDLED并手动调用SDL_SetMainReady()告诉 SDL2 不需要它接管入口。5.2 zip.c 编译进 C 工程报 C 链接错误现象zip.c用 GCC 单独编译没问题但放进 Visual Studio 或 VS Code 的 C 工程后链接器报一堆_zip_open_entry未定义的错误或者编译时直接把.c当 C 文件处理报「void* 隐式转换」之类的错误。原因zip.c是 C 文件函数按 C 符号表导出而 BattleScene.cpp 这些 C 文件里使用zip_open_entry时链接器按 C 的名字修饰name mangling去找符号自然找不到。另一种情况是工程把所有源文件都按 C 编译C 风格的指针转换在 C 里是编译错误。解决在打包给 C 调用的头文件里加extern C守卫。常见做法是这样#ifdef __cplusplus extern C { #endif SDL_RWops *zip_open_entry(const char *archive, const char *entry); #ifdef __cplusplus } #endif逻辑说明extern C告诉 C 编译器花括号内的函数按 C 符号规则导出和解析这样 BattleScene.cpp 链接时就能找到 zip.c 里编译出的符号。参数说明拿 CMake 管理时zip.c不需要特殊标记只要头文件有这层守卫C 源文件会自动按 C 编译C 源文件会自动走extern C声明路径。这一步漏掉的症状极其隐蔽因为编译阶段全通过只有链接报错很多新手会在这一关卡一整天。5.3 从 zip 包里读中文文件名条目乱码现象zip 资源包里的素材名是中文比如地图/洛阳.grp用自己写的 zip.c 打开时找不到条目或者读出来的文件名是乱码。原因老工具生成的 zip 包对文件名的编码不统一——Windows 下很多压缩软件按 GBK 存文件名没有设置 UTF-8 标志位而 SDL2 和现代文件 API 默认按 UTF-8 处理。两边编码不一致中央目录里记录的字节序列和zip_open_entry传入的中文字符串对不上自然查不到。还有一类更恶心的包会带上「伪加密」标记General Purpose Bit 的 bit 0 被置位但其实数据段并没有真正加密。如果读取逻辑检测到加密位就拒绝解压就会直接被挡在门外。解决第一个选择是给素材统一起 ASCII 文件名比如map_luoyang.grp、hd_001.grp最省事也最不容易在跨平台时翻车。第二个选择是在 zip.c 里做编码归一识别通用位标记里的 UTF-8 标志位没置位的按 GBK 解码后再转 UTF-8统一成内部标准。伪加密的识别逻辑通常是在解析中央目录时检查通用位如果只有 bit 0 置位且版本号为 20就跳过这个标记直接走正常解压流程。判断标准只有一个解压后的数据校验CRC是否通过。这个坑我在移植别的老游戏时也踩过血泪经验是不要相信任何压缩软件默认的中文文件名统一 ASCII 能避开九成问题。5.4 存档结构体整块写入升级版本后旧档全废现象存档功能跑通了玩了几十个小时某天更新引擎加了个新字段再读旧档直接崩溃或者读出来的属性错乱主角属性变成队友的。原因存档用了fwrite(state, sizeof(state), 1, fp)整体落盘。编译器对齐会往结构体里塞 padding字段一多布局就和预期不一致新增字段后整个结构体偏移量全变旧档的字节流按新布局解析每一个字段位置都是错的。解决按前文 4.3 的做法逐字段读写并在文件头写魔数和版本号。具体到踩坑现场我给新字段都加了默认值逻辑读档时发现版本号低于SAVE_VERSION走一层迁移函数把缺失字段填充为默认值再另存一次让旧档自动升级成新档。从那以后我规定存档代码里禁止出现sizeof(state)这种写法每次改字段都主动检查是否需要升版本号。5.5 粒子数量一多帧率直接崩到个位数现象ParticleExample 演示里一次爆发 50 个粒子没问题加到 300 个就开始卡GPU 占用不高CPU 却跑满。原因大概率是每帧都在做高开销的事要么每帧new粒子对象、帧尾再释放在 Windows 上频繁堆分配会触发堆锁竞争要么每帧对每个粒子都调用SDL_SetTextureBlendMode或切换绘制目标渲染状态切换的开销远比画几个像素大。解决按 4.2 的池化方案把粒子数上限固定成常量混合模式在粒子系统初始化时设置一次不要进每帧循环。还有一条排查经验先关掉粒子系统运行看帧率再打开对比能快速确认是不是粒子的锅如果是用性能分析工具看是耗在malloc还是耗在SDL_RenderCopy的调用次数上定位后再针对性优化。6. 验证习惯固定时间步长与帧率日志SDL2 程序最容易出现「开发机上 60 FPS别的机器只有 30 FPS然后整个游戏变慢」的问题。原因就是上一章的delta被直接放大30 FPS 下每帧delta变成 33ms动画按更大的时间步走看似速度一致但物理和碰撞在这种大步长下会产生跳变行动位置看起来一顿一顿。移植老游戏时原版逻辑是按固定帧数设计的不能简单用实时delta硬套。我习惯用固定时间步长来跑逻辑渲染仍然按真实的帧率走。核心是把逻辑更新累积成固定步进避免大步长带来的数值跳动const float FIXED_DT 1.0f / 60.0f; // 逻辑固定 60 步/秒 float accumulator 0.0f; Uint32 now SDL_GetTicks(); float frame_dt (now - last_tick) / 1000.0f; last_tick now; accumulator frame_dt; while (accumulator FIXED_DT) { g_current_scene-update(FIXED_DT); // 战斗、移动、粒子都走固定步长 accumulator - FIXED_DT; }逻辑说明update永远以FIXED_DT为步长执行帧率高时一帧可能调用两次update帧率低时则减少调用次数量化误差被累积器吸收。参数说明accumulator要限制上限比如2.0f防止画面卡死几秒后一次性补几十帧逻辑这种情况应该直接丢弃多余时间否则战斗伤计算会做出离谱结果。配套的帧率日志也是必要的验证手段。我在引擎里放一个简单的环型计数器每 60 帧采样一次SDL_GetPerformanceCounter输出平均帧耗时。发布前跑一遍完整流程日志里帧耗时曲线平稳、无尖刺才敢打包。那次在粒子系统翻车之后我形成了个习惯任何一个新模块合进来release 构建跑一次把帧率日志从头留到尾不在这上面省时间。这套做法也推荐给做老游戏移植的同行编译不报错只是第一步运行平顺才是真过了关。希望帮到你。本文还有配套的精品资源点击获取