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内部可以保存的纹理数量是有限的,如果超过限制就需要有动态创建/销毁纹理的逻辑;
  • 最后提供图片合成(精灵图)或自动整合图片为一个大纹理也是可以优化的方向;
  • 源码在此
相关推荐
不惑_3 天前
最佳ThreeJS实践 · 实现赛博朋克风格的三维图像气泡效果
javascript·node.js·webgl
小彭努力中4 天前
50. GLTF格式简介 (Web3D领域JPG)
前端·3d·webgl
小彭努力中4 天前
52. OrbitControls辅助设置相机参数
前端·3d·webgl
幻梦丶海炎5 天前
【Threejs进阶教程-着色器篇】8. Shadertoy如何使用到Threejs-基础版
webgl·threejs·着色器·glsl
小彭努力中5 天前
43. 创建纹理贴图
前端·3d·webgl·贴图
小彭努力中6 天前
45. 圆形平面设置纹理贴图
前端·3d·webgl·贴图
Ian10256 天前
webGL入门(五)绘制多边形
开发语言·前端·javascript·webgl
小彭努力中7 天前
49. 建模软件绘制3D场景(Blender)
前端·3d·blender·webgl
优雅永不过时·10 天前
使用three.js 实现着色器草地的效果
前端·javascript·智慧城市·webgl·three·着色器
baker_zhuang11 天前
Threejs创建胶囊体
webgl·threejs·web3d