本章节,我们将要创建一个简单的创建,其中包括地形,天空盒以及跟随摄像机三种对象类。
首先我们使用3ds max创建一个地形:
第一步,在3ds max中创建一个平面,长度和宽度都是10000,长宽分段都是100。这样,我们就会创建一个拥有10000个正四边形组成的大平面(大正四边形)了。注意,每个正四边形的边长也是100。这跟我们在DirectX中使用灵活顶点构造一个超大四边形,其实是一个原理。很显然,使用3ds max制作要比DirectX简单容易多了。虽然我们在3ds max中看到整个大的平面是由10000个正四边形构成的,但是在DirectX中绘制这个平面的时候,每个四边形其实还是由两个三角形组成的。游戏引擎中最基本的图元还是三角形。需要注意的是,我们要将平面的中心点移动到世界坐标系的原点,具体操作就是选中平面,按W键(移动工具)后,将下面的x/y/z值改成0即可。
为了方便查看平面的样子,我们可以滚动鼠标中间的滚轮,来放大/缩小视图,这样可以更大范围的看到整个平面。同时也可以按下鼠标中间的滚轮拖动,来移动视图,使用更好的角度来观察整个平面。这是3ds max最基本的视图操作。
第二步,将我们提前准备好的一张草地的纹理贴图,直接拖拽到平面上。这个简便的操作实际就是给平面施加一个纹理材质,效果如下:
第三步,给草地做出高度,将平面转换成“可编辑多边形”,然后选择“面”的操作级别,这样,我们就能选中平面中的任意一个四边形了,按住Ctrl键可以帮我们加选四边形。
选中几个四边形,使用移动工具(按W键),将其沿Z轴向上拖动,使其选中的四边形向上移动。这里,我们选中的是世界坐标系原点附近的四边形。使用Ctrl键可以加选多个四边形面。
当然,我们也可以直接修改右下角的Z值,来明确向上移动的距离数值,这里我们设置为100。
第四步,使用”软选择”工具,给草地做出平滑的高度。
我们将平面的编辑模式改成”顶点”,而不是之前的”面”模式,这样,我们选中的则是平面的顶点,而不是之前的四边形。
在“顶点”选择模式的下方,由一个“软选择”工具,勾选“使用软选择”,然后设置“衰减”值,这个数值越大,影响的顶点范围就越广。此时,你点击平面上的一点,就会发下效果如下:
此时,我们同样使用移动工具(W键)来移动我们选中的顶点,就会发现附近的顶点会跟着一起移动,这样就会形成一个平滑的坡度。
其实,我们可以从顶点颜色能够推算出来,红色区域的顶点,移动距离大,橙色顶点小一些,绿色会再小一些,蓝色基本上影响不大了。在3ds max中使用颜色来区分影响程度的方式,在蒙皮的时候也会用到。当然,我们可以继续选择其他顶点,继续拖拽形成高低起伏的效果。
第五步,将我们的模型导出为X格式即可。
默认情况下,3ds max不支持导出X格式模型,我们需要借助Axe_free 插件来完成。这个插件是免费的,根据你的3ds max版本下载对应的Axe_free版本即可。解压Axe_free之后,直接放到 3ds max安装目录下的plugins下即可,非常简单。
接下来我们使用VS2019来创建新项目“D3D_10_Terrain”,首先创建公共头文件“main.h”文件,该文件中包含了我们以往用到的所有头文件,以及释放对象的宏,代码如下:
#pragma once
#include <windows.h>
#include <d3d9.h>
#include <d3dx9.h>
#include <dinput.h>
#include <time.h>
#include <math.h>
#include <iostream>
#include <fstream>
#include <string>
// 引入依赖的库文件
#pragma comment(lib,"d3d9.lib")
#pragma comment(lib,"d3dx9.lib")
#pragma comment(lib,"dinput8.lib")
#pragma comment(lib,"dxguid.lib")
#pragma comment(lib,"winmm.lib")
#define WINDOW_LEFT 200 // 窗口位置
#define WINDOW_TOP 100 // 窗口位置
#define WINDOW_WIDTH 800 // 窗口宽度
#define WINDOW_HEIGHT 600 // 窗口高度
#define WINDOW_TITLE L"D3D游戏开发" // 窗口标题
#define CLASS_NAME L"D3D游戏开发" // 窗口类名
// 自定义一个SAFE_RELEASE()宏, 释放指针对象
#ifndef SAFE_DELETE
#define SAFE_DELETE(p){ if (p) { delete (p); (p)=NULL; } }
#endif
// 自定义一个SAFE_RELEASE()宏, 释放COM对象
#ifndef SAFE_DELETE_ARRAY
#define SAFE_DELETE_ARRAY(p){ if (p) { delete[] (p); (p)=NULL; } }
#endif
// 自定义一个SAFE_DELETE_ARRAY()宏, 释放数组对象
#ifndef SAFE_RELEASE
#define SAFE_RELEASE(p){ if (p) { (p)->Release(); (p)=NULL; } }
#endif
// 记录日志
void log(std::string txt);
// wchar_t* 转 char*
char* convert(const wchar_t* wstr);
接下来,我们将对Input的输入做一个封装,这个类之前我们封装过,可以直接拿过来使用,也就是“InputClass.h”和“InputClass.cpp”两个文件。接下来,我们要创建一个可以自由移动的透视摄像机类。我们之前做的摄像机只能沿着世界坐标系X/Y/Z轴移动,并不能自由移动。这样的摄像机对于2D游戏已经够用了,但是3D游戏还是不行的。在3D游戏中,我们可以随意的移动和旋转摄像机来查看场景中的任何物体。这个自由移动的摄像机的实现,主要是一个局部坐标系的概念。我们为摄像机声明三个方向向量,向上的方向,向右的方向,以及向观察点的方向。摄像机移动或旋转的时候,就可以按照这三个方向来调整。由于这个三个方向向量是局部坐标系的概念,因此它是跟随摄像机的变动而变动的。也就是说,我们向前移动的时候,实际就是向着观察点的方向移动。当我们旋转之后,观察点也随之变动,那么我们继续向前移动的话,还是向着观察点的方向移动。也就是说,摄像机向前移动,永远都是相对于自己的前面而言的。我们创建“CameraClass.h”和“CameraClass.cpp”两个文件,首先是“CameraClass.h”文件内容:
#pragma once
#include "main.h"
// 自由移动摄像机
class CameraClass {
public:
D3DXVECTOR3 _right; // 右方向
D3DXVECTOR3 _up; // 上方向
D3DXVECTOR3 _look; // 观察方向
D3DXVECTOR3 _pos; // 位置
public:
// 构造方法
CameraClass();
// 有参构造函数
CameraClass(float x, float y, float z);
// 析构方法
~CameraClass();
// 前后移动
void frontBackMove(float step);
// 左右移动
void leftRightMove(float step);
// 上下移动
void upDownMove(float step);
// 围绕 _up 方向旋转,也就是左右旋转(摇头)
void leftRightRotate(float angle);
// 围绕 _right 方向旋转,也就是上下旋转(点头)
void upDownRotate(float angle);
// 围绕 _look 方向旋转,相当于侧头
void sideRotate(float angle);
// 计算取景变换矩阵
void getViewMatrix(D3DXMATRIX* V);
};
我们声明的三个方向向量和摄像机为位置信息,其中分别针对这三个方向定义了移动和旋转的函数。最后一个就是根据方向和位置来计算一个取景变换矩阵。“CameraClass.cpp”内容:
#include "CameraClass.h"
// 自由移动摄像机: 构造方法
CameraClass::CameraClass() {
_pos = D3DXVECTOR3(0.0f, 0.0f, 0.0f);
_right = D3DXVECTOR3(1.0f, 0.0f, 0.0f);
_up = D3DXVECTOR3(0.0f, 1.0f, 0.0f);
_look = D3DXVECTOR3(0.0f, 0.0f, 1.0f);
}
// 自由移动摄像机: 有参构造方法
CameraClass::CameraClass(float x, float y, float z) {
_pos = D3DXVECTOR3(x, y, z);
_right = D3DXVECTOR3(1.0f, 0.0f, 0.0f);
_up = D3DXVECTOR3(0.0f, 1.0f, 0.0f);
_look = D3DXVECTOR3(0.0f, 0.0f, 1.0f);
}
// 自由移动摄像机: 析构方法
CameraClass::~CameraClass() {}
// 自由移动摄像机: 前后移动
void CameraClass::frontBackMove(float step) {
_pos += D3DXVECTOR3(_look.x, 0.0f, _look.z) * step;
}
// 自由移动摄像机: 左右移动
void CameraClass::leftRightMove(float step) {
_pos += D3DXVECTOR3(_right.x, 0.0f, _right.z) * step;
}
// 自由移动摄像机: 上下移动
void CameraClass::upDownMove(float step) {
_pos.y += step;
}
// 自由移动摄像机: 围绕 _up 方向旋转,也就是左右旋转(摇头)
void CameraClass::leftRightRotate(float angle) {
D3DXMATRIX M;
D3DXMatrixRotationY(&M, angle);
D3DXVec3TransformCoord(&_right, &_right, &M);
D3DXVec3TransformCoord(&_look, &_look, &M);
}
// 自由移动摄像机: 围绕 _right 方向旋转,也就是上下旋转(点头)
void CameraClass::upDownRotate(float angle) {
D3DXMATRIX M;
D3DXMatrixRotationAxis(&M, &_right, angle);
D3DXVec3TransformCoord(&_up, &_up, &M);
D3DXVec3TransformCoord(&_look, &_look, &M);
}
// 自由移动摄像机: 围绕 _look 方向旋转,相当于侧头
void CameraClass::sideRotate(float angle) {
D3DXMATRIX M;
D3DXMatrixRotationAxis(&M, &_look, angle);
D3DXVec3TransformCoord(&_right, &_right, &M);
D3DXVec3TransformCoord(&_up, &_up, &M);
}
// 自由移动摄像机: 计算取景变换矩阵
void CameraClass::getViewMatrix(D3DXMATRIX* V) {
D3DXVec3Normalize(&_look, &_look);
D3DXVec3Cross(&_up, &_look, &_right);
D3DXVec3Normalize(&_up, &_up);
D3DXVec3Cross(&_right, &_up, &_look);
D3DXVec3Normalize(&_right, &_right);
float x = -D3DXVec3Dot(&_right, &_pos);
float y = -D3DXVec3Dot(&_up, &_pos);
float z = -D3DXVec3Dot(&_look, &_pos);
(*V)(0, 0) = _right.x; (*V)(0, 1) = _up.x; (*V)(0, 2) = _look.x; (*V)(0, 3) = 0.0f;
(*V)(1, 0) = _right.y; (*V)(1, 1) = _up.y; (*V)(1, 2) = _look.y; (*V)(1, 3) = 0.0f;
(*V)(2, 0) = _right.z; (*V)(2, 1) = _up.z; (*V)(2, 2) = _look.z; (*V)(2, 3) = 0.0f;
(*V)(3, 0) = x; (*V)(3, 1) = y; (*V)(3, 2) = z; (*V)(3, 3) = 1.0f;
}
上面的函数中,D3DXMatrixRotationAxis是生成一个围绕指定方向旋转指定角度的旋转矩阵。D3DXVec3TransformCoord就是将一个向量做旋转操作。最后就是计算取景变换矩阵,这个过程不需要大家掌握。接下来就是我们的地形类,首先地形是一个网格对象,因此我们需要先对网格对象进行封装,创建“MeshClass.h”和“MeshClass.cpp”两个文件,其实我们之前就已经对网格进行了封装,可以拿过来直接使用。有了基本模型的封装,那么我们的地形类就可以继承MeshClass,并在此基础上做一个调整了。我们上文中创建了一个地形模型,也就是一个大的四边形纹理,直接使用MeshClass加载进来不就得嘛。为什么还要创建一个地形类呢,答案就是地形高度的计算,也就是根据X/Z值,计算Y值。我们的地形四边形中,每个顶点记录了X/Y/Z的数值。但是,小四边形的边长是100,那么小四边形内部的X/Y/Z值怎么计算呢?X和Z是根据角色的移动计算出来的,那么我们就需要根据X/Z的值来计算Y值。计算Y值得的原理就是三角形的线性高度变化。
在上面的图例中,点P位于AB中间位置,如果AA´的距离是10,也就是说A的高度是10。那么点P的高度就应该是5,也就是P´的高度。这就是线性变化的原理。因为P是AB的一半,因此P点的高度就应该是A高度的一半。明白原理后,我们创建“TerrainClass.h”和“TerrainClass.cpp”两个文件,其中“TerrainClass.h”文件如下:
#pragma once
#include "MeshClass.h"
// 地形类,主要是地形高度的计算
class TerrainClass : public MeshClass {
public:
// 模型顶点数组,里面包含x,y,z值
D3DXVECTOR3* meshVertexes;
// 模型中四边形的长度
float interval = 0.0f;
public:
// 构造方法
TerrainClass(LPDIRECT3DDEVICE9 _device, const wchar_t* _dir, const wchar_t* _file, float _interval);
// 析构方法
~TerrainClass();
// 获取草地模型顶点数组
void getMeshVertexes();
// 获取草地模型顶点Y值
float getMeshVertexY(float x, float z);
// 获取地形高度值(Y值)
float getTerrainHeight(float x, float z);
};
我们提供了三个函数,getMeshVertexes函数用于获取模型中所有顶点的坐标信息。getMeshVertexY函数用于根据给定的X/Z值来获取所在小四边形的四个顶点的Y值。也就是说,我们需要确定给定的X/Z值的点,来确定该点在那个小四边形中,并获取这个小四边形的四个顶点坐标,还有进一步确认该点在这个小四边形中那个三角形中。我们知道这个小四边形是由两个三角形组成的。这个信息确认后,我们就可以借助上文的图例算法来计算了。接下来就是“TerrainClass.cpp”文件的内容:
#include "TerrainClass.h"
// 地形类: 构造方法
TerrainClass::TerrainClass(LPDIRECT3DDEVICE9 _device, const wchar_t* _dir, const wchar_t* _file, float _interval) : MeshClass(_device, _dir, _file), interval(_interval) {
// 获取草地模型顶点数组
init();
getMeshVertexes();
};
// 地形类: 析构方法
TerrainClass::~TerrainClass() {
delete[] meshVertexes;
};
// 地形类: 获取草地模型顶点数组
void TerrainClass::getMeshVertexes() {
// 顶点数量
const int size = meshObj->GetNumVertices();
meshVertexes = new D3DXVECTOR3[size];
// 先克隆一份顶点数据
LPD3DXMESH cloneMesh = 0;
meshObj->CloneMeshFVF(meshObj->GetOptions(), D3DFVF_XYZ, D3DDevice, &cloneMesh);
// 获取顶点缓冲数据
LPDIRECT3DVERTEXBUFFER9 cloneMeshVertexBuffer;
cloneMesh->GetVertexBuffer(&cloneMeshVertexBuffer);
// 读取顶点缓存
D3DXVECTOR3* pVertices;
cloneMeshVertexBuffer->Lock(0, sizeof(meshVertexes), (void**)&pVertices, 0);
for (DWORD i = 0; i < size; i++)
{
meshVertexes[i].x = round(pVertices[i].x);
meshVertexes[i].y = round(pVertices[i].y);
meshVertexes[i].z = round(pVertices[i].z);
}
cloneMeshVertexBuffer->Unlock();
// 是否克隆网格对象
cloneMesh->Release();
cloneMesh = NULL;
};
// 地形类: 获取草地模型顶点Y值
float TerrainClass::getMeshVertexY(float x, float z) {
int size = meshObj->GetNumVertices();
for (int i = 0; i < size; i++) {
D3DXVECTOR3 temp = meshVertexes[i];
if (temp.x == x && temp.z == z) {
return temp.y;
}
}
return 0;
};
// 地形类: 获取地形高度值(Y值)
float TerrainClass::getTerrainHeight(float x, float z) {
// 根据坐标获取所在四边形的四个顶点
float vx_min = 0, vx_max = 0, vz_min = 0, vz_max;
if (x >= 0) {
vx_min = (int)(x / 100) * 100;
vx_max = vx_min + interval;
}
else {
vx_max = (int)(x / 100) * 100;
vx_min = vx_max - interval;
}
if (z >= 0) {
vz_min = (int)(z / 100) * 100;
vz_max = vz_min + interval;
}
else {
vz_max = (int)(z / 100) * 100;
vz_min = vz_max - interval;
}
// 四边形四条边平行于X/Z轴
// 两个顶点的间距为 100
// 下图可以理解为俯视角度
//
// V1 V2
// *---*
// | / |
// *---*
// V0 V3
//
// 获取四个顶点的Y值
float V0 = getMeshVertexY(vx_min, vz_min);
float V1 = getMeshVertexY(vx_min, vz_max);
float V2 = getMeshVertexY(vx_max, vz_max);
float V3 = getMeshVertexY(vx_max, vz_min);
// 如果四个顶点的高度是一样的,就直接返回任意一个高度即可
//if (V0 == V1 && V1 == V2 && V2 == V3) {
//return V0;
//}
// 判断(x,z)坐标在四边形中的位置
// 四边形是一个边长为100的正方形
// 如果 x == z 则该点在对角线V0V2上
// 如果 x < z 则该点在三角形V0V1V2中
// 如果 x > z 则该点在三角形V0V2V3中
// 地形高度值(Y值)
float height = 0.0f;
// 在三角形V0V1V2中
if (x <= z)
{
// V2和V1之间的高度差(可正可负)
float uy = V2 - V1;
// 点(x,z)与V1的水平差(x轴方向)
float ux = x - vx_min;
// 点(x,z)在V1V2的比率
float ux_rate = ux / interval;
// V0和V1之间的高度差(可正可负)
float vy = V0 - V1;
// 点(x,z)与V1的水平差(z轴方向)
float vz = vz_max - z;
// 点(x,z)在V1V0的比率
float vz_rate = vz / interval;
// 点(x,z)的高度为:V1高度 + x轴高度差 + z轴高度差
// x/z轴的高度差计算公式为:V1点到V2点在x轴上的高度是线性变化的,如果V1到V2高度下降2米,则一半位置的时候,下降1米
// 我们根据x轴的距离比率来计算高度差的
height = V1 + uy * ux_rate + vy * vz_rate;
}
else
{
float uy = V0 - V3;
float ux = vx_max - x;
float ux_rate = ux / interval;
float vy = V2 - V3;
float vz = z - vz_min;
float vz_rate = vz / interval;
height = V3 + uy * ux_rate + vy * vz_rate;
}
// 返回地形高度
return height;
};
这个实现类可能有些复杂,需要认真的去思考,里面涉及的一些细节比较多。比如如何确定指定点在那个三角形中,以及正负数的判断等等。所有类都封装完毕之后,我们就开始“main.cpp”文件的内容,首先是全局变量的声明:
// 引入头文件
#include "main.h"
#include "MeshClass.h"
#include "InputClass.h"
#include "CameraClass.h"
#include "TerrainClass.h"
// Direct3D设备指针对象
LPDIRECT3DDEVICE9 D3DDevice = NULL;
// 输入类
InputClass* input;
// 摄像机
CameraClass* camera;
// 草地模型
TerrainClass* land;
// 胶囊体模型
float x, y, z;
MeshClass* capsule;
// 摄像机移动和胶囊体移动速度
float distance = 5.0f;
在这里,我们使用一个胶囊体模型在草地上面进行移动,来测试我们的地形高度计算是否正确。摄像机可以根据“WASD”来进行左右前后移动,“QE”来进行左右旋转,“CV”来进行上下移动。而胶囊体模型的移动就是“IJKL”在世界坐标系的X轴和Z轴方向进行移动。注意,两者的移动是不一样的,摄像机是一个自由移动的,而胶囊体的移动仅能够在世界坐标系的X/Z轴方向移动。后期我们也会给这个胶囊体设置为一个自由移动的物体,并且让摄像机跟随起移动而移动。接下来就是initScene函数,代码如下:
// 初始化输入
input = new InputClass(D3DDevice, hwnd, hInstance);
// 初始化摄像机,参数是摄像机位置
camera = new CameraClass(500, 300, -300);
// 草地模型边长为10000,分段为100,因此每两个顶点间隔就是100
float interval = 100.0f;
// 初始化草地模型
land = new TerrainClass(D3DDevice, L"asset/", L"ground.X", interval);
// 创建一个胶囊体
x = 500.0f, y = 0.0f, z = 100.0f;
capsule = new MeshClass(D3DDevice, L"asset/", L"capsule.X");
capsule->init();
// 线性纹理
D3DDevice->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
D3DDevice->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);
// 初始化投影变换
initProjection();
// 初始化光照
initLight();
由于摄像机解决了取景变换矩阵,所以我们的initProjection函数改动如下:
// 设置取景变换矩阵
D3DXMATRIX V;
camera->getViewMatrix(&V);
D3DDevice->SetTransform(D3DTS_VIEW, &V);
// 设置透视投影变换矩阵
D3DXMATRIX projMatrix;
float angle = D3DX_PI * 0.5f;
float wh = (float)WINDOW_WIDTH / (float)WINDOW_HEIGHT;
D3DXMatrixPerspectiveFovLH(&projMatrix, angle, wh, 1.0f, 1000.0f);
D3DDevice->SetTransform(D3DTS_PROJECTION, &projMatrix);
// 设置视口变换
D3DVIEWPORT9 viewport = { 0, 0, WINDOW_WIDTH, WINDOW_HEIGHT, 0, 1 };
D3DDevice->SetViewport(&viewport);
光照还是按照我们之前的旧代码,设置全局环境光以及默认材质。接下来我们renderScene函数,代码如下:
// 绘制草地
D3DXMATRIX worldMatrix;
D3DXMatrixTranslation(&worldMatrix, 0.0f, 0.0f, 0.0f);
D3DDevice->SetTransform(D3DTS_WORLD, &worldMatrix);
land->render();
// 绘制胶囊体
D3DXMATRIX worldMatrix2;
D3DXMatrixTranslation(&worldMatrix2, x, y, z);
D3DDevice->SetTransform(D3DTS_WORLD, &worldMatrix2);
capsule->render();
// 调整取景变换矩阵
D3DXMATRIX V;
camera->getViewMatrix(&V);
D3DDevice->SetTransform(D3DTS_VIEW, &V);
由于我们需要操作摄像机的移动,因此在renderScene函数添加取景变换矩阵的调整,这样移动和旋转摄像机,场景画面就会变化了。我们先运行代码看看效果:
我们可以看到在草地上面放置了一个木板材质的胶囊体。接下来,我们就来完成update函数,让摄像机和胶囊体能够移动,代码如下:
// 获取键盘信息
input->update();
char key = input->key[0];
// 按键判断
switch (key) {
case 'A':
// 摄像机向左移动
camera->leftRightMove(-distance);
break;
case 'D':
// 摄像机 向右移动
camera->leftRightMove(distance);
break;
case 'W':
// 摄像机 向前移动
camera->frontBackMove(distance);
break;
case 'S':
// 摄像机 向后移动
camera->frontBackMove(-distance);
break;
case 'Q':
// 摄像机 向左旋转
camera->leftRightRotate(-0.02);
break;
case 'E':
// 摄像机 向右旋转
camera->leftRightRotate(0.02);
break;
case 'C':
// 摄像机 向上移动
camera->upDownMove(distance);
break;
case 'V':
// 摄像机 向下移动
camera->upDownMove(-distance);
break;
case 'I':
// 模型向 z 轴正方向移动
z += distance;
// 根据地形高度设置模型y轴位置
y = land->getTerrainHeight(x, z);
break;
case 'K':
// 模型向 z 轴负方向移动
z -= distance;
// 根据地形高度设置模型y轴位置
y = land->getTerrainHeight(x, z);
break;
case 'J':
// 模型向 x 轴负方向移动
x -= distance;
// 根据地形高度设置模型y轴位置
y = land->getTerrainHeight(x, z);
break;
case 'L':
// 模型向 x 轴正方向移动
x += distance;
// 根据地形高度设置模型y轴位置
y = land->getTerrainHeight(x, z);
break;
}
对于胶囊体的移动,我们只需要修改器x和z的数值,然后由地形类来就算y值,这样就完成了胶囊体的移动。而摄像机的自由移动则是调用起内部方法,但是,最终的取景变换还是在renderScene函数中完成的。运行效果如下:
通过移动胶囊体,我们发现,它的Y值计算还是正确的,但是,我们分别控制胶囊体和摄像机确实比较麻烦。
本课程的所有代码案例下载地址:
备注:这是我们游戏开发系列教程的第二个课程,这个课程主要使用C++语言和DirectX来讲解游戏开发中的一些基础理论知识。学习目标主要依理解理论知识为主,附带的C++代码能够看懂且运行成功即可,不要求我们使用DirectX来开发游戏。课程中如果有一些错误的地方,请大家留言指正,感激不尽!