1

WebGl渲染器小游戏实战(上)

 2 years ago
source link: https://jelly.jd.com/article/61681cf366c14901a1f87575
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
JELLY | WebGl渲染器小游戏实战(上)
WebGl渲染器小游戏实战(上)
上传日期:2021.10.25
经过对GLSL的了解,以及shadertoy上各种项目的洗礼,现在开发简单交互图形应该不是一个怎么困难的问题了。下面开始来对一些已有业务逻辑的项目做GLSL渲染器替换开发。

起因是看到某些小游戏广告,感觉机制有趣,实现起来应该也不会很复杂,就尝试自己开发一个

11d36b920f7b79f2.png

游戏十分简单,类似泡泡龙一样的从屏幕下方中间射出不同颜色大小的泡泡,泡泡上浮到顶部,相同颜色的泡泡可以合并成大一级的不同颜色泡泡。简单说就是一个上下反过来的合成大西瓜

较特别的地方是为了表现泡泡的质感,在颜色相同的泡泡靠近时,会有水滴表面先合并的效果,这一部分就需要用到着色器渲染来实现了

先对逻辑分层

最上层为游戏业务逻辑Game,管理游戏开始、结束状态,响应用户输入,记录游戏分数等

其次为游戏逻辑驱动层Engine,管理游戏元素,暴露可由用户控制的动作,引用渲染器控制游戏场景渲染更新

再往下是物理引擎模块Physics,管理游戏元素之间的关系,以及实现Engine需要的接口

与引擎模块并列的是渲染器模块Renderer,读取从Engine输入的游戏元素,渲染游戏场景

这样分层的好处是,各个模块可以独立替换/修改;例如在GLSL渲染器开发完成前,可以替换成其他的渲染器,如2D canvas渲染器,甚至使用HTML DOM来渲染

结构图如下:

11d36b920f7b79f2.png

游戏逻辑实现

游戏业务逻辑 Game

因为游戏业务比较简单,这一层只负责做这几件事:

  1. 输入HTML canvas元素,指定游戏渲染范围
  2. 初始化驱动层Engine
  3. 监听用户操作事件touchend/click,调用Engine控制射出泡泡
  4. 循环调用Engineupdate更新方法,并检查超过指定高度的泡泡数量,如数量超过0则停止游戏
class Game {
  constructor(canvas) {
    this.engine = new Engine(canvas)
    document.addEventListener('touchend', (e) => {
      if(!this.isEnd) {
        this.shoot({
          x: e.pageX,
          y: e.pageY
        }, randomLevel())
      }
    })
  }
  shoot(pos, newBallLevel) {
    // 已准备好的泡泡射出去
    this.engine.shoot(pos, START_V)
    // 在初始点生成新的泡泡
    this.engine.addStillBall(BALL_INFO[newBallLevel])
  }
  update() {
    this.engine.update()
    let point = 0;
    let overflowCount = 0;
    this.engine.physics.getAllBall().forEach(ball => {
      if(!ball.isStatic){
        point += Math.pow(2, ball.level);
        if (ball.position.y > _this.sceneSize.width * 1.2) {
          overflowCount++
        }
      }
    })
    if(overflowCount > 1){
      this.gameEnd(point);
    }
  }
  gameEnd(point) {
    this.isEnd = true
    ...
  }
}

驱动层 Engine

这一层的逻辑负责管理物理引擎Physics和渲染器模块Renderer,并暴露交互方法供Game调用

指定了物理引擎模块需提供以下接口方法:

  1. 在指定的位置生成固定的泡泡,供用户作下一次操作时使用
  2. 把固定的泡泡按指定的方向射出

在更新方法update里,读取所有泡泡所在的位置和大小、等级颜色信息,再调用渲染器渲染泡泡

