贪吃蛇

demo

实现

规则:

  1. 地图大小24*24
  2. 初始蛇长度为3,位置随机;随机在空余位置生成一个食物
  3. 蛇定时步进一次,自动向“前”移动;可以上下左右操作蛇的移动方向,无法向“后”移动
  4. 吃掉食物后蛇长度加1,随机生成一个新食物,若没有空余位置则完成游戏
  5. 蛇头碰到地图边缘或身体时游戏结束

用一个24*24的二维数组(matrix)表示地图,每个元素代表地图中的一个位置,值为0表示空位,1表示蛇,2表示食物。

用一个列表(snake)表示蛇的当前位置,蛇头在最后。

js
function step(direction) {
  if (direction === adverseDirection(prevArrow.value)) direction = prevArrow.value
  else prevArrow.value = direction
  const moveStep = {
    ArrowRight: [0, 1],
    ArrowLeft: [0, -1],
    ArrowUp: [-1, 0],
    ArrowDown: [1, 0],
  }[direction]
  const head = snake.value[snake.value.length - 1]
  const newHead = [head[0] + moveStep[0], head[1] + moveStep[1]]
  if(newHead[0] < 0 || newHead[0] >= GRID_SIZE || newHead[1] < 0 || newHead[1] >= GRID_SIZE || matrix.value[newHead[0]][newHead[1]] === 1) {
    alert('Game Over!')
    return
  }
  const eated = matrix.value[newHead[0]][newHead[1]] === 2
  matrix.value[newHead[0]][newHead[1]] = 1
  snake.value.push(newHead)
  if(GRID_SIZE * GRID_SIZE === snake.value.length) {
    alert('You Win!')
    return
  }
  // 进食则生成新食物,否则尾巴前进一格
  if(eated) {
    try {
      const [rowIdx, colIdx] = generateFood()
      matrix.value[rowIdx][colIdx] = 2
    } catch (error) {
      alert('Game Error: ' + (error.message || 'Unknown Error'))
      return
    }
  } else {
    const tail = snake.value.shift()
    matrix.value[tail[0]][tail[1]] = 0
  }
}
// 生成食物,排除snake占据的位置,随机从其余位置选择一个
function generateFood() {
  const emptyCount = GRID_SIZE * GRID_SIZE - snake.value.length;
  if(emptyCount <= 0) throw new Error('Process Error!')
  let randomNum = Math.floor(Math.random() * emptyCount) + 1
  for(let rowIdx = 0; rowIdx < matrix.value.length; rowIdx++) {
    for(let colIdx = 0; colIdx < matrix.value[rowIdx].length; colIdx++) {
      const col = matrix.value[rowIdx][colIdx]
      if(col > 0) continue
      if(--randomNum === 0) return [rowIdx, colIdx]
    }
  }
}
function adverseDirection(direction) {
  return {
    ArrowRight: 'ArrowLeft',
    ArrowLeft: 'ArrowRight',
    ArrowUp: 'ArrowDown',
    ArrowDown: 'ArrowUp',
  }[direction]
}

源码

vue
<script setup type="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

const GRID_SIZE = 24
const STEP_INTERVAL = 800
const visible = ref(false)

onMounted(() => {
  visible.value = true
  document.addEventListener('keyup', recodeDirectives)
})
onBeforeUnmount(() => {
  document.removeEventListener('keyup', recodeDirectives)
})

const directions = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
const status = ref('init')
const matrix = ref(Array.from({ length: GRID_SIZE }).map(() => Array.from({ length: GRID_SIZE }).fill(0)))
const snake = ref([]) // 蛇头在最后

/**
 * 自动步进
 * @param e 
 */
let stepInterval
const prevArrow = ref()
const currArrows = ref([])
function recodeDirectives({ code }) {
  if(status.value !== 'playing') return
  // 忽略非方向按键事件
  if (!directions.includes(code)) return
  currArrows.value.push(code)
  if(currArrows.value.length > 2) currArrows.value.shift()
}
function startInterval() {
  if(stepInterval) return
  stepInterval = setInterval(() => {
    if(status.value !== 'playing') {
      clearInterval(stepInterval)
      stepInterval = null
      return
    }
    step(currArrows.value.shift() || prevArrow.value)
  }, STEP_INTERVAL)
}
function stopInterval() {
  if(!stepInterval) return
  clearInterval(stepInterval)
  stepInterval = null
}

function initSnake(d) {
  if(!d) d = directions[Math.floor(Math.random() * directions.length)]
  prevArrow.value = d
  currArrows.value = [d]
  const midIdx = Math.floor(GRID_SIZE / 2 - 1)
  let list = [] // 蛇头在最后
  switch (d) {
    case 'ArrowDown':
      list = [[midIdx - 1, midIdx], [midIdx, midIdx]]
      break
    case 'ArrowUp':
      list = [[midIdx + 1, midIdx], [midIdx, midIdx]]
      break
    case 'ArrowRight':
      list = [[midIdx, midIdx - 1], [midIdx, midIdx]]
      break
    case 'ArrowLeft':
      list = [[midIdx, midIdx + 1], [midIdx, midIdx]]
      break
    default: throw new Error('Invalid direction')
  }
  snake.value = []
  list.forEach(([x, y]) => {
    matrix.value[x][y] = 1
    snake.value.push([x, y])
  })
}

