游戏编程指南
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
LPARAM lParam //副消息值2
);
消息处理函数必须按照上面的这个样式来定义,当然函数名称可以随便取。
Test中的WinProc( )函数就是一个典型的消息处理函数。在这个函数中明确的处理了3个消息,分别是WM_KEYDOWN(击键)、WM_RBUTTONDOWN(鼠标右键按下)、WM_CLOSE(关闭窗口)、WM_DESTROY(销毁窗口)。值得注意的是,应用程序发送到窗口的消息远远不止以上这几条,象WM_SIZE、WM_MINIMIZE、WM_CREATE、WM_MOVE等频繁使用的消息就有几十条。在附录中可以查到Windows常见消息列表。
为了减轻编程的负担,Windows提供了DefWindowProc( )函数来处理这些最常用的消息,调用了这个函数后,这些消息将按照系统默认的方式得到处理。因此,在消息处理函数中,只须处理那些有必要进行特别响应的消息,其余的消息都可交给DefWindowProc( )函数来处理。
3.4 常用Windows函数
3.4.1 显示对话框
MessageBox函数可以用来显示对话框,它的原形是:
int MessageBox(HWND hwndParent, LPCSTR lpszText, LPCSTR lpszTitle, UINT fuStyle);
其中的四个参数依次为:窗口句柄,文字内容,标题,风格。常用风格有:MB_OK、MB_OKCANCEL、MB_RETRYCANCEL、MB_YESNO、MB_YESNOCANCEL,代表对话框有哪些按钮。常用返回值有IDCANCEL、IDNO、IDOK、IDRETRY、IDYES,代表哪个按钮被按下。
3.4.2 定时器
定时器可以使程序每隔一段时间执行一个函数。用法如下:
SetTimer(HWND hwnd, UINT ID, UINT Elapse, TIMERPROC TimerFunc);
四个参数依次为窗口句柄、定时器标识(同一程序内各个定时器的标识应不相同,一般从1、2、3...一直排下去)、每隔多少毫秒(千分之一秒)执行一次程序,要执行的过程。
这个要执行的过程应这样定义:
void CALLBACK MyTimer(HWND hwnd,UINT uMsg,UINT idEvent,DWORD dwTime);
这几个规定的参数都没什么用,我们在过程里作自己的事就行了,不用理这几个给我们的参数。
注意:定时器的优先级不高,当处理器很忙时我们需要定时执行的程序常常不能按时地执行;无论你把定时器的Elapse设得多小,它实际上最小只能是55ms;有的Windows函数在TimerFunc中用不了,而且在TimerFunc里不要做一些费时间的东西。
3.4.3 得到时间
我们经常需要在程序中得到当前的准确时间来完成测试速度等工作。这时我们可以使用GetTickCount( ),因为该函数可以返回Windows已经运行了多少毫秒。然而有时我们需要得到更准确的时间,这时可使用这种方法:
__int64 time2, freq; //时间,计时器频率
double time; //以秒为单位的时间
QueryPerformanceCounter((LARGE_INTEGER*)&time2); //得到计时开始的时间
QueryPerformanceFrequency((LARGE_INTEGER*)&freq); //得到计时器频率
time = (double)(time2) / (double)freq; //将时间转为以秒为单位
3.4.4 播放声音
我们可以使用MCI来简易地实现在程序中播放MIDI和WAV等声音。使用它需要预先声明,我们需要在文件头#include ,并在工程中加入"winmm.lib"
下面先让我们看看播放MIDI的过程。首先我们要打开设备:
MCI_OPEN_PARMS OpenParms;
OpenParms.lpstrDeviceType =
(LPCSTR) MCI_DEVTYPE_SEQUENCER; //是MIDI类型文件
OpenParms.lpstrElementName = (LPCSTR) filename; //文件名
OpenParms.wDeviceID = 0; //打开的设备的标识,后面需要使用
mciSendCommand (NULL, MCI_OPEN,
MCI_WAIT | MCI_OPEN_TYPE |
MCI_OPEN_TYPE_ID | MCI_OPEN_ELEMENT,
(DWORD)(LPVOID) &OpenParms); //打开设备
接着就可以播放MIDI了:
MCI_PLAY_PARMS PlayParms;
PlayParms.dwFrom = 0; //从什么时间位置播放,单位为毫秒
mciSendCommand (DeviceID, MCI_PLAY, //DeviceID需等于上面的设备标识
MCI_FROM, (DWORD)(LPVOID)&PlayParms); //播放MIDI
停止播放:
mciSendCommand (DeviceID, MCI_STOP, NULL, NULL);
最后要关闭设备:
mciSendCommand (DeviceID, MCI_CLOSE, NULL, NULL);
打开WAV文件与打开MIDI文件的方法几乎完全相同,只是需要将MCI_DEVTYPE_SEQUENCER 改为MCI_DEVTYPE_WAVEFORM_AUDIO。
第四章 描绘游戏的画笔
看完上一章后,我们已经大致地掌握了Windows编程的方法。这意味着我们为进行DirectDraw编程打下了坚实的基础。DirectDraw是DirectX的重要组成部分,它就像一支画笔,主要负责各种把各种图像显示在屏幕上,对Windows环境中的游戏非常重要。现在就让我们进入激动人心的DirectDraw部分吧。
4.1 初始化DirectDraw
为了告诉编译器我们需要使用DirectDraw,我们要在程序文件中#include ,并把"ddraw.lib"和"dxguid.lib"加入工程(如果你不会加,请看1.1节)。记住,做完了这些工作后DirectDraw程序才能被正常编译。
4.1.1 简介
让我们先看一看一个常见的DirectDraw初始化函数:
LPDIRECTDRAW7 lpDD; // DirectDraw对象的指针
LPDIRECTDRAWSURFACE7 lpDDSPrimary; // DirectDraw主页面的指针
LPDIRECTDRAWSURFACE7 lpDDSBuffer; // DirectDraw后台缓存的指针
LPDIRECTDRAWSURFACE7 lpDDSBack; // 存放背景图的页面的指针
BOOL InitDDraw( )
{
DDSURFACEDESC2 ddsd; // DirectDraw的页面描述
if ( DirectDrawCreateEx (NULL, (void **)&lpDD, IID_IDirectDraw7, NULL) != DD_OK )
return FALSE; //创建DirectDraw对象
//这里使用了 if ( xxx != DD_OK) 的方法进行错误检测,这是最常用的方法
if (lpDD->SetCooperativeLevel(hwnd,DDSCL_EXCLUSIVE|DDSCL_FULLSCREEN) != DD_OK )
return FALSE; //设置DirectDraw控制级
if ( lpDD->SetDisplayMode( 640, 480, 32, 0, DDSDM_STANDARDVGAMODE ) != DD_OK )
return FALSE; //设置显示模式
//开始创建主页面,先清空页面描述
memset(&ddsd, 0, sizeof(DDSURFACEDESC2));
//填充页面描述
ddsd.dwSize = sizeof( ddsd );
ddsd.dwFlags = DDSD_CAPS|DDSD_BACKBUFFERCOUNT; //有后台缓存
ddsd.ddsCaps.dwCaps = DDSCAPS_PRIMARYSURFACE|DDSCAPS_FLIP|DDSCAPS_COMPLEX;
ddsd.dwBackBufferCount = 1; //一个后台缓存
if ( lpDD->CreateSurface( &ddsd, &lpDDSPrimary, NULL ) != DD_OK )
return FALSE; //创建主页面
ddsd.ddsCaps.dwCaps = DDSCAPS_BACKBUFFER; //这是后台缓存
if ( DD_OK != lpDDSPrimary->GetAttachedSurface( &ddsd.ddsCaps, &lpDDSBuffer ) )
return FALSE; //创建后台缓存
ddsd.dwSize = sizeof( ddsd );
ddsd.dwFlags = DDSD_CAPS|DDSD_WIDTH|DDSD_HEIGHT;
ddsd.ddsCaps.dwCaps = DDSCAPS_OFFSCREENPLAIN; //这是离屏页面
ddsd.dwHeight=480; //高
ddsd.dwWidth=640; //宽
if ( DD_OK != lpDD->CreateSurface( &ddsd, &lpDDSBack, NULL ) )
return FALSE; //创建放背景图的页面
//如还有别的页面可在此处继续创建
return TRUE;
}
我们可以看到,在开头首先定义了指向DirectDraw对象和DirectDraw页面(又称DirectDraw表面)对象的指针。LPDIRECTDRAW7和LPDIRECTDRAWSURFACE7类型(7是版本号)是在ddraw.h头文件里预定义的,指向IDirectDraw7和IDirectDrawSurface7类型的长型指针(前面加的LP代表Long Point),从后面我们用的是"->"而不是"."也可以看出这一点。DD是DirectDraw的缩写,DDS是DirectDrawSurface的缩写,因此习惯上我们把变量名起为lpDD和lpDDSXXX。
大家需要注意的是:虽然VC.net自带的DirectX SDK是8.1版的,但是由于Microsoft从DirectX 8.0起停止了对DirectDraw的更新,所以DirectDraw目前的最新版本还是7.0。
4.1.2 DirectDraw对象
如果要使用DirectDraw,必须创建一个DirectDraw对象,它是DirectDraw接口的核心。用DirectDrawCreateEx( )函数可以创建DirectDraw对象,DirectDrawCreateEx( )函数是在ddraw.h中定义的,它的原型如下:
HRESULT WINAPI DirectDrawCreateEx(
GUID FAR *lpGUID,
LPVOID *lplpDD,
REFIID iid,
IUnknown FAR *pUnkOuter
);
第一个参数是lpGUID:指向DirectDraw接口的全局唯一标志符(Global Unique IDentify)的指针。在这里,我们给它NULL,表示我们将使用当前的DirectDraw接口。
第二个参数是lplpDD:这个参数是用来接受初始化的DirectDraw对象的地址。在这里,我们给它用强制类型转换为void**类型的&lpdd(传递指针的指针,这样这个函数才能改变指针的指向)。
第三个参数是iid:给它IID_IDirectDraw7吧,表示我们要创建IDirectDraw7对象。
第四个参数是pUnkOuter:目前必须是NULL。
所有的DirectDraw函数的返回值都是HRESULT类型,它是一个32位的值。函数调用成功用 "DD_OK"表示,所有的错误值标志开头都为"DDERR",如:
DDERR_DIRECTDRAWALREADYCREATED
DDERR_OUTOFMEMORY
在附录中可查到这些错误值的列表。
我们一般用"if ( DirectDrawCreateEx (NULL, (void **)&lpDD, IID_IDirectDraw7, NULL) != DD_OK ) return FALSE;"来创建DirectDraw对象,这样当创建不成功时就会退出函数并返回FALSE。
4.1.3 设置控制级和显示模式
DirectDrawCreate函数调用成功后,lpDD已经指向了一个DirectDraw对象,它是整个DirectDraw接口的最高层领导,以后的步骤都是在它的控制之下。
我们用IDirectDraw7::SetCooperativeLevel( )来设置DirectDraw程序对系统的控制级。它的原型如下:
HRESULT SetCooperativeLevel (HWND hWnd, DWORD dwFlags )
第一个参数是窗口句柄,我们给它hWnd,使DirectDraw对象与主窗口联系上。
第二个参数是控制级标志。这里使用DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN,表示我们期望DirectDraw以独占和全屏方式工作。
控制级描述了DirectDraw是怎样与显示设备及系统作用的。DirectDraw控制级一般被用来决定应用程序是运行于全屏模式(必须与独占模式同时使用),还是运行于窗口模式。但DirectDraw的控制级还可设置如下两项:
(1)允许按Ctrl + Alt + Del重新启动(仅用于独占模式,为DDSCL_ALLOWREBOOT)。
(2)不允许对DirectDraw应用程序最小化或还原 (DDSCL_NOWINDOWCHANGES)。
普通的控制级(DDSCL_NORMAL)表明我们的DirectDraw应用程序将以窗口的形式运行。在这种控制级下,我们将不能改变显示器分辨率,或进行换页操作(这是一个重要的操作,在4.2节会介绍)。除此之外,我们也不能够调用那些会对显存产生激烈反应的函数,如第五章要讲的Lock( )。
当应用程序为全屏并且独占的控制级时,我们就可以充分的利用硬件资源了。此时其它应用程序仍可创建页面、使用DirectDraw或GDI的函数,只是无法改变显示模式。
下一步我们使用IDirectDraw7::SetDisplayMode( )来设置显示模式,其原形为:
HRESULT SetDisplayMode(
DWORD dwWidth,
DWORD dwHeight,
DWORD dwBPP,
DWORD dwRefreshRate,
DWORD dwFlags
);
dwWidth and dwHeight用来设置显示模式的宽度和高度。
dwBPP用来设置显示模式的颜色位数。
dwRefreshRate设置屏幕的刷新率,0为使用默认值。
dwFlags现在唯一有效的值是DDSDM_STANDARDVGAMODE。
4.1.4 创建页面
下一步是创建一个DirectDrawSurface对象。
DirectDrawSurface对象代表了一个页面。你可以把页面想象为一张张可供DirectDraw描绘的画布。页面可以有很多种表现形式,它既可以是可见的,称为主页面(Primary Surface);也可以是作换页用的不可见页面,称为后台缓存(Back Buffer),在换页后,它成为可见(换页在4.2节会讲);还有一种始终不可见的,称为离屏页面(Off-screen Surface),它的作用是存储图像。其中,最重要的页面是主页面,每个DirectDraw应用程序都必须创建至少一个主页面,一般来说它就代表着我们的屏幕。
创建一个页面之前,首先需要填充一个DDSURFACEDESC2结构,它是DirectDraw Surface Description的缩写,意思是DirectDraw的页面描述。它的结构非常庞大,这里只能作一个最简单的介绍。要注意的是在填充此结构前一定要将其清空!下面是一个典型的主页面的页面描述:
ddsd.dwSize = sizeof( ddsd ); //给dwSize页面描述的大小
ddsd.dwFlags = DDSD_CAPS|DDSD_BACKBUFFERCOUNT; //有后台缓存
ddsd.ddsCaps.dwCaps=DDSCAPS_PRIMARYSURFACE|DDSCAPS_FLIP|DDSCAPS_COMPLEX; //为主页面,有后台缓存,有换页链
ddsd.dwBackBufferCount = 1; //一个后台缓存
再看看一个普通表面的页面描述:
ddsd.dwSize = sizeof( ddsd );
ddsd.dwFlags = DDSD_CAPS|DDSD_WIDTH|DDSD_HEIGHT; //高、宽由我们指定
ddsd.ddsCaps.dwCaps = DDSCAPS_OFFSCREENPLAIN; //这是离屏页面
ddsd.dwHeight=480; //高
ddsd.dwWidth=640; //宽
页面描述填充完毕后,把它传递给IDirectDraw7::CreateSurface( )方法即可创造页面。CreateSurface( )的原形是:
HRESULT CreateSurface(
LPDDSURFACEDESC2 lpDDSurfaceDesc,
LPDIRECTDRAWSURFACE FAR *lplpDDSurface,
IUnknown FAR *pUnkOuter
);
CreateSurface( )函数的第一个参数是被填充了页面信息的DDSURFACEDESC2结构的地址,此处为&ddsd;第二个参数是接收主页面指针的地址,此处为&lpDDSPrimary;第三个参数现在必须为NULL,为该函数所保留。
如果函数调用成功,lpDDSPrimary将成为一个合法的主页面对象。由于在前面已经设置了该程序的工作模式为独占和全屏,所以,此时主页面所代表的实际上是我们的整个显示屏幕。在主页面上所绘制的图形将立即反映到我们的显示屏幕上。
DirectDraw初始化函数最后创造了一个离屏页面,如果我们想创造更多的页面,由于页面描述已被填充好,只需接着它像下面这样先设置高度和宽度再创建页面即可:
ddsd.dwHeight=XXX;
ddsd.dwWidth=XXX;
if ( DD_OK != lpDD->CreateSurface( &ddsd, &lpDDSABC, NULL) )
return FALSE;
4.2 后台缓存和换页
图4.1
后台缓存和换页对形成无闪烁的动画至关重要。举一个例子,要显示一个物体在一张图片上运动,我们需要在a时刻先画物体,在b时刻把一开始被物体遮住的背景画好,最后在c时刻把物体画在新位置上。但这些操作需要一定时间,如果我们直接改主页面,那么b时刻用户就会看到画面上没有物体,但a和c时刻画面上又有物体,用户就会觉得画面有些闪烁。如何解决这个问题呢?
DirectDraw 中的换页就可以帮我们这个忙。首先,我们得设置好一个换页链结构,它由一组 DirectDraw 页面组成,每一个页面都可以被轮流换页至显示屏幕。当前正好位于显示屏幕的页面叫主页面。等待换页至屏幕的页面叫后台缓存。应用程序在后台缓存上进行绘图操作,然后将此页面换页成为主页面,原来的后台缓存就显示在屏幕上了,而原来的主页面就成为了后台缓存,在一般情况下我们只需改变后台缓存的内容。所以,我们在完成了b、c两个步骤后再换页即可避免闪烁现象。
我们既可以创建一个简单的双换页链结构(一个主页面和一个后台缓存),也可以创建一个有时候更快的多缓存换页链结构。一般我们只需像4.2.1节的例子那样使用双缓存换页链结构即可。
换页所使用的函数是IDirectDrawSurface7::Flip( )。它的原形是:
HRESULT Flip(LPDIRECTDRAWSURFACE lpDDSurface, DWORD dwFlags);
下面介绍它的参数:
(1)lpDDSurface
换页链中另一个页面的 IDirectDrawSurface7接口的地址,代表换页操作的目标页面。这个页面必须是换页链中的一员。该参数缺省值是 NULL, 在这种情况下, DirectDraw 从换页链中按照前后隶属关系依次换页。
(2)dwFlags
换页的标志选项,常用DDFLIP_WAIT,同BltFast中的DDBLTFAST_WAIT差不多。
一般我们这样即可换页:
lpDDSPrimary->Flip(NULL,DDFLIP_WAIT);
4.3 调入图像
初始化DirectDraw后,要将位图(*.bmp)调入页面是非常简单的,对于普通的真彩(24位)图像我们只需用DDReLoadBitmap(页面,"图像名.bmp")即可调入图像。这个函数在ddutil.cpp中,你需要把它和ddutil.h拷贝到你的程序的目录下,将其加入你的Project并在主程序的开头#include "ddutil.h"(在Chapter IV.zip中你可以找到我修改过以适应DirectDraw7的ddutil.h和ddutil.cpp)。注意,DDReLoadBitmap( )函数会自动缩放图像以适应页面的大小。
4.4 页面的丢失与恢复
当一个DirectDraw程序被最小化时,它就丧失了对页面的控制权,如果我们的程序不知道,继续改变页面时就会发生"DDERR_SURFACELOST"错误。而当我们重新回到DirectDraw程序时Windows不会帮我们把页面恢复,如果我们不自己恢复页面用户就会看到黑屏。为了避免出现这种情况,我们可以写一个恢复页面的函数:
void RestoreSurface( )
{
lpDD->RestoreAllSurfaces( ); //恢复所有页面
ReloadBitmap( );//自己的调图函数,重新画上页面内容
}
值得注意的是Windows也不会帮我们恢复页面的实际内容,我们要象上面的程序那样,再调用自己的调图函数才行。应该什么时候调用RestoreSurface( )呢?是不是每一条改变页面的语句都要测试一下有没有发生DDERR_SURFACELOST错误呢?其实并不需要。一般游戏的引擎都是频繁刷新式,每秒钟要刷新几十次,每一次刷新必然要调用上面提到的Flip( )。所以我们可以写一个FlipSurface( )然后以后调用它来换页:
void FlipSurface( )
{
HRESULT ddrval;
ddrval=lpDDSPrimary->Flip(NULL,DDFLIP_WAIT);
if (ddrval==DDERR_SURFACELOST)
RestoreSurface( );
}
4.5 透明色
透明色(又称关键色)对实现不规则物体的移动至关重要。所谓透明色,指的就是图像传送中不会被传送的区域的颜色。比如说我们先在一个纯绿色的背景上画了一个人物,把这幅画调入一个页面,再将纯绿色设为这个页面的透明色。以后当我们进行图像传送时,只需指定传送范围为一个包括了人物的矩形,DirectDraw将只会把不规则的人物传到新的页面上而不会把纯绿色的背景一起传送。当然,人物本身不能包含纯绿色,否则就不能完整地传送了。
一个页面在同一时刻只能有一种透明色,设置透明色的方法是DDSetColorKey(页面名,RGB(红,绿,蓝)); ,这里的"红""绿""蓝"即为平常我们在许多图像处理软件中看到的R/G/B值。例如DDSetColorKey(lpDDSMap, RGB(255,0,255));。
因为这个函数也是ddutil提供的,所以你要确认ddutil.cpp在工程内且#include "ddutil.h"。注意这个函数的调用不是很快,因为它还要用一个技巧转换一下你提供的颜色,以保证在任何色彩位数下透明色均能工作。事实上,如果你使用的是32位色,我们完全可以更快速地直接设置Color Key:
DDCOLORKEY ddck;
ddck.dwColorSpaceLowValue = RGB(x,x,x);
ddck.dwColorSpaceHighValue = ddck.dwColorSpaceLowValue;
lpDDSXXX->SetColorKey(DDCKEY_SRCBLT, &ddck);
4.6 图像传送
用DirectDraw做游戏,最常用的函数就是图像传送(又称位块传送)函数。它的作用是在各个页面之间传送指定矩形范围内的图像,并可同时对其进行各种处理。使用IDirectDrawSurface7::Blt( )和IDirectDrawSurface7::Bltfast( ) 函数可以进行图像传送。Blt( )函数功能很强大,可对图像进行缩放、旋转、镜象等操作。不过平常我们用简单但够用的Bltfast( )就可以了。它的原形是:
HRESULT BltFast(
DWORD dwX,
DWORD dwY,
LPDIRECTDRAWSURFACE lpDDSrcSurface,
LPRECT lpSrcRect,
DWORD dwTrans
);
下面将逐一介绍这几个参数:
(1)dwX和dwY
图像将被传送到目标页面何处。
(2)lpDDSrcSurface
图像传送操作的源页面。目标页面就是调用此方法的页面。
(3)lpSrcRect
一个 RECT (Rectangle,即矩形)结构的地址,指明源页面上将被传送的区域。如果该参数是 NULL, 整个源页面将被使用。 RECT结构在DirectDraw中非常常用,最好在程序中定义一个RECT类型的全局变量,如rect,再象这样写一个函数:
void MakeRect (int left, int top, int right, int bottom)
{
rect.bottom = bottom;
rect.left = left;
rect.right = right;
rect.top = top;
}
用时对它的left、top、right、bottom参数分别赋予矩形的左上角的x和y坐标、右下角的x和y坐标。
(4)dwTrans
指定传送类型。有如下几种:
DDBLTFAST_NOCOLORKEY
指定进行一次普通的复制,不带透明成分。
DDBLTFAST_SRCCOLORKEY
指定进行一次带透明色的图像传送,使用源页面的透明色。
DDBLTFAST_WAIT
如果图像传送器正忙,不断重试直到图像传送器准备好并传送好时才返回。一般都使用这个参数。
这几种类型很长又经常用,最好这样定义两个全局变量:
DWORD SrcKey = DDBLTFAST_SRCCOLORKEY | DDBLTFAST_WAIT
DWORD NoKey = DDBLTFAST_NOCOLORKEY | DDBLTFAST_WAIT
举一些例子。如果我们想把lpDDSBack上的所有内容传到lpDDSBuffer上作为背景,则使用:
lpDDSBuffer -> BltFast(0,0, lpDDSBack, NULL,NoKey);
如果我们想将lpDDSSpirit上(20,30)到(50,60)的一个人物放到lpDDSBuffer上,且左上角在lpDDSBuffer的(400,300)处,要使用透明色,则使用:
MakeRect (20,30,50,60);
LpDDSBuffer -> BltFast(400,300,lpDDSSpirit,&rect,SrcKey);
请注意,DirectDraw的BLT函数只要源矩形或被传送到目标页面后的图像有一点在页面外,例如MakeRect(100,200,500,400)后将其BLT到一个640x480的页面的(400,200)处,就什么都不会BLT!解决问题最好的办法是自己写一个新的BLT,剪裁一下(有的书介绍用Clipper来剪裁,速度比这种方法慢)。
下面就将这个新的BLT的内容给出来(预先定义了一些数组存储页面高度、宽度等信息,这样做确实很方便):
void MyBlt (int x,int y,int src_id,int dest_id,DWORD method)
{
int rl,rt,tx1,tx2,ty1,ty2,tl,tt;
RECT rect2=rect; //保存原rect的内容
rl=rect.left;
rt=rect.top;
if (rect.left>SW[src_id]) //SW中存储页面宽度
goto noblt; //不进行图像传送
if (rect.top>SH[src_id]) //SH中存储页面高度
goto noblt;
if (rect.right<0)
goto noblt;
if (rect.bottom<0)
goto noblt;
if (rect.left<0)
rect.left=0;
if (rect.top<0)
rect.top=0;
if (rect.right>SW[src_id])
rect.right=SW[src_id];
if (rect.bottom>SH[src_id])
rect.bottom=SH[src_id];
tx1=x+rect.left-rl;
ty1=y+rect.top-rt;
tx2=x+rect.right-rl;
ty2=y+rect.bottom-rt;
if (tx2<0)
goto noblt;
if (ty2<0)
goto noblt;
if (tx1>SW[dest_id])
goto noblt;
if (ty1>SH[dest_id])
goto noblt;
tl=tx1;
tt=ty1;
if (tx1<0)
tx1=0;
if (ty1<0)
ty1=0;
if (tx2>SW[dest_id])
tx2=SW[dest_id];
if (ty2>SH[dest_id])
ty2=SH[dest_id];
rl=rect.left;
rt=rect.top;
rect.left=tx1-tl+rl;
rect.top=ty1-tt+rt;
rect.right=tx2-tl+rl;
rect.bottom=ty2-tt+rt;
DDS[dest_id]->BltFast(tx1,ty1,DDS[src_id],&rect,method);
//DDS为存储页面指针的数组
noblt:
rect=rect2; //恢复原来的rect
}
4.7 程序实例
见Chapter IV.zip
4.8 图像缩放
图像缩放是一种常见的特效,功能强大的函数IDirectDrawSurface7::Blt( )理所当然地提供了这种功能。执行的速度还过得去可惜效果不太好,就象用Windows中的画笔缩放图像一样。Blt函数的原形是这样的:
HRESULT Blt(
LPRECT lpDestRect,
LPDIRECTDRAWSURFACE4 lpDDSrcSurface,
LPRECT lpSrcRect,
DWORD dwFlags,
LPDDBLTFX lpDDBltFx
);
lpDDSrcSurface是源页面的指针,lpDestRect和lpSrcRect分别是目标和源页面的矩形的指针,如果两个矩形的大小不一致就会自动进行缩放。至于dwFlags嘛,按照透明色的情况给它DDBLT_KEYDEST|DDBLT_WAIT、DDBLT_KEYSRC|DDBLT_WAIT或是DDBLT_WAIT即可。最后一个参数lpDDBltFx指明了要使用的特效,可惜没什么有价值的特效(除了5.1节将讲述的填色),给它NULL吧。
4.9 释放DirectDraw对象
一个完整的DirectDraw程序还需要在最后释放所有DirectDraw对象。为了方便这个过程,我们可以定义几个宏:
#define SAFE_DELETE(p) { if(p) { delete (p); (p)=NULL; } }
#define SAFE_DELETE_ARRAY(p) { if(p) { delete[] (p); (p)=NULL; } }
#define SAFE_RELEASE(p) { if(p) { (p)->Release(); (p)=NULL; } }
然后可以像下面的一段程序那样释放DirectDraw对象:
void FreeDDraw( )
{
SAFE_RELEASE(lpDDSPrimary); //释放主页面对象
//如果还有别的页面也像lpDDSPrimary一样释放
SAFE_RELEASE(lpDD); //释放DirectDraw对象
}
第五章 丰富画面的技巧
在上一章中,我们学习了不少DirectDraw的基础知识。但为了做出一个真正的有可玩性的游戏,我们还要继续学点东西。这一章的第四节是一个很简单的游戏实例,大家可以参考参考。
5.1 填涂颜色
要对页面指定范围内填充某种颜色(一般是填充页面的透明色以达到清除的目的),可以利用IDirectDrawSurface7::Blt( )函数。用法如下:
MakeRect(x,x,x,x); //指定范围
DDBLTFX ddBltFx;
ddBltFx.dwSize=sizeof(DDBLTFX);
ddBltFx.dwFillColor=RGB(x,x,x); //要填充的颜色
lpDDS->Blt(&rect,NULL,&rect,DDBLT_WAIT|DDBLT_COLORFILL,&ddBltFx);
这里顺便讲一个重要的问题,即所谓的"555"和"565"。我们经常用16位色,但16除3不是整数,那么要各用多少位来表示红、绿、蓝呢?有的显卡是三种颜色各用5位,即0rrrrrgggggbbbbb,被称为"555"。但其它显卡用rrrrrggggggbbbbb的形式来表示这三种颜色,因为人眼对绿色更敏感,这就是"565"。所以如果你初始化时把屏幕置为16位色,填色时就不能用RGB( )了(但设置透明色时仍可),要写一段程序来判断显卡是"555"还是"565"再自己根据情况转换颜色。不过如果我们想填充纯红、绿、蓝、黑则不用这么麻烦,这样就可以:
MakeRect(x,x,x,x); //指定范围
DDPIXELFORMAT ddpf;
ddpf.dwSize = sizeof(ddpf);
lpDDSBuffer->GetPixelFormat(&ddpf);
DDBLTFX ddBltFx;
ddBltFx.dwSize=sizeof(DDBLTFX);
ddBltFx.dwFillColor=(WORD)ddpf.dwGBitMask; //如是纯红则为dwRBitMask,
//纯蓝则为dwBBitMask
lpDDS->Blt(&rect,NULL,&rect,DDBLT_WAIT|DDBLT_COLORFILL,&ddBltFx);
如果我们想填充纯黑色,可以把dwFillColor设为0,并去掉所有与ddpf有关的语句。
5.2 输出文字
为了向页面输出文字,我们首先要获得页面的HDC(设备描述句柄),然后调用Windows GDI 函数向页面输出文字。由于获得句柄后将不能使用DirectDraw函数改动页面,所以输出完文字后要立刻释放句柄。
HDC hdc;
if (lpDDSXXX->GetDC(&hdc) == DD_OK) //拿句柄
{
SetBkColor(hdc, RGB(0, 0, 255)); //设置文字背景色,如为透明则把这一句改为: //SetBkMode(hdc,TRANSPARENT);
SetTextColor(hdc, RGB(255, 255, 0)); //设置文字颜色
TextOut(hdc,100,400, text, strlen(text)); //句柄, 左上角X, 左上角Y,
//文字(char *), 文字长度
lpDDSXXX->ReleaseDC(hdc); //释放句柄
}
5.3 GDI作图
由于DirectDraw并没有提供画点、线,圆等的语句,所以我们要借助Windows GDI函数来完成这些工作。就像输出文字时一样,我们先要获得页面的HDC:
HDC hdc;
lpDDSXXX->GetDC(&hdc);
画点是最简单的,SetPixel (hdc, x, y, RGB(r, g, b)); 即可在屏幕的(x,y)坐标处画上一个指定颜色的点。
如果需要画线等,我们需要创建"画笔":
HPEN hpen = CreatePen (PS_SOLID, 5, RGB(r, g, b));
CreatePen的第一个参数意义为画笔样式,常用的有PS_SOLID(普通画笔)和PS_DOT(由间断点组成的画笔,需要设置画笔宽度为1)。第二个参数是画笔的宽度,第三个参数是画笔的颜色。
接着将画笔给HDC:
SelectObject (hdc, hpen);
移动画笔到(x1,y1):
MoveToEx (hdc, x1, y1, NULL);
从画图起始位置向(x2,y2)坐标处画线:
LineTo (hdc, x2, y2);
下面列出一些常用的画图语句,使用方法和画线差不多,设定完画笔即可使用:
Rectangle(hdc, x1, y1, x2, y2); //画矩形
Ellipse(hdc, x1, y1, x2, y2); //画椭圆
值得注意的是我们画的图形将由一个"刷子"来填充,使用最简单的单色刷子的方法是:
HBRUSH hbrush = CreateSolidBrush (RGB(r, g, b)); //创建刷子
SelectObject (hdc, hbrush); //使用刷子
画完后,我们要记住释放HDC:
lpDDSXXX->ReleaseDC(hdc);
5.4 程序实例
5.5 锁定页面
学完了上面的知识,还有很多特效我们是做不出来的,比如淡入淡出、半透明等,因为DirectDraw中现成的函数根本没有这些功能。当然,要做这些东西还是有办法的,使用IDirectDrawSurface7::Lock( )就能让我们随心所欲,因为此函数可以允许我们直接修改页面。
Lock( )函数的用法如下:
HRESULT Lock(
LPRECT lpDestRect,
LPDDSURFACEDESC2 lpDDSurfaceDesc,
DWORD dwFlags,
HANDLE hEvent
);
第一个参数为一个指向某个RECT的指针,它指定将被锁定的页面区域。如果该参数为 NULL,整个页面将被锁定。
第二个参数为一个 DDSURFACEDESC2结构的地址,将被填充页面的相关信息。
第三个参数,即dwFlags,还是象以前一样给它DDLOCK_WAIT。
第四个参数规定要为NULL。
现在举一个例子来说明怎样使用Lock( ),我们的目标是使lpDDSBack半透明地浮现在lpDDSBuffer上。先看看完整的锁屏部分(注意,这一节只讨论24和32位色下如何操作):
DDSURFACEDESC2 ddsd, ddsd2; //DirectDraw页面描述
ZeroMemory(&ddsd, sizeof(ddsd)); //ddsd用前要清空
ddsd.dwSize = sizeof(ddsd); //DirectDraw中的对象都要这样
ZeroMemory(&ddsd2, sizeof(ddsd2));
ddsd2.dwSize = sizeof(ddsd2);
lpDDSBuffer->Lock(NULL, &ddsd, DDLOCK_WAIT, NULL); //Lock!
lpDDSBack->Lock(NULL, &ddsd2, DDLOCK_WAIT, NULL);
BYTE *Bitmap = (BYTE*)ddsd.lpSurface; //Lock后页面的信息被存在这里,请注意
//这个指针可能每次Lock( )后都不同!
BYTE *Bitmap2 = (BYTE*)ddsd2.lpSurface;
锁住页面后,Bitmap数组的存储格式是这样的:如为32位色则页面上坐标为(x,y)的点的R/G/B值分别存在Bitmap的y*ddsd.lPitch+x*4,y*ddsd.lPitch+x*4+1,y*ddsd.lPitch+x*4+2处;如为24位色则页面上坐标为(x,y)的点的R/G/B值分别存在Bitmap的y*ddsd.lPitch+x*3,y*ddsd.lPitch+x*3+1,y*ddsd.lPitch+x*3+2处。事实上,lPitch就是行的宽度。所以,现在我们就可以发挥想象,做出想要的一切效果了,比如说光照效果(将一目标页面按光照表改变亮度即可)!下面是接下来的代码(32位色时):
int pos;
for (int y=0;y<480; y++)
{
for (int x=0; x<640; x++)
{
Bitmap[pos] =(Bitmap[pos]+Bitmap2[pos])>>1; //改R
pos++;
Bitmap[pos] =(Bitmap[pos]+Bitmap2[pos])>>1; //改G
pos++;
Bitmap[pos] =(Bitmap[pos]+Bitmap2[pos])>>1; //改B
pos+=2;//到下一个R处
}
pos+=ddsd.lPitch;
}
lpDDSBack->Unlock(NULL); //Unlock!
lpDDSBuffer->Unlock(NULL);
由于使用Lock后DirectDraw要锁定页面,在没有使用Unlock( )前我们是无法用其他办法如Blt来修改页面的。所以用完Lock( )要赶快象上面的程序那样Unlock( )。Unlock的方法很简单,lpDDSXXX->Unlock(LPRECT lpDestRect)即可。
5.6 程序提速
上面的程序看起来好像很简单,但运行速度很可能会很慢,即使你直接用汇编重写也不会快多少。原因是读显存非常慢,写显存的速度也比写内存慢。解决这个问题的方法是:
(1) 把除了主页面外的所有页面放在内存中(初始化页面时将ddsd.ddsCaps.dwCaps中的 DDSCAPS_OFFSCREENPLAIN后再或( | )一项DDSCAPS_SYSTEMMEMORY),当然也要将主页面的页面描述改一下:
ddsd.dwSize=sizeof(ddsd);
ddsd.dwFlags=DDSD_CAPS;
ddsd.ddsCaps.dwCaps=DDSCAPS_PRIMARYSURFACE;
后台缓冲改为一个普通的离屏页面。这样做的另一个好处是你Lock( )一次后就永远得到了页面指针,而且然后一Unlock( )就又可以使用Blt了。所以你就拥有了两种改变页面的手段。
(2) 将Flip( )和不带透明色的BltFast( )改成直接用memcpy( )拷贝。注意要一行一行地拷贝,比如说640x480x24位色下的全屏幕拷贝是这样的:
BYTE *pSrc=(BYTE *)ddsd_src.lpSurface; //源页面
BYTE *pDest=(BYTE *)ddsd_dest.lpSurface; //目标页面
for (int y=0;y<480;y++)
{
memcpy(pDest, pSrc, 1920); //若为32位色则为2560=640*32/8
pSrc+=ddsd_src.lPitch; //移至下一行
pDest+=ddsd_dest.lPitch; //移至下一行
}
事实上memcpy( )还有进一步“压榨”的余地,下面是用SSE指令实现的超高速memcpy( )。把它拷贝到你的程序中享受完美的速度吧(快80%以上),呵呵(nQWORDs为要拷贝多少个8字节,注意它应能整除8!)。
void Qmemcpy(void *dst, void *src, int nQWORDs)
{
#define CACHEBLOCK 1024 //一个块中有多少QWORDs
//修改此值有可能实现更高的速度
int n=((int)(nQWORDs/CACHEBLOCK))*CACHEBLOCK;
int m=nQWORDs-n;
if (n)
{
_asm //下面先拷贝整数个块
{
mov esi, src
mov edi, dst
mov ecx, n //要拷贝多少个块
lea esi, [esi+ecx*8]
lea edi, [edi+ecx*8]
neg ecx
mainloop:
mov eax, CACHEBLOCK / 16
prefetchloop:
mov ebx, [esi+ecx*8] //预读此循环
mov ebx, [esi+ecx*8+64] //预读下循环
add ecx, 16
dec eax
jnz prefetchloop
sub ecx, CACHEBLOCK
mov eax, CACHEBLOCK / 8
writeloop:
movq mm0, qword ptr [esi+ecx*8 ]
movq mm1, qword ptr [esi+ecx*8+8 ]
movq mm2, qword ptr [esi+ecx*8+16]
movq mm3, qword ptr [esi+ecx*8+24]
movq mm4, qword ptr [esi+ecx*8+32]
movq mm5, qword ptr [esi+ecx*8+40]
movq mm6, qword ptr [esi+ecx*8+48]
movq mm7, qword ptr [esi+ecx*8+56]
movntq qword ptr [edi+ecx*8 ], mm0
movntq qword ptr [edi+ecx*8+8 ], mm1
movntq qword ptr [edi+ecx*8+16], mm2
movntq qword ptr [edi+ecx*8+24], mm3
movntq qword ptr [edi+ecx*8+32], mm4
movntq qword ptr [edi+ecx*8+40], mm5
movntq qword ptr [edi+ecx*8+48], mm6
movntq qword ptr [edi+ecx*8+56], mm7
add ecx, 8
dec eax
jnz writeloop
or ecx, ecx
jnz mainloop
}
}
if (m)
{
_asm
{
mov esi, src
mov edi, dst
mov ecx, m
mov ebx, nQWORDs
lea esi, [esi+ebx*8]
lea edi, [edi+ebx*8]
neg ecx
copyloop:
prefetchnta [esi+ecx*8+512] //预读
movq mm0, qword ptr [esi+ecx*8 ]
movq mm1, qword ptr [esi+ecx*8+8 ]
movq mm2, qword ptr [esi+ecx*8+16]
movq mm3, qword ptr [esi+ecx*8+24]
movq mm4, qword ptr [esi+ecx*8+32]
movq mm5, qword ptr [esi+ecx*8+40]
movq mm6, qword ptr [esi+ecx*8+48]
movq mm7, qword ptr [esi+ecx*8+56]
movntq qword ptr [edi+ecx*8 ], mm0
movntq qword ptr [edi+ecx*8+8 ], mm1
movntq qword ptr [edi+ecx*8+16], mm2
movntq qword ptr [edi+ecx*8+24], mm3
movntq qword ptr [edi+ecx*8+32], mm4
movntq qword ptr [edi+ecx*8+40], mm5
movntq qword ptr [edi+ecx*8+48], mm6
movntq qword ptr [edi+ecx*8+56], mm7
add ecx, 8
jnz copyloop
sfence
emms
}
}
else
{
_asm
{
sfence
emms
}
}
}
同样的,memset也可用SSE指令优化(快300%以上),代码在此(nQWORDs应能被8整除):
void Qmemset(void *dst, int c, unsigned long nQWORDs)
{
__asm
{
movq mm0, c
punpcklbw mm0, mm0
punpcklwd mm0, mm0
punpckldq mm0, mm0
mov edi, dst
mov ecx, nQWORDs
lea edi, [edi + ecx * 8]
neg ecx
movq mm1, mm0
movq mm2, mm0
movq mm3, mm0
movq mm4, mm0
movq mm5, mm0
movq mm6, mm0
movq mm7, mm0
loopwrite:
movntq [edi + ecx * 8 ], mm0
movntq [edi + ecx * 8 + 8 ], mm1
movntq [edi + ecx * 8 + 16], mm2
movntq [edi + ecx * 8 + 24], mm3
movntq [edi + ecx * 8 + 32], mm4
movntq [edi + ecx * 8 + 40], mm5
movntq [edi + ecx * 8 + 48], mm6
movntq [edi + ecx * 8 + 56], mm7
add ecx, 8
jnz loopwrite
emms
}
}
(3) 由于DirectDraw提供的带透明色的BltFast( )此时的工作效率不容乐观,所以我们可以使用RLE压缩掉透明色,同时自己写一个能传送带透明色的图像的函数。你可能已经听过RLE压缩,没听过也没关系,下面就举个例子。如果你定义透明色为(255,0,255),那么这样的一串点:(255,0,255), (255,0,255) , (255,0,255), (255,0,255), (100,23,43), (213,29,85), (255,0,255), (34,56,112), (255,0,255), (255,0,255)可用这样的方式存储:
首先是4个透明点,那么我们在开头放一个0,表示由透明色开头,否则需放一个1。然后是0,12,(表示需跳过4x3=0012个字节),接着是0,2,(即后面为002个图像点),100,23,43,213,29,85,0,3(需跳过003个字节),0,1(后面为001个图像点),34,56,112,0,6(需跳过006个字节)。这样用20个字节就表示出了原来需要30个字节的图像,而且BLT时速度更快,因为无须对每一个点进行判断是否透明色。
一般来说,RLE压缩只在图像的每一行内进行(而不是将整块图像作为一个整体)以方便编程。
如果你还需要更快的速度而不在乎文件大小的话,还有一种比较“邪”的解决方案,其思路是用另外一个程序自动生成成千上万条赋值指令实现BLT。这种办法可以省去大量判断和跳转指令所耗的时间,速度显然达到顶峰。
看起来好像要重写很多东西,其实改动的部分并不多。这样改了之后整个程序的速度就会快很多,但还不能很好地满足全屏幕特效的要求,因为全屏幕特效实在很耗时间,只有用汇编和MMX等指令重写速度才能比较快。所以在下面一章中,我们将介绍内嵌汇编和MMX指令。当然,你直接用5.7节的现成代码也行。
5.7 特殊效果
为了方便大家编游戏,这里列出了一些常用特效的制作方法。如果你想知道这些代码是如何工作的,请阅读第六章。下面的程序基于640x480x32位色,如果你使用的分辨率不同也很容易修改,但如果使用的色彩数不同就很麻烦了。
5.7.1 减暗和加亮
减暗和加亮是两个用处很大的特效,在游戏中有许多应用。
__int64 mask=0x4040404040404040; //要减暗或加亮的程度,应为
//0xABCDEFGHABCDEFGH格式
//改一改就可以做出彩色灯光效果
int tt=y*lP +x*4; //lP应为后台缓存的lPitch,x和y应为要处理的区域的左上
//角的坐标
__asm
{
movq mm1,mask;
mov eax,pS; //pS应为后台缓存的指针(lpSurfase)
add eax,tt;
mov ecx,h; //h为要处理的区域的高度
outloop:
push eax;
mov ebx,w; //w为要处理的区域的宽度/2
innloop:
movq mm0,[eax];
psubusb mm0,mm1; //如果要加亮,请把psubusb改成paddusb
movq [eax],mm0;
add eax,8;
dec ebx;
jnz innloop;
pop eax;
add eax,lP;
dec ecx;
jnz outloop;
emms;
}
5.7.2 淡入淡出
淡入淡出常被用在切换游戏场景的时候,实现它是非常简单的,只要不断执行5.7.1节的程序就行了。
5.7.3 半透明
半透明是一个很炫的特效,其原理是把两张图像的每一个点的颜色值做一个平均。下面就说说最容易做,速度最快,同时也是最常见的50%半透明的做法:
(1)将特效图像(例如火焰什么的)预先用图像处理软件把RGB值都削减一半。
(2)在刷新屏幕函数中执行此段代码:
__int64 mask=0x7F7F7F7F7F7F7F7F;
int tt=y*lP +x*4; //lP应为后台缓存的lPitch,x和y应为要处理的区域的左上
//角的坐标
__asm
{
movq edx, pB; //pB应为储存特效图像的页面的指针
mov eax, pS; //pS应为后台缓存的指针(lpSurfase)
add eax, tt;
mov ecx, h; //h为要处理的区域的高度
outloop:
push eax;
push edx;
mov ebx, w; //w为要处理的区域的宽度/2
innloop:
movq mm0, [eax];
psrlw mm0, 1;
movq mm1, [edx];
pand mm0, mask; //实现"psrlb"
paddusb mm0, mm1;
movq [eax], mm0;
add eax, 8;
add edx, 8;
dec ebx;
jnz innloop;
pop edx;
pop eax;
add eax, lP;
add edx, lP1; //lP1应为储存特效图像的页面的lPitch
dec ecx;
jnz outloop;
emms;
}
5.7.4 光照
在程序开始时的初始化光照表:
void InitLight( )
{
//BaseLight为基本光照表
BaseLight=new unsigned char [307200]; //640*480=307200
for (int i=0;i<640;i++)
for (int j=0;j<480;j++)
BaseLight[i+j*640]=sqrt((i-320)*(i-320)+(j-240)*
(j-240))*224/400; //最暗处亮度减224
}
按某光照表(LightTable[ ])减低页面上图像的亮度:
BYTE *pl=(BYTE *)&LightTable[0];
__asm
{
mov ecx,480;
mov edi, pb; //pb应为后台缓存的指针(lpSurfase)
mov esi, pl;
outloop:
push edi;
mov ebx, 320; // 640/2=320
innloop:
movq mm0,[edi];
movq mm1,[esi]; //读入八个点的亮度,设其为hgfedcba
punpcklbw mm1,mm1; //ddccbbaa
punpcklbw mm1,mm1; //bbbbaaaa,同时处理两个点
psubusb mm0,mm1; //减去亮度
movq [edi],mm0; //放回去(在内存中是aaaabbbb)
add edi,8;
add esi,2; //下两个点
dec ebx;
jnz innloop;
pop edi;
add edi, lP; //lP应为后台缓存的lPitch
dec ecx;
jnz outloop;
emms;
}
5.7.5 动态光照
首先要弄清楚一个问题:如果一束光的亮度是200,一束光的亮度是150,混合后的结果会是怎样呢?不可能是200+150=350,因为这样就超出了255的上界。答案应该是(1-(255-200)/255*(255-150)*255)*255,即255-(255-200)*(255-150)/255=232。如果我们用"暗度"代替亮度的话,算法就更简单了,只要把两个暗度相乘再除以255即可。
其实最麻烦的不是算出最后的"暗度",而是处理当光照范围超出屏幕时的情况。假设BaseLight[ ]为环境光照表,Light[ ]为要加上的灯光的光照表,那么当灯的位置靠近屏幕边缘时显然就要做一个剪裁。由于MMX指令一次可以算好4个点的光照情况,所以问题变得更加复杂。那么怎么做才最好呢?把BaseLight[ ]扩大几个像素是一种方便的办法,不过此时放光照时就要多加一条指令。下面请看代码:
void InitLight( ) //初始化光照表
{
LightTable=new unsigned char [310080]; //实际光照表,646*480=310080
BaseLight=new unsigned char [310080]; //基本光照表
for (int i=0;i<646;i++) //多开几个字节防止越界
{
for (int j=0;j<480;j++)
{
BaseLight[i+j*646]=sqrt((i-323)*(i-323)+(j-240)*(j-240))*224/400;
}
}
SmallLight=new unsigned char [10004]; //灯光光照表,多开4个字节以防止越界
for (i=0;i<100;i++)
{
for (int j=0;j<100;j++)
{
int dis=sqrt((i-50)*(i-50)+(j-50)*(j-50))*255/45;
if (dis<255)
SmallLight[i+j*100]=dis;
else
SmallLight[i+j*100]=255;
}
}
for (i=10000;i<10004;i++)
SmallLight[i]=255;
}
每次刷新屏幕时,我们都要先把BaseLight拷贝到LightTable:
memcpy(LightTable,BaseLight,310080);
然后把SmallLight加入LightTable:
p1=&(LightTable[y*646+x]); //y和x为SmallLight[]在屏幕上的左上角坐标
p2=&(SmallLight[rect.top*100+rect.left]); //rect为经过剪裁计算出的SmallLight[]
//的有效区域
//剪裁的思路可参考4.6节的程序
int height=rect.bottom-rect.top;
int width=(rect.right-rect.left+3)/4; //加上3以保证全部处理
if ((height>0)&&(width>0))
{
__asm
{
pxor mm2,mm2; //将mm2清0
mov edi,p1;
mov esi,p2;
mov ebx,height;
_oloop:
mov ecx,width;
push edi;
push esi;
_iloop:
movq mm0,[edi];
movq mm1,[esi];
punpcklbw mm0,mm2;
punpcklbw mm1,mm2;
pmullw mm0,mm1; //乘起来
psrlw mm0,8; //除以256,因为显然比255快
packuswb mm0,mm2;
movd [edi],mm0; //放回去
add esi,4;
add edi,4;
dec ecx;
jnz _iloop;
pop esi;
pop edi;
add esi,100;
add edi,646;
dec ebx;
jnz _oloop;
emms;
}
}
放光照表LightTable的程序改为:
BYTE *pl=(BYTE *)&LightTable[0];
__asm
{
mov ecx,480;
mov edi, pb; //pb应为后台缓存的指针(lpSurfase)
mov esi, pl;
outloop:
push edi;
mov ebx, 320; // 640/2=320
innloop:
movq mm0,[edi];
movq mm1,[esi]; //读入八个点的亮度,设其为hgfedcba
punpcklbw mm1,mm1; //ddccbbaa
punpcklbw mm1,mm1; //bbbbaaaa,同时处理两个点
psubusb mm0,mm1; //减去亮度
movq [edi],mm0; //放回去(在内存中是aaaabbbb)
add edi,8;
add esi,2; //下两个点
dec ebx;
jnz innloop;
pop edi;
add edi, lP; //lP应为后台缓存的lPitch
add esi, 6;
dec ecx;
jnz outloop;
emms;
}
5.7.6 光照系统
为了实现类似Diablo II的光照系统,我们还有许多工作要做。核心任务是解决墙壁和精灵的精确光照问题和墙壁对光线的遮挡问题。如果你直接使用5.7.5节的代码的话而不对墙壁和精灵做额外处理的话,你首先会发现它们的光照不太正常----一点立体感都没有,因为它们在Z方向有伸展,离原点的距离可不能拿它们在屏幕上的位置来算;第二个问题是墙壁居然挡不住光。好,接下去我们来看看该怎么做。
第一个问题是比较好解决的,如果墙壁和精灵很高,只要预先做好一张“如果底部光强为n那么高h处光照为多少”的表,然后刷新屏幕时先用5.7.5节的代码处理地表,再按这张表和光照表画精灵和墙壁即可;如果它们不高,那可以近似处理一下,把它们全部用底部的光照画上去就行啦,Diablo II就是这样做的。
第二个问题就比较麻烦了。由于直接对屏幕上所有点判断是否被墙遮挡显然是不可能的,我们最好把屏幕分成一个个小格子,再依次判断它们是否被墙遮挡,如果是就在光照表中置这个格子中的所有点一个很暗的值,最后再进行插值运算使光照过渡平滑。挺难做啊,看看具体代码吧:
5.7.7 天气效果
先顺便说一下火焰特效和闪电特效,它们一般是通过放已经做好的小动画实现的。
下面言归正传,先来看看下雨的效果该如何实现。这个效果主要由雨滴和水波特效组成。雨滴效果通过画一些方向(基本均随风向)、亮度、长度基本相同的白线,并在timer里设置它们朝自己的方向移动即可实现;水波特效比较麻烦一点,后面将会详细讲;另外加入一点突然的淡入淡出模拟闪电效果可以更真实。
下雪也是一种常见的天气特效。
第六章 加速游戏的魔法
为了加速游戏,一提起汇编语言,大家也许会感到很神秘。其实如果你学起来就会发现,它并非想象中那样难。特别是内嵌汇编,由于它和C++紧密结合,使你不必考虑很多烦琐的细节(例如输入输出函数的写法),学习起来比较容易。使用内嵌汇编,特别是使用MMX指令,可以大大提高各种游戏中常见特效的速度,对于编出一个漂亮的游戏非常重要。学好汇编语言还有一个特别有趣的用处:可以观察和看懂VC++生成的汇编代码,从而更好地了解C++语言本身和优化代码。
6.1 内嵌汇编简介
在高级语言中,我们可以无所顾忌地使用各种语句,再由编译器将语句经过非常复杂的编译过程将其转换为机器指令后运行。事实上,处理器本身所能处理的指令不多;更糟糕的是,大部分指令不能直接施用在内存中的变量上,要借助寄存器这个中间存储单元(你可以把寄存器看做是一个变量)。Pentium级处理器的寄存器不多,只有8个32位通用寄存器,分别被称为EAX, EBX, ECX, EDX, EBP, ESP, EDI , ESI。每一个通用寄存器的低16位又分别被称为AX, BX, CX, DX, BP, SP, DI , SI。其中AX, BX, CX, DX的高8位被称为AH, BH, CH, DH;低8位被称为AL, BL, CL, DL。注意在内嵌汇编中不应使用EBP和ESP,它们存储着重要的堆栈信息。
还有一个非常重要的寄存器,叫做标志寄存器(EFLAGS),标明了运算结果的各个属性,你不能直接读取或修改它。这些属性有:不溢出/溢出(OF)、正/负(SF)、非零/零(ZF)、偶/奇(PF)、不进位/进位(CF)等。
汇编语言中若要表示有符号整数,需先写出该整数的绝对值的二进制形式,若此数为正数或零则已得到结果,否则将其取反(0->1,1->0)后再加上一即为结果。所以一个8位寄存器可表示的有符号整数范围为从-128到127。
与C++类似,汇编语言提供了得到指针所指内存的方法,这被称为"寻址"。用法很简单,象这样:[寄存器+寄存器*1/2/4/8+32位立即数]就可以得到这个位置的数了。举一个例子,如果有一个数组unsigned short A[100],且EAX中存储着A[0]的地址,那么[EAX+58]即为A[29]的值;如果此时EBX=9,那么[EAX+EBX*2+4]将是A[11]的值。
那么又怎么把一个变量的地址装载进寄存器呢?后面将会介绍。
内嵌汇编的使用方法是:
_asm
{
语句 //后面可加可不加分号
}
你可以把它插入程序中的任何位置,非常灵活。
6.2 基本指令
基本指令均不影响标志寄存器。
第一条指令是传送指令:MOV DEST, SRC。其作用为将DEST赋以值SRC。其中DEST和SRC可为整数(称为立即数)、变量或[地址](存储器),寄存器。需注意的是有的操作是不允许的:在汇编语言中你永远不能将存储器或寄存器内容赋给立即数(你见过5=a这样的语句吗?);也不能将存储器内容直接赋给另一存储器,必须借助寄存器作为中间变量来实现。关于MOV还有一点要注意的是DEST和SRC必须都为32位/16位/8位,即同一大小。值得特别注意的是,数据在内存中的存储方式是以字节为单位颠倒的,即:如果内存地址0000存储的字节是5F,地址0001存储的字节是34,地址0002存储的字节是6A,地址0003存储的字节是C4,那么地址0000处存储的字(WORD,16位)为345F,双字(DWORD,32位)为C46A345F。
第二条指令是地址装载指令:LEA A, B。其作用为将B变量的地址装载进A寄存器(A需为32位)。要注意的是不能像LEA EAX, Temp[5]这样直接调数组中某个元素的地址。这个指令还可以用来进行简单的运算,考虑下面的语句:LEA EAX, [EBX+ECX*4+8],此语句可将EBX+ECX*4+8的值赋给EAX。
OK,让我们看一个可以将两个正整数相加的程序:
#include
using namespace std;
//此程序也展示了内嵌汇编应如何使用C++中的指针
void main( )
{
unsigned int a,b;
cin>>a;
cin>>b;
int *c = &a;
__asm //下面是内嵌汇编...
{
mov eax, c; //c中存储的a的地址->eax
mov eax, [eax]; //a的值->eax
//注意直接mov eax, [c]是错误的
mov ebx, b; //可以像这样直接对ebx赋值
lea eax, [eax+ebx];
mov a, eax; //可以直接将eax的值->a
} //内嵌汇编部分结束...
cout<
}
第三条指令是交换指令,形式为XCHG A, B。A和B中至少有一个须为寄存器。如果你想交换两处内存中的数据则要使用寄存器作为中间人。
接着是扩展传送指令,共有两条,为MOVSX DEST, SRC和MOVZX DEST, SRC,它们的用处分别是将SRC中的有符号数或无符号数赋给DEST。这时你就可以将字长较短的寄存器的内容赋给字长较长的寄存器,反之则不行。
大家会发现,8个通用寄存器实在无法满足编程的要求。为了解决这一矛盾,引入了堆栈这一聪明的设想。你可以把堆栈想象为一块放箱子的区域,用入栈(PUSH)可将一个箱子放在现有箱子的最顶端,而出栈(POP)可将现有箱子最顶端的那个箱子取出。看看下面的指令吧:
push eax //eax进栈, 堆栈为eax
push ebx //eax进栈, 堆栈为eax ebx
push ecx //eax进栈, 堆栈为eax ebx ecx
pop ebx //ebx=ecx, 堆栈为eax ebx
pop eax //eax=ebx, 堆栈为eax
pop ecx //ecx=eax, 堆栈空
可以看到,堆栈不仅可以方便地暂时存储数据而且还可以调整他们的次序。
6.3 算术指令
算术指令大都影响标志寄存器。这些指令比较容易明白,现在将其列出:
表6.1
clc CF=0
stc CF=1
cmc CF=1-CF
add a,b a=a+b (结果过大可能会有古怪的结果,且置CF 1)
adc a,b a=a+b+CF (加上进位)
sub a,b a=a-b (如结果小于0会加上2的16或32次方,且置CF 1)
sbb a,b a=a-b-CF (减去退位)
inc a a++
dec a a- -
neg a a=-a
mul a eax=eax*a后的低32位, edx=高32位
例: mov eax,234723
mov edx, 12912189
mul edx;
则eax=2835794967
edx=705
div a eax=(edx eax)/a的商, edx=余数
例: mov eax,12121
mov edx,2
此时(edx eax)=8589946713
mov ebx,121
div ebx;
则eax=70991295
edx=18
imul / idiv dest, src 有符号数乘 / 除法,dest=dest乘 / 除src
imul / idiv dest, s1, s2 有符号数乘 / 除法,dest=s1乘 / 除s2
为了让大家弄懂标志,请看两段程序(出现的数都为十六进制数):
表6.2
指令 CF ZF SF OF PF AX或BX
mov ax, 7896 ? ? ? ? ? 7896
add al, ah 1 0 0 0 0 780e
add ah, al 0 0 1 1 0 860e
add al, f2 1 1 0 0 1 8600
add al, 1234 0 0 1 0 0 9834
mov bx, 9048 ? ? ? ? ? 9048
sub bh, bl 0 0 0 1 1 4848
sub bl, bh 0 1 0 0 1 4800
sub bl, 5 1 0 1 0 0 48fb
sub bx, 8f34 1 0 1 1 0 b9c7
6.4 逻辑与移位指令
逻辑指令会将标志寄存器中的OF和CF清零。
表6.3
not a a=~a(注意not与neg不同!)
and a, b a=a&b
or a, b a=a|b
xor a, b a=a^b
下面是移位指令,其中x可为8位立即数或CL寄存器。
表6.4
sal(也可写成shl) a, x 将a左移x位,CF=移出的那一位数
空位用0补足
sar a, x 将有符号a右移x位,CF=移出的那一位数
空位按a的符号用0/1补足
shr a, x 将无符号a右移x位,CF=移出的那一位数
空位用0补足
rol a, x 将a循环左移(左边出去的数又从最右边回来)
ror a, x 将a循环右移(右边出去的数又从最左边回来)
rcl / rcr a, x 把CF放在目标最左边然后循环左/右移
shld a, b, x 将a左移x位, 空出位用b高端m位填充
例:shld edx, eax, 16可将eax的高16位 放入dx中。
shrd a, b, x 将a右移x位, 空出位用b低端m位填充
6.5 比较、测试、转移与循环指令
比较与测试指令基本上总是与转移指令相配合使用,其形式分别为CMP a, b和TEST a, b。CMP实际上是根据a-b的值改变标志寄存器但不改变a和b,可以检测两个数的大小关系。TEST则是根据a&b的值改变标志寄存器,同样不改变a和b。这条指令可以用来测试a中哪些位为1。执行完这些指令后,立刻用转移指令就可实现条件转移,因为条件转移语句会根据标志寄存器决定是否转移。转移指令的使用方法就像这样:
__asm{
_addax: add ax,1; //_addax是标号
jmp _addax;
}
转移指令有:
JMP 无条件转移
JE / JZ ZF=1时转移
JNE / JNZ ZF=0时转移
JS SF=1时转移
JNS SF=0时转移
JO OF=1时转移
JNO OF=0时转移
JP / JPE PF=1时转移
JNP / JPO PF=0时转移
根据两无符号数关系转移:
JA / JNBE 大于时转移 (CF或ZF=0)
JBE / JNA 不大于时转移 (CF或ZF=1)
JB / JNAE / JC 小于时转移 (CF=1)
JNB / JAE / JNC 不小于时转移 (CF=0)
根据两有符号数关系转移:
JNLE / JG 大于时转移 ((SF异或OF)或ZF)=0 )
JLE / JNG 不大于时转移 ((SF异或OF)或ZF)=1 )
JL / JNGE 小于时转移 (SF异或OF=1)
JNL / JGE 不小于时转移 (SF异或OF=0)
特殊转移语句:
JECXZ CX=0时转移
为了记住这么多条指令,你只需知道一点,就是无符号数之间的关系分别被称为Above,Equal,Below,分别代表大于,等于,小于;有符号数之间相应的关系则分别被称为Great,Equal,Less。
事实上,有些转移是可以避免的。举个例子,要算一个数的绝对值是否要用转移呢?请看一段程序:
MOV EDX,EAX
SAR EDX,31 //EDX现在全为EAX的符号位
XOR EAX,EDX
SUB EAX,EDX
找出两个数中较大的一个应该要用转移吧?不过也可以象下面的解决方案那样利用标志,真是绝了:
SUB EBX,EAX
SBB ECX,ECX //如果EBX≥EAX,现在ECX=0,否则ECX=FFFFFFFF
AND ECX,EBX
ADD EAX,ECX
下面的一段程序实现了if (a != 0) a = b; else a = c;
CMP EAX,1
SBB EAX,EAX
XOR ECX,EBX
AND EAX,ECX
XOR EAX,EBX
循环语句常用的是LOOP,它等价于DEC CX加上JNZ。
下面看一个汇编的综合运用:冒泡排序。
#include
using namespace std;
#define array_size 10
int a[array_size]={42, 73, 65, 97, 23, 59, 18, 84, 36, 6};
void main()
{
int *p;
p=&a[0];
p--;
__asm
{
mov esi,p;
mov ecx,array_size;
_outloop:
mov edx,ecx;
_inloop:
mov eax, [ esi+ecx*4 ]; //一个int占4字节
mov ebx, [ esi+edx*4 ];
cmp eax, ebx;
jnb _noxchg; //不交换
mov [ esi+ecx*4 ], ebx;
mov [ esi+edx*4 ], eax;
_noxchg:
dec edx;
jnz _inloop;
loop _outloop;
}
for (int i=0;i<10;i++)
cout< <<"
mov eax, R;
add eax, 100;
mov R, eax;
mov eax, G;
add eax, 100; // !!!
mov G, eax;
mov eax, B;
add eax, 100;
mov B, eax;
CPU在执行作了标记的一行时会出现我们不想要的结果,因为G值不会停留在255上。而使用色饱和功能可使其停留在255上,同样减暗时也会停留在0上。
可以使用下面这个函数检测CPU是否支持MMX指令:
int CheckMMX( )
{
int isMMX=0;
__asm
{
mov eax,1;
cpuid;
test edx,00800000h;
jz NotSupport;
mov isMMX,1;
NotSupport:
}
return isMMX;
}
顺便看看如何判断CPU是否支持SSE吧:
int CheckSSE()
{
int isSSE = 0;
_asm
{
mov eax, 1
cpuid
shr edx,0x1A
jnc NotSupport
mov isSSE, 1
NotSupport:
}
return isSSE;
}
下面是几条最基本的MMX指令:
EMMS
由于执行MMX指令时占用了浮点运算单元,使用完MMX指令后要记住执行这条指令以释放浮点运算单元。
MOVD dest, src
dest为MMX寄存器 / 通用寄存器 / 存储器,src为MMX寄存器 / 通用寄存器 / 存储器。其作用是将src中的32位传送到dest的低32位并置dest的高32位零。
MOVQ dest, src
dest为MMX寄存器 / 存储器,src为MMX寄存器 / 存储器。其作用是将src中的64位传送到dest。
6.7 MMX指令集之算术与比较指令
MMX提供了一些非常好用的算术指令,可以大大加快2D特效的速度。
首先是加减法指令,共有8条,形式为PADD/PSUBxy dest, src,其中x为 空/ "S" / "US",分别代表无色饱和、有符号色饱和,无符号色饱和。y为"B" / "W" 分别代表以一字节或一字为运算单位,即同时执行8条字节加法或4条字加法。若x为空则y还可取D,代表同时执行2条双字加法。dest和src均需为MMX寄存器。举三个例子:
mm0= 008 000 005 000 255 000 141 045
mm1= 000 057 005 000 005 000 131 002
PADDB mm0, mm1后 mm0= 008 057 010 000 004 000 016 047
mm0= 008 000 005 000 255 000 141 045
mm1= 000 057 005 000 005 000 131 002
PADDSB mm0, mm1后 mm0= 008 057 010 000 255 000 255 047
mm0= 001234 000010 000005 008516
mm1= 000001 000020 000001 009343
PSUBSW mm0, mm1后 mm0= 001233 000000 000004 000000
然后是乘法语句,共有三条:
PMADDWD dest, src
假设src原为 A, B, C, D;dest为E, F, G, H。则执行完后dest为A*E+B*F, C*G+D*H。
PMULHW dest, src
假设src原为 A, B, C, D;dest为E, F, G, H。则执行完后dest为A*E高16位, B*F高16位, C*G高16位, D*H高16位。
PMULLW dest, src
该指令与PMULHW几乎相同,就是改成了低16位。
这三条指令在图像变形与旋转、半透明等特效中均有应用。
下面是比较指令:
PCMPEQx dest, src x=B / W / D dest和src均为MMX寄存器
当dest与src相等时,置相应的dest全1,否则置相应的dest全0。例:
mm0=012321, 000912, 023849, 005634
mm1=032123, 000912, 022234, 005634
PCMPEQD mm0, mm1后 mm1=000000, 065535, 000000, 065535
PCMPGTx dest, src x=B / W / D dest和src均为MMX寄存器
当dest大于src时,置相应的dest全1,否则置相应的dest全0。
举一个使用比较指令的小技巧:PCMPEQD mm?, mm? 即可把mm?清零。当然用下面的PXOR mm?, mm?也可以。
6.8 MMX指令集之逻辑与移位指令
PAND dest, src dest为MMX寄存器,src为MMX寄存器 / 内存单元
dest= dest & src。
PANDN dest, src dest为MMX寄存器,src为MMX寄存器 / 内存单元
这条指令的作用是dest= (~dest)&src。
POR dest, src dest为MMX寄存器,src为MMX寄存器 / 内存单元
dest= dest | src。
PXOR dest, src dest为MMX寄存器,src为MMX寄存器 / 内存单元
dest= dest ^ src。
注意MMX指令集中无PNOT这样的指令,你想到了解决方法吗?
PSLLx dest, src x=W / D / Q,表示位移的单位
dest为MMX寄存器, src可为各种寄存器 / 存储器 / 立即数
进行左移,举两个例子:
mm0=86E1 04C7 19F8 42EE
PSLLW mm0, 4后 mm0=6E10 4C70 9F80 2EE0
mm0=028F 76AA 85C9 BEE1
PSLLQ mm0, 8后 mm0=8F76 AA85 C9BE E100
PSRAx dest, src x=W / D
dest为MMX寄存器, src可为各种寄存器 / 存储器 / 立即数
进行有符号数右移。
PSRLx dest, src x=W / D / Q
dest为MMX寄存器, src可为各种寄存器 / 存储器 / 立即数
进行无符号数右移。
我们会注意到,MMX指令集中没有提供经常要使用的以字节为单位左/右移的指令,解决问题的方法其实也很简单。分析一下以字为单位左/右移后得到的结果和以字节为单位左/右移应得到的结果你会发现它们有相似之处,又因为一般来说我们左/右移的位数是固定的,所以我们只要先以字为单位左/右移后再根据左/右移的位数PAND一个常数即可。如果你左/右移的位数不定,那就只能借助下面的格式调整指令了。
6.9 MMX指令集之格式调整指令
格式调整指令是MMX指令中非常重要的组成部分,包括打包和扩展两大类。
打包指令有:
PACKSSx dest, src x=WB / DW 最后得到有符号数,带色饱和
例: mm0= 8000, -200, 55, 34
mm1= -1281, 27, -99, 127
PACKSSWB mm0, mm1后 mm0= -128, 27, -99, 127, 127, -128, 55, 34
PACKUSx dest, src x=WB
除了最后得到无符号数(0-255)之外与PACKSSx相同。
扩展指令有:
PUNPCKHx dest, src x=BW / WD / DQ
例: mm0= AF, 45, 0E, 8A, 12, 67, FF, 00
mm1= 11, 91, AB, 5C, 93, B8, 0F, 09
PUNPCKHBW mm0, mm1后 mm0= 11AF, 9145, AB0E, 5C8A
PUNPCKLx dest, src x=BW / WD / DQ
例: mm0= AF, 45, 0E, 8A, 12, 67, FF, 00
mm1= 11, 91, AB, 5C, 93, B8, 0F, 09
PUNPCKLBW mm0, mm1后 mm0= 9312, B867, 0FFF, 0900
MMX指令集已经基本上介绍完了,现在你就应该想想怎么把它运用到程序中去了。在5.7节给出了一些常用的2D特效用MMX指令的实现,可供参考。
第七章 我没有想好名字
如果你只靠上面几章所讲述的知识编了个游戏,喜欢的人恐怕会不多?,为什么?因为没有人会玩一个控制不流畅而且声音效果不佳的游戏。为了在游戏中更好地管理各种输入设备,我们需要使用DirectInput。而通过使用DirectX Audio可以在游戏中实现各种更逼真的音乐效果。它们都是DirectX的重要组成部分。使用DirectInput前我们需要#include 并在工程中加入dinput8.lib和dxguid.lib,而使用DirectX Audio前我们需要#include 并在工程中加入dsound.lib和dxguid.lib。
7.1 读取键盘数据
首先,我们必须创建一个DirectInput8对象(DirectX 9.0并没有对DInput和DAudio做多大改动),就像这样:
LPDIRECTINPUT8 pInput;
DirectInput8Create(GetModuleHandle(NULL),
DIRECTINPUT_VERSION,
IID_IDirectInput8,
(void**)&pInput,
NULL);
然后,我们需要创建一个DirectInput设备:
LPDIRECTINPUTDEVICE8 pDev;
pInput->CreateDevice(GUID_SysKeyboard, &pDev, NULL);
设置好它的数据格式:
pDev->SetDataFormat(&c_dfDIKeyboard);
设置它的协作级,这里设为独占设备+前台:
pDev->SetCooperativeLevel(hwnd,DISCL_EXCLUSIVE|DISCL_FOREGROUND);
获取设备:
pDev->Acquire();
像上面那样初始化后,我们就已经把Windows对键盘的控制权剥夺了,以后的键盘消息将不会被送入消息循环,我们可以把消息循环中处理键盘消息的语句拿掉了。当然,这时我们需要在程序的适当地方,比如说在刷新游戏时,加入对键盘数据进行读取和处理的语句,就像下面的一段程序:
#define KEYDOWN(key) (buffer[key] & 0x80) //定义一个宏,方便处理键盘数据
char buffer[256]; //键盘数据
pDev->GetDeviceState(sizeof(buffer),(LPVOID)&buffer); //得到键盘数据
if (KEYDOWN(DIK_XXX)) //如果XXX键被按下…(请参阅附录二)
{
…… //处理之
}
…… //处理其它键
哈哈,真是挺方便的。有时候真的有点怀疑DirectX是不是一种回到遥远的可爱的DOS时代的“倒退”。因为无论是DirectInput还是DirectDraw都是太像DOS下的做法了。
7.2 读取鼠标数据
读取鼠标数据和读取键盘数据的步骤差不多,首先也是要创建设备:
pInput->CreateDevice(GUID_SysMouse, &pDev, NULL);
设置数据格式:
pDev->SetDataFormat(&c_dfDIMouse);
设置协作级:
pDev->SetCooperativeLevel(hwnd,DISCL_EXCLUSIVE | DISCL_FOREGROUND);
获取设备:
pDev->Acquire();
那么怎样读取鼠标数据呢?如果要取得鼠标的当前状态,这样即可:
DIMOUSESTATE mouse_stat; //鼠标状态
//得到鼠标状态
pDev->GetDeviceState(sizeof(DIMOUSESTATE),(LPVOID)&mouse_stat);
得到的mouse_stat是一个DIMOUSESTATE类型的结构,它有四个成员:lX,lY,lZ和rgbButtons[4]。其中lX、lY和lZ分别是自上次调用此函数以来鼠标在X轴、Y轴和Z轴(滚轮)方向上移动的距离,而不是鼠标此时的坐标;其距离单位不是像素,但你完全可以把它看做以像素为单位。所以,我们需要定义两个变量mousex=0和mousey=0,然后把lX和lY累加上去即可。这样做的好处是鼠标坐标不再受屏幕的制约,而且屏幕中心的mousex和mousey值可以永远是0,不随屏幕分辨率而改变。rgbButtons是一个存储哪些鼠标键被按下的数组,我们可以这样做来读取它:
//定义一个宏,方便处理鼠标数据
#define MOUSEBUTTONDOWN(b) (mouse_stat.rgbButtons[b]&0x80)
if (MOUSEBUTTONDOWN(0)) //如果左键被按下…
{
…… //处理之
}
…… //处理右键(1)和中键(2)
7.3 恢复和关闭DirectInput
7.3.1 恢复DirectInput设备
就像在DirectDraw中那样,使用DirectInput的程序被最小化时DirectInput设备会出现"丢失"现象。恢复的办法很干脆:先关闭DirectInput再重新初始化即可。
7.3.2 关闭DirectInput
关闭DirectInput也是非常简单的(SAFE_RELEASE的定义在4.8节):
pDev->Unacquire( );
SAFE_RELEASE(pDev);
SAFE_RELEASE(pInput);
7.4 初始化和关闭DirectX Audio
7.4.1 初始化DirectX Audio
使用DirectX Audio前,按规矩还是要先初始化。在下面的这段初始化程序中要用到三个DXAudio提供的对象:IDirectMusicLoader8、IDirectMusicPerformance8和IDirectMusicSegment8。IDirectMusicLoader8顾名思义是用来调入音乐的,IDirectMusicPerformance8可以认为是音频设备,而IDirectMusicSegment8就是代表音乐。
#include
IDirectMusicLoader8* pLoader= NULL;
IDirectMusicPerformance8* pPerf = NULL;
IDirectMusicSegment8* pSeg = NULL;
CoInitialize(NULL); //初始化COM
CoCreateInstance(CLSID_DirectMusicLoader, NULL,
CLSCTX_INPROC, IID_IDirectMusicLoader8,
(void**)&pLoader); //创建pLoader对象
CoCreateInstance(CLSID_DirectMusicPerformance, NULL,
CLSCTX_INPROC, IID_IDirectMusicPerformance8,
(void**)&pPerf ); //创建pPerf对象
pPerf->InitAudio(
NULL, //这里可以是一个指向IDirectMusic*对象的指针
NULL, //这里可以是一个指向IDirectSound*对象的指针
hwnd, //窗口句柄
DMUS_APATH_SHARED_STEREOPLUSREVERB, //AudioPath类型
//这里打开了立体声及混响,效果很不错
64, //音乐通道数
DMUS_AUDIOF_ALL, //使用声卡的所有特性
NULL //可以指向一个DMUS_AUDIOPARAMS对象,更详细地说明各种参数
);
7.4.2 关闭DirectX Audio
关闭DXAudio还是老思路,先按7.5.3节的办法停止音乐,然后Release即可:
pPerf->CloseDown(); //关闭
SAFE_RELEASE(pLoader); //释放对象
SAFE_RELEASE(pPerf);
SAFE_RELEASE(pSeg);
CoUninitialize(); //停止使用COM
7.5 播放MIDI和WAV音乐
MIDI音乐和WAV音乐在游戏编程中经常用到。其中前者一般是用作背景音乐,而后者多是用在各种音效方面,如发射导弹等等。虽然我们可以用3.4.4节的方法,但使用DXAudio可以更充分地利用硬件资源,从而实现更少的CPU占用率。方法如下:
7.5.1 调入MIDI和WAV文件
在播放音乐之前,第一步当然是调入音乐文件:
CHAR strSoundPath[MAX_PATH]; //存储音乐所在路径
GetCurrentDirectory(MAX_PATH, strSoundPath); //得到程序所在路径
strcat(strSoundPath, "//Sounds"); //这里设置音乐在程序路径下的Sounds子目录
WCHAR wstrSoundPath[MAX_PATH]; //存储UNICODE形式的路径
//将路径转为UNICODE形式
MultiByteToWideChar(CP_ACP, 0,strSoundPath, -1, wstrSoundPath, MAX_PATH);
pLoader->SetSearchDirectory(
GUID_DirectMusicAllTypes, //搜索所有支持的格式
wstrSoundPath,
FALSE
);
WCHAR wstrFileName[MAX_PATH]; //存储UNICODE形式的文件名
//将文件名转为UNICODE形式
MultiByteToWideChar(CP_ACP, 0, "a.mid", -1, wstrFileName, MAX_PATH);
pLoader->LoadObjectFromFile(
CLSID_DirectMusicSegment, //文件类型
IID_IDirectMusicSegment8, //目标对象类型
wstrFileName, //文件名,同样应为UNICODE形式
(LPVOID*) &pSeg //目标对象
);
7.5.2 播放MIDI和WAV文件
调入完音乐之后,在适当的时候可以开始播放音乐:
pSeg->SetRepeats(音乐要重复的次数); //DMUS_SEG_REPEAT_INFINITE为无限次
pSeg->Download( pPerf ); //将音乐数据传给pPerf
pPerf->PlaySegmentEx(
pSeg, //要播放的音乐
NULL, //现在只能是NULL
NULL,
0,
0,
NULL,
NULL,
NULL //Audiopath,现在先不要管它是什么
);
7.5.3 停止播放
停止播放音乐也是非常简单的:
pPerf->Stop(
NULL, //停止哪个通道,NULL代表所有通道
NULL,
0, //经过多少时间才停止播放
0
);
7.6 在3D空间中播放音乐
我们的下一个问题是如何使音乐更逼真,最好能使音乐3D化,这将大大加强真实性。要实现这个其实也不难,首先我们要设定所谓的AudioPath,它可以指定音乐的播放方式。
IDirectMusicAudioPath8* pPath; //AudioPath
pPerf->CreateStandardAudioPath( //创建AudioPath
DMUS_APATH_DYNAMIC_3D, //3D的
64, //通道数
TRUE, //是否立即激活AudioPath
&pPath //要创建的AudioPath
);
然后,我们要从AudioPath中得到一个"3D缓存",它将存储音乐的播放位置等信息。
IDirectSound3DBuffer8* pBuffer; //3D缓存
pPath->GetObjectInPath(
DMUS_PCHANNEL_ALL, //搜索全部通道
DMUS_PATH_BUFFER, //为DirectSound 缓存
0,
GUID_NULL,
0,
IID_IDirectSound3DBuffer8, //缓存类型
(LPVOID*) &pBuffer //要创建的缓存
);
下一步是设定音乐在3D空间的何处播放。例如:
pBuffer->SetPosition( -0.1f, 0.0f, 0.0f, DS3D_IMMEDIATE );
这条指令把音乐的播放位置设为(-0.1f, 0.0f, 0.0f),由于默认的听众位置在坐标(0, 0, 0)处,脸朝着Z轴的正方向,头朝着Y轴正方向,所以其效果将是听众的右耳听得到声音但左耳听不到声音,就像音乐是从自己的正右方发出。
如果把最后一个参数设为DS3D_DEFERRED,此操作将被挂起直到调用IDirectSound3DListener8 :: CommitDeferredSettings( )为止,这样可以一次处理多个设定防止反复计算。
我们还可以设置音乐源的速度及其播放角度:
pBuffer->SetVelocity(vx,vy,vz,DS3D_IMMEDIATE);//设置在x,y,z轴方向的速度
//设置播放角度大小(度),inncone为内角度,outcone为外角度
//音乐在内角度范围内不衰减,在内外角度之间慢慢衰减,超出外角度时消失
pBuffer->SetConeAngles(inncone, outcone, DS3D_IMMEDIATE);
pBuffer->SetConeOrientation(x, y, z, DS3D_IMMEDIATE); //设置朝哪个方向播放
那么我们如何设定听众的位置呢?可以这样:
IDirectSound3DListener8* pListener;
pPath->GetObjectInPath( //创建听众
0,
DMUS_PATH_PRIMARY_BUFFER,
0,
GUID_NULL,
0,
IID_IDirectSound3DListener8,
(LPVOID*) &pListener
);
//设置听众面向(x1,y1,z1),头朝着(x2,y2,z2)
pListener->SetOrientation(x1,y1,z1,x2,y2,z2,DS3D_IMMEDIATE);
pListener->SetPosition(x,y,z,DS3D_IMMEDIATE); //听众位置
pListener->SetVelocity(vx,vy,vz,DS3D_IMMEDIATE); //听众速度
7.7 播放MP3音乐
MIDI音乐的问题是对声卡的依赖性过大,好声卡和差声卡的播放效果实在相差太远。WAV音乐虽然绝对足够精确,但占用的空间之大不可小视。MP3恐怕是一个较好的解决方案。值得注意的是,播放MP3并不需要DirectX Audio,需要的是DirectShow。所以,我们要#include ,并在工程中加入strmiids.lib。
7.7.1 调入MP3文件
下面把初始化DirectShow和调入MP3合起来说说吧。首先,我们要定义三个对象,其中IGraphBuilder*类型的可以认为是媒体播放设备,IMediaControl*类型的变量负责媒体的播放控制,而IMediaPosition*类型的变量负责媒体的播放位置设定。
IGraphBuilder* pGBuilder;
IMediaControl* pMControl;
IMediaPosition* pMPos;
CoInitialize(NULL); //初始化COM
//创建各个对象
CoCreateInstance(CLSID_FilterGraph, NULL,
CLSCTX_INPROC, IID_IGraphBuilder, (void**)&pGBuilder);
pGBuilder->QueryInterface(IID_IMediaControl, (void**)&pMControl);
pGBuilder->QueryInterface(IID_IMediaPosition, (void**)&pMPos);
CHAR strSoundPath[MAX_PATH]; //存储音乐所在路径
WCHAR wstrSoundPath[MAX_PATH]; //存储UNICODE形式的路径
GetCurrentDirectory(MAX_PATH, strSoundPath);
strcat(strSoundPath, "//Sounds//");
strcat(strSoundPath, "a.mp3"); //假设要播放的是Sounds子目录下的a.mp3
MultiByteToWideChar(CP_ACP, 0, strSoundPath, -1,wstrSoundPath, MAX_PATH);
pGBuilder->RenderFile(wstrSoundPath, NULL); //调入文件
7.7.2 播放MP3文件
播放MP3的方法十分简单:
pMPos->put_CurrentPosition(0); //移动到文件头
pMControl->Run(); //播放
7.7.3 停止播放和释放对象
最后,我们要停止播放音乐并释放各个对象:
pMControl->Stop(); //停止播放
//释放对象
SAFE_RELEASE(pMControl);
SAFE_RELEASE(pMPos);
SAFE_RELEASE(pGBuilder);
CoUninitialize(); //释放COM
第八章 支撑游戏的基石
在这一章中,我们将看看数据结构,算法和人工智能在游戏中的应用。我想每个人都知道“人工智能”这个字眼吧,但数据结构和算法是干什么的呢?说简单点,数据结构就是在程序中各种数据的组织形式,而算法就是处理这些数据的方法。Niklaus Wirth曾说过“数据结构+算法=程序”,可见其重要性。
8.1 链表
链表是一种灵活的数据结构,它可以说是把指针用到了极致。最简单的链表是由一个个像这样的节点组成的:
struct node //节点
{
int data; //节点数据
node* next; //指向下一个节点的指针
};
一个个链表的节点就像一节节火车车厢一样通过next指针一个接一个地连接着,当我们在链表中查找数据时,我们也要一个接一个地往下找。可以想象,在链表的任何位置添加新节点都是十分简单的,而删除链表中的某个节点时也只要把它的父节点指向它的子节点即可。正因为链表有这些特点,它被广泛地应用于各种元素的个数或是元素的排列顺序经常需要改变的场合。
我们还可以使用双向链表,即再使用一个指向上一个节点的指针。这将使链表变得更加方便——可以从后往前查找节点,但同时也增大了链表的大小。
链表在游戏编程中有不少应用,例如组织游戏中像精灵(Sprite,指游戏中会移动的东西)这样的经常需要修改的元素。
8.2 哈希表
使用哈希表(Hash Table)可以大大减少查找工作的时间。举一个简单的例子,如果你要在一本字典中找某单词,那你应该怎样做呢?如果不使用哈希表,那么你似乎只能一个个找下去。当然,我们知道字典是排好序的,所以大可使用二分查找等更快的方法。但如果是职工编号等完全无序的数据呢?这时,我们需要一张哈希表。
怎么建立哈希表呢?所谓哈希表,其实是一个很简单的东西,它可以说是为数据建立了一个索引。还是上面那个例子,我们首先应该通过某一个函数的变换,把字典里的所有单词变成一些尽量不相同的数。如果能做到完全不相同的话,这个函数就是一个完美的哈希函数。当然,这显然比较难。一个比较糟糕的哈希函数——我们给它起个名字叫f(x) ——就是按单词的头一个字母,把单词转换成0到25之间的数,就像我们平常查字典时那样。好一点的解决方案是把单词的所有字母都这样转换一下,然后再加起来,对某一个大数取模。下一步,我们建立一个大数组HashTable,它的大小要能容纳所有哈希函数的可能取值,对于f(x),我们要开一个HashTable[26]。然后我们把这个数组的每一个元素都变成一个链表,把对应的单词一个接一个地放进去(其实把单词转换成数后就应该立刻把它放进数组)。此时HashTable[0]的内容就像这样:"a"?"Aachen"?"Aalborg"?"aardvark"?…
现在大家看出来了吧,是的,我们只要把我们要找的单词通过f(x)转换成一个数,然后再在HashTable[f(x)]中查找即可。哈希函数取得越好,相同哈希函数的单词就越少,我们的查找就越快。然而不容忽视的是,数组很可能也变大了。所以哈希表是用空间换时间。
关于哈希函数的选取,和如果有两个元素哈希函数值相同时的处理,现在都研究得比较多。比如说有一种办法是:在HashTable[f(x)]已被其它元素占据时,看看HashTable[g(f(x))]是否是空的,如果还不行就看HashTable[g(g(f(x)))],直到行为止。g(x)可以是x+1之类。显然,如果使用这种办法,HashTable[]的大小要比元素的个数多。这种办法避免了链表的使用,但增加了计算g(x)的花费。总之,一切要根据实际情况选择。
最后给一个比较好用的Hash函数给大家吧,它能将一个单词转为一个整数:
int Hashf(const char *s)
{
int hash, i, l;
hash = 0;
l = strlen(s);
for(i=0; i
hash=(hv*26+s[i]-'a')%size; //size为hash表大小
return hv;
}
8.3 快速排序
最常用的算法之一是排序算法。由于对排序算法的速度要求较高,我们通常使用快速排序。其算法如下:
void QuickSort(int begin, int end) //对数组a排序,start为开始排序的位置,end为排
{ //序结束位置,例如a[10]则start=0,end=9。
int p=begin;
int q=end;
int mid=a[p]; //标准数
int temp;
while(p
{
while (a[p]
while (a[q]>mid) q--; //数组右边的数大于等于标准数
if (p<=q)
{
temp=a[p];
a[p]=a[q];
a[q]=temp; //交换a[p]、a[q]。
p++; q--;
}
}
if (q>begin) QuickSort(begin,q); //继续对前半部分排序
if (p
}
其实快速排序的思路是不难理解的,首先在头尾设置两个指针然后向中间移动,发现两指针所指的数交换会改善排序状况则交换,如两指针到达或越过了对方那么就表明已经把数分成了两组,再递归调用自己对这两组分别实施同一过程即可。
8.4 深度优先搜索
下面就讲讲图的搜索算法,它们在找路和AI中都很有用。最常用的图搜索算法是深度优先搜索(DFS)和广度优先搜索(BFS)。
深度优先搜索,即能走就走,若有多条路可走则按照一定的次序选择(如上下左右),但不走回头路。如果无路可走就退回。显然这种方法不一定能找到最短的路径,但它对内存的要求很小。由于与真实的找路过程有相似之处,所以可以让精灵直接按搜索的过程移动,不需任何等待。不过由于上下左右的次序太机械,精灵一开始并不是朝着最短的路线走去,所以移动路线还不够真实,特别在比较空阔的时候会容易找不到路。
例如,下面的地图(黑色的格子代表墙,其它格子都是可以行走的)如果要从左上角走到右下角,深度优先搜索的次序如下(A->Z->a->o):
?????????????? ??? ???????????????????????
??? ????A???a???d?e?f??????
??? ????B???Z?b?c???g??????
??C?X?Y???????h??????
??? ????D???????????i?j????
??? ????E???L?????T???k?l??
??? ????F?J?K?N?O?S?U???m??
??? ????G???M???P???V???n??
??? ????H?I???R?Q???W???o??
??? ???????????????????????
图8.1
下面就是标准的深度优先搜索程序:
#include
#include
using namespace std;
#define SX 10 //宽
#define SY 10 //长
int dx[4]={0,0,-1,1}; //四种移动方向对x和y坐标的影响
int dy[4]={-1,1,0,0};
char Block[SY][SX]= //障碍表
{{ 0,1,0,0,0,0,0,0,0,0 },
{ 0,1,1,0,1,1,1,0,0,0 },
{ 0,0,0,0,0,0,0,0,0,0 },
{ 1,1,1,0,1,0,0,0,1,0 },
{ 0,1,0,0,1,0,1,1,1,0 },
{ 0,1,0,0,1,1,1,1,1,0 },
{ 0,0,0,1,1,0,0,0,1,0 },
{ 0,1,0,0,0,0,1,0,1,0 },
{ 0,1,1,1,0,1,1,0,1,1 },
{ 0,0,0,0,0,0,1,0,0,0 }};
int MaxAct=4; //移动方向总数
char Table[SY][SX]={0}; //是否已到过
int Level=-1; //第几步
int LevelComplete=0; //这一步的搜索是否完成
int AllComplete=0; //全部搜索是否完成
char Act[1000]={0}; //每一步的移动方向,搜索1000步
int x=0,y=0; //现在的x和y坐标
int TargetX=9,TargetY=9; //目标x和y坐标
void Test( );
void Back( );
int ActOK( );
void main( )
{
Table[y][x]=1; //做已到过标记
while (!AllComplete) //是否全部搜索完
{
Level++;LevelComplete=0; //搜索下一步
while (!LevelComplete)
{
Act[Level]++; //改变移动方向
if (ActOK( )) //移动方向是否合理
{
Test( ); //测试是否已到目标
LevelComplete=1; //该步搜索完成
}
else
{
if (Act[Level]>MaxAct) //已搜索完所有方向
Back( ); //回上一步
if (Level<0) //全部搜索完仍无结果
LevelComplete=AllComplete=1; //退出
}
}
}
}
void Test( )
{
if ((x==TargetX)&&(y==TargetY)) //已到目标
{
for (int i=0;i<=Level;i++)
cout<<(int)Act[i]; //输出结果
LevelComplete=AllComplete=1; //完成搜索
}
}
int ActOK( )
{
int tx=x+dx[Act[Level]-1]; //将到点的x坐标
int ty=y+dy[Act[Level]-1]; //将到点的y坐标
if (Act[Level]>MaxAct) //方向错误?
return 0;
if ((tx>=SX)||(tx<0)) //x坐标出界?
return 0;
if ((ty>=SY)||(ty<0)) //y坐标出界?
return 0;
if (Table[ty][tx]==1) //已到过?
return 0;
if (Block[ty][tx]==1) //有障碍?
return 0;
x=tx;
y=ty; //移动
Table[y][x]=1; //做已到过标记
return 1;
}
void Back( )
{
x-=dx[Act[Level-1]-1];
y-=dy[Act[Level-1]-1]; //退回原来的点
Table[y][x]=0; //清除已到过标记
Act[Level]=0; //清除方向
Level--; //回上一层
}
输出结果是224442221322244414422244,其中1代表上,2代表下,3代表左,4代表右。
8.5 广度优先搜索
与深度优先搜索相对应的是广度优先搜索。这种方法的思路很简单,就是先搜索一步可到的点,再搜索两步可到的点......如此直到找到目标点为止。这种搜索方法显然能保证走的是最短路径,搜索速度也较快,不过对空间的占用较大。请看标准广度优先搜索程序:
#include
#include
using namespace std;
#define SX 10 //宽
#define SY 10 //长
int dx[4]={0,0,-1,1}; //四种移动方向对x和y坐标的影响
int dy[4]={-1,1,0,0};
char Block[SY][SX]= //障碍表
{{ 0,1,0,0,0,0,0,0,0,0 },
{ 0,1,1,0,1,1,1,0,0,0 },
{ 0,0,0,0,0,0,0,0,0,0 },
{ 1,1,1,0,1,0,0,0,1,0 },
{ 0,1,0,0,1,0,1,1,1,0 },
{ 0,1,0,0,1,1,1,1,1,0 },
{ 0,0,0,1,1,0,0,0,1,0 },
{ 0,1,0,0,0,0,1,0,1,0 },
{ 0,1,1,1,0,1,1,0,1,1 },
{ 0,0,0,0,0,0,1,0,0,0 }};
int AllComplete=0; //全部完成标志
int LevelNow=1, //搜索到第几层
Act, //现在的移动方向
ActBefore, //现在的节点的父节点
MaxAct=4, //移动方向总数
ActNow, //现在的节点
tx,ty; //当前坐标
int LevelFoot[200] = {0}, //每一层最后的节点
ActHead[3000] = {0}; //每一个节点的父节点
char AllAct[3000] = {0}; //每一个节点的移动方向
char ActX[3000] = {0}, ActY[3000] = {0}; //按节点移动后的坐标
char Table[SY][SX] = {0}; //已到过标记
char TargetX=9,TargetY=9; //目标点
int ActOK( );
int Test( );
int ActOK( )
{
tx=ActX[ActBefore]+dx[Act-1]; //将到点的x坐标
ty=ActY[ActBefore]+dy[Act-1]; //将到点的y坐标
if ((tx>=SX)||(tx<0)) //x坐标出界?
return 0;
if ((ty>=SY)||(ty<0)) //y坐标出界?
return 0;
if (Table[ty][tx]==1) //已到过?
return 0;
if (Block[ty][tx]==1) //有障碍?
return 0;
return 1;
}
int Test( )
{
if ((tx==TargetX)&&(ty==TargetY)) //已到目标
{
int act=ActNow;
while (act!=0)
{
cout<<(int)AllAct[act]; //一步步向前推出所有移动方向
act=ActHead[act]; //所以输出倒了过来
}
return 1;
}
return 0;
}
void main()
{
LevelNow=1;
LevelFoot[1]=0;
LevelFoot[0]=-1;
ActX[0]=0;
ActY[0]=0;
while (!AllComplete)
{
LevelNow++; //开始搜索下一层
LevelFoot[LevelNow]=LevelFoot[LevelNow-1];
//新一层的尾节点先设为与上一层相同
for (ActBefore=LevelFoot[LevelNow-2]+1;
ActBefore<=LevelFoot[LevelNow-1];
ActBefore++) //对上一层所有节点扩展
{
for (Act=1;Act<=MaxAct;Act++) //尝试所有方向
{
if ((ActOK( )) && (!AllComplete)) //操作可行?
{
LevelFoot[LevelNow]++; //移动尾指针准备加入新节点
ActNow=LevelFoot[LevelNow]; //找到加入新节点位置
ActHead[ActNow]=ActBefore; //置头指针
AllAct[ActNow]=Act; //加入新节点
ActX[ActNow]=tx;
ActY[ActNow]=ty; //存储移动后位置
Table[ty][tx]=1; //做已到过标记
if (Test( )) AllComplete=1; //完成?
}
}
}
}
}
输出结果是4422244144422322244422,倒过来就是2244422232244414422244,确实比DFS找到的路径短。
使用DFS和BFS可解决各式各样的问题。下面就给大家出一道著名的中学生计算机竞赛题,看看你能不能较快地解决它:
有三个没有刻度的桶,容量分别为3升、5升、8升。现在8升的桶是满的,你可以将水在桶中倒来倒去。例如,首先8->3,那么8升桶内将会有5升水,3升桶会被装满;然后3->5,那么3升桶将被倒空,5升桶内将有3升水。你的目标是平分这8升水,即使5升桶和8升桶内均有4升水。
8.6 启发式搜索
大家听过一个叫A*的东东吗?A*就是一种启发式搜索方法。当然,你没听过也不要紧,下面就讲讲启发式搜索。启发式搜索的核心是一个估价函数F(x),扩展节点时先扩展F(x)值小的节点。F(x)又等于G(x)+H(x),其中G(x)为从起始状态到当前状态的代价,一般就是已经搜索了多少步;而H(x)则是当前状态到目标状态的估计代价,即估计还有多少步就可到目标。为了保证搜索到的是最优解,H(x)必须大于或等于实际代价,F(子节点)也必须大于或等于F(父节点)。就找路的问题来说,我们可以把H(x)定为离目标的直线距离,显然这样就可以满足上面的两个条件,保证找到的是最短路径。启发式搜索的好处是速度快而且占用空间不多。
显然,这个算法的关键有两点:一,如何快速地在大量节点中找到F(x)最小的节点;二,如何在节省空间的情况下快速判断一个节点是否已扩展过(其实这也是前面的DFS和BFS应用于寻路时需要解决的,靠一个数组Table[SY][SX]对空间占用过大,不过话又说会来,如果你的搜索限定在小范围内,例如几个屏幕,用数组的办法更好)。首先,我们来看看要使用的数据结构:
#include
using namespace std;
int node_count = 0; //目前的待扩展节点数
int allnode_count = 0; //目前的节点数
#define SX 10 //地图宽
#define SY 10 //地图长
#define MAX_NODE 100 //允许同时存在多少待扩展节点
#define MAX_ALLNODE 1000 //允许节点数
#define MAX_HASH 1999 //Hash表大小,最好是质数
int tx = 9, ty = 9; //目标坐标
int dx[4] = {0,0,-1,1};//四种移动方向对x和y坐标的影响
int dy[4] = {-1,1,0,0};
char Block[SY][SX] = //障碍表
{{ 0,1,0,0,0,0,0,0,0,0 },
{ 0,1,1,0,1,1,1,0,0,0 },
{ 0,0,0,0,0,0,0,0,0,0 },
{ 1,1,1,0,1,0,0,0,1,0 },
{ 0,1,0,0,1,0,1,1,1,0 },
{ 0,1,0,0,1,1,1,1,1,0 },
{ 0,0,0,1,1,0,0,0,1,0 },
{ 0,1,0,0,0,0,1,0,1,0 },
{ 0,1,1,1,0,1,1,0,1,1 },
{ 0,0,0,0,0,0,1,0,0,0 }};
struct NODE //待扩展节点的资料
{
int x,y,f,level,n;
}
node[MAX_NODE];
struct //节点的资料
{
int act,father;
}
allnode[MAX_ALLNODE];
struct //Hash表,用来判断节点是否已访问过
{
int x,y;
}
Hash[MAX_HASH];
所有待扩展节点储存在一个数组node[ ]中。node[ ]的每一个元素都是一个结构NODE{ int x,y,f,level,n; },其中x和y是节点坐标,f是节点的F函数值,level是节点位于何层,n是节点在allnode[ ]中的位置。node[1]存储的永远是F函数值最小且未扩展的节点,这是通过AddNode( )和GetNode( )实现的。最后还有一个node_count,存储目前节点的实际数量。
数组node[ ]的实际结构是类似这样的:
图8.2
有点像一棵树吧,这棵树的最大特点是父节点的F函数值永远比子节点的小,例如node[1].f node[x/2].f(x>1)。那么我们如何将一个新节点加入才能保持这棵树的性质呢?首先,我们要把node_count++,这时node[node_count](按照图8.x就是node[10],它是node[5]的子节点)空了出来。然后,我们将这个新节点和它的父节点的F函数值比较。如果新节点的较小,就把它的父节点复制到新节点处(比如node[10]=node[5];),新节点的预备位置上移到父节点处,这样一直做下去;如果父节点的较小,子节点的位置就已可确定。代码是这样的:
void AddNode(int x, int y, int act, int level, int father)
{
if ((x>=SX) || (x<0) || (y>=SY) || (y<0) ||
(Block[y][x]) || (CheckHash(x,y))) //CheckHash(x,y)可检查节点是否访问过
return;
node_count++;
int p = node_count, q;
int f = level + abs(x-tx) + abs(y-ty); //启发函数定义
while( p > 1 )
{
q = p >> 1;
if( f < node[q].f )
node[p] = node[q];
else
break;
p = q;
}
node[p].x = x;
node[p].y = y;
node[p].f = f;
node[p].level = level;
node[p].n = allnode_count;
allnode[allnode_count].act = act;
allnode[allnode_count].father = father;
allnode_count++;
Add2Hash(x, y); //加入Hash表
}
如何从node[ ]中取出node[1]呢?直接取出谁都会,问题是剩下的一堆子节点和子子节点如何处理。不过其实也不难,首先,我们在node[1]的两个子节点中找一个F( )较小的,比如说node[2]吧,把它移上来;然后再在这个子节点的两个子子节点node[4]和node[5]中找一个F( )较小的,把它移到node[2]的位置。这样一直做下去,直到最后一层。那么在下面就空出了一个位置,我们可以把node[node_count]移到那里,并把node_count--。
NODE GetNode()
{
NODE Top = node[1];
int p,q;
NODE a = node[node_count];
node_count--;
p = 1;
q = p * 2;
while( q <= node_count )
{
if( (node[q+1].f < node[q].f) && (q < node_count) )
q++;
if( node[q].f < a.f )
node[p] = node[q];
else
break;
p = q;
q = p * 2;
}
node[p] = a;
return Top;
}
接下来,我们看看Add2Hash函数是如何把节点加入Hash表的:
void Add2Hash(int x, int y)
{
int f = (x * SY + y) % MAX_HASH; //Hash函数定义
for (;;)
{
if ((Hash[f].x) || (Hash[f].y)) //若x和y均为0则该Hash位置未被占用
{
f++; //如已占用就看下一个位置
}
else //找到一个未占用位置
{
Hash[f].x = x + 1; //加上1以防止与未占用Hash位置混淆
Hash[f].y = y + 1;
break;
}
}
}
那么CheckHash( )又是如何工作的呢:
int CheckHash(int x, int y)
{
int f = (x * SY + y) % MAX_HASH;
for (;;)
{
if ((Hash[f].x) || (Hash[f].y))
{
if ((Hash[f].x == x + 1) && (Hash[f].y == y + 1))
{
return 1; //找到了
}
else
f++; //找下一个位置
}
else
{
return 0; //找不到
}
}
}
最后,我们来看看主程序:
void main()
{
int level = 0;
AddNode(0,0,-1,0,0);
for(;;)
{
NODE a = GetNode(); //取出一个节点
level = a.level + 1;
if ((a.x == tx) && (a.y == ty)) //是目标吗?
break;
for (int i = 0; i<4; i++)
{
AddNode(a.x + dx[i], a.y + dy[i], i, level, a.n); //扩展此节点
}
}
//开始输出节点
allnode_count--;
while(allnode[allnode_count].act != -1)
{
cout << allnode[allnode_count].act + 1;
allnode_count = allnode[allnode_count].father;
}
}
由于在上面的程序中人物的移动方向被限制在四个方向,我们得到的路径一定是过分曲曲折折的(这也是DFS和BFS在游戏寻路方面的小遗憾),所以我们下一步需要把它“拉直”。一种十分简单的拉直思路是:
while (!已全部拉直)
{
已全部拉直=true;
for (i=0到节点数)
{
for (j=i到节点数)
{
if (i到j的直线不通过任何障碍) //这是最需要优化的一步
{
去除i与j之间的节点;
已全部拉直=false;
}
}
}
}
应该如何最快速地实现这段伪代码呢?我们可以先粗略地扫描几次,把最常见的弯路径拉直,这样节点数将大大减少,随便用任何方法判断i与j间有无障碍都将很快。
8.7 动态规划
动态规划是一种非常有趣的算法。它可以用极其简洁的语句漂亮地完成复杂的任务。
我们先来看一张地图(线上的数字代表距离):
图8.3
为了求出A地到B地的最短距离,我们可以使用动态规划,其程序是这样的:
#include
using namespace std;
const int n=6; //有多少个地方
//两地之间的距离,若为很大的数,如99999,表示两地间无道路
//自己到自己的距离应定为0
int dis[n][n] =
{{ 0, 5, 8, 99999, 99999, 99999 },
{ 5, 0, 2, 7, 99999, 99999 },
{ 8, 2, 0, 1, 6, 99999 },
{ 99999, 7, 1, 0, 99999, 4 },
{ 99999, 99999, 6, 99999, 0, 3 },
{ 99999, 99999, 99999, 4, 3, 0 }};
int d[n]; //某地离A地的距离
void main()
{
d[0]=0; //A地离A地的距离是0
for (int i=1;i
{
d[i]=99999; //除A地外,其它地方到A地的距离都估计为一个很大的数
}
for (i=0;i
{
for (int j=0;j
{
for (int k=0;k
{
if (d[j]>d[k]+dis[j][k]) //如果距离可改进
{
d[j]=d[k]+dis[j][k]; //改进之
}
}
}
}
cout<
}
其实这个程序有点大材小用,因为我们只要稍加修改即可用同样的算法求出地图中任意两点间的最短距离。这个算法的主要缺点大家也许都看出来了,就是实在太慢了。只要节点数一多,三重循环就会耗去大量的时间。
8.8 神经网络
相信大家第一次玩WorldCraft3时会输给电脑吧,但是和电脑打得多了,摸透了它的战术之后就可以轻易取胜,因为人有学习的能力,电脑却不然。那么用什么办法才能使电脑的思考方式接近于人类,并拥有自我学习的能力呢?神经网络(Neural Networks)是一种目前很热门的实现方法。我们知道,人类的神经系统是由神经元构成的,神经网络也不例外,只不过在神经网络中神经元的反应被大大地简化了。一个典型的神经网络的神经元由任意多个输入端和输出端组成,其输出等于1/(1+e-s),s为所有输入值*权重(每个输入值都有一个相应的权重,待会训练时改变的就是权重的值)之和。
例如,如果一个神经元的输入分别为:x1=0.5,x2=0.8,x3=0;对应的权重为:w1=0.1,w2=0,w3=1,那么s=0.5*0.1+0.8*0+0*1=0.05,神经元的输出为1/(1+e-0.05)=0.512。
用多个神经元可以构建起复杂的网络,就像这样:
图8.4
如果我们给x1输入1,x2输入0,那么位于左上的神经元会输出0.881,位于左下的神经元会输出0.500,这两个结果和1输入最后一个神经元中,最终结果f为0.655。
下面我们来看看如何训练网络。还是上面这个例子,如果我们想用这个网络实现奇偶检验(如果x1与x2均为0或1,那么f=1;否则,即如果x1与x2一个为0一个为1的话,f=0),那么我们可以这样训练此网络:
①计算最后一个神经元的误差δ,它等于(d-f)*f*(1-f),其中d为期望输出,f为实际输出。在这个例子里,δ=(0-0.655)*0.655*(1-0.655)=-0.148。
②倒推上一层网络的每一个神经元的误差。举个例子,左上角的神经元只有一个输出端,我们首先要计算其指向的神经元的δ与此路径的w之积(如果有多个输出端,将所有输出端的这个积加起来),在这里是-0.148*3=-0.444。这个神经元的输出是0.881,那么我们将-0.444*0.881*(1-0.881),得到此神经元的δ为-0.047。同样的道理,左下角神经元的δ为0.074。
③对于每条路径,其新w=w+δx,δ为此路径指向的神经元的δ,x为此路径的输入值。这样,左上角神经元的w变成了1.953,-2和-0.047;左下角神经元的w变成了1.074,3和-0.926;最后一个神经元的w变成了2.870,-2.074和-1.148。
如果这时我们再输入刚才的数据,会发现f变成了0.563,的确比训练前的0.655更接近期望值0。
8.9 遗传规划
遗传规划(Genetic Programming)又是一个天才的构想,它从进化论中吸取灵感,通过就像自然生物的"过度繁殖,生存斗争,遗传和变异"的方法实现算法的自我进化。还是用一个实际问题来说明GP的方法吧:如何使精灵自动找到附近的墙并永远绕着墙走?
在这里我们假定精灵所在的世界是二维的,而且是一格格的,精灵有八个传感器:n, ne, e, se, s, sw, w, nw,当其对应的方向不能行走时传感器返回1否则返回0,精灵有四条指令:north,east,south,west,代表向相应的方向移动一格。那么这个程序可以实现我们想要的功能:
图8.5
这里IF操作符的意义是:如果最左边的分支的运算结果为1那么执行中间的分支否则执行最右边的分支。事实上你还可以定义其它的操作符和指令,把任何程序都写成这种格式。
现在的问题是,怎样用GP的方法找到这样的程序?第一步是随机生成大批小程序,然后就开始对它们进行优化,你可以自由选择方法,一个典型的方法是:
①随机选出7个程序,测试哪个的效果最好,将其加入下一代中。这样重复直到10%的程序被复制。
②其余90%的下一代程序是通过"杂交"形成的。程序的父母由再次进行7选1得到,杂交的方法是在父母程序上各随机选择一个杂交点,然后将杂交点及杂交点之下的整个分支交换,交换后的父程序或母程序进入下一代。
③有的时候可以进行"基因突变",方法是将7选1得到的程序树的某一个分支用随机生成的新分支代替。突变的概率可以定为1%,事实上自然界中突变的概率还要小得多。
第九章 向三维世界迈进
在DirectX 8.0中,Microsoft公司把原有的DirectDraw和Direct3D合并成为DirectX Graphics(但仍可使用这些老部件),并进行了许多修改使其变得更强大和易于使用。DirectX 9.0又在DirectX 8.0的基础上做了许多改进。使用DirectX Graphics,我们可以充分利用硬件资源编写出画面精美的3D游戏。
9.1 概述
我们都知道,为了确定空间中的一个点的位置,我们需要使用三个坐标。在DXGraphics中,三根坐标轴的伸展方向如图9.1所示。
我们还知道,目前的3D游戏中的物体实际上都是由一个个有着贴图的三角形构成的。事实上,在DXGraphics中的3D物体也可以由一个个点或一条条线组成(不过,这叫3D物体吗??)。而线和三角形又是由顶点(Vertex)构成的。无论是点、线还是三角形,在DXGraphics中都叫Primitive。 下面再说一个概念:纹理(Texture),我想大多数人都知道这是什么吧。 图9.1
直接创建出来的3D物体是很不真实的,因为它的每个三角形的表面都是一种颜色,不像日常生活中的东西。而纹理就像是三角形的皮肤一样,赋予它不再单调的色彩。
图9.2
我们会发现,原来的3D物体是白色的,但世界上显然有别的颜色的物体。为了表明物体在光线照射下呈现出的颜色,我们还需要使用材质(Material)。用过3DMAX的人都知道,一个物体的材质由四个值决定,它们分别是:
①Diffuse
物体在普通光下漫反射显示的颜色。
②Ambient
物体在环境光照射下显示的颜色。
③Specular
物体的高光区域的颜色。
④Emissive
如果物体会发光,发出的光的颜色。
接下来还有一个重要的概念:Mipmap。在3D游戏中,有的物体离我们远,有的物体离我们近,同样的纹理会随着距离的不同而显示出来的大小不同(废话)。在物体离我们很远时,我们就不需要使用那么大的纹理,这样可以提高点显示速度并使图像看上去更舒服,这就是Mipmap技术(好像有点道理)。然而使用这种技术的一个后果是对内存或显存的消耗也大了,因为需要存储几张大小不同的纹理。一个Mipmap序列是由一系列大小依次减半的纹理构成的,就像图9.3:
图9.3
最后我们看看使用DXGraphics的游戏的主流程:
Main( )
{
初始化变量和指针;
初始化窗口;
初始化DirectX Graphics;
设置场景;
创建场景;
for(;;) //主循环
{
if (有消息)
{
if (为退出信息)
{
释放DirectX Graphics;
释放指针;
退出;
}
if (为用户有效输入)
{
处理;
}
else
{
调用缺省消息处理函数;
}
}
else if (程序激活)
{
清除场景; (Clear)
开始场景; (BeginScene)
渲染场景;
结束场景; (EndScene)
显示场景; (Present)
如果设备丢失,恢复之;
刷新游戏的其它部分;
}
else
{
等待消息;
}
}
}
基本概念就先讲到这里吧,让我们先来看看如何初始化DirectX Graphics。
9.2 基本知识
9.2.1 初始化DXGraphics
就像初始化DirectDraw,在初始化DirectX Graphics前,我们先要做一些准备工作:在程序开始前包含 、 等常用的头文件,并在工程中加入"d3d9.lib"和"d3dx9.lib"。接着我们就可以执行像下面这样的程序进行初始化:
#define _FullScreen //在这里可以方便地设定程序的运行方式——窗口,还是全屏幕
int InitGraph(HWND hwnd) //初始化DirectX Graphics需要窗口句柄
{
LPDIRECT3D9 pD3D; //D3D对象
LPDIRECT3DDEVICE9 pDev; //D3D设备
D3DDISPLAYMODE d3ddm; //D3D显示模式
D3DPRESENT_PARAMETERS d3dpp; //D3D图像显示方法
if(NULL == (pD3D = Direct3Dcreate9(D3D_SDK_VERSION)))
return -1; //创建D3D对象,D3D_SDK_VERSION为在d3d9.h中定义的版本号
if(FAILED(pD3D -> GetAdapterDisplayMode(D3DADAPTER_DEFAULT,&d3ddm)))
return -1; //得到当前的显示模式
ZeroMemory(&d3dpp, sizeof(d3dpp)); //清空d3dpp,准备填充内容
#if defined(_Windowed) //如果需要程序在窗口模式运行…
d3dpp.Windowed = TRUE; //设定窗口模式
d3dpp.SwapEffect = D3DSWAPEFFECT_DISCARD; //设定换页效果为丢弃后台缓存
d3dpp.BackBufferFormat = D3DFMT_UNKNOWN; //设定后台缓存格式为未知
#endif
#if defined(_FullScreen) //如果需要程序在全屏幕模式运行…
d3dpp.Windowed=FALSE;
d3dpp.hDeviceWindow=hwnd; //窗口句柄
d3dpp.SwapEffect=D3DSWAPEFFECT_FLIP; //设定换页效果为翻页
d3dpp.BackBufferCount=2; //有2个后台缓存
d3dpp.BackBufferWidth=800; //屏幕宽为800像素
d3dpp.BackBufferHeight=600; //屏幕长为600像素
//使用当前设定的此显示模式下的刷新率
d3dpp.FullScreen_RefreshRateInHz=D3DPRESENT_RATE_DEFAULT;
//立即显示刷新后的图像
d3dpp.FullScreen_PresentationInterval=D3DPRESENT_INTERVAL_IMMEDIATE;
#endif
d3dpp.BackBufferFormat=d3ddm.Format; //色彩深度为桌面的色彩深度
//开启自动深度缓存,即自动按正确的遮盖关系显示图像
d3dpp.EnableAutoDepthStencil=TRUE;
d3dpp.AutoDepthStencilFormat=D3DFMT_D16; //16位自动深度缓存
//创建D3D设备
if(FAILED(pD3D->CreateDevice( D3DADAPTER_DEFAULT, //使用主显示设备
D3DDEVTYPE_HAL, //使用3D硬件,若为
//D3DDEVTYPE_REF则只使用CPU
hwnd, //窗口句柄
//下面的参数若为D3DCREATE_HARDWARE_VERTEXPROCESSING则使用硬件T&L
D3DCREATE_SOFTWARE_VERTEXPROCESSING,
&d3dpp, //上面填充的D3D图像显示方法
&pDev ) ) ) //要创建的D3D设备
return -1;
}
9.2.2 关闭DXGraphics
关闭DirectX Graphics是非常简单的,使用4.8节所说的SAFE_RELEASE释放D3D对象和D3D设备即可。
9.2.3 恢复DXGraphics设备
DirectX中不少设备都存在"丢失"现象(比如你按下Alt+TAB切换出DirectX程序时),DirectX Graphics设备也不例外。DirectX9中提供了一个Reset( )函数以帮助我们恢复设备,其用法如下:
//Present在9.5节会介绍,这一句的意思是:如果设备丢失...
if (pDev->Present(NULL,NULL,NULL,NULL)==D3DERR_DEVICELOST)
{
//如果已经可以用Reset()恢复设备...
if (pDev->TestCooperativeLevel()==D3DERR_DEVICENOTRESET)
{
pDev->Reset(&d3dpp); //d3dpp是前面初始化时设置的图像显示方法
重设渲染状态; //见9.3.1节
重设矩阵; //见9.3.2节
}
}
9.3 设置场景
9.3.1 设置渲染状态
DXGraphics中可以设置的渲染状态实在不少,大大小小的有几十条,不过常用的并不多。设置渲染状态的方法是IDIRECT3DDEVICE9::SetRenderState( D3DRENDERSTATETYPE State, DWORD Value);,其中State是要设置的状态,Value是要设置成什么状态。下表列出了常用的渲染状态:
表9.1
State Value 含义
D3DRS_SHADEMODE D3DSHADE_FLAT 平坦光照模式
D3DSHADE_GOURAUD 较平滑的光照模式
D3DRS_LIGHTING TRUE 打开光照
FALSE 关闭光照
D3DRS_AMBIENT ARGB格式的环境光颜色,例如0xFF00FF00为绿光
D3DRS_CULLMODE D3DCULL_NONE 显示全部三角形
D3DCULL_CW 不显示三个顶点呈顺时针排列的三角形
D3DCULL_CCW 不显示三个顶点呈逆时针排列的三角形
D3DRS_FILLMODE D3DFILL_POINT 只显示顶点
D3DFILL_WIREFRAME 只显示由线构成的框架
D3DFILL_SOLID 全部显示
D3DRS_ZENABLE D3DZB_TRUE 打开Z缓存
D3DZB_FALSE 关闭深度缓存
你不必每次刷新都重新设置一次渲染状态,因为一来会减慢速度,二来渲染状态设置了之后一般不会自己改变。
D3DRS_SHADEMODE的默认值是D3DSHADE_GOURAUD,在这种模式下物体的光照效果还可以。如果使用D3DSHADE_FLAT效果会比较糟糕,因为此时物体的一个面只能是一种颜色。
合理地设置D3DRS_CULLMODE,同时合理地创建3D物体,可以实现使被挡住的面部不被渲染,从而减少大约一半的显示工作量。然而要设置好这些东西是挺麻烦的,所以我们一般使用D3DCULL_NONE。
9.3.2 设置矩阵
不知道大家有没有听过"矩阵"这个数学名词,在3D图形编程中我们要大量地使用矩阵对3D物体的坐标进行转换。你可以把最常用的4阶矩阵看作16个数排成的4x4的方阵,就像这样:
图9.3
一个4x4的矩阵可以和一个空间中的坐标(x,y,z)进行乘法运算,得出一个新的坐标(x',y',z')。运算法则是这样的:
x'=(x*M11)+(y*M21)+(z*M31)+M41
y'=(x*M12)+(y*M22)+(z*M32)+M42
z'=(x*M13)+(y*M23)+(z*M33)+M43
在DXGraphics中,空间中的一个点要依次经过三个矩阵的转换才能正确地按要求显示在屏幕上,这三个矩阵分别是World,View和Projection。这三个矩阵都可以使三维形体平移、旋转和放缩,但一般说来World矩阵负责把形体放到场景中正确的位置,View矩阵负责根据观察者的位置和看的方向调整形体,而Projection矩阵负责设置视角和透视矫正(使物体近大远小)等。如果要手工填充这几个矩阵那就太麻烦了,DXGraphics当然提供了方便我们创建这些矩阵的函数,下面我们来看看例子:
D3DXMATRIX matWorld; //定义World矩阵
D3DXMatrixIdentity(&matWorld); //定义其为单位矩阵,即不改变场景
pDev->SetTransform(D3DTS_WORLD,&matWorld); //设置矩阵
D3DXMATRIX matView; //定义View矩阵
D3DXMatrixLookAtLH(&matView,
&D3DXVECTOR3(x, y, z), //眼睛所在的位置
&D3DXVECTOR3(ox, oy, oz), //眼睛向何处看去
&D3DXVECTOR3(0.0f,1.0f,0.0f)); //什么是"上"
pDev->SetTransform(D3DTS_VIEW,&matView); //设置矩阵
D3DXMATRIX matProj; //定义Projection矩阵
D3DXMatrixPerspectiveFovLH(&matProj,
D3DX_PI/2, //视角大小,这里设为π/2弧度,即90o
1.0f, //纵横比
0.01f, //眼睛可以看到多近
500.0f); //眼睛可以看到多远
pDev->SetTransform(D3DTS_PROJECTION,&matProj); //设置矩阵
需要解释一下的是D3DXMatrixLookAtLH函数最后一个参数的意义,你可以这样理解它:如果我们站在坐标原点处,那么它就是指明我们的头朝着哪一个方向。一般来说,我们的头是向着y轴方向的,所以一般把它定为(0,1,0)。
9.4 创建场景
设置完场景后,我们就需要创建一个场景以供显示。这一步有两种实现方法,第一种是自己把所有点、线和三角形的数据告诉DXGraphics,第二种是使用DXGraphics提供的函数直接调入用3DMAX等画好的3D场景。显然,第二种方法更为简便,但如果我们要在屏幕上画一个2D的东西,比如准星时,该怎样做呢?这时可以像9.4.2节那样使用ID3DXSPRITE对象。
9.4.1 调入3D场景
先告诉大家一个坏消息:DXGraphics只能调入它自己定义的.x格式的文件!
接下来是一个好消息:你可以去http://www.milkshape3d.com下载一个超好用的MilkShape 3D,它不但可以实现将各种3D作图软件生成的文件转成.x格式,而且还可以把Quake2, Quake3,Counter Strike,Half-Life,Max Payne,Unreal,Serious Sam,The Sims等游戏中使用的3D文件格式转成.x文件!
然而还有一个坏消息:Milkshape 3D不注册只能使用一个月,而且我们常用的改日期大法对它效果不好。那么怎么办呢?其实DirectX SDK中有一个插件,可以把用3DMax或Maya画好的场景输出为.x,自己在MICROSOFT的网站上找找DirectX SDK Extras吧。
下面转入正题:如何调入.x文件呢?其实非常简单:
LPD3DXMESH Mesh; //Mesh是网的意思,这里指3D场景
DWORD nMater; //场景中共有多少种Materials,即材质
D3DMATERIAL9* Mater; //存放场景中各种材质的数据的数组
LPDIRECT3DTEXTURE9* Tex; //存放场景中各种纹理的数据的数组
LPD3DXBUFFER pBuffer; //临时缓存
D3DXMATERIAL* tMater; //临时材质
D3DXLoadMeshFromX("mp5.x", //.x文件名
D3DXMESH_MANAGED, //说明Mesh由DXGraphics管理
pDev, //刚创建的D3D设备
NULL, //这里可以是一个LPD3DXBUFFER对象,保存场景中每一
//个三角形与哪三个三角形相联
&pBuffer, &nMater, &Mesh); //上面刚定义的那群东西
tMater = (D3DXMATERIAL*)pBuffer->GetBufferPointer( ); //得到材质的数据指针
Mater = new D3DMATERIAL8[nMater];
Tex = new LPDIRECT3DTEXTURE8[nMater];
for (DWORD i=0;i
{
Mater[i] = tMater[i].MatD3D; //tMater的作用是防止不断调用GetBufferPointer()
Mater[i].Ambient = Mater[i].Diffuse; //设置好Ambient材质,因为我们的场景中一
//般只有环境光,而用作图软件画的物体一般
//没有设置此材质,结果会看不到东西
//创建纹理
//请保证场景中每一个三角形都有纹理,否则会出错!
D3DXCreateTextureFromFile(pDev, tMater[i].pTextureFilename, &Tex[i]);
}
//释放临时对象
pBuffer->Release();
D3DXComputeNormals(Mesh); //计算各个顶点的朝向,为以后使用灯光作准备
调入场景后,我们可以执行ID3DXMesh::OptimizeInplace( )对场景进行优化,这条指令可以提高不少显示速度。首先,我们要根据Mesh中面的个数开一个数组储存Mesh中各个面的连接情况(就是D3DXLoadMeshFromX( )的第四个参数的意义):
DWORD *pAdj=new DWORD[Mesh->GetNumFaces()*3];
获得数据:
Mesh->GenerateAdjacency(0.0f,pAdj);
然后就可以优化Mesh:
Mesh->OptimizeInplace(D3DXMESHOPT_VERTEXCACHE, pAdj,NULL, NULL, NULL);
9.4.2 调入2D图像
即使是在3D游戏中,仍然少不了2D的东西。事实上,在不少游戏中一些看上去应该是3D的东西,例如树什么的,其实也就是一张张2D图像。调入和显示2D图像最简单的办法是使用ID3DXSPRITE对象。首先,我们要把图像作为纹理调入内存:
LPDIRECT3DTEXTURE9 tex; //指向纹理的指针
LPD3DXSPRITE spr; //指向ID3DXSPRITE对象的指针
D3DXCreateTextureFromFileEx(pDev,"cross.png", //文件名
D3DX_DEFAULT, //文件宽,这里设为自动
D3DX_DEFAULT, //文件高,这里设为自动
D3DX_DEFAULT, //需要多少级mipmap,这里设为自动
0, //此纹理的用途
D3DFMT_UNKNOWN, //自动检测文件格式
D3DPOOL_MANAGED, //由DXGraphics管理
D3DX_DEFAULT, //纹理过滤方法,这里设为默认方法
D3DX_DEFAULT, //mipmap纹理过滤方法,这里设为默认方法
0xFFFFFFFF, //透明色颜色,ARGB格式,这里设为白色
NULL, //读出的图像格式存储在何变量中
NULL, //读出的调色板存储在何变量中
&tex); //要创建的纹理
然后便可创建ID3DXSPRITE对象:
D3DXCreateSprite(pDev,&spr);
9.5 刷新场景
在DXGraphics中,刷新一次场景需要五步。不过不用担心,除了渲染场景这一步,其它几步都非常简单。首先,我们要清除场景:
pDev->Clear(0, NULL,
D3DCLEAR_TARGET|D3DCLEAR_ZBUFFER,
D3DCOLOR_XRGB(0,0,0), 1.0f, 0);
这条指令可以只清除指定的几个矩形中的屏幕内容,第一个参数的意义就是要清除多少个矩形中的内容,而第二个参数指向一个装满了这些矩形的数组。第三个参数说明要清除那些内容,包括渲染后的场景 ( D3DCLEAR_TARGET ),Z Buffer ( D3DCLEAR_ZBUFFER ) 和Stencil Buffer ( D3DCLEAR_STENCIL )。第四、五、六个参数分别表示场景要被清为什么颜色,Z Buffer要被清为什么值,Stencil要被清为什么值。
但是,Z Buffer和Stencil Buffer到底是什么东东?你还记得我们在9.2节中开启的所谓的自动深度缓存吗?那就是Z Buffer,它实际上就是一个数组,储存着物体离观察者眼睛的远近:0.0f是最近,1.0f是最远。我们都知道近的东西可以挡住远的东西(除非它是透明的?),不过以前的显示卡是不懂这个道理的,一切都要你自己计算。现在好了,绝大多数显示卡都支持这项功能,不会再出现古怪的图像(什么?你喜欢以前的样子?faint)。为了实现不显示被挡住的点,在Z Buffer中要开辟一块区域,即Stencil Buffer,储存是否显示该点。比如说,在32位的Z Buffer中放置8位的Stencil Buffer。所以通过修改Stencil Buffer,我们可以控制要显示哪些点,实现镂空、简单的淡入淡出(把一个个点改为图像的颜色或黑色)等效果。
清除干净场景后,我们就应该调用IDirect3Ddevice9::BeginScene( )开始场景,为渲染场景做好准备。这个函数没有任何参数,非常简单。
下一步应该是渲染场景,由于这一步有几种实现方法,比较复杂一点,所以我把它放在9.6节单独讲述,这里先跳过去吧。
渲染好了场景就应该调用IDirect3Ddevice9::EndScene( )结束场景,这也是一个没有参数的函数。
最后,我们可以调用IDirect3Ddevice9::Present( )函数将场景显示出来。这个函数的原型是这样的:HRESULT Present(CONST RECT* pSourceRect,
CONST RECT* pDestRect,
HWND hDestWindowOverride,
CONST RGNDATA* pDirtyRegion);
一般来说,它的四个参数都应被设为NULL,也就是pDev->Present(NULL,NULL,NULL,NULL);即可。设备丢失时此函数会返回D3DERR_DEVICELOST。
9.6 渲染场景
9.6.1 渲染3D场景
对于用9.4.1节的方法调入的3D物体,我们可以这样渲染:
for (unsigned long i=0;i
{
pDev->SetMaterial( &Mater[i] ); //设置材质
pDev->SetTexture( 0, Tex[i] ); //设置纹理
Mesh->DrawSubset( i ); //渲染是第i种材质的三角形
}
9.6.2 渲染2D图像
对于用9.4.2节的方法调入的2D图像,我们可以这样渲染:
spr->Draw(tex, //前面创建的纹理
NULL, //源矩阵,就像DirectDraw中那个
NULL, //一个D3DXVECTOR2结构,表明在横竖方向分别扩大多少倍
NULL, //一个D3DXVECTOR2结构,绕何点旋转
0, //顺时针方向旋转多少弧度
&D3DXVECTOR2(397,297), //放到屏幕上何处
0xFFFFFFFF); //图像的颜色,ARGB格式,这里设为不透明白色
//如果设为0x7FFFFFFF等数值,用10.2节的办法即可实现半透明
9.7 改变场景
大家也许会注意到,渲染出来的3D物体的位置和方向、大小等似乎是无法控制的。但办法总是有的,我们可以借助World矩阵完成对3D物体的改变。方法很简单:
D3DXMATRIX matw,matt,mato;
pDev->GetTransform(D3DTS_WORLD,&matw); //得到原来的World矩阵
mato=matw;
D3DXMatrixTranslation(&matt, x,y,z); //将matt变成一个能将物体移动(x,y,z)距离的矩阵
D3DXMatrixMultiply(&matw, &matt, &matw); //matw=matt*matw,可将matw按matt
//在x/y/z轴方向上分别移动x,y,z
D3DXMatrixRotationYawPitchRoll(&matt, ry, rx, rz); //将matt变成一个能将物体旋转
//(rx,ry,rz)角度的矩阵
D3DXMatrixMultiply(&matw, &matt, &matw); //matw=matt*matw,可将matw按matt
//在x/y/z轴方向上分别旋转rx,ry,rz
D3DXMatrixScaling(&matt, sx, sy, sz); //将matt变成一个能将物体缩放(sx,sy,sz)倍的矩阵
D3DXMatrixMultiply(&matw, &matt, &matw); //matw=matt*matw,可将matw按matt
//在x/y/z轴方向上分别缩放sx,sy,sz倍
pDev->SetTransform(D3DTS_WORLD,&matw); //设定矩阵
for (unsigned long i=0;i
{
pDev->SetMaterial( &Mater[i] );
pDev->SetTexture( 0, Tex[i] );
Mesh->DrawSubset( i );
}
pDev->SetTransform(D3DTS_WORLD,&mato); //复原矩阵
接下去画第二个物体的时候可以依此类推:先改变World矩阵,再复原之。
另外还有一个重要问题是如何实现在3D场景中的漫游,这其实也非常简单,只要根据主角所在位置在每次刷新前设置好View矩阵即可。
9.8 显示文字
大家还记得在DirectDraw中我们是如何使用HDC实现文字输出吗?在DXGraphics中可没有获得HDC的办法,我们应该使用ID3DXFont类。首先我们要创建一种字体:
HFONT font;
font = CreateFont(20, 0, 0, 0, FW_BOLD, 0, 0, 0, 0, 0, 0, 0, 0, "Tahoma");
这里我们创建的是一个大小为20个像素的粗体Tahoma字体,CreateFont的第一、第五和最后一个参数是最值得我们注意的。
创建完字体后,下一步就应该是根据它创建ID3DXFont对象:
LPD3DXFONT pFont;
D3DXCreateFont( pDev, font, &pFont);
接下去,用一个矩形确定要将文字放到什么位置:
MakeRect(650,0,800,20); //MakeRect的定义在4.6节
最后就可以输出文字:
pFont->DrawText("Hello!", //要输出的文字
strlen("Hello!"), //文字长度
&rect, //上面创建的矩形
DT_RIGHT, //文字在矩形中的显示方式,还可以是DT_LEFT或DT_CENTER等等
D3DCOLOR_ARGB(128,255,0,0) ); //图像的颜色,这里设为半透明红色
9.9 程序实例
第十章 我没有想好名字
第九章所讲述的3D编程知识可以说是比较基本的,如何才能使我们的游戏更逼真和华丽呢?这就需要我们使用一系列比较高级的技术。不过放心,他们学起来并不是非常复杂。如果你能很好地掌握这些技术,做出一个类似QuakeIII这样的游戏都将会是完全可能的。
本来准备把这章一口气写完的,不过我的小猫还没下完整的DX9SDK,等下完看看和DX8有何区别和改进再写吧…
10.1 灯光
常用的在3D场景中实现灯光的方法有三种,第一种是利用DXGraphics提供的灯光对象简单地完成不太精确的光照效果,第二种方法是利用Lightmap技术,第三种办法是利用Pixel Shader。我们还是在一节中先看看最简单的第一种吧。
DXGraphics提供了三种光源:点光源POINT(例如灯泡)、平行光源DIRECTIONAL(例如太阳光)和聚光灯SPOT(DiabloII里面围绕着主角的一圈光就可以看作是这种光)。一个光源可以由一个D3DLIGHT9结构表示,这个结构的参数有:
Type:表明光源种类。可以是D3DLIGHT_POINT、D3DLIGHT_SPOT或 D3DLIGHT_DIRECTIONAL。
Diffuse / Specular / Ambient:光源射出的三种光的颜色,每一个都是一个D3DCOLORVALUE结构,有a/r/g/b四个成员,取值范围都是0.0f到1.0f。
Position:光源的位置,对于平行光源此值无意义。
Direction:光源照射的方向,对于点光源此值无意义。
Range:光源能照射多远,最大允许值为sqrt(FLT_MAX),对于平行光源此值无意义,
Attenuation0 / Attenuation1 / Attenuation2:指定光线的衰减方式。一般来说Attenuation0与Attenuation2都设为0,Attenuation1设为一个常数。如果你将Attenuation0设为1并将其余两个值设为0,光线将不随距离而衰减。对于平行光源此值无意义。
接下来的三个参数是聚光灯所特有的。我们先来看看Diablo II中的聚光灯是怎样的:
图10.1
大家都会注意到,聚光灯的内圈的亮度不随距离而衰减,内圈与外圈之间则不然。下面三个参数就与这些有关:
Theta:内圈的角度(单位为弧度)。
Phi:外圈的角度(单位为弧度)。
Falloff:内圈与外圈之间的亮度的衰减率,一般设为1.0f。
填充完D3DLIGHT9结构后,下一步就是设置灯光。假设你刚才填充的D3DLIGHT9结构的变量的名字是d3dLight,那么这样做即可:
pDev->SetLight(0, &d3dLight); //设置为0号灯
pDev->LightEnable(0, TRUE); //打开0号灯
最后在渲染状态里打开灯光就完成了设置。
注意灯光对于2D图像不起作用(显然),那么如何实现DiabloII这样的2D光照呢?最简单的办法就是用3DMAX画一个2D平面然后把它当做3D场景调入,不过这显然太浪费了一点,用下面介绍的半透明和纹理混合倒是个好主意。
10.2 半透明
半透明实在是很漂亮,现在的游戏使用它非常频繁。如果你想做的效果是使2D图像和文字半透明的话,实现方法难以置信地简单:
pDev->SetRenderState(D3DRS_ALPHABLENDENABLE,TRUE);
pDev->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA);
pDev->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
对,就设置这三句就行了。当然别忘了渲染2D图像和文字时设置颜色的ALPHA值为想要的透明度----如果你的图像不是已经包含了ALPHA通道。想想当年我们为了在DirectDraw中实现半透明要写多么复杂的代码,真是感慨万千啊?。
那么如果我们要使3D物体半透明该怎么办呢?同样简单。首先,确定一下你需要使整个面的ALPHA值相同(例如实现水的效果)还是使用纹理的ALPHA通道。如果是第一种,只要在设计场景时把这个面的Diffuse颜色的a值设为透明度,然后在渲染前执行下面的语句:
pDev->SetTextureStageState(0,D3DTSS_ALPHAOP,D3DTOP_SELECTARG1);
pDev->SetTextureStageState(0,D3DTSS_ALPHAARG1,D3DTA_DIFFUSE);
如果你想用第二种实现方法,本来是不用执行任何语句的,因为系统默认状态就是使用ALPHA通道。但是如果你执行过上面那两条语句把半透明参考源改为Diffuse.a的话,那么要在渲染这种纹理前恢复它:
pDev->SetTextureStageState(0,D3DTSS_ALPHAARG1,D3DTA_TEXTURE);
哈哈,看来做出半透明效果确实非常简单,怪不得大家这么喜欢用它?。
10.3 纹理混合
纹理混合可以使我们轻易地作出光照、弹痕等效果。其原理是给一个三角形安上两种纹理,而这两种纹理的混合方式由你指定。
首先,我们要把第二种纹理调入内存:
D3DXCreateTextureFromFile(pDev, "light.bmp", &tex);
然后把它设为1号纹理(默认纹理为0号):
pDev->SetTexture(1, tex);
设定它如何与0号纹理混合:
//设定纹理混合源一为1号纹理的图像
pDev->SetTextureStageState( 1, D3DTSS_COLORARG1, D3DTA_TEXTURE);
//设定纹理混合源二为目前的图像
pDev->SetTextureStageState( 1, D3DTSS_COLORARG2, D3DTA_CURRENT);
//设定纹理混合指令为相乘
pDev->SetTextureStageState( 1, D3DTSS_COLOROP, D3DTOP_MODULATE);
纹理混合指令有很多种,下表列出了几种常用的指令的效果:
表10.1
D3DTOP_DISABLE 禁止纹理混合
D3DTOP_SELECTARG1 只显示Arg1
D3DTOP_SELECTARG2 只显示Arg2
D3DTOP_MODULATE Arg1 * Arg2
D3DTOP_MODULATE2X Arg1 * Arg2 *2
D3DTOP_MODULATE4X Arg1 * Arg2 *4
D3DTOP_ADD Arg1 + Arg2
D3DTOP_ADDSIGNED Arg1 + Arg2 – 0.5
D3DTOP_ADDSIGNED2X ( Arg1 + Arg2 – 0.5 ) * 2
D3DTOP_SUBTRACT Arg1 – Arg2
D3DTOP_ADDSMOOTH Arg1 + Arg2 – Arg1 * Arg2
为了正确地把第二种纹理显示出来,我们要设定第二种纹理的纹理坐标。什么是纹理坐标呢?其实就是Mesh中的某个顶点对应着纹理中的哪个点。举个例子,如果某个三角形要把纹理的右下那一半显示出来,那么我们可以把三角形的一个顶点的纹理坐标设为( 0.0f, 1.0f ),即纹理的左下角;第二个顶点的纹理坐标设为( 1.0f, 1.0f ),即纹理的右下角;第三个顶点的纹理坐标设为( 1.0f, 0.0f ),即纹理的右上角。
由于用3D作图软件画出的Mesh一般只有一套纹理坐标,我们首先要把它转换为拥有两套纹理坐标:
Mesh->CloneMeshFVF(D3DXMESH_MANAGED,
D3DFVF_XYZ | //有坐标信息
D3DFVF_NORMAL | //有顶点朝向信息
D3DFVF_DIFFUSE | //有DIFFUSE颜色信息
D3DFVF_TEX2 //有两套纹理坐标
, pDev, &Mesh);
然后根据我们刚才的设定定义一个顶点数据类型:
strucet vertex
{
float x, y, z;
float nx, ny, nz;
D3DCOLOR diffuse;
float tu1, tv1; //第一套纹理坐标
float tu2, tv2; //第二套纹理坐标
};
vertex *v; //存储Mesh中顶点的信息
下一步是锁定Mesh并得到顶点信息:
int count = Mesh->GetNumVertices(); //存储Mesh中有多少顶点
Mesh->LockVertexBuffer(NULL,(BYTE**)&v);
//下面用拷贝第一套纹理坐标的办法生成第二套纹理坐标
//你可以任意改变这个算法
//比如说只给其中几个三角形设置纹理坐标
for (i=0;i
{
//事实上在这里可以任意更改Mesh中各个顶点的信息
v[i].tu2=v[i].tu1;
v[i].tv2=v[i].tv1;
}
最后要解锁Mesh:
Mesh->UnlockVertexBuffer();
10.4 雾
雾是一个能使场景更真实的效果,在很多游戏中都有应用。
图10.2 雾的效果
在DirectX中有两种雾:Vertex Fog和Pixel Fog,顾名思义,它们分别是基于顶点和像素。所以Pixel Fog的效果显然要好一些,而两者的速度没什么差别。下面我们来看看Pixel Fog的设置方法:
float fogstart=0.01f,fogend=50.0f; //雾的开始范围和结束范围
pDev->SetRenderState(D3DRS_FOGENABLE, TRUE); //打开雾
pDev->SetRenderState(D3DRS_FOGCOLOR, 0x00000000); //设置雾的颜色
//设置雾的衰减方式为线性
pDev->SetRenderState(D3DRS_FOGTABLEMODE, D3DFOG_LINEAR );
//设定雾的开始范围和结束范围
//*((DWORD*)(&x))的作用是通过把指向x的指针转为指向DWORD类型的数据的指
//针来实现把x转为DWORD类型的目的(绕口…)
pDev->SetRenderState(D3DRS_FOGSTART, *((DWORD*)(&fogstart)));
pDev->SetRenderState(D3DRS_FOGEND, *((DWORD*)(&fogend)));
10.5 凹凸贴图与环境贴图
凹凸贴图和环境贴图对于渲染出更真实的物体是非常重要的,实现它们的方法也并不复杂。我们先来看看凹凸贴图的实现方法吧。
图10.3 凹凸贴图
接下来让我们看看环境贴图。
图10.4 环境贴图
10.6 粒子系统
使用粒子系统可以制造出火焰、瀑布等各种炫目的特效。
图10.5 粒子系统
10.7 骨骼动画
如果你玩过CS,也许会对这个游戏是如何制造出较为真实的走动、跳跃等动画有过不少猜想。事实上,它使用的是骨骼动画技术。这个技术要做起来可是比较麻烦的?。我们还是先来看看什么是骨骼动画技术。
在没有骨骼动画技术之前,三维动画是采用关键帧技术实现的----即只保存几个关键的mesh,然后通过在它们之间插值实现动画,就像下面这幅图所显示的那样:
图10.6 关键帧技术
这无疑是个挺好的技术,然而出于速度的考虑,插值一般是采用最简单和最粗略的线性插值,如果动画一复杂,效果将无法令人满意。就拿下图做例子,我们使用线性插值将只能得到图2的效果,而不是图1的流畅旋转效果。
图10.7 关键帧技术的问题
该怎么办呢?联想到人的各种动作是如何实现的,我们可以赋予mesh以骨骼,而三角形就象是皮肤,将跟随着骨骼一起运动----实际上就是用骨骼的转换矩阵转换各个顶点。所以要记住,骨骼本身是没有意义的,只有骨骼的转换矩阵才是重要的。
图10.8 骨骼示意图
现在想想,如果我们将上臂举起,并同时选转下臂,那么下臂的实际运动将会是怎样的呢?显然是两个运动的合成。所以,我们需要建立起一个骨骼们的连接(或者说是继承)关系,然后子骨骼的实际转换矩阵将是子骨骼的转换矩阵与母骨骼的转换矩阵的乘积。这就是骨骼动画技术的精髓。好象不是很复杂的样子,hehe。
不过这种实现还是有一个小问题,图10.6展示了它。
图10.9
左边的效果看起来不太漂亮,不过它就是上面所述的简单方法的结果。为了实现右边的效果,我们需要使多根骨骼同时影响一个顶点。Mesh中的每个顶点将被赋以多个weight(权重),它们决定了这个顶点被哪些骨骼以多大的程度影响。一般来说,两个weight(实际上只需给出一跟骨骼的weight,另一个weight一般来说显然等于1-weight)已经可以给出很好的结果。然后,我们将顶点分别以这些骨骼的矩阵转换,再把得到的这些结果按权重相加就是答案。
OK,骨骼动画的原理就讲完了,下一步是想想该怎么实现它。
10.8 镜子
镜子的效果在游戏中不少见,水面、光滑的地板等都可以被认为是一种镜子。实现这种特效一般要靠Stencil Buffer技术。
图10.10 镜子效果
10.9 影子
也许你会对DXGraphics提供的灯光不能产生影子而感到困惑。DXGraphics不这样做的原因是影子对程序速度的影响实在太大。举个例子,如果要显示一个人物的影子,我们首先要
图10.11 影子效果
不过,在很多游戏(例如Counter Strike)中我们只能看到建筑物的影子,而这些影子又都是固定的,所以要好做得多。我们可以预先计算好这些阴影的范围,并把它们加入mesh,程序运行的时候只需判断人物是否在阴影中,如果是的话就把人物变暗即可。
第十一章 我没有想好名字
要说现在最热的,莫过于网络游戏,而且看来它会是大势所趋。那么想编游戏的我们又怎么能错过潮流呢?因为现在的网络编程最常用的是Winsock,所以我们下面就来看看如何使用它吧。
11.1 基本概念
Client, Server, Sync, Async…
11.2 程序流程
11.2.1 服务器端
在调用其他Winsock函数前,我们首先要执行WSAStartup( )以初始化Winsock并通知Windows将Winsock的DLL调入内存。接下来,我们需要调用socket( )创建服务器端的socket,并把它和服务器的一个端口用bind( )绑定。然后,我们需要用WSAAsyncSelect设定网络事件,以便在窗口的消息处理函数中处理各种网络事件。最后调用listen( )开始监听各种网络事件。
当一个网络事件发生时,窗口的消息处理函数会接受到一条网络消息。根据消息的wParam参数,我们可以了解消息的类型,从而对消息进行一些处理。比如说,如果wParam是FD_ACCEPT,说明有一个客户端请求与服务器连接,我们可以调用accept( )接受之并获得客户端的socket;如果wParam是FD_READ,那么发生的网络事件是服务器收到了一条从客户端发来的信息,调用recv( )可以把信息收下来。
关闭服务器有两种方法,第一种是所谓的"优雅办法",它需要几个步骤:首先要调用shutdown( ),这会停止发送新信息并给客户端发送一个FD_CLOSE网络消息(客户端收到这则消息后,应该把还要发送的信息发出,然后依次调用shutdown( )和closesocket( ))。此时在服务器端,我们应该在收到FD_READ时调用recv( )收完所有信息,然后在收到客户端发来的FD_CLOSE后才能 closesocket( )。最后可以调用WSACleanup( )把Winsock的DLL清理出内存。第二种办法是"暴力法",关闭速度够快但是有可能丢失最后的信息。这种方法用起来也简单,直接执行closesocket( )即可。
11.2.2 客户端
客户端的初始化过程与服务器端的过程有一些相似之处。首先也是WSAStartup( ),然后一样执行socket( )创建socket,但接下来就无需使用bind( )绑定接口了,因为没有人会去连接客户端。下一步的注册网络事件也有少许不同:没有了FD_ACCEPT却有FD_CONNECT。最后当然少不了connect( )指令,用于连接服务器。
连接上服务器后,服务器会把一条FD_WRITE指令发给客户,表示可以发送信息了。这时客户端就可以使用send( )向服务器发送各种信息。
关闭客户端的步骤和关闭服务器的步骤完全一样。
11.3 程序实例
首先,我们当然要在程序中#include ,注意这条语句必须在#include 之前。然后,我们要在工程中加入WS2_32.lib。下面我们先来看看服务器端的代码(为了方便说明,没有进行错误处理)。
我们预先定义一个类以方便处理:
class SERVER
{
public:
SERVER();
void Accept(SOCKET s);
void Close(SOCKET s);
void Receive(SOCKET s);
void Send(SOCKET s);
~SERVER();
SOCKET server, client[5]; //存储服务器及客户的socket
}Server;
SERVER::SERVER( ) //初始化
{
for (int i=0; i<5; i++)
client[i]=NULL;
WSADATA wData;
WSAStartup( MAKEWORD(2,2), &wData ); //初始化Winsock
server = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); //创建服务器socket
sockaddr_in localaddr; //存储本地地址
localaddr.sin_family = AF_INET; //类型为Internet地址
localaddr.sin_port = htons(8888); //设定端口为8888,可以为0-65535范围
localaddr.sin_addr.s_addr = INADDR_ANY; //可接收任何人的连接
bind(server, (struct sockaddr*)&localaddr, sizeof(sockaddr)); //绑定地址
WSAAsyncSelect(server, hwnd, WM_USER+2, //自己设定一个消息代号
FD_ACCEPT | FD_CLOSE | FD_READ | FD_WRITE); //设置事件
listen(server, 5); //开始监听,设置最多连接5个客户
}
既然我们已经设定好了消息,在服务器窗口的消息处理函数中就可以接受到网络消息:
WinProc()
{
SOCKET s;
int iEvent;
switch(message)
{
case WM_USER+2: //处理网络消息
event = WSAGETSELECTEVENT( lParam ); //得到事件代号
s = (SOCKET) wParam;
switch(event)
{
case FD_ACCEPT: //有客户试图连接
Server->Accept(s);
break;
case FD_CLOSE: //任何一方试图关闭连接
Server ->Close(s);
break;
case FD_READ: //可接受信息
Server ->Receive(s);
break;
case FD_WRITE: //可发送信息
Server ->Send(s);
break;
}
return 0;
case XXXXX: //处理其它消息
……
//想关闭服务器时执行closesocket(server);
}
return DefWindowProc( hwnd, message, wParam, lParam );
}
void SERVER::Accept(SOCKET s) //接受连接
{
SOCKADDR_IN address;
int size = sizeof( SOCKADDR );
//将客户的socket存入client[]
for (int i=0; i<5; i++)
if (client[i]==NULL)
client[i] = accept( server, (LPSOCKADDR)&address, &size );
}
void SERVER::Receive(SOCKET s) //接收信息
{
char buffer[255]; //存储接收到的信息
recv(client,buffer,sizeof(buffer),0);
}
void SERVER::Close(SOCKET s) //关闭客户socket,用的是暴力法?
{
for (int i=0; i<5; i++)
if (client[i]==s)
client[i]=NULL;
closesocket(s);
}
接下来我们看看客户端的代码:
class CLIENT
{
public:
CLIENT();
void Close(SOCKET s);
void Receive(SOCKET s);
void Send(SOCKET s);
~CLIENT();
SOCKET client;
}Client;
CLIENT::CLIENT() //初始化
{
WSADATA wData;
WSAStartup( MAKEWORD(2,2), &wData );
client = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
WSAAsyncSelect(client, hwnd,
WM_USER+2, FD_CONNECT | FD_CLOSE | FD_READ | FD_WRITE);
sockaddr_in target;
target.sin_family = AF_INET;
target.sin_port = htons (8888); //服务器端口
target.sin_addr.s_addr = inet_addr ("127.0.0.1"); //服务器IP
connect(client, (struct sockaddr*)&target, sizeof(target)); //试图连接
}
CLIENT::Send(socket s)
{
char buffer[255];
strcpy(buffer,"haha, I am connected!");
send(client, buffer, sizeof(buffer),0);
}
CLIENT::Close(socket s)
{
closesocket(s);
}
客户端的消息处理函数是这样的:
WinProc()
{
SOCKET s;
int iEvent;
switch(message)
{
case WM_USER+2: //处理网络消息
event = WSAGETSELECTEVENT( lParam ); //得到事件代号
s = (SOCKET) wParam;
switch(event)
{
case FD_CONNECT: //连接服务器的结果已出来
//这里应该执行WSAGetLastError( ),具体方法见11.4节
break;
case FD_CLOSE: //任何一方试图关闭连接
Client ->Close(s);
break;
case FD_READ: //可接受信息
Client ->Receive(s);
break;
case FD_WRITE: //可发送信息
Client ->Send(s);
break;
}
return 0;
case XXXXX: //处理其它消息
……
//想关闭客户端时执行closesocket(client);
}
return DefWindowProc( hwnd, message, wParam, lParam );
}
11.4 错误处理
在Winsock中,发生各种错误的几率是比较大的,因为网络本身具有一定的不稳定性。发生了错误当然要处理,执行WSAGetLastError( )会返回上一个错误的代码,在附录四中可以查到这些代码的含义。那么我们怎样知道有没有发生错误呢?当然是靠函数的返回值。
表11.1 常用Winsock函数返回值
函数名 成功时的返回值 失败时的返回值
WSAStartup( ) 0 一个socket错误代码
WSAAsyncSelect( ) 0 SOCKET_ERROR
WSACleanup( ) 0 SOCKET_ERROR
socket( ) 一个合法的socket INVALID_SOCKET
bind( ) 0 SOCKET_ERROR
listen( ) 0 SOCKET_ERROR
connect( ) 0 SOCKET_ERROR
accept( ) 一个合法的socket INVALID_SOCKET
recv( ) 收到了多少字节数据 SOCKET_ERROR
send( ) 发送了多少字节数据 SOCKET_ERROR
shutdown( ) 0 SOCKET_ERROR
closesocket( ) 0 SOCKET_ERROR
注意的是connect( )经常会返回用WSAGetLastError可得知错误代码为10035的SOCKET_ERROR,其原因是connect( )需要一段时间才能完成。此时我们应该继续调用connect( ),如果返回用WSAGetLastError可得知错误代码为10056的SOCKET_ERROR,说明已连接上。
11.5 显示IP地址
从上面的客户端程序可以看到,我们连接时需要服务器端的IP地址。虽然我们可以通过在服务器端运行winipcfg或ipconfig得到它,但如果能在服务器屏幕上直接把它显示出来就更好了。其方法是这样的:
void GetIP(int stat)
{
static char buf[MAXGETHOSTSTRUCT]; //存储服务器资料
if (stat==0) //如果是首次调用
{
char name[255]; //存储服务器的名字
gethostname(name,255); //得到服务器的名字
DWORD add = inet_addr( name ); //试图将服务器名转为Internet地址
if( add == INADDR_NONE ) //如果无法转换
{
WSAAsyncGetHostByName( hwnd, WM_USER+1,
name, buf, MAXGETHOSTSTRUCT); //通过服务器名得到IP
}
else
{
WSAAsyncGetHostByAddr( hwnd, WM_USER+1,
(const char *)&add, sizeof( IN_ADDR ), AF_INET,
buf, MAXGETHOSTSTRUCT ); //通过服务器地址得到IP
}
}
else //如果已收到WM_USER+1消息
{
char IP[64]; //存储IP地址,为xxx.xxx.xxx.xxx形式
LPHOSTENT lphostent = (LPHOSTENT)buf;
strcpy( IP, inet_ntoa( *(LPIN_ADDR)lphostent->h_addr ) );
//在这里可以放一些显示IP地址的语句
}
}
我们在初始化完服务器后即可调用此函数,记住这时stat=0。它将会注册一个网络消息WM_USER+1,如果我们在服务器窗口的消息循环中收到此消息,即说明已得到服务器资料,可以调用GetIP(1)把IP显示出来。
11.6 更有效地传送数据
前面所讲述的都只是怎样传送数据,但到底应该传送什么数据才能达到更高的效率呢?这显然要根据游戏类型而定。
第十二章 创造我们的世界
因为我当初自己摸索的时候觉得编游戏并不是很难的事情,所以如果你觉得这里要加什么内容就告诉我一声,谢谢!
12.1 程序流程
一般来说,一个使用DirectX的游戏的主流程是这样的:
Main( )
{
初始化变量和指针;
初始化窗口;
初始化DirectX;
初始化Winsock;
for(;;) //主循环
{
if (有消息)
{
if (为退出信息)
{
释放DirectX;
释放指针;
退出;
}
else if (为网络消息)
{
处理;
}
else
{
调用缺省消息处理函数;
}
}
else if (程序激活)
{
通过DInput读取键盘和鼠标信息,处理之;
刷新图像;
刷新游戏的其它部分;
如果设备丢失,恢复之;
}
else
{
等待消息;
}
}
}
12.2 程序结构
图12.1
上面这幅图显示了一个典型的单机游戏的结构。
随便想的,望指正
12.3 基本方法
(1)先动笔设计,再编程。一个好的设计可以让我们事半功倍。
(2)循序渐进,先写框架,慢慢扩充新特性。
(3)最好别偷懒用别人的框架,自己写的才是最好用的。
(4)做较大改变前先备份代码,保证永远有一个可以运行的备份,以防失去激情?。
12.4 SLG编程要点
12.4.1 电脑AI
和RTS的局部AI差不多
12.5 RPG & ARPG编程要点
12.5.1 迷宫的生成
我会写例程的,等一下吧
12.5.2 脚本技术
其实不是很复杂吧,只要稍微动笔设计一下就行了。不知道为什么这么多人问
12.6 RTS编程要点
12.6.1 寻路
A*加避让?要写例程…
12.6.2 电脑AI
整体AI:一般是AI脚本技术。但似乎效果不怎么好。如何才能有大局观?思考中…
局部AI:少血的兵往后退(补血或再上前线),集中火力解决血最少而攻击力较高的敌人(注
意也有可能集中火力得不偿失),等等。(抄袭WC3的微操?)
12.7 FPS编程要点
12.7.1 移动
实现3D场景中的移动可以很难也可以很简单,关键在于你想达到怎样的效果和你是否拥有清晰的思维。我们下面还是说说一种在FPS中十分常见,而且效果很好的移动方式:用鼠标控制视线,用键盘控制前后左右的移动。
为了实现这种效果,当然必须存储玩家的所在坐标(假设为x,y,z)和眼睛看的位置(假设为tx,ty,tz)。首先在游戏的刷新函数中加上这几句(工作原理自己想想吧,非常简单):
//假设mousex为鼠标的x坐标,mousey为鼠标的y坐标,且已按7.2节使用DInput
if (mousey>236) //防止mousey越界使视线的上下移动不正常
mousey=236; //236约等于π*150/2
if (mousey<-236) //防止mousey越界使视线的上下移动不正常
mousey=-236;
Player->ty=Player->y-tan((float)(mousey)/150); //150可以改为其它常数
Player->tx=Player->x+sin((float)(mousex)/150);
Player->tz=Player->z+cos((float)(mousex)/150);
然后在每次刷新画面时都像这样设置一次View矩阵:
D3DXMatrixLookAtLH(&matView,
&D3DXVECTOR3(x, y, z),
&D3DXVECTOR3(tx, ty, tz),
&D3DXVECTOR3(0.0f,1.0f,0.0f));
现在已经实现了用鼠标控制视线,下一步是加入移动函数,这其实也非常简单:
float dx=(tx-x)*time*7; //time为一次刷新所耗时间(秒)
float dz=(tz-z)*time*7; //你可以随便修改7这个数以实现你想要的速度
最后根据移动方向把x和z加上或减去dx和dz就完成了整个移动系统,很容易吧?
12.7.2 碰撞检测
不要把3D碰撞检测想得太难(虽然确实很难),因为体贴的Microsoft程序员已经想到了我们的难处(真难得啊)。在DXGraphics中提供了一个函数可以极大地方便我们的编程,它就是D3DXIntersect( ),其作用是判断空间中的一条射线是否与指定的Mesh相交,如果相交的话还会返回距离。所以只要引一条从人物出发,到人物将要移动到的地方结束的射线,再判断一下与任何Mesh的距离是否都小于某值,即可完成几乎完全精确的碰撞检测。唯一的问题是人物可能在其它方向上与物体相交,不过这种事发生的概率不大,有的话可以通过增加射线数解决----现在的CPU这么快,我们可以懒一点?。
此函数的原型是:
D3DXIntersect(
LPD3DXBASEMESH pMesh, //要测试的Mesh
CONST D3DXVECTOR3* pRayPos, //射线从何处开始
CONST D3DXVECTOR3* pRayDir, //射线的方向
BOOL* pHit, //是否相交
DWORD* pFaceIndex, //在Mesh中的第几个三角形处相交
FLOAT* pU, //在三角形的何处相交
FLOAT* pV, //在三角形的何处相交
FLOAT* pDist, //距离
LPD3DXBUFFER* ppAllHits, //保存所有相交点的信息
DWORD* pCountOfHits //相交几次
);
要说明的只有一点:射线的方向应该是什么呢?很简单,假设起点为(x1,y1,z1),终点为(x2,y2,z2),方向就是(x2-x1,y2-y1,z2-z1)。
不过,如果场景中的多边形一多,这样做碰撞检测的效率可就比较低了,解决办法是使用Bounding Box & Bounding Sphere,即把物体看做是由一些长方体和球构成,然后借助它们实现碰撞检测。
12.8 游戏中的物理学
哈哈,我喜欢这一节?。这里将会向大家介绍一些物理基础知识。
附 录
附录一 Windows常见消息列表
WM_ACTIVATE Indicates a change in the activation state
WM_ACTIVATEAPP Notifies applications when a new task is activated
WM_CANCELMODE Notifies a window to cancel internal modes
WM_CHANGECBCHAIN Notifies clipboard viewer of removal from chain
WM_CHAR Passes keyboard events to focus window
WM_CHARTOITEM Provides list-box keystrokes to owner window
WM_CHILDACTIVATE Notifies a child window of activation
WM_CLEAR Clears an edit control or combo box
WM_CLOSE Signals a window or application to terminate
WM_COMMAND Specifies a command message
WM_COMMNOTIFY Notifies a window about the status of its queues
WM_COMPACTING Indicates a low memory condition
WM_COMPAREITEM Determines position of combo-box or list-box item
WM_COPY Copies a selection to the clipboard
WM_CREATE Indicates that a window is being created
WM_CTLCOLOR Indicates that a control is about to be drawn
WM_CUT Deletes a selection and copies it to the clipboard
WM_DEADCHAR Indicates when a dead key is pressed
WM_DELETEITEM Indicates owner-drawn item or control is altered
WM_DESTROY Indicates window is about to be destroyed
WM_DEVMODECHANGE Indicates when device-mode settings are changed
WM_DRAWCLIPBOARD Indicates when clipboard contents are changed
WM_DRAWITEM Indicates when owner-drawn control or menu changes
WM_DROPFILES Indicates when a file is dropped
WM_ENABLE Indicates when enable state of window is changing
WM_ENDSESSION Indicates whether the Windows session is ending
WM_ENTERIDLE Indicates a modal dialog box or menu is idle
WM_ERASEBKGND Indicates when background of window needs erasing
WM_FONTCHANGE Indicates a change in the font-resource pool
WM_GETDLGCODE Allows processing of control input
WM_GETFONT Retrieves the font that a control is using
WM_GETMINMAXINFO Retrieves minimum and maximum sizing information
WM_GETTEXT Copies the text that corresponds to a window
WM_GETTEXTLENGTH Determines length of text associated with a window
WM_HSCROLL Indicates a click in a horizontal scroll bar
WM_ICONERASEBKGND Notifies minimized window to fill icon background
WM_INITDIALOG Initializes a dialog box
WM_INITMENU Indicates when a menu is about to become active
WM_INITMENUPOPUP Indicates when a pop-up menu is being created
WM_KEYDOWN Indicates when a nonsystem key is pressed
WM_KEYUP Indicates when a nonsystem key is released
WM_KILLFOCUS Indicates window is about to lose input focus
WM_LBUTTONDBLCLK Indicates double-click of left mouse button
WM_LBUTTONDOWN Indicates when left mouse button is pressed
WM_LBUTTONUP Indicates when left mouse button is released
WM_MBUTTONDBLCLK Indicates double-click of middle mouse button
WM_MBUTTONDOWN Indicates when middle mouse button is pressed
WM_MBUTTONUP Indicates when middle mouse button is released
WM_MDIACTIVATE Activates a new MDI child window
WM_MDICASCADE Arranges MDI child windows in a cascade format
WM_MDICREATE Prompts an MDI client to create a child window
WM_MDIDESTROY Closes an MDI child window
WM_MDIGETACTIVE Retrieves data about the active MDI child window
WM_MDIICONARRANGE Arranges minimized MDI child windows
WM_MDIMAXIMIZE Maximizes an MDI child window
WM_MDINEXT Activates the next MDI child window
WM_MDIRESTORE Prompts an MDI client to restore a child window
WM_MDISETMENU Replaces the menu of a MDI frame window
WM_MDITILE Arranges MDI child windows in a tiled format
WM_MEASUREITEM Requests dimensions of owner-drawn control
WM_MENUCHAR Indicates when unknown menu mnemonic is pressed
WM_MENUSELECT Indicates when a menu item is selected
WM_MOUSEACTIVATE Indicates a mouse click in an inactive window
WM_MOUSEMOVE Indicates mouse-cursor movement
WM_MOVE Indicates the position of a window has changed
WM_NCACTIVATE Changes the active state of a nonclient area
WM_NCCALCSIZE Calculates the size of a window's client area
WM_NCCREATE Indicates a nonclient area is being created
WM_NCDESTROY Indicates when nonclient area is being destroyed
WM_NCHITTEST Indicates mouse-cursor movement
WM_NCLBUTTONDBLCLK Indicates non-client left button double-click
WM_NCLBUTTONDOWN Indicates left button pressed in nonclient area
WM_NCLBUTTONUP Indicates left button released in nonclient area
WM_NCMBUTTONDBLCLK Indicates middle button nonclient double-click
WM_NCMBUTTONDOWN Indicates middle button pressed in nonclient area
WM_NCMBUTTONUP Indicates middle button released in nonclient area
WM_NCMOUSEMOVE Indicates mouse-cursor movement in nonclient area
WM_NCPAINT Indicates a window's frame needs painting
WM_NCRBUTTONDBLCLK Indicates right button nonclient double-click
WM_NCRBUTTONDOWN Indicates right button pressed in nonclient area
WM_NCRBUTTONUP Indicates right button released in nonclient area
WM_NEXTDLGCTL Sets the focus to a different dialog box control
WM_PAINT Indicates a window frame needs painting
WM_PAINTCLIPBOARD Paints the specified portion of the window
WM_PALETTECHANGED Indicates focus-window has realized its palette
WM_PALETTEISCHANGING Informs windows about change to palette
WM_PARENTNOTIFY Notifies parent of child-window activity
WM_PASTE Inserts clipboard data into an edit control
WM_POWER Indicates the system is entering suspended mode
WM_QUERYDRAGICON Requests a cursor handle for a minimized window
WM_QUERYENDSESSION Requests that the Windows session be ended
WM_QUERYNEWPALETTE Allows a window to realize its logical palette
WM_QUERYOPEN Requests that a minimized window be restored
WM_QUEUESYNC Delimits CBT messages
WM_QUIT Requests that an application be terminated
WM_RBUTTONDBLCLK Indicates a double-click of right mouse button
WM_RBUTTONDOWN Indicates when the right mouse button is pressed
WM_RBUTTONUP Indicates when the right mouse button is released
WM_RENDERALLFORMATS Notifies owner to render all clipboard formats
WM_RENDERFORMAT Notifies owner to render particular clipboard data
WM_SETCURSOR Displays the appropriate mouse cursor shape
WM_SETFOCUS Indicates when a window has gained input focus
WM_SETFONT Sets the font for a control
WM_SETREDRAW Allows or prevents redrawing in a window
WM_SETTEXT Sets the text of a window
WM_SHOWWINDOW Indicates a window is about to be hidden or shown
WM_SIZE Indicates a change in window size
WM_SIZECLIPBOARD Indicates a change in clipboard size
WM_SPOOLERSTATUS Indicates when a print job is added or removed
WM_SYSCHAR Indicates when a System-menu key is pressed
WM_SYSCOLORCHANGE Indicates when a system color setting is changed
WM_SYSCOMMAND Indicates when a System-command is requested
WM_SYSDEADCHAR Indicates when a system dead key is pressed
WM_SYSKEYDOWN Indicates that ALT plus another key was pressed
WM_SYSKEYUP Indicates that ALT plus another key was released
WM_SYSTEMERROR Indicates that a system error has occurred
WM_TIMECHANGE Indicates that the system time has been set
WM_TIMER Indicates timeout interval for a timer has elapsed
WM_UNDO Undoes the last operation in an edit control
WM_USER Indicates a range of message values
WM_VKEYTOITEM Provides list-box keystrokes to owner window
WM_VSCROLL Indicates a click in a vertical scroll bar
WM_VSCROLLCLIPBOARD Prompts the owner to scroll clipboard contents
WM_WINDOWPOSCHANGED Notifies a window of a size or position change
WM_WINDOWPOSCHANGING Notifies a window of a new size or position
附录二 虚拟键列表
Windows消息中的虚拟键
VK_LBUTTON 鼠标左键 0x01
VK_RBUTTON 鼠标右键 0x02
VK_CANCEL Ctrl + Break 0x03
VK_MBUTTON 鼠标中键 0x04
VK_BACK Backspace 键 0x08
VK_TAB Tab 键 0x09
VK_RETURN 回车键 0x0D
VK_SHIFT Shift 键 0x10
VK_CONTROL Ctrl 键 0x11
VK_MENU Alt 键 0x12
VK_PAUSE Pause 键 0x13
VK_CAPITAL Caps Lock 键 0x14
VK_ESCAPE Esc 键 0x1B
VK_SPACE 空格键 0x20
VK_PRIOR Page Up 键 0x21
VK_NEXT Page Down 键 0x22
VK_END End 键 0x23
VK_HOME Home 键 0x24
VK_LEFT 左箭头键 0x25
VK_UP 上箭头键 0x26
VK_RIGHT 右箭头键 0x27
VK_DOWN 下箭头键 0x28
VK_SNAPSHOT Print Screen 键 0x2C
VK_INSERT Insert 键 0x2D
VK_DELETE Delete 键 0x2E
'0' – '9' 数字 0 - 9 0x30 - 0x39
'A' – 'Z' 字母 A - Z 0x41 - 0x5A
VK_LWIN 左WinKey(104键盘才有) 0x5B
VK_RWIN 右WinKey(104键盘才有) 0x5C
VK_APPS AppsKey(104键盘才有) 0x5D
VK_NUMPAD0 小键盘 0 键 0x60
VK_NUMPAD1 小键盘 1 键 0x61
VK_NUMPAD2 小键盘 2 键 0x62
VK_NUMPAD3 小键盘 3 键 0x63
VK_NUMPAD4 小键盘 4 键 0x64
VK_NUMPAD5 小键盘 5 键 0x65
VK_NUMPAD6 小键盘 6 键 0x66
VK_NUMPAD7 小键盘 7 键 0x67
VK_NUMPAD8 小键盘 8 键 0x68
VK_NUMPAD9 小键盘 9 键 0x69
VK_F1 - VK_F24 功能键F1 – F24 0x70 - 0x87
VK_NUMLOCK Num Lock 键 0x90
VK_SCROLL Scroll Lock 键 0x91
DirectInput中的虚拟键
DIK_0 – DIK_9 数字 0 - 9
DIK_A – DIK_Z 字母 A - Z
DIK_F1 – DIK_F12 功能键F1 – F12
DIK_BACK Backspace 键
DIK_TAB Tab 键
DIK_RETURN 回车键
DIK_LSHIFT 左Shift 键
DIK_RSHIFT 右Shift 键
DIK_LCONTROL 左Ctrl 键
DIK_RCONTROL 右Ctrl 键
DIK_LMENU 左Alt 键
DIK_RMENU 右Alt 键
DIK_PAUSE Pause 键
DIK_CAPITAL Caps Lock 键
DIK_ESCAPE Esc 键
DIK_SPACE 空格键
DIK_PRIOR Page Up 键
DIK_NEXT Page Down 键
DIK_END End 键
DIK_HOME Home 键
DIK_LEFT 左箭头键
DIK_UP 上箭头键
DIK_RIGHT 右箭头键
DIK_DOWN 下箭头键
DIK_SYSRQ SysRq键
DIK_INSERT Insert 键
DIK_DELETE Delete 键
DIK_LWIN 左WinKey(104键盘才有)
DIK_RWIN 右WinKey(104键盘才有)
DIK_APPS AppsKey(104键盘才有)
DIK_NUMPAD0 – DIK_NUMPAD0 小键盘 0 – 9 键
DIK_NUMLOCK Num Lock 键
DIK_SCROLL Scroll Lock 键
附录三 DirectX函数返回值列表
DirectDraw部分
DD_OK
The request completed successfully.
DDERR_ALREADYINITIALIZED
The object has already been initialized.
DDERR_BLTFASTCANTCLIP
A DirectDrawClipper object is attached to a source surface that has passed into a call to the IDirectDrawSurface::BltFast method.
DDERR_CANNOTATTACHSURFACE
A surface cannot be attached to another requested surface.
DDERR_CANNOTDETACHSURFACE
A surface cannot be detached from another requested surface.
DDERR_CANTCREATEDC
Windows can not create any more device contexts (DCs), or a DC was requested for a palette-indexed surface when the surface had no palette and the display mode was not palette-indexed (in this case DirectDraw cannot select a proper palette into the DC).
DDERR_CANTDUPLICATE
Primary and 3-D surfaces, or surfaces that are implicitly created, cannot be duplicated.
DDERR_CANTLOCKSURFACE
Access to this surface is refused because an attempt was made to lock the primary surface without DCI support.
DDERR_CANTPAGELOCK
An attempt to page lock a surface failed. Page lock will not work on a display-memory surface or an emulated primary surface.
DDERR_CANTPAGEUNLOCK
An attempt to page unlock a surface failed. Page unlock will not work on a display-memory surface or an emulated primary surface.
DDERR_CLIPPERISUSINGHWND
An attempt was made to set a clip list for a DirectDrawClipper object that is already monitoring a window handle.
DDERR_COLORKEYNOTSET
No source color key is specified for this operation.
DDERR_CURRENTLYNOTAVAIL
No support is currently available.
DDERR_DCALREADYCREATED
A device context (DC) has already been returned for this surface. Only one DC can be retrieved for each surface.
DDERR_DEVICEDOESNTOWNSURFACE
Surfaces created by one DirectDraw device cannot be used directly by another DirectDraw device.
DDERR_DIRECTDRAWALREADYCREATED
A DirectDraw object representing this driver has already been created for this process.
DDERR_EXCEPTION
An exception was encountered while performing the requested operation.
DDERR_EXCLUSIVEMODEALREADYSET
An attempt was made to set the cooperative level when it was already set to exclusive.
DDERR_EXPIRED
The data has expired and is therefore no longer valid.
DDERR_GENERIC
There is an undefined error condition.
DDERR_HEIGHTALIGN
The height of the provided rectangle is not a multiple of the required alignment.
DDERR_HWNDALREADYSET
The DirectDraw cooperative level window handle has already been set. It cannot be reset while the process has surfaces or palettes created.
DDERR_HWNDSUBCLASSED
DirectDraw is prevented from restoring state because the DirectDraw cooperative level window handle has been subclassed.
DDERR_IMPLICITLYCREATED
The surface cannot be restored because it is an implicitly created surface.
DDERR_INCOMPATIBLEPRIMARY
The primary surface creation request does not match with the existing primary surface.
DDERR_INVALIDCAPS
One or more of the capability bits passed to the callback function are incorrect.
DDERR_INVALIDCLIPLIST
DirectDraw does not support the provided clip list.
DDERR_INVALIDDIRECTDRAWGUID
The globally unique identifier (GUID) passed to the DirectDrawCreate function is not a valid DirectDraw driver identifier.
DDERR_INVALIDMODE
DirectDraw does not support the requested mode.
DDERR_INVALIDOBJECT
DirectDraw received a pointer that was an invalid DirectDraw object.
DDERR_INVALIDPARAMS
One or more of the parameters passed to the method are incorrect.
DDERR_INVALIDPIXELFORMAT
The pixel format was invalid as specified.
DDERR_INVALIDPOSITION
The position of the overlay on the destination is no longer legal.
DDERR_INVALIDRECT
The provided rectangle was invalid.
DDERR_INVALIDSTREAM
The specified stream contains invalid data.
DDERR_INVALIDSURFACETYPE
The requested operation could not be performed because the surface was of the wrong type.
DDERR_LOCKEDSURFACES
One or more surfaces are locked, causing the failure of the requested operation.
DDERR_MOREDATA
There is more data available than the specified buffer size can hold.
DDERR_NO3D
No 3-D hardware or emulation is present.
DDERR_NOALPHAHW
No alpha acceleration hardware is present or available, causing the failure of the requested operation.
DDERR_NOBLTHW
No blitter hardware is present.
DDERR_NOCLIPLIST
No clip list is available.
DDERR_NOCLIPPERATTACHED
No DirectDrawClipper object is attached to the surface object.
DDERR_NOCOLORCONVHW
The operation cannot be carried out because no color-conversion hardware is present or available.
DDERR_NOCOLORKEY
The surface does not currently have a color key.
DDERR_NOCOLORKEYHW
The operation cannot be carried out because there is no hardware support for the destination color key.
DDERR_NOCOOPERATIVELEVELSET
A create function is called without the IDirectDraw7::SetCooperativeLevel method being called.
DDERR_NODC
No DC has ever been created for this surface.
DDERR_NODDROPSHW
No DirectDraw raster operation (ROP) hardware is available.
DDERR_NODIRECTDRAWHW
Hardware-only DirectDraw object creation is not possible; the driver does not support any hardware.
DDERR_NODIRECTDRAWSUPPORT
DirectDraw support is not possible with the current display driver.
DDERR_NOEMULATION
Software emulation is not available.
DDERR_NOEXCLUSIVEMODE
The operation requires the application to have exclusive mode, but the application does not have exclusive mode.
DDERR_NOFLIPHW
Flipping visible surfaces is not supported.
DDERR_NOFOCUSWINDOW
An attempt was made to create or set a device window without first setting the focus window.
DDERR_NOGDI
No GDI is present.
DDERR_NOHWND
Clipper notification requires a window handle, or no window handle has been previously set as the cooperative level window handle.
DDERR_NOMIPMAPHW
The operation cannot be carried out because no mipmap capable texture mapping hardware is present or available.
DDERR_NOMIRRORHW
The operation cannot be carried out because no mirroring hardware is present or available.
DDERR_NONONLOCALVIDMEM
An attempt was made to allocate non-local video memory from a device that does not support non-local video memory.
DDERR_NOOPTIMIZEHW
The device does not support optimized surfaces.
DDERR_NOOVERLAYDEST
The IDirectDrawSurface::GetOverlayPosition method is called on an overlay that the IDirectDrawSurface::UpdateOverlay method has not been called on to establish a destination.
DDERR_NOOVERLAYHW
The operation cannot be carried out because no overlay hardware is present or available.
DDERR_NOPALETTEATTACHED
No palette object is attached to this surface.
DDERR_NOPALETTEHW
There is no hardware support for 16- or 256-color palettes.
DDERR_NORASTEROPHW
The operation cannot be carried out because no appropriate raster operation hardware is present or available.
DDERR_NOROTATIONHW
The operation cannot be carried out because no rotation hardware is present or available.
DDERR_NOSTRETCHHW
The operation cannot be carried out because there is no hardware support for stretching.
DDERR_NOT4BITCOLOR
The DirectDrawSurface object is not using a 4-bit color palette and the requested operation requires a 4-bit color palette.
DDERR_NOT4BITCOLORINDEX
The DirectDrawSurface object is not using a 4-bit color index palette and the requested operation requires a 4-bit color index palette.
DDERR_NOT8BITCOLOR
The DirectDrawSurface object is not using an 8-bit color palette and the requested operation requires an 8-bit color palette.
DDERR_NOTAOVERLAYSURFACE
An overlay component is called for a non-overlay surface.
DDERR_NOTEXTUREHW
The operation cannot be carried out because no texture-mapping hardware is present or available.
DDERR_NOTFLIPPABLE
An attempt has been made to flip a surface that cannot be flipped.
DDERR_NOTFOUND
The requested item was not found.
DDERR_NOTINITIALIZED
An attempt was made to call an interface method of a DirectDraw object created by CoCreateInstance before the object was initialized.
DDERR_NOTLOADED
The surface is an optimized surface, but it has not yet been allocated any memory.
DDERR_NOTLOCKED
An attempt is made to unlock a surface that was not locked.
DDERR_NOTPAGELOCKED
An attempt is made to page unlock a surface with no outstanding page locks.
DDERR_NOTPALETTIZED
The surface being used is not a palette-based surface.
DDERR_NOVSYNCHW
The operation cannot be carried out because there is no hardware support for vertical blank synchronized operations.
DDERR_NOZBUFFERHW
The operation to create a z-buffer in display memory or to perform a blit using a z-buffer cannot be carried out because there is no hardware support for z-buffers.
DDERR_NOZOVERLAYHW
The overlay surfaces cannot be z-layered based on the z-order because the hardware does not support z-ordering of overlays.
DDERR_OUTOFCAPS
The hardware needed for the requested operation has already been allocated.
DDERR_OUTOFMEMORY
DirectDraw does not have enough memory to perform the operation.
DDERR_OUTOFVIDEOMEMORY
DirectDraw does not have enough display memory to perform the operation.
DDERR_OVERLAPPINGRECTS
Operation could not be carried out because the source and destination rectangles are on the same surface and overlap each other.
DDERR_OVERLAYCANTCLIP
The hardware does not support clipped overlays.
DDERR_OVERLAYCOLORKEYONLYONEACTIVE
An attempt was made to have more than one color key active on an overlay.
DDERR_OVERLAYNOTVISIBLE
The IDirectDrawSurface4::GetOverlayPosition method is called on a hidden overlay.
DDERR_PALETTEBUSY
Access to this palette is refused because the palette is locked by another thread.
DDERR_PRIMARYSURFACEALREADYEXISTS
This process has already created a primary surface.
DDERR_REGIONTOOSMALL
The region passed to the IDirectDrawClipper::GetClipList method is too small.
DDERR_SURFACEALREADYATTACHED
An attempt was made to attach a surface to another surface to which it is already attached.
DDERR_SURFACEALREADYDEPENDENT
An attempt was made to make a surface a dependency of another surface to which it is already dependent.
DDERR_SURFACEBUSY
Access to the surface is refused because the surface is locked by another thread.
DDERR_SURFACEISOBSCURED
Access to the surface is refused because the surface is obscured.
DDERR_SURFACELOST
Access to the surface is refused because the surface memory is gone. Call the IDirectDrawSurface::Restore method on this surface to restore the memory associated with it.
DDERR_SURFACENOTATTACHED
The requested surface is not attached.
DDERR_TOOBIGHEIGHT
The height requested by DirectDraw is too large.
DDERR_TOOBIGSIZE
The size requested by DirectDraw is too large. However, the individual height and width are valid sizes.
DDERR_TOOBIGWIDTH
The width requested by DirectDraw is too large.
DDERR_UNSUPPORTED
The operation is not supported.
DDERR_UNSUPPORTEDFORMAT
The pixel format requested is not supported by DirectDraw.
DDERR_UNSUPPORTEDMASK
The bitmask in the pixel format requested is not supported by DirectDraw.
DDERR_UNSUPPORTEDMODE
The display is currently in an unsupported mode.
DDERR_VERTICALBLANKINPROGRESS
A vertical blank is in progress.
DDERR_VIDEONOTACTIVE
The video port is not active.
DDERR_WASSTILLDRAWING
The previous blit operation that is transferring information to or from this surface is incomplete.
DDERR_WRONGMODE
This surface cannot be restored because it was created in a different mode.
DDERR_XALIGN
The provided rectangle was not horizontally aligned on a required boundary.
Direct3D部分
D3D_OK
No error occurred.
D3DERR_CONFLICTINGRENDERSTATE
The currently set render states cannot be used together.
D3DERR_CONFLICTINGTEXTUREFILTER
The current texture filters cannot be used together.
D3DERR_CONFLICTINGTEXTUREPALETTE
The current textures cannot be used simultaneously. This generally occurs when a multitexture device requires that all palletized textures simultaneously enabled also share the same palette.
D3DERR_DEVICELOST
The device is lost and cannot be restored at the current time, so rendering is not possible.
D3DERR_DEVICENOTRESET
The device cannot be reset.
D3DERR_DRIVERINTERNALERROR
Internal driver error.
D3DERR_INVALIDCALL
The method call is invalid. For example, a method's parameter may have an invalid value.
D3DERR_INVALIDDEVICE
The requested device type is not valid.
D3DERR_MOREDATA
There is more data available than the specified buffer size can hold.
D3DERR_NOTAVAILABLE
This device does not support the queried technique.
D3DERR_NOTFOUND
The requested item was not found.
D3DERR_OUTOFVIDEOMEMORY
Direct3D does not have enough display memory to perform the operation.
D3DERR_TOOMANYOPERATIONS
The application is requesting more texture-filtering operations than the device supports.
D3DERR_UNSUPPORTEDALPHAARG
The device does not support a specified texture-blending argument for the alpha channel.
D3DERR_UNSUPPORTEDALPHAOPERATION
The device does not support a specified texture-blending operation for the alpha channel.
D3DERR_UNSUPPORTEDCOLORARG
The device does not support a specified texture-blending argument for color values.
D3DERR_UNSUPPORTEDCOLOROPERATION
The device does not support a specified texture-blending operation for color values.
D3DERR_UNSUPPORTEDFACTORVALUE
The device does not support the specified texture factor value.
D3DERR_UNSUPPORTEDTEXTUREFILTER
The device does not support the specified texture filter.
D3DERR_WRONGTEXTUREFORMAT
The pixel format of the texture surface is not valid.
E_FAIL
An undetermined error occurred inside the Direct3D subsystem.
E_INVALIDARG
An invalid parameter was passed to the returning function
E_INVALIDCALL
The method call is invalid. For example, a method's parameter may have an invalid value.
E_OUTOFMEMORY
Direct3D could not allocate sufficient memory to complete the call.
S_OK
No error occurred.
附录四 Winsock函数返回值列表
WSAEINTR (10004)
Interrupted Function Call -- A blocking operation was cancelled.
WSAEACCESS (10013)
Permission Denied -- An attempt to access a socket was forbidden by its access permissions.
WSAEFAULT (10014)
Bad Address -- An invalid pointer address was specified in a function call.
WSAEINVAL (10022)
Invalid Argument -- An invalid argument was passed to a function.
WSAEMFILE (10024)
Too Many Open Files -- There are too many open sockets.
WSAEWOULDBLOCK (10035)
Resource Temporarily Unavailable -- The specified socket operation cannot be completed immediately, but the operation should be retried later.
WSAEINPROGRESS (10036)
Operation Now in Progress -- A blocking operation is currently executing.
WSAEALREADY (10037)
Operation Already in Progress -- An operation was attempted on a non-binding socket that already had an operation in progress.
WSAENOTSOCK (10038)
Socket Operation on Non-Socket -- An operation was attempted on something that is not a socket.
WSAEDESTADDRREQ (10039)
Destination Address Required -- A required address was omitted from a socket operation.
WSAEMSGSIZE (10040)
Message Too Long -- A message was sent on a datagram socket that exceeds the internal message buffer or some other limit.
WSAEPROTOTYPE (10041)
Protocol Wrong Type for Socket -- A protocol was specified that is not supported by the target socket.
WSAENOPROTOOPT (10042)
Bad Protocol Option -- An unknown, invalid, or unsupported protocol option or leel was specified.
WSAEPROTONOSUPPORT (10043)
Protocol Not Supported -- The specified protocol is not supported or is not implemented.
WSAESOCKTNOSUPPORT (10044)
Socket Type Not Supported -- The specified socket type is not supported in the address family.
WSAEOPNOTSUPP (10045)
Operation Not Supported -- The specified operation is not supported by the referenced object.
WSAEPFNOSUPPORT (10046)
Protocol Family Not Supported -- The specified protocol family is not supported or is not implemented.
WSAEAFNOSUPPORT (10047)
Address Family Not Supported by Protocol Family -- An address incompatible with the requested network protocol was used.
WSAEADDRINUSE (10048)
Address Already in Use -- An attempt to use the same IP address and port with two different sockets simultaneously was made.
WSAEADDRNOTAVAIL (10049)
Cannot Assign Requested Address -- The requested address is not valid (given the context of the function).
WSAENETDOWN (10050)
Network is Down -- A socket operation encountered a network that is down.
WSAENETUNREACH (10051)
Network is Unreachable -- A socket operation encountered an unreachable network.
WSAENETRESET (10052)
Network Dropped Connection on Reset -- A connection was broken due to "keep-alive" activity detecting a failure.
WSAECONNABORTED (10053)
Software Caused Connection Abort -- A connection was aborted by software on the host computer.
WSAECONNRESET (10054)
Connection Reset by Peer -- A connection was forcibly closed by the remote host.
WSAENOBUFS (10055)
No Buffer Space Available -- A socket operation could not be performed because the system ran out of buffer space or the queue was full.
WSAEISCONN (10056)
Socket is Already Connected -- A connect request was made on a socket that is already connected.
WSAENOTCONN (10057)
Socket is Not Connected -- An attempt to send or receive data failed because the socket is not connected.
WSAESHUTDOWN (10058)
Cannot Send After Socket Shutdown -- An attempt to send or receive data failed because the socket has already been shut down.
WSAETIMEDOUT (10060)
Connection Timed Out -- The remote host failed to respond within the timeout period.
WSAECONNREFUSED (10061)
Connection Refused -- The target machine actively refused the attempt to connect to it.
WSAEHOSTDOWN (10064)
Host is Down -- The destination host is down.
WSAEHOSTUNREACH (10065)
No Route to Host -- The destination host is unreachable.
WSAEPROCLIM (10067)
Too Many Processes -- The Winsock implementation has exceeded the number of applications that can use it simultaneously.
WSASYSNOTREADY (10091)
Network Subsystem is Unavailable -- The underlying system to provide network services is unavailable.
WSAVERNOTSUPPORTED (10092)
winsock.dll Version Out of Range -- The Winsock implementation does not support the requested Winsock version.
WSANOTINITIALIZED (10093)
Successful WSAStartup Not Yet Performed -- The calling application has not successfully called WSAStartup to initiate a Winsock session.
WSAEDISCON (10094)
Graceful Shutdown in Progress -- The remote party has initiated a graceful shutdown sequence.
WSATYPE_NOT_FOUND (10109)
Class Type Not Found -- The specified class was not found.
WSAHOST_NOT_FOUND (11001)
Host Not Found -- No network host matching the hostname or address was found.
WSATRY_AGAIN (11002)
Non-Authoritative Host Not Found -- A temporary error while resolving a hostname occured, and should be retried later.
WSANO_RECOVERY (11003)
This is a Non-Recoverable Error -- Some sort of non-recoverable error occured during a database lookup.
WSANO_DATA (11004)
Valid Name, No Data Record of Requested Type -- The requested name is valid and was found, but does not have the associated data requested.
附录五 游戏编程常用网址
新浪网游戏制作论坛
http://newbbs2.sina.com.cn/index.shtml?games:gamedesign
17173游戏联盟论坛
http://bbs.17173.com.cn/default.asp
CSDN专家门诊
http://www.csdn.net/Expert/Forum.asp?roomid=12&typenum=2
中国游戏开发者
http://mays.soage.com/
中国游戏开发技术资源网
http://www.gameres.com/
云风工作室
http://linux.163.com/cloudwu/2000/index.html
Imagic工作室
http://www.imagic3d.com/cindex.html
Game1st
http://www.game1st.com/cindex.htm
何苦做游戏
http://www.npc6.com/index.htm
金点时空
http://www.gpgame.com/
GameDev (英文)
http://www.gamedev.net
FlipCode (英文)
http://www.flipcode.com
附录六 中英文名词对照
Back Buffer 后台缓存
Buffer 缓存
Class 类
Color Key 关键色,透明色
Cooperative Level 控制级,协作级
Off-screen Surface 离屏页面
Material 材质,材料
Primary Surface 主页面
Sprite 精灵
Surface 页面,表面
Template 模板
Texture 纹理,贴图
Union 联合
Vertex 顶点
附录七 常见问题及解决办法
1. 程序编译时出现"Warning"
说了是"Warning"(警告)就不会是什么大问题,你可以忽略它们。不过有时程序的运行异常就是由这些Warning引起,特别是类型转换警告。
2. "Cannot Execute Program"
退出VC.net再重新启动它,也可以试试Build---Rebuild Solution。如果还不行,检查程序是否正在运行中。再不行,到工程属性处看看是否设错了exe输出路径。
3. "Unresolved External Symbol"
如果出现"Main"或是"WinMain"的字样,你一定是选错了工程类型:到底是Win32 Application,还是Win32 Console Application?如果出现了类或函数的名字,那么要么是你没有在工程中加入所有应加入的lib,要么是你定义的extern变量没有无extern形式与之相对应。请参阅1.1节和1.8节。
4. 运行时出错
95%以上的运行时错误是由指针引起的。所以现在立刻进入Debug模式,然后按F5执行代码,看看到底是在哪一行代码处出错吧。特别注意此行的指针是否未初始化和是否越界。
5. 大家还有什么问题,可以告诉我 <