ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用C++从零实现控制台俄罗斯方块:核心算法与工程实践

用C++从零实现控制台俄罗斯方块:核心算法与工程实践 我至今还留着大学时写的第一个俄罗斯方块源码整整四百多行没有类、没有结构体全靠一堆全局变量硬撑。后来工作几年回头再看才明白当时很多写法虽然能跑但扩展性几乎为零。这篇就把我后来重写的一版控制台俄罗斯方块完整拿出来讲一遍。除了代码可以直接抄走我更想讲清楚为什么这样设计。俄罗斯方块这个项目覆盖了 C 里特别实用的几个点二维数组与矩阵存储、结构体封装、碰撞检测的边界处理、游戏主循环的帧刷新逻辑以及终端环境下输入输出的跨平台适配。如果你刚学完 C 基础语法、正处于不知道写点什么练手的阶段这个项目是很合适的起点。把这一版跑通并搞懂之后后面无论是做面试项目、还是往图形界面方向扩展都有一条顺畅的路。1. 为什么拿俄罗斯方块入门 C 项目目标与技术选型1.1 一个小游戏背后到底考了什么很多人觉得俄罗斯方块简单确实规则一句话就能说清七种方块往下落填满一行就消掉直到堆到顶。但正因为规则少实现反而要把很多基本功压缩在一起。这个项目里真正训练的能力有三块数据结构设计棋盘怎么存、方块形状怎么表达、旋转后的新形状怎么生成这直接关系到代码的可读性和扩展性。状态机思维一个方块从生成、下落、固定到消行每个状态都有明确的进入和退出条件游戏整体还有暂停、结束等状态。用循环配合分支来处理这些状态比单纯堆函数要清晰得多。边界条件处理碰撞检测里要同时考虑左墙、右墙、底部、已有方块四种情况消行后上方所有行要整体下移旋转后坐标可能越界……这些细节才是实际开发中最容易出 bug 的地方。很多人刷了一堆八股面试题真到让手写一个完整程序的时候反而无从下手。俄罗斯方块就是这样一块试金石——你能不看参考独立写完说明你对基础语法和程序结构的掌握已经过关了。1.2 控制台版还是图形库版我为什么先写控制台版现在很多教程一上来就上 SFML、Qt或者 Unity诚然画面好看但对初学者来说有一个很大的问题你分不清自己是在学游戏逻辑还是在学图形库 API。控制台版不一样。它把画面简化成一张字符画布你只要关注棋盘是什么样子、方块往哪挪、哪里撞了这些本质问题不用被窗口消息循环、纹理加载、坐标换算这些额外概念拖住。我自己的经验是先写一个控制台版然后在这个基础上封装一个渲染层接口再换成图形界面。这样层次是清楚的——游戏逻辑和渲染完全解耦以后想换 Qt、SDL只需要重写渲染层。维度控制台版图形库版SFML/Qt上手门槛低几个头文件就能跑高需要理解窗口和事件系统核心逻辑训练完整覆盖被渲染代码稀释成就感出效果快画面好但调试成本高依赖复杂度几乎为零需要额外安装和配置1.3 这次代码的跨平台策略控制台编程有一个绕不开的问题Windows 和 Linux 下的按键检测、光标移动函数不一样。Windows 上有conio.h里的_kbhit()和_getch()Linux 上没有Windows 用SetConsoleCursorPosition()移动光标Linux 上要自己输出 ANSI 转义序列。我用条件编译把这层差异包起来了Windows 下直接调用系统 API。Linux/macOS 下用termios实现一个简易的无阻塞按键检测用 ANSI 转义码\033[row;colH移动光标。这样一份源码在两个平台都能编译运行而不会一堆报错。后面第 5 节我会给出详细的编译命令。2. 数据结构先于代码棋盘与七种方块的建模2.1 棋盘为什么不直接用一个一维数组棋盘是固定的 10 列 x 20 行标准俄罗斯方块的规格。我选择bool board[ROWS][COLS]这样一个二维数组行是从上往下数的 0 到 19列从左往右是 0 到 9。这里有个细节值得说一下如果你用过一维数组bool board[ROWS * COLS]访问某个格子时就得写board[row * COLS col]下标换算多了之后代码可读性会明显下降。棋盘规模不大用二维数组的直观性收益是远大于那一点点内存的。我只存是否已经有方块这一种状态用bool就够了。如果你以后想扩展每个格子有不同的颜色或闪烁动画再改成int或自定义结构体就行。2.2 七种 Tetromino 的矩阵存储法七种方块分别是 I、O、T、S、Z、J、L。常见存储方案有三种坐标数组存每个小方块相对中心点的偏移。字符串数组用字符画把形状直接画出来。4x4 矩阵用一个 4x4 的二维数组有方块的位置填 1。我最终选了第三种原因很简单4x4 矩阵配合矩阵旋转算法实现起来最直观而且不容易写错。七种方块的矩阵长这样const int SHAPES[7][4][4] { { {0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0} }, // I { {0,0,0,0},{0,1,1,0},{0,1,1,0},{0,0,0,0} }, // O { {0,0,0,0},{0,1,0,0},{1,1,1,0},{0,0,0,0} }, // T { {0,0,0,0},{0,1,1,0},{1,1,0,0},{0,0,0,0} }, // S { {0,0,0,0},{1,1,0,0},{0,1,1,0},{0,0,0,0} }, // Z { {0,0,0,0},{1,0,0,0},{1,1,1,0},{0,0,0,0} }, // J { {0,0,0,0},{0,0,1,0},{1,1,1,0},{0,0,0,0} } // L };矩阵里空白行是刻意留出来的目的是让方块在 4x4 的容器里居中。比如 I 方块横着时占第 1 行旋转后变成纵向列在中央。这样旋转时不会跑偏也简化了碰撞检测——我只需要遍历整个 4x4 矩阵就行了。2.3 方块对象坐标与形状的绑定每个当前操作的方块除了形状数据还需要知道它在棋盘上的位置。我定义了一个Block结构体struct Block { int shape[4][4]; int x, y; // 4x4 矩阵左上角在棋盘上的坐标 Block() { memset(shape, 0, sizeof(shape)); x COLS / 2 - 2; y 0; } void load(int type) { memcpy(shape, SHAPES[type], sizeof(shape)); } void rotate() { /* ... */ } void rotateBack() { /* ... */ } };这里的x表示矩阵左边缘对应的列号y表示矩阵顶行对应的行号。比如当前方块的某个非零元素在shape[i][j]它在棋盘上的真实位置就是(y i, x j)。这个映射关系是整个碰撞检测和渲染的基础先把它想明白再往下看代码会轻松很多。3. 游戏主循环输入、渲染、逻辑刷新三件事3.1 为什么这种游戏不依赖事件循环很多入门同学习惯写事件驱动的游戏按下按键触发一个回调函数。但控制台游戏天然没有窗口消息队列更自然的做法是帧循环。帧循环的思想很简单程序在一个while循环里反复做三件事——读输入、更新状态、渲染画面。每一次循环就是一帧帧间隔通过Sleep()或usleep()控制。这样做的好处是下落、移动、消行、绘制这些逻辑全部在一个同步的时间线上推进不存在多线程编程里的竞态问题。对新手来说调试也好做在某一行打断点就能看到这一帧的完整状态变化。3.2 渲染的层次先画背景再叠当前方块我完全不赞成用system(cls)清屏重画因为它会让终端有严重的闪烁。更好的方案是维护一个字符缓冲区screen[SCREEN_H][SCREEN_W]把每一帧的画面整体拼好再用光标定位一次性输出。渲染的层次固定为四步清空屏幕缓冲区全部填成空格。画边框上下左右四条边界用#填充。画已经固定到棋盘上的方块用[]表示两个字符宽的格子。画当前正在下落的方块也统一用[]。用字符串拼接而不是一句一句cout是因为终端输出很慢一次大输出比几百次小输出快得多。这是控制台项目里提高帧率的有效手段。3.3 按键响应方向键与 WASD 的兼容方案Windows 控制台的方向键会触发两个字节第一个是224第二个才是真正的键码左 75、右 77、上 72、下 80。在检测输入时要先判断第一个字节。为了让代码跨平台更好用我同时支持 WASDA/D左右移动W/ 上方向键旋转S/ 下方向键软降一格空格硬降到底Q退出这里有一个我在实际开发中遇到的细节如果按键检测和下落逻辑放在同一个循环里玩家长按方向键时方块会以每帧移动一格的速度平移。这个速度往往是够用的但如果你觉得太快或太慢就要单独设一个按键冷却计数器。后面第 6 节我会专门讲这个问题的优化。4. 算法拆解旋转、碰撞、消行与得分4.1 4x4 矩阵旋转为什么不会越界四阶方阵顺时针旋转有一个非常简洁的公式void rotate() { int t[4][4]; for (int i 0; i 4; i) for (int j 0; j 4; j) t[j][3 - i] shape[i][j]; memcpy(shape, t, sizeof(shape)); }理解这个公式有一个直观记忆矩阵旋转可以拆成转置加上左右翻转。顺时针旋转等于先转置再水平翻转。用代码写出来就是t[j][3-i] shape[i][j]。因为存储在 4x4 容器里旋转时不会发生数组越界。旋转后在某个特定的堆叠位置可能撞到墙或已有方块我的处理方式是先尝试旋转如果检测到碰撞就旋转回去。这就是rotateBack()的作用。有经验的玩家可能会注意到真正的俄罗斯方块有墙踢机制——贴墙旋转时方块会往外推一格。我这个版本没做后面第 6 节会给出扩展思路。4.2 碰撞检测必须包含的三种边界碰撞检测是这个小游戏的核心函数。我的collide()接收三个参数方块本身以及尝试移动到的目标位置(nx, ny)。bool collide(const Block b, int nx, int ny) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (!b.shape[i][j]) continue; int bx nx j; int by ny i; if (bx 0 || bx COLS || by ROWS) return true; if (by 0 board[by][bx]) return true; } } return false; }它覆盖了三种情况左右边界bx 0或bx COLS表示方块有格子越出了棋盘左右范围。底部边界by ROWS表示方块已经落到最下面一行之下。与已有方块重叠只要目标位置已经被占用就发生碰撞。注意by 0这个判断方块刚生成时可能在棋盘顶端之上这时候允许它处于y 0的区域不做棋盘数组访问避免数组越界。4.3 消行后的行迁移最容易写错的细节消行逻辑值得单独拿出来讲。判断某一行是否满很简单遍历这一行的每个格子看是否都为true。但真正容易写错的是消除一行之后上方所有行要整体下移然后还要重新检查当前行。int clearLines() { int lines 0; for (int r ROWS - 1; r 0; --r) { bool full true; for (int c 0; c COLS; c) if (!board[r][c]) { full false; break; } if (full) { lines; for (int k r; k 0; --k) for (int c 0; c COLS; c) board[k][c] board[k - 1][c]; for (int c 0; c COLS; c) board[0][c] false; r; // 关键重新检查当前 r } } return lines; }为什么要有r因为第r行被消除后第r-1行移到了第r行。如果此时继续执行循环里的--r就会跳到r-1行而新移下来的第r行就没有被检查。加上r配合 for 循环自带的--r让r保持原位从而检查一次新移下来的内容。这个细节写在注释里了但实际写的时候很容易漏掉。4.4 计分与难度曲线的简单联动经典计分规则我做成了一张小表一次消除行数得分1 行1002 行3003 行5004 行800用score lines 0 ? 0 : (lines 1 ? 100 : lines 2 ? 300 : lines 3 ? 500 : 800);这样一个表达式就能算出来。难度方面我没有用得分做唯一标准而是用累计消行数totalLines决定等级每 10 行升一级level totalLines / 10 1; int dropInterval max(1, 18 - (level - 1) * 2);dropInterval是多少个 tick 下落一格。等级越高dropInterval越小方块落得越快。这种曲线在低等级的时候给玩家足够反应时间在高等级时节奏明显加快实际游玩手感不错。5. 完整源码与编译运行5.1 全部源码下面的代码是完整的单文件实现我按照第 2、3、4 节讲的设计原样整理了注释。你把它保存为tetris.cpp即可。/* * Console Tetris * 平台Windows / Linux / macOS 控制台 * 操作 * A / D 或 方向键左右移动 * W / 上方向键旋转 * S / 下方向键软降一格 * 空格 硬降到底 * Q 退出 */ #include cstdio #include cstring #include cstdlib #include ctime #include string #include algorithm #ifdef _WIN32 #include conio.h #include windows.h #else #include termios.h #include unistd.h #include fcntl.h int _kbhit() { struct termios oldt, newt; tcgetattr(STDIN_FILENO, oldt); newt oldt; newt.c_lflag ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, newt); int oldf fcntl(STDIN_FILENO, F_GETFL, 0); fcntl(STDIN_FILENO, F_SETFL, oldf | O_NONBLOCK); int ch getchar(); tcsetattr(STDIN_FILENO, TCSANOW, oldt); fcntl(STDIN_FILENO, F_SETFL, oldf); if (ch ! EOF) { ungetc(ch, stdin); return 1; } return 0; } int _getch() { struct termios oldt, newt; int ch; tcgetattr(STDIN_FILENO, oldt); newt oldt; newt.c_lflag ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, newt); ch getchar(); tcsetattr(STDIN_FILENO, TCSANOW, oldt); return ch; } #endif using namespace std; const int COLS 10; const int ROWS 20; const int SCREEN_W COLS * 2 20; const int SCREEN_H ROWS 2; const int SHAPES[7][4][4] { { {0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0} }, // I { {0,0,0,0},{0,1,1,0},{0,1,1,0},{0,0,0,0} }, // O { {0,0,0,0},{0,1,0,0},{1,1,1,0},{0,0,0,0} }, // T { {0,0,0,0},{0,1,1,0},{1,1,0,0},{0,0,0,0} }, // S { {0,0,0,0},{1,1,0,0},{0,1,1,0},{0,0,0,0} }, // Z { {0,0,0,0},{1,0,0,0},{1,1,1,0},{0,0,0,0} }, // J { {0,0,0,0},{0,0,1,0},{1,1,1,0},{0,0,0,0} } // L }; bool board[ROWS][COLS]; char screen[SCREEN_H][SCREEN_W]; int score 0; int totalLines 0; int level 1; void gotoxy(int x, int y) { #ifdef _WIN32 COORD c; c.X x; c.Y y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), c); #else printf(\033[%d;%dH, y 1, x 1); #endif } void hideCursor() { #ifdef _WIN32 CONSOLE_CURSOR_INFO info {100, FALSE}; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), info); #else printf(\033[?25l); #endif } void showCursor() { #ifdef _WIN32 CONSOLE_CURSOR_INFO info {100, TRUE}; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), info); #else printf(\033[?25h); #endif } struct Block { int shape[4][4]; int x, y; Block() { memset(shape, 0, sizeof(shape)); x COLS / 2 - 2; y 0; } void load(int type) { memcpy(shape, SHAPES[type], sizeof(shape)); } void rotate() { int t[4][4]; for (int i 0; i 4; i) for (int j 0; j 4; j) t[j][3 - i] shape[i][j]; memcpy(shape, t, sizeof(shape)); } void rotateBack() { int t[4][4]; for (int i 0; i 4; i) for (int j 0; j 4; j) t[3 - j][i] shape[i][j]; memcpy(shape, t, sizeof(shape)); } }; bool collide(const Block b, int nx, int ny) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (!b.shape[i][j]) continue; int bx nx j; int by ny i; if (bx 0 || bx COLS || by ROWS) return true; if (by 0 board[by][bx]) return true; } } return false; } void lockBlock(const Block b) { for (int i 0; i 4; i) for (int j 0; j 4; j) if (b.shape[i][j]) { int bx b.x j; int by b.y i; if (by 0 by ROWS bx 0 bx COLS) board[by][bx] true; } } int clearLines() { int lines 0; for (int r ROWS - 1; r 0; --r) { bool full true; for (int c 0; c COLS; c) if (!board[r][c]) { full false; break; } if (full) { lines; for (int k r; k 0; --k) for (int c 0; c COLS; c) board[k][c] board[k - 1][c]; for (int c 0; c COLS; c) board[0][c] false; r; } } return lines; } void render(const Block cur) { for (int i 0; i SCREEN_H; i) for (int j 0; j SCREEN_W; j) screen[i][j] ; for (int i 0; i SCREEN_H; i) for (int j 0; j COLS * 2 1; j) if (i 0 || i SCREEN_H - 1 || j 0 || j COLS * 2 1) screen[i][j] #; for (int r 0; r ROWS; r) for (int c 0; c COLS; c) if (board[r][c]) { screen[r 1][c * 2 1] [; screen[r 1][c * 2 2] ]; } for (int i 0; i 4; i) for (int j 0; j 4; j) if (cur.shape[i][j]) { int sr cur.y i 1; int sc (cur.x j) * 2 1; if (sr 0 sr SCREEN_H - 1 sc 0 sc SCREEN_W - 2) { screen[sr][sc] [; screen[sr][sc 1] ]; } } string infos[] { Score: to_string(score), Level: to_string(level), Lines: to_string(totalLines), , A/D Move, W Rotate, S SoftDown, Space HardDrop, Q Quit }; int infoX COLS * 2 4; for (size_t k 0; k sizeof(infos) / sizeof(infos[0]); k) for (size_t i 0; i infos[k].size() infoX (int)i SCREEN_W; i) screen[1 (int)k][infoX (int)i] infos[k][i]; gotoxy(0, 0); string out; for (int i 0; i SCREEN_H; i) { out.append(screen[i], SCREEN_W); if (i SCREEN_H - 1) out \n; } printf(%s, out.c_str()); } int main() { srand((unsigned)time(nullptr)); memset(board, 0, sizeof(board)); hideCursor(); Block cur; cur.load(rand() % 7); bool running true; long long tick 0; while (running) { if (_kbhit()) { int key _getch(); if (key 224 || key 0) { key _getch(); switch (key) { case 75: if (!collide(cur, cur.x - 1, cur.y)) --cur.x; break; case 77: if (!collide(cur, cur.x 1, cur.y)) cur.x; break; case 80: if (!collide(cur, cur.x, cur.y 1)) cur.y; break; case 72: cur.rotate(); if (collide(cur, cur.x, cur.y)) cur.rotateBack(); break; } } else if (key a || key A) { if (!collide(cur, cur.x - 1, cur.y)) --cur.x; } else if (key d || key D) { if (!collide(cur, cur.x 1, cur.y)) cur.x; } else if (key s || key S) { if (!collide(cur, cur.x, cur.y 1)) cur.y; } else if (key w || key W) { cur.rotate(); if (collide(cur, cur.x, cur.y)) cur.rotateBack(); } else if (key ) { while (!collide(cur, cur.x, cur.y 1)) cur.y; lockBlock(cur); int lines clearLines(); score lines 0 ? 0 : (lines 1 ? 100 : lines 2 ? 300 : lines 3 ? 500 : 800); totalLines lines; level totalLines / 10 1; cur.load(rand() % 7); if (collide(cur, cur.x, cur.y)) { running false; break; } } else if (key q || key Q || key 27) { running false; break; } } tick; int dropInterval max(1, 18 - (level - 1) * 2); if (tick % dropInterval 0) { if (!collide(cur, cur.x, cur.y 1)) { cur.y; } else { lockBlock(cur); int lines clearLines(); score lines 0 ? 0 : (lines 1 ? 100 : lines 2 ? 300 : lines 3 ? 500 : 800); totalLines lines; level totalLines / 10 1; cur.load(rand() % 7); if (collide(cur, cur.x, cur.y)) { running false; break; } } } render(cur); #ifdef _WIN32 Sleep(30); #else usleep(30000); #endif } showCursor(); gotoxy(0, SCREEN_H); printf(Game Over! Score: %d, Lines: %d\n, score, totalLines); #ifdef _WIN32 system(pause); #else printf(\n); #endif return 0; }5.2 Visual Studio 与 MinGW 编译在Visual Studio里新建控制台应用把代码粘贴进源.cpp直接编译运行即可。这里插一句很多新手会搜到的问题程序编译完成后如果拿到别的电脑上运行提示缺少运行库那是因为 MSVC 编译出的程序依赖对应版本的 Visual C Redistributable。用MinGWg编译则不会因为 MinGW 默认静态链接了运行库。如果你习惯用MinGW / g在项目目录打开终端执行g -O2 -o tetris tetris.cpp ./tetris-O2优化级别对终端渲染帧率也有帮助建议保留。5.3 Linux / macOS 编译运行与 VS Code 环境Linux 和 macOS 直接用 g / clang 编译g -O2 -o tetris tetris.cpp ./tetris注意 macOS 上如果没装命令行工具会提示找不到g执行xcode-select --install安装即可。用VS Code写 C 项目的同学我建议这样配置环境安装 C/C 扩展微软官方那个。安装编译器后在终端里确认g -v能正常输出。用 VS Code 自带的终端执行上面的编译命令不要依赖插件自动任务。很多同学问我vscode配置c/c环境到底怎么才算配好我觉得标准很简单能在终端里把.cpp文件编译成可执行文件并且能跑起来。至于 launch.json、tasks.json 那些都是锦上添花的东西不配一样能开发。6. 实测中的问题与针对性优化6.1 旋转后卡墙的处理贴墙旋转是俄罗斯方块最常见的手感问题。比如 I 方块贴着右墙玩家按旋转想把它立起来但旋转后的纵向方块有一部分在墙外我的实现会直接判定碰撞并回滚。更接近商业游戏的做法是加一个墙踢旋转后如果发生碰撞就左右平移 1-2 格看看能不能塞进去。常见做法是依次尝试偏移0, -1, 1, -2, 2哪个位置不碰撞就用哪个。代码结构可以这样改// 旋转入口 cur.rotate(); bool ok !collide(cur, cur.x, cur.y); int offsets[] {0, -1, 1, -2, 2}; if (!ok) { for (int k 1; k 5; k) { int nx cur.x offsets[k]; if (!collide(cur, nx, cur.y)) { cur.x nx; ok true; break; } } } if (!ok) cur.rotateBack();这个改动不大但对手感的提升非常明显强烈建议自己动手加上。6.2 长按方向键导致移动过快把按键判断放在帧循环里的一个副作用是只要你按住方向键它会以帧率的速度持续移动。笔记本键盘的按键重复率又很高有时候想微调一格结果一下子飞出去两格。要解决这个可以在主循环里维护moveCooldown和inputRepeat两个计数器第一次按下的瞬间立即移动之后只有经过一定 tick 才响应第二次。我没在基础版本里加是为了让核心逻辑更直观但这个优化值得尝试。6.3 终端闪烁问题与基于坐标的局部绘制主循环里我每次重建整个屏幕字符串并输出屏幕是 22 行 x 40 列数据量不算大在 30ms 帧间隔下基本稳定。但如果终端窗口尺寸很大或者未来你想扩展出更多信息面板完整重绘会开始吃力。这时候可以引入脏矩形思想只在上一帧和这一帧不同的区域重新输出。比如方块移动时只有旧位置和新位置那几行需要重绘计分变化时只有信息面板那个区域需要重绘。实现方式是把上一帧的屏幕内容保存下来比较当前帧凡是不同的区域都用gotoxy定位到对应位置再单独输出。对这种规模的控制台游戏完整重绘通常是够的。但如果想追求极致流畅局部重绘是一条你值得探索的路。6.4 从控制台到图形界面的扩展建议控制台版跑通之后最自然的下一步是把它改造成图形界面版本。我的建议是保留现有游戏逻辑只替换render()函数。具体路径可以是用SFML或SDL2创建一个窗口把每个格子画成有颜色的矩形。在render()里遍历棋盘根据board[r][c]绘制对应颜色。把按键检测从_kbhit()换成图形库的事件监听。因为核心逻辑碰撞、消行、旋转、计分全部在Block和全局棋盘里与渲染完全解耦替换渲染层几乎不需要改动其他代码。这也是当初坚持把数据结构和渲染分层的原因——游戏逻辑永远是游戏的内核渲染只是展示方式。我自己的体会是如果你能把这个控制台版真正吃透再做图形版可能只需要两天。但如果一上来就直接写图形版光窗口事件和贴图就够你忙活一周而且写完你对游戏逻辑的理解可能还是模糊的。从简单处着手反而走得更快。
返回列表