儿童节快乐,经典童年游戏-贪吃蛇

78 阅读4分钟

利用改bugs 的时间间隙,写了个贪吃蛇,保存文件 index.html, 打开即玩,祝大小朋友六一快乐

github 传送门

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
  <title>Document</title>

  <style type="text/css">
    div, ul, li {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    ul, li {
      list-style: none;
    }

    #playground {
      outline: none;
      border: 1px solid dimgray
    }

    .spot {
      position: absolute;
      width: 10px;
      height: 10px;
      background: lightblue;
    }
    .snake-head {
      background: orangered;
    }

    .food {
      background: seagreen;
    }

    .panel {
      display: flex;
      justify-content: space-around;
      list-style: none;
      font-size: 10px;
      padding-top: 50px;
    }

    .panel input {
      width: 50px;
      border: none;
      outline: none;
    }

  </style>
  <style>
    html {
      width: 100%;
      height: 100%;
    }
    @media screen and (orientation: portrait) {
    /*竖屏 css*/
      body {
        display: flex;
        height: 100%;
        flex-direction: column;
        align-items: center;
        justify-content: space-between;
      }
      .game-box {
        display: flex;
        width: 100%;
        flex-direction: column;
        justify-content: space-around;
        align-items: center;
      }
    }
    @media screen and (orientation: landscape) {
      body {
        display: flex;
        flex-direction: row;
        justify-content: space-between;
        align-items: center;
      }
    }
  </style>
