来用原生JavaScript,做一个能吃满全图的AI贪吃蛇吧

900 阅读12分钟

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

项目预览

仓库地址: github

online demo: github pages , gitee pages

截图预览:

  1. 桌面端:

  2. 移动端:

  3. AI模式:

简介

贪吃蛇是一款很经典的游戏,相信大家都有玩过,我也是非常喜欢这款游戏,这次使用原生JS配合Canvas API实现了一版像素画风的贪吃蛇。

游戏的玩法就是最简单的贪吃蛇规则,食物在随机位置出现,🐍吃到食物会长大,并且速度也会逐渐加快(有上限),咬到自己或者创到墙壁🐍会死亡,吃满了整张地图则游戏成功。

个人感觉游戏初期蛇移动的较慢,比较浪费时间,所以还额外增加了冲刺功能,按下空格键,可以做到一个短暂加速的冲刺效果,这样可以加快前期发育的速度(也更容易作死)。

支持提前输入,(魂系列玩家应该都知道这是啥),快速多次按下方向键,输入的方向会被缓存,并依次使用。举个🌰,假设🐍的方向是右,玩家可以快速依次按下了下键和左键,蛇会先向下移动一格再向左移动一格。如果不支持提前输入,一旦玩家按键速度大于蛇移动速度,玩家的一些操作就可能丢失,所以个人认为这个设定还是有必要的。

另外,还增加了一个全自动游玩的AI模式,AI模式中🐍会自行寻找路径去吃食物,玩家只需观赏就好,不过目前还不完美,🐍有时候会把自己创死。

本项目完全使用原生JS编写,除了打包工具以外没有依赖任何类库。

项目介绍

1,目录结构

│
├─public
│      favicon.ico
│      index.html -------- 入口HTML文件
│
└─src
    │  ai.js ------------- AI逻辑
    │  aStar.js ---------- AStar寻路算法
    │  event.js ---------- 简易的事件监听器
    │  food.js ----------- 食物组件
    │  gameMap.js -------- 地图组件
    │  gameObject.js ----- 基础游戏组件
    │  index.js ---------- 入口文件,游戏主逻辑
    │  keyboard.js ------- 键盘事件处理
    │  mainLoop.js ------- 简易的游戏主循环
    │  priorityQueue.js -- 优先队列(基于堆)
    │  renderer.js ------- 简易渲染器
    │  resultText.js ----- 游戏结果组件
    │  utils.js ---------- 工具方法
    │
    └─snake -------------- 蛇组件
            constants.js
            index.js
            snake.js ----- 蛇组件
            snakeBody.js - 蛇基础节点组件
            snakeHead.js - 蛇头节点组件
            snakeTail.js - 蛇尾节点组件

2,基础原理

2.1,基础游戏组件

通过堆贪吃蛇游戏进行直观的分析,不难看出,游戏主要由地图、蛇、食物这三种元素构成。我们可以把这三种元素归类成游戏的组件,通过定义一个GameObject类型来表示游戏组件,游戏组件用于描述游戏中某个元素的位置、渲染以及功能逻辑。

/** GameObject.js */

export class GameObject {
  constructor (x, y) {
    // 描述位置、可见性
    this.x = x
    this.y = y
    this.visible = true
  }

  // 子类在这里实现自己的功能逻辑
  update () {}

  // 子类在这里实现渲染逻辑
  draw (renderer) {}
}

有了这个抽象的GameObject类,我们可以通过继承的方式分别实现地图、蛇、食物三种子类,三种子类拥有统一的更新方法及渲染方法,便于后续的管理。

2.2,游戏循环

借助浏览器的requestAnimationFrame方法,我们可以轻松的创建一个刷新频率为60FPS(通常情况下)的游戏循环,每次循环的时候更新各组件的状态,清空画布并重新绘制各组件,即可实现游戏的运行。

实现一个MainLoop类,用于管理游戏循环。

/** mainLoop.js */

const LOOP_STATUS = {
  RUNNING: 0,
  PAUSED: 1,
  STOPPED: 2
}

export class MainLoop {
  loopStatus = LOOP_STATUS.PAUSED;
  onLoop;
  lastTimeStamp;

  // 设置每次循环时调用的函数
  setOnLoop (onLoop) { this.onLoop = onLoop }

  // 每次循环时计算出时间差,并调用setOnLoop设置的函数
  loop (timestamp) {
    const elapsed = timestamp - this.lastTimeStamp
    this.lastTimeStamp = timestamp
    this.onLoop && this.onLoop(elapsed)
    window.requestAnimationFrame(this.loop.bind(this))
  }

