flappy bird是13年红极一时的小游戏,其简单有趣的玩法和变态的难度形成了强烈反差,引发全球玩家竞相把玩,欲罢不能!遂选择复刻这个小游戏,在实现的过程中向大家演示compo工具包的ui组合、数据驱动等重要思想。
不记得这个游戏或完全没玩过的朋友,可以点击下面的链接,体验一下flappy bird的玩法。
https://flappybird.io/
为拆解游戏,笔者也录了一段游戏过程。
反复观看这段gif,可以发现游戏的一些规律:
远处的建筑和近处的土壤是静止不动的小鸟一直在上下移动,伴随着翅膀和身体的飞翔姿态管道和路面则不断地向左移动,营造出小鸟向前飞翔的视觉效果通过截图、切图、填充像素和简单的ps,可以拿到各元素的图片。
各方卡司已就位,接下来开始布置整个画面。暂不实现元素的移动效果,先把静态的整体效果搭建好。
静止不动的建筑远景最为简单,封装到可组合函数farbackground
里,内部放置一张图片即可。
@composable funfarbackground(modifier:modifier){ column{ image( painter=painterresource(id=r.drawable.background), contentscale=contentscale.fillbounds, contentdescription=null, modifier=modifier.fillmaxsize() ) } }
远景的下面由分割线、路面和土壤组成,封装到nearforeground
函数里。通过modifier
的fraction
参数控制路面和土壤的比例,保证在不同尺寸屏幕上能按比例呈现游戏界面。
@composable funnearforeground(...){ column(modifier){ //分割线 divider( color=grounddividerpurple, thickness=5.dp ) //路面 box(modifier=modifier.fillmaxwidth()){ image( painter=painterresource(id=r.drawable.foreground_road), ... modifier=modifier .fillmaxwidth() .fillmaxheight(0.23f) ) } } //土壤 image( painter=painterresource(id=r.drawable.foreground_earth), ... modifier=modifier .fillmaxwidth() .fillmaxheight(0.77f) ) } }
将整个游戏画面抽象成gamescreen
函数,通过column
竖着排列远景和前景。考虑到移动的小鸟和管道需要呈现在远景之上,所以在远景的外面包上一层box
组件。
@composable fungamescreen(...){ column(...){ box(modifier=modifier .align(alignment.centerhorizontally) .fillmaxwidth() ){ farbackground(modifier.fillmaxsize()) } box(modifier=modifier .align(alignment.centerhorizontally) .fillmaxwidth() ){ nearforeground( modifier=modifier.fillmaxsize() ) } } }
仔细观察管道,会发现一些管道具备朝上朝下、高度随机的特点。为此将管道的视图分拆成盖子和柱子两部分:
盖子和柱子的放置顺序决定管道的朝向柱子的高度则控制着管道整体的高度 这样的话,只使用盖子和柱子两张图片,就可以灵活实现各种形态的管道。先来组合盖子pipecover
和柱子pipepillar
的可组合函数。
@composable funpipecover(){ image( painter=painterresource(id=r.drawable.pipe_cover), contentscale=contentscale.fillbounds, contentdescription=null, modifier=modifier.size(pipecoverwidth,pipecoverheight) ) } @composable funpipepillar(modifier:modifier=modifier,height:dp=90.dp){ image( painter=painterresource(id=r.drawable.pipe_pillar), contentscale=content个三scale.fillbounds, contentdescription=null, modifier=modifier.size(50.dp,height) ) }
管道的可组合函数pipe
可以根据照朝向和高度的参数,组合成对应的管道。
@composable funpipe( height:dp=highpipe, up:boolean=true ){ box(...){ column{ if(up){ pipepillar(modifier.align(centerhorizontally),height-30.dp) pipecover() }el{ pipecover() pipepillar(modifier.align(centerhorizontally),height-30.dp) } } } }
另外,管道都是成对出现、且无论高度如何中间的间距是固定的。所以我们再实现一个管道组的可组合函数pipecouple
。
@composable funpipecouple(...){ box(...){ getuppipe(height=upheight, modifier=modifier .align(alignment.topend) ) getdownpipe(height=downheight, modifier=modifier .align(alignment.bottomend) ) } }
将固若金汤打一地名pipecouple添加到farbackground的下面,管道就放置完毕了。
@composable fungamescreen(...){ column(...){ box(...){ farbackground(modifier.fillmaxsize()) //管道对添加远景上去 pipecouple( modifier=modifier.fillmaxsize() ) } ... } }
小鸟通过image组件即可实现,默认情况下放置到布局的center方位。
@composable funbird(...){ box(...){ image( painter=painterresource(id=r.drawable.bird_match), contentscale=contentscale.fillbounds, contentdescription=null, modifier=modifier .size(birdsizewidth,birdsizeheight) .align(alignment.center) ) } }
视觉上小鸟呈现在管道的前面,所以bird
可组合函数要添加到管道组函数的后面。
@composable fungamescreen(...){ column(...){ box(...){ ... pipecouple(...) //将小鸟添加到远景上去 bird( modifier=modifier.fillmaxsize(), state=viewstate ) } } }
至此,各元素都放置完了。接下来着手让小鸟,管道和路面这些动态元素动起来。
compo中modifier#offt()函数可以更改视图在横纵方向上的偏移值,通过不断地调整这个偏移值,即可营造出动态的视觉效果。无论是小鸟还是管道和路面,它们的移动状态都可以依赖这个思路。
那如何管理这些持续变化的偏移值数据?如何将数据反映到画面上?
compo通过state驱动可组合函数进行重组,进而达到画面的重绘。所以我们将这些数据封到viewstate
中,交由viewmodel
框架计算和更新,compo订阅state之后驱动所有元素活动起来。除了个元素的偏移值数据,state中还要存放游戏分值,游戏状态等额外信息。
dataclassviewstate( valgamestatus:gamestatus=gamestatus.waiting, //小鸟状态 valbirdstate:birdstate=birdstate(), //管道组状态 valpipestatelist:list<pipestate>=pipestatelist, vartargetpipeindex:int=-1, //路面状态 valroadstatelist:list<roadstate>=roadstatelist, vartargetroadindex:int=-1, //分值数据 valscore:int=0, valbestscore:int=0, ) enumclassgamestatus{ waiting, running, dying, over }
用户点击屏幕会触发游戏开始、重新开始、小鸟上升等动作,这些视图上的事件需要反向传递给viewmodel处理和做出响应。事件由clickable
数据类封装,再转为对应的gameaction
发送到viewmodel中。
dataclassclickable( valonstart:()->unit={}, valontap:()->unit={}, valonrestart:()->unit={}, valonexit:()->unit={} ) aledclassgameaction{ objectstart:gameaction() objectautotick:gameaction() objecttouchlift:gameaction() objectrestart:gameaction() }
前面说过,可以不断调整下offt数据使得视图动起来。具体实现可以通过launchedeffect
启动一个定时任务,定期发送一个更新视图的动作autotick
。注意:compo里获取viewmodel实例发生nosuchmethoderror
错误的话,记得按照官方构建的版本重新sync一下。
tcontent{ flappybirdtheme{ surface(color=materialtheme.colors.background){ valgameviewmodel:gameviewmodel=viewmodel() launchedeffect(key1=unit){ while(isactive){ delay(autotickduration) gameviewmodel.dispatch(gameaction.autotick) } } flappy(clickable( onstart={ gameviewmodel.dispatch(gameaction.start) }... )) } }
viewmodel收到action后开启协程,计算视图的位置、更新对应state,之后发射出去。
classgameviewmodel:viewmodel(){ fundispatch(...){ respon(action,viewstate.value) } privatefunrespon(action:gameaction,state:viewstate){ viewmodelscope.launch{ withcontext(dispatchers.default){ emit(when(action){ gameaction.autotick->run{ //路面,管道组以及小鸟移动的新state获取 ... state.copy( gamestatus=gamestatus.running, birdstate=newbirdstate, pipestatelist=newpipestatelist, roadstatelist=newroadstatelist ) } ... }) } } } }
如果画面上只放一张路面图片,更改x轴offt的话,剩余的部分会没有路面,无法呈现出不断移动的效果。
思前想后,发现放置两张路面图片可以解决:一张放在屏幕外侧,一张放在屏幕内侧。游戏的过程中同时同方向移动两张图片,当前一张图片移出屏幕后重置其位置,进而营造出道路不断移动的效果。
@composable funnearforeground(...){ valviewmodel:gameviewmodel=viewmodel() column(...){ ... //路面 box(modifier=modifier.fillmaxwidth()){ state.roadstatelist.foreach{roadstate-> image( ... modifier=modifier ... //不断调整路面在x轴的偏移值 .offt(x=roadstate.offt) ) } } ... if(state.playzonesize.first>0){ state.roadstatelist.foreachindexed{index,roadstate-> //任意路面的偏移值达到两张图片位置差的时候 //重置路面位置,重新回到屏幕外 if(roadstate.offt<=-temproadwidthofft){ viewmodel.dispatch(gameaction.roadexit,roadindex=index) } } } } }
viewmodel收到roadexit
的action之后通知路面state进行位置的重置。
classgameviewmodel:viewmodel(){ privatefunrespon(action:gameaction,state:viewstate){ viewmodelscope.launch{ withcontext(dispatchers.default){ emit(when(action){ gameaction.roadexit->run{ valnewroadstate:list<roadstate>= if(state.targetroadindex==0){ listof(state.roadstatelist[0].ret(),state.roadstatelist[1]) }el{ listof(state.roadstatelist[0],state.roadstatelist[1].ret()) } state.copy( gamestatus=gamestatus.running, roadstatelist=newroadstate ) } }) } } } } dataclassroadstate(varofft:dp=roadwidthofft){ //移动路面 funmove():roadstate=copy(offt=offt-roadmovevelocity) //重置路面 funret():roadstate=copy(offt=temproadwidthofft) }
设备屏幕宽度有限,同一时间最多呈现两组管道就可以了。和路面运动的思路类似,只需要放置两组管道,就可以实现管道不停移动的视觉效果。
具体的话,两组管道相隔一段距离放置,游戏中两组管道一起同时向左移动。当前一组管道运动到屏幕外的时候,将其位置重置。
那如何计算管道移动到屏幕外的时机?
画面重组的时候判断管道偏移值是否达到屏幕宽度,yes的话向viewmodel发送管道重置的action。
@composable funpipecouple( modifier:modifier=modifier, state:viewstate=viewstate(), pipeindex:int=0 ){ valviewmodel:gameviewmodel=viewmodel() valpipestate=state.pipestatelist[pipeindex] box(...){ //从state中获取管道的偏移值,在重组的时候让管道移动 getuppipe(height=pipestate.upheight, modifier=modifier .align(alignment.topend) .offt(x=pipestate.offt) ) getdownpipe(...) if(state.playzonesize.first>0){ ... //移动到屏幕外的时候发送重置action if(pipestate.offt<-playzonewidthindp){ viewmodel.dispatch(gameaction.pipeexit,pipeindex=pipeindex) } } } }
viewmodel收到pipeexit
的action后发起重置管道数据,并将更新发射出去。
classgameviewmodel:viewmodel(){ privatefunrespon(action:gameaction,state:viewstate){ viewmodelscope.launch{ withcontext(dispatchers.default){ emit(when(action){ gameaction.pipeexit->run{ valnewpipestatelist:list<pipestate>= if(state.targetpipeindex==0){ listof( state.pipestatelist[0].ret(), state.pipestatelist[1] ) }el{ listof( state.pipestatelist[0], state.pipestatelist[1].ret() ) } state.copy( pipestatelist=newpipestatelist ) } }) } } } }
但相比路面,管道还具备高度随机、间距固定的特性。所以重置位置的同时记得将柱子的高度随机赋值,并给另一根柱子赋值剩余的高度。
dataclasspipestate( varofft:dp=firstpipewidthofft, varupheight:dp=valueutil.getra大学军训多久ndomdp(lowpipe,highpipe), vardownheight:dp=totalpipeheight-upheight-pipedistance ){ //移动管道 funmove():pipestate= copy(offt=offt-pipemovevelocity) //重置管道 funret():pipestate{ //随机赋值上面管道的高度 valnewupheight=valueutil.getrandomdp(lowpipe,highpipe) returncopy( offt=firstpipewidthofft, upheight=newupheight, //下面管道的高度由差值赋值 downheight=totalpipeheight-newupheight-pipedistance ) } }
需要留意一点的是,如果希望管道组出现的节奏固定,那么管道组之间的横向间距(不是上下管道的间距)始终需要保持一致。为此两组管道初始的offt数据要遵循一些规则,此处省略计算的过程,大概规则如下。
valfirstpipewidthofft=pipecoverwidth*2 //第二组管道的offt等于 //屏幕宽度加上三倍第一组管道offt的一半 valcondpipewidthofft=(totalpipewidth+firstpipewidthofft*3)/2 valpipestatelist=listof( pipestate(), pipestate(offt=(condpipewidthofft)) )
不断调整小鸟图片在y轴上的偏移值可以实现小鸟的上下移动。但相较于路面和管道,小鸟的需要些特有的处理:
监听用户的点击事件,向上调整偏移值实现上升效果在上升和下降的过程中,调整小鸟的rotate
角度,以演示运动的姿态在触碰到路面的时刻,发送hitground
的action停止游戏@composable fungamescreen(...){ ... column( modifier=modifier .fillmaxsize() .background(foregroundearthyellow) .run{ pointerinteropfilter{ when(it.action){ //监听点击事件,触发游戏开始或小鸟上升 action_down->{ if(viewstate.gamestatus==gamestatus.waiting) clickable.onstart() elif(viewstate.gamestatus==gamestatus.running) clickable.ontap() } ... } fal } } ){...} }
小鸟根据state的offt数据开始移动和调整姿态,同时在触地的时候告知viewmodel。因为下降的偏移值误差可能导致触地的那刻小鸟位置发生偏差,所以在小鸟下落到路面的临界点后需要手动调整下offt值。
@composable funbird(...){ ... //根据小鸟上升或下降的状态调整小鸟的roate角度 valrotatedegree= if(state.islifting)liftingdegree elif(state.isfalling)fallingdegree elpendingdegree box(...){ varcorrectbirdheight=state.birdstate.birdheight if(state.playzonesize.cond>0){ ... valfallingthreshold=birdhitgroundthreshold //小鸟偏移值达到背景边界时发送落地action if(correctbirdheight+fallingthreshold>=playzoneheightindp/2){ viewmodel.dispatch(gameaction.hitground) //修改下offt值避免下落到临界位置的误差 correctbirdheight=playzoneheightindp/2-fallingthreshold } } image( ... modifier=modifier .size(birdsizewidth,birdsizeheight) .align(alignment.center) .offt(y=correctbirdheight) //将旋转角度应用到小鸟,展示飞翔姿态 .rotate(rotatedegree) ) } }
动态的元素都实现好了,下一步开始安排碰撞算法,并将实时分值同步展示到游戏上方。
仔细思考,发现当管道组移动到小鸟飞翔区域的时候,计算小鸟是否处在管道区域即可判断是否产生了碰撞。而当管道移动出小鸟飞翔范围的时候,即可判定小鸟成功穿过了管道,开始计分。
如下图所示当管道移动到小鸟飞翔区域的时候,红色部分为危险地带,绿色部分才是安全区域。
@composable fungamescreen(...){ ... column(...){ box(...){ ... //添加实时展示分值的text组件 scoreboard( modifier=modifier.fillmaxsize(), state=viewstate, clickable=clickable ) //遍历两个管道组,检查小鸟的穿过状态 if(viewstate.gamestatus==gamestatus.running){ viewstate.pipestatelist.foreachindexed{pipeindex,pipestate-> checkpipestatus( viewstate.birdstate.birdheight, pipestate, playzonewidthindp, playzoneheightindp ).also{ when(it){ //碰撞到管道的话通知viewmodel,安排坠落 pipestatus.birdhit->{ viewmodel.dispatch(gameaction.hitpipe) } //成功通过的话通知viewmodel计分 pipestatus.birdcrosd->{ viewmodel.dispatch(gameaction.crosdpipe,pipeindex=pipeindex) } } } } } } } } @composable funcheckpipestatus(...):pipestatus{ //管道尚未移动到小鸟运动区域 if(pipestate.offt-pipecoverwidth>-zonewidth/2+birdsizewidth/2){ returnpipestatus.birdcoming }elif(pipestate.offt-pipecoverwidth<-zonewidth/2-birdsizewidth/2){ //小鸟成功穿过管道 returnpipestatus.birdcrosd }el{ valbirdtop=(zoneheight-birdsizeheight)/2+birdheightofft valbirdbottom=(zoneheight+birdsizeheight)/2+birdheightofft //管道移动到小鸟运动区域并和小鸟重合 if(birdtop<pipestate.upheight||birdbottom>zoneheight-pipestate.downheight){ returnpipestatus.birdhit } returnpipestatus.birdcrossing } }
viewmodel收到碰撞hitpipe
和穿过管道crosdpipe
的action后进行坠落或计分的处理。
classgameviewmodel:viewmodel(){ privatefunrespon(action:gameaction,state:viewstate){ viewmodelscope.launch{ withcontext(dispatchers.default){ emit(when(action){ gameaction.hitpipe->run{ //撞击到管道后快速坠落 valnewbirdstate=state.birdstate.quickfall() state.copy( //并将游戏status更新为dying gamestatus=gamestatus.dying, birdstate=newbirdstate ) } gameaction.crosdpipe->run{ valtargetpipestate=state.pipestatelist[state.targetpipeindex] //计算过分值的话跳过,避免重复计分 if(targetpipestate.counted){ return@runstate.copy() } //标记该管道组已经统计过分值 valcountedpipestate=targetpipestate.co巴东三峡教案unt() valnewpipestatelist=if(state.targetpipeindex==0){ listof(countedpipestate,state.pipestatelist[1]) }el{ listof(state.pipestatelist[0],countedpipestate) } state.copy( pipestatelist=newpipestatelist, //当前分值累加 score=state.score+1, //最高分取最高分和当前分值的较大值即可 bestscore=(state.score+1).coerceatleast(state.bestscore) ) } }) } } } }
当小鸟碰撞到了管道,立刻将下落的速度提高,并将rotate角度加大,营造出快速坠落的效果。
@composable funbird(...){ ... valrotatedegree= if(state.islifting)liftingdegree elif(state.isfalling)fallingdegree elif(state.isquickfalling)dyingdegree elif(state.isover)deaddegree elpendingdegree }
结束和实时两种分值功能有交叉,统一封装到scoreboard
可组合函数中,根据游戏状态自由切换。
游戏结束时展示的信息较为丰富,包含本次分值、最高分值,以及重新开始和退出两个按钮。为了方便视图的preview
和提高重组性能,我们将其拆分为单个分值、按钮、分值仪表盘和结束分值四个部分。
compo的preview功能很好用,但要留意一点:其composable函数里不要放入viewmodel逻辑,否则会渲染失败。我们可以拆分ui和viewmodel逻辑,在保证preview能顺利进行的同时能复用视图部分的代码。
@composable funscoreboard(...){ when(state.gamestatus){ //开始的状态下展示简单的实时分值 gamestatus.running->realtimeboard(modifier,state.score) //结束的话展示丰富的仪表盘 gamestatus.over->gameoverboard(modifier,state.score,state.bestscore,clickable) } } //包含丰富分值和按钮的box组件 @composable fungameoverboard(...){ box(...){ column(...){ gameoverscoreboard( modifier.align(centerhorizontally), score, maxscore ) spacer(...) gameoverbutton(modifier=modifier.wrapcontentsize().align(centerhorizontally),clickable) } } }
丰富分值和按钮的可组合函数的分别实现。
//展示丰富分值,包括背景边框、当前分值和最高分值 @composable fungameoverscoreboard(...){ box(...){ //scoreboardbackground image( painter=painterresource(id=r.drawable.score_board_bg), ... ) column(...){ labelscorefield(modifier,r.drawable.score_bg,score) spacer( modifier=modifier .wrapcontentwidth() .height(3.dp) ) labelscorefield(modifier,r.drawable.best_score_bg,maxscore) } } } //重新开始和退出按钮 @composable fungameoverbutton(...){ row(...){ //重新开始按钮 image( painter=painterresource(id=r.drawable.restart_button), ... modifier=modifier ... .clickable(true){ clickable.onrestart() } ) spacer(...) //退出按钮 image( painter=painterresource(id=r.drawable.exit_button), ... modifier=modifier ... .clickable(true){ clickable.onexit() } ) } }
再监听重新开始和退出按钮的事件,发送restart
和exit
的action。exit的响应比较简单,直接关闭activity即可。
tcontent{ flappybirdtheme{ surface(color=materialtheme.colors.background){ valgameviewmodel:gameviewmodel=viewmodel() flappy(clickable( ... onrestart={ gameviewmodel.dispatch(gameaction.restart) }, onexit={ finish() } )) } } }
restart则要告知viewmodel去重置各种游戏数据,包括小鸟位置、管道和道路的位置、以及分值,但最高分值数据应当保留下来。
classgameviewmodel:viewmodel(){ privatefunrespon(action:gameaction,state:viewstate){ viewmodelscope.launch{ withcontext(dispatchers.default){ emit(when(action){ gameaction.restart->run{ state.ret(state.bestscore) } }) } } } } dataclassviewstate( ... //重置state数据,最高分值除外 funret(bestscore:int):viewstate= viewstate(bestscore=bestscore) }
给复刻好的游戏做个logo:采用小鸟的icon和特有的蓝色背景作成的adaptive icon
。
从点击logo到游戏结束再到重新开始,录制一段完整游戏。
复刻的效果还是比较完整的,但仍然有不少可以优化和扩展的地方:
1.比如增加简易模式的选择。可以从小鸟的升降幅度、管道的间隔、管道移动的速度、连续出现的组数等角度入手
2.增加翅膀扇动的姿态。实现的话也不难,比如将小鸟的翅膀部分扣出来,在飞翔的过程中不断地来回rotate一定角度
3.canvas自定义描画。部分视图元素采用的是图片,其实也可以通过canvas来实现,顺道强化一下compo的描画使用
以上就是利用jetpack compo复刻游戏flappy bird的详细内容,更多关于jetpack compo flappy bird游戏的资料请关注www.887551.com其它相关文章时间真是一把杀猪刀!
本文发布于:2023-04-05 00:59:16,感谢您对本站的认可!
本文链接:https://www.wtabcd.cn/fanwen/zuowen/ac9ad1b4bba1d9a2b84dcfc0de3c798b.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文word下载地址:利用Jetpack Compose复刻游戏Flappy Bird.doc
本文 PDF 下载地址:利用Jetpack Compose复刻游戏Flappy Bird.pdf
留言与评论(共有 0 条评论) |