2

这次给大家带来的是通过Egret实现密室逃生小游戏的教程。该游戏包括人物状态机、MVC设计模式和单例模式,该游戏在1.5s内通过玩家点击操作寻找安全点,方可进入下一关,关卡无限,分数无限。下面是具体的模块介绍和代码实现。

该游戏主要内容包括

**开始游戏场景
游戏场景
游戏结束结算场景
全局常量类
人物状态机类**

游戏源码素材下载:https://github.com/shenysun/R...

创建全局常量类

在所有舞台搭建之前先写一个全局的静态方法类,取名为GameConst。这个类里面的方法和常量可以供全局使用,例如舞台宽高、通过名字获取位图、通过名字获取纹理集精灵等等。这个类可以大大减少后期的代码量,降低整体的耦合度。

/**常用常量类 */
class GameConst {
    /**舞台宽度 */
    public static StageW:number;
    /**舞台高度 */
    public static StageH:number;

    /**根据名字创建位图 */
    public static CreateBitmapByName(name:string):egret.Bitmap {
        let texture:egret.Texture = RES.getRes(name);
        let bitmap:egret.Bitmap = new egret.Bitmap(texture);
        return bitmap;
    }
    /**
     * 根据name关键字创建一个Bitmap对象。此name 是根据TexturePacker 组合成的一张位图
     */
    public static createBitmapFromSheet(name:string, sheetName:string):egret.Bitmap {
        let texture:egret.Texture = RES.getRes(`${sheetName}_json.${name}`);
        let result:egret.Bitmap = new egret.Bitmap(texture);
        return result;
    }

    public static getTextureFromSheet(name:string, sheetName:string):egret.Texture {
        let result:egret.Texture = RES.getRes(`${sheetName}_json.${name}`);
        return result;
    }
    /**移除子类方法 */
    public static removeChild(child:egret.DisplayObject) {
        if(child && child.parent) {
            if((<any>child.parent).removeElement) {
                (<any>child.parent).removeElement(<any>(child));
            }
            else {
                child.parent.removeChild(child);
            }
        }
    }
}

如果游戏中设置图片锚点较多也可以在这个类里面加一个设置锚点的方法,传入对象,横轴锚点和纵轴锚点坐标三个参数。

开始场景

图片描述

开始页面比较简洁,有一个LOGO和两个按钮分别是开始游戏,更多游戏。

/**游戏开始场景 */
class StartGameLayer extends egret.Sprite {
    /**开始按钮 */
    private startBtn:MyButton;
    /**更多按钮 */
    private moreBtn:MyButton;
    /**LOGO */
    private titleImage:egret.Bitmap;
    public constructor() {
        super();
        this.init();
    }
    private init():void {
        /**添加游戏LOGO */
        this.titleImage = GameConst.createBitmapFromSheet("logo_mishitaosheng", "ui");
        this.titleImage.x = 51;
        this.titleImage.y = 161;
        this.addChild(this.titleImage);
        //开始按钮设置
        this.startBtn = new MyButton("btn_y", "btn_kaishi");
        this.addChild(this.startBtn);
        this.startBtn.x = (GameConst.StageW - this.startBtn.width) / 2;
        this.startBtn.y = GameConst.StageH / 2 - 75;
        this.startBtn.setClick(this.onStartGameClick);
        //更多按钮设置
        this.moreBtn = new MyButton("btn_b", "btn_gengduo");
        this.moreBtn.x = (GameConst.StageW - this.startBtn.width) / 2;
        this.moreBtn.y =GameConst.StageH / 2 + 75;
        this.addChild(this.moreBtn);
        this.moreBtn.setClick(this.onMoreBtnClick);
        //文本
        let tex:egret.TextField = new egret.TextField();
        tex.width = GameConst.StageW;
        tex.textAlign = egret.HorizontalAlign.CENTER;
        tex.strokeColor = 0x403e3e;
        tex.stroke = 1;
        tex.bold = true;
        tex.y = GameConst.StageH / 2 + 250;
        tex.text = "Powered By ShenYSun";
        this.addChild(tex);
    }
    private onStartGameClick() {
        GameControl.Instance.onGameScenesHandler();
    }
    private onMoreBtnClick() {
        console.log("更多游戏");
        platform.GetInfo();
    }
}

