游戏编程

游戏编程指南
A Guide to Game Programming

v1.10alpha
最后更新于2003.1.14

本文基于VC7.0 / DirectX 9.0 / Winsock 2.2
推荐使用Word 2000及以上版本阅读


大家看完之后如果有什么意见和建议请务必在留言簿提出,谢谢!!!
如果你认为任何地方写错了,请告诉我…
如果你认为任何地方难以理解,请告诉我…
如果你觉得这篇东西还不算太垃圾,欢迎推荐给你的朋友?…


本文99%为原创内容,转载时请只给出连接,谢谢!
也希望大家不要随便修改,谢谢!


使用"查看"----"文档结构图"可大大方便阅读本文档



彭博 著
By Peng Bo
Email: [email protected]
QQ: 4982526
http://www.kanepeng.com
目 录
游戏编程指南 1
目 录 1
导 读 1
第一章 表述游戏的语言 1
1.1 VC.net概述 1
1.2 入门知识 4
1.2.1 数与数据类型 4
1.2.2 变量与常量 4
1.2.3 Namespace 5
1.2.4 操作符与表达式 6
1.3 预编译指令 7
1.4 结构,联合和枚举 8
1.4.1 结构 8
1.4.2 联合 9
1.4.3 枚举 10
1.5 控制语句 10
1.5.1 判断和跳转语句 10
1.5.2 选择语句 11
1.5.3 循环语句 13
1.6 函数 13
1.7 指针、数组与字符串 17
1.7.1 指针 17
1.7.2 数组 19
1.7.3 字符串 22
1.7.4 小结 23
1.8 多文件程序的结构 23
1.9 常用函数 25
第二章 如何说得更地道 29
2.1 定义和使用类 29
2.2 类的构造函数 32
2.3 类的静态成员 34
2.4 运算符重载 35
2.5 类的继承 38
2.6 虚函数和抽象类 41
2.7 模板 42
2.8 优化程序 45
2.9 调试程序 47
第三章 容纳游戏的空间 49
3.1 基本Windows程序 49
3.2 WinMain函数 53
3.2.1 简介 53
3.2.2 注册窗口类 53
3.2.3 创建窗口 55
3.2.4 显示和更新窗口 56
3.2.5 消息循环 57
3.3 消息处理函数 58
3.4 常用Windows函数 59
3.4.1 显示对话框 59
3.4.2 定时器 59
3.4.3 得到时间 60
3.4.4 播放声音 60
第四章 描绘游戏的画笔 61
4.1 初始化DirectDraw 61
4.1.1 简介 61
4.1.2 DirectDraw对象 62
4.1.3 设置控制级和显示模式 63
4.1.4 创建页面 64
4.2 后台缓存和换页 66
4.3 调入图像 67
4.4 页面的丢失与恢复 67
4.5 透明色 68
4.6 图像传送 68
4.7 程序实例 72
4.8 图像缩放 72
4.9 释放DirectDraw对象 72
第五章 丰富画面的技巧 74
5.1 填涂颜色 74
5.2 输出文字 75
5.3 GDI作图 75
5.4 程序实例 76
5.5 锁定页面 76
5.6 程序提速 78
5.7 特殊效果 82
5.7.1 减暗和加亮 82
5.7.2 淡入淡出 83
5.7.3 半透明 83
5.7.4 光照 84
5.7.5 动态光照 85
5.7.6 光照系统 88
5.7.7 天气效果 88
第六章 加速游戏的魔法 89
6.1 内嵌汇编简介 89
6.2 基本指令 90
6.3 算术指令 91
6.4 逻辑与移位指令 93
6.5 比较、测试、转移与循环指令 93
6.6 MMX指令集之基本指令 96
6.7 MMX指令集之算术与比较指令 98
6.8 MMX指令集之逻辑与移位指令 99
6.9 MMX指令集之格式调整指令 100
第七章 我没有想好名字 102
7.1 读取键盘数据 102
7.2 读取鼠标数据 103
7.3 恢复和关闭DirectInput 104
7.3.1 恢复DirectInput设备 104
7.3.2 关闭DirectInput 104
7.4 初始化和关闭DirectX Audio 104
7.4.1 初始化DirectX Audio 104
7.4.2 关闭DirectX Audio 105
7.5 播放MIDI和WAV音乐 105
7.5.1 调入MIDI和WAV文件 105
7.5.2 播放MIDI和WAV文件 106
7.5.3 停止播放 107
7.6 在3D空间中播放音乐 107
7.7 播放MP3音乐 109
7.7.1 调入MP3文件 109
7.7.2 播放MP3文件 109
7.7.3 停止播放和释放对象 110
第八章 支撑游戏的基石 111
8.1 链表 111
8.2 哈希表 111
8.3 快速排序 112
8.4 深度优先搜索 113
8.5 广度优先搜索 117
8.6 启发式搜索 120
8.7 动态规划 126
8.8 神经网络 128
8.9 遗传规划 129
第九章 向三维世界迈进 131
9.1 概述 131
9.2 基本知识 133
9.2.1 初始化DXGraphics 133
9.2.2 关闭DXGraphics 135
9.2.3 恢复DXGraphics设备 135
9.3 设置场景 135
9.3.1 设置渲染状态 135
9.3.2 设置矩阵 136
9.4 创建场景 137
9.4.1 调入3D场景 138
9.4.2 调入2D图像 139
9.5 刷新场景 140
9.6 渲染场景 141
9.6.1 渲染3D场景 141
9.6.2 渲染2D图像 141
9.7 改变场景 141
9.8 显示文字 142
9.9 程序实例 143
第十章 我没有想好名字 144
10.1 灯光 144
10.2 半透明 145
10.3 纹理混合 146
10.4 雾 148
10.5 凹凸贴图与环境贴图 149
10.6 粒子系统 149
10.7 骨骼动画 149
10.8 镜子 151
10.9 影子 151
第十一章 我没有想好名字 152
11.1 基本概念 152
11.2 程序流程 152
11.2.1 服务器端 152
11.2.2 客户端 153
11.3 程序实例 153
11.4 错误处理 158
11.5 显示IP地址 158
11.6 更有效地传送数据 159
第十二章 创造我们的世界 161
12.1 程序流程 161
12.2 程序结构 162
12.3 基本方法 163
12.4 SLG编程要点 163
12.4.1 电脑AI 163
12.5 RPG & ARPG编程要点 163
12.5.1 迷宫的生成 163
12.5.2 脚本技术 163
12.6 RTS编程要点 163
12.6.1 寻路 163
12.6.2 电脑AI 163
12.7 FPS编程要点 164
12.7.1 移动 164
12.7.2 碰撞检测 164
12.8 游戏中的物理学 165
附 录 166
附录一 Windows常见消息列表 166
附录二 虚拟键列表 171
Windows消息中的虚拟键 171
DirectInput中的虚拟键 172
附录三 DirectX函数返回值列表 174
DirectDraw部分 174
Direct3D部分 181
附录四 Winsock函数返回值列表 183
附录五 游戏编程常用网址 187
附录六 中英文名词对照 188
附录七 常见问题及解决办法 189
1. 程序编译时出现"Warning" 189
2. "Cannot Execute Program" 189
3. "Unresolved External Symbol" 189
4. 运行时出错 189
5. 大家还有什么问题,可以告诉我 189
导 读

在开始阅读全文之前,希望你能抽出一些时间阅读这里的内容…

一、你想编一个怎样的游戏?
(1)星际争霸,帝国时代,英雄无敌,大富翁4,轩辕剑3,传奇,石器时代…
这些都是正宗的2D游戏,其标志是:视角完全固定或只有四个观察方向。这些游戏中特效不多,即使有也不需要使用汇编进行加速。
推荐阅读:第1、2、3、4、5章及第12章的相关部分。
可选阅读:第7、8章。如果需要网络功能,需阅读第11章。

(2)暗黑2,秦殇…
这是一类比较特殊的2D游戏,其特点在于各种特效(半透明,光影效果等)的大规模使用。有的此类游戏还可以使用3D加速卡来加速2D特效。
推荐阅读:第1、2、3、4、5、6章及第12章的相关部分。
可选阅读:第7、8、9、10章。如果需要网络功能,需阅读第11章。
由于现在的显卡几乎都能很好地支持3D加速功能,所以如果你打算放弃对没有3D加速卡的计算机的支持,可不阅读第4、5、6章,而推荐阅读第9章和第10章的第1、2节。

(3)反恐精英,雷神,魔兽争霸3,地牢围攻,FIFA,极品飞车,MU…
这些都是纯3D游戏,也代表了目前游戏的发展趋势。
推荐阅读:第1、2、3、7、9、10章及第12章的相关部分。
可选阅读:第8章。如果需要网络功能,需阅读第11章。
第一章 表述游戏的语言

想必大家都听说过“计算机语言”吧,我们就要靠它来向计算机表述我们的游戏到底是怎样的----这个过程就是所谓“编程”。由于游戏对速度的要求较高,过去我们一般使用C语言,因为用它编制的程序不仅执行速度快,还可以充分地使用硬件的各种资源。而现在(不过也是十多年前的事了?)有了C++语言,它是对C语言的重大改进。C++语言的最大特点是提供了“类” ,成为了“面向对象”的语言。关于此,我们会在第二章详细介绍。本章将先介绍一些游戏编程所必需的C++语言基础知识。最后需要提醒大家的是,在学习本章时最好边学边实践,自己试着写写C++程序。

1.1 VC.net概述
在切入C++语言之前,我们有必要简略地介绍一下VC.net的基本使用方法。首先当然是安装VC.net,值得注意的是,VC.net中附带的DirectX SDK并不是8.1最终版,推荐访问http://www.microsoft.com/msdownload/platformsdk/sdkupdate/更新之。然后启动VC.net,会看见一个"Start Page",在"Profile"一栏选择"Visual C++ Developer"。第二步是转到"Get Started"一栏,选择"New Project",并在出现的窗口选择"Visual C++ Projects"一栏中的"Win32 Project",填好"Name"和"Location",按"OK",这时会出现一个"Win32 Application Wizard"。此时需要在"Application Settings"一栏把"Empty project"前的方框勾上,以防VC.net在工程中加入一些无意义的垃圾;如果想编DOS窗口下的程序,例如这一章和下一章的程序,还要把"Console Application"选上。最后按"Finish"就完成了工程的创建。

