webgl笔记(七) ——— 你玩过贪吃蛇吗

摘要

好久没更新了,之前几篇文章,简单的介绍了一些webgl的具体用法,但都停留在比较简单的demo阶段。那么今天就来实现一个复杂一点的demo(是的,依然是demo),写一个贪吃蛇小游戏,在线游玩

贪吃蛇要素

  • 蛇 (废话)
  • 地图
  • 食物

地图

  • 我们先来实现最简单的地图,这里采用网格的形式,网格的中心绘制一个点,代表这一格,目标效果如下图所示

渲染

  • 为了让我们的代码看起来更专业,我们定义一个通用的渲染类,通过这个类来控制所有的绘制操作,首先,我们要创建一个canvas,这样我们才能使用webgl
javascript 复制代码
class Game {
  canvas: HTMLCanvasElement
  width = 0
  height = 0
  container: HTMLElement | null = null
  gl: WebGLRenderingContext | null = null
  projectionMat: number[] = [];

  constructor(root: HTMLElement | string) {
    if (typeof root === 'string') {
      this.container = document.querySelector(root);
    } else {
      this.container = root;
    }

    this.canvas = document.createElement('canvas');
    this.gl = this.canvas.getContext('webgl');

    if (!this.gl) return;

    if (this.container) {
      // 初始化canvas,使其等于父节点的长宽
      this.container.appendChild(this.canvas);
      this.canvas.width = this.container.clientWidth;
      this.canvas.height = this.container.clientHeight;
      this.canvas.style.width = '100%';
      this.canvas.style.height = '100%';
      this.width = this.canvas.width;
      this.height = this.canvas.height;
      this.projectionMat = createProjectionMat(0, this.canvas.width, 0, this.canvas.height);
      // 重置视口
      this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
    }
  }

  private clearCanvas() {
    // 黑色背景
    if (!this.gl) return;
    this.gl.clearColor(0, 0, 0, 1);
    this.gl.clear(this.gl.COLOR_BUFFER_BIT);
  }

  init() {
    // 一些初始化操作
    this.clearCanvas();
  }
}
  • 上面这个方法定义了一些常用的变量,主要用于初始化canvas,其中createProjectionMat是之前文章提到过的投影转换矩阵,代码参考,由于只要canvas宽高没有变化,这个矩阵就是不变的,所以可以在初始化阶段就赋予值,接下来为这个类添加绘制点的方法
javascript 复制代码
class Game {
  ...
  pointProgram: WebGLProgram | null = null
  
  private switchPointProgram() {
    if (!this.gl) return;
    if (!this.pointProgram) {
      // 顶点shader
      const vertexShader = createShader(this.gl, this.gl.VERTEX_SHADER, pointVertSrc);
      // 片元shader
      const frgShader = createShader(this.gl, this.gl.FRAGMENT_SHADER, pointFrgSrc);
      if (!vertexShader || !frgShader) return;
      this.pointProgram = createProgram(this.gl, [vertexShader, frgShader]);
      if (!this.pointProgram) return;
      // 创建program之后可以设置投影矩阵
      setUniformMat(this.gl, this.pointProgram, this.projectionMat, 'u_projection');
    }
    if (!this.pointProgram) return;
    // useProgram 指定绘制点的逻辑
    this.gl.useProgram(this.pointProgram);
  }
  
  private drawPoints(points: { x: number, y: number, red: number, blue: number, green: number, alpha: number, scale: number }[]) {
    if (!this.gl) return;
    this.switchPointProgram();
    if (!this.pointProgram) return;

    const pointPos: number[] = [];
    const pointColor: number[] = [];
    const pointSize: number[] = [];

    points.forEach(point => {
      // 一次把所有点绘制出来
      pointPos.splice(pointPos.length, 0, points.x, points.y);
      pointColor.splice(pointColor.length, 0, point.red, point.green, point.blue, point.alpha);
      pointSize.push(point.scale);
    })

    setAttribute(this.gl!, this.pointProgram!, pointPos, 'a_position');
    setAttribute(this.gl!, this.pointProgram!, pointSize, 'a_size', 1);
    setAttribute(this.gl!, this.pointProgram!, pointColor, 'a_color', 4);

    this.gl.drawArrays(this.gl.POINTS, 0, points.length)
  }
}
  • createShadercreateProgramsetUniformMatsetAttribute这些方法也都是之前文章介绍过的,具体代码参考这里
  • 可以看到我们定义点,除了x,y坐标,还有rgba颜色值,和点的大小scale
  • 接着看下绘制点的shader,每个顶点接收坐标值以及颜色值,并把颜色值传给片元shader,这里通过设置gl_PointSize,可以控制绘制点的大小
javascript 复制代码
export const pointVertSrc = `
  attribute vec2 a_position;
  attribute vec4 a_color;
  attribute float a_size;
  varying vec4 v_color;
  uniform mat4 u_projection;
  void main(void) {
    gl_Position = u_projection * vec4(a_position, 0, 1.0);
    gl_PointSize = 1.0 * a_size;
    v_color = a_color;
  }
`;

export const pointFrgSrc = `
  precision mediump float;
  varying vec4 v_color;

  void main() {
    gl_FragColor = v_color;
  }
`;
  • 有了绘制点的程序之后,我们需要定义哪些东西可以被绘制,同时让class Game提供一个add方法,将要绘制的元素添加到Game里面,再通过render方法绘制这些元素
  • 说干就干,首先定义一个类,用来表示可以被添加到Game的元素
