听说有一个生命游戏,会不会演化出一些数字生命呢,想做出来玩玩,看能不能发现什么规律,感觉实现起来简单,对此感兴趣的新手可以拿来练习。
这个生命游戏的出现,来自英国数学家约翰·何顿·康威在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的上下文context
const 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*gw
let y = paddingT + padding + g.y*gw
ctx.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]*gw
let y = padding + d[1]*gw + paddingT
ctx.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
//通过不同的方向去计算,记录一个单元格周围存活的count
dires.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=true
const { 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软件在不同平台上硬件性能表现不同,发现有的卡,有的流畅,针对这个问题,想做游戏可以考虑在哪个平台上开发,保证用户体验上不会很差。
写到了这里,初步明确了项目开发的方向,可以尝试按照文章步骤做出来,具体细节可参考项目源码,
以上为全部内容,感谢阅读,再见!


3567

被折叠的 条评论
为什么被折叠?



