需要用到的知识点

  1. 条件判断
  2. 循环
  3. 函数
  4. 数组
  5. 多cpp文件调用
  6. 指针
  7. windows的cmd操作

整个程序的实现流程

我们先简单看一下整个实现流程的目录,这个也差不多是我实现时候的具体流程。我一般在写一个相对有点工程量的东西之前会先确定我要做的东西是什么,然后进行分析,接着就是大概设计一下整个项目的模块都有哪些,当然基本是不可能完全覆盖和正确的,但是会有一个大致的方向和流程去完成整个项目,能有效降低不必要工作和逻辑的清晰程度,也就是完成了熵减,这都是大话,我们还是直接看具体要怎么实现我们今天的主题–俄罗斯方块。

1. 画图

我最喜欢的就是先画图,先将朴实无华的数字打印变成花里胡哨的图案, 能够让整个工作从一开始就显得极其有趣,本次地图沿用上一次写的贪吃蛇地图,不过我们采用光标移动的方式来进行地图元素的局部刷新,这样能有效避免全局刷新带来的闪屏,因为这次我们依旧完全只采用cmd来编程,所以我们不需要任何额外的安装包。

画图模块主要完成的是两件事情: 画地图,画方块。后面还会加入绘制提示板块和得分板块的功能。

画地图