javascript 复制代码
class GameItem {
  x: number
  y: number
  scale = 1
  red = 0.5
  blue = 0.5
  green = 0.5
  alpha = 1

  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}
  • 上面这个类可以代表一个可以被绘制的点,但只能代表一个点,而我们的地图,包含多个点,我们希望可以有一个统一的类来表示含有多个点的物件,所以再定义一个类
javascript 复制代码
class GameObject {
  items: GameItem[] = []
  children: GameObject[] = []

  add(obj: GameObject) {
    this.children.push(obj);
  }

  remove(obj: GameObject) {
    const index = this.children.indexOf(obj);
    if (index > -1) return this.children.splice(index, 1);
  }
}
  • 可以看到除了items代表可以被绘制的点,我们还添加了一个叫children的对象,这个children为后续的蛇和食物对象提供了扩展能力,这样所有GameObject对象不仅包含可以被绘制的点,还可以包含着另一组,包含可被绘制成点的对象,比如说地图有一组点(二维点阵),地图还可以包含蛇,蛇也包含了一组点(一个队列,代表蛇身)
  • 现在可以改造一下Game,让他遍历所有子孙元素,并绘制里面的所有点
javascript 复制代码
class Game {
  ...
  GameObject[] = []
  
  add(obj: GameObject) {
    this.children.push(obj);
  }

  remove(obj: GameObject) {
    const index = this.children.indexOf(obj);
    if (index > -1) return this.children.splice(index, 1);
  }
  
  private drawPoints(points: GameItem[]) {
    ...
  }
  
  walk(objs: GameObject[]) {
    // 遍历所有元素
    const pointArr: GameItem[] = [];
    objs.forEach(obj => {
      obj.items.forEach(i => {
        pointArr.push(i);
      });
      if (obj.children && obj.children.length) {
        const points = this.walk(obj.children);
        pointArr.splice(pointArr.length, 0, ...points);
      }
    })
    return pointArr;
  }

  render() {
    this.clearCanvas();
    const points = this.walk(this.children);
    // 提取子孙元素里所有的点,争取一次绘制,而不需要多次调用draw call
    if (points.length) this.drawPoints(points);
  }
}

添加地图

  • 好了,现在终于可以开始画地图了,我们写一个地图的类,不同的是,这次地图类要继承GameObject以便添加到Game
javascript 复制代码
class Map extends GameObject {
  items: GameItem[] = [];
  col: number; // 地图的行数
  row: number; // 地图的列数
  game: Game; // 保存以便后续调用

  constructor(game: Game, row: number, col: number) {
    super();
    this.col = col;
    this.row = row;
    this.game = game;
    this.reset();
  }

  reset() {
    for (let i = 0; i < this.row; i += 1) {
      for (let j = 0; j < this.col; j += 1) {
        this.items.push(new GameItem(i, j));
      }
    }
  }
}
  • 一开始我们设想地图里面直接保存所有可被绘制的点,同时根据传入的行数和列数决定地图的大小,但是这个GameItem里面只有x和y的信息,我们通过行数和列数可以很快的知道对应的GameItem,但我们无法从GameItem反推出其对应的行与列,所以我们还需要对GameItem做一层包装,让其可以记录行列序号
javascript 复制代码
class Point extends GameItem {
  red = 0.5;
  green = 0.5;
  blue = 0.5;
  static CellSize = 20; // 每个单元格的固定长宽
  scale = 2; // 点大小
  rowIdx: number; // 对应行index
  colIdx: number; // 对应列index
  game: Game; // 将game和map保存下来,方便后续直接调用其中的方法
  map: Map;

  constructor(game: Game, map: Map, rowIdx: number, colIdx: number) {
    // 通过行index和列index,还有单元格大小,可以直接算出x和y
    super(Point.CellSize * colIdx + Point.CellSize / 2, Point.CellSize * rowIdx + Point.CellSize / 2);
    this.rowIdx = rowIdx;
    this.colIdx = colIdx;
    this.game = game;
    this.map = map;
  }
}

class Map extends GameObject {
  items: Point[] = [];
  ...
  
  constructor(game: Game) {
    super();
    // 通过容器大小,直接算出行数和列数
    this.col = Math.floor(game.width / Point.CellSize);
    this.row = Math.floor(game.height / Point.CellSize);
    this.game = game;
    this.reset();
  }
  
  reset() {
    for (let i = 0; i < this.row; i += 1) {
      for (let j = 0; j < this.col; j += 1) {
        this.items.push(new Point(this.game, this, i, j));
      }
    }
  }
}
  • 这样处理后,地图就会根据容器自动算出行数和列数,并根据行数和列数算出每个点的位置(x, y),并记录每个点对应的行列序号
  • 最后我们只需要new一下map,然后add到game里,再执行render方法,就可以得到一幅贪吃蛇的地图了
javascript 复制代码
// 假设有一个id为app的div
const game = new Game('#app');
const map = new Map(game);
game.add(map);
game.render();
  • 有了地图,我们可以开始设置snake,蛇了。

蛇,可以认为是一组队列,队列中每个元素,可以是一个点,只要大小和颜色区分与地图的点即可,但这样没有挑战性,我们让蛇队列上的每一个节点都是一个贴图;