  // 使用以下三个方法可以控制循环状态
  start () {
    if (this.loopStatus === LOOP_STATUS.PAUSED) {
      this.loopStatus = LOOP_STATUS.RUNNING
      this.lastTimeStamp = window.performance.now()
      window.requestAnimationFrame(this.loop.bind(this))
    }
  }
  pause () {
    if (this.loopStatus === LOOP_STATUS.RUNNING) {
      this.loopStatus = LOOP_STATUS.PAUSED
    }
  }
  stop () {
    if (this.loopStatus !== LOOP_STATUS.STOPPED) {
      this.loopStatus = LOOP_STATUS.STOPPED
    }
  }
}

有了循环以后,我们可以维护一个游戏组件列表,每次循环时依次刷新列表中的组件。

/** MainLoop使用示例 */
const gameObjects = [gameMap, snake, food];
mainLoop.setOnLoop((elapsed) => {
    // 更新
	gameObjects.forEach(el => el.update(elapsed));
    // 渲染
    gameObjects.forEach(el => el.draw());
})
mainLoop.start()

2.3,处理键盘事件

利用在window对象上监听keydown、keyup事件的方式可以获取到键盘的状态,我们可以把键盘状态缓存在一个对象中,使用一个简单的KeyBoard类,用于获取单个按键的情况。

const KEY_STATUS = {
  PRESSED: 'pressed',
  RELEASED: 'released'
}

// 缓存按键的情况
const keyStatus = {}

// 监听输入,向keyStatus中更新按键情况
window.addEventListener('keydown', (event) => {
  const code = event.code
  keyStatus[code] = KEY_STATUS.PRESSED
})
window.addEventListener('keyup', (event) => {
  const code = event.code
  keyStatus[code] = KEY_STATUS.RELEASED
})

// 用于方便的获取单个按键的情况
export class Keyboard {
  static isPressed (key) {
    return keyStatus[key] === KEY_STATUS.PRESSED
  }
  static isReleased (key) {
    return keyStatus[key] === KEY_STATUS.RELEASED
  }

  constructor (key) { his.key = key }

  isPressed () {
    return Keyboard.isPressed(this.key)
  }
  isReleased () {
    return Keyboard.isReleased(this.key)
  }
}

有了KeyBoard类,我们可以很方便的创建出一个处理输入逻辑的函数。

/** Keyboard类使用示例 */
// 创建关联相应键值的Keyboard实例
const keyArrowUp = new Keyboard('ArrowUp')
const keyArrowRight = new Keyboard('ArrowRight')
const keyArrowLeft = new Keyboard('ArrowLeft')
const keyArrowDown = new Keyboard('ArrowDown')

// 根据按下不同键位,切换蛇的方向
const checkInput = () => {
  if (keyArrowUp.isPressed()) {
    snake.changeDirection(DIRECTION.UP)
  } else if (keyArrowRight.isPressed()) {
    snake.changeDirection(DIRECTION.RIGHT)
  } else if (keyArrowDown.isPressed()) {
    snake.changeDirection(DIRECTION.DOWN)
  } else if (keyArrowLeft.isPressed()) {
    snake.changeDirection(DIRECTION.LEFT)
  }
}

2.4,游戏基本结构

把游戏组件、游戏循环以及键盘事件三个模块组合起来,我们就可以得到游戏的基本机构。

/** 伪代码示例 */
const gameObjects = [gameMap, snake, food, resultText];
mainLoop.onLoop(() => {
    // 检查输入
    if (checkInput()) {
        snake.changeDirection(input);
    }
    
    // 处理蛇吃到食物时的逻辑
    if (snake.isEat(food)) {
        snake.grow();
        snake.speedUp();
        createNewFood();
    }
    // 游戏结束时显示文案
    if (isSuccess || isFail) {
        resultText.visible = true;
    }
    
    // 绘制各组件
    gameObjects.forEach(el => el.draw());
})
mainLoop.start()

3,像素风

游戏的画面是绘制在Canvas元素上,因为本人太菜了,不会webgl,所以只能使用Canvas API完成绘制。

为了方便编写绘制逻辑,我们可以实现一个Renderer类,封装一些常用的渲染操作。

