乐趣区

教程文档-3D-跑酷小游戏实战开发

本教程带领大家从零开始开发一款完整的 3D 跑酷游戏,主要面向读者是有一定 Egret2D 开发经验的小伙伴,手把手教你学习 EgretPro 开发,快速开启您的 EgretPro 开发之旅。

下面是整个游戏的制作过程。

游戏制作

工欲善其事必先利其器,在开始制作游戏之前,您需要检查您是否安装了以下开发环境:

  1. 检查你的 EgretPro 是否更新到了 1.6 以及以上版本;
  2. 检查下是否安装了 5.3.7 以及以上的 Egret2d 引擎。

创建项目

首先,打开 EgretPro,项目 – 库项目 – 新建。

其次,创建完项目之后,EgretPro 会自动打开刚才创建的库项目 RunningGame,在 resource/scenes 目录下面创建一个场景 RunningScene,双击在场景编辑中打开该场景。如下图所示:

创建跑道

跑道是通过 3 个实体 Cude 拼接而成的,并且随着主角向前奔跑,跑道需要滚动起来,我们再这里通过组件的方式动态的创建跑道。

使用 Vscode 打开自己刚才创建的项目,然后在 src 目录下面创建一个文件夹 game 用来存放我们的组件代码,然后在 game 目录中创建 RoadController.ts 文件。

import {component} from "@egret/ecs";
import {Behaviour, GameEntity, EngineFactory, Vector3} from "@egret/engine";
import {MeshFilter, DefaultMeshes, MeshRenderer, Material} from "@egret/render";
import {ResourceManager} from "@egret/core";

@component()
class RoadController extends Behaviour{private roads:GameEntity[] = [];
    private roadSize:Vector3 = Vector3.create(5,1,10);
    async onStart(){const RoadMaterial:Material = (await ResourceManager.instance.loadUri("assets/materials/ground.mat.json")).data;

        // 创建 3 个实体拼接成跑道
        for (let i = 0; i < 3; i++) {const road:GameEntity = EngineFactory.createGameEntity3D("road"+i);
            road.addComponent(MeshFilter).mesh = DefaultMeshes.CUBE;
            road.addComponent(MeshRenderer).material = RoadMaterial;
            road.transform.localScale = this.roadSize;
            road.transform.setPosition(0,0,i*this.roadSize.z);
            this.roads.push(road);
            
        }
    }

    onUpdate(){}
}

然后把 RoadController 组件添加在场景的 Root 实体上,

最后点击预览(内置)(或者预览浏览器)按钮,就可以看到下面的效果了。

摄像机的这个角度看上去有点别扭,你可以在运行界面,通过调试摄像机的 TransForm 参数来调整摄像机的位置与角度达到画面看上去比较舒服。

添加主角

将主角添加到场景中,并且向前奔跑,效果图如下:

第一步,将美术同学给的游戏素材 BakedAnimation 拷贝到项目目录 resource/assets/animations 中。

第二步,在 EgretPro 编辑器资源管理器中打开 resource/assets/animations/BakedAnimation/Boy_standby_out 目录,拖拽 Boy_standby.gltf.prefab.json 文件到场景中。

第三步,播放主角奔跑动画。有两种处理办法:

方法 1:直接在 pro 编辑中进行操作。具体如下:

方法 2:在 pro 编辑中去掉勾选 autoPlay,通过脚本去实现,在 game 目录下面创建 PlayerController 文件;(记得把 PlayerController 组件挂在到主角实体上哦)

import {component} from "@egret/ecs";
import {Behaviour} from "@egret/engine";
import {Animation} from "@egret/animation";
@component()
class PlayerController extends Behaviour{onStart(){const animation = this.entity.getComponentInChildren(Animation);
        animation.play("Boy_run_0");
}
}

到这步为止,我们的主角就在跑道上奔跑起来了,但是我们还需要让我们的玩家往前移动,我们需要在 onUpdate 函数中不断的更新主角的位置。同时我们还需要让摄像机跟随这主角进行同速度的移动,否则你会发现你的主角在你的屏幕中越跑越远,越来越小啦~

import {component} from "@egret/ecs";
import {Behaviour} from "@egret/engine";
import {Animation} from "@egret/animation";
@component()
class PlayerController extends Behaviour{

...
onUpdate(dt){this.entity.transform.translate(0,0,this.moveSpeed*dt);
  this.camera.transform.translate(0,0,this.moveSpeed*dt);
}
}

随着主角往前移动,我们发现走着走着,我们脚下的跑道没了~

