基于Pixi实现的Flappy Bird(1)--初始化游戏

1,237 阅读7分钟

Flappy Bird 是一款前不久风靡世界的休闲小游戏,虽然难度超高,但是游戏本身却非常简单。下面使用 PIXI 来快速开发 HTML5 版的 Flappy Bird

20230316154845.gif

初始化舞台

  • 由于图片素材的背景大小为 720x1280,所以初始化游戏舞台的大小为 720x1280
  • 并在舞台上设置一个主容器,游戏将会放入到主容器中
 <template>
   <canvas id="flappy" ref="pixi"></canvas>
 </template>
 data() {
   return {
     app: null, // pixi应用
     mainContainer: null, // 主容器
     game: null, // 游戏
     // pixi的宽高
     appData: {
       width: 720,
       height: 1280,
     }
   };
 },
 ​
 initApp() {
   this.app = new Application({
     width: this.appData.width, //宽
     height: this.appData.height, //高
     backgroundAlpha: 0, // 背景透明
     antialias: true, // 开启抗锯齿,使字体和图形边缘更加平滑
     resolution: 1, // 像素比
     view: this.$refs.pixi, // 引用渲染器的canvas元素
   });
   this.mainContainer = new Container(); // 初始化主容器
   this.app.stage.addChild(this.mainContainer);
 }
  • 至此为止,浏览器中会有一个宽720,高1280canvas

image-20230316104550743.png

场景分析

  • Flappy Bird 的场景大致可以划分以下几个部分:

    1. 背景和地面: 背景图和不断移动的地面是贯穿整个游戏,没有变化的

    2. 准备场景: 一个简单的游戏提示画面,游戏开始前和失败后重新开始都会进入此场景

    3. 游戏场景: 障碍物不断的从右往左移动,玩家控制小鸟的飞行,分数跟着变化

      • 分数: 玩家当前的分数
      • 小鸟: 玩家控制的精灵,是游戏的主角
      • 障碍物: 游戏中另一个重要的角色,障碍物由许多成对的上下管子组成
    4. 结束场景: 游戏失败后,显示得分以及相关按钮等

    5. 资源: 所有资源在游戏初始化时都预先加载

  • 通过分析,需要创建移动的地面准备和结束场景分数小鸟障碍物、以及一个预加载资源的类,最后封装成一个 Game

 Assets.js // 资源加载类
 ​
 Bird.js // 小鸟类
 ​
 Game.js // 游戏主类
 ​
 Ground.js // 地面类
 ​
 OverScene.js // 结束场景类
 ​
 Pipe.js // 障碍物类
 ​
 ReadyScene.js // 准备场景类
 ​
 Score.js // 分数类