点击startBtn按钮执行GameControl类的切换场景方法。

场景控制类

/**游戏管理 */
class GameControl extends egret.Sprite {
    private static _instance:GameControl;
    public static get Instance() {
        if(!GameControl._instance) {
            GameControl._instance = new GameControl();
        }
        return GameControl._instance;
     }
    /**当前场景 */
    private currentStage:egret.DisplayObjectContainer;
    //开始游戏
    private startGame:StartGameLayer;
    /**游戏场景 */
    private gameScenes:GameScenesLayer;
    /**结束场景 */
    private overScenes:GameOverLayer;
    /**背景 */
    private bgImg:egret.Bitmap;
    public constructor() {
        super();
        this.startGame = new StartGameLayer();
        this.gameScenes = new GameScenesLayer();
        this.overScenes = new GameOverLayer();
    }

    public setStageHandler(stage:egret.DisplayObjectContainer):void {
        /**设置当前场景的背景 */
        this.currentStage = stage;
        this.bgImg = GameConst.CreateBitmapByName("bg_jpg");
        this.bgImg.width = GameConst.StageW;
        this.bgImg.height = GameConst.StageH;
        //把背景添加到当期场景
        this.currentStage.addChild(this.bgImg);
    }
    /**开始游戏的场景 */
    public startGameHandler():void {
        if(this.gameScenes && this.gameScenes.parent) {
            GameConst.removeChild(this.gameScenes);
        }
        if(this.gameScenes && this.overScenes.parent) {
            GameConst.removeChild(this.overScenes);
        }
        this.currentStage.addChild(this.startGame);
        GameApp.xia.visible = true;
    }

    /**游戏场景 */
    public onGameScenesHandler():void {
        if(this.startGame && this.startGame.parent) {
            GameConst.removeChild(this.startGame);
        }
        if(this.overScenes && this.overScenes.parent) {
            GameConst.removeChild(this.overScenes);
        }
        this.currentStage.addChild(this.gameScenes);
        GameApp.xia.visible = false;
    }

    /**游戏结束场景 */
    public showGameOverSceneHandler():void{
        if(this.startGame && this.startGame.parent){
            GameConst.removeChild(this.startGame)
        }
        if(this.gameScenes && this.gameScenes.parent){
            GameConst.removeChild(this.gameScenes)
        }
        this.currentStage.addChild(this.overScenes);
        GameApp.xia.visible = true;
    }

    public getGameOverDisplay():GameOverLayer {
        return this.overScenes;
    }

}

场景切换贯穿游戏全局,封装成类方便调用,以及后期扩展只需要加上新场景类的实例,便可以切换自如。

自定义按钮类

不难发现上面的开始游戏界面的按钮是MyButton类型,在MyButton类的构造函数中传入背景图和显示文字,创建出一个按钮。此类有一个设置点击事件的方法,按钮调用此公开方法传入触发事件即可设置点击事件。

/**自定义按钮类 */
class MyButton extends egret.Sprite {
    private _bg:egret.Bitmap;
    private title:egret.Bitmap;
    private onClick:Function;

    public constructor(bgName:string, titleName:string) {
        super();
        this._bg = GameConst.createBitmapFromSheet(bgName, "ui");
        this.addChild(this._bg);

        this.title = GameConst.createBitmapFromSheet(titleName, "ui");
        
        this.title.x = (this._bg.width - this.title.width) >> 1;
        this.title.y = (this._bg.height - this.title.height) >> 1;
        this.addChild(this.title);
    }

