12

之前看到一个指尖冒险游戏,觉得挺有意思,就想学习一下怎么实现,毕竟当产经提出类似的需求时,问我等开发可不可以实现的时候,不至于回答不知道。
本文的主要思路,参考的是凹凸实验室的这篇文章:H5游戏开发:指尖大冒险,通过这篇文章和代码,学习游戏搭建的整体思路和关键技术点。通过CreateJS的中文教程,学习CreateJS的基础,然后不清楚的api,就翻文档。
点击这里可以试玩游戏

CreateJS介绍

想大概知道CreateJS的构成、各个部分的功能以及常用的api,可以参看这篇文章
CreateJS 中包含以下四个部分:

  • EaselJS:用于 Sprites、图形和位图的绘制,是操作canvas的核心库
  • TweenJS:用于做动画效果
  • SoundJS:音频播放引擎
  • PreloadJS:网站资源预加载,提供加载的进度回调,以及资源获取

EaselJS常用方法

EaselJS是对canvas api的封装,便于我们操作canvas绘制图形图案。EaselJS定义了很多类型供我们使用。

Stage类

Stage类,是用来实例化一个舞台,其实是对canvas元素的包装,一个canvas元素对应这个一个stage,我们最终的元素都要使用addChild方法,添加到stage上面。

const canvas = document.querySelector('#canvas');
//创建舞台
const stage = new createjs.Stage(canvas);

Shape类

Shape类用来绘制图形,每绘制一个图形都要new一个Shape对象,对象继承很多方法可以链式调用,使用起来相当方便,比如我们要绘制一个圆形,只需要如下简单的代码即可完成

//创建一个Shape对象
const circle = new createjs.Shape();
//用画笔设置颜色,调用方法画矩形,矩形参数:x,y,w,h
circle.graphics.beginFill("#f00").drawCircle(0, 0, 100);
//添加到舞台
stage.addChild(circle);
//刷新舞台
stage.update();

其中graphics其实是Graphics类的一个实例,包含了后面的诸多方法。

Bitmap、SpriteSheet

这两个类都是用来操作图片的,Bitmap用来绘制单张图片到stage,SpriteSheet可以比作css里的雪碧图,可以用来在一张图片里提取出多个sprite图,也可以方便制作图片帧动画。
比如游戏中我们要使用树叶图片,就如下加入

const img = new Image();
img.src = './imgs/leaf.png';
let leaf = null;
img.onload = () => {
    leaf = new Createjs.Bitmap('./imgs/leaf.png');
    stage.addChild(leaf);
    stage.update();
}

上面因为要确保图片加载之后再渲染到stage上,所以步骤比较麻烦,PreloadJS提供给我们更加易用的预加载方法,上面代码就可以修改如下:

const queue = new createjs.LoadQueue();

queue.loadManifest([
  { id: 'leaf', src: require('./imgs/leaf.png') },
]);

let leaf = null;
queue.on('complete', () => {
  leaf = new createjs.Bitmap(preload.getResult('leaf'));
  stage.addChild(leaf);
  stage.update();
});

SpriteSheet则可以用来方便操作雪碧图,比如游戏中,障碍物和阶梯其实都在一张雪碧图上,通过如下的方式,我们可以方便的获取到想要的sprite,如下我们要获取阶梯:

const spriteSheet = new createjs.SpriteSheet({
      images: [preload.getResult('stair')],
      frames: [
        [0, 0, 150, 126],
        [0, 126, 170, 180],
        [170, 126, 170, 180],
        [340, 126, 170, 180],
        [510, 126, 170, 180],
        [680, 126, 170, 180],
      ],
      animations: {
        stair: [0],
        wood: [1],
        explosive: [2],
        ice: [3],
        mushroom: [4],
        stone: [5],
      },
    });

 const stair = new createjs.Sprite(spriteSheet, 'stair');

同时使用它可以方便制作帧动画,比如机器人的跳跃动画:

const spriteSheet = new createjs.SpriteSheet({
      images: [prelaod.getResult('player')],
      frames: {
        width: 150,
        height: 294,
        count: 17,
      },
      animations: {
        work: [0, 9, 'walk', 0.2],
        jump: [10, 16, 0, 0.5],
      },
    });
const sprite = new createjs.Sprite(spriteSheet);
sprite.gotoAndPlay('jump');

Container类

