经典童年游戏-俄罗斯方块

84 阅读5分钟

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>
    html, body {
      width: 100%
    }

    div, ul, li {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    ul, li {
      list-style: none;
    }

    #playground {
      margin-top: 50px;
      outline: none;
      border: 1px solid dimgray
    }

    .dot {
      position: absolute;
      background: lightblue;
    }

    body {
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    canvas {
      user-select: none;
    }

    .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 id="controller-btns">
    <button onclick="game.start()">start</button>
    <button onclick="game.pause()">pause</button>
    <button onclick="game.destroy()">reset</button>
  </div>
  <div class="game-box">
    <form name="panel" class="panel">
      <div>状态:<input name="gameState" disabled/></div>
      <div>得分:<input name="score" disabled/></div>
    </form>
    <div id="playground" tabindex="0" border-collapse="none"></div>
  </div>
  <canvas id="controller"></canvas>
  <script id="common">
    const isDev = false

    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 () {
      return [this.x, this.y]
    }

    const Direction = {
      up: 0,
      right: 1,
      down: 2,
      left: 3
    }
  </script>
  <script id="dot">
    function Dot (parent, position) {
      this.position = position;
      const el = document.createElement("div")
      el.classList.add("dot")
      el.style.cssText = `width: ${ Dot.size }px; height: ${Dot.size}px`
      this.parent = parent
      parent.el.appendChild(el)
      this.el = el
      this.move()
    }

    Dot.size = isDev ? 10 : 20

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

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

    Dot.prototype.setPosition = function (x, y) {
      this.position.x = x
      this.position.y = y
      return this
    }

    Dot.prototype.indexArray = function () {
      return [this.position.x / Dot.size, this.position.y / Dot.size]
    }
  </script>
  <script id="cube">
    const CubeState = {
      unstable: 0,
      stable: 1
    }

    function Cube (parent) {
      this.parent = parent
      this.dots = []
      this.canRotate = true
      this.state = CubeState.unstable
    }

    Cube.prototype.setState = function (state) {
      this.state = state
    }

    Cube.prototype.suicide = function () {
      this.dots.forEach(dot => dot.suicide())
    }

    Cube.prototype.mark = function (bool = true) {
      const blocks = this.parent.blocks
      this.dots.forEach(dot => {
        const [x, y] = dot.indexArray()
        blocks[y][x] = dot
      })
    }

    Cube.prototype.tryMove = function (direction = Direction.down) {
      // 如果存在一个 dot 下方有实体,或者到底了
      const len = this.dots.length
      let state = this.state
      for (let i = 0; i < len; i++) {
        const dot = this.dots[i]
        const x = dot.position.x / Dot.size
        const y = dot.position.y / Dot.size + 1
        const nextY = dot.position.y + Dot.size
        // 还未出现在屏幕上的点跳过
        if (nextY <= 0) {
          continue
        }

        if (nextY >= this.parent.height || this.parent.blocks[y][x]) {
          state = CubeState.stable
          break
        }
      }
      this.setState(state)
      if (state == CubeState.unstable) {
        this.move(direction)
      }
      return state
    }

    Cube.prototype.rotate = function () {
      if (!this.canRotate) {
        return
      }
      const len = this.dots.length

      const { x: x0, y: y0 } = this.getCenter()
      const sin = Math.sin(Math.PI / 2)
      const cos = Math.cos(Math.PI / 2)
      for (let i = 0; i < len; i++) {
        const { x, y } = this.dots[i].position
        // https://wenku.baidu.com/view/434e1c6a497302768e9951e79b89680202d86b71.html?_wkts_=1686114508666
        const x1 = (x - x0) * cos - (y -y0) * sin + x0
        const y1 = (x - x0) * sin + (y - y0) * cos + y0
        this.dots[i].setPosition(x1, y1)
      }
      this.tryMove(Direction.up)
    }

    Cube.prototype.move = function (direction = Direction.down) {
      let deltaX = deltaY = 0
      switch (direction) {
        case Direction.up:
          break
        case Direction.right:
            deltaX += Dot.size
          break
        case Direction.down:
            deltaY += Dot.size
          break
        case Direction.left:
            deltaX -= Dot.size
          break
        default:
      }
      const len = this.dots.length
      const blocks = this.parent.blocks
      // 检测左右两侧是否可以移动
      for (let i = 0; i < len; i++) {
        const dot = this.dots[i]
        const x = dot.position.x + deltaX
        const y = dot.position.y + deltaY
        if(y <= 0) continue
        if (
          x < 0 ||
          x > this.parent.width - Dot.size ||
          blocks[y/Dot.size][x/Dot.size]
        ) {
          deltaX = 0
          break
        }
      }

      for (let i = 0; i < len; i++) {
        const dot = this.dots[i]
        const x = dot.position.x + deltaX
        const y = dot.position.y + deltaY
        dot.setPosition(x, y).move()
      }
    }

    function Stone (parent, position) {
      position.y -= Dot.size * 2
      Cube.call(this, parent)
      this.canRotate = false
      this.dots = [
        new Dot(parent, new Position(position.x, position.y)),
        new Dot(parent, new Position(position.x + Dot.size, position.y)),
        new Dot(parent, new Position(position.x, position.y + Dot.size)),
        new Dot(parent, new Position(position.x + Dot.size, position.y + Dot.size)),
      ]
    }

    Stone.prototype.getCenter = function () {
      return this.dots[2].position
    }

    function Snake (parent, position) {
      position.y -= Dot.size * 1
      Cube.call(this, parent)
      this.dots = new Array(5).fill(undefined).map((v, i) => {
        return new Dot(parent, new Position(position.x + i * Dot.size, position.y))
      })
    }

    Snake.prototype.getCenter = function () {
      const len = this.dots.length
      const half = Math.floor(len / 2)
      const center = this.dots[half]
      return center.position
    }

    function Bee (parent, position) {
      position.y -= Dot.size * 1
      Cube.call(this, parent)
      this.dots = [
        new Dot(parent, new Position(position.x, position.y)),
      ]
    }

    Bee.prototype.getCenter = function () {
      return this.dots[0].position
    }

    function Zebra (parent, position) {
      position.y -= Dot.size * 2
      Cube.call(this, parent)
      this.dots = [
        new Dot(parent, new Position(position.x, position.y)),
        new Dot(parent, new Position(position.x, position.y + Dot.size)),
        new Dot(parent, new Position(position.x + Dot.size, position.y + Dot.size)),
        new Dot(parent, new Position(position.x + Dot.size, position.y + Dot.size * 2)),
      ]
    }

    Zebra.prototype.getCenter = function () {
      return this.dots[1].position
    }

    function SeaHorse (parent, position) {
      position.y -= Dot.size * 2
      Cube.call(this, parent)
      this.dots = [
        new Dot(parent, new Position(position.x, position.y)),
      ].concat(new Array(3).fill(undefined).map((v, i) => {
        return new Dot(parent, new Position(position.x + Dot.size * i, position.y + Dot.size))
      }))
    }

    SeaHorse.prototype.getCenter = function () {
      return this.dots[1].position
    }

    function Tank (parent, position) {
      position.y -= Dot.size * 2
      Cube.call(this, parent)
      this.dots = [
        new Dot(parent, new Position(position.x, position.y)),
        new Dot(parent, new Position(position.x, position.y + Dot.size)),
        new Dot(parent, new Position(position.x - Dot.size, position.y + Dot.size)),
        new Dot(parent, new Position(position.x + Dot.size, position.y + Dot.size)),
      ]
    }

    Tank.prototype.getCenter = function () {
      return this.dots[1].position
    }

    // mock cube
    function Human (parent, position) {
      position.y -= Dot.size * 3
      Cube.call(this, parent)
      this.dots = [
        ...new Array(parent.row).fill(undefined).map((v, i) => {
          return new Dot(parent, new Position(i * Dot.size, position.y))
        }),
        new Dot(parent, new Position(position.x, position.y + 1 * Dot.size)),
        ...new Array(parent.row).fill(undefined).map((v, i) => {
          return new Dot(parent, new Position(i * Dot.size, position.y + Dot.size * 2))
        }),
        new Dot(parent, new Position(position.x + Dot.size * 3, position.y + 3 * Dot.size)),
        ...new Array(parent.row).fill(undefined).map((v, i) => {
          return new Dot(parent, new Position(i * Dot.size, position.y + Dot.size * 4))
        })
      ]
    }

    Human.prototype.getCenter = function () {
      return this.dots[10].position
    }

    // 批量组合动态继承
    function extend (targetCtor, ...sourceCtors) {
      for (var i = 0; i < sourceCtors.length; i++) {
        const sourceCtor = sourceCtors[i];
        const oldPrototype = sourceCtor.prototype
        sourceCtor.prototype = Object.assign(Object.create(targetCtor.prototype), oldPrototype)
        sourceCtor.prototype.constructor = sourceCtor
      }
      targetCtor.random = function (parent) {
        const index = Math.floor(Math.random() * sourceCtors.length)
        const position = new Position(Math.round(parent.row / 2) * Dot.size, 0)
        const Ctor = sourceCtors[index]
        const cube = new Ctor(parent, position)
        return cube
      }
    }
    extend(Cube, Stone, Snake, Bee, Zebra, SeaHorse, Tank)
  </script>
  <script>
    function Playground (row = 20, col = 10) {
      const width = row * Dot.size
      const height = col * Dot.size
      this.col = col
      this.row = row
      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(col)
        .fill(false)
        .map(() => new Array(row))
      this.blocks = blocks
    }
  </script>
  <script id="game">
    const GameState = {
      failure: -1,
      init: 0,
      running: 1,
      pause: 2,
      stop: 3
    }

    function Game () {
      this.score = 0
      this.cube = null
      this.startTime = null
      this.previousTimeStamp = null
      this.playground = null
      this.state = null
      this.normalInterval = 300
      this.interval = this.normalInterval
      this.lastSetDirectionStamp = Date.now()
      // 连续两次间隔时间
      this.speedupInterval = 500
      // 加速的时间量
      this.speedupIntervalDelta = 100
      // 加速持续时间
      this.speedupIntervalDuration = 2000
      this.cubeCount = 0
      this.keydownHandler = null
      this.animationFrameRes = null
    }

    Game.prototype.init = function () {
      this.setState(GameState.init)
      if (isDev) {
        this.playground = new Playground()
      } else {
        this.playground = new Playground(19, 28)
      }

      this.initHandler()

      if (isDev) {
        document.getElementById('controller').style.display = 'none'
      } else {
        document.getElementById('controller-btns').style.display = 'none'
      }

      return this
    }

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

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

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

    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)
      this.cube === null && this.makeCube()
      this.animationFrameRes = window.requestAnimationFrame(this.run.bind(this))
      this.playground.el.focus()
    }

    Game.prototype.clearDots = function () {
      const blocks = this.playground.blocks
      // 检测是否存在可以消除的 cube
      let count = 0
      for (let i = blocks.length - 1; i >= 0; i--) {
        const row = blocks[i]
        let flag = true
        for (let j = 0; j < row.length; j++) {
          if (!row[j]) {
            flag = false
            break
          }
        }

        if (flag) {
          count++
          for (let j = 0; j < row.length; j++) {
            // 肉体上消灭,移除 dom
            row[j].suicide()
            // 精神上消灭, 上面行替代下面行
            blocks[i][j] = null
          }
          continue
        }

        // 向下移动 count 步
        for (let j = 0; j < row.length; j++) {
          const dot = row[j]
          if (!dot || !count) continue
          const [x, y] = dot.indexArray()
          dot.setPosition(dot.position.x, dot.position.y + Dot.size * count).move()
          blocks[y][x] = null
          // forward $count step
          blocks[y + count][x] = dot
        }
      }

      this.score += count * 10
    }

    Game.prototype.makeCube = function () {
      const cube = Cube.random(this.playground)
      this.cube = cube
      return cube
    }

    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;
        let cubeState = CubeState.unstable
        if (this.cube) {
          cubeState = this.cube.tryMove()
        }
        if (cubeState === CubeState.stable) {
          this.cube.mark(true)
          // 检测是否有方块到顶了
          const len = this.cube.dots.length
          for (let i = 0; i < len; i++) {
            const dot = this.cube.dots[i]
            if (dot.position.y === 0) {
              game.over()
              break;
            }
          }
          if (this.state === GameState.running) {
            this.clearDots()
            this.makeCube()
          }
        }
      }
      // 更新面板数据
      this.updatePanel()
      if (this.state === GameState.running) {
        window.requestAnimationFrame(this.run.bind(this));
      }
    }

    Game.prototype.over = function () {
      this.setState(GameState.failure)
      window.alert('game over')
    }

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

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

    Game.prototype.destroy = function () {
      this.state = GameState.stop
      // 解除事件
      this.playground.el.removeEventListener('keydown', this.keydownHandler)
      // 删除 dom
      this.playground.el.querySelectorAll('.dot').forEach(dot => dot.remove())
      // 数据清空
      this.animationFrameRes && window.cancelAnimationFrame(this.animationFrameRes)
      Game.apply(this)
      this.init()
      this.updatePanel()
    }

    Game.prototype.setDirection = function (direction) {
      if (direction == Direction.up) {
        this.cube.rotate()
        return
      }
      this.cube.tryMove(direction)
    }

    Game.prototype.updatePanel = function () {
      const panel = document.forms.panel
      panel.gameState.value = this.state
      panel.score.value = this.score
    }

    const game = new Game().init()
  </script>
  <script id="controller">
    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>