    //设置点击触发事件
    public setClick(func:Function):void {
        this.touchEnabled = true;
        this.addEventListener(egret.TouchEvent.TOUCH_TAP, this.onClickEvent, this);
        this.onClick = func;
    }
    //点击触发的事件
    private onClickEvent() {
        this.onClick();
    }

    public setTitle(title:string):void {
        this.title = GameConst.CreateBitmapByName(title);
    }

    public get bg() {
        return this._bg;
    }
    public set bg(bg:egret.Bitmap) {
        this._bg = bg;
    }
}

图片描述

自定义特殊数字类

一般游戏中的分数、时间等数字组成的UI为了美观都会使用位图文本,但是当游戏逻辑跑起来需要不断的刷新游戏的分数,每次改变分数的时候都要从纹理集里面调用对应位图,在时间上是一个大大的浪费,所以创建一个特殊字符类SpecialNumber,让这个类替我们实现转换特殊字符。

具体代码如下:

/**特殊字符数字类 */
class SpecialNumber extends egret.DisplayObjectContainer {
    public constructor() {
        super();
    }
    public gap:number = 0;
    /**设置显示的字符串 */
    public setData(str:string):void {
        this.clear();
        if(str == "" || str == null) {
            return;
        }
        //把所有数字每一个都存进数组中
        let chars:Array<string> = str.split("");
        let w:number = 0;
        //所有的长度
        let length:number = chars.length;
        for(let i:number = 0; i < length; i++) {
            try {
                let image:egret.Bitmap = GameConst.createBitmapFromSheet(chars[i], "ui");
                if(image) {
                    image.x = w;
                    w += image.width + this.gap;
                    this.addChild(image);
                }
            } catch (error) {
                console.log(error);
            }
        }
        this.anchorOffsetX = this.width / 2;
    }
    public clear() {
        while(this.numChildren) {
            this.removeChildAt(0);
        }
    }
}

在体验过游戏的时候会发现任务会根据不一样的墙体高度摆不一样的poss,这才poss全是来自于帧动画纹理集,只需要把对应一套的动画解析出来人物就会跳起舞来。下面是人物状态类。

状态机类

人物共有五个状态,其中一个是默认状态state为跳舞状态STAGE1,还有设置当前状态的方法setState

/**角色动作类 */
class Role extends egret.Sprite{
    //状态
    public static STATE1:number = 0;
    public static STATE2:number = 1;
    public static STATE3:number = 2;
    public static STATE4:number = 3;
    public static STATE5:number = 4;
    /**人物状态集合 */
    public static FRAMES:Array<any> = [
        ["0020003", "0020004", "0020005", "0020006","0020007"],
        ["0020008"],
        ["0020009", "0020010"],
        ["0020011", "0020012"],
        ["xue0001", "xue0002", "xue0003", "xue0004", "xue0005"]
    ]
    //身体
    private Body:egret.Bitmap;
    private state:number;
    private currFrames:Array<any>;
    private currFramesIndex:number = 0;
    private runFlag:number;
    private isLoop:boolean;

    public constructor() {
        super();
        this.Body = new egret.Bitmap;
        //人物初始状态
        this.Body = GameConst.createBitmapFromSheet("Role.FRAMES[0][0]", "Sprites");
        //设置锚点
        this.Body.anchorOffsetX = this.Body.width * 0.5;
        this.addChild(this.Body);
    }

    /**设置状态 */
    public setState(state:number) :void {
        this.state = state;
        //死亡状态
        if(this.state == Role.STATE5) {
            this.isLoop = false;
            this.Body.anchorOffsetY = this.Body.height * 0;
        }else{
            this.isLoop = true;
            this.Body.anchorOffsetY = this.Body.height * 1;
        }

        if(this.state == Role.STATE3 || this.state == Role.STATE4){
            this.currFrames = [];
            if(Math.random() > 0.5){
                this.currFrames.push(Role.FRAMES[this.state][0]);
            }else{
                this.currFrames.push(Role.FRAMES[this.state][3]);
            }
        }else{
            this.currFrames = Role.FRAMES[this.state];
        }
        this.currFramesIndex = 0;
        this.setBody();
    }