绘制多张贴图

  • 之前文章里介绍过如何绘制贴图,但之前的方法是每画一个图就调用一次draw call,这样当然没有问题,但每次调用都涉及CPU和GPU之间的交互,是有一定性能损失的,在贴图数不多的前提下,我们可以考虑在一次draw call里,就把所有贴图都绘制了,要实现这个效果,代价是每次调用draw call之前,需要提前把多个贴图的顶点都准备好,再传给GPU,也就是需要用空间换时间,具体多次贴图绘制的代码可以参考这里
  • 具体原理就是在shader里声明一个sampler2D的数组,接着只需要给贴图坐标指定一个index,指示使用的贴图在该数组里的索引,就可以让GPU在不同坐标上使用不同贴图进行绘制
  • 接下来我们看下具体实现,首先是shader
javascript 复制代码
export const textureVertSrc = `
  attribute vec2 a_position;
  attribute float a_textureIndex;
  varying float v_textureIndex;
  attribute vec2 a_texCoord;
  varying vec2 v_texCoord;
  uniform mat4 u_projection;
  void main() {
    gl_Position = u_projection * vec4(a_position, 0, 1.0);
    v_textureIndex = a_textureIndex;
    v_texCoord = a_texCoord; 
  }
`;

export const textureFrgSrc = `
  #define numTextures 8
  precision mediump float;
  varying float v_textureIndex;
  varying vec2 v_texCoord;
  uniform sampler2D u_textures[numTextures];
      
  vec4 getSampleFromArray(sampler2D textures[8], int ndx, vec2 uv) {
    vec4 color = vec4(uv, 0, 1);
    for (int i = 0; i < numTextures; ++i) {
      if (i == ndx) {
        color = texture2D(u_textures[i], uv);
      }
    }
    return color;
  }
      
  void main() {
    gl_FragColor = getSampleFromArray(u_textures, int(v_textureIndex + 0.5), v_texCoord);
  }
`;
  • 先看顶点shader,除了常规的点坐标a_position,贴图坐标a_texCoordv_texCoord,投影矩阵u_projection,还多了两个a_textureIndexv_textureIndex,这两个就是用来指定当前点使用的是哪个贴图的,代表贴图数组里的index,由于该值需要从顶点shader传递到片元shader,所以有a_xxxv_xxx两个变量
  • 再看片元shader,贴图数组的长度必须提前定义,无法动态修改,这里定为了8,uniform sampler2D u_textures[8]这种写法是glsl内部的支持,代表定义一个sampler2D的数组,同理uniform int a[4]即定义了一个int类型的长度为4的数组
  • 片元shader里还定义了getSampleFromArray这个方法,这是因为webgl不允许使用除常量索引表达式之外的任何内容去对sampler2D类型的数组进行取值(参考),所以这里进行了一次遍历,取出对应的贴图,再根据贴图坐标,获取到对应的像素,最后直接赋予片元该像素;
  • 有了shader就可以编写绘制代码了,首先改写Game,把shader加载进来
javascript 复制代码
class Game {
  ...
  textureProgram: WebGLProgram | null = null

  private switchTextureProgram() {
    if (!this.gl) return;
    if (!this.textureProgram) {
      const vertexShader = createShader(this.gl, this.gl.VERTEX_SHADER, textureVertSrc);
      const frgShader = createShader(this.gl, this.gl.FRAGMENT_SHADER, textureFrgSrc);
      if (!vertexShader || !frgShader) return;
      this.textureProgram = createProgram(this.gl, [vertexShader, frgShader]);
      if (!this.textureProgram) return;
      setUniformMat(this.gl, this.textureProgram, this.projectionMat, 'u_projection');
      // u_textures是一个数组,可以直接通过下面的方式获取到index为0的地址
      // getUniformLocation的第二个参数填写"u_textures[0]"也可以获取到索引为0的地址
      // "u_textures[1]"则获取到索引为1的地址
      const textureLoc = this.gl.getUniformLocation(this.textureProgram, "u_textures");
      // 前面的文章提到过,对于sample2D,对其赋值就是告诉webgl从内部的哪个贴图进行像素读取
      // 直接对数组进行赋值,一次指定u_textures中的元素分别从0至7号贴图进行取值
      this.gl.uniform1iv(textureLoc, [0, 1, 2, 3, 4, 5, 6, 7]);
    }
    if (!this.textureProgram) return;
    this.gl.useProgram(this.textureProgram);
  }
}
  • 为了绘制贴图,我们还需要知道哪些元素应该被绘制成点,哪些应该被绘制成贴图,所以我们要改造GameItem
javascript 复制代码
class GameItem {
  private type: 'point' | 'cube'
  width: number
  height: number
  texture?: string
  ...

  constructor(type: 'point' | 'cube' = 'point', x = 0, y = 0, width = 0, height = 0) {
    this.type = type;
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
  }

  isPoint() {
    return this.type === 'point';
  }

  isTexture() {
    return this.type === 'cube' && !!this.texture;
  }
}
  • 这里主要添加了一个type字段,表明当前元素是一个点,还是一个矩形,同时矩形需要有widthheight属性,另外增加一个texture字段,代表对应贴图的名称,并添加相应的判断方法,判断是point还是texture
  • 相应的,我们还需要改造Game方法,让对应的元素采用对应的绘制方法
