基于Pixi实现的Flappy Bird(3)--初始化障碍物

693 阅读5分钟
  • 到目前为止,已经实现了小鸟的飞行,接下来开始初始化障碍物,让障碍物不断从右到左移动

创建障碍

  • 障碍是游戏中另一个非常重要的角色,整个障碍由许多成对的上下管子组成,障碍只需要显示和变换位置,所有使用容器 Container 类来创建障碍 Pipe

  • 创建 Pipe.js 文件,实现一个 Pipe 类,继承自 Container,装载障碍物的容器宽度由障碍物的总数和每个障碍物的宽度决定,并且障碍物上下之间也需要固定的间隔,障碍物水平之间也需要有固定的间隔,所有这里要先声明5个常量:

    • 障碍物总数 COUNT(一个障碍物由上下两部分管子组成)
    • 每个障碍物的宽度 WIDTH
    • 障碍物的水平间隔 SPACE_X
    • 上下管子之间的间隔 SPACE_Y
  • 这些障碍物是不间断从右到左移动的,并且样式不变,所以最好能重复利用已有的障碍物,可以节省很多内存

  • 因此,当障碍物有一半移出屏幕左侧时,可以将其重新排布到装载容器后面,并且重新规划位置,这样就可以复用已有的管子,并且位置也会重新调整,让玩家看起来就会有源源不断的并且不重复管子

  • 所以需要多添加一个常量:移出屏幕左侧的障碍物数量 NUM_OFF_PIPES

初始化障碍

 
 import { Container, Sprite } from "pixi.js";
 import { Ease } from "pixi-ease";
 class Pipe extends Container {
   static COUNT = 4; // 障碍物总数(一根管子分上下两部分),4个障碍物也就是8根管子
   static SPACE_X = 300; // 管子之间的水平间隔
   static SPACE_Y = 290; // 管子上下两部分之间的垂直间隔,即小鸟要穿越的空间大小
   static WIDTH = 148 + Pipe.SPACE_X; // 每个障碍物的宽度(包括管子之间的间隔)
   static NUM_OFF_PIPES = Pipe.COUNT * 0.5; // 移出屏幕左侧的障碍物数量,为障碍物总数的一半
   
   constructor(options) {
     super();
     this.texture = options.texture;
     this.groundY = options.groundY; // 地面高度
     this.startX = options.startX; // 障碍物的起始位置
     this.gameHeight = options.gameHeight; // 游戏整体高度
    
     this.width = Pipe.WIDTH * Pipe.COUNT; // 初始化障碍容器的总宽度
     this.passPipe = 0; // 记录小鸟穿越的管子数量
     
     this.createPipe(); // 创建障碍物
     
     this.ease = new Ease(); // 实例化一个缓动实例,后面移动障碍需要使用
   }
 }
 ​
 export default Pipe;
  • 声明 createPipe 方法,用于创建障碍物,通过确定障碍物的总数量,循环创建组成障碍物的上下部分管子,并且将其位置摆放好
 createPipe() {
   for (let i = 0; i < Pipe.COUNT; i++) {
     // 创建上部分管子
     const downPipe = this.initPipe(this.texture.down, "downPipe");
     this.addChild(downPipe);
     // 创建下部分管子
     const upPipe = this.initPipe(this.texture.up, "upPipe");
     this.addChild(upPipe);
     // 摆放上下部分管子
     this.placePipe(downPipe, upPipe, i);
   }
 }
  • 声明 initPipe 方法,用于将管子的纹理创建成精灵,并将创建的精灵返回
 initPipe(texture, spriteName = undefined) {
   const sprite = new Sprite(texture);
   sprite.scale.set(0.8, 0.8);
   sprite.name = spriteName;
   return sprite;
 }
  • 声明 placePipe 方法,用于确定上下管子的 xy 位置,下部分管子需要保证两点

    • 下部分管子 Y 轴最大值: 地面 Y 坐标 - 180(保证不埋没于地面)
    • 下部分管子 Y 轴最小值: 地面 Y 坐标 - 管子高度 + 与上部分管子的间距(保证不脱离地面)
  • 保证这两点后,下部分管子在最大值与最小值直接随机一个 Y 坐标即可,而 X 坐标为障碍物的宽度倍数

  • 确定下部分管子之后,上部分管子的 x 坐标和下部分一致y 坐标与下部分管子相隔 SPACE_Y 的距离

image-20230322161458659.png

placePipe(down, up, index) {
  // 下部分管子Y轴最大值(地面Y坐标 - 180,保证不埋没于地面)
  const downMaxY =this.groundY - 180;
  // 下部分管子Y轴最小值(地面Y坐标 - 管子高度 + 间距,保证不脱离地面)
  const downMinY = this.groundY - down.height + Pipe.SPACE_Y;
  // 随机下部分管子Y轴坐标
  down.y = downMinY + (downMaxY - downMinY) * Math.random();
  // 确定下部分管子X坐标
  down.x = Pipe.WIDTH * index;
  // 确定上部分管子Y坐标(下部分管子-间距)
  up.y = down.y - up.height - Pipe.SPACE_Y;
  // 确定下部分管子X坐标
  up.x = down.x;
}
  • 完成以上内容之后,在 Game.js 中完善 initGamePipe方法,将障碍物实例化,并添加到舞台上