    private setBody() {
        this.Body.texture = GameConst.getTextureFromSheet(this.currFrames[this.currFramesIndex], "Sprites");
        this.Body.anchorOffsetX = this.Body.width * 0.5;
        if(this.state == Role.STATE5){
            this.isLoop = false;
            this.Body.anchorOffsetY = this.Body.height * 0;
        }else{
            this.isLoop = true;
            this.Body.anchorOffsetY = this.Body.height * 1;
        }
    }
    public run():boolean{
        this.runFlag ++;
        if(this.runFlag > 4){
            this.runFlag = 0;
        }
        if(this.runFlag != 0){
            return;
        }
        var gotoFrameIndex:number = this.currFramesIndex + 1;
        if(gotoFrameIndex == this.currFrames.length){
            if(this.isLoop){
                gotoFrameIndex = 0;
            }else{
                gotoFrameIndex = this.currFramesIndex;
            }
        }
        if(gotoFrameIndex != this.currFramesIndex){
            this.currFramesIndex = gotoFrameIndex;
            this.setBody();
        }
        return false;
    }

    public play():void{
        egret.startTick(this.run,this);
        this.runFlag = 0;
    }
    public stop():void{
        egret.stopTick(this.run,this);
    }
}

游戏场景

一切工作准备就绪,下面就是本文的重点-----游戏场景的搭建以及逻辑的实现。先看一下游戏内的主要内容

图片描述

首先是蓝色的游戏背景,和开始游戏界面背景如出一辙不用更换,在场景管理的时候注意背景保留一下继续使用。

其次分数、关卡、上背景图等等这些只需要调用常量类的获取纹理集图片的方法调整位置即可实现。

最后重点介绍一下内容:

墙体生成和运动

人物运动和状态切换

分数和关卡数改变并记录最高分数

下面是重要代码片段

墙体生成和运动

墙体分别包括上半部分和下半部分

/**上部分墙体容器 */
    private topContianer:egret.Sprite;
    /**下部分墙体容器 */
    private bottomContianer:egret.Sprite;

容器内又包含了上下部分的墙体图片,上下边界线

/**上下墙体填充图 */
    private topSprite:egret.Sprite;
    private bottomSprite:egret.Sprite;
    /**上下边界线 */
    private topLine:egret.Shape;
    private bottomLine:egret.Shape;

把填充图和边界线加到容器内(以上边界为例)

this.topContianer = new egret.Sprite();
    this.addChild(this.topContianer);
    this.topSprite = new egret.Sprite();
    this.topContianer.addChild(this.topSprite);
    this.topContianer.addChild(this.topLine);

定义一个top和bottom范围区间,随机在舞台范围内取值。

let min:number = 150;
        let flag:boolean = false;
        let len:number = 8;
        let w:number = GameConst.StageW / len;        
        for(let i:number = 0; i < len; i++) {
            var h:number = min + Math.floor(Math.random() * 8) * 10;
            this.bottomRects.push(new egret.Rectangle(i * w, GameConst.StageH - h, w, h));

            h = GameConst.StageH - h; 
            if (Math.random() < 0.2 || (!flag && i == len - 1)) {
                var index:number = Math.floor(Math.random() * this.spaceArr.length);
                h -= this.spaceArr[index];
                flag = true;
            }
            this.topRects.push(new egret.Rectangle(i * w, 0, w, h));
        }

这是随机取区域已经完成,不过都是理想的区域,并没有填充实际上的图片,下面写一个方法通过区域来填充背景墙。

private fullFront(bgSptite:egret.Sprite, rects:Array<egret.Rectangle>, isBottom:boolean = false):void {
        bgSptite.cacheAsBitmap = false;
        this.clearBg(bgSptite);
        var len:number = rects.length;
        for (var i:number = 0; i < len; i++) {
            var rec:egret.Rectangle = rects[i];
            var bitmap:egret.Bitmap;
            
            if (this.bgBitmaps.length) {
                bitmap = this.bgBitmaps.pop();
            } else {
                bitmap = new egret.Bitmap();
                bitmap.texture = this.bg;
            }
            bitmap.scrollRect = rec;
            bitmap.x = rec.x;
            bitmap.y = rec.y;
            bgSptite.addChild(bitmap);
        }
    }