预加载资源

  • 为了让玩家有更流畅的游戏体验,图片素材一般需要预先加载成纹理,需要使用 PIXILoader 加载器,对纹理进行加载
  • 其中需要加载地面、小鸟、准备和结束场景、障碍物、分数的纹理
  • 构建 Assets 类,让其继承 PIXILoader
  • 有些纹理需要使用矩形切割其 frame(帧) ,则需通过 new Texture(baseTexture, frame)
 import { Loader } from "pixi.js";
 class Assets extends Loader {
   constructor() {
     super();
     this.ground = null; // 地面
     this.bird = null; // 小鸟
     this.ready = null; // 准备场景
     this.score = null; // 分数
     this.over = null; // 结束场景
     this.pipe = null; // 障碍物
   }
 }
 ​
 export default Assets;
  • 声明 init 方法,该方法主要用于加载所有资源,并接收一个回调函数,在资源加载完成之后执行相关逻辑
 init(callback) {
   // 定义所需资源
   const resources = [
     { name: "ground", url: require("@/assets/images/bird/ground.png") },
     { name: "bird", url: require("@/assets/images/bird/bird.png") },
     { name: "ready", url: require("@/assets/images/bird/ready.png") },
     { name: "number", url: require("@/assets/images/bird/number.png") },
     { name: "over", url: require("@/assets/images/bird/over.png") },
     {
       name: "pipe",
       url: require("@/assets/images/bird/pipe-green.png"),
     },
   ];
   // 重置loader
   this.reset();
   // 添加资源
   this.add(resources);
   // 资源加载完成时执行load方法
   this.load((_, resources) => {
     utils.clearTextureCache(); // 先清理纹理缓存,否则纹理会重复添加
     this.loadAssets(resources); // 处理纹理
     callback?.(); // 执行相应的回调
   });
 }
  • 声明 loadAssets 方法,用于在纹理加载完成后对其进行处理
 loadAssets(resources) {
   // 地面
   this.ground = resources["ground"].texture;
   
   // 小鸟
   const birdFrames = [
     [0, 120, 86, 60],
     [0, 60, 86, 60],
     [0, 0, 86, 60],
   ];
   this.bird = birdFrames.map(
     (item) => new Texture(resources["bird"].texture, new Rectangle(...item)));
   
   // 准备场景
   this.ready = {
     tip: new Texture(resources["ready"].texture, new Rectangle(0, 0, 480, 158)),
     tap: new Texture(resources["ready"].texture, new Rectangle(0, 158, 286, 246)),
   };
   
   // 障碍
   this.pipe = {
     up: new Texture(resources["pipe"].texture, new Rectangle(148, 0, 148, 820)),
     down: new Texture(resources["pipe"].texture, new Rectangle(0, 0, 148, 820)),
   };
   
   // 得分
   const scoreX = [0, 61, 121, 191, 261, 331, 401, 471, 541, 611];
   this.score = scoreX.map(
     (item) => new Texture(resources["number"].texture, new Rectangle(item, 0, 60, 91)));
 ​
   // 结束场景
   this.over = {
     tip: new Texture(resources["over"].texture, new Rectangle(0, 298, 508, 158)),
     board: new Texture(resources["over"].texture, new Rectangle(0, 0, 590, 298)),
     startBtn: new Texture(resources["over"].texture, new Rectangle(590, 0, 290, 176)),
     rankBtn: new Texture(resources["over"].texture, new Rectangle(590, 176, 290, 176)),
   };
   
   // 背景
   this.bg = resources["bg"].texture;
 }

初始化游戏

  • 创建 Game.js 文件,其作为游戏的主模块,整合了游戏的全部内容
  • 在实例化 Game 类时,自定义游戏的显示宽高,和游戏装载的容器,所有需要传入一个对象 {width,height,stage}
  • 游戏中需要初始化游戏的所有元素,包括所有显示对象,以及游戏当前的状态和玩家的当前分数
 import Assets from "./Assets";
 import Ground from "./Ground";
 import ReadyScene from "./ReadyScene";
 import OverScene from "./OverScene";
 import Bird from "./Bird";
 import Score from "./Score";
 import Pipe from "./Pipe";
 ​
 class Game {
   constructor(options) {
     this.width = options.width; // 游戏呈现的宽度
     this.height = options.height; // 游戏呈现的高度
     this.stage = options.stage; // 装载游戏的容器
     
     this.assets = null;
     this.bird = null;
     this.bg = null;
     this.readyScene = null;
     this.score = null;
     this.pipe = null;
     this.ground = null;
     
     this.state = undefined; // 定义游戏状态,ready准备,running游戏中,over结束
     this.curScore = 0; // 定义玩家当前分数
     
     this.init(); // 初始化所需资源
   }
 }
  • 声明 init 方法,用于实例化 Assets 类加载所有资源,调用 Assets 类的 init() 方法加载纹理,并在纹理加载完成后创建游戏
 init() {
   this.assets = new Assets();
   this.assets.init(() => this.createGame());
 }
  • 声明 createGame 方法,用于初始化游戏的所有显示对象
 createGame() {
   // 初始化背景
   this.initBg();
   // 初始化地面
   this.initGround();
   // 初始化小鸟
   this.initBird();
   // 初始化游戏准备场景
   this.initScene();
   // 初始化分数
   this.initScore();
   // 初始化障碍物
   this.initGamePipe();
   // 游戏准备
   this.gameReady();
 }
  • 在游戏主模块 Game.js 中,为游戏的每个显示对象,声明属于自己的初始化方法
 // 初始化背景
 initBg() {}
 // 初始化地面
 initGround() {}
 ​
 // 初始化小鸟
 initBird() {}
 ​
 // 初始化分数
 initScore() {}
 ​
 // 初始化场景
 initScene() {
   // 初始化游戏准备场景
   //...
 ​
   // 初始化游戏结束场景
   //...
 }
 ​
 // 初始化游戏障碍物
 initGamePipe() {}