这个时候我们需要在主角向前移动的同时不断的往前铺路,打开我们的 RoadController.ts 组件,

@component()
class RoadController extends Behaviour{
         ...    

    onUpdate(){if(this.roads.length == 0) return;

        const currentRoad = this.roads[0];
              // 当玩家跑过跑道之后,将跑道再次利用
        if(this.player.transform.position.z > currentRoad.transform.position.z){const changeRoad = this.roads.shift();
            changeRoad.transform.setPosition(0,0, this.roads[this.roads.length-1].transform.position.z+this.roadSize.z);
            this.roads.push(changeRoad);
        }
    }
}

最后我们需要通过鼠标左键来控制主角左右移动。我们需要在 onUpdate 函数中,根据鼠标的移动来改变主角的位置。

@component()
class RoadController extends Behaviour{onUpdate(dt){

       
        let moveX = 0;

        this.entity.transform.translate(0,0,this.moveSpeed*dt);
        this.camera.transform.translate(0,0,this.moveSpeed*dt);

        const _leftMouse = this.inputManager.getInput(InputCode.LeftMouse);
        // 鼠标左键按下的时候记录下主角的位置
        if(_leftMouse.isDown){this.startPostionX = this.entity.transform.position.x;}
        // 获取鼠标移动的距离
        if(_leftMouse.isHold){const point = _leftMouse.entity.getComponent(Pointer);
            moveX = point.position.x - point.downPosition.x;
            
        }
        
        if (moveX) {
          // 增加一些跑道边界的限制
            let playerNextPostionX = this.startPostionX + moveX/100;
            if (playerNextPostionX > this.roadBound) {playerNextPostionX = this.roadBound;}

            if (playerNextPostionX < -this.roadBound) {playerNextPostionX = -this.roadBound;}
                        // 设置主角的位置
            this.entity.transform.setPosition( playerNextPostionX,
                this.entity.transform.position.y,
                this.entity.transform.position.z);
        }

        }
}

添加金币

预制体 是一个游戏对象及其组件的集合,目的是使游戏对象及资源能够被重复使用,相同的游戏对象可以通过一个 预制体 来创建,此过程可以理解为实例化

游戏中的金币散落在整个跑道上,在这里使用预制体来做金币是最合适的!

如何创建预制体?

  1. 打开(或者创建)resource/perfab 目录,然后在资源管理器模块,右键 –》创建预制体 coin.perfab.json。
  2. 双击刚刚创建的预制体,在层级栏中右键 –>3D–> 球体;
  3. 选中刚才创建的球体,在属性栏给其选择 material 项,选择,coin.mat.json;

首先, 我们先来创建一个金币组件 Coin.ts

import {component} from "@egret/ecs";
import {Behaviour, GameEntity} from "@egret/engine";

@component()
export class Coin extends Behaviour{// 因为金币需要在其他类中引用,所以需要将该类 export 出去}

我们的金币都是散落在跑道上的,所以每创建一截跑道,我们就在该跑道上随机创建一些金币,金币的数量是随机的,位置也是随机的。接下来,需要在 RoadController.ts 组件中添加如下逻辑代码:

import {component} from "@egret/ecs";
import {Behaviour, GameEntity, EngineFactory, Vector3} from "@egret/engine";
import {MeshFilter, DefaultMeshes, MeshRenderer, Material} from "@egret/render";
import {ResourceManager, serializedField, property, EditType} from "@egret/core";
import {Coin, CoinPool} from "./Coin";

@component()
class RoadController extends Behaviour{
        ...
    private coinPerfabUrl:string = "assets/perfab/coin.prefab.json";

    async onStart(){const RoadMaterial:Material = (await ResourceManager.instance.loadUri("assets/materials/ground.mat.json")).data;

        // 创建 3 个实体拼接成跑道
        for (let i = 0; i < 6; i++) {
            ...
            // 在每截跑道上创建金币
            this.createCoins(road);
            
        }
    }

    onUpdate(){if(this.roads.length == 0) return;
        const currentRoad = this.roads[0];       
        if(this.player.transform.position.z > currentRoad.transform.position.z){
           ...
            // 添加金币
            this.createCoins(changeRoad);
            
        }


    }