图1.1 Start Page
在屏幕的左边,我们可以看到出现了"Solution Explorer"和"Dynamic Help"两栏,其中"Solution Explorer"又可变为"Class View"或"Resource View",其内容分别为:工程中包含有什么文件,工程中的类、变量和函数的结构,工程中包含有什么资源。至于"Dynamic Help"就是动态帮助,非常方便。
大家会注意到现在工程还没有文件,所以接下去我们需要学习如何新建一个文件,如果你想新建的文件是C++程序文件(.cpp),那么应该在"Source Files"上按右键,选择"Add" --- "Add New Item",在出现的窗口中选择"C++ File",定好名字,再按"Open"即可(假如你加入的文件叫"Haha.cpp",Solution Explorer将如右图所示);如果你想新建的文件是头文件(现在先不要管头文件是什么),在"Header Files"按右键,也选择"Add" --- "Add New Item",在出现的窗口中选择"Header File",也定好名字并按"Open"就行了。
在工具栏的中部可以更改程序的模式:Debug或是Release。在一般情况下,建议大家选择Release,可以减少文件大小并增加运行速度;而在调试程序时,必须选择Debug。在默认的情况下,编译好的程序会相应的放在工程目录下的"Debug"或"Release"子目录内。
最后我们来看看一个重要的操作,即如何把LIB文件(现在也不要管LIB文件是什么…)加入工程中:首先在"Solution Explorer"窗口中找到工程名,然后在上面按右
图1.2 键并选择"Properties",在出现的窗口中选择"Linker" --- "Input" --- "Additional Dependencies",最后填上要加入的LIB文件名即可。

OK,下面让我们看一个简单的C++程序:
/*-------------------------------------------------
First C++ Program
--------------------------------------------------*/

#include //现在你只需知道要使用输入输出语句就必须写这行。
//这一行结尾不用加分号因为它不是真正的C++语句。
//在1.3节将会对此做出解释。
using namespace std; //这是什么意思呢…在1.2.3节会有解释。

int a=5; //声明变量a,同时顺便赋5给它。C++中变量都需先声明后使用。
//int说明了a的数据类型为整数。

int square(int x); //声明函数square,它有一个参数,为int类型,即整数。返回值也
//为int类型。C++中的函数都需要先声明后给出定义。

int square(int x) //函数真正定义。
{
return x*
x; //返回x*x,可以在一个语句中的间隔位置插入回车将其分成几行。
}

