R语言游戏之旅 贪食蛇入门
前言
用R语言进行统计分析不神奇,用R语言做分类算法不神奇,用R语言做可视也不神奇,你见过用R语言做的游戏吗?
本文将带你进入R语言的游戏开发,用R语言实现贪食蛇游戏。
目录
贪食蛇游戏介绍
场景设计
程序设计
R语言实现
1. 贪食蛇游戏介绍
贪食蛇是一个产生于1970年代中后期的计算机游戏。此类游戏在1990年代由于一些小屏幕设备引入而再度流行起來,在现在的手机上基本都可安装此小游戏。
在游戏中,玩家操控一条细长的直线蛇,它会不停前进,玩家只能操控蛇的头部朝向(上下左右),一路拾起触碰到之物(水果),并要避免触碰到自身或者其他障碍物。每次贪食蛇吃掉一件食物,它的身体便增长一些。吃掉一些食物后会使蛇的移動速度逐漸加快,让游戏的难度渐渐变大。游戏设置分为四面都有墙,并且不可以穿越,蛇头碰到墙或障碍物时,游戏结束。以游戏过程吃到的水果,得分。 贪食蛇游戏,在各种设备上都有实现,已经有很多种版本。
2. 场景设计
要开发这款游戏,我们应该如何动手呢?首先,我们需要从软件开发的角度,对这款游戏进行需求分析,列出游戏的规则,并设计业务流程,给出游戏的原型,验证是否可行。
2.1 需求分析
贪食蛇游戏,应该有3个场景:开机场景,游戏场景,结束场景。
开机场景:运行程序,在游戏前,给用户做准备,并提示如何操作游戏。
游戏场景:游戏运行中的场景。
结束场景:当用户胜利、失败或退出时的场景,并提示用户在游戏中的得分。
开机场景和结束场景比较简单,不再解释。游戏场景,包括一块画布,一条蛇,一个蛇头和一个不定长的蛇尾,一个水果,边界和障碍物。
2.2 游戏规则
游戏进行时的规则:
1. 开始游戏后,用户可以通过上(up)下(down)左(left)右(right)键,来操作蛇头,控制蛇的前进方向,还可以按q键直接游戏失败,其他的键盘操作无效。
2. 蛇头用蓝色标识,蛇尾用灰色标识,水果用红色标识,障碍物用黑色标识。
3. 当蛇头移动到水果的位置后,表示蛇吃到了水果,蛇尾的长度加1。水果会在下一次蛇头移动后,在空路径上自动生成。
4. 游戏画布的外围是枪,当蛇头移动到画布看不到的位置,则表示蛇头撞到枪,游戏失败。
5. 游戏画面中,有一些黑色障碍物,当蛇头碰到障碍,游戏失败。
6. 当蛇头碰到蛇尾时,游戏失败。
2.3 业务流程
场景切换的流程:
打开程序时,用户首先看到开机场景,按任意键后进入游戏场景。
在游戏场景,当游戏失败,进入结束场景;按q键,则直接游戏失败。
在结束场景,按空格回到开机场景;按q键,则直接能出软件。
2.4 游戏原型
我们画出3个场景的界面。左边为开机场景,中间是游戏场景,右边是结束场景。
我们根据游戏原型的图,用程序画出游戏的场景。
3. 程序设计
通过上面的功能需求分析,我们已经非常清楚地了解贪食游戏的各种规则和功能。接下来,我们要把需求分析中的业务语言,通过技术语言重新描述,并考虑非功能需求,以及R语言相关的技术细节。
3.1 游戏场景
我们让每个场景对应于一块画布,及每个场景对应一个内存结构。
开机场景,是静态的,我们可以提前生成好这块画布存储起来,也可以当用户切换时再临时生成,性能开销不大。
游戏场景,是动态的,每进行一次用户的交互行为或按时间刷新时,都需要求重新绘制画布,让游戏场景通过绑定事件来生成画布。由于用户会频繁操作,因此性能开销比较大。
结束场景,是动态的,在结束场景会显示当次游戏的得分,需要在切换时临时生成。
3.2 游戏对象
在游戏进行中,会产生很多的对象,如上文中提到的。这些对象都需要在内存中进行定义,匹配到对应程序语言的数据类型。
画布对象:
画布:用矩阵来描述,画布中每个小方块对应到矩阵中一个数据。
画布大小:画布的长和宽,分别用对应两个数字变量。
画布坐标:用于画布内小格子的定位,从左到右横坐标是1到20,从底到顶纵坐标为1到20。
画布索引:用于画布内小格式的定位,按从左到右,从底到顶的顺序,为1到400。
方格:在画布里最小的单位是方格,按照画面的比例,设置方格的大小。
蛇对象:
蛇头:用一个向量来描述,只有一个方格。游戏开始时,起点位置为坐标(2,2),默认蛇头向上移动,用户打开界面显示位置为(2,3)。
蛇尾:用数据框来描述,存储不定长度的坐标向量。游戏开始时,蛇尾长度是0。
水果对象:
水果:用一个向量来描述,只有一个方格。游戏开始时,随机在空格式上,选一个坐标为水果位置。
边界和障碍物:
边界:无内存描述,通过计算判断。当蛇头坐标超过矩阵坐标时,触发边界。
障碍物:用数据框来描述,存储不定长度的坐标向量。
3.3 游戏事件
游戏过程中,会有3种事件,键盘事件、时间事件和碰撞事件。
键盘事件:全局事件,用户通过键盘输入,而触发的事件,比如,上下左右控制蛇的移动方向。
时间事件:全局事件,系统计时以每0.2秒触发一个时间事件,比如,蛇头每0.2秒的移动一格。
碰撞事件:当蛇头移动时,与非空和格式碰撞除法的事情,比如,吃到水果,蛇头撞到蛇尾。
通常情况,上面3种事件分别有3个线程来控制。但由于R语言本身是单线程的设计,而且不支持异步调用,因此我们无法同时实现上面的3个事件监听。取一种折中方案为,全局监听键盘事件,通过键盘事件触发碰撞事件的进行检查。
3.4 游戏控制
在游戏进行中,每个状态我们都需要进行控制的。比如,什么时候生成新的水果,什么时候增加一节尾巴,什么游戏结束等。通过定义控制函数,可以方便我们管理游戏运行中的各种游戏状态。
上图中每个方块代表一个R语言函数定义:
run():启动程序。
keydown():监听键盘事件,锁定线程。
stage0():创建开机场景,可视化输出。
stage1():创建游戏场景,可视化输出。
stage2():创建结束场景,可视化输出。
init():打开游戏场景时,初始化游戏变量。
furit():判断并生成水果坐标。
head():生成蛇头移动坐标。
fail():失败查询,判断蛇头是否撞墙或蛇尾,如果失败则跳过画图,进入结束场景。
body():生成蛇尾移动坐标。
drawTable():绘制游戏背景。
drawMatrix():绘制游戏矩阵。
通过程序设计过程,我们就把需求分析中的业务语言描述,变成了程序开发中的技术语言描述。经过完整的设计后,最后就剩下写代码了。
4. R语言实现
用R语言写代码,其实没有几行就可以搞定,按照上面的函数定义,我们把代码像填空一样地写进去就行了。当然,在写代码的过程中,我们需要掌握一些R语言特性,让代码更健壮。
run()函数,启动程序。
run<-function(){
# 设置全局画布无边
par(mai=rep(0,4),oma=rep(0,4))
# 定义全局环境空间,用于封装变量
e<<-new.env()
# 启动开机场景
stage0()
# 注册键盘事件
getGraphicsEvent(prompt="Snake Game",onKeybd=keydown)
}
上面代码中,通过定义环境空间e来存储变量,可以有效的解决变量名冲突,和变量污染的问题。
keydown函数,监听键盘事件。
keydown<-function(K){
print(paste("keydown:",K,",stage:",e$stage));
if(e$stage==0){ #开机画面
init()
stage1()
return(NULL)
}
if(e$stage==2){ #结束画面
if(K=="q") q()
else if(K==' ') stage0()
return(NULL)
}
if(e$stage==1){ #游戏中
if(K == "q") {
stage2()
} else {
if(tolower(K) %in% c("up","down","left","right")){
e$lastd<-e$dir
e$dir<-tolower(K)
stage1()
}
}
}
return(NULL)
}
代码中,参数K为键盘输入。通过对当前所在场景,与键盘输入的条件判断,来确定键盘事件的响应。在游戏中,键盘只响应5个键 "up","down","left","right","q"。
stage0():创建开机场景,可视化输出。
# 开机画图
stage0<-function(){
e$stage<-0
plot(0,0,xlim=c(0,1),ylim=c(0,1),type='n',xaxs="i", yaxs="i")
text(0.5,0.7,label="Snake Game",cex=5)
text(0.5,0.4,label="Any keyboard to start",cex=2,col=4)
text(0.5,0.3,label="Up,Down,Left,Rigth to control direction",cex=2,col=2)
text(0.2,0.05,label="Author:DanZhang",cex=1)
text(0.5,0.05,label="http://blog.fens.me",cex=1)
}
stage2():创建结束场景,可视化输出。
# 结束画图
stage2<-function(){
e$stage<-2
plot(0,0,xlim=c(0,1),ylim=c(0,1),type='n',xaxs="i", yaxs="i")
text(0.5,0.7,label="Game Over",cex=5)
text(0.5,0.4,label="Space to restart, q to quit.",cex=2,col=4)
text(0.5,0.3,label=paste("Congratulations! You have eat",nrow(e$tail),"fruits!"),cex=2,col=2)
text(0.2,0.05,label="Author:DanZhang",cex=1)
text(0.5,0.05,label="http://blog.fens.me",cex=1)
}
init():打开游戏场景时,初始化游戏变量。
# 初始化环境变量
init<-function(){
e<<-new.env()
e$stage<-0 #场景
e$width<-e$height<-20 #切分格子
e$step<-1/e$width #步长
e$m<-matrix(rep(0,e$width*e$height),nrow=e$width) #点矩阵
e$dir<-e$lastd<-'up' # 移动方向
e$head<-c(2,2) #初始蛇头
e$lastx<-e$lasty<-2 # 初始化蛇头上一个点
e$tail<-data.frame(x=c(),y=c())#初始蛇尾
e$col_furit<-2 #水果颜色
e$col_head<-4 #蛇头颜色
e$col_tail<-8 #蛇尾颜色
e$col_path<-0 #路颜色
}
代码中,初始化全局的环境空间e,然后将所有需要的变量,定义在e中。
furit():判断并生成水果坐标。
# 随机的水果点
furit<-function(){
if(length(index(e$col_furit))<=0){ #不存在水果
idx<-sample(index(e$col_path),1)
fx<-ifelse(idx%%e$width==0,10,idx%%e$width)
fy<-ceiling(idx/e$height)
e$m[fx,fy]<-e$col_furit
print(paste("furit idx",idx))
print(paste("furit axis:",fx,fy))
}
}
fail():失败查询,判断蛇头是否撞墙或蛇尾,如果失败则跳过画图,进入结束场景。
# 检查失败
fail<-function(){
# head出边界
if(length(which(e$head<1))>0 | length(which(e$head>e$width))>0){
print("game over: Out of ledge.")
keydown('q')
return(TRUE)
}
# head碰到tail
if(e$m[e$head[1],e$head[2]]==e$col_tail){
print("game over: head hit tail")
keydown('q')
return(TRUE)
}
return(FALSE)
}
head():生成蛇头移动坐标。
# snake head
head<-function(){
e$lastx<-e$head[1]
e$lasty<-e$head[2]
# 方向操作
if(e$dir=='up') e$head[2]<-e$head[2]+1
if(e$dir=='down') e$head[2]<-e$head[2]-1
if(e$dir=='left') e$head[1]<-e$head[1]-1
if(e$dir=='right') e$head[1]<-e$head[1]+1
}
body():生成蛇尾移动坐标。
# snake body
body<-function(){
e$m[e$lastx,e$lasty]<-0
e$m[e$head[1],e$head[2]]<-e$col_head #snake
if(length(index(e$col_furit))<=0){ #不存在水果
e$tail<-rbind(e$tail,data.frame(x=e$lastx,y=e$lasty))
}
if(nrow(e$tail)>0) { #如果有尾巴
e$tail<-rbind(e$tail,data.frame(x=e$lastx,y=e$lasty))
e$m[e$tail[1,]$x,e$tail[1,]$y]<-e$col_path
e$tail<-e$tail[-1,]
e$m[e$lastx,e$lasty]<-e$col_tail
}
print(paste("snake idx",index(e$col_head)))
print(paste("snake axis:",e$head[1],e$head[2]))
}
drawTable():绘制游戏背景。
# 画布背景
drawTable<-function(){
plot(0,0,xlim=c(0,1),ylim=c(0,1),type='n',xaxs="i", yaxs="i")
# 显示背景表格
abline(h=seq(0,1,e$step),col="gray60") # 水平线
abline(v=seq(0,1,e$step),col="gray60") # 垂直线
# 显示矩阵
df<-data.frame(x=rep(seq(0,0.95,e$step),e$width),y=rep(seq(0,0.95,e$step),each=e$height),lab=seq(1,e$width*e$height))
text(df$x+e$step/2,df$y+e$step/2,label=df$lab)
}
drawMatrix():绘制游戏矩阵。
# 根据矩阵画数据
drawMatrix<-function(){
idx<-which(e$m>0)
px<- (ifelse(idx%%e$width==0,e$width,idx%%e$width)-1)/e$width+e$step/2
py<- (ceiling(idx/e$height)-1)/e$height+e$step/2
pxy<-data.frame(x=px,y=py,col=e$m[idx])
points(pxy$x,pxy$y,col=pxy$col,pch=15,cex=4.4)
}
stage1():创建游戏场景,stage1()函数内部,封装了游戏场景运行时的函数,并进行调用。
# 游戏中
stage1<-function(){
e$stage<-1
furit<-function(){...} //见furit
fail<-function(){...} //见fail
head<-function(){...} //见head
body<-function(){...}//见body
drawTable<-function(){...} //见drawTable
drawMatrix<-function(){...} //见drawMatrix
# 运行函数
furit()
head()
if(!fail()){ #失败检查
body()
drawTable()
drawMatrix()
}
}
注:此处代码为伪代码。
最后,是完整的程序代码。
# 初始化环境变量
init<-function(){
e<<-new.env()
e$stage<-0 #场景
e$width<-e$height<-20 #切分格子
e$step<-1/e$width #步长
e$m<-matrix(rep(0,e$width*e$height),nrow=e$width) #点矩阵
e$dir<-e$lastd<-'up' # 移动方向
e$head<-c(2,2) #初始蛇头
e$lastx<-e$lasty<-2 # 初始化蛇头上一个点
e$tail<-data.frame(x=c(),y=c())#初始蛇尾
e$col_furit<-2 #水果颜色
e$col_head<-4 #蛇头颜色
e$col_tail<-8 #蛇尾颜色
e$col_path<-0 #路颜色
}
# 获得矩阵的索引值
index<-function(col) which(e$m==col)
# 游戏中
stage1<-function(){
e$stage<-1
# 随机的水果点
furit<-function(){
if(length(index(e$col_furit))<=0){ #不存在水果
idx<-sample(index(e$col_path),1)
fx<-ifelse(idx%%e$width==0,10,idx%%e$width)
fy<-ceiling(idx/e$height)
e$m[fx,fy]<-e$col_furit
print(paste("furit idx",idx))
print(paste("furit axis:",fx,fy))
}
}
# 检查失败
fail<-function(){
# head出边界
if(length(which(e$head<1))>0 | length(which(e$head>e$width))>0){
print("game over: Out of ledge.")
keydown('q')
return(TRUE)
}
# head碰到tail
if(e$m[e$head[1],e$head[2]]==e$col_tail){
print("game over: head hit tail")
keydown('q')
return(TRUE)
}
return(FALSE)
}
# snake head
head<-function(){
e$lastx<-e$head[1]
e$lasty<-e$head[2]
# 方向操作
if(e$dir=='up') e$head[2]<-e$head[2]+1
if(e$dir=='down') e$head[2]<-e$head[2]-1
if(e$dir=='left') e$head[1]<-e$head[1]-1
if(e$dir=='right') e$head[1]<-e$head[1]+1
}
# snake body
body<-function(){
e$m[e$lastx,e$lasty]<-0
e$m[e$head[1],e$head[2]]<-e$col_head #snake
if(length(index(e$col_furit))<=0){ #不存在水果
e$tail<-rbind(e$tail,data.frame(x=e$lastx,y=e$lasty))
}
if(nrow(e$tail)>0) { #如果有尾巴
e$tail<-rbind(e$tail,data.frame(x=e$lastx,y=e$lasty))
e$m[e$tail[1,]$x,e$tail[1,]$y]<-e$col_path
e$tail<-e$tail[-1,]
e$m[e$lastx,e$lasty]<-e$col_tail
}
print(paste("snake idx",index(e$col_head)))
print(paste("snake axis:",e$head[1],e$head[2]))
}
# 画布背景
drawTable<-function(){
plot(0,0,xlim=c(0,1),ylim=c(0,1),type='n',xaxs="i", yaxs="i")
}
# 根据矩阵画数据
drawMatrix<-function(){
idx<-which(e$m>0)
px<- (ifelse(idx%%e$width==0,e$width,idx%%e$width)-1)/e$width+e$step/2
py<- (ceiling(idx/e$height)-1)/e$height+e$step/2
pxy<-data.frame(x=px,y=py,col=e$m[idx])
points(pxy$x,pxy$y,col=pxy$col,pch=15,cex=4.4)
}
furit()
head()
if(!fail()){
body()
drawTable()
drawMatrix()
}
}
# 开机画图
stage0<-function(){
e$stage<-0
plot(0,0,xlim=c(0,1),ylim=c(0,1),type='n',xaxs="i", yaxs="i")
text(0.5,0.7,label="Snake Game",cex=5)
text(0.5,0.4,label="Any keyboard to start",cex=2,col=4)
text(0.5,0.3,label="Up,Down,Left,Rigth to control direction",cex=2,col=2)
text(0.2,0.05,label="Author:DanZhang",cex=1)
text(0.5,0.05,label="http://blog.fens.me",cex=1)
}
# 结束画图
stage2<-function(){
e$stage<-2
plot(0,0,xlim=c(0,1),ylim=c(0,1),type='n',xaxs="i", yaxs="i")
text(0.5,0.7,label="Game Over",cex=5)
text(0.5,0.4,label="Space to restart, q to quit.",cex=2,col=4)
text(0.5,0.3,label=paste("Congratulations! You have eat",nrow(e$tail),"fruits!"),cex=2,col=2)
text(0.2,0.05,label="Author:DanZhang",cex=1)
text(0.5,0.05,label="http://blog.fens.me",cex=1)
}
# 键盘事件
keydown<-function(K){
print(paste("keydown:",K,",stage:",e$stage));
if(e$stage==0){ #开机画面
init()
stage1()
return(NULL)
}
if(e$stage==2){ #结束画面
if(K=="q") q()
else if(K==' ') stage0()
return(NULL)
}
if(e$stage==1){ #游戏中
if(K == "q") {
stage2()
} else {
if(tolower(K) %in% c("up","down","left","right")){
e$lastd<-e$dir
e$dir<-tolower(K)
stage1()
}
}
}
return(NULL)
}
#######################################
# RUN
#######################################
run<-function(){
par(mai=rep(0,4),oma=rep(0,4))
e<<-new.env()
stage0()
# 注册事件
getGraphicsEvent(prompt="Snake Game",onKeybd=keydown)
}
run()
游戏截图:
全部代码仅仅190行,有效代码行只有100行左右,我们就实现了贪食蛇游戏。当然,时间事件我们没有实现,只因为R语言本身的单线程机制,而且不支持异步调用。正因为R语言强大的数据处理能力和可视化能力,让我们的程序写起来非常简单。我想如果让R来实现策略类游戏的矩阵部分的计算,一定会非常顺手的。
作者介绍:
张丹,R语言中文社区专栏特邀作者,《R的极客理想》系列图书作者,民生银行大数据中心数据分析师,前况客创始人兼CTO。
活动推荐:(阅读原文即可报名)
主题:天工开物,人机同行 IBM 2017数据科学家论坛
时间:4月11日 14:30 - 18:30
地点:北京市海淀区紫竹院路29号北京香格里拉饭店2层