function init() {
  if(!['init', 'gameover', 'win'].includes(status.value)) return
  try {
    matrix.value = Array.from({ length: GRID_SIZE }).map(() => Array.from({ length: GRID_SIZE }).fill(0))
    initSnake()
    // 初始食物位置
    const [rowIdx, colIdx] = generateFood()
    matrix.value[rowIdx][colIdx] = 2
    status.value = 'init'
    start()
  } catch (error) {
    console.error(error)
  }
}
function start() {
  if(!['init', 'paused'].includes(status.value)) return
  status.value = 'playing'
  document.addEventListener('keyup', recodeDirectives)
  // 恢复自动步进
  startInterval()
}
function pause() {
  if(status.value !== 'playing') return
  status.value = 'paused'
  document.removeEventListener('keyup', recodeDirectives)
  // 停止自动步进
  stopInterval()
}
function stop(s) {
  if(!['paused', 'playing'].includes(status.value) || !['gameover', 'win'].includes(s)) return
  status.value = s
  document.removeEventListener('keyup', recodeDirectives)
}
function step(direction) {
  if(status.value !== 'playing') return
  if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(direction)) return
  if (direction === adverseDirection(prevArrow.value)) direction = prevArrow.value
  else prevArrow.value = direction
  const moveStep = {
    ArrowRight: [0, 1],
    ArrowLeft: [0, -1],
    ArrowUp: [-1, 0],
    ArrowDown: [1, 0],
  }[direction]
  const head = snake.value[snake.value.length - 1]
  const newHead = [head[0] + moveStep[0], head[1] + moveStep[1]]
  if(newHead[0] < 0 || newHead[0] >= GRID_SIZE || newHead[1] < 0 || newHead[1] >= GRID_SIZE || matrix.value[newHead[0]][newHead[1]] === 1) {
    stop('gameover')
    alert('Game Over!')
    return
  }
  const eated = matrix.value[newHead[0]][newHead[1]] === 2
  matrix.value[newHead[0]][newHead[1]] = 1
  snake.value.push(newHead)
  if(GRID_SIZE * GRID_SIZE === snake.value.length) {
    stop('win')
    alert('You Win!')
    return
  }
  // 进食则生成新食物,否则尾巴前进一格
  if(eated) {
    try {
      const [rowIdx, colIdx] = generateFood()
      matrix.value[rowIdx][colIdx] = 2
    } catch (error) {
      stop('gameover')
      alert('Game Error: ' + (error.message || 'Unknown Error'))
      return
    }
  } else {
    const tail = snake.value.shift()
    matrix.value[tail[0]][tail[1]] = 0
  }
}
// 生成食物,排除snake占据的位置,随机从其余位置选择一个
function generateFood() {
  const emptyCount = GRID_SIZE * GRID_SIZE - snake.value.length;
  if(emptyCount <= 0) throw new Error('Process Error!')
  let randomNum = Math.floor(Math.random() * emptyCount) + 1
  for(let rowIdx = 0; rowIdx < matrix.value.length; rowIdx++) {
    for(let colIdx = 0; colIdx < matrix.value[rowIdx].length; colIdx++) {
      const col = matrix.value[rowIdx][colIdx]
      if(col > 0) continue
      if(--randomNum === 0) return [rowIdx, colIdx]
    }
  }
}
function adverseDirection(direction) {
  return {
    ArrowRight: 'ArrowLeft',
    ArrowLeft: 'ArrowRight',
    ArrowUp: 'ArrowDown',
    ArrowDown: 'ArrowUp',
  }[direction]
}
</script>

<template>
  <div class="container">
    <div v-show="visible" class="panel-wrap">
      <div id="panel" :class="[status]">
        <div v-if="['init', 'paused', 'gameover', 'win'].includes(status)" class="overlay">
          <button v-if="status === 'init'" @click="init">开始</button>
          <button v-if="status === 'paused'" @click="start">继续</button>
          <button v-if="status === 'gameover'" @click="init">重新开始</button>
          <button v-if="status === 'win'" @click="init">重新开始</button>
        </div>
        <template v-for="(row, rowIdx) in matrix">
          <div v-for="(cell, colIdx) in row" :key="colIdx" class="cell-wrap">
            <div
              v-if="cell"
              class="cell"
              :class="{
                body: cell === 1,
                head: cell === 1 && snake[snake.length - 1][0] === rowIdx && snake[snake.length - 1][1] === colIdx,
                food: cell === 2
              }"
            />
          </div>
        </template>
      </div>
    </div>
    <div class="menu">
      <!-- <div>currArrows: {{ currArrows }}</div> -->
      <button v-if="status === 'playing'" @click="pause">暂停</button>
      <button v-if="['paused', 'playing'].includes(status)" @click="stop('gameover')">结束</button>
    </div>
  </div>
</template>

<style>
html,body {
  margin: 0;
}
.container {
  height: 100vh;
  height: 100dvh;
  padding: 1rem;
  background: #fff;
  box-sizing: border-box;
}
.panel-wrap {
  display: flex;
  justify-content: center;
  align-items: center;
}
#panel {
  --cellLen: clamp(10px, calc(min(90vw, 80vh) / 24), 36px);
  position: relative;
  display: grid;
  grid-template-columns: repeat(v-bind(GRID_SIZE), var(--cellLen));
  border: 1px solid rgba(0,0,0,0.4);
}
.overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: rgba(0,0,0,0.3);
  z-index: 10;
}
.cell-wrap {
  display: flex;
  justify-content: center;
  align-items: center;
  height: var(--cellLen);
  width: var(--cellLen);
  min-width: 0;
  min-height: 0;
  z-index: 2;
}
.cell {
  height: 70%;
  width: 70%;
  cursor: pointer;
  user-select: none;
}
.cell.body, .cell.head {
  background-color: #1e1e1e;
  box-shadow: 0 0 2px 1px rgba(0,0,0,0.4);
}
.cell.head {
  height: 90%;
  width: 90%;
  border-radius: 2px;
}
.cell.food {
  background-color: #0ff;
  box-shadow: 0 0 2px 1px rgba(0,255,255,0.4);
}
.menu {
  text-align: center;
  margin-top: 1rem;
}
</style>