我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛
项目预览
仓库地址: github
online demo: github pages , gitee pages
截图预览:
-
桌面端:
-
移动端:
-
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还是不太完善,还是有些场景没有覆盖到,所以有时还是会把自己创死,只能等后续再完善了。(可能要🕊)
现在游戏还没有音效和配乐,这也是接下来打算补齐的地方,不过目前还没想到合适的配乐,如果大家有想法的话欢迎留言哈,同时关于本文有任何问题也欢迎大家留言。
感谢大家的阅读,都看到这里了,希望大家能帮忙点个赞,哈哈。