    async createCoins(road:GameEntity){
      // 数量随机
        const coindCnt = Math.floor(Math.random()*3) + 1;
        for (let i = 0; i < coindCnt; i++) {
            // 创建金币 --- 预制体
            coin = await EngineFactory.createPrefab(this.coinPerfabUrl) as GameEntity;
            coin.addComponent(Coin);
          
          // 位置随机
            coin.transform.setPosition(Math.random() * this.roadSize.x - this.roadSize.x / 2, 1, road.transform.position.z + i * 1.5)
        }
    }
}

这样子我们就可以在道路上面看见很多随机的金币啦!

但是如果是有一定开发经验的小伙伴肯定知道我们还需要一个金币池,来回收金币,避免一直创建金币。

接下来我们再来创建一个金币缓冲池。我直接在 Coin.ts 文件中添加一个 CoinPool 类。

// Coin.ts

export class CoinPool{static coinPool:GameEntity[] = [];}

当金币离开摄像机的渲染范围,我们就应该把金币放入到缓冲池中,同时金币实体不在参与渲染,使金币处于休眠状态。这些对金币的处理我们都放在系统类中去做。

系统便是来处理拥有一个或多个相同特征组件的实体集合的工具,其只拥有行为(即在系统中没有任何状态)。

我们系统类中获取所有的金币。那么如何在系统中获取所有的金币呢?

实体匹配器是用来定义具有某种组件特征的实体集合的规则,通过一个匹配器实例,就可以定义一个明确的实体集合的规则。

// 举列子,我们通过实体匹配器获取场景中所有具有 Transform 和 MeshRender 组件的实体集合
Matcher.create(GameEntity, true, Transform, MeshRenderer);

回归到我们的案例中,我们先创建一个金币系统类 CoinSystem.ts, 在其中,我们要获取所有的金币,然后在每一帧中检查金币是否被玩家甩到背后,不需要渲染了,那么我们就把它回收到金币池中。具体看下面的代码:

import {System, system, Matcher} from "@egret/ecs";
import {GameEntity} from "@egret/engine";
import {Coin, CoinPool} from "./Coin";


@system()
export class CoinSystem extends System{

    player:GameEntity = null;
    
  // 实体匹配器
    getMatchers(){// 获取所有金币,返回集合,返回的集合存储在 this.grounps[0]中
        return  [Matcher.create(GameEntity,true,Coin)
        ];
    }
  