关键代码bitmap.scrollRect = rec是把位图按照区域进行分割,显示对象的滚动矩形范围。显示对象被裁切为矩形定义的大小,当您更改 scrollRect 对象的 x 和 y 属性时,它会在矩形内滚动。

上下背景位图填充完毕,下面可是画上下边界线,同样是写了一个方法(以上边界为例),如下:

private drawLine():void {
        var lineH:number = 10;
        this.topLine.graphics.clear();
        this.topLine.graphics.lineStyle(lineH, 0x33E7FE);
        this.bottomLine.graphics.clear();
        this.bottomLine.graphics.lineStyle(lineH, 0x33E7FE);
        this.drawTopLine(lineH / 2);
        this.drawBottomLine(lineH / 2);
        this.topLine.graphics.endFill();
        this.bottomLine.graphics.endFill();
    }

    private drawTopLine(lineH:number):void {
        var len:number = this.topRects.length;
        for (var i:number = 0; i < len; i++) {
            var rec:egret.Rectangle = this.topRects[i];
            if (i == 0) {
                this.topLine.graphics.moveTo(rec.x, rec.height);
                this.topLine.graphics.lineTo(rec.x + rec.width, rec.height);
            } else {
                this.topLine.graphics.lineTo(rec.x, rec.height);
                this.topLine.graphics.lineTo(rec.x + rec.width, rec.height);
            }
        }
    }

此时,背景填充完毕,但墙体还不能运动。前面this.topContianer.y = -200;把上部分墙体的的纵轴设置在-200的位置,等到游戏开始执行Tween动画,使this.topContianer.y = 0,为了有更好的效果游戏开始延迟1.5s再调用墙体运动,Tween动画如下:

let self = this;
        setTimeout(function() {
            // self.shakeRun();
            //上面的模块往下运动
            egret.Tween.get(this.topContianer).to({"y":0}, 100).call(function():void {
            self.landOver();
            })
        }, 1500);

人物运动和状态切换

人物运动:给舞台添加点击事件,判断点击位置并移动。

/**点击事件 */
private onClick(e:egret.TouchEvent):void {
    let len:number = this.bottomRects.length;
    for(let i:number = 0; i < len; i++) {
        let rec:egret.Rectangle = this.bottomRects[i];
        if(e.stageX > rec.x && e.stageX < rec.x + rec.width) {
            this.setRolePos(i);
            break;
        }
    }
}

操作角色所在位置全部是根据上面定义的人物所在位置下标rolePosIndex的相对位置来决定的。

private setRolePos(index:number, offY:number = 17, offX:number = 0, isInit:boolean = false):void {
        if (!isInit) {
            //人物每次移动一个格子
            if (this.rolePosIndex > index) {
                index = this.rolePosIndex - 1;
            }
            else if (this.rolePosIndex < index) {
                index = this.rolePosIndex + 1;
            }
        }
        this.rolePosIndex = index;
        var rec:egret.Rectangle = this.bottomRects[index];
        //一次只移动一格
        this.role.x = rec.x + rec.width / 2 + offX;
        this.role.y = rec.y + offY;
    }

状态切换:

墙体运动完毕之后,通过人物所在位置下标找到上半部分墙体和下半部分墙体对应的位置的差值,并根据差值判断人物是否存活,如果存活应该表现出什么状态。

获取人物所在位置上下墙体的距离:

 privategetSpace():number{
       lettop:egret.Rectangle=this.topRects[this.rolePosIndex];
       letbottom:egret.Rectangle=this.bottomRects[this.rolePosIndex];
       returnGameConst.StageH-top.height-bottom.height;
  }