javascript 复制代码
class Game {
  ...
  walk(objs: GameObject[]) {
    const pointArr: GameItem[] = [];
    const textureArr: GameItem[] = [];
    objs.forEach(obj => {
      obj.items.forEach(i => {
        if (i.isPoint()) pointArr.push(i);
        else if (i.isTexture()) textureArr.push(i);
      });
      if (obj.children && obj.children.length) {
        const { points, textures } = this.walk(obj.children);
        pointArr.splice(pointArr.length, 0, ...points);
        textureArr.splice(textureArr.length, 0, ...textures);
      }
    })
    return {
      points: pointArr,
      textures: textureArr
    }
  }

  render() {
    this.clearCanvas();
    const { points, textures } = this.walk(this.children);
    if (points.length) this.drawPoints(points);
    if (textures.length) this.drawTexture(textures);
  }
}
  • 接下来我们来实现这个drawTexture
javascript 复制代码
class Game {
  ...
  private drawTexture(cubes: GameItem[]) {
    if (!this.gl) return;
    this.switchTextureProgram();
    if (!this.textureProgram) return;

    const pointPos: number[] = [];
    const textureIdx: number[] = [];
    const coorPos: number[] = [];

    cubes.forEach(cube => {
      const centerX = cube.x;
      const centerY = cube.y;
      const left = centerX - cube.width / 2;
      const right = centerX + cube.width / 2;
      const top = centerY - cube.height / 2;
      const down = centerY + cube.height / 2; 
      // 两个三角形 六个点
      const drawPoints = [
        left, top, right, top, left, down,
        left, down, right, down, right, top
      ]
      pointPos.splice(pointPos.length, 0, ...drawPoints);
      // 根据texture获取纹理id
      const textureConfig = this.texture.getTexture(cube.texture!);
      // 由于每个点都需要对应一个纹理id,所以需要加入六个id
      textureIdx.push(textureConfig.textureID);
      textureIdx.push(textureConfig.textureID);
      textureIdx.push(textureConfig.textureID);
      textureIdx.push(textureConfig.textureID);
      textureIdx.push(textureConfig.textureID);
      textureIdx.push(textureConfig.textureID);
      // 同理纹理坐标也是六个点
      const texcoords = [
        0.0, 0.0, 1.0, 0.0, 0.0, 1.0,
        0.0, 1.0, 1.0, 1.0, 1.0, 0.0
      ]
      coorPos.splice(coorPos.length, 0, ...texcoords);
    })
    
    setAttribute(this.gl, this.textureProgram, pointPos, 'a_position');
    // 最后的true参数用来指定这个attribute传入的data是一个int类型,否则默认是float类型
    setAttribute(this.gl, this.textureProgram, textureIdx, 'a_textureIndex', 1, this.gl.STATIC_DRAW, true);
    setAttribute(this.gl, this.textureProgram, coorPos, 'a_texCoord');

    this.gl.drawArrays(this.gl.TRIANGLES, 0, cubes.length * 2 * 3);
  }
  ...
}
  • 注意这里由于我们是通过drawArrays的方式进行绘制的,即每三个点决定一个三角形,而每个矩形对应两个三角形,即六个点,所以纹理坐标和纹理索引都需要有六个
  • ok,现在我们已经可以通过Game绘制贴图了,但贴图从哪来,这是个问题,为了解决这个问题,我们写一个专门的类来管理贴图
javascript 复制代码
// 贴图元素interface
interface TextureConfig {
  link: string, // 图片链接
  img: HTMLImageElement, // 加载图片的元素,用于创建纹理
  textureID: number, // 既是webgl内部的纹理id,也是shader中sample2D数组的索引
  texture?: WebGLTexture | null // webgl纹理
}

export default class Texture {
  // 贴图名字作为key,贴图元素作为value,内部管理一个map
  private textureMap: Record<string, TextureConfig> = {}
  // 需要有webgl的上下文,后面创建纹理需要用到
  private gl: WebGLRenderingContext | null = null

  constructor(gl: WebGLRenderingContext | null) {
    this.gl = gl;
  }

  getTexture(name: string) {
    // 根据名字获取贴图元素
    return this.textureMap[name];
  }

  generateWebglTexture(textureConfig: TextureConfig) {
    // webgl创建纹理
    if (!this.gl) return null;
    // 从0号纹理开始,根据当前纹理id,依次激活1号纹理、2号纹理...
    this.gl.activeTexture(this.gl.TEXTURE0 + textureConfig.textureID);
    const texture = this.gl.createTexture();
    if (!texture) return null;
    this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
    this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
    this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
    this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
    this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
    // 根据图片创建纹理
    this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, textureConfig.img);

    return texture;
  }

  createTexture(name: string, link: string) {
    if (this.textureMap[name]) return;
    // 指定图片名字,和图片链接,先读取图片,再创建纹理
    this.textureMap[name] = {
      link,
      img: new Image(),
      textureID: Object.keys(this.textureMap).length
    }
    return new Promise(resolve => {
      this.textureMap[name].img.onload = () => {
        const texture = this.generateWebglTexture(this.textureMap[name]);
        this.textureMap[name].texture = texture;
        resolve(this.textureMap[name]);
      }
      this.textureMap[name].img.src = link;
    })
  }
}
  • 这个Texture类主要拿来创建,用于绘制贴图的webgl纹理,以及管理纹理对象,管理图片名称和片元shader中sample2D的索引,另外注意createTexture是一个异步方法,这是由于需要从链接下载图片,这是个异步的过程;
  • 另外需要注意的是,这个Texture方法,不能无限制的创建纹理,一是因为webgl内部纹理对象的数量是有限的,其次我们在片元中声明的sample2D数组长度也是固定的,超过了就对应不上了,所以这里如何动态的创建纹理,管理纹理id和sample2D索引,是一个优化方向,目前先假设加载纹理的数量不会超过8;
  • Texture对象添加到Game
