摘要
好久没更新了,之前几篇文章,简单的介绍了一些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)
}
}
createShader
,createProgram
,setUniformMat
,setAttribute
这些方法也都是之前文章介绍过的,具体代码参考这里- 可以看到我们定义点,除了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_texCoord
、v_texCoord
,投影矩阵u_projection
,还多了两个a_textureIndex
和v_textureIndex
,这两个就是用来指定当前点使用的是哪个贴图的,代表贴图数组里的index,由于该值需要从顶点shader传递到片元shader,所以有a_xxx
和v_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
字段,表明当前元素是一个点,还是一个矩形,同时矩形需要有width
和height
属性,另外增加一个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;
}
}
- 如此我们就可以通过
getCellType
和updateCellType
来获取和更新地图上的网格类型,由于在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 Cube
和class Point
几乎是一样的,都记录了其在地图上的行列index,不同的是class Point
决定大小的是scale
,class Cube
决定大小的是width
和height
,同时,支持在初始化阶段指定对应纹理 - 蛇身上每个点都是一个矩形,我们拓展一下
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 Map
的emptyCells
属性,是用来获取所有不为食物,也不为蛇的格子,实现如下
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内部可以保存的纹理数量是有限的,如果超过限制就需要有动态创建/销毁纹理的逻辑;
- 最后提供图片合成(精灵图)或自动整合图片为一个大纹理也是可以优化的方向;
- 源码在此