class Engine {
  constructor(canvas) {
    this.renderer = new Renderer(canvas)
    this.physics = new Physics()
  }
  addStillBall({ pos, radius, level }) {
    this.physics.createBall(pos, radius, level, true)
    this.updateRender()
  }
  shoot(pos, startV) {
    this.physics.shoot(pos, startV)
  }
  updateRender() {
    // 更新渲染器渲染信息
  }
  update() {
    // 调用渲染器更新场景渲染
    this.renderer.draw()
  }
}

物理引擎模块 Physics

物理引擎使用了matter.js,没别的原因,就是因为之前有项目经验,并且自带一个渲染器,可以拿来辅助我们自己渲染的开发

Physics模块在初始化的时候先初始化场景:

左、右、下的边框使用普通的矩形碰撞体实现

顶部的半圆使用预先画好的SVG图形,使用matter.jsSVG类的pathToVertices方法生成碰撞体,插入到场景中

因为泡泡都是向上漂浮的,所以置重力方向为y轴的负方向

以上完成了物理引擎中的场景搭设

上文提到的物理引擎需要提供的两个方法:

1.在指定的位置生成固定的泡泡,供用户作下一次操作时使用

创建一个圆型碰撞体放到场景的指定位置,并记录为Physics的内部属性供射出方法使用

2.把固定的泡泡按指定的方向射出

射出的方向由用户的点击位置决定,但射出的速度是固定的

可以通过点击位置和原始位置连线的向量,作归一化后乘以初速度大小计算

另外在游戏运行过程中,还需要实现以下功能:

3.检查是否有相同颜色的泡泡相撞

其实matter.js是有提供两个碰撞体碰撞时触发的collisionStart事件的,但是对于碰撞后合并生成的泡泡,即使与相同颜色的泡泡触碰,也不会触发这个事件,所以只能手动去检测两个泡泡是否碰撞

这里使用的方法是判断两个圆形的中心距离,是否小于等于半径之和,是则判断为碰撞

4.相撞的相同颜色泡泡合并为高一级的泡泡

碰撞的两个泡泡,取y座标靠上的一个作为合并的目标,靠下的一个作为源泡泡,合并后的泡泡座标设在目标泡泡座标上

源泡泡碰撞设为关闭,并设为固定位置;

只实现合并的功能的话,只需要把源泡泡的位置设为目标泡泡的座标就可以,但为了实现动画过渡,源泡泡的位置移动做了如下的处理:

  1. 在每个更新周期计算源泡泡和目标泡泡位置的差值,得到源泡泡需要移动的向量
  2. 移动向量的1/8,在下一个更新周期重复1、2的操作
  3. 当两个泡泡的位置差值小于一个较小的值(这里设为5)时,视为合并完成,销毁源泡泡,并更新目标泡泡的等级信息
class Physics {
  constructor() {
    this.matterEngine = Matter.Engine.create()
    // 置重力方向为y轴负方向(即为上)
    this.matterEngine.world.gravity.y = -1

    // 添加三面墙
    Matter.World.add(this.matterEngine.world, Matter.Bodies.rectangle(...))
    ...
    ...

    // 添加上方圆顶
    const path = document.getElementById('path')
    const points = Matter.Svg.pathToVertices(path, 30)
    Matter.World.add(this.matterEngine.world, Matter.Bodies.fromVertices(x, y, [points], ...))

    // 因为需要使用自定义的碰撞检测方法,在物理引擎的beforeUpdate事件上绑定检测碰撞和合并泡泡方法调用
    Matter.Events.on(this.matterEngine, 'beforeUpdate', e => {
      // 检查是否有正在合并的泡泡,没有则检测是否有相同颜色的泡泡碰撞
      if(!this.collisionInfo) {
        this.collisionInfo = this.checkCollision()
      }
      if(this.collisionInfo) {
        // 若有正在合并的泡泡,(继续)调用合并方法,在合并完成后清空属性
        this.mergeBall(this.collisionInfo.srcBody, this.collisionInfo.targetBody, () => {
          this.collistionInfo = null
        })
      }
    })

    Matter.Engine.run(this.matterEngine)
  }