javascript 复制代码
class Game {
  ...
  texture: Texture;
  
  constructor(root: HTMLElement | string) {
    ...
    this.texture = new Texture(this.gl);
  }
}
  • 则外部可以通过Game创建纹理
javascript 复制代码
const game = new Game('#app');
// 加载logo.png,贴图名字就用logo对应
await game.texture.createTexture('logo', require('./assets/logo.png'));
  • 现在我们就具备了绘制贴图的能力了,接下来我们需要创建一条蛇,并把贴图应用到蛇身上的元素中

  • 在定义蛇之前,我们需要改造一下class Map,这是因为,蛇和地图上的点,占据着地图中相同的位置,我们需要区分当前地图上这一行这一列,这个格子上是蛇,还是地图的点,或是什么别的东西
javascript 复制代码
enum CELL_TYPE {
  SNAKE = 1,
  EMPTY = 0,
}

class Map extends GameObject {
  ...
  // cellTypeMap包含行列信息,具体看下面的赋值
  private cellTypeMap: Record<string, CELL_TYPE> = {};
  ...
  
  updateCellType(rowIdx: number, colIdx: number, type: CELL_TYPE) {
    this.cellTypeMap[`${rowIdx},${colIdx}`] = type;
  }
  
  getCellType(rowIdx: number, colIdx: number) {
    return this.cellTypeMap[`${rowIdx},${colIdx}`] || CELL_TYPE.EMPTY;
  }
}
  • 如此我们就可以通过getCellTypeupdateCellType来获取和更新地图上的网格类型,由于在class Map的初始化阶段,我们通过new Point新建了地图点,所以改造class Point,让其初始化阶段,就更新地图的网格类型
javascript 复制代码
class Point {
  ...
  // 根据行列获取当前网格类型
  get cellType() {
    return this.map.getCellType(this.rowIdx, this.colIdx);
  }
  ...
  
  constructor(game: Game, map: Map, rowIdx: number, colIdx: number, type = CELL_TYPE.EMPTY) {
    ...
    // 更新网格类型,默认是empty
    this.map.updateCellType(this.rowIdx, this.colIdx, type);
    ...
  }
}
  • 这样我们就完成了Map的网格类型初始化,类似的,我们需要定义一个矩形的类,里面可以指定纹理,来表示蛇身上的点
javascript 复制代码
class Cube extends GameItem {
  static CellSize = 30;
  scale = 1;
  width = 20;
  height = 20;
  rowIdx: number;
  colIdx: number;
  game: Game;
  map: Map;

  get cellType() {
    return this.map.getCellType(this.rowIdx, this.colIdx);
  }

  constructor(game: Game, map: Map, rowIdx: number, colIdx: number, type = CELL_TYPE.EMPTY, texture = '') {
    // gameitem 支持两种类型,一种是point,一种是cube
    super('cube', Cube.CellSize * colIdx + Cube.CellSize / 2, Cube.CellSize * rowIdx + Cube.CellSize / 2);
    this.rowIdx = rowIdx;
    this.colIdx = colIdx;
    this.game = game;
    this.map = map;
    this.map.updateCellType(this.rowIdx, this.colIdx, type);
    this.texture = texture;
  }
}
  • 可以看到class Cubeclass Point几乎是一样的,都记录了其在地图上的行列index,不同的是class Point决定大小的是scaleclass Cube决定大小的是widthheight,同时,支持在初始化阶段指定对应纹理
  • 蛇身上每个点都是一个矩形,我们拓展一下Cube类,让他表示蛇身上的每一个点
javascript 复制代码
class SnakePoint extends Cube {
  constructor(game: Game, map: Map, rowIdx: number, colIdx: number) {
    // logo就是通过 await game.texture.createTexture('logo', require('./assets/logo.png')); 加载的纹理,这里只要把名字传进来即可对应上纹理对象
    super(game, map, rowIdx, colIdx, CELL_TYPE.SNAKE, 'logo');
  }
}
  • 定义蛇
javascript 复制代码
class Snake extends GameObject {
  // 约定第一个元素是蛇尾,最后一个元素是蛇头
  items: SnakePoint[] = [];
  map: Map;
  game: Game;
  
  constructor(game: Game, map: Map) {
    super();
    this.map = map;
    this.game = game;
    
    this.reset();
  }
  
  reset() {
    // 固定位置定义三个点,作为蛇身
    this.items = [
      new SnakePoint(this.game, this.map, 2, 1),
      new SnakePoint(this.game, this.map, 2, 2),
      new SnakePoint(this.game, this.map, 2, 3)
    ];
  }
}
  • 然后把蛇添加到地图了
javascript 复制代码
class Map extends GameObject {
  ...
  snake: Snake;
  ...
  