int main( ) //主函数,每个DOS窗口下的C++程序都需要它
{
int A; //声明变量A。C++中变量声明的位置是比较随意的。
cout<<"请输入A:"; //输出"请输入A:",箭头的方向很直观。
cin>>A; //输入A, 注意箭头方向的更改。
cout<<"A="< < (3) 用"{"和"}"括起的句被称为块语句,形式上被认为是一个语句(就像PASCAL中的begin和end)。
(4) "//"至行尾为注释,"/*"至"*/"中全为注释,它们不会被编译。
(5) 主体是由一个个函数所构成的。在1.6节将会详细地介绍函数。

1.2 入门知识
1.2.1 数与数据类型
对于十进制数的表示,C++与其它语言一致,同样可以使用科学记数法,如3.145e-4。在C++中还可以直接表示十六进制数,只要在前面加上"0x"即可。如0x23。如果要表示的是负十六进制数,可以直接在"0x"前加上负号。为了清楚说明一个数是float类型,我们可以在数的结尾加上"f",例如1.00f。否则,该数默认为double类型。
下面我们来看看C++中的基本数据类型:
bool(逻辑型) char(字符或8位整数) short(16位整数)
int(16位或32位整数) long(32位整数) float(32位浮点数)
double(64位浮点数) long double (80位浮点数)
bool类型用true和false代表真与假,其实际占用空间是8位。
某一char类型的变量如果等于'a'(注意C++中字符用单引号,字符串用双引号),则它又等于a的ASCII码,即97。依此类推。
int类型在DOS下一般为16位,在WINDOWS下一般为32位,如果想保险一点自己试试就知道了。
在整数数据类型前可加上"unsigned"表示为无符号数,数的范围可增大一倍。比如说char类型数据的范围是-128到127,unsigned char类型数据的范围则为0到255。
使用sizeof( )可以得到任何对象占用的字节数,例如如果有一个char类型的变量a, 则sizeof(a)会返回1。
有的类型之间是可以自动转换的,如可以把一个float类型的变量的值赋给一个int类型的变量,小数点后的部分将会被自动截掉。如果不放心可使用强制类型转换,形式为(目标类型)变量名。比如说如果有一个char类型的变量 c值为'b',直接输出c会得到'b'这个字符但输出(int)c会得到'b'的ASCII码。强制类型转换不会改变变量的值(除非将一个浮点数转换为整数等情况),它只是返回转换后的值。注意字符串和整数之间不能用强制类型转换实现转换,办法在1.9节。
我们还可以借助typedef定义自己的数据类型,例如typedef myint unsigned int;后myint就等价于unsigned int。VC.net系统已经预先用typedef定义好了不少类型,例如BYTE等价于unsigned char,WORD等价于unsigned short,DWORD 等价于unsigned long等等。

1.2.2 变量与常量
C++中的变量几乎可在任何地方处定义,而且可以同时定义多个变量,如int a,b;。但每一个变量只在最紧挨它的一对{和}符号内起作用,只有在所有函数之外定义的变量才为全局变量,即在整个cpp文件中有效。如果局部变量和全局变量重名,调用时会使用局部变量,如果一定要使用那个全局变量,调用时在变量名前加上"::"即可。这里建议大家尽量少用全局变量,因为它可能使程序变得混乱和难于调试。
所有变量定义的前面均可加上修饰符"const"表示它是常量,不能在程序中改变它的值。其实如果我们不打算在程序中改变某变量的值,我们就可以把它声明为常量以防止意外改动。我们还可加上修饰符"static"表示此变量是静态变量,这个要举一个例子以方便说明:比如说在某一个函数内有这样一条定义:static int count=0; ,那么程序执行前就会为count这个变量开辟一块固定的空间并把count的初值设为0。以后每次执行这个函数时,程序不会象普通变量那样重新为它分配空间,也就是不会改变它的位置和数值,换句话说,它的生命周期与整个程序一样。这样只要在函数中再加一句count=count+1即可统计这个函数执行了多少次。

1.2.3 Namespace
Namespace是一个挺有趣的东西,它的引入是为了方便我们使用相同名字的变量、常量、类(在第二章我们会接触类)或是函数。一个Namespace是这样定义的:
namespace xxx //xxx是namespace的名字
{
在这里可以像平常一样定义各种东西
}

以后要使用某个namespace中的东西,比如说xxx中的aaa,像这样:xxx::aaa即可。不过这样好像挺麻烦的----平白无故就多出了一个"xxx::"。于是有了"using namespace xxx;"这种语句,可以帮你省下这几个字符。记住,"using namespace"也只是在最紧挨它的一对{和}符号内起作用,在所有函数之外执行的这条语句才在整个文件中有效。注意:
namespace s1
{
int a=0;
}

namespace s2
{
float a=0;
}

void main( )
{
using namespace s1;
using namespace s2;
//a=a+1; //这句是错误的!因为编译器此时无法确定a在哪个namespace
s1::a = s2::a + 1; //这样就是正确的
}

那么我们在第一个程序中为何要using namespace std;呢?其实也是为了把"std::cout"变成简洁一点的"cout"。请看1.3节。

1.2.4 操作符与表达式
最后要说的就是C++中的操作符和表达式,与其它语言相同的就不在此赘述,讲讲一些与其它语言不同的内容:
%为取余数,比如说20%3=2。
在逻辑表达式中,用==表示相等,!=表示不等,比如说(4==5)为FALSE;大于等于用>=表示,小于等于则是<=。&&表示逻辑与,||表示逻辑或,!表示逻辑非。例如如果a=8,则( (a!=9) && ( (a==3) || (a==6) ) )为false。
<<(左移)和>>(右移)非常好用,作用是把这个数的二进制形式向左或右移位(cin和cout中的<<和>>被使用了运算符重载,所以意义不同,具体可参阅2.4节),举两个例子也许会好说明些:
18(二进制形式为0010010)<<2得到72(二进制形式为1001000)
77(二进制形式为1001101)>>3得到9(二进制形式为0001001)
我们可以看到,左移和右移可以代替乘或除2的n次方的作用,而且这样做可以节省不少CPU运算时间。在程序优化中这一种方法是十分重要的,例如a*9可用(a<<3)+a代替(注意,"+"运算比"<<"运算优先)。
C++还提供了算术与&、算术或|、算术非~,算术异或^等重要的二进制运算。比如25(11001)^17(10001)等于8(01000)。这些运算都是逐位对二进制数进行的。0&0=0, 0&1=0, 1&0=0, 1&1=1; 0|0=0, 0|1=1, 1|0=1, 1|1=1; ~0=1, ~1=0; 0^0=0, 0^1=1, 1^0=1, 1^1=0。
++/--操作符,即自增1/自减1,是C++的特色之一。a=7; a++; 则a变为8。(C++语言岂不是成了D语言??) 注意a++和++a不同:++a是先自增后给值,a++是先给值后自增:若a=12,(a++)+5为17,(++a)+5却为18,不过a后来都变成了13。
最后要说的是一个很有趣的操作符,就是"?:",它可以在一定程度上代替if语句的作用,因为"A?B:C"等价于"if A then 返回B else 返回 C"。举一个例子,(a>b)?a:b可返回a和b中的较大者。
值得注意的是由于C++的操作符众多,所以运算先后次序较复杂,如果没有注意到这一点而少加了几个括号将出现出人意料的结果。下面按优先级高到低列出了C++中的操作符:
1. ()(小括号) [](数组下标) .(类的成员) ->(指向的类的成员)
2. !(逻辑非) .(位取反) -(负号) ++(加1) --(减1) &(变量地址)
3. *(指针所指内容) sizeof(长度计算)
4. *(乘) /(除) %(取模)
5. +(加) -(减)
6. <<(位左移) >> (位右移)
7. < (小于) <= (小于等于) > (大于) >= (大于等于)
8. == (等于) != (不等于)
9. & (位与)
10. ^ (位异或)
11. | (位或)
12. && (逻辑与)
13. || (逻辑或)
14. ? : (?表达式)
15. = += -=(联合操作)
在表达式方面,C++基本与其它语言相同。只是C++为了简化程序,提供了联合操作:"左值 操作符=表达式"等价于"左值= 左值 操作符 表达式"。例如a*=6+b等价于a=a*(6+b),c+=8等价于c=c+8。
在C++中,所有表达式都有返回值。一般来说,(左值 操作符 右值)表达式的返回值与右值相同;条件表达式如(a>b)的返回值在条件成立时为1,不成立时为0。

1.3 预编译指令
现在该解释在第一个例子中#include 的意义了,其实这句是预编译指令。预编译指令指示了在程序正式编译前就由编译器进行的操作,可以放在程序中的任何位置。常见的预编译指令有:
(1)#include 指令
该指令指示编译器将xxx.xxx文件的全部内容插入此处。若用<>括起文件则在系统的INCLUDE目录中寻找文件,若用" "括起文件则在当前目录中寻找文件。一般来说,该文件后缀名都为"h"或"hpp",被称为头文件,其中主要内容为各种东西的声明。
那么为什么在第一个程序中我们可以省略"iostream.h"的".h"呢(大家可以自己找找,会发现并没有一个叫iostream的文件)?这有一个小故事。当初ANSI在规范化C++的时候对iostream.h进行了一些修改,比如说吧其中的所有东西放进了一个叫std的namespace里(还有许多头件都被这样修改了)。但是程序员就不答应了,因为这意味着他们的程序都要被修改才能适应新编译器。于是ANSI只好保留了对原来调用iostream.h的方法(#include )的支持,并把调用新的iostream.h的方法修改成现在的样子(#include )。
言归正传,我们#include 之后编译器会看到iostream.h中对输入输出函数的声明,于是知道你要使用这些函数,就会将包含有输入输出函数定义的库文件与编译好的你的程序连接,形成可执行程序。
注意<>不会在当前目录下搜索头文件,如果我们不用<>而用""把头文件名扩起,其意义为在先在当前目录下搜索头文件,再在系统默认目录下搜索。

(2)#define指令
该指令有三种用法,第一种是定义标识,标识有效范围为整个程序,形如#define XXX,常与#if配合使用;第二种是定义常数,如#define max_sprite 100,则max_sprite代表100(建议大家尽量使用const定义常数);第三种是定义"函数",如#define get_max(a, b) ((a)>(b)?(a):(b)) 则以后使用get_max(x,y)可得到x和y中大者(这种方法存在一些弊病,例如get_max(a++, b)时,a++会被执行多少次取决于a和b的大小!所以建议大家还是用内联函数而不是这种方法提高速度。关于函数,请参阅1.6节。不过这种方法的确非常灵活,因为a和b可以是各种数据类型,这个特点我们可以换用2.7节介绍的模板实现)。

(3)#if、#else和#endif指令
这些指令一般这样配合使用:
#if defined(标识) //如果定义了标识
要执行的指令
#else
要执行的指令
#endif

在头文件中为了避免重复调用(比如说两个头文件互相包含对方),常采用这样的结构:
#if !(defined XXX) //XXX为一个在你的程序中唯一的标识符,
//每个头文件的标识符都不应相同。
//起标识符的常见方法是若头文件名为"abc.h"
//则标识为"abc_h"
#define XXX
真正的内容,如函数声明之类
#endif

1.4 结构,联合和枚举
1.4.1 结构
结构可以把不同变量变为一个变量的成员。例如:
struct S //定义结构S
{
short hi; //结构S的第一个成员
short lo; //结构S的第二个成员
};
S s; //定义S类型的变量s

然后我们就可以像s.hi和s.lo一样使用s的成员。

1.4.2 联合
联合可以让不同变量共享相同的一块空间,举个例子:
#include
using namespace std;

struct S
{
short hi;
short lo;
};

union MIX
{
long l;
S s;
char c[4];
};

void main ( )
{
MIX mix;
mix.l=100000;
cout< <
cout<
}

此时mix所在的内存位置的情况是这样的:

图1.2

1.4.3 枚举
枚举的用处是迅速定义大量常量。例如:
enum YEAR //可以给枚举起一个名字
{
january=1, //如果不加上"=1"月份将依次为0-11,不符合我们平时的习惯,所以
//可以加上它。
february,
march,
april,
may,
june,
july,
august,
september,
october,
november,
december
};

1.5 控制语句
C++中的控制语句格式简洁且功能强大,充分证明了它是程序员的语言。

1.5.1 判断和跳转语句
C++中的判断语句格式如下:
if (条件) 真时执行语句; else假时执行语句;
例如:
if (a>=9) a++; else a--;
值得注意的是C++中的“真”与“假”的意义就是这条表达式不为0 还是为0。比如if (a-b) do_stuff; 的作用与 if (a!=b) do_stuff; 相同。
臭名昭著的跳转语句(不过有时候你还是不得不用)则是这样的:
标号:语句;(一般来说标号用"_"开头)
goto标号;
举个例子方便大家理解:

#include
using namespace std;
void main( )
{
int target=245; int a;
cout<<"欢迎您玩这个无聊的猜数游戏"<
cout<<"您的目标是猜中我想好的数"<
cout<<"请输入第一次猜的数:";
_input: cin>>a;
if (a>target)
{
cout<<"您刚才输入的数太大了!"<
cout<<"";
goto _input;
}
else if (a
{
cout<<"您刚才输入的数太小了!"<
cout<<"再猜一次:";
goto _input;
}
else
cout<<"恭喜你,猜对了!"<
}

1.5.2 选择语句
C++中的选择语句很灵活,我们先看看与其它高级语言相似的形式:
switch (变量)
{
case常量/常数1:
语句;//注意,这里可有多个语句且不需用{ }括起,不过其中不能定义变量。
break; //为什么要加这一句呢?下面会解释。
case常量/常数2:
语句;
break;
……
case 常量/常数n:
语句;
break;
default: //如所有条件都不满足则执行这里的语句。
语句;//这后面就没有必要加break;了。
}

break的作用其实是防止继续执行后面的语句,试试下面的程序:
#include
using namespace std;

const aaa=5;

void main( )
{
int a;
cin>>a;
switch(a)
{
case 0:
cout< <<"您输入的是0";
case 3:
cout< <<"您输入的是3";
case aaa:
cout< <<"您输入的数与AAA相等";
default:
cout< <<"???";
}
}

按照一般人的想法,当你输入0、2、3、5时会分别得到"您输入的是0"、 "???"、 "您输入的是3"、 "您输入的数与aaa相等",不过你可以试试结果是否真的是这样。试完后,你可以加上一些break再看看结果又将是怎样。

1.5.3 循环语句
先介绍while循环语句,共有两种形式:第一种是while (条件) 语句,意义为先判断条件是否满足,如果满足则执行语句(否则退出循环),然后重复这个过程。第二种形式是do 语句 while (条件),意义为先执行语句再判断条件,如果条件成立则继续执行语句(不成立就退出循环),这个过程也会不断重复下去。例如while((x+=1)=y);语句可以使x不断加1直到变成与y的值相同。
然后就是C++最强大的for循环,它的形式如下:
for (语句1;条件;语句2) 语句3 (其中任何一部分都可省略)
看上去好像很古怪,其实它就等价于这样:
语句1;
while (条件)
{
语句3;
语句2;
}
比如for (i=1;i<=100;i++) cout< <
又比如for (cin<
for (;;);将会陷入死循环,注意它比while(1);执行速度快。
在循环语句中可顺便定义变量,如for (int i=1;i<=100;i++) cout< <
有时我们需在循环中途跳至循环外,此时break又可以派上用场了。有时又需要在循环中途跳至下一次循环,continue可以帮你这个忙。

1.6 函数
C++中的函数是这样定义的:
返回值数据类型 函数名(参数表)
{
语句;
}
例如:
int fun(int x, int y)
{
x=x+1; //注意这一句只能在函数内改变x的值,请参阅下文
return x*y; //返回x*y,并会立刻退出该函数
}
当返回值数据类型为void时表示无返回值,就像其它语言中的“过程”。
参数表中在不引起歧义的情况下可有缺省值,例如void xyz(int a, int b=0); (只需在声明函数时说明缺省值),则xyz(12)等价于xyz(12,0)。
在main函数开始前最好声明一下程序中的函数(main函数不必声明),声明格式为:
返回值的数据类型 函数名(参数表); (注意有一个分号)
在声明的参数表里可以省略变量名,例如void myfunc(int,float);
在函数的定义(而不是声明)的最前面加上"inline"说明其为内联函数可提高一点速度,但增大了文件的大小。
就象其它语言一样,C++中的函数可以递归调用(自己调用自己)。它还有一个区别于其它语言的重要特性----可以"重载",例如如果有这样两个函数:
float fun(float x)
{
return x*x*x;
}

int fun(int x)
{
return x*x;
}
假设a为4,那么如果a为一个float类型变量,fun(a)会返回64;但若a为int类型,fun(a)会返回16。可以想像,这个特性在实际编程中将十分有用。
下面我们再看看一个问题:有人想编一个交换a和b的函数,于是他这样写:
void swap(int a, int b)
{
int t=a;
a=b;
b=t;
cout<<"a="<
<<"
void swap(int &a, int &b)
{
int t=a;
a=b;
b=t;
}
在默认情况下,函数的返回值也只是一个复制品,如果你一定要让它返回真正的东西,可以像这样写函数:int &foo(){do_something;}。不过注意在1.7.1节中说明的限制----我们不能这样返回在函数中创建的变量。
下面举一个使用了函数的程序例子(比较无聊?):

#include
using namespace std;

float pi=3.14159;

float s_circle(float r);
float v_cylinder(float r, float h);
float v_cone(float r, float h);
float v_all(float stop, float smiddle, float sbottom,float h);

float v_all(float stop, float smiddle, float sbottom,float h)
{
return (stop+4*smiddle+sbottom)*h/6;
}

float v_cone(float r, float h)
{
return s_circle(r)*h/3;
}

float v_cylinder(float r, float h)
{
return s_circle(r)*h;
}

float s_circle(float r)
{
return pi*r*r;
}

void main( )
{
float r,h;
float st,sm,sb;
cout<<"这个十分无趣的程序会帮您计算一些几何体的体积"<
cout< <<"0代表要计算圆锥体"<
cout<<"1代表要计算圆柱体"<
cout<<"2代表要计算拟柱体"<
cout<<"请选择:";
int choice;
cin>>choice;
cout<
switch(choice)
{
case 0:
cout<<"底面半径=?";
cin>>r;
cout<<"高=?";
cin>>h;
cout< <<"体积="<
cin>>h;
cout< <<"体积="<
cin>>sm;
cout<<"下表面的面积=?";
cin>>sb;
cout<<"高=?";
cin>>h;
cout< <<"体积="<
这里顺便提醒一下,我们绝不应该将在函数中创建的变量的地址或引用返回。因为在退出一个函数时,在函数体中所创建的所有变量都将被销毁,所以虽然地址是可以传回去,但它所指向的内容已经毫无意义。那么指针呢?在函数里new的指针能传回去吗?答案是可以,但是,你必须为极有可能发生的内存泄漏负责,因为要把这些指针找出来一个个delete掉实在很麻烦。“我们可以试试返回静态变量的地址或引用”,有的人会这样想。这在大多数情况下是个好办法,但是仍然存在可能的漏洞----因为这个静态变量的地址由始到终都是不变的。如下:
int &foo(int a)
{
static int t;
t=a;
return t;
}

int main()
{

if ( foo(1) == foo(2) ) //这个条件将会成立!

}

指针有什么用呢?第一个用处是可以动态分配大量内存。我们知道DOS下很多语言对数组的大小有很严格的限制,但C++ 却可以开辟非常大的数组,而且可以用完就释放内存,这就是指针的功劳。具体会在介绍数组时介绍。
我们还可以创建函数指针,这也是C++的特色之一。所谓函数指针,顾名思义就是指向一个函数的指针。举个例子,如果有一些函数:
float aaa ( int p );
float bbb ( int q );
float ccc ( int r );
那么我们可以这样定义一个函数指针:float (*p) (int);
这时就可以将p指向上面的各个函数,如p = bbb;执行后p(100);就等价于bbb(100);
如果在某一段程序中需要根据情况(例如某变量的值)调用aaa、bbb,ccc函数,那么我们可以不必使用烦琐的switch,只需使用函数指针数组即可,非常方便。
顺便说一下这时应该如何用typedef定义p的数据类型:typedef float(*pFunction)(int);后直接用pFunction p;即可定义上面的那个指针p。

1.7.2 数组
C++中的数组和指针有着千丝万缕的联系。象其它语言一样,C++可以直接定义数组,如int a[100]; 即可定义一个由100个char类型变量组成的数组;也可以在定义时顺便赋值,例如char b[5]={'a', 'b', 'c', 'f', 'z'};;还可以定义高维数组,如char c[200][50];(相当于BASIC中的c(200, 50))。使用数组时要注意几点:
(1)数组的下标是从0开始的,上面所定义的a数组的下标范围为0到99,刚好是100个元素。
(2)数组越界不会有任何提示。
(3)数组需要你自己清零。

如果你使用直接定义的方法产生数组,还需注意下面两点:
(1)数组的大小必须是常数或常量,象int a; int b[a];这样是错误的。
(2)你得到的实际上是一个特殊的与"数组名"同名的指针。

第二点也许有些费解,你可以试试这段程序就会明白:
#include
using namespace std;

void main( )
{
int abc[1000]={0}; //这样就可以使数组被预先清0
//注意int abc[1000]={1};会使abc[0]=1而其它元素=0
abc[0]=987;
cout<<*abc<
*abc=787;
cout<
}

我们还可以直接使用指针创建数组。比如说我们要临时分配一块空间,存储100000个int类型数据,那么就可以这样做:int *p; p=new int[100000];(你可以将其合并为int *p=new int[100000],在这里又出现了一个新操作符"new"),则系统会在内存找到一块足够大的空闲空间,再将p指向这块空间的起始位置,以后就可以把p当成一个数组来使用了。这种办法的第一个好处是用完这块内存后可以释放内存(其实你应该永远这样做,否则会造成所谓Memory Leak,即内存资源泄漏),就象这样即可:delete[ ] p;("delete[ ]"也是C++中的操作符);第二个好处是可以动态定义数组,例如:
int a; cin<
所以,建议大家使用new来创建数组。
不过直接使用刚才用来创建数组的指针并不方便(试试p=&p[100]能实现把p指向p[100]吗?可能会死机!),最灵活的办法是把一个另外的指针指向数组的元素,因为指针可以进行加减运算。比如说如果p=a[0],执行p+=46; 即可使p指向a[46],再执行p--;则p指向a[45]。看看下面的例子:
#include
using namespace std;

void main( )
{
int *p,*q;
p=new int[100000];
q=&p[0];
for (int i=0;i<100000;i++)
*(q++)=0; //这样也可以清零
q=&p[1];
*q=128; //把p[1]变成128
cout<
delete[ ] p; //删除数组用delete[ ]
delete q; //删除指针用delete
//要养成用完指针就释放的良好习惯
}

有时候你可能会忘记已经释放了一个指针,仍去使用它,结果会出现不可预料的结果。为了防止出现这种情况,你可以在释放完指针后再把它设为NULL以保证它不被继续使用。我们可以用这样两个宏:
#define SAFE_DELETE(p) { if(p) { delete (p); (p)=NULL; } }
#define SAFE_DELETE_ARRAY(p) { if(p) { delete[] (p); (p)=NULL; } }

下面还要讲讲使用指针创建高维数组的方法,因为此时要用到指针的指针(指针也是变量,也要占内存,所以也有自己的地址)甚至指针的指针的指针的……(啊!有一位听众晕倒了!谁抬他出去?)下面的一段程序演示了如何创建一个高维数组p[40][60](比较难懂,做好心理准备):
int **p; //指向指针的指针!
p=new int *[40]; //执行完后p就是一个元素为指针的数组!
//可以将这句与 p=new int[40]; 对照着想想
for (int i=0;i<40;i++)
p[i]=new int[60]; //为p数组中的每一指针分配内存,将其也变为一个个数组
下面是一个二维数组p[n][m]的结构:
图1.4
如果你弄懂了上面的程序,你就可以再玩点新花样:定义不对称数组。比如这样:
int **p; *p=new int *[10];
for (int i=0;i<10;i++)
p[i]=new int[i+1];

1.7.3 字符串
C++中的字符串其实也是指针的一种,因为并没有一种基本数据类型是字符串,所谓字符串实际是一个以"/0"(这叫做转义符,代表一个ASCII码为0的符号)作为结束标志的一个字符指针(char *),它实际上是一个字符数组,就像图1.2中那样。所以如果有一个字符串s为"abc",实际上它为"abc/0",sizeof(s)会返回4,而不是3。定义字符数组时也要记住多留一位。
一般是用字符指针的方法定义字符串的:char* str = "muhahaha";,但我们知道使用指针前一定要先找好初地址,所以事实上执行的是将str指向一个const char[]。由于这里有个const,我们没有必要用delete[]释放这个字符指针。用过BASIC的人要注意C++中的字符串并不能比较(用==比较两个指针时它只会比较两个指针的所指向的地址是否相同)、相互赋值、相加和相减,这些操作一般是靠使用系统提供的字符串操作函数实现的,请参阅1.9节。请特别注意字符串不能相互赋值,请看下面一段代码:
char *str="aaaa";
char *str1="oh";
str1=str; //!!!
cout<

输出很正常,似乎我们实现了拷贝字符串的目的。然而仔细想一想,把一个指针赋给另一个指针时到底会发生什么?假设str指针本来指向地址0x0048d0c0,而str1指针指向0x0048d0bc,那么执行str1=str;后两指针将同时指向地址0x0048d0c0!C++并不会为了字符串搞特殊化,指针的赋值操作只会简单地拷贝地址,而不是拷贝内容。要拷贝内容,还得靠1.9节介绍的strcpy( )。

1.7.4 小结
学了这么多C++知识,大家是不是有点疲倦了呢?如果你想兴奋一下,那么就看看下面这段程序吧,它可以输出π的前781位。
#include
using namespace std;
long a=10000,b=0,c=2800,d,e=0,f[2801],g;
void main()
{
for(;b-c;)f[b++]=a/5;
for(;d=0,g=c*2;c-=14,cout<
for(b=c;d+=f[b]*a,f[b]=d%--g,d/=g--,--b;d*=b);
}
程序使用了C++提供的所有能简化代码的手段,包括前面没提到的逗号(可以将几个语句硬拼在一起,返回值以最靠右的语句为准)。它表现出来的数学功底是很惊人的,值得大家研究研究。当然,不提倡写这样费解的代码!

1.8 多文件程序的结构
记得以前我第一次使用Visual C++编游戏的时候,由于当时对C++还不是很熟,调试了很久都没有成功。后来把程序email给了一位高手叫他看看问题在哪里,过了几天他把程序送回来时已经可以运行了,原来他在我的头文件中声明变量的语句前都加了一个"extern"。这是什么意思呢?当时我还不清楚,因为很多书上并没有讲多文件的程序应该怎么写。不过现在当你看完这一节时我想你就应该明白了。
首先我们来看看多文件程序成为可执行程序的全过程:
图1.5
我们可以发现,库文件(扩展名为LIB,其实是一种特殊的已经编译好的程序。系统函数的定义都是存在LIB内,以使你看不到它们的源代码)是在最后的连接一步加入程序的,各个文件也是在这一步才建立联系的。
extern的作用就是告诉编译器此变量会在其它程序文件中声明。把这种外部变量声明放在头文件里,再在每个文件中都包含这个头文件,然后只要在任何一个文件中声明变量,所有文件就都可以使用这个变量了。如果不加extern,各个文件使用的变量虽然同名但内容不会统一。
在各个文件中也需要先声明函数,之后才能使用它。不过这时用不着使用extern了。
最后我们来看看一个简单的多文件程序的例子:

/*---------------main.h----------------*/

#if !(defined MAIN_H)

#include
using namespace std;

extern int a;

void print();

#define MAIN_H
#endif


/*---------------main.cpp----------------*/
#include "main.h"

int a;

void main()
{
a=3;
print();
}


/*---------------function.cpp----------------*/
#include "main.h"

void print()
{
cout<
}

1.9 常用函数
C++与其它语言的一大区别是提供了庞大的函数库,能用好它就可以提高你的效率。
先看看 里面的:
int rand( ):返回一个随机的整数。
void srand(int):根据参数重新初始化随机数产生器。
int/float abs(int/float):返回数的绝对值。
min/max(a,b):返回a和b中的较小/大者,用#define定义的,大家不用担心效率。
int atoi(char *s);,返回由s字符串转换成的整数。
double atof(char *s);,返回由s字符串转换成的浮点数。
char* gcvt(double num, int sig, char *str);,num为待转换浮点数,sig为转换后数的有效数字数,str为目标字符串起点。函数返回指向str的指针。举个例子,如果sig=5那么9.876会转换成"9.876",-123.4578会变成"-123.46",6.7898e5就成了"6.7898e+05"。

然后是 里面的数学函数:
sin、cos、tan:这个你应该懂吧?。
asin、acos、atan:反三角函数。
sinh、cosh、tanh:双曲三角函数。
log、log10:自然和常用对数。
exp、pow10:上面两个函数的反函数。
pow(x,y):返回x的y次幂。
sqrt:开平方根。
ceil:返回最小的不小于x的整数。
floor:返回最大的不大于x的整数。
hypot(x,y):返回x的平方加上y的平方再开方的值。

文件读写函数在 里面,使用方法是:
首先定义指向文件的指针并打开文件,例如FILE *file = fopen ("aa.bbb", "rb");,其中aa.bbb为你要打开的文件名(注意,如果在VC.net的开发环境中按F5或Ctrl+F5执行程序,程序的默认文件读取目录是工程的目录,而不是工程目录下的Debug或是Release目录),如果有路径则要用"//"或"/"代替"/"。"rb"是打开的模式,基本模式有这些:
表1.1
可读数据? 可写数据? 打开文件时读写指针位置 如不存在则创立新文件?
r 是 否 文件头 否
w 否 是 文件头 否
a 否 是 文件尾 是
r+ 是 是 文件头 否
w+ 是 是 文件头 是
a+ 是 是 文件尾 是
在基本模式后加上b或t可设定要打开的是二进制文件还是文本文件。对于前者,我们打开文件之后用fread可以读入数据,读写指针也会随之后移。fread的使用方法为:fread(p, size, n, file);,p为指向读出的数据将存放的位置的指针,size为每一个数据块的字节数,n为要读多少个数据块,file则为刚才定义的指向文件的指针。例如fread(&a[0][0], sizeof(a[0][0]), sizeof(a)/sizeof(a[0][0]), file);可将数据读入二维数组a。需要注意的是这时你得到的数据为ASCII码!例如如果文件的内容为"10",你将读出49和48这两个数(1和0的ASCII码)。用fwrite则可以写数据,形式与fread一模一样,使用方法也相同。
对于文本文件,我们应该使用fscanf( )和fprintf( )函数来读取和写入数据。这两个函数比较灵活,让我们看下面一段程序:
#include
using namespace std;

char s[5]="abcd";
int i=967;
float f=3.1415;
char c='x';

void main()
{
FILE *file=fopen("aa.txt","wt+");
fprintf(file,"str1=%s",s); //%s表示在这个位置上是一个字符串
fprintf(file,"/nint2 = %d",i); //%d表示整数,/n表示换行
fprintf(file,"/nfloat3=/n%f/nCH AR 4 = %c",f,c);
//%f表示浮点数,%c表示字符,可以把几个fprintf合起来写
fclose(file);
}
运行完后aa.txt的内容是:
str1=abcd
int2 = 967
float3=
3.141500
CH AR 4 = x
fscanf( )和fprintf( )的使用方法几乎是完全一样的,唯一的区别是,如果你要把数据读入普通变量,要在变量的前面加一个"&",使fprintf可以修改变量的值。当然,如果要读入的是字符串之类的指针就不必这样了。
fseek可以移动读写指针,形式为fseek(file, offset, whence);,file为文件指针,whence为寻址开始地点,0代表开头,1代表当前位置,2代表文件尾。offset则为需移动的字节数。
使用ftell(file);可以得知当前的读写指针位置(离文件头有多少个字节的距离)。
其实还有一种简单很多的文件读写方法(也更符合C++标准):
#include
using namespace std;
int a;
……
iofstream file;
file.open("abc.dat"); //使用file.open("abc.dat", ios::binary);可指定为二进制模式
file>>a; //就像cin
file<<"abcdefg"; //就像cout
……

接着要说的是常用的字符串函数,在 内有它们的定义。
char *strcpy(char *dest, char *src);,该函数使dest=src并返回新的dest。使用它还可以实现字符串和字符数组之间的转换。
char* strcat(char *dest, char *src);,将src连接到dest的后面,并返回新的dest。
char* strstr(char *s1, char *s2);,返回指向s2在s1中第一次出现的位置的指针。
char* strchr(char *s1, char c);,返回指向c在s1中第一次出现的位置的指针。
char* strlwr(char *s);,将s中的所有大写字母转为小写。
char* strset(char *s, char c);,将s内所有字符替换为字符c。
int strlen(char *s);,返回字符串的长度。

最后是 中的内存函数:
memcpy(char *dest, char *src, int n);,将从src开始的n个字节的内存内容拷贝到从dest开始的内存中。注意dest和src在内存中的位置不能重叠。
memmove(char *dest, char *src, int n);,也可以实现拷贝,dest和src在内存中的位置可以重叠。当然,它比memcpy慢。
memset(s, c, n);,将从s开始的n个字节都设为c。可以用来将数组和结构清零。

第二章 如何说得更地道

C++和C最大的区别在于C++是一种面向对象(object-oriented)的语言,即程序是以对象而不是函数为基础,所以严格说来,我们在第一章所讨论的还不是地道的C++程序。类(class)正是实现面向对象的关键,它是一种数据类型,是对事物的一种表达和抽象。类拥有各种成员,其中有的是数据,标识类的各种属性;有的是函数(类中的函数又叫方法),表示对类可进行的各种操作。举一个例子,我们可以建立一个“草”类,它可以有“高度”等各种属性和“割”、“浇水”等各种方法。

2.1 定义和使用类
让我们先看一个使用了类的程序:
//-----------------------------grass.h---------------------------------
class grass //定义grass类
{
private: //声明下面的成员为私有。类外的函数如果试图访问,编译器会告诉你发生错
//误并拒绝继续编译。缺省情况下类中的一切均为私有,所以这一行可以省略。
int height; //一般来说,类中的所有数据成员都应为私有
//不过本章后面的程序为了便于说明也拥有公有数据成员
public: //下面的成员为公有,谁都可以访问。
void cut( );
void water( );
int get_height( );
void set_height(int newh);
}; //这个分号不要漏了!


//-----------------------------grass.cpp-------------------------------
#include
using namespace std;
#include "grass.h"

//下面对类的方法进行定义
void grass::cut( ) // "::"表示cut( )是grass的成员。
{
if (height>=10)
height-=10; //可自由访问grass中的任何成员。
}

void grass::water( )
{
height+=10;
}

int grass::get_height( ) //在类的外部不能直接访问height,所以要写这个函数
{
return height;
}

void grass::set_height(int newh) //同样我们写了这个函数
{
if (newh>=0)
height=newh;
}

void main( )
{
grass grass1,grass2; //其实这一句和"int a,b;"没什么区别,想一想!这一句语
//句被称为实例化。
grass1.set_height(20); //如果你用过VB一定会觉得很亲切。类以外的函数即使
//是访问类的公有部分也要用"."。
cout<
grass1.set_height(-100); //因为set_height作了保护措施,所以这一句不会给
//height一个荒唐的值
cout<
grass1.cut( );
cout<
grass2=grass1; //同一种对象可直接互相赋值
cout<
grass *grass3; //也可定义指向类的指针
grass3=new grass; //同样要new
grass3->set_height(40); //由于grass3是指针,这里要用"->"。其实也可以
//使用(*grass3).set_height(40); ("."操作符比"*"
//操作符执行时优先) ,不过这样写比较麻烦。
grass3->water( );
cout< get_height( );
delete grass3; //释放指针
}

看了注释你应该可以读懂这个程序,现在我们可以看到类的第一个优点了:封装性。封装指的就是像上面这样似乎故弄玄虚地把height隐藏起来,并写几个好像很无聊的读取和改写height的函数。然而在程序中我们已经可以看到这样可以保护数据。而且在大型软件和多人协作中,由于私有成员可以隐藏类的核心部分,只是通过公有的接口与其它函数沟通,所以当我们修改类的数据结构时,只要再改一改接口函数,别的函数还是可以象以前一样调用类中的数据,这样就可以使一个类作为一个模块而出现,有利于大家的协作和减少错误。
有的人也许会认为写接口函数会减慢速度,那么你可以在定义前面加上"inline"使其成为内联函数。
类以外的函数其实也有办法直接访问类的私有部分,只要在类中声明类的方法时加入形如"friend int XXX (int xxx, int xxx) "这样的语句,类以外的"int XXX (int xxx, int xxx) "函数就可访问类的私有部分了。此时这个函数称为类的友元。
注意类中的函数最好不要返回类中的私有成员的引用或指针,否则我们将显然可以通过它z强行访问类中的私有成员。
除了public和private两种权限外还有protected权限,平时是和private一样的,后面在讲类的继承时会进一步解释它的用途。
在类的定义中要注意定义成员数据时不能同时初始化(好像int a=0这样),且不能用extern说明成员数据。
一种类的对象可以作为另一种类的成员。例如:
class x
{
int a;
};

class y
{
x b;
};
如果我们把上面两个类的声明互调,那么由于执行x b;时x类还根本未被定义,编译器会报错。那么应如何解决呢?很简单,在最前面加一句class x;预先声明一下即可。
同一种类可以互相赋值。类可作为数组的元素。可以定义指向类的指针。总之类拥有普通的数据类型的性质。
只要定义一次类,就可以大批量地通过实例化建立一批对象,且建立的对象都有直观的属性和方法。这也是类的好处之一。
结构其实也是一种类,只不过结构的缺省访问权限是公有。定义结构时只需把"class"换为"struct"。 一般我们在仅描述数据时使用结构,在既要描述数据,又要描述对数据进行的操作时使用类。
最后介绍一下用什么办法可以得到一个变量是哪个类的对象:typeid(aaa).name( )能返回aaa变量所属类的名称,注意这是在程序运行期实现的,很酷吧,不过不要滥用它。

2.2 类的构造函数
当我们将一个类实例化时,经常会希望能同时将它的一些成员初始化。为此,我们可以使用构造函数。构造函数是一个无返回值(void都不用写)且与类同名的函数,它将在类被实例化时自动执行。构造函数的使用就象下面这样:
#include
using namespace std;
class grass
{
public:
int height;
grass(int height); //构造函数。当然,它需为public权限
//虽然在这个程序中它有参数,但并不必需
};

grass::grass(int height)
{
this->height=height; //对于任何一个对象的方法来说,this永远是一个指向这个
//对象的指针。所以这样写能使编译器知道是类中的height
}


void main( )
{
grass grass1(10); //普通对象实例化时就要给出初始化参数
//如果构造函数无参数就不需要写"(10)"
grass *grass2;
grass2=new grass(30); //指针此时要给出初始化参数
cout<
cout< height;
}

值得注意的是,当你使用grass grass1=grass2;或grass grass1(grass2);这样的方式来初始化对象时,构造函数将不会被执行,执行的将是所谓的拷贝构造函数。如果你偷懒没写它,系统会自动生成一个,它的行为将是逐字节拷贝grass2到grass1。这个行为看上去很正常,然而如果类中有指针型成员时它却存在着灾难性的后果。看看下面的一端代码片段:
grass grass1;
grass1.x=”hehe”; //假设x是grass类的一个char*类型成员
{
grass grass2=grass1; //此时grass2的x将和grass1的x指向同一个值!
} //grass2和它的x成员一起被销毁
//现在grass1.x也已无辜地失去意义

所以,当我们的类中有指针型成员时,我们必须像这样写一个自己的拷贝构造函数:
grass::grass(grass& grass1) //名称应与类相同,参数应为对同类数据的引用
//如果我们写成grass::grass(grass grass1),显然会陷入死循环,因为此时编译器需要
//调用拷贝构造函数来生成参数的复制品,所以我们必须使用实参
{
//在这里正确地拷贝各个数据
}

但是这实在挺麻烦的,有没有办法干脆禁止掉这种意义一般来说并不大的拷贝初始化呢?很简单,自己写一个只有声明没有定义的拷贝构造函数,并声明其为private权限,即可防止编译器自做聪明----编译时如果它发现grass grass1=grass2;这样的语句时会报错,不过你也别想用foo(grass a);这样的函数了,必须用foo(grass &a);……

构造函数还有一个用处就是可以进行类型转换。例如,我们定义了一个这样的构造函数:
grass::grass(int x)
{
height=x;
}
现在,如果我们定义了一个grass类的gg对象,以后就可以执行gg=5; 这样的语句了,也可将int类型的变量赋值给gg,因为这时实际上执行了gg=grass(5);这样的语句(如果我们使用了2.4节介绍的方法重载了=运算符,那么只会执行重载的=运算符)。
还有一种叫析构函数的东西,形如grass::~grass( ),在我们delete一个指向对象的指针时会自动调用,你应该在里面释放类的指针型成员。

2.3 类的静态成员
类的静态数据成员和普通的静态变量含义不同,它的意思是:在每一个类实例化时并不分配存储空间,而是该类的每个对象共享一个存储空间,并且该类的所有对象都可以直接访问该存储空间。其实它就是一个专门供这个类的对象使用的变量----如果你把它声明为private权限的话。
在类中定义静态数据成员,只须在定义时在前面加上"static"。类的静态数据成员只能在类外进行初始化,若没有对其进行初始化,则自动被初始化为 0。在类外引用静态数据成员必须始终用类名::变量名的形式。静态数据成员可以用来统计创建了多少个这种对象。
举一个例子:
#include
using namespace std;
class AA
{
private:
int a;
public:
static int count; //定义类的静态成员
AA(int aa=0) { a=aa; count++; }
//对于类的方法,如果是较简单的可以这样写以使程序紧凑
int get_a( ) { return a; }
};

int AA::count=0; //在类外初始化

void main()
{
cout<<"Count="< <
AA x(10),y(20);
cout <<"x.a="<

2.4 运算符重载
运算符重载可以使类变得非常直观和易用。比如说,我们定义了一个复数类(什么,你没学过复数?你读几年级?),然后再将加、减、乘、除等运算符重载,就可以自由地对复数对象好像整数一样进行这些运算了!可以想象,这将大大方便我们。
使用运算符重载很简单,我们就举一个复数类的例子来说明怎样使用:

#include
using namespace std;

class complex
{
private:
double real;
double image;
public:
complex ( ); //缺省构造函数
complex (double r, double i); //顺便初始化值的构造函数
complex operator +(complex x); //计算A+B
complex operator ++( ); //计算++A
complex operator --(int); //计算A—
complex operator =(double x); //把一个double赋给一个complex时该怎么办
//系统还自动生成了一个complex operator=(complex);,它的实现是简单拷贝
//所以如果类中有指针成员,它会像默认的拷贝构造函数那样出问题?
//我们如果要重写它,还要注意检查自己赋给自己的情况
void print(); //输出复数
};

complex::complex( )
{
real=0.0f;
image=0.0f;
}

complex::complex(double r, double i)
{
real=r;
image=i;
}

complex complex::operator +(complex x)
{
complex c;
c.real=real+x.real;
c.image=image+x.image;
return c;
}

complex complex::operator ++( )
{
complex c;
++real;
c.real=real;
c.image=image;
return c;
}

complex complex::operator --(int)
{
complex c;
c.real=real;
c.image=image;
real--;
return c;
}

complex complex::operator =(double x)
{
real=x;
return *this; //按照C++的惯例,返回*this,以便实现链式表达式
}

void complex::print( )
{
cout< <<"+"< <<"I"<
}

void main( )
{
complex a(1,2);
complex b(4,5);
complex c=a+b;
complex d=++a;
complex e=b--;
//complex f=0.234; //这样写现在还不行,因为上面没写相应的拷贝构造函数
//你可以试着写一个
complex f;
f=a=0.234; //链式表达式
a.print( );
c.print( );
d.print( );
e.print( );
f.print( );
}

除了"."、 ".*"、 "::"、 "?:"四个运算符外,其它运算符(包括new、delete)都可被重载,cin和cout就是两个典型的例子。
对于双目运算符(即A?B),如加、减、乘、除等,可这样重载:
"complex operator ?(complex B);",运算时就好像调用A的这个方法一样。
对于前置的单目运算符(即?A),如"-A"、 "--A"、"++A"等,可这样重载:
"complex complex::operator ?( );"。
对于后置的单目运算符,如"A--"、 "A++",可这样重载:
"complex complex::operator ?(int);",其中参数表中的int不能省去。
下面出一道题让大家考虑考虑吧:创建一个字符串类并将+、-、=、==等运算符重载,使我们可以直观地操作字符串。

2.5 类的继承
可以继承是类的第二个优点,它使大型程序的结构变得严谨并减少了程序员的重复劳动。继承到底是什么呢?举个例子,比如说树和猫这两样东西,看起来好像毫不相干,但它们都有质量、体积等共有的属性和买卖、称量等共有的方法。所以我们可不可以先定义一个基类,它只包含两样事物共有的属性和方法,然后再从它派生出树和猫这两样事物,使它们继承基类的所有性质,以避免重复的定义呢?答案是肯定的。由于一个类可以同时继承多个类,一个类也可同时被多个类继承,我们可以建立起一个复杂的继承关系,就象这样:
图2.1
我们可以看到,继承是很灵活样的。要说明继承是很简单的,只要像这样定义派生类即可:
class 派生类名 :派生性质 基类名1,派生性质 基类名2,...,派生性质 基类名n
{
这里面同定义普通类一样,不必再说明基类中的成员
};
关于这里的派生性质,有这样一张表可供参考:
表2.1
派生性质 在基类中的访问权限 在派生类中的访问权限
public public public
protected protected
private 不可访问
protected public protected
protected protected
private 不可访问
private public private
protected private
private 不可访问
这张表中的后两栏意思是:当基类中设置了这种访问权限的成员被派生类继承时,它将等价于设置了什么访问权限的派生类的成员。
下面我们看一个例子:
#include
using namespace std;

class THING //定义基类
{
protected:
int mass,volume;
public:
THING(int m, int v);
int get_mass( );
void set_mass(int new_mass);
};

THING::THING(int m, int v)
{
mass=m;
volume=v;
}

int THING::get_mass( )
{
return mass;
}

void THING::set_mass(int new_mass)
{
if (new_mass>=0)
mass=new_mass;
}

class ANIMAL: public THING //定义派生类
{
private:
int life;
public:
ANIMAL(int x) : THING(10+x,7) { life=x; }; //定义派生类的构造函数时需要给
//出初始化基类的办法
//如有多个基类,用逗号隔开分别//提供参数
void set_life(int new_life) { if (new_life>=0) life=new_life; };
int get_life( ) { return life; };
void kill( ) { life=0; };
};

ANIMAL cat(50);

void main( )
{
cout<
cout<
cat.set_life(100); //也有自己的方法
cat.kill( );
cout<
}
当某类同时继承了多个类而这些类又拥有相同名称的函数时,我们可以使用像这样的语句说明要使用的是哪一个类的方法:child->father::get_life();。

2.6 虚函数和抽象类
虚函数体现了类的第三个优点:多态性(看上去好像很深奥)。
有时候,在一个含有基类和派生类的程序中,我们需要在派生类中定义一个和基类的方法具有相同的函数名、返回类型和参数表,但函数的具体内容不同的方法。比如说,我们首先定义了一个"植物"类,然后又定义了一些它的派生类"松树"、"柳树"、"杨树"等等,然后在派生类中重载了"种植"方法,因为我们知道它的实现随着树种的不同而不同。但此时当一个"植物"类的指针指向一个"柳树"类的对象时(这是合法的),基类指针还是只能访问基类的"种植"方法,而不是在派生类中重新定义的方法!解决问题的办法是在基类中把这个方法定义为虚函数。
虚函数的定义方法是在基类声明成员函数时在最前加关键字"virtual"。
我们也举一个例子来说明虚函数的使用方法:
#include
using namespace std;

class Base
{
public:
int a;
virtual int get_a( ) { return a; };
};

class Child: public Base
{
public:
int get_a( ) {return a*a; };
Child(int aa) {a=aa; };
};

Child child(10);

void main( )
{
Base *p;
p=&child;
cout< get_a( );
}

从运行结果可以看到,调用的是Child类的get_a( )。你可以试一试删去virtual看看输出有什么变化。
值得注意的是基类的析构函数一定要是虚函数,否则在你通过基类的指针delete派生类的对象时显然将不会调用派生类的析构函数,这可不是我们希望看到的。另外,在派生类重载基类的函数是没有作用的,编译器只会根据指针的类型选择调用哪个函数。
有时候我们不需要用基类来定义对象,则可把基类的函数定义为纯虚函数,也不需再在基类中给出函数的实现。这时基类就被称为抽象类。还是上面的哪个植物的例子,由于我们这时显然不会定义一个"植物"类的对象,只会根据具体的树的不同而选择一个相应的类,所以我们完全可以把"植物"类中的虚函数全部定义为纯虚函数。定义纯虚函数的方法是在加了"virtual"后再去掉函数体并在声明后加上"=0"。就象这样:"virtual int get_a( )=0;"。

2.7 模板
模板(template)是C++语言提供的一个有趣而有用的东西,它可以使我们快速地定义一系列相似的类或函数。下面我们先看看如何用使用模板来定义类:
#include
using namespace std;

template //这是一个所谓的prefix,即前缀。< >内为模板参数,在这
//里是一个类T。有了这个前缀,下面的半条语句就可以把T
//当作一个类的名称使用
class List
{
private:
T *a; //a是一个指向T类型的数据的指针
public:
int size;
List(int n);
T operator[ ](int i); //List[ ]的返回值为T类型的数据
~List();
};

template List ::List( int n ) //哇!这是天书吗......
//其实很好懂,首先把前缀去掉,
//剩下的List 中的 是必须
//重复一次的参数表
{
a = new T[n]; //使a成为一个成员为T类型的数据的数组
for (int i=0; i
a[i]=(T)(i+47); //给a数组分配内容
size = n;
}

template List ::~List()
{
delete[] a;
}

template T List ::operator[ ](int i) //注意List 前的T是
//这个函数的返回值的类型
{
return a[i]+1; //和普通的[]有一点小小的区别?
}

void main()
{
List c(10); // 给模板提供参数,说明T即char
//我们完全可以把char看成是一个类
for (int i=0;i
cout<
}

我们可以用类似的方法定义有多个参数的模板,比如:
#include
using namespace std;

template class List
{
private:
T *a;
public:
int size;
List();
T operator[ ](int i);
~List();
};

template List ::List()
{
a = new T[U];
for (int i=0; i
size = U;
}

template T List ::operator[ ](int i)
{
return a[i]+1;
}

template List ::~List()
{
delete[] a;
}

void main()
{
List c; //注意,你只能把常数赋值给模板的"实际"参数
//因为模板的本质是直接替换!就像#define一样
//所以你不用担心它的效率?
for (int i=0;i
cout<
}

下面我们再来看看如何用模板定义函数:
#include
using namespace std;

template T print(T n); //和定义类时差不多,返回值为T类型,参数
//n也为T类型

template T print(T n)
{
cout< <
return n;
}

void main()
{
float x=3.14;
print(x);
char y='m';
cout<
}

大家是不是觉得有点像函数重载呢?不过不必浪费时间去写几乎完全一样的函数了。还记得1.3节所介绍的用#include定义的函数吗?它的好处是适用于所有数据类型,但现在,我们用模板也可以实现完全相同的功能了。注意编译器实现模板的办法实际上也是根据数据类型的多少创建一堆差不多的类或函数。
其实模板的引入就像当初类的引入一样有着重大的的意义,一种新的编程思想应运而生:Generic Programming (GP)。这种编程思想的核心是使算法抽象化,从而可以适用于一切数据类型。著名的STL(Standard Template Library)就是这种思想的应用成果。感兴趣的读者可以自己找一些这方面的书看看,对自己的编程水平的提高会有好处。

2.8 优化程序
首先提醒大家一句,再好的语句上的优化也比不上算法上的优化所带来的巨大效益,所以我觉得对这方面不太熟悉的人都应该买本讲数据结构与算法的书来看看。在第八章讲述了几种常用的算法,如果你感兴趣可以看看。
下面就转入正题,讲一讲一般的优化技巧吧:
(1)使用内联函数。

(2)展开循环。
for (i = 0; i < 100; i++)
{
do_stuff(i);
}
可以展开成:
for (i = 0; i < 100; )
{
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
do_stuff(i); i++;
}

(3)运算强度减弱。
例如如果有这样一段程序:
int x = w % 8;
int y = x * 33;
float z = x/5;
for (i = 0; i < 100; i++)
{
h = 14 * i;
cout<
}

上面的程序这样改动可以大大加快速度:
int x = w & 7;
int y = (x << 5) + x; //<<比+的运算优先级低!
float z = x*0.2;
for (i = h = 0; i < 100; i++)
{
cout<
h += 14;
}

(4)查表。这种方法挺有用。比如说我们定义了一个函数f(x)可以返回x*x*x,其中x的范围是0~1,精度为0.001,那么我们可以建立一个数组a[1000],a[t*1000]存储的是预先计算好的t*t*t的值,以后调用函数就可以用查找数组代替了。

2.9 调试程序
每个人都会犯错误,在编程中也如此。写完程序后第一次运行就直接通过的情况实在是不多的,偶尔出现一两次都是值得高兴的事。有错当然要改,但很多时候最难的并不是改正错误,而是找到错误,有时候写程序的时间还不如找错误的时间长。为了帮助大家节省一点时间,下面就讲一讲一点找错误的经验。
首先当然要说说常见的错误有哪些,最经常出现的是:漏分号、多分号、漏各种括号、多各种括号、"=="写成了"="(上面的错误看上去很弱智,不过也容易犯)、数组(指针)越界(最常见的错误之一!)、变量越界、指针使用前未赋初值、释放了指针之后继续使用它……等等。如果你的程序有时出错有时又不出错,很可能就是指针的问题。
有一点要注意的是VC.net显示出的出错的那一行有可能不是真正出错的位置!
常用的找错办法就是先确认你刚刚改动了哪些语句,然后用/*和*/把可能出错的语句屏障掉,如果运行后还不通过就再扩大范围。即使有一段程序你觉得不可能有什么问题或以前工作正常也要试试将它屏障,有时就是在似乎最不可能出错的地方出了问题。
还有一种大家都经常用的找错办法就是把一些变量的值显示在屏幕上,或是把程序运行的详细过程存入文件中,出什么问题一目了然。如果再像QuakeIII一样用一个"控制台"显示出来就很酷了。
象其它编译器一样,VC.net提供了变量观察(Watch)、单步执行(Step)等常规调试手段,当然你首先需要把工程设为Debug模式。然后设置好断点(在要设置断点的那一行左边的灰色区域按一下即可,会出现一个红圆,程序运行到此处会暂停),按F5就可以开始调试。此时会出现一个调试工具栏:

图2.2 调试工具栏
图标的意义分别为:执行此语句,停止此语句的执行,停止调试,重新调试,显示即将执行的语句,调试入函数,跳过函数,调试出此{},用十六进制显示数据,显示断点情况。
大家还会注意到左下角出现了一个变量观察窗口,在这里可以非常方便地观察变量的值和改变情况。
我们还可以打开反汇编窗口、内存观察窗口和寄存器观察窗口,它们可是威力无比的,用起来非常爽。观察编译器生成的代码也是深入了解C++语言华丽的外表背后的真相的好办法。
VC++还提供了两条调试语句可以帮助你调试,第一条语句是assert。它的使用方法是assert(条件),你可以把它放到需要的地方,当条件不满足时就会显示一个对话框,说明在哪个程序哪一行出现了条件不满足,然后你可以选择停止,继续或是忽略。这条语句非常有用,因为直接执行程序时(而不是在VC++中调试)它也能工作。第二条语句是OutputDebugString (要输出的字符串),可以在屏幕下方编译窗口的调试那一栏显示这个字符串。
利用VC.net的开发环境调试是一项十分方便的事,只要你多调试(这不用刻意追求,因为它不可避免?),一定可以越来越熟练。
本书的C++语言部分到此可以告一段落了,但这里所讲述的只是C++语言的冰山一角,因为C++语言可被称为博大精深,而且它还在不断发展。希望大家在以后的日子里不要停止对C++语言的学习和研究,你一定会不断有新的感受和发现。最后推荐两本必读的好书:Scott Douglas Meyers的Effective C++和More Effective C++(其中不少内容我已经穿插到了前面的文字中?)。
第三章 容纳游戏的空间

因为我们编好的游戏将在Windows下运行,所以学习一点Windows编程知识是必需的。Microsoft为了方便Windows编程制作了一个庞大的类库MFC,把Windows的方方面面都封装了起来。但此类库只是比较适合编写字板之类的标准Windows程序,对于游戏来说它实在是过于烦琐和累赘,所以我们一般都不使用它,自己从头用Windows API(Application Programming Interface 应用编程接口,其实就是一堆Windows为开发者提供的函数)写Windows程序。

3.1 基本Windows程序
最基本的Windows程序看起来都有点长,它的流程图是这样的:
图3.1
但你不必担心Windows编程过于复杂。在所有的Windows程序中,都需要一个初始化的过程,而这个过程对于任何Windows程序而言,都是大同小异的。你也许会想到使用VB做一个最简单的程序不用敲一行代码,其实这是因为VB已经暗地里帮你敲好了。

#include

//函数声明
BOOL InitWindow( HINSTANCE hInstance, int nCmdShow );
LRESULT CALLBACK WinProc( HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam );

//变量说明
HWND hWnd; //窗口句柄
//************************************************************
//函数:WinMain( )
//功能:Windows程序入口函数。创建主窗口,处理消息循环
//************************************************************
int PASCAL WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
if ( !InitWindow( hInstance, nCmdShow ) ) return FALSE; //创建主窗口
//如果创建不成功则返回FALSE并同时退出程序
MSG msg;
//进入消息循环:
for(;;)
{
if(PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
if ( msg.message==WM_QUIT) break;
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
return msg.wParam;
}

//************************************************************
//函数:InitWindow( )
//功能:创建窗口
//************************************************************

static BOOL InitWindow( HINSTANCE hInstance, int nCmdShow )
{
//定义窗口风格:
WNDCLASS wc;
wc.style = NULL;
wc.lpfnWndProc = (WNDPROC)WinProc;
wc.cbClsExtra = 0;
wc.cbWndExtra = 0;
wc.hInstance = hInstance;
wc.hIcon = NULL;
wc.hCursor = NULL;
wc.hbrBackground = CreateSolidBrush (RGB(100, 0, 0)); //暗红色的背景
wc.lpszMenuName = NULL;
wc.lpszClassName = "My_Test";
RegisterClass(&wc);//注册窗口
//按所给参数创造窗口
hWnd = CreateWindow("My_Test",
"My first program",
WS_POPUP|WS_MAXIMIZE,0,0,
GetSystemMetrics( SM_CXSCREEN ), //此函数返回屏幕宽度
GetSystemMetrics( SM_CYSCREEN ), //此函数返回屏幕高度
NULL,NULL,hInstance,NULL);
if( !hWnd ) return FALSE;
ShowWindow(hWnd,nCmdShow);//显示窗口
UpdateWindow(hWnd);//刷新窗口
return TRUE;
}

//************************************************************
//函数:WinProc( )
//功能:处理窗口消息
//************************************************************

LRESULT CALLBACK WinProc( HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam )
{
switch( message )
{
case WM_KEYDOWN://击键消息
switch( wParam )
{
case VK_ESCAPE:
MessageBox(hWnd,"ESC键按下了! 确定后退出!","Keyboard",MB_OK);
PostMessage(hWnd, WM_CLOSE, 0, 0);//给窗口发送WM_CLOSE消息
break;
}
return 0; //处理完一个消息后返回0

case WM_CLOSE: //准备退出
DestroyWindow( hWnd ); //释放窗口
return 0;

case WM_RBUTTONDOWN:
MessageBox(hWnd,"鼠标右键按下了!","Mouse",MB_OK);
return 0;

case WM_DESTROY: //如果窗口被人释放…
PostQuitMessage( 0 ); //给窗口发送WM_QUIT消息
return 0;
}
//调用缺省消息处理过程
return DefWindowProc(hWnd, message, wParam, lParam);
}

按1.1节的方法建立一个工程后,输入程序,按Ctrl+F5执行一下,就会出现一个暗红色的"窗口"。然后你可以试试按按鼠标右键或Esc键看看效果,就像图3. 2。怎么样?VB要做到同样的效果恐怕有点麻烦,这也算是从头写代码的一点好处吧。

图3.2

3.2 WinMain函数
3.2.1 简介
WinMain( )函数与DOS程序的main ( )函数基本起同样的作用,但有一点不同的是WinMain( )函数必须带有四个系统传递给它的参数。WinMain( )函数的原型如下:
int PASCAL WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
LPSTR lpCmdLine, int nCmdShow)
第一个参数hInstance是标识该应用程序的句柄。不过句柄又是什么呢?其实就是一个指向该程序所占据的内存区域的指针,它唯一地代表了该应用程序,Windows使用它管理内存中的各种对象。当然,它十分重要。在后面的初始化程序主窗口的过程中就需要使用它作为参数。
第二个参数是hPrevInstance,给它NULL吧,这个参数只是为了保持与16位Windows的应用程序的兼容性。
第三个参数是lpCmdLine,是指向应用程序命令行参数字符串的指针。比如说我们运行"test hello",则此参数指向的字符串为"hello"。
最后一个参数是nCmdShow,是一个用来指定窗口显示方式的整数。关于窗口显示方式的种类,将在下面说明。

3.2.2 注册窗口类
一个程序可以有许多窗口,但只有一个是主窗口,它是与该应用程序唯一对应的。
创建窗口前通常要填充一个窗口类WNDCLASS,并调用RegisterClass( )对该窗口类进行注册。每个窗口都有一些基本的属性,如窗口标题栏文字、窗口大小和位置、鼠标、背景色,窗口消息处理函数(后面会讲这个函数)的名称等等。注册的过程就是将这些属性告诉系统,然后再调用CreateWindow( )函数创建出窗口。
下面列出了WNDCLASS的成员:

UINT style; //窗口的风格
WNDPROC lpfnWndProc; //窗口消息处理函数的指针
int cbClsExtra; //分配给窗口类结构之后的额外字节数
int cbWndExtra; //分配给窗口实例之后的额外字节数
HANDLE hInstance; //窗口所对应的应用程序的句柄
HICON hIcon; //窗口的图标
HCURSOR hCursor; //窗口的鼠标
HBRUSH hbrBackground; //窗口的背景
LPCTSTR lpszMenuName; //窗口的菜单资源名称
LPCTSTR lpszClassName; //窗口类的名称

WNDCLASS的第一个成员style表示窗口类的风格,它往往是由一些基本的风格通过位的"或"操作(操作符"|")组合而成。下表列出了一些常用的基本窗口风格:
表3.1
风格 含义
CS_HREDRAW 如果窗口宽度发生改变,重绘整个窗口
CS_VREDRAW 如果窗口高度发生改变,重绘整个窗口
CS_DBLCLKS 能感受用户在窗口中的双击消息
CS_NOCLOSE 禁用系统菜单中的"关闭"命令
CS_SAVEBITS 把被窗口遮掩的屏幕图像部分作为位图保存起来。当该窗口被移动时,Windows使用被保存的位图来重建屏幕图像
第二个成员是lpfnWndProc,给它消息处理函数的函数名称即可,必要时应该进行强制类型转换,将其转换成WNDPROC型。
接下来的cbClsExtra和wc.cbWndExtra一般都可以设为0。
然后的hInstance成员,给它的值是窗口所对应的应用程序的句柄,表明该窗口与此应用程序是相关联的。
下面的hIcon是让我们给这个窗口指定一个图标,这个程序没有设置。
鼠标也没有设置,因为编游戏时的鼠标都是在刷新屏幕时自己画上去的。
hbrBackground成员用来定义窗口的背景色。这里设为CreateSolidBrush (RGB(100, 0, 0)),即暗红色。关于CreateSolidBrush函数,请参阅4.10节。
lpszMenuName成员的值我们给它NULL,表示该窗口没有菜单。
WNDCLASS的最后一个成员lpszClassName是让我们给这个窗口类起一个独一无二的名称,因为Windows操作系统中有许许多多的窗口类。通常,我们可以用程序名来命名这个窗口类的名称。在调用CreateWindow( )函数时将要用到这个名称。
填充完WNDCLASS后,我们需要调用RegisterClass( )函数进行注册;该函数如调用成功,则返回一个非0值,表明系统中已经注册了这个窗口类。如果失败,则返回0。

3.2.3 创建窗口
当窗口类注册完毕之后,我们就可以创建一个窗口,这是通过调用CreateWindow( )函数完成的。窗口类中已经预先定义了窗口的一般属性,而在CreateWindow( )中的参数中可以进一步指定窗口更具体的属性。下面举一个例子来说明CreatWindow( )的用法:

hwnd = CreateWindow(
"Simple_Program", //创建窗口所用的窗口类的名称
"A Simple Windows Program", //窗口标题
WS_OVERLAPPEDWINDOW, //窗口风格,定义为普通型
100, //窗口位置的x坐标
100, //窗口位置的y坐标
400, //窗口的宽度
300, //窗口的高度
NULL, //父窗口句柄
NULL, //菜单句柄
hInstance, //应用程序句柄
NULL ); //一般都为NULL

第一个参数是创建该窗口所使用的窗口类的名称,注意这个名称应与前面所注册的窗口类的名称一致。
第三个参数为创建的窗口的风格,下表列出了常用的窗口风格:
表3.2
风格 含义
WS_OVERLAPPEDWINDOW 创建一个层叠式窗口,有边框、标题栏、系统菜单、最大最小化按钮,是以下几种风格的集合:WS_OVERLAPPED, WS_CAPTION, WS_SYSMENU, WS_THICKFRAME, WS_MINIMIZEBOX, WS_MAXIMIZEBOX
WS_POPUPWINDOW 创建一个弹出式窗口,是以下几种风格的集合: WS_BORDER, WS_POPUP, WS_SYSMENU。必须再加上WS_CAPTION与才能使窗口菜单可见。
WS_OVERLAPPED & WS_TILED 创建一个层叠式窗口,它有标题栏和边框。
WS_POPUP 该窗口为弹出式窗口,不能与WS_CHILD同时使用。
WS_BORDER 窗口有单线边框。
WS_CAPTION 窗口有标题栏。
WS_CHILD 该窗口为子窗口,不能与WS_POPUP同时使用。
WS_DISABLED 该窗口为无效,即对用户操作不产生任何反应。
WS_HSCROLL / WS_VSCROLL 窗口有水平滚动条 / 垂直滚动条。
WS_MAXIMIZE / WS_MINIMIZE 窗口初始化为最大化 / 最小化。
WS_MAXIMIZEBOX / WS_MINIMIZEBOX 窗口有最大化按钮 / 最小化按钮
WS_SIZEBOX & WS_THICKFRAME 边框可进行大小控制的窗口
WS_SYSMENU 创建一个有系统菜单的窗口,必须与WS_CAPTION风格同时使用
WS_TILED 创建一个层叠式窗口,有标题栏
WS_VISIBLE 窗口为可见
在DirectX编程中,我们一般使用的是WS_POPUP | WS_MAXIMIZE,用这个标志创建的窗口没有标题栏和系统菜单且窗口为最大化,可以充分满足DirectX编程的需要。
如果窗口创建成功,CreateWindow( )返回新窗口的句柄,否则返回NULL。

3.2.4 显示和更新窗口
窗口创建后,并不会在屏幕上显示出来,要真正把窗口显示在屏幕上,还得使用ShowWindow( )函数,其原型如下:
BOOL ShowWindow( HWND hWnd, int nCmdShow );
参数hWnd就是要显示的窗口的句柄。
nCmdShow是窗口的显示方式,一般给它WinMain( )函数得到的nCmdShow的值就可以了。常用的窗口显示方式有:
表3.3
方式 含义
SW_HIDE 隐藏窗口
SW_MINIMIZE 最小化窗口
SW_RESTORE 恢复并激活窗口
SW_SHOW 显示并激活窗口
SW_SHOWMAXIMIZED 最大化并激活窗口
SW_SHOWMINIMIZED 最小化并激活窗口
ShowWindow( )函数的执行优先级不高,当系统正忙着执行其它的任务时窗口不会立即显示出来。所以我们使用ShowWindow( )函数后还要再调用UpdateWindow(HWND hWnd); 函数以保证立即显示窗口。

3.2.5 消息循环
在WinMain( )函数中,调用InitWindow( )函数成功地创建了应用程序主窗口之后,就要启动消息循环,其代码如下:
for(;;)
{
if(PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
if ( msg.message==WM_QUIT) break;
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
Windows应用程序可以接收各种形式的信息,这包括键盘和鼠标的动作、记时器消息,其它应用程序发来的消息等等。Windows系统会自动将这些消息放入应用程序的消息队列中。
PeekMessage( )函数就是用来从应用程序的消息队列中按照先进先出的原则将这些消息一个个的取出来,放进一个MSG结构中去。如果队列中没有任何消息,PeekMessage( )函数将立即返回。如果队列中有消息,它将取出一个后返回。
MSG结构包含了一条Windows消息的完整信息,它由下面的几部分组成:

HWND hwnd; //接收消息的窗口句柄
UINT message; //主消息值
WPARAM wParam; //副消息值1,其具体含义依赖于主消息值
LPARAM lParam; //副消息值2,其具体含义依赖于主消息值
DWORD time; //消息被投递的时间
POINT pt; //鼠标的位置

该结构中的主消息表明了消息的类型,例如是键盘消息还是鼠标消息等。副消息的含义则依赖于主消息值,比如说如果主消息是键盘消息,那么wParam中存储了是键盘的哪个具体键;如果主消息是鼠标消息,那么LOWORD(lParam)和HIWORD(lParam)分别为鼠标位置的x和y坐标;如果主消息是WM_ACTIVATE,wParam就表示了程序是否处于激活状态。这里顺便说一下,定义一个POINT类型的变量curpos后,在程序的任意位置使用GetCursorPos(&curpos)都可以将鼠标坐标存储在curpos.x和curpos.y中。
PeekMessage( )函数的原型如下:

BOOL PeekMessage (
LPMSG lpMsg, //指向一个MSG结构的指针,用来保存消息
HWND hWnd, //指定哪个窗口的消息将被获取
UINT wMsgFilterMin, //指定获取的主消息值的最小值
UINT wMsgFilterMax, //指定获取的主消息值的最大值
UINT wRemoveMsg //得到消息后是否移除消息
);

PeekMessage( )的第一个参数的意义上面已解释。
第二个参数是用来指定从哪个窗口的消息队列中获取消息,其它窗口的消息将被过滤掉。如果该参数为NULL,则PeekMessage( )从该应用程序所有窗口的消息队列中获取消息。
第三个和第四个参数是用来过滤MSG结构中主消息值的,主消息值在wMsgFilterMin和wMsgFilterMax之外的消息将被过滤掉。如果这两个参数均为0,表示接收所有消息。
第五个参数用来设置分发完消息后是否将消息从队列中移除,一般设为PM_REMOVE即移除。
TranslateMessage( )函数的作用是把虚拟键消息转换到字符消息,以满足键盘输入的需要。DispatchMessage( )函数所完成的工作是把当前的消息发送到对应的窗口过程中去。
开启消息循环其实是很简单的一个步骤,几乎所有的程序都是按照Test的这个方法。我们完全不必去深究这些函数的作用,只是简单的照抄就可以了。
另外,这里介绍的消息循环开启方法比某些书上所介绍的用GetMessage( )的方法要好一些,因为GetMessage( )如果得不到消息会一直等待,结果就耗费了许多宝贵的时间,使游戏不能及时刷新。

3.3 消息处理函数
消息处理函数又叫窗口过程,在这个函数中,不同的消息将被switch语句分配到不同的处理程序中去。Windows的消息处理函数的原型是这样定义的:

LRESULT CALLBACK WindowProc(
HWND hwnd, //接收消息窗口的句柄
UINT uMsg, //主消息值
WPARAM wParam, //副消息值1
  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值