根据返回的距离差值判断人物的状态:

privatecheckState() {
       letspace:number=this.getSpace();
       if(space==0) {
           this.role.setState(Role.STATE5);
      } elseif(space==this.spaceArr[2]) {
           this.role.setState(Role.STATE4);
      } elseif(space==this.spaceArr[0]) {
           this.role.setState(Role.STATE3);
      } elseif(space==this.spaceArr[1]) {
           this.role.setState(Role.STATE2);
      }
       if(space==0) {
           this.setRolePos(this.rolePosIndex, -10, 4);
      }
  }

根据返回的距离判断游戏状态,若返回值为0,游戏结束;不为0,进入下一关:

/**检验这关结束主角是否存活 */
   privatecheckResult() {
       letspace:number=this.getSpace();
       letself=this;
       if(space==0) {
           this.dieNum++;
           if(this.dieNum==1) {
               this.role.stop();
               setTimeout(function() {
                   //游戏结束
                   GameControl.Instance.getGameOverDisplay().setGameOverDataHandler(self.score, self.curretMaxScore); 
                   GameControl.Instance.showGameOverSceneHandler();
              }, 500);
               
               return;
          }
      }
       //进入下一关
       else{
           this.curretLevel++;
           this.score+=10;
           if(this.score>this.curretMaxScore) {
               this.curretMaxScore=this.score;
          }
           //刷新成绩
           this.refreshScore();
      }
       setTimeout(function() {
           self.refurbish()
      }, 1000);
  }

分数和关卡

接着上一步此时如果人物存活进入下一关,那么就要刷新游戏成绩和关卡数,并检验此时是否为最高成绩:

/**刷新成绩数据 */
privaterefreshScore() {
this.LvNum.setData(this.curretLevel.toString());
this.recodeNum.setData(this.score.toString());
}

游戏进入下一关卡:

 /**刷新游戏关卡 */
   privaterefreshPoint() {
       this.initData();
       this.start();
  }

游戏结算界面
图片描述

游戏结算界面效果图

和开始界面差不多,有所不同的是需要从游戏场景中传入本局分数和做高分数,在这个页面写一个公开的setGameOverDataHandler方法,游戏结束是调用此方法传入数值。

/**游戏结束页面分数最高分数 */
publicsetGameOverDataHandler(score:number=0, maxScore:number=0):void{
this.scoreNum.setData(score.toString());
this.maxScore.setData(maxScore.toString());
}

扩展

墙体晃动效果

游戏场景内当墙体要下落的时候墙体会晃动一下,晃动墙体不仅提醒玩家这个墙体即将下落,同时也增加了这个游戏的可玩性,下面是控制墙体晃动的Shake类。

/**墙体晃动 */
classShake{
privateinitY:number;
privateshakeNum:number;
privateoverFunc:Function;
privateobj:egret.DisplayObject;
privatenum:number;
privateflag:number;
​
publicrun(obj:egret.DisplayObject, shakeNum:number, overFunc:Function=null) {
this.obj=obj;
this.initY=obj.y;
this.shakeNum=shakeNum;
this.overFunc=overFunc;
egret.startTick(this.loop, this);
this.num=0;
this.flag=0;
}
privateloop():boolean{
if(this.flag==0) {
if(this.obj.y<=this.initY) {
this.obj.y+=5;
} else{
this.obj.y-=5;
}
if(this.obj.y==this.initY) {
this.num++;
if(this.num==this.shakeNum) {
egret.stopTick(this.loop, this);
if(this.overFunc) {
this.overFunc();
}
}
}
}
this.flag++;
if(this.flag==2) {
this.flag=0;
}
returnfalse;
}
}

小结
本文通过对一个简单小游戏进行模块化的分析,并介绍了模块化的好处,降低这些模块之间的耦合度,后期如果要加新功能对旧的代码无需进行大的修改。


Mandy
66 声望7 粉丝

我是一名勤劳的搬运工