简介:面向高校C++课程设计与Windows游戏开发初学者的一份“捕鱼达人”完整工程,围绕Windows API、GDI绘图、消息循环与面向对象设计展开,可用于课程设计参考、游戏逻辑拆解和上机实践。压缩包共36个文件,约7.42MB,包含cpp/h源码、bmp/jpg图像素材、ogg/mp3/wav音效、txt说明以及dsp/dsw工程文件,覆盖窗口创建、鱼群与炮弹绘制、碰撞检测、得分计算和资源加载等环节。已有1310人学习,适合按模块阅读源码、对照运行效果理解事件处理与定时器机制。其价值在于提供可编译的课程设计雏形,便于读者梳理类结构、复用素材、排查编译链接问题,并在此基础上改造玩法或完善注释。
1. 从一份“能跑起来”的课设说起:捕鱼达人在 Windows 上到底要做哪些事
带过几届 Windows 程序设计课程设计之后,我发现一个反直觉的现象:做捕鱼达人这类小游戏,最容易被扣分的不是玩法不够花哨,而是第一步就跑偏——上来就找一个现成引擎,把窗口、渲染、输入全托管出去,结果答辩时被问“Win32 消息循环在哪”“WM_PAINT 你怎么处理的”,一句都答不上来。这门课考的是 C++ 与 Windows 平台本身,题目要的是用 Win32 API 把一个能玩、能计分、能自洽运行的捕鱼达人写出来,而不是做一个成品手游。
所以这套方案的目标很明确:用纯 C++ 加 Win32 + GDI,做一份结构清楚、模块分明、可以直接照着实现的课程设计。适合两类人——正在赶 Windows 程序设计课设、需要一个能交差又能讲明白骨架的学生,以及想拿一个完整小项目练手 Win32 编程的开发者。下面从架构讲到落地,再讲到怎么验收。
2. 总体架构设计:Win32 窗口、游戏循环与五个模块的职责划分
先把骨架立住再谈玩法。这个项目最容易出现的问题是“所有代码塞进 WndProc”,一开始图省事,写到鱼群、子弹、结算全堆在消息回调里,后面改一个数值要翻三百行。我一般会在动手前就把模块切开,让窗口层只负责“开门”,逻辑层负责“演戏”。
2.1 窗口与消息:把 Win32 骨架先立住
窗口部分是整份课设的“地基”,也是最容易被导师盯的部分。核心是三件事:注册窗口类、创建窗口、跑消息循环。客户区尺寸我一般固定成 1024×600,用AdjustWindowRect反推外框大小,这样游戏内的坐标系和屏幕像素就是 1:1,不用额外做缩放换算,调试时鼠标点在哪、鱼就在哪,省掉一堆对齐的麻烦。
// winmain.cpp —— Win32 窗口骨架 int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int nCmdShow) { // CS_HREDRAW | CS_VREDRAW:窗口尺寸变化时整体重绘,避免残影 WNDCLASSEX wc = { sizeof(wc) }; wc.style = CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.lpszClassName = L"FishGameWnd"; RegisterClassEx(&wc); // 客户区固定 1024x600,反推窗口外框尺寸 RECT rc = { 0, 0, 1024, 600 }; AdjustWindowRect(&rc, WS_OVERLAPPEDWINDOW & ~WS_THICKFRAME, FALSE); HWND hWnd = CreateWindowEx(0, wc.lpszClassName, L"捕鱼达人", WS_OVERLAPPEDWINDOW & ~WS_THICKFRAME, // 去掉可调边框,省掉尺寸适配 CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, nullptr, nullptr, hInst, nullptr); ShowWindow(hWnd, nCmdShow); return RunGameLoop(hWnd); // 主循环单独抽出去,见 2.3 }这段代码里有两个参数值得说明。WS_OVERLAPPEDWINDOW & ~WS_THICKFRAME去掉的是可拖拽边框,课设阶段没必要做窗口缩放适配,固定尺寸能省掉大量坐标变换代码;AdjustWindowRect的第三个参数传FALSE表示不带菜单栏,如果你的版本要加菜单,这里改成TRUE,否则客户区会比预期矮一截。窗口类注册失败、创建返回空指针这两种情况,通常的做法是弹一个MessageBox再退出,别让程序静默失败——答辩演示时白屏卡死比报错难看十倍。
消息回调里只留三件事:WM_PAINT交给渲染模块、WM_LBUTTONDOWN/WM_MOUSEMOVE转成输入事件塞进队列、WM_DESTROY里PostQuitMessage。其他一律不写。这条纪律看着死板,但它决定了你后面改玩法时是改一个文件还是改一片。
2.2 模块划分:渲染、逻辑、资源、输入、场景五层
模块划分我推荐按“谁依赖谁”来切,而不是按功能名字随便分。合理的依赖方向是单向的:场景层调逻辑层和渲染层,渲染层和逻辑层都只依赖资源层,输入层被场景层读取。反过来依赖一旦出现,比如逻辑层去调InvalidateRect,这个项目基本就烂尾了。
| 模块 | 主要职责 | 关键类/文件 | 不该做的事 |
|---|---|---|---|
| 平台层 | 窗口、消息、鼠标输入 | winmain.cpp、WndProc | 不写任何游戏规则 |
| 场景层 | 状态切换(菜单/游戏/结算) | SceneManager | 不直接操作 HDC |
| 逻辑层 | 鱼群、子弹、碰撞、计分 | World、Fish、Bullet | 不碰任何 GDI 句柄 |
| 渲染层 | 双缓冲、精灵绘制、文字 | Renderer、Sprite | 不修改对象状态 |
| 资源层 | 位图、音效、鱼种配置表 | ResourceManager、FishTable | 不关心谁在用 |
这张表建议直接写进设计文档的第一页。答辩时导师问“你的模块怎么划分的”,把表一摊,比口头解释五分钟管用。逻辑层和渲染层彻底分开还有个隐性好处:碰撞检测的数值可以在控制台里单独跑单元测试,不用每次开窗口看效果。
2.3 主循环:PeekMessage 加固定步长,别用 GetMessage
这是整个项目里技术含量最高、也最容易被问倒的一处。很多人写游戏循环直接照抄窗口程序模板里的while (GetMessage(...)),结果发现鱼不动——因为GetMessage在没有消息时会阻塞,你的逻辑更新永远等不到执行。正确做法是PeekMessage非阻塞取消息,取完继续往下跑逻辑。
// gameloop.cpp —— 固定步长主循环 const double FIXED_DT = 1.0 / 60.0; // 逻辑固定 60Hz,与显示器刷新解耦 LARGE_INTEGER freq, prev, now; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&prev); double accumulator = 0.0; bool running = true; while (running) { MSG msg; while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { // 非阻塞取消息 if (msg.message == WM_QUIT) { running = false; break; } TranslateMessage(&msg); DispatchMessage(&msg); } QueryPerformanceCounter(&now); double frame = double(now.QuadPart - prev.QuadPart) / freq.QuadPart; prev = now; if (frame > 0.25) frame = 0.25; // 拖动窗口后防止一次性补几百帧 accumulator += frame; while (accumulator >= FIXED_DT) { Update(FIXED_DT); // 逻辑只吃固定步长,速度不随机器变 accumulator -= FIXED_DT; } Render(accumulator / FIXED_DT); // 渲染拿插值系数做平滑 Sleep(1); // 主动让出 CPU,别把核跑满 }QueryPerformanceCounter比GetTickCount精度高一个量级,GetTickCount只有毫秒级,60Hz 下每帧误差能到 10% 以上,表现出来就是鱼游得一抖一抖。frame > 0.25那行是后悔药:用户拖动窗口时 Windows 会阻塞消息循环,松手瞬间累计时间可能是好几秒,不加限制while里会疯狂补帧,直接假死。Sleep(1)则是最朴素的风控,不加的话任务管理器里这个课设常年 25% CPU,答辩时机器风扇狂转,观感很差。
固定步长的意义,我用一句话概括:逻辑更新次数由时间决定,不由帧率决定。同一份代码在老旧笔记本和演示用的新机器上跑,鱼的速度必须一样,否则数值全部要重调。
3. 渲染与动画落地:双缓冲、帧动画与坐标体系
架构搭好之后,第二个大坑立刻出现:闪屏。绝大多数课设的“画面撕裂感”都来自同一个原因——在WM_PAINT里直接往窗口 DC 上画。背景先刷一遍白,再一条一条画鱼,用户眼睛看到的就是中间那一瞬间的空屏。
3.1 双缓冲:从闪烁到稳定的最小改动
双缓冲的原理不复杂:先在内存里画完整一帧,再一次性贴到屏幕。关键点在于内存 DC 和位图不能每帧创建,CreateCompatibleBitmap是个重操作,每帧建一次的话帧率直接腰斩。
// renderer.cpp —— 双缓冲绘制 void Renderer::Present(HDC hdcWindow, int w, int h) { // 内存 DC 只在尺寸变化时重建,避免每帧 CreateCompatibleBitmap if (!memDC_) { memDC_ = CreateCompatibleDC(hdcWindow); memBmp_ = CreateCompatibleBitmap(hdcWindow, w, h); oldBmp_ = SelectObject(memDC_, memBmp_); } DrawBackground(memDC_); // 所有绘制都落在内存 DC DrawFish(memDC_); DrawBullets(memDC_); DrawHUD(memDC_); // 金币、分数、炮台等级 BitBlt(hdcWindow, 0, 0, w, h, memDC_, 0, 0, SRCCOPY); // 一次性上屏 }SRCCOPY表示直接拷贝像素,如果要做淡入淡出可以换BLEND,但课设阶段没必要。oldBmp_必须存下来,析构时SelectObject(memDC_, oldBmp_)还原再DeleteObject(memBmp_),漏掉这一步就是典型的 GDI 对象泄漏,任务管理器里“GDI 对象”一栏会一路往上涨,涨到一万左右程序直接画不出东西——这是 Windows 每个进程 10000 个 GDI 句柄的硬上限,也是最经典的血泪经验。
还有个细节:WM_PAINT里必须调BeginPaint/EndPaint,否则系统会一直发重绘消息,CPU 直接拉满。有些人图省事用GetDC/ReleaseDC,短期能跑,长时间运行会出各种玄学问题。
3.2 帧动画与精灵表:鱼怎么“游”起来
鱼游动靠的是精灵表逐帧切换,一张图横向排若干帧,按时间取模算当前帧。这里有个参数必须讲清楚:动画速度和移动速度是两套独立的量,别混在一起。
// sprite.cpp —— 按时间推进帧动画 void Fish::UpdateAnim(float dt) { animTime += dt; const FishDesc& d = kFishTable[type]; int frame = int(animTime * d.animFps) % d.frameCount; // 帧率与位移速度解耦 srcRect.left = frame * d.cellW; srcRect.right = srcRect.left + d.cellW; srcRect.top = 0; srcRect.bottom = d.cellH; }animFps通常配 8~12,太快像抽搐,太慢像卡顿;frameCount是每种鱼自己的帧数,小鱼 4 帧、大鱼 8 帧很常见。位移速度则放在vel里,单位是像素每秒,和帧率无关。这两者分开配置的好处是:想让鱼游得慢一点只改vel,动画节奏不受影响,反之亦然。
朝向问题也在这里处理。鱼从右往左游时把源矩形水平翻转,GDI 没有现成的翻转功能,一般用StretchBlt配合负的宽度和SrcCopy,或者干脆准备左右两套图。前者省资源但要处理坐标偏移,后者省事但图多一倍,课设阶段我更倾向后者——文件大几百 KB,换来的是不用调试镜像翻转的坐标账。
3.3 坐标体系:世界坐标与屏幕坐标别混用
炮台在屏幕底部中央,鱼从四周游入,子弹从炮口射出。如果所有对象都用屏幕坐标,逻辑会变得很别扭:炮台旋转、鱼群路径、边界判断全搅在一起。我一般约定一套世界坐标,原点在客户区左上角,单位像素,但所有对象的位置都用一个Vec2存,渲染时统一加相机偏移(这个项目里偏移恒为 0,但接口先留好)。
炮台的角度用弧度制,鼠标位置减去炮口位置得到向量,atan2算出角度,再限制在 0 到 π 之间(只允许朝上打)。子弹初速度由角度分解成vx = cos(angle) * speed、vy = -sin(angle) * speed,注意 y 轴向下,所以sin前面要加负号。这个负号是新手最容易漏的地方,漏了之后子弹全部朝下飞,你还得盯着屏幕找半天。
提示:炮台角度限制和子弹速度分解建议写在同一个函数里,别分散在输入处理和逻辑更新两处,否则改一次参数要同步改两个地方。
4. 对象与规则实现:鱼、炮弹、碰撞检测与计分结算
渲染通了之后,项目进入真正的“玩法”阶段。这一块最能体现设计水平,也最容易写成流水账。核心是三件事:对象怎么管、碰撞怎么判、分数怎么算。
4.1 对象模型:用对象池管生命周期,别频繁 new/delete
捕鱼达人里子弹每秒产生十几发,命中即销毁,小鱼不断刷新。如果每颗子弹都new一个再delete,跑十分钟就是上百万次分配释放,堆碎片和性能抖动都会来。标准做法是对象池:预分配一大块,用alive标记复用。
// world.h —— 鱼与子弹的数据结构 struct Fish { int type; // 鱼种索引,决定分值、半径、帧数 Vec2 pos, vel; // 位置(像素)、速度(像素/秒) float radius; // 碰撞半径,取贴图宽度的 0.4 倍左右 int hp; // 剩余可命中次数,大鱼 > 1 float animTime; // 动画计时 bool alive; }; class World { public: void Update(float dt); void Fire(const Vec2& from, float angle); // 发射一颗子弹 private: std::vector<Fish> fishPool_; // 预留 256 条,超出则复用最老的 std::vector<Bullet> bulletPool_; // 预留 512 颗 int coins_ = 1000; // 初始金币,打空即结算 int cannonLevel_ = 1; };池子的容量要按最坏情况估:屏幕上同时出现的鱼最多 40 条左右,池子开 256 足够;子弹考虑射速和存活时间,512 够用。满了之后不扩容,而是直接复用最老的那一条,这个策略听起来粗糙,但实际游戏里根本不会触发,属于兜底。
对象池和双缓冲是同一个思路的两个面:热点路径上不做分配。这句话建议在答辩时说出来,很容易加分。
4.2 碰撞检测:圆与圆的判定,加一层网格分桶
子弹打鱼本质上是个圆与圆的相交判断。别写开方,比较平方距离就行,一帧几百次判断虽然不至于卡,但这是基本功。
// collision.cpp —— 圆-圆相交判定 bool HitTest(const Bullet& b, const Fish& f) { float dx = b.pos.x - f.pos.x; float dy = b.pos.y - f.pos.y; float r = b.radius + f.radius; return dx * dx + dy * dy <= r * r; // 平方比较,省一次 sqrt }鱼多了之后就得加优化。朴素做法是子弹数 × 鱼数两两比较,512 × 40 也就两万次,60 帧下是一百二十万次,现代机器扛得住,但课上讲出来不够看。加一层网格分桶:把客户区切成 128×128 的格子,每条鱼按自己的位置和半径登记到覆盖的格子里,子弹只需要查自己所在格子的鱼。
// 网格分桶:屏幕 1024x600 切成 8x5 个格子 const int CELL = 128; std::vector<int> grid[8][5]; void RebuildGrid(const std::vector<Fish>& fish) { for (auto& row : grid) for (auto& cell : row) cell.clear(); for (int i = 0; i < (int)fish.size(); ++i) { if (!fish[i].alive) continue; int gx = int(fish[i].pos.x) / CELL, gy = int(fish[i].pos.y) / CELL; if (gx >= 0 && gx < 8 && gy >= 0 && gy < 5) grid[gx][gy].push_back(i); } }注意大鱼半径可能超过格子边长,跨格的情况要在登记时把覆盖的格子都塞一遍,否则会出现“明明打中了却没判到”的诡异现象。这个 bug 我见过不止一次,表现是打大鱼边缘时偶发穿模,查起来非常费劲。
4.3 规则与数值:命中不是必得,结算要可解释
玩法规则的设计决定了这份课设是“能动”还是“好玩”。我的做法是三层:碰撞判定 -> 命中概率 -> 金币结算。碰撞只是“打到了”,打到了不一定捕获,稀有鱼要低概率,小鱼高概率,这样才有博弈感。
// rules.cpp —— 命中后的捕获与结算 int ResolveCatch(const Fish& f, int cannonLevel) { const FishDesc& d = kFishTable[f.type]; // 炮台等级越高,捕获加成越高,但设上限防止一刀999 float bonus = 1.0f + 0.15f * (cannonLevel - 1); if (Rand01() > d.catchRate * bonus) return 0; // 打中但跑掉,播一个溅水动画 return d.score * cannonLevel; // 金币 = 基础分 × 炮台等级 }catchRate在鱼种表里配:小热带鱼 0.6~0.7,中等鱼 0.3,大型鱼 0.08~0.12。Rand01()用std::mt19937配均匀分布,别用rand()——rand()的周期和分布质量在频繁调用下会露馅,而且没有可复现的种子管理,调数值时非常痛苦。
数值曲线也要有个基准:初始 1000 金币,一发子弹消耗 10 金币,小鱼分值 5~10,中等 30,大鱼 200。这条曲线保证新手期金币缓慢下降、偶尔大鱼回血,节奏大概 3~5 分钟一局。关卡节奏用“累计发射数”推进比用时间更稳,因为玩家的节奏不一样,用时间会出现“挂机也在涨难度”的怪事。
注意:所有概率和分值都放在独立的配置表里(哪怕是硬编码的静态数组),别散落在逻辑函数中。答辩时导师随手让你“把大鱼概率调低一半”,你能一秒钟改完,这就是加分项。
5. 避坑与排查:卡顿、闪烁、泄漏最常见的四类翻车
写到这里,功能基本齐了。但从“我机器上能跑”到“答辩机器上能跑”,中间还隔着一堆坑。这一章列的都是这类项目里高频出现、且排查成本很高的四类问题,按现象、原因、解决拆开讲。
5.1 四类高频翻车:现象、原因与解决
第一类:画面闪烁,鱼拖影。现象是鱼移动时后面跟着一串残影,或者整个画面一明一暗地跳。原因基本只有一个:在窗口 DC 上直接绘制,且没有处理WM_ERASEBKGND。系统在WM_PAINT之前会先用背景色擦一遍,擦完你才画,眼睛看到的就是“白屏-画面-白屏”的循环。解决办法是双缓冲,同时在WM_ERASEBKGND里直接return 1告诉系统“背景我自己擦”,这一步很多人漏掉,光加双缓冲还是闪。
第二类:CPU 占用飙高,风扇狂转。现象是任务管理器里进程稳占 25%(四核机器就是一个满核)。原因是没有主动让出 CPU,PeekMessage循环在没消息时是空转的。解决办法是在循环尾部Sleep(1),或者用MsgWaitForMultipleObjects精确等待。加了之后 CPU 通常能降到个位数,代价是最多 1ms 的输入延迟,对这种游戏完全无感。
第三类:GDI 对象泄漏,跑久了白屏。现象是运行十几分钟后画面突然变成一片白或者黑,任务管理器里 GDI 对象数持续上涨。原因是每帧创建CreateCompatibleDC/CreateCompatibleBitmap/CreateFont却没有DeleteObject,撞上 10000 个句柄的进程上限。解决办法是把这些句柄的生命周期收进 RAII 类里,构造时创建、析构时释放,或者至少在一个Init/Shutdown里成对管理。创建和销毁必须成对出现,这一条没有例外。
第四类:换台机器速度就变了。现象是在自己电脑上正常,到答辩教室的机器上鱼游得飞快或者慢动作。原因是用帧数当时间单位(“每帧移动 5 像素”),帧率高就快。解决办法就是第 2 章那套固定步长加累加器,逻辑更新只吃FIXED_DT。这是硬件无关性的基本要求,也是导师最爱追问的点之一。
第五类(补充):资源加载失败,图全是马赛克。现象是窗口开了但贴图不出来。原因多半是用了相对路径LoadImage(L"res\\fish.bmp"),而程序的工作目录取决于你怎么启动它——双击 exe 和从 IDE 里跑,工作目录可能不一样。稳妥做法是用GetModuleFileName拿到 exe 的绝对路径,再拼资源子目录。这个坑很隐蔽,因为调试时一切正常,打包交作业时才炸。
5.2 排查工具箱:日志、帧率显示与 GDI 计数
上面这些问题,光靠盯着屏幕猜是查不出来的,得有几个常备手段。
第一个是帧率显示。在 HUD 上画一行FPS: 59,实现很简单:统计一秒内Render被调用的次数。这一行字能解决 80% 的性能疑问——是逻辑慢还是渲染慢,看帧率波动就知道。
第二个是分级日志。别用printf满屏刷,把日志分成INFO/WARN/ERROR三档,命中结算打WARN,资源加载失败打ERROR。调试时开全部,交付时只留ERROR。日志文件写到 exe 同目录,出问题让同学把文件发过来,比让他描述“就是卡”有用一百倍。
第三个是 GDI 对象计数,用GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS),每五秒打一次日志。数值稳定说明没泄漏,持续上涨就是有句柄没释放,配合创建点加计数就能定位到具体位置。
// diag.cpp —— 五秒一次打印 GDI 对象数与帧率 static int frameCount = 0; static DWORD lastTick = GetTickCount(); void OnFrameEnd() { ++frameCount; DWORD now = GetTickCount(); if (now - lastTick >= 5000) { DWORD gdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); LogInfo(L"FPS=%.1f GDI=%lu", frameCount * 1000.0 / (now - lastTick), gdi); frameCount = 0; lastTick = now; } }这套东西加起来不到一百行,但它把“玄学卡顿”变成了可观测的数字。答辩时把日志文件一展示,说明你测过、调过、有数据,比讲十分钟原理更有说服力。
6. 验收与答辩:把课设讲成一份可复现的方案
最后一公里是把东西交出去、讲明白。演示和答辩最忌讳两件事:一是现场手忙脚乱找文件、改代码;二是被问原理时只会说“网上抄的”。我一般建议按“先讲结构、再走流程、最后接问题”的顺序准备。
演示脚本先写死一条路径:从菜单进入游戏 → 打三发子弹命中一条小鱼 → 升级炮台打中一条大鱼 → 金币归零进结算页 → 返回菜单重开。全程两分钟,每一步都对应你架构里的一个模块,边点边说“这里是场景切换”“这里是命中结算”,让导师看到的不是画面,是结构。
验收清单可以提前列成表,交作业时附在设计文档后面,显得规范:
| 验收项 | 判定标准 | 自查方式 |
|---|---|---|
| 窗口与消息 | 可正常启动、关闭、无卡死 | 连续开关 20 次 |
| 渲染稳定性 | 无闪烁、无拖影 | 目视 + 帧率显示稳定 60 |
| 资源占用 | CPU < 10%,GDI 对象数恒定 | 任务管理器观察 10 分钟 |
| 玩法完整性 | 发射、命中、结算、重开闭环 | 按演示脚本走一遍 |
| 数值合理性 | 一局 3~5 分钟,金币曲线不崩 | 连续玩三局记录金币 |
| 代码规范 | 模块分明,无超长函数 | 每个 .cpp 单文件 < 500 行 |
答辩问题提前准备四类,命中率极高。第一类问“为什么不用引擎”,答案就是开头那段:课程考的是 Win32 平台能力,用引擎等于把考点托管出去。第二类问“双缓冲怎么实现的”,要能说出内存 DC、兼容位图、BitBlt三个关键词,并指出句柄只在尺寸变化时重建。第三类问“帧率变了游戏速度会不会变”,把固定步长加累加器讲一遍,重点落在“逻辑更新次数由时间决定”。第四类问“内存怎么管的”,答对象池加 RAII,再补一句“热点路径上不做动态分配”。
还有一个容易被忽略的细节:答辩前把编译产物和源码分开放,源码目录里别留.obj、.pdb和一堆测试用图。有的同学习惯在桌面直接编译,交上去的文件夹里混着七八个版本的 exe,导师点错一个跑的是旧版,当场翻车。
从那以后我每次带这类课设,都强制走一遍“换机器冷启动 + 连续运行十分钟 + 按演示脚本走全程”这三步,缺一步都不敢让人上台。这三步不花什么时间,但能把绝大多数现场事故挡在门外。希望帮到你。
本文还有配套的精品资源,点击获取