/** renderer.js */
export class Renderer {
  // 获取Canvas Context实例
  constructor (canvas) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
  }

  // 清空Canvas
  clear (fillColor) {
    if (!fillColor) {
      this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    } else {
      this.ctx.save()
      this.ctx.fillStyle = fillColor
      this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
      this.ctx.restore()
    }
  }

  // 绘制矩形
  drawRect (x, y, width, height, color) {
    const { ctx } = this
    ctx.save()
    ctx.fillStyle = color
    ctx.fillRect(x, y, width, height)
    ctx.restore()
  }

  // 绘制文字
  drawText (x, y, text, color, font) {
    const { ctx } = this
    ctx.save()
    ctx.textAlign = 'left'
    ctx.textBaseline = 'top'
    ctx.font = font
    ctx.fillStyle = color
    ctx.fillText(text, x, y)
    ctx.restore()
  }

  // 测量文字宽高
  measureText (text, font) {
    const { ctx } = this
    ctx.save()
    ctx.font = font
    const textMetrics = ctx.measureText(text)
    ctx.restore()

    const width = textMetrics.width
    const height = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent
    return { width, height }
  }
}

我打算把这款游戏做成像素画风,像素画实际就是一堆颜色各异的小方块的排列组合,使用二维数组即可描述。

遍历二维数组,根据下标确定位置,根据元素值确定颜色,在相应的位置绘制出指定颜色的方块,最后就能绘制出一幅像素画。

/** 伪代码示例 */
// 颜色数组
const COLORS = [
  '',
  '#59b574', '#4e1413', '#832525', '#bc3532', '#e23f40', '#e78385',
  '#fceaaa', '#f3de70', '#cf641e', '#f37a2a', '#8e4413'
]

// 像素画二维数组,数组元素是COLORS数组元素的下标
const pixelData = [
  [0, 0, 0, 1, 0, 0, 0, 0],
  [0, 0, 0, 0, 2, 0, 0, 0],
  [0, 0, 3, 3, 2, 3, 0, 0],
  [0, 3, 6, 6, 2, 4, 3, 0],
  [0, 3, 5, 5, 5, 4, 3, 0],
  [0, 3, 4, 4, 4, 4, 3, 0],
  [0, 0, 3, 3, 3, 3, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0]
]

// 绘制时每个“像素”的大小
const pixelSize = 4

// 遍历二维数组进行绘制
for (let r = 0; r < pixelData.length; r++) {
  for (let c = 0; c < pixelData[r].length; c++) {
    if (pixelData[r][c] !== 0) {
      renderer.drawRect(
        x + c * pixelSize, // 位置
        y + r * pixelSize,
        pixelSize, // 大小
        pixelSize,
        COLORS[pixelData[r][c]] // 颜色
      )
    }
  }
}

4,食物组件

已经确定了像素画风的实现原理,那么构造一个食物组件就很简单了,并且为了给游戏增加一些花样,我们可以做出多种外观的食物,通过一个type字段来进行切换。

/** food.js */

import { GameObject } from './gameObject.js'
import { LP2RP } from './utils.js'

// 定义食物类型
export const TYPE = {
  APPLE: 0,
  CHERRY: 1,
  BANANA: 2,
  WATERMELON: 3
}

const COLORS = [
  '',
  '#59b574', '#4e1413', '#832525', '#bc3532', '#e23f40', '#e78385',
  '#fceaaa', '#f3de70', '#cf641e', '#f37a2a', '#8e4413'
]

// 食物类型对应的像素点阵
const PIXEL_DATAS = {
  [TYPE.APPLE]: [
    [0, 0, 0, 1, 0, 0, 0, 0],
    [0, 0, 0, 0, 2, 0, 0, 0],
    [0, 0, 3, 3, 2, 3, 0, 0],
    [0, 3, 6, 6, 2, 4, 3, 0],
    [0, 3, 5, 5, 5, 4, 3, 0],
    [0, 3, 4, 4, 4, 4, 3, 0],
    [0, 0, 3, 3, 3, 3, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0]
  ],
  [TYPE.CHERRY]: [/** 省略 */],
  [TYPE.BANANA]: [/** 省略 */],
  [TYPE.WATERMELON]: [/** 省略 */],
}

export class Food extends GameObject {
  constructor (x, y, pixelSize, type) {
    super(x, y)
    this.pixelSize = pixelSize
    // type字段控制绘制哪种点阵
    this.type = type
  }

  update () {}

  // 绘制
  draw (renderer) {
    const { pixelSize, type } = this
    // type字段控制绘制哪种点阵
    const pixelData = PIXEL_DATAS[type]
    const { x, y } = LP2RP(this.x, this.y, pixelSize)
    for (let r = 0; r < pixelData.length; r++) {
      for (let c = 0; c < pixelData[r].length; c++) {
        if (pixelData[r][c] !== 0) {
          renderer.drawRect(
            x + c * pixelSize,
            y + r * pixelSize,
            pixelSize,
            pixelSize,
            COLORS[pixelData[r][c]]
          )
        }
      }
    }
  }
}

5,地图组件

地图组件比食物组件复杂一些,我是想做成用树来做为地图边界,目前只画了两种树的像素点阵,每次进入页面时随机生成一个树的序列,根据这个序列去绘制四边的树。

