【uniapp】元胞自动机GameOfLife生命游戏项目开发流程详解
听说有一个生命游戏,会不会演化出一些数字生命呢,想做出来玩玩,看能不能发现什么规律,感觉实现起来简单,对此感兴趣的新手可以拿来练习。
这个生命游戏的出现,来自英国数学家约翰·何顿·康威在1970年发明的细胞自动机,也叫元胞自动机,英文名Game of Life
这里使用HBuilderX工具开发一个项目-生命游戏,用uniapp
创建,它是可以编译多平台上运行,下面详细讲一下开发过程。
萌新小白学习需前要具备的以下知识点:
- 熟悉前端开发,有Web网页设计基础,会使用HTML,CSS,JavaScript;
- 在Web网页中使用vue.js;
- 使用HBuilderX开发工具;
如果以上都具备了,请继续往下看
首先,从电脑上打开已安装的HBuilderX开发工具,选择菜单栏上的文件
→新建项目
创建项目
在新建的项目窗口中,如下图
选择对应的选项uni-app
,默认模板
,越简单越好,
图中填入的
uniapp-simple-app
是项目文件夹名称,这里改成uni-app-game-of-life
,
若勾选了 uni-app x,那就要把原来的
javascript
开发语言改成用uts
开发语言写,若你还不会用uts
,就不要勾选它哦
最后点击创建
按钮,开发工具下就会出现一个创建好的项目文件夹uni-app-game-of-life
页面文件
项目中所有文件名带后缀.vue
的都是页面文件,当打开查看后,
学过vue.js
的同学都知道,文件内容里面划分为三段,用标签对<>
表示,分别是:
template
页面模板,类似HTML页面script
页面脚本,这里写页面的脚本JavaScript代码style
页面样式,写样式表,用于控制页面显示特效
开始页面
开始页面已自动新建好了,就在项目文件夹里,从根目录下找,
文件位置在pages/index/index.vue
,打开查看,
此文件里修改,试试添加一个开始游戏按钮,
开始按钮
在文件内容的一段布局模板template
标签里,往里面添加一个按钮控件,添加的内容如下
<template><view class="content"><!-- 此处省略 --><view class="footer"><!-- 此处省略 --><button class="btn" @click="enter" type="primary">开始</button><!-- 此处省略 --></view></view>
</template>
在文件的脚本script
标签里添加代码,实现点击按钮去打开游戏页面,代码如下
//...
export default {data() {return {//...}},//...methods: {//.../*** 点击开始按钮*/enter(){//进入游戏页面uni.navigateTo({url: '/pages/game/game',success: res => {},fail: () => {},complete: () => {}})}}
}
还有最后的样式style
标签里,内容如下
<style lang="scss">
.footer {//...
}
</style>
那是改控件的显示效果,和HTML网页设计的样式一样的,不是重点
开始页面改好了,可以点击编译运行
看看,如下图
编译运行选运行到
内置浏览器
看效果默认是H5的页面,操作没有外置的浏览器查看方便
编译运行通常是选择运行到Chrome浏览器
上看效果,打开开发者工具(按组合键Ctrl+Shift+I,再按Ctrl+Shift+M),模拟移动端,如下图
看着反应快,可边改边保存看运行效果,
看页面上还有控件输入框,主题按钮,这些是后面完善的细节,实现了修改游戏数据和主题颜色,但那些不是重点,这里就不展开讲
当点击开始游戏,它就会在开发工具的控制台Console下显示报错,不用看就知道,还有游戏页面没有做出来呢
游戏页面
游戏页面需要自己添加,有两种方式:
- 新建页面向导
- 修改配置文件
添加页面
- 这里采用
新建页面向导
,对新手来说,最简单又好理解,
选中项目里的文件夹pages
,点鼠标右键,选择新建页面,如下图
新建页面弹出一个窗口,如下图
这样填:
- 输入页面名称
game
,这是页面文件名, - 默认勾选了
创建同名目录
, 那就是先新建文件夹game
, 再往里面新建页面文件game.vue
, - 还有可修改页面标题,为
"navigationBarTitleText": "生命游戏"
点击确定后,会留意到项目中会多了个页面文件,是游戏页面,
文件位置/pages/game/game.vue
,打开查看,
会发现基本的结构,已经自动填写好了,
- 若要采用
修改配置文件
来建立游戏页面,
就去看看项目文件夹下的文件pages.json
,内容如下
{"pages": [ //pages数组中第一项表示应用启动页{"path": "pages/index/index","style": {"navigationBarTitleText": "生命游戏"}},{"path" : "pages/game/game","style" : {"navigationBarTitleText" : "生命游戏"}}],"globalStyle": {//..."navigationBarTitleText": "生命游戏",//...},//..
}
会发现上面配置项有好多,这里省略了,不要关心那些,那都是开发工具自动填写的,可以自己填写,这对新手来说不友好,
改配置如有不懂的,可参考文档全局配置-页面路由
在游戏页面文件里面,添加游戏页面的布局,内容如下
<template><view class="page"><canvas class="canvas" id="CSDN_zs1028" canvas-id="CSDN_zs1028" @touchstart="ontouchstart" @touchmove="ontouchmove" @touchend="ontouchend" @touchcancel="ontouchend"></canvas><view class="footer"><!-- 此处省略 --></view>
</template>
主要看canvas控件,是一个画布,用来绘制游戏画面
然后,就是添加游戏页面的脚本,代码如下
//使用app对象,将来调用全局方法和修改数据
const app = getApp()export default {data() {return {//...speed: 500, //初始速度maxSpeed: 1000, //最大速度cols: 32, //网格的初始列数isPlay: false, //是否动画isShowFPS: true, //显示刷新帧率,评估机器运行性能isDrawing: false, isCloseLoop: true, //是否在画布范围内移动,否则移动会超出范围//游戏数据data: [//...],};},/*** 页面加载*/onLoad(){//...},/*** 页面卸载*/onUnload(){ //...},/*** 准备就绪*/onReady(){//...},//...methods:{//...}
}
从上面看出,执行页面加载的顺序是先调用
onLoad
, 再调用onReady
方法,
页面加载
当进入页面时,需要先从页面加载的方法onLoad
里做些初始化数据,代码如下
let data = app.getLocalData()
//将获取到的本地数据设置到页面
Object.assign(this,{//...cols : data.cols,speed : data.speed,data : data.data,
})
页面卸载
当退出页面时,会调用页面卸载的方法onUnload
,
就在这里做个处理,保存一下数据,代码如下
let data = app.getLocalData()
data.data = this.data
data.cols = this.cols
data.speed = this.speed
//将页面的数据保存到本地
app.setLocalData(data)
准备就绪
待页面加载完成,就会调用一个方法onReady
,
在这里就可处理操作页面上的控件,做初始化处理,代码如下
//创建一个查询工具,通过id查找页面的一个控件canvas实例
uni.createSelectorQuery().select('#CSDN_zs1028').fields({size:true,context:true,
}).exec(res=>{//得到一个画布控件的宽和高const { width, height } = res[0]//获取控件canvas的上下文contextconst ctx = uni.createCanvasContext('CSDN_zs1028')//存起来,也就是设置到之前定义的data,多了一个属性canvas数据Object.assign(this,{canvas:{width,height,ctx,//...}})//加载数据this.reload()//画坐标轴和网格this.drawCoordinateAxisAndGrid(()=>{//画对象this.drawObjects(()=>{//开始this.start()})})
})
从上面可以看出,有四个自定义方法需要一步一步来实现,
自定义方法通常是写在methods
里面,代码如下
{/*** 重新加载*/reload(){//...},/*** 绘制网格*/drawCoordinateAxisAndGrid(callback){//...},/*** 绘制所有对象*/drawObjects(callback){//...},/*** 实现动画循环的方法*/requestAnimationFrame(callback){//...},/*** 开始游戏*/start(){//...},/*** 处理更新下一步的数据*/nextStep() {//...},/*** 触摸画布开始*/ontouchstart(e){//...},/*** 触摸画布点移动*/ontouchmove(e){//...},/*** 触摸画布结束*/ontouchend(){//...},//...
}
游戏逻辑
一些游戏逻辑都是写在methods
里面,供后面需要的时候调用
重新加载
初始化游戏数据,就调用重新加载方法reload
,代码如下
const { cols, data, canvas } = this//校验配置,网格列数必须是偶数
if (cols%2!=0) throw new Error(`grid cols value not is odd number ${cols}`)
//其它判断省略...//从配置拿出padding的边距值...
const { width, height, offsetTop:paddingT } = canvas
//计算出每一个单元格的大小
let gw = (width-padding*2)/cols
//计算出网格的行数
let rows = parseInt((height-padding*2-paddingT)/gw)
//定义一个网格的单元格数量,等于列数乘以行
let grids = new Array(cols*rows)
for(let i=0; i<grids.length; i++){//给每个单元格重新赋初值,x和y分别为第几列和几行,方便定位grids[i]={x:i%cols,y:parseInt(i/cols)}
}
//将计算出的每个属性存起来
Object.assign(this, {gw,rows,grids})
绘制网格
接下来,就会先绘制一个网格出来,看看之前初始化的网格数据有没有问题,
绘制网格的方法drawCoordinateAxisAndGrid(callback)
,代码如下
const { canvas, grids, gw } = this
const { ctx, width, height, offsetTop:paddingT } = canvas
const { paddingLR:padding } = Config
//绘制一个边框
ctx.fillStyle = '#ffffff'
ctx.fillRect(0,0,width,height)//绘制坐标轴x和y
//省略了...
let p = 2;
//...
ctx.strokeText('0',p,paddingT)
//...
ctx.strokeText('x',width-p,paddingT)
//...
ctx.strokeText('y',p,height-p)//绘制网格
ctx.beginPath()
grids.forEach((g,i)=>{let x = padding + g.x*gwlet y = paddingT + padding + g.y*gwctx.rect(x,y,gw,gw)
})
ctx.stroke()
ctx.draw(true,callback)
这uniapp项目的canvas绘制方法是draw(),它是异步绘制的,等绘制完就需要调用传入的第二个参数callback,才能继续下一个绘制,保证绘制顺序不会出错
写到这里,可以编译运行看看绘制的网格,效果如下图
这是参考的一个数学坐标系,从左上角开始,水平方向为x轴,垂直方向为y轴
绘制对象
绘制的对象就是指网格里面的元胞,对象数据用之前的data()
方法返回的数据属性data
表示,
游戏数据,用默认属性data
的值,可以是这样的,代码如下
data: [[5,2],[6,3],[5,4],[7,3],[6,4],
],
看不懂是什么数据,没关系,接下来将数据可视化
调用绘制的方法drawObjects(callback)
就能把上面数据对象画出来,实现代码如下
const { themeStyleIndex, canvas, data, gw } = this
const { ctx, width, height, offsetTop:paddingT } = canvas
const { paddingLR:padding, themeStyles } = Config
//按主题样式指定颜色,绘制一个背景
ctx.fillStyle = themeStyles[themeStyleIndex][0]
ctx.fillRect(0,paddingT,width,height-paddingT)
//下面继续,将对象数据通过单元格绘制出来
ctx.beginPath()
ctx.fillStyle = themeStyles[themeStyleIndex][1]
data.forEach((d,i)=>{let x = padding + d[0]*gwlet y = padding + d[1]*gw + paddingTctx.fillRect(x,y,gw,gw)
})
ctx.draw(true,callback)
写到这里,可以编译运行看看效果,绘制出是所有数据对象,效果如下图
对比上面属性data的值,可以看出规律来,
例如5,2,对应网格中一个在5列2行的一个点(单元格),类似点亮LED灯珠的效果
绘制出来的所有对象,发现一个问题:绘制的网格不见了
那之前绘制的网格是被覆盖了,若不需要绘制网格,可以注释掉绘制网格的方法
开始游戏
开始游戏,让绘制出来的所有数据对象(活)动起来,
需要调用方法·start·,代码如下
//...//实现下一帧方法,更新画面
const nextFPS = ()=>{//...
}
//执行更新动画
nextFPS()
方法里面还有一个自定义方法nextFPS
,作为递归调用,
实现循环动画,让所有数据对象不要停下来,代码如下
const { canvas, isPlay, isShowFPS, themeStyleIndex, speed, maxSpeed } = this
const { paddingLR:padding, themeStyles } = Config
const { ctx, offsetTop:paddingT } = canvas
//若没有暂停,就继续
if (isPlay) {let timeSpan = Date.now() - timer//此处省略...//判断时间,如果到了,就处理更新下一步的数据,让对象活过来if (timeSpan > maxSpeed - speed) {this.nextStep()timer2 = Date.now()}//绘制所有对象this.drawObjects()//...//绘制完成后,就到下次更新ctx.draw(true,()=>this.requestAnimationFrame(nextFPS))return
}
//下次更新
this.requestAnimationFrame(nextFPS)
从上面看出,里面还调用了方法
nextFPS
,实现循环动画
其中调用了一个方法requestAnimationFrame
,这个方法是自定义的,
有个自带的方法,如下
uni.requestAnimationFrame(callback)
为什么不用它,因为它能在以前的旧版本上跨多端平台上使用,现在继续用它还是存在问题的,
uniapp官方未及时解决这个问题,就只有开发者自己解决,作者这里实现中用到了条件编译,这里不展开讲,具体看源码,可以保证能编译在H5,和小程序上顺利执行动画
其中还调用了一个方法nextStep
,这个方法也是自定义的,
这是游戏里主要的游戏规则逻辑,实现稍微复杂点,新手可能看不懂,作者会多加注释,若能看懂那就学到了吧
实现处理更新下一步的对象数据,代码如下
const { data, rows, isCloseLoop, cols } = this
//定义个记录死亡的
const delIds = []
//定义个记录存活的
const data2 = []
//定义个不同方向的偏移数据
const dires = [//...
]
//遍历处理对象的每一个单元格数据
data.forEach((d,i)=>{let x = d[0] //所在列let y = d[1] //所在行//计数let count = 0//通过不同的方向去计算,记录一个单元格周围存活的countdires.forEach(e=>{//此处省略... 请自己多想想,怎么实现})//孤单,或者拥挤,标记为死亡if (count<2 || count>3) delIds.unshift(i)//超出范围,标记为死亡else if (x<0 || x>=cols || y<0 || y>=rows) delIds.unshift(i)
})
//删除数据对象中死亡的
delIds.forEach(i=>data.splice(i,1))
data2.forEach(d=>{//满足三个,则为存活,加入数据对象if (d.count==3) data.push([d.x,d.y])
})
游戏交互
以为这就结束了?还有最后的步骤,
游戏是开始了,可怎么操作没有反应,需要实现游戏的交互逻辑,
当手指触摸画布canvas时,会调用三个自定义方法,
触摸画布开始时,调用的方法是ontouchstart(e)
,代码如下
this.isDrawing=false
就这?没错,这是为了判断触摸后有没有移动的情况,看不懂没关系,慢慢研究就懂了
触摸画布点移动时,调用的方法是ontouchmove(e)
,代码如下
//当触摸移动,表示在绘制,应该改成暂停游戏
this.isPlay=false
//表示在触摸移动
this.isDrawing=trueconst { touches } = e
const { gw, canvas, grids, data } = this
//计算出触摸点在画布的位置
let left = touches[0].pageX - canvas.offsetLeft
let top = touches[0].pageY - canvas.offsetTop
//...
//计算位置所在列和行
let x = parseInt(left/gw)
let y = parseInt(top/gw)
//找出位置对应的一个单元格
let g = grids.find(g=>g.x==x && g.y==y)
if(g){let d = data.find(d=>d[0]==x && d[1]==y)//如果这个不是对象的一部分,就加入if (!d) {data.push([x,y])//重新绘制对象this.drawObjects(()=>{})}
}
触摸结束时,调用的方法是ontouchend()
,代码如下
const { isDrawing, isPlay } = this
if(!isDrawing) this.isPlay=!isPlay
看上面就知道,这个处理和触摸开始的方法是对应的,重置设置
运行测试
做到这里,基本上就算完成了,可以点击运行测试看看效果,
项目运行测试效果如下图
录制的效果看着有掉帧的情况,与实际效果是不一样的,估计是作者电脑当时多开后台程序,内存要爆,就有点卡,
想看是否效果流畅的的可以亲自下来体验
小提示:
生命游戏这个项目,编译出来的App软件在不同平台上硬件性能表现不同,发现有的卡,有的流畅,针对这个问题,想做游戏可以考虑在哪个平台上开发,保证用户体验上不会很差。
写到了这里,初步明确了项目开发的方向,可以尝试按照文章步骤做出来,具体细节可参考项目源码,
此项目源码已上传到CSDN资源,在此文章头有展示,更多源码资源,请点击此处查看
以上为全部内容,感谢阅读,再见!