今后我们实验室的研究重点将会聚焦在“基于游戏的测评”和”教育游戏化“这两个主题上,因此很有必要研究实现“爆款”游戏的一些基本的技术方法。这篇文章将介绍如何借助Matlab GUI + 面向对象编程技术实现贪吃蛇游戏。
所有的游戏都可以解构成至少两个层次:模型层和表现层。为了实现游戏目标,我们最优先要解决的是映射问题。
Q1,如何从模型层映射到表现层?
这里有一个关键词叫做渲染(Render)。贪吃蛇游戏的场景是一张地图,它是由一个个方块组成的。它的模型层是一个所有参数都为1的数值矩阵,映射到表现层则是由一个个白色方块组成的图像矩阵。我们试试在中间抠出来一个小黑块。代码如下:
% filename is:: drawMap_SnakeGame01.m
这里专门设计了一个30x30的方块:
fkMatrix = ones(30,30);
我们借助一个叫做kron的函数,将模型矩阵modelMatrix中的点放大成方块。显示效果如图:
地图上游走着一条蛇(坐标x,y的向量Array),而且一般这条蛇都是带颜色的(不能只是黑白的),所以,上面这种渲染机制还不够完美。
假如贪吃蛇一开始是3个小方块组成的,而且蛇的头部和身体是不同颜色的(蛇头是红色的,身体是蓝色的)。我们试着写一个这样的向量出来:
snakeArray = [6,6; 6,5; 6,4];
我们先实现蛇头红色方块的渲染机制:
% filename is:: drawMap_SnakeGame01.m
我们分别对RGB三层的矩阵进行渲染,然后把三者用cat函数合并为一个矩阵,bigMatrix = bigMatrix_R + bigMatrix_G + bigMatrix_B。我们来看一下效果:
现在,我们争取一口气把蛇的头部和身体都打印出来看看:
% filename is:: drawMap_SnakeGame01.m
这么看感觉有点乱,我们截图看下Matlab编辑器窗口的样子:
最后出来的效果:
这就相当于解决了渲染问题,接下来我们可以专注在模型层,把蛇的数学模型对象设计好,然后通过渲染机制映射到表现层(坐标轴)。
第一个类是蛇Snake
classdef
暂时只有一个初始化的方法(也可以叫做构造函数),给snakeArray和snakeDirection两个变量赋值。
第二个类是主窗口类WinViewer
我们把蛇的坐标向量获取过来之后,利用之前代码中的渲染机制,把模型层的数值矩阵映射到了表现层的坐标轴上。效果如下:
渲染的问题解决了之后,我们借助面向对象编程(极简版),把蛇的模型层的数据,渲染到了表现层。这个时候我们就遇到了第二个问题:
Q2,怎么才能让蛇动起来?
我们需要添加一些功能:
第一,在主窗口类中添加一个Timer定时器。它的功能是每间隔一段时间,都会驱动它的回调函数执行一次。
第二,在蛇类中添加一个move方法,方便主窗口每次执行更新函数时调用蛇对象的这个move方法。
第三,在move移动之后,可以再渲染一次蛇对象,更新到表现层。
针对第二个问题,怎么才能实现贪吃蛇的移动?
它其实是一个技术活,涉及到一个“栈”的概念。
snakeArray = [6,6; 6,5; 6,4]; % 原始状态
--- 1 --->
snakeArray = [6,6; 6,5]; % 去掉尾巴
--- 2 --->
snakeArray = [6,7; 6,6; 6,5]; % 增加头部
所以,这个问题就又变成了,Matlab中向量是如何去掉尾巴?
snakeArray(end,:) = [];
Matlab又是如何增加头部的?
tmpPoint = [snakeArray(1,1) snakeArray(1,2)+1];snakeArray = [tmpPoint; snakeArray];
这只是所有四种情况的其中一种代码形式(因为存在四个方向的问题,暂时取向右方向)。
我们先来完善蛇Snake类的代码,然后再在主窗口WinViewer类中添加Timer控件对象。
classdef
这样看还是不够清楚,我们再截图看下:
有了这个带move方法的蛇Snake类之后,我们就可以再进一步完善WinViewer类。
classdef WinViewer < handlepropertiesviewSizehFigurehAxesfkMatrixbigMatrixmodelMatrix_RmodelMatrix_GmodelMatrix_BsnakeObjtimePeriodtimerObjendmethods% 构造函数function obj = WinViewer(obj)% 设置窗口的宽和高figWidth = 600;figHeight = 600;% 设置窗口最初的位置x和yfigX = 100;figY = 100;% 确定窗口位置大小viewSizeobj.viewSize = [figX figY figWidth figHeight];% 创建窗口obj.hFigure = figure(1);set(obj.hFigure, 'position',obj.viewSize);% 创建坐标轴obj.hAxes = axes('parent',obj.hFigure);set(obj.hAxes, 'units','pixels', 'position',[1 1 figWidth figHeight]);% 初始化方块矩阵fkMatrix和地图矩阵bigMatrixobj.fkMatrix = ones(30,30);obj.bigMatrix = ones(600,600,3);obj.modelMatrix_R = ones(20,20);obj.modelMatrix_G = ones(20,20);obj.modelMatrix_B = ones(20,20);% load the snake objectobj.snakeObj = Snake();% timerObj <--- 添加Timer控件!obj.timePeriod = 1;obj.timerObj = timer('ExecutionMode','fixedDelay', 'Period',obj.timePeriod);set(obj.timerObj, 'TimerFcn',@obj.timerCallbackFcn);% updateSnakeobj.renderSnake();% Binding Mechanismset(obj.hFigure, 'DeleteFcn',@obj.hFigure_DeleteFcn);% 开启游戏obj.start();end% function --> updateSnakefunction obj = renderSnake(obj)% load the variablessnakeArray = obj.snakeObj.snakeArray;fkMatrix = obj.fkMatrix;modelMatrix_R = obj.modelMatrix_R;modelMatrix_G = obj.modelMatrix_G;modelMatrix_B = obj.modelMatrix_B;% LOOP: index is ifor i = 1:length(snakeArray)if i == 1tmpX = snakeArray(i,1);tmpY = snakeArray(i,2);% RmodelMatrix_R(tmpX,tmpY) = 1;% GmodelMatrix_G(tmpX,tmpY) = 0;% BmodelMatrix_B(tmpX,tmpY) = 0;elsetmpX = snakeArray(i,1);tmpY = snakeArray(i,2);% RmodelMatrix_R(tmpX,tmpY) = 0;% GmodelMatrix_G(tmpX,tmpY) = 0;% BmodelMatrix_B(tmpX,tmpY) = 1;endend% RbigMatrix_R = kron(modelMatrix_R,fkMatrix);% GbigMatrix_G = kron(modelMatrix_G,fkMatrix);% BbigMatrix_B = kron(modelMatrix_B,fkMatrix);% combineobj.bigMatrix = cat(3, bigMatrix_R, bigMatrix_G, bigMatrix_B);% show the bigMatrix in BlackWhiteimshow(obj.bigMatrix,'parent',obj.hAxes);end% function --> startfunction obj = start(obj)%启动Timerstart(obj.timerObj);end% function --> timerCallbackFcnfunction timerCallbackFcn(obj, src, data)obj.snakeObj.move();obj.renderSnake();end% function -->function hFigure_DeleteFcn(obj, src, data)ts = timerfind;timerN = length(ts);if timerN >0stop(ts); delete(ts);return;endendendend
添加Timer控件对象,不需要事先写好Timer类,因为它是Matlab自带的。
% timerObj <--- 添加Timer控件!
obj.timePeriod = 1;
obj.timerObj = timer('ExecutionMode','fixedDelay', 'Period',obj.timePeriod);
set(obj.timerObj, 'TimerFcn',@obj.timerCallbackFcn);
我们一般都是要设置一下它的间隔时间timePeriod和它驱动的回调函数。
注意,Timer控件是独立于窗口存在的,因此你关闭窗口的时候,它仍然会待在内存里,不会自己消失掉。这样是不是很危险?!所以,但凡是Timer控件出现的地方,也一定要专门设计一个DeleteFcn,在窗口退出的时候调用它,以停止和删除Timer控件。
% Binding Mechanism
set(obj.hFigure, 'DeleteFcn',@obj.hFigure_DeleteFcn);
这个函数内容是:
% function --> hFigure_DeleteFcn
代码的意思是:查找内存中的timer,如果有的话,就都删掉。
这里要插播一个新需求,就是我很想呈现贪吃蛇的动图,在我之前的教程中从来没有探讨过如何制作一个gif动图,我们试着在现有的框架下,添加少量代码(用后即焚!),来实现这个功能,以使得大家能够直观地看到贪吃蛇跑起来的样子有多风骚
我们把整个过程分为两步来执行:
(1)在Timer控件启动的更新函数中,想办法进行截图并保存;
在更新函数中添加截图和保存图片的操作:
... ...
(2)重新写一个脚本文件,将10张jpg的图片合成一张gif的动图:
% filename is:: generateGIF.m
我们一起来看一下动图的效果:
从用户体验的视角来看,动态的比静态的好ღ( ´・ᴗ・` ) 完美回答第二个问题,如何让贪吃蛇动起来。
如果一直让贪吃蛇往一个方向前进,总有一天它会跑到矩阵外去了(发生错误!可能程序本身不会报错,但是,逻辑上是有问题的):
从这个图可以看出,Matlab的绘图功能是有自适应机制的,你看到左边那个游戏范围因为贪吃蛇超出边界太多会越变越小。所以,第三个问题是要避免这样的逻辑错误发生,就需要对贪吃蛇进行行为上的限定。
Q3,如何让贪吃蛇待在游戏设定的范围内?
这就需要进行专门的判断,如果贪吃蛇超出游戏限定的范围,游戏结束!
我们有两种选择,一种是把碰撞边界的判断写在Snake类中,另外一种是把碰撞边界的判断写在窗口类中。因为Snake类如果想探测边界碰撞,需要引入窗口的宽和高,暂时我想还是把碰撞检测放在窗口类下面:
% function --> judgeKnockEdge
在每次更新的时候,有一个调用碰撞检测的方法,一旦检测到碰撞的状态是1,也就是说蛇Snake超出游戏边界了,就不要再渲染蛇了,
% function --> timerCallbackFcn
直接调用gameover方法:
% function --> gameover
然后把结束指导语,也就是白色背景imgMat_White画到坐标轴hAxes上 + 写着”Game Over“的文本控件显示出来。
窗口类的完整代码分享如下(除了上述内容之外,还增加了一个文本控件):
classdef
第四个问题是,贪吃蛇现在只往一个方向去,往右,它还没有接受来自键盘的控制。所以下一个问题我们要解决的是:
Q4,如何才能操控贪吃蛇的移动方向?
我们要给整个窗口绑定一个键盘操作的函数:
% --- Binding Mechanism ---set(obj.hFigure, 'DeleteFcn',@obj.hFigure_DeleteFcn);set(obj.hFigure, 'WindowKeyPressFcn',@obj.hFigure_WindowKeyPressFcn);
键盘操作的具体函数的代码:
% G. function --> function obj = hFigure_WindowKeyPressFcn(obj, src, data)KeyPressed=data.Key;if strcmp(KeyPressed,'escape')close(gcf);endend
这里写的是最简单的键盘输入,就是在窗口游戏运行的时候,假如玩家按下'Escape' 退出键的话,游戏终止+窗口关闭。
我们只需要在这个框架下,将按键和贪吃蛇的方向绑定在一起即可。键盘操作函数的代码具体扩展如下:
% G. function --> hFigure_WindowKeyPressFcn
这个函数的目的是形成一个代表方向的参数dValue,想办法传给贪吃蛇对象的changeDirection方法,这个方法是蛇Snake类新增加的:
% changeDirection
这段代码看起来怎么这么复杂?
这是因为,贪吃蛇在向某个方向行进的时候,这个方向和反方向的按键是不起作用的;只有在这个方向垂直的两个方向按键才能真正改变方向。例如,贪吃蛇一开始是向右行进的(4),那么,你如果这个时候按下向右(4)或者向左方向键(3),它是不起作用的;你只有按下向上(1)或者向下方向键(2),它才能起到改变方向的作用。
最后,还要修改蛇Snake类中的移动move方法的代码,我把完整的代码粘贴过来,大家看一下move方法的代码修改后的逻辑:
classdef
我们来看一下按键改变贪吃蛇的效果:
一旦引入了玩家的操控,这条贪吃蛇的运动轨迹就实时反映了玩家(现在是我)的意识做出的一系列输出动作。
Q5,如何才能随机贪吃蛇的食物?
和蛇Snake类很类似,我们也需要创建一个食物Food类。
classdef
然后,我们需要在一开始的对象变量中添加一个foodObj
snakeObj
在窗口WinViewer类的初始化函数中,添加食物对象的初始化代码:
% load the snake and food objectsobj.snakeObj = Snake();obj.foodObj = Food();
然后,我们将原来的渲染函数renderSnake修改为蛇和食物一起渲染的renderSnake_and_Food函数:
% A. function --> renderSnake_and_Food
最后,在Timer控件驱动的回调函数中,微调一下调用渲染函数的代码:
% D. function --> timerCallbackFcn
我们来看一下加入了食物的游戏开始的画面效果:
这个时候的贪吃蛇和食物之间是平行关系,即便贪吃蛇经过了食物的位置,它也是穿过的状态,食物不会被吃掉,贪吃蛇也不会长长。而且,在食物被吃掉之后,我们还需要随机一个新的位置来放置食物。
Q6,如何随机放置食物,并且让贪吃蛇能吃到食物,并且还能变长?
(1)随机放置食物的操作,我们打算在游戏窗口WinViewer类中添加一个随机的方法randomCoordinate,除了随机之外,还要限定随机食物的位置不能与贪吃蛇重合。代码分享如下:
% H. function --> randomCoordinate
我们在随机方法中嵌套了一个while循环,其目的是为了让随机满足条件后才成立。满足什么条件呢?就是你随机的食物,不能出现在贪吃蛇身上。我这里先预设了一个变量
mOverlap =0;
就是说,随机的食物和贪吃蛇一开始是不重叠的;然后将食物的坐标和贪吃蛇的头部和身体的所有坐标进行一次循环比对,如果有任何一次匹配成功,设置
mOverlap =1;
然后break,从while循环中退出
if mOverlap ==1
break;
end
(2)我们接下来要解决吃到食物的问题,这个问题又可以拆分为一个判断isEatFood是否贪吃蛇吃到食物,以及,如果吃到食物之后,应该进行的后续的操作。
这个判断函数应该写在哪个位置呢?这个判断函数应该发生在贪吃蛇移动之后,但是,又是在渲染之前。代码分享如下:
% D. function --> timerCallbackFcnfunction timerCallbackFcn(obj, src, data)obj.snakeObj.move();obj.judgeKnockEdge();if obj.mState_KnockEdge == 1obj.gameover();return;endobj.judgeEatFood();if obj.mState_EatFood == 1obj.randomCoordinate();endobj.renderSnake_and_Food();% obj.getframeFigure();end
(3)贪吃蛇吃到食物之后,要随机新的坐标,
obj.randomCoordinate();
(4)最后让蛇的长度增加1格,如何实现?
还记得那个删除掉的蛇的尾巴吗?你可以在每次删除的时候,把它记录一下,然后,在move之后,如果判断出贪吃蛇吃掉了食物的话,就把尾巴重新还给它。因此,第一步是在Snake类中增加一个变量,专门用来存储移动之后删掉的那个尾巴:
classdef
在对蛇的尾巴删除之前,我们都先把snakeArray最后一个数据赋给obj.snakeTail:
obj
在窗口WinViewer类中,我们首先增加了一个变量
mState_KnockEdge
增加了Timer控件对应的回调函数中的内容方法:
% D. function --> timerCallbackFcn
每次Timer的回调函数要先进行贪吃蛇是否吃到了食物的判断,
% F.2 function --> judgeEatFoodfunction obj = judgeEatFood(obj, src, data)snakeArray = obj.snakeObj.snakeArray;snakeHead = snakeArray(1,:);tmpX = snakeHead(1,1);tmpY = snakeHead(1,2);foodArray = obj.foodObj.foodArray;foodX = foodArray(1,1);foodY = foodArray(1,2);if tmpX == foodX & tmpY == foodYobj.mState_EatFood = 1;endend
如果贪吃蛇当前是刚好吃到食物,就想办法把mState_EatFood的状态设置为1:
obj.mState_EatFood = 1;
它会决定在Timer函数中运行随机食物位置的函数randomCoordinate,
% H. function --> randomCoordinate
并将蛇的长度增加一格的函数snakeLonger。
% I. function --> snakeLonger
我们来一起看下这个时候的效果:
下面还剩下一个工作,就是贪吃蛇不可以自己吃自己:
第一步,先在窗口WinViewer类的属性中添加mState_KnockSelf变量:
mState_KnockEdge
第二步,在窗口WinViewer类的构造函数中,初始化mState_KnockSelf变量的值:
% mState_...obj.mState_KnockEdge = 0;obj.mState_KnockSelf = 0;obj.mState_EatFood = 0;
第三步,在Timer控件的回调函数中增加一个判断是否贪吃蛇吃到自己身体的逻辑:
% D. function --> timerCallbackFcn
第四步,创建judgeKnockSelf方法的代码:
% F.2 function --> judgeKnockSelf
整个贪吃蛇游戏的代码完成了
我们把蛇Snake类,食物类Food,窗口类WinViewer,还有生成gif动图的脚本文件代码全部分享出来:
蛇Snake类
classdef
食物Food类:
classdef
窗口WinViewer类:
classdef
生成动图的脚本代码:
% filename is:: generateGIF.m
.