Container类,用来新建一个容器对象,它可以包含 Text 、 Bitmap 、 Shape 、 Sprite 等其他的 EaselJS 元素,多个元素包含在一个 Container 中方便统一管理。比如游戏中floor对象和robot对象,其实会被添加进同一个container,保证floor和robot始终在屏幕的中央。

const contain = new createjs.Container();
contain.addChild(floor, robot);
stage.addChild(contain);

stage刷新

舞台的刷新要调用update,但始终手动调用不太可能,我们一般在createjs里面的ticker事件中调用,每触发一次tick事件,就update一下舞台

createjs.Ticker.addEventListener(“tick”, tick);
function tick(e) {
    if (e.paused !== 1) {
        //处理
        stage.update();  //刷新舞台
    }else {}
}
createjs.Ticker.paused = 1;  //在函数任何地方调用这个,则会暂停tick里面的处理
createjs.Ticker.paused = 0;  //恢复游戏
createjs.Ticker.setFPS(60); // 用来设置tick的频率

TweenJS

tweenjs主要是负责动画处理,比如游戏中树叶的位移动画如下:

createjs.Tween.get(this.leafCon1, { override: true })
                    .to({ y: this.nextPosY1 }, 500)
                    .call(() => { this.moving = false; });

overrider设置为true,是为了保证该对象在执行当前动画的时候没有别的动画在执行,to将leafCon1的y坐标设为nextPosY1,call是动画执行完毕后的回调。
在编写游戏过程成,常用到的api大概就这么多,还有很多用法,需要的时候查阅文档就行了。

游戏的实现

整个游戏按照渲染层次划分为景物层、阶梯层、背景层。每个层面上,只需关注自身的渲染,以及暴露给控制层的逻辑接口。
我们将游戏拆分成4个对象,树叶类Leaves用来负责渲染无限滚动效果的树叶背景;阶梯类Floor用来渲染阶梯和障碍物,自身实现阶梯的生成和掉落方法;机器人类Robot用来渲染机器人,自身实现左跳、右跳、掉落和撞上障碍物的逻辑处理;Game类用来控制整个游戏的流程,负责整个舞台的最终渲染,组合各个对象的逻辑操作。

Leaves

对于景物层,用来渲染两边的树叶,树叶的渲染比较简单,只是将2张树叶图片渲染到canvas,在createjs里面我们所有的实例,都是通过addchild的方法,添加到stage上面。2张图片我们分别用Bitmap创建,设置好相应的x坐标(一个紧贴屏幕左边,一个紧贴右边),同时将2个bitmap实例,添加到container里面,以便作为一个整体进行操作。因为景物层需要做出无限延伸的效果,所以需要拷贝一个container制造不断移动的假象,具体原理参看指尖大冒险。在每次点击事件里,调用translateY(offset),就可以让树叶移动一段距离。

class Leaves {
  constructor(options, canvas) {
    this.config = {
      transThreshold: 0,
    };
    Object.assign(this.config, options);

    this.moving = false;
    this.nextPosY1 = 0;
    this.nextPosY2 = 0;
    this.canvas = canvas;
    this.leafCon1 = null; // 树叶背景的容器
    this.leafCon2 = null;
    this.sprite = null;
    this.leafHeight = 0;
    this.init();
  }

  init() {
    const left = new createjs.Bitmap(preload.getResult('left'));
    const right = new createjs.Bitmap(preload.getResult('right'));
    left.x = 0;
    right.x = this.canvas.width - right.getBounds().width;
    this.leafCon1 = new createjs.Container();
    this.leafCon1.addChild(left, right);
    this.leafHeight = this.leafCon1.getBounds().height;
    this.nextPosY1 = this.leafCon1.y = this.canvas.height - this.leafHeight; // eslint-disable-line
    this.leafCon2 = this.leafCon1.clone(true); //  //某些createjs版本这个方法会报 图片找不到的错误
    this.nextPosY2 = this.leafCon2.y = this.leafCon1.y - this.leafHeight; // eslint-disable-line
    this.sprite = new createjs.Container();
    this.sprite.addChild(this.leafCon1, this.leafCon2);
  }

