本文简述一门课程,演示win32api开发俄罗斯方块的开发过程。如果学生学习过C语言,没学过或者学习C++不好,刚刚開始学习win32api程序设计,还不懂消息循环和注冊窗体类。
近期的照片在这里 [http://www.douban.com/photos/album/132796665/] 和 [http://www.douban.com/photos/album/133241544/]。
1. 背景和原则
我这学期讲一门课,本科三年级,学生满员17人。一般接近满员,最低一次5人,那天据林同学说,其它的同学都去看足球赛了。
课程名字叫做算法与程序设计实践3。第一堂课我照例要解释:到了"3"这个阶段,就不讲算法了,仅仅有实践。只是,后来看看算法也还是有一点应用,比方从一个线性表里删除符合条件的元素们,在线性表里查找符合条件的元素,这样的难度的。
课是在机房上的,大部分时间学生和教师都看着显示,所以一学期下来,好多同学和我见面可能都不太认识。只是我们对代码的形成过程更熟悉一些。
我试图贯彻下述原则:学生应该看到教师编程的过程,而不不过结果;学生应该看到在编辑器和编译器中的代码,而不是WORD或PPT里的;学生应该先学会临模教师的编程过程,而没有能力直接临模结果;学生甚至应该看到教师的错误及错误的解决过程、教师的无知及检索过程,学生不应该看到事先排练的完美的编程过程和全知全能的教师,那样的过程和专家,学生模仿时无从下手。
所以,我课前不准备,在课堂上无意犯各种错误--偶尔演示学生们easy犯的错误--及解决。在LOG文件里记录我们的计划和当前的进度,在绘图里画下原型。
所以,我假装对某些API和函数不熟悉,演示在MSDN和互联网中查找手冊和解决方式的步骤。单独做一些技术原型验证对API的调用结果的猜想,而不是在project的过程中在项目代码中測试技术。有时,我知道问题在哪里,可是要先列出各种可能,然后一一验证猜想(而不是直接解决,这似乎是计算机本科生很easy犯的错误,假设攻克了就认定那是问题的解决办法)。除了这两点,其余的时间我应该尽可能诚实。
有时候,学生会告诉我哪里错了,先于我发现问题的解决办法。这令我享受这种教学过程。
终于,我们--以我编码为主--实现了WIN32API开发的俄罗斯方块。
选择俄罗斯方块的原因,是由于小游戏的业务逻辑足够复杂,保证学生了解在相对复杂的业务逻辑时的面临的问题和编程行为与toy作品不同;所使用的到技术较少,避免过多的机制 (数据库、网络等)分散学生的注意力,保证学生把精力集中在对业务逻辑上。
选择win32api是课堂上投票的结果。选择C语言而没有使用C++有两个原因。一是学生的C++掌握通常并不熟练;二是我希望学生能在项目中发现面向对象的必要性和长处,而不是仅由于学习过哪些语言而在project中选用;三是希望演示用C也能够实现基于对象的程序设计 (不是面向对象,不包含继承,仅包含方法与数据的内聚)。
2. 技术原型
涉及到的技术原型,要在project開始前建立小项目,以验证对这些技术的掌握和对效果的猜想。
要实验的技术列表,来源于需求。我们先不写代码,口头描写叙述需求,然后分解需求到所需的技术。这样就形成了技术列表。这个过程中,同一时候也形成了定义,包含名词和动词表。
这些技术原型也限定了除C语言以外须要掌握的技术,在这次开发其中。
技术原型包含:
* 使用GDI绘图、擦除。用于画小块和移动小块。移动是依据视觉暂留在新的位置上绘图,并把旧位置上的小块以底色重画。
* 键盘消息响应。用于在不暂停小块下落的情况下接受玩家通过按键操纵小块左移、右移、旋转、高速下落。
* 特定范围的随机数生成。用于在创建新的小块时,决定是哪个类型。类型计有S型、L型、凸形、田形,及它们的旋转。
* 计时器 (timer),用于驱动小块定时下落,推断是否该清除一行,计分,刷新工作区 (重画) 等。
* 在工作区输出文字。用于调试和显示分数。
终于形成的原型部分代码量例如以下。代码在附件中的 prototype文件夹下
绘图 (及消息循环) ,draw,226行
擦除,eraser,263行
在工作区输出文字,textout,201行
按键消息响应,key,207行
随机数,random, 31行
计时器,timer,214行
3. 开发过程的里程碑
技术原型确定以后,再又一次回到需求,并把需求排期。争取每次课程限定完毕一个功能。
需求排期遵循的原则是:优先完毕对其它功能无信赖的部分;优先完毕核心功能。
下面是开发过程中的里程碑。
1) 生成块。
2) 计时器驱动,块自己主动下降
3) 键盘控制块 旋转、高速下降、左移、右移
4) 落究竟或粘在底部已存在块上 (if (conficted || touch_bottom) stick)
5) 删除一行:删除一行,把之上的行下降一行
6) 计分:消除一行和多行分值不同
下面功能在本学期没有实现。
7) 生成新块前,在预览区显示下一个块
8) 分数积累到一定程度 (?),加快块下落的速度
开发过程以git版本号控制方式记录了历史,每一个重要功能一次commit,以日期作为message。
4. 定义
我们在开发前用示意图约定了一些定义,作为词汇表。排版原因,我在这里有文字解释一下。
俄罗斯方块元素:工作区上画图的最小单位,是一个小方格。俄罗斯方块的名字 Terris 即四元素,由于每一个当前块由4个元素组成。
数组元素:即C语言中的数组元素,数组中的某一个。提出这个定义是为了差别于俄罗斯方块的元素。
当前块 (current block) :正在移动的由四个元素构成的块。有S型、L型、田字型等类型。
已存在的块 (exist block) :堆积在工作区底部的,已经粘成一团的元素。
像素坐标,世界坐标。像素坐标是由GDI画图定义的,世界坐标由我们定义,以元素为单位,左上是原点 (0,0) ,向右向下递增。
stick。当前块接触到已存在的块,或者当前块接触到工作区底部,此时应该把当前块增加到已存在的块中,然后生成新的当前块;假设导致已存在的块中某一行充满元素,须要按游戏规则删除此行,然后把已存在的块中此行以上的元素降落一行。
5. 数据结构及流程
下面介绍当前块、已存在块、键盘操作、删除已存在块中的一行的数据结构和流程。
5.1 当前块
当前块中,包含当前块的下面数据:当前坐标,上一次的坐标 (用以擦除) ,当前类型 (接下来会解释),上一次的类型 (用于旋转)。结构体例如以下,整个程序中仅仅有这个结构体的唯一实例。
struct struct_block{
int x;
int y; /* row 0, col 0 */
int old_x;
int old_y;
int* type;
int* old_type;
};
当前块的类型使用数组实现,例如以下,各自是一字型、田字型、凸字型。
int line_v_block[]={0, 0, 0, 1, 0, 2, 0, 3};
int line_h_block[]={0,0,1,0,2,0,3,0};
int tian_block[]={0, 0, 0, 1, 1, 0, 1, 1};
int tu_v_block[]={0,1,1,0,1,1,2,1};
int tu_h_block[]={0,1,1,0,1,1,1,2};
数组中的每两个数值 (数据中的元素)代表一个当前块中的元素的坐标,计8个数值代表4个元素。
生成块时,
current_block.type = line_v_block;
指定了当前块的元素。
画图时,遍历"类型数组",把每一个元素绘出。不管何种类型,都遵循这一流程,从而实现"以数据作为代码":类型数组即数据,遍历"类型数组"、在旋转时改变类型等即为引擎。
旋转的代码演示样例,改变类型 (的指针) :
if(current_block.type == line_v_block)
{
current_block.type = line_h_block;
}
平移的代码演示样例,改变横坐标:
current_block.x -= 1;
自己主动下降的代码演示样例,改变上一次的纵坐标和当前纵坐标。
if(! is_conflicted() && ! is_touch_bottom())
{
current_block.old_y = current_block.y;
current_block.y = current_block.y + 1;
}
else
{
stick();
generate_block();
}
高速下降:
纵坐标 添加 全部元素中到达底部 (或已存在块中同一横坐标的顶) 的最短距离。
貌似题外话,helper函数:is_conflicted(),推断当前块是否接触到已存在块;is_touch_bottom(),推断当前块是否触底;匹配横坐标,给出当前块的底坐标;求当前块距离底部的最短距离。等等。
开发helper函数的目的,是为了使程序总体流程清晰。保障总体清晰的方法之中的一个,是要求每一个函数内容不得超过一屏。假设超过了,就须要折解出 helper 函数。在主流程中调用 helper 函数,而把helper函数体移出主流程,这样主流程代码长度就下降了。这和小学写作文的时候,老师要求先拉大纲是一个道理。常常有同学说,在开发过程中会发现新的功能,在开发遇到新的技术,没有做原型的,因此难以把握大纲。这都说明把握大纲和做计划的能力还差,须要通过练习来训练。这和小学生写着写着作文发现须要查字典,或者写跑题了,是一个道理。我们的成长并不是认识的字多了,而是能预见到将会用到哪些字
(甚至表达手法、写作素材)。
此外,在面向对象中,有些的函数会成为game (或者 current block 或者 exist block )的成员函数。这在开发中会认识到,假设它们与数据能内聚在一个类中,该是多么方便,因此了解面向对象的在信息隐藏方面的优势。这些函数应归属于哪个类,是由哪个类承担这个责任决定的。
5.2 已存在块
已存在块中包含下面数据结构:块的长度 (其实,是块的长度*2,代码中以横坐标和纵坐标作为两个数组元素) ,已存在块数组。例如以下。
int exist_block_size=0;
int exist_block[(maxx+1)*(maxy+1)];
这样的数据结构,及当前块的数据结构,把横纵坐标无区别地,不以结构体地方式放在数组中,在兴许开发中带来了麻烦。只是因为课程时间有限,后来,我未对此做出改动。应该逐渐演化程序结构,形成以元素作为结构体的数组。再开发出一些helper甚至成员函数,遍历时以俄罗斯方块元素为单位,而不是当前代码中的以数组元素为单位。
对已存在块数据结构操作的函数之中的一个是 stick,用于在当前块触底 (或触及已存在块)时,把当前块中的元素移到已存在块中。
有不少helper函数,基本都是通过遍历 exist_block,按匹配条件读当中的坐标。包含:匹配横坐标,给出已存在块的顶坐标 int get_exist_block_top(int x)。
5.3 键盘操作 & 动作序列
玩家操作块这一操作,由键盘消息响应開始。我们不在键盘响应中处理这一事件,而是仅仅在这里记住这个动作,增加动作序列中。这是后来的版本号。最初的版本号,我们也不在键盘响应中处理事件,而是调用 block.cpp 中的函数。原则是:凡依赖win32api的,放在 tetris.cpp 中,如 timer, 键盘响应,画图;凡是与业务逻辑有关,平台无关的,放在 block.cpp 中。接收向上箭头,是键盘响应,平台相关,所以放在 tetris.cpp 中;此时调用的 rotate,用于改变当前块的类型或坐标,平台无关,所以放在
block.cpp 中。
动作序列的数据结构例如以下。在动作序列数组buffer_action_seq中,数组动作元素
(动作) 的类型是 枚举 action。
enum action{ action_left=1, action_right=2, action_speed_down=3, action_rotate=4, action_down_auto=5, action_na=0};
action buffer_action_seq[action_size]={action_na};
int buffer_action_cursor = 0;
由玩家触发键盘消息開始,流程例如以下。
1)键盘消息响应:
buffer_action_seq[buffer_action_cursor++] = action_rotate;在动作序列中增加一个动作。这相应于设计模式中的 commander 模式要解决的问题。
2)在timer中自己主动下降
timer中 buffer_action_seq[buffer_action_cursor++] = action_down_auto; 在动作序列中增加一个动作。
3)在timer中触发WM_PAINT
timer 中 InvalidateRect 触发 WM_PAINT
4)WM_PAINT中运行动作序列
erase_old_block_seq(hdc);
erase_old_block_seq (hdc) 遍历动作序列,按每一个动作改变当前块坐标,然后擦除因为动作产生的旧块。遍历动作序列以后,就完毕了自上个 timer 周期以来全部的动作,擦除了这期间产生的全部旧块。
void erase_old_block_seq(HDC hdc) 片断例如以下:
for (i = 0; i < buffer_action_cursor; i++)
{
switch (buffer_action_seq[i])
{
case action_left:
move_left();
erase_old_block(hdc);
break;
在序列里的每一个动作中,move_left 改坐标, erase_old_block(hdc) 擦除旧块.
5)WM_PAINT画新的当前块和已存在块
draw_current_block(hdc);
draw_exist_block(hdc);
由于重绘比计算花费的时间要多,作为性能优化,假设当前块与旧块坐标全然同样,不重画。
另,还有一个版本号的动作序列,不使用枚举和swtich-case,通过把函数作为消息传递给责任者,实现disptach:
void (*next_action)() = move_still;
next_action = move_left
当中 move_left是一个函数。next_action这种元素 (类型是函数) 组成一个数组,作为动作序列。运行动作序列时,用以下这种代码:
while ( next_action++ != action_termination )
next_action;
因为 next_action 既是函数,也是数组元素的指针,因此上述代码不是伪代码,而是能够运行的。这类似于 jump table 技术,数组元素的类型函数,能够遍历数组,运行元素相应的函数。
5.4 删除一行 & 计分数
每一个 timer 中,都调用 void kill_all_full_lines()。它遍历 exist block,凡符合满行条件的,调用 kill_block_in_line 删除该行,调用move_exist_block_down_line 把该行以上的 exist_block 下降一行。
这三个 helper 函数都是通过遍历 exist block 中的每一个元素,匹配坐标条件,然后删除数组元素或者改变数组元素的值。如前所述,因为 exist block 封装中未使用 俄罗斯方块元素,所以这些遍历都写得很丑陋。
删除一行以后,累积删除的行数。全删以后,依据删除的行数进行 switch-case,向全局变量 score 累加分数。在下个timer中,把 score 用 textout 输出到工作区。
6. 回想和检讨
6.1 数据结构,封装,循环条件
因为最初的 (也是终于的)数据结构设计偷了懒,后来又没有足够的时间改动,此前已经提及两次,exist block的结构过于贴近平台,而远离需求。exist block的颗粒度太低,是以 int 为类型的 数组元素,相应于需求中的 俄罗斯方块元素 中的横纵坐标之中的一个。某个数组元素究竟是横坐标还是纵坐标,究竟是第几个俄罗斯方块元素,这些都须要由代码实现。这样,按需求写helper函数的时候,遍历的元素选取、终止条件,都遇到了麻烦。我在课堂上写作时须要考虑,有时还会错。经验说明,当我须要细致考虑,或者讲述时间较长时,学生听懂可能已经有相当难度了。终止条件错误的bug,在代码中存在两三处,导致在
exist block够多时,即游戏进行一段时间,工作区中会出现莫名其妙的俄罗斯方块元素。这个bug在最后阶段才解决。
这个故事告诉我们,设计不好,对编码实现的难度要求就会提高。战略失误,战役和战斗就不easy打。领导决策肤浅,要求下属跑死,结果也是白扯。道理都是一样的。
6.2 不要对付过去
在开发中间的某堂课,我们发现当前块移动时后面留了尾迹,擦得不干净。这些那堂课快结束了。为了能让学生在课后反复我课堂上的工作,所以我"对付"了代码,由局部刷新改为刷新整个工作区,包含背景。这样尾迹表面上清除掉了。
之后,延续了这段"对付"的代码。直到期末将至,我才发现这段"对付"掩盖了还有一些bug,坐标移动的bug导致除非刷新整个工作区就有尾迹。这个bug在最后阶段才解决。
6.3 并行,timer
有文章指出,刚開始学习的人很不easy理解的程序概念包含:赋值、递归和迭代、并行。本程序中有几个埋得比較深的bug,是因为我对并行没有足够警惕造成的。
timer, 键盘响应,WM_PAINT会并行发生。当当中一个未处理完的时候,还有一个可能就開始运行;甚至timer未处理完的时候,还有一个timer也可能会開始。而这些并行的代码,都调用了 block.cpp。比方有时导致当中一个正改坐标尚未完毕,还有一个開始刷新工作区,这样工作区里就出现个元素,位置是乱七八糟的。
并行的处理,须要 原子操作、进程间通信、避免重入 等概念。上述提到的动作序列,目的之中的一个就是希望擦除旧的当前块这一动作仅仅在 timer 中发生。
在本课程中,应该不期待学生具备这些操作系统中的知识。只是我还没有想到该怎样设计才干规避这些知识。只是我猜应该类似于不用线程也能设计出贪吃蛇,应该有依赖更浅显知识的设计手段,比方单纯轮询,而不用事件响应、消息循环。有哪位知道,请赐教,谢谢。
6.4 猜想后,应该先验证,然后再改动
学生们通常把验证猜想和实施解决归约成了一步,我也常常如此。下文中的他们,包含我。
他们观察到问题,然后做出猜想。这是正常步骤。
可是他们不以实验验证猜想是正确的,急急按猜想改动代码。假设问题消失了,好,他们假设抓住了问题的解决办法;假设问题还在,就再做个猜想,然后又立即改动。甚至更糟糕,没有退回到上一步的起点,就在当前工作代码上"继续"改动,让各个猜想累加起来,终于问题解决的时候甚至不知道是什么原因。
应该先设计实验,按猜想的模型,假设如何就会如何。验证猜想以后,再去解决。比方假设因为 timer 和 keyboard事件响应 同步导致绘图混乱,那么,不应该着争写进程通信,而是 应该先选用简单粗暴的手段 去除同步,以更大的颗粒度作为原子操作,验证猜想。假设猜想正确,现象应该有所改变。尽管影响性能和效果,但这并非准备终于採用的代码,仅仅是用来验证猜想的。当猜想验证以后,再去想效果更好的方案真正解决,比方建立个变量作为信号灯。
6.5 不要轻易更换技术方案,试图绕过问题
这个方面,我最初是发现计算机本科的同学倾向强烈。常常有方案,明明再向前一步就能解决,他们却在此时换了方案。问为什么。答:由于这个技术解决不了这个问题。
确定"不"是极其困难的,甚至比确定"能"要难上非常多。你不能,并不是就能确定这个方法不能。
须要充分了解你所使用的技术,对它可以完毕的任务有足够和明白的自信。同一时候,对用来替换的方案能解决何种问题,也应该明白。做原型验证,依据理论推论,这些都是解决之道。见到工具,拿来就用,偏听偏信别人的评论,就太草率了;一旦发现并不是万能良药,转身就去寻找就的手段,这就更草率了。
6.6 版本号控制
为了让学生能看到开发的过程,我上课时用文件系统做了版本号控制,每次课一个文件夹,有时压缩成zip。课程结束以后,一个版本号一个版本号增加git,然后commit,操作了两个小时(?),其间又操心整错了,苦不堪言。
下次一定要从最開始就做版本号控制。还要在 commit 前把 debug, pch, sdf 等二进制垃圾手动删除。
7. 附件
附件是以git版本号控制的代码及日志,在这里[http://download.csdn.net/detail/younggift/7499881]。
protype下是技术原型。
tetris下的是俄罗斯方块项目本身。早先的版本号是VS2010的,最后一天的是VS2012的。你能够仅代码部分加入进win32project,以适应你的VS版本号,或者dev c++版本号。
log0.txt是课堂上的日志。log1.txt是最后一天前期的日志。log2.doc是最后一天后期的日志,由于须要截图,所以改成用word。
pic.bmp是图片,用来说明定义的。
branch是一个分支,我忘了它是否增加了 trunk,留在那里备用,以防遗漏。
--------------------
博客会手工同步到下面地址:
[http://giftdotyoung.blogspot.com]
[http://blog.csdn.net/younggift]
俄罗斯方块:win32api开发,布布扣,bubuko.com