    onFrame(){const coins = this.groups[0].entities as GameEntity[];
        for (const coin of coins) {
          // 如果金币远离了主角
            if(coin.transform.position.z < this.player.transform.position.z-5){
                coin.enabled = false;
                coin.getComponent(Coin).enabled = false;
                CoinPool.coinPool.push(coin);
            }
        }
    }

}

系统并不像组件直接添加到实体上就可以起作用,我们需要在组件注册系统。接下来,在 PlayerController 中,添加如下两行代码。

//PlayerController.ts 
...
onAwake(){Application.instance.systemManager.registerSystem(CoinSystem);
        Application.instance.systemManager.getSystem(CoinSystem).player = this.entity;
    }
...

当创建金币的时候,我们首先考虑从缓冲池中获取。修改 RoadController.ts 中 createCoins()函数

//RoadController.ts

async createCoins(road:GameEntity){const coindCnt = Math.floor(Math.random()*3) + 1;
        for (let i = 0; i < coindCnt; i++) {
            let coin:GameEntity = null;
            if(CoinPool.coinPool.length>0){
              // 获取缓冲池中的金币
                coin = CoinPool.coinPool.pop();
                coin.enabled = true;
                coin.getComponent(Coin).enabled = true;
                console.log("-----------");
                
            }else{
            // 创建金币 --- 预置体
            coin = await EngineFactory.createPrefab(this.coinPerfabUrl) as GameEntity;
            coin.addComponent(Coin);
            
            }

            coin.transform.setPosition(Math.random() * this.roadSize.x - this.roadSize.x / 2, 1, road.transform.position.z + i * 1.5)
        }
    }

处理碰撞

接下来,我们要做的就是处理金币与主角之间的碰撞了,我们需要检查每一个金币,如果和主角发生了碰撞,那么我们的金币就有一个缓动动画然后消失。那么对金币与主角碰撞处理的这一个行为我们还是放在 CoinSystem.ts 系统类中去处理。

//CoinSystem.ts
    onFrame(){
       ...

            // 判断玩家与金币的碰撞
            if (coin.transform.position.getDistance(this.player.transform.position)<1) {console.log("-----+++++");
                
                coin.getComponent(Coin).enabled = false;
                
                Tween.toPosition(coin.transform,1,{
                    y:3,
                    ease:Elastic.easeOut,
                    onComplete:()=>{
                        coin.enabled = false;
                        CoinPool.coinPool.push(coin);
                    }
                })    
            }

添加 UI 界面

到这一步,我们的 3D 场景就完成啦!

接下来,我们需要给案例添加一个 UI 界面,也就是要加入一些 2d 的内容。在这里我需要给读者讲解一下如何在 3d 场景中添加 UI 界面?具体参考这里:Egret2d 与 EgretPro 整合

第一步,将 3d 场景导出成第三方库 pro-library;

第二步,创建一个 2d 项目,将第三库 pro-library 添加到 2d 项目中;

第三步,升级 2D 项目,修改 index.html

egret.runEgret({
    renderMode: "webgl",
    audioType: 0,
calculateCanvasScaleFactor: function (context) {
const backingStore = context.backingStorePixelRatio ||
            context.webkitBackingStorePixelRatio ||
            context.mozBackingStorePixelRatio ||
            context.msBackingStorePixelRatio ||
            context.oBackingStorePixelRatio ||
            context.backingStorePixelRatio || 1;
return (window.devicePixelRatio || 1) / backingStore;
},
    pro: true, // 需要修改为 true, 以启动 EgretPro 环境
});

第四步,EgretPro 中的场景渲染为一个 egret.Texture 对象,Egret 引擎只需要将 egret.Texture 添加到 Bitmap 对象当中,即可渲染到舞台。

const texture = await egret.pro.createTextureFrom3dScene("assets/scenes/animations/test.scene.json", 640, 640);
const bitmap = new egret.Bitmap(texture);
this.addChild(bitmap);

结合本案例,我们处理完上面的第一、二、三步,我的项目目录如下图:

然后我点击开始游戏按钮,添加跑酷的场景。具体看 Main.ts 文件。

//Main.ts 文件 
private async onButtonClick(e: egret.TouchEvent) {const texture = await egret.pro.createTextureFrom3dScene("assets/scenes/RunningScene.scene.json",640,1136);
       const bitmap = new egret.Bitmap(texture);
       this.addChild(bitmap);
    }

但是这个时候,大家发现我们的主角无法左右移动了,这个时候需要我们将 2d 的触摸事件传递到 3d 场景中。那么就需要通过 egret.pro 去完成通信。

    //Main.ts

        private createGameScene(){
       //...
                this.addEventListener(egret.TouchEvent.TOUCH_BEGIN,this.onTouchBegin,this);
        this.addEventListener(egret.TouchEvent.TOUCH_MOVE,this.onTouchMove,this);
    }

    private startPostionX:number;

    private onTouchBegin(e:egret.TouchEvent){
        this.startPostionX = e.stageX;
          // 派发消息
        egret.pro.dispatch("2dTouchBegin",1);
    }

    private onTouchMove(e:egret.TouchEvent){
        const moveX = e.stageX - this.startPostionX;
      // 派发消息
        egret.pro.dispatch("2dTouchMove",1,moveX);

    }

那么我们需要在 3d 场景的 PalyerContrller 中接受消息并且处理消息。具体见下:

//PlayerController.ts  
onStart(){
      ...

        // 监听 2d 的消息
        Application.instance.egretProUtil.addEventListener("2dTouchBegin",1,this.touchBeginFrom2D,this);
        Application.instance.egretProUtil.addEventListener("2dTouchMove",1,this.touchMoveFrom2D,this);

    }

    touchBeginFrom2D(){this.startPostionX = this.entity.transform.position.x;}

    touchMoveFrom2D(messag:any){

        let moveX = this.startPostionX + messag/100;
        if(moveX > this.roadBound){moveX = this.roadBound}
        if(moveX < -this.roadBound){moveX = -this.roadBound;}

        this.entity.transform.setPosition(
            moveX,
            this.entity.transform.position.y,
            this.entity.transform.position.z
        );
    }

然后,我们控制台输入指令:egret run -a 就可以运行你的项目啦!

小结

恭喜你完成了使用 EgretPro 制作的第一款游戏,在这里你可以下载完整的游戏源码。

通过本示例你可以学到手势识别、动画系统、金币系统、碰撞处理、UI 系统、EgretPro 与 Egret2d 项目交互等知识点同时你也可以学习了解到 EgretPro 与 Egret2d 之间的交互 – 使用 EgretPro 制作 3D 场景,然后把制作好的 3D 场景导出为第三库的形式添加到 Egret2D 项目,在 Egret2D 项目中处理 UI 系统,使用 Egret.pro 中的方法进行 3D 与 2D 之间的通信。

最后,您还可以继续在本示例的基础上添加功能继续完善您的游戏。期待您的完美作品!

退出移动版