  tranlateY(distance) {
    if (this.moving) return;
    this.moving = true;
    const threshold = this.canvas.height || this.config.transThreshold;
    const curPosY1 = this.leafCon1.y;
    const curPosY2 = this.leafCon2.y;
    this.nextPosY1 = curPosY1 + distance;
    this.nextPosY2 = curPosY2 + distance;

    if (curPosY1 >= threshold) {
      this.leafCon1.y = this.nextPosY2 - this.leafHeight;
    } else {
      createjs.Tween.get(this.leafCon1, { override: true })
                    .to({ y: this.nextPosY1 }, 500)
                    .call(() => { this.moving = false; });
    }

    if (curPosY2 >= threshold) {
      this.leafCon2.y = this.nextPosY1 - this.leafHeight;
    } else {
      createjs.Tween.get(this.leafCon2, { override: true })
                    .to({ y: this.nextPosY2 }, 500)
                    .call(() => { this.moving = false; });
    }
  }
}

Floor

阶梯类用来负责阶梯的生成,以及障碍物的生成,同时也要负责阶梯掉落的逻辑。

class Floor {
  constructor(config, canvas) {
    this.config = {};
    this.stairSequence = []; //阶梯渲染对应的序列
    this.barrierSequence = []; //障碍物渲染对应的序列
    this.stairArr = []; //阶梯的spite对象数组 
    this.barrierArr = []; //障碍物的spite对象数组
    this.barrierCon = null; // 障碍物容器
    this.stairCon = null; // 阶梯容器
    this.canvas = canvas;
    this.lastX = 0; // 最新一块阶梯的位置
    this.lastY = 0;
    this.dropIndex = -1;
    Object.assign(this.config, config);
    this.init();
  }

  init() {
    this.stair = new createjs.Sprite(spriteSheet, 'stair');
    this.stair.width = this.stair.getBounds().width;
    this.stair.height = this.stair.getBounds().height;

    let barriers = ['wood', 'explosive', 'ice', 'mushroom', 'stone'];
    barriers = barriers.map((item) => {
      const container = new createjs.Container();
      const st = this.stair.clone(true);
      const bar = new createjs.Sprite(spriteSheet, item);
      bar.y = st.y - 60;
      container.addChild(st, bar);
      return container;
    });

    this.barriers = barriers;

    const firstStair = this.stair.clone(true);
    firstStair.x = this.canvas.width / 2 - this.stair.width / 2; //eslint-disable-line
    firstStair.y = this.canvas.height - this.stair.height - bottomOffset;//eslint-disable-line
    this.lastX = firstStair.x;
    this.lastY = firstStair.y;

    this.stairCon = new createjs.Container();
    this.barrierCon = new createjs.Container();
    this.stairCon.addChild(firstStair);
    this.stairArr.push(firstStair);
    this.sprite = new createjs.Container();
    this.sprite.addChild(this.stairCon, this.barrierCon);
  }

  addOneFloor(stairDirection, barrierType, animation) {
    //stairDirection  -1 代表前一个阶梯的左边,1右边
    //逐一添加阶梯,每个添加一个阶梯,对应选择添加一个障碍物
  }

  addFloors(stairSequence, barrierSequence) {
    stairSequence.forEach((item, index) => {
      this.addOneFloor(item, barrierSequence[index], false); // 批量添加无动画
    });
  }

  dropStair(stair) {
   //掉落摸一个阶梯,同时掉落障碍物数组中y轴坐标大于当前掉落阶梯y轴坐标的障碍物
  }

  drop() {
    const stair = this.stairArr.shift();

    stair && this.dropStair(stair); // eslint-disable-line

    while (this.stairArr.length > 9) {
      this.dropStair(this.stairArr.shift()); //阶梯数组最多显示9个阶梯
    }
  }
}

Robot

Robot类用来创建机器人对象,机器人对象需要move方法来跳跃阶梯,同时也需要处理踏空和撞到障碍物的情况。

class Robot {
  constructor(options, canvas) {
    this.config = {
      initDirect: -1,
    };
    Object.assign(this.config, options);
    this.sprite = null;
    this.canvas = canvas;
    this.lastX = 0; //上一次x轴位置
    this.lastY = 0;// 上一次y轴位置
    this.lastDirect = this.config.initDirect; //上一次跳跃的方向
    this.init();
  }

  init() {
    const spriteSheet = new createjs.SpriteSheet({
      /* 机器人sprites */
    });
    this.sprite = new createjs.Sprite(spriteSheet);
    const bounds = this.sprite.getBounds();
    this.sprite.x = this.canvas.width / 2 - bounds.width / 2;
    this.lastX = this.sprite.x;
    this.sprite.y = this.canvas.height - bounds.height - bottomOffset - 40;
    this.lastY = this.sprite.y;
    if (this.config.initDirect === 1) {
      this.sprite.scaleX = -1;
      this.sprite.regX = 145;
    }
    // this.sprite.scaleX = -1;
  }