initGamePipe() {
  this.pipe = new Pipe({
    gameHeight: this.height, // 游戏高度
    startX: this.width + 200, // 起始位置
    groundHeight: this.ground.height, // 地面高度
    texture: this.assets.pipe, // 所需纹理
  });
  this.stage.addChild(this.pipe);
}
  • 实例化后,将看到障碍物如下图所示

image-20230322172744075.png

  • 现在需要声明一个 reset 方法,用于初始化障碍物的起始位置,并且记录穿越障碍物的数量
 reset() {
   this.x = this.startX;
   this.passThrough = 0; // 记录穿越障碍物的数量
 }
  • Game.jsgameReady 方法中,调用一下 Pipe 类的 reset 方法,那么管子就会在当前视口之外
 gameReady() {
   this.readyScene.visible = true;
   // 重置障碍
   this.pipe.reset(); // 添加此句
   this.state = "ready";
   this.bird.birdReady();
 }

移动障碍

  • 障碍初始化完成后,其最核心的状态就是从右至左不断的移动,管子也不断的产生

  • 先声明 startMove 方法,通过缓动库 Ease 让其不断移动,移动则需要确定两个因素

    • 目标位置: 首先确定装载障碍物的整个容器的初始位置为视口宽度+200,目标就是让整个容器的一半移出视口左边,则目标位置为 2 个障碍物宽度(包括水平间隔)
    • 过渡时间:1ms 移动 4px 为基准,总体时间就是当前位置与目标位置的差值 * 4
 startMove() {
   const targetX = -Pipe.WIDTH * Pipe.NUM_OFF_PIPES; // 移动到的目标位置
   const duration = (this.x - targetX) * 4; // 过渡时间
   this.ease.add(this, { x: targetX }, { duration, ease: "linear" }); // 启动
 }
  • 当用户点击屏幕时,会调用 Game.js 中的 gameStart 方法,所有要在该方法中同时调用 Pipe.startMove() 让管子移动起来
 gameStart() {
   this.readyScene.visible = false;
   this.state = "running";
   this.pipe.startMove(); // 添加此句
   
   //...
 }

20230322180103 .gif

  • 当有一半的障碍物移出视口时,则到达了目标位置,此时需要重新排布障碍物的位置

  • 声明 resetPipe 方法,用于重新排布所有障碍物,从而达到重复利用管子的目的

    1. ①首先把移出视口的障碍物,重新放到队列的最后面
    2. ②然后重新排布所有障碍物的位置
    3. ③将装载容器重新设置到视口的开始处
    4. ④让容器重新开始移动

image-20230322201226962.png

 // 重置管子
 resetPipe() {
   const total = this.children.length;
   
   // 把已移出屏幕外的管子放到队列最后面,并重置其可穿越位置
   for (let i = 0; i < Pipe.NUM_OFF_PIPES; i++) {
     const downPipe = this.getChildAt(0);
     const upPipe = this.getChildAt(1);
     // 把移出的管子移动到队列最后
     this.setChildIndex(downPipe, total - 1);
     this.setChildIndex(upPipe, total - 1);
     // 重新将管子摆放到视口内
     this.placePipe(downPipe, upPipe, Pipe.NUM_OFF_PIPES + i); 
   }
 ​
   // 重新确定队列中所有管子的x轴坐标;
   for (let i = 0; i < total - Pipe.NUM_OFF_PIPES * 2; i++) {
     const pipe = this.getChildAt(i);
     pipe.x = Pipe.WIDTH * Math.floor(i * 0.5);
   }
 ​
   this.x = 0; // 重新确定障碍物的x轴坐标
   this.passPipe += Pipe.NUM_OFF_PIPES; // 记录穿过的障碍物数量,后面会用到
   this.startMove(); // 重新移动
 }
  • 那么什么时候调用 resetPipe 方法?当一轮移动完成时,立刻调用该方法对障碍物重置即可
  • 所以需要监听 ease 实例的 onComplete 事件,在回调中执行 resetPipe
 class Pipe extends Container {
     //...
     // 设置缓动
     this.ease = new Ease();
     this.ease.on("complete", () => {
       this.resetPipe();
     });
   }
  • 此时,障碍物即可不断从右往左移动,并且不会占用过多内存

20230322202748.gif

停止障碍

  • 停止移动障碍就只需停止缓动即可,需要声明一个 stopMove 方法,用于后面游戏结束时停止移动
 stopMove() {
   this.ease.destroy();
 }