/** gameMap.js */
import { GameObject } from './gameObject.js'
import { getRandomInt, LP2RP } from './utils.js'

const COLORS = [
  '',
  '#23d57d', '#11804b', '#084b2a', '#8f4613',
  '#cb651b', '#522a07', '#6e7679'
]

// 两种树
const PIXEL_DATAS = {
  // 普通树
  TREE: [/** 省略 */],
  // 橡树
  OAK: [/** 省略 */],
}

export class GameMap extends GameObject {
  constructor (config) {
    const { x, y, rowNums, colNums, pixelSize } = config
    super(x, y)
    this.rowNums = rowNums
    this.colNums = colNums
    this.pixelSize = pixelSize

    // 生成树类型序列
    this._treeTypeArr = Array(rowNums * 2 + colNums * 2 + 4)
    for (let i = 0; i < this._treeTypeArr.length; i++) {
      this._treeTypeArr[i] = ['TREE', 'OAK'][getRandomInt(0, 2)]
    }
  }

  update () {}

  draw (renderer) {
    this._drawTrees(renderer)
  }

  // 绘制单个像素点阵
  _drawCell (renderer, x, y, type) {
    const pixelData = PIXEL_DATAS[type]
    const { pixelSize } = this
    const { x: rx, y: ry } = LP2RP(x, y, pixelSize)
    for (let r = 0; r < pixelData.length; r++) {
      for (let c = 0; c < pixelData[r].length; c++) {
        if (pixelData[r][c] !== 0) {
          renderer.drawRect( rx + c * pixelSize, ry + r * pixelSize, pixelSize, pixelSize, COLORS[pixelData[r][c]])
        }
      }
    }
  }

  // 批量绘制树
  _drawTrees (renderer) {
    let typeIndex = 0
    for (let r = 0; r < this.rowNums + 2; ++r) {
      this._drawCell(renderer, 0, r, this._treeTypeArr[typeIndex++])
      this._drawCell(renderer, this.colNums + 1, r, this._treeTypeArr[typeIndex++])
    }
    for (let c = 1; c <= this.colNums; ++c) {
      this._drawCell(renderer, c, 0, this._treeTypeArr[typeIndex++])
      this._drawCell(renderer, c, this.colNums + 1, this._treeTypeArr[typeIndex++])
    }
  }
}

6,蛇组件

6.1,数据结构

前面介绍的食物与地图都是纯展示型的组件,蛇组件比较复杂,蛇的长度需要动态变化,所以在蛇组件中还需要实现相关的逻辑。

蛇是由一个个节点组成的,并且每次移动所有节点的位置基本都要变化,后一个节点需要移动至前一个节点的位置,线性表结构适合处理这种节点间的关系。

除了移动以外,蛇还会成长,每次成长会多出一个节点,所以还需要对线性表经常进行插入操作。

考虑到以上两点,最终项目里使用了链表结构来表示蛇,每次成长的时候只需要在头节点和第二个节点之间插入新节点即可。

// 双链表节点
class ListNode {
  constructor (value, prev, next) {
    this.value = value
    this.next = next || null
    this.prev = prev || null
  }
}

export class Snake extends GameObject {
  constructor (config) {
    const { length, rowNums, colNums, pixelSize } = config
    super(0, 0)
    
    // 构造链表
    this.head = new ListNode(new SnakeHead(length, 1, pixelSize, SnakeHead.TYPES.RIGHT))
    let tail = this.head
    for (let i = 1; i < length - 1; i++) {
      tail.next = new ListNode(new SnakeBody(length - i, 1, pixelSize, SnakeBody.TYPES.HORIZONTAL), tail)
      tail = tail.next
    }
    tail.next = new ListNode(new SnakeTail(1, 1, pixelSize, SnakeTail.TYPES.RIGHT), tail)
    tail = tail.next
    this.tail = tail
  }
}

6.2,三种节点

目前设计了三种蛇的节点类型,分别用于表示头节点,身体节点与尾节点,这三种均是纯展示型的组件,只是头、尾节点只需要处理4种方向,身体节点需要处理8种方向。

ps:其实设计成三种组件有些浪费,可以考虑合到一个组件中,使用字段区分头尾和身体。

import { GameObject } from '../gameObject.js'
import { DIRECTION } from './constants.js'

export class SnakeBody extends GameObject {
  // 方向类型
  static TYPES = {
    HORIZONTAL: 0,
    VERTICAL: 1,
    LEFTTOP: 2,
    RIGHTTOP: 3,
    LEFTBOTTOM: 4,
    RIGHTBOTTOM: 5,
    UP: 6,
    RIGHT: 7,
    DOWN: 8,
    LEFT: 9
  }