  move(x, y) {
    this.lastX += x;
    this.lastY += y;

    this.sprite.gotoAndPlay('jump');
    createjs.Tween.get(this.sprite, { override: true })
                  .to({
                    x: this.lastX,
                    y: this.lastY,
                  }, 200);
  }

  moveRight() {
    if (this.lastDirect !== 1) {
      this.lastDirect = 1;
      this.sprite.scaleX = -1;
      this.sprite.regX = 145;
    }
    this.move(moveXOffset, moveYOffset);
  }

  moveLeft() {
    if (this.lastDirect !== -1) {
      this.lastDirect = -1;
      this.sprite.scaleX = 1;
      this.sprite.regX = 0;
    }
    this.move(-1 * moveXOffset, moveYOffset);
  }

  dropAndDisappear(dir) {// 踏空掉落 处理
    const posY = this.sprite.y;
    const posX = this.sprite.x;
    this.sprite.stop();
    createjs.Tween.removeTweens(this.sprite);
    createjs.Tween.get(this.sprite, { override: true })
                  .to({
                    x: posX + dir * 2 * moveXOffset,
                    y: posY + moveYOffset,
                  }, 240)
                  .to({
                    y: this.canvas.height + this.sprite.y,
                  }, 800)
                  .set({
                    visible: false,
                  });
  }

  hitAndDisappear() {// 撞击障碍物处理
    createjs.Tween.get(this.sprite, { override: true })
                  .wait(500)
                  .set({
                    visible: false,
                  });
  }
}

Game

Game类是整个游戏的控制中心,负责用户点击事件的处理,负责将各个对象最终添加到舞台,

class Game {
  constructor(options) {
    // this.init();
    this.config = {
      initStairs: 8,
      onProgress: () => {},
      onComplete: () => {},
      onGameEnd: () => {},
    };
    Object.assign(this.config, options);
    this.stairIndex = -1; // 记录当前跳到第几层
    this.autoDropTimer = null;
    this.clickTimes = 0;
    this.score = 0;
    this.isStart = false;
    this.init();
  }

  init() {
    this.canvas = document.querySelector('#stage');
    this.canvas.width = window.innerWidth * 2;
    this.canvas.height = window.innerHeight * 2;
    this.stage = new createjs.Stage(this.canvas);

    createjs.Ticker.setFPS(60);
    createjs.Ticker.addEventListener('tick', () => {
      if (e.paused !== true) {
        this.stage.update();
      }
    });

    queue.on('complete', () => {
      this.run();
      this.config.onComplete();
    });
    queue.on('fileload', this.config.onProgress);
  }

  getInitialSequence() {// 获取初始的阶梯和障碍物序列
    const stairSeq = [];
    const barrSeq = [];
    for (let i = 0; i < this.config.initStairs; i += 1) {
      stairSeq.push(util.getRandom(0, 2));
      barrSeq.push(util.getRandomNumBySepcial(this.config.barrProbabitiy));
    }
    return {
      stairSeq,
      barrSeq,
    };
  }

  createGameStage() { //渲染舞台
    this.background = new createjs.Shape();
    this.background.graphics.beginFill('#001605').drawRect(0, 0, this.canvas.width, this.canvas.height);

    const seq = this.getInitialSequence();
    this.leves = new Leaves(this.config, this.canvas);
    this.floor = new Floor(this.config, this.canvas);
    this.robot = new Robot({
      initDirect: seq.stairSeq[0],
    }, this.canvas);
    this.stairs = new createjs.Container();
    this.stairs.addChild(this.floor.sprite, this.robot.sprite);
    // robot 与阶梯是一体,这样才能在跳跃时保持robot与stair的相对距离
    this.stairs.lastX = this.stairs.x;
    this.stairs.lastY = this.stairs.y;
    this.floor.addFloors(seq.stairSeq, seq.barrSeq);
    this.stage.addChild(this.background, this.stairs, this.leves.sprite);
    // 所有的container 重新 add,才能保证stage clear有效,舞台重新渲染,否则restart后有重复的
  }