  reset() {
    ...
    // 新建蛇
    this.snake = new Snake(this.game, this);
    // 添加蛇到地图中
    this.children = [this.snake];
  }
}
  • 这时,game对象里的children有map,map的children里有snake,所以调用game.render(),就可以把蛇绘制出来了,下图那三个vue的logo就是蛇身

移动蛇

  • 有了静态蛇,下一步我们的任务就是让蛇动起来,我们监听键盘的上下左右键,控制蛇往上下左右方向移动,为此我们继续给class snake添加属性
javascript 复制代码
// DIRECTION定义为一个正数一个负数,是为了方便判断哪两个方向是正好相反的
// 因为当蛇沿着某一个方向运动时,无法直接让其调头
enum DIRECTION {
  LEFT = -1,
  RIGHT = 1,
  UP = -2,
  DOWN = 2,
}

class Snake extends GameObject {
  ...
  // 添加一个方向属性,记录当前蛇运动的方向,初始化为向右
  private _direction: DIRECTION = DIRECTION.RIGHT;
  ...
  
  removeListeners() {
    // 移除监听
    document.body.removeEventListener('keydown', this.snakeMoveListener.bind(this));
  }
  
  registerListeners() {
    // 注册监听
    this.removeListeners();
    document.body.addEventListener('keydown', this.snakeMoveListener.bind(this))
  }
  
  changeDirection(direction: DIRECTION) {
    if ((direction + this._direction) === 0) return; // 不能直接调头
    this._direction = direction;
  }
  
  snakeMoveListener(event: KeyboardEvent) {
    const { code } = event;
    switch(code) {
      // 判断按钮的code,对蛇进行转向
      case 'ArrowUp':
        this.changeDirection(DIRECTION.UP);
        break;
      case 'ArrowRight':
        this.changeDirection(DIRECTION.RIGHT);
        break;
      case 'ArrowLeft':
        this.changeDirection(DIRECTION.LEFT);
        break;
      case 'ArrowDown':
        this.changeDirection(DIRECTION.DOWN);
        break;
    }
  }
  
  reset() {
    ...
    // 重置方向
    this._direction = DIRECTION.RIGHT;
    this.registerListeners();
  }
}
  • 上面的代码仅对蛇的方向进行改变,但并没有实际移动蛇,事实上,我们希望蛇是自动移动的,每隔300ms,就在当前方向上移动一格,我们改变方向也仅仅是改变蛇下一次移动的方向,并不会实际移动蛇,首先我们来实现移动一格这个功能
javascript 复制代码
class Snake extends GameObject {
  ...
  // 这里定义了两个方向,_direction代表当前的移动方向,direction代表下一次移动的方向
  // 这是因为,蛇是自动移动的,如果在移动间隔内,用户手快,改变了两次方向,则可能跳过不能调头的限制,导致蛇运行轨迹错乱
  private direction: DIRECTION = DIRECTION.RIGHT;
  private _direction: DIRECTION = DIRECTION.RIGHT;
  // 蛇运动之后,存在游戏结束的可能,即蛇自己咬到自己,onGameOver就是回调,可通过外部赋值覆盖
  onGameOver = () => { /* */ }
  ...
  
  reset() {
    ...
    this.direction = this._direction = DIRECTION.RIGHT;
    ...
  }
  
  changeDirection(direction: DIRECTION) {
    if ((direction + this._direction) === 0) return; // 不能调转方向
    // 只改变下一次运动的方向,当前移动方向_direction不变
    this.direction = direction;
  }
  
  // 真正移动的方法
  move() {
    // items长度为0,则蛇身是0,此时不处理
    if (!this.items.length) return;
    // 真正move的时候才更新_direction,防止手速过快的用户点击
    this._direction = this.direction;
    switch(this.direction) {
      case DIRECTION.RIGHT:
        this.moveRight()
        break;
      case DIRECTION.LEFT:
        this.moveLeft();
        break;
      case DIRECTION.UP:
        this.moveUp();
        break;
      case DIRECTION.DOWN:
        this.moveDown();
        break;
      default:
        break;
    }
  }
  
  private moveRight() {
    const lastItem = this.items[this.items.length - 1];
    // 往右移动,行不变,列变
    const newCol = (lastItem.colIdx + 1) % this.map.col;
    this.snakeMove(lastItem.rowIdx, newCol)
  }

  private moveLeft() {
    const lastItem = this.items[this.items.length - 1];
    // 往左移动,行不变,列变
    const newCol = (lastItem.colIdx - 1 + this.map.col) % this.map.col;
    this.snakeMove(lastItem.rowIdx, newCol)
  }
  
  private moveUp() {
    const lastItem = this.items[this.items.length - 1];
    // 往上移动,行变,列不变
    const newRow = (lastItem.rowIdx - 1 + this.map.row) % this.map.row;
    this.snakeMove(newRow, lastItem.colIdx)
  }

  private moveDown() {
    const lastItem = this.items[this.items.length - 1];
    // 往下移动,行变,列不变
    const newRow = (lastItem.rowIdx + 1) % this.map.row;
    this.snakeMove(newRow, lastItem.colIdx)
  }
  