  // 头节点只有这些类型
  static ENDPOINT_TYPES = [
    SnakeBody.TYPES.UP,
    SnakeBody.TYPES.RIGHT,
    SnakeBody.TYPES.DOWN,
    SnakeBody.TYPES.LEFT
  ]

  // 把头节点的类型转换成无拐角的身体节点的类型
  static ENDPOINT_TYPES_TO_TYPES = {
    [SnakeBody.TYPES.UP]: SnakeBody.TYPES.VERTICAL,
    [SnakeBody.TYPES.RIGHT]: SnakeBody.TYPES.HORIZONTAL,
    [SnakeBody.TYPES.DOWN]: SnakeBody.TYPES.VERTICAL,
    [SnakeBody.TYPES.LEFT]: SnakeBody.TYPES.HORIZONTAL
  }

  // 两种方向的组合,产生对应的拐角类型
  static DIRECTION_TO_TYPE = {
    ['' + DIRECTION.UP + DIRECTION.RIGHT]: SnakeBody.TYPES.LEFTTOP,
    ['' + DIRECTION.UP + DIRECTION.LEFT]: SnakeBody.TYPES.RIGHTTOP,
    ['' + DIRECTION.RIGHT + DIRECTION.DOWN]: SnakeBody.TYPES.RIGHTTOP,
    ['' + DIRECTION.RIGHT + DIRECTION.UP]: SnakeBody.TYPES.RIGHTBOTTOM,
    ['' + DIRECTION.DOWN + DIRECTION.LEFT]: SnakeBody.TYPES.RIGHTBOTTOM,
    ['' + DIRECTION.DOWN + DIRECTION.RIGHT]: SnakeBody.TYPES.LEFTBOTTOM,
    ['' + DIRECTION.LEFT + DIRECTION.UP]: SnakeBody.TYPES.LEFTBOTTOM,
    ['' + DIRECTION.LEFT + DIRECTION.DOWN]: SnakeBody.TYPES.LEFTTOP
  }

  static COLORS = [/** 颜色数据,过长,省略 */];
  static PIXEL_DATAS = {/** 点阵数据,过长,省略 */}

  constructor (x, y, pixelSize, type) {
    super(x, y)
    this.pixelSize = pixelSize
    
    // 方向类型
    this.type = type || SnakeBody.TYPES.HORIZONTAL
  }

  update () {}
  draw (renderer) {/** 渲染像素点阵,省略 */}
}

6.3,蛇的移动

移动比较简单,蛇是双链表结构,只需要从后向前的遍历链表,把前驱节点的位置及方向赋值给当前节点即可。

move () {
  // 遍历链表,把前驱节点的位置及方向赋值给当前节点
  for (let p = this.tail; p.prev; p = p.prev) {
    // 赋值位置
    p.value.x = p.prev.value.x
    p.value.y = p.prev.value.y
    // 赋值方向类型
    if (SnakeBody.ENDPOINT_TYPES.includes(p.prev.value.type)) {
      p.value.type = SnakeBody.ENDPOINT_TYPES_TO_TYPES[p.prev.value.type]
    } else {
      p.value.type = p.prev.value.type
    }
  }
  // 计算尾节点的类型
  this.tail.value.type = this._calcTailType()
  // 计算头节点的新位置及类型
  const head = this.head
  const direction = directionValues[this.direction]
  head.value.x += direction[1]
  head.value.y += direction[0]
  // 如果方向变化,将产生拐角类型
  if (this.direction !== this._lastDirection) {
    head.value.type = SnakeHead.DIRECTION_TO_TYPE[this.direction]
    head.next.value.type = SnakeBody.DIRECTION_TO_TYPE['' + this._lastDirection + this.direction]
    this._lastDirection = this.direction
  }
}

// 计算尾节点的方向
_calcTailType () {
  const tail = this.tail.value
  const tailPrev = this.tail.prev.value
  if (tail.x === tailPrev.x) {
    return tail.y < tailPrev.y ? SnakeTail.TYPES.DOWN : SnakeTail.TYPES.UP
  } else {
    return tail.x < tailPrev.x ? SnakeTail.TYPES.RIGHT : SnakeTail.TYPES.LEFT
  }
}

6.4,蛇的转向

蛇类需要对外暴露一个方法,用于接收用户输入的方向,该方法需要判断输入的方向是否合法,比如不能是当前方向的反方向。

前文提到过,本项目计划支持提前输入,这就需要把用户的输入连续输入缓存起来,所以判断输入合法时是要与缓存的最新一次输入做比较。