  bindEvents() {
    this.background.addEventListener('click', this.handleClick.bind(this)); // 必须有元素才会触发,点击空白区域无效
    // this.stage.addEventListener('click', this.handleClick); // 必须有元素才会触发,点击空白区域无效
  }

  run() {
    this.clickTimes = 0;
    this.score = 0;
    this.stairIndex = -1;
    this.autoDropTimer = null;
    this.createGameStage();
    this.bindEvents();
    createjs.Ticker.setPaused(false);
  }

  start() {
    this.isStart = true;
  }

  restart() {
    this.stage.clear();
    this.run();
    this.start();
  }

  handleClick(event) {
    if (this.isStart) {
      const posX = event.stageX;
      this.stairIndex += 1;
      this.clickTimes += 1;
      let direct = -1;
      this.autoDrop();
      if (posX > (this.canvas.width / 2)) {
        this.robot.moveRight();
        direct = 1;
        this.centerFloor(-1 * moveXOffset, -1 * moveYOffset);
      } else {
        this.robot.moveLeft();
        direct = -1;
        this.centerFloor(moveXOffset, -1 * moveYOffset);
      }
      this.addStair();
      this.leves.tranlateY(-1 * moveYOffset);
      this.checkJump(direct);
    }
  }

  centerFloor(x, y) { // 将阶梯层始终置于舞台中央
    this.stairs.lastX += x;
    this.stairs.lastY += y;

    createjs.Tween.get(this.stairs, { override: true })
                  .to({
                    x: this.stairs.lastX,
                    y: this.stairs.lastY,
                  }, 500);
  }

  checkJump(direct) { //机器人每次跳跃检查 是否掉落消失
    const stairSequence = this.floor.stairSequence; // like [-1, 1,1,-1], -1代表左,1代表右
   
    if (direct !== stairSequence[this.stairIndex]) {// 当前跳到的楼层的阶梯方向与跳跃的方向不一致,则代表失败
      this.drop(direct);
      this.gameOver();
    }
  }

  drop(direct) {
    const barrierSequence = this.floor.barrierSequence;

    if (barrierSequence[this.stairIndex] !== 1) {
      this.robot.dropAndDisappear(direct);
    } else {
      this.shakeStairs();
      this.robot.hitAndDisappear();
    }
  }

  shakeStairs() {
    createjs.Tween.removeTweens(this.stairs);
    createjs.Tween.get(this.stairs, {
      override: true,
    }).to({
      x: this.stairs.x + 5,
      y: this.stairs.y - 5,
    }, 50, createjs.Ease.getBackInOut(2.5)).to({
      x: this.stairs.x,
      y: this.stairs.y,
    }, 50, createjs.Ease.getBackInOut(2.5)).to({
      x: this.stairs.x + 5,
      y: this.stairs.y - 5,
    }, 50, createjs.Ease.getBackInOut(2.5)).to({ // eslint-disable-line
      x: this.stairs.x,
      y: this.stairs.y,
    }, 50, createjs.Ease.getBackInOut(2.5)).pause(); // eslint-disable-line
  }

  addStair() { //添加随机方向的一个阶梯
    const stair = util.getRandom(0, 2);
    const barrier = util.getRandomNumBySepcial(this.config.barrProbabitiy);
    this.floor.addOneFloor(stair, barrier, true);
  }

  autoDrop() { //阶梯自动掉落
    if (!this.autoDropTimer) {
      this.autoDropTimer = createjs.setInterval(() => {
        this.floor.drop();
        if (this.clickTimes === this.floor.dropIndex) {
          createjs.clearInterval(this.autoDropTimer);
          this.robot.dropAndDisappear(0);
          this.gameOver();
        }
      }, 1000);
    }
  }

  gameOver() {
    createjs.clearInterval(this.autoDropTimer);
    this.isStart = false;
    this.config.onGameEnd();
    setTimeout(() => {
      createjs.Ticker.setPaused(true);
    }, 1000);
  }
}

总结

本文只是在H5游戏开发:指尖大冒险的基础上,将代码实现了一遍,在这个过程不仅学到了createjs的一些基本用法,也知道了游戏开发问题的解决可以从视觉层面以及逻辑底层两方面考虑。createjs在使用过程也会遇到一些问题,比如clear舞台之后,舞台上的元素并没有清空,这些我在代码里也做了注释。感兴趣的同学可以看一下源码 https://github.com/shengbowen...

参考


布利丹牵驴子
694 声望21 粉丝

if you never try, you'll never know.