  // 在指定位置生成泡泡
  createBall(pos, radius, level, isStatic) {
    const ball = Matter.Bodies.circle(pos.x, pos.y, radius, {
      ...// 不同等级不同的大小通过scale区分
    })
    // 如果生成的是固定的泡泡,则记录在属性上供下次射出时使用
    if(isStatic) {
      this.stillBall = ball
    }
    Matter.World.add(this.matterEngine.world, [ball])
  }

  // 射出泡泡
  shoot(pos, startV) {
    if(this.stillBall) {
      // 计算点击位置与原始位置的向量,归一化(使长度为1)之后乘以初始速度大小
      let v = Matter.Vector.create(pos.x - this.stillBall.position.x, pos.y - this.stillBall.position.y) 
      v = Matter.Vector.normalise(v)
      v = Vector.mult(v, startV)

      // 设置泡泡为可活动的,并把初速度赋予泡泡
      Body.setStatic(this.stillBall, false);
      Body.setVelocity(this.stillBall, v);
    }
  }

  // 检测是否有相同颜色的泡泡碰撞
  checkCollision() {
    // 拿到活动中的泡泡碰撞体的列表
    const bodies = this.getAllBall()
    let targetBody, srcBody
    // 逐对泡泡碰撞体遍历
    for(let i = 0; i < bodies.length; i++) {
      const bodyA = bodies[i]
      for(let j = i + 1; j < bodies.length; j++) {
        const bodyB = bodies[j]
        if(bodyA.level === bodyB.level) {
          // 用距离的平方比较,避免计算开平方
          if(getDistSq(bodyA.position, bodyB.position) <= 4 * bodyA.circleRadius * bodyA.circleRadius) {
            // 使用靠上的泡泡作为目标泡泡
            if(bodyA.position.y < bodyB.position.y) {
              targetBody = bodyA
              srcBody = bodyB
            } else {
              targetBody = bodyB
              srcBody = bodyA
            }
            return {
              srcBody,
              targetBody
            }
          }
        }
      }
    }
    return false
  }

  mergeBall(srcBody, targetBody, callback) {
    const dist = Math.sqrt(getDistSq(srcBody.position, targetBody.position))
    // 源泡泡位置设为固定的,且不参与碰撞
    Matter.Body.setStatic(srcBody, true)
    srcBody.collisionFilter.mask = mergeCategory
    // 如果两个泡泡合并到距离小于5的时候, 目标泡泡升级为上一级的泡泡
    if(dist < 5) {
      // 合并后的泡泡的等级
      const newLevel = Math.min(targetBody.level + 1, 8)
      const scale = BallRadiusMap[newLevel] / BallRaiusMap[targetBody.level]
      // 更新目标泡泡信息
      Matter.Body.scale(targetBody, scale, scale)
      Matter.Body.set(targetBody, {level: newLevel})
      Matter.World.remove(this.matterEngine.world, srcBody)
      callback()
      return
    }
    // 需要继续播放泡泡靠近动画
    const velovity = {
      x: targetBody.position.x - srcBody.position.x,
      y: targetBody.position.y - srcBody.position.y
    };
    // 泡泡移动速度先慢后快
    velovity.x /= dist / 8;
    velovity.y /= dist / 8;
    Matter.Body.translate(srcBody, Matter.Vector.create(velovity.x, velovity.y));
  }
}

渲染器模块

GLSL渲染器的实现比较复杂,当前可以先使用matter.js自带的渲染器调试一下

Physics模块中,再初始化一个matter.jsrender

class Physics {
  constructor(...) {
    ...
    this.render = Matter.Render.create(...)
    Matter.Render.run(this.render)
  }
}
11d36b920f7b79f2.png

跑起来符合预想的游戏逻辑

下篇再继续讲解渲染器的实现过程


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK