Pixi从零实现一个小游戏

2,883 阅读7分钟

dina.jpg

前言

最近因为要做h5游戏类型的项目,所以需要去看下游戏相关的前端框架,最终选择了pixi(懒得挑了),学习一个框架最快速的方式就是开发,于是我花了点时间,写了一个简单的小游戏,对pixi有了个大概的了解。应付一些简单的项目应该没有什么问题。

demo地址

  • PC端请调成手机模式
  • 图片资源为网络图片,仅供学习使用 游戏截屏
    image.png

前期准备

pixi基础

Pixi.js中文网 (huashengweilai.com) 中文文档比较简单,只包含了游戏的基础操作,不过这些对我来说已经够用了

1.舞台

使用pixi创建一个舞台,所有的游戏元素都会在这个舞台内展示

import * as Pixi from 'pixi.js';

const app = new Pixi.Application();
// 将舞台放到html内
document.body.appendChild(app.view)

2.资源加载

静态资源需要使用loader加载之后才能使用,我们直接引入资源创建精灵的话是没有任何效果的。对于静态资源过大的情况,我们可以等待loader全部加载完,在执行页面渲染。

import img from './assets/image/1.png';

// loader可链式调用,最后要执行load,否则并不会加载
// 加载资源时,建议最好给一个name,后面引用的时候比较方便
app.scene.loader.add('img', img).load();
// loader加载进度
app.scene.loader.onProgress.add(() => {
    console.log('loading。。。')
})
// loader加载完成
app.scene.loader.onComplete.add(() => {
    console.log('loading cpmplete')
})

3.创建精灵

在静态资源加载完成后,创建精灵,在舞台内显示,我们可以比较轻松的控制精灵的属性

// 创建精灵
const img = new Pixi.Sprite(app.scene.loader.resources['img'].texture);
// 指定精灵属性

// 宽高
img.width = 100;
img.height = 100;

// x,y轴位置
img.x = 100;
img.y = 100;

// 设置精灵的原点
img.anchor.x = 0.5;
img.anchor.y = 0.5;

// 设置精灵的缩放
img.scale.set(1);

//设置精灵的旋转角度
img.rotation = 1.2;

//设置精灵的透明度
img.alpha = 0.9

//将精灵放入舞台
app.scene.stage.addChild(img);

4. ticker

使用ticker循环系统,可以比较方便的做一些简单的动画效果,本质上和requestAnimationFrame类似

const ticker = Pixi.Ticker.shared;

const run = () => {
    img.x += 1;
    img.y += 1;
    if (img.x > 100) {
    // 将循环函数移出ticker
        ticker.remove(run);
    };
};
// 将循环函数加入ticker中
ticker.add(run);
// 停止ticker内所有的循环函数
ticker.stop();
// 开启循环
ticker.start();

5.文本元素

用Pixi.Text构造一个文本元素

const options = {
    fontFamily: "Arial",
    fontSize: 48,
    fill: "white",
};

const msg = new Pixi.Text('wenben', options);

// 修改文本内容
msg.text = 'wenben1';
// 修改样式
msg.style = {};

游戏设计

第一次做游戏,从简单的做起准没错,我选择做射击游戏,内容尽可能的简化,游戏思路及玩法如下:

  • 固定大炮位置,点击屏幕区域,炮口旋转至对应角度,并发出一个炮弹
  • 两种怪物类型,自上而下移动,超出屏幕后移除
  • 怪物1与炮弹发生一次碰撞,怪物1和炮弹消失,玩家得一分
  • 怪物2与炮弹发生一次碰撞,怪物变小,继续移动,炮弹消失,发生二次碰撞,怪物消失,玩家得两分
  • 左上角记分板

需要注意的事项:

  1. 怪物种类随机生成,以及对应的被击数(生命值)
  2. 怪物和炮弹的碰撞检测
  3. 记分板更新
  4. 炮管的旋转

明确了制作方向,那就开始动手做吧!

游戏开发

项目结构以及开发方式

这个项目使用了单例模式和面向对象(假),项目构建是js + webpack,本来使用ts写的,但是写着写着发现没什么必要,于是就转为了js。项目结构如下:

--- pixi-game  
   --- src  
        // 静态资源
       --- assets 
        // 常量
       --- constant  
        // 组件
       --- components  
        // 方法
       --- utils  
    // webpack配置
    --- config
    //  html
    --- public  
    // 入口
    --- index.js

创建全局app实例

一个游戏只有一个app实例,并且只能初始化一个,所以这里我用到了单例模式。之后所有的组件都会默认绑定这个实例到自身,方便调用app的方法

class Game {
    // 舞台的默认宽高
    stageWitdh = window.innerWidth;
    stageHeight = window.innerHeight;
    // 游戏的状态
    state = {
        reword: 0,
        play: false,
        over: false,
        paused: false,
    };
    // 创建记分板的时候,将其赋值给game
    msg = null;
    // 创建一个炮弹数组,碰撞检测用
    bullets = [];
    // 初始化pixi舞台
    scene = new Pixi.Application({
        width: this.stageWitdh,
        height: this.stageHeight,
        transparent: true,
        backgroundColor: 0x00000,
    });
    // 窗口改变时动态修改舞台大小
    rerender() {
        this.scene.view.width = this.window.innerWidth;
        this.scene.view.height = this.window.innerHeight;
    }
}

// 单例
function createGame() {
    let game;
    return () => {
        if (!game) {
            game = new Game();
        }
        return game;
    };
};

export default createGame();

大炮部分

大炮整体分为两部分,一个不动的底座,一个可以旋转的炮筒,需要注意的时,默认情况下,精灵的旋转是以左上角为基础的,这显然不满足我的大炮的转动,所以需要修改炮筒的原点,让它在转动时是基于底座圆心位置。

class Connon {
   ...
   instance = Game()
   ...
   constructor() {
      this.cannonBase = new Sprite('game_cannon_base');
      this.cannonBody = new Sprite('game_cannon_body');
      ......
      // 设置炮身的原点
      this.cannonBody.anchor.x = 0.5;
      this.cannonBody.anchor.y = 0.8;
      // 炮身原点的坐标,方便后面计算角度
      this.anchorPos = {
         x: window.innerWidth / 2,
         y: window.innerHeight - this.cannonBody.height * .2
      }
      // 设置一个安全高度,防止炮身随意旋转
      this.safeHeight = window.innerHeight - this.cannonBody.height*3/2
      // 开启监听
      this.listener();
   }

   listener() {
      window.addEventListener('touchstart', this.touchEvent.bind(this))
   }
    // touch事件
   touchEvent(e) {
       // 获取touch坐标
      const { clientX, clientY } = e.changedTouches[0];
      if (clientY > this.safeHeight) {
         return;
      }
      // 旋转角度的计算
      const x = this.anchorPos.x - clientX;
      const y = this.anchorPos.y - clientY;
      const rotate = this.cannonBody.rotation ? (+this.cannonBody.rotation).toFixed(2) : 0;
      let nextRotate = -(x/y).toFixed(2);
      ......
      // 旋转函数
      const run = () => {
         ......
         if (rotationStep ==  nextRotate.toFixed(2) || Math.abs(rotationStep) >= 1.2) {
            // 旋转到达指定的角度或者最大角度时,停止旋转,生成一个炮弹
            ticker.remove(run);
            ticker.addOnce(() => {
               new Bullet({
                  rotation: nextRotate,
                  y: this.anchorPos.y,
               })}
            );
            return;
         }
         ......
         this.cannonBody.rotation = rotationStep;
      }

      ticker.remove(run);
      ticker.add(run);

   }

}
export default Connon;

炮弹部分

当旋转停止时,需要生成一个炮弹并向指定角度发射,炮弹旋转的角度和炮身相同,当炮弹未命中目标且超出屏幕后,自动移除,

class Bullet {
   // 炮弹生成比较简单,删掉了
   // 重点是对自身的检测
   run(r) {
      let stepX = r;
      let stepY = -1;
      const move = () => {
         stepX += r;
         stepY += -1;
         this.bullet.x += stepX;
         this.bullet.y += stepY;

         if (this.bullet.x < -this.bullet.width || this.bullet.y < -this.bullet.height) {
            ticker.remove(move);
         }
      }
      
      const remove = () => {
         ticker.remove(move);
         this.instance.scene.stage.removeChild(this.bullet);
      }
      // 给炮弹自身绑定一个移除方法,后面会用到
      this.bullet.remove = remove;
      ticker.add(move);
   }
}

export default Bullet;

怪物部分

这个项目我找了两个怪物的图片,在创建时使用随机数,随机创建一个类型的怪物,怪物从不同的高度落下,造成延迟感,在怪物运动的途中,进行碰撞检测,通过拿到当前屏幕上的所有子弹进行坐标比对,进而做出对应操作:

class Monster{
    instance = Game()
    speed = 6
    // 怪物信息
    monsterNames = [
        {name: 'game_monster_b', hit: 2, scale: 1.8, reword: 2}, 
        {name: 'game_monster_s', hit: 1, scale: 1, reword: 1}
    ]
    hitCount = 0;
    alphaStep = 0.1;
    constructor(props) {
        this.init();
    }
    init() {
        // 随机取一个索引
        const randomIndex =Math.floor( Math.random() * 1 + 0.4);
        const info = this.monsterNames[randomIndex];
        this.ms = new Sprite(info.name);
        this.ms.info = info;
        ......
        // 设置随机的y轴坐标
        this.ms.y = Math.random() * 200 - 400;
        this.instance.scene.stage.addChild(this.ms);
        this.run();
    }

    run() {
        // 移动方法
        const move = () => {
            this.ms['y'] += this.speed;
            // 判断是否有子弹
            if (this.instance.bullets.length) {
                for(let i = 0; i < this.instance.bullets.length; i++) {
                    // 进行碰撞检测
                    const isHit = bulletHit(this.ms, this.instance.bullets[i]);
                    if (isHit) {
                        // 出现碰撞,移除炮弹
                        this.hitCount += 1;
                        this.instance.bullets[i].remove();
                        this.instance.bullets.splice(i, 1);
                        i --;
                        // 判断是否超过怪物的最大碰撞数
                        if (this.hitCount >= this.ms.info.hit) {
                            // 移除怪物,执行销毁函数
                            ticker.remove(move);
                            this.destory();
                            return;
                        }
                    }
                }
            }
            // hit数两次的做一个简单的动画
            if (this.hitCount) {
                this.ms.alpha = 0.8;
                this.ms.scale.set(SCALE);
            }
            // 超出屏幕,移除
            if (this.ms.y >= window.innerHeight) {
                ticker.remove(move);
                this.instance.scene.stage.removeChild(this.ms);
            }
        }
        ticker.add(move);
    }
    destory() {
        this.instance.state.reword += this.ms.info.reword;
        this.instance.msg.update();
        this.instance.scene.stage.removeChild(this.ms);
    }
}
export default Monster;

碰撞函数

碰撞检测是游戏内不可缺少的一部分,有很多游戏引擎都自带碰撞检测方法,但是pixi里是没有的,需要我们自己开发。
本次的游戏开发只涉及了两个物体的碰撞,怪物和炮弹,炮弹的原点设置为图形中心,我们只需要在怪物的移动过程中判断子弹的中心点是否在怪物身体内,方法实现如下:

export const bulletHit = (m, b) => {
    const m_left = m.x;
    const m_right = m.x + m.width;
    const m_top = m.y;
    const m_bottom = m.y + m.height;
    // 只要让子弹的中心点坐标包含在怪物身体坐标内,就认为两者发生了碰撞
    return m_left < b.x && m_right > b.x && m_top < b.y && m_bottom > b.y;
}

记分板

记分板比较简单,创建一个text,每次发生怪物被击死亡是,分数增加,调用text的更新方法,显示最新的分数。为了方便调用,初始化的记分板直接挂载到了全局app上。

class Text {
    instance = Game();
    constructor(options = {}) {
        this.text = new Pixi.Text(`总分:${this.instance.state.reword}`, {
            fontFamily: "Arial",
            fontSize: 48,
            fill: "white",
        });
        this.instance.msg = this;
        this.instance.scene.stage.addChild(this.text);
    }
    update() {
        this.text.text = `总分:${this.instance.state.reword}`;
    }
}

完成

游戏基本开发完成,虽然操作起来比较僵硬,炮身旋转依然有bug存在,但是作为个人学习开发的第一个项目,我已经成功迈出了第一步。从工作中学到了新的技术栈,提升了自己的能力,拓展了知识储备。

有兴趣的可以直接到gitlab上下载源码(代码可能有些混乱,里面有些我测试的东西),不足的地方,希望各位大佬能指点一下。感谢!