changeDirection (direction) {
  // 缓存队列为空,与当前方向比较、判断合法性,合法则推入队列
  if (this._directionQueue.length === 0) {
    if (direction !== this.direction && INVALID_DIRECTION[direction] !== this.direction) {
      this._directionQueue.push(direction)
    }
  } else {
    // 缓存队列不为空,与队列最后一项比较、判断合法性,合法则推入队列
    const lastDirection = this._directionQueue[this._directionQueue.length - 1]
    if (direction !== lastDirection && INVALID_DIRECTION[direction] !== lastDirection) {
      this._directionQueue.push(direction)
    }
  }
}

6.5,蛇的成长

蛇的成长前文也提到过,需要向链表第二个位置插入新节点,把节点的位置赋值给该节点,然后计算头节点的新位置并移动头节点,即可完成一次成长。

需要注意的时,成长时也是可以转向的,所以还需要计算一次新节点的方向类型,如果发生了转向,新节点将会是拐角类型。

_doGrow () {
  const head = this.head
  // 插入新节点至链表第二个位置,并使用头节点位置赋值新节点的位置
  const node = new ListNode(new SnakeBody(head.value.x, head.value.y, this.pixelSize, head.value.type), head, head.next)
  head.next.prev = node
  head.next = node
  this.length++
  
  // 计算头节点的新位置
  const direction = directionValues[this.direction]
  head.value.x += direction[1]
  head.value.y += direction[0]
    
  // 如果发生了转向,计算新节点的方向类型
  if (this.direction !== this._lastDirection) {
    head.value.type = SnakeHead.DIRECTION_TO_TYPE[this.direction]
    head.next.value.type = SnakeBody.DIRECTION_TO_TYPE['' + this._lastDirection + this.direction]
    this._lastDirection = this.direction
  }
}

判断是否吃到食物:

isEat (food) {
  return this.head.value.x === food.x && this.head.value.y === food.y
}

6.6,蛇的死亡

构造蛇类型时传入了地图的宽高,如果蛇头不在宽高范围内,即为创墙死亡。

如果蛇头与蛇身的节点位置重合,即为创到自己死亡。

isDead () {
  const head = this.head
  const { x: headX, y: headY } = head.value
  // 判断是否创墙
  if (headX < 1 || headX > this.colNums ||
    headY < 1 || headY > this.rowNums) {
    return true
  }
  // 判断是否创自己
  for (let node = head.next; node; node = node.next) {
    if (node.value.x === headX && node.value.y === headY) {
      return true
    }
  }
  return false
}

6.7,蛇的速度

蛇的移动速度本质是间隔多久执行一次移动操作,正常来讲使用定时器可以轻松实现,但我们这是游戏项目,还可以在update函数中进行控制。

update函数是每帧调用的,参数是帧间隔的时间,我们可以累加并缓存间隔时间,当累加的结果大于我们设定的值时,执行移动操作。设定的值越大速度越慢,反之越快。

update (elapsed) {
  // 累加间隔时间
  this._elapsed += elapsed
  
  // 累加值大于设定的值时,执行操作
  if (this._elapsed >= 1000 / this.speed) {
    this._elapsed = 0
    // 从方向队列中取出一个方法,进行转向
    if (this._directionQueue.length > 0) {
      this._lastDirection = this.direction
      this.direction = this._directionQueue.shift()
    }
    // 成长或移动
    if (this._canGrow) {
      this._doGrow()
      this._canGrow = false
    } else {
      this.move()
    }
  }
}

动态调整设定的值,即可动态调整速度

speedUp () {
  this.speed = Math.min(this.speed + 1, MAX_SPEED)
}

speedDown () {
  this.speed = Math.max(this.speed - 1, 1)
}

6.8,冲刺功能

冲刺实际是短时间把速度提高,我们可以额外定义一个变量表示冲刺速度,并且在update中优先使用这个变量做判断,平时这个值设为0,当用户按下冲刺键时,短暂把这个值提高。

dash () {
  this._dash = 10
  setTimeout(() => {
    this._dash = 0
  }, 300)
}

update (elapsed) {
  this._elapsed += elapsed
  // 优先使用_dash
  if (this._elapsed >= 1000 / (this._dash || this.speed)) {
    // ...省略
  }
}

7,游戏主流程

我们现在已经实现了简易的游戏循环、键盘事件处理、三个游戏组件,接下来就是把各个部分组合在一起,让游戏真正运行起来。

首先要设定canvas尺寸,并创建renderer实例,这是渲染的基础。

const ROW_NUM = 10 // 地图行数
const COL_NUM = 10 // 地图列数
const PIXEL_SIZE = 4 // 单个像素的尺寸