游戏背景

  • 由于背景是不变的,所以使用其加载好的纹理创建一个精灵实例,作为整体游戏的背景,并且初始化 pixi 时设置了背景透明
 initBg() {
   this.bg = new Sprite(this.assets.bg);
   this.stage.addChild(this.bg);
 }

image-20230316111132024.png

游戏地板

  • 地面处于画面最下端,但地面是从右到左的不断循环的移动着的
  • 所以地面需要使用平铺精灵,让其不断循环滚动
  • 创建 Ground 类,并且其继承于 PIXI 的平铺精灵 TilingSprite 类,该类需要纹理和宽高三个参数,另外需要知道舞台的高度,以确定地面的摆放位置
 import { TilingSprite } from "pixi.js";
 class Ground extends TilingSprite {
   static DELTA_X = 0.128; // 定义每帧移动的像素数
   constructor(options) {
     super(options.texture, 840, 281); // 将TilingSprite的参数传入
     
     this.gameHeight = options.height;
     const baseY = this.gameHeight - this.height; // 地面位置 = 游戏高度 - 地面精灵高度
 ​
     this.position.set(0, baseY);
     this.tilePosition.set(0, 0); // 设置平铺精灵纹理的偏移量
     this.viewportX = 0; // 记录当前视口位置
   }
 }
 ​
 export default Ground;
  • 完善 Game.js 中的 initGround 方法,实例化一个地面并添加到容器中
 initGround() {
   this.ground = new Ground({
     height: this.height,
     texture: this.assets.ground,
   });
   this.ground.zIndex = 2; // 这里设置层级,需要令装载它的父容器设置sortableChildren属性为true
   this.stage.addChild(this.ground); // 添加到舞台中
 }

image-20230316145400813.png

  • 现在通过不断更新地面精灵的偏移量,可以在水平或垂直方向上移动纹理,并使纹理环绕这个精灵,无需实际改变精灵的位置从而模拟滚动
  • 声明 setViewportX 方法,接收一个新的滚动位置 newViewportX 作为参数
 setViewportX(newViewportX) {
   const scrollX = newViewportX - this.viewportX; // 记录滚动的距离差
   this.viewportX = newViewportX; //  更新viewportX
   this.tilePosition.x -= scrollX * Ground.DELTA_X; // 利用差值使纹理不断偏移
 }
  • 声明 moveViewportBy 方法,用于不断更新精灵在视口的位置,从而达到改变精灵的纹理偏移量
  • 接收一个滚动单位 units 作为参数,使视口每帧滚动 units 个单位
 moveViewportBy(units) {
   const newViewportX = this.viewportX + units; // 更新视口位置
   this.setViewportX(newViewportX);
 }
  • 最后通过 PIXITicker 钟表,不断执行 moveViewportBy 方法,每秒执行 60
 import { TilingSprite, Ticker } from "pixi.js";
 class Ground extends TilingSprite {
   // ...
   // ...
   // ...
   Ticker.shared.add(() => this.moveViewportBy(20)); // 每帧移动20个单位,1个单位为0.128像素
 }
  • 最后在主方法 initApp 中将 Game 实例化就能看到地面在不断从右往左移动
 initApp() {
   //...
   //...
   
   this.mainContainer.sortableChildren = true; // 开启容器内children层级排序
   // 实例化游戏
   this.game = new Game({
     stage: this.mainContainer,
     width: this.appData.width,
     height: this.appData.height,
   });
   
   //...
   //...
 }

1678951142187 00_00_00-00_00_30.gif