C 实现植物大战僵尸(二)
C 实现植物大战僵尸(二)
前文链接,C 实现植物大战僵尸(一)
五 制作启动菜单
启动菜单函数
void startUI()
{
IMAGE imageBg, imgMenu1, imgMenu2;
loadimage(&imageBg, "res/menu.png");
loadimage(&imgMenu1, "res/menu1.png");
loadimage(&imgMenu2, "res/menu2.png");
bool mouseStatus = false; //0 表示鼠标未移动至开始游戏位置
while (1)
{
BeginBatchDraw();
putimage(0, 0, &imageBg);
//根据鼠标是否移动至游戏开始位置, 显示不同的图片
putimagePNG(UI_LEFT_MARGIN, UI_TOP_MARGIN, mouseStatus ? &imgMenu2 : &imgMenu1);
ExMessage msg;
if (peekmessage(&msg)) //监听鼠标事件
{
//当鼠标移动至开始游戏位置, 界面高亮
if (msg.x > UI_LEFT_MARGIN && msg.x < UI_LEFT_MARGIN + UI_WIDTH
&& msg.y > UI_TOP_MARGIN && msg.y < UI_TOP_MARGIN + UI_HIGHT)
{
putimagePNG(UI_LEFT_MARGIN, UI_TOP_MARGIN, &imgMenu2);
//表示鼠标移动至开始游戏位置, 如果一直不移动鼠标则一直高亮
mouseStatus = true;
//当鼠标点击时, 进入游戏
if (msg.message == WM_LBUTTONDOWN)
return;
}
else mouseStatus = false;
}
EndBatchDraw();
}
}
提醒
不能把 startUI 放在 gameInit 前, gameInit 包含了创建游戏图形窗口
int main()
{
gameInit(); //不能把 startUI 放在 gameInit 前, gameInit 包含了创建游戏图形窗口
startUI();
updateWindow(); //窗口视图展示
int timer = 0; //用以计时 20 毫秒更新一次
while (1)
{
userClick(); //监听窗口鼠标事件
timer += getDelay();
if (timer > 20)
{
updateWindow(); //更新窗口视图
updateGame(); //更新游戏动画帧
timer = 0;
}
}
system("pause");
return 0;
}
效果展示
鼠标移动至开始冒险模式时,会变成高亮效果,当点击开始开始冒险模式时,进入游戏
六 创建和显示随机阳光
相关数据结构
//阳光球在飘落过程中 X 坐标不变
typedef struct SunShineBall
{
int x; //当前 X 轴坐标
int y; //当前 Y 轴坐标
int frameId; //当前图片帧编号
int destination; //飘落目标位置 Y 坐标
bool used; //是否在使用
int timer; //统计飘落目标位置后的帧次数
}SunShineBall;
#define MAX_BALLS_NUM 10
#define SUM_SHINE_PIC_NUM 29
SunShineBall balls[MAX_BALLS_NUM];
IMAGE imgSunShineBall[SUM_SHINE_PIC_NUM];
在更新游戏数据的函数中,创建阳光球并且更新阳光球数据
void updateGame()
{
for (int i = 0; i < GRASS_GRID_ROW; ++i)
{
for (int j = 0; j < GRASS_GRID_COL; ++j)
{
if (plants[i][j].type >= 0)
{
if (imgPlant[plants[i][j].type][++plants[i][j].frameId] == NULL)
plants[i][j].frameId = 0;
}
}
}
createSunshine();
updateSunshine();
}
核心实现是 createSunshine (创建阳光球) 和 updateSunshine (更新阳光球数据)
void createSunshine()
{
static int callCnt = 0;
static int randomCallCnt = 400;
if (callCnt++ >= randomCallCnt)
{
randomCallCnt = 200 + rand() % 200;
callCnt = 0;
//从阳光池中取一个可用阳光
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
//找到一个未使用的阳光, 则进行初始化
if (!balls[i].used)
{
//只允许阳光掉落在草地范围内任意位置
balls[i].x = GRASS_LEFT_MARGIN +
(rand() % (GRASS_GRID_COL * GRASS_GRID_WIDTH));
balls[i].y = GRASS_TOP_MARGIN;
balls[i].frameId = 0;
//目标点在中间三行
balls[i].destination = GRASS_TOP_MARGIN +
GRASS_GRID_ROW + (rand() % (3 * GRASS_GRID_HIGHT));
balls[i].used = true;
balls[i].timer = 0;
break;
}
}
}
}
void updateSunshine()
{
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
if (balls[i].used)
{
if (balls[i].y < balls[i].destination)
{
balls[i].y += 2; //每次移动两个像素
//修改当前图片帧编号, 并在到达 SUM_SHINE_PIC_NUM 时重置图片帧为 0
balls[i].frameId = ++balls[i].frameId % SUM_SHINE_PIC_NUM;
}
else //当阳光下落至目标位置时, 停止移动
{
if (balls[i].timer < 100) ++balls[i].timer;
else balls[i].used = false;
}
}
}
}
在 gameInit 函数中加载阳光图片
void gameInit()
{
//加载背景图片
loadimage(&imgBg, "res/map0.jpg");
loadimage(&imgBar, "res/bar5.png");
//加载植物卡片
char name[64];
//将二维指针数组内存空间置零
memset(imgPlant, 0, sizeof(imgPlant));
memset(plants, -1, sizeof(plants));
memset(balls, 0, sizeof(balls));
for (int i = 0; i < PLANT_CNT; ++i)
{
//获取植物卡片相对路径名称
sprintf(name, "res/Cards/card_%d.png", i + 1);
loadimage(&imgCards[i], name);
for (int j = 0;i < MAX_PICTURE_NUM; ++j)
{
//获取动态植物素材相对路径名称
sprintf(name, "res/Plants/%d/%d.png", i, j + 1);
if (fileExist(name)) {
imgPlant[i][j] = new IMAGE;
loadimage(imgPlant[i][j], name);
}
else break;
}
}
//加载阳光图片
for (int i = 0; i < SUM_SHINE_PIC_NUM; ++i)
{
sprintf(name, "res/sunshine/%d.png", i + 1);
loadimage(&imgSunShineBall[i], name);
}
//配置随机种子
srand(time(NULL));
//创建游戏图形窗口
initgraph(WIN_WIDTH, WIN_HIGHT, 1);
}
在 updateWindow 函数中渲染阳光球
void updateWindow()
{
//使用双缓冲, 解决输出窗口闪屏
BeginBatchDraw();
//渲染背景图至窗口
putimage(0, 0, &imgBg);
putimagePNG(250, 0, &imgBar);
//渲染植物卡牌
for (int i = 0;i < PLANT_CNT;++i)
putimage(PIC_LEFT_MARGIN + i * PIC_WIDTH, 6, &imgCards[i]);
//渲染种植植物
for (int i = 0; i < GRASS_GRID_ROW; ++i)
{
for (int j = 0; j < GRASS_GRID_COL; ++j)
{
if (plants[i][j].type >= 0)
{
putimagePNG(GRASS_LEFT_MARGIN + j * GRASS_GRID_WIDTH + 5, //微调植物种植位置
GRASS_TOP_MARGIN + i * GRASS_GRID_HIGHT + 10,
imgPlant[plants[i][j].type][plants[i][j].frameId]);
}
}
}
//渲染随机阳光
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
if (balls[i].used)
putimagePNG(balls[i].x, balls[i].y, &imgSunShineBall[balls[i].frameId]);
}
//渲染当前拖动的植物
if (currIndex >= 0)
{
IMAGE* currImage = imgPlant[currIndex][0];
putimagePNG(currX - currImage->getwidth() / 2,
currY - currImage->getheight() / 2, currImage);
}
EndBatchDraw(); //结束双缓冲
}
效果展示
阳光球会在游戏开始的 400 帧后,开始从随机位置(只能是草坪)下落,之后阳光球会在 200 帧加上一个 200 内随机帧的时间内下落
七 收集阳光并显示阳光值
int sunShineVal = 50; //全局变量阳光值
核心函数
#include <mmsystem.h>
#pragma commet(lib, "winmm.lib")
//加上音效头文件, 如果有 mciSendString 外部符号 ERROR 请按下方链接解决
void collectSunShine(ExMessage* msg)
{
IMAGE* imgSunShine = NULL;
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
//阳光球在使用中
if (balls[i].used)
{
//找到对应的阳光球图片
imgSunShine = &imgSunShineBall[balls[i].frameId];
//判断鼠标移动的位置是否处于当前阳光球的位置
if (msg->x > balls[i].x && msg->x < balls[i].x + imgSunShine->getwidth()
&& msg->y > balls[i].y && msg->y < balls[i].y + imgSunShine->getheight())
{
mciSendString("play res/audio/sunshine.mp3", 0, 0, 0);
balls[i].used = false;
sunShineVal += 25;
}
}
}
}
在 userClick 函数中调用,收集阳光值
在 gameInit 函数中设置阳光值字体
void gameInit()
{
//加载背景图片
loadimage(&imgBg, "res/map0.jpg");
loadimage(&imgBar, "res/bar5.png");
//加载植物卡片
char name[64];
//将二维指针数组内存空间置零
memset(imgPlant, 0, sizeof(imgPlant));
memset(plants, -1, sizeof(plants));
memset(balls, 0, sizeof(balls));
for (int i = 0; i < PLANT_CNT; ++i)
{
//获取植物卡片相对路径名称
sprintf(name, "res/Cards/card_%d.png", i + 1);
loadimage(&imgCards[i], name);
for (int j = 0;i < MAX_PICTURE_NUM; ++j)
{
//获取动态植物素材相对路径名称
sprintf(name, "res/Plants/%d/%d.png", i, j + 1);
if (fileExist(name)) {
imgPlant[i][j] = new IMAGE;
loadimage(imgPlant[i][j], name);
}
else break;
}
}
//加载阳光图片
for (int i = 0; i < SUM_SHINE_PIC_NUM; ++i)
{
sprintf(name, "res/sunshine/%d.png", i + 1);
loadimage(&imgSunShineBall[i], name);
}
//配置随机种子
srand(time(NULL));
//创建游戏图形窗口
initgraph(WIN_WIDTH, WIN_HIGHT, 1);
//设置字体
LOGFONT f;
gettextstyle(&f);
f.lfHeight = 30;
f.lfWidth = 15;
strcpy(f.lfFaceName, "Segoe UI Black");
f.lfQuality = ANTIALIASED_QUALITY; //抗锯齿化效果
settextstyle(&f);
setbkmode(TRANSPARENT); //设置背景透明
setcolor(BLACK); //设置字体颜色
}
在 updateWindow 函数中渲染阳光值
void updateWindow()
{
//使用双缓冲, 解决输出窗口闪屏
BeginBatchDraw();
//渲染背景图至窗口
putimage(0, 0, &imgBg);
putimagePNG(250, 0, &imgBar);
//渲染植物卡牌
for (int i = 0;i < PLANT_CNT;++i)
putimage(PIC_LEFT_MARGIN + i * PIC_WIDTH, 6, &imgCards[i]);
//渲染种植植物
for (int i = 0; i < GRASS_GRID_ROW; ++i)
{
for (int j = 0; j < GRASS_GRID_COL; ++j)
{
if (plants[i][j].type >= 0)
{
putimagePNG(GRASS_LEFT_MARGIN + j * GRASS_GRID_WIDTH + 5, //微调植物种植位置
GRASS_TOP_MARGIN + i * GRASS_GRID_HIGHT + 10,
imgPlant[plants[i][j].type][plants[i][j].frameId]);
}
}
}
//渲染随机阳光
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
if (balls[i].used)
putimagePNG(balls[i].x, balls[i].y, &imgSunShineBall[balls[i].frameId]);
}
//渲染当前拖动的植物
if (currIndex >= 0)
{
IMAGE* currImage = imgPlant[currIndex][0];
putimagePNG(currX - currImage->getwidth() / 2,
currY - currImage->getheight() / 2, currImage);
}
//渲染阳光值
char scoreText[8];
sprintf(scoreText, "%d", sunShineVal);
outtextxy(277, 67, scoreText);
EndBatchDraw(); //结束双缓冲
}
效果展示
阳光球在下落过程中,或到达目标点后停留的 100 帧内。若鼠标移动至对应阳光球的位置,则该阳光被收集(会触发对应的音效和左上角阳光值增加 25)
vs 中 mciSendString 添加音效报错无法找到的外部符号
八 创建僵尸并实现行走
相关数据结构
#define MAX_ZOMBIE_NUM 10
#define MAX_ZOMBIE_PIC_NUM 22
typedef struct Zombie {
int x; //当前 X 轴坐标
int y; //当前 Y 轴坐标
int frameId; //当前图片帧编号
int speed;
bool used; //是否在使用
};
Zombie zombies[MAX_ZOMBIE_NUM];
IMAGE imgZombies[MAX_ZOMBIE_PIC_NUM];
在更新游戏数据的函数中,创建僵尸并且更新僵尸数据
void updateGame()
{
for (int i = 0; i < GRASS_GRID_ROW; ++i)
{
for (int j = 0; j < GRASS_GRID_COL; ++j)
{
if (plants[i][j].type >= 0)
{
if (imgPlant[plants[i][j].type][++plants[i][j].frameId] == NULL)
plants[i][j].frameId = 0;
}
}
}
createSunshine();
updateSunshine();
createZombie();
updateZombie();
}
核心函数
void createZombie()
{
//延缓函数调用次数并增加些随机性
static int zombieCallCnt = 0;
static int randZombieCallCnt = 500;
if (zombieCallCnt++ < randZombieCallCnt) return;
randZombieCallCnt = 300 + rand() % 200;
zombieCallCnt = 0;
for (int i = 0; i < MAX_ZOMBIE_NUM; ++i)
{
//找一个未在界面的僵尸初始化
if (!zombies[i].used)
{
zombies[i].x = WIN_WIDTH;
//出现在草地的任意一格上
zombies[i].y = GRASS_TOP_MARGIN +
(rand() % GRASS_GRID_ROW) * GRASS_GRID_HIGHT;
zombies[i].frameId = 0;
//僵尸的移动速度
zombies[i].speed = 1;
zombies[i].used = 1;
break;
}
}
}
void updateZombie()
{
//延缓函数调用次数
static int CallCnt = 0;
if (++CallCnt < 3) return;
CallCnt = 0;
for (int i = 0; i < MAX_ZOMBIE_NUM; ++i)
{
if (zombies[i].used)
{
//僵尸行走
zombies[i].x -= zombies[i].speed;
//僵尸更换图片帧
zombies[i].frameId = ++zombies[i].frameId % MAX_ZOMBIE_PIC_NUM;
//目前先这样写待优化
if (zombies[i].x < 170)
{
printf("GAME OVER !");
MessageBox(NULL, "over", "over", 0);
exit(0);
}
}
}
}
在 gameInit 中加载图片
//加载僵尸图片
for (int i = 0; i < MAX_ZOMBIE_PIC_NUM; ++i)
{
sprintf(name, "res/zm/0/%d.png", i + 1);
loadimage(&imgZombies[i], name);
}
在 updateWindow 中渲染僵尸
//渲染僵尸
for (int i = 0; i < MAX_ZOMBIE_NUM; ++i)
{
if (zombies[i].used)
{
IMAGE* img = &imgZombies[zombies[i].frameId];
//该位置 + img->getheight(), 因为 zombies[i].y 是草地格子的高度, +5 像素是微调
putimagePNG(zombies[i].x, zombies[i].y + img->getheight() + 5,
img);
}
}
效果展示
僵尸会随机从游戏窗口右边任意草格子产生,并行走至左边房屋处。当僵尸行走至左边房屋处时,游戏将结束,并弹出提示窗口 over ,点击后程序退出
九 实现阳光球飞跃
在阳光球结构体中增加成员
typedef struct SunShineBall
{
int x; //当前 X 轴坐标
int y; //当前 Y 轴坐标
int frameId; //当前图片帧编号
int destination; //飘落目标位置 Y 坐标
bool used; //是否在使用
int timer; //统计飘落目标位置后的帧次数
float xOffset; //阳光球飞跃过程中每次 X 轴偏移量
float yOffset; //阳光球飞跃过程中每次 Y 轴偏移量
}SunShineBall;
在创建阳光球时进行初始化(有进行 memset 其实不初始化也是 0,仅为了规范)
注意更改 createSunshine 的判断条件,if (!balls[i].used && balls[i].xOffset == 0)
在飞跃状态时不能对其进行初始化
void createSunshine()
{
static int sunCallCnt = 0;
static int randSunCallCnt = 400;
if (++sunCallCnt < randSunCallCnt) return;
randSunCallCnt = 200 + rand() % 200;
sunCallCnt = 0;
//从阳光池中取一个可用阳光
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
//找到一个未使用的阳光, 则进行初始化
if (!balls[i].used && balls[i].xOffset == 0)
{
//只允许阳光掉落在草地范围内(不允许左一格)
balls[i].x = GRASS_LEFT_MARGIN + GRASS_GRID_WIDTH +
(rand() % GRASS_GRID_COL) * GRASS_GRID_WIDTH;
balls[i].y = GRASS_TOP_MARGIN;
balls[i].frameId = 0;
//目标点在中间三行
balls[i].destination = GRASS_TOP_MARGIN +
GRASS_GRID_HIGHT + (rand() % (3 * GRASS_GRID_HIGHT));
balls[i].used = true;
balls[i].timer = 0;
//对阳光球飞跃过程中的 X, Y 进行初始化
balls[i].xOffset = 0;
balls[i].yOffset = 0;
break;
}
}
}
在收集阳光球时,计算阳光球飞跃过程中的 X, Y 偏移量
void collectSunShine(ExMessage* msg)
{
IMAGE* imgSunShine = NULL;
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
//阳光球在使用中
if (balls[i].used)
{
//找到对应的阳光球图片
imgSunShine = &imgSunShineBall[balls[i].frameId];
//判断鼠标移动的位置是否处于当前阳光球的位置
if (msg->x > balls[i].x && msg->x < balls[i].x + imgSunShine->getwidth()
&& msg->y > balls[i].y && msg->y < balls[i].y + imgSunShine->getheight())
{
mciSendString("play res/audio/sunshine.mp3", NULL, 0, NULL);
balls[i].used = false;
//计算阳光球飞跃过程中的 X, Y 偏移量
const float angle = atan((float)(balls[i].y - 0) / (float)(balls[i].x - 262));
balls[i].xOffset = 16 * cos(angle);
balls[i].yOffset = 16 * sin(angle);
}
}
}
}
主要内容,是在更新阳光球游戏数据时,else if (balls[i].xOffset)
需要不断调整 balls[i].x
和 balls[i].y
的值(不断调整阳光球的位置坐标)
void updateSunshine()
{
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
if (balls[i].used)
{
if (balls[i].y < balls[i].destination)
{
balls[i].y += 2; //每次移动两个像素
//修改当前图片帧编号, 并在到达 SUM_SHINE_PIC_NUM 时重置图片帧为 0
balls[i].frameId = ++balls[i].frameId % SUM_SHINE_PIC_NUM;
}
else //当阳光下落至目标位置时, 停止移动
{
if (balls[i].timer < 100) ++balls[i].timer;
else balls[i].used = false;
}
}
else if (balls[i].xOffset) //阳光球处于飞跃状态
{
if (balls[i].y > 0 && balls[i].x > 262)
{
//不断调整阳光球的位置坐标
const float angle = atan((float)(balls[i].y - 0) / (float)(balls[i].x - 262));
balls[i].xOffset = 16 * cos(angle);
balls[i].yOffset = 16 * sin(angle);
balls[i].x -= balls[i].xOffset;
balls[i].y -= balls[i].yOffset;
}
else
{
//阳光球飞至计分器位置, 则将 xOffset 置 0, 且加上 25 积分
balls[i].xOffset = 0;
balls[i].yOffset = 0;
sunShineVal += 25;
}
}
}
}
最后不要忘记飞跃阳光球的渲染条件,修改 updateWindow 函数
优化使用 mciSendString 收集阳光球卡顿
方法一好处在于节省资源,不会有线程的频繁创建和销毁;方法二好处是简便(原理同样是开异步线程)
方法一 : 单独开一个线程死循环
static bool isEnd = false;
/* sunShineMusic 加减也可换为使用 mutex */
long sunShineMusic = 0;
HANDLE sunShineThread = NULL;
DWORD WINAPI PlayMusic(LPVOID lpParam)
{
while (1)
{
if (sunShineMusic)
{
mciSendString("play res/audio/sunshine.mp3", NULL, 0, NULL);
InterlockedDecrement(&sunShineMusic);
/* 这里也可使用异步 notify 的方式 */
Sleep(100);
}
if (isEnd) break;
}
return 0;
}
在收集阳光时把 sunShineMusic InterlockedIncrement
void collectSunShine(ExMessage* msg)
{
IMAGE* imgSunShine = NULL;
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
//阳光球在使用中
if (balls[i].used)
{
//找到对应的阳光球图片
imgSunShine = &imgSunShineBall[balls[i].frameId];
//判断鼠标移动的位置是否处于当前阳光球的位置
if (msg->x > balls[i].x && msg->x < balls[i].x + imgSunShine->getwidth()
&& msg->y > balls[i].y && msg->y < balls[i].y + imgSunShine->getheight())
{
InterlockedIncrement(&sunShineMusic);
balls[i].used = false;
sunShineVal += 25;
}
}
}
}
程序退出时,把线程资源清除
int main()
{
gameInit(); //不能把 startUI 放在 gameInit 前, gameInit 包含了创建游戏图形窗口
startUI();
updateWindow(); //窗口视图展示
sunShineThread = CreateThread(NULL, 0,
(LPTHREAD_START_ROUTINE)PlayMusic, (LPVOID)NULL, 0, NULL);
int timer = 0; //用以计时 20 毫秒更新一次
while (1)
{
userClick(); //监听窗口鼠标事件
timer += getDelay();
if (timer > 20)
{
updateWindow(); //更新窗口视图
updateGame(); //更新游戏动画帧
timer = 0;
}
}
isEnd = true;
//等待线程退出
WaitForSingleObject(sunShineThread, INFINITE);
if (sunShineThread)
//关闭线程
CloseHandle(sunShineThread);
system("pause");
return 0;
}
方法二 : 使用 PlaySound API
注意 SND_ASYNC 参数,可参考 API 之 playsound,同样是以异步线程方式去播放音频
void collectSunShine(ExMessage* msg)
{
IMAGE* imgSunShine = NULL;
for (int i = 0; i < MAX_BALLS_NUM; ++i)
{
//阳光球在使用中
if (balls[i].used)
{
//找到对应的阳光球图片
imgSunShine = &imgSunShineBall[balls[i].frameId];
//判断鼠标移动的位置是否处于当前阳光球的位置
if (msg->x > balls[i].x && msg->x < balls[i].x + imgSunShine->getwidth()
&& msg->y > balls[i].y && msg->y < balls[i].y + imgSunShine->getheight())
{
PlaySound("res/audio/sunshine.wav", NULL, SND_FILENAME | SND_ASYNC);
balls[i].used = false;
sunShineVal += 25;
}
}
}
}
解决 VS 告警太多,屏蔽该告警
例告警序号为 8888
一 直接在代码中添加 #pragma warning(disable:8888)
二 进入项目属性,通过 C/C++ -> Advanced -> Disable Specific Warnings 设置,输入8888 来屏蔽
效果展示
在阳光球下落或落至草地目标点未消失之前,将鼠标移至阳光球上时,阳光球将会飞跃至左上角的计分板,然后阳光值积分会增加 25 (GIF 动图如下)
超过 CSDN 图片大小限制了 。。。感兴趣可以访问 如下链接
https://lucky-1331733286.cos.ap-guangzhou.myqcloud.com/images/202412281852663.gif
遇到的小问题
反三角函数 atan 的函数是浮点数类型(之前没用过),千万不要写成这样 const float angle = atan()(balls[i].y - 0) / (balls[i].x - 262));
,将会导致阳光球在飞跃过程先在 X 轴平移一段再飞跃
在 debug 时,直接把对应的 xOffset,yOffset,x 和 y 打印了出来,如下
才发现了问题的原因,解决方法就是如上代码,直接强转即可 const float angle = atan((float)(balls[i].y - 0) / (float)(balls[i].x - 262));
原因很好理解,跟数值在计算机中如何存储有关,感兴趣的可以去翻 C 进阶 — 数据在内存中的存储
原文地址:https://blog.csdn.net/qq_44868502/article/details/144793405
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!