  private snakeMove(rowIdx: number, colIdx: number) {
    // 获取当前位置的地图类型
    const cellType = this.map.getCellType(rowIdx, colIdx);
    // 蛇头新位置
    const newItem = new SnakePoint(this.game, this.map, rowIdx, colIdx);
    // 新位置也是蛇,则吃到自己 game over
    if (cellType === CELL_TYPE.SNAKE) {
      this.onGameOver();
      return;
    }
    // 否则每个点往前挪,先更新蛇尾位置,变回empty
    this.map.updateCellType(this.items[0].rowIdx, this.items[0].colIdx, CELL_TYPE.EMPTY);
    // 接着遍历每个点,用原本下一个节点的信息更新当前的点
    this.items.forEach((item, index) => {
      const nextIdx = index + 1;
      if (nextIdx < this.items.length) {
        item.updatePoint(this.items[nextIdx]);
      } else {
        // 最后一个元素用刚刚新建的蛇头进行更新
        item.updatePoint(newItem);
      }
    });
    // 新的蛇头位置网格类型需要更新为snake,之前的位置因为原本就是蛇,所以无需更新
    this.map.updateCellType(newItem.rowIdx, newItem.colIdx, CELL_TYPE.SNAKE);
  }
}
  • 上面代码提到一个updatePoint的方法,这是因为,本游戏的实现上,尽可能的复用已有的元素,所以蛇移动了之后,需要修改原有的元素上的一些属性,让他变成一个"新"的点,主要是修改其行列序号等一些信息
javascript 复制代码
class Point {
  ...
  updatePoint(point: Point) {
    // 点主要修改以下属性,其余保持不变
    this.scale = point.scale;
    this.colIdx = point.colIdx;
    this.rowIdx = point.rowIdx;
    this.x = point.x;
    this.y = point.y;
  }
  ...
}

class Cube {
  ...
  updatePoint(cube: Cube) {
    // 矩形修改的属性
    this.colIdx = cube.colIdx;
    this.rowIdx = cube.rowIdx;
    this.x = cube.x;
    this.y = cube.y;
    this.translateX = cube.translateX;
    this.translateY = cube.translateY;
    this.width = cube.width;
    this.height = cube.height;
  }
  ...
}
  • 有了上面的代码,就可以实现了往移动方向挪动一个网格的效果了,接下来,只需要由外部元素通过setInterval调用snake.move(),再通过game.render()更新canvas渲染,就可以使蛇自己往前走,看看效果
  • 蛇的逻辑处理完了,最后添加食物

食物

先给出食物的逻辑:

  • 地图上最多存在三个食物
  • 食物也是由贴图组成的
  • 每个食物的贴图不同,生成时随机决定
  • 蛇吃了食物后,蛇尾变成对应食物的贴图

食物的初始化

  • 由上面规则可以知道不同食物,贴图不同,首先我们要先加载对应的贴图
javascript 复制代码
  const game = new Game('#playground');
  await game.texture.createTexture('logo', require('./assets/logo.png'));
  await game.texture.createTexture('food1', require('./assets/food_1.png'));
  await game.texture.createTexture('food2', require('./assets/food_2.png'));
  await game.texture.createTexture('food3', require('./assets/food_3.png'));
  await game.texture.createTexture('food4', require('./assets/food_4.png'));
  • 有了贴图,来定义食物的类
javascript 复制代码
export enum CELL_TYPE {
  SNAKE = 1,
  FOOD = 2, // 添加一个食物的网格类型
  EMPTY = 0,
}