// 获取canvas实例
const canvas = document.querySelector('#main-canvas')
// 设置宽高
canvas.width = PIXEL_SIZE * 8 * (COL_NUM + 2)
canvas.height = PIXEL_SIZE * 8 * (ROW_NUM + 2)

// 创建renderer
const renderer = new Renderer(canvas)

然后是创建各游戏组件的实例,其中食物实例的位置需要随机,所以还要实现一个生成随机位置的函数。

const gameMap = new GameMap({
  x: 0,
  y: 0,
  rowNums: ROW_NUM,
  colNums: COL_NUM,
  pixelSize: PIXEL_SIZE
})

let snake = new Snake({
  length: 3,
  rowNums: ROW_NUM,
  colNums: COL_NUM,
  pixelSize: PIXEL_SIZE
})

// 生成随机食物坐标
const generateFoodXY = () => {
  let x, y
  do {
    x = getRandomInt(0, gameMap.colNums) + 1
    y = getRandomInt(0, gameMap.rowNums) + 1
  } while (snake.includes(x, y))

  return [x, y]
}

const food = new Food(...generateFoodXY(), PIXEL_SIZE, FOODTYPE.CHERRY)

接下来需要处理游戏循环相关的逻辑,在每帧一次的循环中,我们不仅需要更新及渲染游戏组件,还需要还需要执行游戏的主逻辑,让游戏按照既定的规则运行。

需要执行的逻辑包括:检查用户输入、判断游戏是否成功、判断蛇是否死亡(游戏失败)以及蛇是否吃到食物可以成长等。

const keyW = new Keyboard('KeyW')
const keyD = new Keyboard('KeyD')
const keyS = new Keyboard('KeyS')
const keyA = new Keyboard('KeyA')
const keyArrowUp = new Keyboard('ArrowUp')
const keyArrowRight = new Keyboard('ArrowRight')
const keyArrowLeft = new Keyboard('ArrowLeft')
const keyArrowDown = new Keyboard('ArrowDown')
const keySpace = new Keyboard('Space')

// 检查用户输入
const checkInput = () => {
  if (keyW.isPressed() || keyArrowUp.isPressed()) {
    snake.changeDirection(DIRECTION.UP)
  } else if (keyD.isPressed() || keyArrowRight.isPressed()) {
    snake.changeDirection(DIRECTION.RIGHT)
  } else if (keyS.isPressed() || keyArrowDown.isPressed()) {
    snake.changeDirection(DIRECTION.DOWN)
  } else if (keyA.isPressed() || keyArrowLeft.isPressed()) {
    snake.changeDirection(DIRECTION.LEFT)
  } else if (keySpace.isPressed()) {
    snake.dash()
  }
}

// 游戏主逻辑
const update = (elapsed) => {
  if (snake.length === COL_NUM * ROW_NUM) { // 判断游戏是否成功
    // 展示游戏成功文案
  } else if (!snake.isDead()) { // 判断蛇是否死亡(游戏失败)
    // 更新组件
    snake.update(elapsed)
    
    // 若吃到食物,蛇成长并加速,食物刷新位置及类型
    if (snake.isEat(food)) {
      snake.grow()
      snake.speedUp()
      if (snake.length < COL_NUM * ROW_NUM) { // 蛇没有吃满全图则刷新食物位置及类型
        [food.x, food.y] = generateFoodXY()
        food.type = [FOODTYPE.APPLE, FOODTYPE.CHERRY, FOODTYPE.BANANA, FOODTYPE.WATERMELON][getRandomInt(0, 4)]
      } else {
        food.visible = false // 蛇已经吃满全图则隐藏食物
      }
    }
  } else { // 不满足以上条件说明游戏结束
    // 展示游戏失败文案
  }
}

// 游戏主渲染
const draw = () => {
  renderer.clear('DarkSeaGreen') // 清空上一帧绘制的画面
  gameMap.visible && gameMap.draw(renderer) // 绘制地图
  food.visible && food.draw(renderer) // 绘制食物
  snake.visible && snake.draw(renderer) // 绘制蛇
}

// 创建并开启游戏循环
const mainLoop = new MainLoop()
mainLoop.setOnLoop((elapsed) => {
  checkInput() // 输入检测
  update(elapsed) // 执行主逻辑
  draw() // 渲染所有组件
})
mainLoop.start() // 启动循环

8,触屏操作支持

移动端没有键盘,需要使用触屏操控,需要在页面上增加一些按钮,并设置好点击事件监听,调用蛇对应的方法。

