前述
《俄罗斯方块》这款游戏大家应该都不陌生吧,以前的老爷手机上都会内置这款游戏,本篇我们一起使用白鹭引擎开发一款简易版的《俄罗斯方块》小游戏。
演示地址:点击查看
开始
运行效果:
(说明:帧数做了删减)
界面中,中间是游戏界面,底部是三个操作按钮,右边是游戏信息展示。图形下落过程中,玩家可以操作按钮控制图形。
首先将相应图片资源复制一份到我们的项目中,然后回到编辑器头部的资源,弹出添加提示,点击添加,这样资源配置信息就会自动添加到 default.res.json 中。
设计游戏主界面
设计好的皮肤文件效果:
首先我们将界面大小设置为 400 * 500 ,界面上添加相关控件:图形方块容器scrollBox
,下一个图形方块预览容器nextShapeBox
,分数显示控件scoreLabel
,底部三个操作按钮leftBtn
、rotateShapeBtn
、rightBtn
(已开启触摸监听)。
新建ts文件Pannel.ts
,创建类Pannel
,将皮肤引入,绑定自定义事件:
// Pannel.tsclass Pannel extends ponent {public scrollBox: eui.Group;public nextShapeBox: eui.Group;public leftBtn: eui.Button;public rotateShapeBtn: eui.Button;public rightBtn: eui.Button;public scoreLabel: eui.Label;// 分数 private _score: number = 0;public constructor() {super();this.skinName = "resource/skins/Pannel.exml";this.event();}private event() {const LeftEvent:MainEvent = new MainEvent(MainEvent.Left);const RightEvent:MainEvent = new MainEvent(MainEvent.Right);const RotateShapeEvent:MainEvent = new MainEvent(MainEvent.RotateShape);/**点击按钮'左边' */this.leftBtn.addEventListener(egret.TouchEvent.TOUCH_TAP, () => {this.dispatchEvent(LeftEvent);}, this);/**点击按钮'右边' */this.rightBtn.addEventListener(egret.TouchEvent.TOUCH_TAP, () => {this.dispatchEvent(RightEvent);}, this);/**点击按钮'翻转' */this.rotateShapeBtn.addEventListener(egret.TouchEvent.TOUCH_TAP, () => {this.dispatchEvent(RotateShapeEvent);}, this);}}
当点击底部三个按钮,触发对应的自定义事件,然后在父层上监听事件:
// Main.tsthis.pannelUI = new Pannel();this.pannelUI.addEventListener(MainEvent.Left, this.translateAction, this);this.pannelUI.addEventListener(MainEvent.Right, this.translateAction, this);this.pannelUI.addEventListener(MainEvent.RotateShape, this.rotateShape, this);
自定义事件类MainEvent
的实现:
// MainEvent.tsclass MainEvent extends egret.Event {/**往左边移动 */public static Left:string = '左移';/**往右边移动 */public static Right:string = '右移';/**图形翻转 */public static RotateShape:string = '图形翻转';/**重新开始 */public static Restart:string = '重新开始';private _resName: string = ""; public constructor(type:string, resName:string="", bubbles:boolean=false, cancelable:boolean=false) {super(type, bubbles, cancelable);this._resName = resName;}public get resName(): string {return this._resName;}}
当点击左右按钮时,触发的回调方法translateAction
中,我们通过属性type
来确定是左移还是右移 :
// Main.tsprivate translateAction(event: MainEvent): void {if (event.type === '左移') {this.translateXShape(-1);} elseif (event.type === '右移') {this.translateXShape(1);}}
最后我们给游戏主容器添加一个黑色矩形边框
// Pannel.ts// 给主容器添加一个矩形边框 const shp:egret.Shape = new egret.Shape();shp.graphics.lineStyle( 2, 0xffffff );shp.graphics.beginFill( 0x000000, 1);shp.graphics.drawRect( 0, 0, this.scrollBox.width, this.scrollBox.height);shp.graphics.endFill();this.scrollBox.addChild( shp );
设计重新开始界面
设计好的皮肤文件效果:
首先我们将界面大小设置为 400 * 500 ,界面上添加相关控件:先添加一个透明度为0.8
、背景颜色为黑色的矩形,然后再添加按钮“来一局”(已开启触摸监听)。
新建ts文件 Restart.ts,创建类 Restart ,将皮肤引入,绑定自定义事件:
// Restart.tsclass Restart extends ponent {public restart: eui.Button;public constructor() {super();this.skinName = "resource/skins/Restart.exml";this.event();}private event() {const RestartEvent:MainEvent = new MainEvent(MainEvent.Restart);/**点击按钮 `再来一局` */this.restart.addEventListener(egret.TouchEvent.TOUCH_TAP, () => {this.dispatchEvent(RestartEvent);}, this);}}
这里提下,如果编译后提示如:Duplicate identifier 'xxx'
等,如果没有问题怎么也通过不了,此时我们先运行egret clean
清除 ,然后重新编译。
当点击按钮“再来一局”,触发对应的自定义事件,然后在父层上监听事件:
// Main.tsthis.restartUI = new Restart();this.restartUI.addEventListener(MainEvent.Restart, this.start, this);
方块图形
俄罗斯方块一共有七种形状,如图:
设置方块坐标
本文我们就讲解第一种图形,其它图形读者可参考下面内容自行研究。
第一种方块图形的在坐标轴上的表示:
上图我们在坐标轴上绘制了第一种形状,由四个格子组成,每个格子的起点为:A(1 , 0)、B(1, 1)、C(1, 2)、D(0, 2)。(这里每个格子的长度为单位长度)
这样我们就获取到第一种图形的起点坐标:shapeArr = [[1, 0],[1, 1],[1, 2],[0, 2]]。
格子 A 的起点坐标转为实际坐标:
x = Main.Gridsize * shapeArr[0][0];y = Main.Gridsize * shapeArr[0][1];
Main.Gridsize
是每个正方形格子的大小。
然后我们将格子x
轴方向居中放置到容器this.pannelUI.nextShapeBox
中。x轴上居中的位置坐标:shapeX = this.pannelUI.scrollBox.width / 2,y轴: shapeY = Main.Gridsize * 2。
此时格子 A 的起点坐标转为实际坐标:
x = Main.Gridsize * shapeArr[0][0] + shapeX;y = Main.Gridsize * shapeArr[0][1] + shapeY;
这里 x 值最终要等于值 shapeX。 但由于格子 A 起点坐标为 (1, 0),并不满足需求。这里我们直接将格子 A 起点坐标的 X 轴方向往左移动一个单位距离,转换后坐标为: (0, 1)。
故该形状的坐标表示变为:shapeArr = [[0, 0],[0, 1],[0, 2],[-1, 2]]。
整理后实现转换实际坐标方法:
// Main.tsprivate transitionCoordinate(shapeArr, shapeX, shapeY) {const arr = [];for (let i = 0; i < shapeArr.length; i++) {arr.push([ Main.Gridsize * shapeArr[i][0] + shapeX, Main.Gridsize * shapeArr[i][1] + shapeY]);}return arr;}
添加方块
获取到图形的实际坐标后,我们在父层添加图形。图形是由 20 * 20 大小的格子组成,格子的图片资源为rect_png
。
一个格子:
grid = Util.createBitmapByName('rect_png');
在辅助类Util
中我们封装了获取资源位图的方法。
因为所有的方块都是由grid
组成,随着游戏的持续,生成的格子对象越来越多,会影响性能。我们有必要从回收池中获取格子对象。
从回收池中获取格子:
// Main.tsprivate getGrid():egret.Bitmap {let grid;if (this.poolList.length) {// 取出队列的最后一个grid = this.poolList.pop();} else {grid = Util.createBitmapByName('rect_png');}return grid;}
属性this.poolList
是格子回收池列表。 获取一个格子时,先从列表中取,没有的话再实例一个格子对象。
从显示对象列表中移除格子:
// Main.tsprivate destroyGrid(grid: egret.Bitmap, layer:eui.Group) {layer.removeChild(grid);this.poolList.push(grid);}
当在父容器上的格子移除后,放回到回收池中。
然后根据坐标绘制图形:
// Main.ts/**绘制图形 */private drawShape(shape?:any, layer?: eui.Group): void {const shapeObject = shape || this.nowShape;const container = layer || this.pannelUI.scrollBox;const arr = this.transitionCoordinate(shapeObject.data, shapeObject.x, shapeObject.y);for (let i = 0; i < arr.length; i++) {const grid = this.getGrid();grid.x = arr[i][0];grid.y = arr[i][1];grid.name = 'grid' + '_' + shapeObject.index;container.addChild(grid);}}
方法内this.nowShape
是当前要添加的图形属性。参数shape
是要添加的图形属性,参数layer
是图形容器。
一个图形的属性对象组成:
// Main.ts// 默认Y轴超出容器范围,x轴居中this.nowShape = {x: this.pannelUI.scrollBox.width / 2,y: -40,shapeIndex: this.nextShapeIndex,index: this.index,data: JSON.parse(JSON.stringify(this.shapeList[this.nextShapeIndex]))};
属性this.shapeList
是方块形状的集合,属性this.nextShapeIndex
是下一个要添加的方块形状索引。
创建一个新的图形方法如下:
// Main.tsprivate createNewShape(): void {this.nowShape = {...}this.index ++;// 随机赋值下一个方块形状索引this.nextShapeIndex = Math.floor(Math.random() * this.shapeList.length);// 将下一个图形添加到预览容器中const nextShape = {x: 40,y: 40,index: 0,data: JSON.parse(JSON.stringify(this.shapeList[this.nextShapeIndex]))};this.clearNextShape();this.drawShape(nextShape, this.pannelUI.nextShapeBox);// 添加心跳监听,图形不断往下移动egret.startTick(this.translateYShape, this);}
创建一个图形的流程:我们先设置好当前要添加的图形属性,然后随机设置下一个图形的类型索引,再将下一个图形添加到预览容器中。最后添加心跳监听,让当前方块不断往下移动。
清除预览容器内的格子方法this.clearNextShape
内,我们调用方法this.clearShape
。
清除父层上所有的子对象,我们可直接调用this.removeChildren
方法。但因为我们需要将界面上要移除的格子对象保存到回收池中,所以需要先获取父层上的格子对象。
// Main.ts/**获取指定容器中格子对象列表 */private getGridFromLayer(layer: eui.Group, index?: number): egret.Bitmap[] {let arr = [];for (let i = 0; i < layer.numChildren; i++) {const grid = layer.getChildAt(i);if (grid) {if (typeof index === 'undefined') {if (grid.name.indexOf('grid') > -1) {arr.push(grid);}} elseif (grid.name === ('grid_'+index) ) {arr.push(grid);}}}return arr;}
获取到格子列表后,再移除:
// Main.ts/**清除图形显示容器的当前图形 */private clearShape(layer: eui.Group, index?: number):void {let gridArr = this.getGridFromLayer(layer, index);let grid;while(gridArr.length) {grid = gridArr.shift();this.destroyGrid(<egret.Bitmap>grid, layer);}}
方块往下移动
方块添加到容器后,就不断往下移动,移动速度越快,游戏难度就越高。
首先定义时间阈值:
// Main.tsprivate timeNum: number = 0;// 移动速度,阈值private timeMax = 300;private time = 0;
添加一个新的方块后,就会监听心跳,执行回调方法this.translateYShape
,实现下降速率控制:
// Main.ts// timeStamp 是心跳回调时的时间戳private translateYShape(timeStamp:number): boolean {const now = timeStamp;const time = this.time;const pass = now - time;this.timeNum += pass;// 超出阈值if (this.timeNum > this.timeMax) {this.timeNum = 0;// 更新当前图形Y轴值,重绘当前图形this.clearShape(this.pannelUI.scrollBox, this.nowShape.index);this.nowShape.y += Main.Gridsize;this.drawShape();}this.time = now;return false;}
执行后,方块就不断往下移动,一直超出游戏场景范围。我们希望方块碰到容器底部后,就停止运动,然后新增一个方块到容器,假设我们已经实现了检测方块能否继续往下移动方法,修改如下:
// Main.tsprivate translateYShape(timeStamp:number): boolean {//其它代码省略......// 检测方块是否可以继续运行const checkedBool = this.checkYBoundary();if (checkedBool) {// 更新当前图形Y轴值,重绘当前图形this.clearShape(this.pannelUI.scrollBox, this.nowShape.index);this.nowShape.y += Main.Gridsize;this.drawShape();} else {// 停止心跳监听egret.stopTick(this.translateYShape, this);// 新增下一个图形方块到容器this.createNewShape();}//其它代码省略......}
接下我们实现方法this.checkYBoundary
。
方块是由多个大小相同的小格子组成的,每次往下移动都是一个格子大小。判断方块能否继续往下移动,需要检查三个情况:
当前方块中有个小格子的 Y 轴已经在整个容器最底部,那就不能往下移动;当前方块中有个小格子,如果下个移动位置已经被占用,那就不能往下移动;如果满足条件2后,如果此时有个小格子超出顶部位置,那么游戏结束;
在次之前,我们将容器分隔成多个小格子(容器大小设置成可以被Main.Gridsize
整除):
// Main.tsprivate createMatrix():void {// grids[i] Y轴 grids[i][j] X轴,注意我们的坐标轴是左上角开始,往下是Y轴,往右是X轴this.grids = <any>[];for (let i = 0; i < this.pannelUI.scrollBox.height / Main.Gridsize; i++) {this.grids[i] = <any>[];for (let j = 0; j < this.pannelUI.scrollBox.width / Main.Gridsize; j++) {this.grids[i][j] = false;}}}
初始时,我们给每个格子的值都设置为 false ,表示没有被占用。游戏每次开始前,运行上面方法。
有了上面的准备,我们实现方法checkYBoundary
。
首先获取当前方块(移动中)的所有小格子实际坐标,然后再获取每个小格子在容器格子集合(this.grids
)的位置:
// Main.tsprivate checkYBoundary() : boolean {let bool = true;// 获取当前方块的小格子的实际坐标,const arr = this.transitionCoordinate(this.nowShape.data, this.nowShape.x, this.nowShape.y);for (let i = 0; i < arr.length; i++) {// xNum,yNum 是方块的小格子在容器的位置索引const xNum = arr[i][0] / Main.Gridsize;const yNum = arr[i][1] / Main.Gridsize;}return bool;}
在循环体中,先判断是否已经在最底部:
// Main.ts// 注意:坐标轴的原点是左上角if (yNum === (this.grids.length - 1)) {bool = false;break;}
再判断下一步是否被占用:
// Main.tsif ( (typeof this.grids[yNum + 1] !== 'undefined') && this.grids[yNum + 1][xNum]) {bool = false;break;}
最后再判断此时的方块是否还未进入容器(每次新增的方块 Y 轴值都是负值):
// Main.tsif ( (typeof this.grids[yNum + 1] !== 'undefined') && this.grids[yNum + 1][xNum]) {if (yNum === -1) {// 游戏结束this.restart();}bool = false;break;}
一旦游戏结束就不能再自动新增图形,所以我们定义了属性isPause
,标记游戏是否暂停。
方法restart
:
// Main.tsprivate restart(): void {console.log('游戏结束')this.isPause = true;egret.stopTick(this.translateYShape, this);this.addChild(this.restartUI);}
重写修改方法translateYShape
:
// Main.tsprivate translateYShape(timeStamp:number): boolean {// 省略其它代码......if (!this.isPause) {const checkedBool = this.checkYBoundary();if (checkedBool) {// 省略} else {// 省略 }}// 省略}
上面我们实现了方块下降和下降的检测。当停止下降后,我们需要将容器的相应格子标记为占用,如果此时某行都被占用后就得销毁同时增加分数。
当停止下降后,我们执行方法this.drawWall
,再次改造方法translateYShape
:
// Main.tsprivate translateYShape(timeStamp:number): boolean {// 省略其它代码......if (!this.isPause) {const checkedBool = this.checkYBoundary();if (checkedBool) {// 省略} else {this.drawWall();// 省略 }}// 省略}
分数
方块停止下降后,当检测到某行全被占用,就更新分数,销毁该行的小格子,下面我们讲解如何实现。
方块停止下降后,我们需要把方块所在的容器小格子标记为已占用,跟方法checkYBoundary
一样,我们先获取当前图形的坐标信息,然后再获取索引,最后标记。
我们实现方法drawWall
。
// Main.tsprivate drawWall():void {const arr = this.transitionCoordinate(this.nowShape.data, this.nowShape.x, this.nowShape.y);let i = 0;try {for (i = 0; i < arr.length; i++) {const yNum = arr[i][1] / Main.Gridsize;const xNum = arr[i][0] / Main.Gridsize;//停止下降后,此时要把所在的格子标志为已占用(true)this.grids[yNum][xNum] = true;}} catch (error) {this.restart();}}
然后检测某行都被占用的格子,设置分数,并重新赋予每个格子的值(最终表现为堆叠的格子整体下降了):
// Main.ts // 方法 drawWallfor (i = 0; i < this.grids.length; i++) {// 当前循环的行是否满格,值默认占满let mark = true;// 循环某个行,该行上的所有小格子都被占用for (let k = 0; k < this.grids[i].length; k++) {if (!this.grids[i][k]) {mark = false;break;}}if (mark) {this.changeScore();}}
当检测到满格时,就更新分数:
// Main.tsprivate changeScore(score?:number): void {if (typeof score === 'undefined') {this.score += 1;} else {this.score = score;}this.pannelUI.score = this.score;}
我们在类Pannel
里新增更新分数方法:
// Pannel.tspublic get score(): number {return this._score;}/**设置分数 */public set score(score: number) {this._score = score;this.scoreLabel.text = this._score + '分';}
分数更新完毕后,接下来将该行以上占用格子往下移动,我们只需将前一行值赋予当前行,循环赋予即可。
// Main.ts// 方法 drawWallif (mark) {this.changeScore();// i 是此时占满格子的行所在的索引for (let j = i; j > 0; j--) {for (let h = 0; h < this.grids[i].length; h++) {// 将上一行的占用值赋予当前行this.grids[j][h] = this.grids[j-1][h];}}}
然后重新绘制被占用的格子,先清除再绘制:
// Main.ts// 方法 drawWallthis.clearShape(this.pannelUI.scrollBox);// 绘制已被占的格子for (let g = 0; g < this.grids.length; g++) {for (let s = 0; s < this.grids[g].length; s++) {if (this.grids[g][s]) {const grid = this.getGrid();grid.x = s * Main.Gridsize;grid.y = g * Main.Gridsize;this.pannelUI.scrollBox.addChild(grid);}}}
小结
本小结讲解了方块的形状坐标表示,添加方块,往下移动方块,检测Y轴移动、分数的设置。
操作方块
游戏界面上设计了三个按钮,分别为:左移、翻转、右移,这小结我们实现这三个功能。
左右移动
左右移动实现是一样的,我们在父层已经监听了点击左右移动的事件,执行回调方法translateAction
:
// Main.tsprivate translateAction(event: MainEvent): void {if (event.type === '左移') {this.translateXShape(-1);} elseif (event.type === '右移') {this.translateXShape(1);}}
跟Y轴下降一样,每次左右移动的距离都是属性值Main.Gridsize
的 n 倍。
// Main.ts// num 负往左边移动;正往右边移动private translateXShape(num: number): void {this.nowShape.x += Main.Gridsize * num;this.clearShape(this.pannelUI.scrollBox, this.nowShape.index);this.drawShape();}
当不断点击往左或往右后,方块就超出容器范围,我们希望当处于边界时,不能移动,假设已经实现了x轴左右移动检测方法this.checkXBoundary
, 上面重新改造:
// Main.ts// num 负往左边移动;正往右边移动private translateXShape(num: number): void {// x轴可左右移动检测if (this.checkXBoundary()) {this.nowShape.x += Main.Gridsize * num;this.clearShape(this.pannelUI.scrollBox, this.nowShape.index);this.drawShape();}}
翻转
跟左右按钮一样,我们也实现监听回调,先放代码:
private rotateShape():void {// 田字图形无需翻转if (this.nowShape.shapeIndex === 5) {return;}const data = this.nowShape.data;const temp = [];for (let i = 0; i < data.length; i++) {// 关键代码temp.push([data[i][1], -data[i][0] ]);}this.nowShape.data = temp;this.clearShape(this.pannelUI.scrollBox, this.nowShape.index);this.drawShape();}
上面我们实现了图形绕原点逆时针旋转,读者可能对这部分不是很清楚,接下来我们简单推导下公式。
我们先看在直角坐标系上一个点围绕原点逆时针旋转的效果图:
现在问题为:已知旋转前点的坐标为 P,方向角度为 a ,绕原点的旋转角度为 b ,点到原点的距离为 R,求旋转后点的坐标 P’ 。
根据三角函数,我们可以得出坐标 P’ :
x' = Rcos(a+b)y' = Rsin(a+b)
根据和差公式,得到如下等式:
x' = Rcos(a)cos(b) - Rsin(a)sin(b)y' = Rsin(a)cos(b) + Rcos(a)sin(b)
观察上式,Rcos(a) = x, Rsin(a) = y, 带入等式:
x' = xcos(b) - ysin(b)y' = xsin(b) + ycos(b)
我们每次翻转都是 90° ,因为 cos(90) = 0, sin(90) = 1 带入上面公式,得出:
x' = -yy' = x
上面公式是通过直角坐标系得出,我们的画布坐标轴值是相反的,故:
x' = -(-y) = yy' = (-x) = -x
这里顺便提下,因为 π 精度和浮点运算问题, js根据角度计算sin和cos值的计算方式可如下:
//角度var vAngle=90;//正弦值var vSin= Math.round(Math.sin((vAngle * Math.PI/180)) * 1000000) / 1000000;//余弦值var vCos= Math.round(Math.cos((vAngle * Math.PI/180)) * 1000000) / 1000000;
实现了逆时针翻转后,我们需要再次检测边界问题,重新改造方法rotateShape
:
// Main.tsprivate rotateShape():void {// 代码省略......if (this.checkXBoundary()) {// 通过后才重新绘制,代码省略......}}
x轴左右移动检测有三种情况是无法通过的:
超出容器左边;超出容器右边;碰到被占用的格子;
我们在方法checkXBoundary
中实现三种情况的检测。
先获取组成当前方块的小格子坐标,获取所在容器的位置:
// Main.tsprivate checkXBoundary(): Boolean {let bool = true;const arr = this.transitionCoordinate(this.nowShape.data, this.nowShape.x, this.nowShape.y);try {for (let i = 0; i < arr.length; i++) {const xNum = arr[i][0] / Main.Gridsize;const yNum = arr[i][1] / Main.Gridsize;// 实现条件判断}}catch(e) {}return bool;}
判断超出左边边界:
// Main.tsprivate checkXBoundary(): Boolean {// 省略代码......if (xNum < 0) {bool = false;break;}}
判断超出右边边界:
// Main.tsprivate checkXBoundary(): Boolean {// 省略代码...... this.grids[0].length 取第一行的格子数if (xNum > this.grids[0].length - 1) {bool = false;break;}}
判断碰到被占用的格子:
// Main.tsprivate checkXBoundary(): Boolean {// 省略代码......if (this.grids[yNum][xNum]) {bool = false;break;}}
我们在翻转图形时,如果已经在边界,或者左右两边已经有被占用的格子,此时不能翻转:
// Main.tsprivate rotateShape():void {// 省略代码......、// 只有检测通过才能重绘实现翻转效果if (this.checkXBoundary()) {this.clearShape(this.pannelUI.scrollBox, this.nowShape.index);this.drawShape();}}
假如翻转超出了左右,我们也允许翻转的话,那么此时应该将图形往左移或右移,具体实现请读者参考示例。
小结
本小结讲解了方块的左右移动控制,x轴移动检测,图形翻转的实现。
最后
本篇我们从头讲解了一个简单俄罗斯方块游戏的实现,希望读者能够学到使用白鹭引擎开发小游戏。本篇中方块图形一共有7种,希望读者能够按照教程自行推出坐标集,另外上面实现图形翻转效果也可以进行改进,这些请读者自行实现,本篇不再细述。