function getRandomInt(min: number, max: number) {
  // 返回整数随机数
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

class FoodPoint extends Cube {
  // 一个食物
  width = 15;
  height = 15;

  constructor(game: Game, map: Map, rowIdx: number, colIdx: number) {
    // 食物初始化的时候,随机给一个id,对应之前加载的食物纹理food1/food2/food3/food4
    const foodID = getRandomInt(1, 4);

    super(game, map, rowIdx, colIdx, CELL_TYPE.FOOD, `food${foodID}`);
  }
}

class Foods extends GameObject {
  // 这个类是一组食物,而不是一个食物,所有食物都用这个类统一管理
  items: FoodPoint[] = [];
  map: Map;
  game: Game;
  private maxCount = 3; // 最多三个食物
  
  constructor(game: Game, map: Map) {
    super();
    this.game = game;
    this.map = map;
    this.reset();
  }
  
  createFood() {
    if (this.items.length >= this.maxCount) return;
    // 判断当前是否有空余的网格了,如果没有 则不继续生成食物
    const emptyCells = this.map.emptyCells;
    if (!emptyCells.length) return;
    // 从剩余网格里取一个格子作为新增食物的位置
    const [newRowIdx, newColIdx] = emptyCells[getRandomInt(0, emptyCells.length - 1)];
    const newFood = new FoodPoint(this.game, this.map, +newRowIdx, +newColIdx);
    this.items.push(newFood);
  }
  
  reset() {
    // 重置食物列表
    this.items = [];
  }
}
  • class Foods初始化的时候,不会马上添加食物,食物通过外部调用生成的,生成的方法里,我们调用了class MapemptyCells属性,是用来获取所有不为食物,也不为蛇的格子,实现如下
javascript 复制代码
class Map extends GameObject {
  ...
  get emptyCells() {
    // empty的枚举是0,filter出0的格子,然后split获取对应的行列
    return Object.keys(this.cellTypeMap).filter(key => !this.cellTypeMap[key]).map(key => key.split(','));
  }
  ...
}
  • 蛇会自动移动,食物会自动生成,其间隔我们约定是一样的,因此可以把移动蛇的方法和生成食物的方法统一写入map中,后续只需要调用map的方法,就可以一次性生成食物和移动蛇了
javascript 复制代码
class Map extends GameObject {
  ...
  update() {
    this.snake.move();
    this.foods.createFood();
  }

  reset() {
    ...
    this.foods = new Foods(this.game, this);
    this.snake = new Snake(this.game, this, this.foods);
    this.children = [this.snake, this.foods];
  }
}
  • reset方法可以重置地图状态,在初始化,以及game over之后可以用于重置
  • update方法则是统一更新食物和蛇,在外部可以通过setInterval调用map.update()以及game.render()从而进行游戏
  • 效果如下

吃到食物

  • 最后我们需要实现蛇吃食物的逻辑,蛇吃到食物后,蛇尾变成食物的贴图,同时食物消失,首先是改造class Snake,在蛇移动过程中,碰到食物的网格时
javascript 复制代码
class Snake extends GameObject {
  ...
  private snakeMove(rowIdx: number, colIdx: number) {
    const cellType = this.map.getCellType(rowIdx, colIdx);
    const newItem = new SnakePoint(this.game, this.map, rowIdx, colIdx);
    if (cellType === CELL_TYPE.SNAKE) {
      this.onGameOver();
      return;
    }
    // 每个点往前挪
    this.map.updateCellType(this.items[0].rowIdx, this.items[0].colIdx, CELL_TYPE.EMPTY);
    this.items.forEach((item, index) => {
      const nextIdx = index + 1;
      if (nextIdx < this.items.length) {
        item.updatePoint(this.items[nextIdx]);
      } else {
        item.updatePoint(newItem);
      }
    });
    this.map.updateCellType(newItem.rowIdx, newItem.colIdx, CELL_TYPE.SNAKE);
    if (cellType === CELL_TYPE.FOOD) {
      // 查看吃到哪个食物,根据newItem里的行列序号确认
      const foodItem = this.foods.eat(newItem);
      if (foodItem) {
        // 吃到食物变成对应食物的贴图,蛇尾插一个新元素,纹理和食物相同
        const newTailItem = new SnakePoint(this.game, this.map, foodItem.rowIdx, foodItem.colIdx);
        // 变色并加到末尾
        newTailItem.texture = foodItem.texture;
        this.items.unshift(newTailItem);
      }
      return;
    }
  }
  ...
}
  • 改造class Foods,支持被吃
javascript 复制代码
class Foods extends GameObject {
  ...
  eat(point: Cube) {
    // 找出被吃的水果,从队列里删除并返回
    const index = this.items.indexOf(this.items.filter(item => item.rowIdx === point.rowIdx && item.colIdx === point.colIdx)[0]);
    if (index > -1) {
      const deleteItem = this.items.splice(index, 1)[0];
      // 删除了对应食物,对应食物就不会渲染了
      // 同时因为吃到食物的时候,new了一个snakePoint,所以这个位置的网格类型已被更新为snake,这里无需处理
      return deleteItem;
    }
  }
  ...
}
  • 终于到最后一步了,运行游戏,大功告成

写在最后

  • 其实这个游戏用到webgl的内容是不多的,只有绘制点和一个draw call里绘制多个贴图这两个方法,更多的是考虑如何去设计一个游戏框架,如何进行渲染,如何控制各元素之间的层级关系,系统内各个模块(蛇/地图/食物)之间如何协作等等;虽然这个demo写的较为简陋(去掉较为),但也可以借此一窥那些游戏框架究竟做了一些什么工作;
  • 另外在实现过程里也可以看出webgl的一个优化方向,是如何协调空间和时间的平衡,一个drawcall可以绘制多个贴图(节省数据传输的时间),但是需要更多的顶点信息(增加内存的使用),反之亦然;
  • 将同类型的元素(点或贴图)提取到一个draw call里渲染,或放在相邻的顺序渲染,也能优化性能;
  • 还有就是webgl内部可以保存的纹理数量是有限的,如果超过限制就需要有动态创建/销毁纹理的逻辑;
  • 最后提供图片合成(精灵图)或自动整合图片为一个大纹理也是可以优化的方向;
  • 源码在此
相关推荐
小猪努力学前端8 小时前
基于PixiJS的小游戏广告开发
前端·webgl·游戏开发
光影少年2 天前
WebGIS 和GIS学习路线图
学习·前端框架·webgl
DBBH2 天前
Cesium源码分析之渲染3DTile的一点思考
图形渲染·webgl·cesium.js
Robet2 天前
TS2d渲染引擎
webgl
Robet3 天前
WebGL2D渲染引擎
webgl
goodName4 天前
如何实现精准操控?Cesium模型移动旋转控件实现
webgl·cesium
丫丫7237346 天前
Three.js 模型树结构与节点查询学习笔记
javascript·webgl
allenjiao8 天前
WebGPU vs WebGL:WebGPU什么时候能完全替代WebGL?Web 图形渲染的迭代与未来
前端·图形渲染·webgl·threejs·cesium·webgpu·babylonjs
mapvthree9 天前
mapvthree Engine 设计分析——二三维一体化的架构设计
webgl·数字孪生·mapvthree·jsapi2d·jsapigl·引擎对比
GISer_Jing10 天前
3D Cesium渲染架剖析
javascript·3d·webgl