</head>
<body>
  <div class="game-box">
    <form name="panel" class="panel">
      <div>状态:<input name="gameState" disabled/></div>
      <div>长度:<input name="snakeLength" disabled/></div>
      <div>步数:<input name="stepCount" disabled/></div>
    </form>
    <div id="playground" tabindex="0" border-collapse="none"></div>
  </div>
  <canvas id="controller"></canvas>
  <script type="text/javascript">
    function Position (x, y) {
      this.x = x; this.y = y;
    }

    Position.prototype.set = function (x, y) {
      this.x = x; this.y = y;
    }

    Position.prototype.get = function (x, y) {
      return [this.x, this.y]
    }

    const Direction = {
      up: 0,
      right: 1,
      down: 2,
      left: 3
    }

    function Spot (parent, position) {
      this.position = position
      const el = document.createElement("div")
      el.classList.add("spot")
      this.parent = parent
      parent.el.appendChild(el)
      this.el = el
      this.move()
      this.mark(true)
    }

    Spot.prototype.mark = function (isOccupied) {
      const colIndex = this.position.x / Snake.size
      const rowIndex = this.position.y / Snake.size
      this.parent.blocks[rowIndex][colIndex] = isOccupied
    }

    Spot.prototype.move = function () {
      const { x, y } = this.position
      this.el.style.transform = `translate(${x}px,${y}px)`
    }

    Spot.prototype.suicide = function () {
      this.el.remove()
    }

    function Food (parent, position) {
      Spot.call(this, parent, position)
    }

    Food.prototype = Object.create(Spot.prototype)
    Food.prototype.constructor = Food

    const MoveState = {
      crash: -1,
      back: 0,
      forward: 1
    }

    function Snake (...spots) {
      this.spots = spots
      this.step = 2
      this.deltaX = this.step
      this.deltaY = 0
      this.lastDeltaX = this.deltaX
      this.lastDeltaY = this.deltaY
      this.direction = null
    }

    Snake.size = 10

    Snake.prototype.setDelta = function (deltaX, deltaY) {
      this.deltaX = deltaX
      this.deltaY = deltaY
    }

    Snake.prototype.setDirection = function (direction) {
      const step = this.step
      this.direction = direction
      switch (direction) {
        case Direction.up: this.setDelta(0, -step)
          break
        case Direction.right: this.setDelta(step, 0)
          break
        case Direction.down: this.setDelta(0, step)
          break
        case Direction.left: this.setDelta(-step, 0)
          break
        default:
      }
      this.move()
    }

    Snake.prototype.head = function () {
      return this.spots[this.spots.length - 1]
    }

    Snake.prototype.tail = function () {
      return this.spots[0]
    }

    Snake.prototype.size = function () {
      return this.spots.length
    }

    Snake.prototype.move = function () {
      const { deltaX, deltaY } = this
      // 判断如果当前方向与之前相反则不执行任何操作
      if (this.lastDeltaX + deltaX === 0 && this.lastDeltaY + deltaY === 0) {
        this.deltaX = this.lastDeltaX
        this.deltaY = this.lastDeltaY
        this.move()
        return MoveState.back
      }

      // 尾清除
      this.tail().mark()

      const depart = Snake.size / this.step
      const head = this.head()
      const headPosition = new Position(head.position.x + deltaX * depart, head.position.y + deltaY * depart)

      // 左右横穿
      if (headPosition.x < 0) {
        headPosition.x += head.parent.width
      }
      if (headPosition.x >= head.parent.width) {
        headPosition.x -= head.parent.width
      }

      // 上下横穿
      if (headPosition.y < 0) {
        headPosition.y += head.parent.height
      }
      if (headPosition.y >= head.parent.height) {
        headPosition.y -= head.parent.height
      }

      for (let i = 0; i < this.spots.length - 1; i++) {
        const prev = this.spots[i + 1]
        const current = this.spots[i]

        if (
          prev.position.x === headPosition.x &&
          prev.position.y === headPosition.y
        ) {
          return MoveState.crash
        }

        current.position.set(prev.position.x, prev.position.y)
        current.move()
      }

      head.position.set(headPosition.x, headPosition.y)
      this.lastDeltaX = deltaX
      this.lastDeltaY = deltaY
      head.move()
      // 头标记
      head.mark(true)
      return MoveState.forward
    }

    Snake.prototype.eat = function (food) {
      const oldHead = this.head()
      oldHead.el.classList.remove('snake-head')

      const spot = new Spot(food.parent, food.position)
      this.spots.push(spot)
      food.suicide()

      const newHead = this.head()
      newHead.el.classList.add('snake-head')
    }

    function Playground (width = 200, height = 100) {
      this.width = width
      this.height = height
      this.el = document.getElementById("playground");
      this.el.style.width = width + "px"
      this.el.style.height = height + "px"
      const blocks = new Array(this.height / Snake.size)
        .fill(undefined)
        .map(() => new Array(this.width / Snake.size))
      this.blocks = blocks
    }
  </script>
  <script>
    const GameState = {
      failure: -1,
      init: 0,
      running: 1,
      pause: 2,
      stop: 3
    }

    function Game () {
      this.foods = []
      this.startTime = null
      this.previousTimeStamp = null
      this.playground = null
      this.snake = null
      this.snakeInitialSize = 4
      this.state = null
      this.normalInterval = 500
      this.interval = this.normalInterval
      this.lastSetDirectionStamp = Date.now()
      // 连续两次间隔时间
      this.speedupInterval = 500
      // 加速的时间量
      this.speedupIntervalDelta = 100
      // 加速持续时间
      this.speedupIntervalDuration = 2000
      this.stepCount = 0
      this.keydownHandler = null
    }

    Game.prototype.init = function () {
      this.setState(GameState.init)

      // this.playground = new Playground()
      this.playground = new Playground(370, 560)
      this.snake = this.createSnake()
      this.initHandler()
      this.createFoods()
      return this
    }

    Game.prototype.destroy = function () {
      this.state = GameState.stop
      // 解除事件
      this.playground.el.removeEventListener('keydown', this.keydownHandler)
      // 删除 dom
      this.snake.spots.forEach(spots => {
        spots.suicide()
      })
      this.foods.forEach(food => {
        food.suicide()
      })
      // 数据清空
      Game.apply(this)
      this.init()
      this.updatePanel()
    }

    Game.prototype.setState = function (state) {
      this.state = state
      this.stateChanged(state)
    }

    Game.prototype.stateChanged = function (state) {
      switch(state) {
        case GameState.running:
          break
          // TODO: game over tooltip
        case GameState.failure: alert("game over")
        default:
      }
    }

    Game.prototype.updatePanel = function () {
      const panel = document.forms.panel
      panel.gameState.value = this.state
      panel.snakeLength.value = this.snake.size()
      panel.stepCount.value = this.stepCount
    }

    Game.prototype.createSnake = function () {
      const playground = this.playground
      const body = new Array(this.snakeInitialSize).fill([0, 0]).map(function ([x, y], index) {
        const spot = new Spot(playground, new Position(x + index * Snake.size, y))
        return spot
      })
      const snake = new Snake(
        ...body
      )
      snake.head().el.classList.add('snake-head')
      return snake
    }

    Game.prototype.setDirection = function (direction) {
      // 控制加速时长,加速间隔
      if (Date.now() - this.lastSetDirectionStamp < this.speedupInterval) {
        const flag = direction === this.snake.direction ? -1 : 1
        const interval = this.interval + flag * this.speedupIntervalDelta
        if (interval >= this.normalInterval * 0.1) {
          this.interval = interval
        }
      }

      this.snake.setDirection(direction)
      this.lastSetDirectionStamp = Date.now()
    }

    Game.prototype.createFoods = function (num = 10) {
      let random = Math.round(Math.random() * num)
      if (random === 0) {
        // 检测食物数量,如果 小于 1 则创建
        if (this.foods.length <= 1) {
          random = 1
        } else {
          // 不创建
          return
        }
      }

      const blocks = this.playground.blocks
      const spareBlocks = []
      for (let i = 0; i < blocks.length; i++) {
        const rows = blocks[i]
        for (let j = 0; j < rows.length; j++) {
          if (rows[j]) {
            continue
          }
          spareBlocks.push([j, i])
        }
      }
      for (let i = random; i >= 0; i--) {
        this.createFood(spareBlocks)
      }
    }

    // 优先距离蛇头 50 格内生成食物,并且不能生成在蛇身体上, 也不能生成到已有的食物身上
    Game.prototype.createFood = function (spareBlocks) {
      const randomIndex = Math.floor(Math.random() * spareBlocks.length)
      const [x, y] = spareBlocks[randomIndex]

      randomX = x * Snake.size
      randomY = y * Snake.size

      // 查找当前位置是否可用,若不可用,则继续生成
      const food = new Food(this.playground, new Position(randomX, randomY))
      food.el.classList.add("food")
      this.foods.push(food)
    }

    Game.prototype.initHandler = function () {
      this.keydownHandler = this.keydown.bind(this)
      this.playground.el.addEventListener("keydown", this.keydownHandler)
    }

    Game.prototype.keydown = function(e) {
      switch(e.key) {
        case 'ArrowDown':
          this.setDirection(Direction.down)
          break
        case 'ArrowUp':
          this.setDirection(Direction.up)
          break;
        case 'ArrowLeft':
          this.setDirection(Direction.left)
          break
        case 'ArrowRight':
          this.setDirection(Direction.right)
          break
        default:
      }
      return false
    }

    Game.prototype.start = function () {
      if (this.state === GameState.running) {
        return
      }
      this.setState(GameState.running)
      window.requestAnimationFrame(this.run.bind(this))
      this.playground.el.focus()
    }

    Game.prototype.run = function (timestamp) {
      if (this.startTime === null) {
        this.startTime = timestamp;
      }
      const elapsed = timestamp - this.previousTimeStamp;
      // 超出加速最大时间,取消加速
      if (Date.now() - this.lastSetDirectionStamp > this.speedupIntervalDuration) {
        this.interval = this.normalInterval
      }

      if (this.previousTimeStamp == null || elapsed > this.interval) {
        this.previousTimeStamp = timestamp;
        const moveState = this.snake.move()
        if (moveState === MoveState.crash) {
          this.setState(GameState.failure)
        }
        if (moveState === MoveState.forward) {
          this.stepCount++
        }

        // 检测前方是否存在食物
        const head = this.snake.head()
        for (let i = this.foods.length - 1; i >= 0; i--) {
          const food = this.foods[i]
          if (food.position.x === head.position.x && food.position.y === head.position.y) {
            // 精神上消灭
            this.foods.splice(i, 1)
            // 肉体上消灭
            this.snake.eat(food)
            // 创建新的食物 or 不创建
            this.createFoods(Math.random() > 0.5 ? 1 : 0)
          }
        }
      }
      // 更新面板数据
      this.updatePanel()
      if (this.state === GameState.running) {
        window.requestAnimationFrame(this.run.bind(this));
      }
    }

    Game.prototype.pause = function () {
      this.setState(GameState.pause)
    }

    Game.prototype.stop = function () {
      this.setState(GameState.stop)
    }
  </script>
  <script>
    const game = new Game().init()
  </script>
  <script>
    function calculateCube(heart, r) {
      const d = Math.sqrt(r**2 / 2)
      return [
        [heart.x - d, heart.y - d],
        [heart.x + d, heart.y - d],
        [heart.x + d, heart.y + d],
        [heart.x - d, heart.y + d],
      ]
    }

    function drawText (ctx, heart, text, color) {
       const oldStrokeStyle = ctx.strokeStyle
      ctx.strokeStyle = color
      ctx.strokeText(text, heart.x - ctx.measureText(text).width / 2, heart.y);
      ctx.strokeStyle = oldStrokeStyle
    }

    (function (canvas, game) {
      const height = 220
      const width = height * 2
      const r1 = 100
      const r2 = 20
      canvas.width = width
      canvas.height = height
      canvas.style.height = height + 'px'
      const ctx = canvas.getContext('2d')

      const heart = new Position(width / 2, height / 2)
      ctx.beginPath()
      ctx.ellipse(heart.x, heart.y, width / 2, heart.y, 0, 0, Math.PI * 2, true)
      ctx.stroke()

      ctx.moveTo(heart.x + r1, heart.y);
      ctx.beginPath()
      ctx.arc(heart.x, heart.y, r1, 0, Math.PI * 2, true);
      ctx.stroke()

      ctx.moveTo(heart.x + r2, heart.y);
      ctx.beginPath()
      ctx.fillStyle = 'red'
      ctx.arc(heart.x, heart.y, r2, 0, Math.PI * 2, true);
      ctx.fill()

      drawText(ctx, heart, 'P', '#fff')

      // 绘制控制按钮
      const startHeart = new Position((heart.x - r1) / 2, height / 2)
      ctx.moveTo(startHeart.x + r2, startHeart.y)
      ctx.beginPath()
      ctx.fillStyle = 'green'
      ctx.arc(startHeart.x, startHeart.y, r2, 0, Math.PI * 2, true)
      ctx.fill()

      drawText(ctx, startHeart, 'S', '#fff')

      const stopHeart = new Position(width - (heart.x - r1) / 2, height / 2)
      ctx.moveTo(stopHeart.x + r2, stopHeart.y)
      ctx.beginPath()
      ctx.fillStyle = '#555'
      ctx.arc(stopHeart.x, stopHeart.y, r2, 0, Math.PI * 2, true)
      ctx.fill()

      ctx.save()
      ctx.strokeStyle = '#fff'
      ctx.strokeText('R', stopHeart.x - ctx.measureText("P").width / 2, stopHeart.y);
      ctx.restore()

      // 圆的对角线
      // line1 y = x - height / 2
      // line2 y = heart.x + height / 2 - x
      const [ p1, p2, p3, p4 ] = calculateCube(heart, r1)
      const [p5, p6, p7, p8] = calculateCube(heart, r2)
      ctx.beginPath()

      ctx.moveTo(...p1)
      ctx.lineTo(...p5)
      ctx.moveTo(...p2)
      ctx.lineTo(...p6)
      ctx.moveTo(...p3)
      ctx.lineTo(...p7)
      ctx.moveTo(...p4)
      ctx.lineTo(...p8)

      ctx.stroke()

      canvas.addEventListener('click', function (e) {
        const { offsetX, offsetY } = e
        const leftSide = Math.pow(offsetX - heart.x, 2) + Math.pow(offsetY - heart.y, 2)
        if (leftSide < r2 * r2) {
          game.stop()
          return
        }

        // 大圆以外, 小圆以内
        if (leftSide > r1 * r1) {
          if (Math.pow(offsetX - startHeart.x, 2) + Math.pow(offsetY - startHeart.y, 2) < r2 * r2) {
            game.start()
          }

          if (Math.pow(offsetX - stopHeart.x, 2) + Math.pow(offsetY - stopHeart.y, 2) < r2 * r2) {
            game.destroy()
          }
          return
        }

        if (game.state !== GameState.running) {
          return
        }

        // 判断点击上下左右
        if (offsetY > offsetX - height / 2) {
          if (offsetY > heart.x + height / 2 - offsetX) {
            game.setDirection(Direction.down)
          } else {
            game.setDirection(Direction.left)
          }
        } else {
          if (offsetY > heart.x + height / 2 - offsetX) {
            game.setDirection(Direction.right)
          } else {
            game.setDirection(Direction.up)
          }
        }
      })
    })(document.getElementById('controller'), game)
  </script>
</body>
</html>