/** index.html */
<div id="ctrl-bar" class="horizontal main-content hidden">
  <!-- 冲刺按钮 -->
  <button id="btn-dash" class="btn-dash">
    Dash
  </button>
  <div id="joystick" class="joystick">
    <!-- 上按钮 -->
    <button id="btn-joystick-up" class="btn-joystick joystick-up">
      <!-- 使用css绘制了三角形 -->
      <div class="triangle-up"></div>
    </button>
    <div class="horizontal">
      <!-- 左按钮 -->
      <button id="btn-joystick-left" class="btn-joystick joystick-left">
        <div class="triangle-left"></div>
      </button>
      <!-- 右按钮 -->
      <button id="btn-joystick-right" class="btn-joystick joystick-right">
        <div class="triangle-right"></div>
      </button>
    </div>
    <!-- 下按钮 -->
    <button id="btn-joystick-down" class="btn-joystick joystick-down">
      <div class="triangle-down"></div>
    </button>
  </div>
</div>
/** 按钮点击事件绑定 */
btnJoystickUp.addEventListener('touchstart', () => { snake.changeDirection(DIRECTION.UP) })
btnJoystickRight.addEventListener('touchstart', () => { snake.changeDirection(DIRECTION.RIGHT) })
btnJoystickDown.addEventListener('touchstart', () => { snake.changeDirection(DIRECTION.DOWN) })
btnJoystickLeft.addEventListener('touchstart', () => { snake.changeDirection(DIRECTION.LEFT) })
btnDash.addEventListener('touchstart', () => { snake.dash() })

9,实现AI模式

9.1,基本原理

前文中提到过,项目中的AI模式,并没有使用深度学习等技术,只是使用寻路算法配合一些选择策略实现,下面来讲一下该如何设计相关的策略。

最重要的事情,是要保证蛇能够活下来。实现蛇不创墙很简单,所以重点要防止蛇创到自己。分析蛇的移动可以发现,蛇每次移动时,蛇头会移动到一个新的空位,蛇身及蛇尾会移动到其前一个节点的位置,相较于移动前,蛇头及所有蛇身节点的位置还是被占用的,只有蛇尾原来的位置是变为空置的状态。简而言之就是当前蛇尾的位置在下一次移动时将变成一个空位置。

既然蛇头每次需要占用一个空位置,蛇尾每次释放一个空位置,那么我们只需要保证,以蛇头占用的新位置为起点,以蛇尾当前位置为终点,能够找到一条连通的路径,那么这个新位置就是安全的。只要我们每次移动时都只移动到这样的新位置,那么蛇就可以一直追着自己的尾巴跑下去,永远也不会创到自己。

如果在选择新位置时在加一个限制条件,除了需要能连通到蛇尾以外,还需要能联通到食物并且距离食物要近,那么蛇就可以在“追尾”的过程中顺便吃到食物。

9.2,寻路算法

判断两个位置之间是否连通需要使用寻路相关的算法,本项目中使用的是AStar算法,不过因为目前地图的尺寸很小所以使用更简洁的Dijkstra或者BFS其实也可以。

受限于篇幅,这里不对AStar算法做过多描述,感兴趣的伙伴可以直接去阅读项目中的代码,或本文末尾的一些参考链接。

9.3,选择策略

上文中描述了最基本的策略,在实践过程中还发现了一些值得注意的细节。

首先是需要限制蛇频繁进行转向操作。蛇频繁转向时有可能圈出许多很小的空闲空间,在游戏后期这些细碎的空间将极大的影响效率。

其次是长度短的时候可以不太在意新位置与蛇尾的距离,达到一定长度以后,应该尽量避免蛇头与蛇尾靠的太近,因为游戏越往后空间越宝贵,蛇头与蛇尾离得远些将获得更多的移动空间。

还要注意选择路径时增加一点随机性,如果每次都按照固定的顺序选择新位置,在某些场景下可能会出现蛇一直绕圈的情况,一点点随机数可以打破这种情况。

以上几点都能有效提升蛇的存活概率,不过同时也会降低一些效率。

结语

这个项目其实想了很久了,之前上大学的时候就做过一个C语言控制台版本的,不过当时使尽了浑身解数也没能实现吃满全屏的AI,如今可以算是圆梦了。

不过目前的AI还是不太完善,还是有些场景没有覆盖到,所以有时还是会把自己创死,只能等后续再完善了。(可能要🕊)

现在游戏还没有音效和配乐,这也是接下来打算补齐的地方,不过目前还没想到合适的配乐,如果大家有想法的话欢迎留言哈,同时关于本文有任何问题也欢迎大家留言。

感谢大家的阅读,都看到这里了,希望大家能帮忙点个赞,哈哈。

参考

CanvasRenderingContext2D

requestAnimationFrame

贪吃蛇 AI 的实现 snake AI

A* 寻路算法

JS实现优先队列的三种方式

A*算法优化