HANDLE hOut=GetStdHandle(STD_OUTPUT_HANDLE);//获取标准输出句柄/* 设置光标位置 */
void pos(int x,int y){// 这里有个非常注意的点需要你关注,这里的x和y代表的是cmd中的坐标,// 第一个参数位横轴,第二个参数是纵轴,另外由于我们打印的字符占位两个位置,所以每次偏移量要乘以二// 另外,二维数组的第一维代表的是行数,也就是纵轴,第二维代表的是列数,也就是横轴,所以在选定光标位置时需要进行调换COORD posPoint = {x * 2, y}; //设置坐标SetConsoleCursorPosition(hOut,posPoint);
}/* 初始化地图 */
void initMap(int **map){// 这里有一个需要关注的地方, 那就是边界是墙,那么到时候在判定的时候, // 我们要将墙体的大小算进到整个地图大小中,也就是height是包含了墙体了的 for(int i = 0; i < height; i++){for(int j = 0; j < width; j++){if(i == height - 1){map[i][j] = 1; }else if(j == 0 || j == width - 1){map[i][j] = 1; }else{map[i][j] = 0; }}} return;
}/* 绘制地图 */
void drawMap(int **map){;pos(0, 0);for(int i = 0; i < height; i++){for(int j = 0; j < width; j++){if(map[i][j] == 1){cout << "■"; // 墙体 }else{cout << "□";    // 可移动区域 }}cout << endl;} return;
}

上面的注释其实已经介绍了,但我这里还是要强调一下:二维数组的第一维代表的是行数,也就是纵轴,第二维代表的是列数,也就是横轴,所以在选定光标位置时需要进行调换。

地图绘制完成后,我们可以开始绘制方块了, 这里我们假设我们已经完成了方块的设定和初始化了,当然,我比较建议你先跳转到初始化方块的部分先了解方块是怎么定义的,这样有助于你理解一下的实现。

画方块

/* 绘制方块 */
void drawBlock(Block block){int x = block.x; // 获取方块左上角的坐标int y = block.y;for(int i = 0; i < 4; i++){for(int j = 0; j < 4; j++){pos(y + j, x + i); // 这里的x和y的位置倒置,上面讲pos这个函数时有讲述原因。if(block.shape[i][j] == 1 && x + i >= 0 && x + i < height - 1 && y + j < width - 1 && y + j > 0){std::cout << "■";}}}pos(0,height); // 绘制完成后,需要将位置定位到最低行,这样有助于减少光标对游戏的视觉影响。
}/* 清除方块 */
void cleanBlock(Block block){int x = block.x;int y = block.y;for(int i = 0; i < 4; i++){for(int j = 0; j < 4; j++){pos(y + j, x + i);if(block.shape[i][j] == 1 && x + i >= 0 && x + i < height - 1 && y + j < width - 1 && y + j > 0){ // 注意只有原方块存在■的地图需要涂成□,这样就可以避免误删std::cout << "□";}}}pos(0,height);
} /* 添加方块到地图中 */
void addBlock(int **map, Block block){int x = block.x;int y = block.y;for(int i = 0; i < 4; i++){for(int j = 0; j < 4; j++){if(block.shape[i][j] == 1 && x + i >= 0 && x + i < height - 1 && y + j < width - 1 && y + j > 0){map[x+i][y+j] = block.shape[i][j];}}}
}

绘制方块:将方块绘制到地图中去,这个方块目前是独立且存在的,可以移动,也可以旋转。

清除方块:从地图中清除掉当前方块。当方块移动或者是旋转时,我们会采用先将方块清除掉的策略,然后再将移动或旋转后的方块绘制到地图中去(策略简单且好实现与维护)。

添加方块到地图中:当方块不能再往下之后,我们将他的方块存放到当前地图中。由于方块绘制已经完成了,我们只需要将这个值存放到全局地图变量map数组中,保证这个方块值确实存放入地图中就可以了。

:map是地图变量,0表示可以移动的区域,1表示不可移动区域。我们采取将绘制和地图元素保存同步且独立的方式进行操作,也就是显示的内容确实是与map的值相同的,但是我们可以将显示的内容当前一个特殊的值载体,map看成一个载体,这样子,我们或许能够更加清晰地理清整个过程。

2. 初始化方块

关于方块的初始化, 本来是想使用类的继承和多态的,后来也只是用了类的属性和方法这一特性而已,使用了类初始化对象的方式也确实简化了一些工作(虽然不用也可以,就是我想学习一下类而已)。

方块的类定义如下:

class Block // class declaration
{public:int x; // 横坐标,以左上角为标志位 int y; // 纵坐标,以左上角为标志位 int type; // 方块类型 int director; // 旋转方向 0:向左,1向右 int shape[4][4]; // 格子大小 int shapes[8][4][4]; public:/* 设置方块属性 */ void set(int _x, int _y, int _shape){x = _x;y = _y;if(_shape != -1){for(int i = 0; i < 4; i++)for(int j = 0; j < 4; j++)shape[i][j] = shapes[_shape][i][j];   type = _shape;director = 0;}}void generate(){for(int i = 0; i < 8; i++)for(int j = 0; j < 4; j++)for(int k = 0; k < 4; k++)shapes[i][j][k] = 0;/* 石头 */shapes[0][1][1] = 1;/* 棍子 */shapes[1][1][0] = shapes[1][1][1] = shapes[1][1][2] = shapes[1][1][3] = 1;/* 七 (左)*/shapes[2][0][0] = shapes[2][0][1] = shapes[2][1][1] = shapes[2][2][1] = 1;/* 七 (右) */shapes[3][0][1] = shapes[3][0][2] = shapes[3][1][1] = shapes[3][2][1] = 1;/* 凸 */ shapes[4][0][1] = shapes[4][1][0] = shapes[4][1][1] = shapes[4][2][1] = 1;/* 田 */shapes[5][1][1] = shapes[5][1][2] = shapes[5][2][1] = shapes[5][2][2] = 1;/* Z(左) */shapes[6][0][0] = shapes[6][0][1] = shapes[6][1][1] = shapes[6][1][2] = 1;/* Z(右)*/shapes[7][0][2] = shapes[7][0][1] = shapes[7][1][1] = shapes[7][1][0] = 1;}};

以上的参数都有对应的注释了,所以我也不再赘述,我核心讲一下这么设定的原因。(其实我是偷师了好几个人才选择了这样的方式,嘘~~)

我们采用一个4*4的网格来存放一个俄罗斯方块,并且我们希望这个方块尽量靠近左上角(统一才是最好的),当然,除了石头和田(这两是乖孩子呀),因为这两个不需要进行旋转(没错,这个紧靠左上角的策略是为了后续的旋转做准备)。使用二维矩阵来表示方块能够很方便地进行shape矩阵和map矩阵的运算,而其中x,y就是shape矩阵再map矩阵中的偏移量。这样我们就可以很清楚地知道这个shape矩阵现在位于map矩阵中的哪个位置,就可以直接进行计算,绘制,以及判断等操作。

其中,4*4的网格(矩阵)中, 取值为1表示的是此处有方块■,其他为0则表示没有方块,这样就完成了方块的初始化了。

然后外部通过传入方块类型type来指定当前初始化的俄罗斯方块属于什么方块,这也为了后续的随机生成方块以及方块提示做了准备。

3. 方块旋转

这一部分我想算是整个俄罗斯方块比较复杂和困难的地方了, 旋转的困难主要在于,1. 不同的方块的旋转策略可能不同,2. 旋转是否合法(旋转后如果撞墙或者会与其他已有块撞在一起,那是不可以进行旋转的)。

关于第一个难点,由于我们已经将俄罗斯方块尽量贴着左上角来绘制了,所以除了棍子方块以外(棍子这个异类,我们批判他),其他方块都是存放在左上角3*3的矩阵中的,所以我们进行旋转时,我们可以只旋转左上角的3*3的矩阵。另外有部分方块其实只有两种形态,所以向左旋转后,我们希望它下次旋转是向右旋转的,这其中有棍子,Z这两种方块,而旋转方向则由当前俄罗斯方块的方向来决定,然后我们筛选出其中不需要旋转的俄罗斯方块:石头,田;那么分类就很清晰了。可以分成一下四类。

  1. 棍子。
  2. 棍子,Z。
  3. 石头,田。
  4. 7,凸。(7777777777)

关于第二个难点,我们采用一个比较偷懒但是确实有效的方案,我们先完成旋转,然后检查旋转后的俄罗斯方块是否与已有方块重合,如果有,则再让方块逆方向旋转一次。最后绘制旋转后的俄罗斯方块。(这里你可以选择改进我的代码,只有正确旋转才会进行擦除以及重绘,这样应该能让逻辑显得更加智慧一些。)

也许你很想要写那种直接判断是否可以旋转的代码来实现这个功能,而不是像我这样转来转去浪费计算资源,如果你选择这么实现我当然不会阻止,毕竟能实现这种操作的也是大佬了,我也很希望你能留言你的想法的,因为我当时考虑了好久,发现这种方法好实现,而且其实浪费的计算资源也还可以接受,其实最重要的是逻辑简单。(我最喜欢的就是逻辑简单的代码,我从来不难为我自己)。

代码如下:

/* 矩阵旋转90度 */
void rotation(Block *block, int director){// 我们只旋转左上角3*3的矩阵,并且向director旋转90度/* 向左旋转 */if(director == 0){/* 角转换 */ int value = block->shape[0][0];block->shape[0][0] = block->shape[0][2];block->shape[0][2] = block->shape[2][2];block->shape[2][2] = block->shape[2][0];block->shape[2][0] = value;/* 十字转换 */value = block->shape[0][1];block->shape[0][1] = block->shape[1][2];block->shape[1][2] = block->shape[2][1];block->shape[2][1] = block->shape[1][0];block->shape[1][0] = value;}else if(director == 1){/* 角转换 */ int value = block->shape[0][0];block->shape[0][0] = block->shape[2][0]; block->shape[2][0] = block->shape[2][2];block->shape[2][2] = block->shape[0][2];block->shape[0][2] = value;/* 十字转换 */value = block->shape[0][1];block->shape[0][1] = block->shape[1][0];block->shape[1][0] = block->shape[2][1];block->shape[2][1] = block->shape[1][2];block->shape[1][2] = value;}/* 处理棍子的特殊情况 */if(block->type == 1){if(block->shape[1][3] == 1){block->shape[1][3] = 0;block->shape[3][1] = 1;}else{block->shape[1][3] = 1;block->shape[3][1] = 0;}}
}
void _transfer(int **map, Block *block, int sign){int director = block->director;cleanBlock(*block);     // 擦除旋转前的block switch(sign){case 0:rotation(block, director);// 旋转后进行碰撞检查if(checkCrash(map, block) == 1){rotation(block, director^1);}break;case 1:rotation(block, director);block->director = block->director ^ 1;// 旋转后进行碰撞检查if(checkCrash(map, block) == 1){rotation(block, director ^ 1);block->director = block->director ^ 1;}break;}drawBlock(*block);  // 重新绘制旋转后的block
}
/* 旋转 */
void transfer(int **map, Block *block){int sign = block->type;/*sign: 什么类型的方块, 不同类型的方块的旋转策略不同 */if(sign == 2 || sign == 3 || sign == 4){ // 在左上角的三格内旋转 _transfer(map, block, 0);}else if(sign == 1 || sign == 6 || sign == 7){    // 处理棍子,Z_transfer(map, block, 1);}else if(sign == 0 || sign == 5){ // 不需要处理的
//      _transfer(block, 2);return;}}

4. 方块移动

哇, 旋转写完了, 终于我们可以开始写移动了。

这里我要讲一下一开始我的误区,我一开始是方块和地图不独立的,所以地图也存放这当前移动方块的值,那么移动的时候,直观上来看,假如我们向左移动一列,那么我们应该删除右边那一列的俄罗斯方块本身,然后将整个俄罗斯方块一列一列地不断地向左替换。新到达的左边一列复制右边一列的俄罗斯方块(注意,这里我们复制的是俄罗斯方块本身的块,也就是说我们还要检查每个点是否是俄罗斯方块本身),这样的操作在这种地图与方块融合的方式实现起来极其复杂(我真为自己感到着急,第一天写的时候差点就因为这个放弃了,然后我就尝试将方块和地图进行分离存放。)。

分离存放后,我们采取用x,y来存储一个俄罗斯方块在地图map中的偏移量,并且方块自己存储自己的方块矩阵shape。这样就可以很方便地实现我们上面的操作,然后我就发现,再使用上面的操作似乎也没有必要,我完全可以直接先将移动前的方块从地图中清除,然后再将移动后的方块绘制出来,这样只需要修改x,y这两个偏移量就可以实现移动的操作,而擦除的是cmd中的打印的方块,所以也不需要操作map,需要注意的是,这里不需要操作map中的值,这也是我上面解释说要讲cmd和map各自看成独立的存值变量的原因。

我们总结一下(怪我太啰嗦了):

  1. 按下移动建后,将俄罗斯方块从地图中擦除,并更新x,y。
  2. 检查移动后的方块是否合法,如果不合法,将x,y重新设置为原值。
  3. 绘制新的俄罗斯方块。

你也可以像上面的方块旋转一样,改进我这里的逻辑,在检查之后再进行擦除操作,让代码显得更加智慧。

int _move(int **map, Block *block, int x, int y, int sign){// sign: 1表示向下移动, 0表示左右移动 /* 消除所有属于block的块 *//* 重新定位block的位置,生成新的block */cleanBlock(*block);block->set(block->x + x, block->y + y, -1);if(checkCrash(map, block) == 1){block->set(block->x - x, block->y - y, -1);drawBlock(*block);if(sign == 1)addBlock(map, *block);return 1;}drawBlock(*block);return 0;
}

5. 异常检查

异常检查就是为了检查移动和旋转是否合法,以及是否死亡。说白了就是检查俄罗斯方块与地图有重合。

int checkCrash(int **map, Block *block){int x = block->x;int y = block->y;for(int i = 0; i < 4; i++){for(int j = 0; j < 4; j++){if(block->shape[i][j] == 1 && map[x+i][y+j] == 1){return 1;}}}return 0;
}

6. 成行检查

是否有成行的,如果有,消行加分。

int _checkLine(int **map, int line, int width){for(int i = 0; i < width; i++){if(map[line][i] == 0)return 0;}return 1;
}
int checkLine(int **map, int height, int width){int indexL = -1;/* 检查哪一行是成行的 */for(int i = 0; i < height - 1; i++){int sign = 0;if(_checkLine(map, i, width) == 1){indexL = i;break;}}//如果某一行成行,则将当前行的值替换成上一行的值,并且以上的行也进行相同操作,除了第一行。if(indexL != -1){for(int i = indexL; i > 0; i--){for(int j = 0; j < width; j++){map[i][j] = map[i-1][j];}}}else{return 0;} return 1;
}

7. 提示

提示功能是比较花里胡哨的功能了,不过,游戏的丰富度是极其重要的,所以我这次就很坚定地加入了这个模块。

代码如下:

提示下一个方块

void drawPrompt(){/* 加入提示:提示下一个方块的形状 *//**1. 为了方便起见,我们这个提示的大小直接固定,如果你有想要修改的意向,这一部分的操作也是个可展开细做的地方。 2. 提示部分放在右上角最顶部 **/ int promptH = 8;int promptW = 8; pos(width, 0);for(int i = 0; i < promptH; i++){for(int j = 0; j < promptW; j++){pos(width + j, i);if(i == promptH - 1 || i == 0){std::cout << "■";}else if(j == 0 || j == promptW - 1){std::cout << "■";}}} pos(0,height);
} void _drawPrompt(Block block){/* 加入提示:提示下一个方块的形状 *//**1. 为了方便起见,我们这个提示的大小直接固定,如果你有想要修改的意向,这一部分的操作也是个可展开细做的地方。 2. 提示部分放在右上角最顶部 **/ for(int i = 0; i < 4; i++){for(int j = 0; j < 4; j++){pos(width + 2 + j, 3 + i);if(block.shape[i][j] == 1){std::cout << "■";}else{std::cout << "  ";}}}pos(0,height);
}

提示已获得分数

简单起见,最高分为999分。(因为画图真的好难啊! 呜呜呜~~)。

void _drawNumber(int points[5][3], int x, int y){for(int i = 0; i < 5; i++){for(int j = 0; j < 3; j++){COORD posPoint = {2 * y + j, x + i}; //设置坐标SetConsoleCursorPosition(hOut,posPoint);if(points[i][j] == 1){cout << "+";}else{cout << " ";} }}pos(0, height);
}void drawNumber(int number, int x, int y){if(number == 0){int points[5][3] = {{1, 1, 1},{1, 0, 1},{1, 0, 1},{1, 0, 1},{1, 1, 1}};_drawNumber(points, x, y);}else if(number == 1){int points[5][3] = {{0, 0, 1},{0, 0, 1},{0, 0, 1},{0, 0, 1},{0, 0, 1}};_drawNumber(points, x, y);}else if(number == 2){int points[5][3] = {{1, 1, 1},{0, 0, 1},{1, 1, 1},{1, 0, 0},{1, 1, 1}};_drawNumber(points, x, y);}else if(number == 3){int points[5][3] = {{1, 1, 1},{0, 0, 1},{1, 1, 1},{0, 0, 1},{1, 1, 1}};_drawNumber(points, x, y);}else if(number == 4){int points[5][3] = {{1, 0, 1},{1, 0, 1},{1, 1, 1},{0, 0, 1},{0, 0, 1}};_drawNumber(points, x, y);}else if(number == 5){int points[5][3] = {{1, 1, 1},{1, 0, 0},{1, 1, 1},{0, 0, 1},{1, 1, 1}};_drawNumber(points, x, y);}else if(number == 6){int points[5][3] = {{1, 1, 1},{1, 0, 0},{1, 1, 1},{1, 0, 1},{1, 1, 1}};_drawNumber(points, x, y);}else if(number == 7){int points[5][3] = {{1, 1, 1},{0, 0, 1},{0, 0, 1},{0, 0, 1},{0, 0, 1}};_drawNumber(points, x, y);}else if(number == 8){int points[5][3] = {{1, 1, 1},{1, 0, 1},{1, 1, 1},{1, 0, 1},{1, 1, 1}};_drawNumber(points, x, y);}else if(number == 9){int points[5][3] = {{1, 1, 1},{1, 0, 1},{1, 1, 1},{0, 0, 1},{1, 1, 1}};_drawNumber(points, x, y);}return;
}void drawScore(int score){/* 展示已获得的分数 */drawNumber(score % 10, promptH + 1, width + 6);score = score / 10;drawNumber(score % 10, promptH + 1, width + 3);score = score / 10;drawNumber(score % 10, promptH + 1, width + 0);
}

代码

欢迎star!!^ . ^

github: https://github.com/iajqs/pratice-c/tree/master/Tetris

参考

  1. https://zhuanlan.zhihu.com/p/57052168 这个强烈推荐,从大佬那里学来很多东西,而且还有骚操作。

C++ 实现俄罗斯方块(附详细解析)相关推荐

  1. upload-labs 全1-21关 附详细解析(文件上传漏洞)

    目录 注释:分析在每关开头. 第 1 关 第 2 关 第 3 关 第 4 关​​​ 第 5 关 第 6 关​​​ 第 7 关 第 8 关 第 9 关​ 第 10 关 第 11 关 ​第 12 关 第 ...

  2. 开发者进阿里必看的30道经典数据库面试题【附详细解析】

    (6)rowid是联系表与DBF文件的桥梁 [](()索引特点 索引的特点 (1)索引一旦建立,** Oracle管理系统会对其进行自动维护**, 而且由Oracle管理系统决定何时使用索引 (2)用 ...

  3. 嵌入式系统测试题40道附详细解析

    1.与个人计算机(PC)相比,嵌入式系统具有许多不同的特点.下面不属于嵌入式系统特点的是(  ). A)  嵌入式系统与具体应用紧密结合,具有很强的专用性 B)  嵌入式系统通常包含在非计算机设备(系 ...

  4. 4003基于邻接表的新顶点的增加(C++,附详细解析)

    描述 给定一个无向图,在此无向图中增加一个新顶点. 输入 多组数据,每组m+2行.第一行有两个数字n和m,代表有n个顶点和m条边.顶点编号为1到n.第二行到第m+1行每行有两个数字h和k,代表边依附的 ...

  5. 电子电路期末考试复习预测题一(内附详细解析)

    电子电路期末考试复习预测题(一) 目录 电子电路期末考试复习预测题(一) 复习题一 试题库(1)直流电路 试题库(2)直流电路 试题库(3)暂态电路 复习题一 试题库(1)直流电路 三.单项选择题 1 ...

  6. 电子电路期末考试复习预测题二(2)(内附详细解析)

    电子电路期末考试复习预测题二(2) 目录 电子电路期末考试复习预测题二(2) 复习题二 试题库(4)交流电路 试题库(5)交流电路 试题库(6)交流电路 试题库(7)交流电路 试题库(8)暂态电路 复 ...

  7. JUC.Condition学习笔记[附详细源码解析]

    JUC.Condition学习笔记[附详细源码解析] 目录 Condition的概念 大体实现流程 I.初始化状态 II.await()操作 III.signal()操作 3个主要方法 Conditi ...

  8. php tire树,Immutable.js源码之List 类型的详细解析(附示例)

    本篇文章给大家带来的内容是关于Immutable.js源码之List 类型的详细解析(附示例),有一定的参考价值,有需要的朋友可以参考一下,希望对你有所帮助. 一.存储图解 我以下面这段代码为例子,画 ...

  9. beautifulsoup解析动态页面div未展开_实战|Python轻松实现动态网页爬虫(附详细源码)...

    用浅显易懂的语言分享爬虫.数据分析及可视化等干货,希望人人都能学到新知识.项目背景事情是这样的,前几天我公众号写了篇爬虫入门的实战文章,叫做<实战|手把手教你用Python爬虫(附详细源码)&g ...

最新文章

  1. 硬盘突然提示没有初始化_分享一下固态硬盘不认盘的修复方法
  2. 巨鲸任务调度平台:spark flink任务调度
  3. SQL的各种连接(cross join、inner join、full join)的用法理解
  4. css background 充满自适应_剖析一些经典的CSS布局问题,为前端开发+面试保驾护航...
  5. Kiwi浏览器 MIUI禁第三方广告 ADB停用系统应用
  6. lisp钢管_技术专栏集合管道模式(上)
  7. TCP的三次握手和四次挥手理解及面试题
  8. 57个深度学习专业术语
  9. 利用Swoole编写一个TCP服务器,顺带测试下Swoole的4层生命周期
  10. Atitit.java线程池使用总结attilax 1.1. 动态更改线程数量 1 1.2. code 1 三、线程池的原理 其实线程池的原理很简单,类似于操作系统中的缓冲区的概念,它的流程如下
  11. STM32CubeMAX入门篇
  12. 计算机查找的快捷键是,电脑快捷键快速查找
  13. 云计算实战应用案例精讲-【深度学习】多模态融合(论文篇七)
  14. 2021React面试精选——持续更新
  15. 华为防火墙地址转换技术(NAT)
  16. 虹科资讯| 虹科AR荣获汽车后市场“20佳”维修工具评委会提名奖!
  17. Linux安装Elasticsearch和Kibana
  18. TensorFlow Lite(实战系列一):TFLite Android 迁移训练构建自己的图像识别APP
  19. 香港区块链贸易融资平台将于9月上线
  20. 计算机毕业设计java企业人事管理

热门文章

  1. 中国剩余定理与扩展中国剩余定理
  2. Problem E: 时间:24小时制转12小时制
  3. matlab中结束脚本运行_一个处理dump文件的小脚本
  4. 两种方法转换U盘格式
  5. win10网络图标变灰,状态栏网络图标消失【电信校园网】
  6. 在这六大场景用上桌面云,你就是最亮的新IT人!
  7. unity语言如何切换成中文编辑器的切换
  8. 你知道机器人奇点吗?机器人奇点问题应该如何解决?
  9. 习题 3-6 纵横字谜的答案
  10. Spring事务传播性(较详细描述)