前述
《俄罗斯方块》这款游戏大家应该都不陌生吧,以前的老爷手机上都会内置这款游戏,本篇我们一起使用白鹭引擎开发一款简易版的《俄罗斯方块》小游戏。
演示地址:点击查看
开始
运行效果:
(说明:帧数做了删减)
界面中,中间是游戏界面,底部是三个操作按钮,右边是游戏信息展示。图形下落过程中,玩家可以操作按钮控制图形。
首先将相应图片资源复制一份到我们的项目中,然后回到编辑器头部的资源,弹出添加提示,点击添加,这样资源配置信息就会自动添加到 default.res.json 中。
设计游戏主界面
设计好的皮肤文件效果:
首先我们将界面大小设置为 400 * 500 ,界面上添加相关控件:图形方块容器 scrollBox
,下一个图形方块预览容器 nextShapeBox
,分数显示控件 scoreLabel
,底部三个操作按钮 leftBtn
、rotateShapeBtn
、rightBtn
(已开启触摸监听)。
新建ts文件 Pannel.ts
,创建类 Pannel
,将皮肤引入,绑定自定义事件:
// Pannel.ts
class Pannel extends eui.Component {
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.ts
this.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.ts
class 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.ts
private translateAction(event: MainEvent): void {
if (event.type === '左移') {
this.translateXShape(-1);
} else
if (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.ts
class Restart extends eui.Component {
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.ts
this.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.ts
private 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.ts
private getGrid():egret.Bitmap {
let grid;
if (this.poolList.length) {
// 取出队列的最后一个
grid = this.poolList.pop();
} else {
grid = Util.createBitmapByName('rect_png');
}
return grid;
}
属性 this.poolList
是格子回收池列表。 获取一个格子时,先从列表中取,没有的话再实例一个格子对象。
从显示对象列表中移除格子:
// Main.ts
private 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.ts
private 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);
}
} else
if (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.ts
private 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.ts
private 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.ts
private 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.ts
private 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.ts
if ( (typeof this.grids[yNum + 1] !== 'undefined') && this.grids[yNum + 1][xNum]) {
bool = false;
break;
}
最后再判断此时的方块是否还未进入容器(每次新增的方块 Y 轴值都是负值):
// Main.ts
if ( (typeof this.grids[yNum + 1] !== 'undefined') && this.grids[yNum + 1][xNum]) {
if (yNum === -1) {
// 游戏结束
this.restart();
}
bool = false;
break;
}
一旦游戏结束就不能再自动新增图形,所以我们定义了属性 isPause
,标记游戏是否暂停。
方法 restart
:
// Main.ts
private restart(): void {
console.log('游戏结束')
this.isPause = true;
egret.stopTick(this.translateYShape, this);
this.addChild(this.restartUI);
}
重写修改方法 translateYShape
:
// Main.ts
private translateYShape(timeStamp:number): boolean {
// 省略其它代码......
if (!this.isPause) {
const checkedBool = this.checkYBoundary();
if (checkedBool) {
// 省略
} else {
// 省略
}
}
// 省略
}
上面我们实现了方块下降和下降的检测。当停止下降后,我们需要将容器的相应格子标记为占用,如果此时某行都被占用后就得销毁同时增加分数。
当停止下降后,我们执行方法 this.drawWall
,再次改造方法 translateYShape
:
// Main.ts
private translateYShape(timeStamp:number): boolean {
// 省略其它代码......
if (!this.isPause) {
const checkedBool = this.checkYBoundary();
if (checkedBool) {
// 省略
} else {
this.drawWall();
// 省略
}
}
// 省略
}
分数
方块停止下降后,当检测到某行全被占用,就更新分数,销毁该行的小格子,下面我们讲解如何实现。
方块停止下降后,我们需要把方块所在的容器小格子标记为已占用,跟方法 checkYBoundary
一样,我们先获取当前图形的坐标信息,然后再获取索引,最后标记。
我们实现方法 drawWall
。
// Main.ts
private 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
// 方法 drawWall
for (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.ts
private changeScore(score?:number): void {
if (typeof score === 'undefined') {
this.score += 1;
} else {
this.score = score;
}
this.pannelUI.score = this.score;
}
我们在类 Pannel
里新增更新分数方法:
// Pannel.ts
public get score(): number {
return this._score;
}
/**设置分数 */
public set score(score: number) {
this._score = score;
this.scoreLabel.text = this._score + '分';
}
分数更新完毕后,接下来将该行以上占用格子往下移动,我们只需将前一行值赋予当前行,循环赋予即可。
// Main.ts
// 方法 drawWall
if (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
// 方法 drawWall
this.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.ts
private translateAction(event: MainEvent): void {
if (event.type === '左移') {
this.translateXShape(-1);
} else
if (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' = -y
y' = x
上面公式是通过直角坐标系得出,我们的画布坐标轴值是相反的,故:
x' = -(-y) = y
y' = (-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.ts
private rotateShape():void {
// 代码省略......
if (this.checkXBoundary()) {
// 通过后才重新绘制,代码省略......
}
}
x轴左右移动检测有三种情况是无法通过的:
- 超出容器左边;
- 超出容器右边;
- 碰到被占用的格子;
我们在方法 checkXBoundary
中实现三种情况的检测。
先获取组成当前方块的小格子坐标,获取所在容器的位置:
// Main.ts
private 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.ts
private checkXBoundary(): Boolean {
// 省略代码......
if (xNum < 0) {
bool = false;
break;
}
}
判断超出右边边界:
// Main.ts
private checkXBoundary(): Boolean {
// 省略代码...... this.grids[0].length 取第一行的格子数
if (xNum > this.grids[0].length - 1) {
bool = false;
break;
}
}
判断碰到被占用的格子:
// Main.ts
private checkXBoundary(): Boolean {
// 省略代码......
if (this.grids[yNum][xNum]) {
bool = false;
break;
}
}
我们在翻转图形时,如果已经在边界,或者左右两边已经有被占用的格子,此时不能翻转:
// Main.ts
private rotateShape():void {
// 省略代码......、
// 只有检测通过才能重绘实现翻转效果
if (this.checkXBoundary()) {
this.clearShape(this.pannelUI.scrollBox, this.nowShape.index);
this.drawShape();
}
}
假如翻转超出了左右,我们也允许翻转的话,那么此时应该将图形往左移或右移,具体实现请读者参考示例。
小结
本小结讲解了方块的左右移动控制,x轴移动检测,图形翻转的实现。
最后
本篇我们从头讲解了一个简单俄罗斯方块游戏的实现,希望读者能够学到使用白鹭引擎开发小游戏。本篇中方块图形一共有7种,希望读者能够按照教程自行推出坐标集,另外上面实现图形翻转效果也可以进行改进,这些请读者自行实现,本篇不再细述。